@olomonhelpdesk2/helpdesk 0.1.13 → 0.1.15
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 +243 -76
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -106,7 +106,12 @@ function readExampleRoutes(raw) {
|
|
|
106
106
|
}
|
|
107
107
|
paths.set(route, path5);
|
|
108
108
|
}
|
|
109
|
-
|
|
109
|
+
const again = /* @__PURE__ */ new Set();
|
|
110
|
+
for (const route of Array.isArray(body["again"]) ? body["again"] : []) {
|
|
111
|
+
if (typeof route === "string" && paths.has(route)) again.add(route);
|
|
112
|
+
else refused.push({ route: String(route), because: "it is named as safe to open again, but not as a route with a record" });
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, examples: { paths, refused, again } };
|
|
110
115
|
}
|
|
111
116
|
function resolveVisit(route, examples) {
|
|
112
117
|
const needs = dynamicSegments(route);
|
|
@@ -422,7 +427,7 @@ function preflight(facts) {
|
|
|
422
427
|
name: "posture",
|
|
423
428
|
ok: true,
|
|
424
429
|
blocking: false,
|
|
425
|
-
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"
|
|
426
431
|
});
|
|
427
432
|
if (facts.rebaselineReason) {
|
|
428
433
|
checks.push({
|
|
@@ -1298,7 +1303,9 @@ async function phoneFit(page, width) {
|
|
|
1298
1303
|
if (content > laid + 1) return { fits: false, because: `it runs ${Math.round(content - laid)}px off the side` };
|
|
1299
1304
|
const main2 = document.querySelector("main, [role=main]");
|
|
1300
1305
|
const column = main2 ? main2.getBoundingClientRect().width : laid;
|
|
1301
|
-
|
|
1306
|
+
if (column < laid * 0.75) return { fits: false, because: `its main column is ${Math.round(column)}px of ${laid}px, beside the rest of the layout` };
|
|
1307
|
+
const overflow = main2 ? main2.scrollWidth - main2.clientWidth : 0;
|
|
1308
|
+
return overflow > 1 ? { fits: false, because: `its content runs ${Math.round(overflow)}px past its main column` } : { fits: true };
|
|
1302
1309
|
},
|
|
1303
1310
|
{ width }
|
|
1304
1311
|
)
|
|
@@ -1336,6 +1343,10 @@ import { createHash } from "node:crypto";
|
|
|
1336
1343
|
var LAYOUT_MANIFEST_SCHEMA = "helpdesk.layout-manifest/1";
|
|
1337
1344
|
var PHONE_SUFFIX = "--phone";
|
|
1338
1345
|
var PHONE_VIEWPORT = { width: 390, height: 844 };
|
|
1346
|
+
var PHONE_VIEWPORTS = [PHONE_VIEWPORT, { width: 430, height: 932 }];
|
|
1347
|
+
function phoneViewport(width) {
|
|
1348
|
+
return PHONE_VIEWPORTS.find((viewport) => viewport.width === width) ?? PHONE_VIEWPORT;
|
|
1349
|
+
}
|
|
1339
1350
|
var CONTROL_ROLES = [
|
|
1340
1351
|
"button",
|
|
1341
1352
|
"link",
|
|
@@ -1511,13 +1522,21 @@ function layoutManifestText(manifest) {
|
|
|
1511
1522
|
// core/capture/clips.ts
|
|
1512
1523
|
var CLIP_VIEWPORT = { width: 1280, height: 800 };
|
|
1513
1524
|
var CLIP_FPS = 10;
|
|
1514
|
-
var CLIP_MAX_MS =
|
|
1525
|
+
var CLIP_MAX_MS = 2e4;
|
|
1515
1526
|
var CLIP_MAX_STEPS = 12;
|
|
1516
1527
|
var CLIP_MAX_MP4_BYTES = 3 * 1024 * 1024;
|
|
1517
1528
|
var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
|
|
1518
1529
|
var CLIP_GIF_WIDTH = 960;
|
|
1519
|
-
var CLIP_ACTIONS = ["click", "fill", "select", "check"];
|
|
1520
|
-
|
|
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;
|
|
1521
1540
|
var MAX_TEXT2 = 200;
|
|
1522
1541
|
var MAX_TITLE = 120;
|
|
1523
1542
|
var MAX_ID = 80;
|
|
@@ -1540,21 +1559,40 @@ function readStep(value, at) {
|
|
|
1540
1559
|
const says = cleanText(step["says"]);
|
|
1541
1560
|
const given = step["value"];
|
|
1542
1561
|
if (!route || !isPathLike(route)) return `step ${at} names no screen`;
|
|
1543
|
-
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`;
|
|
1544
1563
|
if (!name || name.length > MAX_TEXT2) return `step ${at} names no control`;
|
|
1545
|
-
if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select nor
|
|
1564
|
+
if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select, check nor auto`;
|
|
1546
1565
|
if (!says || says.length > MAX_TEXT2) return `step ${at} has no sentence for a reader`;
|
|
1566
|
+
if (role === void 0 && action !== "auto") return `step ${at} says how to act on a control without saying what kind it is`;
|
|
1547
1567
|
const typed = action === "fill" || action === "select";
|
|
1548
|
-
|
|
1549
|
-
if (
|
|
1550
|
-
|
|
1568
|
+
const valued = typeof given === "string" && given.trim() !== "" && given.length <= MAX_TEXT2;
|
|
1569
|
+
if (typed && !valued) return `step ${at} has nothing to ${action}`;
|
|
1570
|
+
if (action !== "auto" && !typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
|
|
1571
|
+
if (action === "auto" && given !== void 0 && !valued) return `step ${at} has a value nothing can type`;
|
|
1572
|
+
return {
|
|
1573
|
+
route,
|
|
1574
|
+
control: { ...role === void 0 ? {} : { role }, name },
|
|
1575
|
+
action,
|
|
1576
|
+
...valued && (typed || action === "auto") ? { value: given } : {},
|
|
1577
|
+
says
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
function readSteps(raw) {
|
|
1581
|
+
const steps = [];
|
|
1582
|
+
for (const [index, value] of raw.entries()) {
|
|
1583
|
+
const step = readStep(value, index + 1);
|
|
1584
|
+
if (typeof step === "string") return step;
|
|
1585
|
+
steps.push(step);
|
|
1586
|
+
}
|
|
1587
|
+
return steps;
|
|
1551
1588
|
}
|
|
1552
|
-
function readClipScripts(raw) {
|
|
1589
|
+
function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
|
|
1553
1590
|
const scripts = [];
|
|
1554
1591
|
const refused = [];
|
|
1555
1592
|
if (raw === void 0) return { scripts, refused };
|
|
1556
1593
|
if (!Array.isArray(raw)) return { scripts, refused: [{ id: "clips", because: "clips is not a list" }] };
|
|
1557
|
-
|
|
1594
|
+
if (raw.length > limit) refused.push({ id: "clips", because: `${raw.length - limit} clip(s) past the first ${limit} were not read` });
|
|
1595
|
+
for (const value of raw.slice(0, limit)) {
|
|
1558
1596
|
const script = recordOf2(value);
|
|
1559
1597
|
const id = typeof script["id"] === "string" ? script["id"] : "";
|
|
1560
1598
|
const label = id || "a clip";
|
|
@@ -1579,21 +1617,18 @@ function readClipScripts(raw) {
|
|
|
1579
1617
|
refuse(`it has ${rawSteps.length} steps, and a clip has between 1 and ${CLIP_MAX_STEPS}`);
|
|
1580
1618
|
continue;
|
|
1581
1619
|
}
|
|
1582
|
-
const
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
if (typeof step === "string") {
|
|
1587
|
-
problem = step;
|
|
1588
|
-
break;
|
|
1589
|
-
}
|
|
1590
|
-
steps.push(step);
|
|
1620
|
+
const rawBefore = script["before"];
|
|
1621
|
+
if (rawBefore !== void 0 && (!Array.isArray(rawBefore) || rawBefore.length > CLIP_MAX_LEAD_STEPS)) {
|
|
1622
|
+
refuse(`its steps before the recording are not a list of at most ${CLIP_MAX_LEAD_STEPS}`);
|
|
1623
|
+
continue;
|
|
1591
1624
|
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1625
|
+
const steps = readSteps(rawSteps);
|
|
1626
|
+
const before = readSteps(rawBefore ?? []);
|
|
1627
|
+
if (typeof steps === "string" || typeof before === "string") {
|
|
1628
|
+
refuse(typeof before === "string" ? `before the recording, ${before}` : steps);
|
|
1594
1629
|
continue;
|
|
1595
1630
|
}
|
|
1596
|
-
const first = steps[0];
|
|
1631
|
+
const first = before[0] ?? steps[0];
|
|
1597
1632
|
if (!isPathLike(visit) || !fillsPattern(first.route, visit.split("?")[0])) {
|
|
1598
1633
|
refuse(`it opens at ${visit || "nowhere"}, which is not ${first.route}`);
|
|
1599
1634
|
continue;
|
|
@@ -1602,17 +1637,19 @@ function readClipScripts(raw) {
|
|
|
1602
1637
|
refuse("it names no screen to land on");
|
|
1603
1638
|
continue;
|
|
1604
1639
|
}
|
|
1605
|
-
scripts.push({ id, title, visit, steps, ...lands ? { lands } : {} });
|
|
1640
|
+
scripts.push({ id, title, visit, ...before.length ? { before } : {}, steps, ...lands ? { lands } : {} });
|
|
1606
1641
|
}
|
|
1607
1642
|
return { scripts, refused };
|
|
1608
1643
|
}
|
|
1609
1644
|
function resolveStep(manifest, step) {
|
|
1610
1645
|
const name = cleanText(step.control.name).toLowerCase();
|
|
1611
1646
|
const where = manifest.screen.path;
|
|
1612
|
-
const
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
if (matches
|
|
1647
|
+
const role = step.control.role;
|
|
1648
|
+
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` };
|
|
1616
1653
|
return { ok: true, control: matches[0] };
|
|
1617
1654
|
}
|
|
1618
1655
|
function onScreen(route, location2) {
|
|
@@ -1622,19 +1659,22 @@ function clipRoutes(script) {
|
|
|
1622
1659
|
return [.../* @__PURE__ */ new Set([...script.steps.map((step) => step.route), ...script.lands ? [script.lands] : []])];
|
|
1623
1660
|
}
|
|
1624
1661
|
function clipFingerprint(script, touched) {
|
|
1662
|
+
const stepsOf = (steps) => steps.map((step) => ({
|
|
1663
|
+
route: step.route,
|
|
1664
|
+
role: step.control.role ?? null,
|
|
1665
|
+
name: step.control.name,
|
|
1666
|
+
action: step.action,
|
|
1667
|
+
value: step.value ?? null,
|
|
1668
|
+
says: step.says
|
|
1669
|
+
}));
|
|
1625
1670
|
const canonical = {
|
|
1626
1671
|
version: 1,
|
|
1627
1672
|
id: script.id,
|
|
1628
1673
|
title: script.title,
|
|
1629
1674
|
lands: script.lands ?? null,
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
name: step.control.name,
|
|
1634
|
-
action: step.action,
|
|
1635
|
-
value: step.value ?? null,
|
|
1636
|
-
says: step.says
|
|
1637
|
-
})),
|
|
1675
|
+
// Only when there are some, so a clip with none keeps the fingerprint it had before steps could come first.
|
|
1676
|
+
...script.before?.length ? { before: stepsOf(script.before) } : {},
|
|
1677
|
+
steps: stepsOf(script.steps),
|
|
1638
1678
|
touched: [...touched].map((screen) => [screen.route, screen.shotId, screen.sha256]).sort((left, right) => String(left[0]).localeCompare(String(right[0])))
|
|
1639
1679
|
};
|
|
1640
1680
|
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
@@ -2584,7 +2624,7 @@ async function runCapture(options) {
|
|
|
2584
2624
|
const evidence = [];
|
|
2585
2625
|
const build = await options.browser.build?.().catch(() => void 0);
|
|
2586
2626
|
for (const [index, shot] of options.shots.entries()) {
|
|
2587
|
-
const taken = await captureOne(options, shot, build);
|
|
2627
|
+
const taken = shot.variant === "phone" ? await captureAtPhoneWidths(options, shot, build) : await captureOne(options, shot, build);
|
|
2588
2628
|
const outcome = shot.variant ? { ...taken.outcome, variant: shot.variant } : taken.outcome;
|
|
2589
2629
|
outcomes.push(outcome);
|
|
2590
2630
|
if (taken.observed && !shot.variant) survey.push(taken.observed);
|
|
@@ -2632,6 +2672,19 @@ async function layoutOf(page, shot, options, cover, size, landed) {
|
|
|
2632
2672
|
return void 0;
|
|
2633
2673
|
}
|
|
2634
2674
|
}
|
|
2675
|
+
async function captureAtPhoneWidths(options, shot, build) {
|
|
2676
|
+
const tried = [];
|
|
2677
|
+
let taken;
|
|
2678
|
+
for (const { width } of PHONE_VIEWPORTS) {
|
|
2679
|
+
taken = await captureOne(options, { ...shot, phoneWidth: width }, build);
|
|
2680
|
+
if (taken.unfit === void 0) return taken;
|
|
2681
|
+
tried.push(`at ${width}px ${taken.unfit}`);
|
|
2682
|
+
}
|
|
2683
|
+
return {
|
|
2684
|
+
...taken,
|
|
2685
|
+
outcome: { ...taken.outcome, reason: `not laid out for a phone (${tried.join("; ")}), so there is no phone-width frame` }
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2635
2688
|
async function captureOne(options, shot, build) {
|
|
2636
2689
|
if (shot.unvisitable) {
|
|
2637
2690
|
return { outcome: { shotId: shot.id, route: shot.route, status: "skipped", reason: shot.unvisitable } };
|
|
@@ -2639,7 +2692,7 @@ async function captureOne(options, shot, build) {
|
|
|
2639
2692
|
const visit = shot.visit ?? shot.route;
|
|
2640
2693
|
let page;
|
|
2641
2694
|
try {
|
|
2642
|
-
page = await options.browser.page(shot.variant ? { variant: shot.variant } : void 0);
|
|
2695
|
+
page = await options.browser.page(shot.variant ? { variant: shot.variant, ...shot.phoneWidth ? { width: shot.phoneWidth } : {} } : void 0);
|
|
2643
2696
|
await page.goto(new URL(visit, options.baseUrl).toString());
|
|
2644
2697
|
if (await page.isSignIn()) {
|
|
2645
2698
|
return {
|
|
@@ -2700,9 +2753,10 @@ async function captureOne(options, shot, build) {
|
|
|
2700
2753
|
route: shot.route,
|
|
2701
2754
|
status: "skipped",
|
|
2702
2755
|
result: observed.result,
|
|
2703
|
-
reason: `not laid out for a phone at ${
|
|
2756
|
+
reason: `not laid out for a phone at ${phoneViewport(shot.phoneWidth).width}px (${fit.because}), so there is no phone-width frame`
|
|
2704
2757
|
},
|
|
2705
|
-
observed
|
|
2758
|
+
observed,
|
|
2759
|
+
unfit: fit.because
|
|
2706
2760
|
};
|
|
2707
2761
|
}
|
|
2708
2762
|
const evidence = shot.variant ? void 0 : await readEvidence(settledPage, shot.route, options, build, visit);
|
|
@@ -2769,7 +2823,7 @@ async function captureOne(options, shot, build) {
|
|
|
2769
2823
|
});
|
|
2770
2824
|
const image = frame.value;
|
|
2771
2825
|
const size = pngSize(image);
|
|
2772
|
-
const minimum = shot.variant === "phone" ?
|
|
2826
|
+
const minimum = shot.variant === "phone" ? phoneViewport(shot.phoneWidth).width * CAPTURE_CONTRACT.stillScale : CAPTURE_CONTRACT.minStillWidth;
|
|
2773
2827
|
if (size && size.width < minimum) {
|
|
2774
2828
|
return {
|
|
2775
2829
|
outcome: {
|
|
@@ -2988,8 +3042,9 @@ async function playwrightBrowser(options = {}) {
|
|
|
2988
3042
|
};
|
|
2989
3043
|
return {
|
|
2990
3044
|
async page(wanted) {
|
|
3045
|
+
const phone = phoneViewport(wanted?.width);
|
|
2991
3046
|
const context = await browser.newContext(
|
|
2992
|
-
wanted?.variant === "phone" ? { ...contextOptions, viewport: { ...
|
|
3047
|
+
wanted?.variant === "phone" ? { ...contextOptions, viewport: { ...phone }, isMobile: true, hasTouch: true } : wanted?.variant === "clip" ? { ...contextOptions, viewport: { ...CLIP_VIEWPORT }, deviceScaleFactor: CAPTURE_CONTRACT.clipScale } : contextOptions
|
|
2993
3048
|
);
|
|
2994
3049
|
if (!options.liveClock) {
|
|
2995
3050
|
await context.addInitScript(freezeScript(CAPTURE_CONTRACT.pinnedClock));
|
|
@@ -3068,7 +3123,7 @@ async function playwrightBrowser(options = {}) {
|
|
|
3068
3123
|
return readPageLayout(page, cover ?? []);
|
|
3069
3124
|
},
|
|
3070
3125
|
async phoneFit() {
|
|
3071
|
-
return phoneFit(page,
|
|
3126
|
+
return phoneFit(page, phone.width).catch(() => ({ fits: false, because: "the page could not be measured at that width" }));
|
|
3072
3127
|
},
|
|
3073
3128
|
async location() {
|
|
3074
3129
|
const url = new URL(page.url());
|
|
@@ -3330,6 +3385,7 @@ var MOVE_FRAMES = 6;
|
|
|
3330
3385
|
var DWELL_MS = 500;
|
|
3331
3386
|
var PRESS_MS = 200;
|
|
3332
3387
|
var CHARS_PER_FRAME = 2;
|
|
3388
|
+
var TYPING_FRAMES = 12;
|
|
3333
3389
|
var SETTLE_MS = 3e3;
|
|
3334
3390
|
var STEP_WAIT_MS = 4e3;
|
|
3335
3391
|
var CLOSING_MS = 1200;
|
|
@@ -3396,11 +3452,75 @@ async function recordClip(options, script, touched) {
|
|
|
3396
3452
|
await keep(next, FRAME_MS);
|
|
3397
3453
|
return true;
|
|
3398
3454
|
};
|
|
3455
|
+
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
|
+
);
|
|
3467
|
+
const steady = async () => {
|
|
3468
|
+
let previous = await page.screenshot(cover);
|
|
3469
|
+
for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
|
|
3470
|
+
await pause(FRAME_MS);
|
|
3471
|
+
const next = await page.screenshot(cover);
|
|
3472
|
+
if (sameBytes(next, previous)) return;
|
|
3473
|
+
previous = next;
|
|
3474
|
+
}
|
|
3475
|
+
};
|
|
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) {
|
|
3479
|
+
await pause(FRAME_MS);
|
|
3480
|
+
where = await location2();
|
|
3481
|
+
}
|
|
3482
|
+
if (onScreen(route, where)) {
|
|
3483
|
+
await page.quiet?.(SETTLE_MS);
|
|
3484
|
+
await steady();
|
|
3485
|
+
}
|
|
3486
|
+
return where;
|
|
3487
|
+
};
|
|
3488
|
+
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);
|
|
3492
|
+
for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3493
|
+
await pause(FRAME_MS);
|
|
3494
|
+
resolution = await measureOn(step, where);
|
|
3495
|
+
}
|
|
3496
|
+
if (!resolution.ok) return resolution.because;
|
|
3497
|
+
const target = centreOf(resolution.control.box);
|
|
3498
|
+
const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
|
|
3499
|
+
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);
|
|
3503
|
+
} else if (action === "select") {
|
|
3504
|
+
await pointer({ kind: "select", at: target, value: step.value ?? "" });
|
|
3505
|
+
} else {
|
|
3506
|
+
await pointer({ kind: "click", at: target });
|
|
3507
|
+
}
|
|
3508
|
+
await page.quiet?.(SETTLE_MS);
|
|
3509
|
+
await steady();
|
|
3510
|
+
return null;
|
|
3511
|
+
};
|
|
3399
3512
|
await page.goto(new URL(script.visit, options.baseUrl).toString());
|
|
3400
3513
|
if (await page.isSignIn()) return refuse("its first screen asked for a sign-in, so this session is not the demo tenant's");
|
|
3401
3514
|
const opened = await location2();
|
|
3402
|
-
|
|
3403
|
-
|
|
3515
|
+
const performedFirst = script.before?.[0] ?? script.steps[0];
|
|
3516
|
+
if (!onScreen(performedFirst.route, opened)) return fail(`it opened on ${opened}, not ${performedFirst.route}`);
|
|
3517
|
+
for (const [index, step] of (script.before ?? []).entries()) {
|
|
3518
|
+
const problem = await lead(step);
|
|
3519
|
+
if (problem) return fail(`before the recording, step ${index + 1}: ${problem}`);
|
|
3520
|
+
}
|
|
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}`);
|
|
3523
|
+
const before = await judge(starting);
|
|
3404
3524
|
if (before) return refuse(before);
|
|
3405
3525
|
let resting = START;
|
|
3406
3526
|
let poster;
|
|
@@ -3427,26 +3547,17 @@ async function recordClip(options, script, touched) {
|
|
|
3427
3547
|
if (!onScreen(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
|
|
3428
3548
|
const arrived = await judge(where);
|
|
3429
3549
|
if (arrived) return refuse(arrived);
|
|
3430
|
-
|
|
3431
|
-
buildLayoutManifest(await layout(cover), {
|
|
3432
|
-
shotId: script.id,
|
|
3433
|
-
route: step.route,
|
|
3434
|
-
path: where.split("?")[0],
|
|
3435
|
-
variant: "desktop",
|
|
3436
|
-
image: { ...CLIP_VIEWPORT },
|
|
3437
|
-
origin: new URL(options.baseUrl).origin
|
|
3438
|
-
}),
|
|
3439
|
-
step
|
|
3440
|
-
);
|
|
3441
|
-
let resolution = await measure();
|
|
3550
|
+
let resolution = await measureOn(step, where);
|
|
3442
3551
|
for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3443
3552
|
await cursor(resting);
|
|
3444
3553
|
await film();
|
|
3445
|
-
resolution = await
|
|
3554
|
+
resolution = await measureOn(step, where);
|
|
3446
3555
|
}
|
|
3447
3556
|
if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
|
|
3448
3557
|
const target = centreOf(resolution.control.box);
|
|
3449
3558
|
const startedAt = at;
|
|
3559
|
+
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`);
|
|
3450
3561
|
for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
|
|
3451
3562
|
await cursor(point);
|
|
3452
3563
|
await shoot(FRAME_MS);
|
|
@@ -3456,14 +3567,15 @@ async function recordClip(options, script, touched) {
|
|
|
3456
3567
|
poster ??= frames[frames.length - 1].file;
|
|
3457
3568
|
await cursor(target, true);
|
|
3458
3569
|
await shoot(PRESS_MS);
|
|
3459
|
-
if (
|
|
3570
|
+
if (action === "fill") {
|
|
3571
|
+
const value = step.value ?? "";
|
|
3460
3572
|
await pointer({ kind: "focus", at: target });
|
|
3461
3573
|
await cursor(target, false);
|
|
3462
|
-
for (const chunk of chunksOf(
|
|
3574
|
+
for (const chunk of chunksOf(value, Math.max(CHARS_PER_FRAME, Math.ceil(value.length / TYPING_FRAMES)))) {
|
|
3463
3575
|
await type(chunk);
|
|
3464
3576
|
await shoot(FRAME_MS);
|
|
3465
3577
|
}
|
|
3466
|
-
} else if (
|
|
3578
|
+
} else if (action === "select") {
|
|
3467
3579
|
await pointer({ kind: "select", at: target, value: step.value ?? "" });
|
|
3468
3580
|
} else {
|
|
3469
3581
|
await pointer({ kind: "click", at: target });
|
|
@@ -3481,9 +3593,9 @@ async function recordClip(options, script, touched) {
|
|
|
3481
3593
|
if (after) return refuse(after);
|
|
3482
3594
|
steps.push({
|
|
3483
3595
|
route: step.route,
|
|
3484
|
-
role:
|
|
3596
|
+
role: resolution.control.role,
|
|
3485
3597
|
name: resolution.control.name,
|
|
3486
|
-
action
|
|
3598
|
+
action,
|
|
3487
3599
|
says: step.says,
|
|
3488
3600
|
atMs: startedAt,
|
|
3489
3601
|
box: resolution.control.box
|
|
@@ -3533,7 +3645,7 @@ async function recordClip(options, script, touched) {
|
|
|
3533
3645
|
await rm(dir, { recursive: true, force: true }).catch(() => void 0);
|
|
3534
3646
|
}
|
|
3535
3647
|
}
|
|
3536
|
-
var CLIP_BUDGET_MS =
|
|
3648
|
+
var CLIP_BUDGET_MS = 20 * 6e4;
|
|
3537
3649
|
async function runClips(options) {
|
|
3538
3650
|
const started = Date.now();
|
|
3539
3651
|
const outcomes = [];
|
|
@@ -4669,11 +4781,18 @@ function shotsFromRepo(repo, previous) {
|
|
|
4669
4781
|
}))
|
|
4670
4782
|
);
|
|
4671
4783
|
}
|
|
4672
|
-
function withPhoneShots(shots, previous) {
|
|
4784
|
+
function withPhoneShots(shots, previous, again = /* @__PURE__ */ new Set()) {
|
|
4673
4785
|
const planned = new Set(shots.map((shot) => shot.id));
|
|
4674
|
-
const phones = shots.filter((shot) => !shot.visit && !shot.unvisitable && !shot.variant && !planned.has(`${shot.id}${PHONE_SUFFIX}`)).map((shot) => {
|
|
4786
|
+
const phones = shots.filter((shot) => (!shot.visit || again.has(shot.route)) && !shot.unvisitable && !shot.variant && !planned.has(`${shot.id}${PHONE_SUFFIX}`)).map((shot) => {
|
|
4675
4787
|
const id = `${shot.id}${PHONE_SUFFIX}`;
|
|
4676
|
-
return {
|
|
4788
|
+
return {
|
|
4789
|
+
id,
|
|
4790
|
+
route: shot.route,
|
|
4791
|
+
...shot.visit ? { visit: shot.visit } : {},
|
|
4792
|
+
depicts: `${shot.depicts}, at phone width`,
|
|
4793
|
+
variant: "phone",
|
|
4794
|
+
...seenBefore(previous.get(id))
|
|
4795
|
+
};
|
|
4677
4796
|
});
|
|
4678
4797
|
return [...shots, ...phones];
|
|
4679
4798
|
}
|
|
@@ -4734,6 +4853,28 @@ async function writeLocalClip(dir, clip) {
|
|
|
4734
4853
|
durationMs: clip.recording.durationMs
|
|
4735
4854
|
};
|
|
4736
4855
|
}
|
|
4856
|
+
function articleClipsToRecord(raw, examples) {
|
|
4857
|
+
if (!Array.isArray(raw)) return readClipScripts(raw, CLIP_MAX_ARTICLE_SCRIPTS);
|
|
4858
|
+
const named = examples ?? { paths: /* @__PURE__ */ new Map(), refused: [] };
|
|
4859
|
+
const unopened = [];
|
|
4860
|
+
const opened = raw.map((value) => {
|
|
4861
|
+
const script = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
4862
|
+
const visit = typeof script["visit"] === "string" ? script["visit"] : "";
|
|
4863
|
+
const resolved = resolveVisit(visit, named);
|
|
4864
|
+
if (!("needs" in resolved)) return { ...script, visit: resolved.visit };
|
|
4865
|
+
unopened.push({ id: typeof script["id"] === "string" ? script["id"] : "a clip", because: describeMissingExample(visit, resolved.needs) });
|
|
4866
|
+
return void 0;
|
|
4867
|
+
});
|
|
4868
|
+
const read = readClipScripts(
|
|
4869
|
+
opened.filter((value) => value !== void 0),
|
|
4870
|
+
CLIP_MAX_ARTICLE_SCRIPTS
|
|
4871
|
+
);
|
|
4872
|
+
return { scripts: read.scripts, refused: [...unopened, ...read.refused] };
|
|
4873
|
+
}
|
|
4874
|
+
function clipsToRecord(seeded, articles) {
|
|
4875
|
+
const named = new Set(seeded.map((script) => script.id));
|
|
4876
|
+
return [...seeded, ...articles.filter((script) => !named.has(script.id))];
|
|
4877
|
+
}
|
|
4737
4878
|
function clipForm(clip) {
|
|
4738
4879
|
const id = clip.recording.clipId;
|
|
4739
4880
|
const form = new FormData();
|
|
@@ -4751,9 +4892,18 @@ async function clipsOnDisk(dir) {
|
|
|
4751
4892
|
return /* @__PURE__ */ new Map();
|
|
4752
4893
|
}
|
|
4753
4894
|
}
|
|
4895
|
+
function unrecordedLines(refused, skipped) {
|
|
4896
|
+
const lines = [
|
|
4897
|
+
...skipped.map((piece) => ` ${piece.slug ? `${piece.slug}${piece.at ? ` at ${piece.at}` : ""}` : "The help desk"}: ${piece.because}`),
|
|
4898
|
+
...refused.map((entry) => ` ${entry.id}: ${entry.because}`)
|
|
4899
|
+
];
|
|
4900
|
+
if (!lines.length) return [];
|
|
4901
|
+
const shown = lines.slice(0, 12);
|
|
4902
|
+
return [`${lines.length} piece(s) of the articles' steps are not recorded:`, ...shown, ...lines.length > shown.length ? [` and ${lines.length - shown.length} more.`] : []];
|
|
4903
|
+
}
|
|
4754
4904
|
async function recordSeededClips(args) {
|
|
4755
4905
|
if (!args.scripts.length) {
|
|
4756
|
-
console.log("\n--clips was given, but the seed command named no clips, so none were recorded.");
|
|
4906
|
+
console.log("\n--clips was given, but the seed command named no clips and no article asks for one, so none were recorded.");
|
|
4757
4907
|
return [];
|
|
4758
4908
|
}
|
|
4759
4909
|
if (!await ffmpegAvailable()) {
|
|
@@ -4766,9 +4916,12 @@ async function recordSeededClips(args) {
|
|
|
4766
4916
|
);
|
|
4767
4917
|
const previous = args.local ? await clipsOnDisk(args.local) : /* @__PURE__ */ new Map();
|
|
4768
4918
|
const written = [];
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
`
|
|
4919
|
+
const asked = args.scripts.length - args.named;
|
|
4920
|
+
console.log(
|
|
4921
|
+
`
|
|
4922
|
+
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.
|
|
4923
|
+
`
|
|
4924
|
+
);
|
|
4772
4925
|
const outcomes = await runClips({
|
|
4773
4926
|
baseUrl: args.baseUrl,
|
|
4774
4927
|
browser: args.browser,
|
|
@@ -4943,7 +5096,7 @@ Approve one on the plan page, then run this again.`
|
|
|
4943
5096
|
}
|
|
4944
5097
|
}
|
|
4945
5098
|
const withRecords = seeded?.examples ? withExamples(shots, seeded.examples) : shots;
|
|
4946
|
-
const planned = seededLast(parsed.phone ? withPhoneShots(withRecords, previous) : withRecords);
|
|
5099
|
+
const planned = seededLast(parsed.phone ? withPhoneShots(withRecords, previous, seeded?.examples?.again) : withRecords);
|
|
4947
5100
|
if (parsed.cassette) {
|
|
4948
5101
|
try {
|
|
4949
5102
|
stage = await stageAgainstRecording({
|
|
@@ -4993,7 +5146,7 @@ Approve one on the plan page, then run this again.`
|
|
|
4993
5146
|
console.log(`${coverageReport(found, shots.length).join("\n")}
|
|
4994
5147
|
`);
|
|
4995
5148
|
}
|
|
4996
|
-
const phones = parsed.phone ? `, each opened at its own address again at ${
|
|
5149
|
+
const phones = parsed.phone ? `, each opened at its own address again at phone width (${PHONE_VIEWPORTS.map((viewport) => `${viewport.width}px`).join(", then ")}) if it is laid out for a phone` : "";
|
|
4997
5150
|
console.log(
|
|
4998
5151
|
stage ? `Photographing ${shots.length} screen(s) of ${baseUrl}${phones}, served from ${parsed.cassette}.
|
|
4999
5152
|
` : `Photographing ${shots.length} screen(s) of ${baseUrl}${phones}.
|
|
@@ -5109,8 +5262,19 @@ ${unclassified.join("\n")}`);
|
|
|
5109
5262
|
Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
|
|
5110
5263
|
}
|
|
5111
5264
|
if (parsed.clips) {
|
|
5265
|
+
const named = seeded?.clips ?? [];
|
|
5266
|
+
const articles = seeded ? articleClipsToRecord(orders.clipScripts, seeded.examples) : { scripts: [], refused: [] };
|
|
5267
|
+
const asked = Array.isArray(orders.clipScripts) ? orders.clipScripts.length : 0;
|
|
5268
|
+
if (!seeded && asked) {
|
|
5269
|
+
console.log(`
|
|
5270
|
+
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.`);
|
|
5271
|
+
}
|
|
5272
|
+
const unrecorded = seeded ? unrecordedLines(articles.refused, Array.isArray(orders.clipsSkipped) ? orders.clipsSkipped : []) : [];
|
|
5273
|
+
if (unrecorded.length) console.log(`
|
|
5274
|
+
${unrecorded.join("\n")}`);
|
|
5112
5275
|
await recordSeededClips({
|
|
5113
|
-
scripts:
|
|
5276
|
+
scripts: clipsToRecord(named, articles.scripts),
|
|
5277
|
+
named: named.length,
|
|
5114
5278
|
browser,
|
|
5115
5279
|
baseUrl,
|
|
5116
5280
|
clearance,
|
|
@@ -5274,13 +5438,16 @@ if (runningAsCommand(import.meta.url)) {
|
|
|
5274
5438
|
);
|
|
5275
5439
|
}
|
|
5276
5440
|
export {
|
|
5441
|
+
articleClipsToRecord,
|
|
5277
5442
|
clipForm,
|
|
5443
|
+
clipsToRecord,
|
|
5278
5444
|
frameForm,
|
|
5279
5445
|
framesOnDisk,
|
|
5280
5446
|
main,
|
|
5281
5447
|
onePerScreen,
|
|
5282
5448
|
seededLast,
|
|
5283
5449
|
shotsFromRepo,
|
|
5450
|
+
unrecordedLines,
|
|
5284
5451
|
withExamples,
|
|
5285
5452
|
withPhoneShots,
|
|
5286
5453
|
writeLocalClip,
|