kryptheon 0.1.8 → 0.1.10
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/bin/kryptheon.js +532 -22
- package/kryptheon-cleanup.js +245 -0
- package/kryptheon-recordings.js +149 -0
- package/kryptheon-reporter.js +21 -5
- package/kryptheon-selectors.js +301 -0
- package/kryptheon-signature.js +553 -530
- package/package.json +67 -56
- package/bin/kryptheon-Shantanu.js +0 -1672
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Reading a recording back and asking: will this selector still match tomorrow?
|
|
2
|
+
//
|
|
3
|
+
// Codegen writes whatever identified the element at the moment it was clicked,
|
|
4
|
+
// and on a page full of live figures that means the numbers end up inside the
|
|
5
|
+
// selector:
|
|
6
|
+
//
|
|
7
|
+
// page.locator('div').filter({ hasText: 'Savings Rate 0%' }).nth(3)
|
|
8
|
+
//
|
|
9
|
+
// The 0% is a calculated value. Next week it is 4%, the selector matches
|
|
10
|
+
// nothing, and the test fails while the app is perfectly fine. That failure is
|
|
11
|
+
// a lie, and a red that means nothing is as bad as a green that means nothing.
|
|
12
|
+
//
|
|
13
|
+
// So this reads the file codegen produced and says which selectors are resting
|
|
14
|
+
// on something that moves. It only reports. Nothing here rewrites a selector:
|
|
15
|
+
// the right replacement depends on the app, and guessing would trade a visible
|
|
16
|
+
// problem for an invisible one.
|
|
17
|
+
//
|
|
18
|
+
// Pure string work: no Playwright, no filesystem, so the command line and the
|
|
19
|
+
// reporter can both use it.
|
|
20
|
+
|
|
21
|
+
/* --------------------------------------------------------------------------
|
|
22
|
+
What counts as data that moves.
|
|
23
|
+
-------------------------------------------------------------------------- */
|
|
24
|
+
|
|
25
|
+
// Money, in the shapes a UI actually prints it: a currency mark next to a
|
|
26
|
+
// number, or a grouped/decimal amount standing on its own.
|
|
27
|
+
const CURRENCY = /[$₹€£¥₽]\s*\d|\b\d{1,3}(?:,\d{3})+(?:\.\d+)?\b|\b\d+\.\d{2}\b/;
|
|
28
|
+
|
|
29
|
+
const PERCENT = /\d+(?:\.\d+)?\s*%/;
|
|
30
|
+
|
|
31
|
+
const MONTHS = /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
|
|
32
|
+
|
|
33
|
+
// Clock times, calendar dates, and the relative stamps that change by the hour.
|
|
34
|
+
const DATETIME = new RegExp(
|
|
35
|
+
[
|
|
36
|
+
'\\b\\d{1,2}:\\d{2}\\b',
|
|
37
|
+
'\\b\\d{4}-\\d{1,2}-\\d{1,2}\\b',
|
|
38
|
+
'\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b',
|
|
39
|
+
'\\b\\d+\\s*(?:second|minute|hour|day|week|month|year)s?\\s*ago\\b',
|
|
40
|
+
'\\b(?:just now|yesterday|today|tomorrow)\\b',
|
|
41
|
+
].join('|'),
|
|
42
|
+
'i',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What kind of moving data is in this text, if any.
|
|
47
|
+
*
|
|
48
|
+
* The kinds are ordered because they overlap - "$1,234.56" is currency before
|
|
49
|
+
* it is a number - and because only the last of them is a maybe.
|
|
50
|
+
*/
|
|
51
|
+
function volatileKind(text) {
|
|
52
|
+
const value = String(text || '');
|
|
53
|
+
if (PERCENT.test(value)) return 'percent';
|
|
54
|
+
if (CURRENCY.test(value)) return 'currency';
|
|
55
|
+
if (DATETIME.test(value)) return 'datetime';
|
|
56
|
+
if (MONTHS.test(value) && /\d/.test(value)) return 'datetime';
|
|
57
|
+
// A bare digit is the weakest signal. "Step 2 of 3" is a heading that will
|
|
58
|
+
// very likely still say that tomorrow, so it is reported differently rather
|
|
59
|
+
// than not at all - the reader knows their own app, and this does not.
|
|
60
|
+
if (/\d/.test(value)) return 'number';
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function describeKind(kind) {
|
|
65
|
+
if (kind === 'percent') return 'a percentage that is calculated from live data';
|
|
66
|
+
if (kind === 'currency') return 'an amount of money that changes as the data changes';
|
|
67
|
+
if (kind === 'datetime') return 'a date or time that will not be the same tomorrow';
|
|
68
|
+
return 'a number';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* --------------------------------------------------------------------------
|
|
72
|
+
Pulling the pieces out of a recorded line.
|
|
73
|
+
-------------------------------------------------------------------------- */
|
|
74
|
+
|
|
75
|
+
// Locators that match on text a person can read, and so can move when the
|
|
76
|
+
// content moves. getByLabel, getByPlaceholder and getByTestId are deliberately
|
|
77
|
+
// absent: a field's label and an author's test id are part of the app, not part
|
|
78
|
+
// of its data.
|
|
79
|
+
const TEXT_MATCHER = new RegExp(
|
|
80
|
+
[
|
|
81
|
+
'has(?:Not)?Text\\s*:\\s*(["\'])((?:\\\\.|(?!\\1)[^\\\\])*)\\1',
|
|
82
|
+
'getBy(?:Text|Title|AltText)\\s*\\(\\s*(["\'])((?:\\\\.|(?!\\3)[^\\\\])*)\\3',
|
|
83
|
+
'name\\s*:\\s*(["\'])((?:\\\\.|(?!\\5)[^\\\\])*)\\5',
|
|
84
|
+
].join('|'),
|
|
85
|
+
'g',
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// text= and :has-text() inside a raw CSS/Playwright selector string.
|
|
89
|
+
const INLINE_TEXT = /(?::has-text|:text-is|:text)\s*\(\s*(["'])((?:\\.|(?!\1)[^\\])*)\1|\btext\s*=\s*(["'])((?:\\.|(?!\3)[^\\])*)\3/g;
|
|
90
|
+
|
|
91
|
+
/** Every human-readable string this line matches on. */
|
|
92
|
+
function textMatchers(line) {
|
|
93
|
+
const text = String(line || '');
|
|
94
|
+
const found = [];
|
|
95
|
+
let m;
|
|
96
|
+
|
|
97
|
+
TEXT_MATCHER.lastIndex = 0;
|
|
98
|
+
while ((m = TEXT_MATCHER.exec(text)) !== null) {
|
|
99
|
+
const value = m[2] !== undefined ? m[2] : m[4] !== undefined ? m[4] : m[6];
|
|
100
|
+
if (value !== undefined) found.push(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
INLINE_TEXT.lastIndex = 0;
|
|
104
|
+
while ((m = INLINE_TEXT.exec(text)) !== null) {
|
|
105
|
+
const value = m[2] !== undefined ? m[2] : m[4];
|
|
106
|
+
if (value !== undefined) found.push(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Tags that say nothing about what the element is for. An element found by
|
|
113
|
+
// one of these has no anchor: it is wherever it happened to be that day.
|
|
114
|
+
const GENERIC_TAG = /^(?:div|span|p|li|ul|ol|td|tr|th|tbody|thead|table|section|article|main|header|footer|aside|nav|form|small|b|i|em|strong|figure)$/i;
|
|
115
|
+
|
|
116
|
+
/** The bare generic tag this line locates by, e.g. locator('div') -> div. */
|
|
117
|
+
function genericTagIn(line) {
|
|
118
|
+
const m = String(line || '').match(/\.\s*locator\s*\(\s*(["'])\s*([A-Za-z][A-Za-z0-9]*)\s*\1/);
|
|
119
|
+
if (!m) return null;
|
|
120
|
+
return GENERIC_TAG.test(m[2]) ? m[2].toLowerCase() : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The positional step this line leans on, e.g. .nth(3) or .first(). */
|
|
124
|
+
function positionalIn(line) {
|
|
125
|
+
const text = String(line || '');
|
|
126
|
+
const nth = text.match(/\.\s*nth\s*\(\s*(-?\d+)\s*\)/);
|
|
127
|
+
if (nth) return '.nth(' + nth[1] + ')';
|
|
128
|
+
if (/\.\s*first\s*\(\s*\)/.test(text)) return '.first()';
|
|
129
|
+
if (/\.\s*last\s*\(\s*\)/.test(text)) return '.last()';
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The things done to an element, as opposed to the things that find one. The
|
|
134
|
+
// selector is everything before these.
|
|
135
|
+
const ACTIONS = new RegExp(
|
|
136
|
+
'\\.\\s*(?:click|dblclick|fill|type|press|check|uncheck|selectOption|hover|tap|focus|blur|clear|' +
|
|
137
|
+
'setInputFiles|dragTo|scrollIntoViewIfNeeded|selectText|waitFor|screenshot|' +
|
|
138
|
+
'textContent|innerText|inputValue|isVisible|isChecked|count)\\s*\\(',
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The selector chain on a line, without the plumbing around it.
|
|
143
|
+
*
|
|
144
|
+
* Shown to the reader as-is, so it has to be the thing they will search their
|
|
145
|
+
* file for - not a summary of it.
|
|
146
|
+
*/
|
|
147
|
+
function selectorChain(line) {
|
|
148
|
+
let text = String(line || '').trim();
|
|
149
|
+
text = text.replace(/^await\s+/, '');
|
|
150
|
+
text = text.replace(/^(?:const|let|var)\s+\w+\s*=\s*/, '');
|
|
151
|
+
text = text.replace(/^expect\s*\(\s*/, '');
|
|
152
|
+
const page = text.match(/\bpage\s*\.\s*([\s\S]*)$/);
|
|
153
|
+
if (page) text = page[1];
|
|
154
|
+
|
|
155
|
+
// Cut at the action, so what is left is only how the element was found.
|
|
156
|
+
const action = text.match(ACTIONS);
|
|
157
|
+
if (action && action.index > 0) text = text.slice(0, action.index);
|
|
158
|
+
|
|
159
|
+
text = text.replace(/[\s;]+$/, '');
|
|
160
|
+
// An expect(...) wrapper leaves one unmatched closing bracket behind.
|
|
161
|
+
let depth = 0;
|
|
162
|
+
for (let i = 0; i < text.length; i++) {
|
|
163
|
+
if (text[i] === '(' || text[i] === '[' || text[i] === '{') depth++;
|
|
164
|
+
else if (text[i] === ')' || text[i] === ']' || text[i] === '}') {
|
|
165
|
+
depth--;
|
|
166
|
+
if (depth < 0) return text.slice(0, i).replace(/[\s.;]+$/, '');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return text;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* --------------------------------------------------------------------------
|
|
173
|
+
The report.
|
|
174
|
+
-------------------------------------------------------------------------- */
|
|
175
|
+
|
|
176
|
+
const TESTID_FIX =
|
|
177
|
+
'put a data-testid on the element you are clicking, then getByTestId(...)';
|
|
178
|
+
const TESTID_FIX_2 = 'will find it whatever the numbers say,';
|
|
179
|
+
const TEXT_FIX = 'or match on text in the app that does not change.';
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Every selector in a recording that is resting on something that moves.
|
|
183
|
+
*
|
|
184
|
+
* A line can be fragile for more than one reason at once, and usually is: the
|
|
185
|
+
* one that started this had live data in its text and a position in the DOM.
|
|
186
|
+
* Both are reported, because fixing one of them still leaves a test that lies.
|
|
187
|
+
*/
|
|
188
|
+
function findFragileSelectors(source) {
|
|
189
|
+
const findings = [];
|
|
190
|
+
const lines = String(source || '').split('\n');
|
|
191
|
+
|
|
192
|
+
lines.forEach((raw, i) => {
|
|
193
|
+
const line = raw.trim();
|
|
194
|
+
if (!/\bpage\s*\./.test(line)) return;
|
|
195
|
+
// A test id is an anchor the app author put there on purpose. Nothing on
|
|
196
|
+
// such a line is guesswork, so nothing on it is reported.
|
|
197
|
+
if (/getByTestId\s*\(/.test(line)) return;
|
|
198
|
+
|
|
199
|
+
const reasons = [];
|
|
200
|
+
|
|
201
|
+
for (const text of textMatchers(line)) {
|
|
202
|
+
const kind = volatileKind(text);
|
|
203
|
+
if (!kind) continue;
|
|
204
|
+
reasons.push({
|
|
205
|
+
kind: kind === 'number' ? 'number' : 'data',
|
|
206
|
+
soft: kind === 'number',
|
|
207
|
+
text: text,
|
|
208
|
+
why:
|
|
209
|
+
kind === 'number'
|
|
210
|
+
? "'" + text + "' has a number in it"
|
|
211
|
+
: "'" + text + "' contains " + describeKind(kind),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const positional = positionalIn(line);
|
|
216
|
+
if (positional) {
|
|
217
|
+
reasons.push({
|
|
218
|
+
kind: 'positional',
|
|
219
|
+
soft: false,
|
|
220
|
+
text: positional,
|
|
221
|
+
why: positional + ' depends on the order things appear in the page',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const tag = genericTagIn(line);
|
|
226
|
+
if (tag) {
|
|
227
|
+
reasons.push({
|
|
228
|
+
kind: 'generic',
|
|
229
|
+
soft: false,
|
|
230
|
+
text: tag,
|
|
231
|
+
why: "locator('" + tag + "') is any " + tag + " at all, with nothing to say which one is meant",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!reasons.length) return;
|
|
236
|
+
|
|
237
|
+
findings.push({
|
|
238
|
+
line: i + 1,
|
|
239
|
+
selector: selectorChain(line),
|
|
240
|
+
source: line,
|
|
241
|
+
reasons: reasons,
|
|
242
|
+
// A finding is only a maybe when every reason for it is a maybe.
|
|
243
|
+
soft: reasons.every((r) => r.soft),
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return findings;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The report, as lines, ready to print.
|
|
252
|
+
*
|
|
253
|
+
* Returns nothing at all when there is nothing to say. Silence is the correct
|
|
254
|
+
* output for a clean recording - a tool that always prints something trains
|
|
255
|
+
* people to stop reading it.
|
|
256
|
+
*/
|
|
257
|
+
function describeFragileSelectors(findings) {
|
|
258
|
+
const list = findings || [];
|
|
259
|
+
if (!list.length) return [];
|
|
260
|
+
|
|
261
|
+
const count = list.length;
|
|
262
|
+
const lines = [];
|
|
263
|
+
lines.push('');
|
|
264
|
+
lines.push(' ' + count + (count === 1 ? ' selector rests' : ' selectors rest') + ' on data that can change.');
|
|
265
|
+
lines.push(' If the data changes, this test fails even though the app is fine:');
|
|
266
|
+
lines.push('');
|
|
267
|
+
|
|
268
|
+
// The reasons sit directly under the selector they are about, so a line
|
|
269
|
+
// number reaching two digits does not shear the column apart.
|
|
270
|
+
const width = Math.max.apply(null, list.map((f) => String(f.line).length));
|
|
271
|
+
const indent = ' '.repeat(' line '.length + width + ': '.length);
|
|
272
|
+
|
|
273
|
+
for (const finding of list) {
|
|
274
|
+
const label = String(finding.line).padStart(width);
|
|
275
|
+
lines.push(' line ' + label + ': ' + finding.selector);
|
|
276
|
+
for (const reason of finding.reasons) lines.push(indent + reason.why);
|
|
277
|
+
if (finding.soft) {
|
|
278
|
+
lines.push(indent + 'this one may well be fine - only you know if that text moves');
|
|
279
|
+
}
|
|
280
|
+
lines.push('');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
lines.push(' Better: ' + TESTID_FIX);
|
|
284
|
+
lines.push(' ' + TESTID_FIX_2);
|
|
285
|
+
lines.push(' ' + TEXT_FIX);
|
|
286
|
+
lines.push('');
|
|
287
|
+
lines.push(' The test will still run. Just know that a failure here');
|
|
288
|
+
lines.push(' will not always mean a real bug.');
|
|
289
|
+
lines.push('');
|
|
290
|
+
return lines;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
findFragileSelectors: findFragileSelectors,
|
|
295
|
+
describeFragileSelectors: describeFragileSelectors,
|
|
296
|
+
volatileKind: volatileKind,
|
|
297
|
+
textMatchers: textMatchers,
|
|
298
|
+
selectorChain: selectorChain,
|
|
299
|
+
positionalIn: positionalIn,
|
|
300
|
+
genericTagIn: genericTagIn,
|
|
301
|
+
};
|