kryptheon 0.1.9 → 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 +280 -1
- 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 -58
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Telling apart the two things a red line can mean.
|
|
2
|
+
//
|
|
3
|
+
// A recording that used to pass and now does not is a regression: something
|
|
4
|
+
// changed and it is worth looking at. A recording that has never passed once is
|
|
5
|
+
// not a regression - there is nothing to regress from. Usually it was made
|
|
6
|
+
// while learning the tool, or it contains a step that cannot happen twice.
|
|
7
|
+
//
|
|
8
|
+
// Counting them together is what makes the first day miserable. Someone records
|
|
9
|
+
// three or four flows while working out what this is, runs check, and reads
|
|
10
|
+
// "2 working, 4 broken" - so the tool looks like it is reporting four faults in
|
|
11
|
+
// an app that is fine. Nothing is broken. Four recordings simply never had a
|
|
12
|
+
// baseline.
|
|
13
|
+
//
|
|
14
|
+
// Pure string and number work: no filesystem, no Playwright, so the reporter,
|
|
15
|
+
// the command line and the unit checks all use the same rules.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How a run actually went, in the three states that mean different things.
|
|
19
|
+
*
|
|
20
|
+
* `unproven` is deliberately not folded into `broken`. It is the whole point.
|
|
21
|
+
*/
|
|
22
|
+
function summarise(records) {
|
|
23
|
+
const list = records || [];
|
|
24
|
+
let working = 0;
|
|
25
|
+
let broken = 0;
|
|
26
|
+
let unproven = 0;
|
|
27
|
+
|
|
28
|
+
for (const record of list) {
|
|
29
|
+
if (!record) continue;
|
|
30
|
+
if (record.status === 'passed') {
|
|
31
|
+
working++;
|
|
32
|
+
} else if (record.status === 'failed') {
|
|
33
|
+
if (record.neverPassed) unproven++;
|
|
34
|
+
else broken++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { working: working, broken: broken, unproven: unproven };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function plural(n, word) {
|
|
41
|
+
return n + ' ' + word + (n === 1 ? '' : 's');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The lines that explain a set of recordings that have never passed.
|
|
46
|
+
*
|
|
47
|
+
* Said as a state of the recordings rather than a fault in the app, because
|
|
48
|
+
* that is what it is, and because the reader's next move is to make each one
|
|
49
|
+
* pass once - not to go looking through their code.
|
|
50
|
+
*/
|
|
51
|
+
function unprovenLines(count) {
|
|
52
|
+
if (!count) return [];
|
|
53
|
+
const is = count === 1 ? 'has' : 'have';
|
|
54
|
+
const it = count === 1 ? 'it' : 'they';
|
|
55
|
+
return [
|
|
56
|
+
plural(count, 'recording') + ' ' + is + ' no baseline yet - ' + it + ' ' + is + ' never passed.',
|
|
57
|
+
'Each one has to pass once before it can tell you anything. Until then',
|
|
58
|
+
'nothing is known to be broken.',
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The summary at the end of a run, in two parts when the second part applies.
|
|
64
|
+
*
|
|
65
|
+
* The first line only ever counts recordings that once worked, so a green
|
|
66
|
+
* "0 broken" stays honest: it means nothing regressed, and the line underneath
|
|
67
|
+
* says what is still unproven rather than hiding it.
|
|
68
|
+
*/
|
|
69
|
+
function summaryLines(counts) {
|
|
70
|
+
const c = counts || { working: 0, broken: 0, unproven: 0 };
|
|
71
|
+
const lines = ['Summary: ' + c.working + ' working, ' + c.broken + ' broken.'];
|
|
72
|
+
for (const line of unprovenLines(c.unproven)) lines.push(line);
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The baseline keys belonging to one recording file.
|
|
78
|
+
*
|
|
79
|
+
* Baselines are keyed "tests/thing.spec.js :: Title", so removing a recording
|
|
80
|
+
* without removing these would leave the saved results of a file that is no
|
|
81
|
+
* longer there.
|
|
82
|
+
*/
|
|
83
|
+
function baselineKeysForSpec(baselines, specPath) {
|
|
84
|
+
const wanted = String(specPath || '').split('\\').join('/');
|
|
85
|
+
if (!wanted) return [];
|
|
86
|
+
return Object.keys(baselines || {}).filter((key) => {
|
|
87
|
+
const file = String(key).split(' :: ')[0].split('\\').join('/');
|
|
88
|
+
return file === wanted;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What a person meant by their answer to a numbered list.
|
|
94
|
+
*
|
|
95
|
+
* Accepts a number, a file name, "all", or nothing at all. Anything else is
|
|
96
|
+
* reported as not understood rather than guessed at - these answers delete
|
|
97
|
+
* files, so a near-miss must never be treated as a choice.
|
|
98
|
+
*/
|
|
99
|
+
function chooseFromList(names, answer) {
|
|
100
|
+
const list = names || [];
|
|
101
|
+
const said = String(answer == null ? '' : answer).trim();
|
|
102
|
+
if (!said) return { action: 'cancel' };
|
|
103
|
+
if (/^(a|all)$/i.test(said)) return { action: 'remove', names: list.slice() };
|
|
104
|
+
|
|
105
|
+
if (/^\d+$/.test(said)) {
|
|
106
|
+
const index = Number(said);
|
|
107
|
+
if (index < 1 || index > list.length) {
|
|
108
|
+
return { action: 'unclear', said: said, why: 'there is no recording numbered ' + index };
|
|
109
|
+
}
|
|
110
|
+
return { action: 'remove', names: [list[index - 1]] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const lower = said.toLowerCase();
|
|
114
|
+
const matches = list.filter((name) => {
|
|
115
|
+
const n = String(name).toLowerCase();
|
|
116
|
+
return n === lower || n === lower + '.spec.js' || n.replace(/\.spec\.js$/, '') === lower;
|
|
117
|
+
});
|
|
118
|
+
if (matches.length === 1) return { action: 'remove', names: matches };
|
|
119
|
+
if (matches.length > 1) {
|
|
120
|
+
return { action: 'unclear', said: said, why: 'more than one recording is called that' };
|
|
121
|
+
}
|
|
122
|
+
return { action: 'unclear', said: said, why: 'there is no recording called that' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What to ask when a project already has recordings and another is starting.
|
|
127
|
+
*
|
|
128
|
+
* Keeping is the default because it is the answer that cannot lose work.
|
|
129
|
+
*/
|
|
130
|
+
function existingRecordingsQuestion(count) {
|
|
131
|
+
return [
|
|
132
|
+
'',
|
|
133
|
+
' This project already has ' + plural(count, 'recording') + '.',
|
|
134
|
+
'',
|
|
135
|
+
' k keep them, and add this new one (default)',
|
|
136
|
+
' r replace them - delete ' + (count === 1 ? 'it' : 'all ' + count) + ' and keep only this one',
|
|
137
|
+
'',
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
summarise: summarise,
|
|
143
|
+
summaryLines: summaryLines,
|
|
144
|
+
unprovenLines: unprovenLines,
|
|
145
|
+
baselineKeysForSpec: baselineKeysForSpec,
|
|
146
|
+
chooseFromList: chooseFromList,
|
|
147
|
+
existingRecordingsQuestion: existingRecordingsQuestion,
|
|
148
|
+
plural: plural,
|
|
149
|
+
};
|
package/kryptheon-reporter.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const recordings = require('./kryptheon-recordings.js');
|
|
7
8
|
|
|
8
9
|
// An assistant runs this after every change, so it needs a mode that says
|
|
9
10
|
// nothing at all when the news is good.
|
|
@@ -712,6 +713,12 @@ class KryptheonReporter {
|
|
|
712
713
|
rawMessage: message,
|
|
713
714
|
observations: observations,
|
|
714
715
|
};
|
|
716
|
+
// Whether this ever worked decides more than one line of the report, so it
|
|
717
|
+
// is settled before the record is kept. A recording that has never passed
|
|
718
|
+
// is not a regression - there is nothing for it to have regressed from -
|
|
719
|
+
// and the summary counts it apart from the ones that broke.
|
|
720
|
+
const lastPass = this._lastPassed(test.title, recordingId);
|
|
721
|
+
record.neverPassed = !lastPass;
|
|
715
722
|
this.records.push(record);
|
|
716
723
|
|
|
717
724
|
const out = [];
|
|
@@ -721,7 +728,6 @@ class KryptheonReporter {
|
|
|
721
728
|
|
|
722
729
|
for (const detail of translated.details || []) out.push(' ' + detail);
|
|
723
730
|
|
|
724
|
-
const lastPass = this._lastPassed(test.title, recordingId);
|
|
725
731
|
out.push(lastPass ? ' This was working on ' + formatWhen(lastPass) + '.' : ' This has not passed before.');
|
|
726
732
|
|
|
727
733
|
// A first run that fails is a different situation from a regression:
|
|
@@ -788,14 +794,24 @@ class KryptheonReporter {
|
|
|
788
794
|
process.stdout.write('\n(could not write ' + path.basename(HISTORY_FILE) + ': ' + err.message + ')\n');
|
|
789
795
|
}
|
|
790
796
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
797
|
+
// "Broken" is kept for recordings that used to work. The ones that never
|
|
798
|
+
// did are counted and explained separately, because reading "4 broken" on
|
|
799
|
+
// the first day - when all four were made while learning the tool and none
|
|
800
|
+
// has ever had a baseline - says the app is at fault when nothing is.
|
|
801
|
+
const counts = recordings.summarise(this.records);
|
|
802
|
+
|
|
803
|
+
// The one line stands in for the whole report only when there is no report
|
|
804
|
+
// to make. A recording that has never passed is not counted as broken, but
|
|
805
|
+
// it did fail and its reasons were printed above, so the run still owes the
|
|
806
|
+
// reader its closing count.
|
|
807
|
+
if (QUIET && !counts.broken && !counts.unproven) {
|
|
808
|
+
const s = counts.working === 1 ? '' : 's';
|
|
809
|
+
process.stdout.write('OK ' + counts.working + ' recording' + s + ' still working.\n');
|
|
794
810
|
return;
|
|
795
811
|
}
|
|
796
812
|
|
|
797
813
|
process.stdout.write(
|
|
798
|
-
'\
|
|
814
|
+
'\n' + recordings.summaryLines(counts).join('\n') + '\n' +
|
|
799
815
|
'History saved to ' + path.basename(HISTORY_FILE) + '\n\n'
|
|
800
816
|
);
|
|
801
817
|
|
|
@@ -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
|
+
};
|