Files
kandev/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts
T
2026-09-06 17:18:09 +01:00

745 lines
32 KiB
TypeScript

/**
* Mobile parity for the sidebar view system (filters / sort / group + saved
* views).
*
* The mobile task-switcher sheet exposes a saved-view chip row, fixed direct
* creation action, and filter gear backed by the shared `SidebarFilterPopover`.
* The desktop AppSidebar uses a separate dropdown picker for the same state.
*
* Lives in `mobile-*.spec.ts` so the `mobile-chrome` Playwright project applies
* the mobile device automatically.
*/
import { test, expect, type SeedData } from "../../fixtures/test-base";
import type { Page, Locator } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import { dwell } from "../../helpers/causal-waits";
import { SessionPage } from "../../pages/session-page";
import {
repositoryName,
seedSidebarCombinationRepository,
SIDEBAR_COMBINATION_REPOSITORY_NAME,
} from "./sidebar-repository-grouping-helpers";
async function seedAndOpenSheet(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
titles: string[],
): Promise<Locator> {
const stepOpts = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
for (const title of titles) {
await apiClient.seedTask(seedData.workspaceId, title, stepOpts);
}
// The nav task is seeded (not createTask) so it has a primary session and the
// mobile chat panel renders — `session.waitForLoad()` gates on session-chat.
const navTask = await apiClient.seedTask(seedData.workspaceId, "Mobile Views Nav", stepOpts);
await testPage.goto(`/t/${navTask.task_id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Open the task-switcher sheet from the mobile session top bar.
await testPage.getByTestId("mobile-session-menu").click();
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
await expect(sheet.getByTestId("sidebar-filter-bar")).toBeVisible({ timeout: 10_000 });
return sheet;
}
/** Add a "Title contains <value>" filter clause via the (portaled) popover. */
async function addTitleFilter(testPage: Page, sheet: Locator, value: string): Promise<void> {
await sheet.getByTestId("sidebar-filter-gear").click();
const popover = testPage.getByTestId("sidebar-filter-popover");
await expect(popover).toBeVisible();
await popover.getByTestId("filter-add-button").click();
await popover.getByTestId("filter-dimension-select").click();
// Radix Select portals options to document.body (not under `popover`), so we
// can't scope to the popover here; `.first()` is the deliberate convention for
// this case (see apps/web/AGENTS.md). Only one select is open at a time.
await testPage.getByRole("option", { name: "Title", exact: false }).first().click();
await popover.getByTestId("filter-value-input").fill(value);
}
async function taskRowOrder(sheet: Locator, taskIds: string[]) {
const rows = await sheet.locator("[data-task-row-id]").all();
const order: string[] = [];
for (const row of rows) {
const id = await row.getAttribute("data-task-row-id");
if (id && taskIds.includes(id)) order.push(id);
}
return order;
}
async function touchDrag(page: Page, source: Locator, target: Locator): Promise<void> {
const sourceBox = await source.boundingBox();
const targetBox = await target.boundingBox();
expect(sourceBox).not.toBeNull();
expect(targetBox).not.toBeNull();
const client = await page.context().newCDPSession(page);
const start = {
x: sourceBox!.x + sourceBox!.width / 2,
y: sourceBox!.y + sourceBox!.height / 2,
};
const end = {
x: targetBox!.x + targetBox!.width / 2,
y: targetBox!.y + targetBox!.height / 2,
};
await client.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ id: 1, x: start.x, y: start.y }],
});
await dwell(300, "library-timer", "dnd-kit TouchSensor waits 250ms before activating");
for (let step = 1; step <= 12; step += 1) {
const progress = step / 12;
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [
{
id: 1,
x: start.x + (end.x - start.x) * progress,
y: start.y + (end.y - start.y) * progress,
},
],
});
}
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await client.detach();
}
test.describe("Mobile sidebar — view system", () => {
test("multi-repository group shows the complete ordered label", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
const secondRepository = await seedSidebarCombinationRepository(
apiClient,
seedData.workspaceId,
backend.tmpDir,
);
const primaryRepositoryName = await repositoryName(
apiClient,
seedData.workspaceId,
seedData.repositoryId,
);
const expectedLabel = `${primaryRepositoryName}, ${SIDEBAR_COMBINATION_REPOSITORY_NAME}`;
const multiRepositoryTask = await apiClient.createTask(
seedData.workspaceId,
"Mobile multi-repository task",
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repositories: [
{ repository_id: seedData.repositoryId },
{ repository_id: secondRepository.id },
],
},
);
await apiClient.createTask(seedData.workspaceId, "Mobile primary repository task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, []);
const combinationHeader = sheet.locator(
`[data-testid="sidebar-group-header"][data-group-label="${expectedLabel}"]`,
);
await expect(combinationHeader).toHaveCount(1);
const combinationGroup = combinationHeader.locator("..");
await expect(
combinationGroup.locator(`[data-task-row-id="${multiRepositoryTask.id}"]`),
).toBeVisible();
const primaryGroup = sheet
.locator(`[data-testid="sidebar-group-header"][data-group-key="${primaryRepositoryName}"]`)
.locator("..");
await expect(primaryGroup).toHaveCount(1);
await expect(
primaryGroup.locator(`[data-task-row-id="${multiRepositoryTask.id}"]`),
).toHaveCount(0);
await combinationHeader.scrollIntoViewIfNeeded();
await prCapture.screenshot("multi-repository-group-mobile", {
caption: "Mobile task drawer uses the same complete repository combination group.",
});
});
test("direct creation stays touch-reachable, viewport-safe, and persists", async ({
testPage,
apiClient,
seedData,
}) => {
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, ["Mobile New View Task"]);
const newView = sheet.getByTestId("sidebar-new-view");
const gear = sheet.getByTestId("sidebar-filter-gear");
await expect(newView).toBeVisible();
await expect(newView).toContainText("New view");
const newViewBox = await newView.boundingBox();
const gearBox = await gear.boundingBox();
const chipRowBox = await sheet.getByTestId("sidebar-view-chip-row").boundingBox();
expect(newViewBox?.height).toBeGreaterThanOrEqual(40);
expect(gearBox?.height).toBeGreaterThanOrEqual(40);
expect(chipRowBox!.x + chipRowBox!.width).toBeLessThanOrEqual(newViewBox!.x);
expect(newViewBox!.x + newViewBox!.width).toBeLessThanOrEqual(gearBox!.x);
await expect(
sheet.getByTestId("sidebar-view-chip-row").getByTestId("sidebar-new-view"),
).toHaveCount(0);
await newView.click();
const popover = testPage.getByTestId("sidebar-filter-popover");
const renameInput = popover.getByTestId("view-rename-input");
await expect(popover).toBeVisible();
await expect(renameInput).toBeFocused();
await expect(renameInput).toHaveValue("New view");
const popoverBox = await popover.boundingBox();
const viewport = testPage.viewportSize();
expect(popoverBox).not.toBeNull();
expect(viewport).not.toBeNull();
expect(popoverBox!.x).toBeGreaterThanOrEqual(0);
expect(popoverBox!.x + popoverBox!.width).toBeLessThanOrEqual(viewport!.width);
await popover.getByRole("button", { name: "Cancel" }).click();
await testPage.keyboard.press("Escape");
await expect(
sheet.getByTestId("sidebar-view-chip-row").getByTestId("sidebar-view-chip").filter({
hasText: "New view",
}),
).toHaveAttribute("data-active", "true");
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await expect(sheet.getByTestId("mobile-task-switcher-list")).toHaveCSS("overflow-y", "auto");
await expect
.poll(async () => {
const { settings } = await apiClient.getUserSettings();
return (settings.sidebar_views as Array<{ name?: string }> | undefined)?.some(
(view) => view.name === "New view",
);
})
.toBe(true);
await testPage.reload();
await new SessionPage(testPage).waitForLoad();
await testPage.getByTestId("mobile-session-menu").click();
const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" });
await expect(
reloadedSheet
.getByTestId("sidebar-view-chip-row")
.getByTestId("sidebar-view-chip")
.filter({ hasText: "New view" }),
).toHaveAttribute("data-active", "true");
});
test("editing filters in the mobile sheet narrows the task list live", async ({
testPage,
apiClient,
seedData,
}) => {
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, [
"Fix auth bug",
"Update deps",
"Refactor auth",
]);
// All seeded tasks visible before filtering.
await expect(sheet.getByText("Fix auth bug")).toBeVisible({ timeout: 10_000 });
await expect(sheet.getByText("Update deps")).toBeVisible();
await addTitleFilter(testPage, sheet, "auth");
const filterEditor = testPage.getByTestId("sidebar-filter-popover");
// Draft is active — the gear shows its unsaved indicator. Scope to the sheet:
// the globally-mounted (hidden on mobile) AppSidebar TasksViewPicker renders
// the same testid, so a page-level query is a strict-mode collision.
await expect(sheet.getByTestId("sidebar-filter-gear-indicator")).toBeVisible();
const blockedNewView = sheet.getByTestId("sidebar-new-view");
await expect(blockedNewView).toHaveAttribute("aria-disabled", "true");
await expect(blockedNewView).toHaveAttribute("aria-label", /save or discard changes/i);
// The filter editor is a modal drawer on coarse pointers. Close it before
// invoking the fixed-bar action underneath it.
await testPage.keyboard.press("Escape");
await expect(filterEditor).toBeHidden();
// aria-disabled communicates the blocked state, but this action remains
// clickable so touch/keyboard users can get the concrete reason toast.
await blockedNewView.click({ force: true });
await expect(
testPage.getByText("Save or discard changes before creating a new view."),
).toBeVisible();
await testPage.keyboard.press("Escape");
// The list inside the sheet re-filters live via applyView.
await expect(sheet.getByText("Fix auth bug")).toBeVisible();
await expect(sheet.getByText("Refactor auth")).toBeVisible();
await expect(sheet.getByText("Update deps")).toHaveCount(0);
});
test("offers archived as a filter dimension and loads archived rows", async ({
testPage,
apiClient,
seedData,
}) => {
const archivedTask = await apiClient.seedTask(seedData.workspaceId, "Mobile archived filter", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.archiveTask(archivedTask.task_id);
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, ["Mobile filter options"]);
await sheet.getByTestId("sidebar-filter-gear").click();
const popover = testPage.getByTestId("sidebar-filter-popover");
await expect(popover).toBeVisible();
await popover.getByTestId("filter-add-button").click();
await popover.getByTestId("filter-dimension-select").click();
await expect(testPage.getByRole("option", { name: "Archived", exact: true })).toBeVisible();
await testPage.getByRole("option", { name: "Archived", exact: true }).click();
const viewport = testPage.viewportSize();
const sheetBox = await sheet.boundingBox();
const popoverBox = await popover.boundingBox();
expect(viewport).not.toBeNull();
expect(sheetBox).not.toBeNull();
expect(popoverBox).not.toBeNull();
for (const box of [sheetBox!, popoverBox!]) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport!.width);
expect(box.y + box.height).toBeLessThanOrEqual(viewport!.height);
}
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await testPage.keyboard.press("Escape");
await expect(sheet.getByText("Mobile archived filter")).toBeVisible({ timeout: 10_000 });
await expect(sheet.getByText("Archived", { exact: true })).toBeVisible();
await expect(sheet.getByText("Mobile filter options")).toHaveCount(0);
await sheet.getByText("Mobile archived filter").click();
await expect(testPage).toHaveURL((url) => url.pathname === `/t/${archivedTask.task_id}`);
// The mobile detail header uses the session top bar; desktop renders the
// unarchive button directly in its task top bar.
await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible({ timeout: 10_000 });
await expect(
testPage
.getByTestId("mobile-task-layout")
.getByText("Mobile archived filter", { exact: true }),
).toBeVisible();
});
test("switching saved views swaps the filtered list in the sheet", async ({
testPage,
apiClient,
seedData,
}) => {
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, [
"Fix auth bug",
"Update deps",
]);
// Build a saved "Auth View" that only keeps auth tasks.
await addTitleFilter(testPage, sheet, "auth");
const popover = testPage.getByTestId("sidebar-filter-popover");
await popover.getByTestId("view-save-as-button").click();
await popover.getByTestId("view-save-as-name-input").fill("Auth View");
await popover.getByTestId("view-save-as-confirm").click();
await testPage.keyboard.press("Escape");
const chipRow = sheet.getByTestId("sidebar-view-chip-row");
// Auth View is active; non-auth task hidden.
await expect(
chipRow.getByTestId("sidebar-view-chip").filter({ hasText: "Auth View" }),
).toHaveAttribute("data-active", "true");
await expect(sheet.getByText("Update deps")).toHaveCount(0);
// Switch back to the default "All tasks" chip — full list returns.
await chipRow.getByTestId("sidebar-view-chip").filter({ hasText: "All tasks" }).click();
await expect(sheet.getByText("Update deps")).toBeVisible();
await expect(sheet.getByText("Fix auth bug")).toBeVisible();
});
test("mobile last activity sort is touch reachable, persistent, and contained", async ({
testPage,
apiClient,
seedData,
}) => {
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, [
"Mobile activity old",
"Mobile activity new",
]);
const listed = await apiClient.listTasks(seedData.workspaceId);
const oldTask = listed.tasks.find((task) => task.title === "Mobile activity old");
const newTask = listed.tasks.find((task) => task.title === "Mobile activity new");
expect(oldTask?.id).toBeTruthy();
expect(newTask?.id).toBeTruthy();
await apiClient.updateTaskTitle(oldTask!.id, "Mobile activity old touched");
const gear = sheet.getByTestId("sidebar-filter-gear");
await gear.tap();
const popover = testPage.getByTestId("sidebar-filter-popover");
await expect(popover).toBeVisible();
await popover.getByTestId("sidebar-sort-settings-toggle").tap();
await popover.getByTestId("sort-key-select").tap();
await expect(testPage.getByRole("option", { name: "Updated", exact: true })).toContainText(
"Last task summary refresh. Background events can change it.",
);
await expect(
testPage.getByRole("option", { name: "Last activity", exact: true }),
).toContainText("Last user or agent action. Viewing a task does not change it.");
await expect(testPage.getByRole("option", { name: "Status", exact: true })).toContainText(
"Task state, from review to backlog.",
);
await expect(testPage.getByRole("option", { name: "Created", exact: true })).toContainText(
"When the task was created.",
);
await expect(testPage.getByRole("option", { name: "Title", exact: true })).toContainText(
"Task title in alphabetical order.",
);
await expect(testPage.getByRole("option", { name: "Custom", exact: true })).toContainText(
"The manual order you set for tasks.",
);
for (const { label, description } of [
{
label: "Updated",
description: "Last task summary refresh. Background events can change it.",
},
{
label: "Last activity",
description: "Last user or agent action. Viewing a task does not change it.",
},
{ label: "Status", description: "Task state, from review to backlog." },
{ label: "Created", description: "When the task was created." },
{ label: "Title", description: "Task title in alphabetical order." },
{ label: "Custom", description: "The manual order you set for tasks." },
]) {
const option = testPage.getByRole("option", { name: label, exact: true });
const descriptionId = await option.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
await expect(testPage.locator(`[id="${descriptionId}"]`)).toHaveText(description);
}
await testPage.getByRole("option", { name: "Last activity", exact: true }).tap();
const direction = popover.getByTestId("sort-direction-toggle");
if ((await direction.getAttribute("data-direction")) !== "desc") await direction.tap();
await popover.getByTestId("view-save-as-button").tap();
await popover.getByTestId("view-save-as-name-input").fill("Mobile last activity");
await popover.getByTestId("view-save-as-confirm").tap();
await testPage.keyboard.press("Escape");
const taskIds = [oldTask!.id, newTask!.id];
await expect.poll(() => taskRowOrder(sheet, taskIds)).toEqual(taskIds);
const viewport = testPage.viewportSize();
const list = sheet.getByTestId("mobile-task-switcher-list");
const sheetBox = await sheet.boundingBox();
const listBox = await list.boundingBox();
expect(viewport).not.toBeNull();
expect(sheetBox).not.toBeNull();
expect(listBox).not.toBeNull();
expect(listBox!.x).toBeGreaterThanOrEqual(sheetBox!.x);
expect(listBox!.x + listBox!.width).toBeLessThanOrEqual(sheetBox!.x + sheetBox!.width);
await expect(list).toHaveCSS("overflow-y", "auto");
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await testPage.reload();
await new SessionPage(testPage).waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" });
await expect(
reloadedSheet.getByTestId("sidebar-view-chip").filter({ hasText: "Mobile last activity" }),
).toHaveAttribute("data-active", "true");
await expect.poll(() => taskRowOrder(reloadedSheet, taskIds)).toEqual(taskIds);
});
test("keeps mobile section labels clear of separator lines", async ({
testPage,
apiClient,
seedData,
}) => {
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, [
"Mobile separator spacing",
]);
await sheet.getByTestId("sidebar-filter-gear").click();
const popover = testPage.getByTestId("sidebar-filter-popover");
await expect(popover).toBeVisible();
for (const { label, sectionLevel } of [
{ label: "Filters", sectionLevel: 2 },
{ label: "Sort", sectionLevel: 1 },
{ label: "Group by", sectionLevel: 1 },
]) {
const heading = popover.getByText(label, { exact: true });
const section =
sectionLevel === 2 ? heading.locator("..").locator("..") : heading.locator("..");
const [headingBox, sectionBox] = await Promise.all([
heading.boundingBox(),
section.boundingBox(),
]);
expect(headingBox).not.toBeNull();
expect(sectionBox).not.toBeNull();
expect(headingBox!.y).toBeGreaterThan(sectionBox!.y + 3);
}
});
test("keeps mobile section separators inside the drawer surface", async ({
testPage,
apiClient,
seedData,
}) => {
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, [
"Mobile separator containment",
]);
await sheet.getByTestId("sidebar-filter-gear").tap();
const drawer = testPage.getByTestId("sidebar-filter-drawer");
const popover = testPage.getByTestId("sidebar-filter-popover");
await expect(drawer).toBeVisible();
await expect(popover).toBeVisible();
const [drawerBox, popoverBox] = await Promise.all([
drawer.boundingBox(),
popover.boundingBox(),
]);
expect(drawerBox).not.toBeNull();
expect(popoverBox).not.toBeNull();
expect(popoverBox!.x).toBeGreaterThan(drawerBox!.x);
expect(popoverBox!.x + popoverBox!.width).toBeLessThan(drawerBox!.x + drawerBox!.width);
});
test("task row settings use the drawer, touch targets, and persisted preview", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const taskTitle = "Mobile task row layout";
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, [taskTitle]);
const listedTasks = await apiClient.listTasks(seedData.workspaceId);
const task = listedTasks.tasks.find((candidate) => candidate.title === taskTitle);
expect(task?.id).toBeTruthy();
const gear = sheet.getByTestId("sidebar-filter-gear");
await gear.tap();
const drawer = testPage.getByTestId("sidebar-filter-drawer");
const popover = testPage.getByTestId("sidebar-filter-popover");
await expect(drawer).toBeVisible();
await expect(popover).toBeVisible();
await popover.getByTestId("sidebar-group-settings-toggle").tap();
await popover.getByTestId("group-key-select").tap();
for (const { label, description } of [
{ label: "None", description: "Keep all tasks in one list." },
{ label: "Repository", description: "Separate tasks by repository." },
{ label: "Workflow", description: "Separate tasks by workflow." },
{ label: "Workflow step", description: "Separate tasks by workflow step." },
{ label: "Executor type", description: "Separate tasks by executor type." },
{ label: "State", description: "Separate tasks by state." },
]) {
const option = testPage.getByRole("option", { name: label, exact: true });
const descriptionId = await option.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
await expect(testPage.locator(`[id="${descriptionId}"]`)).toHaveText(description);
}
await testPage.getByRole("option", { name: "Repository", exact: true }).tap();
const settings = popover.getByTestId("task-row-settings");
await expect(settings.getByTestId("task-row-details-toggle")).toHaveCount(0);
await settings.getByTestId("task-row-settings-toggle").tap();
await expect(settings.getByTestId("task-row-details-toggle")).toBeVisible();
await settings.getByTestId("task-row-trailing-select").tap();
for (const { label, description } of [
{ label: "Git changes", description: "Show added and removed lines." },
{ label: "Relative time", description: "Show when the task was last updated." },
{
label: "Change request status",
description: "Show the pull request or merge request status.",
},
{ label: "Nothing", description: "Leave the right side empty." },
]) {
const option = testPage.getByRole("option", { name: label, exact: true });
const descriptionId = await option.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
await expect(testPage.locator(`[id="${descriptionId}"]`)).toHaveText(description);
}
await testPage.getByRole("option", { name: "Git changes", exact: true }).tap();
for (const control of [
"task-row-details-toggle",
"task-row-detail-handle-relative_time",
"task-row-detail-toggle-relative_time",
]) {
const box = await settings.getByTestId(control).boundingBox();
expect(box?.height).toBeGreaterThanOrEqual(40);
expect(box?.width).toBeGreaterThanOrEqual(40);
}
const trailingSelectBox = await settings.getByTestId("task-row-trailing-select").boundingBox();
expect(trailingSelectBox).not.toBeNull();
expect(trailingSelectBox!.height).toBeGreaterThanOrEqual(44);
const pullRequestHandle = settings.getByTestId("task-row-detail-handle-pull_request_number");
const relativeTimeHandle = settings.getByTestId("task-row-detail-handle-relative_time");
await touchDrag(testPage, pullRequestHandle, relativeTimeHandle);
await expect
.poll(async () =>
settings
.locator("div[data-testid^='task-row-detail-']")
.evaluateAll((rows) =>
rows.map((row) => row.getAttribute("data-testid")?.replace("task-row-detail-", "")),
),
)
.toEqual(["pull_request_number", "relative_time", "repository"]);
await settings.getByTestId("task-row-detail-toggle-repository").tap();
await settings.getByTestId("task-row-details-toggle").tap();
const compactRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: taskTitle });
const compactRowBox = await compactRow.boundingBox();
const compactTitleBox = await compactRow
.getByText(taskTitle, { exact: true })
.first()
.boundingBox();
expect(compactRowBox).not.toBeNull();
expect(compactTitleBox).not.toBeNull();
expect(
Math.abs(
compactTitleBox!.y +
compactTitleBox!.height / 2 -
(compactRowBox!.y + compactRowBox!.height / 2),
),
).toBeLessThanOrEqual(1);
await settings.getByTestId("task-row-trailing-select").tap();
await testPage.getByRole("option", { name: "Relative time", exact: true }).tap();
await popover.getByTestId("view-save-as-button").tap();
await popover.getByTestId("view-save-as-name-input").fill("Mobile task rows");
await popover.getByTestId("view-save-as-confirm").tap();
const row = compactRow;
await expect(row).toBeVisible();
const trailingTime = row.getByTestId("sidebar-task-trailing-time");
await expect(trailingTime).toBeVisible();
await expect(row.getByTestId("sidebar-task-time")).toHaveCount(0);
await expect(trailingTime).not.toContainText(/ago|yesterday/i);
await expect(trailingTime.locator(".sr-only")).toHaveText(/\S/);
const mobileTimeBox = await trailingTime.boundingBox();
expect(mobileTimeBox).not.toBeNull();
expect(mobileTimeBox!.width).toBeGreaterThanOrEqual(40);
await prCapture.screenshot("mobile-task-row-settings", {
caption: "Mobile task-row settings in the inset bottom drawer",
});
await expect(popover).toHaveCSS("overflow-y", "auto");
const drawerBox = await drawer.boundingBox();
const viewport = testPage.viewportSize();
expect(drawerBox).not.toBeNull();
expect(viewport).not.toBeNull();
expect(drawerBox!.x).toBeGreaterThanOrEqual(0);
expect(drawerBox!.x + drawerBox!.width).toBeLessThanOrEqual(viewport!.width);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await testPage.keyboard.press("Escape");
await expect(popover).toBeHidden();
const taskAction = row.getByRole("button", { name: "Task actions" });
await expect(taskAction).toBeVisible();
const taskActionBox = await taskAction.boundingBox();
expect(taskActionBox).not.toBeNull();
expect(taskActionBox!.width).toBeGreaterThanOrEqual(44);
expect(taskActionBox!.height).toBeGreaterThanOrEqual(44);
const rowBoxWithAction = await row.boundingBox();
expect(rowBoxWithAction).not.toBeNull();
expect(taskActionBox!.x).toBeGreaterThanOrEqual(rowBoxWithAction!.x - 1);
expect(taskActionBox!.x + taskActionBox!.width).toBeLessThanOrEqual(
rowBoxWithAction!.x + rowBoxWithAction!.width + 1,
);
expect(taskActionBox!.x).toBeGreaterThanOrEqual(drawerBox!.x - 1);
expect(taskActionBox!.x + taskActionBox!.width).toBeLessThanOrEqual(
drawerBox!.x + drawerBox!.width + 1,
);
await row.tap();
await expect(testPage).toHaveURL((url) => url.pathname === `/t/${task!.id}`);
await testPage.reload();
await new SessionPage(testPage).waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" });
await expect(
reloadedSheet.getByTestId("sidebar-view-chip").filter({ hasText: "Mobile task rows" }),
).toHaveAttribute("data-active", "true");
await reloadedSheet.getByTestId("sidebar-filter-gear").tap();
const reloadedPopover = testPage.getByTestId("sidebar-filter-popover");
await expect(reloadedPopover.getByTestId("task-row-details-toggle")).toHaveCount(0);
await reloadedPopover.getByTestId("task-row-settings-toggle").tap();
await expect(reloadedPopover.getByTestId("task-row-trailing-select")).toContainText(
"Relative time",
);
await testPage.keyboard.press("Escape");
});
test("many saved views scroll without covering fixed actions", async ({
testPage,
apiClient,
seedData,
}) => {
await seedAndOpenSheet(testPage, apiClient, seedData, ["Scrollable Views Task"]);
const views = Array.from({ length: 8 }, (_, index) => ({
id: `mobile-scroll-${index}`,
name: `Long mobile view ${index + 1}`,
filters: [],
sort: { key: "state", direction: "asc" },
group: "repository",
collapsed_groups: [],
}));
const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
sidebar_views: views,
sidebar_active_view_id: views[0].id,
sidebar_draft: null,
});
expect(response.ok).toBe(true);
await testPage.reload();
await new SessionPage(testPage).waitForLoad();
await testPage.getByTestId("mobile-session-menu").click();
const sheet = testPage.getByRole("dialog");
const chipRow = sheet.getByTestId("sidebar-view-chip-row");
await expect(chipRow).toBeVisible();
expect(await chipRow.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(
true,
);
await chipRow.evaluate((element) => {
element.scrollLeft = element.scrollWidth;
});
expect(await chipRow.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
await expect(sheet.getByTestId("sidebar-new-view")).toBeVisible();
await expect(sheet.getByTestId("sidebar-filter-gear")).toBeVisible();
});
test("tapping a group header collapses and expands the group in the sheet", async ({
testPage,
apiClient,
seedData,
}) => {
// The default "All tasks" view groups by repository, so the seeded tasks
// render under a collapsible group header in the sheet.
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, [
"Collapse Task A",
"Collapse Task B",
]);
const header = sheet.getByTestId("sidebar-group-header").first();
await expect(header).toBeVisible();
await expect(sheet.getByText("Collapse Task A")).toBeVisible();
// Collapse hides the group's tasks; expand brings them back.
await header.click();
await expect(sheet.getByText("Collapse Task A")).toHaveCount(0);
await header.click();
await expect(sheet.getByText("Collapse Task A")).toBeVisible();
});
});