moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

75
tests/vision/README.md Normal file
View 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.40.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
View 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);
}

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

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