mirror of
https://github.com/morluto/rea
synced 2026-10-28 23:11:11 -04:00
443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { Cli } from "incur";
|
|
import pino from "pino";
|
|
import { expect, it, vi } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
|
import { registerBrowserScenarioCommands } from "../../../src/cliBrowserScenarioCommands.js";
|
|
import { BROWSER_SCENARIO_START_STEP_ID } from "../../../src/domain/browserScenarioValues.js";
|
|
|
|
const serveJson = async (
|
|
registered: ReturnType<typeof registerValidatingCli>,
|
|
command: string,
|
|
input: unknown,
|
|
): Promise<unknown> => {
|
|
let stdout = "";
|
|
await registered.cli.serve([command, JSON.stringify(input), "--json"], {
|
|
env: {},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
exit: () => undefined,
|
|
});
|
|
expect(registered.createProvider).not.toHaveBeenCalled();
|
|
expect(registered.readSecret).not.toHaveBeenCalled();
|
|
return JSON.parse(stdout) as unknown;
|
|
};
|
|
|
|
/** Register only the scenario commands, with a provider that must never run. */
|
|
const registerValidatingCli = () => {
|
|
const cli = Cli.create("rea-scenario-validation-test", { sync: false });
|
|
const createProvider = vi.fn((): never => {
|
|
throw new Error("validation must not construct a browser provider");
|
|
});
|
|
const readSecret = vi.fn((): never => {
|
|
throw new Error("validation must not resolve secret environment values");
|
|
});
|
|
const environment = Object.defineProperty({}, "SESSION_TOKEN", {
|
|
enumerable: true,
|
|
get: readSecret,
|
|
});
|
|
registerBrowserScenarioCommands(cli, pino({ level: "silent" }), environment, {
|
|
createProvider,
|
|
});
|
|
return { cli, createProvider, readSecret };
|
|
};
|
|
|
|
const launchScenario = {
|
|
browser: { mode: "launch", executable_path: "browser" },
|
|
start_url: { url: "http://127.0.0.1:3000" },
|
|
actions: [{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }],
|
|
} as const;
|
|
|
|
it("accepts a valid scenario without launching or attaching to a browser", async () => {
|
|
const output = await serveJson(
|
|
registerValidatingCli(),
|
|
"validate-browser-scenario",
|
|
launchScenario,
|
|
);
|
|
expect(output).toEqual({ valid: true, actions: 1, secrets: 0 });
|
|
});
|
|
|
|
it.each(["validate-browser-scenario", "capture-browser-scenario"])(
|
|
"%s rejects a literal Basic username containing a colon before creating a provider",
|
|
async (command) => {
|
|
const output = await serveJson(registerValidatingCli(), command, {
|
|
...launchScenario,
|
|
actions: [
|
|
{
|
|
step_id: "authenticate",
|
|
action: "request",
|
|
method: "GET",
|
|
path: "/api",
|
|
headers: [
|
|
{
|
|
name: "Authorization",
|
|
value: {
|
|
source: "basic_credentials",
|
|
parts: [
|
|
{ source: "literal", value: "ops:admin" },
|
|
{ source: "literal", value: "" },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(output).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
expect.objectContaining({
|
|
path: ["actions", 0, "headers", 0, "value", "parts", 0, "value"],
|
|
message: expect.stringContaining(
|
|
"Basic username cannot contain a colon",
|
|
),
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it("counts declared secrets without resolving their environment values", async () => {
|
|
const cli = registerValidatingCli();
|
|
const output = await serveJson(cli, "validate-browser-scenario", {
|
|
...launchScenario,
|
|
actions: [
|
|
{
|
|
step_id: "sign-in",
|
|
action: "fill",
|
|
locator: { kind: "css", selector: "#token" },
|
|
value: { source: "secret", secret_id: "session_token" },
|
|
},
|
|
],
|
|
secrets: [
|
|
{ secret_id: "session_token", environment_variable: "SESSION_TOKEN" },
|
|
{ secret_id: "api_key", environment_variable: "API_KEY" },
|
|
],
|
|
});
|
|
expect(output).toEqual({ valid: true, actions: 1, secrets: 2 });
|
|
});
|
|
|
|
it("rejects a duplicate action step identity with the offending field path", async () => {
|
|
const output = await serveJson(
|
|
registerValidatingCli(),
|
|
"validate-browser-scenario",
|
|
{
|
|
...launchScenario,
|
|
actions: [
|
|
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 },
|
|
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 2 },
|
|
],
|
|
},
|
|
);
|
|
expect(output).toMatchObject({
|
|
error: "Browser scenario validation failed",
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
details: {
|
|
issues: [
|
|
expect.objectContaining({
|
|
path: ["actions"],
|
|
reason: "invalid_value",
|
|
message: "Action step IDs must be unique",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects an action that reuses the reserved initial step identity", async () => {
|
|
const output = await serveJson(
|
|
registerValidatingCli(),
|
|
"validate-browser-scenario",
|
|
{
|
|
...launchScenario,
|
|
actions: [
|
|
{
|
|
step_id: BROWSER_SCENARIO_START_STEP_ID,
|
|
action: "wait_for_timeout",
|
|
duration_ms: 1,
|
|
},
|
|
],
|
|
},
|
|
);
|
|
expect(output).toMatchObject({
|
|
error: "Browser scenario validation failed",
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
details: {
|
|
issues: [
|
|
expect.objectContaining({
|
|
path: ["actions", 0, "step_id"],
|
|
reason: "invalid_value",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects an undeclared secret reference without exposing any secret value", async () => {
|
|
const cli = registerValidatingCli();
|
|
const output = await serveJson(cli, "validate-browser-scenario", {
|
|
...launchScenario,
|
|
actions: [
|
|
{
|
|
step_id: "sign-in",
|
|
action: "fill",
|
|
locator: { kind: "css", selector: "#token" },
|
|
value: { source: "secret", secret_id: "session_token" },
|
|
},
|
|
],
|
|
});
|
|
const serialized = JSON.stringify(output);
|
|
expect(output).toMatchObject({
|
|
error: "Browser scenario validation failed",
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
details: {
|
|
issues: [
|
|
expect.objectContaining({
|
|
path: ["secrets"],
|
|
reason: "invalid_value",
|
|
message: expect.stringContaining("session_token"),
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
// The declaration is reported by identity only; no environment variable name
|
|
// or resolved value belongs in a validation reply.
|
|
expect(serialized).not.toContain("SESSION_TOKEN");
|
|
expect(serialized).not.toContain("environment_variable");
|
|
});
|
|
|
|
it("rejects an unknown field instead of ignoring it", async () => {
|
|
const output = await serveJson(
|
|
registerValidatingCli(),
|
|
"validate-browser-scenario",
|
|
{
|
|
...launchScenario,
|
|
navigation: { timeout_ms: 5 },
|
|
},
|
|
);
|
|
expect(output).toMatchObject({
|
|
error: "Browser scenario validation failed",
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
details: {
|
|
issues: [
|
|
expect.objectContaining({
|
|
path: ["navigation"],
|
|
reason: "unknown_argument",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects a scenario whose input file cannot be read", async () => {
|
|
const cli = registerValidatingCli();
|
|
let stdout = "";
|
|
await cli.cli.serve(
|
|
["validate-browser-scenario", "missing-scenario.json", "--json"],
|
|
{
|
|
env: {},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
exit: () => undefined,
|
|
},
|
|
);
|
|
expect(cli.createProvider).not.toHaveBeenCalled();
|
|
expect(cli.readSecret).not.toHaveBeenCalled();
|
|
expect(JSON.parse(stdout) as unknown).toMatchObject({
|
|
input_path: "missing-scenario.json",
|
|
input_reason: "read-failed",
|
|
});
|
|
});
|
|
|
|
it("accepts a disconnected CDP target without constructing a provider", async () => {
|
|
expect(
|
|
await serveJson(registerValidatingCli(), "validate-browser-scenario", {
|
|
...launchScenario,
|
|
browser: {
|
|
mode: "connect",
|
|
cdp_endpoint: "http://127.0.0.1:1",
|
|
target_id: "unavailable-target",
|
|
},
|
|
}),
|
|
).toEqual({ valid: true, actions: 1, secrets: 0 });
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
{ secret_id: "token", environment_variable: "FIRST" },
|
|
{ secret_id: "token", environment_variable: "SECOND" },
|
|
],
|
|
[
|
|
{ secret_id: "first", environment_variable: "SAME" },
|
|
{ secret_id: "second", environment_variable: "SAME" },
|
|
],
|
|
])(
|
|
"rejects duplicate secret identities or environment references (%j)",
|
|
async (first, second) => {
|
|
expect(
|
|
await serveJson(registerValidatingCli(), "validate-browser-scenario", {
|
|
...launchScenario,
|
|
secrets: [first, second],
|
|
}),
|
|
).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
expect.objectContaining({
|
|
path: ["secrets"],
|
|
reason: "invalid_value",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
const savedSteps = [
|
|
{
|
|
step_id: "create",
|
|
action: "request",
|
|
method: "POST",
|
|
path: "/api/items",
|
|
json: { title: "test" },
|
|
save: [{ name: "id", json_pointer: "/id" }],
|
|
},
|
|
{
|
|
step_id: "read",
|
|
action: "request",
|
|
method: "GET",
|
|
path: "/api/items/{saved:id}",
|
|
},
|
|
];
|
|
it("accepts saved response references only after their producing step", async () => {
|
|
const valid = { ...launchScenario, actions: savedSteps };
|
|
expect(
|
|
await serveJson(
|
|
registerValidatingCli(),
|
|
"validate-browser-scenario",
|
|
valid,
|
|
),
|
|
).toEqual({ valid: true, actions: 2, secrets: 0 });
|
|
expect(
|
|
await serveJson(registerValidatingCli(), "validate-browser-scenario", {
|
|
...valid,
|
|
actions: [...savedSteps].reverse(),
|
|
}),
|
|
).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
expect.objectContaining({
|
|
path: ["actions", 0],
|
|
message: expect.stringContaining("no earlier step saves"),
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
cliTest.for(["launch", "connect"] as const)(
|
|
"validates %s through the public CLI while runtime resources are unavailable",
|
|
async (mode, { cli }) => {
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"validate-browser-scenario",
|
|
JSON.stringify({
|
|
...launchScenario,
|
|
browser:
|
|
mode === "launch"
|
|
? { mode, executable_path: "/nonexistent/rea-validation-browser" }
|
|
: {
|
|
mode,
|
|
cdp_endpoint: "http://127.0.0.1:1",
|
|
target_id: "not-attached",
|
|
},
|
|
secrets: [
|
|
{
|
|
secret_id: "session_token",
|
|
environment_variable: "SESSION_TOKEN",
|
|
},
|
|
],
|
|
}),
|
|
"--json",
|
|
],
|
|
environment: {
|
|
REA_LOG_LEVEL: "silent",
|
|
SESSION_TOKEN: "validation-unread-secret",
|
|
},
|
|
});
|
|
expect(result.exitCode, result.stderr).toBe(0);
|
|
expect(result.json).toEqual({ valid: true, actions: 1, secrets: 1 });
|
|
expect(result.stdout).not.toContain("validation-unread-secret");
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"validates stdin and caller-relative file paths with canonical diagnostics",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory("rea-scenario-validation-");
|
|
const input = JSON.stringify(launchScenario);
|
|
await writeFile(join(root, "valid.json"), input);
|
|
await writeFile(
|
|
join(root, "invalid.json"),
|
|
JSON.stringify({
|
|
...launchScenario,
|
|
actions: [...launchScenario.actions, ...launchScenario.actions],
|
|
}),
|
|
);
|
|
await writeFile(join(root, "malformed.json"), '{"private-value":');
|
|
for (const arguments_ of [
|
|
["validate-browser-scenario", "valid.json", "--json"],
|
|
["validate-browser-scenario", "-", "--json"],
|
|
]) {
|
|
const result = await cli.run({
|
|
arguments: arguments_,
|
|
cwd: root,
|
|
...(arguments_[1] === "-" ? { input } : {}),
|
|
});
|
|
expect(result.exitCode, result.stderr).toBe(0);
|
|
expect(result.json).toEqual({ valid: true, actions: 1, secrets: 0 });
|
|
}
|
|
const rejected = await cli.run({
|
|
arguments: ["validate-browser-scenario", "invalid.json", "--json"],
|
|
cwd: root,
|
|
});
|
|
expect(rejected.exitCode).toBe(1);
|
|
expect(rejected.json).toMatchObject({
|
|
error: "Browser scenario validation failed",
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
expect.objectContaining({
|
|
path: ["actions"],
|
|
message: "Action step IDs must be unique",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
const malformed = await cli.run({
|
|
arguments: ["validate-browser-scenario", "malformed.json", "--json"],
|
|
cwd: root,
|
|
});
|
|
expect(malformed.exitCode).toBe(1);
|
|
expect(malformed.json).toMatchObject({
|
|
code: "invalid_request",
|
|
input_path: "malformed.json",
|
|
input_reason: "invalid-json",
|
|
});
|
|
expect(malformed.stdout).not.toContain("private-value");
|
|
},
|
|
);
|