@olomonhelpdesk2/helpdesk 0.1.5 → 0.1.10
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 +158 -47
- package/helpdesk-seed.mjs +31 -10
- package/helpdesk.mjs +199 -19
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -51,6 +51,18 @@ function unusablePath(path3) {
|
|
|
51
51
|
if (/\[|\]/.test(path3)) return "the path still holds a dynamic segment rather than a record";
|
|
52
52
|
return void 0;
|
|
53
53
|
}
|
|
54
|
+
var PLAIN_QUERY = /^[\w.~%:+,-]+=[\w.~%:+,-]*(?:&[\w.~%:+,-]+=[\w.~%:+,-]*)*$/;
|
|
55
|
+
function queryFor(route, named) {
|
|
56
|
+
const mark = named.indexOf("?");
|
|
57
|
+
const path3 = mark < 0 ? named : named.slice(0, mark);
|
|
58
|
+
const query = mark < 0 ? "" : named.slice(mark + 1);
|
|
59
|
+
if (!query) return { because: "it has no dynamic segment, so the only thing to name for it is the query it opens with" };
|
|
60
|
+
if (path3 !== route) return { because: `${named} is not ${route} with a query` };
|
|
61
|
+
const unusable = unusablePath(route);
|
|
62
|
+
if (unusable) return { because: unusable };
|
|
63
|
+
if (named.length > MAX_PATH || !PLAIN_QUERY.test(query)) return { because: "the query is not a plain list of names and values" };
|
|
64
|
+
return { visit: named };
|
|
65
|
+
}
|
|
54
66
|
function readExampleRoutes(raw) {
|
|
55
67
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
56
68
|
return { ok: false, because: "the file is not a JSON object" };
|
|
@@ -68,14 +80,20 @@ function readExampleRoutes(raw) {
|
|
|
68
80
|
const paths = /* @__PURE__ */ new Map();
|
|
69
81
|
const refused = [];
|
|
70
82
|
for (const [route, value] of entries) {
|
|
71
|
-
if (!route.startsWith("/")
|
|
72
|
-
refused.push({ route, because: "it is not a route
|
|
83
|
+
if (!route.startsWith("/")) {
|
|
84
|
+
refused.push({ route, because: "it is not a route" });
|
|
73
85
|
continue;
|
|
74
86
|
}
|
|
75
87
|
if (typeof value !== "string") {
|
|
76
88
|
refused.push({ route, because: "its example is not a path" });
|
|
77
89
|
continue;
|
|
78
90
|
}
|
|
91
|
+
if (dynamicSegments(route).length === 0) {
|
|
92
|
+
const opened = queryFor(route, value.trim());
|
|
93
|
+
if ("because" in opened) refused.push({ route, because: opened.because });
|
|
94
|
+
else paths.set(route, opened.visit);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
79
97
|
const path3 = value.trim();
|
|
80
98
|
const unusable = unusablePath(path3);
|
|
81
99
|
if (unusable) {
|
|
@@ -92,8 +110,8 @@ function readExampleRoutes(raw) {
|
|
|
92
110
|
}
|
|
93
111
|
function resolveVisit(route, examples) {
|
|
94
112
|
const needs = dynamicSegments(route);
|
|
95
|
-
if (needs.length === 0) return { visit: route };
|
|
96
113
|
const path3 = examples.paths.get(route);
|
|
114
|
+
if (needs.length === 0) return { visit: path3 ?? route };
|
|
97
115
|
return path3 ? { visit: path3 } : { needs };
|
|
98
116
|
}
|
|
99
117
|
function describeMissingExample(route, needs) {
|
|
@@ -136,7 +154,20 @@ var CAPTURE_CONTRACT = {
|
|
|
136
154
|
/**
|
|
137
155
|
* Injected before every shot. Necessary, not sufficient — see above.
|
|
138
156
|
*/
|
|
139
|
-
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}"
|
|
140
171
|
};
|
|
141
172
|
var VISUAL_STABILITY = {
|
|
142
173
|
/** Gap between comparison frames. Short enough to be cheap, long enough to differ. */
|
|
@@ -165,7 +196,7 @@ function actionFor(outcome) {
|
|
|
165
196
|
return void 0;
|
|
166
197
|
case "not-found": {
|
|
167
198
|
if ((!outcome.missingEndpoint || outcome.missingEndpoint === outcome.route) && !outcome.route.includes("[")) {
|
|
168
|
-
return `${outcome.route} answered Not Found here, and its address names no record to create. If it is behind a feature flag, turn the flag on where the run happens
|
|
199
|
+
return `${outcome.route} answered Not Found here, and its address names no record to create. If it is behind a feature flag, turn the flag on where the run happens. If it opens only from a record, as a form started from one does, name the address it opens at in the file the seed command writes at ${CI_EXAMPLES_OUT_ENV}. If it is gone, it will drop out of the plan.`;
|
|
169
200
|
}
|
|
170
201
|
const asked = outcome.missingEndpoint ?? outcome.route;
|
|
171
202
|
const noun = subjectOf(fillsPattern(outcome.route, asked) ? outcome.route : asked);
|
|
@@ -340,40 +371,40 @@ function preflight(facts) {
|
|
|
340
371
|
name: "target",
|
|
341
372
|
ok: true,
|
|
342
373
|
blocking: false,
|
|
343
|
-
detail: `${facts.baseUrl}, reading from ${facts.replaying}
|
|
374
|
+
detail: `${facts.baseUrl}, reading from ${facts.replaying}. No live backend is reachable from this run, so nothing on these screens can be a real person's`
|
|
344
375
|
});
|
|
345
376
|
} else if (facts.session && live && !facts.productionGranted) {
|
|
346
377
|
checks.push({
|
|
347
378
|
name: "target",
|
|
348
379
|
ok: false,
|
|
349
380
|
blocking: true,
|
|
350
|
-
detail: `${facts.baseUrl} does not look like a demo or staging address, and this run has a session. A help article shows the typical state of a screen, not somebody's data
|
|
381
|
+
detail: `${facts.baseUrl} does not look like a demo or staging address, and this run has a session. A help article shows the typical state of a screen, not somebody's data. Point this at a demo tenant, or pass --allow-production if that address really is the seeded one.`
|
|
351
382
|
});
|
|
352
383
|
} else {
|
|
353
384
|
checks.push({
|
|
354
385
|
name: "target",
|
|
355
386
|
ok: true,
|
|
356
387
|
blocking: false,
|
|
357
|
-
detail: facts.session ? `${facts.baseUrl}, signed in${live ? "
|
|
388
|
+
detail: facts.session ? `${facts.baseUrl}, signed in${live ? ", accepted as seeded by --allow-production" : " as a demo tenant"}` : `${facts.baseUrl}, signed in as nobody`
|
|
358
389
|
});
|
|
359
390
|
}
|
|
360
391
|
checks.push({
|
|
361
392
|
name: "browser",
|
|
362
393
|
ok: Boolean(facts.browser),
|
|
363
394
|
blocking: false,
|
|
364
|
-
detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified
|
|
395
|
+
detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified. A browser upgrade will look like the product changing"
|
|
365
396
|
});
|
|
366
397
|
checks.push({
|
|
367
398
|
name: "clock",
|
|
368
399
|
ok: facts.clockPinned,
|
|
369
400
|
blocking: false,
|
|
370
|
-
detail: facts.clockPinned ? "pinned, with randomness and generated ids seeded from it" : "running live (--live-clock)
|
|
401
|
+
detail: facts.clockPinned ? "pinned, with randomness and generated ids seeded from it" : "running live (--live-clock). Screens showing a date will differ every run, so nothing here can tell you whether they changed"
|
|
371
402
|
});
|
|
372
403
|
checks.push({
|
|
373
404
|
name: "locale",
|
|
374
405
|
ok: true,
|
|
375
406
|
blocking: false,
|
|
376
|
-
detail: `${facts.locale}, ${facts.timezone}
|
|
407
|
+
detail: `${facts.locale}, ${facts.timezone}, set explicitly rather than inherited from this machine`
|
|
377
408
|
});
|
|
378
409
|
checks.push({
|
|
379
410
|
name: "isolation",
|
|
@@ -398,7 +429,7 @@ function preflight(facts) {
|
|
|
398
429
|
name: "rebaseline",
|
|
399
430
|
ok: true,
|
|
400
431
|
blocking: false,
|
|
401
|
-
detail: `replacing every stored frame
|
|
432
|
+
detail: `replacing every stored frame: "${facts.rebaselineReason}"`
|
|
402
433
|
});
|
|
403
434
|
}
|
|
404
435
|
const blocked = checks.filter((check) => check.blocking && !check.ok);
|
|
@@ -425,11 +456,30 @@ function attestation(run) {
|
|
|
425
456
|
].filter(Boolean).join("\n");
|
|
426
457
|
}
|
|
427
458
|
|
|
459
|
+
// core/capture/run-record.ts
|
|
460
|
+
function runIdentityFromEnv(env) {
|
|
461
|
+
if (env["GITHUB_ACTIONS"] !== "true" || !env["GITHUB_RUN_NUMBER"]) return void 0;
|
|
462
|
+
const said = { number: env["GITHUB_RUN_NUMBER"] };
|
|
463
|
+
for (const [key, name] of [
|
|
464
|
+
["id", "GITHUB_RUN_ID"],
|
|
465
|
+
["attempt", "GITHUB_RUN_ATTEMPT"],
|
|
466
|
+
["repository", "GITHUB_REPOSITORY"],
|
|
467
|
+
["server", "GITHUB_SERVER_URL"]
|
|
468
|
+
]) {
|
|
469
|
+
const value = env[name];
|
|
470
|
+
if (value) said[key] = value;
|
|
471
|
+
}
|
|
472
|
+
return said;
|
|
473
|
+
}
|
|
474
|
+
|
|
428
475
|
// core/capture/run-verdict.ts
|
|
476
|
+
function runCounts(run) {
|
|
477
|
+
return `${run.captured} photographed, ${run.unchanged} unchanged, ${run.skipped} skipped, ${run.refused} refused, ${run.failed} failed`;
|
|
478
|
+
}
|
|
429
479
|
function runVerdict(run) {
|
|
430
480
|
const delivered = run.captured + run.unchanged;
|
|
431
481
|
const missed = run.skipped + run.refused + run.failed;
|
|
432
|
-
const counts =
|
|
482
|
+
const counts = runCounts(run);
|
|
433
483
|
if (run.screens > 0 && delivered === 0) {
|
|
434
484
|
return {
|
|
435
485
|
exit: 1,
|
|
@@ -580,6 +630,8 @@ var ALREADY_SAFE = [
|
|
|
580
630
|
function alreadySafe(kind, value) {
|
|
581
631
|
return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
|
|
582
632
|
}
|
|
633
|
+
var BIRTH_LABEL = /(?:\bdate[\s_-]*of[\s_-]*birth|\bbirth[\s_-]*(?:date|day)|\bdob\b|\bd\.o\.b\b|\bborn\b)/i;
|
|
634
|
+
var LABEL_REACH = 48;
|
|
583
635
|
var PATTERNS = [
|
|
584
636
|
{
|
|
585
637
|
kind: "email",
|
|
@@ -629,8 +681,9 @@ var PATTERNS = [
|
|
|
629
681
|
{
|
|
630
682
|
kind: "date-of-birth",
|
|
631
683
|
regex: /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g,
|
|
632
|
-
because: "a
|
|
633
|
-
replace: () => "1970-01-01"
|
|
684
|
+
because: "a date labeled as a birth date, which identifies somebody",
|
|
685
|
+
replace: () => "1970-01-01",
|
|
686
|
+
labelled: BIRTH_LABEL
|
|
634
687
|
},
|
|
635
688
|
{
|
|
636
689
|
kind: "passport",
|
|
@@ -651,8 +704,13 @@ function scrub(text2, options = {}) {
|
|
|
651
704
|
let output = text2;
|
|
652
705
|
for (const pattern of PATTERNS) {
|
|
653
706
|
const seen = /* @__PURE__ */ new Map();
|
|
654
|
-
output = output.replace(pattern.regex, (
|
|
707
|
+
output = output.replace(pattern.regex, (...args) => {
|
|
708
|
+
const match = args[0];
|
|
709
|
+
const named = typeof args[args.length - 1] === "object";
|
|
710
|
+
const offset = args[args.length - (named ? 3 : 2)];
|
|
711
|
+
const whole = args[args.length - (named ? 2 : 1)];
|
|
655
712
|
if (alreadySafe(pattern.kind, match)) return match;
|
|
713
|
+
if (pattern.labelled && !pattern.labelled.test(whole.slice(Math.max(0, offset - LABEL_REACH), offset))) return match;
|
|
656
714
|
const already = seen.get(match);
|
|
657
715
|
if (already) return already;
|
|
658
716
|
const replacement = pattern.replace(match, seen.size);
|
|
@@ -669,7 +727,7 @@ function scrub(text2, options = {}) {
|
|
|
669
727
|
found: brand,
|
|
670
728
|
replacement: brand,
|
|
671
729
|
where,
|
|
672
|
-
because: "a real institution in a published screenshot implies a partnership
|
|
730
|
+
because: "a real institution in a published screenshot implies a partnership: a legal review item, not a design nit"
|
|
673
731
|
});
|
|
674
732
|
}
|
|
675
733
|
}
|
|
@@ -697,7 +755,7 @@ function gateFrame(pageText, options = {}) {
|
|
|
697
755
|
return {
|
|
698
756
|
safe: false,
|
|
699
757
|
findings,
|
|
700
|
-
reason: onlyBrands ? `Not photographed${where}: the screen names ${named.join(", ")}. A real company in a published screenshot implies a partnership, which is a legal review item rather than a design nit
|
|
758
|
+
reason: onlyBrands ? `Not photographed${where}: the screen names ${named.join(", ")}. A real company in a published screenshot implies a partnership, which is a legal review item rather than a design nit, so somebody has to say whether it belongs there. If these names are on this screen on purpose, list them on the capture approval and this screen is photographed as it is.` : `Not photographed${where}: the screen showed ${kinds.join(", ")} that looks real. A screenshot cannot be redacted afterwards, so it is refused rather than cleaned. Point the capture at a demo tenant, or fix the seed that produced this.`
|
|
701
759
|
};
|
|
702
760
|
}
|
|
703
761
|
|
|
@@ -1037,11 +1095,22 @@ var LIMITS = {
|
|
|
1037
1095
|
fields: 24,
|
|
1038
1096
|
notices: 20,
|
|
1039
1097
|
warnings: 12,
|
|
1098
|
+
disabled: 24,
|
|
1099
|
+
empty: 12,
|
|
1100
|
+
gatedBy: 8,
|
|
1040
1101
|
choices: 12,
|
|
1041
1102
|
/** Longer than this is a paragraph of copy, not a label. */
|
|
1042
1103
|
label: 120,
|
|
1043
1104
|
sentence: 300
|
|
1044
1105
|
};
|
|
1106
|
+
var EMPTY_STATE = /^(?:no\s+(?!(?:longer|need|problem|worries|matter|one|way|thanks)\b)[a-z]|nothing\b|none\s|you (?:have|haven't|don't have|do not have)\s+(?:no|any|not)\b|there(?:'s| (?:are|is|were))\s+(?:no|nothing)\b|there aren't any\b|(?:you|we) haven't\b.*\byet\b)/i;
|
|
1107
|
+
function isEmptyState(text2) {
|
|
1108
|
+
const sentence = text2.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
|
|
1109
|
+
if (!sentence || sentence.length > LIMITS.sentence || sentence.includes("?")) return false;
|
|
1110
|
+
if (/\b(?:until|unless)\b/i.test(sentence)) return false;
|
|
1111
|
+
if (sentence.split(" ").length < 2) return false;
|
|
1112
|
+
return EMPTY_STATE.test(sentence);
|
|
1113
|
+
}
|
|
1045
1114
|
|
|
1046
1115
|
// core/discovery/aria-facts.ts
|
|
1047
1116
|
var LANDMARK = {
|
|
@@ -1092,6 +1161,12 @@ function readAriaFactRecords(snapshot, options = {}) {
|
|
|
1092
1161
|
const state = controlState(node);
|
|
1093
1162
|
if (node.role === "heading") {
|
|
1094
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);
|
|
1095
1170
|
continue;
|
|
1096
1171
|
}
|
|
1097
1172
|
if (node.role === "link") {
|
|
@@ -1115,7 +1190,9 @@ function readAriaFactRecords(snapshot, options = {}) {
|
|
|
1115
1190
|
continue;
|
|
1116
1191
|
}
|
|
1117
1192
|
if (NOTICE.has(node.role)) {
|
|
1118
|
-
|
|
1193
|
+
const said = trim(node.text || node.name, LIMITS.sentence);
|
|
1194
|
+
add("notice", said, node);
|
|
1195
|
+
if (isEmptyState(said)) add("empty", said, node);
|
|
1119
1196
|
}
|
|
1120
1197
|
}
|
|
1121
1198
|
return facts;
|
|
@@ -1152,7 +1229,7 @@ function parse(snapshot) {
|
|
|
1152
1229
|
if (node.name || node.text) parent.choices.push(node.name || node.text);
|
|
1153
1230
|
continue;
|
|
1154
1231
|
}
|
|
1155
|
-
|
|
1232
|
+
out.push(node);
|
|
1156
1233
|
stack.push({ depth: depth2, node });
|
|
1157
1234
|
}
|
|
1158
1235
|
return out;
|
|
@@ -1235,6 +1312,7 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1235
1312
|
const label = accessibleName(element);
|
|
1236
1313
|
if (HEADING.test(tag) || role === "heading") {
|
|
1237
1314
|
add("heading", label, landmark);
|
|
1315
|
+
if (isEmptyState(label)) add("empty", label, landmark);
|
|
1238
1316
|
continue;
|
|
1239
1317
|
}
|
|
1240
1318
|
if (tag === "button" || PRESSABLE.has(role) || submitLike(element)) {
|
|
@@ -1260,11 +1338,20 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1260
1338
|
continue;
|
|
1261
1339
|
}
|
|
1262
1340
|
if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
|
|
1263
|
-
|
|
1341
|
+
const said = trim2(text(inner), LIMITS.sentence);
|
|
1342
|
+
add("notice", said, landmark);
|
|
1343
|
+
if (isEmptyState(said)) add("empty", said, landmark);
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
if (TEXT_HOLDER.has(tag) && !BLOCK_INSIDE.test(inner)) {
|
|
1347
|
+
const said = trim2(text(inner).replace(/\s+([.,;:!])/g, "$1"), LIMITS.sentence);
|
|
1348
|
+
if (isEmptyState(said)) add("empty", said, landmark);
|
|
1264
1349
|
}
|
|
1265
1350
|
}
|
|
1266
1351
|
return facts;
|
|
1267
1352
|
}
|
|
1353
|
+
var TEXT_HOLDER = /* @__PURE__ */ new Set(["p", "span", "small", "td", "li", "div"]);
|
|
1354
|
+
var BLOCK_INSIDE = /<(?!\/?(?:strong|em|b|i|a|code|br|span|small)\b)[a-z]/i;
|
|
1268
1355
|
var OVERLAY_ROLE = {
|
|
1269
1356
|
dialog: "dialog",
|
|
1270
1357
|
alertdialog: "dialog",
|
|
@@ -1616,7 +1703,7 @@ function requireDeployment(producer, input) {
|
|
|
1616
1703
|
const value = deployment.value.trim();
|
|
1617
1704
|
if (BROWSER_SHAPED.test(value) || value === input.captureEnvironment?.trim()) {
|
|
1618
1705
|
throw new UnstampedObservation(
|
|
1619
|
-
`A ${producer} observation gives "${value}" as the deployment that answered it. That names the browser that did the reading, which belongs in captureEnvironment. What is wanted here is what the application was running
|
|
1706
|
+
`A ${producer} observation gives "${value}" as the deployment that answered it. That names the browser that did the reading, which belongs in captureEnvironment. What is wanted here is what the application was running (a deployment id, a build id, a release SHA) or unknownDeployment("why not").`
|
|
1620
1707
|
);
|
|
1621
1708
|
}
|
|
1622
1709
|
}
|
|
@@ -1665,7 +1752,7 @@ async function captureOne(options, shot, build) {
|
|
|
1665
1752
|
status: "skipped",
|
|
1666
1753
|
result: "bounced",
|
|
1667
1754
|
needsAccount: true,
|
|
1668
|
-
reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant
|
|
1755
|
+
reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant, never a real account"
|
|
1669
1756
|
},
|
|
1670
1757
|
observed: { route: shot.route, result: "bounced" }
|
|
1671
1758
|
};
|
|
@@ -1853,7 +1940,7 @@ async function captureOne(options, shot, build) {
|
|
|
1853
1940
|
sha256,
|
|
1854
1941
|
settled: frame.settled,
|
|
1855
1942
|
...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
|
|
1856
|
-
reason: "photographed, but this screen never stopped moving
|
|
1943
|
+
reason: "photographed, but this screen never stopped moving. Its hash will differ every run, so drift on it means nothing"
|
|
1857
1944
|
}
|
|
1858
1945
|
} : {
|
|
1859
1946
|
shotId: shot.id,
|
|
@@ -2003,6 +2090,8 @@ async function playwrightBrowser(options = {}) {
|
|
|
2003
2090
|
const response = await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
|
|
2004
2091
|
status = response?.status();
|
|
2005
2092
|
await page.addStyleTag({ content: CAPTURE_CONTRACT.motionOff }).catch(() => void 0);
|
|
2093
|
+
await page.addStyleTag({ content: CAPTURE_CONTRACT.hideOff }).catch(() => void 0);
|
|
2094
|
+
await page.evaluate((name) => document.documentElement.setAttribute(name, ""), CAPTURE_CONTRACT.marker).catch(() => void 0);
|
|
2006
2095
|
await page.evaluate(() => document.fonts?.ready).catch(() => void 0);
|
|
2007
2096
|
},
|
|
2008
2097
|
async text() {
|
|
@@ -2546,7 +2635,7 @@ async function stageAgainstRecording(options) {
|
|
|
2546
2635
|
died = code ?? 1;
|
|
2547
2636
|
});
|
|
2548
2637
|
} else {
|
|
2549
|
-
options.onLog?.(`The recording is at ${replay.url}
|
|
2638
|
+
options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
|
|
2550
2639
|
}
|
|
2551
2640
|
const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), options.onLog, () => died !== void 0);
|
|
2552
2641
|
if (app) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
@@ -2557,7 +2646,7 @@ async function stageAgainstRecording(options) {
|
|
|
2557
2646
|
throw new Error(
|
|
2558
2647
|
`The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
|
|
2559
2648
|
|
|
2560
|
-
Something else is already answering on ${options.appUrl}. That is almost always a server left behind by an earlier run
|
|
2649
|
+
Something else is already answering on ${options.appUrl}. That is almost always a server left behind by an earlier run. This will not photograph it, because it is not the product this run started.` : "")
|
|
2561
2650
|
);
|
|
2562
2651
|
}
|
|
2563
2652
|
if (!up) {
|
|
@@ -2748,7 +2837,7 @@ function gapReport(gaps) {
|
|
|
2748
2837
|
""
|
|
2749
2838
|
];
|
|
2750
2839
|
for (const gap of gaps) {
|
|
2751
|
-
const broke = gap.broke.length ? `
|
|
2840
|
+
const broke = gap.broke.length ? ` (breaks ${gap.broke.join(", ")})` : "";
|
|
2752
2841
|
lines.push(` ${gap.method} ${gap.path}${broke}`);
|
|
2753
2842
|
if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
|
|
2754
2843
|
}
|
|
@@ -2795,7 +2884,7 @@ function coverageReport(found, total) {
|
|
|
2795
2884
|
...found.missing.map((route) => ` ${route}`),
|
|
2796
2885
|
"",
|
|
2797
2886
|
"They will not be photographed. Record again and visit them, or they stay out of the",
|
|
2798
|
-
"plan
|
|
2887
|
+
"plan: an empty screen photographed from a gap in the recording looks exactly like an",
|
|
2799
2888
|
"empty screen belonging to a customer with nothing, which is how a help article ends up",
|
|
2800
2889
|
"showing a product with no content in it."
|
|
2801
2890
|
];
|
|
@@ -2991,7 +3080,7 @@ function score(route, signals) {
|
|
|
2991
3080
|
}
|
|
2992
3081
|
if (hasSegment(route.path, MONEY_OR_ACCESS)) {
|
|
2993
3082
|
total += 18;
|
|
2994
|
-
because.push("money or access
|
|
3083
|
+
because.push("money or access: asked about out of proportion to its traffic");
|
|
2995
3084
|
}
|
|
2996
3085
|
if (hasSegment(route.path, ENTRY_POINT)) {
|
|
2997
3086
|
total += 16;
|
|
@@ -3087,6 +3176,16 @@ function buildArticlePlan(options) {
|
|
|
3087
3176
|
function repositoryFiles(cwd) {
|
|
3088
3177
|
return execFileSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).split("\n").filter(Boolean);
|
|
3089
3178
|
}
|
|
3179
|
+
var WORKFLOW_ACTIONS = {
|
|
3180
|
+
checkout: "actions/checkout@v7",
|
|
3181
|
+
setupNode: "actions/setup-node@v7"
|
|
3182
|
+
};
|
|
3183
|
+
var SETUP_STEPS = ` - uses: ${WORKFLOW_ACTIONS.checkout}
|
|
3184
|
+
- uses: ${WORKFLOW_ACTIONS.setupNode}
|
|
3185
|
+
with:
|
|
3186
|
+
node-version: "22"
|
|
3187
|
+
package-manager-cache: false
|
|
3188
|
+
`;
|
|
3090
3189
|
|
|
3091
3190
|
// core/discovery/storyboard.ts
|
|
3092
3191
|
function shotIdFor(route) {
|
|
@@ -3109,7 +3208,8 @@ function buildStoryboard(options) {
|
|
|
3109
3208
|
const beneath = options.routes.filter(
|
|
3110
3209
|
(route) => !covered.has(route.path) && !route.skipped && [...covered].some((base) => base !== "/" && route.path.startsWith(`${base}/`))
|
|
3111
3210
|
);
|
|
3112
|
-
const
|
|
3211
|
+
const own = [...covered].map((path3) => byPath.get(path3)).filter((route) => Boolean(route) && !route.skipped);
|
|
3212
|
+
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));
|
|
3113
3213
|
const limit = options.maxSteps ?? 5;
|
|
3114
3214
|
const steps = ordered.slice(0, limit).map((route, index) => ({
|
|
3115
3215
|
route: route.path,
|
|
@@ -3322,6 +3422,10 @@ Approve one on the plan page, then run this again.`
|
|
|
3322
3422
|
`
|
|
3323
3423
|
);
|
|
3324
3424
|
const runId = local ? void 0 : await openRun(parsed.to, headers, shots.length);
|
|
3425
|
+
let told = Promise.resolve();
|
|
3426
|
+
const tell = (body) => {
|
|
3427
|
+
told = told.then(() => tellHelpDesk(parsed.to, headers, body));
|
|
3428
|
+
};
|
|
3325
3429
|
try {
|
|
3326
3430
|
const report = await runCapture({
|
|
3327
3431
|
baseUrl,
|
|
@@ -3354,13 +3458,16 @@ Approve one on the plan page, then run this again.`
|
|
|
3354
3458
|
role: parsed.session ? unknownRole() : noRole("this run was signed out"),
|
|
3355
3459
|
onProgress: (outcome, index, total) => {
|
|
3356
3460
|
const mark = { captured: "\u2713", unchanged: "=", skipped: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
|
|
3357
|
-
console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ?
|
|
3461
|
+
console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? `: ${outcome.reason}` : ""}`);
|
|
3358
3462
|
if (runId) {
|
|
3359
|
-
|
|
3463
|
+
tell({
|
|
3360
3464
|
action: "progress",
|
|
3361
3465
|
jobId: runId,
|
|
3362
3466
|
item: outcome.route,
|
|
3467
|
+
// Kept for a help desk that predates `outcome`, which folds the
|
|
3468
|
+
// five into three and cannot tell a refusal from a skip.
|
|
3363
3469
|
status: outcome.status === "captured" || outcome.status === "unchanged" ? "ok" : outcome.status === "failed" ? "failed" : "skipped",
|
|
3470
|
+
outcome: outcome.status,
|
|
3364
3471
|
...outcome.reason ? { note: outcome.reason } : {},
|
|
3365
3472
|
// So Setup can ask about them, once each. See `core/capture/unclassified.ts`.
|
|
3366
3473
|
...outcome.unaccounted?.length ? { unaccounted: outcome.unaccounted } : {}
|
|
@@ -3413,10 +3520,8 @@ Approve one on the plan page, then run this again.`
|
|
|
3413
3520
|
return { ok: false, error: body.error ?? `the help desk answered ${response.status}` };
|
|
3414
3521
|
}
|
|
3415
3522
|
});
|
|
3416
|
-
console.log(
|
|
3417
|
-
|
|
3418
|
-
${report.captured} photographed, ${report.unchanged} unchanged, ${report.skipped} skipped, ${report.refused} refused, ${report.failed} failed.`
|
|
3419
|
-
);
|
|
3523
|
+
console.log(`
|
|
3524
|
+
${runCounts(report)}.`);
|
|
3420
3525
|
const unclassified = unclassifiedLines(
|
|
3421
3526
|
groupUnclassified(
|
|
3422
3527
|
report.outcomes.flatMap((outcome) => outcome.unaccounted?.length ? [{ route: outcome.route, values: outcome.unaccounted }] : [])
|
|
@@ -3481,7 +3586,7 @@ whether they changed. Usually a spinner that never resolves or a looping animati
|
|
|
3481
3586
|
const gated = report.outcomes.filter((outcome) => outcome.needsAccount);
|
|
3482
3587
|
if (gated.length) {
|
|
3483
3588
|
console.log(
|
|
3484
|
-
stage ? "\nSome screens still showed a sign-in. A recording answers the backend, but it cannot\ngive the product a session
|
|
3589
|
+
stage ? "\nSome screens still showed a sign-in. A recording answers the backend, but it cannot\ngive the product a session: record the sign-in itself, or save one against the replayed\nproduct and pass it with --session." : parsed.session ? "\nSome screens still showed a sign-in. The saved session may have expired. Run `sign-in` again." : `
|
|
3485
3590
|
Some screens are behind your sign-in. To photograph them, save a session against a
|
|
3486
3591
|
DEMO tenant and pass it back:
|
|
3487
3592
|
|
|
@@ -3508,27 +3613,32 @@ ${attestation({
|
|
|
3508
3613
|
console.log(`
|
|
3509
3614
|
${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
|
|
3510
3615
|
}
|
|
3616
|
+
const counts = {
|
|
3617
|
+
screens: shots.length,
|
|
3618
|
+
captured: report.captured,
|
|
3619
|
+
unchanged: report.unchanged,
|
|
3620
|
+
skipped: report.skipped,
|
|
3621
|
+
refused: report.refused,
|
|
3622
|
+
failed: report.failed
|
|
3623
|
+
};
|
|
3511
3624
|
if (runId) {
|
|
3625
|
+
await told;
|
|
3512
3626
|
await tellHelpDesk(parsed.to, headers, {
|
|
3513
3627
|
action: "finish",
|
|
3514
3628
|
jobId: runId,
|
|
3629
|
+
// What the verdict below prints, so Setup can say what this log says.
|
|
3630
|
+
counts,
|
|
3515
3631
|
...report.survey.length ? { survey: report.survey } : {}
|
|
3516
3632
|
});
|
|
3517
3633
|
}
|
|
3518
|
-
const verdict = runVerdict(
|
|
3519
|
-
screens: shots.length,
|
|
3520
|
-
captured: report.captured,
|
|
3521
|
-
unchanged: report.unchanged,
|
|
3522
|
-
skipped: report.skipped,
|
|
3523
|
-
refused: report.refused,
|
|
3524
|
-
failed: report.failed
|
|
3525
|
-
});
|
|
3634
|
+
const verdict = runVerdict(counts);
|
|
3526
3635
|
if (verdict.sentence) console.log(`
|
|
3527
3636
|
${verdict.sentence}`);
|
|
3528
3637
|
if (verdict.annotation && process.env["GITHUB_ACTIONS"] === "true") console.log(verdict.annotation);
|
|
3529
3638
|
return verdict.exit;
|
|
3530
3639
|
} catch (error) {
|
|
3531
3640
|
if (runId) {
|
|
3641
|
+
await told;
|
|
3532
3642
|
await tellHelpDesk(parsed.to, headers, {
|
|
3533
3643
|
action: "finish",
|
|
3534
3644
|
jobId: runId,
|
|
@@ -3543,11 +3653,12 @@ ${verdict.sentence}`);
|
|
|
3543
3653
|
}
|
|
3544
3654
|
}
|
|
3545
3655
|
async function openRun(to, headers, total) {
|
|
3656
|
+
const run = runIdentityFromEnv(process.env);
|
|
3546
3657
|
try {
|
|
3547
3658
|
const response = await fetch(`${to}/api/helpdesk/captures`, {
|
|
3548
3659
|
method: "POST",
|
|
3549
3660
|
headers: { ...headers, "content-type": "application/json" },
|
|
3550
|
-
body: JSON.stringify({ action: "start", total })
|
|
3661
|
+
body: JSON.stringify({ action: "start", total, ...run ? { run } : {} })
|
|
3551
3662
|
});
|
|
3552
3663
|
if (!response.ok) return void 0;
|
|
3553
3664
|
const body = await response.json();
|
|
@@ -3577,7 +3688,7 @@ async function signIn(argv) {
|
|
|
3577
3688
|
return 2;
|
|
3578
3689
|
}
|
|
3579
3690
|
console.log(
|
|
3580
|
-
`Opening ${baseUrl}. Sign in as your DEMO tenant
|
|
3691
|
+
`Opening ${baseUrl}. Sign in as your DEMO tenant, never as a real customer.
|
|
3581
3692
|
This window is yours: use SSO, a one-time code, whatever you normally do.
|
|
3582
3693
|
The session will be saved to ${sessionPath} and sent nowhere.
|
|
3583
3694
|
`
|
package/helpdesk-seed.mjs
CHANGED
|
@@ -219,7 +219,7 @@ function gapReport(gaps2) {
|
|
|
219
219
|
""
|
|
220
220
|
];
|
|
221
221
|
for (const gap of gaps2) {
|
|
222
|
-
const broke = gap.broke.length ? `
|
|
222
|
+
const broke = gap.broke.length ? ` (breaks ${gap.broke.join(", ")})` : "";
|
|
223
223
|
lines.push(` ${gap.method} ${gap.path}${broke}`);
|
|
224
224
|
if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
|
|
225
225
|
}
|
|
@@ -733,6 +733,8 @@ var ALREADY_SAFE = [
|
|
|
733
733
|
function alreadySafe(kind, value) {
|
|
734
734
|
return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
|
|
735
735
|
}
|
|
736
|
+
var BIRTH_LABEL = /(?:\bdate[\s_-]*of[\s_-]*birth|\bbirth[\s_-]*(?:date|day)|\bdob\b|\bd\.o\.b\b|\bborn\b)/i;
|
|
737
|
+
var LABEL_REACH = 48;
|
|
736
738
|
var PATTERNS = [
|
|
737
739
|
{
|
|
738
740
|
kind: "email",
|
|
@@ -782,8 +784,9 @@ var PATTERNS = [
|
|
|
782
784
|
{
|
|
783
785
|
kind: "date-of-birth",
|
|
784
786
|
regex: /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g,
|
|
785
|
-
because: "a
|
|
786
|
-
replace: () => "1970-01-01"
|
|
787
|
+
because: "a date labeled as a birth date, which identifies somebody",
|
|
788
|
+
replace: () => "1970-01-01",
|
|
789
|
+
labelled: BIRTH_LABEL
|
|
787
790
|
},
|
|
788
791
|
{
|
|
789
792
|
kind: "passport",
|
|
@@ -804,8 +807,13 @@ function scrub(text, options = {}) {
|
|
|
804
807
|
let output = text;
|
|
805
808
|
for (const pattern of PATTERNS) {
|
|
806
809
|
const seen = /* @__PURE__ */ new Map();
|
|
807
|
-
output = output.replace(pattern.regex, (
|
|
810
|
+
output = output.replace(pattern.regex, (...args) => {
|
|
811
|
+
const match = args[0];
|
|
812
|
+
const named = typeof args[args.length - 1] === "object";
|
|
813
|
+
const offset = args[args.length - (named ? 3 : 2)];
|
|
814
|
+
const whole = args[args.length - (named ? 2 : 1)];
|
|
808
815
|
if (alreadySafe(pattern.kind, match)) return match;
|
|
816
|
+
if (pattern.labelled && !pattern.labelled.test(whole.slice(Math.max(0, offset - LABEL_REACH), offset))) return match;
|
|
809
817
|
const already = seen.get(match);
|
|
810
818
|
if (already) return already;
|
|
811
819
|
const replacement = pattern.replace(match, seen.size);
|
|
@@ -822,7 +830,7 @@ function scrub(text, options = {}) {
|
|
|
822
830
|
found: brand,
|
|
823
831
|
replacement: brand,
|
|
824
832
|
where,
|
|
825
|
-
because: "a real institution in a published screenshot implies a partnership
|
|
833
|
+
because: "a real institution in a published screenshot implies a partnership: a legal review item, not a design nit"
|
|
826
834
|
});
|
|
827
835
|
}
|
|
828
836
|
}
|
|
@@ -838,7 +846,7 @@ function describeScrub(result) {
|
|
|
838
846
|
}
|
|
839
847
|
const lines = [`${result.findings.length} thing(s) found. Nothing publishes until you confirm.`, ""];
|
|
840
848
|
for (const [kind, group] of byKind) {
|
|
841
|
-
lines.push(` ${kind.toUpperCase().replace(/-/g, " ")}
|
|
849
|
+
lines.push(` ${kind.toUpperCase().replace(/-/g, " ")}: ${group[0].because}`);
|
|
842
850
|
for (const finding of group.slice(0, 5)) {
|
|
843
851
|
lines.push(
|
|
844
852
|
kind === "brand" ? ` ${finding.found} (left as-is: decide whether this frame can ship)` : ` ${finding.found} \u2192 ${finding.replacement}`
|
|
@@ -994,7 +1002,20 @@ var CAPTURE_CONTRACT = {
|
|
|
994
1002
|
/**
|
|
995
1003
|
* Injected before every shot. Necessary, not sufficient — see above.
|
|
996
1004
|
*/
|
|
997
|
-
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}"
|
|
998
1019
|
};
|
|
999
1020
|
var RECORDING_MISS_STATUS = 599;
|
|
1000
1021
|
|
|
@@ -1217,7 +1238,7 @@ async function record(args) {
|
|
|
1217
1238
|
[
|
|
1218
1239
|
`Recording everything sent to ${args.upstream}.`,
|
|
1219
1240
|
"",
|
|
1220
|
-
"Point your product's backend address at this and use it
|
|
1241
|
+
"Point your product's backend address at this and use it: sign in, open the screens",
|
|
1221
1242
|
"you want documented, and make the errors happen if you can. What you visit is what",
|
|
1222
1243
|
"the recording will be able to replay.",
|
|
1223
1244
|
"",
|
|
@@ -1289,7 +1310,7 @@ function carriedReport(carried, cassette) {
|
|
|
1289
1310
|
(field) => ` ${field.key.padEnd(width)} ${String(field.count).padStart(4)}\xD7 ${field.example}`
|
|
1290
1311
|
),
|
|
1291
1312
|
"",
|
|
1292
|
-
"That is not automatically wrong
|
|
1313
|
+
"That is not automatically wrong: an id, a status or a currency is meant to survive.",
|
|
1293
1314
|
"But this package only knows the words every product shares: people, their contact",
|
|
1294
1315
|
"details, their address, and the role each one is playing. Whatever your product holds",
|
|
1295
1316
|
"that no other product does is on the list above, and it was not touched.",
|
|
@@ -1304,7 +1325,7 @@ function carriedReport(carried, cassette) {
|
|
|
1304
1325
|
" company, note, amount",
|
|
1305
1326
|
"",
|
|
1306
1327
|
" note invents a sentence of about the same length, for anything free-text.",
|
|
1307
|
-
" amount invents a different figure of the same size, for a salary or a balance
|
|
1328
|
+
" amount invents a different figure of the same size, for a salary or a balance:",
|
|
1308
1329
|
" a number is never detectable by shape, so naming it is the only way it is caught."
|
|
1309
1330
|
] : []
|
|
1310
1331
|
];
|
package/helpdesk.mjs
CHANGED
|
@@ -188,7 +188,7 @@ function score(route, signals) {
|
|
|
188
188
|
}
|
|
189
189
|
if (hasSegment(route.path, MONEY_OR_ACCESS)) {
|
|
190
190
|
total += 18;
|
|
191
|
-
because.push("money or access
|
|
191
|
+
because.push("money or access: asked about out of proportion to its traffic");
|
|
192
192
|
}
|
|
193
193
|
if (hasSegment(route.path, ENTRY_POINT)) {
|
|
194
194
|
total += 16;
|
|
@@ -538,11 +538,22 @@ var LIMITS = {
|
|
|
538
538
|
fields: 24,
|
|
539
539
|
notices: 20,
|
|
540
540
|
warnings: 12,
|
|
541
|
+
disabled: 24,
|
|
542
|
+
empty: 12,
|
|
543
|
+
gatedBy: 8,
|
|
541
544
|
choices: 12,
|
|
542
545
|
/** Longer than this is a paragraph of copy, not a label. */
|
|
543
546
|
label: 120,
|
|
544
547
|
sentence: 300
|
|
545
548
|
};
|
|
549
|
+
var EMPTY_STATE = /^(?:no\s+(?!(?:longer|need|problem|worries|matter|one|way|thanks)\b)[a-z]|nothing\b|none\s|you (?:have|haven't|don't have|do not have)\s+(?:no|any|not)\b|there(?:'s| (?:are|is|were))\s+(?:no|nothing)\b|there aren't any\b|(?:you|we) haven't\b.*\byet\b)/i;
|
|
550
|
+
function isEmptyState(text) {
|
|
551
|
+
const sentence = text.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
|
|
552
|
+
if (!sentence || sentence.length > LIMITS.sentence || sentence.includes("?")) return false;
|
|
553
|
+
if (/\b(?:until|unless)\b/i.test(sentence)) return false;
|
|
554
|
+
if (sentence.split(" ").length < 2) return false;
|
|
555
|
+
return EMPTY_STATE.test(sentence);
|
|
556
|
+
}
|
|
546
557
|
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;
|
|
547
558
|
var DESTRUCTIVE = /\b(delete|deletes|deleting|remove|removes|revoke|revokes|erase|wipe|discard|destroy|overwrite|overwrites|reset|disconnect|unpublish|cancel subscription)\b/i;
|
|
548
559
|
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;
|
|
@@ -553,18 +564,24 @@ function readScreenFacts(files, options = {}) {
|
|
|
553
564
|
const links = [];
|
|
554
565
|
const fields = [];
|
|
555
566
|
const notices = [];
|
|
556
|
-
const
|
|
567
|
+
const disabled = [];
|
|
568
|
+
const empty = [];
|
|
557
569
|
const messageDepth = options.messageDepth ?? 2;
|
|
570
|
+
const read = files.filter((file) => !options.skip?.(file.path));
|
|
571
|
+
const gatedBy = flagsGating(read, messageDepth);
|
|
558
572
|
for (const file of files) {
|
|
559
573
|
if (options.skip?.(file.path)) continue;
|
|
560
574
|
const source = withoutComments(file.contents);
|
|
561
|
-
const before = headings.length + actions.length + links.length + fields.length + notices.length;
|
|
562
|
-
readElements(source, { headings, actions, links, fields });
|
|
563
|
-
if ((file.depth ?? 0) <= messageDepth)
|
|
564
|
-
|
|
575
|
+
const before = headings.length + actions.length + links.length + fields.length + notices.length + empty.length;
|
|
576
|
+
readElements(source, { headings, actions, links, fields, disabled, empty });
|
|
577
|
+
if ((file.depth ?? 0) <= messageDepth) {
|
|
578
|
+
readMessages(source, notices);
|
|
579
|
+
readEmptyMessages(source, empty);
|
|
580
|
+
}
|
|
581
|
+
const after = headings.length + actions.length + links.length + fields.length + notices.length + empty.length;
|
|
565
582
|
if (after > before) facts.readFrom.push(file.path);
|
|
566
583
|
}
|
|
567
|
-
return finishScreenFacts({ headings, actions, links, fields, notices, readFrom: facts.readFrom });
|
|
584
|
+
return finishScreenFacts({ headings, actions, links, fields, notices, readFrom: facts.readFrom, disabled, empty, gatedBy });
|
|
568
585
|
}
|
|
569
586
|
function finishScreenFacts(parts) {
|
|
570
587
|
const warnings = [];
|
|
@@ -581,23 +598,48 @@ function finishScreenFacts(parts) {
|
|
|
581
598
|
fields: cappedBy(parts.fields, (field) => `${field.label}|${field.kind}`, LIMITS.fields),
|
|
582
599
|
notices: capped(parts.notices, LIMITS.notices),
|
|
583
600
|
warnings: capped(warnings, LIMITS.warnings),
|
|
584
|
-
readFrom: parts.readFrom
|
|
601
|
+
readFrom: parts.readFrom,
|
|
602
|
+
...states({ disabled: parts.disabled, empty: parts.empty, gatedBy: parts.gatedBy })
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function states(parts) {
|
|
606
|
+
const disabled = capped(parts.disabled ?? [], LIMITS.disabled);
|
|
607
|
+
const empty = capped(parts.empty ?? [], LIMITS.empty);
|
|
608
|
+
const gatedBy = capped(parts.gatedBy ?? [], LIMITS.gatedBy);
|
|
609
|
+
return {
|
|
610
|
+
...disabled.length ? { disabled } : {},
|
|
611
|
+
...empty.length ? { empty } : {},
|
|
612
|
+
...gatedBy.length ? { gatedBy } : {}
|
|
585
613
|
};
|
|
586
614
|
}
|
|
587
615
|
var HEADING_TAG = /^h[1-4]$/;
|
|
588
616
|
var BUTTON_TAG = /^(button|Button|MenuItem|Tab)$/;
|
|
589
617
|
var LINK_TAG = /^(a|Link|NavLink)$/;
|
|
590
618
|
var FIELD_TAG = /^(input|Input|textarea|Textarea|select|Select|Checkbox|Radio|Switch|Toggle)$/;
|
|
619
|
+
var TEXT_TAG = /^(p|span|small|td|li|div)$/;
|
|
620
|
+
var NOT_INLINE = /<(?!\/?(?:strong|em|b|i|code|br)\b)[A-Za-z]/;
|
|
621
|
+
var EMPTY_TAG = /^(EmptyState|Empty|EmptyMessage|EmptyPanel|NoResults|ZeroState|BlankState)$/;
|
|
622
|
+
var EMPTY_PROPS = ["emptyState", "emptyText", "emptyMessage", "emptyLabel", "emptyTitle", "emptyDescription"];
|
|
623
|
+
var DISABLED_PROP = /(?:^|\s)(?:disabled|aria-disabled)(?=\s|=|\/|$)(?!\s*=\s*\{\s*false\s*\})(?!\s*=\s*["']false["'])/;
|
|
591
624
|
function readElements(source, into) {
|
|
592
625
|
for (const tag of tagsIn(source)) {
|
|
593
626
|
const attributes = readAttributes(tag.attributes);
|
|
594
627
|
if (HEADING_TAG.test(tag.name)) {
|
|
595
|
-
|
|
628
|
+
const children = childrenOf(source, tag);
|
|
629
|
+
const heading = visibleText(children);
|
|
630
|
+
push(into.headings, heading);
|
|
631
|
+
for (const said of [heading, ...chosenIn(children)]) if (isEmptyState(said)) push(into.empty, said, LIMITS.sentence);
|
|
596
632
|
continue;
|
|
597
633
|
}
|
|
598
634
|
push(into.headings, attributes["heading"]);
|
|
635
|
+
for (const prop of EMPTY_PROPS) pushWords(into.empty, attributes[prop]);
|
|
636
|
+
if (EMPTY_TAG.test(tag.name)) {
|
|
637
|
+
for (const prop of ["title", "description", "message"]) pushWords(into.empty, attributes[prop]);
|
|
638
|
+
}
|
|
599
639
|
if (BUTTON_TAG.test(tag.name)) {
|
|
600
|
-
|
|
640
|
+
const label = visibleText(childrenOf(source, tag)) || attributes["label"] || attributes["aria-label"];
|
|
641
|
+
push(into.actions, label);
|
|
642
|
+
if (DISABLED_PROP.test(tag.attributes)) push(into.disabled, label);
|
|
601
643
|
continue;
|
|
602
644
|
}
|
|
603
645
|
if (LINK_TAG.test(tag.name)) {
|
|
@@ -611,6 +653,16 @@ function readElements(source, into) {
|
|
|
611
653
|
if (FIELD_TAG.test(tag.name)) {
|
|
612
654
|
const field = fieldFrom(tag.name, attributes, source, tag);
|
|
613
655
|
if (field) into.fields.push(field);
|
|
656
|
+
if (field && DISABLED_PROP.test(tag.attributes)) push(into.disabled, field.label);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (TEXT_TAG.test(tag.name)) {
|
|
660
|
+
const children = childrenOf(source, tag);
|
|
661
|
+
if (NOT_INLINE.test(children)) continue;
|
|
662
|
+
const written = visibleText(children, LIMITS.sentence).replace(/\s+([.,;:!])/g, "$1");
|
|
663
|
+
for (const said of [written, ...chosenIn(children)]) {
|
|
664
|
+
if (isEmptyState(said)) push(into.empty, said, LIMITS.sentence);
|
|
665
|
+
}
|
|
614
666
|
}
|
|
615
667
|
}
|
|
616
668
|
for (const tag of tagsIn(source)) {
|
|
@@ -723,7 +775,7 @@ function childrenOf(source, tag) {
|
|
|
723
775
|
function escapeName(name) {
|
|
724
776
|
return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
725
777
|
}
|
|
726
|
-
function visibleText(fragment) {
|
|
778
|
+
function visibleText(fragment, limit = LIMITS.label) {
|
|
727
779
|
if (!fragment) return "";
|
|
728
780
|
let text = fragment;
|
|
729
781
|
let previous = "";
|
|
@@ -732,7 +784,26 @@ function visibleText(fragment) {
|
|
|
732
784
|
text = text.replace(/\{[^{}]*\}/g, " ");
|
|
733
785
|
}
|
|
734
786
|
text = text.replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/—/g, "\u2014").replace(/–/g, "\u2013").replace(/&(?:l|r)squo;/g, "'").replace(/&(?:l|r)dquo;/g, '"').replace(/&#\d+;/g, " ").replace(/\s+/g, " ").trim();
|
|
735
|
-
return /[A-Za-z]/.test(text) ? trim(text,
|
|
787
|
+
return /[A-Za-z]/.test(text) ? trim(text, limit) : "";
|
|
788
|
+
}
|
|
789
|
+
function chosenIn(fragment) {
|
|
790
|
+
const chosen = [];
|
|
791
|
+
let depth2 = 0;
|
|
792
|
+
let start = 0;
|
|
793
|
+
for (let index = 0; index < fragment.length; index += 1) {
|
|
794
|
+
if (fragment[index] === "{") {
|
|
795
|
+
if (depth2 === 0) start = index + 1;
|
|
796
|
+
depth2 += 1;
|
|
797
|
+
} else if (fragment[index] === "}" && depth2 > 0) {
|
|
798
|
+
depth2 -= 1;
|
|
799
|
+
if (depth2 > 0) continue;
|
|
800
|
+
for (const literal of fragment.slice(start, index).matchAll(/"([^"\\]*)"|'([^'\\]*)'|`([^`\\$]*)`/g)) {
|
|
801
|
+
const text = (literal[1] ?? literal[2] ?? literal[3] ?? "").replace(/\s+/g, " ").trim();
|
|
802
|
+
if (text) chosen.push(text);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return chosen;
|
|
736
807
|
}
|
|
737
808
|
function readAttributes(raw) {
|
|
738
809
|
const attributes = {};
|
|
@@ -752,11 +823,80 @@ function humanise(name) {
|
|
|
752
823
|
function scrubExpressions(value) {
|
|
753
824
|
return value.replace(/\$\{[^}]*\}/g, "\u2026").replace(/\s+/g, " ").trim();
|
|
754
825
|
}
|
|
755
|
-
function push(into, value) {
|
|
826
|
+
function push(into, value, limit = LIMITS.label) {
|
|
756
827
|
const text = value === void 0 ? "" : scrubExpressions(value);
|
|
757
828
|
if (!text || !/[A-Za-z]/.test(text)) return;
|
|
758
829
|
if ((text.match(/…/g)?.length ?? 0) > 1) return;
|
|
759
|
-
into.push(trim(text,
|
|
830
|
+
into.push(trim(text, limit));
|
|
831
|
+
}
|
|
832
|
+
function pushWords(into, value) {
|
|
833
|
+
if (value && value.trim().split(/\s+/).length >= 2) push(into, value, LIMITS.sentence);
|
|
834
|
+
}
|
|
835
|
+
var EMPTY_PROPERTY = /(?:^|[\s,{(])(?:emptyState|emptyText|emptyMessage|emptyLabel|emptyTitle|emptyDescription)\s*:\s*(["'`])((?:(?!\1)[\s\S]){4,}?)\1/g;
|
|
836
|
+
function readEmptyMessages(source, into) {
|
|
837
|
+
for (const match of source.matchAll(EMPTY_PROPERTY)) pushWords(into, match[2].replace(/\s+/g, " ").trim());
|
|
838
|
+
}
|
|
839
|
+
var FLAG_NAME = /\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*_(?:ENABLED|ENABLE|FLAG|FEATURE)|(?:ENABLE|FEATURE|FLAG)_[A-Z0-9]+(?:_[A-Z0-9]+)*)\b/g;
|
|
840
|
+
var FLAG_NAME_ONLY = /^[A-Za-z0-9_.:-]{1,80}$/;
|
|
841
|
+
var DECLARATION = /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=>/gm;
|
|
842
|
+
var NOT_THERE = /\b(?:notFound|redirect)\s*\(/g;
|
|
843
|
+
function flagsIn(text) {
|
|
844
|
+
return [...new Set([...text.matchAll(FLAG_NAME)].map((match) => match[1]))];
|
|
845
|
+
}
|
|
846
|
+
function flagsGating(files, messageDepth) {
|
|
847
|
+
const named = /* @__PURE__ */ new Map();
|
|
848
|
+
for (const file of files) {
|
|
849
|
+
if ((file.depth ?? 0) > messageDepth) continue;
|
|
850
|
+
const source = withoutComments(file.contents);
|
|
851
|
+
const declarations = [...source.matchAll(DECLARATION)];
|
|
852
|
+
declarations.forEach((declaration, index) => {
|
|
853
|
+
const name = declaration[1] ?? declaration[2];
|
|
854
|
+
const body = source.slice(declaration.index, declarations[index + 1]?.index ?? source.length);
|
|
855
|
+
const flags = flagsIn(body);
|
|
856
|
+
if (flags.length) named.set(name, /* @__PURE__ */ new Set([...named.get(name) ?? [], ...flags]));
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
const gating = /* @__PURE__ */ new Set();
|
|
860
|
+
for (const file of files) {
|
|
861
|
+
if ((file.depth ?? 0) > 0) continue;
|
|
862
|
+
const source = withoutComments(file.contents);
|
|
863
|
+
for (const call of source.matchAll(NOT_THERE)) {
|
|
864
|
+
const condition = guardOf(source, call.index);
|
|
865
|
+
if (condition === void 0) continue;
|
|
866
|
+
for (const flag of flagsIn(condition)) gating.add(flag);
|
|
867
|
+
for (const [, dotted, bracketed] of condition.matchAll(
|
|
868
|
+
/process\.env(?:\.([A-Z][A-Z0-9_]*)|\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\])/g
|
|
869
|
+
)) {
|
|
870
|
+
gating.add(dotted ?? bracketed);
|
|
871
|
+
}
|
|
872
|
+
for (const [, args] of condition.matchAll(/\b\w*(?:[Ff]lag|[Ff]eature|[Ee]nabled)\w*\s*\(([^()]*)\)/g)) {
|
|
873
|
+
for (const [, literal] of args.matchAll(/["'`]([^"'`]+)["'`]/g)) {
|
|
874
|
+
if (FLAG_NAME_ONLY.test(literal)) gating.add(literal);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
for (const [, callee] of condition.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
878
|
+
for (const flag of named.get(callee) ?? []) gating.add(flag);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return [...gating];
|
|
883
|
+
}
|
|
884
|
+
function guardOf(source, callAt) {
|
|
885
|
+
const before = source.slice(Math.max(0, callAt - 400), callAt);
|
|
886
|
+
const opened = [...before.matchAll(/\bif\s*\(/g)].at(-1);
|
|
887
|
+
if (!opened) return void 0;
|
|
888
|
+
const start = opened.index + opened[0].length;
|
|
889
|
+
let depth2 = 1;
|
|
890
|
+
let cursor = start;
|
|
891
|
+
while (cursor < before.length && depth2 > 0) {
|
|
892
|
+
const character = before[cursor];
|
|
893
|
+
if (character === "(") depth2 += 1;
|
|
894
|
+
else if (character === ")") depth2 -= 1;
|
|
895
|
+
cursor += 1;
|
|
896
|
+
}
|
|
897
|
+
if (depth2 !== 0) return void 0;
|
|
898
|
+
if (!/^\s*(?:\{\s*)?(?:return\s+)?$/.test(before.slice(cursor))) return void 0;
|
|
899
|
+
return before.slice(start, cursor - 1);
|
|
760
900
|
}
|
|
761
901
|
function trim(value, limit) {
|
|
762
902
|
return value.length <= limit ? value : `${value.slice(0, limit - 1).trimEnd()}\u2026`;
|
|
@@ -818,7 +958,9 @@ function readProduct(routes, options) {
|
|
|
818
958
|
}
|
|
819
959
|
function hasAnything(facts) {
|
|
820
960
|
return Boolean(
|
|
821
|
-
facts.headings.length || facts.actions.length || facts.links.length || facts.fields.length || facts.notices.length
|
|
961
|
+
facts.headings.length || facts.actions.length || facts.links.length || facts.fields.length || facts.notices.length || // A flagged page whose screen is all in a component the reader cannot
|
|
962
|
+
// follow still says it is behind a flag, and an empty state is still words.
|
|
963
|
+
facts.gatedBy?.length || facts.empty?.length
|
|
822
964
|
);
|
|
823
965
|
}
|
|
824
966
|
function describeReading(routes) {
|
|
@@ -827,6 +969,34 @@ function describeReading(routes) {
|
|
|
827
969
|
return { screens: documentable.length, read, pathsOnly: read === 0 };
|
|
828
970
|
}
|
|
829
971
|
|
|
972
|
+
// core/github/remote.ts
|
|
973
|
+
function repositoryFromRemote(remote) {
|
|
974
|
+
const raw = remote?.trim();
|
|
975
|
+
if (!raw) return void 0;
|
|
976
|
+
if (OWNER_NAME.test(raw)) return withoutGit(raw);
|
|
977
|
+
const scp = SCP.exec(raw);
|
|
978
|
+
const at = scp ? { host: scp[1], hostname: scp[1], path: scp[2] } : located(raw);
|
|
979
|
+
if (!at) return void 0;
|
|
980
|
+
const parts = withoutGit(at.path.replace(/^\/+|\/+$/g, "")).split("/").filter(Boolean);
|
|
981
|
+
if (!parts.length) return void 0;
|
|
982
|
+
if (GITHUB.test(at.hostname)) return parts.length === 2 ? `${parts[0]}/${parts[1]}` : void 0;
|
|
983
|
+
return `${at.host}/${parts.join("/")}`;
|
|
984
|
+
}
|
|
985
|
+
var OWNER_NAME = /^[A-Za-z0-9][\w-]*\/(?!\.\.?$)[\w.-]+$/;
|
|
986
|
+
var SCP = /^(?:[^@\s/]+@)?([^:\s/]{2,}):(?!\/)(\S+)$/;
|
|
987
|
+
var GITHUB = /^(www\.)?github\.com$/i;
|
|
988
|
+
function withoutGit(path) {
|
|
989
|
+
return path.replace(/\.git$/, "");
|
|
990
|
+
}
|
|
991
|
+
function located(raw) {
|
|
992
|
+
try {
|
|
993
|
+
const url = new URL(raw);
|
|
994
|
+
return url.hostname && /^(https?|ssh|git):$/.test(url.protocol) ? { host: url.host, hostname: url.hostname, path: url.pathname } : void 0;
|
|
995
|
+
} catch {
|
|
996
|
+
return void 0;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
830
1000
|
// core/markers/propose.ts
|
|
831
1001
|
function toLines(contents) {
|
|
832
1002
|
const lines = contents.split("\n");
|
|
@@ -950,7 +1120,7 @@ function markersPullRequestBody(result) {
|
|
|
950
1120
|
}
|
|
951
1121
|
lines.push(
|
|
952
1122
|
"",
|
|
953
|
-
"**A wrong one costs nothing.** Delete the line and the article stops being linked to that file. If we missed one, the screen is still matched by its route
|
|
1123
|
+
"**A wrong one costs nothing.** Delete the line and the article stops being linked to that file. If we missed one, the screen is still matched by its route. This only makes the match more precise."
|
|
954
1124
|
);
|
|
955
1125
|
if (result.unmatchedSlugs.length > 0) {
|
|
956
1126
|
lines.push(
|
|
@@ -1031,7 +1201,7 @@ async function runMap(options) {
|
|
|
1031
1201
|
lines.push(
|
|
1032
1202
|
"",
|
|
1033
1203
|
proposed.proposals.length ? `Wrote ${proposed.proposals.length} marker(s) to ${options.markersOut}. Apply with:
|
|
1034
|
-
git apply ${options.markersOut}` : "Every file already carries its marker
|
|
1204
|
+
git apply ${options.markersOut}` : "Every file already carries its marker: nothing to propose."
|
|
1035
1205
|
);
|
|
1036
1206
|
}
|
|
1037
1207
|
let sent = false;
|
|
@@ -1054,7 +1224,7 @@ async function runMap(options) {
|
|
|
1054
1224
|
throw new Error(`The help desk refused the map (${response.status}). ${body}`);
|
|
1055
1225
|
}
|
|
1056
1226
|
sent = true;
|
|
1057
|
-
lines.push("", "Sent. Whoever asked you to run this can see it now
|
|
1227
|
+
lines.push("", "Sent. Whoever asked you to run this can see it now. You are done.");
|
|
1058
1228
|
} else if (options.token) {
|
|
1059
1229
|
lines.push("", "No destination given. Pass --to https://your-helpdesk.example to send this.");
|
|
1060
1230
|
}
|
|
@@ -1077,7 +1247,7 @@ function readTsconfig(cwd) {
|
|
|
1077
1247
|
}
|
|
1078
1248
|
function originOf(cwd) {
|
|
1079
1249
|
try {
|
|
1080
|
-
return execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" })
|
|
1250
|
+
return repositoryFromRemote(execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" }));
|
|
1081
1251
|
} catch {
|
|
1082
1252
|
return void 0;
|
|
1083
1253
|
}
|
|
@@ -1089,6 +1259,16 @@ function headCommit(cwd) {
|
|
|
1089
1259
|
return null;
|
|
1090
1260
|
}
|
|
1091
1261
|
}
|
|
1262
|
+
var WORKFLOW_ACTIONS = {
|
|
1263
|
+
checkout: "actions/checkout@v7",
|
|
1264
|
+
setupNode: "actions/setup-node@v7"
|
|
1265
|
+
};
|
|
1266
|
+
var SETUP_STEPS = ` - uses: ${WORKFLOW_ACTIONS.checkout}
|
|
1267
|
+
- uses: ${WORKFLOW_ACTIONS.setupNode}
|
|
1268
|
+
with:
|
|
1269
|
+
node-version: "22"
|
|
1270
|
+
package-manager-cache: false
|
|
1271
|
+
`;
|
|
1092
1272
|
|
|
1093
1273
|
// adapters/invoked-as-command.ts
|
|
1094
1274
|
import { realpathSync } from "node:fs";
|