kryptheon 0.1.1 → 0.1.3
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/bin/kryptheon.js +507 -60
- package/kryptheon-replay.js +0 -0
- package/kryptheon-reporter.js +114 -9
- package/package.json +5 -3
package/bin/kryptheon.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
|
-
const { spawnSync } = require('child_process');
|
|
13
|
+
const { spawn, spawnSync } = require('child_process');
|
|
14
|
+
const replay = require('../kryptheon-replay.js');
|
|
14
15
|
|
|
15
16
|
const PACKAGE_DIR = path.join(__dirname, '..');
|
|
16
17
|
const USER_DIR = process.cwd();
|
|
@@ -19,6 +20,10 @@ const TESTS_DIR = path.join(USER_DIR, 'tests');
|
|
|
19
20
|
|
|
20
21
|
const MIN_NODE = [20, 6, 0];
|
|
21
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
|
+
|
|
22
27
|
function nodeIsTooOld(version) {
|
|
23
28
|
const parts = String(version || process.versions.node).split('.').map(Number);
|
|
24
29
|
for (let i = 0; i < MIN_NODE.length; i++) {
|
|
@@ -133,6 +138,43 @@ function listSpecFiles() {
|
|
|
133
138
|
.filter((name) => /\.(spec|test)\.(c|m)?[jt]sx?$/.test(name));
|
|
134
139
|
}
|
|
135
140
|
|
|
141
|
+
// A plain GET on Node's own http/https, deliberately NOT fetch. fetch keeps
|
|
142
|
+
// its sockets alive in a pool, and those were still open at exit - handles
|
|
143
|
+
// being torn down while the process is ending is how libuv assertions happen
|
|
144
|
+
// on Windows. agent:false closes the socket as soon as we are done with it.
|
|
145
|
+
function httpGet(url, timeoutMs, redirectsLeft) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = new URL(url);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return reject(Object.assign(new Error('bad address'), { code: 'ERR_INVALID_URL' }));
|
|
152
|
+
}
|
|
153
|
+
const lib = parsed.protocol === 'https:' ? require('https') : require('http');
|
|
154
|
+
const req = lib.get(url, { agent: false, timeout: timeoutMs || 8000 }, (res) => {
|
|
155
|
+
const location = res.headers && res.headers.location;
|
|
156
|
+
if (location && res.statusCode >= 300 && res.statusCode < 400 && (redirectsLeft || 0) > 0) {
|
|
157
|
+
res.resume();
|
|
158
|
+
req.destroy();
|
|
159
|
+
return resolve(httpGet(new URL(location, url).toString(), timeoutMs, redirectsLeft - 1));
|
|
160
|
+
}
|
|
161
|
+
let body = '';
|
|
162
|
+
res.setEncoding('utf8');
|
|
163
|
+
res.on('data', (chunk) => {
|
|
164
|
+
if (body.length < 300000) body += chunk;
|
|
165
|
+
});
|
|
166
|
+
res.on('end', () => {
|
|
167
|
+
req.destroy();
|
|
168
|
+
resolve({ status: res.statusCode, body: body });
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
req.on('timeout', () => {
|
|
172
|
+
req.destroy(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }));
|
|
173
|
+
});
|
|
174
|
+
req.on('error', reject);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
136
178
|
// --- is the address actually there? ----------------------------------------
|
|
137
179
|
|
|
138
180
|
// "localhost:3000" is what people type; give it a scheme before using it.
|
|
@@ -177,10 +219,7 @@ function classifyFetchError(err) {
|
|
|
177
219
|
|
|
178
220
|
async function reachability(url, timeoutMs) {
|
|
179
221
|
try {
|
|
180
|
-
|
|
181
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs || 8000);
|
|
182
|
-
await fetch(url, { signal: controller.signal, redirect: 'manual' });
|
|
183
|
-
clearTimeout(timer);
|
|
222
|
+
await httpGet(url, timeoutMs || 8000, 0);
|
|
184
223
|
return { ok: true, kind: 'reachable' };
|
|
185
224
|
} catch (err) {
|
|
186
225
|
const kind = classifyFetchError(err);
|
|
@@ -251,6 +290,19 @@ function countRecordedActions(source) {
|
|
|
251
290
|
return found ? found.length : 0;
|
|
252
291
|
}
|
|
253
292
|
|
|
293
|
+
// A spec that pulls Playwright in directly gets none of our diagnostics.
|
|
294
|
+
function importsPlaywrightDirectly(source) {
|
|
295
|
+
const text = String(source || '');
|
|
296
|
+
// The literal specifier is what matters; import and require both carry it.
|
|
297
|
+
return text.indexOf("'@playwright/test'") !== -1 || text.indexOf('"@playwright/test"') !== -1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// codegen always writes the opening page.goto. A recording is only real if
|
|
301
|
+
// something else happened as well.
|
|
302
|
+
function isRealRecording(source) {
|
|
303
|
+
return countRecordedActions(source) >= 2;
|
|
304
|
+
}
|
|
305
|
+
|
|
254
306
|
function timestampName() {
|
|
255
307
|
const d = new Date();
|
|
256
308
|
const pad = (n) => String(n).padStart(2, '0');
|
|
@@ -324,13 +376,9 @@ function nameFromTitle(title) {
|
|
|
324
376
|
// to the recorded path if this cannot be reached.
|
|
325
377
|
async function fetchTitle(url) {
|
|
326
378
|
try {
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
clearTimeout(timer);
|
|
331
|
-
if (!res.ok) return null;
|
|
332
|
-
const html = await res.text();
|
|
333
|
-
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
379
|
+
const res = await httpGet(url, 5000, 2);
|
|
380
|
+
if (!res || res.status < 200 || res.status >= 300) return null;
|
|
381
|
+
const m = String(res.body).match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
334
382
|
return m ? m[1].replace(/\s+/g, ' ').trim() : null;
|
|
335
383
|
} catch (err) {
|
|
336
384
|
return null;
|
|
@@ -357,8 +405,13 @@ function deriveTestName(source) {
|
|
|
357
405
|
if (name) return name;
|
|
358
406
|
}
|
|
359
407
|
|
|
360
|
-
|
|
361
|
-
|
|
408
|
+
// The first thing done describes a recording better than the last, and the
|
|
409
|
+
// last is often "Sign Out", which would name every recording after the way
|
|
410
|
+
// it ended rather than what it was for.
|
|
411
|
+
const names = [...String(source).matchAll(/name:\s*['"]([^'"]+)['"]/g)]
|
|
412
|
+
.map((m) => m[1].trim())
|
|
413
|
+
.filter((name) => name && !/(log|sign)[\s_-]?out/i.test(name));
|
|
414
|
+
if (names.length) return names[0];
|
|
362
415
|
|
|
363
416
|
const hosts = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
|
|
364
417
|
if (hosts.length) {
|
|
@@ -420,72 +473,207 @@ async function nameRecording(relativeFile) {
|
|
|
420
473
|
}
|
|
421
474
|
return target;
|
|
422
475
|
}
|
|
476
|
+
// Recording needs a real browser window on someone's screen. Inside an AI
|
|
477
|
+
// coding assistant there is no desktop to draw it on, so codegen would sit
|
|
478
|
+
// there forever and the run would be killed before anything was tidied up.
|
|
479
|
+
function looksLikeNoDesktop() {
|
|
480
|
+
if (process.env.KRYPTHEON_FORCE_RECORD) return false;
|
|
481
|
+
return !process.stdout.isTTY;
|
|
482
|
+
}
|
|
423
483
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
484
|
+
function explainNoWindow(url) {
|
|
485
|
+
console.error('');
|
|
486
|
+
console.error(' Recording needs a real browser window on your screen.');
|
|
487
|
+
console.error('');
|
|
488
|
+
console.error(' This terminal cannot show one - that is what happens inside an');
|
|
489
|
+
console.error(' AI coding assistant, or any window-less session.');
|
|
490
|
+
console.error('');
|
|
491
|
+
console.error(' Open Command Prompt (or PowerShell) yourself and run:');
|
|
492
|
+
console.error(' npx kryptheon record ' + url);
|
|
493
|
+
console.error('');
|
|
494
|
+
console.error(' Nothing was recorded.');
|
|
495
|
+
console.error('');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Is a Playwright-controlled browser actually showing a window?
|
|
499
|
+
function browserWindowIsUp() {
|
|
500
|
+
try {
|
|
501
|
+
if (process.platform === 'win32') {
|
|
502
|
+
const probe = spawnSync(
|
|
503
|
+
'powershell',
|
|
504
|
+
[
|
|
505
|
+
'-NoProfile',
|
|
506
|
+
'-Command',
|
|
507
|
+
"@(Get-Process chrome,msedge -ErrorAction SilentlyContinue | " +
|
|
508
|
+
"Where-Object { $_.Path -like '*ms-playwright*' -and $_.MainWindowHandle -ne 0 }).Count",
|
|
509
|
+
],
|
|
510
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 8000 }
|
|
511
|
+
);
|
|
512
|
+
return Number(String(probe.stdout || '').trim()) > 0;
|
|
513
|
+
}
|
|
514
|
+
const probe = spawnSync('ps', ['-A', '-o', 'command'], { encoding: 'utf8', timeout: 8000 });
|
|
515
|
+
return /ms-playwright/.test(String(probe.stdout || ''));
|
|
516
|
+
} catch (err) {
|
|
517
|
+
return true; // cannot tell - do not block the user
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Runs codegen without blocking the event loop, so SIGINT and SIGTERM can
|
|
522
|
+
// still be handled. spawnSync would freeze the loop and no handler would run.
|
|
523
|
+
function startCodegen(args) {
|
|
524
|
+
const cli = findPlaywrightCli();
|
|
525
|
+
if (!cli) {
|
|
428
526
|
console.error('');
|
|
429
|
-
console.error('
|
|
430
|
-
console.error('
|
|
527
|
+
console.error(' The testing engine is missing from this install.');
|
|
528
|
+
console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
|
|
431
529
|
console.error('');
|
|
432
|
-
return
|
|
530
|
+
return null;
|
|
433
531
|
}
|
|
434
|
-
|
|
532
|
+
return spawn(process.execPath, [cli].concat(args), {
|
|
533
|
+
cwd: USER_DIR,
|
|
534
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
535
|
+
});
|
|
536
|
+
}
|
|
435
537
|
|
|
436
|
-
|
|
437
|
-
const reach = await reachability(target);
|
|
438
|
-
if (!reach.ok) {
|
|
439
|
-
explainUnreachable(target, reach.kind);
|
|
440
|
-
return 1;
|
|
441
|
-
}
|
|
538
|
+
// --- making a recording fit to run twice ------------------------------------
|
|
442
539
|
|
|
443
|
-
|
|
540
|
+
function readSpec(relativeFile) {
|
|
541
|
+
try {
|
|
542
|
+
return fs.readFileSync(path.join(USER_DIR, relativeFile), 'utf8');
|
|
543
|
+
} catch (err) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
444
547
|
|
|
445
|
-
|
|
548
|
+
function writeSpec(relativeFile, source) {
|
|
446
549
|
try {
|
|
447
|
-
fs.
|
|
550
|
+
fs.writeFileSync(path.join(USER_DIR, relativeFile), source, 'utf8');
|
|
551
|
+
return true;
|
|
448
552
|
} catch (err) {
|
|
449
|
-
|
|
553
|
+
return false;
|
|
450
554
|
}
|
|
555
|
+
}
|
|
451
556
|
|
|
452
|
-
|
|
557
|
+
// Passwords typed during recording land in the file as plain text. Swap them
|
|
558
|
+
// for environment variables before the file is left lying around.
|
|
559
|
+
function takeOutSecrets(relativeFile) {
|
|
560
|
+
const source = readSpec(relativeFile);
|
|
561
|
+
if (source === null) return [];
|
|
562
|
+
const result = replay.scrubSecrets(source);
|
|
563
|
+
if (!result.replacements.length) return [];
|
|
564
|
+
return writeSpec(relativeFile, result.source) ? result.replacements : [];
|
|
565
|
+
}
|
|
453
566
|
|
|
567
|
+
function reportSecrets(replacements) {
|
|
568
|
+
if (!replacements || !replacements.length) return;
|
|
569
|
+
console.log(' What you typed into the password box was not saved in the test.');
|
|
454
570
|
console.log('');
|
|
455
|
-
console.log('
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
571
|
+
console.log(' Put it in a file called .env next to your tests, like this:');
|
|
572
|
+
for (const item of replacements) {
|
|
573
|
+
console.log(' ' + item.envName + '=your ' + String(item.field).toLowerCase() + ' here');
|
|
574
|
+
}
|
|
459
575
|
console.log('');
|
|
460
|
-
console.log('
|
|
576
|
+
console.log(' Keep .env out of version control - it holds the real value.');
|
|
461
577
|
console.log('');
|
|
578
|
+
}
|
|
462
579
|
|
|
463
|
-
|
|
464
|
-
|
|
580
|
+
function askYesNo(question) {
|
|
581
|
+
return new Promise((resolve) => {
|
|
582
|
+
if (!process.stdin.isTTY) return resolve(false);
|
|
583
|
+
const readline = require('readline');
|
|
584
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
585
|
+
rl.question(question, (answer) => {
|
|
586
|
+
rl.close();
|
|
587
|
+
// Let the loop finish later: stdin stays open otherwise.
|
|
588
|
+
try {
|
|
589
|
+
process.stdin.pause();
|
|
590
|
+
} catch (err) {
|
|
591
|
+
/* nothing to do */
|
|
592
|
+
}
|
|
593
|
+
resolve(!/^\s*n/i.test(String(answer)));
|
|
594
|
+
});
|
|
465
595
|
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Ending on a logout means the next run starts somewhere else. Almost nobody
|
|
599
|
+
// wants that, so ask.
|
|
600
|
+
async function offerToDropLogout(relativeFile) {
|
|
601
|
+
const source = readSpec(relativeFile);
|
|
602
|
+
if (source === null) return false;
|
|
603
|
+
const trimmed = replay.dropTrailingLogout(source);
|
|
604
|
+
if (!trimmed.removed) return false;
|
|
466
605
|
|
|
606
|
+
console.log(' The recording ends by logging out ("' + trimmed.removed + '").');
|
|
607
|
+
console.log(' That means the next run would not start where this one did.');
|
|
608
|
+
console.log('');
|
|
609
|
+
|
|
610
|
+
const drop = await askYesNo(' Leave that last step out? [Y/n] ');
|
|
611
|
+
if (!drop) {
|
|
612
|
+
console.log('');
|
|
613
|
+
console.log(' Kept it. If checks fail from the second run onwards, this is why.');
|
|
614
|
+
console.log('');
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
writeSpec(relativeFile, trimmed.source);
|
|
618
|
+
console.log('');
|
|
619
|
+
console.log(' Removed the sign-out step.');
|
|
620
|
+
console.log('');
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function reportReplayRisks(relativeFile) {
|
|
625
|
+
const source = readSpec(relativeFile);
|
|
626
|
+
if (source === null) return [];
|
|
627
|
+
const risks = replay.findReplayRisks(source);
|
|
628
|
+
if (!risks.length) return [];
|
|
629
|
+
|
|
630
|
+
console.log(' Before you rely on this recording, one thing to know:');
|
|
631
|
+
console.log('');
|
|
632
|
+
for (const risk of risks) {
|
|
633
|
+
console.log(' "' + risk.step + '" (line ' + risk.line + ')');
|
|
634
|
+
console.log(' ' + risk.why + '.');
|
|
635
|
+
console.log(' What to do: ' + risk.fix + '.');
|
|
636
|
+
console.log('');
|
|
637
|
+
}
|
|
638
|
+
console.log(' Everything else in the recording will run again fine.');
|
|
639
|
+
console.log('');
|
|
640
|
+
return risks;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Everything that has to happen to whatever codegen left behind, however
|
|
644
|
+
// the run ended. Module level so it can be exercised directly.
|
|
645
|
+
async function finaliseRecording(outFile, context) {
|
|
646
|
+
const ctx = context || {};
|
|
467
647
|
const specPath = path.join(USER_DIR, outFile);
|
|
468
648
|
let source = null;
|
|
469
649
|
try {
|
|
470
650
|
source = fs.readFileSync(specPath, 'utf8');
|
|
471
651
|
} catch (err) {
|
|
472
|
-
source = null;
|
|
652
|
+
source = null;
|
|
473
653
|
}
|
|
474
654
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
if (source !== null && countRecordedActions(source) > 0) {
|
|
655
|
+
if (source !== null && isRealRecording(source)) {
|
|
656
|
+
// Import rewrite first: it is instant, so even a second interruption
|
|
657
|
+
// leaves behind a spec that works.
|
|
479
658
|
pointAtFixture(outFile);
|
|
480
|
-
|
|
659
|
+
|
|
660
|
+
// Anything typed into a password box is still a literal in this file at
|
|
661
|
+
// this point. Take it out before doing anything else with the file.
|
|
662
|
+
const secrets = takeOutSecrets(outFile);
|
|
663
|
+
|
|
664
|
+
const named = await nameRecording(outFile);
|
|
481
665
|
console.log('');
|
|
482
|
-
console.log(' Saved to ' +
|
|
666
|
+
console.log(' Saved to ' + named);
|
|
483
667
|
console.log(' Run it any time with: kryptheon check');
|
|
484
668
|
console.log('');
|
|
485
|
-
|
|
669
|
+
|
|
670
|
+
reportSecrets(secrets);
|
|
671
|
+
await offerToDropLogout(named);
|
|
672
|
+
reportReplayRisks(named);
|
|
673
|
+
|
|
674
|
+
return { code: 0, savedAs: named };
|
|
486
675
|
}
|
|
487
676
|
|
|
488
|
-
// Nothing usable: leave no empty file behind.
|
|
489
677
|
if (source !== null) {
|
|
490
678
|
try {
|
|
491
679
|
fs.unlinkSync(specPath);
|
|
@@ -493,7 +681,7 @@ async function record(url) {
|
|
|
493
681
|
/* nothing more we can do */
|
|
494
682
|
}
|
|
495
683
|
}
|
|
496
|
-
if (!hadTestsDir) {
|
|
684
|
+
if (!ctx.hadTestsDir) {
|
|
497
685
|
try {
|
|
498
686
|
if (fs.readdirSync(TESTS_DIR).length === 0) fs.rmdirSync(TESTS_DIR);
|
|
499
687
|
} catch (err) {
|
|
@@ -501,8 +689,16 @@ async function record(url) {
|
|
|
501
689
|
}
|
|
502
690
|
}
|
|
503
691
|
|
|
504
|
-
if (
|
|
505
|
-
|
|
692
|
+
if (ctx.reason === 'no-window') {
|
|
693
|
+
explainNoWindow(ctx.target);
|
|
694
|
+
} else if (ctx.reason === 'signal') {
|
|
695
|
+
console.log('');
|
|
696
|
+
console.log(' Recording stopped before anything was saved.');
|
|
697
|
+
console.log('');
|
|
698
|
+
console.log(' Nothing was recorded.');
|
|
699
|
+
console.log('');
|
|
700
|
+
} else if (source === null || /ERR_|Error:/.test(String(ctx.stderr || ''))) {
|
|
701
|
+
explainCodegenFailure(ctx.target, ctx.stderr);
|
|
506
702
|
} else {
|
|
507
703
|
console.log('');
|
|
508
704
|
console.log(' Nothing was recorded.');
|
|
@@ -511,7 +707,125 @@ async function record(url) {
|
|
|
511
707
|
console.log(' Run the same command again and use your app before closing it.');
|
|
512
708
|
console.log('');
|
|
513
709
|
}
|
|
514
|
-
return 1;
|
|
710
|
+
return { code: 1, savedAs: null };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function record(url) {
|
|
714
|
+
if (!url) {
|
|
715
|
+
console.error('');
|
|
716
|
+
console.error(' Which address should I open?');
|
|
717
|
+
console.error('');
|
|
718
|
+
console.error(' Add the web address of your app, for example:');
|
|
719
|
+
console.error(' kryptheon record https://www.example.com');
|
|
720
|
+
console.error('');
|
|
721
|
+
return 1;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const target = normaliseRecordUrl(url);
|
|
725
|
+
|
|
726
|
+
// No desktop means no window means nothing to record. Say so now rather
|
|
727
|
+
// than hanging until something kills us.
|
|
728
|
+
if (looksLikeNoDesktop()) {
|
|
729
|
+
explainNoWindow(target);
|
|
730
|
+
return 1;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Fail early and kindly rather than letting codegen throw a stack trace.
|
|
734
|
+
const reach = await reachability(target);
|
|
735
|
+
if (!reach.ok) {
|
|
736
|
+
explainUnreachable(target, reach.kind);
|
|
737
|
+
return 1;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (!ensureBrowser()) return 1;
|
|
741
|
+
|
|
742
|
+
const hadTestsDir = fs.existsSync(TESTS_DIR);
|
|
743
|
+
try {
|
|
744
|
+
fs.mkdirSync(TESTS_DIR, { recursive: true });
|
|
745
|
+
} catch (err) {
|
|
746
|
+
/* codegen will report if it cannot write */
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const outFile = path.join('tests', timestampName());
|
|
750
|
+
const specPath = path.join(USER_DIR, outFile);
|
|
751
|
+
|
|
752
|
+
console.log('');
|
|
753
|
+
console.log(' Opening ' + target + ' in a browser.');
|
|
754
|
+
console.log('');
|
|
755
|
+
console.log(' Use your app normally - click, type, sign in, whatever you want');
|
|
756
|
+
console.log(' covered. Every step is recorded as you go.');
|
|
757
|
+
console.log('');
|
|
758
|
+
console.log(' When you are done, close the browser window to save the test.');
|
|
759
|
+
console.log('');
|
|
760
|
+
|
|
761
|
+
const child = startCodegen(['codegen', '--target', 'playwright-test', '-o', outFile, target]);
|
|
762
|
+
if (!child) return 1;
|
|
763
|
+
|
|
764
|
+
let stderr = '';
|
|
765
|
+
if (child.stderr) child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
766
|
+
|
|
767
|
+
// Everything that has to happen no matter how this ends: tidy the file,
|
|
768
|
+
// wire it up, name it. Runs once, whether codegen exits on its own or we
|
|
769
|
+
// are interrupted.
|
|
770
|
+
let finished = false;
|
|
771
|
+
const finalise = async (reason) => {
|
|
772
|
+
if (finished) return 1;
|
|
773
|
+
finished = true;
|
|
774
|
+
const outcome = await finaliseRecording(outFile, {
|
|
775
|
+
hadTestsDir: hadTestsDir,
|
|
776
|
+
target: target,
|
|
777
|
+
stderr: stderr,
|
|
778
|
+
reason: reason,
|
|
779
|
+
});
|
|
780
|
+
return outcome.code;
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
const stopChild = () => {
|
|
784
|
+
try {
|
|
785
|
+
child.kill();
|
|
786
|
+
} catch (err) {
|
|
787
|
+
/* already gone */
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
return await new Promise((resolve) => {
|
|
792
|
+
// If no window ever appears, stop instead of waiting for someone to kill us.
|
|
793
|
+
let waited = 0;
|
|
794
|
+
const watchdog = setInterval(() => {
|
|
795
|
+
waited += WINDOW_POLL_MS;
|
|
796
|
+
if (browserWindowIsUp()) {
|
|
797
|
+
clearInterval(watchdog);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (waited >= WINDOW_TIMEOUT_MS) {
|
|
801
|
+
clearInterval(watchdog);
|
|
802
|
+
stopChild();
|
|
803
|
+
finalise('no-window').then(resolve);
|
|
804
|
+
}
|
|
805
|
+
}, WINDOW_POLL_MS);
|
|
806
|
+
|
|
807
|
+
const onSignal = () => {
|
|
808
|
+
clearInterval(watchdog);
|
|
809
|
+
stopChild();
|
|
810
|
+
finalise('signal').then(resolve);
|
|
811
|
+
};
|
|
812
|
+
process.once('SIGINT', onSignal);
|
|
813
|
+
process.once('SIGTERM', onSignal);
|
|
814
|
+
process.once('SIGHUP', onSignal);
|
|
815
|
+
|
|
816
|
+
child.on('error', () => {
|
|
817
|
+
clearInterval(watchdog);
|
|
818
|
+
finalise('error').then(resolve);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
child.on('close', () => {
|
|
822
|
+
clearInterval(watchdog);
|
|
823
|
+
process.removeListener('SIGINT', onSignal);
|
|
824
|
+
process.removeListener('SIGTERM', onSignal);
|
|
825
|
+
process.removeListener('SIGHUP', onSignal);
|
|
826
|
+
finalise('exit').then(resolve);
|
|
827
|
+
});
|
|
828
|
+
});
|
|
515
829
|
}
|
|
516
830
|
|
|
517
831
|
// Recordings import the fixture by package name, which only resolves if
|
|
@@ -545,6 +859,58 @@ function specsNeedThePackage() {
|
|
|
545
859
|
});
|
|
546
860
|
}
|
|
547
861
|
|
|
862
|
+
// Older versions, and any run that was killed part way, left specs behind that
|
|
863
|
+
// import Playwright directly (so they produce no diagnostics) or that contain
|
|
864
|
+
// nothing but the opening navigation (so they verify nothing). Repair the
|
|
865
|
+
// first kind and refuse to run the second, rather than reporting a false OK.
|
|
866
|
+
function triageSpecs() {
|
|
867
|
+
const repaired = [];
|
|
868
|
+
const notRecordings = [];
|
|
869
|
+
const runnable = [];
|
|
870
|
+
const withSecrets = [];
|
|
871
|
+
|
|
872
|
+
for (const name of listSpecFiles()) {
|
|
873
|
+
const full = path.join(TESTS_DIR, name);
|
|
874
|
+
let source;
|
|
875
|
+
try {
|
|
876
|
+
source = fs.readFileSync(full, 'utf8');
|
|
877
|
+
} catch (err) {
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (/(from|require\()\s*(['"])@playwright\/test\2/.test(source)) {
|
|
882
|
+
const relative = path.join('tests', name);
|
|
883
|
+
if (pointAtFixture(relative)) {
|
|
884
|
+
repaired.push(name);
|
|
885
|
+
try {
|
|
886
|
+
source = fs.readFileSync(full, 'utf8');
|
|
887
|
+
} catch (err) {
|
|
888
|
+
/* keep what we had */
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (!isRealRecording(source)) {
|
|
894
|
+
notRecordings.push(name);
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
// Recordings made before secrets were stripped still hold the real value.
|
|
898
|
+
// Say so rather than rewriting: a spec that works today should keep
|
|
899
|
+
// working until the person has somewhere to put the password.
|
|
900
|
+
if (replay.scrubSecrets(source).replacements.length) {
|
|
901
|
+
withSecrets.push({ name: name, fields: replay.scrubSecrets(source).replacements });
|
|
902
|
+
}
|
|
903
|
+
runnable.push(name);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return {
|
|
907
|
+
repaired: repaired,
|
|
908
|
+
notRecordings: notRecordings,
|
|
909
|
+
runnable: runnable,
|
|
910
|
+
withSecrets: withSecrets,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
548
914
|
function check() {
|
|
549
915
|
if (!listSpecFiles().length) {
|
|
550
916
|
console.log('');
|
|
@@ -570,8 +936,57 @@ function check() {
|
|
|
570
936
|
console.log('');
|
|
571
937
|
return 1;
|
|
572
938
|
}
|
|
939
|
+
const triage = triageSpecs();
|
|
940
|
+
|
|
941
|
+
for (const name of triage.repaired) {
|
|
942
|
+
console.log('');
|
|
943
|
+
console.log(' Repaired ' + path.join('tests', name));
|
|
944
|
+
console.log(' It was saved without the part that explains failures. Fixed now.');
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
for (const item of triage.withSecrets) {
|
|
948
|
+
console.log('');
|
|
949
|
+
console.log(' Careful: ' + path.join('tests', item.name) + ' has a real password written into it.');
|
|
950
|
+
console.log(' Anyone who can read that file can read the password.');
|
|
951
|
+
console.log(' Re-record it to have the password moved into .env automatically.');
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
for (const name of triage.notRecordings) {
|
|
955
|
+
console.log('');
|
|
956
|
+
console.log(' Skipped ' + path.join('tests', name));
|
|
957
|
+
console.log(' This is not a real recording - it only opens a page and stops,');
|
|
958
|
+
console.log(' so it cannot tell you whether anything works.');
|
|
959
|
+
console.log(' Record it again: kryptheon record <url>');
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (!triage.runnable.length) {
|
|
963
|
+
console.log('');
|
|
964
|
+
console.log(' Nothing could be checked.');
|
|
965
|
+
console.log('');
|
|
966
|
+
console.log(' None of the files in tests/ is a usable recording.');
|
|
967
|
+
console.log(' Record one with: kryptheon record <url>');
|
|
968
|
+
console.log('');
|
|
969
|
+
return 1;
|
|
970
|
+
}
|
|
971
|
+
|
|
573
972
|
if (!ensureBrowser()) return 1;
|
|
574
|
-
|
|
973
|
+
|
|
974
|
+
// Only the usable recordings are handed to the test runner, so a file that
|
|
975
|
+
// cannot produce diagnostics can never be counted as passing.
|
|
976
|
+
const only = triage.runnable.map((name) => path.join('tests', name).split(path.sep).join('/'));
|
|
977
|
+
const status = runPlaywright(['test', '--config', CONFIG].concat(only)).status;
|
|
978
|
+
|
|
979
|
+
// Without this, a green summary could still hide a recording that checked
|
|
980
|
+
// nothing at all.
|
|
981
|
+
if (triage.notRecordings.length) {
|
|
982
|
+
const many = triage.notRecordings.length > 1;
|
|
983
|
+
console.log(
|
|
984
|
+
' Note: ' + triage.notRecordings.length + ' recording' + (many ? 's' : '') +
|
|
985
|
+
' above checked nothing and ' + (many ? 'were' : 'was') + ' skipped.'
|
|
986
|
+
);
|
|
987
|
+
console.log('');
|
|
988
|
+
}
|
|
989
|
+
return status;
|
|
575
990
|
}
|
|
576
991
|
|
|
577
992
|
// --- accept -----------------------------------------------------------------
|
|
@@ -676,6 +1091,29 @@ function accept(name) {
|
|
|
676
1091
|
|
|
677
1092
|
const [command, ...rest] = process.argv.slice(2);
|
|
678
1093
|
|
|
1094
|
+
// process.exit() cuts the process off mid-teardown. On Windows a terminal's
|
|
1095
|
+
// output is written asynchronously, so exiting while the last line is still
|
|
1096
|
+
// queued tears down a handle that is already closing - which is what produces
|
|
1097
|
+
// "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)" in libuv. Setting
|
|
1098
|
+
// the code and letting Node wind down on its own avoids that entirely, and
|
|
1099
|
+
// still reports the same exit status.
|
|
1100
|
+
async function finishWith(code) {
|
|
1101
|
+
process.exitCode = code;
|
|
1102
|
+
const streams = [process.stdout, process.stderr];
|
|
1103
|
+
await Promise.all(
|
|
1104
|
+
streams.map(
|
|
1105
|
+
(stream) =>
|
|
1106
|
+
new Promise((resolve) => {
|
|
1107
|
+
if (!stream || typeof stream.write !== 'function' || !stream.writableLength) return resolve();
|
|
1108
|
+
const done = () => resolve();
|
|
1109
|
+
stream.once('drain', done);
|
|
1110
|
+
const guard = setTimeout(done, 1000);
|
|
1111
|
+
if (guard.unref) guard.unref();
|
|
1112
|
+
})
|
|
1113
|
+
)
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
679
1117
|
async function main() {
|
|
680
1118
|
if (nodeIsTooOld()) {
|
|
681
1119
|
console.error('');
|
|
@@ -685,31 +1123,31 @@ async function main() {
|
|
|
685
1123
|
console.error('');
|
|
686
1124
|
console.error(' Download the latest from https://nodejs.org and try again.');
|
|
687
1125
|
console.error('');
|
|
688
|
-
|
|
1126
|
+
return finishWith(1);
|
|
689
1127
|
}
|
|
690
1128
|
|
|
691
1129
|
switch (command) {
|
|
692
1130
|
case 'record':
|
|
693
|
-
|
|
1131
|
+
return finishWith(await record(rest[0]));
|
|
694
1132
|
break;
|
|
695
1133
|
case 'check':
|
|
696
|
-
|
|
1134
|
+
return finishWith(check());
|
|
697
1135
|
break;
|
|
698
1136
|
case 'accept':
|
|
699
|
-
|
|
1137
|
+
return finishWith(accept(rest.join(' ').trim()));
|
|
700
1138
|
break;
|
|
701
1139
|
case undefined:
|
|
702
1140
|
case '-h':
|
|
703
1141
|
case '--help':
|
|
704
1142
|
case 'help':
|
|
705
1143
|
usage();
|
|
706
|
-
|
|
1144
|
+
return finishWith(0);
|
|
707
1145
|
break;
|
|
708
1146
|
default:
|
|
709
1147
|
console.error('');
|
|
710
1148
|
console.error(' Unknown command: ' + command);
|
|
711
1149
|
usage();
|
|
712
|
-
|
|
1150
|
+
return finishWith(1);
|
|
713
1151
|
}
|
|
714
1152
|
}
|
|
715
1153
|
|
|
@@ -723,6 +1161,15 @@ module.exports = {
|
|
|
723
1161
|
pointAtFixture: pointAtFixture,
|
|
724
1162
|
listSpecFiles: listSpecFiles,
|
|
725
1163
|
countRecordedActions: countRecordedActions,
|
|
1164
|
+
isRealRecording: isRealRecording,
|
|
1165
|
+
importsPlaywrightDirectly: importsPlaywrightDirectly,
|
|
1166
|
+
triageSpecs: triageSpecs,
|
|
1167
|
+
record: record,
|
|
1168
|
+
finaliseRecording: finaliseRecording,
|
|
1169
|
+
takeOutSecrets: takeOutSecrets,
|
|
1170
|
+
reportReplayRisks: reportReplayRisks,
|
|
1171
|
+
offerToDropLogout: offerToDropLogout,
|
|
1172
|
+
looksLikeNoDesktop: looksLikeNoDesktop,
|
|
726
1173
|
classifyFetchError: classifyFetchError,
|
|
727
1174
|
normaliseRecordUrl: normaliseRecordUrl,
|
|
728
1175
|
isLocalAddress: isLocalAddress,
|
|
Binary file
|
package/kryptheon-reporter.js
CHANGED
|
@@ -477,6 +477,84 @@ function buildPromptLines(testTitle, translated, obs, lastPassIso, now) {
|
|
|
477
477
|
);
|
|
478
478
|
}
|
|
479
479
|
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// The first run is a special case: nothing has regressed, because nothing has
|
|
482
|
+
// ever passed. Usually the recording itself contains a step that only worked
|
|
483
|
+
// the once.
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
// Playwright works the error's file out of a stack trace, and its parser
|
|
487
|
+
// stops at the first "(" - so a folder called "New folder (2)" arrives as
|
|
488
|
+
// "2)\tests\thing.spec.js". The path declared for the test itself is read from
|
|
489
|
+
// the file system and is always whole, so prefer that whenever the stack's
|
|
490
|
+
// version does not point at a real file.
|
|
491
|
+
function trustworthySpecPath(test, errorFile) {
|
|
492
|
+
const declared = (test && test.location && test.location.file) || null;
|
|
493
|
+
if (errorFile && path.isAbsolute(errorFile)) {
|
|
494
|
+
try {
|
|
495
|
+
if (fs.existsSync(errorFile)) return errorFile;
|
|
496
|
+
} catch (err) {
|
|
497
|
+
/* fall through to the declared path */
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (declared) {
|
|
501
|
+
try {
|
|
502
|
+
if (fs.existsSync(declared)) return declared;
|
|
503
|
+
} catch (err) {
|
|
504
|
+
/* fall through */
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return errorFile || declared || null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Identity of the recording itself, so history cannot be inherited by a
|
|
511
|
+
// different recording that happens to share a name.
|
|
512
|
+
function recordingIdFor(specPath) {
|
|
513
|
+
if (!specPath) return null;
|
|
514
|
+
let source;
|
|
515
|
+
try {
|
|
516
|
+
source = fs.readFileSync(specPath, 'utf8');
|
|
517
|
+
} catch (err) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
return require('./kryptheon-replay.js').recordingId(source);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function firstRunAdvice(test) {
|
|
528
|
+
const lines = [];
|
|
529
|
+
lines.push(' This is the first run of this recording, so nothing has broken yet -');
|
|
530
|
+
lines.push(' a step that cannot happen twice is the more likely explanation.');
|
|
531
|
+
|
|
532
|
+
const file = test && test.location && test.location.file;
|
|
533
|
+
if (!file) return { lines: lines, namedAStep: false };
|
|
534
|
+
|
|
535
|
+
let source;
|
|
536
|
+
try {
|
|
537
|
+
source = fs.readFileSync(file, 'utf8');
|
|
538
|
+
} catch (err) {
|
|
539
|
+
return { lines: lines, namedAStep: false };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
let risks = [];
|
|
543
|
+
try {
|
|
544
|
+
risks = require('./kryptheon-replay.js').findReplayRisks(source);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
return { lines: lines, namedAStep: false };
|
|
547
|
+
}
|
|
548
|
+
if (!risks.length) return { lines: lines, namedAStep: false };
|
|
549
|
+
|
|
550
|
+
lines.push(' Most likely the step that cannot repeat:');
|
|
551
|
+
for (const risk of risks.slice(0, 3)) {
|
|
552
|
+
lines.push(' - "' + risk.step + '" (line ' + risk.line + ') - ' + risk.why + '.');
|
|
553
|
+
}
|
|
554
|
+
lines.push(' Record again without it, and this should settle down.');
|
|
555
|
+
return { lines: lines, namedAStep: true };
|
|
556
|
+
}
|
|
557
|
+
|
|
480
558
|
// ---------------------------------------------------------------------------
|
|
481
559
|
// Reporter
|
|
482
560
|
// ---------------------------------------------------------------------------
|
|
@@ -518,15 +596,22 @@ class KryptheonReporter {
|
|
|
518
596
|
}
|
|
519
597
|
}
|
|
520
598
|
|
|
521
|
-
// Most recent earlier run in which
|
|
522
|
-
|
|
599
|
+
// Most recent earlier run in which THIS recording passed. The name alone is
|
|
600
|
+
// not enough: deleting a recording and making a new one that happens to end
|
|
601
|
+
// up with the same name would otherwise inherit the old one's history, and
|
|
602
|
+
// a brand new recording would be told it "was working" yesterday.
|
|
603
|
+
_lastPassed(title, recordingId) {
|
|
523
604
|
for (let i = this.previousRuns.length - 1; i >= 0; i--) {
|
|
524
605
|
const run = this.previousRuns[i];
|
|
525
606
|
const tests = (run && run.tests) || [];
|
|
526
607
|
for (let j = tests.length - 1; j >= 0; j--) {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
608
|
+
const entry = tests[j];
|
|
609
|
+
if (!entry || entry.title !== title || entry.status !== 'passed') continue;
|
|
610
|
+
// Entries written before recordings were fingerprinted cannot be
|
|
611
|
+
// matched to one, so they are not claimed by any.
|
|
612
|
+
if (!entry.recordingId || !recordingId) continue;
|
|
613
|
+
if (entry.recordingId !== recordingId) continue;
|
|
614
|
+
return entry.timestamp || run.runAt;
|
|
530
615
|
}
|
|
531
616
|
}
|
|
532
617
|
return null;
|
|
@@ -537,11 +622,16 @@ class KryptheonReporter {
|
|
|
537
622
|
result.status === 'passed' ? 'passed' : result.status === 'skipped' ? 'skipped' : 'failed';
|
|
538
623
|
const timestamp = (result.startTime instanceof Date ? result.startTime : new Date()).toISOString();
|
|
539
624
|
|
|
625
|
+
const specPath = trustworthySpecPath(test, null);
|
|
626
|
+
const recordingId = recordingIdFor(specPath);
|
|
627
|
+
|
|
540
628
|
const record = {
|
|
541
629
|
title: test.title,
|
|
542
630
|
status: status,
|
|
543
631
|
durationMs: result.duration,
|
|
544
632
|
timestamp: timestamp,
|
|
633
|
+
recordingId: recordingId,
|
|
634
|
+
specFile: specPath ? path.relative(USER_DIR, specPath) : null,
|
|
545
635
|
};
|
|
546
636
|
|
|
547
637
|
if (status === 'skipped') {
|
|
@@ -585,7 +675,7 @@ class KryptheonReporter {
|
|
|
585
675
|
const observations = readObservations(result.attachments);
|
|
586
676
|
const located = primary.location || (errors.find(function (e) { return e.location; }) || {}).location;
|
|
587
677
|
const line = located ? located.line : null;
|
|
588
|
-
const file = located ? located.file : null;
|
|
678
|
+
const file = trustworthySpecPath(test, located ? located.file : null);
|
|
589
679
|
const locator = extractLocator(message);
|
|
590
680
|
const translated = translate(message);
|
|
591
681
|
|
|
@@ -612,13 +702,28 @@ class KryptheonReporter {
|
|
|
612
702
|
|
|
613
703
|
for (const detail of translated.details || []) out.push(' ' + detail);
|
|
614
704
|
|
|
615
|
-
const lastPass = this._lastPassed(test.title);
|
|
705
|
+
const lastPass = this._lastPassed(test.title, recordingId);
|
|
616
706
|
out.push(lastPass ? ' This was working on ' + formatWhen(lastPass) + '.' : ' This has not passed before.');
|
|
617
707
|
|
|
618
|
-
|
|
708
|
+
// A first run that fails is a different situation from a regression:
|
|
709
|
+
// nothing has broken, because nothing ever worked. Far more often the
|
|
710
|
+
// recording contains a step that cannot happen twice.
|
|
711
|
+
let blamedTheRecording = false;
|
|
712
|
+
if (!lastPass) {
|
|
713
|
+
const advice = firstRunAdvice(test);
|
|
714
|
+
for (const line of advice.lines) out.push(line);
|
|
715
|
+
blamedTheRecording = advice.namedAStep;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// On a first run we have already named the step that cannot repeat, and
|
|
719
|
+
// the usual "your last change may have..." advice would contradict it.
|
|
720
|
+
if (blamedTheRecording) {
|
|
721
|
+
/* the first-run block above already says what to do */
|
|
722
|
+
} else if (translated.advice) {
|
|
619
723
|
out.push(' What to check: ' + translated.advice.split('%TEST%').join(test.title));
|
|
724
|
+
} else {
|
|
725
|
+
out.push(' What to check: this is the raw message from the test tool - it was not recognised.');
|
|
620
726
|
}
|
|
621
|
-
else out.push(' What to check: this is the raw message from the test tool - it was not recognised.');
|
|
622
727
|
|
|
623
728
|
for (const observed of whereToLookLines(observations)) out.push(observed);
|
|
624
729
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Record what you do in your app and get plain-language reports when it breaks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -11,14 +11,16 @@
|
|
|
11
11
|
".": "./kryptheon-fixture.js",
|
|
12
12
|
"./kryptheon-fixture": "./kryptheon-fixture.js",
|
|
13
13
|
"./kryptheon-reporter": "./kryptheon-reporter.js",
|
|
14
|
-
"./package.json": "./package.json"
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
"./kryptheon-replay": "./kryptheon-replay.js"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"bin/",
|
|
18
19
|
"kryptheon-fixture.js",
|
|
19
20
|
"kryptheon-reporter.js",
|
|
20
21
|
"playwright.config.js",
|
|
21
|
-
"README.md"
|
|
22
|
+
"README.md",
|
|
23
|
+
"kryptheon-replay.js"
|
|
22
24
|
],
|
|
23
25
|
"engines": {
|
|
24
26
|
"node": ">=20.6.0"
|