kryptheon 0.1.16 → 0.1.18

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
@@ -46,6 +46,22 @@ npx kryptheon record https://your-app.example.com
46
46
  A browser opens. Use your app the way a customer would. Close the browser when
47
47
  you are done, and the recording is saved as a test.
48
48
 
49
+ On its own, a recording knows that every button and field is still there and
50
+ that the last page looks the same. To say what must be true — "Reservation
51
+ received" appears after booking — use the toolbar at the top of the page while
52
+ recording:
53
+
54
+ | Button | Click it, then click… | Kryptheon then checks |
55
+ | --- | --- | --- |
56
+ | Eye | any element | it is on the page |
57
+ | `ab` | some text | that text is there |
58
+ | Box | a filled-in field | the field still holds that value |
59
+ | `{ }` | a part of the page | that part is laid out the same |
60
+
61
+ The round red button pauses and resumes recording. The arrow only shows how an
62
+ element would be found; it adds nothing to the test. One check at the end of a
63
+ flow — on the success message — is usually all a recording needs.
64
+
49
65
  ## Check
50
66
 
51
67
  ```
@@ -127,6 +143,24 @@ npx kryptheon accept "Checkout"
127
143
 
128
144
  `npx kryptheon accept` on its own lists what has been saved.
129
145
 
146
+ ## Renamed buttons
147
+
148
+ A button that only changed its words has not broken anything. When a step
149
+ cannot find "Reserve a table" and the page shows exactly one new button in its
150
+ place — "Reserve here" — kryptheon clicks that one, carries on through the rest
151
+ of the flow, and says so:
152
+
153
+ ```
154
+ OK Reserve a table (16.0s)
155
+ Renamed: The button "Reserve a table" is now called "Reserve here" - I used "Reserve here" and carried on.
156
+ Nothing is broken. To stop seeing this, record the flow again.
157
+ ```
158
+
159
+ It only does this when the page proves it: one name gone, the one the step
160
+ wanted, and one new name of the same kind. A button that is gone with nothing
161
+ in its place, or a rename next to other new buttons, fails as it always did.
162
+ Checks you added with the toolbar are never redirected.
163
+
130
164
  ## Files it creates in your folder
131
165
 
132
166
  | File | What it is |
package/bin/kryptheon.js CHANGED
@@ -2057,6 +2057,57 @@ async function finishWith(code) {
2057
2057
  );
2058
2058
  }
2059
2059
 
2060
+ // The copy that must run `check` is the one the recordings load. Measured: in
2061
+ // a folder `record` set up, `npx kryptheon@latest check` ran npx's copy, whose
2062
+ // Playwright loads the config, while the recordings loaded the folder's copy -
2063
+ // two Playwrights, and "Requiring @playwright/test second time" before a single
2064
+ // recording ran. `npx kryptheon check` worked in the same folder only because
2065
+ // npx happened to pick the local copy.
2066
+ //
2067
+ // So when this is not the folder's copy, the folder's copy does the work.
2068
+ // Returns its exit code, or null to carry on here.
2069
+ function handToFolderCopy() {
2070
+ if (process.env.KRYPTHEON_HANDED_OVER) return null; // once, never in a loop
2071
+ const theirs = project.kryptheonCopyFor(USER_DIR);
2072
+ if (!theirs) return null;
2073
+ let same = false;
2074
+ try {
2075
+ same = fs.realpathSync(theirs) === fs.realpathSync(PACKAGE_DIR);
2076
+ } catch (err) {
2077
+ same = path.resolve(theirs) === path.resolve(PACKAGE_DIR);
2078
+ }
2079
+ if (same) return null;
2080
+
2081
+ let theirVersion = 'unknown';
2082
+ try {
2083
+ theirVersion = JSON.parse(fs.readFileSync(path.join(theirs, 'package.json'), 'utf8')).version;
2084
+ } catch (err) {
2085
+ /* said as unknown */
2086
+ }
2087
+ const mine = packageVersion();
2088
+ if (theirVersion !== mine) {
2089
+ // Not an error: the folder's copy is the one its recordings are written
2090
+ // against. Said, so an older one is never mistaken for this one.
2091
+ console.log('');
2092
+ console.log(' Running the kryptheon installed in this folder (' + theirVersion + '), because your');
2093
+ console.log(' recordings load that one. ' + (mine !== 'unknown' ? 'To move this folder to ' + mine + ':' : 'To update it:'));
2094
+ console.log(' npm i -D kryptheon@latest');
2095
+ }
2096
+ const result = spawnSync(process.execPath, [path.join(theirs, 'bin', 'kryptheon.js')].concat(process.argv.slice(2)), {
2097
+ cwd: USER_DIR,
2098
+ stdio: 'inherit',
2099
+ env: Object.assign({}, process.env, { KRYPTHEON_HANDED_OVER: '1' }),
2100
+ });
2101
+ if (result.error) {
2102
+ console.error('');
2103
+ console.error(' Could not run the kryptheon installed in this folder: ' + result.error.message);
2104
+ console.error(' Reinstalling it usually fixes this: npm i -D kryptheon@latest');
2105
+ console.error('');
2106
+ return 1;
2107
+ }
2108
+ return result.status === null ? 1 : result.status;
2109
+ }
2110
+
2060
2111
  async function main() {
2061
2112
  if (nodeIsTooOld()) {
2062
2113
  console.error('');
@@ -2073,9 +2124,11 @@ async function main() {
2073
2124
  case 'record':
2074
2125
  return finishWith(await record(rest[0]));
2075
2126
  break;
2076
- case 'check':
2127
+ case 'check': {
2128
+ const handedOver = handToFolderCopy();
2129
+ if (handedOver !== null) return finishWith(handedOver);
2077
2130
  return finishWith(check({ quiet: rest.indexOf('--quiet') !== -1 || rest.indexOf('-q') !== -1 }));
2078
- break;
2131
+ }
2079
2132
  case 'remove':
2080
2133
  case 'rm':
2081
2134
  return finishWith(await remove(rest.join(" ").trim()));
@@ -2122,6 +2175,7 @@ module.exports = {
2122
2175
  rulesBlock: rulesBlock,
2123
2176
  record: record,
2124
2177
  prepareFolder: prepareFolder,
2178
+ handToFolderCopy: handToFolderCopy,
2125
2179
  finaliseRecording: finaliseRecording,
2126
2180
  takeOutSecrets: takeOutSecrets,
2127
2181
  reportSecrets: reportSecrets,
@@ -22,6 +22,7 @@ const base = require('@playwright/test');
22
22
  const { requestPath } = require('./kryptheon-reporter.js');
23
23
  const signature = require('./kryptheon-signature.js');
24
24
  const replay = require('./kryptheon-replay.js');
25
+ const heal = require('./kryptheon-heal.js');
25
26
 
26
27
  const MAX_ITEMS = 5; // keep the failure block readable
27
28
 
@@ -133,6 +134,9 @@ function normalisedEntry(current) {
133
134
  // only to a file name, and a re-recording that lands on the same name
134
135
  // inherits a baseline it never created and can never match.
135
136
  if (current.recordingId) stored.recordingId = current.recordingId;
137
+ // The names each step saw, which is what lets a later run tell a renamed
138
+ // button from a missing one. See kryptheon-heal.js.
139
+ if (current.steps && Object.keys(current.steps).length) stored.steps = current.steps;
136
140
  return stored;
137
141
  }
138
142
 
@@ -188,14 +192,14 @@ function normaliseTitle(value) {
188
192
 
189
193
  // The address, the title, and what the page ended up showing decide pass or
190
194
  // fail. Browser noise changing is not on its own a regression.
191
- function compareBaselines(previous, current) {
195
+ function compareBaselines(previous, current, renamed) {
192
196
  const urlChanged = normaliseUrl(previous.url) !== normaliseUrl(current.url);
193
197
  const titleChanged = normaliseTitle(previous.title) !== normaliseTitle(current.title);
194
198
  if (!urlChanged && !titleChanged) {
195
199
  // Nothing moved in the address bar. This is the case the address and title
196
200
  // alone cannot see: a single-page app with a catch-all route lands on the
197
201
  // same URL under the same title whether the flow worked or not.
198
- return signature.compareSignatures(previous.signature, current.signature);
202
+ return signature.compareSignatures(previous.signature, current.signature, renamed);
199
203
  }
200
204
 
201
205
  const what =
@@ -248,6 +252,18 @@ function stampRecording(file, key, recordingId) {
248
252
  return saveBaselines(file, all);
249
253
  }
250
254
 
255
+ // Keeps the names each step saw on this passing run, and nothing else about
256
+ // the entry - its recorded time is what "this was working on" means.
257
+ function rememberSteps(file, key, steps) {
258
+ if (!steps || !Object.keys(steps).length) return false;
259
+ const all = readBaselines(file);
260
+ const entry = all[key];
261
+ if (!entry || typeof entry !== 'object') return false;
262
+ if (JSON.stringify(entry.steps || null) === JSON.stringify(steps)) return false;
263
+ entry.steps = steps;
264
+ return saveBaselines(file, all);
265
+ }
266
+
251
267
  function upgradeBaseline(file, key, sig) {
252
268
  const all = readBaselines(file);
253
269
  const entry = all[key];
@@ -257,7 +273,7 @@ function upgradeBaseline(file, key, sig) {
257
273
  }
258
274
 
259
275
  // The whole decision in one call: create on first sight, otherwise compare.
260
- function applyBaseline(file, key, current) {
276
+ function applyBaseline(file, key, current, renamed) {
261
277
  const previous = readBaselines(file)[key];
262
278
  if (!previous || typeof previous !== 'object') {
263
279
  writeBaseline(file, key, current);
@@ -274,7 +290,7 @@ function applyBaseline(file, key, current) {
274
290
  writeBaseline(file, key, current);
275
291
  return { status: 'created', message: null };
276
292
  }
277
- const message = compareBaselines(previous, current);
293
+ const message = compareBaselines(previous, current, renamed);
278
294
  if (message) return { status: 'changed', message: message };
279
295
 
280
296
  // A baseline from before signatures existed passes on address and title, and
@@ -325,6 +341,17 @@ const test = base.test.extend({
325
341
  }
326
342
  })();
327
343
 
344
+ // What the last passing run of this same recording saw. A saved result
345
+ // from a different recording describes steps this one does not have.
346
+ const previousEntry = readBaselines(BASELINE_FILE)[key];
347
+ const sameRecording =
348
+ Boolean(previousEntry) && typeof previousEntry === 'object' &&
349
+ !(previousEntry.recordingId && recordingId && previousEntry.recordingId !== recordingId);
350
+ const healer = heal.watch(page, {
351
+ memory: sameRecording ? previousEntry.steps : null,
352
+ fallback: sameRecording ? previousEntry.signature : null,
353
+ });
354
+
328
355
  // Reports only what this run added on top of the last passing run.
329
356
  const attachObservations = async () => {
330
357
  let url = null;
@@ -354,6 +381,19 @@ const test = base.test.extend({
354
381
 
355
382
  await use(page);
356
383
 
384
+ // Said whether the test passed or not: on a pass it is the only thing the
385
+ // person needs to know, and on a failure it explains the steps before it.
386
+ if (healer.renamed.length) {
387
+ try {
388
+ await testInfo.attach('kryptheon-renamed', {
389
+ body: JSON.stringify(healer.renamed),
390
+ contentType: 'application/json',
391
+ });
392
+ } catch (e) {
393
+ /* a note, never a second failure */
394
+ }
395
+ }
396
+
357
397
  // Read the page before anything else looks at the outcome. A test that
358
398
  // failed is exactly the one where knowing what was on screen matters most,
359
399
  // so this runs whether it passed or not. It is null when the page could
@@ -394,13 +434,17 @@ const test = base.test.extend({
394
434
  failedRequests: failedRequests,
395
435
  signature: captured,
396
436
  recordingId: recordingId,
437
+ steps: heal.mergeMemory(sameRecording ? previousEntry.steps : null, healer.learned),
397
438
  };
398
439
  } catch (e) {
399
440
  current = null; // page closed by the test - leave any baseline untouched
400
441
  }
401
442
  if (!current) return;
402
443
 
403
- const outcome = applyBaseline(BASELINE_FILE, key, current);
444
+ const outcome = applyBaseline(BASELINE_FILE, key, current, healer.renamed);
445
+ if (outcome.status === 'match' || outcome.status === 'learned') {
446
+ rememberSteps(BASELINE_FILE, key, current.steps);
447
+ }
404
448
  if (outcome.status === 'changed') {
405
449
  // Remember what this run saw so `kryptheon accept` can promote it, but
406
450
  // leave the accepted baseline exactly as it was.
@@ -428,5 +472,6 @@ module.exports = {
428
472
  applyBaseline: applyBaseline,
429
473
  upgradeBaseline: upgradeBaseline,
430
474
  stampRecording: stampRecording,
475
+ rememberSteps: rememberSteps,
431
476
  BASELINE_FILE: BASELINE_FILE,
432
477
  };
@@ -0,0 +1,215 @@
1
+ // A button that was renamed has not broken the flow.
2
+ //
3
+ // Found on a real Lovable app: "Reserve a table" became "Reserve here", the
4
+ // replay stopped at the first step that clicked it, and the report said the
5
+ // flow was broken. It was not - the same button was in the same place, and
6
+ // everything after it (the form, the email check, the date check, the
7
+ // confirmation) was never looked at. One rename hid the whole flow, and the
8
+ // person had to go and prove that themselves.
9
+ //
10
+ // So when a step cannot find its element by the recorded name, this asks what
11
+ // changed on the page since the flow last passed. The rule is deliberately
12
+ // narrow: exactly one name of that kind is gone, it is the one the step
13
+ // wanted, and exactly one new name of the same role has appeared in its
14
+ // place. Then the step uses the new one and the report says so. Anything
15
+ // looser - two new buttons, the old name still somewhere, a page that has
16
+ // not finished drawing - is not a rename this can prove, and the step fails
17
+ // exactly as it did before, with the same message.
18
+ //
19
+ // What the page looked like before comes from two places. Every step that
20
+ // finds its element by name notes the names of its role on the page at that
21
+ // moment, and the baseline keeps them from the last passing run. A project
22
+ // recorded before this existed has none of those yet, and falls back to the
23
+ // buttons, links and fields the baseline saw on the flow's last page - which
24
+ // only proves a rename when the step's page and the last page are the same.
25
+ //
26
+ // Only page.getByRole with a plain string name is watched: that is what
27
+ // codegen writes for nearly every click and fill. Assertions are never
28
+ // redirected. A check someone added on purpose says what they expect to see,
29
+ // and quietly checking something else instead would make it a lie.
30
+
31
+ // How long a step waits for its element by the recorded name before asking
32
+ // whether it was renamed. A step whose element is merely slow is not
33
+ // affected: when the page does not prove a rename, the step carries on
34
+ // waiting exactly as it always did.
35
+ const PROBE_MS = 5000;
36
+
37
+ const ACTIONS = [
38
+ 'click', 'dblclick', 'tap', 'hover', 'focus',
39
+ 'fill', 'clear', 'press', 'pressSequentially', 'type',
40
+ 'check', 'uncheck', 'setChecked', 'selectOption', 'setInputFiles',
41
+ ];
42
+
43
+ // The same groups the baseline signature uses: its "actions" are buttons and
44
+ // links, its "fields" are form controls.
45
+ const ACTION_ROLES = new Set(['button', 'link']);
46
+ const FIELD_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'listbox', 'spinbutton', 'checkbox', 'radio', 'slider', 'switch']);
47
+
48
+ function norm(text) {
49
+ return String(text == null ? '' : text).replace(/\s+/g, ' ').trim().toLowerCase();
50
+ }
51
+
52
+ /**
53
+ * Names on the page by role, read from Playwright's accessibility snapshot -
54
+ * the same names getByRole matches against, so nothing is guessed.
55
+ *
56
+ * - button "Reserve here"
57
+ * - link "Story":
58
+ * - textbox "Email" [disabled]
59
+ */
60
+ function namesByRole(snapshot) {
61
+ const out = new Map();
62
+ for (const line of String(snapshot || '').split('\n')) {
63
+ const m = line.match(/^\s*- ([a-z]+) "((?:[^"\\]|\\.)*)"/);
64
+ if (!m) continue;
65
+ let name;
66
+ try {
67
+ name = JSON.parse('"' + m[2] + '"');
68
+ } catch (e) {
69
+ continue;
70
+ }
71
+ if (!name) continue;
72
+ if (!out.has(m[1])) out.set(m[1], []);
73
+ out.get(m[1]).push(name);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * The whole decision, with nothing but names in and out.
80
+ *
81
+ * `wanted` is the name the step was recorded with, `before` and `now` the
82
+ * names of that kind on the page then and now, and `candidates` the names
83
+ * now on the page with the step's own role. Returns the new name, or null
84
+ * when the page does not prove a rename.
85
+ */
86
+ function decideRename(wanted, before, now, candidates) {
87
+ const target = norm(wanted);
88
+ if (!target || !Array.isArray(before) || !before.length) return null;
89
+ const nowSet = new Set((now || []).map(norm));
90
+ const beforeSet = new Set(before.map(norm));
91
+
92
+ const gone = [...beforeSet].filter((n) => !nowSet.has(n));
93
+ const added = [...nowSet].filter((n) => !beforeSet.has(n));
94
+ if (gone.length !== 1 || added.length !== 1) return null;
95
+ // getByRole matches a name that contains the recorded one, so the name
96
+ // that went is the step's own only if it contains what was recorded.
97
+ if (!gone[0].includes(target)) return null;
98
+
99
+ // Of the step's own role. A button renamed while the recorded link went
100
+ // missing is two changes, not one rename.
101
+ return (candidates || []).find((c) => norm(c) === added[0]) || null;
102
+ }
103
+
104
+ /**
105
+ * Watches one test's page. `memory` is what the last passing run noted per
106
+ * step, `fallback` the last page's signature; either may be missing.
107
+ */
108
+ function watch(page, options) {
109
+ const opts = options || {};
110
+ const memory = opts.memory && typeof opts.memory === 'object' ? opts.memory : null;
111
+ const fallback = opts.fallback && typeof opts.fallback === 'object' ? opts.fallback : null;
112
+ const probeMs = opts.probeMs || PROBE_MS;
113
+
114
+ const learned = {};
115
+ const renamed = [];
116
+ let counter = 0;
117
+
118
+ const byRole = page.getByRole.bind(page);
119
+
120
+ async function readRoles() {
121
+ try {
122
+ return namesByRole(await page.locator('body').ariaSnapshot({ timeout: 3000 }));
123
+ } catch (e) {
124
+ return null; // a page that cannot be read proves nothing
125
+ }
126
+ }
127
+
128
+ async function findRename(key, role, name) {
129
+ const roles = await readRoles();
130
+ if (!roles) return null;
131
+ const ofRole = roles.get(role) || [];
132
+ if (memory && Array.isArray(memory[key])) {
133
+ return decideRename(name, memory[key], ofRole, ofRole);
134
+ }
135
+ if (!fallback) return null;
136
+ const kind = ACTION_ROLES.has(role) ? 'actions' : FIELD_ROLES.has(role) ? 'fields' : null;
137
+ if (!kind || !Array.isArray(fallback[kind])) return null;
138
+ const group = kind === 'actions' ? ACTION_ROLES : FIELD_ROLES;
139
+ const now = [];
140
+ for (const [r, names] of roles) if (group.has(r)) now.push(...names);
141
+ return decideRename(name, fallback[kind], now, ofRole);
142
+ }
143
+
144
+ async function act(locator, real, method, args, role, name) {
145
+ const key = counter++ + ' ' + role + ' ' + name;
146
+ const there = await locator
147
+ .first()
148
+ .waitFor({ state: 'attached', timeout: probeMs })
149
+ .then(() => true, () => false);
150
+ if (there) {
151
+ const roles = await readRoles();
152
+ if (roles) learned[key] = roles.get(role) || [];
153
+ return real.apply(locator, args);
154
+ }
155
+
156
+ const newName = await findRename(key, role, name);
157
+ if (newName) {
158
+ const replacement = byRole(role, { name: newName, exact: true });
159
+ const count = await replacement.count().catch(() => 0);
160
+ if (count === 1) {
161
+ renamed.push({ role: role, was: name, now: newName });
162
+ return replacement[method].apply(replacement, args);
163
+ }
164
+ }
165
+ // Not a rename this can prove: the step fails the way it always did.
166
+ return real.apply(locator, args);
167
+ }
168
+
169
+ page.getByRole = function (role, roleOptions) {
170
+ const locator = byRole(role, roleOptions);
171
+ const name = roleOptions && typeof roleOptions.name === 'string' ? roleOptions.name : null;
172
+ if (!name) return locator;
173
+ for (const method of ACTIONS) {
174
+ const real = locator[method];
175
+ if (typeof real !== 'function') continue;
176
+ locator[method] = function () {
177
+ return act(locator, real, method, Array.prototype.slice.call(arguments), role, name);
178
+ };
179
+ }
180
+ return locator;
181
+ };
182
+
183
+ return {
184
+ learned: learned,
185
+ renamed: renamed,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * What to keep for next time: the steps this run found by their recorded
191
+ * name, over whatever was kept before. A step that had to follow a rename
192
+ * keeps its old note - that note is what lets the next run prove the same
193
+ * rename again.
194
+ */
195
+ function mergeMemory(previous, learned) {
196
+ const out = {};
197
+ if (previous && typeof previous === 'object') Object.assign(out, previous);
198
+ if (learned && typeof learned === 'object') Object.assign(out, learned);
199
+ return out;
200
+ }
201
+
202
+ /** The report line, shared by the terminal and the history. */
203
+ function describeRename(entry) {
204
+ const what = entry.role === 'link' ? 'link' : entry.role === 'button' ? 'button' : entry.role;
205
+ return 'The ' + what + ' "' + entry.was + '" is now called "' + entry.now + '" - I used "' + entry.now + '" and carried on.';
206
+ }
207
+
208
+ module.exports = {
209
+ PROBE_MS: PROBE_MS,
210
+ namesByRole: namesByRole,
211
+ decideRename: decideRename,
212
+ watch: watch,
213
+ mergeMemory: mergeMemory,
214
+ describeRename: describeRename,
215
+ };
@@ -238,6 +238,31 @@ function noRecordingsLines() {
238
238
  // missing before the browser, which is a 200MB download on a new machine, and
239
239
  // offers to add it.
240
240
 
241
+ /**
242
+ * The installed kryptheon a recording saved in this folder will load - the
243
+ * nearest node_modules/kryptheon going up - or null if there is none.
244
+ *
245
+ * `check` must be run by this same copy. Found through the MCP server, then
246
+ * measured by hand in a folder `record` had set up: `npx kryptheon@latest
247
+ * check` runs npx's own copy, whose Playwright loads the config, while the
248
+ * recordings load this one's - and Playwright stops with "Requiring
249
+ * @playwright/test second time" before a single recording runs.
250
+ */
251
+ function kryptheonCopyFor(dir) {
252
+ let current = path.resolve(String(dir || ''));
253
+ for (;;) {
254
+ const candidate = path.join(current, 'node_modules', 'kryptheon');
255
+ // Whole copies only. A half-finished install - the fixture there, the rest
256
+ // not - was handed the run and answered with Node's own stack trace.
257
+ const whole = ['kryptheon-fixture.js', 'package.json', path.join('bin', 'kryptheon.js')]
258
+ .every((f) => fs.existsSync(path.join(candidate, f)));
259
+ if (whole) return candidate;
260
+ const parent = path.dirname(current);
261
+ if (parent === current) return null;
262
+ current = parent;
263
+ }
264
+ }
265
+
241
266
  /**
242
267
  * Whether kryptheon can be loaded by a recording saved in this folder.
243
268
  *
@@ -505,6 +530,7 @@ module.exports = {
505
530
  ESCAPE_HATCH: ESCAPE_HATCH,
506
531
  HOME_RECORDINGS: HOME_RECORDINGS,
507
532
  kryptheonReachableFrom: kryptheonReachableFrom,
533
+ kryptheonCopyFor: kryptheonCopyFor,
508
534
  kryptheonInstalledIn: kryptheonInstalledIn,
509
535
  projectAbove: projectAbove,
510
536
  setupPlan: setupPlan,
@@ -5,6 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const recordings = require('./kryptheon-recordings.js');
8
+ const heal = require('./kryptheon-heal.js');
8
9
 
9
10
  // An assistant runs this after every change, so it needs a mode that says
10
11
  // nothing at all when the news is good.
@@ -57,6 +58,13 @@ const ROLE_NOUNS = {
57
58
  option: 'option',
58
59
  img: 'image',
59
60
  dialog: 'dialog',
61
+ // What the recorder's "assert text" button picks when the text is in the
62
+ // body of the page: "a check on the main" meant nothing to anyone.
63
+ main: 'main part of the page',
64
+ navigation: 'menu',
65
+ banner: 'top of the page',
66
+ contentinfo: 'footer',
67
+ status: 'status message',
60
68
  };
61
69
 
62
70
  // Returns a lower-case noun phrase such as: the button "Request my demo".
@@ -69,7 +77,9 @@ function describeLocator(locator) {
69
77
  m = raw.match(/getByRole\(\s*['"]([^'"]+)['"]\s*,\s*\{[^}]*name:\s*['"]([^'"]*)['"]/);
70
78
  if (m) return 'the ' + (ROLE_NOUNS[m[1]] || m[1]) + ' "' + m[2] + '"';
71
79
 
72
- m = raw.match(/getByRole\(\s*['"]([^'"]+)['"]\s*\)/);
80
+ // With no name, or with options that are not a name - { level: 1 } is
81
+ // what a check on a page's main heading gets.
82
+ m = raw.match(/getByRole\(\s*['"]([^'"]+)['"]\s*(?:,\s*\{[^}]*\}\s*)?\)/);
73
83
  if (m) return 'the ' + (ROLE_NOUNS[m[1]] || m[1]);
74
84
 
75
85
  m = raw.match(/getByText\(\s*['"]([^'"]*)['"]/);
@@ -361,21 +371,50 @@ function translate(message) {
361
371
  };
362
372
  }
363
373
 
374
+ // What the recorder's snapshot button writes. Its diff is a page of YAML,
375
+ // which says nothing to someone who has never seen one.
376
+ if (/expect\([^)]*\)(?:\.not)?\.toMatchAriaSnapshot\(/.test(msg)) {
377
+ return {
378
+ reason: capitalise(subject || 'part of the page') + ' is laid out differently than when this check was recorded.',
379
+ advice: 'something in it was added, removed or renamed - the screenshot shows how it looks now.',
380
+ };
381
+ }
382
+
364
383
  // Value comparisons: toHaveTitle / toHaveText / toHaveValue / toHaveCount.
365
- m = msg.match(/expect\([^)]*\)(?:\.not)?\.(toHave\w+|toContainText)\([^)]*\)\s*failed/);
384
+ m = msg.match(/expect\([^)]*\)(\.not)?\.(toHave\w+|toContainText)\([^)]*\)\s*failed/);
366
385
  if (m) {
367
- const expected = (msg.match(/^\s*Expected:\s*(.+)$/m) || [])[1];
368
- const received = (msg.match(/^\s*Received:\s*(.+)$/m) || [])[1];
369
- const matcher = m[1];
386
+ // Playwright names the lines after what it compared: "Expected substring"
387
+ // for toContainText, "Expected string" for toHaveText and toHaveValue,
388
+ // "Expected pattern" for a regex. Reading only "Expected:" missed every
389
+ // check the recorder's own buttons write.
390
+ const expected = (msg.match(/^\s*Expected(?: substring| string| pattern)?:\s*(.+)$/m) || [])[1];
391
+ const received = (msg.match(/^\s*Received(?: string)?:\s*(.+)$/m) || [])[1];
392
+ const negated = Boolean(m[1]);
393
+ const matcher = m[2];
370
394
  const what =
371
395
  matcher === 'toHaveTitle' ? 'the page title'
372
396
  : matcher === 'toHaveURL' ? 'the page address'
373
397
  : matcher === 'toHaveCount' ? 'the number of matching items'
374
398
  : subject ? subject.charAt(0).toLowerCase() + subject.slice(1)
375
399
  : 'the element';
400
+ // A whole page of text is not an answer anyone can read. Say what was
401
+ // looked for, and show what was there only when it is short enough to be
402
+ // the thing someone meant.
403
+ const shown = received && received.trim().length <= 82 ? received.trim() : null;
404
+ if (expected && matcher === 'toContainText') {
405
+ return {
406
+ reason: negated
407
+ ? 'The test expected ' + what + ' not to say ' + expected.trim() + ', but it does.'
408
+ : 'The test expected ' + what + ' to say ' + expected.trim() + ', but ' +
409
+ (shown ? 'it says ' + shown + '.' : 'that text is not there.'),
410
+ advice: 'the wording on the page may have changed, or the step before it did not do what it should.',
411
+ };
412
+ }
376
413
  if (expected && received) {
377
414
  return {
378
- reason: 'The test expected ' + what + ' to be ' + expected.trim() + ', but found ' + received.trim() + '.',
415
+ reason: negated
416
+ ? 'The test expected ' + what + ' not to be ' + expected.trim() + ', but it is.'
417
+ : 'The test expected ' + what + ' to be ' + expected.trim() + ', but found ' + (shown || 'something else') + '.',
379
418
  advice: 'the wording on the page may have changed, or the page shown was not the one expected.',
380
419
  };
381
420
  }
@@ -480,6 +519,21 @@ function readObservations(attachments) {
480
519
  }
481
520
  }
482
521
 
522
+ // Renamed buttons and fields the replay followed (kryptheon-heal.js), each
523
+ // already one sentence. Empty when there were none or the note is unreadable.
524
+ function readRenamed(attachments) {
525
+ const found = (attachments || []).find(function (a) {
526
+ return a.name === 'kryptheon-renamed' && a.body;
527
+ });
528
+ if (!found) return [];
529
+ try {
530
+ const list = JSON.parse(found.body.toString('utf8'));
531
+ return Array.isArray(list) ? list.filter(function (e) { return e && e.was && e.now; }) : [];
532
+ } catch (e) {
533
+ return [];
534
+ }
535
+ }
536
+
483
537
  function uniq(list) {
484
538
  const seen = Object.create(null);
485
539
  const out = [];
@@ -755,11 +809,24 @@ class KryptheonReporter {
755
809
  return;
756
810
  }
757
811
 
812
+ const renamed = readRenamed(result.attachments);
813
+ if (renamed.length) record.renamed = renamed;
814
+ const renamedLines = renamed.map(function (entry) {
815
+ return ' Renamed: ' + heal.describeRename(entry);
816
+ });
817
+
758
818
  if (status === 'passed') {
759
819
  this.records.push(record);
760
820
  if (!QUIET) {
761
821
  process.stdout.write('OK ' + test.title + ' (' + formatDuration(result.duration) + ')\n');
762
822
  }
823
+ // Said even when quiet: a pass that followed a rename is still a pass,
824
+ // but the person should hear that their button has a new name.
825
+ if (renamedLines.length) {
826
+ if (QUIET) process.stdout.write('OK ' + test.title + '\n');
827
+ process.stdout.write(renamedLines.join('\n') + '\n');
828
+ process.stdout.write(' Nothing is broken. To stop seeing this, record the flow again.\n');
829
+ }
763
830
  return;
764
831
  }
765
832
 
@@ -833,6 +900,9 @@ class KryptheonReporter {
833
900
  for (const detail of details) out.push(' ' + detail);
834
901
 
835
902
  out.push(lastPass ? ' This was working on ' + formatWhen(lastPass) + '.' : ' This has not passed before.');
903
+ // On the way to the failure. It explains why the steps before it worked
904
+ // even though a name in the recording no longer matches.
905
+ for (const entry of renamed) out.push(' Renamed on the way: ' + heal.describeRename(entry));
836
906
 
837
907
  // A first run that fails is a different situation from a regression:
838
908
  // nothing has broken, because nothing ever worked. Far more often the
@@ -183,6 +183,23 @@ function setDifference(before, after) {
183
183
  };
184
184
  }
185
185
 
186
+ // A rename the replay already followed is not also a difference in how the
187
+ // page ended up. Takes the pair out of the lists - both halves, or neither.
188
+ function withoutRenames(diff, renamed) {
189
+ const norm = (text) => String(text == null ? '' : text).replace(/\s+/g, ' ').trim().toLowerCase();
190
+ let gone = diff.gone;
191
+ let added = diff.added;
192
+ for (const entry of renamed || []) {
193
+ if (!entry || !entry.was || !entry.now) continue;
194
+ const was = gone.filter((item) => norm(item).includes(norm(entry.was)));
195
+ const now = added.filter((item) => norm(item) === norm(entry.now));
196
+ if (was.length !== 1 || now.length !== 1) continue;
197
+ gone = gone.filter((item) => item !== was[0]);
198
+ added = added.filter((item) => item !== now[0]);
199
+ }
200
+ return { gone: gone, added: added };
201
+ }
202
+
186
203
  function requestLabel(req) {
187
204
  return (req.method || 'GET') + ' ' + req.path + ' ' + req.status;
188
205
  }
@@ -200,14 +217,17 @@ function quoteList(items) {
200
217
  * alone, and a run whose capture failed knows nothing - neither is evidence of
201
218
  * a regression, and treating them as one would fail every old project on
202
219
  * upgrade.
220
+ *
221
+ * `renamed` is what the replay followed on the way (kryptheon-heal.js): the
222
+ * same rename showing up again on the last page is not a second problem.
203
223
  */
204
- function compareSignatures(previous, current) {
224
+ function compareSignatures(previous, current, renamed) {
205
225
  if (!previous || typeof previous !== 'object') return null;
206
226
  if (!current || typeof current !== 'object') return null;
207
227
 
208
228
  const headings = setDifference(previous.headings, current.headings);
209
- const actions = setDifference(previous.actions, current.actions);
210
- const fields = setDifference(previous.fields, current.fields);
229
+ const actions = withoutRenames(setDifference(previous.actions, current.actions), renamed);
230
+ const fields = withoutRenames(setDifference(previous.fields, current.fields), renamed);
211
231
 
212
232
  // Only new failures matter. A 500 that has been fixed since the baseline was
213
233
  // taken is an improvement, and failing the run for it would be perverse.
package/package.json CHANGED
@@ -1,76 +1,78 @@
1
- {
2
- "name": "kryptheon",
3
- "version": "0.1.16",
4
- "description": "Record what you do in your app and get plain-language reports when it breaks.",
5
- "license": "MIT",
6
- "type": "commonjs",
7
- "bin": {
8
- "kryptheon": "bin/kryptheon.js"
9
- },
10
- "exports": {
11
- ".": "./kryptheon-fixture.js",
12
- "./kryptheon-fixture": "./kryptheon-fixture.js",
13
- "./kryptheon-reporter": "./kryptheon-reporter.js",
14
- "./package.json": "./package.json",
15
- "./kryptheon-replay": "./kryptheon-replay.js",
16
- "./kryptheon-signature": "./kryptheon-signature.js",
17
- "./kryptheon-secrets": "./kryptheon-secrets.js"
18
- },
19
- "files": [
20
- "bin/",
21
- "kryptheon-fixture.js",
22
- "kryptheon-reporter.js",
23
- "kryptheon-signature.js",
24
- "kryptheon-secrets.js",
25
- "playwright.config.js",
26
- "README.md",
27
- "kryptheon-replay.js",
28
- "kryptheon-selectors.js",
29
- "kryptheon-recordings.js",
30
- "kryptheon-cleanup.js",
31
- "kryptheon-baselines.js",
32
- "kryptheon-project.js"
33
- ],
34
- "engines": {
35
- "node": ">=20.6.0"
36
- },
37
- "dependencies": {
38
- "@playwright/test": "^1.62.1"
39
- },
40
- "scripts": {
41
- "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-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-load.check.js && node kryptheon-package.check.js",
42
- "check:reporter": "node kryptheon-reporter.check.js",
43
- "check:signature": "node kryptheon-signature.check.js",
44
- "check:stream": "node kryptheon-signature.stream.check.js",
45
- "check:baseline": "node kryptheon-signature.baseline.check.js",
46
- "check:settle": "node kryptheon-signature.settle.check.js",
47
- "check:number": "node kryptheon-signature.number.check.js",
48
- "check:env": "node kryptheon-env.check.js",
49
- "check:watchdog": "node kryptheon-watchdog.check.js",
50
- "check:record": "node kryptheon-record.check.js",
51
- "check:selectors": "node kryptheon-selectors.check.js",
52
- "check:recordings": "node kryptheon-recordings.check.js",
53
- "check:cleanup": "node kryptheon-cleanup.check.js",
54
- "check:orphan": "node kryptheon-orphan.check.js",
55
- "check:project": "node kryptheon-project.check.js",
56
- "check:setup": "node kryptheon-setup.check.js",
57
- "check:browser": "node kryptheon-browser.check.js",
58
- "check:load": "node kryptheon-load.check.js",
59
- "check:package": "node kryptheon-package.check.js",
60
- "prepublishOnly": "node kryptheon-package.check.js"
61
- },
62
- "keywords": [
63
- "playwright",
64
- "testing",
65
- "end-to-end",
66
- "cli"
67
- ],
68
- "repository": {
69
- "type": "git",
70
- "url": "git+https://github.com/jhashantanu987-beep/kryptheon-cli.git"
71
- },
72
- "bugs": {
73
- "url": "https://github.com/jhashantanu987-beep/kryptheon-cli/issues"
74
- },
75
- "homepage": "https://github.com/jhashantanu987-beep/kryptheon-cli#readme"
76
- }
1
+ {
2
+ "name": "kryptheon",
3
+ "version": "0.1.18",
4
+ "description": "Record what you do in your app and get plain-language reports when it breaks.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "kryptheon": "bin/kryptheon.js"
9
+ },
10
+ "exports": {
11
+ ".": "./kryptheon-fixture.js",
12
+ "./kryptheon-fixture": "./kryptheon-fixture.js",
13
+ "./kryptheon-reporter": "./kryptheon-reporter.js",
14
+ "./package.json": "./package.json",
15
+ "./kryptheon-replay": "./kryptheon-replay.js",
16
+ "./kryptheon-signature": "./kryptheon-signature.js",
17
+ "./kryptheon-secrets": "./kryptheon-secrets.js"
18
+ },
19
+ "files": [
20
+ "bin/",
21
+ "kryptheon-fixture.js",
22
+ "kryptheon-reporter.js",
23
+ "kryptheon-signature.js",
24
+ "kryptheon-secrets.js",
25
+ "playwright.config.js",
26
+ "README.md",
27
+ "kryptheon-replay.js",
28
+ "kryptheon-heal.js",
29
+ "kryptheon-selectors.js",
30
+ "kryptheon-recordings.js",
31
+ "kryptheon-cleanup.js",
32
+ "kryptheon-baselines.js",
33
+ "kryptheon-project.js"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20.6.0"
37
+ },
38
+ "dependencies": {
39
+ "@playwright/test": "^1.62.1"
40
+ },
41
+ "scripts": {
42
+ "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-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",
43
+ "check:reporter": "node kryptheon-reporter.check.js",
44
+ "check:signature": "node kryptheon-signature.check.js",
45
+ "check:stream": "node kryptheon-signature.stream.check.js",
46
+ "check:baseline": "node kryptheon-signature.baseline.check.js",
47
+ "check:settle": "node kryptheon-signature.settle.check.js",
48
+ "check:number": "node kryptheon-signature.number.check.js",
49
+ "check:env": "node kryptheon-env.check.js",
50
+ "check:watchdog": "node kryptheon-watchdog.check.js",
51
+ "check:record": "node kryptheon-record.check.js",
52
+ "check:selectors": "node kryptheon-selectors.check.js",
53
+ "check:recordings": "node kryptheon-recordings.check.js",
54
+ "check:cleanup": "node kryptheon-cleanup.check.js",
55
+ "check:orphan": "node kryptheon-orphan.check.js",
56
+ "check:project": "node kryptheon-project.check.js",
57
+ "check:setup": "node kryptheon-setup.check.js",
58
+ "check:browser": "node kryptheon-browser.check.js",
59
+ "check:heal": "node kryptheon-heal.check.js",
60
+ "check:load": "node kryptheon-load.check.js",
61
+ "check:package": "node kryptheon-package.check.js",
62
+ "prepublishOnly": "node kryptheon-package.check.js"
63
+ },
64
+ "keywords": [
65
+ "playwright",
66
+ "testing",
67
+ "end-to-end",
68
+ "cli"
69
+ ],
70
+ "repository": {
71
+ "type": "git",
72
+ "url": "git+https://github.com/jhashantanu987-beep/kryptheon-cli.git"
73
+ },
74
+ "bugs": {
75
+ "url": "https://github.com/jhashantanu987-beep/kryptheon-cli/issues"
76
+ },
77
+ "homepage": "https://github.com/jhashantanu987-beep/kryptheon-cli#readme"
78
+ }