@juangadm/pre-post 1.4.0 → 1.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.
Files changed (62) hide show
  1. package/README.md +1 -0
  2. package/assets/labels/post.png +0 -0
  3. package/assets/labels/pre.png +0 -0
  4. package/dist/attach.d.ts +59 -0
  5. package/dist/attach.d.ts.map +1 -0
  6. package/dist/attach.js +137 -0
  7. package/dist/attach.js.map +1 -0
  8. package/dist/bin/cli.js +20 -0
  9. package/dist/bin/cli.js.map +1 -1
  10. package/dist/browser.d.ts +48 -2
  11. package/dist/browser.d.ts.map +1 -1
  12. package/dist/browser.js +60 -31
  13. package/dist/browser.js.map +1 -1
  14. package/dist/commands/doctor.d.ts +6 -0
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +14 -0
  17. package/dist/commands/doctor.js.map +1 -1
  18. package/dist/commands/pr.d.ts +5 -0
  19. package/dist/commands/pr.d.ts.map +1 -1
  20. package/dist/commands/pr.js +51 -19
  21. package/dist/commands/pr.js.map +1 -1
  22. package/dist/compose.d.ts +72 -0
  23. package/dist/compose.d.ts.map +1 -0
  24. package/dist/compose.js +237 -0
  25. package/dist/compose.js.map +1 -0
  26. package/dist/ffmpeg.d.ts +35 -0
  27. package/dist/ffmpeg.d.ts.map +1 -0
  28. package/dist/ffmpeg.js +143 -0
  29. package/dist/ffmpeg.js.map +1 -0
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +2 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/moments.d.ts +83 -0
  35. package/dist/moments.d.ts.map +1 -0
  36. package/dist/moments.js +170 -0
  37. package/dist/moments.js.map +1 -0
  38. package/dist/record.d.ts +69 -0
  39. package/dist/record.d.ts.map +1 -0
  40. package/dist/record.js +223 -0
  41. package/dist/record.js.map +1 -0
  42. package/dist/report.d.ts +9 -0
  43. package/dist/report.d.ts.map +1 -1
  44. package/dist/report.js +56 -3
  45. package/dist/report.js.map +1 -1
  46. package/dist/run.d.ts +2 -0
  47. package/dist/run.d.ts.map +1 -1
  48. package/dist/run.js +2 -1
  49. package/dist/run.js.map +1 -1
  50. package/dist/timeline.d.ts +46 -0
  51. package/dist/timeline.d.ts.map +1 -0
  52. package/dist/timeline.js +80 -0
  53. package/dist/timeline.js.map +1 -0
  54. package/dist/types.d.ts +34 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/video.d.ts +19 -0
  58. package/dist/video.d.ts.map +1 -0
  59. package/dist/video.js +109 -0
  60. package/dist/video.js.map +1 -0
  61. package/package.json +4 -2
  62. package/skill/SKILL.md +24 -5
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Moments: short, named interactions recorded on Pre and Post and played side
3
+ * by side in one clip.
4
+ *
5
+ * A screenshot cannot show a menu opening or a hover state; a recording of a
6
+ * page sitting still shows nothing either (that is why the first GIF attempt
7
+ * was parked). So the unit of video is an interaction a person would do —
8
+ * "Open the account menu" — written as a few plain steps that target what the
9
+ * person sees, not CSS.
10
+ *
11
+ * Everything here is pure: parsing, validation, choosing which Moments run,
12
+ * and the sentences a reviewer reads. The browser side is `record.ts`.
13
+ */
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import { parseViewport } from './viewport.js';
17
+ const VERBS = ['click', 'hover', 'type', 'press', 'scroll', 'wait'];
18
+ /**
19
+ * Moments per run. A big PR gets video for the few changes a reviewer most
20
+ * needs to see move; every changed page still gets screenshots. Order is
21
+ * priority: the first three run, the rest are named in one line.
22
+ */
23
+ export const MAX_MOMENTS = 3;
24
+ /**
25
+ * Past this a clip still ships, with a nudge to split it. Not a cap: length
26
+ * comes only from the steps someone wrote, so nothing records for minutes on
27
+ * its own, and a clip a reviewer can take in on one play is the goal.
28
+ */
29
+ export const LONG_CLIP_MS = 30_000;
30
+ /** What a clip should aim for; used in the nudge. */
31
+ const TARGET_CLIP_S = 15;
32
+ /** A `wait` longer than this is almost certainly a mistake (ms, not s). */
33
+ const MAX_WAIT_MS = 10_000;
34
+ export class MomentError extends Error {
35
+ }
36
+ function fail(where, message) {
37
+ throw new MomentError(`${where}: ${message}`);
38
+ }
39
+ function parseStep(raw, where) {
40
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
41
+ fail(where, `expected a step like { "click": "Save" }, got ${JSON.stringify(raw)}`);
42
+ }
43
+ const keys = Object.keys(raw);
44
+ if (keys.length !== 1 || !VERBS.includes(keys[0])) {
45
+ fail(where, `a step has exactly one of ${VERBS.join(', ')} (got ${keys.join(', ') || 'nothing'})`);
46
+ }
47
+ const verb = keys[0];
48
+ const value = raw[verb];
49
+ const text = (v) => typeof v === 'string' && v.trim() ? v : null;
50
+ switch (verb) {
51
+ case 'click':
52
+ case 'hover': {
53
+ const target = text(value);
54
+ if (!target)
55
+ fail(where, `"${verb}" needs the text of what to ${verb}, e.g. { "${verb}": "Save" }`);
56
+ return { verb, target };
57
+ }
58
+ case 'type': {
59
+ if (!Array.isArray(value) || value.length !== 2 || !text(value[0]) || typeof value[1] !== 'string') {
60
+ fail(where, '"type" needs [field, text], e.g. { "type": ["Email", "ada@example.com"] }');
61
+ }
62
+ return { verb, target: value[0], text: value[1] };
63
+ }
64
+ case 'press': {
65
+ const key = text(value);
66
+ if (!key)
67
+ fail(where, '"press" needs a key, e.g. { "press": "Escape" }');
68
+ return { verb, key };
69
+ }
70
+ case 'scroll': {
71
+ if (typeof value === 'number' && Number.isFinite(value))
72
+ return { verb, px: value };
73
+ const target = text(value);
74
+ if (!target)
75
+ fail(where, '"scroll" needs the text to scroll to, or a number of pixels');
76
+ return { verb, target };
77
+ }
78
+ case 'wait': {
79
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
80
+ fail(where, '"wait" needs milliseconds, e.g. { "wait": 600 }');
81
+ }
82
+ if (value > MAX_WAIT_MS)
83
+ fail(where, `"wait" is in milliseconds; ${value} is over ${MAX_WAIT_MS / 1000}s`);
84
+ return { verb, ms: value };
85
+ }
86
+ }
87
+ }
88
+ /** Validate one Moment. `where` names it in errors, e.g. "Moment 2". */
89
+ export function parseMoment(raw, where) {
90
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
91
+ fail(where, 'expected an object with name, route and steps');
92
+ const name = typeof raw.name === 'string' ? raw.name.trim() : '';
93
+ if (!name)
94
+ fail(where, 'needs a "name" — the caption a reviewer reads, e.g. "Open the account menu"');
95
+ const label = `${where} ("${name}")`;
96
+ const route = typeof raw.route === 'string' ? raw.route.trim() : '';
97
+ if (!route.startsWith('/'))
98
+ fail(label, 'needs a "route" starting with /, e.g. "/settings"');
99
+ const viewport = raw.viewport === undefined ? 'desktop' : typeof raw.viewport === 'string' ? raw.viewport.trim() : '';
100
+ // Checked here, with the rest of the file, so a typo stops the run before
101
+ // any capture instead of costing one Moment after all of them were recorded.
102
+ try {
103
+ parseViewport(viewport);
104
+ }
105
+ catch {
106
+ fail(label, `"viewport" is desktop, tablet, mobile or WxH (got ${JSON.stringify(raw.viewport)})`);
107
+ }
108
+ if (raw.steps !== undefined && !Array.isArray(raw.steps))
109
+ fail(label, '"steps" is a list');
110
+ const steps = (raw.steps ?? []).map((s, i) => parseStep(s, `${label} step ${i + 1}`));
111
+ return { name, route, viewport, steps };
112
+ }
113
+ /** Validate a list of Moments, as found in config or a file. */
114
+ export function parseMoments(raw, source) {
115
+ const list = Array.isArray(raw) ? raw
116
+ : raw && typeof raw === 'object' && Array.isArray(raw.moments) ? raw.moments
117
+ : fail(source, 'expected a list of Moments, or { "moments": [...] }');
118
+ return list.map((m, i) => parseMoment(m, `${source}, Moment ${i + 1}`));
119
+ }
120
+ /** Read a `--moments` file. */
121
+ export function loadMomentsFile(file, cwd = process.cwd()) {
122
+ const full = path.resolve(cwd, file);
123
+ let text;
124
+ try {
125
+ text = fs.readFileSync(full, 'utf-8');
126
+ }
127
+ catch {
128
+ throw new MomentError(`Cannot read the Moments file ${file}.`);
129
+ }
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(text);
133
+ }
134
+ catch (err) {
135
+ throw new MomentError(`${file} is not valid JSON: ${err.message}`);
136
+ }
137
+ return parseMoments(parsed, path.basename(file));
138
+ }
139
+ /** The Moments that run, in priority order, and the names of the rest. */
140
+ export function selectMoments(moments) {
141
+ return { run: moments.slice(0, MAX_MOMENTS), skipped: moments.slice(MAX_MOMENTS).map(m => m.name) };
142
+ }
143
+ /** A name as a reviewer reads it: “Open the account menu”. */
144
+ export function quote(s) {
145
+ return `“${s}”`;
146
+ }
147
+ /** A step as a reviewer would say it: `Click “Save”`. Shown under the clip. */
148
+ export function describeStep(step) {
149
+ switch (step.verb) {
150
+ case 'click': return `Click ${quote(step.target)}`;
151
+ case 'hover': return `Hover ${quote(step.target)}`;
152
+ case 'type': return `Type ${quote(step.text)} into ${quote(step.target)}`;
153
+ case 'press': return `Press ${step.key}`;
154
+ case 'scroll': return step.target ? `Scroll to ${quote(step.target)}` : `Scroll ${step.px >= 0 ? 'down' : 'up'} ${Math.abs(step.px)}px`;
155
+ case 'wait': return 'Wait';
156
+ }
157
+ }
158
+ /** One line for a clip that ran long, or null. */
159
+ export function longClipNote(name, durationMs) {
160
+ if (durationMs <= LONG_CLIP_MS)
161
+ return null;
162
+ return `${quote(name)} is ${Math.round(durationMs / 1000)}s — clips under ${TARGET_CLIP_S}s get watched; consider splitting it into smaller Moments.`;
163
+ }
164
+ /** One line naming the Moments over the limit, or null. */
165
+ export function skippedNote(skipped) {
166
+ if (!skipped.length)
167
+ return null;
168
+ return `Recorded the first ${MAX_MOMENTS} Moments; also listed, not recorded: ${skipped.map(quote).join(', ')}.`;
169
+ }
170
+ //# sourceMappingURL=moments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"moments.js","sourceRoot":"","sources":["../src/moments.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Record one side of a Moment: load the page, then play its steps while the
3
+ * browser streams frames.
4
+ *
5
+ * Frames come from CDP `Page.startScreencast`, which sends a frame only when
6
+ * pixels change — about every 17 ms during a transition, nothing while the
7
+ * page sits still (measured; docs/internal/video-spike.md). Playwright's own
8
+ * `recordVideo` was the alternative: fixed at 25 fps and recording from the
9
+ * moment the context opens, load and all.
10
+ *
11
+ * Every step's start time is marked, so `timeline.ts` can line the two sides
12
+ * up step by step. A step that fails ends the recording at the moment it
13
+ * began: the seconds spent looking for a button that is not there are not
14
+ * something a reviewer should watch.
15
+ */
16
+ import { AuthOptions, ViewportSize } from './types.js';
17
+ import { Moment } from './moments.js';
18
+ import { SideTiming } from './timeline.js';
19
+ /** Still frames before the first step, so the viewer sees the page first. */
20
+ export declare const LEAD_IN_MS = 500;
21
+ /** After each step, so its result is on screen before the next one starts. */
22
+ export declare const BREATH_MS = 400;
23
+ /** After the last step, so the clip ends on the result. */
24
+ export declare const TAIL_MS = 1000;
25
+ /** A Moment with no steps records this much of the page loading, for entrance animations. */
26
+ export declare const LOAD_ONLY_MS = 3000;
27
+ /**
28
+ * How long a step looks for its target. Short, because a missing target is
29
+ * the normal case on Pre for a new feature, and every second spent looking is
30
+ * a second the whole run waits.
31
+ */
32
+ export declare const FIND_TIMEOUT_MS = 3000;
33
+ /** How long a found target's action (click, hover, typing) may take. */
34
+ export declare const STEP_TIMEOUT_MS = 5000;
35
+ export interface Frame {
36
+ t: number;
37
+ data: Buffer;
38
+ }
39
+ /** Where a pointer step acted, in viewport CSS pixels, for the click marker. */
40
+ export interface PointerMark {
41
+ step: number;
42
+ x: number;
43
+ y: number;
44
+ }
45
+ export interface SideRecording extends SideTiming {
46
+ viewport: ViewportSize;
47
+ frames: Frame[];
48
+ pointers: PointerMark[];
49
+ /** HTTP status of the page. */
50
+ status?: number;
51
+ /** The step that could not be done, and why, in plain words. */
52
+ failure?: {
53
+ step: number;
54
+ message: string;
55
+ };
56
+ }
57
+ export declare class StepError extends Error {
58
+ }
59
+ /**
60
+ * Record `moment` on `url`. Throws NavigationError / HttpStatusError when the
61
+ * page cannot be loaded; returns a recording with `failure` set when a step
62
+ * cannot be done. A page that answers 4xx/5xx comes back with its status and
63
+ * no frames, for the caller to phrase.
64
+ */
65
+ export declare function recordSide(url: string, moment: Moment, viewport: ViewportSize, opts: {
66
+ auth?: AuthOptions;
67
+ maxWidth: number;
68
+ }): Promise<SideRecording>;
69
+ //# sourceMappingURL=record.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"record.d.ts","sourceRoot":"","sources":["../src/record.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEvD,OAAO,EAAE,MAAM,EAAQ,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAE3C,6EAA6E;AAC7E,eAAO,MAAM,UAAU,MAAM,CAAC;AAC9B,8EAA8E;AAC9E,eAAO,MAAM,SAAS,MAAM,CAAC;AAC7B,2DAA2D;AAC3D,eAAO,MAAM,OAAO,OAAO,CAAC;AAC5B,6FAA6F;AAC7F,eAAO,MAAM,YAAY,OAAO,CAAC;AACjC;;;;GAIG;AACH,eAAO,MAAM,eAAe,OAAO,CAAC;AACpC,wEAAwE;AACxE,eAAO,MAAM,eAAe,OAAO,CAAC;AAOpC,MAAM,WAAW,KAAK;IACpB,CAAC,EAAE,MAAM,CAAC;IACV,IAAI,EAAE,MAAM,CAAC;CACd;AAED,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,MAAM,WAAW,aAAc,SAAQ,UAAU;IAC/C,QAAQ,EAAE,YAAY,CAAC;IACvB,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,+BAA+B;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gEAAgE;IAChE,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7C;AAED,qBAAa,SAAU,SAAQ,KAAK;CAAG;AAwHvC;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,IAAI,EAAE;IAAE,IAAI,CAAC,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,aAAa,CAAC,CAwD5J"}
package/dist/record.js ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Record one side of a Moment: load the page, then play its steps while the
3
+ * browser streams frames.
4
+ *
5
+ * Frames come from CDP `Page.startScreencast`, which sends a frame only when
6
+ * pixels change — about every 17 ms during a transition, nothing while the
7
+ * page sits still (measured; docs/internal/video-spike.md). Playwright's own
8
+ * `recordVideo` was the alternative: fixed at 25 fps and recording from the
9
+ * moment the context opens, load and all.
10
+ *
11
+ * Every step's start time is marked, so `timeline.ts` can line the two sides
12
+ * up step by step. A step that fails ends the recording at the moment it
13
+ * began: the seconds spent looking for a button that is not there are not
14
+ * something a reviewer should watch.
15
+ */
16
+ import { gotoChecked, hideDevIndicator, settlePage, withMotionPage } from './browser.js';
17
+ /** Still frames before the first step, so the viewer sees the page first. */
18
+ export const LEAD_IN_MS = 500;
19
+ /** After each step, so its result is on screen before the next one starts. */
20
+ export const BREATH_MS = 400;
21
+ /** After the last step, so the clip ends on the result. */
22
+ export const TAIL_MS = 1000;
23
+ /** A Moment with no steps records this much of the page loading, for entrance animations. */
24
+ export const LOAD_ONLY_MS = 3000;
25
+ /**
26
+ * How long a step looks for its target. Short, because a missing target is
27
+ * the normal case on Pre for a new feature, and every second spent looking is
28
+ * a second the whole run waits.
29
+ */
30
+ export const FIND_TIMEOUT_MS = 3000;
31
+ /** How long a found target's action (click, hover, typing) may take. */
32
+ export const STEP_TIMEOUT_MS = 5000;
33
+ /** A smooth scroll's travel time. */
34
+ const SCROLL_MS = 700;
35
+ /** Typed one key at a time, slow enough to read. */
36
+ const TYPE_DELAY_MS = 45;
37
+ const SETTLE_MS = 8000;
38
+ export class StepError extends Error {
39
+ }
40
+ const ROLES = ['button', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'tab', 'option', 'checkbox', 'radio', 'switch', 'textbox', 'searchbox', 'combobox'];
41
+ /**
42
+ * Candidate locators for a target, most specific first.
43
+ *
44
+ * Steps name what a person sees ("Save", "Email"), so the accessible name of
45
+ * something interactive is tried first, then a field's label or placeholder,
46
+ * then exact visible text, then the same things loosely. `css=` skips all of
47
+ * it for the rare case plain words cannot say.
48
+ */
49
+ function candidates(page, target) {
50
+ if (target.startsWith('css='))
51
+ return [page.locator(target.slice(4))];
52
+ const byRole = (exact) => ROLES.map(role => page.getByRole(role, { name: target, exact })).reduce((a, b) => a.or(b));
53
+ return [
54
+ byRole(true),
55
+ page.getByLabel(target, { exact: true }).or(page.getByPlaceholder(target, { exact: true })),
56
+ page.getByText(target, { exact: true }),
57
+ byRole(false).or(page.getByLabel(target)).or(page.getByPlaceholder(target)).or(page.getByText(target)),
58
+ ];
59
+ }
60
+ /**
61
+ * The visible element a step means, waiting for it to appear — a menu item
62
+ * exists only once the menu is open, and the step before just opened it.
63
+ */
64
+ async function resolveTarget(page, target) {
65
+ const deadline = Date.now() + FIND_TIMEOUT_MS;
66
+ const tiers = candidates(page, target).map(l => l.filter({ visible: true }));
67
+ for (;;) {
68
+ // All tiers at once, most specific match wins: one round trip per poll,
69
+ // not four, while the page is being recorded.
70
+ const counts = await Promise.all(tiers.map(t => t.count().catch(() => 0)));
71
+ const hit = counts.findIndex(n => n > 0);
72
+ if (hit !== -1)
73
+ return tiers[hit].first();
74
+ if (Date.now() >= deadline)
75
+ throw new StepError(`Couldn't find “${target}”`);
76
+ await page.waitForTimeout(100);
77
+ }
78
+ }
79
+ async function pointAt(locator, step, pointers) {
80
+ const box = await locator.boundingBox().catch(() => null);
81
+ if (box)
82
+ pointers.push({ step, x: box.x + box.width / 2, y: box.y + box.height / 2 });
83
+ }
84
+ async function act(what, fn) {
85
+ try {
86
+ await fn();
87
+ }
88
+ catch {
89
+ throw new StepError(`Couldn't ${what}`);
90
+ }
91
+ }
92
+ async function runStep(page, step, index, pointers) {
93
+ if (step.verb === 'wait')
94
+ return page.waitForTimeout(step.ms);
95
+ if (step.verb === 'press')
96
+ return act(`press ${step.key}`, () => page.keyboard.press(step.key));
97
+ if (step.verb === 'scroll' && !step.target) {
98
+ // Smooth on purpose: a jump cut reads as a different page. Explicit
99
+ // `behavior: 'smooth'` wins over the init script's scroll-behavior rule.
100
+ await page.evaluate(px => window.scrollBy({ top: px, behavior: 'smooth' }), step.px);
101
+ return page.waitForTimeout(SCROLL_MS);
102
+ }
103
+ const target = step.target;
104
+ const el = await resolveTarget(page, target);
105
+ const quoted = `“${target}”`;
106
+ switch (step.verb) {
107
+ case 'scroll':
108
+ await act(`scroll to ${quoted}`, () => el.evaluate(node => node.scrollIntoView({ behavior: 'smooth', block: 'center' })));
109
+ return page.waitForTimeout(SCROLL_MS);
110
+ case 'click':
111
+ await pointAt(el, index, pointers);
112
+ return act(`click ${quoted}`, () => el.click({ timeout: STEP_TIMEOUT_MS }));
113
+ case 'hover':
114
+ await pointAt(el, index, pointers);
115
+ return act(`hover ${quoted}`, () => el.hover({ timeout: STEP_TIMEOUT_MS }));
116
+ case 'type':
117
+ await pointAt(el, index, pointers);
118
+ return act(`type into ${quoted}`, async () => {
119
+ await el.click({ timeout: STEP_TIMEOUT_MS });
120
+ await el.pressSequentially(step.text, { delay: TYPE_DELAY_MS, timeout: STEP_TIMEOUT_MS + step.text.length * TYPE_DELAY_MS });
121
+ });
122
+ }
123
+ }
124
+ /**
125
+ * The screencast's own frame clock, in ms, when it agrees with ours. Its
126
+ * timestamp is seconds since the epoch; if a browser ever sends something
127
+ * else, the arrival time is close enough.
128
+ */
129
+ function frameTime(seconds) {
130
+ const now = Date.now();
131
+ const t = typeof seconds === 'number' ? seconds * 1000 : NaN;
132
+ return Number.isFinite(t) && Math.abs(t - now) < 5000 ? t : now;
133
+ }
134
+ /**
135
+ * Stream frames into `frames` until the returned stop is called. Frames are
136
+ * sent at `maxWidth`, the width they will be drawn at, not the page's: a
137
+ * desktop page is shown in an 800px pane, and shipping and holding the full
138
+ * size would be most of the memory a recording uses.
139
+ */
140
+ async function startScreencast(cdp, viewport, maxWidth, frames) {
141
+ let open = true;
142
+ cdp.on('Page.screencastFrame', f => {
143
+ if (open)
144
+ frames.push({ t: frameTime(f.metadata.timestamp), data: Buffer.from(f.data, 'base64') });
145
+ // The browser sends the next frame only once this one is acknowledged.
146
+ cdp.send('Page.screencastFrameAck', { sessionId: f.sessionId }).catch(() => undefined);
147
+ });
148
+ const scale = Math.min(1, maxWidth / viewport.width);
149
+ await cdp.send('Page.startScreencast', {
150
+ format: 'jpeg', quality: 85, everyNthFrame: 1,
151
+ maxWidth: Math.round(viewport.width * scale), maxHeight: Math.round(viewport.height * scale),
152
+ });
153
+ return async () => {
154
+ open = false;
155
+ await cdp.send('Page.stopScreencast').catch(() => undefined);
156
+ };
157
+ }
158
+ /**
159
+ * Record `moment` on `url`. Throws NavigationError / HttpStatusError when the
160
+ * page cannot be loaded; returns a recording with `failure` set when a step
161
+ * cannot be done. A page that answers 4xx/5xx comes back with its status and
162
+ * no frames, for the caller to phrase.
163
+ */
164
+ export async function recordSide(url, moment, viewport, opts) {
165
+ const { auth, maxWidth } = opts;
166
+ return withMotionPage(viewport, auth, async (page, ctx) => {
167
+ const frames = [];
168
+ const pointers = [];
169
+ const marks = [];
170
+ const cdp = await ctx.newCDPSession(page);
171
+ const loadOnly = moment.steps.length === 0;
172
+ // A load-only Moment is about the load, so recording starts before the
173
+ // navigation: an entrance animation can run while the document is still
174
+ // parsing. Any other starts on a settled page, because how fast each side
175
+ // loaded is not the change.
176
+ let start = Date.now();
177
+ let stop = loadOnly ? await startScreencast(cdp, viewport, maxWidth, frames) : null;
178
+ const { status } = await gotoChecked(page, url).catch(async (err) => { await stop?.(); throw err; });
179
+ if (status !== undefined && status >= 400) {
180
+ await stop?.();
181
+ const now = Date.now();
182
+ return { viewport, frames: [], pointers, marks, start: now, end: now, status };
183
+ }
184
+ if (!stop) {
185
+ await settlePage(page, SETTLE_MS);
186
+ await hideDevIndicator(page);
187
+ // The screencast sends nothing until something paints, and a settled
188
+ // page paints nothing. Seed the stream with what is on screen now.
189
+ start = Date.now();
190
+ const first = await page.screenshot({ type: 'jpeg', quality: 85, timeout: 10_000 }).catch(() => null);
191
+ if (first)
192
+ frames.push({ t: start, data: first });
193
+ stop = await startScreencast(cdp, viewport, maxWidth, frames);
194
+ }
195
+ let failure;
196
+ await page.waitForTimeout(loadOnly ? LOAD_ONLY_MS : LEAD_IN_MS);
197
+ for (let i = 0; i < moment.steps.length; i++) {
198
+ const began = Date.now();
199
+ try {
200
+ await runStep(page, moment.steps[i], i, pointers);
201
+ }
202
+ catch (err) {
203
+ failure = { step: i, message: err instanceof StepError ? err.message : `Step ${i + 1} failed`, at: began };
204
+ break;
205
+ }
206
+ marks.push(began);
207
+ if (moment.steps[i].verb !== 'wait')
208
+ await page.waitForTimeout(BREATH_MS);
209
+ }
210
+ if (!failure && !loadOnly)
211
+ await page.waitForTimeout(TAIL_MS);
212
+ await stop();
213
+ const end = failure?.at ?? Date.now();
214
+ // Nothing after `end` is ever shown — for a failed step, that is every
215
+ // frame of the search — so it is not carried into the compositor.
216
+ const shown = frames.filter(f => f.t <= end).sort((a, b) => a.t - b.t);
217
+ return {
218
+ viewport, frames: shown, pointers, marks, start, end, status,
219
+ failure: failure && { step: failure.step, message: failure.message },
220
+ };
221
+ });
222
+ }
223
+ //# sourceMappingURL=record.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"record.js","sourceRoot":"","sources":["../src/record.ts"],"names":[],"mappings":"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"}
package/dist/report.d.ts CHANGED
@@ -35,6 +35,15 @@ export interface CommentOptions {
35
35
  */
36
36
  filesDir?: string;
37
37
  }
