Skip to content

Commit 4efca20

Browse files
authored
Add a script to conveniently compare screenshots on this site (#2136)
## Changes - Adds a Node.JS script to generate screenshots, mainly to support opening PRs. ## Context In PRs like #2133, it is good to accompany the code changes and the explanation (including rationale!) with a before/after comparison. This is oddly involved at the moment! Historically, I used to whip out my trusty interactive <kbd>🪟</kbd><kbd>Shift</kbd><kbd>S</kbd> screenshot shortcut, and kind of aimed to catch the same areas before and after making my change. Then, I pasted them into the PR description and surrounded them by Markdown table markup. Quite tedious, if I'm honest! So here's a script that automates a lot of that. It can even be used to re-build the site locally from two given revisions, and then take snapshots of those pages, using Playwright. It also allows to clip the areas to capture via specifying pixel values, which is admittedly not _that_ convenient, but I couldn't find a better way. The output is a copy/paste'able bit of Markdown/HTML with the images' absolute paths inserted: There is no API to upload images to GitHub PRs, and therefore they have to be uploaded manually. At least this way, the user can copy the entire Markdown, paste it into the PR description editor, then cut the first path to the clipboard, click the "Paste, drop, or click to add files" button below the editor, paste the path, then repeat with the second path. This is precisely what I did over in #2133, with the tell-tale `<!-- Generated by 'node compare-screenshots.js --clip=256x256+900+0 https://git-scm.com/about https://dscho.github.io/git-scm.com/about' -->` comment in the PR body (can be seen here: https://api.github.com/repos/git/git-scm.com/pulls/2133).
2 parents 24c1cd0 + 9745e6a commit 4efca20

File tree

1 file changed

+270
-0
lines changed

1 file changed

+270
-0
lines changed

script/compare-screenshots.js

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
#!/usr/bin/env node
2+
// @ts-check
3+
4+
const usage = `Generate before/after screenshots for two URLs using Playwright.
5+
6+
Usage:
7+
node script/compare-screenshots.js [options] <before-url> <after-url>
8+
9+
Arguments can be URLs or paths to git-scm.com worktrees. When a worktree
10+
path is given, Hugo is run to build the site and a local server is started.
11+
Use worktree@commit to checkout a specific commit before building.
12+
Use worktree:/path/to/page to navigate to a specific page.
13+
Both can be combined: worktree@commit:/path/to/page
14+
As a convenience, @commit implies the current directory, and @{u} is
15+
treated as @@{u} since refs cannot start with a curly brace.
16+
17+
Options:
18+
--dark Emulate dark mode (prefers-color-scheme: dark)
19+
--light Emulate light mode (default)
20+
--clip=<WxH+X+Y> Clip screenshots to specified region (e.g., --clip=1280x720+0+0)
21+
22+
Examples:
23+
node script/compare-screenshots.js https://git-scm.com http://localhost:5000
24+
node script/compare-screenshots.js https://git-scm.com /path/to/worktree
25+
node script/compare-screenshots.js https://git-scm.com @HEAD~2
26+
node script/compare-screenshots.js @{u} .
27+
node script/compare-screenshots.js https://git-scm.com/docs/git-config /path/to/worktree:/docs/git-config
28+
node script/compare-screenshots.js --dark https://git-scm.com http://localhost:5000
29+
node script/compare-screenshots.js --clip=1280x720+0+0 https://git-scm.com http://localhost:5000`;
30+
31+
const { chromium } = require('@playwright/test');
32+
const { spawn, execSync } = require('child_process');
33+
const fs = require('fs');
34+
const path = require('path');
35+
36+
let lastPagePath;
37+
38+
/**
39+
* Parse a worktree argument to extract worktree path, commit, and page path.
40+
*
41+
* Format: [worktree][@commit][:/page/path]
42+
*
43+
* Examples:
44+
* . -> { worktreePath: '.', commit: undefined, pagePath: '' }
45+
* @HEAD~2 -> { worktreePath: '.', commit: 'HEAD~2', pagePath: '' }
46+
* .@HEAD~2 -> { worktreePath: '.', commit: 'HEAD~2', pagePath: '' }
47+
* @{u} -> { worktreePath: '.', commit: '@{u}', pagePath: '' }
48+
* /path/to/worktree:/docs/git -> { worktreePath: '/path/to/worktree', commit: undefined, pagePath: 'docs/git' }
49+
* .@main:/about -> { worktreePath: '.', commit: 'main', pagePath: 'about' }
50+
*
51+
* If no page path is specified, inherits the page path from the previous call.
52+
*
53+
* Returns false if the argument is a URL or not a valid worktree.
54+
*/
55+
function getWorktreeInfo(arg) {
56+
if (arg.startsWith('http://') || arg.startsWith('https://')) {
57+
// Extract path from URL for inheritance
58+
try {
59+
lastPagePath = new URL(arg).pathname.replace(/^\/+/, '');
60+
} catch {
61+
}
62+
return false;
63+
}
64+
// Allow @commit as shorthand for .@commit (current directory)
65+
if (arg.startsWith('@')) arg = '.' + arg;
66+
const colonIndex = arg.indexOf(':');
67+
const beforeColon = colonIndex === -1 ? arg : arg.slice(0, colonIndex);
68+
let pagePath = colonIndex === -1 ? undefined : arg.slice(colonIndex + 1).replace(/^\/+/, '');
69+
const atIndex = beforeColon.indexOf('@');
70+
const worktreePath = atIndex === -1 ? beforeColon : beforeColon.slice(0, atIndex);
71+
let commit = atIndex === -1 ? undefined : beforeColon.slice(atIndex + 1);
72+
// Allow @{u} as shorthand for @@{u} since refs can't start with {
73+
if (commit && commit.startsWith('{')) commit = '@' + commit;
74+
// Inherit page path from previous call if not specified
75+
if (pagePath === undefined && lastPagePath !== undefined) {
76+
pagePath = lastPagePath;
77+
} else if (pagePath !== undefined) {
78+
lastPagePath = pagePath;
79+
}
80+
try {
81+
if (fs.statSync(path.join(worktreePath, 'hugo.yml')).isFile()) {
82+
return { worktreePath, commit, pagePath: pagePath || '' };
83+
}
84+
} catch {
85+
}
86+
return false;
87+
}
88+
89+
async function startServer(worktreePath, port, commit) {
90+
let restoreRef;
91+
let wasDetached = false;
92+
93+
if (commit) {
94+
// Determine if we're on a branch (symbolic ref) or detached HEAD
95+
try {
96+
restoreRef = execSync('git symbolic-ref --short HEAD', { cwd: worktreePath, encoding: 'utf-8' }).trim();
97+
} catch {
98+
// Not on a branch, save the commit SHA
99+
restoreRef = execSync('git rev-parse HEAD', { cwd: worktreePath, encoding: 'utf-8' }).trim();
100+
wasDetached = true;
101+
}
102+
console.log(`Switching to ${commit} in ${worktreePath}...`);
103+
execSync(`git switch -d ${commit}`, { cwd: worktreePath, stdio: 'inherit' });
104+
}
105+
106+
// Build Hugo site
107+
console.error(`Building Hugo site in ${worktreePath}...`);
108+
execSync('hugo', { cwd: worktreePath, stdio: 'inherit' });
109+
110+
// Start serve-public.js
111+
const serverScript = path.join(worktreePath, 'script', 'serve-public.js');
112+
const server = spawn('node', [serverScript], {
113+
cwd: worktreePath,
114+
env: { ...process.env, PORT: String(port) },
115+
stdio: ['ignore', 'pipe', 'inherit'],
116+
});
117+
118+
// Attach restore function to server
119+
server.restore = () => {
120+
if (restoreRef) {
121+
console.log(`Restoring ${worktreePath} to ${restoreRef}...`);
122+
if (wasDetached) {
123+
execSync(`git switch -d ${restoreRef}`, { cwd: worktreePath, stdio: 'inherit' });
124+
} else {
125+
execSync(`git switch ${restoreRef}`, { cwd: worktreePath, stdio: 'inherit' });
126+
}
127+
}
128+
};
129+
130+
// Wait for server to be ready
131+
await new Promise((resolve, reject) => {
132+
const timeout = setTimeout(() => reject(new Error('Server startup timeout')), 30000);
133+
server.stdout.on('data', (data) => {
134+
if (data.toString().includes('Now listening')) {
135+
clearTimeout(timeout);
136+
resolve();
137+
}
138+
});
139+
server.on('error', (err) => {
140+
clearTimeout(timeout);
141+
reject(err);
142+
});
143+
});
144+
145+
return server;
146+
}
147+
148+
async function main() {
149+
const args = process.argv.slice(2);
150+
const options = {
151+
viewport: { width: 1280, height: 720 },
152+
ignoreHTTPSErrors: true,
153+
colorScheme: 'light',
154+
};
155+
let clip;
156+
157+
const urls = args.filter(arg => {
158+
if (!arg.startsWith('--')) return true;
159+
160+
if (arg === '--dark') {
161+
options.colorScheme = 'dark';
162+
} else if (arg === '--light') {
163+
options.colorScheme = 'light';
164+
} else if (arg.startsWith('--clip=')) {
165+
const match = arg.slice(7).match(/^(\d+)x(\d+)\+(\d+)\+(\d+)$/);
166+
if (!match) {
167+
console.error(`Invalid clip format: ${arg} (expected --clip=WxH+X+Y)`);
168+
process.exit(1);
169+
}
170+
clip = {
171+
width: parseInt(match[1], 10),
172+
height: parseInt(match[2], 10),
173+
x: parseInt(match[3], 10),
174+
y: parseInt(match[4], 10),
175+
};
176+
// Ensure viewport is large enough to contain the clip region
177+
options.viewport = {
178+
width: Math.max(options.viewport.width, clip.x + clip.width),
179+
height: Math.max(options.viewport.height, clip.y + clip.height),
180+
};
181+
} else {
182+
console.error(`Unknown option: ${arg}`);
183+
process.exit(1);
184+
}
185+
return false;
186+
});
187+
188+
if (urls.length !== 2) {
189+
console.error(usage);
190+
process.exit(1);
191+
}
192+
193+
const beforeUrl = urls[0];
194+
const afterUrl = urls[1];
195+
196+
const browser = await chromium.launch();
197+
198+
try {
199+
const context = await browser.newContext(options);
200+
201+
const page = await context.newPage();
202+
203+
if (options.colorScheme === 'dark') {
204+
console.error('Using dark mode (prefers-color-scheme: dark)');
205+
}
206+
207+
async function takeScreenshot(urlOrWorktree, outputPath) {
208+
let server;
209+
let url = urlOrWorktree;
210+
211+
const worktreeInfo = getWorktreeInfo(urlOrWorktree);
212+
if (worktreeInfo) {
213+
server = await startServer(worktreeInfo.worktreePath, 5000, worktreeInfo.commit);
214+
url = `http://localhost:5000/${worktreeInfo.pagePath}`;
215+
}
216+
217+
try {
218+
console.error(`Navigating to: ${url}`);
219+
await page.goto(url, { waitUntil: 'networkidle' });
220+
await page.screenshot({ path: outputPath, clip, fullPage: !clip });
221+
const pageDims = await page.evaluate(() => ({
222+
width: document.documentElement.scrollWidth,
223+
height: document.documentElement.scrollHeight,
224+
}));
225+
const info = clip
226+
? `${clip.width}x${clip.height}+${clip.x}+${clip.y} of ${pageDims.width}x${pageDims.height}`
227+
: `${pageDims.width}x${pageDims.height}`;
228+
console.error(`Saved: ${outputPath} (${info})`);
229+
} finally {
230+
if (server) {
231+
server.kill();
232+
server.restore();
233+
}
234+
}
235+
}
236+
237+
await takeScreenshot(beforeUrl, '.before.png');
238+
await takeScreenshot(afterUrl, '.after.png');
239+
240+
console.error(`\nScreenshots saved:`);
241+
console.error(' - .before.png');
242+
console.error(' - .after.png');
243+
244+
console.error(`\nPR comment template (copy paths, then replace by uploading images):\n`);
245+
console.log(`<!-- Generated by 'node ${
246+
process.argv
247+
.slice(1)
248+
.map((e, i) => i != 1 ? e : e.replace(/.*[/\\]/, ''))
249+
.join(' ')
250+
}' -->`);
251+
console.log(`<table>`);
252+
console.log(`<tr><th>Before</th><th>After</th></tr>`);
253+
console.log(`<tr>`);
254+
console.log(`<td>`);
255+
console.log(path.resolve('.before.png'));
256+
console.log(`</td>`);
257+
console.log(`<td>`);
258+
console.log(path.resolve('.after.png'));
259+
console.log(`</td>`);
260+
console.log(`</tr>`);
261+
console.log(`</table>`);
262+
} finally {
263+
await browser.close();
264+
}
265+
}
266+
267+
main().catch((err) => {
268+
console.error(err);
269+
process.exit(1);
270+
});

0 commit comments

Comments
 (0)