kryptheon 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.8",
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