@olomonhelpdesk2/helpdesk 0.1.3 → 0.1.4
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 +172 -12
- package/helpdesk-seed.mjs +10 -0
- package/helpdesk.mjs +11 -2
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -21,6 +21,16 @@ var CAPTURE_CONTRACT = {
|
|
|
21
21
|
maxClipBytes: 2 * 1024 * 1024,
|
|
22
22
|
/** Everything renders as though it were this instant. */
|
|
23
23
|
pinnedClock: "2026-01-15T09:00:00.000Z",
|
|
24
|
+
/**
|
|
25
|
+
* And in this locale and this zone, set on the browser rather than inherited
|
|
26
|
+
* from whoever runs it, so a date on a screen reads the same from any machine.
|
|
27
|
+
*
|
|
28
|
+
* American English, because the products this documents are read in it: a
|
|
29
|
+
* frame showing "15/01/2026" beside an article written for an American
|
|
30
|
+
* reader is a screenshot of a product nobody uses.
|
|
31
|
+
*/
|
|
32
|
+
locale: "en-US",
|
|
33
|
+
timezone: "UTC",
|
|
24
34
|
/**
|
|
25
35
|
* Injected before every shot. Necessary, not sufficient — see above.
|
|
26
36
|
*/
|
|
@@ -87,6 +97,108 @@ function captureReport(outcomes) {
|
|
|
87
97
|
return { ready: ready.length, needData: actionable.length, lines };
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
// core/capture/examples.ts
|
|
101
|
+
var CI_EXAMPLES_OUT_ENV = "HELPDESK_EXAMPLES_OUT";
|
|
102
|
+
var EXAMPLES_SCHEMA = "helpdesk.examples/1";
|
|
103
|
+
var MAX_PATH = 2048;
|
|
104
|
+
var MAX_ROUTES = 500;
|
|
105
|
+
function segmentsOf(path3) {
|
|
106
|
+
return path3.split("/").filter((part) => part !== "");
|
|
107
|
+
}
|
|
108
|
+
function patternOf(route) {
|
|
109
|
+
return segmentsOf(route).map((part) => {
|
|
110
|
+
const optional = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(part);
|
|
111
|
+
if (optional) return { kind: "any", name: optional[1] };
|
|
112
|
+
const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(part);
|
|
113
|
+
if (catchAll) return { kind: "many", name: catchAll[1] };
|
|
114
|
+
const one = /^\[([^\]]+)\]$/.exec(part);
|
|
115
|
+
if (one) return { kind: "one", name: one[1] };
|
|
116
|
+
return { kind: "static", text: part };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function dynamicSegments(route) {
|
|
120
|
+
return patternOf(route).filter((segment) => segment.kind !== "static").map((segment) => segment.name);
|
|
121
|
+
}
|
|
122
|
+
function fillsPattern(pattern, path3) {
|
|
123
|
+
const want = patternOf(pattern);
|
|
124
|
+
const have = segmentsOf(path3);
|
|
125
|
+
const match = (w, h) => {
|
|
126
|
+
if (w === want.length) return h === have.length;
|
|
127
|
+
const segment = want[w];
|
|
128
|
+
if (segment.kind === "static") return have[h] === segment.text && match(w + 1, h + 1);
|
|
129
|
+
if (segment.kind === "one") return h < have.length && match(w + 1, h + 1);
|
|
130
|
+
const least = segment.kind === "many" ? 1 : 0;
|
|
131
|
+
for (let take = have.length - h; take >= least; take -= 1) {
|
|
132
|
+
if (match(w + 1, h + take)) return true;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
};
|
|
136
|
+
return match(0, 0);
|
|
137
|
+
}
|
|
138
|
+
function unusablePath(path3) {
|
|
139
|
+
if (path3.length > MAX_PATH) return "the path is longer than any screen's";
|
|
140
|
+
if (!path3.startsWith("/") || path3.startsWith("//")) return "the path is not a path inside the product";
|
|
141
|
+
if (/[?#\\]/.test(path3)) return "the path carries a query, a fragment or a backslash";
|
|
142
|
+
if (/\s/.test(path3) || [...path3].some((character) => character.charCodeAt(0) < 32)) {
|
|
143
|
+
return "the path carries whitespace or a control character";
|
|
144
|
+
}
|
|
145
|
+
const segments = segmentsOf(path3);
|
|
146
|
+
if (segments.some((segment) => segment === "." || segment === ".." || /%2e%2e|%2f|%5c/i.test(segment))) {
|
|
147
|
+
return "the path climbs out of its position";
|
|
148
|
+
}
|
|
149
|
+
if (/\[|\]/.test(path3)) return "the path still holds a dynamic segment rather than a record";
|
|
150
|
+
return void 0;
|
|
151
|
+
}
|
|
152
|
+
function readExampleRoutes(raw) {
|
|
153
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
154
|
+
return { ok: false, because: "the file is not a JSON object" };
|
|
155
|
+
}
|
|
156
|
+
const body = raw;
|
|
157
|
+
if (body["schema"] !== EXAMPLES_SCHEMA) {
|
|
158
|
+
return { ok: false, because: `the file does not say "schema": "${EXAMPLES_SCHEMA}"` };
|
|
159
|
+
}
|
|
160
|
+
const routes = body["routes"];
|
|
161
|
+
if (typeof routes !== "object" || routes === null || Array.isArray(routes)) {
|
|
162
|
+
return { ok: false, because: 'the file has no "routes" object' };
|
|
163
|
+
}
|
|
164
|
+
const entries = Object.entries(routes);
|
|
165
|
+
if (entries.length > MAX_ROUTES) return { ok: false, because: `the file names more than ${MAX_ROUTES} routes` };
|
|
166
|
+
const paths = /* @__PURE__ */ new Map();
|
|
167
|
+
const refused = [];
|
|
168
|
+
for (const [route, value] of entries) {
|
|
169
|
+
if (!route.startsWith("/") || dynamicSegments(route).length === 0) {
|
|
170
|
+
refused.push({ route, because: "it is not a route with a dynamic segment" });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (typeof value !== "string") {
|
|
174
|
+
refused.push({ route, because: "its example is not a path" });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const path3 = value.trim();
|
|
178
|
+
const unusable = unusablePath(path3);
|
|
179
|
+
if (unusable) {
|
|
180
|
+
refused.push({ route, because: unusable });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!fillsPattern(route, path3)) {
|
|
184
|
+
refused.push({ route, because: `${path3} is not ${route} with its dynamic segments filled in` });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
paths.set(route, path3);
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, examples: { paths, refused } };
|
|
190
|
+
}
|
|
191
|
+
function resolveVisit(route, examples) {
|
|
192
|
+
const needs = dynamicSegments(route);
|
|
193
|
+
if (needs.length === 0) return { visit: route };
|
|
194
|
+
const path3 = examples.paths.get(route);
|
|
195
|
+
return path3 ? { visit: path3 } : { needs };
|
|
196
|
+
}
|
|
197
|
+
function describeMissingExample(route, needs) {
|
|
198
|
+
const named = needs.map((name) => `[${name}]`).join(" and ");
|
|
199
|
+
return `the seed command named no record for ${named} on ${route}, so there is nothing real to open. Add one to the file it writes at ${CI_EXAMPLES_OUT_ENV}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
90
202
|
// core/cli-package.ts
|
|
91
203
|
var HELPDESK_CLI_PACKAGE = "@olomonhelpdesk2/helpdesk";
|
|
92
204
|
function helpdeskCommand(command, args = []) {
|
|
@@ -1438,10 +1550,14 @@ async function runCapture(options) {
|
|
|
1438
1550
|
};
|
|
1439
1551
|
}
|
|
1440
1552
|
async function captureOne(options, shot, build) {
|
|
1553
|
+
if (shot.unvisitable) {
|
|
1554
|
+
return { outcome: { shotId: shot.id, route: shot.route, status: "skipped", reason: shot.unvisitable } };
|
|
1555
|
+
}
|
|
1556
|
+
const visit = shot.visit ?? shot.route;
|
|
1441
1557
|
let page;
|
|
1442
1558
|
try {
|
|
1443
1559
|
page = await options.browser.page();
|
|
1444
|
-
await page.goto(new URL(
|
|
1560
|
+
await page.goto(new URL(visit, options.baseUrl).toString());
|
|
1445
1561
|
if (await page.isSignIn()) {
|
|
1446
1562
|
return {
|
|
1447
1563
|
outcome: {
|
|
@@ -1478,7 +1594,7 @@ async function captureOne(options, shot, build) {
|
|
|
1478
1594
|
};
|
|
1479
1595
|
}
|
|
1480
1596
|
const seen = await page.observe?.() ?? {};
|
|
1481
|
-
const observed = classifyRoute({ ...seen, route:
|
|
1597
|
+
const observed = { ...classifyRoute({ ...seen, route: visit, text: pageText }), route: shot.route };
|
|
1482
1598
|
if (!isPublishable(observed.result)) {
|
|
1483
1599
|
return {
|
|
1484
1600
|
outcome: {
|
|
@@ -1491,7 +1607,7 @@ async function captureOne(options, shot, build) {
|
|
|
1491
1607
|
observed
|
|
1492
1608
|
};
|
|
1493
1609
|
}
|
|
1494
|
-
const evidence = await readEvidence(settledPage, shot.route, options, build);
|
|
1610
|
+
const evidence = await readEvidence(settledPage, shot.route, options, build, visit);
|
|
1495
1611
|
const reading = options.clearance ? await readScreen(settledPage, options.clearance) : void 0;
|
|
1496
1612
|
if (options.clearance && !reading) {
|
|
1497
1613
|
return {
|
|
@@ -1660,9 +1776,9 @@ async function captureOne(options, shot, build) {
|
|
|
1660
1776
|
await page?.close().catch(() => void 0);
|
|
1661
1777
|
}
|
|
1662
1778
|
}
|
|
1663
|
-
async function readEvidence(page, route, options, build) {
|
|
1779
|
+
async function readEvidence(page, route, options, build, visited = route) {
|
|
1664
1780
|
if (!options.commit || !options.repository || !options.session || !build) return void 0;
|
|
1665
|
-
const url = new URL(
|
|
1781
|
+
const url = new URL(visited, options.baseUrl).toString();
|
|
1666
1782
|
const html = await page.html?.().catch(() => "");
|
|
1667
1783
|
const aria = await page.aria?.().catch(() => "");
|
|
1668
1784
|
if (!html && !aria) return void 0;
|
|
@@ -1734,8 +1850,8 @@ async function playwrightBrowser(options = {}) {
|
|
|
1734
1850
|
// Pinned as well as the clock. A date formatted in the runner's own zone
|
|
1735
1851
|
// renders differently in London and in Denver, which makes a frame depend
|
|
1736
1852
|
// on who took it.
|
|
1737
|
-
timezoneId:
|
|
1738
|
-
locale:
|
|
1853
|
+
timezoneId: CAPTURE_CONTRACT.timezone,
|
|
1854
|
+
locale: CAPTURE_CONTRACT.locale,
|
|
1739
1855
|
/**
|
|
1740
1856
|
* A session, when one is supplied — and only ever from a file on the
|
|
1741
1857
|
* machine running this.
|
|
@@ -2351,15 +2467,21 @@ Something else is already answering on ${options.appUrl}. That is almost always
|
|
|
2351
2467
|
};
|
|
2352
2468
|
}
|
|
2353
2469
|
var SESSION_OUT_ENV = CI_SESSION_OUT_ENV;
|
|
2470
|
+
var EXAMPLES_OUT_ENV = CI_EXAMPLES_OUT_ENV;
|
|
2354
2471
|
async function stageSeeded(options) {
|
|
2355
2472
|
const onLog = options.onLog;
|
|
2356
2473
|
const { access, unlink } = await import("node:fs/promises");
|
|
2357
2474
|
await unlink(options.sessionOut).catch(() => void 0);
|
|
2475
|
+
if (options.examplesOut) await unlink(options.examplesOut).catch(() => void 0);
|
|
2358
2476
|
onLog?.(`Seeding a demo tenant with ${options.seedCommand}`);
|
|
2359
2477
|
const seeded = await runToCompletion(
|
|
2360
2478
|
options.seedCommand,
|
|
2361
2479
|
options.seedMs ?? 6e5,
|
|
2362
|
-
{
|
|
2480
|
+
{
|
|
2481
|
+
[CI_SESSION_OUT_ENV]: options.sessionOut,
|
|
2482
|
+
[CI_BASE_URL_ENV]: options.appUrl,
|
|
2483
|
+
...options.examplesOut ? { [CI_EXAMPLES_OUT_ENV]: options.examplesOut } : {}
|
|
2484
|
+
},
|
|
2363
2485
|
onLog
|
|
2364
2486
|
);
|
|
2365
2487
|
if (!seeded.ok) {
|
|
@@ -2377,6 +2499,7 @@ Nothing has been photographed. A run that carried on from here would sign in to
|
|
|
2377
2499
|
onLog?.(
|
|
2378
2500
|
sessionPath ? `The seed command left a session at ${sessionPath}, so the screens behind your login can be photographed.` : `The seed command wrote no session to ${SESSION_OUT_ENV}. Only the screens a signed-out visitor can reach will be photographed.`
|
|
2379
2501
|
);
|
|
2502
|
+
const examples = options.examplesOut ? await examplesFrom(options.examplesOut, onLog) : void 0;
|
|
2380
2503
|
let app;
|
|
2381
2504
|
let died;
|
|
2382
2505
|
if (options.appCommand) {
|
|
@@ -2413,11 +2536,38 @@ Something else is already answering on ${options.appUrl}, which is almost always
|
|
|
2413
2536
|
return {
|
|
2414
2537
|
appUrl: options.appUrl,
|
|
2415
2538
|
...sessionPath ? { sessionPath } : {},
|
|
2539
|
+
...examples ? { examples } : {},
|
|
2416
2540
|
close: async () => {
|
|
2417
2541
|
stop(app);
|
|
2418
2542
|
}
|
|
2419
2543
|
};
|
|
2420
2544
|
}
|
|
2545
|
+
async function examplesFrom(path3, onLog) {
|
|
2546
|
+
const { readFile: readFile2 } = await import("node:fs/promises");
|
|
2547
|
+
let text2;
|
|
2548
|
+
try {
|
|
2549
|
+
text2 = await readFile2(path3, "utf8");
|
|
2550
|
+
} catch {
|
|
2551
|
+
return void 0;
|
|
2552
|
+
}
|
|
2553
|
+
let parsed;
|
|
2554
|
+
try {
|
|
2555
|
+
parsed = JSON.parse(text2);
|
|
2556
|
+
} catch {
|
|
2557
|
+
onLog?.(`The seed command wrote ${EXAMPLES_OUT_ENV}, but it is not JSON, so no screen with an id in its address will be opened.`);
|
|
2558
|
+
return void 0;
|
|
2559
|
+
}
|
|
2560
|
+
const reading = readExampleRoutes(parsed);
|
|
2561
|
+
if (!reading.ok) {
|
|
2562
|
+
onLog?.(`The seed command wrote ${EXAMPLES_OUT_ENV}, but ${reading.because}, so no screen with an id in its address will be opened.`);
|
|
2563
|
+
return void 0;
|
|
2564
|
+
}
|
|
2565
|
+
onLog?.(
|
|
2566
|
+
`The seed command named a record for ${reading.examples.paths.size} screen(s) with an id in the address.`
|
|
2567
|
+
);
|
|
2568
|
+
for (const refused of reading.examples.refused) onLog?.(` not using ${refused.route}: ${refused.because}`);
|
|
2569
|
+
return reading.examples;
|
|
2570
|
+
}
|
|
2421
2571
|
async function runToCompletion(command, timeoutMs, env, onLog) {
|
|
2422
2572
|
return new Promise((resolve) => {
|
|
2423
2573
|
const child = spawn(command, {
|
|
@@ -2920,6 +3070,13 @@ function shotsFromRepo(repo, previous) {
|
|
|
2920
3070
|
...previous.has(step.shotId) ? { sha256: previous.get(step.shotId) } : {}
|
|
2921
3071
|
}));
|
|
2922
3072
|
}
|
|
3073
|
+
function withExamples(shots, examples) {
|
|
3074
|
+
return shots.map((shot) => {
|
|
3075
|
+
const resolved = resolveVisit(shot.route, examples);
|
|
3076
|
+
if ("needs" in resolved) return { ...shot, unvisitable: describeMissingExample(shot.route, resolved.needs) };
|
|
3077
|
+
return resolved.visit === shot.route ? shot : { ...shot, visit: resolved.visit };
|
|
3078
|
+
});
|
|
3079
|
+
}
|
|
2923
3080
|
async function previousFrames(out) {
|
|
2924
3081
|
try {
|
|
2925
3082
|
const manifest = JSON.parse(await readFile(path2.join(out, "manifest.json"), "utf8"));
|
|
@@ -2988,6 +3145,7 @@ Approve one on the plan page, then run this again.`
|
|
|
2988
3145
|
seedCommand: parsed.seedCommand,
|
|
2989
3146
|
...parsed.appCommand ? { appCommand: parsed.appCommand } : {},
|
|
2990
3147
|
sessionOut: parsed.session ?? path2.join(process.cwd(), "helpdesk-demo-session.json"),
|
|
3148
|
+
examplesOut: path2.join(process.cwd(), "helpdesk-examples.json"),
|
|
2991
3149
|
onLog: (line) => console.log(line)
|
|
2992
3150
|
});
|
|
2993
3151
|
if (seeded.sessionPath) parsed.session = seeded.sessionPath;
|
|
@@ -2998,6 +3156,7 @@ Approve one on the plan page, then run this again.`
|
|
|
2998
3156
|
return 1;
|
|
2999
3157
|
}
|
|
3000
3158
|
}
|
|
3159
|
+
const planned = seeded?.examples ? withExamples(shots, seeded.examples) : shots;
|
|
3001
3160
|
if (parsed.cassette) {
|
|
3002
3161
|
try {
|
|
3003
3162
|
stage = await stageAgainstRecording({
|
|
@@ -3022,8 +3181,8 @@ Approve one on the plan page, then run this again.`
|
|
|
3022
3181
|
baseUrl,
|
|
3023
3182
|
browser: await browser.build?.().catch(() => void 0),
|
|
3024
3183
|
clockPinned: !parsed.liveClock,
|
|
3025
|
-
locale:
|
|
3026
|
-
timezone:
|
|
3184
|
+
locale: CAPTURE_CONTRACT.locale,
|
|
3185
|
+
timezone: CAPTURE_CONTRACT.timezone,
|
|
3027
3186
|
session: Boolean(parsed.session),
|
|
3028
3187
|
...stage ? { replaying: parsed.cassette } : {},
|
|
3029
3188
|
...parsed.allowProduction ? { productionGranted: true } : {},
|
|
@@ -3054,7 +3213,7 @@ Approve one on the plan page, then run this again.`
|
|
|
3054
3213
|
try {
|
|
3055
3214
|
const report = await runCapture({
|
|
3056
3215
|
baseUrl,
|
|
3057
|
-
shots,
|
|
3216
|
+
shots: planned,
|
|
3058
3217
|
browser,
|
|
3059
3218
|
...orders.capture ? {
|
|
3060
3219
|
clearance: {
|
|
@@ -3310,5 +3469,6 @@ if (runningAsCommand(import.meta.url)) {
|
|
|
3310
3469
|
);
|
|
3311
3470
|
}
|
|
3312
3471
|
export {
|
|
3313
|
-
main
|
|
3472
|
+
main,
|
|
3473
|
+
withExamples
|
|
3314
3474
|
};
|
package/helpdesk-seed.mjs
CHANGED
|
@@ -981,6 +981,16 @@ var CAPTURE_CONTRACT = {
|
|
|
981
981
|
maxClipBytes: 2 * 1024 * 1024,
|
|
982
982
|
/** Everything renders as though it were this instant. */
|
|
983
983
|
pinnedClock: "2026-01-15T09:00:00.000Z",
|
|
984
|
+
/**
|
|
985
|
+
* And in this locale and this zone, set on the browser rather than inherited
|
|
986
|
+
* from whoever runs it, so a date on a screen reads the same from any machine.
|
|
987
|
+
*
|
|
988
|
+
* American English, because the products this documents are read in it: a
|
|
989
|
+
* frame showing "15/01/2026" beside an article written for an American
|
|
990
|
+
* reader is a screenshot of a product nobody uses.
|
|
991
|
+
*/
|
|
992
|
+
locale: "en-US",
|
|
993
|
+
timezone: "UTC",
|
|
984
994
|
/**
|
|
985
995
|
* Injected before every shot. Necessary, not sufficient — see above.
|
|
986
996
|
*/
|
package/helpdesk.mjs
CHANGED
|
@@ -519,8 +519,17 @@ function baseUrlFromTsconfig(tsconfig) {
|
|
|
519
519
|
}
|
|
520
520
|
|
|
521
521
|
// core/discovery/screen-facts.ts
|
|
522
|
-
function noScreenFacts() {
|
|
523
|
-
return {
|
|
522
|
+
function noScreenFacts(read) {
|
|
523
|
+
return {
|
|
524
|
+
headings: [],
|
|
525
|
+
actions: [],
|
|
526
|
+
links: [],
|
|
527
|
+
fields: [],
|
|
528
|
+
notices: [],
|
|
529
|
+
warnings: [],
|
|
530
|
+
readFrom: [],
|
|
531
|
+
...read ? { read } : {}
|
|
532
|
+
};
|
|
524
533
|
}
|
|
525
534
|
var LIMITS = {
|
|
526
535
|
headings: 12,
|