Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 30 additions & 18 deletions .github/workflow-scripts/__tests__/visual-compare-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`]);
});
});

Expand Down
15 changes: 15 additions & 0 deletions .github/workflow-scripts/__tests__/visual-report-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ const {
} = require('../visual/report');
const {
approvalKey,
baselineImageKey,
baselinePrefix,
blobKey,
publicUrl,
reportPrefix,
} = require('../visual/storage');
Expand Down Expand Up @@ -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}),
Expand Down
59 changes: 49 additions & 10 deletions .github/workflow-scripts/visual/compare-run.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ const {
const {
BASELINE_SEARCH_DEPTH,
approvalKey,
baselineImageKey,
baselinePrefix,
blobKey,
existsAt,
fetchBuffer,
fetchJsonOrNull,
findBaseline,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Expand All @@ -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`,
);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 35 additions & 1 deletion .github/workflow-scripts/visual/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sha256>.png
// baselines/<environment-key>/<main-sha>/manifest.json
// baselines/<environment-key>/<main-sha>/<test-id>.png
// reports/pull/<pr>/<head-sha>/<run-id>-<attempt>/results.json
// reports/pull/<pr>/<head-sha>/<run-id>-<attempt>/<platform>/{expected,captured,diff}/<test-id>.png
// reports/main/<sha>/<run-id>-<attempt>/...
// approvals/pull/<pr>/<fingerprint>.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)) {
Expand All @@ -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');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -135,7 +166,10 @@ async function findBaseline({publicBaseUrl, environmentKey, candidateShas}) {
module.exports = {
BASELINE_SEARCH_DEPTH,
approvalKey,
baselineImageKey,
baselinePrefix,
blobKey,
existsAt,
fetchBuffer,
fetchJsonOrNull,
findBaseline,
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/visual-compare-rntester.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading