Files
PaperClipAI/tests/e2e/new-task-picker-touch.spec.ts
DottaandPaperclip a7a2ed63a0 fix(ui): restore wheel and touch scrolling in task selectors (#15374)
Restore wheel and touch scrolling in nested task selectors and keep mobile picker controls reachable above the keyboard.

Stabilize the affected server route test harnesses for Vitest 5.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-06 15:18:07 -05:00

218 lines
12 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);
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())!;
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);
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 session.detach();
}
return last;
}
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
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");
});
});