@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.
@@ -976,88 +976,57 @@ async function takeInventory(page, regions) {
976
976
  };
977
977
  }
978
978
 
979
- // core/capture/determinism.ts
980
- import { createHash } from "node:crypto";
981
- function freezeScript(pinnedClock, options = {}) {
982
- const fixed = Date.parse(pinnedClock);
983
- if (Number.isNaN(fixed)) {
984
- throw new Error(`freezeScript needs a parsable instant, got ${JSON.stringify(pinnedClock)}`);
985
- }
986
- const seed = options.seed ?? 2654435769;
987
- return `(() => {
988
- const FIXED = ${fixed};
989
- const RealDate = Date;
990
-
991
- class FrozenDate extends RealDate {
992
- constructor(...args) {
993
- // Only the no-argument form is the clock. \`new Date(x)\` is arithmetic
994
- // on a value the page already had, and rewriting it would corrupt real
995
- // data rather than stabilise a render.
996
- if (args.length === 0) super(FIXED);
997
- else super(...args);
998
- }
999
- static now() { return FIXED; }
1000
- }
1001
-
1002
- // \`parse\` and \`UTC\` come along by static inheritance and must not be
1003
- // touched: they are pure functions of their arguments already.
1004
- FrozenDate.prototype.constructor = FrozenDate;
1005
- globalThis.Date = FrozenDate;
1006
-
1007
- let state = ${seed} >>> 0;
1008
- // mulberry32: small, fast, and good enough that a seeded shuffle still looks
1009
- // shuffled. Cryptographic quality is the opposite of what is wanted here.
1010
- globalThis.Math.random = () => {
1011
- state = (state + 0x6d2b79f5) >>> 0;
1012
- let t = state;
1013
- t = Math.imul(t ^ (t >>> 15), t | 1);
1014
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
1015
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
1016
- };
1017
-
1018
- if (globalThis.performance) {
1019
- let ticks = 0;
1020
- // Monotonic and deterministic. Frozen would deadlock anything that waits
1021
- // for time to pass.
1022
- globalThis.performance.now = () => (ticks += 16);
1023
- }
1024
-
1025
- if (globalThis.crypto && globalThis.crypto.randomUUID) {
1026
- let issued = 0;
1027
- globalThis.crypto.randomUUID = () => {
1028
- issued += 1;
1029
- const n = issued.toString(16).padStart(12, "0");
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
- // core/discovery/aria-facts.ts
1116
- var LANDMARK = {
1117
- main: "main",
1118
- navigation: "nav",
1119
- banner: "header",
1120
- contentinfo: "footer",
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
- return out;
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 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
+ };
1246
1104
  }
1247
- function controlState(node) {
1248
- const state = {};
1249
- for (const [name, value] of Object.entries(node.attributes)) {
1250
- if (STATE.has(name)) state[name] = value;
1251
- }
1252
- 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
+ };
1253
1114
  }
1254
- function unescape(value) {
1255
- return value.replace(/\\(.)/g, "$1");
1115
+ function capped(values, limit) {
1116
+ return [...new Set(values)].slice(0, limit);
1256
1117
  }
1257
- function trim(value, limit) {
1258
- const collapsed = value.replace(/\s+/g, " ").trim();
1259
- 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;
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, controlState2(element));
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
- ...controlState2(element)
1204
+ ...controlState(element)
1336
1205
  });
1337
1206
  }
1338
1207
  continue;
1339
1208
  }
1340
1209
  if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
1341
- const said = trim2(text(inner), LIMITS.sentence);
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 = trim2(text(inner).replace(/\s+([.,;:!])/g, "$1"), LIMITS.sentence);
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 = attributesOf2(match[3] ?? "");
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 attributesOf2(raw) {
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 controlState2(element) {
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 trim2(explicit.replace(/\s+/g, " ").trim(), LIMITS.label);
1509
- 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);
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: trim2(label.replace(/\s+/g, " "), LIMITS.label), kind };
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 trim2(value, limit) {
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)).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.10",
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": {