|
| 1 | +/** |
| 2 | + * Integration tests that invoke the real patchloom binary. |
| 3 | + * |
| 4 | + * These tests exercise the actual boundary between the extension and the CLI: |
| 5 | + * process spawning, argument formatting, output parsing, and exit code handling. |
| 6 | + * |
| 7 | + * Skipped automatically if patchloom is not available on PATH or at a known |
| 8 | + * build location, so CI without patchloom installed still passes. |
| 9 | + */ |
| 10 | +import assert from "node:assert/strict"; |
| 11 | +import { execFile } from "node:child_process"; |
| 12 | +import { constants as fsConstants } from "node:fs"; |
| 13 | +import * as fs from "node:fs/promises"; |
| 14 | +import * as os from "node:os"; |
| 15 | +import * as path from "node:path"; |
| 16 | +import { promisify } from "node:util"; |
| 17 | +import test, { describe } from "node:test"; |
| 18 | +import { |
| 19 | + findOnPath, |
| 20 | + parsePatchloomVersion, |
| 21 | + assessPatchloomCompatibility, |
| 22 | + resolvePatchloomStatusWithInputs |
| 23 | +} from "../../src/binary/patchloom"; |
| 24 | + |
| 25 | +const execFileAsync = promisify(execFile); |
| 26 | + |
| 27 | +const KNOWN_BUILD_PATHS = [ |
| 28 | + path.join(os.homedir(), "patchloom", "target", "release", "patchloom"), |
| 29 | + path.join(os.homedir(), "patchloom", "target", "debug", "patchloom") |
| 30 | +]; |
| 31 | + |
| 32 | +async function findPatchloom(): Promise<string | undefined> { |
| 33 | + // Try PATH first |
| 34 | + const onPath = await findOnPath(process.env.PATH, process.platform); |
| 35 | + if (onPath) { |
| 36 | + return onPath; |
| 37 | + } |
| 38 | + |
| 39 | + // Fall back to known build locations |
| 40 | + for (const p of KNOWN_BUILD_PATHS) { |
| 41 | + try { |
| 42 | + await fs.access(p, fsConstants.X_OK); |
| 43 | + return p; |
| 44 | + } catch { |
| 45 | + // not found, try next |
| 46 | + } |
| 47 | + } |
| 48 | + |
| 49 | + return undefined; |
| 50 | +} |
| 51 | + |
| 52 | +async function withTempDir(fn: (dir: string) => Promise<void>): Promise<void> { |
| 53 | + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-cli-test-")); |
| 54 | + try { |
| 55 | + await fn(dir); |
| 56 | + } finally { |
| 57 | + await fs.rm(dir, { recursive: true, force: true }); |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +describe("patchloom CLI integration", async () => { |
| 62 | + const binaryPath = await findPatchloom(); |
| 63 | + if (!binaryPath) { |
| 64 | + test("skipped: patchloom binary not found", { skip: "patchloom not on PATH or at known build path" }, () => {}); |
| 65 | + return; |
| 66 | + } |
| 67 | + |
| 68 | + test("--version returns parseable version string", async () => { |
| 69 | + const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 }); |
| 70 | + const output = `${stdout}${stderr}`.trim(); |
| 71 | + assert.ok(output.length > 0, "version output should not be empty"); |
| 72 | + |
| 73 | + const version = parsePatchloomVersion(output); |
| 74 | + assert.ok(version, `should parse a version from: ${output}`); |
| 75 | + assert.match(version, /^\d+\.\d+\.\d+/, "version should be semver"); |
| 76 | + }); |
| 77 | + |
| 78 | + test("--version output passes compatibility assessment", async () => { |
| 79 | + const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 }); |
| 80 | + const output = `${stdout}${stderr}`.trim(); |
| 81 | + const assessment = assessPatchloomCompatibility(output); |
| 82 | + assert.equal(assessment.compatibility, "supported", |
| 83 | + `installed patchloom should be compatible: ${assessment.message}`); |
| 84 | + }); |
| 85 | + |
| 86 | + test("resolvePatchloomStatusWithInputs discovers the real binary", async () => { |
| 87 | + const status = await resolvePatchloomStatusWithInputs({ |
| 88 | + configuredPath: binaryPath |
| 89 | + }); |
| 90 | + |
| 91 | + assert.equal(status.ready, true); |
| 92 | + assert.equal(status.source, "setting"); |
| 93 | + assert.ok(status.version, "should have a version string"); |
| 94 | + assert.ok(status.binaryPath, "should have a binary path"); |
| 95 | + }); |
| 96 | + |
| 97 | + test("agent-rules produces markdown output", async () => { |
| 98 | + await withTempDir(async (dir) => { |
| 99 | + const { stdout } = await execFileAsync(binaryPath, ["agent-rules"], { |
| 100 | + cwd: dir, |
| 101 | + timeout: 10000 |
| 102 | + }); |
| 103 | + |
| 104 | + assert.ok(stdout.length > 100, "agent-rules output should be substantial"); |
| 105 | + assert.match(stdout, /patchloom/i, "output should mention patchloom"); |
| 106 | + assert.match(stdout, /^#/m, "output should contain markdown headings"); |
| 107 | + }); |
| 108 | + }); |
| 109 | + |
| 110 | + test("doc set modifies a JSON file by selector", async () => { |
| 111 | + await withTempDir(async (dir) => { |
| 112 | + const jsonFile = path.join(dir, "config.json"); |
| 113 | + await fs.writeFile(jsonFile, JSON.stringify({ server: { port: 3000 } }), "utf8"); |
| 114 | + |
| 115 | + await execFileAsync(binaryPath, [ |
| 116 | + "doc", "set", jsonFile, "server.port", "8080", "--apply" |
| 117 | + ], { cwd: dir, timeout: 5000 }); |
| 118 | + |
| 119 | + const result = JSON.parse(await fs.readFile(jsonFile, "utf8")) as Record<string, Record<string, unknown>>; |
| 120 | + assert.equal(result.server.port, 8080, "port should be updated to 8080"); |
| 121 | + }); |
| 122 | + }); |
| 123 | + |
| 124 | + test("doc set preserves YAML comments", async () => { |
| 125 | + await withTempDir(async (dir) => { |
| 126 | + const yamlFile = path.join(dir, "config.yaml"); |
| 127 | + await fs.writeFile(yamlFile, [ |
| 128 | + "# Server configuration", |
| 129 | + "server:", |
| 130 | + " host: localhost # local only", |
| 131 | + " port: 3000", |
| 132 | + "" |
| 133 | + ].join("\n"), "utf8"); |
| 134 | + |
| 135 | + await execFileAsync(binaryPath, [ |
| 136 | + "doc", "set", yamlFile, "server.port", "9090", "--apply" |
| 137 | + ], { cwd: dir, timeout: 5000 }); |
| 138 | + |
| 139 | + const result = await fs.readFile(yamlFile, "utf8"); |
| 140 | + assert.match(result, /# Server configuration/, "top comment should be preserved"); |
| 141 | + assert.match(result, /# local only/, "inline comment should be preserved"); |
| 142 | + assert.match(result, /9090/, "port should be updated"); |
| 143 | + }); |
| 144 | + }); |
| 145 | + |
| 146 | + test("replace performs text substitution in a file", async () => { |
| 147 | + await withTempDir(async (dir) => { |
| 148 | + const txtFile = path.join(dir, "readme.txt"); |
| 149 | + await fs.writeFile(txtFile, "Hello old_name, welcome to old_name project.\n", "utf8"); |
| 150 | + |
| 151 | + await execFileAsync(binaryPath, [ |
| 152 | + "replace", "old_name", "--to", "new_name", txtFile, "--apply" |
| 153 | + ], { cwd: dir, timeout: 5000 }); |
| 154 | + |
| 155 | + const result = await fs.readFile(txtFile, "utf8"); |
| 156 | + assert.ok(!result.includes("old_name"), "old_name should be replaced"); |
| 157 | + assert.ok(result.includes("new_name"), "new_name should be present"); |
| 158 | + }); |
| 159 | + }); |
| 160 | + |
| 161 | + test("tidy fix ensures final newline", async () => { |
| 162 | + await withTempDir(async (dir) => { |
| 163 | + const txtFile = path.join(dir, "no-newline.txt"); |
| 164 | + await fs.writeFile(txtFile, "no trailing newline", "utf8"); |
| 165 | + |
| 166 | + await execFileAsync(binaryPath, [ |
| 167 | + "tidy", "fix", txtFile, "--ensure-final-newline", "--apply" |
| 168 | + ], { cwd: dir, timeout: 5000 }); |
| 169 | + |
| 170 | + const result = await fs.readFile(txtFile, "utf8"); |
| 171 | + assert.ok(result.endsWith("\n"), "file should end with newline after tidy"); |
| 172 | + }); |
| 173 | + }); |
| 174 | + |
| 175 | + test("search finds text across files", async () => { |
| 176 | + await withTempDir(async (dir) => { |
| 177 | + await fs.writeFile(path.join(dir, "a.txt"), "hello world\n", "utf8"); |
| 178 | + await fs.writeFile(path.join(dir, "b.txt"), "goodbye world\n", "utf8"); |
| 179 | + |
| 180 | + const { stdout } = await execFileAsync(binaryPath, [ |
| 181 | + "search", "world", dir, "--json" |
| 182 | + ], { timeout: 5000 }); |
| 183 | + |
| 184 | + const result = JSON.parse(stdout) as { match_count: number }; |
| 185 | + assert.equal(result.match_count, 2, "should find 'world' in both files"); |
| 186 | + }); |
| 187 | + }); |
| 188 | + |
| 189 | + test("doc get reads a value by selector", async () => { |
| 190 | + await withTempDir(async (dir) => { |
| 191 | + const jsonFile = path.join(dir, "data.json"); |
| 192 | + await fs.writeFile(jsonFile, JSON.stringify({ version: "1.2.3" }), "utf8"); |
| 193 | + |
| 194 | + const { stdout } = await execFileAsync(binaryPath, [ |
| 195 | + "doc", "get", jsonFile, "version" |
| 196 | + ], { timeout: 5000 }); |
| 197 | + |
| 198 | + assert.match(stdout.trim(), /1\.2\.3/, "should output the version value"); |
| 199 | + }); |
| 200 | + }); |
| 201 | + |
| 202 | + test("batch applies multiple operations via stdin", async () => { |
| 203 | + await withTempDir(async (dir) => { |
| 204 | + const jsonFile = path.join(dir, "package.json"); |
| 205 | + const txtFile = path.join(dir, "VERSION"); |
| 206 | + await fs.writeFile(jsonFile, JSON.stringify({ version: "1.0.0", name: "test" }), "utf8"); |
| 207 | + await fs.writeFile(txtFile, "1.0.0\n", "utf8"); |
| 208 | + |
| 209 | + const batchInput = [ |
| 210 | + `doc.set ${jsonFile} version "2.0.0"`, |
| 211 | + `replace ${txtFile} "1.0.0" "2.0.0"` |
| 212 | + ].join("\n"); |
| 213 | + |
| 214 | + const child = execFile(binaryPath, ["batch", "--apply"], { cwd: dir, timeout: 5000 }); |
| 215 | + child.stdin!.write(batchInput); |
| 216 | + child.stdin!.end(); |
| 217 | + await new Promise<void>((resolve, reject) => { |
| 218 | + child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`batch exited ${code}`))); |
| 219 | + child.on("error", reject); |
| 220 | + }); |
| 221 | + |
| 222 | + const pkg = JSON.parse(await fs.readFile(jsonFile, "utf8")) as Record<string, unknown>; |
| 223 | + assert.equal(pkg.version, "2.0.0", "package.json version should be updated"); |
| 224 | + |
| 225 | + const ver = await fs.readFile(txtFile, "utf8"); |
| 226 | + assert.match(ver, /2\.0\.0/, "VERSION file should be updated"); |
| 227 | + }); |
| 228 | + }); |
| 229 | + |
| 230 | + test("create makes a new file", async () => { |
| 231 | + await withTempDir(async (dir) => { |
| 232 | + const newFile = path.join(dir, "created.txt"); |
| 233 | + |
| 234 | + await execFileAsync(binaryPath, [ |
| 235 | + "create", newFile, "--content", "hello from patchloom", "--apply" |
| 236 | + ], { cwd: dir, timeout: 5000 }); |
| 237 | + |
| 238 | + const content = await fs.readFile(newFile, "utf8"); |
| 239 | + assert.match(content, /hello from patchloom/, "created file should have the specified content"); |
| 240 | + }); |
| 241 | + }); |
| 242 | + |
| 243 | + test("exit code 3 for search with no matches", async () => { |
| 244 | + await withTempDir(async (dir) => { |
| 245 | + await fs.writeFile(path.join(dir, "empty.txt"), "nothing here\n", "utf8"); |
| 246 | + |
| 247 | + try { |
| 248 | + await execFileAsync(binaryPath, [ |
| 249 | + "search", "NONEXISTENT_STRING_xyz", dir |
| 250 | + ], { timeout: 5000 }); |
| 251 | + assert.fail("should have exited with non-zero code"); |
| 252 | + } catch (error) { |
| 253 | + const execError = error as Error & { code?: number }; |
| 254 | + assert.equal(execError.code, 3, "no-match exit code should be 3"); |
| 255 | + } |
| 256 | + }); |
| 257 | + }); |
| 258 | +}); |
0 commit comments