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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
/**
* H-01 · QW-3 regression — skip BootScreen on return visits.
*
* apps/web/src/pages/Index.tsx gates BootScreen on a `waggle-booted`
* localStorage key. First visit: key absent → BootScreen mounts, runs
* through phase animation, calls `onComplete` which writes the key and
* sets `booted=true`. Subsequent visits: key present → BootScreen never
* mounts; Desktop renders immediately.
*
* Guards three properties:
* 1. Fresh storage shows BootScreen (behavior under the gate).
* 2. Pre-seeded BOOT_KEY makes BootScreen skip entirely.
* 3. Completing the boot writes the key and persists across reload.
*
* Run: npx playwright test tests/e2e/boot-screen-skip.spec.ts --reporter=list
*/
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
const BOOT_KEY = 'waggle-booted';
const BOOT_SCREEN = '[data-testid="boot-screen"]';
async function clearBootFlag(page: Page) {
// Fresh-storage setup: wipe BOOT_KEY before React mounts. Using
// addInitScript so the removal lands before the useState initializer
// in Index.tsx reads localStorage. Wrap in try-catch because some
// browsers throw on localStorage access in file:// contexts.
await page.addInitScript(() => {
try {
window.localStorage.removeItem('waggle-booted');
} catch {
// ignore — localStorage unavailable
}
});
}
async function seedBootFlag(page: Page) {
// Return-visit setup: mark boot as completed before first navigation.
await page.addInitScript(() => {
try {
window.localStorage.setItem('waggle-booted', 'true');
} catch {
// ignore
}
});
}
test.describe('H-01 · QW-3 · BootScreen skip on return visits', () => {
test('fresh storage renders BootScreen', async ({ page }) => {
await clearBootFlag(page);
await page.goto(`${BASE}/`);
// BootScreen should mount immediately (before the ~2.5s auto-advance
// completes). 1s window is well inside the animation runtime.
await expect(page.locator(BOOT_SCREEN)).toBeVisible({ timeout: 1_000 });
});
test('pre-seeded BOOT_KEY skips BootScreen entirely', async ({ page }) => {
await seedBootFlag(page);
await page.goto(`${BASE}/`);
await page.waitForLoadState('domcontentloaded');
// Assert BootScreen never mounted. toHaveCount(0) proves non-rendered,
// not merely off-screen — the AnimatePresence branch in Index.tsx
// conditionally renders on `!booted`.
await expect(page.locator(BOOT_SCREEN)).toHaveCount(0);
// Sanity: confirm the localStorage key survived into the page runtime.
const bootFlag = await page.evaluate(() => window.localStorage.getItem('waggle-booted'));
expect(bootFlag).not.toBeNull();
});
test('completing boot persists the flag across reload', async ({ page }) => {
await clearBootFlag(page);
await page.goto(`${BASE}/`);
// First visit: BootScreen visible.
await expect(page.locator(BOOT_SCREEN)).toBeVisible({ timeout: 1_000 });
// Click to skip — BootScreen listens for click + keydown and fires
// `onComplete`, which writes BOOT_KEY and flips the booted state.
await page.locator(BOOT_SCREEN).click();
// Wait for BootScreen to unmount (AnimatePresence exit anim ~500ms).
await expect(page.locator(BOOT_SCREEN)).toHaveCount(0, { timeout: 3_000 });
// The flag should now be persisted.
const bootFlag = await page.evaluate(() => window.localStorage.getItem('waggle-booted'));
expect(bootFlag).toBe('true');
// Reload — BootScreen must stay skipped.
await page.reload();
await page.waitForLoadState('domcontentloaded');
await expect(page.locator(BOOT_SCREEN)).toHaveCount(0);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
/**
* Failure-injection · network-drop — mid-stream SSE connection loss on POST /api/chat.
*
* FAILURE PATH: the client (apps/web/src/lib/adapter.ts:336-388) consumes the
* chat SSE stream via fetch().then(res.body.getReader()). When the underlying
* network connection is dropped, one of two things happens:
*
* (a) fetch()/reader.read() REJECTS → useChat's catch block
* (apps/web/src/hooks/useChat.ts:264-275) appends an `error` ContentBlock
* rendered by chat-blocks/BlockRenderer.tsx:33-38 as a ⚠️ destructive
* message: "Backend is offline. Connect to a Waggle server to start chatting."
*
* (b) the body simply ENDS without a `done` event → the reader yields
* done=true, the while loop at adapter.ts:360 exits, sendMessage()
* returns normally, and no error is shown but the partial tokens that DID
* arrive remain rendered (graceful truncation, no crash/hang).
*
* The client does NOT auto-retry (by design). These tests assert the RECOVERY /
* ERROR contract for both shapes, plus that a user can re-send after a drop and
* get a fresh, complete stream.
*
* Injection mechanism mirrors the page.route() pattern in spawn-agent-flow.spec.ts.
* Runs against the existing :3333 webServer (WAGGLE_ECHO_MODE in CI → the server
* streams a deterministic "local mode" echo response, used by the recovery test).
*
* Run: npx playwright test tests/e2e/failure-injection/network-drop.spec.ts --reporter=list
*/
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
test.setTimeout(60_000);
// ── Shared UI helpers — mirror the PROVEN boot/navigation flow in
// waggle-complete.spec.ts (test 14.8), not the stale live-chat-flow.spec.ts
// bypass. The onboarding key is `waggle:onboarding`; chat opens via the dock
// button (aria-label = view label) with a sidebar-by-text fallback. ──────
async function skipOnboarding(page: Page) {
await page.evaluate(() => {
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
});
}
async function waitForApp(page: Page) {
await page.waitForSelector(
'.waggle-app-shell, .waggle-sidebar, [role="navigation"], [class*="onboarding"]',
{ timeout: 15_000 },
).catch(() => {});
await page.waitForTimeout(800);
}
async function dismissLoginBriefing(page: Page) {
const startBtn = page.locator('button', { hasText: 'Start Working' });
if (await startBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
await startBtn.click();
await page.waitForTimeout(500);
}
}
async function navigateTo(page: Page, view: string) {
const dockBtn = page.locator(`button[aria-label="${view}"]`);
if (await dockBtn.isVisible().catch(() => false)) {
await dockBtn.click();
await page.waitForTimeout(400);
return;
}
const sidebar = page.locator('[role="navigation"]');
const btn = sidebar.locator('button', { hasText: view });
if (await btn.isVisible().catch(() => false)) {
await btn.click();
await page.waitForTimeout(400);
}
}
async function gotoDesktop(page: Page) {
await page.goto(`${BASE}/chat?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
waitUntil: 'domcontentloaded',
});
await waitForApp(page);
await dismissLoginBriefing(page);
}
async function openChatInput(page: Page) {
await navigateTo(page, 'Chat');
// Real placeholder: "Message Waggle... (/ for commands)" (ChatApp.tsx:1205)
const input = page.getByRole('textbox', { name: /reply|ask waggle|message/i }).first();
await expect(input).toBeVisible({ timeout: 8000 });
return input;
}
// A minimal, well-formed SSE payload that delivers exactly ONE token event and
// then ENDS — no `done` event. This simulates a connection dropped mid-stream
// AFTER at least one token has been delivered (requirement: abort after >=1
// token event). The adapter renders the token, then sees the stream close.
const PARTIAL_SSE_ONE_TOKEN =
'event: token\ndata: {"content":"MIDSTREAM_TOKEN_PROBE "}\n\n';
// ── Test 1 · Hard drop (fetch rejects) → graceful error, no hang ───────
test('chat SSE connection dropped → shows offline error, does not hang or crash', async ({ page }) => {
await gotoDesktop(page);
const input = await openChatInput(page);
// Inject the failure: abort the chat request at the network layer so the
// client's fetch()/reader rejects → useChat catch path fires.
await page.route('**/api/chat', (route) => route.abort('failed'));
await input.fill('trigger a dropped stream');
await input.press('Enter');
// (2) The graceful error message must be shown. BlockRenderer renders the
// The error block must expose the offline copy to the user.
const offlineError = page.getByText(/Backend is offline/i);
await expect(offlineError.first()).toBeVisible({ timeout: 15_000 });
// (3) No hang: the composer must become usable again (loading state cleared
// in useChat's finally). The input stays editable rather than spinning forever.
await expect(input).toBeEditable({ timeout: 10_000 });
// (3b) No crash: the desktop is still alive and the chat input still present.
await expect(input).toBeVisible();
});
// ── Test 2 · Mid-stream truncation (token then close) → token kept, no crash ──
test('chat SSE truncated after one token → partial token rendered, no hang or crash', async ({ page }) => {
await gotoDesktop(page);
const input = await openChatInput(page);
// Fulfill a partial stream: one real token event, then the body ends with no
// `done` event — i.e. the connection dropped mid-stream after a token landed.
await page.route('**/api/chat', (route) =>
route.fulfill({
status: 200,
contentType: 'text/event-stream',
headers: { 'Cache-Control': 'no-cache' },
body: PARTIAL_SSE_ONE_TOKEN,
}),
);
await input.fill('trigger a truncated stream');
await input.press('Enter');
// (1) The token that arrived before the drop must be rendered (proves the drop
// happened mid-token, not before any data).
await expect(page.locator('text=MIDSTREAM_TOKEN_PROBE').first()).toBeVisible({ timeout: 15_000 });
// (3) No hang: reader hit done=true, sendMessage() returned, loading cleared —
// the composer is editable again.
await expect(input).toBeEditable({ timeout: 10_000 });
// (3b) No crash, no infinite loop: desktop + input still present.
await expect(input).toBeVisible();
});
// ── Test 3 · Recovery — re-send after a drop yields a fresh complete stream ──
test('user can re-send after a dropped stream and get a new complete response', async ({ page }) => {
await gotoDesktop(page);
const input = await openChatInput(page);
// First attempt: drop the connection.
await page.route('**/api/chat', (route) => route.abort('failed'));
await input.fill('first attempt that will be dropped');
await input.press('Enter');
const offlineError = page.getByText(/Backend is offline/i);
await expect(offlineError.first()).toBeVisible({ timeout: 15_000 });
await expect(input).toBeEditable({ timeout: 10_000 });
// Snapshot state after the drop so we can assert the RETRY changes it:
// - assistant bubbles use justify-start (user = justify-end) — ChatApp.tsx:1075.
// There is no test-id on message rows, so this flex class is the stable
// structural signal for "an assistant turn rendered".
// - the count of offline-error blocks must NOT grow on the (now-succeeding) retry.
const assistantBubbles = page.locator('.flex.justify-start');
const assistantBubblesBeforeRetry = await assistantBubbles.count();
const offlineErrorsBeforeRetry = await offlineError.count();
// Clear the injected failure so the real :3333 server handles the retry.
await page.unroute('**/api/chat');
// (4) Re-send — the real server handles it. The recovery CONTRACT is mode-
// independent: the composer settles (request completed, no hang), a new
// assistant turn renders, and NO new offline error appears. We deliberately
// do NOT assert the echo-only "local mode" string: a live LLM proxy (LiteLLM
// on :4000) takes precedence over WAGGLE_ECHO_MODE and returns real model
// output, so that copy would make the test environment-dependent.
await input.fill('retry attempt after recovery — please respond');
await input.press('Enter');
// Composer re-enables once the stream settles (proves no hang in either mode).
await expect(input).toBeEditable({ timeout: 45_000 });
// A new assistant turn rendered after the recovery re-send.
await expect.poll(
async () => assistantBubbles.count(),
{ timeout: 45_000, message: 'expected a new assistant response after recovery re-send' },
).toBeGreaterThan(assistantBubblesBeforeRetry);
// The retry must NOT have produced a new "Backend is offline" error.
expect(await offlineError.count()).toBe(offlineErrorsBeforeRetry);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,387 @@
/**
* Full Product Audit — comprehensive E2E covering every app and flow.
*
* Tests every surface a user can reach from the dock, verifies API health,
* and walks through critical user journeys.
*
* Run: npx playwright test tests/e2e/full-product-audit.spec.ts --reporter=list
*/
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
// ── Helpers ───────────────────────────────────────────────────────────
async function dismissOverlay(page: Page) {
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);
}
}
async function gotoDesktop(page: Page) {
await page.goto(`${BASE}/home?skipOnboarding=true&skipBoot=true&tier=power`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[role="navigation"], main', { timeout: 15_000 });
await page.waitForTimeout(600);
await dismissOverlay(page);
}
function routeWithSkip(route: string): string {
const separator = route.includes('?') ? '&' : '?';
return `${BASE}${route}${separator}skipOnboarding=true&skipBoot=true&tier=power`;
}
async function openCurrentApp(page: Page, label: string) {
const nav = page.locator('[role="navigation"]');
const navAliases: Record<string, string[]> = {
Chat: ['Chat'],
Memory: ['Memory'],
Agents: ['Agents'],
Connectors: ['Connectors'],
Home: ['Home'],
Settings: ['Account and settings'],
};
for (const alias of navAliases[label] ?? [label]) {
const btn = nav.locator('button', { hasText: alias }).first();
if (await btn.isVisible({ timeout: 700 }).catch(() => false)) {
await btn.click();
await page.waitForTimeout(700);
return;
}
}
const routes: Record<string, string> = {
Home: '/home',
Room: '/room',
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',
};
const route = routes[label];
if (!route) throw new Error(`No current navigation target configured for "${label}"`);
await page.goto(routeWithSkip(route), { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[role="navigation"], main', { timeout: 15_000 });
await page.waitForTimeout(700);
await dismissOverlay(page);
}
async function openAppViaDock(page: Page, label: string) {
// Direct dock button (has aria-label)
const directBtn = page.locator(`button[aria-label="${label}"]`);
if (await directBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await directBtn.click();
await page.waitForTimeout(800);
return;
}
// Try zone parents (Ops, Extend) — click to open tray, then click child by text
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);
// The tray renders as a fixed portal with [data-dock-tray]
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(800);
return;
}
}
// Close the tray if we didn't find the child
await page.mouse.click(5, 5);
await page.waitForTimeout(200);
}
}
}
async function getVisibleText(page: Page): Promise<string> {
return page.locator('body').innerText();
}
// ── 1. API Health ─────────────────────────────────────────────────────
test.describe('1. API Health', () => {
test('health endpoint', async ({ request }) => {
const res = await request.get(`${BASE}/health`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(['ok', 'degraded']).toContain(data.status);
expect(data.database.healthy).toBe(true);
});
test('workspaces API', async ({ request }) => {
const res = await request.get(`${BASE}/api/workspaces`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(Array.isArray(data)).toBeTruthy();
});
test('personas API', async ({ request }) => {
const res = await request.get(`${BASE}/api/personas`);
expect([200, 404]).toContain(res.status());
if (res.ok()) {
const data = await res.json();
const list = Array.isArray(data) ? data : data.personas ?? [];
expect(list.length).toBeGreaterThan(0);
}
});
test('events API', async ({ request }) => {
const res = await request.get(`${BASE}/api/events?limit=5`);
expect(res.ok()).toBeTruthy();
});
test('vault API', async ({ request }) => {
const res = await request.get(`${BASE}/api/vault`);
expect(res.ok()).toBeTruthy();
});
test('settings API', async ({ request }) => {
const res = await request.get(`${BASE}/api/settings`);
expect(res.ok()).toBeTruthy();
});
test('memory search API', async ({ request }) => {
const res = await request.get(`${BASE}/api/memory/search?q=test&limit=3`);
expect(res.ok()).toBeTruthy();
});
test('marketplace API', async ({ request }) => {
const res = await request.get(`${BASE}/api/marketplace/packs`);
expect([200, 503]).toContain(res.status());
});
test('compliance API', async ({ request }) => {
const res = await request.get(`${BASE}/api/compliance/status`);
expect(res.ok()).toBeTruthy();
});
test('workspace templates API', async ({ request }) => {
const res = await request.get(`${BASE}/api/workspace-templates`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
const list = Array.isArray(data) ? data : data.templates ?? [];
expect(list.length).toBeGreaterThanOrEqual(7);
});
test('cost API responds', async ({ request }) => {
const res = await request.get(`${BASE}/api/cost/by-workspace`);
// Cost endpoint may return various codes depending on workspace state
expect(res.status()).toBeLessThan(600);
});
test('offline status API', async ({ request }) => {
const res = await request.get(`${BASE}/api/offline/status`);
expect(res.ok()).toBeTruthy();
});
test('cron API', async ({ request }) => {
const res = await request.get(`${BASE}/api/cron`);
expect(res.ok()).toBeTruthy();
});
test('skills API', async ({ request }) => {
const res = await request.get(`${BASE}/api/skills`);
expect(res.ok()).toBeTruthy();
});
test('connectors API', async ({ request }) => {
const res = await request.get(`${BASE}/api/connectors`);
expect(res.ok()).toBeTruthy();
});
test('backup metadata API', async ({ request }) => {
const res = await request.get(`${BASE}/api/backup/metadata`);
// May be 200 or 404 depending on backup existence
expect([200, 404]).toContain(res.status());
});
});
// ── 2. Desktop Shell ──────────────────────────────────────────────────
test.describe('2. Desktop Shell', () => {
test('status bar renders with clock', async ({ page }) => {
await gotoDesktop(page);
const text = await getVisibleText(page);
expect(text).toContain('Waggle AI');
});
test('sidebar renders in power tier', async ({ page }) => {
await gotoDesktop(page);
for (const label of ['Chat', 'Memory', 'Agents', 'Library']) {
const btn = page.locator('[role="navigation"]').locator('button', { hasText: label });
await expect(btn).toBeVisible({ timeout: 5000 });
}
});
test('Ctrl+K opens global search', async ({ page }) => {
await gotoDesktop(page);
await page.evaluate(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'k', code: 'KeyK', ctrlKey: true, bubbles: true,
}));
});
await page.waitForTimeout(500);
const searchInput = page.locator('input[placeholder*="Search"]');
await expect(searchInput).toBeVisible({ timeout: 3000 });
});
});
// ── 3. Every App Opens ───────────────────────────────────────────────
const DIRECT_APPS = [
{ label: 'Chat', expect: /persona|message|waggle/i },
{ label: 'Room', expect: /room|agent|specialist|no.*running|empty/i },
{ label: 'Agents', expect: /agent|task|persona|group/i },
{ label: 'Files', expect: /file|folder|workspace|document/i },
{ label: 'Approvals', expect: /approval|pending|no.*pending|history|upgrade|team/i },
];
// Phase 4B sweep: zone names/membership match dock-tiers.ts (System zone, not
// "Ops"; Skills Hub lives under Intelligence; Governance under Team; the
// Extend zone now carries Connector Hub + MCP Hub + Marketplace; Backup left
// the dock — it lives in Settings → Backup, P23).
const ZONE_APPS = [
{ label: 'Mission Control', zone: 'System', expect: /cockpit|health|cost|command/i },
{ label: 'Timeline', zone: 'System', expect: /timeline|activity|no.*activity|last/i },
{ label: 'Usage & Cost', zone: 'System', expect: /usage|telemetry|token|cost/i },
{ label: 'Events & Logs', zone: 'System', expect: /event|log|step|filter/i },
{ label: 'Team Governance', zone: 'Team', expect: /governance|role|team|permission/i },
{ label: 'Skills Hub', zone: 'Intelligence', expect: /skill|installed|marketplace|starter|build/i },
{ label: 'Connector Hub', zone: 'Extend', expect: /connector|connect|service|integration/i },
{ label: 'MCP Hub', zone: 'Extend', expect: /mcp|installed|catalog|server/i },
{ label: 'Marketplace', zone: 'Extend', expect: /marketplace|browse|extension|install/i },
];
test.describe('3. Direct Dock Apps', () => {
for (const app of DIRECT_APPS) {
test(`${app.label} opens and renders content`, async ({ page }) => {
await gotoDesktop(page);
await openCurrentApp(page, app.label);
const text = await getVisibleText(page);
expect(text).toMatch(app.expect);
});
}
});
test.describe('4. Zone Apps (Ops + Extend)', () => {
for (const app of ZONE_APPS) {
test(`${app.label} opens from ${app.zone} zone`, async ({ page }) => {
await gotoDesktop(page);
await openCurrentApp(page, app.label);
const text = await getVisibleText(page);
expect(text).toMatch(app.expect);
});
}
});
// ── 5. Standalone Apps ────────────────────────────────────────────────
test.describe('5. Standalone Apps', () => {
test('Settings opens', async ({ page }) => {
await gotoDesktop(page);
await openCurrentApp(page, 'Settings');
const text = await getVisibleText(page);
expect(text).toMatch(/setting|general|model|billing/i);
});
test('Vault opens', async ({ page }) => {
await gotoDesktop(page);
await openCurrentApp(page, 'Vault');
const text = await getVisibleText(page);
expect(text).toMatch(/vault|key|api|secret|provider/i);
});
test('Home (Dashboard) opens', async ({ page }) => {
await gotoDesktop(page);
await openCurrentApp(page, 'Home');
const text = await getVisibleText(page);
expect(text).toMatch(/workspace|welcome|dashboard|create/i);
});
});
// ── 6. User Journey: Workspace → Chat → Memory ───────────────────────
test.describe('6. User Journey', () => {
test('can open chat and see persona + model in header', async ({ page }) => {
await gotoDesktop(page);
await openCurrentApp(page, 'Chat');
await page.waitForTimeout(1500);
const text = await getVisibleText(page);
// Should see persona selector and model name
expect(text).toMatch(/persona|sonnet|claude|ollama|model|message waggle/i);
});
test('can open memory and see frames or empty state', async ({ page }) => {
await gotoDesktop(page);
// Memory might be in a zone or direct — try both
const memBtn = page.locator('button[aria-label="Memory"]');
if (await memBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await memBtn.click();
} else {
// Open via Ctrl+Shift+5 (shortcut)
await page.evaluate(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: '5', code: 'Digit5', ctrlKey: true, shiftKey: true, bubbles: true,
}));
});
}
await page.waitForTimeout(1000);
const text = await getVisibleText(page);
expect(text).toMatch(/memory|frame|knowledge|harvest|search/i);
});
});
// ── 7. No Console Errors ──────────────────────────────────────────────
test.describe('7. Stability', () => {
test('no critical console errors on load', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await gotoDesktop(page);
await page.waitForTimeout(3000);
const critical = errors.filter(e =>
!e.includes('Failed to fetch') && !e.includes('net::ERR') &&
!e.includes('favicon') && !e.includes('401') && !e.includes('404') &&
!e.includes('sync') && !e.includes('WebSocket') && !e.includes('fetch') &&
!e.includes('model') && !e.includes('chunk')
);
expect(critical).toHaveLength(0);
expect(errors.filter(e => /clerk|content security policy|csp/i.test(e))).toHaveLength(0);
});
test('no uncaught exceptions after opening 3 apps', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', err => errors.push(err.message));
await gotoDesktop(page);
await openCurrentApp(page, 'Chat');
await openCurrentApp(page, 'Room');
await openCurrentApp(page, 'Files');
await page.waitForTimeout(1000);
expect(errors).toHaveLength(0);
});
});

View File

@@ -0,0 +1,632 @@
/**
* Full E2E Wiring Audit — tests every major UI flow against the live backend.
*
* FIXED: Updated from old apps/web desktop OS (port 8080, dock/windows paradigm)
* to current Tauri app shell (port 3333, sidebar navigation paradigm).
*
* Run: node node_modules\playwright\cli.js test tests/e2e/full-wiring-audit.spec.ts --reporter=list
*/
import { test, expect, type Page } from '@playwright/test';
const API = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
// App is served by the same server; Playwright can override the URL for isolated runs.
// ── Helpers ──────────────────────────────────────────────────────────────────
async function skipOnboarding(page: Page) {
await page.evaluate(() => {
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
localStorage.setItem('waggle:first-run', 'done');
localStorage.setItem('waggle-booted', 'true');
});
}
async function waitForApp(page: Page) {
await page.waitForSelector(
'.waggle-app-shell, .waggle-sidebar, [role="navigation"], [class*="onboarding"]',
{ timeout: 15_000 },
).catch(() => {});
await page.waitForTimeout(600);
}
async function navigateSidebar(page: Page, label: string) {
const nav = page.locator('[role="navigation"]');
const sidebarSelectors: Record<string, string[]> = {
Chat: ['[data-testid="nav-chat"]', 'button[aria-label="Chat"]'],
Memory: ['[data-testid="nav-memory"]', 'button[aria-label="Memory"]'],
Settings: ['[data-testid="sidebar-user"]', 'button[aria-label="Account and settings"]'],
};
for (const selector of sidebarSelectors[label] ?? []) {
const btn = nav.locator(selector).first();
if (await btn.isVisible().catch(() => false)) {
await btn.click();
await page.waitForTimeout(500);
return true;
}
}
const textButton = nav.locator('button', { hasText: label }).first();
if (await textButton.isVisible().catch(() => false)) {
await textButton.click();
await page.waitForTimeout(500);
return true;
}
const routeByLabel: Record<string, string> = {
'Skills Hub': '/marketplace',
Events: '/settings/events',
Cockpit: '/settings/mission-control',
'Mission Control': '/settings/mission-control',
Settings: '/settings',
};
const route = routeByLabel[label];
if (route) {
await page.goto(`${route}?skipOnboarding=true&skipBoot=true`);
await waitForApp(page);
return true;
}
throw new Error(`No current navigation target configured for "${label}"`);
}
function collectErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', err => errors.push(err.message));
return errors;
}
// ══════════════════════════════════════════════════════════════════════════════
// SECTION 1 — Backend API Health (all formerly passing — keep identical)
// ══════════════════════════════════════════════════════════════════════════════
test.describe('Backend API Endpoints', () => {
test('GET /health returns ok', async ({ request }) => {
const res = await request.get(`${API}/health`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.status).toBeDefined();
expect(data.llm).toBeDefined();
});
test('GET /api/workspaces returns array', async ({ request }) => {
const res = await request.get(`${API}/api/workspaces`);
expect(res.ok()).toBeTruthy();
expect(Array.isArray(await res.json())).toBeTruthy();
});
test('GET /api/events returns object with events array', async ({ request }) => {
const res = await request.get(`${API}/api/events`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.events).toBeDefined();
expect(Array.isArray(data.events)).toBeTruthy();
});
test('GET /api/skills returns object with skills array', async ({ request }) => {
const res = await request.get(`${API}/api/skills`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(Array.isArray(data.skills)).toBeTruthy();
});
test('GET /api/memory/frames returns object with results array', async ({ request }) => {
const res = await request.get(`${API}/api/memory/frames?limit=5&workspace=default`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(Array.isArray(data.results)).toBeTruthy();
});
test('GET /api/connectors returns connectors', async ({ request }) => {
const res = await request.get(`${API}/api/connectors`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.connectors).toBeDefined();
});
test('GET /api/personas returns personas', async ({ request }) => {
const res = await request.get(`${API}/api/personas`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.personas).toBeDefined();
});
test('GET /api/fleet returns sessions', async ({ request }) => {
const res = await request.get(`${API}/api/fleet`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.sessions).toBeDefined();
});
test('GET /api/cron returns schedules', async ({ request }) => {
const res = await request.get(`${API}/api/cron`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.schedules).toBeDefined();
});
test('GET /api/marketplace/packs returns packs', async ({ request }) => {
const res = await request.get(`${API}/api/marketplace/packs`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.packs).toBeDefined();
});
test('GET /api/settings returns settings object', async ({ request }) => {
const res = await request.get(`${API}/api/settings`);
expect([200, 404]).toContain(res.status()); // May or may not exist
});
test('POST /api/workspaces creates workspace', async ({ request }) => {
const name = `E2E-Audit-${Date.now()}`;
const res = await request.post(`${API}/api/workspaces`, {
data: { name, group: 'Workspaces', description: 'Wiring audit test' },
});
expect([200, 201, 403, 409]).toContain(res.status());
if (res.ok()) {
const ws = await res.json();
const wsData = ws.workspace ?? ws.data ?? ws;
const id = wsData.id ?? wsData.name ?? wsData;
expect(id).toBeDefined();
}
});
test('GET /api/vault returns vault data', async ({ request }) => {
const res = await request.get(`${API}/api/vault`);
expect([200, 404]).toContain(res.status());
});
test('GET /api/costs returns cost data or 403', async ({ request }) => {
const res = await request.get(`${API}/api/costs`);
expect([200, 403]).toContain(res.status());
});
test('GET /api/tier returns tier info', async ({ request }) => {
const res = await request.get(`${API}/api/tier`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(['TRIAL', 'FREE', 'TEAMS', 'ENTERPRISE']).toContain(data.tier);
});
test('GET /api/hooks returns rules array', async ({ request }) => {
const res = await request.get(`${API}/api/hooks`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(Array.isArray(data.rules)).toBeTruthy();
});
test('GET /api/cloud-sync returns sync status', async ({ request }) => {
const res = await request.get(`${API}/api/cloud-sync`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(typeof data.available).toBe('boolean');
});
test('GET /api/marketplace/search returns packages', async ({ request }) => {
const res = await request.get(`${API}/api/marketplace/search?query=pdf&limit=3`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(Array.isArray(data.packages)).toBeTruthy();
});
});
// ══════════════════════════════════════════════════════════════════════════════
// SECTION 2 — App Shell Load (FIXED: port 3333, Tauri app shell selectors)
// ══════════════════════════════════════════════════════════════════════════════
test.describe('Frontend App Load', () => {
test('app loads at port 3333 without crash', async ({ page }) => {
const errors = collectErrors(page);
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
const fatalErrors = errors.filter(e =>
!e.includes('favicon') && !e.includes('net::ERR') &&
!e.includes('ResizeObserver') && !e.includes('404') &&
(e.includes('is not a function') || e.includes('Cannot read') || e.includes('Uncaught'))
);
expect(fatalErrors).toHaveLength(0);
});
test('onboarding or main shell renders — no blank screen', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1200);
const body = await page.textContent('body') ?? '';
expect(body.trim().length).toBeGreaterThan(20);
});
test('app shell renders after onboarding skip', async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
const html = await page.content();
expect(html.length).toBeGreaterThan(1000);
});
test('no 404 errors on static assets', async ({ page }) => {
const failed: string[] = [];
page.on('response', res => {
if (res.status() === 404 && !res.url().includes('/api/')) failed.push(res.url());
});
await page.goto('/');
await waitForApp(page);
expect(failed).toHaveLength(0);
});
});
// ══════════════════════════════════════════════════════════════════════════════
// SECTION 3 — Sidebar Navigation (FIXED: sidebar paradigm, not dock/windows)
// ══════════════════════════════════════════════════════════════════════════════
test.describe('Sidebar Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
});
test('sidebar navigation is visible with buttons', async ({ page }) => {
const nav = page.locator('[role="navigation"]');
const visible = await nav.isVisible().catch(() => false);
if (visible) {
const buttons = nav.locator('button');
expect(await buttons.count()).toBeGreaterThanOrEqual(5);
} else {
// App may be in onboarding — just verify it loaded
const body = await page.textContent('body') ?? '';
expect(body.length).toBeGreaterThan(50);
}
});
test('clicking Settings navigates to settings panel', async ({ page }) => {
const navigated = await navigateSidebar(page, 'Settings');
if (navigated) {
await page.waitForTimeout(500);
const body = await page.textContent('body') ?? '';
expect(body.includes('General') || body.includes('Models') || body.includes('Settings')).toBe(true);
}
});
test('clicking Capabilities navigates to marketplace', async ({ page }) => {
const navigated = await navigateSidebar(page, 'Skills Hub');
if (navigated) {
await page.waitForTimeout(500);
const body = await page.textContent('body') ?? '';
expect(body.length).toBeGreaterThan(50);
}
});
test('clicking Memory navigates to memory view', async ({ page }) => {
const navigated = await navigateSidebar(page, 'Memory');
if (navigated) {
await page.waitForTimeout(500);
const body = await page.textContent('body') ?? '';
expect(body.length).toBeGreaterThan(50);
}
});
test('clicking Chat navigates to chat view', async ({ page }) => {
const navigated = await navigateSidebar(page, 'Chat');
if (navigated) {
await page.waitForTimeout(500);
const body = await page.textContent('body') ?? '';
expect(body.length).toBeGreaterThan(50);
}
});
test('clicking Events navigates to events view', async ({ page }) => {
const navigated = await navigateSidebar(page, 'Events');
if (navigated) {
await page.waitForTimeout(500);
const body = await page.textContent('body') ?? '';
expect(body.length).toBeGreaterThan(50);
}
});
test('clicking Cockpit navigates to cockpit view', async ({ page }) => {
const navigated = await navigateSidebar(page, 'Cockpit');
if (navigated) {
await page.waitForTimeout(500);
const body = await page.textContent('body') ?? '';
expect(body.length).toBeGreaterThan(50);
}
});
});
// ══════════════════════════════════════════════════════════════════════════════
// SECTION 4 — Workspace Wiring
// ══════════════════════════════════════════════════════════════════════════════
test.describe('Workspace Wiring', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
});
test('workspaces load from API', async ({ page }) => {
const res = await page.request.get(`${API}/api/workspaces`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(Array.isArray(data)).toBeTruthy();
expect(data.length).toBeGreaterThanOrEqual(1);
});
test('workspace list is not empty', async ({ page }) => {
const res = await page.request.get(`${API}/api/workspaces`);
expect(res.ok()).toBeTruthy();
const workspaces = await res.json();
expect(Array.isArray(workspaces)).toBeTruthy();
expect(workspaces.length).toBeGreaterThanOrEqual(1);
});
});
// ══════════════════════════════════════════════════════════════════════════════
// SECTION 5 — Chat Wiring
// ══════════════════════════════════════════════════════════════════════════════
test.describe('Chat Wiring', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
});
test('chat view opens without JS crash', async ({ page }) => {
const errors = collectErrors(page);
await navigateSidebar(page, 'Chat');
await page.waitForTimeout(800);
const fatal = errors.filter(e =>
e.includes('is not a function') || e.includes('Cannot read properties of') || e.includes('is not defined')
);
expect(fatal).toEqual([]);
});
test('chat view renders message input or workspace selector', async ({ page }) => {
await navigateSidebar(page, 'Chat');
await page.waitForTimeout(600);
const hasTextarea = await page.locator('textarea').isVisible().catch(() => false);
const hasInput = await page.locator('input[type="text"]').isVisible().catch(() => false);
const body = await page.textContent('body') ?? '';
const hasContent = body.includes('Ask') || body.includes('message') || body.includes('workspace') || body.includes('Chat');
expect(hasTextarea || hasInput || hasContent).toBe(true);
});
test('chat textarea accepts text input', async ({ page }) => {
await navigateSidebar(page, 'Chat');
await page.waitForTimeout(600);
const textarea = page.locator('textarea').first();
if (await textarea.isVisible().catch(() => false)) {
await textarea.fill('Hello E2E test');
expect(await textarea.inputValue()).toContain('Hello');
}
});
});
// ══════════════════════════════════════════════════════════════════════════════
// SECTION 6 — Memory Wiring
// ══════════════════════════════════════════════════════════════════════════════
test.describe('Memory Wiring', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
});
test('memory view opens without JS crash', async ({ page }) => {
const errors = collectErrors(page);
await navigateSidebar(page, 'Memory');
await page.waitForTimeout(800);
const fatal = errors.filter(e =>
e.includes('is not a function') || e.includes('Cannot read properties of')
);
expect(fatal).toEqual([]);
});
test('memory API responds when memory view is open', async ({ page }) => {
await navigateSidebar(page, 'Memory');
const res = await page.request.get(`${API}/api/memory/frames?workspace=default&limit=5`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(Array.isArray(data.results)).toBeTruthy();
});
});
// ══════════════════════════════════════════════════════════════════════════════
// SECTION 7 — Settings Wiring
// ══════════════════════════════════════════════════════════════════════════════
test.describe('Settings Wiring', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
});
test('settings view opens without JS crash', async ({ page }) => {
const errors = collectErrors(page);
await navigateSidebar(page, 'Settings');
await page.waitForTimeout(800);
const fatal = errors.filter(e =>
e.includes('is not a function') || e.includes('Cannot read properties of')
);
expect(fatal).toEqual([]);
});
test('settings tabs are visible after navigation', async ({ page }) => {
await navigateSidebar(page, 'Settings');
await page.waitForTimeout(500);
const body = await page.textContent('body') ?? '';
// At least one settings tab label must appear
const hasTab = body.includes('General') || body.includes('Models') || body.includes('Keys') || body.includes('Advanced');
expect(hasTab).toBe(true);
});
});
// ══════════════════════════════════════════════════════════════════════════════
// SECTION 8 — Capabilities / Marketplace Wiring
// ══════════════════════════════════════════════════════════════════════════════
test.describe('Capabilities Wiring', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
});
test('capabilities view opens without JS crash', async ({ page }) => {
const errors = collectErrors(page);
await navigateSidebar(page, 'Skills Hub');
await page.waitForTimeout(800);
const fatal = errors.filter(e =>
e.includes('is not a function') || e.includes('Cannot read properties of')
);
expect(fatal).toEqual([]);
});
test('marketplace search API is accessible from capabilities view', async ({ page }) => {
await navigateSidebar(page, 'Skills Hub');
const res = await page.request.get(`${API}/api/marketplace/search?query=&limit=5`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(Array.isArray(data.packages)).toBeTruthy();
expect(data.packages.length).toBeGreaterThan(0);
});
});
// ══════════════════════════════════════════════════════════════════════════════
// SECTION 9 — Events + Cockpit + Mission Control Wiring
// ══════════════════════════════════════════════════════════════════════════════
test.describe('Events / Cockpit / Mission Control Wiring', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
});
test('events view opens without crash', async ({ page }) => {
const errors = collectErrors(page);
await navigateSidebar(page, 'Events');
await page.waitForTimeout(600);
const fatal = errors.filter(e => e.includes('is not a function') || e.includes('Cannot read'));
expect(fatal).toEqual([]);
});
test('cockpit view opens without crash', async ({ page }) => {
const errors = collectErrors(page);
await navigateSidebar(page, 'Cockpit');
await page.waitForTimeout(600);
const fatal = errors.filter(e => e.includes('is not a function') || e.includes('Cannot read'));
expect(fatal).toEqual([]);
});
test('mission control view opens without crash', async ({ page }) => {
const errors = collectErrors(page);
await navigateSidebar(page, 'Mission Control');
await page.waitForTimeout(600);
const fatal = errors.filter(e => e.includes('is not a function') || e.includes('Cannot read'));
expect(fatal).toEqual([]);
});
});
// ══════════════════════════════════════════════════════════════════════════════
// SECTION 10 — Console Error Audit (traverse all views, collect errors)
// ══════════════════════════════════════════════════════════════════════════════
test.describe('Full Console Error Audit', () => {
test('traverse all sidebar views — zero critical JS errors', async ({ page }) => {
const criticalErrors: { view: string; error: string }[] = [];
page.on('console', msg => {
if (msg.type() === 'error') {
const text = msg.text();
if (
text.includes('is not a function') ||
text.includes('Cannot read properties of') ||
text.includes('is not defined') ||
text.includes('Uncaught')
) {
criticalErrors.push({ view: 'unknown', error: text });
}
}
});
page.on('pageerror', err => {
criticalErrors.push({ view: 'pageerror', error: err.message });
});
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
const views = ['Chat', 'Memory', 'Events', 'Skills Hub', 'Cockpit', 'Mission Control', 'Settings'];
for (const view of views) {
const nav = page.locator('[role="navigation"]');
const btn = nav.locator('button', { hasText: view });
if (await btn.isVisible().catch(() => false)) {
const before = criticalErrors.length;
await btn.click();
await page.waitForTimeout(600);
// Tag any new errors with the view name
if (criticalErrors.length > before) {
criticalErrors.slice(before).forEach(e => e.view = view);
}
}
}
if (criticalErrors.length > 0) {
const report = criticalErrors.map(e => `[${e.view}] ${e.error}`).join('\n');
expect(criticalErrors.length, `Critical JS errors found:\n${report}`).toBe(0);
}
});
test('no 404 on API calls made during app lifecycle', async ({ page }) => {
const apiErrors: string[] = [];
page.on('response', res => {
if (res.url().includes('/api/') && res.status() === 404) {
apiErrors.push(`404: ${res.url()}`);
}
});
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
// Navigate through key views to trigger their API calls
for (const view of ['Chat', 'Settings', 'Skills Hub']) {
const btn = page.locator('[role="navigation"] button', { hasText: view });
if (await btn.isVisible().catch(() => false)) {
await btn.click();
await page.waitForTimeout(500);
}
}
// Filter out expected 404s (endpoints that are legitimately optional)
const unexpectedErrors = apiErrors.filter(url =>
!url.includes('favicon') && !url.includes('notifications/history')
);
if (unexpectedErrors.length > 0) {
console.log('API 404s detected:', unexpectedErrors);
}
// Warn but don't fail — some 404s may be expected for unimplemented optional endpoints
expect(unexpectedErrors.length).toBeLessThan(5);
});
});

View File

@@ -0,0 +1,162 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
type HookEnvelope = {
ok: boolean;
action: 'install' | 'verify' | 'uninstall';
packageName: string;
stdout: string;
stderr: string;
code: number;
error?: string;
};
type HookToolCase = {
id: 'claude-code' | 'codex' | 'codex-desktop' | 'cursor' | 'hermes' | 'openclaw';
packageName: string;
configDir: string;
configFile: string;
precreateConfig?: string;
managedHookDir?: string;
};
const HOOK_TOOL_CASES: HookToolCase[] = [
{
id: 'claude-code',
packageName: '@waggle/hive-mind-hooks-claude-code',
configDir: '.claude',
configFile: 'settings.json',
precreateConfig: '{}\n',
},
{
id: 'codex',
packageName: '@waggle/hive-mind-hooks-codex',
configDir: '.codex',
configFile: 'hooks.json',
},
{
id: 'codex-desktop',
packageName: '@waggle/hive-mind-hooks-codex-desktop',
configDir: '.codex',
configFile: 'hooks.json',
},
{
id: 'cursor',
packageName: '@waggle/hive-mind-hooks-cursor',
configDir: '.cursor',
configFile: 'hooks.json',
},
{
id: 'hermes',
packageName: '@waggle/hive-mind-hooks-hermes',
configDir: '.hermes',
configFile: 'config.yaml',
},
{
id: 'openclaw',
packageName: '@waggle/hive-mind-hooks-openclaw',
configDir: '.openclaw',
configFile: 'openclaw.json',
managedHookDir: path.join('hooks', 'hive-mind'),
},
];
function writeFakeHiveMindCli(root: string): string {
const cliPath = path.join(root, 'fake-hive-mind-cli.js');
fs.writeFileSync(
cliPath,
[
'#!/usr/bin/env node',
"if (process.argv.includes('--help')) {",
" console.log('hive-mind-cli test help');",
' process.exit(0);',
'}',
"console.error('unexpected fake hive-mind-cli invocation');",
'process.exit(1);',
'',
].join('\n'),
'utf8',
);
return cliPath;
}
test.describe('Launcher real hook lifecycle', () => {
test('runs every hook-capable tool install, verify, and uninstall through the sidecar route in an isolated profile', async ({ request }) => {
test.setTimeout(180_000);
test.skip(
process.env.WAGGLE_E2E_REAL_HOOKS !== '1' || !process.env.WAGGLE_E2E_HOOK_HOME,
'Set WAGGLE_E2E_REAL_HOOKS=1 and WAGGLE_E2E_HOOK_HOME to a throwaway profile; also set USERPROFILE/HOME to that profile before the server starts.',
);
const hookHome = process.env.WAGGLE_E2E_HOOK_HOME!;
fs.mkdirSync(hookHome, { recursive: true });
const fakeCliPath = writeFakeHiveMindCli(hookHome);
const postHook = async (tool: HookToolCase, action: 'install' | 'verify' | 'uninstall') => {
const response = await request.post('/api/tools/hooks', {
data: {
id: tool.id,
action,
...(action === 'install' ? { cliPath: fakeCliPath } : {}),
},
});
expect(response.status(), await response.text()).toBe(200);
const body = await response.json() as HookEnvelope;
expect(body, body.error ?? body.stderr).toMatchObject({
ok: true,
action,
packageName: tool.packageName,
code: 0,
});
return body;
};
try {
for (const tool of HOOK_TOOL_CASES) {
const toolRoot = path.join(hookHome, tool.configDir);
const configPath = path.join(toolRoot, tool.configFile);
const pointerPath = path.join(toolRoot, 'hive-mind-install.json');
const managedHookDir = tool.managedHookDir
? path.join(toolRoot, tool.managedHookDir)
: null;
await test.step(`${tool.id} hook lifecycle`, async () => {
fs.rmSync(toolRoot, { recursive: true, force: true });
if (tool.precreateConfig !== undefined) {
fs.mkdirSync(toolRoot, { recursive: true });
fs.writeFileSync(configPath, tool.precreateConfig, 'utf8');
}
const install = await postHook(tool, 'install');
expect(install.stdout).toContain('install');
expect(fs.existsSync(configPath)).toBe(true);
expect(fs.existsSync(pointerPath)).toBe(true);
if (managedHookDir) {
expect(fs.existsSync(managedHookDir)).toBe(true);
}
const verify = await postHook(tool, 'verify');
expect(verify.stdout).toContain('All checks passed.');
const uninstall = await postHook(tool, 'uninstall');
expect(uninstall.stdout).toContain('uninstall');
expect(fs.existsSync(pointerPath)).toBe(false);
if (tool.precreateConfig !== undefined) {
expect(fs.readFileSync(configPath, 'utf8')).toBe(tool.precreateConfig);
} else {
expect(fs.existsSync(configPath)).toBe(false);
}
if (managedHookDir) {
expect(fs.existsSync(managedHookDir)).toBe(false);
}
});
}
} finally {
for (const tool of HOOK_TOOL_CASES) {
fs.rmSync(path.join(hookHome, tool.configDir), { recursive: true, force: true });
}
fs.rmSync(fakeCliPath, { force: true });
}
});
});

View File

@@ -0,0 +1,140 @@
import { expect, test } from '@playwright/test';
const SKIP_PARAMS = 'skipOnboarding=true&skipBoot=true&skipBriefing=true&tier=power';
const PREFERRED_TOOL_IDS = ['openclaw', 'claude-code', 'hermes'] as const;
const SAFE_ARGS_BY_TOOL: Record<string, string[]> = {
openclaw: ['--version'],
'claude-code': ['--version'],
hermes: ['--version'],
};
type DetectedTool = {
id: string;
displayName: string;
installed: boolean;
installedPath: string | null;
launchable?: boolean;
};
type DetectionEnvelope = {
tools: DetectedTool[];
};
type LaunchEnvelope = {
ok: boolean;
pid: number | null;
error?: string;
};
function routeWithSkip(route: string): string {
const sep = route.includes('?') ? '&' : '?';
return `${route}${sep}${SKIP_PARAMS}`;
}
function chooseSafeTool(tools: DetectedTool[]): DetectedTool | undefined {
return PREFERRED_TOOL_IDS
.map((id) => tools.find((tool) => tool.id === id && tool.installed && tool.installedPath && tool.launchable))
.find((tool): tool is DetectedTool => Boolean(tool));
}
async function readObservedStream(
baseURL: string,
pid: number,
): Promise<{ lines: string[]; exitCode: number | null | undefined }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
const lines: string[] = [];
let exitCode: number | null | undefined;
try {
const response = await fetch(new URL(`/api/tools/stream?pid=${pid}`, baseURL), {
signal: controller.signal,
});
expect(response.ok).toBe(true);
expect(response.body).toBeTruthy();
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (exitCode === undefined) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let eventEnd = buffer.indexOf('\n\n');
while (eventEnd >= 0) {
const rawEvent = buffer.slice(0, eventEnd);
buffer = buffer.slice(eventEnd + 2);
eventEnd = buffer.indexOf('\n\n');
let event = 'message';
let data = '';
for (const line of rawEvent.split('\n')) {
if (line.startsWith('event:')) event = line.slice('event:'.length).trim();
if (line.startsWith('data:')) data += line.slice('data:'.length).trim();
}
if (event === 'line') {
lines.push((JSON.parse(data) as { line: string }).line);
}
if (event === 'exit') {
exitCode = (JSON.parse(data) as { code: number | null }).code;
break;
}
}
}
} finally {
clearTimeout(timeout);
}
return { lines, exitCode };
}
test.describe('Launcher real tool lifecycle', () => {
test('renders a real detected CLI and observes a safe launch to exit', async ({ baseURL, page, request }) => {
test.skip(
process.env.WAGGLE_E2E_REAL_TOOLS !== '1',
'Set WAGGLE_E2E_REAL_TOOLS=1 on a machine with Claude, Hermes, or OpenClaw installed.',
);
const root = baseURL ?? process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
const detectionResponse = await request.get('/api/tools/detect');
expect(detectionResponse.ok()).toBe(true);
const detection = await detectionResponse.json() as DetectionEnvelope;
const tool = chooseSafeTool(detection.tools);
expect(tool, 'expected at least one safe real CLI tool to be installed').toBeTruthy();
await page.goto(routeWithSkip('/launcher?watch=1'), { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.waggle-sidebar, [role="navigation"], main', { timeout: 15_000 });
await expect(page.getByText('Tool Launcher')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(tool!.displayName, { exact: true })).toBeVisible({ timeout: 10_000 });
const launchResponse = await request.post('/api/tools/launch', {
data: {
id: tool!.id,
installedPath: tool!.installedPath,
args: SAFE_ARGS_BY_TOOL[tool!.id],
workspaceId: 'e2e-real-tool-lifecycle',
observe: true,
},
});
expect(launchResponse.status()).toBe(202);
const launch = await launchResponse.json() as LaunchEnvelope;
expect(launch, launch.error).toMatchObject({ ok: true });
expect(launch.pid).toEqual(expect.any(Number));
const stream = await readObservedStream(root, launch.pid!);
expect(stream.exitCode).toBe(0);
expect(stream.lines.join('\n').trim().length).toBeGreaterThan(0);
await expect.poll(async () => {
const processesResponse = await request.get('/api/tools/processes');
expect(processesResponse.ok()).toBe(true);
const body = await processesResponse.json() as {
processes: Array<{ pid: number }>;
};
return body.processes.some((process) => process.pid === launch.pid);
}, { timeout: 5_000 }).toBe(false);
});
});

View File

@@ -0,0 +1,334 @@
import { test, expect, type Page } from '@playwright/test';
const SKIP_PARAMS = 'skipOnboarding=true&skipBoot=true&skipBriefing=true&tier=power';
function routeWithSkip(route: string): string {
const sep = route.includes('?') ? '&' : '?';
return `${route}${sep}${SKIP_PARAMS}`;
}
async function waitForShell(page: Page): Promise<void> {
await page.waitForSelector('.waggle-sidebar, [role="navigation"], main', { timeout: 15_000 });
await page.waitForTimeout(300);
}
async function gotoLauncher(page: Page): Promise<void> {
await page.goto(routeWithSkip('/launcher?watch=1'), { waitUntil: 'domcontentloaded' });
await waitForShell(page);
await expect(page.getByText('Tool Launcher')).toBeVisible({ timeout: 10_000 });
}
async function mockProcesses(page: Page): Promise<void> {
await page.route('**/api/tools/processes', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ processes: [], total: 0 }),
}));
}
const HOOK_RENDER_CASES = [
{ id: 'claude-code', displayName: 'Claude Code', packageName: '@waggle/hive-mind-hooks-claude-code', configDir: '.claude', configFile: 'settings.json' },
{ id: 'codex', displayName: 'Codex CLI', packageName: '@waggle/hive-mind-hooks-codex', configDir: '.codex', configFile: 'hooks.json' },
{ id: 'codex-desktop', displayName: 'Codex Desktop', packageName: '@waggle/hive-mind-hooks-codex-desktop', configDir: '.codex', configFile: 'hooks.json' },
{ id: 'cursor', displayName: 'Cursor', packageName: '@waggle/hive-mind-hooks-cursor', configDir: '.cursor', configFile: 'hooks.json' },
{ id: 'hermes', displayName: 'Hermes Agent', packageName: '@waggle/hive-mind-hooks-hermes', configDir: '.hermes', configFile: 'config.yaml' },
{ id: 'openclaw', displayName: 'OpenClaw', packageName: '@waggle/hive-mind-hooks-openclaw', configDir: '.openclaw', configFile: 'openclaw.json' },
] as const;
test.describe('Launcher rendered states', () => {
test('sidecar-offline detection shows an inline retry action', async ({ page }) => {
let detectCalls = 0;
await mockProcesses(page);
await page.route('**/api/tools/detect', route => {
detectCalls += 1;
if (detectCalls === 1) {
return route.abort('failed');
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
platform: 'linux',
detectedAt: '2026-07-08T00:00:00.000Z',
tools: [
{
id: 'foo-cli',
displayName: 'Foo CLI',
launchable: true,
hookCapable: false,
builtin: false,
acceptsInlinePrompt: true,
installed: true,
installedPath: '/usr/local/bin/foo',
version: '1.0.0',
hooksInstalled: false,
hookPointerPath: null,
},
],
}),
});
});
await gotoLauncher(page);
await expect(page.getByText(/sidecar may be offline/i)).toBeVisible();
await page.getByRole('button', { name: /retry tool detection/i }).click();
await expect(page.getByText('Foo CLI')).toBeVisible();
await expect(page.getByText(/sidecar may be offline/i)).not.toBeVisible();
});
test('long hook stderr is summarized instead of flooding the rendered panel', async ({ page }) => {
await mockProcesses(page);
await page.route('**/api/tools/detect', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
platform: 'linux',
detectedAt: '2026-07-08T00:00:00.000Z',
tools: [
{
id: 'codex',
displayName: 'Codex CLI',
launchable: true,
hookCapable: true,
builtin: true,
acceptsInlinePrompt: true,
installed: true,
installedPath: '/usr/local/bin/codex',
version: '1.0.0',
hooksInstalled: false,
hookPointerPath: null,
},
],
}),
}));
await page.route('**/api/tools/hooks', route => route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
ok: false,
action: 'verify',
packageName: '@waggle/hive-mind-hooks-codex',
stdout: '',
stderr: [
'failure detail 1: missing hook pointer',
'failure detail 2: stale backup file',
'failure detail 3: cli not trusted',
'failure detail 4: config mismatch',
'failure detail 5: lifecycle skipped',
'failure detail 6: retry recommended',
'failure detail 7: noisy internal trace',
'failure detail 8: noisy internal trace',
].join('\n'),
code: 1,
error: 'verify failed',
}),
}));
await gotoLauncher(page);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect(page.getByText(/verify failed/i)).toBeVisible();
await expect(page.getByText('More output')).toBeVisible();
await expect(page.getByText(/2 additional hook output lines hidden/i)).toBeVisible();
await expect(page.getByText(/failure detail 8/i)).not.toBeVisible();
await expect(page.getByText('Recovery')).toBeVisible();
});
test('standard hook install output renders changed file, pointer, backup, and recovery labels', async ({ page }) => {
await mockProcesses(page);
await page.route('**/api/tools/detect', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
platform: 'linux',
detectedAt: '2026-07-08T00:00:00.000Z',
tools: [
{
id: 'codex',
displayName: 'Codex CLI',
launchable: true,
hookCapable: true,
builtin: true,
acceptsInlinePrompt: true,
installed: true,
installedPath: '/usr/local/bin/codex',
version: '1.0.0',
hooksInstalled: false,
hookPointerPath: null,
},
],
}),
}));
await page.route('**/api/tools/hooks', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
action: 'install',
packageName: '@waggle/hive-mind-hooks-codex',
stdout: [
'hive-mind/codex-hooks: install',
' - hooks.json: /home/.codex/hooks.json',
' - install pointer: /home/.codex/hive-mind-install.json',
' - backup: /home/.codex/hooks.json.hive-mind-backup.2026-07-08T19-00-00Z',
'Done. Run "codex" once and approve the hook command if prompted.',
].join('\n'),
stderr: '',
code: 0,
}),
}));
await gotoLauncher(page);
await page.getByRole('button', { name: /install hooks/i }).click();
await expect(page.getByText(/Codex CLI: install OK/i)).toBeVisible();
await expect(page.getByText('Changed file', { exact: true })).toBeVisible();
await expect(page.getByText('/home/.codex/hooks.json', { exact: true })).toBeVisible();
await expect(page.getByText('Install pointer', { exact: true })).toBeVisible();
await expect(page.getByText('/home/.codex/hive-mind-install.json', { exact: true })).toBeVisible();
await expect(page.getByText('Backup', { exact: true })).toBeVisible();
await expect(page.getByText(/hooks\.json\.hive-mind-backup/i)).toBeVisible();
await expect(page.getByText('Recovery', { exact: true })).toBeVisible();
await expect(page.getByText(/hive-mind\/codex-hooks: install/i)).not.toBeVisible();
});
test('all hook-capable tools render install, verify, and uninstall state transitions', async ({ page }) => {
const hookState = new Map(HOOK_RENDER_CASES.map(tool => [tool.id, false]));
await mockProcesses(page);
await page.route('**/api/tools/detect', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
platform: 'linux',
detectedAt: '2026-07-09T00:00:00.000Z',
tools: HOOK_RENDER_CASES.map(tool => ({
id: tool.id,
displayName: tool.displayName,
launchable: true,
hookCapable: true,
builtin: true,
acceptsInlinePrompt: true,
installed: true,
installedPath: `/usr/local/bin/${tool.id}`,
version: '1.0.0',
hooksInstalled: hookState.get(tool.id) === true,
hookPointerPath: hookState.get(tool.id) === true
? `/home/${tool.configDir}/hive-mind-install.json`
: null,
})),
}),
}));
await page.route('**/api/tools/hooks', route => {
const body = route.request().postDataJSON() as { id: string; action: 'install' | 'verify' | 'uninstall' };
const tool = HOOK_RENDER_CASES.find(item => item.id === body.id);
if (!tool) {
return route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ ok: false, action: body.action, error: 'unknown tool' }),
});
}
if (body.action === 'install') hookState.set(tool.id, true);
if (body.action === 'uninstall') hookState.set(tool.id, false);
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
action: body.action,
packageName: tool.packageName,
stdout: body.action === 'verify'
? 'All checks passed.'
: [
`hive-mind/${tool.id}-hooks: ${body.action}`,
` - ${tool.configFile}: /home/${tool.configDir}/${tool.configFile}`,
` - install pointer: /home/${tool.configDir}/hive-mind-install.json`,
' - backup removed: yes',
'Done.',
].join('\n'),
stderr: '',
code: 0,
}),
});
});
await gotoLauncher(page);
for (const tool of HOOK_RENDER_CASES) {
const card = page.getByTestId(`launcher-tool-${tool.id}`);
await expect(card.getByText(tool.displayName, { exact: true })).toBeVisible();
await card.getByRole('button', { name: /install hooks/i }).click();
await expect(page.getByText(`${tool.displayName}: install OK`, { exact: true })).toBeVisible();
await expect(card.getByText('Hooks active', { exact: true })).toBeVisible();
await card.getByRole('button', { name: /^Verify$/ }).click();
await expect(page.getByText(`${tool.displayName}: verify OK`, { exact: true })).toBeVisible();
await card.getByRole('button', { name: /uninstall hooks/i }).click();
await expect(page.getByText(`${tool.displayName}: uninstall OK`, { exact: true })).toBeVisible();
await expect(card.getByText('Hooks active', { exact: true })).not.toBeVisible();
await expect(card.getByRole('button', { name: /install hooks/i })).toBeVisible();
}
});
test('third-party adapter renders launch-only state and sends its prompt', async ({ page }) => {
let launchPayload: unknown = null;
await mockProcesses(page);
await page.route('**/api/tools/detect', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
platform: 'linux',
detectedAt: '2026-07-08T00:00:00.000Z',
tools: [
{
id: 'foo-cli',
displayName: 'Foo CLI',
launchable: true,
hookCapable: false,
builtin: false,
acceptsInlinePrompt: true,
installed: true,
installedPath: '/usr/local/bin/foo',
version: '2.1.0',
hooksInstalled: false,
hookPointerPath: null,
},
],
}),
}));
await page.route('**/api/tools/launch', async route => {
launchPayload = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, pid: 9876 }),
});
});
await gotoLauncher(page);
await expect(page.getByText('Foo CLI', { exact: true })).toBeVisible();
await expect(page.getByText(/v2\.1\.0/i)).toBeVisible();
await expect(page.getByText(/Sent to:/i)).not.toBeVisible();
await expect(page.getByText(/Launch only/i)).toBeVisible();
await expect(page.getByText(/Hook management is not supported for this tool yet/i)).toBeVisible();
await expect(page.getByRole('button', { name: /^Launch$/ })).toBeVisible();
await expect(page.getByRole('button', { name: /install hooks/i })).not.toBeVisible();
await expect(page.getByRole('button', { name: /^Verify$/ })).not.toBeVisible();
await page.getByLabel(/optional launch prompt/i).fill('summarize adapter context');
await expect(page.getByText('Sent to: Foo CLI', { exact: true })).toBeVisible();
await page.getByRole('button', { name: /^Launch$/ }).click();
await expect(page.getByText(/Launched Foo CLI with prompt \(pid 9876\)/i)).toBeVisible();
expect(launchPayload).toMatchObject({
id: 'foo-cli',
installedPath: '/usr/local/bin/foo',
prompt: 'summarize adapter context',
observe: true,
});
});
});

View File

@@ -0,0 +1,140 @@
/**
* H-04 (P40) + H-05 (P41) · Light-mode BootScreen + header polish.
*
* Phase A/B commit 8782cab already moved the Waggle logo to a
* theme-aware asset swap and ensured BootScreen + StatusBar use
* semantic tokens (text-foreground / bg-background / text-primary).
* This spec is the behavioural regression:
*
* H-04 — BootScreen in light mode: mounts, progress track is
* readable (non-zero contrast against background), and the
* light-variant PNG logo is served (not the dark JPEG).
* H-05 — "Waggle AI" title renders with the light-mode foreground
* colour (non-zero contrast against the light background).
*
* We rely on getComputedStyle assertions instead of pixel snapshots
* so the test stays stable across font-rendering / browser-build
* differences.
*
* Run: npx playwright test tests/e2e/light-mode-polish.spec.ts --reporter=list
*/
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
const BOOT_SCREEN = '[data-testid="boot-screen"]';
async function seedLightModeFreshBoot(page: Page) {
// Install the theme flag + clear the boot gate BEFORE React mounts so
// the first render is already in light mode and the BootScreen shows.
await page.addInitScript(() => {
try {
window.localStorage.setItem('waggle-theme', 'light');
window.localStorage.removeItem('waggle-booted');
} catch {
// localStorage unavailable — test will still cover the visual side
}
});
}
/**
* Compute relative luminance for an rgb(...) colour string (sRGB per
* WCAG 2.1). Used to assert text is distinguishable from background
* without baking exact hex values into the test.
*/
function relativeLuminance(rgb: string): number {
const match = rgb.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
if (!match) return NaN;
const [r, g, b] = [match[1], match[2], match[3]].map(v => parseInt(v, 10) / 255);
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function contrast(a: string, b: string): number {
const la = relativeLuminance(a);
const lb = relativeLuminance(b);
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
test.describe('H-04 · BootScreen in light mode', () => {
test('mounts with light theme attribute and light-variant logo', async ({ page }) => {
await seedLightModeFreshBoot(page);
await page.goto(`${BASE}/`);
await expect(page.locator(BOOT_SCREEN)).toBeVisible({ timeout: 1_500 });
// Theme attribute landed before mount.
const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
expect(theme).toBe('light');
// Logo asset: light variant is the .png export (transparent, black
// WAGGLE text), dark variant is the .jpeg. useIsLightTheme flips the
// src. We check the rendered <img> inside BootScreen.
const logoSrc = await page.locator(`${BOOT_SCREEN} img[alt="Waggle AI"]`).getAttribute('src');
expect(logoSrc).toBeTruthy();
expect(logoSrc).toMatch(/\.(png|webp)(\?|$)/i);
});
test('progress fill stands out against the track in light mode', async ({ page }) => {
await seedLightModeFreshBoot(page);
await page.goto(`${BASE}/`);
await expect(page.locator(BOOT_SCREEN)).toBeVisible({ timeout: 1_500 });
// What matters for the "animation stays visible" claim is that the
// PROGRESS FILL (bg-primary) is distinguishable from the TRACK
// (bg-muted) — that's what the user perceives as progress. The
// track/background contrast is deliberately low because `muted`
// is, by design, near-background.
const sample = await page.evaluate(() => {
const root = document.querySelector('[data-testid="boot-screen"]') as HTMLElement | null;
if (!root) return null;
const track = root.querySelector('.bg-muted') as HTMLElement | null;
const fill = track?.querySelector('.bg-primary') as HTMLElement | null;
if (!track || !fill) return null;
return {
bg: window.getComputedStyle(root).backgroundColor,
trackBg: window.getComputedStyle(track).backgroundColor,
fillBg: window.getComputedStyle(fill).backgroundColor,
};
});
expect(sample, 'progress bar markup must be present').not.toBeNull();
// The progress bar is decorative, not an essential UI component for
// WCAG contrast purposes, and the brand primary is honey (#e5a000)
// which does not reach 3:1 against any near-white background. The
// regression we actually care about is "fill is distinguishable from
// track at all" — if muted and primary rendered the same in light
// mode (e.g. both fell back to white), the bar would vanish. Anything
// above 1.3:1 proves that didn't happen.
expect(contrast(sample!.fillBg, sample!.trackBg)).toBeGreaterThan(1.3);
// And fill is not the same as the root background — otherwise the
// filled portion bleeds into the surrounding area.
expect(sample!.fillBg).not.toBe(sample!.bg);
});
});
test.describe('H-05 · Header + title text in light mode', () => {
test('Waggle AI title contrasts with the light background', async ({ page }) => {
await seedLightModeFreshBoot(page);
await page.goto(`${BASE}/`);
await expect(page.locator(BOOT_SCREEN)).toBeVisible({ timeout: 1_500 });
const sample = await page.evaluate(() => {
const root = document.querySelector('[data-testid="boot-screen"]') as HTMLElement | null;
if (!root) return null;
const heading = root.querySelector('h1') as HTMLElement | null;
if (!heading) return null;
return {
bg: window.getComputedStyle(root).backgroundColor,
fg: window.getComputedStyle(heading).color,
text: heading.textContent?.trim() ?? '',
};
});
expect(sample).not.toBeNull();
expect(sample!.text).toBe('Waggle AI');
// WCAG AA for normal text: 4.5:1. BootScreen uses a display font
// (large), where the AA threshold drops to 3.0:1 — we keep 4.0 as
// a defensive minimum.
expect(contrast(sample!.bg, sample!.fg)).toBeGreaterThanOrEqual(4.0);
});
});

View File

@@ -0,0 +1,109 @@
/**
* Live Chat Flow — tests the REAL product loop with actual LLM calls.
*
* Requires: WAGGLE_E2E_LIVE_CHAT=1 and a deterministic live provider.
* This is the test that proves the product actually works.
*/
import { test, expect, type APIRequestContext, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
const RUN_LIVE_CHAT = process.env.WAGGLE_E2E_LIVE_CHAT === '1';
test.setTimeout(120_000);
test.skip(!RUN_LIVE_CHAT, 'Set WAGGLE_E2E_LIVE_CHAT=1 with a deterministic live provider to run live chat assertions.');
async function dismissOverlay(page: Page) {
for (let i = 0; i < 3; i++) {
const overlay = page.locator('.fixed.backdrop-blur-sm');
if (!await overlay.isVisible({ timeout: 1000 }).catch(() => false)) break;
const btn = page.locator('button:has-text("Start Working")');
if (await btn.isVisible({ timeout: 500 }).catch(() => false)) {
await btn.click({ force: true });
await page.waitForTimeout(500);
continue;
}
await page.mouse.click(5, 5);
await page.waitForTimeout(500);
}
}
async function gotoDesktop(page: Page) {
await page.goto(`${BASE}/home?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[role="navigation"], main', { timeout: 15_000 });
await page.waitForTimeout(500);
await dismissOverlay(page);
}
async function firstWorkspaceId(request: APIRequestContext): Promise<string> {
const wsRes = await request.get(`${BASE}/api/workspaces`);
const workspaces = await wsRes.json();
expect(Array.isArray(workspaces)).toBeTruthy();
expect(workspaces.length).toBeGreaterThan(0);
return workspaces[0].id;
}
async function createChatTurn(request: APIRequestContext, workspaceId: string, message: string) {
const res = await request.post(`${BASE}/api/chat`, {
data: { message, workspaceId },
headers: { 'Content-Type': 'application/json' },
timeout: 90_000,
});
expect(res.ok()).toBeTruthy();
const body = await res.text();
expect(body).toContain('event:');
expect(body.length).toBeGreaterThan(50);
}
// ── Verify LLM is available ───────────────────────────────────────────
test('LLM provider is healthy', async ({ request }) => {
const res = await request.get(`${BASE}/health`);
const data = await res.json();
expect(data.llm.health).toBe('healthy');
expect(data.llm.reachable).toBe(true);
});
// ── Core loop: send message → get response ────────────────────────────
test('send a message and get a real LLM response', async ({ page }) => {
await gotoDesktop(page);
// Open chat
await page.locator('button[aria-label="Chat"]').click();
await page.waitForSelector('textarea', { timeout: 15_000 });
// Find the chat input
const input = page.locator('textarea').first();
await expect(input).toBeVisible({ timeout: 5000 });
// Type a simple message
await input.fill('Reply with exactly WAGGLE_TEST_OK and no other text.');
await page.waitForTimeout(300);
// Send (press Enter or click send button)
await input.press('Enter');
// Wait for the response — the agent should stream tokens back
// Look for assistant message content appearing in the chat
const response = page.locator('text=/WAGGLE_TEST_OK|waggle_test_ok|test.ok/i');
await expect(response.first()).toBeVisible({ timeout: 90_000 });
});
// ── Memory save flow ──────────────────────────────────────────────────
test('agent response saves to session history', async ({ request }) => {
const workspaceId = await firstWorkspaceId(request);
await createChatTurn(request, workspaceId, 'Say WAGGLE_HISTORY_OK in one token.');
const sessRes = await request.get(`${BASE}/api/workspaces/${workspaceId}/sessions`);
const sessions = await sessRes.json();
expect(Array.isArray(sessions)).toBeTruthy();
expect(sessions.length).toBeGreaterThan(0);
});
// ── Chat streaming works ──────────────────────────────────────────────
test('chat SSE stream delivers tokens', async ({ request }) => {
const workspaceId = await firstWorkspaceId(request);
await createChatTurn(request, workspaceId, 'Say hello in one word.');
});

View File

@@ -0,0 +1,324 @@
/**
* Phase A/B Verification — E2E tests for the Room + Tiered Autonomy features,
* RETARGETED to the P1a AppShell route contract (the window manager is
* retired — docs/ux-refactor/appshell-conversion-plan.md §3.1):
*
* Bug #1: Default model shows sonnet, not opus
* Bug #2: Onboarding auto-skip for returning users
* Bug #7: Ctrl+Shift+N navigates to the active workspace's chat route
* (window spawning retired, §4.2)
* A.2: DROPPED — concurrent same-workspace multi-persona chat windows
* were consciously removed (§9.10 / §4.3); per-workspace persona
* survives in the widget header.
* A.3: Room opens via the left nav (same aria-labels as the old dock)
* A.4: waggle-window-state-v1 → waggle-chat-state-v1 migration
* (acceptance check 6; the legacy key is deleted, §3.3)
* B.4/B.5: Autonomy chip present in chat header
*
* Run: npx playwright test tests/e2e/phase-ab-verification.spec.ts --reporter=list
*/
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
async function gotoDesktop(page: Page) {
await page.goto(`${BASE}/home?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('[role="navigation"], main', { timeout: 10_000 });
await dismissOverlay(page);
}
async function dismissOverlay(page: Page) {
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);
}
}
// The AppShell left nav reuses the dock's aria-labels (plan §1.3), so the
// old dock-driven helper survives as a nav-driven one.
async function openAppViaDock(page: Page, label: string) {
const routes: Record<string, string> = {
Chat: '/workspaces/default-workspace/chat',
Room: '/room',
Approvals: '/approvals',
};
const routePatterns: Record<string, RegExp> = {
Chat: /\/workspaces\/[^/]+\/chat/,
Room: /\/room/,
Approvals: /\/approvals/,
};
const route = routes[label];
const btn = page.locator(`button[aria-label="${label}"]`);
if (await btn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await btn.click();
const pattern = routePatterns[label];
if (route && pattern) {
await page.waitForURL(pattern, { timeout: 2_500 }).catch(async () => {
await page.goto(`${BASE}${route}?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
waitUntil: 'domcontentloaded',
});
});
await page.waitForSelector('[role="navigation"], main', { timeout: 10_000 });
} else {
await page.waitForTimeout(500);
}
return;
}
if (!route) throw new Error(`No current app route for ${label}`);
await page.goto(`${BASE}${route}?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('[role="navigation"], main', { timeout: 10_000 });
}
// ── Bug #2: Onboarding auto-skip ──────────────────────────────────────────
test.describe('Bug #2 — Onboarding auto-skip', () => {
test('returning user with skipOnboarding param bypasses wizard', async ({ page }) => {
await gotoDesktop(page);
const wizard = page.locator('[class*="onboarding"], [class*="Onboarding"], [class*="wizard"]');
const wizardVisible = await wizard.isVisible().catch(() => false);
expect(wizardVisible).toBe(false);
});
test('desktop hero or dock is visible after skip', async ({ page }) => {
await gotoDesktop(page);
const dockOrHero = page.locator('button[aria-label="Chat"], h1:has-text("Waggle")');
await expect(dockOrHero.first()).toBeVisible({ timeout: 10000 });
});
});
// ── Bug #1: Default model ─────────────────────────────────────────────────
test.describe('Bug #1 — Default model', () => {
test('default model resolves to sonnet, not opus', async ({ page }) => {
await gotoDesktop(page);
await openAppViaDock(page, 'Chat');
await page.waitForTimeout(2000);
// The model appears in the page as text — look for any element containing
// a model name string (sonnet, opus, claude, anthropic, etc.)
const allText = await page.locator('body').innerText();
const hasModelRef = /sonnet|opus|claude/i.test(allText);
if (hasModelRef) {
// If a model string appears, verify the selected/default model is not Opus.
const opusCount = (allText.match(/opus/gi) || []).length;
const sonnetCount = (allText.match(/sonnet/gi) || []).length;
const localCount = (allText.match(/ollama|minimax|gemma|gpt/gi) || []).length;
expect(sonnetCount + localCount).toBeGreaterThan(0);
expect(opusCount).toBeLessThanOrEqual(sonnetCount + localCount);
}
// If no model text at all, that's acceptable (no workspace active)
});
});
// ── Bug #7: Ctrl+Shift+N ──────────────────────────────────────────────────
// Retargeted (plan §4.2): the shortcut retired as a window spawner — it now
// navigates to the active workspace's chat route; no workspace → /home.
test.describe('Bug #7 — Ctrl+Shift+N', () => {
test('Ctrl+Shift+N navigates to the active workspace chat route', async ({ page, request }) => {
await gotoDesktop(page);
const res = await request.get(`${BASE}/api/workspaces`);
const workspaces = await res.json();
const hasWorkspace = Array.isArray(workspaces) && workspaces.length > 0;
// Dispatch Ctrl+Shift+N via evaluate — browser intercepts the real shortcut
await page.evaluate(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'N', code: 'KeyN', ctrlKey: true, shiftKey: true, bubbles: true,
}));
});
if (hasWorkspace) {
await page.waitForURL(/\/workspaces\/[^/]+\/chat/, { timeout: 5000 });
} else {
// routeFor('chat') with no active workspace falls back to /home (§1.3).
await page.waitForURL(/\/home/, { timeout: 5000 });
}
// The single-canvas shell never spawns window chrome (§3.1).
expect(await page.locator('[class*="AppWindow"], [class*="app-window"]').count()).toBe(0);
});
});
// A.2 ("two chat windows can exist simultaneously") DROPPED: concurrent
// same-workspace multi-persona chat windows were consciously removed with the
// window manager (plan §9.10 / §4.3 — D1-c lite not invoked). Per-workspace
// persona switching survives in the chat widget header and is covered by the
// unit suite (p1a-chat-state.test.tsx).
// ── A.3: Room canvas ─────────────────────────────────────────────────────
test.describe('A.3 — Room canvas', () => {
test('Room app opens from dock and shows empty state', async ({ page }) => {
await gotoDesktop(page);
await openAppViaDock(page, 'Room');
await page.waitForTimeout(500);
// Room should show some content (empty state message or tiles area)
const roomContent = page.locator('text=/room|agent|specialist|no.*running|empty/i');
await expect(roomContent.first()).toBeVisible({ timeout: 5000 });
});
});
// ── A.4: Window-state migration (was: window restoration) ────────────────
// Retargeted to acceptance check 6 (plan §3.3): a populated legacy
// waggle-window-state-v1 is salvaged into waggle-chat-state-v1 + the initial
// route, and the legacy key is deleted UNCONDITIONALLY on boot.
test.describe('A.4 — Window-state migration', () => {
test('legacy window state migrates to waggle-chat-state-v1 and the key is removed', async ({ page }) => {
// Seed BEFORE any app code runs (same addInitScript pattern as the
// onboarding skip): one persisted chat window with a persona.
await page.addInitScript(() => {
localStorage.setItem('waggle-booted', 'true');
localStorage.setItem('waggle-window-state-v1', JSON.stringify({
version: 1,
windows: [{
instanceId: 'i-e2e', appId: 'chat', workspaceId: 'ws-e2e',
personaId: 'coder', zIndex: 5, minimized: false, cascadeOffset: 0,
}],
}));
});
await page.goto(`${BASE}/?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
waitUntil: 'domcontentloaded',
});
// §3.3 step 2: the salvaged top window seeds the initial navigation.
await page.waitForURL(/\/workspaces\/ws-e2e\/chat/, { timeout: 10000 });
const { legacy, chatState } = await page.evaluate(() => ({
legacy: localStorage.getItem('waggle-window-state-v1'),
chatState: localStorage.getItem('waggle-chat-state-v1'),
}));
// §3.3 step 4: the legacy key is gone — no dual-format support, ever.
expect(legacy).toBeNull();
// §3.3 step 3: persona salvaged + the explicit normal-autonomy marker.
expect(chatState).not.toBeNull();
const parsed = JSON.parse(chatState!);
expect(parsed.version).toBe(1);
expect(parsed.chats['ws-e2e']).toMatchObject({ personaId: 'coder', autonomyLevel: 'normal' });
});
});
// ── B.5: Autonomy chip ──────────────────────────────────────────────────
test.describe('B.5 — Autonomy controls', () => {
test('chat window shows autonomy-related UI element', async ({ page }) => {
await gotoDesktop(page);
await openAppViaDock(page, 'Chat');
await page.waitForTimeout(1500);
// The chat header shows a "Normal" autonomy chip. Check body text.
const allText = await page.locator('body').innerText();
const hasAutonomy = /ask first|trusted|autopilot|normal|yolo/i.test(allText);
expect(hasAutonomy).toBe(true);
});
});
// ── Approvals app ────────────────────────────────────────────────────────
test.describe('B.4 — Approvals app', () => {
test('Approvals app opens from dock', async ({ page }) => {
await gotoDesktop(page);
await openAppViaDock(page, 'Approvals');
await page.waitForTimeout(500);
const appContent = page.locator('text=/approval|pending|no.*pending|history/i');
await expect(appContent.first()).toBeVisible({ timeout: 5000 });
});
});
// ── Structural health ────────────────────────────────────────────────────
test.describe('Structural health', () => {
test('clean first-run onboarding loads without Clerk, CSP, or page errors', async ({ page }) => {
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
page.on('pageerror', err => pageErrors.push(err.message));
await page.addInitScript(() => {
localStorage.clear();
sessionStorage.clear();
});
await page.goto(`${BASE}/?forceWizard=true`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('region', { name: /waggle onboarding/i })).toBeVisible({ timeout: 20_000 });
await page.waitForTimeout(1_000);
expect(pageErrors).toHaveLength(0);
expect(consoleErrors.filter(e => /clerk|content security policy|csp/i.test(e))).toHaveLength(0);
expect(consoleErrors.filter(e =>
!e.includes('Failed to fetch') &&
!e.includes('net::ERR') &&
!e.includes('favicon') &&
!e.includes('401') &&
!e.includes('404') &&
!e.includes('sync') &&
!e.includes('WebSocket') &&
!e.includes('model') &&
!e.includes('fetch')
)).toHaveLength(0);
});
test('health endpoint returns ok', async ({ request }) => {
const res = await request.get(`${BASE}/health`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(['ok', 'degraded', 'unavailable']).toContain(data.status);
});
test('workspaces API returns array', async ({ request }) => {
const res = await request.get(`${BASE}/api/workspaces`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(Array.isArray(data)).toBeTruthy();
});
test('dock renders all expected buttons', async ({ page }) => {
await gotoDesktop(page);
const expectedApps = ['Chat', 'Memory', 'Agents'];
for (const label of expectedApps) {
const btn = page.locator(`button[aria-label="${label}"]`);
await expect(btn).toBeVisible({ timeout: 5000 });
}
});
test('no console errors on initial load', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await gotoDesktop(page);
await page.waitForTimeout(2000);
// Filter out known benign errors (network requests, background sync, etc.)
const realErrors = errors.filter(e =>
!e.includes('Failed to fetch') &&
!e.includes('net::ERR') &&
!e.includes('favicon') &&
!e.includes('401') &&
!e.includes('404') &&
!e.includes('sync') &&
!e.includes('WebSocket') &&
!e.includes('model') &&
!e.includes('fetch')
);
expect(realErrors).toHaveLength(0);
expect(errors.filter(e => /clerk|content security policy|csp/i.test(e))).toHaveLength(0);
});
});

View File

@@ -0,0 +1,423 @@
/**
* Phase 8 — Visual Regression Baselines (9G-4)
*
* Captures screenshot baselines for all 7 Waggle views in both dark and light
* modes. This completes the 9G-4 gap identified in CONTINUE-PHASE9.md.
*
* Each view × theme = 1 baseline PNG. Total: 14 baselines.
*
* Baseline storage: tests/visual/baselines/
* Snapshot template: {snapshotDir}/{testName}/{arg}{ext} (from playwright.config.ts)
*
* Usage:
* # Create / update baselines (first run or after intentional UI changes)
* npx playwright test tests/e2e/phase8-visual.spec.ts --update-snapshots
*
* # Verify no regressions (CI)
* npx playwright test tests/e2e/phase8-visual.spec.ts
*
* Prerequisites:
* - Server running at localhost:3333 (playwright.config.ts webServer auto-starts it)
* - app/dist built (npm run build in app/)
* - No onboarding wizard state (fresh ~/.waggle or pre-seeded with config)
*
* Diff threshold: 0.3% pixel ratio (configured in playwright.config.ts)
*
* Notes:
* - Tests skip gracefully when onboarding wizard is active (first-run state).
* - MissionControl view is tested for presence only (may be gated by Phase 8D).
* - Animations are disabled via playwright config to prevent flaky snapshots.
*/
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
const RUN_PIXEL_BASELINES = process.env.WAGGLE_E2E_VISUAL === '1' || !process.env.CI;
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Wait for the Waggle app shell to be ready (copied from user-journeys.spec.ts). */
async function waitForApp(page: Page): Promise<void> {
await page.waitForSelector(
'.waggle-app-shell, .waggle-sidebar, [role="navigation"], [class*="onboarding"]',
{ timeout: 15_000 },
).catch(() => {});
await page.waitForTimeout(1000);
}
/** Returns true if the onboarding wizard is blocking the main UI. */
async function isOnboarding(page: Page): Promise<boolean> {
// OnboardingWizard renders with z-[9999] (not z-[1000])
const overlay = page.locator('.fixed.inset-0.z-\\[9999\\]');
if (await overlay.isVisible().catch(() => false)) return true;
const text = page.locator('text=Welcome to Waggle').or(page.locator('text=Why Waggle'));
return text.isVisible().catch(() => false);
}
/** Skip onboarding — hits the server API (source of truth) AND localStorage.
* The server persists onboardingCompleted in config.json which the app reads
* on every load — localStorage alone is not sufficient.
*/
async function skipOnboarding(page: Page): Promise<void> {
// 1. Server-side: PATCH /api/settings — this is what the app reads on load
await page.request.patch(`${BASE}/api/settings`, {
data: { onboardingCompleted: true },
headers: { 'Content-Type': 'application/json' },
}).catch(() => {}); // non-blocking — proceed even if server unreachable
// 2. addInitScript: fires before React mounts on next navigation
await page.addInitScript(() => {
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
localStorage.setItem('waggle:first-run', 'done');
});
// 3. Immediate evaluate: sets localStorage if page already loaded
await page.evaluate(() => {
try {
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
localStorage.setItem('waggle:first-run', 'done');
} catch { /* ignore */ }
}).catch(() => {});
}
function routeWithSkip(route: string): string {
const separator = route.includes('?') ? '&' : '?';
return `${route}${separator}skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`;
}
/**
* Navigate to a named view via the sidebar button.
* Retries if the sidebar is collapsed.
*/
async function navigateTo(page: Page, viewName: string): Promise<void> {
// If onboarding overlay is visible, press Escape or click skip to dismiss it
const overlay = page.locator('.fixed.inset-0.z-\\[9999\\]');
if (await overlay.isVisible({ timeout: 500 }).catch(() => false)) {
// Try to find and click a skip/dismiss button
const skipBtn = page.locator('button').filter({ hasText: /skip|dismiss|close|later/i }).first();
if (await skipBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await skipBtn.click().catch(() => {});
await page.waitForTimeout(500);
} else {
// Press Escape to dismiss
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
}
}
const sidebar = page.locator('[role="navigation"]');
// Ensure sidebar is expanded
const expandBtn = page.locator('button[aria-label="Expand sidebar"]');
if (await expandBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await expandBtn.click();
await page.waitForTimeout(300);
}
const sidebarSelectors: Record<string, string[]> = {
Chat: ['[data-testid="nav-chat"]', 'button[aria-label="Chat"]'],
Memory: ['[data-testid="nav-memory"]', 'button[aria-label="Memory"]'],
Settings: ['[data-testid="sidebar-user"]', 'button[aria-label="Account and settings"]'],
'Agents': ['[data-testid="nav-agents"]', 'button[aria-label="Agents"]'],
Library: ['[data-testid="nav-library"]', 'button[aria-label="Library"]'],
};
for (const selector of sidebarSelectors[viewName] ?? []) {
const candidate = sidebar.locator(selector).first();
if (await candidate.isVisible({ timeout: 700 }).catch(() => false)) {
await candidate.click();
await page.waitForTimeout(600);
return;
}
}
const btn = sidebar.locator('button', { hasText: viewName }).first();
if (await btn.isVisible({ timeout: 700 }).catch(() => false)) {
await btn.click();
await page.waitForTimeout(600);
return;
}
const routes: Record<string, string> = {
Chat: '/workspaces/default/chat',
Memory: '/memory',
Events: '/settings/events',
Capabilities: '/skills',
'Skills Hub': '/skills',
Cockpit: '/settings/mission-control',
'Mission Control': '/settings/mission-control',
Settings: '/settings',
};
const route = routes[viewName];
if (!route) throw new Error(`No current navigation target configured for "${viewName}"`);
await page.goto(routeWithSkip(route), { waitUntil: 'domcontentloaded' });
await waitForApp(page);
}
/**
* Set theme by clicking the sidebar theme toggle until the correct mode is active.
* Returns the final theme ('dark' | 'light').
*/
async function setTheme(page: Page, target: 'dark' | 'light'): Promise<void> {
// Theme toggle is in the sidebar — ensure it's expanded
await page.evaluate((mode) => {
localStorage.setItem('waggle-theme', mode);
if (mode === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
}, target);
await page.waitForTimeout(100);
}
/**
* Capture a stable screenshot — waits for network idle and hides dynamic elements
* (timestamps, cost counters, status bar tokens) that would cause diff failures.
*/
async function stableScreenshot(page: Page): Promise<Buffer> {
// Hide elements whose content changes between runs
await page.evaluate(() => {
const selectors = [
'[data-testid="status-bar-tokens"]',
'[data-testid="status-bar-cost"]',
'[class*="timestamp"]',
'[class*="Timestamp"]',
'.status-bar__cost',
'.waggle-status-bar__tokens',
];
for (const sel of selectors) {
document.querySelectorAll(sel).forEach((el) => {
(el as HTMLElement).style.visibility = 'hidden';
});
}
const dynamicText = [
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s/i,
/^\d{1,2}:\d{2}$/,
/^Last active:/i,
];
document.querySelectorAll('body *').forEach((el) => {
if (el.children.length > 0) return;
const text = el.textContent?.trim() ?? '';
if (dynamicText.some((pattern) => pattern.test(text))) {
(el as HTMLElement).style.visibility = 'hidden';
}
});
document.querySelectorAll('button[aria-label="Notifications"]').forEach((el) => {
(el as HTMLElement).style.visibility = 'hidden';
});
});
await page.waitForTimeout(200);
return page.screenshot({ fullPage: false });
}
// ── View definitions ──────────────────────────────────────────────────────────
const VIEWS = [
{ name: 'Chat', sidebar: 'Chat' },
{ name: 'Memory', sidebar: 'Memory' },
{ name: 'Events', sidebar: 'Events' },
{ name: 'Capabilities', sidebar: 'Skills Hub' },
{ name: 'Cockpit', sidebar: 'Cockpit' },
{ name: 'MissionControl', sidebar: 'Mission Control' },
{ name: 'Settings', sidebar: 'Settings' },
] as const;
const THEMES = ['light', 'dark'] as const;
// ═════════════════════════════════════════════════════════════════════════════
// Visual Baseline Tests (7 views × 2 themes = 14 baselines)
// ═════════════════════════════════════════════════════════════════════════════
for (const theme of THEMES) {
test.describe(`Visual baselines — ${theme} mode`, () => {
test.skip(!RUN_PIXEL_BASELINES, 'Pixel baselines run with WAGGLE_E2E_VISUAL=1; structural smoke tests still run in CI.');
// Visual tests need more time: beforeEach (goto + waitForApp + setTheme) ~10-20s
// + navigateTo ~5s + waitForFunction + networkidle + screenshot ~10s = up to 35s
test.describe.configure({ timeout: 90_000 });
test.beforeEach(async ({ page }) => {
// CRITICAL: register addInitScript BEFORE first goto so localStorage
// is set BEFORE React mounts and reads onboarding state.
await page.addInitScript((targetTheme) => {
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
localStorage.setItem('waggle:first-run', 'done');
localStorage.setItem('waggle-theme', targetTheme);
if (targetTheme === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
}, theme);
// Server-side: PATCH /api/settings (belt and suspenders)
await page.request.patch(`${BASE}/api/settings`, {
data: { onboardingCompleted: true },
headers: { 'Content-Type': 'application/json' },
}).catch(() => {});
// NOW navigate — initScript fires before React, no onboarding shown
await page.goto(routeWithSkip('/home'));
await waitForApp(page);
await setTheme(page, theme);
});
for (const view of VIEWS) {
test(`${view.name} view — ${theme}`, async ({ page }) => {
// No skip conditions — if onboarding blocks navigation, test fails with clear error
// navigateTo will throw if sidebar button not found within 5s
await navigateTo(page, view.sidebar);
// Wait for view content — not a fixed timer
await page.waitForFunction(() =>
(document.body.textContent?.length ?? 0) > 100,
{ timeout: 8000 }
).catch(() => {});
await page.waitForTimeout(400); // short final settle for animations
const screenshot = await stableScreenshot(page);
expect(screenshot).toMatchSnapshot(`${view.name}-${theme}.png`);
});
}
});
}
// ═════════════════════════════════════════════════════════════════════════════
// Structural smoke tests — verify views render without crashing
// (These always run, even without baselines.)
// ═════════════════════════════════════════════════════════════════════════════
test.describe('View structural smoke tests', () => {
test.beforeEach(async ({ page }) => {
// Register initScript BEFORE first goto — sets localStorage before React mounts
await page.addInitScript(() => {
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
localStorage.setItem('waggle:first-run', 'done');
});
await page.request.patch(`${BASE}/api/settings`, {
data: { onboardingCompleted: true },
headers: { 'Content-Type': 'application/json' },
}).catch(() => {});
await page.goto(routeWithSkip('/home'));
await waitForApp(page);
});
test('Chat view: textarea is present and accepts input', async ({ page }) => {
await skipOnboarding(page);
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
await navigateTo(page, 'Chat');
const textarea = page.locator('textarea').first();
await expect(textarea).toBeVisible({ timeout: 5000 });
await textarea.fill('/help');
await expect(textarea).toHaveValue('/help');
});
test('Memory view: search input is present', async ({ page }) => {
await skipOnboarding(page);
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
await navigateTo(page, 'Memory');
// Memory view has a search input or empty state
const searchOrEmpty = page.locator('[placeholder*="search" i]')
.or(page.locator('text=No memories'))
.or(page.locator('text=Search'));
await expect(searchOrEmpty.first()).toBeVisible({ timeout: 5000 });
});
test('Settings view: renders at least 5 tabs', async ({ page }) => {
await skipOnboarding(page);
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
await navigateTo(page, 'Settings');
await page.waitForTimeout(500);
const tabs = page.locator('.settings-panel__tab');
const count = await tabs.count();
if (count > 0) {
expect(count).toBeGreaterThanOrEqual(5);
} else {
// May still be loading
const loading = page.locator('text=Loading').or(page.locator('text=General'));
await expect(loading.first()).toBeVisible({ timeout: 5000 });
}
});
test('Cockpit view: renders cards or loading skeletons', async ({ page }) => {
await skipOnboarding(page);
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
await navigateTo(page, 'Cockpit');
await page.waitForTimeout(1500);
// Just verify the view loaded without crash — content varies
const body = await page.textContent('body') ?? '';
expect(body.length).toBeGreaterThan(50);
});
test('Capabilities view: renders marketplace or loading state', async ({ page }) => {
await skipOnboarding(page);
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
await navigateTo(page, 'Skills Hub');
await page.waitForTimeout(1000);
const content = page.locator('text=Browse')
.or(page.locator('text=Installed'))
.or(page.locator('text=Marketplace'))
.or(page.locator('text=Loading'))
.or(page.locator('[class*="capability"]'));
await expect(content.first()).toBeVisible({ timeout: 5000 });
});
test('Events view: renders timeline or empty state', async ({ page }) => {
await skipOnboarding(page);
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
await navigateTo(page, 'Events');
await page.waitForTimeout(1000);
// Just verify the view loaded without crash — content varies
const body = await page.textContent('body') ?? '';
expect(body.length).toBeGreaterThan(50);
});
test('Mission Control view: renders without crashing', async ({ page }) => {
await skipOnboarding(page);
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
await navigateTo(page, 'Mission Control');
await page.waitForTimeout(500);
// Mission Control may be gated — just check it doesn't crash
await expect(page.locator('body')).not.toBeEmpty();
const bodyText = await page.textContent('body');
expect(bodyText?.trim().length).toBeGreaterThan(5);
});
test('theme toggle changes html class or data-theme attribute', async ({ page }) => {
await skipOnboarding(page);
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
const getThemeSignal = async () => {
const cls = await page.locator('html').getAttribute('class') ?? '';
const dt = await page.locator('html').getAttribute('data-theme') ?? '';
return cls + dt;
};
await navigateTo(page, 'Settings');
await page.getByRole('tab', { name: /General/i }).click();
await expect(page.getByText('Theme')).toBeVisible({ timeout: 5000 });
const before = await getThemeSignal();
const target = before.includes('light') ? 'Dark' : 'Light';
await page.getByRole('button', { name: new RegExp(target, 'i') }).first().click();
await page.waitForTimeout(400);
const after = await getThemeSignal();
expect(after).not.toBe(before);
});
});

View File

@@ -0,0 +1,256 @@
/**
* Polish Plan Verification — E2E tests for Phase 1-3 changes.
*
* Covers:
* Phase 1: Health check cache, marketplace redirect, knowledge graph fallback
* Phase 2: Tier gating (Mission Control, Custom Skills, /spawn, connectors, sidebar)
* Phase 3: Error handling, skip button, workspace switcher trigger
*
* Run: npx playwright test tests/e2e/polish-verification.spec.ts --reporter=list
*/
import { test, expect, type Page } from '@playwright/test';
const API = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
// ── Helpers ────────────────────────────────────────────────────────────────
async function skipOnboarding(page: Page) {
await page.request.patch(`${API}/api/settings`, {
data: { onboardingCompleted: true },
headers: { 'Content-Type': 'application/json' },
}).catch(() => {});
await page.addInitScript(() => {
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
localStorage.setItem('waggle:first-run', 'done');
});
}
async function setTier(tier: string) {
const res = await fetch(`${API}/api/tier`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tier }),
});
return res.ok;
}
async function waitForApp(page: Page) {
await page.waitForSelector(
'.waggle-app-shell, .waggle-sidebar, [role="navigation"], [class*="onboarding"]',
{ timeout: 15_000 },
).catch(() => {});
}
// ── Phase 1: Backend Fixes ────────────────────────────────────────────────
test.describe('Phase 1 — Backend Fixes', () => {
test('health check returns status', async () => {
const res = await fetch(`${API}/health`);
expect(res.ok).toBeTruthy();
const data = await res.json();
expect(data).toHaveProperty('status');
// Should not be permanently stuck in degraded if no key
expect(['ok', 'degraded', 'unavailable']).toContain(data.status);
});
test('marketplace /packs returns 200', async () => {
const res = await fetch(`${API}/api/marketplace/packs`);
// 200 if marketplace DB loaded, 503 if not — both are acceptable
expect([200, 503]).toContain(res.status);
});
test('marketplace /plugins returns 301 redirect hint', async () => {
const res = await fetch(`${API}/api/marketplace/plugins`, { redirect: 'manual' });
expect(res.status).toBe(301);
const data = await res.json();
expect(data.redirect).toBe('/api/marketplace/search');
});
test('knowledge graph returns empty for nonexistent workspace', async () => {
const res = await fetch(`${API}/api/memory/graph?workspace=nonexistent-ws-12345`);
expect(res.ok).toBeTruthy();
const data = await res.json();
expect(data).toEqual({ nodes: [], edges: [] });
});
});
// ── Phase 2: Tier Gating ──────────────────────────────────────────────────
test.describe('Phase 2 — Tier Gating', () => {
test('FREE: Mission Control shows lock overlay', async ({ page }) => {
await setTier('FREE');
await skipOnboarding(page);
await page.goto(`${API}`);
await waitForApp(page);
// Navigate to Mission Control
const mcButton = page.locator('button', { hasText: 'Mission Control' });
if (await mcButton.isVisible({ timeout: 5000 }).catch(() => false)) {
await mcButton.click();
// Should see lock overlay
const lockOverlay = page.locator('text=Upgrade to Teams');
await expect(lockOverlay).toBeVisible({ timeout: 5000 }).catch(() => {
// LockedFeature renders blurred content with upgrade card
});
}
});
test('FREE: sidebar shows lock icon on Mission Control', async ({ page }) => {
await setTier('FREE');
await skipOnboarding(page);
await page.goto(`${API}`);
await waitForApp(page);
// Check for lock icon near Mission Control
const mcNav = page.locator('button[title*="Mission Control"]');
if (await mcNav.isVisible({ timeout: 5000 }).catch(() => false)) {
const title = await mcNav.getAttribute('title');
expect(title).toContain('requires Teams');
}
});
test('FREE: /spawn not in command palette', async ({ page }) => {
await setTier('FREE');
await skipOnboarding(page);
await page.goto(`${API}`);
await waitForApp(page);
// Open command palette with Ctrl+K
await page.keyboard.press('Control+k');
await page.waitForTimeout(500);
// Check if /spawn is hidden
const spawnItem = page.locator('text=/spawn');
const isVisible = await spawnItem.isVisible({ timeout: 2000 }).catch(() => false);
// On FREE, /spawn should not be visible
if (isVisible) {
// If command palette didn't open or render differently, just log
test.info().annotations.push({ type: 'note', description: '/spawn visibility check — palette may not have opened' });
}
});
test('tier endpoint returns valid tier', async () => {
const res = await fetch(`${API}/api/tier`);
expect(res.ok).toBeTruthy();
const data = await res.json();
expect(['TRIAL', 'FREE', 'TEAMS', 'ENTERPRISE']).toContain(data.tier);
expect(data.capabilities).toBeDefined();
});
});
// ── Phase 3: UX Fixes ────────────────────────────────────────────────────
test.describe('Phase 3 — UX Fixes', () => {
test('connector connect shows error on invalid token', async () => {
// Get list of connectors
const listRes = await fetch(`${API}/api/connectors`);
if (!listRes.ok) {
test.skip(true, 'Connectors endpoint not available');
return;
}
const { connectors } = await listRes.json() as { connectors: { id: string; status: string }[] };
const disconnected = connectors.find(c => c.status === 'disconnected');
if (!disconnected) {
test.skip(true, 'No disconnected connectors to test');
return;
}
// Try to connect with obviously bad token
const res = await fetch(`${API}/api/connectors/${disconnected.id}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'invalid-test-token' }),
});
// Should respond (not hang/crash) — may succeed or fail depending on connector
expect(res.status).toBeLessThan(500);
});
test('workspace creation returns error on missing data', async () => {
const res = await fetch(`${API}/api/workspaces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), // Missing required 'name'
});
// Should respond with an error, not crash
expect(res.status).toBeLessThan(500);
});
test('onboarding skip button is visible on API key step', async ({ page }) => {
// Reset onboarding state
await page.addInitScript(() => {
localStorage.removeItem('waggle:onboarding');
localStorage.removeItem('waggle:first-run');
});
await page.request.patch(`${API}/api/settings`, {
data: { onboardingCompleted: false },
headers: { 'Content-Type': 'application/json' },
}).catch(() => {});
await page.goto(`${API}`);
// Wait for onboarding to appear
const onboarding = page.locator('[class*="onboarding"], [data-testid*="onboarding"]');
const visible = await onboarding.isVisible({ timeout: 10_000 }).catch(() => false);
if (!visible) {
test.skip(true, 'Onboarding did not appear');
return;
}
// Navigate to API key step (step 5) — click through wizard
// The skip button should be a proper Button component, not just underlined text
const skipButton = page.locator('button', { hasText: /Skip.*key.*later/i });
// It may take several clicks to reach step 5
// Just verify the button exists somewhere in the wizard
test.info().annotations.push({ type: 'note', description: 'Skip button presence check' });
});
test('workspace switcher trigger exists in sidebar', async ({ page }) => {
await skipOnboarding(page);
await page.goto(`${API}`);
await waitForApp(page);
// Look for the workspace switcher trigger button with ^Tab hint
const switcherTrigger = page.locator('button[title*="Switch workspace"]');
const visible = await switcherTrigger.isVisible({ timeout: 5000 }).catch(() => false);
if (visible) {
// Click it and verify workspace switcher opens
await switcherTrigger.click();
// WorkspaceSwitcher should appear
await page.waitForTimeout(500);
}
});
test('zero React key warnings in console', async ({ page }) => {
const keyWarnings: string[] = [];
page.on('console', (msg) => {
const text = msg.text();
if (text.includes('same key') || text.includes('Each child in a list should have a unique')) {
keyWarnings.push(text);
}
});
await skipOnboarding(page);
await page.goto(`${API}`);
await waitForApp(page);
// Navigate through main views to trigger renders
const views = ['chat', 'capabilities', 'cockpit', 'memory', 'events'];
for (const view of views) {
const btn = page.locator(`button[title*="${view}"]`).first();
if (await btn.isVisible({ timeout: 2000 }).catch(() => false)) {
await btn.click();
await page.waitForTimeout(300);
}
}
// Check for key warnings
if (keyWarnings.length > 0) {
test.info().annotations.push({
type: 'warning',
description: `Found ${keyWarnings.length} React key warning(s): ${keyWarnings[0]?.slice(0, 100)}`,
});
}
expect(keyWarnings.length).toBe(0);
});
});

