This commit is contained in:
1610
tests/e2e/ai-os-positioning-audit.spec.ts
Normal file
1610
tests/e2e/ai-os-positioning-audit.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
96
tests/e2e/boot-screen-skip.spec.ts
Normal file
96
tests/e2e/boot-screen-skip.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1132
tests/e2e/competitive-benchmarks.spec.ts
Normal file
1132
tests/e2e/competitive-benchmarks.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
204
tests/e2e/failure-injection/network-drop.spec.ts
Normal file
204
tests/e2e/failure-injection/network-drop.spec.ts
Normal 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);
|
||||
});
|
||||
1216
tests/e2e/five-persona-state-bundles.spec.ts
Normal file
1216
tests/e2e/five-persona-state-bundles.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
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);
|
||||
});
|
||||
});
|
||||
632
tests/e2e/full-wiring-audit.spec.ts
Normal file
632
tests/e2e/full-wiring-audit.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
162
tests/e2e/launcher-real-hook-lifecycle.spec.ts
Normal file
162
tests/e2e/launcher-real-hook-lifecycle.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
140
tests/e2e/launcher-real-tool-lifecycle.spec.ts
Normal file
140
tests/e2e/launcher-real-tool-lifecycle.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
334
tests/e2e/launcher-rendered-states.spec.ts
Normal file
334
tests/e2e/launcher-rendered-states.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
140
tests/e2e/light-mode-polish.spec.ts
Normal file
140
tests/e2e/light-mode-polish.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
109
tests/e2e/live-chat-flow.spec.ts
Normal file
109
tests/e2e/live-chat-flow.spec.ts
Normal 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.');
|
||||
});
|
||||
324
tests/e2e/phase-ab-verification.spec.ts
Normal file
324
tests/e2e/phase-ab-verification.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
423
tests/e2e/phase8-visual.spec.ts
Normal file
423
tests/e2e/phase8-visual.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
256
tests/e2e/polish-verification.spec.ts
Normal file
256
tests/e2e/polish-verification.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
522
tests/e2e/power-user-stress.spec.ts
Normal file
522
tests/e2e/power-user-stress.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
190
tests/e2e/room-parallel-agents.spec.ts
Normal file
190
tests/e2e/room-parallel-agents.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
160
tests/e2e/runtime-a11y.spec.ts
Normal file
160
tests/e2e/runtime-a11y.spec.ts
Normal 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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
146
tests/e2e/spawn-agent-flow.spec.ts
Normal file
146
tests/e2e/spawn-agent-flow.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
139
tests/e2e/team-server.spec.ts
Normal file
139
tests/e2e/team-server.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
1091
tests/e2e/user-behavior.spec.ts
Normal file
1091
tests/e2e/user-behavior.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
963
tests/e2e/user-journeys.spec.ts
Normal file
963
tests/e2e/user-journeys.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
1111
tests/e2e/waggle-complete.spec.ts
Normal file
1111
tests/e2e/waggle-complete.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user