import { describe, it, expect, beforeEach, afterEach } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; import { execFileSync } from "node:child_process"; import { detectVcs, NoneProvider, } from "./vcs-provider.js"; import { resolveVcsSelection, startWorkflow, reviewWorkflow } from "./workflow-engine.js"; import { findRunDir, readState, releaseLock } from "./workflow-state.js"; import type { WorkflowPlanV1 } from "./workflow-types.js"; function makeTmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-none-test-")); } function cleanupDir(dir: string): void { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { // ignore } } function makeValidPlan(scope?: string[]): WorkflowPlanV1 { return { version: "1", title: "Test plan", planMarkdown: "Do something.", scope: scope ?? ["src/"], acceptanceCriteria: ["Tests pass"], verificationCommands: [["echo", "test"]], }; } describe("None VCS Provider (Snapshot Mode)", () => { let tmpDir: string; let runDir: string; let stateRoot: string; let fakeBinDir: string; let originalWorkflowDir: string | undefined; beforeEach(() => { tmpDir = makeTmpDir(); runDir = makeTmpDir(); stateRoot = makeTmpDir(); // Keep stateRoot completely outside target tmpDir fakeBinDir = path.join(tmpDir, "bin"); fs.mkdirSync(fakeBinDir, { recursive: true }); const fakeBin = path.join(fakeBinDir, "reasonix"); fs.writeFileSync( fakeBin, [`#!/bin/bash`, `echo '{"session_id":"test-session"}'`, `exit 0`].join("\n"), { mode: 0o755 } ); originalWorkflowDir = process.env.AGENT_WORKFLOW_DIR; process.env.AGENT_WORKFLOW_DIR = stateRoot; }); afterEach(() => { if (originalWorkflowDir === undefined) { delete process.env.AGENT_WORKFLOW_DIR; } else { process.env.AGENT_WORKFLOW_DIR = originalWorkflowDir; } cleanupDir(tmpDir); cleanupDir(runDir); cleanupDir(stateRoot); }); it("automatically selects none in a non-VCS directory", async () => { expect(detectVcs(tmpDir)).toBe("none"); const selection = await resolveVcsSelection(tmpDir); expect(selection.vcsKind).toBe("none"); expect(selection.repoRoot).toBe(tmpDir); }); it("fails on explicit --vcs git or svn in a non-VCS directory, but --vcs none works", async () => { await expect(resolveVcsSelection(tmpDir, "git")).rejects.toThrow(); await expect(resolveVcsSelection(tmpDir, "svn")).rejects.toThrow(); const selection = await resolveVcsSelection(tmpDir, "none"); expect(selection.vcsKind).toBe("none"); expect(selection.repoRoot).toBe(tmpDir); }); it("honors defaultVcs: 'none' project configuration", async () => { const configPath = path.join(tmpDir, "agent-workflow.json"); fs.writeFileSync(configPath, JSON.stringify({ version: "1", defaultVcs: "none" }), "utf8"); const selection = await resolveVcsSelection(tmpDir); expect(selection.vcsKind).toBe("none"); }); it("honors defaultVcs: 'git' or 'svn' but fails in non-VCS directories", async () => { const configPath = path.join(tmpDir, "agent-workflow.json"); fs.writeFileSync(configPath, JSON.stringify({ version: "1", defaultVcs: "git" }), "utf8"); await expect(resolveVcsSelection(tmpDir)).rejects.toThrow(/Configured VCS is "git"/); }); it("records snapshot files and detects additions, modifications, and deletions including empty, unicode, special-character, and binary files", () => { const provider = new NoneProvider(); // Setup initial files fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, "src", "text.txt"), "hello world\n", "utf8"); fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "", "utf8"); // Create binary file const binaryData = Buffer.from([1, 2, 0, 4, 5]); fs.writeFileSync(path.join(tmpDir, "src", "binary.bin"), binaryData); // Unicode path fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test\n", "utf8"); // Special characters path fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters\n", "utf8"); // Capture baseline const baseline = provider.captureBaseline(tmpDir, runDir); expect(baseline.kind).toBe("none"); // Check no differences initially let diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toBe(""); // 1. Text modification/addition/deletion fs.writeFileSync(path.join(tmpDir, "src", "text.txt"), "hello world modified\n", "utf8"); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/text.txt b/src/text.txt"); expect(diff).toContain("-hello world"); expect(diff).toContain("+hello world modified"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/text.txt"); // Revert text.txt fs.writeFileSync(path.join(tmpDir, "src", "text.txt"), "hello world\n", "utf8"); // Add new text file fs.writeFileSync(path.join(tmpDir, "src", "added.txt"), "new content\n", "utf8"); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/added.txt b/src/added.txt"); expect(diff).toContain("+new content"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/added.txt"); // Delete it fs.unlinkSync(path.join(tmpDir, "src", "added.txt")); // 2. Empty file modification/addition/deletion // Add new empty file fs.writeFileSync(path.join(tmpDir, "src", "new-empty.txt"), "", "utf8"); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/new-empty.txt b/src/new-empty.txt"); expect(diff).toContain("new file mode 100644"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/new-empty.txt"); fs.unlinkSync(path.join(tmpDir, "src", "new-empty.txt")); // Modify existing empty.txt fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "no longer empty\n", "utf8"); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/empty.txt b/src/empty.txt"); expect(diff).toContain("+no longer empty"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/empty.txt"); fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "", "utf8"); // restore // Delete empty.txt fs.unlinkSync(path.join(tmpDir, "src", "empty.txt")); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/empty.txt b/src/empty.txt"); expect(diff).toContain("deleted file mode 100644"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/empty.txt"); fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "", "utf8"); // restore // 3. Unicode path modification/addition/deletion // Add new Unicode path fs.writeFileSync(path.join(tmpDir, "src", "新建文件.txt"), "unicode content\n", "utf8"); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/新建文件.txt b/src/新建文件.txt"); expect(diff).toContain("+unicode content"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/新建文件.txt"); fs.unlinkSync(path.join(tmpDir, "src", "新建文件.txt")); // Modify existing 中文.txt fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test modified\n", "utf8"); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/中文.txt b/src/中文.txt"); expect(diff).toContain("+unicode test modified"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/中文.txt"); fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test\n", "utf8"); // restore // Delete 中文.txt fs.unlinkSync(path.join(tmpDir, "src", "中文.txt")); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/中文.txt b/src/中文.txt"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/中文.txt"); fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test\n", "utf8"); // restore // 4. Special characters path modification/addition/deletion // Add new special char path fs.writeFileSync(path.join(tmpDir, "src", "new special#char$path.txt"), "special content\n", "utf8"); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/new special#char$path.txt b/src/new special#char$path.txt"); expect(diff).toContain("+special content"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/new special#char$path.txt"); fs.unlinkSync(path.join(tmpDir, "src", "new special#char$path.txt")); // Modify special space & char.txt fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters modified\n", "utf8"); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/special space & char.txt b/src/special space & char.txt"); expect(diff).toContain("+special characters modified"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/special space & char.txt"); fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters\n", "utf8"); // restore // Delete special space & char.txt fs.unlinkSync(path.join(tmpDir, "src", "special space & char.txt")); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/special space & char.txt b/src/special space & char.txt"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/special space & char.txt"); fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters\n", "utf8"); // restore // 5. Binary file modification/deletion fs.writeFileSync(path.join(tmpDir, "src", "binary.bin"), Buffer.from([1, 2, 0, 4, 6])); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/binary.bin b/src/binary.bin"); expect(diff).toContain("Binary files a/src/binary.bin and b/src/binary.bin differ"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/binary.bin"); fs.unlinkSync(path.join(tmpDir, "src", "binary.bin")); diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/src/binary.bin b/src/binary.bin"); expect(diff).toContain("Binary files a/src/binary.bin and /dev/null differ"); expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/binary.bin"); }); it("respects ignore patterns from .gitignore and .agent-workflowignore", () => { const provider = new NoneProvider(); fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); fs.mkdirSync(path.join(tmpDir, "ignored-dir"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content", "utf8"); fs.writeFileSync(path.join(tmpDir, "ignored-dir", "b.txt"), "content", "utf8"); // Add gitignore ignoring ignored-dir/ fs.writeFileSync(path.join(tmpDir, ".gitignore"), "ignored-dir/\n*.log\n", "utf8"); fs.writeFileSync(path.join(tmpDir, "src", "test.log"), "log content", "utf8"); // Add agent-workflowignore with negative rule fs.writeFileSync(path.join(tmpDir, ".agent-workflowignore"), "!test.log\n", "utf8"); // Let's capture baseline const baseline = provider.captureBaseline(tmpDir, runDir); const snapshot = JSON.parse(fs.readFileSync(path.join(runDir, "baseline-snapshot.json"), "utf8")); const files = Object.keys(snapshot.files); // .git and .svn are always excluded expect(files).not.toContain(".git"); expect(files).not.toContain(".svn"); // ignored-dir/ is ignored by .gitignore expect(files).not.toContain("ignored-dir/b.txt"); // test.log is ignored by *.log in .gitignore but negated by !test.log in .agent-workflowignore expect(files).toContain("src/test.log"); }); it("does not follow symbolic links, only records link target", () => { const provider = new NoneProvider(); fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, "src", "target.txt"), "target content\n", "utf8"); // Create a symlink to target.txt try { fs.symlinkSync("target.txt", path.join(tmpDir, "src", "link.txt")); } catch { // Symlink support might require admin privileges on some systems (e.g. Windows), // but on Linux sandbox it works. } if (fs.existsSync(path.join(tmpDir, "src", "link.txt"))) { const baseline = provider.captureBaseline(tmpDir, runDir); const snapshot = JSON.parse(fs.readFileSync(path.join(runDir, "baseline-snapshot.json"), "utf8")); expect(snapshot.files["src/link.txt"]).toBeDefined(); expect(snapshot.files["src/link.txt"].type).toBe("symlink"); expect(snapshot.files["src/link.txt"].target).toBe("target.txt"); } }); it("fails drift check/validation if snapshot is missing or corrupted", () => { const provider = new NoneProvider(); fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8"); const baseline = provider.captureBaseline(tmpDir, runDir); // Works originally expect(() => provider.validateBaseline(tmpDir, baseline, runDir)).not.toThrow(); // Missing snapshot file fs.unlinkSync(path.join(runDir, "baseline-snapshot.json")); expect(() => provider.validateBaseline(tmpDir, baseline, runDir)).toThrow(/Baseline snapshot file is missing/); // Corrupted snapshot file (testing invalid JSON: we must update hash to match corrupted content so hash check passes but parse fails) const corruptedContent = "{invalid-json}"; const corruptedHash = crypto.createHash("sha256").update(Buffer.from(corruptedContent, "utf8")).digest("hex"); fs.writeFileSync(path.join(runDir, "baseline-snapshot.json"), corruptedContent, "utf8"); const corruptedBaseline = { ...baseline, snapshotHash: corruptedHash }; expect(() => provider.validateBaseline(tmpDir, corruptedBaseline, runDir)).toThrow(/Baseline snapshot is not valid JSON/); }); it("fails validation/drift check if snapshot is tampered with using different valid-JSON", () => { const provider = new NoneProvider(); fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8"); const baseline = provider.captureBaseline(tmpDir, runDir); // Tamper with snapshot by writing a different valid JSON like {files:{}} const snapshotPath = path.join(runDir, "baseline-snapshot.json"); fs.writeFileSync(snapshotPath, JSON.stringify({ files: {} }), "utf8"); // Must fail due to hash mismatch expect(() => provider.validateBaseline(tmpDir, baseline, runDir)).toThrow(/Hash mismatch/); }); it("fails validation/drift check if snapshotFile traverses outside runDir", () => { const provider = new NoneProvider(); fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8"); const baseline = provider.captureBaseline(tmpDir, runDir); // Attempt path traversal using relative parent directory const traversalBaseline = { ...baseline, snapshotFile: "../outside.json" }; expect(() => provider.validateBaseline(tmpDir, traversalBaseline, runDir)).toThrow(/Access denied/); // Attempt path traversal using absolute path const absoluteBaseline = { ...baseline, snapshotFile: "/etc/passwd" }; expect(() => provider.validateBaseline(tmpDir, absoluteBaseline, runDir)).toThrow(/Access denied/); }); it("proves mutable ignore files cannot hide scope violations after baseline capture", () => { const provider = new NoneProvider(); fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8"); // Capture baseline with no ignores initially const baseline = provider.captureBaseline(tmpDir, runDir); // Create a file outside the scope and then write a gitignore ignoring it fs.writeFileSync(path.join(tmpDir, "secret.txt"), "secret\n", "utf8"); fs.writeFileSync(path.join(tmpDir, ".gitignore"), "secret.txt\n", "utf8"); // The scan must reuse the frozen ignore policy (no ignores), // so secret.txt must still be detected as changed/out-of-scope! const changed = provider.checkFilesInScope(tmpDir, baseline, ["src/"], runDir); expect(changed).toContain("secret.txt"); const diff = provider.diffFromBaseline(tmpDir, baseline, runDir); expect(diff).toContain("diff --git a/secret.txt b/secret.txt"); }); it("proves admin negations stay unconditionally excluded", () => { const provider = new NoneProvider(); // Create admin directories and files fs.mkdirSync(path.join(tmpDir, ".git"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, ".git", "config"), "some config\n", "utf8"); fs.mkdirSync(path.join(tmpDir, ".svn"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, ".svn", "entries"), "some entries\n", "utf8"); // Write negations fs.writeFileSync(path.join(tmpDir, ".gitignore"), "!.git\n!.git/**\n!.svn\n!.svn/**\n", "utf8"); provider.captureBaseline(tmpDir, runDir); const snapshot = JSON.parse(fs.readFileSync(path.join(runDir, "baseline-snapshot.json"), "utf8")); const files = Object.keys(snapshot.files); expect(files).not.toContain(".git/config"); expect(files).not.toContain(".git"); expect(files).not.toContain(".svn/entries"); expect(files).not.toContain(".svn"); }); it("proves CLI help exposes none under --vcs", async () => { const { WORKFLOW_USAGE } = await import("./workflow-args.js"); expect(WORKFLOW_USAGE).toContain("--vcs "); }); it("rejects starting a workflow if AGENT_WORKFLOW_DIR is equal to or nested under repoRoot", async () => { const nestedStateRoot = path.join(tmpDir, "nested-workflows"); process.env.AGENT_WORKFLOW_DIR = nestedStateRoot; const planFile = path.join(tmpDir, "plan.json"); const plan = makeValidPlan(["src/"]); fs.writeFileSync(planFile, JSON.stringify(plan), "utf8"); await expect(startWorkflow({ planInput: planFile, executor: "reasonix", cwd: tmpDir, })).rejects.toThrow(/cannot be equal to or nested under the repository root/); }); it("preserves project configuration from parent root with defaultVcs:none while keeping repoRoot as cwd", async () => { const parentDir = path.join(tmpDir, "parent"); const subDir = path.join(parentDir, "sub"); fs.mkdirSync(subDir, { recursive: true }); // Initialize parentDir as a Git repository root so findVcsCandidates resolves it execFileSync("git", ["init"], { cwd: parentDir, stdio: "ignore" }); // Write config at parent const configPath = path.join(parentDir, "agent-workflow.json"); fs.writeFileSync( configPath, JSON.stringify({ version: "1", defaultVcs: "none", maxCycles: 9, timeoutSeconds: 99, defaultExecutor: "agy", executors: { agy: { binary: path.join(fakeBinDir, "reasonix") } } }), "utf8" ); // Write plan in subDir const planFile = path.join(subDir, "plan.json"); const plan = makeValidPlan(["src/"]); fs.writeFileSync(planFile, JSON.stringify(plan), "utf8"); // Start workflow from subDir const { runId } = await startWorkflow({ planInput: planFile, cwd: subDir, }); const wDir = findRunDir(runId, subDir); expect(wDir).toBeDefined(); const state = readState(wDir!); expect(state).toBeDefined(); expect(state!.vcsKind).toBe("none"); expect(state!.repoRoot).toBe(subDir); // remains invocation cwd expect(state!.maxCycles).toBe(9); // preserved from parent config expect(state!.timeoutSeconds).toBe(99); // preserved from parent config expect(state!.executor).toBe("agy"); // preserved from parent config releaseLock(subDir, runId); // Assert that CLI overrides still take precedence const { runId: runId2 } = await startWorkflow({ planInput: planFile, cwd: subDir, maxCycles: 3, }); const wDir2 = findRunDir(runId2, subDir); const state2 = readState(wDir2!); expect(state2!.maxCycles).toBe(3); // CLI override takes precedence! releaseLock(subDir, runId2); }); it("runs a full none-mode workflow: start, modify in-scope, review, detect out-of-scope changes, and block review", async () => { const fakeBin = path.join(fakeBinDir, "reasonix"); // Setup working files fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, "src", "code.txt"), "console.log(1)\n", "utf8"); // Setup project config const configPath = path.join(tmpDir, "agent-workflow.json"); fs.writeFileSync( configPath, JSON.stringify({ version: "1", defaultVcs: "none", executors: { reasonix: { binary: fakeBin, }, }, }), "utf8" ); // Setup plan const planFile = path.join(tmpDir, "plan.json"); const plan = makeValidPlan(["src/"]); fs.writeFileSync(planFile, JSON.stringify(plan), "utf8"); // Start workflow const { runId } = await startWorkflow({ planInput: planFile, executor: "reasonix", cwd: tmpDir, }); const wDir = findRunDir(runId, tmpDir); expect(wDir).toBeDefined(); const state = readState(wDir!); expect(state).toBeDefined(); expect(state!.vcsKind).toBe("none"); expect(state!.vcsBaseline?.kind).toBe("none"); expect(state!.status).toBe("awaiting_review"); // Assert startWorkflow naturally reaches awaiting_review // Modify a file in scope fs.writeFileSync(path.join(tmpDir, "src", "code.txt"), "console.log(2)\n", "utf8"); // reviewWorkflow must succeed (generates diff, validates scope) await reviewWorkflow({ runId, cwd: tmpDir }); const updatedState = readState(wDir!); expect(updatedState!.status).toBe("awaiting_host"); // Now modify a file out of scope fs.writeFileSync(path.join(tmpDir, "outside.txt"), "hack\n", "utf8"); // Reset status back to awaiting_review updatedState!.status = "awaiting_review"; fs.writeFileSync(path.join(wDir!, "state.json"), JSON.stringify(updatedState, null, 2), "utf8"); // reviewWorkflow must transition run to awaiting_scope_resolution due to out-of-scope files await reviewWorkflow({ runId, cwd: tmpDir }); const blockedState = readState(wDir!); expect(blockedState!.status).toBe("awaiting_scope_resolution"); // Clean up locks releaseLock(tmpDir, runId); }); });