kryptheon 0.1.1 → 0.1.2

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.
Files changed (2) hide show
  1. package/bin/kryptheon.js +371 -67
  2. package/package.json +1 -1
package/bin/kryptheon.js CHANGED
@@ -10,7 +10,7 @@
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
14
 
15
15
  const PACKAGE_DIR = path.join(__dirname, '..');
16
16
  const USER_DIR = process.cwd();
@@ -19,6 +19,10 @@ const TESTS_DIR = path.join(USER_DIR, 'tests');
19
19
 
20
20
  const MIN_NODE = [20, 6, 0];
21
21
 
22
+ // How long to wait for a browser window to appear before giving up.
23
+ const WINDOW_POLL_MS = 3000;
24
+ const WINDOW_TIMEOUT_MS = 30000;
25
+
22
26
  function nodeIsTooOld(version) {
23
27
  const parts = String(version || process.versions.node).split('.').map(Number);
24
28
  for (let i = 0; i < MIN_NODE.length; i++) {
@@ -133,6 +137,43 @@ function listSpecFiles() {
133
137
  .filter((name) => /\.(spec|test)\.(c|m)?[jt]sx?$/.test(name));
134
138
  }
135
139
 
140
+ // A plain GET on Node's own http/https, deliberately NOT fetch. fetch keeps
141
+ // its sockets alive in a pool, and those were still open at exit - handles
142
+ // being torn down while the process is ending is how libuv assertions happen
143
+ // on Windows. agent:false closes the socket as soon as we are done with it.
144
+ function httpGet(url, timeoutMs, redirectsLeft) {
145
+ return new Promise((resolve, reject) => {
146
+ let parsed;
147
+ try {
148
+ parsed = new URL(url);
149
+ } catch (err) {
150
+ return reject(Object.assign(new Error('bad address'), { code: 'ERR_INVALID_URL' }));
151
+ }
152
+ const lib = parsed.protocol === 'https:' ? require('https') : require('http');
153
+ const req = lib.get(url, { agent: false, timeout: timeoutMs || 8000 }, (res) => {
154
+ const location = res.headers && res.headers.location;
155
+ if (location && res.statusCode >= 300 && res.statusCode < 400 && (redirectsLeft || 0) > 0) {
156
+ res.resume();
157
+ req.destroy();
158
+ return resolve(httpGet(new URL(location, url).toString(), timeoutMs, redirectsLeft - 1));
159
+ }
160
+ let body = '';
161
+ res.setEncoding('utf8');
162
+ res.on('data', (chunk) => {
163
+ if (body.length < 300000) body += chunk;
164
+ });
165
+ res.on('end', () => {
166
+ req.destroy();
167
+ resolve({ status: res.statusCode, body: body });
168
+ });
169
+ });
170
+ req.on('timeout', () => {
171
+ req.destroy(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }));
172
+ });
173
+ req.on('error', reject);
174
+ });
175
+ }
176
+
136
177
  // --- is the address actually there? ----------------------------------------
137
178
 
138
179
  // "localhost:3000" is what people type; give it a scheme before using it.
