@rettangoli/vt 0.0.14 → 1.0.0-rc12

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.
@@ -1,18 +1,452 @@
1
+ function parseStepCommand(stepString) {
2
+ const tokens = stepString.trim().split(/\s+/).filter(Boolean);
3
+ const [command, ...args] = tokens;
4
+ return { command, args };
5
+ }
6
+
7
+ function parseNamedArgs(args) {
8
+ const named = {};
9
+ const positional = [];
10
+ args.forEach((token) => {
11
+ const separatorIndex = token.indexOf("=");
12
+ if (separatorIndex <= 0) {
13
+ positional.push(token);
14
+ return;
15
+ }
16
+ const key = token.slice(0, separatorIndex);
17
+ const value = token.slice(separatorIndex + 1);
18
+ named[key] = value;
19
+ });
20
+ return { named, positional };
21
+ }
22
+
23
+ function toNumber(value, fieldName) {
24
+ const parsed = Number(value);
25
+ if (!Number.isFinite(parsed)) {
26
+ throw new Error(`Invalid ${fieldName}: expected a finite number, got "${value}".`);
27
+ }
28
+ return parsed;
29
+ }
30
+
31
+ function toPositiveInteger(value, fieldName) {
32
+ const parsed = toNumber(value, fieldName);
33
+ if (!Number.isInteger(parsed) || parsed < 1) {
34
+ throw new Error(`Invalid ${fieldName}: expected an integer >= 1, got "${value}".`);
35
+ }
36
+ return parsed;
37
+ }
38
+
39
+ function parseTimeoutValue(value) {
40
+ if (value === undefined) {
41
+ return undefined;
42
+ }
43
+ const timeout = toNumber(value, "timeout");
44
+ if (timeout < 0) {
45
+ throw new Error(`Invalid timeout: expected >= 0, got ${timeout}.`);
46
+ }
47
+ return timeout;
48
+ }
49
+
50
+ function deepEqual(left, right) {
51
+ if (Object.is(left, right)) {
52
+ return true;
53
+ }
54
+ if (typeof left !== typeof right) {
55
+ return false;
56
+ }
57
+ if (left === null || right === null) {
58
+ return left === right;
59
+ }
60
+ if (typeof left !== "object") {
61
+ return left === right;
62
+ }
63
+
64
+ if (Array.isArray(left) || Array.isArray(right)) {
65
+ if (!Array.isArray(left) || !Array.isArray(right)) {
66
+ return false;
67
+ }
68
+ if (left.length !== right.length) {
69
+ return false;
70
+ }
71
+ for (let index = 0; index < left.length; index += 1) {
72
+ if (!deepEqual(left[index], right[index])) {
73
+ return false;
74
+ }
75
+ }
76
+ return true;
77
+ }
78
+
79
+ const leftKeys = Object.keys(left);
80
+ const rightKeys = Object.keys(right);
81
+ if (leftKeys.length !== rightKeys.length) {
82
+ return false;
83
+ }
84
+ for (const key of leftKeys) {
85
+ if (!Object.prototype.hasOwnProperty.call(right, key)) {
86
+ return false;
87
+ }
88
+ if (!deepEqual(left[key], right[key])) {
89
+ return false;
90
+ }
91
+ }
92
+ return true;
93
+ }
94
+
95
+ function isPlainObject(value) {
96
+ return value !== null && typeof value === "object" && !Array.isArray(value);
97
+ }
98
+
99
+ function formatValue(value) {
100
+ if (value === undefined) {
101
+ return "undefined";
102
+ }
103
+ if (typeof value === "string") {
104
+ return `"${value}"`;
105
+ }
106
+ try {
107
+ return JSON.stringify(value);
108
+ } catch {
109
+ return String(value);
110
+ }
111
+ }
112
+
113
+ function requireSelectedElement(command, selectedElement) {
114
+ if (!selectedElement) {
115
+ throw new Error(`\`${command}\` requires a \`select\` block target.`);
116
+ }
117
+ return selectedElement;
118
+ }
119
+
120
+ const WAIT_FOR_STATES = new Set(["attached", "detached", "visible", "hidden"]);
121
+
122
+ const STRUCTURED_ACTIONS = new Set([
123
+ "assert",
124
+ "blur",
125
+ "check",
126
+ "clear",
127
+ "click",
128
+ "customEvent",
129
+ "dblclick",
130
+ "focus",
131
+ "goto",
132
+ "hover",
133
+ "keypress",
134
+ "mouseDown",
135
+ "mouseUp",
136
+ "move",
137
+ "rclick",
138
+ "rightMouseDown",
139
+ "rightMouseUp",
140
+ "scroll",
141
+ "select",
142
+ "selectOption",
143
+ "setViewport",
144
+ "screenshot",
145
+ "uncheck",
146
+ "upload",
147
+ "wait",
148
+ "waitFor",
149
+ "write",
150
+ ]);
151
+
152
+ function assertStructuredKeys(stepObject, allowedKeys, actionName) {
153
+ const unknownKeys = Object.keys(stepObject).filter((key) => !allowedKeys.has(key));
154
+ if (unknownKeys.length > 0) {
155
+ throw new Error(
156
+ `Structured action "${actionName}" has unknown keys: ${unknownKeys.join(", ")}.`,
157
+ );
158
+ }
159
+ }
160
+
161
+ function requireStepAction(stepObject) {
162
+ if (!isPlainObject(stepObject)) {
163
+ throw new Error("Invalid step: expected an object.");
164
+ }
165
+ if (typeof stepObject.action !== "string" || stepObject.action.trim().length === 0) {
166
+ throw new Error("Structured step requires non-empty string `action`.");
167
+ }
168
+ const action = stepObject.action.trim();
169
+ if (!STRUCTURED_ACTIONS.has(action)) {
170
+ throw new Error(`Unknown structured action: "${action}".`);
171
+ }
172
+ return action;
173
+ }
174
+
175
+ function requireStructuredString(stepObject, key, actionName) {
176
+ const value = stepObject[key];
177
+ if (typeof value !== "string" || value.length === 0) {
178
+ throw new Error(`Structured action "${actionName}" requires non-empty string \`${key}\`.`);
179
+ }
180
+ return value;
181
+ }
182
+
183
+ function requireStructuredNumber(stepObject, key, actionName) {
184
+ const value = stepObject[key];
185
+ if (typeof value !== "number" || !Number.isFinite(value)) {
186
+ throw new Error(`Structured action "${actionName}" requires finite number \`${key}\`.`);
187
+ }
188
+ return value;
189
+ }
190
+
191
+ function optionalStructuredNumber(stepObject, key, actionName) {
192
+ if (!Object.prototype.hasOwnProperty.call(stepObject, key)) {
193
+ return undefined;
194
+ }
195
+ return requireStructuredNumber(stepObject, key, actionName);
196
+ }
197
+
198
+ function requireCoordinatesPair(stepObject, actionName) {
199
+ const hasX = Object.prototype.hasOwnProperty.call(stepObject, "x");
200
+ const hasY = Object.prototype.hasOwnProperty.call(stepObject, "y");
201
+ if (hasX !== hasY) {
202
+ throw new Error(`Structured action "${actionName}" requires both \`x\` and \`y\` together.`);
203
+ }
204
+ if (!hasX) {
205
+ return [];
206
+ }
207
+ const x = requireStructuredNumber(stepObject, "x", actionName);
208
+ const y = requireStructuredNumber(stepObject, "y", actionName);
209
+ return [String(x), String(y)];
210
+ }
211
+
212
+ function normalizeStructuredActionStep(stepObject) {
213
+ const action = requireStepAction(stepObject);
214
+
215
+ if (action === "assert") {
216
+ assertStructuredKeys(
217
+ stepObject,
218
+ new Set(["action", "type", "match", "selector", "timeoutMs", "value", "global", "fn", "args"]),
219
+ action,
220
+ );
221
+ const assertionConfig = { ...stepObject };
222
+ delete assertionConfig.action;
223
+ return { kind: "assert", assertionConfig };
224
+ }
225
+
226
+ if (action === "select") {
227
+ assertStructuredKeys(stepObject, new Set(["action", "testId", "steps"]), action);
228
+ const testId = requireStructuredString(stepObject, "testId", action);
229
+ if (!Array.isArray(stepObject.steps)) {
230
+ throw new Error('Structured action "select" requires array `steps`.');
231
+ }
232
+ const nestedSteps = stepObject.steps.map((nestedStep) => normalizeStepValue(nestedStep));
233
+ return { kind: "block", command: "select", args: [testId], nestedSteps };
234
+ }
235
+
236
+ if (action === "click" || action === "dblclick" || action === "hover" || action === "rclick") {
237
+ assertStructuredKeys(stepObject, new Set(["action", "x", "y"]), action);
238
+ return { kind: "command", command: action, args: requireCoordinatesPair(stepObject, action) };
239
+ }
240
+
241
+ if (action === "move") {
242
+ assertStructuredKeys(stepObject, new Set(["action", "x", "y"]), action);
243
+ const x = requireStructuredNumber(stepObject, "x", action);
244
+ const y = requireStructuredNumber(stepObject, "y", action);
245
+ return { kind: "command", command: action, args: [String(x), String(y)] };
246
+ }
247
+
248
+ if (action === "scroll") {
249
+ assertStructuredKeys(stepObject, new Set(["action", "deltaX", "deltaY"]), action);
250
+ const deltaX = requireStructuredNumber(stepObject, "deltaX", action);
251
+ const deltaY = requireStructuredNumber(stepObject, "deltaY", action);
252
+ return { kind: "command", command: action, args: [String(deltaX), String(deltaY)] };
253
+ }
254
+
255
+ if (action === "goto") {
256
+ assertStructuredKeys(stepObject, new Set(["action", "url"]), action);
257
+ return { kind: "command", command: action, args: [requireStructuredString(stepObject, "url", action)] };
258
+ }
259
+
260
+ if (action === "keypress") {
261
+ assertStructuredKeys(stepObject, new Set(["action", "key"]), action);
262
+ return { kind: "command", command: action, args: [requireStructuredString(stepObject, "key", action)] };
263
+ }
264
+
265
+ if (action === "wait") {
266
+ assertStructuredKeys(stepObject, new Set(["action", "ms"]), action);
267
+ const ms = requireStructuredNumber(stepObject, "ms", action);
268
+ return { kind: "command", command: action, args: [String(ms)] };
269
+ }
270
+
271
+ if (action === "setViewport") {
272
+ assertStructuredKeys(stepObject, new Set(["action", "width", "height"]), action);
273
+ const width = requireStructuredNumber(stepObject, "width", action);
274
+ const height = requireStructuredNumber(stepObject, "height", action);
275
+ return { kind: "command", command: action, args: [String(width), String(height)] };
276
+ }
277
+
278
+ if (action === "write") {
279
+ assertStructuredKeys(stepObject, new Set(["action", "value"]), action);
280
+ const value = stepObject.value;
281
+ if (typeof value !== "string") {
282
+ throw new Error('Structured action "write" requires string `value`.');
283
+ }
284
+ return { kind: "command", command: action, args: [value] };
285
+ }
286
+
287
+ if (action === "upload") {
288
+ assertStructuredKeys(stepObject, new Set(["action", "files"]), action);
289
+ if (!Array.isArray(stepObject.files) || stepObject.files.length === 0) {
290
+ throw new Error('Structured action "upload" requires non-empty array `files`.');
291
+ }
292
+ stepObject.files.forEach((filePath, index) => {
293
+ if (typeof filePath !== "string" || filePath.length === 0) {
294
+ throw new Error(
295
+ `Structured action "upload" requires each file path to be a non-empty string (index ${index}).`,
296
+ );
297
+ }
298
+ });
299
+ return { kind: "command", command: action, args: [...stepObject.files] };
300
+ }
301
+
302
+ if (action === "waitFor") {
303
+ assertStructuredKeys(stepObject, new Set(["action", "selector", "state", "timeoutMs"]), action);
304
+ const args = [];
305
+ if (Object.prototype.hasOwnProperty.call(stepObject, "selector")) {
306
+ const selector = requireStructuredString(stepObject, "selector", action);
307
+ args.push(`selector=${selector}`);
308
+ }
309
+ if (Object.prototype.hasOwnProperty.call(stepObject, "state")) {
310
+ const state = requireStructuredString(stepObject, "state", action);
311
+ if (!WAIT_FOR_STATES.has(state)) {
312
+ throw new Error(
313
+ `Structured action "waitFor" has invalid state "${state}". Supported: attached, detached, visible, hidden.`,
314
+ );
315
+ }
316
+ args.push(`state=${state}`);
317
+ }
318
+ const timeoutMs = optionalStructuredNumber(stepObject, "timeoutMs", action);
319
+ if (timeoutMs !== undefined) {
320
+ args.push(`timeoutMs=${timeoutMs}`);
321
+ }
322
+ return { kind: "command", command: action, args };
323
+ }
324
+
325
+ if (action === "selectOption") {
326
+ assertStructuredKeys(stepObject, new Set(["action", "value", "label", "index"]), action);
327
+ const hasValue = Object.prototype.hasOwnProperty.call(stepObject, "value");
328
+ const hasLabel = Object.prototype.hasOwnProperty.call(stepObject, "label");
329
+ const hasIndex = Object.prototype.hasOwnProperty.call(stepObject, "index");
330
+ const setCount = [hasValue, hasLabel, hasIndex].filter(Boolean).length;
331
+
332
+ if (setCount !== 1) {
333
+ throw new Error(
334
+ 'Structured action "selectOption" requires exactly one of `value`, `label`, or `index`.',
335
+ );
336
+ }
337
+
338
+ if (hasValue) {
339
+ return {
340
+ kind: "command",
341
+ command: action,
342
+ args: [`value=${requireStructuredString(stepObject, "value", action)}`],
343
+ };
344
+ }
345
+ if (hasLabel) {
346
+ return {
347
+ kind: "command",
348
+ command: action,
349
+ args: [`label=${requireStructuredString(stepObject, "label", action)}`],
350
+ };
351
+ }
352
+ return {
353
+ kind: "command",
354
+ command: action,
355
+ args: [`index=${requireStructuredNumber(stepObject, "index", action)}`],
356
+ };
357
+ }
358
+
359
+ if (action === "customEvent") {
360
+ assertStructuredKeys(stepObject, new Set(["action", "name", "detail"]), action);
361
+ const eventName = requireStructuredString(stepObject, "name", action);
362
+ const args = [eventName];
363
+ if (stepObject.detail !== undefined) {
364
+ if (!isPlainObject(stepObject.detail)) {
365
+ throw new Error('Structured action "customEvent" requires object `detail` when provided.');
366
+ }
367
+ Object.entries(stepObject.detail).forEach(([key, value]) => {
368
+ const formattedValue = typeof value === "string" ? value : JSON.stringify(value);
369
+ args.push(`${key}=${formattedValue}`);
370
+ });
371
+ }
372
+ return { kind: "command", command: action, args };
373
+ }
374
+
375
+ if (
376
+ action === "blur"
377
+ || action === "check"
378
+ || action === "clear"
379
+ || action === "focus"
380
+ || action === "mouseDown"
381
+ || action === "mouseUp"
382
+ || action === "rightMouseDown"
383
+ || action === "rightMouseUp"
384
+ || action === "screenshot"
385
+ || action === "uncheck"
386
+ ) {
387
+ assertStructuredKeys(stepObject, new Set(["action"]), action);
388
+ return { kind: "command", command: action, args: [] };
389
+ }
390
+
391
+ throw new Error(`Unknown structured action: "${action}".`);
392
+ }
393
+
394
+ function normalizeLegacyBlockStep(stepObject) {
395
+ const keys = Object.keys(stepObject);
396
+ if (keys.length !== 1) {
397
+ throw new Error(`Step object must have exactly one key, got ${keys.length}.`);
398
+ }
399
+ const [key] = keys;
400
+ if (key === "assert") {
401
+ return { kind: "assert", assertionConfig: stepObject.assert };
402
+ }
403
+
404
+ const nestedStepValues = stepObject[key];
405
+ if (!Array.isArray(nestedStepValues)) {
406
+ throw new Error(`Block step "${key}" must contain an array of nested steps.`);
407
+ }
408
+ const { command, args } = parseStepCommand(key);
409
+ const nestedSteps = nestedStepValues.map((nestedStep) => normalizeStepValue(nestedStep));
410
+ return { kind: "block", command, args, nestedSteps };
411
+ }
412
+
413
+ function normalizeStepValue(step) {
414
+ if (!isPlainObject(step)) {
415
+ throw new Error("Invalid step: expected an object.");
416
+ }
417
+ if (Object.prototype.hasOwnProperty.call(step, "action")) {
418
+ return normalizeStructuredActionStep(step);
419
+ }
420
+ return normalizeLegacyBlockStep(step);
421
+ }
422
+
1
423
  async function click(page, args, context, selectedElement) {
2
424
  if (selectedElement) {
3
425
  await selectedElement.click();
4
426
  } else if (args.length >= 2) {
5
- await page.mouse.click(Number(args[0]), Number(args[1]), { button: "left" });
427
+ await page.mouse.click(
428
+ toNumber(args[0], "x"),
429
+ toNumber(args[1], "y"),
430
+ { button: "left" },
431
+ );
6
432
  } else {
7
- console.warn('`click` command needs a `select` block or coordinates.');
433
+ throw new Error("`click` requires a `select` block target or `x y` coordinates.");
8
434
  }
9
435
  }
