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.
@@ -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
+ };