kryptheon 0.1.10 → 0.1.12

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.
@@ -0,0 +1,199 @@
1
+ // Working out whether this folder is a project at all.
2
+ //
3
+ // `kryptheon check` reads recordings out of ./tests and baselines out of the
4
+ // folder it was run in. Run it somewhere that is not a project - a home folder,
5
+ // the place a global install happened to leave you - and it will happily find
6
+ // whatever tests/ happens to be lying around and run those instead, reporting
7
+ // results for a project nobody asked about. That has already happened to
8
+ // somebody who installed into their home folder.
9
+ //
10
+ // Quietly testing the wrong project is the worst thing this tool can do. A
11
+ // green that belongs to something else is not a result, it is a lie with a tick
12
+ // next to it. So the answer to "is this a project?" has to be settled before
13
+ // anything runs, and the answer has to be a stop rather than a guess.
14
+ //
15
+ // Pure path work: nothing here runs a test or reads a recording.
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+
21
+ // For someone who genuinely keeps recordings outside a package. Deliberately
22
+ // long-winded: it should be something you decide to type, not something you
23
+ // stumble into.
24
+ const ESCAPE_HATCH = 'KRYPTHEON_ALLOW_NO_PACKAGE_JSON';
25
+
26
+ function hasPackageJson(dir) {
27
+ try {
28
+ return fs.statSync(path.join(dir, 'package.json')).isFile();
29
+ } catch (e) {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * One comparable form of a path.
36
+ *
37
+ * The trailing separator has to go or C:\Users and C:\Users\ never match, but
38
+ * it must not be taken off before the root test below. "C:" without a separator
39
+ * is not the drive - Node resolves it to the current directory - so a root
40
+ * compared in this form would quietly become "wherever I happen to be".
41
+ */
42
+ function tidy(dir) {
43
+ return path.resolve(String(dir || '')).replace(/[\\/]+$/, '').toLowerCase();
44
+ }
45
+
46
+ /** The folders that are somebody's whole account or the machine itself. */
47
+ function isHomeOrSystem(dir) {
48
+ // A drive or filesystem root: C:\, D:\, /. Done on the resolved path, with
49
+ // its separator still on, for the reason above.
50
+ const resolved = path.resolve(String(dir || ''));
51
+ if (resolved === path.parse(resolved).root) return true;
52
+
53
+ const here = tidy(dir);
54
+ if (here === tidy(os.homedir())) return true;
55
+
56
+ for (const candidate of systemFolders()) {
57
+ if (here === tidy(candidate)) return true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /**
63
+ * Every folder that belongs to the machine or to an account rather than to a
64
+ * project, resolved.
65
+ *
66
+ * The first two entries are derived from the running system; the literals after
67
+ * them are fallbacks for the usual layouts. They overlap - on Windows
68
+ * USERPROFILE is the home folder and "/Users" resolves to C:\Users - and the
69
+ * overlap is deliberate: a machine whose home is somewhere unusual is covered
70
+ * by the derived pair, and a machine whose environment is missing entries is
71
+ * covered by the literals.
72
+ */
73
+ function systemFolders() {
74
+ return [
75
+ // The folder every account lives in - C:\Users, /home, /Users. On its own
76
+ // it has no package.json so it would be refused anyway, but one stray npm
77
+ // install there and it would look exactly like a project.
78
+ path.dirname(os.homedir()),
79
+ process.env.SystemRoot,
80
+ process.env.ProgramFiles,
81
+ process.env['ProgramFiles(x86)'],
82
+ process.env.ProgramData,
83
+ process.env.APPDATA,
84
+ process.env.LOCALAPPDATA,
85
+ process.env.USERPROFILE,
86
+ '/root',
87
+ '/home',
88
+ '/Users',
89
+ '/usr',
90
+ '/etc',
91
+ '/bin',
92
+ '/var',
93
+ '/opt',
94
+ ].filter(Boolean);
95
+ }
96
+
97
+ /**
98
+ * Whether this folder can be checked, and if not, why not.
99
+ *
100
+ * `allowed` is the escape hatch having been set. It is reported rather than
101
+ * hidden, so a run that only worked because of it says so.
102
+ */
103
+ function inspectProject(dir, env) {
104
+ const environment = env || process.env;
105
+ const allowed = String(environment[ESCAPE_HATCH] || '') !== '';
106
+ const packaged = hasPackageJson(dir);
107
+ const personal = isHomeOrSystem(dir);
108
+ const base = { dir: dir, packaged: packaged, personal: personal, allowed: allowed };
109
+
110
+ if (allowed) return Object.assign({ ok: true, reason: null }, base);
111
+
112
+ // A home folder is refused even when it has a package.json, and this is not
113
+ // belt and braces - it is the case that actually happened. Someone installed
114
+ // the tool into their home folder, so npm left a package.json and a
115
+ // node_modules there, and a tests folder alongside them. Every sign this
116
+ // could look for says "project"; none of them is true. The measured state of
117
+ // one real machine: C:\Users\<name> holding package.json, node_modules and
118
+ // tests, none of which belong to anything.
119
+ if (personal) return Object.assign({ ok: false, reason: 'personal' }, base);
120
+ if (!packaged) return Object.assign({ ok: false, reason: 'no-package' }, base);
121
+ return Object.assign({ ok: true, reason: null }, base);
122
+ }
123
+
124
+ /**
125
+ * Why this folder is not a project, and what to do instead.
126
+ *
127
+ * The folder is printed back because the commonest version of this is not
128
+ * knowing where you are - a new terminal, a global install, a shell that opened
129
+ * somewhere else.
130
+ */
131
+ function noProjectLines(inspection) {
132
+ const lines = [''];
133
+
134
+ if (inspection.personal) {
135
+ lines.push(' This is your home or a system folder, not a project.');
136
+ lines.push('');
137
+ lines.push(' You are in: ' + inspection.dir);
138
+ lines.push('');
139
+ // Almost never deliberate, and the one place where running anyway does the
140
+ // most damage. Said explicitly because a home folder that has had anything
141
+ // installed into it looks exactly like a project from the outside.
142
+ lines.push(' Recordings and results are kept beside the app they belong to,');
143
+ lines.push(' so running here would check whichever tests folder happens to be');
144
+ lines.push(' sitting in this one - almost certainly not the one you meant.');
145
+ if (inspection.packaged) {
146
+ lines.push('');
147
+ // Said plainly, because otherwise the package.json sitting right there
148
+ // makes this message look like the bug rather than the diagnosis.
149
+ lines.push(' There is a package.json here, but that is not what makes a project.');
150
+ lines.push(' It looks like npm install was run in this folder by mistake, which');
151
+ lines.push(' leaves a package.json and a node_modules behind.');
152
+ }
153
+ lines.push('');
154
+ } else {
155
+ lines.push(' There is no project here - this folder has no package.json.');
156
+ lines.push('');
157
+ lines.push(' You are in: ' + inspection.dir);
158
+ lines.push('');
159
+ }
160
+
161
+ lines.push(' Change into your project folder and run this again:');
162
+ lines.push('');
163
+ lines.push(' cd path\\to\\your\\project');
164
+ lines.push(' kryptheon check');
165
+ lines.push('');
166
+ lines.push(' If you really do keep recordings outside a package, set');
167
+ lines.push(' ' + ESCAPE_HATCH + '=1 and this will run anyway.');
168
+ lines.push('');
169
+ return lines;
170
+ }
171
+
172
+ /** A real project, with nothing recorded in it yet. */
173
+ function noRecordingsLines() {
174
+ return [
175
+ '',
176
+ ' No recordings were found in this project.',
177
+ '',
178
+ ' This is not a failure, and there is nothing in the code to fix.',
179
+ '',
180
+ ' Record a flow through your app first:',
181
+ '',
182
+ ' kryptheon record http://localhost:3000',
183
+ '',
184
+ ' That opens a browser window, so it has to be run by a person in a',
185
+ ' normal terminal - it cannot be done from an AI assistant.',
186
+ '',
187
+ ];
188
+ }
189
+
190
+ module.exports = {
191
+ ESCAPE_HATCH: ESCAPE_HATCH,
192
+ hasPackageJson: hasPackageJson,
193
+ systemFolders: systemFolders,
194
+ tidy: tidy,
195
+ isHomeOrSystem: isHomeOrSystem,
196
+ inspectProject: inspectProject,
197
+ noProjectLines: noProjectLines,
198
+ noRecordingsLines: noRecordingsLines,
199
+ };
@@ -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
@@ -726,7 +823,14 @@ class KryptheonReporter {
726
823
  out.push('X ' + test.title);
727
824
  out.push(' ' + translated.reason);
728
825
 
729
- for (const detail of translated.details || []) out.push(' ' + detail);
826
+ // A difference against a baseline only means something if there is a
827
+ // baseline of this recording's own to differ from. Saying "this has not
828
+ // passed before" and then listing headings that are gone says two things
829
+ // that cannot both be true, and the reader spends their time trying to
830
+ // reconcile them instead of reading the actual failure.
831
+ const aboutBaseline = /Baseline changed|ended up somewhere different/.test(translated.reason || '');
832
+ const details = lastPass || !aboutBaseline ? translated.details || [] : [];
833
+ for (const detail of details) out.push(' ' + detail);
730
834
 
731
835
  out.push(lastPass ? ' This was working on ' + formatWhen(lastPass) + '.' : ' This has not passed before.');
732
836
 
@@ -800,10 +904,35 @@ class KryptheonReporter {
800
904
  // has ever had a baseline - says the app is at fault when nothing is.
801
905
  const counts = recordings.summarise(this.records);
802
906
 
803
- // The one line stands in for the whole report only when there is no report
804
- // to make. A recording that has never passed is not counted as broken, but
805
- // it did fail and its reasons were printed above, so the run still owes the
806
- // 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
+
807
936
  if (QUIET && !counts.broken && !counts.unproven) {
808
937
  const s = counts.working === 1 ? '' : 's';
809
938
  process.stdout.write('OK ' + counts.working + ' recording' + s + ' still working.\n');
@@ -831,3 +960,7 @@ module.exports = KryptheonReporter;
831
960
  // Shared with the fixture so baselines strip hosts exactly the same way.
832
961
  // Attached to the exported class, so `new (require(...))()` keeps working.
833
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;