@juangadm/pre-post 1.3.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 (106) hide show
  1. package/README.md +43 -288
  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/baseline.d.ts +26 -1
  9. package/dist/baseline.d.ts.map +1 -1
  10. package/dist/baseline.js +92 -19
  11. package/dist/baseline.js.map +1 -1
  12. package/dist/bin/cli.js +40 -16
  13. package/dist/bin/cli.js.map +1 -1
  14. package/dist/browser.d.ts +79 -3
  15. package/dist/browser.d.ts.map +1 -1
  16. package/dist/browser.js +179 -38
  17. package/dist/browser.js.map +1 -1
  18. package/dist/commands/compare.d.ts.map +1 -1
  19. package/dist/commands/compare.js +5 -5
  20. package/dist/commands/compare.js.map +1 -1
  21. package/dist/commands/detect.d.ts +5 -0
  22. package/dist/commands/detect.d.ts.map +1 -1
  23. package/dist/commands/detect.js +3 -0
  24. package/dist/commands/detect.js.map +1 -1
  25. package/dist/commands/doctor.d.ts +6 -0
  26. package/dist/commands/doctor.d.ts.map +1 -1
  27. package/dist/commands/doctor.js +21 -0
  28. package/dist/commands/doctor.js.map +1 -1
  29. package/dist/commands/pr.d.ts +5 -0
  30. package/dist/commands/pr.d.ts.map +1 -1
  31. package/dist/commands/pr.js +167 -113
  32. package/dist/commands/pr.js.map +1 -1
  33. package/dist/comparison.d.ts +5 -0
  34. package/dist/comparison.d.ts.map +1 -1
  35. package/dist/comparison.js +5 -5
  36. package/dist/comparison.js.map +1 -1
  37. package/dist/compose.d.ts +72 -0
  38. package/dist/compose.d.ts.map +1 -0
  39. package/dist/compose.js +237 -0
  40. package/dist/compose.js.map +1 -0
  41. package/dist/diff-worker.d.ts +1 -0
  42. package/dist/diff-worker.d.ts.map +1 -1
  43. package/dist/diff-worker.js +1 -0
  44. package/dist/diff-worker.js.map +1 -1
  45. package/dist/diff.d.ts +42 -0
  46. package/dist/diff.d.ts.map +1 -1
  47. package/dist/diff.js +142 -24
  48. package/dist/diff.js.map +1 -1
  49. package/dist/doctor.d.ts +4 -1
  50. package/dist/doctor.d.ts.map +1 -1
  51. package/dist/doctor.js +5 -1
  52. package/dist/doctor.js.map +1 -1
  53. package/dist/drift.d.ts +29 -0
  54. package/dist/drift.d.ts.map +1 -0
  55. package/dist/drift.js +106 -0
  56. package/dist/drift.js.map +1 -0
  57. package/dist/errors.d.ts +6 -0
  58. package/dist/errors.d.ts.map +1 -1
  59. package/dist/errors.js +6 -0
  60. package/dist/errors.js.map +1 -1
  61. package/dist/ffmpeg.d.ts +35 -0
  62. package/dist/ffmpeg.d.ts.map +1 -0
  63. package/dist/ffmpeg.js +143 -0
  64. package/dist/ffmpeg.js.map +1 -0
  65. package/dist/index.d.ts +4 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +2 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/moments.d.ts +83 -0
  70. package/dist/moments.d.ts.map +1 -0
  71. package/dist/moments.js +170 -0
  72. package/dist/moments.js.map +1 -0
  73. package/dist/record.d.ts +69 -0
  74. package/dist/record.d.ts.map +1 -0
  75. package/dist/record.js +223 -0
  76. package/dist/record.js.map +1 -0
  77. package/dist/report.d.ts +29 -1
  78. package/dist/report.d.ts.map +1 -1
  79. package/dist/report.js +174 -42
  80. package/dist/report.js.map +1 -1
  81. package/dist/routes/imports.d.ts +21 -1
  82. package/dist/routes/imports.d.ts.map +1 -1
  83. package/dist/routes/imports.js +32 -2
  84. package/dist/routes/imports.js.map +1 -1
  85. package/dist/routes.d.ts +18 -0
  86. package/dist/routes.d.ts.map +1 -1
  87. package/dist/routes.js +110 -29
  88. package/dist/routes.js.map +1 -1
  89. package/dist/run.d.ts +48 -12
  90. package/dist/run.d.ts.map +1 -1
  91. package/dist/run.js +140 -18
  92. package/dist/run.js.map +1 -1
  93. package/dist/timeline.d.ts +46 -0
  94. package/dist/timeline.d.ts.map +1 -0
  95. package/dist/timeline.js +80 -0
  96. package/dist/timeline.js.map +1 -0
  97. package/dist/types.d.ts +95 -1
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js +8 -0
  100. package/dist/types.js.map +1 -1
  101. package/dist/video.d.ts +19 -0
  102. package/dist/video.d.ts.map +1 -0
  103. package/dist/video.js +109 -0
  104. package/dist/video.js.map +1 -0
  105. package/package.json +4 -2
  106. package/skill/SKILL.md +38 -10
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
@@ -2,8 +2,27 @@
2
2
  * Output formatting: the PR comment (for humans) and the terminal summary
