@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.
@@ -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(shot.route, options.baseUrl).toString());
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: shot.route, text: pageText });
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(route, options.baseUrl).toString();
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: "UTC",
1738
- locale: "en-GB",
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
- { [CI_SESSION_OUT_ENV]: options.sessionOut, [CI_BASE_URL_ENV]: options.appUrl },
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: "en-GB",
3026
- timezone: "UTC",
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 { headings: [], actions: [], links: [], fields: [], notices: [], warnings: [], readFrom: [] };
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {