Files
waggle-os/tests/e2e/phase-ab-verification.spec.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

325 lines
14 KiB
TypeScript

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