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
package/kryptheon-fixture.js
CHANGED
|
@@ -1,388 +1,432 @@
|
|
|
1
|
-
// Shared test object that watches the browser during every test and, when a
|
|
2
|
-
// test fails, hands the reporter what was actually observed: the URL the
|
|
3
|
-
// browser ended on, any 4xx/5xx responses, and any console errors.
|
|
4
|
-
//
|
|
5
|
-
// It also keeps automatic baselines. After a test passes, the page's final
|
|
6
|
-
// address, title, and the browser noise it produced are remembered. On later
|
|
7
|
-
// runs the address and title are compared, so a recorded test catches
|
|
8
|
-
// regressions without anyone writing an assertion - and noise that was already
|
|
9
|
-
// there on the last passing run is filtered out of failure reports, because it
|
|
10
|
-
// is not evidence of the new problem.
|
|
11
|
-
//
|
|
12
|
-
// Specs import { test, expect } from here instead of from '@playwright/test'.
|
|
13
|
-
// Playwright has no global beforeEach, so this shared module is the way to
|
|
14
|
-
// apply the same behaviour to every spec without repeating it in each one.
|
|
15
|
-
|
|
16
|
-
const fs = require('fs');
|
|
17
|
-
const path = require('path');
|
|
18
|
-
const base = require('@playwright/test');
|
|
19
|
-
|
|
20
|
-
// Reuse the reporter's host-stripping so a baseline stores exactly the shape
|
|
21
|
-
// the reporter prints.
|
|
22
|
-
const { requestPath } = require('./kryptheon-reporter.js');
|
|
23
|
-
const signature = require('./kryptheon-signature.js');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
seen[key]
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
seen[line]
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
lines.push('
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
if (!
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
try {
|
|
332
|
-
|
|
333
|
-
} catch (e) {
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
1
|
+
// Shared test object that watches the browser during every test and, when a
|
|
2
|
+
// test fails, hands the reporter what was actually observed: the URL the
|
|
3
|
+
// browser ended on, any 4xx/5xx responses, and any console errors.
|
|
4
|
+
//
|
|
5
|
+
// It also keeps automatic baselines. After a test passes, the page's final
|
|
6
|
+
// address, title, and the browser noise it produced are remembered. On later
|
|
7
|
+
// runs the address and title are compared, so a recorded test catches
|
|
8
|
+
// regressions without anyone writing an assertion - and noise that was already
|
|
9
|
+
// there on the last passing run is filtered out of failure reports, because it
|
|
10
|
+
// is not evidence of the new problem.
|
|
11
|
+
//
|
|
12
|
+
// Specs import { test, expect } from here instead of from '@playwright/test'.
|
|
13
|
+
// Playwright has no global beforeEach, so this shared module is the way to
|
|
14
|
+
// apply the same behaviour to every spec without repeating it in each one.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const base = require('@playwright/test');
|
|
19
|
+
|
|
20
|
+
// Reuse the reporter's host-stripping so a baseline stores exactly the shape
|
|
21
|
+
// the reporter prints.
|
|
22
|
+
const { requestPath } = require('./kryptheon-reporter.js');
|
|
23
|
+
const signature = require('./kryptheon-signature.js');
|
|
24
|
+
const replay = require('./kryptheon-replay.js');
|
|
25
|
+
|
|
26
|
+
const MAX_ITEMS = 5; // keep the failure block readable
|
|
27
|
+
|
|
28
|
+
// Baselines describe the user's app, so they live in the folder the command
|
|
29
|
+
// was run from - never inside the installed package.
|
|
30
|
+
const USER_DIR = process.cwd();
|
|
31
|
+
const BASELINE_FILE = path.join(USER_DIR, 'kryptheon-baselines.json');
|
|
32
|
+
|
|
33
|
+
function oneLine(text) {
|
|
34
|
+
return String(text).replace(/\s+/g, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Baselines. These helpers take the file path explicitly so they can be
|
|
39
|
+
// exercised against a scratch file in the unit checks.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
// One entry per test, identified by where it lives plus what it is called.
|
|
43
|
+
function baselineKey(specFile, title) {
|
|
44
|
+
let rel = String(specFile || '');
|
|
45
|
+
try {
|
|
46
|
+
rel = path.relative(USER_DIR, specFile);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
/* keep what we were given */
|
|
49
|
+
}
|
|
50
|
+
return rel.split(path.sep).join('/') + ' :: ' + String(title || '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A missing or unreadable file, malformed JSON, or anything that is not a
|
|
54
|
+
// plain object all mean the same thing: no baselines yet.
|
|
55
|
+
function readBaselines(file) {
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = fs.readFileSync(file, { encoding: 'utf8' });
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
// An editor that saved the file with a byte order mark would otherwise make
|
|
63
|
+
// JSON.parse throw, and every baseline would silently vanish - which reads as
|
|
64
|
+
// "all clear" on the next run rather than as the problem it is.
|
|
65
|
+
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(raw);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function saveBaselines(file, all) {
|
|
77
|
+
try {
|
|
78
|
+
// Explicit, and without a byte order mark: this file is read back by the
|
|
79
|
+
// same code and by people, and a mismatch would look like the app changed.
|
|
80
|
+
fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n', { encoding: 'utf8' });
|
|
81
|
+
return true;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return false; // diagnostics must never become a second failure
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Identity of a failed request, independent of host and of run order.
|
|
88
|
+
function requestKey(req) {
|
|
89
|
+
if (!req) return '';
|
|
90
|
+
const where = req.path != null ? req.path : requestPath(req.url || '');
|
|
91
|
+
return (req.method || 'GET') + ' ' + where + ' ' + req.status;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toStoredRequests(list) {
|
|
95
|
+
const seen = Object.create(null);
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const req of list || []) {
|
|
98
|
+
const key = requestKey(req);
|
|
99
|
+
if (seen[key]) continue;
|
|
100
|
+
seen[key] = true;
|
|
101
|
+
out.push({
|
|
102
|
+
method: req.method || 'GET',
|
|
103
|
+
path: req.path != null ? req.path : requestPath(req.url || ''),
|
|
104
|
+
status: req.status,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toStoredErrors(list) {
|
|
111
|
+
const seen = Object.create(null);
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const text of list || []) {
|
|
114
|
+
const line = oneLine(text);
|
|
115
|
+
if (!line || seen[line]) continue;
|
|
116
|
+
seen[line] = true;
|
|
117
|
+
out.push(line);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalisedEntry(current) {
|
|
123
|
+
const stored = {
|
|
124
|
+
url: current.url,
|
|
125
|
+
title: current.title,
|
|
126
|
+
consoleErrors: toStoredErrors(current.consoleErrors),
|
|
127
|
+
failedRequests: toStoredRequests(current.failedRequests),
|
|
128
|
+
};
|
|
129
|
+
// Only when there is one. A run whose capture failed must not write an empty
|
|
130
|
+
// signature over a good one, or the next run would report everything as gone.
|
|
131
|
+
if (current.signature) stored.signature = current.signature;
|
|
132
|
+
// What recording this was taken from. Without it, a saved result is tied
|
|
133
|
+
// only to a file name, and a re-recording that lands on the same name
|
|
134
|
+
// inherits a baseline it never created and can never match.
|
|
135
|
+
if (current.recordingId) stored.recordingId = current.recordingId;
|
|
136
|
+
return stored;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function writeBaseline(file, key, entry) {
|
|
140
|
+
const all = readBaselines(file);
|
|
141
|
+
const stored = normalisedEntry(entry);
|
|
142
|
+
stored.recordedAt = new Date().toISOString();
|
|
143
|
+
all[key] = stored;
|
|
144
|
+
return saveBaselines(file, all);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Records what the failing run saw, WITHOUT changing the accepted baseline.
|
|
148
|
+
// `kryptheon accept` promotes this later, on the user's say-so.
|
|
149
|
+
function writePending(file, key, entry) {
|
|
150
|
+
const all = readBaselines(file);
|
|
151
|
+
const existing = all[key];
|
|
152
|
+
if (!existing || typeof existing !== 'object') return false;
|
|
153
|
+
existing.pending = normalisedEntry(entry);
|
|
154
|
+
existing.pending.seenAt = new Date().toISOString();
|
|
155
|
+
return saveBaselines(file, all);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Promotes a pending observation into the accepted baseline for one test only.
|
|
159
|
+
function acceptBaseline(file, key) {
|
|
160
|
+
const all = readBaselines(file);
|
|
161
|
+
const entry = all[key];
|
|
162
|
+
if (!entry || typeof entry !== 'object') {
|
|
163
|
+
return { ok: false, reason: 'no-entry' };
|
|
164
|
+
}
|
|
165
|
+
if (!entry.pending || typeof entry.pending !== 'object') {
|
|
166
|
+
return { ok: false, reason: 'nothing-pending' };
|
|
167
|
+
}
|
|
168
|
+
const promoted = normalisedEntry(entry.pending);
|
|
169
|
+
promoted.recordedAt = new Date().toISOString();
|
|
170
|
+
all[key] = promoted;
|
|
171
|
+
return saveBaselines(file, all) ? { ok: true, entry: promoted } : { ok: false, reason: 'write-failed' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Query strings and trailing slashes are noise for this comparison.
|
|
175
|
+
function normaliseUrl(value) {
|
|
176
|
+
let s = String(value == null ? '' : value);
|
|
177
|
+
const hash = s.indexOf('#');
|
|
178
|
+
if (hash !== -1) s = s.slice(0, hash);
|
|
179
|
+
const query = s.indexOf('?');
|
|
180
|
+
if (query !== -1) s = s.slice(0, query);
|
|
181
|
+
if (s.length > 1) s = s.replace(/\/+$/, '');
|
|
182
|
+
return s || '/';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normaliseTitle(value) {
|
|
186
|
+
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The address, the title, and what the page ended up showing decide pass or
|
|
190
|
+
// fail. Browser noise changing is not on its own a regression.
|
|
191
|
+
function compareBaselines(previous, current) {
|
|
192
|
+
const urlChanged = normaliseUrl(previous.url) !== normaliseUrl(current.url);
|
|
193
|
+
const titleChanged = normaliseTitle(previous.title) !== normaliseTitle(current.title);
|
|
194
|
+
if (!urlChanged && !titleChanged) {
|
|
195
|
+
// Nothing moved in the address bar. This is the case the address and title
|
|
196
|
+
// alone cannot see: a single-page app with a catch-all route lands on the
|
|
197
|
+
// same URL under the same title whether the flow worked or not.
|
|
198
|
+
return signature.compareSignatures(previous.signature, current.signature);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const what =
|
|
202
|
+
urlChanged && titleChanged ? 'the page address and title changed'
|
|
203
|
+
: urlChanged ? 'the page address changed'
|
|
204
|
+
: 'the page title changed';
|
|
205
|
+
|
|
206
|
+
const lines = ['Baseline changed: ' + what + ' since the last passing run.'];
|
|
207
|
+
if (urlChanged) {
|
|
208
|
+
lines.push('Address was: ' + previous.url);
|
|
209
|
+
lines.push('Address now: ' + current.url);
|
|
210
|
+
}
|
|
211
|
+
if (titleChanged) {
|
|
212
|
+
lines.push('Title was: "' + normaliseTitle(previous.title) + '"');
|
|
213
|
+
lines.push('Title now: "' + normaliseTitle(current.title) + '"');
|
|
214
|
+
}
|
|
215
|
+
return lines.join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Drops anything the last passing run already produced. What is left is the
|
|
219
|
+
// only browser noise that could be evidence of this regression.
|
|
220
|
+
function newObservations(current, previous) {
|
|
221
|
+
const knownErrors = Object.create(null);
|
|
222
|
+
for (const text of (previous && previous.consoleErrors) || []) knownErrors[oneLine(text)] = true;
|
|
223
|
+
|
|
224
|
+
const knownRequests = Object.create(null);
|
|
225
|
+
for (const req of (previous && previous.failedRequests) || []) knownRequests[requestKey(req)] = true;
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
consoleErrors: ((current && current.consoleErrors) || []).filter(function (text) {
|
|
229
|
+
return !knownErrors[oneLine(text)];
|
|
230
|
+
}),
|
|
231
|
+
failedRequests: ((current && current.failedRequests) || []).filter(function (req) {
|
|
232
|
+
return !knownRequests[requestKey(req)];
|
|
233
|
+
}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Adds a signature to an accepted baseline that predates the feature, without
|
|
238
|
+
// touching anything else about it - including when it was recorded, which is
|
|
239
|
+
// what the failure report means by "this last worked at".
|
|
240
|
+
// Ties a saved result written before fingerprints existed to the recording it
|
|
241
|
+
// is now being compared against. Only ever on a pass, so an entry is never
|
|
242
|
+
// bound to a run that did not match it.
|
|
243
|
+
function stampRecording(file, key, recordingId) {
|
|
244
|
+
const all = readBaselines(file);
|
|
245
|
+
const entry = all[key];
|
|
246
|
+
if (!entry || typeof entry !== 'object' || entry.recordingId || !recordingId) return false;
|
|
247
|
+
entry.recordingId = recordingId;
|
|
248
|
+
return saveBaselines(file, all);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function upgradeBaseline(file, key, sig) {
|
|
252
|
+
const all = readBaselines(file);
|
|
253
|
+
const entry = all[key];
|
|
254
|
+
if (!entry || typeof entry !== 'object' || entry.signature || !sig) return false;
|
|
255
|
+
entry.signature = sig;
|
|
256
|
+
return saveBaselines(file, all);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The whole decision in one call: create on first sight, otherwise compare.
|
|
260
|
+
function applyBaseline(file, key, current) {
|
|
261
|
+
const previous = readBaselines(file)[key];
|
|
262
|
+
if (!previous || typeof previous !== 'object') {
|
|
263
|
+
writeBaseline(file, key, current);
|
|
264
|
+
return { status: 'created', message: null };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A saved result taken from a different recording describes a page this
|
|
268
|
+
// flow never visited. Comparing against it produces a failure on the first
|
|
269
|
+
// run that no amount of fixing the app can clear, so it is replaced rather
|
|
270
|
+
// than argued with. Only when both fingerprints are known: an entry from
|
|
271
|
+
// before they existed is left alone, because "cannot tell" must never
|
|
272
|
+
// become "accept whatever this run saw".
|
|
273
|
+
if (previous.recordingId && current.recordingId && previous.recordingId !== current.recordingId) {
|
|
274
|
+
writeBaseline(file, key, current);
|
|
275
|
+
return { status: 'created', message: null };
|
|
276
|
+
}
|
|
277
|
+
const message = compareBaselines(previous, current);
|
|
278
|
+
if (message) return { status: 'changed', message: message };
|
|
279
|
+
|
|
280
|
+
// A baseline from before signatures existed passes on address and title, and
|
|
281
|
+
// learns its signature here. Doing it on a pass rather than up front means an
|
|
282
|
+
// upgraded project never fails on its first run under the new version.
|
|
283
|
+
if (!previous.signature && current.signature) {
|
|
284
|
+
upgradeBaseline(file, key, current.signature);
|
|
285
|
+
return { status: 'learned', message: null };
|
|
286
|
+
}
|
|
287
|
+
if (!previous.recordingId && current.recordingId) {
|
|
288
|
+
stampRecording(file, key, current.recordingId);
|
|
289
|
+
return { status: 'learned', message: null };
|
|
290
|
+
}
|
|
291
|
+
return { status: 'match', message: null };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
const test = base.test.extend({
|
|
297
|
+
page: async ({ page }, use, testInfo) => {
|
|
298
|
+
const consoleErrors = [];
|
|
299
|
+
const failedRequests = [];
|
|
300
|
+
|
|
301
|
+
page.on('console', (msg) => {
|
|
302
|
+
if (msg.type() === 'error') consoleErrors.push(oneLine(msg.text()));
|
|
303
|
+
});
|
|
304
|
+
// Uncaught exceptions never reach page.on('console') in every browser.
|
|
305
|
+
page.on('pageerror', (err) => {
|
|
306
|
+
consoleErrors.push(oneLine((err && err.message) || String(err)));
|
|
307
|
+
});
|
|
308
|
+
page.on('response', (res) => {
|
|
309
|
+
const status = res.status();
|
|
310
|
+
if (status >= 400) {
|
|
311
|
+
failedRequests.push({ method: res.request().method(), url: res.url(), status: status });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const key = baselineKey(testInfo.file, testInfo.title);
|
|
316
|
+
|
|
317
|
+
// The steps this recording actually performs. Comments, blank lines and
|
|
318
|
+
// the test name are ignored, so tidying a file does not read as a
|
|
319
|
+
// different recording - only changing what it does.
|
|
320
|
+
const recordingId = (function () {
|
|
321
|
+
try {
|
|
322
|
+
return replay.recordingId(fs.readFileSync(testInfo.file, 'utf8'));
|
|
323
|
+
} catch (e) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
})();
|
|
327
|
+
|
|
328
|
+
// Reports only what this run added on top of the last passing run.
|
|
329
|
+
const attachObservations = async () => {
|
|
330
|
+
let url = null;
|
|
331
|
+
try {
|
|
332
|
+
url = page.url();
|
|
333
|
+
} catch (e) {
|
|
334
|
+
url = null; // page may already be closed
|
|
335
|
+
}
|
|
336
|
+
const previous = readBaselines(BASELINE_FILE)[key];
|
|
337
|
+
const fresh = newObservations({ consoleErrors, failedRequests }, previous);
|
|
338
|
+
try {
|
|
339
|
+
await testInfo.attach('kryptheon-observations', {
|
|
340
|
+
body: JSON.stringify({
|
|
341
|
+
url: url,
|
|
342
|
+
// What was actually on screen when it gave up. On a flow that
|
|
343
|
+
// stopped half way this is the most useful line in the report.
|
|
344
|
+
pageShowed: signature.describeSignature(captured),
|
|
345
|
+
failedRequests: fresh.failedRequests.slice(0, MAX_ITEMS),
|
|
346
|
+
consoleErrors: fresh.consoleErrors.slice(0, MAX_ITEMS),
|
|
347
|
+
}),
|
|
348
|
+
contentType: 'application/json',
|
|
349
|
+
});
|
|
350
|
+
} catch (e) {
|
|
351
|
+
// Never let diagnostics turn into a second failure.
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
await use(page);
|
|
356
|
+
|
|
357
|
+
// Read the page before anything else looks at the outcome. A test that
|
|
358
|
+
// failed is exactly the one where knowing what was on screen matters most,
|
|
359
|
+
// so this runs whether it passed or not. It is null when the page could
|
|
360
|
+
// not be read at all, and a null is never treated as a difference - not
|
|
361
|
+
// knowing what the page showed is not evidence that it changed.
|
|
362
|
+
//
|
|
363
|
+
// Printed only when asked for, and handed to the CLI through a file:
|
|
364
|
+
// Playwright pipes this worker's descriptors and gives whatever comes out
|
|
365
|
+
// to reporter stdio hooks that this reporter does not implement, so
|
|
366
|
+
// anything printed from here is dropped before it reaches a terminal.
|
|
367
|
+
const captured = await signature.collectSignature(page, failedRequests);
|
|
368
|
+
if (signature.signatureEnabled()) signature.recordSignature(testInfo.title, captured);
|
|
369
|
+
|
|
370
|
+
// Said once per run by the reporter, not once per test: it is a fact about
|
|
371
|
+
// the app, and repeating it for every recording would be noise.
|
|
372
|
+
if (captured && captured.guessedEncoding) {
|
|
373
|
+
try {
|
|
374
|
+
await testInfo.attach('kryptheon-encoding', { body: 'guessed', contentType: 'text/plain' });
|
|
375
|
+
} catch (e) {
|
|
376
|
+
/* a note, never a second failure */
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// The test's own assertions decide first. A test that already failed keeps
|
|
381
|
+
// its own error, and never contributes a baseline.
|
|
382
|
+
if (testInfo.status !== testInfo.expectedStatus) {
|
|
383
|
+
await attachObservations();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Passed: keep what the page ended up as, noise included.
|
|
388
|
+
let current = null;
|
|
389
|
+
try {
|
|
390
|
+
current = {
|
|
391
|
+
url: requestPath(page.url()),
|
|
392
|
+
title: normaliseTitle(await page.title()),
|
|
393
|
+
consoleErrors: consoleErrors,
|
|
394
|
+
failedRequests: failedRequests,
|
|
395
|
+
signature: captured,
|
|
396
|
+
recordingId: recordingId,
|
|
397
|
+
};
|
|
398
|
+
} catch (e) {
|
|
399
|
+
current = null; // page closed by the test - leave any baseline untouched
|
|
400
|
+
}
|
|
401
|
+
if (!current) return;
|
|
402
|
+
|
|
403
|
+
const outcome = applyBaseline(BASELINE_FILE, key, current);
|
|
404
|
+
if (outcome.status === 'changed') {
|
|
405
|
+
// Remember what this run saw so `kryptheon accept` can promote it, but
|
|
406
|
+
// leave the accepted baseline exactly as it was.
|
|
407
|
+
writePending(BASELINE_FILE, key, current);
|
|
408
|
+
await attachObservations();
|
|
409
|
+
throw new Error(outcome.message);
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
module.exports = {
|
|
415
|
+
test: test,
|
|
416
|
+
expect: base.expect,
|
|
417
|
+
// Exported for the CLI and the unit checks.
|
|
418
|
+
baselineKey: baselineKey,
|
|
419
|
+
readBaselines: readBaselines,
|
|
420
|
+
writeBaseline: writeBaseline,
|
|
421
|
+
writePending: writePending,
|
|
422
|
+
acceptBaseline: acceptBaseline,
|
|
423
|
+
newObservations: newObservations,
|
|
424
|
+
requestKey: requestKey,
|
|
425
|
+
normaliseUrl: normaliseUrl,
|
|
426
|
+
normaliseTitle: normaliseTitle,
|
|
427
|
+
compareBaselines: compareBaselines,
|
|
428
|
+
applyBaseline: applyBaseline,
|
|
429
|
+
upgradeBaseline: upgradeBaseline,
|
|
430
|
+
stampRecording: stampRecording,
|
|
431
|
+
BASELINE_FILE: BASELINE_FILE,
|
|
432
|
+
};
|