Files
PaperClipAI/tests/e2e/new-task-picker-touch.spec.ts
T
DottaandPaperclip 88b7bbb5e2 Stabilize task lifecycle browser fixtures (#15878)
## Thinking Path

> - Paperclip manages agents and their tasks.
> - Browser and runner tests check task recovery and user actions.
> - Some fixtures act before a prior request or run has settled.
> - Those races can report a product failure before the intended
assertion runs.
> - This PR binds each check to the exact request, run, or geometry
snapshot it needs.
> - The tests retain their existing budgets and negative assertions.

## Linked Issues or Issue Description

Refs: #15813, #15804.

The draft stack is master → [fixture prerequisite
#15878](https://github.com/paperclipai/paperclip/pull/15878) → [runtime
prerequisite
#15880](https://github.com/paperclipai/paperclip/pull/15880) → [Boat
core #15813](https://github.com/paperclipai/paperclip/pull/15813) → [UI
#15804](https://github.com/paperclipai/paperclip/pull/15804). GitHub
confirms 5, 63, 97, and 75 changed files respectively, each below the
100-file limit. Existing commits and PR URLs remain intact. [Historical
evidence](https://github.com/paperclipai/paperclip/tree/5909bfac9fa41405beb3fe8a65253dc28ba52835/doc/plans/assets/2026-10-10-boat)
remains pinned to its original commit; current proof files stay with the
UI PR.

This is the test-only prerequisite extracted from the experimental Boat
work. It contains five existing test-file changes. It does not add the
Boat adapter or change task admission.

## What Changed

- Wait for the exact signoff fixture run and each replacement run to
succeed before applying the next stage decision.
- Wait for the inbox Retry response for the exact failed run before
navigating away.
- Compare task planning time with the first run that actually started,
instead of assuming the first list item started.
- Read the touch target, container, and scroll position in one browser
evaluation. Keep strict containment and native gesture checks.
- Bind the native recovery fixture to its disposable fake Codex
executable so installed provider binaries cannot override it.

## Verification

Draft branch: `codex/boat-fixture-prerequisite`, commit
`e318850909c0777565b43644a4d8114c6006da60`. Base: master
`e733bddc45e00efb1819fffa8677f7b0d07802c5`. Five changed files.

The isolated fixture-plus-runtime stack passed 24 browser cases. Six
legacy recovery cases could not reach their assertions because the
fixture requires embedded PostgreSQL metadata. A normal embedded
PostgreSQL attempt then failed during cluster setup on this host. These
are not six passing cases. No test seam, timeout increase, or
product-database fixture was used.

The fixture-only commit has not yet had a separate complete local
browser run. All six legacy cases subsequently passed on this exact
fixture commit in PR CI browser shard 8 (20/20 total, job114470999277).
The original local setup failures remain recorded. The full-stack
typecheck/build results belong to the assembled fixture-plus-runtime
tree and are not claimed as independent qualification of this smaller
commit.

Review the exact run/request filters and the unchanged rejection
assertions. Run the four changed Playwright specs and the
native-session-resume suite through the repository's normal fixture
setup. At 13:03:24 UTC, this exact head had 53 successful checks and two
skips, with no pending checks or failures. All eight browser shards
passed. Greptile is 5/5 on the exact head with no unresolved threads.

[Exact-head legacy browser
proof](https://github.com/paperclipai/paperclip/blob/9eacca2c5321bb63f061eef4bb775b6951d5846d/doc/plans/assets/2026-10-10-boat/evidence/prerequisite-legacy-browser-ci-proof.json).

Feature acceptance stays in the downstream report. Deployment29 verified
`05f3725af838b5a074707df1d6ed1e193bdfe49e` at 12:23:23.944 UTC. Native
Claude cold execution passed at 12:39:57.782 after 14m56s of first-use
preparation. Warm continuation passed at 12:42:09.943 with the same
owner, boot and session identities. Normal retirement and archive
followed. The ordinary saved post-idle turn then failed: its primary
attempt reported the closed `LINK_COUNT` module fence at
`turn_ownership_verified` (3,711ms), and same-run recovery settled
failed at 12:52:09 with identity/authority errors. Natural archive
completed at 12:52:21. Numeric link counts and their direction are not
yet proven. A narrower failure-only diagnostic and a bounded read-only
probe are under review; neither establishes a fix for this live failure.
Prior staging27/28 failures remain preserved. Local complete lifecycle
passes and staging Codex/computer/HMR passes remain separate
source-bound evidence. No session, quarantine, provider stop or
migration history was reset. These live observations do not qualify this
prerequisite by themselves.

## Risks

- Waiting for the wrong run could hide a task race. The fixtures match
task, agent, and replacement-run identity before proceeding.
- A geometry helper could weaken touch coverage. This change preserves
containment and gesture assertions and only makes their inputs one
snapshot.
- The six legacy browser cases passed in exact-head CI; their local
PostgreSQL setup limitation remains recorded. No live Boat acceptance
result follows from this test-only change.

Documentation impact: no product command or behavior changes. This
description records fixture scope and validation limits.

## Model Used

OpenAI Codex (GPT-6 family) assisted with code, test execution, and
review. The host does not expose the exact primary model ID or context
window. The work used tools for source inspection and local execution.
Prior architecture work used `gpt-6-astra`; an independent design review
used `gpt-6.1-sol`.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal issue id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-12 07:29:15 -05:00

248 lines
14 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { expect, test, type APIResponse, type Locator, type Page } from "@playwright/test";
async function json(response: APIResponse) {
expect(response.ok(), await response.text()).toBe(true);
return response.json();
}
async function swipeToLastOption(page: Page, picker: Locator, list = picker.getByRole("listbox"), last = list.getByRole("option").last()) {
// The shared picker animates its height between views. Native touch
// coordinates must use the settled sheet rather than its clipped first frame.
await expect.poll(() => picker.evaluate((element) => {
const body = element.querySelector(".composer-run-settings-height");
return [...element.getAnimations(), ...(body?.getAnimations() ?? [])]
.every((animation) => animation.playState !== "running");
})).toBe(true);
// visualViewport resize updates React state after the browser viewport changes.
await expect.poll(async () => {
const bounds = (await picker.boundingBox())!;
return bounds.y + bounds.height;
}).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) {
// 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 x = bounds.x + bounds.width / 2;
const y = bounds.y + bounds.height - 20;
const distance = Math.min(180, bounds.height - 40);
await session.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 12; step += 1) {
await session.send("Input.dispatchTouchEvent", {
type: "touchMove", touchPoints: [{ x, y: y - distance * step / 12 }],
});
// Pace native finger movement across compositor frames.
await page.waitForTimeout(20);
}
await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await expect.poll(() => list.evaluate((element) => element.scrollTop)).toBeGreaterThan(before);
// A drag must not choose a row or dismiss the picker.
await expect(picker).toBeVisible();
}
const bounds = (await list.boundingBox())!;
const target = (await last.boundingBox())!;
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;
}
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(`<div role="dialog" style="position:absolute;top:20px;left:20px;width:300px">
<div role="listbox" style="height:150px;overflow-y:auto;touch-action:pan-y">
${Array.from({ length: 12 }, (_, index) => `<div role="option" style="height:40px">Option ${index}</div>`).join("")}
</div></div>`);
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", {
data: { name: `Touch pickers ${randomUUID()}` },
}));
for (let index = 1; index <= 22; index += 1) {
await json(await request.post(`/api/companies/${company.id}/agents`, {
data: {
name: `Touch Agent ${String(index).padStart(2, "0")}`, role: "engineer",
adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" },
runtimeConfig: { heartbeat: { enabled: false } },
},
}));
await json(await request.post(`/api/companies/${company.id}/projects`, {
data: { name: `Touch Project ${String(index).padStart(2, "0")}` },
}));
}
// Keep the provider catalog deterministic; task UI, agents, and drafts are real.
await page.route(`**/api/companies/${company.id}/adapters/codex_local/models*`, (route) => route.fulfill({
json: Array.from({ length: 24 }, (_, index) => ({
id: `touch-model-${String(index + 1).padStart(2, "0")}`,
label: `Touch Model ${String(index + 1).padStart(2, "0")}`,
})),
}));
await page.goto(`/${company.issuePrefix}/dashboard`);
await page.getByRole("navigation", { name: "Mobile navigation" }).getByRole("button", { name: "New Task", exact: true }).tap();
const draft = page.getByRole("textbox", { name: "editable markdown" });
await draft.fill("Keep this touch selection draft");
const assigneeTrigger = page.getByRole("button", { name: "Select assignee", exact: true });
const modelTrigger = page.getByRole("button", { name: "Select model and effort", exact: true });
await assigneeTrigger.tap();
const assigneePicker = page.getByRole("dialog", { name: "Select assignee", exact: true });
const lastAssignee = await swipeToLastOption(page, assigneePicker);
await expect(lastAssignee).toContainText("Touch Agent 22");
await lastAssignee.tap();
await expect(assigneePicker).toBeHidden();
await expect(assigneeTrigger).toBeFocused();
await modelTrigger.tap();
const picker = page.getByRole("dialog", { name: "Select model and effort", exact: true });
// The settings view must keep its controls stretched across the mobile sheet.
await expect.poll(async () => {
const sheet = (await picker.boundingBox())!;
const control = (await picker.getByRole("button", { name: "Choose exact model" }).boundingBox())!;
return control.width / sheet.width;
}).toBeGreaterThan(0.8);
// Short screens must also let a finger reach the settings below the fold.
const effort = picker.getByRole("slider", { name: "Effort" });
const effortBeforeScroll = await effort.inputValue();
await page.setViewportSize({ width: 390, height: 200 });
await swipeToLastOption(page, picker, picker.getByTestId("composer-run-settings-view"), effort);
await expect(effort).toHaveValue(effortBeforeScroll);
await page.screenshot({ path: testInfo.outputPath("settings-after-touch-scroll.png") });
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole("button", { name: "Choose exact model" }).tap();
await page.screenshot({ path: testInfo.outputPath("model-before-keyboard.png") });
// A reduced viewport exercises the space available when a phone keyboard opens.
await page.setViewportSize({ width: 390, height: 430 });
const lastModel = await swipeToLastOption(page, picker);
await expect(lastModel).toContainText("Touch Model 24");
await page.screenshot({ path: testInfo.outputPath("model-after-touch-scroll.png") });
await lastModel.tap();
await page.getByRole("button", { name: "Close picker" }).tap();
await page.setViewportSize({ width: 390, height: 844 });
await expect(assigneeTrigger).toContainText("Touch Agent 22");
await expect(modelTrigger).toContainText("Touch Model 24");
await expect(draft).toHaveText("Keep this touch selection draft");
const projectTrigger = page.getByRole("button", { name: "Project", exact: true });
await projectTrigger.tap();
const projectPicker = page.getByRole("dialog", { name: "Select project", exact: true });
await page.setViewportSize({ width: 390, height: 430 });
const projectList = projectPicker.locator("[data-mobile-entity-picker-list]");
const lastProject = await swipeToLastOption(page, projectPicker, projectList, projectList.getByRole("button").filter({ hasText: "Touch Project 22" }));
await page.screenshot({ path: testInfo.outputPath("project-after-touch-scroll.png") });
await lastProject.tap();
await expect(projectPicker).toBeHidden();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('[data-slot="new-issue-compact-control"]').filter({ hasText: "Touch Project 22" })).toBeVisible();
await expect(draft).toHaveText("Keep this touch selection draft");
// Closing the nested picker preserves the outer composer draft and choices.
await modelTrigger.tap();
await page.getByRole("button", { name: "Choose exact model" }).tap();
await page.getByRole("searchbox", { name: "Search or paste a model ID" }).press("Escape");
await expect(picker).toBeHidden();
await expect(assigneeTrigger).toBeFocused();
await expect(page.getByRole("button", { name: "Create task", exact: true })).toBeEnabled();
await page.screenshot({ path: testInfo.outputPath("selected-mobile-picker-values.png") });
// The desktop picker uses the same selected values and supports model search.
await page.setViewportSize({ width: 1280, height: 900 });
await modelTrigger.click();
await page.getByRole("button", { name: "Choose exact model" }).click();
await page.getByRole("searchbox", { name: "Search or paste a model ID" }).fill("Touch Model 01");
await page.getByRole("option", { name: "Touch Model 01 touch-model-01", exact: true }).click();
await page.getByRole("button", { name: "Choose exact model" }).press("Escape");
await expect(modelTrigger).toContainText("Touch Model 01");
});
test.describe("desktop picker scrolling", () => {
test.use({ viewport: { width: 1280, height: 900 }, hasTouch: false, isMobile: false });
test("mouse wheels scroll nested model, assignee, and project lists without losing the draft", async ({ page, request }) => {
const company = await json(await request.post("/api/companies", {
data: { name: `Wheel pickers ${randomUUID()}` },
}));
for (let index = 1; index <= 22; index += 1) {
await json(await request.post(`/api/companies/${company.id}/agents`, {
data: {
name: `Wheel Agent ${String(index).padStart(2, "0")}`, role: "engineer",
adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" },
runtimeConfig: { heartbeat: { enabled: false } },
},
}));
await json(await request.post(`/api/companies/${company.id}/projects`, {
data: { name: `Wheel Project ${String(index).padStart(2, "0")}` },
}));
}
await page.route(`**/api/companies/${company.id}/adapters/codex_local/models*`, (route) => route.fulfill({
json: Array.from({ length: 24 }, (_, index) => ({
id: `wheel-model-${String(index + 1).padStart(2, "0")}`,
label: `Wheel Model ${String(index + 1).padStart(2, "0")}`,
})),
}));
await page.goto(`/${company.issuePrefix}/dashboard`);
await page.getByRole("button", { name: "New Task", exact: true }).click();
const draft = page.getByRole("textbox", { name: "editable markdown" });
await draft.fill("Keep this mouse wheel selection draft");
const wheelToBottom = async (list: Locator) => {
await expect.poll(() => list.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
const before = await list.evaluate((element) => element.scrollTop);
await list.hover();
await page.mouse.wheel(0, 2000);
await expect.poll(() => list.evaluate((element) => element.scrollTop)).toBeGreaterThan(before);
};
const assigneeTrigger = page.getByRole("button", { name: "Select assignee", exact: true });
const modelTrigger = page.getByRole("button", { name: "Select model and effort", exact: true });
await assigneeTrigger.click();
await wheelToBottom(page.getByRole("listbox", { name: "Assignees", exact: true }));
await page.getByRole("option").filter({ hasText: "Wheel Agent 22" }).click();
await expect(assigneeTrigger).toContainText("Wheel Agent 22");
await modelTrigger.click();
await page.getByRole("button", { name: "Choose exact model" }).click();
await wheelToBottom(page.getByRole("listbox", { name: "Models", exact: true }));
await page.getByRole("option").filter({ hasText: "Wheel Model 24" }).click();
await page.getByRole("button", { name: "Choose exact model" }).press("Escape");
await expect(modelTrigger).toContainText("Wheel Model 24");
await page.getByRole("button", { name: "Project", exact: true }).click();
const projectPicker = page.getByRole("dialog", { name: "Select project", exact: true });
await wheelToBottom(projectPicker.locator("[data-mobile-entity-picker-list]"));
await projectPicker.getByRole("button").filter({ hasText: "Wheel Project 22" }).click();
await expect(projectPicker).toBeHidden();
await expect(page.locator('[data-slot="new-issue-compact-control"]').filter({ hasText: "Wheel Project 22" })).toBeVisible();
await expect(draft).toHaveText("Keep this mouse wheel selection draft");
});
});