Files
rea/experiments/analysis-efficiency/browser-saved-values.mjs
T
gensokyo-dev 3fcbabe593 feat(browser): reuse saved response values across scenario steps (#7267)
Save typed response fields and reuse generated IDs or authentication values across browser scenario requests, UI observations and navigation through the shared CLI/MCP workflow.

Preserve exact numbers, retained Evidence, explicit source/coverage limits and prospective page-value semantics. Bound response decoding, retained spellings and request serialization before allocation, and protect marked response values across source records, follow-up text and planned screenshots.

Integrates Edgar Arakelyan's #7162, including follow-ups through 9f5191ab0. Final local validation: 560 feature-branch tests and 561 current-main integration tests, static/build/docs checks and real PocketBase CLI/MCP privacy workflows. Whole-task comparison and verification limits are recorded in docs/verification/browser-saved-values-2026-10-26.md.

Co-authored-by: Edgar Arakelyan <edgar.arakelyan@scale.com>
2026-10-27 08:15:52 +08:00

714 lines
24 KiB
JavaScript

/** Compare complete real PocketBase create/update/read tasks through public MCP. */
import assert from "node:assert/strict";
import { readFile, writeFile, mkdir, mkdtemp, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { performance } from "node:perf_hooks";
import { createVerifierCommands } from "../../scripts/lib/verify-commands.mjs";
import { withVerifierCleanup } from "../../scripts/lib/verifier-cleanup.mjs";
const { values } = parseArgs({
options: {
repository: { type: "string" },
mode: { type: "string" },
output: { type: "string" },
origin: { type: "string" },
credentials: { type: "string" },
browser: { type: "string" },
"source-only": { type: "boolean", default: false },
},
});
assert.ok(
values.repository &&
values.output &&
values.origin &&
values.credentials &&
values.browser,
);
assert.ok(["before", "after"].includes(values.mode));
const repo = resolve(values.repository),
output = resolve(values.output),
origin = new URL(values.origin).origin;
assert.equal(
new URL(origin).hostname,
"127.0.0.1",
"Use an owned loopback fixture",
);
await mkdir(output, { mode: 0o700 });
const credentials = JSON.parse(await readFile(values.credentials, "utf8"));
const require = createRequire(join(repo, "package.json"));
const { chromium } = require("playwright-core");
const { Client } = await import(
pathToFileURL(require.resolve("@modelcontextprotocol/client"))
);
const { StdioClientTransport } = await import(
pathToFileURL(require.resolve("@modelcontextprotocol/client/stdio"))
);
const commands = await createVerifierCommands();
const profile = await mkdtemp(join(tmpdir(), "rea-saved-values-"));
const env = {
PATH: process.env.PATH,
NODE_OPTIONS: "--max-old-space-size=3072",
UV_THREADPOOL_SIZE: "2",
REA_LOG_LEVEL: "silent",
REA_MCP_TOOL_FAMILIES: "browser,application,session",
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
REA_TEST_EMAIL: credentials.email,
REA_TEST_PASSWORD: credentials.password,
REA_TEST_WHOLE_JSON: JSON.stringify({ token: "private-token-729d" }),
};
const calls = [];
const runs = [];
let catalog;
const transport = new StdioClientTransport({
command: process.execPath,
args: [join(repo, "scripts/rea.mjs"), "mcp"],
cwd: repo,
env,
stderr: "pipe",
});
transport.stderr?.resume();
const client = new Client({ name: "rea-saved-values-real-app", version: "1" });
commands.adopt(client, "saved-values MCP client");
const tokenHeader = (saved) => ({
name: "Authorization",
value: saved
? { source: "saved", name: "token" }
: { source: "local_storage", key: "rea_auth", json_pointer: "/token" },
});
const decodeBodies = (value) => {
const bodies = [];
const visit = (x) => {
if (x && typeof x === "object") {
if (x.encoding === "base64" && typeof x.content === "string") {
try {
bodies.push(
JSON.parse(Buffer.from(x.content, "base64").toString("utf8")),
);
} catch {}
}
for (const v of Object.values(x)) visit(v);
}
};
visit(value);
return bodies;
};
async function invoke(name, args) {
const defs = catalog.tools.filter((t) => t.name === name);
assert.equal(defs.length, 1);
const start = performance.now();
const result = await client.callTool(
{ name, arguments: args },
{ toolDefinition: defs[0], timeout: 90000 },
);
calls.push({
name,
arguments: args,
result,
elapsed_ms: performance.now() - start,
});
assert.notEqual(
result.isError,
true,
`${name} failed; retain the local receipt`,
);
return result.structuredContent;
}
const loginActions = (saved) => [
{
step_id: "email",
action: "fill",
locator: { kind: "css", selector: "#email" },
value: { source: "secret", secret_id: "email" },
},
{
step_id: "password",
action: "fill",
locator: { kind: "css", selector: "#password" },
value: { source: "secret", secret_id: "password" },
},
{
step_id: "login",
action: "click",
locator: { kind: "css", selector: "#login" },
...(saved
? {
save_response: {
method: "POST",
path: "/api/collections/_superusers/auth-with-password",
save: [{ name: "token", json_pointer: "/token", secret: true }],
},
}
: {}),
timeout_ms: 10000,
},
{
step_id: "ready",
action: "wait_for",
locator: { kind: "css", selector: '#result[data-ready="yes"]' },
state: "visible",
timeout_ms: 10000,
},
];
const edits = (id, saved) => [
{
step_id: "update",
action: "request",
method: "PATCH",
path: `/api/collections/rea_items/records/${id}`,
headers: [tokenHeader(saved)],
json: { title: `record-${id}`, quantity: saved ? "{saved:quantity}" : 11 },
expected_status: [200],
},
{
step_id: "read",
action: "request",
method: "GET",
path: `/api/collections/rea_items/records/${id}?at=${id}`,
headers: [tokenHeader(saved)],
expected_status: [200],
},
{
step_id: "open",
action: "goto",
destination: { url: `${origin}/api/collections/rea_items/records/${id}` },
wait_until: "load",
},
];
const captureOptions = {
after_each_step: ["url"],
at_end: ["url", "accessibility"],
events: ["network"],
network: { request_body: true, response_body: true, header_values: true },
};
try {
const context = await chromium.launchPersistentContext(profile, {
executablePath: resolve(values.browser),
headless: true,
args: ["--remote-debugging-port=0", "--no-sandbox"],
});
commands.adopt(context, "owned saved-values Chromium context");
const page = context.pages()[0];
await page.goto(origin, { waitUntil: "networkidle" });
const port = (
await readFile(join(profile, "DevToolsActivePort"), "utf8")
).split("\n")[0];
const endpoint = `http://127.0.0.1:${port}`;
const targets = await (await fetch(endpoint + "/json/list")).json();
const target = targets.find(
(t) => t.type === "page" && t.url === origin + "/",
);
assert.ok(target);
const browser = {
mode: "connect",
cdp_endpoint: endpoint,
target_id: target.id,
};
const declared = [
{ secret_id: "email", environment_variable: "REA_TEST_EMAIL" },
{ secret_id: "password", environment_variable: "REA_TEST_PASSWORD" },
];
const scenario = (actions, secrets = declared) => ({
browser,
start_url: { url: origin + "/" },
secrets,
actions,
capture: captureOptions,
detail: "summary",
});
await client.connect(transport);
catalog = await client.listTools();
await writeFile(join(output, "catalog.json"), JSON.stringify(catalog));
for (const temperature of values["source-only"] ? [] : ["cold", "warm"]) {
const saved = values.mode === "after";
const start = performance.now(),
firstCall = calls.length;
const create = {
step_id: "create",
action: "request",
method: "POST",
path: "/api/collections/rea_items/records",
headers: [tokenHeader(saved)],
json: { title: `created-${temperature}`, quantity: 11 },
expected_status: [200, 201],
...(saved
? {
save: [
{ name: "record_id", json_pointer: "/id" },
{ name: "quantity", json_pointer: "/quantity" },
],
}
: {}),
};
const firstActions = [...loginActions(saved), create];
if (saved) firstActions.push(...edits("{saved:record_id}", true));
const first = await invoke(
"capture_browser_scenario",
scenario(firstActions),
);
const firstSummary = first.normalized_result;
assert.ok(
firstSummary.summary.steps.every((step) => step.status === "completed"),
"Initial capture must complete",
);
let final = first;
let id;
let legacyPreReadObserved;
if (!saved) {
const selected = await invoke("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: firstSummary.parent_evidence_id,
},
view: {
kind: "browser-scenario",
collection: "events",
step_indices: [firstActions.length],
event_kinds: ["network-content"],
limit: 16,
},
});
assert.equal(selected.normalized_result.coverage.exhausted, true);
const records = decodeBodies(selected.normalized_result).filter(
(b) => b?.collectionName === "rea_items" && typeof b.id === "string",
);
assert.equal(records.length, 1);
id = records[0].id;
final = await invoke(
"capture_browser_scenario",
scenario(edits(id, false), []),
);
}
assert.ok(
final.normalized_result.summary.steps.every(
(step) => step.status === "completed",
),
"Completed task must not hide failed actions",
);
const completedMs = performance.now() - start,
taskCalls = calls.length - firstCall;
const path = join(output, `${temperature}-evidence.json`);
await invoke("export_evidence_bundle", { path });
const bundle = JSON.parse(await readFile(path, "utf8"));
const parent = bundle.records.find(
(r) => r.evidence_id === final.normalized_result.parent_evidence_id,
);
assert.ok(parent);
if (!saved) {
const legacy = bundle.records.find(
(record) => record.evidence_id === firstSummary.parent_evidence_id,
);
assert.ok(legacy);
const login = decodeBodies(legacy.normalized_result).filter(
(body) => typeof body?.token === "string",
);
assert.equal(login.length, 1);
const preReadLogin = decodeBodies({
events: legacy.normalized_result.events.items.filter(
(event) => event.step_index < firstActions.length,
),
}).filter((body) => typeof body?.token === "string");
legacyPreReadObserved = preReadLogin.length === 1;
if (legacyPreReadObserved) {
assert.ok(
!preReadLogin[0].token.startsWith("[REDACTED:"),
"An observed pre-read login response must preserve its unmarked token",
);
const later = legacy.normalized_result.events.items.filter(
(event) => event.step_index >= firstActions.length,
);
assert.ok(
!JSON.stringify({
events: later,
bodies: decodeBodies({ events: later }),
}).includes(preReadLogin[0].token),
"Post-read facts must use the current page-value policy",
);
}
}
const recordBodies = decodeBodies(parent.normalized_result).filter(
(b) => b?.collectionName === "rea_items" && typeof b.id === "string",
);
assert.ok(recordBodies.length >= 2);
if (saved) id = recordBodies[0].id;
assert.ok(recordBodies.every((b) => b.id === id));
assert.ok(
recordBodies.some((b) => b.title === `record-${id}` && b.quantity === 11),
);
const read = await fetch(
`${origin}/api/collections/rea_items/records/${id}`,
);
assert.equal(read.status, 200);
const actual = await read.json();
assert.equal(actual.title, `record-${id}`);
assert.equal(actual.quantity, 11);
const requestBodies = decodeBodies(parent.normalized_result).filter(
(b) => b?.title === `record-${id}` && !Object.hasOwn(b, "collectionName"),
);
assert.ok(requestBodies.length > 0);
assert.ok(requestBodies.every((b) => typeof b.quantity === "number"));
if (saved) {
const loginBodies = decodeBodies(parent.normalized_result).filter(
(b) => typeof b?.token === "string",
);
assert.equal(loginBodies.length, 1);
assert.ok(loginBodies[0].token.startsWith("[REDACTED:"));
}
runs.push({
temperature,
legacy_pre_read_observed: legacyPreReadObserved,
elapsed_ms: completedMs,
task_calls: taskCalls,
evidence_id: parent.evidence_id,
record_id: id,
quantity_type: typeof actual.quantity,
});
}
if (values["source-only"]) {
assert.equal(values.mode, "after");
const sourceScenario = scenario(
[
{
step_id: "source",
action: "request",
method: "GET",
path: "/escaped-values.json?credential=source-token",
save: [
{ name: "token", json_pointer: "/token", secret: true },
{ name: "code", json_pointer: "/code", secret: true },
],
},
{
step_id: "later",
action: "request",
method: "GET",
path: "/escaped-values.json",
},
],
[],
);
const captured = await invoke("capture_browser_scenario", sourceScenario);
assert.ok(
captured.normalized_result.summary.steps.every(
(step) => step.status === "completed",
),
);
const path = join(output, "source-evidence.json");
await invoke("export_evidence_bundle", { path });
const bundle = JSON.parse(await readFile(path, "utf8"));
const parent = bundle.records.find(
(record) =>
record.evidence_id === captured.normalized_result.parent_evidence_id,
);
assert.ok(parent);
const sourceRequests = parent.normalized_result.events.items.filter(
(event) =>
event.kind === "request" &&
event.step_index === 1 &&
event.url.url.includes("/escaped-values.json"),
);
assert.equal(sourceRequests.length, 1);
const transaction = sourceRequests[0].transaction_id;
const sourceEvents = parent.normalized_result.events.items.filter(
(event) => event.transaction_id === transaction,
);
assert.ok(sourceEvents.some((event) => event.kind === "response"));
assert.ok(
!JSON.stringify(sourceEvents).includes("source-token"),
"Source transaction metadata must not retain the value learned from its response",
);
const bodies = decodeBodies(parent.normalized_result).filter(
(body) =>
Object.hasOwn(body ?? {}, "token") && Object.hasOwn(body, "code"),
);
assert.equal(
bodies.length,
2,
"Both actual source and later response must be observed",
);
for (const body of bodies) {
assert.equal(body.token, "[REDACTED:page:source.token]");
assert.equal(body.code, "[REDACTED:page:source.code]");
}
const expected = Buffer.from(
'{"token":"[REDACTED:page:source.token]","code":"[REDACTED:page:source.code]","unknown":1e-400}',
).toString("base64");
assert.equal(
JSON.stringify(parent.normalized_result).split(JSON.stringify(expected))
.length - 1,
2,
"Both source bodies must preserve the unmarked numeric spelling exactly",
);
const metadata = await invoke("capture_browser_scenario", {
...sourceScenario,
capture: {
...sourceScenario.capture,
network: {
request_body: false,
response_body: false,
header_values: false,
},
},
});
assert.ok(
metadata.normalized_result.summary.steps.every(
(step) => step.status === "completed",
),
);
const metadataPath = join(output, "metadata-evidence.json");
await invoke("export_evidence_bundle", { path: metadataPath });
const metadataBundle = JSON.parse(await readFile(metadataPath, "utf8"));
const metadataParent = metadataBundle.records.find(
(record) =>
record.evidence_id === metadata.normalized_result.parent_evidence_id,
);
assert.ok(metadataParent);
const metadataRequest = metadataParent.normalized_result.events.items.find(
(event) =>
event.kind === "request" &&
event.step_index === 1 &&
event.url.url.includes("/escaped-values.json"),
);
assert.ok(metadataRequest);
assert.equal(metadataRequest.url.origin, origin);
assert.ok(metadataRequest.url.url.includes("/escaped-values.json"));
assert.ok(!metadataRequest.url.url.includes("source-token"));
assert.ok(
!metadataParent.normalized_result.events.items.some(
(event) => event.kind === "network-content",
),
);
const countUrl = `${origin}/api/collections/rea_items/records?perPage=1`;
const beforeCount = (await (await fetch(countUrl)).json()).totalItems;
assert.ok(Number.isSafeInteger(beforeCount));
const whole = await invoke("capture_browser_scenario", {
...sourceScenario,
start_url: { url: origin + "/whole-json.html" },
secrets: [
{
secret_id: "whole_json",
environment_variable: "REA_TEST_WHOLE_JSON",
},
],
storage: {
local_storage: [
{
origin,
entries: [
{
name: "whole_json",
value: { source: "secret", secret_id: "whole_json" },
},
],
},
],
},
actions: [
{
step_id: "settled",
action: "wait_for",
locator: { kind: "css", selector: '#result[data-ready="yes"]' },
state: "visible",
timeout_ms: 10000,
},
],
});
assert.ok(
whole.normalized_result.summary.steps.every(
(step) => step.status === "completed",
),
);
const wholePath = join(output, "whole-json-evidence.json");
await invoke("export_evidence_bundle", { path: wholePath });
const wholeBundle = JSON.parse(await readFile(wholePath, "utf8"));
const wholeParent = wholeBundle.records.find(
(record) =>
record.evidence_id === whole.normalized_result.parent_evidence_id,
);
assert.ok(wholeParent);
const post = wholeParent.normalized_result.events.items.find(
(event) => event.kind === "request" && event.method === "POST",
);
assert.ok(post);
const postEvents = wholeParent.normalized_result.events.items.filter(
(event) => event.transaction_id === post.transaction_id,
);
assert.ok(
postEvents.some(
(event) =>
event.kind === "network-content" &&
event.phase === "request" &&
event.body.state === "unavailable",
),
);
const postResponse = postEvents.find((event) => event.kind === "response");
assert.ok(postResponse && [400, 401, 403].includes(postResponse.status));
assert.ok(!JSON.stringify(wholeParent).includes("private-token-729d"));
assert.equal(
(await (await fetch(countUrl)).json()).totalItems,
beforeCount,
);
const followup = await invoke("capture_browser_scenario", {
...sourceScenario,
start_url: { url: origin + "/immediate-followup.html" },
capture: {
...sourceScenario.capture,
events: [...sourceScenario.capture.events, "console"],
after_each_step: [
...sourceScenario.capture.after_each_step,
"screenshot",
],
at_end: [...sourceScenario.capture.at_end, "screenshot"],
},
actions: [
{
step_id: "exchange",
action: "click",
locator: { kind: "css", selector: "#run" },
save_response: {
method: "GET",
path: "/escaped-values.json",
save: [{ name: "token", json_pointer: "/token", secret: true }],
},
timeout_ms: 10000,
},
{
step_id: "settled",
action: "wait_for",
locator: { kind: "css", selector: '#result[data-ready="yes"]' },
state: "visible",
timeout_ms: 10000,
},
],
});
const followupPath = join(output, "followup-evidence.json");
await invoke("export_evidence_bundle", { path: followupPath });
const followupBundle = JSON.parse(await readFile(followupPath, "utf8"));
const followupParent = followupBundle.records.find(
(record) =>
record.evidence_id === followup.normalized_result.parent_evidence_id,
);
assert.ok(followupParent);
assert.equal(await page.locator("#sensitive").innerText(), "source-token");
assert.equal(followupParent.normalized_result.steps.length, 3);
assert.ok(
followupParent.normalized_result.steps.every(
(step) =>
step.artifacts.screenshot.state === "missing" &&
step.completeness.missing_sections.includes("screenshot"),
),
);
assert.ok(
followupParent.normalized_result.completeness.missing_sections.includes(
"screenshot",
),
);
const consoleEvents = followupParent.normalized_result.events.items.filter(
(event) => event.kind === "console" && event.text.includes("REDACTED"),
);
assert.equal(consoleEvents.length, 1);
assert.ok(
!JSON.stringify(followupParent.normalized_result.events.items).includes(
"source-token",
),
);
assert.ok(
followup.normalized_result.summary.steps.every(
(step) => step.status === "completed",
),
);
const followupPost = followupParent.normalized_result.events.items.find(
(event) => event.kind === "request" && event.method === "POST",
);
assert.ok(followupPost);
const followupEvents = followupParent.normalized_result.events.items.filter(
(event) => event.transaction_id === followupPost.transaction_id,
);
assert.ok(!JSON.stringify(followupEvents).includes("source-token"));
const followupBodies = decodeBodies({ events: followupEvents });
assert.ok(
followupBodies.some(
(body) => body?.token === "[REDACTED:page:exchange.token]",
),
);
assert.ok(
followupEvents.some(
(event) => event.kind === "response" && event.status === 403,
),
);
assert.equal(
(await (await fetch(countUrl)).json()).totalItems,
beforeCount,
);
const publicScreenshot = await invoke("capture_browser_scenario", {
...sourceScenario,
start_url: { url: origin + "/immediate-followup.html" },
actions: [
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 },
],
capture: {
...sourceScenario.capture,
events: [],
network: {
request_body: false,
response_body: false,
header_values: false,
},
after_each_step: [],
at_end: ["screenshot"],
},
});
const publicPath = join(output, "public-screenshot-evidence.json");
await invoke("export_evidence_bundle", { path: publicPath });
const publicBundle = JSON.parse(await readFile(publicPath, "utf8"));
const publicParent = publicBundle.records.find(
(record) =>
record.evidence_id ===
publicScreenshot.normalized_result.parent_evidence_id,
);
assert.ok(publicParent);
assert.equal(await page.locator("#sensitive").innerText(), "");
const png =
publicParent.normalized_result.steps.at(-1).artifacts.screenshot;
assert.equal(png.state, "captured");
assert.equal(
Buffer.from(png.value.data_base64, "base64")
.subarray(0, 8)
.toString("hex"),
"89504e470d0a1a0a",
);
runs.push({
verification: "source-and-later-JSON-spellings",
verified_bodies: bodies.length,
metadata_only_source_url: true,
whole_json_secret_withheld: true,
immediate_followup_protected: true,
immediate_console_protected: true,
rendered_secret_screenshots_withheld: true,
nonsecret_screenshot_captured: true,
whole_json_status: postResponse.status,
});
}
await writeFile(join(output, "calls.json"), JSON.stringify(calls), {
mode: 0o600,
});
await writeFile(
join(output, "metrics.json"),
JSON.stringify({ mode: values.mode, runs }, null, 2),
);
console.log(JSON.stringify({ mode: values.mode, runs }));
} finally {
await withVerifierCleanup(
() => commands.close(),
[
async () => {
if (commands.cleanupResources.length === 0)
await rm(profile, { recursive: true, force: true });
},
],
);
}