38
+ /**
39
+ * The clips, above the screenshots: they show what screenshots cannot, and a
40
+ * reviewer who presses play once has seen the change.
41
+ *
42
+ * An attachment URL on its own line is what GitHub turns into a player. A
43
+ * clip on the assets branch cannot play there, so its last frame links to it,
44
+ * with the one thing that would make it play.
45
+ */
46
+ export declare function momentLines(result: Pick<PrRunResult, 'moments' | 'momentsSkipped' | 'momentsHint'>, local?: (f?: string) => string | undefined): string[];
38
47
  /**
39
48
  * Markdown body for the sticky PR comment.
40
49
  */
@@ -1 +1 @@
1
- {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAgC,WAAW,EAAE,mBAAmB,EAAY,MAAM,YAAY,CAAC;AAKtG,eAAO,MAAM,aAAa,qCAAqC,CAAC;AAgBhE,kFAAkF;AAClF,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,mBAAmB,CAAC;IAC1B,MAAM,EAAE,mBAAmB,EAAE,CAAC;CAC/B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,mBAAmB,EAAE,GAAG,WAAW,EAAE,CAmB3E;AAED,sFAAsF;AACtF,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAE3F;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,GAAG,CAAC,EAAE,IAAI,CAAC;IACX;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAKD;;GAEG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,GAAE,cAAmB,GAAG,MAAM,CA2ItF;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CA0CxD"}
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAgC,WAAW,EAAE,mBAAmB,EAAY,MAAM,YAAY,CAAC;AAMtG,eAAO,MAAM,aAAa,qCAAqC,CAAC;AAgBhE,kFAAkF;AAClF,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,mBAAmB,CAAC;IAC1B,MAAM,EAAE,mBAAmB,EAAE,CAAC;CAC/B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,mBAAmB,EAAE,GAAG,WAAW,EAAE,CAmB3E;AAED,sFAAsF;AACtF,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAE3F;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,GAAG,CAAC,EAAE,IAAI,CAAC;IACX;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,GAAG,gBAAgB,GAAG,aAAa,CAAC,EAAE,KAAK,GAAE,CAAC,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,GAAG,SAAkB,GAAG,MAAM,EAAE,CAwBjK;AAKD;;GAEG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,GAAE,cAAmB,GAAG,MAAM,CAiJtF;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAiDxD"}
package/dist/report.js CHANGED
@@ -7,6 +7,7 @@ import { isBrokenVerdict, sideName } from './types.js';
7
7
  import { describePageError, describeShift } from './run.js';
8
8
  import { hostOf, isLocalUrl } from './url.js';
9
9
  import { capNotice } from './routes.js';
10
+ import { MAX_MOMENTS, quote, skippedNote } from './moments.js';
10
11
  export const STICKY_MARKER = '<!-- pre-post:visual-changes -->';
11
12
  function code(s) {
12
13
  return `\`${s}\``;
@@ -56,6 +57,45 @@ export function groupChanges(outcomes) {
56
57
  export function sideLabel(url, label, localName) {
57
58
  return label ?? (isLocalUrl(url) ? localName : hostOf(url));
58
59
  }
60
+ /**
61
+ * The clips, above the screenshots: they show what screenshots cannot, and a
62
+ * reviewer who presses play once has seen the change.
63
+ *
64
+ * An attachment URL on its own line is what GitHub turns into a player. A
65
+ * clip on the assets branch cannot play there, so its last frame links to it,
66
+ * with the one thing that would make it play.
67
+ */
68
+ export function momentLines(result, local = f => f) {
69
+ const lines = [];
70
+ const recorded = (result.moments ?? []).filter(m => m.status === 'recorded');
71
+ const failed = (result.moments ?? []).filter(m => m.status === 'error');
72
+ for (const m of recorded) {
73
+ const seconds = m.durationMs ? ` · ${(m.durationMs / 1000).toFixed(1)}s` : '';
74
+ lines.push(`### ▶ ${m.name} — ${viewportLabel(m.viewport)}`, '', `${code(m.route)}${seconds}${m.preNote ? ` · ${m.preNote}` : ''}`, '');
75
+ const video = m.videoUrl ?? local(m.file);
76
+ const poster = m.posterUrl ?? local(m.poster);
77
+ if (m.inline && m.videoUrl)
78
+ lines.push(m.videoUrl, '');
79
+ else if (video && poster)
80
+ lines.push(`[![${m.name} — open the video](${poster})](${video})`, '');
81
+ else if (video)
82
+ lines.push(`[Open the video](${video})`, '');
83
+ if (m.note)
84
+ lines.push(`<sub>${m.note}</sub>`, '');
85
+ }
86
+ if (recorded.some(m => !m.inline) && result.momentsHint)
87
+ lines.push(`<sub>${result.momentsHint}</sub>`, '');
88
+ if (failed.length) {
89
+ lines.push('**Could not record:**');
90
+ for (const m of failed)
91
+ lines.push(`- ${quote(m.name)}: ${m.error}`);
92
+ lines.push('');
93
+ }
94
+ if (result.momentsSkipped?.length) {
95
+ lines.push(`**Also listed, not recorded (over the ${MAX_MOMENTS}-Moment limit):** ${result.momentsSkipped.map(quote).join(', ')}`, '');
96
+ }
97
+ return lines;
98
+ }
59
99
  /** A run shorter than this says how long it took in the PR description. */
60
100
  const FAST_RUN_MS = 30_000;
61
101
  /**
@@ -64,15 +104,17 @@ const FAST_RUN_MS = 30_000;
64
104
  export function buildComment(result, options = {}) {
65
105
  const lines = [STICKY_MARKER, '## Visual changes', ''];
66
106
  /** Published URLs when there are any, else the local files. */
107
+ const dir = options.filesDir;
108
+ const local = (f) => f && dir && path.isAbsolute(f) ? path.relative(dir, f).split(path.sep).join('/') : f;
67
109
  const images = (o) => {
68
110
  if (o.urls)
69
111
  return o.urls;
70
- const dir = options.filesDir;
71
112
  if (!dir || !o.files)
72
113
  return o.files ?? {};
73
- const local = (f) => f && (path.isAbsolute(f) ? path.relative(dir, f).split(path.sep).join('/') : f);
74
114
  return Object.fromEntries(Object.entries(o.files).map(([k, f]) => [k, local(f)]));
75
115
  };
116
+ const clips = momentLines(result, local);
117
+ const hasClips = (result.moments ?? []).some(m => m.status === 'recorded');
76
118
  const changed = result.outcomes.filter(o => o.status === 'changed');
77
119
  const unchanged = result.outcomes.filter(o => o.status === 'unchanged');
78
120
  const errors = result.outcomes.filter(o => o.status === 'error');
@@ -124,8 +166,11 @@ export function buildComment(result, options = {}) {
124
166
  : '**No pages affected** — nothing this branch changed renders on a page.', '');
125
167
  }
126
168
  else if (changed.length === 0 && oneSided.length === 0) {
127
- lines.push('No visual changes.', '');
169
+ // A clip can show a change no still frame does (a transition, a hover), so
170
+ // with clips the claim is only about the screenshots.
171
+ lines.push(hasClips ? 'No visual changes in the screenshots.' : 'No visual changes.', '');
128
172
  }
173
+ lines.push(...clips);
129
174
  for (const { lead: o, others } of groupChanges(result.outcomes)) {
130
175
  const u = images(o);
131
176
  if (others.length) {
@@ -229,6 +274,14 @@ export function buildSummary(result) {
229
274
  }
230
275
  if (result.omitted)
231
276
  lines.push(` ${capNotice(result.omitted.routes, result.omitted.cap)}`);
277
+ for (const m of result.moments ?? []) {
278
+ lines.push(m.status === 'recorded'
279
+ ? ` ▶ ${quote(m.name)} ${m.viewport} ${(m.durationMs / 1000).toFixed(1)}s${m.inline ? ' inline in PR' : ''}${m.preNote ? ` (${m.preNote})` : ''}`
280
+ : ` ▶ ${quote(m.name)} not recorded: ${m.error}`);
281
+ }
282
+ const skipped = skippedNote(result.momentsSkipped ?? []);
283
+ if (skipped)
284
+ lines.push(` ${skipped}`);
232
285
  // Named for what actually happened. The images normally go in the PR
233
286
  // description and only fall back to a comment, so "Comment:" sent a reader
234
287
  // looking for a comment that a run with zero comments had never created.