Remove completed todo.md + html-css-foundation mockups; strip dead §N phase refs from comments/docs (simplify visual E2E to drop the mockup-comparison oracle)
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in", §4). Runs against the
|
||||
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in"). Runs against the
|
||||
// real Ory stack via compose.e2e-auth.yml, where the session→JWT TTL is shortened to 8s and the
|
||||
// web clock skew is 0 — so the ~10m token lapses in seconds and the hot path re-mints it from the
|
||||
// still-live Kratos session. We drive the flow over HTTP (fetch, manual cookies) because Kratos
|
||||
// and web sit on different hosts here; web's own server-side cookie relay is what we exercise.
|
||||
// The browser-UI login is owned by §8; this proves the timeout/refresh server behaviour end-to-end.
|
||||
// The browser-UI login is owned by the full-flow E2E; this proves the timeout/refresh server behaviour end-to-end.
|
||||
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3); admin role granted in Keto
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -15,7 +15,7 @@ import { expect, test } from "@playwright/test";
|
||||
// (compose.e2e-devstack.yml) against the plain `docker compose up` topology, so it sees
|
||||
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
|
||||
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3)
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
|
||||
async function signIn(page: import("@playwright/test").Page): Promise<void> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type Browser, type Page, expect, test } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Full browser E2E (todo §8): the real Playwright UI against the live stack via the same-origin
|
||||
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
||||
// gateway (compose.e2e-full.yml) — the browser-UI login the earlier full-stack suites deferred here.
|
||||
// Coverage is the test titles below, plus the standalone SSO test.
|
||||
//
|
||||
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
|
||||
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
|
||||
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
||||
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3), holds the admin role in Keto
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
||||
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
||||
@@ -103,7 +103,7 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("return_to: a deep link while logged out returns to that page after login (§9)", async ({ page }) => {
|
||||
test("return_to: a deep link while logged out returns to that page after login", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
|
||||
// flow server-side, so it's consumed, not shown in the settled URL).
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Mock OIDC provider for the SSO browser E2E (todo §8) — a stand-in for Google/etc. so the test
|
||||
// Mock OIDC provider for the SSO browser E2E — a stand-in for Google/etc. so the test
|
||||
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
|
||||
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
|
||||
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Full-stack OAuth2 login + consent E2E (§6): another app logs in *through* plainpages. Hydra
|
||||
// Full-stack OAuth2 login + consent E2E: another app logs in *through* plainpages. Hydra
|
||||
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
|
||||
// the Kratos session and accepts, Hydra continues to web's /oauth2/consent, web shows the themed
|
||||
// consent screen, and Allow drives Hydra to issue the authorization code. We drive the flow over
|
||||
// HTTP (fetch, per-host cookie jars) because the browser hosts differ on the compose network; this
|
||||
// exercises web's server-side challenge handling. The browser-UI login is owned by §8.
|
||||
// exercises web's server-side challenge handling. The browser-UI login is owned by the full-flow E2E (full-flow.spec.ts).
|
||||
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
|
||||
const HYDRA_ADMIN = process.env.HYDRA_ADMIN_URL ?? "http://hydra:4445";
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3)
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
|
||||
function setCookieLine(res: Response, name: string): string | undefined {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Visual + functional checks against the live app (the `web` compose service, BASE_URL) and the
|
||||
// static html-css-foundation mockups (bind-mounted at /repo). Run via compose.e2e.yml. Parallel
|
||||
// per the project's E2E principle (todo §1.1); deterministic colorScheme/viewport so the
|
||||
// computed-style parity vs the reference design is stable.
|
||||
// Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
|
||||
// compose.e2e.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
|
||||
// so the rendered design is stable across runs.
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "artifacts/test-output",
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Same-origin gateway for the browser E2E (todo §8). The themed login form posts straight to
|
||||
// Same-origin gateway for the browser E2E. The themed login form posts straight to
|
||||
// Kratos' flow action and Kratos sets the session cookie for its own base_url host — so for a real
|
||||
// browser, web and Kratos must look like ONE origin (cookies are host-scoped). This tiny stdlib
|
||||
// reverse proxy fronts both on a single host (the browser's only origin), exactly as a production
|
||||
|
||||
+13
-46
@@ -3,10 +3,6 @@ import { readFileSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
// The mockups are bind-mounted at /repo (sibling to /repo/public so their ../public/css/ resolves).
|
||||
const MOCKUP = "file:///repo/html-css-foundation";
|
||||
const APP_SHELL = `${MOCKUP}/App%20Shell.html`;
|
||||
const AUTH = `${MOCKUP}/Auth.html`;
|
||||
const SHOTS = "artifacts/screenshots";
|
||||
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
||||
const SESSION_COOKIE = "plainpages_jwt"; // src/login.ts — web verifies it against the committed dev JWKS
|
||||
@@ -15,7 +11,7 @@ const shot = (page: Page, name: string): Promise<Buffer> =>
|
||||
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
|
||||
|
||||
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
||||
// gated dashboard (§10) renders for a "signed-in" user without standing up Ory — web verifies it
|
||||
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
||||
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
||||
function devSession(roles: string[] = []): string {
|
||||
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
||||
@@ -28,16 +24,16 @@ function devSession(roles: string[] = []): string {
|
||||
|
||||
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
|
||||
|
||||
// The dashboard is gated (§10): a page navigation needs a session. Plant one per test — a plain
|
||||
// member (no roles) so the gated scheduling/admin nav stays filtered out, matching the mockup.
|
||||
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
|
||||
// member (no roles) so the gated scheduling/admin nav stays filtered out.
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||
});
|
||||
|
||||
test("captures live pages + reference mockups for side-by-side review", async ({ page }) => {
|
||||
test("captures the live pages for review", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
// §10: the default /dashboard is the instructional starter, not a mock-data list.
|
||||
// the default /dashboard is the instructional starter, not a mock-data list.
|
||||
await expect(page.getByRole("heading", { name: "Starter dashboard" })).toBeVisible();
|
||||
await shot(page, "live-01-dashboard");
|
||||
|
||||
@@ -49,35 +45,6 @@ test("captures live pages + reference mockups for side-by-side review", async ({
|
||||
await page.goto("/dashboard");
|
||||
await shot(page, "live-04-mobile");
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
|
||||
await page.goto(APP_SHELL);
|
||||
await shot(page, "mockup-01-app-shell");
|
||||
await page.goto(AUTH);
|
||||
await shot(page, "mockup-02-auth");
|
||||
});
|
||||
|
||||
// The live DOM reuses the foundation's classes, so the same styles.css must compute identically
|
||||
// on both — proof we render the intended graphics, independent of the (different) row data.
|
||||
const PROPS = ["backgroundColor", "borderRadius", "borderTopColor", "color", "fontSize", "fontWeight"] as const;
|
||||
const styleOf = (page: Page, selector: string): Promise<Record<string, string>> =>
|
||||
page.locator(selector).first().evaluate((el, props) => {
|
||||
const cs = getComputedStyle(el as Element);
|
||||
return Object.fromEntries(props.map((p) => [p, cs.getPropertyValue(p) || (cs as unknown as Record<string, string>)[p]]));
|
||||
}, PROPS as unknown as string[]);
|
||||
|
||||
test("live components compute the same design-system styles as the reference mockup", async ({ page, context }) => {
|
||||
await page.goto("/dashboard");
|
||||
const ref = await context.newPage();
|
||||
await ref.goto(APP_SHELL);
|
||||
|
||||
// Shell components appear on every page, incl. the instructional /dashboard. The data components
|
||||
// (.filters/.table/.pager) used to live on the mock dashboard (removed in §10); they're now covered
|
||||
// by the mockup screenshots + their unit tests, and rendered live with real data by the full-flow
|
||||
// E2E (the admin Users list) which the Ory-free visual suite can't stand up.
|
||||
for (const selector of [".sidebar", ".topbar", ".brand", ".btn.btn-primary", ".theme-switch"]) { // .btn-primary = the dashboard's "Browse the example plugin" CTA
|
||||
expect(await styleOf(page, selector), `computed style mismatch for ${selector}`).toEqual(await styleOf(ref, selector));
|
||||
}
|
||||
await ref.close();
|
||||
});
|
||||
|
||||
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
|
||||
@@ -93,7 +60,7 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
|
||||
|
||||
// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
|
||||
// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
|
||||
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone (§10).)
|
||||
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone.)
|
||||
|
||||
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
@@ -128,11 +95,11 @@ test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a t
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
|
||||
test("the public landing at / is ungated and links to sign in + register (§10)", async ({ page, context }) => {
|
||||
test("the public landing at / is ungated and links to sign in + register", async ({ page, context }) => {
|
||||
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".landing")).toBeVisible();
|
||||
// §10: the same app shell every page renders — the menu shows even signed out (role-filtered).
|
||||
// the same app shell every page renders — the menu shows even signed out (role-filtered).
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
||||
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
||||
@@ -148,9 +115,9 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
|
||||
|
||||
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
|
||||
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
|
||||
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the §8 full
|
||||
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
|
||||
// E2E (full-flow.spec). Side-effect-free.
|
||||
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login (§10)", async ({ page, request }) => {
|
||||
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
|
||||
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
||||
// probes are genuinely anonymous.
|
||||
// The public overview is reachable with no session (200), not bounced to sign in.
|
||||
@@ -158,13 +125,13 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
|
||||
expect(pub.status()).toBe(200);
|
||||
const body = await pub.text();
|
||||
expect(body).toContain("Scheduling");
|
||||
// Anonymous in the native shell (§10): the gated Dashboard link is hidden (it would only dead-end at
|
||||
// Anonymous in the native shell: the gated Dashboard link is hidden (it would only dead-end at
|
||||
// /login), and the shell's Sign-in link carries the current page as return_to.
|
||||
expect(body).not.toContain('href="/dashboard"');
|
||||
expect(body).toContain('href="/login?return_to=%2Fscheduling"');
|
||||
|
||||
// The gated shifts list still bounces (don't follow — this Ory-free suite has no /login handler);
|
||||
// assert the gate's 303 with the requested page preserved as return_to (§9).
|
||||
// assert the gate's 303 with the requested page preserved as return_to.
|
||||
const res = await request.get("/scheduling/shifts", { maxRedirects: 0 });
|
||||
expect(res.status()).toBe(303);
|
||||
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
||||
@@ -172,7 +139,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
|
||||
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
|
||||
// but the gated Shifts leaf is filtered out.
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders (§10)
|
||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
|
||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user