@olomonhelpdesk2/helpdesk 0.1.14 → 0.1.17

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 +300 -71
  2. package/package.json +1 -1
@@ -427,7 +427,7 @@ function preflight(facts) {
427
427
  name: "posture",
428
428
  ok: true,
429
429
  blocking: false,
430
- 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"
431
431
  });
432
432
  if (facts.rebaselineReason) {
433
433
  checks.push({
@@ -1522,13 +1522,21 @@ function layoutManifestText(manifest) {
1522
1522
  // core/capture/clips.ts
1523
1523
  var CLIP_VIEWPORT = { width: 1280, height: 800 };
1524
1524
  var CLIP_FPS = 10;
1525
- var CLIP_MAX_MS = 3e4;
1525
+ var CLIP_MAX_MS = 2e4;
1526
1526
  var CLIP_MAX_STEPS = 12;
1527
1527
  var CLIP_MAX_MP4_BYTES = 3 * 1024 * 1024;
1528
1528
  var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
1529
1529
  var CLIP_GIF_WIDTH = 960;
1530
- var CLIP_ACTIONS = ["click", "fill", "select", "check"];
1531
- 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;
1532
1540
  var MAX_TEXT2 = 200;
1533
1541
  var MAX_TITLE = 120;
1534
1542
  var MAX_ID = 80;
@@ -1551,21 +1559,44 @@ function readStep(value, at) {
1551
1559
  const says = cleanText(step["says"]);
1552
1560
  const given = step["value"];
1553
1561
  if (!route || !isPathLike(route)) return `step ${at} names no screen`;
1554
- 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`;
1555
1563
  if (!name || name.length > MAX_TEXT2) return `step ${at} names no control`;
1556
- if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select nor check`;
1564
+ const scope = control["scope"] === void 0 ? void 0 : cleanText(control["scope"]);
1565
+ if (scope !== void 0 && (!scope || scope.length > MAX_TEXT2)) return `step ${at} names a region of the screen with no name`;
1566
+ if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select, check nor auto`;
1557
1567
  if (!says || says.length > MAX_TEXT2) return `step ${at} has no sentence for a reader`;
1568
+ if (role === void 0 && action !== "auto") return `step ${at} says how to act on a control without saying what kind it is`;
1558
1569
  const typed = action === "fill" || action === "select";
1559
- if (typed && (typeof given !== "string" || !given.trim() || given.length > MAX_TEXT2)) return `step ${at} has nothing to ${action}`;
1560
- if (!typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
1561
- return { route, control: { role, name }, action, ...typed ? { value: given } : {}, says };
1570
+ const valued = typeof given === "string" && given.trim() !== "" && given.length <= MAX_TEXT2;
1571
+ if (typed && !valued) return `step ${at} has nothing to ${action}`;
1572
+ if (action !== "auto" && !typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
1573
+ if (action === "auto" && given !== void 0 && !valued) return `step ${at} has a value nothing can type`;
1574
+ if (step["follows"] !== void 0 && step["follows"] !== true) return `step ${at} says neither that it follows the step before it nor that it does not`;
1575
+ return {
1576
+ route,
1577
+ control: { ...role === void 0 ? {} : { role }, name, ...scope ? { scope } : {} },
1578
+ action,
1579
+ ...valued && (typed || action === "auto") ? { value: given } : {},
1580
+ says,
1581
+ ...step["follows"] === true ? { follows: true } : {}
1582
+ };
1583
+ }
1584
+ function readSteps(raw) {
1585
+ const steps = [];
1586
+ for (const [index, value] of raw.entries()) {
1587
+ const step = readStep(value, index + 1);
1588
+ if (typeof step === "string") return step;
1589
+ steps.push(step);
1590
+ }
1591
+ return steps;
1562
1592
  }
1563
- function readClipScripts(raw) {
1593
+ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
1564
1594
  const scripts = [];
1565
1595
  const refused = [];
1566
1596
  if (raw === void 0) return { scripts, refused };
1567
1597
  if (!Array.isArray(raw)) return { scripts, refused: [{ id: "clips", because: "clips is not a list" }] };
1568
- for (const value of raw.slice(0, MAX_SCRIPTS)) {
1598
+ if (raw.length > limit) refused.push({ id: "clips", because: `${raw.length - limit} clip(s) past the first ${limit} were not read` });
1599
+ for (const value of raw.slice(0, limit)) {
1569
1600
  const script = recordOf2(value);
1570
1601
  const id = typeof script["id"] === "string" ? script["id"] : "";
1571
1602
  const label = id || "a clip";
@@ -1590,40 +1621,86 @@ function readClipScripts(raw) {
1590
1621
  refuse(`it has ${rawSteps.length} steps, and a clip has between 1 and ${CLIP_MAX_STEPS}`);
1591
1622
  continue;
1592
1623
  }
1593
- const steps = [];
1594
- let problem = "";
1595
- for (const [index, rawStep] of rawSteps.entries()) {
1596
- const step = readStep(rawStep, index + 1);
1597
- if (typeof step === "string") {
1598
- problem = step;
1599
- break;
1600
- }
1601
- steps.push(step);
1624
+ const rawBefore = script["before"];
1625
+ if (rawBefore !== void 0 && (!Array.isArray(rawBefore) || rawBefore.length > CLIP_MAX_LEAD_STEPS)) {
1626
+ refuse(`its steps before the recording are not a list of at most ${CLIP_MAX_LEAD_STEPS}`);
1627
+ continue;
1602
1628
  }
1603
- if (problem) {
1604
- refuse(problem);
1629
+ const steps = readSteps(rawSteps);
1630
+ const before = readSteps(rawBefore ?? []);
1631
+ if (typeof steps === "string" || typeof before === "string") {
1632
+ refuse(typeof before === "string" ? `before the recording, ${before}` : steps);
1605
1633
  continue;
1606
1634
  }
1607
- const first = steps[0];
1635
+ const first = before[0] ?? steps[0];
1608
1636
  if (!isPathLike(visit) || !fillsPattern(first.route, visit.split("?")[0])) {
1609
1637
  refuse(`it opens at ${visit || "nowhere"}, which is not ${first.route}`);
1610
1638
  continue;
1611
1639
  }
1640
+ if (first.follows) {
1641
+ refuse("its first step follows no step before it, and is the screen it opens at");
1642
+ continue;
1643
+ }
1612
1644
  if (lands !== void 0 && !isPathLike(lands)) {
1613
1645
  refuse("it names no screen to land on");
1614
1646
  continue;
1615
1647
  }
1616
- scripts.push({ id, title, visit, steps, ...lands ? { lands } : {} });
1648
+ scripts.push({ id, title, visit, ...before.length ? { before } : {}, steps, ...lands ? { lands } : {} });
1617
1649
  }
1618
1650
  return { scripts, refused };
1619
1651
  }
1652
+ function comparable(text2) {
1653
+ return cleanText(text2).toLowerCase().replace(/(…|\.\.\.)$/, "").trim();
1654
+ }
1655
+ var CHROME_ROLES = ["navigation", "complementary", "banner", "contentinfo"];
1656
+ var IN_CHROME = /\b(sidebar|side bar|menu|navigation|nav bar|top bar|header)\b/i;
1657
+ function within(manifest, control, regionIds) {
1658
+ const regions = new Map(manifest.regions.map((region) => [region.id, region]));
1659
+ const seen = /* @__PURE__ */ new Set();
1660
+ for (let id = control.region; id && !seen.has(id); id = regions.get(id)?.parent) {
1661
+ seen.add(id);
1662
+ if (regionIds.has(id)) return true;
1663
+ }
1664
+ return false;
1665
+ }
1666
+ function namesControl(shown, named) {
1667
+ const have = comparable(shown);
1668
+ const want = comparable(named);
1669
+ return Boolean(want) && (have === want || have.startsWith(`${want} `));
1670
+ }
1671
+ function inChrome(manifest, control) {
1672
+ const regions = new Map(manifest.regions.map((region) => [region.id, region]));
1673
+ const seen = /* @__PURE__ */ new Set();
1674
+ for (let id = control.region; id && !seen.has(id); id = regions.get(id)?.parent) {
1675
+ seen.add(id);
1676
+ if (CHROME_ROLES.includes(regions.get(id)?.role ?? "")) return true;
1677
+ }
1678
+ return false;
1679
+ }
1620
1680
  function resolveStep(manifest, step) {
1621
- const name = cleanText(step.control.name).toLowerCase();
1681
+ const name = comparable(step.control.name);
1622
1682
  const where = manifest.screen.path;
1623
- const matches = manifest.controls.filter((control) => control.role === step.control.role && control.name.toLowerCase() === name);
1624
- if (matches.length === 0) return { ok: false, because: `no ${step.control.role} "${step.control.name}" on ${where}` };
1625
- 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` };
1626
- if (matches[0].state?.disabled) return { ok: false, because: `${step.control.role} "${step.control.name}" on ${where} is disabled` };
1683
+ const role = step.control.role;
1684
+ const what = role ?? "control";
1685
+ const candidates = manifest.controls.filter((control) => !role || control.role === role);
1686
+ let matches = candidates.filter((control) => comparable(control.name) === name);
1687
+ if (!role && !matches.length) matches = candidates.filter((control) => namesControl(control.name, step.control.name));
1688
+ const scope = step.control.scope;
1689
+ if (scope && matches.length > 1) {
1690
+ const regions = new Set(manifest.regions.filter((region) => region.id === scope || comparable(region.label) === comparable(scope)).map((region) => region.id));
1691
+ const scoped = matches.filter((control) => within(manifest, control, regions));
1692
+ if (scoped.length) matches = scoped;
1693
+ }
1694
+ if (!role && matches.length > 1) {
1695
+ const chrome = IN_CHROME.test(step.says);
1696
+ const meant = matches.filter((control) => inChrome(manifest, control) === chrome);
1697
+ if (meant.length === 1) matches = meant;
1698
+ }
1699
+ if (matches.length === 0) return { ok: false, because: `no ${what} "${step.control.name}" on ${where}` };
1700
+ if (matches.length > 1) {
1701
+ return { ok: false, because: `${matches.length} of ${what} "${step.control.name}" on ${where}${scope ? ` in ${scope}` : ""}, so the step does not say which` };
1702
+ }
1703
+ if (matches[0].state?.disabled) return { ok: false, because: `${what} "${step.control.name}" on ${where} is disabled` };
1627
1704
  return { ok: true, control: matches[0] };
1628
1705
  }
1629
1706
  function onScreen(route, location2) {
@@ -1633,19 +1710,23 @@ function clipRoutes(script) {
1633
1710
  return [.../* @__PURE__ */ new Set([...script.steps.map((step) => step.route), ...script.lands ? [script.lands] : []])];
1634
1711
  }
1635
1712
  function clipFingerprint(script, touched) {
1713
+ const stepsOf = (steps) => steps.map((step) => ({
1714
+ route: step.route,
1715
+ role: step.control.role ?? null,
1716
+ name: step.control.name,
1717
+ ...step.control.scope ? { scope: step.control.scope } : {},
1718
+ action: step.action,
1719
+ value: step.value ?? null,
1720
+ says: step.says
1721
+ }));
1636
1722
  const canonical = {
1637
1723
  version: 1,
1638
1724
  id: script.id,
1639
1725
  title: script.title,
1640
1726
  lands: script.lands ?? null,
1641
- steps: script.steps.map((step) => ({
1642
- route: step.route,
1643
- role: step.control.role,
1644
- name: step.control.name,
1645
- action: step.action,
1646
- value: step.value ?? null,
1647
- says: step.says
1648
- })),
1727
+ // Only when there are some, so a clip with none keeps the fingerprint it had before steps could come first.
1728
+ ...script.before?.length ? { before: stepsOf(script.before) } : {},
1729
+ steps: stepsOf(script.steps),
1649
1730
  touched: [...touched].map((screen) => [screen.route, screen.shotId, screen.sha256]).sort((left, right) => String(left[0]).localeCompare(String(right[0])))
1650
1731
  };
1651
1732
  return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
@@ -3101,6 +3182,11 @@ async function playwrightBrowser(options = {}) {
3101
3182
  return `${url.pathname}${url.search}`;
3102
3183
  },
3103
3184
  async pointer(action) {
3185
+ if (action.kind === "scroll") {
3186
+ await page.mouse.move(action.at.x, action.at.y);
3187
+ await page.mouse.wheel(0, action.by);
3188
+ return;
3189
+ }
3104
3190
  if (action.kind === "select") {
3105
3191
  const handle = await page.evaluateHandle(({ x, y }) => document.elementFromPoint(x, y), action.at);
3106
3192
  const element = handle.asElement();
@@ -3356,10 +3442,17 @@ var MOVE_FRAMES = 6;
3356
3442
  var DWELL_MS = 500;
3357
3443
  var PRESS_MS = 200;
3358
3444
  var CHARS_PER_FRAME = 2;
3445
+ var TYPING_FRAMES = 12;
3359
3446
  var SETTLE_MS = 3e3;
3360
3447
  var STEP_WAIT_MS = 4e3;
3361
3448
  var CLOSING_MS = 1200;
3449
+ var SCROLL_TO = 0.45;
3450
+ var WHOLE_SHIFT = 1e5;
3362
3451
  var START = { x: Math.round(CLIP_VIEWPORT.width / 2), y: Math.round(CLIP_VIEWPORT.height * 0.66) };
3452
+ function wholePage(raw) {
3453
+ const down = (item) => ({ ...item, box: { ...item.box, y: item.box.y + WHOLE_SHIFT } });
3454
+ return { ...raw, viewport: { width: raw.viewport.width, height: 2 * WHOLE_SHIFT }, regions: raw.regions.map(down), controls: raw.controls.map(down) };
3455
+ }
3363
3456
  function chunksOf(text2, size) {
3364
3457
  return Array.from({ length: Math.ceil(text2.length / size) }, (_, index) => text2.slice(index * size, (index + 1) * size));
3365
3458
  }
@@ -3422,57 +3515,144 @@ async function recordClip(options, script, touched) {
3422
3515
  await keep(next, FRAME_MS);
3423
3516
  return true;
3424
3517
  };
3518
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3519
+ const manifestOf = (raw, step, where) => buildLayoutManifest(raw, {
3520
+ shotId: script.id,
3521
+ route: step.route,
3522
+ path: where.split("?")[0],
3523
+ variant: "desktop",
3524
+ image: { width: raw.viewport.width, height: raw.viewport.height },
3525
+ origin: new URL(options.baseUrl).origin
3526
+ });
3527
+ const steady = async () => {
3528
+ let previous = await page.screenshot(cover);
3529
+ for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3530
+ await pause(FRAME_MS);
3531
+ const next = await page.screenshot(cover);
3532
+ if (sameBytes(next, previous)) return;
3533
+ previous = next;
3534
+ }
3535
+ };
3536
+ let resting = START;
3537
+ const find = async (step, where, filming) => {
3538
+ const raw = await layout(cover);
3539
+ const shown = resolveStep(manifestOf(raw, step, where), step);
3540
+ if (shown.ok) return shown;
3541
+ const whole = resolveStep(manifestOf(wholePage(raw), step, where), step);
3542
+ if (!whole.ok) return shown;
3543
+ const box = whole.control.box;
3544
+ const by = Math.round(box.y - WHOLE_SHIFT + box.height / 2 - CLIP_VIEWPORT.height * SCROLL_TO);
3545
+ const wheel = { x: Math.round(Math.min(Math.max(box.x + box.width / 2, 1), CLIP_VIEWPORT.width - 1)), y: Math.round(CLIP_VIEWPORT.height / 2) };
3546
+ if (Math.abs(by) < 2) return shown;
3547
+ if (filming) {
3548
+ for (const point of cursorPath(resting, wheel, MOVE_FRAMES)) {
3549
+ await cursor(point);
3550
+ await shoot(FRAME_MS);
3551
+ }
3552
+ resting = wheel;
3553
+ }
3554
+ await pointer({ kind: "scroll", at: wheel, by });
3555
+ if (filming) {
3556
+ await cursor(resting);
3557
+ for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3558
+ if (!await film()) break;
3559
+ }
3560
+ } else {
3561
+ await steady();
3562
+ }
3563
+ return resolveStep(manifestOf(await layout(cover), step, where), step);
3564
+ };
3565
+ const reach = async (route) => {
3566
+ let where = await location2();
3567
+ for (let waited = 0; !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3568
+ await pause(FRAME_MS);
3569
+ where = await location2();
3570
+ }
3571
+ if (onScreen(route, where)) {
3572
+ await page.quiet?.(SETTLE_MS);
3573
+ await steady();
3574
+ }
3575
+ return where;
3576
+ };
3577
+ const settled = async () => {
3578
+ await page.quiet?.(SETTLE_MS);
3579
+ await steady();
3580
+ return location2();
3581
+ };
3582
+ const performedOn = (step, where) => onScreen(step.route, where) ? step.route : [...script.before ?? [], ...script.steps].map((each) => each.route).find((route) => onScreen(route, where)) ?? where.split("?")[0];
3583
+ const lead = async (step) => {
3584
+ const where = step.follows ? await settled() : await reach(step.route);
3585
+ if (!step.follows && !onScreen(step.route, where)) return `the browser is on ${where}, not ${step.route}`;
3586
+ let resolution = await find(step, where, false);
3587
+ for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3588
+ await pause(FRAME_MS);
3589
+ resolution = await find(step, where, false);
3590
+ }
3591
+ if (!resolution.ok) return resolution.because;
3592
+ const target = centreOf(resolution.control.box);
3593
+ const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
3594
+ if (action === "fill") {
3595
+ if (!step.value) return `it types into ${resolution.control.name}, and says nothing to type`;
3596
+ await pointer({ kind: "focus", at: target });
3597
+ await type(step.value);
3598
+ } else if (action === "select") {
3599
+ await pointer({ kind: "select", at: target, value: step.value ?? "" });
3600
+ } else {
3601
+ await pointer({ kind: "click", at: target });
3602
+ }
3603
+ await page.quiet?.(SETTLE_MS);
3604
+ await steady();
3605
+ return null;
3606
+ };
3425
3607
  await page.goto(new URL(script.visit, options.baseUrl).toString());
3426
3608
  if (await page.isSignIn()) return refuse("its first screen asked for a sign-in, so this session is not the demo tenant's");
3427
3609
  const opened = await location2();
3428
- if (!onScreen(script.steps[0].route, opened)) return fail(`it opened on ${opened}, not ${script.steps[0].route}`);
3429
- const before = await judge(opened);
3610
+ const performedFirst = script.before?.[0] ?? script.steps[0];
3611
+ if (!onScreen(performedFirst.route, opened)) return fail(`it opened on ${opened}, not ${performedFirst.route}`);
3612
+ for (const [index, step] of (script.before ?? []).entries()) {
3613
+ const problem = await lead(step);
3614
+ if (problem) return fail(`before the recording, step ${index + 1}: ${problem}`);
3615
+ }
3616
+ const first = script.steps[0];
3617
+ const starting = !script.before?.length ? opened : first.follows ? await settled() : await reach(first.route);
3618
+ if (!first.follows && !onScreen(first.route, starting)) return fail(`once the steps before it were done, the browser is on ${starting}, not ${first.route}`);
3619
+ const before = await judge(starting);
3430
3620
  if (before) return refuse(before);
3431
- let resting = START;
3432
3621
  let poster;
3433
3622
  const steps = [];
3434
- const arrive = async (route) => {
3623
+ const arrive = async (route, follows = false) => {
3435
3624
  let where = await location2();
3436
- for (let waited = 0; !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3625
+ for (let waited = 0; !follows && !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3437
3626
  await cursor(resting);
3438
3627
  await film();
3439
3628
  where = await location2();
3440
3629
  }
3441
- if (!onScreen(route, where)) return where;
3630
+ if (!follows && !onScreen(route, where)) return where;
3442
3631
  await page.quiet?.(SETTLE_MS);
3443
3632
  await cursor(resting);
3444
3633
  for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3445
3634
  if (!await film()) break;
3446
3635
  }
3447
- return where;
3636
+ return follows ? location2() : where;
3448
3637
  };
3449
3638
  await cursor(resting);
3450
3639
  checked.add(await shoot(OPENING_MS));
3451
3640
  for (const [index, step] of script.steps.entries()) {
3452
- const where = await arrive(step.route);
3453
- if (!onScreen(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
3641
+ const where = await arrive(step.route, step.follows === true);
3642
+ if (!step.follows && !onScreen(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
3454
3643
  const arrived = await judge(where);
3455
3644
  if (arrived) return refuse(arrived);
3456
- const measure = async () => 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
- let resolution = await measure();
3645
+ let resolution = await find(step, where, true);
3468
3646
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3469
3647
  await cursor(resting);
3470
3648
  await film();
3471
- resolution = await measure();
3649
+ resolution = await find(step, where, true);
3472
3650
  }
3473
3651
  if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
3474
3652
  const target = centreOf(resolution.control.box);
3475
3653
  const startedAt = at;
3654
+ const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
3655
+ if (action === "fill" && !step.value) return fail(`step ${index + 1}: it types into ${resolution.control.name}, and says nothing to type`);
3476
3656
  for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
3477
3657
  await cursor(point);
3478
3658
  await shoot(FRAME_MS);
@@ -3482,14 +3662,15 @@ async function recordClip(options, script, touched) {
3482
3662
  poster ??= frames[frames.length - 1].file;
3483
3663
  await cursor(target, true);
3484
3664
  await shoot(PRESS_MS);
3485
- if (step.action === "fill") {
3665
+ if (action === "fill") {
3666
+ const value = step.value ?? "";
3486
3667
  await pointer({ kind: "focus", at: target });
3487
3668
  await cursor(target, false);
3488
- for (const chunk of chunksOf(step.value ?? "", CHARS_PER_FRAME)) {
3669
+ for (const chunk of chunksOf(value, Math.max(CHARS_PER_FRAME, Math.ceil(value.length / TYPING_FRAMES)))) {
3489
3670
  await type(chunk);
3490
3671
  await shoot(FRAME_MS);
3491
3672
  }
3492
- } else if (step.action === "select") {
3673
+ } else if (action === "select") {
3493
3674
  await pointer({ kind: "select", at: target, value: step.value ?? "" });
3494
3675
  } else {
3495
3676
  await pointer({ kind: "click", at: target });
@@ -3506,10 +3687,10 @@ async function recordClip(options, script, touched) {
3506
3687
  const after = await judge(now);
3507
3688
  if (after) return refuse(after);
3508
3689
  steps.push({
3509
- route: step.route,
3510
- role: step.control.role,
3690
+ route: performedOn(step, where),
3691
+ role: resolution.control.role,
3511
3692
  name: resolution.control.name,
3512
- action: step.action,
3693
+ action,
3513
3694
  says: step.says,
3514
3695
  atMs: startedAt,
3515
3696
  box: resolution.control.box
@@ -3559,7 +3740,7 @@ async function recordClip(options, script, touched) {
3559
3740
  await rm(dir, { recursive: true, force: true }).catch(() => void 0);
3560
3741
  }
3561
3742
  }
3562
- var CLIP_BUDGET_MS = 10 * 6e4;
3743
+ var CLIP_BUDGET_MS = 20 * 6e4;
3563
3744
  async function runClips(options) {
3564
3745
  const started = Date.now();
3565
3746
  const outcomes = [];
@@ -4767,6 +4948,28 @@ async function writeLocalClip(dir, clip) {
4767
4948
  durationMs: clip.recording.durationMs
4768
4949
  };
4769
4950
  }
4951
+ function articleClipsToRecord(raw, examples) {
4952
+ if (!Array.isArray(raw)) return readClipScripts(raw, CLIP_MAX_ARTICLE_SCRIPTS);
4953
+ const named = examples ?? { paths: /* @__PURE__ */ new Map(), refused: [] };
4954
+ const unopened = [];
4955
+ const opened = raw.map((value) => {
4956
+ const script = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
4957
+ const visit = typeof script["visit"] === "string" ? script["visit"] : "";
4958
+ const resolved = resolveVisit(visit, named);
4959
+ if (!("needs" in resolved)) return { ...script, visit: resolved.visit };
4960
+ unopened.push({ id: typeof script["id"] === "string" ? script["id"] : "a clip", because: describeMissingExample(visit, resolved.needs) });
4961
+ return void 0;
4962
+ });
4963
+ const read = readClipScripts(
4964
+ opened.filter((value) => value !== void 0),
4965
+ CLIP_MAX_ARTICLE_SCRIPTS
4966
+ );
4967
+ return { scripts: read.scripts, refused: [...unopened, ...read.refused] };
4968
+ }
4969
+ function clipsToRecord(seeded, articles) {
4970
+ const named = new Set(seeded.map((script) => script.id));
4971
+ return [...seeded, ...articles.filter((script) => !named.has(script.id))];
4972
+ }
4770
4973
  function clipForm(clip) {
4771
4974
  const id = clip.recording.clipId;
4772
4975
  const form = new FormData();
@@ -4784,9 +4987,18 @@ async function clipsOnDisk(dir) {
4784
4987
  return /* @__PURE__ */ new Map();
4785
4988
  }
4786
4989
  }
4990
+ function unrecordedLines(refused, skipped) {
4991
+ const lines = [
4992
+ ...skipped.map((piece) => ` ${piece.slug ? `${piece.slug}${piece.at ? ` at ${piece.at}` : ""}` : "The help desk"}: ${piece.because}`),
4993
+ ...refused.map((entry) => ` ${entry.id}: ${entry.because}`)
4994
+ ];
4995
+ if (!lines.length) return [];
4996
+ const shown = lines.slice(0, 12);
4997
+ return [`${lines.length} piece(s) of the articles' steps are not recorded:`, ...shown, ...lines.length > shown.length ? [` and ${lines.length - shown.length} more.`] : []];
4998
+ }
4787
4999
  async function recordSeededClips(args) {
4788
5000
  if (!args.scripts.length) {
4789
- console.log("\n--clips was given, but the seed command named no clips, so none were recorded.");
5001
+ console.log("\n--clips was given, but the seed command named no clips and no article asks for one, so none were recorded.");
4790
5002
  return [];
4791
5003
  }
4792
5004
  if (!await ffmpegAvailable()) {
@@ -4799,9 +5011,12 @@ async function recordSeededClips(args) {
4799
5011
  );
4800
5012
  const previous = args.local ? await clipsOnDisk(args.local) : /* @__PURE__ */ new Map();
4801
5013
  const written = [];
4802
- console.log(`
4803
- 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.
4804
- `);
5014
+ const asked = args.scripts.length - args.named;
5015
+ console.log(
5016
+ `
5017
+ 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.
5018
+ `
5019
+ );
4805
5020
  const outcomes = await runClips({
4806
5021
  baseUrl: args.baseUrl,
4807
5022
  browser: args.browser,
@@ -5142,8 +5357,19 @@ ${unclassified.join("\n")}`);
5142
5357
  Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
5143
5358
  }
5144
5359
  if (parsed.clips) {
5360
+ const named = seeded?.clips ?? [];
5361
+ const articles = seeded ? articleClipsToRecord(orders.clipScripts, seeded.examples) : { scripts: [], refused: [] };
5362
+ const asked = Array.isArray(orders.clipScripts) ? orders.clipScripts.length : 0;
5363
+ if (!seeded && asked) {
5364
+ console.log(`
5365
+ 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.`);
5366
+ }
5367
+ const unrecorded = seeded ? unrecordedLines(articles.refused, Array.isArray(orders.clipsSkipped) ? orders.clipsSkipped : []) : [];
5368
+ if (unrecorded.length) console.log(`
5369
+ ${unrecorded.join("\n")}`);
5145
5370
  await recordSeededClips({
5146
- scripts: seeded?.clips ?? [],
5371
+ scripts: clipsToRecord(named, articles.scripts),
5372
+ named: named.length,
5147
5373
  browser,
5148
5374
  baseUrl,
5149
5375
  clearance,
@@ -5307,13 +5533,16 @@ if (runningAsCommand(import.meta.url)) {
5307
5533
  );
5308
5534
  }
5309
5535
  export {
5536
+ articleClipsToRecord,
5310
5537
  clipForm,
5538
+ clipsToRecord,
5311
5539
  frameForm,
5312
5540
  framesOnDisk,
5313
5541
  main,
5314
5542
  onePerScreen,
5315
5543
  seededLast,
5316
5544
  shotsFromRepo,
5545
+ unrecordedLines,
5317
5546
  withExamples,
5318
5547
  withPhoneShots,
5319
5548
  writeLocalClip,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.14",
3
+ "version": "0.1.17",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {