From 64669fc920239f40ae6cda4b7a38aed0a2e88c8f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 23:57:14 +0800 Subject: [PATCH 1/3] fix(desktop): stop the e2e fixture transitioning every property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `transition-duration: 0.01ms !important` is not "no transition". The initial `transition-property` is `all`, so the declaration gave every element under `[data-maka-e2e-fixture]` a live transition on every animatable property, and `* { scrollbar-color: ... }` resolves from a relative `oklch()` that Chromium re-serialises on each style recalculation — so something changed on every recalc and the transitions fired. Measured on a real Electron build, one wheel sweep over the ten mounted Turns of the prompt-rail fixture produced 2,481 `transitionrun` events; with `transition: none` it produces 0. `none` also serves the reason the declaration exists — a fixture render is a settled state, not an entry frame — strictly better: sampling right after a gesture found 3 and then 10 transitions still unfinished under 0.01ms, and 0 under `none`. Both variants change together, as the comment on the first one requires. Generated-by: Claude Code --- apps/desktop/src/renderer/styles/base.css | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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; } From 39f22a8e2ece40bfd50445c9674d92c9d8f870c6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 23:57:14 +0800 Subject: [PATCH 2/3] test(desktop): replace the never-run transcript perf suite with count ratchets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MAKA_TRANSCRIPT_PERF` and `MAKA_TRANSCRIPT_STRESS` appeared nowhere in the repository except their own reads, so every test in native-transcript-perf.spec.ts had been skipped since it was written — no workflow set either variable. What the suite would have measured was the fixture's transition storm rather than the product, and its gestures wrote absolute `scrollTop` values per frame, erasing the browser's scroll-anchoring correction between frames. Replaced with three assertions that run in ordinary CI, drive the scroller with real relative wheel input through CDP, and assert counts rather than milliseconds: no transition starts during a gesture, the browser skips Turns the reader scrolled past, and the mounted range stays within DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS across a full 120-Turn traversal. Style-recalculation and layout counts were measured and rejected: recalc runs 315-332 with the storm and 156-225 without, leaving no threshold both stable on a shared runner and tight enough to catch anything smaller; layout count is 440-460 in both arms. The single-scroll-writer property is already covered by transcript-scroll-authority's unit tests. `checkVisibility({ contentVisibilityAuto: true })` is not usable here — its flag updates during rendering, so it reported 0 skipped Turns in every position where `contentvisibilityautostatechange` reported 1 to 8. Generated-by: Claude Code --- .../e2e/native-transcript-perf.spec.ts | 440 ------------------ .../e2e/transcript-scroll-cost.spec.ts | 265 +++++++++++ .../src/main/e2e-fixture/seed-helpers.ts | 4 +- 3 files changed, 266 insertions(+), 443 deletions(-) delete mode 100644 apps/desktop/e2e/native-transcript-perf.spec.ts create mode 100644 apps/desktop/e2e/transcript-scroll-cost.spec.ts 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..76abff4aff --- /dev/null +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -0,0 +1,265 @@ +/* + * 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())), + )); +} + +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 }); +} + +/** + * 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); + + await returnToLatest(page); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); + 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'; From f293969257c55fde8570574c2a15b44c399d2456 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 00:42:40 +0800 Subject: [PATCH 3/3] test(desktop): wait for the return-to-latest range reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the first CI run surfaced, neither reproducible locally. `isVisible()` answers about the current instant and does not wait, so on a loaded runner the probe falls through before the affordance renders. The branch it fell through to — clicking the last prompt-rail tick — was inherited from a suite that never ran, so nothing had ever executed it. Wait for the button instead, and drop the untested fallback. Coming back from the far end of the history is a range reload rather than 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 already on screen, and the CI trace shows the click landing and the assertion timing out behind it with four workers competing. Give that one step 30s. Generated-by: Claude Code --- apps/desktop/e2e/transcript-scroll-cost.spec.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 76abff4aff..9762f7948e 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -156,12 +156,19 @@ async function moveToTail(page: Page): Promise { )); } +/** + * 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)$/, }); - if (await returnLatest.isVisible()) await returnLatest.click(); - else await page.locator('.maka-prompt-rail-tick').last().click({ force: true }); + await expect(returnLatest).toBeVisible(); + await returnLatest.click(); } /** @@ -258,8 +265,12 @@ test('paging back through the whole history keeps the mounted range bounded', as 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); + .toHaveCount(1, { timeout: 30_000 }); expect(await turns.count()).toBeLessThanOrEqual(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); });