|
| 1 | +/** |
| 2 | + * Tests for `agentsync ls` — vault namespace + artifact discovery. Real git |
| 3 | + * fixtures, no mocking. Seeds two machine namespaces so the cross-machine |
| 4 | + * browse path (the point of the command) is exercised. |
| 5 | + */ |
| 6 | +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; |
| 7 | +import { mkdirSync, writeFileSync } from "node:fs"; |
| 8 | +import { rm } from "node:fs/promises"; |
| 9 | +import { createRequire } from "node:module"; |
| 10 | +import { dirname, join } from "node:path"; |
| 11 | +import { machineVaultRoot } from "../../config/paths"; |
| 12 | +import { |
| 13 | + createBareRepo, |
| 14 | + createMachineFixture, |
| 15 | + createTmpDir, |
| 16 | + runGit, |
| 17 | + seedVaultRepo, |
| 18 | + type TestMachineFixture, |
| 19 | +} from "../../test-helpers/fixtures"; |
| 20 | + |
| 21 | +{ |
| 22 | + const require = createRequire(import.meta.url); |
| 23 | + // biome-ignore lint/style/useNodejsImportProtocol: deliberate alias to bypass mock cache |
| 24 | + const realFsPromises = require("fs/promises") as typeof import("node:fs/promises"); |
| 25 | + mock.module("node:fs/promises", () => ({ ...realFsPromises, default: realFsPromises })); |
| 26 | +} |
| 27 | + |
| 28 | +mock.module("@clack/prompts", () => ({ |
| 29 | + intro: () => {}, |
| 30 | + outro: () => {}, |
| 31 | + log: { success: () => {}, info: () => {}, warn: () => {}, error: () => {} }, |
| 32 | + note: () => {}, |
| 33 | + spinner: () => ({ start: () => {}, stop: () => {}, message: () => {} }), |
| 34 | +})); |
| 35 | + |
| 36 | +type LsMod = typeof import("../ls"); |
| 37 | +let lsMod: LsMod; |
| 38 | + |
| 39 | +const RUNTIME_ENV_KEYS = ["AGENTSYNC_VAULT_DIR", "AGENTSYNC_KEY_PATH", "AGENTSYNC_MACHINE"]; |
| 40 | + |
| 41 | +/** Write a placeholder .age file under a machine namespace (content need not decrypt). */ |
| 42 | +function seedArtifact(machine: TestMachineFixture, name: string, relPath: string): void { |
| 43 | + const target = join(machineVaultRoot(machine.vaultDir, name), relPath); |
| 44 | + mkdirSync(dirname(target), { recursive: true }); |
| 45 | + writeFileSync(target, "ciphertext-placeholder", "utf8"); |
| 46 | +} |
| 47 | + |
| 48 | +describe("ls command", () => { |
| 49 | + let tmpDir: string; |
| 50 | + let machine: TestMachineFixture; |
| 51 | + let bare: string; |
| 52 | + const savedEnv: Record<string, string | undefined> = {}; |
| 53 | + |
| 54 | + beforeEach(async () => { |
| 55 | + lsMod = await import("../ls"); |
| 56 | + tmpDir = await createTmpDir(); |
| 57 | + machine = await createMachineFixture(tmpDir, "config-test"); |
| 58 | + bare = await createBareRepo(tmpDir); |
| 59 | + seedVaultRepo({ machine, bareRepoPath: bare }); |
| 60 | + |
| 61 | + // Two machine namespaces: this machine (config-test) and a remote one. |
| 62 | + seedArtifact(machine, "config-test", join("codex", "AGENTS.md.age")); |
| 63 | + seedArtifact(machine, "work-laptop", join("claude", "CLAUDE.md.age")); |
| 64 | + seedArtifact(machine, "work-laptop", join("claude", "skills", "foo.tar.age")); |
| 65 | + runGit(["add", "."], machine.vaultDir); |
| 66 | + runGit(["commit", "-m", "seed namespaces"], machine.vaultDir); |
| 67 | + runGit(["push", "origin", "main"], machine.vaultDir); |
| 68 | + |
| 69 | + for (const key of RUNTIME_ENV_KEYS) savedEnv[key] = process.env[key]; |
| 70 | + process.env.AGENTSYNC_VAULT_DIR = machine.vaultDir; |
| 71 | + process.env.AGENTSYNC_KEY_PATH = machine.keyPath; |
| 72 | + process.env.AGENTSYNC_MACHINE = machine.machineName; |
| 73 | + process.exitCode = 0; |
| 74 | + }); |
| 75 | + |
| 76 | + afterEach(async () => { |
| 77 | + for (const key of RUNTIME_ENV_KEYS) { |
| 78 | + const value = savedEnv[key]; |
| 79 | + if (value === undefined) delete process.env[key]; |
| 80 | + else process.env[key] = value; |
| 81 | + } |
| 82 | + await rm(tmpDir, { recursive: true, force: true }); |
| 83 | + }); |
| 84 | + |
| 85 | + test("no machine lists every namespace", async () => { |
| 86 | + const result = await lsMod.performLs({}); |
| 87 | + expect(result.kind).toBe("machines"); |
| 88 | + if (result.kind === "machines") { |
| 89 | + expect(result.machines).toEqual(["config-test", "work-laptop"]); |
| 90 | + } |
| 91 | + }); |
| 92 | + |
| 93 | + test("a machine lists its copyable artifact paths", async () => { |
| 94 | + const result = await lsMod.performLs({ machine: "work-laptop" }); |
| 95 | + expect(result.kind).toBe("artifacts"); |
| 96 | + if (result.kind === "artifacts") { |
| 97 | + expect(result.paths).toEqual(["claude/CLAUDE.md.age", "claude/skills/foo.tar.age"]); |
| 98 | + } |
| 99 | + }); |
| 100 | + |
| 101 | + test("a path prefix narrows the listing", async () => { |
| 102 | + const result = await lsMod.performLs({ machine: "work-laptop", path: "claude/skills" }); |
| 103 | + expect(result.kind).toBe("artifacts"); |
| 104 | + if (result.kind === "artifacts") { |
| 105 | + expect(result.paths).toEqual(["claude/skills/foo.tar.age"]); |
| 106 | + } |
| 107 | + }); |
| 108 | + |
| 109 | + test("self resolves to this machine's namespace", async () => { |
| 110 | + const result = await lsMod.performLs({ machine: "self" }); |
| 111 | + expect(result.kind).toBe("artifacts"); |
| 112 | + if (result.kind === "artifacts") { |
| 113 | + expect(result.paths).toEqual(["codex/AGENTS.md.age"]); |
| 114 | + } |
| 115 | + }); |
| 116 | + |
| 117 | + test("an unknown machine lists the available ones", async () => { |
| 118 | + const result = await lsMod.performLs({ machine: "ghost" }); |
| 119 | + expect(result.kind).toBe("unknown-machine"); |
| 120 | + if (result.kind === "unknown-machine") { |
| 121 | + expect(result.available).toContain("work-laptop"); |
| 122 | + } |
| 123 | + }); |
| 124 | + |
| 125 | + test("an empty path reports empty", async () => { |
| 126 | + const result = await lsMod.performLs({ machine: "work-laptop", path: "cursor" }); |
| 127 | + expect(result.kind).toBe("empty"); |
| 128 | + }); |
| 129 | + |
| 130 | + test("a traversal path cannot escape the machine namespace", async () => { |
| 131 | + // `..` segments must not enumerate .age files outside the namespace. |
| 132 | + const result = await lsMod.performLs({ machine: "work-laptop", path: "../../../../etc" }); |
| 133 | + expect(result.kind).toBe("empty"); |
| 134 | + }); |
| 135 | + |
| 136 | + test("reports reconcile-error when vault history has diverged", async () => { |
| 137 | + // Advance the remote independently from a second clone. |
| 138 | + const otherClone = join(tmpDir, "other-clone"); |
| 139 | + runGit(["clone", bare, otherClone]); |
| 140 | + runGit(["config", "user.email", "t@t.local"], otherClone); |
| 141 | + runGit(["config", "user.name", "t"], otherClone); |
| 142 | + writeFileSync(join(otherClone, "remote-extra.txt"), "remote\n", "utf8"); |
| 143 | + runGit(["add", "."], otherClone); |
| 144 | + runGit(["commit", "-m", "remote advance"], otherClone); |
| 145 | + runGit(["push", "origin", "main"], otherClone); |
| 146 | + |
| 147 | + // Advance the local vault on a divergent commit. |
| 148 | + writeFileSync(join(machine.vaultDir, "local-extra.txt"), "local\n", "utf8"); |
| 149 | + runGit(["add", "."], machine.vaultDir); |
| 150 | + runGit(["commit", "-m", "local advance"], machine.vaultDir); |
| 151 | + |
| 152 | + const result = await lsMod.performLs({ machine: "work-laptop" }); |
| 153 | + expect(result.kind).toBe("reconcile-error"); |
| 154 | + }); |
| 155 | + |
| 156 | + test("the CLI wrapper exits 1 on an unknown machine", async () => { |
| 157 | + process.exitCode = 0; |
| 158 | + await lsMod.lsCommand.run?.({ |
| 159 | + args: { machine: "ghost" }, |
| 160 | + rawArgs: [], |
| 161 | + cmd: {} as never, |
| 162 | + } as never); |
| 163 | + expect(process.exitCode).toBe(1); |
| 164 | + }); |
| 165 | +}); |
0 commit comments