mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 16:35:27 +02:00
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>
218 lines
12 KiB
TypeScript
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");
|
|
});
|
|
});
|