diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index 7af51a35c2..87cf9551e1 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -137,8 +137,49 @@ interface ActivePromptRailSnapshot { sourceTurnId: string | null; } -async function activePromptRailSnapshot(page: Page): Promise { - return page.evaluate(async ({ promptCount }) => { +interface RailStateSnapshot extends ActivePromptRailSnapshot { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +} + +interface RailSettleArgs { + promptCount: number; + quietFrames: number; + timeoutMs: number; +} + +type RailSettleOutcome = + | { settled: true; state: RailStateSnapshot } + | { settled: false; state: RailStateSnapshot | null }; + +// The rail resolves on the frame after a scroll and keeps re-resolving for +// six frames after each transcript mutation, and the transcript keeps +// remeasuring under them — so a snapshot can agree mid-settle and differ one +// mutation later. After a scroll to the bottom that remeasure grows +// `scrollHeight` while `scrollTop` stays put, the atEnd branch flips, and the +// current tick moves off the last prompt for good: a stable flip that no +// fixed delay between two reads can rule out (#4675 and the 4-worker stress +// on its review thread). The observable quiet state is instead one full read +// — tick mapping plus the scroll metrics that feed it — unchanged across six +// consecutive painted frames while mapping to the Turn being read. Every +// input the rail's resolver reacts to (scroll, mutation, geometry) is then +// exactly what produced the asserted state, so the reads a test makes after +// this helper see the same rail. The loop resolves with the state it +// verified; the passing path reads nothing after it. +// +// The loop runs in one `page.evaluate` so the per-frame reads cannot straddle +// a protocol round trip, and it is passed as a real function: a string +// pageFunction is evaluated as an expression and never invoked. +const RAIL_QUIET_FRAMES = 6; +const RAIL_SETTLE_TIMEOUT_MS = 10_000; + +async function settlePromptRailInPage({ + promptCount, + quietFrames, + timeoutMs, +}: RailSettleArgs): Promise { + const read = (): RailStateSnapshot => { const root = document.querySelector('[data-chat-scroll-container="true"]'); if (!root) throw new Error('the chat scroll container is missing'); const ticks = [...document.querySelectorAll('.maka-prompt-rail-tick')]; @@ -175,9 +216,7 @@ async function activePromptRailSnapshot(page: Page): Promise((resolve) => { + let previousKey: string | null = null; + let quietFramesSeen = 0; + let lastState: RailStateSnapshot | null = null; + const timer = setTimeout( + () => resolve({ settled: false, state: lastState }), + timeoutMs, + ); + const frame = (): void => { + const state = read(); + const key = JSON.stringify(state); + quietFramesSeen = key === previousKey ? quietFramesSeen + 1 : 0; + previousKey = key; + lastState = state; + const agreed = state.expectedId !== null + && state.currentIds.length === 1 + && state.currentIds[0] === state.expectedId; + if (agreed && quietFramesSeen >= quietFrames) { + clearTimeout(timer); + resolve({ settled: true, state }); + return; + } + requestAnimationFrame(frame); }; - }, { promptCount: PROMPT_RAIL_PROMPT_COUNT }); + frame(); + }); } async function expectPromptRailMatchesReadingPosition(page: Page): Promise { - let lastSnapshot: ActivePromptRailSnapshot | null = null; - try { - await expect.poll(async () => { - lastSnapshot = await activePromptRailSnapshot(page); - return lastSnapshot.expectedId !== null - && lastSnapshot.currentIds.length === 1 - && lastSnapshot.currentIds[0] === lastSnapshot.expectedId; - }, { message: 'the one current tick maps from the Turn being read' }).toBe(true); - } catch { - throw new Error(`the prompt rail did not settle on the reading position: ${JSON.stringify(lastSnapshot)}`); + const outcome = await page.evaluate(settlePromptRailInPage, { + promptCount: PROMPT_RAIL_PROMPT_COUNT, + quietFrames: RAIL_QUIET_FRAMES, + timeoutMs: RAIL_SETTLE_TIMEOUT_MS, + }); + if (!outcome.settled) { + throw new Error(`the prompt rail did not settle on the reading position: ${JSON.stringify(outcome.state)}`); } - const snapshot = await activePromptRailSnapshot(page); - expect(snapshot.expectedId, `no visible Turn in ${JSON.stringify(snapshot)}`).not.toBeNull(); - expect(snapshot.currentIds).toEqual([snapshot.expectedId]); + expect(outcome.state.expectedId, `no visible Turn in ${JSON.stringify(outcome.state)}`).not.toBeNull(); + expect(outcome.state.currentIds).toEqual([outcome.state.expectedId]); } async function scrollTranscriptThroughHistory(page: Page): Promise {