@@ -177,10 +218,7 @@ function classifyFetchError(err) {
177
218
 
178
219
  async function reachability(url, timeoutMs) {
179
220
  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);
221
+ await httpGet(url, timeoutMs || 8000, 0);
184
222
  return { ok: true, kind: 'reachable' };
185
223
  } catch (err) {
186
224
  const kind = classifyFetchError(err);
@@ -251,6 +289,19 @@ function countRecordedActions(source) {
251
289
  return found ? found.length : 0;
252
290
  }
253
291
 
292
+ // A spec that pulls Playwright in directly gets none of our diagnostics.
293
+ function importsPlaywrightDirectly(source) {
294
+ const text = String(source || '');
295
+ // The literal specifier is what matters; import and require both carry it.
296
+ return text.indexOf("'@playwright/test'") !== -1 || text.indexOf('"@playwright/test"') !== -1;
297
+ }
298
+
299
+ // codegen always writes the opening page.goto. A recording is only real if
300
+ // something else happened as well.
301
+ function isRealRecording(source) {
302
+ return countRecordedActions(source) >= 2;
303
+ }
304
+
254
305
  function timestampName() {
255
306
  const d = new Date();
256
307
  const pad = (n) => String(n).padStart(2, '0');
@@ -324,13 +375,9 @@ function nameFromTitle(title) {
324
375
  // to the recorded path if this cannot be reached.
325
376
  async function fetchTitle(url) {
326
377
  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);
378
+ const res = await httpGet(url, 5000, 2);
379
+ if (!res || res.status < 200 || res.status >= 300) return null;
380
+ const m = String(res.body).match(/<title[^>]*>([\s\S]*?)<\/title>/i);
334
381
  return m ? m[1].replace(/\s+/g, ' ').trim() : null;
335
382
  } catch (err) {
336
383
  return null;
@@ -420,6 +467,127 @@ async function nameRecording(relativeFile) {
420
467
  }
421
468
  return target;
422
469
  }
470
+ // Recording needs a real browser window on someone's screen. Inside an AI
471
+ // coding assistant there is no desktop to draw it on, so codegen would sit
472
+ // there forever and the run would be killed before anything was tidied up.
473
+ function looksLikeNoDesktop() {
474
+ if (process.env.KRYPTHEON_FORCE_RECORD) return false;
475
+ return !process.stdout.isTTY;
476
+ }
477
+
478
+ function explainNoWindow(url) {
479
+ console.error('');
480
+ console.error(' Recording needs a real browser window on your screen.');
481
+ console.error('');
482
+ console.error(' This terminal cannot show one - that is what happens inside an');
483
+ console.error(' AI coding assistant, or any window-less session.');
484
+ console.error('');
485
+ console.error(' Open Command Prompt (or PowerShell) yourself and run:');
486
+ console.error(' npx kryptheon record ' + url);
487
+ console.error('');
488
+ console.error(' Nothing was recorded.');
489
+ console.error('');
490
+ }
491
+
492
+ // Is a Playwright-controlled browser actually showing a window?
493
+ function browserWindowIsUp() {
494
+ try {
495
+ if (process.platform === 'win32') {
496
+ const probe = spawnSync(
497
+ 'powershell',
498
+ [
499
+ '-NoProfile',
500
+ '-Command',
501
+ "@(Get-Process chrome,msedge -ErrorAction SilentlyContinue | " +
502
+ "Where-Object { $_.Path -like '*ms-playwright*' -and $_.MainWindowHandle -ne 0 }).Count",
503
+ ],
504
+ { encoding: 'utf8', windowsHide: true, timeout: 8000 }
505
+ );
506
+ return Number(String(probe.stdout || '').trim()) > 0;
507
+ }
508
+ const probe = spawnSync('ps', ['-A', '-o', 'command'], { encoding: 'utf8', timeout: 8000 });
509
+ return /ms-playwright/.test(String(probe.stdout || ''));
510
+ } catch (err) {
511
+ return true; // cannot tell - do not block the user
512
+ }
513
+ }
514
+
515
+ // Runs codegen without blocking the event loop, so SIGINT and SIGTERM can
516
+ // still be handled. spawnSync would freeze the loop and no handler would run.
517
+ function startCodegen(args) {
518
+ const cli = findPlaywrightCli();
519
+ if (!cli) {
520
+ console.error('');
521
+ console.error(' The testing engine is missing from this install.');
522
+ console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
523
+ console.error('');
524
+ return null;
525
+ }
526
+ return spawn(process.execPath, [cli].concat(args), {
527
+ cwd: USER_DIR,
528
+ stdio: ['inherit', 'inherit', 'pipe'],
529
+ });
530
+ }
531
+
532
+ // Everything that has to happen to whatever codegen left behind, however
533
+ // the run ended. Module level so it can be exercised directly.
534
+ async function finaliseRecording(outFile, context) {
535
+ const ctx = context || {};
536
+ const specPath = path.join(USER_DIR, outFile);
537
+ let source = null;
538
+ try {
539
+ source = fs.readFileSync(specPath, 'utf8');
540
+ } catch (err) {
541
+ source = null;
542
+ }
543
+
544
+ if (source !== null && isRealRecording(source)) {
545
+ // Import rewrite first: it is instant, so even a second interruption
546
+ // leaves behind a spec that works.
547
+ pointAtFixture(outFile);
548
+ const named = await nameRecording(outFile);
549
+ console.log('');
550
+ console.log(' Saved to ' + named);
551
+ console.log(' Run it any time with: kryptheon check');
552
+ console.log('');
553
+ return { code: 0, savedAs: named };
554
+ }
555
+
556
+ if (source !== null) {
557
+ try {
558
+ fs.unlinkSync(specPath);
559
+ } catch (err) {
560
+ /* nothing more we can do */
561
+ }
562
+ }
563
+ if (!ctx.hadTestsDir) {
564
+ try {
565
+ if (fs.readdirSync(TESTS_DIR).length === 0) fs.rmdirSync(TESTS_DIR);
566
+ } catch (err) {
567
+ /* only tidying up */
568
+ }
569
+ }
570
+
571
+ if (ctx.reason === 'no-window') {
572
+ explainNoWindow(ctx.target);
573
+ } else if (ctx.reason === 'signal') {
574
+ console.log('');
575
+ console.log(' Recording stopped before anything was saved.');
576
+ console.log('');
577
+ console.log(' Nothing was recorded.');
578
+ console.log('');
579
+ } else if (source === null || /ERR_|Error:/.test(String(ctx.stderr || ''))) {
580
+ explainCodegenFailure(ctx.target, ctx.stderr);
581
+ } else {
582
+ console.log('');
583
+ console.log(' Nothing was recorded.');
584
+ console.log('');
585
+ console.log(' The browser closed before anything was clicked or typed.');
586
+ console.log(' Run the same command again and use your app before closing it.');
587
+ console.log('');
588
+ }
589
+ return { code: 1, savedAs: null };
590
+ }
423
591
 
424
592
  async function record(url) {
425
593
  if (!url) {
@@ -431,8 +599,16 @@ async function record(url) {
431
599
  console.error('');
432
600
  return 1;
433
601
  }
602
+
434
603
  const target = normaliseRecordUrl(url);
435
604
 
605
+ // No desktop means no window means nothing to record. Say so now rather
606
+ // than hanging until something kills us.
607
+ if (looksLikeNoDesktop()) {
608
+ explainNoWindow(target);
609
+ return 1;
610
+ }
611
+
436
612
  // Fail early and kindly rather than letting codegen throw a stack trace.
437
613
  const reach = await reachability(target);
438
614
  if (!reach.ok) {
@@ -449,7 +625,8 @@ async function record(url) {
449
625
  /* codegen will report if it cannot write */
450
626
  }
451
627
 
452
- let outFile = path.join('tests', timestampName());
628
+ const outFile = path.join('tests', timestampName());
629
+ const specPath = path.join(USER_DIR, outFile);
453
630
 
454
631
  console.log('');
455
632
  console.log(' Opening ' + target + ' in a browser.');
@@ -460,58 +637,74 @@ async function record(url) {
460
637
  console.log(' When you are done, close the browser window to save the test.');
461
638
  console.log('');
462
639
 
463
- const run = runPlaywright(['codegen', '--target', 'playwright-test', '-o', outFile, target], {
464
- quietErrors: true,
465
- });
466
-
467
- const specPath = path.join(USER_DIR, outFile);
468
- let source = null;
469
- try {
470
- source = fs.readFileSync(specPath, 'utf8');
471
- } catch (err) {
472
- source = null; // codegen wrote nothing at all
473
- }
474
-
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) {
479
- pointAtFixture(outFile);
480
- outFile = await nameRecording(outFile);
481
- console.log('');
482
- console.log(' Saved to ' + outFile);
483
- console.log(' Run it any time with: kryptheon check');
484
- console.log('');
485
- return 0;
486
- }
640
+ const child = startCodegen(['codegen', '--target', 'playwright-test', '-o', outFile, target]);
641
+ if (!child) return 1;
642
+
643
+ let stderr = '';
644
+ if (child.stderr) child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
645
+
646
+ // Everything that has to happen no matter how this ends: tidy the file,
647
+ // wire it up, name it. Runs once, whether codegen exits on its own or we
648
+ // are interrupted.
649
+ let finished = false;
650
+ const finalise = async (reason) => {
651
+ if (finished) return 1;
652
+ finished = true;
653
+ const outcome = await finaliseRecording(outFile, {
654
+ hadTestsDir: hadTestsDir,
655
+ target: target,
656
+ stderr: stderr,
657
+ reason: reason,
658
+ });
659
+ return outcome.code;
660
+ };
487
661
 
488
- // Nothing usable: leave no empty file behind.
489
- if (source !== null) {
662
+ const stopChild = () => {
490
663
  try {
491
- fs.unlinkSync(specPath);
664
+ child.kill();
492
665
  } catch (err) {
493
- /* nothing more we can do */
666
+ /* already gone */
494
667
  }
495
- }
496
- if (!hadTestsDir) {
497
- try {
498
- if (fs.readdirSync(TESTS_DIR).length === 0) fs.rmdirSync(TESTS_DIR);
499
- } catch (err) {
500
- /* only tidying up */
501
- }
502
- }
668
+ };
503
669
 
504
- if (run.status !== 0 || source === null) {
505
- explainCodegenFailure(target, run.stderr);
506
- } else {
507
- console.log('');
508
- console.log(' Nothing was recorded.');
509
- console.log('');
510
- console.log(' The browser closed before anything was clicked or typed.');
511
- console.log(' Run the same command again and use your app before closing it.');
512
- console.log('');
513
- }
514
- return 1;
670
+ return await new Promise((resolve) => {
671
+ // If no window ever appears, stop instead of waiting for someone to kill us.
672
+ let waited = 0;
673
+ const watchdog = setInterval(() => {
674
+ waited += WINDOW_POLL_MS;
675
+ if (browserWindowIsUp()) {
676
+ clearInterval(watchdog);
677
+ return;
678
+ }
679
+ if (waited >= WINDOW_TIMEOUT_MS) {
680
+ clearInterval(watchdog);
681
+ stopChild();
682
+ finalise('no-window').then(resolve);
683
+ }
684
+ }, WINDOW_POLL_MS);
685
+
686
+ const onSignal = () => {
687
+ clearInterval(watchdog);
688
+ stopChild();
689
+ finalise('signal').then(resolve);
690
+ };
691
+ process.once('SIGINT', onSignal);
692
+ process.once('SIGTERM', onSignal);
693
+ process.once('SIGHUP', onSignal);
694
+
695
+ child.on('error', () => {
696
+ clearInterval(watchdog);
697
+ finalise('error').then(resolve);
698
+ });
699
+
700
+ child.on('close', () => {
701
+ clearInterval(watchdog);
702
+ process.removeListener('SIGINT', onSignal);
703
+ process.removeListener('SIGTERM', onSignal);
704
+ process.removeListener('SIGHUP', onSignal);
705
+ finalise('exit').then(resolve);
706
+ });
707
+ });
515
708
  }
516
709
 
517
710
  // Recordings import the fixture by package name, which only resolves if
@@ -545,6 +738,46 @@ function specsNeedThePackage() {
545
738
  });
546
739
  }
547
740
 
741
+ // Older versions, and any run that was killed part way, left specs behind that
742
+ // import Playwright directly (so they produce no diagnostics) or that contain
743
+ // nothing but the opening navigation (so they verify nothing). Repair the
744
+ // first kind and refuse to run the second, rather than reporting a false OK.
745
+ function triageSpecs() {
746
+ const repaired = [];
747
+ const notRecordings = [];
748
+ const runnable = [];
749
+
750
+ for (const name of listSpecFiles()) {
751
+ const full = path.join(TESTS_DIR, name);
752
+ let source;
753
+ try {
754
+ source = fs.readFileSync(full, 'utf8');
755
+ } catch (err) {
756
+ continue;
757
+ }
758
+
759
+ if (/(from|require\()\s*(['"])@playwright\/test\2/.test(source)) {
760
+ const relative = path.join('tests', name);
761
+ if (pointAtFixture(relative)) {
762
+ repaired.push(name);
763
+ try {
764
+ source = fs.readFileSync(full, 'utf8');
765
+ } catch (err) {
766
+ /* keep what we had */
767
+ }
768
+ }
769
+ }
770
+
771
+ if (!isRealRecording(source)) {
772
+ notRecordings.push(name);
773
+ continue;
774
+ }
775
+ runnable.push(name);
776
+ }
777
+
778
+ return { repaired: repaired, notRecordings: notRecordings, runnable: runnable };
779
+ }
780
+
548
781
  function check() {
549
782
  if (!listSpecFiles().length) {
550
783
  console.log('');
@@ -570,8 +803,50 @@ function check() {
570
803
  console.log('');
571
804
  return 1;
572
805
  }
806
+ const triage = triageSpecs();
807
+
808
+ for (const name of triage.repaired) {
809
+ console.log('');
810
+ console.log(' Repaired ' + path.join('tests', name));
811
+ console.log(' It was saved without the part that explains failures. Fixed now.');
812
+ }
813
+
814
+ for (const name of triage.notRecordings) {
815
+ console.log('');
816
+ console.log(' Skipped ' + path.join('tests', name));
817
+ console.log(' This is not a real recording - it only opens a page and stops,');
818
+ console.log(' so it cannot tell you whether anything works.');
819
+ console.log(' Record it again: kryptheon record <url>');
820
+ }
821
+
822
+ if (!triage.runnable.length) {
823
+ console.log('');
824
+ console.log(' Nothing could be checked.');
825
+ console.log('');
826
+ console.log(' None of the files in tests/ is a usable recording.');
827
+ console.log(' Record one with: kryptheon record <url>');
828
+ console.log('');
829
+ return 1;
830
+ }
831
+
573
832
  if (!ensureBrowser()) return 1;
574
- return runPlaywright(['test', '--config', CONFIG]).status;
833
+
834
+ // Only the usable recordings are handed to the test runner, so a file that
835
+ // cannot produce diagnostics can never be counted as passing.
836
+ const only = triage.runnable.map((name) => path.join('tests', name).split(path.sep).join('/'));
837
+ const status = runPlaywright(['test', '--config', CONFIG].concat(only)).status;
838
+
839
+ // Without this, a green summary could still hide a recording that checked
840
+ // nothing at all.
841
+ if (triage.notRecordings.length) {
842
+ const many = triage.notRecordings.length > 1;
843
+ console.log(
844
+ ' Note: ' + triage.notRecordings.length + ' recording' + (many ? 's' : '') +
845
+ ' above checked nothing and ' + (many ? 'were' : 'was') + ' skipped.'
846
+ );
847
+ console.log('');
848
+ }
849
+ return status;
575
850
  }
576
851
 
577
852
  // --- accept -----------------------------------------------------------------
@@ -676,6 +951,29 @@ function accept(name) {
676
951
 
677
952
  const [command, ...rest] = process.argv.slice(2);
678
953
 
954
+ // process.exit() cuts the process off mid-teardown. On Windows a terminal's
955
+ // output is written asynchronously, so exiting while the last line is still
956
+ // queued tears down a handle that is already closing - which is what produces
957
+ // "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)" in libuv. Setting
958
+ // the code and letting Node wind down on its own avoids that entirely, and
959
+ // still reports the same exit status.
960
+ async function finishWith(code) {
961
+ process.exitCode = code;
962
+ const streams = [process.stdout, process.stderr];
963
+ await Promise.all(
964
+ streams.map(
965
+ (stream) =>
966
+ new Promise((resolve) => {
967
+ if (!stream || typeof stream.write !== 'function' || !stream.writableLength) return resolve();
968
+ const done = () => resolve();
969
+ stream.once('drain', done);
970
+ const guard = setTimeout(done, 1000);
971
+ if (guard.unref) guard.unref();
972
+ })
973
+ )
974
+ );
975
+ }
976
+
679
977
  async function main() {
680
978
  if (nodeIsTooOld()) {
681
979
  console.error('');
@@ -685,31 +983,31 @@ async function main() {
685
983
  console.error('');
686
984
  console.error(' Download the latest from https://nodejs.org and try again.');
687
985
  console.error('');
688
- process.exit(1);
986
+ return finishWith(1);
689
987
  }
690
988
 
691
989
  switch (command) {
692
990
  case 'record':
693
- process.exit(await record(rest[0]));
991
+ return finishWith(await record(rest[0]));
694
992
  break;
695
993
  case 'check':
696
- process.exit(check());
994
+ return finishWith(check());
697
995
  break;
698
996
  case 'accept':
699
- process.exit(accept(rest.join(' ').trim()));
997
+ return finishWith(accept(rest.join(' ').trim()));
700
998
  break;
701
999
  case undefined:
702
1000
  case '-h':
703
1001
  case '--help':
704
1002
  case 'help':
705
1003
  usage();
706
- process.exit(0);
1004
+ return finishWith(0);
707
1005
  break;
708
1006
  default:
709
1007
  console.error('');
710
1008
  console.error(' Unknown command: ' + command);
711
1009
  usage();
712
- process.exit(1);
1010
+ return finishWith(1);
713
1011
  }
714
1012
  }
715
1013
 
@@ -723,6 +1021,12 @@ module.exports = {
723
1021
  pointAtFixture: pointAtFixture,
724
1022
  listSpecFiles: listSpecFiles,
725
1023
  countRecordedActions: countRecordedActions,
1024
+ isRealRecording: isRealRecording,
1025
+ importsPlaywrightDirectly: importsPlaywrightDirectly,
1026
+ triageSpecs: triageSpecs,
1027
+ record: record,
1028
+ finaliseRecording: finaliseRecording,
1029
+ looksLikeNoDesktop: looksLikeNoDesktop,
726
1030
  classifyFetchError: classifyFetchError,
727
1031
  normaliseRecordUrl: normaliseRecordUrl,
728
1032
  isLocalAddress: isLocalAddress,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",