kryptheon 0.1.0 → 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 +518 -36
  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++) {
@@ -58,28 +62,36 @@ function findPlaywrightCli() {
58
62
 
59
63
  // Always runs with the user's folder as the working directory, so tests,
60
64
  // baselines and .env are found where they actually live.
61
- function runPlaywright(args) {
65
+ // Returns { status, stderr }. With quietErrors the child's stderr is captured
66
+ // rather than inherited, so a Node or Playwright stack trace never reaches the
67
+ // person running the command.
68
+ function runPlaywright(args, options) {
69
+ const quiet = !!(options && options.quietErrors);
62
70
  const cli = findPlaywrightCli();
63
71
  if (!cli) {
64
72
  console.error('');
65
73
  console.error(' The testing engine is missing from this install.');
66
74
  console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
67
75
  console.error('');
68
- return 1;
76
+ return { status: 1, stderr: '' };
69
77
  }
70
78
 
71
79
  const result = spawnSync(process.execPath, [cli].concat(args), {
72
80
  cwd: USER_DIR,
73
- stdio: 'inherit',
81
+ stdio: quiet ? ['inherit', 'inherit', 'pipe'] : 'inherit',
82
+ encoding: 'utf8',
74
83
  });
75
84
 
76
85
  if (result.error) {
77
86
  console.error('');
78
87
  console.error(' Could not start the testing engine: ' + result.error.message);
79
88
  console.error('');
80
- return 1;
89
+ return { status: 1, stderr: String(result.stderr || '') };
81
90
  }
82
- return result.status === null ? 1 : result.status;
91
+ return {
92
+ status: result.status === null ? 1 : result.status,
93
+ stderr: String(result.stderr || ''),
94
+ };
83
95
  }
84
96
 
85
97
  // Chromium is a separate download from the npm package, so the first run on a
@@ -97,7 +109,7 @@ function ensureBrowser() {
97
109
  console.log(' Downloading a browser to run your app in.');
98
110
  console.log(' This is about 200MB and only happens once.');
99
111
  console.log('');
100
- const status = runPlaywright(['install', 'chromium']);
112
+ const status = runPlaywright(['install', 'chromium']).status;
101
113
  if (status !== 0) {
102
114
  console.error('');
103
115
  console.error(' The browser download did not finish.');
@@ -125,6 +137,171 @@ function listSpecFiles() {
125
137
  .filter((name) => /\.(spec|test)\.(c|m)?[jt]sx?$/.test(name));
126
138
  }
127
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
+
177
+ // --- is the address actually there? ----------------------------------------
178
+
179
+ // "localhost:3000" is what people type; give it a scheme before using it.
180
+ function normaliseRecordUrl(url) {
181
+ const s = String(url == null ? '' : url).trim();
182
+ if (!s) return s;
183
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) return s;
184
+ return 'http://' + s;
185
+ }
186
+
187
+ function isLocalAddress(url) {
188
+ let host;
189
+ try {
190
+ host = new URL(url).hostname.toLowerCase();
191
+ } catch (err) {
192
+ return false;
193
+ }
194
+ return (
195
+ host === 'localhost' ||
196
+ host === '127.0.0.1' ||
197
+ host === '0.0.0.0' ||
198
+ host === '::1' ||
199
+ host === '[::1]' ||
200
+ host.endsWith('.local') ||
201
+ host.endsWith('.localhost')
202
+ );
203
+ }
204
+
205
+ // Turns a fetch failure into one of a few plain kinds. Split out from the
206
+ // network call so it can be checked without a network.
207
+ function classifyFetchError(err) {
208
+ const code = (err && err.cause && err.cause.code) || (err && err.code) || null;
209
+ if (code === 'ECONNREFUSED') return 'refused';
210
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'unknown-address';
211
+ if (err && err.name === 'AbortError') return 'timeout';
212
+ if (code === 'ETIMEDOUT' || code === 'ECONNRESET') return 'timeout';
213
+ // A certificate complaint means something IS answering there. Let the
214
+ // browser deal with it rather than blocking the recording.
215
+ if (code && /CERT|SSL|SELF_SIGNED|VERIFY/i.test(String(code))) return 'reachable';
216
+ return 'other';
217
+ }
218
+
219
+ async function reachability(url, timeoutMs) {
220
+ try {
221
+ await httpGet(url, timeoutMs || 8000, 0);
222
+ return { ok: true, kind: 'reachable' };
223
+ } catch (err) {
224
+ const kind = classifyFetchError(err);
225
+ return kind === 'reachable' ? { ok: true, kind: kind } : { ok: false, kind: kind };
226
+ }
227
+ }
228
+
229
+ // One place that explains an address not answering, used both before codegen
230
+ // starts and if codegen itself reports the same thing.
231
+ function explainUnreachable(url, kind) {
232
+ const local = isLocalAddress(url);
233
+ console.error('');
234
+ console.error(' Nothing answered at ' + url);
235
+ console.error('');
236
+
237
+ if (kind === 'unknown-address') {
238
+ console.error(' That web address could not be found.');
239
+ console.error(' Check the spelling, and that you are connected to the internet.');
240
+ } else if (kind === 'timeout') {
241
+ console.error(' The address did not answer in time.');
242
+ console.error(' It may be slow, or blocked by a firewall or VPN.');
243
+ } else if (local) {
244
+ console.error(' Your app does not look like it is running.');
245
+ console.error('');
246
+ console.error(' For an app on your own machine, start it first - usually:');
247
+ console.error(' npm run dev');
248
+ console.error('');
249
+ console.error(' Then check the address and port match what it printed.');
250
+ } else {
251
+ console.error(' Nothing is listening at that address.');
252
+ console.error(' Check the address, including the port number, and that the site is up.');
253
+ }
254
+
255
+ console.error('');
256
+ console.error(' Nothing was recorded.');
257
+ console.error('');
258
+ }
259
+
260
+ // Codegen writes Node/Playwright traces to stderr. We never show those; we
261
+ // pick out the part that means something and say it in plain words.
262
+ function explainCodegenFailure(url, stderr) {
263
+ const text = String(stderr || '');
264
+ if (/ERR_CONNECTION_REFUSED/.test(text)) return explainUnreachable(url, 'refused');
265
+ if (/ERR_NAME_NOT_RESOLVED/.test(text)) return explainUnreachable(url, 'unknown-address');
266
+ if (/ERR_CONNECTION_TIMED_OUT|ERR_TIMED_OUT/.test(text)) return explainUnreachable(url, 'timeout');
267
+ if (/ERR_INTERNET_DISCONNECTED/.test(text)) {
268
+ console.error('');
269
+ console.error(' There is no internet connection.');
270
+ console.error('');
271
+ console.error(' Nothing was recorded.');
272
+ console.error('');
273
+ return;
274
+ }
275
+ console.error('');
276
+ console.error(' The browser closed before anything could be recorded.');
277
+ console.error('');
278
+ console.error(' Check that ' + url + ' opens normally in your own browser,');
279
+ console.error(' then try again.');
280
+ console.error('');
281
+ console.error(' Nothing was recorded.');
282
+ console.error('');
283
+ }
284
+
285
+ // A recording is only worth keeping if the browser actually did something.
286
+ // An abandoned or failed run leaves a test body with no page calls at all.
287
+ function countRecordedActions(source) {
288
+ const found = String(source || '').match(/\bpage\s*\.\s*[A-Za-z_$][\w$]*\s*\(/g);
289
+ return found ? found.length : 0;
290
+ }
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
+
128
305
  function timestampName() {
129
306
  const d = new Date();
130
307
  const pad = (n) => String(n).padStart(2, '0');
@@ -198,13 +375,9 @@ function nameFromTitle(title) {
198
375
  // to the recorded path if this cannot be reached.
199
376
  async function fetchTitle(url) {
200
377
  try {
201
- const controller = new AbortController();
202
- const timer = setTimeout(() => controller.abort(), 5000);
203
- const res = await fetch(url, { signal: controller.signal });
204
- clearTimeout(timer);
205
- if (!res.ok) return null;
206
- const html = await res.text();
207
- 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);
208
381
  return m ? m[1].replace(/\s+/g, ' ').trim() : null;
209
382
  } catch (err) {
210
383
  return null;
@@ -294,6 +467,127 @@ async function nameRecording(relativeFile) {
294
467
  }
295
468
  return target;
296
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
+ }
297
591
 
298
592
  async function record(url) {
299
593
  if (!url) {
@@ -305,18 +599,37 @@ async function record(url) {
305
599
  console.error('');
306
600
  return 1;
307
601
  }
602
+
603
+ const target = normaliseRecordUrl(url);
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
+
612
+ // Fail early and kindly rather than letting codegen throw a stack trace.
613
+ const reach = await reachability(target);
614
+ if (!reach.ok) {
615
+ explainUnreachable(target, reach.kind);
616
+ return 1;
617
+ }
618
+
308
619
  if (!ensureBrowser()) return 1;
309
620
 
621
+ const hadTestsDir = fs.existsSync(TESTS_DIR);
310
622
  try {
311
623
  fs.mkdirSync(TESTS_DIR, { recursive: true });
312
624
  } catch (err) {
313
625
  /* codegen will report if it cannot write */
314
626
  }
315
627
 
316
- let outFile = path.join('tests', timestampName());
628
+ const outFile = path.join('tests', timestampName());
629
+ const specPath = path.join(USER_DIR, outFile);
317
630
 
318
631
  console.log('');
319
- console.log(' Opening ' + url + ' in a browser.');
632
+ console.log(' Opening ' + target + ' in a browser.');
320
633
  console.log('');
321
634
  console.log(' Use your app normally - click, type, sign in, whatever you want');
322
635
  console.log(' covered. Every step is recorded as you go.');
@@ -324,20 +637,74 @@ async function record(url) {
324
637
  console.log(' When you are done, close the browser window to save the test.');
325
638
  console.log('');
326
639
 
327
- const status = runPlaywright(['codegen', '--target', 'playwright-test', '-o', outFile, url]);
640
+ const child = startCodegen(['codegen', '--target', 'playwright-test', '-o', outFile, target]);
641
+ if (!child) return 1;
328
642
 
329
- // Keyed off the file rather than the exit code: codegen still writes the
330
- // recording when the window is force-closed, and that file should be wired
331
- // up the same way.
332
- if (fs.existsSync(path.join(USER_DIR, outFile))) {
333
- pointAtFixture(outFile);
334
- outFile = await nameRecording(outFile);
335
- console.log('');
336
- console.log(' Saved to ' + outFile);
337
- console.log(' Run it any time with: kryptheon check');
338
- console.log('');
339
- }
340
- return status;
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
+ };
661
+
662
+ const stopChild = () => {
663
+ try {
664
+ child.kill();
665
+ } catch (err) {
666
+ /* already gone */
667
+ }
668
+ };
669
+
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
+ });
341
708
  }
342
709
 
343
710
  // Recordings import the fixture by package name, which only resolves if
@@ -371,6 +738,46 @@ function specsNeedThePackage() {
371
738
  });
372
739
  }
373
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
+
374
781
  function check() {
375
782
  if (!listSpecFiles().length) {
376
783
  console.log('');
@@ -396,8 +803,50 @@ function check() {
396
803
  console.log('');
397
804
  return 1;
398
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
+
399
832
  if (!ensureBrowser()) return 1;
400
- return runPlaywright(['test', '--config', CONFIG]);
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;
401
850
  }
402
851
 
403
852
  // --- accept -----------------------------------------------------------------
@@ -502,6 +951,29 @@ function accept(name) {
502
951
 
503
952
  const [command, ...rest] = process.argv.slice(2);
504
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
+
505
977
  async function main() {
506
978
  if (nodeIsTooOld()) {
507
979
  console.error('');
@@ -511,31 +983,31 @@ async function main() {
511
983
  console.error('');
512
984
  console.error(' Download the latest from https://nodejs.org and try again.');
513
985
  console.error('');
514
- process.exit(1);
986
+ return finishWith(1);
515
987
  }
516
988
 
517
989
  switch (command) {
518
990
  case 'record':
519
- process.exit(await record(rest[0]));
991
+ return finishWith(await record(rest[0]));
520
992
  break;
521
993
  case 'check':
522
- process.exit(check());
994
+ return finishWith(check());
523
995
  break;
524
996
  case 'accept':
525
- process.exit(accept(rest.join(' ').trim()));
997
+ return finishWith(accept(rest.join(' ').trim()));
526
998
  break;
527
999
  case undefined:
528
1000
  case '-h':
529
1001
  case '--help':
530
1002
  case 'help':
531
1003
  usage();
532
- process.exit(0);
1004
+ return finishWith(0);
533
1005
  break;
534
1006
  default:
535
1007
  console.error('');
536
1008
  console.error(' Unknown command: ' + command);
537
1009
  usage();
538
- process.exit(1);
1010
+ return finishWith(1);
539
1011
  }
540
1012
  }
541
1013
 
@@ -548,6 +1020,16 @@ module.exports = {
548
1020
  nameRecording: nameRecording,
549
1021
  pointAtFixture: pointAtFixture,
550
1022
  listSpecFiles: listSpecFiles,
1023
+ countRecordedActions: countRecordedActions,
1024
+ isRealRecording: isRealRecording,
1025
+ importsPlaywrightDirectly: importsPlaywrightDirectly,
1026
+ triageSpecs: triageSpecs,
1027
+ record: record,
1028
+ finaliseRecording: finaliseRecording,
1029
+ looksLikeNoDesktop: looksLikeNoDesktop,
1030
+ classifyFetchError: classifyFetchError,
1031
+ normaliseRecordUrl: normaliseRecordUrl,
1032
+ isLocalAddress: isLocalAddress,
551
1033
  nodeIsTooOld: nodeIsTooOld,
552
1034
  };
553
1035
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon",
3
- "version": "0.1.0",
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",