mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 11:25:39 +00:00
Every non-entity GET read became a document from its latest initial recording, bounded only by writes under related roots, so GET /api/me read before and after POST /api/orders published the post-write value as initial state (entities already leave such a type recorded). A document whose value changed between two of its line's reads across any recorded write is now recorded as fields-differ; an unchanged value is still a document. MCP hints treated OPTIONS and TRACE as non-idempotent writes, so a CORS preflight tool fell to the destructive default. The RFC 9110 safe methods (GET, HEAD, OPTIONS, TRACE) are read-only and idempotent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1127 lines
33 KiB
TypeScript
1127 lines
33 KiB
TypeScript
import { expect, it } from "vitest";
|
|
import {
|
|
analysisViewCaptureEvidence,
|
|
environmentManifestCaptureFixture,
|
|
environmentStateCaptureFixture,
|
|
} from "../../../tests/fixtures/analysisView.js";
|
|
import {
|
|
SCENARIO_EXCHANGES,
|
|
scenarioCaptureEvidence,
|
|
scenarioCaptureFixture,
|
|
} from "../../../tests/fixtures/browserScenarioCapture.js";
|
|
import type { Evidence } from "../evidence.js";
|
|
import type { JsonValue } from "../jsonValue.js";
|
|
import {
|
|
projectAnalysisView,
|
|
type AnalysisViewParent,
|
|
type AnalysisViewResult,
|
|
} from "./analysisView.js";
|
|
import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js";
|
|
import {
|
|
at,
|
|
identityCandidates,
|
|
replaceAt,
|
|
type JsonObject,
|
|
} from "./environmentStateRules.js";
|
|
|
|
type State = Extract<AnalysisViewResult, { kind: "environment-state" }>;
|
|
const ALL_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 stateOf = (parent: AnalysisViewParent = parentOf()): State => {
|
|
const result = projectAnalysisView(parent, {
|
|
kind: "environment-state",
|
|
template_rules: [...ALL_RULES],
|
|
});
|
|
if (!result.ok || result.value.kind !== "environment-state")
|
|
throw new Error("Expected an environment state");
|
|
return result.value;
|
|
};
|
|
|
|
type Row = readonly [
|
|
method: string,
|
|
path: string,
|
|
body?: JsonValue,
|
|
status?: number,
|
|
];
|
|
|
|
/** HAR entries one second apart on one origin; a string body is recorded as written. */
|
|
const captureOf = (rows: readonly Row[]): AnalysisViewParent =>
|
|
parentOf(
|
|
analysisViewCaptureEvidence(
|
|
environmentManifestCaptureFixture(
|
|
rows.map(([method, path, body = null, status = 200], index) => ({
|
|
startedDateTime: new Date(
|
|
Date.parse("2026-10-17T18:00:00.000Z") + index * 1000,
|
|
).toISOString(),
|
|
time: 5,
|
|
request: { method, url: `http://app.example${path}` },
|
|
response: {
|
|
status,
|
|
content: {
|
|
mimeType: "application/json",
|
|
text: typeof body === "string" ? body : JSON.stringify(body),
|
|
},
|
|
},
|
|
})),
|
|
),
|
|
),
|
|
);
|
|
|
|
const viewOf = (state: State, tool: string) =>
|
|
state.views.find((view) => view.tool === tool);
|
|
|
|
it("extracts the state the Grafana-shaped capture's reads observed", () => {
|
|
const state = stateOf();
|
|
expect(state.timeline).toBe("recorded-start-time");
|
|
expect(state.types).toContainEqual({
|
|
collection_template: "/api/folders",
|
|
id_field: "uid",
|
|
id_basis: "path-parameter",
|
|
init_before_ordinal: 20,
|
|
volatile_fields: [],
|
|
});
|
|
expect(state.types).toContainEqual({
|
|
collection_template: "/api/search",
|
|
id_field: "id",
|
|
id_basis: "field-name",
|
|
init_before_ordinal: null,
|
|
volatile_fields: [],
|
|
});
|
|
expect(
|
|
state.entities.filter((entity) => entity.type === "/api/folders"),
|
|
).toEqual([
|
|
{
|
|
type: "/api/folders",
|
|
collection: "/api/folders",
|
|
id: "fg1jo91e57e2ob",
|
|
fields: { id: 5, uid: "fg1jo91e57e2ob", title: "probe folder" },
|
|
view_fields: [],
|
|
record_ordinals: [12, 14],
|
|
},
|
|
{
|
|
type: "/api/folders",
|
|
collection: "/api/folders",
|
|
id: "general",
|
|
fields: { id: 0, uid: "general", title: "Dashboards" },
|
|
view_fields: [],
|
|
record_ordinals: [13],
|
|
},
|
|
]);
|
|
expect(viewOf(state, "get_folders")).toMatchObject({
|
|
shape: "list",
|
|
pointer: "",
|
|
membership: "recorded",
|
|
});
|
|
expect(viewOf(state, "get_folders_by_uid")).toMatchObject({
|
|
shape: "item",
|
|
envelope_ordinal: 12,
|
|
checked_record_ordinals: [12, 13],
|
|
});
|
|
expect(viewOf(state, "get_dashboards_by_uid")).toMatchObject({
|
|
shape: "item",
|
|
pointer: "/dashboard",
|
|
});
|
|
expect(viewOf(state, "get_annotations")).toEqual({
|
|
tool: "get_annotations",
|
|
shape: "recorded",
|
|
reason: "no-init-recording",
|
|
});
|
|
// The one team member listed proves no suffix-named identity, so the list is a document of its path.
|
|
expect(
|
|
state.documents.map((document) => [document.tool, document.path]),
|
|
).toEqual([
|
|
[
|
|
"get_datasources_health_by_uid",
|
|
"/api/datasources/uid/dg1jo96tern5sb/health",
|
|
],
|
|
["get_org", "/api/org"],
|
|
["get_teams_members_by_id", "/api/teams/1/members"],
|
|
["get_user", "/api/user"],
|
|
]);
|
|
expect(viewOf(state, "get_plugins_settings_by_plugin_id")).toEqual({
|
|
tool: "get_plugins_settings_by_plugin_id",
|
|
shape: "recorded",
|
|
reason: "path-varies",
|
|
});
|
|
expect(state.unknowns.map((unknown) => unknown.kind)).toEqual([
|
|
"authorization-model",
|
|
"response-semantics",
|
|
"state-transition",
|
|
]);
|
|
expect(
|
|
state.limitations.filter((limitation) =>
|
|
limitation.startsWith("Body shapes"),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("ends a type's state at the first write under its root, not at unrelated writes", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/items/1", { id: 1, title: "Granite Ledger" }],
|
|
["PUT", "/api/notes/9", { id: 9 }],
|
|
["GET", "/api/items/1", { id: 1, title: "Granite Ledger" }],
|
|
["PUT", "/api/items/1", { id: 1, title: "Saltmarsh Depot" }],
|
|
["GET", "/api/items/1", { id: 1, title: "Saltmarsh Depot" }],
|
|
]),
|
|
);
|
|
expect(state.types).toEqual([
|
|
{
|
|
collection_template: "/api/items",
|
|
id_field: "id",
|
|
id_basis: "path-parameter",
|
|
init_before_ordinal: 3,
|
|
volatile_fields: [],
|
|
},
|
|
]);
|
|
expect(state.entities).toMatchObject([
|
|
{ id: "1", fields: { title: "Granite Ledger" }, record_ordinals: [0, 2] },
|
|
]);
|
|
expect(state.views).toContainEqual(
|
|
expect.objectContaining({ shape: "item", checked_record_ordinals: [2] }),
|
|
);
|
|
});
|
|
|
|
it("keeps the latest value of a field that changed with no related write and names it volatile", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/items/1", { id: 1, title: "Lighthouse Rota", seen: 10 }],
|
|
["GET", "/api/items/1", { id: 1, title: "Lighthouse Rota", seen: 11 }],
|
|
["GET", "/api/items/2", { id: 2, title: "Blue Heron", seen: 3 }],
|
|
]),
|
|
);
|
|
expect(state.types[0]).toMatchObject({ volatile_fields: ["seen"] });
|
|
expect(state.entities[0]).toMatchObject({
|
|
id: "1",
|
|
fields: { seen: 11 },
|
|
});
|
|
expect(state.views).toContainEqual(
|
|
expect.objectContaining({
|
|
shape: "item",
|
|
checked_record_ordinals: [1, 2],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("leaves a type recorded when a value changed across a write under another root", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/search", [{ id: 1, title: "Granite Ledger" }]],
|
|
["POST", "/api/dashboards/db", { id: 1, title: "Saltmarsh Depot" }],
|
|
["GET", "/api/search", [{ id: 1, title: "Saltmarsh Depot" }]],
|
|
]),
|
|
);
|
|
expect(state.types).toEqual([]);
|
|
expect(state.entities).toEqual([]);
|
|
expect(viewOf(state, "get_search")).toEqual({
|
|
tool: "get_search",
|
|
shape: "recorded",
|
|
reason: "fields-differ",
|
|
});
|
|
});
|
|
|
|
it("leaves a type recorded when a value changed across a write already in flight", () => {
|
|
const exchange = ([method, path, seconds, time, title]: readonly [
|
|
string,
|
|
string,
|
|
number,
|
|
number,
|
|
string,
|
|
]) => ({
|
|
startedDateTime: new Date(
|
|
Date.parse("2026-10-17T18:00:00.000Z") + seconds * 1000,
|
|
).toISOString(),
|
|
time,
|
|
request: { method, url: `http://app.example${path}` },
|
|
response: {
|
|
status: 200,
|
|
content: {
|
|
mimeType: "application/json",
|
|
text: JSON.stringify([{ id: 1, title }]),
|
|
},
|
|
},
|
|
});
|
|
const state = stateOf(
|
|
parentOf(
|
|
analysisViewCaptureEvidence(
|
|
environmentManifestCaptureFixture([
|
|
exchange([
|
|
"POST",
|
|
"/api/dashboards/db",
|
|
0,
|
|
30_000,
|
|
"Saltmarsh Depot",
|
|
]),
|
|
exchange(["GET", "/api/search", 10, 5, "Granite Ledger"]),
|
|
exchange(["GET", "/api/search", 40, 5, "Saltmarsh Depot"]),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
expect(viewOf(state, "get_search")).toEqual({
|
|
tool: "get_search",
|
|
shape: "recorded",
|
|
reason: "fields-differ",
|
|
});
|
|
});
|
|
|
|
it("does not list the whole collection when a counter exceeds the number listed", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
[
|
|
"GET",
|
|
"/api/items",
|
|
{ total: 50, items: [{ id: 1, title: "Ada Finch" }] },
|
|
],
|
|
]),
|
|
);
|
|
expect(viewOf(state, "get_items")).toMatchObject({
|
|
shape: "list",
|
|
membership: "recorded",
|
|
counters: [],
|
|
});
|
|
});
|
|
|
|
it("lists the whole collection with its counter only when every recording did", () => {
|
|
const rows: Row[] = [
|
|
[
|
|
"GET",
|
|
"/api/items",
|
|
{
|
|
total: 2,
|
|
items: [
|
|
{ id: 1, title: "Ada Finch" },
|
|
{ id: 2, title: "Tomas Vey" },
|
|
],
|
|
},
|
|
],
|
|
["GET", "/api/items/1", { title: "Ada Finch", id: 1, notes: "first" }],
|
|
["GET", "/api/items/2", { id: 2, notes: "second", title: "Tomas Vey" }],
|
|
];
|
|
const whole = stateOf(captureOf(rows));
|
|
expect(whole.views).toContainEqual(
|
|
expect.objectContaining({
|
|
shape: "list",
|
|
pointer: "/items",
|
|
fields: ["id", "title"],
|
|
membership: "all",
|
|
counters: ["total"],
|
|
}),
|
|
);
|
|
expect(whole.entities.map((entity) => entity.fields)).toEqual([
|
|
{ id: 1, title: "Ada Finch", notes: "first" },
|
|
{ id: 2, notes: "second", title: "Tomas Vey" },
|
|
]);
|
|
const filtered = stateOf(
|
|
captureOf([
|
|
...rows,
|
|
[
|
|
"GET",
|
|
"/api/items?title=Ada",
|
|
{ total: 1, items: [{ id: 1, title: "Ada Finch" }] },
|
|
],
|
|
]),
|
|
);
|
|
expect(filtered.views).toContainEqual(
|
|
expect.objectContaining({ shape: "list", membership: "recorded" }),
|
|
);
|
|
});
|
|
|
|
it("leaves recorded the views it cannot reproduce, with the reason", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
[
|
|
"GET",
|
|
"/api/boards/1",
|
|
{ meta: { viewer: "Ada Finch" }, board: { id: 1, name: "Rota" } },
|
|
],
|
|
[
|
|
"GET",
|
|
"/api/boards/2",
|
|
{ meta: { viewer: "Mina Okafor" }, board: { id: 2, name: "Depot" } },
|
|
],
|
|
["GET", "/api/ledgers/1", '{"id":1,"total":12345678901234567890}'],
|
|
["POST", "/api/boards/search", [{ id: 1, name: "Rota" }]],
|
|
]),
|
|
);
|
|
expect(
|
|
state.views.map((view) =>
|
|
view.shape === "recorded" ? [view.tool, view.reason] : view.tool,
|
|
),
|
|
).toEqual([
|
|
["post_boards_search", "query-body"],
|
|
["get_boards_by_id", "envelope-varies"],
|
|
["get_ledgers_by_id", "body-unusable"],
|
|
]);
|
|
expect(state.types).toEqual([]);
|
|
expect(state.entities).toEqual([]);
|
|
});
|
|
|
|
it("holds a written path's non-entity read as a document", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/settings", { theme: "dark", density: "compact" }],
|
|
["PUT", "/api/settings", { theme: "light", density: "compact" }],
|
|
["GET", "/api/settings", { theme: "light", density: "compact" }],
|
|
]),
|
|
);
|
|
expect(state.documents).toEqual([
|
|
{
|
|
tool: "get_settings",
|
|
path: "/api/settings",
|
|
value: { theme: "dark", density: "compact" },
|
|
record_ordinal: 0,
|
|
},
|
|
]);
|
|
expect(viewOf(state, "get_settings")).toEqual({
|
|
tool: "get_settings",
|
|
shape: "document",
|
|
checked_record_ordinals: [0],
|
|
});
|
|
});
|
|
|
|
it("holds a read-only non-entity read as a document unless it varies by request line", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/config", { theme: "dark", locale: "en-GB" }],
|
|
[
|
|
"GET",
|
|
"/api/rates",
|
|
[
|
|
{ currency: "EUR", rate: 1.1 },
|
|
{ currency: "EUR", rate: 1.2 },
|
|
],
|
|
],
|
|
["GET", "/api/stats?range=day", { visits: 3 }],
|
|
["GET", "/api/stats?range=week", { visits: 9 }],
|
|
]),
|
|
);
|
|
expect(state.documents).toEqual([
|
|
{
|
|
tool: "get_config",
|
|
path: "/api/config",
|
|
value: { theme: "dark", locale: "en-GB" },
|
|
record_ordinal: 0,
|
|
},
|
|
{
|
|
tool: "get_rates",
|
|
path: "/api/rates",
|
|
value: [
|
|
{ currency: "EUR", rate: 1.1 },
|
|
{ currency: "EUR", rate: 1.2 },
|
|
],
|
|
record_ordinal: 1,
|
|
},
|
|
]);
|
|
expect(viewOf(state, "get_stats")).toEqual({
|
|
tool: "get_stats",
|
|
shape: "recorded",
|
|
reason: "query-varies",
|
|
});
|
|
expect(state.types).toEqual([]);
|
|
});
|
|
|
|
it("infers a suffix-named identity only when no identity name identifies the listed items", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
[
|
|
"GET",
|
|
"/api/memberships",
|
|
[
|
|
{ orgId: 7, userId: 1, role: "Admin" },
|
|
{ orgId: 8, userId: 1, role: "Viewer" },
|
|
],
|
|
],
|
|
[
|
|
"GET",
|
|
"/api/seats",
|
|
{
|
|
total: 2,
|
|
seats: [
|
|
{ parentId: 1, seat_id: "s-1" },
|
|
{ parentId: 1, seat_id: "s-2" },
|
|
],
|
|
},
|
|
],
|
|
[
|
|
"GET",
|
|
"/api/projects",
|
|
[
|
|
{ projectId: "a", id: 2 },
|
|
{ projectId: "b", id: 3 },
|
|
],
|
|
],
|
|
[
|
|
"GET",
|
|
"/api/grants",
|
|
[
|
|
{ userId: 1, orgId: 7 },
|
|
{ userId: 2, orgId: 8 },
|
|
],
|
|
],
|
|
["GET", "/api/members", []],
|
|
["GET", "/api/members", [{ memberId: "m-1" }, { memberId: "m-2" }]],
|
|
["GET", "/api/comments", [{ postId: 9, authorId: 3, text: "hi" }]],
|
|
]),
|
|
);
|
|
expect(
|
|
state.types.map(({ collection_template, id_field, id_basis }) => [
|
|
collection_template,
|
|
id_field,
|
|
id_basis,
|
|
]),
|
|
).toEqual([
|
|
// Both identify the grants; the earlier key in item order wins.
|
|
["/api/grants", "userId", "field-name"],
|
|
// The empty first recording constrains no field.
|
|
["/api/members", "memberId", "field-name"],
|
|
["/api/memberships", "orgId", "field-name"],
|
|
["/api/projects", "id", "field-name"],
|
|
["/api/seats", "seat_id", "field-name"],
|
|
]);
|
|
expect(viewOf(state, "get_seats")).toMatchObject({
|
|
shape: "list",
|
|
pointer: "/seats",
|
|
membership: "all",
|
|
counters: ["total"],
|
|
});
|
|
expect(viewOf(state, "get_members")).toMatchObject({ shape: "list" });
|
|
expect(
|
|
state.entities
|
|
.filter((entity) => entity.type === "/api/memberships")
|
|
.map((entity) => entity.id),
|
|
).toEqual(["7", "8"]);
|
|
// One listed item shows no field distinct, so its foreign keys identify nothing.
|
|
expect(viewOf(state, "get_comments")).toMatchObject({ shape: "document" });
|
|
});
|
|
|
|
it("chooses an envelope's array by identity name, then by a suffix-named field", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
[
|
|
"GET",
|
|
"/api/board",
|
|
{
|
|
labels: [{ text: "urgent" }, { text: "later" }],
|
|
cards: [
|
|
{ cardId: 1, text: "Granite Ledger" },
|
|
{ cardId: 2, text: "Blue Heron" },
|
|
],
|
|
},
|
|
],
|
|
[
|
|
"GET",
|
|
"/api/directory",
|
|
{
|
|
owners: [{ orgId: 1 }, { orgId: 2 }],
|
|
teams: [
|
|
{ id: 1, orgId: 1 },
|
|
{ id: 2, orgId: 1 },
|
|
],
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
expect(
|
|
state.types.map(({ collection_template, id_field }) => [
|
|
collection_template,
|
|
id_field,
|
|
]),
|
|
).toEqual([
|
|
["/api/board", "cardId"],
|
|
["/api/directory", "id"],
|
|
]);
|
|
expect(viewOf(state, "get_board")).toMatchObject({
|
|
shape: "list",
|
|
pointer: "/cards",
|
|
});
|
|
expect(viewOf(state, "get_directory")).toMatchObject({
|
|
shape: "list",
|
|
pointer: "/teams",
|
|
});
|
|
});
|
|
|
|
it("locates an item entity wrapped in a one-element array and merges it with the list view", () => {
|
|
const first = "6ad47b5c1023b500010873e3";
|
|
const second = "6ad47b5c1023b500010873e4";
|
|
const state = stateOf(
|
|
captureOf([
|
|
[
|
|
"GET",
|
|
"/ghost/api/admin/posts/",
|
|
{
|
|
posts: [
|
|
{ id: first, title: "Granite Ledger", status: "draft" },
|
|
{ id: second, title: "Blue Heron", status: "published" },
|
|
],
|
|
meta: { pagination: { page: 1, pages: 1 } },
|
|
},
|
|
],
|
|
[
|
|
"GET",
|
|
`/ghost/api/admin/posts/${first}/`,
|
|
{
|
|
posts: [
|
|
{
|
|
id: first,
|
|
title: "Granite Ledger",
|
|
status: "draft",
|
|
html: "<p>Quarry count</p>",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
["GET", "/api/depots/5", [{ id: 5, name: "Saltmarsh Depot" }]],
|
|
["GET", "/api/rosters/9", [{ id: 31, roster: 9, name: "Ada Finch" }]],
|
|
]),
|
|
);
|
|
expect(viewOf(state, "get_rosters_by_id")).toEqual({
|
|
tool: "get_rosters_by_id",
|
|
shape: "recorded",
|
|
reason: "no-identity",
|
|
});
|
|
const posts = "/ghost/api/admin/posts";
|
|
expect(state.types).toContainEqual({
|
|
collection_template: posts,
|
|
id_field: "id",
|
|
id_basis: "path-parameter",
|
|
init_before_ordinal: null,
|
|
volatile_fields: [],
|
|
});
|
|
expect(state.views).toContainEqual(
|
|
expect.objectContaining({
|
|
shape: "item",
|
|
type: posts,
|
|
pointer: "/posts/0",
|
|
fields: ["id", "title", "status", "html"],
|
|
checked_record_ordinals: [1],
|
|
}),
|
|
);
|
|
expect(state.views).toContainEqual(
|
|
expect.objectContaining({
|
|
shape: "list",
|
|
type: posts,
|
|
pointer: "/posts",
|
|
membership: "all",
|
|
}),
|
|
);
|
|
expect(state.views).toContainEqual(
|
|
expect.objectContaining({
|
|
shape: "item",
|
|
type: "/api/depots",
|
|
pointer: "/0",
|
|
}),
|
|
);
|
|
expect(state.entities.filter((entity) => entity.type === posts)).toEqual([
|
|
{
|
|
type: posts,
|
|
collection: posts,
|
|
id: first,
|
|
fields: {
|
|
id: first,
|
|
title: "Granite Ledger",
|
|
status: "draft",
|
|
html: "<p>Quarry count</p>",
|
|
},
|
|
view_fields: [],
|
|
record_ordinals: [0, 1],
|
|
},
|
|
expect.objectContaining({ id: second, record_ordinals: [0] }),
|
|
]);
|
|
});
|
|
|
|
it("reads and replaces only the root, one member or the element of a one-element array", () => {
|
|
const body = { posts: [{ id: 1 }], meta: { posts: 1 } };
|
|
expect(at(body, "/posts/0")).toEqual({ id: 1 });
|
|
expect(replaceAt(body, "/posts/0", null)).toEqual({
|
|
posts: [null],
|
|
meta: { posts: 1 },
|
|
});
|
|
expect(at([{ id: 1 }], "/0")).toEqual({ id: 1 });
|
|
expect(replaceAt([{ id: 1 }], "/0", { id: 2 })).toEqual([{ id: 2 }]);
|
|
for (const pointer of ["/posts/1", "/meta/posts", "/posts/0/id"])
|
|
expect(at(body, pointer)).toBeUndefined();
|
|
expect(at({ posts: [{ id: 1 }, { id: 2 }] }, "/posts/0")).toBeUndefined();
|
|
expect(replaceAt(body, "/meta/posts", null)).toBe(body);
|
|
});
|
|
|
|
it("leaves an item path's non-entity read recorded", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/files/123", { text: "initial document" }],
|
|
["PUT", "/api/files/123", null],
|
|
]),
|
|
);
|
|
expect(state.documents).toEqual([]);
|
|
expect(viewOf(state, "get_files_by_id")).toMatchObject({ shape: "recorded" });
|
|
});
|
|
|
|
it("holds a deleted path's initial non-entity read as a document", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/settings", { text: "initial document" }],
|
|
["DELETE", "/api/settings", null],
|
|
]),
|
|
);
|
|
expect(state.documents).toEqual([
|
|
{
|
|
tool: "get_settings",
|
|
path: "/api/settings",
|
|
value: { text: "initial document" },
|
|
record_ordinal: 0,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("orders a scenario capture by request order and needs its response bodies", () => {
|
|
const state = stateOf(parentOf(scenarioCaptureEvidence()));
|
|
expect(state.timeline).toBe("record-order");
|
|
expect(state.types).toEqual([
|
|
{
|
|
collection_template: "/api/users",
|
|
id_field: "id",
|
|
id_basis: "path-parameter",
|
|
init_before_ordinal: null,
|
|
volatile_fields: [],
|
|
},
|
|
]);
|
|
expect(state.entities.map((entity) => entity.fields)).toEqual([
|
|
{ id: 2, login: "probe" },
|
|
{ id: 3, login: "viewer" },
|
|
]);
|
|
const headersOnly = scenarioCaptureEvidence(
|
|
scenarioCaptureFixture(SCENARIO_EXCHANGES, {
|
|
request_body: true,
|
|
response_body: false,
|
|
header_values: true,
|
|
}),
|
|
);
|
|
expect(
|
|
projectAnalysisView(parentOf(headersOnly), {
|
|
kind: "environment-state",
|
|
template_rules: [],
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisUnsupportedTargetError",
|
|
message: expect.stringContaining("response_body selected"),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("refuses when extraction would exceed the projection budget", () => {
|
|
expect(
|
|
projectAnalysisView(
|
|
parentOf(),
|
|
{ kind: "environment-state", template_rules: [...ALL_RULES] },
|
|
{ state: new EnvironmentStateAccumulator(160 * 1024, [...ALL_RULES]) },
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisResourceConstraintError",
|
|
resource: "memory",
|
|
message: expect.stringContaining("Environment-state"),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("ends every type's state at a write whose URL was redacted, and says so", () => {
|
|
const parent = captureOf([
|
|
["GET", "/api/items/1", { id: 1, title: "Granite Ledger" }],
|
|
["PUT", "/api/items/1", { id: 1 }],
|
|
["GET", "/api/items/1", { id: 1, title: "Saltmarsh Depot" }],
|
|
]);
|
|
const result = structuredClone(parent.normalizedResult) as {
|
|
records: { reported: { request: { url: string | null } } }[];
|
|
};
|
|
// A scenario keeps a redacted URL as null, so the write's target is unknown.
|
|
result.records[1]!.reported.request.url = null;
|
|
const state = stateOf({ ...parent, normalizedResult: result });
|
|
expect(state.types).toMatchObject([{ init_before_ordinal: 1 }]);
|
|
expect(state.entities).toMatchObject([
|
|
{ fields: { title: "Granite Ledger" }, record_ordinals: [0] },
|
|
]);
|
|
expect(state.limitations).toContain(
|
|
"1 of the recorded writes had a redacted URL; each ends the initial state of every type, since its target is unknown.",
|
|
);
|
|
});
|
|
|
|
it("infers no identity for a list whose recordings held no items and holds it as a document", () => {
|
|
const state = stateOf(captureOf([["GET", "/api/widgets", []]]));
|
|
expect(state.types).toEqual([]);
|
|
expect(state.views).toEqual([
|
|
{ tool: "get_widgets", shape: "document", checked_record_ordinals: [0] },
|
|
]);
|
|
});
|
|
|
|
it("does not call a value volatile when it differs between request lines", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/items/1?lang=fr", { id: 1, name: "Bonjour" }],
|
|
["GET", "/api/items/1", { id: 1, name: "Hello" }],
|
|
]),
|
|
);
|
|
expect(state.types).toEqual([]);
|
|
expect(
|
|
state.views.map((view) =>
|
|
view.shape === "recorded" ? [view.tool, view.reason] : view.tool,
|
|
),
|
|
).toEqual([["get_items_by_id", "fields-differ"]]);
|
|
});
|
|
|
|
it("orders entities by their earliest sighting, so a complete list keeps all membership", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
[
|
|
"GET",
|
|
"/api/items",
|
|
[
|
|
{ id: 1, title: "Granite Ledger" },
|
|
{ id: 2, title: "Blue Heron" },
|
|
],
|
|
],
|
|
["GET", "/api/items/2", { id: 2, title: "Blue Heron" }],
|
|
["GET", "/api/items/1", { id: 1, title: "Granite Ledger" }],
|
|
]),
|
|
);
|
|
expect(state.entities.map((entity) => entity.id)).toEqual(["1", "2"]);
|
|
expect(state.views).toContainEqual(
|
|
expect.objectContaining({ tool: "get_items", membership: "all" }),
|
|
);
|
|
});
|
|
|
|
it("does not take a later recording that is itself an entity as the list", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/items", { items: [{ id: 1, title: "Granite Ledger" }] }],
|
|
[
|
|
"GET",
|
|
"/api/items",
|
|
{ id: 99, items: [{ id: 2, title: "Blue Heron" }] },
|
|
],
|
|
]),
|
|
);
|
|
// Without one list across its recordings, the read is held as a document, not entities.
|
|
expect(state.views).toContainEqual(
|
|
expect.objectContaining({ tool: "get_items", shape: "document" }),
|
|
);
|
|
expect(state.entities).toEqual([]);
|
|
});
|
|
|
|
it("leaves a type recorded when two of its tools read different values across a write", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/search", [{ id: 1, title: "Granite Ledger" }]],
|
|
["POST", "/api/dashboards/db", { id: 9 }],
|
|
["GET", "/api/search/1", { id: 1, title: "Saltmarsh Depot" }],
|
|
]),
|
|
);
|
|
expect(state.types).toEqual([]);
|
|
expect(state.entities).toEqual([]);
|
|
expect(
|
|
state.views.map((view) =>
|
|
view.shape === "recorded" ? [view.tool, view.reason] : view.tool,
|
|
),
|
|
).toEqual([
|
|
["get_search", "fields-differ"],
|
|
["get_search_by_id", "fields-differ"],
|
|
]);
|
|
});
|
|
|
|
it.each(["old", "new"])(
|
|
"retains field observations through sparse reads across a write (%s)",
|
|
(title) => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/search", [{ id: 1, title: "old" }]],
|
|
["POST", "/api/items", { id: 2 }],
|
|
["GET", "/api/search?fields=id", [{ id: 1 }]],
|
|
["GET", "/api/search/1", { id: 1, title }],
|
|
]),
|
|
);
|
|
if (title === "new") {
|
|
expect(state.types).toEqual([]);
|
|
expect(state.entities).toEqual([]);
|
|
expect(
|
|
state.views.every(
|
|
(view) =>
|
|
view.shape === "recorded" && view.reason === "fields-differ",
|
|
),
|
|
).toBe(true);
|
|
} else {
|
|
expect(viewOf(state, "get_search_by_id")).toMatchObject({
|
|
shape: "item",
|
|
});
|
|
expect(state.entities[0]?.fields.title).toBe("old");
|
|
}
|
|
},
|
|
);
|
|
|
|
it("proves the identity field against every item response", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/widgets/1", { left: 1, right: 1 }],
|
|
["GET", "/api/widgets/2", { right: 2, left: "other" }],
|
|
]),
|
|
);
|
|
expect(state.types).toMatchObject([
|
|
{ id_field: "right", id_basis: "path-parameter" },
|
|
]);
|
|
expect(state.entities.map((entity) => entity.id)).toEqual(["1", "2"]);
|
|
});
|
|
|
|
it.each(["/api", "/api/items/1/update"])(
|
|
"bounds a type by a write at a related root (%s)",
|
|
(path) => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/items/1", { id: 1, title: "initial" }],
|
|
["POST", path, {}],
|
|
["GET", "/api/items/1", { id: 1, title: "after" }],
|
|
]),
|
|
);
|
|
expect(state.types).toMatchObject([{ init_before_ordinal: 1 }]);
|
|
expect(state.entities).toMatchObject([{ fields: { title: "initial" } }]);
|
|
},
|
|
);
|
|
|
|
it("chooses the earliest related write in timeline order, across ancestor and descendant roots", () => {
|
|
const parent = captureOf([
|
|
["GET", "/api/items/1", { id: 1, title: "initial" }],
|
|
["POST", "/api/items/1/update", {}],
|
|
["POST", "/api", {}],
|
|
["GET", "/api/items/1", { id: 1, title: "after" }],
|
|
]);
|
|
const data = structuredClone(parent.normalizedResult) as {
|
|
records: { reported: { startedDateTime: string } }[];
|
|
};
|
|
const first = data.records[1]!.reported.startedDateTime;
|
|
data.records[1]!.reported.startedDateTime =
|
|
data.records[2]!.reported.startedDateTime;
|
|
data.records[2]!.reported.startedDateTime = first;
|
|
const state = stateOf({ ...parent, normalizedResult: data });
|
|
expect(state.types).toMatchObject([{ init_before_ordinal: 2 }]);
|
|
expect(state.entities).toMatchObject([{ fields: { title: "initial" } }]);
|
|
});
|
|
|
|
it("bounds a parameterized namespace by concrete writes before publishing new items", () => {
|
|
const result = projectAnalysisView(
|
|
captureOf([
|
|
["GET", "/api/v1/repos/alice/alpha/issues/1", { id: 1 }],
|
|
["GET", "/api/v1/repos/bob/beta/issues/2", { id: 2 }],
|
|
["GET", "/api/v1/repos/carol/gamma/issues/3", { id: 3 }],
|
|
["POST", "/api/v1/repos/dana/delta/issues", { id: 4 }],
|
|
["GET", "/api/v1/repos/dana/delta/issues/4", { id: 4 }],
|
|
]),
|
|
{
|
|
kind: "environment-state",
|
|
template_rules: [...ALL_RULES, "sibling-pair-variance"],
|
|
},
|
|
);
|
|
if (!result.ok || result.value.kind !== "environment-state")
|
|
throw new Error("Expected state");
|
|
expect(result.value.entities.map((entity) => entity.id)).toEqual([
|
|
"1",
|
|
"2",
|
|
"3",
|
|
]);
|
|
expect(result.value.types).toMatchObject([{ init_before_ordinal: 3 }]);
|
|
});
|
|
|
|
it.each(["totalcount", "itemcount"])(
|
|
"recognizes a compact compound item total (%s)",
|
|
(name) => {
|
|
const state = stateOf(
|
|
captureOf([["GET", "/api/items", { [name]: 50, items: [{ id: 1 }] }]]),
|
|
);
|
|
expect(viewOf(state, "get_items")).toMatchObject({
|
|
shape: "list",
|
|
membership: "recorded",
|
|
counters: [],
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[[], [{ id: 1 }]],
|
|
[[{ id: 1 }], []],
|
|
[[{ id: 1 }], [{ id: 2 }]],
|
|
] as const)(
|
|
"keeps changed list membership across another root's write recorded (%j -> %j)",
|
|
(before, after) => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/search", [...before]],
|
|
["POST", "/api/items", { id: 2 }],
|
|
["GET", "/api/search", [...after]],
|
|
]),
|
|
);
|
|
expect(state.types).toEqual([]);
|
|
expect(state.entities).toEqual([]);
|
|
expect(viewOf(state, "get_search")).toEqual({
|
|
tool: "get_search",
|
|
shape: "recorded",
|
|
reason: "fields-differ",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("accepts stable membership across another root's write and changes confined before it", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/search", []],
|
|
["GET", "/api/search", [{ id: 1 }]],
|
|
["POST", "/api/items", { id: 2 }],
|
|
["GET", "/api/search", [{ id: 1 }]],
|
|
]),
|
|
);
|
|
expect(viewOf(state, "get_search")).toMatchObject({ shape: "list" });
|
|
expect(state.entities.map((entity) => entity.id)).toEqual(["1"]);
|
|
});
|
|
|
|
it("keeps membership checks specific to the full request line", () => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/search?page=1", [{ id: 1 }]],
|
|
["POST", "/api/items", { id: 2 }],
|
|
["GET", "/api/search?page=2", [{ id: 2 }]],
|
|
]),
|
|
);
|
|
expect(viewOf(state, "get_search")).toMatchObject({
|
|
shape: "list",
|
|
membership: "recorded",
|
|
});
|
|
});
|
|
|
|
it("does not treat a first observation as a membership change", () => {
|
|
const parent = captureOf([
|
|
["GET", "/api/search", [{ id: 1 }]],
|
|
["POST", "/api/items", { id: 2 }],
|
|
["GET", "/api/search", [{ id: 1 }]],
|
|
]);
|
|
const data = structuredClone(parent.normalizedResult) as {
|
|
records: { reported: { time: number } }[];
|
|
};
|
|
data.records[0]!.reported.time = 1500;
|
|
const state = stateOf({ ...parent, normalizedResult: data });
|
|
expect(viewOf(state, "get_search")).toMatchObject({ shape: "list" });
|
|
});
|
|
|
|
it.each([
|
|
[{ id: 1 }, { id: 1, title: "Created" }],
|
|
[{ id: 1, title: "Original" }, { id: 1 }],
|
|
])(
|
|
"keeps fields added or removed across another root's write recorded (%j -> %j)",
|
|
(before, after) => {
|
|
const state = stateOf(
|
|
captureOf([
|
|
["GET", "/api/search/1", before],
|
|
["POST", "/api/items", { id: 2 }],
|
|
["GET", "/api/search/1", after],
|
|
]),
|
|
);
|
|
expect(state.types).toEqual([]);
|
|
expect(state.entities).toEqual([]);
|
|
expect(viewOf(state, "get_search_by_id")).toMatchObject({
|
|
shape: "recorded",
|
|
reason: "fields-differ",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("reads only item totals and next-page markers as signs of a partial list", () => {
|
|
const membership = (body: JsonValue) =>
|
|
stateOf(captureOf([["GET", "/api/items", body]])).views.map((view) =>
|
|
view.shape === "list" ? [view.membership, view.counters] : view.shape,
|
|
);
|
|
// Page and id numbers are not item totals, so a complete page stays whole.
|
|
expect(
|
|
membership({
|
|
page: 1,
|
|
total: 2,
|
|
total_pages: 1,
|
|
account_id: 77,
|
|
items: [{ id: 1 }, { id: 2 }],
|
|
}),
|
|
).toEqual([["all", ["total"]]]);
|
|
expect(membership({ has_more: true, items: [{ id: 1 }, { id: 2 }] })).toEqual(
|
|
[["recorded", []]],
|
|
);
|
|
expect(membership({ total: "50", items: [{ id: 1 }] })).toEqual([
|
|
["recorded", []],
|
|
]);
|
|
});
|
|
|
|
it("reads a sparse list once per candidate field when inferring identity", () => {
|
|
const fields = 2_000;
|
|
const wide: JsonObject = Object.fromEntries(
|
|
Array.from({ length: fields }, (_, index) => [
|
|
`f${String(index)}Id`,
|
|
index,
|
|
]),
|
|
);
|
|
let reads = 0;
|
|
const sparse = Array.from(
|
|
{ length: 5_000 },
|
|
() =>
|
|
new Proxy<JsonObject>(
|
|
{},
|
|
{
|
|
get: (target, key, receiver) => {
|
|
reads += 1;
|
|
return Reflect.get(target, key, receiver) as unknown;
|
|
},
|
|
},
|
|
),
|
|
);
|
|
expect(identityCandidates([wide, ...sparse])).toEqual([]);
|
|
// Each identity name and suffix field stops at the first sparse item.
|
|
expect(reads).toBeLessThanOrEqual(fields + 20);
|
|
});
|
|
|
|
it("keeps a document out of the state when its value changed across a write under another root", () => {
|
|
const changed = stateOf(
|
|
captureOf([
|
|
["GET", "/api/me", { credits: 10, plan: "free" }],
|
|
["POST", "/api/orders", { id: "o1" }],
|
|
["GET", "/api/me", { credits: 9, plan: "free" }],
|
|
]),
|
|
);
|
|
expect(changed.documents).toEqual([]);
|
|
expect(changed.views).toContainEqual({
|
|
tool: "get_me",
|
|
shape: "recorded",
|
|
reason: "fields-differ",
|
|
});
|
|
// A value the write left unchanged is still one initial state.
|
|
const unchanged = stateOf(
|
|
captureOf([
|
|
["GET", "/api/me", { credits: 10, plan: "free" }],
|
|
["POST", "/api/orders", { id: "o1" }],
|
|
["GET", "/api/me", { credits: 10, plan: "free" }],
|
|
]),
|
|
);
|
|
expect(unchanged.documents).toMatchObject([
|
|
{ tool: "get_me", value: { credits: 10, plan: "free" }, record_ordinal: 2 },
|
|
]);
|
|
});
|