Files
reverse-engineering-anything/tests/acceptance/tools/qlogCapture.test.ts
T
291059adb4 feat(analysis-view): project an environment manifest from web network captures (#4954)
* feat(analysis-view): project an environment manifest from web network captures

Add the environment-manifest view kind to inspect_analysis_view for retained
HAR Evidence: the primary origin's recorded request lines become a
deterministic tool surface (names, path templates, parameters with their
rule and basis, observed query names, value-free body shapes, record
pointers), with excluded groups, three fixed unknowns and the shared
projection budget. The four format rules always apply; after-uid-literal,
opaque-token and sibling-variance are opt-in and labelled, and literal
fold-in never touches a format-rule parameter. The api-surface accumulator
moves onto shared route, media-type and budget helpers without behaviour
change.

Refs morluto/rea#4933

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(web-network): check the manifest refusal text like the api-surface refusal

The real-capture verifier stringified the whole MCP result for the
environment-manifest refusal, so a declared "[" literal matched the result
envelope itself; compare the parsed refusal as the api-surface branch does.

Refs morluto/rea#4933

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(specs): record the hosted web-network lane run for the manifest view

Refs morluto/rea#4933

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(specs): reconcile the manifest view verification scope note with the hosted run

Refs morluto/rea#4933

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): bound body, route and name work in the environment manifest

Addresses the automated review findings on the PR: read request bodies at the
top level only instead of walking nested values; admit every literal route
part before materializing it; compare at most 32 segments in sibling-variance
and say so; keep stop words literal after a uid literal; keep the next
collision suffix per tool-name base; preserve a templated exclusion when two
groups print the same row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): keep manifest folding, naming and media-type reads linear

Addresses the second automated review round: fold literal siblings through
set membership, key collision suffixes by the truncated name they share, and
charge a request media type before it is scanned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): charge the manifest group key before building it

Addresses the third automated review round: the composite key copies the
method, origin, template and rules, so that copy is reserved before the key
is stringified.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): accept a +json suffix only on application media types

Addresses the fourth automated review round: `image/example+json` or
`text/example+json` no longer counts as JSON for origin selection, exclusion
or body shapes, matching the documented `application/json` and
`application/*+json` contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): require the capture parent's exact subject for a manifest

Addresses the fifth automated review round: the manifest cites the report
path and digest, so a parent whose subject is absent or names another path or
SHA-256 is refused at admission, as the capture view already does; the
api-surface view keeps its existing admission.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): share truncated suffix allocation work

Track suffix progress by the actual truncated prefix and suffix width. Distinct full names can collide only after suffix truncation; sharing that progress prevents quadratic rescanning while preserving shorter free names and existing allocation order.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(analysis-view): merge only identifier-like siblings under sibling-variance

Addresses the sixth automated review finding: a bucket merged whenever one
literal carried a digit, folding plain words such as `mine` into the
parameter. The rule now partitions the bucket, merges the identifier-like
siblings and leaves the other literals as their own tools.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(analysis-view): combine qualifying sibling cohorts

Keep coded identifiers and a qualifying plural hyphenated cohort in one eligibility Set before checking the minimum merge size. Read each literal once and reuse the same membership at the consumer, preserving non-identifier routes and the three-hyphen minimum.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* docs(analysis-view): clarify opt-in sibling folding

State the additional sibling-variance selection in the guide, acceptance specification and MCP parameter description. Preserve the existing single-rule behavior and caller-selected inference rules.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-19 07:07:37 +08:00

460 lines
14 KiB
TypeScript

