@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.
@@ -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
- // core/capture/determinism.ts
967
- import { createHash } from "node:crypto";
968
- function freezeScript(pinnedClock, options = {}) {
969
- const fixed = Date.parse(pinnedClock);
970
- if (Number.isNaN(fixed)) {
971
- throw new Error(`freezeScript needs a parsable instant, got ${JSON.stringify(pinnedClock)}`);
972
- }
973
- const seed = options.seed ?? 2654435769;
974
- return `(() => {
975
- const FIXED = ${fixed};
976
- const RealDate = Date;
977
-
978
- class FrozenDate extends RealDate {
979
- constructor(...args) {
980
- // Only the no-argument form is the clock. \`new Date(x)\` is arithmetic
981
- // on a value the page already had, and rewriting it would corrupt real
982
- // data rather than stabilise a render.
983
- if (args.length === 0) super(FIXED);
984
- else super(...args);
985
- }
986
- static now() { return FIXED; }
987
- }
988
-
989
- // \`parse\` and \`UTC\` come along by static inheritance and must not be
990
- // touched: they are pure functions of their arguments already.
991
- FrozenDate.prototype.constructor = FrozenDate;
992
- globalThis.Date = FrozenDate;
993
-
994
- let state = ${seed} >>> 0;
995
- // mulberry32: small, fast, and good enough that a seeded shuffle still looks
996
- // shuffled. Cryptographic quality is the opposite of what is wanted here.
997
- globalThis.Math.random = () => {
998
- state = (state + 0x6d2b79f5) >>> 0;
999
- let t = state;
1000
- t = Math.imul(t ^ (t >>> 15), t | 1);
1001
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
1002
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
1003
- };
1004
-
1005
- if (globalThis.performance) {
1006
- let ticks = 0;
1007
- // Monotonic and deterministic. Frozen would deadlock anything that waits
1008
- // for time to pass.
1009
- globalThis.performance.now = () => (ticks += 16);
1010
- }
1011
-
1012
- if (globalThis.crypto && globalThis.crypto.randomUUID) {
1013
- let issued = 0;
1014
- globalThis.crypto.randomUUID = () => {
1015
- issued += 1;
1016
- const n = issued.toString(16).padStart(12, "0");
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
- // core/discovery/aria-facts.ts
1103
- var LANDMARK = {
1104
- main: "main",
1105
- navigation: "nav",
1106
- banner: "header",
1107
- contentinfo: "footer",
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
- return out;
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 attributes;
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 controlState(node) {
1235
- const state = {};
1236
- for (const [name, value] of Object.entries(node.attributes)) {
1237
- if (STATE.has(name)) state[name] = value;
1238
- }
1239
- return Object.keys(state).length ? { controlState: state } : {};
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 unescape(value) {
1242
- return value.replace(/\\(.)/g, "$1");
1115
+ function capped(values, limit) {
1116
+ return [...new Set(values)].slice(0, limit);
1243
1117
  }
1244
- function trim(value, limit) {
1245
- const collapsed = value.replace(/\s+/g, " ").trim();
1246
- return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1).trimEnd()}\u2026`;
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, controlState2(element));
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
- ...controlState2(element)
1204
+ ...controlState(element)
1323
1205
  });
1324
1206
  }
1325
1207
  continue;
1326
1208
  }
1327
1209
  if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
1328
- const said = trim2(text(inner), LIMITS.sentence);
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 = trim2(text(inner).replace(/\s+([.,;:!])/g, "$1"), LIMITS.sentence);
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 = attributesOf2(match[3] ?? "");
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 attributesOf2(raw) {
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 controlState2(element) {
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 trim2(explicit.replace(/\s+/g, " ").trim(), LIMITS.label);
1496
- return trim2(text(element.inner), LIMITS.label);
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: trim2(label.replace(/\s+/g, " "), LIMITS.label), kind };
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 trim2(value, limit) {
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 ordered = [...covered].map((path3) => byPath.get(path3)).filter((route) => Boolean(route) && !route.skipped).sort((a, b) => a.path.length - b.path.length).concat(beneath.sort((a, b) => a.path.length - b.path.length));
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)).length;
969
- return { screens: documentable.length, read, pathsOnly: read === 0 };
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {