From d5da781cd8760504ca4497a17138aefd020dabc1 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 2 Aug 2026 14:49:07 -0400 Subject: [PATCH] feat: exercise the Fastify starter in the verify harness The scaffold has offered a Fastify API since the templates bump, but the conformance harness only drove the Express adapter, so a green run said nothing about whether a Fastify-scaffolded project worked. Adds verify/adapter-fastify-app on port 3001, a twin of the Express adopter backend: same routes, same env contract, same capture transport, so a spec cannot tell which one answered and any difference in behaviour is a real one. Two differences the framework forces: the Fastify adapter takes no issuer option, and Fastify binds loopback by default, which is unreachable from outside the container. The adapter specs are not duplicated. Both projects run the same files from ./adapter and differ only in the adapterUrl project option, so adding an adopter framework is a project entry plus a compose service. Because the projects share a directory, matrixReporter now takes the layer from the Playwright project name rather than the spec path, and sizes each column to its own header. --local builds and packs @seamless-auth/fastify alongside core and express, with core packed into both vendor dirs since each image installs it beside its own adapter. --api-only and --no-react are unchanged. Closes #147 --- .changeset/verify-fastify-adapter.md | 17 +++++ AGENTS.md | 25 +++++--- src/commands/verify.test.ts | 67 ++++++++++++++++++-- src/commands/verify.ts | 53 ++++++++++++---- verify/adapter-fastify-app/Dockerfile | 14 ++++ verify/adapter-fastify-app/package.json | 12 ++++ verify/adapter-fastify-app/server.mjs | 64 +++++++++++++++++++ verify/adapter-fastify-app/vendor/.gitignore | 3 + verify/adapter-fastify-app/vendor/.gitkeep | 0 verify/docker-compose.verify.yml | 26 ++++++++ verify/harness/global-setup.ts | 5 +- verify/harness/lib/client.ts | 10 ++- verify/harness/lib/env.ts | 4 ++ verify/harness/lib/fixtures.ts | 15 ++++- verify/harness/lib/matrixReporter.ts | 25 ++++++-- verify/harness/playwright.config.ts | 13 +++- 16 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 .changeset/verify-fastify-adapter.md create mode 100644 verify/adapter-fastify-app/Dockerfile create mode 100644 verify/adapter-fastify-app/package.json create mode 100644 verify/adapter-fastify-app/server.mjs create mode 100644 verify/adapter-fastify-app/vendor/.gitignore create mode 100644 verify/adapter-fastify-app/vendor/.gitkeep 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',