3
3
  * (for the developer or the agent that invoked the CLI).
4
4
  */
5
- import { PrRunResult } from './types.js';
5
+ import { PrRunResult, RouteCaptureOutcome } from './types.js';
6
6
  export declare const STICKY_MARKER = "<!-- pre-post:visual-changes -->";
7
+ /** One change to show, and the other routes that show exactly the same change. */
8
+ export interface ChangeGroup {
9
+ lead: RouteCaptureOutcome;
10
+ others: RouteCaptureOutcome[];
11
+ }
12
+ /**
13
+ * Collapse routes that show the same change into one entry.
14
+ *
15
+ * A nav edit touches every page, and the report used to repeat it: six
16
+ * sections, 24 images, one change. Routes whose change has the same
17
+ * fingerprint, at the same viewport, are one entry led by the first of them.
18
+ * So are routes whose only change is the same move: the sentence ("Content
19
+ * shifted down 8px. Nothing else changed.") is the whole report, and eight
20
+ * pages of it was twelve full-page images saying one thing. A move with
21
+ * something else on top, or a change with no fingerprint, stands alone.
22
+ */
23
+ export declare function groupChanges(outcomes: RouteCaptureOutcome[]): ChangeGroup[];
24
+ /** A reviewer's name for a side: its label, else the host a deployment answers on. */
25
+ export declare function sideLabel(url: string, label: string | undefined, localName: string): string;
7
26
  export interface CommentOptions {
8
27
  version?: string;
9
28
  headSha?: string | null;
@@ -16,6 +35,15 @@ export interface CommentOptions {
16
35
  */
17
36
  filesDir?: string;
18
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[];
19
47
  /**
20
48
  * Markdown body for the sticky PR comment.
21
49
  */
@@ -1 +1 @@
1
- {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAe,WAAW,EAAuB,MAAM,YAAY,CAAC;AAI3E,eAAO,MAAM,aAAa,qCAAqC,CAAC;AAgBhE,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,CAqGtF;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAsCxD"}
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
@@ -3,8 +3,11 @@
3
3
  * (for the developer or the agent that invoked the CLI).
4
4
  */
5
5
  import path from 'path';
6
- import { describeShift } from './run.js';
6
+ import { isBrokenVerdict, sideName } from './types.js';
7
+ import { describePageError, describeShift } from './run.js';
7
8
  import { hostOf, isLocalUrl } from './url.js';
9
+ import { capNotice } from './routes.js';
10
+ import { MAX_MOMENTS, quote, skippedNote } from './moments.js';
8
11
  export const STICKY_MARKER = '<!-- pre-post:visual-changes -->';
9
12
  function code(s) {
10
13
  return `\`${s}\``;
@@ -18,6 +21,81 @@ function groupByRoute(outcomes) {
18
21
  map.set(o.route, [...(map.get(o.route) ?? []), o]);
19
22
  return map;
20
23
  }
24
+ /**
25
+ * Collapse routes that show the same change into one entry.
26
+ *
27
+ * A nav edit touches every page, and the report used to repeat it: six
28
+ * sections, 24 images, one change. Routes whose change has the same
29
+ * fingerprint, at the same viewport, are one entry led by the first of them.
30
+ * So are routes whose only change is the same move: the sentence ("Content
31
+ * shifted down 8px. Nothing else changed.") is the whole report, and eight
32
+ * pages of it was twelve full-page images saying one thing. A move with
33
+ * something else on top, or a change with no fingerprint, stands alone.
34
+ */
35
+ export function groupChanges(outcomes) {
36
+ const groups = [];
37
+ const byKey = new Map();
38
+ // Filtered here, not by callers: `pr` groups to choose what to upload and
39
+ // the report groups to render, and both must pick the same lead.
40
+ for (const o of outcomes.filter(o => o.status === 'changed' && (o.urls || o.files))) {
41
+ const key = o.shift
42
+ ? (o.shift.otherChange ? null : `${o.viewport}:move:${Math.round(o.shift.px)}`)
43
+ : o.fingerprint ? `${o.viewport}:${o.fingerprint}` : null;
44
+ const existing = key ? byKey.get(key) : undefined;
45
+ if (existing) {
46
+ existing.others.push(o);
47
+ continue;
48
+ }
49
+ const group = { lead: o, others: [] };
50
+ groups.push(group);
51
+ if (key)
52
+ byKey.set(key, group);
53
+ }
54
+ return groups;
55
+ }
56
+ /** A reviewer's name for a side: its label, else the host a deployment answers on. */
57
+ export function sideLabel(url, label, localName) {
58
+ return label ?? (isLocalUrl(url) ? localName : hostOf(url));
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
+ }
21
99
  /** A run shorter than this says how long it took in the PR description. */
22
100
  const FAST_RUN_MS = 30_000;
23
101
  /**
@@ -26,70 +104,100 @@ const FAST_RUN_MS = 30_000;
26
104
  export function buildComment(result, options = {}) {
27
105
  const lines = [STICKY_MARKER, '## Visual changes', ''];
28
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;
29
109
  const images = (o) => {
30
110
  if (o.urls)
31
111
  return o.urls;
32
- const dir = options.filesDir;
33
112
  if (!dir || !o.files)
34
113
  return o.files ?? {};
35
- const local = (f) => f && (path.isAbsolute(f) ? path.relative(dir, f).split(path.sep).join('/') : f);
36
114
  return Object.fromEntries(Object.entries(o.files).map(([k, f]) => [k, local(f)]));
37
115
  };
38
- const routes = groupByRoute(result.outcomes);
116
+ const clips = momentLines(result, local);
117
+ const hasClips = (result.moments ?? []).some(m => m.status === 'recorded');
39
118
  const changed = result.outcomes.filter(o => o.status === 'changed');
40
119
  const unchanged = result.outcomes.filter(o => o.status === 'unchanged');
41
120
  const errors = result.outcomes.filter(o => o.status === 'error');
121
+ // Only the baseline can be broken here: a broken Post is a verdict, above.
122
+ const notCompared = result.outcomes.filter(o => o.status === 'broken');
42
123
  // A page that only one side has is still something a reviewer must see, so it
43
124
  // counts as a change for "did anything happen" — but it is shown as one
44
125
  // screenshot, never as a Pre/Post pair, because there is no pair.
45
126
  const oneSided = result.outcomes.filter(o => (o.status === 'added' || o.status === 'removed') && (o.urls || o.files));
46
127
  // Both sides can now be a deployment, so name the actual hosts rather than
47
128
  // assuming Post is the reader's own checkout.
48
- const postLabel = isLocalUrl(result.afterBase) ? 'this branch (local)' : hostOf(result.afterBase);
129
+ // Named for the reviewer. A local side is served on a throwaway port, so its
130
+ // URL named nothing and changed on every run; the label says what it is.
131
+ const preLabel = sideLabel(result.beforeBase, result.beforeLabel, 'a local server');
132
+ const postLabel = sideLabel(result.afterBase, result.afterLabel, 'this branch');
49
133
  const sha = options.headSha ? ` @ ${code(options.headSha.slice(0, 7))}` : '';
50
134
  // Only a quick run earns a mention: the number is there to show the tool is
51
135
  // cheap to run, and a slow one would say the opposite.
52
136
  const took = result.durationMs > 0 && result.durationMs < FAST_RUN_MS ? ` in ${Math.max(1, Math.round(result.durationMs / 1000))}s` : '';
53
- lines.push(`**Pre** = ${hostOf(result.beforeBase)} · **Post** = ${postLabel}${sha} · <a href="https://github.com/juangadm/pre-post">pre-post</a>${took}`, '');
137
+ // No sides means nothing was served — a run that stopped before the dev
138
+ // servers because no page could be captured. Naming empty sides says nothing.
139
+ if (result.beforeBase || result.afterBase) {
140
+ lines.push(`**Pre** = ${preLabel} · **Post** = ${postLabel}${sha} · <a href="https://github.com/juangadm/pre-post">pre-post</a>${took}`, '');
141
+ }
142
+ // A side that renders an error page is the whole story. One sentence and
143
+ // where it happened; no screenshots, because a picture of an error panel in a
144
+ // Pre/Post table reads as a design, and that is how it used to ship.
145
+ if (isBrokenVerdict(result.verdict)) {
146
+ lines.push(`**${result.verdict.hint}**`, '');
147
+ for (const o of result.outcomes.filter(o => o.broken)) {
148
+ lines.push(`- ${code(o.route)} ${viewportLabel(o.viewport)}: ${describePageError(o.broken.error)}`);
149
+ }
150
+ lines.push('');
151
+ return lines.join('\n');
152
+ }
54
153
  // "No visual changes" is a claim about what the two sides looked like, so it
55
154
  // may only be made about routes that were actually compared. A run where
56
155
  // every capture failed compared nothing, and printing it there — directly
57
156
  // above a list of six "Could not capture" lines, which is how it shipped —
58
157
  // reports a clean diff for a run that produced no diff at all.
59
158
  if (changed.length + unchanged.length + oneSided.length === 0) {
60
- lines.push(errors.length
61
- ? `**Nothing was compared** — ${errors.length === 1 ? 'the only capture' : `all ${errors.length} captures`} failed. See below.`
62
- : '**Nothing was compared** — no route produced a screenshot.', '');
159
+ const failed = errors.length + notCompared.length;
160
+ lines.push(failed
161
+ ? `**Nothing was compared** — ${failed === 1 ? 'the only capture' : `all ${failed} captures`} failed. See below.`
162
+ : result.outcomes.length > 0
163
+ ? '**Nothing was compared** — no route produced a screenshot.'
164
+ : result.skippedDynamic.length
165
+ ? '**Nothing was compared yet** — the only pages this branch affects need a sample URL. See below.'
166
+ : '**No pages affected** — nothing this branch changed renders on a page.', '');
63
167
  }
64
168
  else if (changed.length === 0 && oneSided.length === 0) {
65
- 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.', '');
66
172
  }
67
- for (const [route, outcomes] of routes) {
68
- const changedHere = outcomes.filter(o => o.status === 'changed' && (o.urls || o.files));
69
- if (changedHere.length === 0)
70
- continue;
71
- for (const o of changedHere) {
72
- const u = images(o);
73
- lines.push(`### ${code(route)} — ${viewportLabel(o.viewport)}`, '');
74
- // A move repaints everything below it, so the percentage says nothing a
75
- // reviewer can use. Say how far it moved instead, and whether the branch
76
- // changed anything else.
77
- if (o.shift) {
78
- lines.push(o.shift.otherChange
79
- ? `Content ${describeShift(o.shift.px)}. The pair below is aligned on that move, so it shows what changed besides it.`
80
- : `Content ${describeShift(o.shift.px)}. Nothing else changed.`, '');
81
- }
82
- const cropped = Boolean(u.cropBefore && u.cropAfter);
83
- const pre = u.cropBefore ?? u.before;
84
- const post = u.cropAfter ?? u.after;
85
- lines.push('| Pre | Post |', '|:---:|:---:|', `| ![Pre](${pre}) | ![Post](${post}) |`, '');
86
- // Without a crop the pair above is already the full page; repeating it
87
- // under a fold gives a reviewer two more identical images to scroll past.
88
- if (cropped) {
89
- lines.push('<details>', `<summary>Full page</summary>`, '');
90
- lines.push('| Pre (full) | Post (full) |', '|:---:|:---:|', `| ![Pre full](${u.before}) | ![Post full](${u.after}) |`, '');
91
- lines.push('</details>', '');
92
- }
173
+ lines.push(...clips);
174
+ for (const { lead: o, others } of groupChanges(result.outcomes)) {
175
+ const u = images(o);
176
+ if (others.length) {
177
+ const all = [o, ...others].map(r => code(r.route)).join(', ');
178
+ lines.push(`### Same change on ${others.length + 1} pages — ${viewportLabel(o.viewport)}`, '', `${all}. Shown on ${code(o.route)}:`, '');
179
+ }
180
+ else {
181
+ lines.push(`### ${code(o.route)} — ${viewportLabel(o.viewport)}`, '');
182
+ }
183
+ // A move repaints everything below it, so the percentage says nothing a
184
+ // reviewer can use. Say how far it moved instead, and whether the branch
185
+ // changed anything else.
186
+ if (o.shift) {
187
+ lines.push(o.shift.otherChange
188
+ ? `Content ${describeShift(o.shift.px)}. The pair below is aligned on that move, so it shows what changed besides it.`
189
+ : `Content ${describeShift(o.shift.px)}. Nothing else changed.`, '');
190
+ }
191
+ const cropped = Boolean(u.cropBefore && u.cropAfter);
192
+ const pre = u.cropBefore ?? u.before;
193
+ const post = u.cropAfter ?? u.after;
194
+ lines.push('| Pre | Post |', '|:---:|:---:|', `| ![Pre](${pre}) | ![Post](${post}) |`, '');
195
+ // Without a crop the pair above is already the full page; repeating it
196
+ // under a fold gives a reviewer two more identical images to scroll past.
197
+ if (cropped) {
198
+ lines.push('<details>', `<summary>Full page</summary>`, '');
199
+ lines.push('| Pre (full) | Post (full) |', '|:---:|:---:|', `| ![Pre full](${u.before}) | ![Post full](${u.after}) |`, '');
200
+ lines.push('</details>', '');
93
201
  }
94
202
  }
95
203
  for (const o of oneSided) {
@@ -101,7 +209,7 @@ export function buildComment(result, options = {}) {
101
209
  const side = o.status === 'added' ? 'Post' : 'Pre';
102
210
  lines.push(`### ${code(o.route)} — ${viewportLabel(o.viewport)} · ${what}`, '');
103
211
  lines.push(o.status === 'added'
104
- ? `This route has no baseline — ${hostOf(result.beforeBase)} returns 404 for it — so there is no "before" to show and no percentage to quote. ${side} only:`
212
+ ? `This route has no baseline — ${preLabel} returns 404 for it — so there is no "before" to show and no percentage to quote. ${side} only:`
105
213
  : `This route is gone on this branch, so there is no "after". ${side} only:`, '');
106
214
  lines.push(`![${side}](${image})`, '');
107
215
  }
@@ -116,9 +224,20 @@ export function buildComment(result, options = {}) {
116
224
  lines.push(`- ${code(o.route)} ${o.viewport}: ${o.error}`);
117
225
  lines.push('');
118
226
  }
227
+ if (notCompared.length) {
228
+ lines.push('**Not compared** (the baseline shows an error on these pages, not caused by this branch):');
229
+ for (const o of notCompared)
230
+ lines.push(`- ${code(o.route)} ${o.viewport}: ${describePageError(o.broken.error)}`);
231
+ lines.push('');
232
+ }
119
233
  if (result.skippedDynamic.length) {
120
234
  lines.push(`**Needs a sample URL:** ${result.skippedDynamic.map(code).join(', ')} — add them under ${code('"samples"')} in ${code('.pre-post.json')}.`, '');
121
235
  }
236
+ // A route list that silently stops at the cap reads as "these are all the
237
+ // pages this branch touches". Name the rest.
238
+ if (result.omitted) {
239
+ lines.push(`**Also affected, not captured (over the ${result.omitted.cap}-page limit):** ${result.omitted.routes.map(code).join(', ')}`, '');
240
+ }
122
241
  return lines.join('\n');
123
242
  }
124
243
  /**
@@ -137,11 +256,14 @@ export function buildSummary(result) {
137
256
  o.route,
138
257
  o.viewport,
139
258
  o.status === 'error' ? 'error'
140
- : o.status === 'changed' ? 'changed'
141
- : o.status === 'added' ? 'new page'
142
- : o.status === 'removed' ? 'removed'
143
- : 'no change',
144
- o.status === 'error' ? (o.error ?? '') : (o.note || ''),
259
+ : o.status === 'broken' ? 'broken'
260
+ : o.status === 'changed' ? 'changed'
261
+ : o.status === 'added' ? 'new page'
262
+ : o.status === 'removed' ? 'removed'
263
+ : 'no change',
264
+ o.status === 'error' ? (o.error ?? '')
265
+ : o.broken ? `${sideName(o.broken.side)}: ${describePageError(o.broken.error)}`
266
+ : (o.note || ''),
145
267
  ]);
146
268
  const widths = [0, 1, 2].map(i => Math.max(...rows.map(r => r[i].length), 0));
147
269
  for (const r of rows) {
@@ -150,6 +272,16 @@ export function buildSummary(result) {
150
272
  if (result.skippedDynamic.length) {
151
273
  lines.push(` needs sample URL: ${result.skippedDynamic.join(', ')} (add to .pre-post.json "samples")`);
152
274
  }
275
+ if (result.omitted)
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}`);
153
285
  // Named for what actually happened. The images normally go in the PR
154
286
  // description and only fall back to a comment, so "Comment:" sent a reader
155
287
  // looking for a comment that a run with zero comments had never created.