pi-agent-browser-native 0.2.32 → 0.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +61 -20
  3. package/docs/ARCHITECTURE.md +9 -2
  4. package/docs/COMMAND_REFERENCE.md +45 -14
  5. package/docs/ELECTRON.md +23 -4
  6. package/docs/RELEASE.md +15 -5
  7. package/docs/REQUIREMENTS.md +1 -1
  8. package/docs/SUPPORT_MATRIX.md +36 -22
  9. package/docs/TOOL_CONTRACT.md +90 -31
  10. package/extensions/agent-browser/index.ts +407 -4373
  11. package/extensions/agent-browser/lib/input-modes/electron.ts +170 -0
  12. package/extensions/agent-browser/lib/input-modes/job.ts +265 -0
  13. package/extensions/agent-browser/lib/input-modes/lookups.ts +447 -0
  14. package/extensions/agent-browser/lib/input-modes/params.ts +188 -0
  15. package/extensions/agent-browser/lib/input-modes/semantic-action.ts +107 -0
  16. package/extensions/agent-browser/lib/input-modes/shared.ts +46 -0
  17. package/extensions/agent-browser/lib/input-modes/types.ts +221 -0
  18. package/extensions/agent-browser/lib/input-modes.ts +44 -0
  19. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +762 -0
  20. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +450 -0
  21. package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +46 -0
  22. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +736 -0
  23. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +413 -0
  24. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +868 -0
  25. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +482 -0
  26. package/extensions/agent-browser/lib/orchestration/browser-run.ts +1 -0
  27. package/extensions/agent-browser/lib/orchestration/input-plan.ts +338 -0
  28. package/extensions/agent-browser/lib/playbook.ts +22 -20
  29. package/extensions/agent-browser/lib/process.ts +106 -4
  30. package/extensions/agent-browser/lib/results/action-recommendations.ts +269 -0
  31. package/extensions/agent-browser/lib/results/artifact-manifest.ts +114 -0
  32. package/extensions/agent-browser/lib/results/artifact-state.ts +13 -0
  33. package/extensions/agent-browser/lib/results/categories.ts +106 -0
  34. package/extensions/agent-browser/lib/results/contracts.ts +220 -0
  35. package/extensions/agent-browser/lib/results/editable-ref-evidence.ts +72 -0
  36. package/extensions/agent-browser/lib/results/envelope.ts +2 -1
  37. package/extensions/agent-browser/lib/results/network.ts +64 -0
  38. package/extensions/agent-browser/lib/results/next-actions.ts +117 -0
  39. package/extensions/agent-browser/lib/results/presentation/artifacts.ts +506 -0
  40. package/extensions/agent-browser/lib/results/presentation/batch.ts +355 -0
  41. package/extensions/agent-browser/lib/results/presentation/common.ts +53 -0
  42. package/extensions/agent-browser/lib/results/presentation/content.ts +36 -0
  43. package/extensions/agent-browser/lib/results/presentation/diagnostics.ts +730 -0
  44. package/extensions/agent-browser/lib/results/presentation/errors.ts +125 -0
  45. package/extensions/agent-browser/lib/results/presentation/large-output.ts +182 -0
  46. package/extensions/agent-browser/lib/results/presentation/navigation.ts +216 -0
  47. package/extensions/agent-browser/lib/results/presentation/registry.ts +182 -0
  48. package/extensions/agent-browser/lib/results/presentation/semantic-action.ts +133 -0
  49. package/extensions/agent-browser/lib/results/presentation/skills.ts +143 -0
  50. package/extensions/agent-browser/lib/results/presentation.ts +96 -2403
  51. package/extensions/agent-browser/lib/results/recovery-actions.ts +139 -0
  52. package/extensions/agent-browser/lib/results/recovery-next-actions.ts +71 -0
  53. package/extensions/agent-browser/lib/results/selector-recovery.ts +312 -0
  54. package/extensions/agent-browser/lib/results/shared.ts +17 -789
  55. package/extensions/agent-browser/lib/results/snapshot-high-value-controls.ts +262 -0
  56. package/extensions/agent-browser/lib/results/snapshot-refs.ts +100 -0
  57. package/extensions/agent-browser/lib/results/snapshot-segments.ts +366 -0
  58. package/extensions/agent-browser/lib/results/snapshot-spill.ts +63 -0
  59. package/extensions/agent-browser/lib/results/snapshot.ts +37 -489
  60. package/extensions/agent-browser/lib/results/text.ts +40 -0
  61. package/extensions/agent-browser/lib/results.ts +16 -5
  62. package/extensions/agent-browser/lib/session-page-state.ts +486 -0
  63. package/package.json +2 -1
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Purpose: Compile top-level Electron wrapper inputs into validated Electron actions.
3
+ * Responsibilities: Enforce action-specific fields, launch-target rules, and wrapper-owned flag safety.
4
+ * Scope: Electron input-mode validation only; launch/probe/cleanup execution stays in the extension entrypoint.
5
+ */
6
+
7
+ import { isRecord } from "../parsing.js";
8
+ import {
9
+ AGENT_BROWSER_ELECTRON_ACTIONS,
10
+ AGENT_BROWSER_ELECTRON_HANDOFFS,
11
+ AGENT_BROWSER_ELECTRON_LIST_FIELDS,
12
+ AGENT_BROWSER_ELECTRON_PROBE_FIELDS,
13
+ AGENT_BROWSER_ELECTRON_RESERVED_APP_ARGS,
14
+ AGENT_BROWSER_ELECTRON_TARGET_TYPES,
15
+ type AgentBrowserElectronAction,
16
+ type CompiledAgentBrowserElectron,
17
+ } from "./types.js";
18
+
19
+ function validateOptionalNonEmptyString(input: Record<string, unknown>, fieldName: string): { value?: string; error?: string } {
20
+ const value = input[fieldName];
21
+ if (value === undefined) return {};
22
+ if (typeof value !== "string" || value.trim().length === 0) {
23
+ return { error: `electron.${fieldName} must be a non-empty string when provided.` };
24
+ }
25
+ return { value: value.trim() };
26
+ }
27
+
28
+ function validateOptionalElectronStringArray(input: Record<string, unknown>, fieldName: "allow" | "appArgs" | "deny"): string | undefined {
29
+ const value = input[fieldName];
30
+ if (value === undefined) return undefined;
31
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) {
32
+ return `electron.${fieldName} must be an array of non-empty strings when provided.`;
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ function validateOptionalElectronEnum<T extends string>(input: Record<string, unknown>, fieldName: string, values: readonly T[]): string | undefined {
38
+ const value = input[fieldName];
39
+ if (value === undefined) return undefined;
40
+ if (typeof value !== "string" || !values.includes(value as T)) {
41
+ return `electron.${fieldName} must be one of: ${values.join(", ")}.`;
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ function getReservedElectronAppArg(appArgs: string[] | undefined): string | undefined {
47
+ return appArgs?.find((arg) => {
48
+ const trimmed = arg.trim();
49
+ return trimmed === "--" || AGENT_BROWSER_ELECTRON_RESERVED_APP_ARGS.some((reserved) => trimmed === reserved || trimmed.startsWith(`${reserved}=`));
50
+ });
51
+ }
52
+
53
+ function validateElectronLaunchAppArgs(appArgs: string[] | undefined): string | undefined {
54
+ const reservedArg = getReservedElectronAppArg(appArgs);
55
+ return reservedArg
56
+ ? `electron.appArgs must not include wrapper-owned launch flag ${reservedArg}.`
57
+ : undefined;
58
+ }
59
+
60
+ function validateOptionalElectronPositiveInteger(input: Record<string, unknown>, fieldName: "maxResults" | "timeoutMs"): { value?: number; error?: string } {
61
+ const value = input[fieldName];
62
+ if (value === undefined) return {};
63
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
64
+ return { error: `electron.${fieldName} must be a positive integer when provided.` };
65
+ }
66
+ return { value };
67
+ }
68
+
69
+ function onlyAllowedElectronFields(input: Record<string, unknown>, action: string, allowedFields: ReadonlySet<string>): string | undefined {
70
+ return Object.keys(input).find((fieldName) => !allowedFields.has(fieldName))
71
+ ? `electron.${action} does not support electron.${Object.keys(input).find((fieldName) => !allowedFields.has(fieldName))}.`
72
+ : undefined;
73
+ }
74
+
75
+ export function compileAgentBrowserElectron(input: unknown): { compiled?: CompiledAgentBrowserElectron; error?: string } {
76
+ if (!isRecord(input)) return { error: "electron must be an object." };
77
+ const action = input.action;
78
+ if (typeof action !== "string" || !AGENT_BROWSER_ELECTRON_ACTIONS.includes(action as AgentBrowserElectronAction)) {
79
+ return { error: `electron.action must be one of: ${AGENT_BROWSER_ELECTRON_ACTIONS.join(", ")}.` };
80
+ }
81
+ for (const fieldName of ["query", "appPath", "appName", "bundleId", "executablePath", "launchId"] as const) {
82
+ const validation = validateOptionalNonEmptyString(input, fieldName);
83
+ if (validation.error) return { error: validation.error };
84
+ }
85
+ for (const fieldName of ["appArgs", "allow", "deny"] as const) {
86
+ const error = validateOptionalElectronStringArray(input, fieldName);
87
+ if (error) return { error };
88
+ }
89
+ const handoffError = validateOptionalElectronEnum(input, "handoff", AGENT_BROWSER_ELECTRON_HANDOFFS);
90
+ if (handoffError) return { error: handoffError };
91
+ const targetTypeError = validateOptionalElectronEnum(input, "targetType", AGENT_BROWSER_ELECTRON_TARGET_TYPES);
92
+ if (targetTypeError) return { error: targetTypeError };
93
+ for (const fieldName of ["maxResults", "timeoutMs"] as const) {
94
+ const validation = validateOptionalElectronPositiveInteger(input, fieldName);
95
+ if (validation.error) return { error: validation.error };
96
+ }
97
+ if (input.all !== undefined && input.all !== true) {
98
+ return { error: "electron.all must be true when provided." };
99
+ }
100
+ if (action === "list") {
101
+ const unsupportedListField = Object.keys(input).find((fieldName) => !AGENT_BROWSER_ELECTRON_LIST_FIELDS.has(fieldName));
102
+ if (unsupportedListField) {
103
+ return { error: `electron.list only supports query and maxResults; remove electron.${unsupportedListField}.` };
104
+ }
105
+ return {
106
+ compiled: {
107
+ action: "list",
108
+ maxResults: validateOptionalElectronPositiveInteger(input, "maxResults").value,
109
+ query: validateOptionalNonEmptyString(input, "query").value,
110
+ },
111
+ };
112
+ }
113
+ if (action === "probe") {
114
+ const unsupportedProbeField = Object.keys(input).find((fieldName) => !AGENT_BROWSER_ELECTRON_PROBE_FIELDS.has(fieldName));
115
+ if (unsupportedProbeField) {
116
+ return { error: `electron.probe only supports action, launchId, and timeoutMs; remove electron.${unsupportedProbeField}.` };
117
+ }
118
+ const launchId = validateOptionalNonEmptyString(input, "launchId").value;
119
+ const timeoutMs = validateOptionalElectronPositiveInteger(input, "timeoutMs").value;
120
+ return {
121
+ compiled: {
122
+ action: "probe",
123
+ ...(launchId ? { launchId } : {}),
124
+ ...(timeoutMs ? { timeoutMs } : {}),
125
+ },
126
+ };
127
+ }
128
+ if (action === "launch") {
129
+ const allowedFields = new Set(["action", "allow", "appArgs", "appName", "appPath", "bundleId", "deny", "executablePath", "handoff", "targetType", "timeoutMs"]);
130
+ const unsupportedFieldError = onlyAllowedElectronFields(input, action, allowedFields);
131
+ if (unsupportedFieldError) return { error: unsupportedFieldError };
132
+ const appArgs = (input.appArgs as string[] | undefined)?.map((item) => item.trim());
133
+ const appArgsError = validateElectronLaunchAppArgs(appArgs);
134
+ if (appArgsError) return { error: appArgsError };
135
+ const targetFields = ["appPath", "appName", "bundleId", "executablePath"] as const;
136
+ const providedTargets = targetFields.filter((fieldName) => input[fieldName] !== undefined);
137
+ if (providedTargets.length !== 1) {
138
+ return { error: "electron.launch requires exactly one of appPath, appName, bundleId, or executablePath." };
139
+ }
140
+ return {
141
+ compiled: {
142
+ action: "launch",
143
+ allow: (input.allow as string[] | undefined)?.map((item) => item.trim()),
144
+ appArgs,
145
+ deny: (input.deny as string[] | undefined)?.map((item) => item.trim()),
146
+ appName: validateOptionalNonEmptyString(input, "appName").value,
147
+ appPath: validateOptionalNonEmptyString(input, "appPath").value,
148
+ bundleId: validateOptionalNonEmptyString(input, "bundleId").value,
149
+ executablePath: validateOptionalNonEmptyString(input, "executablePath").value,
150
+ handoff: (input.handoff as "connect" | "snapshot" | "tabs" | undefined) ?? "snapshot",
151
+ targetType: (input.targetType as "any" | "page" | "webview" | undefined) ?? "page",
152
+ timeoutMs: validateOptionalElectronPositiveInteger(input, "timeoutMs").value,
153
+ },
154
+ };
155
+ }
156
+ const allowedFields = new Set(["action", "all", "launchId", "timeoutMs"]);
157
+ const unsupportedFieldError = onlyAllowedElectronFields(input, action, allowedFields);
158
+ if (unsupportedFieldError) return { error: unsupportedFieldError };
159
+ if (input.all === true && input.launchId !== undefined) {
160
+ return { error: `electron.${action} accepts launchId or all, not both.` };
161
+ }
162
+ return {
163
+ compiled: {
164
+ action: action as "cleanup" | "status",
165
+ all: input.all === true || undefined,
166
+ launchId: validateOptionalNonEmptyString(input, "launchId").value,
167
+ timeoutMs: validateOptionalElectronPositiveInteger(input, "timeoutMs").value,
168
+ },
169
+ };
170
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Purpose: Compile constrained job and lightweight QA wrapper inputs to upstream batch commands.
3
+ * Responsibilities: Validate job/QA fields, produce argv/stdin, and summarize QA diagnostic results.
4
+ * Scope: Job and QA modes only.
5
+ */
6
+
7
+ import type { ArtifactVerificationSummary } from "../results/contracts.js";
8
+ import { isRecord } from "../parsing.js";
9
+ import { summarizeNetworkFailures } from "../results/network.js";
10
+ import { getBatchResultItems, getCommandNameFromBatchItem, getSelectValues } from "./shared.js";
11
+ import {
12
+ AGENT_BROWSER_JOB_STEP_ACTIONS,
13
+ AGENT_BROWSER_QA_LOAD_STATES,
14
+ type AgentBrowserJobStepAction,
15
+ type AgentBrowserQaLoadState,
16
+ type AgentBrowserQaPresetAnalysis,
17
+ type CompiledAgentBrowserJob,
18
+ type CompiledAgentBrowserJobStep,
19
+ type CompiledAgentBrowserQaPreset,
20
+ } from "./types.js";
21
+
22
+ function getRequiredJobString(step: Record<string, unknown>, field: "path" | "selector" | "text" | "url", action: AgentBrowserJobStepAction): { value?: string; error?: string } {
23
+ const value = step[field];
24
+ if (typeof value !== "string" || value.trim().length === 0) {
25
+ return { error: `job step ${action} requires a non-empty ${field} string.` };
26
+ }
27
+ return { value };
28
+ }
29
+
30
+ export function compileAgentBrowserJob(input: unknown): { compiled?: CompiledAgentBrowserJob; error?: string } {
31
+ if (!isRecord(input)) {
32
+ return { error: "job must be an object." };
33
+ }
34
+ const rawSteps = input.steps;
35
+ if (!Array.isArray(rawSteps) || rawSteps.length === 0) {
36
+ return { error: "job.steps must be a non-empty array." };
37
+ }
38
+ const steps: CompiledAgentBrowserJobStep[] = [];
39
+ for (const [index, rawStep] of rawSteps.entries()) {
40
+ if (!isRecord(rawStep)) {
41
+ return { error: `job.steps[${index}] must be an object.` };
42
+ }
43
+ const action = rawStep.action;
44
+ if (typeof action !== "string" || !AGENT_BROWSER_JOB_STEP_ACTIONS.includes(action as AgentBrowserJobStepAction)) {
45
+ return { error: `job.steps[${index}].action must be one of: ${AGENT_BROWSER_JOB_STEP_ACTIONS.join(", ")}.` };
46
+ }
47
+ const jobAction = action as AgentBrowserJobStepAction;
48
+ let args: string[];
49
+ if (jobAction === "open") {
50
+ const result = getRequiredJobString(rawStep, "url", jobAction);
51
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
52
+ args = ["open", result.value as string];
53
+ } else if (jobAction === "click") {
54
+ const result = getRequiredJobString(rawStep, "selector", jobAction);
55
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
56
+ args = ["click", result.value as string];
57
+ } else if (jobAction === "fill") {
58
+ const selector = getRequiredJobString(rawStep, "selector", jobAction);
59
+ if (selector.error) return { error: `job.steps[${index}]: ${selector.error}` };
60
+ const text = getRequiredJobString(rawStep, "text", jobAction);
61
+ if (text.error) return { error: `job.steps[${index}]: ${text.error}` };
62
+ args = ["fill", selector.value as string, text.value as string];
63
+ } else if (jobAction === "select") {
64
+ const selector = getRequiredJobString(rawStep, "selector", jobAction);
65
+ if (selector.error) return { error: `job.steps[${index}]: ${selector.error}` };
66
+ const values = getSelectValues(rawStep, `job.steps[${index}]`);
67
+ if (values.error) return { error: values.error };
68
+ args = ["select", selector.value as string, ...(values.values as string[])];
69
+ } else if (jobAction === "wait") {
70
+ const milliseconds = rawStep.milliseconds;
71
+ if (typeof milliseconds !== "number" || !Number.isInteger(milliseconds) || milliseconds <= 0) {
72
+ return { error: `job.steps[${index}]: job step wait requires a positive integer milliseconds value.` };
73
+ }
74
+ args = ["wait", String(milliseconds)];
75
+ } else if (jobAction === "assertText") {
76
+ const result = getRequiredJobString(rawStep, "text", jobAction);
77
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
78
+ args = ["wait", "--text", result.value as string];
79
+ } else if (jobAction === "assertUrl") {
80
+ const result = getRequiredJobString(rawStep, "url", jobAction);
81
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
82
+ args = ["wait", "--url", result.value as string];
83
+ } else if (jobAction === "waitForDownload") {
84
+ const result = getRequiredJobString(rawStep, "path", jobAction);
85
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
86
+ args = ["wait", "--download", result.value as string];
87
+ } else {
88
+ const result = getRequiredJobString(rawStep, "path", jobAction);
89
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
90
+ args = ["screenshot", result.value as string];
91
+ }
92
+ steps.push({ action: jobAction, args });
93
+ }
94
+ return { compiled: { args: ["batch"], stdin: JSON.stringify(steps.map((step) => step.args)), steps } };
95
+ }
96
+
97
+ export function isHttpOrHttpsUrl(url: string): boolean {
98
+ try {
99
+ const protocol = new URL(url).protocol;
100
+ return protocol === "http:" || protocol === "https:";
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ function describeQaChecksRun(checks: CompiledAgentBrowserQaPreset["checks"]): string {
107
+ const parts = [`load:${checks.loadState}`];
108
+ if (checks.expectedText.length > 0) parts.push(`text×${checks.expectedText.length}`);
109
+ if (checks.expectedSelector) parts.push("selector");
110
+ if (checks.checkNetwork) parts.push("network");
111
+ if (checks.checkConsole) parts.push("console");
112
+ if (checks.checkErrors) parts.push("errors");
113
+ if (checks.screenshotPath) parts.push("screenshot");
114
+ return parts.join(", ");
115
+ }
116
+
117
+ export function extractQaPageContext(options: {
118
+ attachedTarget?: { title?: string; url?: string };
119
+ batchData?: unknown;
120
+ compiled?: CompiledAgentBrowserQaPreset;
121
+ }): { title?: string; url?: string } {
122
+ if (options.attachedTarget?.title || options.attachedTarget?.url) {
123
+ return { title: options.attachedTarget.title, url: options.attachedTarget.url };
124
+ }
125
+ for (const item of getBatchResultItems(options.batchData)) {
126
+ if (getCommandNameFromBatchItem(item) !== "open" || !isRecord(item.result)) continue;
127
+ const url = typeof item.result.url === "string" ? item.result.url : undefined;
128
+ const title = typeof item.result.title === "string" ? item.result.title : undefined;
129
+ if (url || title) return { title, url };
130
+ }
131
+ if (options.compiled?.checks.url) {
132
+ return { url: options.compiled.checks.url };
133
+ }
134
+ return {};
135
+ }
136
+
137
+ export function buildQaCompactPassText(options: {
138
+ artifactVerification?: ArtifactVerificationSummary;
139
+ batchStepCount: number;
140
+ checks: CompiledAgentBrowserQaPreset["checks"];
141
+ page?: { title?: string; url?: string };
142
+ qaPreset: AgentBrowserQaPresetAnalysis;
143
+ }): string {
144
+ const lines = [options.qaPreset.summary];
145
+ const pageParts = [options.page?.title, options.page?.url].filter((part): part is string => typeof part === "string" && part.length > 0);
146
+ if (pageParts.length > 0) lines.push(`Page: ${pageParts.join(" — ")}`);
147
+ lines.push(`Checks run: ${describeQaChecksRun(options.checks)} (${options.batchStepCount} batch step${options.batchStepCount === 1 ? "" : "s"})`);
148
+ if (options.checks.screenshotPath) {
149
+ const verification = options.artifactVerification;
150
+ lines.push(verification
151
+ ? `Screenshot: ${options.checks.screenshotPath} (${verification.verifiedCount}/${verification.artifacts.length} verified on disk)`
152
+ : `Screenshot: ${options.checks.screenshotPath}`);
153
+ }
154
+ lines.push("Full diagnostic matrix: see details.qaPreset and details.batchSteps.");
155
+ return lines.join("\n");
156
+ }
157
+
158
+ export function analyzeQaPresetResults(data: unknown): AgentBrowserQaPresetAnalysis | undefined {
159
+ const items = getBatchResultItems(data);
160
+ if (items.length === 0) return undefined;
161
+ const failedChecks: string[] = [];
162
+ const warnings: string[] = [];
163
+ for (const item of items) {
164
+ if (item.success === false) {
165
+ failedChecks.push(`${getCommandNameFromBatchItem(item) ?? "step"} failed`);
166
+ }
167
+ const result = isRecord(item.result) ? item.result : undefined;
168
+ const commandName = getCommandNameFromBatchItem(item);
169
+ if (commandName === "errors" && Array.isArray(result?.errors) && result.errors.length > 0) {
170
+ failedChecks.push(`${result.errors.length} page error(s)`);
171
+ }
172
+ if (commandName === "console" && Array.isArray(result?.messages)) {
173
+ const errorCount = result.messages.filter((message) => isRecord(message) && /error/i.test(String(message.type ?? message.level ?? ""))).length;
174
+ if (errorCount > 0) failedChecks.push(`${errorCount} console error message(s)`);
175
+ }
176
+ if (commandName === "network" && Array.isArray(result?.requests)) {
177
+ const networkFailures = summarizeNetworkFailures(result.requests);
178
+ if (networkFailures.actionableCount > 0) failedChecks.push(`${networkFailures.actionableCount} actionable failed network request(s)`);
179
+ if (networkFailures.benignCount > 0) warnings.push(`${networkFailures.benignCount} benign network request failure(s) ignored`);
180
+ }
181
+ }
182
+ const uniqueFailures = [...new Set(failedChecks)];
183
+ const uniqueWarnings = [...new Set(warnings)];
184
+ return {
185
+ failedChecks: uniqueFailures,
186
+ passed: uniqueFailures.length === 0,
187
+ summary: uniqueFailures.length === 0
188
+ ? uniqueWarnings.length === 0 ? "QA preset passed." : `QA preset passed with warnings: ${uniqueWarnings.join("; ")}.`
189
+ : `QA preset failed: ${uniqueFailures.join("; ")}.`,
190
+ warnings: uniqueWarnings,
191
+ };
192
+ }
193
+
194
+ export function compileAgentBrowserQaPreset(input: unknown): { compiled?: CompiledAgentBrowserQaPreset; error?: string } {
195
+ if (!isRecord(input)) {
196
+ return { error: "qa must be an object." };
197
+ }
198
+ const attached = input.attached === true;
199
+ if (input.attached !== undefined && typeof input.attached !== "boolean") {
200
+ return { error: "qa.attached must be a boolean when provided." };
201
+ }
202
+ const url = input.url;
203
+ if (attached && url !== undefined) {
204
+ return { error: "qa.url must be omitted when qa.attached is true." };
205
+ }
206
+ if (!attached && (typeof url !== "string" || url.trim().length === 0)) {
207
+ return { error: "qa.url must be a non-empty string." };
208
+ }
209
+ const normalizedUrl = typeof url === "string" ? url.trim() : undefined;
210
+ const expectedText = input.expectedText === undefined
211
+ ? []
212
+ : typeof input.expectedText === "string"
213
+ ? [input.expectedText]
214
+ : Array.isArray(input.expectedText)
215
+ ? input.expectedText
216
+ : undefined;
217
+ if (!expectedText || expectedText.some((text) => typeof text !== "string" || text.trim().length === 0)) {
218
+ return { error: "qa.expectedText must be a non-empty string or array of non-empty strings when provided." };
219
+ }
220
+ const expectedSelector = input.expectedSelector;
221
+ if (expectedSelector !== undefined && (typeof expectedSelector !== "string" || expectedSelector.trim().length === 0)) {
222
+ return { error: "qa.expectedSelector must be a non-empty string when provided." };
223
+ }
224
+ const screenshotPath = input.screenshotPath;
225
+ if (screenshotPath !== undefined && (typeof screenshotPath !== "string" || screenshotPath.trim().length === 0)) {
226
+ return { error: "qa.screenshotPath must be a non-empty string when provided." };
227
+ }
228
+ for (const field of ["checkConsole", "checkErrors", "checkNetwork"] as const) {
229
+ if (input[field] !== undefined && typeof input[field] !== "boolean") {
230
+ return { error: `qa.${field} must be a boolean when provided.` };
231
+ }
232
+ }
233
+ const rawLoadState = input.loadState;
234
+ if (rawLoadState !== undefined && (typeof rawLoadState !== "string" || !AGENT_BROWSER_QA_LOAD_STATES.includes(rawLoadState as AgentBrowserQaLoadState))) {
235
+ return { error: `qa.loadState must be one of: ${AGENT_BROWSER_QA_LOAD_STATES.join(", ")}.` };
236
+ }
237
+ const checkConsole = input.checkConsole !== false;
238
+ const checkErrors = input.checkErrors !== false;
239
+ const checkNetwork = input.checkNetwork !== false;
240
+ const loadState = (rawLoadState as AgentBrowserQaLoadState | undefined) ?? "domcontentloaded";
241
+ const steps: CompiledAgentBrowserJobStep[] = [];
242
+ if (checkNetwork) steps.push({ action: "wait", args: ["network", "requests", "--clear"] });
243
+ if (checkConsole) steps.push({ action: "wait", args: ["console", "--clear"] });
244
+ if (checkErrors) steps.push({ action: "wait", args: ["errors", "--clear"] });
245
+ if (!attached && normalizedUrl) steps.push({ action: "open", args: ["open", normalizedUrl] });
246
+ steps.push({ action: "wait", args: ["wait", "--load", loadState] });
247
+ for (const text of expectedText) {
248
+ steps.push({ action: "assertText", args: ["wait", "--text", text] });
249
+ }
250
+ if (typeof expectedSelector === "string") {
251
+ steps.push({ action: "wait", args: ["wait", expectedSelector] });
252
+ }
253
+ if (checkNetwork) steps.push({ action: "wait", args: ["network", "requests"] });
254
+ if (checkConsole) steps.push({ action: "wait", args: ["console"] });
255
+ if (checkErrors) steps.push({ action: "wait", args: ["errors"] });
256
+ if (typeof screenshotPath === "string") steps.push({ action: "screenshot", args: ["screenshot", screenshotPath] });
257
+ return {
258
+ compiled: {
259
+ args: ["batch"],
260
+ checks: { attached, checkConsole, checkErrors, checkNetwork, expectedSelector, expectedText, loadState, screenshotPath, url: normalizedUrl },
261
+ stdin: JSON.stringify(steps.map((step) => step.args)),
262
+ steps,
263
+ },
264
+ };
265
+ }