@olomonhelpdesk2/helpdesk 0.1.15 → 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 +127 -32
  2. package/package.json +1 -1
@@ -1561,6 +1561,8 @@ function readStep(value, at) {
1561
1561
  if (!route || !isPathLike(route)) return `step ${at} names no screen`;
1562
1562
  if (role !== void 0 && (typeof role !== "string" || !CONTROL_ROLES.includes(role))) return `step ${at} names no control role a screen lists`;
1563
1563
  if (!name || name.length > MAX_TEXT2) return `step ${at} names no control`;
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`;
1564
1566
  if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select, check nor auto`;
1565
1567
  if (!says || says.length > MAX_TEXT2) return `step ${at} has no sentence for a reader`;
1566
1568
  if (role === void 0 && action !== "auto") return `step ${at} says how to act on a control without saying what kind it is`;
@@ -1569,12 +1571,14 @@ function readStep(value, at) {
1569
1571
  if (typed && !valued) return `step ${at} has nothing to ${action}`;
1570
1572
  if (action !== "auto" && !typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
1571
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`;
1572
1575
  return {
1573
1576
  route,
1574
- control: { ...role === void 0 ? {} : { role }, name },
1577
+ control: { ...role === void 0 ? {} : { role }, name, ...scope ? { scope } : {} },
1575
1578
  action,
1576
1579
  ...valued && (typed || action === "auto") ? { value: given } : {},
1577
- says
1580
+ says,
1581
+ ...step["follows"] === true ? { follows: true } : {}
1578
1582
  };
1579
1583
  }
1580
1584
  function readSteps(raw) {
@@ -1633,6 +1637,10 @@ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
1633
1637
  refuse(`it opens at ${visit || "nowhere"}, which is not ${first.route}`);
1634
1638
  continue;
1635
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
+ }
1636
1644
  if (lands !== void 0 && !isPathLike(lands)) {
1637
1645
  refuse("it names no screen to land on");
1638
1646
  continue;
@@ -1641,14 +1649,57 @@ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
1641
1649
  }
1642
1650
  return { scripts, refused };
1643
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
+ }
1644
1680
  function resolveStep(manifest, step) {
1645
- const name = cleanText(step.control.name).toLowerCase();
1681
+ const name = comparable(step.control.name);
1646
1682
  const where = manifest.screen.path;
1647
1683
  const role = step.control.role;
1648
1684
  const what = role ?? "control";
1649
- const matches = manifest.controls.filter((control) => (!role || control.role === role) && control.name.toLowerCase() === name);
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
+ }
1650
1699
  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` };
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
+ }
1652
1703
  if (matches[0].state?.disabled) return { ok: false, because: `${what} "${step.control.name}" on ${where} is disabled` };
1653
1704
  return { ok: true, control: matches[0] };
1654
1705
  }
@@ -1663,6 +1714,7 @@ function clipFingerprint(script, touched) {
1663
1714
  route: step.route,
1664
1715
  role: step.control.role ?? null,
1665
1716
  name: step.control.name,
1717
+ ...step.control.scope ? { scope: step.control.scope } : {},
1666
1718
  action: step.action,
1667
1719
  value: step.value ?? null,
1668
1720
  says: step.says
@@ -3130,6 +3182,11 @@ async function playwrightBrowser(options = {}) {
3130
3182
  return `${url.pathname}${url.search}`;
3131
3183
  },
3132
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
+ }
3133
3190
  if (action.kind === "select") {
3134
3191
  const handle = await page.evaluateHandle(({ x, y }) => document.elementFromPoint(x, y), action.at);
3135
3192
  const element = handle.asElement();
@@ -3389,7 +3446,13 @@ var TYPING_FRAMES = 12;
3389
3446
  var SETTLE_MS = 3e3;
3390
3447
  var STEP_WAIT_MS = 4e3;
3391
3448
  var CLOSING_MS = 1200;
3449
+ var SCROLL_TO = 0.45;
3450
+ var WHOLE_SHIFT = 1e5;
3392
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
+ }
3393
3456
  function chunksOf(text2, size) {
3394
3457
  return Array.from({ length: Math.ceil(text2.length / size) }, (_, index) => text2.slice(index * size, (index + 1) * size));
3395
3458
  }
@@ -3453,17 +3516,14 @@ async function recordClip(options, script, touched) {
3453
3516
  return true;
3454
3517
  };
3455
3518
  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
- );
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
+ });
3467
3527
  const steady = async () => {
3468
3528
  let previous = await page.screenshot(cover);
3469
3529
  for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
@@ -3473,6 +3533,35 @@ async function recordClip(options, script, touched) {
3473
3533
  previous = next;
3474
3534
  }
3475
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
+ };
3476
3565
  const reach = async (route) => {
3477
3566
  let where = await location2();
3478
3567
  for (let waited = 0; !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
@@ -3485,13 +3574,19 @@ async function recordClip(options, script, touched) {
3485
3574
  }
3486
3575
  return where;
3487
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];
3488
3583
  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);
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);
3492
3587
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3493
3588
  await pause(FRAME_MS);
3494
- resolution = await measureOn(step, where);
3589
+ resolution = await find(step, where, false);
3495
3590
  }
3496
3591
  if (!resolution.ok) return resolution.because;
3497
3592
  const target = centreOf(resolution.control.box);
@@ -3518,40 +3613,40 @@ async function recordClip(options, script, touched) {
3518
3613
  const problem = await lead(step);
3519
3614
  if (problem) return fail(`before the recording, step ${index + 1}: ${problem}`);
3520
3615
  }
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}`);
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}`);
3523
3619
  const before = await judge(starting);
3524
3620
  if (before) return refuse(before);
3525
- let resting = START;
3526
3621
  let poster;
3527
3622
  const steps = [];
3528
- const arrive = async (route) => {
3623
+ const arrive = async (route, follows = false) => {
3529
3624
  let where = await location2();
3530
- 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) {
3531
3626
  await cursor(resting);
3532
3627
  await film();
3533
3628
  where = await location2();
3534
3629
  }
3535
- if (!onScreen(route, where)) return where;
3630
+ if (!follows && !onScreen(route, where)) return where;
3536
3631
  await page.quiet?.(SETTLE_MS);
3537
3632
  await cursor(resting);
3538
3633
  for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3539
3634
  if (!await film()) break;
3540
3635
  }
3541
- return where;
3636
+ return follows ? location2() : where;
3542
3637
  };
3543
3638
  await cursor(resting);
3544
3639
  checked.add(await shoot(OPENING_MS));
3545
3640
  for (const [index, step] of script.steps.entries()) {
3546
- const where = await arrive(step.route);
3547
- 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}`);
3548
3643
  const arrived = await judge(where);
3549
3644
  if (arrived) return refuse(arrived);
3550
- let resolution = await measureOn(step, where);
3645
+ let resolution = await find(step, where, true);
3551
3646
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3552
3647
  await cursor(resting);
3553
3648
  await film();
3554
- resolution = await measureOn(step, where);
3649
+ resolution = await find(step, where, true);
3555
3650
  }
3556
3651
  if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
3557
3652
  const target = centreOf(resolution.control.box);
@@ -3592,7 +3687,7 @@ async function recordClip(options, script, touched) {
3592
3687
  const after = await judge(now);
3593
3688
  if (after) return refuse(after);
3594
3689
  steps.push({
3595
- route: step.route,
3690
+ route: performedOn(step, where),
3596
3691
  role: resolution.control.role,
3597
3692
  name: resolution.control.name,
3598
3693
  action,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.15",
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": {