feat(FN-4014): split merger monolith into domain-specific test suites
The monolithic `merger.test.ts` (8,229 lines) has been decomposed into nine focused test suites covering conflict resolution, diff scope, merge details, merge lifecycle, post-merge, prompt and utils, session recovery, skills, and verification, along with a shared `merger-test-helpers.ts` module for Fusion-Task-Id: FN-4014
This commit is contained in:
@@ -2074,7 +2074,7 @@ describe("Merger worktree pool integration", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Full merger worktree pool integration tests are in merger.test.ts
|
||||
// Full merger worktree pool integration tests are split across merger-merge-lifecycle.test.ts and related merger-*.test.ts files
|
||||
// which tests aiMergeTask with real implementation
|
||||
});
|
||||
|
||||
|
||||
898
packages/engine/src/__tests__/merger-conflict-resolution.test.ts
Normal file
898
packages/engine/src/__tests__/merger-conflict-resolution.test.ts
Normal file
@@ -0,0 +1,898 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
describeModel: vi.fn(() => "mock-provider/mock-model"),
|
||||
promptWithFallback: vi.fn(async (session, prompt, options) => {
|
||||
if (options === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await session.prompt(prompt, options);
|
||||
}
|
||||
}),
|
||||
compactSessionContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Route async `exec` through the `execSync` mock so existing tests that set up
|
||||
// mockedExecSync.mockImplementation for verification commands (vitest run,
|
||||
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
|
||||
// resolves/rejects based on the callback wired here.
|
||||
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: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
|
||||
execFn[promisify.custom] = (cmd: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
|
||||
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
|
||||
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
|
||||
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "function" ? {} : opts;
|
||||
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../rate-limit-retry.js", () => ({
|
||||
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context-limit-detector.js", () => ({
|
||||
isContextLimitError: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
aiMergeTask,
|
||||
pushToRemoteAfterMerge,
|
||||
findWorktreeUser,
|
||||
detectResolvableConflicts,
|
||||
autoResolveFile,
|
||||
resolveConflicts,
|
||||
classifyConflict,
|
||||
getConflictedFiles,
|
||||
isTrivialWhitespaceConflict,
|
||||
resolveWithOurs,
|
||||
resolveWithTheirs,
|
||||
resolveTrivialWhitespace,
|
||||
LOCKFILE_PATTERNS,
|
||||
GENERATED_PATTERNS,
|
||||
parseDiffStat,
|
||||
extractFileScope,
|
||||
validateDiffScope,
|
||||
shouldSyncDependenciesForMerge,
|
||||
summarizeVerificationOutput,
|
||||
inferDefaultTestCommand,
|
||||
resolveTaskDiffBaseRef,
|
||||
commitOrAmendMergeWithFixes,
|
||||
MergeAbortedError,
|
||||
type ConflictCategory,
|
||||
} from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
import { createFnAgent } from "../pi.js";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import * as core from "@fusion/core";
|
||||
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExec = vi.mocked(exec);
|
||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||
const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
|
||||
|
||||
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
||||
const baseTask: Task = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...taskOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
|
||||
listTasks: vi.fn().mockResolvedValue(allTasks),
|
||||
updateTask: vi.fn().mockResolvedValue(baseTask),
|
||||
moveTask: vi.fn().mockResolvedValue(baseTask),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
getVerificationCacheHit: vi.fn().mockReturnValue(null),
|
||||
recordVerificationCachePass: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up execSync to handle the standard merge flow:
|
||||
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
|
||||
* diff --cached (post-agent verify), branch -d
|
||||
*
|
||||
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
|
||||
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
|
||||
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
|
||||
*
|
||||
* For tests that want the merge to fail after 3 attempts, call
|
||||
* setupFailingFallbackStrategy() instead.
|
||||
*/
|
||||
function setupHappyPathExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
return Buffer.from("");
|
||||
}
|
||||
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "did agent commit?" → "0" = yes
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as setupHappyPathExecSync but makes the final fallback merge fail
|
||||
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
|
||||
* to throw after 3 attempts fail.
|
||||
*/
|
||||
function setupFailingFallbackStrategy() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
|
||||
err.name = "ExecSyncError";
|
||||
throw err;
|
||||
}
|
||||
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "did agent commit?" → "0" = yes
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Renamed to setupFailingFallbackStrategy. */
|
||||
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
|
||||
|
||||
|
||||
describe("detectResolvableConflicts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty array when no conflicts exist", async () => {
|
||||
mockedExecSync.mockReturnValue(""); // Empty output = no conflicts
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects package-lock.json as auto-resolvable with 'theirs' strategy", async () => {
|
||||
mockedExecSync.mockReturnValue("package-lock.json\n");
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: "package-lock.json",
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "lock-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects pnpm-lock.yaml as lock file with 'ours' strategy", async () => {
|
||||
mockedExecSync.mockReturnValue("pnpm-lock.yaml\n");
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: "pnpm-lock.yaml",
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "lock-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects yarn.lock as lock file with 'ours' strategy", async () => {
|
||||
mockedExecSync.mockReturnValue("yarn.lock\n");
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "lock-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects Gemfile.lock as lock file with 'ours' strategy", async () => {
|
||||
mockedExecSync.mockReturnValue("Gemfile.lock\n");
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "lock-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects .gen.ts files as generated files with 'theirs' strategy", async () => {
|
||||
mockedExecSync.mockReturnValue("src/types.gen.ts\n");
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "theirs",
|
||||
reason: "generated-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects dist/ paths as generated files with 'theirs' strategy", async () => {
|
||||
mockedExecSync.mockReturnValue("dist/index.js\n");
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "theirs",
|
||||
reason: "generated-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects coverage/ paths as generated files with 'theirs' strategy", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git diff --name-only")) return "coverage/lcov.info\n";
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "theirs",
|
||||
reason: "generated-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks regular source files as complex conflicts", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git diff --name-only")) return "src/components/App.tsx\n";
|
||||
// git diff-tree for trivial detection — return real diff content to indicate non-trivial
|
||||
if (cmdStr.includes("diff-tree")) return "+real change\n-old line\n";
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: "src/components/App.tsx",
|
||||
autoResolvable: false,
|
||||
reason: "complex",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles multiple conflicted files with mixed categories", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git diff --name-only"))
|
||||
return "package-lock.json\nsrc/components/App.tsx\ndist/bundle.js\n";
|
||||
// git diff-tree for trivial detection — return real diff for source files
|
||||
if (cmdStr.includes("diff-tree")) return "+real change\n-old line\n";
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const lockFile = result.find((r) => r.filePath === "package-lock.json");
|
||||
const sourceFile = result.find((r) => r.filePath === "src/components/App.tsx");
|
||||
const distFile = result.find((r) => r.filePath === "dist/bundle.js");
|
||||
|
||||
expect(lockFile).toMatchObject({ autoResolvable: true, reason: "lock-file" });
|
||||
expect(sourceFile).toMatchObject({ autoResolvable: false, reason: "complex" });
|
||||
expect(distFile).toMatchObject({ autoResolvable: true, reason: "generated-file" });
|
||||
});
|
||||
|
||||
it("returns empty array on git command failure", async () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("git command failed");
|
||||
});
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("autoResolveFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock returns empty buffer for all git commands
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
});
|
||||
|
||||
it("calls git checkout --theirs for 'theirs' resolution", async () => {
|
||||
await autoResolveFile("package-lock.json", "theirs", "/tmp/root");
|
||||
|
||||
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git checkout --theirs"),
|
||||
);
|
||||
expect(checkoutCall).toBeDefined();
|
||||
expect(String(checkoutCall![0])).toContain("package-lock.json");
|
||||
});
|
||||
|
||||
it("calls git checkout --ours for 'ours' resolution", async () => {
|
||||
await autoResolveFile("config.json", "ours", "/tmp/root");
|
||||
|
||||
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git checkout --ours"),
|
||||
);
|
||||
expect(checkoutCall).toBeDefined();
|
||||
expect(String(checkoutCall![0])).toContain("config.json");
|
||||
});
|
||||
|
||||
it("stages the resolved file with git add", async () => {
|
||||
await autoResolveFile("package-lock.json", "theirs", "/tmp/root");
|
||||
|
||||
const addCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git add"),
|
||||
);
|
||||
expect(addCall).toBeDefined();
|
||||
expect(String(addCall![0])).toContain("package-lock.json");
|
||||
});
|
||||
|
||||
it("throws error when git checkout fails", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd).includes("checkout")) {
|
||||
throw new Error("checkout failed");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await expect(autoResolveFile("file.ts", "theirs", "/tmp/root")).rejects.toThrow(
|
||||
"Failed to auto-resolve",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("resolveConflicts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock - success
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
});
|
||||
|
||||
it("resolves lock files and returns remaining complex conflicts", async () => {
|
||||
const categories: ConflictCategory[] = [
|
||||
{ filePath: "package-lock.json", autoResolvable: true, strategy: "ours", reason: "lock-file" },
|
||||
{ filePath: "src/App.tsx", autoResolvable: false, reason: "complex" },
|
||||
{ filePath: "dist/bundle.js", autoResolvable: true, strategy: "ours", reason: "generated-file" },
|
||||
];
|
||||
|
||||
const remaining = await resolveConflicts(categories, "/tmp/root");
|
||||
|
||||
// Should have resolved package-lock.json and dist/bundle.js
|
||||
expect(remaining).toEqual(["src/App.tsx"]);
|
||||
|
||||
// Should have called checkout and add for resolved files
|
||||
const checkoutCalls = mockedExecSync.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("checkout"),
|
||||
);
|
||||
expect(checkoutCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns all files when none are auto-resolvable", async () => {
|
||||
const categories: ConflictCategory[] = [
|
||||
{ filePath: "src/App.tsx", autoResolvable: false, reason: "complex" },
|
||||
{ filePath: "src/utils.ts", autoResolvable: false, reason: "complex" },
|
||||
];
|
||||
|
||||
const remaining = await resolveConflicts(categories, "/tmp/root");
|
||||
|
||||
expect(remaining).toEqual(["src/App.tsx", "src/utils.ts"]);
|
||||
// No checkout calls should be made
|
||||
const checkoutCalls = mockedExecSync.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("checkout"),
|
||||
);
|
||||
expect(checkoutCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty array when all conflicts are resolved", async () => {
|
||||
const categories: ConflictCategory[] = [
|
||||
{ filePath: "package-lock.json", autoResolvable: true, strategy: "ours", reason: "lock-file" },
|
||||
{ filePath: "yarn.lock", autoResolvable: true, strategy: "ours", reason: "lock-file" },
|
||||
];
|
||||
|
||||
const remaining = await resolveConflicts(categories, "/tmp/root");
|
||||
|
||||
expect(remaining).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Trivial Conflict Detection Tests ──────────────────────────────────────
|
||||
|
||||
|
||||
describe("trivial conflict detection (isTrivialWhitespaceConflict via detectResolvableConflicts)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("detects whitespace-only conflicts as trivial", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
|
||||
// git diff-tree with -w returns empty = trivial whitespace
|
||||
if (cmdStr.includes("diff-tree")) return "";
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: "src/utils.ts",
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "trivial",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks conflicts with actual content differences as complex", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
|
||||
// git diff-tree returns real content changes = non-trivial
|
||||
if (cmdStr.includes("diff-tree")) return "+return 2;\n-return 1;\n";
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: "src/utils.ts",
|
||||
autoResolvable: false,
|
||||
reason: "complex",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles multiple conflict sections - one non-trivial makes complex", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
|
||||
// Real diff content = non-trivial
|
||||
if (cmdStr.includes("diff-tree")) return "+const x = 999;\n-const x = 2;\n";
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: false,
|
||||
reason: "complex",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles git command errors as complex conflicts", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
|
||||
if (cmdStr.includes("diff-tree")) throw new Error("git error");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: false,
|
||||
reason: "complex",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Retry Logic Tests ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
describe("classifyConflict", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("classifies package-lock.json as 'lockfile-ours'", async () => {
|
||||
const result = await classifyConflict("package-lock.json", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies pnpm-lock.yaml as 'lockfile-ours'", async () => {
|
||||
const result = await classifyConflict("pnpm-lock.yaml", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies yarn.lock as 'lockfile-ours'", async () => {
|
||||
const result = await classifyConflict("yarn.lock", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies Gemfile.lock as 'lockfile-ours'", async () => {
|
||||
const result = await classifyConflict("Gemfile.lock", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies bun.lockb as 'lockfile-ours'", async () => {
|
||||
const result = await classifyConflict("bun.lockb", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies go.sum as 'lockfile-ours'", async () => {
|
||||
const result = await classifyConflict("go.sum", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies *.gen.ts files as 'generated-theirs'", async () => {
|
||||
const result = await classifyConflict("src/types.gen.ts", "/tmp/root");
|
||||
expect(result).toBe("generated-theirs");
|
||||
});
|
||||
|
||||
it("classifies dist/* files as 'generated-theirs'", async () => {
|
||||
const result = await classifyConflict("dist/bundle.js", "/tmp/root");
|
||||
expect(result).toBe("generated-theirs");
|
||||
});
|
||||
|
||||
it("classifies build/* files as 'generated-theirs'", async () => {
|
||||
const result = await classifyConflict("build/index.html", "/tmp/root");
|
||||
expect(result).toBe("generated-theirs");
|
||||
});
|
||||
|
||||
it("classifies *.min.js files as 'generated-theirs'", async () => {
|
||||
const result = await classifyConflict("app.min.js", "/tmp/root");
|
||||
expect(result).toBe("generated-theirs");
|
||||
});
|
||||
|
||||
it("classifies regular source files as 'complex'", async () => {
|
||||
// Mock git diff-tree to return actual content changes (non-trivial)
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
const error = new Error("exit code 1") as any;
|
||||
error.stdout = `diff --git a/src/components/App.tsx b/src/components/App.tsx
|
||||
--- a/src/components/App.tsx
|
||||
+++ b/src/components/App.tsx
|
||||
@@ -1 +1 @@
|
||||
-const x = 1;
|
||||
+const x = 2;`;
|
||||
throw error;
|
||||
});
|
||||
mockedReadFileSync.mockReturnValue("const x = 1;");
|
||||
const result = await classifyConflict("src/components/App.tsx", "/tmp/root");
|
||||
expect(result).toBe("complex");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("getConflictedFiles", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns array of conflicted file paths", async () => {
|
||||
mockedExecSync.mockReturnValue("package-lock.json\nsrc/index.ts\n");
|
||||
|
||||
const result = await getConflictedFiles("/tmp/root");
|
||||
expect(result).toEqual(["package-lock.json", "src/index.ts"]);
|
||||
});
|
||||
|
||||
it("returns empty array when no conflicts", async () => {
|
||||
mockedExecSync.mockReturnValue("");
|
||||
|
||||
const result = await getConflictedFiles("/tmp/root");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array on git error", async () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("git error");
|
||||
});
|
||||
|
||||
const result = await getConflictedFiles("/tmp/root");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("resolveWithOurs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
});
|
||||
|
||||
it("calls git checkout --ours and git add", async () => {
|
||||
await resolveWithOurs("package-lock.json", "/tmp/root");
|
||||
|
||||
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("checkout --ours"),
|
||||
);
|
||||
const addCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git add"),
|
||||
);
|
||||
|
||||
expect(checkoutCall).toBeDefined();
|
||||
expect(addCall).toBeDefined();
|
||||
expect(String(checkoutCall![0])).toContain("package-lock.json");
|
||||
});
|
||||
|
||||
it("throws on git error", async () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("checkout failed");
|
||||
});
|
||||
|
||||
await expect(resolveWithOurs("file.ts", "/tmp/root")).rejects.toThrow(
|
||||
"Failed to auto-resolve",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("resolveWithTheirs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
});
|
||||
|
||||
it("calls git checkout --theirs and git add", async () => {
|
||||
await resolveWithTheirs("dist/bundle.js", "/tmp/root");
|
||||
|
||||
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("checkout --theirs"),
|
||||
);
|
||||
const addCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git add"),
|
||||
);
|
||||
|
||||
expect(checkoutCall).toBeDefined();
|
||||
expect(addCall).toBeDefined();
|
||||
expect(String(checkoutCall![0])).toContain("dist/bundle.js");
|
||||
});
|
||||
|
||||
it("throws on git error", async () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("checkout failed");
|
||||
});
|
||||
|
||||
await expect(resolveWithTheirs("file.ts", "/tmp/root")).rejects.toThrow(
|
||||
"Failed to auto-resolve",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("resolveTrivialWhitespace", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
});
|
||||
|
||||
it("calls git add to resolve trivial whitespace conflict", async () => {
|
||||
await resolveTrivialWhitespace("src/utils.ts", "/tmp/root");
|
||||
|
||||
const addCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git add"),
|
||||
);
|
||||
|
||||
expect(addCall).toBeDefined();
|
||||
expect(String(addCall![0])).toContain("src/utils.ts");
|
||||
});
|
||||
|
||||
it("throws on git error", async () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("add failed");
|
||||
});
|
||||
|
||||
await expect(resolveTrivialWhitespace("file.ts", "/tmp/root")).rejects.toThrow(
|
||||
"Failed to auto-resolve",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("LOCKFILE_PATTERNS and GENERATED_PATTERNS", () => {
|
||||
it("LOCKFILE_PATTERNS contains expected lock file patterns", () => {
|
||||
expect(LOCKFILE_PATTERNS).toContain("package-lock.json");
|
||||
expect(LOCKFILE_PATTERNS).toContain("pnpm-lock.yaml");
|
||||
expect(LOCKFILE_PATTERNS).toContain("yarn.lock");
|
||||
expect(LOCKFILE_PATTERNS).toContain("Gemfile.lock");
|
||||
expect(LOCKFILE_PATTERNS).toContain("bun.lockb");
|
||||
expect(LOCKFILE_PATTERNS).toContain("go.sum");
|
||||
expect(LOCKFILE_PATTERNS).toContain("composer.lock");
|
||||
expect(LOCKFILE_PATTERNS).toContain("poetry.lock");
|
||||
expect(LOCKFILE_PATTERNS).not.toContain("Cargo.lock"); // Not in task spec
|
||||
});
|
||||
|
||||
it("GENERATED_PATTERNS contains expected generated file patterns", () => {
|
||||
expect(GENERATED_PATTERNS).toContain("*.gen.ts");
|
||||
expect(GENERATED_PATTERNS).toContain("*.gen.js");
|
||||
expect(GENERATED_PATTERNS).toContain("*.min.js");
|
||||
expect(GENERATED_PATTERNS).toContain("*.min.css");
|
||||
expect(GENERATED_PATTERNS).toContain("dist/*");
|
||||
expect(GENERATED_PATTERNS).toContain("build/*");
|
||||
expect(GENERATED_PATTERNS).toContain("coverage/*");
|
||||
expect(GENERATED_PATTERNS).toContain("out/*");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("isTrivialWhitespaceConflict", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns true when diff contains only whitespace changes", async () => {
|
||||
// Mock git diff-tree to return empty diff (no content changes)
|
||||
mockedExecSync.mockReturnValue(
|
||||
"diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n"
|
||||
);
|
||||
|
||||
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when diff contains content changes", async () => {
|
||||
// Mock git diff-tree to return diff with actual content changes
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
const error = new Error("exit code 1") as any;
|
||||
error.stdout = `diff --git a/file.ts b/file.ts
|
||||
--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1 +1 @@
|
||||
-const x = 1;
|
||||
+const x = 2;`;
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when only line endings differ (CRLF vs LF)", async () => {
|
||||
// Mock git diff-tree -w to show no content changes (whitespace ignored)
|
||||
mockedExecSync.mockReturnValue(
|
||||
"diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n"
|
||||
);
|
||||
|
||||
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when git diff-tree fails unexpectedly", async () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("fatal: not a git repository");
|
||||
});
|
||||
// Mock readFileSync for the fallback
|
||||
mockedReadFileSync.mockReturnValue("content without conflict markers");
|
||||
|
||||
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("calls git diff-tree with correct index references (:2: and :3:)", async () => {
|
||||
mockedExecSync.mockReturnValue("");
|
||||
|
||||
await isTrivialWhitespaceConflict("src/utils.ts", "/tmp/root");
|
||||
|
||||
const call = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git diff-tree")
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
const cmdStr = String(call![0]);
|
||||
expect(cmdStr).toContain("-w"); // whitespace ignored
|
||||
expect(cmdStr).toContain(':2:"src/utils.ts"');
|
||||
expect(cmdStr).toContain(':3:"src/utils.ts"');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Build Verification Tests ─────────────────────────────────────────
|
||||
|
||||
|
||||
609
packages/engine/src/__tests__/merger-diff-scope.test.ts
Normal file
609
packages/engine/src/__tests__/merger-diff-scope.test.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
describeModel: vi.fn(() => "mock-provider/mock-model"),
|
||||
promptWithFallback: vi.fn(async (session, prompt, options) => {
|
||||
if (options === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await session.prompt(prompt, options);
|
||||
}
|
||||
}),
|
||||
compactSessionContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Route async `exec` through the `execSync` mock so existing tests that set up
|
||||
// mockedExecSync.mockImplementation for verification commands (vitest run,
|
||||
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
|
||||
// resolves/rejects based on the callback wired here.
|
||||
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: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
|
||||
execFn[promisify.custom] = (cmd: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
|
||||
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
|
||||
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
|
||||
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "function" ? {} : opts;
|
||||
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../rate-limit-retry.js", () => ({
|
||||
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context-limit-detector.js", () => ({
|
||||
isContextLimitError: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
aiMergeTask,
|
||||
pushToRemoteAfterMerge,
|
||||
findWorktreeUser,
|
||||
detectResolvableConflicts,
|
||||
autoResolveFile,
|
||||
resolveConflicts,
|
||||
classifyConflict,
|
||||
getConflictedFiles,
|
||||
isTrivialWhitespaceConflict,
|
||||
resolveWithOurs,
|
||||
resolveWithTheirs,
|
||||
resolveTrivialWhitespace,
|
||||
LOCKFILE_PATTERNS,
|
||||
GENERATED_PATTERNS,
|
||||
parseDiffStat,
|
||||
extractFileScope,
|
||||
validateDiffScope,
|
||||
shouldSyncDependenciesForMerge,
|
||||
summarizeVerificationOutput,
|
||||
inferDefaultTestCommand,
|
||||
resolveTaskDiffBaseRef,
|
||||
commitOrAmendMergeWithFixes,
|
||||
MergeAbortedError,
|
||||
type ConflictCategory,
|
||||
} from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
import { createFnAgent } from "../pi.js";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import * as core from "@fusion/core";
|
||||
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExec = vi.mocked(exec);
|
||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||
const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
|
||||
|
||||
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
||||
const baseTask: Task = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...taskOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
|
||||
listTasks: vi.fn().mockResolvedValue(allTasks),
|
||||
updateTask: vi.fn().mockResolvedValue(baseTask),
|
||||
moveTask: vi.fn().mockResolvedValue(baseTask),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
getVerificationCacheHit: vi.fn().mockReturnValue(null),
|
||||
recordVerificationCachePass: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up execSync to handle the standard merge flow:
|
||||
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
|
||||
* diff --cached (post-agent verify), branch -d
|
||||
*
|
||||
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
|
||||
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
|
||||
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
|
||||
*
|
||||
* For tests that want the merge to fail after 3 attempts, call
|
||||
* setupFailingFallbackStrategy() instead.
|
||||
*/
|
||||
function setupHappyPathExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
return Buffer.from("");
|
||||
}
|
||||
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "did agent commit?" → "0" = yes
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as setupHappyPathExecSync but makes the final fallback merge fail
|
||||
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
|
||||
* to throw after 3 attempts fail.
|
||||
*/
|
||||
function setupFailingFallbackStrategy() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
|
||||
err.name = "ExecSyncError";
|
||||
throw err;
|
||||
}
|
||||
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "did agent commit?" → "0" = yes
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Renamed to setupFailingFallbackStrategy. */
|
||||
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
|
||||
|
||||
|
||||
describe("shouldSyncDependenciesForMerge", () => {
|
||||
it("returns true when install state is missing", () => {
|
||||
expect(shouldSyncDependenciesForMerge([], false)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when staged files change package manifests or lockfiles", () => {
|
||||
expect(shouldSyncDependenciesForMerge(["packages/desktop/package.json"], true)).toBe(true);
|
||||
expect(shouldSyncDependenciesForMerge(["pnpm-lock.yaml"], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular source-only changes when install state exists", () => {
|
||||
expect(shouldSyncDependenciesForMerge(["packages/engine/src/merger.ts"], true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pre-merge diffstat scope validation tests ────────────────────────
|
||||
|
||||
|
||||
describe("parseDiffStat", () => {
|
||||
it("parses standard diffstat output", () => {
|
||||
const stat = [
|
||||
" packages/core/src/types.ts | 9 ++--",
|
||||
" packages/engine/src/notifier.ts | 46 +-----",
|
||||
" 2 files changed, 10 insertions(+), 45 deletions(-)",
|
||||
].join("\n");
|
||||
|
||||
const entries = parseDiffStat(stat);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].file).toBe("packages/core/src/types.ts");
|
||||
// Rounding may shift total by ±1, so check approximate range
|
||||
expect(entries[0].insertions + entries[0].deletions).toBeGreaterThanOrEqual(9);
|
||||
expect(entries[0].insertions + entries[0].deletions).toBeLessThanOrEqual(10);
|
||||
expect(entries[1].file).toBe("packages/engine/src/notifier.ts");
|
||||
expect(entries[1].deletions).toBeGreaterThan(entries[1].insertions);
|
||||
});
|
||||
|
||||
it("handles pure-deletion lines", () => {
|
||||
const stat = " packages/engine/src/usage.ts | 527 ---";
|
||||
const entries = parseDiffStat(stat);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].insertions).toBe(0);
|
||||
expect(entries[0].deletions).toBe(527);
|
||||
});
|
||||
|
||||
it("handles pure-insertion lines", () => {
|
||||
const stat = " packages/engine/src/new.ts | 100 +++";
|
||||
const entries = parseDiffStat(stat);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].insertions).toBe(100);
|
||||
expect(entries[0].deletions).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty for unreadable stat", () => {
|
||||
expect(parseDiffStat("(unable to read diff)")).toEqual([]);
|
||||
expect(parseDiffStat("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips summary line", () => {
|
||||
const stat = " 1 file changed, 5 insertions(+)";
|
||||
expect(parseDiffStat(stat)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("extractFileScope", () => {
|
||||
it("extracts file patterns from PROMPT.md", () => {
|
||||
const prompt = [
|
||||
"# Task: FN-100 - Add feature",
|
||||
"",
|
||||
"## File Scope",
|
||||
"",
|
||||
"- `packages/core/src/types.ts`",
|
||||
"- `packages/engine/src/notifier.ts`",
|
||||
"- `packages/dashboard/app/components/*`",
|
||||
"",
|
||||
"## Steps",
|
||||
"",
|
||||
"### Step 1: Do things",
|
||||
].join("\n");
|
||||
|
||||
const scope = extractFileScope(prompt);
|
||||
expect(scope).toEqual([
|
||||
"packages/core/src/types.ts",
|
||||
"packages/engine/src/notifier.ts",
|
||||
"packages/dashboard/app/components/*",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles patterns with artifact annotations", () => {
|
||||
const prompt = [
|
||||
"## File Scope",
|
||||
"",
|
||||
"- `src/foo.ts` (new)",
|
||||
"- `src/bar.ts` (modified)",
|
||||
"",
|
||||
"## Steps",
|
||||
].join("\n");
|
||||
|
||||
const scope = extractFileScope(prompt);
|
||||
expect(scope).toEqual(["src/foo.ts", "src/bar.ts"]);
|
||||
});
|
||||
|
||||
it("returns empty for missing File Scope section", () => {
|
||||
const prompt = "# Task\n\n## Steps\n### Step 1\n";
|
||||
expect(extractFileScope(prompt)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("validateDiffScope", () => {
|
||||
it("returns warnings for large deletions outside scope", async () => {
|
||||
const store = {
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
prompt: [
|
||||
"## File Scope",
|
||||
"",
|
||||
"- `packages/dashboard/app/components/Header.tsx`",
|
||||
"",
|
||||
"## Steps",
|
||||
].join("\n"),
|
||||
}),
|
||||
logEntry: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const diffStat = [
|
||||
" packages/dashboard/app/components/Header.tsx | 20 ++--",
|
||||
" packages/engine/src/usage.ts | 527 ---",
|
||||
" packages/engine/src/usage.test.ts | 524 ---",
|
||||
" 3 files changed, 5 insertions(+), 1066 deletions(-)",
|
||||
].join("\n");
|
||||
|
||||
const result = await validateDiffScope(store, "FN-100", diffStat);
|
||||
expect(result.outOfScopeFiles).toContain("packages/engine/src/usage.ts");
|
||||
expect(result.outOfScopeFiles).toContain("packages/engine/src/usage.test.ts");
|
||||
expect(result.largeOutOfScopeDeletions).toHaveLength(2);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
expect(result.warnings[0]).toContain("SCOPE WARNING");
|
||||
});
|
||||
|
||||
it("allows changeset files outside scope", async () => {
|
||||
const store = {
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
prompt: "## File Scope\n\n- `src/foo.ts`\n\n## Steps",
|
||||
}),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const diffStat = [
|
||||
" src/foo.ts | 10 +++",
|
||||
" .changeset/my-change.md | 5 +++",
|
||||
" 2 files changed, 15 insertions(+)",
|
||||
].join("\n");
|
||||
|
||||
const result = await validateDiffScope(store, "FN-100", diffStat);
|
||||
expect(result.outOfScopeFiles).not.toContain(".changeset/my-change.md");
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty result when no scope is declared", async () => {
|
||||
const store = {
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
prompt: "# Task\n\n## Steps\n",
|
||||
}),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const result = await validateDiffScope(store, "FN-100", " foo.ts | 500 ---");
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not warn for in-scope changes", async () => {
|
||||
const store = {
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
prompt: "## File Scope\n\n- `packages/engine/src/*`\n\n## Steps",
|
||||
}),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const diffStat = [
|
||||
" packages/engine/src/executor.ts | 50 +++---",
|
||||
" packages/engine/src/triage.ts | 30 +++---",
|
||||
" 2 files changed, 40 insertions(+), 40 deletions(-)",
|
||||
].join("\n");
|
||||
|
||||
const result = await validateDiffScope(store, "FN-100", diffStat);
|
||||
expect(result.outOfScopeFiles).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("resolveTaskDiffBaseRef", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("prefers merge-base when it differs from head", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git merge-base "HEAD" "main"') return "merge-base-123" as any;
|
||||
if (cmdStr === 'git rev-parse "HEAD"') return "head-456" as any;
|
||||
throw new Error(`Unexpected command: ${cmdStr}`);
|
||||
});
|
||||
|
||||
const diffBase = await resolveTaskDiffBaseRef({
|
||||
cwd: "/tmp/root",
|
||||
headRef: "HEAD",
|
||||
baseBranch: "main",
|
||||
baseCommitSha: "task-base-789",
|
||||
});
|
||||
|
||||
expect(diffBase).toBe("merge-base-123");
|
||||
expect(
|
||||
mockedExecSync.mock.calls.some(([cmd]) =>
|
||||
String(cmd).includes("merge-base --is-ancestor"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses baseCommitSha when merge-base equals head", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git merge-base "HEAD" "main"') return "head-456" as any;
|
||||
if (cmdStr === 'git rev-parse "HEAD"') return "head-456" as any;
|
||||
if (cmdStr === 'git merge-base --is-ancestor "task-base-789" "HEAD"') return "" as any;
|
||||
throw new Error(`Unexpected command: ${cmdStr}`);
|
||||
});
|
||||
|
||||
const diffBase = await resolveTaskDiffBaseRef({
|
||||
cwd: "/tmp/root",
|
||||
headRef: "HEAD",
|
||||
baseBranch: "main",
|
||||
baseCommitSha: "task-base-789",
|
||||
});
|
||||
|
||||
expect(diffBase).toBe("task-base-789");
|
||||
});
|
||||
|
||||
it("falls back to HEAD~1 when merge-base is unavailable and baseCommitSha is stale", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git merge-base "HEAD" "main"') {
|
||||
throw new Error("missing local main");
|
||||
}
|
||||
if (cmdStr === 'git merge-base "HEAD" "origin/main"') {
|
||||
throw new Error("missing remote main");
|
||||
}
|
||||
if (cmdStr === 'git merge-base --is-ancestor "stale-base" "HEAD"') {
|
||||
throw new Error("stale base sha");
|
||||
}
|
||||
if (cmdStr === 'git rev-parse "HEAD~1"') return "parent-123" as any;
|
||||
throw new Error(`Unexpected command: ${cmdStr}`);
|
||||
});
|
||||
|
||||
const diffBase = await resolveTaskDiffBaseRef({
|
||||
cwd: "/tmp/root",
|
||||
headRef: "HEAD",
|
||||
baseBranch: "main",
|
||||
baseCommitSha: "stale-base",
|
||||
});
|
||||
|
||||
expect(diffBase).toBe("parent-123");
|
||||
});
|
||||
|
||||
it("returns undefined when no merge base, no valid baseCommitSha, and no parent commit are available", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git merge-base "HEAD" "main"') {
|
||||
throw new Error("missing local main");
|
||||
}
|
||||
if (cmdStr === 'git merge-base "HEAD" "origin/main"') {
|
||||
throw new Error("missing remote main");
|
||||
}
|
||||
if (cmdStr === 'git merge-base --is-ancestor "stale-base" "HEAD"') {
|
||||
throw new Error("stale base sha");
|
||||
}
|
||||
if (cmdStr === 'git rev-parse "HEAD~1"') {
|
||||
throw new Error("single commit repo");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmdStr}`);
|
||||
});
|
||||
|
||||
const diffBase = await resolveTaskDiffBaseRef({
|
||||
cwd: "/tmp/root",
|
||||
headRef: "HEAD",
|
||||
baseBranch: "main",
|
||||
baseCommitSha: "stale-base",
|
||||
});
|
||||
|
||||
expect(diffBase).toBeUndefined();
|
||||
});
|
||||
|
||||
// FN-3898 regression: legacy/imported tasks may have a stale baseCommitSha
|
||||
// and no baseBranch. After pre-merge rebase the recorded SHA is older than
|
||||
// the new merge-base, so `baseCommitSha..branch` includes every unrelated
|
||||
// commit landed on main since the fork — inflating scope warnings.
|
||||
it("tightens to merge-base(HEAD, main) when baseBranch is missing and baseCommitSha is an outdated ancestor", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
// No baseBranch → outer merge-base block is skipped.
|
||||
// Display recovery: merge-base(HEAD, main).
|
||||
if (cmdStr === 'git merge-base "HEAD" main') return "current-main-sha" as any;
|
||||
// baseCommitSha is still an ancestor of HEAD…
|
||||
if (cmdStr === 'git merge-base --is-ancestor "old-base-sha" "HEAD"') return "" as any;
|
||||
// …and recoveredBase descends baseCommitSha (rebase fast-forwarded).
|
||||
if (cmdStr === 'git merge-base --is-ancestor "old-base-sha" "current-main-sha"') return "" as any;
|
||||
throw new Error(`Unexpected command: ${cmdStr}`);
|
||||
});
|
||||
|
||||
const diffBase = await resolveTaskDiffBaseRef({
|
||||
cwd: "/tmp/root",
|
||||
headRef: "HEAD",
|
||||
baseBranch: undefined,
|
||||
baseCommitSha: "old-base-sha",
|
||||
});
|
||||
|
||||
expect(diffBase).toBe("current-main-sha");
|
||||
});
|
||||
|
||||
// Preserves the FN-2855 path: when the recovered merge-base is NOT a
|
||||
// descendant of baseCommitSha (e.g., baseCommitSha lives on a deleted
|
||||
// upstream feature branch), keep the task-scoped SHA rather than widening
|
||||
// the diff range.
|
||||
it("keeps baseCommitSha when recoveredBase does not descend it", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git merge-base "HEAD" main') return "unrelated-main-sha" as any;
|
||||
if (cmdStr === 'git merge-base --is-ancestor "feature-base-sha" "HEAD"') return "" as any;
|
||||
if (cmdStr === 'git merge-base --is-ancestor "feature-base-sha" "unrelated-main-sha"') {
|
||||
throw new Error("not an ancestor");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmdStr}`);
|
||||
});
|
||||
|
||||
const diffBase = await resolveTaskDiffBaseRef({
|
||||
cwd: "/tmp/root",
|
||||
headRef: "HEAD",
|
||||
baseBranch: undefined,
|
||||
baseCommitSha: "feature-base-sha",
|
||||
});
|
||||
|
||||
expect(diffBase).toBe("feature-base-sha");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
1004
packages/engine/src/__tests__/merger-merge-details.test.ts
Normal file
1004
packages/engine/src/__tests__/merger-merge-details.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2119
packages/engine/src/__tests__/merger-merge-lifecycle.test.ts
Normal file
2119
packages/engine/src/__tests__/merger-merge-lifecycle.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
904
packages/engine/src/__tests__/merger-post-merge.test.ts
Normal file
904
packages/engine/src/__tests__/merger-post-merge.test.ts
Normal file
@@ -0,0 +1,904 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
describeModel: vi.fn(() => "mock-provider/mock-model"),
|
||||
promptWithFallback: vi.fn(async (session, prompt, options) => {
|
||||
if (options === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await session.prompt(prompt, options);
|
||||
}
|
||||
}),
|
||||
compactSessionContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Route async `exec` through the `execSync` mock so existing tests that set up
|
||||
// mockedExecSync.mockImplementation for verification commands (vitest run,
|
||||
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
|
||||
// resolves/rejects based on the callback wired here.
|
||||
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: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
|
||||
execFn[promisify.custom] = (cmd: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
|
||||
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
|
||||
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
|
||||
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "function" ? {} : opts;
|
||||
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../rate-limit-retry.js", () => ({
|
||||
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context-limit-detector.js", () => ({
|
||||
isContextLimitError: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
aiMergeTask,
|
||||
pushToRemoteAfterMerge,
|
||||
findWorktreeUser,
|
||||
detectResolvableConflicts,
|
||||
autoResolveFile,
|
||||
resolveConflicts,
|
||||
classifyConflict,
|
||||
getConflictedFiles,
|
||||
isTrivialWhitespaceConflict,
|
||||
resolveWithOurs,
|
||||
resolveWithTheirs,
|
||||
resolveTrivialWhitespace,
|
||||
LOCKFILE_PATTERNS,
|
||||
GENERATED_PATTERNS,
|
||||
parseDiffStat,
|
||||
extractFileScope,
|
||||
validateDiffScope,
|
||||
shouldSyncDependenciesForMerge,
|
||||
summarizeVerificationOutput,
|
||||
inferDefaultTestCommand,
|
||||
resolveTaskDiffBaseRef,
|
||||
commitOrAmendMergeWithFixes,
|
||||
MergeAbortedError,
|
||||
type ConflictCategory,
|
||||
} from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
import { createFnAgent } from "../pi.js";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import * as core from "@fusion/core";
|
||||
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExec = vi.mocked(exec);
|
||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||
const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
|
||||
|
||||
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
||||
const baseTask: Task = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...taskOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
|
||||
listTasks: vi.fn().mockResolvedValue(allTasks),
|
||||
updateTask: vi.fn().mockResolvedValue(baseTask),
|
||||
moveTask: vi.fn().mockResolvedValue(baseTask),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
getVerificationCacheHit: vi.fn().mockReturnValue(null),
|
||||
recordVerificationCachePass: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up execSync to handle the standard merge flow:
|
||||
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
|
||||
* diff --cached (post-agent verify), branch -d
|
||||
*
|
||||
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
|
||||
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
|
||||
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
|
||||
*
|
||||
* For tests that want the merge to fail after 3 attempts, call
|
||||
* setupFailingFallbackStrategy() instead.
|
||||
*/
|
||||
function setupHappyPathExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
return Buffer.from("");
|
||||
}
|
||||
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "did agent commit?" → "0" = yes
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as setupHappyPathExecSync but makes the final fallback merge fail
|
||||
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
|
||||
* to throw after 3 attempts fail.
|
||||
*/
|
||||
function setupFailingFallbackStrategy() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
|
||||
err.name = "ExecSyncError";
|
||||
throw err;
|
||||
}
|
||||
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "did agent commit?" → "0" = yes
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Renamed to setupFailingFallbackStrategy. */
|
||||
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
|
||||
|
||||
|
||||
describe("aiMergeTask — post-merge workflow steps", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
setupHappyPathExecSync();
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
state: {},
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("runs post-merge workflow steps after successful merge", async () => {
|
||||
const store = createMockStore();
|
||||
// Add getWorkflowStep to mock
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Post-merge Notify",
|
||||
description: "Send notifications after merge",
|
||||
prompt: "Check the merged code and confirm all is well.",
|
||||
phase: "post-merge",
|
||||
mode: "prompt",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Override getTask to include enabledWorkflowSteps
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
// getWorkflowStep should have been called for the post-merge step
|
||||
expect((store as any).getWorkflowStep).toHaveBeenCalledWith("WS-001");
|
||||
|
||||
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
|
||||
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
|
||||
);
|
||||
expect(postMergeAgentCall).toBeDefined();
|
||||
expect(postMergeAgentCall?.[0]?.cwd).toMatch(/\.worktrees\/post-merge-FN-050-[a-z0-9]+/);
|
||||
expect(postMergeAgentCall?.[0]?.cwd).not.toBe("/tmp/root");
|
||||
|
||||
// Task should still move to done even though post-merge step ran
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
});
|
||||
|
||||
it("uses assigned agent runtime model for post-merge prompt step when workflow step has no override", async () => {
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Post-merge Notify",
|
||||
description: "Send notifications after merge",
|
||||
prompt: "Check merged code.",
|
||||
phase: "post-merge",
|
||||
mode: "prompt",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
assignedAgentId: "agent-001",
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050", {
|
||||
agentStore: {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
getAgent: vi.fn().mockResolvedValue({
|
||||
id: "agent-001",
|
||||
runtimeConfig: {
|
||||
model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
},
|
||||
}),
|
||||
} as any,
|
||||
});
|
||||
|
||||
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
|
||||
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
|
||||
);
|
||||
expect(postMergeAgentCall?.[0]?.defaultProvider).toBe("anthropic");
|
||||
expect(postMergeAgentCall?.[0]?.defaultModelId).toBe("claude-3-5-sonnet-20241022");
|
||||
});
|
||||
|
||||
it("uses workflow-step model override over assigned agent runtime model", async () => {
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Post-merge Notify",
|
||||
description: "Send notifications after merge",
|
||||
prompt: "Check merged code.",
|
||||
phase: "post-merge",
|
||||
mode: "prompt",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4.1-mini",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
assignedAgentId: "agent-001",
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050", {
|
||||
agentStore: {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
getAgent: vi.fn().mockResolvedValue({
|
||||
id: "agent-001",
|
||||
runtimeConfig: {
|
||||
model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
},
|
||||
}),
|
||||
} as any,
|
||||
});
|
||||
|
||||
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
|
||||
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
|
||||
);
|
||||
expect(postMergeAgentCall?.[0]?.defaultProvider).toBe("openai");
|
||||
expect(postMergeAgentCall?.[0]?.defaultModelId).toBe("gpt-4.1-mini");
|
||||
|
||||
const modelLogCall = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call: any) => String(call[1]).includes("Workflow step 'Post-merge Notify' using model:"),
|
||||
);
|
||||
expect(modelLogCall?.[1]).toContain("(workflow step override)");
|
||||
});
|
||||
|
||||
it("falls back to project default override model when no workflow-step or assigned-agent model is set", async () => {
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Post-merge Notify",
|
||||
description: "Send notifications after merge",
|
||||
prompt: "Check merged code.",
|
||||
phase: "post-merge",
|
||||
mode: "prompt",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
defaultProviderOverride: "openai",
|
||||
defaultModelIdOverride: "gpt-4o-mini",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-3-5-haiku-latest",
|
||||
});
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
|
||||
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
|
||||
);
|
||||
expect(postMergeAgentCall?.[0]?.defaultProvider).toBe("openai");
|
||||
expect(postMergeAgentCall?.[0]?.defaultModelId).toBe("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("does not run pre-merge workflow steps in merger", async () => {
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Pre-merge Check",
|
||||
description: "Check before merge",
|
||||
prompt: "Run pre-merge checks.",
|
||||
phase: "pre-merge",
|
||||
mode: "prompt",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
// getWorkflowStep may be called but pre-merge steps should not trigger agent creation
|
||||
// beyond the merge agent itself. We verify createFnAgent was called only once (merge agent)
|
||||
// since pre-merge steps are skipped in the merger
|
||||
const mergeAgentCalls = mockedCreateFnAgent.mock.calls.filter(
|
||||
(c: any) => c[0]?.systemPrompt?.includes("You are a merge agent")
|
||||
);
|
||||
const postMergeCalls = mockedCreateFnAgent.mock.calls.filter(
|
||||
(c: any) => c[0]?.systemPrompt?.includes("post-merge")
|
||||
);
|
||||
|
||||
// No post-merge agent should be created for a pre-merge step
|
||||
expect(postMergeCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("appends post-merge results to existing pre-merge results", async () => {
|
||||
const existingPreMergeResults = [{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Pre-merge Check",
|
||||
phase: "pre-merge",
|
||||
status: "passed",
|
||||
output: "All good",
|
||||
}];
|
||||
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-002",
|
||||
name: "Post-merge Verify",
|
||||
description: "Verify after merge",
|
||||
prompt: "Check merged state.",
|
||||
phase: "post-merge",
|
||||
mode: "prompt",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001", "WS-002"],
|
||||
workflowStepResults: existingPreMergeResults,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
// Should have called updateTask with workflow results containing both pre and post
|
||||
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const resultsCall = updateCalls.find((c: any) =>
|
||||
Array.isArray(c[1]?.workflowStepResults) && c[1].workflowStepResults.length > 1
|
||||
);
|
||||
|
||||
if (resultsCall) {
|
||||
const results = resultsCall[1].workflowStepResults;
|
||||
// Should contain both pre-merge and post-merge results
|
||||
expect(results.some((r: any) => r.phase === "pre-merge")).toBe(true);
|
||||
expect(results.some((r: any) => r.phase === "post-merge")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves task to done even when post-merge step fails", async () => {
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Post-merge Fail",
|
||||
description: "Will fail",
|
||||
prompt: "Fail this check.",
|
||||
phase: "post-merge",
|
||||
mode: "prompt",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
// Make the post-merge agent throw
|
||||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||||
if (opts.systemPrompt?.includes("post-merge")) {
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockRejectedValue(new Error("Post-merge agent failed")),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
state: {},
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
state: {},
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
},
|
||||
};
|
||||
}) as any);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
// Merge should succeed regardless of post-merge step failure
|
||||
expect(result.merged).toBe(true);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
});
|
||||
|
||||
it("runs script-mode post-merge steps", async () => {
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Post-merge Build",
|
||||
description: "Verify build passes",
|
||||
phase: "post-merge",
|
||||
mode: "script",
|
||||
scriptName: "build",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
// Override settings to include scripts
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
scripts: { build: "pnpm build" },
|
||||
});
|
||||
|
||||
// Mock execSync to handle the script execution
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
if (cmdStr === "pnpm build") return "Build successful" as any;
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
const scriptExecCall = mockedExec.mock.calls.find((call: any) => String(call[0]) === "pnpm build");
|
||||
expect(scriptExecCall).toBeDefined();
|
||||
expect(scriptExecCall?.[1]?.cwd).toMatch(/\.worktrees\/post-merge-FN-050-[a-z0-9]+/);
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
});
|
||||
|
||||
it("creates temporary worktree for post-merge steps", async () => {
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Post-merge Notify",
|
||||
description: "Send notifications after merge",
|
||||
prompt: "Check the merged code and confirm all is well.",
|
||||
phase: "post-merge",
|
||||
mode: "prompt",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
const worktreeAddCall = mockedExec.mock.calls.find((call: any) =>
|
||||
String(call[0]).includes("git worktree add") && String(call[0]).includes("post-merge-FN-050-"),
|
||||
);
|
||||
const worktreeRemoveCall = mockedExec.mock.calls.find((call: any) =>
|
||||
String(call[0]).includes("git worktree remove --force") && String(call[0]).includes("post-merge-FN-050-"),
|
||||
);
|
||||
|
||||
expect(worktreeAddCall).toBeDefined();
|
||||
expect(worktreeRemoveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to rootDir when worktree creation fails", async () => {
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Post-merge Notify",
|
||||
description: "Send notifications after merge",
|
||||
prompt: "Check the merged code and confirm all is well.",
|
||||
phase: "post-merge",
|
||||
mode: "prompt",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
const baseExecImpl = mockedExecSync.getMockImplementation();
|
||||
mockedExecSync.mockImplementation((cmd: any, opts: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git worktree add") && cmdStr.includes("post-merge-FN-050-")) {
|
||||
const error: any = new Error("cannot create worktree");
|
||||
error.stderr = "cannot create worktree";
|
||||
throw error;
|
||||
}
|
||||
return baseExecImpl ? baseExecImpl(cmd, opts) : Buffer.from("");
|
||||
});
|
||||
|
||||
const warnSpy = vi.spyOn(mergerLog, "warn");
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
|
||||
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
|
||||
);
|
||||
expect(postMergeAgentCall?.[0]?.cwd).toBe("/tmp/root");
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("could not create post-merge worktree — falling back to rootDir"));
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
});
|
||||
|
||||
it("cleans up temporary worktree even when post-merge step fails", async () => {
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Post-merge Fail",
|
||||
description: "Will fail",
|
||||
prompt: "Fail this check.",
|
||||
phase: "post-merge",
|
||||
mode: "prompt",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||||
if (opts.systemPrompt?.includes("post-merge")) {
|
||||
throw new Error("Post-merge agent creation failed");
|
||||
}
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
state: {},
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
},
|
||||
};
|
||||
}) as any);
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
const worktreeRemoveCall = mockedExec.mock.calls.find((call: any) =>
|
||||
String(call[0]).includes("git worktree remove --force") && String(call[0]).includes("post-merge-FN-050-"),
|
||||
);
|
||||
expect(worktreeRemoveCall).toBeDefined();
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
});
|
||||
|
||||
it("does not create post-merge worktree when no post-merge steps exist", async () => {
|
||||
const store = createMockStore();
|
||||
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Pre-merge Check",
|
||||
description: "Check before merge",
|
||||
prompt: "Run pre-merge checks.",
|
||||
phase: "pre-merge",
|
||||
mode: "prompt",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
const worktreeAddCall = mockedExec.mock.calls.find((call: any) =>
|
||||
String(call[0]).includes("git worktree add") && String(call[0]).includes("post-merge-FN-050-"),
|
||||
);
|
||||
expect(worktreeAddCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Merge Details Collection Tests ─────────────────────────────────────
|
||||
|
||||
|
||||
1003
packages/engine/src/__tests__/merger-prompt-and-utils.test.ts
Normal file
1003
packages/engine/src/__tests__/merger-prompt-and-utils.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
579
packages/engine/src/__tests__/merger-session-recovery.test.ts
Normal file
579
packages/engine/src/__tests__/merger-session-recovery.test.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
describeModel: vi.fn(() => "mock-provider/mock-model"),
|
||||
promptWithFallback: vi.fn(async (session, prompt, options) => {
|
||||
if (options === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await session.prompt(prompt, options);
|
||||
}
|
||||
}),
|
||||
compactSessionContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Route async `exec` through the `execSync` mock so existing tests that set up
|
||||
// mockedExecSync.mockImplementation for verification commands (vitest run,
|
||||
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
|
||||
// resolves/rejects based on the callback wired here.
|
||||
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: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
|
||||
execFn[promisify.custom] = (cmd: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
|
||||
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
|
||||
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
|
||||
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "function" ? {} : opts;
|
||||
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../rate-limit-retry.js", () => ({
|
||||
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context-limit-detector.js", () => ({
|
||||
isContextLimitError: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
aiMergeTask,
|
||||
pushToRemoteAfterMerge,
|
||||
findWorktreeUser,
|
||||
detectResolvableConflicts,
|
||||
autoResolveFile,
|
||||
resolveConflicts,
|
||||
classifyConflict,
|
||||
getConflictedFiles,
|
||||
isTrivialWhitespaceConflict,
|
||||
resolveWithOurs,
|
||||
resolveWithTheirs,
|
||||
resolveTrivialWhitespace,
|
||||
LOCKFILE_PATTERNS,
|
||||
GENERATED_PATTERNS,
|
||||
parseDiffStat,
|
||||
extractFileScope,
|
||||
validateDiffScope,
|
||||
shouldSyncDependenciesForMerge,
|
||||
summarizeVerificationOutput,
|
||||
inferDefaultTestCommand,
|
||||
resolveTaskDiffBaseRef,
|
||||
commitOrAmendMergeWithFixes,
|
||||
MergeAbortedError,
|
||||
type ConflictCategory,
|
||||
} from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
import { createFnAgent } from "../pi.js";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import * as core from "@fusion/core";
|
||||
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExec = vi.mocked(exec);
|
||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||
const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
|
||||
|
||||
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
||||
const baseTask: Task = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...taskOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
|
||||
listTasks: vi.fn().mockResolvedValue(allTasks),
|
||||
updateTask: vi.fn().mockResolvedValue(baseTask),
|
||||
moveTask: vi.fn().mockResolvedValue(baseTask),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
getVerificationCacheHit: vi.fn().mockReturnValue(null),
|
||||
recordVerificationCachePass: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up execSync to handle the standard merge flow:
|
||||
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
|
||||
* diff --cached (post-agent verify), branch -d
|
||||
*
|
||||
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
|
||||
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
|
||||
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
|
||||
*
|
||||
* For tests that want the merge to fail after 3 attempts, call
|
||||
* setupFailingFallbackStrategy() instead.
|
||||
*/
|
||||
function setupHappyPathExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
return Buffer.from("");
|
||||
}
|
||||
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "did agent commit?" → "0" = yes
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as setupHappyPathExecSync but makes the final fallback merge fail
|
||||
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
|
||||
* to throw after 3 attempts fail.
|
||||
*/
|
||||
function setupFailingFallbackStrategy() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
|
||||
err.name = "ExecSyncError";
|
||||
throw err;
|
||||
}
|
||||
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "did agent commit?" → "0" = yes
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Renamed to setupFailingFallbackStrategy. */
|
||||
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
|
||||
|
||||
|
||||
describe("aiMergeTask — fresh session and compaction recovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
function setupFreshSessionExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("--stat")) return "1 file changed";
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1";
|
||||
if (cmdStr.includes("git commit")) return Buffer.from("");
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
it("creates a fresh session for merge agent via createFnAgent", async () => {
|
||||
setupFreshSessionExecSync();
|
||||
|
||||
const sessionInstances: any[] = [];
|
||||
mockedCreateFnAgent.mockImplementation(async () => {
|
||||
const session = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
sessionInstances.push(session);
|
||||
// Use type assertion to match expected return type
|
||||
return { session } as any;
|
||||
});
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
// Session should be created once for the merge agent
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||||
expect(sessionInstances.length).toBe(1);
|
||||
});
|
||||
|
||||
it("disposes session after merge agent completes (finally block)", async () => {
|
||||
setupFreshSessionExecSync();
|
||||
|
||||
const mockDispose = vi.fn();
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: mockDispose,
|
||||
};
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession } as any);
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
// Session should be disposed via finally block
|
||||
expect(mockDispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("imports compactSessionContext and isContextLimitError from respective modules", async () => {
|
||||
// This test verifies the imports are present in merger.ts
|
||||
// The actual functionality is tested via behavior verification
|
||||
const mergerModule = await import("../merger.js");
|
||||
expect(mergerModule).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Merge Prompt Truncation Tests ─────────────────────────────────────
|
||||
|
||||
|
||||
describe("aiMergeTask — context limit recovery with truncation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
function setupContextLimitExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("--stat")) return "1 file changed";
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1";
|
||||
if (cmdStr.includes("git commit")) return Buffer.from("");
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
it("retries with minimal prompt when context limit hit after auto-compaction", async () => {
|
||||
const { isContextLimitError } = await import("../context-limit-detector.js");
|
||||
|
||||
vi.mocked(isContextLimitError).mockReturnValue(true);
|
||||
|
||||
// Track prompt calls
|
||||
const promptCalls: string[] = [];
|
||||
let firstCall = true;
|
||||
mockedCreateFnAgent.mockImplementation(async () => {
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async (prompt: string) => {
|
||||
promptCalls.push(prompt);
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
throw new Error("context window exceeds limit (2013)");
|
||||
}
|
||||
// Second call succeeds
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
return { session } as any;
|
||||
});
|
||||
|
||||
setupContextLimitExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
// Merge should succeed after truncated retry
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
// Should have made 2 prompt calls
|
||||
expect(promptCalls).toHaveLength(2);
|
||||
|
||||
// First call had original prompt, second call should have simplified prompt
|
||||
expect(promptCalls[0]).toContain("## Branch commits");
|
||||
// Second call uses simplifiedContext=true, so it should NOT contain "## Files changed"
|
||||
expect(promptCalls[1]).not.toContain("## Files changed");
|
||||
// Second call should have the minimal placeholder
|
||||
expect(promptCalls[1]).toContain("(see git log)");
|
||||
|
||||
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
|
||||
});
|
||||
|
||||
it("throws when truncated retry also fails with context limit", async () => {
|
||||
const { isContextLimitError } = await import("../context-limit-detector.js");
|
||||
|
||||
vi.mocked(isContextLimitError).mockReturnValue(true);
|
||||
|
||||
// Track prompt calls to verify both original and truncated prompts were tried
|
||||
const promptCalls: string[] = [];
|
||||
|
||||
mockedCreateFnAgent.mockImplementation(async () => {
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async (prompt: string) => {
|
||||
promptCalls.push(prompt);
|
||||
// Both calls fail with context limit error
|
||||
throw new Error("context window exceeds limit (2013)");
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
return { session } as any;
|
||||
});
|
||||
|
||||
// Setup that simulates both attempts failing (first fails, attempts 2 and 3 also fail)
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("--stat")) return "1 file changed";
|
||||
// All merge attempts fail
|
||||
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) {
|
||||
throw new Error("merge conflict");
|
||||
}
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "src/file.ts";
|
||||
// git diff-tree for trivial whitespace detection - return real changes (non-trivial)
|
||||
if (cmdStr.includes("diff-tree")) {
|
||||
const error = new Error("exit code 1") as any;
|
||||
error.stdout = "+const x = 2;\n-const x = 1;";
|
||||
throw error;
|
||||
}
|
||||
if (cmdStr.includes("reset --merge")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
smartConflictResolution: true, // Enable all 3 attempts
|
||||
});
|
||||
|
||||
// Should throw after all attempts exhausted
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow("all 3 attempts exhausted");
|
||||
|
||||
// Verify both original and truncated prompts were attempted (2 attempts for attempt 1)
|
||||
// Each merge attempt calls promptWithFallback twice (original + truncated when compaction fails)
|
||||
// With 3 merge attempts, this means we should have at least 6 prompt calls total
|
||||
expect(promptCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
|
||||
});
|
||||
|
||||
it("succeeds when prompt succeeds on retry after context error", async () => {
|
||||
const { isContextLimitError } = await import("../context-limit-detector.js");
|
||||
|
||||
vi.mocked(isContextLimitError).mockReturnValue(true);
|
||||
|
||||
// Track prompt calls
|
||||
const promptCalls: string[] = [];
|
||||
let firstCall = true;
|
||||
mockedCreateFnAgent.mockImplementation(async () => {
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async (prompt: string) => {
|
||||
promptCalls.push(prompt);
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
throw new Error("context window exceeds limit (2013)");
|
||||
}
|
||||
// Second call succeeds
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
return { session } as any;
|
||||
});
|
||||
|
||||
setupContextLimitExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
// Merge should succeed after retry
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
// Should have made 2 prompt calls
|
||||
expect(promptCalls).toHaveLength(2);
|
||||
|
||||
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
|
||||
});
|
||||
|
||||
it("does not attempt truncation retry for non-context errors", async () => {
|
||||
const { compactSessionContext } = await import("../pi.js");
|
||||
const { isContextLimitError } = await import("../context-limit-detector.js");
|
||||
|
||||
// Non-context error should not trigger recovery path
|
||||
vi.mocked(compactSessionContext).mockResolvedValue(null);
|
||||
vi.mocked(isContextLimitError).mockReturnValue(false);
|
||||
|
||||
// Mock non-context error
|
||||
mockedCreateFnAgent.mockImplementation(async () => {
|
||||
const session = {
|
||||
prompt: vi.fn().mockRejectedValue(new Error("connection refused")),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
return { session } as any;
|
||||
});
|
||||
|
||||
// Setup that simulates merge failing - make merge --squash throw so auto-resolution isn't triggered
|
||||
// Also make commit fail so all attempts exhaust
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("--stat")) return "1 file changed";
|
||||
// All merge attempts fail - make merge --squash throw with conflicts
|
||||
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) {
|
||||
const err = new Error("merge conflict");
|
||||
err.name = "ExecSyncError";
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "src/file.ts";
|
||||
if (cmdStr.includes("reset --merge")) return Buffer.from("");
|
||||
// Make commit fail so attempt 2's auto-resolution also fails
|
||||
if (cmdStr.includes("git commit")) {
|
||||
const err = new Error("commit failed");
|
||||
err.name = "ExecSyncError";
|
||||
throw err;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
smartConflictResolution: true,
|
||||
});
|
||||
|
||||
// Should throw without attempting compaction or truncation
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow("all 3 attempts exhausted");
|
||||
|
||||
// Compaction should NOT have been called for non-context errors
|
||||
expect(vi.mocked(compactSessionContext)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
668
packages/engine/src/__tests__/merger-skills.test.ts
Normal file
668
packages/engine/src/__tests__/merger-skills.test.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
describeModel: vi.fn(() => "mock-provider/mock-model"),
|
||||
promptWithFallback: vi.fn(async (session, prompt, options) => {
|
||||
if (options === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await session.prompt(prompt, options);
|
||||
}
|
||||
}),
|
||||
compactSessionContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Route async `exec` through the `execSync` mock so existing tests that set up
|
||||
// mockedExecSync.mockImplementation for verification commands (vitest run,
|
||||
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
|
||||
// resolves/rejects based on the callback wired here.
|
||||
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: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
|
||||
execFn[promisify.custom] = (cmd: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
|
||||
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
|
||||
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
|
||||
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "function" ? {} : opts;
|
||||
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../rate-limit-retry.js", () => ({
|
||||
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context-limit-detector.js", () => ({
|
||||
isContextLimitError: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
aiMergeTask,
|
||||
pushToRemoteAfterMerge,
|
||||
findWorktreeUser,
|
||||
detectResolvableConflicts,
|
||||
autoResolveFile,
|
||||
resolveConflicts,
|
||||
classifyConflict,
|
||||
getConflictedFiles,
|
||||
isTrivialWhitespaceConflict,
|
||||
resolveWithOurs,
|
||||
resolveWithTheirs,
|
||||
resolveTrivialWhitespace,
|
||||
LOCKFILE_PATTERNS,
|
||||
GENERATED_PATTERNS,
|
||||
parseDiffStat,
|
||||
extractFileScope,
|
||||
validateDiffScope,
|
||||
shouldSyncDependenciesForMerge,
|
||||
summarizeVerificationOutput,
|
||||
inferDefaultTestCommand,
|
||||
resolveTaskDiffBaseRef,
|
||||
commitOrAmendMergeWithFixes,
|
||||
MergeAbortedError,
|
||||
type ConflictCategory,
|
||||
} from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
import { createFnAgent } from "../pi.js";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import * as core from "@fusion/core";
|
||||
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExec = vi.mocked(exec);
|
||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||
const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
|
||||
|
||||
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
||||
const baseTask: Task = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...taskOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
|
||||
listTasks: vi.fn().mockResolvedValue(allTasks),
|
||||
updateTask: vi.fn().mockResolvedValue(baseTask),
|
||||
moveTask: vi.fn().mockResolvedValue(baseTask),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
getVerificationCacheHit: vi.fn().mockReturnValue(null),
|
||||
recordVerificationCachePass: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up execSync to handle the standard merge flow:
|
||||
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
|
||||
* diff --cached (post-agent verify), branch -d
|
||||
*
|
||||
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
|
||||
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
|
||||
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
|
||||
*
|
||||
* For tests that want the merge to fail after 3 attempts, call
|
||||
* setupFailingFallbackStrategy() instead.
|
||||
*/
|
||||
function setupHappyPathExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
return Buffer.from("");
|
||||
}
|
||||
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "did agent commit?" → "0" = yes
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as setupHappyPathExecSync but makes the final fallback merge fail
|
||||
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
|
||||
* to throw after 3 attempts fail.
|
||||
*/
|
||||
function setupFailingFallbackStrategy() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
|
||||
err.name = "ExecSyncError";
|
||||
throw err;
|
||||
}
|
||||
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "did agent commit?" → "0" = yes
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Renamed to setupFailingFallbackStrategy. */
|
||||
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
|
||||
|
||||
|
||||
describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)", () => {
|
||||
// Mock session-skill-context to control skill selection behavior
|
||||
vi.mock("../session-skill-context.js", () => ({
|
||||
buildSessionSkillContext: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes skillSelection to createFnAgent when agentStore is provided", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["fusion"],
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: ["fusion"],
|
||||
skillSource: "role-fallback",
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
setupHappyPathExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
const mockAgentStore = {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050", {
|
||||
agentStore: mockAgentStore as any,
|
||||
});
|
||||
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||
// Find the first createFnAgent call (main merger agent)
|
||||
const firstCall = mockedCreateFnAgent.mock.calls[0];
|
||||
const opts = firstCall[0];
|
||||
expect(opts.skillSelection).toBeDefined();
|
||||
expect(opts.skillSelection!.projectRootDir).toBe("/tmp/root");
|
||||
expect(opts.skillSelection!.requestedSkillNames).toEqual(["fusion"]);
|
||||
expect(opts.skillSelection!.sessionPurpose).toBe("merger");
|
||||
});
|
||||
|
||||
it("uses assigned agent skills when available", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["custom-skill", "another-skill"],
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: ["custom-skill", "another-skill"],
|
||||
skillSource: "assigned-agent",
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
setupHappyPathExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
const mockAgentStore = {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050", {
|
||||
agentStore: mockAgentStore as any,
|
||||
});
|
||||
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||
const firstCall = mockedCreateFnAgent.mock.calls[0];
|
||||
const opts = firstCall[0];
|
||||
expect(opts.skillSelection).toBeDefined();
|
||||
expect(opts.skillSelection!.requestedSkillNames).toEqual(["custom-skill", "another-skill"]);
|
||||
});
|
||||
|
||||
it("does not pass skillSelection when buildSessionSkillContext returns undefined context", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: undefined,
|
||||
resolvedSkillNames: [],
|
||||
skillSource: "none",
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
setupHappyPathExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
const mockAgentStore = {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050", {
|
||||
agentStore: mockAgentStore as any,
|
||||
});
|
||||
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||
const firstCall = mockedCreateFnAgent.mock.calls[0];
|
||||
const opts = firstCall[0];
|
||||
// skillSelection should not be present when context is undefined
|
||||
expect("skillSelection" in opts).toBe(false);
|
||||
});
|
||||
|
||||
it("does not pass skillSelection when agentStore is not provided", async () => {
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
setupHappyPathExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
// No agentStore provided
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||
const firstCall = mockedCreateFnAgent.mock.calls[0];
|
||||
const opts = firstCall[0];
|
||||
expect("skillSelection" in opts).toBe(false);
|
||||
});
|
||||
|
||||
it("gracefully handles buildSessionSkillContext throwing", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockRejectedValue(new Error("Agent not found"));
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
setupHappyPathExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
const mockAgentStore = {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
// Should not throw - graceful fallback
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
|
||||
agentStore: mockAgentStore as any,
|
||||
});
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||
const firstCall = mockedCreateFnAgent.mock.calls[0];
|
||||
const opts = firstCall[0];
|
||||
expect("skillSelection" in opts).toBe(false);
|
||||
});
|
||||
|
||||
it("records resolved skill names in skill context result", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
const resolvedNames = ["skill-a", "skill-b"];
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: resolvedNames,
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: resolvedNames,
|
||||
skillSource: "assigned-agent",
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
setupHappyPathExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
const mockAgentStore = {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050", {
|
||||
agentStore: mockAgentStore as any,
|
||||
});
|
||||
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||
const firstCall = mockedCreateFnAgent.mock.calls[0];
|
||||
const opts = firstCall[0];
|
||||
expect(opts.skillSelection?.requestedSkillNames).toEqual(resolvedNames);
|
||||
});
|
||||
|
||||
it("uses sessionPurpose='merger' in skill selection context", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["fusion"],
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: ["fusion"],
|
||||
skillSource: "role-fallback",
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
setupHappyPathExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
const mockAgentStore = {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050", {
|
||||
agentStore: mockAgentStore as any,
|
||||
});
|
||||
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||
const firstCall = mockedCreateFnAgent.mock.calls[0];
|
||||
const opts = firstCall[0];
|
||||
expect(opts.skillSelection?.sessionPurpose).toBe("merger");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511)", () => {
|
||||
// Mock session-skill-context to control skill selection behavior
|
||||
vi.mock("../session-skill-context.js", () => ({
|
||||
buildSessionSkillContext: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("merge continues when skill selection produces diagnostics", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
// Simulate diagnostics being logged - the resolver would produce these
|
||||
// when requested skills are not found or filtered
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["nonexistent-skill"],
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: [],
|
||||
skillSource: "none",
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
setupHappyPathExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
const mockAgentStore = {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
// Merge should succeed even when skill diagnostics are present
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
|
||||
agentStore: mockAgentStore as any,
|
||||
});
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
});
|
||||
|
||||
it("records skill source in context result for debugging", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["custom-skill"],
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: ["custom-skill"],
|
||||
skillSource: "assigned-agent",
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
setupHappyPathExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
const mockAgentStore = {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
|
||||
agentStore: mockAgentStore as any,
|
||||
});
|
||||
|
||||
// Result should be successful regardless of skill source
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
// Verify skillSelection was passed with the custom skill
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||
const firstCall = mockedCreateFnAgent.mock.calls[0];
|
||||
const opts = firstCall[0];
|
||||
expect(opts.skillSelection?.requestedSkillNames).toEqual(["custom-skill"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
266
packages/engine/src/__tests__/merger-test-helpers.ts
Normal file
266
packages/engine/src/__tests__/merger-test-helpers.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
describeModel: vi.fn(() => "mock-provider/mock-model"),
|
||||
promptWithFallback: vi.fn(async (session, prompt, options) => {
|
||||
if (options === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await session.prompt(prompt, options);
|
||||
}
|
||||
}),
|
||||
compactSessionContext: 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: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
execFn[promisify.custom] = (cmd: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "function" ? {} : opts;
|
||||
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../rate-limit-retry.js", () => ({
|
||||
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context-limit-detector.js", () => ({
|
||||
isContextLimitError: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
aiMergeTask,
|
||||
pushToRemoteAfterMerge,
|
||||
findWorktreeUser,
|
||||
detectResolvableConflicts,
|
||||
autoResolveFile,
|
||||
resolveConflicts,
|
||||
classifyConflict,
|
||||
getConflictedFiles,
|
||||
isTrivialWhitespaceConflict,
|
||||
resolveWithOurs,
|
||||
resolveWithTheirs,
|
||||
resolveTrivialWhitespace,
|
||||
LOCKFILE_PATTERNS,
|
||||
GENERATED_PATTERNS,
|
||||
parseDiffStat,
|
||||
extractFileScope,
|
||||
validateDiffScope,
|
||||
shouldSyncDependenciesForMerge,
|
||||
summarizeVerificationOutput,
|
||||
inferDefaultTestCommand,
|
||||
resolveTaskDiffBaseRef,
|
||||
commitOrAmendMergeWithFixes,
|
||||
MergeAbortedError,
|
||||
buildSourceIssueRef,
|
||||
buildMergePrompt,
|
||||
type ConflictCategory,
|
||||
} from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
import { createFnAgent } from "../pi.js";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import * as core from "@fusion/core";
|
||||
import { type TaskStore, type Task, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
export {
|
||||
aiMergeTask,
|
||||
pushToRemoteAfterMerge,
|
||||
findWorktreeUser,
|
||||
detectResolvableConflicts,
|
||||
autoResolveFile,
|
||||
resolveConflicts,
|
||||
classifyConflict,
|
||||
getConflictedFiles,
|
||||
isTrivialWhitespaceConflict,
|
||||
resolveWithOurs,
|
||||
resolveWithTheirs,
|
||||
resolveTrivialWhitespace,
|
||||
LOCKFILE_PATTERNS,
|
||||
GENERATED_PATTERNS,
|
||||
parseDiffStat,
|
||||
extractFileScope,
|
||||
validateDiffScope,
|
||||
shouldSyncDependenciesForMerge,
|
||||
summarizeVerificationOutput,
|
||||
inferDefaultTestCommand,
|
||||
resolveTaskDiffBaseRef,
|
||||
commitOrAmendMergeWithFixes,
|
||||
MergeAbortedError,
|
||||
buildSourceIssueRef,
|
||||
buildMergePrompt,
|
||||
mergerLog,
|
||||
core,
|
||||
};
|
||||
export type { ConflictCategory, Task };
|
||||
|
||||
export const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
export const mockedExecSync = vi.mocked(execSync);
|
||||
export const mockedExec = vi.mocked(exec);
|
||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
||||
export const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||
export const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
|
||||
|
||||
export function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
||||
const baseTask: Task = {
|
||||
id: "FN-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/KB-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...taskOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
|
||||
listTasks: vi.fn().mockResolvedValue(allTasks),
|
||||
updateTask: vi.fn().mockResolvedValue(baseTask),
|
||||
moveTask: vi.fn().mockResolvedValue(baseTask),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
getVerificationCacheHit: vi.fn().mockReturnValue(null),
|
||||
recordVerificationCachePass: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
export function setupHappyPathExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
return Buffer.from("");
|
||||
}
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
export function setupFailingFallbackStrategy() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
|
||||
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
|
||||
err.name = "ExecSyncError";
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
export const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
|
||||
2624
packages/engine/src/__tests__/merger-verification.test.ts
Normal file
2624
packages/engine/src/__tests__/merger-verification.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user