diff --git a/.changeset/verify-fastify-adapter.md b/.changeset/verify-fastify-adapter.md new file mode 100644 index 0000000..c0aba3a --- /dev/null +++ b/.changeset/verify-fastify-adapter.md @@ -0,0 +1,17 @@ +--- +"seamless-cli": minor +--- + +`seamless verify` now exercises the Fastify starter. The scaffold has offered a Fastify API since the +templates bump, but the conformance harness only ever drove the Express adapter, so a green run said +nothing about whether a Fastify-scaffolded project actually worked. + +The stack gains a second adopter backend (`verify/adapter-fastify-app`, on port 3001) built on +`@seamless-auth/fastify`, a twin of the Express one: same routes, same env contract, same capture +transport. The existing adapter specs run against both without being duplicated, since the two +Playwright projects share a test directory and differ only in which backend they point at. The +conformance grid gains an `adapter-fastify` column, so a failure in one framework is attributable to +that framework. + +`--api-only` and `--no-react` are unchanged, and `--local` builds and packs `@seamless-auth/fastify` +from source alongside core and express. diff --git a/AGENTS.md b/AGENTS.md index 576449c..ead6b07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,13 +126,21 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m prints a flow x layer pass/fail grid (plus JUnit and HTML reports). - [verify/docker-compose.verify.yml](verify/docker-compose.verify.yml): postgres, the auth API, and - the adapter, plus the React starter behind the `react` compose profile. The mock OIDC provider runs - in-process in `global-setup` (it is not a container). -- [verify/adapter-app](verify/adapter-app): a minimal `@seamless-auth/express` adopter backend with a - capture transport, so the harness can read OTP / magic-link codes the adapter would otherwise strip. -- [verify/harness](verify/harness): the Playwright projects (`api`, `adapter`, `react`), `lib/` - helpers, `mock-oidc.ts`, `global-setup.ts`, and `lib/matrixReporter.ts` (the printed grid). It has - its own `node_modules` and browsers. + both adapters, plus the React starter behind the `react` compose profile. The mock OIDC provider + runs in-process in `global-setup` (it is not a container). +- [verify/adapter-app](verify/adapter-app) (port 3000) and + [verify/adapter-fastify-app](verify/adapter-fastify-app) (port 3001): minimal adopter backends on + `@seamless-auth/express` and `@seamless-auth/fastify`, each with a capture transport so the harness + can read OTP / magic-link codes the adapter would otherwise strip. They are deliberately twins: the + same routes on the same env contract, so a spec cannot tell which one answered and any difference + in behaviour is a real one. Keep them in step when either changes. +- [verify/harness](verify/harness): the Playwright projects (`api`, `adapter`, `adapter-fastify`, + `react`), `lib/` helpers, `mock-oidc.ts`, `global-setup.ts`, and `lib/matrixReporter.ts` (the + printed grid). It has its own `node_modules` and browsers. + - The two adapter projects run the *same* specs from `./adapter`; only the `adapterUrl` project + option differs (`lib/fixtures.ts`). Adding an adopter framework is a project entry plus a compose + service, never a copy of the suite. Because they share a directory, `matrixReporter` takes the + layer from the Playwright project name, not the spec's path. Modes and sibling repos: @@ -196,7 +204,8 @@ Templates are not in this repo — they live in the `seamless-templates` monorep - **Adapter OTP limiter**: the adapter funnels all OTP through one client IP, so the API's per-IP OTP limiter (10 per 15 minutes, hardcoded) bounds adapter / react OTP traffic. Keep specs off it where possible (for example, magic-link login instead of a second email-OTP round trip). -- **Version pins**: [verify/adapter-app](verify/adapter-app) pins `@seamless-auth/express` and the +- **Version pins**: [verify/adapter-app](verify/adapter-app) pins `@seamless-auth/express`, + [verify/adapter-fastify-app](verify/adapter-fastify-app) pins `@seamless-auth/fastify`, and the `react-vite` template pins `@seamless-auth/react`. Bump these when new versions publish. - **Templates ref**: the CLI scaffolds from `seamless-templates` at `SEAMLESS_TEMPLATES_REF` ([src/core/images.ts](src/core/images.ts)); bump it when a new templates release publishes. diff --git a/src/commands/verify.test.ts b/src/commands/verify.test.ts index a330afa..1e9cb78 100644 --- a/src/commands/verify.test.ts +++ b/src/commands/verify.test.ts @@ -96,12 +96,20 @@ describe("runVerify — published (default) mode", () => { it("cleans vendor, builds the base stack, and runs API + web layers", async () => { await runVerify([]); - // Stale tarballs are removed from both vendor dirs; non-tgz files are left. - expect(fs.rmSync).toHaveBeenCalledTimes(2); + // Stale tarballs are removed from every vendor dir; non-tgz files are left. + expect(fs.rmSync).toHaveBeenCalledTimes(3); const tails = dockerTails(); expect(tails).toContainEqual(["--profile", "react", "down", "-v"]); // initial clean - expect(tails).toContainEqual(["up", "-d", "--build", "postgres", "auth-api", "adapter"]); + expect(tails).toContainEqual([ + "up", + "-d", + "--build", + "postgres", + "auth-api", + "adapter", + "adapter-fastify", + ]); expect(tails).toContainEqual(["--profile", "react", "up", "-d", "--build", "react"]); expect(tails).toContainEqual(["--profile", "react", "rm", "-sf", "react"]); @@ -109,7 +117,17 @@ describe("runVerify — published (default) mode", () => { expect(callsFor("pnpm")).toHaveLength(0); const npmTests = callsFor("npm").filter((a) => a[0] === "test"); - expect(npmTests).toContainEqual(["test", "--", "--project", "api", "--project", "adapter"]); + // Both adopter frameworks run the same adapter suite. + expect(npmTests).toContainEqual([ + "test", + "--", + "--project", + "api", + "--project", + "adapter", + "--project", + "adapter-fastify", + ]); // The web template declares verify.flows ["oauth"] ⇒ Playwright grep "@oauth". expect(npmTests).toContainEqual(["test", "--", "--project", "react", "--grep", "@oauth"]); @@ -167,10 +185,27 @@ describe("runVerify — flag parsing", () => { await runVerify(["--no-react"]); const tails = dockerTails(); - expect(tails).toContainEqual(["up", "-d", "--build", "postgres", "auth-api", "adapter"]); + expect(tails).toContainEqual([ + "up", + "-d", + "--build", + "postgres", + "auth-api", + "adapter", + "adapter-fastify", + ]); expect(callsFor("npx")).toHaveLength(0); const npmTests = callsFor("npm").filter((a) => a[0] === "test"); - expect(npmTests).toContainEqual(["test", "--", "--project", "api", "--project", "adapter"]); + expect(npmTests).toContainEqual([ + "test", + "--", + "--project", + "api", + "--project", + "adapter", + "--project", + "adapter-fastify", + ]); // No react project test runs. expect(npmTests.some((t) => t.includes("react"))).toBe(false); }); @@ -195,6 +230,8 @@ describe("runVerify — flag parsing", () => { "api", "--project", "adapter", + "--project", + "adapter-fastify", "--grep", "@login", ]); @@ -216,8 +253,26 @@ describe("runVerify — local mode", () => { const pnpm = callsFor("pnpm"); expect(pnpm).toContainEqual(["--filter", "@seamless-auth/core", "build"]); expect(pnpm).toContainEqual(["--filter", "@seamless-auth/express", "build"]); + expect(pnpm).toContainEqual(["--filter", "@seamless-auth/fastify", "build"]); expect(pnpm.some((a) => a.includes("pack"))).toBe(true); + // Each adapter image installs core alongside its own framework package, so + // core is packed into both vendor dirs and neither adapter into the other's. + const packDest = (pkg: string) => + pnpm + .filter((a) => a[1] === pkg && a.includes("pack")) + .map((a) => a[a.length - 1]); + expect(packDest("@seamless-auth/core")).toEqual([ + expect.stringContaining("adapter-app"), + expect.stringContaining("adapter-fastify-app"), + ]); + expect(packDest("@seamless-auth/express")).toEqual([ + expect.stringContaining("adapter-app"), + ]); + expect(packDest("@seamless-auth/fastify")).toEqual([ + expect.stringContaining("adapter-fastify-app"), + ]); + // The react SDK is built and packed with npm. const npm = callsFor("npm"); expect(npm).toContainEqual(["run", "build"]); diff --git a/src/commands/verify.ts b/src/commands/verify.ts index b77ea20..7835a8e 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -137,6 +137,7 @@ function flowsToGrep(flows?: string[]): string | undefined { } const VENDOR_DIR = path.join(VERIFY_DIR, "adapter-app", "vendor"); +const FASTIFY_VENDOR_DIR = path.join(VERIFY_DIR, "adapter-fastify-app", "vendor"); const REACT_VENDOR_DIR = path.join(VERIFY_DIR, "react-vendor"); // The React client SDK (@seamless-auth/react). Defaults to a sibling checkout; @@ -169,7 +170,7 @@ function resolveServerDir(): string { } function cleanVendor(): void { - for (const dir of [VENDOR_DIR, REACT_VENDOR_DIR]) { + for (const dir of [VENDOR_DIR, FASTIFY_VENDOR_DIR, REACT_VENDOR_DIR]) { for (const f of fs.readdirSync(dir)) { if (f.endsWith(".tgz")) fs.rmSync(path.join(dir, f)); } @@ -185,18 +186,32 @@ async function packLocalReactSdk(env: NodeJS.ProcessEnv): Promise { await runCommand("npm", ["pack", "--pack-destination", REACT_VENDOR_DIR], sdkDir, env); } +// Each adapter image installs core plus its own framework package, so the +// tarballs are packed into that image's vendor dir and nothing else. +const ADAPTER_SDKS: Array<{ pkg: string; vendorDir: string }> = [ + { pkg: "@seamless-auth/express", vendorDir: VENDOR_DIR }, + { pkg: "@seamless-auth/fastify", vendorDir: FASTIFY_VENDOR_DIR }, +]; + async function packLocalSdks(env: NodeJS.ProcessEnv): Promise { const serverDir = resolveServerDir(); - console.log(kleur.cyan("→ Building & packing local @seamless-auth/* (core, express)…")); + console.log( + kleur.cyan("→ Building & packing local @seamless-auth/* (core, express, fastify)…"), + ); await runCommand("pnpm", ["--filter", "@seamless-auth/core", "build"], serverDir, env); - await runCommand("pnpm", ["--filter", "@seamless-auth/express", "build"], serverDir, env); - for (const pkg of ["@seamless-auth/core", "@seamless-auth/express"]) { - await runCommand( - "pnpm", - ["--filter", pkg, "pack", "--pack-destination", VENDOR_DIR], - serverDir, - env, - ); + for (const { pkg } of ADAPTER_SDKS) { + await runCommand("pnpm", ["--filter", pkg, "build"], serverDir, env); + } + for (const { pkg, vendorDir } of ADAPTER_SDKS) { + // core goes into both, since each image installs it alongside its adapter. + for (const target of ["@seamless-auth/core", pkg]) { + await runCommand( + "pnpm", + ["--filter", target, "pack", "--pack-destination", vendorDir], + serverDir, + env, + ); + } } } @@ -269,6 +284,7 @@ function collectPackageVersions( const serverDir = resolveServerDir(); push("@seamless-auth/core", readPkgVersion(path.join(serverDir, "packages", "core", "package.json"))); push("@seamless-auth/express", readPkgVersion(path.join(serverDir, "packages", "express", "package.json"))); + push("@seamless-auth/fastify", readPkgVersion(path.join(serverDir, "packages", "fastify", "package.json"))); } catch { // Server checkout unavailable; leave the SDK lines out rather than fail. } @@ -285,6 +301,13 @@ function collectPackageVersions( "@seamless-auth/express", readDepVersion(path.join(VERIFY_DIR, "adapter-app", "package.json"), "@seamless-auth/express"), ); + push( + "@seamless-auth/fastify", + readDepVersion( + path.join(VERIFY_DIR, "adapter-fastify-app", "package.json"), + "@seamless-auth/fastify", + ), + ); const reactPins = new Set(); for (const tmpl of webTemplates) { const pin = readDepVersion(path.join(tmpl.dir, "package.json"), "@seamless-auth/react"); @@ -411,11 +434,12 @@ export async function runVerify(args: string[] = []): Promise { SEAMLESS_OWNER_EMAIL: ownerEmail, SEAMLESS_API_URL: "http://localhost:5312", SEAMLESS_ADAPTER_URL: "http://localhost:3000", + SEAMLESS_FASTIFY_ADAPTER_URL: "http://localhost:3001", }; // The base stack (no browser layer). The react service is added per template below. const baseServices = ["postgres", "auth-api"]; - if (!opts.apiOnly) baseServices.push("adapter"); + if (!opts.apiOnly) baseServices.push("adapter", "adapter-fastify"); let failed = false; let setupError: Error | undefined; @@ -446,10 +470,13 @@ export async function runVerify(args: string[] = []): Promise { // API and adapter layers are template-independent, so they run once. const apiEnv: NodeJS.ProcessEnv = { ...baseEnv, - ...(opts.apiOnly ? {} : { SEAMLESS_VERIFY_ADAPTER: "1" }), + ...(opts.apiOnly + ? {} + : { SEAMLESS_VERIFY_ADAPTER: "1", SEAMLESS_VERIFY_ADAPTER_FASTIFY: "1" }), }; const apiProjects = ["api"]; - if (!opts.apiOnly) apiProjects.push("adapter"); + // The adapter suite runs once per adopter framework, against the same specs. + if (!opts.apiOnly) apiProjects.push("adapter", "adapter-fastify"); const apiLabel = opts.apiOnly ? "API" : "API / adapter"; console.log(kleur.cyan("→ Running the API / adapter conformance…\n")); if (!(await runLayer(results, apiLabel, () => runProjects(apiEnv, apiProjects, opts.grep)))) { diff --git a/verify/adapter-fastify-app/Dockerfile b/verify/adapter-fastify-app/Dockerfile new file mode 100644 index 0000000..bac1e2e --- /dev/null +++ b/verify/adapter-fastify-app/Dockerfile @@ -0,0 +1,14 @@ +FROM node:24-alpine +WORKDIR /app +RUN apk add --no-cache curl +COPY package.json ./ +RUN npm install +# --local mode: install locally-built @seamless-auth/* tarballs over the registry +# versions. vendor/ is empty in --released mode, so this is a no-op there. +COPY vendor/ ./vendor/ +RUN if ls ./vendor/*.tgz >/dev/null 2>&1; then \ + npm install ./vendor/seamless-auth-core-*.tgz ./vendor/seamless-auth-fastify-*.tgz; \ + fi +COPY server.mjs ./ +EXPOSE 3001 +CMD ["node", "server.mjs"] diff --git a/verify/adapter-fastify-app/package.json b/verify/adapter-fastify-app/package.json new file mode 100644 index 0000000..af358df --- /dev/null +++ b/verify/adapter-fastify-app/package.json @@ -0,0 +1,12 @@ +{ + "name": "seamless-verify-adapter-fastify", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Minimal adopter backend for the conformance harness — real @seamless-auth/fastify with a capture transport.", + "dependencies": { + "@fastify/cors": "^11.0.1", + "@seamless-auth/fastify": "^0.3.1", + "fastify": "^5.2.0" + } +} diff --git a/verify/adapter-fastify-app/server.mjs b/verify/adapter-fastify-app/server.mjs new file mode 100644 index 0000000..c5a3b43 --- /dev/null +++ b/verify/adapter-fastify-app/server.mjs @@ -0,0 +1,64 @@ +import cors from "@fastify/cors"; +import Fastify from "fastify"; +import seamlessAuth from "@seamless-auth/fastify"; + +// The Fastify twin of adapter-app/server.mjs. Same routes, same env contract, +// same capture transport: the point of this service is that a spec cannot tell +// which adapter answered it, so any difference in behaviour is a real one. +// +// The adapter strips OTP/magic-link secrets before responding to the browser, so +// the conformance harness can't read codes from responses. These handlers receive +// the raw delivery payloads and stash them for the harness to read via /__captured. +const captured = new Map(); +const ok = (channel) => ({ accepted: true, provider: "capture", channel }); + +const handlers = { + async sendOtpEmail({ to, token }) { + captured.set(to, { token: String(token) }); + return ok("email"); + }, + async sendOtpSms({ to, token }) { + captured.set(to, { token: String(token) }); + return ok("sms"); + }, + async sendMagicLinkEmail({ to, token, magicLinkUrl }) { + captured.set(to, { token, magicLinkUrl }); + return ok("email"); + }, + async sendBootstrapInviteEmail({ to, token, inviteUrl }) { + captured.set(to, { token, inviteUrl }); + return ok("email"); + }, +}; + +const app = Fastify(); + +// The React app (browser) calls the adapter cross-origin with credentials, so +// CORS must echo its origin and allow cookies. WEB_ORIGIN is the React app host. +await app.register(cors, { + origin: process.env.WEB_ORIGIN ?? "http://localhost:5173", + credentials: true, +}); + +app.get("/", async () => ({ ok: true })); +app.get("/__captured/:email", async (req) => captured.get(req.params.email) ?? null); + +// Registered under a prefix, which is how the plugin scopes its cookie and origin +// hooks. @fastify/cookie comes with the plugin, so it is not registered here. +// There is no `issuer` option on this adapter (the Express one takes one); the +// audience is what both check the API's tokens against. +await app.register(seamlessAuth, { + prefix: "/auth", + authServerUrl: process.env.AUTH_SERVER_URL, + cookieSecret: process.env.COOKIE_SIGNING_KEY, + serviceSecret: process.env.API_SERVICE_TOKEN, + audience: process.env.AUTH_SERVER_URL, + jwksKid: process.env.JWKS_KID, + messaging: { handlers, defaults: { appName: "Seamless Verify" } }, +}); + +const port = Number(process.env.PORT ?? 3001); +// 0.0.0.0, not the Fastify default of localhost: the port is published out of the +// container, and a listener bound to loopback inside it is unreachable from the host. +await app.listen({ port, host: "0.0.0.0" }); +console.log(`verify fastify adapter listening on :${port}`); diff --git a/verify/adapter-fastify-app/vendor/.gitignore b/verify/adapter-fastify-app/vendor/.gitignore new file mode 100644 index 0000000..a464f60 --- /dev/null +++ b/verify/adapter-fastify-app/vendor/.gitignore @@ -0,0 +1,3 @@ +*.tgz +!.gitkeep +!.gitignore diff --git a/verify/adapter-fastify-app/vendor/.gitkeep b/verify/adapter-fastify-app/vendor/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/verify/docker-compose.verify.yml b/verify/docker-compose.verify.yml index 06eff7b..d3e74cc 100644 --- a/verify/docker-compose.verify.yml +++ b/verify/docker-compose.verify.yml @@ -117,6 +117,32 @@ services: timeout: 3s retries: 20 + # The same adopter backend on Fastify, so the cookie path is conformance-tested + # for both adapters rather than only the one the harness happens to be written + # against. Identical env contract and capture transport; only the framework and + # the published port differ, which is what makes a failure here attributable. + adapter-fastify: + build: + context: ./adapter-fastify-app + ports: + - '3001:3001' + environment: + PORT: '3001' + AUTH_SERVER_URL: http://auth-api:5312 + APP_ORIGIN: http://localhost:3001 + WEB_ORIGIN: http://localhost:5173 + API_SERVICE_TOKEN: ${API_SERVICE_TOKEN} + COOKIE_SIGNING_KEY: ${API_SERVICE_TOKEN} + JWKS_KID: ${JWKS_KID} + depends_on: + auth-api: + condition: service_healthy + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://localhost:3001/'] + interval: 3s + timeout: 3s + retries: 20 + # React starter app (built SPA on nginx), pointed at the adapter so the React # SDK's `${apiHost}/auth/*` calls hit the adapter's mount. Browser-visible URL, # so API_URL is the host-mapped adapter (localhost:3000), not the compose host. diff --git a/verify/harness/global-setup.ts b/verify/harness/global-setup.ts index 5ebad29..12072f4 100644 --- a/verify/harness/global-setup.ts +++ b/verify/harness/global-setup.ts @@ -1,6 +1,6 @@ import { request as playwrightRequest } from '@playwright/test'; -import { ADAPTER_URL, API_URL, MOCK_OIDC_PORT, REACT_URL } from './lib/env'; +import { ADAPTER_URL, API_URL, FASTIFY_ADAPTER_URL, MOCK_OIDC_PORT, REACT_URL } from './lib/env'; import { startMockOidc } from './mock-oidc'; async function waitForHealth(url: string, name: string, timeoutMs = 120_000): Promise { @@ -39,6 +39,9 @@ export default async function globalSetup(): Promise { if (process.env.SEAMLESS_VERIFY_ADAPTER === '1') { await waitForHealth(`${ADAPTER_URL}/`, 'adapter'); } + if (process.env.SEAMLESS_VERIFY_ADAPTER_FASTIFY === '1') { + await waitForHealth(`${FASTIFY_ADAPTER_URL}/`, 'adapter-fastify'); + } if (process.env.SEAMLESS_VERIFY_REACT === '1') { await waitForHealth(`${REACT_URL}/health`, 'react'); } diff --git a/verify/harness/lib/client.ts b/verify/harness/lib/client.ts index 26dbc59..5ea0472 100644 --- a/verify/harness/lib/client.ts +++ b/verify/harness/lib/client.ts @@ -24,9 +24,13 @@ export async function newApiActor(prefix = 'verify'): Promise { return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() }; } -// A browser-like actor for the cookie path: a context bound to the adapter that +// A browser-like actor for the cookie path: a context bound to an adapter that // persists cookies across requests (the adapter handles service tokens internally). -export async function newAdapterActor(prefix = 'verify'): Promise { - const ctx = await playwrightRequest.newContext({ baseURL: ADAPTER_URL }); +// `baseURL` selects which adapter, so the same specs drive Express and Fastify. +export async function newAdapterActor( + baseURL: string = ADAPTER_URL, + prefix = 'verify', +): Promise { + const ctx = await playwrightRequest.newContext({ baseURL }); return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() }; } diff --git a/verify/harness/lib/env.ts b/verify/harness/lib/env.ts index 016aa13..51e0170 100644 --- a/verify/harness/lib/env.ts +++ b/verify/harness/lib/env.ts @@ -4,6 +4,10 @@ import { randomInt, randomUUID } from 'crypto'; export const API_URL = process.env.SEAMLESS_API_URL ?? 'http://localhost:5312'; export const ADAPTER_URL = process.env.SEAMLESS_ADAPTER_URL ?? 'http://localhost:3000'; +// The Fastify twin of the adapter, on its own port. The adapter specs run against +// both, so a regression in one framework is visible on its own row of the matrix. +export const FASTIFY_ADAPTER_URL = + process.env.SEAMLESS_FASTIFY_ADAPTER_URL ?? 'http://localhost:3001'; export const REACT_URL = process.env.SEAMLESS_REACT_URL ?? 'http://localhost:5173'; export const MOCK_OIDC_PORT = Number(process.env.SEAMLESS_MOCK_OIDC_PORT ?? 9000); diff --git a/verify/harness/lib/fixtures.ts b/verify/harness/lib/fixtures.ts index b38f40c..28afa07 100644 --- a/verify/harness/lib/fixtures.ts +++ b/verify/harness/lib/fixtures.ts @@ -1,17 +1,26 @@ import { test as base } from '@playwright/test'; import { Actor, newAdapterActor, newApiActor } from './client'; +import { ADAPTER_URL } from './env'; + +// `adapterUrl` is a project option rather than something a spec sets: the adapter +// suite is written once and each project points it at a different adopter backend +// (Express, Fastify). A spec cannot tell which one answered, which is the point. +export interface AdapterOptions { + adapterUrl: string; +} // `actor` drives the API directly (Bearer + service token); `adapterActor` drives // the adopter backend over cookies. Both are auto-created and disposed per test. -export const test = base.extend<{ actor: Actor; adapterActor: Actor }>({ +export const test = base.extend({ + adapterUrl: [ADAPTER_URL, { option: true }], actor: async ({}, use) => { const actor = await newApiActor(); await use(actor); await actor.dispose(); }, - adapterActor: async ({}, use) => { - const actor = await newAdapterActor(); + adapterActor: async ({ adapterUrl }, use) => { + const actor = await newAdapterActor(adapterUrl); await use(actor); await actor.dispose(); }, diff --git a/verify/harness/lib/matrixReporter.ts b/verify/harness/lib/matrixReporter.ts index 6abc0d5..df194cc 100644 --- a/verify/harness/lib/matrixReporter.ts +++ b/verify/harness/lib/matrixReporter.ts @@ -1,12 +1,16 @@ import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter'; // Prints a flow x layer conformance grid at the end of the run. Layer comes from -// the spec's directory (api/adapter/react); flow from the spec file name, with a -// few aliases folded together so the same flow lines up across layers. +// the Playwright project; flow from the spec file name, with a few aliases folded +// together so the same flow lines up across layers. -const LAYERS = ['api', 'adapter', 'react'] as const; +const LAYERS = ['api', 'adapter', 'adapter-fastify', 'react'] as const; type Layer = (typeof LAYERS)[number]; +function isLayer(name: string): name is Layer { + return (LAYERS as readonly string[]).includes(name); +} + const FLOW_ALIASES: Record = { emailOtpLogin: 'emailOtp', registration: 'register', @@ -29,7 +33,11 @@ export default class MatrixReporter implements Reporter { onTestEnd(test: TestCase, result: TestResult): void { const match = test.location.file.match(/\/(api|adapter|react)\/([^/]+)\.spec\.[tj]s$/); if (!match) return; - const layer = match[1] as Layer; + // The project name, not the directory: the two adapter projects run the same + // specs from ./adapter, so the path cannot tell them apart. Falls back to the + // directory for a run driven by something that does not name its projects. + const projectName = test.parent.project()?.name ?? ''; + const layer = isLayer(projectName) ? projectName : (match[1] as Layer); const base = match[2]; const flow = FLOW_ALIASES[base] ?? base; // Keyed by test id so the final attempt (after retries) is the one that counts. @@ -51,11 +59,16 @@ export default class MatrixReporter implements Reporter { }; const flowWidth = Math.max('flow'.length, ...flows.map((f) => f.length)); + // Each column is as wide as its own header, so a long layer name (like + // adapter-fastify) widens its column instead of running into the next one. + const layerWidth = (layer: Layer) => layer.length + 3; const pad = (text: string, width: number) => text + ' '.repeat(Math.max(0, width - text.length)); const row = (label: string, get: (layer: Layer) => string) => - ` ${pad(label, flowWidth)} ${LAYERS.map((l) => pad(get(l), 9)).join('')}`; - const rule = ` ${'-'.repeat(flowWidth + 3 + LAYERS.length * 9)}`; + ` ${pad(label, flowWidth)} ${LAYERS.map((l) => pad(get(l), layerWidth(l))).join('')}`; + const totalWidth = + flowWidth + 3 + LAYERS.reduce((sum, l) => sum + layerWidth(l), 0); + const rule = ` ${'-'.repeat(totalWidth)}`; const lines = [ '', diff --git a/verify/harness/playwright.config.ts b/verify/harness/playwright.config.ts index 6687d75..96419ea 100644 --- a/verify/harness/playwright.config.ts +++ b/verify/harness/playwright.config.ts @@ -1,11 +1,12 @@ import { defineConfig, devices } from '@playwright/test'; -import { REACT_URL } from './lib/env'; +import { FASTIFY_ADAPTER_URL, REACT_URL } from './lib/env'; +import type { AdapterOptions } from './lib/fixtures'; // One runner, multiple projects. `api` and `adapter` hit HTTP directly (no // browser); `react` drives chromium against the starter SPA. global-setup // health-gates the stack before any project runs. -export default defineConfig({ +export default defineConfig({ testDir: '.', globalSetup: './global-setup.ts', timeout: 30_000, @@ -25,7 +26,15 @@ export default defineConfig({ // pulling browser specs into a project with no baseURL. projects: [ { name: 'api', testDir: './api' }, + // Both adapter projects run the same specs from the same directory; only the + // adopter backend they point at differs. Adding a framework is a project + // entry plus a compose service, not a copy of the suite. { name: 'adapter', testDir: './adapter' }, + { + name: 'adapter-fastify', + testDir: './adapter', + use: { adapterUrl: FASTIFY_ADAPTER_URL }, + }, { name: 'react', testDir: './react',