diff --git a/dev-docs/BROWSER_TESTING.md b/dev-docs/BROWSER_TESTING.md index 740b958cf..8708ab6f5 100644 --- a/dev-docs/BROWSER_TESTING.md +++ b/dev-docs/BROWSER_TESTING.md @@ -84,3 +84,11 @@ 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. + 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 3cc9d81f0..93be448bf 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.waitForFunction(() => document.querySelector(".git-diff-view .line-insert")) + 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,92 @@ 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() + 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 = 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) + } + // 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 { + 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") + // 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/) + } 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 +890,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 +924,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 +936,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 +954,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()