992 lines
39 KiB
TypeScript
992 lines
39 KiB
TypeScript
import { execSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { test, expect } from "../../fixtures/test-base";
|
|
import { activeSessionId, seedSecondaryClarificationTask } from "../../helpers/clarification";
|
|
import { makeGitEnv } from "../../helpers/git-helper";
|
|
import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store";
|
|
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
|
import {
|
|
expectActiveTaskRow,
|
|
expectActiveTaskRowWithoutColor,
|
|
} from "../../helpers/active-task-row";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
|
|
test.describe("Mobile sidebar task actions", () => {
|
|
test("opens the secondary session that owns clarification from the phone drawer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
const target = await seedSecondaryClarificationTask(
|
|
apiClient,
|
|
seedData,
|
|
"Mobile secondary clarification owner",
|
|
);
|
|
const source = await apiClient.createTask(seedData.workspaceId, "Mobile pending owner source", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
});
|
|
|
|
await testPage.goto(`/t/${source.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await testPage.getByTestId("mobile-session-menu").tap();
|
|
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const targetRow = drawer.getByTestId("sidebar-task-item").filter({ hasText: target.title });
|
|
await expect(targetRow).toBeVisible();
|
|
await expect(targetRow.getByTestId("task-state-waiting-for-input")).toBeVisible();
|
|
await targetRow.tap();
|
|
|
|
await expect(drawer).toBeHidden();
|
|
await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}$`));
|
|
await expect.poll(() => activeSessionId(testPage)).toBe(target.clarificationSessionId);
|
|
await expect(session.clarificationOverlay()).toBeVisible();
|
|
await expect(session.clarificationOverlay()).toContainText(
|
|
"Which database should we use for this project?",
|
|
);
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
testPage.evaluate(
|
|
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
|
),
|
|
{ message: "pending clarification task should not overflow the phone viewport" },
|
|
)
|
|
.toBe(true);
|
|
});
|
|
|
|
test("keeps a linked PR badge beside its title in the phone drawer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const title = "Mobile sidebar PR badge spacing";
|
|
const task = await apiClient.createTask(seedData.workspaceId, title, {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
priority: "critical",
|
|
});
|
|
await apiClient.mockGitHubAssociateTaskPR({
|
|
task_id: task.id,
|
|
owner: "testorg",
|
|
repo: "testrepo",
|
|
pr_number: 902,
|
|
pr_url: "https://github.com/testorg/testrepo/pull/902",
|
|
pr_title: "Mobile sidebar spacing",
|
|
head_branch: "feature/mobile-sidebar-spacing",
|
|
base_branch: "main",
|
|
author_login: "e2e",
|
|
state: "open",
|
|
checks_state: "success",
|
|
mergeable_state: "clean",
|
|
});
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.mobileSessionMenu.tap();
|
|
|
|
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const row = drawer.getByTestId("sidebar-task-item").filter({ hasText: title });
|
|
await expect(row).toBeVisible();
|
|
const prIcon = row.getByTestId(`pr-task-icon-${task.id}`);
|
|
const priorityIcon = row.getByTestId("sidebar-task-priority-indicator");
|
|
await expect(priorityIcon).toHaveAttribute("aria-label", "Priority Critical");
|
|
await expect(prIcon).toBeVisible({ timeout: 15_000 });
|
|
|
|
const spacing = await row.evaluate((el, titleText) => {
|
|
const titleElement = [...el.querySelectorAll("span")].find(
|
|
(candidate) =>
|
|
candidate.classList.contains("whitespace-nowrap") && candidate.textContent === titleText,
|
|
);
|
|
const priority = el.querySelector('[data-testid="sidebar-task-priority-indicator"]');
|
|
const pr = el.querySelector('[data-testid^="pr-task-icon-"]');
|
|
if (!titleElement || !priority || !pr) {
|
|
return { found: false, titleRight: -1, priorityLeft: -1, priorityRight: -1, prLeft: -1 };
|
|
}
|
|
const titleBox = titleElement.getBoundingClientRect();
|
|
const priorityBox = priority.getBoundingClientRect();
|
|
const prBox = pr.getBoundingClientRect();
|
|
return {
|
|
found: true,
|
|
titleRight: titleBox.right,
|
|
priorityLeft: priorityBox.left,
|
|
priorityRight: priorityBox.right,
|
|
prLeft: prBox.left,
|
|
};
|
|
}, title);
|
|
expect(spacing.found).toBe(true);
|
|
expect(spacing.titleRight).toBeLessThanOrEqual(spacing.priorityLeft);
|
|
expect(spacing.priorityRight).toBeLessThanOrEqual(spacing.prLeft);
|
|
|
|
await row.getByRole("button", { name: "Task actions" }).tap();
|
|
const priorityMenu = testPage.getByTestId("task-context-priority");
|
|
await expect(priorityMenu.locator(".tabler-icon-flag")).toBeVisible();
|
|
await priorityMenu.tap();
|
|
const lowOption = testPage.getByTestId("task-context-priority-low");
|
|
await expect(lowOption).toBeVisible();
|
|
const prioritySubmenu = lowOption.locator("xpath=ancestor::*[@role='menu'][1]");
|
|
await prioritySubmenu.evaluate((element) =>
|
|
Promise.all(
|
|
element
|
|
.getAnimations({ subtree: true })
|
|
.map((animation) => animation.finished.catch(() => undefined)),
|
|
),
|
|
);
|
|
const optionBox = await lowOption.boundingBox();
|
|
expect(optionBox).not.toBeNull();
|
|
expect(Math.round(optionBox!.height)).toBeGreaterThanOrEqual(44);
|
|
await lowOption.tap();
|
|
await expect(priorityIcon).toHaveAttribute("aria-label", "Priority Low");
|
|
await expect.poll(async () => (await apiClient.getTask(task.id)).priority).toBe("low");
|
|
await assertNoDocumentHorizontalOverflow(testPage, "mobile sidebar PR badge spacing");
|
|
});
|
|
|
|
test("switches to the selected task and its chat from the phone task drawer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const sourceTitle = "Mobile drawer source task";
|
|
const destinationTitle = "Mobile drawer destination task";
|
|
const destinationResponse = "destination drawer session response";
|
|
const taskOptions = {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.worktreeExecutorProfileId,
|
|
};
|
|
const source = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
sourceTitle,
|
|
seedData.agentProfileId,
|
|
{ ...taskOptions, description: 'e2e:message("source drawer session response")' },
|
|
);
|
|
const destination = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
destinationTitle,
|
|
seedData.agentProfileId,
|
|
{ ...taskOptions, description: `e2e:message("${destinationResponse}")` },
|
|
);
|
|
if (!source.session_id || !destination.session_id) {
|
|
throw new Error("mobile drawer task setup did not create both sessions");
|
|
}
|
|
expect(source.session_id).not.toBe(destination.session_id);
|
|
|
|
// Wait for the destination's actual agent response before navigating, so
|
|
// the assertion below proves that the drawer selected its session rather
|
|
// than merely replacing the URL/title.
|
|
await expect
|
|
.poll(async () => {
|
|
const response = await apiClient.rawRequest(
|
|
"GET",
|
|
`/api/v1/task-sessions/${destination.session_id}/messages?limit=50`,
|
|
);
|
|
const body = (await response.json()) as { messages?: Array<{ content?: string }> };
|
|
return body.messages?.some((message) => message.content?.includes(destinationResponse));
|
|
})
|
|
.toBe(true);
|
|
|
|
await testPage.goto(`/t/${source.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await expect(session.chat.getByText("source drawer session response").last()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
await testPage.getByTestId("mobile-session-menu").tap();
|
|
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const destinationRow = drawer
|
|
.getByTestId("sidebar-task-item")
|
|
.filter({ hasText: destinationTitle });
|
|
await expect(destinationRow).toBeVisible();
|
|
await destinationRow.tap();
|
|
|
|
await expect(drawer).toBeHidden();
|
|
await expect(testPage).toHaveURL(new RegExp(`/t/${destination.id}$`));
|
|
const mobileTopBar = testPage
|
|
.getByTestId("mobile-session-menu")
|
|
.locator("xpath=ancestor::header");
|
|
await expect(mobileTopBar.getByText(destinationTitle, { exact: true })).toBeVisible();
|
|
await expect(session.chat.getByText(destinationResponse).last()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
});
|
|
|
|
test("keeps the active task treatment in the phone task drawer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const task = await apiClient.createTask(seedData.workspaceId, "Mobile active task focus", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
});
|
|
|
|
await apiClient.saveUserSettings({
|
|
sidebar_task_color_patch: {
|
|
colors: { [task.id]: "red" },
|
|
if_missing: false,
|
|
},
|
|
});
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.mobileSessionMenu.tap();
|
|
|
|
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const row = drawer.getByTestId("sidebar-task-item").filter({ hasText: task.title });
|
|
// @covers AC-UI-SIDEBAR-TASK-FOCUS-001.3/001.4
|
|
await expectActiveTaskRow(row);
|
|
await expect(row.locator("div.absolute.left-0.top-0.bottom-0")).toHaveClass(/bg-red-500/);
|
|
await assertNoDocumentHorizontalOverflow(testPage, "mobile active task focus");
|
|
});
|
|
|
|
test("does not show a left marker for an uncolored active task in the phone drawer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const task = await apiClient.createTask(
|
|
seedData.workspaceId,
|
|
"Mobile uncolored active task focus",
|
|
{
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
},
|
|
);
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await testPage.getByTestId("mobile-session-menu").tap();
|
|
|
|
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const row = drawer.getByTestId("sidebar-task-item").filter({ hasText: task.title });
|
|
// @covers AC-UI-SIDEBAR-TASK-FOCUS-001.5
|
|
await expectActiveTaskRowWithoutColor(row);
|
|
await assertNoDocumentHorizontalOverflow(testPage, "mobile uncolored active task focus");
|
|
});
|
|
|
|
test("opens the phone task switcher as an inset bottom card", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const task = await apiClient.seedTask(seedData.workspaceId, "Mobile task drawer surface", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
});
|
|
|
|
await testPage.goto(`/t/${task.task_id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await testPage.getByTestId("mobile-session-menu").click();
|
|
|
|
const surface = testPage.getByRole("dialog", { name: "Tasks" });
|
|
await expect(surface).toBeVisible();
|
|
await expect(surface).toHaveAttribute("data-slot", "drawer-content");
|
|
await surface.evaluate((element) =>
|
|
Promise.all(
|
|
element
|
|
.getAnimations({ subtree: true })
|
|
.map((animation) => animation.finished.catch(() => undefined)),
|
|
),
|
|
);
|
|
|
|
const card = surface.locator('[data-slot="drawer-header"]');
|
|
const [cardBox, viewport] = await Promise.all([
|
|
card.boundingBox(),
|
|
testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
|
|
]);
|
|
if (!cardBox) throw new Error("mobile task drawer card has no layout box");
|
|
expect(cardBox.x).toBeGreaterThanOrEqual(7);
|
|
expect(cardBox.x + cardBox.width).toBeLessThanOrEqual(viewport.width - 7);
|
|
expect(cardBox.y).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("keeps the inline archive confirmation readable on a phone", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const task = await apiClient.seedTask(seedData.workspaceId, "Mobile archive inline target", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
});
|
|
|
|
await testPage.goto(`/t/${task.task_id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await testPage.getByTestId("mobile-session-menu").tap();
|
|
|
|
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const taskRow = drawer
|
|
.getByTestId("sidebar-task-item")
|
|
.filter({ hasText: "Mobile archive inline target" });
|
|
await taskRow.getByRole("button", { name: "Task actions" }).tap();
|
|
await testPage.getByRole("menuitem", { name: "Archive", exact: true }).tap();
|
|
|
|
const confirmation = taskRow.getByTestId("task-archive-inline-confirmation");
|
|
await expect(confirmation).toBeVisible();
|
|
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
|
|
await expect(confirmation).toContainText("Mobile archive inline target");
|
|
|
|
for (const button of [
|
|
confirmation.getByRole("button", { name: "Cancel" }),
|
|
confirmation.getByTestId("archive-task-confirm"),
|
|
]) {
|
|
const box = await button.boundingBox();
|
|
if (!box) throw new Error("inline archive action has no layout box");
|
|
expect(box.width).toBeGreaterThanOrEqual(44);
|
|
expect(box.height).toBeGreaterThanOrEqual(44);
|
|
await expect(button).toBeInViewport();
|
|
}
|
|
|
|
const pageWidth = await testPage.evaluate(() => ({
|
|
scroll: document.documentElement.scrollWidth,
|
|
client: document.documentElement.clientWidth,
|
|
}));
|
|
expect(pageWidth.scroll).toBeLessThanOrEqual(pageWidth.client);
|
|
});
|
|
|
|
test("keeps the in-flight warning compact and reachable on a phone", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
prCapture,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
const title = "Mobile in-flight archive warning";
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
title,
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/background 30s",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await waitForActiveSessionForegroundActivity(testPage, "generating");
|
|
await testPage.getByTestId("mobile-session-menu").tap();
|
|
|
|
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const taskRow = drawer.getByTestId("sidebar-task-item").filter({ hasText: title });
|
|
const heightBefore = await taskRow.evaluate(
|
|
(element) => element.getBoundingClientRect().height,
|
|
);
|
|
await prCapture.screenshot("before-mobile-in-flight-archive", {
|
|
caption: "Phone task row before in-flight archive confirmation",
|
|
});
|
|
await taskRow.getByRole("button", { name: "Task actions" }).tap();
|
|
await testPage.getByRole("menuitem", { name: "Archive", exact: true }).tap();
|
|
|
|
const confirmation = taskRow.getByTestId("task-archive-inline-confirmation");
|
|
const warning = confirmation.getByTestId("still-working-warning");
|
|
await expect(confirmation).toBeVisible();
|
|
await expect(warning).toBeVisible();
|
|
await prCapture.screenshot("mobile-in-flight-archive-warning", {
|
|
caption: "Compact in-flight warning in phone archive confirmation",
|
|
});
|
|
|
|
const warningMetrics = await warning.evaluate((element) => {
|
|
const style = getComputedStyle(element);
|
|
return { fontSize: style.fontSize, lineHeight: style.lineHeight };
|
|
});
|
|
expect(warningMetrics).toEqual({ fontSize: "12px", lineHeight: "20px" });
|
|
await expect(warning).toHaveClass(/text-pretty/);
|
|
const heightOpen = await taskRow.evaluate((element) => element.getBoundingClientRect().height);
|
|
expect(heightOpen).toBeGreaterThan(heightBefore);
|
|
|
|
for (const button of [
|
|
confirmation.getByRole("button", { name: "Cancel" }),
|
|
confirmation.getByTestId("archive-task-confirm"),
|
|
]) {
|
|
const box = await button.boundingBox();
|
|
if (!box) throw new Error("in-flight inline archive action has no layout box");
|
|
expect(box.width).toBeGreaterThanOrEqual(44);
|
|
expect(box.height).toBeGreaterThanOrEqual(44);
|
|
await expect(button).toBeInViewport();
|
|
}
|
|
|
|
const pageWidth = await testPage.evaluate(() => ({
|
|
scroll: document.documentElement.scrollWidth,
|
|
client: document.documentElement.clientWidth,
|
|
}));
|
|
expect(pageWidth.scroll).toBeLessThanOrEqual(pageWidth.client);
|
|
await confirmation.getByRole("button", { name: "Cancel" }).tap();
|
|
await expect(confirmation).toBeHidden();
|
|
});
|
|
|
|
test("keeps the tablet task switcher as a left-side sheet", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
await testPage.setViewportSize({ width: 820, height: 900 });
|
|
const task = await apiClient.seedTask(seedData.workspaceId, "Tablet task sheet surface", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
});
|
|
|
|
await testPage.goto(`/t/${task.task_id}`);
|
|
await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible();
|
|
await testPage.evaluate(
|
|
"window.__KANDEV_E2E_STORE__?.getState().setMobileSessionTaskSwitcherOpen(true)",
|
|
);
|
|
|
|
const surface = testPage.getByRole("dialog", { name: "Tasks" });
|
|
await expect(surface).toBeVisible();
|
|
await expect(surface).toHaveAttribute("data-slot", "sheet-content");
|
|
await expect(surface).toHaveAttribute("data-side", "left");
|
|
});
|
|
|
|
test("returns to the tablet task switcher after canceling a task action", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
await testPage.setViewportSize({ width: 820, height: 900 });
|
|
const title = "Tablet task action target";
|
|
const task = await apiClient.seedTask(seedData.workspaceId, title, {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
});
|
|
|
|
await testPage.goto(`/t/${task.task_id}`);
|
|
await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible();
|
|
await testPage.evaluate(
|
|
"window.__KANDEV_E2E_STORE__?.getState().setMobileSessionTaskSwitcherOpen(true)",
|
|
);
|
|
|
|
const surface = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const taskRow = surface.getByTestId("sidebar-task-item").filter({ hasText: title });
|
|
await taskRow.click({ button: "right" });
|
|
await testPage.getByRole("menuitem", { name: "Rename", exact: true }).click();
|
|
|
|
const renameDialog = testPage.getByRole("dialog", { name: "Rename task" });
|
|
await expect(renameDialog).toBeVisible();
|
|
await expect(surface).toBeVisible();
|
|
await renameDialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
await expect(renameDialog).toHaveCount(0);
|
|
await expect(surface).toBeVisible();
|
|
});
|
|
|
|
test("opens a viewport-bound action sheet without covering diff stats", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const taskTitle = "Mobile task with diff stats";
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
taskTitle,
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:diff-update-setup",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.worktreeExecutorProfileId,
|
|
},
|
|
);
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await expect(
|
|
session.chat.getByText("diff-update-setup complete", { exact: false }),
|
|
).toBeVisible({
|
|
timeout: 60_000,
|
|
});
|
|
|
|
await testPage.getByTestId("mobile-session-menu").click();
|
|
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const taskRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: taskTitle });
|
|
const diffStats = taskRow.getByTestId("sidebar-task-diff-stats");
|
|
const actions = taskRow.getByRole("button", { name: "Task actions" });
|
|
|
|
await expect(diffStats).toBeVisible({ timeout: 15_000 });
|
|
await expect(actions).toBeVisible();
|
|
const [diffBox, actionBox] = await Promise.all([
|
|
diffStats.boundingBox(),
|
|
actions.boundingBox(),
|
|
]);
|
|
if (!diffBox || !actionBox) throw new Error("mobile task controls have no layout box");
|
|
// Chromium can report a 44px transformed box as 43.999969 CSS pixels.
|
|
// Compare physical CSS-pixel intent, not floating-point layout residue.
|
|
expect(Math.round(actionBox.width)).toBeGreaterThanOrEqual(44);
|
|
expect(Math.round(actionBox.height)).toBeGreaterThanOrEqual(44);
|
|
const overlapWidth =
|
|
Math.min(diffBox.x + diffBox.width, actionBox.x + actionBox.width) -
|
|
Math.max(diffBox.x, actionBox.x);
|
|
const overlapHeight =
|
|
Math.min(diffBox.y + diffBox.height, actionBox.y + actionBox.height) -
|
|
Math.max(diffBox.y, actionBox.y);
|
|
expect(overlapWidth <= 0 || overlapHeight <= 0).toBe(true);
|
|
|
|
await testPage.setViewportSize({ width: 390, height: 480 });
|
|
await actions.click();
|
|
|
|
const archiveItem = testPage.getByRole("menuitem", { name: "Archive", exact: true });
|
|
await expect(archiveItem).toBeVisible();
|
|
const menu = archiveItem.locator("xpath=ancestor::*[@role='menu'][1]");
|
|
await menu.evaluate((element) =>
|
|
Promise.all(
|
|
element
|
|
.getAnimations({ subtree: true })
|
|
.map((animation) => animation.finished.catch(() => undefined)),
|
|
),
|
|
);
|
|
const [menuBox, itemBox] = await Promise.all([menu.boundingBox(), archiveItem.boundingBox()]);
|
|
const viewport = testPage.viewportSize();
|
|
if (!menuBox || !itemBox || !viewport) throw new Error("mobile action sheet has no layout box");
|
|
|
|
expect(menuBox.x).toBeGreaterThanOrEqual(8);
|
|
expect(menuBox.x).toBeLessThanOrEqual(10);
|
|
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(viewport.width - 8);
|
|
expect(menuBox.width).toBeGreaterThanOrEqual(viewport.width - 20);
|
|
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(viewport.height);
|
|
expect(viewport.height - (menuBox.y + menuBox.height)).toBeGreaterThanOrEqual(7);
|
|
expect(viewport.height - (menuBox.y + menuBox.height)).toBeLessThanOrEqual(10);
|
|
expect(itemBox.height).toBeGreaterThanOrEqual(44);
|
|
const menuOverflow = await menu.evaluate((element) => ({
|
|
clientHeight: element.clientHeight,
|
|
scrollHeight: element.scrollHeight,
|
|
}));
|
|
expect(menuOverflow.scrollHeight).toBeGreaterThan(menuOverflow.clientHeight);
|
|
for (const actionName of [
|
|
"Pin",
|
|
"Edit",
|
|
"Rename",
|
|
"Duplicate",
|
|
"Archive",
|
|
"Create Subtask",
|
|
"Color",
|
|
"Link",
|
|
"Move to",
|
|
"Delete",
|
|
]) {
|
|
await expect(menu.getByRole("menuitem", { name: actionName, exact: true })).toHaveCount(1);
|
|
}
|
|
await archiveItem.scrollIntoViewIfNeeded();
|
|
await expect(archiveItem).toBeInViewport();
|
|
await expect(diffStats).toBeVisible();
|
|
});
|
|
|
|
test("edits a started task from the phone drawer and closes the drawer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
prCapture,
|
|
}) => {
|
|
const task = await apiClient.createTask(seedData.workspaceId, "Phone sidebar edit target", {
|
|
description: "Phone prompt stays locked",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
});
|
|
await apiClient.updateTaskState(task.id, "IN_PROGRESS");
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await testPage.getByTestId("mobile-session-menu").tap();
|
|
|
|
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const taskRow = drawer
|
|
.getByTestId("sidebar-task-item")
|
|
.filter({ hasText: "Phone sidebar edit target" });
|
|
await taskRow.getByRole("button", { name: "Task actions" }).tap();
|
|
await testPage.getByRole("menuitem", { name: "Edit", exact: true }).tap();
|
|
|
|
await expect(drawer).toBeHidden();
|
|
const dialog = testPage.getByRole("dialog");
|
|
await expect(dialog.getByTestId("task-title-input")).toBeEnabled();
|
|
await expect(dialog.getByTestId("task-description-input")).toBeDisabled();
|
|
await expect(dialog.getByTestId("task-description-input")).toHaveValue(
|
|
"Phone prompt stays locked",
|
|
);
|
|
await prCapture.screenshot("phone-sidebar-task-edit-dialog", {
|
|
caption: "Phone sidebar task editor with started-task prompt locked",
|
|
});
|
|
await dialog.getByTestId("task-title-input").fill("Phone sidebar edit updated");
|
|
await dialog.getByRole("button", { name: "Update", exact: true }).tap();
|
|
|
|
await expect(dialog).toHaveCount(0);
|
|
await expect
|
|
.poll(async () => (await apiClient.getTask(task.id)).title)
|
|
.toBe("Phone sidebar edit updated");
|
|
});
|
|
|
|
test("keeps the tablet task sheet behind the sidebar editor", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
prCapture,
|
|
}) => {
|
|
await testPage.setViewportSize({ width: 820, height: 900 });
|
|
const task = await apiClient.seedTask(seedData.workspaceId, "Tablet sidebar edit target", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
});
|
|
await testPage.goto(`/t/${task.task_id}`);
|
|
await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible();
|
|
await testPage.evaluate(
|
|
"window.__KANDEV_E2E_STORE__?.getState().setMobileSessionTaskSwitcherOpen(true)",
|
|
);
|
|
|
|
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const taskRow = sheet
|
|
.getByTestId("sidebar-task-item")
|
|
.filter({ hasText: "Tablet sidebar edit target" });
|
|
await taskRow.click({ button: "right" });
|
|
await testPage.getByRole("menuitem", { name: "Edit", exact: true }).click();
|
|
|
|
const editor = testPage
|
|
.getByRole("dialog")
|
|
.filter({ has: testPage.getByTestId("task-title-input") });
|
|
await expect(editor.getByTestId("task-title-input")).toBeVisible();
|
|
await expect(sheet).toBeVisible();
|
|
await prCapture.screenshot("tablet-sidebar-task-edit-dialog", {
|
|
caption: "Tablet sidebar task editor with task sheet retained",
|
|
});
|
|
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
await expect(editor).toHaveCount(0);
|
|
await expect(sheet).toBeVisible();
|
|
});
|
|
|
|
test("opens create subtask from the mobile task actions menu", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
prCapture,
|
|
}) => {
|
|
const parentTitle = "Mobile create subtask parent";
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
parentTitle,
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.worktreeExecutorProfileId,
|
|
},
|
|
);
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.waitForChatIdle({ timeout: 30_000 });
|
|
await testPage.getByTestId("mobile-session-menu").click();
|
|
|
|
const taskSheet = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const taskRow = taskSheet.getByTestId("sidebar-task-item").filter({ hasText: parentTitle });
|
|
await taskRow.getByRole("button", { name: "Task actions" }).click();
|
|
|
|
const createSubtask = testPage.getByRole("menuitem", { name: "Create Subtask", exact: true });
|
|
await expect(createSubtask).toBeVisible();
|
|
await prCapture.screenshot("mobile-create-subtask-context-menu", {
|
|
caption: "Mobile task actions menu with Create Subtask",
|
|
});
|
|
await createSubtask.click();
|
|
|
|
const dialog = testPage.getByTestId("new-subtask-dialog");
|
|
await expect(dialog).toBeVisible();
|
|
await expect(testPage.locator('[data-slot="tooltip-content"][data-state="open"]')).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(testPage.getByTestId("subtask-title-input")).toHaveValue(
|
|
/Mobile create subtask parent \/ Subtask 1/,
|
|
);
|
|
const parentBranchBadge = dialog.getByText("Same branch as current session", { exact: true });
|
|
await expect(parentBranchBadge).toBeVisible();
|
|
await expect(dialog.getByTestId("subtask-workspace-mode-inherit")).toHaveAttribute(
|
|
"aria-checked",
|
|
"true",
|
|
);
|
|
await dialog.getByTestId("subtask-workspace-mode-new").tap();
|
|
await expect(parentBranchBadge).toHaveCount(0);
|
|
await expect(dialog.getByTestId("repo-chip-trigger")).toBeVisible();
|
|
await expect(dialog.getByTestId("branch-chip-trigger")).toBeVisible();
|
|
await prCapture.screenshot("mobile-subtask-isolated-workspace", {
|
|
caption: "Mobile New Subtask dialog with isolated workspace controls",
|
|
});
|
|
await expect(testPage.getByTestId("subtask-context-autopilot-row")).toBeVisible();
|
|
const contextTrigger = testPage
|
|
.getByTestId("subtask-context-autopilot-row")
|
|
.locator('[data-slot="select-trigger"]');
|
|
const contextHeight = await contextTrigger.evaluate((element) =>
|
|
Math.round(element.getBoundingClientRect().height),
|
|
);
|
|
const autopilotHeight = await testPage
|
|
.getByTestId("autopilot-toggle-row")
|
|
.evaluate((element) => Math.round(element.getBoundingClientRect().height));
|
|
expect(autopilotHeight).toBe(contextHeight);
|
|
await expect(dialog.getByTestId("autopilot-toggle-row")).toBeVisible();
|
|
await dialog.getByRole("switch", { name: "Autopilot" }).tap();
|
|
await expect(dialog.getByRole("switch", { name: "Autopilot" })).toHaveAttribute(
|
|
"aria-checked",
|
|
"true",
|
|
);
|
|
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
});
|
|
|
|
test("uses the selected non-active parent defaults for mobile subtasks", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
const parentRepoDir = path.join(backend.tmpDir, "repos", "mobile-parent-repo");
|
|
fs.mkdirSync(parentRepoDir, { recursive: true });
|
|
const gitEnv = makeGitEnv(backend.tmpDir);
|
|
execSync("git init -b main", { cwd: parentRepoDir, env: gitEnv });
|
|
execSync('git commit --allow-empty -m "init"', { cwd: parentRepoDir, env: gitEnv });
|
|
const parentRepo = await apiClient.createRepository(
|
|
seedData.workspaceId,
|
|
parentRepoDir,
|
|
"main",
|
|
{ name: "Mobile parent repo" },
|
|
);
|
|
const activeTask = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Mobile active task",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Mobile non-active parent",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [parentRepo.id],
|
|
},
|
|
);
|
|
|
|
await testPage.goto(`/t/${activeTask.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await testPage.getByTestId("mobile-session-menu").click();
|
|
|
|
const taskSheet = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const parentRow = taskSheet
|
|
.getByTestId("sidebar-task-item")
|
|
.filter({ hasText: "Mobile non-active parent" });
|
|
await parentRow.getByRole("button", { name: "Task actions" }).click();
|
|
await testPage.getByRole("menuitem", { name: "Create Subtask", exact: true }).click();
|
|
|
|
const dialog = testPage.getByTestId("new-subtask-dialog");
|
|
await expect(dialog).toBeVisible();
|
|
await expect(testPage.getByTestId("repo-chip-trigger")).toContainText("Mobile parent repo");
|
|
await expect(testPage.getByTestId("subtask-title-input")).toHaveValue(
|
|
/Mobile non-active parent \/ Subtask 1/,
|
|
);
|
|
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
});
|
|
|
|
// @covers AC-UI-MOBILE-TASK-CHROME-001.3
|
|
test("moves a task to another step from the mobile task drawer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const targetStep = seedData.steps.find((step) => step.id !== seedData.startStepId);
|
|
if (!targetStep) throw new Error("mobile move test requires at least two workflow steps");
|
|
const task = await apiClient.seedTask(seedData.workspaceId, "Mobile move target", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
});
|
|
|
|
await testPage.goto(`/t/${task.task_id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await testPage.getByTestId("mobile-session-menu").click();
|
|
|
|
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const taskRow = sheet
|
|
.getByTestId("sidebar-task-item")
|
|
.filter({ hasText: "Mobile move target" });
|
|
const actions = taskRow.getByRole("button", { name: "Task actions" });
|
|
await expect(actions).toBeVisible({ timeout: 15_000 });
|
|
await actions.click();
|
|
|
|
await testPage.getByTestId("task-context-move-to").click();
|
|
await testPage.getByTestId(`task-context-step-${targetStep.id}`).click();
|
|
|
|
await expect
|
|
.poll(async () => (await apiClient.getTask(task.task_id)).workflow_step_id)
|
|
.toBe(targetStep.id);
|
|
});
|
|
|
|
test("creates a policy branch for a local-executor subtask", async ({
|
|
testPage,
|
|
apiClient,
|
|
backend,
|
|
seedData,
|
|
}) => {
|
|
const gitEnv = makeGitEnv(backend.tmpDir);
|
|
const normalizeSeedRepository = () => {
|
|
execSync("git checkout -f main", {
|
|
cwd: seedData.repositoryPath,
|
|
env: gitEnv,
|
|
});
|
|
execSync("git reset --hard main", {
|
|
cwd: seedData.repositoryPath,
|
|
env: gitEnv,
|
|
});
|
|
execSync("git clean -fd", {
|
|
cwd: seedData.repositoryPath,
|
|
env: gitEnv,
|
|
});
|
|
execSync("git branch -f develop", {
|
|
cwd: seedData.repositoryPath,
|
|
env: gitEnv,
|
|
});
|
|
};
|
|
normalizeSeedRepository();
|
|
|
|
const { executors } = await apiClient.listExecutors();
|
|
const localExecutor = executors.find((executor) =>
|
|
["local", "local_pc"].includes(executor.type),
|
|
);
|
|
if (!localExecutor) {
|
|
test.skip(true, "No local executor available");
|
|
return;
|
|
}
|
|
|
|
const policy = await apiClient.createRepositoryBranchPolicy(seedData.repositoryId, {
|
|
name: `Mobile subtask policy ${Date.now()}`,
|
|
base_branch: "main",
|
|
branch_template: "feature/{title}-{suffix}",
|
|
pull_request_target: "develop",
|
|
});
|
|
let localProfile: { id: string; name: string } | undefined;
|
|
const parentTitle = `Mobile policy subtask parent ${Date.now()}`;
|
|
const childTitle = `Mobile policy subtask child ${Date.now()}`;
|
|
|
|
try {
|
|
localProfile = await apiClient.createExecutorProfile(
|
|
localExecutor.id,
|
|
`E2E Mobile Subtask Local ${Date.now()}`,
|
|
);
|
|
const parent = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
parentTitle,
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.worktreeExecutorProfileId,
|
|
},
|
|
);
|
|
await testPage.setViewportSize({ width: 390, height: 844 });
|
|
await testPage.goto(`/t/${parent.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.waitForChatIdle({ timeout: 30_000 });
|
|
await testPage.getByTestId("mobile-session-menu").tap();
|
|
const taskSheet = testPage.getByRole("dialog", { name: "Tasks" });
|
|
const taskRow = taskSheet.getByTestId("sidebar-task-item").filter({ hasText: parentTitle });
|
|
await taskRow.getByRole("button", { name: "Task actions" }).tap();
|
|
await testPage.getByRole("menuitem", { name: "Create Subtask", exact: true }).tap();
|
|
|
|
const dialog = testPage.getByTestId("new-subtask-dialog");
|
|
await dialog.getByTestId("subtask-workspace-mode-new").tap();
|
|
const executorSelector = dialog.getByTestId("executor-profile-selector");
|
|
await expect(async () => {
|
|
await executorSelector.tap();
|
|
await testPage
|
|
.getByRole("option", { name: new RegExp(localProfile.name) })
|
|
.tap({ force: true });
|
|
await expect(executorSelector).toContainText(localProfile.name, { timeout: 1_000 });
|
|
}).toPass({ timeout: 10_000 });
|
|
await dialog.getByTestId("branch-chip-trigger").tap();
|
|
await testPage.getByRole("option", { name: new RegExp(policy.name) }).tap({ force: true });
|
|
await expect(dialog.getByTestId("fresh-branch-toggle")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await dialog.getByTestId("subtask-title-input").fill(childTitle);
|
|
await dialog.getByTestId("subtask-prompt-input").fill("/e2e:simple-message");
|
|
const createSubtask = dialog.getByRole("button", { name: "Create Subtask", exact: true });
|
|
await expect(executorSelector).toContainText(localProfile.name);
|
|
await expect(createSubtask).toBeEnabled();
|
|
await createSubtask.tap();
|
|
await expect(dialog).toBeHidden({ timeout: 30_000 });
|
|
let childId: string | undefined;
|
|
await expect
|
|
.poll(async () => {
|
|
const response = await apiClient.listTasks(seedData.workspaceId);
|
|
childId = response.tasks.find((task) => task.title === childTitle)?.id;
|
|
return childId;
|
|
})
|
|
.toBeTruthy();
|
|
expect(childId).toBeTruthy();
|
|
type PolicyRepositorySnapshot = {
|
|
base_branch?: string;
|
|
branch_policy_id?: string;
|
|
branch_policy_name?: string;
|
|
branch_policy_branch_template?: string;
|
|
branch_policy_pull_request_target?: string;
|
|
checkout_branch?: string;
|
|
};
|
|
let repository: PolicyRepositorySnapshot | undefined;
|
|
await expect
|
|
.poll(async () => {
|
|
const response = await apiClient.rawRequest("GET", `/api/v1/tasks/${childId}`);
|
|
if (!response.ok) return undefined;
|
|
const task = (await response.json()) as { repositories?: PolicyRepositorySnapshot[] };
|
|
repository = task.repositories?.[0];
|
|
return repository;
|
|
})
|
|
.toEqual(
|
|
expect.objectContaining({
|
|
branch_policy_id: policy.id,
|
|
branch_policy_name: policy.name,
|
|
branch_policy_branch_template: "feature/{title}-{suffix}",
|
|
branch_policy_pull_request_target: "develop",
|
|
}),
|
|
);
|
|
// The branch-name renderer bounds the title segment to keep refs
|
|
// portable, so assert the policy template prefix and generated suffix
|
|
// rather than the unbounded title slug.
|
|
expect(repository!.base_branch).toMatch(/^feature\/mobile-policy-subtas-/);
|
|
await expect
|
|
.poll(async () =>
|
|
execSync("git branch --show-current", {
|
|
cwd: seedData.repositoryPath,
|
|
env: makeGitEnv(backend.tmpDir),
|
|
})
|
|
.toString()
|
|
.trim(),
|
|
)
|
|
.toBe(repository!.base_branch);
|
|
} finally {
|
|
if (localProfile) await apiClient.deleteExecutorProfile(localProfile.id).catch(() => {});
|
|
await apiClient.deleteRepositoryBranchPolicy(policy.id).catch(() => {});
|
|
normalizeSeedRepository();
|
|
}
|
|
});
|
|
});
|