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:
@@ -2435,6 +2435,10 @@ describe("MissionStore", () => {
|
||||
expect(firstTask?.branch).not.toBe(secondTask?.branch);
|
||||
expect(firstTask?.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);
|
||||
expect(branchGroup?.branchName).toBe("feature/shared");
|
||||
|
||||
@@ -1894,26 +1894,30 @@ function TaskCardComponent({
|
||||
<span className="card-branch-value">{branchMetadata.baseBranch}</span>
|
||||
</span>
|
||||
)}
|
||||
{task.branchContext != null && task.branchContext.groupId != null && (() => {
|
||||
const groupId = task.branchContext.groupId;
|
||||
const isSharedAssignment = task.branchContext.assignmentMode === "shared";
|
||||
{task.branchContext?.groupId && (() => {
|
||||
const { branchContext } = task;
|
||||
if (!branchContext?.groupId) return null;
|
||||
return (
|
||||
<span
|
||||
className="card-branch-chip"
|
||||
title={
|
||||
isSharedAssignment && branchMetadata.branch
|
||||
? `${groupId} · ${branchMetadata.branch}`
|
||||
: groupId
|
||||
branchContext.assignmentMode === "shared" && branchMetadata.branch
|
||||
? `${branchContext.groupId} · ${branchMetadata.branch}`
|
||||
: branchContext.groupId
|
||||
}
|
||||
onClick={(event) => {
|
||||
if (!onOpenGroupModal) return;
|
||||
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">
|
||||
{isSharedAssignment && branchMetadata.branch ? branchMetadata.branch : groupId}
|
||||
{branchContext.assignmentMode === "shared" && branchMetadata.branch
|
||||
? branchMetadata.branch
|
||||
: branchContext.groupId}
|
||||
</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