Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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<HTMLElement>(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<HTMLElement>(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<HTMLElement>(strip)!
|
||||
const viewport = el.closest<HTMLElement>('.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<HTMLElement>(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<HTMLElement>(strip)!
|
||||
const frames = el.querySelectorAll('.glyph-spinner__frame').length
|
||||
const duration = Number(el.getAnimations()[0]?.effect?.getTiming().duration ?? 0)
|
||||
const seen = new Set<string>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user