rea/tests/productCatalog.test.ts

272 lines
9.3 KiB
TypeScript

import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { z } from "zod";
import { SUPPORTED_CLIENT_DEFINITIONS } from "../src/application/SupportedClients.js";
import {
ARTIFACT_GRAPH_PROVIDER,
JAVASCRIPT_APPLICATION_PROVIDER,
JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER,
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
MANAGED_STATIC_PROVIDER,
MANAGED_WORKFLOW_PROVIDER,
} from "../src/application/InvestigationProviders.js";
import { CDP_BROWSER_PROVIDER_IDENTITY } from "../src/browser/CdpBrowserProvider.js";
import { CDP_ELECTRON_PROVIDER_IDENTITY } from "../src/browser/CdpElectronProvider.js";
import { CLI_COMMAND_NAMES } from "../src/cliCommandNames.js";
import { createCli } from "../src/cli.js";
import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
import { analysisSnapshotSchema } from "../src/domain/analysisSnapshot.js";
import { investigationWorkspaceSchema } from "../src/domain/investigationWorkspace.js";
import {
HOPPER_PROVIDER_IDENTITY,
HOPPER_PROVIDER_TOOL_CONTRACTS,
} from "../src/hopper/HopperProvider.js";
import {
GHIDRA_PROVIDER_IDENTITY,
GHIDRA_PROVIDER_TOOL_CONTRACTS,
} from "../src/ghidra/GhidraProvider.js";
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../src/native/NativeMacOSProvider.js";
import {
assertDocumentationFacts,
documentationFactIssues,
} from "../scripts/lib/docs-facts.mjs";
import { ensureGeneratedFile } from "../scripts/lib/generated-file.mjs";
import {
createCliInventory,
cliCommandDescriptionIssues,
cliCommandOptionNames,
createProductCatalog,
serializeProductCatalog,
} from "../scripts/lib/product-catalog.mjs";
const root = fileURLToPath(new URL("..", import.meta.url));
const temporaryRoots: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryRoots
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
describe("canonical product catalog", () => {
it("matches every source-derived checked-in product fact", async () => {
const catalog = await createProductCatalog(root);
expect(catalog.tools.total).toBe(TOOL_CONTRACTS.length);
expect(
catalog.tools.families.reduce((total, family) => total + family.count, 0),
).toBe(TOOL_CONTRACTS.length);
expect(catalog.setup_clients.map(({ id }) => id)).toEqual(
SUPPORTED_CLIENT_DEFINITIONS.map(({ name }) => name),
);
expect(catalog.cli.commands).toHaveLength(CLI_COMMAND_NAMES.length);
expect(catalog.providers.map(({ id }) => id).sort()).toEqual(
[
HOPPER_PROVIDER_IDENTITY,
GHIDRA_PROVIDER_IDENTITY,
NATIVE_MACOS_PROVIDER_IDENTITY,
ARTIFACT_GRAPH_PROVIDER,
MANAGED_STATIC_PROVIDER,
MANAGED_WORKFLOW_PROVIDER,
CDP_BROWSER_PROVIDER_IDENTITY,
CDP_ELECTRON_PROVIDER_IDENTITY,
JAVASCRIPT_APPLICATION_PROVIDER,
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER,
]
.map(({ id }) => id)
.sort(),
);
expect(
catalog.providers.find(({ id }) => id === HOPPER_PROVIDER_IDENTITY.id)
?.capabilities,
).toEqual(HOPPER_PROVIDER_TOOL_CONTRACTS.map(({ name }) => name).sort());
expect(
catalog.providers.find(({ id }) => id === GHIDRA_PROVIDER_IDENTITY.id)
?.capabilities,
).toEqual(GHIDRA_PROVIDER_TOOL_CONTRACTS.map(({ name }) => name).sort());
expect(
catalog.providers.find(
({ id }) => id === CDP_ELECTRON_PROVIDER_IDENTITY.id,
)?.capabilities,
).toEqual(["inspect_electron_page", "list_electron_targets"]);
expect(
catalog.providers.find(
({ id }) => id === JAVASCRIPT_APPLICATION_PROVIDER.id,
)?.capabilities,
).toEqual(["analyze_javascript_application"]);
expect(
catalog.providers.find(
({ id }) => id === JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER.id,
)?.capabilities,
).toEqual(["reconcile_javascript_runtime"]);
expect(
catalog.providers.find(
({ id }) => id === JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER.id,
)?.capabilities,
).toEqual([
"commit_reconstruction_coverage",
"compare_application_versions",
"execute_node_characterization",
"prepare_node_characterization",
"query_reconstruction_coverage",
"run_controlled_replay",
"trace_application_feature",
]);
expect(
catalog.providers.find(({ id }) => id === MANAGED_WORKFLOW_PROVIDER.id)
?.capabilities,
).toEqual([
"compare_managed_members",
"import_managed_reconstruction",
"plan_managed_runtime_correlation",
"project_managed_application_graph",
"verify_managed_native_boundaries",
]);
expect(
z.toJSONSchema(analysisSnapshotSchema).properties?.snapshot_version,
).toMatchObject({
const: catalog.schemas.find(({ id }) => id === "analysis_snapshot")
?.version,
});
expect(
z.toJSONSchema(investigationWorkspaceSchema).properties
?.workspace_version,
).toMatchObject({
const: catalog.schemas.find(({ id }) => id === "investigation_workspace")
?.version,
});
expect(
JSON.parse(await readFile("docs/product-catalog.json", "utf8")),
).toEqual(catalog);
expect(await serializeProductCatalog(catalog)).toBe(
await readFile("docs/product-catalog.json", "utf8"),
);
await expect(
assertDocumentationFacts(root, catalog),
).resolves.toBeUndefined();
await expect(documentationFactIssues(root, catalog)).resolves.toEqual([]);
}, 15_000);
it("uses the same primary command names as the actual Incur router", () => {
const inventory = createCliInventory(createCli());
expect(inventory.primary).toEqual([...CLI_COMMAND_NAMES].sort());
expect(inventory.aliases).toEqual([
{ name: "compare-bundles", target: "compare" },
]);
});
it("describes every primary command argument and option", () => {
expect(cliCommandDescriptionIssues(createCli())).toEqual([]);
});
it("exposes the shared provider selector on every deep-analysis command", () => {
const cli = createCli();
for (const name of [
"analyze",
"inspect",
"decompile",
"xrefs",
"trace",
"function",
"search",
]) {
expect(cliCommandOptionNames(cli, name)).toContain("provider");
}
});
it("reports tool-family, setup-client, and schema fact drift", async () => {
const catalog = await createProductCatalog(root);
const firstFamily = catalog.tools.families[0];
if (firstFamily === undefined) throw new TypeError("Missing tool family");
const drifted = {
...catalog,
tools: {
...catalog.tools,
families: [
{ ...firstFamily, count: firstFamily.count + 1 },
...catalog.tools.families.slice(1),
],
},
setup_clients: [
...catalog.setup_clients,
{
id: "future_client",
display_name: "Future Client",
format: "json",
configuration: "managed",
},
],
schemas: catalog.schemas.map((schema) =>
schema.id === "process_capture"
? { ...schema, version: Number(schema.version) + 1 }
: schema,
),
};
const issues = await documentationFactIssues(root, drifted);
expect(issues.some((issue) => issue.includes("tool family counts"))).toBe(
true,
);
expect(issues.some((issue) => issue.includes("Future Client"))).toBe(true);
expect(issues.some((issue) => issue.includes("Process Capture v5"))).toBe(
true,
);
});
it("fails check mode without rewriting a stale generated artifact", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-generated-check-"));
temporaryRoots.push(directory);
const path = join(directory, "catalog.json");
await writeFile(path, "stale\n", "utf8");
await expect(
ensureGeneratedFile({
path,
source: "current\n",
check: true,
generateCommand: "npm run docs:generate",
}),
).rejects.toThrow("missing or stale");
expect(await readFile(path, "utf8")).toBe("stale\n");
await expect(
ensureGeneratedFile({
path,
source: "current\n",
check: false,
generateCommand: "npm run docs:generate",
}),
).resolves.toEqual({ changed: true });
expect(await readFile(path, "utf8")).toBe("current\n");
});
it("accepts and preserves native generated-file line endings", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-generated-eol-"));
temporaryRoots.push(directory);
const path = join(directory, "catalog.json");
await writeFile(path, "current\r\n", "utf8");
await expect(
ensureGeneratedFile({
path,
source: "current\n",
check: true,
generateCommand: "npm run docs:generate",
}),
).resolves.toEqual({ changed: false });
expect(await readFile(path, "utf8")).toBe("current\r\n");
await expect(
ensureGeneratedFile({
path,
source: "updated\n",
check: false,
generateCommand: "npm run docs:generate",
}),
).resolves.toEqual({ changed: true });
expect(await readFile(path, "utf8")).toBe("updated\r\n");
});
});