982cdb63eb
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
487 lines
15 KiB
TypeScript
487 lines
15 KiB
TypeScript
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { StoreApi } from "zustand";
|
|
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
|
|
import { ToastProvider } from "@/components/toast-provider";
|
|
import { pluginRegistry } from "@/lib/plugins/registry";
|
|
import type { AppState } from "@/lib/state/store";
|
|
import { TaskTopBarActionsMenu } from "./task-top-bar-actions-menu";
|
|
import type { TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
|
|
|
type WsHandler = (message: { payload: Record<string, unknown> }) => void;
|
|
const wsHandlers = new Map<string, Set<WsHandler>>();
|
|
|
|
function emitWs(type: string, payload: Record<string, unknown>) {
|
|
const handlers = wsHandlers.get(type);
|
|
if (!handlers) return;
|
|
for (const handler of handlers) handler({ payload });
|
|
}
|
|
|
|
const mockClient = {
|
|
subscribe: vi.fn(() => vi.fn()),
|
|
on: vi.fn((type: string, handler: WsHandler) => {
|
|
const handlers = wsHandlers.get(type) ?? new Set();
|
|
handlers.add(handler);
|
|
wsHandlers.set(type, handlers);
|
|
return () => {
|
|
handlers.delete(handler);
|
|
};
|
|
}),
|
|
};
|
|
vi.mock("@/lib/ws/connection", () => ({
|
|
getWebSocketClient: () => mockClient,
|
|
}));
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
mockClient.subscribe.mockClear();
|
|
mockClient.on.mockClear();
|
|
wsHandlers.clear();
|
|
});
|
|
|
|
const TASK_ID = "task-1";
|
|
const OLD_WORKFLOW_ID = "wf-old";
|
|
const NEW_WORKFLOW_ID = "wf-new";
|
|
const TRIGGER_TEST_ID = "task-topbar-actions-menu";
|
|
const MOVE_TO_TEST_ID = "task-context-move-to";
|
|
|
|
function initialStateWithTaskInActiveKanban(): Partial<AppState> {
|
|
return {
|
|
kanban: {
|
|
workflowId: OLD_WORKFLOW_ID,
|
|
tasks: [
|
|
{
|
|
id: TASK_ID,
|
|
workflowId: OLD_WORKFLOW_ID,
|
|
workflowStepId: "step-a",
|
|
title: "Task 1",
|
|
position: 0,
|
|
},
|
|
],
|
|
},
|
|
} as never;
|
|
}
|
|
|
|
function initialStateWithMoveTargets(): Partial<AppState> {
|
|
return {
|
|
kanban: {
|
|
workflowId: OLD_WORKFLOW_ID,
|
|
tasks: [
|
|
{
|
|
id: TASK_ID,
|
|
workflowId: OLD_WORKFLOW_ID,
|
|
workflowStepId: "step-a",
|
|
title: "Task 1",
|
|
position: 0,
|
|
},
|
|
],
|
|
},
|
|
kanbanMulti: {
|
|
snapshots: {
|
|
[OLD_WORKFLOW_ID]: {
|
|
workflowId: OLD_WORKFLOW_ID,
|
|
workflowName: "Workflow",
|
|
steps: [
|
|
{ id: "step-a", title: "Step A", position: 0 },
|
|
{ id: "step-b", title: "Step B", position: 1 },
|
|
],
|
|
tasks: [
|
|
{
|
|
id: TASK_ID,
|
|
workflowId: OLD_WORKFLOW_ID,
|
|
workflowStepId: "step-a",
|
|
title: "Task 1",
|
|
position: 0,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
workflows: {
|
|
items: [{ id: OLD_WORKFLOW_ID, name: "Workflow", workspaceId: "ws-1", hidden: false }],
|
|
},
|
|
} as never;
|
|
}
|
|
|
|
const RESOLVED_BOARD_ROW: TaskActionsMenuBoardRow = {
|
|
id: TASK_ID,
|
|
title: "Task 1",
|
|
workflowStepId: "step-a",
|
|
};
|
|
|
|
let capturedStore: StoreApi<AppState> | null = null;
|
|
|
|
function StoreCapture() {
|
|
capturedStore = useAppStoreApi();
|
|
return null;
|
|
}
|
|
|
|
function renderMenu(initialState: Partial<AppState>) {
|
|
capturedStore = null;
|
|
render(
|
|
<ToastProvider>
|
|
<StateProvider initialState={initialState as never}>
|
|
<StoreCapture />
|
|
<TaskTopBarActionsMenu
|
|
taskId={TASK_ID}
|
|
taskTitle="Task 1"
|
|
boardRow={null}
|
|
workspaceId="ws-1"
|
|
/>
|
|
</StateProvider>
|
|
</ToastProvider>,
|
|
);
|
|
return () => capturedStore!;
|
|
}
|
|
|
|
function renderMenuWithBoardRow(
|
|
initialState: Partial<AppState>,
|
|
boardRow: TaskActionsMenuBoardRow | null,
|
|
) {
|
|
capturedStore = null;
|
|
const { rerender } = render(
|
|
<ToastProvider>
|
|
<StateProvider initialState={initialState as never}>
|
|
<StoreCapture />
|
|
<TaskTopBarActionsMenu
|
|
taskId={TASK_ID}
|
|
taskTitle="Task 1"
|
|
boardRow={boardRow}
|
|
workspaceId="ws-1"
|
|
/>
|
|
</StateProvider>
|
|
</ToastProvider>,
|
|
);
|
|
return {
|
|
getStore: () => capturedStore!,
|
|
setBoardRow: (next: TaskActionsMenuBoardRow | null) =>
|
|
rerender(
|
|
<ToastProvider>
|
|
<StateProvider initialState={initialState as never}>
|
|
<StoreCapture />
|
|
<TaskTopBarActionsMenu
|
|
taskId={TASK_ID}
|
|
taskTitle="Task 1"
|
|
boardRow={next}
|
|
workspaceId="ws-1"
|
|
/>
|
|
</StateProvider>
|
|
</ToastProvider>,
|
|
),
|
|
};
|
|
}
|
|
|
|
function openMenu(trigger: HTMLElement) {
|
|
act(() => {
|
|
trigger.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
|
|
trigger.click();
|
|
});
|
|
}
|
|
|
|
function expectMenuOpen(trigger: HTMLElement, open: boolean) {
|
|
expect(trigger.getAttribute("aria-expanded")).toBe(String(open));
|
|
}
|
|
|
|
describe("TaskTopBarActionsMenu — WS subscription lifetime", () => {
|
|
it("subscribes to the subject task's own WS updates for the lifetime of the trigger", () => {
|
|
const { unmount } = render(
|
|
<ToastProvider>
|
|
<StateProvider initialState={initialStateWithTaskInActiveKanban() as never}>
|
|
<TaskTopBarActionsMenu
|
|
taskId={TASK_ID}
|
|
taskTitle="Task 1"
|
|
boardRow={null}
|
|
workspaceId="ws-1"
|
|
/>
|
|
</StateProvider>
|
|
</ToastProvider>,
|
|
);
|
|
|
|
expect(mockClient.subscribe).toHaveBeenCalledWith(TASK_ID);
|
|
const unsubscribe = mockClient.subscribe.mock.results[0]?.value as ReturnType<typeof vi.fn>;
|
|
|
|
unmount();
|
|
|
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps the trigger mounted when a same-message workflow move lands the task in a workflow with no prior snapshot", () => {
|
|
const getStore = renderMenu(initialStateWithTaskInActiveKanban());
|
|
expect(screen.getByTestId(TRIGGER_TEST_ID)).toBeTruthy();
|
|
|
|
// A single task.updated for a cross-workflow move removes the task from
|
|
// the old workflow's collections and adds it to the new one's snapshot
|
|
// in one state transition (mirroring applyTaskUpdatedCache), so the
|
|
// subject is never simultaneously absent from both.
|
|
act(() => {
|
|
getStore().setState((state) => ({
|
|
kanban: { ...state.kanban, tasks: [] },
|
|
kanbanMulti: {
|
|
...state.kanbanMulti,
|
|
snapshots: {
|
|
...state.kanbanMulti.snapshots,
|
|
[NEW_WORKFLOW_ID]: {
|
|
workflowId: NEW_WORKFLOW_ID,
|
|
workflowName: "New workflow",
|
|
steps: [],
|
|
tasks: [
|
|
{
|
|
id: TASK_ID,
|
|
workflowId: NEW_WORKFLOW_ID,
|
|
workflowStepId: "step-b",
|
|
title: "Task 1",
|
|
position: 0,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
});
|
|
|
|
expect(screen.getByTestId(TRIGGER_TEST_ID)).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("TaskTopBarActionsMenu — live demotion on board-row loss (AC-TASKS-TASK-ACTIONS-MENU-002.6/004.1c)", () => {
|
|
it("demotes Move to out of the menu in place, keeping the top-level menu open, when the board row is lost while the menu is open", () => {
|
|
const { setBoardRow } = renderMenuWithBoardRow(
|
|
initialStateWithMoveTargets(),
|
|
RESOLVED_BOARD_ROW,
|
|
);
|
|
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
|
|
|
openMenu(trigger);
|
|
expectMenuOpen(trigger, true);
|
|
expect(screen.getByTestId(MOVE_TO_TEST_ID)).toBeTruthy();
|
|
|
|
act(() => {
|
|
setBoardRow(null);
|
|
});
|
|
|
|
expectMenuOpen(trigger, true);
|
|
expect(screen.queryByTestId(MOVE_TO_TEST_ID)).toBeNull();
|
|
});
|
|
|
|
it("re-promotes Move to back into the menu in place when the board row resolves again while the menu stays open", () => {
|
|
const { setBoardRow } = renderMenuWithBoardRow(initialStateWithMoveTargets(), null);
|
|
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
|
|
|
openMenu(trigger);
|
|
expectMenuOpen(trigger, true);
|
|
expect(screen.queryByTestId(MOVE_TO_TEST_ID)).toBeNull();
|
|
|
|
act(() => {
|
|
setBoardRow(RESOLVED_BOARD_ROW);
|
|
});
|
|
|
|
expectMenuOpen(trigger, true);
|
|
expect(screen.getByTestId(MOVE_TO_TEST_ID)).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("TaskTopBarActionsMenu — closes on genuine removal (AC-TASKS-TASK-ACTIONS-MENU-004.5)", () => {
|
|
it("closes the trigger's open menu when task.deleted arrives for this task", () => {
|
|
renderMenu(initialStateWithTaskInActiveKanban());
|
|
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
|
|
|
openMenu(trigger);
|
|
expectMenuOpen(trigger, true);
|
|
|
|
act(() => {
|
|
emitWs("task.deleted", { task_id: TASK_ID });
|
|
});
|
|
|
|
expectMenuOpen(trigger, false);
|
|
});
|
|
|
|
it("closes the trigger's open menu when task.updated arrives with archived_at set for this task", () => {
|
|
renderMenu(initialStateWithTaskInActiveKanban());
|
|
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
|
|
|
openMenu(trigger);
|
|
expectMenuOpen(trigger, true);
|
|
|
|
act(() => {
|
|
emitWs("task.updated", { task_id: TASK_ID, archived_at: "2026-09-03T00:00:00Z" });
|
|
});
|
|
|
|
expectMenuOpen(trigger, false);
|
|
});
|
|
|
|
it("ignores task.deleted for a different task id", () => {
|
|
renderMenu(initialStateWithTaskInActiveKanban());
|
|
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
|
|
|
openMenu(trigger);
|
|
expectMenuOpen(trigger, true);
|
|
|
|
act(() => {
|
|
emitWs("task.deleted", { task_id: "some-other-task" });
|
|
});
|
|
|
|
expectMenuOpen(trigger, true);
|
|
});
|
|
|
|
it("ignores task.updated for this task when archived_at is absent (an ordinary field update)", () => {
|
|
renderMenu(initialStateWithTaskInActiveKanban());
|
|
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
|
|
|
openMenu(trigger);
|
|
expectMenuOpen(trigger, true);
|
|
|
|
act(() => {
|
|
emitWs("task.updated", { task_id: TASK_ID, title: "Renamed" });
|
|
});
|
|
|
|
expectMenuOpen(trigger, true);
|
|
});
|
|
});
|
|
|
|
describe("TaskTopBarActionsMenu — board-row loss alone does not close the menu (AC-TASKS-TASK-ACTIONS-MENU-002.6/004.1c)", () => {
|
|
it("stays open and only demotes entries when the board row disappears from the store without a task.deleted/archived lifecycle event", () => {
|
|
const { getStore, setBoardRow } = renderMenuWithBoardRow(
|
|
initialStateWithMoveTargets(),
|
|
RESOLVED_BOARD_ROW,
|
|
);
|
|
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
|
|
|
openMenu(trigger);
|
|
expectMenuOpen(trigger, true);
|
|
expect(screen.getByTestId(MOVE_TO_TEST_ID)).toBeTruthy();
|
|
|
|
// Mirrors what a real board-row loss looks like: the task's row vanishes
|
|
// from the store's collections (e.g. `useTaskActionsMenuBoardRow` can no
|
|
// longer resolve it) and the parent recomputes `boardRow` as null -- but
|
|
// no task.deleted/archived_at lifecycle event ever arrives, because the
|
|
// task itself is still alive. This is the exact state combination the
|
|
// old `existsInBoard`-based implementation could not tell apart from a
|
|
// genuine removal.
|
|
act(() => {
|
|
getStore().setState((state) => ({
|
|
kanban: { ...state.kanban, tasks: [] },
|
|
kanbanMulti: {
|
|
...state.kanbanMulti,
|
|
snapshots: {
|
|
...state.kanbanMulti.snapshots,
|
|
[OLD_WORKFLOW_ID]: {
|
|
...state.kanbanMulti.snapshots[OLD_WORKFLOW_ID],
|
|
tasks: [],
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
setBoardRow(null);
|
|
});
|
|
|
|
expectMenuOpen(trigger, true);
|
|
expect(screen.queryByTestId(MOVE_TO_TEST_ID)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("TaskTopBarActionsMenu — plugin context stays independent of the board row (AC-TASKS-TASK-ACTIONS-MENU-002.4b)", () => {
|
|
const PLUGIN_ID = "test-plugin-subject-workflow-step";
|
|
|
|
afterEach(() => pluginRegistry.unregisterPlugin(PLUGIN_ID));
|
|
|
|
it("passes the subject's own workflowStepId to a plugin primary action for an archived subject with no board row", () => {
|
|
const visible = vi.fn(() => true);
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
|
id: "primary-action",
|
|
group: "primary",
|
|
label: "Plugin primary action",
|
|
visible,
|
|
run: vi.fn(),
|
|
});
|
|
|
|
render(
|
|
<ToastProvider>
|
|
<StateProvider initialState={{} as never}>
|
|
<TaskTopBarActionsMenu
|
|
taskId={TASK_ID}
|
|
taskTitle="Task 1"
|
|
boardRow={null}
|
|
workspaceId="ws-1"
|
|
isArchived
|
|
subjectWorkflowStepId="step-last-known"
|
|
/>
|
|
</StateProvider>
|
|
</ToastProvider>,
|
|
);
|
|
|
|
expect(visible).toHaveBeenCalledWith(
|
|
expect.objectContaining({ workflowStepId: "step-last-known" }),
|
|
);
|
|
});
|
|
|
|
it("falls back to the board row's workflowStepId when no independent subject value is supplied", () => {
|
|
const visible = vi.fn(() => true);
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
|
id: "primary-action",
|
|
group: "primary",
|
|
label: "Plugin primary action",
|
|
visible,
|
|
run: vi.fn(),
|
|
});
|
|
|
|
render(
|
|
<ToastProvider>
|
|
<StateProvider initialState={{} as never}>
|
|
<TaskTopBarActionsMenu
|
|
taskId={TASK_ID}
|
|
taskTitle="Task 1"
|
|
boardRow={RESOLVED_BOARD_ROW}
|
|
workspaceId="ws-1"
|
|
/>
|
|
</StateProvider>
|
|
</ToastProvider>,
|
|
);
|
|
|
|
expect(visible).toHaveBeenCalledWith(expect.objectContaining({ workflowStepId: "step-a" }));
|
|
});
|
|
});
|
|
|
|
describe("TaskTopBarActionsMenu — Delete confirmation keeps executor-specific cleanup copy without a board row", () => {
|
|
it("shows the worktree-specific cleanup effect for an archived subject using the subject's own executor type", async () => {
|
|
render(
|
|
<ToastProvider>
|
|
<StateProvider initialState={{} as never}>
|
|
<TaskTopBarActionsMenu
|
|
taskId={TASK_ID}
|
|
taskTitle="Task 1"
|
|
boardRow={null}
|
|
workspaceId="ws-1"
|
|
isArchived
|
|
subjectPrimaryExecutorType="worktree"
|
|
/>
|
|
</StateProvider>
|
|
</ToastProvider>,
|
|
);
|
|
|
|
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
|
openMenu(trigger);
|
|
(await screen.findByRole("menuitem", { name: "Delete" })).click();
|
|
|
|
const dialog = await screen.findByRole("alertdialog");
|
|
expect(dialog.textContent).toContain("The task's git worktree and its branch will be deleted.");
|
|
});
|
|
});
|
|
|
|
describe("TaskTopBarActionsMenu — Escape closes and returns focus (AC-TASKS-TASK-ACTIONS-MENU-001.9)", () => {
|
|
it("closes the menu and returns focus to the trigger on Escape", async () => {
|
|
renderMenu(initialStateWithTaskInActiveKanban());
|
|
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
|
|
|
openMenu(trigger);
|
|
expectMenuOpen(trigger, true);
|
|
|
|
const menu = await screen.findByRole("menu");
|
|
act(() => {
|
|
menu.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
});
|
|
|
|
expectMenuOpen(trigger, false);
|
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
|
});
|
|
});
|