10
436
 
11
437
  async function customEvent(page, args) {
438
+ if (args.length === 0) {
439
+ throw new Error("`customEvent` requires an event name.");
440
+ }
12
441
  const [eventName, ...params] = args;
13
442
  const payload = {};
14
- params.forEach(param => {
15
- const [key, value] = param.split('=');
443
+ params.forEach((param) => {
444
+ const [key, value] = param.split("=");
445
+ if (!key || value === undefined) {
446
+ throw new Error(
447
+ `Invalid customEvent argument "${param}". Expected key=value.`,
448
+ );
449
+ }
16
450
  payload[key] = value;
17
451
  });
18
452
  await page.evaluate(({ eventName, payload }) => {
@@ -21,6 +455,9 @@ async function customEvent(page, args) {
21
455
  }
22
456
 
23
457
  async function goto(page, args) {
458
+ if (!args[0]) {
459
+ throw new Error("`goto` requires a URL argument.");
460
+ }
24
461
  await page.goto(args[0], { waitUntil: "networkidle" });
25
462
  // Normalize font rendering for consistent screenshots
26
463
  await page.addStyleTag({
@@ -35,6 +472,9 @@ async function goto(page, args) {
35
472
  }
36
473
 
37
474
  async function keypress(page, args) {
475
+ if (!args[0]) {
476
+ throw new Error("`keypress` requires a key argument.");
477
+ }
38
478
  await page.keyboard.press(args[0]);
39
479
  }
40
480
 
@@ -46,47 +486,352 @@ async function mouseUp(page) {
46
486
  await page.mouse.up();
47
487
  }
48
488
 
49
- async function rMouseDown(page){
489
+ async function rightMouseDown(page) {
50
490
  await page.mouse.down({ button: 'right' });
51
491
  }
52
492
 
53
- async function rMouseUp(page){
493
+ async function rightMouseUp(page) {
54
494
  await page.mouse.up({ button: 'right' });
55
495
  }
56
496
 
57
497
  async function move(page, args) {
58
- await page.mouse.move(Number(args[0]), Number(args[1]));
498
+ if (args.length < 2) {
499
+ throw new Error("`move` requires `x y` coordinates.");
500
+ }
501
+ await page.mouse.move(toNumber(args[0], "x"), toNumber(args[1], "y"));
59
502
  }
60
503
 
61
- async function scroll(page, args){
62
- await page.mouse.wheel(Number(args[0]), Number(args[1]));
504
+ async function scroll(page, args) {
505
+ if (args.length < 2) {
506
+ throw new Error("`scroll` requires `deltaX deltaY` values.");
507
+ }
508
+ await page.mouse.wheel(toNumber(args[0], "deltaX"), toNumber(args[1], "deltaY"));
63
509
  }
64
510
 
65
511
  async function rclick(page, args, context, selectedElement) {
66
512
  if (selectedElement) {
67
- await selectedElement.click({ button: 'right' });
513
+ await selectedElement.click({ button: "right" });
68
514
  } else if (args.length >= 2) {
69
- await page.mouse.click(Number(args[0]), Number(args[1]), { button: "right" });
515
+ await page.mouse.click(
516
+ toNumber(args[0], "x"),
517
+ toNumber(args[1], "y"),
518
+ { button: "right" },
519
+ );
70
520
  } else {
71
- console.warn('`rclick` command needs a `select` block or coordinates.');
521
+ throw new Error("`rclick` requires a `select` block target or `x y` coordinates.");
72
522
  }
73
523
  }
74
524
 
75
525
  async function wait(page, args) {
76
- await page.waitForTimeout(Number(args[0]));
526
+ if (!args[0]) {
527
+ throw new Error("`wait` requires a millisecond duration.");
528
+ }
529
+ await page.waitForTimeout(toNumber(args[0], "ms"));
530
+ }
531
+
532
+ async function setViewport(page, args) {
533
+ if (args.length < 2) {
534
+ throw new Error("`setViewport` requires `width height`.");
535
+ }
536
+ await page.setViewportSize({
537
+ width: toPositiveInteger(args[0], "width"),
538
+ height: toPositiveInteger(args[1], "height"),
539
+ });
77
540
  }
78
541
 
79
542
  async function write(page, args, context, selectedElement) {
543
+ const target = requireSelectedElement("write", selectedElement);
544
+ const textToWrite = args.join(" ");
545
+ await target.fill(textToWrite);
546
+ }
547
+
548
+ async function hover(page, args, context, selectedElement) {
80
549
  if (selectedElement) {
81
- const textToWrite = args.join(' ');
82
- await selectedElement.fill(textToWrite);
83
- } else {
84
- console.warn('`write` command called without a `select` block.');
550
+ await selectedElement.hover();
551
+ return;
85
552
  }
553
+ if (args.length >= 2) {
554
+ await page.mouse.move(toNumber(args[0], "x"), toNumber(args[1], "y"));
555
+ return;
556
+ }
557
+ throw new Error("`hover` requires a `select` block target or `x y` coordinates.");
558
+ }
559
+
560
+ async function dblclick(page, args, context, selectedElement) {
561
+ if (selectedElement) {
562
+ await selectedElement.dblclick();
563
+ return;
564
+ }
565
+ if (args.length >= 2) {
566
+ await page.mouse.dblclick(toNumber(args[0], "x"), toNumber(args[1], "y"));
567
+ return;
568
+ }
569
+ throw new Error("`dblclick` requires a `select` block target or `x y` coordinates.");
570
+ }
571
+
572
+ async function focus(page, args, context, selectedElement) {
573
+ const target = requireSelectedElement("focus", selectedElement);
574
+ await target.focus();
575
+ }
576
+
577
+ async function blur(page, args, context, selectedElement) {
578
+ const target = requireSelectedElement("blur", selectedElement);
579
+ await target.evaluate((element) => element.blur());
580
+ }
581
+
582
+ async function clear(page, args, context, selectedElement) {
583
+ const target = requireSelectedElement("clear", selectedElement);
584
+ await target.fill("");
585
+ }
586
+
587
+ async function check(page, args, context, selectedElement) {
588
+ const target = requireSelectedElement("check", selectedElement);
589
+ await target.check();
590
+ }
591
+
592
+ async function uncheck(page, args, context, selectedElement) {
593
+ const target = requireSelectedElement("uncheck", selectedElement);
594
+ await target.uncheck();
595
+ }
596
+
597
+ async function selectOption(page, args, context, selectedElement) {
598
+ const target = requireSelectedElement("selectOption", selectedElement);
599
+ const { named, positional } = parseNamedArgs(args);
600
+
601
+ const hasNamed =
602
+ named.value !== undefined
603
+ || named.label !== undefined
604
+ || named.index !== undefined;
605
+
606
+ if (hasNamed) {
607
+ const option = {};
608
+ if (named.value !== undefined) {
609
+ option.value = named.value;
610
+ }
611
+ if (named.label !== undefined) {
612
+ option.label = named.label;
613
+ }
614
+ if (named.index !== undefined) {
615
+ option.index = toNumber(named.index, "index");
616
+ }
617
+ await target.selectOption(option);
618
+ return;
619
+ }
620
+
621
+ if (positional.length === 0) {
622
+ throw new Error(
623
+ "`selectOption` requires an option value/label or key=value args (value=, label=, index=).",
624
+ );
625
+ }
626
+ await target.selectOption(positional[0]);
627
+ }
628
+
629
+ async function upload(page, args, context, selectedElement) {
630
+ const target = requireSelectedElement("upload", selectedElement);
631
+ const files = args.filter((token) => token.length > 0);
632
+ if (files.length === 0) {
633
+ throw new Error("`upload` requires one or more file paths.");
634
+ }
635
+ await target.setInputFiles(files);
636
+ }
637
+
638
+ async function waitFor(page, args, context, selectedElement) {
639
+ const { named, positional } = parseNamedArgs(args);
640
+ const state = named.state ?? positional[1] ?? "visible";
641
+ const timeout = parseTimeoutValue(named.timeoutMs ?? named.timeout ?? positional[2]);
642
+ const waitOptions = { state };
643
+ if (timeout !== undefined) {
644
+ waitOptions.timeout = timeout;
645
+ }
646
+
647
+ if (selectedElement) {
648
+ await selectedElement.waitFor(waitOptions);
649
+ return;
650
+ }
651
+
652
+ const selector = named.selector ?? positional[0];
653
+ if (!selector) {
654
+ throw new Error(
655
+ "`waitFor` requires a selector (or a selected element in a `select` block).",
656
+ );
657
+ }
658
+ await page.waitForSelector(selector, waitOptions);
659
+ }
660
+
661
+ function requireAssertType(assertionConfig) {
662
+ if (!isPlainObject(assertionConfig)) {
663
+ throw new Error("Structured assert step must be an object.");
664
+ }
665
+ const { type } = assertionConfig;
666
+ if (typeof type !== "string" || type.trim().length === 0) {
667
+ throw new Error("Structured assert step requires a non-empty `type`.");
668
+ }
669
+ return type;
670
+ }
671
+
672
+ function requireMatchMode(assertionConfig, defaultMode = "includes") {
673
+ const mode = assertionConfig.match ?? defaultMode;
674
+ if (mode !== "includes" && mode !== "equals") {
675
+ throw new Error(`Unsupported assert match mode "${mode}". Supported: includes, equals.`);
676
+ }
677
+ return mode;
678
+ }
679
+
680
+ async function assertStructured(page, assertionConfig, selectedElement) {
681
+ const type = requireAssertType(assertionConfig);
682
+
683
+ if (type === "url") {
684
+ if (typeof assertionConfig.value !== "string" || assertionConfig.value.length === 0) {
685
+ throw new Error("`assert.type=url` requires non-empty string `value`.");
686
+ }
687
+ const currentUrl = page.url();
688
+ const expected = assertionConfig.value;
689
+ const matchMode = requireMatchMode(assertionConfig);
690
+ const ok = matchMode === "equals"
691
+ ? currentUrl === expected
692
+ : currentUrl.includes(expected);
693
+ if (!ok) {
694
+ throw new Error(
695
+ `assert url failed: expected "${currentUrl}" to ${matchMode} "${expected}".`,
696
+ );
697
+ }
698
+ return;
699
+ }
700
+
701
+ if (type === "exists") {
702
+ const timeout = parseTimeoutValue(assertionConfig.timeoutMs);
703
+ if (selectedElement && assertionConfig.selector === undefined) {
704
+ const count = await selectedElement.count();
705
+ if (count < 1) {
706
+ throw new Error("assert exists failed: selected element was not found.");
707
+ }
708
+ return;
709
+ }
710
+
711
+ if (typeof assertionConfig.selector !== "string" || assertionConfig.selector.length === 0) {
712
+ throw new Error("`assert.type=exists` requires `selector` when not in a select block.");
713
+ }
714
+ const locator = page.locator(assertionConfig.selector);
715
+ if (timeout !== undefined) {
716
+ await locator.first().waitFor({ state: "attached", timeout });
717
+ }
718
+ const count = await locator.count();
719
+ if (count < 1) {
720
+ throw new Error(`assert exists failed: selector "${assertionConfig.selector}" matched 0 elements.`);
721
+ }
722
+ return;
723
+ }
724
+
725
+ if (type === "visible" || type === "hidden") {
726
+ const timeout = parseTimeoutValue(assertionConfig.timeoutMs);
727
+ const waitOptions = { state: type };
728
+ if (timeout !== undefined) {
729
+ waitOptions.timeout = timeout;
730
+ }
731
+
732
+ if (selectedElement && assertionConfig.selector === undefined) {
733
+ await selectedElement.waitFor(waitOptions);
734
+ return;
735
+ }
736
+
737
+ if (typeof assertionConfig.selector !== "string" || assertionConfig.selector.length === 0) {
738
+ throw new Error(`\`assert.type=${type}\` requires \`selector\` when not in a select block.`);
739
+ }
740
+
741
+ await page.waitForSelector(assertionConfig.selector, waitOptions);
742
+ return;
743
+ }
744
+
745
+ if (type === "text") {
746
+ const expected = assertionConfig.value;
747
+ if (typeof expected !== "string") {
748
+ throw new Error("`assert.type=text` requires string `value`.");
749
+ }
750
+ const matchMode = requireMatchMode(assertionConfig);
751
+
752
+ let actualText = "";
753
+ if (selectedElement && assertionConfig.selector === undefined) {
754
+ actualText = (await selectedElement.textContent()) ?? "";
755
+ } else {
756
+ if (typeof assertionConfig.selector !== "string" || assertionConfig.selector.length === 0) {
757
+ throw new Error("`assert.type=text` requires `selector` when not in a select block.");
758
+ }
759
+ actualText = (await page.locator(assertionConfig.selector).first().textContent()) ?? "";
760
+ }
761
+
762
+ const ok = matchMode === "equals"
763
+ ? actualText === expected
764
+ : actualText.includes(expected);
765
+ if (!ok) {
766
+ throw new Error(
767
+ `assert text failed: expected "${actualText}" to ${matchMode} "${expected}".`,
768
+ );
769
+ }
770
+ return;
771
+ }
772
+
773
+ if (type === "js") {
774
+ const hasGlobal = typeof assertionConfig.global === "string" && assertionConfig.global.length > 0;
775
+ const hasFn = typeof assertionConfig.fn === "string" && assertionConfig.fn.length > 0;
776
+ if (hasGlobal === hasFn) {
777
+ throw new Error("`assert.type=js` requires exactly one of `global` or `fn`.");
778
+ }
779
+ if (!Object.prototype.hasOwnProperty.call(assertionConfig, "value")) {
780
+ throw new Error("`assert.type=js` requires `value`.");
781
+ }
782
+ const args = assertionConfig.args ?? [];
783
+ if (!Array.isArray(args)) {
784
+ throw new Error("`assert.type=js` expects `args` to be an array when provided.");
785
+ }
786
+
787
+ let actual;
788
+ try {
789
+ actual = await page.evaluate(async ({ globalPath, fnPath, fnArgs }) => {
790
+ const resolvePath = (root, dottedPath) => {
791
+ return dottedPath.split(".").reduce((acc, key) => {
792
+ if (acc === null || acc === undefined) {
793
+ return undefined;
794
+ }
795
+ return acc[key];
796
+ }, root);
797
+ };
798
+
799
+ if (globalPath) {
800
+ return resolvePath(window, globalPath);
801
+ }
802
+
803
+ const fn = resolvePath(window, fnPath);
804
+ if (typeof fn !== "function") {
805
+ throw new Error(`Expected function at window.${fnPath}.`);
806
+ }
807
+ return await fn(...fnArgs);
808
+ }, {
809
+ globalPath: hasGlobal ? assertionConfig.global : null,
810
+ fnPath: hasFn ? assertionConfig.fn : null,
811
+ fnArgs: args,
812
+ });
813
+ } catch (error) {
814
+ throw new Error(`assert js failed: ${error?.message ?? String(error)}.`);
815
+ }
816
+
817
+ if (!deepEqual(actual, assertionConfig.value)) {
818
+ throw new Error(
819
+ `assert js failed: expected ${formatValue(assertionConfig.value)}, got ${formatValue(actual)}.`,
820
+ );
821
+ }
822
+ return;
823
+ }
824
+
825
+ throw new Error(
826
+ `Unsupported assert type "${type}". Supported: url, exists, visible, hidden, text, js.`,
827
+ );
86
828
  }
87
829
 
88
830
  async function select(page, args) {
89
831
  const testId = args[0];
832
+ if (!testId) {
833
+ throw new Error("`select` requires a test id.");
834
+ }
90
835
  const hostElementLocator = page.getByTestId(testId);
91
836
 
92
837
  const interactiveElementLocator = hostElementLocator.locator(
@@ -103,61 +848,82 @@ async function select(page, args) {
103
848
  }
104
849
 
105
850
  export function createSteps(page, context) {
106
- let screenshotIndex = 0;
107
-
108
851
  async function screenshot() {
109
- screenshotIndex++;
110
- const screenshotPath = await context.takeAndSaveScreenshot(page, `${context.baseName}-${screenshotIndex}`);
852
+ const screenshotPath = await context.takeAndSaveScreenshot(page, context.baseName);
111
853
  console.log(`Screenshot saved: ${screenshotPath}`);
112
854
  }
113
855
 
114
856
  const actionHandlers = {
857
+ blur,
858
+ check,
859
+ clear,
115
860
  click,
116
861
  customEvent,
862
+ dblclick,
863
+ focus,
117
864
  goto,
865
+ hover,
118
866
  keypress,
119
867
  mouseDown,
120
868
  mouseUp,
121
869
  move,
122
870
  rclick,
871
+ rightMouseDown,
872
+ rightMouseUp,
123
873
  scroll,
124
- rMouseDown,
125
- rMouseUp,
874
+ setViewport,
126
875
  screenshot,
127
876
  select,
877
+ selectOption,
878
+ uncheck,
879
+ upload,
128
880
  wait,
881
+ waitFor,
129
882
  write,
130
883
  };
131
884
 
132
- async function executeSingleStep(stepString, selectedElement) {
133
- const [command, ...args] = stepString.split(" ");
885
+ async function executeCommand(command, args, selectedElement) {
886
+ if (!command) {
887
+ return;
888
+ }
134
889
  const actionFn = actionHandlers[command];
135
890
  if (actionFn) {
136
891
  await actionFn(page, args, context, selectedElement);
137
892
  } else {
138
- console.warn(`Unknown step command: "${command}"`);
893
+ throw new Error(`Unknown step command: "${command}"`);
139
894
  }
140
895
  }
141
896
 
897
+ async function executeNormalizedStep(normalizedStep, selectedElement) {
898
+ if (normalizedStep.kind === "assert") {
899
+ await assertStructured(page, normalizedStep.assertionConfig, selectedElement);
900
+ return;
901
+ }
902
+
903
+ if (normalizedStep.kind === "block") {
904
+ const { command, args, nestedSteps } = normalizedStep;
905
+ const blockFn = actionHandlers[command];
906
+ if (!blockFn) {
907
+ throw new Error(`Unsupported block command: "${command}".`);
908
+ }
909
+ const blockSelectedElement = await blockFn(page, args, context, null);
910
+ for (const nestedStep of nestedSteps) {
911
+ await executeNormalizedStep(nestedStep, blockSelectedElement);
912
+ }
913
+ return;
914
+ }
915
+
916
+ await executeCommand(normalizedStep.command, normalizedStep.args, selectedElement);
917
+ }
918
+
919
+ async function executeStepValue(step, selectedElement) {
920
+ const normalizedStep = normalizeStepValue(step);
921
+ await executeNormalizedStep(normalizedStep, selectedElement);
922
+ }
923
+
142
924
  return {
143
925
  async executeStep(step) {
144
- if (typeof step === 'string') {
145
- await executeSingleStep(step, null);
146
- } else if (typeof step === 'object' && step !== null) {
147
- const blockCommandString = Object.keys(step)[0];
148
- const nestedStepStrings = step[blockCommandString];
149
- const [command, ...args] = blockCommandString.split(" ");
150
-
151
- const blockFn = actionHandlers[command];
152
- if (blockFn) {
153
- const selectedElement = await blockFn(page, args, context, null);
154
- for (const nestedStep of nestedStepStrings) {
155
- await executeSingleStep(nestedStep, selectedElement);
156
- }
157
- } else {
158
- console.warn(`Unsupported block command: "${command}".`);
159
- }
160
- }
926
+ await executeStepValue(step, null);
161
927
  }
162
928
  };
163
- }
929
+ }