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-angular-browser-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-cli': minor
---

`seamless verify` now drives an Angular app through the same browser specs as the React template. The `angular` and `angular-dev` layers run the reference app in `verify/angular-app` on `@seamless-auth/angular` (production build, and `ng serve` with `--dev`), or an Angular web template once the registry has one. `SEAMLESS_ANGULAR_DIR` points the run at another Angular app, and `--local` installs the locally built `@seamless-auth/client` and `@seamless-auth/angular`.
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,21 +415,30 @@ their own Dockerfile's `runtime` target and driven by specs written for their ow
Mobile templates are announced and skipped: the harness has no
simulator to drive, so a native app is checked by running it against a `--keep-up` stack.

Angular runs the same browser specs as React. `@seamless-auth/angular` keeps the React
screens' accessible names, so the `angular` Playwright projects point at the React specs and
drive an Angular app instead: a web template whose `template.json` sets `verify.project` (or
whose registry `framework` is) `angular`, or, until one exists, the reference app in
[verify/angular-app](verify/angular-app). `SEAMLESS_ANGULAR_DIR` points the run at another
Angular app. With `--local`, the client and the built Angular package are packed into
`verify/angular-vendor` and installed over the published ones.

`--adapter-url` holds a server adapter this repository does not build (Go, Rust, Python)
to the same contract as the Express and Fastify adapters. It starts only Postgres and the
auth API and runs the adapter specs against a reference app you started at that URL. What
the reference app must serve, and how it reaches the API, is in
[verify/CONFORMANCE.md](verify/CONFORMANCE.md).

`--dev` runs every browser template a second time on its development server (`vite`, or
`next dev`). Production React runs each effect once; Strict Mode in development runs it
`--dev` runs every browser template a second time on its development server (`vite`,
`ng serve`, or `next dev`). Production React runs each effect once; Strict Mode in development runs it
twice, so an effect that is not idempotent (a single-use magic link verified twice, say)
passes the production pass and fails this one, the way it fails on a developer's first
`npm run dev`. The conformance workflow passes `--dev` on every pull request.

Sibling repositories are resolved next to this one and can be pointed elsewhere with
`SEAMLESS_API_DIR`, `SEAMLESS_SERVER_DIR`, `SEAMLESS_REACT_SDK_DIR`, and
`SEAMLESS_TEMPLATES_DIR`.
`SEAMLESS_TEMPLATES_DIR` (`SEAMLESS_REACT_SDK_DIR` is the client SDK workspace, which also
holds the Angular package).

---

