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 +34 -0
- package/bin/kryptheon.js +56 -2
- package/kryptheon-fixture.js +50 -5
- package/kryptheon-heal.js +215 -0
- package/kryptheon-project.js +26 -0
- package/kryptheon-reporter.js +76 -6
- package/kryptheon-signature.js +23 -3
- package/package.json +78 -76
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
|
-
|
|
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,
|
package/kryptheon-fixture.js
CHANGED
|
@@ -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
|
+
};
|
package/kryptheon-project.js
CHANGED
|
@@ -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,
|
package/kryptheon-reporter.js
CHANGED
|
@@ -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
|
-
|
|
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\([^)]*\)(
|
|
384
|
+
m = msg.match(/expect\([^)]*\)(\.not)?\.(toHave\w+|toContainText)\([^)]*\)\s*failed/);
|
|
366
385
|
if (m) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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:
|
|
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
|
package/kryptheon-signature.js
CHANGED
|
@@ -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.
|
|
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-
|
|
29
|
-
"kryptheon-
|
|
30
|
-
"kryptheon-
|
|
31
|
-
"kryptheon-
|
|
32
|
-
"kryptheon-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"check
|
|
43
|
-
"check:
|
|
44
|
-
"check:
|
|
45
|
-
"check:
|
|
46
|
-
"check:
|
|
47
|
-
"check:
|
|
48
|
-
"check:
|
|
49
|
-
"check:
|
|
50
|
-
"check:
|
|
51
|
-
"check:
|
|
52
|
-
"check:
|
|
53
|
-
"check:
|
|
54
|
-
"check:
|
|
55
|
-
"check:
|
|
56
|
-
"check:
|
|
57
|
-
"check:
|
|
58
|
-
"check:
|
|
59
|
-
"check:
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|