@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.
- package/helpdesk-capture.mjs +300 -71
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1560
|
-
if (
|
|
1561
|
-
|
|
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
|
-
|
|
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
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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
|
-
|
|
1604
|
-
|
|
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 =
|
|
1681
|
+
const name = comparable(step.control.name);
|
|
1622
1682
|
const where = manifest.screen.path;
|
|
1623
|
-
const
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
|
|
3429
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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(
|
|
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 (
|
|
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
|
|
3510
|
-
role:
|
|
3690
|
+
route: performedOn(step, where),
|
|
3691
|
+
role: resolution.control.role,
|
|
3511
3692
|
name: resolution.control.name,
|
|
3512
|
-
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 =
|
|
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
|
-
|
|
4803
|
-
|
|
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:
|
|
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,
|