vestige/apps/dashboard/e2e/pulse-toast.spec.ts
Sam Valladares 844b91a804 feat(dashboard): every organ is a living WebGPU field (LivingFieldPass)
Turn all 17 sparse organs from text-on-black voids into full-bleed, moving,
bioluminescent WebGPU fields. Each cell maps to a real backend fact; verified
live on the real brain with a decoded fill% + motion + zero-error probe.

Foundation: one reusable LivingFieldPass (src/lib/observatory/field/) does
splat -> separable-blur (render passes) -> membrane base coat -> HDR cells with
orbital drift and a CPU pick-mirror. Each organ writes a ~20-line data->cells
mapper (cell-layout.ts: layoutGalaxy / layoutRings / FIELD_HUE).

Organs brought alive (fill% before -> after, all zero WebGPU errors):
stats 0.8->80.9, observatory 10.8->81.4, graph 12.3->82.4, memories ->78.4,
blackbox 18.2->83.3, contradictions 1.6->69.6, dreams 0.3->64.5,
settings 2.7->76.4, reasoning 2.7->39.2, patterns 7.1->45.6,
activation 10->64.9, feed ->89, explore ->83.3, intentions ->66.6,
importance ->73.7, memory-prs ->74.4, schedule ->37.1.

Backend surfaces: add api.memoryChangelog() + ChangelogResponse; surface
suppress/unsuppress on the memories field (shift/alt-click -> scar).

Tests: palace-launch-gate now asserts fillPct>30 + isAnimating (not just
non-black), with a longer settle for reasoning's deep_reference and a raised
timeout for the curated tour. all-routes-smoke treats settings as the WebGPU
field it now is. Gates green: check 0/0, build ok, palace-launch-gate 22/22 +
tour, all-routes-smoke 20/20.

Protected work untouched: MemoryCinema, graph/cinema, observatory scene
shaders, node-renderer, palace-map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 06:20:35 +07:00

241 lines
9.2 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();
}
// QUARANTINED (Jul 2026): the Pulse Toast is a pre-WebGPU-rework DOM
// notification component that no longer exists in the zero-DOM Cognitive OS
// dashboard (only a "Preview Pulse" trigger button remains in settings; the
// toast UI it popped was removed). These assertions look for DOM toast elements
// that are gone → "element(s) not found". Kept for history; unquarantine only
// if a DOM toast surface is reintroduced.
test.describe.fixme('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);
});
});