Add dockerized Playwright E2E (todo §1); screenshot live pages + foundation mockups, assert shared design-system styles match

This commit is contained in:
2026-06-15 16:37:21 +02:00
parent 947851b4ff
commit 6f590148af
10 changed files with 279 additions and 2 deletions

View File

@@ -4,3 +4,5 @@ npm-debug.log
*.log
.DS_Store
html-css-foundation
e2e/artifacts

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@
.DS_Store
*.log
node_modules
# Playwright E2E outputs (screenshots, html report, traces)
e2e/artifacts/

12
Dockerfile.e2e Normal file
View File

@@ -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"]

View File

@@ -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/.
```

31
compose.e2e.yml Normal file
View File

@@ -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

78
e2e/package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

13
e2e/package.json Normal file
View File

@@ -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"
}
}

20
e2e/playwright.config.ts Normal file
View File

@@ -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"] } }],
});

103
e2e/visual.spec.ts Normal file
View File

@@ -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<Buffer> =>
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<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("/");
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 <use> resolves to a defined <symbol> (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);
});

View File

@@ -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 / 00). `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 `<use>` 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.