diff --git a/.github/scripts/playwright-cdp-redirect.mjs b/.github/scripts/playwright-cdp-redirect.mjs new file mode 100644 index 000000000..7e3e76edb --- /dev/null +++ b/.github/scripts/playwright-cdp-redirect.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer as createHttpServer } from "node:http"; +import { createServer as createTcpServer } from "node:net"; +import { once } from "node:events"; + +import { chromium } from "playwright-core"; + +const binary = process.argv[2]; +if (!binary) throw new Error("usage: node playwright-cdp-redirect.mjs "); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(server.address().port); + }); + }); +} + +async function reservePort() { + const server = createTcpServer(); + const port = await listen(server); + await new Promise((resolve) => server.close(resolve)); + return port; +} + +function closeServer(server) { + return new Promise((resolve) => server.close(resolve)); +} + +async function waitForLightpanda(child, port) { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Lightpanda startup timeout")), 5_000); + const onData = (chunk) => { + const line = chunk.toString(); + if (!line.includes("server running") || !line.includes(`127.0.0.1:${port}`)) return; + clearTimeout(timeout); + child.off("error", onError); + child.off("exit", onExit); + resolve(); + }; + const onError = (error) => { + clearTimeout(timeout); + reject(error); + }; + const onExit = (code) => { + clearTimeout(timeout); + reject(new Error(`Lightpanda exited during startup (${code})`)); + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.once("error", onError); + child.once("exit", onExit); + }); +} + +const destinationRequests = []; +const destination = createHttpServer((request, response) => { + destinationRequests.push(request.headers); + response.end("ok"); +}); +const destinationPort = await listen(destination); +const destinationUrl = `http://127.0.0.1:${destinationPort}/destination`; + +const initialRequests = []; +const initial = createHttpServer((request, response) => { + initialRequests.push(request.headers); + response.writeHead(302, { location: destinationUrl }); + response.end(); +}); +const initialPort = await listen(initial); +const initialUrl = `http://127.0.0.1:${initialPort}/start`; + +const cdpPort = await reservePort(); +const child = spawn(binary, [ + "serve", + "--host", + "127.0.0.1", + "--port", + String(cdpPort), + "--log-level", + "info", +], { + env: { ...process.env, LIGHTPANDA_DISABLE_TELEMETRY: "true" }, +}); + +let browser; +try { + await waitForLightpanda(child, cdpPort); + browser = await chromium.connectOverCDP(`ws://127.0.0.1:${cdpPort}`); + const context = await browser.newContext(); + const page = await context.newPage(); + const session = await context.newCDPSession(page); + const pausedUrls = []; + const continuationErrors = []; + + session.on("Fetch.requestPaused", async (event) => { + if (event.request.url !== initialUrl && event.request.url !== destinationUrl) return; + pausedUrls.push(event.request.url); + const params = { requestId: event.requestId }; + if (event.request.url === initialUrl) { + params.headers = Object.entries({ + ...event.request.headers, + "x-single-hop-probe": "initial", + }).map(([name, value]) => ({ name, value: String(value) })); + } + try { + await session.send("Fetch.continueRequest", params); + } catch (error) { + continuationErrors.push(error); + } + }); + + await session.send("Fetch.enable", { + patterns: [{ urlPattern: "*", requestStage: "Request" }], + }); + const response = await page.goto(initialUrl, { + waitUntil: "domcontentloaded", + timeout: 6_000, + }); + + assert.equal(response.status(), 200); + assert.deepEqual(pausedUrls, [initialUrl, destinationUrl]); + assert.equal(continuationErrors.length, 0); + assert.equal(initialRequests.length, 1); + assert.equal(initialRequests[0]["x-single-hop-probe"], "initial"); + assert.equal(destinationRequests.length, 1); + assert.equal(destinationRequests[0]["x-single-hop-probe"], undefined); + + console.log("Playwright auxiliary CDP redirect interception passed"); +} finally { + await browser?.close().catch(() => {}); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + await Promise.race([once(child, "exit"), new Promise((resolve) => setTimeout(resolve, 2_000))]); + } + await Promise.all([closeServer(initial), closeServer(destination)]); +} diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 246e760d4..99f68a23d 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -189,6 +189,29 @@ jobs: kill `cat LPD.pid` while kill -0 `cat LPD.pid` 2>/dev/null; do sleep 1; done + playwright-cdp-session: + name: playwright-cdp-session + needs: + - zig-build-release + + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - run: npm install --no-save --no-package-lock playwright-core@1.58.2 + + - name: download lightpanda release + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: lightpanda-build-release + + - run: chmod a+x ./lightpanda + + - name: run auxiliary CDP redirect interception + run: node .github/scripts/playwright-cdp-redirect.mjs ./lightpanda + wba-test: name: wba-test needs: zig-build-release