diff --git a/impit-node/package.json b/impit-node/package.json index 15b7118d..d29316e1 100644 --- a/impit-node/package.json +++ b/impit-node/package.json @@ -46,7 +46,7 @@ "build:debug": "napi build --platform --no-const-enum", "docs": "npm run build:debug && typedoc --plugin typedoc-plugin-mdn-links ./index.d.ts --out ./docs", "prepublishOnly": "napi prepublish -t npm --no-gh-release", - "test": "vitest --retry=3", + "test": "vitest --retry=3 && node ./test/e2e/run-e2e-tests.mjs", "universal": "napi universal", "copy-version": "napi version" }, diff --git a/impit-node/test/e2e/basic.e2e.mjs b/impit-node/test/e2e/basic.e2e.mjs new file mode 100644 index 00000000..21c9b6ff --- /dev/null +++ b/impit-node/test/e2e/basic.e2e.mjs @@ -0,0 +1,15 @@ +import { Impit } from "../../index.wrapper.js"; +import assert from "node:assert"; + +const impit = new Impit({ + browser: "chrome", + ignoreTlsErrors: true, +}); + +const response = await impit.fetch("https://api.apify.com/v2/browser-info"); + +assert.equal(response.status, 200, "Response status should be 200"); +assert.ok(response.headers.get("content-type")?.includes("application/json"), "Response should be JSON"); +assert.equal(await response.json().then(data => data.headers['accept-encoding']), "gzip, deflate, br, zstd", "Accept-Encoding header should be correct"); + +console.log(`[${import.meta.filename.split('/').pop()}] All assertions passed.`); diff --git a/impit-node/test/e2e/run-e2e-tests.mjs b/impit-node/test/e2e/run-e2e-tests.mjs new file mode 100644 index 00000000..18ca08a6 --- /dev/null +++ b/impit-node/test/e2e/run-e2e-tests.mjs @@ -0,0 +1,102 @@ +import { readdir } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const exts = new Set(['.mts', '.mjs', '.js', '.cts', '.cjs', '.ts']); + +const selfPath = fileURLToPath(import.meta.url); +const selfDir = path.dirname(selfPath); +const selfBase = path.basename(selfPath); + +const toAbs = (p) => (path.isAbsolute(p) ? p : path.resolve(process.cwd(), p)); + +function buildSpawnArgsFor(filePath) { + const nodeCmd = process.argv[0]; // path to node + const resolvedArgv = process.argv.map(toAbs); + const selfIdx = resolvedArgv.findIndex((a) => a === selfPath); + + if (selfIdx > -1) { + // Replicate the original invocation "prefix" (e.g. tsx/ts-node loaders etc.) + const prefix = process.argv.slice(1, selfIdx); // keep as-is (can include non-absolute args) + return { cmd: nodeCmd, args: [...prefix, filePath] }; + } + + // Fallback: use execArgv (e.g. --loader ts-node/esm, --experimental-loader, etc.) + return { cmd: nodeCmd, args: [...process.execArgv, filePath] }; +} + +async function runOne(filePath) { + const { cmd, args } = buildSpawnArgsFor(filePath); + await new Promise((resolve, reject) => { + const cp = spawn(cmd, args, { stdio: 'inherit', cwd: process.cwd(), env: process.env }); + + let settled = false; + const timeoutMs = 30_000; + + const done = (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) { + reject(err); + } else { + resolve(); + } + }; + + const timer = setTimeout(() => { + try { + cp.kill('SIGKILL'); + } catch { + // ignore + } + const err = new Error(`Subprocess timed out after ${timeoutMs} ms`); + console.error(err); + err.code = 'ETIMEDOUT'; + done(err); + }, timeoutMs); + + cp.on('error', (err) => done(err)); + cp.on('exit', (code, signal) => { + if (signal) { + done(new Error(`Subprocess terminated by signal ${signal}`)); + } else if (code && code !== 0) { + const err = new Error(`Subprocess exited with code ${code}`); + // @ts-expect-error attach code for upper-level handling + err.code = code; + done(err); + } else { + done(); + } + }); + }); +} + +const entries = await readdir(selfDir, { withFileTypes: true }); + +const files = entries + .filter((ent) => ent.isFile()) + .map((ent) => ent.name) + .filter((name) => name !== selfBase) + .filter((name) => exts.has(path.extname(name))) + .sort((a, b) => a.localeCompare(b)); + +for (let i = 0; i < files.length; i++) { + const file = files[i]; + const abs = path.join(selfDir, file); + try { + await runOne(abs); + console.log(`[e2e] (${i + 1}/${files.length}) Passed ${file}`); + } catch (err) { + console.error(`[e2e] Failed: ${file}`); + console.error(err); + if (err && typeof err.code === 'number') { + process.exit(err.code); + } else { + process.exit(1); + } + } +} + +console.log('[e2e] All done.'); diff --git a/impit-python/python/impit/impit.pyi b/impit-python/python/impit/impit.pyi index d3a09603..34b4f3c5 100644 --- a/impit-python/python/impit/impit.pyi +++ b/impit-python/python/impit/impit.pyi @@ -370,10 +370,10 @@ class Response: class Client: """Synchronous HTTP client with browser impersonation capabilities. - .. note:: - You can reuse the :class:`Client` instance to make multiple requests. + .. note:: + You can reuse the :class:`Client` instance to make multiple requests. - All requests made by the same client will share the same configuration, resources (e.g., cookie jar and connection pool), and other settings. + All requests made by the same client will share the same configuration, resources (e.g., cookie jar and connection pool), and other settings. Args: browser: Browser to impersonate (`"chrome"` or `"firefox"`). @@ -701,10 +701,10 @@ class Client: class AsyncClient: """Asynchronous HTTP client with browser impersonation capabilities. - .. note:: - You can reuse the :class:`Client` instance to make multiple requests. + .. note:: + You can reuse the :class:`Client` instance to make multiple requests. - All requests made by the same client will share the same configuration, resources (e.g., cookie jar and connection pool), and other settings. + All requests made by the same client will share the same configuration, resources (e.g., cookie jar and connection pool), and other settings. Args: browser: Browser to impersonate (`"chrome"` or `"firefox"`).