@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.
- package/README.md +43 -288
- package/assets/labels/post.png +0 -0
- package/assets/labels/pre.png +0 -0
- package/dist/attach.d.ts +59 -0
- package/dist/attach.d.ts.map +1 -0
- package/dist/attach.js +137 -0
- package/dist/attach.js.map +1 -0
- package/dist/baseline.d.ts +26 -1
- package/dist/baseline.d.ts.map +1 -1
- package/dist/baseline.js +92 -19
- package/dist/baseline.js.map +1 -1
- package/dist/bin/cli.js +40 -16
- package/dist/bin/cli.js.map +1 -1
- package/dist/browser.d.ts +79 -3
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +179 -38
- package/dist/browser.js.map +1 -1
- package/dist/commands/compare.d.ts.map +1 -1
- package/dist/commands/compare.js +5 -5
- package/dist/commands/compare.js.map +1 -1
- package/dist/commands/detect.d.ts +5 -0
- package/dist/commands/detect.d.ts.map +1 -1
- package/dist/commands/detect.js +3 -0
- package/dist/commands/detect.js.map +1 -1
- package/dist/commands/doctor.d.ts +6 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +21 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/pr.d.ts +5 -0
- package/dist/commands/pr.d.ts.map +1 -1
- package/dist/commands/pr.js +167 -113
- package/dist/commands/pr.js.map +1 -1
- package/dist/comparison.d.ts +5 -0
- package/dist/comparison.d.ts.map +1 -1
- package/dist/comparison.js +5 -5
- package/dist/comparison.js.map +1 -1
- package/dist/compose.d.ts +72 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +237 -0
- package/dist/compose.js.map +1 -0
- package/dist/diff-worker.d.ts +1 -0
- package/dist/diff-worker.d.ts.map +1 -1
- package/dist/diff-worker.js +1 -0
- package/dist/diff-worker.js.map +1 -1
- package/dist/diff.d.ts +42 -0
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +142 -24
- package/dist/diff.js.map +1 -1
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +5 -1
- package/dist/doctor.js.map +1 -1
- package/dist/drift.d.ts +29 -0
- package/dist/drift.d.ts.map +1 -0
- package/dist/drift.js +106 -0
- package/dist/drift.js.map +1 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +6 -0
- package/dist/errors.js.map +1 -1
- package/dist/ffmpeg.d.ts +35 -0
- package/dist/ffmpeg.d.ts.map +1 -0
- package/dist/ffmpeg.js +143 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/moments.d.ts +83 -0
- package/dist/moments.d.ts.map +1 -0
- package/dist/moments.js +170 -0
- package/dist/moments.js.map +1 -0
- package/dist/record.d.ts +69 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +223 -0
- package/dist/record.js.map +1 -0
- package/dist/report.d.ts +29 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +174 -42
- package/dist/report.js.map +1 -1
- package/dist/routes/imports.d.ts +21 -1
- package/dist/routes/imports.d.ts.map +1 -1
- package/dist/routes/imports.js +32 -2
- package/dist/routes/imports.js.map +1 -1
- package/dist/routes.d.ts +18 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +110 -29
- package/dist/routes.js.map +1 -1
- package/dist/run.d.ts +48 -12
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +140 -18
- package/dist/run.js.map +1 -1
- package/dist/timeline.d.ts +46 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +80 -0
- package/dist/timeline.js.map +1 -0
- package/dist/types.d.ts +95 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/dist/video.d.ts +19 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +109 -0
- package/dist/video.js.map +1 -0
- package/package.json +4 -2
- 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
|
*/
|
package/dist/report.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,
|
|
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 {
|
|
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(`[](${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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 |', '|:---:|:---:|', `|  |  |`, '');
|
|
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) |', '|:---:|:---:|', `|  |  |`, '');
|
|
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 — ${
|
|
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(``, '');
|
|
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 === '
|
|
141
|
-
: o.status === '
|
|
142
|
-
: o.status === '
|
|
143
|
-
: '
|
|
144
|
-
|
|
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.
|