diff --git a/.github/workflows/ios-nightly-percy.yml b/.github/workflows/ios-nightly-percy.yml index a0c41b05f..e7296b24a 100644 --- a/.github/workflows/ios-nightly-percy.yml +++ b/.github/workflows/ios-nightly-percy.yml @@ -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: @@ -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" diff --git a/scripts/ci/update-doc-screenshots.js b/scripts/ci/update-doc-screenshots.js new file mode 100644 index 000000000..06766840f --- /dev/null +++ b/scripts/ci/update-doc-screenshots.js @@ -0,0 +1,142 @@ +/** + * @file node update-doc-screenshots.js + * 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); +}