diff --git a/apps/desktop/e2e/native-transcript-perf.spec.ts b/apps/desktop/e2e/native-transcript-perf.spec.ts deleted file mode 100644 index 8631bd63fb..0000000000 --- a/apps/desktop/e2e/native-transcript-perf.spec.ts +++ /dev/null @@ -1,440 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { CDPSession, Page } from '@playwright/test'; -import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; -import * as transcriptContract from '../src/preload/transcript-contract'; -import { ensureSidebarExpanded, expect, test } from './fixtures'; - -const PERF_ENABLED = process.env.MAKA_TRANSCRIPT_PERF === '1'; -const STRESS_ENABLED = process.env.MAKA_TRANSCRIPT_STRESS === '1'; -const performanceTest = PERF_ENABLED ? test : test.skip; -const stressTest = STRESS_ENABLED ? test : test.skip; -const SCROLLER = '[data-chat-scroll-container="true"]'; - -interface BrowserCounters { - heapBytes: number; - nodes: number; - documents: number; - jsEventListeners: number; -} - -interface FrameSample { - intervals: number[]; - loafDurations: number[]; - loafSupported: boolean; -} - -interface StressSample extends BrowserCounters { - sweep: number; - iteration: number; - firstTurnId: string | null; - lastTurnId: string | null; - mountedTurns: number; -} - -interface StressSweep { - sweep: number; - successfulPages: number; - samples: StressSample[]; -} - -interface HeapGrowth { - endpointRatio: number; - slopeBytesPerPage: number; - projectedRatio: number; -} - -/** The predeclared secondary heap/DOM release gate permits at most 10% growth. */ -const SECONDARY_RESOURCE_GROWTH_RATIO = 0.1; - -function positiveHeapGrowth(samples: readonly StressSample[]): HeapGrowth { - const first = samples[0]!; - const last = samples.at(-1)!; - const meanIteration = samples.reduce((sum, sample) => sum + sample.iteration, 0) - / samples.length; - const meanHeap = samples.reduce((sum, sample) => sum + sample.heapBytes, 0) - / samples.length; - const slopeNumerator = samples.reduce( - (sum, sample) => sum + (sample.iteration - meanIteration) * (sample.heapBytes - meanHeap), - 0, - ); - const slopeDenominator = samples.reduce( - (sum, sample) => sum + (sample.iteration - meanIteration) ** 2, - 0, - ); - const slopeBytesPerPage = slopeDenominator === 0 ? 0 : slopeNumerator / slopeDenominator; - const iterationSpan = last.iteration - first.iteration; - return { - endpointRatio: Math.max(0, last.heapBytes - first.heapBytes) / first.heapBytes, - slopeBytesPerPage, - projectedRatio: Math.max(0, slopeBytesPerPage * iterationSpan) / first.heapBytes, - }; -} - -function percentile(values: readonly number[], probability: number): number { - if (values.length === 0) return 0; - const ordered = [...values].sort((left, right) => left - right); - return ordered[Math.min(ordered.length - 1, Math.ceil(probability * ordered.length) - 1)]!; -} - -async function collectGarbage(cdp: CDPSession): Promise { - await cdp.send('HeapProfiler.enable'); - await cdp.send('HeapProfiler.collectGarbage'); -} - -async function browserCounters(cdp: CDPSession): Promise { - const [heap, dom] = await Promise.all([ - cdp.send('Runtime.getHeapUsage'), - cdp.send('Memory.getDOMCounters'), - ]); - return { - heapBytes: heap.usedSize, - nodes: dom.nodes, - documents: dom.documents, - jsEventListeners: dom.jsEventListeners, - }; -} - -async function performanceMetrics(cdp: CDPSession): Promise> { - const result = await cdp.send('Performance.getMetrics'); - return new Map(result.metrics.map(({ name, value }) => [name, value])); -} - -function metricDelta( - before: ReadonlyMap, - after: ReadonlyMap, - name: string, -): number { - return (after.get(name) ?? 0) - (before.get(name) ?? 0); -} - -async function prepareFrameRecorder(page: Page): Promise { - await page.evaluate(() => { - const state: FrameSample & { lastFrame: number | null; running: boolean } = { - intervals: [], - loafDurations: [], - loafSupported: PerformanceObserver.supportedEntryTypes - .includes('long-animation-frame'), - lastFrame: null, - running: false, - }; - Object.assign(window, { __makaTranscriptPerf: state }); - if (state.loafSupported) { - try { - const observer = new PerformanceObserver((list) => { - if (!state.running) return; - state.loafDurations.push(...list.getEntries().map((entry) => entry.duration)); - }); - observer.observe({ type: 'long-animation-frame', buffered: false }); - } catch { - state.loafSupported = false; - } - } - }); -} - -async function scrollGesture(page: Page, delta: number, frames = 240): Promise { - return page.evaluate(async ({ selector, delta, frames }) => { - type Recorder = FrameSample & { lastFrame: number | null; running: boolean }; - const root = document.querySelector(selector); - const recorder = (window as Window & { __makaTranscriptPerf?: Recorder }) - .__makaTranscriptPerf; - if (!root || !recorder) throw new Error('the transcript performance probe is missing'); - recorder.intervals.length = 0; - recorder.loafDurations.length = 0; - recorder.lastFrame = null; - recorder.running = true; - const start = root.scrollTop; - await new Promise((resolve) => { - let completed = 0; - const tick = (now: number) => { - if (recorder.lastFrame !== null) recorder.intervals.push(now - recorder.lastFrame); - recorder.lastFrame = now; - completed += 1; - root.scrollTop = start + (delta * completed) / frames; - if (completed >= frames) { - resolve(); - return; - } - requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); - }); - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); - recorder.running = false; - return { - intervals: [...recorder.intervals], - loafDurations: [...recorder.loafDurations], - loafSupported: recorder.loafSupported, - }; - }, { selector: SCROLLER, delta, frames }); -} - -async function moveToTail(page: Page): Promise { - await page.evaluate((selector) => { - const root = document.querySelector(selector); - if (!root) throw new Error('the chat scroll container is missing'); - root.scrollTop = root.scrollHeight; - }, SCROLLER); -} - -async function returnToLatest(page: Page): Promise { - const returnLatest = page.getByRole('button', { - name: /^(?:返回最新消息|Return to latest)$/, - }); - if (await returnLatest.isVisible()) await returnLatest.click(); - else await page.locator('.maka-prompt-rail-tick').last().click({ force: true }); -} - -async function traverseFullHistoryAndReturnToTail(page: Page): Promise { - for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { - const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); - if (firstBefore?.endsWith('-1')) break; - await page.evaluate((selector) => { - const root = document.querySelector(selector); - if (!root) throw new Error('the chat scroll container is missing'); - root.scrollTop = 0; - root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); - }, SCROLLER); - await expect.poll(async () => - page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), - ).not.toBe(firstBefore); - } - await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1); - await returnToLatest(page); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1); -} - -async function measureSessionSwitch(page: Page): Promise { - await ensureSidebarExpanded(page); - const rows = page.locator('.maka-session-row'); - const selected = rows.locator('button.astryx-side-nav-item.selected'); - const originalId = await selected.evaluate((button) => - button.closest('.maka-session-row')?.getAttribute('data-session-id'), - ); - if (!originalId) throw new Error('the prompt-rail Session is not selected'); - const otherId = await rows.evaluateAll( - (elements, current) => elements - .map((element) => element.getAttribute('data-session-id')) - .find((sessionId) => sessionId !== current) ?? null, - originalId, - ); - if (!otherId) throw new Error('the fixture has no second Session'); - const start = performance.now(); - await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button`) - .first() - .click(); - await expect(page.locator( - `.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button.selected`, - )).toHaveCount(1); - await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(originalId)}] button`) - .first() - .click(); - await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); - return performance.now() - start; -} - -performanceTest('LoAF capability is explicit when Chromium cannot measure it', async ({ - promptRailWindow: page, -}) => { - await page.evaluate(() => { - Object.defineProperty(PerformanceObserver, 'supportedEntryTypes', { - configurable: true, - value: [], - }); - }); - await prepareFrameRecorder(page); - const frames = await scrollGesture(page, -1, 2); - expect(frames.loafSupported).toBe(false); -}); - -performanceTest('warm native transcript scroll metrics', async ({ promptRailWindow: page }) => { - test.setTimeout(90_000); - await page.setViewportSize({ width: 1_000, height: 700 }); - await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); - const cdp = await page.context().newCDPSession(page); - await cdp.send('Performance.enable'); - await prepareFrameRecorder(page); - await traverseFullHistoryAndReturnToTail(page); - await moveToTail(page); - - // Warm Chromium, React and the transcript path in both directions before sampling. - await scrollGesture(page, -600, 120); - await scrollGesture(page, 600, 120); - await moveToTail(page); - await collectGarbage(cdp); - await page.evaluate(() => new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - )); - const before = await performanceMetrics(cdp); - const frames = await scrollGesture(page, -600); - expect( - frames.loafSupported, - 'Chromium does not support the long-animation-frame release metric', - ).toBe(true); - const after = await performanceMetrics(cdp); - await collectGarbage(cdp); - const counters = await browserCounters(cdp); - const sourceTurns = await page.locator('[data-turn-source-count]').first() - .getAttribute('data-turn-source-count'); - const mountedTurns = await page.locator('[data-turn-id]').count(); - const domElements = await page.locator('*').count(); - const sessionSwitchMs = await measureSessionSwitch(page); - const result = { - sourceTurns: Number(sourceTurns), - mountedTurns, - domElements, - taskMs: metricDelta(before, after, 'TaskDuration') * 1_000, - scriptMs: metricDelta(before, after, 'ScriptDuration') * 1_000, - layoutMs: metricDelta(before, after, 'LayoutDuration') * 1_000, - recalcStyleMs: metricDelta(before, after, 'RecalcStyleDuration') * 1_000, - heapBytes: counters.heapBytes, - nodes: counters.nodes, - documents: counters.documents, - jsEventListeners: counters.jsEventListeners, - frameCount: frames.intervals.length, - frameP95Ms: percentile(frames.intervals, 0.95), - frameP99Ms: percentile(frames.intervals, 0.99), - frameMaxMs: Math.max(...frames.intervals), - framesOver12_5Ms: frames.intervals.filter((duration) => duration > 12.5).length, - loafOver50Ms: frames.loafDurations.filter((duration) => duration > 50).length, - loafMaxMs: Math.max(0, ...frames.loafDurations), - loafSupported: frames.loafSupported, - sessionSwitchMs, - }; - console.log(`TRANSCRIPT_PERF ${JSON.stringify(result)}`); -}); - -stressTest('600+ Turn repeated paging keeps the active range on a memory plateau', async ({ - promptRailWindow: page, -}) => { - test.setTimeout(180_000); - await page.setViewportSize({ width: 1_000, height: 700 }); - const cdp = await page.context().newCDPSession(page); - expect(PROMPT_RAIL_PROMPT_COUNT).toBeGreaterThanOrEqual(600); - const sweeps: StressSweep[] = []; - const heapGrowth: Array = []; - const captureSample = async (sweep: number, iteration: number): Promise => { - await collectGarbage(cdp); - const counters = await browserCounters(cdp); - const sample = { - sweep, - iteration, - firstTurnId: await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), - lastTurnId: await page.locator('[data-turn-id]').last().getAttribute('data-turn-id'), - mountedTurns: await page.locator('[data-turn-id]').count(), - ...counters, - }; - return sample; - }; - - // Warm the paging composition once, independent of the fixture's history depth. - const latestFirstTurn = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); - await page.evaluate((selector) => { - const root = document.querySelector(selector); - if (!root) throw new Error('the chat scroll container is missing'); - root.scrollTop = 0; - root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); - }, SCROLLER); - await expect.poll(async () => - page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), - ).not.toBe(latestFirstTurn); - await returnToLatest(page); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1); - - for (let sweep = 1; sweep <= 2; sweep += 1) { - const samples: StressSample[] = [await captureSample(sweep, 0)]; - let successfulPages = 0; - for (let iteration = 1; iteration <= PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { - const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); - if (firstBefore?.endsWith('-1')) break; - await page.evaluate((selector) => { - const root = document.querySelector(selector); - if (!root) throw new Error('the chat scroll container is missing'); - root.scrollTop = 0; - root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); - }, SCROLLER); - await expect.poll(async () => - page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), - ).not.toBe(firstBefore); - successfulPages += 1; - if (iteration % 10 !== 0) continue; - samples.push(await captureSample(sweep, iteration)); - } - await expect(page.locator('[data-turn-id]').first()) - .toHaveAttribute('data-turn-id', 'turn-prompt-rail-1'); - if (samples.at(-1)!.iteration !== successfulPages) { - samples.push(await captureSample(sweep, successfulPages)); - } - sweeps.push({ sweep, successfulPages, samples }); - const growth = { sweep, ...positiveHeapGrowth(samples) }; - heapGrowth.push(growth); - console.log(`TRANSCRIPT_STRESS_SWEEP ${JSON.stringify({ - sweep, - successfulPages, - samples, - heapGrowth: growth, - })}`); - expect(growth.endpointRatio).toBeLessThanOrEqual(SECONDARY_RESOURCE_GROWTH_RATIO); - expect(growth.projectedRatio).toBeLessThanOrEqual(SECONDARY_RESOURCE_GROWTH_RATIO); - if (sweep < 2) { - await returnToLatest(page); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1); - } - } - - const firstSweep = sweeps[0]!; - const secondSweep = sweeps[1]!; - expect(firstSweep.successfulPages).toBeGreaterThan(0); - expect(secondSweep.successfulPages).toBeGreaterThan(0); - const firstNodesByIteration = new Map( - firstSweep.samples.map((sample) => [sample.iteration, sample.nodes]), - ); - let nodeMaxSecondToFirstRatio = 0; - for (const [index, sample] of secondSweep.samples.entries()) { - const firstNodes = index === secondSweep.samples.length - 1 - ? firstSweep.samples.at(-1)!.nodes - : firstNodesByIteration.get(sample.iteration); - expect(firstNodes).toBeDefined(); - const ratio = sample.nodes / firstNodes!; - nodeMaxSecondToFirstRatio = Math.max(nodeMaxSecondToFirstRatio, ratio); - } - - const allSamples = sweeps.flatMap((sweep) => sweep.samples); - const mountedMax = Math.max(...allSamples.map((sample) => sample.mountedTurns)); - console.log(`TRANSCRIPT_STRESS ${JSON.stringify({ - fixtureTurns: PROMPT_RAIL_PROMPT_COUNT, - sweeps, - mountedMax, - nodeMin: Math.min(...allSamples.map((sample) => sample.nodes)), - nodeMax: Math.max(...allSamples.map((sample) => sample.nodes)), - nodeMaxSecondToFirstRatio, - heapGrowth, - })}`); - expect(mountedMax).toBeLessThanOrEqual( - transcriptContract.DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, - ); - expect(nodeMaxSecondToFirstRatio).toBeLessThanOrEqual( - 1 + SECONDARY_RESOURCE_GROWTH_RATIO, - ); -}); diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts new file mode 100644 index 0000000000..9762f7948e --- /dev/null +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -0,0 +1,276 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * What one scroll through the transcript COSTS, asserted as counts. + * + * The suite this replaces asserted wall-clock frame timings, and a timing + * assertion on a shared runner either flakes or gets switched off — that one + * was switched off behind an env var nothing ever set, so it never ran at all + * and every regression it existed to catch shipped. These assertions are + * structural: a number that does not move between runs on the same code, and + * does move when the thing it guards regresses. They run in ordinary CI. + * + * Gestures are RELATIVE input — a real wheel through CDP, which is also what + * the product's own history paging listens for. The replaced suite drove + * scrolling by writing absolute `scrollTop` values per frame, which erases the + * scroll-anchoring correction the browser applied since the previous frame, so + * the probe fought the scroller and produced displacement that looked like a + * product bug. + */ + +import type { CDPSession, Page } from '@playwright/test'; +import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; +import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract'; +import { expect, test } from './fixtures'; + +const SCROLLER = '[data-chat-scroll-container="true"]'; +const TURN = '.maka-transcript-turn'; + +declare global { + interface Window { + __makaTranscriptCost?: { + transitionRuns: number; + animationStarts: number; + skipped: WeakSet; + skippedCount: number; + }; + } +} + +/** + * Real wheel input at the centre of the scroller. Relative by construction: a + * wheel tick asks the compositor to move by a delta from wherever the scroller + * currently is, so an anchoring correction between ticks survives instead of + * being overwritten. + */ +async function wheel( + page: Page, + cdp: CDPSession, + options: { ticks: number; deltaY: number }, +): Promise { + const box = await page.locator(SCROLLER).boundingBox(); + if (!box) throw new Error('the chat scroll container has no box'); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + for (let tick = 0; tick < options.ticks; tick += 1) { + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x, + y, + deltaX: 0, + deltaY: options.deltaY, + }); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + } + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); +} + +/** + * Count every transition and animation the page starts, and track which Turns + * the browser is currently skipping. + * + * `contentvisibilityautostatechange` rather than + * `checkVisibility({ contentVisibilityAuto: true })`: the flag that method + * reads is updated during rendering, so a synchronous call right after a + * scroll reports every Turn visible even when the browser is skipping most of + * them. Measured on this fixture, the method returned 0 skipped Turns in every + * position the event reported between 1 and 8. + */ +async function observe(page: Page): Promise { + await page.evaluate(() => { + const state = { + transitionRuns: 0, + animationStarts: 0, + skipped: new WeakSet(), + skippedCount: 0, + }; + window.__makaTranscriptCost = state; + document.addEventListener('transitionrun', () => { state.transitionRuns += 1; }, true); + document.addEventListener('animationstart', () => { state.animationStarts += 1; }, true); + const bound = new WeakSet(); + const bind = (): void => { + for (const turn of document.querySelectorAll('.maka-transcript-turn')) { + if (bound.has(turn)) continue; + bound.add(turn); + turn.addEventListener('contentvisibilityautostatechange', (event) => { + const skipped = (event as Event & { skipped: boolean }).skipped; + if (skipped === state.skipped.has(turn)) return; + if (skipped) state.skipped.add(turn); + else state.skipped.delete(turn); + state.skippedCount += skipped ? 1 : -1; + }); + } + }; + bind(); + new MutationObserver(bind).observe(document.body, { childList: true, subtree: true }); + }); +} + +interface CostSample { + transitionRuns: number; + animationStarts: number; + unfinished: number; + skippedTurns: number; + mountedTurns: number; +} + +async function sample(page: Page): Promise { + return page.evaluate(() => { + const state = window.__makaTranscriptCost; + if (!state) throw new Error('the transcript cost observer is missing'); + return { + transitionRuns: state.transitionRuns, + animationStarts: state.animationStarts, + unfinished: document.body + .getAnimations({ subtree: true }) + .filter((animation) => animation.playState !== 'finished').length, + skippedTurns: state.skippedCount, + mountedTurns: document.querySelectorAll('[data-turn-id]').length, + }; + }); +} + +async function moveToTail(page: Page): Promise { + await page.locator(TURN).last().scrollIntoViewIfNeeded(); + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); +} + +/** + * The affordance a reader who has paged away uses to come back. Waited for + * rather than probed: `isVisible()` answers about this instant, so a probe on + * a loaded runner falls through to whatever the else branch was before the + * button has rendered — which is how the suite this replaces carried an + * untested fallback through a prompt-rail tick that no run ever reached. + */ +async function returnToLatest(page: Page): Promise { + const returnLatest = page.getByRole('button', { + name: /^(?:返回最新消息|Return to latest)$/, + }); + await expect(returnLatest).toBeVisible(); + await returnLatest.click(); +} + +/** + * The fixture's own motion contract, asserted as the count it is. + * + * `[data-maka-e2e-fixture]` collapses motion so a fixture render does not + * depend on the millisecond it settles. It used to do that with + * `transition-duration: 0.01ms`, which is not "no transition": the initial + * `transition-property` is `all`, so every element kept a live transition on + * every animatable property and fired transitionrun/start/end on every style + * recalculation — measured here, ~1,200 transitions for one sweep over ten + * mounted Turns, and tens of thousands over a long one. Every timing number + * the replaced suite reported was mostly that. + * + * Nothing downstream can measure the product while the harness generates work + * of its own, so the harness asserts zero. + */ +test('a scroll through the fixture transcript starts no transitions', async ({ + promptRailWindow: page, +}) => { + await page.setViewportSize({ width: 1_000, height: 700 }); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); + const cdp = await page.context().newCDPSession(page); + await observe(page); + await moveToTail(page); + await wheel(page, cdp, { ticks: 40, deltaY: -120 }); + await wheel(page, cdp, { ticks: 40, deltaY: 120 }); + + const cost = await sample(page); + expect(cost.transitionRuns).toBe(0); + expect(cost.animationStarts).toBe(0); + // The reason the declaration exists: a fixture render is a settled state, + // never an entry frame. `none` serves that strictly better than a near-zero + // duration did — that one left transitions still running at sample time. + expect(cost.unfinished).toBe(0); +}); + +/** + * Containment is engaging at all. A `content-visibility: auto` that stops + * skipping — a Turn that gains a property forcing layout, a container query, + * an ancestor that breaks the containment chain — costs nothing that a timing + * threshold would notice on a ten-Turn range, and everything on a long one. + */ +test('the browser skips the Turns the reader has scrolled past', async ({ + promptRailWindow: page, +}) => { + await page.setViewportSize({ width: 1_000, height: 700 }); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); + const cdp = await page.context().newCDPSession(page); + await observe(page); + await moveToTail(page); + // Two viewports up and back: enough for the Turns at the far end of the + // mounted range to leave the browser's relevance margin in both directions. + await wheel(page, cdp, { ticks: 20, deltaY: -120 }); + await wheel(page, cdp, { ticks: 20, deltaY: 120 }); + + expect((await sample(page)).skippedTurns).toBeGreaterThan(0); +}); + +/** + * The bound the Desktop transcript is built on: paging back through a history + * far longer than the active range mounts a bounded number of Turns, not a + * growing one. Sampled at every page rather than only at the end, because a + * range that overshoots and is trimmed afterwards is the regression. + */ +test('paging back through the whole history keeps the mounted range bounded', async ({ + promptRailWindow: page, +}) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 1_000, height: 700 }); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); + const cdp = await page.context().newCDPSession(page); + const turns = page.locator('[data-turn-id]'); + let mountedMax = 0; + let pages = 0; + + for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { + const firstBefore = await turns.first().getAttribute('data-turn-id'); + if (firstBefore === 'turn-prompt-rail-1') break; + // The product asks for history on an upward wheel near the start, so the + // gesture that pages is the gesture a reader makes. + await wheel(page, cdp, { ticks: 12, deltaY: -120 }); + await expect + .poll(async () => turns.first().getAttribute('data-turn-id')) + .not.toBe(firstBefore); + pages += 1; + mountedMax = Math.max(mountedMax, await turns.count()); + } + + expect(pages).toBeGreaterThan(0); + await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1'); + expect(mountedMax).toBeLessThanOrEqual(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); + + // Coming back from the far end is a range reload, not a scroll: the Host + // resolves a new window around the tail and the renderer mounts it. The + // suite's 10s expect timeout is sized for UI that is already on screen, and + // this step measured past it on a CI runner with four workers competing. + await returnToLatest(page); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1, { timeout: 30_000 }); + expect(await turns.count()).toBeLessThanOrEqual(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); +}); diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 906182d998..02255173ea 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -37,9 +37,7 @@ export const AGENT_GRAPH_SESSION_ID = 'e2e-fixture-agent-graph'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; /** Exceeds both the 64-tick rail and the bounded active transcript range. */ -export const PROMPT_RAIL_PROMPT_COUNT = process.env.MAKA_TRANSCRIPT_STRESS === '1' - ? 640 - : 120; +export const PROMPT_RAIL_PROMPT_COUNT = 120; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; export const LONG_SIDEBAR_SESSION_COUNT = 60; export const LONG_SIDEBAR_PROJECT_ID = 'e2e-fixture-project'; diff --git a/apps/desktop/src/renderer/styles/base.css b/apps/desktop/src/renderer/styles/base.css index b98571b2bd..23b0804678 100644 --- a/apps/desktop/src/renderer/styles/base.css +++ b/apps/desktop/src/renderer/styles/base.css @@ -85,7 +85,13 @@ button { *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; + /* `none`, not a 0.01ms duration: a near-zero duration still creates a + transition per animatable property (the initial `transition-property` + is `all`) and still fires transitionrun/start/end on every style + recalculation. On a long transcript that is tens of thousands of + events per scroll. `none` creates nothing, and settles strictly + faster than 0.01ms does. */ + transition: none !important; scroll-behavior: auto !important; } } @@ -103,7 +109,7 @@ button { [data-maka-reduced-motion="true"] *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; + transition: none !important; /* Scroll behavior + view transition durations also collapse */ scroll-behavior: auto !important; } @@ -117,7 +123,7 @@ button { [data-maka-e2e-fixture="true"] *::before, [data-maka-e2e-fixture="true"] *::after { animation: none !important; - transition-duration: 0.01ms !important; + transition: none !important; caret-color: transparent !important; }