kryptheon 0.1.3 → 0.1.5
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 +132 -86
- package/bin/kryptheon.js +1309 -1179
- package/kryptheon-reporter.js +797 -783
- package/package.json +10 -2
package/bin/kryptheon.js
CHANGED
|
@@ -1,1179 +1,1309 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// The kryptheon CLI. A thin wrapper so nobody has to remember Playwright's
|
|
3
|
-
// command line or edit a config file. Everything here shells out to the
|
|
4
|
-
// Playwright binary that ships with this package.
|
|
5
|
-
//
|
|
6
|
-
// Two roots matter, and they are not the same once this is installed:
|
|
7
|
-
// PACKAGE_DIR - the tool's own files (fixture, reporter, config, this file)
|
|
8
|
-
// USER_DIR - the folder the command was run in, which owns tests/,
|
|
9
|
-
// kryptheon-baselines.json, kryptheon-history.jsonl and .env
|
|
10
|
-
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const { spawn, spawnSync } = require('child_process');
|
|
14
|
-
const replay = require('../kryptheon-replay.js');
|
|
15
|
-
|
|
16
|
-
const PACKAGE_DIR = path.join(__dirname, '..');
|
|
17
|
-
const USER_DIR = process.cwd();
|
|
18
|
-
const CONFIG = path.join(PACKAGE_DIR, 'playwright.config.js');
|
|
19
|
-
const TESTS_DIR = path.join(USER_DIR, 'tests');
|
|
20
|
-
|
|
21
|
-
const MIN_NODE = [20, 6, 0];
|
|
22
|
-
|
|
23
|
-
// How long to wait for a browser window to appear before giving up.
|
|
24
|
-
const WINDOW_POLL_MS = 3000;
|
|
25
|
-
const WINDOW_TIMEOUT_MS = 30000;
|
|
26
|
-
|
|
27
|
-
function nodeIsTooOld(version) {
|
|
28
|
-
const parts = String(version || process.versions.node).split('.').map(Number);
|
|
29
|
-
for (let i = 0; i < MIN_NODE.length; i++) {
|
|
30
|
-
if ((parts[i] || 0) > MIN_NODE[i]) return false;
|
|
31
|
-
if ((parts[i] || 0) < MIN_NODE[i]) return true;
|
|
32
|
-
}
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function usage() {
|
|
37
|
-
console.log('');
|
|
38
|
-
console.log(' kryptheon - record and check your app');
|
|
39
|
-
console.log('');
|
|
40
|
-
console.log(' kryptheon record <url> open your app and record what you do as a test');
|
|
41
|
-
console.log(' kryptheon check run every recorded test and report in plain language');
|
|
42
|
-
console.log(' kryptheon accept <name> agree that one test\'s new result is the correct one');
|
|
43
|
-
console.log('');
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
console.error('');
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
status:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
console.log('');
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
console.error('');
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
res.on('
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
});
|
|
174
|
-
req.on('
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
host === '
|
|
200
|
-
host === '
|
|
201
|
-
host
|
|
202
|
-
host
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (code === '
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (code
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
console.error('
|
|
243
|
-
console.error('
|
|
244
|
-
} else if (
|
|
245
|
-
console.error('
|
|
246
|
-
console.error('');
|
|
247
|
-
|
|
248
|
-
console.error('
|
|
249
|
-
console.error('');
|
|
250
|
-
console.error('
|
|
251
|
-
|
|
252
|
-
console.error('
|
|
253
|
-
console.error('
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (/
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
console.error('
|
|
273
|
-
console.error('');
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
console.error('
|
|
280
|
-
console.error('
|
|
281
|
-
console.error('');
|
|
282
|
-
console.error('
|
|
283
|
-
console.error('');
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
'
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
.
|
|
348
|
-
.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return null;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
console.error('
|
|
489
|
-
console.error('
|
|
490
|
-
console.error('');
|
|
491
|
-
console.error('
|
|
492
|
-
console.error('
|
|
493
|
-
console.error('');
|
|
494
|
-
console.error('
|
|
495
|
-
console.error('');
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
console.error('');
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
console.log('');
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
console.log('');
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
console.log('
|
|
699
|
-
console.log('');
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
console.log('
|
|
707
|
-
console.log('
|
|
708
|
-
console.log('');
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
console.error('
|
|
719
|
-
console.error('
|
|
720
|
-
console.error('');
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
console.log('
|
|
756
|
-
console.log('
|
|
757
|
-
console.log('');
|
|
758
|
-
console.log('
|
|
759
|
-
console.log('');
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if (child
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
//
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
console.log('');
|
|
928
|
-
console.log('
|
|
929
|
-
console.log('');
|
|
930
|
-
console.log('
|
|
931
|
-
console.log('
|
|
932
|
-
console.log('');
|
|
933
|
-
console.log('
|
|
934
|
-
console.log('');
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
console.log('');
|
|
943
|
-
console.log('
|
|
944
|
-
console.log('
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
console.log('');
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
console.log('
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
console.log('
|
|
967
|
-
console.log('
|
|
968
|
-
console.log('');
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
//
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
console.
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
//
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
const
|
|
1043
|
-
const
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
);
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
console.error('
|
|
1125
|
-
console.error('');
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The kryptheon CLI. A thin wrapper so nobody has to remember Playwright's
|
|
3
|
+
// command line or edit a config file. Everything here shells out to the
|
|
4
|
+
// Playwright binary that ships with this package.
|
|
5
|
+
//
|
|
6
|
+
// Two roots matter, and they are not the same once this is installed:
|
|
7
|
+
// PACKAGE_DIR - the tool's own files (fixture, reporter, config, this file)
|
|
8
|
+
// USER_DIR - the folder the command was run in, which owns tests/,
|
|
9
|
+
// kryptheon-baselines.json, kryptheon-history.jsonl and .env
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { spawn, spawnSync } = require('child_process');
|
|
14
|
+
const replay = require('../kryptheon-replay.js');
|
|
15
|
+
|
|
16
|
+
const PACKAGE_DIR = path.join(__dirname, '..');
|
|
17
|
+
const USER_DIR = process.cwd();
|
|
18
|
+
const CONFIG = path.join(PACKAGE_DIR, 'playwright.config.js');
|
|
19
|
+
const TESTS_DIR = path.join(USER_DIR, 'tests');
|
|
20
|
+
|
|
21
|
+
const MIN_NODE = [20, 6, 0];
|
|
22
|
+
|
|
23
|
+
// How long to wait for a browser window to appear before giving up.
|
|
24
|
+
const WINDOW_POLL_MS = 3000;
|
|
25
|
+
const WINDOW_TIMEOUT_MS = 30000;
|
|
26
|
+
|
|
27
|
+
function nodeIsTooOld(version) {
|
|
28
|
+
const parts = String(version || process.versions.node).split('.').map(Number);
|
|
29
|
+
for (let i = 0; i < MIN_NODE.length; i++) {
|
|
30
|
+
if ((parts[i] || 0) > MIN_NODE[i]) return false;
|
|
31
|
+
if ((parts[i] || 0) < MIN_NODE[i]) return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function usage() {
|
|
37
|
+
console.log('');
|
|
38
|
+
console.log(' kryptheon - record and check your app');
|
|
39
|
+
console.log('');
|
|
40
|
+
console.log(' kryptheon record <url> open your app and record what you do as a test');
|
|
41
|
+
console.log(' kryptheon check run every recorded test and report in plain language');
|
|
42
|
+
console.log(' kryptheon accept <name> agree that one test\'s new result is the correct one');
|
|
43
|
+
console.log(' kryptheon setup-ai tell your AI assistant to check its work');
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log(' add --quiet to check for one line when everything passes');
|
|
46
|
+
console.log('');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Resolve Playwright's own CLI and run it on this Node binary, rather than
|
|
50
|
+
// relying on a `playwright` executable being on PATH.
|
|
51
|
+
// The package's "exports" map exposes "./cli" (no .js), so that specifier is
|
|
52
|
+
// the one that resolves; the package.json route is the fallback.
|
|
53
|
+
function findPlaywrightCli() {
|
|
54
|
+
try {
|
|
55
|
+
return require.resolve('@playwright/test/cli', { paths: [PACKAGE_DIR] });
|
|
56
|
+
} catch (err) {
|
|
57
|
+
/* fall through */
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const pkg = require.resolve('@playwright/test/package.json', { paths: [PACKAGE_DIR] });
|
|
61
|
+
return path.join(path.dirname(pkg), 'cli.js');
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Always runs with the user's folder as the working directory, so tests,
|
|
68
|
+
// baselines and .env are found where they actually live.
|
|
69
|
+
// Returns { status, stderr }. With quietErrors the child's stderr is captured
|
|
70
|
+
// rather than inherited, so a Node or Playwright stack trace never reaches the
|
|
71
|
+
// person running the command.
|
|
72
|
+
function runPlaywright(args, options) {
|
|
73
|
+
const quiet = !!(options && options.quietErrors);
|
|
74
|
+
const cli = findPlaywrightCli();
|
|
75
|
+
if (!cli) {
|
|
76
|
+
console.error('');
|
|
77
|
+
console.error(' The testing engine is missing from this install.');
|
|
78
|
+
console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
|
|
79
|
+
console.error('');
|
|
80
|
+
return { status: 1, stderr: '' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = spawnSync(process.execPath, [cli].concat(args), {
|
|
84
|
+
cwd: USER_DIR,
|
|
85
|
+
stdio: quiet ? ['inherit', 'inherit', 'pipe'] : 'inherit',
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (result.error) {
|
|
90
|
+
console.error('');
|
|
91
|
+
console.error(' Could not start the testing engine: ' + result.error.message);
|
|
92
|
+
console.error('');
|
|
93
|
+
return { status: 1, stderr: String(result.stderr || '') };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
status: result.status === null ? 1 : result.status,
|
|
97
|
+
stderr: String(result.stderr || ''),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Chromium is a separate download from the npm package, so the first run on a
|
|
102
|
+
// new machine has to fetch it.
|
|
103
|
+
function ensureBrowser() {
|
|
104
|
+
let executable = null;
|
|
105
|
+
try {
|
|
106
|
+
executable = require('@playwright/test').chromium.executablePath();
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return true; // cannot tell - let Playwright speak for itself
|
|
109
|
+
}
|
|
110
|
+
if (executable && fs.existsSync(executable)) return true;
|
|
111
|
+
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log(' Downloading a browser to run your app in.');
|
|
114
|
+
console.log(' This is about 200MB and only happens once.');
|
|
115
|
+
console.log('');
|
|
116
|
+
const status = runPlaywright(['install', 'chromium']).status;
|
|
117
|
+
if (status !== 0) {
|
|
118
|
+
console.error('');
|
|
119
|
+
console.error(' The browser download did not finish.');
|
|
120
|
+
console.error(' Check your internet connection and try again.');
|
|
121
|
+
console.error('');
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function listSpecFiles() {
|
|
128
|
+
if (!fs.existsSync(TESTS_DIR)) return [];
|
|
129
|
+
let entries = [];
|
|
130
|
+
try {
|
|
131
|
+
entries = fs.readdirSync(TESTS_DIR, { recursive: true });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
try {
|
|
134
|
+
entries = fs.readdirSync(TESTS_DIR);
|
|
135
|
+
} catch (err2) {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return entries
|
|
140
|
+
.map(String)
|
|
141
|
+
.filter((name) => /\.(spec|test)\.(c|m)?[jt]sx?$/.test(name));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A plain GET on Node's own http/https, deliberately NOT fetch. fetch keeps
|
|
145
|
+
// its sockets alive in a pool, and those were still open at exit - handles
|
|
146
|
+
// being torn down while the process is ending is how libuv assertions happen
|
|
147
|
+
// on Windows. agent:false closes the socket as soon as we are done with it.
|
|
148
|
+
function httpGet(url, timeoutMs, redirectsLeft) {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
let parsed;
|
|
151
|
+
try {
|
|
152
|
+
parsed = new URL(url);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return reject(Object.assign(new Error('bad address'), { code: 'ERR_INVALID_URL' }));
|
|
155
|
+
}
|
|
156
|
+
const lib = parsed.protocol === 'https:' ? require('https') : require('http');
|
|
157
|
+
const req = lib.get(url, { agent: false, timeout: timeoutMs || 8000 }, (res) => {
|
|
158
|
+
const location = res.headers && res.headers.location;
|
|
159
|
+
if (location && res.statusCode >= 300 && res.statusCode < 400 && (redirectsLeft || 0) > 0) {
|
|
160
|
+
res.resume();
|
|
161
|
+
req.destroy();
|
|
162
|
+
return resolve(httpGet(new URL(location, url).toString(), timeoutMs, redirectsLeft - 1));
|
|
163
|
+
}
|
|
164
|
+
let body = '';
|
|
165
|
+
res.setEncoding('utf8');
|
|
166
|
+
res.on('data', (chunk) => {
|
|
167
|
+
if (body.length < 300000) body += chunk;
|
|
168
|
+
});
|
|
169
|
+
res.on('end', () => {
|
|
170
|
+
req.destroy();
|
|
171
|
+
resolve({ status: res.statusCode, body: body });
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
req.on('timeout', () => {
|
|
175
|
+
req.destroy(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }));
|
|
176
|
+
});
|
|
177
|
+
req.on('error', reject);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- is the address actually there? ----------------------------------------
|
|
182
|
+
|
|
183
|
+
// "localhost:3000" is what people type; give it a scheme before using it.
|
|
184
|
+
function normaliseRecordUrl(url) {
|
|
185
|
+
const s = String(url == null ? '' : url).trim();
|
|
186
|
+
if (!s) return s;
|
|
187
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) return s;
|
|
188
|
+
return 'http://' + s;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isLocalAddress(url) {
|
|
192
|
+
let host;
|
|
193
|
+
try {
|
|
194
|
+
host = new URL(url).hostname.toLowerCase();
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
return (
|
|
199
|
+
host === 'localhost' ||
|
|
200
|
+
host === '127.0.0.1' ||
|
|
201
|
+
host === '0.0.0.0' ||
|
|
202
|
+
host === '::1' ||
|
|
203
|
+
host === '[::1]' ||
|
|
204
|
+
host.endsWith('.local') ||
|
|
205
|
+
host.endsWith('.localhost')
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Turns a fetch failure into one of a few plain kinds. Split out from the
|
|
210
|
+
// network call so it can be checked without a network.
|
|
211
|
+
function classifyFetchError(err) {
|
|
212
|
+
const code = (err && err.cause && err.cause.code) || (err && err.code) || null;
|
|
213
|
+
if (code === 'ECONNREFUSED') return 'refused';
|
|
214
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'unknown-address';
|
|
215
|
+
if (err && err.name === 'AbortError') return 'timeout';
|
|
216
|
+
if (code === 'ETIMEDOUT' || code === 'ECONNRESET') return 'timeout';
|
|
217
|
+
// A certificate complaint means something IS answering there. Let the
|
|
218
|
+
// browser deal with it rather than blocking the recording.
|
|
219
|
+
if (code && /CERT|SSL|SELF_SIGNED|VERIFY/i.test(String(code))) return 'reachable';
|
|
220
|
+
return 'other';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function reachability(url, timeoutMs) {
|
|
224
|
+
try {
|
|
225
|
+
await httpGet(url, timeoutMs || 8000, 0);
|
|
226
|
+
return { ok: true, kind: 'reachable' };
|
|
227
|
+
} catch (err) {
|
|
228
|
+
const kind = classifyFetchError(err);
|
|
229
|
+
return kind === 'reachable' ? { ok: true, kind: kind } : { ok: false, kind: kind };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// One place that explains an address not answering, used both before codegen
|
|
234
|
+
// starts and if codegen itself reports the same thing.
|
|
235
|
+
function explainUnreachable(url, kind) {
|
|
236
|
+
const local = isLocalAddress(url);
|
|
237
|
+
console.error('');
|
|
238
|
+
console.error(' Nothing answered at ' + url);
|
|
239
|
+
console.error('');
|
|
240
|
+
|
|
241
|
+
if (kind === 'unknown-address') {
|
|
242
|
+
console.error(' That web address could not be found.');
|
|
243
|
+
console.error(' Check the spelling, and that you are connected to the internet.');
|
|
244
|
+
} else if (kind === 'timeout') {
|
|
245
|
+
console.error(' The address did not answer in time.');
|
|
246
|
+
console.error(' It may be slow, or blocked by a firewall or VPN.');
|
|
247
|
+
} else if (local) {
|
|
248
|
+
console.error(' Your app does not look like it is running.');
|
|
249
|
+
console.error('');
|
|
250
|
+
console.error(' For an app on your own machine, start it first - usually:');
|
|
251
|
+
console.error(' npm run dev');
|
|
252
|
+
console.error('');
|
|
253
|
+
console.error(' Then check the address and port match what it printed.');
|
|
254
|
+
} else {
|
|
255
|
+
console.error(' Nothing is listening at that address.');
|
|
256
|
+
console.error(' Check the address, including the port number, and that the site is up.');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
console.error('');
|
|
260
|
+
console.error(' Nothing was recorded.');
|
|
261
|
+
console.error('');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Codegen writes Node/Playwright traces to stderr. We never show those; we
|
|
265
|
+
// pick out the part that means something and say it in plain words.
|
|
266
|
+
function explainCodegenFailure(url, stderr) {
|
|
267
|
+
const text = String(stderr || '');
|
|
268
|
+
if (/ERR_CONNECTION_REFUSED/.test(text)) return explainUnreachable(url, 'refused');
|
|
269
|
+
if (/ERR_NAME_NOT_RESOLVED/.test(text)) return explainUnreachable(url, 'unknown-address');
|
|
270
|
+
if (/ERR_CONNECTION_TIMED_OUT|ERR_TIMED_OUT/.test(text)) return explainUnreachable(url, 'timeout');
|
|
271
|
+
if (/ERR_INTERNET_DISCONNECTED/.test(text)) {
|
|
272
|
+
console.error('');
|
|
273
|
+
console.error(' There is no internet connection.');
|
|
274
|
+
console.error('');
|
|
275
|
+
console.error(' Nothing was recorded.');
|
|
276
|
+
console.error('');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
console.error('');
|
|
280
|
+
console.error(' The browser closed before anything could be recorded.');
|
|
281
|
+
console.error('');
|
|
282
|
+
console.error(' Check that ' + url + ' opens normally in your own browser,');
|
|
283
|
+
console.error(' then try again.');
|
|
284
|
+
console.error('');
|
|
285
|
+
console.error(' Nothing was recorded.');
|
|
286
|
+
console.error('');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// A recording is only worth keeping if the browser actually did something.
|
|
290
|
+
// An abandoned or failed run leaves a test body with no page calls at all.
|
|
291
|
+
function countRecordedActions(source) {
|
|
292
|
+
const found = String(source || '').match(/\bpage\s*\.\s*[A-Za-z_$][\w$]*\s*\(/g);
|
|
293
|
+
return found ? found.length : 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// A spec that pulls Playwright in directly gets none of our diagnostics.
|
|
297
|
+
function importsPlaywrightDirectly(source) {
|
|
298
|
+
const text = String(source || '');
|
|
299
|
+
// The literal specifier is what matters; import and require both carry it.
|
|
300
|
+
return text.indexOf("'@playwright/test'") !== -1 || text.indexOf('"@playwright/test"') !== -1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// codegen always writes the opening page.goto. A recording is only real if
|
|
304
|
+
// something else happened as well.
|
|
305
|
+
function isRealRecording(source) {
|
|
306
|
+
return countRecordedActions(source) >= 2;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function timestampName() {
|
|
310
|
+
const d = new Date();
|
|
311
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
312
|
+
return (
|
|
313
|
+
'recorded-' +
|
|
314
|
+
d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) +
|
|
315
|
+
'-' + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) +
|
|
316
|
+
'.spec.js'
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Codegen imports from '@playwright/test'. Point the new file at the shared
|
|
321
|
+
// fixture instead, so a recorded test gets the same failure diagnostics
|
|
322
|
+
// (failed requests, console errors) as every other spec. Imported by package
|
|
323
|
+
// name, because the user's tests folder is not next to the installed package.
|
|
324
|
+
function pointAtFixture(relativeFile) {
|
|
325
|
+
const full = path.join(USER_DIR, relativeFile);
|
|
326
|
+
let src;
|
|
327
|
+
try {
|
|
328
|
+
src = fs.readFileSync(full, 'utf8');
|
|
329
|
+
} catch (err) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
const updated = src
|
|
333
|
+
.replace(/from (['"])@playwright\/test\1/, "from 'kryptheon/kryptheon-fixture'")
|
|
334
|
+
.replace(/require\((['"])@playwright\/test\1\)/, "require('kryptheon/kryptheon-fixture')");
|
|
335
|
+
if (updated === src) return false;
|
|
336
|
+
try {
|
|
337
|
+
fs.writeFileSync(full, updated, 'utf8');
|
|
338
|
+
return true;
|
|
339
|
+
} catch (err) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// "about-us.html" -> "About Us"; "/" -> null
|
|
345
|
+
function humanisePath(pathname) {
|
|
346
|
+
const last = String(pathname || '')
|
|
347
|
+
.split('?')[0]
|
|
348
|
+
.split('#')[0]
|
|
349
|
+
.split('/')
|
|
350
|
+
.filter(Boolean)
|
|
351
|
+
.pop();
|
|
352
|
+
if (!last) return null;
|
|
353
|
+
const words = last
|
|
354
|
+
.replace(/\.[a-z0-9]+$/i, '')
|
|
355
|
+
.replace(/[-_+]+/g, ' ')
|
|
356
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
357
|
+
.trim();
|
|
358
|
+
if (!words) return null;
|
|
359
|
+
return words
|
|
360
|
+
.split(/\s+/)
|
|
361
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
362
|
+
.join(' ');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Titles are usually "Site | Page". The page half is the useful one, and a
|
|
366
|
+
// very long tail is prose rather than a name, so it is rejected.
|
|
367
|
+
function nameFromTitle(title) {
|
|
368
|
+
const parts = String(title || '')
|
|
369
|
+
.split(/[|–—•:]|\s-\s/)
|
|
370
|
+
.map((p) => p.replace(/\s+/g, ' ').trim())
|
|
371
|
+
.filter(Boolean);
|
|
372
|
+
if (!parts.length) return null;
|
|
373
|
+
const tail = parts[parts.length - 1];
|
|
374
|
+
if (tail && tail.split(' ').length <= 4) return tail;
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Reads the title of a page over plain HTTP. Best effort: the name falls back
|
|
379
|
+
// to the recorded path if this cannot be reached.
|
|
380
|
+
async function fetchTitle(url) {
|
|
381
|
+
try {
|
|
382
|
+
const res = await httpGet(url, 5000, 2);
|
|
383
|
+
if (!res || res.status < 200 || res.status >= 300) return null;
|
|
384
|
+
const m = String(res.body).match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
385
|
+
return m ? m[1].replace(/\s+/g, ' ').trim() : null;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function lastGoto(source) {
|
|
392
|
+
const gotos = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
|
|
393
|
+
return gotos.length ? gotos[gotos.length - 1] : null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Codegen calls every recording "test". Give it a name taken from what was
|
|
397
|
+
// actually recorded: the last page navigated to, else the last thing clicked.
|
|
398
|
+
function deriveTestName(source) {
|
|
399
|
+
const gotos = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
|
|
400
|
+
for (let i = gotos.length - 1; i >= 0; i--) {
|
|
401
|
+
let pathname = gotos[i];
|
|
402
|
+
try {
|
|
403
|
+
pathname = new URL(gotos[i]).pathname;
|
|
404
|
+
} catch (e) {
|
|
405
|
+
/* already a path */
|
|
406
|
+
}
|
|
407
|
+
const name = humanisePath(pathname);
|
|
408
|
+
if (name) return name;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// The first thing done describes a recording better than the last, and the
|
|
412
|
+
// last is often "Sign Out", which would name every recording after the way
|
|
413
|
+
// it ended rather than what it was for.
|
|
414
|
+
const names = [...String(source).matchAll(/name:\s*['"]([^'"]+)['"]/g)]
|
|
415
|
+
.map((m) => m[1].trim())
|
|
416
|
+
.filter((name) => name && !/(log|sign)[\s_-]?out/i.test(name));
|
|
417
|
+
if (names.length) return names[0];
|
|
418
|
+
|
|
419
|
+
const hosts = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
|
|
420
|
+
if (hosts.length) {
|
|
421
|
+
try {
|
|
422
|
+
const host = new URL(hosts[hosts.length - 1]).hostname.replace(/^www\./, '');
|
|
423
|
+
const label = humanisePath('/' + host.split('.')[0]);
|
|
424
|
+
if (label) return label;
|
|
425
|
+
} catch (e) {
|
|
426
|
+
/* fall through */
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function slugify(name) {
|
|
433
|
+
return String(name)
|
|
434
|
+
.toLowerCase()
|
|
435
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
436
|
+
.replace(/^-+|-+$/g, '') || 'recording';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Renames the test inside the file, and the file to match. Only ever touches
|
|
440
|
+
// the file codegen just wrote.
|
|
441
|
+
async function nameRecording(relativeFile) {
|
|
442
|
+
const full = path.join(USER_DIR, relativeFile);
|
|
443
|
+
let src;
|
|
444
|
+
try {
|
|
445
|
+
src = fs.readFileSync(full, 'utf8');
|
|
446
|
+
} catch (err) {
|
|
447
|
+
return relativeFile;
|
|
448
|
+
}
|
|
449
|
+
// Only rename codegen's placeholder, never a name someone chose.
|
|
450
|
+
if (!/\btest\(\s*(['"])test\1/.test(src)) return relativeFile;
|
|
451
|
+
|
|
452
|
+
// The page title is the friendliest source; the recorded path is the
|
|
453
|
+
// fallback when the site cannot be reached.
|
|
454
|
+
const visited = lastGoto(src);
|
|
455
|
+
const name = (visited ? nameFromTitle(await fetchTitle(visited)) : null) || deriveTestName(src);
|
|
456
|
+
if (!name) return relativeFile;
|
|
457
|
+
|
|
458
|
+
const safeName = name.replace(/'/g, "\\'");
|
|
459
|
+
const updated = src.replace(/\btest\(\s*(['"])test\1/, "test('" + safeName + "'");
|
|
460
|
+
|
|
461
|
+
let target = path.join('tests', slugify(name) + '.spec.js');
|
|
462
|
+
let attempt = 1;
|
|
463
|
+
while (fs.existsSync(path.join(USER_DIR, target)) && path.join(USER_DIR, target) !== full) {
|
|
464
|
+
attempt += 1;
|
|
465
|
+
target = path.join('tests', slugify(name) + '-' + attempt + '.spec.js');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
fs.writeFileSync(full, updated, 'utf8');
|
|
470
|
+
if (path.join(USER_DIR, target) !== full) {
|
|
471
|
+
fs.renameSync(full, path.join(USER_DIR, target));
|
|
472
|
+
return target;
|
|
473
|
+
}
|
|
474
|
+
} catch (err) {
|
|
475
|
+
return relativeFile;
|
|
476
|
+
}
|
|
477
|
+
return target;
|
|
478
|
+
}
|
|
479
|
+
// Recording needs a real browser window on someone's screen. Inside an AI
|
|
480
|
+
// coding assistant there is no desktop to draw it on, so codegen would sit
|
|
481
|
+
// there forever and the run would be killed before anything was tidied up.
|
|
482
|
+
function looksLikeNoDesktop() {
|
|
483
|
+
if (process.env.KRYPTHEON_FORCE_RECORD) return false;
|
|
484
|
+
return !process.stdout.isTTY;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function explainNoWindow(url) {
|
|
488
|
+
console.error('');
|
|
489
|
+
console.error(' Recording needs a real browser window on your screen.');
|
|
490
|
+
console.error('');
|
|
491
|
+
console.error(' This terminal cannot show one - that is what happens inside an');
|
|
492
|
+
console.error(' AI coding assistant, or any window-less session.');
|
|
493
|
+
console.error('');
|
|
494
|
+
console.error(' Open Command Prompt (or PowerShell) yourself and run:');
|
|
495
|
+
console.error(' npx kryptheon record ' + url);
|
|
496
|
+
console.error('');
|
|
497
|
+
console.error(' Nothing was recorded.');
|
|
498
|
+
console.error('');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Is a Playwright-controlled browser actually showing a window?
|
|
502
|
+
function browserWindowIsUp() {
|
|
503
|
+
try {
|
|
504
|
+
if (process.platform === 'win32') {
|
|
505
|
+
const probe = spawnSync(
|
|
506
|
+
'powershell',
|
|
507
|
+
[
|
|
508
|
+
'-NoProfile',
|
|
509
|
+
'-Command',
|
|
510
|
+
"@(Get-Process chrome,msedge -ErrorAction SilentlyContinue | " +
|
|
511
|
+
"Where-Object { $_.Path -like '*ms-playwright*' -and $_.MainWindowHandle -ne 0 }).Count",
|
|
512
|
+
],
|
|
513
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 8000 }
|
|
514
|
+
);
|
|
515
|
+
return Number(String(probe.stdout || '').trim()) > 0;
|
|
516
|
+
}
|
|
517
|
+
const probe = spawnSync('ps', ['-A', '-o', 'command'], { encoding: 'utf8', timeout: 8000 });
|
|
518
|
+
return /ms-playwright/.test(String(probe.stdout || ''));
|
|
519
|
+
} catch (err) {
|
|
520
|
+
return true; // cannot tell - do not block the user
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Runs codegen without blocking the event loop, so SIGINT and SIGTERM can
|
|
525
|
+
// still be handled. spawnSync would freeze the loop and no handler would run.
|
|
526
|
+
function startCodegen(args) {
|
|
527
|
+
const cli = findPlaywrightCli();
|
|
528
|
+
if (!cli) {
|
|
529
|
+
console.error('');
|
|
530
|
+
console.error(' The testing engine is missing from this install.');
|
|
531
|
+
console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
|
|
532
|
+
console.error('');
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
return spawn(process.execPath, [cli].concat(args), {
|
|
536
|
+
cwd: USER_DIR,
|
|
537
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// --- making a recording fit to run twice ------------------------------------
|
|
542
|
+
|
|
543
|
+
function readSpec(relativeFile) {
|
|
544
|
+
try {
|
|
545
|
+
return fs.readFileSync(path.join(USER_DIR, relativeFile), 'utf8');
|
|
546
|
+
} catch (err) {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function writeSpec(relativeFile, source) {
|
|
552
|
+
try {
|
|
553
|
+
fs.writeFileSync(path.join(USER_DIR, relativeFile), source, 'utf8');
|
|
554
|
+
return true;
|
|
555
|
+
} catch (err) {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Passwords typed during recording land in the file as plain text. Swap them
|
|
561
|
+
// for environment variables before the file is left lying around.
|
|
562
|
+
function takeOutSecrets(relativeFile) {
|
|
563
|
+
const source = readSpec(relativeFile);
|
|
564
|
+
if (source === null) return [];
|
|
565
|
+
const result = replay.scrubSecrets(source);
|
|
566
|
+
if (!result.replacements.length) return [];
|
|
567
|
+
return writeSpec(relativeFile, result.source) ? result.replacements : [];
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function reportSecrets(replacements) {
|
|
571
|
+
if (!replacements || !replacements.length) return;
|
|
572
|
+
console.log(' What you typed into the password box was not saved in the test.');
|
|
573
|
+
console.log('');
|
|
574
|
+
console.log(' Put it in a file called .env next to your tests, like this:');
|
|
575
|
+
for (const item of replacements) {
|
|
576
|
+
console.log(' ' + item.envName + '=your ' + String(item.field).toLowerCase() + ' here');
|
|
577
|
+
}
|
|
578
|
+
console.log('');
|
|
579
|
+
console.log(' Keep .env out of version control - it holds the real value.');
|
|
580
|
+
console.log('');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function askYesNo(question) {
|
|
584
|
+
return new Promise((resolve) => {
|
|
585
|
+
if (!process.stdin.isTTY) return resolve(false);
|
|
586
|
+
const readline = require('readline');
|
|
587
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
588
|
+
rl.question(question, (answer) => {
|
|
589
|
+
rl.close();
|
|
590
|
+
// Let the loop finish later: stdin stays open otherwise.
|
|
591
|
+
try {
|
|
592
|
+
process.stdin.pause();
|
|
593
|
+
} catch (err) {
|
|
594
|
+
/* nothing to do */
|
|
595
|
+
}
|
|
596
|
+
resolve(!/^\s*n/i.test(String(answer)));
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Ending on a logout means the next run starts somewhere else. Almost nobody
|
|
602
|
+
// wants that, so ask.
|
|
603
|
+
async function offerToDropLogout(relativeFile) {
|
|
604
|
+
const source = readSpec(relativeFile);
|
|
605
|
+
if (source === null) return false;
|
|
606
|
+
const trimmed = replay.dropTrailingLogout(source);
|
|
607
|
+
if (!trimmed.removed) return false;
|
|
608
|
+
|
|
609
|
+
console.log(' The recording ends by logging out ("' + trimmed.removed + '").');
|
|
610
|
+
console.log(' That means the next run would not start where this one did.');
|
|
611
|
+
console.log('');
|
|
612
|
+
|
|
613
|
+
const drop = await askYesNo(' Leave that last step out? [Y/n] ');
|
|
614
|
+
if (!drop) {
|
|
615
|
+
console.log('');
|
|
616
|
+
console.log(' Kept it. If checks fail from the second run onwards, this is why.');
|
|
617
|
+
console.log('');
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
writeSpec(relativeFile, trimmed.source);
|
|
621
|
+
console.log('');
|
|
622
|
+
console.log(' Removed the sign-out step.');
|
|
623
|
+
console.log('');
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function reportReplayRisks(relativeFile) {
|
|
628
|
+
const source = readSpec(relativeFile);
|
|
629
|
+
if (source === null) return [];
|
|
630
|
+
const risks = replay.findReplayRisks(source);
|
|
631
|
+
if (!risks.length) return [];
|
|
632
|
+
|
|
633
|
+
console.log(' Before you rely on this recording, one thing to know:');
|
|
634
|
+
console.log('');
|
|
635
|
+
for (const risk of risks) {
|
|
636
|
+
console.log(' "' + risk.step + '" (line ' + risk.line + ')');
|
|
637
|
+
console.log(' ' + risk.why + '.');
|
|
638
|
+
console.log(' What to do: ' + risk.fix + '.');
|
|
639
|
+
console.log('');
|
|
640
|
+
}
|
|
641
|
+
console.log(' Everything else in the recording will run again fine.');
|
|
642
|
+
console.log('');
|
|
643
|
+
return risks;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Everything that has to happen to whatever codegen left behind, however
|
|
647
|
+
// the run ended. Module level so it can be exercised directly.
|
|
648
|
+
async function finaliseRecording(outFile, context) {
|
|
649
|
+
const ctx = context || {};
|
|
650
|
+
const specPath = path.join(USER_DIR, outFile);
|
|
651
|
+
let source = null;
|
|
652
|
+
try {
|
|
653
|
+
source = fs.readFileSync(specPath, 'utf8');
|
|
654
|
+
} catch (err) {
|
|
655
|
+
source = null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (source !== null && isRealRecording(source)) {
|
|
659
|
+
// Import rewrite first: it is instant, so even a second interruption
|
|
660
|
+
// leaves behind a spec that works.
|
|
661
|
+
pointAtFixture(outFile);
|
|
662
|
+
|
|
663
|
+
// Anything typed into a password box is still a literal in this file at
|
|
664
|
+
// this point. Take it out before doing anything else with the file.
|
|
665
|
+
const secrets = takeOutSecrets(outFile);
|
|
666
|
+
|
|
667
|
+
const named = await nameRecording(outFile);
|
|
668
|
+
console.log('');
|
|
669
|
+
console.log(' Saved to ' + named);
|
|
670
|
+
console.log(' Run it any time with: kryptheon check');
|
|
671
|
+
console.log('');
|
|
672
|
+
|
|
673
|
+
reportSecrets(secrets);
|
|
674
|
+
await offerToDropLogout(named);
|
|
675
|
+
reportReplayRisks(named);
|
|
676
|
+
|
|
677
|
+
return { code: 0, savedAs: named };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (source !== null) {
|
|
681
|
+
try {
|
|
682
|
+
fs.unlinkSync(specPath);
|
|
683
|
+
} catch (err) {
|
|
684
|
+
/* nothing more we can do */
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (!ctx.hadTestsDir) {
|
|
688
|
+
try {
|
|
689
|
+
if (fs.readdirSync(TESTS_DIR).length === 0) fs.rmdirSync(TESTS_DIR);
|
|
690
|
+
} catch (err) {
|
|
691
|
+
/* only tidying up */
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (ctx.reason === 'no-window') {
|
|
696
|
+
explainNoWindow(ctx.target);
|
|
697
|
+
} else if (ctx.reason === 'signal') {
|
|
698
|
+
console.log('');
|
|
699
|
+
console.log(' Recording stopped before anything was saved.');
|
|
700
|
+
console.log('');
|
|
701
|
+
console.log(' Nothing was recorded.');
|
|
702
|
+
console.log('');
|
|
703
|
+
} else if (source === null || /ERR_|Error:/.test(String(ctx.stderr || ''))) {
|
|
704
|
+
explainCodegenFailure(ctx.target, ctx.stderr);
|
|
705
|
+
} else {
|
|
706
|
+
console.log('');
|
|
707
|
+
console.log(' Nothing was recorded.');
|
|
708
|
+
console.log('');
|
|
709
|
+
console.log(' The browser closed before anything was clicked or typed.');
|
|
710
|
+
console.log(' Run the same command again and use your app before closing it.');
|
|
711
|
+
console.log('');
|
|
712
|
+
}
|
|
713
|
+
return { code: 1, savedAs: null };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function record(url) {
|
|
717
|
+
if (!url) {
|
|
718
|
+
console.error('');
|
|
719
|
+
console.error(' Which address should I open?');
|
|
720
|
+
console.error('');
|
|
721
|
+
console.error(' Add the web address of your app, for example:');
|
|
722
|
+
console.error(' kryptheon record https://www.example.com');
|
|
723
|
+
console.error('');
|
|
724
|
+
return 1;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const target = normaliseRecordUrl(url);
|
|
728
|
+
|
|
729
|
+
// No desktop means no window means nothing to record. Say so now rather
|
|
730
|
+
// than hanging until something kills us.
|
|
731
|
+
if (looksLikeNoDesktop()) {
|
|
732
|
+
explainNoWindow(target);
|
|
733
|
+
return 1;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Fail early and kindly rather than letting codegen throw a stack trace.
|
|
737
|
+
const reach = await reachability(target);
|
|
738
|
+
if (!reach.ok) {
|
|
739
|
+
explainUnreachable(target, reach.kind);
|
|
740
|
+
return 1;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (!ensureBrowser()) return 1;
|
|
744
|
+
|
|
745
|
+
const hadTestsDir = fs.existsSync(TESTS_DIR);
|
|
746
|
+
try {
|
|
747
|
+
fs.mkdirSync(TESTS_DIR, { recursive: true });
|
|
748
|
+
} catch (err) {
|
|
749
|
+
/* codegen will report if it cannot write */
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const outFile = path.join('tests', timestampName());
|
|
753
|
+
const specPath = path.join(USER_DIR, outFile);
|
|
754
|
+
|
|
755
|
+
console.log('');
|
|
756
|
+
console.log(' Opening ' + target + ' in a browser.');
|
|
757
|
+
console.log('');
|
|
758
|
+
console.log(' Use your app normally - click, type, sign in, whatever you want');
|
|
759
|
+
console.log(' covered. Every step is recorded as you go.');
|
|
760
|
+
console.log('');
|
|
761
|
+
console.log(' When you are done, close the browser window to save the test.');
|
|
762
|
+
console.log('');
|
|
763
|
+
|
|
764
|
+
const child = startCodegen(['codegen', '--target', 'playwright-test', '-o', outFile, target]);
|
|
765
|
+
if (!child) return 1;
|
|
766
|
+
|
|
767
|
+
let stderr = '';
|
|
768
|
+
if (child.stderr) child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
769
|
+
|
|
770
|
+
// Everything that has to happen no matter how this ends: tidy the file,
|
|
771
|
+
// wire it up, name it. Runs once, whether codegen exits on its own or we
|
|
772
|
+
// are interrupted.
|
|
773
|
+
let finished = false;
|
|
774
|
+
const finalise = async (reason) => {
|
|
775
|
+
if (finished) return 1;
|
|
776
|
+
finished = true;
|
|
777
|
+
const outcome = await finaliseRecording(outFile, {
|
|
778
|
+
hadTestsDir: hadTestsDir,
|
|
779
|
+
target: target,
|
|
780
|
+
stderr: stderr,
|
|
781
|
+
reason: reason,
|
|
782
|
+
});
|
|
783
|
+
return outcome.code;
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const stopChild = () => {
|
|
787
|
+
try {
|
|
788
|
+
child.kill();
|
|
789
|
+
} catch (err) {
|
|
790
|
+
/* already gone */
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
return await new Promise((resolve) => {
|
|
795
|
+
// If no window ever appears, stop instead of waiting for someone to kill us.
|
|
796
|
+
let waited = 0;
|
|
797
|
+
const watchdog = setInterval(() => {
|
|
798
|
+
waited += WINDOW_POLL_MS;
|
|
799
|
+
if (browserWindowIsUp()) {
|
|
800
|
+
clearInterval(watchdog);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (waited >= WINDOW_TIMEOUT_MS) {
|
|
804
|
+
clearInterval(watchdog);
|
|
805
|
+
stopChild();
|
|
806
|
+
finalise('no-window').then(resolve);
|
|
807
|
+
}
|
|
808
|
+
}, WINDOW_POLL_MS);
|
|
809
|
+
|
|
810
|
+
const onSignal = () => {
|
|
811
|
+
clearInterval(watchdog);
|
|
812
|
+
stopChild();
|
|
813
|
+
finalise('signal').then(resolve);
|
|
814
|
+
};
|
|
815
|
+
process.once('SIGINT', onSignal);
|
|
816
|
+
process.once('SIGTERM', onSignal);
|
|
817
|
+
process.once('SIGHUP', onSignal);
|
|
818
|
+
|
|
819
|
+
child.on('error', () => {
|
|
820
|
+
clearInterval(watchdog);
|
|
821
|
+
finalise('error').then(resolve);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
child.on('close', () => {
|
|
825
|
+
clearInterval(watchdog);
|
|
826
|
+
process.removeListener('SIGINT', onSignal);
|
|
827
|
+
process.removeListener('SIGTERM', onSignal);
|
|
828
|
+
process.removeListener('SIGHUP', onSignal);
|
|
829
|
+
finalise('exit').then(resolve);
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Recordings import the fixture by package name, which only resolves if
|
|
835
|
+
// kryptheon is in the user's own node_modules. Running through a bare `npx`
|
|
836
|
+
// puts the package somewhere the tests cannot see.
|
|
837
|
+
// Deliberately a filesystem walk, not require.resolve: this file lives inside
|
|
838
|
+
// the kryptheon package, and a package with a name and an "exports" map can
|
|
839
|
+
// always resolve itself by name, so require.resolve would answer "yes" even
|
|
840
|
+
// when the user's tests have no way to find it.
|
|
841
|
+
function fixtureResolvesForUser() {
|
|
842
|
+
let dir = USER_DIR;
|
|
843
|
+
for (;;) {
|
|
844
|
+
if (fs.existsSync(path.join(dir, 'node_modules', 'kryptheon', 'kryptheon-fixture.js'))) {
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
847
|
+
const parent = path.dirname(dir);
|
|
848
|
+
if (parent === dir) return false;
|
|
849
|
+
dir = parent;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Only a problem if a recording actually asks for it: specs written against a
|
|
854
|
+
// relative path (as in this repo) resolve on their own.
|
|
855
|
+
function specsNeedThePackage() {
|
|
856
|
+
return listSpecFiles().some(function (name) {
|
|
857
|
+
try {
|
|
858
|
+
return fs.readFileSync(path.join(TESTS_DIR, name), 'utf8').indexOf('kryptheon/kryptheon-fixture') !== -1;
|
|
859
|
+
} catch (err) {
|
|
860
|
+
return false;
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Older versions, and any run that was killed part way, left specs behind that
|
|
866
|
+
// import Playwright directly (so they produce no diagnostics) or that contain
|
|
867
|
+
// nothing but the opening navigation (so they verify nothing). Repair the
|
|
868
|
+
// first kind and refuse to run the second, rather than reporting a false OK.
|
|
869
|
+
function triageSpecs() {
|
|
870
|
+
const repaired = [];
|
|
871
|
+
const notRecordings = [];
|
|
872
|
+
const runnable = [];
|
|
873
|
+
const withSecrets = [];
|
|
874
|
+
|
|
875
|
+
for (const name of listSpecFiles()) {
|
|
876
|
+
const full = path.join(TESTS_DIR, name);
|
|
877
|
+
let source;
|
|
878
|
+
try {
|
|
879
|
+
source = fs.readFileSync(full, 'utf8');
|
|
880
|
+
} catch (err) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (/(from|require\()\s*(['"])@playwright\/test\2/.test(source)) {
|
|
885
|
+
const relative = path.join('tests', name);
|
|
886
|
+
if (pointAtFixture(relative)) {
|
|
887
|
+
repaired.push(name);
|
|
888
|
+
try {
|
|
889
|
+
source = fs.readFileSync(full, 'utf8');
|
|
890
|
+
} catch (err) {
|
|
891
|
+
/* keep what we had */
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (!isRealRecording(source)) {
|
|
897
|
+
notRecordings.push(name);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
// Recordings made before secrets were stripped still hold the real value.
|
|
901
|
+
// Say so rather than rewriting: a spec that works today should keep
|
|
902
|
+
// working until the person has somewhere to put the password.
|
|
903
|
+
if (replay.scrubSecrets(source).replacements.length) {
|
|
904
|
+
withSecrets.push({ name: name, fields: replay.scrubSecrets(source).replacements });
|
|
905
|
+
}
|
|
906
|
+
runnable.push(name);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return {
|
|
910
|
+
repaired: repaired,
|
|
911
|
+
notRecordings: notRecordings,
|
|
912
|
+
runnable: runnable,
|
|
913
|
+
withSecrets: withSecrets,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function check(options) {
|
|
918
|
+
const quiet = !!(options && options.quiet);
|
|
919
|
+
// Deliberately not a failure. An assistant told to run this after every
|
|
920
|
+
// change will read a non-zero exit as "something broke" and start fixing
|
|
921
|
+
// code that is perfectly fine. Nothing has been recorded, so there is
|
|
922
|
+
// nothing to verify and nothing to repair.
|
|
923
|
+
if (!listSpecFiles().length) {
|
|
924
|
+
console.log('');
|
|
925
|
+
console.log(' Nothing has been recorded yet, so there is nothing to check.');
|
|
926
|
+
console.log('');
|
|
927
|
+
console.log(' This is not a failure, and there is nothing in the code to fix.');
|
|
928
|
+
console.log('');
|
|
929
|
+
console.log(' To start, record a flow through the app:');
|
|
930
|
+
console.log(' npx kryptheon record <url>');
|
|
931
|
+
console.log('');
|
|
932
|
+
console.log(' That opens a browser window, so it has to be run by a person in');
|
|
933
|
+
console.log(' a normal terminal - it cannot be done from an AI assistant.');
|
|
934
|
+
console.log('');
|
|
935
|
+
return 0;
|
|
936
|
+
}
|
|
937
|
+
if (!fixtureResolvesForUser() && specsNeedThePackage()) {
|
|
938
|
+
console.log('');
|
|
939
|
+
console.log(' Your recordings cannot find kryptheon.');
|
|
940
|
+
console.log('');
|
|
941
|
+
console.log(' They are saved in this folder, but kryptheon itself is installed');
|
|
942
|
+
console.log(' somewhere else, so they have nothing to load. Add it here:');
|
|
943
|
+
console.log('');
|
|
944
|
+
console.log(' npm i -D kryptheon');
|
|
945
|
+
console.log('');
|
|
946
|
+
console.log(' Then run "kryptheon check" again.');
|
|
947
|
+
console.log('');
|
|
948
|
+
return 1;
|
|
949
|
+
}
|
|
950
|
+
const triage = triageSpecs();
|
|
951
|
+
|
|
952
|
+
for (const name of triage.repaired) {
|
|
953
|
+
console.log('');
|
|
954
|
+
console.log(' Repaired ' + path.join('tests', name));
|
|
955
|
+
console.log(' It was saved without the part that explains failures. Fixed now.');
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
for (const item of triage.withSecrets) {
|
|
959
|
+
console.log('');
|
|
960
|
+
console.log(' Careful: ' + path.join('tests', item.name) + ' has a real password written into it.');
|
|
961
|
+
console.log(' Anyone who can read that file can read the password.');
|
|
962
|
+
console.log(' Re-record it to have the password moved into .env automatically.');
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
for (const name of triage.notRecordings) {
|
|
966
|
+
console.log('');
|
|
967
|
+
console.log(' Skipped ' + path.join('tests', name));
|
|
968
|
+
console.log(' This is not a real recording - it only opens a page and stops,');
|
|
969
|
+
console.log(' so it cannot tell you whether anything works.');
|
|
970
|
+
console.log(' Record it again: kryptheon record <url>');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (!triage.runnable.length) {
|
|
974
|
+
console.log('');
|
|
975
|
+
console.log(' Nothing could be checked.');
|
|
976
|
+
console.log('');
|
|
977
|
+
console.log(' None of the files in tests/ is a usable recording.');
|
|
978
|
+
console.log(' Record one with: kryptheon record <url>');
|
|
979
|
+
console.log('');
|
|
980
|
+
return 1;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (!ensureBrowser()) return 1;
|
|
984
|
+
|
|
985
|
+
// Only the usable recordings are handed to the test runner, so a file that
|
|
986
|
+
// cannot produce diagnostics can never be counted as passing.
|
|
987
|
+
const only = triage.runnable.map((name) => path.join('tests', name).split(path.sep).join('/'));
|
|
988
|
+
if (quiet) process.env.KRYPTHEON_QUIET = '1';
|
|
989
|
+
const status = runPlaywright(['test', '--config', CONFIG].concat(only)).status;
|
|
990
|
+
|
|
991
|
+
// Without this, a green summary could still hide a recording that checked
|
|
992
|
+
// nothing at all.
|
|
993
|
+
if (triage.notRecordings.length) {
|
|
994
|
+
const many = triage.notRecordings.length > 1;
|
|
995
|
+
console.log(
|
|
996
|
+
' Note: ' + triage.notRecordings.length + ' recording' + (many ? 's' : '') +
|
|
997
|
+
' above checked nothing and ' + (many ? 'were' : 'was') + ' skipped.'
|
|
998
|
+
);
|
|
999
|
+
console.log('');
|
|
1000
|
+
}
|
|
1001
|
+
return status;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// --- setup-ai ---------------------------------------------------------------
|
|
1005
|
+
//
|
|
1006
|
+
// An assistant that only reads code will happily call a change finished. These
|
|
1007
|
+
// files are the ones the common assistants read on their own, so the rule ends
|
|
1008
|
+
// up in front of them without anyone having to remember it.
|
|
1009
|
+
|
|
1010
|
+
const RULES_START = 'kryptheon:start';
|
|
1011
|
+
const RULES_END = 'kryptheon:end';
|
|
1012
|
+
|
|
1013
|
+
// Every assistant reads at least one of these.
|
|
1014
|
+
const RULES_TARGETS = [
|
|
1015
|
+
{ file: 'CLAUDE.md', comment: 'html' },
|
|
1016
|
+
{ file: 'AGENTS.md', comment: 'html' },
|
|
1017
|
+
{ file: '.cursorrules', comment: 'hash' },
|
|
1018
|
+
];
|
|
1019
|
+
|
|
1020
|
+
function rulesBody() {
|
|
1021
|
+
return [
|
|
1022
|
+
'## Checking your work with kryptheon',
|
|
1023
|
+
'',
|
|
1024
|
+
'After making any change to this app:',
|
|
1025
|
+
'',
|
|
1026
|
+
'1. Run `npx kryptheon check` and show its output.',
|
|
1027
|
+
'2. Do not report the work as done until it passes.',
|
|
1028
|
+
'3. If it fails, fix what broke and run it again.',
|
|
1029
|
+
'4. If it says nothing has been recorded yet, stop and ask the user to run',
|
|
1030
|
+
' `npx kryptheon record <url>` themselves in their own terminal. That',
|
|
1031
|
+
' opens a browser window and cannot be done from your terminal. It is not',
|
|
1032
|
+
' a failure, so do not try to fix it.',
|
|
1033
|
+
'',
|
|
1034
|
+
'Reading the code is not enough. The check runs the app in a real browser,',
|
|
1035
|
+
'which is the only thing that shows whether the change actually works.',
|
|
1036
|
+
];
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Wrapped in markers so a second run replaces the block instead of stacking
|
|
1040
|
+
// another copy underneath the first.
|
|
1041
|
+
function rulesBlock(style) {
|
|
1042
|
+
const open = style === 'hash' ? '# ' + RULES_START : '<!-- ' + RULES_START + ' -->';
|
|
1043
|
+
const close = style === 'hash' ? '# ' + RULES_END : '<!-- ' + RULES_END + ' -->';
|
|
1044
|
+
const body = style === 'hash' ? rulesBody().map((l) => (l ? '# ' + l : '#')) : rulesBody();
|
|
1045
|
+
return [open].concat(body, [close]).join('\n');
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Returns the new contents, and what happened, without touching the disk.
|
|
1049
|
+
function applyRules(existing, style) {
|
|
1050
|
+
const block = rulesBlock(style);
|
|
1051
|
+
if (existing === null || existing === undefined) {
|
|
1052
|
+
return { contents: block + '\n', action: 'created' };
|
|
1053
|
+
}
|
|
1054
|
+
const text = String(existing);
|
|
1055
|
+
const startAt = text.indexOf(RULES_START);
|
|
1056
|
+
const endAt = text.indexOf(RULES_END);
|
|
1057
|
+
|
|
1058
|
+
if (startAt !== -1 && endAt !== -1 && endAt > startAt) {
|
|
1059
|
+
// Replace the previous block, keeping everything around it.
|
|
1060
|
+
const lines = text.split('\n');
|
|
1061
|
+
const first = lines.findIndex((l) => l.indexOf(RULES_START) !== -1);
|
|
1062
|
+
let last = lines.findIndex((l) => l.indexOf(RULES_END) !== -1);
|
|
1063
|
+
if (first === -1 || last === -1 || last < first) return { contents: text, action: 'unchanged' };
|
|
1064
|
+
const rebuilt = lines.slice(0, first).concat(block.split('\n'), lines.slice(last + 1));
|
|
1065
|
+
const contents = rebuilt.join('\n');
|
|
1066
|
+
return { contents: contents, action: contents === text ? 'unchanged' : 'updated' };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Never overwrite what someone else wrote: add to the end.
|
|
1070
|
+
const padded = text.replace(/\s*$/, '');
|
|
1071
|
+
return { contents: padded + '\n\n' + block + '\n', action: 'appended' };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function setupAi() {
|
|
1075
|
+
const results = [];
|
|
1076
|
+
for (const target of RULES_TARGETS) {
|
|
1077
|
+
const full = path.join(USER_DIR, target.file);
|
|
1078
|
+
let existing = null;
|
|
1079
|
+
try {
|
|
1080
|
+
existing = fs.readFileSync(full, 'utf8');
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
existing = null;
|
|
1083
|
+
}
|
|
1084
|
+
const outcome = applyRules(existing, target.comment);
|
|
1085
|
+
if (outcome.action === 'unchanged') {
|
|
1086
|
+
results.push({ file: target.file, action: 'unchanged' });
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
try {
|
|
1090
|
+
fs.writeFileSync(full, outcome.contents, 'utf8');
|
|
1091
|
+
results.push({ file: target.file, action: outcome.action });
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
results.push({ file: target.file, action: 'failed' });
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
console.log('');
|
|
1098
|
+
console.log(' Your AI assistant now has a rule to check its work.');
|
|
1099
|
+
console.log('');
|
|
1100
|
+
for (const r of results) {
|
|
1101
|
+
const said =
|
|
1102
|
+
r.action === 'created' ? 'written'
|
|
1103
|
+
: r.action === 'appended' ? 'added a section to your existing file'
|
|
1104
|
+
: r.action === 'updated' ? 'updated the section it wrote before'
|
|
1105
|
+
: r.action === 'unchanged' ? 'already up to date'
|
|
1106
|
+
: 'could not be written';
|
|
1107
|
+
console.log(' ' + r.file.padEnd(14) + said);
|
|
1108
|
+
}
|
|
1109
|
+
console.log('');
|
|
1110
|
+
console.log(' The rule: after any change, run "npx kryptheon check", show the');
|
|
1111
|
+
console.log(' output, and do not call the work done until it passes.');
|
|
1112
|
+
console.log('');
|
|
1113
|
+
console.log(' Nothing outside the marked section was touched.');
|
|
1114
|
+
console.log('');
|
|
1115
|
+
return results.some((r) => r.action === 'failed') ? 1 : 0;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// --- accept -----------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
function baselineApi() {
|
|
1121
|
+
try {
|
|
1122
|
+
return require(path.join(PACKAGE_DIR, 'kryptheon-fixture.js'));
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
console.error('');
|
|
1125
|
+
console.error(' Could not read the saved results: ' + err.message);
|
|
1126
|
+
console.error('');
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// A key looks like "tests/login.spec.js :: Login".
|
|
1132
|
+
function titleOf(key) {
|
|
1133
|
+
const at = key.indexOf(' :: ');
|
|
1134
|
+
return at === -1 ? key : key.slice(at + 4);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function listBaselines(api) {
|
|
1138
|
+
const all = api.readBaselines(api.BASELINE_FILE);
|
|
1139
|
+
const keys = Object.keys(all);
|
|
1140
|
+
|
|
1141
|
+
console.log('');
|
|
1142
|
+
if (!keys.length) {
|
|
1143
|
+
console.log(' No tests have a saved result yet.');
|
|
1144
|
+
console.log(' Run "kryptheon check" once and they will be saved automatically.');
|
|
1145
|
+
console.log('');
|
|
1146
|
+
return 0;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
console.log(' Tests with a saved result:');
|
|
1150
|
+
console.log('');
|
|
1151
|
+
for (const key of keys) {
|
|
1152
|
+
const waiting = all[key] && all[key].pending ? ' (has a new result waiting)' : '';
|
|
1153
|
+
console.log(' ' + titleOf(key) + waiting);
|
|
1154
|
+
console.log(' from ' + key.split(' :: ')[0]);
|
|
1155
|
+
}
|
|
1156
|
+
console.log('');
|
|
1157
|
+
console.log(' To agree that a new result is correct:');
|
|
1158
|
+
console.log(' kryptheon accept "<name>"');
|
|
1159
|
+
console.log('');
|
|
1160
|
+
return 0;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function accept(name) {
|
|
1164
|
+
const api = baselineApi();
|
|
1165
|
+
if (!api) return 1;
|
|
1166
|
+
if (!name) return listBaselines(api);
|
|
1167
|
+
|
|
1168
|
+
const all = api.readBaselines(api.BASELINE_FILE);
|
|
1169
|
+
const wanted = String(name).trim().toLowerCase();
|
|
1170
|
+
const matches = Object.keys(all).filter(function (key) {
|
|
1171
|
+
return titleOf(key).toLowerCase() === wanted || key.toLowerCase() === wanted;
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
if (!matches.length) {
|
|
1175
|
+
console.error('');
|
|
1176
|
+
console.error(' No saved result for a test called "' + name + '".');
|
|
1177
|
+
console.error(' Run "kryptheon accept" on its own to see the names.');
|
|
1178
|
+
console.error('');
|
|
1179
|
+
return 1;
|
|
1180
|
+
}
|
|
1181
|
+
if (matches.length > 1) {
|
|
1182
|
+
console.error('');
|
|
1183
|
+
console.error(' More than one test is called "' + name + '":');
|
|
1184
|
+
matches.forEach((k) => console.error(' ' + k));
|
|
1185
|
+
console.error('');
|
|
1186
|
+
console.error(' Pass the full line above instead.');
|
|
1187
|
+
console.error('');
|
|
1188
|
+
return 1;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const key = matches[0];
|
|
1192
|
+
const result = api.acceptBaseline(api.BASELINE_FILE, key);
|
|
1193
|
+
|
|
1194
|
+
if (result.ok) {
|
|
1195
|
+
console.log('');
|
|
1196
|
+
console.log(' Updated the saved result for "' + titleOf(key) + '".');
|
|
1197
|
+
console.log(' Address: ' + result.entry.url);
|
|
1198
|
+
console.log(' Title: ' + result.entry.title);
|
|
1199
|
+
console.log('');
|
|
1200
|
+
console.log(' Every other test was left alone.');
|
|
1201
|
+
console.log('');
|
|
1202
|
+
return 0;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
console.error('');
|
|
1206
|
+
if (result.reason === 'nothing-pending') {
|
|
1207
|
+
console.error(' "' + titleOf(key) + '" has no new result waiting.');
|
|
1208
|
+
console.error(' Nothing to accept - it last matched its saved result.');
|
|
1209
|
+
} else if (result.reason === 'no-entry') {
|
|
1210
|
+
console.error(' "' + titleOf(key) + '" has no saved result yet.');
|
|
1211
|
+
} else {
|
|
1212
|
+
console.error(' Could not update the saved result for "' + titleOf(key) + '".');
|
|
1213
|
+
}
|
|
1214
|
+
console.error('');
|
|
1215
|
+
return 1;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
1219
|
+
|
|
1220
|
+
// process.exit() cuts the process off mid-teardown. On Windows a terminal's
|
|
1221
|
+
// output is written asynchronously, so exiting while the last line is still
|
|
1222
|
+
// queued tears down a handle that is already closing - which is what produces
|
|
1223
|
+
// "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)" in libuv. Setting
|
|
1224
|
+
// the code and letting Node wind down on its own avoids that entirely, and
|
|
1225
|
+
// still reports the same exit status.
|
|
1226
|
+
async function finishWith(code) {
|
|
1227
|
+
process.exitCode = code;
|
|
1228
|
+
const streams = [process.stdout, process.stderr];
|
|
1229
|
+
await Promise.all(
|
|
1230
|
+
streams.map(
|
|
1231
|
+
(stream) =>
|
|
1232
|
+
new Promise((resolve) => {
|
|
1233
|
+
if (!stream || typeof stream.write !== 'function' || !stream.writableLength) return resolve();
|
|
1234
|
+
const done = () => resolve();
|
|
1235
|
+
stream.once('drain', done);
|
|
1236
|
+
const guard = setTimeout(done, 1000);
|
|
1237
|
+
if (guard.unref) guard.unref();
|
|
1238
|
+
})
|
|
1239
|
+
)
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async function main() {
|
|
1244
|
+
if (nodeIsTooOld()) {
|
|
1245
|
+
console.error('');
|
|
1246
|
+
console.error(' This tool needs a newer version of Node.');
|
|
1247
|
+
console.error(' You have: ' + process.versions.node);
|
|
1248
|
+
console.error(' You need: 20.6.0 or later');
|
|
1249
|
+
console.error('');
|
|
1250
|
+
console.error(' Download the latest from https://nodejs.org and try again.');
|
|
1251
|
+
console.error('');
|
|
1252
|
+
return finishWith(1);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
switch (command) {
|
|
1256
|
+
case 'record':
|
|
1257
|
+
return finishWith(await record(rest[0]));
|
|
1258
|
+
break;
|
|
1259
|
+
case 'check':
|
|
1260
|
+
return finishWith(check({ quiet: rest.indexOf('--quiet') !== -1 || rest.indexOf('-q') !== -1 }));
|
|
1261
|
+
break;
|
|
1262
|
+
case 'setup-ai':
|
|
1263
|
+
return finishWith(setupAi());
|
|
1264
|
+
case 'accept':
|
|
1265
|
+
return finishWith(accept(rest.join(' ').trim()));
|
|
1266
|
+
break;
|
|
1267
|
+
case undefined:
|
|
1268
|
+
case '-h':
|
|
1269
|
+
case '--help':
|
|
1270
|
+
case 'help':
|
|
1271
|
+
usage();
|
|
1272
|
+
return finishWith(0);
|
|
1273
|
+
break;
|
|
1274
|
+
default:
|
|
1275
|
+
console.error('');
|
|
1276
|
+
console.error(' Unknown command: ' + command);
|
|
1277
|
+
usage();
|
|
1278
|
+
return finishWith(1);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Exported so the naming logic can be checked without opening a browser.
|
|
1283
|
+
module.exports = {
|
|
1284
|
+
humanisePath: humanisePath,
|
|
1285
|
+
nameFromTitle: nameFromTitle,
|
|
1286
|
+
deriveTestName: deriveTestName,
|
|
1287
|
+
slugify: slugify,
|
|
1288
|
+
nameRecording: nameRecording,
|
|
1289
|
+
pointAtFixture: pointAtFixture,
|
|
1290
|
+
listSpecFiles: listSpecFiles,
|
|
1291
|
+
countRecordedActions: countRecordedActions,
|
|
1292
|
+
isRealRecording: isRealRecording,
|
|
1293
|
+
importsPlaywrightDirectly: importsPlaywrightDirectly,
|
|
1294
|
+
triageSpecs: triageSpecs,
|
|
1295
|
+
applyRules: applyRules,
|
|
1296
|
+
rulesBlock: rulesBlock,
|
|
1297
|
+
record: record,
|
|
1298
|
+
finaliseRecording: finaliseRecording,
|
|
1299
|
+
takeOutSecrets: takeOutSecrets,
|
|
1300
|
+
reportReplayRisks: reportReplayRisks,
|
|
1301
|
+
offerToDropLogout: offerToDropLogout,
|
|
1302
|
+
looksLikeNoDesktop: looksLikeNoDesktop,
|
|
1303
|
+
classifyFetchError: classifyFetchError,
|
|
1304
|
+
normaliseRecordUrl: normaliseRecordUrl,
|
|
1305
|
+
isLocalAddress: isLocalAddress,
|
|
1306
|
+
nodeIsTooOld: nodeIsTooOld,
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
if (require.main === module) main();
|