Expand Down
97 changes: 95 additions & 2 deletions src/commands/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const ALL_PROFILES = [
"--profile",
"react-dev",
"--profile",
"angular",
"--profile",
"angular-dev",
"--profile",
"nextjs",
"--profile",
"nextjs-dev",
Expand Down Expand Up @@ -104,6 +108,7 @@ beforeEach(() => {
process.env.SEAMLESS_REACT_SDK_DIR = "/fake/reactsdk";
process.env.SEAMLESS_TEMPLATES_DIR = "/fake/templates";
delete process.env.SEAMLESS_REACT_DIR;
delete process.env.SEAMLESS_ANGULAR_DIR;
});

afterEach(() => {
Expand All @@ -117,7 +122,7 @@ describe("runVerify — published (default) mode", () => {
await runVerify([]);

// Stale tarballs are removed from every vendor dir; non-tgz files are left.
expect(fs.rmSync).toHaveBeenCalledTimes(3);
expect(fs.rmSync).toHaveBeenCalledTimes(4);

const tails = dockerTails();
expect(tails).toContainEqual([...ALL_PROFILES, "down", "-v"]); // initial clean
Expand Down Expand Up @@ -151,6 +156,11 @@ describe("runVerify — published (default) mode", () => {
// The web template declares verify.flows ["oauth"] ⇒ Playwright grep "@oauth".
expect(npmTests).toContainEqual(["test", "--", "--project", "react", "--grep", "@oauth"]);

// The Angular reference app runs the same browser specs, every flow.
expect(tails).toContainEqual(["--profile", "angular", "up", "-d", "--build", "angular"]);
expect(tails).toContainEqual(["--profile", "angular", "rm", "-sf", "angular"]);
expect(npmTests).toContainEqual(["test", "--", "--project", "angular"]);

// A successful run does not exit non-zero.
expect(exitSpy).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -247,6 +257,52 @@ describe("runVerify — published (default) mode", () => {
});
});

describe("runVerify with an Angular app", () => {
it("routes an Angular web template to the Angular runtime instead of the reference app", async () => {
vi.mocked(fs.readFileSync).mockImplementation((p: never) => {
const s = String(p);
if (s.endsWith("registry.json"))
return JSON.stringify({
templates: [
{ id: "web-basic", kind: "web", status: "stable", path: "templates/web-basic" },
{
id: "angular-web",
kind: "web",
framework: "angular",
status: "beta",
path: "templates/web/angular",
},
],
}) as never;
if (s.endsWith("template.json")) return JSON.stringify({}) as never;
return PKG_JSON as never;
});

await runVerify([]);

const up = vi
.mocked(runCommand)
.mock.calls.filter((c) => c[0] === "docker" && (c[1] as string[]).includes("angular"))
.find((c) => (c[1] as string[]).includes("up"));
const env = up?.[3] as NodeJS.ProcessEnv;
expect(env.SEAMLESS_ANGULAR_DIR).toBe("/fake/templates/templates/web/angular");
expect(env.SEAMLESS_VERIFY_TEMPLATE_ID).toBe("angular-web");
expect(logSpy.mock.calls.flat().join("\n")).not.toContain("angular-reference");
});

it("points the reference pass at SEAMLESS_ANGULAR_DIR when set", async () => {
process.env.SEAMLESS_ANGULAR_DIR = "/fake/my-angular-app";

await runVerify([]);

const up = vi
.mocked(runCommand)
.mock.calls.filter((c) => c[0] === "docker" && (c[1] as string[]).includes("angular"))
.find((c) => (c[1] as string[]).includes("up"));
expect((up?.[3] as NodeJS.ProcessEnv).SEAMLESS_ANGULAR_DIR).toBe("/fake/my-angular-app");
});
});

describe("runVerify — flag parsing", () => {
it("--dev adds a development-server pass after each production pass", async () => {
await runVerify(["--dev"]);
Expand All @@ -259,11 +315,20 @@ describe("runVerify — flag parsing", () => {
const npmTests = callsFor("npm").filter((a) => a[0] === "test");
const projects = npmTests.map((t) => t[t.indexOf("--project") + 1]);
// Each template: the production build first, then its dev server.
expect(projects).toEqual(["api", "react", "react-dev", "nextjs", "nextjs-dev"]);
expect(projects).toEqual([
"api",
"react",
"react-dev",
"angular",
"angular-dev",
"nextjs",
"nextjs-dev",
]);
expect(npmTests).toContainEqual(["test", "--", "--project", "react-dev", "--grep", "@oauth"]);

const out = logSpy.mock.calls.flat().join("\n");
expect(out).toContain("Web (dev) · web-basic");
expect(out).toContain("Web (dev) · angular-reference");
expect(out).toContain("Full-stack (dev) · nextjs");
});

Expand Down Expand Up @@ -424,13 +489,41 @@ describe("runVerify — local mode", () => {
});

it("packs the react SDK as a single package when the checkout has no workspaces", async () => {
// A checkout that predates the Angular package has no packages/angular/dist.
vi.mocked(fs.existsSync).mockImplementation(
(p: never) => !String(p).includes("packages/angular/dist"),
);

await runVerify(["--local"]);

const packs = callsFor("npm").filter((a) => a[0] === "pack");
expect(packs).toEqual([["pack", "--pack-destination", expect.stringContaining("react-vendor")]]);
});

it("packs the built Angular package and its client core for the Angular app", async () => {
await runVerify(["--local"]);

const packs = callsFor("npm").filter((a) => a[0] === "pack");
expect(packs).toContainEqual([
"pack",
"-w",
"@seamless-auth/client",
"--pack-destination",
expect.stringContaining("angular-vendor"),
]);
// From the folder ng-packagr builds, which is what npm publishes.
expect(packs).toContainEqual([
"pack",
"/fake/reactsdk/packages/angular/dist",
"--pack-destination",
expect.stringContaining("angular-vendor"),
]);
});

it("packs the client core alongside react when the checkout is a workspace", async () => {
vi.mocked(fs.existsSync).mockImplementation(
(p: never) => !String(p).includes("packages/angular/dist"),
);
vi.mocked(fs.readFileSync).mockImplementation((p: never) => {
const s = String(p);
if (s.endsWith("registry.json")) return REGISTRY_JSON as never;
Expand Down
82 changes: 70 additions & 12 deletions src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ interface BrowserRuntime {
service: string;
devService: string;
// The compose variable that points the services at the template's source.
dirEnv: "SEAMLESS_REACT_DIR" | "SEAMLESS_FULLSTACK_DIR";
dirEnv: "SEAMLESS_REACT_DIR" | "SEAMLESS_ANGULAR_DIR" | "SEAMLESS_FULLSTACK_DIR";
}

const WEB_RUNTIME: BrowserRuntime = {
Expand All @@ -102,6 +102,28 @@ const WEB_RUNTIME: BrowserRuntime = {
dirEnv: "SEAMLESS_REACT_DIR",
};

// An Angular app on @seamless-auth/angular. Its screens keep the React screens'
// accessible names, so it runs the same browser specs (the `angular` Playwright
// projects point at ./react), against its own compose services.
const ANGULAR_RUNTIME: BrowserRuntime = {
project: "angular",
service: "angular",
devService: "angular-dev",
dirEnv: "SEAMLESS_ANGULAR_DIR",
};

// Web templates pick their runtime by verify.project, falling back to the
// framework, and then to React, which is what every web template was before.
const WEB_RUNTIMES: Record<string, BrowserRuntime> = {
react: WEB_RUNTIME,
angular: ANGULAR_RUNTIME,
};

// The Angular reference app in this repo. It runs with every browser layer until
// an Angular web template exists to stand in for it (seamless-templates#111), and
// SEAMLESS_ANGULAR_DIR points the run at another Angular app instead.
const ANGULAR_REFERENCE_DIR = path.join(VERIFY_DIR, "angular-app");

// Full-stack templates render their own screens, so each framework has its own
// specs. A full-stack template whose project is not listed here is skipped, said
// out loud.
Expand All @@ -116,7 +138,10 @@ const FULLSTACK_RUNTIMES: Record<string, BrowserRuntime> = {

// Every profile the browser services live behind, so a clean, a log dump, or a
// teardown reaches whichever one a run left up.
const ALL_PROFILES = [WEB_RUNTIME, ...Object.values(FULLSTACK_RUNTIMES)].flatMap((r) => [
const ALL_PROFILES = [
...Object.values(WEB_RUNTIMES),
...Object.values(FULLSTACK_RUNTIMES),
].flatMap((r) => [
"--profile",
r.service,
"--profile",
Expand Down Expand Up @@ -203,9 +228,19 @@ function resolveWebTemplates(): WebTemplate[] {

const targets: WebTemplate[] = webTemplates.map((t) => {
const dir = resolveDir(t, "Web");
return { id: t.id, dir, flows: readTemplateFlows(dir), runtime: WEB_RUNTIME };
const project = readManifest(dir)?.verify?.project ?? t.framework;
const runtime = (project && WEB_RUNTIMES[project]) || WEB_RUNTIME;
return { id: t.id, dir, flows: readTemplateFlows(dir), runtime };
});

if (!targets.some((t) => t.runtime === ANGULAR_RUNTIME)) {
const dir = process.env.SEAMLESS_ANGULAR_DIR ?? ANGULAR_REFERENCE_DIR;
if (!fs.existsSync(path.join(dir, "package.json"))) {
throw new Error(`SEAMLESS_ANGULAR_DIR=${dir} has no package.json.`);
}
targets.push({ id: "angular-reference", dir, runtime: ANGULAR_RUNTIME });
}

// A full-stack template serves /auth itself and renders its own screens, so it
// runs from its own Dockerfile against specs written for its framework. The
// manifest's verify.project names them, falling back to the framework.
Expand Down Expand Up @@ -238,6 +273,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");
const ANGULAR_VENDOR_DIR = path.join(VERIFY_DIR, "angular-vendor");

// The React client SDK (@seamless-auth/react). Defaults to a sibling checkout;
// override with SEAMLESS_REACT_SDK_DIR. Only needed for --local browser runs.
Expand Down Expand Up @@ -269,7 +305,7 @@ function resolveServerDir(): string {
}

function cleanVendor(): void {
for (const dir of [VENDOR_DIR, FASTIFY_VENDOR_DIR, REACT_VENDOR_DIR]) {
for (const dir of [VENDOR_DIR, FASTIFY_VENDOR_DIR, REACT_VENDOR_DIR, ANGULAR_VENDOR_DIR]) {
for (const f of fs.readdirSync(dir)) {
if (f.endsWith(".tgz")) fs.rmSync(path.join(dir, f));
}
Expand All @@ -292,7 +328,7 @@ function reactSdkWorkspaces(sdkDir: string): string[] {
// over the published version (--local browser runs).
async function packLocalReactSdk(env: NodeJS.ProcessEnv): Promise<void> {
const sdkDir = resolveReactSdkDir();
console.log(kleur.cyan("→ Building & packing local @seamless-auth/react…"));
console.log(kleur.cyan("→ Building & packing local @seamless-auth/react and /angular…"));
await runCommand("npm", ["run", "build"], sdkDir, env);
const workspaceArgs = reactSdkWorkspaces(sdkDir).flatMap((pkg) => ["-w", pkg]);
await runCommand(
Expand All @@ -301,6 +337,24 @@ async function packLocalReactSdk(env: NodeJS.ProcessEnv): Promise<void> {
sdkDir,
env,
);

// @seamless-auth/angular publishes the folder ng-packagr builds, not its source
// directory, so it is packed from there. Its client core goes beside it.
const angularDist = path.join(sdkDir, "packages", "angular", "dist");
if (fs.existsSync(path.join(angularDist, "package.json"))) {
await runCommand(
"npm",
["pack", "-w", "@seamless-auth/client", "--pack-destination", ANGULAR_VENDOR_DIR],
sdkDir,
env,
);
await runCommand(
"npm",
["pack", angularDist, "--pack-destination", ANGULAR_VENDOR_DIR],
sdkDir,
env,
);
}
}

// Each adapter image installs core plus its own framework package, so the
Expand Down Expand Up @@ -421,6 +475,8 @@ function collectPackageVersions(
? path.join(sdkDir, "packages", "react", "package.json")
: path.join(sdkDir, "package.json");
push("@seamless-auth/react", readPkgVersion(reactPkg));
const angularPkg = path.join(sdkDir, "packages", "angular", "package.json");
if (fs.existsSync(angularPkg)) push("@seamless-auth/angular", readPkgVersion(angularPkg));
} catch {
// React SDK checkout unavailable.
}
Expand All @@ -446,13 +502,15 @@ function collectPackageVersions(
for (const pin of reactPins) push("@seamless-auth/react", pin);
}

// A full-stack template builds from its own lockfile in both modes, so its
// server SDK is always the version it pins.
// A full-stack or Angular template builds from its own lockfile in both modes,
// so its SDK is always the version it pins. The Angular reference app pins
// nothing: it installs the published package unless --local packed one.
for (const tmpl of webTemplates.filter((t) => t.runtime !== WEB_RUNTIME)) {
push(
`@seamless-auth/${tmpl.runtime.project} (${tmpl.id})`,
readDepVersion(path.join(tmpl.dir, "package.json"), `@seamless-auth/${tmpl.runtime.project}`),
);
const pkg = `@seamless-auth/${tmpl.runtime.project}`;
const pin =
readDepVersion(path.join(tmpl.dir, "package.json"), pkg) ??
(!opts.local && tmpl.id === "angular-reference" ? "latest" : undefined);
push(`${pkg} (${tmpl.id})`, pin);
}

return versions;
Expand Down Expand Up @@ -659,7 +717,7 @@ export async function runVerify(args: string[] = []): Promise<void> {
};
const grep = opts.grep ?? flowsToGrep(tmpl.flows);
const scope = grep ? ` (${grep})` : " (all flows)";
const kind = runtime === WEB_RUNTIME ? "Web" : "Full-stack";
const kind = Object.values(WEB_RUNTIMES).includes(runtime) ? "Web" : "Full-stack";
console.log(
kleur.bold(
`\n→ ${kind} template: ${tmpl.id}${grep ? ` (flows: ${grep})` : " (all flows)"}\n`,
Expand Down
3 changes: 3 additions & 0 deletions verify/angular-app/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
.angular
3 changes: 3 additions & 0 deletions verify/angular-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
.angular/
30 changes: 30 additions & 0 deletions verify/angular-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# The verify harness builds this image with an `sdk` build context: verify/angular-vendor,
# which holds local @seamless-auth/client and @seamless-auth/angular tarballs on a --local
# run and nothing otherwise. Without them, the published @seamless-auth/angular is used.
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY --from=sdk . /tmp/sdk/
ARG SEAMLESS_ANGULAR_VERSION=latest
RUN if ls /tmp/sdk/*.tgz >/dev/null 2>&1; then \
npm install --no-save /tmp/sdk/*.tgz; \
else \
npm install --no-save "@seamless-auth/angular@${SEAMLESS_ANGULAR_VERSION}"; \
fi
COPY . .

FROM deps AS dev
EXPOSE 5173
CMD ["npm", "run", "dev"]

FROM deps AS builder
RUN npm run build

FROM nginx:alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
CMD ["/entrypoint.sh"]
Loading
Loading