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 CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // The kryptheon CLI. A thin wrapper so nobody has to remember Playwright's
3
3
  // command line or edit a config file. Everything here shells out to the
4
4
  // Playwright binary that ships with this package.
@@ -14,6 +14,9 @@ const { spawn, spawnSync } = require('child_process');
14
14
  const os = require('os');
15
15
  const secrets = require(require('path').join(__dirname, '..', 'kryptheon-secrets.js'));
16
16
  const replay = require('../kryptheon-replay.js');
17
+ const selectors = require('../kryptheon-selectors.js');
18
+ const recordings = require('../kryptheon-recordings.js');
19
+ const cleanup = require('../kryptheon-cleanup.js');
17
20
 
18
21
  const PACKAGE_DIR = path.join(__dirname, '..');
19
22
  const USER_DIR = process.cwd();
@@ -66,6 +69,7 @@ function usage() {
66
69
  console.log(' kryptheon record <url> open your app and record what you do as a test');
67
70
  console.log(' kryptheon check run every recorded test and report in plain language');
68
71
  console.log(' kryptheon accept <name> agree that one test\'s new result is the correct one');
72
+ console.log(' kryptheon remove [name] list the recordings, or remove one you no longer want');
69
73
  console.log(' kryptheon setup-ai tell your AI assistant to check its work');
70
74
  console.log('');
71
75
  console.log(' add --quiet to check for one line when everything passes');
@@ -853,6 +857,89 @@ function reportSecrets(replacements) {
853
857
  console.log('');
854
858
  }
855
859
 
860
+ // One keypress, no Enter, because the safe answer is the default and getting
861
+ // out of the way should cost nothing.
862
+ //
863
+ // Falls back to the default where there is no terminal to read keys from - a
864
+ // CI job, a pipe, an assistant running the command - rather than waiting for
865
+ // an answer that will never arrive.
866
+ function askKey(question, keys, fallback) {
867
+ return new Promise((resolve) => {
868
+ const allowed = keys.map((k) => k.toLowerCase());
869
+ if (!process.stdin.isTTY || !process.stdin.setRawMode) return resolve(fallback);
870
+ process.stdout.write(question);
871
+
872
+ const finish = (answer) => {
873
+ process.stdin.removeListener("data", onKey);
874
+ try {
875
+ process.stdin.setRawMode(false);
876
+ process.stdin.pause();
877
+ } catch (err) {
878
+ /* nothing more to do */
879
+ }
880
+ process.stdout.write(answer + String.fromCharCode(10));
881
+ resolve(answer);
882
+ };
883
+
884
+ const onKey = (chunk) => {
885
+ const key = String(chunk);
886
+ // In raw mode Ctrl-C arrives as a keystroke rather than a signal, so it
887
+ // has to be turned back into one. Sent as a signal rather than exiting
888
+ // here, because exiting would cut off whatever is still buffered - and
889
+ // it must not be read as an answer either: stopping is not "keep going".
890
+ if (key === String.fromCharCode(3)) {
891
+ process.stdin.removeListener('data', onKey);
892
+ try {
893
+ process.stdin.setRawMode(false);
894
+ process.stdin.pause();
895
+ } catch (err) {
896
+ /* nothing more to do */
897
+ }
898
+ process.stdout.write(String.fromCharCode(10));
899
+ process.kill(process.pid, 'SIGINT');
900
+ return;
901
+ }
902
+ if (key === String.fromCharCode(13) || key === String.fromCharCode(10)) return finish(fallback);
903
+ const pressed = key.toLowerCase();
904
+ if (allowed.indexOf(pressed) === -1) return; // an unknown key: keep waiting
905
+ finish(pressed);
906
+ };
907
+
908
+ try {
909
+ process.stdin.setRawMode(true);
910
+ } catch (err) {
911
+ return resolve(fallback);
912
+ }
913
+ process.stdin.resume();
914
+ process.stdin.on("data", onKey);
915
+ });
916
+ }
917
+
918
+ // Removing a recording means removing what was remembered about it too, or
919
+ // the saved results of a file that no longer exists stay behind for ever.
920
+ function deleteRecording(name) {
921
+ const relative = path.join("tests", name).split(path.sep).join("/");
922
+ try {
923
+ fs.unlinkSync(path.join(TESTS_DIR, name));
924
+ } catch (err) {
925
+ return { ok: false, name: name, why: err.message };
926
+ }
927
+
928
+ try {
929
+ const api = require(path.join(PACKAGE_DIR, "kryptheon-fixture.js"));
930
+ const all = api.readBaselines(api.BASELINE_FILE);
931
+ const keys = recordings.baselineKeysForSpec(all, relative);
932
+ if (keys.length) {
933
+ for (const key of keys) delete all[key];
934
+ fs.writeFileSync(api.BASELINE_FILE, JSON.stringify(all, null, 2) + String.fromCharCode(10), "utf8");
935
+ }
936
+ } catch (err) {
937
+ // The recording is gone either way, and a leftover baseline is not worth
938
+ // failing the command over.
939
+ }
940
+ return { ok: true, name: name, file: relative };
941
+ }
942
+
856
943
  function askYesNo(question) {
857
944
  return new Promise((resolve) => {
858
945
  if (!process.stdin.isTTY) return resolve(false);
@@ -871,6 +958,24 @@ function askYesNo(question) {
871
958
  });
872
959
  }
873
960
 
961
+ // A typed answer, for questions whose answers are not single letters.
962
+ function askLine(question) {
963
+ return new Promise((resolve) => {
964
+ if (!process.stdin.isTTY) return resolve("");
965
+ const readline = require("readline");
966
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
967
+ rl.question(question, (answer) => {
968
+ rl.close();
969
+ try {
970
+ process.stdin.pause();
971
+ } catch (err) {
972
+ /* nothing to do */
973
+ }
974
+ resolve(String(answer));
975
+ });
976
+ });
977
+ }
978
+
874
979
  // Ending on a logout means the next run starts somewhere else. Almost nobody
875
980
  // wants that, so ask.
876
981
  async function offerToDropLogout(relativeFile) {
@@ -897,6 +1002,37 @@ async function offerToDropLogout(relativeFile) {
897
1002
  return true;
898
1003
  }
899
1004
 
1005
+ // Codegen writes whatever identified the element at the moment it was clicked,
1006
+ // which on a page of live figures means the figures end up in the selector. Say
1007
+ // so, and say nothing when there is nothing to say.
1008
+ // Codegen records the wrong keystrokes along with the right ones, so a typo
1009
+ // corrected on the second attempt becomes a wrong login performed on every
1010
+ // run. This takes those out and says what it took, because a tool that edits
1011
+ // your recording without telling you is worse than one that leaves the mess.
1012
+ function tidyRecording(relativeFile) {
1013
+ const source = readSpec(relativeFile);
1014
+ if (source === null) return [];
1015
+ const result = cleanup.cleanRecording(source);
1016
+ if (!result.removed.length) return [];
1017
+ if (!writeSpec(relativeFile, result.source)) return [];
1018
+ return result.removed;
1019
+ }
1020
+
1021
+ // Said after the file has its name, with the other closing notes, so the
1022
+ // order on screen matches the order things happened in.
1023
+ function reportTidying(removed) {
1024
+ for (const line of cleanup.describeCleanup(removed)) console.log(line);
1025
+ return removed || [];
1026
+ }
1027
+
1028
+ function reportFragileSelectors(relativeFile) {
1029
+ const source = readSpec(relativeFile);
1030
+ if (source === null) return [];
1031
+ const findings = selectors.findFragileSelectors(source);
1032
+ for (const line of selectors.describeFragileSelectors(findings)) console.log(line);
1033
+ return findings;
1034
+ }
1035
+
900
1036
  function reportReplayRisks(relativeFile) {
901
1037
  const source = readSpec(relativeFile);
902
1038
  if (source === null) return [];
@@ -937,16 +1073,23 @@ async function finaliseRecording(outFile, context) {
937
1073
  // this point. Take it out before doing anything else with the file.
938
1074
  const secrets = takeOutSecrets(outFile);
939
1075
 
1076
+ // After the secrets, deliberately. By this point a password is an
1077
+ // environment variable rather than a literal, so nothing that is said
1078
+ // about a field that was typed twice can put the value back on screen.
1079
+ const tidied = tidyRecording(outFile);
1080
+
940
1081
  const named = await nameRecording(outFile);
941
1082
  console.log('');
942
1083
  console.log(' Saved to ' + named);
943
1084
  console.log(' Run it any time with: kryptheon check');
944
1085
  console.log('');
945
1086
 
1087
+ reportTidying(tidied);
946
1088
  reportSecrets(secrets);
947
1089
  keepArtefactsOutOfGit();
948
1090
  await offerToDropLogout(named);
949
1091
  reportReplayRisks(named);
1092
+ reportFragileSelectors(named);
950
1093
 
951
1094
  return { code: 0, savedAs: named };
952
1095
  }
@@ -1003,6 +1146,125 @@ async function finaliseRecording(outFile, context) {
1003
1146
  return { code: 1, savedAs: null };
1004
1147
  }
1005
1148
 
1149
+ // Every record writes another file and check runs all of them, so a first day
1150
+ // spent learning the tool leaves a pile of recordings that never worked and a
1151
+ // check that reads like a wall of failures. This is the moment asking costs
1152
+ // nothing.
1153
+ //
1154
+ // Keeping is the default because it is the answer that cannot lose work, and
1155
+ // the other one is a single keypress away. Where there is no terminal to
1156
+ // answer with - a CI job, an assistant running the command - nothing is asked
1157
+ // and nothing is deleted: a question nobody can see is not a question.
1158
+ async function askAboutExisting(names) {
1159
+ if (!names.length || !process.stdin.isTTY) return false;
1160
+
1161
+ for (const line of recordings.existingRecordingsQuestion(names.length)) console.log(line);
1162
+ const answer = await askKey(" Keep or replace? [K/r] ", ["k", "r"], "k");
1163
+ if (answer !== "r") return false;
1164
+
1165
+ console.log("");
1166
+ console.log(" They will be deleted once this new recording is saved.");
1167
+ return true;
1168
+ }
1169
+
1170
+ // The old recordings, once the new one is safely on disk. The new file is
1171
+ // never in this list, but it is checked by name anyway - deleting the thing
1172
+ // just recorded is the one mistake here that cannot be undone.
1173
+ function dropOldRecordings(names, savedAs) {
1174
+ const keep = path.basename(String(savedAs || ""));
1175
+ const gone = [];
1176
+ for (const name of names) {
1177
+ if (path.basename(name) === keep) continue;
1178
+ const result = deleteRecording(name);
1179
+ if (result.ok) gone.push(name);
1180
+ }
1181
+ if (!gone.length) return gone;
1182
+ console.log(" Removed " + recordings.plural(gone.length, "older recording") + ":");
1183
+ for (const name of gone) console.log(" " + path.join("tests", name));
1184
+ console.log("");
1185
+ return gone;
1186
+ }
1187
+
1188
+ // --- remove -----------------------------------------------------------------
1189
+ //
1190
+ // Recordings pile up. Without a way to take one out, the only way to stop a
1191
+ // half-finished experiment being run for ever is to know where the files live
1192
+ // and delete one by hand.
1193
+
1194
+ function listRecordings(names) {
1195
+ console.log("");
1196
+ console.log(" " + recordings.plural(names.length, "recording") + " in this project:");
1197
+ console.log("");
1198
+ names.forEach((name, i) => {
1199
+ console.log(" " + String(i + 1).padStart(2) + " " + path.join("tests", name));
1200
+ });
1201
+ console.log("");
1202
+ }
1203
+
1204
+ async function remove(name) {
1205
+ const names = listSpecFiles();
1206
+ if (!names.length) {
1207
+ console.log("");
1208
+ console.log(" There are no recordings to remove.");
1209
+ console.log("");
1210
+ return 0;
1211
+ }
1212
+
1213
+ let choice;
1214
+ if (name) {
1215
+ choice = recordings.chooseFromList(names, name);
1216
+ } else {
1217
+ listRecordings(names);
1218
+ if (!process.stdin.isTTY) {
1219
+ console.log(" To remove one, run: kryptheon remove <name>");
1220
+ console.log("");
1221
+ return 0;
1222
+ }
1223
+ const answer = await askLine(" Which one? [number, a for all, Enter to cancel] ");
1224
+ choice = recordings.chooseFromList(names, answer);
1225
+ }
1226
+
1227
+ if (choice.action === "cancel") {
1228
+ console.log("");
1229
+ console.log(" Nothing was removed.");
1230
+ console.log("");
1231
+ return 0;
1232
+ }
1233
+ if (choice.action === "unclear") {
1234
+ console.error("");
1235
+ console.error(" " + choice.why + ": " + JSON.stringify(choice.said));
1236
+ console.error(" Run \"kryptheon remove\" on its own to see the list.");
1237
+ console.error("");
1238
+ return 1;
1239
+ }
1240
+
1241
+ const gone = [];
1242
+ const failed = [];
1243
+ for (const target of choice.names) {
1244
+ const result = deleteRecording(target);
1245
+ if (result.ok) gone.push(target);
1246
+ else failed.push(result);
1247
+ }
1248
+
1249
+ console.log("");
1250
+ if (gone.length) {
1251
+ console.log(" Removed " + recordings.plural(gone.length, "recording") + ":");
1252
+ for (const g of gone) console.log(" " + path.join("tests", g));
1253
+ console.log("");
1254
+ console.log(
1255
+ gone.length === 1
1256
+ ? " Its saved result was removed too, so nothing is left behind."
1257
+ : " Their saved results were removed too, so nothing is left behind.",
1258
+ );
1259
+ console.log("");
1260
+ }
1261
+ for (const f of failed) {
1262
+ console.error(" Could not remove " + path.join("tests", f.name) + ": " + f.why);
1263
+ console.error("");
1264
+ }
1265
+ return failed.length ? 1 : 0;
1266
+ }
1267
+
1006
1268
  async function record(url) {
1007
1269
  if (!url) {
1008
1270
  console.error('');
@@ -1025,6 +1287,9 @@ async function record(url) {
1025
1287
 
1026
1288
  if (!ensureBrowser()) return 1;
1027
1289
 
1290
+ const existingBefore = listSpecFiles();
1291
+ const replaceExisting = await askAboutExisting(existingBefore);
1292
+
1028
1293
  const hadTestsDir = fs.existsSync(TESTS_DIR);
1029
1294
  try {
1030
1295
  fs.mkdirSync(TESTS_DIR, { recursive: true });
@@ -1064,6 +1329,9 @@ async function record(url) {
1064
1329
  stderr: stderr,
1065
1330
  reason: reason,
1066
1331
  });
1332
+ // Only now, and only if something was actually saved. Deleting first
1333
+ // would mean a recording that went wrong takes the old ones with it.
1334
+ if (replaceExisting && outcome.savedAs) dropOldRecordings(existingBefore, outcome.savedAs);
1067
1335
  return outcome.code;
1068
1336
  };
1069
1337
 
@@ -1606,6 +1874,9 @@ async function main() {
1606
1874
  case 'check':
1607
1875
  return finishWith(check({ quiet: rest.indexOf('--quiet') !== -1 || rest.indexOf('-q') !== -1 }));
1608
1876
  break;
1877
+ case 'remove':
1878
+ case 'rm':
1879
+ return finishWith(await remove(rest.join(" ").trim()));
1609
1880
  case 'setup-ai':
1610
1881
  return finishWith(setupAi());
1611
1882
  case 'accept':
@@ -1660,6 +1931,14 @@ module.exports = {
1660
1931
  inspectBrowserWindows: inspectBrowserWindows,
1661
1932
  parseWindowProbe: parseWindowProbe,
1662
1933
  codegenComplaints: codegenComplaints,
1934
+ reportFragileSelectors: reportFragileSelectors,
1935
+ tidyRecording: tidyRecording,
1936
+ reportTidying: reportTidying,
1937
+ remove: remove,
1938
+ deleteRecording: deleteRecording,
1939
+ dropOldRecordings: dropOldRecordings,
1940
+ askAboutExisting: askAboutExisting,
1941
+ askKey: askKey,
1663
1942
  WINDOW_TIMEOUT_MS: WINDOW_TIMEOUT_MS,
1664
1943
  WINDOW_HARD_LIMIT_MS: WINDOW_HARD_LIMIT_MS,
1665
1944
  WINDOW_POLL_MS: WINDOW_POLL_MS,
@@ -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
+ };