This commit is contained in:
387
tests/e2e/full-product-audit.spec.ts
Normal file
387
tests/e2e/full-product-audit.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user