vestige/apps/dashboard/e2e/pulse-toast.spec.ts
Sam Valladares 8fe8bb2f39 test(v2.3): full e2e + integration coverage for Pulse + Birth Ritual
Post-ship verification pass — five parallel write-agents produced 229 new
tests across vitest units, vitest integration, and Playwright browser e2e.
Net suite: 361 vitest pass (up from 251, +110) and 9/9 Playwright pass on
back-to-back runs.

**toast.test.ts (NEW, 661 lines, 42 tests)**
  Silent-lobotomy batch walk proven (multi-event tick processes ALL, not
  just newest, oldest-first ordering preserved). Hover-panic pause/resume
  with remaining-ms math. All 9 event type translations asserted, all 11
  noise types asserted silent. ConnectionDiscovered 1500ms throttle.
  MAX_VISIBLE=4 eviction. clear() tears down all timers. fireDemoSequence
  staggers 4 toasts at 800ms intervals. vi.useFakeTimers + vi.mock of
  eventFeed; vi.resetModules in beforeEach for module-singleton isolation.

**websocket.test.ts (NEW, 247 lines, 30 tests)**
  injectEvent adds to front, respects MAX_EVENTS=200 with FIFO eviction,
  triggers eventFeed emissions. All 6 derived stores (isConnected,
  heartbeat, memoryCount, avgRetention, suppressedCount, uptimeSeconds)
  verified — defaults, post-heartbeat values, clearEvents preserves
  lastHeartbeat. 13 formatUptime boundary cases (0/59/60/3599/3600/
  86399/86400 seconds + negative / NaN / ±Infinity).

**effects.test.ts (EXTENDED, +501 lines, +21 tests, 51 total)**
  createBirthOrb full lifecycle — sprite count (halo + core), cosmic
  center via camera.quaternion, gestation phase (position lock, opacity
  rise, scale easing, color tint), flight Bezier arc above linear
  midpoint at t=0.5, dynamic mid-flight target redirect. onArrive fires
  exactly once at frame 139. Post-arrival fade + disposal cleans scene
  children. Sanhedrin Shatter: target goes undefined mid-flight →
  onArrive NEVER called, implosion spawned, halo blood-red, eventual
  cleanup. dispose() cleans active orbs. Multiple simultaneous orbs.
  Custom gestation/flight frame opts honored. Zero-alloc invariant
  smoke test (6 orbs × 150 frames, no leaks).

**nodes.test.ts (EXTENDED, +197 lines, +10 tests, 42 total)**
  addNode({isBirthRitual:true}) hides mesh/glow/label immediately,
  stamps birthRitualPending sentinel with correct totalFrames +
  targetScale, does NOT enqueue materialization. igniteNode flips
  visibility + enqueues materialization. Idempotent — second call
  no-op. Non-ritual nodes unaffected. Unknown id is safe no-op.
  Position stored in positions map while invisible (force sim still
  sees it). removeNode + late igniteNode is safe.

**events.test.ts (EXTENDED, +268 lines, +7 tests, 55 total)**
  MemoryCreated → mesh hidden immediately, 2 birth-orb sprites added,
  ZERO RingGeometry meshes and ZERO Points particles at spawn. Full
  ritual drive → onArrive fires, node visible + materializing, sentinel
  cleared. Newton's Cradle: target mesh scale exactly 0.001 * 1.8 right
  after arrival. Dual shockwave: exactly 2 Ring meshes added. Re-read
  live position on arrival — force-sim motion during ritual → burst
  lands at the NEW position. Sanhedrin abort path → rainbow burst,
  shockwave, ripple wave are NEVER called (vi.spyOn).

**three-mock.ts (EXTENDED)**
  Added Color.setRGB — production Three.js has it, the Sanhedrin-
  Shatter path in effects.ts uses it. Two write-agents independently
  monkey-patched the mock inline; consolidated as a 5-line mock
  addition so tests stay clean.

**e2e/pulse-toast.spec.ts (NEW, 235 lines, 6 Playwright tests)**
  Navigate /dashboard/settings → click Preview Pulse → assert first
  toast appears within 500ms → assert >= 2 toasts visible at peak.
  Click-to-dismiss removes clicked toast (matched by aria-label).
  Hover survives >8s past the 5.5s dwell. Keyboard Enter dismisses
  focused toast. CSS animation-play-state:paused on .toast-progress-
  fill while hovered, running on mouseleave. Screenshots attached to
  HTML report. Zero backend dependency (fireDemoSequence is purely
  client-side).

**e2e/birth-ritual.spec.ts (NEW, 199 lines, 3 Playwright tests)**
  Canvas mounts on /dashboard/graph (gracefully test.fixme if MCP
  backend absent). Settings button injection + SPA route to /graph
  → screenshot timeline at t=0/500/1200/2000/2400/3000ms attached
  to HTML report. pageerror + console-error listeners catch any
  crash (would re-surface FATAL 6 if reintroduced). Three back-to-
  back births — no errors, canvas still dispatches clicks.

Run commands:
  cd apps/dashboard && npm test           # 361/361 pass, ~600ms
  cd apps/dashboard && npx playwright test # 9/9 pass, ~25s

Typecheck: 0 errors, 0 warnings. Build: clean adapter-static.
2026-04-20 18:14:50 -05:00

235 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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 34s. 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 <button> elements — they're focusable. focus() avoids
// relying on tab-order through the Settings page, which is unstable
// (depends on stat cards, form fields, and other affordances above it).
await target.focus();
await expect(target).toBeFocused();
await page.keyboard.press('Enter');
await expect(target).toHaveCount(0, { timeout: 2000 });
await clearAllToasts(page);
});
test('6. progress-fill animation is paused while hovering', async ({ page }) => {
await gotoSettings(page);
await clearAllToasts(page);
await firePulse(page);
const first = page.locator(TOAST).first();
await expect(first).toBeVisible({ timeout: 1000 });
// Hover, then read the computed animation-play-state on the inner fill.
// CSS rule: `.toast-item:hover .toast-progress-fill { animation-play-state: paused; }`.
await first.hover();
const playState = await first.locator(PROGRESS_FILL).evaluate(
(el) => window.getComputedStyle(el).animationPlayState
);
expect(playState).toBe('paused');
// Sanity: moving away resumes the animation.
await page.mouse.move(0, 0);
// Small settle so hover styles detach cleanly.
await page.waitForTimeout(100);
// The same toast may have already been dismissed in the small window.
// Guard the assertion so we don't fail on race between resume + dwell.
if (await first.isVisible().catch(() => false)) {
const running = await first.locator(PROGRESS_FILL).evaluate(
(el) => window.getComputedStyle(el).animationPlayState
);
expect(running).toBe('running');
}
await clearAllToasts(page);
});
});