View File

@@ -0,0 +1,522 @@
/**
* Power User Stress Test — acts like a demanding user who clicks everything,
* types everywhere, opens 6 windows at once, switches contexts rapidly,
* and expects nothing to break.
*
* This is NOT a "does it render" test. This is a "can I actually USE this" test.
*/
import { test, expect, type Page } from '@playwright/test';
import { isDevNoiseWorkspace } from '../../apps/web/src/lib/workspace-counts';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
async function dismissOverlay(page: Page) {
for (let i = 0; i < 3; i++) {
const overlay = page.locator('.fixed.backdrop-blur-sm');
if (!await overlay.isVisible({ timeout: 1000 }).catch(() => false)) break;
const btn = page.locator('button:has-text("Start Working")');
if (await btn.isVisible({ timeout: 500 }).catch(() => false)) {
await btn.click({ force: true });
await page.waitForTimeout(500);
continue;
}
await page.mouse.click(5, 5);
await page.waitForTimeout(500);
}
}
async function gotoDesktop(page: Page) {
await page.goto(`${BASE}/home?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('[role="navigation"], main', { timeout: 10_000 });
await dismissOverlay(page);
}
async function openSurface(page: Page, label: string) {
const routes: Record<string, string> = {
Home: '/home',
Workspaces: '/workspaces',
Chat: '/workspaces/default-workspace/chat',
Memory: '/memory',
Room: '/room',
Agents: '/agents',
Files: '/files',
Approvals: '/approvals',
Settings: '/settings',
'API Keys': '/settings/vault',
};
const routePatterns: Record<string, RegExp> = {
Home: /\/home/,
Workspaces: /\/workspaces$/,
Chat: /\/workspaces\/[^/]+\/chat/,
Memory: /\/memory/,
Room: /\/room/,
Agents: /\/agents/,
Files: /\/files/,
Approvals: /\/approvals/,
Settings: /\/settings/,
'API Keys': /\/settings\/vault/,
};
const route = routes[label];
const btn = page.locator(`button[aria-label="${label}"]`);
if (await btn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await btn.click();
const pattern = routePatterns[label];
if (route && pattern) {
await page.waitForURL(pattern, { timeout: 2_500 }).catch(async () => {
await page.goto(`${BASE}${route}?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
waitUntil: 'domcontentloaded',
});
});
await page.waitForSelector('[role="navigation"], main', { timeout: 10_000 });
} else {
await page.waitForTimeout(400);
}
return;
}
if (!route) throw new Error(`No current route for ${label}`);
await page.goto(`${BASE}${route}?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('[role="navigation"], main', { timeout: 10_000 });
}
function chatInput(page: Page) {
return page.getByRole('textbox', { name: /reply|ask waggle|message/i }).first();
}
function dispatch(page: Page, key: string, opts: { ctrl?: boolean; shift?: boolean } = {}) {
return page.evaluate(({ key, ctrl, shift }) => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key, code: `Key${key.toUpperCase()}`,
ctrlKey: ctrl ?? false, shiftKey: shift ?? false, bubbles: true,
}));
}, { key, ctrl: opts.ctrl, shift: opts.shift });
}
// ── 1. Create a workspace from scratch ────────────────────────────────
test.describe('1. Workspace Creation', () => {
test('can create a workspace via API and see it in dashboard', async ({ page, request }) => {
const name = `Power Workspace ${Date.now()}`;
const res = await request.post(`${BASE}/api/workspaces`, {
data: { name, group: 'testing', persona: 'researcher' },
headers: { 'Content-Type': 'application/json' },
});
// Workspace creation might fail if tier limits reached — that's acceptable
if (res.ok()) {
const ws = await res.json();
expect(ws.id).toBeTruthy();
expect(ws.name).toBe(name);
await gotoDesktop(page);
await openSurface(page, 'Workspaces');
await expect(page.locator('body')).toContainText(name, { timeout: 10_000 });
} else {
// If creation fails (tier limit, etc.), just verify the API returns a meaningful error
expect(res.status()).toBeLessThan(500);
}
});
});
// ── 2. Chat interaction stress ────────────────────────────────────────
test.describe('2. Chat Stress', () => {
test('can type in chat input and see it', async ({ page }) => {
await gotoDesktop(page);
await openSurface(page, 'Chat');
const input = chatInput(page);
await expect(input).toBeVisible({ timeout: 5000 });
await input.fill('Hello from stress test! /help');
const val = await input.inputValue();
expect(val).toContain('Hello from stress test');
});
test('slash command menu appears on /', async ({ page }) => {
await gotoDesktop(page);
await openSurface(page, 'Chat');
const input = chatInput(page);
await input.focus();
await input.fill('/');
await page.waitForTimeout(500);
// Slash menu should appear — look for command options
const slashMenu = page.locator('text=/research|draft|plan|catchup|status|spawn/i');
const count = await slashMenu.count();
expect(count).toBeGreaterThan(0);
});
test('persona picker opens and lists personas', async ({ page }) => {
await gotoDesktop(page);
await openSurface(page, 'Chat');
// Click the persona dropdown in chat header
const personaBtn = page.locator('button', { hasText: /Persona/i }).first();
if (await personaBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await personaBtn.click();
await page.waitForTimeout(500);
// Should see persona list
const personas = page.locator('text=/Researcher|Writer|Analyst|Coder|Sales/i');
expect(await personas.count()).toBeGreaterThan(2);
}
});
test('model picker opens and lists models', async ({ page }) => {
await gotoDesktop(page);
await openSurface(page, 'Chat');
// Click the model dropdown
const modelBtn = page.locator('button', { hasText: /sonnet|claude|model/i }).first();
if (await modelBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await modelBtn.click();
await page.waitForTimeout(500);
const text = await page.locator('body').innerText();
expect(text).toMatch(/ollama|minimax|sonnet|opus|haiku|gpt|gemini|model/i);
}
});
test('autonomy chip is clickable and cycles', async ({ page }) => {
await gotoDesktop(page);
await openSurface(page, 'Chat');
const autonomyChip = page.getByRole('button', { name: /ask first|trusted|autopilot/i }).first();
if (await autonomyChip.isVisible({ timeout: 2000 }).catch(() => false)) {
await autonomyChip.click();
await page.waitForTimeout(500);
// Should show autonomy options or cycle to Trusted
const text = await page.locator('body').innerText();
expect(text).toMatch(/ask first|trusted|autopilot|autonomy|minutes/i);
}
});
});
// ── 3. Multi-window chaos ─────────────────────────────────────────────
test.describe('3. Multi-Window Chaos', () => {
test('open 4 windows simultaneously without crash', async ({ page }) => {
await gotoDesktop(page);
await openSurface(page, 'Chat');
await openSurface(page, 'Room');
await openSurface(page, 'Agents');
await openSurface(page, 'Files');
// No crash — page should still be interactive
const text = await page.locator('body').innerText();
expect(text.length).toBeGreaterThan(100);
// Single-canvas navigation should leave the app usable on the final surface.
expect(text).toMatch(/file|folder|workspace|storage/i);
});
test('Ctrl+Shift+N opens the active workspace chat route without crash', async ({ page }) => {
await gotoDesktop(page);
await page.keyboard.press('Control+Shift+N');
await page.waitForURL(/\/workspaces\/[^/]+\/chat/, { timeout: 5_000 });
await expect(chatInput(page)).toBeVisible({ timeout: 5_000 });
});
test('close a window via title bar button', async ({ page }) => {
await gotoDesktop(page);
await openSurface(page, 'Chat');
// Find a close button (the colored dots in the title bar)
const closeBtn = page.locator('button[aria-label="Close window"], button[title="Close"]');
if (await closeBtn.first().isVisible({ timeout: 2000 }).catch(() => false)) {
await closeBtn.first().click();
await page.waitForTimeout(500);
}
// Should not crash
const text = await page.locator('body').innerText();
expect(text).toContain('Waggle');
});
});
// ── 4. Global Search deep test ────────────────────────────────────────
test.describe('4. Global Search', () => {
test('Ctrl+K opens search, can type and see results', async ({ page }) => {
await gotoDesktop(page);
await dispatch(page, 'k', { ctrl: true });
await page.waitForTimeout(500);
const searchInput = page.locator('input[placeholder*="Search"]');
await expect(searchInput).toBeVisible({ timeout: 3000 });
// Type a query
await searchInput.fill('chat');
await page.waitForTimeout(500);
// Should see "Chat" command in results
const results = page.locator('text=/Chat/');
expect(await results.count()).toBeGreaterThan(0);
});
test('search finds workspaces', async ({ page }) => {
await gotoDesktop(page);
await dispatch(page, 'k', { ctrl: true });
await page.waitForTimeout(500);
const searchInput = page.locator('input[placeholder*="Search"]');
await searchInput.fill('default');
await page.waitForTimeout(800);
// Should find the seeded default workspace in a fresh data dir.
const text = await page.locator('body').innerText();
expect(text.toLowerCase()).toContain('default');
});
test('search finds memories', async ({ page }) => {
await gotoDesktop(page);
await dispatch(page, 'k', { ctrl: true });
await page.waitForTimeout(500);
const searchInput = page.locator('input[placeholder*="Search"]');
await searchInput.fill('waggle');
await page.waitForTimeout(1000);
// Should show memory results
const text = await page.locator('body').innerText();
expect(text.toLowerCase()).toContain('waggle');
});
test('Escape closes search', async ({ page }) => {
await gotoDesktop(page);
await dispatch(page, 'k', { ctrl: true });
await page.waitForTimeout(500);
const searchInput = page.locator('input[placeholder*="Search"]');
await expect(searchInput).toBeVisible();
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await expect(searchInput).not.toBeVisible();
});
});
// ── 5. Settings deep dive ─────────────────────────────────────────────
test.describe('5. Settings', () => {
test('can navigate all settings tabs', async ({ page }) => {
await gotoDesktop(page);
await openSurface(page, 'Settings');
await page.waitForTimeout(500);
for (const tab of ['General', 'Models', 'Billing']) {
const tabBtn = page.locator(`button[role="tab"]`, { hasText: tab });
if (await tabBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await tabBtn.click();
await page.waitForTimeout(300);
}
}
// Should not crash
const text = await page.locator('body').innerText();
expect(text.length).toBeGreaterThan(50);
});
});
// ── 6. Vault operations ──────────────────────────────────────────────
test.describe('6. Vault', () => {
test('vault shows keys or empty state', async ({ page }) => {
await gotoDesktop(page);
await openSurface(page, 'API Keys');
await page.waitForTimeout(1000);
const text = await page.locator('body').innerText();
expect(text).toMatch(/vault|key|api|provider|secret|add|anthropic|openai/i);
});
});
// ── 7. Rapid navigation stress ────────────────────────────────────────
test.describe('7. Rapid Navigation', () => {
test('open and close 5 apps rapidly without crash', async ({ page }) => {
await gotoDesktop(page);
const apps = ['Chat', 'Room', 'Agents', 'Files', 'Approvals'];
for (const app of apps) {
await openSurface(page, app);
}
// Close all via Ctrl+W
for (let i = 0; i < 5; i++) {
await dispatch(page, 'w', { ctrl: true });
await page.waitForTimeout(200);
}
// Desktop should be clean — hero visible
await page.waitForTimeout(500);
const text = await page.locator('body').innerText();
expect(text).toContain('Waggle AI');
});
test('keyboard shortcuts work: Ctrl+Shift+1 through 5', async ({ page }) => {
await gotoDesktop(page);
// Open Chat via Ctrl+Shift+1
await dispatch(page, '1', { ctrl: true, shift: true });
await page.waitForTimeout(500);
let text = await page.locator('body').innerText();
expect(text).toMatch(/message|persona|chat/i);
// Close it
await dispatch(page, 'w', { ctrl: true });
await page.waitForTimeout(300);
// Open Memory via Ctrl+Shift+5
await dispatch(page, '5', { ctrl: true, shift: true });
await page.waitForTimeout(500);
text = await page.locator('body').innerText();
expect(text).toMatch(/memory|frame|knowledge|harvest/i);
});
});
// ── 8. Data integrity ─────────────────────────────────────────────────
test.describe('8. Data Integrity', () => {
test('workspace list is consistent between API and UI', async ({ page, request }) => {
const apiRes = await request.get(`${BASE}/api/workspaces`);
const apiWorkspaces = await apiRes.json();
const apiNames = (Array.isArray(apiWorkspaces) ? apiWorkspaces : [])
.filter((workspace: { name: string; status?: string }) => (
workspace.status !== 'archived' && !isDevNoiseWorkspace(workspace.name)
))
.map((workspace: { name: string }) => workspace.name);
await gotoDesktop(page);
await openSurface(page, 'Workspaces');
const body = page.locator('body');
// Every sampled API workspace should appear in the complete workspace view.
for (const name of apiNames.slice(0, 3)) {
await expect(body).toContainText(name, { timeout: 10_000 });
}
});
test('memory frame count matches API', async ({ request }) => {
const res = await request.get(`${BASE}/api/memory/stats`);
if (res.ok()) {
const stats = await res.json();
const total = stats.total?.frameCount ?? stats.personal?.frameCount ?? 0;
expect(total).toBeGreaterThanOrEqual(0);
}
});
test('sessions endpoint returns valid data', async ({ request }) => {
const wsRes = await request.get(`${BASE}/api/workspaces`);
const workspaces = await wsRes.json();
if (Array.isArray(workspaces) && workspaces.length > 0) {
const sessRes = await request.get(`${BASE}/api/workspaces/${workspaces[0].id}/sessions`);
expect(sessRes.ok()).toBeTruthy();
const sessions = await sessRes.json();
expect(Array.isArray(sessions)).toBeTruthy();
}
});
});
// ── 9. Error resilience ───────────────────────────────────────────────
test.describe('9. Error Resilience', () => {
test('invalid API call returns error, does not crash server', async ({ request }) => {
const res = await request.get(`${BASE}/api/workspaces/nonexistent-id-12345`);
expect([404, 500]).toContain(res.status());
// Server should still be healthy after error
const health = await request.get(`${BASE}/health`);
expect(health.ok()).toBeTruthy();
});
test('sending empty chat message is handled gracefully', async ({ request }) => {
const res = await request.post(`${BASE}/api/chat`, {
data: { message: '', workspaceId: 'test' },
headers: { 'Content-Type': 'application/json' },
});
// Should return 400 or handle gracefully, not 500
expect(res.status()).toBeLessThan(500);
});
test('invalid route shows 404 page with recovery link', async ({ page }) => {
await page.goto(`${BASE}/this-does-not-exist?skipOnboarding=true&skipBoot=true&skipBriefing=true`, {
waitUntil: 'domcontentloaded',
});
await page.waitForTimeout(1000);
const text = await page.locator('body').innerText();
// Should show a custom 404 page with a way to get back
expect(text).toMatch(/404|not found|return.*home/i);
});
});
// ── 10. Fresh User Onboarding ─────────────────────────────────────────
test.describe('10. Fresh User Onboarding', () => {
test('new user sees onboarding wizard', async ({ page }) => {
// Navigate WITHOUT skipOnboarding — simulate a brand new user
await page.goto(`${BASE}/`);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(3000);
const text = await page.locator('body').innerText();
// Should show either the onboarding wizard OR the desktop (if auto-skipped for returning user)
expect(text).toMatch(/waggle|welcome|workspace|get started|choose|chat/i);
});
test('onboarding wizard has template selection', async ({ page }) => {
// Clear onboarding state to force wizard
await page.goto(`${BASE}/`);
await page.evaluate(() => {
localStorage.removeItem('waggle:onboarding');
localStorage.removeItem('waggle:first-run');
});
await page.reload();
await page.waitForLoadState('domcontentloaded');
const onboarding = page.getByRole('region', { name: /waggle onboarding/i });
if (!await onboarding.isVisible({ timeout: 5_000 }).catch(() => false)) {
const text = await page.locator('body').innerText();
expect(text).toMatch(/waggle|workspace|continue|chat/i);
return;
}
await onboarding.getByRole('button', { name: /continue/i }).click();
await onboarding.getByRole('button', { name: /continue/i }).click();
await expect(onboarding.getByRole('button', { name: /continue/i })).toBeEnabled({ timeout: 10_000 });
await onboarding.getByRole('button', { name: /continue/i }).click();
await onboarding.getByRole('button', { name: /skip this step/i }).click();
await expect(page.getByText(/Research Hub|Engineering|Sales Pipeline/i).first()).toBeVisible({ timeout: 5_000 });
});
});
// ── 11. Performance baseline ─────────────────────────────────────────
test.describe('10. Performance', () => {
test('initial load completes under 8 seconds', async ({ page }) => {
const start = Date.now();
await page.goto(`${BASE}/?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`);
await page.waitForLoadState('domcontentloaded');
// Wait for dock to render as signal of "app ready"
await page.locator('button[aria-label="Chat"]').waitFor({ state: 'visible', timeout: 8000 });
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(8000);
});
test('health endpoint responds under 2 seconds', async ({ request }) => {
const start = Date.now();
await request.get(`${BASE}/health`);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(2000);
});
test('memory search responds under 3 seconds', async ({ request }) => {
const start = Date.now();
await request.get(`${BASE}/api/memory/search?q=important&limit=5`);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(3000);
});
});

View File

@@ -0,0 +1,190 @@
/**
* P6 regression — Room canvas visualises multiple parallel sub-agents
* correctly, without cross-contamination.
*
* Mocks the adapter's /health handshake and replaces window.EventSource
* with a stub that emits one `subagent_status` event carrying two
* simultaneously-running agents. Then asserts the Room renders two
* distinct tiles with the right role badges + running status.
*
* Pairs with `apps/web/src/lib/room-state-reducer.test.ts` (12 unit
* tests covering the reducer directly). This E2E adds the render-path
* guarantee on top.
*
* Run: npx playwright test tests/e2e/room-parallel-agents.spec.ts --reporter=list
*/
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
// Canonical test payload — two agents in one status event, different roles.
const AGENT_ALPHA_ID = 'agent-alpha-p6';
const AGENT_BETA_ID = 'agent-beta-p6';
const MOCK_ROSTER = {
type: 'subagent_status',
workspaceId: 'default',
agents: [
{
id: AGENT_ALPHA_ID,
name: 'Alpha Researcher',
role: 'researcher',
status: 'running',
task: 'Scout the competitive landscape for bee pollinator tech',
toolsUsed: ['web_search'],
startedAt: Date.now() - 30_000,
},
{
id: AGENT_BETA_ID,
name: 'Beta Coder',
role: 'coder',
status: 'running',
task: 'Draft the TypeScript API surface for the honey-ledger module',
toolsUsed: ['read_file'],
startedAt: Date.now() - 15_000,
},
],
timestamp: new Date().toISOString(),
};
async function installSseMock(page: Page) {
// Adapter gates subscribe() on `_connected`, which flips to true only
// after a successful /health call. Mock it.
await page.route('**/health', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ wsToken: 'p6-ws-token', authToken: 'p6-auth-token' }),
});
});
// Replace EventSource with a stub that dispatches our mock roster on
// the `subagent_status` named event type. Installed via addInitScript
// so it lands before the adapter ever calls `new EventSource(...)`.
await page.addInitScript((roster) => {
type Listener = (e: MessageEvent) => void;
interface MockWindow {
__p6MockSources?: unknown[];
EventSource: unknown;
localStorage: Storage;
}
const win = window as unknown as MockWindow;
class MockEventSource {
url: string;
readyState = 1; // OPEN
onerror: ((e: Event) => void) | null = null;
onmessage: ((e: MessageEvent) => void) | null = null;
onopen: ((e: Event) => void) | null = null;
private listeners: Record<string, Listener[]> = {};
constructor(url: string) {
this.url = url;
win.__p6MockSources = (win.__p6MockSources || []);
win.__p6MockSources.push(this);
// Give React a moment to mount and the adapter to call addEventListener.
setTimeout(() => {
const ls = this.listeners['subagent_status'] ?? [];
const evt = new MessageEvent('subagent_status', {
data: JSON.stringify(roster),
});
for (const l of ls) l(evt);
}, 300);
}
addEventListener(type: string, listener: Listener) {
(this.listeners[type] ??= []).push(listener);
}
removeEventListener(type: string, listener: Listener) {
this.listeners[type] = (this.listeners[type] ?? []).filter((l) => l !== listener);
}
close() { this.readyState = 2; }
// Test helper — re-emit the same roster, useful for cross-contamination check.
__reemit() {
const ls = this.listeners['subagent_status'] ?? [];
const evt = new MessageEvent('subagent_status', { data: JSON.stringify(roster) });
for (const l of ls) l(evt);
}
}
win.EventSource = MockEventSource;
// Skip boot screen so we get to Desktop immediately.
try {
window.localStorage.setItem('waggle-booted', 'true');
} catch { /* storage might be unavailable */ }
}, MOCK_ROSTER);
}
async function dismissOverlay(page: Page) {
for (let i = 0; i < 3; i++) {
const overlay = page.locator('.fixed.backdrop-blur-sm').first();
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) break;
const startBtn = page.locator('button:has-text("Start Working")').first();
if (await startBtn.isVisible({ timeout: 400 }).catch(() => false)) {
await startBtn.click({ force: true });
} else {
await page.mouse.click(5, 5);
}
await page.waitForTimeout(400);
}
}
function routeWithSkip(route: string) {
const sep = route.includes('?') ? '&' : '?';
return `${BASE}${route}${sep}skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`;
}
async function openRoom(page: Page) {
await page.goto(routeWithSkip('/room'), { waitUntil: 'domcontentloaded' });
await expect(page.locator('[data-testid="room-root"]')).toBeVisible({ timeout: 10_000 });
}
test.describe('Room — parallel agent visualization (P6)', () => {
test('renders two simultaneous agents with distinct tiles', async ({ page }) => {
await installSseMock(page);
await openRoom(page);
await dismissOverlay(page);
// Both tiles present, keyed by agent id.
const alpha = page.locator(`[data-testid="room-agent-tile"][data-agent-id="${AGENT_ALPHA_ID}"]`);
const beta = page.locator(`[data-testid="room-agent-tile"][data-agent-id="${AGENT_BETA_ID}"]`);
await expect(alpha).toBeVisible({ timeout: 5000 });
await expect(beta).toBeVisible({ timeout: 5000 });
// Live count chip reflects both.
await expect(page.locator('[data-testid="room-live-count"]')).toContainText('2 live');
// Total tile count = exactly 2 (no duplicates, no phantom tiles).
const allTiles = page.locator('[data-testid="room-agent-tile"]');
await expect(allTiles).toHaveCount(2);
});
test('role badges do not cross-contaminate between simultaneous agents', async ({ page }) => {
await installSseMock(page);
await openRoom(page);
await dismissOverlay(page);
const alpha = page.locator(`[data-testid="room-agent-tile"][data-agent-id="${AGENT_ALPHA_ID}"]`);
const beta = page.locator(`[data-testid="room-agent-tile"][data-agent-id="${AGENT_BETA_ID}"]`);
await expect(alpha).toBeVisible({ timeout: 5000 });
await expect(beta).toBeVisible();
// Each tile carries its own role attribute — verify independence.
await expect(alpha).toHaveAttribute('data-agent-role', 'researcher');
await expect(beta).toHaveAttribute('data-agent-role', 'coder');
// Both are running concurrently.
await expect(alpha).toHaveAttribute('data-agent-status', 'running');
await expect(beta).toHaveAttribute('data-agent-status', 'running');
// Task bodies are distinct — one tile's task text must not leak into the other.
await expect(alpha).toContainText('Scout the competitive landscape');
await expect(beta).toContainText('honey-ledger module');
await expect(alpha).not.toContainText('honey-ledger');
await expect(beta).not.toContainText('Scout the competitive');
});
});

View File

@@ -0,0 +1,160 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const axeSource = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
test.use({ bypassCSP: true });
const SKIP_PARAMS = 'skipOnboarding=true&skipBoot=true&skipBriefing=true&tier=power';
const ROUTES = [
'/home',
'/settings',
'/settings?tab=models',
'/settings?tab=permissions',
'/settings?tab=team',
'/settings?tab=backup',
'/settings?tab=billing',
'/settings?tab=channels',
'/settings?tab=advanced',
'/settings/profile',
'/settings/vault',
'/settings/mission-control',
'/settings/timeline',
'/settings/events',
'/settings/usage',
'/memory',
'/memory?tab=trust',
'/memory?tab=memories',
'/memory?tab=timeline',
'/memory?tab=graph',
'/memory?tab=harvest',
'/memory?tab=weaver',
'/memory?tab=wiki',
'/memory?tab=evolution',
'/workspaces',
'/workspaces/default-workspace/chat',
'/workspaces/default-workspace/tasks',
'/agents',
'/automations',
'/skills',
'/room',
'/waggle-dance',
'/launcher',
'/connectors',
'/mcps',
'/marketplace',
'/files',
'/approvals',
'/artifacts',
'/team',
'/benchmarks',
'/platform',
] as const;
const VIEWPORTS = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
] as const;
type AxeViolation = {
id: string;
impact: string | null;
description: string;
help: string;
nodes: Array<{ target: string[]; html: string; failureSummary?: string }>;
};
type AxeResult = {
violations: AxeViolation[];
};
function routeWithSkip(route: string) {
const sep = route.includes('?') ? '&' : '?';
return `${route}${sep}${SKIP_PARAMS}`;
}
async function gotoApp(page: Page, route: string) {
await page.goto(routeWithSkip(route), { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.waggle-sidebar, [role="navigation"], main', { timeout: 15_000 });
// Let lazy route content and the shell's 200ms entrance transition settle
// before axe samples transient dialog/backdrop layers.
await page.waitForTimeout(800);
}
async function runAxe(page: Page): Promise<AxeViolation[]> {
await page.addScriptTag({ content: axeSource });
const result = await page.evaluate(async () => {
const axe = (window as typeof window & {
axe: { run: (context?: unknown, options?: unknown) => Promise<AxeResult> };
}).axe;
return axe.run(document, {
resultTypes: ['violations'],
});
});
return result.violations;
}
function formatViolations(violations: AxeViolation[]) {
return violations.map((violation) => ({
id: violation.id,
impact: violation.impact,
help: violation.help,
nodes: violation.nodes.map((node) => ({
target: node.target.join(' '),
html: node.html,
failureSummary: node.failureSummary,
})),
}));
}
async function seedWaggleDanceSignal(request: APIRequestContext) {
const response = await request.post('/api/waggle/signals', {
data: {
type: 'discovery',
workspaceId: 'default-workspace',
content: 'Accessible signal timestamp',
metadata: { senderId: 'runtime-a11y' },
},
});
expect(response.status()).toBe(201);
}
test.describe('Runtime accessibility smoke', () => {
test('milestone toast and signal badges have no mobile accessibility violations', async ({ page, request }) => {
await page.setViewportSize({ width: 390, height: 844 });
await seedWaggleDanceSignal(request);
await page.addInitScript(() => {
window.localStorage.setItem('waggle:session-count', '49');
window.localStorage.setItem('waggle:dock-nudge-dismissed', '[10]');
});
await gotoApp(page, '/waggle-dance');
await expect(page.getByText('50 sessions in — nicely done', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Close notification' })).toHaveCSS('opacity', '1');
await expect(page.getByTestId('waggle-unacknowledged-count')).toBeVisible();
expect(formatViolations(await runAxe(page))).toEqual([]);
});
for (const viewport of VIEWPORTS) {
test(`axe has no violations across core routes (${viewport.name})`, async ({ page, request }) => {
test.setTimeout(150_000);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await seedWaggleDanceSignal(request);
const findings: Array<{ route: string; violations: ReturnType<typeof formatViolations> }> = [];
for (const route of ROUTES) {
await gotoApp(page, route);
const violations = await runAxe(page);
if (violations.length > 0) {
findings.push({ route, violations: formatViolations(violations) });
}
}
expect(findings).toEqual([]);
});
}
});

View File

@@ -0,0 +1,146 @@
/**
* Phase A/B polish verification — boot skip + spawn-agent flow.
*
* Covers:
* H-01 (QW-3): BootScreen shows on first visit, skipped on subsequent visits.
* H-02 (P35): SpawnAgentDialog empty-state branches correctly:
* - no keys configured → Settings → Vault CTA with Key icon
* - keys configured but no models → retry CTA
* - models present → model chips rendered
* H-03 (P36): Dock spawn-agent icon click opens SpawnAgentDialog.
*
* Run: npx playwright test tests/e2e/spawn-agent-flow.spec.ts --reporter=list
*/
import { test, expect, type Page } from '@playwright/test';
/**
* Navigate to the Desktop with onboarding + boot screen both skipped.
* Uses the supported `?skipOnboarding=true&tier=power` bypass defined in
* `hooks/useOnboarding.ts:25-32` — that branch writes the completed state
* to localStorage synchronously before the first render so the Desktop
* mounts immediately with the power-tier dock (which includes the
* spawn-agent shortcut).
*/
async function gotoDesktop(page: Page, url = '/home?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true') {
await page.addInitScript(() => {
localStorage.setItem('waggle-booted', 'true');
});
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[role="navigation"], main', { timeout: 10_000 });
}
async function clickSpawnAgent(page: Page) {
const spawnButton = page.getByTestId('nav-spawn-agent');
await expect(spawnButton).toBeVisible({ timeout: 10_000 });
await spawnButton.click();
}
// ── H-01 · BootScreen skip on return visits ────────────────────────────
test.describe('H-01 QW-3 · BootScreen skip', () => {
test('first visit renders BootScreen', async ({ page }) => {
// Do NOT set waggle-booted — we want the fresh-state path.
// skipOnboarding bypass still applies so nothing else blocks.
await page.addInitScript(() => {
localStorage.removeItem('waggle-booted');
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByTestId('boot-screen')).toBeVisible({ timeout: 5_000 });
});
test('return visit skips BootScreen', async ({ page }) => {
await gotoDesktop(page);
await page.waitForLoadState('domcontentloaded');
await expect(page.getByTestId('boot-screen')).toHaveCount(0);
});
});
// ── H-03 · Dock spawn-agent click opens the dialog ─────────────────────
test.describe('H-03 P36 · Dock spawn-agent wiring', () => {
test('clicking the dock rocket icon opens SpawnAgentDialog', async ({ page }) => {
await gotoDesktop(page);
await clickSpawnAgent(page);
await expect(page.getByTestId('spawn-agent-dialog')).toBeVisible();
});
});
// ── H-02 · SpawnAgentDialog empty-state branches ───────────────────────
test.describe('H-02 P35 · Spawn-agent models empty-state', () => {
test('no keys configured → Settings→Vault CTA', async ({ page }) => {
// Mock both endpoints BEFORE navigation so the dialog's useEffect hits them.
await page.route('**/api/litellm/models', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }),
);
await page.route('**/api/agent/model', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ model: '' }) }),
);
await page.route('**/api/providers', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
providers: [
{ id: 'anthropic', name: 'Anthropic', hasKey: false, models: [] },
{ id: 'openai', name: 'OpenAI', hasKey: false, models: [] },
],
}),
}),
);
await gotoDesktop(page);
await clickSpawnAgent(page);
await expect(page.getByTestId('spawn-no-keys-cta')).toBeVisible();
await expect(page.getByTestId('spawn-no-keys-cta')).toContainText(/Settings → Vault|Ollama/i);
});
test('keys configured but no models → retry CTA', async ({ page }) => {
await page.route('**/api/litellm/models', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }),
);
await page.route('**/api/agent/model', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ model: '' }) }),
);
await page.route('**/api/providers', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
providers: [
{ id: 'anthropic', name: 'Anthropic', hasKey: true, models: [] },
],
}),
}),
);
await gotoDesktop(page);
await clickSpawnAgent(page);
await expect(page.getByTestId('spawn-no-models-cta')).toBeVisible();
await expect(page.getByTestId('spawn-no-models-cta')).toContainText(/Retry/i);
});
test('models present → model chip list', async ({ page }) => {
await page.route('**/api/litellm/models', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(['claude-sonnet-4-6', 'gpt-4.1', 'gemma4:31b']),
}),
);
await page.route('**/api/providers', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
providers: [{ id: 'anthropic', name: 'Anthropic', hasKey: true, models: [] }],
}),
}),
);
await gotoDesktop(page);
await clickSpawnAgent(page);
await expect(page.getByTestId('spawn-models-list')).toBeVisible();
const claude = page.getByTestId('spawn-models-list')
.getByRole('button', { name: 'Claude Sonnet 4.6' });
await expect(claude).toBeVisible();
await expect(claude).toHaveAttribute('title', 'claude-sonnet-4-6');
});
});

View File

@@ -0,0 +1,139 @@
/**
* Team Server E2E — tests the multi-user team server running on Docker
* (Postgres + Redis + LiteLLM).
*
* Requires: docker-compose up (postgres:5434, redis:6381, litellm:4000)
* Team server running on port 3100.
*/
import { test, expect } from '@playwright/test';
const TEAM = 'http://127.0.0.1:3100';
test.beforeEach(async ({ request }) => {
try {
const res = await request.get(`${TEAM}/health`, { timeout: 1_000 });
test.skip(!res.ok(), 'Optional team server is not running on 127.0.0.1:3100');
} catch {
test.skip(true, 'Optional team server is not running on 127.0.0.1:3100');
}
});
// ── 1. Server Health ──────────────────────────────────────────────────
test.describe('1. Team Server Health', () => {
test('health endpoint responds', async ({ request }) => {
const res = await request.get(`${TEAM}/health`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.status).toBe('ok');
});
});
// ── 2. Team CRUD ──────────────────────────────────────────────────────
test.describe('2. Team Management', () => {
let teamSlug: string;
test('can create a team', async ({ request }) => {
const name = `TestTeam-${Date.now()}`;
teamSlug = name.toLowerCase().replace(/[^a-z0-9]/g, '-');
const res = await request.post(`${TEAM}/api/teams`, {
data: { name, slug: teamSlug },
headers: { 'Content-Type': 'application/json' },
});
// May need auth — accept 200, 201, or 401
expect([200, 201, 401, 403]).toContain(res.status());
});
test('can list teams', async ({ request }) => {
const res = await request.get(`${TEAM}/api/teams`);
expect([200, 401, 403]).toContain(res.status());
if (res.ok()) {
const data = await res.json();
expect(Array.isArray(data) || Array.isArray(data.teams)).toBeTruthy();
}
});
});
// ── 3. Agent Routes ───────────────────────────────────────────────────
test.describe('3. Agent & Model Routes', () => {
test('agents list', async ({ request }) => {
const res = await request.get(`${TEAM}/api/agents`);
expect([200, 401]).toContain(res.status());
});
test('workflows list', async ({ request }) => {
const res = await request.get(`${TEAM}/api/workflows`);
expect([200, 401, 404]).toContain(res.status());
});
});
// ── 4. Database Connection ────────────────────────────────────────────
test.describe('4. Database', () => {
test('postgres is reachable from server', async ({ request }) => {
// The server started successfully which means DB connected.
// Verify by hitting an endpoint that requires DB.
const res = await request.get(`${TEAM}/api/teams`);
// If 500, DB connection failed. 200 or 401 means DB is fine.
expect(res.status()).not.toBe(500);
});
});
// ── 5. WebSocket Gateway ──────────────────────────────────────────────
test.describe('5. WebSocket', () => {
test('ws endpoint exists', async ({ request }) => {
// HTTP GET to /ws should return upgrade required or similar
const res = await request.get(`${TEAM}/ws`);
// WebSocket endpoints typically return 400 or 426 on plain HTTP
expect([400, 404, 426]).toContain(res.status());
});
});
// ── 6. Cron & Jobs ────────────────────────────────────────────────────
test.describe('6. Background Services', () => {
test('cron status endpoint', async ({ request }) => {
const res = await request.get(`${TEAM}/api/cron`);
expect([200, 401, 404]).toContain(res.status());
});
});
// ── 7. Knowledge & Resources ──────────────────────────────────────────
test.describe('7. Knowledge Routes', () => {
test('skills endpoint', async ({ request }) => {
const res = await request.get(`${TEAM}/api/skills`);
expect([200, 401, 404]).toContain(res.status());
});
});
// ── 8. Concurrent Requests ────────────────────────────────────────────
test.describe('8. Concurrency', () => {
test('10 concurrent health checks all succeed', async ({ request }) => {
const results = await Promise.all(
Array.from({ length: 10 }, () => request.get(`${TEAM}/health`))
);
for (const res of results) {
expect(res.ok()).toBeTruthy();
}
});
test('server handles rapid API calls without crashing', async ({ request }) => {
const endpoints = ['/health', '/api/teams', '/api/agents', '/api/workflows', '/api/cron'];
const results = await Promise.all(
endpoints.map(ep => request.get(`${TEAM}${ep}`))
);
// No 500s — server survived the burst
for (const res of results) {
expect(res.status()).toBeLessThan(500);
}
// Health still ok after burst
const health = await request.get(`${TEAM}/health`);
expect(health.ok()).toBeTruthy();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,963 @@
/**
* E2E User Journey Tests — current AppShell browser journeys.
*
* These tests exercise the live single-canvas shell as a fresh returning user:
* navigation, shortcuts, chat entry, command search, settings, theme, home,
* keyboard help, and status-bar context.
*/
import { test, expect, type Page } from '@playwright/test';
const SKIP_PARAMS = 'skipOnboarding=true&skipBoot=true&skipBriefing=true&tier=power';
function routeWithSkip(route: string) {
const sep = route.includes('?') ? '&' : '?';
return `${route}${sep}${SKIP_PARAMS}`;
}
async function gotoApp(page: Page, route = '/home') {
await page.goto(routeWithSkip(route), { waitUntil: 'domcontentloaded' });
await waitForShell(page);
}
async function waitForShell(page: Page) {
await page.waitForSelector('.waggle-sidebar, [role="navigation"], main', { timeout: 15_000 });
await page.waitForTimeout(300);
}
async function openViaNav(page: Page, testId: string, routePattern: RegExp) {
await page.getByTestId(testId).click();
await page.waitForURL(routePattern, { timeout: 10_000 });
await waitForShell(page);
}
async function pressCtrlShiftDigit(page: Page, digit: string) {
await page.evaluate((d) => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: d,
code: `Digit${d}`,
ctrlKey: true,
shiftKey: true,
bubbles: true,
}));
}, digit);
}
async function visibleHorizontalOverflow(page: Page, selector: string) {
return page.locator(selector).evaluateAll(elements => elements
.map(el => {
const rect = el.getBoundingClientRect();
return {
text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.tagName).trim().slice(0, 80),
left: Math.floor(rect.left),
right: Math.ceil(rect.right),
width: Math.ceil(rect.width),
};
})
.filter(item => item.width > 0 && (item.left < -1 || item.right > window.innerWidth + 1)));
}
test.describe('User Journey Tests', () => {
test('J1: app loads successfully — no blank screen', async ({ page }) => {
await gotoApp(page, '/home');
await expect(page.locator('body')).not.toBeEmpty();
await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
await expect(page.getByText('Waggle AI')).toBeVisible();
});
test('J2: sidebar shows current navigation items', async ({ page }) => {
await gotoApp(page);
const sidebar = page.getByRole('navigation', { name: 'Primary' });
await expect(sidebar).toBeVisible();
for (const label of ['Home', 'Chat', 'Memory', 'Agents', 'Library']) {
await expect(sidebar.getByRole('button', { name: label })).toBeVisible();
}
await expect(page.getByTestId('sidebar-command')).toBeVisible();
await expect(page.getByTestId('nav-spawn-agent')).toBeVisible();
});
test('J3: workspace switcher opens and closes', async ({ page }) => {
await gotoApp(page);
await page.getByTestId('sidebar-workspace').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog).toContainText(/workspace/i);
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
});
test('J3b: notification and create-workspace overlays have dialog close contracts', async ({ page }) => {
await gotoApp(page);
await page.getByRole('button', { name: /^Notifications/ }).click();
const notifications = page.getByRole('dialog', { name: /notifications/i });
await expect(notifications).toBeVisible({ timeout: 5_000 });
await expect(notifications.getByRole('button', { name: /close notifications/i })).toBeVisible();
await page.keyboard.press('Escape');
await expect(notifications).not.toBeVisible({ timeout: 5_000 });
await page.getByTestId('sidebar-workspace').click();
const switcher = page.getByRole('dialog', { name: /switch workspace/i });
await expect(switcher).toBeVisible({ timeout: 5_000 });
await switcher.getByRole('button', { name: /new workspace/i }).click();
const createWorkspace = page.getByRole('dialog', { name: /create workspace/i });
await expect(createWorkspace).toBeVisible({ timeout: 5_000 });
await expect(createWorkspace.getByRole('button', { name: /close create workspace/i })).toBeVisible();
await page.keyboard.press('Escape');
await expect(createWorkspace).not.toBeVisible({ timeout: 5_000 });
});
test('J3c: tier interruption modal exposes a named close contract', async ({ page }) => {
await gotoApp(page);
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('waggle:tier-insufficient', {
detail: {
required: 'TEAMS',
actual: 'FREE',
message: 'Team workspaces require a Team plan.',
},
}));
});
const upgrade = page.getByRole('dialog', { name: /upgrade to unlock/i });
await expect(upgrade).toBeVisible({ timeout: 5_000 });
await upgrade.getByRole('button', { name: /close upgrade dialog/i }).click();
await expect(upgrade).not.toBeVisible({ timeout: 5_000 });
});
test('J3d: approvals revoke-all uses an in-app confirmation', async ({ page }) => {
const nativeDialogs: string[] = [];
let clearCalled = false;
await page.route('**/api/approval/pending', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ pending: [], count: 0 }),
}));
await page.route('**/api/approval/grants', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
grants: [{
id: 'grant-send-email',
toolName: 'send_email',
targetKey: 'client@example.com',
sourceWorkspaceId: 'workspace-1',
description: 'Always allow send_email to client@example.com',
grantedAt: new Date().toISOString(),
expiresAt: null,
}],
count: 1,
}),
}));
await page.route('**/api/approval/grants/clear', route => {
clearCalled = true;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
});
page.on('dialog', async dialog => {
nativeDialogs.push(dialog.message());
await dialog.dismiss();
});
await gotoApp(page, '/approvals');
await page.getByRole('button', { name: /grants/i }).click();
await expect(page.getByText('1 active grant')).toBeVisible({ timeout: 5_000 });
await page.getByRole('button', { name: /revoke all/i }).click();
expect(nativeDialogs).toEqual([]);
const modal = page.getByTestId('approval-modal');
await expect(modal).toBeVisible({ timeout: 5_000 });
await expect(modal).toContainText(/revoke all saved approval grants/i);
await expect(modal).toContainText(/1 saved grant/i);
await page.getByTestId('approval-modal-approve').click();
await expect.poll(() => clearCalled).toBe(true);
await expect(page.getByText('No saved grants')).toBeVisible({ timeout: 5_000 });
});
test('J3e: artifact delete uses an in-app confirmation', async ({ page }) => {
const nativeDialogs: string[] = [];
let deleteCalled = false;
const artifact = {
id: 'artifact-brief',
title: 'Quarterly Research Brief',
kind: 'document',
workspaceId: 'workspace-research',
createdBy: 'agent-researcher',
source: 'agent',
status: 'ready',
storagePath: '/artifacts/quarterly-brief.md',
tags: ['research'],
createdAt: '2026-07-08T08:00:00.000Z',
updatedAt: '2026-07-08T09:00:00.000Z',
};
await page.route('**/api/artifacts**', route => {
const request = route.request();
const url = new URL(request.url());
if (url.pathname === '/api/artifacts/search-related') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ memories: [], sessions: [], tasks: [], agents: [], artifacts: [] }),
});
}
if (url.pathname === '/api/artifacts' && request.method() === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: [artifact], count: 1 }),
});
}
if (url.pathname === '/api/artifacts/artifact-brief' && request.method() === 'DELETE') {
deleteCalled = true;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
}
return route.continue();
});
page.on('dialog', async dialog => {
nativeDialogs.push(dialog.message());
await dialog.dismiss();
});
await gotoApp(page, '/artifacts');
await page.getByRole('button', { name: /quarterly research brief/i }).click();
await expect(page.getByRole('dialog')).toContainText(/quarterly research brief/i);
await page.getByRole('button', { name: /^delete$/i }).click();
expect(nativeDialogs).toEqual([]);
const modal = page.getByTestId('approval-modal');
await expect(modal).toBeVisible({ timeout: 5_000 });
await expect(modal).toContainText(/delete artifact permanently/i);
await expect(modal).toContainText(/backing file/i);
await page.getByTestId('approval-modal-approve').click();
await expect.poll(() => deleteCalled).toBe(true);
});
test('J3f: memory destructive trust actions use in-app confirmations', async ({ page }) => {
const nativeDialogs: string[] = [];
let deleteCalled = false;
let eraseCalled = false;
let allowCalled = false;
const memory = {
id: 'memory-research-note',
kind: 'fact',
title: 'Research Note',
content: 'The supplier review belongs in the Q3 diligence packet.',
scope: 'personal',
workspaceId: null,
source: 'user_stated',
sourceId: null,
sourceUrl: null,
importance: 'normal',
status: 'active',
confidence: 91,
tags: ['research'],
evidence: [],
hasOriginalSource: false,
createdAt: '2026-07-08T08:00:00.000Z',
updatedAt: '2026-07-08T09:00:00.000Z',
};
const suppression = {
source: 'chatgpt',
sourceRef: 'research-export.json',
erasedAt: '2026-07-08T09:30:00.000Z',
reason: 'GDPR Art.17 erasure',
};
await page.route('**/api/memory**', route => {
const request = route.request();
const url = new URL(request.url());
if (url.pathname === '/api/memory' && request.method() === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: [memory], count: 1 }),
});
}
if (url.pathname === '/api/memory/memory-research-note' && request.method() === 'DELETE') {
deleteCalled = true;
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
}
if (url.pathname === '/api/memory/erase' && request.method() === 'POST') {
eraseCalled = true;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
erased: true,
mind: 'personal',
result: {
framesDeleted: 1,
archiveRedacted: 1,
chunkVectorsPurged: 0,
entitiesErased: 1,
relationsErased: 0,
},
}),
});
}
if (url.pathname === '/api/memory/suppression' && request.method() === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ mind: 'personal', suppressed: [suppression] }),
});
}
if (url.pathname === '/api/memory/suppression/allow' && request.method() === 'POST') {
allowCalled = true;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ removed: true, mind: 'personal' }),
});
}
return route.continue();
});
page.on('dialog', async dialog => {
nativeDialogs.push(dialog.message());
await dialog.dismiss();
});
await gotoApp(page, '/memory?tab=memories');
const memoryPanel = page.getByTestId('memory-view-panel');
const memoryCard = memoryPanel.getByLabel('Research Note', { exact: true });
await memoryCard.click();
await expect(page.getByRole('dialog')).toContainText(/research note/i);
await page.getByRole('button', { name: /^delete$/i }).click();
expect(nativeDialogs).toEqual([]);
await expect(page.getByTestId('approval-modal')).toContainText(/delete memory permanently/i);
await expect(page.getByTestId('approval-modal')).toContainText(/archive/i);
await page.getByTestId('approval-modal-approve').click();
await expect.poll(() => deleteCalled).toBe(true);
await memoryCard.click();
await page.getByRole('button', { name: /^erase$/i }).click();
expect(nativeDialogs).toEqual([]);
await expect(page.getByTestId('approval-modal')).toContainText(/erase memory and derived data/i);
await expect(page.getByTestId('approval-modal')).toContainText(/cannot be undone/i);
await page.getByTestId('approval-modal-approve').click();
await expect.poll(() => eraseCalled).toBe(true);
await expect(page.getByText(/erased "research note"/i)).toBeVisible({ timeout: 5_000 });
await page.getByRole('button', { name: /erased sources/i }).click();
await expect(page.getByText('research-export.json')).toBeVisible({ timeout: 5_000 });
await page.getByRole('button', { name: /allow re-import/i }).click();
expect(nativeDialogs).toEqual([]);
await expect(page.getByTestId('approval-modal')).toContainText(/allow source to be re-imported/i);
await expect(page.getByTestId('approval-modal')).toContainText(/re-consented/i);
await page.getByTestId('approval-modal-approve').click();
await expect.poll(() => allowCalled).toBe(true);
});
test('J3g: wiki export destinations use in-app forms', async ({ page }) => {
const nativeDialogs: string[] = [];
let obsidianCalled = false;
let notionCalled = false;
let obsidianBody: unknown = null;
let notionBody: unknown = null;
const wikiPage = {
slug: 'research-guide',
pageType: 'entity',
name: 'Research Guide',
contentHash: 'hash-research-guide',
markdown: '# Research Guide',
frameIds: 'memory-1',
compiledAt: '2026-07-08T09:00:00.000Z',
sourceCount: 3,
};
await page.route('**/api/wiki/**', async route => {
const request = route.request();
const url = new URL(request.url());
if (url.pathname === '/api/wiki/pages' && request.method() === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([wikiPage]),
});
}
if (url.pathname === '/api/wiki/export/obsidian' && request.method() === 'POST') {
obsidianCalled = true;
obsidianBody = request.postDataJSON();
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
outDir: 'C:/Research Vault',
filesWritten: 3,
indexPath: 'C:/Research Vault/_index.md',
byType: { entity: 1, concept: 2 },
}),
});
}
if (url.pathname === '/api/wiki/export/notion' && request.method() === 'POST') {
notionCalled = true;
notionBody = request.postDataJSON();
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
pagesCreated: 1,
pagesUpdated: 2,
pagesUnchanged: 0,
pagesFailed: 0,
byType: { entity: 1 },
errors: [],
}),
});
}
return route.continue();
});
page.on('dialog', async dialog => {
nativeDialogs.push(dialog.message());
await dialog.dismiss();
});
await gotoApp(page, '/memory?tab=wiki');
await expect(page.getByText('Research Guide')).toBeVisible({ timeout: 5_000 });
await page.getByRole('button', { name: /export to obsidian vault/i }).click();
expect(nativeDialogs).toEqual([]);
await expect(page.getByTestId('wiki-export-dialog')).toContainText(/export to obsidian/i);
await page.getByLabel(/obsidian vault directory/i).fill(' C:/Research Vault ');
await page.getByRole('button', { name: /^export to obsidian$/i }).click();
await expect.poll(() => obsidianCalled).toBe(true);
expect(obsidianBody).toEqual({ outDir: 'C:/Research Vault' });
await expect(page.getByText(/obsidian export complete/i)).toBeVisible({ timeout: 5_000 });
await page.getByRole('button', { name: /export to notion workspace/i }).click();
expect(nativeDialogs).toEqual([]);
await expect(page.getByTestId('wiki-export-dialog')).toContainText(/export to notion/i);
await page.getByLabel(/notion root page url/i).fill(' https://www.notion.so/root ');
await page.getByRole('button', { name: /^export to notion$/i }).click();
await expect.poll(() => notionCalled).toBe(true);
expect(notionBody).toEqual({ rootPageUrl: 'https://www.notion.so/root' });
await expect(page.getByText(/notion export complete/i)).toBeVisible({ timeout: 5_000 });
expect(nativeDialogs).toEqual([]);
});
test('J3h: settings backup and restore trust actions stay in-app', async ({ page }) => {
const nativeDialogs: string[] = [];
let restoreCalled = false;
await page.route('**/api/backup', route => route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'Vault key missing' }),
}));
await page.route('**/api/restore', route => {
restoreCalled = true;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
});
page.on('dialog', async dialog => {
nativeDialogs.push(dialog.message());
await dialog.dismiss();
});
await gotoApp(page, '/settings?tab=backup');
await expect(page.getByRole('heading', { name: /encrypted backup/i })).toBeVisible({ timeout: 5_000 });
await page.getByRole('button', { name: /create backup/i }).click();
expect(nativeDialogs).toEqual([]);
await expect(page.getByRole('alert')).toContainText(/vault key missing/i);
await page.getByLabel(/restore backup file/i).setInputFiles({
name: 'research.waggle-backup',
mimeType: 'application/octet-stream',
buffer: Buffer.from('backup-data'),
});
expect(nativeDialogs).toEqual([]);
await expect(page.getByTestId('approval-modal')).toContainText(/restore backup/i);
await expect(page.getByTestId('approval-modal')).toContainText(/research\.waggle-backup/i);
await expect(page.getByTestId('approval-modal')).toContainText(/overwrite current data/i);
await page.getByTestId('approval-modal-approve').click();
await expect.poll(() => restoreCalled).toBe(true);
await expect(page.getByRole('status').filter({ hasText: /backup restored successfully/i })).toBeVisible({ timeout: 5_000 });
expect(nativeDialogs).toEqual([]);
});
test('J4: navigate between primary surfaces using sidebar', async ({ page }) => {
await gotoApp(page);
await openViaNav(page, 'nav-memory', /\/memory/);
await expect(page.locator('body')).toContainText(/memory/i);
await openViaNav(page, 'nav-agents', /\/agents/);
await expect(page.locator('body')).toContainText(/agent|task|template/i);
await openViaNav(page, 'nav-library', /\/artifacts/);
await expect(page.locator('body')).toContainText(/artifact|library|file|workspace/i);
await openViaNav(page, 'nav-home', /\/home/);
await expect(page.locator('body')).toContainText(/workspace|today|continue|create/i);
});
test('J5: navigate views with keyboard shortcuts', async ({ page }) => {
await gotoApp(page);
await pressCtrlShiftDigit(page, '7');
await page.waitForURL(/\/settings/, { timeout: 10_000 });
await expect(page.getByRole('tablist', { name: 'Settings sections' })).toBeVisible();
await pressCtrlShiftDigit(page, '5');
await page.waitForURL(/\/memory/, { timeout: 10_000 });
await expect(page.locator('body')).toContainText(/memory/i);
await pressCtrlShiftDigit(page, '2');
await page.waitForURL(/\/agents/, { timeout: 10_000 });
await expect(page.locator('body')).toContainText(/agent|task|template/i);
});
test('J6: chat textarea accepts input', async ({ page, request }) => {
const workspacesRes = await request.get('/api/workspaces');
const workspaces = await workspacesRes.json();
let workspaceId = Array.isArray(workspaces) ? workspaces[0]?.id : undefined;
if (!workspaceId) {
const createRes = await request.post('/api/workspaces', {
data: { name: `Journey Chat ${Date.now()}`, group: 'Workspaces', description: 'Chat journey workspace' },
});
expect([200, 201, 403, 409]).toContain(createRes.status());
if (createRes.ok()) {
const created = await createRes.json();
const workspace = created.workspace ?? created.data ?? created;
workspaceId = workspace.id ?? workspace.name;
} else {
workspaceId = 'default';
}
}
expect(workspaceId).toBeTruthy();
await gotoApp(page, `/workspaces/${workspaceId}/chat`);
const textarea = page.getByRole('textbox').first();
await expect(textarea).toBeVisible({ timeout: 10_000 });
await textarea.fill('Hello Waggle, this is a test message');
await expect(textarea).toHaveValue('Hello Waggle, this is a test message');
await textarea.fill('/help');
await expect(textarea).toHaveValue('/help');
});
test('J7: global search opens, searches, selects, and closes', async ({ page }) => {
await gotoApp(page);
await page.keyboard.press('Control+k');
const dialog = page.getByTestId('command-center-dialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });
const searchInput = dialog.locator('input, [data-slot="command-input"]').first();
await expect(searchInput).toBeVisible();
await searchInput.fill('memory');
await expect(dialog).toContainText(/memory/i);
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
});
test('J-mobile: Command Center is described and fits at 390px', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await gotoApp(page);
await page.keyboard.press('Control+k');
const dialog = page.getByTestId('command-center-dialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog).toHaveAttribute('aria-describedby', /.+/);
const describedBy = await dialog.getAttribute('aria-describedby');
const description = await page.evaluate((id) => document.getElementById(id ?? '')?.textContent ?? '', describedBy);
expect(description).toMatch(/search and run commands/i);
const overflow = await visibleHorizontalOverflow(
page,
'[data-testid="command-center-dialog"] [cmdk-item]:visible, [data-testid="command-center-dialog"] [cmdk-item] *:visible, [data-testid="command-center-dialog"] input:visible, [data-testid="command-center-dialog"] kbd:visible',
);
expect(overflow, 'command center mobile overflow').toEqual([]);
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
});
test('J8: settings view shows tabs and tab content', async ({ page }) => {
await gotoApp(page, '/settings');
const tablist = page.getByRole('tablist', { name: 'Settings sections' });
await expect(tablist).toBeVisible();
await expect(tablist.getByRole('tab', { name: /Models/i })).toBeVisible();
await expect(tablist.getByRole('tab', { name: /General/i })).toBeVisible();
const panel = page.getByRole('tabpanel').first();
await tablist.getByRole('tab', { name: /General/i }).click();
await expect(panel).toContainText(/Theme|Local-first/i);
await tablist.getByRole('tab', { name: /Models/i }).click();
await expect(panel).toContainText(/Model|provider|local/i);
});
test('J-mobile: Settings is usable at 390px width', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const routes = ['/settings', '/settings?tab=models', '/settings?tab=billing', '/settings/profile'];
for (const route of routes) {
await gotoApp(page, route);
if (route !== '/settings/profile') {
await expect(page.getByRole('tablist', { name: 'Settings sections' })).toBeVisible();
await expect(page.getByRole('tabpanel').first()).toBeVisible();
} else {
await expect(page.locator('body')).toContainText(/profile|identity|save|writing style/i);
}
const documentOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
);
expect(documentOverflow, `${route} document overflow`).toBe(false);
const overflow = await visibleHorizontalOverflow(
page,
'button:visible, [role="tab"]:visible, [role="tabpanel"]:visible, input:visible, select:visible, textarea:visible',
);
expect(overflow, `${route} visible control overflow`).toEqual([]);
}
});
test('J-mobile: create workspace prioritizes primary setup at 390px width', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await gotoApp(page);
await page.getByTestId('sidebar-workspace').click();
const switcher = page.getByRole('dialog', { name: /switch workspace/i });
await expect(switcher).toBeVisible({ timeout: 5_000 });
await switcher.getByRole('button', { name: /new workspace/i }).click();
const dialog = page.getByRole('dialog', { name: /create workspace/i });
await expect(dialog).toBeVisible({ timeout: 5_000 });
const nameInput = dialog.getByRole('textbox', { name: /what project or area/i });
const createButton = dialog.getByRole('button', { name: /^create workspace$/i });
const templateButton = dialog.getByRole('button', { name: /start from template/i });
await expect(nameInput).toBeVisible();
await expect(createButton).toBeVisible();
await expect(templateButton).toBeVisible();
await expect(dialog.getByPlaceholder(/search templates/i)).toHaveCount(0);
const rects = await Promise.all([
nameInput.evaluate(el => {
const r = el.getBoundingClientRect();
return { top: Math.floor(r.top), bottom: Math.ceil(r.bottom), viewport: window.innerHeight };
}),
createButton.evaluate(el => {
const r = el.getBoundingClientRect();
return { top: Math.floor(r.top), bottom: Math.ceil(r.bottom), viewport: window.innerHeight };
}),
]);
expect(rects[0].bottom, `workspace name initially reachable: ${JSON.stringify(rects[0])}`).toBeLessThanOrEqual(rects[0].viewport);
expect(rects[1].bottom, `create action initially reachable: ${JSON.stringify(rects[1])}`).toBeLessThanOrEqual(rects[1].viewport);
await templateButton.click();
await expect(dialog.getByPlaceholder(/search templates/i)).toBeVisible();
const agentButton = dialog.getByRole('button', { name: /choose an agent/i });
await expect(agentButton).toHaveAttribute('aria-expanded', 'false');
await expect(dialog.getByText('Agent (optional)', { exact: true })).toHaveCount(0);
await agentButton.click();
await expect(dialog.getByRole('button', { name: /hide agent assignment/i })).toHaveAttribute('aria-expanded', 'true');
await expect(dialog.getByText('Agent (optional)', { exact: true })).toBeVisible();
expect(await visibleHorizontalOverflow(
page,
'[role="dialog"]:visible, [role="dialog"] button:visible, [role="dialog"] input:visible, [role="dialog"] textarea:visible',
), 'create workspace mobile overflow').toEqual([]);
});
test('J-mobile: first-run onboarding keeps primary actions reachable at 390px width', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
page.on('pageerror', err => pageErrors.push(err.message));
await page.addInitScript(() => {
localStorage.clear();
sessionStorage.clear();
});
await page.goto('/?forceWizard=true', { waitUntil: 'domcontentloaded' });
const onboarding = page.getByRole('region', { name: /waggle onboarding/i });
await expect(onboarding).toBeVisible({ timeout: 20_000 });
expect(await visibleHorizontalOverflow(
page,
'[aria-label="Waggle onboarding"]:visible, [aria-label="Waggle onboarding"] button:visible, [aria-label="Waggle onboarding"] input:visible, [aria-label="Waggle onboarding"] [role="combobox"]:visible',
), 'welcome overflow').toEqual([]);
await onboarding.getByRole('button', { name: /continue/i }).click();
await expect(onboarding.getByText(/tell us who you are/i)).toBeVisible({ timeout: 10_000 });
await page.waitForTimeout(300);
const profileContinue = onboarding.getByRole('button', { name: /continue/i });
await expect(profileContinue).toBeVisible();
const rect = await profileContinue.evaluate(el => {
const r = el.getBoundingClientRect();
return { top: Math.floor(r.top), bottom: Math.ceil(r.bottom), viewport: window.innerHeight };
});
expect(rect.bottom, `Profile Continue should be initially reachable: ${JSON.stringify(rect)}`).toBeLessThanOrEqual(rect.viewport);
expect(await visibleHorizontalOverflow(
page,
'[aria-label="Waggle onboarding"]:visible, [aria-label="Waggle onboarding"] button:visible, [aria-label="Waggle onboarding"] input:visible, [aria-label="Waggle onboarding"] [role="combobox"]:visible',
), 'profile overflow').toEqual([]);
expect(pageErrors).toHaveLength(0);
expect(consoleErrors.filter(e => /clerk|content security policy|csp/i.test(e))).toHaveLength(0);
});
test('J-model: onboarding API-key setup reaches a saved, continuable state', async ({ page }) => {
let keySaved = false;
const settingsPayloads: Record<string, unknown>[] = [];
const providers = {
providers: [
{
id: 'anthropic', name: 'Anthropic', hasKey: false, badge: null,
keyUrl: 'https://console.anthropic.com/settings/keys', requiresKey: true,
models: [{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', cost: '$$', speed: 'medium' }],
},
{
id: 'openai', name: 'OpenAI', hasKey: false, badge: null,
keyUrl: 'https://platform.openai.com/api-keys', requiresKey: true,
models: [{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', cost: '$', speed: 'fast' }],
},
],
search: [{ id: 'duckduckgo', name: 'DuckDuckGo', hasKey: true, priority: 4 }],
activeSearch: 'duckduckgo',
};
await page.route('**/api/providers', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
...providers,
providers: providers.providers.map(provider => ({ ...provider, hasKey: provider.id === 'anthropic' ? keySaved : provider.hasKey })),
}),
}));
await page.route('**/api/local-inference/status', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ servers: [], ollamaInstalled: false, totalLocalModels: 0 }),
}));
await page.route('**/api/settings/probe-model', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ model: null, configured: false, verified: false }),
}));
await page.route('**/api/settings/probe-provider', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ configured: false, valid: false, verified: false }),
}));
await page.route('**/api/settings', async route => {
if (route.request().method() !== 'PUT') {
await route.continue();
return;
}
const payload = route.request().postDataJSON() as Record<string, unknown>;
settingsPayloads.push(payload);
const providerUpdate = payload.providers as Record<string, { apiKey?: string }> | undefined;
if (providerUpdate?.anthropic?.apiKey) keySaved = true;
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ defaultModel: 'claude-sonnet-4-6', providers: {} }) });
});
await page.route('**/api/settings/test-key', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ valid: true, verified: false }),
}));
await page.addInitScript(() => {
localStorage.clear();
sessionStorage.clear();
});
await page.goto('/?forceWizard=true', { waitUntil: 'domcontentloaded' });
const onboarding = page.getByRole('region', { name: /waggle onboarding/i });
await expect(onboarding).toBeVisible({ timeout: 20_000 });
await onboarding.getByRole('button', { name: /continue/i }).click();
await expect(onboarding.getByText(/tell us who you are/i)).toBeVisible({ timeout: 10_000 });
await onboarding.getByRole('button', { name: /continue/i }).click();
await expect(onboarding.getByText(/connect a model/i)).toBeVisible({ timeout: 10_000 });
await onboarding.getByRole('button', { name: /anthropic/i }).click();
const keyInput = onboarding.getByLabel(/api key for anthropic/i);
await expect(keyInput).toBeFocused();
await keyInput.fill('sk-ant-browser-contract');
const saveButton = onboarding.getByRole('button', { name: /validate & save/i });
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(onboarding.getByRole('status').filter({ hasText: /saved/i })).toBeVisible({ timeout: 10_000 });
await expect.poll(() => keySaved).toBe(true);
const keyWriteIndex = settingsPayloads.findIndex(payload => 'providers' in payload);
const modelWriteIndex = settingsPayloads.findIndex(payload => payload.defaultModel === 'claude-sonnet-4-6');
expect(settingsPayloads[keyWriteIndex]).toMatchObject({ providers: { anthropic: { apiKey: 'sk-ant-browser-contract' } } });
expect(modelWriteIndex).toBeGreaterThan(keyWriteIndex);
await expect(onboarding.getByRole('button', { name: /^continue/i })).toBeEnabled({ timeout: 10_000 });
});
test('J-model: Settings API-key setup preserves the same save contract', async ({ page }) => {
let keySaved = false;
await page.route('**/api/providers', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
providers: [{
id: 'anthropic', name: 'Anthropic', hasKey: false, badge: null,
keyUrl: 'https://console.anthropic.com/settings/keys', requiresKey: true,
models: [{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', cost: '$$', speed: 'medium' }],
}],
search: [{ id: 'duckduckgo', name: 'DuckDuckGo', hasKey: true, priority: 4 }],
activeSearch: 'duckduckgo',
}),
}));
await page.route('**/api/local-inference/status', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ servers: [], ollamaInstalled: false, totalLocalModels: 0 }),
}));
await page.route('**/api/settings/probe-model', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ model: null, configured: false, verified: false }),
}));
await page.route('**/api/settings/probe-provider', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ configured: false, valid: false, verified: false }),
}));
await page.route('**/api/settings/test-key', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ valid: true, verified: false }),
}));
await page.route('**/api/settings', async route => {
if (route.request().method() !== 'PUT') {
await route.continue();
return;
}
keySaved = true;
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ providers: {} }) });
});
await gotoApp(page, '/settings?tab=models');
const panel = page.getByRole('tabpanel').first();
await expect(panel.getByText(/bring your own key/i)).toBeVisible({ timeout: 10_000 });
await panel.getByRole('button', { name: /anthropic/i }).click();
const keyInput = panel.getByLabel(/api key for anthropic/i);
await keyInput.fill('sk-ant-settings-contract');
await panel.getByRole('button', { name: /validate & save/i }).click();
await expect(panel.getByRole('status').filter({ hasText: /saved/i })).toBeVisible({ timeout: 10_000 });
await expect.poll(() => keySaved).toBe(true);
});
test('J-route-coverage: thin utility routes render or redirect clearly', async ({ page }) => {
await gotoApp(page, '/benchmarks');
await expect(page.locator('body')).toContainText(/benchmark|capability|score|memory/i);
await gotoApp(page, '/platform');
await expect(page.locator('body')).toContainText(/platform|local|governance|memory|agent/i);
await page.goto(routeWithSkip('/payment-cancelled'), { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\?tab=billing/, { timeout: 10_000 });
await waitForShell(page);
await expect(page.locator('body')).toContainText(/billing|plan|team|solo|checkout/i);
});
test('J-route-coverage: priority thin routes render meaningful shells', async ({ page }) => {
const routeChecks: Array<[string, RegExp]> = [
['/launcher', /tool launcher|optional prompt|detecting installed tools|launch/i],
['/launcher?watch=1', /tool launcher|optional prompt|detecting installed tools|launch/i],
['/waggle-dance', /waggle dance|signals|discovery|handoff/i],
['/artifacts', /artifact|library|document|presentation/i],
['/settings/profile', /who are you|identity|writing style|save/i],
['/settings/timeline', /timeline|workspace|activity/i],
['/payment-success', /checkout|paid|plans|nothing to confirm/i],
['/automations', /automation|schedule|trigger|history|logs/i],
['/mcps', /mcp hub|installed|catalog|custom/i],
['/settings/usage', /usage|cost|tokens|budget|upgrade/i],
['/files', /storage|files|workspace|local/i],
];
for (const [route, bodyPattern] of routeChecks) {
await gotoApp(page, route);
await expect(page.locator('body')).toContainText(bodyPattern);
if (route === '/files') {
await expect(page.getByRole('region', { name: /workspace storage overview/i })).toHaveAttribute('tabindex', '0');
await page.getByRole('tab', { name: /^Files$/ }).click();
await expect(page.getByRole('region', { name: /files in/i })).toHaveAttribute('tabindex', '0');
}
}
});
test('J9: theme cards switch dark/light mode', async ({ page }) => {
await gotoApp(page, '/settings?tab=general');
const panel = page.getByRole('tabpanel');
await panel.getByRole('button', { name: /Light/i }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await panel.getByRole('button', { name: /Dark/i }).click();
await expect(page.locator('html')).not.toHaveAttribute('data-theme', 'light');
});
test('J10: home view shows dashboard workspace affordances', async ({ page }) => {
await gotoApp(page, '/home');
const home = page.locator('[data-testid="home-cockpit"], [data-testid="home-cockpit-empty"]').first();
await expect(home).toBeVisible({ timeout: 10_000 });
await expect(page.locator('body')).toContainText(/workspace|today|create|continue/i);
});
test('J11: keyboard shortcuts help overlay opens and closes', async ({ page }) => {
await gotoApp(page);
await page.keyboard.press('Control+/');
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText(/Keyboard Shortcuts/i);
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
});
test('J12: status bar displays product and active surface context', async ({ page }) => {
await gotoApp(page, '/memory');
await expect(page.getByText('Waggle AI')).toBeVisible();
await expect(page.getByTestId('statusbar-focused-window')).toContainText(/memory/i);
await expect(page.getByRole('button', { name: 'Search', exact: true })).toBeVisible();
});
});

File diff suppressed because it is too large Load Diff