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'] }, + }, +});