Add dockerized Playwright E2E (todo §1); screenshot live pages + foundation mockups, assert shared design-system styles match
This commit is contained in:
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);
|
||||
});
|
||||
Reference in New Issue
Block a user