// ───────────────────────────────────────────────────────────────────────────── // 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