Files
kandev/apps/web/e2e/tests/task/mobile-threads-task-actions.spec.ts
T
2026-09-25 10:30:33 +01:00

179 lines
7.2 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { ChangeWorkflowPage } from "../../pages/change-workflow-page";
import {
allTaskActionOutcomes,
seedActionThreads,
seedLinkedIssue,
ThreadActionsPage,
withTaskActionSettings,
} from "./threads-task-actions-helpers";
import { swipeDeckLeft } from "./mobile-threads-swipe-helpers";
import {
failedActionOutcomes,
filteredArchiveOutcome,
lateArchiveOutcome,
} from "./threads-task-actions-edge-helpers";
import { pendingArchiveRecovery, pendingLastArchive } from "./threads-pending-archive-helpers";
for (const [name, outcome] of [
["pending archive recovers without taking focus", pendingArchiveRecovery],
["pending last archive stays empty through settlement", pendingLastArchive],
] as const) {
test(name, async ({ testPage, apiClient, seedData }, testInfo) => {
test.setTimeout(120_000);
await testPage.setViewportSize({ width: 360, height: 780 });
await withTaskActionSettings(apiClient, () =>
outcome(testPage, apiClient, seedData, true, testInfo),
);
});
}
for (const [name, outcome] of [
["failures and retry", failedActionOutcomes],
["late archive retains a newer menu", lateArchiveOutcome],
["filtered opener retains archive identity", filteredArchiveOutcome],
] as const) {
test(name, async ({ testPage, apiClient, seedData }) => {
test.setTimeout(120_000);
await testPage.setViewportSize({ width: 360, height: 780 });
await withTaskActionSettings(apiClient, () => outcome(testPage, apiClient, seedData, true));
});
}
test("completes all six task actions by touch and cancels destructive choices", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(240_000);
await testPage.setViewportSize({ width: 360, height: 780 });
await seedLinkedIssue(apiClient);
try {
await allTaskActionOutcomes(testPage, apiClient, seedData, true);
} finally {
await apiClient.saveUserSettings({ confirm_task_archive: true });
}
});
// @covers AC-TASKS-THREADS-ACTIONS-004.2 through AC-TASKS-THREADS-ACTIONS-004.7
test("contains the mobile change form and preserves native swiping after dismissal", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
test.setTimeout(240_000);
const { a, destination } = await seedActionThreads(apiClient, seedData);
const destinationName = "Workflow".repeat(12);
await apiClient.updateWorkflow(destination.id, { name: destinationName });
const longName = "A deliberately long workflow step " + "unbroken".repeat(24);
let finalStepId = "";
for (let index = 0; index < 18; index++) {
const step = await apiClient.createWorkflowStep(
destination.id,
`${index} ${longName}`,
index + 1,
{
session_target: { kind: "initial" },
},
);
if (index === 17) finalStepId = step.id;
}
await apiClient.updateTaskTitle(a.id, "Task".repeat(15));
await testPage.setViewportSize({ width: 360, height: 780 });
await testPage.goto(`/threads?workspace=${seedData.workspaceId}`);
const ui = new ThreadActionsPage(testPage, true);
const firstId = await testPage
.getByTestId("threads-board")
.locator("[data-thread-column-id]")
.first()
.getAttribute("data-thread-column-id");
if (!firstId) throw new Error("No first thread");
await expect(ui.trigger(firstId)).toBeVisible();
await ui.expectHeaderActionAlignment(firstId);
await testPage.screenshot({ path: testInfo.outputPath("phone-header-360.png") });
await ui.open(firstId);
const drawer = testPage.getByTestId("task-management-drawer");
await ui.contained(drawer);
await expect(ui.choice("Close")).toHaveCSS("cursor", "pointer");
await testPage.screenshot({ path: testInfo.outputPath("phone-root-360.png") });
await ui.pick("Change workflow...");
const form = new ChangeWorkflowPage(testPage, true);
await expect(form.phoneDrawer).toBeVisible();
await ui.contained(form.phoneDrawer);
await form.chooseWorkflow(destination.id);
const scroll = form.form.getByTestId("change-workflow-scroll");
expect(await scroll.evaluate((element) => getComputedStyle(element).overflowY)).toBe("auto");
expect(
await form.phoneDrawer.evaluate((element) =>
[element, ...element.querySelectorAll("*")]
.filter(
(node) =>
/auto|scroll/.test(getComputedStyle(node).overflowY) &&
node.scrollHeight > node.clientHeight,
)
.map((node) => node.getAttribute("data-testid")),
),
).toEqual(["change-workflow-scroll"]);
await form.form.getByTestId("change-workflow-step").tap();
const finalStep = testPage.locator(`[role="option"][data-value="${finalStepId}"]`);
await finalStep.scrollIntoViewIfNeeded();
await expect(finalStep).toBeVisible();
await expect(finalStep.locator("xpath=ancestor::*[@data-slot='popover-content']")).toHaveCSS(
"opacity",
"1",
);
expect(
await finalStep.evaluate((element) => {
const rect = element.getBoundingClientRect();
const topmost = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
return topmost === element || element.contains(topmost);
}),
).toBe(true);
await assertNoDocumentHorizontalOverflow(testPage, "mobile change workflow form");
await testPage.screenshot({ path: testInfo.outputPath("phone-deep-360.png") });
await testPage.keyboard.press("Escape");
await ui.press(form.form.getByTestId("change-workflow-cancel"));
await expect(ui.trigger(firstId)).toBeFocused();
await ui.open(firstId);
await ui.pick("Delete");
await ui.contained(testPage.getByRole("alertdialog"));
await testPage.screenshot({ path: testInfo.outputPath("phone-delete-360.png") });
await ui.press(testPage.getByRole("button", { name: "Cancel", exact: true }));
await expect(ui.trigger(firstId)).toBeFocused();
for (const width of [320, 700, 820]) {
await testPage.setViewportSize({ width, height: 640 });
await ui.trigger(firstId).scrollIntoViewIfNeeded();
await ui.expectHeaderActionAlignment(firstId, width < 640);
const hitTarget = await ui.trigger(firstId).evaluate((button) => {
const rect = button.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
hit: button.contains(
document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2),
),
};
});
expect(hitTarget).toMatchObject({ width: 44, height: 44, hit: true });
await ui.open(firstId);
await ui.contained(drawer);
const rows = await drawer
.getByRole("button")
.evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
expect(rows.every((height) => height >= 44)).toBe(true);
if (width === 320)
await testPage.screenshot({ path: testInfo.outputPath("phone-root-320.png") });
await ui.pick("Close");
}
await ui.open(firstId);
await testPage.touchscreen.tap(4, 4);
await expect(drawer).toHaveCount(0);
await expect(ui.trigger(firstId)).toBeFocused();
await testPage.setViewportSize({ width: 360, height: 780 });
await swipeDeckLeft(testPage, async () => {
await expect(testPage.getByTestId("thread-swipe-cue")).toHaveText("2/2");
});
await expect(testPage.getByTestId("thread-swipe-cue")).toHaveText("2/2");
});