Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 38 additions & 18 deletions src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type {
FlowError,
FlowResult,
Expand Down Expand Up @@ -83,26 +84,45 @@ function generateSessionName(): string {
}

/**
* Get the path to the agent-browser binary
* Get the path to the agent-browser binary.
*
* Resolves through Node module resolution rather than a hardcoded
* `<flowspec>/node_modules/.bin` path, because that layout only exists
* under nested installs (pnpm, or flowspec's own repo). Hoisting package
* managers (bun, npm) place agent-browser at the consumer root, where the
* old path pointed at nothing and every flow died with "Browser command
* failed" (#15).
*
* Preference order: the `.bin` shim beside the resolved package (same
* spawn semantics as before), then the package's own bin script, then a
* bare `agent-browser` for PATH lookup.
*
* @param fromUrl module URL to resolve from — overridable for tests
*/
function getAgentBrowserPath(): string {
// Try to find agent-browser in node_modules/.bin
// This works whether we're running from src/ or from dist/
const currentFile = import.meta.url;
const currentDir = dirname(fileURLToPath(currentFile));
export function getAgentBrowserPath(fromUrl: string = import.meta.url): string {
try {
const require = createRequire(fromUrl);
const pkgJsonPath = require.resolve("agent-browser/package.json");
const pkgDir = dirname(pkgJsonPath);

// From src/ it's ../node_modules/.bin/agent-browser
// From dist/ it's ../node_modules/.bin/agent-browser
const binPath = join(
currentDir,
"..",
"node_modules",
".bin",
"agent-browser",
);
// The .bin shim lives in the node_modules that contains the package.
const shimPath = join(dirname(pkgDir), ".bin", "agent-browser");
if (existsSync(shimPath)) return shimPath;

const pkg = require(pkgJsonPath) as {
bin?: string | Record<string, string>;
};
const bin =
typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["agent-browser"];
if (bin) {
const binPath = join(pkgDir, bin);
if (existsSync(binPath)) return binPath;
}
} catch {
// Not resolvable as a module — fall through to PATH lookup.
}

// If that doesn't work, fall back to npx
return binPath;
return "agent-browser";
}

/**
Expand Down
94 changes: 94 additions & 0 deletions test/agent-browser-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { getAgentBrowserPath } from "../src/runner.js";

/**
* Regression tests for #15: flowspec hardcoded its own nested
* node_modules/.bin path, which doesn't exist under hoisting package
* managers (bun, npm) — so every flow failed at step 0 when flowspec was
* installed as a dependency.
*/
describe("getAgentBrowserPath", () => {
const tmpDirs: string[] = [];

function makeTmpDir(): string {
// realpath so expectations match module resolution, which returns
// resolved paths (macOS tmpdir lives behind a /var → /private/var symlink)
const dir = realpathSync(mkdtempSync(join(tmpdir(), "flowspec-abr-")));
tmpDirs.push(dir);
return dir;
}

afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});

/** Lay out <root>/node_modules/agent-browser with a bin script. */
function scaffoldPackage(root: string, { withShim }: { withShim: boolean }) {
const pkgDir = join(root, "node_modules", "agent-browser");
mkdirSync(join(pkgDir, "bin"), { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "agent-browser",
version: "0.0.0-test",
bin: { "agent-browser": "./bin/agent-browser.js" },
}),
);
writeFileSync(
join(pkgDir, "bin", "agent-browser.js"),
"#!/usr/bin/env node\n",
);
if (withShim) {
const binDir = join(root, "node_modules", ".bin");
mkdirSync(binDir, { recursive: true });
symlinkSync(
join("..", "agent-browser", "bin", "agent-browser.js"),
join(binDir, "agent-browser"),
);
}
// The module URL resolution starts from — a file at the consumer root,
// as if flowspec code were running from <root>/node_modules/flowspec/.
return pathToFileURL(join(root, "entry.js")).href;
}

it("resolves via the real install in this repo", () => {
const result = getAgentBrowserPath();
expect(result).not.toBe("agent-browser");
expect(result).toContain("agent-browser");
});

it("prefers the .bin shim beside the resolved package (hoisted layout)", () => {
const root = makeTmpDir();
const fromUrl = scaffoldPackage(root, { withShim: true });
expect(getAgentBrowserPath(fromUrl)).toBe(
join(root, "node_modules", ".bin", "agent-browser"),
);
});

it("falls back to the package's own bin script when no shim exists", () => {
const root = makeTmpDir();
const fromUrl = scaffoldPackage(root, { withShim: false });
expect(getAgentBrowserPath(fromUrl)).toBe(
join(root, "node_modules", "agent-browser", "bin", "agent-browser.js"),
);
});

it("falls back to PATH lookup when agent-browser is not resolvable", () => {
const root = makeTmpDir();
const fromUrl = pathToFileURL(join(root, "entry.js")).href;
expect(getAgentBrowserPath(fromUrl)).toBe("agent-browser");
});
});
Loading