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.
- package/bin/kryptheon.js +329 -12
- package/kryptheon-baselines.js +176 -0
- package/kryptheon-cleanup.js +245 -0
- package/kryptheon-fixture.js +45 -1
- package/kryptheon-project.js +199 -0
- package/kryptheon-recordings.js +149 -0
- package/kryptheon-reporter.js +29 -6
- package/kryptheon-selectors.js +301 -0
- package/kryptheon-signature.js +553 -530
- package/package.json +17 -4
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,11 @@ 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');
|
|
20
|
+
const baselines = require('../kryptheon-baselines.js');
|
|
21
|
+
const project = require('../kryptheon-project.js');
|
|
17
22
|
|
|
18
23
|
const PACKAGE_DIR = path.join(__dirname, '..');
|
|
19
24
|
const USER_DIR = process.cwd();
|
|
@@ -66,6 +71,7 @@ function usage() {
|
|
|
66
71
|
console.log(' kryptheon record <url> open your app and record what you do as a test');
|
|
67
72
|
console.log(' kryptheon check run every recorded test and report in plain language');
|
|
68
73
|
console.log(' kryptheon accept <name> agree that one test\'s new result is the correct one');
|
|
74
|
+
console.log(' kryptheon remove [name] list the recordings, or remove one you no longer want');
|
|
69
75
|
console.log(' kryptheon setup-ai tell your AI assistant to check its work');
|
|
70
76
|
console.log('');
|
|
71
77
|
console.log(' add --quiet to check for one line when everything passes');
|
|
@@ -853,6 +859,89 @@ function reportSecrets(replacements) {
|
|
|
853
859
|
console.log('');
|
|
854
860
|
}
|
|
855
861
|
|
|
862
|
+
// One keypress, no Enter, because the safe answer is the default and getting
|
|
863
|
+
// out of the way should cost nothing.
|
|
864
|
+
//
|
|
865
|
+
// Falls back to the default where there is no terminal to read keys from - a
|
|
866
|
+
// CI job, a pipe, an assistant running the command - rather than waiting for
|
|
867
|
+
// an answer that will never arrive.
|
|
868
|
+
function askKey(question, keys, fallback) {
|
|
869
|
+
return new Promise((resolve) => {
|
|
870
|
+
const allowed = keys.map((k) => k.toLowerCase());
|
|
871
|
+
if (!process.stdin.isTTY || !process.stdin.setRawMode) return resolve(fallback);
|
|
872
|
+
process.stdout.write(question);
|
|
873
|
+
|
|
874
|
+
const finish = (answer) => {
|
|
875
|
+
process.stdin.removeListener("data", onKey);
|
|
876
|
+
try {
|
|
877
|
+
process.stdin.setRawMode(false);
|
|
878
|
+
process.stdin.pause();
|
|
879
|
+
} catch (err) {
|
|
880
|
+
/* nothing more to do */
|
|
881
|
+
}
|
|
882
|
+
process.stdout.write(answer + String.fromCharCode(10));
|
|
883
|
+
resolve(answer);
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const onKey = (chunk) => {
|
|
887
|
+
const key = String(chunk);
|
|
888
|
+
// In raw mode Ctrl-C arrives as a keystroke rather than a signal, so it
|
|
889
|
+
// has to be turned back into one. Sent as a signal rather than exiting
|
|
890
|
+
// here, because exiting would cut off whatever is still buffered - and
|
|
891
|
+
// it must not be read as an answer either: stopping is not "keep going".
|
|
892
|
+
if (key === String.fromCharCode(3)) {
|
|
893
|
+
process.stdin.removeListener('data', onKey);
|
|
894
|
+
try {
|
|
895
|
+
process.stdin.setRawMode(false);
|
|
896
|
+
process.stdin.pause();
|
|
897
|
+
} catch (err) {
|
|
898
|
+
/* nothing more to do */
|
|
899
|
+
}
|
|
900
|
+
process.stdout.write(String.fromCharCode(10));
|
|
901
|
+
process.kill(process.pid, 'SIGINT');
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (key === String.fromCharCode(13) || key === String.fromCharCode(10)) return finish(fallback);
|
|
905
|
+
const pressed = key.toLowerCase();
|
|
906
|
+
if (allowed.indexOf(pressed) === -1) return; // an unknown key: keep waiting
|
|
907
|
+
finish(pressed);
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
try {
|
|
911
|
+
process.stdin.setRawMode(true);
|
|
912
|
+
} catch (err) {
|
|
913
|
+
return resolve(fallback);
|
|
914
|
+
}
|
|
915
|
+
process.stdin.resume();
|
|
916
|
+
process.stdin.on("data", onKey);
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Removing a recording means removing what was remembered about it too, or
|
|
921
|
+
// the saved results of a file that no longer exists stay behind for ever.
|
|
922
|
+
function deleteRecording(name) {
|
|
923
|
+
const relative = path.join("tests", name).split(path.sep).join("/");
|
|
924
|
+
try {
|
|
925
|
+
fs.unlinkSync(path.join(TESTS_DIR, name));
|
|
926
|
+
} catch (err) {
|
|
927
|
+
return { ok: false, name: name, why: err.message };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
try {
|
|
931
|
+
const api = require(path.join(PACKAGE_DIR, "kryptheon-fixture.js"));
|
|
932
|
+
const all = api.readBaselines(api.BASELINE_FILE);
|
|
933
|
+
const keys = recordings.baselineKeysForSpec(all, relative);
|
|
934
|
+
if (keys.length) {
|
|
935
|
+
for (const key of keys) delete all[key];
|
|
936
|
+
fs.writeFileSync(api.BASELINE_FILE, JSON.stringify(all, null, 2) + String.fromCharCode(10), "utf8");
|
|
937
|
+
}
|
|
938
|
+
} catch (err) {
|
|
939
|
+
// The recording is gone either way, and a leftover baseline is not worth
|
|
940
|
+
// failing the command over.
|
|
941
|
+
}
|
|
942
|
+
return { ok: true, name: name, file: relative };
|
|
943
|
+
}
|
|
944
|
+
|
|
856
945
|
function askYesNo(question) {
|
|
857
946
|
return new Promise((resolve) => {
|
|
858
947
|
if (!process.stdin.isTTY) return resolve(false);
|
|
@@ -871,6 +960,24 @@ function askYesNo(question) {
|
|
|
871
960
|
});
|
|
872
961
|
}
|
|
873
962
|
|
|
963
|
+
// A typed answer, for questions whose answers are not single letters.
|
|
964
|
+
function askLine(question) {
|
|
965
|
+
return new Promise((resolve) => {
|
|
966
|
+
if (!process.stdin.isTTY) return resolve("");
|
|
967
|
+
const readline = require("readline");
|
|
968
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
969
|
+
rl.question(question, (answer) => {
|
|
970
|
+
rl.close();
|
|
971
|
+
try {
|
|
972
|
+
process.stdin.pause();
|
|
973
|
+
} catch (err) {
|
|
974
|
+
/* nothing to do */
|
|
975
|
+
}
|
|
976
|
+
resolve(String(answer));
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
|
|
874
981
|
// Ending on a logout means the next run starts somewhere else. Almost nobody
|
|
875
982
|
// wants that, so ask.
|
|
876
983
|
async function offerToDropLogout(relativeFile) {
|
|
@@ -897,6 +1004,70 @@ async function offerToDropLogout(relativeFile) {
|
|
|
897
1004
|
return true;
|
|
898
1005
|
}
|
|
899
1006
|
|
|
1007
|
+
// Codegen writes whatever identified the element at the moment it was clicked,
|
|
1008
|
+
// which on a page of live figures means the figures end up in the selector. Say
|
|
1009
|
+
// so, and say nothing when there is nothing to say.
|
|
1010
|
+
// Codegen records the wrong keystrokes along with the right ones, so a typo
|
|
1011
|
+
// corrected on the second attempt becomes a wrong login performed on every
|
|
1012
|
+
// run. This takes those out and says what it took, because a tool that edits
|
|
1013
|
+
// your recording without telling you is worse than one that leaves the mess.
|
|
1014
|
+
// A saved result whose recording is gone, or has been recorded again since,
|
|
1015
|
+
// describes a page nothing here visits any more. Left in place it is handed
|
|
1016
|
+
// to whichever new recording lands on the same name, which then fails on its
|
|
1017
|
+
// first run against a baseline it never created.
|
|
1018
|
+
function dropDeadBaselines() {
|
|
1019
|
+
const api = (function () {
|
|
1020
|
+
try {
|
|
1021
|
+
return require(path.join(PACKAGE_DIR, 'kryptheon-fixture.js'));
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
})();
|
|
1026
|
+
if (!api) return [];
|
|
1027
|
+
|
|
1028
|
+
let all;
|
|
1029
|
+
try {
|
|
1030
|
+
all = api.readBaselines(api.BASELINE_FILE);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
return [];
|
|
1033
|
+
}
|
|
1034
|
+
if (!all || !Object.keys(all).length) return [];
|
|
1035
|
+
|
|
1036
|
+
const result = baselines.pruneBaselines(all, USER_DIR);
|
|
1037
|
+
if (!result.changed) return [];
|
|
1038
|
+
try {
|
|
1039
|
+
fs.writeFileSync(api.BASELINE_FILE, JSON.stringify(result.baselines, null, 2) + String.fromCharCode(10), "utf8");
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
return [];
|
|
1042
|
+
}
|
|
1043
|
+
for (const line of baselines.pruneLines(result.dropped)) console.log(line);
|
|
1044
|
+
return result.dropped;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function tidyRecording(relativeFile) {
|
|
1048
|
+
const source = readSpec(relativeFile);
|
|
1049
|
+
if (source === null) return [];
|
|
1050
|
+
const result = cleanup.cleanRecording(source);
|
|
1051
|
+
if (!result.removed.length) return [];
|
|
1052
|
+
if (!writeSpec(relativeFile, result.source)) return [];
|
|
1053
|
+
return result.removed;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Said after the file has its name, with the other closing notes, so the
|
|
1057
|
+
// order on screen matches the order things happened in.
|
|
1058
|
+
function reportTidying(removed) {
|
|
1059
|
+
for (const line of cleanup.describeCleanup(removed)) console.log(line);
|
|
1060
|
+
return removed || [];
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function reportFragileSelectors(relativeFile) {
|
|
1064
|
+
const source = readSpec(relativeFile);
|
|
1065
|
+
if (source === null) return [];
|
|
1066
|
+
const findings = selectors.findFragileSelectors(source);
|
|
1067
|
+
for (const line of selectors.describeFragileSelectors(findings)) console.log(line);
|
|
1068
|
+
return findings;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
900
1071
|
function reportReplayRisks(relativeFile) {
|
|
901
1072
|
const source = readSpec(relativeFile);
|
|
902
1073
|
if (source === null) return [];
|
|
@@ -937,16 +1108,23 @@ async function finaliseRecording(outFile, context) {
|
|
|
937
1108
|
// this point. Take it out before doing anything else with the file.
|
|
938
1109
|
const secrets = takeOutSecrets(outFile);
|
|
939
1110
|
|
|
1111
|
+
// After the secrets, deliberately. By this point a password is an
|
|
1112
|
+
// environment variable rather than a literal, so nothing that is said
|
|
1113
|
+
// about a field that was typed twice can put the value back on screen.
|
|
1114
|
+
const tidied = tidyRecording(outFile);
|
|
1115
|
+
|
|
940
1116
|
const named = await nameRecording(outFile);
|
|
941
1117
|
console.log('');
|
|
942
1118
|
console.log(' Saved to ' + named);
|
|
943
1119
|
console.log(' Run it any time with: kryptheon check');
|
|
944
1120
|
console.log('');
|
|
945
1121
|
|
|
1122
|
+
reportTidying(tidied);
|
|
946
1123
|
reportSecrets(secrets);
|
|
947
1124
|
keepArtefactsOutOfGit();
|
|
948
1125
|
await offerToDropLogout(named);
|
|
949
1126
|
reportReplayRisks(named);
|
|
1127
|
+
reportFragileSelectors(named);
|
|
950
1128
|
|
|
951
1129
|
return { code: 0, savedAs: named };
|
|
952
1130
|
}
|
|
@@ -1003,6 +1181,125 @@ async function finaliseRecording(outFile, context) {
|
|
|
1003
1181
|
return { code: 1, savedAs: null };
|
|
1004
1182
|
}
|
|
1005
1183
|
|
|
1184
|
+
// Every record writes another file and check runs all of them, so a first day
|
|
1185
|
+
// spent learning the tool leaves a pile of recordings that never worked and a
|
|
1186
|
+
// check that reads like a wall of failures. This is the moment asking costs
|
|
1187
|
+
// nothing.
|
|
1188
|
+
//
|
|
1189
|
+
// Keeping is the default because it is the answer that cannot lose work, and
|
|
1190
|
+
// the other one is a single keypress away. Where there is no terminal to
|
|
1191
|
+
// answer with - a CI job, an assistant running the command - nothing is asked
|
|
1192
|
+
// and nothing is deleted: a question nobody can see is not a question.
|
|
1193
|
+
async function askAboutExisting(names) {
|
|
1194
|
+
if (!names.length || !process.stdin.isTTY) return false;
|
|
1195
|
+
|
|
1196
|
+
for (const line of recordings.existingRecordingsQuestion(names.length)) console.log(line);
|
|
1197
|
+
const answer = await askKey(" Keep or replace? [K/r] ", ["k", "r"], "k");
|
|
1198
|
+
if (answer !== "r") return false;
|
|
1199
|
+
|
|
1200
|
+
console.log("");
|
|
1201
|
+
console.log(" They will be deleted once this new recording is saved.");
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// The old recordings, once the new one is safely on disk. The new file is
|
|
1206
|
+
// never in this list, but it is checked by name anyway - deleting the thing
|
|
1207
|
+
// just recorded is the one mistake here that cannot be undone.
|
|
1208
|
+
function dropOldRecordings(names, savedAs) {
|
|
1209
|
+
const keep = path.basename(String(savedAs || ""));
|
|
1210
|
+
const gone = [];
|
|
1211
|
+
for (const name of names) {
|
|
1212
|
+
if (path.basename(name) === keep) continue;
|
|
1213
|
+
const result = deleteRecording(name);
|
|
1214
|
+
if (result.ok) gone.push(name);
|
|
1215
|
+
}
|
|
1216
|
+
if (!gone.length) return gone;
|
|
1217
|
+
console.log(" Removed " + recordings.plural(gone.length, "older recording") + ":");
|
|
1218
|
+
for (const name of gone) console.log(" " + path.join("tests", name));
|
|
1219
|
+
console.log("");
|
|
1220
|
+
return gone;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// --- remove -----------------------------------------------------------------
|
|
1224
|
+
//
|
|
1225
|
+
// Recordings pile up. Without a way to take one out, the only way to stop a
|
|
1226
|
+
// half-finished experiment being run for ever is to know where the files live
|
|
1227
|
+
// and delete one by hand.
|
|
1228
|
+
|
|
1229
|
+
function listRecordings(names) {
|
|
1230
|
+
console.log("");
|
|
1231
|
+
console.log(" " + recordings.plural(names.length, "recording") + " in this project:");
|
|
1232
|
+
console.log("");
|
|
1233
|
+
names.forEach((name, i) => {
|
|
1234
|
+
console.log(" " + String(i + 1).padStart(2) + " " + path.join("tests", name));
|
|
1235
|
+
});
|
|
1236
|
+
console.log("");
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function remove(name) {
|
|
1240
|
+
const names = listSpecFiles();
|
|
1241
|
+
if (!names.length) {
|
|
1242
|
+
console.log("");
|
|
1243
|
+
console.log(" There are no recordings to remove.");
|
|
1244
|
+
console.log("");
|
|
1245
|
+
return 0;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
let choice;
|
|
1249
|
+
if (name) {
|
|
1250
|
+
choice = recordings.chooseFromList(names, name);
|
|
1251
|
+
} else {
|
|
1252
|
+
listRecordings(names);
|
|
1253
|
+
if (!process.stdin.isTTY) {
|
|
1254
|
+
console.log(" To remove one, run: kryptheon remove <name>");
|
|
1255
|
+
console.log("");
|
|
1256
|
+
return 0;
|
|
1257
|
+
}
|
|
1258
|
+
const answer = await askLine(" Which one? [number, a for all, Enter to cancel] ");
|
|
1259
|
+
choice = recordings.chooseFromList(names, answer);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (choice.action === "cancel") {
|
|
1263
|
+
console.log("");
|
|
1264
|
+
console.log(" Nothing was removed.");
|
|
1265
|
+
console.log("");
|
|
1266
|
+
return 0;
|
|
1267
|
+
}
|
|
1268
|
+
if (choice.action === "unclear") {
|
|
1269
|
+
console.error("");
|
|
1270
|
+
console.error(" " + choice.why + ": " + JSON.stringify(choice.said));
|
|
1271
|
+
console.error(" Run \"kryptheon remove\" on its own to see the list.");
|
|
1272
|
+
console.error("");
|
|
1273
|
+
return 1;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const gone = [];
|
|
1277
|
+
const failed = [];
|
|
1278
|
+
for (const target of choice.names) {
|
|
1279
|
+
const result = deleteRecording(target);
|
|
1280
|
+
if (result.ok) gone.push(target);
|
|
1281
|
+
else failed.push(result);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
console.log("");
|
|
1285
|
+
if (gone.length) {
|
|
1286
|
+
console.log(" Removed " + recordings.plural(gone.length, "recording") + ":");
|
|
1287
|
+
for (const g of gone) console.log(" " + path.join("tests", g));
|
|
1288
|
+
console.log("");
|
|
1289
|
+
console.log(
|
|
1290
|
+
gone.length === 1
|
|
1291
|
+
? " Its saved result was removed too, so nothing is left behind."
|
|
1292
|
+
: " Their saved results were removed too, so nothing is left behind.",
|
|
1293
|
+
);
|
|
1294
|
+
console.log("");
|
|
1295
|
+
}
|
|
1296
|
+
for (const f of failed) {
|
|
1297
|
+
console.error(" Could not remove " + path.join("tests", f.name) + ": " + f.why);
|
|
1298
|
+
console.error("");
|
|
1299
|
+
}
|
|
1300
|
+
return failed.length ? 1 : 0;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1006
1303
|
async function record(url) {
|
|
1007
1304
|
if (!url) {
|
|
1008
1305
|
console.error('');
|
|
@@ -1025,6 +1322,9 @@ async function record(url) {
|
|
|
1025
1322
|
|
|
1026
1323
|
if (!ensureBrowser()) return 1;
|
|
1027
1324
|
|
|
1325
|
+
const existingBefore = listSpecFiles();
|
|
1326
|
+
const replaceExisting = await askAboutExisting(existingBefore);
|
|
1327
|
+
|
|
1028
1328
|
const hadTestsDir = fs.existsSync(TESTS_DIR);
|
|
1029
1329
|
try {
|
|
1030
1330
|
fs.mkdirSync(TESTS_DIR, { recursive: true });
|
|
@@ -1064,6 +1364,9 @@ async function record(url) {
|
|
|
1064
1364
|
stderr: stderr,
|
|
1065
1365
|
reason: reason,
|
|
1066
1366
|
});
|
|
1367
|
+
// Only now, and only if something was actually saved. Deleting first
|
|
1368
|
+
// would mean a recording that went wrong takes the old ones with it.
|
|
1369
|
+
if (replaceExisting && outcome.savedAs) dropOldRecordings(existingBefore, outcome.savedAs);
|
|
1067
1370
|
return outcome.code;
|
|
1068
1371
|
};
|
|
1069
1372
|
|
|
@@ -1222,22 +1525,23 @@ function triageSpecs() {
|
|
|
1222
1525
|
|
|
1223
1526
|
function check(options) {
|
|
1224
1527
|
const quiet = !!(options && options.quiet);
|
|
1528
|
+
|
|
1529
|
+
// Before anything else. Recordings and results are read out of whatever
|
|
1530
|
+
// folder this was run in, so being in the wrong one does not fail - it
|
|
1531
|
+
// succeeds, about the wrong project. That is the one outcome worth exiting
|
|
1532
|
+
// non-zero over even though nothing was tested.
|
|
1533
|
+
const here = project.inspectProject(USER_DIR);
|
|
1534
|
+
if (!here.ok) {
|
|
1535
|
+
for (const line of project.noProjectLines(here)) console.error(line);
|
|
1536
|
+
return 1;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1225
1539
|
// Deliberately not a failure. An assistant told to run this after every
|
|
1226
1540
|
// change will read a non-zero exit as "something broke" and start fixing
|
|
1227
1541
|
// code that is perfectly fine. Nothing has been recorded, so there is
|
|
1228
1542
|
// nothing to verify and nothing to repair.
|
|
1229
1543
|
if (!listSpecFiles().length) {
|
|
1230
|
-
console.log(
|
|
1231
|
-
console.log(' Nothing has been recorded yet, so there is nothing to check.');
|
|
1232
|
-
console.log('');
|
|
1233
|
-
console.log(' This is not a failure, and there is nothing in the code to fix.');
|
|
1234
|
-
console.log('');
|
|
1235
|
-
console.log(' To start, record a flow through the app:');
|
|
1236
|
-
console.log(' npx kryptheon record <url>');
|
|
1237
|
-
console.log('');
|
|
1238
|
-
console.log(' That opens a browser window, so it has to be run by a person in');
|
|
1239
|
-
console.log(' a normal terminal - it cannot be done from an AI assistant.');
|
|
1240
|
-
console.log('');
|
|
1544
|
+
for (const line of project.noRecordingsLines()) console.log(line);
|
|
1241
1545
|
return 0;
|
|
1242
1546
|
}
|
|
1243
1547
|
if (!fixtureResolvesForUser() && specsNeedThePackage()) {
|
|
@@ -1254,6 +1558,7 @@ function check(options) {
|
|
|
1254
1558
|
return 1;
|
|
1255
1559
|
}
|
|
1256
1560
|
keepArtefactsOutOfGit();
|
|
1561
|
+
dropDeadBaselines();
|
|
1257
1562
|
|
|
1258
1563
|
const triage = triageSpecs();
|
|
1259
1564
|
|
|
@@ -1606,6 +1911,9 @@ async function main() {
|
|
|
1606
1911
|
case 'check':
|
|
1607
1912
|
return finishWith(check({ quiet: rest.indexOf('--quiet') !== -1 || rest.indexOf('-q') !== -1 }));
|
|
1608
1913
|
break;
|
|
1914
|
+
case 'remove':
|
|
1915
|
+
case 'rm':
|
|
1916
|
+
return finishWith(await remove(rest.join(" ").trim()));
|
|
1609
1917
|
case 'setup-ai':
|
|
1610
1918
|
return finishWith(setupAi());
|
|
1611
1919
|
case 'accept':
|
|
@@ -1660,6 +1968,15 @@ module.exports = {
|
|
|
1660
1968
|
inspectBrowserWindows: inspectBrowserWindows,
|
|
1661
1969
|
parseWindowProbe: parseWindowProbe,
|
|
1662
1970
|
codegenComplaints: codegenComplaints,
|
|
1971
|
+
reportFragileSelectors: reportFragileSelectors,
|
|
1972
|
+
tidyRecording: tidyRecording,
|
|
1973
|
+
dropDeadBaselines: dropDeadBaselines,
|
|
1974
|
+
reportTidying: reportTidying,
|
|
1975
|
+
remove: remove,
|
|
1976
|
+
deleteRecording: deleteRecording,
|
|
1977
|
+
dropOldRecordings: dropOldRecordings,
|
|
1978
|
+
askAboutExisting: askAboutExisting,
|
|
1979
|
+
askKey: askKey,
|
|
1663
1980
|
WINDOW_TIMEOUT_MS: WINDOW_TIMEOUT_MS,
|
|
1664
1981
|
WINDOW_HARD_LIMIT_MS: WINDOW_HARD_LIMIT_MS,
|
|
1665
1982
|
WINDOW_POLL_MS: WINDOW_POLL_MS,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Keeping a saved result tied to the recording it came from.
|
|
2
|
+
//
|
|
3
|
+
// A baseline used to be identified by where the recording lived and what it was
|
|
4
|
+
// called. That is not enough. Delete the spec files with `del tests\*.spec.js` -
|
|
5
|
+
// which is what anyone does before the tool has taught them `kryptheon remove` -
|
|
6
|
+
// record the flow again, and the new recording is handed the same name. Same
|
|
7
|
+
// path, same title, same key. It inherits the old recording's saved page and
|
|
8
|
+
// fails on its very first run, against a baseline it never created and could
|
|
9
|
+
// never have matched.
|
|
10
|
+
//
|
|
11
|
+
// The report that came out of that said both of these at once:
|
|
12
|
+
//
|
|
13
|
+
// This has not passed before.
|
|
14
|
+
// Headings that are gone: "Transactions"
|
|
15
|
+
//
|
|
16
|
+
// Which cannot both be true. If it has never passed there is no baseline of its
|
|
17
|
+
// own to have changed, so there is nothing to show a difference against.
|
|
18
|
+
//
|
|
19
|
+
// So a saved result now also records a fingerprint of the recording that made
|
|
20
|
+
// it. When the recording is gone, or its steps are not the steps the baseline
|
|
21
|
+
// was taken from, the saved result describes something that no longer exists
|
|
22
|
+
// and is thrown away rather than compared against.
|
|
23
|
+
//
|
|
24
|
+
// Pure data work: no Playwright, no test runner.
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const replay = require('./kryptheon-replay.js');
|
|
29
|
+
|
|
30
|
+
/** The spec file half of a baseline key, in one consistent shape. */
|
|
31
|
+
function specOf(key) {
|
|
32
|
+
return String(key || '').split(' :: ')[0].split('\\').join('/');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function titleOf(key) {
|
|
36
|
+
const parts = String(key || '').split(' :: ');
|
|
37
|
+
return parts.length > 1 ? parts.slice(1).join(' :: ') : '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The fingerprint of the recording in a file, or null if it cannot be read. */
|
|
41
|
+
function fingerprintOf(userDir, specPath) {
|
|
42
|
+
let source;
|
|
43
|
+
try {
|
|
44
|
+
source = fs.readFileSync(path.join(userDir, specPath), 'utf8');
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return replay.recordingId(source);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What each saved result is: still current, orphaned, or stale.
|
|
53
|
+
*
|
|
54
|
+
* `orphan` - the recording it belongs to is not there any more.
|
|
55
|
+
* `stale` - a recording of that name exists, but it is not the one this was
|
|
56
|
+
* taken from. This is the case that cost twenty minutes: the file
|
|
57
|
+
* is present, so nothing looks wrong.
|
|
58
|
+
* `legacy` - written before fingerprints existed. There is no way to tell
|
|
59
|
+
* whether it matches, so it is kept, and a fingerprint is written
|
|
60
|
+
* in so the question can be answered next time. Guessing here would
|
|
61
|
+
* mean either throwing away a good baseline or, far worse, quietly
|
|
62
|
+
* accepting a real regression as the new normal.
|
|
63
|
+
*/
|
|
64
|
+
function classifyBaselines(baselines, userDir) {
|
|
65
|
+
const all = baselines || {};
|
|
66
|
+
const verdicts = [];
|
|
67
|
+
|
|
68
|
+
for (const key of Object.keys(all)) {
|
|
69
|
+
const entry = all[key];
|
|
70
|
+
const spec = specOf(key);
|
|
71
|
+
if (!entry || typeof entry !== 'object') {
|
|
72
|
+
verdicts.push({ key: key, state: 'orphan', spec: spec, title: titleOf(key) });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let exists = false;
|
|
77
|
+
try {
|
|
78
|
+
exists = fs.statSync(path.join(userDir, spec)).isFile();
|
|
79
|
+
} catch (e) {
|
|
80
|
+
exists = false;
|
|
81
|
+
}
|
|
82
|
+
if (!exists) {
|
|
83
|
+
verdicts.push({ key: key, state: 'orphan', spec: spec, title: titleOf(key) });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const now = fingerprintOf(userDir, spec);
|
|
88
|
+
if (!entry.recordingId) {
|
|
89
|
+
verdicts.push({ key: key, state: 'legacy', spec: spec, title: titleOf(key), fingerprint: now });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (now && entry.recordingId !== now) {
|
|
93
|
+
verdicts.push({ key: key, state: 'stale', spec: spec, title: titleOf(key), fingerprint: now });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
verdicts.push({ key: key, state: 'current', spec: spec, title: titleOf(key) });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return verdicts;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The saved results with the dead ones taken out.
|
|
104
|
+
*
|
|
105
|
+
* Returns a new object; nothing is written here. The caller decides whether the
|
|
106
|
+
* file is worth rewriting, so a check that changes nothing never touches it.
|
|
107
|
+
*/
|
|
108
|
+
function pruneBaselines(baselines, userDir) {
|
|
109
|
+
const all = baselines || {};
|
|
110
|
+
const verdicts = classifyBaselines(all, userDir);
|
|
111
|
+
const kept = {};
|
|
112
|
+
const dropped = [];
|
|
113
|
+
let stamped = 0;
|
|
114
|
+
|
|
115
|
+
for (const verdict of verdicts) {
|
|
116
|
+
if (verdict.state === 'orphan' || verdict.state === 'stale') {
|
|
117
|
+
dropped.push(verdict);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const entry = all[verdict.key];
|
|
121
|
+
if (verdict.state === 'legacy' && verdict.fingerprint) {
|
|
122
|
+
// Kept as it is, but tied to this recording from now on.
|
|
123
|
+
kept[verdict.key] = Object.assign({}, entry, { recordingId: verdict.fingerprint });
|
|
124
|
+
stamped++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
kept[verdict.key] = entry;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { baselines: kept, dropped: dropped, stamped: stamped, changed: dropped.length > 0 || stamped > 0 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function plural(n, word) {
|
|
134
|
+
return n + ' ' + word + (n === 1 ? '' : 's');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One line about what was thrown away, said as a fact rather than a warning.
|
|
139
|
+
*
|
|
140
|
+
* Nothing has gone wrong when this happens - a recording was deleted or redone,
|
|
141
|
+
* which is a normal thing to do. It is said out loud only so that a flow which
|
|
142
|
+
* suddenly starts from scratch is not a mystery.
|
|
143
|
+
*/
|
|
144
|
+
function pruneLines(dropped) {
|
|
145
|
+
const list = dropped || [];
|
|
146
|
+
if (!list.length) return [];
|
|
147
|
+
|
|
148
|
+
const orphans = list.filter((d) => d.state === 'orphan');
|
|
149
|
+
const stale = list.filter((d) => d.state === 'stale');
|
|
150
|
+
const lines = [];
|
|
151
|
+
|
|
152
|
+
if (orphans.length) {
|
|
153
|
+
lines.push(
|
|
154
|
+
' ' + plural(orphans.length, 'old baseline') + ' removed - ' +
|
|
155
|
+
(orphans.length === 1 ? 'its recording is' : 'their recordings are') + ' no longer here.',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (stale.length) {
|
|
159
|
+
lines.push(
|
|
160
|
+
' ' + plural(stale.length, 'old baseline') + ' removed - ' +
|
|
161
|
+
(stale.length === 1 ? 'its recording has' : 'those recordings have') +
|
|
162
|
+
' been recorded again since.',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
lines.push('');
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
specOf: specOf,
|
|
171
|
+
titleOf: titleOf,
|
|
172
|
+
fingerprintOf: fingerprintOf,
|
|
173
|
+
classifyBaselines: classifyBaselines,
|
|
174
|
+
pruneBaselines: pruneBaselines,
|
|
175
|
+
pruneLines: pruneLines,
|
|
176
|
+
};
|