From fca70aee2c543430d7fa03564b2a218037139642 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 9 Oct 2026 09:42:21 -0400 Subject: [PATCH] fix(verify): wait for dev servers to compile before running browser specs The svelte-dev layer failed its first spec on run 37880143781 while the other six passed: the first navigation to /login timed out after 10s. A Vite dev server answers / and /health before it has compiled anything, so the first page load paid for the cold transform of the whole client graph (about 90 modules for SvelteKit, whose client runtime is served as source). Reproduced locally with the container held to 0.1 CPU. - vue-app and svelte-app warm their client modules at startup through server.warmup.clientFiles. - The vue-dev and svelte-dev healthchecks wait for a compiled module and then the prebundled SDK, as angular-dev waits for its compiled bundle. - seamless verify now brings each browser service up with --wait. Before, nothing waited on the compose healthchecks; the harness only probed /health. A service that never gets healthy fails its own layer, with its logs, and the run continues. - The nginx services' healthchecks probe 127.0.0.1. busybox wget resolves localhost to ::1 and nginx listens on IPv4 only, so they never passed, which went unnoticed while nothing waited on them. --- .changeset/verify-dev-warmup.md | 5 +++++ src/commands/verify.test.ts | 31 +++++++++++++++++++++++++++++ src/commands/verify.ts | 34 ++++++++++++++++++++++++++++++-- verify/docker-compose.verify.yml | 34 ++++++++++++++++++++++---------- verify/svelte-app/vite.config.ts | 13 ++++++++++++ verify/vue-app/vite.config.ts | 9 ++++++++- 6 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 .changeset/verify-dev-warmup.md diff --git a/.changeset/verify-dev-warmup.md b/.changeset/verify-dev-warmup.md new file mode 100644 index 0000000..24fbbf3 --- /dev/null +++ b/.changeset/verify-dev-warmup.md @@ -0,0 +1,5 @@ +--- +'seamless-cli': patch +--- + +`seamless verify` now waits for each browser service's healthcheck before running its specs. The `vue-dev` and `svelte-dev` services compile their app at startup and report healthy only once a compiled module and the prebundled SDK are served, so a cold Vite dev server no longer makes the first browser spec time out on a slow runner. The production web services' healthchecks now probe 127.0.0.1, since nginx listens on IPv4 only and they never passed on `localhost`. A service that never becomes healthy fails its own layer, with its recent logs, and the run continues. diff --git a/src/commands/verify.test.ts b/src/commands/verify.test.ts index 9ba8648..faadd75 100644 --- a/src/commands/verify.test.ts +++ b/src/commands/verify.test.ts @@ -321,6 +321,7 @@ describe("runVerify — flag parsing", () => { const tails = dockerTails(); expect(tails).toContainEqual(["--profile", "react-dev", "up", "-d", "--build", "react-dev"]); + expect(tails).toContainEqual(["--profile", "react-dev", "up", "-d", "--wait", "react-dev"]); expect(tails).toContainEqual(["--profile", "react-dev", "rm", "-sf", "react-dev"]); expect(tails).toContainEqual(["--profile", "nextjs-dev", "up", "-d", "--build", "nextjs-dev"]); @@ -727,6 +728,36 @@ describe("runVerify — conformance failures (caught, exits 1)", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + it("fails only the layer whose service never gets healthy, and dumps its logs", async () => { + vi.mocked(runCommand).mockImplementation((cmd: string, args: string[]) => { + if (cmd === "docker" && args.includes("--wait") && args.includes("svelte-dev")) { + return Promise.reject(new Error("unhealthy")); + } + return Promise.resolve(); + }); + + await runVerify(["--dev"]); + expect(exitSpy).toHaveBeenCalledWith(1); + + const projects = callsFor("npm") + .filter((a) => a[0] === "test") + .map((t) => t[t.indexOf("--project") + 1]); + // The unhealthy pass runs no specs; the passes after it still run. + expect(projects).not.toContain("svelte-dev"); + expect(projects).toContain("nextjs-dev"); + expect(dockerTails()).toContainEqual([ + "--profile", + "svelte-dev", + "logs", + "--tail", + "80", + "svelte-dev", + ]); + const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(printed).toMatch(/svelte-dev did not become healthy/); + expect(printed).not.toMatch(/Verify aborted/); + }); + it("reports a setup error and prints no layers when the stack fails to build", async () => { vi.mocked(runCommand).mockImplementation((cmd: string, args: string[]) => { // The initial clean uses `down`; the build uses `up` and is not swallowed. diff --git a/src/commands/verify.ts b/src/commands/verify.ts index 6c0c6a2..29fbd55 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -799,9 +799,39 @@ export async function runVerify(args: string[] = []): Promise { for (const pass of passes) { await compose(reactEnv, "--profile", pass.service, "up", "-d", "--build", pass.service); + // Hold the specs until the service's healthcheck passes. The harness's own + // /health probe is not enough on a dev server, which answers it before the + // app is compiled; the dev healthchecks wait for that. A service that never + // gets healthy fails its layer, with its logs, and the run moves on. + const healthy = await compose( + reactEnv, + "--profile", + pass.service, + "up", + "-d", + "--wait", + pass.service, + ).then( + () => true, + () => false, + ); + if (!healthy) { + console.log(kleur.red(`\nāœ– ${pass.service} did not become healthy. Recent logs:\n`)); + await compose( + reactEnv, + "--profile", + pass.service, + "logs", + "--tail", + "80", + pass.service, + ).catch(() => undefined); + } if ( - !(await runLayer(results, pass.label, () => - runProjects(reactEnv, [pass.project], grep), + !(await runLayer( + results, + pass.label, + async () => healthy && runProjects(reactEnv, [pass.project], grep), )) ) { failed = true; diff --git a/verify/docker-compose.verify.yml b/verify/docker-compose.verify.yml index 5072bc4..d517e15 100644 --- a/verify/docker-compose.verify.yml +++ b/verify/docker-compose.verify.yml @@ -199,8 +199,11 @@ services: depends_on: adapter: condition: service_healthy + # 127.0.0.1, not localhost: busybox wget resolves localhost to ::1, and nginx + # listens on IPv4 only. The same holds for the angular, vue, and svelte + # services below. healthcheck: - test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost/health'] + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1/health'] interval: 3s timeout: 3s retries: 20 @@ -268,7 +271,7 @@ services: adapter: condition: service_healthy healthcheck: - test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost/health'] + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1/health'] interval: 3s timeout: 3s retries: 20 @@ -316,13 +319,19 @@ services: adapter: condition: service_healthy healthcheck: - test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost/health'] + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1/health'] interval: 3s timeout: 3s retries: 20 # The same app on `vite` (the Dockerfile's `dev` target). It reads the API - # origin from public/config.js, which already points at localhost:3000. + # origin from public/config.js, which already points at localhost:3000. Vite + # answers `/` with the HTML shell before it has compiled anything, so the first + # navigation would otherwise race the initial transform and dependency + # optimization. vite.config.ts warms the app at startup, and the healthcheck + # waits for the compiled entry and then the prebundled SDK, in that order: + # compiling the entry waits for the dependency scan, and asking for a prebundled + # file before the scan has registered it logs a spurious missing-file error. vue-dev: profiles: ['vue-dev'] build: @@ -336,10 +345,10 @@ services: adapter: condition: service_healthy healthcheck: - test: ['CMD', 'node', '-e', "fetch('http://localhost:5173/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + test: ['CMD', 'node', '-e', "(async()=>{for(const p of ['/','/src/main.ts','/node_modules/.vite/deps/@seamless-auth_vue.js'])if(!(await fetch('http://localhost:5173'+p)).ok)process.exit(1)})().then(()=>process.exit(0),()=>process.exit(1))"] interval: 3s timeout: 3s - retries: 40 + retries: 60 # A SvelteKit app on @seamless-auth/svelte, driven by the same browser specs as # the React template. Built like the Vue app, with the client and Svelte @@ -359,13 +368,18 @@ services: adapter: condition: service_healthy healthcheck: - test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost/health'] + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1/health'] interval: 3s timeout: 3s retries: 20 # The same app on `vite dev` (the Dockerfile's `dev` target). It reads the API - # origin from static/config.js, which already points at localhost:3000. + # origin from static/config.js, which already points at localhost:3000. As with + # vue-dev, `/` (and /health, a server route) answer before the client is + # compiled, and SvelteKit's client runtime is served as source, so a cold first + # navigation is many module transforms. vite.config.ts warms them at startup, + # and the healthcheck waits for a compiled route and then the prebundled SDK, + # in order, for the reason given at vue-dev. svelte-dev: profiles: ['svelte-dev'] build: @@ -379,10 +393,10 @@ services: adapter: condition: service_healthy healthcheck: - test: ['CMD', 'node', '-e', "fetch('http://localhost:5173/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + test: ['CMD', 'node', '-e', "(async()=>{for(const p of ['/','/src/routes/login/+page.svelte','/node_modules/.vite/deps/@seamless-auth_svelte.js'])if(!(await fetch('http://localhost:5173'+p)).ok)process.exit(1)})().then(()=>process.exit(0),()=>process.exit(1))"] interval: 3s timeout: 3s - retries: 40 + retries: 60 # A full-stack template (the Next.js starter) built with its own Dockerfile's # `runtime` target: the standalone production server, on port 80 with /health. diff --git a/verify/svelte-app/vite.config.ts b/verify/svelte-app/vite.config.ts index 16c70be..28a710f 100644 --- a/verify/svelte-app/vite.config.ts +++ b/verify/svelte-app/vite.config.ts @@ -10,4 +10,17 @@ export default defineConfig({ adapter: adapter({ fallback: 'index.html' }), }), ], + server: { + // Compile the app at startup rather than on the first page load. Routes are + // loaded lazily, so each one is listed, and the kit client runtime is served + // as source, not prebundled, so its entry is named to warm the modules it + // imports. Globs skip node_modules, which is why that path is spelled out. + warmup: { + clientFiles: [ + './src/**/*.svelte', + './src/**/*.ts', + './node_modules/@sveltejs/kit/src/runtime/client/entry.js', + ], + }, + }, }); diff --git a/verify/vue-app/vite.config.ts b/verify/vue-app/vite.config.ts index 8677fd0..721a281 100644 --- a/verify/vue-app/vite.config.ts +++ b/verify/vue-app/vite.config.ts @@ -1,4 +1,11 @@ import vue from '@vitejs/plugin-vue'; import { defineConfig } from 'vite'; -export default defineConfig({ plugins: [vue()] }); +export default defineConfig({ + plugins: [vue()], + server: { + // Compile the app at startup rather than on the first page load. The entry + // imports every screen, so warming it covers the whole client graph. + warmup: { clientFiles: ['./src/main.ts'] }, + }, +});