@olomonhelpdesk2/helpdesk 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/helpdesk-capture.mjs +476 -238
- package/helpdesk-seed.mjs +14 -1
- package/helpdesk.mjs +7 -2
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -154,7 +154,20 @@ var CAPTURE_CONTRACT = {
|
|
|
154
154
|
/**
|
|
155
155
|
* Injected before every shot. Necessary, not sufficient — see above.
|
|
156
156
|
*/
|
|
157
|
-
motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
|
|
157
|
+
motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}",
|
|
158
|
+
/**
|
|
159
|
+
* What an app uses to leave its own floating widgets out of the pictures.
|
|
160
|
+
*
|
|
161
|
+
* A feedback button or a chat launcher sits on top of the product on every
|
|
162
|
+
* screen, and an article about the product should show only the product. The
|
|
163
|
+
* capture sets `marker` on `<html>` of every page it photographs, so an app
|
|
164
|
+
* can style a widget away under `html[data-helpdesk-capture]`, and it removes
|
|
165
|
+
* any element carrying `hide` itself. Removed before the screen is read, so
|
|
166
|
+
* what is not in the picture is not asked about either.
|
|
167
|
+
*/
|
|
168
|
+
marker: "data-helpdesk-capture",
|
|
169
|
+
hide: "data-helpdesk-hide",
|
|
170
|
+
hideOff: "[data-helpdesk-hide]{display:none!important}"
|
|
158
171
|
};
|
|
159
172
|
var VISUAL_STABILITY = {
|
|
160
173
|
/** Gap between comparison frames. Short enough to be cheap, long enough to differ. */
|
|
@@ -963,88 +976,57 @@ async function takeInventory(page, regions) {
|
|
|
963
976
|
};
|
|
964
977
|
}
|
|
965
978
|
|
|
966
|
-
//
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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
|
-
return "00000000-0000-4000-8000-" + n;
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
})();`;
|
|
1021
|
-
}
|
|
1022
|
-
function frameHash(bytes) {
|
|
1023
|
-
return createHash("sha256").update(bytes).digest("hex");
|
|
1024
|
-
}
|
|
1025
|
-
async function settle(options) {
|
|
1026
|
-
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1027
|
-
const now = options.now ?? (() => Date.now());
|
|
1028
|
-
const deadline = now() + options.timeoutMs;
|
|
1029
|
-
let value = await options.sample();
|
|
1030
|
-
let samples = 1;
|
|
1031
|
-
let matches = 1;
|
|
1032
|
-
while (matches < options.identicalSamples) {
|
|
1033
|
-
if (now() >= deadline) return { value, samples, settled: false };
|
|
1034
|
-
await wait(options.intervalMs);
|
|
1035
|
-
const next = await options.sample();
|
|
1036
|
-
samples += 1;
|
|
1037
|
-
matches = options.same(value, next) ? matches + 1 : 1;
|
|
1038
|
-
value = next;
|
|
1039
|
-
}
|
|
1040
|
-
return { value, samples, settled: true };
|
|
1041
|
-
}
|
|
1042
|
-
function sameBytes(a, b) {
|
|
1043
|
-
if (a.length !== b.length) return false;
|
|
1044
|
-
for (let index = 0; index < a.length; index += 1) {
|
|
1045
|
-
if (a[index] !== b[index]) return false;
|
|
1046
|
-
}
|
|
1047
|
-
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
|
+
);
|
|
1048
1030
|
}
|
|
1049
1031
|
|
|
1050
1032
|
// core/coverage/normalize.ts
|
|
@@ -1098,152 +1080,52 @@ function isEmptyState(text2) {
|
|
|
1098
1080
|
if (sentence.split(" ").length < 2) return false;
|
|
1099
1081
|
return EMPTY_STATE.test(sentence);
|
|
1100
1082
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
var
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
complementary: "aside",
|
|
1109
|
-
dialog: "dialog",
|
|
1110
|
-
alertdialog: "dialog",
|
|
1111
|
-
form: "form",
|
|
1112
|
-
search: "form"
|
|
1113
|
-
};
|
|
1114
|
-
var ACTION = /* @__PURE__ */ new Set(["button", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "switch"]);
|
|
1115
|
-
var FIELD = {
|
|
1116
|
-
textbox: "text",
|
|
1117
|
-
searchbox: "search",
|
|
1118
|
-
combobox: "select",
|
|
1119
|
-
listbox: "select",
|
|
1120
|
-
checkbox: "checkbox",
|
|
1121
|
-
radio: "radio",
|
|
1122
|
-
slider: "range",
|
|
1123
|
-
spinbutton: "number"
|
|
1124
|
-
};
|
|
1125
|
-
var NOTICE = /* @__PURE__ */ new Set(["status", "alert"]);
|
|
1126
|
-
var STATE = /* @__PURE__ */ new Set(["disabled", "checked", "selected", "expanded", "readonly", "required"]);
|
|
1127
|
-
function readAriaFactRecords(snapshot, options = {}) {
|
|
1128
|
-
const from = options.url ?? "";
|
|
1129
|
-
const inSource = options.inSource ?? /* @__PURE__ */ new Set();
|
|
1130
|
-
const facts = [];
|
|
1131
|
-
const add = (kind, value, node, extra = {}) => {
|
|
1132
|
-
if (!value) return;
|
|
1133
|
-
const scope = scopeFromLandmark(node.landmark);
|
|
1134
|
-
facts.push(
|
|
1135
|
-
fact({
|
|
1136
|
-
kind,
|
|
1137
|
-
value: trim(value, LIMITS.label),
|
|
1138
|
-
channel: "p3",
|
|
1139
|
-
scope,
|
|
1140
|
-
dataClass: classifyData(value, scope, { inSource: inSource.has(value) }),
|
|
1141
|
-
from,
|
|
1142
|
-
landmark: node.landmark,
|
|
1143
|
-
...extra
|
|
1144
|
-
})
|
|
1145
|
-
);
|
|
1146
|
-
};
|
|
1147
|
-
for (const node of parse(snapshot)) {
|
|
1148
|
-
const state = controlState(node);
|
|
1149
|
-
if (node.role === "heading") {
|
|
1150
|
-
add("heading", node.name || node.text, node);
|
|
1151
|
-
if (isEmptyState(node.name || node.text)) add("empty", node.name || node.text, node);
|
|
1152
|
-
continue;
|
|
1153
|
-
}
|
|
1154
|
-
if (node.role === "paragraph" || node.role === "text") {
|
|
1155
|
-
const said = node.text || node.name;
|
|
1156
|
-
if (isEmptyState(said)) add("empty", trim(said, LIMITS.sentence), node);
|
|
1157
|
-
continue;
|
|
1158
|
-
}
|
|
1159
|
-
if (node.role === "link") {
|
|
1160
|
-
add("link", node.name || node.text || node.url || "", node, {
|
|
1161
|
-
...node.url ? { href: node.url } : {},
|
|
1162
|
-
...state
|
|
1163
|
-
});
|
|
1164
|
-
continue;
|
|
1165
|
-
}
|
|
1166
|
-
if (ACTION.has(node.role)) {
|
|
1167
|
-
add("action", node.name || node.text, node, state);
|
|
1168
|
-
continue;
|
|
1169
|
-
}
|
|
1170
|
-
const fieldKind = FIELD[node.role];
|
|
1171
|
-
if (fieldKind) {
|
|
1172
|
-
add("field", node.name || node.text, node, {
|
|
1173
|
-
fieldKind,
|
|
1174
|
-
...node.choices.length ? { choices: node.choices.slice(0, LIMITS.choices) } : {},
|
|
1175
|
-
...state
|
|
1176
|
-
});
|
|
1177
|
-
continue;
|
|
1178
|
-
}
|
|
1179
|
-
if (NOTICE.has(node.role)) {
|
|
1180
|
-
const said = trim(node.text || node.name, LIMITS.sentence);
|
|
1181
|
-
add("notice", said, node);
|
|
1182
|
-
if (isEmptyState(said)) add("empty", said, node);
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
return facts;
|
|
1186
|
-
}
|
|
1187
|
-
var LINE = /^(\s*)-\s+(.*)$/;
|
|
1188
|
-
var ENTRY = /^([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?((?:\s*\[[^\]]*\])*)\s*(?::\s*(.*))?$/;
|
|
1189
|
-
function parse(snapshot) {
|
|
1190
|
-
const out = [];
|
|
1191
|
-
const stack = [];
|
|
1192
|
-
for (const raw of snapshot.split("\n")) {
|
|
1193
|
-
const line = LINE.exec(raw);
|
|
1194
|
-
if (!line) continue;
|
|
1195
|
-
const depth2 = line[1].length;
|
|
1196
|
-
const body = line[2];
|
|
1197
|
-
while (stack.length && stack[stack.length - 1].depth >= depth2) stack.pop();
|
|
1198
|
-
const parent = stack[stack.length - 1]?.node;
|
|
1199
|
-
if (body.startsWith("/")) {
|
|
1200
|
-
const [name, ...rest] = body.split(":");
|
|
1201
|
-
if (parent && name?.trim() === "/url") parent.url = rest.join(":").trim();
|
|
1202
|
-
continue;
|
|
1203
|
-
}
|
|
1204
|
-
const entry = ENTRY.exec(body);
|
|
1205
|
-
if (!entry) continue;
|
|
1206
|
-
const role = entry[1];
|
|
1207
|
-
const node = {
|
|
1208
|
-
role,
|
|
1209
|
-
name: unescape(entry[2] ?? ""),
|
|
1210
|
-
text: (entry[4] ?? "").trim(),
|
|
1211
|
-
attributes: attributesOf(entry[3] ?? ""),
|
|
1212
|
-
landmark: LANDMARK[role] ?? parent?.landmark ?? "none",
|
|
1213
|
-
choices: []
|
|
1214
|
-
};
|
|
1215
|
-
if (role === "option" && parent) {
|
|
1216
|
-
if (node.name || node.text) parent.choices.push(node.name || node.text);
|
|
1217
|
-
continue;
|
|
1218
|
-
}
|
|
1219
|
-
out.push(node);
|
|
1220
|
-
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);
|
|
1221
1090
|
}
|
|
1222
|
-
|
|
1223
|
-
}
|
|
1224
|
-
function attributesOf(raw) {
|
|
1225
|
-
const attributes = {};
|
|
1226
|
-
for (const match of raw.matchAll(/\[([^\]]*)\]/g)) {
|
|
1227
|
-
const [name, ...rest] = match[1].split("=");
|
|
1228
|
-
const key = name?.trim();
|
|
1229
|
-
if (!key) continue;
|
|
1230
|
-
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.`);
|
|
1231
1093
|
}
|
|
1232
|
-
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
|
+
};
|
|
1233
1104
|
}
|
|
1234
|
-
function
|
|
1235
|
-
const
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
+
};
|
|
1240
1114
|
}
|
|
1241
|
-
function
|
|
1242
|
-
return
|
|
1115
|
+
function capped(values, limit) {
|
|
1116
|
+
return [...new Set(values)].slice(0, limit);
|
|
1243
1117
|
}
|
|
1244
|
-
function
|
|
1245
|
-
const
|
|
1246
|
-
|
|
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;
|
|
1247
1129
|
}
|
|
1248
1130
|
|
|
1249
1131
|
// core/discovery/rendered-facts.ts
|
|
@@ -1303,7 +1185,7 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1303
1185
|
continue;
|
|
1304
1186
|
}
|
|
1305
1187
|
if (tag === "button" || PRESSABLE.has(role) || submitLike(element)) {
|
|
1306
|
-
add("action", label, landmark,
|
|
1188
|
+
add("action", label, landmark, controlState(element));
|
|
1307
1189
|
continue;
|
|
1308
1190
|
}
|
|
1309
1191
|
if (tag === "a") {
|
|
@@ -1319,19 +1201,19 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1319
1201
|
add("field", field.label, landmark, {
|
|
1320
1202
|
fieldKind: field.kind,
|
|
1321
1203
|
...field.choices ? { choices: field.choices } : {},
|
|
1322
|
-
...
|
|
1204
|
+
...controlState(element)
|
|
1323
1205
|
});
|
|
1324
1206
|
}
|
|
1325
1207
|
continue;
|
|
1326
1208
|
}
|
|
1327
1209
|
if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
|
|
1328
|
-
const said =
|
|
1210
|
+
const said = trim(text(inner), LIMITS.sentence);
|
|
1329
1211
|
add("notice", said, landmark);
|
|
1330
1212
|
if (isEmptyState(said)) add("empty", said, landmark);
|
|
1331
1213
|
continue;
|
|
1332
1214
|
}
|
|
1333
1215
|
if (TEXT_HOLDER.has(tag) && !BLOCK_INSIDE.test(inner)) {
|
|
1334
|
-
const said =
|
|
1216
|
+
const said = trim(text(inner).replace(/\s+([.,;:!])/g, "$1"), LIMITS.sentence);
|
|
1335
1217
|
if (isEmptyState(said)) add("empty", said, landmark);
|
|
1336
1218
|
}
|
|
1337
1219
|
}
|
|
@@ -1339,6 +1221,58 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1339
1221
|
}
|
|
1340
1222
|
var TEXT_HOLDER = /* @__PURE__ */ new Set(["p", "span", "small", "td", "li", "div"]);
|
|
1341
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
|
+
}
|
|
1342
1276
|
var OVERLAY_ROLE = {
|
|
1343
1277
|
dialog: "dialog",
|
|
1344
1278
|
alertdialog: "dialog",
|
|
@@ -1446,7 +1380,7 @@ function elements(markup) {
|
|
|
1446
1380
|
stack.length = depth2;
|
|
1447
1381
|
continue;
|
|
1448
1382
|
}
|
|
1449
|
-
const attributes =
|
|
1383
|
+
const attributes = attributesOf(match[3] ?? "");
|
|
1450
1384
|
const own = LANDMARK_ROLE[attributes["role"]?.toLowerCase() ?? ""] ?? LANDMARK_TAG[tag];
|
|
1451
1385
|
const frame = {
|
|
1452
1386
|
tag,
|
|
@@ -1467,7 +1401,7 @@ function elements(markup) {
|
|
|
1467
1401
|
return out.sort((a, b) => a.at - b.at);
|
|
1468
1402
|
}
|
|
1469
1403
|
var ATTRIBUTE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
1470
|
-
function
|
|
1404
|
+
function attributesOf(raw) {
|
|
1471
1405
|
const attributes = {};
|
|
1472
1406
|
ATTRIBUTE.lastIndex = 0;
|
|
1473
1407
|
let match;
|
|
@@ -1478,7 +1412,7 @@ function attributesOf2(raw) {
|
|
|
1478
1412
|
}
|
|
1479
1413
|
return attributes;
|
|
1480
1414
|
}
|
|
1481
|
-
function
|
|
1415
|
+
function controlState(element) {
|
|
1482
1416
|
const state = {};
|
|
1483
1417
|
const { attributes } = element;
|
|
1484
1418
|
const flag = (name) => name in attributes && attributes[name] !== "false";
|
|
@@ -1492,8 +1426,8 @@ function controlState2(element) {
|
|
|
1492
1426
|
}
|
|
1493
1427
|
function accessibleName(element) {
|
|
1494
1428
|
const explicit = element.attributes["aria-label"] ?? element.attributes["title"];
|
|
1495
|
-
if (explicit?.trim()) return
|
|
1496
|
-
return
|
|
1429
|
+
if (explicit?.trim()) return trim(explicit.replace(/\s+/g, " ").trim(), LIMITS.label);
|
|
1430
|
+
return trim(text(element.inner), LIMITS.label);
|
|
1497
1431
|
}
|
|
1498
1432
|
function submitLike(element) {
|
|
1499
1433
|
if (element.tag !== "input") return false;
|
|
@@ -1507,7 +1441,7 @@ function fieldFrom(element, body) {
|
|
|
1507
1441
|
const kind = tag === "input" ? type : tag;
|
|
1508
1442
|
const label = attributes["aria-label"]?.trim() || attributes["placeholder"]?.trim() || labelFor(body, attributes["id"]) || humanise(attributes["name"] ?? "");
|
|
1509
1443
|
if (!label) return void 0;
|
|
1510
|
-
const field = { label:
|
|
1444
|
+
const field = { label: trim(label.replace(/\s+/g, " "), LIMITS.label), kind };
|
|
1511
1445
|
if (tag === "select") {
|
|
1512
1446
|
const choices = [...element.inner.matchAll(/<option\b[^>]*>([\s\S]*?)<\/option>/gi)].map((match) => text(match[1] ?? "")).filter(Boolean).slice(0, LIMITS.choices);
|
|
1513
1447
|
if (choices.length) field.choices = choices;
|
|
@@ -1554,10 +1488,279 @@ function decode(value) {
|
|
|
1554
1488
|
return Number.isFinite(numeric) && numeric > 0 && numeric < 1114112 ? String.fromCodePoint(numeric) : whole;
|
|
1555
1489
|
});
|
|
1556
1490
|
}
|
|
1557
|
-
function
|
|
1491
|
+
function trim(value, limit) {
|
|
1558
1492
|
return value.length <= limit ? value : `${value.slice(0, limit - 1).trimEnd()}\u2026`;
|
|
1559
1493
|
}
|
|
1560
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
|
+
|
|
1561
1764
|
// core/coverage/stamp.ts
|
|
1562
1765
|
var RUNTIME = /* @__PURE__ */ new Set(["p3", "p4"]);
|
|
1563
1766
|
var STATIC = /* @__PURE__ */ new Set(["p1", "p2", "p2-state", "p5"]);
|
|
@@ -1709,11 +1912,16 @@ async function runCapture(options) {
|
|
|
1709
1912
|
options.onProgress?.(outcome, index + 1, options.shots.length);
|
|
1710
1913
|
}
|
|
1711
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
|
+
}
|
|
1712
1919
|
return {
|
|
1713
1920
|
baseUrl: options.baseUrl,
|
|
1714
1921
|
outcomes,
|
|
1715
1922
|
evidence,
|
|
1716
1923
|
survey,
|
|
1924
|
+
readings,
|
|
1717
1925
|
...build ? { browser: build } : {},
|
|
1718
1926
|
captured: count("captured"),
|
|
1719
1927
|
unchanged: count("unchanged"),
|
|
@@ -1834,6 +2042,7 @@ async function captureOne(options, shot, build) {
|
|
|
1834
2042
|
};
|
|
1835
2043
|
}
|
|
1836
2044
|
}
|
|
2045
|
+
const facts = await readDetails(settledPage, shot, options);
|
|
1837
2046
|
const approved = new Set(options.clearance?.approved ?? []);
|
|
1838
2047
|
const cover = (reading?.surfaces ?? []).filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
|
|
1839
2048
|
const frame = await settle({
|
|
@@ -1883,7 +2092,8 @@ async function captureOne(options, shot, build) {
|
|
|
1883
2092
|
result: observed.result,
|
|
1884
2093
|
sha256,
|
|
1885
2094
|
settled: frame.settled,
|
|
1886
|
-
reason: "byte-identical to the last run"
|
|
2095
|
+
reason: "byte-identical to the last run",
|
|
2096
|
+
...facts ? { facts } : {}
|
|
1887
2097
|
},
|
|
1888
2098
|
observed,
|
|
1889
2099
|
...evidence ? { seen: evidence } : {}
|
|
@@ -1926,6 +2136,7 @@ async function captureOne(options, shot, build) {
|
|
|
1926
2136
|
result: observed.result,
|
|
1927
2137
|
sha256,
|
|
1928
2138
|
settled: frame.settled,
|
|
2139
|
+
...facts ? { facts } : {},
|
|
1929
2140
|
...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
|
|
1930
2141
|
reason: "photographed, but this screen never stopped moving. Its hash will differ every run, so drift on it means nothing"
|
|
1931
2142
|
}
|
|
@@ -2077,6 +2288,8 @@ async function playwrightBrowser(options = {}) {
|
|
|
2077
2288
|
const response = await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
|
|
2078
2289
|
status = response?.status();
|
|
2079
2290
|
await page.addStyleTag({ content: CAPTURE_CONTRACT.motionOff }).catch(() => void 0);
|
|
2291
|
+
await page.addStyleTag({ content: CAPTURE_CONTRACT.hideOff }).catch(() => void 0);
|
|
2292
|
+
await page.evaluate((name) => document.documentElement.setAttribute(name, ""), CAPTURE_CONTRACT.marker).catch(() => void 0);
|
|
2080
2293
|
await page.evaluate(() => document.fonts?.ready).catch(() => void 0);
|
|
2081
2294
|
},
|
|
2082
2295
|
async text() {
|
|
@@ -2120,6 +2333,9 @@ async function playwrightBrowser(options = {}) {
|
|
|
2120
2333
|
}).catch(() => false);
|
|
2121
2334
|
return { inventory, surfaces, styled };
|
|
2122
2335
|
},
|
|
2336
|
+
async painted(regions) {
|
|
2337
|
+
return readPaintedMarkup(page, regions).catch(() => "");
|
|
2338
|
+
},
|
|
2123
2339
|
async screenshot(cover) {
|
|
2124
2340
|
const masks = (cover ?? []).map((selector) => page.locator(selector));
|
|
2125
2341
|
return page.screenshot({
|
|
@@ -2254,6 +2470,18 @@ async function readScreen(page, clearance) {
|
|
|
2254
2470
|
if (!page.inspect) return void 0;
|
|
2255
2471
|
return page.inspect(clearance.regions);
|
|
2256
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
|
+
}
|
|
2257
2485
|
function pathOf(url) {
|
|
2258
2486
|
try {
|
|
2259
2487
|
const parsed = new URL(url);
|
|
@@ -3193,7 +3421,8 @@ function buildStoryboard(options) {
|
|
|
3193
3421
|
const beneath = options.routes.filter(
|
|
3194
3422
|
(route) => !covered.has(route.path) && !route.skipped && [...covered].some((base) => base !== "/" && route.path.startsWith(`${base}/`))
|
|
3195
3423
|
);
|
|
3196
|
-
const
|
|
3424
|
+
const own = [...covered].map((path3) => byPath.get(path3)).filter((route) => Boolean(route) && !route.skipped);
|
|
3425
|
+
const ordered = options.inOrder ? own : own.sort((a, b) => a.path.length - b.path.length).concat(beneath.sort((a, b) => a.path.length - b.path.length));
|
|
3197
3426
|
const limit = options.maxSteps ?? 5;
|
|
3198
3427
|
const steps = ordered.slice(0, limit).map((route, index) => ({
|
|
3199
3428
|
route: route.path,
|
|
@@ -3432,6 +3661,9 @@ Approve one on the plan page, then run this again.`
|
|
|
3432
3661
|
} : {},
|
|
3433
3662
|
brands: parsed.brands,
|
|
3434
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"] } : {},
|
|
3435
3667
|
/*
|
|
3436
3668
|
* Said rather than inferred. A run with no saved session is signed out,
|
|
3437
3669
|
* which is a fact about the evidence worth recording; a run with one is
|
|
@@ -3506,6 +3738,10 @@ Approve one on the plan page, then run this again.`
|
|
|
3506
3738
|
});
|
|
3507
3739
|
console.log(`
|
|
3508
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
|
+
}
|
|
3509
3745
|
const unclassified = unclassifiedLines(
|
|
3510
3746
|
groupUnclassified(
|
|
3511
3747
|
report.outcomes.flatMap((outcome) => outcome.unaccounted?.length ? [{ route: outcome.route, values: outcome.unaccounted }] : [])
|
|
@@ -3612,7 +3848,9 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
|
|
|
3612
3848
|
jobId: runId,
|
|
3613
3849
|
// What the verdict below prints, so Setup can say what this log says.
|
|
3614
3850
|
counts,
|
|
3615
|
-
...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 } : {}
|
|
3616
3854
|
});
|
|
3617
3855
|
}
|
|
3618
3856
|
const verdict = runVerdict(counts);
|
package/helpdesk-seed.mjs
CHANGED
|
@@ -1002,7 +1002,20 @@ var CAPTURE_CONTRACT = {
|
|
|
1002
1002
|
/**
|
|
1003
1003
|
* Injected before every shot. Necessary, not sufficient — see above.
|
|
1004
1004
|
*/
|
|
1005
|
-
motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
|
|
1005
|
+
motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}",
|
|
1006
|
+
/**
|
|
1007
|
+
* What an app uses to leave its own floating widgets out of the pictures.
|
|
1008
|
+
*
|
|
1009
|
+
* A feedback button or a chat launcher sits on top of the product on every
|
|
1010
|
+
* screen, and an article about the product should show only the product. The
|
|
1011
|
+
* capture sets `marker` on `<html>` of every page it photographs, so an app
|
|
1012
|
+
* can style a widget away under `html[data-helpdesk-capture]`, and it removes
|
|
1013
|
+
* any element carrying `hide` itself. Removed before the screen is read, so
|
|
1014
|
+
* what is not in the picture is not asked about either.
|
|
1015
|
+
*/
|
|
1016
|
+
marker: "data-helpdesk-capture",
|
|
1017
|
+
hide: "data-helpdesk-hide",
|
|
1018
|
+
hideOff: "[data-helpdesk-hide]{display:none!important}"
|
|
1006
1019
|
};
|
|
1007
1020
|
var RECORDING_MISS_STATUS = 599;
|
|
1008
1021
|
|
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
|