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,245 @@
|
|
|
1
|
+
// Taking the fumbling out of a recording before it becomes a baseline.
|
|
2
|
+
//
|
|
3
|
+
// Codegen records every keystroke, including the wrong ones. A username typed
|
|
4
|
+
// once with a typo and again correctly comes out as two fills of the same box:
|
|
5
|
+
//
|
|
6
|
+
// await page.getByRole('textbox', { name: 'Username' }).fill('shantanu892');
|
|
7
|
+
// await page.getByRole('textbox', { name: 'Username' }).fill('shantanu89');
|
|
8
|
+
//
|
|
9
|
+
// Kept as-is, that is not a tidiness problem. The first fill is a whole wrong
|
|
10
|
+
// login attempt performed on every run from then on, and the mistake is baked
|
|
11
|
+
// into the baseline as if it were the intended flow.
|
|
12
|
+
//
|
|
13
|
+
// The same happens with a stray click on nothing - getByRole('img') with no
|
|
14
|
+
// name and no text identifies nothing, and clicking it does nothing - and with
|
|
15
|
+
// the same button clicked twice because the first one seemed not to take.
|
|
16
|
+
//
|
|
17
|
+
// Everything here follows one rule: when in doubt, keep it. Leaving a pointless
|
|
18
|
+
// step in a recording costs a moment. Removing a real one silently changes what
|
|
19
|
+
// the test does, and nobody finds out until the thing it was covering breaks
|
|
20
|
+
// unnoticed. So every removal below needs the steps to be adjacent, to be the
|
|
21
|
+
// same action on the same element, and to have nothing between them - anything
|
|
22
|
+
// less certain is left alone.
|
|
23
|
+
//
|
|
24
|
+
// Pure string work: no Playwright, no filesystem.
|
|
25
|
+
|
|
26
|
+
const selectors = require('./kryptheon-selectors.js');
|
|
27
|
+
|
|
28
|
+
/* --------------------------------------------------------------------------
|
|
29
|
+
Reading the recording as steps.
|
|
30
|
+
-------------------------------------------------------------------------- */
|
|
31
|
+
|
|
32
|
+
// Only the things a person does to the page.
|
|
33
|
+
//
|
|
34
|
+
// Assertions end in toBeVisible, toHaveText and the like, none of which are
|
|
35
|
+
// here, so an assertion is never read as a step in the first place. There was
|
|
36
|
+
// an explicit guard against them as well; mutation testing showed removing it
|
|
37
|
+
// changed nothing, and a line that reads like the reason something is safe -
|
|
38
|
+
// while not being the reason - is worse than no line at all. This list is the
|
|
39
|
+
// reason. Anything added to it has to be a thing a person actually did.
|
|
40
|
+
const ACTION = /\.\s*(click|dblclick|fill|type|press|check|uncheck|selectOption|hover|tap|setInputFiles|clear|goto)\s*\(/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every line that does something to the page, with the parts that matter.
|
|
44
|
+
*
|
|
45
|
+
* `chain` is how the element was found, which is what makes two steps "the
|
|
46
|
+
* same element". It comes from the same reader the fragile-selector report
|
|
47
|
+
* uses, so the two can never disagree about what a step points at.
|
|
48
|
+
*/
|
|
49
|
+
function readSteps(source) {
|
|
50
|
+
const steps = [];
|
|
51
|
+
String(source || '')
|
|
52
|
+
.split('\n')
|
|
53
|
+
.forEach((raw, i) => {
|
|
54
|
+
const line = raw.trim();
|
|
55
|
+
if (!/\bpage\s*\./.test(line)) return;
|
|
56
|
+
const match = line.match(ACTION);
|
|
57
|
+
if (!match) return;
|
|
58
|
+
steps.push({
|
|
59
|
+
line: i + 1,
|
|
60
|
+
text: line,
|
|
61
|
+
action: match[1],
|
|
62
|
+
chain: selectors.selectorChain(line),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
return steps;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The readable name of the thing a step acts on, for saying what went. */
|
|
69
|
+
function labelFor(chain) {
|
|
70
|
+
const text = String(chain || '');
|
|
71
|
+
let m = text.match(/name:\s*(['"])(.*?)\1/);
|
|
72
|
+
if (m) return m[2];
|
|
73
|
+
m = text.match(/getBy(?:Label|Placeholder|Text|TestId|Title|AltText)\(\s*(['"])(.*?)\1/);
|
|
74
|
+
if (m) return m[2];
|
|
75
|
+
m = text.match(/getByRole\(\s*(['"])(.*?)\1/);
|
|
76
|
+
if (m) return 'the ' + m[2];
|
|
77
|
+
m = text.match(/locator\(\s*(['"])(.*?)\1/);
|
|
78
|
+
if (m) return m[2];
|
|
79
|
+
return 'it';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* --------------------------------------------------------------------------
|
|
83
|
+
A step that points at nothing.
|
|
84
|
+
-------------------------------------------------------------------------- */
|
|
85
|
+
|
|
86
|
+
// Anything that says which element is meant. One of these present is enough
|
|
87
|
+
// for a step to be left alone.
|
|
88
|
+
const IDENTIFYING = /name\s*:|getByLabel|getByPlaceholder|getByTestId|getByTitle|getByAltText|getByText/;
|
|
89
|
+
|
|
90
|
+
// What a .filter({ hasText: ... }) is matching on, if anything.
|
|
91
|
+
const HAS_TEXT = /has(?:Not)?Text\s*:\s*(\/(?:\\.|[^/\\])*\/[a-z]*|(['"])(?:\\.|(?!\2).)*\2)/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a text filter actually narrows anything down.
|
|
95
|
+
*
|
|
96
|
+
* `hasText: /^$/` is the one codegen writes when there was no text to match
|
|
97
|
+
* on - it says "an element with empty text", which is every empty element on
|
|
98
|
+
* the page. Written out as a test rather than folded into the pattern above,
|
|
99
|
+
* because a lookahead there can slide along and match at the space instead,
|
|
100
|
+
* which quietly reads an empty filter as identifying.
|
|
101
|
+
*/
|
|
102
|
+
function narrowsAnything(chain) {
|
|
103
|
+
const m = String(chain || '').match(HAS_TEXT);
|
|
104
|
+
if (!m) return false;
|
|
105
|
+
const value = m[1];
|
|
106
|
+
if (value[0] !== '/') return value.length > 2; // a non-empty string
|
|
107
|
+
const body = value.slice(1, value.lastIndexOf('/'));
|
|
108
|
+
return body !== '' && body !== '^$';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A CSS selector that picks something out: an id, a class, an attribute.
|
|
112
|
+
const ANCHORED = /locator\(\s*(['"])[^'"]*[#.\[][^'"]*\1/;
|
|
113
|
+
|
|
114
|
+
// Roles that are not there to be used. A click on one of these does nothing,
|
|
115
|
+
// which is why codegen only ever writes them by accident. Interactive roles -
|
|
116
|
+
// button, link, textbox - are deliberately absent even when unnamed: an
|
|
117
|
+
// unnamed button is still a button, and removing a click on one would change
|
|
118
|
+
// what the recording does.
|
|
119
|
+
const INERT_ROLE = /getByRole\(\s*(['"])(img|image|generic|presentation|none|paragraph|separator|figure)\1/;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A click on something with nothing to identify it.
|
|
123
|
+
*
|
|
124
|
+
* Both halves are required: the step must name nothing at all, and the element
|
|
125
|
+
* must be of a kind that does nothing when clicked. Either one on its own is
|
|
126
|
+
* not enough to be sure.
|
|
127
|
+
*/
|
|
128
|
+
function pointsAtNothing(step) {
|
|
129
|
+
if (!step || step.action !== 'click') return false;
|
|
130
|
+
const chain = step.chain;
|
|
131
|
+
if (IDENTIFYING.test(chain)) return false;
|
|
132
|
+
if (narrowsAnything(chain)) return false;
|
|
133
|
+
if (ANCHORED.test(chain)) return false;
|
|
134
|
+
return INERT_ROLE.test(chain);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* --------------------------------------------------------------------------
|
|
138
|
+
The clean-up.
|
|
139
|
+
-------------------------------------------------------------------------- */
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The steps to take out, and why, in the words they will be reported in.
|
|
143
|
+
*
|
|
144
|
+
* Nothing here looks at the values that were typed. They are not needed to
|
|
145
|
+
* decide anything, and by this point a password may already have been moved
|
|
146
|
+
* into an environment variable - printing "typed X then Y" would put the very
|
|
147
|
+
* thing that was just hidden back on the screen.
|
|
148
|
+
*/
|
|
149
|
+
function findRedundantSteps(source) {
|
|
150
|
+
const steps = readSteps(source);
|
|
151
|
+
const removals = [];
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < steps.length; i++) {
|
|
154
|
+
const step = steps[i];
|
|
155
|
+
const next = steps[i + 1];
|
|
156
|
+
|
|
157
|
+
if (pointsAtNothing(step)) {
|
|
158
|
+
removals.push({
|
|
159
|
+
line: step.line,
|
|
160
|
+
text: step.text,
|
|
161
|
+
kind: 'points-at-nothing',
|
|
162
|
+
why: 'a click on something with nothing to identify it',
|
|
163
|
+
});
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!next || next.chain !== step.chain || step.action === 'goto') continue;
|
|
168
|
+
|
|
169
|
+
// Adjacent in the list, so nothing happened in between. A fill with
|
|
170
|
+
// another action between it and the next one is somebody going back to a
|
|
171
|
+
// field on purpose, and both are kept.
|
|
172
|
+
if (step.action === 'fill' && next.action === 'fill') {
|
|
173
|
+
removals.push({
|
|
174
|
+
line: step.line,
|
|
175
|
+
text: step.text,
|
|
176
|
+
kind: 'typed-twice',
|
|
177
|
+
why: labelFor(step.chain) + ' was typed twice in a row, and only the last one counts',
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (step.action === 'click' && next.action === 'click') {
|
|
183
|
+
// The second click is the one dropped: the first is what the person
|
|
184
|
+
// meant to do, and it is the one whose effect the rest of the recording
|
|
185
|
+
// was made against.
|
|
186
|
+
removals.push({
|
|
187
|
+
line: next.line,
|
|
188
|
+
text: next.text,
|
|
189
|
+
kind: 'clicked-twice',
|
|
190
|
+
why: labelFor(step.chain) + ' was clicked twice in a row, and once is enough',
|
|
191
|
+
});
|
|
192
|
+
i++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return removals;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The recording with the fumbling taken out.
|
|
201
|
+
*
|
|
202
|
+
* Lines are removed by number and nothing else is touched, so what comes back
|
|
203
|
+
* is the file as it was minus whole lines - never reformatted, never rewritten.
|
|
204
|
+
*/
|
|
205
|
+
function cleanRecording(source) {
|
|
206
|
+
const text = String(source || '');
|
|
207
|
+
const removed = findRedundantSteps(text);
|
|
208
|
+
if (!removed.length) return { source: text, removed: [] };
|
|
209
|
+
|
|
210
|
+
const drop = new Set(removed.map((r) => r.line));
|
|
211
|
+
const kept = text.split('\n').filter((line, i) => !drop.has(i + 1));
|
|
212
|
+
return { source: kept.join('\n'), removed: removed };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What was taken out, said plainly.
|
|
217
|
+
*
|
|
218
|
+
* Never silent about it. A tool that edits what you recorded and does not say
|
|
219
|
+
* so is worse than one that leaves the mess in - the next time the recording
|
|
220
|
+
* does something unexpected, you have no way of knowing it was changed.
|
|
221
|
+
*/
|
|
222
|
+
function describeCleanup(removed) {
|
|
223
|
+
const list = removed || [];
|
|
224
|
+
if (!list.length) return [];
|
|
225
|
+
|
|
226
|
+
const lines = [];
|
|
227
|
+
lines.push('');
|
|
228
|
+
lines.push(
|
|
229
|
+
' ' + list.length + (list.length === 1 ? ' step was' : ' steps were') + ' taken out of the recording:',
|
|
230
|
+
);
|
|
231
|
+
for (const item of list) lines.push(' - ' + item.why);
|
|
232
|
+
lines.push('');
|
|
233
|
+
lines.push(' Everything else was left exactly as you did it.');
|
|
234
|
+
lines.push('');
|
|
235
|
+
return lines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
readSteps: readSteps,
|
|
240
|
+
findRedundantSteps: findRedundantSteps,
|
|
241
|
+
cleanRecording: cleanRecording,
|
|
242
|
+
describeCleanup: describeCleanup,
|
|
243
|
+
pointsAtNothing: pointsAtNothing,
|
|
244
|
+
labelFor: labelFor,
|
|
245
|
+
};
|
|
@@ -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
|
|