Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6cb66ca
ci: compare screens drawn from a pull request and its base
M4n0x Oct 31, 2026
f5695c7
ci: write the screens comment and decide the visual review
M4n0x Oct 31, 2026
4af35b0
ci: bind visual-ok to the screens shown and keep artifact text inert
M4n0x Oct 31, 2026
848a293
ci: capture the website for the before/after comparison
M4n0x Oct 31, 2026
2f62d54
ci: serve the website capture only files inside its folder
M4n0x Oct 31, 2026
bb67b73
ci: hold the website's clock and random numbers still for captures
M4n0x Oct 31, 2026
241df70
ci: draw the screens of pull requests and their base
M4n0x Oct 31, 2026
fc3c74b
ci: run the screen scripts when called through a symbolic link
M4n0x Oct 31, 2026
fc0c0de
ci: publish the before/after screens and the visual review status
M4n0x Oct 31, 2026
76f0933
docs: explain the before/after screens and visual-ok
M4n0x Oct 31, 2026
f9ffd40
ci: fail the visual review when CI's screens jobs are missing or skipped
M4n0x Oct 31, 2026
79ef7b3
ci: crop screens of a new height from their first change, with more c…
M4n0x Oct 31, 2026
4ba28af
ci: draw screens when the examples the scenes read change
M4n0x Oct 31, 2026
2db63e5
ci: upload only images the bot decoded and encoded itself
M4n0x Oct 31, 2026
0b966b0
ci: bind visual-ok to the whole after image, not only its crop
M4n0x Oct 31, 2026
47ebccb
ci: fail the review when drawn screens are missing
M4n0x Oct 31, 2026
23b972f
ci: spread pull request screens over releases with room
M4n0x Oct 31, 2026
b754ec9
ci: reconcile each pull request's screens one event at a time
M4n0x Oct 31, 2026
8932b9b
docs: describe the screens reconcile, crops of new heights and full r…
M4n0x Oct 31, 2026
962ad26
ci: tie published screens to their own run, base and moment
M4n0x Oct 31, 2026
5d8436e
ci: compare no screens for a branch that several pull requests share
M4n0x Oct 31, 2026
7c7b0e4
ci: keep a closed sibling's CI runs out of a pull request's screens
M4n0x Oct 31, 2026
4b36ebb
ci: show a reopened pull request's screens again at once
M4n0x Oct 31, 2026
6fb68bf
ci: mark the head errored when the screens plan fails, and pin Playwr…
M4n0x Oct 31, 2026
6b52545
ci: mark the event's head errored when GitHub can't say where it stands
M4n0x Oct 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ CONTRIBUTING.md explains how commit types decide releases.
## Testing

<!-- The tests and manual checks you ran, and what you saw. Say what you could not check.
For visible changes (the bar, popups, setup, Settings), add before/after screenshots, and a short
recording when motion matters. Upload them here; don't commit them. -->
For visible changes, the screens bot comments with before/after images; when none shows your change,
extend a scene in scenes/. Add a short recording when motion matters: upload it here, don't commit it. -->

## Merging

Expand Down
112 changes: 112 additions & 0 deletions .github/scripts/capture-site.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Captures the website for the before/after comparison: node capture-site.mjs SITE OUT
// Each page of the SITE folder at desktop and phone size, full page, in light mode to
// OUT/<page>-<size>.png and in dark mode to OUT/<page>-<size>-dark.png. Requests are answered from
// the folder, without a server, and nothing else is fetched. So that two captures of one commit
// match, motion is reduced, Math.random is seeded, and the page's clock stands at Thursday 22 October
// 2026, 09:41 UTC, moving only when the capture lets 5 seconds of timers and frames run. Playwright
// is resolved from the working folder; STABLEBAR_CAPTURE_CHANNEL=chrome uses the installed Chrome
// instead of Playwright's Chromium.
import { createRequire } from "node:module";
import { mkdirSync, realpathSync, statSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { pathToFileURL } from "node:url";

export const origin = "http://stablebar.test";
const pages = ["index", "privacy", "terms", "404"];
const schemes = { light: "", dark: "-dark" };
const sizes = {
desktop: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 },
phone: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true },
};
const fixedTime = new Date("2026-10-22T09:41:00Z");
/** How long the page's timers and animation frames run, on its stopped clock, before the capture. */
const settleTime = 5000;