import { execFile } from "node:child_process";
import { writeFile, truncate, rm } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it as runtimeTest } from "vitest";
import {
NODE_CAPTURE_HEAP_ENFORCED,
NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
} from "../../../src/browser/history/CaptureRelease.js";
import { webNetworkCaptureSummaryViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import {
webNetworkCaptureSchema,
WEB_NETWORK_CAPTURE_LIMITS,
} from "../../../src/domain/webNetworkCapture.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const hasMemoryBound =
NODE_CAPTURE_HEAP_ENFORCED ||
NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null;
const it = runtimeTest.skipIf(!hasMemoryBound);
const cli = fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url));
const event = {
name: "transport:packet_sent",
time: 1791819895000.25,
data: {
header: { packet_type: "1RTT", packet_number: 5 },
frames: [
{ frame_type: "stream", stream_id: 0, offset: 10, length: 5, fin: true },
],
},
};
const qlog = (events: unknown[]) =>
JSON.stringify({
qlog_format: "JSON",
qlog_version: "0.3",
traces: [
{
vantage_point: { name: "aioquic", type: "server" },
common_fields: { ODCID: "trace-local" },
events,
},
],
});
const assertAdditionalQlogFacets = async (
call: Awaited<ReturnType<typeof connectLocalToolsMcp>>["call"],
retained: { kind: string; evidence_id: string },
): Promise<void> => {
for (const [view, expected] of [
[
{ kind: "qlog", facet: "traces", offset: 1, limit: 1 },
[{ trace_index: 1, event_count: 1, stream_count: 1 }],
],
[
{ kind: "qlog", facet: "loss-declarations", trace_index: 0 },
[{ record_ordinal: 1, packet_number: { state: "reported", value: "5" } }],
],
[{ kind: "qlog", facet: "incomplete-records", trace_index: 0 }, []],
[
{
kind: "qlog",
facet: "ranges",
trace_index: 0,
stream_id: "0",
direction: "sent",
},
[{ start: "10", end: "15" }],
],
[
{
kind: "qlog",
facet: "final-sizes",
trace_index: 0,
stream_id: "0",
direction: "sent",
},
["15"],
],
[{ kind: "qlog", facet: "traces", offset: 99 }, []],
] as const) {
const response = await call("inspect_analysis_view", {
source: retained,
view,
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(
parseEvidence(response.structuredContent).normalized_result,
).toMatchObject({
page: { items: expected },
coverage: { exhausted: true },
});
}
};
it("pages original trace-scoped facts after source deletion without conflating selected events or identical stream IDs", async () => {
const root = await createTestTempDirectory("rea-qlog-pages-");
const path = join(root, "input.qlog");
const source = JSON.parse(
qlog([
event,
{
name: "recovery:packet_lost",
time: 1791819895001,
data: { packet_number: 5, type: "1RTT" },
},
]),
);
source.traces.push({
...source.traces[0],
events: [
{
...event,
data: {
frames: [
{
frame_type: "stream",
stream_id: 0,
offset: 100,
length: 3,
fin: true,
},
],
},
},
],
});
await writeFile(path, JSON.stringify(source));
const { call } = await connectLocalToolsMcp();
const response = await call("inspect_web_network_capture", {
capture_path: path,
format: "qlog",
detail: "summary",
record_ordinals: [2],
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const summary = webNetworkCaptureSummaryViewResultSchema.parse(
parseEvidence(response.structuredContent).normalized_result,
);
expect(summary).toMatchObject({
summary: {
total_records: 3,
retained_records: 1,
qlog: {
trace_count: 2,
retained_stream_count: 2,
loss_declaration_count: 1,
},
},
});
expect(JSON.stringify(summary)).not.toContain('"contributions"');
if (
!("parent_evidence_id" in summary) ||
typeof summary.parent_evidence_id !== "string"
)
throw new Error("Missing retained parent");
const retained = {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
};
const parent = parseEvidence(
(
await call("inspect_web_network_capture", {
capture_path: path,
format: "qlog",
record_ordinals: [2],
})
).structuredContent,
);
expect(parent.evidence_id).toBe(summary.parent_evidence_id);
const portablePath = join(root, "parent.json");
await rm(path);
const pages = [];
for (const trace_index of [0, 1]) {
const page = await call("inspect_analysis_view", {
source: retained,
view: { kind: "qlog", facet: "streams", trace_index, limit: 1 },
});
expect(page.isError, JSON.stringify(page)).not.toBe(true);
pages.push(parseEvidence(page.structuredContent).normalized_result);
}
expect(pages[0]).toMatchObject({
page: { items: [{ stream_id: "0", unique_bytes: "5" }] },
context: { scope: "all-recorded-events-before-selection" },
});
expect(pages[1]).toMatchObject({
page: { items: [{ stream_id: "0", unique_bytes: "3" }] },
});
await assertAdditionalQlogFacets(call, retained);
const view = {
kind: "qlog",
facet: "contributions",
trace_index: 0,
stream_id: "0",
direction: "sent",
offset: 0,
limit: 1,
};
const contribution = parseEvidence(
(await call("inspect_analysis_view", { source: retained, view }))
.structuredContent,
);
expect(contribution.normalized_result).toMatchObject({
page: {
items: [{ record_ordinal: 0, frame_index: 0, offset: "10", length: "5" }],
},
coverage: { total: 1, exhausted: true },
});
await writeFile(
portablePath,
JSON.stringify({ source: { kind: "inline", evidence: parent }, view }),
);
const cliResult = await execute(process.execPath, [
cli,
"inspect-analysis-view",
portablePath,
"--json",
]);
expect(parseEvidence(JSON.parse(cliResult.stdout)).normalized_result).toEqual(
contribution.normalized_result,
);
for (const invalid of [
{ kind: "qlog", facet: "streams" },
{ kind: "qlog", facet: "streams", trace_index: 99 },
{ ...view, stream_id: "99" },
{ kind: "qlog", facet: "traces", trace_index: 0 },
])
expect(
parseMcpToolError(
await call("inspect_analysis_view", {
source: retained,
view: invalid,
}),
),
).toMatchObject({ error: { code: "invalid_request" } });
}, 60_000);
runtimeTest.runIf(!hasMemoryBound)(
"refuses qlog when this runtime cannot enforce a decoder memory bound",
async () => {
const { call } = await connectLocalToolsMcp();
const root = await createTestTempDirectory("rea-qlog-unbounded-");
const path = join(root, "input.qlog");
await writeFile(path, qlog([event]));
expect(
parseMcpToolError(
await call("inspect_web_network_capture", {
capture_path: path,
format: "qlog",
}),
),
).toMatchObject({ error: { code: "capability_unavailable" } });
},
);
it("runs built CLI and real stdio SDK with exact evidence parity, selection, retained references and strict schemas", async () => {
const root = await createTestTempDirectory("rea-qlog-public-");
const path = join(root, "input.qlog");
await writeFile(
path,
qlog([
event,
{
name: "recovery:packet_lost",
time: 1791819895001.125,
data: { packet_number: 5, type: "1RTT" },
},
]),
);
const { call, client } = await connectLocalToolsMcp();
const input = { capture_path: path, format: "qlog", record_ordinals: [1, 0] };
const response = await call("inspect_web_network_capture", input);
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const mcp = parseEvidence(response.structuredContent);
const result = await execute(process.execPath, [
cli,
"inspect-web-network-capture",
path,
"qlog",
"--record",
"1",
"--record",
"0",
"--json",
]);
const cliEvidence = parseEvidence(JSON.parse(result.stdout));
expect(cliEvidence.evidence_id).toBe(mcp.evidence_id);
expect(cliEvidence.normalized_result).toEqual(mcp.normalized_result);
expect(mcp.analysis_profile?.parameters).toMatchObject({
qlog_profile: "aioquic-1.5.0-json-0.3",
});
expect(mcp.authority).toBe("historical-reference");
const decoded = webNetworkCaptureSchema.parse(mcp.normalized_result);
expect(decoded.records.map((record) => record.ordinal)).toEqual([1, 0]);
if (decoded.format !== "qlog") throw new Error("qlog format required");
expect(decoded.qlog.traces[0]?.streams[0]).toMatchObject({
unique_bytes: "5",
covered_ranges: [{ start: "10", end: "15" }],
});
// An unsupported view over an existing retained parent proves lookup without pretending qlog is HAR.
for (const kind of ["api-surface", "environment-manifest"] as const) {
const retained = await call("inspect_analysis_view", {
source: { kind: "retained-evidence", evidence_id: mcp.evidence_id },
view: { kind },
});
expect(parseMcpToolError(retained)).toMatchObject({
error: { code: "unsupported_target" },
});
}
await client.ping();
for (const record_ordinals of [[2], [0, 0]]) {
expect(
parseMcpToolError(
await call("inspect_web_network_capture", {
...input,
record_ordinals,
}),
),
).toMatchObject({ error: { code: "invalid_request" } });
}
const hidden = await call("inspect_web_network_capture", {
capture_path: path,
format: "qlog",
sensitive_values: ["trace-local", "/traces/0", "input.qlog"],
});
expect(JSON.stringify(hidden)).not.toContain("trace-local");
expect(JSON.stringify(hidden)).not.toContain("/traces/0");
expect(JSON.stringify(hidden)).not.toContain("input.qlog");
const hiddenEvidence = parseEvidence(hidden.structuredContent);
expect(
webNetworkCaptureSchema.parse(hiddenEvidence.normalized_result).artifact
.path,
).toBe("");
const invalid = await call("inspect_web_network_capture", {
...input,
mysecret: true,
sensitive_values: ["mysecret"],
});
expect(parseMcpToolError(invalid)).toMatchObject({
error: { code: "invalid_request" },
});
expect(JSON.stringify(invalid)).not.toContain("mysecret");
}, 60_000);
it("fails whole-capture validation before selection and survives malformed, unsupported and oversized qlog", async () => {
const root = await createTestTempDirectory("rea-qlog-errors-");
const path = join(root, "input.qlog");
const { call, client } = await connectLocalToolsMcp();
for (const [text, code] of [
[qlog([event, { ...event, time: "invalid" }]), "invalid_request"],
[qlog([event]).slice(0, -1), "invalid_request"],
[
qlog([event]).replace('"qlog_version":"0.3"', '"qlog_version":"0.4"'),
"capability_unavailable",
],
[
qlog([
{
...event,
extension: JSON.parse("[".repeat(65) + "0" + "]".repeat(65)),
},
]),
"invalid_request",
],
] as const) {
await writeFile(path, text);
expect(
parseMcpToolError(
await call("inspect_web_network_capture", {
capture_path: path,
format: "qlog",
record_ordinals: [0],
}),
),
).toMatchObject({ error: { code } });
const failed: unknown = await execute(process.execPath, [
cli,
"inspect-web-network-capture",
path,
"qlog",
"--json",
]).catch((cause: unknown) => cause);
expect(failed).toMatchObject({
code: 1,
stdout: expect.stringContaining(`"code":"${code}"`),
});
}
await truncate(path, WEB_NETWORK_CAPTURE_LIMITS.inputBytes + 1);
expect(
parseMcpToolError(
await call("inspect_web_network_capture", {
capture_path: path,
format: "qlog",
}),
),
).toMatchObject({ error: { code: "invalid_request" } });
await writeFile(path, qlog([event]));
await client.ping();
expect(
(
await call("inspect_web_network_capture", {
capture_path: path,
format: "qlog",
})
).isError,
).not.toBe(true);
}, 60_000);
runtimeTest.skipIf(process.versions.bun !== undefined || !hasMemoryBound)(
"contains actual qlog decoder heap exhaustion and keeps MCP usable",
async () => {
const root = await createTestTempDirectory("rea-qlog-capacity-");
const path = join(root, "dense.qlog");
const dense = qlog(Array<unknown>(100_000).fill(event));
expect(Buffer.byteLength(dense)).toBeLessThan(
WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
);
await writeFile(path, dense);
const { call, client } = await connectLocalToolsMcp();
const response = await call("inspect_web_network_capture", {
capture_path: path,
format: "qlog",
record_ordinals: [0],
});
expect(parseMcpToolError(response)).toMatchObject({
error: {
code: "resource_constraint",
details: {
resource: "memory",
reported_limits: { old_generation_heap_mib: 192 },
},
},
});
const failed: unknown = await execute(
process.execPath,
[cli, "inspect-web-network-capture", path, "qlog", "--json"],
{ timeout: 40_000, maxBuffer: 1024 * 1024 },
).catch((cause: unknown) => cause);
expect(failed).toMatchObject({
code: 1,
stdout: expect.stringContaining('"code":"resource_constraint"'),
});
await writeFile(path, qlog([event]));
await client.ping();
expect(
(
await call("inspect_web_network_capture", {
capture_path: path,
format: "qlog",
})
).isError,
).not.toBe(true);
},
60_000,
);