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 +252 -21
- package/package.json +5 -3
- package/bin/kryptheon-Shantanu.js +0 -1672
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
|
-
|
|
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
|
-
//
|
|
543
|
-
|
|
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*'
|
|
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:
|
|
743
|
+
{ encoding: 'utf8', windowsHide: true, timeout: WINDOW_PROBE_TIMEOUT_MS }
|
|
555
744
|
);
|
|
556
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1082
|
+
const watch = createWindowWatch();
|
|
863
1083
|
const watchdog = setInterval(() => {
|
|
864
1084
|
if (watchdogOff) {
|
|
865
1085
|
clearInterval(watchdog);
|
|
866
1086
|
return;
|
|
867
1087
|
}
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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.
|
|
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",
|