@mehshekari/gsap-motion 4.3.0 → 4.5.0
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/README.md +1 -1
- package/bin/gsap-motion.mjs +20 -2
- package/package.json +1 -1
- package/skill/SKILL.md +8 -8
- package/skill/adapter/next.md +1 -2
- package/skill/adapter/r3f.md +4 -1
- package/skill/adapter/react.md +8 -5
- package/skill/adapter/three.md +2 -0
- package/skill/example/magnetic-button.md +101 -101
- package/skill/preset/cursor.md +184 -184
- package/skill/preset/magnetic.md +155 -155
- package/skill/reference/devices.md +10 -0
- package/skill/reference/flip.md +220 -220
- package/skill/reference/interaction.md +1 -0
- package/skill/reference/performance.md +87 -0
- package/skill/reference/refine.md +63 -0
- package/skill/reference/routing.md +11 -1
- package/skill/reference/scrolltrigger.md +79 -0
- package/skill/scripts/audit-gsap.mjs +27 -3
- package/skill/scripts/capture-motion.mjs +248 -44
- package/skill/scripts/lib/ast.mjs +82 -12
- package/skill/scripts/lib/rules.mjs +311 -7
- package/skill/scripts/lib/source.mjs +9 -1
- package/skill/scripts/review-motion.mjs +224 -25
- package/skill/template/useScrollScene.ts +126 -126
- package/skill/template/useTimeline.ts +110 -110
|
@@ -19,13 +19,24 @@
|
|
|
19
19
|
* for a reviewer looking at the filmstrip; this reports evidence, with the time
|
|
20
20
|
* and the element, and leaves the verdict to them.
|
|
21
21
|
*
|
|
22
|
-
* Node.js 22+, no dependencies, whatever Chrome the machine already has.
|
|
22
|
+
* Node.js 22+, no dependencies, whatever Chrome the machine already has. With
|
|
23
|
+
* `--probe install` and `--probe read` it drives no browser at all, and prints
|
|
24
|
+
* the two functions to run in one you already have.
|
|
23
25
|
*/
|
|
24
|
-
import {
|
|
26
|
+
import { writeFileSync } from "node:fs";
|
|
25
27
|
import { join, resolve } from "node:path";
|
|
26
28
|
import { fileURLToPath } from "node:url";
|
|
27
29
|
|
|
28
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
connect,
|
|
32
|
+
exitFor,
|
|
33
|
+
findChrome,
|
|
34
|
+
gpuNote,
|
|
35
|
+
parseViewport,
|
|
36
|
+
prepareOut,
|
|
37
|
+
scrollExpression,
|
|
38
|
+
undone,
|
|
39
|
+
} from "./capture-motion.mjs";
|
|
29
40
|
|
|
30
41
|
// --- What a recording means ---------------------------------------------------
|
|
31
42
|
|
|
@@ -199,7 +210,7 @@ export function afterScroll(found, scrolledAt, { settle = 300 } = {}) {
|
|
|
199
210
|
*/
|
|
200
211
|
export function seamEvents(torn, { window = 50 } = {}) {
|
|
201
212
|
const flat = torn.flatMap((element) =>
|
|
202
|
-
element.seams.map((seam) => ({ key: element.key, at: seam.at, jump: seam.jump })),
|
|
213
|
+
element.seams.map((seam) => ({ key: element.key, box: element.box, at: seam.at, jump: seam.jump })),
|
|
203
214
|
);
|
|
204
215
|
flat.sort((a, b) => a.at - b.at);
|
|
205
216
|
|
|
@@ -215,6 +226,7 @@ export function seamEvents(torn, { window = 50 } = {}) {
|
|
|
215
226
|
events.push({
|
|
216
227
|
at: seam.at,
|
|
217
228
|
elements: new Set([seam.key]),
|
|
229
|
+
box: seam.box,
|
|
218
230
|
least: seam.jump,
|
|
219
231
|
most: seam.jump,
|
|
220
232
|
});
|
|
@@ -224,11 +236,44 @@ export function seamEvents(torn, { window = 50 } = {}) {
|
|
|
224
236
|
at: event.at,
|
|
225
237
|
count: event.elements.size,
|
|
226
238
|
example: [...event.elements][0],
|
|
239
|
+
/**
|
|
240
|
+
* Where the example was on screen, as [x, y, width, height]. A key like
|
|
241
|
+
* `div.absolute` names hundreds of elements in a Tailwind project; a real
|
|
242
|
+
* report could not find the one that tore.
|
|
243
|
+
*/
|
|
244
|
+
box: event.box ?? null,
|
|
227
245
|
least: event.least,
|
|
228
246
|
most: event.most,
|
|
229
247
|
}));
|
|
230
248
|
}
|
|
231
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The long frames, with the sampler's own time taken out of each.
|
|
252
|
+
*
|
|
253
|
+
* Reading every moving element's box and style is work inside every frame
|
|
254
|
+
* this records, so a long frame is partly the recorder's. `cost` is that work,
|
|
255
|
+
* per frame, as `[at, ms]`; what falls inside a long frame is subtracted, and
|
|
256
|
+
* the rest is the page's own. Null when the browser has no Long Animation
|
|
257
|
+
* Frames API, which is not the same as no long frames.
|
|
258
|
+
*/
|
|
259
|
+
export function longFrameSummary(longFrames, cost = []) {
|
|
260
|
+
if (!longFrames) return null;
|
|
261
|
+
const own = (frame) =>
|
|
262
|
+
cost
|
|
263
|
+
.filter(([at]) => at >= frame.at && at <= frame.at + frame.duration)
|
|
264
|
+
.reduce((sum, [, ms]) => sum + ms, 0);
|
|
265
|
+
const frames = longFrames.map((frame) => ({
|
|
266
|
+
...frame,
|
|
267
|
+
page: Math.max(0, Math.round(frame.duration - own(frame))),
|
|
268
|
+
}));
|
|
269
|
+
const longest = frames.reduce((a, b) => (b.duration > (a?.duration ?? -1) ? b : a), null);
|
|
270
|
+
return {
|
|
271
|
+
count: frames.length,
|
|
272
|
+
longest,
|
|
273
|
+
blocking: frames.reduce((sum, frame) => sum + frame.blocking, 0),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
232
277
|
/** Each element and the moment it first moved, in that order. */
|
|
233
278
|
export function starts(log) {
|
|
234
279
|
const first = new Map();
|
|
@@ -253,12 +298,15 @@ export function parseOptions(argv) {
|
|
|
253
298
|
watch: 3000,
|
|
254
299
|
scroll: null,
|
|
255
300
|
mobile: false,
|
|
301
|
+
viewport: null,
|
|
256
302
|
reduced: false,
|
|
257
303
|
dark: false,
|
|
258
304
|
cpu: 1,
|
|
259
305
|
out: null,
|
|
260
306
|
json: false,
|
|
261
307
|
chrome: null,
|
|
308
|
+
headed: false,
|
|
309
|
+
probe: null,
|
|
262
310
|
errors: [],
|
|
263
311
|
};
|
|
264
312
|
|
|
@@ -300,9 +348,18 @@ export function parseOptions(argv) {
|
|
|
300
348
|
case "--chrome":
|
|
301
349
|
options.chrome = next() ?? null;
|
|
302
350
|
break;
|
|
351
|
+
case "--headed":
|
|
352
|
+
options.headed = true;
|
|
353
|
+
break;
|
|
303
354
|
case "--mobile":
|
|
304
355
|
options.mobile = true;
|
|
305
356
|
break;
|
|
357
|
+
case "--viewport": {
|
|
358
|
+
const size = parseViewport(next());
|
|
359
|
+
if (size) options.viewport = size;
|
|
360
|
+
else options.errors.push("--viewport takes a width and height, such as --viewport 1440x900.");
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
306
363
|
case "--reduced":
|
|
307
364
|
options.reduced = true;
|
|
308
365
|
break;
|
|
@@ -312,12 +369,27 @@ export function parseOptions(argv) {
|
|
|
312
369
|
case "--json":
|
|
313
370
|
options.json = true;
|
|
314
371
|
break;
|
|
372
|
+
case "--probe": {
|
|
373
|
+
const kind = next();
|
|
374
|
+
if (kind !== "install" && kind !== "read") {
|
|
375
|
+
options.errors.push("--probe takes install or read.");
|
|
376
|
+
} else {
|
|
377
|
+
options.probe = kind;
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
315
381
|
default:
|
|
316
382
|
options.errors.push(`Unknown option: ${argument}`);
|
|
317
383
|
}
|
|
318
384
|
}
|
|
319
385
|
|
|
320
|
-
|
|
386
|
+
/** A probe is printed, not run, so it needs no page. */
|
|
387
|
+
if (options.url === null && options.probe === null) {
|
|
388
|
+
options.errors.push("No URL. Pass the page to review.");
|
|
389
|
+
}
|
|
390
|
+
if (options.mobile && options.viewport) {
|
|
391
|
+
options.errors.push("--mobile sets its own viewport; drop one of --mobile and --viewport.");
|
|
392
|
+
}
|
|
321
393
|
return options;
|
|
322
394
|
}
|
|
323
395
|
|
|
@@ -366,9 +438,46 @@ const RECORDER = `(() => {
|
|
|
366
438
|
return id;
|
|
367
439
|
};
|
|
368
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Long frames and layout shifts, where the browser reports them. The sampler
|
|
443
|
+
* below is itself work inside every frame, so its own time is kept too, and a
|
|
444
|
+
* long frame is reported with that time taken out. A read never shifts
|
|
445
|
+
* layout, so the shifts are the page's alone.
|
|
446
|
+
*/
|
|
447
|
+
const supported = (type) =>
|
|
448
|
+
typeof PerformanceObserver !== "undefined" &&
|
|
449
|
+
(PerformanceObserver.supportedEntryTypes || []).includes(type);
|
|
450
|
+
const longFrames = supported("long-animation-frame") ? [] : null;
|
|
451
|
+
const shift = supported("layout-shift") ? { total: 0, count: 0, largest: null } : null;
|
|
452
|
+
const cost = [];
|
|
453
|
+
if (longFrames) {
|
|
454
|
+
new PerformanceObserver((list) => {
|
|
455
|
+
for (const entry of list.getEntries()) {
|
|
456
|
+
longFrames.push({
|
|
457
|
+
at: Math.round(entry.startTime - started),
|
|
458
|
+
duration: Math.round(entry.duration),
|
|
459
|
+
blocking: Math.round(entry.blockingDuration || 0),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}).observe({ type: "long-animation-frame" });
|
|
463
|
+
}
|
|
464
|
+
if (shift) {
|
|
465
|
+
new PerformanceObserver((list) => {
|
|
466
|
+
for (const entry of list.getEntries()) {
|
|
467
|
+
if (entry.hadRecentInput) continue;
|
|
468
|
+
shift.total += entry.value;
|
|
469
|
+
shift.count += 1;
|
|
470
|
+
if (!shift.largest || entry.value > shift.largest.value) {
|
|
471
|
+
shift.largest = { at: Math.round(entry.startTime - started), value: entry.value };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}).observe({ type: "layout-shift" });
|
|
475
|
+
}
|
|
476
|
+
|
|
369
477
|
const tick =() => {
|
|
478
|
+
const began = performance.now();
|
|
370
479
|
frames += 1;
|
|
371
|
-
const at = Math.round(
|
|
480
|
+
const at = Math.round(began - started);
|
|
372
481
|
const now = new Map();
|
|
373
482
|
const moved = [];
|
|
374
483
|
/**
|
|
@@ -428,6 +537,7 @@ const RECORDER = `(() => {
|
|
|
428
537
|
|
|
429
538
|
previous = now;
|
|
430
539
|
if (moved.length) log.push({ at, moved, scrolled });
|
|
540
|
+
cost.push([at, Math.round((performance.now() - began) * 10) / 10]);
|
|
431
541
|
requestAnimationFrame(tick);
|
|
432
542
|
};
|
|
433
543
|
requestAnimationFrame(tick);
|
|
@@ -471,6 +581,10 @@ const RECORDER = `(() => {
|
|
|
471
581
|
viewport: { width: innerWidth, height: innerHeight },
|
|
472
582
|
text: textLines(),
|
|
473
583
|
log,
|
|
584
|
+
longFrames,
|
|
585
|
+
shift,
|
|
586
|
+
cost,
|
|
587
|
+
canvases: document.querySelectorAll("canvas").length,
|
|
474
588
|
});
|
|
475
589
|
return true;
|
|
476
590
|
})()`;
|
|
@@ -485,7 +599,7 @@ async function watch(options) {
|
|
|
485
599
|
throw new Error("No Chrome found. Install Chrome, or pass --chrome <path>, or set CHROME_PATH.");
|
|
486
600
|
}
|
|
487
601
|
|
|
488
|
-
const browser = connect(executable);
|
|
602
|
+
const browser = connect(executable, { headless: !options.headed });
|
|
489
603
|
const notes = [];
|
|
490
604
|
|
|
491
605
|
try {
|
|
@@ -518,6 +632,9 @@ async function watch(options) {
|
|
|
518
632
|
});
|
|
519
633
|
await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
|
|
520
634
|
}
|
|
635
|
+
if (options.viewport) {
|
|
636
|
+
await call("Emulation.setDeviceMetricsOverride", { ...options.viewport, deviceScaleFactor: 1, mobile: false });
|
|
637
|
+
}
|
|
521
638
|
if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
|
|
522
639
|
|
|
523
640
|
await call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDER });
|
|
@@ -526,19 +643,13 @@ async function watch(options) {
|
|
|
526
643
|
await call("Page.navigate", { url: options.url });
|
|
527
644
|
await wait(options.watch);
|
|
528
645
|
|
|
646
|
+
const notDone = [];
|
|
529
647
|
if (options.scroll !== null) {
|
|
530
|
-
const
|
|
531
|
-
const moved = await evaluate(
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
if (!element) return null;
|
|
536
|
-
element.scrollIntoView({ block: "center" });
|
|
537
|
-
return Math.round(window.scrollY);
|
|
538
|
-
})()`
|
|
539
|
-
: `(window.scrollTo(0, ${pixels}), Math.round(window.scrollY))`,
|
|
540
|
-
);
|
|
541
|
-
notes.push(moved === null ? `scroll: nothing matched ${options.scroll}` : `scroll: to ${moved}px`);
|
|
648
|
+
const step = { type: "scroll", value: options.scroll };
|
|
649
|
+
const moved = await evaluate(scrollExpression(options.scroll));
|
|
650
|
+
const missed = undone(step, moved);
|
|
651
|
+
if (missed) notDone.push(missed);
|
|
652
|
+
else notes.push(`scroll: to ${moved.y}px`);
|
|
542
653
|
await wait(options.watch);
|
|
543
654
|
}
|
|
544
655
|
|
|
@@ -557,9 +668,9 @@ async function watch(options) {
|
|
|
557
668
|
|
|
558
669
|
let shot = null;
|
|
559
670
|
if (options.out) {
|
|
560
|
-
|
|
671
|
+
const out = prepareOut(options.out);
|
|
561
672
|
const png = await call("Page.captureScreenshot", { format: "png" });
|
|
562
|
-
shot = join(
|
|
673
|
+
shot = join(out, "review.png");
|
|
563
674
|
writeFileSync(shot, Buffer.from(png.data, "base64"));
|
|
564
675
|
}
|
|
565
676
|
|
|
@@ -570,6 +681,7 @@ async function watch(options) {
|
|
|
570
681
|
recalcs: delta("RecalcStyleCount"),
|
|
571
682
|
shot,
|
|
572
683
|
notes,
|
|
684
|
+
notDone,
|
|
573
685
|
};
|
|
574
686
|
} finally {
|
|
575
687
|
await browser.close();
|
|
@@ -590,6 +702,7 @@ export function conclude(result) {
|
|
|
590
702
|
/** One track per element, to find the loops that tear. */
|
|
591
703
|
const tracks = new Map();
|
|
592
704
|
const named = new Map();
|
|
705
|
+
const boxes = new Map();
|
|
593
706
|
for (const frame of log) {
|
|
594
707
|
for (const moved of frame.moved) {
|
|
595
708
|
const id = moved.id ?? moved.key;
|
|
@@ -597,6 +710,7 @@ export function conclude(result) {
|
|
|
597
710
|
track.push({ at: frame.at, x: moved.x });
|
|
598
711
|
tracks.set(id, track);
|
|
599
712
|
named.set(id, moved.key);
|
|
713
|
+
boxes.set(id, moved.box);
|
|
600
714
|
}
|
|
601
715
|
}
|
|
602
716
|
const scrolledAt = log.filter((frame) => frame.scrolled).map((frame) => frame.at);
|
|
@@ -606,7 +720,12 @@ export function conclude(result) {
|
|
|
606
720
|
if (track.length < 20) continue;
|
|
607
721
|
const found = afterScroll(seams(track), scrolledAt);
|
|
608
722
|
if (found.length) {
|
|
609
|
-
torn.push({
|
|
723
|
+
torn.push({
|
|
724
|
+
key: named.get(key) ?? key,
|
|
725
|
+
box: boxes.get(key) ?? null,
|
|
726
|
+
seams: found.slice(0, 3),
|
|
727
|
+
frames: track.length,
|
|
728
|
+
});
|
|
610
729
|
}
|
|
611
730
|
}
|
|
612
731
|
|
|
@@ -629,7 +748,48 @@ export function conclude(result) {
|
|
|
629
748
|
overText,
|
|
630
749
|
began: began.slice(0, 8),
|
|
631
750
|
spread: began.length > 1 ? began.at(-1).at - began[0].at : 0,
|
|
751
|
+
/** Both absent from a recording made before they were measured: unknown, not zero. */
|
|
752
|
+
longFrames: longFrameSummary(recorded.longFrames, recorded.cost),
|
|
753
|
+
canvases: recorded.canvases ?? null,
|
|
754
|
+
shift: recorded.shift
|
|
755
|
+
? { ...recorded.shift, total: Math.round(recorded.shift.total * 1000) / 1000 }
|
|
756
|
+
: null,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// --- In a browser this does not drive -----------------------------------------
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* The recorder and its conclusions, as functions for a browser something else
|
|
764
|
+
* already holds — a DevTools MCP server's `evaluate_script`, where a project
|
|
765
|
+
* allows no second Chrome.
|
|
766
|
+
*
|
|
767
|
+
* `install` records from the moment it runs, not from navigation, so first
|
|
768
|
+
* motion is measured from there. `read` concludes in the page with the same
|
|
769
|
+
* functions this file uses, and returns only the findings: the frame log never
|
|
770
|
+
* crosses the wire. Emulation is the driving tool's own to set.
|
|
771
|
+
*/
|
|
772
|
+
export function probe(kind) {
|
|
773
|
+
if (kind === "install") return `() => ${RECORDER}`;
|
|
774
|
+
const helpers = {
|
|
775
|
+
overlap,
|
|
776
|
+
coverage,
|
|
777
|
+
seams,
|
|
778
|
+
motionOverText,
|
|
779
|
+
afterScroll,
|
|
780
|
+
seamEvents,
|
|
781
|
+
starts,
|
|
782
|
+
firstMotion,
|
|
783
|
+
longFrameSummary,
|
|
784
|
+
conclude,
|
|
632
785
|
};
|
|
786
|
+
return `() => {
|
|
787
|
+
if (!window.__gsapMotionReview) return null;
|
|
788
|
+
${Object.entries(helpers)
|
|
789
|
+
.map(([name, fn]) => ` const ${name} = ${fn};`)
|
|
790
|
+
.join("\n")}
|
|
791
|
+
return conclude({ recorded: window.__gsapMotionReview() });
|
|
792
|
+
}`;
|
|
633
793
|
}
|
|
634
794
|
|
|
635
795
|
// --- Report --------------------------------------------------------------------
|
|
@@ -643,9 +803,14 @@ export function report(result, options) {
|
|
|
643
803
|
options.dark && "dark",
|
|
644
804
|
options.mobile && "mobile 390x844",
|
|
645
805
|
options.cpu > 1 && `CPU ÷${options.cpu}`,
|
|
806
|
+
options.headed && "headed, on the GPU",
|
|
646
807
|
].filter(Boolean);
|
|
647
808
|
if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
|
|
809
|
+
const { viewport } = result.recorded;
|
|
810
|
+
if (viewport) lines.push(` viewport: ${viewport.width}x${viewport.height}`);
|
|
648
811
|
for (const note of result.notes) lines.push(` ${note}`);
|
|
812
|
+
const gpu = gpuNote(found.canvases, options);
|
|
813
|
+
if (gpu) lines.push(` ${gpu}`);
|
|
649
814
|
|
|
650
815
|
lines.push("", "What moved");
|
|
651
816
|
if (found.firstMotion === null) {
|
|
@@ -655,7 +820,7 @@ export function report(result, options) {
|
|
|
655
820
|
lines.push(` first motion at ${found.firstMotion}ms`);
|
|
656
821
|
lines.push(
|
|
657
822
|
` ${found.movers} elements moved, ${found.gsapMovers} of them GSAP's` +
|
|
658
|
-
`${found.movers > found.gsapMovers ? " (the rest is CSS, which the source audit cannot see)" : ""}`,
|
|
823
|
+
`${found.movers > found.gsapMovers ? " (the rest is CSS, Web Animations or other script, which the source audit cannot see)" : ""}`,
|
|
659
824
|
);
|
|
660
825
|
if (found.spread) lines.push(` they started over a span of ${found.spread}ms`);
|
|
661
826
|
}
|
|
@@ -680,13 +845,36 @@ export function report(result, options) {
|
|
|
680
845
|
*/
|
|
681
846
|
lines.push(" layout work is not measured here — sampling forces it; use inspect for that");
|
|
682
847
|
|
|
848
|
+
const long = found.longFrames;
|
|
849
|
+
if (long === null) {
|
|
850
|
+
lines.push(" long frames: not measured — this browser has no Long Animation Frames API");
|
|
851
|
+
} else if (long.count === 0) {
|
|
852
|
+
lines.push(" no frame took longer than 50ms");
|
|
853
|
+
} else {
|
|
854
|
+
lines.push(
|
|
855
|
+
` ${long.count} frame${long.count === 1 ? "" : "s"} over 50ms; the longest` +
|
|
856
|
+
` ${long.longest.duration}ms at ${long.longest.at}ms, ${long.longest.page}ms of it` +
|
|
857
|
+
" the page's own rather than the sampler's",
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
const shift = found.shift;
|
|
861
|
+
if (shift === null) {
|
|
862
|
+
lines.push(" layout shift: not measured — this browser does not report it");
|
|
863
|
+
} else {
|
|
864
|
+
lines.push(
|
|
865
|
+
` layout shift: ${shift.total} over ${shift.count} shift${shift.count === 1 ? "" : "s"}` +
|
|
866
|
+
`${shift.largest ? `, the largest ${Math.round(shift.largest.value * 1000) / 1000} at ${shift.largest.at}ms` : ""}`,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
683
870
|
if (found.seams.length) {
|
|
684
871
|
lines.push("", "Seams — a step far larger than the motion's own rhythm");
|
|
685
872
|
for (const event of found.seams.slice(0, 6)) {
|
|
686
873
|
const size = event.least === event.most ? `${event.most}px` : `${event.least}-${event.most}px`;
|
|
874
|
+
const where = event.box ? ` at ${event.box.map(Math.round).join(",")}` : "";
|
|
687
875
|
lines.push(
|
|
688
876
|
` ${event.at}ms ${event.count === 1 ? event.example : `${event.count} elements (${event.example})`}` +
|
|
689
|
-
|
|
877
|
+
`${where} jumps ${size}`,
|
|
690
878
|
);
|
|
691
879
|
}
|
|
692
880
|
}
|
|
@@ -701,6 +889,11 @@ export function report(result, options) {
|
|
|
701
889
|
if (result.shot) lines.push("", `Frame: ${result.shot}`);
|
|
702
890
|
|
|
703
891
|
lines.push("", "These are measurements. Composition, rhythm and taste are yours to judge.");
|
|
892
|
+
/** Last, so a reader who sees only the end still sees it. */
|
|
893
|
+
if (result.notDone?.length) {
|
|
894
|
+
lines.push("", "Not done — the run exits non-zero");
|
|
895
|
+
for (const line of result.notDone) lines.push(` ${line}`);
|
|
896
|
+
}
|
|
704
897
|
return lines.join("\n");
|
|
705
898
|
}
|
|
706
899
|
|
|
@@ -714,13 +907,19 @@ async function main(argv) {
|
|
|
714
907
|
process.exit(2);
|
|
715
908
|
}
|
|
716
909
|
|
|
910
|
+
if (options.probe) {
|
|
911
|
+
console.log(probe(options.probe));
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
717
915
|
try {
|
|
718
916
|
const result = await watch(options);
|
|
719
917
|
console.log(
|
|
720
918
|
options.json
|
|
721
|
-
? JSON.stringify({ url: result.url, ...conclude(result) }, null, 2)
|
|
919
|
+
? JSON.stringify({ url: result.url, ...conclude(result), notDone: result.notDone }, null, 2)
|
|
722
920
|
: report(result, options),
|
|
723
921
|
);
|
|
922
|
+
process.exitCode = exitFor(result.notDone);
|
|
724
923
|
} catch (error) {
|
|
725
924
|
console.error(error.message);
|
|
726
925
|
process.exit(1);
|