kryptheon 0.1.8 → 0.1.9

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
@@ -23,8 +23,22 @@ const TESTS_DIR = path.join(USER_DIR, 'tests');
23
23
  const MIN_NODE = [20, 6, 0];
24
24
 
25
25
  // How long to wait for a browser window to appear before giving up.
26
- const WINDOW_POLL_MS = 3000;
26
+ //
27
+ // The budget is spent only on time we could actually see. A probe that does
28
+ // not answer tells us nothing about the browser, so it must not count against
29
+ // the person running the command - and the hard limit is there so that a probe
30
+ // which never answers still ends the wait rather than hanging forever.
31
+ //
32
+ // Measured on a warm machine, launch to a detectable window is about 2.5s, so
33
+ // 30s is generous; the number that used to matter more was how the waiting was
34
+ // counted, which was by ticks rather than by the clock.
35
+ const WINDOW_POLL_MS = 5000;
27
36
  const WINDOW_TIMEOUT_MS = 30000;
37
+ const WINDOW_HARD_LIMIT_MS = 90000;
38
+ // Lower than the poll interval on purpose: a probe still running when the next
39
+ // one is due is of no use, and every extra second it holds the loop is a second
40
+ // the watchdog is blind.
41
+ const WINDOW_PROBE_TIMEOUT_MS = 4000;
28
42
 
