feat(browser): save values from UI steps and send them as {saved:name}

- click and press steps take save_response {method, path, save}: the
  recorder waits, before the step acts, for the 2xx JSON response to the
  request it triggers and saves values from it
- {saved:name} sends a saved value in a request path (and its query), a goto
  URL, and a request's JSON body, where a string that is only the
  placeholder keeps the saved JSON type; header values and Basic
  credentials parts take {source: "saved", name}
- Basic credentials take parts: [username, password], each a literal,
  secret, saved, cookie or local storage value, so the schema repeats the
  part union once; path_params and json_values are gone, which keeps the
  compact MCP catalog under its 1 MiB frame
- validation refuses a saved value no earlier step saves; other braces are
  sent as written
- Evidence records an empty save list as absent, so scenarios that save
  nothing record the same parameters and scenario_sha256 as before
- environment-build warns about saved values named like credentials that
  are not marked secret (credential-named-saved-values)
- the skill writes the way the UI does and falls back to request steps;
  skill version 50

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-25 10:43:40 -07:00
1 parent 51cf0c3ad2
commit d2e9b68043
17 files changed
+1014 -202

No files matched your search

@@ -2,7 +2,7 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA, and build agent environments (AgentEnv bundles) of web applications from a live instance or a capture. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "49"
version: "50"
---
# REA
@@ -101,10 +101,7 @@ shape, with the application's own locators, paths and headers:
"action": "request",
"timeout_ms": 15000,
"method": "GET",
"path": "/api/folders/{id}",
"path_params": [
{ "name": "id", "value": { "source": "saved", "name": "folder_1" } }
],
"path": "/api/folders/{saved:folder_1}",
"expected_status": [200]
}
]
@@ -121,12 +118,17 @@ client with it. Match `wait_for` locators to exactly one element.
2. Read with `request` steps: each collection the environment needs, twice
before any write, so fields that change on their own show up; one item of
each.
3. Write with `request` steps too. When the application assigns an object's
ID, `save` it from the create's response and send it to later steps through
`path_params` (a `{name}` placeholder in `path`), `json_values` or a header:
never guess an ID, and never create a probe object to learn the next one.
Use the UI only for what the API cannot do; if a journey cannot be scripted,
leave the write unserved and report the limitation.
3. Write the way the application's UI does: fill its forms and click its
buttons, so each request carries the fields the UI sends, including ones
the server would otherwise derive. Give the click or key press that submits
a create `save_response` to keep the new object's ID, then `goto` its page
(`/items/{saved:item_1}`) or read it with a `request` step. Never guess an
ID, and never create a probe object to learn the next one. Write with
`request` steps instead only when the UI cannot reach the operation (an
API-only resource, or a UI that does not call the JSON API), or when coverage
withholds the UI-shaped write as `unexplained-change` or `contradicted`;
that counts as one of the two re-recordings. If a journey cannot be
scripted, leave the write unserved and report the limitation.
Record two numbered scratch objects per write type,
with varied free text. After each write, and before the next one, read the
object back by id and then its list: create 1, read 1, list, create 2,
@@ -143,20 +145,27 @@ About `request` steps:
(`{source: "cookie", name}`), a local storage entry
(`{source: "local_storage", key, json_pointer}`), a saved value
(`{source: "saved", name}`), or Basic credentials
(`{source: "basic_credentials", username, password}`, sent as the base64 of
`username:password` and redacted), with an optional `prefix` such as
`"Bearer "` or `"Basic "`. An API that takes Basic auth, or a header of
encoded credentials with no prefix, needs no session from the UI. Copy the
(`{source: "basic_credentials", parts: [username, password]}`, sent as the
base64 of `username:password` and redacted), with an optional `prefix` such as
`"Bearer "` or `"Basic "`. Each credential part is a literal, secret, saved
value, cookie or local storage entry, so a header the frontend builds from a
login token (the username and the token) can be copied too. An API that
takes Basic auth needs no session from the UI. Copy the
headers the application's own requests send (a CSRF header, a bearer
token). Values read from the page are redacted from records made after they
are read, not from the login response that returned them, and values
shorter than 8 characters are not redacted at all.
- `save: [{name, json_pointer, secret}]` keeps a string or number from the
step's JSON response, such as the created object's `/id`, for later steps;
`secret: true` redacts it, as for a token the API returns. Later steps send
it as `{source: "saved", name}` in `path_params`, `query`, headers, or
`json_values: [{json_pointer, value}]`, which sets it in the body with the
JSON type it was saved with.
`secret: true` redacts it, as for a token the API returns, so mark every
saved token, key or session value secret. On a `click` or `press`,
`save_response: {method, path, save}` keeps values from the response to the
request the step triggers (`{*}` in `path` matches one segment); the step
fails unless a 2xx JSON response arrives before its `timeout_ms`. Later
steps send a saved value as `{saved:name}` in a request `path` (and its
query) or a `goto` URL; in the `json` body, where a string that is only
`{saved:name}` keeps the saved JSON type; or as `{source: "saved", name}` in
a header or credential part.
- Reading a page value makes later bodies costlier to keep: after a CSRF
token, bodies over about 47 KiB are kept as `unavailable`. When the API
returns larger responses, raise `capture.limits`
+4 -1
View File
@@ -203,7 +203,10 @@ attached and a transaction no record represents, and each transaction URL's
query once. Bodies larger than 4 MiB of UTF-8 text are not
scanned; `credential-scan-incomplete` counts them and lists up to 20 record or
event pointers, including base64 bodies whose decoded scan was cut. An empty name
warning does not establish that these bodies were checked. Names do not establish sensitivity,
warning does not establish that these bodies were checked. A browser scenario's
saved values (`save` and `save_response`) named like credentials but not marked
`secret: true` are recorded unredacted; `credential-named-saved-values` lists up
to 20 of them by parameter pointer. Names do not establish sensitivity,
so the build never refuses on them. Capture
Evidence also records the capture file's absolute path (`subject.local_path`,
+81 -1
View File
@@ -207,7 +207,8 @@ HTTP(S) URLs may include ordinary query values and fragments directly. Use
ordered structured query entries when a value needs to reference a declared
environment-backed secret. Form, storage, and cookie values are either literal
strings or declared secret references; request step headers can also read a
cookie or local storage value from the page. Raw URL userinfo credentials are rejected.
cookie or local storage value from the page, and request steps and `goto`
destinations can send [saved values](#saved-values). Raw URL userinfo credentials are rejected.
Captured credential-header
values are not retained, and declared secret values are redacted automatically.
A secret may be declared solely to
@@ -400,6 +401,14 @@ data you may change. This scenario creates one item:
by default) get smaller: after a 32-character `X-CSRFToken` value, a body over
about 47 KiB is kept as `unavailable`. Raise `capture.limits` when the
application's responses are larger.
- A header can send Basic credentials, `{"source": "basic_credentials",
"parts": [username, password]}`: the base64 of `username:password`, after a
`prefix` such as `"Basic "` or with none, as some APIs take encoded
credentials in their own header. Each part is a literal, a declared secret, a
saved value, a cookie or a local storage entry, so a header a frontend builds
from a login token can be sent too. The encoded value is redacted like a page value, as
`[REDACTED:page:<step_id>.<header>]`, and parts read from the page are
redacted as page values are.
- Validation refuses a header name given twice in a step, header names a
page's `fetch` cannot set, such as `Cookie`, `Origin`, `Host` or `Sec-*`, and
`User-Agent`, which Chrome drops; it refuses line breaks and other ASCII
@@ -425,6 +434,77 @@ Captures with request steps state in `limitations` that the steps sent their
declared requests through the page's fetch. A scenario without request steps
is unchanged.
## Saved values
A step can keep a string or number from a JSON response, by name, and a later
step can send it, so a scenario can read back, edit and delete an object whose
id the application assigns:
```json
[
{
"step_id": "save-item-1",
"action": "click",
"timeout_ms": 10000,
"locator": { "kind": "role", "role": "button", "name": "Save" },
"save_response": {
"method": "POST",
"path": "/api/items",
"save": [{ "name": "item_1", "json_pointer": "/id" }]
}
},
{
"step_id": "open-item-1",
"action": "goto",
"timeout_ms": 10000,
"destination": { "url": "http://127.0.0.1:3000/items/{saved:item_1}" },
"wait_until": "load"
},
{
"step_id": "copy-item-1",
"action": "request",
"timeout_ms": 10000,
"method": "POST",
"path": "/api/items/{saved:item_1}/copies",
"json": {
"source_id": "{saved:item_1}",
"title": "Copy of {saved:item_1}"
},
"expected_status": [201]
}
]
```
- A `request` step saves with `save: [{name, json_pointer, secret}]`, from its
own response. A `click` or `press` step saves with `save_response: {method,
path, save}`, from the response to the request it triggers, such as the
create a form's Save button sends: the recorder starts waiting before the
step acts, matches the method and a path on the start origin (`{*}` matches
one segment, and the query is ignored), and fails the step unless a 2xx JSON
response arrives before its `timeout_ms`. A pointer that names no string or
number fails the step, so later steps never send a missing value.
- `{saved:name}` sends a saved value in a request step's `path`, including its
query, and in a `goto` destination's URL, percent-encoded. In a request
step's `json` body, a string that is only `{saved:name}` becomes the saved
value with the JSON type it was saved with, so a saved number id is sent as a
number, and `{saved:name}` inside a longer string is filled as text. A header
value or Basic credentials part sends one as `{"source": "saved", "name"}`.
Other braces, such as `{id}`, are sent as written.
- Validation refuses a saved value that no earlier step saves. A later save of
the same name replaces the value.
- A value saved with `secret: true` is redacted like a page value, as
`[REDACTED:page:<step_id>.<name>]`, from records made after it is saved. The
recorder reads response bodies on their own schedule, so the response it was
saved from may keep it, as a login response may keep a token a later step
reads from the page. Other saved values, such as ids, stay visible, since
environments model them. The environment build warns about saved values
named like credentials that are not marked secret
(`credential-named-saved-values`).
- Evidence records a request step's empty `save` list as absent, so a scenario
that saves nothing records the same parameters and `scenario_sha256` as
before saved values existed, and REA versions without saved values read its
captures.
## Network evidence
`capture.events: ["network"]` retains request, response, completion, failure,
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { scenarioCaptureFixture } from "../../tests/fixtures/browserScenarioCapture.js";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
import { createBrowserScenarioEvidence } from "./BrowserScenarioEvidence.js";
const ORIGIN = "http://127.0.0.1:3101";
const parameters = (actions: readonly unknown[]) =>
createBrowserScenarioEvidence(
browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: "/opt/chromium/chrome" },
start_url: { url: `${ORIGIN}/` },
actions,
}),
scenarioCaptureFixture(),
{ id: "fixture", name: "fixture", version: "1" },
).parameters;
const open = (fields: Record<string, unknown> = {}) => ({
step_id: "open",
action: "goto",
destination: { url: `${ORIGIN}/items` },
wait_until: "load",
...fields,
});
const create = (fields: Record<string, unknown> = {}) => ({
step_id: "create",
action: "request",
method: "POST",
path: "/api/items",
json: { title: "Item 1" },
...fields,
});
describe("browser scenario evidence parameters", () => {
it("records a scenario that saves nothing as it was recorded before saved values existed", () => {
const recorded = parameters([open(), create()]);
const actions = recorded["actions"] as Record<string, unknown>[];
expect(Object.keys(actions[1]!)).not.toContain("save");
expect(parameters([open(), create({ save: [] })])).toEqual(recorded);
const { scenario_sha256, ...scenario } = recorded;
expect(scenario_sha256).toBe(
digestCanonicalValue(scenario, "Browser scenario"),
);
});
it("records the values a step saves and the placeholders that send them", () => {
const recorded = parameters([
create({ save: [{ name: "item", json_pointer: "/id" }] }),
open({ destination: { url: `${ORIGIN}/items/{saved:item}` } }),
]);
expect(recorded["actions"]).toMatchObject([
{ save: [{ name: "item", json_pointer: "/id", secret: false }] },
{ destination: { url: `${ORIGIN}/items/%7Bsaved:item%7D` } },
]);
expect(recorded["scenario_sha256"]).not.toBe(
parameters([create(), open()])["scenario_sha256"],
);
});
});
+19 -4
View File
@@ -9,12 +9,27 @@ import {
} from "../domain/evidence.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
/** A step as Evidence records it: a request step leaves out an empty `save`, so a scenario that saves nothing records and hashes as it did before saved values existed. */
const recordedAction = (
action: BrowserScenario["actions"][number],
): Record<string, unknown> => {
if (action.action !== "request" || action.save.length > 0) return action;
const { save, ...rest } = action;
return rest;
};
const browserScenarioParameters = (
scenario: BrowserScenario,
): EvidenceObservation["parameters"] => ({
...jsonObjectSchema.parse(scenario),
scenario_sha256: digestCanonicalValue(scenario, "Browser scenario"),
});
): EvidenceObservation["parameters"] => {
const recorded = {
...scenario,
actions: scenario.actions.map(recordedAction),
};
return {
...jsonObjectSchema.parse(recorded),
scenario_sha256: digestCanonicalValue(recorded, "Browser scenario"),
};
};
/** Create Evidence without retaining resolved scenario secret values. */
export const createBrowserScenarioEvidence = (
@@ -24,6 +24,7 @@ import {
import {
anonymousOrigin,
credentialScanWarnings,
savedCredentialWarnings,
nameFromCaptureFile,
type EnvironmentBuildWarning,
} from "./environmentBuildReport.js";
@@ -319,7 +320,10 @@ export const buildEnvironment = async (
name,
operation: ENVIRONMENT_EXPORT_OPERATION,
});
warnings.push(...credentialScanWarnings(recorded.value));
warnings.push(
...credentialScanWarnings(recorded.value),
...savedCredentialWarnings(capture.parameters),
);
const packageSource: EnvironmentPackageSource =
input.packageTarball === undefined
@@ -6,6 +6,7 @@ import {
anonymousOrigin,
credentialNamed,
nameFromCaptureFile,
savedCredentialWarnings,
scanCredentialNames,
scanScenarioCredentialNames,
} from "./environmentBuildReport.js";
@@ -455,3 +456,54 @@ it.each([
});
},
);
describe("savedCredentialWarnings", () => {
const entry = (name: string, secret?: boolean) => ({
name,
json_pointer: "/x",
...(secret === undefined ? {} : { secret }),
});
it("points at saved values named like credentials that are not marked secret", () => {
expect(
savedCredentialWarnings({
actions: [
{ action: "goto" },
{
action: "request",
save: [
entry("item_id"),
entry("access_token"),
entry("api_key", true),
],
},
{
action: "click",
save_response: { save: [entry("password", false), entry("list")] },
},
],
}),
).toEqual([
{
code: "credential-named-saved-values",
message: expect.stringContaining("2 saved values"),
pointers: [
"/parameters/actions/1/save/1",
"/parameters/actions/2/save_response/save/0",
],
},
]);
});
it("warns about nothing when every credential-named value is secret, or the parameters are not a scenario", () => {
expect(
savedCredentialWarnings({
actions: [
{ action: "request", save: [entry("token", true), entry("id")] },
],
}),
).toEqual([]);
expect(savedCredentialWarnings({ operation: "har" })).toEqual([]);
expect(savedCredentialWarnings(undefined)).toEqual([]);
});
});
@@ -1,3 +1,4 @@
import { z } from "zod";
import { basename } from "node:path";
import { isIP } from "node:net";
@@ -330,6 +331,57 @@ export const credentialScanWarnings = (capture: {
return warnings;
};
const savedEntries = z.array(
z.object({ name: z.string(), secret: z.boolean().optional() }),
);
const scenarioSaves = z.object({
actions: z.array(
z
.object({
save: savedEntries.optional(),
save_response: z.object({ save: savedEntries }).optional(),
})
.loose(),
),
});
/** A browser scenario's saved values named like credentials but not marked secret, which the capture records unredacted. */
export const savedCredentialWarnings = (
parameters: unknown,
): EnvironmentBuildWarning[] => {
const parsed = scenarioSaves.safeParse(parameters);
if (!parsed.success) return [];
const pointers: string[] = [];
parsed.data.actions.forEach((action, index) => {
const unmarked = (
entries:
| readonly {
readonly name: string;
readonly secret?: boolean | undefined;
}[]
| undefined,
at: string,
) =>
entries?.forEach((entry, position) => {
if (entry.secret !== true && credentialNamed(entry.name))
pointers.push(
`/parameters/actions/${String(index)}/${at}/${String(position)}`,
);
});
unmarked(action.save, "save");
unmarked(action.save_response?.save, "save_response/save");
});
return pointers.length === 0
? []
: [
{
code: "credential-named-saved-values",
message: `${String(pointers.length)} saved values have credential-like names but are not marked secret, so the capture records them unredacted; mark them \`secret: true\` and record again before sharing.`,
pointers: pointers.slice(0, REPORTED_POINTERS),
},
];
};
/** Whether an origin's host is a loopback name or an IP literal, which says nothing about the application. */
export const anonymousOrigin = (origin: string): boolean => {
let host: string;
+3 -4
View File
@@ -319,10 +319,10 @@ export class BrowserScenarioSecrets {
return ok(new BrowserScenarioSecrets(values));
}
/** Values request steps saved from their responses, by name, as JSON strings or numbers. */
/** Values steps saved from JSON responses, by name, as strings or numbers. */
private readonly saved = new Map<string, string | number>();
/** Keep a value a request step saved from its response; a secret one is redacted from everything recorded after it. */
/** Keep a value a step saved from a JSON response; a secret one is redacted from everything recorded after it. */
save(
label: string,
name: string,
@@ -336,8 +336,7 @@ export class BrowserScenarioSecrets {
/** A saved value with the JSON type it was saved with. */
savedValue(name: string): string | number {
const value = this.saved.get(name);
if (value === undefined)
throw new Error(`No earlier request step saved ${name}`);
if (value === undefined) throw new Error(`No earlier step saved ${name}`);
return value;
}
+33 -7
View File
@@ -5,7 +5,11 @@ import type {
BrowserScenarioValue,
} from "../domain/browserScenarioValues.js";
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { performScenarioRequest } from "./PlaywrightScenarioRequest.js";
import {
awaitSavedResponse,
performScenarioRequest,
withSavedPlaceholders,
} from "./PlaywrightScenarioRequest.js";
type ScenarioLocator = Extract<
BrowserScenarioAction,
@@ -41,6 +45,22 @@ interface PerformActionInput {
readonly signal?: AbortSignal | undefined;
}
/** Start waiting for the response a click or key press names in `save_response`, before the step acts. */
const savingResponse = (
input: PerformActionInput,
action: Extract<BrowserScenarioAction, { action: "click" | "press" }>,
timeout: number | undefined,
): (() => Promise<void>) | undefined =>
action.save_response === undefined
? undefined
: awaitSavedResponse(input.page, {
stepId: action.step_id,
spec: action.save_response,
startOrigin: input.startOrigin,
timeout,
secrets: input.secrets,
});
/** Execute one admitted declarative action; a request step sends one declared request through the page's own fetch, and no action runs caller-supplied code. */
export const performPlaywrightScenarioAction = async (
input: PerformActionInput,
@@ -50,27 +70,33 @@ export const performPlaywrightScenarioAction = async (
const options = timeout === undefined ? {} : { timeout };
switch (action.action) {
case "goto":
await page.goto(secrets.url(action.destination), {
...options,
waitUntil: action.wait_until,
});
await page.goto(
withSavedPlaceholders(secrets.url(action.destination), secrets),
{ ...options, waitUntil: action.wait_until },
);
return;
case "click":
case "click": {
const saved = savingResponse(input, action, timeout);
await resolveLocator(page, action.locator).click({
...options,
button: action.button,
clickCount: action.click_count,
});
await saved?.();
return;
}
case "fill":
await resolveLocator(page, action.locator).fill(
resolveValue(action.value, secrets),
options,
);
return;
case "press":
case "press": {
const saved = savingResponse(input, action, timeout);
await resolveLocator(page, action.locator).press(action.key, options);
await saved?.();
return;
}
case "select_option":
await resolveLocator(page, action.locator).selectOption(
resolveValue(action.value, secrets),
+383 -2
View File
@@ -183,11 +183,11 @@ const fakePage = (options: {
} as unknown as Page;
};
const stubFetch = (status: number) => {
const stubFetch = (status: number, bodies: readonly string[] = []) => {
const calls: FakeFetch[] = [];
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
calls.push({ url, init });
return new Response("{}", { status });
return new Response(bodies[calls.length - 1] ?? "{}", { status });
});
return calls;
};
@@ -589,3 +589,384 @@ describe("page value redaction", () => {
expect(held.redact(text)).toBe(text);
});
});
describe("saved value schema", () => {
const saved = (name: string) => ({ source: "saved", name });
const saveId = (stepId: string, name: string) =>
requestStep({
step_id: stepId,
save: [{ name, json_pointer: "/id" }],
});
const clickSaving = (name: string) => ({
step_id: "save-item",
action: "click",
locator: { kind: "role", role: "button", name: "Save" },
save_response: {
method: "POST",
path: "/api/items",
save: [{ name, json_pointer: "/id" }],
},
});
const gotoItem = (url: string) => ({
step_id: "open",
action: "goto",
destination: { url },
wait_until: "load",
});
it("refuses a saved value no earlier step saves, wherever a step sends it", () => {
const sendsItem = requestStep({
step_id: "edit",
method: "PUT",
path: "/api/items/{saved:item}",
});
expect(issues([sendsItem])).toEqual([
"Step edit sends saved value item, which no earlier step saves",
]);
expect(issues([sendsItem, saveId("create", "item")])).toContainEqual(
expect.stringContaining("which no earlier step saves"),
);
expect(issues([saveId("create", "item"), sendsItem])).toEqual([]);
expect(issues([clickSaving("item"), sendsItem])).toEqual([]);
for (const step of [
gotoItem(`${ORIGIN}/items/{saved:item}`),
gotoItem(`${ORIGIN}/items/%7Bsaved%3Aitem%7D`),
requestStep({ path: "/api/items?parent={saved:item}" }),
requestStep({ json: { list: [{ parent: "{saved:item}" }] } }),
requestStep({ json: { note: "Copied from {saved:item}" } }),
requestStep({ headers: [{ name: "X-Item", value: saved("item") }] }),
requestStep({
headers: [
{
name: "Authorization",
value: {
source: "basic_credentials",
parts: [saved("item"), { source: "literal", value: "x" }],
},
},
],
}),
])
expect(issues([step]), JSON.stringify(step)).toContainEqual(
expect.stringContaining("sends saved value item"),
);
});
it("sends other braces as written", () => {
expect(
issues([
gotoItem(`${ORIGIN}/search/{q}`),
requestStep({ path: "/api/items/{id}", json: { name: "{draft}" } }),
]),
).toEqual([]);
});
it("builds Basic credentials from page values and refuses undeclared secret parts", () => {
const basic = (username: unknown, password: unknown) =>
requestStep({
headers: [
{
name: "Authorization",
value: { source: "basic_credentials", parts: [username, password] },
prefix: "Basic ",
},
],
});
expect(
issues([
basic(
{ source: "local_storage", key: "user", json_pointer: "/name" },
{ source: "cookie", name: "pw" },
),
]),
).toEqual([]);
expect(
issues([
basic(
{ source: "literal", value: "admin" },
{ source: "secret", secret_id: "password" },
),
]),
).toContainEqual(expect.stringContaining("not declared"));
const literal = { source: "literal", value: "admin" };
expect(
issues([
requestStep({
headers: [
{
name: "Authorization",
value: { source: "basic_credentials", parts: [literal] },
},
],
}),
]).length,
).toBeGreaterThan(0);
});
it("requires a save_response to name a same-origin path and something to save", () => {
const click = (saveResponse: unknown) => ({
...clickSaving("item"),
save_response: saveResponse,
});
for (const path of ["//evil.test/api", "api/items", "https://evil.test/"])
expect(
issues([
click({
method: "POST",
path,
save: [{ name: "item", json_pointer: "/id" }],
}),
]).length,
path,
).toBeGreaterThan(0);
expect(
issues([click({ method: "POST", path: "/api/items", save: [] })]).length,
).toBeGreaterThan(0);
});
});
describe("saved value execution", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("sends saved ids in the path and body with their saved type, and a saved secret only redacted", async () => {
const calls = stubFetch(200, [
'{"id":42,"session":{"token":"session-token-9f3e"}}',
]);
const parsed = scenario([
requestStep({
json: { title: "Scratch folder 1" },
save: [
{ name: "folder", json_pointer: "/id" },
{ name: "token", json_pointer: "/session/token", secret: true },
],
}),
requestStep({
step_id: "move",
method: "PUT",
path: "/api/folders/{saved:folder}",
json: {
title: "Moved",
parent: "{saved:folder}",
note: "Moved into {saved:folder}",
},
headers: [
{
name: "Authorization",
value: { source: "saved", name: "token" },
prefix: "Bearer ",
},
],
}),
]);
const secrets = resolved(parsed);
const page = fakePage({});
for (const action of parsed.actions) await perform(page, action, secrets);
expect(calls[1]).toEqual({
url: `${ORIGIN}/api/folders/42`,
init: expect.objectContaining({
method: "PUT",
headers: [
["Authorization", "Bearer session-token-9f3e"],
["content-type", "application/json"],
],
body: '{"title":"Moved","parent":42,"note":"Moved into 42"}',
}),
});
expect(secrets.redact("folder 42 with session-token-9f3e")).toBe(
"folder 42 with [REDACTED:page:create.token]",
);
});
it("fails a step whose response holds no value to save", async () => {
stubFetch(200, ['{"items":[]}', "not json"]);
const parsed = scenario([
requestStep({ save: [{ name: "folder", json_pointer: "/id" }] }),
]);
const secrets = resolved(parsed);
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow("no string or number at /id to save as folder");
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow("is not JSON");
expect(() => secrets.savedValue("folder")).toThrow();
});
it("encodes Basic credentials read from local storage and a cookie, and redacts both parts", async () => {
const calls = stubFetch(200);
const parsed = scenario([
requestStep({
headers: [
{
name: "Authorization",
value: {
source: "basic_credentials",
parts: [
{
source: "local_storage",
key: "user",
json_pointer: "/name",
},
{ source: "cookie", name: "api_key" },
],
},
prefix: "Basic ",
},
],
}),
]);
const secrets = resolved(parsed);
await perform(
fakePage({
cookies: [{ name: "api_key", value: "cookie-key-5d1c" }],
storage: { user: JSON.stringify({ name: "operator-7" }) },
}),
parsed.actions[0]!,
secrets,
);
const encoded = Buffer.from("operator-7:cookie-key-5d1c").toString(
"base64",
);
expect(calls[0]!.init.headers).toEqual([
["Authorization", `Basic ${encoded}`],
]);
const redacted = secrets.redact(`${encoded} operator-7 cookie-key-5d1c`);
for (const value of [encoded, "operator-7", "cookie-key-5d1c"])
expect(redacted).not.toContain(value);
});
it("fills a goto destination's placeholders, percent-encoded", async () => {
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
{
step_id: "open",
action: "goto",
destination: { url: `${ORIGIN}/items/{saved:item}/edit` },
wait_until: "load",
},
]);
const secrets = resolved(parsed);
secrets.save("create.item", "item", "a b/c", false);
const goto = vi.fn(async () => null);
await perform({ goto } as unknown as Page, parsed.actions[1]!, secrets);
expect(goto).toHaveBeenCalledWith(
`${ORIGIN}/items/a%20b%2Fc/edit`,
expect.anything(),
);
});
});
describe("saving from a UI step's response", () => {
interface FakeResponse {
readonly method: string;
readonly url: string;
readonly status: number;
readonly body: string;
}
/** A page whose click triggers the given responses; waitForResponse resolves with the first one it names. */
const uiPage = (responses: readonly FakeResponse[]) => {
const order: string[] = [];
const page = {
getByRole: () => ({
click: async () => {
order.push("click");
},
}),
waitForResponse: async (
names: (response: unknown) => boolean,
options: { timeout?: number },
) => {
order.push(`wait ${String(options.timeout)}`);
await Promise.resolve();
const found = responses
.map((response) => ({
request: () => ({ method: () => response.method }),
url: () => response.url,
status: () => response.status,
text: async () => response.body,
}))
.find(names);
if (found === undefined) throw new Error("Timeout exceeded");
return found;
},
} as unknown as Page;
return { page, order };
};
const clickSaving = (path: string) =>
scenario([
{
step_id: "save-item",
action: "click",
timeout_ms: 5000,
locator: { kind: "role", role: "button", name: "Save" },
save_response: {
method: "POST",
path,
save: [{ name: "item", json_pointer: "/id" }],
},
},
]);
it("waits before clicking, and saves from the response the step names", async () => {
const parsed = clickSaving("/api/lists/{*}/items");
const secrets = resolved(parsed);
const { page, order } = uiPage([
{
method: "GET",
url: `${ORIGIN}/api/lists/3/items`,
status: 200,
body: '{"id":"read"}',
},
{
method: "POST",
url: `https://other.test/api/lists/3/items`,
status: 201,
body: '{"id":"foreign"}',
},
{
method: "POST",
url: `${ORIGIN}/api/lists/3/4/items`,
status: 201,
body: '{"id":"deeper"}',
},
{
method: "POST",
url: `${ORIGIN}/api/lists/3/items?draft=1`,
status: 201,
body: '{"id":"item-7"}',
},
]);
await perform(page, parsed.actions[0]!, secrets);
expect(order).toEqual(["wait 5000", "click"]);
expect(secrets.savedValue("item")).toBe("item-7");
});
it("fails the step when no named response arrives or it is not 2xx", async () => {
const parsed = clickSaving("/api/items");
await expect(
perform(uiPage([]).page, parsed.actions[0]!, resolved(parsed)),
).rejects.toThrow(
"no POST /api/items response arrived before the step's timeout",
);
await expect(
perform(
uiPage([
{
method: "POST",
url: `${ORIGIN}/api/items`,
status: 422,
body: '{"id":"x"}',
},
]).page,
parsed.actions[0]!,
resolved(parsed),
),
).rejects.toThrow(
"answered 422; values are saved only from a 2xx response",
);
});
});
+171 -74
View File
@@ -2,8 +2,12 @@ import type { Page, Route } from "playwright-core";
import {
HEADER_FIELD_VALUE,
SAVED_PLACEHOLDER,
type BrowserScenarioAction,
type BrowserScenarioCredentialPart,
type BrowserScenarioRequestHeader,
type BrowserScenarioSaveEntry,
type BrowserScenarioSaveResponse,
} from "../domain/browserScenarioValues.js";
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
@@ -59,31 +63,23 @@ const pointerValue = (
: undefined;
};
/** Resolve one header value; values read from the page are redacted from later records. */
const headerValue = async (
step: {
readonly page: Page;
readonly url: URL;
readonly stepId: string;
readonly secrets: BrowserScenarioSecrets;
},
header: BrowserScenarioRequestHeader,
interface PageValueStep {
readonly page: Page;
readonly url: URL;
readonly stepId: string;
readonly secrets: BrowserScenarioSecrets;
}
/** Read a value from the page, a cookie or a local storage entry, and redact it from later records under a label. */
const readPageValue = async (
step: PageValueStep,
value: Extract<
BrowserScenarioCredentialPart,
{ source: "cookie" | "local_storage" }
>,
label: string,
): Promise<string> => {
const { page, url, stepId, secrets } = step;
const { value } = header;
if (
value.source === "literal" ||
value.source === "secret" ||
value.source === "saved"
)
return secrets.value(value);
if (value.source === "basic_credentials") {
const encoded = Buffer.from(
`${secrets.value(value.username)}:${secrets.value(value.password)}`,
).toString("base64");
secrets.admitPageValue(`${stepId}.${header.name}`, encoded);
return encoded;
}
let read: string | undefined;
if (value.source === "cookie") {
const cookies = await page.context().cookies(url.href);
@@ -133,10 +129,45 @@ const headerValue = async (
);
}
}
secrets.admitPageValue(`${stepId}.${header.name}`, read);
secrets.admitPageValue(label, read);
return read;
};
/** One part of Basic credentials: a literal, secret or saved value, or a value read from the page. */
const credentialPart = (
step: PageValueStep,
part: BrowserScenarioCredentialPart,
label: string,
): Promise<string> | string =>
part.source === "cookie" || part.source === "local_storage"
? readPageValue(step, part, label)
: step.secrets.value(part);
/** Resolve one header value; values read from the page, and credentials built from them, are redacted from later records. */
const headerValue = async (
step: PageValueStep,
header: BrowserScenarioRequestHeader,
): Promise<string> => {
const { stepId, secrets } = step;
const { value } = header;
const label = `${stepId}.${header.name}`;
if (
value.source === "literal" ||
value.source === "secret" ||
value.source === "saved"
)
return secrets.value(value);
if (value.source === "basic_credentials") {
const [username, password] = value.parts;
const user = await credentialPart(step, username!, `${label}.username`);
const pass = await credentialPart(step, password!, `${label}.password`);
const encoded = Buffer.from(`${user}:${pass}`).toString("base64");
secrets.admitPageValue(label, encoded);
return encoded;
}
return readPageValue(step, value, label);
};
/** Append declared query entries after any literal query the path carries, leaving that query as written. */
const withQuery = (
url: URL,
@@ -168,8 +199,8 @@ interface PageRequest {
readonly headers: [string, string][];
readonly body: string | undefined;
readonly deadline: number | undefined;
/** Return the response text, for a step that saves values from it. */
readonly read: boolean;
/** Return the response text up to this many characters, for a step that saves values from it. */
readonly readLimit: number | undefined;
}
/** The largest response text a request step saves values from. */
@@ -217,9 +248,11 @@ const sendFromPage = (
...(request.body === undefined ? {} : { body: request.body }),
});
if (answer.type === "opaqueredirect") return { redirected: true };
if (request.read) {
if (request.readLimit !== undefined) {
const text = await answer.text();
return { status: answer.status, body: text };
return text.length > request.readLimit
? { status: answer.status }
: { status: answer.status, body: text };
}
const reader = answer.body?.getReader();
if (reader !== undefined)
@@ -262,16 +295,7 @@ export const performScenarioRequest = async (input: {
throw new ScenarioRequestError(
`Request step ${action.step_id} runs from a page on the start origin ${startOrigin}; navigate back to it with a goto step first.`,
);
const path = action.path.replace(
/\{([A-Za-z][A-Za-z0-9._-]*)\}/gu,
(placeholder, name: string) => {
const param = action.path_params.find((entry) => entry.name === name);
return param === undefined
? placeholder
: encodeURIComponent(secrets.value(param.value));
},
);
const url = new URL(path, startOrigin);
const url = new URL(withSavedPlaceholders(action.path, secrets), startOrigin);
if (url.origin !== startOrigin)
throw new ScenarioRequestError(
`Request step ${action.step_id} must stay on the start origin ${startOrigin}.`,
@@ -301,7 +325,8 @@ export const performScenarioRequest = async (input: {
throw new ScenarioRequestError(
`Request step ${action.step_id} ran out of time before its request was sent.`,
);
const json = withJsonValues(action, secrets);
const json =
action.json === undefined ? undefined : withSavedJson(action.json, secrets);
const request = {
url: target,
origin: startOrigin,
@@ -309,7 +334,8 @@ export const performScenarioRequest = async (input: {
headers,
body: json === undefined ? undefined : JSON.stringify(json),
deadline,
read: action.save.length > 0,
readLimit:
action.save.length > 0 ? MAX_SAVED_RESPONSE_CHARACTERS : undefined,
};
// The browser can queue evaluate behind a busy page. Keep a host-side gate
// until that evaluation settles, including after the step boundary stops
@@ -359,64 +385,135 @@ export const performScenarioRequest = async (input: {
throw new ScenarioRequestError(
`Request step ${action.step_id} answered ${String(response.status)}; expected ${action.expected_status.join(" or ")}.`,
);
if (action.save.length > 0) saveValues(action, response.body, secrets);
if (action.save.length > 0)
saveValues(action.step_id, response.body, action.save, secrets);
};
/** The step's JSON body with its `json_values` set at their pointers; a saved value keeps its JSON type. */
const withJsonValues = (
action: RequestAction,
/** A path or URL with its `{saved:name}` placeholders filled, each value percent-encoded. */
export const withSavedPlaceholders = (
text: string,
secrets: BrowserScenarioSecrets,
): string =>
text.replace(SAVED_PLACEHOLDER, (_placeholder, name: string) =>
encodeURIComponent(String(secrets.savedValue(name))),
);
/** Whether a request a UI step triggered is the one its `save_response` names: method, start origin, and path with `{*}` matching one segment. */
const namesResponse = (
spec: BrowserScenarioSaveResponse,
startOrigin: string,
method: string,
address: string,
): boolean => {
if (method !== spec.method) return false;
const url = URL.parse(address);
if (url === null || url.origin !== startOrigin) return false;
const pattern = spec.path.split("?")[0]!.split("/");
const segments = url.pathname.split("/");
return (
segments.length === pattern.length &&
pattern.every((part, index) => part === "{*}" || part === segments[index])
);
};
/**
* Start waiting, before a UI step acts, for the response its `save_response` names; the returned call waits for that
* response and saves its values. The step fails unless a 2xx JSON response arrives before its timeout, or the page's
* default timeout when it sets none.
*/
export const awaitSavedResponse = (
page: Page,
step: {
readonly stepId: string;
readonly spec: BrowserScenarioSaveResponse;
readonly startOrigin: string;
readonly timeout: number | undefined;
readonly secrets: BrowserScenarioSecrets;
},
): (() => Promise<void>) => {
const { stepId, spec, startOrigin, timeout, secrets } = step;
const waiting = page.waitForResponse(
(response) =>
namesResponse(
spec,
startOrigin,
response.request().method(),
response.url(),
),
timeout === undefined ? {} : { timeout },
);
// The step may fail before it waits; the rejection is reported by the wait, or not at all.
waiting.catch(() => undefined);
return async () => {
let response: Awaited<typeof waiting>;
try {
response = await waiting;
} catch {
throw new ScenarioRequestError(
`Step ${stepId}: no ${spec.method} ${spec.path} response arrived before the step's timeout, so nothing was saved.`,
);
}
if (response.status() < 200 || response.status() > 299)
throw new ScenarioRequestError(
`Step ${stepId}: the ${spec.method} ${spec.path} response answered ${String(response.status())}; values are saved only from a 2xx response.`,
);
saveValues(stepId, await response.text(), spec.save, secrets);
};
};
/**
* The step's JSON body with its `{saved:name}` placeholders filled: a string that is only a placeholder becomes the
* saved value, keeping its JSON type, and a placeholder inside a longer string is filled as text.
*/
const withSavedJson = (
value: unknown,
secrets: BrowserScenarioSecrets,
): unknown => {
if (action.json_values.length === 0) return action.json;
const body: unknown = structuredClone(action.json ?? {});
for (const { json_pointer, value } of action.json_values) {
const tokens = json_pointer
.slice(1)
.split("/")
.map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~"));
const last = tokens.pop()!;
let parent: unknown = body;
for (const token of tokens)
parent =
parent !== null && typeof parent === "object"
? (parent as Record<string, unknown>)[token]
: undefined;
if (parent === null || typeof parent !== "object")
throw new ScenarioRequestError(
`Request step ${action.step_id}: the JSON body has no object or array at ${json_pointer}'s parent.`,
);
(parent as Record<string, unknown>)[last] =
value.source === "saved"
? secrets.savedValue(value.name)
: secrets.value(value);
if (typeof value === "string") {
const [whole] = [...value.matchAll(SAVED_PLACEHOLDER)];
if (whole !== undefined && whole[0] === value)
return secrets.savedValue(whole[1]!);
return value.replace(SAVED_PLACEHOLDER, (_placeholder, name: string) =>
String(secrets.savedValue(name)),
);
}
return body;
if (Array.isArray(value))
return value.map((item) => withSavedJson(item, secrets));
if (value !== null && typeof value === "object")
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
withSavedJson(item, secrets),
]),
);
return value;
};
/** Save the values the step names from its JSON response, for later request steps. */
/** Save the values a step names from a JSON response, for later steps. */
const saveValues = (
action: RequestAction,
stepId: string,
body: string | undefined,
entries: readonly BrowserScenarioSaveEntry[],
secrets: BrowserScenarioSecrets,
): void => {
if (body === undefined || body.length > MAX_SAVED_RESPONSE_CHARACTERS)
throw new ScenarioRequestError(
`Request step ${action.step_id}: the response is too large to save values from.`,
`Step ${stepId}: the response is too large to save values from.`,
);
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
throw new ScenarioRequestError(
`Request step ${action.step_id}: the response is not JSON, so no value can be saved from it.`,
`Step ${stepId}: the response is not JSON, so no value can be saved from it.`,
);
}
for (const { name, json_pointer, secret } of action.save) {
for (const { name, json_pointer, secret } of entries) {
const value = pointerValue(parsed, json_pointer);
if (value === undefined)
throw new ScenarioRequestError(
`Request step ${action.step_id}: the response has no string or number at ${json_pointer} to save as ${name}.`,
`Step ${stepId}: the response has no string or number at ${json_pointer} to save as ${name}.`,
);
secrets.save(`${action.step_id}.${name}`, name, value, secret);
secrets.save(`${stepId}.${name}`, name, value, secret);
}
};
+53 -35
View File
@@ -13,9 +13,10 @@ import {
HEADER_FIELD_VALUE,
type BrowserScenarioAction,
type BrowserScenarioUrl,
type BrowserScenarioRequestValue,
type BrowserScenarioValue,
savedPlaceholderNames,
} from "./browserScenarioValues.js";
import type { JsonValue } from "./jsonValue.js";
export const browserScenarioInputSchema = z.strictObject({
browser: browserScenarioBrowserSchema.describe(
@@ -59,26 +60,49 @@ const assertUnique = (
};
const secretReferencesInValue = (
value: BrowserScenarioValue | BrowserScenarioRequestValue | undefined,
value: BrowserScenarioValue | undefined,
): readonly string[] => (value?.source === "secret" ? [value.secret_id] : []);
/** The values a request step sends: query, path and body values, headers, and both parts of Basic credentials. */
const requestValues = (
/** A request step's header values, with Basic credentials as their two parts. */
const headerValues = (
action: Extract<BrowserScenarioAction, { action: "request" }>,
): readonly BrowserScenarioRequestValue[] => [
...action.query.map(({ value }) => value),
...action.path_params.map(({ value }) => value),
...action.json_values.map(({ value }) => value),
...action.headers.flatMap(({ value }) =>
value.source === "basic_credentials"
? [value.username, value.password]
: value.source === "literal" ||
value.source === "secret" ||
value.source === "saved"
? [value]
: [],
),
];
) =>
action.headers.flatMap(({ value }) =>
value.source === "basic_credentials" ? value.parts : [value],
);
/** Every string in a JSON value, at any depth. */
const jsonStrings = (value: JsonValue | undefined): readonly string[] => {
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.flatMap(jsonStrings);
if (value !== null && typeof value === "object")
return Object.values(value).flatMap(jsonStrings);
return [];
};
/** The saved values a step sends: `{saved:name}` in a request path, JSON string or goto URL, and saved header values and credential parts. */
const sentSavedNames = (action: BrowserScenarioAction): readonly string[] => {
if (action.action === "goto")
return savedPlaceholderNames(action.destination.url);
if (action.action !== "request") return [];
return [
...[action.path, ...jsonStrings(action.json)].flatMap(
savedPlaceholderNames,
),
...headerValues(action).flatMap((value) =>
value.source === "saved" ? [value.name] : [],
),
];
};
/** The names a step saves: a request's `save`, or a UI step's `save_response`. */
const savedNames = (action: BrowserScenarioAction): readonly string[] =>
action.action === "request"
? action.save.map(({ name }) => name)
: (action.action === "click" || action.action === "press") &&
action.save_response !== undefined
? action.save_response.save.map(({ name }) => name)
: [];
const destinationSecretReferences = (
destination: BrowserScenarioUrl,
@@ -93,36 +117,30 @@ const actionSecretReferences = (
if (action.action === "goto")
return destinationSecretReferences(action.destination);
if (action.action === "request")
return requestValues(action).flatMap(secretReferencesInValue);
return [
...action.query.flatMap(({ value }) => secretReferencesInValue(value)),
...headerValues(action).flatMap((value) =>
value.source === "secret" ? [value.secret_id] : [],
),
];
return [];
};
/** Each saved value a request step sends was saved by an earlier step, and each path placeholder has a value. */
/** Each saved value a step sends was saved by an earlier step. */
const validateSavedValues = (
scenario: ScenarioShape,
context: z.RefinementCtx,
): void => {
const saved = new Set<string>();
scenario.actions.forEach((action, index) => {
if (action.action !== "request") return;
for (const value of requestValues(action))
if (value.source === "saved" && !saved.has(value.name))
for (const name of new Set(sentSavedNames(action)))
if (!saved.has(name))
addIssue(
context,
["actions", index],
`Request step ${action.step_id} sends saved value ${value.name}, which no earlier request step saves`,
`Step ${action.step_id} sends saved value ${name}, which no earlier step saves`,
);
const params = new Set(action.path_params.map(({ name }) => name));
for (const [, name] of action.path.matchAll(
/\{([A-Za-z][A-Za-z0-9._-]*)\}/gu,
))
if (!params.has(name!))
addIssue(
context,
["actions", index, "path"],
`The path placeholder {${name!}} has no path_params value`,
);
for (const { name } of action.save) saved.add(name);
for (const name of savedNames(action)) saved.add(name);
});
};
+65 -53
View File
@@ -138,14 +138,21 @@ const browserScenarioPageValueSchemas = [
}),
] as const;
const jsonPointerSchema = z.string().regex(/^(?:\/.*)?$/u);
/**
* `{saved:name}`, which sends a saved value in a request path, a goto URL or a JSON body string. A parsed URL spells the
* braces, and may spell the colon, percent-encoded.
*/
export const SAVED_PLACEHOLDER =
/(?:\{|%7[Bb])saved(?::|%3[Aa])([A-Za-z][A-Za-z0-9._-]*)(?:\}|%7[Dd])/gu;
/** A value an earlier request step saved from its JSON response. */
/** The saved value names a text's `{saved:name}` placeholders send. */
export const savedPlaceholderNames = (text: string): readonly string[] =>
[...text.matchAll(SAVED_PLACEHOLDER)].map(([, name]) => name!);
/** A value an earlier step saved from a JSON response. */
const savedValueSchema = z.strictObject({
source: z.literal("saved"),
name: scenarioIdentifierSchema.describe(
"Name an earlier request step's `save` gave the value.",
),
name: scenarioIdentifierSchema,
});
/** A literal, declared secret or saved value a request step sends. */
@@ -157,13 +164,58 @@ export type BrowserScenarioRequestValue = z.infer<
typeof browserScenarioRequestValueSchema
>;
/**
* A part of Basic credentials: a literal, declared secret or saved value, or a value read from the page, as a frontend
* builds such a header from a login token. The page values carry no descriptions here, since each part repeats them.
*/
const basicCredentialPartSchema = z.discriminatedUnion("source", [
...browserScenarioRequestValueSchema.options,
z.strictObject({ source: z.literal("cookie"), name: z.string().min(1) }),
z.strictObject({
source: z.literal("local_storage"),
key: z.string().min(1),
json_pointer: z
.string()
.regex(/^(?:\/.*)?$/u)
.optional(),
}),
]);
export type BrowserScenarioCredentialPart = z.infer<
typeof basicCredentialPartSchema
>;
/** A username and password sent as the base64 of `username:password`, as HTTP Basic auth spells them; redacted like a declared secret. */
const basicCredentialsSchema = z.strictObject({
source: z.literal("basic_credentials"),
username: browserScenarioRequestValueSchema,
password: browserScenarioRequestValueSchema,
parts: z
.array(basicCredentialPartSchema)
.length(2)
.describe("Username, then password."),
});
/** A string or number a step keeps from a JSON response, by name, for later steps. */
const saveEntrySchema = z.strictObject({
name: scenarioIdentifierSchema,
json_pointer: z.string().regex(/^(?:\/.*)?$/u),
secret: z.boolean().default(false),
});
export type BrowserScenarioSaveEntry = z.infer<typeof saveEntrySchema>;
/** Values a UI step keeps from the JSON response to a request it triggers. */
const saveResponseSchema = z
.strictObject({
method: z.enum(BROWSER_SCENARIO_REQUEST_METHODS),
path: z
.string()
.regex(/^\/(?![/\\])/u)
.describe("`{*}` matches one segment; the query is ignored."),
save: z.array(saveEntrySchema).min(1),
})
.describe(
"Save values from the 2xx JSON response to a request this step triggers.",
);
export type BrowserScenarioSaveResponse = z.infer<typeof saveResponseSchema>;
const requestHeaderSchema = z.strictObject({
name: z
.string()
@@ -311,6 +363,7 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [
locator: locatorSchema,
button: z.enum(["left", "middle", "right"]).default("left"),
click_count: z.int().positive().default(1),
save_response: saveResponseSchema.optional(),
}),
z.strictObject({
...stepBase,
@@ -323,6 +376,7 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [
action: z.literal("press"),
locator: locatorSchema,
key: z.string().min(1),
save_response: saveResponseSchema.optional(),
}),
z.strictObject({
...stepBase,
@@ -349,62 +403,20 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [
.string()
.regex(/^\/(?![/\\])/u)
.describe(
"Path, and optional literal query, on the start URL's origin, such as /api/folders; it must resolve to that origin. `{name}` placeholders take `path_params`.",
"Path, and optional literal query, on the start URL's origin, such as /api/folders; it must resolve to that origin.",
),
path_params: z
.array(
z.strictObject({
name: scenarioIdentifierSchema,
value: browserScenarioRequestValueSchema,
}),
)
.default([])
.describe(
"Values for the path's `{name}` placeholders, each sent percent-encoded, such as an id an earlier step saved.",
),
query: z
.array(
z.strictObject({
name: z.string().min(1),
value: browserScenarioRequestValueSchema,
}),
)
.default([]),
query: z.array(queryEntrySchema).default([]),
headers: z.array(requestHeaderSchema).default([]),
json: jsonValueSchema
.optional()
.describe(
"JSON request body, sent with Content-Type application/json unless a header sets one; not allowed for GET or HEAD.",
),
json_values: z
.array(
z.strictObject({
json_pointer: jsonPointerSchema.min(1),
value: browserScenarioRequestValueSchema,
}),
)
.default([])
.describe(
"Values set in the JSON body at these pointers before it is sent; a saved value keeps the JSON type it was saved with.",
),
save: z
.array(
z.strictObject({
name: scenarioIdentifierSchema,
json_pointer: jsonPointerSchema.describe(
"Pointer to a string or number in the JSON response, such as /id.",
),
secret: z
.boolean()
.default(false)
.describe(
"Redact the value from everything recorded after it, as a declared secret is.",
),
}),
)
.array(saveEntrySchema)
.default([])
.describe(
"Values this step saves from its JSON response, by name, for later request steps to send.",
"Values to save from the JSON response. Later steps send one as `{saved:name}` in a request path, goto URL or JSON string, or as a header value.",
),
expected_status: z
.array(
+1
View File
@@ -105,6 +105,7 @@ export const environmentBuildReportSchema = z.strictObject({
"name-from-file",
"credential-named-members",
"credential-scan-incomplete",
"credential-named-saved-values",
]),
message: z.string(),
pointers: z
+1 -1
View File
@@ -6,5 +6,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
skillVersion: "49",
skillVersion: "50",
} as const;