fix(hopper): select verified Linux demo mode explicitly
This commit is contained in:
parent
000d4f5e56
commit
e27f635b73
|
|
@ -40,15 +40,25 @@ export interface BridgeLauncher {
|
|||
): Promise<Result<BridgeLaunch, HopperStartError | HopperCancelledError>>;
|
||||
}
|
||||
|
||||
export interface HopperApplicationLauncherOptions {
|
||||
interface SharedHopperApplicationLauncherOptions {
|
||||
readonly launcherPath: string;
|
||||
readonly targetPath: string;
|
||||
readonly targetKind: "executable" | "database";
|
||||
readonly loaderArgs: readonly string[];
|
||||
readonly bridgeScriptPath: string;
|
||||
readonly demoHelperPath?: string;
|
||||
}
|
||||
|
||||
/** Explicit launcher contract; the Linux adapter verifies its pinned Hopper build before execution. */
|
||||
export type HopperApplicationLauncherOptions =
|
||||
| (SharedHopperApplicationLauncherOptions & {
|
||||
readonly launchMode: "native";
|
||||
readonly demoHelperPath?: never;
|
||||
})
|
||||
| (SharedHopperApplicationLauncherOptions & {
|
||||
readonly launchMode: "verified_linux_demo";
|
||||
readonly demoHelperPath: string;
|
||||
});
|
||||
|
||||
/**
|
||||
* Launches Hopper through its documented CLI and injects only REA's owned bridge.
|
||||
*
|
||||
|
|
@ -186,8 +196,13 @@ const linuxDemoLaunch = (
|
|||
};
|
||||
};
|
||||
|
||||
const usesLinuxDemo = (options: HopperApplicationLauncherOptions): boolean =>
|
||||
process.platform === "linux" && options.demoHelperPath !== undefined;
|
||||
/** Select the version-pinned adapter from the caller's explicit launch mode. */
|
||||
export const usesLinuxDemo = (
|
||||
options: HopperApplicationLauncherOptions,
|
||||
): options is HopperApplicationLauncherOptions & {
|
||||
readonly launchMode: "verified_linux_demo";
|
||||
readonly demoHelperPath: string;
|
||||
} => options.launchMode === "verified_linux_demo";
|
||||
|
||||
const spawnLauncher = async (
|
||||
command: string,
|
||||
|
|
|
|||
|
|
@ -114,9 +114,14 @@ export class HopperProvider implements AnalysisProvider {
|
|||
bridgeScriptPath: fileURLToPath(
|
||||
new URL("../../bridge/hopper_bridge.py", import.meta.url),
|
||||
),
|
||||
demoHelperPath: fileURLToPath(
|
||||
new URL("../../scripts/hopper-demo-x11.py", import.meta.url),
|
||||
),
|
||||
...(process.platform === "linux"
|
||||
? {
|
||||
launchMode: "verified_linux_demo" as const,
|
||||
demoHelperPath: fileURLToPath(
|
||||
new URL("../../scripts/hopper-demo-x11.py", import.meta.url),
|
||||
),
|
||||
}
|
||||
: { launchMode: "native" as const }),
|
||||
}),
|
||||
logger: this.logger.child({ layer: "bridge" }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,57 +1,85 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { access, chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { HopperApplicationLauncher } from "../src/hopper/BridgeLauncher.js";
|
||||
import {
|
||||
type HopperApplicationLauncherOptions,
|
||||
usesLinuxDemo,
|
||||
} from "../src/hopper/BridgeLauncher.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
const demoHelperPath = fileURLToPath(
|
||||
new URL("../scripts/hopper-demo-x11.py", import.meta.url),
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories
|
||||
.splice(0)
|
||||
.map((path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
const options = (launcherPath: string): HopperApplicationLauncherOptions => ({
|
||||
launcherPath,
|
||||
targetPath: "/target",
|
||||
targetKind: "executable",
|
||||
loaderArgs: [],
|
||||
bridgeScriptPath: "/rea/hopper_bridge.py",
|
||||
launchMode: "verified_linux_demo",
|
||||
demoHelperPath: "/rea/hopper-demo-x11.py",
|
||||
});
|
||||
|
||||
describe("Hopper application launcher", () => {
|
||||
it.skipIf(process.platform !== "linux")(
|
||||
"uses the Linux demo helper for a non-canonical launcher path",
|
||||
async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-launcher-test-"));
|
||||
directories.push(directory);
|
||||
const helper = join(directory, "demo-helper.py");
|
||||
await writeFile(helper, "import sys\nsys.exit(0)\n");
|
||||
const launcher = new HopperApplicationLauncher({
|
||||
launcherPath: "/usr/bin/true",
|
||||
targetPath: "/tmp/fixture",
|
||||
targetKind: "executable",
|
||||
loaderArgs: [],
|
||||
bridgeScriptPath: "/tmp/hopper_bridge.py",
|
||||
demoHelperPath: helper,
|
||||
});
|
||||
|
||||
const launched = await launcher.launch({
|
||||
directory,
|
||||
socketPath: join(directory, "bridge.sock"),
|
||||
token: "token",
|
||||
runId: "run-wrapper",
|
||||
});
|
||||
|
||||
expect(launched.ok).toBe(true);
|
||||
if (!launched.ok) return;
|
||||
expect(launched.value.process.spawnfile).toBe("/usr/bin/python3");
|
||||
expect(launched.value.process.spawnargs).toContain(helper);
|
||||
expect(launched.value.shutdownByCleanup).toBe(true);
|
||||
expect(await readFile(join(directory, "bootstrap.py"), "utf8")).toContain(
|
||||
"REA_OWNS_PROCESS_LIFETIME = True",
|
||||
);
|
||||
if (launched.value.process.exitCode === null)
|
||||
await new Promise<void>((resolve) => {
|
||||
launched.value.process.once("exit", () => resolve());
|
||||
});
|
||||
describe("Hopper bridge launcher selection", () => {
|
||||
it.each([
|
||||
"/opt/hopper/bin/Hopper",
|
||||
"/usr/local/bin/hopper",
|
||||
"/workspace/bin/hopper-wrapper",
|
||||
])(
|
||||
"routes launcher spelling %s through the explicitly selected pinned adapter",
|
||||
(path) => {
|
||||
expect(usesLinuxDemo(options(path))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not infer demo behavior from a native launcher's basename", () => {
|
||||
expect(
|
||||
usesLinuxDemo({
|
||||
launcherPath: "/opt/hopper/bin/Hopper",
|
||||
targetPath: "/target",
|
||||
targetKind: "executable",
|
||||
loaderArgs: [],
|
||||
bridgeScriptPath: "/rea/hopper_bridge.py",
|
||||
launchMode: "native",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unpinned wrapper before executing it", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-hopper-wrapper-"));
|
||||
const wrapper = join(directory, "hopper-wrapper");
|
||||
const marker = join(directory, "executed");
|
||||
try {
|
||||
await writeFile(wrapper, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`);
|
||||
await chmod(wrapper, 0o700);
|
||||
await expect(
|
||||
execFileAsync(
|
||||
"python3",
|
||||
[
|
||||
demoHelperPath,
|
||||
"--hopper",
|
||||
wrapper,
|
||||
"--socket",
|
||||
join(directory, "bridge.sock"),
|
||||
"--",
|
||||
wrapper,
|
||||
],
|
||||
{ timeout: 3_000 },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 72,
|
||||
stderr: expect.stringContaining("unsupported Hopper binary"),
|
||||
});
|
||||
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue