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:
@@ -287,8 +287,6 @@ function createMockMissionStore() {
|
||||
|
||||
// Mission status helpers for pause/stop
|
||||
computeMissionStatus: vi.fn(() => "active"),
|
||||
getMilestone: vi.fn((id: string) => milestones.get(id)),
|
||||
getMission: vi.fn((id: string) => missions.get(id)),
|
||||
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
@@ -303,11 +301,28 @@ function createMockStore(): 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();
|
||||
app.use(express.json());
|
||||
const store = createMockStore();
|
||||
app.use("/api/missions", createMissionRouter(store));
|
||||
app.use("/api/missions", createMissionRouter(store, options?.missionAutopilot));
|
||||
return { app, store, missionStore: store.getMissionStore() };
|
||||
}
|
||||
|
||||
@@ -1203,4 +1218,263 @@ describe("Mission API", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user