From 0837b87f1cbeb382efc8e8431b9f74ecf37d3545 Mon Sep 17 00:00:00 2001 From: naindev Date: Wed, 21 Oct 2026 21:15:04 +0200 Subject: [PATCH 1/2] test(e2e): verify the panel in a real browser against the real app 25 Playwright tests in a self-contained e2e/ folder cover the landing and reduced motion, the buyer and organizer views, the language selector (default, switch, persistence, no missing keys on any route), the buyer link and its QR code (decoded by jsQR from a capture of the screen), the offline shell and the 360 px layout. They run in their own CI job so a browser flake never hides the .NET result, with a pinned lockfile, npm audit and Dependabot for e2e/. Nothing in e2e/ ships and the panel gains no dependency. --- .github/dependabot.yml | 12 +++++ .github/workflows/ci.yml | 52 ++++++++++++++++++++ .gitignore | 5 ++ Taskfile.yml | 7 +++ e2e/package-lock.json | 95 ++++++++++++++++++++++++++++++++++++ e2e/package.json | 16 ++++++ e2e/playwright.config.ts | 44 +++++++++++++++++ e2e/tests/buyer-link.spec.ts | 61 +++++++++++++++++++++++ e2e/tests/fixtures.ts | 46 +++++++++++++++++ e2e/tests/landing.spec.ts | 32 ++++++++++++ e2e/tests/language.spec.ts | 46 +++++++++++++++++ e2e/tests/mobile.spec.ts | 44 +++++++++++++++++ e2e/tests/offline.spec.ts | 57 ++++++++++++++++++++++ e2e/tests/roles.spec.ts | 61 +++++++++++++++++++++++ 14 files changed, 578 insertions(+) create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/buyer-link.spec.ts create mode 100644 e2e/tests/fixtures.ts create mode 100644 e2e/tests/landing.spec.ts create mode 100644 e2e/tests/language.spec.ts create mode 100644 e2e/tests/mobile.spec.ts create mode 100644 e2e/tests/offline.spec.ts create mode 100644 e2e/tests/roles.spec.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fe60e3f..a13f2c8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,3 +23,15 @@ updates: update-types: - "minor" - "patch" + + # Browser-test tooling only (ADR-0011); nothing in e2e/ ships. + - package-ecosystem: "npm" + directory: "/e2e" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 2 + groups: + e2e-dependencies: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e288e7..71a2295 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,3 +110,55 @@ jobs: - name: Detener el contenedor if: always() run: docker rm -f nainorder-ci || true + + # The panel's visual behaviour (landing, roles, language, QR, offline, phone layout) is + # verified in a real browser against the real application (ADR-0011). It is its own job so a + # browser flake never hides the result of the .NET tests. + browser-tests: + name: Browser tests + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: e2e/package-lock.json + + # Built before the suite starts so the server is up within Playwright's start-up window. + - name: Build the application + run: dotnet build NainOrder.Api/NainOrder.Api.csproj -c Release + + - name: Install test dependencies + working-directory: e2e + run: npm ci + + - name: Audit test dependencies + working-directory: e2e + run: npm audit --audit-level=high + + - name: Install the browser + working-directory: e2e + run: npx playwright install --with-deps chromium + + - name: Run the browser tests + working-directory: e2e + run: npx playwright test + + - name: Keep the report and traces if something fails + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/playwright-report + e2e/test-results + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 1367302..5ac6d2d 100644 --- a/.gitignore +++ b/.gitignore @@ -64,5 +64,10 @@ artifacts/ *.db-shm *.db-wal +# Browser tests (e2e/): dependencies and run output +e2e/node_modules/ +e2e/test-results/ +e2e/playwright-report/ + # Configuración local del editor .vscode/ diff --git a/Taskfile.yml b/Taskfile.yml index 169bc74..00439cb 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -30,3 +30,10 @@ tasks: desc: "Generate strict TypeScript client SDK from OpenAPI specification" cmds: - node scripts/generate-client.mjs + + e2e: + desc: "Run the browser tests (starts the application itself; set PW_CHANNEL=msedge to reuse an installed browser)" + dir: e2e + cmds: + - npm ci + - npx playwright test diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..3afc95a --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,95 @@ +{ + "name": "nainorder-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nainorder-e2e", + "devDependencies": { + "@playwright/test": "1.64.1", + "@types/node": "26.6.5", + "jsqr": "1.4.0", + "pngjs": "7.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..29cd1da --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "nainorder-e2e", + "private": true, + "description": "Browser tests for the NainOrder panel. Dev-only: nothing here ships.", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "install:browser": "playwright install --with-deps chromium" + }, + "devDependencies": { + "@playwright/test": "1.64.1", + "@types/node": "26.6.5", + "jsqr": "1.4.0", + "pngjs": "7.0.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..5525af6 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,44 @@ +import os from "node:os"; +import path from "node:path"; +import { defineConfig, devices } from "@playwright/test"; + +const PORT = Number(process.env.E2E_PORT ?? 8180); +const BASE_URL = `http://localhost:${PORT}`; + +// A throwaway database per run: the suite never touches a developer's nainorder.db. +const database = path.join(os.tmpdir(), `nainorder-e2e-${process.pid}.db`); + +export default defineConfig({ + testDir: "./tests", + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : [["list"]], + + use: { + baseURL: BASE_URL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + // CI uses Playwright's own Chromium; locally PW_CHANNEL=msedge reuses an installed browser. + channel: process.env.PW_CHANNEL || undefined, + // The third-party ecosystem bar and brand CSS are not part of the product under test. + serviceWorkers: "allow" + }, + + projects: [ + { name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1360, height: 820 } } } + ], + + webServer: { + command: `dotnet run --project ../NainOrder.Api -c Release --no-launch-profile --urls ${BASE_URL}`, + url: `${BASE_URL}/health`, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + env: { + ASPNETCORE_ENVIRONMENT: "Development", + ConnectionStrings__DefaultConnection: `Data Source=${database}` + } + } +}); diff --git a/e2e/tests/buyer-link.spec.ts b/e2e/tests/buyer-link.spec.ts new file mode 100644 index 0000000..520ab92 --- /dev/null +++ b/e2e/tests/buyer-link.spec.ts @@ -0,0 +1,61 @@ +import jsQR from "jsqr"; +import { PNG } from "pngjs"; +import { test, expect, startDrop } from "./fixtures"; + +test.use({ locale: "en-GB" }); + +// REQ-104 and REQ-105: the QR code is the buyer link, exactly, as an independent decoder reads it. +test("the QR code decodes to the buyer link", async ({ page, baseURL }) => { + const id = await startDrop(page, "organizer"); + + const link = await page.locator("[data-link]").inputValue(); + expect(link).toBe(`${baseURL}/#/d/${id}`); + + const image = PNG.sync.read(await page.locator("[data-qr]").screenshot()); + const decoded = jsQR(new Uint8ClampedArray(image.data), image.width, image.height); + + expect(decoded?.data).toBe(link); +}); + +// REQ-103: the buyer link opens the buyer page alone, and remembers the visitor. +test("the buyer link shows the buyer page alone and remembers the visitor", async ({ page, browser }) => { + const id = await startDrop(page, "organizer"); + + // A different visitor, as a phone that scanned the code: a fresh context with no history. + const phone = await browser.newContext({ locale: "en-GB" }); + await phone.route(/naindev\.com/, (route) => route.abort()); + const visitor = await phone.newPage(); + + await visitor.goto(`/#/d/${id}`); + await expect(visitor.locator(".buyer")).toBeVisible(); + await expect(visitor.locator(".sidebar")).toBeHidden(); + await expect(visitor.locator(".topbar")).toBeHidden(); + await expect(visitor.locator("[data-role]")).toHaveCount(0); + + await visitor.locator('[data-action="take"]').click(); + await expect(visitor.locator("[data-hold]")).toBeVisible(); + + // Same browser, later visit: the token persisted, so the hold is still theirs. + await visitor.reload(); + await expect(visitor.locator("[data-hold]")).toBeVisible(); + + await phone.close(); +}); + +// REQ-104: copy puts the buyer link on the clipboard. +test("copy puts the buyer link on the clipboard", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await startDrop(page, "organizer"); + + const link = await page.locator("[data-link]").inputValue(); + await page.locator('[data-action="copy"]').click(); + + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(link); +}); + +test("a buyer link to a drop that does not exist says so", async ({ page }) => { + await page.goto("/#/d/00000000-0000-0000-0000-000000000000"); + + await expect(page.locator(".empty__title")).toHaveText("This drop no longer exists"); + await expect(page.locator(".sidebar")).toBeHidden(); +}); diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts new file mode 100644 index 0000000..55ea75e --- /dev/null +++ b/e2e/tests/fixtures.ts @@ -0,0 +1,46 @@ +import { test as base, expect, type Page } from "@playwright/test"; + +/** + * Shared fixtures. Every test starts from a fresh browser context, with the third-party + * ecosystem bar and brand stylesheet blocked: they are not part of the product under test and + * would make runs depend on a network the suite does not control. + */ +export const test = base.extend<{ problems: string[] }>({ + context: async ({ context }, use) => { + await context.route(/naindev\.com/, (route) => route.abort()); + await use(context); + }, + + /** Console errors, page errors and missing-translation warnings seen during the test. */ + problems: async ({ page }, use) => { + const seen: string[] = []; + page.on("pageerror", (error) => seen.push(`pageerror: ${error.message}`)); + page.on("console", (message) => { + const text = message.text(); + const ignorable = /Failed to load resource|net::ERR_/.test(text); // 4xx/5xx and blocked hosts + if (text.includes("[i18n]") || (message.type() === "error" && !ignorable)) seen.push(text); + }); + await use(seen); + } +}); + +export { expect }; + +export type Role = "buyer" | "organizer"; + +/** Opens the landing and starts a drop as the given role, returning the drop id from its buyer link. */ +export const startDrop = async (page: Page, role: Role): Promise => { + await page.goto("/#/drop"); + await page.locator(`[data-start="${role}"]`).click(); + await page.locator(role === "buyer" ? ".buyer" : ".organizer").waitFor(); + + if (role === "buyer") await page.locator('[data-role="organizer"]').click(); + await page.locator("[data-link]").waitFor(); + const link = await page.locator("[data-link]").inputValue(); + if (role === "buyer") await page.locator('[data-role="buyer"]').click(); + + return link.split("/d/")[1]; +}; + +/** Every route of the panel that a visitor can reach from the menu. */ +export const ROUTES = ["drop", "panel", "catalogo", "pedidos", "carrera", "arquitectura"] as const; diff --git a/e2e/tests/landing.spec.ts b/e2e/tests/landing.spec.ts new file mode 100644 index 0000000..ac4ef7c --- /dev/null +++ b/e2e/tests/landing.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "./fixtures"; + +// REQ-090: the landing tells the story without words and offers one action per role. +test("the landing plays the story and offers one action per role", async ({ page }) => { + await page.goto("/#/drop"); + + await expect(page.locator(".story")).toBeVisible(); + await expect(page.locator(".story__caption")).toHaveCount(4); + await expect(page.locator('[data-start="buyer"]')).toBeVisible(); + await expect(page.locator('[data-start="organizer"]')).toBeVisible(); + + const animations = await page.evaluate(() => document.querySelector(".story")!.getAnimations({ subtree: true }).length); + expect(animations).toBeGreaterThan(0); +}); + +// REQ-096: with reduced motion the story shows its end state, still. +test.describe("reduced motion", () => { + test.use({ reducedMotion: "reduce" }); + + test("the story does not animate and shows only its last caption", async ({ page }) => { + await page.goto("/#/drop"); + await expect(page.locator(".story")).toBeVisible(); + + const animations = await page.evaluate(() => document.querySelector(".story")!.getAnimations({ subtree: true }).length); + expect(animations).toBe(0); + + const opacities = await page + .locator(".story__caption") + .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).opacity)); + expect(opacities).toEqual(["0", "0", "0", "1"]); + }); +}); diff --git a/e2e/tests/language.spec.ts b/e2e/tests/language.spec.ts new file mode 100644 index 0000000..2f1f59a --- /dev/null +++ b/e2e/tests/language.spec.ts @@ -0,0 +1,46 @@ +import { test, expect, ROUTES } from "./fixtures"; + +// REQ-094: Spanish and English, remembered, defaulting to the browser's language. +test.describe("browser in English", () => { + test.use({ locale: "en-GB" }); + + test("defaults to English, switches to Spanish and remembers the choice", async ({ page }) => { + await page.goto("/#/drop"); + await expect(page.locator(".landing__title")).toHaveText("Sell a drop without overselling"); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + + await page.locator('[data-lang="es"]').click(); + await expect(page.locator(".landing__title")).toHaveText("Vende un drop sin sobreventa"); + await expect(page.locator("html")).toHaveAttribute("lang", "es"); + + await page.reload(); + await expect(page.locator(".landing__title")).toHaveText("Vende un drop sin sobreventa"); + }); +}); + +test.describe("browser in Spanish", () => { + test.use({ locale: "es-ES" }); + + test("defaults to Spanish", async ({ page }) => { + await page.goto("/#/drop"); + await expect(page.locator(".landing__title")).toHaveText("Vende un drop sin sobreventa"); + }); +}); + +// REQ-095 at run time: no route shows a raw key or logs a missing-translation warning. +for (const language of ["es", "en"] as const) { + test(`every route renders in ${language} with no missing keys or errors`, async ({ page, problems }) => { + await page.goto("/#/drop"); + await page.locator(`[data-lang="${language}"]`).click(); + + for (const route of ROUTES) { + await page.goto(`/#/${route}`); + await expect(page.locator("#main-content")).not.toHaveAttribute("aria-busy", "true"); + } + + // A key shown instead of its text looks like `nav.drop` or `drop.status.Live`. + const text = await page.locator("body").innerText(); + expect(text).not.toMatch(/\b(?:nav|shell|drop|dash|catalog|orders|panel|race|arch|share)\.[a-zA-Z]+/); + expect(problems).toEqual([]); + }); +} diff --git a/e2e/tests/mobile.spec.ts b/e2e/tests/mobile.spec.ts new file mode 100644 index 0000000..2dd86e7 --- /dev/null +++ b/e2e/tests/mobile.spec.ts @@ -0,0 +1,44 @@ +import { test, expect, startDrop, ROUTES } from "./fixtures"; + +// REQ-102: usable at 360 px: no horizontal scroll, touch targets of at least 44 px. +test.use({ locale: "en-GB", viewport: { width: 360, height: 740 }, isMobile: true, hasTouch: true }); + +const overflow = (page: import("@playwright/test").Page) => + page.evaluate(() => document.scrollingElement!.scrollWidth - window.innerWidth); + +for (const route of ROUTES) { + test(`#/${route} does not scroll sideways at 360 px`, async ({ page }) => { + await page.goto(`/#/${route}`); + await expect(page.locator("#main-content")).not.toHaveAttribute("aria-busy", "true"); + await page.waitForTimeout(300); // let the view paint its first data + + expect(await overflow(page)).toBeLessThanOrEqual(0); + }); +} + +test("the buyer page and the organizer view fit and have large touch targets", async ({ page, browser }) => { + const id = await startDrop(page, "organizer"); + expect(await overflow(page)).toBeLessThanOrEqual(0); + + const visitor = await browser.newContext({ locale: "en-GB", viewport: { width: 360, height: 740 }, isMobile: true, hasTouch: true }); + await visitor.route(/naindev\.com/, (route) => route.abort()); + const phone = await visitor.newPage(); + + await phone.goto(`/#/d/${id}`); + await expect(phone.locator(".buyer")).toBeVisible(); + expect(await overflow(phone)).toBeLessThanOrEqual(0); + + const take = await phone.locator('[data-action="take"]').boundingBox(); + expect(take!.height).toBeGreaterThanOrEqual(44); + + await visitor.close(); +}); + +test("primary buttons reach 44 px on a touch screen", async ({ page }) => { + await page.goto("/#/drop"); + + for (const selector of ['[data-start="buyer"]', '[data-start="organizer"]', '[data-lang="en"]']) { + const box = await page.locator(selector).boundingBox(); + expect(box!.height, selector).toBeGreaterThanOrEqual(44); + } +}); diff --git a/e2e/tests/offline.spec.ts b/e2e/tests/offline.spec.ts new file mode 100644 index 0000000..b072100 --- /dev/null +++ b/e2e/tests/offline.spec.ts @@ -0,0 +1,57 @@ +import type { Page } from "@playwright/test"; +import { test, expect, startDrop } from "./fixtures"; + +test.use({ locale: "en-GB" }); + +/** Waits until the service worker is active and controls the page, so the shell is in the cache. */ +const waitForServiceWorker = async (page: Page) => { + await page.evaluate(() => navigator.serviceWorker.ready); + await page.reload(); + await page.waitForFunction(() => navigator.serviceWorker.controller !== null); +}; + +// REQ-100: the manifest the page links is served. +test("the page links a manifest that is served", async ({ page, request }) => { + await page.goto("/#/drop"); + const href = await page.locator('link[rel="manifest"]').getAttribute("href"); + + const response = await request.get(`/${href!.replace(/^\.\//, "")}`); + expect(response.ok()).toBe(true); + expect((await response.json()).display).toBe("standalone"); +}); + +// REQ-101: a reload with no network opens the interface from the cache and says so. +test("offline, the shell opens from the cache and says it is offline", async ({ page, context }) => { + await page.goto("/#/drop"); + await waitForServiceWorker(page); + + await context.setOffline(true); + await page.reload(); + + await expect(page.locator(".nav")).toBeVisible(); + await expect(page.locator("[data-offline-notice]")).toBeVisible(); + await expect(page.locator("[data-health]")).toContainText("unavailable"); + + await context.setOffline(false); + await expect(page.locator("[data-offline-notice]")).toBeHidden(); +}); + +// REQ-101: the API is never served from a cache, and purchases are visibly unavailable. +test("going offline mid-session disables purchases and the API is never faked", async ({ page, context }) => { + await startDrop(page, "buyer"); + await waitForServiceWorker(page); + await expect(page.locator('[data-action="take"]')).toBeVisible(); + + await context.setOffline(true); + + await expect(page.locator("[data-offline-notice]")).toBeVisible(); + const pointerEvents = await page.locator('[data-action="take"]').evaluate((node) => getComputedStyle(node).pointerEvents); + expect(pointerEvents).toBe("none"); + + // A request to the API fails instead of returning stale units. + const reachable = await page.evaluate(() => fetch("/api/drops").then(() => true, () => false)); + expect(reachable).toBe(false); + + await context.setOffline(false); + await expect(page.locator("[data-offline-notice]")).toBeHidden(); +}); diff --git a/e2e/tests/roles.spec.ts b/e2e/tests/roles.spec.ts new file mode 100644 index 0000000..65686ba --- /dev/null +++ b/e2e/tests/roles.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, startDrop } from "./fixtures"; + +test.use({ locale: "en-GB" }); + +const number = async (page: import("@playwright/test").Page, selector: string) => + Number((await page.locator(selector).innerText()).replace(/\D/g, "")); + +// REQ-091: the buyer sees what a buyer needs and nothing of the organizer's tools. +test("the buyer view shows the count, one action and no organizer tools", async ({ page }) => { + await startDrop(page, "buyer"); + + await expect(page.locator("[data-available]")).toBeVisible(); + await expect(page.locator('[data-action="take"]')).toBeVisible(); + await expect(page.locator("[data-grid]")).toHaveCount(0); + await expect(page.locator('[data-action="crowd"]')).toHaveCount(0); +}); + +// REQ-092: the organizer sees the units, the figures, the next expiry and can release a crowd. +test("the organizer view shows the grid, the figures and releases a crowd", async ({ page }) => { + await startDrop(page, "organizer"); + + await expect(page.locator("[data-grid] .drop__cell")).toHaveCount(48); + await expect(page.locator("[data-next]")).toBeVisible(); + await expect(page.locator("[data-qr] svg")).toBeVisible(); + + await page.locator('[data-action="crowd"]').click(); + + // The crowd's 40 buyers each hold one unit; what the panel shows must still add up to 48. + await expect.poll(async () => number(page, '[data-stats] [data-tone="win"] .numeric')).toBe(8); + const stats = page.locator("[data-stats] .numeric"); + const [available, held, sold] = await stats.allInnerTexts().then((values) => values.map(Number)); + expect(available + held + sold).toBe(48); + expect(held).toBe(40); +}); + +// REQ-093: switching role keeps the same drop. +test("switching between buyer and organizer keeps the same drop", async ({ page }) => { + await startDrop(page, "buyer"); + const product = await page.locator("[data-name]").innerText(); + + await page.locator('[data-role="organizer"]').click(); + await expect(page.locator(".organizer")).toBeVisible(); + await expect(page.locator("[data-name]")).toHaveText(product); + + await page.locator('[data-role="buyer"]').click(); + await expect(page.locator(".buyer")).toBeVisible(); + await expect(page.locator("[data-name]")).toHaveText(product); +}); + +// A purchase turns a hold into a sale the organizer sees. +test("a buyer's purchase appears as a sold unit for the organizer", async ({ page }) => { + await startDrop(page, "buyer"); + + await page.locator('[data-action="take"]').click(); + await expect(page.locator("[data-hold]")).toBeVisible(); + await page.locator('[data-action="confirm"]').click(); + await expect(page.locator("[data-hold]")).toBeHidden(); + + await page.locator('[data-role="organizer"]').click(); + await expect(page.locator('[data-stats] div:nth-child(3) .numeric')).toHaveText("1"); +}); From 99b0756770ab926e086d1477e4fc3843054ae79c Mon Sep 17 00:00:00 2001 From: naindev Date: Wed, 21 Oct 2026 21:15:04 +0200 Subject: [PATCH 2/2] docs: trace the visual requirements to the browser tests ADR-0011 (Proposed) records the choice of Playwright outside the panel. REQ-090 to REQ-096 and REQ-101 to REQ-105 move into the requirements with their tests; the traceability gap now lists only what is still not automated (installing on a physical phone). Runbook, README, changelog and the spec task lists are updated. --- README.md | 4 +- docs/10-requirements.md | 22 +++++++--- .../ADR-0011-browser-tests-with-playwright.md | 41 +++++++++++++++++++ docs/30-decisions/README.md | 1 + docs/50-traceability.md | 37 ++++++++++------- docs/60-runbook.md | 12 +++++- docs/90-changelog.md | 5 +++ specs/003-drop-experience/spec.md | 5 +-- specs/003-drop-experience/tasks.md | 2 +- specs/004-mobile-share/spec.md | 6 +-- 10 files changed, 105 insertions(+), 30 deletions(-) create mode 100644 docs/30-decisions/ADR-0011-browser-tests-with-playwright.md diff --git a/README.md b/README.md index 1150c6f..6af11cb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![ci](https://github.com/Nain9Dev/NainOrder/actions/workflows/ci.yml/badge.svg)](https://github.com/Nain9Dev/NainOrder/actions/workflows/ci.yml) [![CodeQL](https://github.com/Nain9Dev/NainOrder/actions/workflows/codeql.yml/badge.svg)](https://github.com/Nain9Dev/NainOrder/actions/workflows/codeql.yml) ![.NET 10](https://img.shields.io/badge/.NET-10-512BD4) -![Tests](https://img.shields.io/badge/tests-123%20passing-2ea44f) +![Tests](https://img.shields.io/badge/tests-123%20.NET%20%2B%2025%20browser-2ea44f) ![Client dependencies](https://img.shields.io/badge/client%20dependencies-0-22d3ee) [![Live demo](https://img.shields.io/badge/live%20demo-order.naindev.com-8b5cf6)](https://order.naindev.com/) @@ -94,6 +94,7 @@ The panel is plain ES modules served by the API itself: no npm, no build step, n | Stock is never oversold, even in a race | `StockRaceTests`, `ConcurrencyTests` | | Two writers on one order never lose an update | `OrderAggregateConcurrencyProbe` | | Domain has zero dependencies | `CleanArchitectureTests` | +| The QR code is the buyer link, the shell works offline, the phone layout fits | `e2e/` — 25 browser tests (Playwright) against the real app, QR decoded by an independent library | | The shipped container works | CI builds the image and probes it | Every requirement maps to a test in [`docs/50-traceability.md`](docs/50-traceability.md). @@ -112,6 +113,7 @@ and `/health`. No database to install: SQLite creates itself. ```bash dotnet test NainOrder.slnx -c Release # 123 tests +cd e2e && npm ci && npx playwright test # 25 browser tests (starts the app itself) docker build -t nainorder . && docker run -p 8080:8080 nainorder ``` diff --git a/docs/10-requirements.md b/docs/10-requirements.md index 588ab0e..a70a5b4 100644 --- a/docs/10-requirements.md +++ b/docs/10-requirements.md @@ -82,24 +82,34 @@ enters this table when it is implemented and tested. ## Drop experience -From [specs/003-drop-experience](../specs/003-drop-experience/spec.md). The visual requirements -(REQ-090 to REQ-094, REQ-096) live in that spec until browser-level tests exist; only what is -verified by an automated test is listed here. +From [specs/003-drop-experience](../specs/003-drop-experience/spec.md). The visual requirements are +verified in a real browser by `e2e/` ([ADR-0011](30-decisions/ADR-0011-browser-tests-with-playwright.md)). | ID | Requirement | | :--- | :--- | +| REQ-090 | When the panel opens on the Drop view with no drop selected, the system shall play a short wordless story — a crowd arrives, units are held, an unconfirmed hold expires, units are sold — and offer one action per role. | +| REQ-091 | Where the visitor chooses the buyer role, the panel shall show only what a buyer needs: the count, one action and their own hold. | +| REQ-092 | Where the visitor chooses the organizer role, the panel shall show the drop's units, the figures, the next hold to expire and the buyer link as a QR code, and shall let the organizer release a simulated crowd. | +| REQ-093 | The panel shall let the visitor switch between buyer and organizer on the same drop without losing it. | +| REQ-094 | The panel shall offer Spanish and English, remember the choice, and default to the browser's language. | | REQ-095 | Every user-visible string of the panel shall exist in both Spanish and English with the same placeholders, and a missing or empty one shall fail the build. | +| REQ-096 | Where the visitor prefers reduced motion, the landing story shall show its end state without animation. | | REQ-097 | The system shall report, with each drop, when its soonest live hold ends, or nothing when no hold is live. | ## Installable app -From [specs/004-mobile-share](../specs/004-mobile-share/spec.md). Behavioural requirements -(REQ-101 to REQ-105: offline shell, 360 px layout, buyer link, QR code) live in that spec until -browser-level tests exist; the QR encoder was checked against an independent decoder at development time. +From [specs/004-mobile-share](../specs/004-mobile-share/spec.md). The behavioural requirements are +verified in a real browser by `e2e/`; the QR code is decoded by an independent library +(`jsQR`) from a capture of the screen. | ID | Requirement | | :--- | :--- | | REQ-100 | The panel shall be installable: a web manifest with name, start URL, standalone display, theme colour and icons of 192 and 512 px, one of them maskable, and every icon present at its declared size. | +| REQ-101 | While the network is unavailable, the panel shall open its interface from a cache and say that it is offline; it shall never serve a cached API response, and purchases shall be visibly unavailable. | +| REQ-102 | The panel shall be usable at 360 px width: no horizontal page scroll, and touch targets of at least 44 px for the primary actions. | +| REQ-103 | When a visitor opens a drop's buyer link (`#/d/`), the panel shall show only the buyer page — no navigation, no top bar — and remember the visitor across visits; for a drop that does not exist it shall say so. | +| REQ-104 | When the organizer opens a drop, the panel shall offer its buyer link as a QR code and as copy and share actions. | +| REQ-105 | The QR code shall be generated in the browser without any third-party library or service, and shall encode the drop's buyer link exactly. | | REQ-106 | The service worker's precache list shall name every static asset of the panel and no file that does not exist, and the service worker shall never handle `/api` or a non-GET request. | ## HTTP contract diff --git a/docs/30-decisions/ADR-0011-browser-tests-with-playwright.md b/docs/30-decisions/ADR-0011-browser-tests-with-playwright.md new file mode 100644 index 0000000..118a92d --- /dev/null +++ b/docs/30-decisions/ADR-0011-browser-tests-with-playwright.md @@ -0,0 +1,41 @@ +# ADR-0011 — Browser tests with Playwright, outside the panel + +**Status:** Proposed + +## Context + +The panel's visual behaviour — the landing story, the buyer and organizer views, the language +selector, the QR code, the offline shell, the phone layout — was verified by hand and listed as a gap +in [50-traceability.md](../50-traceability.md). A gap that depends on someone remembering to look is +not a guarantee. The panel itself has no build step and no dependency +([ADR-0006](ADR-0006-dependency-free-client-panel.md)), so any test tooling must stay out of it. + +## Decision + +Add a self-contained `e2e/` folder with its own `package.json`, using Playwright Test: + +- **Dev-only.** Nothing in `e2e/` ships, and the panel gains no dependency or build step. The .NET + solution does not reference it. +- **Real application.** Playwright starts `NainOrder.Api` itself against a throwaway SQLite file and + drives a real browser: the same path a visitor takes. +- **Independent QR decoder.** `jsQR` decodes the QR code captured from the screen, so the in-repo + encoder is checked by code it does not share. +- **Own CI job**, separate from the .NET job, so a browser flake never hides a unit-test result. +- **Pinned and audited.** Dependencies are pinned by `package-lock.json`; Dependabot watches `e2e/` + and CI runs `npm audit` on it. + +## Alternatives considered + +- **Keep verifying by hand.** Rejected: it is how a regression ships unnoticed. +- **A .NET browser-test library (Playwright for .NET).** Considered; rejected because the JavaScript + runner has better tracing and a lighter CI setup, and the tests describe a JavaScript panel. +- **Unit-testing the panel's modules in Node.** Rejected: the panel's risk is in the browser + (service worker, layout, touch), which a DOM shim does not reproduce. + +## Consequences + +- Contributors need Node only to run `e2e/`; `dotnet test` stays the whole story for the backend. +- CI installs a browser for this job (a few minutes) and uploads Playwright's trace on failure. +- The service-worker and offline tests need a secure context; `localhost` qualifies, so no TLS setup + is needed. +- Tests must wait on observable state, never on fixed sleeps, to stay stable. diff --git a/docs/30-decisions/README.md b/docs/30-decisions/README.md index ddcc850..d1860c9 100644 --- a/docs/30-decisions/README.md +++ b/docs/30-decisions/README.md @@ -14,3 +14,4 @@ Records of significant architectural and design decisions for NainOrder. | [ADR-0008](ADR-0008-drops-with-expiring-holds.md) | Drops with expiring holds | Approved | | [ADR-0009](ADR-0009-sqlite-wal-mode.md) | SQLite in WAL mode | Proposed | | [ADR-0010](ADR-0010-service-worker-shell-cache.md) | Service worker that caches the shell, never the API | Proposed | +| [ADR-0011](ADR-0011-browser-tests-with-playwright.md) | Browser tests with Playwright, outside the panel | Proposed | diff --git a/docs/50-traceability.md b/docs/50-traceability.md index 8bdeab1..0a2beb2 100644 --- a/docs/50-traceability.md +++ b/docs/50-traceability.md @@ -1,6 +1,6 @@ # Traceability -**Status:** Active · verified against a green run of `dotnet test NainOrder.slnx -c Release` (123/123 tests) +**Status:** Active · verified against green runs of `dotnet test NainOrder.slnx -c Release` (123/123 tests) and `npx playwright test` in `e2e/` (25/25 browser tests) Every requirement in [10-requirements.md](10-requirements.md) and the test that demonstrates it. A requirement with no test is not done, whatever the code says. @@ -17,6 +17,7 @@ Test suites: | `Api.OrderAggregateConcurrencyProbe` | Concurrency, aggregate | Multi-context concurrent aggregate mutations and optimistic concurrency conflict enforcement | | `Domain.DropTests` | Example-based + property-based | The drop aggregate with an explicit clock: holds, expiry, limits, the held-plus-sold invariant | | `Api.DropFlowTests` | Integration, concurrent | Drops over HTTP with a hand-moved clock, a 40-buyer crowd and UTC serialisation | +| `e2e/tests/*.spec.ts` | Browser (Playwright), real application | Landing, roles, language, buyer link and QR, offline shell, 360 px layout | | `Api.PwaAssetsTests` | Contract | Manifest, icons and service-worker precache list match the files served | | `Api.I18nParityTests` | Contract | Both panel languages define the same keys and placeholders | | `Api.StockRaceTests` | Integration, concurrent | The on-demand race: many buyers, few units, verdict from persisted stock | @@ -97,7 +98,13 @@ Test suites: | Requirement | Test | | :--- | :--- | -| REQ-095 | `I18nParityTests.Spanish_and_English_define_exactly_the_same_keys`, `No_string_is_empty`, `Placeholders_match_between_languages` | +| REQ-090 | `e2e/tests/landing.spec.ts` › the landing plays the story and offers one action per role | +| REQ-091 | `e2e/tests/roles.spec.ts` › the buyer view shows the count, one action and no organizer tools | +| REQ-092 | `e2e/tests/roles.spec.ts` › the organizer view shows the grid, the figures and releases a crowd (also asserts available + held + sold = 48 as the panel shows it) | +| REQ-093 | `e2e/tests/roles.spec.ts` › switching between buyer and organizer keeps the same drop; a buyer's purchase appears as a sold unit for the organizer | +| REQ-094 | `e2e/tests/language.spec.ts` › defaults to English, switches to Spanish and remembers the choice; defaults to Spanish | +| REQ-095 | `I18nParityTests.Spanish_and_English_define_exactly_the_same_keys`, `No_string_is_empty`, `Placeholders_match_between_languages`; at run time `e2e/tests/language.spec.ts` › every route renders in es / en with no missing keys or errors | +| REQ-096 | `e2e/tests/landing.spec.ts` › reduced motion › the story does not animate and shows only its last caption | | REQ-097 | `DropTests.The_next_expiry_is_the_soonest_live_hold_and_null_when_none_is_live`; `DropFlowTests.A_drop_reports_when_its_next_hold_expires` | ## Installable app @@ -105,6 +112,11 @@ Test suites: | Requirement | Test | | :--- | :--- | | REQ-100 | `PwaAssetsTests.The_manifest_declares_what_a_browser_needs_to_install_the_panel`, `The_manifest_has_192_and_512_icons_and_one_of_them_is_maskable`, `Every_icon_exists_and_is_a_png_of_the_declared_size`, `The_page_links_the_manifest_and_declares_its_colours_and_touch_icon` | +| REQ-101 | `e2e/tests/offline.spec.ts` › offline, the shell opens from the cache and says it is offline; going offline mid-session disables purchases and the API is never faked | +| REQ-102 | `e2e/tests/mobile.spec.ts` › every route does not scroll sideways at 360 px; the buyer page and the organizer view fit and have large touch targets; primary buttons reach 44 px on a touch screen | +| REQ-103 | `e2e/tests/buyer-link.spec.ts` › the buyer link shows the buyer page alone and remembers the visitor; a buyer link to a drop that does not exist says so | +| REQ-104 | `e2e/tests/buyer-link.spec.ts` › the QR code decodes to the buyer link; copy puts the buyer link on the clipboard | +| REQ-105 | `e2e/tests/buyer-link.spec.ts` › the QR code decodes to the buyer link (decoded by `jsQR`, independent of the encoder); the encoder is `assets/js/qr.js`, with no dependency | | REQ-106 | `PwaAssetsTests.Every_precache_entry_points_at_a_file_that_exists`, `Every_static_asset_of_the_panel_is_precached`, `The_service_worker_never_handles_the_api_or_non_get_requests` | ## HTTP contract @@ -128,17 +140,14 @@ Test suites: ## Gaps -Every requirement declared in [10-requirements.md](10-requirements.md) is covered by an automated test passing in the Release suite. +Every requirement declared in [10-requirements.md](10-requirements.md) is covered by an automated test: +the .NET suite (`dotnet test`) or the browser suite (`e2e/`, CI job `Browser tests`). -One known gap, deliberately outside that file: the visual behaviour of the panel in -[specs/003-drop-experience](../specs/003-drop-experience/spec.md) (REQ-090 to REQ-094, REQ-096: -landing story, role lenses, language selector, reduced motion). It was verified by hand in a browser -against the real API and the in-browser engine, in both languages, with no console errors or missing -translation keys; there is no browser-level test yet (task T-056). +What is still not automated, and honestly so: -A second one, from [specs/004-mobile-share](../specs/004-mobile-share/spec.md) (REQ-101 to REQ-105): -the offline shell, the 360 px layout, the standalone buyer page and the QR code. Verified by hand -against the real API with a phone profile and with the network switched off; the QR encoder's -output was decoded by an independent decoder (`jsQR`, run outside the repository) for seven inputs -from version 2 to 10, and a generated code on screen decoded back to its link. The same task T-056 -covers them. +- **Installation on a physical phone and over HTTPS** (Android Chrome, iOS "Add to Home Screen"). + The suite proves the manifest, the icons, the service worker and the offline shell; it cannot + press the install button of a real device (tasks T-066 and T-067, which need the owner). +- **The QR encoder across every version.** The suite decodes the codes the panel actually shows + (a link of about 65 characters, version 5); seven inputs from version 2 to 10 were also decoded by `jsQR` + during development. A property test over many inputs is possible and not yet written. diff --git a/docs/60-runbook.md b/docs/60-runbook.md index 2be8c9a..228bd69 100644 --- a/docs/60-runbook.md +++ b/docs/60-runbook.md @@ -24,8 +24,16 @@ dotnet build NainOrder.slnx -c Release && dotnet test NainOrder.slnx -c Release ``` `TreatWarningsAsErrors` is on in all four projects, so a new warning fails the build. Changes to -`NainOrder.Api/wwwroot` have no build step and must be checked in a browser against a running -instance. +`NainOrder.Api/wwwroot` have no build step; the browser suite checks them against the real +application: + +```bash +cd e2e && npm ci && npx playwright install chromium && npx playwright test +``` + +Playwright starts the API itself on port 8180 with a throwaway database. To reuse an installed +browser, set `PW_CHANNEL=msedge` (or `chrome`). On a failure, `npx playwright show-trace` opens the +recorded trace ([ADR-0011](30-decisions/ADR-0011-browser-tests-with-playwright.md)). ## Container diff --git a/docs/90-changelog.md b/docs/90-changelog.md index 53b77af..01bbed4 100644 --- a/docs/90-changelog.md +++ b/docs/90-changelog.md @@ -8,6 +8,11 @@ Notable changes. Format loosely follows Keep a Changelog; dates come from git. ### Added +- Browser tests (`e2e/`, ADR-0011): 25 Playwright tests against the real application cover the + landing, the buyer and organizer views, the language selector, the buyer link and its QR code + (decoded by an independent library), the offline shell and the 360 px layout. They run in their own + CI job; the visual requirements REQ-090 to REQ-096 and REQ-101 to REQ-105 are now traced to tests. + - Mobile, install and share (`specs/004-mobile-share`, ADR-0010): the panel is an installable app (manifest, icons, service worker that caches the shell and never the API), shows when it is offline, and works at 360 px. An organizer gets a QR code, copy and share for the drop's buyer diff --git a/specs/003-drop-experience/spec.md b/specs/003-drop-experience/spec.md index 384b32f..55fb199 100644 --- a/specs/003-drop-experience/spec.md +++ b/specs/003-drop-experience/spec.md @@ -29,9 +29,8 @@ matters, without reading: the product explains itself through motion and the res ## Status of verification -REQ-095 and REQ-097 have automated tests. REQ-090 to REQ-094 and REQ-096 are visual and were -verified by hand in a browser (real API and in-browser engine, both languages); a browser-level -test is task T-056. +Every requirement has an automated test: REQ-095 and REQ-097 in the .NET suite, REQ-090 to REQ-096 +in the browser suite (`e2e/`, [ADR-0011](../../docs/30-decisions/ADR-0011-browser-tests-with-playwright.md)). ## Non-goals diff --git a/specs/003-drop-experience/tasks.md b/specs/003-drop-experience/tasks.md index 801051c..c05d78a 100644 --- a/specs/003-drop-experience/tasks.md +++ b/specs/003-drop-experience/tasks.md @@ -8,7 +8,7 @@ | T-053 | Buyer and organizer lenses | `[A]` | ✅ Verified in a browser on the real API (REQ-091 to REQ-093, REQ-097) | | T-054 | Translate Dashboard, Catalogue, Orders and the order panel | `[A]` | ✅ Every route in both languages: no console errors, no missing keys | | T-055 | README capture (animated) | `[A]` | ✅ `docs/assets/drop-story.gif` and a live-drop capture | -| T-056 | Browser-level tests for the visual requirements | `[M]` | Playwright decision (a dev dependency) approved by the owner; REQ-090 to REQ-094, REQ-096 move into `docs/10-requirements.md` | +| T-056 | Browser-level tests for the visual requirements | `[M]` | ✅ `e2e/` with Playwright (ADR-0011, Proposed); REQ-090 to REQ-094, REQ-096 and REQ-101 to REQ-105 are in `docs/10-requirements.md` with their tests | | T-057 | Animated request map in the Architecture view (a purchase travelling through the layers) | `[A]` | The four layers light up in order for a real request | ## Handoff diff --git a/specs/004-mobile-share/spec.md b/specs/004-mobile-share/spec.md index e96c640..5d6d75b 100644 --- a/specs/004-mobile-share/spec.md +++ b/specs/004-mobile-share/spec.md @@ -25,9 +25,9 @@ stuck in abandoned carts, and nothing for the audience to install or sign up for ## Verification status -REQ-100 and the offline cache list have automated tests. REQ-101 to REQ-105 are behavioural and were -verified by hand in a browser (desktop and a 375 px mobile viewport, offline mode, a QR decoder run -at development time); browser-level tests remain task T-056 of spec 003. +Every requirement has an automated test: REQ-100 and REQ-106 in the .NET suite, REQ-101 to REQ-105 +in the browser suite (`e2e/`), where the QR code is decoded by `jsQR` from a capture of the screen. +Installing on a physical phone is not automated (T-066, T-067). ## Non-goals