From 9b0d0aeeaaf89b5a788b83237286a7af5571e8d1 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:57:09 +0000 Subject: [PATCH] Store visual baseline images once by content hash Every push to main uploads a full copy of every screenshot under its own commit, ~3.5MB a push, so storage grows with pushes rather than with visual changes. Baseline images are now stored once at `blobs/.png`, and each commit's manifest (marked `imageStore: 'blobs'`) refers to them by hash. A push only uploads its manifests and any images not already in `blobs/`. Older baselines without `imageStore` are still read from their per-commit paths. Changelog: [Internal] --- .../__tests__/visual-compare-test.js | 48 +++++++++------ .../__tests__/visual-report-test.js | 15 +++++ .../workflow-scripts/visual/compare-run.js | 59 +++++++++++++++---- .github/workflow-scripts/visual/storage.js | 36 ++++++++++- .github/workflows/visual-compare-rntester.yml | 6 +- 5 files changed, 133 insertions(+), 31 deletions(-) diff --git a/.github/workflow-scripts/__tests__/visual-compare-test.js b/.github/workflow-scripts/__tests__/visual-compare-test.js index 543617239ce4..820c1883a214 100644 --- a/.github/workflow-scripts/__tests__/visual-compare-test.js +++ b/.github/workflow-scripts/__tests__/visual-compare-test.js @@ -282,35 +282,47 @@ describe('visual comparison', () => { }); describe('stageBaseline', () => { - test('leaves out unstable screenshots', () => { + test('stages blobs not yet uploaded and a manifest that refers to them', async () => { const captureDir = path.join(directory, 'capture'); const outDir = path.join(directory, 'out'); + const white = makePng(4, 4); + const black = makePng(4, 4, () => [0, 0, 0, 255]); + const grey = makePng(4, 4, () => [128, 128, 128, 255]); const manifest = writeCapture(captureDir, { - 'text/stable/default': makePng(4, 4), - 'text/unstable/default': makePng(4, 4), + 'text/uploaded/default': white, + 'text/new/default': black, + 'text/new/same': black, + 'text/unstable/default': grey, + }); + manifest.images.find( + image => image.id === 'text/unstable/default', + ).unstable = true; + await stageBaseline({ + outDir, + captureDir, + manifest, + sha: SHA_A, + blobExists: async key => key === `blobs/${sha256(white)}.png`, }); - manifest.images[1].unstable = true; - stageBaseline({outDir, captureDir, manifest, sha: SHA_A}); - const prefix = `baselines/${manifest.environmentKey}/${SHA_A}`; const staged = JSON.parse( fs.readFileSync( - path.join(outDir, 'baseline-manifests', prefix, 'manifest.json'), + path.join( + outDir, + 'baseline-manifests', + `baselines/${manifest.environmentKey}/${SHA_A}/manifest.json`, + ), 'utf8', ), ); - expect(staged.images.map(image => image.id)).toEqual([ - 'text/stable/default', + expect(staged.imageStore).toBe('blobs'); + expect(staged.images.map(image => image.id).sort()).toEqual([ + 'text/new/default', + 'text/new/same', + 'text/uploaded/default', ]); expect( - fs.existsSync( - path.join( - outDir, - 'baseline-images', - prefix, - 'text/unstable/default.png', - ), - ), - ).toBe(false); + fs.readdirSync(path.join(outDir, 'baseline-blobs', 'blobs')), + ).toEqual([`${sha256(black)}.png`]); }); }); diff --git a/.github/workflow-scripts/__tests__/visual-report-test.js b/.github/workflow-scripts/__tests__/visual-report-test.js index ac6667324fbd..19276b83133e 100644 --- a/.github/workflow-scripts/__tests__/visual-report-test.js +++ b/.github/workflow-scripts/__tests__/visual-report-test.js @@ -20,7 +20,9 @@ const { } = require('../visual/report'); const { approvalKey, + baselineImageKey, baselinePrefix, + blobKey, publicUrl, reportPrefix, } = require('../visual/storage'); @@ -48,8 +50,21 @@ describe('visual reports and approval', () => { ); }); + test.each([ + ['blobs', `blobs/${'a'.repeat(64)}.png`], + [null, `baselines/ios-x-0123abcd/${SHA_A}/text/basic/default.png`], + ])('finds a baseline image with imageStore %p', (imageStore, key) => { + expect( + baselineImageKey( + {environmentKey: 'ios-x-0123abcd', sha: SHA_A, imageStore}, + {id: 'text/basic/default', sha256: 'a'.repeat(64)}, + ), + ).toBe(key); + }); + test.each([ [() => baselinePrefix('k', 'main')], + [() => blobKey('../x')], [ () => reportPrefix({prNumber: -1, headSha: SHA_A, runId: 1, runAttempt: 1}), diff --git a/.github/workflow-scripts/visual/compare-run.js b/.github/workflow-scripts/visual/compare-run.js index 6e132ed8d681..31630e7bf85a 100644 --- a/.github/workflow-scripts/visual/compare-run.js +++ b/.github/workflow-scripts/visual/compare-run.js @@ -35,7 +35,10 @@ const { const { BASELINE_SEARCH_DEPTH, approvalKey, + baselineImageKey, baselinePrefix, + blobKey, + existsAt, fetchBuffer, fetchJsonOrNull, findBaseline, @@ -158,10 +161,18 @@ async function comparePlatform({ }; } + const location = { + environmentKey, + sha: baseline.sha, + imageStore: baseline.manifest.imageStore ?? null, + }; + const baselineImages = new Map( + baseline.manifest.images.map(image => [image.id, image]), + ); const expectedUrl = id => publicUrl( publicBaseUrl, - `${baselinePrefix(environmentKey, baseline.sha)}/${id}.png`, + baselineImageKey(location, baselineImages.get(id)), ); const results = await compareCapture({ manifest, @@ -201,7 +212,11 @@ async function comparePlatform({ platform, environmentKey, manifest, - baseline: {sha: baseline.sha, distance: baseline.distance}, + baseline: { + sha: baseline.sha, + distance: baseline.distance, + imageStore: location.imageStore, + }, results, error: null, }; @@ -225,20 +240,35 @@ function missingCapture(platform, context) { * Stages the capture as the baseline for `sha`, leaving out new screenshots * whose two captures differed. They stay new until a later push to the * default branch captures them the same way twice. + * + * Only images not already in `blobs/` are staged, so a push that changes + * nothing visually publishes just its manifest. `blobExists(key)` checks the + * bucket. `validateCapture` has checked each image's hash, so its blob key + * matches its contents. */ -function stageBaseline({outDir, captureDir, manifest, sha}) { - const prefix = baselinePrefix(manifest.environmentKey, sha); +async function stageBaseline({outDir, captureDir, manifest, sha, blobExists}) { const images = manifest.images.filter(image => image.unstable !== true); + const staged = new Set(); for (const image of images) { + const key = blobKey(image.sha256); + if (staged.has(key) || (await blobExists(key))) { + continue; + } + staged.add(key); writeFile( - path.join(outDir, 'baseline-images', prefix, `${image.id}.png`), + path.join(outDir, 'baseline-blobs', key), fs.readFileSync(path.join(captureDir, `${image.id}.png`)), ); } - // Uploaded after the images: a baseline exists once its manifest does. + // Uploaded after the blobs: a baseline exists once its manifest does. writeFile( - path.join(outDir, 'baseline-manifests', prefix, 'manifest.json'), - `${JSON.stringify({...manifest, images}, null, 2)}\n`, + path.join( + outDir, + 'baseline-manifests', + baselinePrefix(manifest.environmentKey, sha), + 'manifest.json', + ), + `${JSON.stringify({...manifest, imageStore: 'blobs', images}, null, 2)}\n`, ); } @@ -293,11 +323,12 @@ async function prepare() { ); } if (context.kind === 'main') { - stageBaseline({ + await stageBaseline({ outDir, captureDir, manifest: result.manifest, sha: context.headSha, + blobExists: key => existsAt(publicUrl(publicBaseUrl, key)), }); } platforms.push(result); @@ -352,11 +383,19 @@ async function report() { const summaryLines = []; const commentPlatforms = []; for (const platform of state.platforms) { + const expectedById = new Map( + platform.results + .filter(result => result.expected != null) + .map(result => [result.id, result.expected]), + ); const imageUrl = (kind, id) => kind === 'expected' ? publicUrl( publicBaseUrl, - `${baselinePrefix(platform.environmentKey, platform.baseline.sha)}/${id}.png`, + baselineImageKey( + {environmentKey: platform.environmentKey, ...platform.baseline}, + expectedById.get(id), + ), ) : publicUrl( publicBaseUrl, diff --git a/.github/workflow-scripts/visual/storage.js b/.github/workflow-scripts/visual/storage.js index f74addc4932e..bb46d6031437 100644 --- a/.github/workflow-scripts/visual/storage.js +++ b/.github/workflow-scripts/visual/storage.js @@ -14,14 +14,20 @@ const {SHA_PATTERN} = require('./manifest'); const BASELINE_SEARCH_DEPTH = 20; // Object layout. Every key is write-once: reports and approvals include the -// run that produced them, and a baseline is only ever written for a new SHA. +// run that produced them, a baseline is only ever written for a new SHA, and +// a blob's key is its contents' SHA-256. // +// blobs/.png // baselines///manifest.json // baselines///.png // reports/pull///-/results.json // reports/pull///-//{expected,captured,diff}/.png // reports/main//-/... // approvals/pull//.json +// +// A baseline manifest with `imageStore: 'blobs'` refers to its images in +// `blobs/` by hash, so an image unchanged across commits is stored once. +// Older baselines have a copy of each image under their own prefix. function assertSha(sha) { if (!SHA_PATTERN.test(sha)) { @@ -40,6 +46,20 @@ function baselinePrefix(environmentKey, sha) { return `baselines/${environmentKey}/${sha}`; } +function blobKey(sha256) { + if (!/^[0-9a-f]{64}$/.test(sha256)) { + throw new Error(`Invalid SHA-256: ${String(sha256)}`); + } + return `blobs/${sha256}.png`; +} + +/** The key of `image` in the baseline published for `sha`. */ +function baselineImageKey({environmentKey, sha, imageStore}, image) { + return imageStore === 'blobs' + ? blobKey(image.sha256) + : `${baselinePrefix(environmentKey, sha)}/${image.id}.png`; +} + function reportPrefix({prNumber, headSha, runId, runAttempt}) { assertSha(headSha); assertPositiveInteger(runId, 'run ID'); @@ -77,6 +97,17 @@ async function fetchOrNull(url) { return response; } +async function existsAt(url) { + const response = await fetch(url, {method: 'HEAD', cache: 'no-store'}); + if (response.status === 404) { + return false; + } + if (!response.ok) { + throw new Error(`HEAD ${url} failed with ${response.status}`); + } + return true; +} + async function fetchJsonOrNull(url) { const response = await fetchOrNull(url); return response == null ? null : response.json(); @@ -135,7 +166,10 @@ async function findBaseline({publicBaseUrl, environmentKey, candidateShas}) { module.exports = { BASELINE_SEARCH_DEPTH, approvalKey, + baselineImageKey, baselinePrefix, + blobKey, + existsAt, fetchBuffer, fetchJsonOrNull, findBaseline, diff --git a/.github/workflows/visual-compare-rntester.yml b/.github/workflows/visual-compare-rntester.yml index 6bf926fa4207..fc3b753df8eb 100644 --- a/.github/workflows/visual-compare-rntester.yml +++ b/.github/workflows/visual-compare-rntester.yml @@ -139,8 +139,10 @@ jobs: # Objects are write-once; --no-clobber makes a rerun skip what an # earlier attempt already uploaded. gcloud storage cp --recursive --no-clobber "$OUT_DIR/upload/*" "gs://$VISUAL_BUCKET/" - if [[ -d "$OUT_DIR/baseline-images" ]]; then - gcloud storage cp --recursive --no-clobber "$OUT_DIR/baseline-images/*" "gs://$VISUAL_BUCKET/" + if [[ -d "$OUT_DIR/baseline-blobs" ]]; then + gcloud storage cp --recursive --no-clobber "$OUT_DIR/baseline-blobs/*" "gs://$VISUAL_BUCKET/" + fi + if [[ -d "$OUT_DIR/baseline-manifests" ]]; then # A baseline exists once its manifest does, so the manifest goes last. gcloud storage cp --recursive --no-clobber "$OUT_DIR/baseline-manifests/*" "gs://$VISUAL_BUCKET/" fi