kryptheon 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,9 +123,11 @@ Worth adding to `.gitignore`: `.env`, `kryptheon-baselines.json`,
123
123
 
124
124
  ## Requirements
125
125
 
126
- Node 20.6 or later. Recording needs a real terminal window — it opens a browser,
127
- so it cannot run inside an AI coding assistant. The first run downloads a browser
128
- (about 200MB, once).
126
+ Node 20.6 or later. The first run downloads a browser (about 200MB, once).
127
+
128
+ Recording opens a browser window, so it needs a machine with a screen. It will
129
+ always try — if no window appears within 30 seconds it stops and says so,
130
+ rather than refusing up front.
129
131
 
130
132
  ## Licence
131
133
 
package/bin/kryptheon.js CHANGED
@@ -11,6 +11,8 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const { spawn, spawnSync } = require('child_process');
14
+ const os = require('os');
15
+ const secrets = require(require('path').join(__dirname, '..', 'kryptheon-secrets.js'));
14
16
  const replay = require('../kryptheon-replay.js');
15
17
 
16
18
  const PACKAGE_DIR = path.join(__dirname, '..');
@@ -33,6 +35,16 @@ function nodeIsTooOld(version) {
33
35
  return false;
34
36
  }
35
37
 
38
+ // Read from the package rather than repeated here, so it cannot go stale.
39
+ // Someone filing a bug report has to be able to say which version they have.
40
+ function packageVersion() {
41
+ try {
42
+ return require(path.join(PACKAGE_DIR, 'package.json')).version || 'unknown';
43
+ } catch (err) {
44
+ return 'unknown';
45
+ }
46
+ }
47
+
36
48
  function usage() {
37
49
  console.log('');
38
50
  console.log(' kryptheon - record and check your app');
@@ -43,6 +55,7 @@ function usage() {
43
55
  console.log(' kryptheon setup-ai tell your AI assistant to check its work');
44
56
  console.log('');
45
57
  console.log(' add --quiet to check for one line when everything passes');
58
+ console.log(' kryptheon --version print the version you have installed');
46
59
  console.log('');
47
60
  }
48
61
 
@@ -479,23 +492,51 @@ async function nameRecording(relativeFile) {
479
492
  // Recording needs a real browser window on someone's screen. Inside an AI
480
493
  // coding assistant there is no desktop to draw it on, so codegen would sit
481
494
  // there forever and the run would be killed before anything was tidied up.
482
- function looksLikeNoDesktop() {
483
- if (process.env.KRYPTHEON_FORCE_RECORD) return false;
484
- return !process.stdout.isTTY;
495
+ // There is deliberately no up-front refusal here.
496
+ //
497
+ // This used to check process.stdout.isTTY and refuse before launching. That
498
+ // was wrong: isTTY is falsy in plenty of perfectly good terminals depending on
499
+ // how the process was started, and a real user in Command Prompt was told to
500
+ // go and run the command in Command Prompt. Refusing on a guess is far worse
501
+ // than trying and waiting - so we always launch, and the watchdog below
502
+ // reports only when a window genuinely never appears.
503
+
504
+ // Only ever shown after actually trying: the browser was launched and no
505
+ // window arrived. It must never tell someone to go and run the command they
506
+ // have plainly just run.
507
+ function noWindowLines(seconds) {
508
+ return [
509
+ '',
510
+ ' No browser window appeared.',
511
+ '',
512
+ ' The browser was started, but nothing showed up on screen within ' + seconds,
513
+ ' seconds, so there was nowhere to record.',
514
+ '',
515
+ ' The usual reasons:',
516
+ ' - there is no desktop to draw on - a remote or SSH session, a',
517
+ ' container, or an AI coding assistant running the command for you',
518
+ ' - security software stopped the browser from opening',
519
+ ' - the machine is slow and the browser had not finished starting',
520
+ '',
521
+ ' If you are sitting at the screen, run the same command again - a slow',
522
+ ' first start is the most common cause, and the second attempt usually',
523
+ ' works.',
524
+ '',
525
+ ' If you are connected to a different machine, run it on the machine',
526
+ ' that has the screen.',
527
+ '',
528
+ ' If the window really is open and this keeps happening, set',
529
+ ' KRYPTHEON_FORCE_RECORD=1 to skip this check.',
530
+ '',
531
+ ' Nothing was recorded.',
532
+ '',
533
+ ];
485
534
  }
486
535
 
487
- function explainNoWindow(url) {
488
- console.error('');
489
- console.error(' Recording needs a real browser window on your screen.');
490
- console.error('');
491
- console.error(' This terminal cannot show one - that is what happens inside an');
492
- console.error(' AI coding assistant, or any window-less session.');
493
- console.error('');
494
- console.error(' Open Command Prompt (or PowerShell) yourself and run:');
495
- console.error(' npx kryptheon record ' + url);
496
- console.error('');
497
- console.error(' Nothing was recorded.');
498
- console.error('');
536
+ function explainNoWindow() {
537
+ for (const line of noWindowLines(Math.round(WINDOW_TIMEOUT_MS / 1000))) {
538
+ console.error(line);
539
+ }
499
540
  }
500
541
 
501
542
  // Is a Playwright-controlled browser actually showing a window?
@@ -567,15 +608,44 @@ function takeOutSecrets(relativeFile) {
567
608
  return writeSpec(relativeFile, result.source) ? result.replacements : [];
568
609
  }
569
610
 
611
+ // kryptheon leaves a password file and three files of machine state in the
612
+ // project. If there is already a .gitignore, they belong in it.
613
+ function keepArtefactsOutOfGit() {
614
+ const added = secrets.updateGitignore(USER_DIR);
615
+ if (!added.length) return;
616
+ console.log(' Added to .gitignore: ' + added.join(', '));
617
+ console.log('');
618
+ }
619
+
570
620
  function reportSecrets(replacements) {
571
621
  if (!replacements || !replacements.length) return;
622
+ const names = replacements.map((item) => item.envName);
572
623
  console.log(' What you typed into the password box was not saved in the test.');
573
624
  console.log('');
574
- console.log(' Put it in a file called .env next to your tests, like this:');
575
- for (const item of replacements) {
576
- console.log(' ' + item.envName + '=your ' + String(item.field).toLowerCase() + ' here');
625
+
626
+ // Written rather than described. Asked to make the file themselves, most
627
+ // people on Windows reach for Notepad or Out-File, both of which add a byte
628
+ // order mark that makes the first line unreadable.
629
+ const hasEnv = fs.existsSync(path.join(USER_DIR, '.env'));
630
+ const example = hasEnv ? null : secrets.writeEnvExample(USER_DIR, names);
631
+ if (example) {
632
+ console.log(' A file called .env.example has been written here:');
633
+ for (const name of names) console.log(' ' + name + '=yourpassword');
634
+ console.log('');
635
+ console.log(' Put your real password in it and save it as .env, in this folder.');
636
+ } else {
637
+ console.log(' Before this can be checked, put it in a file called .env next to');
638
+ console.log(' your tests, like this:');
639
+ for (const item of replacements) {
640
+ console.log(' ' + item.envName + '=your ' + String(item.field).toLowerCase() + ' here');
641
+ }
577
642
  }
578
643
  console.log('');
644
+ // Said plainly, because the alternative is signing in with a blank password
645
+ // and spending an afternoon looking at the wrong thing.
646
+ console.log(' Until that file exists, "kryptheon check" will skip this recording');
647
+ console.log(' rather than sign in with a blank password.');
648
+ console.log('');
579
649
  console.log(' Keep .env out of version control - it holds the real value.');
580
650
  console.log('');
581
651
  }
@@ -671,6 +741,7 @@ async function finaliseRecording(outFile, context) {
671
741
  console.log('');
672
742
 
673
743
  reportSecrets(secrets);
744
+ keepArtefactsOutOfGit();
674
745
  await offerToDropLogout(named);
675
746
  reportReplayRisks(named);
676
747
 
@@ -693,7 +764,7 @@ async function finaliseRecording(outFile, context) {
693
764
  }
694
765
 
695
766
  if (ctx.reason === 'no-window') {
696
- explainNoWindow(ctx.target);
767
+ explainNoWindow();
697
768
  } else if (ctx.reason === 'signal') {
698
769
  console.log('');
699
770
  console.log(' Recording stopped before anything was saved.');
@@ -726,13 +797,6 @@ async function record(url) {
726
797
 
727
798
  const target = normaliseRecordUrl(url);
728
799
 
729
- // No desktop means no window means nothing to record. Say so now rather
730
- // than hanging until something kills us.
731
- if (looksLikeNoDesktop()) {
732
- explainNoWindow(target);
733
- return 1;
734
- }
735
-
736
800
  // Fail early and kindly rather than letting codegen throw a stack trace.
737
801
  const reach = await reachability(target);
738
802
  if (!reach.ok) {
@@ -793,8 +857,14 @@ async function record(url) {
793
857
 
794
858
  return await new Promise((resolve) => {
795
859
  // If no window ever appears, stop instead of waiting for someone to kill us.
860
+ // Someone whose window is real but undetectable can turn this off.
861
+ const watchdogOff = !!process.env.KRYPTHEON_FORCE_RECORD;
796
862
  let waited = 0;
797
863
  const watchdog = setInterval(() => {
864
+ if (watchdogOff) {
865
+ clearInterval(watchdog);
866
+ return;
867
+ }
798
868
  waited += WINDOW_POLL_MS;
799
869
  if (browserWindowIsUp()) {
800
870
  clearInterval(watchdog);
@@ -871,6 +941,7 @@ function triageSpecs() {
871
941
  const notRecordings = [];
872
942
  const runnable = [];
873
943
  const withSecrets = [];
944
+ const missingSecrets = [];
874
945
 
875
946
  for (const name of listSpecFiles()) {
876
947
  const full = path.join(TESTS_DIR, name);
@@ -903,6 +974,18 @@ function triageSpecs() {
903
974
  if (replay.scrubSecrets(source).replacements.length) {
904
975
  withSecrets.push({ name: name, fields: replay.scrubSecrets(source).replacements });
905
976
  }
977
+
978
+ // A recording that falls back to an empty password is not run at all. It
979
+ // would sign in blank, fail, and send the reader looking at their app.
980
+ const needed = secrets.requiredSecrets(source);
981
+ const absent = needed.length
982
+ ? secrets.missingSecrets(needed, { envFile: secrets.envFileFor(USER_DIR) })
983
+ : [];
984
+ if (absent.length) {
985
+ missingSecrets.push({ name: name, names: absent });
986
+ continue;
987
+ }
988
+
906
989
  runnable.push(name);
907
990
  }
908
991
 
@@ -911,6 +994,7 @@ function triageSpecs() {
911
994
  notRecordings: notRecordings,
912
995
  runnable: runnable,
913
996
  withSecrets: withSecrets,
997
+ missingSecrets: missingSecrets,
914
998
  };
915
999
  }
916
1000
 
@@ -947,6 +1031,8 @@ function check(options) {
947
1031
  console.log('');
948
1032
  return 1;
949
1033
  }
1034
+ keepArtefactsOutOfGit();
1035
+
950
1036
  const triage = triageSpecs();
951
1037
 
952
1038
  for (const name of triage.repaired) {
@@ -962,6 +1048,14 @@ function check(options) {
962
1048
  console.log(' Re-record it to have the password moved into .env automatically.');
963
1049
  }
964
1050
 
1051
+ for (const item of triage.missingSecrets) {
1052
+ console.log('');
1053
+ const example = fs.existsSync(path.join(USER_DIR, '.env.example')) ? '.env.example' : null;
1054
+ for (const line of secrets.missingSecretLines(path.join('tests', item.name), item.names, { examplePath: example })) {
1055
+ console.log(line);
1056
+ }
1057
+ }
1058
+
965
1059
  for (const name of triage.notRecordings) {
966
1060
  console.log('');
967
1061
  console.log(' Skipped ' + path.join('tests', name));
@@ -974,8 +1068,13 @@ function check(options) {
974
1068
  console.log('');
975
1069
  console.log(' Nothing could be checked.');
976
1070
  console.log('');
977
- console.log(' None of the files in tests/ is a usable recording.');
978
- console.log(' Record one with: kryptheon record <url>');
1071
+ if (triage.missingSecrets.length) {
1072
+ console.log(' Every recording here needs a password that is not set yet.');
1073
+ console.log(' Create the .env file above and run this again.');
1074
+ } else {
1075
+ console.log(' None of the files in tests/ is a usable recording.');
1076
+ console.log(' Record one with: kryptheon record <url>');
1077
+ }
979
1078
  console.log('');
980
1079
  return 1;
981
1080
  }
@@ -986,10 +1085,34 @@ function check(options) {
986
1085
  // cannot produce diagnostics can never be counted as passing.
987
1086
  const only = triage.runnable.map((name) => path.join('tests', name).split(path.sep).join('/'));
988
1087
  if (quiet) process.env.KRYPTHEON_QUIET = '1';
1088
+
1089
+ // Debug signatures. The workers cannot print: Playwright pipes their
1090
+ // descriptors and hands the output to reporter stdio hooks that this
1091
+ // reporter does not implement, so it is dropped. They write to this file
1092
+ // instead and it is printed here, on stderr, where the reporter's own
1093
+ // output on stdout is left completely alone.
1094
+ const signatures = require(path.join(PACKAGE_DIR, 'kryptheon-signature.js'));
1095
+ let sink = null;
1096
+ if (signatures.signatureEnabled()) {
1097
+ sink = path.join(os.tmpdir(), 'kryptheon-signature-' + process.pid + '.jsonl');
1098
+ process.env[signatures.SINK_ENV] = sink;
1099
+ }
1100
+
989
1101
  const status = runPlaywright(['test', '--config', CONFIG].concat(only)).status;
990
1102
 
1103
+ if (sink) signatures.drainSignatures(sink);
1104
+
991
1105
  // Without this, a green summary could still hide a recording that checked
992
1106
  // nothing at all.
1107
+ if (triage.missingSecrets.length) {
1108
+ const many = triage.missingSecrets.length > 1;
1109
+ console.log(
1110
+ ' Note: ' + triage.missingSecrets.length + ' recording' + (many ? 's were' : ' was') +
1111
+ ' skipped because a password is not set.'
1112
+ );
1113
+ console.log('');
1114
+ }
1115
+
993
1116
  if (triage.notRecordings.length) {
994
1117
  const many = triage.notRecordings.length > 1;
995
1118
  console.log(
@@ -998,7 +1121,9 @@ function check(options) {
998
1121
  );
999
1122
  console.log('');
1000
1123
  }
1001
- return status;
1124
+ // Skipped for a missing password is not a pass. The fix is one file away,
1125
+ // and reporting zero would let it be mistaken for a clean run.
1126
+ return status || (triage.missingSecrets.length ? 1 : 0);
1002
1127
  }
1003
1128
 
1004
1129
  // --- setup-ai ---------------------------------------------------------------
@@ -1264,6 +1389,11 @@ async function main() {
1264
1389
  case 'accept':
1265
1390
  return finishWith(accept(rest.join(' ').trim()));
1266
1391
  break;
1392
+ case '-v':
1393
+ case '--version':
1394
+ case 'version':
1395
+ console.log(packageVersion());
1396
+ return finishWith(0);
1267
1397
  case undefined:
1268
1398
  case '-h':
1269
1399
  case '--help':
@@ -1281,6 +1411,7 @@ async function main() {
1281
1411
 
1282
1412
  // Exported so the naming logic can be checked without opening a browser.
1283
1413
  module.exports = {
1414
+ packageVersion: packageVersion,
1284
1415
  humanisePath: humanisePath,
1285
1416
  nameFromTitle: nameFromTitle,
1286
1417
  deriveTestName: deriveTestName,
@@ -1297,9 +1428,10 @@ module.exports = {
1297
1428
  record: record,
1298
1429
  finaliseRecording: finaliseRecording,
1299
1430
  takeOutSecrets: takeOutSecrets,
1431
+ reportSecrets: reportSecrets,
1300
1432
  reportReplayRisks: reportReplayRisks,
1301
1433
  offerToDropLogout: offerToDropLogout,
1302
- looksLikeNoDesktop: looksLikeNoDesktop,
1434
+ noWindowLines: noWindowLines,
1303
1435
  classifyFetchError: classifyFetchError,
1304
1436
  normaliseRecordUrl: normaliseRecordUrl,
1305
1437
  isLocalAddress: isLocalAddress,
@@ -20,6 +20,7 @@ const base = require('@playwright/test');
20
20
  // Reuse the reporter's host-stripping so a baseline stores exactly the shape
21
21
  // the reporter prints.
22
22
  const { requestPath } = require('./kryptheon-reporter.js');
23
+ const signature = require('./kryptheon-signature.js');
23
24
 
24
25
  const MAX_ITEMS = 5; // keep the failure block readable
25
26
 
@@ -53,10 +54,14 @@ function baselineKey(specFile, title) {
53
54
  function readBaselines(file) {
54
55
  let raw;
55
56
  try {
56
- raw = fs.readFileSync(file, 'utf8');
57
+ raw = fs.readFileSync(file, { encoding: 'utf8' });
57
58
  } catch (e) {
58
59
  return {};
59
60
  }
61
+ // An editor that saved the file with a byte order mark would otherwise make
62
+ // JSON.parse throw, and every baseline would silently vanish - which reads as
63
+ // "all clear" on the next run rather than as the problem it is.
64
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
60
65
  let parsed;
61
66
  try {
62
67
  parsed = JSON.parse(raw);
@@ -69,7 +74,9 @@ function readBaselines(file) {
69
74
 
70
75
  function saveBaselines(file, all) {
71
76
  try {
72
- fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n', 'utf8');
77
+ // Explicit, and without a byte order mark: this file is read back by the
78
+ // same code and by people, and a mismatch would look like the app changed.
79
+ fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n', { encoding: 'utf8' });
73
80
  return true;
74
81
  } catch (e) {
75
82
  return false; // diagnostics must never become a second failure
@@ -112,12 +119,16 @@ function toStoredErrors(list) {
112
119
  }
113
120
 
114
121
  function normalisedEntry(current) {
115
- return {
122
+ const stored = {
116
123
  url: current.url,
117
124
  title: current.title,
118
125
  consoleErrors: toStoredErrors(current.consoleErrors),
119
126
  failedRequests: toStoredRequests(current.failedRequests),
120
127
  };
128
+ // Only when there is one. A run whose capture failed must not write an empty
129
+ // signature over a good one, or the next run would report everything as gone.
130
+ if (current.signature) stored.signature = current.signature;
131
+ return stored;
121
132
  }
122
133
 
123
134
  function writeBaseline(file, key, entry) {
@@ -170,12 +181,17 @@ function normaliseTitle(value) {
170
181
  return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
171
182
  }
172
183
 
173
- // Only the address and title decide pass or fail. Browser noise changing is
174
- // not on its own a regression.
184
+ // The address, the title, and what the page ended up showing decide pass or
185
+ // fail. Browser noise changing is not on its own a regression.
175
186
  function compareBaselines(previous, current) {
176
187
  const urlChanged = normaliseUrl(previous.url) !== normaliseUrl(current.url);
177
188
  const titleChanged = normaliseTitle(previous.title) !== normaliseTitle(current.title);
178
- if (!urlChanged && !titleChanged) return null;
189
+ if (!urlChanged && !titleChanged) {
190
+ // Nothing moved in the address bar. This is the case the address and title
191
+ // alone cannot see: a single-page app with a catch-all route lands on the
192
+ // same URL under the same title whether the flow worked or not.
193
+ return signature.compareSignatures(previous.signature, current.signature);
194
+ }
179
195
 
180
196
  const what =
181
197
  urlChanged && titleChanged ? 'the page address and title changed'
@@ -213,6 +229,17 @@ function newObservations(current, previous) {
213
229
  };
214
230
  }
215
231
 
232
+ // Adds a signature to an accepted baseline that predates the feature, without
233
+ // touching anything else about it - including when it was recorded, which is
234
+ // what the failure report means by "this last worked at".
235
+ function upgradeBaseline(file, key, sig) {
236
+ const all = readBaselines(file);
237
+ const entry = all[key];
238
+ if (!entry || typeof entry !== 'object' || entry.signature || !sig) return false;
239
+ entry.signature = sig;
240
+ return saveBaselines(file, all);
241
+ }
242
+
216
243
  // The whole decision in one call: create on first sight, otherwise compare.
217
244
  function applyBaseline(file, key, current) {
218
245
  const previous = readBaselines(file)[key];
@@ -221,7 +248,16 @@ function applyBaseline(file, key, current) {
221
248
  return { status: 'created', message: null };
222
249
  }
223
250
  const message = compareBaselines(previous, current);
224
- return message ? { status: 'changed', message: message } : { status: 'match', message: null };
251
+ if (message) return { status: 'changed', message: message };
252
+
253
+ // A baseline from before signatures existed passes on address and title, and
254
+ // learns its signature here. Doing it on a pass rather than up front means an
255
+ // upgraded project never fails on its first run under the new version.
256
+ if (!previous.signature && current.signature) {
257
+ upgradeBaseline(file, key, current.signature);
258
+ return { status: 'learned', message: null };
259
+ }
260
+ return { status: 'match', message: null };
225
261
  }
226
262
 
227
263
  // ---------------------------------------------------------------------------
@@ -261,6 +297,9 @@ const test = base.test.extend({
261
297
  await testInfo.attach('kryptheon-observations', {
262
298
  body: JSON.stringify({
263
299
  url: url,
300
+ // What was actually on screen when it gave up. On a flow that
301
+ // stopped half way this is the most useful line in the report.
302
+ pageShowed: signature.describeSignature(captured),
264
303
  failedRequests: fresh.failedRequests.slice(0, MAX_ITEMS),
265
304
  consoleErrors: fresh.consoleErrors.slice(0, MAX_ITEMS),
266
305
  }),
@@ -273,6 +312,29 @@ const test = base.test.extend({
273
312
 
274
313
  await use(page);
275
314
 
315
+ // Read the page before anything else looks at the outcome. A test that
316
+ // failed is exactly the one where knowing what was on screen matters most,
317
+ // so this runs whether it passed or not. It is null when the page could
318
+ // not be read at all, and a null is never treated as a difference - not
319
+ // knowing what the page showed is not evidence that it changed.
320
+ //
321
+ // Printed only when asked for, and handed to the CLI through a file:
322
+ // Playwright pipes this worker's descriptors and gives whatever comes out
323
+ // to reporter stdio hooks that this reporter does not implement, so
324
+ // anything printed from here is dropped before it reaches a terminal.
325
+ const captured = await signature.collectSignature(page, failedRequests);
326
+ if (signature.signatureEnabled()) signature.recordSignature(testInfo.title, captured);
327
+
328
+ // Said once per run by the reporter, not once per test: it is a fact about
329
+ // the app, and repeating it for every recording would be noise.
330
+ if (captured && captured.guessedEncoding) {
331
+ try {
332
+ await testInfo.attach('kryptheon-encoding', { body: 'guessed', contentType: 'text/plain' });
333
+ } catch (e) {
334
+ /* a note, never a second failure */
335
+ }
336
+ }
337
+
276
338
  // The test's own assertions decide first. A test that already failed keeps
277
339
  // its own error, and never contributes a baseline.
278
340
  if (testInfo.status !== testInfo.expectedStatus) {
@@ -280,7 +342,7 @@ const test = base.test.extend({
280
342
  return;
281
343
  }
282
344
 
283
- // Passed: capture what the page ended up as, noise included.
345
+ // Passed: keep what the page ended up as, noise included.
284
346
  let current = null;
285
347
  try {
286
348
  current = {
@@ -288,6 +350,7 @@ const test = base.test.extend({
288
350
  title: normaliseTitle(await page.title()),
289
351
  consoleErrors: consoleErrors,
290
352
  failedRequests: failedRequests,
353
+ signature: captured,
291
354
  };
292
355
  } catch (e) {
293
356
  current = null; // page closed by the test - leave any baseline untouched
@@ -320,5 +383,6 @@ module.exports = {
320
383
  normaliseTitle: normaliseTitle,
321
384
  compareBaselines: compareBaselines,
322
385
  applyBaseline: applyBaseline,
386
+ upgradeBaseline: upgradeBaseline,
323
387
  BASELINE_FILE: BASELINE_FILE,
324
388
  };
@@ -189,8 +189,11 @@ function translate(message) {
189
189
  return l.indexOf('Baseline changed:') !== -1;
190
190
  });
191
191
  const head = lines[headIndex].replace(/^.*Baseline changed:\s*/, '');
192
+ // An allowlist, so a stack trace that happens to follow the message can
193
+ // never leak into the report. Address and title come from the original
194
+ // baseline; the rest name what the page stopped or started showing.
192
195
  const details = lines.slice(headIndex + 1).filter(function (l) {
193
- return /^(Address|Title) (was|now):/.test(l);
196
+ 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);
194
197
  });
195
198
  return {
196
199
  reason: capitalise(head),
@@ -406,6 +409,9 @@ function whereToLookLines(obs) {
406
409
  const lines = [];
407
410
 
408
411
  if (obs.url) lines.push(' - Browser was on: ' + obs.url);
412
+ // Where the flow actually got to, in words. The whole signature stays behind
413
+ // KRYPTHEON_DEBUG_SIGNATURE for when that much detail is wanted.
414
+ if (obs.pageShowed) lines.push(' - Page showed: ' + obs.pageShowed);
409
415
 
410
416
  for (const req of uniq(obs.failedRequests).slice(0, 5)) {
411
417
  const meaning = describeStatus(req.status);
@@ -568,6 +574,8 @@ class KryptheonReporter {
568
574
  this.previousRuns = [];
569
575
  this.records = [];
570
576
  this.startedAt = new Date();
577
+ // Set by any test whose page never said what encoding it was in.
578
+ this.sawGuessedEncoding = false;
571
579
  }
572
580
 
573
581
  onBegin() {
@@ -624,6 +632,9 @@ class KryptheonReporter {
624
632
  }
625
633
 
626
634
  onTestEnd(test, result) {
635
+ if ((result.attachments || []).some(function (a) { return a.name === 'kryptheon-encoding'; })) {
636
+ this.sawGuessedEncoding = true;
637
+ }
627
638
  const status =
628
639
  result.status === 'passed' ? 'passed' : result.status === 'skipped' ? 'skipped' : 'failed';
629
640
  const timestamp = (result.startTime instanceof Date ? result.startTime : new Date()).toISOString();
@@ -787,6 +798,15 @@ class KryptheonReporter {
787
798
  '\nSummary: ' + passed + ' working, ' + failed + ' broken.\n' +
788
799
  'History saved to ' + path.basename(HISTORY_FILE) + '\n\n'
789
800
  );
801
+
802
+ // Once per run, not once per test: it is one fact about the app, and
803
+ // repeating it under every recording would bury the reports themselves.
804
+ if (this.sawGuessedEncoding) {
805
+ process.stdout.write(
806
+ 'Note: this page does not declare a character encoding, so text may look\n' +
807
+ 'garbled here and in the browser. Add <meta charset="utf-8"> to the page.\n\n'
808
+ );
809
+ }
790
810
  }
791
811
  }
792
812