Files
rea/tests/acceptance/applications/javascriptAnalysisLimits.test.ts
c4aaf813e0 fix(javascript): retain selected application paths in Evidence
Record the admitted selected path separately from canonical artifact identity in successful and partial Evidence. Anchor relative inputs consistently across both CLI routes using the existing caller-path owner.

Adapt shared client verifier assertions and boundary coverage from PR #5261, and the dedicated-route anchoring correction proposed in PR #5265. Historical parsing remains independent of live paths, including legacy records without the parameter.

Validation: 118 focused tests in eight files, static and docs checks, discovery, and installed Gemini CLI 0.29.5 loopback verification of 207 schemas/241 examples passed. Synthetic static-analysis fixtures; no live Google API or native target execution.

Fixes #5258

Co-authored-by: kwakhyun <khyun2348@naver.com>
Co-authored-by: vishal-tambi <tambivishal3@gmail.com>
2026-10-20 00:37:45 +08:00

296 lines
9.9 KiB
TypeScript

import type { Client } from "@modelcontextprotocol/client";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { z } from "zod";
import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js";
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const cases = [
{
name: "both limits",
args: ["--max-heap-mb", "128", "--analysis-timeout-ms", "30000"],
controls: { max_heap_mb: 128, analysis_timeout_ms: 30000 },
},
{
name: "heap limit alone",
args: ["--max-heap-mb", "256"],
controls: { max_heap_mb: 256 },
},
{
name: "timeout alone",
args: ["--analysis-timeout-ms", "40000"],
controls: { analysis_timeout_ms: 40000 },
},
{
name: "omitted limits",
args: [],
controls: {},
},
];
cliTest.for(cases)(
"preserves $name in CLI Evidence without adding ambient defaults",
async ({ args, controls }, { cli }) => {
const root = await createTestTempDirectory("rea-js-limit-provenance-");
await writeFile(join(root, "main.js"), "export const observed = 7;\n");
const response = await cli.run({
arguments: [
"analyze-javascript-application",
root,
...args,
"--format",
"json",
],
environment: { NODE_OPTIONS: "--max-old-space-size=512" },
});
expect(response.exitCode, response.stdout + response.stderr).toBe(0);
const evidence = parseEvidence(response.json);
expect(evidence.parameters).toEqual({
input_path: root,
format: "auto",
integrity_policy: "fail",
...controls,
});
},
);
it("preserves MCP analysis limits in retained Evidence and an exported bundle round trip", async () => {
const root = await createTestTempDirectory("rea-js-limit-mcp-");
await writeFile(join(root, "main.js"), "export const observed = 7;\n");
const bundleRoot = await createTestTempDirectory("rea-js-limit-bundle-");
const path = join(bundleRoot, "evidence.json");
const { client } = await connectLocalToolsMcp({
NODE_OPTIONS: "--max-old-space-size=512",
});
const expected = new Map<string, unknown>();
for (const { controls } of cases) {
const response = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root, ...controls, detail: "summary" },
});
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
const { parent_evidence_id } = z
.object({
normalized_result: z.object({ parent_evidence_id: z.string() }),
})
.parse(response.structuredContent).normalized_result;
expected.set(parent_evidence_id, {
input_path: root,
format: "auto",
integrity_policy: "fail",
...controls,
});
}
expect(expected.size).toBe(cases.length);
const retained = await getBundle(client);
for (const [evidence_id, parameters] of expected)
expect(
retained.records.find((record) => record.evidence_id === evidence_id)
?.parameters,
).toEqual(parameters);
const exported = await client.callTool({
name: "export_evidence_bundle",
arguments: { path },
});
expect(exported.isError).not.toBe(true);
expect(parseEvidenceBundle(JSON.parse(await readFile(path, "utf8")))).toEqual(
retained,
);
const closed = await client.callTool({
name: "close_binary",
arguments: {},
});
expect(closed.isError).not.toBe(true);
expect((await getBundle(client)).records).toEqual([]);
const imported = await client.callTool({
name: "import_evidence_bundle",
arguments: { path },
});
expect(imported.isError, JSON.stringify(imported.content)).not.toBe(true);
expect(await getBundle(client)).toEqual(retained);
}, 60_000);
cliTest(
"retains caller limits and applicable file scopes after an actual analysis timeout",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-js-limit-timeout-");
const sourcePaths = [
"a.js",
"a.js.map",
"data.json",
"index.html",
"package.json",
"z.node",
];
await Promise.all(
(
[
["a.js", "export const observed = 7;\n"],
[
"a.js.map",
JSON.stringify({
version: 3,
sources: [],
names: [],
mappings: "",
}),
],
["data.json", '{"observed":7}'],
["index.html", '<script src="./a.js"></script>'],
["package.json", '{"name":"file-scope-control","main":"a.js"}'],
["z-excluded.js", "export const excluded = true;\n"],
["z.node", Buffer.from([0, 1, 2, 3])],
["zz-excluded.node", Buffer.from([3, 2, 1, 0])],
] as const
).map(([path, content]) => writeFile(join(root, path), content)),
);
const { client } = await connectLocalToolsMcp({
NODE_OPTIONS: "--max-old-space-size=512",
});
const response = await client.callTool({
name: "analyze_javascript_application",
arguments: {
input_path: root,
max_heap_mb: 128,
analysis_timeout_ms: 1,
source_paths: sourcePaths,
},
});
expect(parseMcpToolError(response)).toMatchObject({
error: { code: "provider_timeout" },
});
const bundle = await getBundle(client);
const partial = bundle.records.find(
(record) => record.operation === "analyze_javascript_application",
);
expect(partial?.parameters).toEqual({
input_path: root,
format: "auto",
integrity_policy: "fail",
max_heap_mb: 128,
analysis_timeout_ms: 1,
source_paths: sourcePaths,
});
if (partial === undefined)
throw new Error("Timeout Evidence was not retained");
const cliArguments = [
"analyze-javascript-application",
root,
"--max-heap-mb",
"128",
...sourcePaths.flatMap((path) => ["--source-path", path]),
"--format",
"json",
];
const cliTimeout = await cli.run({
arguments: [...cliArguments, "--analysis-timeout-ms", "1"],
});
expect(cliTimeout.exitCode).toBe(1);
const cliFailure = z
.object({
code: z.literal("provider_timeout"),
details: z.object({ partial_observation: z.unknown() }),
})
.parse(cliTimeout.json);
const cliPartial = parseEvidence(cliFailure.details.partial_observation);
const control = await client.callTool({
name: "analyze_javascript_application",
arguments: {
input_path: root,
max_heap_mb: 128,
source_paths: sourcePaths,
},
});
expect(control.isError).not.toBe(true);
const controlEvidence = parseEvidence(control.structuredContent);
const cliControl = await cli.run({ arguments: cliArguments });
expect(cliControl.exitCode, cliControl.stderr).toBe(0);
const cliControlEvidence = parseEvidence(cliControl.json);
expect(cliControlEvidence.evidence_id).toBe(controlEvidence.evidence_id);
// Observe both adapters and successful controls before testing applicability.
for (const [evidence, interrupted] of [
[partial, true],
[cliPartial, true],
[controlEvidence, false],
[cliControlEvidence, false],
] as const)
expectFileAnalysisApplicability(evidence, interrupted);
await client.ping();
},
60_000,
);
async function getBundle(client: Client) {
const response = await client.callTool({
name: "get_evidence_bundle",
arguments: { detail: "complete" },
});
expect(response.isError).not.toBe(true);
return parseEvidenceBundle(
z.object({ result: z.unknown() }).parse(response.structuredContent).result,
);
}
function expectFileAnalysisApplicability(
evidence: Evidence,
interrupted: boolean,
) {
const result = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
);
const scopeObservations = result.graph.nodes
.filter(({ kind }) => kind === "unknown")
.flatMap(({ observations }) => observations);
const scopes = (path: string) =>
scopeObservations.filter(({ properties }) => properties.path === path);
expect(result.graph.nodes.some(({ kind }) => kind === "native-addon")).toBe(
true,
);
for (const path of ["z.node", "zz-excluded.node"])
expect(scopes(path), path).toEqual([]);
const excluded = scopes("z-excluded.js");
expect(excluded).toHaveLength(1);
expect(excluded[0]).toMatchObject({
properties: { operation: "select-javascript-sources" },
evidence: {
authority: "ast-static-analysis",
state: "unavailable",
confidence: "unknown",
},
});
expect(excluded[0]?.properties).not.toHaveProperty("analysis_failure");
for (const [path, operation] of [
["a.js", "analyze-javascript-source"],
["a.js.map", "parse-local-source-map"],
["data.json", "parse-json-module"],
["index.html", "parse-html-script-references"],
["package.json", "parse-package-json"],
] as const) {
if (!interrupted) expect(scopes(path), path).toEqual([]);
else {
expect(scopes(path), path).toHaveLength(1);
expect(scopes(path)[0]).toMatchObject({
properties: {
operation,
analysis_status: path === "a.js" ? "interrupted" : "not-started",
},
evidence: {
authority: path === "a.js" ? "ast-static-analysis" : "artifact-bytes",
state: "unavailable",
confidence: "unknown",
coverage: { status: "partial" },
},
});
}
}
if (interrupted) expect(result.graph.coverage.status).toBe("partial");
else expect(result.statistics.parsed_javascript_files).toBe(1);
}