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.
- package/bin/kryptheon.js +49 -11
- package/kryptheon-baselines.js +176 -0
- package/kryptheon-fixture.js +432 -388
- package/kryptheon-project.js +199 -0
- package/kryptheon-reporter.js +138 -5
- package/kryptheon-secrets.js +191 -191
- package/kryptheon-signature.js +11 -11
- package/package.json +74 -67
|
@@ -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
|
+
};
|
package/kryptheon-reporter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
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;
|