kryptheon 0.1.9 → 0.1.11

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,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
+ };
@@ -20,7 +20,8 @@ const base = require('@playwright/test');
20
20
  // Reuse the reporter's host-stripping so a baseline stores exactly the shape
21
21
  // the reporter prints.
22
22
  const { requestPath } = require('./kryptheon-reporter.js');
23
- const signature = require('./kryptheon-signature.js');
23
+ const signature = require('./kryptheon-signature.js');
24
+ const replay = require('./kryptheon-replay.js');
24
25
 
25
26
  const MAX_ITEMS = 5; // keep the failure block readable
26
27
 
@@ -128,6 +129,10 @@ function normalisedEntry(current) {
128
129
  // Only when there is one. A run whose capture failed must not write an empty
129
130
  // signature over a good one, or the next run would report everything as gone.
130
131
  if (current.signature) stored.signature = current.signature;
132
+ // What recording this was taken from. Without it, a saved result is tied
133
+ // only to a file name, and a re-recording that lands on the same name
134
+ // inherits a baseline it never created and can never match.
135
+ if (current.recordingId) stored.recordingId = current.recordingId;
131
136
  return stored;
132
137
  }
133
138
 
@@ -232,6 +237,17 @@ function newObservations(current, previous) {
232
237
  // Adds a signature to an accepted baseline that predates the feature, without
233
238
  // touching anything else about it - including when it was recorded, which is
234
239
  // what the failure report means by "this last worked at".
240
+ // Ties a saved result written before fingerprints existed to the recording it
241
+ // is now being compared against. Only ever on a pass, so an entry is never
242
+ // bound to a run that did not match it.
243
+ function stampRecording(file, key, recordingId) {
244
+ const all = readBaselines(file);
245
+ const entry = all[key];
246
+ if (!entry || typeof entry !== 'object' || entry.recordingId || !recordingId) return false;
247
+ entry.recordingId = recordingId;
248
+ return saveBaselines(file, all);
249
+ }
250
+
235
251
  function upgradeBaseline(file, key, sig) {
236
252
  const all = readBaselines(file);
237
253
  const entry = all[key];
@@ -247,6 +263,17 @@ function applyBaseline(file, key, current) {
247
263
  writeBaseline(file, key, current);
248
264
  return { status: 'created', message: null };
249
265
  }
266
+
267
+ // A saved result taken from a different recording describes a page this
268
+ // flow never visited. Comparing against it produces a failure on the first
269
+ // run that no amount of fixing the app can clear, so it is replaced rather
270
+ // than argued with. Only when both fingerprints are known: an entry from
271
+ // before they existed is left alone, because "cannot tell" must never
272
+ // become "accept whatever this run saw".
273
+ if (previous.recordingId && current.recordingId && previous.recordingId !== current.recordingId) {
274
+ writeBaseline(file, key, current);
275
+ return { status: 'created', message: null };
276
+ }
250
277
  const message = compareBaselines(previous, current);
251
278
  if (message) return { status: 'changed', message: message };
252
279
 
@@ -257,6 +284,10 @@ function applyBaseline(file, key, current) {
257
284
  upgradeBaseline(file, key, current.signature);
258
285
  return { status: 'learned', message: null };
259
286
  }
287
+ if (!previous.recordingId && current.recordingId) {
288
+ stampRecording(file, key, current.recordingId);
289
+ return { status: 'learned', message: null };
290
+ }
260
291
  return { status: 'match', message: null };
261
292
  }
262
293
 
@@ -283,6 +314,17 @@ const test = base.test.extend({
283
314
 
284
315
  const key = baselineKey(testInfo.file, testInfo.title);
285
316
 
317
+ // The steps this recording actually performs. Comments, blank lines and
318
+ // the test name are ignored, so tidying a file does not read as a
319
+ // different recording - only changing what it does.
320
+ const recordingId = (function () {
321
+ try {
322
+ return replay.recordingId(fs.readFileSync(testInfo.file, 'utf8'));
323
+ } catch (e) {
324
+ return null;
325
+ }
326
+ })();
327
+
286
328
  // Reports only what this run added on top of the last passing run.
287
329
  const attachObservations = async () => {
288
330
  let url = null;
@@ -351,6 +393,7 @@ const test = base.test.extend({
351
393
  consoleErrors: consoleErrors,
352
394
  failedRequests: failedRequests,
353
395
  signature: captured,
396
+ recordingId: recordingId,
354
397
  };
355
398
  } catch (e) {
356
399
  current = null; // page closed by the test - leave any baseline untouched
@@ -384,5 +427,6 @@ module.exports = {
384
427
  compareBaselines: compareBaselines,
385
428
  applyBaseline: applyBaseline,
386
429
  upgradeBaseline: upgradeBaseline,
430
+ stampRecording: stampRecording,
387
431
  BASELINE_FILE: BASELINE_FILE,
388
432
  };
@@ -0,0 +1,199 @@
1
+ // Working out whether this folder is a project at all.
2
+ //
3
+ // `kryptheon check` reads recordings out of ./tests and baselines out of the
4
+ // folder it was run in. Run it somewhere that is not a project - a home folder,
5
+ // the place a global install happened to leave you - and it will happily find
6
+ // whatever tests/ happens to be lying around and run those instead, reporting
7
+ // results for a project nobody asked about. That has already happened to
8
+ // somebody who installed into their home folder.
9
+ //
10
+ // Quietly testing the wrong project is the worst thing this tool can do. A
11
+ // green that belongs to something else is not a result, it is a lie with a tick
12
+ // next to it. So the answer to "is this a project?" has to be settled before
13
+ // anything runs, and the answer has to be a stop rather than a guess.
14
+ //
15
+ // Pure path work: nothing here runs a test or reads a recording.
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+
21
+ // For someone who genuinely keeps recordings outside a package. Deliberately
22
+ // long-winded: it should be something you decide to type, not something you
23
+ // stumble into.
24
+ const ESCAPE_HATCH = 'KRYPTHEON_ALLOW_NO_PACKAGE_JSON';
25
+
26
+ function hasPackageJson(dir) {
27
+ try {
28
+ return fs.statSync(path.join(dir, 'package.json')).isFile();
29
+ } catch (e) {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * One comparable form of a path.
36
+ *
37
+ * The trailing separator has to go or C:\Users and C:\Users\ never match, but
38
+ * it must not be taken off before the root test below. "C:" without a separator
39
+ * is not the drive - Node resolves it to the current directory - so a root
40
+ * compared in this form would quietly become "wherever I happen to be".
41
+ */
42
+ function tidy(dir) {
43
+ return path.resolve(String(dir || '')).replace(/[\\/]+$/, '').toLowerCase();
44
+ }
45
+
46
+ /** The folders that are somebody's whole account or the machine itself. */
47
+ function isHomeOrSystem(dir) {
48
+ // A drive or filesystem root: C:\, D:\, /. Done on the resolved path, with
49
+ // its separator still on, for the reason above.
50
+ const resolved = path.resolve(String(dir || ''));
51
+ if (resolved === path.parse(resolved).root) return true;
52
+
53
+ const here = tidy(dir);
54
+ if (here === tidy(os.homedir())) return true;
55
+
56
+ for (const candidate of systemFolders()) {
57
+ if (here === tidy(candidate)) return true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /**
63
+ * Every folder that belongs to the machine or to an account rather than to a
64
+ * project, resolved.
65
+ *
66
+ * The first two entries are derived from the running system; the literals after
67
+ * them are fallbacks for the usual layouts. They overlap - on Windows
68
+ * USERPROFILE is the home folder and "/Users" resolves to C:\Users - and the
69
+ * overlap is deliberate: a machine whose home is somewhere unusual is covered
70
+ * by the derived pair, and a machine whose environment is missing entries is
71
+ * covered by the literals.
72
+ */
73
+ function systemFolders() {
74
+ return [
75
+ // The folder every account lives in - C:\Users, /home, /Users. On its own
76
+ // it has no package.json so it would be refused anyway, but one stray npm
77
+ // install there and it would look exactly like a project.
78
+ path.dirname(os.homedir()),
79
+ process.env.SystemRoot,
80
+ process.env.ProgramFiles,
81
+ process.env['ProgramFiles(x86)'],
82
+ process.env.ProgramData,
83
+ process.env.APPDATA,
84
+ process.env.LOCALAPPDATA,
85
+ process.env.USERPROFILE,
86
+ '/root',
87
+ '/home',
88
+ '/Users',
89
+ '/usr',
90
+ '/etc',
91
+ '/bin',
92
+ '/var',
93
+ '/opt',
94
+ ].filter(Boolean);
95
+ }
96
+
97
+ /**
98
+ * Whether this folder can be checked, and if not, why not.
99
+ *
100
+ * `allowed` is the escape hatch having been set. It is reported rather than
101
+ * hidden, so a run that only worked because of it says so.
102
+ */
103
+ function inspectProject(dir, env) {
104
+ const environment = env || process.env;
105
+ const allowed = String(environment[ESCAPE_HATCH] || '') !== '';
106
+ const packaged = hasPackageJson(dir);
107
+ const personal = isHomeOrSystem(dir);
108
+ const base = { dir: dir, packaged: packaged, personal: personal, allowed: allowed };
109
+
110
+ if (allowed) return Object.assign({ ok: true, reason: null }, base);
111
+
112
+ // A home folder is refused even when it has a package.json, and this is not
113
+ // belt and braces - it is the case that actually happened. Someone installed
114
+ // the tool into their home folder, so npm left a package.json and a
115
+ // node_modules there, and a tests folder alongside them. Every sign this
116
+ // could look for says "project"; none of them is true. The measured state of
117
+ // one real machine: C:\Users\<name> holding package.json, node_modules and
118
+ // tests, none of which belong to anything.
119
+ if (personal) return Object.assign({ ok: false, reason: 'personal' }, base);
120
+ if (!packaged) return Object.assign({ ok: false, reason: 'no-package' }, base);
121
+ return Object.assign({ ok: true, reason: null }, base);
122
+ }
123
+
124
+ /**
125
+ * Why this folder is not a project, and what to do instead.
126
+ *
127
+ * The folder is printed back because the commonest version of this is not
128
+ * knowing where you are - a new terminal, a global install, a shell that opened
129
+ * somewhere else.
130
+ */
131
+ function noProjectLines(inspection) {
132
+ const lines = [''];
133
+
134
+ if (inspection.personal) {
135
+ lines.push(' This is your home or a system folder, not a project.');
136
+ lines.push('');
137
+ lines.push(' You are in: ' + inspection.dir);
138
+ lines.push('');
139
+ // Almost never deliberate, and the one place where running anyway does the
140
+ // most damage. Said explicitly because a home folder that has had anything
141
+ // installed into it looks exactly like a project from the outside.
142
+ lines.push(' Recordings and results are kept beside the app they belong to,');
143
+ lines.push(' so running here would check whichever tests folder happens to be');
144
+ lines.push(' sitting in this one - almost certainly not the one you meant.');
145
+ if (inspection.packaged) {
146
+ lines.push('');
147
+ // Said plainly, because otherwise the package.json sitting right there
148
+ // makes this message look like the bug rather than the diagnosis.
149
+ lines.push(' There is a package.json here, but that is not what makes a project.');
150
+ lines.push(' It looks like npm install was run in this folder by mistake, which');
151
+ lines.push(' leaves a package.json and a node_modules behind.');
152
+ }
153
+ lines.push('');
154
+ } else {
155
+ lines.push(' There is no project here - this folder has no package.json.');
156
+ lines.push('');
157
+ lines.push(' You are in: ' + inspection.dir);
158
+ lines.push('');
159
+ }
160
+
161
+ lines.push(' Change into your project folder and run this again:');
162
+ lines.push('');
163
+ lines.push(' cd path\\to\\your\\project');
164
+ lines.push(' kryptheon check');
165
+ lines.push('');
166
+ lines.push(' If you really do keep recordings outside a package, set');
167
+ lines.push(' ' + ESCAPE_HATCH + '=1 and this will run anyway.');
168
+ lines.push('');
169
+ return lines;
170
+ }
171
+
172
+ /** A real project, with nothing recorded in it yet. */
173
+ function noRecordingsLines() {
174
+ return [
175
+ '',
176
+ ' No recordings were found in this project.',
177
+ '',
178
+ ' This is not a failure, and there is nothing in the code to fix.',
179
+ '',
180
+ ' Record a flow through your app first:',
181
+ '',
182
+ ' kryptheon record http://localhost:3000',
183
+ '',
184
+ ' That opens a browser window, so it has to be run by a person in a',
185
+ ' normal terminal - it cannot be done from an AI assistant.',
186
+ '',
187
+ ];
188
+ }
189
+
190
+ module.exports = {
191
+ ESCAPE_HATCH: ESCAPE_HATCH,
192
+ hasPackageJson: hasPackageJson,
193
+ systemFolders: systemFolders,
194
+ tidy: tidy,
195
+ isHomeOrSystem: isHomeOrSystem,
196
+ inspectProject: inspectProject,
197
+ noProjectLines: noProjectLines,
198
+ noRecordingsLines: noRecordingsLines,
199
+ };
@@ -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
+ };