@olomonhelpdesk2/helpdesk 0.1.15 → 0.1.18

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 +217 -62
  2. package/package.json +1 -1
@@ -248,6 +248,11 @@ function helpdeskCommand(command, args = []) {
248
248
 
249
249
  // core/capture/classify.ts
250
250
  var THIN_TEXT_CHARS = 25;
251
+ var ERROR_PAGE_PHRASES = [
252
+ /\bapplication error: a (server|client)-side exception has occurred\b/i,
253
+ /\bthis screen did not load\b/i,
254
+ /\bdigest\s*:?\s*[\w-]{6,}\s+copy reference\b/i
255
+ ];
251
256
  var BOUNDARY_PHRASES = [
252
257
  /\bsomething went wrong\b/i,
253
258
  /\bapplication error\b/i,
@@ -256,8 +261,12 @@ var BOUNDARY_PHRASES = [
256
261
  /\binternal server error\b/i,
257
262
  /\bthis page (could not|couldn'?t) be loaded\b/i,
258
263
  /\bwe('| a)re sorry[ —,-]+something\b/i,
259
- /\berror (id|digest)\b/i
264
+ /\berror (id|digest)\b/i,
265
+ ...ERROR_PAGE_PHRASES
260
266
  ];
267
+ function showsErrorPage(text2) {
268
+ return ERROR_PAGE_PHRASES.some((phrase) => phrase.test(text2));
269
+ }
261
270
  var NOT_FOUND_PHRASES = [
262
271
  /\b(page|it) (was )?not found\b/i,
263
272
  /\b404\b/,
@@ -1010,6 +1019,7 @@ async function readPaintedMarkup(page, regions) {
1010
1019
  const style = window.getComputedStyle(element);
1011
1020
  if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
1012
1021
  if (style.display === "contents") return true;
1022
+ if (typeof element.checkVisibility === "function" && !element.checkVisibility()) return false;
1013
1023
  for (const box of Array.from(element.getClientRects())) {
1014
1024
  if (box.width > 0 && box.height > 0) return true;
1015
1025
  }
@@ -1090,6 +1100,7 @@ async function readPageLayout(page, cover = []) {
1090
1100
  };
1091
1101
  const unseen = (element) => {
1092
1102
  if (element.closest('[aria-hidden="true"], [inert]')) return true;
1103
+ if (typeof element.checkVisibility === "function" && !element.checkVisibility()) return true;
1093
1104
  for (let node = element; node; node = node.parentElement) {
1094
1105
  const style = window.getComputedStyle(node);
1095
1106
  if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return true;
@@ -1561,6 +1572,8 @@ function readStep(value, at) {
1561
1572
  if (!route || !isPathLike(route)) return `step ${at} names no screen`;
1562
1573
  if (role !== void 0 && (typeof role !== "string" || !CONTROL_ROLES.includes(role))) return `step ${at} names no control role a screen lists`;
1563
1574
  if (!name || name.length > MAX_TEXT2) return `step ${at} names no control`;
1575
+ const scope = control["scope"] === void 0 ? void 0 : cleanText(control["scope"]);
1576
+ if (scope !== void 0 && (!scope || scope.length > MAX_TEXT2)) return `step ${at} names a region of the screen with no name`;
1564
1577
  if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select, check nor auto`;
1565
1578
  if (!says || says.length > MAX_TEXT2) return `step ${at} has no sentence for a reader`;
1566
1579
  if (role === void 0 && action !== "auto") return `step ${at} says how to act on a control without saying what kind it is`;
@@ -1569,12 +1582,20 @@ function readStep(value, at) {
1569
1582
  if (typed && !valued) return `step ${at} has nothing to ${action}`;
1570
1583
  if (action !== "auto" && !typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
1571
1584
  if (action === "auto" && given !== void 0 && !valued) return `step ${at} has a value nothing can type`;
1585
+ 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`;
1586
+ const demo = step["demo"] === void 0 ? void 0 : cleanText(step["demo"]);
1587
+ const opens = step["opens"] === void 0 ? void 0 : cleanText(step["opens"]);
1588
+ if (demo !== void 0 && (!demo || demo.length > MAX_TEXT2 || action !== "auto" && action !== "fill")) return `step ${at} has a demo value nothing types`;
1589
+ if (opens !== void 0 && (!isPathLike(opens) || !fillsPattern(route, opens.split("?")[0]))) return `step ${at} opens at ${opens || "nowhere"}, which is not ${route}`;
1572
1590
  return {
1573
1591
  route,
1574
- control: { ...role === void 0 ? {} : { role }, name },
1592
+ control: { ...role === void 0 ? {} : { role }, name, ...scope ? { scope } : {} },
1575
1593
  action,
1576
1594
  ...valued && (typed || action === "auto") ? { value: given } : {},
1577
- says
1595
+ ...demo ? { demo } : {},
1596
+ ...opens ? { opens } : {},
1597
+ says,
1598
+ ...step["follows"] === true ? { follows: true } : {}
1578
1599
  };
1579
1600
  }
1580
1601
  function readSteps(raw) {
@@ -1633,6 +1654,10 @@ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
1633
1654
  refuse(`it opens at ${visit || "nowhere"}, which is not ${first.route}`);
1634
1655
  continue;
1635
1656
  }
1657
+ if (first.follows) {
1658
+ refuse("its first step follows no step before it, and is the screen it opens at");
1659
+ continue;
1660
+ }
1636
1661
  if (lands !== void 0 && !isPathLike(lands)) {
1637
1662
  refuse("it names no screen to land on");
1638
1663
  continue;
@@ -1641,15 +1666,60 @@ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
1641
1666
  }
1642
1667
  return { scripts, refused };
1643
1668
  }
1644
- function resolveStep(manifest, step) {
1645
- const name = cleanText(step.control.name).toLowerCase();
1669
+ function comparable(text2) {
1670
+ return cleanText(text2).toLowerCase().replace(/(…|\.\.\.)$/, "").trim();
1671
+ }
1672
+ var CHROME_ROLES = ["navigation", "complementary", "banner", "contentinfo"];
1673
+ var IN_CHROME = /\b(sidebar|side bar|menu|navigation|nav bar|top bar|header)\b/i;
1674
+ function within(manifest, control, regionIds) {
1675
+ const regions = new Map(manifest.regions.map((region) => [region.id, region]));
1676
+ const seen = /* @__PURE__ */ new Set();
1677
+ for (let id = control.region; id && !seen.has(id); id = regions.get(id)?.parent) {
1678
+ seen.add(id);
1679
+ if (regionIds.has(id)) return true;
1680
+ }
1681
+ return false;
1682
+ }
1683
+ function namesControl(shown, named) {
1684
+ const have = comparable(shown);
1685
+ const want = comparable(named);
1686
+ return Boolean(want) && (have === want || have.startsWith(`${want} `));
1687
+ }
1688
+ function inChrome(manifest, control) {
1689
+ const regions = new Map(manifest.regions.map((region) => [region.id, region]));
1690
+ const seen = /* @__PURE__ */ new Set();
1691
+ for (let id = control.region; id && !seen.has(id); id = regions.get(id)?.parent) {
1692
+ seen.add(id);
1693
+ if (CHROME_ROLES.includes(regions.get(id)?.role ?? "")) return true;
1694
+ }
1695
+ return false;
1696
+ }
1697
+ function resolveStep(manifest, step, options = {}) {
1698
+ const name = comparable(step.control.name);
1646
1699
  const where = manifest.screen.path;
1647
1700
  const role = step.control.role;
1648
1701
  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` };
1702
+ const candidates = manifest.controls.filter((control) => !role || control.role === role);
1703
+ let matches = candidates.filter((control) => comparable(control.name) === name);
1704
+ if (!role && !matches.length) matches = candidates.filter((control) => namesControl(control.name, step.control.name));
1705
+ const scope = step.control.scope;
1706
+ if (scope && matches.length > 1) {
1707
+ const regions = new Set(manifest.regions.filter((region) => region.id === scope || comparable(region.label) === comparable(scope)).map((region) => region.id));
1708
+ const scoped = matches.filter((control) => within(manifest, control, regions));
1709
+ if (scoped.length) matches = scoped;
1710
+ }
1711
+ const narrow = (keep) => {
1712
+ const kept = matches.filter(keep);
1713
+ if (kept.length === 1) matches = kept;
1714
+ };
1715
+ if (!role && matches.length > 1 && options.after) narrow((control) => within(manifest, control, /* @__PURE__ */ new Set([options.after])));
1716
+ if (!role && matches.length > 1) narrow((control) => inChrome(manifest, control) === IN_CHROME.test(step.says));
1717
+ if (!role && matches.length > 1) narrow((control) => !control.state?.disabled);
1718
+ if (matches.length === 0) return { ok: false, kind: "missing", because: `no ${what} "${step.control.name}" on ${where}` };
1719
+ if (matches.length > 1) {
1720
+ return { ok: false, kind: "ambiguous", because: `${matches.length} of ${what} "${step.control.name}" on ${where}${scope ? ` in ${scope}` : ""}, so the step does not say which` };
1721
+ }
1722
+ if (matches[0].state?.disabled) return { ok: false, kind: "disabled", because: `${what} "${step.control.name}" on ${where} is disabled` };
1653
1723
  return { ok: true, control: matches[0] };
1654
1724
  }
1655
1725
  function onScreen(route, location2) {
@@ -1663,6 +1733,8 @@ function clipFingerprint(script, touched) {
1663
1733
  route: step.route,
1664
1734
  role: step.control.role ?? null,
1665
1735
  name: step.control.name,
1736
+ ...step.control.scope ? { scope: step.control.scope } : {},
1737
+ ...step.demo ? { demo: step.demo } : {},
1666
1738
  action: step.action,
1667
1739
  value: step.value ?? null,
1668
1740
  says: step.says
@@ -3130,6 +3202,11 @@ async function playwrightBrowser(options = {}) {
3130
3202
  return `${url.pathname}${url.search}`;
3131
3203
  },
3132
3204
  async pointer(action) {
3205
+ if (action.kind === "scroll") {
3206
+ await page.mouse.move(action.at.x, action.at.y);
3207
+ await page.mouse.wheel(0, action.by);
3208
+ return;
3209
+ }
3133
3210
  if (action.kind === "select") {
3134
3211
  const handle = await page.evaluateHandle(({ x, y }) => document.elementFromPoint(x, y), action.at);
3135
3212
  const element = handle.asElement();
@@ -3138,7 +3215,14 @@ async function playwrightBrowser(options = {}) {
3138
3215
  return;
3139
3216
  }
3140
3217
  await page.mouse.click(action.at.x, action.at.y);
3141
- if (action.kind === "focus") await page.keyboard.press("ControlOrMeta+A");
3218
+ if (action.kind !== "focus") return;
3219
+ const field = await page.evaluate(() => {
3220
+ const focused = document.activeElement;
3221
+ const kind = focused instanceof HTMLInputElement ? focused.type : "";
3222
+ return Boolean(focused && (focused.isContentEditable || focused instanceof HTMLTextAreaElement || focused instanceof HTMLInputElement && !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(kind)));
3223
+ });
3224
+ if (!field) throw new Error(`the press at (${action.at.x}, ${action.at.y}) focused no field, so nothing could be typed`);
3225
+ await page.keyboard.press("ControlOrMeta+A");
3142
3226
  },
3143
3227
  async type(text2) {
3144
3228
  await page.keyboard.type(text2);
@@ -3389,7 +3473,13 @@ var TYPING_FRAMES = 12;
3389
3473
  var SETTLE_MS = 3e3;
3390
3474
  var STEP_WAIT_MS = 4e3;
3391
3475
  var CLOSING_MS = 1200;
3476
+ var SCROLL_TO = 0.45;
3477
+ var WHOLE_SHIFT = 1e5;
3392
3478
  var START = { x: Math.round(CLIP_VIEWPORT.width / 2), y: Math.round(CLIP_VIEWPORT.height * 0.66) };
3479
+ function wholePage(raw) {
3480
+ const down = (item) => ({ ...item, box: { ...item.box, y: item.box.y + WHOLE_SHIFT } });
3481
+ return { ...raw, viewport: { width: raw.viewport.width, height: 2 * WHOLE_SHIFT }, regions: raw.regions.map(down), controls: raw.controls.map(down) };
3482
+ }
3393
3483
  function chunksOf(text2, size) {
3394
3484
  return Array.from({ length: Math.ceil(text2.length / size) }, (_, index) => text2.slice(index * size, (index + 1) * size));
3395
3485
  }
@@ -3413,20 +3503,22 @@ async function recordClip(options, script, touched) {
3413
3503
  let last = new Uint8Array();
3414
3504
  const judge = async (where) => {
3415
3505
  const route = where.split("?")[0];
3416
- const verdict = gateFrame(await page.text(), {
3506
+ const text2 = await page.text();
3507
+ if (showsErrorPage(text2)) return fail(`${route} answered with its error page, not the screen`);
3508
+ const verdict = gateFrame(text2, {
3417
3509
  route,
3418
3510
  ...options.brands ? { brands: options.brands } : {},
3419
3511
  ...options.clearance?.expectedBrands?.length ? { expected: options.clearance.expectedBrands } : {}
3420
3512
  });
3421
- if (!verdict.safe) return verdict.reason ?? `the gate refused ${route}`;
3513
+ if (!verdict.safe) return refuse(verdict.reason ?? `the gate refused ${route}`);
3422
3514
  if (!options.clearance) return null;
3423
3515
  const clearance = options.clearance;
3424
3516
  const reading = await readScreen(page, clearance);
3425
- if (!reading) return "this browser cannot read a screen against a clearance";
3426
- if (reading.inventory.unaccounted.length) return `${reading.inventory.unaccounted.length} value(s) on ${route} are outside every region the clearance names`;
3517
+ if (!reading) return refuse("this browser cannot read a screen against a clearance");
3518
+ if (reading.inventory.unaccounted.length) return refuse(`${reading.inventory.unaccounted.length} value(s) on ${route} are outside every region the clearance names`);
3427
3519
  const approved = new Set(clearance.approved);
3428
3520
  const loose = reading.surfaces.filter((surface) => !surface.read && !clearance.covers.includes(surface.where) && !approved.has(surface.where));
3429
- if (loose.length && !clearance.coverUnnamed) return `${loose.length} surface(s) on ${route} are neither approved nor covered`;
3521
+ if (loose.length && !clearance.coverUnnamed) return refuse(`${loose.length} surface(s) on ${route} are neither approved nor covered`);
3430
3522
  cover = reading.surfaces.filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
3431
3523
  return null;
3432
3524
  };
@@ -3453,17 +3545,16 @@ async function recordClip(options, script, touched) {
3453
3545
  return true;
3454
3546
  };
3455
3547
  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
- );
3548
+ let redirected;
3549
+ const isOn = (route, where) => onScreen(route, where) || redirected?.route === route && where.split("?")[0] === redirected.path;
3550
+ const manifestOf = (raw, step, where) => buildLayoutManifest(raw, {
3551
+ shotId: script.id,
3552
+ route: step.route,
3553
+ path: where.split("?")[0],
3554
+ variant: "desktop",
3555
+ image: { width: raw.viewport.width, height: raw.viewport.height },
3556
+ origin: new URL(options.baseUrl).origin
3557
+ });
3467
3558
  const steady = async () => {
3468
3559
  let previous = await page.screenshot(cover);
3469
3560
  for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
@@ -3473,33 +3564,81 @@ async function recordClip(options, script, touched) {
3473
3564
  previous = next;
3474
3565
  }
3475
3566
  };
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) {
3567
+ let resting = START;
3568
+ let lastRegion;
3569
+ const find = async (step, where, filming) => {
3570
+ const raw = await layout(cover);
3571
+ const shown = resolveStep(manifestOf(raw, step, where), step, { after: lastRegion });
3572
+ if (shown.ok) return shown;
3573
+ const whole = resolveStep(manifestOf(wholePage(raw), step, where), step, { after: lastRegion });
3574
+ if (!whole.ok) return whole.kind === "missing" ? shown : whole;
3575
+ const box = whole.control.box;
3576
+ const by = Math.round(box.y - WHOLE_SHIFT + box.height / 2 - CLIP_VIEWPORT.height * SCROLL_TO);
3577
+ 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) };
3578
+ if (Math.abs(by) < 2) return shown;
3579
+ if (filming) {
3580
+ for (const point of cursorPath(resting, wheel, MOVE_FRAMES)) {
3581
+ await cursor(point);
3582
+ await shoot(FRAME_MS);
3583
+ }
3584
+ resting = wheel;
3585
+ }
3586
+ await pointer({ kind: "scroll", at: wheel, by });
3587
+ if (filming) {
3588
+ await cursor(resting);
3589
+ for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3590
+ if (!await film()) break;
3591
+ }
3592
+ } else {
3593
+ await steady();
3594
+ }
3595
+ return resolveStep(manifestOf(await layout(cover), step, where), step, { after: lastRegion });
3596
+ };
3597
+ const open = async (route, opens, where) => {
3598
+ if (!opens || isOn(route, where)) return where;
3599
+ await page.goto(new URL(opens, options.baseUrl).toString());
3600
+ return location2();
3601
+ };
3602
+ const reach = async (route, opens) => {
3603
+ let where = await open(route, opens, await location2());
3604
+ for (let waited = 0; !isOn(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3479
3605
  await pause(FRAME_MS);
3480
3606
  where = await location2();
3481
3607
  }
3482
- if (onScreen(route, where)) {
3608
+ if (isOn(route, where)) {
3483
3609
  await page.quiet?.(SETTLE_MS);
3484
3610
  await steady();
3485
3611
  }
3486
3612
  return where;
3487
3613
  };
3614
+ const settled = async () => {
3615
+ await page.quiet?.(SETTLE_MS);
3616
+ await steady();
3617
+ return location2();
3618
+ };
3619
+ const focus = (at2) => pointer({ kind: "focus", at: at2 }).then(
3620
+ () => null,
3621
+ (error) => error instanceof Error ? error.message : String(error)
3622
+ );
3623
+ const performedOn = (step, where) => isOn(step.route, where) ? step.route : [...script.before ?? [], ...script.steps].map((each) => each.route).find((route) => isOn(route, where)) ?? where.split("?")[0];
3488
3624
  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);
3625
+ const where = step.follows ? await settled() : await reach(step.route, step.opens);
3626
+ if (!step.follows && !isOn(step.route, where)) return `the browser is on ${where}, not ${step.route}`;
3627
+ let resolution = await find(step, where, false);
3492
3628
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3493
3629
  await pause(FRAME_MS);
3494
- resolution = await measureOn(step, where);
3630
+ resolution = await find(step, where, false);
3495
3631
  }
3496
3632
  if (!resolution.ok) return resolution.because;
3633
+ lastRegion = resolution.control.region;
3497
3634
  const target = centreOf(resolution.control.box);
3498
3635
  const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
3636
+ const typed = step.value ?? step.demo;
3499
3637
  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);
3638
+ if (!typed) return `it types into ${resolution.control.name}, and says nothing to type`;
3639
+ const unfocused = await focus(target);
3640
+ if (unfocused) return unfocused;
3641
+ await type(typed);
3503
3642
  } else if (action === "select") {
3504
3643
  await pointer({ kind: "select", at: target, value: step.value ?? "" });
3505
3644
  } else {
@@ -3513,51 +3652,56 @@ async function recordClip(options, script, touched) {
3513
3652
  if (await page.isSignIn()) return refuse("its first screen asked for a sign-in, so this session is not the demo tenant's");
3514
3653
  const opened = await location2();
3515
3654
  const performedFirst = script.before?.[0] ?? script.steps[0];
3516
- if (!onScreen(performedFirst.route, opened)) return fail(`it opened on ${opened}, not ${performedFirst.route}`);
3655
+ if (!onScreen(performedFirst.route, opened)) {
3656
+ if (!options.screens?.some((route) => onScreen(route, opened))) return fail(`it opened on ${opened}, not ${performedFirst.route}`);
3657
+ redirected = { route: performedFirst.route, path: opened.split("?")[0] };
3658
+ }
3517
3659
  for (const [index, step] of (script.before ?? []).entries()) {
3518
3660
  const problem = await lead(step);
3519
3661
  if (problem) return fail(`before the recording, step ${index + 1}: ${problem}`);
3520
3662
  }
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}`);
3663
+ const first = script.steps[0];
3664
+ const starting = !script.before?.length ? opened : first.follows ? await settled() : await reach(first.route, first.opens);
3665
+ if (!first.follows && !isOn(first.route, starting)) return fail(`once the steps before it were done, the browser is on ${starting}, not ${first.route}`);
3523
3666
  const before = await judge(starting);
3524
- if (before) return refuse(before);
3525
- let resting = START;
3667
+ if (before) return before;
3526
3668
  let poster;
3527
3669
  const steps = [];
3528
- const arrive = async (route) => {
3529
- let where = await location2();
3530
- for (let waited = 0; !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3670
+ const arrive = async (route, follows = false, opens) => {
3671
+ let where = follows ? await location2() : await open(route, opens, await location2());
3672
+ for (let waited = 0; !follows && !isOn(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3531
3673
  await cursor(resting);
3532
3674
  await film();
3533
3675
  where = await location2();
3534
3676
  }
3535
- if (!onScreen(route, where)) return where;
3677
+ if (!follows && !isOn(route, where)) return where;
3536
3678
  await page.quiet?.(SETTLE_MS);
3537
3679
  await cursor(resting);
3538
3680
  for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3539
3681
  if (!await film()) break;
3540
3682
  }
3541
- return where;
3683
+ return follows ? location2() : where;
3542
3684
  };
3543
3685
  await cursor(resting);
3544
3686
  checked.add(await shoot(OPENING_MS));
3545
3687
  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}`);
3688
+ const where = await arrive(step.route, step.follows === true, step.opens);
3689
+ if (!step.follows && !isOn(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
3548
3690
  const arrived = await judge(where);
3549
- if (arrived) return refuse(arrived);
3550
- let resolution = await measureOn(step, where);
3691
+ if (arrived) return arrived;
3692
+ let resolution = await find(step, where, true);
3551
3693
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3552
3694
  await cursor(resting);
3553
3695
  await film();
3554
- resolution = await measureOn(step, where);
3696
+ resolution = await find(step, where, true);
3555
3697
  }
3556
3698
  if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
3699
+ lastRegion = resolution.control.region;
3557
3700
  const target = centreOf(resolution.control.box);
3558
3701
  const startedAt = at;
3559
3702
  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`);
3703
+ const typed = step.value ?? step.demo;
3704
+ if (action === "fill" && !typed) return fail(`step ${index + 1}: it types into ${resolution.control.name}, and says nothing to type`);
3561
3705
  for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
3562
3706
  await cursor(point);
3563
3707
  await shoot(FRAME_MS);
@@ -3568,8 +3712,9 @@ async function recordClip(options, script, touched) {
3568
3712
  await cursor(target, true);
3569
3713
  await shoot(PRESS_MS);
3570
3714
  if (action === "fill") {
3571
- const value = step.value ?? "";
3572
- await pointer({ kind: "focus", at: target });
3715
+ const value = typed ?? "";
3716
+ const unfocused = await focus(target);
3717
+ if (unfocused) return fail(`step ${index + 1}: ${unfocused}`);
3573
3718
  await cursor(target, false);
3574
3719
  for (const chunk of chunksOf(value, Math.max(CHARS_PER_FRAME, Math.ceil(value.length / TYPING_FRAMES)))) {
3575
3720
  await type(chunk);
@@ -3590,9 +3735,9 @@ async function recordClip(options, script, touched) {
3590
3735
  checked.add(frames[frames.length - 1].file);
3591
3736
  const now = await location2();
3592
3737
  const after = await judge(now);
3593
- if (after) return refuse(after);
3738
+ if (after) return after;
3594
3739
  steps.push({
3595
- route: step.route,
3740
+ route: performedOn(step, where),
3596
3741
  role: resolution.control.role,
3597
3742
  name: resolution.control.name,
3598
3743
  action,
@@ -3603,9 +3748,9 @@ async function recordClip(options, script, touched) {
3603
3748
  }
3604
3749
  if (script.lands) {
3605
3750
  const landed = await arrive(script.lands);
3606
- if (!onScreen(script.lands, landed)) return fail(`the last step left the browser on ${landed}, not ${script.lands}`);
3751
+ if (!isOn(script.lands, landed)) return fail(`the last step left the browser on ${landed}, not ${script.lands}`);
3607
3752
  const seen = await judge(landed);
3608
- if (seen) return refuse(seen);
3753
+ if (seen) return seen;
3609
3754
  }
3610
3755
  hold(CLOSING_MS);
3611
3756
  checked.add(frames[frames.length - 1].file);
@@ -3666,7 +3811,7 @@ async function runClips(options) {
3666
3811
  } else if (!options.force && options.held.get(script.id) === fingerprint) {
3667
3812
  outcome = { ...base, status: "unchanged", reason: "neither its steps nor a screen they touch has changed" };
3668
3813
  } else {
3669
- const result = await recordClip(options, script, touched);
3814
+ const result = await recordClip({ ...options, screens: options.screens ?? [...options.frames.keys()] }, script, touched);
3670
3815
  if (!result.ok) {
3671
3816
  outcome = { ...base, status: result.status, reason: result.because };
3672
3817
  } else {
@@ -4857,11 +5002,21 @@ function articleClipsToRecord(raw, examples) {
4857
5002
  if (!Array.isArray(raw)) return readClipScripts(raw, CLIP_MAX_ARTICLE_SCRIPTS);
4858
5003
  const named = examples ?? { paths: /* @__PURE__ */ new Map(), refused: [] };
4859
5004
  const unopened = [];
5005
+ const recordOf3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
5006
+ const withOpens = (steps, from) => Array.isArray(steps) ? steps.map((value, index) => {
5007
+ const step = recordOf3(value);
5008
+ const later = from + index > 0 && step["follows"] !== true;
5009
+ const resolved = later && typeof step["route"] === "string" ? resolveVisit(step["route"], named) : void 0;
5010
+ return resolved && "visit" in resolved && resolved.visit.includes("?") ? { ...step, opens: resolved.visit } : value;
5011
+ }) : steps;
4860
5012
  const opened = raw.map((value) => {
4861
- const script = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
5013
+ const script = recordOf3(value);
4862
5014
  const visit = typeof script["visit"] === "string" ? script["visit"] : "";
4863
5015
  const resolved = resolveVisit(visit, named);
4864
- if (!("needs" in resolved)) return { ...script, visit: resolved.visit };
5016
+ const lead = Array.isArray(script["before"]) ? script["before"].length : 0;
5017
+ if (!("needs" in resolved)) {
5018
+ return { ...script, visit: resolved.visit, ...lead ? { before: withOpens(script["before"], 0) } : {}, steps: withOpens(script["steps"], lead) };
5019
+ }
4865
5020
  unopened.push({ id: typeof script["id"] === "string" ? script["id"] : "a clip", because: describeMissingExample(visit, resolved.needs) });
4866
5021
  return void 0;
4867
5022
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.15",
3
+ "version": "0.1.18",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {