neuron-inspector 0.8.1 → 1.0.0
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 +345 -42
- package/dist/automations/executor.d.ts +21 -0
- package/dist/automations/executor.js +835 -0
- package/dist/automations/executor.js.map +1 -0
- package/dist/automations/index.d.ts +7 -0
- package/dist/automations/index.js +21 -0
- package/dist/automations/index.js.map +1 -0
- package/dist/automations/store.d.ts +15 -0
- package/dist/automations/store.js +193 -0
- package/dist/automations/store.js.map +1 -0
- package/dist/automations/tools.d.ts +14 -0
- package/dist/automations/tools.js +735 -0
- package/dist/automations/tools.js.map +1 -0
- package/dist/automations/types.d.ts +211 -0
- package/dist/automations/types.js +4 -0
- package/dist/automations/types.js.map +1 -0
- package/dist/business-tools.d.ts +17 -0
- package/dist/business-tools.js +3065 -0
- package/dist/business-tools.js.map +1 -0
- package/dist/collections/resolver.js +2 -2
- package/dist/collections/resolver.js.map +1 -1
- package/dist/collections/store.js +2 -2
- package/dist/collections/store.js.map +1 -1
- package/dist/killswitch.js +2 -2
- package/dist/killswitch.js.map +1 -1
- package/dist/monitor.d.ts +36 -2
- package/dist/monitor.js +213 -38
- package/dist/monitor.js.map +1 -1
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +39 -0
- package/dist/paths.js.map +1 -0
- package/dist/recipe-tools.js +28 -3
- package/dist/recipe-tools.js.map +1 -1
- package/dist/recipes.d.ts +2 -0
- package/dist/recipes.js +34 -5
- package/dist/recipes.js.map +1 -1
- package/dist/scheduler.js +4 -8
- package/dist/scheduler.js.map +1 -1
- package/dist/server.js +55 -17
- package/dist/server.js.map +1 -1
- package/dist/session-state.js +2 -2
- package/dist/session-state.js.map +1 -1
- package/dist/tools.js +28 -0
- package/dist/tools.js.map +1 -1
- package/package.json +2 -2
- package/recipes/linkedin-job-scraper/agent.md +144 -0
- package/recipes/linkedin-job-scraper/learnings.md +3 -0
- package/recipes/linkedin-job-scraper/recipe.yaml +71 -0
- package/recipes/sheets-data-entry/agent.md +166 -0
- package/recipes/sheets-data-entry/learnings.md +3 -0
- package/recipes/sheets-data-entry/recipe.yaml +78 -0
- package/recipes/x-job-scraper/agent.md +156 -0
- package/recipes/x-job-scraper/learnings.md +3 -0
- package/recipes/x-job-scraper/recipe.yaml +72 -0
- package/dist/__tests__/correlation.test.d.ts +0 -1
- package/dist/__tests__/correlation.test.js +0 -53
- package/dist/__tests__/correlation.test.js.map +0 -1
- package/dist/__tests__/monitor.test.d.ts +0 -1
- package/dist/__tests__/monitor.test.js +0 -286
- package/dist/__tests__/monitor.test.js.map +0 -1
- package/dist/__tests__/recipes.test.d.ts +0 -1
- package/dist/__tests__/recipes.test.js +0 -415
- package/dist/__tests__/recipes.test.js.map +0 -1
- package/dist/__tests__/scheduler.test.d.ts +0 -1
- package/dist/__tests__/scheduler.test.js +0 -316
- package/dist/__tests__/scheduler.test.js.map +0 -1
- package/dist/__tests__/session-state.test.d.ts +0 -1
- package/dist/__tests__/session-state.test.js +0 -335
- package/dist/__tests__/session-state.test.js.map +0 -1
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step Execution Engine — Browser Automation
|
|
3
|
+
*
|
|
4
|
+
* Dispatches automation steps to bridge primitives via WebSocket.
|
|
5
|
+
* Handles variable substitution, tab tracking, assertions, extraction,
|
|
6
|
+
* retry logic, and error handling.
|
|
7
|
+
*/
|
|
8
|
+
import { handleRecipeTool } from "../recipe-tools.js";
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Helpers
|
|
11
|
+
// ============================================================================
|
|
12
|
+
async function call(ctx, primitive, args, timeoutMs = 15000) {
|
|
13
|
+
return ctx.pending.call(ctx.ws, primitive, args, timeoutMs);
|
|
14
|
+
}
|
|
15
|
+
function sleep(ms) {
|
|
16
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Substitute {{variable}} placeholders in a string
|
|
20
|
+
*/
|
|
21
|
+
function substituteVars(text, vars) {
|
|
22
|
+
return text.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Deep substitute variables in all string fields of a config object
|
|
26
|
+
*/
|
|
27
|
+
function substituteInConfig(config, vars) {
|
|
28
|
+
if (typeof config === "string") {
|
|
29
|
+
return substituteVars(config, vars);
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(config)) {
|
|
32
|
+
return config.map((item) => substituteInConfig(item, vars));
|
|
33
|
+
}
|
|
34
|
+
if (config && typeof config === "object") {
|
|
35
|
+
const result = {};
|
|
36
|
+
for (const [key, value] of Object.entries(config)) {
|
|
37
|
+
result[key] = substituteInConfig(value, vars);
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
return config;
|
|
42
|
+
}
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Recipe Loading
|
|
45
|
+
// ============================================================================
|
|
46
|
+
async function loadRecipe(slug, variables) {
|
|
47
|
+
const args = { slug };
|
|
48
|
+
if (variables)
|
|
49
|
+
args.variables = variables;
|
|
50
|
+
return handleRecipeTool("neuron_recipe_run", args);
|
|
51
|
+
}
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Assertion Checking
|
|
54
|
+
// ============================================================================
|
|
55
|
+
async function checkAssertion(ctx, tabId, assertion, vars) {
|
|
56
|
+
const check = assertion.check;
|
|
57
|
+
const value = substituteVars(assertion.value, vars);
|
|
58
|
+
const expected = assertion.expected;
|
|
59
|
+
try {
|
|
60
|
+
switch (check) {
|
|
61
|
+
case "selector_exists": {
|
|
62
|
+
const result = (await call(ctx, "queryDom", { tabId, selector: value }));
|
|
63
|
+
const exists = !!(result.elements && result.elements.length > 0);
|
|
64
|
+
return { check, passed: exists, actual: exists, expected: true };
|
|
65
|
+
}
|
|
66
|
+
case "selector_not_exists": {
|
|
67
|
+
const result = (await call(ctx, "queryDom", { tabId, selector: value }));
|
|
68
|
+
const exists = !!(result.elements && result.elements.length > 0);
|
|
69
|
+
return { check, passed: !exists, actual: exists, expected: false };
|
|
70
|
+
}
|
|
71
|
+
case "text_contains": {
|
|
72
|
+
const text = (await call(ctx, "evaluateJS", {
|
|
73
|
+
tabId,
|
|
74
|
+
expression: "document.body.innerText",
|
|
75
|
+
}));
|
|
76
|
+
const contains = text.includes(value);
|
|
77
|
+
return { check, passed: contains, actual: contains, expected: true };
|
|
78
|
+
}
|
|
79
|
+
case "text_not_contains": {
|
|
80
|
+
const text = (await call(ctx, "evaluateJS", {
|
|
81
|
+
tabId,
|
|
82
|
+
expression: "document.body.innerText",
|
|
83
|
+
}));
|
|
84
|
+
const contains = text.includes(value);
|
|
85
|
+
return { check, passed: !contains, actual: contains, expected: false };
|
|
86
|
+
}
|
|
87
|
+
case "url_matches": {
|
|
88
|
+
const url = (await call(ctx, "evaluateJS", {
|
|
89
|
+
tabId,
|
|
90
|
+
expression: "location.href",
|
|
91
|
+
}));
|
|
92
|
+
const matches = new RegExp(value).test(url);
|
|
93
|
+
return { check, passed: matches, actual: url, expected: value };
|
|
94
|
+
}
|
|
95
|
+
case "title_matches": {
|
|
96
|
+
const title = (await call(ctx, "evaluateJS", {
|
|
97
|
+
tabId,
|
|
98
|
+
expression: "document.title",
|
|
99
|
+
}));
|
|
100
|
+
const matches = new RegExp(value).test(title);
|
|
101
|
+
return { check, passed: matches, actual: title, expected: value };
|
|
102
|
+
}
|
|
103
|
+
case "element_visible": {
|
|
104
|
+
const visible = (await call(ctx, "evaluateJS", {
|
|
105
|
+
tabId,
|
|
106
|
+
expression: `
|
|
107
|
+
(() => {
|
|
108
|
+
const el = document.querySelector(${JSON.stringify(value)});
|
|
109
|
+
if (!el) return false;
|
|
110
|
+
const rect = el.getBoundingClientRect();
|
|
111
|
+
const style = window.getComputedStyle(el);
|
|
112
|
+
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
|
|
113
|
+
})()
|
|
114
|
+
`,
|
|
115
|
+
}));
|
|
116
|
+
return { check, passed: visible, actual: visible, expected: true };
|
|
117
|
+
}
|
|
118
|
+
case "element_count": {
|
|
119
|
+
const result = (await call(ctx, "queryDom", { tabId, selector: value }));
|
|
120
|
+
const count = result.elements?.length ?? 0;
|
|
121
|
+
const expectedCount = Number(expected ?? 0);
|
|
122
|
+
const op = assertion.op ?? "eq";
|
|
123
|
+
let passed = false;
|
|
124
|
+
if (op === "eq")
|
|
125
|
+
passed = count === expectedCount;
|
|
126
|
+
else if (op === "gt")
|
|
127
|
+
passed = count > expectedCount;
|
|
128
|
+
else if (op === "lt")
|
|
129
|
+
passed = count < expectedCount;
|
|
130
|
+
return { check, passed, actual: count, expected: expectedCount };
|
|
131
|
+
}
|
|
132
|
+
case "js_truthy": {
|
|
133
|
+
const result = await call(ctx, "evaluateJS", { tabId, expression: value });
|
|
134
|
+
const truthy = Boolean(result);
|
|
135
|
+
return { check, passed: truthy, actual: result, expected: true };
|
|
136
|
+
}
|
|
137
|
+
default:
|
|
138
|
+
return { check, passed: false, actual: null, expected: null };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return {
|
|
143
|
+
check,
|
|
144
|
+
passed: false,
|
|
145
|
+
actual: err.message,
|
|
146
|
+
expected,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// ============================================================================
|
|
151
|
+
// Action Dispatchers
|
|
152
|
+
// ============================================================================
|
|
153
|
+
async function executeNavigate(ctx, config, tabId, vars) {
|
|
154
|
+
const url = substituteVars(config.url, vars);
|
|
155
|
+
if (tabId === null) {
|
|
156
|
+
// Open new tab
|
|
157
|
+
const result = (await call(ctx, "openTab", { url }));
|
|
158
|
+
const newTabId = result.tabId;
|
|
159
|
+
if (!newTabId)
|
|
160
|
+
throw new Error("Failed to open tab");
|
|
161
|
+
await sleep(2000); // Wait for initial load
|
|
162
|
+
return { tabId: newTabId, result };
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
// Navigate existing tab
|
|
166
|
+
const result = await call(ctx, "navigate", { tabId, url });
|
|
167
|
+
await sleep(1500);
|
|
168
|
+
return { tabId, result };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function executeClick(ctx, config, tabId, vars) {
|
|
172
|
+
const selector = substituteVars(config.selector, vars);
|
|
173
|
+
const text = config.text ? substituteVars(config.text, vars) : undefined;
|
|
174
|
+
return call(ctx, "smartClick", {
|
|
175
|
+
tabId,
|
|
176
|
+
selectors: [selector],
|
|
177
|
+
texts: text ? [text] : undefined,
|
|
178
|
+
scrollToFind: true,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async function executeType(ctx, config, tabId, vars) {
|
|
182
|
+
const selector = substituteVars(config.selector, vars);
|
|
183
|
+
const text = substituteVars(config.text, vars);
|
|
184
|
+
if (config.clear) {
|
|
185
|
+
await call(ctx, "evaluateJS", {
|
|
186
|
+
tabId,
|
|
187
|
+
expression: `
|
|
188
|
+
(() => {
|
|
189
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
190
|
+
if (el) el.value = '';
|
|
191
|
+
})()
|
|
192
|
+
`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const result = await call(ctx, "smartType", {
|
|
196
|
+
tabId,
|
|
197
|
+
selectors: [selector],
|
|
198
|
+
value: text,
|
|
199
|
+
pressEnter: config.press_enter ?? false,
|
|
200
|
+
});
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
async function executeSelect(ctx, config, tabId, vars) {
|
|
204
|
+
const selector = substituteVars(config.selector, vars);
|
|
205
|
+
const value = config.value ? substituteVars(config.value, vars) : undefined;
|
|
206
|
+
const text = config.text ? substituteVars(config.text, vars) : undefined;
|
|
207
|
+
let expression = "";
|
|
208
|
+
if (value !== undefined) {
|
|
209
|
+
expression = `
|
|
210
|
+
(() => {
|
|
211
|
+
const sel = document.querySelector(${JSON.stringify(selector)});
|
|
212
|
+
if (sel) {
|
|
213
|
+
sel.value = ${JSON.stringify(value)};
|
|
214
|
+
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
})()
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
else if (text !== undefined) {
|
|
222
|
+
expression = `
|
|
223
|
+
(() => {
|
|
224
|
+
const sel = document.querySelector(${JSON.stringify(selector)});
|
|
225
|
+
if (sel) {
|
|
226
|
+
for (const opt of sel.options) {
|
|
227
|
+
if (opt.text === ${JSON.stringify(text)}) {
|
|
228
|
+
sel.value = opt.value;
|
|
229
|
+
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
})()
|
|
236
|
+
`;
|
|
237
|
+
}
|
|
238
|
+
else if (config.index !== undefined) {
|
|
239
|
+
expression = `
|
|
240
|
+
(() => {
|
|
241
|
+
const sel = document.querySelector(${JSON.stringify(selector)});
|
|
242
|
+
if (sel && sel.options[${config.index}]) {
|
|
243
|
+
sel.selectedIndex = ${config.index};
|
|
244
|
+
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
})()
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
251
|
+
return call(ctx, "evaluateJS", { tabId, expression });
|
|
252
|
+
}
|
|
253
|
+
async function executeScroll(ctx, config, tabId, vars) {
|
|
254
|
+
const selector = config.selector ? substituteVars(config.selector, vars) : undefined;
|
|
255
|
+
if (selector) {
|
|
256
|
+
// Scroll to element
|
|
257
|
+
await call(ctx, "evaluateJS", {
|
|
258
|
+
tabId,
|
|
259
|
+
expression: `
|
|
260
|
+
(() => {
|
|
261
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
262
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
263
|
+
})()
|
|
264
|
+
`,
|
|
265
|
+
});
|
|
266
|
+
return { scrolled_to: selector };
|
|
267
|
+
}
|
|
268
|
+
const deltaY = config.y ?? (config.direction === "up" ? -600 : 600);
|
|
269
|
+
const pages = config.pages ?? 1;
|
|
270
|
+
for (let i = 0; i < pages; i++) {
|
|
271
|
+
await call(ctx, "scrollPage", { tabId, deltaY, smooth: true });
|
|
272
|
+
await sleep(400);
|
|
273
|
+
}
|
|
274
|
+
return { scrolled: pages };
|
|
275
|
+
}
|
|
276
|
+
async function executeWait(ctx, config, tabId, vars, timeoutMs) {
|
|
277
|
+
const value = substituteVars(config.value, vars);
|
|
278
|
+
const timeout = config.timeout_ms ?? timeoutMs;
|
|
279
|
+
switch (config.type) {
|
|
280
|
+
case "selector":
|
|
281
|
+
return call(ctx, "waitFor", {
|
|
282
|
+
tabId,
|
|
283
|
+
condition: "element",
|
|
284
|
+
selectors: [value],
|
|
285
|
+
timeoutMs: timeout,
|
|
286
|
+
});
|
|
287
|
+
case "text":
|
|
288
|
+
return call(ctx, "waitFor", {
|
|
289
|
+
tabId,
|
|
290
|
+
condition: "text",
|
|
291
|
+
text: value,
|
|
292
|
+
timeoutMs: timeout,
|
|
293
|
+
});
|
|
294
|
+
case "url":
|
|
295
|
+
return call(ctx, "waitFor", {
|
|
296
|
+
tabId,
|
|
297
|
+
condition: "url",
|
|
298
|
+
urlContains: value,
|
|
299
|
+
timeoutMs: timeout,
|
|
300
|
+
});
|
|
301
|
+
case "time":
|
|
302
|
+
await sleep(Number(value));
|
|
303
|
+
return { waited_ms: Number(value) };
|
|
304
|
+
case "network_idle":
|
|
305
|
+
await sleep(2000); // Simple network idle approximation
|
|
306
|
+
return { network_idle: true };
|
|
307
|
+
default:
|
|
308
|
+
throw new Error(`Unknown wait type: ${config.type}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function executeExtract(ctx, config, tabId, vars) {
|
|
312
|
+
if (config.expression) {
|
|
313
|
+
const expr = substituteVars(config.expression, vars);
|
|
314
|
+
return call(ctx, "evaluateJS", { tabId, expression: expr });
|
|
315
|
+
}
|
|
316
|
+
if (config.selector) {
|
|
317
|
+
const selector = substituteVars(config.selector, vars);
|
|
318
|
+
const result = (await call(ctx, "queryDom", { tabId, selector }));
|
|
319
|
+
if (config.attribute) {
|
|
320
|
+
const attr = config.attribute;
|
|
321
|
+
const extracted = await call(ctx, "evaluateJS", {
|
|
322
|
+
tabId,
|
|
323
|
+
expression: `
|
|
324
|
+
Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
|
|
325
|
+
.map(el => el.getAttribute(${JSON.stringify(attr)}))
|
|
326
|
+
`,
|
|
327
|
+
});
|
|
328
|
+
return config.all ? extracted : extracted[0];
|
|
329
|
+
}
|
|
330
|
+
if (config.property) {
|
|
331
|
+
const prop = config.property;
|
|
332
|
+
const extracted = await call(ctx, "evaluateJS", {
|
|
333
|
+
tabId,
|
|
334
|
+
expression: `
|
|
335
|
+
Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
|
|
336
|
+
.map(el => el[${JSON.stringify(prop)}])
|
|
337
|
+
`,
|
|
338
|
+
});
|
|
339
|
+
return config.all ? extracted : extracted[0];
|
|
340
|
+
}
|
|
341
|
+
return result;
|
|
342
|
+
}
|
|
343
|
+
return call(ctx, "extractData", { tabId });
|
|
344
|
+
}
|
|
345
|
+
async function executeScreenshot(ctx, config, tabId) {
|
|
346
|
+
return call(ctx, "screenshot", { tabId });
|
|
347
|
+
}
|
|
348
|
+
async function executeEvaluate(ctx, config, tabId, vars) {
|
|
349
|
+
const expression = substituteVars(config.expression, vars);
|
|
350
|
+
return call(ctx, "evaluateJS", { tabId, expression });
|
|
351
|
+
}
|
|
352
|
+
async function executeAssert(ctx, config, tabId, vars) {
|
|
353
|
+
return checkAssertion(ctx, tabId, config, vars);
|
|
354
|
+
}
|
|
355
|
+
async function executeKeyboard(ctx, config, tabId, vars) {
|
|
356
|
+
const key = substituteVars(config.key, vars);
|
|
357
|
+
return call(ctx, "pressKey", {
|
|
358
|
+
tabId,
|
|
359
|
+
key,
|
|
360
|
+
modifiers: config.modifiers ?? [],
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
async function executeHover(ctx, config, tabId, vars) {
|
|
364
|
+
const selector = substituteVars(config.selector, vars);
|
|
365
|
+
return call(ctx, "evaluateJS", {
|
|
366
|
+
tabId,
|
|
367
|
+
expression: `
|
|
368
|
+
(() => {
|
|
369
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
370
|
+
if (el) {
|
|
371
|
+
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true }));
|
|
372
|
+
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true }));
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
})()
|
|
377
|
+
`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
async function executeTab(ctx, config, vars) {
|
|
381
|
+
switch (config.action) {
|
|
382
|
+
case "new": {
|
|
383
|
+
const url = config.url ? substituteVars(config.url, vars) : "about:blank";
|
|
384
|
+
const result = (await call(ctx, "openTab", { url }));
|
|
385
|
+
return { tabId: result.tabId ?? null, result };
|
|
386
|
+
}
|
|
387
|
+
case "close": {
|
|
388
|
+
if (config.index !== undefined) {
|
|
389
|
+
// Close by index - we don't have direct tab list access, skip
|
|
390
|
+
return { tabId: null, result: { closed: false } };
|
|
391
|
+
}
|
|
392
|
+
return { tabId: null, result: { closed: true } };
|
|
393
|
+
}
|
|
394
|
+
case "switch": {
|
|
395
|
+
if (config.index !== undefined) {
|
|
396
|
+
// Switch by index - call focusTab with the index
|
|
397
|
+
await call(ctx, "focusTab", { tabId: config.index });
|
|
398
|
+
return { tabId: config.index, result: { switched: true } };
|
|
399
|
+
}
|
|
400
|
+
return { tabId: null, result: { switched: false } };
|
|
401
|
+
}
|
|
402
|
+
default:
|
|
403
|
+
throw new Error(`Unknown tab action: ${config.action}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function executeGroup(ctx, config, tabId, vars, suiteDefaults) {
|
|
407
|
+
const results = [];
|
|
408
|
+
let currentTabId = tabId;
|
|
409
|
+
let currentVars = { ...vars };
|
|
410
|
+
// Handle condition
|
|
411
|
+
if (config.condition) {
|
|
412
|
+
const condExpr = substituteVars(config.condition, currentVars);
|
|
413
|
+
let condResult = false;
|
|
414
|
+
if (currentTabId !== null) {
|
|
415
|
+
try {
|
|
416
|
+
condResult = Boolean(await call(ctx, "evaluateJS", { tabId: currentTabId, expression: condExpr }));
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
condResult = false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (!condResult) {
|
|
423
|
+
return { results, tabId: currentTabId, variables: currentVars };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Handle loop
|
|
427
|
+
if (config.loop) {
|
|
428
|
+
const loop = config.loop;
|
|
429
|
+
let iterations = 0;
|
|
430
|
+
const maxIterations = loop.count ?? 1000; // Safety limit
|
|
431
|
+
while (iterations < maxIterations) {
|
|
432
|
+
if (loop.while) {
|
|
433
|
+
const whileExpr = substituteVars(loop.while, currentVars);
|
|
434
|
+
if (currentTabId === null)
|
|
435
|
+
break;
|
|
436
|
+
const condResult = Boolean(await call(ctx, "evaluateJS", { tabId: currentTabId, expression: whileExpr }));
|
|
437
|
+
if (!condResult)
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
// Execute steps
|
|
441
|
+
for (const step of config.steps) {
|
|
442
|
+
const stepResult = await executeStep(ctx, step, currentVars, currentTabId, suiteDefaults);
|
|
443
|
+
results.push(stepResult.result);
|
|
444
|
+
currentTabId = stepResult.tabId;
|
|
445
|
+
currentVars = stepResult.variables;
|
|
446
|
+
if (stepResult.result.status === "failed" || stepResult.result.status === "error") {
|
|
447
|
+
return { results, tabId: currentTabId, variables: currentVars };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
iterations++;
|
|
451
|
+
if (loop.count !== undefined && iterations >= loop.count)
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
// Execute steps once
|
|
457
|
+
for (const step of config.steps) {
|
|
458
|
+
const stepResult = await executeStep(ctx, step, currentVars, currentTabId, suiteDefaults);
|
|
459
|
+
results.push(stepResult.result);
|
|
460
|
+
currentTabId = stepResult.tabId;
|
|
461
|
+
currentVars = stepResult.variables;
|
|
462
|
+
if (stepResult.result.status === "failed" || stepResult.result.status === "error") {
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return { results, tabId: currentTabId, variables: currentVars };
|
|
468
|
+
}
|
|
469
|
+
// ============================================================================
|
|
470
|
+
// Step Execution
|
|
471
|
+
// ============================================================================
|
|
472
|
+
export async function executeStep(ctx, step, variables, tabId, suiteDefaults) {
|
|
473
|
+
const startTime = Date.now();
|
|
474
|
+
const logs = [];
|
|
475
|
+
let currentTabId = tabId;
|
|
476
|
+
let currentVars = { ...variables };
|
|
477
|
+
// Handle disabled steps
|
|
478
|
+
if (step.disabled) {
|
|
479
|
+
return {
|
|
480
|
+
result: {
|
|
481
|
+
step_id: step.id,
|
|
482
|
+
step_name: step.name,
|
|
483
|
+
action: step.action,
|
|
484
|
+
status: "skipped",
|
|
485
|
+
duration_ms: 0,
|
|
486
|
+
},
|
|
487
|
+
tabId: currentTabId,
|
|
488
|
+
variables: currentVars,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
// Wait before execution
|
|
492
|
+
if (step.wait_before_ms) {
|
|
493
|
+
await sleep(step.wait_before_ms);
|
|
494
|
+
}
|
|
495
|
+
const timeout = step.timeout_ms ?? suiteDefaults?.timeout_ms ?? 15000;
|
|
496
|
+
const onError = step.on_error ?? suiteDefaults?.on_error ?? "stop";
|
|
497
|
+
const retryConfig = step.retry ?? { max: 0, delay_ms: 1000 };
|
|
498
|
+
let attempts = 0;
|
|
499
|
+
let lastError = null;
|
|
500
|
+
let actionResult = null;
|
|
501
|
+
// Retry loop
|
|
502
|
+
while (attempts <= retryConfig.max) {
|
|
503
|
+
try {
|
|
504
|
+
// Substitute variables in config
|
|
505
|
+
const config = substituteInConfig(step.config, currentVars);
|
|
506
|
+
// Execute action
|
|
507
|
+
switch (step.action) {
|
|
508
|
+
case "navigate": {
|
|
509
|
+
const navResult = await executeNavigate(ctx, config, currentTabId, currentVars);
|
|
510
|
+
currentTabId = navResult.tabId;
|
|
511
|
+
actionResult = navResult.result;
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
case "click":
|
|
515
|
+
if (currentTabId === null)
|
|
516
|
+
throw new Error("No active tab for click action");
|
|
517
|
+
actionResult = await executeClick(ctx, config, currentTabId, currentVars);
|
|
518
|
+
break;
|
|
519
|
+
case "type":
|
|
520
|
+
if (currentTabId === null)
|
|
521
|
+
throw new Error("No active tab for type action");
|
|
522
|
+
actionResult = await executeType(ctx, config, currentTabId, currentVars);
|
|
523
|
+
break;
|
|
524
|
+
case "select":
|
|
525
|
+
if (currentTabId === null)
|
|
526
|
+
throw new Error("No active tab for select action");
|
|
527
|
+
actionResult = await executeSelect(ctx, config, currentTabId, currentVars);
|
|
528
|
+
break;
|
|
529
|
+
case "scroll":
|
|
530
|
+
if (currentTabId === null)
|
|
531
|
+
throw new Error("No active tab for scroll action");
|
|
532
|
+
actionResult = await executeScroll(ctx, config, currentTabId, currentVars);
|
|
533
|
+
break;
|
|
534
|
+
case "wait":
|
|
535
|
+
if (currentTabId === null)
|
|
536
|
+
throw new Error("No active tab for wait action");
|
|
537
|
+
actionResult = await executeWait(ctx, config, currentTabId, currentVars, timeout);
|
|
538
|
+
break;
|
|
539
|
+
case "extract":
|
|
540
|
+
if (currentTabId === null)
|
|
541
|
+
throw new Error("No active tab for extract action");
|
|
542
|
+
actionResult = await executeExtract(ctx, config, currentTabId, currentVars);
|
|
543
|
+
break;
|
|
544
|
+
case "screenshot":
|
|
545
|
+
if (currentTabId === null)
|
|
546
|
+
throw new Error("No active tab for screenshot action");
|
|
547
|
+
actionResult = await executeScreenshot(ctx, config, currentTabId);
|
|
548
|
+
break;
|
|
549
|
+
case "evaluate":
|
|
550
|
+
if (currentTabId === null)
|
|
551
|
+
throw new Error("No active tab for evaluate action");
|
|
552
|
+
actionResult = await executeEvaluate(ctx, config, currentTabId, currentVars);
|
|
553
|
+
break;
|
|
554
|
+
case "assert":
|
|
555
|
+
if (currentTabId === null)
|
|
556
|
+
throw new Error("No active tab for assert action");
|
|
557
|
+
actionResult = await executeAssert(ctx, config, currentTabId, currentVars);
|
|
558
|
+
break;
|
|
559
|
+
case "keyboard":
|
|
560
|
+
if (currentTabId === null)
|
|
561
|
+
throw new Error("No active tab for keyboard action");
|
|
562
|
+
actionResult = await executeKeyboard(ctx, config, currentTabId, currentVars);
|
|
563
|
+
break;
|
|
564
|
+
case "hover":
|
|
565
|
+
if (currentTabId === null)
|
|
566
|
+
throw new Error("No active tab for hover action");
|
|
567
|
+
actionResult = await executeHover(ctx, config, currentTabId, currentVars);
|
|
568
|
+
break;
|
|
569
|
+
case "tab": {
|
|
570
|
+
const tabResult = await executeTab(ctx, config, currentVars);
|
|
571
|
+
if (tabResult.tabId !== null)
|
|
572
|
+
currentTabId = tabResult.tabId;
|
|
573
|
+
actionResult = tabResult.result;
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
case "group": {
|
|
577
|
+
const groupResult = await executeGroup(ctx, config, currentTabId, currentVars, suiteDefaults);
|
|
578
|
+
currentTabId = groupResult.tabId;
|
|
579
|
+
currentVars = groupResult.variables;
|
|
580
|
+
actionResult = { steps: groupResult.results };
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
case "recipe": {
|
|
584
|
+
const cfg = config;
|
|
585
|
+
const recipeSlug = substituteVars(cfg.slug, currentVars);
|
|
586
|
+
const recipeVars = cfg.variables
|
|
587
|
+
? Object.fromEntries(Object.entries(cfg.variables).map(([k, v]) => [k, substituteVars(v, currentVars)]))
|
|
588
|
+
: undefined;
|
|
589
|
+
actionResult = await loadRecipe(recipeSlug, recipeVars);
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
default:
|
|
593
|
+
throw new Error(`Unknown action: ${step.action}`);
|
|
594
|
+
}
|
|
595
|
+
// Success - break retry loop
|
|
596
|
+
lastError = null;
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
lastError = err;
|
|
601
|
+
attempts++;
|
|
602
|
+
if (onError === "retry" && attempts <= retryConfig.max) {
|
|
603
|
+
logs.push(`Attempt ${attempts} failed: ${lastError.message}. Retrying...`);
|
|
604
|
+
await sleep(retryConfig.delay_ms);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Handle final error
|
|
612
|
+
if (lastError) {
|
|
613
|
+
const duration = Date.now() - startTime;
|
|
614
|
+
if (onError === "continue") {
|
|
615
|
+
logs.push(`Error (continuing): ${lastError.message}`);
|
|
616
|
+
return {
|
|
617
|
+
result: {
|
|
618
|
+
step_id: step.id,
|
|
619
|
+
step_name: step.name,
|
|
620
|
+
action: step.action,
|
|
621
|
+
status: "error",
|
|
622
|
+
duration_ms: duration,
|
|
623
|
+
error: lastError.message,
|
|
624
|
+
logs,
|
|
625
|
+
},
|
|
626
|
+
tabId: currentTabId,
|
|
627
|
+
variables: currentVars,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
result: {
|
|
632
|
+
step_id: step.id,
|
|
633
|
+
step_name: step.name,
|
|
634
|
+
action: step.action,
|
|
635
|
+
status: "failed",
|
|
636
|
+
duration_ms: duration,
|
|
637
|
+
error: lastError.message,
|
|
638
|
+
logs,
|
|
639
|
+
},
|
|
640
|
+
tabId: currentTabId,
|
|
641
|
+
variables: currentVars,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
// Run assertions
|
|
645
|
+
const assertionResults = [];
|
|
646
|
+
if (step.assertions && currentTabId !== null) {
|
|
647
|
+
for (const assertion of step.assertions) {
|
|
648
|
+
const result = await checkAssertion(ctx, currentTabId, assertion, currentVars);
|
|
649
|
+
assertionResults.push(result);
|
|
650
|
+
if (!result.passed) {
|
|
651
|
+
logs.push(`Assertion failed: ${assertion.check} - expected ${assertion.expected}, got ${result.actual}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Extract variables
|
|
656
|
+
const extracted = {};
|
|
657
|
+
if (step.extract && currentTabId !== null) {
|
|
658
|
+
for (const [varName, expression] of Object.entries(step.extract)) {
|
|
659
|
+
try {
|
|
660
|
+
const expr = substituteVars(expression, currentVars);
|
|
661
|
+
const value = await call(ctx, "evaluateJS", { tabId: currentTabId, expression: expr });
|
|
662
|
+
extracted[varName] = String(value ?? "");
|
|
663
|
+
currentVars[varName] = extracted[varName];
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
logs.push(`Failed to extract ${varName}: ${err.message}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// Wait after execution
|
|
671
|
+
if (step.wait_after_ms) {
|
|
672
|
+
await sleep(step.wait_after_ms);
|
|
673
|
+
}
|
|
674
|
+
const duration = Date.now() - startTime;
|
|
675
|
+
const allAssertionsPassed = assertionResults.length === 0 || assertionResults.every((a) => a.passed);
|
|
676
|
+
return {
|
|
677
|
+
result: {
|
|
678
|
+
step_id: step.id,
|
|
679
|
+
step_name: step.name,
|
|
680
|
+
action: step.action,
|
|
681
|
+
status: allAssertionsPassed ? "passed" : "failed",
|
|
682
|
+
duration_ms: duration,
|
|
683
|
+
assertions: assertionResults.length > 0 ? assertionResults : undefined,
|
|
684
|
+
extracted: Object.keys(extracted).length > 0 ? extracted : undefined,
|
|
685
|
+
logs: logs.length > 0 ? logs : undefined,
|
|
686
|
+
},
|
|
687
|
+
tabId: currentTabId,
|
|
688
|
+
variables: currentVars,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
// ============================================================================
|
|
692
|
+
// Suite Execution
|
|
693
|
+
// ============================================================================
|
|
694
|
+
export async function executeSuite(ctx, suiteId, suite) {
|
|
695
|
+
const startTime = new Date();
|
|
696
|
+
const steps = [];
|
|
697
|
+
let currentTabId = null;
|
|
698
|
+
let variables = { ...suite.variables };
|
|
699
|
+
let status = "passed";
|
|
700
|
+
try {
|
|
701
|
+
// Execute setup recipe (before setup steps)
|
|
702
|
+
if (suite.setup_recipe) {
|
|
703
|
+
const recipeStart = Date.now();
|
|
704
|
+
try {
|
|
705
|
+
const recipeResult = await loadRecipe(suite.setup_recipe.slug, suite.setup_recipe.variables);
|
|
706
|
+
steps.push({
|
|
707
|
+
step_id: "__setup_recipe",
|
|
708
|
+
step_name: `Recipe: ${suite.setup_recipe.slug}`,
|
|
709
|
+
action: "recipe",
|
|
710
|
+
status: "passed",
|
|
711
|
+
duration_ms: Date.now() - recipeStart,
|
|
712
|
+
logs: [JSON.stringify(recipeResult)],
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
steps.push({
|
|
717
|
+
step_id: "__setup_recipe",
|
|
718
|
+
step_name: `Recipe: ${suite.setup_recipe.slug}`,
|
|
719
|
+
action: "recipe",
|
|
720
|
+
status: "failed",
|
|
721
|
+
duration_ms: Date.now() - recipeStart,
|
|
722
|
+
error: err.message,
|
|
723
|
+
});
|
|
724
|
+
status = "failed";
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Execute setup steps
|
|
728
|
+
if (suite.setup && status === "passed") {
|
|
729
|
+
for (const step of suite.setup) {
|
|
730
|
+
const result = await executeStep(ctx, step, variables, currentTabId, suite.defaults);
|
|
731
|
+
steps.push(result.result);
|
|
732
|
+
currentTabId = result.tabId;
|
|
733
|
+
variables = result.variables;
|
|
734
|
+
if (result.result.status === "failed" || result.result.status === "error") {
|
|
735
|
+
status = "failed";
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// Execute main steps (only if setup succeeded)
|
|
741
|
+
if (status === "passed") {
|
|
742
|
+
for (const step of suite.steps) {
|
|
743
|
+
const result = await executeStep(ctx, step, variables, currentTabId, suite.defaults);
|
|
744
|
+
steps.push(result.result);
|
|
745
|
+
currentTabId = result.tabId;
|
|
746
|
+
variables = result.variables;
|
|
747
|
+
if (result.result.status === "failed") {
|
|
748
|
+
status = "failed";
|
|
749
|
+
// Continue or stop based on on_error
|
|
750
|
+
if (step.on_error !== "continue")
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
else if (result.result.status === "error") {
|
|
754
|
+
status = "error";
|
|
755
|
+
if (step.on_error !== "continue")
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
// Wait between steps
|
|
759
|
+
if (suite.defaults?.wait_between_ms) {
|
|
760
|
+
await sleep(suite.defaults.wait_between_ms);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
status = "error";
|
|
767
|
+
steps.push({
|
|
768
|
+
step_id: "suite_error",
|
|
769
|
+
action: "error",
|
|
770
|
+
status: "error",
|
|
771
|
+
duration_ms: 0,
|
|
772
|
+
error: err.message,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
finally {
|
|
776
|
+
// Always execute teardown steps
|
|
777
|
+
if (suite.teardown) {
|
|
778
|
+
for (const step of suite.teardown) {
|
|
779
|
+
try {
|
|
780
|
+
const result = await executeStep(ctx, step, variables, currentTabId, suite.defaults);
|
|
781
|
+
steps.push(result.result);
|
|
782
|
+
currentTabId = result.tabId;
|
|
783
|
+
variables = result.variables;
|
|
784
|
+
}
|
|
785
|
+
catch (err) {
|
|
786
|
+
steps.push({
|
|
787
|
+
step_id: step.id,
|
|
788
|
+
step_name: step.name,
|
|
789
|
+
action: step.action,
|
|
790
|
+
status: "error",
|
|
791
|
+
duration_ms: 0,
|
|
792
|
+
error: err.message,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
// Always execute teardown recipe (after teardown steps)
|
|
798
|
+
if (suite.teardown_recipe) {
|
|
799
|
+
const recipeStart = Date.now();
|
|
800
|
+
try {
|
|
801
|
+
const recipeResult = await loadRecipe(suite.teardown_recipe.slug, suite.teardown_recipe.variables);
|
|
802
|
+
steps.push({
|
|
803
|
+
step_id: "__teardown_recipe",
|
|
804
|
+
step_name: `Recipe: ${suite.teardown_recipe.slug}`,
|
|
805
|
+
action: "recipe",
|
|
806
|
+
status: "passed",
|
|
807
|
+
duration_ms: Date.now() - recipeStart,
|
|
808
|
+
logs: [JSON.stringify(recipeResult)],
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
catch (err) {
|
|
812
|
+
steps.push({
|
|
813
|
+
step_id: "__teardown_recipe",
|
|
814
|
+
step_name: `Recipe: ${suite.teardown_recipe.slug}`,
|
|
815
|
+
action: "recipe",
|
|
816
|
+
status: "error",
|
|
817
|
+
duration_ms: Date.now() - recipeStart,
|
|
818
|
+
error: err.message,
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
const endTime = new Date();
|
|
824
|
+
return {
|
|
825
|
+
suite_id: suiteId,
|
|
826
|
+
suite_name: suite.name,
|
|
827
|
+
status,
|
|
828
|
+
started_at: startTime.toISOString(),
|
|
829
|
+
completed_at: endTime.toISOString(),
|
|
830
|
+
duration_ms: endTime.getTime() - startTime.getTime(),
|
|
831
|
+
steps,
|
|
832
|
+
extracted: variables,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
//# sourceMappingURL=executor.js.map
|