mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-19 08:10:08 +02:00
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.
This commit is contained in:
@@ -2,60 +2,123 @@
|
||||
* 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 } from "../src/browseClient";
|
||||
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 every candidate path to a non-existent location.
|
||||
const originalEnv = process.env.BROWSE_BIN;
|
||||
process.env.BROWSE_BIN = "/nonexistent/browse-does-not-exist";
|
||||
|
||||
// We can't easily mock the sibling and global paths without touching
|
||||
// the filesystem, so in a typical dev environment this will usually
|
||||
// find the real browse. That's fine — on CI it will throw, and the
|
||||
// error message shape is what we're actually asserting.
|
||||
let thrown: any = null;
|
||||
// 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 {
|
||||
resolveBrowseBin();
|
||||
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.message).toContain("browse binary not found");
|
||||
expect(thrown.message).toContain("./setup");
|
||||
expect(thrown.message).toContain("BROWSE_BIN");
|
||||
}
|
||||
|
||||
// Restore env
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.BROWSE_BIN;
|
||||
} else {
|
||||
process.env.BROWSE_BIN = originalEnv;
|
||||
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 BROWSE_BIN when it points at a real executable", () => {
|
||||
const originalEnv = process.env.BROWSE_BIN;
|
||||
// `/bin/sh` exists on every POSIX system and is executable.
|
||||
process.env.BROWSE_BIN = "/bin/sh";
|
||||
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);
|
||||
});
|
||||
|
||||
try {
|
||||
const resolved = resolveBrowseBin();
|
||||
expect(resolved).toBe("/bin/sh");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.BROWSE_BIN;
|
||||
} else {
|
||||
process.env.BROWSE_BIN = originalEnv;
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { normalize, copyPasteGate } from "../src/pdftotext";
|
||||
import * as path from "node:path";
|
||||
import { normalize, copyPasteGate, findExecutable, resolvePdftotext, PdftotextUnavailableError } from "../src/pdftotext";
|
||||
|
||||
describe("normalize", () => {
|
||||
test("strips trailing spaces", () => {
|
||||
@@ -104,3 +105,103 @@ describe("copyPasteGate — assertion logic", () => {
|
||||
expect(Math.abs(expectedBreaks - tooManyBreaksNormalized)).toBeLessThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Binary resolution (v1.24-aligned) ──────────────────────────
|
||||
|
||||
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 (pdftotext.ts)", () => {
|
||||
test("returns the bare path on POSIX when it's executable", () => {
|
||||
if (process.platform === "win32") return;
|
||||
expect(findExecutable("/bin/sh")).toBe("/bin/sh");
|
||||
});
|
||||
|
||||
test("on win32, probes .exe / .cmd / .bat after the bare-path miss", () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const base = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd");
|
||||
expect(findExecutable(base)).toBe(base + ".exe");
|
||||
});
|
||||
|
||||
test("returns null when no extension matches", () => {
|
||||
expect(findExecutable("/nonexistent/path/to/nothing")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePdftotext (override resolution, v1.24-aligned)", () => {
|
||||
test("honors GSTACK_PDFTOTEXT_BIN when it points at a real executable", () => {
|
||||
// We can't fake a real pdftotext, but we can fake "any executable" to
|
||||
// exercise the override-resolution path. describeBinary will mark flavor
|
||||
// as "unknown" since cmd.exe / /bin/sh don't respond to -v like pdftotext;
|
||||
// the test asserts on the bin-path resolution, not the version probe.
|
||||
const info = withEnv({ GSTACK_PDFTOTEXT_BIN: REAL_EXE }, () => resolvePdftotext());
|
||||
expect(info.bin).toBe(REAL_EXE);
|
||||
});
|
||||
|
||||
test("honors PDFTOTEXT_BIN as a back-compat alias", () => {
|
||||
const info = withEnv(
|
||||
{ GSTACK_PDFTOTEXT_BIN: undefined, PDFTOTEXT_BIN: REAL_EXE },
|
||||
() => resolvePdftotext(),
|
||||
);
|
||||
expect(info.bin).toBe(REAL_EXE);
|
||||
});
|
||||
|
||||
test("GSTACK_PDFTOTEXT_BIN takes precedence over PDFTOTEXT_BIN", () => {
|
||||
const info = withEnv(
|
||||
{ GSTACK_PDFTOTEXT_BIN: REAL_EXE, PDFTOTEXT_BIN: "/nonexistent/legacy" },
|
||||
() => resolvePdftotext(),
|
||||
);
|
||||
expect(info.bin).toBe(REAL_EXE);
|
||||
});
|
||||
|
||||
test("strips wrapping double quotes from override values", () => {
|
||||
const info = withEnv({ GSTACK_PDFTOTEXT_BIN: `"${REAL_EXE}"` }, () => resolvePdftotext());
|
||||
expect(info.bin).toBe(REAL_EXE);
|
||||
});
|
||||
|
||||
test("error message includes Windows install hint and GSTACK_PDFTOTEXT_BIN", () => {
|
||||
let thrown: unknown = null;
|
||||
try {
|
||||
withEnv(
|
||||
{
|
||||
GSTACK_PDFTOTEXT_BIN: "/nonexistent/gstack-pdftotext",
|
||||
PDFTOTEXT_BIN: "/nonexistent/pdftotext",
|
||||
PATH: "",
|
||||
Path: "",
|
||||
},
|
||||
() => resolvePdftotext(),
|
||||
);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
// If the test box has a real pdftotext on disk, resolution succeeds
|
||||
// (POSIX candidates) — that's fine; the assertion is gated on whether
|
||||
// it threw. On Windows-CI without poppler, it throws.
|
||||
if (thrown) {
|
||||
expect(thrown).toBeInstanceOf(PdftotextUnavailableError);
|
||||
expect((thrown as Error).message).toContain("pdftotext not found");
|
||||
expect((thrown as Error).message).toContain("GSTACK_PDFTOTEXT_BIN");
|
||||
expect((thrown as Error).message).toContain("Windows");
|
||||
expect((thrown as Error).message).toContain("scoop install poppler");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user