kryptheon 0.1.0

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,324 @@
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
+
24
+ const MAX_ITEMS = 5; // keep the failure block readable
25
+
26
+ // Baselines describe the user's app, so they live in the folder the command
27
+ // was run from - never inside the installed package.
28
+ const USER_DIR = process.cwd();
29
+ const BASELINE_FILE = path.join(USER_DIR, 'kryptheon-baselines.json');
30
+
31
+ function oneLine(text) {
32
+ return String(text).replace(/\s+/g, ' ').trim();
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Baselines. These helpers take the file path explicitly so they can be
37
+ // exercised against a scratch file in the unit checks.
38
+ // ---------------------------------------------------------------------------
39
+
40
+ // One entry per test, identified by where it lives plus what it is called.
41
+ function baselineKey(specFile, title) {
42
+ let rel = String(specFile || '');
43
+ try {
44
+ rel = path.relative(USER_DIR, specFile);
45
+ } catch (e) {
46
+ /* keep what we were given */
47
+ }
48
+ return rel.split(path.sep).join('/') + ' :: ' + String(title || '');
49
+ }
50
+
51
+ // A missing or unreadable file, malformed JSON, or anything that is not a
52
+ // plain object all mean the same thing: no baselines yet.
53
+ function readBaselines(file) {
54
+ let raw;
55
+ try {
56
+ raw = fs.readFileSync(file, 'utf8');
57
+ } catch (e) {
58
+ return {};
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ } catch (e) {
64
+ return {};
65
+ }
66
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
67
+ return parsed;
68
+ }
69
+
70
+ function saveBaselines(file, all) {
71
+ try {
72
+ fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n', 'utf8');
73
+ return true;
74
+ } catch (e) {
75
+ return false; // diagnostics must never become a second failure
76
+ }
77
+ }
78
+
79
+ // Identity of a failed request, independent of host and of run order.
80
+ function requestKey(req) {
81
+ if (!req) return '';
82
+ const where = req.path != null ? req.path : requestPath(req.url || '');
83
+ return (req.method || 'GET') + ' ' + where + ' ' + req.status;
84
+ }
85
+
86
+ function toStoredRequests(list) {
87
+ const seen = Object.create(null);
88
+ const out = [];
89
+ for (const req of list || []) {
90
+ const key = requestKey(req);
91
+ if (seen[key]) continue;
92
+ seen[key] = true;
93
+ out.push({
94
+ method: req.method || 'GET',
95
+ path: req.path != null ? req.path : requestPath(req.url || ''),
96
+ status: req.status,
97
+ });
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function toStoredErrors(list) {
103
+ const seen = Object.create(null);
104
+ const out = [];
105
+ for (const text of list || []) {
106
+ const line = oneLine(text);
107
+ if (!line || seen[line]) continue;
108
+ seen[line] = true;
109
+ out.push(line);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ function normalisedEntry(current) {
115
+ return {
116
+ url: current.url,
117
+ title: current.title,
118
+ consoleErrors: toStoredErrors(current.consoleErrors),
119
+ failedRequests: toStoredRequests(current.failedRequests),
120
+ };
121
+ }
122
+
123
+ function writeBaseline(file, key, entry) {
124
+ const all = readBaselines(file);
125
+ const stored = normalisedEntry(entry);
126
+ stored.recordedAt = new Date().toISOString();
127
+ all[key] = stored;
128
+ return saveBaselines(file, all);
129
+ }
130
+
131
+ // Records what the failing run saw, WITHOUT changing the accepted baseline.
132
+ // `kryptheon accept` promotes this later, on the user's say-so.
133
+ function writePending(file, key, entry) {
134
+ const all = readBaselines(file);
135
+ const existing = all[key];
136
+ if (!existing || typeof existing !== 'object') return false;
137
+ existing.pending = normalisedEntry(entry);
138
+ existing.pending.seenAt = new Date().toISOString();
139
+ return saveBaselines(file, all);
140
+ }
141
+
142
+ // Promotes a pending observation into the accepted baseline for one test only.
143
+ function acceptBaseline(file, key) {
144
+ const all = readBaselines(file);
145
+ const entry = all[key];
146
+ if (!entry || typeof entry !== 'object') {
147
+ return { ok: false, reason: 'no-entry' };
148
+ }
149
+ if (!entry.pending || typeof entry.pending !== 'object') {
150
+ return { ok: false, reason: 'nothing-pending' };
151
+ }
152
+ const promoted = normalisedEntry(entry.pending);
153
+ promoted.recordedAt = new Date().toISOString();
154
+ all[key] = promoted;
155
+ return saveBaselines(file, all) ? { ok: true, entry: promoted } : { ok: false, reason: 'write-failed' };
156
+ }
157
+
158
+ // Query strings and trailing slashes are noise for this comparison.
159
+ function normaliseUrl(value) {
160
+ let s = String(value == null ? '' : value);
161
+ const hash = s.indexOf('#');
162
+ if (hash !== -1) s = s.slice(0, hash);
163
+ const query = s.indexOf('?');
164
+ if (query !== -1) s = s.slice(0, query);
165
+ if (s.length > 1) s = s.replace(/\/+$/, '');
166
+ return s || '/';
167
+ }
168
+
169
+ function normaliseTitle(value) {
170
+ return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
171
+ }
172
+
173
+ // Only the address and title decide pass or fail. Browser noise changing is
174
+ // not on its own a regression.
175
+ function compareBaselines(previous, current) {
176
+ const urlChanged = normaliseUrl(previous.url) !== normaliseUrl(current.url);
177
+ const titleChanged = normaliseTitle(previous.title) !== normaliseTitle(current.title);
178
+ if (!urlChanged && !titleChanged) return null;
179
+
180
+ const what =
181
+ urlChanged && titleChanged ? 'the page address and title changed'
182
+ : urlChanged ? 'the page address changed'
183
+ : 'the page title changed';
184
+
185
+ const lines = ['Baseline changed: ' + what + ' since the last passing run.'];
186
+ if (urlChanged) {
187
+ lines.push('Address was: ' + previous.url);
188
+ lines.push('Address now: ' + current.url);
189
+ }
190
+ if (titleChanged) {
191
+ lines.push('Title was: "' + normaliseTitle(previous.title) + '"');
192
+ lines.push('Title now: "' + normaliseTitle(current.title) + '"');
193
+ }
194
+ return lines.join('\n');
195
+ }
196
+
197
+ // Drops anything the last passing run already produced. What is left is the
198
+ // only browser noise that could be evidence of this regression.
199
+ function newObservations(current, previous) {
200
+ const knownErrors = Object.create(null);
201
+ for (const text of (previous && previous.consoleErrors) || []) knownErrors[oneLine(text)] = true;
202
+
203
+ const knownRequests = Object.create(null);
204
+ for (const req of (previous && previous.failedRequests) || []) knownRequests[requestKey(req)] = true;
205
+
206
+ return {
207
+ consoleErrors: ((current && current.consoleErrors) || []).filter(function (text) {
208
+ return !knownErrors[oneLine(text)];
209
+ }),
210
+ failedRequests: ((current && current.failedRequests) || []).filter(function (req) {
211
+ return !knownRequests[requestKey(req)];
212
+ }),
213
+ };
214
+ }
215
+
216
+ // The whole decision in one call: create on first sight, otherwise compare.
217
+ function applyBaseline(file, key, current) {
218
+ const previous = readBaselines(file)[key];
219
+ if (!previous || typeof previous !== 'object') {
220
+ writeBaseline(file, key, current);
221
+ return { status: 'created', message: null };
222
+ }
223
+ const message = compareBaselines(previous, current);
224
+ return message ? { status: 'changed', message: message } : { status: 'match', message: null };
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+
229
+ const test = base.test.extend({
230
+ page: async ({ page }, use, testInfo) => {
231
+ const consoleErrors = [];
232
+ const failedRequests = [];
233
+
234
+ page.on('console', (msg) => {
235
+ if (msg.type() === 'error') consoleErrors.push(oneLine(msg.text()));
236
+ });
237
+ // Uncaught exceptions never reach page.on('console') in every browser.
238
+ page.on('pageerror', (err) => {
239
+ consoleErrors.push(oneLine((err && err.message) || String(err)));
240
+ });
241
+ page.on('response', (res) => {
242
+ const status = res.status();
243
+ if (status >= 400) {
244
+ failedRequests.push({ method: res.request().method(), url: res.url(), status: status });
245
+ }
246
+ });
247
+
248
+ const key = baselineKey(testInfo.file, testInfo.title);
249
+
250
+ // Reports only what this run added on top of the last passing run.
251
+ const attachObservations = async () => {
252
+ let url = null;
253
+ try {
254
+ url = page.url();
255
+ } catch (e) {
256
+ url = null; // page may already be closed
257
+ }
258
+ const previous = readBaselines(BASELINE_FILE)[key];
259
+ const fresh = newObservations({ consoleErrors, failedRequests }, previous);
260
+ try {
261
+ await testInfo.attach('kryptheon-observations', {
262
+ body: JSON.stringify({
263
+ url: url,
264
+ failedRequests: fresh.failedRequests.slice(0, MAX_ITEMS),
265
+ consoleErrors: fresh.consoleErrors.slice(0, MAX_ITEMS),
266
+ }),
267
+ contentType: 'application/json',
268
+ });
269
+ } catch (e) {
270
+ // Never let diagnostics turn into a second failure.
271
+ }
272
+ };
273
+
274
+ await use(page);
275
+
276
+ // The test's own assertions decide first. A test that already failed keeps
277
+ // its own error, and never contributes a baseline.
278
+ if (testInfo.status !== testInfo.expectedStatus) {
279
+ await attachObservations();
280
+ return;
281
+ }
282
+
283
+ // Passed: capture what the page ended up as, noise included.
284
+ let current = null;
285
+ try {
286
+ current = {
287
+ url: requestPath(page.url()),
288
+ title: normaliseTitle(await page.title()),
289
+ consoleErrors: consoleErrors,
290
+ failedRequests: failedRequests,
291
+ };
292
+ } catch (e) {
293
+ current = null; // page closed by the test - leave any baseline untouched
294
+ }
295
+ if (!current) return;
296
+
297
+ const outcome = applyBaseline(BASELINE_FILE, key, current);
298
+ if (outcome.status === 'changed') {
299
+ // Remember what this run saw so `kryptheon accept` can promote it, but
300
+ // leave the accepted baseline exactly as it was.
301
+ writePending(BASELINE_FILE, key, current);
302
+ await attachObservations();
303
+ throw new Error(outcome.message);
304
+ }
305
+ },
306
+ });
307
+
308
+ module.exports = {
309
+ test: test,
310
+ expect: base.expect,
311
+ // Exported for the CLI and the unit checks.
312
+ baselineKey: baselineKey,
313
+ readBaselines: readBaselines,
314
+ writeBaseline: writeBaseline,
315
+ writePending: writePending,
316
+ acceptBaseline: acceptBaseline,
317
+ newObservations: newObservations,
318
+ requestKey: requestKey,
319
+ normaliseUrl: normaliseUrl,
320
+ normaliseTitle: normaliseTitle,
321
+ compareBaselines: compareBaselines,
322
+ applyBaseline: applyBaseline,
323
+ BASELINE_FILE: BASELINE_FILE,
324
+ };