From e3b12138c6c15460d2e372b0dce1d1cbba491fa9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 11 Oct 2026 03:42:36 -0500 Subject: [PATCH] test: synchronize signoff retries and touch geometry --- tests/e2e/new-task-picker-touch.spec.ts | 36 ++++++++++++++++++++++--- tests/e2e/signoff-policy.spec.ts | 4 +++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/tests/e2e/new-task-picker-touch.spec.ts b/tests/e2e/new-task-picker-touch.spec.ts index b48d7084d6..6804125114 100644 --- a/tests/e2e/new-task-picker-touch.spec.ts +++ b/tests/e2e/new-task-picker-touch.spec.ts @@ -21,13 +21,23 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB }).toBeLessThanOrEqual(page.viewportSize()!.height); expect((await picker.boundingBox())!.y).toBeGreaterThanOrEqual(0); const session = await page.context().newCDPSession(page); + const targetHandle = await last.elementHandle(); + expect(targetHandle).not.toBeNull(); try { expect(await list.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true); for (let attempt = 0; attempt < 20; attempt += 1) { - const bounds = (await list.boundingBox())!; - const target = (await last.boundingBox())!; + // Momentum can finish between protocol round trips. Observe geometry and + // scrollTop together so a stale target cannot trigger a swipe at the end. + const { bounds, target, before } = await list.evaluate((element, option) => { + const container = element.getBoundingClientRect(); + const item = option!.getBoundingClientRect(); + return { + bounds: { x: container.x, y: container.y, width: container.width, height: container.height }, + target: { y: item.y, height: item.height }, + before: element.scrollTop, + }; + }, targetHandle); if (target.y >= bounds.y && target.y + target.height <= bounds.y + bounds.height) break; - const before = await list.evaluate((element) => element.scrollTop); const x = bounds.x + bounds.width / 2; const y = bounds.y + bounds.height - 20; const distance = Math.min(180, bounds.height - 40); @@ -49,6 +59,7 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB expect(target.y).toBeGreaterThanOrEqual(bounds.y); expect(target.y + target.height).toBeLessThanOrEqual(bounds.y + bounds.height + 1); } finally { + await targetHandle?.dispose(); await session.detach(); } return last; @@ -56,6 +67,25 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); +test("touch scroll observation stays valid when momentum finishes between browser reads", async ({ page, browserName }) => { + test.skip(browserName !== "chromium", "Native touch drags use Chromium's input protocol."); + await page.setContent(`
+
+ ${Array.from({ length: 12 }, (_, index) => `
Option ${index}
`).join("")} +
`); + const picker = page.getByRole("dialog"), list = picker.getByRole("listbox"), last = list.getByRole("option").last(); + const boundingBox = last.boundingBox.bind(last); + // Reproduce the observed compositor interleaving: a returned target rectangle + // is stale by the next scrollTop read because inertia reached the list end. + last.boundingBox = async (options) => { + const beforeMomentum = await boundingBox(options); + await list.evaluate((element) => { element.scrollTop = element.scrollHeight - element.clientHeight; }); + return beforeMomentum; + }; + await swipeToLastOption(page, picker, list, last); + await expect(picker).toBeVisible(); +}); + test("new-task assignee, model, and project sheets scroll by touch and retain the selected values", async ({ page, request, browserName }, testInfo) => { test.skip(browserName !== "chromium", "Native touch drags use Chromium's input protocol."); const company = await json(await request.post("/api/companies", { diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts index 37c0056417..da005094f2 100644 --- a/tests/e2e/signoff-policy.spec.ts +++ b/tests/e2e/signoff-policy.spec.ts @@ -309,6 +309,9 @@ async function retryAgentPatchWithCurrentLockOnConflict( if (issueRunLock.assigneeAgentId !== agent.agentId) return res; const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? fallbackRunId; + // A new lock can belong to a newly promoted fixture run, not the completed + // run we first invoked. Let that exact verified run settle before PATCH. + await waitForFixtureRun(board, agent.agentId, issueId, lockedRunId); res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { headers: { "X-Paperclip-Run-Id": lockedRunId }, data: patchData, @@ -365,6 +368,7 @@ async function agentPatch( // run-lock race — leave it for the caller to assert on. if (issueRunLock.assigneeAgentId !== agent.agentId) break; const retryRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? runId; + await waitForFixtureRun(board, agent.agentId, issueId, retryRunId); res = await patchWith(retryRunId); } return res;