kryptheon 0.1.28 → 0.1.30

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,1048 +1,1080 @@
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 the project's history.jsonl.
4
-
5
- const fs = require('fs');
6
- const os = require('os');
7
- const path = require('path');
8
- const recordings = require('./kryptheon-recordings.js');
9
- const heal = require('./kryptheon-heal.js');
10
- const store = require('./kryptheon-store.js');
11
-
12
- // An assistant runs this after every change, so it needs a mode that says
13
- // nothing at all when the news is good.
14
- const QUIET = !!process.env.KRYPTHEON_QUIET;
15
-
16
- // The history belongs to whoever is running the tests, so it is kept in their
17
- // project's store - not inside the installed package, and not in the project.
18
- const USER_DIR = process.cwd();
19
- const HISTORY_FILE = store.pathsFor(USER_DIR).history;
20
-
21
- // Said with ~ for the home folder: the full path is long, and the person only
22
- // needs to know it is outside their project and where to look.
23
- function shortHome(file) {
24
- const home = os.homedir();
25
- return file.toLowerCase().startsWith(home.toLowerCase() + path.sep) ? '~' + file.slice(home.length) : file;
26
- }
27
-
28
- const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec'];
29
-
30
- function formatWhen(iso) {
31
- const d = new Date(iso);
32
- if (isNaN(d.getTime())) return null;
33
- let hours = d.getHours();
34
- const suffix = hours >= 12 ? 'PM' : 'AM';
35
- hours = hours % 12 || 12;
36
- const minutes = String(d.getMinutes()).padStart(2, '0');
37
- return d.getDate() + ' ' + MONTHS[d.getMonth()] + ' at ' + hours + ':' + minutes + ' ' + suffix;
38
- }
39
-
40
- // Playwright colours its error messages with ANSI escapes; strip them before
41
- // any pattern matching or they break every match and force the raw fallback.
42
- // Built via RegExp so the source carries no literal escape character.
43
- const ANSI_PATTERN = new RegExp('\\u001b\\[[0-9;?]*[ -/]*[@-~]', 'g');
44
-
45
- function stripAnsi(value) {
46
- return String(value == null ? '' : value).replace(ANSI_PATTERN, '');
47
- }
48
-
49
- function formatDuration(ms) {
50
- if (typeof ms !== 'number' || isNaN(ms)) return 'unknown';
51
- if (ms < 1000) return ms + 'ms';
52
- return (ms / 1000).toFixed(1) + 's';
53
- }
54
-
55
- // ---------------------------------------------------------------------------
56
- // Turning a Playwright locator string into something a human can read.
57
- // ---------------------------------------------------------------------------
58
-
59
- const ROLE_NOUNS = {
60
- button: 'button',
61
- textbox: 'text box',
62
- link: 'link',
63
- heading: 'heading',
64
- checkbox: 'checkbox',
65
- radio: 'radio button',
66
- combobox: 'dropdown',
67
- option: 'option',
68
- img: 'image',
69
- dialog: 'dialog',
70
- // What the recorder's "assert text" button picks when the text is in the
71
- // body of the page: "a check on the main" meant nothing to anyone.
72
- main: 'main part of the page',
73
- navigation: 'menu',
74
- banner: 'top of the page',
75
- contentinfo: 'footer',
76
- status: 'status message',
77
- };
78
-
79
- // Returns a lower-case noun phrase such as: the button "Request my demo".
80
- // Sentence-initial uses go through capitalise() below.
81
- function describeLocator(locator) {
82
- if (!locator) return null;
83
- const raw = String(locator).trim();
84
- let m;
85
-
86
- m = raw.match(/getByRole\(\s*['"]([^'"]+)['"]\s*,\s*\{[^}]*name:\s*['"]([^'"]*)['"]/);
87
- if (m) return 'the ' + (ROLE_NOUNS[m[1]] || m[1]) + ' "' + m[2] + '"';
88
-
89
- // With no name, or with options that are not a name - { level: 1 } is
90
- // what a check on a page's main heading gets.
91
- m = raw.match(/getByRole\(\s*['"]([^'"]+)['"]\s*(?:,\s*\{[^}]*\}\s*)?\)/);
92
- if (m) return 'the ' + (ROLE_NOUNS[m[1]] || m[1]);
93
-
94
- m = raw.match(/getByText\(\s*['"]([^'"]*)['"]/);
95
- if (m) return 'the text "' + m[1] + '"';
96
-
97
- m = raw.match(/getByLabel\(\s*['"]([^'"]*)['"]/);
98
- if (m) return 'the field labelled "' + m[1] + '"';
99
-
100
- m = raw.match(/getByPlaceholder\(\s*['"]([^'"]*)['"]/);
101
- if (m) return 'the field with placeholder "' + m[1] + '"';
102
-
103
- m = raw.match(/getByTestId\(\s*['"]([^'"]*)['"]/);
104
- if (m) return 'the element with test id "' + m[1] + '"';
105
-
106
- m = raw.match(/getByTitle\(\s*['"]([^'"]*)['"]/);
107
- if (m) return 'the element titled "' + m[1] + '"';
108
-
109
- // Fall back to a name: value anywhere in the selector before the raw text.
110
- m = raw.match(/name:\s*['"]([^'"]*)['"]/);
111
- if (m) return 'the element "' + m[1] + '"';
112
-
113
- return 'the element `' + raw + '`';
114
- }
115
-
116
- function capitalise(text) {
117
- return text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
118
- }
119
-
120
- // Turns what Playwright printed - a regex literal like /\/dashboard\.html/ or a
121
- // quoted absolute URL - into the readable path a person recognises.
122
- function tidyUrl(raw) {
123
- let s = String(raw == null ? '' : raw).trim();
124
- s = s.replace(/^["']+|["']+$/g, '');
125
-
126
- const asRegex = s.match(/^\/(.*)\/[gimsuy]*$/);
127
- if (asRegex) s = asRegex[1].replace(/\\(.)/g, '$1');
128
-
129
- try {
130
- const u = new URL(s);
131
- return (u.pathname || '/') + (u.search || '');
132
- } catch (e) {
133
- return s;
134
- }
135
- }
136
-
137
- // Path and query only - never the host. Output gets shared and pasted around,
138
- // and hostnames leak project identifiers.
139
- function requestPath(requestUrl) {
140
- try {
141
- const u = new URL(requestUrl);
142
- return (u.pathname || '/') + (u.search || '');
143
- } catch (e) {
144
- // Not a parseable absolute URL: strip any scheme://host prefix by hand.
145
- const stripped = String(requestUrl).replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*/, '');
146
- return stripped || '/';
147
- }
148
- }
149
-
150
- // True when the Playwright call log shows we were waiting for an element that
151
- // never turned up - the signal that distinguishes "missing" from "slow".
152
- function waitingForLocator(message) {
153
- return /^\s*-\s*waiting for\s+.+$/m.test(String(message));
154
- }
155
-
156
- function isTimeoutMessage(message) {
157
- return /Timeout\s+\d+ms exceeded|[Tt]imeout of \d+ms exceeded|TimeoutError/.test(String(message));
158
- }
159
-
160
- // Pull the locator out of an error message, whichever form it took.
161
- function extractLocator(message) {
162
- let m = message.match(/^\s*Locator:\s*(.+)$/m);
163
- if (m) return m[1].trim();
164
- m = message.match(/^\s*-\s*waiting for (.+)$/m);
165
- if (m) return m[1].trim();
166
- return null;
167
- }
168
-
169
- // True when the technical line would only repeat the headline in other words.
170
- // Keeping it then just makes the block longer without adding anything.
171
- function addsNothing(technical, reason) {
172
- const strip = function (s) {
173
- return String(s == null ? '' : s)
174
- .toLowerCase()
175
- .replace(/^baseline changed:\s*/, '')
176
- .replace(/[^a-z0-9]+/g, ' ')
177
- .trim();
178
- };
179
- const a = strip(technical);
180
- const b = strip(reason);
181
- if (!a) return true;
182
- return a === b || b.indexOf(a) !== -1 || a.indexOf(b) !== -1;
183
- }
184
-
185
- function firstMeaningfulLine(message) {
186
- const line = String(message).split('\n').find(function (l) {
187
- return l.trim().length > 0;
188
- });
189
- return line ? line.replace(/^(TimeoutError|Error):\s*/, '').trim() : 'The test failed.';
190
- }
191
-
192
- // ---------------------------------------------------------------------------
193
- // Errors that belong to no test.
194
- //
195
- // A spec file with a syntax error, or one importing something that is not
196
- // there, never becomes a test at all - so nothing calls onTestEnd and the run
197
- // ends with no results. Playwright reports it through onError. A reporter that
198
- // does not implement onError prints a summary of nothing and exits 1, and the
199
- // person is left with a silent failure and no idea which file is at fault.
200
- // ---------------------------------------------------------------------------
201
-
202
- // Playwright says this once the load errors have already been reported. On its
203
- // own it means an empty tests folder; after a real error it is a consequence,
204
- // not a cause, and repeating it just buries the thing that has to be fixed.
205
- function isNoTestsFound(error) {
206
- return /No tests found/i.test(String((error && error.message) || ''));
207
- }
208
-
209
- /**
210
- * A load error, reduced to the file, the line and one sentence.
211
- *
212
- * The message carries a code frame in ANSI colour and repeats the absolute
213
- * path inside its own first line. Neither helps here: the path is printed
214
- * separately and relative, and the frame belongs in an editor.
215
- */
216
- function readLoadError(error, userDir) {
217
- const raw = stripAnsi((error && error.message) || (error && error.value) || String(error || ""));
218
- const location = (error && error.location) || {};
219
-
220
- let file = location.file || null;
221
- if (file) {
222
- try {
223
- file = path.relative(userDir || process.cwd(), file);
224
- } catch (e) {
225
- /* an absolute path is still better than nothing */
226
- }
227
- }
228
-
229
- // First line only, with the absolute path taken back out of it.
230
- let message = raw.split("\n").map(function (l) { return l.trim(); }).filter(Boolean)[0] || "The file could not be read.";
231
- if (location.file) message = message.split(location.file + ": ").join("");
232
- message = message.replace(/\s*\(\d+:\d+\)\s*$/, "");
233
-
234
- return {
235
- file: file,
236
- line: typeof location.line === "number" ? location.line : null,
237
- message: message,
238
- };
239
- }
240
-
241
- /**
242
- * What to print when nothing could be loaded.
243
- *
244
- * Said as "this file", not "the tests failed", because nothing was tested. The
245
- * app may be perfectly fine; a file in the tests folder is not.
246
- */
247
- function loadErrorLines(errors, userDir) {
248
- const real = (errors || []).filter(function (e) { return !isNoTestsFound(e); });
249
- const lines = [];
250
-
251
- if (!real.length) {
252
- // Nothing ran and nothing explained why. Saying so is still better than a
253
- // summary of zero, which reads as "everything is fine".
254
- lines.push("Nothing ran.");
255
- lines.push("");
256
- lines.push("No recording produced a result, and the test runner gave no reason.");
257
- lines.push("Check that the files in tests/ contain a test.");
258
- lines.push("");
259
- return lines;
260
- }
261
-
262
- const many = real.length > 1;
263
- lines.push(many ? real.length + " of your recordings could not be read:" : "One of your recordings could not be read:");
264
- lines.push("");
265
- for (const error of real) {
266
- const read = readLoadError(error, userDir);
267
- const where = read.file ? read.file + (read.line ? " line " + read.line : "") : "(the file was not named)";
268
- lines.push(" " + where);
269
- lines.push(" " + read.message);
270
- lines.push("");
271
- }
272
- lines.push(many
273
- ? "Nothing else ran, because those files could not be loaded at all."
274
- : "Nothing else ran, because that file could not be loaded at all.");
275
- lines.push("Fix it and run this again.");
276
- lines.push("");
277
- return lines;
278
- }
279
-
280
- // ---------------------------------------------------------------------------
281
- // Translation: Playwright error -> { reason, advice }
282
- // ---------------------------------------------------------------------------
283
-
284
- function translate(message) {
285
- const msg = String(message || '');
286
- const locatorRaw = extractLocator(msg);
287
- const subject = describeLocator(locatorRaw);
288
- let m;
289
-
290
- // A baseline mismatch raised by the fixture. The message is already written
291
- // for a person; lift it out and keep the Was/Now lines as detail.
292
- if (/Baseline changed:/.test(msg)) {
293
- const lines = msg.split('\n').map(function (l) {
294
- return l.trim();
295
- });
296
- const headIndex = lines.findIndex(function (l) {
297
- return l.indexOf('Baseline changed:') !== -1;
298
- });
299
- const head = lines[headIndex].replace(/^.*Baseline changed:\s*/, '');
300
- // An allowlist, so a stack trace that happens to follow the message can
301
- // never leak into the report. Address and title come from the original
302
- // baseline; the rest name what the page stopped or started showing.
303
- const details = lines.slice(headIndex + 1).filter(function (l) {
304
- return /^(Address|Title) (was|now):/.test(l) || /^(Headings|Buttons and links|Form fields) that are (gone|new):/.test(l) || /^Requests that failed this time:/.test(l);
305
- });
306
- return {
307
- reason: capitalise(head),
308
- details: details,
309
- // The throw happens inside the fixture, so a source location would just
310
- // point at plumbing the reader did not write.
311
- hideSource: true,
312
- // %TEST% is filled in with the test's name when the block is printed,
313
- // so the command resets only this one test.
314
- advice: 'if this change is intended, run: npx kryptheon accept "%TEST%"',
315
- };
316
- }
317
-
318
- // Navigation failures, e.g. "page.goto: net::ERR_NAME_NOT_RESOLVED at <url>"
319
- m = msg.match(/(?:page\.goto|page\.reload|page\.goBack)[^\n]*?(net::[A-Z_]+)(?:\s+at\s+(\S+))?/);
320
- if (m) {
321
- const url = m[2] ? ' (' + m[2] + ')' : '';
322
- const kind = m[1];
323
- let why = 'the address could not be reached';
324
- if (kind === 'net::ERR_NAME_NOT_RESOLVED') why = 'that web address could not be found';
325
- else if (kind === 'net::ERR_CONNECTION_REFUSED') why = 'the server refused the connection';
326
- else if (kind === 'net::ERR_INTERNET_DISCONNECTED') why = 'there was no internet connection';
327
- else if (kind === 'net::ERR_CONNECTION_TIMED_OUT') why = 'the server did not respond in time';
328
- return {
329
- reason: 'The page could not be opened' + url + ' - ' + why + '.',
330
- advice: 'check the site is online, that the address in the test is correct, and that you are connected to the internet.',
331
- };
332
- }
333
- if (/page\.goto[\s\S]*Timeout \d+ms exceeded/.test(msg)) {
334
- return {
335
- reason: 'The page took too long to load and the test gave up waiting.',
336
- advice: 'the site may be slow or down; try opening it in a browser yourself.',
337
- };
338
- }
339
-
340
- // expect(...).not.toBeVisible() - the element was still there. This must be
341
- // checked before the call-log rules below, because its call log also carries
342
- // a "waiting for" line even though the element was found.
343
- if (/expect\([^)]*\)\.not\.toBeVisible\(\)\s*failed/.test(msg)) {
344
- return {
345
- reason: capitalise(subject || 'the element') + ' was still on the page, but the test expected it to be gone by now.',
346
- advice: 'the page may not have moved on to the next step - for example a form that did not submit.',
347
- };
348
- }
349
-
350
- // expect(...).toBeVisible() - either absent entirely, or present but hidden.
351
- if (/expect\([^)]*\)\.toBeVisible\(\)\s*failed/.test(msg)) {
352
- if (/element\(s\) not found/.test(msg)) {
353
- return {
354
- reason: 'Could not find ' + (subject || 'the element') + ' on the page.',
355
- advice: 'your last change may have renamed, hidden, or removed it.',
356
- };
357
- }
358
- return {
359
- reason: capitalise(subject || 'the element') + ' is on the page but never became visible.',
360
- advice: 'it may be hidden behind a popup, still loading, or scrolled out of view.',
361
- };
362
- }
363
-
364
- // A URL mismatch is not "the content changed" - the browser simply ended up
365
- // somewhere else. Say where it was expected and where it actually was.
366
- // Playwright labels these two ways: "Expected pattern:"/"Received string:"
367
- // for a regex, and "Expected:"/"Received:" for a plain string.
368
- if (/expect\([^)]*\)(?:\.not)?\.toHaveURL\(/.test(msg)) {
369
- const expectedRaw = (msg.match(/^\s*Expected(?: pattern)?:\s*(.+)$/m) || [])[1];
370
- const actualRaw = (msg.match(/^\s*Received(?: string)?:\s*(.+)$/m) || [])[1];
371
- const details = [];
372
- if (expectedRaw) details.push('Expected: ' + tidyUrl(expectedRaw));
373
- if (actualRaw) details.push('Actually on: ' + tidyUrl(actualRaw));
374
- return {
375
- reason: 'The page did not go where it was supposed to.',
376
- details: details,
377
- urlExpected: expectedRaw ? tidyUrl(expectedRaw) : null,
378
- urlActual: actualRaw ? tidyUrl(actualRaw) : null,
379
- advice: 'the step before this may not have worked - for example a sign-in that was rejected, or a redirect elsewhere.',
380
- };
381
- }
382
-
383
- // What the recorder's snapshot button writes. Its diff is a page of YAML,
384
- // which says nothing to someone who has never seen one.
385
- if (/expect\([^)]*\)(?:\.not)?\.toMatchAriaSnapshot\(/.test(msg)) {
386
- return {
387
- reason: capitalise(subject || 'part of the page') + ' is laid out differently than when this check was recorded.',
388
- advice: 'something in it was added, removed or renamed - the screenshot shows how it looks now.',
389
- };
390
- }
391
-
392
- // Value comparisons: toHaveTitle / toHaveText / toHaveValue / toHaveCount.
393
- m = msg.match(/expect\([^)]*\)(\.not)?\.(toHave\w+|toContainText)\([^)]*\)\s*failed/);
394
- if (m) {
395
- // Playwright names the lines after what it compared: "Expected substring"
396
- // for toContainText, "Expected string" for toHaveText and toHaveValue,
397
- // "Expected pattern" for a regex. Reading only "Expected:" missed every
398
- // check the recorder's own buttons write.
399
- const expected = (msg.match(/^\s*Expected(?: substring| string| pattern)?:\s*(.+)$/m) || [])[1];
400
- const received = (msg.match(/^\s*Received(?: string)?:\s*(.+)$/m) || [])[1];
401
- const negated = Boolean(m[1]);
402
- const matcher = m[2];
403
- const what =
404
- matcher === 'toHaveTitle' ? 'the page title'
405
- : matcher === 'toHaveURL' ? 'the page address'
406
- : matcher === 'toHaveCount' ? 'the number of matching items'
407
- : subject ? subject.charAt(0).toLowerCase() + subject.slice(1)
408
- : 'the element';
409
- // A whole page of text is not an answer anyone can read. Say what was
410
- // looked for, and show what was there only when it is short enough to be
411
- // the thing someone meant.
412
- const shown = received && received.trim().length <= 82 ? received.trim() : null;
413
- if (expected && matcher === 'toContainText') {
414
- return {
415
- reason: negated
416
- ? 'The test expected ' + what + ' not to say ' + expected.trim() + ', but it does.'
417
- : 'The test expected ' + what + ' to say ' + expected.trim() + ', but ' +
418
- (shown ? 'it says ' + shown + '.' : 'that text is not there.'),
419
- advice: 'the wording on the page may have changed, or the step before it did not do what it should.',
420
- };
421
- }
422
- if (expected && received) {
423
- return {
424
- reason: negated
425
- ? 'The test expected ' + what + ' not to be ' + expected.trim() + ', but it is.'
426
- : 'The test expected ' + what + ' to be ' + expected.trim() + ', but found ' + (shown || 'something else') + '.',
427
- advice: 'the wording on the page may have changed, or the page shown was not the one expected.',
428
- };
429
- }
430
- return {
431
- reason: 'A check on ' + what + ' did not match what the test expected.',
432
- advice: 'the content on the page may have changed since this test was written.',
433
- };
434
- }
435
-
436
- // Timeouts. A timeout on its own says nothing about the cause: the deciding
437
- // signal is whether the call log shows we were still waiting for an element.
438
- // If it does, the element was never there - that is a missing element, not a
439
- // slow site. Only a timeout with no such line is genuine slowness.
440
- if (isTimeoutMessage(msg)) {
441
- if (waitingForLocator(msg)) {
442
- return {
443
- reason: 'Could not find ' + (subject || 'the element') + ' on the page.',
444
- advice: 'your last change may have renamed, hidden, or removed it.',
445
- };
446
- }
447
-
448
- m = msg.match(/(?:[Tt]imeout of (\d+)ms exceeded|Timeout (\d+)ms exceeded)/);
449
- const ms = m ? Number(m[1] || m[2]) : null;
450
- const howLong = ms ? ' after ' + Math.max(1, Math.round(ms / 1000)) + ' seconds' : '';
451
- return {
452
- reason: 'The test ran out of time' + howLong + ' and was stopped.',
453
- advice: 'the site may be slower than usual, or the test is waiting for something that never happens.',
454
- };
455
- }
456
-
457
- // Anything not recognised: fall back to Playwright's own wording.
458
- return { reason: firstMeaningfulLine(msg), advice: null, raw: true };
459
- }
460
-
461
- // ---------------------------------------------------------------------------
462
- // "Where to look" - only things actually observed in the browser during the
463
- // test. Never a source file or line number, which would be a guess.
464
- // ---------------------------------------------------------------------------
465
-
466
- // Plain-language meaning of an HTTP status. The raw code always stays visible
467
- // next to it; this only adds the explanation.
468
- function describeStatus(status) {
469
- const code = Number(status);
470
- if (code === 400 || code === 401) {
471
- return 'the request was rejected - usually wrong credentials, a missing login session, or invalid data being sent';
472
- }
473
- if (code === 403) return 'the server refused permission for this action';
474
- if (code === 404) {
475
- return 'this address does not exist on the server - the route may have been renamed or removed';
476
- }
477
- if (code === 429) return 'too many requests too quickly - the server is rate limiting';
478
- if (code === 500 || code === 502 || code === 503) {
479
- return 'the server crashed or is unavailable - the error is in backend code, not the page';
480
- }
481
- if (code >= 500) return 'the server could not complete the request';
482
- if (code >= 400) return 'the server rejected the request';
483
- return null;
484
- }
485
-
486
- // How long ago the test last passed, in words.
487
- function timeAgo(iso, now) {
488
- const then = new Date(iso).getTime();
489
- if (isNaN(then)) return null;
490
- const minutes = Math.round(((now || Date.now()) - then) / 60000);
491
- if (minutes < 1) return 'less than a minute ago';
492
- if (minutes === 1) return '1 minute ago';
493
- if (minutes < 60) return minutes + ' minutes ago';
494
- const hours = Math.floor(minutes / 60);
495
- if (hours === 1) return '1 hour ago';
496
- if (hours < 24) return hours + ' hours ago';
497
- const days = Math.floor(hours / 24);
498
- return days === 1 ? '1 day ago' : days + ' days ago';
499
- }
500
-
501
- function wrapText(text, width) {
502
- const words = String(text).split(/\s+/).filter(Boolean);
503
- const lines = [];
504
- let line = '';
505
- for (const word of words) {
506
- if (!line.length) line = word;
507
- else if ((line + ' ' + word).length <= width) line += ' ' + word;
508
- else {
509
- lines.push(line);
510
- line = word;
511
- }
512
- }
513
- if (line) lines.push(line);
514
- return lines;
515
- }
516
-
517
- // The prompt uses the same host-free formatting as "Where to look".
518
-
519
- function readObservations(attachments) {
520
- const found = (attachments || []).find(function (a) {
521
- return a.name === 'kryptheon-observations' && a.body;
522
- });
523
- if (!found) return null;
524
- try {
525
- return JSON.parse(found.body.toString('utf8'));
526
- } catch (e) {
527
- return null;
528
- }
529
- }
530
-
531
- // Renamed buttons and fields the replay followed (kryptheon-heal.js), each
532
- // already one sentence. Empty when there were none or the note is unreadable.
533
- function readRenamed(attachments) {
534
- const found = (attachments || []).find(function (a) {
535
- return a.name === 'kryptheon-renamed' && a.body;
536
- });
537
- if (!found) return [];
538
- try {
539
- const list = JSON.parse(found.body.toString('utf8'));
540
- return Array.isArray(list) ? list.filter(function (e) { return e && e.was && e.now; }) : [];
541
- } catch (e) {
542
- return [];
543
- }
544
- }
545
-
546
- function uniq(list) {
547
- const seen = Object.create(null);
548
- const out = [];
549
- for (const item of list || []) {
550
- const key = typeof item === 'string' ? item : JSON.stringify(item);
551
- if (!seen[key]) {
552
- seen[key] = true;
553
- out.push(item);
554
- }
555
- }
556
- return out;
557
- }
558
-
559
- function whereToLookLines(obs) {
560
- if (!obs) return [];
561
- const lines = [];
562
-
563
- if (obs.url) lines.push(' - Browser was on: ' + obs.url);
564
- // Where the flow actually got to, in words. The whole signature stays behind
565
- // KRYPTHEON_DEBUG_SIGNATURE for when that much detail is wanted.
566
- if (obs.pageShowed) lines.push(' - Page showed: ' + obs.pageShowed);
567
-
568
- for (const req of uniq(obs.failedRequests).slice(0, 5)) {
569
- const meaning = describeStatus(req.status);
570
- lines.push(
571
- ' - ' + (req.method || 'GET') + ' ' + requestPath(req.url) + ' returned ' + req.status +
572
- (meaning ? '\n ' + meaning : '')
573
- );
574
- }
575
-
576
- for (const err of uniq(obs.consoleErrors).slice(0, 5)) {
577
- const text = String(err).replace(/\s+/g, ' ').trim();
578
- lines.push(' - Console error: ' + (text.length > 160 ? text.slice(0, 157) + '...' : text));
579
- }
580
-
581
- // Nothing observed at all - the section is omitted entirely.
582
- if (!lines.length) return [];
583
- return [' Where to look:'].concat(lines);
584
- }
585
-
586
- // ---------------------------------------------------------------------------
587
- // A prompt the user can paste into their AI coding tool. Built strictly from
588
- // what this run observed: no causes, no theories, no file paths.
589
- // ---------------------------------------------------------------------------
590
-
591
- // Reasons arrive in mixed shapes (some end in a full stop, the raw fallback
592
- // may not even start capitalised), so normalise each into a real sentence.
593
- function asSentence(text) {
594
- let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
595
- if (!s) return '';
596
- s = s.charAt(0).toUpperCase() + s.slice(1);
597
- if (!/[.!?]$/.test(s)) s += '.';
598
- return s;
599
- }
600
-
601
- function buildPromptLines(testTitle, translated, obs, lastPassIso, now) {
602
- const sentences = [];
603
- sentences.push('My "' + testTitle + '" flow broke.');
604
-
605
- // What failed.
606
- if (translated.urlActual && translated.urlExpected) {
607
- sentences.push(
608
- 'The page stayed on ' + translated.urlActual + ' instead of reaching ' + translated.urlExpected + '.'
609
- );
610
- } else {
611
- sentences.push(translated.reason);
612
- if (obs && obs.url) sentences.push('The browser was on ' + obs.url + '.');
613
- }
614
-
615
- // What the browser saw.
616
- if (obs) {
617
- for (const req of uniq(obs.failedRequests).slice(0, 3)) {
618
- sentences.push(
619
- 'The request ' + (req.method || 'GET') + ' ' + requestPath(req.url) + ' returned ' + req.status + '.'
620
- );
621
- }
622
- for (const err of uniq(obs.consoleErrors).slice(0, 2)) {
623
- const text = String(err).replace(/\s+/g, ' ').trim();
624
- sentences.push('The browser console reported: "' + (text.length > 140 ? text.slice(0, 137) + '...' : text) + '".');
625
- }
626
- }
627
-
628
- // When it last worked.
629
- const ago = lastPassIso ? timeAgo(lastPassIso, now) : null;
630
- if (ago) sentences.push('This was working ' + ago + '.');
631
-
632
- sentences.push('Fix only this.');
633
-
634
- const body = wrapText(sentences.map(asSentence).filter(Boolean).join(' '), 72);
635
- return [' Paste this into your AI tool:', ' ' + '─'.repeat(29)].concat(
636
- body.map(function (l) {
637
- return ' ' + l;
638
- })
639
- );
640
- }
641
-
642
- // ---------------------------------------------------------------------------
643
- // The first run is a special case: nothing has regressed, because nothing has
644
- // ever passed. Usually the recording itself contains a step that only worked
645
- // the once.
646
- // ---------------------------------------------------------------------------
647
-
648
- // Playwright works the error's file out of a stack trace, and its parser
649
- // stops at the first "(" - so a folder called "New folder (2)" arrives as
650
- // "2)\tests\thing.spec.js". The path declared for the test itself is read from
651
- // the file system and is always whole, so prefer that whenever the stack's
652
- // version does not point at a real file.
653
- function trustworthySpecPath(test, errorFile) {
654
- const declared = (test && test.location && test.location.file) || null;
655
- if (errorFile && path.isAbsolute(errorFile)) {
656
- try {
657
- if (fs.existsSync(errorFile)) return errorFile;
658
- } catch (err) {
659
- /* fall through to the declared path */
660
- }
661
- }
662
- if (declared) {
663
- try {
664
- if (fs.existsSync(declared)) return declared;
665
- } catch (err) {
666
- /* fall through */
667
- }
668
- }
669
- return errorFile || declared || null;
670
- }
671
-
672
- // Identity of the recording itself, so history cannot be inherited by a
673
- // different recording that happens to share a name.
674
- function recordingIdFor(specPath) {
675
- if (!specPath) return null;
676
- let source;
677
- try {
678
- source = fs.readFileSync(specPath, 'utf8');
679
- } catch (err) {
680
- return null;
681
- }
682
- try {
683
- return require('./kryptheon-replay.js').recordingId(source);
684
- } catch (err) {
685
- return null;
686
- }
687
- }
688
-
689
- function firstRunAdvice(test) {
690
- const lines = [];
691
- lines.push(' This is the first run of this recording, so nothing has broken yet -');
692
- lines.push(' a step that cannot happen twice is the more likely explanation.');
693
-
694
- const file = test && test.location && test.location.file;
695
- if (!file) return { lines: lines, namedAStep: false };
696
-
697
- let source;
698
- try {
699
- source = fs.readFileSync(file, 'utf8');
700
- } catch (err) {
701
- return { lines: lines, namedAStep: false };
702
- }
703
-
704
- let risks = [];
705
- try {
706
- risks = require('./kryptheon-replay.js').findReplayRisks(source);
707
- } catch (err) {
708
- return { lines: lines, namedAStep: false };
709
- }
710
- if (!risks.length) return { lines: lines, namedAStep: false };
711
-
712
- lines.push(' Most likely the step that cannot repeat:');
713
- for (const risk of risks.slice(0, 3)) {
714
- lines.push(' - "' + risk.step + '" (line ' + risk.line + ') - ' + risk.why + '.');
715
- }
716
- lines.push(' Record again without it, and this should settle down.');
717
- return { lines: lines, namedAStep: true };
718
- }
719
-
720
- // ---------------------------------------------------------------------------
721
- // Reporter
722
- // ---------------------------------------------------------------------------
723
-
724
- class KryptheonReporter {
725
- constructor() {
726
- this.previousRuns = [];
727
- this.records = [];
728
- this.startedAt = new Date();
729
- // Errors that belong to no test: a spec file that would not load.
730
- this.loadErrors = [];
731
- // Set by any test whose page never said what encoding it was in.
732
- this.sawGuessedEncoding = false;
733
- }
734
-
735
- // Playwright calls this for anything that happened outside a test: a spec
736
- // file that would not load, most often. Kept rather than printed here, so
737
- // the reason lands beside the summary instead of ahead of the run.
738
- onError(error) {
739
- this.loadErrors.push(error);
740
- }
741
-
742
- onBegin() {
743
- this.startedAt = new Date();
744
- // Read history before this run is appended, so "was working on" looks
745
- // only at genuinely earlier runs.
746
- this.previousRuns = this._readHistory();
747
- if (!QUIET) {
748
- process.stdout.write('\nKryptheon test run - ' + formatWhen(this.startedAt.toISOString()) + '\n\n');
749
- }
750
- }
751
-
752
- _readHistory() {
753
- try {
754
- if (!fs.existsSync(HISTORY_FILE)) return [];
755
- return fs
756
- .readFileSync(HISTORY_FILE, 'utf8')
757
- .split('\n')
758
- .filter(function (l) {
759
- return l.trim().length > 0;
760
- })
761
- .map(function (line) {
762
- try {
763
- return JSON.parse(line);
764
- } catch (e) {
765
- return null; // skip malformed lines rather than crashing the run
766
- }
767
- })
768
- .filter(Boolean);
769
- } catch (e) {
770
- return [];
771
- }
772
- }
773
-
774
- // Most recent earlier run in which THIS recording passed. The name alone is
775
- // not enough: deleting a recording and making a new one that happens to end
776
- // up with the same name would otherwise inherit the old one's history, and
777
- // a brand new recording would be told it "was working" yesterday.
778
- _lastPassed(title, recordingId) {
779
- for (let i = this.previousRuns.length - 1; i >= 0; i--) {
780
- const run = this.previousRuns[i];
781
- const tests = (run && run.tests) || [];
782
- for (let j = tests.length - 1; j >= 0; j--) {
783
- const entry = tests[j];
784
- if (!entry || entry.title !== title || entry.status !== 'passed') continue;
785
- // Entries written before recordings were fingerprinted cannot be
786
- // matched to one, so they are not claimed by any.
787
- if (!entry.recordingId || !recordingId) continue;
788
- if (entry.recordingId !== recordingId) continue;
789
- return entry.timestamp || run.runAt;
790
- }
791
- }
792
- return null;
793
- }
794
-
795
- onTestEnd(test, result) {
796
- if ((result.attachments || []).some(function (a) { return a.name === 'kryptheon-encoding'; })) {
797
- this.sawGuessedEncoding = true;
798
- }
799
- const status =
800
- result.status === 'passed' ? 'passed' : result.status === 'skipped' ? 'skipped' : 'failed';
801
- const timestamp = (result.startTime instanceof Date ? result.startTime : new Date()).toISOString();
802
-
803
- const specPath = trustworthySpecPath(test, null);
804
- const recordingId = recordingIdFor(specPath);
805
-
806
- const record = {
807
- title: test.title,
808
- status: status,
809
- durationMs: result.duration,
810
- timestamp: timestamp,
811
- recordingId: recordingId,
812
- specFile: specPath ? path.relative(USER_DIR, specPath) : null,
813
- };
814
-
815
- if (status === 'skipped') {
816
- this.records.push(record);
817
- process.stdout.write('-- ' + test.title + ' (skipped)\n');
818
- return;
819
- }
820
-
821
- const renamed = readRenamed(result.attachments);
822
- if (renamed.length) record.renamed = renamed;
823
- const renamedLines = renamed.map(function (entry) {
824
- return ' Renamed: ' + heal.describeRename(entry);
825
- });
826
-
827
- if (status === 'passed') {
828
- this.records.push(record);
829
- if (!QUIET) {
830
- process.stdout.write('OK ' + test.title + ' (' + formatDuration(result.duration) + ')\n');
831
- }
832
- // Said even when quiet: a pass that followed a rename is still a pass,
833
- // but the person should hear that their button has a new name.
834
- if (renamedLines.length) {
835
- if (QUIET) process.stdout.write('OK ' + test.title + '\n');
836
- process.stdout.write(renamedLines.join('\n') + '\n');
837
- process.stdout.write(' Nothing is broken. To stop seeing this, record the flow again.\n');
838
- }
839
- return;
840
- }
841
-
842
- // On a test-level timeout Playwright reports two errors: a bare
843
- // "Test timeout of Nms exceeded." with no call log, and a second one
844
- // carrying the call log that says what we were waiting for. Reading only
845
- // the first throws away the diagnosis, so combine them and prefer the
846
- // error that actually carries detail.
847
- const rawErrors = (result.errors && result.errors.length ? result.errors : [result.error]).filter(Boolean);
848
- const errors = rawErrors.map(function (e) {
849
- return {
850
- message: stripAnsi(e.message || e.value || ''),
851
- location: e.location,
852
- };
853
- });
854
- const detailed = errors.filter(function (e) {
855
- return /Call log:|Locator:/.test(e.message);
856
- });
857
- const primary = detailed[0] || errors[0] || { message: 'The test failed.' };
858
- const message = errors
859
- .map(function (e) {
860
- return e.message;
861
- })
862
- .filter(Boolean)
863
- .join('\n\n') || 'The test failed.';
864
-
865
- const shot = (result.attachments || []).find(function (a) {
866
- return a.name === 'screenshot' && a.path;
867
- });
868
- const observations = readObservations(result.attachments);
869
- const located = primary.location || (errors.find(function (e) { return e.location; }) || {}).location;
870
- const line = located ? located.line : null;
871
- const file = trustworthySpecPath(test, located ? located.file : null);
872
- const locator = extractLocator(message);
873
- const translated = translate(message);
874
-
875
- record.failure = {
876
- line: line,
877
- file: file ? path.relative(USER_DIR, file) : null,
878
- locator: locator,
879
- assertion: (function () {
880
- const m = message.match(/expect\([^)]*\)(\.not)?\.(\w+)\(/);
881
- if (!m) return null;
882
- return (m[1] ? 'not.' : '') + m[2];
883
- })(),
884
- screenshot: shot ? shot.path : null,
885
- plainLanguage: translated.reason,
886
- rawMessage: message,
887
- observations: observations,
888
- };
889
- // Whether this ever worked decides more than one line of the report, so it
890
- // is settled before the record is kept. A recording that has never passed
891
- // is not a regression - there is nothing for it to have regressed from -
892
- // and the summary counts it apart from the ones that broke.
893
- const lastPass = this._lastPassed(test.title, recordingId);
894
- record.neverPassed = !lastPass;
895
- this.records.push(record);
896
-
897
- const out = [];
898
- out.push('');
899
- out.push('X ' + test.title);
900
- out.push(' ' + translated.reason);
901
-
902
- // A difference against a baseline only means something if there is a
903
- // baseline of this recording's own to differ from. Saying "this has not
904
- // passed before" and then listing headings that are gone says two things
905
- // that cannot both be true, and the reader spends their time trying to
906
- // reconcile them instead of reading the actual failure.
907
- const aboutBaseline = /Baseline changed|ended up somewhere different/.test(translated.reason || '');
908
- const details = lastPass || !aboutBaseline ? translated.details || [] : [];
909
- for (const detail of details) out.push(' ' + detail);
910
-
911
- out.push(lastPass ? ' This was working on ' + formatWhen(lastPass) + '.' : ' This has not passed before.');
912
- // On the way to the failure. It explains why the steps before it worked
913
- // even though a name in the recording no longer matches.
914
- for (const entry of renamed) out.push(' Renamed on the way: ' + heal.describeRename(entry));
915
-
916
- // A first run that fails is a different situation from a regression:
917
- // nothing has broken, because nothing ever worked. Far more often the
918
- // recording contains a step that cannot happen twice.
919
- let blamedTheRecording = false;
920
- if (!lastPass) {
921
- const advice = firstRunAdvice(test);
922
- for (const line of advice.lines) out.push(line);
923
- blamedTheRecording = advice.namedAStep;
924
- }
925
-
926
- // On a first run we have already named the step that cannot repeat, and
927
- // the usual "your last change may have..." advice would contradict it.
928
- if (blamedTheRecording) {
929
- /* the first-run block above already says what to do */
930
- } else if (translated.advice) {
931
- out.push(' What to check: ' + translated.advice.split('%TEST%').join(test.title));
932
- } else {
933
- out.push(' What to check: this is the raw message from the test tool - it was not recognised.');
934
- }
935
-
936
- for (const observed of whereToLookLines(observations)) out.push(observed);
937
-
938
- if (shot) out.push(' Screenshot: ' + shot.path);
939
- if (file && line && !translated.hideSource) {
940
- out.push(' Source: ' + path.relative(USER_DIR, file) + ' line ' + line);
941
- }
942
- const technical = firstMeaningfulLine(primary.message);
943
- if (!translated.raw && !addsNothing(technical, translated.reason)) {
944
- out.push(' Technical detail: ' + technical);
945
- }
946
-
947
- out.push('');
948
- for (const promptLine of buildPromptLines(test.title, translated, observations, lastPass)) {
949
- out.push(promptLine);
950
- }
951
- out.push('');
952
-
953
- process.stdout.write(out.join('\n') + '\n');
954
- }
955
-
956
- onEnd(result) {
957
- const passed = this.records.filter(function (r) {
958
- return r.status === 'passed';
959
- }).length;
960
- const failed = this.records.filter(function (r) {
961
- return r.status === 'failed';
962
- }).length;
963
-
964
- const entry = {
965
- runAt: this.startedAt.toISOString(),
966
- status: result && result.status ? result.status : 'unknown',
967
- durationMs: Date.now() - this.startedAt.getTime(),
968
- passed: passed,
969
- failed: failed,
970
- tests: this.records,
971
- };
972
-
973
- // Append only - existing lines are never rewritten.
974
- let saved = false;
975
- try {
976
- fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
977
- fs.appendFileSync(HISTORY_FILE, JSON.stringify(entry) + '\n', 'utf8');
978
- saved = true;
979
- } catch (err) {
980
- process.stdout.write('\n(could not write ' + shortHome(HISTORY_FILE) + ': ' + err.message + ')\n');
981
- }
982
-
983
- // "Broken" is kept for recordings that used to work. The ones that never
984
- // did are counted and explained separately, because reading "4 broken" on
985
- // the first day - when all four were made while learning the tool and none
986
- // has ever had a baseline - says the app is at fault when nothing is.
987
- const counts = recordings.summarise(this.records);
988
-
989
- // A file that would not load is said first, and always - before the quiet
990
- // shortcut, and whatever else the run managed to do.
991
- //
992
- // In practice a file that will not load stops the whole run, so nothing
993
- // else has anything to report and the block below returns. This is not
994
- // written as though that were guaranteed: onError also carries errors that
995
- // arrive outside any test, and one of those turning up after results would
996
- // otherwise be dropped in silence - which is the bug this whole thing is
997
- // here to fix.
998
- const unreadable = this.loadErrors.some(function (e) { return !isNoTestsFound(e); });
999
- if (unreadable) {
1000
- process.stdout.write(
1001
- String.fromCharCode(10) + loadErrorLines(this.loadErrors, USER_DIR).join(String.fromCharCode(10)),
1002
- );
1003
- }
1004
-
1005
- // Nothing ran at all. A summary of zero reads as good news, and in quiet
1006
- // mode it would say "0 recordings still working" over the top of a file
1007
- // that does not compile, so neither is printed. If the reason has not
1008
- // already been given above, it is given now.
1009
- if (!this.records.length) {
1010
- if (!unreadable) {
1011
- process.stdout.write(
1012
- String.fromCharCode(10) + loadErrorLines(this.loadErrors, USER_DIR).join(String.fromCharCode(10)),
1013
- );
1014
- }
1015
- return;
1016
- }
1017
-
1018
- if (QUIET && !counts.broken && !counts.unproven) {
1019
- const s = counts.working === 1 ? '' : 's';
1020
- process.stdout.write('OK ' + counts.working + ' recording' + s + ' still working.\n');
1021
- return;
1022
- }
1023
-
1024
- process.stdout.write(
1025
- '\n' + recordings.summaryLines(counts).join('\n') + '\n' +
1026
- (saved ? 'History saved outside your project, in ' + shortHome(HISTORY_FILE) + '\n' : '') + '\n'
1027
- );
1028
-
1029
- // Once per run, not once per test: it is one fact about the app, and
1030
- // repeating it under every recording would bury the reports themselves.
1031
- if (this.sawGuessedEncoding) {
1032
- process.stdout.write(
1033
- 'Note: this page does not declare a character encoding, so text may look\n' +
1034
- 'garbled here and in the browser. Add <meta charset="utf-8"> to the page.\n\n'
1035
- );
1036
- }
1037
- }
1038
- }
1039
-
1040
- module.exports = KryptheonReporter;
1041
-
1042
- // Shared with the fixture so baselines strip hosts exactly the same way.
1043
- // Attached to the exported class, so `new (require(...))()` keeps working.
1044
- module.exports.requestPath = requestPath;
1045
-
1046
- // Exported for the checks: what a file that would not load turns into.
1047
- module.exports.readLoadError = readLoadError;
1048
- module.exports.loadErrorLines = loadErrorLines;
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 the project's history.jsonl.
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const recordings = require('./kryptheon-recordings.js');
9
+ const heal = require('./kryptheon-heal.js');
10
+ const store = require('./kryptheon-store.js');
11
+
12
+ // An assistant runs this after every change, so it needs a mode that says
13
+ // nothing at all when the news is good.
14
+ const QUIET = !!process.env.KRYPTHEON_QUIET;
15
+
16
+ // The history belongs to whoever is running the tests, so it is kept in their
17
+ // project's store - not inside the installed package, and not in the project.
18
+ const USER_DIR = process.cwd();
19
+ const HISTORY_FILE = store.pathsFor(USER_DIR).history;
20
+
21
+ // Said with ~ for the home folder: the full path is long, and the person only
22
+ // needs to know it is outside their project and where to look.
23
+ function shortHome(file) {
24
+ const home = os.homedir();
25
+ return file.toLowerCase().startsWith(home.toLowerCase() + path.sep) ? '~' + file.slice(home.length) : file;
26
+ }
27
+
28
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec'];
29
+
30
+ function formatWhen(iso) {
31
+ const d = new Date(iso);
32
+ if (isNaN(d.getTime())) return null;
33
+ let hours = d.getHours();
34
+ const suffix = hours >= 12 ? 'PM' : 'AM';
35
+ hours = hours % 12 || 12;
36
+ const minutes = String(d.getMinutes()).padStart(2, '0');
37
+ return d.getDate() + ' ' + MONTHS[d.getMonth()] + ' at ' + hours + ':' + minutes + ' ' + suffix;
38
+ }
39
+
40
+ // Playwright colours its error messages with ANSI escapes; strip them before
41
+ // any pattern matching or they break every match and force the raw fallback.
42
+ // Built via RegExp so the source carries no literal escape character.
43
+ const ANSI_PATTERN = new RegExp('\\u001b\\[[0-9;?]*[ -/]*[@-~]', 'g');
44
+
45
+ function stripAnsi(value) {
46
+ return String(value == null ? '' : value).replace(ANSI_PATTERN, '');
47
+ }
48
+
49
+ function formatDuration(ms) {
50
+ if (typeof ms !== 'number' || isNaN(ms)) return 'unknown';
51
+ if (ms < 1000) return ms + 'ms';
52
+ return (ms / 1000).toFixed(1) + 's';
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Turning a Playwright locator string into something a human can read.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const ROLE_NOUNS = {
60
+ button: 'button',
61
+ textbox: 'text box',
62
+ link: 'link',
63
+ heading: 'heading',
64
+ checkbox: 'checkbox',
65
+ radio: 'radio button',
66
+ combobox: 'dropdown',
67
+ option: 'option',
68
+ img: 'image',
69
+ dialog: 'dialog',
70
+ // What the recorder's "assert text" button picks when the text is in the
71
+ // body of the page: "a check on the main" meant nothing to anyone.
72
+ main: 'main part of the page',
73
+ navigation: 'menu',
74
+ banner: 'top of the page',
75
+ contentinfo: 'footer',
76
+ status: 'status message',
77
+ };
78
+
79
+ // Returns a lower-case noun phrase such as: the button "Request my demo".
80
+ // Sentence-initial uses go through capitalise() below.
81
+ function describeLocator(locator) {
82
+ if (!locator) return null;
83
+ const raw = String(locator).trim();
84
+ let m;
85
+
86
+ m = raw.match(/getByRole\(\s*['"]([^'"]+)['"]\s*,\s*\{[^}]*name:\s*['"]([^'"]*)['"]/);
87
+ if (m) return 'the ' + (ROLE_NOUNS[m[1]] || m[1]) + ' "' + m[2] + '"';
88
+
89
+ // With no name, or with options that are not a name - { level: 1 } is
90
+ // what a check on a page's main heading gets.
91
+ m = raw.match(/getByRole\(\s*['"]([^'"]+)['"]\s*(?:,\s*\{[^}]*\}\s*)?\)/);
92
+ if (m) return 'the ' + (ROLE_NOUNS[m[1]] || m[1]);
93
+
94
+ m = raw.match(/getByText\(\s*['"]([^'"]*)['"]/);
95
+ if (m) return 'the text "' + m[1] + '"';
96
+
97
+ m = raw.match(/getByLabel\(\s*['"]([^'"]*)['"]/);
98
+ if (m) return 'the field labelled "' + m[1] + '"';
99
+
100
+ m = raw.match(/getByPlaceholder\(\s*['"]([^'"]*)['"]/);
101
+ if (m) return 'the field with placeholder "' + m[1] + '"';
102
+
103
+ m = raw.match(/getByTestId\(\s*['"]([^'"]*)['"]/);
104
+ if (m) return 'the element with test id "' + m[1] + '"';
105
+
106
+ m = raw.match(/getByTitle\(\s*['"]([^'"]*)['"]/);
107
+ if (m) return 'the element titled "' + m[1] + '"';
108
+
109
+ // Fall back to a name: value anywhere in the selector before the raw text.
110
+ m = raw.match(/name:\s*['"]([^'"]*)['"]/);
111
+ if (m) return 'the element "' + m[1] + '"';
112
+
113
+ return 'the element `' + raw + '`';
114
+ }
115
+
116
+ function capitalise(text) {
117
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
118
+ }
119
+
120
+ // Turns what Playwright printed - a regex literal like /\/dashboard\.html/ or a
121
+ // quoted absolute URL - into the readable path a person recognises.
122
+ function tidyUrl(raw) {
123
+ let s = String(raw == null ? '' : raw).trim();
124
+ s = s.replace(/^["']+|["']+$/g, '');
125
+
126
+ const asRegex = s.match(/^\/(.*)\/[gimsuy]*$/);
127
+ if (asRegex) s = asRegex[1].replace(/\\(.)/g, '$1');
128
+
129
+ try {
130
+ const u = new URL(s);
131
+ return (u.pathname || '/') + (u.search || '');
132
+ } catch (e) {
133
+ return s;
134
+ }
135
+ }
136
+
137
+ // Path and query only - never the host. Output gets shared and pasted around,
138
+ // and hostnames leak project identifiers.
139
+ function requestPath(requestUrl) {
140
+ try {
141
+ const u = new URL(requestUrl);
142
+ return (u.pathname || '/') + (u.search || '');
143
+ } catch (e) {
144
+ // Not a parseable absolute URL: strip any scheme://host prefix by hand.
145
+ const stripped = String(requestUrl).replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*/, '');
146
+ return stripped || '/';
147
+ }
148
+ }
149
+
150
+ // True when the Playwright call log shows we were waiting for an element that
151
+ // never turned up - the signal that distinguishes "missing" from "slow".
152
+ function waitingForLocator(message) {
153
+ return /^\s*-\s*waiting for\s+.+$/m.test(String(message));
154
+ }
155
+
156
+ function isTimeoutMessage(message) {
157
+ return /Timeout\s+\d+ms exceeded|[Tt]imeout of \d+ms exceeded|TimeoutError/.test(String(message));
158
+ }
159
+
160
+ // Pull the locator out of an error message, whichever form it took.
161
+ function extractLocator(message) {
162
+ let m = message.match(/^\s*Locator:\s*(.+)$/m);
163
+ if (m) return m[1].trim();
164
+ m = message.match(/^\s*-\s*waiting for (.+)$/m);
165
+ if (m) return m[1].trim();
166
+ return null;
167
+ }
168
+
169
+ // True when the technical line would only repeat the headline in other words.
170
+ // Keeping it then just makes the block longer without adding anything.
171
+ function addsNothing(technical, reason) {
172
+ const strip = function (s) {
173
+ return String(s == null ? '' : s)
174
+ .toLowerCase()
175
+ .replace(/^baseline changed:\s*/, '')
176
+ .replace(/[^a-z0-9]+/g, ' ')
177
+ .trim();
178
+ };
179
+ const a = strip(technical);
180
+ const b = strip(reason);
181
+ if (!a) return true;
182
+ return a === b || b.indexOf(a) !== -1 || a.indexOf(b) !== -1;
183
+ }
184
+
185
+ function firstMeaningfulLine(message) {
186
+ const line = String(message).split('\n').find(function (l) {
187
+ return l.trim().length > 0;
188
+ });
189
+ return line ? line.replace(/^(TimeoutError|Error):\s*/, '').trim() : 'The test failed.';
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Errors that belong to no test.
194
+ //
195
+ // A spec file with a syntax error, or one importing something that is not
196
+ // there, never becomes a test at all - so nothing calls onTestEnd and the run
197
+ // ends with no results. Playwright reports it through onError. A reporter that
198
+ // does not implement onError prints a summary of nothing and exits 1, and the
199
+ // person is left with a silent failure and no idea which file is at fault.
200
+ // ---------------------------------------------------------------------------
201
+
202
+ // Playwright says this once the load errors have already been reported. On its
203
+ // own it means an empty tests folder; after a real error it is a consequence,
204
+ // not a cause, and repeating it just buries the thing that has to be fixed.
205
+ function isNoTestsFound(error) {
206
+ return /No tests found/i.test(String((error && error.message) || ''));
207
+ }
208
+
209
+ /**
210
+ * A load error, reduced to the file, the line and one sentence.
211
+ *
212
+ * The message carries a code frame in ANSI colour and repeats the absolute
213
+ * path inside its own first line. Neither helps here: the path is printed
214
+ * separately and relative, and the frame belongs in an editor.
215
+ */
216
+ function readLoadError(error, userDir) {
217
+ const raw = stripAnsi((error && error.message) || (error && error.value) || String(error || ""));
218
+ const location = (error && error.location) || {};
219
+
220
+ let file = location.file || null;
221
+ if (file) {
222
+ try {
223
+ file = path.relative(userDir || process.cwd(), file);
224
+ } catch (e) {
225
+ /* an absolute path is still better than nothing */
226
+ }
227
+ }
228
+
229
+ // First line only, with the absolute path taken back out of it.
230
+ let message = raw.split("\n").map(function (l) { return l.trim(); }).filter(Boolean)[0] || "The file could not be read.";
231
+ if (location.file) message = message.split(location.file + ": ").join("");
232
+ message = message.replace(/\s*\(\d+:\d+\)\s*$/, "");
233
+
234
+ return {
235
+ file: file,
236
+ line: typeof location.line === "number" ? location.line : null,
237
+ message: message,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * What to print when nothing could be loaded.
243
+ *
244
+ * Said as "this file", not "the tests failed", because nothing was tested. The
245
+ * app may be perfectly fine; a file in the tests folder is not.
246
+ */
247
+ function loadErrorLines(errors, userDir) {
248
+ const real = (errors || []).filter(function (e) { return !isNoTestsFound(e); });
249
+ const lines = [];
250
+
251
+ if (!real.length) {
252
+ // Nothing ran and nothing explained why. Saying so is still better than a
253
+ // summary of zero, which reads as "everything is fine".
254
+ lines.push("Nothing ran.");
255
+ lines.push("");
256
+ lines.push("No recording produced a result, and the test runner gave no reason.");
257
+ lines.push("Check that the files in tests/ contain a test.");
258
+ lines.push("");
259
+ return lines;
260
+ }
261
+
262
+ const many = real.length > 1;
263
+ lines.push(many ? real.length + " of your recordings could not be read:" : "One of your recordings could not be read:");
264
+ lines.push("");
265
+ for (const error of real) {
266
+ const read = readLoadError(error, userDir);
267
+ const where = read.file ? read.file + (read.line ? " line " + read.line : "") : "(the file was not named)";
268
+ lines.push(" " + where);
269
+ lines.push(" " + read.message);
270
+ lines.push("");
271
+ }
272
+ lines.push(many
273
+ ? "Nothing else ran, because those files could not be loaded at all."
274
+ : "Nothing else ran, because that file could not be loaded at all.");
275
+ lines.push("Fix it and run this again.");
276
+ lines.push("");
277
+ return lines;
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Translation: Playwright error -> { reason, advice }
282
+ // ---------------------------------------------------------------------------
283
+
284
+ function translate(message) {
285
+ const msg = String(message || '');
286
+ const locatorRaw = extractLocator(msg);
287
+ const subject = describeLocator(locatorRaw);
288
+ let m;
289
+
290
+ // A baseline mismatch raised by the fixture. The message is already written
291
+ // for a person; lift it out and keep the Was/Now lines as detail.
292
+ if (/Baseline changed:/.test(msg)) {
293
+ const lines = msg.split('\n').map(function (l) {
294
+ return l.trim();
295
+ });
296
+ const headIndex = lines.findIndex(function (l) {
297
+ return l.indexOf('Baseline changed:') !== -1;
298
+ });
299
+ const head = lines[headIndex].replace(/^.*Baseline changed:\s*/, '');
300
+ // An allowlist, so a stack trace that happens to follow the message can
301
+ // never leak into the report. Address and title come from the original
302
+ // baseline; the rest name what the page stopped or started showing.
303
+ const details = lines.slice(headIndex + 1).filter(function (l) {
304
+ return /^(Address|Title) (was|now):/.test(l) || /^(Headings|Buttons and links|Form fields) that are (gone|new):/.test(l) || /^Requests that failed this time:/.test(l);
305
+ });
306
+ return {
307
+ reason: capitalise(head),
308
+ details: details,
309
+ // The throw happens inside the fixture, so a source location would just
310
+ // point at plumbing the reader did not write.
311
+ hideSource: true,
312
+ // %TEST% is filled in with the test's name when the block is printed,
313
+ // so the command resets only this one test.
314
+ advice: 'if this change is intended, run: npx kryptheon accept "%TEST%"',
315
+ };
316
+ }
317
+
318
+ // One step, more than one match: "strict mode violation: getByRole(...)
319
+ // resolved to 2 elements". The thing is there - too many times over - so
320
+ // "could not find" would be the wrong story. Found on a blind test
321
+ // (LaunchRail): two "Review" buttons, and the first run blamed steps that
322
+ // had nothing to do with it.
323
+ m = msg.match(/strict mode violation:\s*([\s\S]+?)\s+resolved to (\d+) elements/);
324
+ if (m) {
325
+ const what = describeLocator(m[1]) || 'what this step looks for';
326
+ return {
327
+ reason: 'The recording asks for ' + what + ', but the page has ' + m[2] + ' of them and it does not say which one.',
328
+ advice: 'record this step again and click something only that one has. If the page should show just one, ' +
329
+ 'the step before it may have left the page in a different state than when it was recorded.',
330
+ ambiguous: true,
331
+ };
332
+ }
333
+
334
+ // Navigation failures, e.g. "page.goto: net::ERR_NAME_NOT_RESOLVED at <url>"
335
+ m = msg.match(/(?:page\.goto|page\.reload|page\.goBack)[^\n]*?(net::[A-Z_]+)(?:\s+at\s+(\S+))?/);
336
+ if (m) {
337
+ const url = m[2] ? ' (' + m[2] + ')' : '';
338
+ const kind = m[1];
339
+ let why = 'the address could not be reached';
340
+ if (kind === 'net::ERR_NAME_NOT_RESOLVED') why = 'that web address could not be found';
341
+ else if (kind === 'net::ERR_CONNECTION_REFUSED') why = 'the server refused the connection';
342
+ else if (kind === 'net::ERR_INTERNET_DISCONNECTED') why = 'there was no internet connection';
343
+ else if (kind === 'net::ERR_CONNECTION_TIMED_OUT') why = 'the server did not respond in time';
344
+ return {
345
+ reason: 'The page could not be opened' + url + ' - ' + why + '.',
346
+ advice: 'check the site is online, that the address in the test is correct, and that you are connected to the internet.',
347
+ };
348
+ }
349
+ if (/page\.goto[\s\S]*Timeout \d+ms exceeded/.test(msg)) {
350
+ return {
351
+ reason: 'The page took too long to load and the test gave up waiting.',
352
+ advice: 'the site may be slow or down; try opening it in a browser yourself.',
353
+ };
354
+ }
355
+
356
+ // expect(...).not.toBeVisible() - the element was still there. This must be
357
+ // checked before the call-log rules below, because its call log also carries
358
+ // a "waiting for" line even though the element was found.
359
+ if (/expect\([^)]*\)\.not\.toBeVisible\(\)\s*failed/.test(msg)) {
360
+ return {
361
+ reason: capitalise(subject || 'the element') + ' was still on the page, but the test expected it to be gone by now.',
362
+ advice: 'the page may not have moved on to the next step - for example a form that did not submit.',
363
+ };
364
+ }
365
+
366
+ // expect(...).toBeVisible() - either absent entirely, or present but hidden.
367
+ if (/expect\([^)]*\)\.toBeVisible\(\)\s*failed/.test(msg)) {
368
+ if (/element\(s\) not found/.test(msg)) {
369
+ return {
370
+ reason: 'Could not find ' + (subject || 'the element') + ' on the page.',
371
+ advice: 'your last change may have renamed, hidden, or removed it.',
372
+ };
373
+ }
374
+ return {
375
+ reason: capitalise(subject || 'the element') + ' is on the page but never became visible.',
376
+ advice: 'it may be hidden behind a popup, still loading, or scrolled out of view.',
377
+ };
378
+ }
379
+
380
+ // A URL mismatch is not "the content changed" - the browser simply ended up
381
+ // somewhere else. Say where it was expected and where it actually was.
382
+ // Playwright labels these two ways: "Expected pattern:"/"Received string:"
383
+ // for a regex, and "Expected:"/"Received:" for a plain string.
384
+ if (/expect\([^)]*\)(?:\.not)?\.toHaveURL\(/.test(msg)) {
385
+ const expectedRaw = (msg.match(/^\s*Expected(?: pattern)?:\s*(.+)$/m) || [])[1];
386
+ const actualRaw = (msg.match(/^\s*Received(?: string)?:\s*(.+)$/m) || [])[1];
387
+ const details = [];
388
+ if (expectedRaw) details.push('Expected: ' + tidyUrl(expectedRaw));
389
+ if (actualRaw) details.push('Actually on: ' + tidyUrl(actualRaw));
390
+ return {
391
+ reason: 'The page did not go where it was supposed to.',
392
+ details: details,
393
+ urlExpected: expectedRaw ? tidyUrl(expectedRaw) : null,
394
+ urlActual: actualRaw ? tidyUrl(actualRaw) : null,
395
+ advice: 'the step before this may not have worked - for example a sign-in that was rejected, or a redirect elsewhere.',
396
+ };
397
+ }
398
+
399
+ // What the recorder's snapshot button writes. Its diff is a page of YAML,
400
+ // which says nothing to someone who has never seen one.
401
+ if (/expect\([^)]*\)(?:\.not)?\.toMatchAriaSnapshot\(/.test(msg)) {
402
+ return {
403
+ reason: capitalise(subject || 'part of the page') + ' is laid out differently than when this check was recorded.',
404
+ advice: 'something in it was added, removed or renamed - the screenshot shows how it looks now.',
405
+ };
406
+ }
407
+
408
+ // Value comparisons: toHaveTitle / toHaveText / toHaveValue / toHaveCount.
409
+ m = msg.match(/expect\([^)]*\)(\.not)?\.(toHave\w+|toContainText)\([^)]*\)\s*failed/);
410
+ if (m) {
411
+ // Playwright names the lines after what it compared: "Expected substring"
412
+ // for toContainText, "Expected string" for toHaveText and toHaveValue,
413
+ // "Expected pattern" for a regex. Reading only "Expected:" missed every
414
+ // check the recorder's own buttons write.
415
+ const expected = (msg.match(/^\s*Expected(?: substring| string| pattern)?:\s*(.+)$/m) || [])[1];
416
+ const received = (msg.match(/^\s*Received(?: string)?:\s*(.+)$/m) || [])[1];
417
+ const negated = Boolean(m[1]);
418
+ const matcher = m[2];
419
+ const what =
420
+ matcher === 'toHaveTitle' ? 'the page title'
421
+ : matcher === 'toHaveURL' ? 'the page address'
422
+ : matcher === 'toHaveCount' ? 'the number of matching items'
423
+ : subject ? subject.charAt(0).toLowerCase() + subject.slice(1)
424
+ : 'the element';
425
+ // A whole page of text is not an answer anyone can read. Say what was
426
+ // looked for, and show what was there only when it is short enough to be
427
+ // the thing someone meant.
428
+ const shown = received && received.trim().length <= 82 ? received.trim() : null;
429
+ if (expected && matcher === 'toContainText') {
430
+ return {
431
+ reason: negated
432
+ ? 'The test expected ' + what + ' not to say ' + expected.trim() + ', but it does.'
433
+ : 'The test expected ' + what + ' to say ' + expected.trim() + ', but ' +
434
+ (shown ? 'it says ' + shown + '.' : 'that text is not there.'),
435
+ advice: 'the wording on the page may have changed, or the step before it did not do what it should.',
436
+ };
437
+ }
438
+ if (expected && received) {
439
+ return {
440
+ reason: negated
441
+ ? 'The test expected ' + what + ' not to be ' + expected.trim() + ', but it is.'
442
+ : 'The test expected ' + what + ' to be ' + expected.trim() + ', but found ' + (shown || 'something else') + '.',
443
+ advice: 'the wording on the page may have changed, or the page shown was not the one expected.',
444
+ };
445
+ }
446
+ return {
447
+ reason: 'A check on ' + what + ' did not match what the test expected.',
448
+ advice: 'the content on the page may have changed since this test was written.',
449
+ };
450
+ }
451
+
452
+ // Timeouts. A timeout on its own says nothing about the cause: the deciding
453
+ // signal is whether the call log shows we were still waiting for an element.
454
+ // If it does, the element was never there - that is a missing element, not a
455
+ // slow site. Only a timeout with no such line is genuine slowness.
456
+ if (isTimeoutMessage(msg)) {
457
+ if (waitingForLocator(msg)) {
458
+ return {
459
+ reason: 'Could not find ' + (subject || 'the element') + ' on the page.',
460
+ advice: 'your last change may have renamed, hidden, or removed it.',
461
+ };
462
+ }
463
+
464
+ m = msg.match(/(?:[Tt]imeout of (\d+)ms exceeded|Timeout (\d+)ms exceeded)/);
465
+ const ms = m ? Number(m[1] || m[2]) : null;
466
+ const howLong = ms ? ' after ' + Math.max(1, Math.round(ms / 1000)) + ' seconds' : '';
467
+ return {
468
+ reason: 'The test ran out of time' + howLong + ' and was stopped.',
469
+ advice: 'the site may be slower than usual, or the test is waiting for something that never happens.',
470
+ };
471
+ }
472
+
473
+ // Anything not recognised: fall back to Playwright's own wording.
474
+ return { reason: firstMeaningfulLine(msg), advice: null, raw: true };
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // "Where to look" - only things actually observed in the browser during the
479
+ // test. Never a source file or line number, which would be a guess.
480
+ // ---------------------------------------------------------------------------
481
+
482
+ // Plain-language meaning of an HTTP status. The raw code always stays visible
483
+ // next to it; this only adds the explanation.
484
+ function describeStatus(status) {
485
+ const code = Number(status);
486
+ if (code === 400 || code === 401) {
487
+ return 'the request was rejected - usually wrong credentials, a missing login session, or invalid data being sent';
488
+ }
489
+ if (code === 403) return 'the server refused permission for this action';
490
+ if (code === 404) {
491
+ return 'this address does not exist on the server - the route may have been renamed or removed';
492
+ }
493
+ if (code === 429) return 'too many requests too quickly - the server is rate limiting';
494
+ if (code === 500 || code === 502 || code === 503) {
495
+ return 'the server crashed or is unavailable - the error is in backend code, not the page';
496
+ }
497
+ if (code >= 500) return 'the server could not complete the request';
498
+ if (code >= 400) return 'the server rejected the request';
499
+ return null;
500
+ }
501
+
502
+ // How long ago the test last passed, in words.
503
+ function timeAgo(iso, now) {
504
+ const then = new Date(iso).getTime();
505
+ if (isNaN(then)) return null;
506
+ const minutes = Math.round(((now || Date.now()) - then) / 60000);
507
+ if (minutes < 1) return 'less than a minute ago';
508
+ if (minutes === 1) return '1 minute ago';
509
+ if (minutes < 60) return minutes + ' minutes ago';
510
+ const hours = Math.floor(minutes / 60);
511
+ if (hours === 1) return '1 hour ago';
512
+ if (hours < 24) return hours + ' hours ago';
513
+ const days = Math.floor(hours / 24);
514
+ return days === 1 ? '1 day ago' : days + ' days ago';
515
+ }
516
+
517
+ function wrapText(text, width) {
518
+ const words = String(text).split(/\s+/).filter(Boolean);
519
+ const lines = [];
520
+ let line = '';
521
+ for (const word of words) {
522
+ if (!line.length) line = word;
523
+ else if ((line + ' ' + word).length <= width) line += ' ' + word;
524
+ else {
525
+ lines.push(line);
526
+ line = word;
527
+ }
528
+ }
529
+ if (line) lines.push(line);
530
+ return lines;
531
+ }
532
+
533
+ // The prompt uses the same host-free formatting as "Where to look".
534
+
535
+ function readObservations(attachments) {
536
+ const found = (attachments || []).find(function (a) {
537
+ return a.name === 'kryptheon-observations' && a.body;
538
+ });
539
+ if (!found) return null;
540
+ try {
541
+ return JSON.parse(found.body.toString('utf8'));
542
+ } catch (e) {
543
+ return null;
544
+ }
545
+ }
546
+
547
+ // Renamed buttons and fields the replay followed (kryptheon-heal.js), each
548
+ // already one sentence. Empty when there were none or the note is unreadable.
549
+ function readRenamed(attachments) {
550
+ const found = (attachments || []).find(function (a) {
551
+ return a.name === 'kryptheon-renamed' && a.body;
552
+ });
553
+ if (!found) return [];
554
+ try {
555
+ const list = JSON.parse(found.body.toString('utf8'));
556
+ return Array.isArray(list) ? list.filter(function (e) { return e && e.was && e.now; }) : [];
557
+ } catch (e) {
558
+ return [];
559
+ }
560
+ }
561
+
562
+ function uniq(list) {
563
+ const seen = Object.create(null);
564
+ const out = [];
565
+ for (const item of list || []) {
566
+ const key = typeof item === 'string' ? item : JSON.stringify(item);
567
+ if (!seen[key]) {
568
+ seen[key] = true;
569
+ out.push(item);
570
+ }
571
+ }
572
+ return out;
573
+ }
574
+
575
+ function whereToLookLines(obs) {
576
+ if (!obs) return [];
577
+ const lines = [];
578
+
579
+ if (obs.url) lines.push(' - Browser was on: ' + obs.url);
580
+ // Where the flow actually got to, in words. The whole signature stays behind
581
+ // KRYPTHEON_DEBUG_SIGNATURE for when that much detail is wanted.
582
+ if (obs.pageShowed) lines.push(' - Page showed: ' + obs.pageShowed);
583
+
584
+ for (const req of uniq(obs.failedRequests).slice(0, 5)) {
585
+ const meaning = describeStatus(req.status);
586
+ lines.push(
587
+ ' - ' + (req.method || 'GET') + ' ' + requestPath(req.url) + ' returned ' + req.status +
588
+ (meaning ? '\n ' + meaning : '')
589
+ );
590
+ }
591
+
592
+ for (const err of uniq(obs.consoleErrors).slice(0, 5)) {
593
+ const text = String(err).replace(/\s+/g, ' ').trim();
594
+ lines.push(' - Console error: ' + (text.length > 160 ? text.slice(0, 157) + '...' : text));
595
+ }
596
+
597
+ // Nothing observed at all - the section is omitted entirely.
598
+ if (!lines.length) return [];
599
+ return [' Where to look:'].concat(lines);
600
+ }
601
+
602
+ // ---------------------------------------------------------------------------
603
+ // A prompt the user can paste into their AI coding tool. Built strictly from
604
+ // what this run observed: no causes, no theories, no file paths.
605
+ // ---------------------------------------------------------------------------
606
+
607
+ // Reasons arrive in mixed shapes (some end in a full stop, the raw fallback
608
+ // may not even start capitalised), so normalise each into a real sentence.
609
+ function asSentence(text) {
610
+ let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
611
+ if (!s) return '';
612
+ s = s.charAt(0).toUpperCase() + s.slice(1);
613
+ if (!/[.!?]$/.test(s)) s += '.';
614
+ return s;
615
+ }
616
+
617
+ function buildPromptLines(testTitle, translated, obs, lastPassIso, now) {
618
+ const sentences = [];
619
+ // Never passed: nothing broke, so an AI tool must not be sent off to "fix"
620
+ // the app. Found on a blind test (LaunchRail): "My flow broke. Fix only
621
+ // this." about a recording on its very first replay.
622
+ if (!lastPassIso) {
623
+ sentences.push('My "' + testTitle + '" recording has never passed - this was its first replay.');
624
+ } else {
625
+ sentences.push('My "' + testTitle + '" flow broke.');
626
+ }
627
+
628
+ // What failed.
629
+ if (translated.urlActual && translated.urlExpected) {
630
+ sentences.push(
631
+ 'The page stayed on ' + translated.urlActual + ' instead of reaching ' + translated.urlExpected + '.'
632
+ );
633
+ } else {
634
+ sentences.push(translated.reason);
635
+ if (obs && obs.url) sentences.push('The browser was on ' + obs.url + '.');
636
+ }
637
+
638
+ // What the browser saw.
639
+ if (obs) {
640
+ for (const req of uniq(obs.failedRequests).slice(0, 3)) {
641
+ sentences.push(
642
+ 'The request ' + (req.method || 'GET') + ' ' + requestPath(req.url) + ' returned ' + req.status + '.'
643
+ );
644
+ }
645
+ for (const err of uniq(obs.consoleErrors).slice(0, 2)) {
646
+ const text = String(err).replace(/\s+/g, ' ').trim();
647
+ sentences.push('The browser console reported: "' + (text.length > 140 ? text.slice(0, 137) + '...' : text) + '".');
648
+ }
649
+ }
650
+
651
+ // When it last worked.
652
+ const ago = lastPassIso ? timeAgo(lastPassIso, now) : null;
653
+ if (ago) sentences.push('This was working ' + ago + '.');
654
+
655
+ if (lastPassIso) {
656
+ sentences.push('Fix only this.');
657
+ } else {
658
+ sentences.push('Before changing the app, help me work out whether the recording or the app is at fault.');
659
+ }
660
+
661
+ const body = wrapText(sentences.map(asSentence).filter(Boolean).join(' '), 72);
662
+ return [' Paste this into your AI tool:', ' ' + '─'.repeat(29)].concat(
663
+ body.map(function (l) {
664
+ return ' ' + l;
665
+ })
666
+ );
667
+ }
668
+
669
+ // ---------------------------------------------------------------------------
670
+ // The first run is a special case: nothing has regressed, because nothing has
671
+ // ever passed. Usually the recording itself contains a step that only worked
672
+ // the once.
673
+ // ---------------------------------------------------------------------------
674
+
675
+ // Playwright works the error's file out of a stack trace, and its parser
676
+ // stops at the first "(" - so a folder called "New folder (2)" arrives as
677
+ // "2)\tests\thing.spec.js". The path declared for the test itself is read from
678
+ // the file system and is always whole, so prefer that whenever the stack's
679
+ // version does not point at a real file.
680
+ function trustworthySpecPath(test, errorFile) {
681
+ const declared = (test && test.location && test.location.file) || null;
682
+ if (errorFile && path.isAbsolute(errorFile)) {
683
+ try {
684
+ if (fs.existsSync(errorFile)) return errorFile;
685
+ } catch (err) {
686
+ /* fall through to the declared path */
687
+ }
688
+ }
689
+ if (declared) {
690
+ try {
691
+ if (fs.existsSync(declared)) return declared;
692
+ } catch (err) {
693
+ /* fall through */
694
+ }
695
+ }
696
+ return errorFile || declared || null;
697
+ }
698
+
699
+ // Identity of the recording itself, so history cannot be inherited by a
700
+ // different recording that happens to share a name.
701
+ function recordingIdFor(specPath) {
702
+ if (!specPath) return null;
703
+ let source;
704
+ try {
705
+ source = fs.readFileSync(specPath, 'utf8');
706
+ } catch (err) {
707
+ return null;
708
+ }
709
+ try {
710
+ return require('./kryptheon-replay.js').recordingId(source);
711
+ } catch (err) {
712
+ return null;
713
+ }
714
+ }
715
+
716
+ function firstRunAdvice(test) {
717
+ const lines = [];
718
+ lines.push(' This is the first run of this recording, so nothing has broken yet -');
719
+ lines.push(' a step that cannot happen twice is the more likely explanation.');
720
+
721
+ const file = test && test.location && test.location.file;
722
+ if (!file) return { lines: lines, namedAStep: false };
723
+
724
+ let source;
725
+ try {
726
+ source = fs.readFileSync(file, 'utf8');
727
+ } catch (err) {
728
+ return { lines: lines, namedAStep: false };
729
+ }
730
+
731
+ let risks = [];
732
+ try {
733
+ risks = require('./kryptheon-replay.js').findReplayRisks(source);
734
+ } catch (err) {
735
+ return { lines: lines, namedAStep: false };
736
+ }
737
+ if (!risks.length) return { lines: lines, namedAStep: false };
738
+
739
+ lines.push(' Most likely the step that cannot repeat:');
740
+ for (const risk of risks.slice(0, 3)) {
741
+ lines.push(' - "' + risk.step + '" (line ' + risk.line + ') - ' + risk.why + '.');
742
+ }
743
+ lines.push(' Record again without it, and this should settle down.');
744
+ return { lines: lines, namedAStep: true };
745
+ }
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // Reporter
749
+ // ---------------------------------------------------------------------------
750
+
751
+ class KryptheonReporter {
752
+ constructor() {
753
+ this.previousRuns = [];
754
+ this.records = [];
755
+ this.startedAt = new Date();
756
+ // Errors that belong to no test: a spec file that would not load.
757
+ this.loadErrors = [];
758
+ // Set by any test whose page never said what encoding it was in.
759
+ this.sawGuessedEncoding = false;
760
+ }
761
+
762
+ // Playwright calls this for anything that happened outside a test: a spec
763
+ // file that would not load, most often. Kept rather than printed here, so
764
+ // the reason lands beside the summary instead of ahead of the run.
765
+ onError(error) {
766
+ this.loadErrors.push(error);
767
+ }
768
+
769
+ onBegin() {
770
+ this.startedAt = new Date();
771
+ // Read history before this run is appended, so "was working on" looks
772
+ // only at genuinely earlier runs.
773
+ this.previousRuns = this._readHistory();
774
+ if (!QUIET) {
775
+ process.stdout.write('\nKryptheon test run - ' + formatWhen(this.startedAt.toISOString()) + '\n\n');
776
+ }
777
+ }
778
+
779
+ _readHistory() {
780
+ try {
781
+ if (!fs.existsSync(HISTORY_FILE)) return [];
782
+ return fs
783
+ .readFileSync(HISTORY_FILE, 'utf8')
784
+ .split('\n')
785
+ .filter(function (l) {
786
+ return l.trim().length > 0;
787
+ })
788
+ .map(function (line) {
789
+ try {
790
+ return JSON.parse(line);
791
+ } catch (e) {
792
+ return null; // skip malformed lines rather than crashing the run
793
+ }
794
+ })
795
+ .filter(Boolean);
796
+ } catch (e) {
797
+ return [];
798
+ }
799
+ }
800
+
801
+ // Most recent earlier run in which THIS recording passed. The name alone is
802
+ // not enough: deleting a recording and making a new one that happens to end
803
+ // up with the same name would otherwise inherit the old one's history, and
804
+ // a brand new recording would be told it "was working" yesterday.
805
+ _lastPassed(title, recordingId) {
806
+ for (let i = this.previousRuns.length - 1; i >= 0; i--) {
807
+ const run = this.previousRuns[i];
808
+ const tests = (run && run.tests) || [];
809
+ for (let j = tests.length - 1; j >= 0; j--) {
810
+ const entry = tests[j];
811
+ if (!entry || entry.title !== title || entry.status !== 'passed') continue;
812
+ // Entries written before recordings were fingerprinted cannot be
813
+ // matched to one, so they are not claimed by any.
814
+ if (!entry.recordingId || !recordingId) continue;
815
+ if (entry.recordingId !== recordingId) continue;
816
+ return entry.timestamp || run.runAt;
817
+ }
818
+ }
819
+ return null;
820
+ }
821
+
822
+ onTestEnd(test, result) {
823
+ if ((result.attachments || []).some(function (a) { return a.name === 'kryptheon-encoding'; })) {
824
+ this.sawGuessedEncoding = true;
825
+ }
826
+ const status =
827
+ result.status === 'passed' ? 'passed' : result.status === 'skipped' ? 'skipped' : 'failed';
828
+ const timestamp = (result.startTime instanceof Date ? result.startTime : new Date()).toISOString();
829
+
830
+ const specPath = trustworthySpecPath(test, null);
831
+ const recordingId = recordingIdFor(specPath);
832
+
833
+ const record = {
834
+ title: test.title,
835
+ status: status,
836
+ durationMs: result.duration,
837
+ timestamp: timestamp,
838
+ recordingId: recordingId,
839
+ specFile: specPath ? path.relative(USER_DIR, specPath) : null,
840
+ };
841
+
842
+ if (status === 'skipped') {
843
+ this.records.push(record);
844
+ process.stdout.write('-- ' + test.title + ' (skipped)\n');
845
+ return;
846
+ }
847
+
848
+ const renamed = readRenamed(result.attachments);
849
+ if (renamed.length) record.renamed = renamed;
850
+ const renamedLines = renamed.map(function (entry) {
851
+ return ' Renamed: ' + heal.describeRename(entry);
852
+ });
853
+
854
+ if (status === 'passed') {
855
+ this.records.push(record);
856
+ if (!QUIET) {
857
+ process.stdout.write('OK ' + test.title + ' (' + formatDuration(result.duration) + ')\n');
858
+ }
859
+ // Said even when quiet: a pass that followed a rename is still a pass,
860
+ // but the person should hear that their button has a new name.
861
+ if (renamedLines.length) {
862
+ if (QUIET) process.stdout.write('OK ' + test.title + '\n');
863
+ process.stdout.write(renamedLines.join('\n') + '\n');
864
+ process.stdout.write(' Nothing is broken. To stop seeing this, record the flow again.\n');
865
+ }
866
+ return;
867
+ }
868
+
869
+ // On a test-level timeout Playwright reports two errors: a bare
870
+ // "Test timeout of Nms exceeded." with no call log, and a second one
871
+ // carrying the call log that says what we were waiting for. Reading only
872
+ // the first throws away the diagnosis, so combine them and prefer the
873
+ // error that actually carries detail.
874
+ const rawErrors = (result.errors && result.errors.length ? result.errors : [result.error]).filter(Boolean);
875
+ const errors = rawErrors.map(function (e) {
876
+ return {
877
+ message: stripAnsi(e.message || e.value || ''),
878
+ location: e.location,
879
+ };
880
+ });
881
+ const detailed = errors.filter(function (e) {
882
+ return /Call log:|Locator:/.test(e.message);
883
+ });
884
+ const primary = detailed[0] || errors[0] || { message: 'The test failed.' };
885
+ const message = errors
886
+ .map(function (e) {
887
+ return e.message;
888
+ })
889
+ .filter(Boolean)
890
+ .join('\n\n') || 'The test failed.';
891
+
892
+ const shot = (result.attachments || []).find(function (a) {
893
+ return a.name === 'screenshot' && a.path;
894
+ });
895
+ const observations = readObservations(result.attachments);
896
+ const located = primary.location || (errors.find(function (e) { return e.location; }) || {}).location;
897
+ const line = located ? located.line : null;
898
+ const file = trustworthySpecPath(test, located ? located.file : null);
899
+ const locator = extractLocator(message);
900
+ const translated = translate(message);
901
+
902
+ record.failure = {
903
+ line: line,
904
+ file: file ? path.relative(USER_DIR, file) : null,
905
+ locator: locator,
906
+ assertion: (function () {
907
+ const m = message.match(/expect\([^)]*\)(\.not)?\.(\w+)\(/);
908
+ if (!m) return null;
909
+ return (m[1] ? 'not.' : '') + m[2];
910
+ })(),
911
+ screenshot: shot ? shot.path : null,
912
+ plainLanguage: translated.reason,
913
+ rawMessage: message,
914
+ observations: observations,
915
+ };
916
+ // Whether this ever worked decides more than one line of the report, so it
917
+ // is settled before the record is kept. A recording that has never passed
918
+ // is not a regression - there is nothing for it to have regressed from -
919
+ // and the summary counts it apart from the ones that broke.
920
+ const lastPass = this._lastPassed(test.title, recordingId);
921
+ record.neverPassed = !lastPass;
922
+ this.records.push(record);
923
+
924
+ const out = [];
925
+ out.push('');
926
+ out.push('X ' + test.title);
927
+ out.push(' ' + translated.reason);
928
+
929
+ // A difference against a baseline only means something if there is a
930
+ // baseline of this recording's own to differ from. Saying "this has not
931
+ // passed before" and then listing headings that are gone says two things
932
+ // that cannot both be true, and the reader spends their time trying to
933
+ // reconcile them instead of reading the actual failure.
934
+ const aboutBaseline = /Baseline changed|ended up somewhere different/.test(translated.reason || '');
935
+ const details = lastPass || !aboutBaseline ? translated.details || [] : [];
936
+ for (const detail of details) out.push(' ' + detail);
937
+
938
+ out.push(lastPass ? ' This was working on ' + formatWhen(lastPass) + '.' : ' This has not passed before.');
939
+ // On the way to the failure. It explains why the steps before it worked
940
+ // even though a name in the recording no longer matches.
941
+ for (const entry of renamed) out.push(' Renamed on the way: ' + heal.describeRename(entry));
942
+
943
+ // A first run that fails is a different situation from a regression:
944
+ // nothing has broken, because nothing ever worked. Far more often the
945
+ // recording contains a step that cannot happen twice.
946
+ let blamedTheRecording = false;
947
+ if (!lastPass && translated.ambiguous) {
948
+ // The failure already says what is wrong with the recording: guessing
949
+ // at a step that cannot repeat would point somewhere else entirely.
950
+ out.push(' This is the first run of this recording, so nothing has broken yet -');
951
+ out.push(' it stopped on a step that matches more than one thing on the page.');
952
+ } else if (!lastPass) {
953
+ const advice = firstRunAdvice(test);
954
+ for (const line of advice.lines) out.push(line);
955
+ blamedTheRecording = advice.namedAStep;
956
+ }
957
+
958
+ // On a first run we have already named the step that cannot repeat, and
959
+ // the usual "your last change may have..." advice would contradict it.
960
+ if (blamedTheRecording) {
961
+ /* the first-run block above already says what to do */
962
+ } else if (translated.advice) {
963
+ out.push(' What to check: ' + translated.advice.split('%TEST%').join(test.title));
964
+ } else {
965
+ out.push(' What to check: this is the raw message from the test tool - it was not recognised.');
966
+ }
967
+
968
+ for (const observed of whereToLookLines(observations)) out.push(observed);
969
+
970
+ if (shot) out.push(' Screenshot: ' + shot.path);
971
+ if (file && line && !translated.hideSource) {
972
+ out.push(' Source: ' + path.relative(USER_DIR, file) + ' line ' + line);
973
+ }
974
+ const technical = firstMeaningfulLine(primary.message);
975
+ if (!translated.raw && !addsNothing(technical, translated.reason)) {
976
+ out.push(' Technical detail: ' + technical);
977
+ }
978
+
979
+ out.push('');
980
+ for (const promptLine of buildPromptLines(test.title, translated, observations, lastPass)) {
981
+ out.push(promptLine);
982
+ }
983
+ out.push('');
984
+
985
+ process.stdout.write(out.join('\n') + '\n');
986
+ }
987
+
988
+ onEnd(result) {
989
+ const passed = this.records.filter(function (r) {
990
+ return r.status === 'passed';
991
+ }).length;
992
+ const failed = this.records.filter(function (r) {
993
+ return r.status === 'failed';
994
+ }).length;
995
+
996
+ const entry = {
997
+ runAt: this.startedAt.toISOString(),
998
+ status: result && result.status ? result.status : 'unknown',
999
+ durationMs: Date.now() - this.startedAt.getTime(),
1000
+ passed: passed,
1001
+ failed: failed,
1002
+ tests: this.records,
1003
+ };
1004
+
1005
+ // Append only - existing lines are never rewritten.
1006
+ let saved = false;
1007
+ try {
1008
+ fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
1009
+ fs.appendFileSync(HISTORY_FILE, JSON.stringify(entry) + '\n', 'utf8');
1010
+ saved = true;
1011
+ } catch (err) {
1012
+ process.stdout.write('\n(could not write ' + shortHome(HISTORY_FILE) + ': ' + err.message + ')\n');
1013
+ }
1014
+
1015
+ // "Broken" is kept for recordings that used to work. The ones that never
1016
+ // did are counted and explained separately, because reading "4 broken" on
1017
+ // the first day - when all four were made while learning the tool and none
1018
+ // has ever had a baseline - says the app is at fault when nothing is.
1019
+ const counts = recordings.summarise(this.records);
1020
+
1021
+ // A file that would not load is said first, and always - before the quiet
1022
+ // shortcut, and whatever else the run managed to do.
1023
+ //
1024
+ // In practice a file that will not load stops the whole run, so nothing
1025
+ // else has anything to report and the block below returns. This is not
1026
+ // written as though that were guaranteed: onError also carries errors that
1027
+ // arrive outside any test, and one of those turning up after results would
1028
+ // otherwise be dropped in silence - which is the bug this whole thing is
1029
+ // here to fix.
1030
+ const unreadable = this.loadErrors.some(function (e) { return !isNoTestsFound(e); });
1031
+ if (unreadable) {
1032
+ process.stdout.write(
1033
+ String.fromCharCode(10) + loadErrorLines(this.loadErrors, USER_DIR).join(String.fromCharCode(10)),
1034
+ );
1035
+ }
1036
+
1037
+ // Nothing ran at all. A summary of zero reads as good news, and in quiet
1038
+ // mode it would say "0 recordings still working" over the top of a file
1039
+ // that does not compile, so neither is printed. If the reason has not
1040
+ // already been given above, it is given now.
1041
+ if (!this.records.length) {
1042
+ if (!unreadable) {
1043
+ process.stdout.write(
1044
+ String.fromCharCode(10) + loadErrorLines(this.loadErrors, USER_DIR).join(String.fromCharCode(10)),
1045
+ );
1046
+ }
1047
+ return;
1048
+ }
1049
+
1050
+ if (QUIET && !counts.broken && !counts.unproven) {
1051
+ const s = counts.working === 1 ? '' : 's';
1052
+ process.stdout.write('OK ' + counts.working + ' recording' + s + ' still working.\n');
1053
+ return;
1054
+ }
1055
+
1056
+ process.stdout.write(
1057
+ '\n' + recordings.summaryLines(counts).join('\n') + '\n' +
1058
+ (saved ? 'History saved outside your project, in ' + shortHome(HISTORY_FILE) + '\n' : '') + '\n'
1059
+ );
1060
+
1061
+ // Once per run, not once per test: it is one fact about the app, and
1062
+ // repeating it under every recording would bury the reports themselves.
1063
+ if (this.sawGuessedEncoding) {
1064
+ process.stdout.write(
1065
+ 'Note: this page does not declare a character encoding, so text may look\n' +
1066
+ 'garbled here and in the browser. Add <meta charset="utf-8"> to the page.\n\n'
1067
+ );
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ module.exports = KryptheonReporter;
1073
+
1074
+ // Shared with the fixture so baselines strip hosts exactly the same way.
1075
+ // Attached to the exported class, so `new (require(...))()` keeps working.
1076
+ module.exports.requestPath = requestPath;
1077
+
1078
+ // Exported for the checks: what a file that would not load turns into.
1079
+ module.exports.readLoadError = readLoadError;
1080
+ module.exports.loadErrorLines = loadErrorLines;