The merge delivers the FN-5483 identity-guard bypass for merger-driven commits on detached HEAD, plus Steps 2 and 6 of FN-5479 which gate the limbo counter by enqueue acceptance and document the associated invariant. It also restores the dashboard's PWA and theme-boot contract in index.html. New reg Fusion-Task-Id: FN-5479
407 lines
16 KiB
TypeScript
407 lines
16 KiB
TypeScript
import { vi } from "vitest";
|
|
import { installTaskWorktreeIdentityGuard } from "../worktree-hooks.js";
|
|
|
|
// Mock external dependencies
|
|
vi.mock("../pi.js", () => ({
|
|
createFnAgent: vi.fn(),
|
|
describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"),
|
|
compactSessionContext: vi.fn(async (session, instructions) => {
|
|
if (typeof (session as any).compact === "function") {
|
|
return (session as any).compact(instructions);
|
|
}
|
|
return null;
|
|
}),
|
|
promptWithFallback: vi.fn(async (session, prompt, options) => {
|
|
if (options === undefined) {
|
|
await session.prompt(prompt);
|
|
} else {
|
|
await session.prompt(prompt, options);
|
|
}
|
|
}),
|
|
}));
|
|
vi.mock("../reviewer.js", () => ({
|
|
reviewStep: vi.fn(),
|
|
}));
|
|
vi.mock("../logger.js", () => {
|
|
const createMockLogger = () => ({
|
|
log: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
});
|
|
return {
|
|
createLogger: vi.fn(() => createMockLogger()),
|
|
schedulerLog: createMockLogger(),
|
|
executorLog: createMockLogger(),
|
|
planLog: createMockLogger(),
|
|
mergerLog: createMockLogger(),
|
|
worktreePoolLog: createMockLogger(),
|
|
reviewerLog: createMockLogger(),
|
|
prMonitorLog: createMockLogger(),
|
|
runtimeLog: createMockLogger(),
|
|
ipcLog: createMockLogger(),
|
|
projectManagerLog: createMockLogger(),
|
|
hybridExecutorLog: createMockLogger(),
|
|
formatError: (err: unknown) => {
|
|
if (err instanceof Error) {
|
|
const message = err.message || err.name || "Error";
|
|
const stack = err.stack;
|
|
return { message, stack, detail: stack ?? message };
|
|
}
|
|
const message = typeof err === "string" ? err : String(err);
|
|
return { message, detail: message };
|
|
},
|
|
};
|
|
});
|
|
vi.mock("../merger.js", () => ({
|
|
aiMergeTask: vi.fn(),
|
|
findWorktreeUser: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
vi.mock("../agent-session-helpers.js", async () => {
|
|
const { createFnAgent } = await import("../pi.js");
|
|
return {
|
|
createResolvedAgentSession: async (options: any) => {
|
|
const result = await createFnAgent(options);
|
|
return {
|
|
session: result.session,
|
|
sessionFile: result.sessionFile,
|
|
runtimeId: "pi",
|
|
wasConfigured: false,
|
|
};
|
|
},
|
|
extractRuntimeHint: (runtimeConfig: Record<string, unknown> | undefined) => {
|
|
const hint = runtimeConfig?.runtimeHint;
|
|
return typeof hint === "string" && hint.trim().length > 0 ? hint.trim() : undefined;
|
|
},
|
|
resolveExecutorSessionModel: (
|
|
taskModelProvider: string | undefined,
|
|
taskModelId: string | undefined,
|
|
settings: Record<string, unknown> | undefined,
|
|
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
|
) => {
|
|
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
|
|
const slash = model.indexOf("/");
|
|
if (slash > 0 && slash < model.length - 1) {
|
|
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
|
|
}
|
|
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
|
|
if (typeof settings?.executionProvider === "string" && typeof settings?.executionModelId === "string") {
|
|
return { provider: settings.executionProvider as string, modelId: settings.executionModelId as string };
|
|
}
|
|
if (typeof settings?.executionGlobalProvider === "string" && typeof settings?.executionGlobalModelId === "string") {
|
|
return { provider: settings.executionGlobalProvider as string, modelId: settings.executionGlobalModelId as string };
|
|
}
|
|
if (typeof settings?.defaultProviderOverride === "string" && typeof settings?.defaultModelIdOverride === "string") {
|
|
return { provider: settings.defaultProviderOverride as string, modelId: settings.defaultModelIdOverride as string };
|
|
}
|
|
if (typeof settings?.defaultProvider === "string" && typeof settings?.defaultModelId === "string") {
|
|
return { provider: settings.defaultProvider as string, modelId: settings.defaultModelId as string };
|
|
}
|
|
return { provider: undefined, modelId: undefined };
|
|
},
|
|
};
|
|
});
|
|
vi.mock("../worktree-names.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../worktree-names.js")>("../worktree-names.js");
|
|
return {
|
|
...actual,
|
|
generateWorktreeName: vi.fn().mockReturnValue("swift-falcon"),
|
|
};
|
|
});
|
|
vi.mock("../worktree-pool.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../worktree-pool.js")>();
|
|
const backend = await vi.importActual<typeof import("../worktree-backend.js")>("../worktree-backend.js");
|
|
return {
|
|
...actual,
|
|
ActiveSessionWorktreeRemovalError: backend.ActiveSessionWorktreeRemovalError,
|
|
RemovalReason: backend.RemovalReason,
|
|
removeWorktree: vi.fn(actual.removeWorktree),
|
|
classifyTaskWorktree: vi.fn().mockResolvedValue({ ok: true }),
|
|
describeRegisteredWorktrees: vi.fn().mockResolvedValue({ rawOutput: "", canonicalized: [] }),
|
|
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
|
|
};
|
|
});
|
|
vi.mock("../worktree-hooks.js", () => ({
|
|
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
|
|
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
|
|
}));
|
|
|
|
vi.mock("../worktree-stale-lock.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../worktree-stale-lock.js")>("../worktree-stale-lock.js");
|
|
return {
|
|
...actual,
|
|
parseIndexLockPath: vi.fn(actual.parseIndexLockPath),
|
|
classifyStaleLock: vi.fn(),
|
|
tryRemoveStaleLock: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("../worktree-stale-registration.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../worktree-stale-registration.js")>("../worktree-stale-registration.js");
|
|
return {
|
|
...actual,
|
|
parseStaleRegistrationPath: vi.fn(actual.parseStaleRegistrationPath),
|
|
recoverStaleRegistration: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("node:child_process", async () => {
|
|
const { promisify } = await import("node:util");
|
|
const { EventEmitter } = await import("node:events");
|
|
const execSyncFn = vi.fn();
|
|
const spawnFn = vi.fn((cmd: string, opts?: any) => {
|
|
const child = new EventEmitter() as any;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.pid = 12345;
|
|
child.exitCode = null;
|
|
child.signalCode = null;
|
|
child.kill = vi.fn();
|
|
queueMicrotask(() => {
|
|
try {
|
|
const out = execSyncFn(cmd, opts);
|
|
const stdout = out === undefined ? "" : out.toString();
|
|
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
|
|
child.exitCode = 0;
|
|
child.emit("close", 0, null);
|
|
} catch (err) {
|
|
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
|
|
const stdout = error?.stdout?.toString?.() ?? "";
|
|
const stderr = error?.stderr?.toString?.() ?? "";
|
|
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
|
|
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
|
|
child.exitCode = error.status ?? error.code ?? 1;
|
|
child.emit("close", child.exitCode, null);
|
|
}
|
|
});
|
|
return child;
|
|
});
|
|
|
|
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
|
|
const callback = typeof opts === "function" ? opts : cb;
|
|
const forwardedOpts = typeof opts === "function" ? undefined : opts;
|
|
try {
|
|
const out = execSyncFn(cmd, forwardedOpts);
|
|
const stdout = out === undefined ? "" : out.toString();
|
|
if (typeof callback === "function") callback(null, stdout, "");
|
|
} catch (err) {
|
|
if (typeof callback === "function") {
|
|
const error = err as { stdout?: string; stderr?: string };
|
|
callback(err, error?.stdout?.toString?.() ?? "", error?.stderr?.toString?.() ?? "");
|
|
}
|
|
}
|
|
});
|
|
|
|
const execFileFn: any = vi.fn((_file: string, _args: string[] | undefined, opts: any, cb: any) => {
|
|
const callback = typeof opts === "function" ? opts : cb;
|
|
if (typeof callback === "function") {
|
|
callback(null, { stdout: "", stderr: "" });
|
|
}
|
|
});
|
|
|
|
execFn[promisify.custom] = (cmd: string, opts?: any) =>
|
|
new Promise((resolve, reject) => {
|
|
execFn(cmd, opts, (err: any, stdout: string, stderr: string) => {
|
|
if (err) {
|
|
(err as Record<string, unknown>).stdout = stdout;
|
|
(err as Record<string, unknown>).stderr = stderr;
|
|
reject(err);
|
|
} else {
|
|
resolve({ stdout, stderr });
|
|
}
|
|
});
|
|
});
|
|
execFileFn[promisify.custom] = (_file: string, _args?: string[], _opts?: any) =>
|
|
Promise.resolve({ stdout: "", stderr: "" });
|
|
|
|
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
|
|
});
|
|
vi.mock("node:fs", () => ({
|
|
existsSync: vi.fn().mockReturnValue(true),
|
|
realpathSync: vi.fn((path: string) => path),
|
|
}));
|
|
|
|
export const mockExecuteAll = vi.fn().mockResolvedValue([]);
|
|
export const mockTerminateAllSessions = vi.fn().mockResolvedValue(undefined);
|
|
export const mockCleanup = vi.fn().mockResolvedValue(undefined);
|
|
|
|
vi.mock("../step-session-executor.js", () => ({
|
|
StepSessionExecutor: vi.fn().mockImplementation(() => ({
|
|
executeAll: mockExecuteAll,
|
|
terminateAllSessions: mockTerminateAllSessions,
|
|
cleanup: mockCleanup,
|
|
})),
|
|
}));
|
|
|
|
vi.mock("../rate-limit-retry.js", () => ({
|
|
withRateLimitRetry: vi.fn((fn: () => Promise<unknown>) => fn()),
|
|
}));
|
|
vi.mock("../worktree-db-hydrate.js", () => ({
|
|
hydrateWorktreeDb: vi.fn().mockResolvedValue({
|
|
tasksCopied: 0,
|
|
documentsCopied: 0,
|
|
degraded: false,
|
|
}),
|
|
}));
|
|
vi.mock("../verification-utils.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../verification-utils.js")>("../verification-utils.js");
|
|
return {
|
|
...actual,
|
|
runVerificationCommand: vi.fn(),
|
|
};
|
|
});
|
|
vi.mock("@mariozechner/pi-coding-agent", () => {
|
|
const mockSessionManager = {};
|
|
return {
|
|
SessionManager: {
|
|
create: vi.fn().mockReturnValue(mockSessionManager),
|
|
open: vi.fn().mockReturnValue(mockSessionManager),
|
|
inMemory: vi.fn().mockReturnValue(mockSessionManager),
|
|
},
|
|
ModelRegistry: vi.fn().mockImplementation(() => ({
|
|
find: vi.fn(),
|
|
refresh: vi.fn(),
|
|
})),
|
|
AuthStorage: {
|
|
create: vi.fn().mockReturnValue({}),
|
|
},
|
|
getAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
|
|
};
|
|
});
|
|
|
|
import { createFnAgent } from "../pi.js";
|
|
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
|
import { generateWorktreeName } from "../worktree-names.js";
|
|
import { findWorktreeUser } from "../merger.js";
|
|
import { StepSessionExecutor } from "../step-session-executor.js";
|
|
import { withRateLimitRetry } from "../rate-limit-retry.js";
|
|
import { exec, execSync } from "node:child_process";
|
|
import { existsSync, realpathSync } from "node:fs";
|
|
import { hydrateWorktreeDb } from "../worktree-db-hydrate.js";
|
|
import { classifyTaskWorktree, describeRegisteredWorktrees, isUsableTaskWorktree } from "../worktree-pool.js";
|
|
import { classifyStaleLock, tryRemoveStaleLock } from "../worktree-stale-lock.js";
|
|
import { parseStaleRegistrationPath, recoverStaleRegistration } from "../worktree-stale-registration.js";
|
|
import { executingTaskLock } from "../active-session-registry.js";
|
|
|
|
export const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
|
export const mockedSessionManager = vi.mocked(SessionManager);
|
|
export const mockedGenerateWorktreeName = vi.mocked(generateWorktreeName);
|
|
export const mockedFindWorktreeUser = vi.mocked(findWorktreeUser);
|
|
export const mockedStepSessionExecutor = vi.mocked(StepSessionExecutor);
|
|
export const mockedWithRateLimitRetry = vi.mocked(withRateLimitRetry);
|
|
export const mockedExec = vi.mocked(exec);
|
|
export const mockedExecSync = vi.mocked(execSync);
|
|
export const mockedExistsSync = vi.mocked(existsSync);
|
|
export const mockedRealpathSync = vi.mocked(realpathSync);
|
|
export const mockedHydrateWorktreeDb = vi.mocked(hydrateWorktreeDb);
|
|
export const mockedClassifyTaskWorktree = vi.mocked(classifyTaskWorktree);
|
|
export const mockedDescribeRegisteredWorktrees = vi.mocked(describeRegisteredWorktrees);
|
|
export const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
|
|
export const mockedClassifyStaleLock = vi.mocked(classifyStaleLock);
|
|
export const mockedTryRemoveStaleLock = vi.mocked(tryRemoveStaleLock);
|
|
export const mockedParseStaleRegistrationPath = vi.mocked(parseStaleRegistrationPath);
|
|
export const mockedRecoverStaleRegistration = vi.mocked(recoverStaleRegistration);
|
|
export const mockedInstallTaskWorktreeIdentityGuard = vi.mocked(installTaskWorktreeIdentityGuard);
|
|
|
|
export type EventListener = (...args: unknown[]) => void;
|
|
|
|
export function createMockStore() {
|
|
const listeners = new Map<string, EventListener[]>();
|
|
const store = {
|
|
on: vi.fn((event: string, fn: EventListener) => {
|
|
const existing = listeners.get(event) || [];
|
|
existing.push(fn);
|
|
listeners.set(event, existing);
|
|
}),
|
|
_trigger(event: string, ...args: unknown[]) {
|
|
for (const fn of listeners.get(event) || []) fn(...args);
|
|
},
|
|
emit: vi.fn(),
|
|
listTasks: vi.fn().mockResolvedValue([]),
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}),
|
|
updateTask: vi.fn().mockResolvedValue({}),
|
|
moveTask: vi.fn().mockResolvedValue({}),
|
|
handoffToReview: vi.fn().mockImplementation(async (id: string) => store.moveTask(id, "in-review")),
|
|
mergeTask: vi.fn().mockResolvedValue({}),
|
|
createTask: vi.fn().mockImplementation(async (input: Record<string, unknown>) => ({
|
|
id: "FN-002",
|
|
title: input.title,
|
|
description: input.description,
|
|
column: "triage",
|
|
dependencies: input.dependencies ?? [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
})),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
addTaskComment: vi.fn().mockResolvedValue(undefined),
|
|
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
|
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
|
updateSettings: vi.fn().mockResolvedValue({}),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: undefined,
|
|
}),
|
|
updateStep: vi.fn().mockResolvedValue({}),
|
|
getWorkflowStep: vi.fn().mockResolvedValue(undefined),
|
|
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
|
setPluginWorkflowStepTemplates: vi.fn(),
|
|
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
|
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
|
|
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
|
};
|
|
return store as any;
|
|
}
|
|
|
|
export function resetExecutorMocks() {
|
|
vi.clearAllMocks();
|
|
mockedExec.mockReset();
|
|
mockedExecSync.mockReset();
|
|
mockedIsUsableTaskWorktree.mockResolvedValue(true);
|
|
mockedClassifyTaskWorktree.mockImplementation(async (rootDir: string, worktreePath: string) => {
|
|
const usable = await mockedIsUsableTaskWorktree(rootDir, worktreePath);
|
|
return usable
|
|
? { ok: true }
|
|
: { ok: false, classification: "incomplete", reason: "missing or invalid .git metadata" };
|
|
});
|
|
mockedClassifyStaleLock.mockReset();
|
|
mockedTryRemoveStaleLock.mockReset();
|
|
mockedParseStaleRegistrationPath.mockReset();
|
|
mockedRecoverStaleRegistration.mockReset();
|
|
mockedInstallTaskWorktreeIdentityGuard.mockReset();
|
|
mockedClassifyStaleLock.mockResolvedValue({ kind: "fresh", reason: "fresh" } as any);
|
|
mockedParseStaleRegistrationPath.mockImplementation((value) => {
|
|
if (!value) return null;
|
|
const match = /'([^']+)'\s+is a missing but already registered worktree/i.exec(String(value));
|
|
return match?.[1] ?? null;
|
|
});
|
|
mockedRecoverStaleRegistration.mockResolvedValue({ recovered: true, actions: ["prune"] });
|
|
mockedInstallTaskWorktreeIdentityGuard.mockResolvedValue(undefined);
|
|
mockedTryRemoveStaleLock.mockResolvedValue({ removed: true });
|
|
mockExecuteAll.mockResolvedValue([]);
|
|
mockTerminateAllSessions.mockResolvedValue(undefined);
|
|
mockCleanup.mockResolvedValue(undefined);
|
|
// FN-4811 follow-up: the executingTaskLock is process-wide module state, so it must
|
|
// be cleared between tests or earlier tests' claims will block later tests' execute()
|
|
// calls ("expected at least 2 createFnAgent calls but got 0" / "expected not called
|
|
// but called 3 times" symptoms in executor-pause / executor-prompt tests).
|
|
executingTaskLock._clearForTest();
|
|
}
|