Add dockerized Playwright E2E (todo §1); screenshot live pages + foundation mockups, assert shared design-system styles match
This commit is contained in:
@@ -4,3 +4,5 @@ npm-debug.log
|
||||
*.log
|
||||
.DS_Store
|
||||
html-css-foundation
|
||||
|
||||
e2e/artifacts
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
node_modules
|
||||
|
||||
# Playwright E2E outputs (screenshots, html report, traces)
|
||||
e2e/artifacts/
|
||||
|
||||
12
Dockerfile.e2e
Normal file
12
Dockerfile.e2e
Normal 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"]
|
||||
17
README.md
17
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/.
|
||||
```
|
||||
|
||||
31
compose.e2e.yml
Normal file
31
compose.e2e.yml
Normal 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
78
e2e/package-lock.json
generated
Normal 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
13
e2e/package.json
Normal 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
20
e2e/playwright.config.ts
Normal 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
103
e2e/visual.spec.ts
Normal 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);
|
||||
});
|
||||
2
todo.md
2
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 `<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.
|
||||
|
||||
Reference in New Issue
Block a user