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,1672 @@
1
+ #!/usr/bin/env node
2
+ // The kryptheon CLI. A thin wrapper so nobody has to remember Playwright's
3
+ // command line or edit a config file. Everything here shells out to the
4
+ // Playwright binary that ships with this package.
5
+ //
6
+ // Two roots matter, and they are not the same once this is installed:
7
+ // PACKAGE_DIR - the tool's own files (fixture, reporter, config, this file)
8
+ // USER_DIR - the folder the command was run in, which owns tests/,
9
+ // kryptheon-baselines.json, kryptheon-history.jsonl and .env
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { spawn, spawnSync } = require('child_process');
14
+ const os = require('os');
15
+ const secrets = require(require('path').join(__dirname, '..', 'kryptheon-secrets.js'));
16
+ const replay = require('../kryptheon-replay.js');
17
+
18
+ const PACKAGE_DIR = path.join(__dirname, '..');
19
+ const USER_DIR = process.cwd();
20
+ const CONFIG = path.join(PACKAGE_DIR, 'playwright.config.js');
21
+ const TESTS_DIR = path.join(USER_DIR, 'tests');
22
+
23
+ const MIN_NODE = [20, 6, 0];
24
+
25
+ // How long to wait for a browser window to appear before giving up.
26
+ //
27
+ // The budget is spent only on time we could actually see. A probe that does
28
+ // not answer tells us nothing about the browser, so it must not count against
29
+ // the person running the command - and the hard limit is there so that a probe
30
+ // which never answers still ends the wait rather than hanging forever.
31
+ //
32
+ // Measured on a warm machine, launch to a detectable window is about 2.5s, so
33
+ // 30s is generous; the number that used to matter more was how the waiting was
34
+ // counted, which was by ticks rather than by the clock.
35
+ const WINDOW_POLL_MS = 5000;
36
+ const WINDOW_TIMEOUT_MS = 30000;
37
+ const WINDOW_HARD_LIMIT_MS = 90000;
38
+ // Lower than the poll interval on purpose: a probe still running when the next
39
+ // one is due is of no use, and every extra second it holds the loop is a second
40
+ // the watchdog is blind.
41
+ const WINDOW_PROBE_TIMEOUT_MS = 4000;
42
+
43
+ function nodeIsTooOld(version) {
44
+ const parts = String(version || process.versions.node).split('.').map(Number);
45
+ for (let i = 0; i < MIN_NODE.length; i++) {
46
+ if ((parts[i] || 0) > MIN_NODE[i]) return false;
47
+ if ((parts[i] || 0) < MIN_NODE[i]) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ // Read from the package rather than repeated here, so it cannot go stale.
53
+ // Someone filing a bug report has to be able to say which version they have.
54
+ function packageVersion() {
55
+ try {
56
+ return require(path.join(PACKAGE_DIR, 'package.json')).version || 'unknown';
57
+ } catch (err) {
58
+ return 'unknown';
59
+ }
60
+ }
61
+
62
+ function usage() {
63
+ console.log('');
64
+ console.log(' kryptheon - record and check your app');
65
+ console.log('');
66
+ console.log(' kryptheon record <url> open your app and record what you do as a test');
67
+ console.log(' kryptheon check run every recorded test and report in plain language');
68
+ console.log(' kryptheon accept <name> agree that one test\'s new result is the correct one');
69
+ console.log(' kryptheon setup-ai tell your AI assistant to check its work');
70
+ console.log('');
71
+ console.log(' add --quiet to check for one line when everything passes');
72
+ console.log(' kryptheon --version print the version you have installed');
73
+ console.log('');
74
+ }
75
+
76
+ // Resolve Playwright's own CLI and run it on this Node binary, rather than
77
+ // relying on a `playwright` executable being on PATH.
78
+ // The package's "exports" map exposes "./cli" (no .js), so that specifier is
79
+ // the one that resolves; the package.json route is the fallback.
80
+ function findPlaywrightCli() {
81
+ try {
82
+ return require.resolve('@playwright/test/cli', { paths: [PACKAGE_DIR] });
83
+ } catch (err) {
84
+ /* fall through */
85
+ }
86
+ try {
87
+ const pkg = require.resolve('@playwright/test/package.json', { paths: [PACKAGE_DIR] });
88
+ return path.join(path.dirname(pkg), 'cli.js');
89
+ } catch (err) {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ // Always runs with the user's folder as the working directory, so tests,
95
+ // baselines and .env are found where they actually live.
96
+ // Returns { status, stderr }. With quietErrors the child's stderr is captured
97
+ // rather than inherited, so a Node or Playwright stack trace never reaches the
98
+ // person running the command.
99
+ function runPlaywright(args, options) {
100
+ const quiet = !!(options && options.quietErrors);
101
+ const cli = findPlaywrightCli();
102
+ if (!cli) {
103
+ console.error('');
104
+ console.error(' The testing engine is missing from this install.');
105
+ console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
106
+ console.error('');
107
+ return { status: 1, stderr: '' };
108
+ }
109
+
110
+ const result = spawnSync(process.execPath, [cli].concat(args), {
111
+ cwd: USER_DIR,
112
+ stdio: quiet ? ['inherit', 'inherit', 'pipe'] : 'inherit',
113
+ encoding: 'utf8',
114
+ });
115
+
116
+ if (result.error) {
117
+ console.error('');
118
+ console.error(' Could not start the testing engine: ' + result.error.message);
119
+ console.error('');
120
+ return { status: 1, stderr: String(result.stderr || '') };
121
+ }
122
+ return {
123
+ status: result.status === null ? 1 : result.status,
124
+ stderr: String(result.stderr || ''),
125
+ };
126
+ }
127
+
128
+ // Chromium is a separate download from the npm package, so the first run on a
129
+ // new machine has to fetch it.
130
+ function ensureBrowser() {
131
+ let executable = null;
132
+ try {
133
+ executable = require('@playwright/test').chromium.executablePath();
134
+ } catch (err) {
135
+ return true; // cannot tell - let Playwright speak for itself
136
+ }
137
+ if (executable && fs.existsSync(executable)) return true;
138
+
139
+ console.log('');
140
+ console.log(' Downloading a browser to run your app in.');
141
+ console.log(' This is about 200MB and only happens once.');
142
+ console.log('');
143
+ const status = runPlaywright(['install', 'chromium']).status;
144
+ if (status !== 0) {
145
+ console.error('');
146
+ console.error(' The browser download did not finish.');
147
+ console.error(' Check your internet connection and try again.');
148
+ console.error('');
149
+ return false;
150
+ }
151
+ return true;
152
+ }
153
+
154
+ function listSpecFiles() {
155
+ if (!fs.existsSync(TESTS_DIR)) return [];
156
+ let entries = [];
157
+ try {
158
+ entries = fs.readdirSync(TESTS_DIR, { recursive: true });
159
+ } catch (err) {
160
+ try {
161
+ entries = fs.readdirSync(TESTS_DIR);
162
+ } catch (err2) {
163
+ return [];
164
+ }
165
+ }
166
+ return entries
167
+ .map(String)
168
+ .filter((name) => /\.(spec|test)\.(c|m)?[jt]sx?$/.test(name));
169
+ }
170
+
171
+ // A plain GET on Node's own http/https, deliberately NOT fetch. fetch keeps
172
+ // its sockets alive in a pool, and those were still open at exit - handles
173
+ // being torn down while the process is ending is how libuv assertions happen
174
+ // on Windows. agent:false closes the socket as soon as we are done with it.
175
+ function httpGet(url, timeoutMs, redirectsLeft) {
176
+ return new Promise((resolve, reject) => {
177
+ let parsed;
178
+ try {
179
+ parsed = new URL(url);
180
+ } catch (err) {
181
+ return reject(Object.assign(new Error('bad address'), { code: 'ERR_INVALID_URL' }));
182
+ }
183
+ const lib = parsed.protocol === 'https:' ? require('https') : require('http');
184
+ const req = lib.get(url, { agent: false, timeout: timeoutMs || 8000 }, (res) => {
185
+ const location = res.headers && res.headers.location;
186
+ if (location && res.statusCode >= 300 && res.statusCode < 400 && (redirectsLeft || 0) > 0) {
187
+ res.resume();
188
+ req.destroy();
189
+ return resolve(httpGet(new URL(location, url).toString(), timeoutMs, redirectsLeft - 1));
190
+ }
191
+ let body = '';
192
+ res.setEncoding('utf8');
193
+ res.on('data', (chunk) => {
194
+ if (body.length < 300000) body += chunk;
195
+ });
196
+ res.on('end', () => {
197
+ req.destroy();
198
+ resolve({ status: res.statusCode, body: body });
199
+ });
200
+ });
201
+ req.on('timeout', () => {
202
+ req.destroy(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }));
203
+ });
204
+ req.on('error', reject);
205
+ });
206
+ }
207
+
208
+ // --- is the address actually there? ----------------------------------------
209
+
210
+ // "localhost:3000" is what people type; give it a scheme before using it.
211
+ function normaliseRecordUrl(url) {
212
+ const s = String(url == null ? '' : url).trim();
213
+ if (!s) return s;
214
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) return s;
215
+ return 'http://' + s;
216
+ }
217
+
218
+ function isLocalAddress(url) {
219
+ let host;
220
+ try {
221
+ host = new URL(url).hostname.toLowerCase();
222
+ } catch (err) {
223
+ return false;
224
+ }
225
+ return (
226
+ host === 'localhost' ||
227
+ host === '127.0.0.1' ||
228
+ host === '0.0.0.0' ||
229
+ host === '::1' ||
230
+ host === '[::1]' ||
231
+ host.endsWith('.local') ||
232
+ host.endsWith('.localhost')
233
+ );
234
+ }
235
+
236
+ // Turns a fetch failure into one of a few plain kinds. Split out from the
237
+ // network call so it can be checked without a network.
238
+ function classifyFetchError(err) {
239
+ const code = (err && err.cause && err.cause.code) || (err && err.code) || null;
240
+ if (code === 'ECONNREFUSED') return 'refused';
241
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'unknown-address';
242
+ if (err && err.name === 'AbortError') return 'timeout';
243
+ if (code === 'ETIMEDOUT' || code === 'ECONNRESET') return 'timeout';
244
+ // A certificate complaint means something IS answering there. Let the
245
+ // browser deal with it rather than blocking the recording.
246
+ if (code && /CERT|SSL|SELF_SIGNED|VERIFY/i.test(String(code))) return 'reachable';
247
+ return 'other';
248
+ }
249
+
250
+ async function reachability(url, timeoutMs) {
251
+ try {
252
+ await httpGet(url, timeoutMs || 8000, 0);
253
+ return { ok: true, kind: 'reachable' };
254
+ } catch (err) {
255
+ const kind = classifyFetchError(err);
256
+ return kind === 'reachable' ? { ok: true, kind: kind } : { ok: false, kind: kind };
257
+ }
258
+ }
259
+
260
+ // One place that explains an address not answering, used both before codegen
261
+ // starts and if codegen itself reports the same thing.
262
+ function explainUnreachable(url, kind) {
263
+ const local = isLocalAddress(url);
264
+ console.error('');
265
+ console.error(' Nothing answered at ' + url);
266
+ console.error('');
267
+
268
+ if (kind === 'unknown-address') {
269
+ console.error(' That web address could not be found.');
270
+ console.error(' Check the spelling, and that you are connected to the internet.');
271
+ } else if (kind === 'timeout') {
272
+ console.error(' The address did not answer in time.');
273
+ console.error(' It may be slow, or blocked by a firewall or VPN.');
274
+ } else if (local) {
275
+ console.error(' Your app does not look like it is running.');
276
+ console.error('');
277
+ console.error(' For an app on your own machine, start it first - usually:');
278
+ console.error(' npm run dev');
279
+ console.error('');
280
+ console.error(' Then check the address and port match what it printed.');
281
+ } else {
282
+ console.error(' Nothing is listening at that address.');
283
+ console.error(' Check the address, including the port number, and that the site is up.');
284
+ }
285
+
286
+ console.error('');
287
+ console.error(' Nothing was recorded.');
288
+ console.error('');
289
+ }
290
+
291
+ // Codegen writes Node/Playwright traces to stderr. We never show those; we
292
+ // pick out the part that means something and say it in plain words.
293
+ /**
294
+ * The lines codegen wrote that look like a real complaint, shown verbatim.
295
+ *
296
+ * Playwright's stderr also carries banners and progress noise, so only lines
297
+ * that read as an error are lifted out - a wall of unrelated output would be
298
+ * worse than none. Nothing is invented here: if it said nothing, nothing is
299
+ * printed and the usual explanation follows.
300
+ */
301
+ function codegenComplaints(stderr, limit) {
302
+ const seen = Object.create(null);
303
+ const out = [];
304
+ for (const raw of String(stderr || '').split('\n')) {
305
+ const line = raw.replace(/\s+/g, ' ').trim();
306
+ if (!line) continue;
307
+ if (!/(^|\b)(Error|error:|ERR_|EACCES|EPERM|ENOENT|Failed|failed to|Timeout|not found)/.test(line)) continue;
308
+ if (seen[line]) continue;
309
+ seen[line] = true;
310
+ out.push(line.length > 160 ? line.slice(0, 157) + '...' : line);
311
+ if (out.length >= (limit || 3)) break;
312
+ }
313
+ return out;
314
+ }
315
+
316
+ function reportCodegenSaid(stderr) {
317
+ const said = codegenComplaints(stderr);
318
+ if (!said.length) return false;
319
+ console.error('');
320
+ console.error(' The browser reported:');
321
+ for (const line of said) console.error(' ' + line);
322
+ return true;
323
+ }
324
+
325
+ function explainCodegenFailure(url, stderr) {
326
+ const text = String(stderr || '');
327
+ if (/ERR_CONNECTION_REFUSED/.test(text)) return explainUnreachable(url, 'refused');
328
+ if (/ERR_NAME_NOT_RESOLVED/.test(text)) return explainUnreachable(url, 'unknown-address');
329
+ if (/ERR_CONNECTION_TIMED_OUT|ERR_TIMED_OUT/.test(text)) return explainUnreachable(url, 'timeout');
330
+ if (/ERR_INTERNET_DISCONNECTED/.test(text)) {
331
+ console.error('');
332
+ console.error(' There is no internet connection.');
333
+ console.error('');
334
+ console.error(' Nothing was recorded.');
335
+ console.error('');
336
+ return;
337
+ }
338
+ console.error('');
339
+ console.error(' The browser closed before anything could be recorded.');
340
+ console.error('');
341
+ console.error(' Check that ' + url + ' opens normally in your own browser,');
342
+ console.error(' then try again.');
343
+ console.error('');
344
+ console.error(' Nothing was recorded.');
345
+ console.error('');
346
+ }
347
+
348
+ // A recording is only worth keeping if the browser actually did something.
349
+ // An abandoned or failed run leaves a test body with no page calls at all.
350
+ function countRecordedActions(source) {
351
+ const found = String(source || '').match(/\bpage\s*\.\s*[A-Za-z_$][\w$]*\s*\(/g);
352
+ return found ? found.length : 0;
353
+ }
354
+
355
+ // A spec that pulls Playwright in directly gets none of our diagnostics.
356
+ function importsPlaywrightDirectly(source) {
357
+ const text = String(source || '');
358
+ // The literal specifier is what matters; import and require both carry it.
359
+ return text.indexOf("'@playwright/test'") !== -1 || text.indexOf('"@playwright/test"') !== -1;
360
+ }
361
+
362
+ // codegen always writes the opening page.goto. A recording is only real if
363
+ // something else happened as well.
364
+ function isRealRecording(source) {
365
+ return countRecordedActions(source) >= 2;
366
+ }
367
+
368
+ function timestampName() {
369
+ const d = new Date();
370
+ const pad = (n) => String(n).padStart(2, '0');
371
+ return (
372
+ 'recorded-' +
373
+ d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) +
374
+ '-' + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) +
375
+ '.spec.js'
376
+ );
377
+ }
378
+
379
+ // Codegen imports from '@playwright/test'. Point the new file at the shared
380
+ // fixture instead, so a recorded test gets the same failure diagnostics
381
+ // (failed requests, console errors) as every other spec. Imported by package
382
+ // name, because the user's tests folder is not next to the installed package.
383
+ function pointAtFixture(relativeFile) {
384
+ const full = path.join(USER_DIR, relativeFile);
385
+ let src;
386
+ try {
387
+ src = fs.readFileSync(full, 'utf8');
388
+ } catch (err) {
389
+ return false;
390
+ }
391
+ const updated = src
392
+ .replace(/from (['"])@playwright\/test\1/, "from 'kryptheon/kryptheon-fixture'")
393
+ .replace(/require\((['"])@playwright\/test\1\)/, "require('kryptheon/kryptheon-fixture')");
394
+ if (updated === src) return false;
395
+ try {
396
+ fs.writeFileSync(full, updated, 'utf8');
397
+ return true;
398
+ } catch (err) {
399
+ return false;
400
+ }
401
+ }
402
+
403
+ // "about-us.html" -> "About Us"; "/" -> null
404
+ function humanisePath(pathname) {
405
+ const last = String(pathname || '')
406
+ .split('?')[0]
407
+ .split('#')[0]
408
+ .split('/')
409
+ .filter(Boolean)
410
+ .pop();
411
+ if (!last) return null;
412
+ const words = last
413
+ .replace(/\.[a-z0-9]+$/i, '')
414
+ .replace(/[-_+]+/g, ' ')
415
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
416
+ .trim();
417
+ if (!words) return null;
418
+ return words
419
+ .split(/\s+/)
420
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
421
+ .join(' ');
422
+ }
423
+
424
+ // Titles are usually "Site | Page". The page half is the useful one, and a
425
+ // very long tail is prose rather than a name, so it is rejected.
426
+ function nameFromTitle(title) {
427
+ const parts = String(title || '')
428
+ .split(/[|–—•:]|\s-\s/)
429
+ .map((p) => p.replace(/\s+/g, ' ').trim())
430
+ .filter(Boolean);
431
+ if (!parts.length) return null;
432
+ const tail = parts[parts.length - 1];
433
+ if (tail && tail.split(' ').length <= 4) return tail;
434
+ return null;
435
+ }
436
+
437
+ // Reads the title of a page over plain HTTP. Best effort: the name falls back
438
+ // to the recorded path if this cannot be reached.
439
+ async function fetchTitle(url) {
440
+ try {
441
+ const res = await httpGet(url, 5000, 2);
442
+ if (!res || res.status < 200 || res.status >= 300) return null;
443
+ const m = String(res.body).match(/<title[^>]*>([\s\S]*?)<\/title>/i);
444
+ return m ? m[1].replace(/\s+/g, ' ').trim() : null;
445
+ } catch (err) {
446
+ return null;
447
+ }
448
+ }
449
+
450
+ function lastGoto(source) {
451
+ const gotos = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
452
+ return gotos.length ? gotos[gotos.length - 1] : null;
453
+ }
454
+
455
+ // Codegen calls every recording "test". Give it a name taken from what was
456
+ // actually recorded: the last page navigated to, else the last thing clicked.
457
+ function deriveTestName(source) {
458
+ const gotos = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
459
+ for (let i = gotos.length - 1; i >= 0; i--) {
460
+ let pathname = gotos[i];
461
+ try {
462
+ pathname = new URL(gotos[i]).pathname;
463
+ } catch (e) {
464
+ /* already a path */
465
+ }
466
+ const name = humanisePath(pathname);
467
+ if (name) return name;
468
+ }
469
+
470
+ // The first thing done describes a recording better than the last, and the
471
+ // last is often "Sign Out", which would name every recording after the way
472
+ // it ended rather than what it was for.
473
+ const names = [...String(source).matchAll(/name:\s*['"]([^'"]+)['"]/g)]
474
+ .map((m) => m[1].trim())
475
+ .filter((name) => name && !/(log|sign)[\s_-]?out/i.test(name));
476
+ if (names.length) return names[0];
477
+
478
+ const hosts = [...String(source).matchAll(/page\.goto\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
479
+ if (hosts.length) {
480
+ try {
481
+ const host = new URL(hosts[hosts.length - 1]).hostname.replace(/^www\./, '');
482
+ const label = humanisePath('/' + host.split('.')[0]);
483
+ if (label) return label;
484
+ } catch (e) {
485
+ /* fall through */
486
+ }
487
+ }
488
+ return null;
489
+ }
490
+
491
+ function slugify(name) {
492
+ return String(name)
493
+ .toLowerCase()
494
+ .replace(/[^a-z0-9]+/g, '-')
495
+ .replace(/^-+|-+$/g, '') || 'recording';
496
+ }
497
+
498
+ // Renames the test inside the file, and the file to match. Only ever touches
499
+ // the file codegen just wrote.
500
+ async function nameRecording(relativeFile) {
501
+ const full = path.join(USER_DIR, relativeFile);
502
+ let src;
503
+ try {
504
+ src = fs.readFileSync(full, 'utf8');
505
+ } catch (err) {
506
+ return relativeFile;
507
+ }
508
+ // Only rename codegen's placeholder, never a name someone chose.
509
+ if (!/\btest\(\s*(['"])test\1/.test(src)) return relativeFile;
510
+
511
+ // The page title is the friendliest source; the recorded path is the
512
+ // fallback when the site cannot be reached.
513
+ const visited = lastGoto(src);
514
+ const name = (visited ? nameFromTitle(await fetchTitle(visited)) : null) || deriveTestName(src);
515
+ if (!name) return relativeFile;
516
+
517
+ const safeName = name.replace(/'/g, "\\'");
518
+ const updated = src.replace(/\btest\(\s*(['"])test\1/, "test('" + safeName + "'");
519
+
520
+ let target = path.join('tests', slugify(name) + '.spec.js');
521
+ let attempt = 1;
522
+ while (fs.existsSync(path.join(USER_DIR, target)) && path.join(USER_DIR, target) !== full) {
523
+ attempt += 1;
524
+ target = path.join('tests', slugify(name) + '-' + attempt + '.spec.js');
525
+ }
526
+
527
+ try {
528
+ fs.writeFileSync(full, updated, 'utf8');
529
+ if (path.join(USER_DIR, target) !== full) {
530
+ fs.renameSync(full, path.join(USER_DIR, target));
531
+ return target;
532
+ }
533
+ } catch (err) {
534
+ return relativeFile;
535
+ }
536
+ return target;
537
+ }
538
+ // Recording needs a real browser window on someone's screen. Inside an AI
539
+ // coding assistant there is no desktop to draw it on, so codegen would sit
540
+ // there forever and the run would be killed before anything was tidied up.
541
+ // There is deliberately no up-front refusal here.
542
+ //
543
+ // This used to check process.stdout.isTTY and refuse before launching. That
544
+ // was wrong: isTTY is falsy in plenty of perfectly good terminals depending on
545
+ // how the process was started, and a real user in Command Prompt was told to
546
+ // go and run the command in Command Prompt. Refusing on a guess is far worse
547
+ // than trying and waiting - so we always launch, and the watchdog below
548
+ // reports only when a window genuinely never appears.
549
+
550
+ // Only ever shown after actually trying: the browser was launched and no
551
+ // window arrived. It must never tell someone to go and run the command they
552
+ // have plainly just run.
553
+ function noWindowLines(seconds) {
554
+ return [
555
+ '',
556
+ ' No browser window appeared.',
557
+ '',
558
+ ' The browser was started, but nothing showed up on screen within ' + seconds,
559
+ ' seconds, so there was nowhere to record.',
560
+ '',
561
+ ' The usual reasons:',
562
+ ' - there is no desktop to draw on - a remote or SSH session, a',
563
+ ' container, or an AI coding assistant running the command for you',
564
+ ' - security software stopped the browser from opening',
565
+ ' - the machine is slow and the browser had not finished starting',
566
+ '',
567
+ ' If you are sitting at the screen, run the same command again - a slow',
568
+ ' first start is the most common cause, and the second attempt usually',
569
+ ' works.',
570
+ '',
571
+ ' If you are connected to a different machine, run it on the machine',
572
+ ' that has the screen.',
573
+ '',
574
+ ' If the window really is open and this keeps happening, set',
575
+ ' KRYPTHEON_FORCE_RECORD=1 to skip this check.',
576
+ '',
577
+ ' Nothing was recorded.',
578
+ '',
579
+ ];
580
+ }
581
+
582
+ function explainNoWindow() {
583
+ for (const line of noWindowLines(Math.round(WINDOW_TIMEOUT_MS / 1000))) {
584
+ console.error(line);
585
+ }
586
+ }
587
+
588
+ // The browser is there and running; only the window could not be seen. Telling
589
+ // this person their browser never opened would be plainly wrong, so the way
590
+ // past the check comes first rather than buried at the bottom of a list.
591
+ function windowUndetectedLines(seconds) {
592
+ return [
593
+ '',
594
+ ' The browser is running, but no window could be seen.',
595
+ '',
596
+ ' A browser process started and was still going after ' + seconds + ' seconds,',
597
+ ' so this is most likely the check being wrong rather than the browser.',
598
+ '',
599
+ ' If the window is open in front of you, run it again with the check off:',
600
+ ' KRYPTHEON_FORCE_RECORD=1 kryptheon record <url>',
601
+ '',
602
+ ' If there is no window on screen, the browser may have opened somewhere',
603
+ ' with no desktop to draw on - a remote session, a container, or an AI',
604
+ ' assistant running the command for you.',
605
+ '',
606
+ ' Nothing was recorded.',
607
+ '',
608
+ ];
609
+ }
610
+
611
+ // The probe kept failing to answer. Nothing was learned about the browser
612
+ // either way, and saying "no window appeared" would be inventing a fact.
613
+ function probeUnclearLines(answered, unanswered) {
614
+ return [
615
+ '',
616
+ ' Could not tell whether a browser window opened.',
617
+ '',
618
+ ' The check that looks for the window did not answer ' + unanswered + ' of its',
619
+ ' ' + (answered + unanswered) + ' attempts, so there is nothing to go on. This is usually a',
620
+ ' machine under load, or security software holding up the check itself.',
621
+ '',
622
+ ' If the window is open in front of you, run it again with the check off:',
623
+ ' KRYPTHEON_FORCE_RECORD=1 kryptheon record <url>',
624
+ '',
625
+ ' Otherwise just run the same command again.',
626
+ '',
627
+ ' Nothing was recorded.',
628
+ '',
629
+ ];
630
+ }
631
+
632
+ /**
633
+ * Decides, tick by tick, whether to keep waiting for the browser window.
634
+ *
635
+ * Kept apart from the timer and the process so it can be driven directly: the
636
+ * cases worth checking are a probe that answers slowly, one that never answers,
637
+ * and a browser that runs without a window, none of which are reachable by
638
+ * launching a real browser and hoping.
639
+ *
640
+ * Returns null to keep waiting, or a verdict:
641
+ * 'window' - a window is up, stop watching
642
+ * 'no-window' - the browser ran for the whole budget without one
643
+ * 'never-started' - no browser process ever appeared
644
+ * 'unclear' - the probe mostly did not answer, so nothing is known
645
+ */
646
+ function createWindowWatch(options) {
647
+ const opts = options || {};
648
+ const probe = opts.probe || inspectBrowserWindows;
649
+ const clock = opts.now || Date.now;
650
+ const budgetMs = opts.budgetMs == null ? WINDOW_TIMEOUT_MS : opts.budgetMs;
651
+ const hardLimitMs = opts.hardLimitMs == null ? WINDOW_HARD_LIMIT_MS : opts.hardLimitMs;
652
+
653
+ const started = clock();
654
+ let lastTick = started;
655
+ // Time we could actually see, in milliseconds. Counted from the clock rather
656
+ // than by adding the poll interval per tick: a probe that takes four seconds
657
+ // used to cost the budget three, so "thirty seconds" was never thirty.
658
+ let spent = 0;
659
+ let answered = 0;
660
+ let unanswered = 0;
661
+ let sawProcess = false;
662
+
663
+ return {
664
+ tick: function () {
665
+ const now = clock();
666
+ const elapsed = now - lastTick;
667
+ lastTick = now;
668
+
669
+ const seen = probe();
670
+ if (seen && seen.known) {
671
+ answered++;
672
+ if (seen.windows > 0) return { verdict: 'window' };
673
+ if (seen.processes > 0) sawProcess = true;
674
+ spent += elapsed;
675
+ } else {
676
+ // Nothing was learned, so nothing is charged.
677
+ unanswered++;
678
+ }
679
+
680
+ const outOfBudget = spent >= budgetMs;
681
+ const outOfPatience = now - started >= hardLimitMs;
682
+ if (!outOfBudget && !outOfPatience) return null;
683
+
684
+ if (unanswered > answered) {
685
+ return { verdict: 'unclear', answered: answered, unanswered: unanswered };
686
+ }
687
+ return {
688
+ verdict: sawProcess ? 'no-window' : 'never-started',
689
+ seconds: Math.round((now - started) / 1000),
690
+ };
691
+ },
692
+ stats: function () {
693
+ return { spent: spent, answered: answered, unanswered: unanswered, sawProcess: sawProcess };
694
+ },
695
+ };
696
+ }
697
+
698
+ // What can be seen of the browser right now.
699
+ //
700
+ // known - whether the question could be answered at all
701
+ // processes - Playwright browser processes running
702
+ // windows - how many of those are actually showing a window
703
+ //
704
+ // The three are kept apart on purpose. The old version returned one boolean,
705
+ // so "there is no window" and "the probe did not answer" were the same value,
706
+ // and a slow machine could be told its browser had never opened. They are
707
+ // different facts and they deserve different answers.
708
+ //
709
+ // One command returns both counts: asking twice would double the cost of the
710
+ // most expensive thing in this loop.
711
+ /**
712
+ * Turns the probe's two numbers into the shape the watchdog reads.
713
+ *
714
+ * Anything that is not two numbers means the answer did not arrive intact, and
715
+ * an answer that did not arrive must never read as "nothing is running" - that
716
+ * conflation is the whole bug this replaced.
717
+ */
718
+ function parseWindowProbe(stdout) {
719
+ const unknown = { known: false, processes: 0, windows: 0 };
720
+ const parts = String(stdout == null ? '' : stdout).trim().split(/\s+/);
721
+ if (parts.length < 2) return unknown;
722
+ const processes = Number(parts[0]);
723
+ const windows = Number(parts[1]);
724
+ if (!Number.isFinite(processes) || !Number.isFinite(windows)) return unknown;
725
+ if (processes < 0 || windows < 0 || windows > processes) return unknown;
726
+ return { known: true, processes: processes, windows: windows };
727
+ }
728
+
729
+ function inspectBrowserWindows() {
730
+ const unknown = { known: false, processes: 0, windows: 0 };
731
+ try {
732
+ if (process.platform === 'win32') {
733
+ const probe = spawnSync(
734
+ 'powershell',
735
+ [
736
+ '-NoProfile',
737
+ '-Command',
738
+ "$p = @(Get-Process chrome,msedge -ErrorAction SilentlyContinue | " +
739
+ "Where-Object { $_.Path -like '*ms-playwright*' }); " +
740
+ "$w = @($p | Where-Object { $_.MainWindowHandle -ne 0 }); " +
741
+ 'Write-Output ("{0} {1}" -f $p.Count, $w.Count)',
742
+ ],
743
+ { encoding: 'utf8', windowsHide: true, timeout: WINDOW_PROBE_TIMEOUT_MS }
744
+ );
745
+ // A timeout, a missing shell, or a non-zero exit all mean the same thing:
746
+ // nothing was learned. Only a well formed answer counts as knowing.
747
+ if (probe.error || probe.status !== 0) return unknown;
748
+ return parseWindowProbe(probe.stdout);
749
+ }
750
+
751
+ const probe = spawnSync('ps', ['-A', '-o', 'command'], {
752
+ encoding: 'utf8',
753
+ timeout: WINDOW_PROBE_TIMEOUT_MS,
754
+ });
755
+ if (probe.error || probe.status !== 0) return unknown;
756
+ const running = String(probe.stdout || '')
757
+ .split('\n')
758
+ .filter(function (line) {
759
+ return line.indexOf('ms-playwright') !== -1;
760
+ }).length;
761
+ // ps cannot see windows, so a running browser is taken at its word here.
762
+ return { known: true, processes: running, windows: running };
763
+ } catch (err) {
764
+ return unknown;
765
+ }
766
+ }
767
+
768
+ // Runs codegen without blocking the event loop, so SIGINT and SIGTERM can
769
+ // still be handled. spawnSync would freeze the loop and no handler would run.
770
+ function startCodegen(args) {
771
+ const cli = findPlaywrightCli();
772
+ if (!cli) {
773
+ console.error('');
774
+ console.error(' The testing engine is missing from this install.');
775
+ console.error(' Reinstalling usually fixes it: npm install -g kryptheon');
776
+ console.error('');
777
+ return null;
778
+ }
779
+ return spawn(process.execPath, [cli].concat(args), {
780
+ cwd: USER_DIR,
781
+ stdio: ['inherit', 'inherit', 'pipe'],
782
+ });
783
+ }
784
+
785
+ // --- making a recording fit to run twice ------------------------------------
786
+
787
+ function readSpec(relativeFile) {
788
+ try {
789
+ return fs.readFileSync(path.join(USER_DIR, relativeFile), 'utf8');
790
+ } catch (err) {
791
+ return null;
792
+ }
793
+ }
794
+
795
+ function writeSpec(relativeFile, source) {
796
+ try {
797
+ fs.writeFileSync(path.join(USER_DIR, relativeFile), source, 'utf8');
798
+ return true;
799
+ } catch (err) {
800
+ return false;
801
+ }
802
+ }
803
+
804
+ // Passwords typed during recording land in the file as plain text. Swap them
805
+ // for environment variables before the file is left lying around.
806
+ function takeOutSecrets(relativeFile) {
807
+ const source = readSpec(relativeFile);
808
+ if (source === null) return [];
809
+ const result = replay.scrubSecrets(source);
810
+ if (!result.replacements.length) return [];
811
+ return writeSpec(relativeFile, result.source) ? result.replacements : [];
812
+ }
813
+
814
+ // kryptheon leaves a password file and three files of machine state in the
815
+ // project. If there is already a .gitignore, they belong in it.
816
+ function keepArtefactsOutOfGit() {
817
+ const added = secrets.updateGitignore(USER_DIR);
818
+ if (!added.length) return;
819
+ console.log(' Added to .gitignore: ' + added.join(', '));
820
+ console.log('');
821
+ }
822
+
823
+ function reportSecrets(replacements) {
824
+ if (!replacements || !replacements.length) return;
825
+ const names = replacements.map((item) => item.envName);
826
+ console.log(' What you typed into the password box was not saved in the test.');
827
+ console.log('');
828
+
829
+ // Written rather than described. Asked to make the file themselves, most
830
+ // people on Windows reach for Notepad or Out-File, both of which add a byte
831
+ // order mark that makes the first line unreadable.
832
+ const hasEnv = fs.existsSync(path.join(USER_DIR, '.env'));
833
+ const example = hasEnv ? null : secrets.writeEnvExample(USER_DIR, names);
834
+ if (example) {
835
+ console.log(' A file called .env.example has been written here:');
836
+ for (const name of names) console.log(' ' + name + '=yourpassword');
837
+ console.log('');
838
+ console.log(' Put your real password in it and save it as .env, in this folder.');
839
+ } else {
840
+ console.log(' Before this can be checked, put it in a file called .env next to');
841
+ console.log(' your tests, like this:');
842
+ for (const item of replacements) {
843
+ console.log(' ' + item.envName + '=your ' + String(item.field).toLowerCase() + ' here');
844
+ }
845
+ }
846
+ console.log('');
847
+ // Said plainly, because the alternative is signing in with a blank password
848
+ // and spending an afternoon looking at the wrong thing.
849
+ console.log(' Until that file exists, "kryptheon check" will skip this recording');
850
+ console.log(' rather than sign in with a blank password.');
851
+ console.log('');
852
+ console.log(' Keep .env out of version control - it holds the real value.');
853
+ console.log('');
854
+ }
855
+
856
+ function askYesNo(question) {
857
+ return new Promise((resolve) => {
858
+ if (!process.stdin.isTTY) return resolve(false);
859
+ const readline = require('readline');
860
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
861
+ rl.question(question, (answer) => {
862
+ rl.close();
863
+ // Let the loop finish later: stdin stays open otherwise.
864
+ try {
865
+ process.stdin.pause();
866
+ } catch (err) {
867
+ /* nothing to do */
868
+ }
869
+ resolve(!/^\s*n/i.test(String(answer)));
870
+ });
871
+ });
872
+ }
873
+
874
+ // Ending on a logout means the next run starts somewhere else. Almost nobody
875
+ // wants that, so ask.
876
+ async function offerToDropLogout(relativeFile) {
877
+ const source = readSpec(relativeFile);
878
+ if (source === null) return false;
879
+ const trimmed = replay.dropTrailingLogout(source);
880
+ if (!trimmed.removed) return false;
881
+
882
+ console.log(' The recording ends by logging out ("' + trimmed.removed + '").');
883
+ console.log(' That means the next run would not start where this one did.');
884
+ console.log('');
885
+
886
+ const drop = await askYesNo(' Leave that last step out? [Y/n] ');
887
+ if (!drop) {
888
+ console.log('');
889
+ console.log(' Kept it. If checks fail from the second run onwards, this is why.');
890
+ console.log('');
891
+ return false;
892
+ }
893
+ writeSpec(relativeFile, trimmed.source);
894
+ console.log('');
895
+ console.log(' Removed the sign-out step.');
896
+ console.log('');
897
+ return true;
898
+ }
899
+
900
+ function reportReplayRisks(relativeFile) {
901
+ const source = readSpec(relativeFile);
902
+ if (source === null) return [];
903
+ const risks = replay.findReplayRisks(source);
904
+ if (!risks.length) return [];
905
+
906
+ console.log(' Before you rely on this recording, one thing to know:');
907
+ console.log('');
908
+ for (const risk of risks) {
909
+ console.log(' "' + risk.step + '" (line ' + risk.line + ')');
910
+ console.log(' ' + risk.why + '.');
911
+ console.log(' What to do: ' + risk.fix + '.');
912
+ console.log('');
913
+ }
914
+ console.log(' Everything else in the recording will run again fine.');
915
+ console.log('');
916
+ return risks;
917
+ }
918
+
919
+ // Everything that has to happen to whatever codegen left behind, however
920
+ // the run ended. Module level so it can be exercised directly.
921
+ async function finaliseRecording(outFile, context) {
922
+ const ctx = context || {};
923
+ const specPath = path.join(USER_DIR, outFile);
924
+ let source = null;
925
+ try {
926
+ source = fs.readFileSync(specPath, 'utf8');
927
+ } catch (err) {
928
+ source = null;
929
+ }
930
+
931
+ if (source !== null && isRealRecording(source)) {
932
+ // Import rewrite first: it is instant, so even a second interruption
933
+ // leaves behind a spec that works.
934
+ pointAtFixture(outFile);
935
+
936
+ // Anything typed into a password box is still a literal in this file at
937
+ // this point. Take it out before doing anything else with the file.
938
+ const secrets = takeOutSecrets(outFile);
939
+
940
+ const named = await nameRecording(outFile);
941
+ console.log('');
942
+ console.log(' Saved to ' + named);
943
+ console.log(' Run it any time with: kryptheon check');
944
+ console.log('');
945
+
946
+ reportSecrets(secrets);
947
+ keepArtefactsOutOfGit();
948
+ await offerToDropLogout(named);
949
+ reportReplayRisks(named);
950
+
951
+ return { code: 0, savedAs: named };
952
+ }
953
+
954
+ if (source !== null) {
955
+ try {
956
+ fs.unlinkSync(specPath);
957
+ } catch (err) {
958
+ /* nothing more we can do */
959
+ }
960
+ }
961
+ if (!ctx.hadTestsDir) {
962
+ try {
963
+ if (fs.readdirSync(TESTS_DIR).length === 0) fs.rmdirSync(TESTS_DIR);
964
+ } catch (err) {
965
+ /* only tidying up */
966
+ }
967
+ }
968
+
969
+ if (ctx.reason === 'no-window' || ctx.reason === 'never-started' || ctx.reason === 'unclear') {
970
+ // What the browser itself said comes first. It used to be read only when
971
+ // codegen exited on its own, so on this path a real error - a bad address,
972
+ // a refused connection - was captured and then thrown away, and the reader
973
+ // got a list of guesses instead of the answer.
974
+ reportCodegenSaid(ctx.stderr);
975
+ const detail = ctx.detail || {};
976
+ if (ctx.reason === 'unclear') {
977
+ for (const line of probeUnclearLines(detail.answered || 0, detail.unanswered || 0)) {
978
+ console.error(line);
979
+ }
980
+ } else if (ctx.reason === 'no-window') {
981
+ for (const line of windowUndetectedLines(detail.seconds || Math.round(WINDOW_TIMEOUT_MS / 1000))) {
982
+ console.error(line);
983
+ }
984
+ } else {
985
+ explainNoWindow();
986
+ }
987
+ } else if (ctx.reason === 'signal') {
988
+ console.log('');
989
+ console.log(' Recording stopped before anything was saved.');
990
+ console.log('');
991
+ console.log(' Nothing was recorded.');
992
+ console.log('');
993
+ } else if (source === null || /ERR_|Error:/.test(String(ctx.stderr || ''))) {
994
+ explainCodegenFailure(ctx.target, ctx.stderr);
995
+ } else {
996
+ console.log('');
997
+ console.log(' Nothing was recorded.');
998
+ console.log('');
999
+ console.log(' The browser closed before anything was clicked or typed.');
1000
+ console.log(' Run the same command again and use your app before closing it.');
1001
+ console.log('');
1002
+ }
1003
+ return { code: 1, savedAs: null };
1004
+ }
1005
+
1006
+ async function record(url) {
1007
+ if (!url) {
1008
+ console.error('');
1009
+ console.error(' Which address should I open?');
1010
+ console.error('');
1011
+ console.error(' Add the web address of your app, for example:');
1012
+ console.error(' kryptheon record https://www.example.com');
1013
+ console.error('');
1014
+ return 1;
1015
+ }
1016
+
1017
+ const target = normaliseRecordUrl(url);
1018
+
1019
+ // Fail early and kindly rather than letting codegen throw a stack trace.
1020
+ const reach = await reachability(target);
1021
+ if (!reach.ok) {
1022
+ explainUnreachable(target, reach.kind);
1023
+ return 1;
1024
+ }
1025
+
1026
+ if (!ensureBrowser()) return 1;
1027
+
1028
+ const hadTestsDir = fs.existsSync(TESTS_DIR);
1029
+ try {
1030
+ fs.mkdirSync(TESTS_DIR, { recursive: true });
1031
+ } catch (err) {
1032
+ /* codegen will report if it cannot write */
1033
+ }
1034
+
1035
+ const outFile = path.join('tests', timestampName());
1036
+ const specPath = path.join(USER_DIR, outFile);
1037
+
1038
+ console.log('');
1039
+ console.log(' Opening ' + target + ' in a browser.');
1040
+ console.log('');
1041
+ console.log(' Use your app normally - click, type, sign in, whatever you want');
1042
+ console.log(' covered. Every step is recorded as you go.');
1043
+ console.log('');
1044
+ console.log(' When you are done, close the browser window to save the test.');
1045
+ console.log('');
1046
+
1047
+ const child = startCodegen(['codegen', '--target', 'playwright-test', '-o', outFile, target]);
1048
+ if (!child) return 1;
1049
+
1050
+ let stderr = '';
1051
+ if (child.stderr) child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
1052
+
1053
+ // Everything that has to happen no matter how this ends: tidy the file,
1054
+ // wire it up, name it. Runs once, whether codegen exits on its own or we
1055
+ // are interrupted.
1056
+ let finished = false;
1057
+ const finalise = async (reason, detail) => {
1058
+ if (finished) return 1;
1059
+ finished = true;
1060
+ const outcome = await finaliseRecording(outFile, {
1061
+ hadTestsDir: hadTestsDir,
1062
+ target: target,
1063
+ detail: detail || null,
1064
+ stderr: stderr,
1065
+ reason: reason,
1066
+ });
1067
+ return outcome.code;
1068
+ };
1069
+
1070
+ const stopChild = () => {
1071
+ try {
1072
+ child.kill();
1073
+ } catch (err) {
1074
+ /* already gone */
1075
+ }
1076
+ };
1077
+
1078
+ return await new Promise((resolve) => {
1079
+ // If no window ever appears, stop instead of waiting for someone to kill us.
1080
+ // Someone whose window is real but undetectable can turn this off.
1081
+ const watchdogOff = !!process.env.KRYPTHEON_FORCE_RECORD;
1082
+ const watch = createWindowWatch();
1083
+ const watchdog = setInterval(() => {
1084
+ if (watchdogOff) {
1085
+ clearInterval(watchdog);
1086
+ return;
1087
+ }
1088
+ // A child that has already gone is the close handler's business, not
1089
+ // this one's: it knows the exit code and what was written.
1090
+ if (child.exitCode !== null || child.signalCode !== null) {
1091
+ clearInterval(watchdog);
1092
+ return;
1093
+ }
1094
+ const outcome = watch.tick();
1095
+ if (!outcome) return;
1096
+ clearInterval(watchdog);
1097
+ if (outcome.verdict === 'window') return;
1098
+ stopChild();
1099
+ finalise(outcome.verdict, outcome).then(resolve);
1100
+ }, WINDOW_POLL_MS);
1101
+
1102
+ const onSignal = () => {
1103
+ clearInterval(watchdog);
1104
+ stopChild();
1105
+ finalise('signal').then(resolve);
1106
+ };
1107
+ process.once('SIGINT', onSignal);
1108
+ process.once('SIGTERM', onSignal);
1109
+ process.once('SIGHUP', onSignal);
1110
+
1111
+ child.on('error', () => {
1112
+ clearInterval(watchdog);
1113
+ finalise('error').then(resolve);
1114
+ });
1115
+
1116
+ child.on('close', () => {
1117
+ clearInterval(watchdog);
1118
+ process.removeListener('SIGINT', onSignal);
1119
+ process.removeListener('SIGTERM', onSignal);
1120
+ process.removeListener('SIGHUP', onSignal);
1121
+ finalise('exit').then(resolve);
1122
+ });
1123
+ });
1124
+ }
1125
+
1126
+ // Recordings import the fixture by package name, which only resolves if
1127
+ // kryptheon is in the user's own node_modules. Running through a bare `npx`
1128
+ // puts the package somewhere the tests cannot see.
1129
+ // Deliberately a filesystem walk, not require.resolve: this file lives inside
1130
+ // the kryptheon package, and a package with a name and an "exports" map can
1131
+ // always resolve itself by name, so require.resolve would answer "yes" even
1132
+ // when the user's tests have no way to find it.
1133
+ function fixtureResolvesForUser() {
1134
+ let dir = USER_DIR;
1135
+ for (;;) {
1136
+ if (fs.existsSync(path.join(dir, 'node_modules', 'kryptheon', 'kryptheon-fixture.js'))) {
1137
+ return true;
1138
+ }
1139
+ const parent = path.dirname(dir);
1140
+ if (parent === dir) return false;
1141
+ dir = parent;
1142
+ }
1143
+ }
1144
+
1145
+ // Only a problem if a recording actually asks for it: specs written against a
1146
+ // relative path (as in this repo) resolve on their own.
1147
+ function specsNeedThePackage() {
1148
+ return listSpecFiles().some(function (name) {
1149
+ try {
1150
+ return fs.readFileSync(path.join(TESTS_DIR, name), 'utf8').indexOf('kryptheon/kryptheon-fixture') !== -1;
1151
+ } catch (err) {
1152
+ return false;
1153
+ }
1154
+ });
1155
+ }
1156
+
1157
+ // Older versions, and any run that was killed part way, left specs behind that
1158
+ // import Playwright directly (so they produce no diagnostics) or that contain
1159
+ // nothing but the opening navigation (so they verify nothing). Repair the
1160
+ // first kind and refuse to run the second, rather than reporting a false OK.
1161
+ function triageSpecs() {
1162
+ const repaired = [];
1163
+ const notRecordings = [];
1164
+ const runnable = [];
1165
+ const withSecrets = [];
1166
+ const missingSecrets = [];
1167
+
1168
+ for (const name of listSpecFiles()) {
1169
+ const full = path.join(TESTS_DIR, name);
1170
+ let source;
1171
+ try {
1172
+ source = fs.readFileSync(full, 'utf8');
1173
+ } catch (err) {
1174
+ continue;
1175
+ }
1176
+
1177
+ if (/(from|require\()\s*(['"])@playwright\/test\2/.test(source)) {
1178
+ const relative = path.join('tests', name);
1179
+ if (pointAtFixture(relative)) {
1180
+ repaired.push(name);
1181
+ try {
1182
+ source = fs.readFileSync(full, 'utf8');
1183
+ } catch (err) {
1184
+ /* keep what we had */
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ if (!isRealRecording(source)) {
1190
+ notRecordings.push(name);
1191
+ continue;
1192
+ }
1193
+ // Recordings made before secrets were stripped still hold the real value.
1194
+ // Say so rather than rewriting: a spec that works today should keep
1195
+ // working until the person has somewhere to put the password.
1196
+ if (replay.scrubSecrets(source).replacements.length) {
1197
+ withSecrets.push({ name: name, fields: replay.scrubSecrets(source).replacements });
1198
+ }
1199
+
1200
+ // A recording that falls back to an empty password is not run at all. It
1201
+ // would sign in blank, fail, and send the reader looking at their app.
1202
+ const needed = secrets.requiredSecrets(source);
1203
+ const absent = needed.length
1204
+ ? secrets.missingSecrets(needed, { envFile: secrets.envFileFor(USER_DIR) })
1205
+ : [];
1206
+ if (absent.length) {
1207
+ missingSecrets.push({ name: name, names: absent });
1208
+ continue;
1209
+ }
1210
+
1211
+ runnable.push(name);
1212
+ }
1213
+
1214
+ return {
1215
+ repaired: repaired,
1216
+ notRecordings: notRecordings,
1217
+ runnable: runnable,
1218
+ withSecrets: withSecrets,
1219
+ missingSecrets: missingSecrets,
1220
+ };
1221
+ }
1222
+
1223
+ function check(options) {
1224
+ const quiet = !!(options && options.quiet);
1225
+ // Deliberately not a failure. An assistant told to run this after every
1226
+ // change will read a non-zero exit as "something broke" and start fixing
1227
+ // code that is perfectly fine. Nothing has been recorded, so there is
1228
+ // nothing to verify and nothing to repair.
1229
+ if (!listSpecFiles().length) {
1230
+ console.log('');
1231
+ console.log(' Nothing has been recorded yet, so there is nothing to check.');
1232
+ console.log('');
1233
+ console.log(' This is not a failure, and there is nothing in the code to fix.');
1234
+ console.log('');
1235
+ console.log(' To start, record a flow through the app:');
1236
+ console.log(' npx kryptheon record <url>');
1237
+ console.log('');
1238
+ console.log(' That opens a browser window, so it has to be run by a person in');
1239
+ console.log(' a normal terminal - it cannot be done from an AI assistant.');
1240
+ console.log('');
1241
+ return 0;
1242
+ }
1243
+ if (!fixtureResolvesForUser() && specsNeedThePackage()) {
1244
+ console.log('');
1245
+ console.log(' Your recordings cannot find kryptheon.');
1246
+ console.log('');
1247
+ console.log(' They are saved in this folder, but kryptheon itself is installed');
1248
+ console.log(' somewhere else, so they have nothing to load. Add it here:');
1249
+ console.log('');
1250
+ console.log(' npm i -D kryptheon');
1251
+ console.log('');
1252
+ console.log(' Then run "kryptheon check" again.');
1253
+ console.log('');
1254
+ return 1;
1255
+ }
1256
+ keepArtefactsOutOfGit();
1257
+
1258
+ const triage = triageSpecs();
1259
+
1260
+ for (const name of triage.repaired) {
1261
+ console.log('');
1262
+ console.log(' Repaired ' + path.join('tests', name));
1263
+ console.log(' It was saved without the part that explains failures. Fixed now.');
1264
+ }
1265
+
1266
+ for (const item of triage.withSecrets) {
1267
+ console.log('');
1268
+ console.log(' Careful: ' + path.join('tests', item.name) + ' has a real password written into it.');
1269
+ console.log(' Anyone who can read that file can read the password.');
1270
+ console.log(' Re-record it to have the password moved into .env automatically.');
1271
+ }
1272
+
1273
+ for (const item of triage.missingSecrets) {
1274
+ console.log('');
1275
+ const example = fs.existsSync(path.join(USER_DIR, '.env.example')) ? '.env.example' : null;
1276
+ for (const line of secrets.missingSecretLines(path.join('tests', item.name), item.names, { examplePath: example })) {
1277
+ console.log(line);
1278
+ }
1279
+ }
1280
+
1281
+ for (const name of triage.notRecordings) {
1282
+ console.log('');
1283
+ console.log(' Skipped ' + path.join('tests', name));
1284
+ console.log(' This is not a real recording - it only opens a page and stops,');
1285
+ console.log(' so it cannot tell you whether anything works.');
1286
+ console.log(' Record it again: kryptheon record <url>');
1287
+ }
1288
+
1289
+ if (!triage.runnable.length) {
1290
+ console.log('');
1291
+ console.log(' Nothing could be checked.');
1292
+ console.log('');
1293
+ if (triage.missingSecrets.length) {
1294
+ console.log(' Every recording here needs a password that is not set yet.');
1295
+ console.log(' Create the .env file above and run this again.');
1296
+ } else {
1297
+ console.log(' None of the files in tests/ is a usable recording.');
1298
+ console.log(' Record one with: kryptheon record <url>');
1299
+ }
1300
+ console.log('');
1301
+ return 1;
1302
+ }
1303
+
1304
+ if (!ensureBrowser()) return 1;
1305
+
1306
+ // Only the usable recordings are handed to the test runner, so a file that
1307
+ // cannot produce diagnostics can never be counted as passing.
1308
+ const only = triage.runnable.map((name) => path.join('tests', name).split(path.sep).join('/'));
1309
+ if (quiet) process.env.KRYPTHEON_QUIET = '1';
1310
+
1311
+ // Debug signatures. The workers cannot print: Playwright pipes their
1312
+ // descriptors and hands the output to reporter stdio hooks that this
1313
+ // reporter does not implement, so it is dropped. They write to this file
1314
+ // instead and it is printed here, on stderr, where the reporter's own
1315
+ // output on stdout is left completely alone.
1316
+ const signatures = require(path.join(PACKAGE_DIR, 'kryptheon-signature.js'));
1317
+ let sink = null;
1318
+ if (signatures.signatureEnabled()) {
1319
+ sink = path.join(os.tmpdir(), 'kryptheon-signature-' + process.pid + '.jsonl');
1320
+ process.env[signatures.SINK_ENV] = sink;
1321
+ }
1322
+
1323
+ const status = runPlaywright(['test', '--config', CONFIG].concat(only)).status;
1324
+
1325
+ if (sink) signatures.drainSignatures(sink);
1326
+
1327
+ // Without this, a green summary could still hide a recording that checked
1328
+ // nothing at all.
1329
+ if (triage.missingSecrets.length) {
1330
+ const many = triage.missingSecrets.length > 1;
1331
+ console.log(
1332
+ ' Note: ' + triage.missingSecrets.length + ' recording' + (many ? 's were' : ' was') +
1333
+ ' skipped because a password is not set.'
1334
+ );
1335
+ console.log('');
1336
+ }
1337
+
1338
+ if (triage.notRecordings.length) {
1339
+ const many = triage.notRecordings.length > 1;
1340
+ console.log(
1341
+ ' Note: ' + triage.notRecordings.length + ' recording' + (many ? 's' : '') +
1342
+ ' above checked nothing and ' + (many ? 'were' : 'was') + ' skipped.'
1343
+ );
1344
+ console.log('');
1345
+ }
1346
+ // Skipped for a missing password is not a pass. The fix is one file away,
1347
+ // and reporting zero would let it be mistaken for a clean run.
1348
+ return status || (triage.missingSecrets.length ? 1 : 0);
1349
+ }
1350
+
1351
+ // --- setup-ai ---------------------------------------------------------------
1352
+ //
1353
+ // An assistant that only reads code will happily call a change finished. These
1354
+ // files are the ones the common assistants read on their own, so the rule ends
1355
+ // up in front of them without anyone having to remember it.
1356
+
1357
+ const RULES_START = 'kryptheon:start';
1358
+ const RULES_END = 'kryptheon:end';
1359
+
1360
+ // Every assistant reads at least one of these.
1361
+ const RULES_TARGETS = [
1362
+ { file: 'CLAUDE.md', comment: 'html' },
1363
+ { file: 'AGENTS.md', comment: 'html' },
1364
+ { file: '.cursorrules', comment: 'hash' },
1365
+ ];
1366
+
1367
+ function rulesBody() {
1368
+ return [
1369
+ '## Checking your work with kryptheon',
1370
+ '',
1371
+ 'After making any change to this app:',
1372
+ '',
1373
+ '1. Run `npx kryptheon check` and show its output.',
1374
+ '2. Do not report the work as done until it passes.',
1375
+ '3. If it fails, fix what broke and run it again.',
1376
+ '4. If it says nothing has been recorded yet, stop and ask the user to run',
1377
+ ' `npx kryptheon record <url>` themselves in their own terminal. That',
1378
+ ' opens a browser window and cannot be done from your terminal. It is not',
1379
+ ' a failure, so do not try to fix it.',
1380
+ '',
1381
+ 'Reading the code is not enough. The check runs the app in a real browser,',
1382
+ 'which is the only thing that shows whether the change actually works.',
1383
+ ];
1384
+ }
1385
+
1386
+ // Wrapped in markers so a second run replaces the block instead of stacking
1387
+ // another copy underneath the first.
1388
+ function rulesBlock(style) {
1389
+ const open = style === 'hash' ? '# ' + RULES_START : '<!-- ' + RULES_START + ' -->';
1390
+ const close = style === 'hash' ? '# ' + RULES_END : '<!-- ' + RULES_END + ' -->';
1391
+ const body = style === 'hash' ? rulesBody().map((l) => (l ? '# ' + l : '#')) : rulesBody();
1392
+ return [open].concat(body, [close]).join('\n');
1393
+ }
1394
+
1395
+ // Returns the new contents, and what happened, without touching the disk.
1396
+ function applyRules(existing, style) {
1397
+ const block = rulesBlock(style);
1398
+ if (existing === null || existing === undefined) {
1399
+ return { contents: block + '\n', action: 'created' };
1400
+ }
1401
+ const text = String(existing);
1402
+ const startAt = text.indexOf(RULES_START);
1403
+ const endAt = text.indexOf(RULES_END);
1404
+
1405
+ if (startAt !== -1 && endAt !== -1 && endAt > startAt) {
1406
+ // Replace the previous block, keeping everything around it.
1407
+ const lines = text.split('\n');
1408
+ const first = lines.findIndex((l) => l.indexOf(RULES_START) !== -1);
1409
+ let last = lines.findIndex((l) => l.indexOf(RULES_END) !== -1);
1410
+ if (first === -1 || last === -1 || last < first) return { contents: text, action: 'unchanged' };
1411
+ const rebuilt = lines.slice(0, first).concat(block.split('\n'), lines.slice(last + 1));
1412
+ const contents = rebuilt.join('\n');
1413
+ return { contents: contents, action: contents === text ? 'unchanged' : 'updated' };
1414
+ }
1415
+
1416
+ // Never overwrite what someone else wrote: add to the end.
1417
+ const padded = text.replace(/\s*$/, '');
1418
+ return { contents: padded + '\n\n' + block + '\n', action: 'appended' };
1419
+ }
1420
+
1421
+ function setupAi() {
1422
+ const results = [];
1423
+ for (const target of RULES_TARGETS) {
1424
+ const full = path.join(USER_DIR, target.file);
1425
+ let existing = null;
1426
+ try {
1427
+ existing = fs.readFileSync(full, 'utf8');
1428
+ } catch (err) {
1429
+ existing = null;
1430
+ }
1431
+ const outcome = applyRules(existing, target.comment);
1432
+ if (outcome.action === 'unchanged') {
1433
+ results.push({ file: target.file, action: 'unchanged' });
1434
+ continue;
1435
+ }
1436
+ try {
1437
+ fs.writeFileSync(full, outcome.contents, 'utf8');
1438
+ results.push({ file: target.file, action: outcome.action });
1439
+ } catch (err) {
1440
+ results.push({ file: target.file, action: 'failed' });
1441
+ }
1442
+ }
1443
+
1444
+ console.log('');
1445
+ console.log(' Your AI assistant now has a rule to check its work.');
1446
+ console.log('');
1447
+ for (const r of results) {
1448
+ const said =
1449
+ r.action === 'created' ? 'written'
1450
+ : r.action === 'appended' ? 'added a section to your existing file'
1451
+ : r.action === 'updated' ? 'updated the section it wrote before'
1452
+ : r.action === 'unchanged' ? 'already up to date'
1453
+ : 'could not be written';
1454
+ console.log(' ' + r.file.padEnd(14) + said);
1455
+ }
1456
+ console.log('');
1457
+ console.log(' The rule: after any change, run "npx kryptheon check", show the');
1458
+ console.log(' output, and do not call the work done until it passes.');
1459
+ console.log('');
1460
+ console.log(' Nothing outside the marked section was touched.');
1461
+ console.log('');
1462
+ return results.some((r) => r.action === 'failed') ? 1 : 0;
1463
+ }
1464
+
1465
+ // --- accept -----------------------------------------------------------------
1466
+
1467
+ function baselineApi() {
1468
+ try {
1469
+ return require(path.join(PACKAGE_DIR, 'kryptheon-fixture.js'));
1470
+ } catch (err) {
1471
+ console.error('');
1472
+ console.error(' Could not read the saved results: ' + err.message);
1473
+ console.error('');
1474
+ return null;
1475
+ }
1476
+ }
1477
+
1478
+ // A key looks like "tests/login.spec.js :: Login".
1479
+ function titleOf(key) {
1480
+ const at = key.indexOf(' :: ');
1481
+ return at === -1 ? key : key.slice(at + 4);
1482
+ }
1483
+
1484
+ function listBaselines(api) {
1485
+ const all = api.readBaselines(api.BASELINE_FILE);
1486
+ const keys = Object.keys(all);
1487
+
1488
+ console.log('');
1489
+ if (!keys.length) {
1490
+ console.log(' No tests have a saved result yet.');
1491
+ console.log(' Run "kryptheon check" once and they will be saved automatically.');
1492
+ console.log('');
1493
+ return 0;
1494
+ }
1495
+
1496
+ console.log(' Tests with a saved result:');
1497
+ console.log('');
1498
+ for (const key of keys) {
1499
+ const waiting = all[key] && all[key].pending ? ' (has a new result waiting)' : '';
1500
+ console.log(' ' + titleOf(key) + waiting);
1501
+ console.log(' from ' + key.split(' :: ')[0]);
1502
+ }
1503
+ console.log('');
1504
+ console.log(' To agree that a new result is correct:');
1505
+ console.log(' kryptheon accept "<name>"');
1506
+ console.log('');
1507
+ return 0;
1508
+ }
1509
+
1510
+ function accept(name) {
1511
+ const api = baselineApi();
1512
+ if (!api) return 1;
1513
+ if (!name) return listBaselines(api);
1514
+
1515
+ const all = api.readBaselines(api.BASELINE_FILE);
1516
+ const wanted = String(name).trim().toLowerCase();
1517
+ const matches = Object.keys(all).filter(function (key) {
1518
+ return titleOf(key).toLowerCase() === wanted || key.toLowerCase() === wanted;
1519
+ });
1520
+
1521
+ if (!matches.length) {
1522
+ console.error('');
1523
+ console.error(' No saved result for a test called "' + name + '".');
1524
+ console.error(' Run "kryptheon accept" on its own to see the names.');
1525
+ console.error('');
1526
+ return 1;
1527
+ }
1528
+ if (matches.length > 1) {
1529
+ console.error('');
1530
+ console.error(' More than one test is called "' + name + '":');
1531
+ matches.forEach((k) => console.error(' ' + k));
1532
+ console.error('');
1533
+ console.error(' Pass the full line above instead.');
1534
+ console.error('');
1535
+ return 1;
1536
+ }
1537
+
1538
+ const key = matches[0];
1539
+ const result = api.acceptBaseline(api.BASELINE_FILE, key);
1540
+
1541
+ if (result.ok) {
1542
+ console.log('');
1543
+ console.log(' Updated the saved result for "' + titleOf(key) + '".');
1544
+ console.log(' Address: ' + result.entry.url);
1545
+ console.log(' Title: ' + result.entry.title);
1546
+ console.log('');
1547
+ console.log(' Every other test was left alone.');
1548
+ console.log('');
1549
+ return 0;
1550
+ }
1551
+
1552
+ console.error('');
1553
+ if (result.reason === 'nothing-pending') {
1554
+ console.error(' "' + titleOf(key) + '" has no new result waiting.');
1555
+ console.error(' Nothing to accept - it last matched its saved result.');
1556
+ } else if (result.reason === 'no-entry') {
1557
+ console.error(' "' + titleOf(key) + '" has no saved result yet.');
1558
+ } else {
1559
+ console.error(' Could not update the saved result for "' + titleOf(key) + '".');
1560
+ }
1561
+ console.error('');
1562
+ return 1;
1563
+ }
1564
+
1565
+ const [command, ...rest] = process.argv.slice(2);
1566
+
1567
+ // process.exit() cuts the process off mid-teardown. On Windows a terminal's
1568
+ // output is written asynchronously, so exiting while the last line is still
1569
+ // queued tears down a handle that is already closing - which is what produces
1570
+ // "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)" in libuv. Setting
1571
+ // the code and letting Node wind down on its own avoids that entirely, and
1572
+ // still reports the same exit status.
1573
+ async function finishWith(code) {
1574
+ process.exitCode = code;
1575
+ const streams = [process.stdout, process.stderr];
1576
+ await Promise.all(
1577
+ streams.map(
1578
+ (stream) =>
1579
+ new Promise((resolve) => {
1580
+ if (!stream || typeof stream.write !== 'function' || !stream.writableLength) return resolve();
1581
+ const done = () => resolve();
1582
+ stream.once('drain', done);
1583
+ const guard = setTimeout(done, 1000);
1584
+ if (guard.unref) guard.unref();
1585
+ })
1586
+ )
1587
+ );
1588
+ }
1589
+
1590
+ async function main() {
1591
+ if (nodeIsTooOld()) {
1592
+ console.error('');
1593
+ console.error(' This tool needs a newer version of Node.');
1594
+ console.error(' You have: ' + process.versions.node);
1595
+ console.error(' You need: 20.6.0 or later');
1596
+ console.error('');
1597
+ console.error(' Download the latest from https://nodejs.org and try again.');
1598
+ console.error('');
1599
+ return finishWith(1);
1600
+ }
1601
+
1602
+ switch (command) {
1603
+ case 'record':
1604
+ return finishWith(await record(rest[0]));
1605
+ break;
1606
+ case 'check':
1607
+ return finishWith(check({ quiet: rest.indexOf('--quiet') !== -1 || rest.indexOf('-q') !== -1 }));
1608
+ break;
1609
+ case 'setup-ai':
1610
+ return finishWith(setupAi());
1611
+ case 'accept':
1612
+ return finishWith(accept(rest.join(' ').trim()));
1613
+ break;
1614
+ case '-v':
1615
+ case '--version':
1616
+ case 'version':
1617
+ console.log(packageVersion());
1618
+ return finishWith(0);
1619
+ case undefined:
1620
+ case '-h':
1621
+ case '--help':
1622
+ case 'help':
1623
+ usage();
1624
+ return finishWith(0);
1625
+ break;
1626
+ default:
1627
+ console.error('');
1628
+ console.error(' Unknown command: ' + command);
1629
+ usage();
1630
+ return finishWith(1);
1631
+ }
1632
+ }
1633
+
1634
+ // Exported so the naming logic can be checked without opening a browser.
1635
+ module.exports = {
1636
+ packageVersion: packageVersion,
1637
+ humanisePath: humanisePath,
1638
+ nameFromTitle: nameFromTitle,
1639
+ deriveTestName: deriveTestName,
1640
+ slugify: slugify,
1641
+ nameRecording: nameRecording,
1642
+ pointAtFixture: pointAtFixture,
1643
+ listSpecFiles: listSpecFiles,
1644
+ countRecordedActions: countRecordedActions,
1645
+ isRealRecording: isRealRecording,
1646
+ importsPlaywrightDirectly: importsPlaywrightDirectly,
1647
+ triageSpecs: triageSpecs,
1648
+ applyRules: applyRules,
1649
+ rulesBlock: rulesBlock,
1650
+ record: record,
1651
+ finaliseRecording: finaliseRecording,
1652
+ takeOutSecrets: takeOutSecrets,
1653
+ reportSecrets: reportSecrets,
1654
+ reportReplayRisks: reportReplayRisks,
1655
+ offerToDropLogout: offerToDropLogout,
1656
+ noWindowLines: noWindowLines,
1657
+ windowUndetectedLines: windowUndetectedLines,
1658
+ probeUnclearLines: probeUnclearLines,
1659
+ createWindowWatch: createWindowWatch,
1660
+ inspectBrowserWindows: inspectBrowserWindows,
1661
+ parseWindowProbe: parseWindowProbe,
1662
+ codegenComplaints: codegenComplaints,
1663
+ WINDOW_TIMEOUT_MS: WINDOW_TIMEOUT_MS,
1664
+ WINDOW_HARD_LIMIT_MS: WINDOW_HARD_LIMIT_MS,
1665
+ WINDOW_POLL_MS: WINDOW_POLL_MS,
1666
+ classifyFetchError: classifyFetchError,
1667
+ normaliseRecordUrl: normaliseRecordUrl,
1668
+ isLocalAddress: isLocalAddress,
1669
+ nodeIsTooOld: nodeIsTooOld,
1670
+ };
1671
+
1672
+ if (require.main === module) main();