openvibers/openvibe.blog Commits
agent: put the captured screenshots in the patch notes (plan L9) (#202)
Changes
@@@ -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 everyfew hours piled up faster than anyone could read them (27 waiting, none published since v2). A batch whose dayalready has a waiting draft nobody has touched is folded into it: a fresh draft from all of the day's commits, the@@@ -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);@@@ -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());},};}@@@ -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) {nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
## Unreleased
## 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
- 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
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
already has a waiting draft nobody has touched is folded into it: a fresh draft from all of the day's commits, the
@@ -98,7 +98,7 @@ async function createApp(opts = {}) {
@@ -98,7 +98,7 @@ async function createApp(opts = {}) {
const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });
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
// 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.
// 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 });
const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, patchNotes, fetchImpl, log });
if (patchNotes.attach) patchNotes.attach({ changelog });
if (patchNotes.attach) patchNotes.attach({ changelog });
posts.setPublishGate(patchNotes.gate);
posts.setPublishGate(patchNotes.gate);
@@ -20,6 +20,9 @@
@@ -20,6 +20,9 @@
*/
*/
const { serviceAuth } = require('openvibe-contracts');
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 }) {
function createMedia({ config, fetchImpl = globalThis.fetch }) {
const enabled = Boolean(config.oauth.clientSecret && config.media.internalUrl);
const enabled = Boolean(config.oauth.clientSecret && config.media.internalUrl);
const tokens = enabled ? serviceAuth.createTokenClient({
const tokens = enabled ? serviceAuth.createTokenClient({
@@ -27,24 +30,68 @@ function createMedia({ config, fetchImpl = globalThis.fetch }) {
@@ -27,24 +30,68 @@ function createMedia({ config, fetchImpl = globalThis.fetch }) {
audience: 'openvibe.media', scope: 'media.object.read', fetchImpl,
audience: 'openvibe.media', scope: 'media.object.read', fetchImpl,
}) : null;
}) : 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 {
return {
enabled,
enabled,
urlFor: (mediaId) => `${config.media.publicUrl}/o/${encodeURIComponent(mediaId)}`,
urlFor: (mediaId) => `${config.media.publicUrl}/o/${encodeURIComponent(mediaId)}`,
async resolve(mediaId) {
async resolve(mediaId) {
if (!tokens) throw new Error('Media verification is not configured');
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()) },
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.status === 401 && tokens.invalidate) tokens.invalidate();
if (!res.ok) throw new Error(`Media answered ${res.status}`);
if (!res.ok) throw new Error(`Media answered ${res.status}`);
const data = await res.json().catch(() => null);
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());
},
},
};
};
}
}
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
* blocking check or a missing person's approval stops posts.publish.
* blocking check or a missing person's approval stops posts.publish.
*/
*/
const { build } = require('./stories');
const { build } = require('./stories');
+const shotsMod = require('./shots');
const { compose, briefFor, mergeAi } = require('./draft');
const { compose, briefFor, mergeAi } = require('./draft');
const review = require('./review');
const review = require('./review');
const { createVoiceCards } = require('./voice');
const { createVoiceCards } = require('./voice');
@@ -18,7 +19,7 @@ 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 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);
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 db = store.db;
const voice = voiceCards || createVoiceCards({ store });
const voice = voiceCards || createVoiceCards({ store });
const maxStories = Math.max(2, Math.min(8, (config.patchNotes && config.patchNotes.maxStories) || 6));
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 =
@@ -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 };
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) ──────────────────────────────────────────────────────────────────
// ── 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
// 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
// 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 =
@@ -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) }))),
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(),
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);
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)' : ''}`);
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) {
async function load(postId) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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); });