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