kryptheon 0.1.8 → 0.1.10

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
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // The kryptheon CLI. A thin wrapper so nobody has to remember Playwright's
3
3
  // command line or edit a config file. Everything here shells out to the
4
4
  // Playwright binary that ships with this package.
@@ -14,6 +14,9 @@ const { spawn, spawnSync } = require('child_process');
14
14
  const os = require('os');
15
15
  const secrets = require(require('path').join(__dirname, '..', 'kryptheon-secrets.js'));
16
16
  const replay = require('../kryptheon-replay.js');
17
+ const selectors = require('../kryptheon-selectors.js');
18
+ const recordings = require('../kryptheon-recordings.js');
19
+ const cleanup = require('../kryptheon-cleanup.js');
17
20
 
18
21
  const PACKAGE_DIR = path.join(__dirname, '..');
19
22
  const USER_DIR = process.cwd();
@@ -23,8 +26,22 @@ const TESTS_DIR = path.join(USER_DIR, 'tests');
23
26
  const MIN_NODE = [20, 6, 0];
24
27
 
25
28
  // How long to wait for a browser window to appear before giving up.
26
- const WINDOW_POLL_MS = 3000;
29
+ //
30
+ // The budget is spent only on time we could actually see. A probe that does
31
+ // not answer tells us nothing about the browser, so it must not count against
32
+ // the person running the command - and the hard limit is there so that a probe
33
+ // which never answers still ends the wait rather than hanging forever.
34
+ //
35
+ // Measured on a warm machine, launch to a detectable window is about 2.5s, so
36
+ // 30s is generous; the number that used to matter more was how the waiting was
37
+ // counted, which was by ticks rather than by the clock.
38
+ const WINDOW_POLL_MS = 5000;
27
39
  const WINDOW_TIMEOUT_MS = 30000;
40
+ const WINDOW_HARD_LIMIT_MS = 90000;
41
+ // Lower than the poll interval on purpose: a probe still running when the next
42
+ // one is due is of no use, and every extra second it holds the loop is a second
43
+ // the watchdog is blind.
44
+ const WINDOW_PROBE_TIMEOUT_MS = 4000;
28
45
 
