openvibers/openvibe.blog Commits

agent: put the captured screenshots in the patch notes (plan L9) (#202)

OP OpenVibers committed 7 days ago · cf15a047ec · 6 files, +376 −10

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8ddfb20..c454a89 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,12 @@
## Unreleased
++- The screenshot pass, from the Blog's side (plan L9, devblog §3.3): when the patch notes are drafted, Blog reads the shots
++ OpenVibe.Host captured (the newest one taken before the release and the first one taken after it, per page and viewport).
++ When the two differ by more than 0.5 % of the pixels, both are attached to the draft (before, after) with the alt text and the
++ caption of §3.3; when they do not, only the shot after the release is attached; when there is no shot, the draft keeps its
++ "No screenshot" line. A person still reviews the draft; nothing publishes itself.
++
- Patch notes: one waiting draft per day (2026-11-02). A person reviews every Patch notes post, and drafts filed every
few hours piled up faster than anyone could read them (27 waiting, none published since v2). A batch whose day
already has a waiting draft nobody has touched is folded into it: a fresh draft from all of the day's commits, the
ddiff --git a/server/app.js b/server/app.js
iindex 608dd27..e1b366d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -98,7 +98,7 @@ async function createApp(opts = {}) {
const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });
// Patch notes v2: the story map, the template, the voice cards and the review gate. The changelog files drafts
// through it; posts.publish refuses a draft with a blocking check or no person's approval.
-- const patchNotes = opts.patchNotes || require('./patch-notes').createPatchNotes({ config, store, blogs, posts, publication, aiDrafts, fetchImpl, log });
++ const patchNotes = opts.patchNotes || require('./patch-notes').createPatchNotes({ config, store, blogs, posts, publication, aiDrafts, media, fetchImpl, log });
const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, patchNotes, fetchImpl, log });
if (patchNotes.attach) patchNotes.attach({ changelog });
posts.setPublishGate(patchNotes.gate);
ddiff --git a/server/clients/media.js b/server/clients/media.js
iindex 76cf9a8..c7860a6 100644
---- a/server/clients/media.js
++++ b/server/clients/media.js
@@@ -20,6 +20,9 @@
*/
const { serviceAuth } = require('openvibe-contracts');
++/** The tenant the shots live in (plan L9): OpenVibe.Host stores them in its own Media tenant. */
++const SHOTS_APP = 'host';
++
function createMedia({ config, fetchImpl = globalThis.fetch }) {
const enabled = Boolean(config.oauth.clientSecret && config.media.internalUrl);
const tokens = enabled ? serviceAuth.createTokenClient({
@@@ -27,24 +30,68 @@ function createMedia({ config, fetchImpl = globalThis.fetch }) {
audience: 'openvibe.media', scope: 'media.object.read', fetchImpl,
}) : null;
++ /** The tenant an object is asked for: Blog's own, or Host's (the shots, plan L9). */
++ const shotsApp = () => (config.media.shotsApp || SHOTS_APP);
++
++ /** The object in one tenant, or why it is not there. Anything else THROWS (check_failed). */
++ async function resolveIn(app, mediaId) {
++ const res = await fetchImpl(`${config.media.internalUrl}/api/v2/${encodeURIComponent(app)}/objects/${encodeURIComponent(mediaId)}`, {
++ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
++ signal: AbortSignal.timeout(5000),
++ });
++ if (res.status === 404) return { exists: false, reason: 'not_found' };
++ if (res.status === 410) return { exists: false, reason: 'deleted' };
++ if (res.status === 401 && tokens.invalidate) tokens.invalidate();
++ if (!res.ok) throw new Error(`Media answered ${res.status}`);
++ const data = await res.json().catch(() => null);
++ const obj = data && (data.object || data);
++ if (obj && obj.lifecycle_status === 'deleted') return { exists: false, reason: 'deleted' };
++ return { exists: true };
++ }
++
return {
enabled,
urlFor: (mediaId) => `${config.media.publicUrl}/o/${encodeURIComponent(mediaId)}`,
async resolve(mediaId) {
if (!tokens) throw new Error('Media verification is not configured');
-- const res = await fetchImpl(`${config.media.internalUrl}/api/v2/${encodeURIComponent(config.media.app)}/objects/${encodeURIComponent(mediaId)}`, {
++ const own = await resolveIn(config.media.app, mediaId);
++ if (own.exists || own.reason === 'deleted') return own;
++ // The shots Host captured live in Host's own tenant (plan L9): look there too, and never
++ // turn an outage or a missing grant into "the object is gone" (check_failed).
++ const other = shotsApp();
++ if (other && other !== config.media.app) {
++ const there = await resolveIn(other, mediaId);
++ if (there.exists || there.reason === 'deleted') return there;
++ }
++ return own;
++ },
++
++ /** The shots Host captured (plan L9): the index of the host tenant, newest first. */
++ async listShots({ app = null, kind = 'screenshot', limit = 100 } = {}) {
++ if (!tokens) throw new Error('Media verification is not configured');
++ const tenant = app || shotsApp();
++ const res = await fetchImpl(`${config.media.internalUrl}/api/v2/${encodeURIComponent(tenant)}/objects?kind=${encodeURIComponent(kind)}&limit=${limit}`, {
headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
-- signal: AbortSignal.timeout(5000),
++ signal: AbortSignal.timeout(8000),
});
-- if (res.status === 404) return { exists: false, reason: 'not_found' };
-- if (res.status === 410) return { exists: false, reason: 'deleted' };
if (res.status === 401 && tokens.invalidate) tokens.invalidate();
if (!res.ok) throw new Error(`Media answered ${res.status}`);
const data = await res.json().catch(() => null);
-- const obj = data && (data.object || data);
-- if (obj && obj.lifecycle_status === 'deleted') return { exists: false, reason: 'deleted' };
-- return { exists: true };
++ return (data && data.objects) || [];
++ },
++
++ /** The bytes of one shot. */
++ async download(mediaId, { app = null } = {}) {
++ if (!tokens) throw new Error('Media verification is not configured');
++ const tenant = app || shotsApp();
++ const res = await fetchImpl(`${config.media.internalUrl}/api/v2/${encodeURIComponent(tenant)}/objects/${encodeURIComponent(mediaId)}/download`, {
++ headers: { ...(await tokens.authHeaders()) },
++ redirect: 'follow',
++ signal: AbortSignal.timeout(30000),
++ });
++ if (!res.ok) throw new Error(`Media answered ${res.status}`);
++ return Buffer.from(await res.arrayBuffer());
},
};
}
ddiff --git a/server/patch-notes/index.js b/server/patch-notes/index.js
iindex 378c2ca..e8ffb43 100644
---- a/server/patch-notes/index.js
++++ b/server/patch-notes/index.js
@@@ -11,6 +11,7 @@
* blocking check or a missing person's approval stops posts.publish.
*/
const { build } = require('./stories');
++const shotsMod = require('./shots');
const { compose, briefFor, mergeAi } = require('./draft');
const review = require('./review');
const { createVoiceCards } = require('./voice');
@@@ -18,7 +19,7 @@ const { createVoiceCards } = require('./voice');
const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
const asJson = (v) => (v && typeof v === 'object' ? JSON.stringify(v) : v);
--function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts = null, voiceCards = null, changelog = null, fetchImpl = globalThis.fetch, log = console }) {
++function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts = null, voiceCards = null, changelog = null, media = null, fetchImpl = globalThis.fetch, log = console }) {
const db = store.db;
const voice = voiceCards || createVoiceCards({ store });
const maxStories = Math.max(2, Math.min(8, (config.patchNotes && config.patchNotes.maxStories) || 6));
@@@ -117,6 +118,67 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =
return { doc: det, map, card, samples, usedAi, ai, authorship, checks, owner, entries: list };
}
++ /**
++ * The screenshot pass (plan L9, devblog §3.3): for every story, the shots Host captured of the pages
++ * that story names — the newest one taken before the release and the first one taken after it. When the
++ * two differ by more than 0.5 % of the pixels, both are attached (before, after); when they do not, only
++ * the one after is attached; when there is no shot, the draft keeps its "No screenshot" line.
++ *
++ * Nothing is published here: the shots go on the draft, and a person still reviews it.
++ */
++ async function attachShots(viewer, post, { entries = [], stories = [] } = {}) {
++ if (!media || typeof media.listShots !== 'function') return { attached: [], reason: 'the shot index is not configured' };
++ let shots = [];
++ try { shots = await media.listShots(); } catch (err) {
++ log.warn && log.warn(`[patch-notes] the shots were not read: ${err.message}`);
++ return { attached: [], reason: 'the shots could not be read' };
++ }
++ if (!shots.length) return { attached: [], reason: 'no shot yet' };
++ const groups = new Map();
++ for (const s of shots) {
++ const key = shotsMod.groupKey(s);
++ if (!groups.has(key)) groups.set(key, []);
++ groups.get(key).push(s);
++ }
++ const attached = [];
++ for (const story of stories || []) {
++ const at = (story.entries || []).map((e) => Date.parse(e.deployed_at || 0)).filter(Boolean).sort((a, b) => b - a)[0] || null;
++ let best = null;
++ for (const [key, list] of groups) {
++ const service = String(key).split('|')[0];
++ if (!story.sites || !story.sites.includes(service)) continue;
++ const pair = shotsMod.pickPair(list, at);
++ if (!pair.after) continue;
++ let ratio = 1;
++ if (pair.before) {
++ try {
++ const [a, b] = await Promise.all([media.download(pair.before.id), media.download(pair.after.id)]);
++ ratio = shotsMod.diffRatio(a, b);
++ } catch (err) {
++ log.warn && log.warn(`[patch-notes] the shots of ${key} were not compared: ${err.message}`);
++ continue;
++ }
++ }
++ const mode = shotsMod.decide(pair.before, pair.after, ratio);
++ if (!best || ratio > best.ratio) best = { key, ...pair, ratio, mode };
++ }
++ if (!best || best.mode === 'none') continue;
++ const meta = (best.after.metadata || {});
++ const date = String(meta.captured_at || new Date().toISOString()).slice(0, 10);
++ const whatChanged = (story.title || 'the page changed');
++ const whatWasWrong = 'the page looked different before this release';
++ const caption = shotsMod.captionFor({ before: best.before, after: best.after, viewport: meta.viewport, date, whatWasWrong, whatChanged });
++ const alt = shotsMod.altFor({ mode: best.mode, whatWasWrong, whatChanged });
++ if (best.mode === 'both' && best.before) {
++ attached.push(await posts.attach(viewer, post, { mediaId: best.before.id, role: 'inline', alt, caption, position: 0 }));
++ attached.push(await posts.attach(viewer, post, { mediaId: best.after.id, role: 'inline', alt, caption, position: 1 }));
++ } else {
++ attached.push(await posts.attach(viewer, post, { mediaId: best.after.id, role: 'inline', alt, caption, position: 0 }));
++ }
++ }
++ return { attached };
++ }
++
// ── One waiting draft per day (11-02) ──────────────────────────────────────────────────────────────────
// A person reviews every Patch notes post (devblog §3.6). Filed every few hours, the drafts piled up faster than
// anyone could read them (27 waiting on 11-02, none published since v2). So a day has one waiting draft: a batch
@@@ -209,9 +271,10 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =
post_id: post.id, blog_id: blog.id, stories: asJson(p.map.stories.map((s) => ({ key: s.key, title: s.title, kind: s.kind, sites: s.sites, commits: s.entries.map((e) => e.sha) }))),
entries: asJson(list.map(entryView)), checks: asJson(p.checks), voice_card: asJson(p.card.card), override: null, now: store.now(),
});
++ const shotResult = await attachShots(viewer, post, { entries: list, stories: p.map.stories });
if (changelog && changelog.markEntries) await changelog.markEntries(list, post.id);
log.log && log.log(`[patch-notes] drafted ${post.id} from ${list.length} commits${p.usedAi ? ' (AI prose)' : ''}`);
-- return { created: true, post, revision, url: publication.postUrl(blog, post), doc: p.doc, checks: p.checks, usedAi: p.usedAi, stories: p.map.stories };
++ return { created: true, post, revision, url: publication.postUrl(blog, post), doc: p.doc, checks: p.checks, usedAi: p.usedAi, stories: p.map.stories, shots: shotResult };
}
async function load(postId) {
ddiff --git a/server/patch-notes/shots.js b/server/patch-notes/shots.js
nnew file mode 100644
iindex 0000000..70b8b9c
---- /dev/null
++++ b/server/patch-notes/shots.js
@@@ -0,0 +1,138 @@
++'use strict';
++/**
++ * The screenshot pass, from the Blog's side (plan L9, devblog §3.3).
++ *
++ * OpenVibe.Host captures the public pages (ovhost shots, every six hours) and stores each PNG in
++ * OpenVibe.Media, in Host's own tenant, with the service, the path, the viewport, the release and the
++ * time it was taken. When the patch notes are drafted for a release, Blog reads that index: for the
++ * page a story names, the newest shot taken before the release and the first one taken after it.
++ *
++ * both the two shots differ by more than 0.5 % of the pixels: both are attached (before, after)
++ * after they do not: only the shot after the release is attached
++ * none there is no shot: the draft keeps its "No screenshot" line
++ *
++ * A person still reviews the draft; nothing here publishes anything.
++ */
++const zlib = require('zlib');
++
++/** More than this share of the pixels changed and the two shots are worth showing side by side. */
++const DIFF_THRESHOLD = 0.005;
++const PNG_MAGIC = 0x89504e47;
++const CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 };
++
++/** A PNG, decoded: { width, height, channels, data }. 8-bit, not interlaced (what Chrome writes). */
++function decodePng(buf) {
++ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
++ if (b.length < 8 || b.readUInt32BE(0) !== PNG_MAGIC) throw new Error('not a PNG');
++ let pos = 8;
++ let ihdr = null;
++ const idat = [];
++ while (pos + 8 <= b.length) {
++ const len = b.readUInt32BE(pos);
++ const type = b.toString('ascii', pos + 4, pos + 8);
++ const data = b.subarray(pos + 8, pos + 8 + len);
++ if (type === 'IHDR') ihdr = { width: data.readUInt32BE(0), height: data.readUInt32BE(4), bitDepth: data[8], colorType: data[9], interlace: data[12] };
++ else if (type === 'IDAT') idat.push(Buffer.from(data));
++ else if (type === 'IEND') break;
++ pos += 12 + len;
++ }
++ if (!ihdr) throw new Error('the PNG has no header');
++ if (ihdr.bitDepth !== 8) throw new Error('only 8-bit PNGs are compared');
++ if (ihdr.interlace) throw new Error('an interlaced PNG cannot be compared');
++ const channels = CHANNELS[ihdr.colorType];
++ if (!channels) throw new Error(`unsupported PNG colour type ${ihdr.colorType}`);
++ const raw = zlib.inflateSync(Buffer.concat(idat));
++ const stride = ihdr.width * channels;
++ const out = Buffer.alloc(ihdr.height * stride);
++ let rp = 0;
++ for (let y = 0; y < ihdr.height; y++) {
++ const filter = raw[rp++];
++ const line = raw.subarray(rp, rp + stride);
++ rp += stride;
++ const cur = out.subarray(y * stride, (y + 1) * stride);
++ const prev = y > 0 ? out.subarray((y - 1) * stride, y * stride) : Buffer.alloc(stride);
++ for (let x = 0; x < stride; x++) {
++ const a = x >= channels ? cur[x - channels] : 0;
++ const b = prev[x];
++ const c = x >= channels ? prev[x - channels] : 0;
++ let v = line[x];
++ if (filter === 1) v += a;
++ else if (filter === 2) v += b;
++ else if (filter === 3) v += Math.floor((a + b) / 2);
++ else if (filter === 4) {
++ const p = a + b - c;
++ const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c);
++ v += pa <= pb && pa <= pc ? a : (pb <= pc ? b : c);
++ }
++ cur[x] = v & 0xff;
++ }
++ }
++ return { width: ihdr.width, height: ihdr.height, channels, data: out };
++}
++
++/** The share of the pixels that differ between two PNGs (1 when the sizes differ). */
++function diffRatio(a, b) {
++ const x = decodePng(a);
++ const y = decodePng(b);
++ if (x.width !== y.width || x.height !== y.height) return 1;
++ const total = x.width * x.height;
++ if (!total) return 0;
++ const n = Math.min(x.channels, y.channels, 3);
++ let different = 0;
++ for (let p = 0; p < total; p++) {
++ const o = p * x.channels;
++ const q = p * y.channels;
++ if (x.data[o] !== y.data[q] || x.data[o + 1] !== y.data[q + 1] || x.data[o + 2] !== y.data[q + 2]) different++;
++ }
++ return different / total;
++}
++
++/** The shots of one page and viewport, oldest first. */
++function groupKey(shot) {
++ const m = (shot && shot.metadata) || {};
++ return `${m.service || ''}|${m.path || ''}|${m.viewport || ''}`;
++}
++
++/**
++ * The pair around a release: the newest shot taken before it and the first one taken after it.
++ * answers { before, after } (either may be null).
++ */
++function pickPair(shots, at) {
++ const list = [...(shots || [])].sort((a, b) => Date.parse((a.metadata || {}).captured_at || 0) - Date.parse((b.metadata || {}).captured_at || 0));
++ const t = Date.parse(at || 0);
++ let before = null;
++ let after = null;
++ for (const s of list) {
++ const at2 = Date.parse((s.metadata || {}).captured_at || 0);
++ if (Number.isFinite(t) && at2 <= t) before = s;
++ else if (!after) after = s;
++ }
++ if (!after && before) { after = before; before = null; }
++ return { before, after };
++}
++
++/** What to do with a pair: both shots, only the one after, or nothing. */
++function decide(before, after, ratio) {
++ if (!after) return 'none';
++ if (!before) return 'after';
++ return ratio > DIFF_THRESHOLD ? 'both' : 'after';
++}
++
++/** The caption of §3.3: "Before: …. After: …. 390px, <date>." */
++function captionFor({ before, after, viewport = 'phone', date = '', whatWasWrong = null, whatChanged = null }) {
++ const wrong = whatWasWrong || 'the page looked different';
++ const changed = whatChanged || 'the page changed';
++ const width = viewport === 'desktop' ? '1440px' : '390px';
++ const when = date || new Date().toISOString().slice(0, 10);
++ return `Before: ${wrong}. After: ${changed}. ${width}, ${when}.`;
++}
++
++/** The alt text of §3.3: "Two phone screens. Left: …. Right: …." */
++function altFor({ mode, whatWasWrong = null, whatChanged = null }) {
++ const wrong = whatWasWrong || 'the page before this release';
++ const changed = whatChanged || 'the page after this release';
++ if (mode === 'both') return `Two phone screens. Left: ${wrong}. Right: ${changed}.`;
++ return `A phone screen: ${changed}.`;
++}
++
++module.exports = { DIFF_THRESHOLD, decodePng, diffRatio, groupKey, pickPair, decide, captionFor, altFor };
ddiff --git a/test/patch-notes-shots.test.js b/test/patch-notes-shots.test.js
nnew file mode 100644
iindex 0000000..80d4687
---- /dev/null
++++ b/test/patch-notes-shots.test.js
@@@ -0,0 +1,112 @@
++'use strict';
++/**
++ * The screenshot pass, from the Blog's side (server/patch-notes/shots.js, plan L9, devblog §3.3):
++ * the newest shot before a release and the first one after it are compared; more than 0.5 % of the
++ * pixels changed and both are attached, otherwise only the one after is; no shot at all and the
++ * draft keeps its "No screenshot" line.
++ */
++const assert = require('assert');
++const zlib = require('zlib');
++const { diffRatio, pickPair, decide, captionFor, altFor, DIFF_THRESHOLD, groupKey } = require('../server/patch-notes/shots');
++
++/** A PNG (8-bit RGB, no filter): one colour per pixel. */
++function png(width, height, pixels) {
++ const stride = width * 3;
++ const raw = Buffer.alloc(height * (1 + stride));
++ for (let y = 0; y < height; y++) {
++ const row = y * (1 + stride);
++ raw[row] = 0;
++ for (let x = 0; x < width; x++) {
++ const p = pixels[y * width + x];
++ raw[row + 1 + x * 3] = p[0];
++ raw[row + 2 + x * 3] = p[1];
++ raw[row + 3 + x * 3] = p[2];
++ }
++ }
++ const chunk = (type, data) => {
++ const len = Buffer.alloc(4);
++ len.writeUInt32BE(data.length);
++ const t = Buffer.from(type, 'ascii');
++ const crc = Buffer.alloc(4);
++ crc.writeUInt32BE(zlib.crc32(Buffer.concat([t, data])));
++ return Buffer.concat([len, t, data, crc]);
++ };
++ const ihdr = Buffer.alloc(13);
++ ihdr.writeUInt32BE(width, 0);
++ ihdr.writeUInt32BE(height, 4);
++ ihdr[8] = 8; // 8 bits
++ ihdr[9] = 2; // truecolour
++ return Buffer.concat([
++ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
++ chunk('IHDR', ihdr),
++ chunk('IDAT', zlib.deflateSync(raw)),
++ chunk('IEND', Buffer.alloc(0)),
++ ]);
++}
++
++const flat = (w, h, colour) => png(w, h, Array.from({ length: w * h }, () => colour));
++
++const shot = (id, { service = 'live', path = '/', viewport = 'phone', at = '2026-11-01T00:00:00Z', release = 'abc123' } = {}) => ({
++ id,
++ metadata: { service, path, viewport, release, captured_at: at },
++});
++
++const tests = [];
++const test = (name, fn) => tests.push({ name, fn });
++const run = async () => {
++ for (const t of tests) {
++ await t.fn();
++ process.stdout.write(`✓ ${t.name}\n`);
++ }
++};
++
++(async () => {
++ test('two identical shots differ in no pixel', async () => {
++ assert.strictEqual(diffRatio(flat(10, 10, [10, 20, 30]), flat(10, 10, [10, 20, 30])), 0);
++ });
++
++ test('one changed pixel in a hundred is more than the 0.5 % threshold', async () => {
++ const before = flat(10, 10, [0, 0, 0]);
++ const pixels = Array.from({ length: 100 }, () => [0, 0, 0]);
++ pixels[0] = [255, 255, 255]; // one pixel of the hundred changed
++ const after = png(10, 10, pixels);
++ const ratio = diffRatio(before, after);
++ assert.ok(ratio > DIFF_THRESHOLD, `${ratio} is more than ${DIFF_THRESHOLD}`);
++ assert.strictEqual(decide({ id: 'a' }, { id: 'b' }, ratio), 'both');
++ });
++
++ test('shots of a different size count as different', async () => {
++ assert.strictEqual(diffRatio(flat(10, 10, [0, 0, 0]), flat(20, 10, [0, 0, 0])), 1);
++ });
++
++ test('the pair around a release is the newest shot before it and the first one after it', async () => {
++ const list = [
++ shot('a', { at: '2026-10-30T00:00:00Z' }),
++ shot('b', { at: '2026-10-31T00:00:00Z' }),
++ shot('c', { at: '2026-11-02T00:00:00Z' }),
++ shot('d', { at: '2026-11-03T00:00:00Z' }),
++ ];
++ const pair = pickPair(list, '2026-11-01T12:00:00Z');
++ assert.deepStrictEqual([pair.before.id, pair.after.id], ['b', 'c']);
++ });
++
++ test('the shots of one page and viewport share a key', async () => {
++ assert.strictEqual(groupKey(shot('a', { service: 'blog', path: '/pricing' })), 'blog|/pricing|phone');
++ });
++
++ test('without a pair of shots nothing is attached', async () => {
++ assert.strictEqual(decide(null, null, 0), 'none');
++ assert.strictEqual(decide(null, { id: 'a' }, 0), 'after');
++ });
++
++ test('the caption and the alt text follow the formula of §3.3', async () => {
++ const caption = captionFor({ viewport: 'phone', date: '2026-11-06', whatWasWrong: 'the page ran wider than the phone', whatChanged: 'it fits now' });
++ assert.strictEqual(caption, 'Before: the page ran wider than the phone. After: it fits now. 390px, 2026-11-06.');
++ assert.strictEqual(altFor({ mode: 'both', whatWasWrong: 'it was too wide', whatChanged: 'it fits now' }), 'Two phone screens. Left: it was too wide. Right: it fits now.');
++ assert.ok(altFor({ mode: 'after', whatChanged: 'it fits now' }).length > 0, 'the single shot has alt text too');
++ });
++
++ await run();
++ console.log('patch notes shots: the pair, the 0.5 % threshold, the caption and the alt text');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });