/** * 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 = { 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 = { 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 { 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); }); });