kryptheon 0.1.2 → 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
@@ -11,6 +11,7 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
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();
@@ -404,8 +405,13 @@ function deriveTestName(source) {
404
405
  if (name) return name;
405
406
  }
406
407
 
407
- const names = [...String(source).matchAll(/name:\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
408
- 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];
409
415
 
410
416
  const hosts = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
411
417
  if (hosts.length) {
@@ -529,6 +535,111 @@ function startCodegen(args) {
529
535
  });
530
536
  }
531
537
 
538
+ // --- making a recording fit to run twice ------------------------------------
539
+
540
+ function readSpec(relativeFile) {
541
+ try {
542
+ return fs.readFileSync(path.join(USER_DIR, relativeFile), 'utf8');
543
+ } catch (err) {
544
+ return null;
545
+ }
546
+ }
547
+
548
+ function writeSpec(relativeFile, source) {
549
+ try {
550
+ fs.writeFileSync(path.join(USER_DIR, relativeFile), source, 'utf8');
551
+ return true;
552
+ } catch (err) {
553
+ return false;
554
+ }
555
+ }
556
+
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
+ }
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.');
570
+ console.log('');
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
+ }
575
+ console.log('');
576
+ console.log(' Keep .env out of version control - it holds the real value.');
577
+ console.log('');
578
+ }
579
+
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
+ });
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;
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
+
532
643
  // Everything that has to happen to whatever codegen left behind, however
533
644
  // the run ended. Module level so it can be exercised directly.
534
645
  async function finaliseRecording(outFile, context) {
@@ -545,11 +656,21 @@ async function finaliseRecording(outFile, context) {
545
656
  // Import rewrite first: it is instant, so even a second interruption
546
657
  // leaves behind a spec that works.
547
658
  pointAtFixture(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
+
548
664
  const named = await nameRecording(outFile);
549
665
  console.log('');
550
666
  console.log(' Saved to ' + named);
551
667
  console.log(' Run it any time with: kryptheon check');
552
668
  console.log('');
669
+
670
+ reportSecrets(secrets);
671
+ await offerToDropLogout(named);
672
+ reportReplayRisks(named);
673
+
553
674
  return { code: 0, savedAs: named };
554
675
  }
555
676
 
@@ -746,6 +867,7 @@ function triageSpecs() {
746
867
  const repaired = [];
747
868
  const notRecordings = [];
748
869
  const runnable = [];
870
+ const withSecrets = [];
749
871
 
750
872
  for (const name of listSpecFiles()) {
751
873
  const full = path.join(TESTS_DIR, name);
@@ -772,10 +894,21 @@ function triageSpecs() {
772
894
  notRecordings.push(name);
773
895
  continue;
774
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
+ }
775
903
  runnable.push(name);
776
904
  }
777
905
 
778
- return { repaired: repaired, notRecordings: notRecordings, runnable: runnable };
906
+ return {
907
+ repaired: repaired,
908
+ notRecordings: notRecordings,
909
+ runnable: runnable,
910
+ withSecrets: withSecrets,
911
+ };
779
912
  }
780
913
 
781
914
  function check() {
@@ -811,6 +944,13 @@ function check() {
811
944
  console.log(' It was saved without the part that explains failures. Fixed now.');
812
945
  }
813
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
+
814
954
  for (const name of triage.notRecordings) {
815
955
  console.log('');
816
956
  console.log(' Skipped ' + path.join('tests', name));
@@ -1026,6 +1166,9 @@ module.exports = {
1026
1166
  triageSpecs: triageSpecs,
1027
1167
  record: record,
1028
1168
  finaliseRecording: finaliseRecording,
1169
+ takeOutSecrets: takeOutSecrets,
1170
+ reportReplayRisks: reportReplayRisks,
1171
+ offerToDropLogout: offerToDropLogout,
1029
1172
  looksLikeNoDesktop: looksLikeNoDesktop,
1030
1173
  classifyFetchError: classifyFetchError,
1031
1174
  normaliseRecordUrl: normaliseRecordUrl,
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.2",
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"