diff --git a/src/cliBrowserContext.ts b/src/cliBrowserContext.ts index b45282dcd..f7cf9b1a0 100644 --- a/src/cliBrowserContext.ts +++ b/src/cliBrowserContext.ts @@ -7,9 +7,20 @@ import { } from "./domain/errors.js"; import type { JsonValue } from "./domain/jsonValue.js"; -/** Load the configured passive browser authority and provider for one CLI operation. */ -export const browserContext = async (operation: string) => { - const config = parseConfig(process.env); +/** + * Load the configured passive browser authority and provider for one CLI + * operation. + * + * `environment` defaults to the process environment so CLI callers stay + * unchanged, but every caller may supply the environment explicitly. That is + * what makes configuration-driven behaviour testable without mutating + * `process.env` for the duration of a test. + */ +export const browserContext = async ( + operation: string, + environment: Readonly> = process.env, +) => { + const config = parseConfig(environment); if (!config.ok) return { ok: false as const, error: browserCliError(config.error) }; const policy = config.value.browserObservationPolicy; diff --git a/src/cliHostInputs.test.ts b/src/cliHostInputs.test.ts new file mode 100644 index 000000000..7aa90c9fc --- /dev/null +++ b/src/cliHostInputs.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { browserContext } from "./cliBrowserContext.js"; +import { HopperProvider } from "./hopper/HopperProvider.js"; +import { silentLogger } from "./logger.js"; +import { parseConfig } from "./config.js"; + +const config = (env: Record = {}) => { + const parsed = parseConfig(env); + if (!parsed.ok) throw new Error("expected valid configuration"); + return parsed.value; +}; + +describe("host platform is injected, not read from the ambient process", () => { + it("reports Hopper as unsupported for a platform it cannot run on", () => { + const provider = new HopperProvider(config(), silentLogger, "win32"); + const availability = provider.inspectAvailability(); + expect(availability).toMatchObject({ + status: "unavailable", + code: "unsupported_host", + }); + expect(JSON.stringify(availability)).toContain("win32"); + }); + + it.each(["darwin", "linux"] as const)( + "does not gate Hopper as unsupported on %s", + (platform) => { + const provider = new HopperProvider( + config({ hopper_launcher_path: "/nonexistent/hopper-launcher" }), + silentLogger, + platform, + ); + expect(provider.inspectAvailability().code).not.toBe("unsupported_host"); + }, + ); +}); + +describe("configuration is read from an injected environment", () => { + it("lets the supplied environment decide whether the operation is available", async () => { + // The error projection deliberately redacts specifics, so the meaningful + // signal is the outcome: the same helper fails or succeeds purely as a + // function of the environment it was handed. + const disabled = await browserContext("inspect_web_page", {}); + expect(disabled.ok).toBe(false); + + const enabled = await browserContext("inspect_web_page", { + REA_BROWSER_OBSERVE_ENABLED: "true", + REA_BROWSER_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]', + REA_BROWSER_ALLOWED_ORIGINS_JSON: '["https://app.example.test"]', + }); + expect(enabled.ok).toBe(true); + }); + + it("reports a configuration error from the supplied environment", async () => { + const invalid = await browserContext("inspect_web_page", { + REA_BROWSER_ALLOWED_ORIGINS: "https://example.test", + REA_BROWSER_MAX_BYTES: "not-a-number", + }); + expect(invalid.ok).toBe(false); + if (invalid.ok) return; + // Projected through the shared analysis-error contract, like every other + // CLI failure path. + expect(JSON.stringify(invalid.error)).toContain("code"); + }); +}); diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index 28882dc5d..63f71ab53 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -84,9 +84,16 @@ const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( /** Concrete analysis provider backed by REA's private Hopper bridge. */ export class HopperProvider implements AnalysisProviderCandidate { + /** + * Host platform, injected so availability and launch-mode decisions can be + * exercised for any host rather than only the machine running the suite. + * Mirrors `GhidraProvider`, which already threads its installation host's + * platform the same way. + */ constructor( private readonly config: AppConfig, private readonly logger: Logger, + private readonly platform: NodeJS.Platform = process.platform, ) {} identity(): ProviderIdentity { @@ -100,13 +107,13 @@ export class HopperProvider implements AnalysisProviderCandidate { inspectAvailability(): ProviderAvailability { const diagnostics = { launcher_path: this.config.hopperLauncherPath, - platform: process.platform, + platform: this.platform, }; - if (process.platform !== "darwin" && process.platform !== "linux") + if (this.platform !== "darwin" && this.platform !== "linux") return { status: "unavailable", code: "unsupported_host", - reason: `Hopper integration is not supported on ${process.platform}.`, + reason: `Hopper integration is not supported on ${this.platform}.`, diagnostics, }; try { @@ -205,7 +212,7 @@ export class HopperProvider implements AnalysisProviderCandidate { bridgeScriptPath: fileURLToPath( new URL("../../bridge/hopper_bridge.py", import.meta.url), ), - ...(process.platform === "linux" + ...(this.platform === "linux" ? { launchMode: "verified_linux_demo" as const, demoHelperPath: fileURLToPath(