kryptheon 0.1.0 → 0.1.1

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 +192 -14
  2. package/package.json +1 -1
package/bin/kryptheon.js CHANGED
@@ -58,28 +58,36 @@ function findPlaywrightCli() {
58
58
 
59
59
  // Always runs with the user's folder as the working directory, so tests,
60
60
  // baselines and .env are found where they actually live.
61
- function runPlaywright(args) {
61
+ // Returns { status, stderr }. With quietErrors the child's stderr is captured
62
+ // rather than inherited, so a Node or Playwright stack trace never reaches the
63
+ // person running the command.
64
+ function runPlaywright(args, options) {
65
+ const quiet = !!(options && options.quietErrors);
62
66
  const cli = findPlaywrightCli();
63
67
  if (!cli) {
64
68
  console.error('');
65
69
  console.error(' The testing engine is missing from this install.');
66
70
  console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
67
71
  console.error('');
68
- return 1;
72
+ return { status: 1, stderr: '' };
69
73
  }
70
74
 
71
75
  const result = spawnSync(process.execPath, [cli].concat(args), {
72
76
  cwd: USER_DIR,
73
- stdio: 'inherit',
77
+ stdio: quiet ? ['inherit', 'inherit', 'pipe'] : 'inherit',
78
+ encoding: 'utf8',
74
79
  });
75
80
 
76
81
  if (result.error) {
77
82
  console.error('');
78
83
  console.error(' Could not start the testing engine: ' + result.error.message);
79
84
  console.error('');
80
- return 1;
85
+ return { status: 1, stderr: String(result.stderr || '') };
81
86
  }
82
- return result.status === null ? 1 : result.status;
87
+ return {
88
+ status: result.status === null ? 1 : result.status,
89
+ stderr: String(result.stderr || ''),
90
+ };
83
91
  }
84
92
 
85
93
  // Chromium is a separate download from the npm package, so the first run on a
@@ -97,7 +105,7 @@ function ensureBrowser() {
97
105
  console.log(' Downloading a browser to run your app in.');
98
106
  console.log(' This is about 200MB and only happens once.');
99
107
  console.log('');
100
- const status = runPlaywright(['install', 'chromium']);
108
+ const status = runPlaywright(['install', 'chromium']).status;
101
109
  if (status !== 0) {
102
110
  console.error('');
103
111
  console.error(' The browser download did not finish.');
@@ -125,6 +133,124 @@ function listSpecFiles() {
125
133
  .filter((name) => /\.(spec|test)\.(c|m)?[jt]sx?$/.test(name));
126
134
  }
127
135
 
136
+ // --- is the address actually there? ----------------------------------------
137
+
138
+ // "localhost:3000" is what people type; give it a scheme before using it.
139
+ function normaliseRecordUrl(url) {
140
+ const s = String(url == null ? '' : url).trim();
141
+ if (!s) return s;
142
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) return s;
143
+ return 'http://' + s;
144
+ }
145
+
146
+ function isLocalAddress(url) {
147
+ let host;
148
+ try {
149
+ host = new URL(url).hostname.toLowerCase();
150
+ } catch (err) {
151
+ return false;
152
+ }
153
+ return (
154
+ host === 'localhost' ||
155
+ host === '127.0.0.1' ||
156
+ host === '0.0.0.0' ||
157
+ host === '::1' ||
158
+ host === '[::1]' ||
159
+ host.endsWith('.local') ||
160
+ host.endsWith('.localhost')
161
+ );
162
+ }
163
+
164
+ // Turns a fetch failure into one of a few plain kinds. Split out from the
165
+ // network call so it can be checked without a network.
166
+ function classifyFetchError(err) {
167
+ const code = (err && err.cause && err.cause.code) || (err && err.code) || null;
168
+ if (code === 'ECONNREFUSED') return 'refused';
169
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'unknown-address';
170
+ if (err && err.name === 'AbortError') return 'timeout';
171
+ if (code === 'ETIMEDOUT' || code === 'ECONNRESET') return 'timeout';
172
+ // A certificate complaint means something IS answering there. Let the
173
+ // browser deal with it rather than blocking the recording.
174
+ if (code && /CERT|SSL|SELF_SIGNED|VERIFY/i.test(String(code))) return 'reachable';
175
+ return 'other';
176
+ }
177
+
178
+ async function reachability(url, timeoutMs) {
179
+ 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);
184
+ return { ok: true, kind: 'reachable' };
185
+ } catch (err) {
186
+ const kind = classifyFetchError(err);
187
+ return kind === 'reachable' ? { ok: true, kind: kind } : { ok: false, kind: kind };
188
+ }
189
+ }
190
+
191
+ // One place that explains an address not answering, used both before codegen
192
+ // starts and if codegen itself reports the same thing.
193
+ function explainUnreachable(url, kind) {
194
+ const local = isLocalAddress(url);
195
+ console.error('');
196
+ console.error(' Nothing answered at ' + url);
197
+ console.error('');
198
+
199
+ if (kind === 'unknown-address') {
200
+ console.error(' That web address could not be found.');
201
+ console.error(' Check the spelling, and that you are connected to the internet.');
202
+ } else if (kind === 'timeout') {
203
+ console.error(' The address did not answer in time.');
204
+ console.error(' It may be slow, or blocked by a firewall or VPN.');
205
+ } else if (local) {
206
+ console.error(' Your app does not look like it is running.');
207
+ console.error('');
208
+ console.error(' For an app on your own machine, start it first - usually:');
209
+ console.error(' npm run dev');
210
+ console.error('');
211
+ console.error(' Then check the address and port match what it printed.');
212
+ } else {
213
+ console.error(' Nothing is listening at that address.');
214
+ console.error(' Check the address, including the port number, and that the site is up.');
215
+ }
216
+
217
+ console.error('');
218
+ console.error(' Nothing was recorded.');
219
+ console.error('');
220
+ }
221
+
222
+ // Codegen writes Node/Playwright traces to stderr. We never show those; we
223
+ // pick out the part that means something and say it in plain words.
224
+ function explainCodegenFailure(url, stderr) {
225
+ const text = String(stderr || '');
226
+ if (/ERR_CONNECTION_REFUSED/.test(text)) return explainUnreachable(url, 'refused');
227
+ if (/ERR_NAME_NOT_RESOLVED/.test(text)) return explainUnreachable(url, 'unknown-address');
228
+ if (/ERR_CONNECTION_TIMED_OUT|ERR_TIMED_OUT/.test(text)) return explainUnreachable(url, 'timeout');
229
+ if (/ERR_INTERNET_DISCONNECTED/.test(text)) {
230
+ console.error('');
231
+ console.error(' There is no internet connection.');
232
+ console.error('');
233
+ console.error(' Nothing was recorded.');
234
+ console.error('');
235
+ return;
236
+ }
237
+ console.error('');
238
+ console.error(' The browser closed before anything could be recorded.');
239
+ console.error('');
240
+ console.error(' Check that ' + url + ' opens normally in your own browser,');
241
+ console.error(' then try again.');
242
+ console.error('');
243
+ console.error(' Nothing was recorded.');
244
+ console.error('');
245
+ }
246
+
247
+ // A recording is only worth keeping if the browser actually did something.
248
+ // An abandoned or failed run leaves a test body with no page calls at all.
249
+ function countRecordedActions(source) {
250
+ const found = String(source || '').match(/\bpage\s*\.\s*[A-Za-z_$][\w$]*\s*\(/g);
251
+ return found ? found.length : 0;
252
+ }
253
+
128
254
  function timestampName() {
129
255
  const d = new Date();
130
256
  const pad = (n) => String(n).padStart(2, '0');
@@ -305,8 +431,18 @@ async function record(url) {
305
431
  console.error('');
306
432
  return 1;
307
433
  }
434
+ const target = normaliseRecordUrl(url);
435
+
436
+ // Fail early and kindly rather than letting codegen throw a stack trace.
437
+ const reach = await reachability(target);
438
+ if (!reach.ok) {
439
+ explainUnreachable(target, reach.kind);
440
+ return 1;
441
+ }
442
+
308
443
  if (!ensureBrowser()) return 1;
309
444
 
445
+ const hadTestsDir = fs.existsSync(TESTS_DIR);
310
446
  try {
311
447
  fs.mkdirSync(TESTS_DIR, { recursive: true });
312
448
  } catch (err) {
@@ -316,7 +452,7 @@ async function record(url) {
316
452
  let outFile = path.join('tests', timestampName());
317
453
 
318
454
  console.log('');
319
- console.log(' Opening ' + url + ' in a browser.');
455
+ console.log(' Opening ' + target + ' in a browser.');
320
456
  console.log('');
321
457
  console.log(' Use your app normally - click, type, sign in, whatever you want');
322
458
  console.log(' covered. Every step is recorded as you go.');
@@ -324,20 +460,58 @@ async function record(url) {
324
460
  console.log(' When you are done, close the browser window to save the test.');
325
461
  console.log('');
326
462
 
327
- const status = runPlaywright(['codegen', '--target', 'playwright-test', '-o', outFile, url]);
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
+ }
328
474
 
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))) {
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) {
333
479
  pointAtFixture(outFile);
334
480
  outFile = await nameRecording(outFile);
335
481
  console.log('');
336
482
  console.log(' Saved to ' + outFile);
337
483
  console.log(' Run it any time with: kryptheon check');
338
484
  console.log('');
485
+ return 0;
486
+ }
487
+
488
+ // Nothing usable: leave no empty file behind.
489
+ if (source !== null) {
490
+ try {
491
+ fs.unlinkSync(specPath);
492
+ } catch (err) {
493
+ /* nothing more we can do */
494
+ }
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
+ }
503
+
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('');
339
513
  }
340
- return status;
514
+ return 1;
341
515
  }
342
516
 
343
517
  // Recordings import the fixture by package name, which only resolves if
@@ -397,7 +571,7 @@ function check() {
397
571
  return 1;
398
572
  }
399
573
  if (!ensureBrowser()) return 1;
400
- return runPlaywright(['test', '--config', CONFIG]);
574
+ return runPlaywright(['test', '--config', CONFIG]).status;
401
575
  }
402
576
 
403
577
  // --- accept -----------------------------------------------------------------
@@ -548,6 +722,10 @@ module.exports = {
548
722
  nameRecording: nameRecording,
549
723
  pointAtFixture: pointAtFixture,
550
724
  listSpecFiles: listSpecFiles,
725
+ countRecordedActions: countRecordedActions,
726
+ classifyFetchError: classifyFetchError,
727
+ normaliseRecordUrl: normaliseRecordUrl,
728
+ isLocalAddress: isLocalAddress,
551
729
  nodeIsTooOld: nodeIsTooOld,
552
730
  };
553
731
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",