@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.
- package/helpdesk-capture.mjs +217 -62
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1645
|
-
|
|
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
|
|
1650
|
-
|
|
1651
|
-
if (matches.length
|
|
1652
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
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
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
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 (
|
|
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 (!
|
|
3491
|
-
let resolution = await
|
|
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
|
|
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 (!
|
|
3501
|
-
|
|
3502
|
-
|
|
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))
|
|
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
|
|
3522
|
-
|
|
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
|
|
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; !
|
|
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 (!
|
|
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 (!
|
|
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
|
|
3550
|
-
let resolution = await
|
|
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
|
|
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
|
-
|
|
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 =
|
|
3572
|
-
|
|
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
|
|
3738
|
+
if (after) return after;
|
|
3594
3739
|
steps.push({
|
|
3595
|
-
route: step
|
|
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 (!
|
|
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
|
|
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 =
|
|
5013
|
+
const script = recordOf3(value);
|
|
4862
5014
|
const visit = typeof script["visit"] === "string" ? script["visit"] : "";
|
|
4863
5015
|
const resolved = resolveVisit(visit, named);
|
|
4864
|
-
|
|
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
|
});
|