/** Replaces Math.random with a seeded generator (mulberry32), in every document before its scripts. */
function seedRandom() {
let seed = 0x5eed;
Math.random = () => {
seed = (seed + 0x6d2b79f5) | 0;
let value = Math.imul(seed ^ (seed >>> 15), 1 | seed);
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}

/**
* The real path of the file in the `root` folder that `url` asks for, or null when it asks for none.
* Symbolic links count only when they lead to a file inside the folder.
*/
export function fileFor(root, url) {
const { origin: asked, pathname } = new URL(url);
if (asked !== origin) return null;
try {
const folder = realpathSync(root);
const path = resolve(folder, `.${decodeURIComponent(pathname)}`);
const file = realpathSync(pathname.endsWith("/") ? join(path, "index.html") : path);
return file.startsWith(folder + sep) && statSync(file).isFile() ? file : null;
} catch {
return null;
}
}

/**
* Waits for the fonts and for every image to load and decode. A broken image is the same in every
* capture, so it only gets a warning, and shows in the comparison.
*/
async function settle(tab) {
await tab.waitForLoadState("networkidle");
const broken = await tab.evaluate(async () => {
await document.fonts.ready;
const images = [...document.images];
for (const image of images) image.loading = "eager";
const results = await Promise.all(images.map((image) => image.decode().then(() => null, () => image.getAttribute("src"))));
return results.filter(Boolean);
});
if (broken.length > 0) console.log(`::warning::${new URL(tab.url()).pathname}: these images don't load: ${broken.join(", ")}`);
}

export async function capture(site, out, { channel } = {}) {
const root = resolve(site);
const { chromium } = createRequire(join(process.cwd(), "capture-site.cjs"))("playwright");
mkdirSync(out, { recursive: true });
const browser = await chromium.launch(channel ? { channel } : {});
try {
for (const [size, options] of Object.entries(sizes)) {
for (const [scheme, suffix] of Object.entries(schemes)) {
const context = await browser.newContext({ ...options, colorScheme: scheme, reducedMotion: "reduce", locale: "en-US", timezoneId: "UTC" });
await context.addInitScript(seedRandom);
await context.route("**/*", (route) => {
const url = route.request().url();
const file = fileFor(root, url);
if (file) return route.fulfill({ path: file });
return url.startsWith(`${origin}/`) ? route.fulfill({ status: 404, body: "" }) : route.abort();
});
for (const page of pages) {
const tab = await context.newPage();
await tab.clock.install({ time: fixedTime });
await tab.clock.pauseAt(fixedTime);
await tab.goto(`${origin}/${page}.html`, { waitUntil: "load" });
await settle(tab);
await tab.clock.runFor(settleTime);
await settle(tab);
await tab.screenshot({ path: join(out, `${page}-${size}${suffix}.png`), fullPage: true, animations: "disabled" });
await tab.close();
}
await context.close();
}
}
} finally {
await browser.close();
}
}

// Node names a module by its real path, so the path it was called by is resolved first.
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
const [site, out] = process.argv.slice(2);
if (!site || !out) {
console.error("usage: capture-site.mjs SITE OUT");
process.exit(64);
}
await capture(site, out, { channel: process.env.STABLEBAR_CAPTURE_CHANNEL });
}
29 changes: 29 additions & 0 deletions .github/scripts/capture-site.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileFor, origin } from "./capture-site.mjs";

test("fileFor answers requests from the site folder only", () => {
const root = mkdtempSync(join(tmpdir(), "site-"));
mkdirSync(join(root, "site", "images"), { recursive: true });
const site = join(root, "site");
writeFileSync(join(site, "index.html"), "<p>home</p>");
writeFileSync(join(site, "images", "a b.webp"), "image");
writeFileSync(join(root, "secret.txt"), "secret");
symlinkSync(join(root, "secret.txt"), join(site, "leak.txt"));
symlinkSync(join(site, "index.html"), join(site, "home.html"));
const real = realpathSync(site);
assert.equal(fileFor(site, `${origin}/`), join(real, "index.html"));
assert.equal(fileFor(site, `${origin}/index.html?x=1#top`), join(real, "index.html"));
assert.equal(fileFor(site, `${origin}/images/a%20b.webp`), join(real, "images", "a b.webp"));
assert.equal(fileFor(site, `${origin}/home.html`), join(real, "index.html"));
assert.equal(fileFor(site, `${origin}/leak.txt`), null);
assert.equal(fileFor(site, `${origin}/missing.html`), null);
assert.equal(fileFor(site, `${origin}/images`), null);
assert.equal(fileFor(site, `${origin}/%2e%2e/secret.txt`), null);
assert.equal(fileFor(site, `${origin}/..%2fsecret.txt`), null);
assert.equal(fileFor(site, `${origin}/%E0%A4%A`), null);
assert.equal(fileFor(site, "https://stablebar.app/index.html"), null);
});
Loading
Loading