@olomonhelpdesk2/helpdesk 0.1.10 → 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/helpdesk-capture.mjs +458 -236
- package/helpdesk.mjs +7 -2
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -976,88 +976,57 @@ async function takeInventory(page, regions) {
|
|
|
976
976
|
};
|
|
977
977
|
}
|
|
978
978
|
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
return "00000000-0000-4000-8000-" + n;
|
|
1031
|
-
};
|
|
1032
|
-
}
|
|
1033
|
-
})();`;
|
|
1034
|
-
}
|
|
1035
|
-
function frameHash(bytes) {
|
|
1036
|
-
return createHash("sha256").update(bytes).digest("hex");
|
|
1037
|
-
}
|
|
1038
|
-
async function settle(options) {
|
|
1039
|
-
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1040
|
-
const now = options.now ?? (() => Date.now());
|
|
1041
|
-
const deadline = now() + options.timeoutMs;
|
|
1042
|
-
let value = await options.sample();
|
|
1043
|
-
let samples = 1;
|
|
1044
|
-
let matches = 1;
|
|
1045
|
-
while (matches < options.identicalSamples) {
|
|
1046
|
-
if (now() >= deadline) return { value, samples, settled: false };
|
|
1047
|
-
await wait(options.intervalMs);
|
|
1048
|
-
const next = await options.sample();
|
|
1049
|
-
samples += 1;
|
|
1050
|
-
matches = options.same(value, next) ? matches + 1 : 1;
|
|
1051
|
-
value = next;
|
|
1052
|
-
}
|
|
1053
|
-
return { value, samples, settled: true };
|
|
1054
|
-
}
|
|
1055
|
-
function sameBytes(a, b) {
|
|
1056
|
-
if (a.length !== b.length) return false;
|
|
1057
|
-
for (let index = 0; index < a.length; index += 1) {
|
|
1058
|
-
if (a[index] !== b[index]) return false;
|
|
1059
|
-
}
|
|
1060
|
-
return true;
|
|
979
|
+
// browser/painted.ts
|
|
980
|
+
async function readPaintedMarkup(page, regions) {
|
|
981
|
+
return page.evaluate(
|
|
982
|
+
inPage(
|
|
983
|
+
(spec) => {
|
|
984
|
+
const painted = (element) => {
|
|
985
|
+
const style = window.getComputedStyle(element);
|
|
986
|
+
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
|
|
987
|
+
if (style.display === "contents") return true;
|
|
988
|
+
for (const box of Array.from(element.getClientRects())) {
|
|
989
|
+
if (box.width > 0 && box.height > 0) return true;
|
|
990
|
+
}
|
|
991
|
+
return false;
|
|
992
|
+
};
|
|
993
|
+
const choice = (element) => element.tagName === "OPTION" || element.tagName === "OPTGROUP";
|
|
994
|
+
const matched = [];
|
|
995
|
+
for (const region of spec.regions) {
|
|
996
|
+
let found = [];
|
|
997
|
+
try {
|
|
998
|
+
found = Array.from(document.querySelectorAll(region));
|
|
999
|
+
} catch {
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
for (const element of found) if (!matched.includes(element)) matched.push(element);
|
|
1003
|
+
}
|
|
1004
|
+
const roots = matched.filter((element) => !matched.some((other) => other !== element && other.contains(element)));
|
|
1005
|
+
const prune = (original, copy) => {
|
|
1006
|
+
const originals = Array.from(original.children);
|
|
1007
|
+
const copies = Array.from(copy.children);
|
|
1008
|
+
for (const [index, child] of originals.entries()) {
|
|
1009
|
+
const twin = copies[index];
|
|
1010
|
+
if (!twin) continue;
|
|
1011
|
+
if (!choice(child) && !painted(child)) {
|
|
1012
|
+
twin.remove();
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
prune(child, twin);
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
const parts = [];
|
|
1019
|
+
for (const root of roots) {
|
|
1020
|
+
if (!painted(root)) continue;
|
|
1021
|
+
const copy = root.cloneNode(true);
|
|
1022
|
+
prune(root, copy);
|
|
1023
|
+
parts.push(copy.outerHTML);
|
|
1024
|
+
}
|
|
1025
|
+
return parts.join("\n");
|
|
1026
|
+
},
|
|
1027
|
+
{ regions: [...regions] }
|
|
1028
|
+
)
|
|
1029
|
+
);
|
|
1061
1030
|
}
|
|
1062
1031
|
|
|
1063
1032
|
// core/coverage/normalize.ts
|
|
@@ -1111,152 +1080,52 @@ function isEmptyState(text2) {
|
|
|
1111
1080
|
if (sentence.split(" ").length < 2) return false;
|
|
1112
1081
|
return EMPTY_STATE.test(sentence);
|
|
1113
1082
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
var
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
complementary: "aside",
|
|
1122
|
-
dialog: "dialog",
|
|
1123
|
-
alertdialog: "dialog",
|
|
1124
|
-
form: "form",
|
|
1125
|
-
search: "form"
|
|
1126
|
-
};
|
|
1127
|
-
var ACTION = /* @__PURE__ */ new Set(["button", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "switch"]);
|
|
1128
|
-
var FIELD = {
|
|
1129
|
-
textbox: "text",
|
|
1130
|
-
searchbox: "search",
|
|
1131
|
-
combobox: "select",
|
|
1132
|
-
listbox: "select",
|
|
1133
|
-
checkbox: "checkbox",
|
|
1134
|
-
radio: "radio",
|
|
1135
|
-
slider: "range",
|
|
1136
|
-
spinbutton: "number"
|
|
1137
|
-
};
|
|
1138
|
-
var NOTICE = /* @__PURE__ */ new Set(["status", "alert"]);
|
|
1139
|
-
var STATE = /* @__PURE__ */ new Set(["disabled", "checked", "selected", "expanded", "readonly", "required"]);
|
|
1140
|
-
function readAriaFactRecords(snapshot, options = {}) {
|
|
1141
|
-
const from = options.url ?? "";
|
|
1142
|
-
const inSource = options.inSource ?? /* @__PURE__ */ new Set();
|
|
1143
|
-
const facts = [];
|
|
1144
|
-
const add = (kind, value, node, extra = {}) => {
|
|
1145
|
-
if (!value) return;
|
|
1146
|
-
const scope = scopeFromLandmark(node.landmark);
|
|
1147
|
-
facts.push(
|
|
1148
|
-
fact({
|
|
1149
|
-
kind,
|
|
1150
|
-
value: trim(value, LIMITS.label),
|
|
1151
|
-
channel: "p3",
|
|
1152
|
-
scope,
|
|
1153
|
-
dataClass: classifyData(value, scope, { inSource: inSource.has(value) }),
|
|
1154
|
-
from,
|
|
1155
|
-
landmark: node.landmark,
|
|
1156
|
-
...extra
|
|
1157
|
-
})
|
|
1158
|
-
);
|
|
1159
|
-
};
|
|
1160
|
-
for (const node of parse(snapshot)) {
|
|
1161
|
-
const state = controlState(node);
|
|
1162
|
-
if (node.role === "heading") {
|
|
1163
|
-
add("heading", node.name || node.text, node);
|
|
1164
|
-
if (isEmptyState(node.name || node.text)) add("empty", node.name || node.text, node);
|
|
1165
|
-
continue;
|
|
1166
|
-
}
|
|
1167
|
-
if (node.role === "paragraph" || node.role === "text") {
|
|
1168
|
-
const said = node.text || node.name;
|
|
1169
|
-
if (isEmptyState(said)) add("empty", trim(said, LIMITS.sentence), node);
|
|
1170
|
-
continue;
|
|
1171
|
-
}
|
|
1172
|
-
if (node.role === "link") {
|
|
1173
|
-
add("link", node.name || node.text || node.url || "", node, {
|
|
1174
|
-
...node.url ? { href: node.url } : {},
|
|
1175
|
-
...state
|
|
1176
|
-
});
|
|
1177
|
-
continue;
|
|
1178
|
-
}
|
|
1179
|
-
if (ACTION.has(node.role)) {
|
|
1180
|
-
add("action", node.name || node.text, node, state);
|
|
1181
|
-
continue;
|
|
1182
|
-
}
|
|
1183
|
-
const fieldKind = FIELD[node.role];
|
|
1184
|
-
if (fieldKind) {
|
|
1185
|
-
add("field", node.name || node.text, node, {
|
|
1186
|
-
fieldKind,
|
|
1187
|
-
...node.choices.length ? { choices: node.choices.slice(0, LIMITS.choices) } : {},
|
|
1188
|
-
...state
|
|
1189
|
-
});
|
|
1190
|
-
continue;
|
|
1191
|
-
}
|
|
1192
|
-
if (NOTICE.has(node.role)) {
|
|
1193
|
-
const said = trim(node.text || node.name, LIMITS.sentence);
|
|
1194
|
-
add("notice", said, node);
|
|
1195
|
-
if (isEmptyState(said)) add("empty", said, node);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
return facts;
|
|
1199
|
-
}
|
|
1200
|
-
var LINE = /^(\s*)-\s+(.*)$/;
|
|
1201
|
-
var ENTRY = /^([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?((?:\s*\[[^\]]*\])*)\s*(?::\s*(.*))?$/;
|
|
1202
|
-
function parse(snapshot) {
|
|
1203
|
-
const out = [];
|
|
1204
|
-
const stack = [];
|
|
1205
|
-
for (const raw of snapshot.split("\n")) {
|
|
1206
|
-
const line = LINE.exec(raw);
|
|
1207
|
-
if (!line) continue;
|
|
1208
|
-
const depth2 = line[1].length;
|
|
1209
|
-
const body = line[2];
|
|
1210
|
-
while (stack.length && stack[stack.length - 1].depth >= depth2) stack.pop();
|
|
1211
|
-
const parent = stack[stack.length - 1]?.node;
|
|
1212
|
-
if (body.startsWith("/")) {
|
|
1213
|
-
const [name, ...rest] = body.split(":");
|
|
1214
|
-
if (parent && name?.trim() === "/url") parent.url = rest.join(":").trim();
|
|
1215
|
-
continue;
|
|
1216
|
-
}
|
|
1217
|
-
const entry = ENTRY.exec(body);
|
|
1218
|
-
if (!entry) continue;
|
|
1219
|
-
const role = entry[1];
|
|
1220
|
-
const node = {
|
|
1221
|
-
role,
|
|
1222
|
-
name: unescape(entry[2] ?? ""),
|
|
1223
|
-
text: (entry[4] ?? "").trim(),
|
|
1224
|
-
attributes: attributesOf(entry[3] ?? ""),
|
|
1225
|
-
landmark: LANDMARK[role] ?? parent?.landmark ?? "none",
|
|
1226
|
-
choices: []
|
|
1227
|
-
};
|
|
1228
|
-
if (role === "option" && parent) {
|
|
1229
|
-
if (node.name || node.text) parent.choices.push(node.name || node.text);
|
|
1230
|
-
continue;
|
|
1231
|
-
}
|
|
1232
|
-
out.push(node);
|
|
1233
|
-
stack.push({ depth: depth2, node });
|
|
1083
|
+
var IRREVERSIBLE = /\b(cannot be undone|can't be undone|permanently|permanent|irreversible|no way back|cannot be recovered|unrecoverable|this cannot be reversed)\b/i;
|
|
1084
|
+
var DESTRUCTIVE = /\b(delete|deletes|deleting|remove|removes|revoke|revokes|erase|wipe|discard|destroy|overwrite|overwrites|reset|disconnect|unpublish|cancel subscription)\b/i;
|
|
1085
|
+
var PERMISSION = /\b(only (?:an?\s+)?(?:owner|admin|administrator)s?\b|your role|not allowed|do(?:es)? not have permission|don't have permission|permission to|requires? (?:an? )?(?:owner|admin|editing|editor)|admins? only|read[- ]only)\b/i;
|
|
1086
|
+
function finishScreenFacts(parts) {
|
|
1087
|
+
const warnings = [];
|
|
1088
|
+
for (const sentence of parts.notices) {
|
|
1089
|
+
if (IRREVERSIBLE.test(sentence) || PERMISSION.test(sentence)) warnings.push(sentence);
|
|
1234
1090
|
}
|
|
1235
|
-
|
|
1236
|
-
}
|
|
1237
|
-
function attributesOf(raw) {
|
|
1238
|
-
const attributes = {};
|
|
1239
|
-
for (const match of raw.matchAll(/\[([^\]]*)\]/g)) {
|
|
1240
|
-
const [name, ...rest] = match[1].split("=");
|
|
1241
|
-
const key = name?.trim();
|
|
1242
|
-
if (!key) continue;
|
|
1243
|
-
attributes[key] = rest.length ? rest.join("=").trim() : true;
|
|
1091
|
+
for (const label of parts.actions) {
|
|
1092
|
+
if (DESTRUCTIVE.test(label)) warnings.push(`"${label}" destroys or replaces something.`);
|
|
1244
1093
|
}
|
|
1245
|
-
return
|
|
1094
|
+
return {
|
|
1095
|
+
headings: capped(parts.headings, LIMITS.headings),
|
|
1096
|
+
actions: capped(parts.actions, LIMITS.actions),
|
|
1097
|
+
links: cappedBy(parts.links, (link) => `${link.label ?? ""}|${link.href}`, LIMITS.links),
|
|
1098
|
+
fields: cappedBy(parts.fields, (field) => `${field.label}|${field.kind}`, LIMITS.fields),
|
|
1099
|
+
notices: capped(parts.notices, LIMITS.notices),
|
|
1100
|
+
warnings: capped(warnings, LIMITS.warnings),
|
|
1101
|
+
readFrom: parts.readFrom,
|
|
1102
|
+
...states({ disabled: parts.disabled, empty: parts.empty, gatedBy: parts.gatedBy })
|
|
1103
|
+
};
|
|
1246
1104
|
}
|
|
1247
|
-
function
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1105
|
+
function states(parts) {
|
|
1106
|
+
const disabled = capped(parts.disabled ?? [], LIMITS.disabled);
|
|
1107
|
+
const empty = capped(parts.empty ?? [], LIMITS.empty);
|
|
1108
|
+
const gatedBy = capped(parts.gatedBy ?? [], LIMITS.gatedBy);
|
|
1109
|
+
return {
|
|
1110
|
+
...disabled.length ? { disabled } : {},
|
|
1111
|
+
...empty.length ? { empty } : {},
|
|
1112
|
+
...gatedBy.length ? { gatedBy } : {}
|
|
1113
|
+
};
|
|
1253
1114
|
}
|
|
1254
|
-
function
|
|
1255
|
-
return
|
|
1115
|
+
function capped(values, limit) {
|
|
1116
|
+
return [...new Set(values)].slice(0, limit);
|
|
1256
1117
|
}
|
|
1257
|
-
function
|
|
1258
|
-
const
|
|
1259
|
-
|
|
1118
|
+
function cappedBy(values, key, limit) {
|
|
1119
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1120
|
+
const kept = [];
|
|
1121
|
+
for (const value of values) {
|
|
1122
|
+
const identity = key(value);
|
|
1123
|
+
if (seen.has(identity)) continue;
|
|
1124
|
+
seen.add(identity);
|
|
1125
|
+
kept.push(value);
|
|
1126
|
+
if (kept.length >= limit) break;
|
|
1127
|
+
}
|
|
1128
|
+
return kept;
|
|
1260
1129
|
}
|
|
1261
1130
|
|
|
1262
1131
|
// core/discovery/rendered-facts.ts
|
|
@@ -1316,7 +1185,7 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1316
1185
|
continue;
|
|
1317
1186
|
}
|
|
1318
1187
|
if (tag === "button" || PRESSABLE.has(role) || submitLike(element)) {
|
|
1319
|
-
add("action", label, landmark,
|
|
1188
|
+
add("action", label, landmark, controlState(element));
|
|
1320
1189
|
continue;
|
|
1321
1190
|
}
|
|
1322
1191
|
if (tag === "a") {
|
|
@@ -1332,19 +1201,19 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1332
1201
|
add("field", field.label, landmark, {
|
|
1333
1202
|
fieldKind: field.kind,
|
|
1334
1203
|
...field.choices ? { choices: field.choices } : {},
|
|
1335
|
-
...
|
|
1204
|
+
...controlState(element)
|
|
1336
1205
|
});
|
|
1337
1206
|
}
|
|
1338
1207
|
continue;
|
|
1339
1208
|
}
|
|
1340
1209
|
if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
|
|
1341
|
-
const said =
|
|
1210
|
+
const said = trim(text(inner), LIMITS.sentence);
|
|
1342
1211
|
add("notice", said, landmark);
|
|
1343
1212
|
if (isEmptyState(said)) add("empty", said, landmark);
|
|
1344
1213
|
continue;
|
|
1345
1214
|
}
|
|
1346
1215
|
if (TEXT_HOLDER.has(tag) && !BLOCK_INSIDE.test(inner)) {
|
|
1347
|
-
const said =
|
|
1216
|
+
const said = trim(text(inner).replace(/\s+([.,;:!])/g, "$1"), LIMITS.sentence);
|
|
1348
1217
|
if (isEmptyState(said)) add("empty", said, landmark);
|
|
1349
1218
|
}
|
|
1350
1219
|
}
|
|
@@ -1352,6 +1221,58 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1352
1221
|
}
|
|
1353
1222
|
var TEXT_HOLDER = /* @__PURE__ */ new Set(["p", "span", "small", "td", "li", "div"]);
|
|
1354
1223
|
var BLOCK_INSIDE = /<(?!\/?(?:strong|em|b|i|a|code|br|span|small)\b)[a-z]/i;
|
|
1224
|
+
function readRenderedFacts(html, options = {}) {
|
|
1225
|
+
return toScreenFacts(readRenderedFactRecords(html, options), options.url);
|
|
1226
|
+
}
|
|
1227
|
+
function toScreenFacts(facts, from) {
|
|
1228
|
+
const headings = [];
|
|
1229
|
+
const actions = [];
|
|
1230
|
+
const links = [];
|
|
1231
|
+
const fields = [];
|
|
1232
|
+
const notices = [];
|
|
1233
|
+
const disabled = [];
|
|
1234
|
+
const empty = [];
|
|
1235
|
+
for (const entry of facts) {
|
|
1236
|
+
if ((entry.kind === "action" || entry.kind === "field") && entry.controlState?.["disabled"]) {
|
|
1237
|
+
disabled.push(entry.value);
|
|
1238
|
+
}
|
|
1239
|
+
switch (entry.kind) {
|
|
1240
|
+
case "heading":
|
|
1241
|
+
headings.push(entry.value);
|
|
1242
|
+
break;
|
|
1243
|
+
case "action":
|
|
1244
|
+
actions.push(entry.value);
|
|
1245
|
+
break;
|
|
1246
|
+
case "link":
|
|
1247
|
+
links.push(entry.href ? { label: entry.value, href: entry.href } : { href: entry.value });
|
|
1248
|
+
break;
|
|
1249
|
+
case "field": {
|
|
1250
|
+
const field = { label: entry.value, kind: entry.fieldKind ?? "text" };
|
|
1251
|
+
if (entry.choices?.length) field.choices = [...entry.choices];
|
|
1252
|
+
fields.push(field);
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
case "notice":
|
|
1256
|
+
notices.push(entry.value);
|
|
1257
|
+
break;
|
|
1258
|
+
case "empty":
|
|
1259
|
+
empty.push(entry.value);
|
|
1260
|
+
break;
|
|
1261
|
+
case "warning":
|
|
1262
|
+
break;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
return finishScreenFacts({
|
|
1266
|
+
headings,
|
|
1267
|
+
actions,
|
|
1268
|
+
links,
|
|
1269
|
+
fields,
|
|
1270
|
+
notices,
|
|
1271
|
+
readFrom: from ? [from] : [],
|
|
1272
|
+
disabled,
|
|
1273
|
+
empty
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1355
1276
|
var OVERLAY_ROLE = {
|
|
1356
1277
|
dialog: "dialog",
|
|
1357
1278
|
alertdialog: "dialog",
|
|
@@ -1459,7 +1380,7 @@ function elements(markup) {
|
|
|
1459
1380
|
stack.length = depth2;
|
|
1460
1381
|
continue;
|
|
1461
1382
|
}
|
|
1462
|
-
const attributes =
|
|
1383
|
+
const attributes = attributesOf(match[3] ?? "");
|
|
1463
1384
|
const own = LANDMARK_ROLE[attributes["role"]?.toLowerCase() ?? ""] ?? LANDMARK_TAG[tag];
|
|
1464
1385
|
const frame = {
|
|
1465
1386
|
tag,
|
|
@@ -1480,7 +1401,7 @@ function elements(markup) {
|
|
|
1480
1401
|
return out.sort((a, b) => a.at - b.at);
|
|
1481
1402
|
}
|
|
1482
1403
|
var ATTRIBUTE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
1483
|
-
function
|
|
1404
|
+
function attributesOf(raw) {
|
|
1484
1405
|
const attributes = {};
|
|
1485
1406
|
ATTRIBUTE.lastIndex = 0;
|
|
1486
1407
|
let match;
|
|
@@ -1491,7 +1412,7 @@ function attributesOf2(raw) {
|
|
|
1491
1412
|
}
|
|
1492
1413
|
return attributes;
|
|
1493
1414
|
}
|
|
1494
|
-
function
|
|
1415
|
+
function controlState(element) {
|
|
1495
1416
|
const state = {};
|
|
1496
1417
|
const { attributes } = element;
|
|
1497
1418
|
const flag = (name) => name in attributes && attributes[name] !== "false";
|
|
@@ -1505,8 +1426,8 @@ function controlState2(element) {
|
|
|
1505
1426
|
}
|
|
1506
1427
|
function accessibleName(element) {
|
|
1507
1428
|
const explicit = element.attributes["aria-label"] ?? element.attributes["title"];
|
|
1508
|
-
if (explicit?.trim()) return
|
|
1509
|
-
return
|
|
1429
|
+
if (explicit?.trim()) return trim(explicit.replace(/\s+/g, " ").trim(), LIMITS.label);
|
|
1430
|
+
return trim(text(element.inner), LIMITS.label);
|
|
1510
1431
|
}
|
|
1511
1432
|
function submitLike(element) {
|
|
1512
1433
|
if (element.tag !== "input") return false;
|
|
@@ -1520,7 +1441,7 @@ function fieldFrom(element, body) {
|
|
|
1520
1441
|
const kind = tag === "input" ? type : tag;
|
|
1521
1442
|
const label = attributes["aria-label"]?.trim() || attributes["placeholder"]?.trim() || labelFor(body, attributes["id"]) || humanise(attributes["name"] ?? "");
|
|
1522
1443
|
if (!label) return void 0;
|
|
1523
|
-
const field = { label:
|
|
1444
|
+
const field = { label: trim(label.replace(/\s+/g, " "), LIMITS.label), kind };
|
|
1524
1445
|
if (tag === "select") {
|
|
1525
1446
|
const choices = [...element.inner.matchAll(/<option\b[^>]*>([\s\S]*?)<\/option>/gi)].map((match) => text(match[1] ?? "")).filter(Boolean).slice(0, LIMITS.choices);
|
|
1526
1447
|
if (choices.length) field.choices = choices;
|
|
@@ -1567,10 +1488,279 @@ function decode(value) {
|
|
|
1567
1488
|
return Number.isFinite(numeric) && numeric > 0 && numeric < 1114112 ? String.fromCodePoint(numeric) : whole;
|
|
1568
1489
|
});
|
|
1569
1490
|
}
|
|
1570
|
-
function
|
|
1491
|
+
function trim(value, limit) {
|
|
1571
1492
|
return value.length <= limit ? value : `${value.slice(0, limit - 1).trimEnd()}\u2026`;
|
|
1572
1493
|
}
|
|
1573
1494
|
|
|
1495
|
+
// core/capture/screen-details.ts
|
|
1496
|
+
function screenDetails(markup, context) {
|
|
1497
|
+
const read = readRenderedFacts(markup, { url: context.route });
|
|
1498
|
+
const clean = (text2) => scrub(text2, { where: context.route, ...context.brands?.length ? { brands: context.brands } : {} }).findings.length === 0;
|
|
1499
|
+
const keep = (values) => values.filter(clean);
|
|
1500
|
+
const links = [];
|
|
1501
|
+
for (const link of read.links) {
|
|
1502
|
+
const href = linkTarget(link.href, context);
|
|
1503
|
+
if (!href || link.label && !clean(link.label)) continue;
|
|
1504
|
+
links.push(link.label ? { label: link.label, href } : { href });
|
|
1505
|
+
}
|
|
1506
|
+
const disabled = keep(read.disabled ?? []);
|
|
1507
|
+
const empty = keep(read.empty ?? []);
|
|
1508
|
+
return {
|
|
1509
|
+
headings: keep(read.headings),
|
|
1510
|
+
actions: keep(read.actions),
|
|
1511
|
+
links,
|
|
1512
|
+
fields: read.fields.filter((field) => clean(field.label)).map((field) => field.choices ? { ...field, choices: keep(field.choices) } : field),
|
|
1513
|
+
notices: keep(read.notices),
|
|
1514
|
+
warnings: keep(read.warnings),
|
|
1515
|
+
readFrom: [context.route],
|
|
1516
|
+
read: { how: "rendered", commit: commitOf(context.commit) },
|
|
1517
|
+
...disabled.length ? { disabled } : {},
|
|
1518
|
+
...empty.length ? { empty } : {}
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
function commitOf(commit) {
|
|
1522
|
+
return commit && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(commit) ? commit.toLowerCase() : null;
|
|
1523
|
+
}
|
|
1524
|
+
var RECORD_ID = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,}|\d+|[a-z0-9]{24,})$/i;
|
|
1525
|
+
function linkTarget(href, context) {
|
|
1526
|
+
let url;
|
|
1527
|
+
try {
|
|
1528
|
+
url = new URL(href, context.origin);
|
|
1529
|
+
} catch {
|
|
1530
|
+
return void 0;
|
|
1531
|
+
}
|
|
1532
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
1533
|
+
if (url.origin !== context.origin) return `${url.origin}${url.pathname}`;
|
|
1534
|
+
const path3 = url.pathname;
|
|
1535
|
+
const screen = context.patterns.find((pattern) => fillsPattern(pattern, path3));
|
|
1536
|
+
if (screen) return screen;
|
|
1537
|
+
return path3.split("/").map((segment) => RECORD_ID.test(segment) ? "[id]" : segment).join("/");
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// core/capture/determinism.ts
|
|
1541
|
+
import { createHash } from "node:crypto";
|
|
1542
|
+
function freezeScript(pinnedClock, options = {}) {
|
|
1543
|
+
const fixed = Date.parse(pinnedClock);
|
|
1544
|
+
if (Number.isNaN(fixed)) {
|
|
1545
|
+
throw new Error(`freezeScript needs a parsable instant, got ${JSON.stringify(pinnedClock)}`);
|
|
1546
|
+
}
|
|
1547
|
+
const seed = options.seed ?? 2654435769;
|
|
1548
|
+
return `(() => {
|
|
1549
|
+
const FIXED = ${fixed};
|
|
1550
|
+
const RealDate = Date;
|
|
1551
|
+
|
|
1552
|
+
class FrozenDate extends RealDate {
|
|
1553
|
+
constructor(...args) {
|
|
1554
|
+
// Only the no-argument form is the clock. \`new Date(x)\` is arithmetic
|
|
1555
|
+
// on a value the page already had, and rewriting it would corrupt real
|
|
1556
|
+
// data rather than stabilise a render.
|
|
1557
|
+
if (args.length === 0) super(FIXED);
|
|
1558
|
+
else super(...args);
|
|
1559
|
+
}
|
|
1560
|
+
static now() { return FIXED; }
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// \`parse\` and \`UTC\` come along by static inheritance and must not be
|
|
1564
|
+
// touched: they are pure functions of their arguments already.
|
|
1565
|
+
FrozenDate.prototype.constructor = FrozenDate;
|
|
1566
|
+
globalThis.Date = FrozenDate;
|
|
1567
|
+
|
|
1568
|
+
let state = ${seed} >>> 0;
|
|
1569
|
+
// mulberry32: small, fast, and good enough that a seeded shuffle still looks
|
|
1570
|
+
// shuffled. Cryptographic quality is the opposite of what is wanted here.
|
|
1571
|
+
globalThis.Math.random = () => {
|
|
1572
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
1573
|
+
let t = state;
|
|
1574
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
1575
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
1576
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
if (globalThis.crypto && globalThis.crypto.randomUUID) {
|
|
1580
|
+
let issued = 0;
|
|
1581
|
+
globalThis.crypto.randomUUID = () => {
|
|
1582
|
+
issued += 1;
|
|
1583
|
+
const n = issued.toString(16).padStart(12, "0");
|
|
1584
|
+
return "00000000-0000-4000-8000-" + n;
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
})();`;
|
|
1588
|
+
}
|
|
1589
|
+
function frameHash(bytes) {
|
|
1590
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
1591
|
+
}
|
|
1592
|
+
async function settle(options) {
|
|
1593
|
+
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1594
|
+
const now = options.now ?? (() => Date.now());
|
|
1595
|
+
const deadline = now() + options.timeoutMs;
|
|
1596
|
+
let value = await options.sample();
|
|
1597
|
+
let samples = 1;
|
|
1598
|
+
let matches = 1;
|
|
1599
|
+
while (matches < options.identicalSamples) {
|
|
1600
|
+
if (now() >= deadline) return { value, samples, settled: false };
|
|
1601
|
+
await wait(options.intervalMs);
|
|
1602
|
+
const next = await options.sample();
|
|
1603
|
+
samples += 1;
|
|
1604
|
+
matches = options.same(value, next) ? matches + 1 : 1;
|
|
1605
|
+
value = next;
|
|
1606
|
+
}
|
|
1607
|
+
return { value, samples, settled: true };
|
|
1608
|
+
}
|
|
1609
|
+
function sameBytes(a, b) {
|
|
1610
|
+
if (a.length !== b.length) return false;
|
|
1611
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
1612
|
+
if (a[index] !== b[index]) return false;
|
|
1613
|
+
}
|
|
1614
|
+
return true;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// core/discovery/aria-facts.ts
|
|
1618
|
+
var LANDMARK = {
|
|
1619
|
+
main: "main",
|
|
1620
|
+
navigation: "nav",
|
|
1621
|
+
banner: "header",
|
|
1622
|
+
contentinfo: "footer",
|
|
1623
|
+
complementary: "aside",
|
|
1624
|
+
dialog: "dialog",
|
|
1625
|
+
alertdialog: "dialog",
|
|
1626
|
+
form: "form",
|
|
1627
|
+
search: "form"
|
|
1628
|
+
};
|
|
1629
|
+
var ACTION = /* @__PURE__ */ new Set(["button", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "switch"]);
|
|
1630
|
+
var FIELD = {
|
|
1631
|
+
textbox: "text",
|
|
1632
|
+
searchbox: "search",
|
|
1633
|
+
combobox: "select",
|
|
1634
|
+
listbox: "select",
|
|
1635
|
+
checkbox: "checkbox",
|
|
1636
|
+
radio: "radio",
|
|
1637
|
+
slider: "range",
|
|
1638
|
+
spinbutton: "number"
|
|
1639
|
+
};
|
|
1640
|
+
var NOTICE = /* @__PURE__ */ new Set(["status", "alert"]);
|
|
1641
|
+
var STATE = /* @__PURE__ */ new Set(["disabled", "checked", "selected", "expanded", "readonly", "required"]);
|
|
1642
|
+
function readAriaFactRecords(snapshot, options = {}) {
|
|
1643
|
+
const from = options.url ?? "";
|
|
1644
|
+
const inSource = options.inSource ?? /* @__PURE__ */ new Set();
|
|
1645
|
+
const facts = [];
|
|
1646
|
+
const add = (kind, value, node, extra = {}) => {
|
|
1647
|
+
if (!value) return;
|
|
1648
|
+
const scope = scopeFromLandmark(node.landmark);
|
|
1649
|
+
facts.push(
|
|
1650
|
+
fact({
|
|
1651
|
+
kind,
|
|
1652
|
+
value: trim2(value, LIMITS.label),
|
|
1653
|
+
channel: "p3",
|
|
1654
|
+
scope,
|
|
1655
|
+
dataClass: classifyData(value, scope, { inSource: inSource.has(value) }),
|
|
1656
|
+
from,
|
|
1657
|
+
landmark: node.landmark,
|
|
1658
|
+
...extra
|
|
1659
|
+
})
|
|
1660
|
+
);
|
|
1661
|
+
};
|
|
1662
|
+
for (const node of parse(snapshot)) {
|
|
1663
|
+
const state = controlState2(node);
|
|
1664
|
+
if (node.role === "heading") {
|
|
1665
|
+
add("heading", node.name || node.text, node);
|
|
1666
|
+
if (isEmptyState(node.name || node.text)) add("empty", node.name || node.text, node);
|
|
1667
|
+
continue;
|
|
1668
|
+
}
|
|
1669
|
+
if (node.role === "paragraph" || node.role === "text") {
|
|
1670
|
+
const said = node.text || node.name;
|
|
1671
|
+
if (isEmptyState(said)) add("empty", trim2(said, LIMITS.sentence), node);
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
if (node.role === "link") {
|
|
1675
|
+
add("link", node.name || node.text || node.url || "", node, {
|
|
1676
|
+
...node.url ? { href: node.url } : {},
|
|
1677
|
+
...state
|
|
1678
|
+
});
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
if (ACTION.has(node.role)) {
|
|
1682
|
+
add("action", node.name || node.text, node, state);
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
const fieldKind = FIELD[node.role];
|
|
1686
|
+
if (fieldKind) {
|
|
1687
|
+
add("field", node.name || node.text, node, {
|
|
1688
|
+
fieldKind,
|
|
1689
|
+
...node.choices.length ? { choices: node.choices.slice(0, LIMITS.choices) } : {},
|
|
1690
|
+
...state
|
|
1691
|
+
});
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
if (NOTICE.has(node.role)) {
|
|
1695
|
+
const said = trim2(node.text || node.name, LIMITS.sentence);
|
|
1696
|
+
add("notice", said, node);
|
|
1697
|
+
if (isEmptyState(said)) add("empty", said, node);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
return facts;
|
|
1701
|
+
}
|
|
1702
|
+
var LINE = /^(\s*)-\s+(.*)$/;
|
|
1703
|
+
var ENTRY = /^([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?((?:\s*\[[^\]]*\])*)\s*(?::\s*(.*))?$/;
|
|
1704
|
+
function parse(snapshot) {
|
|
1705
|
+
const out = [];
|
|
1706
|
+
const stack = [];
|
|
1707
|
+
for (const raw of snapshot.split("\n")) {
|
|
1708
|
+
const line = LINE.exec(raw);
|
|
1709
|
+
if (!line) continue;
|
|
1710
|
+
const depth2 = line[1].length;
|
|
1711
|
+
const body = line[2];
|
|
1712
|
+
while (stack.length && stack[stack.length - 1].depth >= depth2) stack.pop();
|
|
1713
|
+
const parent = stack[stack.length - 1]?.node;
|
|
1714
|
+
if (body.startsWith("/")) {
|
|
1715
|
+
const [name, ...rest] = body.split(":");
|
|
1716
|
+
if (parent && name?.trim() === "/url") parent.url = rest.join(":").trim();
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
const entry = ENTRY.exec(body);
|
|
1720
|
+
if (!entry) continue;
|
|
1721
|
+
const role = entry[1];
|
|
1722
|
+
const node = {
|
|
1723
|
+
role,
|
|
1724
|
+
name: unescape(entry[2] ?? ""),
|
|
1725
|
+
text: (entry[4] ?? "").trim(),
|
|
1726
|
+
attributes: attributesOf2(entry[3] ?? ""),
|
|
1727
|
+
landmark: LANDMARK[role] ?? parent?.landmark ?? "none",
|
|
1728
|
+
choices: []
|
|
1729
|
+
};
|
|
1730
|
+
if (role === "option" && parent) {
|
|
1731
|
+
if (node.name || node.text) parent.choices.push(node.name || node.text);
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
out.push(node);
|
|
1735
|
+
stack.push({ depth: depth2, node });
|
|
1736
|
+
}
|
|
1737
|
+
return out;
|
|
1738
|
+
}
|
|
1739
|
+
function attributesOf2(raw) {
|
|
1740
|
+
const attributes = {};
|
|
1741
|
+
for (const match of raw.matchAll(/\[([^\]]*)\]/g)) {
|
|
1742
|
+
const [name, ...rest] = match[1].split("=");
|
|
1743
|
+
const key = name?.trim();
|
|
1744
|
+
if (!key) continue;
|
|
1745
|
+
attributes[key] = rest.length ? rest.join("=").trim() : true;
|
|
1746
|
+
}
|
|
1747
|
+
return attributes;
|
|
1748
|
+
}
|
|
1749
|
+
function controlState2(node) {
|
|
1750
|
+
const state = {};
|
|
1751
|
+
for (const [name, value] of Object.entries(node.attributes)) {
|
|
1752
|
+
if (STATE.has(name)) state[name] = value;
|
|
1753
|
+
}
|
|
1754
|
+
return Object.keys(state).length ? { controlState: state } : {};
|
|
1755
|
+
}
|
|
1756
|
+
function unescape(value) {
|
|
1757
|
+
return value.replace(/\\(.)/g, "$1");
|
|
1758
|
+
}
|
|
1759
|
+
function trim2(value, limit) {
|
|
1760
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
1761
|
+
return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1).trimEnd()}\u2026`;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1574
1764
|
// core/coverage/stamp.ts
|
|
1575
1765
|
var RUNTIME = /* @__PURE__ */ new Set(["p3", "p4"]);
|
|
1576
1766
|
var STATIC = /* @__PURE__ */ new Set(["p1", "p2", "p2-state", "p5"]);
|
|
@@ -1722,11 +1912,16 @@ async function runCapture(options) {
|
|
|
1722
1912
|
options.onProgress?.(outcome, index + 1, options.shots.length);
|
|
1723
1913
|
}
|
|
1724
1914
|
const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
|
|
1915
|
+
const readings = {};
|
|
1916
|
+
for (const outcome of outcomes) {
|
|
1917
|
+
if (outcome.facts && (outcome.status === "captured" || outcome.status === "unchanged")) readings[outcome.route] = outcome.facts;
|
|
1918
|
+
}
|
|
1725
1919
|
return {
|
|
1726
1920
|
baseUrl: options.baseUrl,
|
|
1727
1921
|
outcomes,
|
|
1728
1922
|
evidence,
|
|
1729
1923
|
survey,
|
|
1924
|
+
readings,
|
|
1730
1925
|
...build ? { browser: build } : {},
|
|
1731
1926
|
captured: count("captured"),
|
|
1732
1927
|
unchanged: count("unchanged"),
|
|
@@ -1847,6 +2042,7 @@ async function captureOne(options, shot, build) {
|
|
|
1847
2042
|
};
|
|
1848
2043
|
}
|
|
1849
2044
|
}
|
|
2045
|
+
const facts = await readDetails(settledPage, shot, options);
|
|
1850
2046
|
const approved = new Set(options.clearance?.approved ?? []);
|
|
1851
2047
|
const cover = (reading?.surfaces ?? []).filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
|
|
1852
2048
|
const frame = await settle({
|
|
@@ -1896,7 +2092,8 @@ async function captureOne(options, shot, build) {
|
|
|
1896
2092
|
result: observed.result,
|
|
1897
2093
|
sha256,
|
|
1898
2094
|
settled: frame.settled,
|
|
1899
|
-
reason: "byte-identical to the last run"
|
|
2095
|
+
reason: "byte-identical to the last run",
|
|
2096
|
+
...facts ? { facts } : {}
|
|
1900
2097
|
},
|
|
1901
2098
|
observed,
|
|
1902
2099
|
...evidence ? { seen: evidence } : {}
|
|
@@ -1939,6 +2136,7 @@ async function captureOne(options, shot, build) {
|
|
|
1939
2136
|
result: observed.result,
|
|
1940
2137
|
sha256,
|
|
1941
2138
|
settled: frame.settled,
|
|
2139
|
+
...facts ? { facts } : {},
|
|
1942
2140
|
...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
|
|
1943
2141
|
reason: "photographed, but this screen never stopped moving. Its hash will differ every run, so drift on it means nothing"
|
|
1944
2142
|
}
|
|
@@ -2135,6 +2333,9 @@ async function playwrightBrowser(options = {}) {
|
|
|
2135
2333
|
}).catch(() => false);
|
|
2136
2334
|
return { inventory, surfaces, styled };
|
|
2137
2335
|
},
|
|
2336
|
+
async painted(regions) {
|
|
2337
|
+
return readPaintedMarkup(page, regions).catch(() => "");
|
|
2338
|
+
},
|
|
2138
2339
|
async screenshot(cover) {
|
|
2139
2340
|
const masks = (cover ?? []).map((selector) => page.locator(selector));
|
|
2140
2341
|
return page.screenshot({
|
|
@@ -2269,6 +2470,18 @@ async function readScreen(page, clearance) {
|
|
|
2269
2470
|
if (!page.inspect) return void 0;
|
|
2270
2471
|
return page.inspect(clearance.regions);
|
|
2271
2472
|
}
|
|
2473
|
+
async function readDetails(page, shot, options) {
|
|
2474
|
+
if (!options.clearance || !page.painted) return void 0;
|
|
2475
|
+
const markup = await page.painted(options.clearance.regions).catch(() => "");
|
|
2476
|
+
if (!markup) return void 0;
|
|
2477
|
+
return screenDetails(markup, {
|
|
2478
|
+
route: shot.route,
|
|
2479
|
+
commit: options.commit,
|
|
2480
|
+
origin: new URL(options.baseUrl).origin,
|
|
2481
|
+
patterns: options.shots.map((each) => each.route),
|
|
2482
|
+
brands: options.brands
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2272
2485
|
function pathOf(url) {
|
|
2273
2486
|
try {
|
|
2274
2487
|
const parsed = new URL(url);
|
|
@@ -3448,6 +3661,9 @@ Approve one on the plan page, then run this again.`
|
|
|
3448
3661
|
} : {},
|
|
3449
3662
|
brands: parsed.brands,
|
|
3450
3663
|
force: Boolean(parsed.rebaseline),
|
|
3664
|
+
// The commit a workflow run checked out, so the screens' details say
|
|
3665
|
+
// which code they describe. Absent anywhere else, and never guessed.
|
|
3666
|
+
.../^[0-9a-f]{40}$/i.test(process.env["GITHUB_SHA"] ?? "") ? { commit: process.env["GITHUB_SHA"] } : {},
|
|
3451
3667
|
/*
|
|
3452
3668
|
* Said rather than inferred. A run with no saved session is signed out,
|
|
3453
3669
|
* which is a fact about the evidence worth recording; a run with one is
|
|
@@ -3522,6 +3738,10 @@ Approve one on the plan page, then run this again.`
|
|
|
3522
3738
|
});
|
|
3523
3739
|
console.log(`
|
|
3524
3740
|
${runCounts(report)}.`);
|
|
3741
|
+
const read = Object.keys(report.readings).length;
|
|
3742
|
+
if (read && !local) {
|
|
3743
|
+
console.log(`Read what ${read} screen${read === 1 ? "" : "s"} say, as the help desk's screen details: headings, buttons, fields and messages.`);
|
|
3744
|
+
}
|
|
3525
3745
|
const unclassified = unclassifiedLines(
|
|
3526
3746
|
groupUnclassified(
|
|
3527
3747
|
report.outcomes.flatMap((outcome) => outcome.unaccounted?.length ? [{ route: outcome.route, values: outcome.unaccounted }] : [])
|
|
@@ -3628,7 +3848,9 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
|
|
|
3628
3848
|
jobId: runId,
|
|
3629
3849
|
// What the verdict below prints, so Setup can say what this log says.
|
|
3630
3850
|
counts,
|
|
3631
|
-
...report.survey.length ? { survey: report.survey } : {}
|
|
3851
|
+
...report.survey.length ? { survey: report.survey } : {},
|
|
3852
|
+
// What each photographed screen says, as the screens' details.
|
|
3853
|
+
...Object.keys(report.readings).length ? { readings: report.readings } : {}
|
|
3632
3854
|
});
|
|
3633
3855
|
}
|
|
3634
3856
|
const verdict = runVerdict(counts);
|
package/helpdesk.mjs
CHANGED
|
@@ -965,8 +965,13 @@ function hasAnything(facts) {
|
|
|
965
965
|
}
|
|
966
966
|
function describeReading(routes) {
|
|
967
967
|
const documentable = routes.filter((route) => !route.skipped);
|
|
968
|
-
const read = documentable.filter((route) => route.facts && hasAnything(route.facts))
|
|
969
|
-
return {
|
|
968
|
+
const read = documentable.filter((route) => route.facts && hasAnything(route.facts));
|
|
969
|
+
return {
|
|
970
|
+
screens: documentable.length,
|
|
971
|
+
read: read.length,
|
|
972
|
+
rendered: read.filter((route) => route.facts?.read?.how === "rendered").length,
|
|
973
|
+
pathsOnly: read.length === 0
|
|
974
|
+
};
|
|
970
975
|
}
|
|
971
976
|
|
|
972
977
|
// core/github/remote.ts
|