import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { buildRemoteFindCommand, buildRemoteGrepCommand, formatRemoteSearchOutput, resolveRemoteSearchPath, runRemoteFind, runRemoteGrep, } from "../src/remote-search.ts"; import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts"; function grepProtocol(backend: string, rows: Array<[string, number, string]> = []): Buffer { const fields = rows.flatMap(([path, line, content]) => [path, String(line), content]); return Buffer.from(`__PI_SSH_SEARCH_BACKEND__:${backend}\n${fields.length > 0 ? `${fields.join("\0")}\0` : ""}`); } class SearchTransport implements RemoteTransport { command = ""; cwd = ""; private readonly output: Buffer; private readonly stderr: Buffer; private readonly exitCode: number | null; constructor(output: string | Buffer, exitCode: number | null = 0, stderr = "") { this.output = Buffer.isBuffer(output) ? output : Buffer.from(output); this.stderr = Buffer.from(stderr); this.exitCode = exitCode; } connect(): Promise { return Promise.resolve(); } dispose(): Promise { return Promise.resolve(); } exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> { this.command = command; this.cwd = cwd; options.onData(this.output); if (this.stderr.length > 0) (options.onStderr ?? options.onData)(this.stderr); return Promise.resolve({ exitCode: this.exitCode }); } capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); } readFile(): Promise { throw new Error("not used"); } ensureReadable(): Promise { throw new Error("not used"); } ensureReadableWritable(): Promise { throw new Error("not used"); } detectImageMimeType(): Promise { throw new Error("not used"); } mkdir(): Promise { throw new Error("not used"); } writeFile(): Promise { throw new Error("not used"); } } function toolPath(name: string): string { return execFileSync("/bin/sh", ["-c", `command -v ${name}`], { encoding: "utf8" }).trim(); } function toolBin(root: string, names: string[]): string { const bin = join(root, `bin-${names.join("-")}`); mkdirSync(bin); for (const name of names) symlinkSync(toolPath(name), join(bin, name)); return bin; } function execute(command: string, cwd: string, path: string) { return spawnSync("/bin/bash", ["-c", command], { cwd, env: { ...process.env, PATH: path } }); } test("resolves remote search roots without using local filesystem semantics", () => { assert.equal(resolveRemoteSearchPath(undefined, "/srv/app", "/home/build"), "/srv/app"); assert.equal(resolveRemoteSearchPath("src", "/srv/app", "/home/build"), "/srv/app/src"); assert.equal(resolveRemoteSearchPath("~/logs", "/srv/app", "/home/build"), "/home/build/logs"); assert.equal(resolveRemoteSearchPath("/var/log", "/srv/app", "/home/build"), "/var/log"); assert.throws(() => resolveRemoteSearchPath("~other/project", "/srv/app", "/home/build"), /only ~ or ~\//); }); test("builds bounded capability-adaptive commands with unified grep semantics", () => { const find = buildRemoteFindCommand({ pattern: "it's-app", limit: 12 }, "/srv/app dir"); assert.match(find.command, /command -v fd/); assert.match(find.command, /git-ls-files/); assert.match(find.command, /head -n 13/); assert.match(find.command, /'it'"'"'s-app'/); assert.match(find.command, /'\/srv\/app dir'/); const grep = buildRemoteGrepCommand({ pattern: "needle+", literal: false, include: "*.ts", includeHidden: true, limit: 20 }, "/srv/app"); assert.match(grep.command, /rg --null/); assert.match(grep.command, /git .* grep .* -z .* -E/); assert.match(grep.command, /grep -n -I -E/); assert.match(grep.command, /PI_SSH_TAKE=21/); assert.match(grep.command, /--hidden/); assert.throws(() => buildRemoteGrepCommand({ pattern: "x", include: "sub\/*.ts" }, "/srv/app"), /basename glob without \//); assert.throws(() => buildRemoteGrepCommand({ pattern: "bad\npattern" }, "/srv/app"), /newline/); assert.throws(() => buildRemoteFindCommand({ pattern: "x", limit: 201 }, "/srv/app"), /limit/); }); test("normalizes NUL-delimited git paths, escaped newlines, truncation, and backend", () => { const result = formatRemoteSearchOutput( grepProtocol("git-grep", [["src/a.ts", 2, "TODO"], ["src/line\nb.ts", 3, "TODO"], ["src/c.ts", 4, "TODO"]]), "/srv/app", 2, "grep", ); assert.equal(result.backend, "git-grep"); assert.equal(result.matchCount, 2); assert.equal(result.truncated, true); assert.match(result.text, /\/srv\/app\/src\/a\.ts:2:TODO/); assert.match(result.text, /src\/line\\nb\.ts:3:TODO/); assert.doesNotMatch(result.text, /src\/c\.ts/); }); test("executes remote find from the active workspace while searching a separate root", async () => { const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/data/search/a.ts\n"); const result = await runRemoteFind(transport, { pattern: "a", path: "/data/search", limit: 5 }, "/srv/app", "/home/build"); assert.equal(transport.cwd, "/srv/app"); assert.match(transport.command, /command -v fd/); assert.match(transport.command, /\/data\/search/); assert.equal(result.matchCount, 1); assert.match(result.text, /backend: fd/); }); test("searches a single remote file without treating the file as execution cwd", async () => { const transport = new SearchTransport(grepProtocol("ripgrep", [["/var/log/application.log", 7, "ERROR"]])); const result = await runRemoteGrep(transport, { pattern: "ERROR", path: "/var/log/application.log" }, "/srv/app", "/home/build"); assert.equal(transport.cwd, "/srv/app"); assert.match(transport.command, /\/var\/log\/application\.log/); assert.equal(result.matchCount, 1); assert.match(result.text, /application\.log:7:ERROR/); }); test("keeps successful stderr warnings out of match rows and uses stderr for failures", async () => { const warningTransport = new SearchTransport(grepProtocol("ripgrep"), 0, "warning: skipped socket\n"); const warningResult = await runRemoteGrep(warningTransport, { pattern: "ABSENT" }, "/srv/app", "/home/build"); assert.equal(warningResult.matchCount, 0); assert.match(warningResult.text, /No matches found/); assert.match(warningResult.text, /Remote warning: warning: skipped socket/); const failed = new SearchTransport(grepProtocol("git-grep"), 128, "fatal: invalid regular expression\n"); await assert.rejects( runRemoteGrep(failed, { pattern: "[", literal: false }, "/srv/app", "/home/build"), (error: unknown) => { assert.match(String(error), /remote grep failed with exit code 128: fatal: invalid regular expression/); assert.doesNotMatch(String(error), /__PI_SSH_SEARCH_BACKEND__/); return true; }, ); }); test("reports actionable bounded-search timeout guidance", async () => { const transport = new SearchTransport(""); transport.exec = () => Promise.reject(new Error("SSH command timed out after 30s")); await assert.rejects( runRemoteGrep(transport, { pattern: "TODO", path: "/home/build" }, "/srv/app", "/home/build"), /remote grep timed out after 30s \(root: \/home\/build\); narrow the remote path with ssh_find/, ); }); test("forces git-grep and fallback no-match paths and preserves real errors", () => { const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-")); try { mkdirSync(join(root, "src")); writeFileSync(join(root, "src", "a.ts"), "const value = 'TODO';\n", "utf8"); execFileSync(toolPath("git"), ["init", "-q", root]); execFileSync(toolPath("git"), ["-C", root, "add", "src/a.ts"]); const gitPath = toolBin(root, ["git"]); const fallbackPath = toolBin(root, ["find", "grep"]); const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 5 }, root); const gitProcess = execute(grep.command, root, gitPath); assert.equal(gitProcess.status, 0, gitProcess.stderr.toString()); const gitResult = formatRemoteSearchOutput(gitProcess.stdout, root, grep.limit, "grep"); assert.equal(gitResult.backend, "git-grep"); assert.equal(gitResult.matchCount, 1); const fallbackProcess = execute(grep.command, root, fallbackPath); assert.equal(fallbackProcess.status, 0, fallbackProcess.stderr.toString()); const fallbackResult = formatRemoteSearchOutput(fallbackProcess.stdout, root, grep.limit, "grep"); assert.equal(fallbackResult.backend, "grep"); assert.equal(fallbackResult.matchCount, 1); const noMatch = buildRemoteGrepCommand({ pattern: "ABSENT", include: "*.ts" }, root); for (const path of [gitPath, fallbackPath]) { const process = execute(noMatch.command, root, path); assert.equal(process.status, 0, process.stderr.toString()); assert.equal(formatRemoteSearchOutput(process.stdout, root, noMatch.limit, "grep").matchCount, 0); } const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root); const invalidProcess = execute(invalidRegex.command, root, fallbackPath); assert.notEqual(invalidProcess.status, 0); assert.match(invalidProcess.stderr.toString(), /regular expression|bracket/i); const missing = buildRemoteFindCommand({ pattern: "anything" }, join(root, "missing")); const missingProcess = execute(missing.command, root, fallbackPath); assert.notEqual(missingProcess.status, 0); assert.match(missingProcess.stderr.toString(), /find|No such file|not found/i); } finally { rmSync(root, { recursive: true, force: true }); } }); test("rg, git-grep, and fallback agree on hidden files, basename globs, and portable ERE", (context) => { let rg: string; try { rg = toolPath("rg"); } catch { context.skip("rg is unavailable"); return; } const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-matrix-")); try { mkdirSync(join(root, "sub")); mkdirSync(join(root, ".secret")); for (const path of ["visible.ts", "sub/nested.ts", ".hidden.ts", ".secret/deep.ts"]) { writeFileSync(join(root, path), "needle\nneedlee\n", "utf8"); } execFileSync(toolPath("git"), ["init", "-q", root]); execFileSync(toolPath("git"), ["-C", root, "add", "."]); const paths = [toolBin(root, ["rg"]), toolBin(root, ["git"]), toolBin(root, ["find", "grep"])]; assert.equal(toolPath("rg"), rg); const runCounts = (input: Parameters[0]) => paths.map((path) => { const built = buildRemoteGrepCommand(input, root); const process = execute(built.command, root, path); assert.equal(process.status, 0, process.stderr.toString()); return formatRemoteSearchOutput(process.stdout, root, built.limit, "grep").matchCount; }); assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts" }), [4, 4, 4]); assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts", includeHidden: true }), [8, 8, 8]); assert.deepEqual(runCounts({ pattern: "needle+", literal: false, include: "*.ts" }), [4, 4, 4]); } finally { rmSync(root, { recursive: true, force: true }); } }); test("fallback NUL protocol keeps newline filenames as one bounded result", () => { const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-newline-")); try { const filename = "line\nbreak.ts"; writeFileSync(join(root, filename), "needle\n", "utf8"); const fallbackPath = toolBin(root, ["find", "grep"]); const built = buildRemoteGrepCommand({ pattern: "needle", include: "*.ts" }, root); const process = execute(built.command, root, fallbackPath); assert.equal(process.status, 0, process.stderr.toString()); const result = formatRemoteSearchOutput(process.stdout, root, built.limit, "grep"); assert.equal(result.matchCount, 1); assert.match(result.text, /line\\nbreak\.ts:1:needle/); } finally { rmSync(root, { recursive: true, force: true }); } }); test("propagates fd and ripgrep failures while preserving ripgrep no-match", () => { const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-backends-")); try { const bin = join(root, "bin"); mkdirSync(bin); const fd = join(bin, "fd"); const rg = join(bin, "rg"); writeFileSync(fd, "#!/bin/sh\necho 'fd exploded' >&2\nexit 3\n", { mode: 0o755 }); writeFileSync(rg, "#!/bin/sh\necho 'rg exploded' >&2\nexit 2\n", { mode: 0o755 }); const environment = { ...process.env, PATH: `${bin}:/usr/bin:/bin` }; const find = buildRemoteFindCommand({ pattern: "anything" }, root); const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, env: environment }); assert.equal(failedFind.status, 3); assert.match(failedFind.stderr.toString(), /fd exploded/); const grep = buildRemoteGrepCommand({ pattern: "anything" }, root); const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment }); assert.equal(failedGrep.status, 2); assert.match(failedGrep.stderr.toString(), /rg exploded/); writeFileSync(rg, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment }); assert.equal(noMatch.status, 0); assert.equal(formatRemoteSearchOutput(noMatch.stdout, root, grep.limit, "grep").matchCount, 0); } finally { rmSync(root, { recursive: true, force: true }); } });