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:
commit
d2247ac08a
9 changed files with 314 additions and 1 deletions
45
.github/workflows/workbench-ui.yml
vendored
45
.github/workflows/workbench-ui.yml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
52
workbench-ui/e2e/palette.spec.ts
Normal file
52
workbench-ui/e2e/palette.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
42
workbench-ui/e2e/preview-offline.spec.ts
Normal file
42
workbench-ui/e2e/preview-offline.spec.ts
Normal 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([]);
|
||||
});
|
||||
99
workbench-ui/e2e/reduced-motion.spec.ts
Normal file
99
workbench-ui/e2e/reduced-motion.spec.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
29
workbench-ui/playwright.config.ts
Normal file
29
workbench-ui/playwright.config.ts
Normal 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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue