fix(environment): count scenario bodies after request admission

Move body-retention coverage accounting to the manifest request-line owner. Preserve source retention facts by ordinal and remove the competing eligibility rule from scenario projection.
This commit is contained in:
morluto committed 2026-10-24 10:17:23 +08:00
1 parent 37f24125ad
commit 2f2b07f750
7 files changed
+213 -41

No files matched your search

@@ -46,7 +46,9 @@ change.
transactions with a usable request line retained no response body (tools
citing them replay only the recorded status and content type, either of
which may be absent) and how many retained response bodies carry redaction
markers. A body that redaction emptied, because a declared secret could not
markers. Both counts use the same request-line admission as manifest grouping,
so unredacted URLs without a usable transport origin and recorded path contribute
to neither numerator nor denominator. A body that redaction emptied, because a declared secret could not
be cut out, is treated as not retained rather than replayed as an empty
reply.
- `environment-serve` and `environment-export` authenticate either parent
@@ -13,6 +13,7 @@ import {
import { completeWithinViewCoverage } from "./analysisViewCoverage.js";
import { recordedResponseBodyIsEmpty } from "./recordedResponseBody.js";
import type { UnsignedAnalysisView } from "./analysisView.js";
import type { ScenarioResponseBodyRetention } from "./scenarioCaptureRecords.js";
import { WebNetworkProjectionAccumulator } from "./WebNetworkProjectionAccumulator.js";
import {
compareTools,
@@ -227,6 +228,9 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
readonly #origins = new Map<string, OriginStats>();
readonly #rules: ReadonlySet<TemplateRule>;
#unrepresented = 0;
#scenarioTransactions = 0;
#scenarioWithoutBody = 0;
#scenarioRedactedBodies = 0;
#unrepresentableBodyKey = false;
constructor(
@@ -244,7 +248,10 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
return true;
}
add(record: WebNetworkCaptureRecord): Result<void, AnalysisError> {
add(
record: WebNetworkCaptureRecord,
scenarioResponseBody?: ScenarioResponseBodyRetention,
): Result<void, AnalysisError> {
const limitations = this.addLimitations(record.limitations);
if (!limitations.ok) return limitations;
try {
@@ -258,6 +265,13 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
this.#unrepresented += 1;
return ok(undefined);
}
// Coverage uses the same admitted request line as grouping, including origin and raw path.
if (scenarioResponseBody !== undefined) {
this.#scenarioTransactions += 1;
if (scenarioResponseBody === "absent") this.#scenarioWithoutBody += 1;
if (scenarioResponseBody === "redacted")
this.#scenarioRedactedBodies += 1;
}
const route = scanRouteTemplate(line.path, this.scratch);
if (route === null) return this.overBudget();
const queryNames = readQueryParameterNames(line.query, this.scratch);
@@ -539,6 +553,16 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
]
: []),
...this.#segmentLimitLimitations(toolGroups),
...(this.#scenarioWithoutBody > 0
? [
`${String(this.#scenarioWithoutBody)} of ${String(this.#scenarioTransactions)} scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.`,
]
: []),
...(this.#scenarioRedactedBodies > 0
? [
`${String(this.#scenarioRedactedBodies)} of ${String(this.#scenarioTransactions)} scenario transactions with a usable request line retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.`,
]
: []),
...(this.#unrepresented > 0
? [
`${String(this.#unrepresented)} retained records without a usable request line are not represented in the manifest.`,
@@ -3,6 +3,7 @@ import {
compareClockInstants,
readClock,
} from "./environmentStateWriteValues.js";
import type { ScenarioResponseBodyRetention } from "./scenarioCaptureRecords.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { parseExactJson } from "../exactJsonNumber.js";
import { MAX_JSON_DEPTH, type JsonValue } from "../jsonValue.js";
@@ -148,8 +149,11 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
return false;
}
override add(record: WebNetworkCaptureRecord): Result<void, AnalysisError> {
const grouped = super.add(record);
override add(
record: WebNetworkCaptureRecord,
scenarioResponseBody?: ScenarioResponseBodyRetention,
): Result<void, AnalysisError> {
const grouped = super.add(record, scenarioResponseBody);
if (!grouped.ok) return grouped;
try {
const line = readRecordedRequestLine(record.reported, true, this.scratch);
@@ -15,7 +15,10 @@ import {
type TemplateRule,
} from "./environmentManifestRules.js";
import { admitHarCaptureParent } from "./WebNetworkProjectionAccumulator.js";
import { admitScenarioCaptureParent } from "./scenarioCaptureRecords.js";
import {
admitScenarioCaptureParent,
type ScenarioResponseBodyRetention,
} from "./scenarioCaptureRecords.js";
import { ROUTE_RULES } from "./webNetworkRouteTemplates.js";
/** Opt-in template rules, each listed at most once; shared by every view that groups tools. */
@@ -133,6 +136,10 @@ export interface CaptureParent {
| "capture_browser_scenario";
readonly records: readonly WebNetworkCaptureRecord[];
readonly total_records: number;
readonly scenarioResponseBodies?: ReadonlyMap<
number,
ScenarioResponseBodyRetention
>;
readonly limitations: readonly string[];
readonly artifact: { readonly path: string; readonly sha256: string | null };
}
@@ -150,6 +157,7 @@ const admitCaptureParent = (
operation: "capture_browser_scenario",
records: scenario.value.records,
total_records: scenario.value.totalRecords,
scenarioResponseBodies: scenario.value.responseBodies,
limitations: scenario.value.limitations,
artifact: { path: "", sha256: null },
})
@@ -207,7 +215,10 @@ const addCaptureRecords = (
]);
if (!limitations.ok) return limitations;
for (const record of capture.records) {
const added = target.accumulator.add(record);
const added = target.accumulator.add(
record,
capture.scenarioResponseBodies?.get(record.ordinal),
);
if (!added.ok) return added;
}
return ok(undefined);
@@ -12,6 +12,22 @@ import {
} from "./scenarioCaptureRecords.js";
import { EnvironmentManifestAccumulator } from "./EnvironmentManifestAccumulator.js";
const manifestLimitations = (
projected: ReturnType<typeof scenarioCaptureRecords>,
) => {
const accumulator = new EnvironmentManifestAccumulator();
for (const record of projected.records) {
const added = accumulator.add(
record,
projected.responseBodies.get(record.ordinal),
);
if (!added.ok) throw added.error;
}
const result = accumulator.finish();
if (!result.ok) throw result.error;
return result.value.limitations;
};
describe("scenarioCaptureRecords", () => {
it("projects each transaction, in request order, as a HAR-shaped record with its retained bytes", () => {
const projected = scenarioCaptureRecords(
@@ -57,7 +73,7 @@ describe("scenarioCaptureRecords", () => {
expect(projected.records[7]?.reported).toEqual({
request: { method: "GET", url: "http://127.0.0.1:3101/api/slow" },
});
expect(projected.limitations).toContain(
expect(manifestLimitations(projected)).toContain(
"1 of 7 scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.",
);
});
@@ -77,10 +93,10 @@ describe("scenarioCaptureRecords", () => {
};
const projected = scenarioCaptureRecords(duplicated, "manifest");
expect(projected.records[0]?.reported).toEqual({});
// No manifest tool can cite it, so it is not counted among bodiless tools.
// The shared request-line owner rejects this ambiguous transaction before body counting.
expect(
projected.limitations.some((limitation) =>
limitation.includes("retained no response body"),
manifestLimitations(projected).some((limitation) =>
limitation.includes("scenario transactions with a usable request line"),
),
).toBe(false);
});
@@ -128,7 +144,7 @@ describe("scenarioCaptureRecords", () => {
"response.content.text",
);
expect(projected.records[1]?.binary_fields).toEqual([]);
expect(projected.limitations).toEqual(
expect(manifestLimitations(projected)).toEqual(
expect.arrayContaining([
"1 of 2 scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.",
"1 of 2 scenario transactions with a usable request line retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.",
@@ -19,16 +19,20 @@ import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
import { analysisViewResultInputError } from "./analysisViewParent.js";
import type { AnalysisViewParent } from "./analysisView.js";
import type { WebNetworkProjectionAccumulator } from "./WebNetworkProjectionAccumulator.js";
import { textBytes } from "./webNetworkRouteTemplates.js";
import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js";
type RequestEvent = Extract<BrowserScenarioEvent, { kind: "request" }>;
type ContentEvent = Extract<BrowserScenarioEvent, { kind: "network-content" }>;
type CapturedBody = Extract<BrowserNetworkBody, { state: "captured" }>;
/** Response-byte retention, independent of whether the transaction has a usable request line. */
export type ScenarioResponseBodyRetention = "absent" | "retained" | "redacted";
/** A scenario capture's network transactions in the record shape HAR captures project from. */
export interface ScenarioCaptureRecords {
readonly records: readonly WebNetworkCaptureRecord[];
readonly totalRecords: number;
readonly responseBodies: ReadonlyMap<number, ScenarioResponseBodyRetention>;
readonly limitations: readonly string[];
}
@@ -106,10 +110,7 @@ const record = (
purpose: "manifest" | "replay",
): {
readonly record: WebNetworkCaptureRecord;
/** The record keeps a usable request line, so the manifest may cite it. */
readonly represented: boolean;
readonly body: boolean;
readonly redacted: boolean;
readonly responseBody: ScenarioResponseBodyRetention;
} => {
const { item, event: request } = observed;
const location = {
@@ -135,9 +136,7 @@ const record = (
)
return {
record: { ordinal, location, reported: {}, ...empty },
represented: false,
body: false,
redacted: false,
responseBody: "absent",
};
const requestContent = onlyContent(index, request.sequence);
const responseContent = onlyContent(index, response?.sequence);
@@ -176,9 +175,8 @@ const record = (
},
],
},
represented: !request.url.redacted,
body: body !== undefined,
redacted: body?.redacted === true,
responseBody:
body === undefined ? "absent" : body.redacted ? "redacted" : "retained",
};
};
@@ -189,21 +187,18 @@ export const scenarioCaptureRecords = (
): ScenarioCaptureRecords => {
const index = indexBrowserScenarioTransactions(capture.events.items);
const records: WebNetworkCaptureRecord[] = [];
let withoutBody = 0;
let representedCount = 0;
let redactedBodies = 0;
const responseBodies = new Map<number, ScenarioResponseBodyRetention>();
for (const [item, event] of capture.events.items.entries()) {
if (event.kind !== "request") continue;
const projected = record(records.length, { event, item }, index, purpose);
records.push(projected.record);
if (!projected.represented) continue;
representedCount += 1;
if (!projected.body) withoutBody += 1;
if (projected.redacted) redactedBodies += 1;
if (purpose === "manifest")
responseBodies.set(projected.record.ordinal, projected.responseBody);
}
return {
records,
totalRecords: records.length,
responseBodies,
limitations: [
"Scenario records are the capture's network transactions in request order; each record ordinal is the transaction's zero-based position.",
...(capture.events.dropped > 0
@@ -211,16 +206,6 @@ export const scenarioCaptureRecords = (
`The scenario capture omitted ${String(capture.events.dropped)} events; the manifest covers only retained transactions.`,
]
: []),
...(withoutBody > 0
? [
`${String(withoutBody)} of ${String(representedCount)} scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.`,
]
: []),
...(redactedBodies > 0
? [
`${String(redactedBodies)} of ${String(representedCount)} scenario transactions with a usable request line retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.`,
]
: []),
],
};
};
@@ -234,14 +219,17 @@ const member = (value: unknown, key: string): unknown =>
/**
* Bytes the manifest derives from a borrowed scenario: validation decodes and re-encodes one body at
* a time (two bytes per base64 character), and projection keeps the decoded text of every request
* body. Response bodies stay borrowed, as HAR response text does.
* body. Response bodies stay borrowed, as HAR response text does; retention metadata is keyed
* by each request ordinal.
*/
const derivedTextBytes = (value: unknown): number => {
const items = member(member(value, "events"), "items");
let largestBody = 0;
let requestText = 0;
let responseBodyEntries = 0;
// Each event is read on its own, so a body without content leaves the others counted.
for (const event of Array.isArray(items) ? items : []) {
if (member(event, "kind") === "request") responseBodyEntries += 1;
const content = member(member(event, "body"), "content");
if (
member(event, "kind") !== "network-content" ||
@@ -252,7 +240,7 @@ const derivedTextBytes = (value: unknown): number => {
if (member(event, "phase") === "request")
requestText += textBytes(Math.ceil((content.length * 3) / 4));
}
return 64 + largestBody * 2 + requestText;
return 64 + largestBody * 2 + requestText + responseBodyEntries * ENTRY_BYTES;
};
/** Admit a capture_browser_scenario parent whose network content identifies response media types; state also needs bodies. */
@@ -0,0 +1,127 @@
import { Cli } from "incur";
import { expect, it } from "vitest";
import { registerAnalysisViewCommands } from "../../../src/cli/analysisViewCommands.js";
import { sanitizeBrowserUrl } from "../../../src/domain/browserObservation.js";
import { browserScenarioCaptureSchema } from "../../../src/domain/browserScenarioCapture.js";
import { silentLogger } from "../../../src/logger.js";
import {
scenarioCaptureEvidence,
scenarioCaptureFixture,
} from "../../fixtures/browserScenarioCapture.js";
const unusableUrls = [
"data:text/plain,no-origin",
"file:///public/example.txt",
"blob:https://example.test/resource",
"not a url",
"https:example.test/api/items",
];
const manifest = async (
urls: readonly string[],
body: "absent" | "redacted",
) => {
const original = scenarioCaptureFixture(
urls.map(() => ({
method: "GET",
url: "/api/items",
...(body === "absent"
? {}
: {
status: 200,
mediaType: "application/json",
body: '{"token":"[REDACTED:token]"}',
}),
})),
);
let request = 0;
const capture = browserScenarioCaptureSchema.parse({
...original,
events: {
...original.events,
items: original.events.items.map((event) => {
if (event.kind === "request")
return { ...event, url: sanitizeBrowserUrl(urls[request++]!) };
if (event.kind === "network-content" && event.body.state === "captured")
return { ...event, body: { ...event.body, redacted: true } };
return event;
}),
},
});
const cli = Cli.create("scenario-coverage", { sync: false });
registerAnalysisViewCommands(cli, silentLogger);
let stdout = "";
let exitCode = 0;
await cli.serve(
[
"inspect-analysis-view",
JSON.stringify({
source: { kind: "inline", evidence: scenarioCaptureEvidence(capture) },
view: { kind: "environment-manifest" },
}),
"--json",
],
{
env: {},
stdout: (text) => {
stdout += text;
},
exit: (code) => {
exitCode = code;
},
},
);
expect(exitCode).toBe(0);
return (
JSON.parse(stdout) as {
normalized_result: {
records: { ordinal: number }[];
limitations: string[];
};
}
).normalized_result;
};
it.each(
unusableUrls.flatMap((url) =>
(["absent", "redacted"] as const).map((body) => ({ url, body })),
),
)(
"excludes $url from scenario $body body-retention coverage through the CLI handler",
async ({ url, body }) => {
const result = await manifest([url], body);
expect(result.records).toEqual([]);
expect(result.limitations).toContain(
"1 retained records without a usable request line are not represented in the manifest.",
);
expect(
result.limitations.filter((limitation) =>
limitation.includes("scenario transactions with a usable request line"),
),
).toEqual([]);
},
);
it.each(["absent", "redacted"] as const)(
"counts only the admitted request in mixed scenario %s body-retention coverage",
async (body) => {
const urls = [...unusableUrls, "https://example.test/api/control"];
const result = await manifest(urls, body);
expect(result.records.map((record) => record.ordinal)).toEqual([
unusableUrls.length,
]);
expect(result.limitations).toContain(
`${String(unusableUrls.length)} retained records without a usable request line are not represented in the manifest.`,
);
expect(
result.limitations.filter((limitation) =>
limitation.includes("scenario transactions with a usable request line"),
),
).toEqual([
body === "absent"
? "1 of 1 scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent."
: "1 of 1 scenario transactions with a usable request line retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.",
]);
},
);