/** * E2E contract for the compositor-only GlyphSpinner. * * The spinner's whole reason for existing in this shape is a CSS animation: * every frame is in the DOM from mount and a `transform` keyframes animation * scrolls between them, so there is no JS timer and no per-tick DOM mutation * scheduling document-scale style recalculation. * * None of that is observable in jsdom — it has no animation engine, no * cascade resolution for `steps()`, and no `Element.getAnimations()`. The * jsdom suite (src/components/ui/glyph-spinner.test.tsx) therefore pins the * DATA and WIRING, and this spec pins the RENDERED BEHAVIOUR in a real * browser, which is the only place the stylesheet actually runs. * * This replaces three tests that asserted on the TEXT of the stylesheet. * Reading source in a test is banned outright (AGENTS.md) and those tests * proved the point: a var()-fallback edit that changed no rendered pixel * broke one of them, while none of them had ever executed the CSS. * * Prerequisite: `npm run build` must have been run so dist/ exists. */ import { expect, type Page, test } from '@playwright/test' import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' /* Scope to a spinner that is actually RUNNING. Turns from earlier tests in * this file leave parked spinners mounted (kept-alive panes, swap overlays * hold them with data-paused='true'), and document.querySelector returns the * FIRST strip in the DOM — a stale parked one once two turns have run. */ const STRIP = '.glyph-spinner:not([data-paused="true"]) .glyph-spinner__strip' /** Prompt the mock server holds open so the spinner runs for the whole file. */ const SPINNER_PROMPT = 'E2E_GLYPH_SPINNER_HOLD' /** * Get a RUNNING frame strip into the DOM deterministically. * * A turn is sent so the app is genuinely busy (the mock server holds the * stream open), but which surface mounts a spinner mid-turn is app policy * that has changed before and will again — the transcript, status stack and * swap overlay all park/unmount theirs at different moments, which made this * spec racy. The contract under test is the STYLESHEET (steps() animation, * layer promotion, the data-paused and global pause gates), and that CSS is * driven entirely by the `data-paused` attribute — the same attribute the * parked assertions below already toggle. So: wait for any mounted spinner * (the ChatSwapOverlay keeps one mounted, parked, after boot), then unpark it * and assert against the running animation. */ async function mountSpinner(page: Page): Promise { if (await page.locator(STRIP).count()) { return } const composer = page.locator('[contenteditable="true"]').first() await composer.waitFor({ state: 'visible', timeout: 10_000 }) await composer.click() await composer.type(SPINNER_PROMPT, { delay: 10 }) await page.keyboard.press('Enter') await page.waitForSelector('.glyph-spinner__strip', { state: 'attached', timeout: 20_000 }) await page.evaluate(() => { for (const el of document.querySelectorAll('.glyph-spinner[data-paused]')) { el.removeAttribute('data-paused') } }) await page.waitForSelector(STRIP, { state: 'attached', timeout: 20_000 }) } test.describe('GlyphSpinner (compositor animation)', () => { let fixture: MockBackendFixture test.beforeAll(async () => { fixture = await setupMockBackend({ mockServer: { holdFirstStreamForPrompt: SPINNER_PROMPT }, }) await waitForAppReady(fixture) }) test.afterAll(async () => { fixture?.mock.releaseHeldStream() await fixture?.cleanup() }) test('animates with a steps() transform keyframes animation, one step per frame', async () => { const { page } = fixture await mountSpinner(page) const observed = await page.evaluate(strip => { const el = document.querySelector(strip) if (!el) { throw new Error('no frame strip in the DOM') } const style = getComputedStyle(el) const animations = el.getAnimations() return { frameCount: el.querySelectorAll('.glyph-spinner__frame').length, timingFunction: style.animationTimingFunction, iterationCount: style.animationIterationCount, durationMs: animations[0]?.effect?.getTiming().duration ?? null, names: animations.map(a => (a as CSSAnimation).animationName), // A percentage translate makes the animation layout-dependent, which // Chromium refuses to composite. Read the engine's own keyframes: a // revert to translateY(-100%) shows up here, while the computed // `style.transform` always serializes to a matrix and can't tell. travel: ((animations[0]?.effect as KeyframeEffect | undefined)?.getKeyframes() ?? []) .map(k => String((k as Keyframe & { transform?: string }).transform ?? '')) .join(' | ') } }, STRIP) // The strip carries every frame; `steps(N)` parks on each one in turn. expect(observed.frameCount).toBeGreaterThan(1) // Chromium has serialized jump-end as both `steps(N)` and `steps(N, end)`. expect(observed.timingFunction).toMatch(new RegExp(`^steps\\(${observed.frameCount}\\b`)) expect(observed.iterationCount).toBe('infinite') expect(observed.names).toContain('glyph-spinner-advance') // One full cycle is frames x interval, so the duration must be a positive // multiple of the frame count — not the single-frame interval. expect(observed.durationMs).toBeGreaterThan(0) // Length-typed travel, never a percentage: `translateY(-100%)` would keep // the animation off the compositor. Chromium has serialized the resolved // keyframe both as the authored `calc(...)` and as an absolute `...px` // length depending on version — accept any length, reject percentages. expect(observed.travel).toMatch(/calc\(|px\)/) expect(observed.travel).not.toContain('%') }) test('is promoted to a layer while running, and neither animates nor holds a layer when parked', async () => { const { page } = fixture await mountSpinner(page) const running = await page.evaluate(strip => { const el = document.querySelector(strip)! return { playState: getComputedStyle(el).animationPlayState, willChange: getComputedStyle(el).willChange } }, STRIP) expect(running.playState).toBe('running') // Scoped to active spinners — a permanently promoted layer per parked // spinner is pure memory at fan-out breadth. expect(running.willChange).toBe('transform') // 1. The per-spinner gate: a kept-alive but inactive pane, or an explicit // `paused` prop (ChatSwapOverlay's fade-out). const parked = await page.evaluate(strip => { const el = document.querySelector(strip)! const viewport = el.closest('.glyph-spinner')! const previous = viewport.getAttribute('data-paused') viewport.setAttribute('data-paused', 'true') const state = { playState: getComputedStyle(el).animationPlayState, willChange: getComputedStyle(el).willChange } if (previous === null) { viewport.removeAttribute('data-paused') } else { viewport.setAttribute('data-paused', previous) } return state }, STRIP) expect(parked.playState).toBe('paused') expect(parked.willChange).toBe('auto') // 2. The global gate: window blur / minimize / document-hidden, which // main.tsx drives by arming this attribute on the root. The strip must // be named in that rule, or every spinner keeps animating behind an // inactive window — the CPU burn the original ticker's pause // controller existed to avoid. const globallyPaused = await page.evaluate(strip => { const root = document.documentElement const had = root.hasAttribute('data-renderer-animations-paused') root.setAttribute('data-renderer-animations-paused', '') const playState = getComputedStyle(document.querySelector(strip)!).animationPlayState if (!had) { root.removeAttribute('data-renderer-animations-paused') } return playState }, STRIP) expect(globallyPaused).toBe('paused') }) test('advances in discrete frames and creates no timer-driven DOM churn', async () => { const { page } = fixture await mountSpinner(page) // Sample the resolved transform across one full cycle. A steps() animation // holds each value for a whole interval and jumps between them, so the // distinct values it visits must be bounded by the frame count — a linear // animation would produce a new value on every sample. const sampled = await page.evaluate(async strip => { const el = document.querySelector(strip)! const frames = el.querySelectorAll('.glyph-spinner__frame').length const duration = Number(el.getAnimations()[0]?.effect?.getTiming().duration ?? 0) const seen = new Set() const textAtStart = el.textContent const deadline = performance.now() + duration while (performance.now() < deadline) { seen.add(getComputedStyle(el).transform) await new Promise(resolve => requestAnimationFrame(() => resolve(null))) } return { distinct: seen.size, frames, textUnchanged: el.textContent === textAtStart } }, STRIP) expect(sampled.distinct).toBeGreaterThan(1) expect(sampled.distinct).toBeLessThanOrEqual(sampled.frames + 1) // The old implementation rewrote textContent ~12x/second. Nothing may // mutate the DOM as this animates — that mutation is the whole incident. expect(sampled.textUnchanged).toBe(true) }) })