Introduce deterministic deep-provider discovery and immutable per-target routing while retaining disjoint artifact and native capability families. Expose provider selection consistently through MCP, CLI, environment configuration, session status, prompt completion, typed diagnostics, and generated documentation. Preserve exact provider/profile provenance without request racing or transparent fallback. Cover overlapping providers, ambiguity, rejection details, cancellation, restoration, package installation, and real Hopper target switching.
151 lines
4.7 KiB
TypeScript
151 lines
4.7 KiB
TypeScript
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import type { BinaryTarget } from "../src/domain/binaryTarget.js";
|
|
import {
|
|
hopperLoaderArgsForTarget,
|
|
resolveHopperAnalysisProfile,
|
|
} from "../src/hopper/HopperAnalysisProfile.js";
|
|
import { HOPPER_PROVIDER_IDENTITY } from "../src/hopper/HopperProvider.js";
|
|
|
|
let directory: string | undefined;
|
|
|
|
afterEach(async () => {
|
|
if (directory !== undefined)
|
|
await rm(directory, { recursive: true, force: true });
|
|
directory = undefined;
|
|
});
|
|
|
|
describe("Hopper analysis profiles", () => {
|
|
it.each([
|
|
[target("mach-o", "arm64"), ["-l", "Mach-O", "--aarch64"]],
|
|
[target("elf", "x86_64"), ["-l", "ELF", "--intel-64"]],
|
|
[target("pe", "x86"), ["-l", "WinPE", "--intel-32"]],
|
|
[
|
|
{
|
|
...target("mach-o", "arm64"),
|
|
availableArchitectures: ["x86_64", "arm64"],
|
|
} satisfies BinaryTarget,
|
|
["-l", "FAT", "--aarch64", "-l", "Mach-O"],
|
|
],
|
|
[
|
|
{
|
|
path: "/tmp/fixture.hop",
|
|
sha256: "b".repeat(64),
|
|
kind: "database",
|
|
format: "analysis-database",
|
|
} satisfies BinaryTarget,
|
|
[],
|
|
],
|
|
] as const)("derives complete non-interactive options", (input, expected) => {
|
|
expect(hopperLoaderArgsForTarget(input)).toEqual({
|
|
ok: true,
|
|
value: expected,
|
|
});
|
|
expect("loaderArgs" in input).toBe(false);
|
|
});
|
|
|
|
it("commits launcher build and configured overrides without target coupling", async () => {
|
|
directory = await mkdtemp(join(tmpdir(), "rea-hopper-profile-"));
|
|
const launcher = join(directory, "hopper");
|
|
await writeFile(launcher, "Hopper build A");
|
|
const input = target("mach-o", "arm64");
|
|
const derived = await resolveHopperAnalysisProfile(input, {
|
|
launcherPath: launcher,
|
|
loaderArgsOverride: [],
|
|
provider: HOPPER_PROVIDER_IDENTITY,
|
|
});
|
|
const overridden = await resolveHopperAnalysisProfile(input, {
|
|
launcherPath: launcher,
|
|
loaderArgsOverride: ["-l", "Mach-O", "--intel-64"],
|
|
provider: HOPPER_PROVIDER_IDENTITY,
|
|
});
|
|
if (
|
|
!derived.ok ||
|
|
derived.value.profile === null ||
|
|
!overridden.ok ||
|
|
overridden.value.profile === null
|
|
)
|
|
throw new Error("fixture profiles did not resolve");
|
|
expect(derived.value.compatibility).toEqual({
|
|
loaderArgs: ["-l", "Mach-O", "--aarch64"],
|
|
});
|
|
expect(overridden.value.compatibility).toEqual({
|
|
loaderArgs: ["-l", "Mach-O", "--intel-64"],
|
|
});
|
|
expect(overridden.value.profile.digest).not.toBe(
|
|
derived.value.profile.digest,
|
|
);
|
|
expect(derived.value.profile.provider.version).toMatch(
|
|
/^launcher-sha256:[a-f0-9]{64}$/u,
|
|
);
|
|
|
|
await writeFile(launcher, "Hopper build B");
|
|
const changedBuild = await resolveHopperAnalysisProfile(input, {
|
|
launcherPath: launcher,
|
|
loaderArgsOverride: [],
|
|
provider: HOPPER_PROVIDER_IDENTITY,
|
|
});
|
|
if (!changedBuild.ok || changedBuild.value.profile === null)
|
|
throw new Error("changed fixture profile did not resolve");
|
|
expect(changedBuild.value.profile.provider.version).not.toBe(
|
|
derived.value.profile.provider.version,
|
|
);
|
|
});
|
|
|
|
it("keeps compatibility output but declines cache identity when version is unresolved", async () => {
|
|
const resolved = await resolveHopperAnalysisProfile(
|
|
target("elf", "x86_64"),
|
|
{
|
|
launcherPath: "/missing/hopper",
|
|
loaderArgsOverride: [],
|
|
provider: HOPPER_PROVIDER_IDENTITY,
|
|
},
|
|
);
|
|
expect(resolved).toEqual({
|
|
ok: true,
|
|
value: {
|
|
profile: null,
|
|
compatibility: {
|
|
loaderArgs: ["-l", "ELF", "--intel-64"],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("stops launcher hashing when profile resolution is cancelled", async () => {
|
|
directory = await mkdtemp(join(tmpdir(), "rea-hopper-profile-cancel-"));
|
|
const launcher = join(directory, "hopper");
|
|
await writeFile(launcher, "Hopper build");
|
|
const controller = new AbortController();
|
|
const resolving = resolveHopperAnalysisProfile(target("elf", "x86_64"), {
|
|
launcherPath: launcher,
|
|
loaderArgsOverride: [],
|
|
provider: HOPPER_PROVIDER_IDENTITY,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
controller.abort();
|
|
|
|
await expect(resolving).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
|
|
});
|
|
});
|
|
});
|
|
|
|
const target = (
|
|
format: "mach-o" | "elf" | "pe",
|
|
architecture: "x86" | "x86_64" | "arm" | "arm64",
|
|
): BinaryTarget => ({
|
|
path: "/tmp/fixture",
|
|
sha256: "a".repeat(64),
|
|
kind: "executable",
|
|
format,
|
|
architecture,
|
|
availableArchitectures: [architecture],
|
|
});
|