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` 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(), ), },