@showrun/core 0.1.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/dist/__tests__/dsl-validation.test.d.ts +2 -0
  3. package/dist/__tests__/dsl-validation.test.d.ts.map +1 -0
  4. package/dist/__tests__/dsl-validation.test.js +203 -0
  5. package/dist/__tests__/pack-versioning.test.d.ts +2 -0
  6. package/dist/__tests__/pack-versioning.test.d.ts.map +1 -0
  7. package/dist/__tests__/pack-versioning.test.js +165 -0
  8. package/dist/__tests__/validator.test.d.ts +2 -0
  9. package/dist/__tests__/validator.test.d.ts.map +1 -0
  10. package/dist/__tests__/validator.test.js +149 -0
  11. package/dist/authResilience.d.ts +146 -0
  12. package/dist/authResilience.d.ts.map +1 -0
  13. package/dist/authResilience.js +378 -0
  14. package/dist/browserLauncher.d.ts +74 -0
  15. package/dist/browserLauncher.d.ts.map +1 -0
  16. package/dist/browserLauncher.js +159 -0
  17. package/dist/browserPersistence.d.ts +49 -0
  18. package/dist/browserPersistence.d.ts.map +1 -0
  19. package/dist/browserPersistence.js +143 -0
  20. package/dist/context.d.ts +10 -0
  21. package/dist/context.d.ts.map +1 -0
  22. package/dist/context.js +30 -0
  23. package/dist/dsl/builders.d.ts +340 -0
  24. package/dist/dsl/builders.d.ts.map +1 -0
  25. package/dist/dsl/builders.js +416 -0
  26. package/dist/dsl/conditions.d.ts +33 -0
  27. package/dist/dsl/conditions.d.ts.map +1 -0
  28. package/dist/dsl/conditions.js +169 -0
  29. package/dist/dsl/interpreter.d.ts +24 -0
  30. package/dist/dsl/interpreter.d.ts.map +1 -0
  31. package/dist/dsl/interpreter.js +491 -0
  32. package/dist/dsl/stepHandlers.d.ts +32 -0
  33. package/dist/dsl/stepHandlers.d.ts.map +1 -0
  34. package/dist/dsl/stepHandlers.js +787 -0
  35. package/dist/dsl/target.d.ts +28 -0
  36. package/dist/dsl/target.d.ts.map +1 -0
  37. package/dist/dsl/target.js +110 -0
  38. package/dist/dsl/templating.d.ts +21 -0
  39. package/dist/dsl/templating.d.ts.map +1 -0
  40. package/dist/dsl/templating.js +73 -0
  41. package/dist/dsl/types.d.ts +695 -0
  42. package/dist/dsl/types.d.ts.map +1 -0
  43. package/dist/dsl/types.js +7 -0
  44. package/dist/dsl/validation.d.ts +15 -0
  45. package/dist/dsl/validation.d.ts.map +1 -0
  46. package/dist/dsl/validation.js +974 -0
  47. package/dist/index.d.ts +20 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +20 -0
  50. package/dist/jsonPackValidator.d.ts +11 -0
  51. package/dist/jsonPackValidator.d.ts.map +1 -0
  52. package/dist/jsonPackValidator.js +61 -0
  53. package/dist/loader.d.ts +35 -0
  54. package/dist/loader.d.ts.map +1 -0
  55. package/dist/loader.js +107 -0
  56. package/dist/networkCapture.d.ts +107 -0
  57. package/dist/networkCapture.d.ts.map +1 -0
  58. package/dist/networkCapture.js +390 -0
  59. package/dist/packUtils.d.ts +36 -0
  60. package/dist/packUtils.d.ts.map +1 -0
  61. package/dist/packUtils.js +97 -0
  62. package/dist/packVersioning.d.ts +25 -0
  63. package/dist/packVersioning.d.ts.map +1 -0
  64. package/dist/packVersioning.js +137 -0
  65. package/dist/runner.d.ts +62 -0
  66. package/dist/runner.d.ts.map +1 -0
  67. package/dist/runner.js +170 -0
  68. package/dist/types.d.ts +336 -0
  69. package/dist/types.d.ts.map +1 -0
  70. package/dist/types.js +1 -0
  71. package/dist/validator.d.ts +20 -0
  72. package/dist/validator.d.ts.map +1 -0
  73. package/dist/validator.js +68 -0
  74. package/package.json +49 -0
