Files
kandev/apps/web/e2e/tests/task/threads-task-actions-helpers.ts
T
2026-09-12 16:54:56 +02:00

297 lines
12 KiB
TypeScript

import { expect, type Locator, type Page } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { createStandardProfile } from "../../helpers/git-helper";
import { waitForLatestSessionDone } from "../../helpers/session";
import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions";
export async function withTaskActionSettings(api: ApiClient, run: () => Promise<void>) {
const { settings } = await api.getUserSettings();
const keys = [
"confirm_task_archive",
"thread_views",
"thread_active_view_id",
"thread_view_draft",
];
const restore = Object.fromEntries(
keys.filter((key) => key in settings).map((key) => [key, settings[key]]),
);
try {
await run();
} finally {
expect((await api.rawRequest("PATCH", "/api/v1/user/settings", restore)).ok).toBe(true);
}
}
export async function seedActionThreads(api: ApiClient, seed: SeedData) {
const profile = await createStandardProfile(api, "thread-task-actions");
const source = await api.createWorkflow(seed.workspaceId, "Thread actions source", "simple");
const { steps } = await api.listWorkflowSteps(source.id);
const start = steps.find((step) => step.is_start_step);
if (!start) throw new Error("Thread action workflow has no start step");
const tasks = [];
for (const title of ["Task action target A", "Unaffected conversation B"]) {
const task = await api.createTaskWithAgent(seed.workspaceId, title, profile.id, {
description: "/e2e:simple-message",
workflow_id: source.id,
workflow_step_id: start.id,
repository_ids: [seed.repositoryId],
});
await waitForLatestSessionDone(api, task.id, 1, `ready task ${title}`);
tasks.push(task);
}
const destination = await api.createWorkflow(seed.workspaceId, "Task actions destination");
const destinationStep = await api.createWorkflowStep(destination.id, "Incoming", 0);
const localStep = await api.createWorkflowStep(source.id, "Task actions triage", 20);
const emptyWorkflow = await api.createWorkflow(seed.workspaceId, "No available steps");
return { a: tasks[0], b: tasks[1], destination, destinationStep, localStep, emptyWorkflow };
}
export class ThreadActionsPage {
constructor(
readonly page: Page,
readonly mobile = false,
) {
page.setDefaultTimeout(15_000);
}
column(taskId: string) {
return this.page.getByTestId(`thread-column-${taskId}`);
}
trigger(taskId: string) {
return this.column(taskId).getByRole("button", { name: "Task actions", exact: true });
}
async expectHeaderActionAlignment(taskId: string, withPicker = this.mobile) {
const header = this.column(taskId).locator("header");
const expand = header.getByRole("button", { name: "Open task", exact: true });
const actions = this.trigger(taskId);
await expect(actions).toBeVisible();
const expandIcon = await requireBox(expand.locator("svg"), "expand icon");
const actionsIcon = await requireBox(actions.locator("svg"), "task actions icon");
expect(
actionsIcon.y + actionsIcon.height / 2,
"header icons share a vertical center",
).toBeCloseTo(expandIcon.y + expandIcon.height / 2, 1);
if (!withPicker) return;
const picker = header.getByTestId("thread-picker-trigger");
const pickerIcon = await requireBox(picker.locator("svg"), "thread picker icon");
const pickerGap = expandIcon.x + expandIcon.width / 2 - pickerIcon.x - pickerIcon.width / 2;
const actionsGap = actionsIcon.x + actionsIcon.width / 2 - expandIcon.x - expandIcon.width / 2;
expect(actionsGap, "phone header icons are evenly spaced").toBeCloseTo(pickerGap, 1);
let previousRight = 0;
for (const button of [picker, expand, actions]) {
const box = await requireBox(button, "phone header touch target");
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.height).toBeGreaterThanOrEqual(44);
expect(box.x, "header touch targets do not overlap").toBeGreaterThanOrEqual(previousRight);
previousRight = box.x + box.width;
expect(
await button.evaluate((element) => {
const rect = element.getBoundingClientRect();
return element.contains(
document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2),
);
}),
"header control receives touches at its center",
).toBe(true);
}
}
async press(locator: Locator) {
if (this.mobile) await locator.tap();
else await locator.click();
}
async open(taskId: string) {
await this.trigger(taskId).scrollIntoViewIfNeeded();
await this.press(this.trigger(taskId));
await expect(
this.page.getByTestId(this.mobile ? "task-management-drawer" : "task-management-menu"),
).toBeVisible();
}
choice(name: string) {
return this.page.getByRole(this.mobile ? "button" : "menuitem", { name, exact: true });
}
async nested(name: string) {
const option = this.choice(name);
if (this.mobile) await option.tap();
else {
await this.movePointer(option);
await option.click();
await expect(option).toHaveAttribute("aria-expanded", "true");
}
}
async pick(name: string) {
const choice = this.choice(name);
if (!this.mobile) await this.movePointer(choice);
await this.press(choice);
}
private async movePointer(choice: Locator) {
await expect(choice).toBeVisible();
await choice.evaluate((element) =>
Promise.all(
(element.closest('[role="menu"]')?.getAnimations() ?? []).map(
(animation) => animation.finished,
),
),
);
const box = await requireBox(choice, "task menu choice");
// Radix pointer grace needs direction from intermediate moves when a submenu flips left.
await this.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 12 });
}
async cancelDelete(taskId: string) {
await this.open(taskId);
await this.pick("Delete");
const dialog = this.page.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await this.press(dialog.getByRole("button", { name: "Cancel", exact: true }));
await expect(dialog).toBeHidden();
await expect(this.trigger(taskId)).toBeFocused();
}
async delete(taskId: string) {
await this.open(taskId);
await this.pick("Delete");
await this.confirmDelete();
await expect(this.column(taskId)).toHaveCount(0);
}
async confirmDelete() {
const consent = this.page.getByTestId("delete-discard-worktree-checkbox");
if (await consent.isVisible()) await this.press(consent);
await this.press(this.page.getByTestId("thread-delete-confirm"));
}
async contained(surface: Locator) {
await expect(surface).toBeVisible();
await surface.evaluate((element) =>
Promise.all(
element
.getAnimations({ subtree: true })
.filter((animation) => Number.isFinite(animation.effect?.getComputedTiming().endTime))
.map((animation) => animation.finished.catch(() => undefined)),
),
);
const box = await requireBox(surface, "task actions surface");
const viewport = this.page.viewportSize()!;
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width + 1);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height + 1);
await assertNoDocumentHorizontalOverflow(this.page, "Threads task actions");
}
}
// @covers AC-TASKS-THREADS-ACTIONS-001.1 through AC-TASKS-THREADS-ACTIONS-001.6
export async function allTaskActionOutcomes(
page: Page,
api: ApiClient,
seed: SeedData,
mobile: boolean,
) {
const fixture = await seedActionThreads(api, seed);
await api.updateRepository(seed.repositoryId, {
provider: "github",
provider_owner: "testorg",
provider_name: "testrepo",
provider_host: "github.com",
});
const { a, b, destination, destinationStep, localStep, emptyWorkflow } = fixture;
const ui = new ThreadActionsPage(page, mobile);
await api.saveUserSettings({ confirm_task_archive: true });
await page.goto(`/threads?workspace=${seed.workspaceId}&taskId=${a.id}`);
const siblingBefore = await api.getTask(b.id);
const deletions: string[] = [];
page.on("request", (request) => {
if (request.method() === "DELETE" && request.url().includes(`/tasks/${a.id}`))
deletions.push(request.url());
});
await ui.cancelDelete(a.id);
expect(deletions).toEqual([]);
await ui.open(a.id);
const root = page.getByTestId(mobile ? "task-management-scroll" : "task-management-menu");
await expect(root.getByRole(mobile ? "button" : "menuitem")).toHaveText([
"Priority",
"Move to",
"Send to workflow",
"Link",
"Archive",
"Delete",
]);
await page.keyboard.press("Escape");
await ui.open(a.id);
await ui.nested("Send to workflow");
await expect(page.getByTestId(`task-context-workflow-${emptyWorkflow.id}`)).toBeDisabled();
await page.keyboard.press("Escape");
if (mobile) await page.keyboard.press("Escape");
await ui.open(a.id);
await ui.nested("Priority");
await ui.pick("Critical");
await expect.poll(async () => (await api.getTask(a.id)).priority).toBe("critical");
await ui.open(a.id);
await ui.nested("Link");
await ui.pick("GitHub Issue");
await expect(page.getByTestId("task-management-drawer")).toHaveCount(0);
await page
.getByTestId("task-github-issue-input")
.fill("https://github.com/testorg/testrepo/issues/901");
await ui.contained(page.getByRole("dialog"));
await ui.press(page.getByTestId("task-github-issue-submit"));
await expect.poll(async () => (await api.getTask(a.id)).metadata?.issue_number).toBe(901);
await expect(ui.trigger(a.id)).toBeFocused();
await ui.open(a.id);
await ui.nested("Move to");
const current = await api.getTask(a.id);
await expect(page.getByTestId(`task-context-step-${current.workflow_step_id}`)).toBeDisabled();
await ui.pick("Task actions triage");
await expect.poll(async () => (await api.getTask(a.id)).workflow_step_id).toBe(localStep.id);
await ui.open(a.id);
await ui.nested("Send to workflow");
await ui.nested(destination.name);
await ui.pick("Incoming");
await expect
.poll(async () => (await api.getTask(a.id)).workflow_step_id)
.toBe(destinationStep.id);
await expect(page).toHaveURL(/\/threads\?/);
await page.reload();
await expect(ui.column(a.id)).toBeVisible();
expect((await api.getTask(a.id)).priority).toBe("critical");
expect((await api.getTask(a.id)).metadata?.issue_number).toBe(901);
expect(await api.getTask(b.id)).toMatchObject({
priority: siblingBefore.priority,
workflow_step_id: siblingBefore.workflow_step_id,
});
await ui.open(a.id);
await ui.pick("Archive");
await expect(page.getByTestId("archive-task-confirm")).toBeVisible();
await ui.press(page.getByRole("button", { name: "Cancel", exact: true }));
await expect(ui.column(a.id)).toBeVisible();
await ui.open(a.id);
await ui.pick("Archive");
await ui.press(page.getByTestId("archive-task-confirm"));
await expect(ui.column(a.id)).toHaveCount(0);
await expect(ui.trigger(b.id)).toBeFocused();
await expect.poll(async () => (await api.getTask(a.id)).archived_at).toBeTruthy();
await ui.delete(b.id);
await expect(page.getByTestId("threads-empty-state")).toBeVisible();
await expect(page.getByTestId("threads-empty-state")).toBeFocused();
await expect(page).toHaveURL(/\/threads/);
expect((await api.rawRequest("GET", `/api/v1/tasks/${b.id}`)).status).toBe(404);
}
export async function seedLinkedIssue(api: ApiClient) {
await api.mockGitHubSetUser("thread-actions-user");
await api.mockGitHubAddIssues([
{
number: 901,
title: "Issue linked from Threads",
state: "open",
author_login: "test-user",
repo_owner: "testorg",
repo_name: "testrepo",
html_url: "https://github.com/testorg/testrepo/issues/901",
},
]);
}