refactor(browser): remove arbitrary capture ceilings
This commit is contained in:
@@ -4,8 +4,6 @@ const {
|
||||
createElectronActiveBoundaryPatches,
|
||||
} = require("./electron-active-hook-boundaries.cjs");
|
||||
|
||||
const MAX_EVENT_NAME_LENGTH = 128;
|
||||
const MAX_IDENTIFIER_LENGTH = 256;
|
||||
const ipcEventKinds = new Set([
|
||||
"main-handler-invocation",
|
||||
"main-event-invocation",
|
||||
@@ -24,9 +22,6 @@ let observedRuntime = 0;
|
||||
let sequence = 0;
|
||||
let correlationSequence = 0;
|
||||
|
||||
const boundedString = (value, maximum) =>
|
||||
typeof value === "string" ? value.slice(0, maximum) : null;
|
||||
|
||||
const shape = (value, depth = 0) => {
|
||||
if (depth > 2 || value === null) return value === null ? "null" : "nested";
|
||||
if (Array.isArray(value)) return "array";
|
||||
@@ -64,7 +59,7 @@ const isAllowedNavigation = (value) => {
|
||||
|
||||
const processType = () =>
|
||||
typeof process.type === "string" && process.type.length > 0
|
||||
? process.type.slice(0, MAX_IDENTIFIER_LENGTH)
|
||||
? process.type
|
||||
: "main";
|
||||
|
||||
const identity = (value, prefix) => {
|
||||
@@ -72,9 +67,9 @@ const identity = (value, prefix) => {
|
||||
const id = value.id;
|
||||
const processId = value.pid;
|
||||
return typeof id === "string" || typeof id === "number"
|
||||
? `${prefix}:${String(id)}`.slice(0, MAX_IDENTIFIER_LENGTH)
|
||||
? `${prefix}:${String(id)}`
|
||||
: typeof processId === "number"
|
||||
? `${prefix}:pid:${String(processId)}`.slice(0, MAX_IDENTIFIER_LENGTH)
|
||||
? `${prefix}:pid:${String(processId)}`
|
||||
: null;
|
||||
};
|
||||
|
||||
@@ -82,7 +77,7 @@ const frameIdentity = (event) => {
|
||||
const frame = event?.senderFrame ?? event?.frame;
|
||||
const id = frame?.frameTreeNodeId ?? frame?.routingId ?? frame?.id;
|
||||
return typeof id === "string" || typeof id === "number"
|
||||
? `frame:${String(id)}`.slice(0, MAX_IDENTIFIER_LENGTH)
|
||||
? `frame:${String(id)}`
|
||||
: null;
|
||||
};
|
||||
|
||||
@@ -119,20 +114,24 @@ const record = (event) => {
|
||||
: [];
|
||||
events.push({
|
||||
...raw,
|
||||
correlation_id: boundedString(raw.correlation_id, MAX_IDENTIFIER_LENGTH),
|
||||
event: boundedString(raw.event, MAX_EVENT_NAME_LENGTH),
|
||||
correlation_id: raw.correlation_id,
|
||||
event: typeof raw.event === "string" ? raw.event : null,
|
||||
channel: typeof raw.channel === "string" ? raw.channel : null,
|
||||
sender: boundedString(raw.sender, MAX_IDENTIFIER_LENGTH),
|
||||
receiver: boundedString(raw.receiver, MAX_IDENTIFIER_LENGTH),
|
||||
frame: boundedString(raw.frame, MAX_IDENTIFIER_LENGTH),
|
||||
target: boundedString(raw.target, MAX_IDENTIFIER_LENGTH),
|
||||
argument_shapes: argumentShapes.map(
|
||||
(value) => boundedString(value, 64) ?? "unknown",
|
||||
sender: typeof raw.sender === "string" ? raw.sender : null,
|
||||
receiver: typeof raw.receiver === "string" ? raw.receiver : null,
|
||||
frame: typeof raw.frame === "string" ? raw.frame : null,
|
||||
target: typeof raw.target === "string" ? raw.target : null,
|
||||
argument_shapes: argumentShapes.map((value) =>
|
||||
typeof value === "string" ? value : "unknown",
|
||||
),
|
||||
result_shape: boundedString(raw.result_shape, 64),
|
||||
process_type: boundedString(raw.process_type, MAX_IDENTIFIER_LENGTH),
|
||||
artifact_path: boundedString(raw.artifact_path, 16_384),
|
||||
artifact_sha256: boundedString(raw.artifact_sha256, 64),
|
||||
result_shape:
|
||||
typeof raw.result_shape === "string" ? raw.result_shape : null,
|
||||
process_type:
|
||||
typeof raw.process_type === "string" ? raw.process_type : null,
|
||||
artifact_path:
|
||||
typeof raw.artifact_path === "string" ? raw.artifact_path : null,
|
||||
artifact_sha256:
|
||||
typeof raw.artifact_sha256 === "string" ? raw.artifact_sha256 : null,
|
||||
});
|
||||
return events.at(-1);
|
||||
};
|
||||
|
||||
@@ -35,11 +35,6 @@ export const requiredRecord = (value: unknown): UnknownRecord => {
|
||||
return record;
|
||||
};
|
||||
|
||||
export const boundedText = (value: unknown, maximum = 1_024): string | null => {
|
||||
const text = stringValue(value);
|
||||
return text === undefined ? null : text.slice(0, maximum);
|
||||
};
|
||||
|
||||
export const isHttpUrl = (value: string | undefined): boolean => {
|
||||
if (value === undefined) return false;
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@ export const authorizedElectronFile = async (
|
||||
value: string,
|
||||
roots: readonly string[],
|
||||
): Promise<string | undefined> => {
|
||||
if (value.length > 65_536 || /%(?:2f|5c)/iu.test(value)) return undefined;
|
||||
if (/%(?:2f|5c)/iu.test(value)) return undefined;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { createElectronActiveObservationFixtureResult } from "../domain/electronActiveObservation.fixture.js";
|
||||
import { runElectronActions } from "./PlaywrightElectronActiveActions.js";
|
||||
|
||||
it("parses bounded window, renderer, and deep-link actions for agents", () => {
|
||||
it("parses window, renderer, and deep-link actions for agents", () => {
|
||||
const input = electronActiveObservationInputSchema.parse({
|
||||
executable_path: "/opt/electron",
|
||||
application_path: "/opt/app/main.js",
|
||||
@@ -122,12 +122,7 @@ it("redacts action inputs from Playwright failures", async () => {
|
||||
windows: () => [page],
|
||||
};
|
||||
|
||||
const result = await runElectronActions(
|
||||
application as never,
|
||||
input,
|
||||
{},
|
||||
Date.now() + 1_000,
|
||||
);
|
||||
const result = await runElectronActions(application as never, input, {});
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
status: "failed",
|
||||
@@ -154,7 +149,6 @@ it("retains complete action errors and Electron window text", async () => {
|
||||
{ windows: () => [page] } as never,
|
||||
input,
|
||||
{},
|
||||
Date.now() + 1_000,
|
||||
);
|
||||
const result = createElectronActiveObservationFixtureResult("/opt/app");
|
||||
const longUrl = `file://${"/segment".repeat(10_000)}`;
|
||||
@@ -281,12 +275,7 @@ it("runs an untargeted deep-link without requiring a BrowserWindow", async () =>
|
||||
evaluate: async () => true,
|
||||
};
|
||||
|
||||
const result = await runElectronActions(
|
||||
application as never,
|
||||
input,
|
||||
{},
|
||||
Date.now() + 1_000,
|
||||
);
|
||||
const result = await runElectronActions(application as never, input, {});
|
||||
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
@@ -297,6 +286,30 @@ it("runs an untargeted deep-link without requiring a BrowserWindow", async () =>
|
||||
]);
|
||||
});
|
||||
|
||||
it("honors caller-selected Electron waits beyond the former action timeout", async () => {
|
||||
const input = electronActiveObservationInputSchema.parse({
|
||||
executable_path: "/opt/electron",
|
||||
application_path: "/opt/app/main.js",
|
||||
application_root: "/opt/app",
|
||||
actions: [{ step_id: "wait-long", kind: "wait", duration_ms: 60_000 }],
|
||||
});
|
||||
let duration = 0;
|
||||
const page = {
|
||||
waitForTimeout: async (value: number) => {
|
||||
duration = value;
|
||||
},
|
||||
};
|
||||
const application = {
|
||||
windows: () => [page],
|
||||
firstWindow: async () => page,
|
||||
};
|
||||
|
||||
const result = await runElectronActions(application as never, input, {});
|
||||
|
||||
expect(result[0]).toMatchObject({ status: "completed" });
|
||||
expect(duration).toBe(60_000);
|
||||
});
|
||||
|
||||
it("rejects action outcomes that disagree with their error state", () => {
|
||||
const result = createElectronActiveObservationFixtureResult("/opt/app");
|
||||
const action = result.actions[0];
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
} from "../domain/electronActiveObservation.js";
|
||||
|
||||
const OPERATION = "capture_electron_scenario" as const;
|
||||
const ACTION_TIMEOUT_MS = 5_000;
|
||||
|
||||
const hookEventSchema = z.strictObject({
|
||||
sequence: z.number().int().min(1),
|
||||
@@ -108,7 +107,6 @@ export const runElectronActions = async (
|
||||
application: ElectronApplication,
|
||||
input: ElectronActiveObservationInput,
|
||||
options: ExecutionOptions,
|
||||
deadline: number,
|
||||
): Promise<ElectronActions> => {
|
||||
const actions: ElectronActions = [];
|
||||
for (const action of input.actions) {
|
||||
@@ -118,18 +116,13 @@ export const runElectronActions = async (
|
||||
const windowIndex = actionWindowIndex(action);
|
||||
let selectedWindow: Page | undefined;
|
||||
try {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0)
|
||||
throw new BrowserObservationError(OPERATION, "timeout");
|
||||
selectedWindow =
|
||||
windowIndex === null
|
||||
? undefined
|
||||
: await waitForWindow({
|
||||
application,
|
||||
windowIndex,
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
signal: options.signal,
|
||||
deadline,
|
||||
});
|
||||
if (
|
||||
(action.kind === "click" ||
|
||||
@@ -143,10 +136,7 @@ export const runElectronActions = async (
|
||||
application,
|
||||
action,
|
||||
selectedWindow,
|
||||
remaining,
|
||||
actionTimeoutMs: ACTION_TIMEOUT_MS,
|
||||
options,
|
||||
deadline,
|
||||
});
|
||||
actions.push({
|
||||
step_id: action.step_id,
|
||||
@@ -179,20 +169,14 @@ type RunActionContext = {
|
||||
readonly application: ElectronApplication;
|
||||
readonly action: ElectronAction;
|
||||
readonly selectedWindow: Page | undefined;
|
||||
readonly remaining: number;
|
||||
readonly actionTimeoutMs: number;
|
||||
readonly options: ExecutionOptions;
|
||||
readonly deadline: number;
|
||||
};
|
||||
|
||||
const runAction = async ({
|
||||
application,
|
||||
action,
|
||||
selectedWindow,
|
||||
remaining,
|
||||
actionTimeoutMs,
|
||||
options,
|
||||
deadline,
|
||||
}: RunActionContext): Promise<void> => {
|
||||
switch (action.kind) {
|
||||
case "click":
|
||||
@@ -200,25 +184,21 @@ const runAction = async ({
|
||||
throw new BrowserObservationError(OPERATION, "window_not_found");
|
||||
await runWithExecutionLimits(
|
||||
selectedWindow.locator(action.selector).click({
|
||||
timeout: Math.min(actionTimeoutMs, remaining),
|
||||
timeout: 0,
|
||||
}),
|
||||
options.signal,
|
||||
deadline,
|
||||
);
|
||||
return;
|
||||
case "wait": {
|
||||
const page =
|
||||
selectedWindow ??
|
||||
(await application.firstWindow({
|
||||
timeout: Math.min(actionTimeoutMs, remaining),
|
||||
timeout: 0,
|
||||
}));
|
||||
await runWithExecutionLimits(
|
||||
page.waitForTimeout(Math.min(action.duration_ms, remaining)),
|
||||
page.waitForTimeout(action.duration_ms),
|
||||
options.signal,
|
||||
deadline,
|
||||
);
|
||||
if (action.duration_ms > remaining)
|
||||
throw new BrowserObservationError(OPERATION, "timeout");
|
||||
return;
|
||||
}
|
||||
case "renderer-reload":
|
||||
@@ -226,10 +206,9 @@ const runAction = async ({
|
||||
throw new BrowserObservationError(OPERATION, "window_not_found");
|
||||
await runWithExecutionLimits(
|
||||
selectedWindow.reload({
|
||||
timeout: Math.min(actionTimeoutMs, remaining),
|
||||
timeout: 0,
|
||||
}),
|
||||
options.signal,
|
||||
deadline,
|
||||
);
|
||||
return;
|
||||
case "renderer-crash": {
|
||||
@@ -241,7 +220,6 @@ const runAction = async ({
|
||||
return true;
|
||||
}, action.window_index),
|
||||
options.signal,
|
||||
deadline,
|
||||
);
|
||||
if (!crashed)
|
||||
throw new BrowserObservationError(OPERATION, "window_not_found");
|
||||
@@ -251,7 +229,6 @@ const runAction = async ({
|
||||
await runWithExecutionLimits(
|
||||
emitDeepLink(application, action),
|
||||
options.signal,
|
||||
deadline,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -273,20 +250,15 @@ const actionWindowIndex = (action: ElectronAction): number | null => {
|
||||
type WaitForWindowContext = {
|
||||
readonly application: ElectronApplication;
|
||||
readonly windowIndex: number;
|
||||
readonly timeout: number;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly deadline: number;
|
||||
};
|
||||
|
||||
const waitForWindow = async ({
|
||||
application,
|
||||
windowIndex,
|
||||
timeout,
|
||||
signal,
|
||||
deadline,
|
||||
}: WaitForWindowContext): Promise<Page> => {
|
||||
const stopAt = Math.min(deadline, Date.now() + timeout);
|
||||
while (Date.now() < stopAt) {
|
||||
while (true) {
|
||||
if (signal?.aborted === true)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
const window = application.windows()[windowIndex];
|
||||
@@ -294,10 +266,8 @@ const waitForWindow = async ({
|
||||
await runWithExecutionLimits(
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 25)),
|
||||
signal,
|
||||
stopAt,
|
||||
);
|
||||
}
|
||||
throw new BrowserObservationError(OPERATION, "window_not_found");
|
||||
};
|
||||
|
||||
const emitDeepLink = (
|
||||
@@ -430,35 +400,39 @@ export const readApplicationState = async (
|
||||
};
|
||||
};
|
||||
|
||||
/** Apply a deadline and caller cancellation to one Playwright operation. */
|
||||
/** Apply an optional lifecycle deadline and caller cancellation to an operation. */
|
||||
export const runWithExecutionLimits = async <Value>(
|
||||
operation: Promise<Value>,
|
||||
signal: AbortSignal | undefined,
|
||||
deadline: number,
|
||||
deadline?: number,
|
||||
): Promise<Value> => {
|
||||
if (signal?.aborted === true)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
if (signal === undefined && deadline === undefined) return operation;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortListener: (() => void) | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new BrowserObservationError(OPERATION, "timeout")),
|
||||
Math.max(1, deadline - Date.now()),
|
||||
const interruptions: Promise<never>[] = [];
|
||||
if (deadline !== undefined)
|
||||
interruptions.push(
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new BrowserObservationError(OPERATION, "timeout")),
|
||||
Math.max(1, deadline - Date.now()),
|
||||
);
|
||||
}),
|
||||
);
|
||||
if (signal !== undefined)
|
||||
interruptions.push(
|
||||
new Promise<never>((_, reject) => {
|
||||
abortListener = () =>
|
||||
reject(new BrowserObservationError(OPERATION, "cancelled"));
|
||||
signal.addEventListener("abort", abortListener, { once: true });
|
||||
}),
|
||||
);
|
||||
});
|
||||
const cancellation =
|
||||
signal === undefined
|
||||
? timeout
|
||||
: Promise.race([
|
||||
timeout,
|
||||
new Promise<never>((_, reject) => {
|
||||
abortListener = () =>
|
||||
reject(new BrowserObservationError(OPERATION, "cancelled"));
|
||||
signal.addEventListener("abort", abortListener, { once: true });
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
return await Promise.race([operation, cancellation]);
|
||||
return interruptions.length === 0
|
||||
? await operation
|
||||
: await Promise.race([operation, ...interruptions]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
if (abortListener !== undefined)
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
} from "./PlaywrightElectronActiveActions.js";
|
||||
|
||||
const OPERATION = "capture_electron_scenario" as const;
|
||||
const RUN_TIMEOUT_MS = 60_000;
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Public identity for provider-owned Electron runtime experiments. */
|
||||
export const PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY: ProviderIdentity =
|
||||
@@ -165,14 +165,14 @@ export class PlaywrightElectronActiveProvider
|
||||
try {
|
||||
if (options.signal?.aborted === true)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
const deadline = Date.now() + RUN_TIMEOUT_MS;
|
||||
const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS;
|
||||
const paths = await canonicalPaths(input);
|
||||
application = await electron.launch({
|
||||
executablePath: paths.executable,
|
||||
cwd: paths.root,
|
||||
env: safeElectronEnvironment(runId),
|
||||
args: ["-r", hookPath, paths.application, ...input.args],
|
||||
timeout: Math.max(1, deadline - Date.now()),
|
||||
timeout: Math.max(1, startupDeadline - Date.now()),
|
||||
});
|
||||
const leaderPid = application.process().pid;
|
||||
if (
|
||||
@@ -192,16 +192,10 @@ export class PlaywrightElectronActiveProvider
|
||||
expectedCommand: paths.executable,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
};
|
||||
const actions = await runElectronActions(
|
||||
application,
|
||||
input,
|
||||
options,
|
||||
deadline,
|
||||
);
|
||||
const actions = await runElectronActions(application, input, options);
|
||||
const state = await runWithExecutionLimits(
|
||||
readApplicationState(application),
|
||||
options.signal,
|
||||
deadline,
|
||||
);
|
||||
outcome = ok(createResult(paths, input, actions, state));
|
||||
} catch (cause: unknown) {
|
||||
@@ -341,7 +335,7 @@ const createResult = (
|
||||
},
|
||||
coverage: createCoverage(hookSnapshot),
|
||||
limitations: [
|
||||
"IPC payloads are represented only by bounded value shapes; values are never retained, channels are capped at 1,024 characters, and argument-shape arrays at 32 entries.",
|
||||
"IPC payloads are represented by value shapes; payload values are never retained.",
|
||||
"IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.",
|
||||
"The runtime timeline records lifecycle, navigation, shell, permission, popup, download, protocol, preload, native-addon, process, and IPC events; activity before hook installation is unavailable.",
|
||||
"The preload and renderer process contexts are not instrumented by the main-process -r hook; preload configuration and contextBridge API-shape events are main-boundary observations, not proof of renderer-side execution.",
|
||||
|
||||
@@ -165,3 +165,36 @@ it("accepts complete browser inputs beyond former string length caps", () => {
|
||||
: undefined,
|
||||
).toBe(longToken);
|
||||
});
|
||||
|
||||
it("accepts caller-selected viewport sizes and click counts without ceilings", () => {
|
||||
const input = {
|
||||
browser: {
|
||||
mode: "launch",
|
||||
executable_path: "/opt/chromium",
|
||||
headless: true,
|
||||
user_data: "temporary-owned",
|
||||
cleanup: "close-and-delete-profile",
|
||||
},
|
||||
start_url: { url: "https://app.example.test/" },
|
||||
allowed_origins: ["https://app.example.test"],
|
||||
environment: {
|
||||
viewport: { width: 20_000, height: 10_000, device_scale_factor: 8 },
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
step_id: "many-clicks",
|
||||
action: "click",
|
||||
locator: { kind: "css", selector: "#item" },
|
||||
click_count: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const parsed = browserScenarioSchema.parse(input);
|
||||
expect(parsed.environment.viewport).toEqual({
|
||||
width: 20_000,
|
||||
height: 10_000,
|
||||
device_scale_factor: 8,
|
||||
});
|
||||
expect(parsed.actions[0]).toMatchObject({ click_count: 4 });
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
browserScenarioEventSchema,
|
||||
browserStepArtifactsSchema,
|
||||
} from "./browserScenarioCaptureValues.js";
|
||||
import { browserScenarioCaptureSchema } from "./browserScenarioCapture.js";
|
||||
|
||||
const networkEvent = {
|
||||
sequence: 1,
|
||||
@@ -140,6 +141,69 @@ it("accepts complete storage, history, and text artifacts beyond former caps", (
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts complete scenario output beyond former string and origin caps", () => {
|
||||
const longText = "detail".repeat(1_000);
|
||||
const origins = Array.from(
|
||||
{ length: 33 },
|
||||
(_, index) => `https://site-${index}.example.test`,
|
||||
);
|
||||
const step = (index: number) => ({
|
||||
step_index: index,
|
||||
step_id: longText,
|
||||
action: longText,
|
||||
elapsed_ms: 0,
|
||||
before_url: sanitizeBrowserUrl("https://example.test/"),
|
||||
after_url: sanitizeBrowserUrl("https://example.test/"),
|
||||
event_sequence_start: 1,
|
||||
event_sequence_end: 0,
|
||||
artifacts: {
|
||||
screenshot: { state: "not_requested" },
|
||||
dom: { state: "not_requested" },
|
||||
accessibility: { state: "not_requested" },
|
||||
url: { state: "not_requested" },
|
||||
history: { state: "not_requested" },
|
||||
storage: { state: "not_requested" },
|
||||
},
|
||||
completeness: {
|
||||
status: "complete",
|
||||
equality_eligible: true,
|
||||
missing_sections: [],
|
||||
truncated_sections: [],
|
||||
},
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
const parsed = browserScenarioCaptureSchema.parse({
|
||||
browser: {
|
||||
mode: "launch",
|
||||
process_ownership: "provider-owned",
|
||||
cleanup: "terminated-owned-process",
|
||||
product: longText,
|
||||
version: longText,
|
||||
},
|
||||
scenario: {
|
||||
start_origin: longText,
|
||||
allowed_origins: origins,
|
||||
action_count: 1,
|
||||
secret_references: [longText],
|
||||
},
|
||||
duration_ms: 0,
|
||||
steps: [step(0), step(1)],
|
||||
events: { retained: 0, dropped: 0, items: [] },
|
||||
completeness: {
|
||||
status: "complete",
|
||||
equality_eligible: true,
|
||||
missing_sections: [],
|
||||
truncated_sections: [],
|
||||
},
|
||||
limitations: Array.from({ length: 65 }, () => longText),
|
||||
});
|
||||
|
||||
expect(parsed.scenario.allowed_origins).toHaveLength(33);
|
||||
expect(parsed.steps[0]?.step_id).toBe(longText);
|
||||
expect(parsed.limitations).toHaveLength(65);
|
||||
});
|
||||
|
||||
it("derives equality eligibility from exact completeness state", () => {
|
||||
expect(
|
||||
browserScenarioCompletenessSchema.safeParse({
|
||||
|
||||
@@ -21,21 +21,21 @@ export {
|
||||
type BrowserStepArtifacts,
|
||||
} from "./browserScenarioCaptureValues.js";
|
||||
|
||||
/** Step-indexed, explicitly bounded browser scenario observation. */
|
||||
/** Step-indexed browser scenario observation. */
|
||||
export const browserScenarioCaptureSchema = z
|
||||
.strictObject({
|
||||
browser: z.strictObject({
|
||||
mode: z.enum(["launch", "connect"]),
|
||||
process_ownership: z.enum(["provider-owned", "external"]),
|
||||
cleanup: z.enum(["terminated-owned-process", "disconnected-external"]),
|
||||
product: z.string().min(1).max(1_024),
|
||||
version: z.string().min(1).max(256),
|
||||
product: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
}),
|
||||
scenario: z.strictObject({
|
||||
start_origin: z.string().min(1).max(2_048),
|
||||
allowed_origins: z.array(z.string().min(1).max(2_048)).min(1).max(32),
|
||||
start_origin: z.string().min(1),
|
||||
allowed_origins: z.array(z.string().min(1)).min(1),
|
||||
action_count: z.number().int().min(1),
|
||||
secret_references: z.array(z.string().min(1).max(64)),
|
||||
secret_references: z.array(z.string().min(1)),
|
||||
}),
|
||||
duration_ms: z.number().int().min(0),
|
||||
steps: z.array(browserScenarioStepSchema).min(1),
|
||||
@@ -45,7 +45,7 @@ export const browserScenarioCaptureSchema = z
|
||||
items: z.array(browserScenarioEventSchema),
|
||||
}),
|
||||
completeness: browserScenarioCompletenessSchema,
|
||||
limitations: z.array(z.string().min(1).max(2_048)).max(64),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
})
|
||||
.superRefine((capture, context) => {
|
||||
if (capture.events.retained !== capture.events.items.length)
|
||||
|
||||
@@ -30,13 +30,13 @@ export const captureStateSchema = <Schema extends z.ZodType>(value: Schema) =>
|
||||
}),
|
||||
z.strictObject({
|
||||
state: z.literal("missing"),
|
||||
reason: z.string().min(1).max(1_024),
|
||||
reason: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
state: z.literal("truncated"),
|
||||
observed: z.number().int().min(0),
|
||||
retained: z.number().int().min(0),
|
||||
reason: z.string().min(1).max(1_024),
|
||||
reason: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -281,8 +281,8 @@ export const classifyBrowserScenarioCompleteness = (
|
||||
|
||||
const browserScenarioStepShape = {
|
||||
step_index: z.number().int().min(0),
|
||||
step_id: z.string().min(1).max(64),
|
||||
action: z.string().min(1).max(64),
|
||||
step_id: z.string().min(1),
|
||||
action: z.string().min(1),
|
||||
elapsed_ms: z.number().int().min(0),
|
||||
before_url: sanitizedBrowserUrlSchema,
|
||||
after_url: sanitizedBrowserUrlSchema,
|
||||
|
||||
@@ -95,9 +95,9 @@ export const browserScenarioEnvironmentSchema = z
|
||||
.strictObject({
|
||||
viewport: z
|
||||
.strictObject({
|
||||
width: z.number().int().min(320).max(7_680).default(1_280),
|
||||
height: z.number().int().min(240).max(4_320).default(720),
|
||||
device_scale_factor: z.number().min(0.5).max(4).default(1),
|
||||
width: z.number().int().positive().default(1_280),
|
||||
height: z.number().int().positive().default(720),
|
||||
device_scale_factor: z.number().positive().default(1),
|
||||
})
|
||||
.default({ width: 1_280, height: 720, device_scale_factor: 1 }),
|
||||
locale: z
|
||||
@@ -182,7 +182,7 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [
|
||||
action: z.literal("click"),
|
||||
locator: locatorSchema,
|
||||
button: z.enum(["left", "middle", "right"]).default("left"),
|
||||
click_count: z.number().int().min(1).max(3).default(1),
|
||||
click_count: z.number().int().positive().default(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
...stepBase,
|
||||
@@ -382,8 +382,8 @@ const snapshotKindSchema = z.enum([
|
||||
|
||||
export const browserScenarioCaptureSchema = z
|
||||
.strictObject({
|
||||
after_each_step: z.array(snapshotKindSchema).max(6).default([]),
|
||||
at_end: z.array(snapshotKindSchema).max(6).default(["url"]),
|
||||
after_each_step: z.array(snapshotKindSchema).default([]),
|
||||
at_end: z.array(snapshotKindSchema).default(["url"]),
|
||||
events: z
|
||||
.array(
|
||||
z.enum([
|
||||
|
||||
@@ -51,6 +51,6 @@ export const createElectronActiveObservationFixtureResult = (
|
||||
observed: 1,
|
||||
},
|
||||
limitations: [
|
||||
"IPC payloads are represented only by bounded value shapes; values are never retained.",
|
||||
"IPC payloads are represented by value shapes; payload values are never retained.",
|
||||
],
|
||||
});
|
||||
|
||||
@@ -35,4 +35,15 @@ describe("inline web screenshots", () => {
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a caller-supplied target ID without a length ceiling", () => {
|
||||
const targetId = "target-".repeat(100);
|
||||
expect(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_origins: ["https://app.example.test"],
|
||||
target_id: targetId,
|
||||
}).target_id,
|
||||
).toBe(targetId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ export type WebScreenshotArtifact = z.infer<typeof webScreenshotArtifactSchema>;
|
||||
export const captureWebScreenshotInputSchema = z.strictObject({
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
allowed_origins: browserAllowedOriginsSchema,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
target_id: z.string().trim().min(1),
|
||||
});
|
||||
export type CaptureWebScreenshotInput = z.infer<
|
||||
typeof captureWebScreenshotInputSchema
|
||||
|
||||
@@ -87,6 +87,8 @@ electron.ipcMain.handle(channel, async () => ({ ok: true }));
|
||||
await electron.ipcMain.handlers.get(channel)({ sender: { id: 9 }, senderFrame: { routingId: 7 } }, ...Array.from({ length: 40 }, () => ({ value: true })));
|
||||
window.webContents.send("main-to-renderer", { value: true });
|
||||
window.webContents.emit("render-process-gone", { reason: "crashed" });
|
||||
const longEventName = "l".repeat(2048);
|
||||
window.webContents.emit(longEventName);
|
||||
const utility = electron.utilityProcess.fork("utility.js");
|
||||
utility.emit("message", { data: { value: true } });
|
||||
utility.postMessage({ value: true });
|
||||
@@ -163,6 +165,14 @@ it("retains active hook lifecycle and IPC evidence without a count ceiling", asy
|
||||
});
|
||||
expect(ipc?.channel).toBe("x".repeat(2_048));
|
||||
expect(ipc?.argument_shapes).toHaveLength(40);
|
||||
expect(snapshot.events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "web-contents-lifecycle",
|
||||
event: "l".repeat(2048),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves fatal process termination after recording uncaught exceptions", async () => {
|
||||
|
||||
@@ -56,4 +56,14 @@ describe("Electron file scope", () => {
|
||||
await authorizedElectronFile("file:///tmp/root%2Findex.html", roots),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts file URLs with long query text inside a root", async () => {
|
||||
const root = await createTestTempDirectory("rea-electron-long-url-");
|
||||
const path = join(root, "index.html");
|
||||
await writeFile(path, "allowed");
|
||||
const roots = await canonicalElectronRoots([root]);
|
||||
const value = `${pathToFileURL(path).href}?${"x".repeat(70_000)}`;
|
||||
|
||||
await expect(authorizedElectronFile(value, roots)).resolves.toBe(path);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user