Skip to content
Merged
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
75 changes: 75 additions & 0 deletions .github/workflows/ios-nightly-percy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
inputs:
refresh_unstable:
description: Also replace the doc site screenshots of excludeFromPercy examples
type: boolean
default: false

jobs:
check:
Expand Down Expand Up @@ -89,3 +94,73 @@ jobs:
PERCY_COMMIT: ${{ github.sha }}
PERCY_VISUAL_CONFIG: '{"diffIgnoreEnabled":true,"diffIgnorePercentage":0.005}'
PERCY_CLIENT_ERROR_LOGS: false

doc-screenshots-branch:
name: Doc site screenshots branch
needs: [check, ios]
if: always() && needs.check.outputs.run == 'true'
runs-on: ubuntu-latest
env:
BRANCH: auto/example-screenshots
steps:
- uses: actions/download-artifact@v4
id: download
continue-on-error: true
with:
name: doc-screenshots
path: captured

- uses: actions/checkout@v4
if: steps.download.outcome == 'success'
with:
path: maps

- uses: actions/checkout@v4
if: steps.download.outcome == 'success'
with:
repository: rnmapbox/maps-docs
ssh-key: ${{ secrets.MAPS_DOCS_DEPLOY_KEY }}
path: maps-docs

- uses: actions/setup-node@v4
if: steps.download.outcome == 'success'
with:
node-version: ${{ needs.check.outputs.NVMRC }}

- name: Update changed screenshots
if: steps.download.outcome == 'success'
run: |
npm install --no-save --prefix "$RUNNER_TEMP/deps" pngjs@7.0.0
NODE_PATH="$RUNNER_TEMP/deps/node_modules" node maps/scripts/ci/update-doc-screenshots.js \
captured maps-docs/examples-screenshots maps/docs/examples.json "$RUNNER_TEMP/summary.md"
env:
REFRESH_UNSTABLE: ${{ inputs.refresh_unstable }}

# Rebuilt on top of maps-docs main every time, so it never conflicts with the docs pushed
# to main by update-docs.yml. maps-docs opens the PR from it.
- name: Push the screenshots branch
if: steps.download.outcome == 'success'
working-directory: maps-docs
run: |
if [ ! -s "$RUNNER_TEMP/summary.md" ]; then
echo "No meaningful screenshot changes"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -q -b "$BRANCH"
git add examples-screenshots
{
echo "Update example screenshots from rnmapbox/maps@${GITHUB_SHA::7}"
echo
echo "Captured by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
echo
cat "$RUNNER_TEMP/summary.md"
} > "$RUNNER_TEMP/message.txt"
git commit -q -F "$RUNNER_TEMP/message.txt"
cat "$RUNNER_TEMP/message.txt" >> "$GITHUB_STEP_SUMMARY"
if git fetch -q origin "$BRANCH" && git diff --quiet FETCH_HEAD HEAD; then
echo "$BRANCH is already up to date"
exit 0
fi
git push --force origin "HEAD:$BRANCH"
142 changes: 142 additions & 0 deletions scripts/ci/update-doc-screenshots.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* @file node update-doc-screenshots.js <captured-dir> <docs-screenshots-dir> <examples.json> <summary.md>
* Copies the captured example screenshots that differ meaningfully into the doc site's
* examples-screenshots folder, drops screenshots of removed examples, keeps screenshots.json
* in sync and writes a markdown summary of what changed (empty if nothing did).
*
* excludeFromPercy examples differ by 2-80% between runs (animations, timers), so they are only
* replaced when new, when the screenshot size changed or with REFRESH_UNSTABLE=true.
*/

const fs = require('fs');
const path = require('path');
const { PNG } = require('pngjs');

const [capturedDir, docsDir, examplesJSONPath, summaryPath] =
process.argv.slice(2);

// The status bar can't be fully frozen (the location arrow comes and goes)
const IGNORE_TOP_FRACTION = 0.075;
const CHANNEL_TOLERANCE = 16;
const THRESHOLD = 0.005;
const refreshUnstable = process.env.REFRESH_UNSTABLE === 'true';

