From 0531a9b46d4691b34993f0fe93274f4df8f7c47f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 1 Nov 2026 03:24:11 +0100 Subject: [PATCH 1/2] test(ui): await computed Monaco diffs before composer selection The Git history composer test can measure an initially rendered model line before Monaco's worker installs inline deleted-code view zones. A native click at that stale point opens Copy changed lines; Home and Shift+End then target that menu instead of selecting editor text. The remaining hover-only context widget disappears during pointer movement, producing the misleading html-interception and DOM-detachment failure seen across unrelated PRs. Wait for the fixture's expected computed insertion decoration before locating and clicking each modified line. Keep the real pointer checks and every existing composer assertion, including identical-target editor/model identity, multi-line selections, commit-qualified context, WSL service-directory authority and draft restoration. Document this browser-fixture readiness rule; application behavior is unchanged. Add a real-worker regression that buffers the first computeDiff reply and withholds repaint callbacks for 30 native frames while normal input and Playwright actionability remain live. Removing the readiness wait makes the regression fail because Copy changed lines is present; restoring it passes with a non-empty native editor selection and the expected composer context. The untouched baseline also reproduced the exact CI html-interception/detachment signature locally. UI typecheck and both focused browser cases pass; repeated full affected suites provide additional validation in the PR. --- dev-docs/BROWSER_TESTING.md | 5 ++ packages/ui/tests/browser/git-history.test.ts | 80 +++++++++++++++++-- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/dev-docs/BROWSER_TESTING.md b/dev-docs/BROWSER_TESTING.md index 740b958cf0..552fa2023d 100644 --- a/dev-docs/BROWSER_TESTING.md +++ b/dev-docs/BROWSER_TESTING.md @@ -84,3 +84,8 @@ synthetic provider. - Tests that need stateful middleware (SSE feeds driven by the test, custom Vite transform hooks, configs that diverge from the canonical daemon config) keep their own server and live in the heavy tier. +- Before pointer/keyboard selection in a Monaco diff, wait for its computed + diff decoration (`.line-insert` in the Git fixture), then locate the line. + Plain model text can paint before the worker result inserts deleted-code view + zones. `git-history.test.ts` holds a real worker reply and defers Monaco's + repaint to exercise that geometry change with native pointer checks intact. diff --git a/packages/ui/tests/browser/git-history.test.ts b/packages/ui/tests/browser/git-history.test.ts index 3cc9d81f0e..9e2c2c6f30 100644 --- a/packages/ui/tests/browser/git-history.test.ts +++ b/packages/ui/tests/browser/git-history.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { after, before, test } from "node:test" import { fileURLToPath } from "node:url" -import { chromium, type Browser } from "playwright" +import { chromium, type Browser, type Page } from "playwright" import { createFixtureShutdown } from "./fixture-shutdown" import { armFixtureNavigationBudget } from "./fixture-navigation-budget" import { createServer, type ViteDevServer } from "vite" @@ -38,6 +38,14 @@ const gitFocusBoundaryInitScript = String.raw`(() => { })()` let cache: Awaited> + +async function clickModifiedDiffLine(page: Page) { + // Plain model text paints before the asynchronous diff adds its deleted-code + // view zones. Selecting those old coordinates can open Monaco's copy menu. + await page.locator(".git-diff-view .line-insert").first().waitFor() + await page.locator(".git-diff-view .editor.modified .view-line").filter({ hasText: 'title: "Git"' }).click() +} + test("large Changes inventories keep the reader controls responsive", async () => { const page = await browser.newPage({ viewport: { width: 2000, height: 1120 } }) try { @@ -784,6 +792,67 @@ test("real SessionView keeps its composer while file previews are fenced by sess } finally { await page.close() } }) +test("composer selection waits for the computed diff when its initial paint is deferred", async () => { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + await page.addInitScript({ content: String.raw`(() => { + const NativeWorker = window.Worker + window.Worker = class extends NativeWorker { + constructor(...args) { + super(...args) + const requests = new Set(), post = this.postMessage.bind(this) + let hold = true + this.postMessage = (message, ...rest) => { + if (message?.method === "$computeDiff") requests.add(message.req) + return post(message, ...rest) + } + this.addEventListener("message", event => { + if (!hold || !requests.delete(event.data?.seq)) return + hold = false + event.stopImmediatePropagation() + window.releaseDiff = () => this.dispatchEvent(new MessageEvent("message", { data: event.data })) + }, true) + } + } + window.deferDiffPaint = () => { + const raf = window.requestAnimationFrame.bind(window), cancel = window.cancelAnimationFrame.bind(window), frames = new Map() + // Fault injection: keep Monaco's old DOM for 30 frames after its real + // worker result changes hit-test geometry, without pausing native input. + let id = 0, remaining = 30 + window.requestAnimationFrame = callback => { frames.set(--id, callback); return id } + window.cancelAnimationFrame = handle => { if (!frames.delete(handle)) cancel(handle) } + const release = () => { + if (--remaining) { raf(release); return } + window.requestAnimationFrame = raf + window.cancelAnimationFrame = cancel + for (const callback of frames.values()) raf(callback) + } + raf(release) + window.releaseDiff() + } + })()` }) + try { + await page.goto(`${url}?session=1`) + await page.getByRole("button", { name: /Changements/ }).click() + await page.getByRole("button", { name: "Aperçu du fichier · src/components/git-panel.tsx", exact: true }).click() + const line = page.locator(".git-diff-view .editor.modified .view-line").filter({ hasText: 'title: "Git"' }) + await line.waitFor() + await page.waitForFunction(() => Boolean((window as any).releaseDiff)) + assert.equal(await page.locator(".git-diff-view .line-insert").count(), 0, "visible model text precedes diff computation") + // Deliver the real worker reply while withholding Monaco's repaint. Native + // pointer hit testing and Playwright's captured animation frames stay live. + // Without the computed-diff wait, the old line opens Copy changed lines. + await page.evaluate(() => (window as any).deferDiffPaint()) + await clickModifiedDiffLine(page) + await page.keyboard.press("Home") + await page.keyboard.press("Shift+End") + assert.equal(await page.getByRole("menuitem", { name: "Copy changed lines", exact: true }).count(), 0, "selection never opens the deleted-code menu") + assert.equal(await page.evaluate(() => (window as any).monaco.editor.getDiffEditors()[0].getModifiedEditor().getSelection().isEmpty()), false) + await page.getByRole("button", { name: "Ajouter au prompt", exact: true }).click() + assert.match(await page.locator(".session-view textarea.prompt-input").inputValue(), /File: src\/components\/git-panel.tsx : 3-3/) + } finally { await page.close() } +}) + test("central diff inserts local lines and revision-qualified history into the real session composer", async () => { const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }) const errors: string[] = [] @@ -796,8 +865,7 @@ test("central diff inserts local lines and revision-qualified history into the r await composer.fill("Conserver ce brouillon") await page.getByRole("button", { name: /Changements/ }).click() await page.getByRole("button", { name: "Aperçu du fichier · src/components/git-panel.tsx", exact: true }).click() - const modifiedLine = page.locator(".git-diff-view .editor.modified .view-line").filter({ hasText: 'title: "Git"' }) - await modifiedLine.click() + await clickModifiedDiffLine(page) await page.keyboard.press("Home") await page.keyboard.press("Shift+End") // Git status reconciliation reopens a fresh, identical target to reload the @@ -831,7 +899,7 @@ test("central diff inserts local lines and revision-qualified history into the r // Native editor keyboard selection must keep the full range, not just the hovered line. await page.getByRole("button", { name: "Aperçu du fichier · src/styles/panels/git-history.css", exact: true }).click() - await modifiedLine.click() + await clickModifiedDiffLine(page) await page.keyboard.press("Control+Home") await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowDown") @@ -843,7 +911,7 @@ test("central diff inserts local lines and revision-qualified history into the r await page.getByRole("button", { name: "Commits", exact: true }).first().click() await page.getByRole("button", { name: /Make Git history the starting point/ }).click() await page.getByRole("button", { name: "Aperçu du fichier · src/components/git-panel.tsx", exact: true }).click() - await modifiedLine.click() + await clickModifiedDiffLine(page) await page.keyboard.press("Home") await page.keyboard.press("Shift+End") await insert.click() @@ -861,7 +929,7 @@ test("central diff inserts local lines and revision-qualified history into the r await page.getByRole("combobox", { name: "Worktree à consulter" }).selectOption("review") await page.getByRole("button", { name: /Changements/ }).click() await page.getByRole("button", { name: "Aperçu du fichier · src/components/git-panel.tsx", exact: true }).click() - await modifiedLine.click() + await clickModifiedDiffLine(page) await page.keyboard.press("Home") await page.keyboard.press("Shift+End") await insert.click() From 6584d355129a40a41b5042470a56f6fd276dc20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 1 Nov 2026 03:57:03 +0100 Subject: [PATCH 2/2] test(ui): synchronize delayed diff paint with native selection The regression's elapsed 30-frame paint hold could expire before a slow browser driver reached the line click, allowing removal of the readiness wait to pass. Bind fault injection to the real readiness and pointer boundaries instead of an elapsed frame budget. The main-world DOM readiness probe delivers the buffered real diff reply and releases painting before it can observe Monaco's insertion decoration. Without that single wait, deliver the reply only on native pointer movement after the old line has been measured and scrolled, then retain stale DOM through mouse-down until native pointer-up. Assert the readiness release boundary alongside the existing absent-menu, native selection and real composer-context checks. Native hit testing, keyboard input and all original composer assertions remain unchanged. Eight one-line removed-wait negative controls all fail on the actual Copy changed lines menu: five ordinary runs and three at 6x renderer CPU throttling. Restoring only the wait passes both targeted cases in all eight matching runs (16/16). Three new full affected browser-suite repetitions pass 37/37 each (111/111), as did the original head's three repetitions. UI typecheck, fixture wiring and diff whitespace checks pass. No CI runs were manipulated. --- dev-docs/BROWSER_TESTING.md | 3 ++ packages/ui/tests/browser/git-history.test.ts | 47 ++++++++++++++----- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/dev-docs/BROWSER_TESTING.md b/dev-docs/BROWSER_TESTING.md index 552fa2023d..8708ab6f54 100644 --- a/dev-docs/BROWSER_TESTING.md +++ b/dev-docs/BROWSER_TESTING.md @@ -89,3 +89,6 @@ synthetic provider. Plain model text can paint before the worker result inserts deleted-code view zones. `git-history.test.ts` holds a real worker reply and defers Monaco's repaint to exercise that geometry change with native pointer checks intact. + Its main-world DOM readiness probe releases the reply and repaint; a direct + native click instead receives the reply at pointer movement and releases + repaint on pointer-up, keeping the stale geometry through mouse-down. diff --git a/packages/ui/tests/browser/git-history.test.ts b/packages/ui/tests/browser/git-history.test.ts index 9e2c2c6f30..93be448bf4 100644 --- a/packages/ui/tests/browser/git-history.test.ts +++ b/packages/ui/tests/browser/git-history.test.ts @@ -42,7 +42,7 @@ let cache: Awaited> async function clickModifiedDiffLine(page: Page) { // Plain model text paints before the asynchronous diff adds its deleted-code // view zones. Selecting those old coordinates can open Monaco's copy menu. - await page.locator(".git-diff-view .line-insert").first().waitFor() + await page.waitForFunction(() => document.querySelector(".git-diff-view .line-insert")) await page.locator(".git-diff-view .editor.modified .view-line").filter({ hasText: 'title: "Git"' }).click() } @@ -816,19 +816,43 @@ test("composer selection waits for the computed diff when its initial paint is d } window.deferDiffPaint = () => { const raf = window.requestAnimationFrame.bind(window), cancel = window.cancelAnimationFrame.bind(window), frames = new Map() - // Fault injection: keep Monaco's old DOM for 30 frames after its real - // worker result changes hit-test geometry, without pausing native input. - let id = 0, remaining = 30 + const query = document.querySelector + let id = 0, released = false window.requestAnimationFrame = callback => { frames.set(--id, callback); return id } window.cancelAnimationFrame = handle => { if (!frames.delete(handle)) cancel(handle) } - const release = () => { - if (--remaining) { raf(release); return } + const release = boundary => { + if (released) return + released = true + window.diffPaintReleaseBoundary = boundary + document.querySelector = query + document.removeEventListener("pointermove", onPointerMove, true) + document.removeEventListener("pointerup", onPointerUp, true) window.requestAnimationFrame = raf window.cancelAnimationFrame = cancel for (const callback of frames.values()) raf(callback) } - raf(release) - window.releaseDiff() + // The main-world readiness probe releases painting before it can see + // the real decoration. Without that probe, keep stale DOM through the + // native mouse-down that opens Monaco's deleted-code menu. + document.querySelector = function(selector) { + if (selector === ".git-diff-view .line-insert") { + deliver() + release("readiness") + } + return query.call(this, selector) + } + let delivered = false + const deliver = () => { + if (delivered) return + delivered = true + window.releaseDiff() + } + // With no readiness wait, deliver only after Playwright has measured + // and scrolled the old line, at its real pointer movement to that point. + const onPointerMove = () => deliver() + const onPointerUp = () => release("pointerup") + document.addEventListener("pointermove", onPointerMove, true) + document.addEventListener("pointerup", onPointerUp, true) } })()` }) try { @@ -839,14 +863,15 @@ test("composer selection waits for the computed diff when its initial paint is d await line.waitFor() await page.waitForFunction(() => Boolean((window as any).releaseDiff)) assert.equal(await page.locator(".git-diff-view .line-insert").count(), 0, "visible model text precedes diff computation") - // Deliver the real worker reply while withholding Monaco's repaint. Native - // pointer hit testing and Playwright's captured animation frames stay live. - // Without the computed-diff wait, the old line opens Copy changed lines. + // Arm reply delivery at the readiness probe or native pointer movement; + // keep stale DOM until the probe or native pointer-up releases painting. + // Native hit testing and Playwright's actionability frames stay live. await page.evaluate(() => (window as any).deferDiffPaint()) await clickModifiedDiffLine(page) await page.keyboard.press("Home") await page.keyboard.press("Shift+End") assert.equal(await page.getByRole("menuitem", { name: "Copy changed lines", exact: true }).count(), 0, "selection never opens the deleted-code menu") + assert.equal(await page.evaluate(() => (window as any).diffPaintReleaseBoundary), "readiness") assert.equal(await page.evaluate(() => (window as any).monaco.editor.getDiffEditors()[0].getModifiedEditor().getSelection().isEmpty()), false) await page.getByRole("button", { name: "Ajouter au prompt", exact: true }).click() assert.match(await page.locator(".session-view textarea.prompt-input").inputValue(), /File: src\/components\/git-panel.tsx : 3-3/)