29
46
  function nodeIsTooOld(version) {
30
47
  const parts = String(version || process.versions.node).split('.').map(Number);
@@ -52,6 +69,7 @@ function usage() {
52
69
  console.log(' kryptheon record <url> open your app and record what you do as a test');
53
70
  console.log(' kryptheon check run every recorded test and report in plain language');
54
71
  console.log(' kryptheon accept <name> agree that one test\'s new result is the correct one');
72
+ console.log(' kryptheon remove [name] list the recordings, or remove one you no longer want');
55
73
  console.log(' kryptheon setup-ai tell your AI assistant to check its work');
56
74
  console.log('');
57
75
  console.log(' add --quiet to check for one line when everything passes');
@@ -276,6 +294,38 @@ function explainUnreachable(url, kind) {
276
294
 
277
295
  // Codegen writes Node/Playwright traces to stderr. We never show those; we
278
296
  // pick out the part that means something and say it in plain words.
297
+ /**
298
+ * The lines codegen wrote that look like a real complaint, shown verbatim.
299
+ *
300
+ * Playwright's stderr also carries banners and progress noise, so only lines
301
+ * that read as an error are lifted out - a wall of unrelated output would be
302
+ * worse than none. Nothing is invented here: if it said nothing, nothing is
303
+ * printed and the usual explanation follows.
304
+ */
305
+ function codegenComplaints(stderr, limit) {
306
+ const seen = Object.create(null);
307
+ const out = [];
308
+ for (const raw of String(stderr || '').split('\n')) {
309
+ const line = raw.replace(/\s+/g, ' ').trim();
310
+ if (!line) continue;
311
+ if (!/(^|\b)(Error|error:|ERR_|EACCES|EPERM|ENOENT|Failed|failed to|Timeout|not found)/.test(line)) continue;
312
+ if (seen[line]) continue;
313
+ seen[line] = true;
314
+ out.push(line.length > 160 ? line.slice(0, 157) + '...' : line);
315
+ if (out.length >= (limit || 3)) break;
316
+ }
317
+ return out;
318
+ }
319
+
320
+ function reportCodegenSaid(stderr) {
321
+ const said = codegenComplaints(stderr);
322
+ if (!said.length) return false;
323
+ console.error('');
324
+ console.error(' The browser reported:');
325
+ for (const line of said) console.error(' ' + line);
326
+ return true;
327
+ }
328
+
279
329
  function explainCodegenFailure(url, stderr) {
280
330
  const text = String(stderr || '');
281
331
  if (/ERR_CONNECTION_REFUSED/.test(text)) return explainUnreachable(url, 'refused');
@@ -539,8 +589,149 @@ function explainNoWindow() {
539
589
  }
540
590
  }
541
591
 
542
- // Is a Playwright-controlled browser actually showing a window?
543
- function browserWindowIsUp() {
592
+ // The browser is there and running; only the window could not be seen. Telling
593
+ // this person their browser never opened would be plainly wrong, so the way
594
+ // past the check comes first rather than buried at the bottom of a list.
595
+ function windowUndetectedLines(seconds) {
596
+ return [
597
+ '',
598
+ ' The browser is running, but no window could be seen.',
599
+ '',
600
+ ' A browser process started and was still going after ' + seconds + ' seconds,',
601
+ ' so this is most likely the check being wrong rather than the browser.',
602
+ '',
603
+ ' If the window is open in front of you, run it again with the check off:',
604
+ ' KRYPTHEON_FORCE_RECORD=1 kryptheon record <url>',
605
+ '',
606
+ ' If there is no window on screen, the browser may have opened somewhere',
607
+ ' with no desktop to draw on - a remote session, a container, or an AI',
608
+ ' assistant running the command for you.',
609
+ '',
610
+ ' Nothing was recorded.',
611
+ '',
612
+ ];
613
+ }
614
+
615
+ // The probe kept failing to answer. Nothing was learned about the browser
616
+ // either way, and saying "no window appeared" would be inventing a fact.
617
+ function probeUnclearLines(answered, unanswered) {
618
+ return [
619
+ '',
620
+ ' Could not tell whether a browser window opened.',
621
+ '',
622
+ ' The check that looks for the window did not answer ' + unanswered + ' of its',
623
+ ' ' + (answered + unanswered) + ' attempts, so there is nothing to go on. This is usually a',
624
+ ' machine under load, or security software holding up the check itself.',
625
+ '',
626
+ ' If the window is open in front of you, run it again with the check off:',
627
+ ' KRYPTHEON_FORCE_RECORD=1 kryptheon record <url>',
628
+ '',
629
+ ' Otherwise just run the same command again.',
630
+ '',
631
+ ' Nothing was recorded.',
632
+ '',
633
+ ];
634
+ }
635
+
636
+ /**
637
+ * Decides, tick by tick, whether to keep waiting for the browser window.
638
+ *
639
+ * Kept apart from the timer and the process so it can be driven directly: the
640
+ * cases worth checking are a probe that answers slowly, one that never answers,
641
+ * and a browser that runs without a window, none of which are reachable by
642
+ * launching a real browser and hoping.
643
+ *
644
+ * Returns null to keep waiting, or a verdict:
645
+ * 'window' - a window is up, stop watching
646
+ * 'no-window' - the browser ran for the whole budget without one
647
+ * 'never-started' - no browser process ever appeared
648
+ * 'unclear' - the probe mostly did not answer, so nothing is known
649
+ */
650
+ function createWindowWatch(options) {
651
+ const opts = options || {};
652
+ const probe = opts.probe || inspectBrowserWindows;
653
+ const clock = opts.now || Date.now;
654
+ const budgetMs = opts.budgetMs == null ? WINDOW_TIMEOUT_MS : opts.budgetMs;
655
+ const hardLimitMs = opts.hardLimitMs == null ? WINDOW_HARD_LIMIT_MS : opts.hardLimitMs;
656
+
657
+ const started = clock();
658
+ let lastTick = started;
659
+ // Time we could actually see, in milliseconds. Counted from the clock rather
660
+ // than by adding the poll interval per tick: a probe that takes four seconds
661
+ // used to cost the budget three, so "thirty seconds" was never thirty.
662
+ let spent = 0;
663
+ let answered = 0;
664
+ let unanswered = 0;
665
+ let sawProcess = false;
666
+
667
+ return {
668
+ tick: function () {
669
+ const now = clock();
670
+ const elapsed = now - lastTick;
671
+ lastTick = now;
672
+
673
+ const seen = probe();
674
+ if (seen && seen.known) {
675
+ answered++;
676
+ if (seen.windows > 0) return { verdict: 'window' };
677
+ if (seen.processes > 0) sawProcess = true;
678
+ spent += elapsed;
679
+ } else {
680
+ // Nothing was learned, so nothing is charged.
681
+ unanswered++;
682
+ }
683
+
684
+ const outOfBudget = spent >= budgetMs;
685
+ const outOfPatience = now - started >= hardLimitMs;
686
+ if (!outOfBudget && !outOfPatience) return null;
687
+
688
+ if (unanswered > answered) {
689
+ return { verdict: 'unclear', answered: answered, unanswered: unanswered };
690
+ }
691
+ return {
692
+ verdict: sawProcess ? 'no-window' : 'never-started',
693
+ seconds: Math.round((now - started) / 1000),
694
+ };
695
+ },
696
+ stats: function () {
697
+ return { spent: spent, answered: answered, unanswered: unanswered, sawProcess: sawProcess };
698
+ },
699
+ };
700
+ }
701
+
702
+ // What can be seen of the browser right now.
703
+ //
704
+ // known - whether the question could be answered at all
705
+ // processes - Playwright browser processes running
706
+ // windows - how many of those are actually showing a window
707
+ //
708
+ // The three are kept apart on purpose. The old version returned one boolean,
709
+ // so "there is no window" and "the probe did not answer" were the same value,
710
+ // and a slow machine could be told its browser had never opened. They are
711
+ // different facts and they deserve different answers.
712
+ //
713
+ // One command returns both counts: asking twice would double the cost of the
714
+ // most expensive thing in this loop.
715
+ /**
716
+ * Turns the probe's two numbers into the shape the watchdog reads.
717
+ *
718
+ * Anything that is not two numbers means the answer did not arrive intact, and
719
+ * an answer that did not arrive must never read as "nothing is running" - that
720
+ * conflation is the whole bug this replaced.
721
+ */
722
+ function parseWindowProbe(stdout) {
723
+ const unknown = { known: false, processes: 0, windows: 0 };
724
+ const parts = String(stdout == null ? '' : stdout).trim().split(/\s+/);
725
+ if (parts.length < 2) return unknown;
726
+ const processes = Number(parts[0]);
727
+ const windows = Number(parts[1]);
728
+ if (!Number.isFinite(processes) || !Number.isFinite(windows)) return unknown;
729
+ if (processes < 0 || windows < 0 || windows > processes) return unknown;
730
+ return { known: true, processes: processes, windows: windows };
731
+ }
732
+
733
+ function inspectBrowserWindows() {
734
+ const unknown = { known: false, processes: 0, windows: 0 };
544
735
  try {
545
736
  if (process.platform === 'win32') {
546
737
  const probe = spawnSync(
@@ -548,17 +739,33 @@ function browserWindowIsUp() {
548
739
  [
549
740
  '-NoProfile',
550
741
  '-Command',
551
- "@(Get-Process chrome,msedge -ErrorAction SilentlyContinue | " +
552
- "Where-Object { $_.Path -like '*ms-playwright*' -and $_.MainWindowHandle -ne 0 }).Count",
742
+ "$p = @(Get-Process chrome,msedge -ErrorAction SilentlyContinue | " +
743
+ "Where-Object { $_.Path -like '*ms-playwright*' }); " +
744
+ "$w = @($p | Where-Object { $_.MainWindowHandle -ne 0 }); " +
745
+ 'Write-Output ("{0} {1}" -f $p.Count, $w.Count)',
553
746
  ],
554
- { encoding: 'utf8', windowsHide: true, timeout: 8000 }
747
+ { encoding: 'utf8', windowsHide: true, timeout: WINDOW_PROBE_TIMEOUT_MS }
555
748
  );
556
- return Number(String(probe.stdout || '').trim()) > 0;
749
+ // A timeout, a missing shell, or a non-zero exit all mean the same thing:
750
+ // nothing was learned. Only a well formed answer counts as knowing.
751
+ if (probe.error || probe.status !== 0) return unknown;
752
+ return parseWindowProbe(probe.stdout);
557
753
  }
558
- const probe = spawnSync('ps', ['-A', '-o', 'command'], { encoding: 'utf8', timeout: 8000 });
559
- return /ms-playwright/.test(String(probe.stdout || ''));
754
+
755
+ const probe = spawnSync('ps', ['-A', '-o', 'command'], {
756
+ encoding: 'utf8',
757
+ timeout: WINDOW_PROBE_TIMEOUT_MS,
758
+ });
759
+ if (probe.error || probe.status !== 0) return unknown;
760
+ const running = String(probe.stdout || '')
761
+ .split('\n')
762
+ .filter(function (line) {
763
+ return line.indexOf('ms-playwright') !== -1;
764
+ }).length;
765
+ // ps cannot see windows, so a running browser is taken at its word here.
766
+ return { known: true, processes: running, windows: running };
560
767
  } catch (err) {
561
- return true; // cannot tell - do not block the user
768
+ return unknown;
562
769
  }
563
770
  }
564
771
 
@@ -650,6 +857,89 @@ function reportSecrets(replacements) {
650
857
  console.log('');
651
858
  }
652
859
 
860
+ // One keypress, no Enter, because the safe answer is the default and getting
861
+ // out of the way should cost nothing.
862
+ //
863
+ // Falls back to the default where there is no terminal to read keys from - a
864
+ // CI job, a pipe, an assistant running the command - rather than waiting for
865
+ // an answer that will never arrive.
866
+ function askKey(question, keys, fallback) {
867
+ return new Promise((resolve) => {
868
+ const allowed = keys.map((k) => k.toLowerCase());
869
+ if (!process.stdin.isTTY || !process.stdin.setRawMode) return resolve(fallback);
870
+ process.stdout.write(question);
871
+
872
+ const finish = (answer) => {
873
+ process.stdin.removeListener("data", onKey);
874
+ try {
875
+ process.stdin.setRawMode(false);
876
+ process.stdin.pause();
877
+ } catch (err) {
878
+ /* nothing more to do */
879
+ }
880
+ process.stdout.write(answer + String.fromCharCode(10));
881
+ resolve(answer);
882
+ };
883
+
884
+ const onKey = (chunk) => {
885
+ const key = String(chunk);
886
+ // In raw mode Ctrl-C arrives as a keystroke rather than a signal, so it
887
+ // has to be turned back into one. Sent as a signal rather than exiting
888
+ // here, because exiting would cut off whatever is still buffered - and
889
+ // it must not be read as an answer either: stopping is not "keep going".
890
+ if (key === String.fromCharCode(3)) {
891
+ process.stdin.removeListener('data', onKey);
892
+ try {
893
+ process.stdin.setRawMode(false);
894
+ process.stdin.pause();
895
+ } catch (err) {
896
+ /* nothing more to do */
897
+ }
898
+ process.stdout.write(String.fromCharCode(10));
899
+ process.kill(process.pid, 'SIGINT');
900
+ return;
901
+ }
902
+ if (key === String.fromCharCode(13) || key === String.fromCharCode(10)) return finish(fallback);
903
+ const pressed = key.toLowerCase();
904
+ if (allowed.indexOf(pressed) === -1) return; // an unknown key: keep waiting
905
+ finish(pressed);
906
+ };
907
+
908
+ try {
909
+ process.stdin.setRawMode(true);
910
+ } catch (err) {
911
+ return resolve(fallback);
912
+ }
913
+ process.stdin.resume();
914
+ process.stdin.on("data", onKey);
915
+ });
916
+ }
917
+
918
+ // Removing a recording means removing what was remembered about it too, or
919
+ // the saved results of a file that no longer exists stay behind for ever.
920
+ function deleteRecording(name) {
921
+ const relative = path.join("tests", name).split(path.sep).join("/");
922
+ try {
923
+ fs.unlinkSync(path.join(TESTS_DIR, name));
924
+ } catch (err) {
925
+ return { ok: false, name: name, why: err.message };
926
+ }
927
+
928
+ try {
929
+ const api = require(path.join(PACKAGE_DIR, "kryptheon-fixture.js"));
930
+ const all = api.readBaselines(api.BASELINE_FILE);
931
+ const keys = recordings.baselineKeysForSpec(all, relative);
932
+ if (keys.length) {
933
+ for (const key of keys) delete all[key];
934
+ fs.writeFileSync(api.BASELINE_FILE, JSON.stringify(all, null, 2) + String.fromCharCode(10), "utf8");
935
+ }
936
+ } catch (err) {
937
+ // The recording is gone either way, and a leftover baseline is not worth
938
+ // failing the command over.
939
+ }
940
+ return { ok: true, name: name, file: relative };
941
+ }
942
+
653
943
  function askYesNo(question) {
654
944
  return new Promise((resolve) => {
655
945
  if (!process.stdin.isTTY) return resolve(false);
@@ -668,6 +958,24 @@ function askYesNo(question) {
668
958
  });
669
959
  }
670
960
 
961
+ // A typed answer, for questions whose answers are not single letters.
962
+ function askLine(question) {
963
+ return new Promise((resolve) => {
964
+ if (!process.stdin.isTTY) return resolve("");
965
+ const readline = require("readline");
966
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
967
+ rl.question(question, (answer) => {
968
+ rl.close();
969
+ try {
970
+ process.stdin.pause();
971
+ } catch (err) {
972
+ /* nothing to do */
973
+ }
974
+ resolve(String(answer));
975
+ });
976
+ });
977
+ }
978
+
671
979
  // Ending on a logout means the next run starts somewhere else. Almost nobody
672
980
  // wants that, so ask.
673
981
  async function offerToDropLogout(relativeFile) {
@@ -694,6 +1002,37 @@ async function offerToDropLogout(relativeFile) {
694
1002
  return true;
695
1003
  }
696
1004
 
1005
+ // Codegen writes whatever identified the element at the moment it was clicked,
1006
+ // which on a page of live figures means the figures end up in the selector. Say
1007
+ // so, and say nothing when there is nothing to say.
1008
+ // Codegen records the wrong keystrokes along with the right ones, so a typo
1009
+ // corrected on the second attempt becomes a wrong login performed on every
1010
+ // run. This takes those out and says what it took, because a tool that edits
1011
+ // your recording without telling you is worse than one that leaves the mess.
1012
+ function tidyRecording(relativeFile) {
1013
+ const source = readSpec(relativeFile);
1014
+ if (source === null) return [];
1015
+ const result = cleanup.cleanRecording(source);
1016
+ if (!result.removed.length) return [];
1017
+ if (!writeSpec(relativeFile, result.source)) return [];
1018
+ return result.removed;
1019
+ }
1020
+
1021
+ // Said after the file has its name, with the other closing notes, so the
1022
+ // order on screen matches the order things happened in.
1023
+ function reportTidying(removed) {
1024
+ for (const line of cleanup.describeCleanup(removed)) console.log(line);
1025
+ return removed || [];
1026
+ }
1027
+
1028
+ function reportFragileSelectors(relativeFile) {
1029
+ const source = readSpec(relativeFile);
1030
+ if (source === null) return [];
1031
+ const findings = selectors.findFragileSelectors(source);
1032
+ for (const line of selectors.describeFragileSelectors(findings)) console.log(line);
1033
+ return findings;
1034
+ }
1035
+
697
1036
  function reportReplayRisks(relativeFile) {
698
1037
  const source = readSpec(relativeFile);
699
1038
  if (source === null) return [];
@@ -734,16 +1073,23 @@ async function finaliseRecording(outFile, context) {
734
1073
  // this point. Take it out before doing anything else with the file.
735
1074
  const secrets = takeOutSecrets(outFile);
736
1075
 
1076
+ // After the secrets, deliberately. By this point a password is an
1077
+ // environment variable rather than a literal, so nothing that is said
1078
+ // about a field that was typed twice can put the value back on screen.
1079
+ const tidied = tidyRecording(outFile);
1080
+
737
1081
  const named = await nameRecording(outFile);
738
1082
  console.log('');
739
1083
  console.log(' Saved to ' + named);
740
1084
  console.log(' Run it any time with: kryptheon check');
741
1085
  console.log('');
742
1086
 
1087
+ reportTidying(tidied);
743
1088
  reportSecrets(secrets);
744
1089
  keepArtefactsOutOfGit();
745
1090
  await offerToDropLogout(named);
746
1091
  reportReplayRisks(named);
1092
+ reportFragileSelectors(named);
747
1093
 
748
1094
  return { code: 0, savedAs: named };
749
1095
  }
@@ -763,8 +1109,24 @@ async function finaliseRecording(outFile, context) {
763
1109
  }
764
1110
  }
765
1111
 
766
- if (ctx.reason === 'no-window') {
767
- explainNoWindow();
1112
+ if (ctx.reason === 'no-window' || ctx.reason === 'never-started' || ctx.reason === 'unclear') {
1113
+ // What the browser itself said comes first. It used to be read only when
1114
+ // codegen exited on its own, so on this path a real error - a bad address,
1115
+ // a refused connection - was captured and then thrown away, and the reader
1116
+ // got a list of guesses instead of the answer.
1117
+ reportCodegenSaid(ctx.stderr);
1118
+ const detail = ctx.detail || {};
1119
+ if (ctx.reason === 'unclear') {
1120
+ for (const line of probeUnclearLines(detail.answered || 0, detail.unanswered || 0)) {
1121
+ console.error(line);
1122
+ }
1123
+ } else if (ctx.reason === 'no-window') {
1124
+ for (const line of windowUndetectedLines(detail.seconds || Math.round(WINDOW_TIMEOUT_MS / 1000))) {
1125
+ console.error(line);
1126
+ }
1127
+ } else {
1128
+ explainNoWindow();
1129
+ }
768
1130
  } else if (ctx.reason === 'signal') {
769
1131
  console.log('');
770
1132
  console.log(' Recording stopped before anything was saved.');
@@ -784,6 +1146,125 @@ async function finaliseRecording(outFile, context) {
784
1146
  return { code: 1, savedAs: null };
785
1147
  }
786
1148
 
1149
+ // Every record writes another file and check runs all of them, so a first day
1150
+ // spent learning the tool leaves a pile of recordings that never worked and a
1151
+ // check that reads like a wall of failures. This is the moment asking costs
1152
+ // nothing.
1153
+ //
1154
+ // Keeping is the default because it is the answer that cannot lose work, and
1155
+ // the other one is a single keypress away. Where there is no terminal to
1156
+ // answer with - a CI job, an assistant running the command - nothing is asked
1157
+ // and nothing is deleted: a question nobody can see is not a question.
1158
+ async function askAboutExisting(names) {
1159
+ if (!names.length || !process.stdin.isTTY) return false;
1160
+
1161
+ for (const line of recordings.existingRecordingsQuestion(names.length)) console.log(line);
1162
+ const answer = await askKey(" Keep or replace? [K/r] ", ["k", "r"], "k");
1163
+ if (answer !== "r") return false;
1164
+
1165
+ console.log("");
1166
+ console.log(" They will be deleted once this new recording is saved.");
1167
+ return true;
1168
+ }
1169
+
1170
+ // The old recordings, once the new one is safely on disk. The new file is
1171
+ // never in this list, but it is checked by name anyway - deleting the thing
1172
+ // just recorded is the one mistake here that cannot be undone.
1173
+ function dropOldRecordings(names, savedAs) {
1174
+ const keep = path.basename(String(savedAs || ""));
1175
+ const gone = [];
1176
+ for (const name of names) {
1177
+ if (path.basename(name) === keep) continue;
1178
+ const result = deleteRecording(name);
1179
+ if (result.ok) gone.push(name);
1180
+ }
1181
+ if (!gone.length) return gone;
1182
+ console.log(" Removed " + recordings.plural(gone.length, "older recording") + ":");
1183
+ for (const name of gone) console.log(" " + path.join("tests", name));
1184
+ console.log("");
1185
+ return gone;
1186
+ }
1187
+
1188
+ // --- remove -----------------------------------------------------------------
1189
+ //
1190
+ // Recordings pile up. Without a way to take one out, the only way to stop a
1191
+ // half-finished experiment being run for ever is to know where the files live
1192
+ // and delete one by hand.
1193
+
1194
+ function listRecordings(names) {
1195
+ console.log("");
1196
+ console.log(" " + recordings.plural(names.length, "recording") + " in this project:");
1197
+ console.log("");
1198
+ names.forEach((name, i) => {
1199
+ console.log(" " + String(i + 1).padStart(2) + " " + path.join("tests", name));
1200
+ });
1201
+ console.log("");
1202
+ }
1203
+
1204
+ async function remove(name) {
1205
+ const names = listSpecFiles();
1206
+ if (!names.length) {
1207
+ console.log("");
1208
+ console.log(" There are no recordings to remove.");
1209
+ console.log("");
1210
+ return 0;
1211
+ }
1212
+
1213
+ let choice;
1214
+ if (name) {
1215
+ choice = recordings.chooseFromList(names, name);
1216
+ } else {
1217
+ listRecordings(names);
1218
+ if (!process.stdin.isTTY) {
1219
+ console.log(" To remove one, run: kryptheon remove <name>");
1220
+ console.log("");
1221
+ return 0;
1222
+ }
1223
+ const answer = await askLine(" Which one? [number, a for all, Enter to cancel] ");
1224
+ choice = recordings.chooseFromList(names, answer);
1225
+ }
1226
+
1227
+ if (choice.action === "cancel") {
1228
+ console.log("");
1229
+ console.log(" Nothing was removed.");
1230
+ console.log("");
1231
+ return 0;
1232
+ }
1233
+ if (choice.action === "unclear") {
1234
+ console.error("");
1235
+ console.error(" " + choice.why + ": " + JSON.stringify(choice.said));
1236
+ console.error(" Run \"kryptheon remove\" on its own to see the list.");
1237
+ console.error("");
1238
+ return 1;
1239
+ }
1240
+
1241
+ const gone = [];
1242
+ const failed = [];
1243
+ for (const target of choice.names) {
1244
+ const result = deleteRecording(target);
1245
+ if (result.ok) gone.push(target);
1246
+ else failed.push(result);
1247
+ }
1248
+
1249
+ console.log("");
1250
+ if (gone.length) {
1251
+ console.log(" Removed " + recordings.plural(gone.length, "recording") + ":");
1252
+ for (const g of gone) console.log(" " + path.join("tests", g));
1253
+ console.log("");
1254
+ console.log(
1255
+ gone.length === 1
1256
+ ? " Its saved result was removed too, so nothing is left behind."
1257
+ : " Their saved results were removed too, so nothing is left behind.",
1258
+ );
1259
+ console.log("");
1260
+ }
1261
+ for (const f of failed) {
1262
+ console.error(" Could not remove " + path.join("tests", f.name) + ": " + f.why);
1263
+ console.error("");
1264
+ }
1265
+ return failed.length ? 1 : 0;
1266
+ }
1267
+
787
1268
  async function record(url) {
788
1269
  if (!url) {
789
1270
  console.error('');
@@ -806,6 +1287,9 @@ async function record(url) {
806
1287
 
807
1288
  if (!ensureBrowser()) return 1;
808
1289
 
1290
+ const existingBefore = listSpecFiles();
1291
+ const replaceExisting = await askAboutExisting(existingBefore);
1292
+
809
1293
  const hadTestsDir = fs.existsSync(TESTS_DIR);
810
1294
  try {
811
1295
  fs.mkdirSync(TESTS_DIR, { recursive: true });
@@ -835,15 +1319,19 @@ async function record(url) {
835
1319
  // wire it up, name it. Runs once, whether codegen exits on its own or we
836
1320
  // are interrupted.
837
1321
  let finished = false;
838
- const finalise = async (reason) => {
1322
+ const finalise = async (reason, detail) => {
839
1323
  if (finished) return 1;
840
1324
  finished = true;
841
1325
  const outcome = await finaliseRecording(outFile, {
842
1326
  hadTestsDir: hadTestsDir,
843
1327
  target: target,
1328
+ detail: detail || null,
844
1329
  stderr: stderr,
845
1330
  reason: reason,
846
1331
  });
1332
+ // Only now, and only if something was actually saved. Deleting first
1333
+ // would mean a recording that went wrong takes the old ones with it.
1334
+ if (replaceExisting && outcome.savedAs) dropOldRecordings(existingBefore, outcome.savedAs);
847
1335
  return outcome.code;
848
1336
  };
849
1337
 
@@ -859,22 +1347,24 @@ async function record(url) {
859
1347
  // If no window ever appears, stop instead of waiting for someone to kill us.
860
1348
  // Someone whose window is real but undetectable can turn this off.
861
1349
  const watchdogOff = !!process.env.KRYPTHEON_FORCE_RECORD;
862
- let waited = 0;
1350
+ const watch = createWindowWatch();
863
1351
  const watchdog = setInterval(() => {
864
1352
  if (watchdogOff) {
865
1353
  clearInterval(watchdog);
866
1354
  return;
867
1355
  }
868
- waited += WINDOW_POLL_MS;
869
- if (browserWindowIsUp()) {
1356
+ // A child that has already gone is the close handler's business, not
1357
+ // this one's: it knows the exit code and what was written.
1358
+ if (child.exitCode !== null || child.signalCode !== null) {
870
1359
  clearInterval(watchdog);
871
1360
  return;
872
1361
  }
873
- if (waited >= WINDOW_TIMEOUT_MS) {
874
- clearInterval(watchdog);
875
- stopChild();
876
- finalise('no-window').then(resolve);
877
- }
1362
+ const outcome = watch.tick();
1363
+ if (!outcome) return;
1364
+ clearInterval(watchdog);
1365
+ if (outcome.verdict === 'window') return;
1366
+ stopChild();
1367
+ finalise(outcome.verdict, outcome).then(resolve);
878
1368
  }, WINDOW_POLL_MS);
879
1369
 
880
1370
  const onSignal = () => {
@@ -1384,6 +1874,9 @@ async function main() {
1384
1874
  case 'check':
1385
1875
  return finishWith(check({ quiet: rest.indexOf('--quiet') !== -1 || rest.indexOf('-q') !== -1 }));
1386
1876
  break;
1877
+ case 'remove':
1878
+ case 'rm':
1879
+ return finishWith(await remove(rest.join(" ").trim()));
1387
1880
  case 'setup-ai':
1388
1881
  return finishWith(setupAi());
1389
1882
  case 'accept':
@@ -1432,6 +1925,23 @@ module.exports = {
1432
1925
  reportReplayRisks: reportReplayRisks,
1433
1926
  offerToDropLogout: offerToDropLogout,
1434
1927
  noWindowLines: noWindowLines,
1928
+ windowUndetectedLines: windowUndetectedLines,
1929
+ probeUnclearLines: probeUnclearLines,
1930
+ createWindowWatch: createWindowWatch,
1931
+ inspectBrowserWindows: inspectBrowserWindows,
1932
+ parseWindowProbe: parseWindowProbe,
1933
+ codegenComplaints: codegenComplaints,
1934
+ reportFragileSelectors: reportFragileSelectors,
1935
+ tidyRecording: tidyRecording,
1936
+ reportTidying: reportTidying,
1937
+ remove: remove,
1938
+ deleteRecording: deleteRecording,
1939
+ dropOldRecordings: dropOldRecordings,
1940
+ askAboutExisting: askAboutExisting,
1941
+ askKey: askKey,
1942
+ WINDOW_TIMEOUT_MS: WINDOW_TIMEOUT_MS,
1943
+ WINDOW_HARD_LIMIT_MS: WINDOW_HARD_LIMIT_MS,
1944
+ WINDOW_POLL_MS: WINDOW_POLL_MS,
1435
1945
  classifyFetchError: classifyFetchError,
1436
1946
  normaliseRecordUrl: normaliseRecordUrl,
1437
1947
  isLocalAddress: isLocalAddress,