rea/tests/boundary/filesystem/productCatalog.test.ts

286 lines
9.5 KiB
TypeScript

import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { SUPPORTED_CLIENT_DEFINITIONS } from "../../../src/application/SupportedClients.js";
import { CLI_COMMAND_NAMES } from "../../../src/cliCommandNames.js";
import { createCli } from "../../../src/cli.js";
import { FRIDA_PROVIDER_IDENTITY } from "../../../src/frida/FridaInstrumentationManager.js";
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
import {
HOPPER_OPERATIONS,
HOPPER_PROVIDER_IDENTITY,
} from "../../../src/hopper/HopperProviderCapabilities.js";
import {
GHIDRA_PROVIDER_IDENTITY,
GHIDRA_OPERATIONS,
} from "../../../src/ghidra/GhidraProviderCapabilities.js";
import {
documentationFactIssues,
skillReferenceIssues,
} from "../../../scripts/lib/docs-facts.mjs";
import { ensureGeneratedFile } from "../../../scripts/lib/generated-file.mjs";
import {
createCliInventory,
cliCommandDescriptionIssues,
cliCommandOptionNames,
} from "../../../scripts/lib/catalog-cli.mjs";
import {
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("admits every documented browser scenario through the named contract", async () => {
const guide = await readFile(
join(root, "docs/browser-scenario-contract.md"),
"utf8",
);
const examples = [
...guide.matchAll(/```json\r?\n([\s\S]*?)\r?\n```/gu),
].map((match) => match[1] ?? "");
expect(examples.length).toBeGreaterThanOrEqual(2);
const contract = TOOL_CONTRACTS.find(
({ name }) => name === "capture_browser_scenario",
);
if (contract === undefined)
throw new Error("Missing browser scenario contract");
for (const example of examples)
expect(contract.inputSchema.safeParse(JSON.parse(example)).success).toBe(
true,
);
});
it("matches every source-derived build-generated product fact", async () => {
const catalog = await createProductCatalog(root);
expect(catalog.setup_clients.map(({ id }) => id)).toEqual(
SUPPORTED_CLIENT_DEFINITIONS.map(({ name }) => name),
);
expect(
catalog.providers.find(({ id }) => id === HOPPER_PROVIDER_IDENTITY.id)
?.capabilities,
).toEqual([...HOPPER_OPERATIONS].sort());
expect(
catalog.providers.find(({ id }) => id === GHIDRA_PROVIDER_IDENTITY.id)
?.capabilities,
).toEqual([...GHIDRA_OPERATIONS].sort());
expect(
catalog.providers.find(({ id }) => id === FRIDA_PROVIDER_IDENTITY.id)
?.capabilities,
).toEqual(
TOOL_CONTRACTS.filter(({ name }) => name.includes("frida"))
.map(({ name }) => name)
.sort(),
);
expect(
JSON.parse(await readFile("docs/public/product-catalog.json", "utf8")),
).toEqual(catalog);
expect(await serializeProductCatalog(catalog)).toBe(
await readFile("docs/public/product-catalog.json", "utf8"),
);
await expect(documentationFactIssues(root, catalog)).resolves.toEqual([]);
}, 60_000);
});
describe("canonical CLI catalog", () => {
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" },
]);
}, 30_000);
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",
"annotate-native-function",
"inspect-native-api",
"search",
]) {
expect(cliCommandOptionNames(cli, name)).toContain("provider");
}
});
it("does not impose a private result limit on feature tracing", () => {
expect(cliCommandOptionNames(createCli(), "trace")).not.toContain("limit");
});
});
describe("canonical product catalog drift", () => {
it("rejects missing and repository-only links in an independently installed skill", async () => {
const directory = await createTestTempDirectory("rea-skill-references-");
const bundle = join(directory, "skill");
await mkdir(bundle);
await writeFile(join(directory, "repo-only.md"), "Repository-only guide");
await writeFile(join(bundle, "local.md"), "Bundled guide");
await writeFile(
join(bundle, "SKILL.md"),
[
"[bundled](local.md#guide)",
"[missing](missing.md)",
"[repository-only](../repo-only.md)",
"[public](https://example.test/guide)",
].join("\n"),
);
expect(await skillReferenceIssues(bundle)).toEqual([
"SKILL.md: missing skill reference: missing.md",
"SKILL.md: reference escapes installed skill bundle: ../repo-only.md",
]);
});
it("requires setup-client facts in the canonical installation guide", async () => {
const catalog = await createProductCatalog(root);
const drifted = {
...catalog,
setup_clients: [
...catalog.setup_clients,
{
id: "future_client",
display_name: "Future Client",
format: "json",
configuration: "managed",
},
],
};
const issues = await documentationFactIssues(root, drifted);
expect(issues).toContain("docs/installation.md: missing Future Client");
expect(issues).not.toContain("README.md: missing Future Client");
});
it("keeps translated readmes linked to canonical setup and tool details", async () => {
const directory = await createTestTempDirectory("rea-readme-facts-");
temporaryRoots.push(directory);
const paths = [
"README.md",
"README_zh.md",
"README_ja.md",
"README_ko.md",
"README_ar.md",
"website/locales/languages.json",
"docs/installation.md",
"AGENTS.md",
".github/pull_request_template.md",
];
for (const path of paths) {
const destination = join(directory, path);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(root, path), destination);
}
await mkdir(join(directory, "skills/reverse-engineer-anything"), {
recursive: true,
});
const translatedPath = join(directory, "README_zh.md");
const translated = await readFile(translatedPath, "utf8");
const catalog = await createProductCatalog(root);
await expect(documentationFactIssues(directory, catalog)).resolves.toEqual(
[],
);
for (const [original, changed] of [
[
"(docs/installation.md#supported-agents)",
"(docs/installation.md#missing-agents)",
],
[
"https://rea.tools/zh-CN/guides/native/",
"https://rea.tools/zh-CN/missing/",
],
[
"https://rea.tools/zh-CN/guides/native/",
"https://rea.tools/zh-TW/guides/native/",
],
[
"https://rea.tools/zh-CN/guides/native/",
"https://rea.tools/zh-CN/guides/native/?other=1",
],
] as const) {
expect(translated).toContain(original);
await writeFile(
translatedPath,
translated.replace(original, changed),
"utf8",
);
await expect(
documentationFactIssues(directory, catalog),
).resolves.toEqual([
"README_zh.md: documentation links differ from README.md",
]);
}
});
it("fails check mode without rewriting a stale generated artifact", async () => {
const directory = await createTestTempDirectory("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 createTestTempDirectory("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");
});
});