From 53698b6cf90bebdee0b6f65849ca552fd10f1423 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 16:59:11 -0800 Subject: [PATCH 1/2] fix(scripts): scenarios bind the web app to the address e2e opens The web dev server bound `localhost`, which is ::1 alone on machines that resolve the name to IPv6 first, while e2e waited on 127.0.0.1 until its startup timeout. The start script now runs the server and the web dev server separately and passes the web one the host e2e uses, and says so at once if it still binds elsewhere. Co-Authored-By: Claude Fable 5.2 --- scripts/e2e.config.ts | 11 +++++--- scripts/scenarios/start-app.ts | 49 +++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/scripts/e2e.config.ts b/scripts/e2e.config.ts index 6db320e4aa728..520fc8338da71 100644 --- a/scripts/e2e.config.ts +++ b/scripts/e2e.config.ts @@ -46,6 +46,11 @@ export const T3_HOME = resolved.home; export const MOBILE_T3_HOME = `${T3_HOME}-mobile`; export const MOBILE = resolved.mobile; const startApp = resolved.startApp; +/** + * The one address e2e opens the web app at and the app is told to bind. Never `localhost`: the + * runner and the app can resolve that name to different addresses. + */ +const LOOPBACK = "127.0.0.1"; /** * Leases a device from the Simlock pool, whose gateway speaks agent-device's lease protocol. @@ -131,12 +136,12 @@ export default { name: "web", engine: web({ viewport: { width: 1280, height: 800 } }), app: { - url: "http://127.0.0.1:0", + url: `http://${LOOPBACK}:0`, // Vite answers before the server does; this path is proxied to the server. - readyUrl: "http://127.0.0.1:{port}/.well-known/t3/environment", + readyUrl: `http://${LOOPBACK}:{port}/.well-known/t3/environment`, command: { executable: resolved.node, - args: [startApp, "web", T3_HOME, "{port}"], + args: [startApp, "web", T3_HOME, LOOPBACK, "{port}"], cwd: APP_DIR, startupTimeout: 300_000, log: ".e2e/app.log", diff --git a/scripts/scenarios/start-app.ts b/scripts/scenarios/start-app.ts index 5eabe224b9778..5dee3f26c138c 100644 --- a/scripts/scenarios/start-app.ts +++ b/scripts/scenarios/start-app.ts @@ -141,17 +141,21 @@ const startApp = Effect.fn("startApp")(function* (input: { ]; if (input.surface === "web") { - const [webPort = ""] = input.ports; - const serverPort = yield* net.reserveLoopbackPort(); + // e2e opens the app at this host, so the web server is told to bind exactly it. Left to its + // default, `localhost`, it binds ::1 alone on machines that resolve the name to IPv6 first. + const [host = "", webPort = ""] = input.ports; + const origin = `http://${host}:${webPort}`; + const serverPort = String(yield* net.reserveLoopbackPort()); const token = yield* (yield* SecureRandom.SecureRandom).uuidV4(); - const runner = yield* start({ + const ports = ["--port", serverPort, "--dev-url", origin]; + const webEnv = { ...env, T3CODE_PORT_OFFSET: String(Number(webPort) - BASE_WEB_PORT) }; + const server = yield* start({ cwd: checkout, command: node, - args: devRunner("dev", ["--port", String(serverPort)]), + args: devRunner("dev:server", ports), env: { - ...env, - T3CODE_PORT_OFFSET: String(Number(webPort) - BASE_WEB_PORT), - T3CODE_DEV_ALLOWED_ORIGINS: `http://127.0.0.1:${webPort}`, + ...webEnv, + T3CODE_DEV_ALLOWED_ORIGINS: origin, // Makes the startup pairing link reusable, so every test can sign in with it. T3CODE_DEV_AUTH_TOKEN: `${token}${token}`.replaceAll("-", ""), }, @@ -165,7 +169,32 @@ const startApp = Effect.fn("startApp")(function* (input: { .pipe(Effect.ignore); }, }); - return yield* runner.exitCode; + const web = yield* start({ + cwd: checkout, + command: node, + args: devRunner("dev:web", [...ports, "--", "--host", host]), + env: webEnv, + onLine: Console.log, + }); + // A web server that came up on another loopback address would leave e2e waiting out its + // whole startup timeout; say so at once instead. + const boundElsewhere = Effect.gen(function* () { + const elsewhere = host === "::1" ? "127.0.0.1" : "::1"; + if (yield* net.hasListenerOnHost(Number(webPort), host)) return yield* Effect.never; + if (!(yield* net.hasListenerOnHost(Number(webPort), elsewhere))) { + return yield* new ScenarioError({ message: "The web server is not listening yet." }); + } + yield* Console.error( + `The web server listens on ${elsewhere}:${webPort}, not on ${host}: it ignored --host.`, + ); + return 1; + }).pipe( + Effect.retry({ + schedule: Schedule.spaced("1 second"), + while: (error) => error._tag === "ScenarioError", + }), + ); + return yield* Effect.raceAll([server.exitCode, web.exitCode, boundElsewhere]); } // The device is another machine: the server listens on every interface, and Metro serves the @@ -212,7 +241,9 @@ const command = Command.make( ), home: Argument.String("t3-home").pipe(Argument.withDescription("T3 home to recreate.")), ports: Argument.String("port").pipe( - Argument.withDescription("web: . mobile: ."), + Argument.withDescription( + "web: . mobile: .", + ), Argument.variadic(), ), }, From 5379f98d7a0860a711917b363c2316599968db42 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 17:07:12 -0800 Subject: [PATCH 2/2] docs: scenarios name their loopback address and the browser pre-install command Co-Authored-By: Claude Fable 5.2 --- docs/operations/scenarios.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/operations/scenarios.md b/docs/operations/scenarios.md index c7a753f8ae2b5..c49f23a4b2068 100644 --- a/docs/operations/scenarios.md +++ b/docs/operations/scenarios.md @@ -70,7 +70,19 @@ cd scripts && pnpm exec e2e run scenarios/replay-turn-streams.e2e.ts --headed e2e starts the dev stack through [`start-app.ts`](../../scripts/scenarios/start-app.ts) on a free port, runs the tests, and stops it. Results land in `scripts/.e2e`: `report.json`, `summary.md`, -and per test a `trace.md`, its screenshots, and a video. The first run downloads e2e's browser. +and per test a `trace.md`, its screenshots, and a video. `vp run scenarios --target web` runs one +target; flags go straight after the script name, without `--`. + +The first run downloads e2e's browser (about 120 MB). To fetch it ahead of time, such as in a +machine image, run this from the repository root after `vp i`: + +```sh +node scripts/node_modules/playwright-core/cli.js install --only-shell chromium ffmpeg +``` + +e2e opens the web app at `127.0.0.1`, and the start script tells the web dev server to bind that +address. It does not use `localhost`, which some machines resolve to `::1` first. + `e2e run --help` lists the rest: `--grep`, `--watch`, `--last-failed`, `--changed-since`. Run one at a time per checkout: a run recreates the checkout's scenario T3 home and its `.e2e`