Merge pull request #708 from AssetOverflow/feat/wb-r0b-playwright

test(workbench): playwright smoke lane (ADR-0162 acceptance 5-7)
This commit is contained in:
Shay 2026-06-12 12:16:42 -07:00 committed by GitHub
commit d2247ac08a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 314 additions and 1 deletions

View file

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

4
.gitignore vendored
View file

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

View file

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

View file

@ -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([]);
});

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
@ -7,6 +7,8 @@ export default defineConfig({
environment: "happy-dom",
setupFiles: ["./src/test/setup.ts"],
globals: true,
// e2e/ belongs to Playwright (pnpm test:e2e), not vitest.
exclude: [...configDefaults.exclude, "e2e/**"],
// Fail fast instead of hanging to a CI wall (Wave R brief R0a).
// A hung worker at teardown was previously an indefinite hang; these
// caps convert any residual live-handle leak into a loud failure.