From c356a13055b45632391d1a3e5e6d8a6bede98cb0 Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 12 Jun 2026 11:26:28 -0700 Subject: [PATCH] test(workbench): playwright smoke lane (ADR-0162 acceptance 5-7) --- .github/workflows/workbench-ui.yml | 45 +++++++++++ .gitignore | 4 + workbench-ui/e2e/palette.spec.ts | 52 +++++++++++++ workbench-ui/e2e/preview-offline.spec.ts | 42 ++++++++++ workbench-ui/e2e/reduced-motion.spec.ts | 99 ++++++++++++++++++++++++ workbench-ui/package.json | 2 + workbench-ui/playwright.config.ts | 29 +++++++ workbench-ui/pnpm-lock.yaml | 38 +++++++++ 8 files changed, 311 insertions(+) create mode 100644 workbench-ui/e2e/palette.spec.ts create mode 100644 workbench-ui/e2e/preview-offline.spec.ts create mode 100644 workbench-ui/e2e/reduced-motion.spec.ts create mode 100644 workbench-ui/playwright.config.ts diff --git a/.github/workflows/workbench-ui.yml b/.github/workflows/workbench-ui.yml index 2cf67b33..53a77c1a 100644 --- a/.github/workflows/workbench-ui.yml +++ b/.github/workflows/workbench-ui.yml @@ -4,6 +4,10 @@ name: workbench-ui # workbench-ui changes merged with no frontend CI at all — smoke.yml and # full-pytest.yml are Python-only. # +# The e2e job is the Playwright smoke lane (Wave R brief R0b) — ADR-0162 +# acceptance criteria 5-7. Separate job so vitest results are not hostage +# to browser install. +# # The path filter includes this workflow file itself so changes to the # lane are validated by the lane. @@ -59,3 +63,44 @@ jobs: - name: vitest (full suite must pass AND exit) run: pnpm test + + e2e: + name: playwright smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: workbench-ui + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: set up pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: workbench-ui/package.json + + - name: set up node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: workbench-ui/pnpm-lock.yaml + + - name: install dependencies + run: pnpm install --frozen-lockfile + + - name: cache playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('workbench-ui/pnpm-lock.yaml') }} + + - name: install chromium + run: pnpm exec playwright install --with-deps chromium + + - name: build (tsc + vite) + run: pnpm build + + - name: playwright smoke (ADR-0162 acceptance 5-7) + run: pnpm test:e2e diff --git a/.gitignore b/.gitignore index 8996afa4..3498f48d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ uv.lock reports/ frontier_wave1.json +# Workbench UI browser-test artifacts +workbench-ui/test-results/ +workbench-ui/playwright-report/ + # Claude Code local session artifacts (per-developer, never tracked) .claude/ diff --git a/workbench-ui/e2e/palette.spec.ts b/workbench-ui/e2e/palette.spec.ts new file mode 100644 index 00000000..ea256b81 --- /dev/null +++ b/workbench-ui/e2e/palette.spec.ts @@ -0,0 +1,52 @@ +import { expect, test, type Page } from "@playwright/test"; + +const API_ORIGIN = "http://127.0.0.1:8765"; + +const APP_ROUTES = [ + { label: "Chat", path: "/chat", command: "Open Chat" }, + { label: "Trace", path: "/trace", command: "Open Trace" }, + { label: "Replay", path: "/replay", command: "Open Replay" }, + { label: "Proposals", path: "/proposals", command: "Open Proposals" }, + { label: "Evals", path: "/evals", command: "Open Evals" }, + { label: "Runs", path: "/runs", command: "Open Runs" }, + { label: "Packs", path: "/packs", command: "Open Packs" }, + { label: "Vault", path: "/vault", command: "Open Vault" }, + { label: "Audit", path: "/audit", command: "Open Audit" }, + { label: "Settings", path: "/settings", command: "Open Settings" }, +] as const; + +async function makeBackendAbsent(page: Page) { + await page.route(`${API_ORIGIN}/**`, (route) => route.abort("failed")); +} + +async function expectUsableRoute(page: Page) { + const main = page.locator('[data-region="main"]'); + await expect(main).toBeVisible(); + await expect(main).not.toHaveText(/^\s*$/); +} + +async function openPalette(page: Page) { + await page.keyboard.press("ControlOrMeta+K"); + await expect(page.getByRole("dialog", { name: "Command Palette" })).toBeVisible(); +} + +test.describe("command palette route smoke", () => { + test.beforeEach(async ({ page }) => { + await makeBackendAbsent(page); + }); + + for (const startRoute of APP_ROUTES) { + test(`opens with Meta+K and reaches every route from ${startRoute.label}`, async ({ page }) => { + for (const targetRoute of APP_ROUTES) { + await page.goto(startRoute.path); + await expectUsableRoute(page); + + await openPalette(page); + await page.getByRole("button", { name: targetRoute.command }).click(); + + await expect(page).toHaveURL(new RegExp(`${targetRoute.path}$`)); + await expectUsableRoute(page); + } + }); + } +}); diff --git a/workbench-ui/e2e/preview-offline.spec.ts b/workbench-ui/e2e/preview-offline.spec.ts new file mode 100644 index 00000000..08142764 --- /dev/null +++ b/workbench-ui/e2e/preview-offline.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; + +const PRIMITIVE_SECTIONS = [ + "Primitives", + "Badges", + "States", + "SplitPane", + "TabBar", + "MetadataTable", + "DigestBadge", + "Timestamp", + "SearchInput", + "Stable JSON Viewer", +] as const; + +const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1"]); + +test("preview renders every primitive section with external network blocked", async ({ context, page }) => { + const blockedExternalRequests: string[] = []; + + await context.route("**", async (route) => { + const url = new URL(route.request().url()); + const isLocal = LOCAL_HOSTNAMES.has(url.hostname); + if (isLocal || url.protocol === "data:" || url.protocol === "blob:") { + await route.continue(); + return; + } + + blockedExternalRequests.push(route.request().url()); + await route.abort("blockedbyclient"); + }); + + await page.goto("/preview"); + + await expect( + page.getByRole("heading", { name: "CORE Workbench Design System v1" }), + ).toBeVisible(); + for (const sectionName of PRIMITIVE_SECTIONS) { + await expect(page.getByRole("heading", { name: sectionName })).toBeVisible(); + } + expect(blockedExternalRequests).toEqual([]); +}); diff --git a/workbench-ui/e2e/reduced-motion.spec.ts b/workbench-ui/e2e/reduced-motion.spec.ts new file mode 100644 index 00000000..2570e120 --- /dev/null +++ b/workbench-ui/e2e/reduced-motion.spec.ts @@ -0,0 +1,99 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +const API_ORIGIN = "http://127.0.0.1:8765"; + +const runtimeStatus = { + backend: "numpy", + git_revision: "abcdef1234567890", + engine_state_present: true, + checkpoint_revision: "deadbeef12345678", + revision_warning: false, + active_session_id: null, + mutation_mode: "read_only", +}; + +const chatTurn = { + prompt: "What is truth?", + surface: "Truth is what is true. pack-grounded (en_core_cognition_v1).", + articulation_surface: "Truth is what is true.", + walk_surface: "truth -> true", + grounding_source: "pack", + epistemic_state: "decoded", + normative_clearance: "cleared", + normative_detail: "", + trace_hash: "sha256:0123456789abcdef0123456789abcdef", + refusal_emitted: false, + hedge_injected: false, + mutation_mode: "runtime_turn", + identity_verdict: { outcome: "cleared", runtime_detail: "" }, + safety_verdict: { outcome: "cleared", runtime_detail: "" }, + ethics_verdict: { outcome: "cleared", runtime_detail: "" }, + proposal_candidates: [{ candidate_id: "cand_123", source_kind: "discovery" }], + turn_cost_ms: 42, + checkpoint_emitted: true, +}; + +function ok(data: unknown) { + return { + ok: true, + generated_at: "2026-06-12T00:00:00Z", + data, + }; +} + +async function stubChatBackend(page: Page) { + await page.route(`${API_ORIGIN}/**`, async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/runtime/status") { + await route.fulfill({ json: ok(runtimeStatus) }); + return; + } + if (url.pathname === "/chat/turn") { + await route.fulfill({ json: ok(chatTurn) }); + return; + } + await route.abort("failed"); + }); +} + +async function expectInstantMotion(locator: Locator) { + const durations = await locator.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + animationDuration: style.animationDuration, + transitionDuration: style.transitionDuration, + }; + }); + + for (const durationList of Object.values(durations)) { + for (const duration of durationList.split(",")) { + expect(duration.trim()).toBe("0s"); + } + } +} + +test.use({ reducedMotion: "reduce" }); + +test("reduced motion collapses palette and drawer durations to instant", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await stubChatBackend(page); + await page.goto("/chat"); + expect( + await page.evaluate(() => window.matchMedia("(prefers-reduced-motion: reduce)").matches), + ).toBe(true); + + await page.keyboard.press("ControlOrMeta+K"); + const palette = page.getByRole("dialog", { name: "Command Palette" }); + await expect(palette).toBeVisible(); + await expectInstantMotion(palette); + await page.keyboard.press("Escape"); + + await page.getByPlaceholder("Ask CORE a question...").fill("What is truth?"); + await page.getByRole("button", { name: "Submit" }).click(); + await expect(page.getByText("Truth is what is true.")).toBeVisible(); + + await page.getByRole("button", { name: "Open trace drawer" }).click(); + const drawer = page.getByRole("dialog", { name: "Turn trace" }); + await expect(drawer).toBeVisible(); + await expectInstantMotion(drawer); +}); diff --git a/workbench-ui/package.json b/workbench-ui/package.json index 1a193791..33cd5e52 100644 --- a/workbench-ui/package.json +++ b/workbench-ui/package.json @@ -9,6 +9,7 @@ "dev": "vite --host 127.0.0.1", "preview": "vite preview --host 127.0.0.1", "test": "vitest run", + "test:e2e": "playwright test", "test:enum-coverage": "pnpm enum:snapshot && vitest run src/design/components/badges/enumCoverage.test.ts", "generate:tokens": "tsx scripts/generate-tokens.ts", "enum:snapshot": "cd .. && uv run python scripts/dump-enums.py > workbench-ui/enum-snapshot.json" @@ -27,6 +28,7 @@ "tailwind-merge": "2.6.0" }, "devDependencies": { + "@playwright/test": "1.60.0", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", diff --git a/workbench-ui/playwright.config.ts b/workbench-ui/playwright.config.ts new file mode 100644 index 00000000..ba105d5f --- /dev/null +++ b/workbench-ui/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 4173); +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { + timeout: 5_000, + }, + reporter: process.env.CI ? [["github"], ["list"]] : "list", + use: { + baseURL, + trace: "on-first-retry", + }, + webServer: { + command: `pnpm build && pnpm exec vite preview --host 127.0.0.1 --port ${port}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/workbench-ui/pnpm-lock.yaml b/workbench-ui/pnpm-lock.yaml index 1f625a9e..c54cba3f 100644 --- a/workbench-ui/pnpm-lock.yaml +++ b/workbench-ui/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: specifier: 2.6.0 version: 2.6.0 devDependencies: + '@playwright/test': + specifier: 1.60.0 + version: 1.60.0 '@testing-library/jest-dom': specifier: 6.6.3 version: 6.6.3 @@ -509,6 +512,11 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -1190,6 +1198,11 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1374,6 +1387,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -2029,6 +2052,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -2673,6 +2700,9 @@ snapshots: fraction.js@4.3.7: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2803,6 +2833,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + postcss-import@15.1.0(postcss@8.4.49): dependencies: postcss: 8.4.49