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 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
- const controller = new AbortController();
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 controller = new AbortController();
328
- const timer = setTimeout(() => controller.abort(), 5000);
329
- const res = await fetch(url, { signal: controller.signal });
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
- const names = [...String(source).matchAll(/name:\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
361
- if (names.length) return names[names.length - 1].trim();
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
- async function record(url) {
425
- if (!url) {
426
- console.error('');
427
- console.error(' Which address should I open?');
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(' Add the web address of your app, for example:');
430
- console.error(' kryptheon record https://www.example.com');
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 1;
530
+ return null;
433
531
  }
434
- const target = normaliseRecordUrl(url);
532
+ return spawn(process.execPath, [cli].concat(args), {
533
+ cwd: USER_DIR,
534
+ stdio: ['inherit', 'inherit', 'pipe'],
535
+ });
536
+ }
435
537
 
436
- // Fail early and kindly rather than letting codegen throw a stack trace.
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
- if (!ensureBrowser()) return 1;
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
- const hadTestsDir = fs.existsSync(TESTS_DIR);
548
+ function writeSpec(relativeFile, source) {
446
549
  try {
447
- fs.mkdirSync(TESTS_DIR, { recursive: true });
550
+ fs.writeFileSync(path.join(USER_DIR, relativeFile), source, 'utf8');
551
+ return true;
448
552
  } catch (err) {
449
- /* codegen will report if it cannot write */
553
+ return false;
450
554
  }
555
+ }
451
556
 
452
- let outFile = path.join('tests', timestampName());
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(' Opening ' + target + ' in a browser.');
456
- console.log('');
457
- console.log(' Use your app normally - click, type, sign in, whatever you want');
458
- console.log(' covered. Every step is recorded as you go.');
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(' When you are done, close the browser window to save the test.');
576
+ console.log(' Keep .env out of version control - it holds the real value.');
461
577
  console.log('');
578
+ }
462
579
 
463
- const run = runPlaywright(['codegen', '--target', 'playwright-test', '-o', outFile, target], {
464
- quietErrors: true,
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; // codegen wrote nothing at all
652
+ source = null;
473
653
  }
474
654
 
475
- // Keyed off what is in the file rather than the exit code: codegen still
476
- // writes a recording when the window is force-closed, and still leaves an
477
- // empty shell behind when the page never loaded.
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
- outFile = await nameRecording(outFile);
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 ' + outFile);
666
+ console.log(' Saved to ' + named);
483
667
  console.log(' Run it any time with: kryptheon check');
484
668
  console.log('');
485
- return 0;
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 (run.status !== 0 || source === null) {
505
- explainCodegenFailure(target, run.stderr);
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
- return runPlaywright(['test', '--config', CONFIG]).status;
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
- process.exit(1);
1126
+ return finishWith(1);
689
1127
  }
690
1128
 
691
1129
  switch (command) {
692
1130
  case 'record':
693
- process.exit(await record(rest[0]));
1131
+ return finishWith(await record(rest[0]));
694
1132
  break;
695
1133
  case 'check':
696
- process.exit(check());
1134
+ return finishWith(check());
697
1135
  break;
698
1136
  case 'accept':
699
- process.exit(accept(rest.join(' ').trim()));
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
- process.exit(0);
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
- process.exit(1);
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
@@ -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 this same test title passed.
522
- _lastPassed(title) {
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
- if (tests[j] && tests[j].title === title && tests[j].status === 'passed') {
528
- return tests[j].timestamp || run.runAt;
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
- if (translated.advice) {
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.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"