Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/verify-dev-warmup.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions src/commands/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand Down Expand Up @@ -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.
Expand Down
34 changes: 32 additions & 2 deletions src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,9 +799,39 @@ export async function runVerify(args: string[] = []): Promise<void> {

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;
Expand Down
34 changes: 24 additions & 10 deletions verify/docker-compose.verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions verify/svelte-app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
},
});
9 changes: 8 additions & 1 deletion verify/vue-app/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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'] },
},
});
Loading