kryptheon 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,6 +70,10 @@ npx kryptheon record https://your-app.example.com
70
70
  A browser opens. Use your app the way a customer would. Close the browser when
71
71
  you are done, and the recording is saved as a test.
72
72
 
73
+ Add `--phone` to record in a phone-sized window (390 x 844) - for a menu or a
74
+ layout that only changes on a phone. The recording keeps that size, so every
75
+ replay uses it too. On the dashboard it is the "Phone size" box under Record.
76
+
73
77
  On its own, a recording knows that every button and field is still there and
74
78
  that the last page looks the same. To say what must be true — "Reservation
75
79
  received" appears after booking — use the toolbar at the top of the page while
package/bin/kryptheon.js CHANGED
@@ -91,6 +91,7 @@ function usage() {
91
91
  console.log(' The same, for scripts and AI tools:');
92
92
  console.log('');
93
93
  console.log(' npx kryptheon record <url> open your app and record what you do as a test');
94
+ console.log(' ... <url> --phone the same in a phone-sized window, replayed at that size');
94
95
  console.log(' npx kryptheon check run every recorded test and report in plain language');
95
96
  console.log(' npx kryptheon accept <name> agree that one test\'s new result is the correct one');
96
97
  console.log(' npx kryptheon remove [name] list the recordings, or remove one you no longer want');
@@ -1527,7 +1528,28 @@ async function prepareFolder(url) {
1527
1528
  return makeReady(USER_DIR) ? null : 1;
1528
1529
  }
1529
1530
 
1530
- async function record(url) {
1531
+ // A phone's window: the size the recording is made at and replayed at.
1532
+ // Found on a blind test (Northstar): a mobile menu that never opened and a
1533
+ // card wider than the screen were only visible below 440px, and every
1534
+ // recording was made at desktop width.
1535
+ // Written with a comma: codegen's own help shows "1280x720", but codegen
1536
+ // itself refuses that ("use width,height") - measured on Playwright 1.62.
1537
+ const PHONE_SIZE = '390,844';
1538
+
1539
+ /**
1540
+ * What codegen is started with. At phone size, codegen writes
1541
+ * test.use({ viewport: ... }) into the recording itself, so every replay
1542
+ * opens at the size it was recorded at - nothing else has to remember it.
1543
+ */
1544
+ function codegenArgs(outFile, target, options) {
1545
+ const phone = options && options.phone;
1546
+ return ['codegen', '--target', 'playwright-test']
1547
+ .concat(phone ? ['--viewport-size=' + PHONE_SIZE] : [])
1548
+ .concat(['-o', outFile, target]);
1549
+ }
1550
+
1551
+ async function record(url, options) {
1552
+ const phone = Boolean(options && options.phone);
1531
1553
  if (!url) {
1532
1554
  console.error('');
1533
1555
  console.error(' Which address should I open?');
@@ -1571,7 +1593,8 @@ async function record(url) {
1571
1593
  const specPath = path.join(USER_DIR, outFile);
1572
1594
 
1573
1595
  console.log('');
1574
- console.log(' Opening ' + target + ' in a browser.');
1596
+ console.log(' Opening ' + target + (phone ? ' in a phone-sized window (' + PHONE_SIZE.replace(',', ' x ') + ').' : ' in a browser.'));
1597
+ if (phone) console.log(' It is replayed at the same size every time.');
1575
1598
  console.log('');
1576
1599
  console.log(' Use your app normally - click, type, sign in, whatever you want');
1577
1600
  console.log(' covered. Every step is recorded as you go.');
@@ -1579,7 +1602,7 @@ async function record(url) {
1579
1602
  console.log(' When you are done, close the browser window to save the test.');
1580
1603
  console.log('');
1581
1604
 
1582
- const child = startCodegen(['codegen', '--target', 'playwright-test', '-o', outFile, target]);
1605
+ const child = startCodegen(codegenArgs(outFile, target, { phone: phone }));
1583
1606
  if (!child) return 1;
1584
1607
 
1585
1608
  let stderr = '';
@@ -2450,10 +2473,11 @@ function recheckFromDashboard(id, opened, say) {
2450
2473
  }
2451
2474
 
2452
2475
  /** Records one flow the way `npx kryptheon record <address>` does, and names what it saved. */
2453
- function recordFromDashboard(address, say) {
2476
+ function recordFromDashboard(address, say, options) {
2477
+ const phone = Boolean(options && options.phone);
2454
2478
  const before = listSpecFiles();
2455
- say('A browser window opens on ' + address + '. Use your app, then close that window to save.');
2456
- return kryptheonChild(['record', address], say).then(({ code }) => {
2479
+ say('A ' + (phone ? 'phone-sized ' : '') + 'browser window opens on ' + address + '. Use your app, then close that window to save.');
2480
+ return kryptheonChild(['record', address].concat(phone ? ['--phone'] : []), say).then(({ code }) => {
2457
2481
  const added = listSpecFiles().filter((name) => !before.includes(name));
2458
2482
  if (!added.length) return { ok: false, exit: code, why: 'no recording was saved' };
2459
2483
  return { ok: true, exit: code, recordings: added, summary: 'saved ' + added.join(', ') };
@@ -2539,9 +2563,68 @@ function dashboardAlreadyRunning(port) {
2539
2563
  });
2540
2564
  }
2541
2565
 
2566
+ /**
2567
+ * The dashboard, started from the home folder: kept in a kryptheon-tests
2568
+ * folder there, the one record already uses - set up the first time, asked
2569
+ * once, and simply opened every time after.
2570
+ *
2571
+ * Found when the first person paid: the site's Kryptheon.cmd download was
2572
+ * flagged as dangerous by the browser and by Windows, and would not open
2573
+ * once saved. Its replacement is Win+R and one pasted line - and Win+R starts
2574
+ * in the home folder, where dashboard used to refuse to run at all.
2575
+ */
2576
+ async function dashboardFromHome(plan) {
2577
+ const ready = project.hasPackageJson(plan.into) && project.kryptheonInstalledIn(plan.into);
2578
+ if (!ready) {
2579
+ console.log('');
2580
+ console.log(' Kryptheon keeps your recordings and results in a folder of their own:');
2581
+ console.log('');
2582
+ console.log(' ' + plan.into);
2583
+ console.log('');
2584
+ console.log(' The first time, I make it and install kryptheon in it (about 20MB).');
2585
+ console.log(' Nothing else on this computer is touched.');
2586
+ console.log('');
2587
+ const question = project.setupQuestion(plan);
2588
+ const yes = process.stdin.isTTY
2589
+ ? project.answeredYes(await askLine(question.text), question.yesByDefault)
2590
+ : question.yesByDefault;
2591
+ if (!yes) {
2592
+ console.log('');
2593
+ console.log(' Nothing was changed. To keep them beside your app instead, open a');
2594
+ console.log(' terminal in its folder and run: npx kryptheon@latest dashboard');
2595
+ console.log('');
2596
+ return 1;
2597
+ }
2598
+ try {
2599
+ fs.mkdirSync(plan.into, { recursive: true });
2600
+ } catch (err) {
2601
+ console.error('');
2602
+ console.error(' Could not make the folder: ' + plan.into);
2603
+ console.error('');
2604
+ return 1;
2605
+ }
2606
+ if (!makeReady(plan.into)) return 1;
2607
+ }
2608
+ // Run again from inside that folder, as record does: every path in this
2609
+ // file is worked out once, from the folder the command started in.
2610
+ const child = spawn(process.execPath, [__filename, 'dashboard'], { cwd: plan.into, stdio: 'inherit' });
2611
+ const wait = () => {};
2612
+ process.on('SIGINT', wait);
2613
+ process.on('SIGTERM', wait);
2614
+ const code = await new Promise((resolve) => {
2615
+ child.on('error', () => resolve(1));
2616
+ child.on('close', (status) => resolve(status === null ? 1 : status));
2617
+ });
2618
+ process.removeListener('SIGINT', wait);
2619
+ process.removeListener('SIGTERM', wait);
2620
+ return code;
2621
+ }
2622
+
2542
2623
  async function dashboardCommand() {
2543
2624
  const here = project.inspectProject(USER_DIR);
2544
2625
  if (!here.ok) {
2626
+ const plan = project.setupPlan(USER_DIR);
2627
+ if (plan.action === 'home') return await dashboardFromHome(plan);
2545
2628
  for (const line of project.noProjectLines(here)) console.error(line);
2546
2629
  return 1;
2547
2630
  }
@@ -2565,7 +2648,7 @@ async function dashboardCommand() {
2565
2648
  const app = dash.createServer(USER_DIR, {
2566
2649
  run: (id, say) => runFromDashboard(id, opened, say),
2567
2650
  recheck: (id, say) => recheckFromDashboard(id, opened, say),
2568
- record: (address, say) => recordFromDashboard(address, say),
2651
+ record: (address, say, options) => recordFromDashboard(address, say, options),
2569
2652
  db: (action, connection, say) => databaseFromDashboard(action, connection, say),
2570
2653
  words: (connection) => nightWords(connection),
2571
2654
  });
@@ -2867,7 +2950,7 @@ async function main() {
2867
2950
 
2868
2951
  switch (command) {
2869
2952
  case 'record':
2870
- return finishWith(await record(rest[0]));
2953
+ return finishWith(await record(rest.find((a) => !/^--/.test(a)), { phone: rest.includes('--phone') }));
2871
2954
  break;
2872
2955
  case 'check': {
2873
2956
  const handedOver = handToFolderCopy();
@@ -2919,6 +3002,8 @@ async function main() {
2919
3002
 
2920
3003
  // Exported so the naming logic can be checked without opening a browser.
2921
3004
  module.exports = {
3005
+ codegenArgs: codegenArgs,
3006
+ PHONE_SIZE: PHONE_SIZE,
2922
3007
  packageVersion: packageVersion,
2923
3008
  humanisePath: humanisePath,
2924
3009
  nameFromTitle: nameFromTitle,
package/kryptheon-code.js CHANGED
@@ -137,8 +137,11 @@ function calleeName(node) {
137
137
  // feeds another. It was 6, which a constant-only lookup (a template, a call,
138
138
  // its return, a map of maps) used up before reaching the strings at the
139
139
  // bottom, so a harmless label came back "unknown". Giving up always errs
140
- // towards reporting, never towards silence.
141
- const MAX_DEPTH = 12;
140
+ // towards reporting, never towards silence. It was 12 until a blind test
141
+ // (Northstar): a helper's parameter, back to its call inside a map() callback,
142
+ // to the list, a slice of a list reassigned from a filter of the fixed one -
143
+ // 13 steps. 18 leaves room for one more layer of the same.
144
+ const MAX_DEPTH = 18;
142
145
 
143
146
  function classify(node, scope, code, depth, aliases) {
144
147
  if (!node || depth > MAX_DEPTH) return 'unknown';
@@ -483,7 +486,7 @@ function fromBinding(id, scope, code, depth, aliases) {
483
486
  const binding = scope.getBinding(id.name);
484
487
  if (!binding) return 'unknown';
485
488
  if (binding.path && binding.path.type === 'CatchClause') return 'error';
486
- if (binding.kind === 'param') return fromCalls(binding, code, depth);
489
+ if (binding.kind === 'param') return fromCallback(binding, code, depth) || fromCalls(binding, code, depth);
487
490
  if (binding.kind === 'module') return fromImport(binding, depth);
488
491
  const init = binding.path && binding.path.node && binding.path.node.init;
489
492
  const later = binding.constantViolations || [];
@@ -518,6 +521,35 @@ function fromBinding(id, scope, code, depth, aliases) {
518
521
  return classify(init, binding.path.scope, code, depth + 1, aliases);
519
522
  }
520
523
 
524
+ // Array methods that hand their callback the list's own items.
525
+ const ITEM_CALLBACKS = /^(map|forEach|filter|find|findLast|findIndex|findLastIndex|some|every|flatMap|sort|toSorted)$/;
526
+
527
+ /**
528
+ * A parameter of a callback handed to list.map(...) and its kin: the item is
529
+ * whatever the list holds, the index a number, the third the list itself.
530
+ * Null when the parameter is not that.
531
+ *
532
+ * Found on a blind test (Northstar): pageRows.map(row => `...${severityClass(row.severity)}...`).
533
+ * The helper's parameter was followed to its call, and there `row` was a
534
+ * parameter of an arrow nobody names - "unknown" - though pageRows only ever
535
+ * held the file's own fixed list. The map().join() reading knew what row was;
536
+ * a name followed back into the same callback did not.
537
+ */
538
+ function fromCallback(binding, code, depth) {
539
+ const fn = binding.path && binding.path.parentPath;
540
+ if (!fn || !/Function/.test(fn.node.type)) return null;
541
+ const call = fn.parentPath;
542
+ if (!call || !/CallExpression$/.test(call.node.type) || call.node.arguments[0] !== fn.node) return null;
543
+ const method = calleeName(call.node.callee);
544
+ if (!/MemberExpression$/.test(call.node.callee.type) || !ITEM_CALLBACKS.test(method)) return null;
545
+ const index = fn.node.params.indexOf(binding.path.node);
546
+ const list = () => classify(call.node.callee.object, call.scope, code, depth + 1);
547
+ if (index === 0 || (index === 1 && /^(sort|toSorted)$/.test(method))) return list();
548
+ if (index === 1) return 'safe';
549
+ if (index === 2) return list();
550
+ return null;
551
+ }
552
+
521
553
  /**
522
554
  * A parameter is as dangerous as the worst thing any call passes it. Every
523
555
  * use of the function has to be a call this file makes: one handed on as a
@@ -155,6 +155,10 @@
155
155
  recordInput.placeholder = 'http://localhost:3000';
156
156
  recordInput.setAttribute('aria-label', 'Your app address');
157
157
  recordInput.spellcheck = false;
158
+ // Kept like the box beside it, so a page refresh does not untick it.
159
+ var phoneBox = el('input');
160
+ phoneBox.type = 'checkbox';
161
+ phoneBox.id = 'recordPhone';
158
162
  var dbInput = el('input', 'field');
159
163
  dbInput.type = 'password';
160
164
  dbInput.placeholder = 'postgresql://postgres.<project>:<password>@...pooler.supabase.com:5432/postgres';
@@ -441,7 +445,7 @@
441
445
  var go = button('sun sm', 'film', a.label, function () {
442
446
  var address = recordInput.value.trim();
443
447
  if (!address) { toast('Put your app\'s address in the box first - for example http://localhost:3000'); recordInput.focus(); return; }
444
- runJob('/api/record', { url: address }).then(function (done) {
448
+ runJob('/api/record', { url: address, phone: phoneBox.checked }).then(function (done) {
445
449
  var r = done && done.result;
446
450
  toast(r && r.ok ? 'Saved: ' + (r.recordings || []).join(', ') : 'Nothing was saved' + (r && r.why ? ' - ' + r.why : ''));
447
451
  }, function () {});
@@ -450,6 +454,11 @@
450
454
  go.disabled = busy;
451
455
  line.appendChild(go);
452
456
  wrap.appendChild(line);
457
+ var size = el('label', 'fallback');
458
+ size.setAttribute('for', 'recordPhone');
459
+ size.appendChild(phoneBox);
460
+ size.appendChild(document.createTextNode(' Phone size (390 x 844) - for menus and layouts that only change on a phone. Replayed at the same size.'));
461
+ wrap.appendChild(size);
453
462
  wrap.appendChild(el('div', 'fallback', 'or in a terminal: ' + a.command));
454
463
  return wrap;
455
464
  }
@@ -825,7 +825,9 @@ function createServer(root, options) {
825
825
  if (url.pathname === '/api/record' && typeof opts.record === 'function') {
826
826
  const address = appAddress(input.url);
827
827
  if (!address) return json(res, 400, { ok: false, why: 'that is not a web address - it should look like http://localhost:3000' });
828
- return begin('record', 'Recording ' + address, 'npx kryptheon record ' + address, (say) => opts.record(address, say));
828
+ const phone = input.phone === true;
829
+ return begin('record', 'Recording ' + address + (phone ? ' at phone size' : ''),
830
+ 'npx kryptheon record ' + address + (phone ? ' --phone' : ''), (say) => opts.record(address, say, { phone: phone }));
829
831
  }
830
832
  if (url.pathname === '/api/db' && typeof opts.db === 'function') {
831
833
  const action = String(input.action || '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "Record what you do in your app and get plain-language reports when it breaks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",
@@ -52,7 +52,7 @@
52
52
  "@playwright/test": "^1.62.1"
53
53
  },
54
54
  "scripts": {
55
- "check": "node kryptheon-store.check.js && node kryptheon-code.check.js && node kryptheon-constdata.check.js && node kryptheon-accept.check.js && node kryptheon-keys.check.js && node kryptheon-dashboard.check.js && node kryptheon-autostart.check.js && node kryptheon-changes.check.js && node kryptheon-look.check.js && node kryptheon-verify.check.js && node kryptheon-recheck.check.js && 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-signature.number.check.js && node kryptheon-env.check.js && node kryptheon-watchdog.check.js && node kryptheon-record.check.js && node kryptheon-selectors.check.js && node kryptheon-recordings.check.js && node kryptheon-cleanup.check.js && node kryptheon-orphan.check.js && node kryptheon-project.check.js && node kryptheon-setup.check.js && node kryptheon-browser.check.js && node kryptheon-heal.check.js && node kryptheon-load.check.js && node kryptheon-package.check.js",
55
+ "check": "node kryptheon-store.check.js && node kryptheon-code.check.js && node kryptheon-constdata.check.js && node kryptheon-accept.check.js && node kryptheon-keys.check.js && node kryptheon-dashboard.check.js && node kryptheon-autostart.check.js && node kryptheon-changes.check.js && node kryptheon-look.check.js && node kryptheon-verify.check.js && node kryptheon-recheck.check.js && 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-signature.number.check.js && node kryptheon-env.check.js && node kryptheon-watchdog.check.js && node kryptheon-phone.check.js && node kryptheon-homedash.check.js && node kryptheon-record.check.js && node kryptheon-selectors.check.js && node kryptheon-recordings.check.js && node kryptheon-cleanup.check.js && node kryptheon-orphan.check.js && node kryptheon-project.check.js && node kryptheon-setup.check.js && node kryptheon-browser.check.js && node kryptheon-heal.check.js && node kryptheon-load.check.js && node kryptheon-package.check.js",
56
56
  "check:store": "node kryptheon-store.check.js",
57
57
  "check:code": "node kryptheon-code.check.js",
58
58
  "check:constdata": "node kryptheon-constdata.check.js",