@anweat/dsh-browser 0.1.15 → 0.2.0-rc.1
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.
- package/README.md +272 -75
- package/assets/skills/dsh-browser/SKILL.md +120 -0
- package/assets/skills/dsh-browser/references/develop.md +48 -0
- package/assets/skills/dsh-browser/references/observe.md +91 -0
- package/assets/skills/dsh-browser/references/opencli.md +25 -0
- package/assets/skills/dsh-browser/references/recipes.md +149 -0
- package/assets/skills/dsh-browser/references/userscripts.md +44 -0
- package/cordis.patch.yml +4 -0
- package/lib/actions/act.d.ts +3 -0
- package/lib/actions/act.js +147 -0
- package/lib/actions/act.js.map +1 -0
- package/lib/actions/automation.d.ts +3 -0
- package/lib/actions/automation.js +356 -0
- package/lib/actions/automation.js.map +1 -0
- package/lib/actions/crawl.d.ts +3 -0
- package/lib/actions/crawl.js +28 -0
- package/lib/actions/crawl.js.map +1 -0
- package/lib/actions/errors.d.ts +53 -0
- package/lib/actions/errors.js +178 -0
- package/lib/actions/errors.js.map +1 -0
- package/lib/actions/index-view.d.ts +42 -0
- package/lib/actions/index-view.js +268 -0
- package/lib/actions/index-view.js.map +1 -0
- package/lib/actions/inspect.d.ts +3 -0
- package/lib/actions/inspect.js +35 -0
- package/lib/actions/inspect.js.map +1 -0
- package/lib/actions/observe.d.ts +3 -0
- package/lib/actions/observe.js +121 -0
- package/lib/actions/observe.js.map +1 -0
- package/lib/actions/opencli.d.ts +3 -0
- package/lib/actions/opencli.js +51 -0
- package/lib/actions/opencli.js.map +1 -0
- package/lib/actions/registry.d.ts +35 -0
- package/lib/actions/registry.js +89 -0
- package/lib/actions/registry.js.map +1 -0
- package/lib/actions/run.d.ts +34 -0
- package/lib/actions/run.js +226 -0
- package/lib/actions/run.js.map +1 -0
- package/lib/actions/runtime.d.ts +3 -0
- package/lib/actions/runtime.js +33 -0
- package/lib/actions/runtime.js.map +1 -0
- package/lib/actions/schema.d.ts +31 -0
- package/lib/actions/schema.js +309 -0
- package/lib/actions/schema.js.map +1 -0
- package/lib/actions/script.d.ts +3 -0
- package/lib/actions/script.js +97 -0
- package/lib/actions/script.js.map +1 -0
- package/lib/actions/shared.d.ts +31 -0
- package/lib/actions/shared.js +45 -0
- package/lib/actions/shared.js.map +1 -0
- package/lib/actions/surface.d.ts +22 -0
- package/lib/actions/surface.js +18 -0
- package/lib/actions/surface.js.map +1 -0
- package/lib/actions/target.d.ts +3 -0
- package/lib/actions/target.js +66 -0
- package/lib/actions/target.js.map +1 -0
- package/lib/actions/types.d.ts +181 -0
- package/lib/actions/types.js +48 -0
- package/lib/actions/types.js.map +1 -0
- package/lib/activation-rules.d.ts +29 -0
- package/lib/activation-rules.js +33 -0
- package/lib/activation-rules.js.map +1 -0
- package/lib/approval-policy.d.ts +40 -2
- package/lib/approval-policy.js +184 -97
- package/lib/approval-policy.js.map +1 -1
- package/lib/automation-assets-rpc.d.ts +8 -2
- package/lib/automation-assets-rpc.js +67 -6
- package/lib/automation-assets-rpc.js.map +1 -1
- package/lib/automation-assets.d.ts +152 -4
- package/lib/automation-assets.js +255 -15
- package/lib/automation-assets.js.map +1 -1
- package/lib/automation-convert.d.ts +23 -0
- package/lib/automation-convert.js +110 -0
- package/lib/automation-convert.js.map +1 -0
- package/lib/automation-development.d.ts +26 -0
- package/lib/automation-development.js +47 -0
- package/lib/automation-development.js.map +1 -1
- package/lib/automation-execution.d.ts +57 -1
- package/lib/automation-execution.js +167 -11
- package/lib/automation-execution.js.map +1 -1
- package/lib/automation-journal.d.ts +92 -0
- package/lib/automation-journal.js +449 -0
- package/lib/automation-journal.js.map +1 -0
- package/lib/automation-v2.d.ts +138 -0
- package/lib/automation-v2.js +631 -0
- package/lib/automation-v2.js.map +1 -0
- package/lib/automation.d.ts +82 -5
- package/lib/automation.js +239 -82
- package/lib/automation.js.map +1 -1
- package/lib/browser-service.d.ts +304 -32
- package/lib/browser-service.js +631 -182
- package/lib/browser-service.js.map +1 -1
- package/lib/client/SettingsCard.js +95 -57
- package/lib/client/SettingsCard.js.map +1 -1
- package/lib/client/asset-editor.d.ts +55 -0
- package/lib/client/asset-editor.js +101 -0
- package/lib/client/asset-editor.js.map +1 -0
- package/lib/client/automation-assets-client.d.ts +99 -2
- package/lib/client/automation-assets-client.js +243 -10
- package/lib/client/automation-assets-client.js.map +1 -1
- package/lib/client/form.d.ts +48 -4
- package/lib/client/form.js +142 -29
- package/lib/client/form.js.map +1 -1
- package/lib/client/index.d.ts +25 -2
- package/lib/client/index.js +5 -1
- package/lib/client/index.js.map +1 -1
- package/lib/client/locales.d.ts +85 -6
- package/lib/client/locales.js +64 -12
- package/lib/client/locales.js.map +1 -1
- package/lib/client/prompts-form.d.ts +67 -0
- package/lib/client/prompts-form.js +140 -0
- package/lib/client/prompts-form.js.map +1 -0
- package/lib/client/prompts-status-client.d.ts +34 -0
- package/lib/client/prompts-status-client.js +53 -0
- package/lib/client/prompts-status-client.js.map +1 -0
- package/lib/client.js +1914 -329
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +50 -1
- package/lib/config.js +31 -1
- package/lib/config.js.map +1 -1
- package/lib/dialogs.d.ts +37 -0
- package/lib/dialogs.js +73 -0
- package/lib/dialogs.js.map +1 -0
- package/lib/freedom.d.ts +23 -7
- package/lib/freedom.js +48 -59
- package/lib/freedom.js.map +1 -1
- package/lib/index.d.ts +4 -4
- package/lib/index.js +36 -10
- package/lib/index.js.map +1 -1
- package/lib/journal.d.ts +109 -0
- package/lib/journal.js +238 -0
- package/lib/journal.js.map +1 -0
- package/lib/locator.d.ts +82 -0
- package/lib/locator.js +190 -0
- package/lib/locator.js.map +1 -0
- package/lib/observe-script.d.ts +27 -0
- package/lib/observe-script.js +565 -0
- package/lib/observe-script.js.map +1 -0
- package/lib/observe.d.ts +179 -0
- package/lib/observe.js +368 -0
- package/lib/observe.js.map +1 -0
- package/lib/policy-keys.d.ts +30 -0
- package/lib/policy-keys.js +34 -0
- package/lib/policy-keys.js.map +1 -0
- package/lib/prompt-limits.d.ts +30 -0
- package/lib/prompt-limits.js +31 -0
- package/lib/prompt-limits.js.map +1 -0
- package/lib/prompts.d.ts +131 -0
- package/lib/prompts.js +295 -0
- package/lib/prompts.js.map +1 -0
- package/lib/skill.d.ts +100 -0
- package/lib/skill.js +181 -0
- package/lib/skill.js.map +1 -0
- package/lib/tool-defs.d.ts +72 -0
- package/lib/tool-defs.js +67 -0
- package/lib/tool-defs.js.map +1 -0
- package/lib/tools.d.ts +19 -4
- package/lib/tools.js +124 -922
- package/lib/tools.js.map +1 -1
- package/package.json +35 -30
- package/scripts/dump-prompts.mjs +29 -0
- package/scripts/measure-tool-surface.mjs +190 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe schema v2: strict locators, goto/clear steps, typed inputs and outputs,
|
|
3
|
+
* and asset-level postconditions.
|
|
4
|
+
*
|
|
5
|
+
* v1 recipes (`schemaVersion` absent or 1) never pass through this module: they
|
|
6
|
+
* keep `.first()` and the old fill rule. A v2 step locates with a LocatorSpec,
|
|
7
|
+
* which must match exactly one element unless it carries `index` + `indexReason`
|
|
8
|
+
* (or the converter's `explicitFirst` marker).
|
|
9
|
+
* @module dsh-browser/automation-v2
|
|
10
|
+
*/
|
|
11
|
+
import { RecipeAssertionError, RecipeValidationError, isTimeoutError } from "./actions/errors.js";
|
|
12
|
+
import { resolveLocator, validateLocatorSpec, withStrictLocator } from "./locator.js";
|
|
13
|
+
export const WAIT_CONDITIONS_V2 = ['locator', 'text', 'url', 'load', 'time'];
|
|
14
|
+
export const STEP_TYPES_V2 = ['wait', 'click', 'fill', 'clear', 'type', 'press', 'select', 'check', 'hover', 'scroll', 'goto', 'extract', 'assert', 'screenshot'];
|
|
15
|
+
export const EXTRACT_MODES_V2 = ['text', 'html', 'links', 'attribute'];
|
|
16
|
+
export const INPUT_TYPES = ['string', 'number', 'enum'];
|
|
17
|
+
export const OUTPUT_TYPES = ['string', 'number', 'json'];
|
|
18
|
+
const NAME = /^[a-zA-Z][\w-]{0,39}$/;
|
|
19
|
+
const MAX_STEPS = 25;
|
|
20
|
+
const ALLOWED_FIELDS = {
|
|
21
|
+
wait: ['condition', 'locator', 'value', 'waitMs', 'timeoutMs'],
|
|
22
|
+
click: ['locator', 'timeoutMs'],
|
|
23
|
+
hover: ['locator', 'timeoutMs'],
|
|
24
|
+
fill: ['locator', 'value', 'allowEmpty', 'timeoutMs'],
|
|
25
|
+
clear: ['locator', 'timeoutMs'],
|
|
26
|
+
type: ['locator', 'value', 'timeoutMs'],
|
|
27
|
+
press: ['key', 'locator', 'timeoutMs'],
|
|
28
|
+
select: ['locator', 'value', 'timeoutMs'],
|
|
29
|
+
check: ['locator', 'checked', 'timeoutMs'],
|
|
30
|
+
scroll: ['deltaY', 'waitMs'],
|
|
31
|
+
goto: ['url', 'timeoutMs'],
|
|
32
|
+
extract: ['locator', 'mode', 'attribute', 'limit', 'as', 'timeoutMs'],
|
|
33
|
+
assert: ['locator', 'text', 'urlIncludes', 'timeoutMs'],
|
|
34
|
+
screenshot: [],
|
|
35
|
+
};
|
|
36
|
+
function finite(value, fallback, min, max, label) {
|
|
37
|
+
const resolved = value ?? fallback;
|
|
38
|
+
if (typeof resolved !== 'number' || !Number.isFinite(resolved) || resolved < min || resolved > max)
|
|
39
|
+
throw new Error(label + ' must be between ' + min + ' and ' + max);
|
|
40
|
+
return resolved;
|
|
41
|
+
}
|
|
42
|
+
function text(value, label, max) {
|
|
43
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > max)
|
|
44
|
+
throw new Error(label + ' must contain 1 to ' + max + ' characters');
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function isRecord(value) {
|
|
48
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
/** True when `host` is one of `domains` or a subdomain of one. */
|
|
51
|
+
export function hostInDomains(host, domains) {
|
|
52
|
+
const lower = host.toLowerCase();
|
|
53
|
+
return domains.some(domain => lower === domain.toLowerCase() || lower.endsWith('.' + domain.toLowerCase()));
|
|
54
|
+
}
|
|
55
|
+
function parseHttpUrl(value, label) {
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = new URL(value);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new Error(`${label} must be an absolute http(s) URL`);
|
|
62
|
+
}
|
|
63
|
+
if (!['http:', 'https:'].includes(parsed.protocol))
|
|
64
|
+
throw new Error(`${label} must be an absolute http(s) URL`);
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
function gotoAllowed(url, options) {
|
|
68
|
+
return (options.sameOrigin !== undefined && url.origin === options.sameOrigin) || hostInDomains(url.hostname, options.allowedDomains ?? []);
|
|
69
|
+
}
|
|
70
|
+
function describeAllowed(options) {
|
|
71
|
+
const parts = [...options.allowedDomains ?? []];
|
|
72
|
+
if (options.sameOrigin)
|
|
73
|
+
parts.push('origin ' + options.sameOrigin);
|
|
74
|
+
return parts.join(', ') || 'none declared';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Refuse a recipe whose literal goto URLs leave the allowed domains, before any step runs.
|
|
78
|
+
* Throws a message with "is not allowed on", which the shared mapping reports as POLICY_DENIED.
|
|
79
|
+
*/
|
|
80
|
+
export function assertGotoAllowed(steps, options) {
|
|
81
|
+
steps.forEach((step, index) => {
|
|
82
|
+
if (step?.type !== 'goto' || typeof step.url !== 'string')
|
|
83
|
+
return;
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = new URL(step.url);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
} // malformed URLs are reported by validation
|
|
91
|
+
if (!['http:', 'https:'].includes(parsed.protocol))
|
|
92
|
+
return;
|
|
93
|
+
if (!gotoAllowed(parsed, options))
|
|
94
|
+
throw new Error(`recipe step ${index + 1}: goto ${parsed.hostname} is not allowed on this recipe (allowed: ${describeAllowed(options)}); nothing ran`);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function checkLocator(value, label) {
|
|
98
|
+
if (!isRecord(value))
|
|
99
|
+
throw new Error(`${label} locator must be an object such as {"role":"button","name":"Save"} or {"css":"#id"}`);
|
|
100
|
+
if ('selector' in value)
|
|
101
|
+
throw new Error(`${label} locator uses "css" for a CSS selector in v2, not "selector"`);
|
|
102
|
+
if ('frame' in value)
|
|
103
|
+
throw new Error(`${label} locator uses "framePath" (iframe selectors, outermost first) in v2, not "frame"`);
|
|
104
|
+
return validateLocatorSpec(value, `${label} locator`);
|
|
105
|
+
}
|
|
106
|
+
/** Structural validation of v2 steps. Throws a message the model can act on; nothing is run. */
|
|
107
|
+
export function validateRecipeV2(steps) {
|
|
108
|
+
try {
|
|
109
|
+
checkSteps(steps);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
throw error instanceof RecipeValidationError ? error : new RecipeValidationError(error instanceof Error ? error.message : String(error));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function checkSteps(steps) {
|
|
116
|
+
if (!Array.isArray(steps) || steps.length < 1 || steps.length > MAX_STEPS)
|
|
117
|
+
throw new Error(`recipe must contain between 1 and ${MAX_STEPS} steps`);
|
|
118
|
+
const names = new Set();
|
|
119
|
+
steps.forEach((raw, index) => {
|
|
120
|
+
const label = 'recipe step ' + (index + 1);
|
|
121
|
+
if (!isRecord(raw))
|
|
122
|
+
throw new Error(label + ' must be an object');
|
|
123
|
+
const step = raw;
|
|
124
|
+
if (!STEP_TYPES_V2.includes(step.type))
|
|
125
|
+
throw new Error(`${label}: unsupported step type ${JSON.stringify(step.type)}; v2 uses ${STEP_TYPES_V2.join(', ')}`);
|
|
126
|
+
const allowed = ALLOWED_FIELDS[step.type];
|
|
127
|
+
for (const key of Object.keys(step)) {
|
|
128
|
+
if (key === 'type' || allowed.includes(key))
|
|
129
|
+
continue;
|
|
130
|
+
throw new Error(key === 'selector'
|
|
131
|
+
? `${label} (${step.type}): v2 steps locate with "locator" (for example {"css":"#id"}), not "selector"`
|
|
132
|
+
: `${label} (${step.type}): unsupported field "${key}"; allowed: ${['type', ...allowed].join(', ')}`);
|
|
133
|
+
}
|
|
134
|
+
const where = `${label} (${step.type})`;
|
|
135
|
+
if (step.timeoutMs !== undefined)
|
|
136
|
+
finite(step.timeoutMs, 15_000, 1, 30_000, where + ' timeoutMs');
|
|
137
|
+
switch (step.type) {
|
|
138
|
+
case 'wait': {
|
|
139
|
+
if (!WAIT_CONDITIONS_V2.includes(step.condition))
|
|
140
|
+
throw new Error(`${where}: unsupported wait condition ${JSON.stringify(step.condition)}; use one of ${WAIT_CONDITIONS_V2.join(', ')}`);
|
|
141
|
+
if (step.condition === 'locator')
|
|
142
|
+
checkLocator(step.locator, where);
|
|
143
|
+
else if (step.locator !== undefined)
|
|
144
|
+
throw new Error(`${where}: locator is only valid with condition "locator"`);
|
|
145
|
+
if (step.condition === 'text')
|
|
146
|
+
text(step.value, where + ' value', 2_000);
|
|
147
|
+
if (step.condition === 'url')
|
|
148
|
+
text(step.value, where + ' value', 2_000);
|
|
149
|
+
if (step.condition === 'time')
|
|
150
|
+
finite(step.waitMs, 0, 0, 10_000, where + ' waitMs');
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case 'click':
|
|
154
|
+
case 'hover':
|
|
155
|
+
case 'clear':
|
|
156
|
+
checkLocator(step.locator, where);
|
|
157
|
+
break;
|
|
158
|
+
case 'fill': {
|
|
159
|
+
checkLocator(step.locator, where);
|
|
160
|
+
if (typeof step.value !== 'string')
|
|
161
|
+
throw new Error(`${where}: value must be a string`);
|
|
162
|
+
if (step.value.length > 20_000)
|
|
163
|
+
throw new Error(`${where}: value must contain at most 20000 characters`);
|
|
164
|
+
if (step.allowEmpty !== undefined && typeof step.allowEmpty !== 'boolean')
|
|
165
|
+
throw new Error(`${where}: allowEmpty must be a boolean`);
|
|
166
|
+
if (step.value === '' && step.allowEmpty !== true)
|
|
167
|
+
throw new Error(`${where}: value is empty. Write allowEmpty: true to set an empty string on purpose, or use a clear step`);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case 'type':
|
|
171
|
+
checkLocator(step.locator, where);
|
|
172
|
+
text(step.value, where + ' value', 20_000);
|
|
173
|
+
break;
|
|
174
|
+
case 'press':
|
|
175
|
+
text(step.key, where + ' key', 100);
|
|
176
|
+
if (step.locator !== undefined)
|
|
177
|
+
checkLocator(step.locator, where);
|
|
178
|
+
break;
|
|
179
|
+
case 'select':
|
|
180
|
+
checkLocator(step.locator, where);
|
|
181
|
+
text(step.value, where + ' value', 2_000);
|
|
182
|
+
break;
|
|
183
|
+
case 'check':
|
|
184
|
+
checkLocator(step.locator, where);
|
|
185
|
+
break;
|
|
186
|
+
case 'scroll':
|
|
187
|
+
finite(step.deltaY, 2_000, -20_000, 20_000, where + ' deltaY');
|
|
188
|
+
finite(step.waitMs, 400, 0, 5_000, where + ' waitMs');
|
|
189
|
+
break;
|
|
190
|
+
case 'goto':
|
|
191
|
+
text(step.url, where + ' url', 2_000);
|
|
192
|
+
if (!/\{\{/.test(step.url))
|
|
193
|
+
parseHttpUrl(step.url, where + ' url');
|
|
194
|
+
break;
|
|
195
|
+
case 'extract': {
|
|
196
|
+
if (step.locator !== undefined)
|
|
197
|
+
checkLocator(step.locator, where);
|
|
198
|
+
if (step.mode !== undefined && !EXTRACT_MODES_V2.includes(step.mode))
|
|
199
|
+
throw new Error(`${where}: unsupported extract mode ${JSON.stringify(step.mode)}; use one of ${EXTRACT_MODES_V2.join(', ')}`);
|
|
200
|
+
finite(step.limit, 100, 1, 500, where + ' limit');
|
|
201
|
+
if (step.mode === 'attribute')
|
|
202
|
+
text(step.attribute, where + ' attribute', 100);
|
|
203
|
+
else if (step.attribute !== undefined)
|
|
204
|
+
throw new Error(`${where}: attribute is only valid with mode "attribute"`);
|
|
205
|
+
if (step.as !== undefined) {
|
|
206
|
+
if (typeof step.as !== 'string' || !NAME.test(step.as))
|
|
207
|
+
throw new Error(`${where}: as must be a name of letters, digits, _ or - starting with a letter (max 40)`);
|
|
208
|
+
if (names.has(step.as))
|
|
209
|
+
throw new Error(`${where}: output name "${step.as}" is already used by an earlier extract`);
|
|
210
|
+
names.add(step.as);
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case 'assert': {
|
|
215
|
+
if (step.locator === undefined && step.text === undefined && step.urlIncludes === undefined)
|
|
216
|
+
throw new Error(`${where}: assert needs locator, text, or urlIncludes`);
|
|
217
|
+
if (step.locator !== undefined)
|
|
218
|
+
checkLocator(step.locator, where);
|
|
219
|
+
if (step.text !== undefined)
|
|
220
|
+
text(step.text, where + ' text', 2_000);
|
|
221
|
+
if (step.urlIncludes !== undefined)
|
|
222
|
+
text(step.urlIncludes, where + ' urlIncludes', 2_000);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case 'screenshot':
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// --- asset-level fields -------------------------------------------------------
|
|
231
|
+
function placeholders(value, into) {
|
|
232
|
+
if (typeof value === 'string')
|
|
233
|
+
for (const match of value.matchAll(/\{\{([a-zA-Z][\w-]*)\}\}/g))
|
|
234
|
+
into.add(match[1]);
|
|
235
|
+
else if (Array.isArray(value))
|
|
236
|
+
value.forEach(entry => placeholders(entry, into));
|
|
237
|
+
else if (isRecord(value))
|
|
238
|
+
Object.values(value).forEach(entry => placeholders(entry, into));
|
|
239
|
+
}
|
|
240
|
+
/** Every `{{name}}` used by the steps and postconditions. */
|
|
241
|
+
export function placeholderNames(...values) {
|
|
242
|
+
const names = new Set();
|
|
243
|
+
values.forEach(value => placeholders(value, names));
|
|
244
|
+
return [...names];
|
|
245
|
+
}
|
|
246
|
+
export function normalizeInputSchema(raw) {
|
|
247
|
+
if (!Array.isArray(raw) || raw.length > 20)
|
|
248
|
+
throw new Error('inputSchema must be an array of at most 20 inputs');
|
|
249
|
+
const seen = new Set();
|
|
250
|
+
return raw.map((entry, index) => {
|
|
251
|
+
const label = `inputSchema[${index}]`;
|
|
252
|
+
if (!isRecord(entry))
|
|
253
|
+
throw new Error(label + ' must be an object');
|
|
254
|
+
for (const key of Object.keys(entry))
|
|
255
|
+
if (!['name', 'type', 'required', 'example', 'enumValues', 'description'].includes(key))
|
|
256
|
+
throw new Error(`${label} has an unsupported field "${key}"`);
|
|
257
|
+
if (typeof entry.name !== 'string' || !NAME.test(entry.name))
|
|
258
|
+
throw new Error(`${label}.name must be a name of letters, digits, _ or - starting with a letter (max 40)`);
|
|
259
|
+
if (seen.has(entry.name))
|
|
260
|
+
throw new Error(`${label}.name "${entry.name}" is declared twice`);
|
|
261
|
+
seen.add(entry.name);
|
|
262
|
+
if (!INPUT_TYPES.includes(entry.type))
|
|
263
|
+
throw new Error(`${label}.type must be one of ${INPUT_TYPES.join(', ')}`);
|
|
264
|
+
if (entry.required !== undefined && typeof entry.required !== 'boolean')
|
|
265
|
+
throw new Error(`${label}.required must be a boolean`);
|
|
266
|
+
const spec = { name: entry.name, type: entry.type };
|
|
267
|
+
if (entry.required !== undefined)
|
|
268
|
+
spec.required = entry.required;
|
|
269
|
+
if (entry.description !== undefined)
|
|
270
|
+
spec.description = text(entry.description, label + '.description', 200);
|
|
271
|
+
if (entry.type === 'enum') {
|
|
272
|
+
if (!Array.isArray(entry.enumValues) || entry.enumValues.length < 1 || entry.enumValues.length > 50)
|
|
273
|
+
throw new Error(`${label}.enumValues must list 1 to 50 values for an enum input`);
|
|
274
|
+
const values = entry.enumValues.map((value, at) => text(value, `${label}.enumValues[${at}]`, 200));
|
|
275
|
+
if (new Set(values).size !== values.length)
|
|
276
|
+
throw new Error(`${label}.enumValues contains duplicates`);
|
|
277
|
+
spec.enumValues = values;
|
|
278
|
+
}
|
|
279
|
+
else if (entry.enumValues !== undefined)
|
|
280
|
+
throw new Error(`${label}.enumValues is only valid for type enum`);
|
|
281
|
+
if (entry.example !== undefined) {
|
|
282
|
+
const example = entry.example;
|
|
283
|
+
if (typeof example !== 'string' && typeof example !== 'number')
|
|
284
|
+
throw new Error(`${label}.example must be a string or a number`);
|
|
285
|
+
if (entry.type === 'number' && !Number.isFinite(Number(example)))
|
|
286
|
+
throw new Error(`${label}.example must be numeric for a number input`);
|
|
287
|
+
if (entry.type === 'enum' && !spec.enumValues.includes(String(example)))
|
|
288
|
+
throw new Error(`${label}.example must be one of enumValues`);
|
|
289
|
+
spec.example = example;
|
|
290
|
+
}
|
|
291
|
+
return spec;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
export function normalizeOutputSchema(raw, steps) {
|
|
295
|
+
if (!Array.isArray(raw) || raw.length > 20)
|
|
296
|
+
throw new Error('outputSchema must be an array of at most 20 outputs');
|
|
297
|
+
const produced = new Set(steps.filter(step => step?.type === 'extract' && step.as).map(step => step.as));
|
|
298
|
+
const seen = new Set();
|
|
299
|
+
return raw.map((entry, index) => {
|
|
300
|
+
const label = `outputSchema[${index}]`;
|
|
301
|
+
if (!isRecord(entry))
|
|
302
|
+
throw new Error(label + ' must be an object');
|
|
303
|
+
for (const key of Object.keys(entry))
|
|
304
|
+
if (!['name', 'type', 'description', 'dedupe'].includes(key))
|
|
305
|
+
throw new Error(`${label} has an unsupported field "${key}"`);
|
|
306
|
+
if (typeof entry.name !== 'string' || !NAME.test(entry.name))
|
|
307
|
+
throw new Error(`${label}.name must be a name of letters, digits, _ or - starting with a letter (max 40)`);
|
|
308
|
+
if (seen.has(entry.name))
|
|
309
|
+
throw new Error(`${label}.name "${entry.name}" is declared twice`);
|
|
310
|
+
seen.add(entry.name);
|
|
311
|
+
if (!OUTPUT_TYPES.includes(entry.type))
|
|
312
|
+
throw new Error(`${label}.type must be one of ${OUTPUT_TYPES.join(', ')}`);
|
|
313
|
+
if (!produced.has(entry.name))
|
|
314
|
+
throw new Error(`${label}.name "${entry.name}" is not produced: add an extract step with as: "${entry.name}"`);
|
|
315
|
+
if (entry.dedupe !== undefined && entry.dedupe !== true)
|
|
316
|
+
throw new Error(`${label}.dedupe must be true when present`);
|
|
317
|
+
if (entry.dedupe === true && entry.type === 'number')
|
|
318
|
+
throw new Error(`${label}.dedupe applies to string and json outputs, not number`);
|
|
319
|
+
return { name: entry.name, type: entry.type, ...entry.description !== undefined ? { description: text(entry.description, label + '.description', 200) } : {}, ...entry.dedupe === true ? { dedupe: true } : {} };
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
export function normalizePostconditions(raw, steps) {
|
|
323
|
+
if (!Array.isArray(raw) || raw.length > 10)
|
|
324
|
+
throw new Error('postconditions must be an array of at most 10 conditions');
|
|
325
|
+
const produced = new Set(steps.filter(step => step?.type === 'extract' && step.as).map(step => step.as));
|
|
326
|
+
return raw.map((entry, index) => {
|
|
327
|
+
const label = `postconditions[${index}]`;
|
|
328
|
+
if (!isRecord(entry))
|
|
329
|
+
throw new Error(label + ' must be an object');
|
|
330
|
+
const kinds = ['selector', 'text', 'urlIncludes', 'output'].filter(kind => entry[kind] !== undefined);
|
|
331
|
+
if (kinds.length !== 1)
|
|
332
|
+
throw new Error(`${label} needs exactly one of selector, text, urlIncludes, or output`);
|
|
333
|
+
const kind = kinds[0];
|
|
334
|
+
const allowed = kind === 'output' ? ['output', 'nonEmpty', 'allowEmpty'] : [kind, 'timeoutMs'];
|
|
335
|
+
for (const key of Object.keys(entry))
|
|
336
|
+
if (!allowed.includes(key))
|
|
337
|
+
throw new Error(`${label} has an unsupported field "${key}" for ${kind}`);
|
|
338
|
+
if (kind === 'output') {
|
|
339
|
+
const name = text(entry.output, label + '.output', 40);
|
|
340
|
+
if (!produced.has(name))
|
|
341
|
+
throw new Error(`${label}.output "${name}" is not produced by any extract step with as: "${name}"`);
|
|
342
|
+
const flags = [entry.nonEmpty === true, entry.allowEmpty === true].filter(Boolean).length;
|
|
343
|
+
if (flags !== 1 || (entry.nonEmpty !== undefined && entry.nonEmpty !== true) || (entry.allowEmpty !== undefined && entry.allowEmpty !== true))
|
|
344
|
+
throw new Error(`${label} for an output needs exactly one of nonEmpty: true or allowEmpty: true`);
|
|
345
|
+
return entry.nonEmpty === true ? { output: name, nonEmpty: true } : { output: name, allowEmpty: true };
|
|
346
|
+
}
|
|
347
|
+
if (entry.timeoutMs !== undefined)
|
|
348
|
+
finite(entry.timeoutMs, 5_000, 1, 30_000, label + '.timeoutMs');
|
|
349
|
+
const value = text(entry[kind], `${label}.${kind}`, kind === 'selector' ? 500 : 2_000);
|
|
350
|
+
return { [kind]: value, ...entry.timeoutMs !== undefined ? { timeoutMs: entry.timeoutMs } : {} };
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
export function normalizeRequiredCapabilities(raw) {
|
|
354
|
+
if (!Array.isArray(raw) || raw.length > 20)
|
|
355
|
+
throw new Error('requiredCapabilities must be an array of at most 20 names');
|
|
356
|
+
return [...new Set(raw.map((entry, index) => {
|
|
357
|
+
const value = text(entry, `requiredCapabilities[${index}]`, 60);
|
|
358
|
+
if (!/^[a-zA-Z0-9][\w.:-]*$/.test(value))
|
|
359
|
+
throw new Error(`requiredCapabilities[${index}] must be a capability name such as act.fill or auth:profile`);
|
|
360
|
+
return value;
|
|
361
|
+
}))];
|
|
362
|
+
}
|
|
363
|
+
/** Steps whose locator still takes the first match without a reason: left by the v1 converter. */
|
|
364
|
+
export function pendingDisambiguation(steps) {
|
|
365
|
+
const pending = [];
|
|
366
|
+
steps.forEach((step, index) => {
|
|
367
|
+
if (step?.locator?.explicitFirst)
|
|
368
|
+
pending.push({ step: index + 1, action: step.type, kind: 'explicit-first', locator: structuredClone(step.locator) });
|
|
369
|
+
});
|
|
370
|
+
return pending;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Validate caller inputs against the declared schema and convert them to the
|
|
374
|
+
* strings placeholders are replaced with: numbers are parsed and canonicalized,
|
|
375
|
+
* enums must be a listed value, an optional input left out becomes "".
|
|
376
|
+
*/
|
|
377
|
+
export function coerceInputs(schema, raw) {
|
|
378
|
+
const given = isRecord(raw) ? raw : {};
|
|
379
|
+
if (raw !== undefined && raw !== null && !isRecord(raw))
|
|
380
|
+
throw new Error('automation inputs must be an object');
|
|
381
|
+
if (Object.keys(given).length > 20 || Object.keys(given).some(key => !NAME.test(key)))
|
|
382
|
+
throw new Error('automation inputs exceed key, count, or value limits');
|
|
383
|
+
const declared = new Set(schema.map(spec => spec.name));
|
|
384
|
+
const extra = Object.keys(given).filter(name => !declared.has(name));
|
|
385
|
+
if (extra.length)
|
|
386
|
+
throw new Error('undeclared automation inputs: ' + extra.join(', '));
|
|
387
|
+
const out = {};
|
|
388
|
+
const missing = [];
|
|
389
|
+
for (const spec of schema) {
|
|
390
|
+
const value = given[spec.name];
|
|
391
|
+
if (value === undefined || value === null) {
|
|
392
|
+
if (spec.required === false)
|
|
393
|
+
out[spec.name] = '';
|
|
394
|
+
else
|
|
395
|
+
missing.push(spec.name);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (spec.type === 'number') {
|
|
399
|
+
const parsed = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() !== '' ? Number(value.trim()) : NaN);
|
|
400
|
+
if (!Number.isFinite(parsed))
|
|
401
|
+
throw new Error(`automation input "${spec.name}" must be a number (got ${JSON.stringify(value)})`);
|
|
402
|
+
out[spec.name] = String(parsed);
|
|
403
|
+
}
|
|
404
|
+
else if (spec.type === 'enum') {
|
|
405
|
+
const chosen = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? String(value) : '';
|
|
406
|
+
if (!spec.enumValues.includes(chosen))
|
|
407
|
+
throw new Error(`automation input "${spec.name}" must be one of ${spec.enumValues.join(' | ')} (got ${JSON.stringify(value)})`);
|
|
408
|
+
out[spec.name] = chosen;
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
|
|
412
|
+
throw new Error(`automation input "${spec.name}" must be a string`);
|
|
413
|
+
out[spec.name] = String(value);
|
|
414
|
+
}
|
|
415
|
+
if (out[spec.name].length > 10_000)
|
|
416
|
+
throw new Error('automation inputs exceed key, count, or value limits');
|
|
417
|
+
}
|
|
418
|
+
if (missing.length)
|
|
419
|
+
throw new Error('missing declared automation inputs: ' + missing.join(', '));
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
/** Replace `{{name}}` in every string of a step or postcondition. `type` and `indexReason` are never touched. */
|
|
423
|
+
export function materializeDeep(value, inputs) {
|
|
424
|
+
const walk = (node, key) => {
|
|
425
|
+
if (typeof node === 'string') {
|
|
426
|
+
if (key === 'type' || key === 'indexReason')
|
|
427
|
+
return node;
|
|
428
|
+
return node.replace(/\{\{([a-zA-Z][\w-]*)\}\}/g, (_match, name) => {
|
|
429
|
+
if (!(name in inputs))
|
|
430
|
+
throw new Error(`missing automation input: ${name}`);
|
|
431
|
+
return inputs[name];
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (Array.isArray(node))
|
|
435
|
+
return node.map(entry => walk(entry));
|
|
436
|
+
if (isRecord(node))
|
|
437
|
+
return Object.fromEntries(Object.entries(node).map(([entryKey, entry]) => [entryKey, walk(entry, entryKey)]));
|
|
438
|
+
return node;
|
|
439
|
+
};
|
|
440
|
+
return walk(value);
|
|
441
|
+
}
|
|
442
|
+
// --- running ------------------------------------------------------------------
|
|
443
|
+
function cap(value, max = 50_000) {
|
|
444
|
+
return value.length <= max ? value : value.slice(0, max) + '\n…(truncated)';
|
|
445
|
+
}
|
|
446
|
+
/** Run one v2 step. Returns the value an extract or screenshot step produced. */
|
|
447
|
+
export async function runStepV2(page, step, ctx) {
|
|
448
|
+
const act = (run) => {
|
|
449
|
+
const timeout = step.timeoutMs ?? 15_000;
|
|
450
|
+
return withStrictLocator(resolveLocator(page, step.locator), locator => run(locator, timeout));
|
|
451
|
+
};
|
|
452
|
+
switch (step.type) {
|
|
453
|
+
case 'goto': {
|
|
454
|
+
const target = parseHttpUrl(step.url, 'goto url');
|
|
455
|
+
if (!gotoAllowed(target, ctx))
|
|
456
|
+
throw new Error(`goto ${target.hostname} is not allowed on this recipe (allowed: ${describeAllowed(ctx)})`);
|
|
457
|
+
if (!ctx.goto)
|
|
458
|
+
throw new Error('goto is not available in this runner');
|
|
459
|
+
await ctx.goto(target.href);
|
|
460
|
+
// A redirect can leave the allowed domains after the request was made; the page must not be used from there.
|
|
461
|
+
let landed;
|
|
462
|
+
try {
|
|
463
|
+
landed = new URL(page.url());
|
|
464
|
+
}
|
|
465
|
+
catch { /* about:blank and the like */ }
|
|
466
|
+
if (landed && ['http:', 'https:'].includes(landed.protocol) && !gotoAllowed(landed, ctx))
|
|
467
|
+
throw new Error(`goto landed on ${landed.hostname}, which is not allowed on this recipe (allowed: ${describeAllowed(ctx)})`);
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
case 'wait': {
|
|
471
|
+
const timeout = step.timeoutMs ?? 15_000;
|
|
472
|
+
if (step.condition === 'locator')
|
|
473
|
+
await withStrictLocator(resolveLocator(page, step.locator), locator => locator.waitFor({ state: 'visible', timeout }));
|
|
474
|
+
else if (step.condition === 'text')
|
|
475
|
+
await page.getByText(step.value, { exact: false }).first().waitFor({ state: 'visible', timeout });
|
|
476
|
+
else if (step.condition === 'url')
|
|
477
|
+
await page.waitForURL((url) => url.href.includes(step.value), { timeout });
|
|
478
|
+
else if (step.condition === 'load')
|
|
479
|
+
await page.waitForLoadState('networkidle', { timeout });
|
|
480
|
+
else
|
|
481
|
+
await page.waitForTimeout(step.waitMs ?? 0);
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
case 'click':
|
|
485
|
+
await act((locator, timeout) => locator.click({ timeout }));
|
|
486
|
+
return undefined;
|
|
487
|
+
case 'hover':
|
|
488
|
+
await act((locator, timeout) => locator.hover({ timeout }));
|
|
489
|
+
return undefined;
|
|
490
|
+
case 'fill':
|
|
491
|
+
await act((locator, timeout) => locator.fill(step.value, { timeout }));
|
|
492
|
+
return undefined;
|
|
493
|
+
case 'clear':
|
|
494
|
+
await act((locator, timeout) => locator.clear({ timeout }));
|
|
495
|
+
return undefined;
|
|
496
|
+
case 'type':
|
|
497
|
+
await act((locator, timeout) => locator.pressSequentially(step.value, { timeout }));
|
|
498
|
+
return undefined;
|
|
499
|
+
case 'press':
|
|
500
|
+
if (step.locator)
|
|
501
|
+
await act((locator, timeout) => locator.press(step.key, { timeout }));
|
|
502
|
+
else
|
|
503
|
+
await page.keyboard.press(step.key);
|
|
504
|
+
return undefined;
|
|
505
|
+
case 'select':
|
|
506
|
+
await act((locator, timeout) => locator.selectOption(step.value, { timeout }));
|
|
507
|
+
return undefined;
|
|
508
|
+
case 'check':
|
|
509
|
+
await act((locator, timeout) => step.checked === false ? locator.uncheck({ timeout }) : locator.check({ timeout }));
|
|
510
|
+
return undefined;
|
|
511
|
+
case 'scroll':
|
|
512
|
+
await page.mouse.wheel(0, step.deltaY ?? 2_000);
|
|
513
|
+
await page.waitForTimeout(step.waitMs ?? 400);
|
|
514
|
+
return undefined;
|
|
515
|
+
case 'extract': {
|
|
516
|
+
// Default 5 s instead of the 30 s page default: a selector that is not there fails fast (LOCATOR_NOT_FOUND).
|
|
517
|
+
const timeout = step.timeoutMs ?? 5_000;
|
|
518
|
+
const mode = step.mode ?? 'text';
|
|
519
|
+
const run = async (target) => {
|
|
520
|
+
if (mode === 'text')
|
|
521
|
+
return cap(await target.innerText({ timeout }));
|
|
522
|
+
if (mode === 'html')
|
|
523
|
+
return cap(await target.innerHTML({ timeout }));
|
|
524
|
+
if (mode === 'attribute')
|
|
525
|
+
return String(await target.getAttribute(step.attribute, { timeout }) ?? '');
|
|
526
|
+
// links: the container must exist (evaluateAll alone would turn a missing container into an empty list).
|
|
527
|
+
await target.waitFor({ state: 'attached', timeout });
|
|
528
|
+
const rows = await target.locator('a[href]').evaluateAll(new Function('anchors', 'limit', 'return anchors.slice(0, limit).map(function (anchor) { return { text: String(anchor.textContent || "").trim(), url: String(anchor.href || "") } })'), step.limit ?? 100);
|
|
529
|
+
return cap(JSON.stringify(rows));
|
|
530
|
+
};
|
|
531
|
+
if (step.locator)
|
|
532
|
+
return withStrictLocator(resolveLocator(page, step.locator), run);
|
|
533
|
+
return run(page.locator('body'));
|
|
534
|
+
}
|
|
535
|
+
case 'assert': {
|
|
536
|
+
const timeout = step.timeoutMs ?? 5_000;
|
|
537
|
+
try {
|
|
538
|
+
if (step.locator)
|
|
539
|
+
await withStrictLocator(resolveLocator(page, step.locator), locator => locator.waitFor({ state: 'visible', timeout }));
|
|
540
|
+
if (step.text)
|
|
541
|
+
await page.getByText(step.text, { exact: false }).first().waitFor({ state: 'visible', timeout });
|
|
542
|
+
if (step.urlIncludes)
|
|
543
|
+
await page.waitForURL((url) => url.href.includes(step.urlIncludes), { timeout });
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
// Only "never became true" is a failed assertion; ambiguity, a closed page or a bad locator keep their own cause.
|
|
547
|
+
if (!isTimeoutError(error))
|
|
548
|
+
throw error;
|
|
549
|
+
const expected = [step.locator ? 'locator ' + JSON.stringify(step.locator) : '', step.text ? 'text ' + JSON.stringify(step.text) : '', step.urlIncludes ? 'url containing ' + JSON.stringify(step.urlIncludes) : ''].filter(Boolean).join(' and ');
|
|
550
|
+
throw new RecipeAssertionError('assert failed: ' + expected + ' did not become true within ' + timeout + 'ms. ' + (error instanceof Error ? error.message : String(error)));
|
|
551
|
+
}
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
case 'screenshot':
|
|
555
|
+
return ctx.captureScreenshot();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/** Turn an extracted string into the declared output type. Returns the typed value, or a problem description. */
|
|
559
|
+
export function coerceOutput(spec, value) {
|
|
560
|
+
if (spec.type === 'string')
|
|
561
|
+
return typeof value === 'string' ? { ok: true, value } : { ok: false, problem: `output "${spec.name}" should be a string` };
|
|
562
|
+
const raw = typeof value === 'string' ? value : JSON.stringify(value);
|
|
563
|
+
if (spec.type === 'number') {
|
|
564
|
+
const parsed = Number(raw.trim().replace(/,/g, ''));
|
|
565
|
+
return raw.trim() !== '' && Number.isFinite(parsed) ? { ok: true, value: parsed } : { ok: false, problem: `output "${spec.name}" is not a number: ${JSON.stringify(raw.slice(0, 80))}` };
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
return { ok: false, problem: `output "${spec.name}" is not valid JSON: ${JSON.stringify(raw.slice(0, 80))}` };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/** Drop repeats, keeping the first of each in order: array elements by value, text by line. Other values are returned unchanged. */
|
|
575
|
+
export function dedupeValue(value) {
|
|
576
|
+
if (Array.isArray(value)) {
|
|
577
|
+
const seen = new Set();
|
|
578
|
+
return value.filter(entry => { const key = JSON.stringify(entry); if (seen.has(key))
|
|
579
|
+
return false; seen.add(key); return true; });
|
|
580
|
+
}
|
|
581
|
+
if (typeof value === 'string') {
|
|
582
|
+
const seen = new Set();
|
|
583
|
+
return value.split('\n').map(line => line.trim()).filter(line => { if (!line || seen.has(line))
|
|
584
|
+
return false; seen.add(line); return true; }).join('\n');
|
|
585
|
+
}
|
|
586
|
+
return value;
|
|
587
|
+
}
|
|
588
|
+
function isEmptyValue(value) {
|
|
589
|
+
if (value === null || value === undefined)
|
|
590
|
+
return true;
|
|
591
|
+
if (typeof value === 'string')
|
|
592
|
+
return value.trim().length === 0;
|
|
593
|
+
if (Array.isArray(value))
|
|
594
|
+
return value.length === 0;
|
|
595
|
+
if (isRecord(value))
|
|
596
|
+
return Object.keys(value).length === 0;
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
/** Check every postcondition; a condition that does not hold is described, never thrown. */
|
|
600
|
+
export async function evaluatePostconditions(page, conditions, outputs, signal) {
|
|
601
|
+
const failures = [];
|
|
602
|
+
for (const [index, condition] of conditions.entries()) {
|
|
603
|
+
if (signal?.aborted)
|
|
604
|
+
break;
|
|
605
|
+
const label = `postcondition ${index + 1} ${JSON.stringify(condition)}`;
|
|
606
|
+
if ('output' in condition) {
|
|
607
|
+
const produced = outputs.find(output => output.name === condition.output);
|
|
608
|
+
if (!produced)
|
|
609
|
+
failures.push(`${label}: output "${condition.output}" was not produced`);
|
|
610
|
+
else if (condition.nonEmpty && isEmptyValue(produced.value))
|
|
611
|
+
failures.push(`${label}: output "${condition.output}" is empty`);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const timeout = condition.timeoutMs ?? 5_000;
|
|
615
|
+
try {
|
|
616
|
+
if ('selector' in condition)
|
|
617
|
+
await page.locator(condition.selector).first().waitFor({ state: 'visible', timeout });
|
|
618
|
+
else if ('text' in condition)
|
|
619
|
+
await page.getByText(condition.text, { exact: false }).first().waitFor({ state: 'visible', timeout });
|
|
620
|
+
else
|
|
621
|
+
await page.waitForURL((url) => url.href.includes(condition.urlIncludes), { timeout });
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
if (!isTimeoutError(error))
|
|
625
|
+
return { failures, error };
|
|
626
|
+
failures.push(`${label}: did not hold within ${timeout}ms. ${(error instanceof Error ? error.message : String(error)).split('\n')[0]}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return { failures };
|
|
630
|
+
}
|
|
631
|
+
//# sourceMappingURL=automation-v2.js.map
|