Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 83 additions & 19 deletions apps/desktop/e2e/prompt-rail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,49 @@ interface ActivePromptRailSnapshot {
sourceTurnId: string | null;
}

async function activePromptRailSnapshot(page: Page): Promise<ActivePromptRailSnapshot> {
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<RailSettleOutcome> {
const read = (): RailStateSnapshot => {
const root = document.querySelector<HTMLElement>('[data-chat-scroll-container="true"]');
if (!root) throw new Error('the chat scroll container is missing');
const ticks = [...document.querySelectorAll<HTMLElement>('.maka-prompt-rail-tick')];
Expand Down Expand Up @@ -175,35 +216,58 @@ async function activePromptRailSnapshot(page: Page): Promise<ActivePromptRailSna
: readingBandTurns[0] ?? scrollportTurns[0] ?? null;
const expectedRailIndex = sourceTurn === null || ticks.length === 0
? null
: Math.round(
sourceTurn.index * (ticks.length - 1) / (promptCount - 1),
);
: Math.round(sourceTurn.index * (ticks.length - 1) / (promptCount - 1));
const expectedId = expectedRailIndex === null
? null
: ticks[expectedRailIndex]?.dataset.promptTurnId ?? null;
return {
currentIds,
expectedId,
sourceTurnId: sourceTurn?.id ?? null,
scrollTop: root.scrollTop,
scrollHeight: root.scrollHeight,
clientHeight: root.clientHeight,
};
};
return new Promise<RailSettleOutcome>((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<void> {
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<void> {
Expand Down