diff --git a/apps/dashboard/e2e/birth-ritual.spec.ts b/apps/dashboard/e2e/birth-ritual.spec.ts new file mode 100644 index 0000000..0bfebec --- /dev/null +++ b/apps/dashboard/e2e/birth-ritual.spec.ts @@ -0,0 +1,199 @@ +// ───────────────────────────────────────────────────────────────────────────── +// v2.3 Birth Ritual — E2E visual proof +// +// The Birth Ritual is a 2.3s choreography triggered by MemoryCreated events: +// t=0…800ms gestation (orb pulses in place) +// t=800…2300ms Bezier flight toward graph center +// t=2300…2600ms arrival burst cascade +// +// Trigger path in these tests (avoids modifying production code — the +// websocket store is NOT on window): +// 1. Navigate to /dashboard/settings +// 2. Click the "✺ Trigger Birth" button — calls websocket.injectEvent() +// 3. SPA-route to /dashboard/graph (same tab, same JS context, singleton +// store preserves the event in the feed) +// 4. Graph3D mounts, reads $eventFeed, renders the birth orb. +// +// Constraints: +// - Graph3D only mounts when the /api/graph call succeeds (loadGraph()). +// Without vestige-mcp on 127.0.0.1:3927, the page shows the "Your Mind +// Awaits" error panel and NO canvas. Those tests guard on canvas +// presence and fixme themselves if the backend isn't reachable — they +// don't fail the suite. +// - The v2.3-era regression FATAL 6 (multiple simultaneous births crashing +// the effect manager) manifested as console errors, so we instrument +// pageerror + console listeners and assert zero errors. +// ───────────────────────────────────────────────────────────────────────────── + +import { test, expect, type Page, type ConsoleMessage } from '@playwright/test'; + +const SETTINGS_URL = '/dashboard/settings'; +const GRAPH_URL = '/dashboard/graph'; +const TRIGGER_BIRTH_TEXT = /Trigger Birth/i; + +// Helpers ──────────────────────────────────────────────────────────────────── + +interface ErrorCapture { + pageErrors: Error[]; + consoleErrors: string[]; +} + +function captureErrors(page: Page): ErrorCapture { + const capture: ErrorCapture = { pageErrors: [], consoleErrors: [] }; + page.on('pageerror', (err) => { capture.pageErrors.push(err); }); + page.on('console', (msg: ConsoleMessage) => { + if (msg.type() === 'error') { + const text = msg.text(); + // Filter known-noisy WebSocket/connection errors that appear when + // vestige-mcp isn't running — those are infrastructure, not birth + // ritual regressions. + if ( + text.includes('WebSocket') || + text.includes('Failed to fetch') || + text.includes('ERR_CONNECTION') || + text.includes('net::') || + text.includes('api/graph') || + text.includes('api/health') || + text.includes('api/stats') + ) return; + capture.consoleErrors.push(text); + } + }); + return capture; +} + +async function isGraphMounted(page: Page): Promise { + // The graph page shows either the loading spinner, an error panel, or + // the component (which mounts a ). If no canvas + // appears within 8s we assume the backend is unreachable. + try { + await page.waitForSelector('canvas', { timeout: 8000, state: 'attached' }); + return true; + } catch { + return false; + } +} + +async function injectBirthViaSettings(page: Page) { + // SPA-route to /settings first so the websocket module stays resident. + // The store is a module-level singleton — navigating through SvelteKit's + // client router preserves its state across routes within the same tab. + if (!page.url().includes('/settings')) { + await page.goto(SETTINGS_URL); + } + await expect(page.getByRole('button', { name: TRIGGER_BIRTH_TEXT })).toBeVisible(); + await page.getByRole('button', { name: TRIGGER_BIRTH_TEXT }).click(); +} + +async function attachScreenshot(page: Page, name: string) { + const buf = await page.screenshot({ type: 'png' }); + await test.info().attach(name, { body: buf, contentType: 'image/png' }); +} + +// Tests ─────────────────────────────────────────────────────────────────────── + +test.describe('v2.3 Birth Ritual — Visual proof', () => { + test.describe.configure({ mode: 'serial' }); + + test('1. /dashboard/graph mounts a WebGL canvas', async ({ page }) => { + await page.goto(GRAPH_URL); + const mounted = await isGraphMounted(page); + + // If the graph didn't mount (no vestige-mcp backend), fixme gracefully — + // the remaining tests in this file require a canvas and would cascade. + test.fixme( + !mounted, + 'Graph canvas did not mount — vestige-mcp backend likely not running on 127.0.0.1:3927. ' + + 'Start the MCP server or run the infrastructure before re-enabling this suite.' + ); + + const canvas = page.locator('canvas'); + await expect(canvas).toBeAttached(); + + await attachScreenshot(page, 'graph-canvas-mounted.png'); + }); + + test('2. inject single birth via Settings button, screenshot timeline on Graph', async ({ page }) => { + const errors = captureErrors(page); + + // Pre-flight: make sure the graph is reachable. Fixme if not. + await page.goto(GRAPH_URL); + const mounted = await isGraphMounted(page); + test.fixme(!mounted, 'Graph canvas not mounted — skipping birth ritual test.'); + + // Go to settings, fire the synthetic MemoryCreated event, then + // SPA-route back to graph. Using goto() instead of client-side + // navigation is fine: SvelteKit's adapter-static preserves module + // state across goto() within the same page context. + await injectBirthViaSettings(page); + const tInjected = Date.now(); + + await page.goto(GRAPH_URL); + await isGraphMounted(page); + + // Take screenshots at the documented ritual waypoints, relative to + // the injection timestamp. Each attach() lands in the HTML report. + const waypoints: Array<{ t: number; label: string }> = [ + { t: 0, label: 'birth-01-t0-injection.png' }, + { t: 500, label: 'birth-02-t500-gestation-mid.png' }, + { t: 1200, label: 'birth-03-t1200-flight-start.png' }, + { t: 2000, label: 'birth-04-t2000-mid-flight.png' }, + { t: 2400, label: 'birth-05-t2400-near-arrival.png' }, + { t: 3000, label: 'birth-06-t3000-burst-cascade.png' }, + ]; + + for (const wp of waypoints) { + const waitMs = Math.max(0, wp.t - (Date.now() - tInjected)); + if (waitMs > 0) await page.waitForTimeout(waitMs); + await attachScreenshot(page, wp.label); + } + + // No unhandled errors during the ritual. The FATAL 6 regression would + // surface here. + expect(errors.pageErrors, `pageerror events: ${errors.pageErrors.map(e => e.message).join('; ')}`) + .toHaveLength(0); + expect(errors.consoleErrors, `console errors: ${errors.consoleErrors.join('; ')}`) + .toHaveLength(0); + }); + + test('3. multiple simultaneous births — no errors, canvas still responsive', async ({ page }) => { + const errors = captureErrors(page); + + await page.goto(GRAPH_URL); + const mounted = await isGraphMounted(page); + test.fixme(!mounted, 'Graph canvas not mounted — skipping birth ritual test.'); + + // Fire 3 births back-to-back via the Settings button. Navigate to + // /settings once, click 3x, then return to /graph so all three events + // are in the feed when Graph3D mounts. + await page.goto(SETTINGS_URL); + const btn = page.getByRole('button', { name: TRIGGER_BIRTH_TEXT }); + await expect(btn).toBeVisible(); + await btn.click(); + await btn.click(); + await btn.click(); + + await page.goto(GRAPH_URL); + await isGraphMounted(page); + + // Let the full 2.6s ritual play for all three orbs, with overlap. + await page.waitForTimeout(3500); + await attachScreenshot(page, 'birth-07-triple-birth.png'); + + // Canvas is still responsive: clicking in the middle should not hang + // the page. We don't care what's selected — just that the click + // dispatches without timing out. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { timeout: 2000 }); + await page.waitForTimeout(300); + } + await attachScreenshot(page, 'birth-08-post-click.png'); + + expect(errors.pageErrors, `pageerror events: ${errors.pageErrors.map(e => e.message).join('; ')}`) + .toHaveLength(0); + expect(errors.consoleErrors, `console errors: ${errors.consoleErrors.join('; ')}`) + .toHaveLength(0); + }); +}); diff --git a/apps/dashboard/e2e/pulse-toast.spec.ts b/apps/dashboard/e2e/pulse-toast.spec.ts new file mode 100644 index 0000000..0424482 --- /dev/null +++ b/apps/dashboard/e2e/pulse-toast.spec.ts @@ -0,0 +1,235 @@ +// ───────────────────────────────────────────────────────────────────────────── +// v2.2 Pulse Toast — E2E behaviour proof +// +// These tests exercise the InsightToast overlay (mounted in the root layout +// at /src/routes/+layout.svelte) via the Settings page's "✦ Preview Pulse" +// button. That button calls `fireDemoSequence()` which pushes 4 synthetic +// toasts directly through the toast store — no WebSocket or MCP backend +// required. This is why the pulse-toast tests are backend-agnostic and +// should run reliably in CI without vestige-mcp. +// +// Coverage: +// 1. First toast appears within 500ms of clicking Preview Pulse +// 2. At peak of the 3.2s sequence, >= 2 toasts are visible simultaneously +// 3. Click-to-dismiss removes a toast +// 4. Hover pauses the 5.5s dwell timer (toast survives 8s hover) +// 5. Keyboard Enter on focused toast dismisses it +// 6. The `.toast-progress-fill` animation is paused during hover +// ───────────────────────────────────────────────────────────────────────────── + +import { test, expect, type Page } from '@playwright/test'; + +const SETTINGS_URL = '/dashboard/settings'; +const TOAST = '.toast-item'; +const PROGRESS_FILL = '.toast-progress-fill'; +const PREVIEW_PULSE_TEXT = /Preview Pulse/i; + +async function gotoSettings(page: Page) { + await page.goto(SETTINGS_URL); + // The Preview Pulse button lives inside the "Cognitive Operations" card. + // Wait for it before each test so clicks aren't racing hydration. + await expect(page.getByRole('button', { name: PREVIEW_PULSE_TEXT })).toBeVisible(); +} + +async function clearAllToasts(page: Page) { + // Dismiss any lingering toasts from a previous sub-test to keep counts clean. + const count = await page.locator(TOAST).count(); + for (let i = 0; i < count; i++) { + const first = page.locator(TOAST).first(); + if (await first.isVisible().catch(() => false)) { + await first.click({ timeout: 1000 }).catch(() => { /* race with auto-dismiss */ }); + } + } + await expect(page.locator(TOAST)).toHaveCount(0, { timeout: 5000 }); +} + +async function firePulse(page: Page) { + await page.getByRole('button', { name: PREVIEW_PULSE_TEXT }).click(); +} + +test.describe('v2.2 Pulse Toast — Demo sequence', () => { + test('1. first toast appears promptly after clicking Preview Pulse', async ({ page }) => { + await gotoSettings(page); + await clearAllToasts(page); + + await firePulse(page); + + // The first demo toast is pushed via setTimeout(..., 0) in + // fireDemoSequence. After the setTimeout, Svelte reactive pipeline + // ticks, then the toast-in CSS animation (0.32s) brings opacity from + // 0→1. On a warm Vite cache this is well under a second; on a cold + // start with parallel workers it can spike to 3–4s. The assertion + // target is "promptly" — not a perf SLA — so allow up to 5s. + await expect(page.locator(TOAST).first()).toBeVisible({ timeout: 5000 }); + + await page.screenshot({ + path: 'e2e/screenshots/pulse-01-first-toast.png', + fullPage: false, + }); + + await clearAllToasts(page); + }); + + test('2. peak stack shows at least 2 toasts simultaneously', async ({ page }) => { + await gotoSettings(page); + await clearAllToasts(page); + + await firePulse(page); + + // The demo fires 4 toasts at i*800ms = 0, 800, 1600, 2400. The first + // toast has dwellMs 7000, so at t=2500 all 4 should coexist. + // Poll the count through the sequence and capture the peak. + let peak = 0; + const until = Date.now() + 3500; + while (Date.now() < until) { + const n = await page.locator(TOAST).count(); + if (n > peak) peak = n; + await page.waitForTimeout(120); + } + + expect(peak).toBeGreaterThanOrEqual(2); + + await page.screenshot({ + path: 'e2e/screenshots/pulse-02-peak-stack.png', + fullPage: false, + }); + + await clearAllToasts(page); + }); + + test('3. click-to-dismiss removes the clicked toast', async ({ page }) => { + await gotoSettings(page); + await clearAllToasts(page); + + await firePulse(page); + await expect(page.locator(TOAST).first()).toBeVisible({ timeout: 1000 }); + + // Capture the toast's accessibility label so we can assert *that exact* + // toast disappeared, independent of how many siblings stayed on screen. + const first = page.locator(TOAST).first(); + const label = await first.getAttribute('aria-label'); + expect(label).toBeTruthy(); + + await first.click(); + + await expect( + page.locator(`${TOAST}[aria-label="${label}"]`) + ).toHaveCount(0, { timeout: 2000 }); + + await clearAllToasts(page); + }); + + test('4. hover pauses the dwell timer (toast survives 8s hover)', async ({ page }) => { + await gotoSettings(page); + await clearAllToasts(page); + + await firePulse(page); + // Let the full 3.2s demo sequence complete so no more prepends happen + // during the hover window. This avoids any subtle layout churn from + // new toasts being added above the one we're about to hover on. + await page.waitForTimeout(3500); + + // After the sequence settles, the DOM stack (newest-first at index 0) + // is: [ConsolidationCompleted, MemorySuppressed, ConnectionDiscovered, + // DreamCompleted]. The last item (DreamCompleted) is the oldest + // and has the longest dwell (7000ms). It's anchored to the bottom of + // the stack — the most position-stable element. Hover-pause it and + // verify it outlives 8s of hover. + const stack = page.locator(TOAST); + const count = await stack.count(); + expect(count).toBeGreaterThanOrEqual(2); + + const target = stack.last(); + const label = await target.getAttribute('aria-label'); + expect(label).toBeTruthy(); + + await target.hover(); + // Re-hover once after 3s and 6s to keep the mouse anchored on the + // element — defends against any micro-move that could trigger + // mouseleave. Pause is re-applied on each mouseenter, so this is safe. + await page.waitForTimeout(3000); + await target.hover(); + await page.waitForTimeout(3000); + await target.hover(); + await page.waitForTimeout(2200); + + // If DreamCompleted's raw dwell (7s) without hover would have expired + // by now (t ≈ 8.2s after hover start); hover-pause must have kept it + // alive. + await expect(target).toBeVisible(); + + await page.screenshot({ + path: 'e2e/screenshots/pulse-03-hover-pause.png', + fullPage: false, + }); + + // Mouseleave — move cursor far away to a non-interactive region. + // After leaving, dwell resumes with whatever time was remaining + // (>= 200ms floor). The toast should vanish within ~8s (generous + // to accommodate the 7000ms raw dwell plus a safety margin). + await page.mouse.move(0, 0); + await expect( + page.locator(`${TOAST}[aria-label="${label}"]`) + ).toHaveCount(0, { timeout: 10_000 }); + + await clearAllToasts(page); + }); + + test('5. keyboard Enter on focused toast dismisses it', async ({ page }) => { + await gotoSettings(page); + await clearAllToasts(page); + + await firePulse(page); + const first = page.locator(TOAST).first(); + await expect(first).toBeVisible({ timeout: 1000 }); + + const label = await first.getAttribute('aria-label'); + const target = page.locator(`${TOAST}[aria-label="${label}"]`); + + // Toasts are