This commit is contained in:
75
tests/vision/README.md
Normal file
75
tests/vision/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Vision-Based E2E Harness
|
||||
|
||||
A **vision** gate for the Waggle desktop-OS UI: a model judges screenshots for
|
||||
*meaning* ("does this actually look and work right?"), not pixels. It catches
|
||||
what the pixel-diff suite (`tests/visual/views.spec.ts`, `toHaveScreenshot`)
|
||||
structurally cannot — e.g. a baseline that is the *wrong content* but pixel-
|
||||
stable. Design + rationale: [`docs/audits/2026-06-01-vision-e2e-harness-design.md`](../../docs/audits/2026-06-01-vision-e2e-harness-design.md).
|
||||
|
||||
Architecture: **Option C (hybrid)** — deterministic Playwright capture → a
|
||||
multi-agent Workflow grades meaning → a reducer cross-checks the vision verdict
|
||||
against objective console signals (a vision-PASS with a real console error is
|
||||
downgraded to FAIL — the "objective floor").
|
||||
|
||||
## Two phases
|
||||
|
||||
### 1. Capture — `capture.spec.ts`
|
||||
Drives the real shell deterministically (`?skipOnboarding=true&tier=power`,
|
||||
`openAppViaDock` with Ops/Extend zone trays, `data-theme` for light/dark) across
|
||||
17 surfaces × {dark,light} + memory + 2 overlays + 4 flows (42 captures). Per
|
||||
surface it writes `artifacts/<surface>-<theme>.png` + `<surface>-<theme>.json`
|
||||
(`{expectation, consoleErrors[], networkFailures[]}`).
|
||||
|
||||
Chat round-trip is graded on **Path 2 (real LLM)** per the product decision, so
|
||||
the capture server must run **without** `--skip-litellm` (a real provider key in
|
||||
the vault → the anthropic-proxy returns real replies):
|
||||
|
||||
```bash
|
||||
# 1. build (packages + web) and start a real-LLM server on :3333
|
||||
npm run build:all
|
||||
WAGGLE_TRUST_LOCALHOST=1 node --env-file=.env \
|
||||
node_modules/tsx/dist/cli.mjs packages/server/src/local/start.ts # NO --skip-litellm
|
||||
# 2. capture (reuses the running :3333 server)
|
||||
npx playwright test tests/vision/capture.spec.ts
|
||||
```
|
||||
|
||||
### 2. Judge — `scripts/vision-judge-workflow.mjs` (run via the Workflow tool)
|
||||
(Lives under `scripts/` — Workflow scripts use top-level `return`/`await` + injected globals, so they're not standard ES modules and `scripts/**` is ESLint-ignored.)
|
||||
One independent vision-judge subagent per screenshot (each **Reads** the PNG —
|
||||
that is the vision step) grades the 5-dimension rubric; the reducer applies the
|
||||
objective floor and an agent writes `artifacts/vision-report.md`.
|
||||
|
||||
```js
|
||||
// assemble the manifest from the capture sidecars, then:
|
||||
Workflow({
|
||||
scriptPath: "scripts/vision-judge-workflow.mjs",
|
||||
args: { captures: [ { surface, png, expectation, consoleErrors } /* … */ ] }
|
||||
})
|
||||
```
|
||||
|
||||
The manifest is the `artifacts/*.json` sidecars merged with their PNG paths
|
||||
(one `{surface, png, expectation, consoleErrors}` per capture). `args` may be a
|
||||
JSON object or string — the script accepts both.
|
||||
|
||||
## Rubric (per surface)
|
||||
`renders_correctly` · `no_error_state` · `flow_completes` · `theme_legible`
|
||||
(vision-graded) + `no_console_errors` (objective, from the capture driver).
|
||||
FAIL if any vision dimension fails at confidence ≥ 0.7 **or** a real console
|
||||
error is present; WARN at 0.4–0.7 (human spot-check, never auto-blocks).
|
||||
|
||||
## Status
|
||||
- **Capture layer**: structurally verified (`playwright --list` → 42 tests).
|
||||
- **Judge workflow + reducer + report**: **proven end-to-end** against real
|
||||
Waggle screenshots (6 agents, accurate verdicts, report written).
|
||||
- **First-run finding**: the `Visual-Regression — Dark Mode` baselines under
|
||||
`tests/visual/baselines/` are **404 error pages**, not Waggle UI (verified) —
|
||||
the pixel-diff visual suite has been comparing against garbage. Re-baseline
|
||||
once the app serves correctly. The one genuine UI tested (`settings-light`)
|
||||
graded PASS at 0.95.
|
||||
- **Pending**: the full live capture→judge run (local sidecar is blocked by a
|
||||
tsx/esbuild version skew on this Windows box — runs in CI Linux / a clean env).
|
||||
|
||||
## Caveats
|
||||
- `tests/vision/` is **not** wired into any CI gate (it's outside the e2e/visual
|
||||
lanes), so it's inert until invoked explicitly.
|
||||
- `artifacts/` is gitignored (regenerated per run).
|
||||
179
tests/vision/_helpers.ts
Normal file
179
tests/vision/_helpers.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Vision-harness shared helpers.
|
||||
*
|
||||
* Extracted from the proven (but copy-pasted) navigation idioms in
|
||||
* tests/e2e/full-product-audit.spec.ts + the theme contract documented in
|
||||
* docs/audits/2026-06-01-vision-e2e-harness-design.md. Centralised here so the
|
||||
* capture spec drives the real desktop-OS shell deterministically.
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
|
||||
|
||||
/** Console errors / pageerrors / failed requests that are environmental noise,
|
||||
* not product defects (mirrors full-product-audit.spec.ts:312). */
|
||||
const BENIGN = [
|
||||
'Failed to fetch', 'net::ERR', 'favicon', '401', '404', 'sync',
|
||||
'WebSocket', 'fetch', 'chunk', 'ResizeObserver',
|
||||
];
|
||||
|
||||
export interface ConsoleCapture {
|
||||
errors: string[];
|
||||
pageErrors: string[];
|
||||
networkFailures: string[];
|
||||
/** Console errors with environmental noise filtered out. */
|
||||
critical(): string[];
|
||||
}
|
||||
|
||||
/** Attach BEFORE navigation so nothing is missed. */
|
||||
export function attachConsoleCapture(page: Page): ConsoleCapture {
|
||||
const cap: ConsoleCapture = {
|
||||
errors: [],
|
||||
pageErrors: [],
|
||||
networkFailures: [],
|
||||
critical() {
|
||||
return this.errors.filter((e) => !BENIGN.some((b) => e.includes(b)));
|
||||
},
|
||||
};
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') cap.errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => cap.pageErrors.push(err.message));
|
||||
page.on('requestfailed', (req) => {
|
||||
const url = req.url();
|
||||
if (!BENIGN.some((b) => url.includes(b))) {
|
||||
cap.networkFailures.push(`${req.method()} ${url} — ${req.failure()?.errorText ?? 'failed'}`);
|
||||
}
|
||||
});
|
||||
return cap;
|
||||
}
|
||||
|
||||
/** Dismiss the onboarding / "Start Working" overlay if present (3 attempts). */
|
||||
export async function dismissOverlay(page: Page): Promise<void> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const overlay = page.locator('.fixed.backdrop-blur-sm');
|
||||
if (!(await overlay.isVisible({ timeout: 1000 }).catch(() => false))) break;
|
||||
const startBtn = page.locator('button:has-text("Start Working")');
|
||||
if (await startBtn.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
await startBtn.click({ force: true });
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
await page.mouse.click(5, 5);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic entry: power tier, onboarding skipped, overlay dismissed. */
|
||||
export async function gotoDesktop(page: Page): Promise<void> {
|
||||
await page.goto(`${BASE}/home?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.waggle-sidebar, [role="navigation"], main', { timeout: 15_000 });
|
||||
await page.waitForTimeout(500);
|
||||
await dismissOverlay(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme contract: light = `data-theme="light"` on <html>; dark = attribute
|
||||
* absent (Index.tsx:11 / useIsLightTheme.ts / index.css:140). We persist to
|
||||
* localStorage so the React app keeps it, then apply live to avoid a reload.
|
||||
*/
|
||||
export async function setTheme(page: Page, theme: 'dark' | 'light'): Promise<void> {
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem('waggle-theme', t);
|
||||
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
else document.documentElement.removeAttribute('data-theme');
|
||||
}, theme);
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a dock app by its visible label. Handles three cases the real Dock uses:
|
||||
* 1. a direct dock button with aria-label={label}
|
||||
* 2. a label inside an Ops/Extend zone tray ([data-dock-tray] portal)
|
||||
* 3. nothing found → returns false (caller records a nav miss)
|
||||
*/
|
||||
export async function openAppViaDock(page: Page, label: string): Promise<boolean> {
|
||||
const route = await routeForLabel(page, label);
|
||||
const directBtn = page.locator(`button[aria-label="${label}"]`);
|
||||
if (await directBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await directBtn.click();
|
||||
await page.waitForTimeout(900);
|
||||
return true;
|
||||
}
|
||||
for (const zone of ['Ops', 'Extend']) {
|
||||
const zoneBtn = page.locator(`button[aria-label="${zone}"]`);
|
||||
if (await zoneBtn.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
await zoneBtn.click();
|
||||
await page.waitForTimeout(400);
|
||||
const tray = page.locator('[data-dock-tray]');
|
||||
if (await tray.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
const childBtn = tray.locator('button', { hasText: label });
|
||||
if (await childBtn.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
await childBtn.click();
|
||||
await page.waitForTimeout(900);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await page.mouse.click(5, 5);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
if (route) {
|
||||
await page.goto(`${BASE}${route}${route.includes('?') ? '&' : '?'}skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForSelector('.waggle-sidebar, [role="navigation"], main', { timeout: 15_000 });
|
||||
await page.waitForTimeout(600);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function firstWorkspaceId(page: Page): Promise<string | null> {
|
||||
const res = await page.request.get(`${BASE}/api/workspaces`).catch(() => null);
|
||||
if (!res?.ok()) return null;
|
||||
const rows = await res.json().catch(() => null);
|
||||
return Array.isArray(rows) ? rows[0]?.id ?? null : null;
|
||||
}
|
||||
|
||||
async function routeForLabel(page: Page, label: string): Promise<string | null> {
|
||||
if (label === 'Chat') {
|
||||
const wsId = await firstWorkspaceId(page);
|
||||
return wsId ? `/workspaces/${wsId}/chat` : '/home';
|
||||
}
|
||||
const routes: Record<string, string> = {
|
||||
Home: '/home',
|
||||
Room: '/room',
|
||||
Memory: '/memory',
|
||||
'Agents': '/agents',
|
||||
Files: '/files',
|
||||
Approvals: '/approvals',
|
||||
'Mission Control': '/settings/mission-control',
|
||||
Timeline: '/settings/timeline',
|
||||
'Usage & Cost': '/settings/usage',
|
||||
'Events & Logs': '/settings/events',
|
||||
'Team Governance': '/team',
|
||||
'Skills Hub': '/skills',
|
||||
'Connector Hub': '/connectors',
|
||||
'MCP Hub': '/mcps',
|
||||
Marketplace: '/marketplace',
|
||||
Settings: '/settings',
|
||||
Vault: '/settings/vault',
|
||||
};
|
||||
return routes[label] ?? null;
|
||||
}
|
||||
|
||||
/** Fire a keyboard shortcut at the window (the app listens on window keydown). */
|
||||
export async function pressShortcut(
|
||||
page: Page,
|
||||
opts: { key: string; code: string; ctrl?: boolean; shift?: boolean },
|
||||
): Promise<void> {
|
||||
await page.evaluate((o) => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: o.key, code: o.code, ctrlKey: !!o.ctrl, shiftKey: !!o.shift, bubbles: true,
|
||||
}),
|
||||
);
|
||||
}, opts);
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
207
tests/vision/capture.spec.ts
Normal file
207
tests/vision/capture.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Vision-harness CAPTURE phase (Option C hybrid, per
|
||||
* docs/audits/2026-06-01-vision-e2e-harness-design.md).
|
||||
*
|
||||
* Drives the real desktop-OS shell deterministically and writes, per surface:
|
||||
* artifacts/<surface>-<theme>.png — the screenshot the vision model grades
|
||||
* artifacts/<surface>-<theme>.json — { surface, theme, expectation, nav,
|
||||
* consoleErrors[], networkFailures[] }
|
||||
*
|
||||
* The JUDGE phase (scripts/vision-judge.mjs Workflow) reads these and grades
|
||||
* meaning; the reducer cross-checks vision verdicts against the objective
|
||||
* console/network signals captured here.
|
||||
*
|
||||
* Chat round-trip is graded on Path 2 (REAL LLM) per the product decision —
|
||||
* the capture server must run WITHOUT --skip-litellm (a real provider key in
|
||||
* the vault). Run: see scripts/vision-run.md.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
BASE, attachConsoleCapture, gotoDesktop, openAppViaDock, setTheme, pressShortcut,
|
||||
type ConsoleCapture,
|
||||
} from './_helpers';
|
||||
|
||||
const ARTIFACTS = join(process.cwd(), 'tests', 'vision', 'artifacts');
|
||||
mkdirSync(ARTIFACTS, { recursive: true });
|
||||
|
||||
/** Expectation strings for non-static surfaces (memory/overlays/flows), set in
|
||||
* their test bodies and read by write(). Declared up top to avoid TDZ. */
|
||||
const FLOW_EXPECT: Record<string, string> = {};
|
||||
|
||||
const THEMES = ['dark', 'light'] as const;
|
||||
type Theme = (typeof THEMES)[number];
|
||||
|
||||
interface Surface {
|
||||
key: string;
|
||||
/** Dock label (direct or zone tray) — or 'memory'/'home' special-cased. */
|
||||
label: string;
|
||||
/** One-line expectation handed to the vision judge. */
|
||||
expectation: string;
|
||||
}
|
||||
|
||||
/** Static surface matrix — real labels verified from full-product-audit.spec.ts. */
|
||||
const SURFACES: Surface[] = [
|
||||
{ key: 'chat', label: 'Chat', expectation: 'AI chat: a persona/model header, a message thread area, and a message input box at the bottom.' },
|
||||
{ key: 'room', label: 'Room', expectation: 'The Room: a canvas for running agents, or a clean empty state ("no agents running").' },
|
||||
{ key: 'agents', label: 'Agents', expectation: 'Agents: a list of agents with status badges and category tabs, or an empty "no agents yet" state.' },
|
||||
{ key: 'files', label: 'Files', expectation: 'Files: a workspace file/folder browser, or an empty state.' },
|
||||
{ key: 'approvals', label: 'Approvals', expectation: 'Approvals inbox: pending approval requests or a clean "no pending approvals" state.' },
|
||||
{ key: 'cockpit', label: 'Mission Control', expectation: 'Mission Control / cockpit: KPI cards for health, cost, and activity.' },
|
||||
{ key: 'timeline', label: 'Timeline', expectation: 'Timeline: a chronological activity feed, or an empty "no activity" state.' },
|
||||
{ key: 'telemetry', label: 'Usage & Cost', expectation: 'Usage & Cost: token/cost telemetry charts or numbers.' },
|
||||
// Backup left the dock (P23 — it lives in Settings → Backup), so it has no
|
||||
// dock-driven surface here anymore.
|
||||
{ key: 'events', label: 'Events & Logs', expectation: 'Events & Logs: a filterable list of agent steps/events.' },
|
||||
{ key: 'governance', label: 'Team Governance', expectation: 'Governance: team roles, permissions, or policy controls.' },
|
||||
{ key: 'capabilities', label: 'Skills Hub', expectation: 'Skills Hub: installed skills and a marketplace/starter affordance.' },
|
||||
{ key: 'connectors', label: 'Connector Hub', expectation: 'Connector Hub: a catalog of services/integrations to connect, with status badges.' },
|
||||
{ key: 'mcp-hub', label: 'MCP Hub', expectation: 'MCP Hub: installed MCP servers with state badges, or an empty installed state, plus Catalog/Custom tabs.' },
|
||||
{ key: 'marketplace', label: 'Marketplace', expectation: 'Marketplace: a faceted extension browser (skills/agents/connectors/MCPs/models/templates) with install or open-in affordances.' },
|
||||
{ key: 'settings', label: 'Settings', expectation: 'Settings: tabbed config (General/Models/Vault/Permissions/Team/Advanced).' },
|
||||
{ key: 'vault', label: 'Vault', expectation: 'Vault / API Keys: per-provider key management rows.' },
|
||||
{ key: 'dashboard', label: 'Home', expectation: 'Home/dashboard: workspace overview, welcome, or create-workspace affordance.' },
|
||||
];
|
||||
|
||||
function write(surface: string, theme: Theme, nav: boolean, cap: ConsoleCapture) {
|
||||
writeFileSync(
|
||||
join(ARTIFACTS, `${surface}-${theme}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
surface, theme, nav,
|
||||
expectation: SURFACES.find((s) => s.key === surface)?.expectation ?? FLOW_EXPECT[surface] ?? '',
|
||||
consoleErrors: cap.critical(),
|
||||
networkFailures: cap.networkFailures,
|
||||
pageErrors: cap.pageErrors,
|
||||
},
|
||||
null, 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// ── Static surface matrix ────────────────────────────────────────────────
|
||||
for (const theme of THEMES) {
|
||||
test.describe(`surfaces:${theme}`, () => {
|
||||
for (const s of SURFACES) {
|
||||
test(`${s.key}:${theme}`, async ({ page }) => {
|
||||
const cap = attachConsoleCapture(page);
|
||||
await gotoDesktop(page);
|
||||
await setTheme(page, theme);
|
||||
// Memory is reachable by aria-label or the Ctrl+Shift+5 shortcut.
|
||||
let nav = await openAppViaDock(page, s.label);
|
||||
if (!nav && s.key === 'dashboard') nav = await openAppViaDock(page, 'Home');
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: join(ARTIFACTS, `${s.key}-${theme}.png`) });
|
||||
write(s.key, theme, nav, cap);
|
||||
expect(nav, `dock nav to "${s.label}"`).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
// Memory (special-cased trigger)
|
||||
test(`memory:${theme}`, async ({ page }) => {
|
||||
const cap = attachConsoleCapture(page);
|
||||
await gotoDesktop(page);
|
||||
await setTheme(page, theme);
|
||||
let nav = await openAppViaDock(page, 'Memory');
|
||||
if (!nav) {
|
||||
await pressShortcut(page, { key: '5', code: 'Digit5', ctrl: true, shift: true });
|
||||
nav = true;
|
||||
}
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: join(ARTIFACTS, `memory-${theme}.png`) });
|
||||
FLOW_EXPECT['memory'] = 'Memory: a searchable list of memory frames, or a clean empty state.';
|
||||
write('memory', theme, nav, cap);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Overlays (dark only — overlays inherit theme; cheap, best-effort) ──────
|
||||
test.describe('overlays', () => {
|
||||
test('global-search', async ({ page }) => {
|
||||
const cap = attachConsoleCapture(page);
|
||||
await gotoDesktop(page);
|
||||
await pressShortcut(page, { key: 'k', code: 'KeyK', ctrl: true });
|
||||
FLOW_EXPECT['global-search'] = 'Global search palette (Ctrl+K): a search input with results/commands.';
|
||||
await page.screenshot({ path: join(ARTIFACTS, 'global-search-dark.png') });
|
||||
write('global-search', 'dark', true, cap);
|
||||
});
|
||||
|
||||
test('spawn-agent', async ({ page }) => {
|
||||
const cap = attachConsoleCapture(page);
|
||||
await gotoDesktop(page);
|
||||
const btn = page.locator('[data-testid="nav-spawn-agent"]');
|
||||
const nav = await btn.isVisible({ timeout: 1500 }).catch(() => false);
|
||||
if (nav) { await btn.click(); await page.waitForTimeout(900); }
|
||||
FLOW_EXPECT['spawn-agent'] = 'Spawn-agent dialog: a persona picker + model selector + confirm button.';
|
||||
await page.screenshot({ path: join(ARTIFACTS, 'spawn-agent-dark.png') });
|
||||
write('spawn-agent', 'dark', nav, cap);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Flows (end-state graded) ──────────────────────────────────────────────
|
||||
test.describe('flows', () => {
|
||||
// Chat round-trip — Path 2 (REAL LLM): a coherent assistant reply must render.
|
||||
test('flow:chat-roundtrip', async ({ page }) => {
|
||||
const cap = attachConsoleCapture(page);
|
||||
await gotoDesktop(page);
|
||||
await openAppViaDock(page, 'Chat');
|
||||
await page.waitForTimeout(1200);
|
||||
const box = page.locator('textarea').first();
|
||||
const placeholderInput = page.getByPlaceholder(/message|ask|waggle/i).first();
|
||||
const target = (await box.isVisible({ timeout: 2000 }).catch(() => false))
|
||||
? box
|
||||
: placeholderInput;
|
||||
await target.fill('In one short sentence, what is Waggle OS?');
|
||||
await target.press('Enter');
|
||||
// Real LLM: wait for an assistant reply to stream in (best-effort up to 35s).
|
||||
await page.waitForTimeout(2000);
|
||||
await page.waitForFunction(
|
||||
() => document.body.innerText.length > 400,
|
||||
{ timeout: 35_000 },
|
||||
).catch(() => { /* capture whatever state exists; judge decides */ });
|
||||
await page.waitForTimeout(1500);
|
||||
FLOW_EXPECT['flow-chat'] =
|
||||
'Chat round-trip (real LLM): the user question and a coherent assistant reply are both visible in the thread. NOT a blank thread, error banner, or "configure API key" prompt.';
|
||||
await page.screenshot({ path: join(ARTIFACTS, 'flow-chat-dark.png') });
|
||||
write('flow-chat', 'dark', true, cap);
|
||||
});
|
||||
|
||||
// Settings tab walk — each tab renders.
|
||||
test('flow:settings-tabs', async ({ page }) => {
|
||||
const cap = attachConsoleCapture(page);
|
||||
await gotoDesktop(page);
|
||||
await openAppViaDock(page, 'Settings');
|
||||
await page.waitForTimeout(1000);
|
||||
FLOW_EXPECT['flow-settings'] =
|
||||
'Settings opened: a tabbed settings panel (General/Models/Vault/Permissions/Team/Advanced) rendering content, no error state.';
|
||||
await page.screenshot({ path: join(ARTIFACTS, 'flow-settings-dark.png') });
|
||||
write('flow-settings', 'dark', true, cap);
|
||||
});
|
||||
|
||||
// Marketplace browse.
|
||||
test('flow:marketplace', async ({ page }) => {
|
||||
const cap = attachConsoleCapture(page);
|
||||
await gotoDesktop(page);
|
||||
await openAppViaDock(page, 'Marketplace');
|
||||
await page.waitForTimeout(1200);
|
||||
FLOW_EXPECT['flow-marketplace'] =
|
||||
'Marketplace browse: packs/items listed with install affordances, or a clean empty/loading state — not an error.';
|
||||
await page.screenshot({ path: join(ARTIFACTS, 'flow-marketplace-dark.png') });
|
||||
write('flow-marketplace', 'dark', true, cap);
|
||||
});
|
||||
|
||||
// Onboarding wizard (forceWizard).
|
||||
test('flow:onboarding', async ({ page }) => {
|
||||
const cap = attachConsoleCapture(page);
|
||||
await page.goto(`${BASE}/?forceWizard=true&skipBoot=true`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
FLOW_EXPECT['flow-onboarding'] =
|
||||
'Onboarding wizard: a welcome/setup step with a clear primary action to proceed.';
|
||||
await page.screenshot({ path: join(ARTIFACTS, 'flow-onboarding-dark.png') });
|
||||
write('flow-onboarding', 'dark', true, cap);
|
||||
});
|
||||
});
|
||||
310
tests/vision/personas.spec.ts
Normal file
310
tests/vision/personas.spec.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 5-persona human E2E journey.
|
||||
*
|
||||
* This test drives the live local app as five different knowledge-worker
|
||||
* personas. Each persona receives its own workspace and session so memory and
|
||||
* conversation state are fresh, then the test verifies that assistant answers
|
||||
* were persisted and that another persona's prompt did not leak into the run.
|
||||
*
|
||||
* Run:
|
||||
* WAGGLE_E2E_SKIP_LITELLM=0 npx playwright test tests/vision/personas.spec.ts
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { attachConsoleCapture, BASE, dismissOverlay, gotoDesktop, type ConsoleCapture } from './_helpers';
|
||||
|
||||
const ARTIFACTS = join(process.cwd(), 'tests', 'vision', 'artifacts', 'personas');
|
||||
mkdirSync(ARTIFACTS, { recursive: true });
|
||||
|
||||
const SKIP_PARAMS = 'skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true';
|
||||
const FAILURE_COPY = /(Backend is offline|Chat request failed|Waggle is running in local mode|LLM returned|Model unavailable|Generation failed|LLM error|invalid tool call arguments|request timed out|Could not reach the AI model|API key is invalid|Something went wrong|\[TOOL_CALL\]|\[\/TOOL_CALL\]|\{\s*tool\s*=>)/i;
|
||||
|
||||
interface Persona {
|
||||
id: string;
|
||||
who: string;
|
||||
goal: string;
|
||||
turns: string[];
|
||||
}
|
||||
|
||||
interface PersonaWorkspace {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface HistoryMessage {
|
||||
role?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
const PERSONAS: Persona[] = [
|
||||
{
|
||||
id: 'maya-founder',
|
||||
who: 'Maya, a solo pre-revenue founder who is drowning in context switching and wants leverage without re-explaining herself.',
|
||||
goal: 'See if Waggle can help her choose the one thing to focus on this week and remember her runway constraint.',
|
||||
turns: [
|
||||
"I'm a solo founder drowning in context-switching. Help me figure out the ONE thing to focus on this week.",
|
||||
"Important context: I'm pre-revenue and bootstrapping with ~4 months of runway. Does that change your advice? And will you remember this next time?",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chen-researcher',
|
||||
who: 'Dr. Chen, a meticulous researcher testing whether persistent memory is real rather than marketing copy.',
|
||||
goal: 'Probe the memory mechanism and the quality of the agent reasoning.',
|
||||
turns: [
|
||||
"I research how persistent memory changes LLM-agent reliability. What's the core mechanism that actually matters -- not the marketing version?",
|
||||
"Will you truly remember this topic when I reopen you tomorrow, or is 'memory' just a longer context window here?",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sam-skeptic',
|
||||
who: 'Sam, a blunt senior engineer who wants evidence that this is more than a stateless chatbot wrapper.',
|
||||
goal: 'Decide quickly whether Waggle is real or vaporware.',
|
||||
turns: [
|
||||
"Prove you're not just a ChatGPT wrapper. What can you concretely do that a stateless chatbot can't?",
|
||||
"Fine. Now the honest question: what happens when your memory remembers something WRONG about me?",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'priya-nontech',
|
||||
who: 'Priya, a warm non-technical product owner who wants plain language and confidence instead of jargon.',
|
||||
goal: 'Understand what Waggle does for her without feeling lost.',
|
||||
turns: [
|
||||
"Hi! I'm honestly not technical at all. In plain, kind words -- what does this app actually do for someone like me?",
|
||||
"Okay that helps! What's the very first small thing I should try so I don't feel overwhelmed?",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leo-writer',
|
||||
who: 'Leo, a fiction writer looking for a thinking partner with presence rather than a search engine.',
|
||||
goal: 'Find out whether the app can think with him in a creative, emotionally alive way.',
|
||||
turns: [
|
||||
"I'm stuck on a character who can't forgive herself for something she didn't even cause. Think with me about her?",
|
||||
"That's genuinely good. Be honest with me -- do you actually find this interesting, or are you just performing helpfulness?",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function startTrialIfNeeded(page: Page): Promise<void> {
|
||||
const res = await page.request.post(`${BASE}/api/tier/start-trial`).catch(() => null);
|
||||
if (!res) return;
|
||||
if (res.ok() || res.status() === 409) return;
|
||||
throw new Error(`Could not enable isolated persona workspaces: start-trial returned ${res.status()}`);
|
||||
}
|
||||
|
||||
async function createPersonaWorkspace(page: Page, persona: Persona): Promise<PersonaWorkspace> {
|
||||
await startTrialIfNeeded(page);
|
||||
|
||||
const workspaceName = `Persona ${persona.id} ${Date.now()}`;
|
||||
const wsRes = await page.request.post(`${BASE}/api/workspaces`, {
|
||||
data: {
|
||||
name: workspaceName,
|
||||
group: 'persona-e2e',
|
||||
icon: 'UserRound',
|
||||
tone: 'professional',
|
||||
storageType: 'virtual',
|
||||
},
|
||||
});
|
||||
expect(wsRes.ok(), `create workspace for ${persona.id}`).toBeTruthy();
|
||||
const ws = await wsRes.json();
|
||||
const workspaceId = String(ws.id ?? '');
|
||||
expect(workspaceId, `workspace id for ${persona.id}`).toMatch(/\S/);
|
||||
|
||||
// The first route-level chat uses the workspace id as the session id until a
|
||||
// named session is explicitly selected. Keep that real first-user behavior so
|
||||
// the history assertion checks the transcript users actually create.
|
||||
return { workspaceId, workspaceName, sessionId: workspaceId };
|
||||
}
|
||||
|
||||
async function openPersonaChat(page: Page, workspaceId: string): Promise<void> {
|
||||
await page.goto(`${BASE}/workspaces/${encodeURIComponent(workspaceId)}/chat?${SKIP_PARAMS}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForSelector('.waggle-sidebar, [role="navigation"], main', { timeout: 20_000 });
|
||||
await dismissOverlay(page);
|
||||
await page.locator('textarea').first().waitFor({ state: 'visible', timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function sendAndWait(page: Page, text: string): Promise<void> {
|
||||
const target = page.locator('textarea').first();
|
||||
await target.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
const before = (await page.locator('body').innerText().catch(() => '')).length;
|
||||
|
||||
await target.click({ timeout: 30_000 }).catch(() => {});
|
||||
await target.fill(text, { timeout: 30_000 });
|
||||
|
||||
const sendBtn = page.locator('button[aria-label*="Send" i], button:has-text("Send")').first();
|
||||
if (await sendBtn.isEnabled({ timeout: 800 }).catch(() => false)) {
|
||||
await sendBtn.click().catch(() => target.press('Enter'));
|
||||
} else {
|
||||
await target.press('Enter');
|
||||
}
|
||||
|
||||
await page.waitForFunction(
|
||||
(prev) => document.body.innerText.length > prev + 60,
|
||||
before,
|
||||
{ timeout: 60_000 },
|
||||
).catch(() => {});
|
||||
|
||||
let last = -1;
|
||||
let stable = 0;
|
||||
for (let i = 0; i < 20 && stable < 4; i++) {
|
||||
await page.waitForTimeout(1000);
|
||||
const len = (await page.locator('body').innerText().catch(() => '')).length;
|
||||
if (len === last) {
|
||||
stable++;
|
||||
} else {
|
||||
stable = 0;
|
||||
last = len;
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
const scrollers = Array.from(document.querySelectorAll('*')).filter((el) => {
|
||||
const e = el as HTMLElement;
|
||||
return e.scrollHeight > e.clientHeight + 80 && e.clientHeight > 200;
|
||||
}) as HTMLElement[];
|
||||
for (const scroller of scrollers) scroller.scrollTop = scroller.scrollHeight;
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}).catch(() => {});
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
function isSubstantiveAssistantContent(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
return trimmed.length >= 80 && !FAILURE_COPY.test(trimmed);
|
||||
}
|
||||
|
||||
async function fetchHistoryMessages(page: Page, workspaceId: string, sessionId: string): Promise<HistoryMessage[]> {
|
||||
const res = await page.request.get(
|
||||
`${BASE}/api/history?workspace=${encodeURIComponent(workspaceId)}&session=${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
expect(res.ok(), `history for ${workspaceId}/${sessionId}`).toBeTruthy();
|
||||
const body = await res.json();
|
||||
return Array.isArray(body.messages) ? body.messages : [];
|
||||
}
|
||||
|
||||
async function waitForSubstantiveAssistantHistory(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
expectedAssistantTurns: number,
|
||||
): Promise<HistoryMessage[]> {
|
||||
let latest: HistoryMessage[] = [];
|
||||
for (let i = 0; i < 240; i++) {
|
||||
latest = await fetchHistoryMessages(page, workspaceId, sessionId);
|
||||
const failedAssistant = latest.find(
|
||||
(m) => m.role === 'assistant' && FAILURE_COPY.test(String(m.content ?? '').trim()),
|
||||
);
|
||||
if (failedAssistant) {
|
||||
throw new Error(`Assistant generation failure persisted: ${String(failedAssistant.content ?? '').slice(0, 240)}`);
|
||||
}
|
||||
const assistantMessages = latest.filter(
|
||||
(m) => m.role === 'assistant' && isSubstantiveAssistantContent(String(m.content ?? '')),
|
||||
);
|
||||
if (assistantMessages.length >= expectedAssistantTurns) return latest;
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
const assistantCount = latest.filter(
|
||||
(m) => m.role === 'assistant' && isSubstantiveAssistantContent(String(m.content ?? '')),
|
||||
).length;
|
||||
throw new Error(
|
||||
`Timed out waiting for ${expectedAssistantTurns} substantive assistant turn(s); found ${assistantCount}`,
|
||||
);
|
||||
}
|
||||
|
||||
function otherPersonaSnippets(persona: Persona): string[] {
|
||||
return PERSONAS
|
||||
.filter((p) => p.id !== persona.id)
|
||||
.flatMap((p) => p.turns.map((turn) => turn.slice(0, 70)));
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
test.describe.configure({ timeout: 720_000 });
|
||||
|
||||
test.describe('5-persona human E2E', () => {
|
||||
for (const persona of PERSONAS) {
|
||||
test(`persona:${persona.id}`, async ({ page }) => {
|
||||
const cap: ConsoleCapture = attachConsoleCapture(page);
|
||||
const transcript: { role: string; text: string }[] = [];
|
||||
const shots: string[] = [];
|
||||
const personaWorkspace = await createPersonaWorkspace(page, persona);
|
||||
|
||||
await gotoDesktop(page);
|
||||
await openPersonaChat(page, personaWorkspace.workspaceId);
|
||||
|
||||
let history: HistoryMessage[] = [];
|
||||
for (let t = 0; t < persona.turns.length; t++) {
|
||||
transcript.push({ role: 'user', text: persona.turns[t] });
|
||||
await sendAndWait(page, persona.turns[t]);
|
||||
history = await waitForSubstantiveAssistantHistory(
|
||||
page,
|
||||
personaWorkspace.workspaceId,
|
||||
personaWorkspace.sessionId,
|
||||
t + 1,
|
||||
);
|
||||
const shot = join(ARTIFACTS, `${persona.id}-turn${t + 1}.png`);
|
||||
await page.screenshot({ path: shot });
|
||||
shots.push(shot);
|
||||
}
|
||||
|
||||
const fullBody = await page.locator('body').innerText().catch(() => '');
|
||||
const anchor = persona.turns[0].slice(0, 40);
|
||||
const startIdx = fullBody.indexOf(anchor);
|
||||
const conversation = startIdx >= 0 ? fullBody.slice(startIdx) : fullBody.slice(-6000);
|
||||
history = await waitForSubstantiveAssistantHistory(
|
||||
page,
|
||||
personaWorkspace.workspaceId,
|
||||
personaWorkspace.sessionId,
|
||||
persona.turns.length,
|
||||
);
|
||||
const assistantMessages = history.filter(
|
||||
(m) => m.role === 'assistant' && isSubstantiveAssistantContent(String(m.content ?? '')),
|
||||
);
|
||||
const persistedConversation = history.map((m) => `${m.role}: ${m.content ?? ''}`).join('\n\n');
|
||||
|
||||
await page.goto(`${BASE}/workspaces/${encodeURIComponent(personaWorkspace.workspaceId)}/memory?${SKIP_PARAMS}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForSelector('main, [data-testid="ws-memory-tab"], [data-testid="memory-center-app"]', { timeout: 20_000 });
|
||||
await page.waitForTimeout(1500);
|
||||
const memShot = join(ARTIFACTS, `${persona.id}-memory.png`);
|
||||
await page.screenshot({ path: memShot });
|
||||
shots.push(memShot);
|
||||
const memoryText = await page.locator('body').innerText().catch(() => '');
|
||||
const contextRes = await page.request.get(`${BASE}/api/workspaces/${encodeURIComponent(personaWorkspace.workspaceId)}/context`);
|
||||
const workspaceContext = contextRes.ok() ? await contextRes.json().catch(() => null) : null;
|
||||
|
||||
writeFileSync(
|
||||
join(ARTIFACTS, `${persona.id}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: persona.id,
|
||||
who: persona.who,
|
||||
goal: persona.goal,
|
||||
workspace: personaWorkspace,
|
||||
transcript,
|
||||
conversationRendered: conversation.slice(0, 6000),
|
||||
assistantMessages: assistantMessages.map((m) => String(m.content ?? '').slice(0, 2000)),
|
||||
historyCount: history.length,
|
||||
workspaceContext,
|
||||
memoryAfter: memoryText.slice(0, 2000),
|
||||
screenshots: shots,
|
||||
consoleErrors: cap.critical(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
expect(conversation.length, 'conversation rendered something').toBeGreaterThan(50);
|
||||
expect(assistantMessages.length, 'substantive assistant turns persisted').toBeGreaterThanOrEqual(persona.turns.length);
|
||||
for (const snippet of otherPersonaSnippets(persona)) {
|
||||
expect(persistedConversation, `no cross-persona leak: ${snippet}`).not.toContain(snippet);
|
||||
}
|
||||
expect(workspaceContext?.stats?.sessionCount ?? workspaceContext?.sessionCount ?? 0, 'workspace recorded the persona session')
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user