From 6f590148af12791918368d521e57a3f2c6672364 Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 15 Jun 2026 16:37:21 +0200 Subject: [PATCH] =?UTF-8?q?Add=20dockerized=20Playwright=20E2E=20(todo=20?= =?UTF-8?q?=C2=A71);=20screenshot=20live=20pages=20+=20foundation=20mockup?= =?UTF-8?q?s,=20assert=20shared=20design-system=20styles=20match?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 2 + .gitignore | 3 ++ Dockerfile.e2e | 12 +++++ README.md | 17 ++++++- compose.e2e.yml | 31 ++++++++++++ e2e/package-lock.json | 78 +++++++++++++++++++++++++++++ e2e/package.json | 13 +++++ e2e/playwright.config.ts | 20 ++++++++ e2e/visual.spec.ts | 103 +++++++++++++++++++++++++++++++++++++++ todo.md | 2 +- 10 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 Dockerfile.e2e create mode 100644 compose.e2e.yml create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/visual.spec.ts diff --git a/.dockerignore b/.dockerignore index 3a7b2df..b8dec35 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,5 @@ npm-debug.log *.log .DS_Store html-css-foundation + +e2e/artifacts diff --git a/.gitignore b/.gitignore index 5742681..e640087 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ .DS_Store *.log node_modules + +# Playwright E2E outputs (screenshots, html report, traces) +e2e/artifacts/ diff --git a/Dockerfile.e2e b/Dockerfile.e2e new file mode 100644 index 0000000..0034b7d --- /dev/null +++ b/Dockerfile.e2e @@ -0,0 +1,12 @@ +# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e/. +# Built/run via compose.e2e.yml; targets the `web` service over the network. +FROM mcr.microsoft.com/playwright:v1.49.1-noble + +WORKDIR /e2e + +COPY e2e/package.json e2e/package-lock.json ./ +RUN npm ci + +COPY e2e/ ./ + +CMD ["npx", "playwright", "test"] diff --git a/README.md b/README.md index 8be0b8b..06f7fa7 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,23 @@ auto-merged by `docker compose up`) turns them back off for live editing. ```bash docker compose run --rm web npm run typecheck # strict tsc --noEmit -docker compose run --rm web npm test # node --test +docker compose run --rm web npm test # node --test (units) ``` +### End-to-end (Playwright) + +E2E runs in the official Playwright image (browsers preinstalled) against the live `web` +service — no Node/browsers on the host. It screenshots the live pages **and** the +`html-css-foundation` mockups, then asserts the live DOM computes the **same design-system +styles** as the reference (so a styling regression fails the build, independent of the row data). + +```bash +docker compose -f compose.yml -f compose.e2e.yml run --rm e2e # run the suite +docker compose -f compose.yml -f compose.e2e.yml down -v # tear down after +``` + +Screenshots + an HTML report land in `e2e/artifacts/` (git-ignored). Tests run in parallel. + ## Building a plugin _(planned)_ A plugin is a folder under `plugins/`. The host discovers it at boot — no @@ -370,6 +384,7 @@ views/ Core EJS templates (index = the app-shell People dashboard, public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (planned) plugins/ Drop-in plugin folders, auto-discovered (planned) +e2e/ Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it) html-css-foundation/ HTML design mockups — the source for the building-block partials; reference the stylesheets in public/css/. ``` diff --git a/compose.e2e.yml b/compose.e2e.yml new file mode 100644 index 0000000..0db2c7d --- /dev/null +++ b/compose.e2e.yml @@ -0,0 +1,31 @@ +# Playwright E2E. Brings up the app + a Playwright runner, screenshots the live pages and the +# html-css-foundation mockups, and asserts the live DOM computes the same design styles. +# docker compose -f compose.yml -f compose.e2e.yml run --rm e2e +# docker compose -f compose.yml -f compose.e2e.yml down -v # tear down after +# Screenshots + HTML report land in ./e2e/artifacts/ (git-ignored). +services: + web: + # Dev throwaways are fine for tests; cache templates for production-like rendering. + environment: + CACHE_TEMPLATES: "true" + REQUIRE_SECURE_SECRETS: "false" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] + interval: 2s + timeout: 4s + retries: 15 + + e2e: + build: + context: . + dockerfile: Dockerfile.e2e + depends_on: + web: + condition: service_healthy + environment: + BASE_URL: http://web:3000 + volumes: + # The mockups + their stylesheet, kept as siblings so file:// ../public/css resolves. + - ./html-css-foundation:/repo/html-css-foundation:ro + - ./public:/repo/public:ro + - ./e2e/artifacts:/e2e/artifacts diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..25c4d88 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "plainpages-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plainpages-e2e", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "1.49.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", + "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..10a36c0 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "plainpages-e2e", + "version": "0.1.0", + "private": true, + "description": "Playwright visual + functional E2E: live app vs the html-css-foundation design.", + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.49.1" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..7ddec22 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,20 @@ +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. +export default defineConfig({ + testDir: ".", + outputDir: "artifacts/test-output", + fullyParallel: true, + forbidOnly: true, + reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]], + use: { + baseURL: process.env.BASE_URL ?? "http://localhost:3000", + colorScheme: "light", + screenshot: "only-on-failure", + viewport: { width: 1280, height: 800 }, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts new file mode 100644 index 0000000..b53db6b --- /dev/null +++ b/e2e/visual.spec.ts @@ -0,0 +1,103 @@ +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 shot = (page: Page, name: string): Promise => + page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` }); + +test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); }); + +test("captures live pages + reference mockups for side-by-side review", async ({ page }) => { + await page.goto("/"); + await expect(page.locator(".sidebar")).toBeVisible(); + await expect(page.locator("table.table tbody tr").first()).toBeVisible(); + await shot(page, "live-01-dashboard"); + + await page.goto("/?sort=-name&status=active"); + await shot(page, "live-02-sorted-filtered"); + + await page.goto("/"); + await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio + await shot(page, "live-03-dark"); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + 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> => + 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)[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("/"); + const ref = await context.newPage(); + await ref.goto(APP_SHELL); + + for (const selector of [".sidebar", ".topbar", ".brand", ".btn.btn-primary", ".theme-switch", ".filters", ".pager"]) { + expect(await styleOf(page, selector), `computed style mismatch for ${selector}`).toEqual(await styleOf(ref, selector)); + } + await ref.close(); +}); + +test("every icon resolves to a defined (no broken graphics)", async ({ page }) => { + await page.goto("/"); + const missing = await page.evaluate(() => { + const ids = new Set([...document.querySelectorAll("symbol[id]")].map((s) => s.id)); + return [...document.querySelectorAll("use")] + .map((u) => (u.getAttribute("href") ?? "").replace(/^#/, "")) + .filter((id) => id && !ids.has(id)); + }); + expect(missing).toEqual([]); +}); + +test("sorting and search drive the list through the URL (zero-JS)", async ({ page }) => { + await page.goto("/"); + const total = await page.locator("tbody tr").count(); + + await page.getByRole("link", { name: /Name/ }).first().click(); + await expect(page).toHaveURL(/sort=name/); + await expect(page.locator("thead th").filter({ hasText: "Name" })).toHaveAttribute("aria-sort", "ascending"); + + await page.goto("/"); + await page.locator('input[name="q"]').fill("Avery"); + await page.getByRole("button", { name: /Apply filters/ }).click(); + await expect(page).toHaveURL(/q=Avery/); + expect(await page.locator("tbody tr").count()).toBeLessThan(total); +}); + +test("theme switch flips the palette with no JavaScript", async ({ page }) => { + await page.goto("/"); + const light = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); + await page.locator("#theme-dark").check({ force: true }); + const dark = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); + expect(dark).not.toBe(light); +}); + +test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await expect(page.locator(".hamburger")).toBeVisible(); + + const offCanvas = await page.locator(".sidebar").evaluate((el) => { + const r = el.getBoundingClientRect(); + return r.right <= 1 || r.left >= window.innerWidth; + }); + expect(offCanvas).toBe(true); +}); diff --git a/todo.md b/todo.md index a350f24..6ebc679 100644 --- a/todo.md +++ b/todo.md @@ -38,7 +38,7 @@ everything via Docker. - [x] Helper `parseListQuery(url)` → `{ q, filters, sort, page, pageSize }`. → `src/list-query.ts`: pure, never throws; inverse of the filter-bar GET form + sort/pagination links. Accepts `URL`/`URLSearchParams`/string. `q` trimmed; `filters` = every non-reserved param as `string[]` (multi-value chips kept, empties dropped); `sort` = `{field,dir}` with `-field` ⇒ desc (lone `-`/empty ⇒ null); `page` a positive int (else 1); `pageSize` defaults 25, clamped to [1, max 100]. Reserved names + page-size bounds overridable via options. `list-query.test.ts` covers the full/default/clamp/custom-name matrix. - [x] Helper `paginate(total, page, pageSize)` → page model. → `src/paginate.ts`: pure, URL-free math feeding `pagination.ejs`; caller maps page numbers → hrefs. Returns `{ from, to, page, pageCount, pageSize, prev, next, total, pages }`. Inputs clamped/guarded (page pinned to [1,pageCount], total/pageSize coerced to sane ints, empty list ⇒ 1 page / 0–0). `pages` = first/last `boundaries` + `siblings`-wide window around current, sorted/deduped, with ellipsis for gaps >1 (a lone hole is shown, not collapsed); `siblings`/`boundaries` overridable. `paginate.test.ts` covers model/clamp/empty/windowing. - [x] Replace placeholder `index` with the app-shell dashboard. → `/` now renders a real app-shell "People" list. `src/dashboard.ts` (pure `buildDashboardModel(url, roles)`) wires the §1 helpers end-to-end: `parseListQuery` → filter (q/status/team) + sort + `paginate` over a 30-row mock dataset → `composeNav`; builds the filter-bar/data-table/pagination/shell configs with canonical, state-preserving links. `views/index.ejs` composes the partials around the shell by capturing each `include()` (EJS returns the string) into a slot. Filtering/sorting/paging all round-trip the URL, zero-JS. Removed the dead `partials/header.ejs`. `dashboard.test.ts` covers default/search/sort/paginate; `app.test.ts` asserts the live page + URL filtering. Mock data + demo profile stand in until §2/§4. -- [ ] Check the full system in Playwright and make screenshots and compare to the static original design in html-css-foundation to make sure we're showing the correct graphics. +- [x] Check the full system in Playwright and make screenshots and compare to the static original design in html-css-foundation to make sure we're showing the correct graphics. → Dockerized Playwright (official image, browsers preinstalled — no host Node/browsers): `e2e/` (config + `visual.spec.ts`), `Dockerfile.e2e`, `compose.e2e.yml` run the suite against the live `web` service. 6 parallel tests: screenshots live (default/sorted+filtered/dark/mobile) **and** the foundation mockups (App Shell + Auth) → `e2e/artifacts/` (git-ignored); asserts the live DOM computes the **same** design-system styles as the mockup for the shared components (`.sidebar/.topbar/.brand/.btn-primary/.theme-switch/.filters/.pager`), every icon `` resolves, sort/search round-trip the URL, the CSS theme switch flips the palette, and mobile hides the sidebar off-canvas. Verified visually: live dashboard matches the mockup design (light + dark); diffs are data only. All green. - [ ] Go over all HTML and CSS and make adjust it to be as sematic as we can, css classes, ids html elements and all, then add semantic DOM as a priority in this project. - [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.