Files
gstack/make-pdf/test/browseClient.test.ts
T
Samuel Carson b0c138c545 fix(make-pdf): Bun.which-based binary resolution for browse + pdftotext on Windows
Extends v1.24.0.0's Bun.which + GSTACK_*_BIN override pattern (introduced in
browse/src/claude-bin.ts via #1252) to the two other binary resolvers in the
codebase: make-pdf/src/browseClient.ts:resolveBrowseBin and
make-pdf/src/pdftotext.ts:resolvePdftotext.

Same Windows quirks (fs.accessSync(X_OK) degrades to existence-check; `which`
isn't available outside Git Bash; bun --compile --outfile X emits X.exe), same
Bun.which-based fix shape, same env override convention.

Changes:
  - GSTACK_BROWSE_BIN / GSTACK_PDFTOTEXT_BIN as the v1.24-aligned overrides;
    BROWSE_BIN / PDFTOTEXT_BIN remain as back-compat aliases.
  - Bun.which() replaces execFileSync('which', ...) for PATH lookup. Handles
    Windows PATHEXT natively; no more `where`-vs-`which` branch.
  - findExecutable(base) helper exported from each module, probes .exe/.cmd/.bat
    after the bare-path miss on win32. Linux/macOS behavior is bit-identical
    (isExecutable short-circuits before the win32 branch ever runs).
  - macCandidates renamed posixCandidates (always was — /opt/homebrew, /usr/local,
    /usr/bin). No Windows candidates added; Poppler installs scatter across
    Scoop/Chocolatey/portable zips and guessing causes false positives.
  - Error messages get a Windows install hint (scoop install poppler / oschwartz10612)
    and `setx` example for GSTACK_*_BIN.
  - Pre-existing test 'honors BROWSE_BIN when it points at a real executable'
    was hardcoded /bin/sh — made cross-platform via a REAL_EXE constant
    (cmd.exe on win32, /bin/sh on POSIX). Was a Windows-CI blocker on its own.

Coordination: PR #1094 (@BkashJEE) covered browseClient.ts independently with a
narrower scope; this PR's pdftotext + cross-platform tests + GSTACK_*_BIN naming
are additive. Either order of merge works.

Test plan:
  - bun test make-pdf/test/browseClient.test.ts make-pdf/test/pdftotext.test.ts
    on win32 — 29 pass, 0 fail (12 new assertions: findExecutable POSIX/win32/null,
    resolveBrowseBin GSTACK_BROWSE_BIN + BROWSE_BIN + precedence + quote-strip,
    same shape for resolvePdftotext + Windows install hint in error message).
  - POSIX branch unchanged — fs.accessSync(X_OK) on Linux/macOS short-circuits
    before any win32 logic runs, matching the v1.24 claude-bin.ts pattern.
2026-05-03 15:50:03 -05:00

136 lines
4.9 KiB
TypeScript

/**
* browseClient unit tests — binary resolution and error mapping.
*
* These are pure unit tests; they do NOT require a running browse daemon.
* Cross-platform: assertions that pin POSIX behavior early-return on win32
* and vice versa, so both lanes only exercise their own branch.
*/
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { BrowseClientError } from "../src/types";
import { resolveBrowseBin, findExecutable } from "../src/browseClient";
// A real, always-present executable for the test platform — `cmd.exe` on
// Windows (System32 is on every install) and `/bin/sh` on POSIX. Lets the
// "honors override when it points at a real executable" test work in both
// lanes without writing a temp script.
const REAL_EXE: string =
process.platform === "win32"
? path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe")
: "/bin/sh";
function withEnv<T>(overrides: Record<string, string | undefined>, fn: () => T): T {
const saved: Record<string, string | undefined> = {};
for (const k of Object.keys(overrides)) saved[k] = process.env[k];
for (const [k, v] of Object.entries(overrides)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
try {
return fn();
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
}
describe("findExecutable", () => {
test("returns the bare path on POSIX when it's executable", () => {
if (process.platform === "win32") return;
const found = findExecutable("/bin/sh");
expect(found).toBe("/bin/sh");
});
test("on win32, probes .exe / .cmd / .bat after the bare-path miss", () => {
if (process.platform !== "win32") return;
// cmd.exe lives at System32\cmd.exe — probe with the bare base.
const base = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd");
const found = findExecutable(base);
expect(found).toBe(base + ".exe");
});
test("returns null when no extension matches", () => {
const found = findExecutable("/nonexistent/path/to/nothing");
expect(found).toBeNull();
});
});
describe("resolveBrowseBin", () => {
test("throws BrowseClientError with setup hint when nothing is found", () => {
// Point overrides at non-existent paths and clear PATH so Bun.which finds
// nothing. Sibling/global probes go through findExecutable on real paths,
// but the test asserts on the error shape rather than depending on whether
// a real browse install exists on the box.
let thrown: unknown = null;
try {
withEnv(
{
GSTACK_BROWSE_BIN: "/nonexistent/gstack-browse-bin",
BROWSE_BIN: "/nonexistent/browse-bin",
PATH: "",
Path: "",
},
() => resolveBrowseBin(),
);
} catch (err) {
thrown = err;
}
if (thrown) {
expect(thrown).toBeInstanceOf(BrowseClientError);
expect((thrown as BrowseClientError).message).toContain("browse binary not found");
expect((thrown as BrowseClientError).message).toContain("./setup");
expect((thrown as BrowseClientError).message).toContain("GSTACK_BROWSE_BIN");
// Back-compat alias still surfaces in the diagnostic.
expect((thrown as BrowseClientError).message).toContain("BROWSE_BIN");
}
// If the test box has a real browse install on disk, sibling/global may
// resolve and the helper won't throw — that's fine; the assertion is
// gated on whether it threw at all.
});
test("honors GSTACK_BROWSE_BIN when it points at a real executable", () => {
const resolved = withEnv({ GSTACK_BROWSE_BIN: REAL_EXE }, () => resolveBrowseBin());
expect(resolved).toBe(REAL_EXE);
});
test("honors BROWSE_BIN as a back-compat alias", () => {
const resolved = withEnv(
{ GSTACK_BROWSE_BIN: undefined, BROWSE_BIN: REAL_EXE },
() => resolveBrowseBin(),
);
expect(resolved).toBe(REAL_EXE);
});
test("GSTACK_BROWSE_BIN takes precedence over BROWSE_BIN", () => {
const resolved = withEnv(
{ GSTACK_BROWSE_BIN: REAL_EXE, BROWSE_BIN: "/nonexistent/legacy" },
() => resolveBrowseBin(),
);
expect(resolved).toBe(REAL_EXE);
});
test("strips wrapping double quotes from override values", () => {
const resolved = withEnv({ GSTACK_BROWSE_BIN: `"${REAL_EXE}"` }, () => resolveBrowseBin());
expect(resolved).toBe(REAL_EXE);
});
});
describe("BrowseClientError", () => {
test("captures exit code, command, and stderr", () => {
const err = new BrowseClientError(127, "pdf", "Chromium not found");
expect(err.exitCode).toBe(127);
expect(err.command).toBe("pdf");
expect(err.stderr).toBe("Chromium not found");
expect(err.message).toContain("browse pdf exited 127");
expect(err.message).toContain("Chromium not found");
expect(err.name).toBe("BrowseClientError");
});
});