test(FN-1084): expand mission workflow automated test coverage
- Add dashboard mission e2e coverage for autopilot endpoint flows and mission summary behavior - Add comprehensive mission interview tests for planning, step progression, and output handling - Extend CLI mission command tests for add/link workflows and argument validation paths - Strengthen engine mission scheduler assertions around activation sequencing and state transitions - Add core mission store tests to validate mission persistence and related edge cases
This commit is contained in:
@@ -38,6 +38,10 @@ const {
|
|||||||
runMissionShow,
|
runMissionShow,
|
||||||
runMissionDelete,
|
runMissionDelete,
|
||||||
runMissionActivateSlice,
|
runMissionActivateSlice,
|
||||||
|
runMilestoneAdd,
|
||||||
|
runSliceAdd,
|
||||||
|
runFeatureAdd,
|
||||||
|
runFeatureLinkTask,
|
||||||
} = await import("./mission.js");
|
} = await import("./mission.js");
|
||||||
|
|
||||||
// Helper to mock console output
|
// Helper to mock console output
|
||||||
@@ -103,12 +107,44 @@ function createMockMissionStore(overrides = {}) {
|
|||||||
title: "Test Mission",
|
title: "Test Mission",
|
||||||
status: "active",
|
status: "active",
|
||||||
}),
|
}),
|
||||||
deleteMission: vi.fn(),
|
addMilestone: vi.fn().mockReturnValue({
|
||||||
|
id: "MS-001",
|
||||||
|
title: "New Milestone",
|
||||||
|
status: "planning",
|
||||||
|
}),
|
||||||
|
getMilestone: vi.fn().mockReturnValue({
|
||||||
|
id: "MS-001",
|
||||||
|
title: "Milestone 1",
|
||||||
|
status: "active",
|
||||||
|
}),
|
||||||
|
addSlice: vi.fn().mockReturnValue({
|
||||||
|
id: "SL-001",
|
||||||
|
title: "New Slice",
|
||||||
|
status: "pending",
|
||||||
|
}),
|
||||||
getSlice: vi.fn().mockReturnValue({
|
getSlice: vi.fn().mockReturnValue({
|
||||||
id: "SL-001",
|
id: "SL-001",
|
||||||
title: "Test Slice",
|
title: "Test Slice",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
}),
|
}),
|
||||||
|
addFeature: vi.fn().mockReturnValue({
|
||||||
|
id: "F-001",
|
||||||
|
title: "New Feature",
|
||||||
|
status: "defined",
|
||||||
|
acceptanceCriteria: undefined,
|
||||||
|
}),
|
||||||
|
getFeature: vi.fn().mockReturnValue({
|
||||||
|
id: "F-001",
|
||||||
|
title: "Feature 1",
|
||||||
|
status: "defined",
|
||||||
|
}),
|
||||||
|
linkFeatureToTask: vi.fn().mockImplementation((featureId: string, taskId: string) => ({
|
||||||
|
id: featureId,
|
||||||
|
title: "Feature 1",
|
||||||
|
status: "triaged",
|
||||||
|
taskId,
|
||||||
|
})),
|
||||||
|
deleteMission: vi.fn(),
|
||||||
activateSlice: vi.fn().mockReturnValue({
|
activateSlice: vi.fn().mockReturnValue({
|
||||||
id: "SL-001",
|
id: "SL-001",
|
||||||
title: "Test Slice",
|
title: "Test Slice",
|
||||||
@@ -119,6 +155,17 @@ function createMockMissionStore(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockResolvedProjectStore(
|
||||||
|
missionStore: ReturnType<typeof createMockMissionStore>,
|
||||||
|
overrides: Partial<{ getTask: ReturnType<typeof vi.fn> }> = {},
|
||||||
|
) {
|
||||||
|
vi.mocked(getStore).mockResolvedValue({
|
||||||
|
getMissionStore: () => missionStore,
|
||||||
|
getTask: vi.fn().mockResolvedValue({ id: "FN-001" }),
|
||||||
|
...overrides,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
describe("mission commands", () => {
|
describe("mission commands", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -512,4 +559,228 @@ describe("mission commands", () => {
|
|||||||
mockError.mockRestore();
|
mockError.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("runMilestoneAdd", () => {
|
||||||
|
it("adds a milestone successfully", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore({
|
||||||
|
addMilestone: vi.fn().mockReturnValue({ id: "MS-010", title: "M2", status: "planning" }),
|
||||||
|
});
|
||||||
|
mockResolvedProjectStore(mockMissionStore);
|
||||||
|
|
||||||
|
const consoleCapture = captureConsole();
|
||||||
|
try {
|
||||||
|
await runMilestoneAdd("M-001", "M2", "Details");
|
||||||
|
expect(mockMissionStore.addMilestone).toHaveBeenCalledWith("M-001", {
|
||||||
|
title: "M2",
|
||||||
|
description: "Details",
|
||||||
|
});
|
||||||
|
expect(consoleCapture.logs.some((line) => line.includes("Added MS-010"))).toBe(true);
|
||||||
|
} finally {
|
||||||
|
consoleCapture.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when mission does not exist", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore({ getMission: vi.fn().mockReturnValue(undefined) });
|
||||||
|
mockResolvedProjectStore(mockMissionStore);
|
||||||
|
|
||||||
|
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||||
|
throw new Error("process.exit");
|
||||||
|
});
|
||||||
|
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(runMilestoneAdd("M-404", "M2")).rejects.toThrow("process.exit");
|
||||||
|
expect(mockError).toHaveBeenCalledWith("✗ Mission M-404 not found");
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
mockExit.mockRestore();
|
||||||
|
mockError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts interactively when title is omitted", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore();
|
||||||
|
mockResolvedProjectStore(mockMissionStore);
|
||||||
|
|
||||||
|
const mockRl = {
|
||||||
|
question: vi.fn().mockResolvedValueOnce("Interactive milestone").mockResolvedValueOnce("Interactive desc"),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mocked(createInterface).mockReturnValue(mockRl as any);
|
||||||
|
|
||||||
|
await runMilestoneAdd("M-001");
|
||||||
|
|
||||||
|
expect(mockRl.question).toHaveBeenCalledWith("Milestone title: ");
|
||||||
|
expect(mockMissionStore.addMilestone).toHaveBeenCalledWith("M-001", {
|
||||||
|
title: "Interactive milestone",
|
||||||
|
description: "Interactive desc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runSliceAdd", () => {
|
||||||
|
it("adds a slice successfully", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore({
|
||||||
|
addSlice: vi.fn().mockReturnValue({ id: "SL-010", title: "Slice", status: "pending" }),
|
||||||
|
});
|
||||||
|
mockResolvedProjectStore(mockMissionStore);
|
||||||
|
|
||||||
|
const consoleCapture = captureConsole();
|
||||||
|
try {
|
||||||
|
await runSliceAdd("MS-001", "Slice", "Slice details");
|
||||||
|
expect(mockMissionStore.addSlice).toHaveBeenCalledWith("MS-001", {
|
||||||
|
title: "Slice",
|
||||||
|
description: "Slice details",
|
||||||
|
});
|
||||||
|
expect(consoleCapture.logs.some((line) => line.includes("Added SL-010"))).toBe(true);
|
||||||
|
} finally {
|
||||||
|
consoleCapture.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when milestone does not exist", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore({ getMilestone: vi.fn().mockReturnValue(undefined) });
|
||||||
|
mockResolvedProjectStore(mockMissionStore);
|
||||||
|
|
||||||
|
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||||
|
throw new Error("process.exit");
|
||||||
|
});
|
||||||
|
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(runSliceAdd("MS-404", "Slice")).rejects.toThrow("process.exit");
|
||||||
|
expect(mockError).toHaveBeenCalledWith("✗ Milestone MS-404 not found");
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
mockExit.mockRestore();
|
||||||
|
mockError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts interactively when title is omitted", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore();
|
||||||
|
mockResolvedProjectStore(mockMissionStore);
|
||||||
|
|
||||||
|
const mockRl = {
|
||||||
|
question: vi.fn().mockResolvedValueOnce("Interactive slice").mockResolvedValueOnce("Interactive slice desc"),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mocked(createInterface).mockReturnValue(mockRl as any);
|
||||||
|
|
||||||
|
await runSliceAdd("MS-001");
|
||||||
|
|
||||||
|
expect(mockRl.question).toHaveBeenCalledWith("Slice title: ");
|
||||||
|
expect(mockMissionStore.addSlice).toHaveBeenCalledWith("MS-001", {
|
||||||
|
title: "Interactive slice",
|
||||||
|
description: "Interactive slice desc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runFeatureAdd", () => {
|
||||||
|
it("adds a feature with acceptance criteria", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore({
|
||||||
|
addFeature: vi.fn().mockReturnValue({
|
||||||
|
id: "F-010",
|
||||||
|
title: "Feature",
|
||||||
|
status: "defined",
|
||||||
|
acceptanceCriteria: "Ship works",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
mockResolvedProjectStore(mockMissionStore);
|
||||||
|
|
||||||
|
await runFeatureAdd("SL-001", "Feature", "Feature details", "Ship works");
|
||||||
|
|
||||||
|
expect(mockMissionStore.addFeature).toHaveBeenCalledWith("SL-001", {
|
||||||
|
title: "Feature",
|
||||||
|
description: "Feature details",
|
||||||
|
acceptanceCriteria: "Ship works",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when slice does not exist", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore({ getSlice: vi.fn().mockReturnValue(undefined) });
|
||||||
|
mockResolvedProjectStore(mockMissionStore);
|
||||||
|
|
||||||
|
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||||
|
throw new Error("process.exit");
|
||||||
|
});
|
||||||
|
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(runFeatureAdd("SL-404", "Feature")).rejects.toThrow("process.exit");
|
||||||
|
expect(mockError).toHaveBeenCalledWith("✗ Slice SL-404 not found");
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
mockExit.mockRestore();
|
||||||
|
mockError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts interactively when title is omitted", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore();
|
||||||
|
mockResolvedProjectStore(mockMissionStore);
|
||||||
|
|
||||||
|
const mockRl = {
|
||||||
|
question: vi.fn()
|
||||||
|
.mockResolvedValueOnce("Interactive feature")
|
||||||
|
.mockResolvedValueOnce("Interactive feature desc")
|
||||||
|
.mockResolvedValueOnce("Interactive acceptance"),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mocked(createInterface).mockReturnValue(mockRl as any);
|
||||||
|
|
||||||
|
await runFeatureAdd("SL-001");
|
||||||
|
|
||||||
|
expect(mockRl.question).toHaveBeenCalledWith("Feature title: ");
|
||||||
|
expect(mockMissionStore.addFeature).toHaveBeenCalledWith("SL-001", {
|
||||||
|
title: "Interactive feature",
|
||||||
|
description: "Interactive feature desc",
|
||||||
|
acceptanceCriteria: "Interactive acceptance",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runFeatureLinkTask", () => {
|
||||||
|
it("links a feature to a task", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore();
|
||||||
|
const getTask = vi.fn().mockResolvedValue({ id: "FN-001" });
|
||||||
|
mockResolvedProjectStore(mockMissionStore, { getTask });
|
||||||
|
|
||||||
|
await runFeatureLinkTask("F-001", "FN-001");
|
||||||
|
|
||||||
|
expect(getTask).toHaveBeenCalledWith("FN-001");
|
||||||
|
expect(mockMissionStore.linkFeatureToTask).toHaveBeenCalledWith("F-001", "FN-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when feature does not exist", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore({ getFeature: vi.fn().mockReturnValue(undefined) });
|
||||||
|
mockResolvedProjectStore(mockMissionStore);
|
||||||
|
|
||||||
|
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||||
|
throw new Error("process.exit");
|
||||||
|
});
|
||||||
|
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(runFeatureLinkTask("F-404", "FN-001")).rejects.toThrow("process.exit");
|
||||||
|
expect(mockError).toHaveBeenCalledWith("✗ Feature F-404 not found");
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
mockExit.mockRestore();
|
||||||
|
mockError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when task does not exist", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore();
|
||||||
|
const getTask = vi.fn().mockRejectedValue(new Error("missing"));
|
||||||
|
mockResolvedProjectStore(mockMissionStore, { getTask });
|
||||||
|
|
||||||
|
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||||
|
throw new Error("process.exit");
|
||||||
|
});
|
||||||
|
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(runFeatureLinkTask("F-001", "FN-404")).rejects.toThrow("process.exit");
|
||||||
|
expect(mockError).toHaveBeenCalledWith("✗ Task FN-404 not found");
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
mockExit.mockRestore();
|
||||||
|
mockError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -169,6 +169,117 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Mission Summary & Slice Discovery Tests ───────────────────────────
|
||||||
|
|
||||||
|
describe("Mission summary helpers", () => {
|
||||||
|
it("getMissionSummary returns zeros for an empty mission", () => {
|
||||||
|
const mission = store.createMission({ title: "Empty" });
|
||||||
|
|
||||||
|
const summary = store.getMissionSummary(mission.id);
|
||||||
|
|
||||||
|
expect(summary).toEqual({
|
||||||
|
totalMilestones: 0,
|
||||||
|
completedMilestones: 0,
|
||||||
|
totalFeatures: 0,
|
||||||
|
completedFeatures: 0,
|
||||||
|
progressPercent: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMissionSummary falls back to milestone progress when no features exist", () => {
|
||||||
|
const mission = store.createMission({ title: "Milestones only" });
|
||||||
|
const m1 = store.addMilestone(mission.id, { title: "M1" });
|
||||||
|
store.addMilestone(mission.id, { title: "M2" });
|
||||||
|
store.updateMilestone(m1.id, { status: "complete" });
|
||||||
|
|
||||||
|
const summary = store.getMissionSummary(mission.id);
|
||||||
|
|
||||||
|
expect(summary.totalMilestones).toBe(2);
|
||||||
|
expect(summary.completedMilestones).toBe(1);
|
||||||
|
expect(summary.totalFeatures).toBe(0);
|
||||||
|
expect(summary.completedFeatures).toBe(0);
|
||||||
|
expect(summary.progressPercent).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMissionSummary reports partial feature completion", () => {
|
||||||
|
const mission = store.createMission({ title: "Partial features" });
|
||||||
|
const milestone = store.addMilestone(mission.id, { title: "M1" });
|
||||||
|
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||||
|
const f1 = store.addFeature(slice.id, { title: "F1" });
|
||||||
|
const f2 = store.addFeature(slice.id, { title: "F2" });
|
||||||
|
store.addFeature(slice.id, { title: "F3" });
|
||||||
|
|
||||||
|
store.updateFeature(f1.id, { status: "done" });
|
||||||
|
store.updateFeature(f2.id, { status: "done" });
|
||||||
|
|
||||||
|
const summary = store.getMissionSummary(mission.id);
|
||||||
|
|
||||||
|
expect(summary.totalFeatures).toBe(3);
|
||||||
|
expect(summary.completedFeatures).toBe(2);
|
||||||
|
expect(summary.progressPercent).toBe(67);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMissionSummary reports 100% when all features are done", () => {
|
||||||
|
const mission = store.createMission({ title: "All done" });
|
||||||
|
const milestone = store.addMilestone(mission.id, { title: "M1" });
|
||||||
|
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||||
|
const f1 = store.addFeature(slice.id, { title: "F1" });
|
||||||
|
const f2 = store.addFeature(slice.id, { title: "F2" });
|
||||||
|
|
||||||
|
store.updateFeature(f1.id, { status: "done" });
|
||||||
|
store.updateFeature(f2.id, { status: "done" });
|
||||||
|
|
||||||
|
const summary = store.getMissionSummary(mission.id);
|
||||||
|
expect(summary.progressPercent).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMissionSummary rounds progress percent accurately", () => {
|
||||||
|
const mission = store.createMission({ title: "Rounding" });
|
||||||
|
const milestone = store.addMilestone(mission.id, { title: "M1" });
|
||||||
|
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||||
|
const f1 = store.addFeature(slice.id, { title: "F1" });
|
||||||
|
store.addFeature(slice.id, { title: "F2" });
|
||||||
|
store.addFeature(slice.id, { title: "F3" });
|
||||||
|
|
||||||
|
store.updateFeature(f1.id, { status: "done" });
|
||||||
|
|
||||||
|
const summary = store.getMissionSummary(mission.id);
|
||||||
|
expect(summary.progressPercent).toBe(33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findNextPendingSlice skips completed slices in earlier milestones", () => {
|
||||||
|
const mission = store.createMission({ title: "Next pending" });
|
||||||
|
const m1 = store.addMilestone(mission.id, { title: "M1" });
|
||||||
|
const m2 = store.addMilestone(mission.id, { title: "M2" });
|
||||||
|
const completed = store.addSlice(m1.id, { title: "Done slice" });
|
||||||
|
const pending = store.addSlice(m2.id, { title: "Pending slice" });
|
||||||
|
|
||||||
|
store.updateSlice(completed.id, { status: "complete" });
|
||||||
|
|
||||||
|
const next = store.findNextPendingSlice(mission.id);
|
||||||
|
expect(next?.id).toBe(pending.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findNextPendingSlice returns undefined when no pending slices exist", () => {
|
||||||
|
const mission = store.createMission({ title: "No pending" });
|
||||||
|
const milestone = store.addMilestone(mission.id, { title: "M1" });
|
||||||
|
const slice = store.addSlice(milestone.id, { title: "Completed" });
|
||||||
|
store.updateSlice(slice.id, { status: "complete" });
|
||||||
|
|
||||||
|
const next = store.findNextPendingSlice(mission.id);
|
||||||
|
expect(next).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findNextPendingSlice returns first pending slice in a single-milestone mission", () => {
|
||||||
|
const mission = store.createMission({ title: "Single" });
|
||||||
|
const milestone = store.addMilestone(mission.id, { title: "M1" });
|
||||||
|
const pending = store.addSlice(milestone.id, { title: "Pending" });
|
||||||
|
|
||||||
|
const next = store.findNextPendingSlice(mission.id);
|
||||||
|
expect(next?.id).toBe(pending.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Milestone CRUD Tests ──────────────────────────────────────────────
|
// ── Milestone CRUD Tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe("Milestone CRUD", () => {
|
describe("Milestone CRUD", () => {
|
||||||
|
|||||||
@@ -287,8 +287,6 @@ function createMockMissionStore() {
|
|||||||
|
|
||||||
// Mission status helpers for pause/stop
|
// Mission status helpers for pause/stop
|
||||||
computeMissionStatus: vi.fn(() => "active"),
|
computeMissionStatus: vi.fn(() => "active"),
|
||||||
getMilestone: vi.fn((id: string) => milestones.get(id)),
|
|
||||||
getMission: vi.fn((id: string) => missions.get(id)),
|
|
||||||
|
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
off: vi.fn(),
|
off: vi.fn(),
|
||||||
@@ -303,11 +301,28 @@ function createMockStore(): TaskStore {
|
|||||||
} as unknown as TaskStore;
|
} as unknown as TaskStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildApp() {
|
function createMockMissionAutopilot() {
|
||||||
|
return {
|
||||||
|
watchMission: vi.fn(),
|
||||||
|
unwatchMission: vi.fn(),
|
||||||
|
isWatching: vi.fn().mockReturnValue(false),
|
||||||
|
getAutopilotStatus: vi.fn().mockReturnValue({
|
||||||
|
enabled: false,
|
||||||
|
state: "inactive",
|
||||||
|
watched: false,
|
||||||
|
lastActivityAt: undefined,
|
||||||
|
}),
|
||||||
|
checkAndStartMission: vi.fn().mockResolvedValue(undefined),
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(options?: { missionAutopilot?: ReturnType<typeof createMockMissionAutopilot> }) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
app.use("/api/missions", createMissionRouter(store));
|
app.use("/api/missions", createMissionRouter(store, options?.missionAutopilot));
|
||||||
return { app, store, missionStore: store.getMissionStore() };
|
return { app, store, missionStore: store.getMissionStore() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,4 +1218,263 @@ describe("Mission API", () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Autopilot Endpoints ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("autopilot endpoints", () => {
|
||||||
|
describe("GET /api/missions/:missionId/autopilot", () => {
|
||||||
|
it("returns autopilot status from service when provided", async () => {
|
||||||
|
const missionAutopilot = createMockMissionAutopilot();
|
||||||
|
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||||
|
const mission = missionStore.createMission({ title: "Autopilot Mission" });
|
||||||
|
|
||||||
|
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||||
|
enabled: true,
|
||||||
|
state: "watching",
|
||||||
|
watched: true,
|
||||||
|
lastActivityAt: "2026-04-07T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await get(app, `/api/missions/${mission.id}/autopilot`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
state: "watching",
|
||||||
|
watched: true,
|
||||||
|
lastActivityAt: "2026-04-07T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(missionAutopilot.getAutopilotStatus).toHaveBeenCalledWith(mission.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns fallback mission status when autopilot service is unavailable", async () => {
|
||||||
|
const { app, missionStore } = buildApp();
|
||||||
|
const mission = missionStore.createMission({ title: "Fallback Mission" });
|
||||||
|
missionStore.updateMission(mission.id, {
|
||||||
|
autopilotEnabled: true,
|
||||||
|
autopilotState: "watching",
|
||||||
|
lastAutopilotActivityAt: "2026-04-07T13:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await get(app, `/api/missions/${mission.id}/autopilot`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
state: "watching",
|
||||||
|
watched: false,
|
||||||
|
lastActivityAt: "2026-04-07T13:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/missions/:missionId/autopilot", () => {
|
||||||
|
it("enables autopilot and starts planning missions", async () => {
|
||||||
|
const missionAutopilot = createMockMissionAutopilot();
|
||||||
|
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||||
|
const mission = missionStore.createMission({ title: "Enable Autopilot" });
|
||||||
|
|
||||||
|
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||||
|
enabled: true,
|
||||||
|
state: "watching",
|
||||||
|
watched: true,
|
||||||
|
lastActivityAt: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
`/api/missions/${mission.id}/autopilot`,
|
||||||
|
JSON.stringify({ enabled: true }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||||
|
expect(missionAutopilot.checkAndStartMission).toHaveBeenCalledWith(mission.id);
|
||||||
|
expect(missionStore.updateMission).toHaveBeenCalledWith(mission.id, { autopilotEnabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables autopilot and unwatches mission", async () => {
|
||||||
|
const missionAutopilot = createMockMissionAutopilot();
|
||||||
|
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||||
|
const mission = missionStore.createMission({ title: "Disable Autopilot" });
|
||||||
|
|
||||||
|
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||||
|
enabled: false,
|
||||||
|
state: "inactive",
|
||||||
|
watched: false,
|
||||||
|
lastActivityAt: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
`/api/missions/${mission.id}/autopilot`,
|
||||||
|
JSON.stringify({ enabled: false }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(missionAutopilot.unwatchMission).toHaveBeenCalledWith(mission.id);
|
||||||
|
expect(missionAutopilot.checkAndStartMission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when enabled is missing or not boolean", async () => {
|
||||||
|
const { app, missionStore } = buildApp();
|
||||||
|
const mission = missionStore.createMission({ title: "Invalid Payload" });
|
||||||
|
|
||||||
|
const missingRes = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
`/api/missions/${mission.id}/autopilot`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
expect(missingRes.status).toBe(400);
|
||||||
|
|
||||||
|
const invalidRes = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
`/api/missions/${mission.id}/autopilot`,
|
||||||
|
JSON.stringify({ enabled: "yes" }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
expect(invalidRes.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns fallback response without autopilot service", async () => {
|
||||||
|
const { app, missionStore } = buildApp();
|
||||||
|
const mission = missionStore.createMission({ title: "No Autopilot Service" });
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
`/api/missions/${mission.id}/autopilot`,
|
||||||
|
JSON.stringify({ enabled: true }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
state: "inactive",
|
||||||
|
watched: false,
|
||||||
|
lastActivityAt: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/missions/:missionId/autopilot/start", () => {
|
||||||
|
it("starts watching when autopilot is enabled", async () => {
|
||||||
|
const missionAutopilot = createMockMissionAutopilot();
|
||||||
|
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||||
|
const mission = missionStore.createMission({ title: "Start Autopilot" });
|
||||||
|
missionStore.updateMission(mission.id, { autopilotEnabled: true });
|
||||||
|
|
||||||
|
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||||
|
enabled: true,
|
||||||
|
state: "watching",
|
||||||
|
watched: true,
|
||||||
|
lastActivityAt: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/${mission.id}/autopilot/start`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||||
|
expect(missionAutopilot.checkAndStartMission).toHaveBeenCalledWith(mission.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when mission autopilot is disabled", async () => {
|
||||||
|
const missionAutopilot = createMockMissionAutopilot();
|
||||||
|
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||||
|
const mission = missionStore.createMission({ title: "Disabled Autopilot" });
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/${mission.id}/autopilot/start`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("not enabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 when autopilot service is unavailable", async () => {
|
||||||
|
const { app, missionStore } = buildApp();
|
||||||
|
const mission = missionStore.createMission({ title: "Service Unavailable" });
|
||||||
|
missionStore.updateMission(mission.id, { autopilotEnabled: true });
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/${mission.id}/autopilot/start`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/missions/:missionId/autopilot/stop", () => {
|
||||||
|
it("stops watching when autopilot service is available", async () => {
|
||||||
|
const missionAutopilot = createMockMissionAutopilot();
|
||||||
|
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||||
|
const mission = missionStore.createMission({ title: "Stop Autopilot" });
|
||||||
|
|
||||||
|
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||||
|
enabled: true,
|
||||||
|
state: "inactive",
|
||||||
|
watched: false,
|
||||||
|
lastActivityAt: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/${mission.id}/autopilot/stop`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(missionAutopilot.unwatchMission).toHaveBeenCalledWith(mission.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns fallback status when autopilot service is unavailable", async () => {
|
||||||
|
const { app, missionStore } = buildApp();
|
||||||
|
const mission = missionStore.createMission({ title: "Stop Fallback" });
|
||||||
|
missionStore.updateMission(mission.id, {
|
||||||
|
autopilotEnabled: true,
|
||||||
|
lastAutopilotActivityAt: "2026-04-07T15:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/${mission.id}/autopilot/stop`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
state: "inactive",
|
||||||
|
watched: false,
|
||||||
|
lastActivityAt: "2026-04-07T15:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
241
packages/dashboard/src/mission-interview.test.ts
Normal file
241
packages/dashboard/src/mission-interview.test.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { mockCreateKbAgent } = vi.hoisted(() => ({
|
||||||
|
mockCreateKbAgent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@fusion/engine", () => ({
|
||||||
|
createKbAgent: mockCreateKbAgent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
__resetMissionInterviewState,
|
||||||
|
cancelMissionInterviewSession,
|
||||||
|
checkRateLimit,
|
||||||
|
cleanupMissionInterviewSession,
|
||||||
|
createMissionInterviewSession,
|
||||||
|
getMissionInterviewSession,
|
||||||
|
getMissionInterviewSummary,
|
||||||
|
getRateLimitResetTime,
|
||||||
|
InvalidSessionStateError,
|
||||||
|
missionInterviewStreamManager,
|
||||||
|
parseMissionAgentResponse,
|
||||||
|
RateLimitError,
|
||||||
|
SessionNotFoundError,
|
||||||
|
submitMissionInterviewResponse,
|
||||||
|
} from "./mission-interview.js";
|
||||||
|
|
||||||
|
function createQuestionJson(id = "q-1"): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: "question",
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
type: "text",
|
||||||
|
question: "What should we build first?",
|
||||||
|
description: "Initial scope",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCompleteJson(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: "complete",
|
||||||
|
data: {
|
||||||
|
missionTitle: "Mission Ready",
|
||||||
|
missionDescription: "Complete plan",
|
||||||
|
milestones: [
|
||||||
|
{
|
||||||
|
title: "Milestone 1",
|
||||||
|
slices: [
|
||||||
|
{
|
||||||
|
title: "Slice 1",
|
||||||
|
features: [
|
||||||
|
{ title: "Feature 1", acceptanceCriteria: "Works" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockAgent(responses: string[]) {
|
||||||
|
const queue = [...responses];
|
||||||
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
state: { messages },
|
||||||
|
prompt: vi.fn(async () => {
|
||||||
|
const response = queue.shift() ?? createQuestionJson("q-fallback");
|
||||||
|
messages.push({ role: "assistant", content: response });
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCurrentQuestion(sessionId: string): Promise<void> {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
if (getMissionInterviewSession(sessionId)?.currentQuestion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
throw new Error("Timed out waiting for currentQuestion");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mission-interview module", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
__resetMissionInterviewState();
|
||||||
|
mockCreateKbAgent.mockImplementation(async () => createMockAgent([createQuestionJson()]));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session lifecycle", () => {
|
||||||
|
it("creates, retrieves, and cleans up a session", async () => {
|
||||||
|
const sessionId = await createMissionInterviewSession("127.0.0.1", "Launch platform", "/tmp/project");
|
||||||
|
|
||||||
|
const session = getMissionInterviewSession(sessionId);
|
||||||
|
expect(session).toBeDefined();
|
||||||
|
expect(session?.missionTitle).toBe("Launch platform");
|
||||||
|
|
||||||
|
cleanupMissionInterviewSession(sessionId);
|
||||||
|
expect(getMissionInterviewSession(sessionId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a session and throws when canceling missing session", async () => {
|
||||||
|
const sessionId = await createMissionInterviewSession("127.0.0.2", "Cancel mission", "/tmp/project");
|
||||||
|
await waitForCurrentQuestion(sessionId);
|
||||||
|
|
||||||
|
await cancelMissionInterviewSession(sessionId);
|
||||||
|
expect(getMissionInterviewSession(sessionId)).toBeUndefined();
|
||||||
|
|
||||||
|
await expect(cancelMissionInterviewSession(sessionId)).rejects.toBeInstanceOf(SessionNotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rate limiting", () => {
|
||||||
|
it("enforces max sessions per IP and exposes reset time", async () => {
|
||||||
|
const ip = "10.0.0.1";
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await createMissionInterviewSession(ip, `Mission ${i}`, "/tmp/project");
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(createMissionInterviewSession(ip, "Mission 6", "/tmp/project")).rejects.toBeInstanceOf(RateLimitError);
|
||||||
|
expect(getRateLimitResetTime(ip)).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkRateLimit tracks allowance and lockout", () => {
|
||||||
|
const ip = "10.0.0.2";
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
expect(checkRateLimit(ip)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(checkRateLimit(ip)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("submitMissionInterviewResponse", () => {
|
||||||
|
it("processes response and returns completed summary", async () => {
|
||||||
|
mockCreateKbAgent.mockImplementationOnce(async () =>
|
||||||
|
createMockAgent([createQuestionJson("q-plan"), createCompleteJson()]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionId = await createMissionInterviewSession("172.16.0.1", "Build mission", "/tmp/project");
|
||||||
|
await waitForCurrentQuestion(sessionId);
|
||||||
|
|
||||||
|
const session = getMissionInterviewSession(sessionId);
|
||||||
|
const questionId = session?.currentQuestion?.id;
|
||||||
|
expect(questionId).toBe("q-plan");
|
||||||
|
|
||||||
|
const result = await submitMissionInterviewResponse(sessionId, {
|
||||||
|
[questionId as string]: "We should prioritize auth first",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.type).toBe("complete");
|
||||||
|
expect(getMissionInterviewSummary(sessionId)?.missionTitle).toBe("Mission Ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws SessionNotFoundError for unknown session", async () => {
|
||||||
|
await expect(submitMissionInterviewResponse("missing", {})).rejects.toBeInstanceOf(SessionNotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws InvalidSessionStateError when no active question", async () => {
|
||||||
|
const sessionId = await createMissionInterviewSession("172.16.0.2", "No question", "/tmp/project");
|
||||||
|
await waitForCurrentQuestion(sessionId);
|
||||||
|
|
||||||
|
const session = getMissionInterviewSession(sessionId);
|
||||||
|
if (!session) throw new Error("session should exist");
|
||||||
|
session.currentQuestion = undefined;
|
||||||
|
|
||||||
|
await expect(submitMissionInterviewResponse(sessionId, {})).rejects.toBeInstanceOf(InvalidSessionStateError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stream manager", () => {
|
||||||
|
it("subscribes, broadcasts, unsubscribes, and cleans up", () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const unsubscribe = missionInterviewStreamManager.subscribe("session-1", callback);
|
||||||
|
|
||||||
|
expect(missionInterviewStreamManager.hasSubscribers("session-1")).toBe(true);
|
||||||
|
|
||||||
|
missionInterviewStreamManager.broadcast("session-1", { type: "thinking", data: "analyzing" });
|
||||||
|
expect(callback).toHaveBeenCalledWith({ type: "thinking", data: "analyzing" });
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
expect(missionInterviewStreamManager.hasSubscribers("session-1")).toBe(false);
|
||||||
|
|
||||||
|
missionInterviewStreamManager.cleanupSession("session-1");
|
||||||
|
expect(missionInterviewStreamManager.hasSubscribers("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("response parsing", () => {
|
||||||
|
it("parses direct JSON question responses", () => {
|
||||||
|
const parsed = parseMissionAgentResponse(createQuestionJson("q-direct"));
|
||||||
|
expect(parsed.type).toBe("question");
|
||||||
|
if (parsed.type === "question") {
|
||||||
|
expect(parsed.data.id).toBe("q-direct");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses markdown-wrapped complete responses", () => {
|
||||||
|
const wrapped = `\n\`\`\`json\n${createCompleteJson()}\n\`\`\``;
|
||||||
|
const parsed = parseMissionAgentResponse(wrapped);
|
||||||
|
expect(parsed.type).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses embedded JSON inside prose", () => {
|
||||||
|
const text = `Here is the plan output:\n${createQuestionJson("q-embedded")}\nThanks.`;
|
||||||
|
const parsed = parseMissionAgentResponse(text);
|
||||||
|
expect(parsed.type).toBe("question");
|
||||||
|
if (parsed.type === "question") {
|
||||||
|
expect(parsed.data.id).toBe("q-embedded");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs and parses JSON with trailing commas", () => {
|
||||||
|
const malformed = '{"type":"question","data":{"id":"q-fix","type":"text","question":"Q?",},}';
|
||||||
|
const parsed = parseMissionAgentResponse(malformed);
|
||||||
|
expect(parsed.type).toBe("question");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on invalid response structure", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseMissionAgentResponse(JSON.stringify({ type: "unknown", data: null })),
|
||||||
|
).toThrow("invalid response structure");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom errors", () => {
|
||||||
|
it("sets expected error names", () => {
|
||||||
|
expect(new RateLimitError("rate").name).toBe("RateLimitError");
|
||||||
|
expect(new SessionNotFoundError("missing").name).toBe("SessionNotFoundError");
|
||||||
|
expect(new InvalidSessionStateError("bad").name).toBe("InvalidSessionStateError");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ function createMockMissionStore(): any {
|
|||||||
getMission: vi.fn(),
|
getMission: vi.fn(),
|
||||||
getMissionWithHierarchy: vi.fn(),
|
getMissionWithHierarchy: vi.fn(),
|
||||||
getFeatureByTaskId: vi.fn(),
|
getFeatureByTaskId: vi.fn(),
|
||||||
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
|
updateFeatureStatus: vi.fn(),
|
||||||
computeSliceStatus: vi.fn(),
|
computeSliceStatus: vi.fn(),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
off: vi.fn(),
|
off: vi.fn(),
|
||||||
@@ -38,6 +38,8 @@ function createMockTaskStore(): any {
|
|||||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||||
moveTask: vi.fn().mockResolvedValue(undefined),
|
moveTask: vi.fn().mockResolvedValue(undefined),
|
||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||||
|
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
|
||||||
getRootDir: vi.fn().mockReturnValue("/test/project"),
|
getRootDir: vi.fn().mockReturnValue("/test/project"),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
off: vi.fn(),
|
off: vi.fn(),
|
||||||
@@ -106,12 +108,20 @@ describe("Scheduler Mission Integration", () => {
|
|||||||
let taskStore: any;
|
let taskStore: any;
|
||||||
let missionStore: any;
|
let missionStore: any;
|
||||||
let scheduler: Scheduler;
|
let scheduler: Scheduler;
|
||||||
|
let listeners: Record<string, Array<(...args: any[]) => void>>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
listeners = {};
|
||||||
taskStore = createMockTaskStore();
|
taskStore = createMockTaskStore();
|
||||||
missionStore = createMockMissionStore();
|
missionStore = createMockMissionStore();
|
||||||
taskStore.getMissionStore.mockReturnValue(missionStore);
|
taskStore.getMissionStore.mockReturnValue(missionStore);
|
||||||
|
|
||||||
|
taskStore.on.mockImplementation((event: string, handler: (...args: any[]) => void) => {
|
||||||
|
listeners[event] ??= [];
|
||||||
|
listeners[event].push(handler);
|
||||||
|
return taskStore;
|
||||||
|
});
|
||||||
|
|
||||||
const semaphore = new AgentSemaphore(2);
|
const semaphore = new AgentSemaphore(2);
|
||||||
scheduler = new Scheduler(taskStore, {
|
scheduler = new Scheduler(taskStore, {
|
||||||
pollIntervalMs: 1000,
|
pollIntervalMs: 1000,
|
||||||
@@ -152,6 +162,44 @@ describe("Scheduler Mission Integration", () => {
|
|||||||
expect(result).toEqual(mockActivated);
|
expect(result).toEqual(mockActivated);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips milestones with unmet dependencies and activates the next eligible pending slice", async () => {
|
||||||
|
const mockActivated = createMockSlice({ id: "SL-ELIGIBLE", status: "active" });
|
||||||
|
|
||||||
|
missionStore.getMissionWithHierarchy.mockReturnValue({
|
||||||
|
id: "M-001",
|
||||||
|
status: "active",
|
||||||
|
milestones: [
|
||||||
|
{
|
||||||
|
id: "MS-BLOCKED",
|
||||||
|
orderIndex: 0,
|
||||||
|
dependencies: ["MS-DEP"],
|
||||||
|
status: "planning",
|
||||||
|
slices: [{ id: "SL-BLOCKED", status: "pending", orderIndex: 0 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "MS-DEP",
|
||||||
|
orderIndex: 1,
|
||||||
|
dependencies: [],
|
||||||
|
status: "planning",
|
||||||
|
slices: [{ id: "SL-DEP", status: "complete", orderIndex: 0 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "MS-ELIGIBLE",
|
||||||
|
orderIndex: 2,
|
||||||
|
dependencies: [],
|
||||||
|
status: "active",
|
||||||
|
slices: [{ id: "SL-ELIGIBLE", status: "pending", orderIndex: 0 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
missionStore.activateSlice.mockReturnValue(mockActivated);
|
||||||
|
|
||||||
|
const result = await scheduler.activateNextPendingSlice("M-001");
|
||||||
|
|
||||||
|
expect(missionStore.activateSlice).toHaveBeenCalledWith("SL-ELIGIBLE");
|
||||||
|
expect(result).toEqual(mockActivated);
|
||||||
|
});
|
||||||
|
|
||||||
it("should return null when no pending slices", async () => {
|
it("should return null when no pending slices", async () => {
|
||||||
missionStore.getMissionWithHierarchy.mockReturnValue({
|
missionStore.getMissionWithHierarchy.mockReturnValue({
|
||||||
id: "M-001",
|
id: "M-001",
|
||||||
@@ -200,68 +248,79 @@ describe("Scheduler Mission Integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Mission-aware scheduling", () => {
|
describe("Mission-aware scheduling", () => {
|
||||||
it("should handle task completion with mission integration", async () => {
|
it("filters out todo tasks whose mission is blocked", async () => {
|
||||||
const feature = createMockFeature({ id: "F-001", sliceId: "SL-001", taskId: "FN-001" });
|
const blockedTask = {
|
||||||
const slice = createMockSlice({ id: "SL-001", milestoneId: "MS-001" });
|
id: "FN-001",
|
||||||
const milestone = createMockMilestone({ id: "MS-001", missionId: "M-001" });
|
title: "Blocked task",
|
||||||
const mission = createMockMission({ id: "M-001", autoAdvance: true });
|
column: "todo",
|
||||||
const nextSlice = createMockSlice({ id: "SL-002", status: "pending" });
|
paused: false,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
sliceId: "SL-001",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
missionStore.getFeatureByTaskId.mockReturnValue(feature);
|
taskStore.listTasks.mockResolvedValue([blockedTask]);
|
||||||
missionStore.getSlice.mockReturnValue(slice);
|
taskStore.getSettings.mockResolvedValue({
|
||||||
missionStore.getMilestone.mockReturnValue(milestone);
|
maxConcurrent: 2,
|
||||||
missionStore.getMission.mockReturnValue(mission);
|
maxWorktrees: 2,
|
||||||
missionStore.computeSliceStatus.mockReturnValue("complete");
|
});
|
||||||
missionStore.findNextPendingSlice.mockReturnValue(nextSlice);
|
missionStore.getSlice.mockReturnValue(createMockSlice({ id: "SL-001", milestoneId: "MS-001" }));
|
||||||
missionStore.activateSlice.mockReturnValue({ ...nextSlice, status: "active" });
|
missionStore.getMilestone.mockReturnValue(createMockMilestone({ id: "MS-001", missionId: "M-001" }));
|
||||||
|
missionStore.getMission.mockReturnValue(createMockMission({ id: "M-001", status: "blocked" }));
|
||||||
|
|
||||||
// Verify the scheduler has access to mission store
|
vi.spyOn(scheduler as any, "validateTaskFilesystem").mockResolvedValue({ valid: true });
|
||||||
expect(scheduler).toBeDefined();
|
|
||||||
|
await scheduler.schedule();
|
||||||
|
|
||||||
|
expect(taskStore.moveTask).not.toHaveBeenCalled();
|
||||||
|
expect(taskStore.updateTask).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not auto-advance when mission is blocked", async () => {
|
it("delegates completion progression to missionAutopilot when a linked mission slice completes", async () => {
|
||||||
const feature = createMockFeature({ id: "F-001", sliceId: "SL-001", taskId: "FN-001" });
|
const localTaskStore = createMockTaskStore();
|
||||||
const slice = createMockSlice({ id: "SL-001", milestoneId: "MS-001" });
|
const localMissionStore = createMockMissionStore();
|
||||||
const milestone = createMockMilestone({ id: "MS-001", missionId: "M-001" });
|
const localListeners: Record<string, Array<(...args: any[]) => void>> = {};
|
||||||
const mission = createMockMission({ id: "M-001", status: "blocked" });
|
const missionAutopilot = {
|
||||||
|
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
|
||||||
|
stop: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
missionStore.getFeatureByTaskId.mockReturnValue(feature);
|
localTaskStore.on.mockImplementation((event: string, handler: (...args: any[]) => void) => {
|
||||||
missionStore.getSlice.mockReturnValue(slice);
|
localListeners[event] ??= [];
|
||||||
missionStore.getMilestone.mockReturnValue(milestone);
|
localListeners[event].push(handler);
|
||||||
missionStore.getMission.mockReturnValue(mission);
|
return localTaskStore;
|
||||||
missionStore.computeSliceStatus.mockReturnValue("complete");
|
|
||||||
|
|
||||||
// Verify that scheduler has the mission store
|
|
||||||
expect(scheduler).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle task with no linked feature gracefully", async () => {
|
const localScheduler = new Scheduler(localTaskStore, {
|
||||||
missionStore.getFeatureByTaskId.mockReturnValue(undefined);
|
pollIntervalMs: 1000,
|
||||||
|
semaphore: new AgentSemaphore(2),
|
||||||
// Should not throw when feature is not found
|
missionStore: localMissionStore,
|
||||||
expect(scheduler).toBeDefined();
|
missionAutopilot: missionAutopilot as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle multiple slices becoming ready simultaneously", async () => {
|
localMissionStore.getFeatureByTaskId.mockReturnValue(
|
||||||
const mission = createMockMission({ id: "M-001" });
|
createMockFeature({ id: "F-001", sliceId: "SL-001", taskId: "FN-001", status: "triaged" }),
|
||||||
const pendingSlice1 = createMockSlice({ id: "SL-002", status: "pending" });
|
);
|
||||||
const pendingSlice2 = createMockSlice({ id: "SL-003", status: "pending" });
|
localMissionStore.getSlice.mockReturnValue(createMockSlice({ id: "SL-001", status: "complete" }));
|
||||||
|
localMissionStore.updateFeatureStatus.mockReturnValue(undefined);
|
||||||
|
|
||||||
missionStore.getMission.mockReturnValue(mission);
|
const handler = localListeners["task:moved"]?.[0];
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
// First call returns SL-002
|
handler?.({
|
||||||
missionStore.findNextPendingSlice
|
task: { id: "FN-001", sliceId: "SL-001" },
|
||||||
.mockReturnValueOnce(pendingSlice1)
|
from: "in-progress",
|
||||||
.mockReturnValueOnce(pendingSlice2)
|
to: "done",
|
||||||
.mockReturnValueOnce(null);
|
});
|
||||||
|
|
||||||
// Activate should be called for each slice
|
await vi.waitFor(() => {
|
||||||
missionStore.activateSlice
|
expect(missionAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
|
||||||
.mockReturnValueOnce({ ...pendingSlice1, status: "active" })
|
});
|
||||||
.mockReturnValueOnce({ ...pendingSlice2, status: "active" });
|
|
||||||
|
|
||||||
// Verify that the scheduler has the mission store
|
localScheduler.stop();
|
||||||
expect(scheduler).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user