kryptheon 0.1.11 → 0.1.13

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.
@@ -23,6 +23,12 @@ const path = require('path');
23
23
  // stumble into.
24
24
  const ESCAPE_HATCH = 'KRYPTHEON_ALLOW_NO_PACKAGE_JSON';
25
25
 
26
+ // Where `record` keeps recordings for somebody who ran it in their home folder.
27
+ // A new terminal opens there, so for anyone who has only a web address - an
28
+ // app built in Lovable, no source folder anywhere - it is where the command
29
+ // gets typed.
30
+ const HOME_RECORDINGS = 'kryptheon-tests';
31
+
26
32
  function hasPackageJson(dir) {
27
33
  try {
28
34
  return fs.statSync(path.join(dir, 'package.json')).isFile();
@@ -107,6 +113,13 @@ function inspectProject(dir, env) {
107
113
  const personal = isHomeOrSystem(dir);
108
114
  const base = { dir: dir, packaged: packaged, personal: personal, allowed: allowed };
109
115
 
116
+ // Somebody whose recordings `record` put in a folder of their own, standing
117
+ // in their home folder again because that is where a new terminal opens.
118
+ // Pointed at by name, rather than told to go and find a project they never
119
+ // had.
120
+ const kept = tidy(dir) === tidy(os.homedir()) ? path.join(path.resolve(dir), HOME_RECORDINGS) : null;
121
+ if (kept && hasPackageJson(kept)) base.recordingsAt = kept;
122
+
110
123
  if (allowed) return Object.assign({ ok: true, reason: null }, base);
111
124
 
112
125
  // A home folder is refused even when it has a package.json, and this is not
@@ -131,6 +144,19 @@ function inspectProject(dir, env) {
131
144
  function noProjectLines(inspection) {
132
145
  const lines = [''];
133
146
 
147
+ if (inspection.recordingsAt) {
148
+ lines.push(' This is your home folder, not a project.');
149
+ lines.push('');
150
+ lines.push(' Your recordings are in: ' + inspection.recordingsAt);
151
+ lines.push('');
152
+ lines.push(' To check them:');
153
+ lines.push('');
154
+ lines.push(' cd "' + inspection.recordingsAt + '"');
155
+ lines.push(' npx kryptheon check');
156
+ lines.push('');
157
+ return lines;
158
+ }
159
+
134
160
  if (inspection.personal) {
135
161
  lines.push(' This is your home or a system folder, not a project.');
136
162
  lines.push('');
@@ -163,6 +189,16 @@ function noProjectLines(inspection) {
163
189
  lines.push(' cd path\\to\\your\\project');
164
190
  lines.push(' kryptheon check');
165
191
  lines.push('');
192
+ // The person with a web address and no folder at all - an app built in
193
+ // Lovable - has no project to change into, and would be stuck on the line
194
+ // above. Not offered in a system folder: record refuses to set one up.
195
+ if (!inspection.personal || tidy(inspection.dir) === tidy(os.homedir())) {
196
+ lines.push(' No project folder, only a web address? Record from here and it');
197
+ lines.push(' will be set up for you:');
198
+ lines.push('');
199
+ lines.push(' npx kryptheon record https://your-app.example.com');
200
+ lines.push('');
201
+ }
166
202
  lines.push(' If you really do keep recordings outside a package, set');
167
203
  lines.push(' ' + ESCAPE_HATCH + '=1 and this will run anyway.');
168
204
  lines.push('');
@@ -187,8 +223,296 @@ function noRecordingsLines() {
187
223
  ];
188
224
  }
189
225
 
226
+ // --- Setting a folder up, before recording into it ---------------------------
227
+ //
228
+ // Somebody who built their app in Lovable has an address and nothing else: no
229
+ // source folder, no package.json, nothing to change into. Their recordings
230
+ // still need somewhere to live, `check` refuses a folder with no package.json,
231
+ // and a recording cannot load the fixture unless kryptheon is installed where
232
+ // the recording is.
233
+ //
234
+ // `record` used to find all of that out afterwards. Measured in an empty
235
+ // folder: it started the browser, would have let the person spend five minutes
236
+ // using their app and saved the recording - and then `check`, in that same
237
+ // folder, said "There is no project here". So record now works out what is
238
+ // missing before the browser, which is a 200MB download on a new machine, and
239
+ // offers to add it.
240
+
241
+ /**
242
+ * Whether kryptheon can be loaded by a recording saved in this folder.
243
+ *
244
+ * A filesystem walk, not require.resolve: this file lives inside the kryptheon
245
+ * package, and a package with a name and an "exports" map can always resolve
246
+ * itself by name, so require.resolve would answer "yes" even when the user's
247
+ * recordings have no way to find it.
248
+ */
249
+ function kryptheonReachableFrom(dir) {
250
+ let current = path.resolve(String(dir || ''));
251
+ if (insideKryptheonItself(current)) return true;
252
+ for (;;) {
253
+ if (fs.existsSync(path.join(current, 'node_modules', 'kryptheon', 'kryptheon-fixture.js'))) {
254
+ return true;
255
+ }
256
+ const parent = path.dirname(current);
257
+ if (parent === current) return false;
258
+ current = parent;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Inside the kryptheon package's own folder - this repository.
264
+ *
265
+ * Node lets a package load itself by its own name, so a recording made here
266
+ * finds the fixture with no node_modules/kryptheon anywhere. Without this,
267
+ * record in this repository offered to npm install kryptheon into kryptheon.
268
+ * Only the nearest package.json counts: that is the package a file belongs to.
269
+ */
270
+ function insideKryptheonItself(dir) {
271
+ let current = dir;
272
+ for (;;) {
273
+ if (hasPackageJson(current)) {
274
+ // The name alone is not enough: somebody's own app can be called that.
275
+ // The fixture beside it is what makes it this package.
276
+ try {
277
+ return JSON.parse(fs.readFileSync(path.join(current, 'package.json'), 'utf8')).name === 'kryptheon' &&
278
+ fs.existsSync(path.join(current, 'kryptheon-fixture.js'));
279
+ } catch (err) {
280
+ return false;
281
+ }
282
+ }
283
+ const parent = path.dirname(current);
284
+ if (parent === current) return false;
285
+ current = parent;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Installed in this folder itself, not borrowed from one above it.
291
+ *
292
+ * A folder record sets up gets its own copy even when one is reachable from
293
+ * further up. The copy further up is, on the one machine where this was looked
294
+ * at, a stray install in a home folder - and recordings that lean on that stop
295
+ * working the day somebody tidies it away.
296
+ */
297
+ function kryptheonInstalledIn(dir) {
298
+ return fs.existsSync(path.join(path.resolve(String(dir || '')), 'node_modules', 'kryptheon', 'kryptheon-fixture.js'));
299
+ }
300
+
301
+ /**
302
+ * The nearest folder above this one that is a project.
303
+ *
304
+ * Stops at a home or system folder rather than walking through it: the one
305
+ * package.json most likely to be found up there is the stray one a mistaken
306
+ * install left in a home folder, and that is nobody's project.
307
+ */
308
+ function projectAbove(dir) {
309
+ let current = path.resolve(String(dir || ''));
310
+ for (;;) {
311
+ const parent = path.dirname(current);
312
+ if (parent === current) return null;
313
+ if (isHomeOrSystem(parent)) return null;
314
+ if (hasPackageJson(parent)) return parent;
315
+ current = parent;
316
+ }
317
+ }
318
+
319
+ /** A folder record set up on an earlier run - its package.json asks for kryptheon. */
320
+ function setUpForKryptheon(dir) {
321
+ try {
322
+ const json = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
323
+ return !!((json.devDependencies && json.devDependencies.kryptheon) ||
324
+ (json.dependencies && json.dependencies.kryptheon));
325
+ } catch (err) {
326
+ return false;
327
+ }
328
+ }
329
+
330
+ function isEmptyFolder(dir) {
331
+ try {
332
+ return fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0;
333
+ } catch (err) {
334
+ return false;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * What this folder needs before a recording made in it can be checked. Only
340
+ * looks; never writes.
341
+ *
342
+ * ready nothing
343
+ * create no package.json and no project above: make one, then install
344
+ * install a project without kryptheon: install it
345
+ * above no package.json, but a project sits above this folder
346
+ * home a home folder: record in a kryptheon-tests folder inside it
347
+ * refuse a system folder, or a home whose kryptheon-tests is taken
348
+ */
349
+ function setupPlan(dir, env) {
350
+ const environment = env || process.env;
351
+ const here = path.resolve(String(dir || ''));
352
+
353
+ // Somebody who has said they keep recordings outside a package is left
354
+ // exactly as they were before any of this existed.
355
+ if (String(environment[ESCAPE_HATCH] || '') !== '') return { action: 'ready', dir: here };
356
+
357
+ if (tidy(here) === tidy(os.homedir())) {
358
+ const into = path.join(here, HOME_RECORDINGS);
359
+ // Used only if it is plainly ours or plainly nothing. A folder of that name
360
+ // holding something else is somebody's, and recording into it would put a
361
+ // package.json and node_modules in the middle of it.
362
+ if (!fs.existsSync(into) || isEmptyFolder(into) || setUpForKryptheon(into)) {
363
+ return { action: 'home', dir: here, into: into };
364
+ }
365
+ return { action: 'refuse', reason: 'taken', dir: here, into: into };
366
+ }
367
+ if (isHomeOrSystem(here)) return { action: 'refuse', reason: 'system', dir: here };
368
+
369
+ if (!hasPackageJson(here)) {
370
+ const above = projectAbove(here);
371
+ if (above) return { action: 'above', dir: here, project: above };
372
+ return { action: 'create', dir: here };
373
+ }
374
+ if (!kryptheonReachableFrom(here)) return { action: 'install', dir: here };
375
+ return { action: 'ready', dir: here };
376
+ }
377
+
378
+ /** What is about to happen to this folder, said before it happens. */
379
+ function setupLines(plan, url) {
380
+ const address = url || 'https://your-app.example.com';
381
+ const lines = [''];
382
+
383
+ if (plan.action === 'create') {
384
+ lines.push(' This folder is not set up for recordings yet.');
385
+ lines.push('');
386
+ lines.push(' You are in: ' + plan.dir);
387
+ lines.push('');
388
+ lines.push(' Your recordings are saved here, and kryptheon has to be installed');
389
+ lines.push(' beside them or they cannot be checked later. So I will add:');
390
+ lines.push('');
391
+ lines.push(' package.json a small file that marks this folder as a project');
392
+ lines.push(' node_modules kryptheon itself, about 20MB');
393
+ lines.push('');
394
+ lines.push(' Nothing already in this folder is touched.');
395
+ } else if (plan.action === 'install') {
396
+ lines.push(' kryptheon is not installed in this project yet.');
397
+ lines.push('');
398
+ lines.push(' You are in: ' + plan.dir);
399
+ lines.push('');
400
+ lines.push(' Recordings load it by name, so without it here they cannot be');
401
+ lines.push(' checked later. I will run:');
402
+ lines.push('');
403
+ lines.push(' npm install --save-dev kryptheon');
404
+ lines.push('');
405
+ lines.push(' which adds one line to package.json and about 20MB to node_modules.');
406
+ } else if (plan.action === 'above') {
407
+ lines.push(' This folder is inside a project:');
408
+ lines.push('');
409
+ lines.push(' ' + plan.project);
410
+ lines.push('');
411
+ lines.push(' Recordings belong beside the app they test, so the usual thing is');
412
+ lines.push(' to run this from there:');
413
+ lines.push('');
414
+ lines.push(' cd "' + plan.project + '"');
415
+ lines.push(' npx kryptheon record ' + address);
416
+ lines.push('');
417
+ lines.push(' If that is not your project - a package.json can be left behind by');
418
+ lines.push(' an install run in the wrong place - I can set this folder up on its');
419
+ lines.push(' own instead.');
420
+ } else if (plan.action === 'home') {
421
+ lines.push(' You are in your home folder.');
422
+ lines.push('');
423
+ lines.push(' Recordings are kept in a folder of their own rather than loose in');
424
+ lines.push(' here, so I will ' + (fs.existsSync(plan.into) ? 'use this one' : 'make one') + ' and work in that:');
425
+ lines.push('');
426
+ lines.push(' ' + plan.into);
427
+ } else if (plan.action === 'refuse' && plan.reason === 'taken') {
428
+ lines.push(' You are in your home folder. Recordings are kept in a folder of');
429
+ lines.push(' their own, and I would normally use:');
430
+ lines.push('');
431
+ lines.push(' ' + plan.into);
432
+ lines.push('');
433
+ lines.push(' but something else is already there, and it is not one I made.');
434
+ lines.push(' Nothing was changed.');
435
+ lines.push('');
436
+ lines.push(' Make a new folder of your own, open a terminal in it, and run the');
437
+ lines.push(' same command again.');
438
+ } else if (plan.action === 'refuse') {
439
+ lines.push(' This is a system folder, not somewhere to keep recordings.');
440
+ lines.push('');
441
+ lines.push(' You are in: ' + plan.dir);
442
+ lines.push('');
443
+ lines.push(' Nothing was changed. Make a new folder of your own - on the Desktop');
444
+ lines.push(' is fine - open a terminal in it, and run the same command again.');
445
+ if (process.platform === 'win32') {
446
+ lines.push(' (In File Explorer: right-click inside the folder, Open in Terminal.)');
447
+ }
448
+ }
449
+ lines.push('');
450
+ return lines;
451
+ }
452
+
453
+ /**
454
+ * The question, and which way Enter answers it.
455
+ *
456
+ * Yes by default where saying yes is what the person came for. No by default
457
+ * for a folder inside another project, where yes puts a second project inside
458
+ * somebody's first.
459
+ */
460
+ function setupQuestion(plan) {
461
+ if (plan.action === 'above') return { text: ' Set this folder up on its own instead? [y/N] ', yesByDefault: false };
462
+ if (plan.action === 'install') return { text: ' Install it? [Y/n] ', yesByDefault: true };
463
+ if (plan.action === 'home') return { text: ' Go ahead? [Y/n] ', yesByDefault: true };
464
+ return { text: ' Set this folder up? [Y/n] ', yesByDefault: true };
465
+ }
466
+
467
+ /** An answer typed at the question, read the way the question offered it. */
468
+ function answeredYes(answer, yesByDefault) {
469
+ const typed = String(answer || '').trim();
470
+ if (!typed) return !!yesByDefault;
471
+ return /^y/i.test(typed);
472
+ }
473
+
474
+ /** After recording from a home folder: where the recordings went, and how to get back to them. */
475
+ function homeDoneLines(into) {
476
+ return [
477
+ '',
478
+ ' Your recordings are in: ' + into,
479
+ '',
480
+ ' To check them after a change, open a terminal and run:',
481
+ '',
482
+ ' cd "' + into + '"',
483
+ ' npx kryptheon check',
484
+ '',
485
+ ];
486
+ }
487
+
488
+ /** The install did not finish. Nothing needs undoing: running again picks up where this stopped. */
489
+ function installFailedLines() {
490
+ return [
491
+ '',
492
+ ' kryptheon could not be installed in this folder.',
493
+ '',
494
+ ' Installing needs an internet connection. Check it, then run the same',
495
+ ' command again - nothing needs undoing first.',
496
+ '',
497
+ ' Or install it yourself, then record:',
498
+ '',
499
+ ' npm install --save-dev kryptheon',
500
+ '',
501
+ ];
502
+ }
503
+
190
504
  module.exports = {
191
505
  ESCAPE_HATCH: ESCAPE_HATCH,
506
+ HOME_RECORDINGS: HOME_RECORDINGS,
507
+ kryptheonReachableFrom: kryptheonReachableFrom,
508
+ kryptheonInstalledIn: kryptheonInstalledIn,
509
+ projectAbove: projectAbove,
510
+ setupPlan: setupPlan,
511
+ setupLines: setupLines,
512
+ setupQuestion: setupQuestion,
513
+ answeredYes: answeredYes,
514
+ homeDoneLines: homeDoneLines,
515
+ installFailedLines: installFailedLines,
192
516
  hasPackageJson: hasPackageJson,
193
517
  systemFolders: systemFolders,
194
518
  tidy: tidy,
@@ -170,6 +170,94 @@ function firstMeaningfulLine(message) {
170
170
  return line ? line.replace(/^(TimeoutError|Error):\s*/, '').trim() : 'The test failed.';
171
171
  }
172
172
 
173
+ // ---------------------------------------------------------------------------
174
+ // Errors that belong to no test.
175
+ //
176
+ // A spec file with a syntax error, or one importing something that is not
177
+ // there, never becomes a test at all - so nothing calls onTestEnd and the run
178
+ // ends with no results. Playwright reports it through onError. A reporter that
179
+ // does not implement onError prints a summary of nothing and exits 1, and the
180
+ // person is left with a silent failure and no idea which file is at fault.
181
+ // ---------------------------------------------------------------------------
182
+
183
+ // Playwright says this once the load errors have already been reported. On its
184
+ // own it means an empty tests folder; after a real error it is a consequence,
185
+ // not a cause, and repeating it just buries the thing that has to be fixed.
186
+ function isNoTestsFound(error) {
187
+ return /No tests found/i.test(String((error && error.message) || ''));
188
+ }
189
+
190
+ /**
191
+ * A load error, reduced to the file, the line and one sentence.
192
+ *
193
+ * The message carries a code frame in ANSI colour and repeats the absolute
194
+ * path inside its own first line. Neither helps here: the path is printed
195
+ * separately and relative, and the frame belongs in an editor.
196
+ */
197
+ function readLoadError(error, userDir) {
198
+ const raw = stripAnsi((error && error.message) || (error && error.value) || String(error || ""));
199
+ const location = (error && error.location) || {};
200
+
201
+ let file = location.file || null;
202
+ if (file) {
203
+ try {
204
+ file = path.relative(userDir || process.cwd(), file);
205
+ } catch (e) {
206
+ /* an absolute path is still better than nothing */
207
+ }
208
+ }
209
+
210
+ // First line only, with the absolute path taken back out of it.
211
+ let message = raw.split("\n").map(function (l) { return l.trim(); }).filter(Boolean)[0] || "The file could not be read.";
212
+ if (location.file) message = message.split(location.file + ": ").join("");
213
+ message = message.replace(/\s*\(\d+:\d+\)\s*$/, "");
214
+
215
+ return {
216
+ file: file,
217
+ line: typeof location.line === "number" ? location.line : null,
218
+ message: message,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * What to print when nothing could be loaded.
224
+ *
225
+ * Said as "this file", not "the tests failed", because nothing was tested. The
226
+ * app may be perfectly fine; a file in the tests folder is not.
227
+ */
228
+ function loadErrorLines(errors, userDir) {
229
+ const real = (errors || []).filter(function (e) { return !isNoTestsFound(e); });
230
+ const lines = [];
231
+
232
+ if (!real.length) {
233
+ // Nothing ran and nothing explained why. Saying so is still better than a
234
+ // summary of zero, which reads as "everything is fine".
235
+ lines.push("Nothing ran.");
236
+ lines.push("");
237
+ lines.push("No recording produced a result, and the test runner gave no reason.");
238
+ lines.push("Check that the files in tests/ contain a test.");
239
+ lines.push("");
240
+ return lines;
241
+ }
242
+
243
+ const many = real.length > 1;
244
+ lines.push(many ? real.length + " of your recordings could not be read:" : "One of your recordings could not be read:");
245
+ lines.push("");
246
+ for (const error of real) {
247
+ const read = readLoadError(error, userDir);
248
+ const where = read.file ? read.file + (read.line ? " line " + read.line : "") : "(the file was not named)";
249
+ lines.push(" " + where);
250
+ lines.push(" " + read.message);
251
+ lines.push("");
252
+ }
253
+ lines.push(many
254
+ ? "Nothing else ran, because those files could not be loaded at all."
255
+ : "Nothing else ran, because that file could not be loaded at all.");
256
+ lines.push("Fix it and run this again.");
257
+ lines.push("");
258
+ return lines;
259
+ }
260
+
173
261
  // ---------------------------------------------------------------------------
174
262
  // Translation: Playwright error -> { reason, advice }
175
263
  // ---------------------------------------------------------------------------
@@ -575,10 +663,19 @@ class KryptheonReporter {
575
663
  this.previousRuns = [];
576
664
  this.records = [];
577
665
  this.startedAt = new Date();
666
+ // Errors that belong to no test: a spec file that would not load.
667
+ this.loadErrors = [];
578
668
  // Set by any test whose page never said what encoding it was in.
579
669
  this.sawGuessedEncoding = false;
580
670
  }
581
671
 
672
+ // Playwright calls this for anything that happened outside a test: a spec
673
+ // file that would not load, most often. Kept rather than printed here, so
674
+ // the reason lands beside the summary instead of ahead of the run.
675
+ onError(error) {
676
+ this.loadErrors.push(error);
677
+ }
678
+
582
679
  onBegin() {
583
680
  this.startedAt = new Date();
584
681
  // Read history before this run is appended, so "was working on" looks
@@ -807,10 +904,35 @@ class KryptheonReporter {
807
904
  // has ever had a baseline - says the app is at fault when nothing is.
808
905
  const counts = recordings.summarise(this.records);
809
906
 
810
- // The one line stands in for the whole report only when there is no report
811
- // to make. A recording that has never passed is not counted as broken, but
812
- // it did fail and its reasons were printed above, so the run still owes the
813
- // reader its closing count.
907
+ // A file that would not load is said first, and always - before the quiet
908
+ // shortcut, and whatever else the run managed to do.
909
+ //
910
+ // In practice a file that will not load stops the whole run, so nothing
911
+ // else has anything to report and the block below returns. This is not
912
+ // written as though that were guaranteed: onError also carries errors that
913
+ // arrive outside any test, and one of those turning up after results would
914
+ // otherwise be dropped in silence - which is the bug this whole thing is
915
+ // here to fix.
916
+ const unreadable = this.loadErrors.some(function (e) { return !isNoTestsFound(e); });
917
+ if (unreadable) {
918
+ process.stdout.write(
919
+ String.fromCharCode(10) + loadErrorLines(this.loadErrors, USER_DIR).join(String.fromCharCode(10)),
920
+ );
921
+ }
922
+
923
+ // Nothing ran at all. A summary of zero reads as good news, and in quiet
924
+ // mode it would say "0 recordings still working" over the top of a file
925
+ // that does not compile, so neither is printed. If the reason has not
926
+ // already been given above, it is given now.
927
+ if (!this.records.length) {
928
+ if (!unreadable) {
929
+ process.stdout.write(
930
+ String.fromCharCode(10) + loadErrorLines(this.loadErrors, USER_DIR).join(String.fromCharCode(10)),
931
+ );
932
+ }
933
+ return;
934
+ }
935
+
814
936
  if (QUIET && !counts.broken && !counts.unproven) {
815
937
  const s = counts.working === 1 ? '' : 's';
816
938
  process.stdout.write('OK ' + counts.working + ' recording' + s + ' still working.\n');
@@ -838,3 +960,7 @@ module.exports = KryptheonReporter;
838
960
  // Shared with the fixture so baselines strip hosts exactly the same way.
839
961
  // Attached to the exported class, so `new (require(...))()` keeps working.
840
962
  module.exports.requestPath = requestPath;
963
+
964
+ // Exported for the checks: what a file that would not load turns into.
965
+ module.exports.readLoadError = readLoadError;
966
+ module.exports.loadErrorLines = loadErrorLines;