mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-10 16:22:36 +02:00
236 lines
8.7 KiB
TypeScript
236 lines
8.7 KiB
TypeScript
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|||
|
|
// 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 <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);
|
|||
|
|
});
|
|||
|
|
});
|