From 5153821ec88e749925058e248126c9dc486c8e15 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 15 Sep 2026 02:06:50 +0200 Subject: [PATCH] test(e2e): keep the completed-block, font-readiness, and live-page guards inside journeys - agent-stream-ui: the auto-scroll journey anchors on the completed intro block by content and requires its Markdown root to stay the same mounted node while later blocks stream (#1989 guard, reshaped off the tail block). - changes-pane: the code-typography journey holds font loads and requires no diff geometry before they resolve, then applies the font change. - agent-timeline-pagination: the live-output page load runs against a thirty-minute mock stream and gates the release on the running turn, so the status assertion no longer races a ten-second turn. --- .../app/e2e/browser/agent-stream-ui.spec.ts | 17 +++++- .../browser/agent-timeline-pagination.spec.ts | 8 ++- packages/app/e2e/browser/changes-pane.spec.ts | 55 +++++++++++++++++- .../app/e2e/support/helpers/agent-stream.ts | 58 ++++++++++++++++++- .../support/helpers/timeline-pagination.ts | 7 ++- 5 files changed, 135 insertions(+), 10 deletions(-) diff --git a/packages/app/e2e/browser/agent-stream-ui.spec.ts b/packages/app/e2e/browser/agent-stream-ui.spec.ts index 5ad8a643ee..386727046e 100644 --- a/packages/app/e2e/browser/agent-stream-ui.spec.ts +++ b/packages/app/e2e/browser/agent-stream-ui.spec.ts @@ -6,6 +6,7 @@ import { expectRunningAgentChrome, expectTurnCopyButton, expectScrollFollowsNewContent, + observeCompletedMarkdownBlock, } from "../support/helpers/agent-stream"; import { expectScrollStaysFixed, @@ -48,7 +49,9 @@ test.describe("Agent stream UI", () => { } }); - test("auto-scroll sticks to bottom across token bursts", async ({ page }) => { + test("auto-scroll follows token bursts while streamed Markdown grows in place", async ({ + page, + }) => { test.setTimeout(120_000); const agent = await startRunningMockAgent(page, { prefix: "stream-scroll-", @@ -56,8 +59,18 @@ test.describe("Agent stream UI", () => { prompt: "Stream for auto-scroll test.", }); try { - await awaitAssistantMessage(page); + await awaitAssistantMessage(page, "walking through"); await expectScrollFollowsNewContent(page); + + await test.step("a completed block stays mounted while later blocks stream", async () => { + await awaitAssistantMessage(page, "Now I have a clearer picture"); + const intro = page + .getByTestId("assistant-message") + .filter({ hasText: "walking through" }) + .first(); + const block = await observeCompletedMarkdownBlock(page, intro); + await block.expectLaterStreamKeepsMounted(); + }); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts index ec7cedba61..07778d21a7 100644 --- a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts @@ -134,7 +134,8 @@ test.describe("Agent timeline pagination", () => { test("finishes loading an older page while live output continues", async ({ page }) => { test.setTimeout(120_000); - const agent = await seedLongMockAgentTimeline({ turns: 40 }); + // The live turn streams for thirty minutes, so it is still running at every assertion. + const agent = await seedLongMockAgentTimeline({ turns: 40, liveTurns: "thirty-minute-stream" }); try { const history = await holdOlderHistoryPages(page, agent); await openAgentTimeline(page, agent); @@ -146,10 +147,11 @@ test.describe("Agent timeline pagination", () => { agent.agentId, (snapshot) => snapshot.status === "running", ); + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + await expect(timeline.getByText("walking through").first()).toBeAttached(); history.releasePage(1); - await userScrollsTimelineToHistoryStart(page); - await expectTimelinePromptVisible(page, agent.oldestPrompt); + await expect(timeline.getByText(agent.newestOlderPagePrompt, { exact: true })).toBeAttached(); await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 }); const running = await agent.client.fetchAgents({ scope: "active" }); expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe( diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 0bd0c3e16d..637d1b633b 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -980,11 +980,21 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag .toBe(true); }); -test("changes diff applies code size changes to gutter and code typography", async ({ page }) => { +test("changes diff waits for configured fonts, then applies code typography changes", async ({ + page, +}) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useCodeFont(page, 12); await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); + await holdBrowserFontLoads(page); + await navigateToWorkspaceChanges(page, workspace); + + await test.step("commit no geometry before the configured fonts are ready", async () => { + await expect(page.getByTestId("git-diff-canvas")).toBeVisible(); + await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0); + await releaseBrowserFontLoads(page); + await expectExpandedMountedTabDiff(page); + }); const before = await readDiffTypographyGeometry(page); await changeCodeTypographyFromSettings(page, { @@ -1374,6 +1384,41 @@ async function setOpenChangesPresentation( } } +/** Holds every font load in the document until released; later loads pass through. */ +async function holdBrowserFontLoads(page: Page): Promise { + await page.addInitScript(() => { + const fontSet = document.fonts; + const originalLoad = fontSet.load.bind(fontSet); + const pending: Array<() => void> = []; + let released = false; + Object.defineProperty(fontSet, "load", { + configurable: true, + value(font: string, text?: string) { + if (released) return originalLoad(font, text); + return new Promise((resolve, reject) => { + pending.push(() => { + originalLoad(font, text).then(resolve, reject); + }); + }); + }, + }); + Object.assign(window, { + __releasePaseoDiffFontLoads() { + released = true; + for (const release of pending.splice(0)) release(); + }, + }); + }); +} + +async function releaseBrowserFontLoads(page: Page): Promise { + await page.evaluate(() => { + ( + window as typeof window & { __releasePaseoDiffFontLoads: () => void } + ).__releasePaseoDiffFontLoads(); + }); +} + async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise { const canvas = page.getByTestId("git-diff-canvas"); await expect @@ -1609,12 +1654,16 @@ async function createWorkspaceWithStickyTransitionDiff(): Promise { + await navigateToWorkspaceChanges(page, workspace); + await expectExpandedMountedTabDiff(page); +} + +async function navigateToWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise { await page.setViewportSize({ width: 1400, height: 900 }); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await waitForWorkspaceTabsVisible(page); await page.getByTestId("workspace-explorer-toggle").first().click(); await openChangesInVisibleExplorer(page); - await expectExpandedMountedTabDiff(page); } /** The Explorer overlay a phone-sized viewport shows, with its Changes tab selected. */ diff --git a/packages/app/e2e/support/helpers/agent-stream.ts b/packages/app/e2e/support/helpers/agent-stream.ts index 313028ab77..4b50eec8f0 100644 --- a/packages/app/e2e/support/helpers/agent-stream.ts +++ b/packages/app/e2e/support/helpers/agent-stream.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; import { readScrollMetrics, waitForContentGrowth, expectNearBottom } from "./agent-bottom-anchor"; export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise { @@ -7,6 +7,62 @@ export async function awaitAssistantMessage(page: Page, hasText?: string | RegEx await expect(target).toBeVisible({ timeout: 30_000 }); } +/** + * Observes an assistant block that has finished streaming. While later blocks + * stream below it, its Markdown root must stay the same mounted node with no + * descendants removed. Re-creating completed blocks on every stream update is + * what froze Android chats (#1989). + */ +export async function observeCompletedMarkdownBlock( + page: Page, + message: Locator, +): Promise<{ expectLaterStreamKeepsMounted(): Promise }> { + const block = await message.locator(":scope > *").first().elementHandle(); + const root = await message + .locator(":scope > *") + .first() + .locator(":scope > *") + .first() + .elementHandle(); + if (!block || !root) { + throw new Error("Expected the completed assistant block to contain a Markdown root"); + } + await page.evaluate((observed) => { + const evidence = { removedNodes: 0 }; + const observer = new MutationObserver((records) => { + for (const record of records) evidence.removedNodes += record.removedNodes.length; + }); + observer.observe(observed, { childList: true, subtree: true }); + Object.assign(window, { + __completedMarkdownEvidence: evidence, + __completedMarkdownObserver: observer, + }); + }, block); + + return { + async expectLaterStreamKeepsMounted() { + const { contentHeight } = await readScrollMetrics(page); + await waitForContentGrowth(page, contentHeight + 200); + const evidence = await page.evaluate( + ([observedBlock, observedRoot]) => { + const state = window as typeof window & { + __completedMarkdownEvidence?: { removedNodes: number }; + __completedMarkdownObserver?: MutationObserver; + }; + state.__completedMarkdownObserver?.disconnect(); + return { + connected: observedRoot.isConnected, + sameRoot: observedBlock.firstElementChild === observedRoot, + removedNodes: state.__completedMarkdownEvidence?.removedNodes, + }; + }, + [block, root] as const, + ); + expect(evidence).toEqual({ connected: true, sameRoot: true, removedNodes: 0 }); + }, + }; +} + export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise { await expect( page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(), diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index c105dfd910..7cc19461b2 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -15,6 +15,8 @@ export { holdDaemonHydration }; interface LongTimelineAgentOptions { turns: number; + /** Live turns stream for ten seconds unless a test needs one that outlasts it. */ + liveTurns?: "ten-second-stream" | "thirty-minute-stream"; } export interface LongTimelineAgent extends MockAgentWorkspace { @@ -23,6 +25,8 @@ export interface LongTimelineAgent extends MockAgentWorkspace { firstOlderPagePrompt: string; initialTailAnchorPrompt: string; initialTailOldestPrompt: string; + /** The newest prompt on the first older page, adjacent to the initial tail. */ + newestOlderPagePrompt: string; oldestPrompt: string; newestPrompt: string; } @@ -71,7 +75,7 @@ export async function seedLongMockAgentTimeline( const agent = await seedMockAgentWorkspace({ repoPrefix: "timeline-pagination-", title: "Timeline pagination regression", - model: "ten-second-stream", + model: options.liveTurns ?? "ten-second-stream", }); for (let index = 0; index < options.turns; index += 1) { @@ -85,6 +89,7 @@ export async function seedLongMockAgentTimeline( firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)), initialTailAnchorPrompt: promptForTurn(Math.max(0, options.turns - 5)), initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)), + newestOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 21)), oldestPrompt: promptForTurn(0), newestPrompt: promptForTurn(options.turns - 1), };