kryptheon 0.1.6 → 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/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
 
@@ -595,15 +608,44 @@ function takeOutSecrets(relativeFile) {
595
608
  return writeSpec(relativeFile, result.source) ? result.replacements : [];
596
609
  }
597
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
+
598
620
  function reportSecrets(replacements) {
599
621
  if (!replacements || !replacements.length) return;
622
+ const names = replacements.map((item) => item.envName);
600
623
  console.log(' What you typed into the password box was not saved in the test.');
601
624
  console.log('');
602
- console.log(' Put it in a file called .env next to your tests, like this:');
603
- for (const item of replacements) {
604
- 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
+ }
605
642
  }
606
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('');
607
649
  console.log(' Keep .env out of version control - it holds the real value.');
608
650
  console.log('');
609
651
  }
@@ -699,6 +741,7 @@ async function finaliseRecording(outFile, context) {
699
741
  console.log('');
700
742
 
701
743
  reportSecrets(secrets);
744
+ keepArtefactsOutOfGit();
702
745
  await offerToDropLogout(named);
703
746
  reportReplayRisks(named);
704
747
 
@@ -898,6 +941,7 @@ function triageSpecs() {
898
941
  const notRecordings = [];
899
942
  const runnable = [];
900
943
  const withSecrets = [];
944
+ const missingSecrets = [];
901
945
 
902
946
  for (const name of listSpecFiles()) {
903
947
  const full = path.join(TESTS_DIR, name);
@@ -930,6 +974,18 @@ function triageSpecs() {
930
974
  if (replay.scrubSecrets(source).replacements.length) {
931
975
  withSecrets.push({ name: name, fields: replay.scrubSecrets(source).replacements });
932
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
+
933
989
  runnable.push(name);
934
990
  }
935
991
 
@@ -938,6 +994,7 @@ function triageSpecs() {
938
994
  notRecordings: notRecordings,
939
995
  runnable: runnable,
940
996
  withSecrets: withSecrets,
997
+ missingSecrets: missingSecrets,
941
998
  };
942
999
  }
943
1000
 
@@ -974,6 +1031,8 @@ function check(options) {
974
1031
  console.log('');
975
1032
  return 1;
976
1033
  }
1034
+ keepArtefactsOutOfGit();
1035
+
977
1036
  const triage = triageSpecs();
978
1037
 
979
1038
  for (const name of triage.repaired) {
@@ -989,6 +1048,14 @@ function check(options) {
989
1048
  console.log(' Re-record it to have the password moved into .env automatically.');
990
1049
  }
991
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
+
992
1059
  for (const name of triage.notRecordings) {
993
1060
  console.log('');
994
1061
  console.log(' Skipped ' + path.join('tests', name));
@@ -1001,8 +1068,13 @@ function check(options) {
1001
1068
  console.log('');
1002
1069
  console.log(' Nothing could be checked.');
1003
1070
  console.log('');
1004
- console.log(' None of the files in tests/ is a usable recording.');
1005
- 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
+ }
1006
1078
  console.log('');
1007
1079
  return 1;
1008
1080
  }
@@ -1013,10 +1085,34 @@ function check(options) {
1013
1085
  // cannot produce diagnostics can never be counted as passing.
1014
1086
  const only = triage.runnable.map((name) => path.join('tests', name).split(path.sep).join('/'));
1015
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
+
1016
1101
  const status = runPlaywright(['test', '--config', CONFIG].concat(only)).status;
1017
1102
 
1103
+ if (sink) signatures.drainSignatures(sink);
1104
+
1018
1105
  // Without this, a green summary could still hide a recording that checked
1019
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
+
1020
1116
  if (triage.notRecordings.length) {
1021
1117
  const many = triage.notRecordings.length > 1;
1022
1118
  console.log(
@@ -1025,7 +1121,9 @@ function check(options) {
1025
1121
  );
1026
1122
  console.log('');
1027
1123
  }
1028
- 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);
1029
1127
  }
1030
1128
 
1031
1129
  // --- setup-ai ---------------------------------------------------------------
@@ -1291,6 +1389,11 @@ async function main() {
1291
1389
  case 'accept':
1292
1390
  return finishWith(accept(rest.join(' ').trim()));
1293
1391
  break;
1392
+ case '-v':
1393
+ case '--version':
1394
+ case 'version':
1395
+ console.log(packageVersion());
1396
+ return finishWith(0);
1294
1397
  case undefined:
1295
1398
  case '-h':
1296
1399
  case '--help':
@@ -1308,6 +1411,7 @@ async function main() {
1308
1411
 
1309
1412
  // Exported so the naming logic can be checked without opening a browser.
1310
1413
  module.exports = {
1414
+ packageVersion: packageVersion,
1311
1415
  humanisePath: humanisePath,
1312
1416
  nameFromTitle: nameFromTitle,
1313
1417
  deriveTestName: deriveTestName,
@@ -1324,6 +1428,7 @@ module.exports = {
1324
1428
  record: record,
1325
1429
  finaliseRecording: finaliseRecording,
1326
1430
  takeOutSecrets: takeOutSecrets,
1431
+ reportSecrets: reportSecrets,
1327
1432
  reportReplayRisks: reportReplayRisks,
1328
1433
  offerToDropLogout: offerToDropLogout,
1329
1434
  noWindowLines: noWindowLines,
@@ -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
 
@@ -0,0 +1,191 @@
1
+ // Which environment variables a recording needs, and whether they are there.
2
+ //
3
+ // Why this exists. When a password is typed during recording it is taken out
4
+ // of the spec and replaced with `process.env.KRYPTHEON_PASSWORD || ''`. If
5
+ // nobody ever creates the .env file, that expression quietly evaluates to an
6
+ // empty string: the run submits a blank password, the login fails for the most
7
+ // boring reason imaginable, and the report blames the app. Two hours can go
8
+ // into that before anyone thinks to check the environment.
9
+ //
10
+ // So the names are read out of the spec before anything runs, and a recording
11
+ // whose secret is missing is not run at all - a blank password is never sent.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ // The exact shape the scrubber writes. Anything else in the file that happens
17
+ // to mention process.env is the author's own business and left alone.
18
+ const SECRET_USE = /process\.env\.([A-Z][A-Z0-9_]*)\s*\|\|\s*(['"])\2/g;
19
+
20
+ /** Every environment variable this recording falls back to an empty string for. */
21
+ function requiredSecrets(source) {
22
+ const out = [];
23
+ const seen = Object.create(null);
24
+ let match;
25
+ SECRET_USE.lastIndex = 0;
26
+ while ((match = SECRET_USE.exec(String(source || '')))) {
27
+ const name = match[1];
28
+ if (seen[name]) continue;
29
+ seen[name] = true;
30
+ out.push(name);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * Reads a .env file into a plain object. Never throws: a missing file simply
37
+ * means nothing was defined there.
38
+ *
39
+ * Values are not expanded or unescaped beyond stripping one layer of quotes -
40
+ * this only has to answer "is it set", and guessing at more would be a second
41
+ * dialect of .env for people to trip over.
42
+ */
43
+ function readEnvFile(file) {
44
+ const out = Object.create(null);
45
+ let raw;
46
+ try {
47
+ raw = fs.readFileSync(file, 'utf8');
48
+ } catch (e) {
49
+ return out;
50
+ }
51
+ // A byte order mark would otherwise become part of the first name.
52
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
53
+ for (const line of raw.split('\n')) {
54
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
55
+ if (!match) continue;
56
+ out[match[1]] = match[2].replace(/^(['"])([\s\S]*)\1$/, '$2');
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /** A value that is present and not blank. An empty variable is not set. */
62
+ function isSet(value) {
63
+ return typeof value === 'string' && value.trim() !== '';
64
+ }
65
+
66
+ /**
67
+ * Which of the names a recording needs are missing, looking at both the real
68
+ * environment and the project's .env - the same two places the test itself
69
+ * will look when it runs.
70
+ */
71
+ function missingSecrets(names, options) {
72
+ const opts = options || {};
73
+ const env = opts.env || process.env;
74
+ const fromFile = opts.envFile ? readEnvFile(opts.envFile) : {};
75
+ return (names || []).filter((name) => !isSet(env[name]) && !isSet(fromFile[name]));
76
+ }
77
+
78
+ /**
79
+ * How to create the file without a byte order mark.
80
+ *
81
+ * PowerShell's own `Out-File -Encoding utf8` writes one, and so does Notepad,
82
+ * which is why this spells out a command that does not rather than leaving it
83
+ * to whatever the person reaches for first.
84
+ */
85
+ function howToWriteEnv(names, platform) {
86
+ const line = names.map((name) => name + '=yourpassword').join('\\n');
87
+ if ((platform || process.platform) === 'win32') {
88
+ return [
89
+ ' In PowerShell, this writes it without a byte order mark:',
90
+ ' [IO.File]::WriteAllText("$PWD\\.env", "' + line + '`n")',
91
+ '',
92
+ ' Avoid Out-File and Notepad here: both add a mark that makes the first',
93
+ ' line unreadable, and the password would silently come out blank.',
94
+ ];
95
+ }
96
+ return [' For example:', " printf '" + line.replace(/\\n/g, "\\n") + "\\n' > .env"];
97
+ }
98
+
99
+ /** What to tell somebody who has not created the file yet. */
100
+ function missingSecretLines(specName, names, options) {
101
+ const opts = options || {};
102
+ const many = names.length > 1;
103
+ const lines = [
104
+ ' Skipped ' + specName,
105
+ ' This recording needs a password' + (many ? ' and other secrets' : '') + ', and there is none set.',
106
+ '',
107
+ ];
108
+ if (opts.examplePath) {
109
+ lines.push(' There is a ' + opts.examplePath + ' here already. Put the real value in it');
110
+ lines.push(' and save it as .env, in the same folder.');
111
+ } else {
112
+ lines.push(' Create a .env file in this folder containing:');
113
+ for (const name of names) lines.push(' ' + name + '=yourpassword');
114
+ }
115
+ lines.push('');
116
+ for (const line of howToWriteEnv(names, opts.platform)) lines.push(line);
117
+ lines.push('');
118
+ lines.push(' Without it the recording would sign in with a blank password and');
119
+ lines.push(' fail for a reason that has nothing to do with your app, so it was');
120
+ lines.push(' not run. Keep .env out of version control.');
121
+ return lines;
122
+ }
123
+
124
+ /**
125
+ * Writes .env.example so nobody has to get the encoding right by hand.
126
+ *
127
+ * Plain UTF-8, no byte order mark. Never overwrites: the file may already hold
128
+ * names from an earlier recording, and clobbering it would lose them.
129
+ */
130
+ function writeEnvExample(dir, names) {
131
+ const file = path.join(dir, '.env.example');
132
+ if (fs.existsSync(file)) return null;
133
+ const body = (names || []).map((name) => name + '=yourpassword').join('\n') + '\n';
134
+ try {
135
+ fs.writeFileSync(file, body, { encoding: 'utf8' });
136
+ return file;
137
+ } catch (e) {
138
+ return null; // a convenience, never a reason to fail a recording
139
+ }
140
+ }
141
+
142
+ // Everything kryptheon leaves in a project that should not be committed: the
143
+ // real password, and three files that are machine state rather than source.
144
+ const GITIGNORE_ENTRIES = ['.env', 'kryptheon-baselines.json', 'kryptheon-history.jsonl', 'test-results/'];
145
+
146
+ /**
147
+ * Adds the missing entries to an existing .gitignore. Returns what it added.
148
+ *
149
+ * Only touches a file that is already there - creating one in a project that
150
+ * has deliberately gone without is not this tool's decision to make.
151
+ */
152
+ function updateGitignore(dir, entries) {
153
+ const file = path.join(dir, '.gitignore');
154
+ let raw;
155
+ try {
156
+ raw = fs.readFileSync(file, { encoding: 'utf8' });
157
+ } catch (e) {
158
+ return [];
159
+ }
160
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
161
+ const present = new Set(
162
+ raw
163
+ .split('\n')
164
+ .map((line) => line.trim().replace(/^\/+/, '').replace(/\/+$/, ''))
165
+ .filter(Boolean),
166
+ );
167
+ const wanted = entries || GITIGNORE_ENTRIES;
168
+ const missing = wanted.filter((entry) => !present.has(entry.replace(/\/+$/, '')));
169
+ if (!missing.length) return [];
170
+ const needsNewline = raw.length && !raw.endsWith('\n');
171
+ const block = (needsNewline ? '\n' : '') + '\n# kryptheon\n' + missing.join('\n') + '\n';
172
+ try {
173
+ fs.appendFileSync(file, block, { encoding: 'utf8' });
174
+ return missing;
175
+ } catch (e) {
176
+ return [];
177
+ }
178
+ }
179
+
180
+ module.exports = {
181
+ requiredSecrets: requiredSecrets,
182
+ readEnvFile: readEnvFile,
183
+ missingSecrets: missingSecrets,
184
+ missingSecretLines: missingSecretLines,
185
+ howToWriteEnv: howToWriteEnv,
186
+ writeEnvExample: writeEnvExample,
187
+ updateGitignore: updateGitignore,
188
+ GITIGNORE_ENTRIES: GITIGNORE_ENTRIES,
189
+ isSet: isSet,
190
+ envFileFor: (dir) => path.join(dir, '.env'),
191
+ };
@@ -0,0 +1,530 @@
1
+ // A "DOM signature": a small, plain description of what a page ended up
2
+ // showing. Not a screenshot and not a pixel comparison - just the words a
3
+ // person would use to say what is on screen.
4
+ //
5
+ // Why it exists. Automatic baselines currently remember the final address and
6
+ // the page title. In a single-page app with a catch-all route those both stay
7
+ // the same when a flow breaks: a login that no longer logs anyone in still
8
+ // lands on the same URL with the same title, so the baseline sees no
9
+ // difference and the run is reported as fine. The signature is the thing that
10
+ // would have noticed - the headings, buttons, links and fields on the page
11
+ // change even when the address does not.
12
+ //
13
+ // STEP ONE ONLY. This module captures and prints. Nothing here is written to a
14
+ // baseline, compared against anything, or allowed to affect whether a test
15
+ // passes. Collection does not even run unless KRYPTHEON_DEBUG_SIGNATURE=1.
16
+
17
+ const fs = require('fs');
18
+
19
+ const MAX_TEXT = 80;
20
+ const MAX_ACTIONS = 40;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Pure helpers. No browser, no filesystem - so the unit checks can exercise
24
+ // them directly.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * One line, no digits, capped.
29
+ *
30
+ * The digits are the important part. "Savings rate 0%" and "Savings rate 12%"
31
+ * are the same page as far as this is concerned, and a signature that treated
32
+ * them as different would report a regression every time a number moved.
33
+ */
34
+ function normaliseText(value) {
35
+ const line = String(value == null ? '' : value)
36
+ .replace(/\s+/g, ' ')
37
+ .trim()
38
+ .replace(/\d+/g, '#');
39
+ return line.length > MAX_TEXT ? line.slice(0, MAX_TEXT) : line;
40
+ }
41
+
42
+ function uniqueSorted(list) {
43
+ const seen = Object.create(null);
44
+ const out = [];
45
+ for (const item of list || []) {
46
+ const text = normaliseText(item);
47
+ if (!text || seen[text]) continue;
48
+ seen[text] = true;
49
+ out.push(text);
50
+ }
51
+ return out.sort();
52
+ }
53
+
54
+ function uniqueInOrder(list) {
55
+ const seen = Object.create(null);
56
+ const out = [];
57
+ for (const item of list || []) {
58
+ const text = normaliseText(item);
59
+ if (!text || seen[text]) continue;
60
+ seen[text] = true;
61
+ out.push(text);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Path only: no host, no query string, no fragment.
68
+ *
69
+ * Deliberately not the reporter's requestPath, which keeps the query string so
70
+ * a person reading a failure can see it. A signature is stored and compared,
71
+ * and query strings routinely carry tokens, ids and search terms.
72
+ */
73
+ function signaturePath(requestUrl) {
74
+ let s = String(requestUrl == null ? '' : requestUrl);
75
+ try {
76
+ s = new URL(s).pathname || '/';
77
+ } catch (e) {
78
+ // Not absolute: strip any scheme://host by hand, then the query and hash.
79
+ s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*/, '');
80
+ const hash = s.indexOf('#');
81
+ if (hash !== -1) s = s.slice(0, hash);
82
+ const query = s.indexOf('?');
83
+ if (query !== -1) s = s.slice(0, query);
84
+ }
85
+ return s || '/';
86
+ }
87
+
88
+ /** Method, path and status. Never a query string, never a body. */
89
+ function toSignatureRequests(list) {
90
+ const seen = Object.create(null);
91
+ const out = [];
92
+ for (const req of list || []) {
93
+ const status = Number(req && req.status);
94
+ if (!(status >= 400 && status <= 599)) continue;
95
+ const entry = {
96
+ method: (req && req.method) || 'GET',
97
+ path: signaturePath((req && req.url) != null ? req.url : req && req.path),
98
+ status: status,
99
+ };
100
+ const key = entry.method + ' ' + entry.path + ' ' + entry.status;
101
+ if (seen[key]) continue;
102
+ seen[key] = true;
103
+ out.push(entry);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Assembles the signature from raw, page-shaped input. */
109
+ function buildSignature(raw) {
110
+ const input = raw || {};
111
+ return {
112
+ headings: uniqueInOrder(input.headings),
113
+ actions: uniqueSorted(input.actions).slice(0, MAX_ACTIONS),
114
+ fields: uniqueSorted(input.fields),
115
+ failedRequests: toSignatureRequests(input.failedRequests),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * One plain line saying what was on screen, for a failure report.
121
+ *
122
+ * Deliberately not the JSON. Somebody reading a failure wants "it was still on
123
+ * the sign-in page", not a structure to parse; the whole object stays behind
124
+ * the debug flag for when the detail is actually wanted.
125
+ */
126
+ function describeSignature(signature) {
127
+ if (!signature || typeof signature !== 'object') return null;
128
+ const headings = (signature.headings || []).slice(0, 2);
129
+ const actions = (signature.actions || []).slice(0, 3);
130
+ const fields = (signature.fields || []).slice(0, 3);
131
+
132
+ let what;
133
+ if (headings.length) what = quoteList(headings);
134
+ else if (fields.length) what = 'a form';
135
+ else if (actions.length) what = 'no headings';
136
+ else return null; // nothing readable was on the page
137
+
138
+ const parts = [];
139
+ if (actions.length) parts.push(actions.join(', '));
140
+ if (!headings.length && fields.length) parts.push(fields.join(', '));
141
+ return parts.length ? what + ' with ' + parts.join('; ') : what;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Comparing two signatures.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * What left and what arrived, as sets.
150
+ *
151
+ * Sets, not sequences: a page that moves its buttons around is the same page.
152
+ * Only something appearing or disappearing is worth stopping a run over.
153
+ */
154
+ function setDifference(before, after) {
155
+ const had = new Set(before || []);
156
+ const has = new Set(after || []);
157
+ return {
158
+ gone: [...had].filter((item) => !has.has(item)),
159
+ added: [...has].filter((item) => !had.has(item)),
160
+ };
161
+ }
162
+
163
+ function requestLabel(req) {
164
+ return (req.method || 'GET') + ' ' + req.path + ' ' + req.status;
165
+ }
166
+
167
+ function quoteList(items) {
168
+ return items.map((item) => '"' + item + '"').join(', ');
169
+ }
170
+
171
+ /**
172
+ * Compares a stored signature with what this run saw. Returns a message
173
+ * describing the change, or null when nothing worth failing over moved.
174
+ *
175
+ * Returns null when either side is missing. A baseline recorded before this
176
+ * feature existed has no signature and must keep working on address and title
177
+ * alone, and a run whose capture failed knows nothing - neither is evidence of
178
+ * a regression, and treating them as one would fail every old project on
179
+ * upgrade.
180
+ */
181
+ function compareSignatures(previous, current) {
182
+ if (!previous || typeof previous !== 'object') return null;
183
+ if (!current || typeof current !== 'object') return null;
184
+
185
+ const headings = setDifference(previous.headings, current.headings);
186
+ const actions = setDifference(previous.actions, current.actions);
187
+ const fields = setDifference(previous.fields, current.fields);
188
+
189
+ // Only new failures matter. A 500 that has been fixed since the baseline was
190
+ // taken is an improvement, and failing the run for it would be perverse.
191
+ const knownRequests = new Set((previous.failedRequests || []).map(requestLabel));
192
+ const newRequests = (current.failedRequests || [])
193
+ .map(requestLabel)
194
+ .filter((label) => !knownRequests.has(label));
195
+
196
+ const moved =
197
+ headings.gone.length ||
198
+ headings.added.length ||
199
+ actions.gone.length ||
200
+ actions.added.length ||
201
+ fields.gone.length ||
202
+ fields.added.length ||
203
+ newRequests.length;
204
+ if (!moved) return null;
205
+
206
+ const lines = ['Baseline changed: the page ended up somewhere different than before.'];
207
+ if (headings.gone.length) lines.push('Headings that are gone: ' + quoteList(headings.gone));
208
+ if (headings.added.length) lines.push('Headings that are new: ' + quoteList(headings.added));
209
+ if (actions.gone.length) lines.push('Buttons and links that are gone: ' + quoteList(actions.gone));
210
+ if (actions.added.length) lines.push('Buttons and links that are new: ' + quoteList(actions.added));
211
+ if (fields.gone.length) lines.push('Form fields that are gone: ' + quoteList(fields.gone));
212
+ if (fields.added.length) lines.push('Form fields that are new: ' + quoteList(fields.added));
213
+ if (newRequests.length) lines.push('Requests that failed this time: ' + newRequests.join(', '));
214
+ return lines.join('\n');
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // The browser side.
219
+ // ---------------------------------------------------------------------------
220
+
221
+ // Runs inside the page. Written as a single self-contained function because
222
+ // page.evaluate serialises it across the wire - it cannot close over anything
223
+ // in this file.
224
+ function readPage() {
225
+ const isVisible = (el) => {
226
+ if (!el) return false;
227
+ // checkVisibility covers display:none, visibility:hidden, content-visibility
228
+ // and the hidden attribute in one call where it exists.
229
+ if (typeof el.checkVisibility === 'function') {
230
+ if (!el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true })) return false;
231
+ } else {
232
+ const style = window.getComputedStyle(el);
233
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
234
+ }
235
+ const rect = el.getBoundingClientRect();
236
+ return rect.width > 0 || rect.height > 0;
237
+ };
238
+
239
+ const text = (el) => (el && el.innerText ? el.innerText : (el && el.textContent) || '');
240
+
241
+ const headings = [];
242
+ for (const el of document.querySelectorAll('h1, h2, h3')) {
243
+ if (isVisible(el)) headings.push(text(el));
244
+ }
245
+
246
+ const actions = [];
247
+ const actionSelector =
248
+ 'button, a[href], [role="button"], [role="link"], input[type="submit"], input[type="button"]';
249
+ for (const el of document.querySelectorAll(actionSelector)) {
250
+ if (!isVisible(el)) continue;
251
+ const label =
252
+ el.getAttribute('aria-label') ||
253
+ (el.tagName === 'INPUT' ? el.value : '') ||
254
+ text(el) ||
255
+ el.getAttribute('title') ||
256
+ '';
257
+ if (label) actions.push(label);
258
+ }
259
+
260
+ const fields = [];
261
+ for (const el of document.querySelectorAll('input, select, textarea')) {
262
+ const type = (el.getAttribute('type') || '').toLowerCase();
263
+ if (type === 'hidden' || type === 'submit' || type === 'button') continue;
264
+ if (!isVisible(el)) continue;
265
+ // What a person would call this field, in the order they would find it.
266
+ let label = '';
267
+ if (el.id) {
268
+ const tag = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
269
+ if (tag) label = text(tag);
270
+ }
271
+ if (!label && el.closest('label')) label = text(el.closest('label'));
272
+ if (!label) label = el.getAttribute('aria-label') || '';
273
+ if (!label) label = el.getAttribute('placeholder') || '';
274
+ if (!label) label = el.getAttribute('name') || '';
275
+ if (!label) label = el.id || '';
276
+ if (label) fields.push(label);
277
+ }
278
+
279
+ // A page that never declares its encoding is decoded by guesswork, and the
280
+ // text really does come out as mojibake - on screen and therefore in here.
281
+ // That is the app's bug, not this tool's, but it is worth saying once.
282
+ const declared = !!document.querySelector('meta[charset], meta[http-equiv="Content-Type" i]');
283
+ const guessedEncoding = !declared && String(document.characterSet || '').toUpperCase() !== 'UTF-8';
284
+
285
+ return { headings: headings, actions: actions, fields: fields, guessedEncoding: guessedEncoding };
286
+ }
287
+
288
+ /** True only for an explicit opt-in. */
289
+ function signatureEnabled(env) {
290
+ const source = env || process.env;
291
+ return String(source.KRYPTHEON_DEBUG_SIGNATURE || '') === '1';
292
+ }
293
+
294
+ // How often the page is read while waiting for it to hold still.
295
+ const SETTLE_GAP = 200;
296
+ // How long it has to hold still before the reading is believed. Measured, not
297
+ // guessed: against a page that renders 1.5s after its request answers, 700ms
298
+ // still read the screen from before the click and 1200ms read the right one.
299
+ const SETTLE_QUIET = 1200;
300
+ // Never spend longer than this on one signature, however restless the page.
301
+ const SETTLE_BUDGET = 4000;
302
+ // A pending request is worth waiting out first, but not for the whole budget.
303
+ const NETWORK_BUDGET = 1500;
304
+
305
+ /** Two readings of the same page, exactly. */
306
+ function sameSignature(a, b) {
307
+ if (!a || !b) return false;
308
+ return JSON.stringify(a) === JSON.stringify(b);
309
+ }
310
+
311
+ async function pause(page, ms) {
312
+ try {
313
+ await page.waitForTimeout(ms);
314
+ } catch (e) {
315
+ await new Promise((resolve) => setTimeout(resolve, ms));
316
+ }
317
+ }
318
+
319
+ async function readSignature(page, failedRequests) {
320
+ const raw = await page.evaluate(readPage);
321
+ const signature = buildSignature({
322
+ headings: raw.headings,
323
+ actions: raw.actions,
324
+ fields: raw.fields,
325
+ failedRequests: failedRequests,
326
+ });
327
+ // Not part of the signature itself - it describes the page's markup, not
328
+ // what the page showed, and it must never count as a difference.
329
+ Object.defineProperty(signature, 'guessedEncoding', {
330
+ value: !!raw.guessedEncoding,
331
+ enumerable: false,
332
+ });
333
+ return signature;
334
+ }
335
+
336
+ /**
337
+ * Reads the page once it has stopped changing, and returns a signature - or
338
+ * null if it could not be read at all. Never throws: this is a diagnostic and
339
+ * must not become a second failure.
340
+ *
341
+ * Why it is not just networkidle. In a single-page app a click causes no
342
+ * navigation: the request answers and the framework swaps the DOM a moment
343
+ * later. Playwright's networkidle means "no requests for 500ms", which is a
344
+ * fact about the network, not about the page - so a render that lands even
345
+ * 300ms after the response is missed entirely and the reading describes the
346
+ * screen the user was on *before* the click. That is exactly how a working
347
+ * login came back as the login form.
348
+ *
349
+ * Why it is not a fixed sleep either: whatever number is picked is too short
350
+ * for a slow machine and wasted on a fast one.
351
+ *
352
+ * So: wait out any pending request, then read the page repeatedly until it has
353
+ * held still for a while. The quiet window matters more than the gap between
354
+ * reads - two identical readings 200ms apart prove nothing on a page that is
355
+ * simply waiting, which is why stability is measured from the last change seen
356
+ * rather than from the first pair that happened to agree. A page that never
357
+ * settles is capped, and whatever it looked like at the cap is used; running
358
+ * out of patience is not a reason to fail somebody's test.
359
+ */
360
+ function positiveNumber(value, fallback) {
361
+ const n = Number(value);
362
+ return Number.isFinite(n) && n > 0 ? n : fallback;
363
+ }
364
+
365
+ /**
366
+ * The quiet window and the cap, either of which a slow app may need more of.
367
+ *
368
+ * No amount of waiting can predict a render that has not happened yet, so
369
+ * these are a judgement about how long a page is given to prove it has
370
+ * finished. An app that renders later than the window still reads early, and
371
+ * raising this is the way out of that.
372
+ */
373
+ function settleSettings(env) {
374
+ const source = env || process.env;
375
+ return {
376
+ quiet: positiveNumber(source.KRYPTHEON_SIGNATURE_QUIET_MS, SETTLE_QUIET),
377
+ budget: positiveNumber(source.KRYPTHEON_SIGNATURE_BUDGET_MS, SETTLE_BUDGET),
378
+ };
379
+ }
380
+
381
+ async function collectSignature(page, failedRequests, options) {
382
+ const opts = options || {};
383
+ const fromEnv = settleSettings(opts.env);
384
+ const gap = opts.settleGap == null ? SETTLE_GAP : opts.settleGap;
385
+ const quiet = opts.settleQuiet == null ? fromEnv.quiet : opts.settleQuiet;
386
+ const budget = opts.settleBudget == null ? fromEnv.budget : opts.settleBudget;
387
+
388
+ try {
389
+ const started = Date.now();
390
+
391
+ // An answer still in flight will change the page the moment it lands, so
392
+ // it is worth waiting out before deciding the page has stopped moving.
393
+ try {
394
+ await page.waitForLoadState('networkidle', {
395
+ timeout: Math.min(NETWORK_BUDGET, Math.max(0, budget - (Date.now() - started))),
396
+ });
397
+ } catch (e) {
398
+ /* still talking, or the state is unavailable - the loop below copes */
399
+ }
400
+
401
+ let current = await readSignature(page, failedRequests);
402
+ let lastChange = Date.now();
403
+
404
+ for (;;) {
405
+ const now = Date.now();
406
+ if (now - lastChange >= quiet) return current;
407
+ if (now - started >= budget) return current;
408
+ await pause(page, gap);
409
+ const next = await readSignature(page, failedRequests);
410
+ if (!sameSignature(current, next)) lastChange = Date.now();
411
+ current = next;
412
+ }
413
+ } catch (e) {
414
+ return null;
415
+ }
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Getting the signature out of the worker and onto the terminal.
420
+ //
421
+ // Nothing written from inside a Playwright worker reaches the terminal here.
422
+ // Playwright pipes the worker's real file descriptors and hands whatever comes
423
+ // out to reporter.onStdOut / onStdErr; kryptheon's reporter implements neither
424
+ // hook and the config runs no other reporter, so it is collected and dropped.
425
+ // Measured, not assumed: console.log, process.stdout.write, process.stderr.write
426
+ // and fs.writeSync to both fd 1 and fd 2 all vanish.
427
+ //
428
+ // So the worker hands the signature over through a file, and the CLI - whose
429
+ // stderr really is the terminal - prints it once the run is over. The reporter
430
+ // and its output format are untouched.
431
+ // ---------------------------------------------------------------------------
432
+
433
+ const SINK_ENV = 'KRYPTHEON_SIGNATURE_FILE';
434
+
435
+ /** Where the worker should leave signatures, or null if nobody asked for them. */
436
+ function sinkPath(env) {
437
+ const source = env || process.env;
438
+ const file = source[SINK_ENV];
439
+ return file ? String(file) : null;
440
+ }
441
+
442
+ /** One JSON line per test. Append-only, so parallel workers cannot interleave. */
443
+ function recordSignature(label, signature, env) {
444
+ const file = sinkPath(env);
445
+ if (!file) return false;
446
+ try {
447
+ fs.appendFileSync(file, JSON.stringify({ test: label, signature: signature }) + '\n', 'utf8');
448
+ return true;
449
+ } catch (e) {
450
+ return false; // a diagnostic must never become a second failure
451
+ }
452
+ }
453
+
454
+ /** Reads what the workers left, newest run last. Missing file means nothing ran. */
455
+ function readRecorded(file) {
456
+ let raw;
457
+ try {
458
+ raw = fs.readFileSync(file, 'utf8');
459
+ } catch (e) {
460
+ return [];
461
+ }
462
+ const out = [];
463
+ for (const line of raw.split('\n')) {
464
+ if (!line.trim()) continue;
465
+ try {
466
+ out.push(JSON.parse(line));
467
+ } catch (e) {
468
+ /* a torn line is not worth failing over */
469
+ }
470
+ }
471
+ return out;
472
+ }
473
+
474
+ /**
475
+ * Prints the signature. Step one does nothing else with it.
476
+ *
477
+ * Goes to stderr so the reporter's own output, which is stdout, stays exactly
478
+ * as it was - a person or a script reading the report sees no difference.
479
+ */
480
+ function printSignature(signature, label, out) {
481
+ const write = out || ((line) => process.stderr.write(line + '\n'));
482
+ if (!signature) {
483
+ write('[kryptheon signature] ' + label + ': could not read the page');
484
+ return;
485
+ }
486
+ write('[kryptheon signature] ' + label);
487
+ write(JSON.stringify(signature, null, 2));
488
+ }
489
+
490
+ /** Prints everything the workers recorded, then clears the file. */
491
+ function drainSignatures(file, out) {
492
+ const records = readRecorded(file);
493
+ for (const record of records) {
494
+ printSignature(record.signature, record.test, out);
495
+ }
496
+ try {
497
+ fs.rmSync(file, { force: true });
498
+ } catch (e) {
499
+ /* leftover temp file is not worth failing over */
500
+ }
501
+ return records.length;
502
+ }
503
+
504
+ module.exports = {
505
+ SINK_ENV: SINK_ENV,
506
+ sinkPath: sinkPath,
507
+ recordSignature: recordSignature,
508
+ readRecorded: readRecorded,
509
+ drainSignatures: drainSignatures,
510
+ normaliseText: normaliseText,
511
+ uniqueSorted: uniqueSorted,
512
+ uniqueInOrder: uniqueInOrder,
513
+ signaturePath: signaturePath,
514
+ toSignatureRequests: toSignatureRequests,
515
+ buildSignature: buildSignature,
516
+ compareSignatures: compareSignatures,
517
+ describeSignature: describeSignature,
518
+ setDifference: setDifference,
519
+ signatureEnabled: signatureEnabled,
520
+ collectSignature: collectSignature,
521
+ printSignature: printSignature,
522
+ readPage: readPage,
523
+ sameSignature: sameSignature,
524
+ settleSettings: settleSettings,
525
+ MAX_TEXT: MAX_TEXT,
526
+ SETTLE_GAP: SETTLE_GAP,
527
+ SETTLE_QUIET: SETTLE_QUIET,
528
+ SETTLE_BUDGET: SETTLE_BUDGET,
529
+ MAX_ACTIONS: MAX_ACTIONS,
530
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Record what you do in your app and get plain-language reports when it breaks.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -12,12 +12,16 @@
12
12
  "./kryptheon-fixture": "./kryptheon-fixture.js",
13
13
  "./kryptheon-reporter": "./kryptheon-reporter.js",
14
14
  "./package.json": "./package.json",
15
- "./kryptheon-replay": "./kryptheon-replay.js"
15
+ "./kryptheon-replay": "./kryptheon-replay.js",
16
+ "./kryptheon-signature": "./kryptheon-signature.js",
17
+ "./kryptheon-secrets": "./kryptheon-secrets.js"
16
18
  },
17
19
  "files": [
18
20
  "bin/",
19
21
  "kryptheon-fixture.js",
20
22
  "kryptheon-reporter.js",
23
+ "kryptheon-signature.js",
24
+ "kryptheon-secrets.js",
21
25
  "playwright.config.js",
22
26
  "README.md",
23
27
  "kryptheon-replay.js"
@@ -29,7 +33,11 @@
29
33
  "@playwright/test": "^1.62.1"
30
34
  },
31
35
  "scripts": {
32
- "check": "node kryptheon-reporter.check.js"
36
+ "check": "node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-step3.check.js",
37
+ "check:stream": "node kryptheon-signature.stream.check.js",
38
+ "check:baseline": "node kryptheon-signature.baseline.check.js",
39
+ "check:settle": "node kryptheon-signature.settle.check.js",
40
+ "check:step3": "node kryptheon-step3.check.js"
33
41
  },
34
42
  "keywords": [
35
43
  "playwright",
@@ -12,27 +12,27 @@ const USER_DIR = process.cwd();
12
12
  // .env is not fatal: CI can supply the same variables as real env vars.
13
13
  // loadEnvFile only exists from Node 20.12, and the package supports 20.6, so
14
14
  // fall back to a small parser rather than silently skipping the file.
15
+ // Deliberately not process.loadEnvFile, and this is the whole reason:
16
+ //
17
+ // On Windows, PowerShell's `Out-File -Encoding utf8` and Notepad's "Save as
18
+ // UTF-8" both write a byte order mark. loadEnvFile keeps it, so the first line
19
+ // of the file defines "KRYPTHEON_PASSWORD" rather than
20
+ // "KRYPTHEON_PASSWORD" - measured, not assumed. The variable then reads as
21
+ // undefined, the recording signs in with an empty password, and the login
22
+ // fails for a reason nothing in the report mentions. The file is right there,
23
+ // so nothing warns either.
24
+ //
25
+ // One parser, shared with the CLI, is also what stops the command that decides
26
+ // whether a password is present from disagreeing with the test that uses it.
15
27
  function loadEnv(file) {
16
- if (typeof process.loadEnvFile === 'function') {
17
- try {
18
- process.loadEnvFile(file);
19
- return;
20
- } catch (err) {
21
- if (err && err.code !== 'ENOENT') throw err;
22
- return;
23
- }
24
- }
25
- let raw;
26
- try {
27
- raw = require('fs').readFileSync(file, 'utf8');
28
- } catch (err) {
29
- return; // no .env is fine
30
- }
31
- for (const line of raw.split('\n')) {
32
- const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
33
- if (!match) continue;
34
- const value = match[2].replace(/^(['"])(.*)\1$/, '$2');
35
- if (process.env[match[1]] === undefined) process.env[match[1]] = value;
28
+ const secrets = require(path.join(PACKAGE_DIR, 'kryptheon-secrets.js'));
29
+ const values = secrets.readEnvFile(file);
30
+ for (const name of Object.keys(values)) {
31
+ // A real environment variable wins over the file - but only a real one.
32
+ // A variable that exists and is empty is how "unset" arrives from a shell
33
+ // or a CI job, and letting that beat the file would leave the password
34
+ // blank with the answer sitting right there on disk.
35
+ if (!secrets.isSet(process.env[name])) process.env[name] = values[name];
36
36
  }
37
37
  }
38
38