function diffFraction(aPath, bPath) {
const a = PNG.sync.read(fs.readFileSync(aPath));
const b = PNG.sync.read(fs.readFileSync(bPath));
if (a.width !== b.width || a.height !== b.height) {
return null;
}
const top = Math.round(a.height * IGNORE_TOP_FRACTION);
let differing = 0;
for (let i = top * a.width * 4; i < a.data.length; i += 4) {
for (let c = 0; c < 4; c++) {
if (Math.abs(a.data[i + c] - b.data[i + c]) > CHANNEL_TOLERANCE) {
differing++;
break;
}
}
}
return differing / (a.width * (a.height - top));
}

const examples = JSON.parse(fs.readFileSync(examplesJSONPath, 'utf8'));
const screenshotsJSONPath = path.join(docsDir, 'screenshots.json');
const screenshots = JSON.parse(fs.readFileSync(screenshotsJSONPath, 'utf8'));

const changed = [];
const added = [];
const removed = [];
const missing = [];
const expected = new Set();

for (const { groupName, examples: groupExamples } of examples) {
for (const { name, metadata } of groupExamples) {
if (!metadata) {
continue;
}
const key = `${groupName}/${name}`;
expected.add(key);
const captured = path.join(capturedDir, groupName, `${name}.png`);
if (!fs.existsSync(captured)) {
missing.push(key);
continue;
}
const image = `examples-screenshots/${groupName}/${name}.png`;
const dest = path.join(docsDir, groupName, `${name}.png`);
const entry = screenshots[groupName]?.[name];
if (entry && fs.existsSync(dest)) {
const fraction = diffFraction(captured, dest);
const unstable = metadata.excludeFromPercy && !refreshUnstable;
if (fraction === null) {
changed.push(`${key} (size changed)`);
} else {
const percent = `${(fraction * 100).toFixed(1)}%`;
console.log(`${key}: ${percent}${unstable ? ' (unstable, kept)' : ''}`);
if (unstable || fraction <= THRESHOLD) {
continue;
}
changed.push(`${key} (${percent} of the pixels)`);
}
} else {
added.push(key);
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(captured, dest);
screenshots[groupName] = screenshots[groupName] || {};
screenshots[groupName][name] = { images: [image] };
}
}

for (const [groupName, group] of Object.entries(screenshots)) {
for (const [name, { images }] of Object.entries(group)) {
if (expected.has(`${groupName}/${name}`)) {
continue;
}
removed.push(`${groupName}/${name}`);
for (const image of images) {
fs.rmSync(path.join(docsDir, '..', image), { force: true });
}
delete group[name];
}
if (Object.keys(group).length === 0) {
delete screenshots[groupName];
}
}

const referenced = new Set(
Object.values(screenshots).flatMap((group) =>
Object.values(group).flatMap(({ images }) => images),
),
);
for (const file of fs.readdirSync(docsDir, { recursive: true })) {
const image = path.posix.join('examples-screenshots', file);
if (file.endsWith('.png') && !referenced.has(image)) {
removed.push(`${file.slice(0, -'.png'.length)} (unreferenced file)`);
fs.rmSync(path.join(docsDir, file));
}
}

if (missing.length > 0) {
console.log(`Not captured, kept as is: ${missing.join(', ')}`);
}

const sections = [
['Changed', changed],
['New', added],
['Removed', removed],
].filter(([, keys]) => keys.length > 0);

if (sections.length === 0) {
fs.writeFileSync(summaryPath, '');
console.log('No meaningful screenshot changes');
} else {
fs.writeFileSync(screenshotsJSONPath, JSON.stringify(screenshots));
const summary = sections
.map(
([title, keys]) => `${title}:\n${keys.map((k) => `- ${k}`).join('\n')}`,
)
.join('\n\n');
fs.writeFileSync(summaryPath, `${summary}\n`);
console.log(summary);
}
Loading