kryptheon 0.1.11 → 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.
@@ -1,432 +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');
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
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
- };
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
+ };