* fix(environment)!: report recorded reads and target uncertainty BREAKING CHANGE: Environment state and coverage can report method-replay for non-GET safe reads; exhaustive reason consumers must handle it. * docs(environment): include safe-method replay reason
257 lines
7.9 KiB
TypeScript
257 lines
7.9 KiB
TypeScript
import { expect, it } from "vitest";
|
|
import {
|
|
analysisViewCaptureEvidence,
|
|
environmentStateCaptureFixture,
|
|
timedCaptureEvidence,
|
|
} from "../../../tests/fixtures/analysisView.js";
|
|
import {
|
|
SCENARIO_EXCHANGES,
|
|
scenarioCaptureEvidence,
|
|
scenarioCaptureFixture,
|
|
} from "../../../tests/fixtures/browserScenarioCapture.js";
|
|
import type { Evidence } from "../evidence.js";
|
|
import {
|
|
analysisViewRequestSchema,
|
|
projectAnalysisView,
|
|
type AnalysisViewParent,
|
|
type AnalysisViewResult,
|
|
} from "./analysisView.js";
|
|
import {
|
|
WITHHELD_WRITE_HINTS,
|
|
summarizeEnvironmentCoverage,
|
|
} from "./environmentCoverageView.js";
|
|
import type { StateView } from "./environmentStateRules.js";
|
|
import { environmentStateFields } from "./environmentStateView.js";
|
|
import type { StateWritePlan } from "./environmentStateWriteRules.js";
|
|
|
|
const RULES = [
|
|
"after-uid-literal",
|
|
"opaque-token",
|
|
"sibling-variance",
|
|
] as const;
|
|
|
|
const parentOf = (
|
|
evidence: Evidence = analysisViewCaptureEvidence(
|
|
environmentStateCaptureFixture(),
|
|
),
|
|
): AnalysisViewParent => ({
|
|
evidenceId: evidence.evidence_id,
|
|
operation: evidence.operation,
|
|
normalizedResult: evidence.normalized_result,
|
|
subject: evidence.subject,
|
|
limitations: [],
|
|
parameters: evidence.parameters,
|
|
});
|
|
|
|
const project = (
|
|
kind: "environment-state" | "environment-coverage",
|
|
parent: AnalysisViewParent = parentOf(),
|
|
): AnalysisViewResult => {
|
|
const projected = projectAnalysisView(
|
|
parent,
|
|
analysisViewRequestSchema.parse({ kind, template_rules: [...RULES] }),
|
|
);
|
|
if (!projected.ok) throw projected.error;
|
|
return projected.value;
|
|
};
|
|
|
|
const plan = (
|
|
tool: string,
|
|
reason: StateWritePlan["reason"],
|
|
extra: Partial<StateWritePlan> = {},
|
|
): StateWritePlan =>
|
|
({
|
|
tool,
|
|
effect: "create",
|
|
target: "/api/folders",
|
|
served: reason === null,
|
|
reason,
|
|
exemplar_ordinal: 4,
|
|
observation: null,
|
|
response: null,
|
|
body: null,
|
|
rules: [],
|
|
collection: null,
|
|
insert: null,
|
|
merge: null,
|
|
checked_record_ordinals: [],
|
|
contradicted_by: null,
|
|
...extra,
|
|
}) as StateWritePlan;
|
|
|
|
it("has a hint for every reason the state view reports for a withheld write", () => {
|
|
const reasons = environmentStateFields.writes.element.shape.reason
|
|
.unwrap()
|
|
.options.slice()
|
|
.sort();
|
|
expect(Object.keys(WITHHELD_WRITE_HINTS).sort()).toEqual(reasons);
|
|
for (const hint of Object.values(WITHHELD_WRITE_HINTS))
|
|
expect(hint.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("counts read tools by how they answer and lists writes as served or withheld with hints", () => {
|
|
const views: StateView[] = [
|
|
{ tool: "get_folders", shape: "document", checked_record_ordinals: [] },
|
|
{ tool: "get_users", shape: "recorded", reason: "query-varies" },
|
|
{ tool: "get_teams", shape: "recorded", reason: "envelope-varies" },
|
|
{ tool: "get_orgs", shape: "recorded", reason: "query-varies" },
|
|
];
|
|
const writes = [
|
|
plan("post_folders", null),
|
|
plan("put_folders_by_uid", "contradicted", {
|
|
effect: "update",
|
|
contradicted_by: 9,
|
|
}),
|
|
plan("delete_folders_by_uid", "no-state-target", {
|
|
effect: "delete",
|
|
exemplar_ordinal: null,
|
|
}),
|
|
];
|
|
expect(summarizeEnvironmentCoverage({ views, writes })).toEqual({
|
|
reads: {
|
|
state: 1,
|
|
recorded: 3,
|
|
recorded_reasons: { "envelope-varies": 1, "query-varies": 2 },
|
|
},
|
|
writes: {
|
|
served: [{ tool: "post_folders", effect: "create" }],
|
|
withheld: [
|
|
{
|
|
tool: "put_folders_by_uid",
|
|
effect: "update",
|
|
reason: "contradicted",
|
|
hint: WITHHELD_WRITE_HINTS.contradicted,
|
|
exemplar_ordinal: 4,
|
|
contradicted_by: 9,
|
|
},
|
|
{
|
|
tool: "delete_folders_by_uid",
|
|
effect: "delete",
|
|
reason: "no-state-target",
|
|
hint: WITHHELD_WRITE_HINTS["no-state-target"],
|
|
exemplar_ordinal: null,
|
|
contradicted_by: null,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("summarizes the state the environment-state view derives with the same rules", () => {
|
|
const state = project("environment-state");
|
|
const coverage = project("environment-coverage");
|
|
if (
|
|
state.kind !== "environment-state" ||
|
|
coverage.kind !== "environment-coverage"
|
|
)
|
|
throw new Error("Expected state and coverage views");
|
|
const summary = summarizeEnvironmentCoverage(state);
|
|
expect(coverage.reads).toEqual(summary.reads);
|
|
expect(coverage.writes).toEqual(summary.writes);
|
|
expect(coverage.reads.state + coverage.reads.recorded).toBe(
|
|
state.views.length,
|
|
);
|
|
expect(coverage.writes.served.length + coverage.writes.withheld.length).toBe(
|
|
state.writes.length,
|
|
);
|
|
expect(coverage).toMatchObject({
|
|
view: { kind: "environment-coverage", template_rules: [...RULES] },
|
|
parent_evidence_id: state.parent_evidence_id,
|
|
origin: state.origin,
|
|
name: state.name,
|
|
unknowns: state.unknowns,
|
|
coverage: state.coverage,
|
|
});
|
|
expect(coverage.limitations).toEqual(
|
|
expect.arrayContaining([
|
|
...state.limitations,
|
|
expect.stringContaining("does not run the environment"),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("does not claim reads are missing when a literal delete has no modelled target", () => {
|
|
const evidence = timedCaptureEvidence([
|
|
["GET", "/api/folders", [{ uid: "f1", title: "General" }]],
|
|
["GET", "/api/folders/f1", { uid: "f1", title: "General" }],
|
|
["DELETE", "/api/folders/f1", null, 204],
|
|
["GET", "/api/folders/f1", { message: "not found" }, 404],
|
|
["GET", "/api/folders", []],
|
|
]);
|
|
const parent = parentOf(evidence);
|
|
const state = project("environment-state", parent);
|
|
const coverage = project("environment-coverage", parent);
|
|
if (
|
|
state.kind !== "environment-state" ||
|
|
coverage.kind !== "environment-coverage"
|
|
)
|
|
throw new Error("Expected state and coverage views");
|
|
expect(state.views).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ tool: "get_folders", shape: "list" }),
|
|
expect.objectContaining({ tool: "get_folders_f1", shape: "document" }),
|
|
]),
|
|
);
|
|
expect(coverage.writes.withheld).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
reason: "no-state-target",
|
|
hint: expect.stringContaining(
|
|
"could not be linked to a modelled state target",
|
|
),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("says an untimed capture learns no clock, and only for an untimed capture", () => {
|
|
const untimed = /learns no clock/u;
|
|
const scenarioParent = parentOf(
|
|
scenarioCaptureEvidence(
|
|
scenarioCaptureFixture(SCENARIO_EXCHANGES, {
|
|
request_body: true,
|
|
response_body: true,
|
|
header_values: true,
|
|
}),
|
|
),
|
|
);
|
|
for (const [parent, timeline] of [
|
|
[parentOf(), "recorded-start-time"],
|
|
[scenarioParent, "record-order"],
|
|
] as const) {
|
|
const state = project("environment-state", parent);
|
|
const coverage = project("environment-coverage", parent);
|
|
if (
|
|
state.kind !== "environment-state" ||
|
|
coverage.kind !== "environment-coverage"
|
|
)
|
|
throw new Error("Expected state and coverage views");
|
|
expect(state.timeline).toBe(timeline);
|
|
expect(coverage.limitations.some((line) => untimed.test(line))).toBe(
|
|
timeline === "record-order",
|
|
);
|
|
}
|
|
});
|
|
|
|
it("refuses a browser scenario without response bodies as the state view does", () => {
|
|
const evidence = scenarioCaptureEvidence(
|
|
scenarioCaptureFixture(SCENARIO_EXCHANGES, {
|
|
request_body: true,
|
|
response_body: false,
|
|
header_values: true,
|
|
}),
|
|
);
|
|
const view = (kind: "environment-state" | "environment-coverage") =>
|
|
projectAnalysisView(
|
|
parentOf(evidence),
|
|
analysisViewRequestSchema.parse({ kind, template_rules: [] }),
|
|
);
|
|
const state = view("environment-state");
|
|
const coverage = view("environment-coverage");
|
|
if (state.ok || coverage.ok) throw new Error("Expected both refusals");
|
|
expect(coverage.error).toMatchObject({
|
|
_tag: "AnalysisUnsupportedTargetError",
|
|
message: state.error.message,
|
|
});
|
|
});
|