@olomonhelpdesk2/helpdesk 0.1.13 → 0.1.15

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.
Files changed (2) hide show
  1. package/helpdesk-capture.mjs +243 -76
  2. package/package.json +1 -1
@@ -106,7 +106,12 @@ function readExampleRoutes(raw) {
106
106
  }
107
107
  paths.set(route, path5);
108
108
  }
109
- return { ok: true, examples: { paths, refused } };
109
+ const again = /* @__PURE__ */ new Set();
110
+ for (const route of Array.isArray(body["again"]) ? body["again"] : []) {
111
+ if (typeof route === "string" && paths.has(route)) again.add(route);
112
+ else refused.push({ route: String(route), because: "it is named as safe to open again, but not as a route with a record" });
113
+ }
114
+ return { ok: true, examples: { paths, refused, again } };
110
115
  }
111
116
  function resolveVisit(route, examples) {
112
117
  const needs = dynamicSegments(route);
@@ -422,7 +427,7 @@ function preflight(facts) {
422
427
  name: "posture",
423
428
  ok: true,
424
429
  blocking: false,
425
- detail: facts.clips ? "read-only for the screenshots; after them, the clips the seed command named press the controls their steps name, in the tenant it built" : "read-only: this navigates and photographs, and activates no control"
430
+ detail: facts.clips ? "read-only for the screenshots; after them, the clips the seed command named and the articles ask for press the controls their steps name, in the tenant it built" : "read-only: this navigates and photographs, and activates no control"
426
431
  });