29
43
  function nodeIsTooOld(version) {
30
44
  const parts = String(version || process.versions.node).split('.').map(Number);
@@ -276,6 +290,38 @@ function explainUnreachable(url, kind) {
276
290
 
277
291
  // Codegen writes Node/Playwright traces to stderr. We never show those; we
278
292
  // pick out the part that means something and say it in plain words.
293
+ /**
294
+ * The lines codegen wrote that look like a real complaint, shown verbatim.
295
+ *
296
+ * Playwright's stderr also carries banners and progress noise, so only lines
297
+ * that read as an error are lifted out - a wall of unrelated output would be
298
+ * worse than none. Nothing is invented here: if it said nothing, nothing is
299
+ * printed and the usual explanation follows.
300
+ */
301
+ function codegenComplaints(stderr, limit) {
302
+ const seen = Object.create(null);
303
+ const out = [];
304
+ for (const raw of String(stderr || '').split('\n')) {
305
+ const line = raw.replace(/\s+/g, ' ').trim();
306
+ if (!line) continue;
307
+ if (!/(^|\b)(Error|error:|ERR_|EACCES|EPERM|ENOENT|Failed|failed to|Timeout|not found)/.test(line)) continue;
308
+ if (seen[line]) continue;
309
+ seen[line] = true;
310
+ out.push(line.length > 160 ? line.slice(0, 157) + '...' : line);
311
+ if (out.length >= (limit || 3)) break;
312
+ }
313
+ return out;
314
+ }
315
+
316
+ function reportCodegenSaid(stderr) {
317
+ const said = codegenComplaints(stderr);
318
+ if (!said.length) return false;
319
+ console.error('');
320
+ console.error(' The browser reported:');
321
+ for (const line of said) console.error(' ' + line);
322
+ return true;
323
+ }
324
+
279
325
  function explainCodegenFailure(url, stderr) {
280
326
  const text = String(stderr || '');
281
327
  if (/ERR_CONNECTION_REFUSED/.test(text)) return explainUnreachable(url, 'refused');
@@ -539,8 +585,149 @@ function explainNoWindow() {
539
585
  }
540
586
  }
541
587
 
542
- // Is a Playwright-controlled browser actually showing a window?
543
- function browserWindowIsUp() {
588
+ // The browser is there and running; only the window could not be seen. Telling
589
+ // this person their browser never opened would be plainly wrong, so the way
590
+ // past the check comes first rather than buried at the bottom of a list.
591
+ function windowUndetectedLines(seconds) {
592
+ return [
593
+ '',
594
+ ' The browser is running, but no window could be seen.',
595
+ '',
596
+ ' A browser process started and was still going after ' + seconds + ' seconds,',
597
+ ' so this is most likely the check being wrong rather than the browser.',
598
+ '',
599
+ ' If the window is open in front of you, run it again with the check off:',
600
+ ' KRYPTHEON_FORCE_RECORD=1 kryptheon record <url>',
601
+ '',
602
+ ' If there is no window on screen, the browser may have opened somewhere',
603
+ ' with no desktop to draw on - a remote session, a container, or an AI',
604
+ ' assistant running the command for you.',
605
+ '',
606
+ ' Nothing was recorded.',
607
+ '',
608
+ ];
609
+ }
610
+
611
+ // The probe kept failing to answer. Nothing was learned about the browser
612
+ // either way, and saying "no window appeared" would be inventing a fact.
613
+ function probeUnclearLines(answered, unanswered) {
614
+ return [
615
+ '',
616
+ ' Could not tell whether a browser window opened.',
617
+ '',
618
+ ' The check that looks for the window did not answer ' + unanswered + ' of its',
619
+ ' ' + (answered + unanswered) + ' attempts, so there is nothing to go on. This is usually a',
620
+ ' machine under load, or security software holding up the check itself.',
621
+ '',
622
+ ' If the window is open in front of you, run it again with the check off:',
623
+ ' KRYPTHEON_FORCE_RECORD=1 kryptheon record <url>',
624
+ '',
625
+ ' Otherwise just run the same command again.',
626
+ '',
627
+ ' Nothing was recorded.',
628
+ '',
629
+ ];
630
+ }
631
+
632
+ /**
633
+ * Decides, tick by tick, whether to keep waiting for the browser window.
634
+ *
635
+ * Kept apart from the timer and the process so it can be driven directly: the
636
+ * cases worth checking are a probe that answers slowly, one that never answers,
637
+ * and a browser that runs without a window, none of which are reachable by
638
+ * launching a real browser and hoping.
639
+ *
640
+ * Returns null to keep waiting, or a verdict:
641
+ * 'window' - a window is up, stop watching
642
+ * 'no-window' - the browser ran for the whole budget without one
643
+ * 'never-started' - no browser process ever appeared
644
+ * 'unclear' - the probe mostly did not answer, so nothing is known
645
+ */
646
+ function createWindowWatch(options) {
647
+ const opts = options || {};
648
+ const probe = opts.probe || inspectBrowserWindows;
649
+ const clock = opts.now || Date.now;
650
+ const budgetMs = opts.budgetMs == null ? WINDOW_TIMEOUT_MS : opts.budgetMs;
651
+ const hardLimitMs = opts.hardLimitMs == null ? WINDOW_HARD_LIMIT_MS : opts.hardLimitMs;
652
+
653
+ const started = clock();
654
+ let lastTick = started;
655
+ // Time we could actually see, in milliseconds. Counted from the clock rather
656
+ // than by adding the poll interval per tick: a probe that takes four seconds
657
+ // used to cost the budget three, so "thirty seconds" was never thirty.
658
+ let spent = 0;
659
+ let answered = 0;
660
+ let unanswered = 0;
661
+ let sawProcess = false;
662
+
663
+ return {
664
+ tick: function () {
665
+ const now = clock();
666
+ const elapsed = now - lastTick;
667
+ lastTick = now;
668
+
669
+ const seen = probe();
670
+ if (seen && seen.known) {
671
+ answered++;
672
+ if (seen.windows > 0) return { verdict: 'window' };
673
+ if (seen.processes > 0) sawProcess = true;
674
+ spent += elapsed;
675
+ } else {
676
+ // Nothing was learned, so nothing is charged.
677
+ unanswered++;
678
+ }
679
+
680
+ const outOfBudget = spent >= budgetMs;
681
+ const outOfPatience = now - started >= hardLimitMs;
682
+ if (!outOfBudget && !outOfPatience) return null;
683
+
684
+ if (unanswered > answered) {
685
+ return { verdict: 'unclear', answered: answered, unanswered: unanswered };
686
+ }
687
+ return {
688
+ verdict: sawProcess ? 'no-window' : 'never-started',
689
+ seconds: Math.round((now - started) / 1000),
690
+ };
691
+ },
692
+ stats: function () {
693
+ return { spent: spent, answered: answered, unanswered: unanswered, sawProcess: sawProcess };
694
+ },
695
+ };
696
+ }
697
+
698
+ // What can be seen of the browser right now.
699
+ //
700
+ // known - whether the question could be answered at all
701
+ // processes - Playwright browser processes running
702
+ // windows - how many of those are actually showing a window
703
+ //
704
+ // The three are kept apart on purpose. The old version returned one boolean,
705
+ // so "there is no window" and "the probe did not answer" were the same value,
706
+ // and a slow machine could be told its browser had never opened. They are
707
+ // different facts and they deserve different answers.
708
+ //
709
+ // One command returns both counts: asking twice would double the cost of the
710
+ // most expensive thing in this loop.
711
+ /**
712
+ * Turns the probe's two numbers into the shape the watchdog reads.
713
+ *
714
+ * Anything that is not two numbers means the answer did not arrive intact, and
715
+ * an answer that did not arrive must never read as "nothing is running" - that
716
+ * conflation is the whole bug this replaced.
717
+ */
718
+ function parseWindowProbe(stdout) {
719
+ const unknown = { known: false, processes: 0, windows: 0 };
720
+ const parts = String(stdout == null ? '' : stdout).trim().split(/\s+/);
721
+ if (parts.length < 2) return unknown;
722
+ const processes = Number(parts[0]);
723
+ const windows = Number(parts[1]);
724
+ if (!Number.isFinite(processes) || !Number.isFinite(windows)) return unknown;
725
+ if (processes < 0 || windows < 0 || windows > processes) return unknown;
726
+ return { known: true, processes: processes, windows: windows };
727
+ }
728
+
729
+ function inspectBrowserWindows() {
730
+ const unknown = { known: false, processes: 0, windows: 0 };
544
731
  try {
545
732
  if (process.platform === 'win32') {
546
733
  const probe = spawnSync(
@@ -548,17 +735,33 @@ function browserWindowIsUp() {
548
735
  [
549
736
  '-NoProfile',
550
737
  '-Command',
551
- "@(Get-Process chrome,msedge -ErrorAction SilentlyContinue | " +
552
- "Where-Object { $_.Path -like '*ms-playwright*' -and $_.MainWindowHandle -ne 0 }).Count",
738
+ "$p = @(Get-Process chrome,msedge -ErrorAction SilentlyContinue | " +
739
+ "Where-Object { $_.Path -like '*ms-playwright*' }); " +
740
+ "$w = @($p | Where-Object { $_.MainWindowHandle -ne 0 }); " +
741
+ 'Write-Output ("{0} {1}" -f $p.Count, $w.Count)',
553
742
  ],
554
- { encoding: 'utf8', windowsHide: true, timeout: 8000 }
743
+ { encoding: 'utf8', windowsHide: true, timeout: WINDOW_PROBE_TIMEOUT_MS }
555
744
  );
556
- return Number(String(probe.stdout || '').trim()) > 0;
745
+ // A timeout, a missing shell, or a non-zero exit all mean the same thing:
746
+ // nothing was learned. Only a well formed answer counts as knowing.
747
+ if (probe.error || probe.status !== 0) return unknown;
748
+ return parseWindowProbe(probe.stdout);
557
749
  }
558
- const probe = spawnSync('ps', ['-A', '-o', 'command'], { encoding: 'utf8', timeout: 8000 });
559
- return /ms-playwright/.test(String(probe.stdout || ''));
750
+
751
+ const probe = spawnSync('ps', ['-A', '-o', 'command'], {
752
+ encoding: 'utf8',
753
+ timeout: WINDOW_PROBE_TIMEOUT_MS,
754
+ });
755
+ if (probe.error || probe.status !== 0) return unknown;
756
+ const running = String(probe.stdout || '')
757
+ .split('\n')
758
+ .filter(function (line) {
759
+ return line.indexOf('ms-playwright') !== -1;
760
+ }).length;
761
+ // ps cannot see windows, so a running browser is taken at its word here.
762
+ return { known: true, processes: running, windows: running };
560
763
  } catch (err) {
561
- return true; // cannot tell - do not block the user
764
+ return unknown;
562
765
  }
563
766
  }
564
767
 
@@ -763,8 +966,24 @@ async function finaliseRecording(outFile, context) {
763
966
  }
764
967
  }
765
968
 
766
- if (ctx.reason === 'no-window') {
767
- explainNoWindow();
969
+ if (ctx.reason === 'no-window' || ctx.reason === 'never-started' || ctx.reason === 'unclear') {
970
+ // What the browser itself said comes first. It used to be read only when
971
+ // codegen exited on its own, so on this path a real error - a bad address,
972
+ // a refused connection - was captured and then thrown away, and the reader
973
+ // got a list of guesses instead of the answer.
974
+ reportCodegenSaid(ctx.stderr);
975
+ const detail = ctx.detail || {};
976
+ if (ctx.reason === 'unclear') {
977
+ for (const line of probeUnclearLines(detail.answered || 0, detail.unanswered || 0)) {
978
+ console.error(line);
979
+ }
980
+ } else if (ctx.reason === 'no-window') {
981
+ for (const line of windowUndetectedLines(detail.seconds || Math.round(WINDOW_TIMEOUT_MS / 1000))) {
982
+ console.error(line);
983
+ }
984
+ } else {
985
+ explainNoWindow();
986
+ }
768
987
  } else if (ctx.reason === 'signal') {
769
988
  console.log('');
770
989
  console.log(' Recording stopped before anything was saved.');
@@ -835,12 +1054,13 @@ async function record(url) {
835
1054
  // wire it up, name it. Runs once, whether codegen exits on its own or we
836
1055
  // are interrupted.
837
1056
  let finished = false;
838
- const finalise = async (reason) => {
1057
+ const finalise = async (reason, detail) => {
839
1058
  if (finished) return 1;
840
1059
  finished = true;
841
1060
  const outcome = await finaliseRecording(outFile, {
842
1061
  hadTestsDir: hadTestsDir,
843
1062
  target: target,
1063
+ detail: detail || null,
844
1064
  stderr: stderr,
845
1065
  reason: reason,
846
1066
  });
@@ -859,22 +1079,24 @@ async function record(url) {
859
1079
  // If no window ever appears, stop instead of waiting for someone to kill us.
860
1080
  // Someone whose window is real but undetectable can turn this off.
861
1081
  const watchdogOff = !!process.env.KRYPTHEON_FORCE_RECORD;
862
- let waited = 0;
1082
+ const watch = createWindowWatch();
863
1083
  const watchdog = setInterval(() => {
864
1084
  if (watchdogOff) {
865
1085
  clearInterval(watchdog);
866
1086
  return;
867
1087
  }
868
- waited += WINDOW_POLL_MS;
869
- if (browserWindowIsUp()) {
1088
+ // A child that has already gone is the close handler's business, not
1089
+ // this one's: it knows the exit code and what was written.
1090
+ if (child.exitCode !== null || child.signalCode !== null) {
870
1091
  clearInterval(watchdog);
871
1092
  return;
872
1093
  }
873
- if (waited >= WINDOW_TIMEOUT_MS) {
874
- clearInterval(watchdog);
875
- stopChild();
876
- finalise('no-window').then(resolve);
877
- }
1094
+ const outcome = watch.tick();
1095
+ if (!outcome) return;
1096
+ clearInterval(watchdog);
1097
+ if (outcome.verdict === 'window') return;
1098
+ stopChild();
1099
+ finalise(outcome.verdict, outcome).then(resolve);
878
1100
  }, WINDOW_POLL_MS);
879
1101
 
880
1102
  const onSignal = () => {
@@ -1432,6 +1654,15 @@ module.exports = {
1432
1654
  reportReplayRisks: reportReplayRisks,
1433
1655
  offerToDropLogout: offerToDropLogout,
1434
1656
  noWindowLines: noWindowLines,
1657
+ windowUndetectedLines: windowUndetectedLines,
1658
+ probeUnclearLines: probeUnclearLines,
1659
+ createWindowWatch: createWindowWatch,
1660
+ inspectBrowserWindows: inspectBrowserWindows,
1661
+ parseWindowProbe: parseWindowProbe,
1662
+ codegenComplaints: codegenComplaints,
1663
+ WINDOW_TIMEOUT_MS: WINDOW_TIMEOUT_MS,
1664
+ WINDOW_HARD_LIMIT_MS: WINDOW_HARD_LIMIT_MS,
1665
+ WINDOW_POLL_MS: WINDOW_POLL_MS,
1435
1666
  classifyFetchError: classifyFetchError,
1436
1667
  normaliseRecordUrl: normaliseRecordUrl,
1437
1668
  isLocalAddress: isLocalAddress,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",
@@ -33,11 +33,13 @@
33
33
  "@playwright/test": "^1.62.1"
34
34
  },
35
35
  "scripts": {
36
- "check": "node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-step3.check.js",
36
+ "check": "node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-step3.check.js && node kryptheon-watchdog.check.js && node kryptheon-record.check.js",
37
37
  "check:stream": "node kryptheon-signature.stream.check.js",
38
38
  "check:baseline": "node kryptheon-signature.baseline.check.js",
39
39
  "check:settle": "node kryptheon-signature.settle.check.js",
40
- "check:step3": "node kryptheon-step3.check.js"
40
+ "check:step3": "node kryptheon-step3.check.js",
41
+ "check:watchdog": "node kryptheon-watchdog.check.js",
42
+ "check:record": "node kryptheon-record.check.js"
41
43
  },
42
44
  "keywords": [
43
45
  "playwright",