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,678 @@
1
+ // Plain-language Playwright reporter for non-technical readers.
2
+ // Prints a short line per passing test and a readable block per failure,
3
+ // and appends one JSON line per run to kryptheon-history.jsonl.
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ // The history belongs to whoever is running the tests, so it lives in their
9
+ // folder - not inside the installed package.
10
+ const USER_DIR = process.cwd();
11
+ const HISTORY_FILE = path.join(USER_DIR, 'kryptheon-history.jsonl');
12
+
13
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec'];
14
+
15
+ function formatWhen(iso) {
16
+ const d = new Date(iso);
17
+ if (isNaN(d.getTime())) return null;
18
+ let hours = d.getHours();
19
+ const suffix = hours >= 12 ? 'PM' : 'AM';
20
+ hours = hours % 12 || 12;
21
+ const minutes = String(d.getMinutes()).padStart(2, '0');
22
+ return d.getDate() + ' ' + MONTHS[d.getMonth()] + ' at ' + hours + ':' + minutes + ' ' + suffix;
23
+ }
24
+
25
+ // Playwright colours its error messages with ANSI escapes; strip them before
26
+ // any pattern matching or they break every match and force the raw fallback.
27
+ // Built via RegExp so the source carries no literal escape character.
28
+ const ANSI_PATTERN = new RegExp('\\u001b\\[[0-9;?]*[ -/]*[@-~]', 'g');
29
+
30
+ function stripAnsi(value) {
31
+ return String(value == null ? '' : value).replace(ANSI_PATTERN, '');
32
+ }
33
+
34
+ function formatDuration(ms) {
35
+ if (typeof ms !== 'number' || isNaN(ms)) return 'unknown';
36
+ if (ms < 1000) return ms + 'ms';
37
+ return (ms / 1000).toFixed(1) + 's';
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Turning a Playwright locator string into something a human can read.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const ROLE_NOUNS = {
45
+ button: 'button',
46
+ textbox: 'text box',
47
+ link: 'link',
48
+ heading: 'heading',
49
+ checkbox: 'checkbox',
50
+ radio: 'radio button',
51
+ combobox: 'dropdown',
52
+ option: 'option',
53
+ img: 'image',
54
+ dialog: 'dialog',
55
+ };
56
+
57
+ // Returns a lower-case noun phrase such as: the button "Request my demo".
58
+ // Sentence-initial uses go through capitalise() below.
59
+ function describeLocator(locator) {
60
+ if (!locator) return null;
61
+ const raw = String(locator).trim();
62
+ let m;
63
+
64
+ m = raw.match(/getByRole\(\s*['"]([^'"]+)['"]\s*,\s*\{[^}]*name:\s*['"]([^'"]*)['"]/);
65
+ if (m) return 'the ' + (ROLE_NOUNS[m[1]] || m[1]) + ' "' + m[2] + '"';
66
+
67
+ m = raw.match(/getByRole\(\s*['"]([^'"]+)['"]\s*\)/);
68
+ if (m) return 'the ' + (ROLE_NOUNS[m[1]] || m[1]);
69
+
70
+ m = raw.match(/getByText\(\s*['"]([^'"]*)['"]/);
71
+ if (m) return 'the text "' + m[1] + '"';
72
+
73
+ m = raw.match(/getByLabel\(\s*['"]([^'"]*)['"]/);
74
+ if (m) return 'the field labelled "' + m[1] + '"';
75
+
76
+ m = raw.match(/getByPlaceholder\(\s*['"]([^'"]*)['"]/);
77
+ if (m) return 'the field with placeholder "' + m[1] + '"';
78
+
79
+ m = raw.match(/getByTestId\(\s*['"]([^'"]*)['"]/);
80
+ if (m) return 'the element with test id "' + m[1] + '"';
81
+
82
+ m = raw.match(/getByTitle\(\s*['"]([^'"]*)['"]/);
83
+ if (m) return 'the element titled "' + m[1] + '"';
84
+
85
+ // Fall back to a name: value anywhere in the selector before the raw text.
86
+ m = raw.match(/name:\s*['"]([^'"]*)['"]/);
87
+ if (m) return 'the element "' + m[1] + '"';
88
+
89
+ return 'the element `' + raw + '`';
90
+ }
91
+
92
+ function capitalise(text) {
93
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
94
+ }
95
+
96
+ // Turns what Playwright printed - a regex literal like /\/dashboard\.html/ or a
97
+ // quoted absolute URL - into the readable path a person recognises.
98
+ function tidyUrl(raw) {
99
+ let s = String(raw == null ? '' : raw).trim();
100
+ s = s.replace(/^["']+|["']+$/g, '');
101
+
102
+ const asRegex = s.match(/^\/(.*)\/[gimsuy]*$/);
103
+ if (asRegex) s = asRegex[1].replace(/\\(.)/g, '$1');
104
+
105
+ try {
106
+ const u = new URL(s);
107
+ return (u.pathname || '/') + (u.search || '');
108
+ } catch (e) {
109
+ return s;
110
+ }
111
+ }
112
+
113
+ // Path and query only - never the host. Output gets shared and pasted around,
114
+ // and hostnames leak project identifiers.
115
+ function requestPath(requestUrl) {
116
+ try {
117
+ const u = new URL(requestUrl);
118
+ return (u.pathname || '/') + (u.search || '');
119
+ } catch (e) {
120
+ // Not a parseable absolute URL: strip any scheme://host prefix by hand.
121
+ const stripped = String(requestUrl).replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*/, '');
122
+ return stripped || '/';
123
+ }
124
+ }
125
+
126
+ // True when the Playwright call log shows we were waiting for an element that
127
+ // never turned up - the signal that distinguishes "missing" from "slow".
128
+ function waitingForLocator(message) {
129
+ return /^\s*-\s*waiting for\s+.+$/m.test(String(message));
130
+ }
131
+
132
+ function isTimeoutMessage(message) {
133
+ return /Timeout\s+\d+ms exceeded|[Tt]imeout of \d+ms exceeded|TimeoutError/.test(String(message));
134
+ }
135
+
136
+ // Pull the locator out of an error message, whichever form it took.
137
+ function extractLocator(message) {
138
+ let m = message.match(/^\s*Locator:\s*(.+)$/m);
139
+ if (m) return m[1].trim();
140
+ m = message.match(/^\s*-\s*waiting for (.+)$/m);
141
+ if (m) return m[1].trim();
142
+ return null;
143
+ }
144
+
145
+ // True when the technical line would only repeat the headline in other words.
146
+ // Keeping it then just makes the block longer without adding anything.
147
+ function addsNothing(technical, reason) {
148
+ const strip = function (s) {
149
+ return String(s == null ? '' : s)
150
+ .toLowerCase()
151
+ .replace(/^baseline changed:\s*/, '')
152
+ .replace(/[^a-z0-9]+/g, ' ')
153
+ .trim();
154
+ };
155
+ const a = strip(technical);
156
+ const b = strip(reason);
157
+ if (!a) return true;
158
+ return a === b || b.indexOf(a) !== -1 || a.indexOf(b) !== -1;
159
+ }
160
+
161
+ function firstMeaningfulLine(message) {
162
+ const line = String(message).split('\n').find(function (l) {
163
+ return l.trim().length > 0;
164
+ });
165
+ return line ? line.replace(/^(TimeoutError|Error):\s*/, '').trim() : 'The test failed.';
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Translation: Playwright error -> { reason, advice }
170
+ // ---------------------------------------------------------------------------
171
+
172
+ function translate(message) {
173
+ const msg = String(message || '');
174
+ const locatorRaw = extractLocator(msg);
175
+ const subject = describeLocator(locatorRaw);
176
+ let m;
177
+
178
+ // A baseline mismatch raised by the fixture. The message is already written
179
+ // for a person; lift it out and keep the Was/Now lines as detail.
180
+ if (/Baseline changed:/.test(msg)) {
181
+ const lines = msg.split('\n').map(function (l) {
182
+ return l.trim();
183
+ });
184
+ const headIndex = lines.findIndex(function (l) {
185
+ return l.indexOf('Baseline changed:') !== -1;
186
+ });
187
+ const head = lines[headIndex].replace(/^.*Baseline changed:\s*/, '');
188
+ const details = lines.slice(headIndex + 1).filter(function (l) {
189
+ return /^(Address|Title) (was|now):/.test(l);
190
+ });
191
+ return {
192
+ reason: capitalise(head),
193
+ details: details,
194
+ // The throw happens inside the fixture, so a source location would just
195
+ // point at plumbing the reader did not write.
196
+ hideSource: true,
197
+ // %TEST% is filled in with the test's name when the block is printed,
198
+ // so the command resets only this one test.
199
+ advice: 'if this change is intended, run: kryptheon accept "%TEST%"',
200
+ };
201
+ }
202
+
203
+ // Navigation failures, e.g. "page.goto: net::ERR_NAME_NOT_RESOLVED at <url>"
204
+ m = msg.match(/(?:page\.goto|page\.reload|page\.goBack)[^\n]*?(net::[A-Z_]+)(?:\s+at\s+(\S+))?/);
205
+ if (m) {
206
+ const url = m[2] ? ' (' + m[2] + ')' : '';
207
+ const kind = m[1];
208
+ let why = 'the address could not be reached';
209
+ if (kind === 'net::ERR_NAME_NOT_RESOLVED') why = 'that web address could not be found';
210
+ else if (kind === 'net::ERR_CONNECTION_REFUSED') why = 'the server refused the connection';
211
+ else if (kind === 'net::ERR_INTERNET_DISCONNECTED') why = 'there was no internet connection';
212
+ else if (kind === 'net::ERR_CONNECTION_TIMED_OUT') why = 'the server did not respond in time';
213
+ return {
214
+ reason: 'The page could not be opened' + url + ' - ' + why + '.',
215
+ advice: 'check the site is online, that the address in the test is correct, and that you are connected to the internet.',
216
+ };
217
+ }
218
+ if (/page\.goto[\s\S]*Timeout \d+ms exceeded/.test(msg)) {
219
+ return {
220
+ reason: 'The page took too long to load and the test gave up waiting.',
221
+ advice: 'the site may be slow or down; try opening it in a browser yourself.',
222
+ };
223
+ }
224
+
225
+ // expect(...).not.toBeVisible() - the element was still there. This must be
226
+ // checked before the call-log rules below, because its call log also carries
227
+ // a "waiting for" line even though the element was found.
228
+ if (/expect\([^)]*\)\.not\.toBeVisible\(\)\s*failed/.test(msg)) {
229
+ return {
230
+ reason: capitalise(subject || 'the element') + ' was still on the page, but the test expected it to be gone by now.',
231
+ advice: 'the page may not have moved on to the next step - for example a form that did not submit.',
232
+ };
233
+ }
234
+
235
+ // expect(...).toBeVisible() - either absent entirely, or present but hidden.
236
+ if (/expect\([^)]*\)\.toBeVisible\(\)\s*failed/.test(msg)) {
237
+ if (/element\(s\) not found/.test(msg)) {
238
+ return {
239
+ reason: 'Could not find ' + (subject || 'the element') + ' on the page.',
240
+ advice: 'your last change may have renamed, hidden, or removed it.',
241
+ };
242
+ }
243
+ return {
244
+ reason: capitalise(subject || 'the element') + ' is on the page but never became visible.',
245
+ advice: 'it may be hidden behind a popup, still loading, or scrolled out of view.',
246
+ };
247
+ }
248
+
249
+ // A URL mismatch is not "the content changed" - the browser simply ended up
250
+ // somewhere else. Say where it was expected and where it actually was.
251
+ // Playwright labels these two ways: "Expected pattern:"/"Received string:"
252
+ // for a regex, and "Expected:"/"Received:" for a plain string.
253
+ if (/expect\([^)]*\)(?:\.not)?\.toHaveURL\(/.test(msg)) {
254
+ const expectedRaw = (msg.match(/^\s*Expected(?: pattern)?:\s*(.+)$/m) || [])[1];
255
+ const actualRaw = (msg.match(/^\s*Received(?: string)?:\s*(.+)$/m) || [])[1];
256
+ const details = [];
257
+ if (expectedRaw) details.push('Expected: ' + tidyUrl(expectedRaw));
258
+ if (actualRaw) details.push('Actually on: ' + tidyUrl(actualRaw));
259
+ return {
260
+ reason: 'The page did not go where it was supposed to.',
261
+ details: details,
262
+ urlExpected: expectedRaw ? tidyUrl(expectedRaw) : null,
263
+ urlActual: actualRaw ? tidyUrl(actualRaw) : null,
264
+ advice: 'the step before this may not have worked - for example a sign-in that was rejected, or a redirect elsewhere.',
265
+ };
266
+ }
267
+
268
+ // Value comparisons: toHaveTitle / toHaveText / toHaveValue / toHaveCount.
269
+ m = msg.match(/expect\([^)]*\)(?:\.not)?\.(toHave\w+|toContainText)\([^)]*\)\s*failed/);
270
+ if (m) {
271
+ const expected = (msg.match(/^\s*Expected:\s*(.+)$/m) || [])[1];
272
+ const received = (msg.match(/^\s*Received:\s*(.+)$/m) || [])[1];
273
+ const matcher = m[1];
274
+ const what =
275
+ matcher === 'toHaveTitle' ? 'the page title'
276
+ : matcher === 'toHaveURL' ? 'the page address'
277
+ : matcher === 'toHaveCount' ? 'the number of matching items'
278
+ : subject ? subject.charAt(0).toLowerCase() + subject.slice(1)
279
+ : 'the element';
280
+ if (expected && received) {
281
+ return {
282
+ reason: 'The test expected ' + what + ' to be ' + expected.trim() + ', but found ' + received.trim() + '.',
283
+ advice: 'the wording on the page may have changed, or the page shown was not the one expected.',
284
+ };
285
+ }
286
+ return {
287
+ reason: 'A check on ' + what + ' did not match what the test expected.',
288
+ advice: 'the content on the page may have changed since this test was written.',
289
+ };
290
+ }
291
+
292
+ // Timeouts. A timeout on its own says nothing about the cause: the deciding
293
+ // signal is whether the call log shows we were still waiting for an element.
294
+ // If it does, the element was never there - that is a missing element, not a
295
+ // slow site. Only a timeout with no such line is genuine slowness.
296
+ if (isTimeoutMessage(msg)) {
297
+ if (waitingForLocator(msg)) {
298
+ return {
299
+ reason: 'Could not find ' + (subject || 'the element') + ' on the page.',
300
+ advice: 'your last change may have renamed, hidden, or removed it.',
301
+ };
302
+ }
303
+
304
+ m = msg.match(/(?:[Tt]imeout of (\d+)ms exceeded|Timeout (\d+)ms exceeded)/);
305
+ const ms = m ? Number(m[1] || m[2]) : null;
306
+ const howLong = ms ? ' after ' + Math.max(1, Math.round(ms / 1000)) + ' seconds' : '';
307
+ return {
308
+ reason: 'The test ran out of time' + howLong + ' and was stopped.',
309
+ advice: 'the site may be slower than usual, or the test is waiting for something that never happens.',
310
+ };
311
+ }
312
+
313
+ // Anything not recognised: fall back to Playwright's own wording.
314
+ return { reason: firstMeaningfulLine(msg), advice: null, raw: true };
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // "Where to look" - only things actually observed in the browser during the
319
+ // test. Never a source file or line number, which would be a guess.
320
+ // ---------------------------------------------------------------------------
321
+
322
+ // Plain-language meaning of an HTTP status. The raw code always stays visible
323
+ // next to it; this only adds the explanation.
324
+ function describeStatus(status) {
325
+ const code = Number(status);
326
+ if (code === 400 || code === 401) {
327
+ return 'the request was rejected - usually wrong credentials, a missing login session, or invalid data being sent';
328
+ }
329
+ if (code === 403) return 'the server refused permission for this action';
330
+ if (code === 404) {
331
+ return 'this address does not exist on the server - the route may have been renamed or removed';
332
+ }
333
+ if (code === 429) return 'too many requests too quickly - the server is rate limiting';
334
+ if (code === 500 || code === 502 || code === 503) {
335
+ return 'the server crashed or is unavailable - the error is in backend code, not the page';
336
+ }
337
+ if (code >= 500) return 'the server could not complete the request';
338
+ if (code >= 400) return 'the server rejected the request';
339
+ return null;
340
+ }
341
+
342
+ // How long ago the test last passed, in words.
343
+ function timeAgo(iso, now) {
344
+ const then = new Date(iso).getTime();
345
+ if (isNaN(then)) return null;
346
+ const minutes = Math.round(((now || Date.now()) - then) / 60000);
347
+ if (minutes < 1) return 'less than a minute ago';
348
+ if (minutes === 1) return '1 minute ago';
349
+ if (minutes < 60) return minutes + ' minutes ago';
350
+ const hours = Math.floor(minutes / 60);
351
+ if (hours === 1) return '1 hour ago';
352
+ if (hours < 24) return hours + ' hours ago';
353
+ const days = Math.floor(hours / 24);
354
+ return days === 1 ? '1 day ago' : days + ' days ago';
355
+ }
356
+
357
+ function wrapText(text, width) {
358
+ const words = String(text).split(/\s+/).filter(Boolean);
359
+ const lines = [];
360
+ let line = '';
361
+ for (const word of words) {
362
+ if (!line.length) line = word;
363
+ else if ((line + ' ' + word).length <= width) line += ' ' + word;
364
+ else {
365
+ lines.push(line);
366
+ line = word;
367
+ }
368
+ }
369
+ if (line) lines.push(line);
370
+ return lines;
371
+ }
372
+
373
+ // The prompt uses the same host-free formatting as "Where to look".
374
+
375
+ function readObservations(attachments) {
376
+ const found = (attachments || []).find(function (a) {
377
+ return a.name === 'kryptheon-observations' && a.body;
378
+ });
379
+ if (!found) return null;
380
+ try {
381
+ return JSON.parse(found.body.toString('utf8'));
382
+ } catch (e) {
383
+ return null;
384
+ }
385
+ }
386
+
387
+ function uniq(list) {
388
+ const seen = Object.create(null);
389
+ const out = [];
390
+ for (const item of list || []) {
391
+ const key = typeof item === 'string' ? item : JSON.stringify(item);
392
+ if (!seen[key]) {
393
+ seen[key] = true;
394
+ out.push(item);
395
+ }
396
+ }
397
+ return out;
398
+ }
399
+
400
+ function whereToLookLines(obs) {
401
+ if (!obs) return [];
402
+ const lines = [];
403
+
404
+ if (obs.url) lines.push(' - Browser was on: ' + obs.url);
405
+
406
+ for (const req of uniq(obs.failedRequests).slice(0, 5)) {
407
+ const meaning = describeStatus(req.status);
408
+ lines.push(
409
+ ' - ' + (req.method || 'GET') + ' ' + requestPath(req.url) + ' returned ' + req.status +
410
+ (meaning ? '\n ' + meaning : '')
411
+ );
412
+ }
413
+
414
+ for (const err of uniq(obs.consoleErrors).slice(0, 5)) {
415
+ const text = String(err).replace(/\s+/g, ' ').trim();
416
+ lines.push(' - Console error: ' + (text.length > 160 ? text.slice(0, 157) + '...' : text));
417
+ }
418
+
419
+ // Nothing observed at all - the section is omitted entirely.
420
+ if (!lines.length) return [];
421
+ return [' Where to look:'].concat(lines);
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // A prompt the user can paste into their AI coding tool. Built strictly from
426
+ // what this run observed: no causes, no theories, no file paths.
427
+ // ---------------------------------------------------------------------------
428
+
429
+ // Reasons arrive in mixed shapes (some end in a full stop, the raw fallback
430
+ // may not even start capitalised), so normalise each into a real sentence.
431
+ function asSentence(text) {
432
+ let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
433
+ if (!s) return '';
434
+ s = s.charAt(0).toUpperCase() + s.slice(1);
435
+ if (!/[.!?]$/.test(s)) s += '.';
436
+ return s;
437
+ }
438
+
439
+ function buildPromptLines(testTitle, translated, obs, lastPassIso, now) {
440
+ const sentences = [];
441
+ sentences.push('My "' + testTitle + '" flow broke.');
442
+
443
+ // What failed.
444
+ if (translated.urlActual && translated.urlExpected) {
445
+ sentences.push(
446
+ 'The page stayed on ' + translated.urlActual + ' instead of reaching ' + translated.urlExpected + '.'
447
+ );
448
+ } else {
449
+ sentences.push(translated.reason);
450
+ if (obs && obs.url) sentences.push('The browser was on ' + obs.url + '.');
451
+ }
452
+
453
+ // What the browser saw.
454
+ if (obs) {
455
+ for (const req of uniq(obs.failedRequests).slice(0, 3)) {
456
+ sentences.push(
457
+ 'The request ' + (req.method || 'GET') + ' ' + requestPath(req.url) + ' returned ' + req.status + '.'
458
+ );
459
+ }
460
+ for (const err of uniq(obs.consoleErrors).slice(0, 2)) {
461
+ const text = String(err).replace(/\s+/g, ' ').trim();
462
+ sentences.push('The browser console reported: "' + (text.length > 140 ? text.slice(0, 137) + '...' : text) + '".');
463
+ }
464
+ }
465
+
466
+ // When it last worked.
467
+ const ago = lastPassIso ? timeAgo(lastPassIso, now) : null;
468
+ if (ago) sentences.push('This was working ' + ago + '.');
469
+
470
+ sentences.push('Fix only this.');
471
+
472
+ const body = wrapText(sentences.map(asSentence).filter(Boolean).join(' '), 72);
473
+ return [' Paste this into your AI tool:', ' ' + '─'.repeat(29)].concat(
474
+ body.map(function (l) {
475
+ return ' ' + l;
476
+ })
477
+ );
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Reporter
482
+ // ---------------------------------------------------------------------------
483
+
484
+ class KryptheonReporter {
485
+ constructor() {
486
+ this.previousRuns = [];
487
+ this.records = [];
488
+ this.startedAt = new Date();
489
+ }
490
+
491
+ onBegin() {
492
+ this.startedAt = new Date();
493
+ // Read history before this run is appended, so "was working on" looks
494
+ // only at genuinely earlier runs.
495
+ this.previousRuns = this._readHistory();
496
+ process.stdout.write('\nKryptheon test run - ' + formatWhen(this.startedAt.toISOString()) + '\n\n');
497
+ }
498
+
499
+ _readHistory() {
500
+ try {
501
+ if (!fs.existsSync(HISTORY_FILE)) return [];
502
+ return fs
503
+ .readFileSync(HISTORY_FILE, 'utf8')
504
+ .split('\n')
505
+ .filter(function (l) {
506
+ return l.trim().length > 0;
507
+ })
508
+ .map(function (line) {
509
+ try {
510
+ return JSON.parse(line);
511
+ } catch (e) {
512
+ return null; // skip malformed lines rather than crashing the run
513
+ }
514
+ })
515
+ .filter(Boolean);
516
+ } catch (e) {
517
+ return [];
518
+ }
519
+ }
520
+
521
+ // Most recent earlier run in which this same test title passed.
522
+ _lastPassed(title) {
523
+ for (let i = this.previousRuns.length - 1; i >= 0; i--) {
524
+ const run = this.previousRuns[i];
525
+ const tests = (run && run.tests) || [];
526
+ for (let j = tests.length - 1; j >= 0; j--) {
527
+ if (tests[j] && tests[j].title === title && tests[j].status === 'passed') {
528
+ return tests[j].timestamp || run.runAt;
529
+ }
530
+ }
531
+ }
532
+ return null;
533
+ }
534
+
535
+ onTestEnd(test, result) {
536
+ const status =
537
+ result.status === 'passed' ? 'passed' : result.status === 'skipped' ? 'skipped' : 'failed';
538
+ const timestamp = (result.startTime instanceof Date ? result.startTime : new Date()).toISOString();
539
+
540
+ const record = {
541
+ title: test.title,
542
+ status: status,
543
+ durationMs: result.duration,
544
+ timestamp: timestamp,
545
+ };
546
+
547
+ if (status === 'skipped') {
548
+ this.records.push(record);
549
+ process.stdout.write('-- ' + test.title + ' (skipped)\n');
550
+ return;
551
+ }
552
+
553
+ if (status === 'passed') {
554
+ this.records.push(record);
555
+ process.stdout.write('OK ' + test.title + ' (' + formatDuration(result.duration) + ')\n');
556
+ return;
557
+ }
558
+
559
+ // On a test-level timeout Playwright reports two errors: a bare
560
+ // "Test timeout of Nms exceeded." with no call log, and a second one
561
+ // carrying the call log that says what we were waiting for. Reading only
562
+ // the first throws away the diagnosis, so combine them and prefer the
563
+ // error that actually carries detail.
564
+ const rawErrors = (result.errors && result.errors.length ? result.errors : [result.error]).filter(Boolean);
565
+ const errors = rawErrors.map(function (e) {
566
+ return {
567
+ message: stripAnsi(e.message || e.value || ''),
568
+ location: e.location,
569
+ };
570
+ });
571
+ const detailed = errors.filter(function (e) {
572
+ return /Call log:|Locator:/.test(e.message);
573
+ });
574
+ const primary = detailed[0] || errors[0] || { message: 'The test failed.' };
575
+ const message = errors
576
+ .map(function (e) {
577
+ return e.message;
578
+ })
579
+ .filter(Boolean)
580
+ .join('\n\n') || 'The test failed.';
581
+
582
+ const shot = (result.attachments || []).find(function (a) {
583
+ return a.name === 'screenshot' && a.path;
584
+ });
585
+ const observations = readObservations(result.attachments);
586
+ const located = primary.location || (errors.find(function (e) { return e.location; }) || {}).location;
587
+ const line = located ? located.line : null;
588
+ const file = located ? located.file : null;
589
+ const locator = extractLocator(message);
590
+ const translated = translate(message);
591
+
592
+ record.failure = {
593
+ line: line,
594
+ file: file ? path.relative(USER_DIR, file) : null,
595
+ locator: locator,
596
+ assertion: (function () {
597
+ const m = message.match(/expect\([^)]*\)(\.not)?\.(\w+)\(/);
598
+ if (!m) return null;
599
+ return (m[1] ? 'not.' : '') + m[2];
600
+ })(),
601
+ screenshot: shot ? shot.path : null,
602
+ plainLanguage: translated.reason,
603
+ rawMessage: message,
604
+ observations: observations,
605
+ };
606
+ this.records.push(record);
607
+
608
+ const out = [];
609
+ out.push('');
610
+ out.push('X ' + test.title);
611
+ out.push(' ' + translated.reason);
612
+
613
+ for (const detail of translated.details || []) out.push(' ' + detail);
614
+
615
+ const lastPass = this._lastPassed(test.title);
616
+ out.push(lastPass ? ' This was working on ' + formatWhen(lastPass) + '.' : ' This has not passed before.');
617
+
618
+ if (translated.advice) {
619
+ out.push(' What to check: ' + translated.advice.split('%TEST%').join(test.title));
620
+ }
621
+ else out.push(' What to check: this is the raw message from the test tool - it was not recognised.');
622
+
623
+ for (const observed of whereToLookLines(observations)) out.push(observed);
624
+
625
+ if (shot) out.push(' Screenshot: ' + shot.path);
626
+ if (file && line && !translated.hideSource) {
627
+ out.push(' Source: ' + path.relative(USER_DIR, file) + ' line ' + line);
628
+ }
629
+ const technical = firstMeaningfulLine(primary.message);
630
+ if (!translated.raw && !addsNothing(technical, translated.reason)) {
631
+ out.push(' Technical detail: ' + technical);
632
+ }
633
+
634
+ out.push('');
635
+ for (const promptLine of buildPromptLines(test.title, translated, observations, lastPass)) {
636
+ out.push(promptLine);
637
+ }
638
+ out.push('');
639
+
640
+ process.stdout.write(out.join('\n') + '\n');
641
+ }
642
+
643
+ onEnd(result) {
644
+ const passed = this.records.filter(function (r) {
645
+ return r.status === 'passed';
646
+ }).length;
647
+ const failed = this.records.filter(function (r) {
648
+ return r.status === 'failed';
649
+ }).length;
650
+
651
+ const entry = {
652
+ runAt: this.startedAt.toISOString(),
653
+ status: result && result.status ? result.status : 'unknown',
654
+ durationMs: Date.now() - this.startedAt.getTime(),
655
+ passed: passed,
656
+ failed: failed,
657
+ tests: this.records,
658
+ };
659
+
660
+ // Append only - existing lines are never rewritten.
661
+ try {
662
+ fs.appendFileSync(HISTORY_FILE, JSON.stringify(entry) + '\n', 'utf8');
663
+ } catch (err) {
664
+ process.stdout.write('\n(could not write ' + path.basename(HISTORY_FILE) + ': ' + err.message + ')\n');
665
+ }
666
+
667
+ process.stdout.write(
668
+ '\nSummary: ' + passed + ' working, ' + failed + ' broken.\n' +
669
+ 'History saved to ' + path.basename(HISTORY_FILE) + '\n\n'
670
+ );
671
+ }
672
+ }
673
+
674
+ module.exports = KryptheonReporter;
675
+
676
+ // Shared with the fixture so baselines strip hosts exactly the same way.
677
+ // Attached to the exported class, so `new (require(...))()` keeps working.
678
+ module.exports.requestPath = requestPath;