08f59dedae
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { produce } from "immer";
|
|
import type { Draft } from "immer";
|
|
|
|
import { snapshotToState } from "./mapper";
|
|
import { hydrateState } from "@/lib/state/hydration/hydrator";
|
|
import { defaultState } from "@/lib/state/default-state";
|
|
import type { AppState } from "@/lib/state/store";
|
|
import { taskId, workflowId, workspaceId } from "@/lib/types/ids";
|
|
import type { WorkflowSnapshot } from "@/lib/types/http";
|
|
import { partitionWipTasks } from "@/lib/kanban/wip-queue";
|
|
|
|
const now = "2026-07-10T12:00:00Z";
|
|
const workflowID = workflowId("workflow-1");
|
|
const workspaceID = workspaceId("workspace-1");
|
|
|
|
function snapshotWithPendingAction(action: unknown): WorkflowSnapshot {
|
|
return {
|
|
workflow: {
|
|
id: workflowID,
|
|
workspace_id: workspaceID,
|
|
name: "Workflow",
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
steps: [
|
|
{
|
|
id: "step-1",
|
|
workflow_id: workflowID,
|
|
name: "Todo",
|
|
position: 0,
|
|
color: "bg-neutral-400",
|
|
allow_manual_move: true,
|
|
complete_task_on_enter: false,
|
|
auto_advance_requires_signal: false,
|
|
cancel_triggers_turn_complete: false,
|
|
},
|
|
],
|
|
tasks: [
|
|
{
|
|
id: taskId("task-1"),
|
|
workspace_id: workspaceID,
|
|
workflow_id: workflowID,
|
|
workflow_step_id: "step-1",
|
|
position: 0,
|
|
title: "Task",
|
|
description: "",
|
|
state: "TODO",
|
|
priority: "medium",
|
|
primary_session_pending_action: action,
|
|
created_at: now,
|
|
updated_at: now,
|
|
} as WorkflowSnapshot["tasks"][number],
|
|
],
|
|
};
|
|
}
|
|
|
|
const EXECUTOR_ID = "exec-worktree";
|
|
const EXECUTOR_TYPE = "worktree";
|
|
const EXECUTOR_NAME = "Worktree";
|
|
|
|
function snapshotWithExecutor(): WorkflowSnapshot {
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
snapshot.tasks[0].primary_executor_id = EXECUTOR_ID;
|
|
snapshot.tasks[0].primary_executor_type = EXECUTOR_TYPE;
|
|
snapshot.tasks[0].primary_executor_name = EXECUTOR_NAME;
|
|
return snapshot;
|
|
}
|
|
|
|
// eslint-disable-next-line max-lines-per-function -- test describe block, splitting hurts readability
|
|
describe("snapshotToState", () => {
|
|
it("keeps known primary session pending action values", () => {
|
|
const state = snapshotToState(snapshotWithPendingAction("permission"));
|
|
|
|
expect(state.kanban?.tasks[0]?.primarySessionPendingAction).toBe("permission");
|
|
});
|
|
|
|
it("drops unrecognized primary session pending action values", () => {
|
|
const state = snapshotToState(snapshotWithPendingAction("unknown"));
|
|
|
|
expect(state.kanban?.tasks[0]?.primarySessionPendingAction).toBeUndefined();
|
|
});
|
|
|
|
// The server-rendered boot payload is the first thing a board paints from,
|
|
// so a dropped assignee here shows every card as unassigned until the first
|
|
// WS update happens to arrive.
|
|
it("hydrates the human assignee into the initial kanban state", () => {
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
snapshot.tasks[0].assignee_user_id = "user-7";
|
|
|
|
const state = snapshotToState(snapshot);
|
|
|
|
expect(state.kanban?.tasks[0]?.assigneeUserId).toBe("user-7");
|
|
});
|
|
|
|
it("hydrates task metadata into the initial kanban state", () => {
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
snapshot.tasks[0].metadata = {
|
|
port_forwarding_enabled: true,
|
|
unrelated: "preserve",
|
|
};
|
|
|
|
const state = snapshotToState(snapshot);
|
|
|
|
expect(state.kanban?.tasks[0]?.metadata).toEqual(snapshot.tasks[0].metadata);
|
|
});
|
|
|
|
it("hydrates the signal-gated flag into the initial kanban step state", () => {
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
Object.assign(snapshot.steps[0], {
|
|
auto_advance_requires_signal: true,
|
|
});
|
|
|
|
const state = snapshotToState(snapshot);
|
|
|
|
expect(state.kanban?.steps[0]).toMatchObject({
|
|
auto_advance_requires_signal: true,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[0, 0],
|
|
[3, 3],
|
|
[undefined, undefined],
|
|
])("maps active subagent count %s", (wireValue, expected) => {
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
snapshot.tasks[0].active_subagent_count = wireValue;
|
|
|
|
const state = snapshotToState(snapshot);
|
|
|
|
expect(state.kanban?.tasks[0]?.activeSubagentCount).toBe(expected);
|
|
});
|
|
|
|
it.each([
|
|
[true, true],
|
|
[false, false],
|
|
[undefined, false],
|
|
])(
|
|
"maps auto_start_failed %s so a page reload does not hide or resurrect the badge",
|
|
(wireValue, expected) => {
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
snapshot.tasks[0].auto_start_failed = wireValue;
|
|
|
|
const state = snapshotToState(snapshot);
|
|
|
|
expect(state.kanban?.tasks[0]?.autoStartFailed).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it("maps parked-on-background-work fields so a REST refresh cannot silently clear them", () => {
|
|
// Regression guard: snapshotToState previously omitted these three fields
|
|
// entirely, so hooks/use-workflow-snapshot's REST-based refresh stripped
|
|
// an already-parked task's icon on every re-fetch after the initial
|
|
// boot-hydration skip (AC-68/AC-73/AC-62).
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
snapshot.tasks[0].parked_on_background_work = true;
|
|
snapshot.tasks[0].parked_revision = 3;
|
|
snapshot.tasks[0].parked_epoch = 2;
|
|
|
|
const state = snapshotToState(snapshot);
|
|
|
|
expect(state.kanban?.tasks[0]).toMatchObject({
|
|
parkedOnBackgroundWork: true,
|
|
parkedRevision: 3,
|
|
parkedEpoch: 2,
|
|
});
|
|
});
|
|
|
|
it("leaves parked-on-background-work fields unset when the snapshot omits them", () => {
|
|
const state = snapshotToState(snapshotWithPendingAction(undefined));
|
|
|
|
expect(state.kanban?.tasks[0]?.parkedOnBackgroundWork).toBeUndefined();
|
|
expect(state.kanban?.tasks[0]?.parkedRevision).toBeUndefined();
|
|
expect(state.kanban?.tasks[0]?.parkedEpoch).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
[true, true],
|
|
[false, false],
|
|
[undefined, false],
|
|
])(
|
|
"maps workspace_orphaned %s so a page reload does not hide or resurrect the badge",
|
|
(wireValue, expected) => {
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
snapshot.tasks[0].workspace_orphaned = wireValue;
|
|
|
|
const state = snapshotToState(snapshot);
|
|
|
|
expect(state.kanban?.tasks[0]?.workspaceOrphaned).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it("hydrates the task status summary into the initial kanban state", () => {
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
snapshot.tasks[0].status_summary = {
|
|
revision: 4,
|
|
updated_at: now,
|
|
primary_session: { id: "session-1", state: "WAITING_FOR_INPUT" },
|
|
};
|
|
|
|
const state = snapshotToState(snapshot);
|
|
|
|
expect(state.kanban?.tasks[0]?.statusSummary).toEqual(snapshot.tasks[0].status_summary);
|
|
});
|
|
|
|
it("maps the primary executor binding onto the kanban task", () => {
|
|
const snapshot = snapshotWithExecutor();
|
|
snapshot.tasks[0].is_remote_executor = false;
|
|
|
|
const state = snapshotToState(snapshot);
|
|
|
|
expect(state.kanban?.tasks[0]).toMatchObject({
|
|
primaryExecutorId: EXECUTOR_ID,
|
|
primaryExecutorType: EXECUTOR_TYPE,
|
|
primaryExecutorName: EXECUTOR_NAME,
|
|
isRemoteExecutor: false,
|
|
});
|
|
});
|
|
|
|
it("leaves the executor binding unset when the snapshot omits it", () => {
|
|
// The backend marks every executor field `omitempty`, so a task whose
|
|
// primary session has not bound an executor yet arrives with the keys
|
|
// absent rather than null. The mapper must yield undefined here, never
|
|
// crash and never invent a binding.
|
|
const state = snapshotToState(snapshotWithPendingAction(undefined));
|
|
|
|
expect(state.kanban?.tasks[0]?.primaryExecutorId).toBeUndefined();
|
|
expect(state.kanban?.tasks[0]?.primaryExecutorType).toBeUndefined();
|
|
expect(state.kanban?.tasks[0]?.primaryExecutorName).toBeUndefined();
|
|
expect(state.kanban?.tasks[0]?.isRemoteExecutor).toBe(false);
|
|
});
|
|
|
|
it("leaves an explicitly undefined executor binding unset", () => {
|
|
// Reviewer-requested wire-contract coverage. JSON omits undefined values,
|
|
// but typed snapshot fixtures can retain the property; both representations
|
|
// must produce the same unresolved client state.
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
snapshot.tasks[0].primary_executor_type = undefined;
|
|
|
|
const state = snapshotToState(snapshot);
|
|
|
|
expect(state.kanban?.tasks[0]?.primaryExecutorType).toBeUndefined();
|
|
});
|
|
|
|
it("preserves workflow step WIP fields", () => {
|
|
const state = snapshotToState({
|
|
workflow: {
|
|
id: workflowID,
|
|
workspace_id: workspaceID,
|
|
name: "Workflow",
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
steps: [
|
|
{
|
|
id: "step-1",
|
|
workflow_id: workflowID,
|
|
name: "Review",
|
|
position: 1,
|
|
color: "bg-blue-500",
|
|
allow_manual_move: true,
|
|
wip_limit: 2,
|
|
pull_from_step_id: "step-0",
|
|
},
|
|
],
|
|
tasks: [],
|
|
} as unknown as WorkflowSnapshot);
|
|
|
|
expect(state.kanban?.steps[0]).toMatchObject({
|
|
wip_limit: 2,
|
|
pull_from_step_id: "step-0",
|
|
});
|
|
});
|
|
|
|
it("forwards task WIP admission + overflow fields into the kanban state", () => {
|
|
// The HTTP snapshot path must preserve the backend queue projection.
|
|
const snapshot = snapshotWithPendingAction(undefined);
|
|
snapshot.tasks[0].state = "IN_PROGRESS";
|
|
snapshot.tasks[0].priority = "high";
|
|
snapshot.tasks[0].wip_admitted = false;
|
|
snapshot.tasks[0].queued_for_step_id = "step-1";
|
|
snapshot.tasks[0].queued_at = now;
|
|
snapshot.tasks.push({
|
|
...snapshot.tasks[0],
|
|
id: taskId("task-2"),
|
|
priority: "critical",
|
|
});
|
|
|
|
const state = snapshotToState(snapshot);
|
|
const task = state.kanban?.tasks.find((candidate) => candidate.id === taskId("task-1"));
|
|
const tasks = state.kanban?.tasks ?? [];
|
|
|
|
expect(task).toBeDefined();
|
|
if (!task) return;
|
|
|
|
expect(task).toMatchObject({
|
|
state: "IN_PROGRESS",
|
|
priority: "high",
|
|
createdAt: now,
|
|
wipAdmitted: false,
|
|
queuedForStepId: "step-1",
|
|
queuedAt: now,
|
|
});
|
|
const partition = partitionWipTasks(tasks, "step-1");
|
|
expect(partition).toMatchObject({ admitted: [] });
|
|
expect(partition.queued.map((queuedTask) => queuedTask.id)).toEqual([
|
|
taskId("task-2"),
|
|
taskId("task-1"),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("snapshotToState feeding hydrateState", () => {
|
|
it("does not blank an already-known executor when a re-hydration lands", () => {
|
|
// Regression guard for the bug this change fixes, exercised through the
|
|
// real chain: use-workflow-snapshot calls hydrate(snapshotToState(...)) on
|
|
// (re)mount, and mergeKanbanTasks replaces the cached task wholesale when
|
|
// the incoming updatedAt is not older. While snapshotToState dropped the
|
|
// executor fields, that replacement silently reset primaryExecutorType to
|
|
// undefined and the "Add folder" control disappeared.
|
|
const draft = structuredClone(defaultState) as AppState;
|
|
draft.kanban.tasks = [
|
|
{
|
|
id: taskId("task-1"),
|
|
workflowId: workflowID,
|
|
workflowStepId: "step-1",
|
|
title: "Task",
|
|
position: 0,
|
|
updatedAt: now,
|
|
primaryExecutorId: EXECUTOR_ID,
|
|
primaryExecutorType: EXECUTOR_TYPE,
|
|
primaryExecutorName: EXECUTOR_NAME,
|
|
},
|
|
] as AppState["kanban"]["tasks"];
|
|
|
|
const result = produce(draft, (d: Draft<AppState>) => {
|
|
hydrateState(d, snapshotToState(snapshotWithExecutor()));
|
|
});
|
|
|
|
expect(result.kanban.tasks[0]?.primaryExecutorType).toBe(EXECUTOR_TYPE);
|
|
});
|
|
});
|