mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
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.",
|
||||
]);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user