FN-5823: add shared-branch-group entry-point verification coverage
Add end-to-end checks to keep branch entry points aligned with shared-branch-group flow. - add dashboard integration tests covering shared-branch-group entry behavior across entry points - update TaskCard branch-group chip rendering to match shared assignment metadata handling - extend mission store test/docs coverage for shared-branch-group alignment expectations Files changed: docs/missions.md | 10 + packages/core/src/__tests__/mission-store.test.ts | 4 + packages/dashboard/app/components/TaskCard.tsx | 22 +- packages/dashboard/src/__tests__/shared-branch-group-entry-points.test.ts | 354 +++++++++++++++++++++ 4 files changed, 381 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-5823 Fusion-Task-Lineage: 7c06e2d2-0233-4a02-a79e-daecab47f0a9
This commit is contained in:
@@ -47,6 +47,16 @@ Supported modes:
|
|||||||
|
|
||||||
The Mission Manager create/edit form exposes this as **Branch strategy** plus a conditional **Branch name** field for `existing` and `custom-new`.
|
The Mission Manager create/edit form exposes this as **Branch strategy** plus a conditional **Branch name** field for `existing` and `custom-new`.
|
||||||
|
|
||||||
|
### Shared branch-group invariant across entry points
|
||||||
|
|
||||||
|
Across all branch entry points (planning/subtask creation, mission triage, and New Task `shared-group` creation), Fusion enforces one rule:
|
||||||
|
|
||||||
|
- Persist a **per-task working branch** on each task (the checkout branch used for execution).
|
||||||
|
- Persist the shared branch only as the **group merge target** via `branchContext.groupId` → `branch_groups.branchName`.
|
||||||
|
- Never persist the shared branch itself as a task working/checkout branch.
|
||||||
|
|
||||||
|
This keeps member execution isolated per task while still routing member landings into a single shared integration branch.
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
|
|
||||||
Use the Mission Manager UI to create missions and build hierarchy interactively.
|
Use the Mission Manager UI to create missions and build hierarchy interactively.
|
||||||
|
|||||||
@@ -2435,6 +2435,10 @@ describe("MissionStore", () => {
|
|||||||
expect(firstTask?.branch).not.toBe(secondTask?.branch);
|
expect(firstTask?.branch).not.toBe(secondTask?.branch);
|
||||||
expect(firstTask?.branchContext?.groupId).toBe(`mission:${mission.id}`);
|
expect(firstTask?.branchContext?.groupId).toBe(`mission:${mission.id}`);
|
||||||
expect(secondTask?.branchContext?.groupId).toBe(`mission:${mission.id}`);
|
expect(secondTask?.branchContext?.groupId).toBe(`mission:${mission.id}`);
|
||||||
|
expect(firstTask?.branchContext?.assignmentMode).toBe("shared");
|
||||||
|
expect(secondTask?.branchContext?.assignmentMode).toBe("shared");
|
||||||
|
expect(firstTask?.branchContext?.source).toBe("mission");
|
||||||
|
expect(secondTask?.branchContext?.source).toBe("mission");
|
||||||
|
|
||||||
const branchGroup = ts.getBranchGroupBySource("mission", mission.id);
|
const branchGroup = ts.getBranchGroupBySource("mission", mission.id);
|
||||||
expect(branchGroup?.branchName).toBe("feature/shared");
|
expect(branchGroup?.branchName).toBe("feature/shared");
|
||||||
|
|||||||
@@ -1894,26 +1894,30 @@ function TaskCardComponent({
|
|||||||
<span className="card-branch-value">{branchMetadata.baseBranch}</span>
|
<span className="card-branch-value">{branchMetadata.baseBranch}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.branchContext != null && task.branchContext.groupId != null && (() => {
|
{task.branchContext?.groupId && (() => {
|
||||||
const groupId = task.branchContext.groupId;
|
const { branchContext } = task;
|
||||||
const isSharedAssignment = task.branchContext.assignmentMode === "shared";
|
if (!branchContext?.groupId) return null;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="card-branch-chip"
|
className="card-branch-chip"
|
||||||
title={
|
title={
|
||||||
isSharedAssignment && branchMetadata.branch
|
branchContext.assignmentMode === "shared" && branchMetadata.branch
|
||||||
? `${groupId} · ${branchMetadata.branch}`
|
? `${branchContext.groupId} · ${branchMetadata.branch}`
|
||||||
: groupId
|
: branchContext.groupId
|
||||||
}
|
}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (!onOpenGroupModal) return;
|
if (!onOpenGroupModal) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onOpenGroupModal(groupId);
|
onOpenGroupModal(branchContext.groupId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="card-branch-label">{isSharedAssignment ? "Shared" : "Group"}</span>
|
<span className="card-branch-label">
|
||||||
|
{branchContext.assignmentMode === "shared" ? "Shared" : "Group"}
|
||||||
|
</span>
|
||||||
<span className="card-branch-value">
|
<span className="card-branch-value">
|
||||||
{isSharedAssignment && branchMetadata.branch ? branchMetadata.branch : groupId}
|
{branchContext.assignmentMode === "shared" && branchMetadata.branch
|
||||||
|
? branchMetadata.branch
|
||||||
|
: branchContext.groupId}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import { createApiRoutes } from "../routes.js";
|
||||||
|
import type { TaskStore, TaskCreateInput, TaskBranchContext } from "@fusion/core";
|
||||||
|
import { request as performRequest } from "../test-request.js";
|
||||||
|
|
||||||
|
const { mockExecFile, mockExecSync } = vi.hoisted(() => ({
|
||||||
|
mockExecFile: vi.fn(),
|
||||||
|
mockExecSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:child_process", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||||
|
mockExecSync.mockImplementation(((...args: Parameters<typeof actual.execSync>) => actual.execSync(...args)) as typeof actual.execSync);
|
||||||
|
mockExecFile.mockImplementation((...callArgs: unknown[]) => {
|
||||||
|
const [file, argsOrCb, maybeOptions, maybeCb] = callArgs as [string, unknown, unknown, unknown];
|
||||||
|
const args = Array.isArray(argsOrCb) ? argsOrCb : [];
|
||||||
|
const cb =
|
||||||
|
typeof maybeCb === "function"
|
||||||
|
? (maybeCb as (err: unknown, stdout?: string, stderr?: string) => void)
|
||||||
|
: typeof maybeOptions === "function"
|
||||||
|
? (maybeOptions as (err: unknown, stdout?: string, stderr?: string) => void)
|
||||||
|
: typeof argsOrCb === "function"
|
||||||
|
? (argsOrCb as (err: unknown, stdout?: string, stderr?: string) => void)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (file === "pgrep" && args[0] === "-f" && args[1] === "vitest") {
|
||||||
|
if (cb) queueMicrotask(() => cb(null, "", ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (actual.execFile as (...innerArgs: unknown[]) => unknown)(...callArgs);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
execSync: mockExecSync,
|
||||||
|
execFile: mockExecFile,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@fusion/core", async (importOriginal) => {
|
||||||
|
const { createCoreMock } = await import("../test/mockCoreEngine.js");
|
||||||
|
return createCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
|
||||||
|
resolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-test"),
|
||||||
|
isGhAvailable: vi.fn(),
|
||||||
|
isGhAuthenticated: vi.fn(),
|
||||||
|
isQmdAvailable: vi.fn().mockResolvedValue(false),
|
||||||
|
CentralCore: vi.fn().mockImplementation(() => ({
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
listProjects: vi.fn().mockResolvedValue([]),
|
||||||
|
reconcileProjectStatuses: vi.fn().mockResolvedValue(undefined),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@fusion/engine", async () => {
|
||||||
|
const { createEngineMock } = await import("../test/mockCoreEngine.js");
|
||||||
|
return createEngineMock({
|
||||||
|
createFnAgent: vi.fn(async (options?: { onText?: (delta: string) => void }) => ({
|
||||||
|
session: {
|
||||||
|
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||||
|
prompt: vi.fn(async function (this: { state?: { messages?: Array<{ role: string; content: string }> } }, message: string) {
|
||||||
|
options?.onText?.("mock-ai-output");
|
||||||
|
const messages = this.state?.messages ?? [];
|
||||||
|
messages.push({ role: "user", content: message });
|
||||||
|
messages.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: JSON.stringify({
|
||||||
|
subtasks: [
|
||||||
|
{ id: "subtask-1", title: "Auth backend", description: "Implement backend", suggestedSize: "M", dependsOn: [] },
|
||||||
|
{ id: "subtask-2", title: "Auth UI", description: "Implement UI", suggestedSize: "S", dependsOn: ["subtask-1"] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
promptWithFallback: vi.fn(async (session: { prompt: (message: string) => Promise<void> }, prompt: string) => {
|
||||||
|
await session.prompt(prompt);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type BranchGroup = {
|
||||||
|
id: string;
|
||||||
|
sourceType: "planning" | "mission" | "new-task";
|
||||||
|
sourceId: string;
|
||||||
|
branchName: string;
|
||||||
|
autoMerge: boolean;
|
||||||
|
prState: "none";
|
||||||
|
status: "open";
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createMockStore(): TaskStore {
|
||||||
|
const tasks = new Map<string, { id: string; title?: string; description: string; branch?: string; branchContext?: TaskBranchContext }>();
|
||||||
|
const branchGroupsBySource = new Map<string, BranchGroup>();
|
||||||
|
const branchGroupsByBranch = new Map<string, BranchGroup>();
|
||||||
|
let seq = 7000;
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
getTask: vi.fn(async (id: string) => tasks.get(id) ?? null),
|
||||||
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
searchTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
findRecentTasksByContentFingerprint: vi.fn().mockResolvedValue([]),
|
||||||
|
createTask: vi.fn(async (input: TaskCreateInput) => {
|
||||||
|
seq += 1;
|
||||||
|
const id = `FN-${seq}`;
|
||||||
|
const created = {
|
||||||
|
id,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
column: input.column ?? "triage",
|
||||||
|
dependencies: input.dependencies ?? [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
branch: input.branch,
|
||||||
|
branchContext: input.branchContext,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
tasks.set(id, created);
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
createTaskWithReservedId: undefined,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(async (id: string, patch: { branch?: string; branchContext?: TaskBranchContext }) => {
|
||||||
|
const existing = tasks.get(id);
|
||||||
|
if (!existing) throw new Error("missing");
|
||||||
|
const next = { ...existing, ...patch };
|
||||||
|
tasks.set(id, next);
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
updateStep: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({ defaultBranch: "main", autoMerge: false }),
|
||||||
|
getSettingsFast: vi.fn().mockResolvedValue({ defaultBranch: "main", autoMerge: false }),
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
updateGlobalSettings: vi.fn(),
|
||||||
|
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||||
|
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||||
|
getGlobalSettingsStore: vi.fn().mockReturnValue({
|
||||||
|
getSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
updateSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.fusion/settings.json"),
|
||||||
|
init: vi.fn().mockResolvedValue(false),
|
||||||
|
invalidateCache: vi.fn(),
|
||||||
|
}),
|
||||||
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||||
|
getAgentLogCount: vi.fn().mockResolvedValue(0),
|
||||||
|
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
|
||||||
|
addSteeringComment: vi.fn(),
|
||||||
|
addTaskComment: vi.fn(),
|
||||||
|
updateTaskComment: vi.fn(),
|
||||||
|
deleteTaskComment: vi.fn(),
|
||||||
|
getTaskDocuments: vi.fn().mockResolvedValue([]),
|
||||||
|
getTaskDocument: vi.fn().mockResolvedValue(null),
|
||||||
|
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
|
||||||
|
getAllDocuments: vi.fn().mockResolvedValue([]),
|
||||||
|
upsertTaskDocument: vi.fn(),
|
||||||
|
deleteTaskDocument: vi.fn().mockResolvedValue(undefined),
|
||||||
|
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||||
|
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||||
|
linkGithubIssue: vi.fn().mockResolvedValue(undefined),
|
||||||
|
recordActivity: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||||
|
getDistributedTaskIdAllocator: vi.fn().mockReturnValue({
|
||||||
|
reserveDistributedTaskId: vi.fn().mockResolvedValue({ reservationId: "res-1", taskId: "FN-8001" }),
|
||||||
|
commitDistributedTaskIdReservation: vi.fn().mockResolvedValue({}),
|
||||||
|
abortDistributedTaskIdReservation: vi.fn().mockResolvedValue({}),
|
||||||
|
}),
|
||||||
|
ensureBranchGroupForSource: vi.fn((sourceType: "planning" | "mission" | "new-task", sourceId: string, init: { branchName: string; autoMerge?: boolean }) => {
|
||||||
|
const key = `${sourceType}:${sourceId}`;
|
||||||
|
const existing = branchGroupsBySource.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const group: BranchGroup = {
|
||||||
|
id: `BG-${sourceType}-${sourceId}`,
|
||||||
|
sourceType,
|
||||||
|
sourceId,
|
||||||
|
branchName: init.branchName,
|
||||||
|
autoMerge: Boolean(init.autoMerge),
|
||||||
|
prState: "none",
|
||||||
|
status: "open",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
branchGroupsBySource.set(key, group);
|
||||||
|
branchGroupsByBranch.set(init.branchName, group);
|
||||||
|
return group;
|
||||||
|
}),
|
||||||
|
getBranchGroupBySource: vi.fn((sourceType: "planning" | "mission" | "new-task", sourceId: string) => branchGroupsBySource.get(`${sourceType}:${sourceId}`) ?? null),
|
||||||
|
getBranchGroupByBranchName: vi.fn((name: string) => branchGroupsByBranch.get(name) ?? null),
|
||||||
|
setTaskBranchGroup: vi.fn(async (taskId: string, groupId: string) => {
|
||||||
|
const task = tasks.get(taskId);
|
||||||
|
if (!task) return;
|
||||||
|
const group = [...branchGroupsBySource.values()].find((candidate) => candidate.id === groupId);
|
||||||
|
if (!group) return;
|
||||||
|
tasks.set(taskId, {
|
||||||
|
...task,
|
||||||
|
branchContext: {
|
||||||
|
groupId,
|
||||||
|
source: group.sourceType,
|
||||||
|
assignmentMode: "shared",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||||
|
createWorkflowStep: vi.fn(),
|
||||||
|
getWorkflowStep: vi.fn(),
|
||||||
|
updateWorkflowStep: vi.fn(),
|
||||||
|
deleteWorkflowStep: vi.fn(),
|
||||||
|
getMissionStore: vi.fn().mockReturnValue({
|
||||||
|
listMissions: vi.fn().mockReturnValue([]),
|
||||||
|
createMission: vi.fn(),
|
||||||
|
getMissionWithHierarchy: vi.fn(),
|
||||||
|
updateMission: vi.fn(),
|
||||||
|
getMission: vi.fn(),
|
||||||
|
deleteMission: vi.fn(),
|
||||||
|
listMilestonesByMission: vi.fn().mockReturnValue([]),
|
||||||
|
createMilestone: vi.fn(),
|
||||||
|
updateMilestone: vi.fn(),
|
||||||
|
getMilestone: vi.fn(),
|
||||||
|
deleteMilestone: vi.fn(),
|
||||||
|
listTasksByMilestone: vi.fn().mockReturnValue([]),
|
||||||
|
createMissionTask: vi.fn(),
|
||||||
|
updateMissionTask: vi.fn(),
|
||||||
|
getMissionTask: vi.fn(),
|
||||||
|
deleteMissionTask: vi.fn(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return store as unknown as TaskStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function REQUEST(app: express.Express, method: string, path: string, body?: unknown) {
|
||||||
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||||
|
const res = await performRequest(app, method, path, payload, { "Content-Type": "application/json" });
|
||||||
|
return { status: res.status, body: res.body };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shared branch-group entry-point invariants", () => {
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMockStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps planning/subtasks + new-task shared mode on per-task working branches", async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
|
||||||
|
const subtaskStart = await REQUEST(app, "POST", "/api/subtasks/start-streaming", { description: "Break down auth scope" });
|
||||||
|
expect(subtaskStart.status).toBe(201);
|
||||||
|
const sessionId = subtaskStart.body.sessionId as string;
|
||||||
|
|
||||||
|
const subtaskCreate = await REQUEST(app, "POST", "/api/subtasks/create-tasks", {
|
||||||
|
sessionId,
|
||||||
|
branchSelection: { mode: "custom-new", branchName: "feature/auth-shared", baseBranch: "main" },
|
||||||
|
branchAssignment: { mode: "shared" },
|
||||||
|
subtasks: [
|
||||||
|
{ tempId: "temp-1", title: "Auth backend", description: "Implement backend" },
|
||||||
|
{ tempId: "temp-2", title: "Auth UI", description: "Implement UI", dependsOn: ["temp-1"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(subtaskCreate.status).toBe(201);
|
||||||
|
const planningCalls = (store.createTask as ReturnType<typeof vi.fn>).mock.calls;
|
||||||
|
const firstPlanning = planningCalls[0]?.[0] as TaskCreateInput;
|
||||||
|
const secondPlanning = planningCalls[1]?.[0] as TaskCreateInput;
|
||||||
|
expect(firstPlanning.branch).toBe("feature/auth-shared/auth-backend");
|
||||||
|
expect(secondPlanning.branch).toBe("feature/auth-shared/auth-ui");
|
||||||
|
expect(firstPlanning.branch).not.toBe("feature/auth-shared");
|
||||||
|
expect(secondPlanning.branch).not.toBe("feature/auth-shared");
|
||||||
|
expect(firstPlanning.branch).not.toBe(secondPlanning.branch);
|
||||||
|
const planningGroup = (store.getBranchGroupBySource as ReturnType<typeof vi.fn>).mock.results.at(-1)?.value as BranchGroup;
|
||||||
|
expect(firstPlanning.branchContext).toMatchObject({ groupId: `planning:${sessionId}`, source: "planning", assignmentMode: "shared" });
|
||||||
|
expect(secondPlanning.branchContext).toMatchObject({ groupId: `planning:${sessionId}`, source: "planning", assignmentMode: "shared" });
|
||||||
|
expect(planningGroup.branchName).toBe("feature/auth-shared");
|
||||||
|
|
||||||
|
const newTask = await REQUEST(app, "POST", "/api/tasks", {
|
||||||
|
title: "Shared entry-point task",
|
||||||
|
description: "Task using shared group",
|
||||||
|
branchSelection: { mode: "shared-group", branchName: "feature/newtask-shared" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(newTask.status).toBe(201);
|
||||||
|
const newTaskCreateCall = (store.createTask as ReturnType<typeof vi.fn>).mock.calls[2]?.[0] as TaskCreateInput;
|
||||||
|
const newTaskGroup = (store.getBranchGroupByBranchName as ReturnType<typeof vi.fn>).mock.results.at(-1)?.value as BranchGroup | null;
|
||||||
|
expect(newTaskCreateCall.branch).toBe("feature/newtask-shared/shared-entry-point-task");
|
||||||
|
expect(newTaskCreateCall.branch).not.toBe("feature/newtask-shared");
|
||||||
|
const createdTaskId = newTask.body.id as string;
|
||||||
|
const persistedTask = await store.getTask(createdTaskId);
|
||||||
|
expect(persistedTask?.branchContext).toMatchObject({ source: "new-task", assignmentMode: "shared" });
|
||||||
|
const joinedGroupId = (store.setTaskBranchGroup as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as string;
|
||||||
|
expect(joinedGroupId).toBe(persistedTask?.branchContext?.groupId);
|
||||||
|
expect(newTaskGroup).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves non-shared selection semantics", async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
|
||||||
|
const perTaskDerivedStart = await REQUEST(app, "POST", "/api/subtasks/start-streaming", { description: "Break down auth scope" });
|
||||||
|
const perTaskDerivedSessionId = perTaskDerivedStart.body.sessionId as string;
|
||||||
|
await REQUEST(app, "POST", "/api/subtasks/create-tasks", {
|
||||||
|
sessionId: perTaskDerivedSessionId,
|
||||||
|
branchSelection: { mode: "custom-new", branchName: "feature/auth-shared", baseBranch: "main" },
|
||||||
|
branchAssignment: { mode: "per-task-derived" },
|
||||||
|
subtasks: [
|
||||||
|
{ tempId: "temp-1", title: "Auth backend", description: "Implement backend" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await REQUEST(app, "POST", "/api/tasks", { description: "project default", branchSelection: { mode: "project-default" } });
|
||||||
|
await REQUEST(app, "POST", "/api/tasks", { description: "existing", branchSelection: { mode: "existing", branchName: "feature/existing", baseBranch: "main" } });
|
||||||
|
await REQUEST(app, "POST", "/api/tasks", { description: "custom", branchSelection: { mode: "custom-new", branchName: "feature/custom", baseBranch: "main" } });
|
||||||
|
await REQUEST(app, "POST", "/api/tasks", { title: "Auto task", description: "auto", branchSelection: { mode: "auto-new" } });
|
||||||
|
|
||||||
|
const calls = (store.createTask as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[0] as TaskCreateInput);
|
||||||
|
expect(calls[0].branch).toBe("feature/auth-shared");
|
||||||
|
expect(calls[0].branchContext).toMatchObject({ assignmentMode: "per-task-derived", source: "planning" });
|
||||||
|
|
||||||
|
expect(calls[1].branch).toBeUndefined();
|
||||||
|
expect(calls[1].branchContext).toBeUndefined();
|
||||||
|
|
||||||
|
expect(calls[2].branch).toBe("feature/existing");
|
||||||
|
expect(calls[2].branchContext).toBeUndefined();
|
||||||
|
|
||||||
|
expect(calls[3].branch).toBe("feature/custom");
|
||||||
|
expect(calls[3].branchContext).toBeUndefined();
|
||||||
|
|
||||||
|
expect(calls[4].branch).toBeUndefined();
|
||||||
|
expect((store.updateTask as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1]).toMatchObject({
|
||||||
|
branch: expect.stringMatching(/^fusion\/fn-/),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.ensureBranchGroupForSource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user