@@ -0,0 +1,787 @@
1
+ import { resolveTemplate } from './templating.js';
2
+ import { resolveTargetWithFallback, selectorToTarget } from './target.js';
3
+ import { search as jmesSearch } from '@jmespath-community/jmespath';
4
+ /**
5
+ * Executes a navigate step
6
+ */
7
+ async function executeNavigate(ctx, step) {
8
+ try {
9
+ await ctx.page.goto(step.params.url, {
10
+ waitUntil: step.params.waitUntil ?? 'networkidle',
11
+ });
12
+ }
13
+ catch (err) {
14
+ // Ignore timeout errors — the page content is usually loaded even if
15
+ // networkidle never fires (common on SPAs with long-polling / websockets).
16
+ if (err?.name === 'TimeoutError' || err?.message?.includes('Timeout')) {
17
+ // Navigation reached the page but the load event didn't settle in time.
18
+ return;
19
+ }
20
+ throw err;
21
+ }
22
+ }
23
+ /**
24
+ * Executes an extract_title step
25
+ */
26
+ async function executeExtractTitle(ctx, step) {
27
+ const title = await ctx.page.title();
28
+ ctx.collectibles[step.params.out] = title;
29
+ }
30
+ /**
31
+ * Executes an extract_text step
32
+ */
33
+ async function executeExtractText(ctx, step) {
34
+ // Support both legacy selector and new target
35
+ const targetOrAnyOf = step.params.target ?? (step.params.selector ? selectorToTarget(step.params.selector) : null);
36
+ if (!targetOrAnyOf) {
37
+ throw new Error('ExtractText step must have either "target" or "selector"');
38
+ }
39
+ // Resolve target with fallback and scope
40
+ const { locator, matchedTarget, matchedCount } = await resolveTargetWithFallback(ctx.page, targetOrAnyOf, step.params.scope);
41
+ // Log matched target for diagnostics (if hint provided, include it)
42
+ if (step.params.hint) {
43
+ console.log(`[ExtractText:${step.id}] Matched target: ${JSON.stringify(matchedTarget)}, count: ${matchedCount}, hint: ${step.params.hint}`);
44
+ }
45
+ const count = matchedCount;
46
+ if (count === 0) {
47
+ // No elements found, use default if provided
48
+ ctx.collectibles[step.params.out] = step.params.default ?? '';
49
+ return;
50
+ }
51
+ if (step.params.first === true) {
52
+ // Get first element only (explicit first: true)
53
+ const text = await locator.first().textContent();
54
+ ctx.collectibles[step.params.out] = step.params.trim ?? true ? text?.trim() ?? '' : text ?? '';
55
+ }
56
+ else {
57
+ // Get all elements (default behavior for scraping)
58
+ const texts = [];
59
+ for (let i = 0; i < count; i++) {
60
+ const text = await locator.nth(i).textContent();
61
+ const processed = step.params.trim ?? true ? text?.trim() ?? '' : text ?? '';
62
+ texts.push(processed);
63
+ }
64
+ ctx.collectibles[step.params.out] = texts;
65
+ }
66
+ }
67
+ /**
68
+ * Executes a sleep step
69
+ */
70
+ async function executeSleep(ctx, step) {
71
+ await new Promise((resolve) => setTimeout(resolve, step.params.durationMs));
72
+ }
73
+ /**
74
+ * Executes a wait_for step
75
+ */
76
+ async function executeWaitFor(ctx, step) {
77
+ const timeout = step.timeoutMs ?? step.params.timeoutMs ?? 30000;
78
+ // Support both legacy selector and new target
79
+ const targetOrAnyOf = step.params.target ?? (step.params.selector ? selectorToTarget(step.params.selector) : null);
80
+ if (targetOrAnyOf) {
81
+ const { locator, matchedTarget, matchedCount } = await resolveTargetWithFallback(ctx.page, targetOrAnyOf, step.params.scope);
82
+ // Log matched target for diagnostics
83
+ if (step.params.hint) {
84
+ console.log(`[WaitFor:${step.id}] Matched target: ${JSON.stringify(matchedTarget)}, count: ${matchedCount}, hint: ${step.params.hint}`);
85
+ }
86
+ if (step.params.visible ?? true) {
87
+ await locator.first().waitFor({ state: 'visible', timeout });
88
+ }
89
+ else {
90
+ await locator.first().waitFor({ state: 'attached', timeout });
91
+ }
92
+ }
93
+ else if (step.params.url) {
94
+ if (typeof step.params.url === 'string') {
95
+ await ctx.page.waitForURL(step.params.url, { timeout });
96
+ }
97
+ else {
98
+ // For pattern matching, use a function matcher
99
+ const urlPattern = step.params.url.pattern;
100
+ const exactMatch = step.params.url.exact ?? false;
101
+ await ctx.page.waitForURL((url) => {
102
+ if (exactMatch) {
103
+ return url.href === urlPattern;
104
+ }
105
+ return url.href.includes(urlPattern);
106
+ }, { timeout });
107
+ }
108
+ }
109
+ else if (step.params.loadState) {
110
+ await ctx.page.waitForLoadState(step.params.loadState, { timeout });
111
+ }
112
+ else {
113
+ throw new Error('wait_for step must specify selector, url, or loadState');
114
+ }
115
+ }
116
+ /**
117
+ * Executes a click step
118
+ */
119
+ async function executeClick(ctx, step) {
120
+ // Support both legacy selector and new target
121
+ const targetOrAnyOf = step.params.target ?? (step.params.selector ? selectorToTarget(step.params.selector) : null);
122
+ if (!targetOrAnyOf) {
123
+ throw new Error('Click step must have either "target" or "selector"');
124
+ }
125
+ // Resolve target with fallback and scope
126
+ const { locator, matchedTarget, matchedCount } = await resolveTargetWithFallback(ctx.page, targetOrAnyOf, step.params.scope);
127
+ // Log matched target for diagnostics
128
+ if (step.params.hint) {
129
+ console.log(`[Click:${step.id}] Matched target: ${JSON.stringify(matchedTarget)}, count: ${matchedCount}, hint: ${step.params.hint}`);
130
+ }
131
+ const target = step.params.first ?? true ? locator.first() : locator;
132
+ if (step.params.waitForVisible ?? true) {
133
+ await target.waitFor({ state: 'visible' });
134
+ }
135
+ await target.click();
136
+ }
137
+ /**
138
+ * Executes a fill step
139
+ */
140
+ async function executeFill(ctx, step) {
141
+ // Support both legacy selector and new target
142
+ const targetOrAnyOf = step.params.target ?? (step.params.selector ? selectorToTarget(step.params.selector) : null);
143
+ if (!targetOrAnyOf) {
144
+ throw new Error('Fill step must have either "target" or "selector"');
145
+ }
146
+ // Resolve target with fallback and scope
147
+ const { locator, matchedTarget, matchedCount } = await resolveTargetWithFallback(ctx.page, targetOrAnyOf, step.params.scope);
148
+ // Log matched target for diagnostics
149
+ if (step.params.hint) {
150
+ console.log(`[Fill:${step.id}] Matched target: ${JSON.stringify(matchedTarget)}, count: ${matchedCount}, hint: ${step.params.hint}`);
151
+ }
152
+ const target = step.params.first ?? true ? locator.first() : locator;
153
+ await target.waitFor({ state: 'visible' });
154
+ if (step.params.clear ?? true) {
155
+ await target.fill(step.params.value);
156
+ }
157
+ else {
158
+ await target.type(step.params.value);
159
+ }
160
+ }
161
+ /**
162
+ * Executes an extract_attribute step
163
+ */
164
+ async function executeExtractAttribute(ctx, step) {
165
+ // Support both legacy selector and new target
166
+ const targetOrAnyOf = step.params.target ?? (step.params.selector ? selectorToTarget(step.params.selector) : null);
167
+ if (!targetOrAnyOf) {
168
+ throw new Error('ExtractAttribute step must have either "target" or "selector"');
169
+ }
170
+ // Resolve target with fallback and scope
171
+ const { locator, matchedTarget, matchedCount } = await resolveTargetWithFallback(ctx.page, targetOrAnyOf, step.params.scope);
172
+ // Log matched target for diagnostics
173
+ if (step.params.hint) {
174
+ console.log(`[ExtractAttribute:${step.id}] Matched target: ${JSON.stringify(matchedTarget)}, count: ${matchedCount}, hint: ${step.params.hint}`);
175
+ }
176
+ const count = matchedCount;
177
+ if (count === 0) {
178
+ ctx.collectibles[step.params.out] = step.params.default ?? '';
179
+ return;
180
+ }
181
+ if (step.params.first === true) {
182
+ // Get first element only (explicit first: true)
183
+ const value = await locator.first().getAttribute(step.params.attribute);
184
+ ctx.collectibles[step.params.out] = value ?? step.params.default ?? '';
185
+ }
186
+ else {
187
+ // Get all elements (default behavior for scraping)
188
+ const values = [];
189
+ for (let i = 0; i < count; i++) {
190
+ const value = await locator.nth(i).getAttribute(step.params.attribute);
191
+ values.push(value);
192
+ }
193
+ ctx.collectibles[step.params.out] = values;
194
+ }
195
+ }
196
+ /**
197
+ * Executes an assert step
198
+ */
199
+ async function executeAssert(ctx, step) {
200
+ const errors = [];
201
+ // Support both legacy selector and new target
202
+ const targetOrAnyOf = step.params.target ?? (step.params.selector ? selectorToTarget(step.params.selector) : null);
203
+ if (targetOrAnyOf) {
204
+ const { locator, matchedTarget, matchedCount } = await resolveTargetWithFallback(ctx.page, targetOrAnyOf, step.params.scope);
205
+ // Log matched target for diagnostics
206
+ if (step.params.hint) {
207
+ console.log(`[Assert:${step.id}] Matched target: ${JSON.stringify(matchedTarget)}, count: ${matchedCount}, hint: ${step.params.hint}`);
208
+ }
209
+ if (matchedCount === 0) {
210
+ errors.push(`Element not found: ${JSON.stringify(matchedTarget)}`);
211
+ }
212
+ else if (step.params.visible !== undefined) {
213
+ const isVisible = await locator.first().isVisible();
214
+ if (step.params.visible && !isVisible) {
215
+ errors.push(`Element not visible: ${JSON.stringify(matchedTarget)}`);
216
+ }
217
+ else if (!step.params.visible && isVisible) {
218
+ errors.push(`Element should not be visible: ${JSON.stringify(matchedTarget)}`);
219
+ }
220
+ }
221
+ if (step.params.textIncludes) {
222
+ const text = await locator.first().textContent();
223
+ if (!text || !text.includes(step.params.textIncludes)) {
224
+ errors.push(`Element text does not include "${step.params.textIncludes}": ${JSON.stringify(matchedTarget)}`);
225
+ }
226
+ }
227
+ }
228
+ if (step.params.urlIncludes) {
229
+ const url = ctx.page.url();
230
+ if (!url.includes(step.params.urlIncludes)) {
231
+ errors.push(`URL does not include "${step.params.urlIncludes}": ${url}`);
232
+ }
233
+ }
234
+ if (errors.length > 0) {
235
+ const message = step.params.message || errors.join('; ');
236
+ throw new Error(`Assertion failed: ${message}`);
237
+ }
238
+ }
239
+ /**
240
+ * Executes a set_var step
241
+ * Note: Value may contain templates that need to be resolved
242
+ */
243
+ async function executeSetVar(ctx, step) {
244
+ // If value is a string, it might contain templates - resolve them
245
+ let resolvedValue = step.params.value;
246
+ if (typeof resolvedValue === 'string') {
247
+ // Resolve templates in the value
248
+ const varContext = {
249
+ inputs: ctx.inputs,
250
+ vars: ctx.vars,
251
+ };
252
+ resolvedValue = resolveTemplate(resolvedValue, varContext);
253
+ }
254
+ ctx.vars[step.params.name] = resolvedValue;
255
+ }
256
+ /**
257
+ * Extract value from object using JMESPath expression.
258
+ * Returns { value, hint? } where hint contains diagnostic info on failure.
259
+ *
260
+ * JMESPath supports:
261
+ * - "results[0].name" - array access and nested fields
262
+ * - "results[*].name" - wildcard to get all names
263
+ * - "results[*].{name: name, url: url}" - object projection
264
+ * - "results[?status == 'active']" - filtering
265
+ * - "results | [0]" - piping
266
+ *
267
+ * For backward compatibility, JSONPath-style paths starting with "$." are
268
+ * automatically converted (the "$." prefix is stripped).
269
+ */
270
+ function getByPath(obj, path) {
271
+ let trimmed = path.trim();
272
+ // Backward compatibility: strip JSONPath-style "$." prefix
273
+ // $.results[0].name -> results[0].name
274
+ if (trimmed.startsWith('$.')) {
275
+ trimmed = trimmed.slice(2);
276
+ }
277
+ else if (trimmed === '$') {
278
+ // "$" alone means root in JSONPath - return the whole object
279
+ return { value: obj };
280
+ }
281
+ // Empty path returns the whole object
282
+ if (!trimmed) {
283
+ return { value: obj };
284
+ }
285
+ try {
286
+ const result = jmesSearch(obj, trimmed);
287
+ // Check for null/undefined results and provide diagnostic hint
288
+ if (result === null || result === undefined) {
289
+ return {
290
+ value: result,
291
+ hint: `JMESPath '${trimmed}' matched nothing (returned ${result}). Verify the path exists. Try a simpler path like 'data' or 'keys(@)' to inspect the structure.`,
292
+ };
293
+ }
294
+ // Check for empty array results
295
+ if (Array.isArray(result) && result.length === 0) {
296
+ return {
297
+ value: result,
298
+ hint: `JMESPath '${trimmed}' returned an empty array. The path may be correct but no items matched.`,
299
+ };
300
+ }
301
+ return { value: result };
302
+ }
303
+ catch (error) {
304
+ const errorMessage = error instanceof Error ? error.message : String(error);
305
+ return {
306
+ value: undefined,
307
+ hint: `JMESPath syntax error in '${trimmed}': ${errorMessage}. See https://jmespath.org for syntax reference.`,
308
+ };
309
+ }
310
+ }
311
+ function sleepMs(ms) {
312
+ return new Promise((resolve) => setTimeout(resolve, ms));
313
+ }
314
+ /**
315
+ * Executes a network_find step. If waitForMs is set and no match is found initially, polls the buffer until a match appears or timeout.
316
+ */
317
+ async function executeNetworkFind(ctx, step) {
318
+ if (!ctx.networkCapture) {
319
+ throw new Error('network_find requires an active browser session with network capture. Run the flow in a context that has network capture enabled.');
320
+ }
321
+ const where = step.params.where ?? {};
322
+ const pick = step.params.pick ?? 'last';
323
+ const waitForMs = step.params.waitForMs ?? 0;
324
+ const pollIntervalMs = Math.min(Math.max(step.params.pollIntervalMs ?? 400, 100), 5000);
325
+ // When matching on response body, the capture's response handler is async (await response.body()).
326
+ // Give in-flight handlers time to complete before the first lookup so entries have responseBodyText.
327
+ if (where.responseContains != null) {
328
+ await sleepMs(Math.min(pollIntervalMs * 4, 2000));
329
+ }
330
+ let requestId = ctx.networkCapture.getRequestIdByIndex(where, pick);
331
+ if (requestId == null && waitForMs > 0) {
332
+ const deadline = Date.now() + waitForMs;
333
+ while (Date.now() < deadline) {
334
+ await sleepMs(pollIntervalMs);
335
+ requestId = ctx.networkCapture.getRequestIdByIndex(where, pick);
336
+ if (requestId != null)
337
+ break;
338
+ }
339
+ }
340
+ if (requestId == null) {
341
+ // Get ALL captured requests to help debug (larger buffer to catch the request)
342
+ const allRequests = ctx.networkCapture.list(100, 'all');
343
+ // Build search terms from the where clause for relevance filtering
344
+ const searchTerms = [];
345
+ if (where.urlIncludes) {
346
+ // Split URL pattern into searchable terms (e.g., "/api/discovery/search" -> ["api", "discovery", "search"])
347
+ searchTerms.push(...where.urlIncludes.split(/[\/\-_.]/).filter(s => s.length > 2));
348
+ }
349
+ if (where.urlRegex) {
350
+ // Extract alphanumeric words from regex
351
+ searchTerms.push(...where.urlRegex.match(/[a-zA-Z]{3,}/g) || []);
352
+ }
353
+ // Find relevant requests (those that match any search term)
354
+ let relevantRequests = allRequests;
355
+ if (searchTerms.length > 0) {
356
+ relevantRequests = allRequests.filter(r => {
357
+ const urlLower = r.url.toLowerCase();
358
+ return searchTerms.some(term => urlLower.includes(term.toLowerCase()));
359
+ });
360
+ }
361
+ // If no relevant requests found, fall back to API requests, then all
362
+ let displayRequests = relevantRequests;
363
+ let filterDesc = `matching "${searchTerms.join('", "')}"`;
364
+ if (displayRequests.length === 0) {
365
+ displayRequests = allRequests.filter(r => r.resourceType === 'xhr' ||
366
+ r.resourceType === 'fetch' ||
367
+ /\/api\//i.test(r.url) ||
368
+ /graphql/i.test(r.url));
369
+ filterDesc = 'API/XHR';
370
+ }
371
+ if (displayRequests.length === 0) {
372
+ displayRequests = allRequests;
373
+ filterDesc = 'all';
374
+ }
375
+ const sampleUrls = displayRequests
376
+ .slice(-15)
377
+ .map(r => ` ${r.method} ${r.url}`)
378
+ .join('\n');
379
+ const debugInfo = displayRequests.length > 0
380
+ ? `\n\nCaptured requests (${filterDesc}, showing ${Math.min(displayRequests.length, 15)} of ${allRequests.length} total):\n${sampleUrls}`
381
+ : `\n\nNo requests captured (0 total). The request may not have been triggered yet.`;
382
+ const msg = `network_find: no request matched (where: ${JSON.stringify(where)}, pick: ${pick})${waitForMs > 0 ? ` within ${waitForMs}ms` : ''}. Ensure the request is triggered before this step (e.g. by navigation or a prior interaction), or increase waitForMs.${debugInfo}`;
383
+ console.warn(`[${step.id}] ${msg}`);
384
+ throw new Error(msg);
385
+ }
386
+ ctx.vars[step.params.saveAs] = requestId;
387
+ }
388
+ /**
389
+ * Executes a network_replay step
390
+ */
391
+ async function executeNetworkReplay(ctx, step) {
392
+ if (!ctx.networkCapture) {
393
+ throw new Error('network_replay requires an active browser session with network capture. Run the flow in a context that has network capture enabled.');
394
+ }
395
+ const requestId = step.params.requestId;
396
+ const overrides = step.params.overrides
397
+ ? {
398
+ url: step.params.overrides.url,
399
+ setQuery: step.params.overrides.setQuery,
400
+ setHeaders: step.params.overrides.setHeaders,
401
+ body: step.params.overrides.body,
402
+ urlReplace: step.params.overrides.urlReplace,
403
+ bodyReplace: step.params.overrides.bodyReplace,
404
+ }
405
+ : undefined;
406
+ let result;
407
+ try {
408
+ result = await ctx.networkCapture.replay(requestId, overrides);
409
+ }
410
+ catch (err) {
411
+ const msg = err instanceof Error ? err.message : String(err);
412
+ if (msg.includes('Request not found')) {
413
+ throw new Error(`${msg} The request may not have been captured yet. Ensure a network_find step runs before network_replay and triggers the request (e.g. by navigating or interacting first). Use waitForMs in network_find to wait for the request to appear (e.g. waitForMs: 10000).`);
414
+ }
415
+ throw err;
416
+ }
417
+ // Check for auth failure in network_replay response
418
+ if (ctx.authMonitor?.isEnabled() && ctx.currentStepId) {
419
+ // Get the original request URL from the captured entry
420
+ const entry = ctx.networkCapture.get(requestId);
421
+ const url = entry?.url || '';
422
+ if (ctx.authMonitor.isAuthFailure(url, result.status)) {
423
+ ctx.authMonitor.recordFailure({
424
+ url,
425
+ status: result.status,
426
+ stepId: ctx.currentStepId,
427
+ });
428
+ }
429
+ }
430
+ if (step.params.saveAs) {
431
+ ctx.vars[step.params.saveAs] = {
432
+ status: result.status,
433
+ contentType: result.contentType,
434
+ body: result.body,
435
+ bodySize: result.bodySize,
436
+ };
437
+ }
438
+ // Use 'path' with fallback to deprecated 'jsonPath' for backward compatibility
439
+ const pathExpr = step.params.response.path || step.params.response.jsonPath;
440
+ let outValue;
441
+ if (step.params.response.as === 'json') {
442
+ try {
443
+ outValue = JSON.parse(result.body);
444
+ }
445
+ catch {
446
+ throw new Error(`network_replay: response body is not valid JSON (status ${result.status})`);
447
+ }
448
+ if (pathExpr) {
449
+ const pathResult = getByPath(outValue, pathExpr);
450
+ outValue = pathResult.value;
451
+ // Store hint if path extraction had issues
452
+ if (pathResult.hint) {
453
+ ctx.vars['__jmespath_hint'] = pathResult.hint;
454
+ }
455
+ }
456
+ }
457
+ else {
458
+ if (pathExpr) {
459
+ const pathResult = getByPath(JSON.parse(result.body), pathExpr);
460
+ outValue = pathResult.value;
461
+ // Store hint if path extraction had issues
462
+ if (pathResult.hint) {
463
+ ctx.vars['__jmespath_hint'] = pathResult.hint;
464
+ }
465
+ }
466
+ else {
467
+ outValue = result.body;
468
+ }
469
+ if (typeof outValue === 'object' && outValue !== null) {
470
+ outValue = JSON.stringify(outValue);
471
+ }
472
+ }
473
+ ctx.collectibles[step.params.out] = outValue;
474
+ }
475
+ /**
476
+ * Executes a network_extract step (from var set by network_replay saveAs or similar)
477
+ */
478
+ async function executeNetworkExtract(ctx, step) {
479
+ // Check vars first, then collectibles (network_replay uses 'out' for collectibles, 'saveAs' for vars)
480
+ const raw = ctx.vars[step.params.fromVar] ?? ctx.collectibles[step.params.fromVar];
481
+ if (raw === undefined) {
482
+ throw new Error(`network_extract: var "${step.params.fromVar}" is not set (checked vars and collectibles)`);
483
+ }
484
+ // Replay saveAs stores { body, status, contentType, bodySize }; support that or raw string
485
+ const bodyStr = raw && typeof raw === 'object' && 'body' in raw && typeof raw.body === 'string'
486
+ ? raw.body
487
+ : typeof raw === 'string'
488
+ ? raw
489
+ : JSON.stringify(raw);
490
+ // Use 'path' with fallback to deprecated 'jsonPath' for backward compatibility
491
+ const pathExpr = step.params.path || step.params.jsonPath;
492
+ // Collect hints from JMESPath operations
493
+ const hints = [];
494
+ let value;
495
+ if (step.params.as === 'json') {
496
+ const parsed = JSON.parse(bodyStr);
497
+ if (pathExpr) {
498
+ const pathResult = getByPath(parsed, pathExpr);
499
+ value = pathResult.value;
500
+ if (pathResult.hint) {
501
+ hints.push(pathResult.hint);
502
+ }
503
+ }
504
+ else {
505
+ value = parsed;
506
+ }
507
+ // Note: JMESPath handles projections natively, e.g., "results[*].{id: id, name: name}"
508
+ // The deprecated 'transform' parameter is no longer needed
509
+ }
510
+ else {
511
+ if (pathExpr) {
512
+ const pathResult = getByPath(JSON.parse(bodyStr), pathExpr);
513
+ value = pathResult.value;
514
+ if (pathResult.hint) {
515
+ hints.push(pathResult.hint);
516
+ }
517
+ }
518
+ else {
519
+ value = bodyStr;
520
+ }
521
+ if (typeof value === 'object' && value !== null) {
522
+ value = JSON.stringify(value);
523
+ }
524
+ }
525
+ ctx.collectibles[step.params.out] = value;
526
+ // Store hints in a special variable for propagation to run results
527
+ if (hints.length > 0) {
528
+ const existingHints = ctx.vars['__jmespath_hints'] || [];
529
+ ctx.vars['__jmespath_hints'] = [...existingHints, ...hints];
530
+ }
531
+ }
532
+ /**
533
+ * Executes a select_option step
534
+ */
535
+ async function executeSelectOption(ctx, step) {
536
+ // Support both legacy selector and new target
537
+ const targetOrAnyOf = step.params.target ?? (step.params.selector ? selectorToTarget(step.params.selector) : null);
538
+ if (!targetOrAnyOf) {
539
+ throw new Error('SelectOption step must have either "target" or "selector"');
540
+ }
541
+ // Resolve target with fallback and scope
542
+ const { locator, matchedTarget, matchedCount } = await resolveTargetWithFallback(ctx.currentFrame ?? ctx.page, targetOrAnyOf, step.params.scope);
543
+ // Log matched target for diagnostics
544
+ if (step.params.hint) {
545
+ console.log(`[SelectOption:${step.id}] Matched target: ${JSON.stringify(matchedTarget)}, count: ${matchedCount}, hint: ${step.params.hint}`);
546
+ }
547
+ const target = step.params.first ?? true ? locator.first() : locator;
548
+ // Convert value to Playwright's selectOption format
549
+ const values = Array.isArray(step.params.value) ? step.params.value : [step.params.value];
550
+ const selectOptions = values.map(v => {
551
+ if (typeof v === 'string') {
552
+ return { value: v };
553
+ }
554
+ else if ('label' in v) {
555
+ return { label: v.label };
556
+ }
557
+ else if ('index' in v) {
558
+ return { index: v.index };
559
+ }
560
+ return v;
561
+ });
562
+ await target.selectOption(selectOptions);
563
+ }
564
+ /**
565
+ * Executes a press_key step
566
+ */
567
+ async function executePressKey(ctx, step) {
568
+ const times = step.params.times ?? 1;
569
+ const delayMs = step.params.delayMs ?? 0;
570
+ // If target is specified, focus it first
571
+ const targetOrAnyOf = step.params.target ?? (step.params.selector ? selectorToTarget(step.params.selector) : null);
572
+ if (targetOrAnyOf) {
573
+ const { locator, matchedTarget, matchedCount } = await resolveTargetWithFallback(ctx.currentFrame ?? ctx.page, targetOrAnyOf, step.params.scope);
574
+ if (step.params.hint) {
575
+ console.log(`[PressKey:${step.id}] Matched target: ${JSON.stringify(matchedTarget)}, count: ${matchedCount}, hint: ${step.params.hint}`);
576
+ }
577
+ await locator.first().focus();
578
+ }
579
+ // Press the key the specified number of times
580
+ // Note: keyboard is accessed from the page, not the frame
581
+ for (let i = 0; i < times; i++) {
582
+ await ctx.page.keyboard.press(step.params.key);
583
+ if (delayMs > 0 && i < times - 1) {
584
+ await sleepMs(delayMs);
585
+ }
586
+ }
587
+ }
588
+ /**
589
+ * Executes an upload_file step
590
+ */
591
+ async function executeUploadFile(ctx, step) {
592
+ const path = await import('path');
593
+ // Support both legacy selector and new target
594
+ const targetOrAnyOf = step.params.target ?? (step.params.selector ? selectorToTarget(step.params.selector) : null);
595
+ if (!targetOrAnyOf) {
596
+ throw new Error('UploadFile step must have either "target" or "selector"');
597
+ }
598
+ // Resolve target with fallback and scope
599
+ const { locator, matchedTarget, matchedCount } = await resolveTargetWithFallback(ctx.currentFrame ?? ctx.page, targetOrAnyOf, step.params.scope);
600
+ if (step.params.hint) {
601
+ console.log(`[UploadFile:${step.id}] Matched target: ${JSON.stringify(matchedTarget)}, count: ${matchedCount}, hint: ${step.params.hint}`);
602
+ }
603
+ const target = step.params.first ?? true ? locator.first() : locator;
604
+ // Resolve file paths (relative to pack directory if not absolute)
605
+ const files = Array.isArray(step.params.files) ? step.params.files : [step.params.files];
606
+ const resolvedFiles = files.map(f => {
607
+ if (path.isAbsolute(f)) {
608
+ return f;
609
+ }
610
+ return ctx.packDir ? path.join(ctx.packDir, f) : f;
611
+ });
612
+ await target.setInputFiles(resolvedFiles);
613
+ }
614
+ /**
615
+ * Executes a frame step
616
+ */
617
+ async function executeFrame(ctx, step) {
618
+ if (step.params.action === 'exit') {
619
+ // Return to main frame
620
+ ctx.currentFrame = undefined;
621
+ return;
622
+ }
623
+ // Enter frame
624
+ const frameSpec = step.params.frame;
625
+ let frame = null;
626
+ if (typeof frameSpec === 'string') {
627
+ // Try as name first, then as CSS selector
628
+ frame = ctx.page.frame(frameSpec);
629
+ if (!frame) {
630
+ // Try as CSS selector - get the frame from the iframe element
631
+ // We need to use elementHandle to get the actual frame
632
+ const iframeElement = await ctx.page.locator(frameSpec).first().elementHandle();
633
+ if (iframeElement) {
634
+ frame = await iframeElement.contentFrame();
635
+ }
636
+ }
637
+ }
638
+ else if ('name' in frameSpec) {
639
+ frame = ctx.page.frame({ name: frameSpec.name });
640
+ }
641
+ else if ('url' in frameSpec) {
642
+ frame = ctx.page.frame({ url: frameSpec.url });
643
+ }
644
+ if (!frame) {
645
+ throw new Error(`Frame not found: ${JSON.stringify(frameSpec)}`);
646
+ }
647
+ ctx.currentFrame = frame;
648
+ }
649
+ /**
650
+ * Executes a new_tab step
651
+ */
652
+ async function executeNewTab(ctx, step) {
653
+ if (!ctx.browserContext) {
654
+ throw new Error('new_tab requires a browser context. Make sure the runner provides browserContext in StepContext.');
655
+ }
656
+ const pages = ctx.browserContext.pages();
657
+ const currentTabIndex = pages.indexOf(ctx.page);
658
+ // Create new page
659
+ const newPage = await ctx.browserContext.newPage();
660
+ // Navigate if URL provided
661
+ if (step.params.url) {
662
+ try {
663
+ await newPage.goto(step.params.url, { waitUntil: 'networkidle' });
664
+ }
665
+ catch (err) {
666
+ if (err?.name === 'TimeoutError' || err?.message?.includes('Timeout')) {
667
+ // Page loaded but networkidle didn't settle — continue anyway.
668
+ }
669
+ else {
670
+ throw err;
671
+ }
672
+ }
673
+ }
674
+ // Save tab index if requested
675
+ if (step.params.saveTabIndexAs) {
676
+ const newPages = ctx.browserContext.pages();
677
+ ctx.vars[step.params.saveTabIndexAs] = newPages.indexOf(newPage);
678
+ }
679
+ // Store previous tab index and switch to new tab
680
+ ctx.previousTabIndex = currentTabIndex;
681
+ // Note: The runner should update ctx.page to newPage after this step
682
+ ctx.vars['__newPage'] = newPage;
683
+ }
684
+ /**
685
+ * Executes a switch_tab step
686
+ */
687
+ async function executeSwitchTab(ctx, step) {
688
+ if (!ctx.browserContext) {
689
+ throw new Error('switch_tab requires a browser context. Make sure the runner provides browserContext in StepContext.');
690
+ }
691
+ const pages = ctx.browserContext.pages();
692
+ const currentTabIndex = pages.indexOf(ctx.page);
693
+ let targetIndex;
694
+ if (step.params.tab === 'last') {
695
+ targetIndex = pages.length - 1;
696
+ }
697
+ else if (step.params.tab === 'previous') {
698
+ if (ctx.previousTabIndex === undefined) {
699
+ throw new Error('switch_tab: no previous tab to switch to');
700
+ }
701
+ targetIndex = ctx.previousTabIndex;
702
+ }
703
+ else {
704
+ targetIndex = step.params.tab;
705
+ }
706
+ if (targetIndex < 0 || targetIndex >= pages.length) {
707
+ throw new Error(`switch_tab: tab index ${targetIndex} out of range (0-${pages.length - 1})`);
708
+ }
709
+ const targetPage = pages[targetIndex];
710
+ // Close current tab if requested
711
+ if (step.params.closeCurrentTab) {
712
+ await ctx.page.close();
713
+ }
714
+ // Store previous tab index and switch
715
+ ctx.previousTabIndex = currentTabIndex;
716
+ // Note: The runner should update ctx.page to targetPage after this step
717
+ ctx.vars['__newPage'] = targetPage;
718
+ await targetPage.bringToFront();
719
+ }
720
+ /**
721
+ * Executes a single DSL step
722
+ */
723
+ export async function executeStep(ctx, step) {
724
+ switch (step.type) {
725
+ case 'navigate':
726
+ await executeNavigate(ctx, step);
727
+ break;
728
+ case 'extract_title':
729
+ await executeExtractTitle(ctx, step);
730
+ break;
731
+ case 'extract_text':
732
+ await executeExtractText(ctx, step);
733
+ break;
734
+ case 'extract_attribute':
735
+ await executeExtractAttribute(ctx, step);
736
+ break;
737
+ case 'sleep':
738
+ await executeSleep(ctx, step);
739
+ break;
740
+ case 'wait_for':
741
+ await executeWaitFor(ctx, step);
742
+ break;
743
+ case 'click':
744
+ await executeClick(ctx, step);
745
+ break;
746
+ case 'fill':
747
+ await executeFill(ctx, step);
748
+ break;
749
+ case 'assert':
750
+ await executeAssert(ctx, step);
751
+ break;
752
+ case 'set_var':
753
+ await executeSetVar(ctx, step);
754
+ break;
755
+ case 'network_find':
756
+ await executeNetworkFind(ctx, step);
757
+ break;
758
+ case 'network_replay':
759
+ await executeNetworkReplay(ctx, step);
760
+ break;
761
+ case 'network_extract':
762
+ await executeNetworkExtract(ctx, step);
763
+ break;
764
+ case 'select_option':
765
+ await executeSelectOption(ctx, step);
766
+ break;
767
+ case 'press_key':
768
+ await executePressKey(ctx, step);
769
+ break;
770
+ case 'upload_file':
771
+ await executeUploadFile(ctx, step);
772
+ break;
773
+ case 'frame':
774
+ await executeFrame(ctx, step);
775
+ break;
776
+ case 'new_tab':
777
+ await executeNewTab(ctx, step);
778
+ break;
779
+ case 'switch_tab':
780
+ await executeSwitchTab(ctx, step);
781
+ break;
782
+ default:
783
+ // TypeScript exhaustiveness check
784
+ const _exhaustive = step;
785
+ throw new Error(`Unknown step type: ${_exhaustive.type}`);
786
+ }
787
+ }