@olomonhelpdesk2/helpdesk 0.1.5 → 0.1.9
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 +140 -45
- package/helpdesk-seed.mjs +17 -9
- 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) {
|
|
@@ -165,7 +183,7 @@ function actionFor(outcome) {
|
|
|
165
183
|
return void 0;
|
|
166
184
|
case "not-found": {
|
|
167
185
|
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
|
|
186
|
+
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
187
|
}
|
|
170
188
|
const asked = outcome.missingEndpoint ?? outcome.route;
|
|
171
189
|
const noun = subjectOf(fillsPattern(outcome.route, asked) ? outcome.route : asked);
|
|
@@ -340,40 +358,40 @@ function preflight(facts) {
|
|
|
340
358
|
name: "target",
|
|
341
359
|
ok: true,
|
|
342
360
|
blocking: false,
|
|
343
|
-
detail: `${facts.baseUrl}, reading from ${facts.replaying}
|
|
361
|
+
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
362
|
});
|
|
345
363
|
} else if (facts.session && live && !facts.productionGranted) {
|
|
346
364
|
checks.push({
|
|
347
365
|
name: "target",
|
|
348
366
|
ok: false,
|
|
349
367
|
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
|
|
368
|
+
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
369
|
});
|
|
352
370
|
} else {
|
|
353
371
|
checks.push({
|
|
354
372
|
name: "target",
|
|
355
373
|
ok: true,
|
|
356
374
|
blocking: false,
|
|
357
|
-
detail: facts.session ? `${facts.baseUrl}, signed in${live ? "
|
|
375
|
+
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
376
|
});
|
|
359
377
|
}
|
|
360
378
|
checks.push({
|
|
361
379
|
name: "browser",
|
|
362
380
|
ok: Boolean(facts.browser),
|
|
363
381
|
blocking: false,
|
|
364
|
-
detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified
|
|
382
|
+
detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified. A browser upgrade will look like the product changing"
|
|
365
383
|
});
|
|
366
384
|
checks.push({
|
|
367
385
|
name: "clock",
|
|
368
386
|
ok: facts.clockPinned,
|
|
369
387
|
blocking: false,
|
|
370
|
-
detail: facts.clockPinned ? "pinned, with randomness and generated ids seeded from it" : "running live (--live-clock)
|
|
388
|
+
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
389
|
});
|
|
372
390
|
checks.push({
|
|
373
391
|
name: "locale",
|
|
374
392
|
ok: true,
|
|
375
393
|
blocking: false,
|
|
376
|
-
detail: `${facts.locale}, ${facts.timezone}
|
|
394
|
+
detail: `${facts.locale}, ${facts.timezone}, set explicitly rather than inherited from this machine`
|
|
377
395
|
});
|
|
378
396
|
checks.push({
|
|
379
397
|
name: "isolation",
|
|
@@ -398,7 +416,7 @@ function preflight(facts) {
|
|
|
398
416
|
name: "rebaseline",
|
|
399
417
|
ok: true,
|
|
400
418
|
blocking: false,
|
|
401
|
-
detail: `replacing every stored frame
|
|
419
|
+
detail: `replacing every stored frame: "${facts.rebaselineReason}"`
|
|
402
420
|
});
|
|
403
421
|
}
|
|
404
422
|
const blocked = checks.filter((check) => check.blocking && !check.ok);
|
|
@@ -425,11 +443,30 @@ function attestation(run) {
|
|
|
425
443
|
].filter(Boolean).join("\n");
|
|
426
444
|
}
|
|
427
445
|
|
|
446
|
+
// core/capture/run-record.ts
|
|
447
|
+
function runIdentityFromEnv(env) {
|
|
448
|
+
if (env["GITHUB_ACTIONS"] !== "true" || !env["GITHUB_RUN_NUMBER"]) return void 0;
|
|
449
|
+
const said = { number: env["GITHUB_RUN_NUMBER"] };
|
|
450
|
+
for (const [key, name] of [
|
|
451
|
+
["id", "GITHUB_RUN_ID"],
|
|
452
|
+
["attempt", "GITHUB_RUN_ATTEMPT"],
|
|
453
|
+
["repository", "GITHUB_REPOSITORY"],
|
|
454
|
+
["server", "GITHUB_SERVER_URL"]
|
|
455
|
+
]) {
|
|
456
|
+
const value = env[name];
|
|
457
|
+
if (value) said[key] = value;
|
|
458
|
+
}
|
|
459
|
+
return said;
|
|
460
|
+
}
|
|
461
|
+
|
|
428
462
|
// core/capture/run-verdict.ts
|
|
463
|
+
function runCounts(run) {
|
|
464
|
+
return `${run.captured} photographed, ${run.unchanged} unchanged, ${run.skipped} skipped, ${run.refused} refused, ${run.failed} failed`;
|
|
465
|
+
}
|
|
429
466
|
function runVerdict(run) {
|
|
430
467
|
const delivered = run.captured + run.unchanged;
|
|
431
468
|
const missed = run.skipped + run.refused + run.failed;
|
|
432
|
-
const counts =
|
|
469
|
+
const counts = runCounts(run);
|
|
433
470
|
if (run.screens > 0 && delivered === 0) {
|
|
434
471
|
return {
|
|
435
472
|
exit: 1,
|
|
@@ -580,6 +617,8 @@ var ALREADY_SAFE = [
|
|
|
580
617
|
function alreadySafe(kind, value) {
|
|
581
618
|
return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
|
|
582
619
|
}
|
|
620
|
+
var BIRTH_LABEL = /(?:\bdate[\s_-]*of[\s_-]*birth|\bbirth[\s_-]*(?:date|day)|\bdob\b|\bd\.o\.b\b|\bborn\b)/i;
|
|
621
|
+
var LABEL_REACH = 48;
|
|
583
622
|
var PATTERNS = [
|
|
584
623
|
{
|
|
585
624
|
kind: "email",
|
|
@@ -629,8 +668,9 @@ var PATTERNS = [
|
|
|
629
668
|
{
|
|
630
669
|
kind: "date-of-birth",
|
|
631
670
|
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"
|
|
671
|
+
because: "a date labeled as a birth date, which identifies somebody",
|
|
672
|
+
replace: () => "1970-01-01",
|
|
673
|
+
labelled: BIRTH_LABEL
|
|
634
674
|
},
|
|
635
675
|
{
|
|
636
676
|
kind: "passport",
|
|
@@ -651,8 +691,13 @@ function scrub(text2, options = {}) {
|
|
|
651
691
|
let output = text2;
|
|
652
692
|
for (const pattern of PATTERNS) {
|
|
653
693
|
const seen = /* @__PURE__ */ new Map();
|
|
654
|
-
output = output.replace(pattern.regex, (
|
|
694
|
+
output = output.replace(pattern.regex, (...args) => {
|
|
695
|
+
const match = args[0];
|
|
696
|
+
const named = typeof args[args.length - 1] === "object";
|
|
697
|
+
const offset = args[args.length - (named ? 3 : 2)];
|
|
698
|
+
const whole = args[args.length - (named ? 2 : 1)];
|
|
655
699
|
if (alreadySafe(pattern.kind, match)) return match;
|
|
700
|
+
if (pattern.labelled && !pattern.labelled.test(whole.slice(Math.max(0, offset - LABEL_REACH), offset))) return match;
|
|
656
701
|
const already = seen.get(match);
|
|
657
702
|
if (already) return already;
|
|
658
703
|
const replacement = pattern.replace(match, seen.size);
|
|
@@ -669,7 +714,7 @@ function scrub(text2, options = {}) {
|
|
|
669
714
|
found: brand,
|
|
670
715
|
replacement: brand,
|
|
671
716
|
where,
|
|
672
|
-
because: "a real institution in a published screenshot implies a partnership
|
|
717
|
+
because: "a real institution in a published screenshot implies a partnership: a legal review item, not a design nit"
|
|
673
718
|
});
|
|
674
719
|
}
|
|
675
720
|
}
|
|
@@ -697,7 +742,7 @@ function gateFrame(pageText, options = {}) {
|
|
|
697
742
|
return {
|
|
698
743
|
safe: false,
|
|
699
744
|
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
|
|
745
|
+
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
746
|
};
|
|
702
747
|
}
|
|
703
748
|
|
|
@@ -1037,11 +1082,22 @@ var LIMITS = {
|
|
|
1037
1082
|
fields: 24,
|
|
1038
1083
|
notices: 20,
|
|
1039
1084
|
warnings: 12,
|
|
1085
|
+
disabled: 24,
|
|
1086
|
+
empty: 12,
|
|
1087
|
+
gatedBy: 8,
|
|
1040
1088
|
choices: 12,
|
|
1041
1089
|
/** Longer than this is a paragraph of copy, not a label. */
|
|
1042
1090
|
label: 120,
|
|
1043
1091
|
sentence: 300
|
|
1044
1092
|
};
|
|
1093
|
+
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;
|
|
1094
|
+
function isEmptyState(text2) {
|
|
1095
|
+
const sentence = text2.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
|
|
1096
|
+
if (!sentence || sentence.length > LIMITS.sentence || sentence.includes("?")) return false;
|
|
1097
|
+
if (/\b(?:until|unless)\b/i.test(sentence)) return false;
|
|
1098
|
+
if (sentence.split(" ").length < 2) return false;
|
|
1099
|
+
return EMPTY_STATE.test(sentence);
|
|
1100
|
+
}
|
|
1045
1101
|
|
|
1046
1102
|
// core/discovery/aria-facts.ts
|
|
1047
1103
|
var LANDMARK = {
|
|
@@ -1092,6 +1148,12 @@ function readAriaFactRecords(snapshot, options = {}) {
|
|
|
1092
1148
|
const state = controlState(node);
|
|
1093
1149
|
if (node.role === "heading") {
|
|
1094
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);
|
|
1095
1157
|
continue;
|
|
1096
1158
|
}
|
|
1097
1159
|
if (node.role === "link") {
|
|
@@ -1115,7 +1177,9 @@ function readAriaFactRecords(snapshot, options = {}) {
|
|
|
1115
1177
|
continue;
|
|
1116
1178
|
}
|
|
1117
1179
|
if (NOTICE.has(node.role)) {
|
|
1118
|
-
|
|
1180
|
+
const said = trim(node.text || node.name, LIMITS.sentence);
|
|
1181
|
+
add("notice", said, node);
|
|
1182
|
+
if (isEmptyState(said)) add("empty", said, node);
|
|
1119
1183
|
}
|
|
1120
1184
|
}
|
|
1121
1185
|
return facts;
|
|
@@ -1152,7 +1216,7 @@ function parse(snapshot) {
|
|
|
1152
1216
|
if (node.name || node.text) parent.choices.push(node.name || node.text);
|
|
1153
1217
|
continue;
|
|
1154
1218
|
}
|
|
1155
|
-
|
|
1219
|
+
out.push(node);
|
|
1156
1220
|
stack.push({ depth: depth2, node });
|
|
1157
1221
|
}
|
|
1158
1222
|
return out;
|
|
@@ -1235,6 +1299,7 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1235
1299
|
const label = accessibleName(element);
|
|
1236
1300
|
if (HEADING.test(tag) || role === "heading") {
|
|
1237
1301
|
add("heading", label, landmark);
|
|
1302
|
+
if (isEmptyState(label)) add("empty", label, landmark);
|
|
1238
1303
|
continue;
|
|
1239
1304
|
}
|
|
1240
1305
|
if (tag === "button" || PRESSABLE.has(role) || submitLike(element)) {
|
|
@@ -1260,11 +1325,20 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1260
1325
|
continue;
|
|
1261
1326
|
}
|
|
1262
1327
|
if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
|
|
1263
|
-
|
|
1328
|
+
const said = trim2(text(inner), LIMITS.sentence);
|
|
1329
|
+
add("notice", said, landmark);
|
|
1330
|
+
if (isEmptyState(said)) add("empty", said, landmark);
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
if (TEXT_HOLDER.has(tag) && !BLOCK_INSIDE.test(inner)) {
|
|
1334
|
+
const said = trim2(text(inner).replace(/\s+([.,;:!])/g, "$1"), LIMITS.sentence);
|
|
1335
|
+
if (isEmptyState(said)) add("empty", said, landmark);
|
|
1264
1336
|
}
|
|
1265
1337
|
}
|
|
1266
1338
|
return facts;
|
|
1267
1339
|
}
|
|
1340
|
+
var TEXT_HOLDER = /* @__PURE__ */ new Set(["p", "span", "small", "td", "li", "div"]);
|
|
1341
|
+
var BLOCK_INSIDE = /<(?!\/?(?:strong|em|b|i|a|code|br|span|small)\b)[a-z]/i;
|
|
1268
1342
|
var OVERLAY_ROLE = {
|
|
1269
1343
|
dialog: "dialog",
|
|
1270
1344
|
alertdialog: "dialog",
|
|
@@ -1616,7 +1690,7 @@ function requireDeployment(producer, input) {
|
|
|
1616
1690
|
const value = deployment.value.trim();
|
|
1617
1691
|
if (BROWSER_SHAPED.test(value) || value === input.captureEnvironment?.trim()) {
|
|
1618
1692
|
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
|
|
1693
|
+
`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
1694
|
);
|
|
1621
1695
|
}
|
|
1622
1696
|
}
|
|
@@ -1665,7 +1739,7 @@ async function captureOne(options, shot, build) {
|
|
|
1665
1739
|
status: "skipped",
|
|
1666
1740
|
result: "bounced",
|
|
1667
1741
|
needsAccount: true,
|
|
1668
|
-
reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant
|
|
1742
|
+
reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant, never a real account"
|
|
1669
1743
|
},
|
|
1670
1744
|
observed: { route: shot.route, result: "bounced" }
|
|
1671
1745
|
};
|
|
@@ -1853,7 +1927,7 @@ async function captureOne(options, shot, build) {
|
|
|
1853
1927
|
sha256,
|
|
1854
1928
|
settled: frame.settled,
|
|
1855
1929
|
...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
|
|
1856
|
-
reason: "photographed, but this screen never stopped moving
|
|
1930
|
+
reason: "photographed, but this screen never stopped moving. Its hash will differ every run, so drift on it means nothing"
|
|
1857
1931
|
}
|
|
1858
1932
|
} : {
|
|
1859
1933
|
shotId: shot.id,
|
|
@@ -2546,7 +2620,7 @@ async function stageAgainstRecording(options) {
|
|
|
2546
2620
|
died = code ?? 1;
|
|
2547
2621
|
});
|
|
2548
2622
|
} else {
|
|
2549
|
-
options.onLog?.(`The recording is at ${replay.url}
|
|
2623
|
+
options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
|
|
2550
2624
|
}
|
|
2551
2625
|
const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), options.onLog, () => died !== void 0);
|
|
2552
2626
|
if (app) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
@@ -2557,7 +2631,7 @@ async function stageAgainstRecording(options) {
|
|
|
2557
2631
|
throw new Error(
|
|
2558
2632
|
`The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
|
|
2559
2633
|
|
|
2560
|
-
Something else is already answering on ${options.appUrl}. That is almost always a server left behind by an earlier run
|
|
2634
|
+
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
2635
|
);
|
|
2562
2636
|
}
|
|
2563
2637
|
if (!up) {
|
|
@@ -2748,7 +2822,7 @@ function gapReport(gaps) {
|
|
|
2748
2822
|
""
|
|
2749
2823
|
];
|
|
2750
2824
|
for (const gap of gaps) {
|
|
2751
|
-
const broke = gap.broke.length ? `
|
|
2825
|
+
const broke = gap.broke.length ? ` (breaks ${gap.broke.join(", ")})` : "";
|
|
2752
2826
|
lines.push(` ${gap.method} ${gap.path}${broke}`);
|
|
2753
2827
|
if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
|
|
2754
2828
|
}
|
|
@@ -2795,7 +2869,7 @@ function coverageReport(found, total) {
|
|
|
2795
2869
|
...found.missing.map((route) => ` ${route}`),
|
|
2796
2870
|
"",
|
|
2797
2871
|
"They will not be photographed. Record again and visit them, or they stay out of the",
|
|
2798
|
-
"plan
|
|
2872
|
+
"plan: an empty screen photographed from a gap in the recording looks exactly like an",
|
|
2799
2873
|
"empty screen belonging to a customer with nothing, which is how a help article ends up",
|
|
2800
2874
|
"showing a product with no content in it."
|
|
2801
2875
|
];
|
|
@@ -2991,7 +3065,7 @@ function score(route, signals) {
|
|
|
2991
3065
|
}
|
|
2992
3066
|
if (hasSegment(route.path, MONEY_OR_ACCESS)) {
|
|
2993
3067
|
total += 18;
|
|
2994
|
-
because.push("money or access
|
|
3068
|
+
because.push("money or access: asked about out of proportion to its traffic");
|
|
2995
3069
|
}
|
|
2996
3070
|
if (hasSegment(route.path, ENTRY_POINT)) {
|
|
2997
3071
|
total += 16;
|
|
@@ -3087,6 +3161,16 @@ function buildArticlePlan(options) {
|
|
|
3087
3161
|
function repositoryFiles(cwd) {
|
|
3088
3162
|
return execFileSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).split("\n").filter(Boolean);
|
|
3089
3163
|
}
|
|
3164
|
+
var WORKFLOW_ACTIONS = {
|
|
3165
|
+
checkout: "actions/checkout@v7",
|
|
3166
|
+
setupNode: "actions/setup-node@v7"
|
|
3167
|
+
};
|
|
3168
|
+
var SETUP_STEPS = ` - uses: ${WORKFLOW_ACTIONS.checkout}
|
|
3169
|
+
- uses: ${WORKFLOW_ACTIONS.setupNode}
|
|
3170
|
+
with:
|
|
3171
|
+
node-version: "22"
|
|
3172
|
+
package-manager-cache: false
|
|
3173
|
+
`;
|
|
3090
3174
|
|
|
3091
3175
|
// core/discovery/storyboard.ts
|
|
3092
3176
|
function shotIdFor(route) {
|
|
@@ -3322,6 +3406,10 @@ Approve one on the plan page, then run this again.`
|
|
|
3322
3406
|
`
|
|
3323
3407
|
);
|
|
3324
3408
|
const runId = local ? void 0 : await openRun(parsed.to, headers, shots.length);
|
|
3409
|
+
let told = Promise.resolve();
|
|
3410
|
+
const tell = (body) => {
|
|
3411
|
+
told = told.then(() => tellHelpDesk(parsed.to, headers, body));
|
|
3412
|
+
};
|
|
3325
3413
|
try {
|
|
3326
3414
|
const report = await runCapture({
|
|
3327
3415
|
baseUrl,
|
|
@@ -3354,13 +3442,16 @@ Approve one on the plan page, then run this again.`
|
|
|
3354
3442
|
role: parsed.session ? unknownRole() : noRole("this run was signed out"),
|
|
3355
3443
|
onProgress: (outcome, index, total) => {
|
|
3356
3444
|
const mark = { captured: "\u2713", unchanged: "=", skipped: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
|
|
3357
|
-
console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ?
|
|
3445
|
+
console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? `: ${outcome.reason}` : ""}`);
|
|
3358
3446
|
if (runId) {
|
|
3359
|
-
|
|
3447
|
+
tell({
|
|
3360
3448
|
action: "progress",
|
|
3361
3449
|
jobId: runId,
|
|
3362
3450
|
item: outcome.route,
|
|
3451
|
+
// Kept for a help desk that predates `outcome`, which folds the
|
|
3452
|
+
// five into three and cannot tell a refusal from a skip.
|
|
3363
3453
|
status: outcome.status === "captured" || outcome.status === "unchanged" ? "ok" : outcome.status === "failed" ? "failed" : "skipped",
|
|
3454
|
+
outcome: outcome.status,
|
|
3364
3455
|
...outcome.reason ? { note: outcome.reason } : {},
|
|
3365
3456
|
// So Setup can ask about them, once each. See `core/capture/unclassified.ts`.
|
|
3366
3457
|
...outcome.unaccounted?.length ? { unaccounted: outcome.unaccounted } : {}
|
|
@@ -3413,10 +3504,8 @@ Approve one on the plan page, then run this again.`
|
|
|
3413
3504
|
return { ok: false, error: body.error ?? `the help desk answered ${response.status}` };
|
|
3414
3505
|
}
|
|
3415
3506
|
});
|
|
3416
|
-
console.log(
|
|
3417
|
-
|
|
3418
|
-
${report.captured} photographed, ${report.unchanged} unchanged, ${report.skipped} skipped, ${report.refused} refused, ${report.failed} failed.`
|
|
3419
|
-
);
|
|
3507
|
+
console.log(`
|
|
3508
|
+
${runCounts(report)}.`);
|
|
3420
3509
|
const unclassified = unclassifiedLines(
|
|
3421
3510
|
groupUnclassified(
|
|
3422
3511
|
report.outcomes.flatMap((outcome) => outcome.unaccounted?.length ? [{ route: outcome.route, values: outcome.unaccounted }] : [])
|
|
@@ -3481,7 +3570,7 @@ whether they changed. Usually a spinner that never resolves or a looping animati
|
|
|
3481
3570
|
const gated = report.outcomes.filter((outcome) => outcome.needsAccount);
|
|
3482
3571
|
if (gated.length) {
|
|
3483
3572
|
console.log(
|
|
3484
|
-
stage ? "\nSome screens still showed a sign-in. A recording answers the backend, but it cannot\ngive the product a session
|
|
3573
|
+
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
3574
|
Some screens are behind your sign-in. To photograph them, save a session against a
|
|
3486
3575
|
DEMO tenant and pass it back:
|
|
3487
3576
|
|
|
@@ -3508,27 +3597,32 @@ ${attestation({
|
|
|
3508
3597
|
console.log(`
|
|
3509
3598
|
${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
|
|
3510
3599
|
}
|
|
3600
|
+
const counts = {
|
|
3601
|
+
screens: shots.length,
|
|
3602
|
+
captured: report.captured,
|
|
3603
|
+
unchanged: report.unchanged,
|
|
3604
|
+
skipped: report.skipped,
|
|
3605
|
+
refused: report.refused,
|
|
3606
|
+
failed: report.failed
|
|
3607
|
+
};
|
|
3511
3608
|
if (runId) {
|
|
3609
|
+
await told;
|
|
3512
3610
|
await tellHelpDesk(parsed.to, headers, {
|
|
3513
3611
|
action: "finish",
|
|
3514
3612
|
jobId: runId,
|
|
3613
|
+
// What the verdict below prints, so Setup can say what this log says.
|
|
3614
|
+
counts,
|
|
3515
3615
|
...report.survey.length ? { survey: report.survey } : {}
|
|
3516
3616
|
});
|
|
3517
3617
|
}
|
|
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
|
-
});
|
|
3618
|
+
const verdict = runVerdict(counts);
|
|
3526
3619
|
if (verdict.sentence) console.log(`
|
|
3527
3620
|
${verdict.sentence}`);
|
|
3528
3621
|
if (verdict.annotation && process.env["GITHUB_ACTIONS"] === "true") console.log(verdict.annotation);
|
|
3529
3622
|
return verdict.exit;
|
|
3530
3623
|
} catch (error) {
|
|
3531
3624
|
if (runId) {
|
|
3625
|
+
await told;
|
|
3532
3626
|
await tellHelpDesk(parsed.to, headers, {
|
|
3533
3627
|
action: "finish",
|
|
3534
3628
|
jobId: runId,
|
|
@@ -3543,11 +3637,12 @@ ${verdict.sentence}`);
|
|
|
3543
3637
|
}
|
|
3544
3638
|
}
|
|
3545
3639
|
async function openRun(to, headers, total) {
|
|
3640
|
+
const run = runIdentityFromEnv(process.env);
|
|
3546
3641
|
try {
|
|
3547
3642
|
const response = await fetch(`${to}/api/helpdesk/captures`, {
|
|
3548
3643
|
method: "POST",
|
|
3549
3644
|
headers: { ...headers, "content-type": "application/json" },
|
|
3550
|
-
body: JSON.stringify({ action: "start", total })
|
|
3645
|
+
body: JSON.stringify({ action: "start", total, ...run ? { run } : {} })
|
|
3551
3646
|
});
|
|
3552
3647
|
if (!response.ok) return void 0;
|
|
3553
3648
|
const body = await response.json();
|
|
@@ -3577,7 +3672,7 @@ async function signIn(argv) {
|
|
|
3577
3672
|
return 2;
|
|
3578
3673
|
}
|
|
3579
3674
|
console.log(
|
|
3580
|
-
`Opening ${baseUrl}. Sign in as your DEMO tenant
|
|
3675
|
+
`Opening ${baseUrl}. Sign in as your DEMO tenant, never as a real customer.
|
|
3581
3676
|
This window is yours: use SSO, a one-time code, whatever you normally do.
|
|
3582
3677
|
The session will be saved to ${sessionPath} and sent nowhere.
|
|
3583
3678
|
`
|
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}`
|
|
@@ -1217,7 +1225,7 @@ async function record(args) {
|
|
|
1217
1225
|
[
|
|
1218
1226
|
`Recording everything sent to ${args.upstream}.`,
|
|
1219
1227
|
"",
|
|
1220
|
-
"Point your product's backend address at this and use it
|
|
1228
|
+
"Point your product's backend address at this and use it: sign in, open the screens",
|
|
1221
1229
|
"you want documented, and make the errors happen if you can. What you visit is what",
|
|
1222
1230
|
"the recording will be able to replay.",
|
|
1223
1231
|
"",
|
|
@@ -1289,7 +1297,7 @@ function carriedReport(carried, cassette) {
|
|
|
1289
1297
|
(field) => ` ${field.key.padEnd(width)} ${String(field.count).padStart(4)}\xD7 ${field.example}`
|
|
1290
1298
|
),
|
|
1291
1299
|
"",
|
|
1292
|
-
"That is not automatically wrong
|
|
1300
|
+
"That is not automatically wrong: an id, a status or a currency is meant to survive.",
|
|
1293
1301
|
"But this package only knows the words every product shares: people, their contact",
|
|
1294
1302
|
"details, their address, and the role each one is playing. Whatever your product holds",
|
|
1295
1303
|
"that no other product does is on the list above, and it was not touched.",
|
|
@@ -1304,7 +1312,7 @@ function carriedReport(carried, cassette) {
|
|
|
1304
1312
|
" company, note, amount",
|
|
1305
1313
|
"",
|
|
1306
1314
|
" 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
|
|
1315
|
+
" amount invents a different figure of the same size, for a salary or a balance:",
|
|
1308
1316
|
" a number is never detectable by shape, so naming it is the only way it is caught."
|
|
1309
1317
|
] : []
|
|
1310
1318
|
];
|
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";
|