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