427
432
  if (facts.rebaselineReason) {
428
433
  checks.push({
@@ -1298,7 +1303,9 @@ async function phoneFit(page, width) {
1298
1303
  if (content > laid + 1) return { fits: false, because: `it runs ${Math.round(content - laid)}px off the side` };
1299
1304
  const main2 = document.querySelector("main, [role=main]");
1300
1305
  const column = main2 ? main2.getBoundingClientRect().width : laid;
1301
- return column >= laid * 0.75 ? { fits: true } : { fits: false, because: `its main column is ${Math.round(column)}px of ${laid}px, beside the rest of the layout` };
1306
+ if (column < laid * 0.75) return { fits: false, because: `its main column is ${Math.round(column)}px of ${laid}px, beside the rest of the layout` };
1307
+ const overflow = main2 ? main2.scrollWidth - main2.clientWidth : 0;
1308
+ return overflow > 1 ? { fits: false, because: `its content runs ${Math.round(overflow)}px past its main column` } : { fits: true };
1302
1309
  },
1303
1310
  { width }
1304
1311
  )
@@ -1336,6 +1343,10 @@ import { createHash } from "node:crypto";
1336
1343
  var LAYOUT_MANIFEST_SCHEMA = "helpdesk.layout-manifest/1";
1337
1344
  var PHONE_SUFFIX = "--phone";
1338
1345
  var PHONE_VIEWPORT = { width: 390, height: 844 };
1346
+ var PHONE_VIEWPORTS = [PHONE_VIEWPORT, { width: 430, height: 932 }];
1347
+ function phoneViewport(width) {
1348
+ return PHONE_VIEWPORTS.find((viewport) => viewport.width === width) ?? PHONE_VIEWPORT;
1349
+ }
1339
1350
  var CONTROL_ROLES = [
1340
1351
  "button",
1341
1352
  "link",
@@ -1511,13 +1522,21 @@ function layoutManifestText(manifest) {
1511
1522
  // core/capture/clips.ts
1512
1523
  var CLIP_VIEWPORT = { width: 1280, height: 800 };
1513
1524
  var CLIP_FPS = 10;
1514
- var CLIP_MAX_MS = 3e4;
1525
+ var CLIP_MAX_MS = 2e4;
1515
1526
  var CLIP_MAX_STEPS = 12;
1516
1527
  var CLIP_MAX_MP4_BYTES = 3 * 1024 * 1024;
1517
1528
  var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
1518
1529
  var CLIP_GIF_WIDTH = 960;
1519
- var CLIP_ACTIONS = ["click", "fill", "select", "check"];
1520
- var MAX_SCRIPTS = 50;
1530
+ var CLIP_ACTIONS = ["click", "fill", "select", "check", "auto"];
1531
+ function actionForRole(role, value) {
1532
+ if (role === "textbox" || role === "searchbox") return "fill";
1533
+ if ((role === "combobox" || role === "listbox") && value) return "select";
1534
+ if (role === "checkbox" || role === "radio" || role === "switch") return "check";
1535
+ return "click";
1536
+ }
1537
+ var MAX_SEEDED_SCRIPTS = 50;
1538
+ var CLIP_MAX_ARTICLE_SCRIPTS = 400;
1539
+ var CLIP_MAX_LEAD_STEPS = 2 * CLIP_MAX_STEPS;
1521
1540
  var MAX_TEXT2 = 200;
1522
1541
  var MAX_TITLE = 120;
1523
1542
  var MAX_ID = 80;
@@ -1540,21 +1559,40 @@ function readStep(value, at) {
1540
1559
  const says = cleanText(step["says"]);
1541
1560
  const given = step["value"];
1542
1561
  if (!route || !isPathLike(route)) return `step ${at} names no screen`;
1543
- if (typeof role !== "string" || !CONTROL_ROLES.includes(role)) return `step ${at} names no control role a screen lists`;
1562
+ if (role !== void 0 && (typeof role !== "string" || !CONTROL_ROLES.includes(role))) return `step ${at} names no control role a screen lists`;
1544
1563
  if (!name || name.length > MAX_TEXT2) return `step ${at} names no control`;
1545
- if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select nor check`;
1564
+ if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select, check nor auto`;
1546
1565
  if (!says || says.length > MAX_TEXT2) return `step ${at} has no sentence for a reader`;
1566
+ if (role === void 0 && action !== "auto") return `step ${at} says how to act on a control without saying what kind it is`;
1547
1567
  const typed = action === "fill" || action === "select";
1548
- if (typed && (typeof given !== "string" || !given.trim() || given.length > MAX_TEXT2)) return `step ${at} has nothing to ${action}`;
1549
- if (!typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
1550
- return { route, control: { role, name }, action, ...typed ? { value: given } : {}, says };
1568
+ const valued = typeof given === "string" && given.trim() !== "" && given.length <= MAX_TEXT2;
1569
+ if (typed && !valued) return `step ${at} has nothing to ${action}`;
1570
+ if (action !== "auto" && !typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
1571
+ if (action === "auto" && given !== void 0 && !valued) return `step ${at} has a value nothing can type`;
1572
+ return {
1573
+ route,
1574
+ control: { ...role === void 0 ? {} : { role }, name },
1575
+ action,
1576
+ ...valued && (typed || action === "auto") ? { value: given } : {},
1577
+ says
1578
+ };
1579
+ }
1580
+ function readSteps(raw) {
1581
+ const steps = [];
1582
+ for (const [index, value] of raw.entries()) {
1583
+ const step = readStep(value, index + 1);
1584
+ if (typeof step === "string") return step;
1585
+ steps.push(step);
1586
+ }
1587
+ return steps;
1551
1588
  }
1552
- function readClipScripts(raw) {
1589
+ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
1553
1590
  const scripts = [];
1554
1591
  const refused = [];
1555
1592
  if (raw === void 0) return { scripts, refused };
1556
1593
  if (!Array.isArray(raw)) return { scripts, refused: [{ id: "clips", because: "clips is not a list" }] };
1557
- for (const value of raw.slice(0, MAX_SCRIPTS)) {
1594
+ if (raw.length > limit) refused.push({ id: "clips", because: `${raw.length - limit} clip(s) past the first ${limit} were not read` });
1595
+ for (const value of raw.slice(0, limit)) {
1558
1596
  const script = recordOf2(value);
1559
1597
  const id = typeof script["id"] === "string" ? script["id"] : "";
1560
1598
  const label = id || "a clip";
@@ -1579,21 +1617,18 @@ function readClipScripts(raw) {
1579
1617
  refuse(`it has ${rawSteps.length} steps, and a clip has between 1 and ${CLIP_MAX_STEPS}`);
1580
1618
  continue;
1581
1619
  }
1582
- const steps = [];
1583
- let problem = "";
1584
- for (const [index, rawStep] of rawSteps.entries()) {
1585
- const step = readStep(rawStep, index + 1);
1586
- if (typeof step === "string") {
1587
- problem = step;
1588
- break;
1589
- }
1590
- steps.push(step);
1620
+ const rawBefore = script["before"];
1621
+ if (rawBefore !== void 0 && (!Array.isArray(rawBefore) || rawBefore.length > CLIP_MAX_LEAD_STEPS)) {
1622
+ refuse(`its steps before the recording are not a list of at most ${CLIP_MAX_LEAD_STEPS}`);
1623
+ continue;
1591
1624
  }
1592
- if (problem) {
1593
- refuse(problem);
1625
+ const steps = readSteps(rawSteps);
1626
+ const before = readSteps(rawBefore ?? []);
1627
+ if (typeof steps === "string" || typeof before === "string") {
1628
+ refuse(typeof before === "string" ? `before the recording, ${before}` : steps);
1594
1629
  continue;
1595
1630
  }
1596
- const first = steps[0];
1631
+ const first = before[0] ?? steps[0];
1597
1632
  if (!isPathLike(visit) || !fillsPattern(first.route, visit.split("?")[0])) {
1598
1633
  refuse(`it opens at ${visit || "nowhere"}, which is not ${first.route}`);
1599
1634
  continue;
@@ -1602,17 +1637,19 @@ function readClipScripts(raw) {
1602
1637
  refuse("it names no screen to land on");
1603
1638
  continue;
1604
1639
  }
1605
- scripts.push({ id, title, visit, steps, ...lands ? { lands } : {} });
1640
+ scripts.push({ id, title, visit, ...before.length ? { before } : {}, steps, ...lands ? { lands } : {} });
1606
1641
  }
1607
1642
  return { scripts, refused };
1608
1643
  }
1609
1644
  function resolveStep(manifest, step) {
1610
1645
  const name = cleanText(step.control.name).toLowerCase();
1611
1646
  const where = manifest.screen.path;
1612
- const matches = manifest.controls.filter((control) => control.role === step.control.role && control.name.toLowerCase() === name);
1613
- if (matches.length === 0) return { ok: false, because: `no ${step.control.role} "${step.control.name}" on ${where}` };
1614
- if (matches.length > 1) return { ok: false, because: `${matches.length} of ${step.control.role} "${step.control.name}" on ${where}, so the step does not say which` };
1615
- if (matches[0].state?.disabled) return { ok: false, because: `${step.control.role} "${step.control.name}" on ${where} is disabled` };
1647
+ const role = step.control.role;
1648
+ const what = role ?? "control";
1649
+ const matches = manifest.controls.filter((control) => (!role || control.role === role) && control.name.toLowerCase() === name);
1650
+ if (matches.length === 0) return { ok: false, because: `no ${what} "${step.control.name}" on ${where}` };
1651
+ if (matches.length > 1) return { ok: false, because: `${matches.length} of ${what} "${step.control.name}" on ${where}, so the step does not say which` };
1652
+ if (matches[0].state?.disabled) return { ok: false, because: `${what} "${step.control.name}" on ${where} is disabled` };
1616
1653
  return { ok: true, control: matches[0] };
1617
1654
  }
1618
1655
  function onScreen(route, location2) {
@@ -1622,19 +1659,22 @@ function clipRoutes(script) {
1622
1659
  return [.../* @__PURE__ */ new Set([...script.steps.map((step) => step.route), ...script.lands ? [script.lands] : []])];
1623
1660
  }
1624
1661
  function clipFingerprint(script, touched) {
1662
+ const stepsOf = (steps) => steps.map((step) => ({
1663
+ route: step.route,
1664
+ role: step.control.role ?? null,
1665
+ name: step.control.name,
1666
+ action: step.action,
1667
+ value: step.value ?? null,
1668
+ says: step.says
1669
+ }));
1625
1670
  const canonical = {
1626
1671
  version: 1,
1627
1672
  id: script.id,
1628
1673
  title: script.title,
1629
1674
  lands: script.lands ?? null,
1630
- steps: script.steps.map((step) => ({
1631
- route: step.route,
1632
- role: step.control.role,
1633
- name: step.control.name,
1634
- action: step.action,
1635
- value: step.value ?? null,
1636
- says: step.says
1637
- })),
1675
+ // Only when there are some, so a clip with none keeps the fingerprint it had before steps could come first.
1676
+ ...script.before?.length ? { before: stepsOf(script.before) } : {},
1677
+ steps: stepsOf(script.steps),
1638
1678
  touched: [...touched].map((screen) => [screen.route, screen.shotId, screen.sha256]).sort((left, right) => String(left[0]).localeCompare(String(right[0])))
1639
1679
  };
1640
1680
  return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
@@ -2584,7 +2624,7 @@ async function runCapture(options) {
2584
2624
  const evidence = [];
2585
2625
  const build = await options.browser.build?.().catch(() => void 0);
2586
2626
  for (const [index, shot] of options.shots.entries()) {
2587
- const taken = await captureOne(options, shot, build);
2627
+ const taken = shot.variant === "phone" ? await captureAtPhoneWidths(options, shot, build) : await captureOne(options, shot, build);
2588
2628
  const outcome = shot.variant ? { ...taken.outcome, variant: shot.variant } : taken.outcome;
2589
2629
  outcomes.push(outcome);
2590
2630
  if (taken.observed && !shot.variant) survey.push(taken.observed);
@@ -2632,6 +2672,19 @@ async function layoutOf(page, shot, options, cover, size, landed) {
2632
2672
  return void 0;
2633
2673
  }
2634
2674
  }
2675
+ async function captureAtPhoneWidths(options, shot, build) {
2676
+ const tried = [];
2677
+ let taken;
2678
+ for (const { width } of PHONE_VIEWPORTS) {
2679
+ taken = await captureOne(options, { ...shot, phoneWidth: width }, build);
2680
+ if (taken.unfit === void 0) return taken;
2681
+ tried.push(`at ${width}px ${taken.unfit}`);
2682
+ }
2683
+ return {
2684
+ ...taken,
2685
+ outcome: { ...taken.outcome, reason: `not laid out for a phone (${tried.join("; ")}), so there is no phone-width frame` }
2686
+ };
2687
+ }
2635
2688
  async function captureOne(options, shot, build) {
2636
2689
  if (shot.unvisitable) {
2637
2690
  return { outcome: { shotId: shot.id, route: shot.route, status: "skipped", reason: shot.unvisitable } };
@@ -2639,7 +2692,7 @@ async function captureOne(options, shot, build) {
2639
2692
  const visit = shot.visit ?? shot.route;
2640
2693
  let page;
2641
2694
  try {
2642
- page = await options.browser.page(shot.variant ? { variant: shot.variant } : void 0);
2695
+ page = await options.browser.page(shot.variant ? { variant: shot.variant, ...shot.phoneWidth ? { width: shot.phoneWidth } : {} } : void 0);
2643
2696
  await page.goto(new URL(visit, options.baseUrl).toString());
2644
2697
  if (await page.isSignIn()) {
2645
2698
  return {
@@ -2700,9 +2753,10 @@ async function captureOne(options, shot, build) {
2700
2753
  route: shot.route,
2701
2754
  status: "skipped",
2702
2755
  result: observed.result,
2703
- reason: `not laid out for a phone at ${PHONE_VIEWPORT.width}px (${fit.because}), so there is no phone-width frame`
2756
+ reason: `not laid out for a phone at ${phoneViewport(shot.phoneWidth).width}px (${fit.because}), so there is no phone-width frame`
2704
2757
  },
2705
- observed
2758
+ observed,
2759
+ unfit: fit.because
2706
2760
  };
2707
2761
  }
2708
2762
  const evidence = shot.variant ? void 0 : await readEvidence(settledPage, shot.route, options, build, visit);
@@ -2769,7 +2823,7 @@ async function captureOne(options, shot, build) {
2769
2823
  });
2770
2824
  const image = frame.value;
2771
2825
  const size = pngSize(image);
2772
- const minimum = shot.variant === "phone" ? PHONE_VIEWPORT.width * CAPTURE_CONTRACT.stillScale : CAPTURE_CONTRACT.minStillWidth;
2826
+ const minimum = shot.variant === "phone" ? phoneViewport(shot.phoneWidth).width * CAPTURE_CONTRACT.stillScale : CAPTURE_CONTRACT.minStillWidth;
2773
2827
  if (size && size.width < minimum) {
2774
2828
  return {
2775
2829
  outcome: {
@@ -2988,8 +3042,9 @@ async function playwrightBrowser(options = {}) {
2988
3042
  };
2989
3043
  return {
2990
3044
  async page(wanted) {
3045
+ const phone = phoneViewport(wanted?.width);
2991
3046
  const context = await browser.newContext(
2992
- wanted?.variant === "phone" ? { ...contextOptions, viewport: { ...PHONE_VIEWPORT }, isMobile: true, hasTouch: true } : wanted?.variant === "clip" ? { ...contextOptions, viewport: { ...CLIP_VIEWPORT }, deviceScaleFactor: CAPTURE_CONTRACT.clipScale } : contextOptions
3047
+ wanted?.variant === "phone" ? { ...contextOptions, viewport: { ...phone }, isMobile: true, hasTouch: true } : wanted?.variant === "clip" ? { ...contextOptions, viewport: { ...CLIP_VIEWPORT }, deviceScaleFactor: CAPTURE_CONTRACT.clipScale } : contextOptions
2993
3048
  );
2994
3049
  if (!options.liveClock) {
2995
3050
  await context.addInitScript(freezeScript(CAPTURE_CONTRACT.pinnedClock));
@@ -3068,7 +3123,7 @@ async function playwrightBrowser(options = {}) {
3068
3123
  return readPageLayout(page, cover ?? []);
3069
3124
  },
3070
3125
  async phoneFit() {
3071
- return phoneFit(page, PHONE_VIEWPORT.width).catch(() => ({ fits: false, because: "the page could not be measured at that width" }));
3126
+ return phoneFit(page, phone.width).catch(() => ({ fits: false, because: "the page could not be measured at that width" }));
3072
3127
  },
3073
3128
  async location() {
3074
3129
  const url = new URL(page.url());
@@ -3330,6 +3385,7 @@ var MOVE_FRAMES = 6;
3330
3385
  var DWELL_MS = 500;
3331
3386
  var PRESS_MS = 200;
3332
3387
  var CHARS_PER_FRAME = 2;
3388
+ var TYPING_FRAMES = 12;
3333
3389
  var SETTLE_MS = 3e3;
3334
3390
  var STEP_WAIT_MS = 4e3;
3335
3391
  var CLOSING_MS = 1200;
@@ -3396,11 +3452,75 @@ async function recordClip(options, script, touched) {
3396
3452
  await keep(next, FRAME_MS);
3397
3453
  return true;
3398
3454
  };
3455
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3456
+ const measureOn = async (step, where) => resolveStep(
3457
+ buildLayoutManifest(await layout(cover), {
3458
+ shotId: script.id,
3459
+ route: step.route,
3460
+ path: where.split("?")[0],
3461
+ variant: "desktop",
3462
+ image: { ...CLIP_VIEWPORT },
3463
+ origin: new URL(options.baseUrl).origin
3464
+ }),
3465
+ step
3466
+ );
3467
+ const steady = async () => {
3468
+ let previous = await page.screenshot(cover);
3469
+ for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3470
+ await pause(FRAME_MS);
3471
+ const next = await page.screenshot(cover);
3472
+ if (sameBytes(next, previous)) return;
3473
+ previous = next;
3474
+ }
3475
+ };
3476
+ const reach = async (route) => {
3477
+ let where = await location2();
3478
+ for (let waited = 0; !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3479
+ await pause(FRAME_MS);
3480
+ where = await location2();
3481
+ }
3482
+ if (onScreen(route, where)) {
3483
+ await page.quiet?.(SETTLE_MS);
3484
+ await steady();
3485
+ }
3486
+ return where;
3487
+ };
3488
+ const lead = async (step) => {
3489
+ const where = await reach(step.route);
3490
+ if (!onScreen(step.route, where)) return `the browser is on ${where}, not ${step.route}`;
3491
+ let resolution = await measureOn(step, where);
3492
+ for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3493
+ await pause(FRAME_MS);
3494
+ resolution = await measureOn(step, where);
3495
+ }
3496
+ if (!resolution.ok) return resolution.because;
3497
+ const target = centreOf(resolution.control.box);
3498
+ const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
3499
+ if (action === "fill") {
3500
+ if (!step.value) return `it types into ${resolution.control.name}, and says nothing to type`;
3501
+ await pointer({ kind: "focus", at: target });
3502
+ await type(step.value);
3503
+ } else if (action === "select") {
3504
+ await pointer({ kind: "select", at: target, value: step.value ?? "" });
3505
+ } else {
3506
+ await pointer({ kind: "click", at: target });
3507
+ }
3508
+ await page.quiet?.(SETTLE_MS);
3509
+ await steady();
3510
+ return null;
3511
+ };
3399
3512
  await page.goto(new URL(script.visit, options.baseUrl).toString());
3400
3513
  if (await page.isSignIn()) return refuse("its first screen asked for a sign-in, so this session is not the demo tenant's");
3401
3514
  const opened = await location2();
3402
- if (!onScreen(script.steps[0].route, opened)) return fail(`it opened on ${opened}, not ${script.steps[0].route}`);
3403
- const before = await judge(opened);
3515
+ const performedFirst = script.before?.[0] ?? script.steps[0];
3516
+ if (!onScreen(performedFirst.route, opened)) return fail(`it opened on ${opened}, not ${performedFirst.route}`);
3517
+ for (const [index, step] of (script.before ?? []).entries()) {
3518
+ const problem = await lead(step);
3519
+ if (problem) return fail(`before the recording, step ${index + 1}: ${problem}`);
3520
+ }
3521
+ const starting = script.before?.length ? await reach(script.steps[0].route) : opened;
3522
+ if (!onScreen(script.steps[0].route, starting)) return fail(`once the steps before it were done, the browser is on ${starting}, not ${script.steps[0].route}`);
3523
+ const before = await judge(starting);
3404
3524
  if (before) return refuse(before);
3405
3525
  let resting = START;
3406
3526
  let poster;
@@ -3427,26 +3547,17 @@ async function recordClip(options, script, touched) {
3427
3547
  if (!onScreen(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
3428
3548
  const arrived = await judge(where);
3429
3549
  if (arrived) return refuse(arrived);
3430
- const measure = async () => resolveStep(
3431
- buildLayoutManifest(await layout(cover), {
3432
- shotId: script.id,
3433
- route: step.route,
3434
- path: where.split("?")[0],
3435
- variant: "desktop",
3436
- image: { ...CLIP_VIEWPORT },
3437
- origin: new URL(options.baseUrl).origin
3438
- }),
3439
- step
3440
- );
3441
- let resolution = await measure();
3550
+ let resolution = await measureOn(step, where);
3442
3551
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3443
3552
  await cursor(resting);
3444
3553
  await film();
3445
- resolution = await measure();
3554
+ resolution = await measureOn(step, where);
3446
3555
  }
3447
3556
  if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
3448
3557
  const target = centreOf(resolution.control.box);
3449
3558
  const startedAt = at;
3559
+ const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
3560
+ if (action === "fill" && !step.value) return fail(`step ${index + 1}: it types into ${resolution.control.name}, and says nothing to type`);
3450
3561
  for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
3451
3562
  await cursor(point);
3452
3563
  await shoot(FRAME_MS);
@@ -3456,14 +3567,15 @@ async function recordClip(options, script, touched) {
3456
3567
  poster ??= frames[frames.length - 1].file;
3457
3568
  await cursor(target, true);
3458
3569
  await shoot(PRESS_MS);
3459
- if (step.action === "fill") {
3570
+ if (action === "fill") {
3571
+ const value = step.value ?? "";
3460
3572
  await pointer({ kind: "focus", at: target });
3461
3573
  await cursor(target, false);
3462
- for (const chunk of chunksOf(step.value ?? "", CHARS_PER_FRAME)) {
3574
+ for (const chunk of chunksOf(value, Math.max(CHARS_PER_FRAME, Math.ceil(value.length / TYPING_FRAMES)))) {
3463
3575
  await type(chunk);
3464
3576
  await shoot(FRAME_MS);
3465
3577
  }
3466
- } else if (step.action === "select") {
3578
+ } else if (action === "select") {
3467
3579
  await pointer({ kind: "select", at: target, value: step.value ?? "" });
3468
3580
  } else {
3469
3581
  await pointer({ kind: "click", at: target });
@@ -3481,9 +3593,9 @@ async function recordClip(options, script, touched) {
3481
3593
  if (after) return refuse(after);
3482
3594
  steps.push({
3483
3595
  route: step.route,
3484
- role: step.control.role,
3596
+ role: resolution.control.role,
3485
3597
  name: resolution.control.name,
3486
- action: step.action,
3598
+ action,
3487
3599
  says: step.says,
3488
3600
  atMs: startedAt,
3489
3601
  box: resolution.control.box
@@ -3533,7 +3645,7 @@ async function recordClip(options, script, touched) {
3533
3645
  await rm(dir, { recursive: true, force: true }).catch(() => void 0);
3534
3646
  }
3535
3647
  }
3536
- var CLIP_BUDGET_MS = 10 * 6e4;
3648
+ var CLIP_BUDGET_MS = 20 * 6e4;
3537
3649
  async function runClips(options) {
3538
3650
  const started = Date.now();
3539
3651
  const outcomes = [];
@@ -4669,11 +4781,18 @@ function shotsFromRepo(repo, previous) {
4669
4781
  }))
4670
4782
  );
4671
4783
  }
4672
- function withPhoneShots(shots, previous) {
4784
+ function withPhoneShots(shots, previous, again = /* @__PURE__ */ new Set()) {
4673
4785
  const planned = new Set(shots.map((shot) => shot.id));
4674
- const phones = shots.filter((shot) => !shot.visit && !shot.unvisitable && !shot.variant && !planned.has(`${shot.id}${PHONE_SUFFIX}`)).map((shot) => {
4786
+ const phones = shots.filter((shot) => (!shot.visit || again.has(shot.route)) && !shot.unvisitable && !shot.variant && !planned.has(`${shot.id}${PHONE_SUFFIX}`)).map((shot) => {
4675
4787
  const id = `${shot.id}${PHONE_SUFFIX}`;
4676
- return { id, route: shot.route, depicts: `${shot.depicts}, at phone width`, variant: "phone", ...seenBefore(previous.get(id)) };
4788
+ return {
4789
+ id,
4790
+ route: shot.route,
4791
+ ...shot.visit ? { visit: shot.visit } : {},
4792
+ depicts: `${shot.depicts}, at phone width`,
4793
+ variant: "phone",
4794
+ ...seenBefore(previous.get(id))
4795
+ };
4677
4796
  });
4678
4797
  return [...shots, ...phones];
4679
4798
  }
@@ -4734,6 +4853,28 @@ async function writeLocalClip(dir, clip) {
4734
4853
  durationMs: clip.recording.durationMs
4735
4854
  };
4736
4855
  }
4856
+ function articleClipsToRecord(raw, examples) {
4857
+ if (!Array.isArray(raw)) return readClipScripts(raw, CLIP_MAX_ARTICLE_SCRIPTS);
4858
+ const named = examples ?? { paths: /* @__PURE__ */ new Map(), refused: [] };
4859
+ const unopened = [];
4860
+ const opened = raw.map((value) => {
4861
+ const script = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
4862
+ const visit = typeof script["visit"] === "string" ? script["visit"] : "";
4863
+ const resolved = resolveVisit(visit, named);
4864
+ if (!("needs" in resolved)) return { ...script, visit: resolved.visit };
4865
+ unopened.push({ id: typeof script["id"] === "string" ? script["id"] : "a clip", because: describeMissingExample(visit, resolved.needs) });
4866
+ return void 0;
4867
+ });
4868
+ const read = readClipScripts(
4869
+ opened.filter((value) => value !== void 0),
4870
+ CLIP_MAX_ARTICLE_SCRIPTS
4871
+ );
4872
+ return { scripts: read.scripts, refused: [...unopened, ...read.refused] };
4873
+ }
4874
+ function clipsToRecord(seeded, articles) {
4875
+ const named = new Set(seeded.map((script) => script.id));
4876
+ return [...seeded, ...articles.filter((script) => !named.has(script.id))];
4877
+ }
4737
4878
  function clipForm(clip) {
4738
4879
  const id = clip.recording.clipId;
4739
4880
  const form = new FormData();
@@ -4751,9 +4892,18 @@ async function clipsOnDisk(dir) {
4751
4892
  return /* @__PURE__ */ new Map();
4752
4893
  }
4753
4894
  }
4895
+ function unrecordedLines(refused, skipped) {
4896
+ const lines = [
4897
+ ...skipped.map((piece) => ` ${piece.slug ? `${piece.slug}${piece.at ? ` at ${piece.at}` : ""}` : "The help desk"}: ${piece.because}`),
4898
+ ...refused.map((entry) => ` ${entry.id}: ${entry.because}`)
4899
+ ];
4900
+ if (!lines.length) return [];
4901
+ const shown = lines.slice(0, 12);
4902
+ return [`${lines.length} piece(s) of the articles' steps are not recorded:`, ...shown, ...lines.length > shown.length ? [` and ${lines.length - shown.length} more.`] : []];
4903
+ }
4754
4904
  async function recordSeededClips(args) {
4755
4905
  if (!args.scripts.length) {
4756
- console.log("\n--clips was given, but the seed command named no clips, so none were recorded.");
4906
+ console.log("\n--clips was given, but the seed command named no clips and no article asks for one, so none were recorded.");
4757
4907
  return [];
4758
4908
  }
4759
4909
  if (!await ffmpegAvailable()) {
@@ -4766,9 +4916,12 @@ async function recordSeededClips(args) {
4766
4916
  );
4767
4917
  const previous = args.local ? await clipsOnDisk(args.local) : /* @__PURE__ */ new Map();
4768
4918
  const written = [];
4769
- console.log(`
4770
- The seed command named ${args.scripts.length} clip(s). Each is recorded in the demo tenant it built when it is new or something it shows has changed.
4771
- `);
4919
+ const asked = args.scripts.length - args.named;
4920
+ console.log(
4921
+ `
4922
+ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how-to and troubleshooting articles ask for ${asked} more, one for each piece of their steps` : ""}. Each is recorded in the demo tenant the seed built when it is new or something it shows has changed.
4923
+ `
4924
+ );
4772
4925
  const outcomes = await runClips({
4773
4926
  baseUrl: args.baseUrl,
4774
4927
  browser: args.browser,
@@ -4943,7 +5096,7 @@ Approve one on the plan page, then run this again.`
4943
5096
  }
4944
5097
  }
4945
5098
  const withRecords = seeded?.examples ? withExamples(shots, seeded.examples) : shots;
4946
- const planned = seededLast(parsed.phone ? withPhoneShots(withRecords, previous) : withRecords);
5099
+ const planned = seededLast(parsed.phone ? withPhoneShots(withRecords, previous, seeded?.examples?.again) : withRecords);
4947
5100
  if (parsed.cassette) {
4948
5101
  try {
4949
5102
  stage = await stageAgainstRecording({
@@ -4993,7 +5146,7 @@ Approve one on the plan page, then run this again.`
4993
5146
  console.log(`${coverageReport(found, shots.length).join("\n")}
4994
5147
  `);
4995
5148
  }
4996
- const phones = parsed.phone ? `, each opened at its own address again at ${PHONE_VIEWPORT.width}px wide if it is laid out for a phone` : "";
5149
+ const phones = parsed.phone ? `, each opened at its own address again at phone width (${PHONE_VIEWPORTS.map((viewport) => `${viewport.width}px`).join(", then ")}) if it is laid out for a phone` : "";
4997
5150
  console.log(
4998
5151
  stage ? `Photographing ${shots.length} screen(s) of ${baseUrl}${phones}, served from ${parsed.cassette}.
4999
5152
  ` : `Photographing ${shots.length} screen(s) of ${baseUrl}${phones}.
@@ -5109,8 +5262,19 @@ ${unclassified.join("\n")}`);
5109
5262
  Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
5110
5263
  }
5111
5264
  if (parsed.clips) {
5265
+ const named = seeded?.clips ?? [];
5266
+ const articles = seeded ? articleClipsToRecord(orders.clipScripts, seeded.examples) : { scripts: [], refused: [] };
5267
+ const asked = Array.isArray(orders.clipScripts) ? orders.clipScripts.length : 0;
5268
+ if (!seeded && asked) {
5269
+ console.log(`
5270
+ The help desk's articles ask for ${asked} clip(s), and none is recorded: clips are recorded only in a demo tenant a --seed-command built.`);
5271
+ }
5272
+ const unrecorded = seeded ? unrecordedLines(articles.refused, Array.isArray(orders.clipsSkipped) ? orders.clipsSkipped : []) : [];
5273
+ if (unrecorded.length) console.log(`
5274
+ ${unrecorded.join("\n")}`);
5112
5275
  await recordSeededClips({
5113
- scripts: seeded?.clips ?? [],
5276
+ scripts: clipsToRecord(named, articles.scripts),
5277
+ named: named.length,
5114
5278
  browser,
5115
5279
  baseUrl,
5116
5280
  clearance,
@@ -5274,13 +5438,16 @@ if (runningAsCommand(import.meta.url)) {
5274
5438
  );
5275
5439
  }
5276
5440
  export {
5441
+ articleClipsToRecord,
5277
5442
  clipForm,
5443
+ clipsToRecord,
5278
5444
  frameForm,
5279
5445
  framesOnDisk,
5280
5446
  main,
5281
5447
  onePerScreen,
5282
5448
  seededLast,
5283
5449
  shotsFromRepo,
5450
+ unrecordedLines,
5284
5451
  withExamples,
5285
5452
  withPhoneShots,
5286
5453
  writeLocalClip,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {