Files
kandev/apps/web/e2e/tests/task/mobile-threads-view.spec.ts
T
João Salavisa 5ee5161166 feat(ui): confirm saved view deletion (#3461)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-09-07 16:14:58 +01:00

329 lines
14 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
import { seedSecondaryClarificationTask } from "../../helpers/clarification";
import { createStandardProfile, openTaskSession } from "../../helpers/git-helper";
import { assertNoHorizontalOverflow } from "../../helpers/session-stream-overload";
import { waitForLatestSessionDone } from "../../helpers/session";
import { attachGatewayTrafficCapture, type GatewayTrafficFrame } from "../../helpers/ws-traffic";
const AGENT_TITLE = "Mobile threads live work";
function sentSessionIds(frames: readonly GatewayTrafficFrame[], action: string): string[] {
return frames
.filter((frame) => frame.direction === "sent" && frame.action === action && frame.sessionId)
.map((frame) => frame.sessionId as string);
}
test.describe("Mobile Threads view", () => {
test("reaches the deck from the drawer and pages one full-width column", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const profile = await createStandardProfile(apiClient, "mobile-threads");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
AGENT_TITLE,
profile.id,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await openTaskSession(testPage, AGENT_TITLE);
await waitForLatestSessionDone(apiClient, task.id, 1, `agent turn for ${AGENT_TITLE}`);
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await mobile.mobileMenuButton.click();
const menu = testPage.getByRole("dialog", { name: "Menu" });
await menu.getByRole("radio", { name: "Threads", exact: true }).click();
await expect(testPage).toHaveURL(/\/threads/);
const column = testPage.getByTestId(`thread-column-${task.id}`);
await expect(column).toBeVisible();
await expect(column).toContainText(AGENT_TITLE);
// The phone layout pages the deck: one column fills the viewport rather
// than shrinking several into an unreadable row.
const viewportWidth = testPage.viewportSize()?.width ?? 0;
const columnWidth = (await column.boundingBox())?.width ?? 0;
expect(viewportWidth).toBeGreaterThan(0);
expect(columnWidth).toBeGreaterThan(viewportWidth * 0.7);
expect(columnWidth).toBeLessThanOrEqual(viewportWidth);
});
test("uses a bounded native picker for the selected session on phone", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const target = await seedSecondaryClarificationTask(
apiClient,
seedData,
"Mobile threads multi-session target",
);
const capture = attachGatewayTrafficCapture(testPage);
await testPage.goto(`/threads?taskId=${target.id}&sessionId=${target.clarificationSessionId}`);
const board = testPage.getByTestId("threads-board");
const column = testPage.getByTestId(`thread-column-${target.id}`);
await expect(board).toBeVisible();
await expect(column).toBeVisible();
const picker = column.getByTestId("thread-session-picker-trigger");
await expect(picker).toBeVisible();
await expect
.poll(() => sentSessionIds(capture.frames, "session.subscribe"), {
timeout: 30_000,
message: "mobile Threads did not subscribe the deep-linked session",
})
.toContain(target.clarificationSessionId);
expect(sentSessionIds(capture.frames, "session.subscribe")).not.toContain(
target.primarySessionId,
);
await expect(column.getByTestId("session-chat")).toHaveCount(1);
await picker.tap();
const sheet = testPage.getByRole("dialog", { name: "Select session" });
await expect(sheet).toBeVisible();
const sheetContent = testPage.getByTestId("thread-session-picker-sheet");
await expect(sheetContent).toBeVisible();
await expect
.poll(() => sheet.getByTestId(/^thread-session-row-/).count(), {
timeout: 15_000,
message: "mobile session picker did not load every task session",
})
.toBe(2);
await expect(
sheet.getByTestId(`thread-session-row-${target.clarificationSessionId}`),
).toHaveAttribute("aria-current", "true");
const primaryRow = sheet.getByTestId(`thread-session-row-${target.primarySessionId}`);
const primaryRowBox = await primaryRow.boundingBox();
expect(primaryRowBox?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(await sheetContent.evaluate((element) => element.className)).toContain(
"safe-area-inset-bottom",
);
await primaryRow.tap();
await expect(sheet).toBeHidden();
await expect
.poll(() => sentSessionIds(capture.frames, "session.subscribe"), {
timeout: 30_000,
message: "mobile picker did not activate the primary session",
})
.toContain(target.primarySessionId);
await expect
.poll(() => sentSessionIds(capture.frames, "session.unsubscribe"), {
timeout: 30_000,
message: "mobile picker did not release the sibling session",
})
.toContain(target.clarificationSessionId);
await expect(column.getByTestId("session-chat")).toHaveCount(1);
await assertNoHorizontalOverflow(testPage, "mobile Threads session picker");
});
test("switches and edits saved views in one native drawer", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const profile = await createStandardProfile(apiClient, "mobile-threads-saved-view");
const firstTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile saved view first work",
profile.id,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
const secondTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile saved view second work",
profile.id,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await openTaskSession(testPage, "Mobile saved view first work");
await waitForLatestSessionDone(apiClient, firstTask.id, 1, "first saved view agent turn");
await openTaskSession(testPage, "Mobile saved view second work");
await waitForLatestSessionDone(apiClient, secondTask.id, 1, "second saved view agent turn");
await testPage.goto("/threads");
const trigger = testPage.getByTestId("threads-mobile-view-trigger");
await expect(trigger).toBeVisible();
await trigger.tap();
const drawer = testPage.getByTestId("threads-mobile-view-drawer");
await expect(drawer).toBeVisible();
await expect(drawer.getByTestId("threads-mobile-view-list")).toBeVisible();
await drawer.getByTestId("threads-mobile-new-view").tap();
const editor = drawer.getByTestId("threads-view-editor");
await expect(editor).toBeVisible();
await expect(editor.getByTestId("threads-max-columns")).toHaveValue("5");
await editor.getByTestId("threads-scope-select").tap();
await testPage.getByRole("option", { name: "Selected tasks", exact: true }).tap();
await editor.getByTestId("threads-open-task-picker").tap();
await expect(drawer.getByTestId("threads-task-picker")).toBeVisible();
await drawer.getByTestId("threads-task-picker-search").fill("Mobile saved");
await expect(drawer.getByTestId("threads-task-picker-row")).toHaveCount(2);
await expect(
drawer.getByTestId("threads-task-picker-row").first().getByRole("img", { name: "Review" }),
).toBeVisible();
await expect(
drawer.getByTestId("threads-task-picker-row").first().getByTestId("threads-task-picker-step"),
).toHaveText("Review");
await drawer.getByTestId("threads-task-picker-select-all").tap();
const row = drawer.getByTestId("threads-task-picker-row").first();
const rowBox = await row.boundingBox();
expect(rowBox?.height ?? 0).toBeGreaterThanOrEqual(44);
await drawer.getByTestId("threads-task-picker-back").tap();
await expect(editor).toBeVisible();
await editor.getByTestId("threads-filter-add").tap();
await editor.getByTestId("threads-filter-dimension").tap();
await testPage.getByRole("option", { name: "Title", exact: true }).tap();
await editor.getByTestId("threads-filter-value").fill("Mobile saved view");
await editor.getByTestId("threads-sort-select").tap();
const titleSort = testPage.getByRole("option", { name: "Title", exact: true });
await expect(titleSort).toContainText("alphabetical");
await titleSort.tap();
await editor.getByTestId("threads-max-columns").fill("1");
const savedViewResponse = testPage.waitForResponse((response) => {
const request = response.request();
if (
!response.ok() ||
request.method() !== "PATCH" ||
!request.url().includes("/api/v1/user/settings")
) {
return false;
}
const payload = request.postDataJSON() as {
thread_view_draft?: unknown;
thread_views?: Array<{ max_columns?: number | null }>;
} | null;
return (
payload?.thread_view_draft === null &&
payload.thread_views?.some((view) => view.max_columns === 1) === true
);
});
await editor.getByTestId("threads-view-save").tap();
await savedViewResponse;
await drawer.getByTestId("threads-mobile-view-back").tap();
await drawer
.locator('[data-testid^="threads-mobile-view-option-"]')
.filter({ hasText: "New view" })
.tap();
const board = testPage.getByTestId("threads-board");
await expect(board.locator("[data-thread-column-id]")).toHaveCount(1);
await testPage.reload();
await expect(testPage.getByTestId("threads-mobile-view-trigger")).toContainText("New view");
await expect(
testPage.getByTestId("threads-board").locator("[data-thread-column-id]"),
).toHaveCount(1);
const reloadedTrigger = testPage.getByTestId("threads-mobile-view-trigger");
await reloadedTrigger.tap();
const reloadedDrawer = testPage.getByTestId("threads-mobile-view-drawer");
await reloadedDrawer.getByTestId("threads-mobile-view-option-view-all-threads").tap();
await expect(
testPage.getByTestId("threads-board").locator("[data-thread-column-id]"),
).toHaveCount(2);
const drawerAfterSwitch = testPage.getByTestId("threads-mobile-view-drawer");
await expect(drawerAfterSwitch).toBeHidden();
await expect(reloadedTrigger).toBeFocused();
// The settings list remains a native touch surface after the saved view
// round trip. Reopen it to validate the same geometry and safe-area
// contract used by the editor and picker pages.
await reloadedTrigger.tap();
const geometryDrawer = testPage.getByTestId("threads-mobile-view-drawer");
await expect(geometryDrawer).toBeVisible();
await expect(geometryDrawer.getByTestId("threads-mobile-view-list")).toBeVisible();
const buttons = geometryDrawer.locator("button:visible");
const buttonCount = await buttons.count();
for (let index = 0; index < buttonCount; index += 1) {
const box = await buttons.nth(index).boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
await assertNoHorizontalOverflow(testPage, "mobile Threads saved views");
await expect(geometryDrawer).toHaveClass(/safe-area-inset-bottom/);
await geometryDrawer.getByTestId("threads-mobile-view-option-view-all-threads").tap();
await expect(geometryDrawer).toBeHidden();
await expect(reloadedTrigger).toBeFocused();
});
test("confirms deletion of a saved view inside the native drawer", async ({
testPage,
apiClient,
}) => {
const baseView = {
task_scope: { mode: "all", task_ids: [] },
filters: [],
sort: { key: "attention", direction: "asc" },
max_columns: null,
};
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
thread_views: [
{ ...baseView, id: "view-all-threads", name: "All threads" },
{ ...baseView, id: "view-release", name: "Release threads" },
],
thread_active_view_id: "view-release",
thread_view_draft: null,
});
expect(seedResponse.ok).toBe(true);
await testPage.goto("/threads");
const trigger = testPage.getByTestId("threads-mobile-view-trigger");
await expect(trigger).toContainText("Release threads");
await trigger.tap();
const drawer = testPage.getByTestId("threads-mobile-view-drawer");
await drawer.getByTestId("threads-mobile-view-settings").tap();
const editor = drawer.getByTestId("threads-view-editor");
await editor.getByTestId("threads-view-delete").tap();
const confirmation = editor.getByTestId("saved-task-view-delete-confirmation");
await expect(confirmation).toHaveAccessibleName("Delete Release threads?");
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1);
for (const action of await confirmation.getByRole("button").all()) {
const box = await action.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
await confirmation.getByRole("button", { name: "Cancel" }).tap();
await expect(drawer).toBeVisible();
await expect(editor.getByTestId("threads-view-delete")).toBeFocused();
await editor.getByTestId("threads-view-delete").tap();
const deletedViewResponse = testPage.waitForResponse(
(response) =>
response.ok() &&
response.request().method() === "PATCH" &&
response.url().includes("/api/v1/user/settings"),
);
await editor
.getByTestId("saved-task-view-delete-confirmation")
.getByRole("button", { name: "Delete Release threads" })
.tap();
await deletedViewResponse;
await expect(drawer).toBeHidden();
await expect(trigger).toContainText("All threads");
await expect(trigger).toBeFocused();
});
});