mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
feat(browser): validate browser scenarios with --dry-run before launching
Checking a scenario file meant running it, which discovers, launches or attaches to a browser and can change page state before a typo surfaces. `rea capture-browser-scenario <json> --dry-run` reuses the same JSON and path resolution, the canonical scenario schema and its semantic rules, and the secret-availability rule capture applies before acquiring a browser, now shared from one domain owner. It returns the same projected input errors, or a compact plan of steps, secret availability and the stages it did not check, and never creates a provider or reads secret values. Fixes #5488 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,50 @@ profile for this request; it does not start your fixture server or install a
|
||||
browser. A schema-valid scenario does not prove that the executable or target
|
||||
is available on the host.
|
||||
|
||||
### Validate without a browser
|
||||
|
||||
Add `--dry-run` to check a scenario before it launches or changes anything:
|
||||
|
||||
```bash
|
||||
rea capture-browser-scenario ./browser-scenario.json --dry-run --json
|
||||
```
|
||||
|
||||
The dry run reads the same JSON argument and resolves the same paths as a
|
||||
capture, then applies the same schema and semantic rules: duplicate or reserved
|
||||
step IDs, duplicate secret IDs or variables, and undeclared secret references.
|
||||
It also requires each declared secret's environment variable to be set, which
|
||||
is the rule a capture applies before acquiring a browser. Errors are the same
|
||||
`invalid_request` projection with field paths, under
|
||||
`"error": "Browser scenario validation failed"`. A valid scenario exits 0
|
||||
with a compact plan and creates no provider, profile, file or connection:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "capture_browser_scenario",
|
||||
"dry_run": true,
|
||||
"browser_mode": "launch",
|
||||
"start_origin": "http://127.0.0.1:3000",
|
||||
"steps": [
|
||||
{ "step_id": "scenario_start", "action": "goto_start" },
|
||||
{ "step_id": "settle", "action": "wait_for_timeout" }
|
||||
],
|
||||
"secrets": [],
|
||||
"not_checked": [
|
||||
"browser executable, CDP endpoint and target discovery",
|
||||
"browser launch or attachment",
|
||||
"navigation, network reachability and page state",
|
||||
"locator resolution, action outcomes and capture limits"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Steps are listed in capture order, including the initial navigation. Secrets
|
||||
report only `environment_value: "present"`; values are never read into the
|
||||
plan or printed. `not_checked` names the stages a dry run cannot establish, so
|
||||
a valid plan is not a prediction that the capture will succeed. The dry run is
|
||||
CLI-only. Over MCP, `capture_browser_scenario` already rejects invalid input
|
||||
before acquiring a browser.
|
||||
|
||||
## Interaction example: capture the visible result
|
||||
|
||||
The default final URL answers "where did the page end up?", not "what did the
|
||||
|
||||
@@ -8,8 +8,13 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import {
|
||||
browserScenarioSecretAvailability,
|
||||
type BrowserScenario,
|
||||
} from "../domain/browserScenario.js";
|
||||
import { BROWSER_SCENARIO_START_STEP_ID } from "../domain/browserScenarioValues.js";
|
||||
import type { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
|
||||
import {
|
||||
completeProviderEvidence,
|
||||
@@ -82,3 +87,58 @@ export const captureBrowserScenario = async (
|
||||
options.signal,
|
||||
);
|
||||
};
|
||||
|
||||
/** What a dry run checked, and the explicit stages it stopped before. */
|
||||
export interface BrowserScenarioPlan {
|
||||
readonly operation: typeof OPERATION;
|
||||
readonly dry_run: true;
|
||||
readonly browser_mode: BrowserScenario["browser"]["mode"];
|
||||
readonly start_origin: string;
|
||||
/** Steps in capture order, including the initial navigation step. */
|
||||
readonly steps: readonly {
|
||||
readonly step_id: string;
|
||||
readonly action: string;
|
||||
}[];
|
||||
readonly secrets: readonly {
|
||||
readonly secret_id: string;
|
||||
readonly environment_variable: string;
|
||||
readonly environment_value: "present";
|
||||
}[];
|
||||
readonly not_checked: readonly string[];
|
||||
}
|
||||
|
||||
const NOT_CHECKED = [
|
||||
"browser executable, CDP endpoint and target discovery",
|
||||
"browser launch or attachment",
|
||||
"navigation, network reachability and page state",
|
||||
"locator resolution, action outcomes and capture limits",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Apply the execution rules that need no browser to an already parsed
|
||||
* scenario, then stop before any provider is created. Secret values are
|
||||
* never read.
|
||||
*/
|
||||
export const planBrowserScenario = (
|
||||
scenario: BrowserScenario,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Result<BrowserScenarioPlan, AnalysisInputError> => {
|
||||
const available = browserScenarioSecretAvailability(scenario, environment);
|
||||
if (!available.ok) return available;
|
||||
return ok({
|
||||
operation: OPERATION,
|
||||
dry_run: true,
|
||||
browser_mode: scenario.browser.mode,
|
||||
start_origin: new URL(scenario.start_url.url).origin,
|
||||
steps: [
|
||||
{ step_id: BROWSER_SCENARIO_START_STEP_ID, action: "goto_start" },
|
||||
...scenario.actions.map(({ step_id, action }) => ({ step_id, action })),
|
||||
],
|
||||
secrets: scenario.secrets.map(({ secret_id, environment_variable }) => ({
|
||||
secret_id,
|
||||
environment_variable,
|
||||
environment_value: "present" as const,
|
||||
})),
|
||||
not_checked: NOT_CHECKED,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,10 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import {
|
||||
browserScenarioSecretAvailability,
|
||||
type BrowserScenario,
|
||||
} from "../domain/browserScenario.js";
|
||||
import type {
|
||||
BrowserScenarioUrl,
|
||||
BrowserScenarioValue,
|
||||
@@ -287,34 +290,14 @@ export class BrowserScenarioSecrets {
|
||||
scenario: BrowserScenario,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Result<BrowserScenarioSecrets, AnalysisInputError> {
|
||||
const available = browserScenarioSecretAvailability(scenario, environment);
|
||||
if (!available.ok) return err(available.error);
|
||||
const values = new Map<string, string>();
|
||||
for (const [index, declaration] of scenario.secrets.entries()) {
|
||||
const value = environment[declaration.environment_variable];
|
||||
if (
|
||||
!Object.hasOwn(environment, declaration.environment_variable) ||
|
||||
typeof value !== "string"
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
"capture_browser_scenario",
|
||||
{
|
||||
userMessage: `Set environment variable ${declaration.environment_variable} for browser scenario secret ${declaration.secret_id} in the environment that starts REA, then retry.`,
|
||||
},
|
||||
[
|
||||
{
|
||||
path: ["secrets", index, "environment_variable"],
|
||||
reason: "missing_argument",
|
||||
message: `Environment variable ${declaration.environment_variable} is not set for declared secret ${declaration.secret_id}.`,
|
||||
expected: {
|
||||
secret_id: declaration.secret_id,
|
||||
environment_variable: declaration.environment_variable,
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
values.set(declaration.secret_id, value);
|
||||
}
|
||||
for (const declaration of scenario.secrets)
|
||||
values.set(
|
||||
declaration.secret_id,
|
||||
environment[declaration.environment_variable]!,
|
||||
);
|
||||
return ok(new BrowserScenarioSecrets(values));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Cli } from "incur";
|
||||
import { z } from "zod";
|
||||
|
||||
import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js";
|
||||
import {
|
||||
captureBrowserScenario,
|
||||
planBrowserScenario,
|
||||
} from "./application/BrowserScenarioCaptureService.js";
|
||||
import { withCommandCancellation } from "./cli/commandCancellation.js";
|
||||
import { CLI_COMMANDS } from "./cliCommandNames.js";
|
||||
import { parseCliJsonInput, resolveCliJsonPaths } from "./cliJsonInput.js";
|
||||
@@ -38,9 +41,20 @@ export const registerBrowserScenarioCommands = (
|
||||
.string()
|
||||
.describe("Inline browser scenario JSON or JSON file path"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
options: z.object({
|
||||
dryRun: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Validate the scenario and its secret environment variables, then stop before discovering, launching or attaching to a browser",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options: { dryRun } }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.captureBrowserScenario, async () => {
|
||||
const failure = dryRun
|
||||
? "Browser scenario validation failed"
|
||||
: "Browser scenario capture failed";
|
||||
const input = await parseCliJsonInput(
|
||||
args.inputJson,
|
||||
OPERATION,
|
||||
@@ -58,7 +72,12 @@ export const registerBrowserScenarioCommands = (
|
||||
input.value,
|
||||
{ cause: scenario.error },
|
||||
),
|
||||
failure,
|
||||
);
|
||||
if (dryRun) {
|
||||
const plan = planBrowserScenario(scenario.data, environment);
|
||||
return plan.ok ? plan.value : cliError(plan.error, failure);
|
||||
}
|
||||
// Load Playwright only for this command, not during CLI startup.
|
||||
const provider =
|
||||
options.createProvider === undefined
|
||||
@@ -74,7 +93,7 @@ export const registerBrowserScenarioCommands = (
|
||||
result = await captureBrowserScenario(provider, scenario.data, {
|
||||
signal,
|
||||
});
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
return result.ok ? result.value : cliError(result.error, failure);
|
||||
} finally {
|
||||
try {
|
||||
await provider?.close?.();
|
||||
@@ -96,7 +115,8 @@ export const registerBrowserScenarioCommands = (
|
||||
|
||||
const cliError = (
|
||||
error: Parameters<typeof projectAnalysisError>[0],
|
||||
label: string,
|
||||
): JsonValue => ({
|
||||
error: "Browser scenario capture failed",
|
||||
error: label,
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { AnalysisInputError } from "./analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
|
||||
import { webRuntimeCommandTimeoutSchema } from "./browserDeadline.js";
|
||||
import {
|
||||
browserScenarioActionSchema,
|
||||
@@ -209,3 +212,38 @@ export const browserScenarioSchema = browserScenarioInputSchema.superRefine(
|
||||
validateBrowserScenario,
|
||||
);
|
||||
export type BrowserScenario = z.infer<typeof browserScenarioSchema>;
|
||||
|
||||
/**
|
||||
* Refuse a declared secret whose environment variable is absent. Execution and
|
||||
* dry runs share this rule. It checks presence only and never reads a value.
|
||||
*/
|
||||
export const browserScenarioSecretAvailability = (
|
||||
scenario: BrowserScenario,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Result<void, AnalysisInputError> => {
|
||||
for (const [index, declaration] of scenario.secrets.entries())
|
||||
if (
|
||||
!Object.hasOwn(environment, declaration.environment_variable) ||
|
||||
typeof environment[declaration.environment_variable] !== "string"
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
"capture_browser_scenario",
|
||||
{
|
||||
userMessage: `Set environment variable ${declaration.environment_variable} for browser scenario secret ${declaration.secret_id} in the environment that starts REA, then retry.`,
|
||||
},
|
||||
[
|
||||
{
|
||||
path: ["secrets", index, "environment_variable"],
|
||||
reason: "missing_argument",
|
||||
message: `Environment variable ${declaration.environment_variable} is not set for declared secret ${declaration.secret_id}.`,
|
||||
expected: {
|
||||
secret_id: declaration.secret_id,
|
||||
environment_variable: declaration.environment_variable,
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Cli } from "incur";
|
||||
import pino from "pino";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BrowserScenarioCapturePort } from "../../../src/application/BrowserScenarioCapturePort.js";
|
||||
import { BrowserScenarioSecrets } from "../../../src/browser/BrowserScenarioSecrets.js";
|
||||
import { registerBrowserScenarioCommands } from "../../../src/cliBrowserScenarioCommands.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { browserScenarioSchema } from "../../../src/domain/browserScenario.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const SECRET_VALUE = "dry-run-secret-value-1234";
|
||||
|
||||
const scenario = {
|
||||
browser: { mode: "launch", executable_path: "/rea-missing/browser" },
|
||||
start_url: {
|
||||
url: "https://example.test/login",
|
||||
query: [
|
||||
{
|
||||
name: "token",
|
||||
value: { source: "secret", secret_id: "api_token" },
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 },
|
||||
{
|
||||
step_id: "account",
|
||||
action: "goto",
|
||||
destination: { url: "https://example.test/account" },
|
||||
wait_until: "load",
|
||||
},
|
||||
],
|
||||
secrets: [{ secret_id: "api_token", environment_variable: "REA_DRY_TOKEN" }],
|
||||
};
|
||||
|
||||
/** One in-process CLI whose provider factory records every invocation. */
|
||||
const scenarioCli = (environment: Readonly<Record<string, string>>) => {
|
||||
const createProvider = vi.fn((): BrowserScenarioCapturePort => {
|
||||
throw new Error("A dry run must not create a browser provider");
|
||||
});
|
||||
const cli = Cli.create("rea-scenario-dry-run-test", { sync: false });
|
||||
registerBrowserScenarioCommands(cli, pino({ level: "silent" }), environment, {
|
||||
createProvider,
|
||||
});
|
||||
const serve = async (input: unknown, ...flags: string[]) => {
|
||||
let stdout = "";
|
||||
await cli.serve(
|
||||
["capture-browser-scenario", JSON.stringify(input), ...flags, "--json"],
|
||||
{
|
||||
env: {},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
exit: () => undefined,
|
||||
},
|
||||
);
|
||||
return { stdout, json: JSON.parse(stdout) as Record<string, unknown> };
|
||||
};
|
||||
return { createProvider, serve };
|
||||
};
|
||||
|
||||
describe("capture-browser-scenario --dry-run", () => {
|
||||
it("returns the plan without creating a provider or reading secret values into output", async () => {
|
||||
const { createProvider, serve } = scenarioCli({
|
||||
REA_DRY_TOKEN: SECRET_VALUE,
|
||||
});
|
||||
const { stdout, json } = await serve(scenario, "--dry-run");
|
||||
expect(json).toEqual({
|
||||
operation: "capture_browser_scenario",
|
||||
dry_run: true,
|
||||
browser_mode: "launch",
|
||||
start_origin: "https://example.test",
|
||||
steps: [
|
||||
{ step_id: "scenario_start", action: "goto_start" },
|
||||
{ step_id: "settle", action: "wait_for_timeout" },
|
||||
{ step_id: "account", action: "goto" },
|
||||
],
|
||||
secrets: [
|
||||
{
|
||||
secret_id: "api_token",
|
||||
environment_variable: "REA_DRY_TOKEN",
|
||||
environment_value: "present",
|
||||
},
|
||||
],
|
||||
not_checked: expect.arrayContaining(["browser launch or attachment"]),
|
||||
});
|
||||
expect(stdout).not.toContain(SECRET_VALUE);
|
||||
expect(createProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a missing secret environment variable with the execution rule's error", async () => {
|
||||
const { createProvider, serve } = scenarioCli({});
|
||||
const { json } = await serve(scenario, "--dry-run");
|
||||
const parsed = browserScenarioSchema.parse(scenario);
|
||||
const execution = BrowserScenarioSecrets.resolve(parsed, {});
|
||||
if (execution.ok) throw new Error("Missing secret resolved");
|
||||
expect(json).toEqual({
|
||||
error: "Browser scenario validation failed",
|
||||
...projectAnalysisError(execution.error),
|
||||
});
|
||||
expect(json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["secrets", 0, "environment_variable"],
|
||||
reason: "missing_argument",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// An inherited or non-string value is not an available secret.
|
||||
const inherited = Object.create({ REA_DRY_TOKEN: SECRET_VALUE }) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(
|
||||
(await scenarioCli(inherited).serve(scenario, "--dry-run")).json,
|
||||
).toMatchObject({ code: "invalid_request" });
|
||||
expect(createProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports schema and semantic errors exactly as execution does", async () => {
|
||||
const invalid = {
|
||||
...scenario,
|
||||
actions: [...scenario.actions, scenario.actions[0]],
|
||||
secrets: [],
|
||||
};
|
||||
const dry = scenarioCli({ REA_DRY_TOKEN: SECRET_VALUE });
|
||||
const executed = scenarioCli({ REA_DRY_TOKEN: SECRET_VALUE });
|
||||
const { json: dryJson } = await dry.serve(invalid, "--dry-run");
|
||||
const { json: executedJson } = await executed.serve(invalid);
|
||||
expect(dryJson).toMatchObject({
|
||||
error: "Browser scenario validation failed",
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: ["actions"],
|
||||
message: "Action step IDs must be unique",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: ["secrets"],
|
||||
message: "Secret reference api_token is not declared",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const { error: dryLabel, ...dryProjection } = dryJson;
|
||||
const { error: executedLabel, ...executedProjection } = executedJson;
|
||||
expect([dryLabel, executedLabel]).toEqual([
|
||||
"Browser scenario validation failed",
|
||||
"Browser scenario capture failed",
|
||||
]);
|
||||
expect(dryProjection).toEqual(executedProjection);
|
||||
expect(dry.createProvider).not.toHaveBeenCalled();
|
||||
expect(executed.createProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still runs execution when the flag is omitted", async () => {
|
||||
const { createProvider, serve } = scenarioCli({
|
||||
REA_DRY_TOKEN: SECRET_VALUE,
|
||||
});
|
||||
const { json } = await serve(scenario);
|
||||
expect(json).not.toHaveProperty("dry_run");
|
||||
expect(createProvider).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"validates a scenario through the compiled CLI without a browser",
|
||||
async ({ cli }) => {
|
||||
const valid = await cli.run({
|
||||
arguments: [
|
||||
"capture-browser-scenario",
|
||||
JSON.stringify(scenario),
|
||||
"--dry-run",
|
||||
"--json",
|
||||
],
|
||||
environment: { REA_LOG_LEVEL: "silent", REA_DRY_TOKEN: SECRET_VALUE },
|
||||
});
|
||||
expect(valid.exitCode).toBe(0);
|
||||
expect(valid.json).toMatchObject({ dry_run: true, browser_mode: "launch" });
|
||||
expect(valid.stdout).not.toContain(SECRET_VALUE);
|
||||
const missing = await cli.run({
|
||||
arguments: [
|
||||
"capture-browser-scenario",
|
||||
JSON.stringify(scenario),
|
||||
"--dry-run",
|
||||
"--json",
|
||||
],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(missing.exitCode).not.toBe(0);
|
||||
expect(missing.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { issues: [{ path: ["secrets", 0, "environment_variable"] }] },
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user