diff --git a/.github/workflows/icon-parity.yml b/.github/workflows/icon-parity.yml new file mode 100644 index 000000000..44a49e92e --- /dev/null +++ b/.github/workflows/icon-parity.yml @@ -0,0 +1,159 @@ +name: '[CI] Icon Parity' + +# Renders every icon component in Chromium and compares it to Figma's own PNG. +# Gate values (64px canvas, pixelmatch threshold 0.3 / includeAA, fail above 2 diff px) +# are measured, not guessed β€” each value carries its measurement in the comments of +# scripts/icon-extractor/src/parity/lib.ts. +# +# Two entry points share this one job so the gate and the report live in one place: +# - pull_request: any change that can alter how an icon rasterizes +# - workflow_call: the weekly Figma sync, on the PR it just opened + +on: + workflow_dispatch: + workflow_call: + inputs: + pr-number: + description: 'PR to comment the report on. Empty means report to the log only.' + type: string + required: false + changed-icons: + description: 'Comma-separated icons to draw on the report even when they pass.' + type: string + required: false + pull_request: + paths: + - packages/icons/src/** + - scripts/icon-extractor/src/parity/** + - scripts/icon-extractor/src/transformer/** + - scripts/icon-extractor/src/config.ts + - scripts/icon-extractor/icon-extractor.config.json + - .github/workflows/icon-parity.yml + +# This block replaces the default permissions rather than adding to them, so `contents` and +# `pull-requests` have to stay listed alongside the OIDC `id-token` the S3 upload needs. +permissions: + contents: read + pull-requests: write + id-token: write + +concurrency: + group: icon-parity-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + parity: + name: Icon Parity + runs-on: ubuntu-latest + # Forks get no FIGMA_TOKEN, so there is no baseline to compare against. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + env: + CACHE: scripts/icon-extractor/src/parity/.cache + steps: + - name: Checkout branch + uses: actions/checkout@v4 + + - name: Install + uses: ./.github/composite/install + + - name: Build icons + run: pnpm --filter @vapor-ui/icons build + + - name: Install Chromium + run: pnpm --filter @repo/icon-extractor exec playwright install --with-deps chromium + + # Colour icons are fetched and reported but not gated: Figma and Chromium antialias + # them differently enough (worst 164 px) to drown any real signal. + - name: Compare against Figma + id: compare + continue-on-error: true + env: + FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }} + # Passed through env, never interpolated into the script: `${{ }}` is a text + # substitution the runner performs before bash parses the file, so a value + # holding `$(...)` would run as code even inside double quotes. + CHANGED_ICONS: ${{ inputs.changed-icons }} + run: | + pnpm --filter @repo/icon-extractor parity:fetch --type=basic + pnpm --filter @repo/icon-extractor parity:fetch --type=symbol + pnpm --filter @repo/icon-extractor parity:render + # --show adds rows to the page; it does not narrow the gate. Empty on the + # pull_request path, where the diff itself already says what changed. + pnpm --filter @repo/icon-extractor parity:compare \ + --show="$CHANGED_ICONS" + + # compare.ts already computed these, so read them back instead of re-deriving them + # from the rendered report. + - name: Read counts + id: counts + if: always() + run: | + if [[ ! -f "$CACHE/report.json" ]]; then + # rendered must be set here too: an unset output is '', and '' != '0' + # would send the upload after a page that was never written. + printf 'ran=false\nrendered=0\n' >> "$GITHUB_OUTPUT" + echo 'No parity report β€” the comparison never ran.' >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + { + echo 'ran=true' + jq -r '"total=\(.total)", + "failed=\(.failed)", + "mono=\(.mono.count)", + "mono_worst=\(.mono.worst)", + "rendered=\(.rendered)"' "$CACHE/report.json" + } >> "$GITHUB_OUTPUT" + + # A green run draws no rows, so there is no page worth an assume-role and an upload. + - name: Configure AWS credentials + if: steps.counts.outputs.rendered != '0' + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_IAM_ROLE_NAME }} + role-session-name: ${{ secrets.AWS_IAM_ROLE_SESSION_NAME }} + aws-region: ${{ secrets.AWS_REGION }} + + # report.html inlines the Figma / code / diff PNG of every failing icon, so one object + # is the whole report. `index.html` is the bucket website's default document. + - name: Upload report to S3 + id: s3 + if: steps.counts.outputs.rendered != '0' + env: + # A branch name may contain `$`, `(` and backticks, so interpolating it into + # the script would hand this step β€” which holds the AWS session β€” arbitrary code. + BRANCH_REF: ${{ github.head_ref || github.ref_name }} + run: | + BRANCH=$(echo "$BRANCH_REF" | tr '/' '-') + # Keyed on the run, not the branch alone: a branch-only key means a second run + # today overwrites the page that an earlier PR comment still links to, so that + # comment quietly starts showing a different run's result. `run_id` is unique + # per run and already monotonic, so it sorts chronologically for free. + KEY="icon-parity/$BRANCH/${{ github.run_id }}/index.html" + aws s3 cp "$CACHE/report.html" "s3://${{ secrets.BUCKET_NAME }}/$KEY" + URL="http://${{ secrets.BUCKET_NAME }}/$KEY" + echo "url=$URL" >> "$GITHUB_OUTPUT" + # A dispatch run has no PR to comment on, so the link has to land somewhere. + echo "[Icon parity report]($URL)" >> "$GITHUB_STEP_SUMMARY" + + - name: Comment on PR + if: steps.counts.outputs.ran == 'true' && (inputs.pr-number || github.event.pull_request.number) + uses: marocchino/sticky-pull-request-comment@v2 + with: + number: ${{ inputs.pr-number || github.event.pull_request.number }} + header: icon-parity + message: | + ${{ steps.compare.outcome == 'failure' && '🚫 **μ•„μ΄μ½˜ μ‹œκ° 검증 μ‹€νŒ¨**' || 'βœ… **μ•„μ΄μ½˜ μ‹œκ° 검증 톡과**' }} + + | 전체 | 게이트 λŒ€μƒ (mono) | μ‹€νŒ¨ | mono μ΅œλŒ€ diff | 리포트 | + | :--- | :--- | :--- | :--- | :--- | + | ${{ steps.counts.outputs.total }} | ${{ steps.counts.outputs.mono }} | ${{ steps.counts.outputs.failed }} | ${{ steps.counts.outputs.mono_worst }} px | ${{ steps.counts.outputs.rendered != '0' && format('[μ—΄κΈ° β†—οΈŽ]({0})', steps.s3.outputs.url) || 'β€”' }} | + + [μ›Œν¬ν”Œλ‘œ μ‹€ν–‰ 보기](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + # Same author as the visual-regression comment β€” both land on the same PR, and + # the repo already reserves the default token for machine work rather than + # anything a reviewer reads. + GITHUB_TOKEN: ${{ secrets.VAPOR_BOT_TOKEN }} + + - name: Fail if parity failed + if: steps.compare.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/sync-figma-icons.yml b/.github/workflows/sync-figma-icons.yml index a281ae4ce..9a813c514 100644 --- a/.github/workflows/sync-figma-icons.yml +++ b/.github/workflows/sync-figma-icons.yml @@ -6,6 +6,15 @@ on: # Run every Thursday at 4 PM KST (07:00 UTC) - cron: '0 7 * * 4' +# The called parity workflow uploads to S3 over OIDC, and a called workflow cannot be granted +# more than its caller holds β€” `id-token` is never in the default token, so it has to be asked +# for here. Opening this block replaces the defaults, so the write scopes this job's own +# commit / PR steps rely on have to be listed too. +permissions: + contents: write + pull-requests: write + id-token: write + concurrency: # Fixed group, not keyed on github.ref: every run targets the same BRANCH_NAME # regardless of the ref it was dispatched from, so runs must serialize. @@ -23,6 +32,10 @@ jobs: # The unspecified default is `bash -e`, which has no `pipefail`: a failing command # on the left of a pipe would be reported as success. shell: bash + outputs: + has_changes: ${{ steps.pr.outputs.has_changes }} + pr_number: ${{ steps.pr.outputs.number }} + changed_icons: ${{ steps.changed.outputs.icons }} steps: - name: Checkout branch uses: actions/checkout@v4 @@ -45,12 +58,25 @@ jobs: FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }} run: pnpm --filter @repo/icon-extractor sync-icons:symbol + # New and updated icons get drawn on the parity report so the sync PR shows them next + # to Figma. Deleted ones are left out β€” there is no component left to render. + - name: List changed icons + id: changed + run: | + ICONS=$(jq -rs '[.[] | .created[], .updated[]] | join(",")' \ + .sync-summary/basic.json .sync-summary/symbol.json) + echo "icons=$ICONS" >> "$GITHUB_OUTPUT" + # Reads what both syncs recorded and writes `.changeset/sync-icons-*.md` plus # `pr_body.md`. Untracked until the next step decides there is something to commit. - name: Write changeset and PR body run: pnpm --filter @repo/icon-extractor write-release-notes - name: Commit and open pull request + # The `pr_number` job output reads this id. A missing step id is not an error in + # Actions β€” the expression just evaluates to empty β€” so losing it silently stops + # the parity report from ever reaching the sync PR. + id: pr env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -64,6 +90,9 @@ jobs: exit 0 fi + # The parity job gates on this. + echo "has_changes=true" >> "$GITHUB_OUTPUT" + git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git switch -C "$BRANCH_NAME" @@ -85,18 +114,52 @@ jobs: EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --base main --state open --json number --jq '.[0].number // empty') if [[ -n "$EXISTING_PR" ]]; then gh pr edit "$EXISTING_PR" --body-file pr_body.md + PR_NUMBER="$EXISTING_PR" else - gh pr create \ + # `gh pr create` prints the new PR's URL, whose last segment is its number. + PR_URL=$(gh pr create \ --title "feat: sync icons from Figma" \ --body-file pr_body.md \ --base main \ - --head "$BRANCH_NAME" + --head "$BRANCH_NAME") + PR_NUMBER="${PR_URL##*/}" fi + # The parity job needs this to know where to comment. + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + + # The gate, the report and the PR comment all live in icon-parity.yml so this entry point + # and the pull_request one can never drift apart. + # + # This call cannot be dropped in favour of that pull_request trigger: the sync PR is opened + # and pushed with GITHUB_TOKEN, and GitHub raises no workflow events for those, so the sync + # PR would otherwise never be checked. For the same reason there is no double run here. + # + # Manual dispatch always runs it, so the check can be exercised without a Figma diff. + parity: + name: Parity + needs: sync-figma-icons + if: needs.sync-figma-icons.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/icon-parity.yml + secrets: inherit + with: + pr-number: ${{ needs.sync-figma-icons.outputs.pr_number }} + changed-icons: ${{ needs.sync-figma-icons.outputs.changed_icons }} + + notify: + name: Notify Slack + needs: [sync-figma-icons, parity] + # A skipped parity job is not a failure, so this only fires on a real one. + if: failure() + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@v4 + + - name: Install + uses: ./.github/composite/install - name: Notify Slack on Failure - if: failure() - run: | - WORKFLOW_STATUS=failure pnpm --filter @repo/icon-extractor notify:slack + run: WORKFLOW_STATUS=failure pnpm --filter @repo/icon-extractor notify:slack env: SLACK_GDS_ALARM_WEBHOOK_URL: ${{ secrets.SLACK_GDS_ALARM_WEBHOOK_URL }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/.gitignore b/.gitignore index 51edbb854..91d140353 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,9 @@ docs/plans/ # Worktrees .worktrees +# Icon parity check cache (baselines, renders, diffs) +scripts/icon-extractor/src/parity/.cache/ + # Icon sync intermediates (see scripts/icon-extractor) .sync-summary/ pr_body.md diff --git a/.vscode/settings.json b/.vscode/settings.json index de387b770..79d50b9f7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,9 @@ "source.fixAll.eslint": "explicit" }, "js/ts.tsdk.path": "node_modules/typescript/lib", + "files.watcherExclude": { + "**/scripts/icon-extractor/src/parity/.cache/**": true + }, "json.schemas": [ { "url": "https://cdn.jsdelivr.net/npm/tsup/schema.json", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7c44d668..29b793edb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -973,9 +973,33 @@ importers: '@types/node': specifier: ^22.20.1 version: 22.20.1 + '@types/pngjs': + specifier: ^6.0.5 + version: 6.0.5 + '@types/react': + specifier: 'catalog:' + version: 19.2.18 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.5(@types/react@19.2.18) eslint: specifier: 'catalog:' version: 9.39.5(jiti@2.7.0) + pixelmatch: + specifier: ^7.2.0 + version: 7.2.0 + playwright: + specifier: ^1.62.1 + version: 1.62.1 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 + react: + specifier: 'catalog:' + version: 19.2.8 + react-dom: + specifier: 'catalog:' + version: 19.2.8(react@19.2.8) tsx: specifier: ^4.23.13 version: 4.23.13 @@ -4691,6 +4715,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} @@ -8310,6 +8337,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pixelmatch@7.2.0: + resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} + hasBin: true + pkg-dir@3.0.0: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} @@ -8331,6 +8362,10 @@ packages: engines: {node: '>=20'} hasBin: true + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + portfinder@1.0.38: resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} engines: {node: '>= 10.12'} @@ -14153,6 +14188,10 @@ snapshots: undici-types: 7.18.2 optional: true + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 22.20.1 + '@types/prismjs@1.26.5': {} '@types/react-dom@19.2.5(@types/react@19.2.18)': @@ -18782,6 +18821,10 @@ snapshots: pirates@4.0.7: {} + pixelmatch@7.2.0: + dependencies: + pngjs: 7.0.0 + pkg-dir@3.0.0: dependencies: find-up: 3.0.0 @@ -18804,6 +18847,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@7.0.0: {} + portfinder@1.0.38: dependencies: async: 3.2.6 diff --git a/scripts/icon-extractor/package.json b/scripts/icon-extractor/package.json index b66eda4fc..d5df302c9 100644 --- a/scripts/icon-extractor/package.json +++ b/scripts/icon-extractor/package.json @@ -6,10 +6,13 @@ "type": "module", "scripts": { "extract": "tsx --env-file-if-exists=.env ./src/cli.ts", - "format": "prettier --write \"./src/**/*.{ts,md}\"", - "format:check": "prettier --check \"./src/**/*.{ts,md}\"", - "lint": "eslint ./src", + "format": "prettier --write \"./{src,tests}/**/*.{ts,md}\"", + "format:check": "prettier --check \"./{src,tests}/**/*.{ts,md}\"", + "lint": "eslint ./src ./tests", "notify:slack": "tsx ./src/slack/notify.ts", + "parity:compare": "tsx ./src/parity/compare.ts", + "parity:fetch": "tsx --env-file-if-exists=.env ./src/parity/fetch-baseline.ts", + "parity:render": "tsx ./src/parity/render.ts", "sync-icons:basic": "pnpm extract --type=basic", "sync-icons:symbol": "pnpm extract --type=symbol", "test": "vitest --run", @@ -31,7 +34,15 @@ "@repo/typescript-config": "workspace:*", "@types/lodash-es": "^4.17.12", "@types/node": "^22.20.1", + "@types/pngjs": "^6.0.5", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", "eslint": "catalog:", + "pixelmatch": "^7.2.0", + "playwright": "^1.62.1", + "pngjs": "^7.0.0", + "react": "catalog:", + "react-dom": "catalog:", "tsx": "^4.23.13", "typescript": "catalog:", "vitest": "catalog:" diff --git a/scripts/icon-extractor/src/parity/.gitignore b/scripts/icon-extractor/src/parity/.gitignore new file mode 100644 index 000000000..ceddaa37f --- /dev/null +++ b/scripts/icon-extractor/src/parity/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/scripts/icon-extractor/src/parity/compare.ts b/scripts/icon-extractor/src/parity/compare.ts new file mode 100644 index 000000000..c546495a6 --- /dev/null +++ b/scripts/icon-extractor/src/parity/compare.ts @@ -0,0 +1,206 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import pc from 'picocolors'; +import pixelmatch from 'pixelmatch'; +import { PNG } from 'pngjs'; + +import type { Manifest } from './lib'; +import { + CACHE_DIR, + DIFF_GATE, + MANIFEST, + PIXELMATCH_OPTIONS, + flags, + nameSet, + onlyFilter, +} from './lib'; + +type Row = { + name: string; + diffPixels: number; + isColorIcon: boolean; + width: number; + height: number; + failed: boolean; +}; + +const args = flags(); +const threshold = Number(args.threshold ?? DIFF_GATE); +// A non-numeric --threshold parses to NaN, and `diffPixels > NaN` is always false β€” the gate +// would pass everything without a word, which is the one failure lib.ts warns about. +if (!Number.isFinite(threshold) || threshold < 0) { + throw new Error(`--threshold must be a non-negative number (got ${args.threshold})`); +} +// Colour icons are rasterizer noise, not signal β€” gate them only to exercise the failure path. +const gateColor = args['gate-color'] === 'true'; +const keep = onlyFilter(args.only); +// `--show=A,B` adds rows to the page without narrowing what gets compared. The sync workflow +// passes the icons it just changed, so a reviewer sees them next to Figma even on a green run. +// `--only` cannot do this job: it drops every other icon out of the gate as well. +const shown = args.show ? nameSet(args.show) : new Set(); + +const baselineDir = path.join(CACHE_DIR, 'baseline'); +const codeDir = path.join(CACHE_DIR, 'render'); +const diffDir = path.join(CACHE_DIR, 'diff'); +await fs.mkdir(diffDir, { recursive: true }); + +const manifest: Manifest = JSON.parse(await fs.readFile(MANIFEST, 'utf8').catch(() => '{}')); + +// The expected name list comes from the *baseline* (what Figma actually has), never from the +// render output: enumerating our renders let a missing one vanish silently as +// `813 compared, skipped: []`. +const names = (await fs.readdir(baselineDir)) + .filter((f) => f.endsWith('.png')) + .map((f) => f.slice(0, -4)) + .filter(keep) + .sort(); +if (!names.length) { + throw new Error( + `no baseline in ${baselineDir}${args.only ? ` matching --only=${args.only}` : ''} β€” run parity:fetch first`, + ); +} + +const rows: Row[] = []; +const skipped: string[] = []; +const missing: string[] = []; +const unclassified: string[] = []; + +for (const name of names) { + const codeFile = path.join(codeDir, `${name}.png`); + if (!(await fs.stat(codeFile).catch(() => null))) { + missing.push(`${name} (no code render)`); + continue; + } + const entry = manifest[name]; + if (!entry) { + unclassified.push(name); + continue; + } + + const figma = PNG.sync.read(await fs.readFile(path.join(baselineDir, `${name}.png`))); + const code = PNG.sync.read(await fs.readFile(codeFile)); + if (figma.width !== code.width || figma.height !== code.height) { + skipped.push(`${name} (${figma.width}x${figma.height} vs ${code.width}x${code.height})`); + continue; + } + + const a = new Uint8Array(figma.data); + const b = new Uint8Array(code.data); + const diff = new PNG({ width: figma.width, height: figma.height }); + const diffPixels = pixelmatch(a, b, diff.data, figma.width, figma.height, { + ...PIXELMATCH_OPTIONS, + diffMask: true, + }); + + await fs.writeFile(path.join(diffDir, `${name}.png`), PNG.sync.write(diff)); + + rows.push({ + name, + diffPixels, + isColorIcon: entry.isColorIcon, + width: figma.width, + height: figma.height, + failed: (gateColor || !entry.isColorIcon) && diffPixels > threshold, + }); +} + +rows.sort((x, y) => y.diffPixels - x.diffPixels); +const failures = rows.filter((row) => row.failed); +const mono = rows.filter((row) => !row.isColorIcon); +const color = rows.filter((row) => row.isColorIcon); +const worst = (group: Row[]) => Math.max(0, ...group.map((row) => row.diffPixels)); +// What the HTML page will actually draw. An explicit --only shows every requested icon; otherwise +// failures first, then whatever --show asked for. The workflow uploads the page only when this is +// non-empty, so a green run never hands a reviewer a link to an empty table. +// +// Failures lead so the cap can never hide one: a sync that regenerates every icon marks all 814 as +// changed, and a page with 814 rows of inlined PNGs is several megabytes. +const MAX_DRAWN = 60; +const candidates = args.only + ? rows + : [...failures, ...rows.filter((row) => !row.failed && shown.has(row.name))]; +const drawn = candidates.slice(0, MAX_DRAWN); +const folded = candidates.length - drawn.length; + +// The headline counts live here, not only in the rendered page: the workflow reads them straight +// out of this file for the PR comment. Re-parsing a rendered report to recover numbers we already +// have is how the Playwright job ended up grepping its own markdown. +const report = { + threshold, + pixelmatch: PIXELMATCH_OPTIONS, + total: rows.length, + expected: names.length, + failed: failures.length, + rendered: drawn.length, + mono: { count: mono.length, worst: worst(mono) }, + colour: { count: color.length, worst: worst(color) }, + skipped, + missing, + unclassified, + rows, +}; +await fs.writeFile(path.join(CACHE_DIR, 'report.json'), JSON.stringify(report, null, 2)); + +// Self-contained HTML (images inlined) so the one file the workflow uploads to S3 opens on its +// own. The plain lists below cover the failure causes that have no image to show. +const dataUri = async (file: string) => + `data:image/png;base64,${(await fs.readFile(file)).toString('base64')}`; +const cell = async (dir: string, name: string) => + ``; +const htmlRows = await Promise.all( + drawn.map( + async (row) => + `${row.name}${row.failed ? ' FAIL' : ''}` + + `
${row.diffPixels} px` + + (await cell(baselineDir, row.name)) + + (await cell(codeDir, row.name)) + + (await cell(diffDir, row.name)) + + '', + ), +); +const htmlList = (label: string, list: string[]) => + list.length + ? `

${label} (${list.length})

` + : ''; +await fs.writeFile( + path.join(CACHE_DIR, 'report.html'), + `Icon parity report + +

Icon parity report

+

${failures.length} of ${mono.length} mono icons over ${threshold} diff pixels (worst ${worst(mono)}).
+${color.length} colour icons are reported but not gated β€” Figma and Chromium disagree on these (worst ${worst(color)}).

+${shown.size ? '

Rows without FAIL are icons this sync changed. They are here for review, not because anything is wrong with them.

' : ''} +${htmlRows.join('')}
FigmaCodeDiff
+${folded ? `

${folded} more row(s) not drawn β€” the page is capped at ${MAX_DRAWN}.

` : ''} +${htmlList('missing renders', missing)}${htmlList('skipped (size mismatch)', skipped)}${htmlList('not in manifest', unclassified)}`, +); + +console.log( + `${rows.length}/${names.length} compared β€” mono ${mono.length} (worst ${worst(mono)}), ` + + `colour ${color.length} (worst ${worst(color)}, ungated), ` + + `${pc.red(String(failures.length))} over ${threshold}` + + (skipped.length ? `, ${skipped.length} skipped` : '') + + (missing.length ? `, ${pc.red(`${missing.length} missing`)}` : ''), +); +console.log(`report: ${path.join(CACHE_DIR, 'report.html')}`); +console.log(`diffs: ${diffDir} (${rows.length} PNGs)`); +if (failures.length) { + console.error( + pc.red( + `mono icons over ${threshold} diff pixels:\n ` + + failures.map((row) => `${row.name} (${row.diffPixels})`).join('\n '), + ), + ); +} +if (missing.length) console.error(pc.red(`missing renders:\n ${missing.join('\n ')}`)); +if (unclassified.length) { + console.error(pc.red(`not in manifest (re-run parity:fetch):\n ${unclassified.join('\n ')}`)); +} +if (failures.length || missing.length || skipped.length || unclassified.length) { + process.exitCode = 1; +} diff --git a/scripts/icon-extractor/src/parity/fetch-baseline.ts b/scripts/icon-extractor/src/parity/fetch-baseline.ts new file mode 100644 index 000000000..da61028d0 --- /dev/null +++ b/scripts/icon-extractor/src/parity/fetch-baseline.ts @@ -0,0 +1,132 @@ +/** + * Step 1 β€” download Figma's own raster for every icon. + * + * Usage: + * tsx src/parity/fetch-baseline.ts [--type=basic|symbol] [--only=Name,Name] [--limit=N] [--scale=N] + * + * The baseline is a PNG on purpose: this check asks whether our components render the way FIGMA + * renders them, so the reference has to come out of Figma's rasterizer. Comparing against Figma's + * SVG export instead would only re-test svgo, which tests/svgr-transformer.test.ts covers + * offline and for free. + * + * Already-downloaded icons are skipped, so changing `--scale` means clearing `.cache/baseline` + * first β€” otherwise the previous scale's files are reused and the run silently mixes sizes. + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import pLimit from 'p-limit'; +import pc from 'picocolors'; + +import { getImages } from '~/api/figma-client'; +import { ICON_TYPE_NAMES, colorFrameIds, figma, iconTypes, isIconType } from '~/config'; +import { fetchIconNodes } from '~/downloader/svg-downloader'; +import { normalizeIconName } from '~/utils/icon-name'; + +import type { Manifest } from './lib'; +import { CACHE_DIR, MANIFEST, flags, onlyFilter } from './lib'; + +// Figma's images endpoint has no documented node-count cap, but percent-encoding the `:` in node +// ids pushed a 814-node query past 8KB and returned HTTP 414 (measured). Batch to stay under it. +const BATCH_SIZE = 200; + +const args = flags(); +const type = args.type ?? 'basic'; +const limit = args.limit ? Number(args.limit) : Infinity; +const keep = onlyFilter(args.only); +// 4 is Figma's documented maximum, so 64px is all the resolution this check can get. +const scale = args.scale ? Number(args.scale) : 4; + +if (!process.env.FIGMA_TOKEN) { + console.error(pc.red('FIGMA_TOKEN is not set.')); + process.exit(1); +} +if (!isIconType(type)) { + console.error(pc.red(`--type must be one of ${ICON_TYPE_NAMES.join(', ')}`)); + process.exit(1); +} +// Figma's documented range. Out of range comes back as HTTP 400 with no per-node detail. +if (!Number.isFinite(scale) || scale < 0.01 || scale > 4) { + console.error(pc.red(`--scale must be between 0.01 and 4 (got ${args.scale})`)); + process.exit(1); +} + +const outDir = path.join(CACHE_DIR, 'baseline'); +await fs.mkdir(outDir, { recursive: true }); + +const iconType = iconTypes[type]; +// Colour-ness is the parent frame, not the icon name β€” same rule as the sync itself. +const colorFrames = colorFrameIds(iconType); +const components = await fetchIconNodes({ + fileKey: figma.fileKey, + frameIds: iconType.frames.map((frame) => frame.id), +}); +components.sort((a, b) => normalizeIconName(a.name).localeCompare(normalizeIconName(b.name))); + +const wanted = components + .filter((node) => keep(normalizeIconName(node.name))) + .slice(0, limit) + .map((node) => ({ + id: node.id, + name: normalizeIconName(node.name), + isColorIcon: colorFrames.has(node.parentId), + })); +if (!wanted.length) { + console.error(pc.red(`no ${type} icon matched --only=${args.only}`)); + process.exit(1); +} + +// compare.ts only gates mono icons, so it needs Figma's own answer to "is this a colour icon". +// Rewritten every run, merged across --type runs so a basic run does not drop the symbol flags. +const manifest: Manifest = JSON.parse(await fs.readFile(MANIFEST, 'utf8').catch(() => '{}')); +for (const icon of wanted) manifest[icon.name] = { id: icon.id, isColorIcon: icon.isColorIcon }; +await fs.writeFile(MANIFEST, JSON.stringify(manifest, null, 2)); + +// Already-downloaded icons are skipped so re-runs are cheap. +const missing: typeof wanted = []; +for (const icon of wanted) { + const file = path.join(outDir, `${icon.name}.png`); + if (!(await fs.stat(file).catch(() => null))) missing.push(icon); +} +console.log(`${wanted.length} icons, ${missing.length} to download (scale ${scale})`); + +const urls = new Map(); +const renderFailed: string[] = []; +for (let i = 0; i < missing.length; i += BATCH_SIZE) { + const batch = missing.slice(i, i + BATCH_SIZE); + const { images } = await getImages({ + fileKey: figma.fileKey, + nodeIds: batch.map((icon) => icon.id), + format: 'png', + // Figma renders PNGs at the node bbox; scale=4 on a 16px icon gives 64px. + scale, + }); + for (const icon of batch) { + // HTTP 200 does not mean every node rendered β€” nulls are per-node failures. + const url = images[icon.id]; + if (!url) renderFailed.push(icon.name); + else urls.set(icon.name, url); + } +} +// A warning would let the run go green with a smaller baseline, and compare.ts enumerates the +// baseline to decide what to check β€” so a failed render would quietly drop that icon's gate. +if (renderFailed.length) { + console.error(pc.red(`figma rendered no image for: ${renderFailed.join(', ')}`)); + process.exit(1); +} + +const concurrency = pLimit(10); +await Promise.all( + [...urls].map(([name, url]) => + concurrency(async () => { + const response = await fetch(url); + if (!response.ok) throw new Error(`${name}: ${response.status} ${response.statusText}`); + await fs.writeFile( + path.join(outDir, `${name}.png`), + Buffer.from(await response.arrayBuffer()), + ); + }), + ), +); + +console.log(pc.green(`baseline ready: ${outDir}`)); diff --git a/scripts/icon-extractor/src/parity/lib.ts b/scripts/icon-extractor/src/parity/lib.ts new file mode 100644 index 000000000..ea8999824 --- /dev/null +++ b/scripts/icon-extractor/src/parity/lib.ts @@ -0,0 +1,87 @@ +/** + * Shared settings for the icon parity check: fetch a Figma PNG per icon, render our component + * to a PNG the same size in Chromium, and count differing pixels with pixelmatch. + * + * The four values below (canvas size, includeAA, threshold, gate) were measured across all 594 + * mono icons, not guessed. Each carries the measurement that fixed it β€” read those before + * changing one, because the failure they guard against is silent: a gate that is too loose + * passes a shifted icon without a word. + */ +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const PARITY_DIR = path.dirname(fileURLToPath(import.meta.url)); +/** Override to hold a cache fetched at another `--scale` without clobbering the main one. */ +const CACHE_DIR = process.env.PARITY_CACHE_DIR ?? path.join(PARITY_DIR, '.cache'); + +/** Written by fetch-baseline, read by compare β€” which icons are colour, straight from Figma. */ +const MANIFEST = path.join(CACHE_DIR, 'manifest.json'); + +type Manifest = Record; + +/** + * `includeAA: true` reads backwards: `false` is pixelmatch's default and turns its anti-aliasing + * detector ON. A thin line moved by 1px changes only edge pixels, which that detector always + * classifies as anti-aliasing and drops β€” 253 of 594 mono icons could move a full pixel and + * still score ≀1. `true` skips the detector and counts every pixel over the threshold. + * + * `threshold: 0.3` is an alpha difference of 79 for these icons (mono icons are pure black, so + * pixelmatch's YIQ distance collapses to the alpha delta). 99.8% of rasterizer-noise pixels fall + * below it and 62% of the pixels a 1px shift moves fall above it. Lower and noise leaks in; + * higher (0.4) and the smallest real shifts score 2, colliding with noise. + */ +const PIXELMATCH_OPTIONS = { threshold: 0.3, includeAA: true } as const; + +/** + * Fail above this many differing pixels. Measured 2026-09-07 over all 594 mono icons: a correct + * icon scores at most 2 (DividerOutlineIcon), and the smallest defect worth catching β€” the source + * shifted 0.25 units, i.e. 1px on the 64px canvas β€” scores at least 4 (MinusOutlineIcon). The + * gate sits at the noise ceiling rather than halfway between: a silent miss costs more than a + * false alarm you can see in the PR. + * + * 64Γ—64 is the only canvas where those two ranges separate. At 16px a 0.25-unit shift flips no + * pixel at all, and at 32px the best setting leaves signal min 1 against noise max 1. It is also + * Figma's maximum `scale`, and both sides rasterize the vector directly β€” no resampling. + * + * Re-measure if Chromium, Playwright, the icon bundle or Figma's renderer changes. + */ +const DIFF_GATE = 2; + +/** `--flag=value` / `--flag` parsing. No dependency needed. */ +function flags(): Record { + const out: Record = {}; + for (const arg of process.argv.slice(2)) { + const m = /^--([^=]+)(?:=(.*))?$/.exec(arg); + if (m) out[m[1]] = m[2] ?? 'true'; + } + return out; +} + +/** + * `--only=A,B` keeps just those icon names; without the flag everything passes. + * + * Empty entries are dropped so a caller can pass a list stitched from several sources β€” + * `format('{0},{1}', ...)` in a workflow yields `A,,B` when one source is empty. + */ +function nameSet(list: string): Set { + return new Set(list.split(',').filter(Boolean)); +} + +function onlyFilter(only: string | undefined): (name: string) => boolean { + if (!only) return () => true; + const wanted = nameSet(only); + return (name) => wanted.has(name); +} + +export type { Manifest }; +export { + CACHE_DIR, + DIFF_GATE, + MANIFEST, + PARITY_DIR, + PIXELMATCH_OPTIONS, + flags, + nameSet, + onlyFilter, +}; diff --git a/scripts/icon-extractor/src/parity/render.ts b/scripts/icon-extractor/src/parity/render.ts new file mode 100644 index 000000000..3cd231379 --- /dev/null +++ b/scripts/icon-extractor/src/parity/render.ts @@ -0,0 +1,86 @@ +import type { ComponentType } from 'react'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import pc from 'picocolors'; +import type { Browser, Page } from 'playwright'; +import { chromium } from 'playwright'; +import { PNG } from 'pngjs'; + +import { REPO_ROOT } from '~/utils/file-system'; + +import { CACHE_DIR, flags, onlyFilter } from './lib'; + +const ICON_BUNDLE = path.join(REPO_ROOT, 'packages/icons/dist/index.js'); + +const DEFAULT_COLOR = '#000'; + +type RenderProps = { + width: number; + height: number; + style: { color: string; transform?: string }; +}; + +async function rasterize(page: Page, svg: string, width: number, height: number): Promise { + await page.setViewportSize({ width, height }); + await page.setContent( + `${svg}`, + ); + return page.screenshot({ omitBackground: true }); +} + +async function main() { + const args = flags(); + const color = args.color ?? DEFAULT_COLOR; + const keep = onlyFilter(args.only); + + const baselineDir = path.join(CACHE_DIR, 'baseline'); + const codeDir = path.join(CACHE_DIR, 'render'); + await fs.mkdir(codeDir, { recursive: true }); + + const icons = (await import(ICON_BUNDLE)) as Record>; + const names = (await fs.readdir(baselineDir)) + .filter((file) => file.endsWith('.png')) + .map((file) => file.slice(0, -4)) + .filter(keep) + .sort(); + + const browser: Browser = await chromium.launch(); + const page = await browser.newPage(); + let rendered = 0; + const missing: string[] = []; + + for (const name of names) { + const Icon = icons[name]; + if (typeof Icon !== 'function') { + missing.push(name); + continue; + } + + const baseline = PNG.sync.read(await fs.readFile(path.join(baselineDir, `${name}.png`))); + const markup = renderToStaticMarkup( + createElement(Icon, { + width: baseline.width, + height: baseline.height, + style: { color }, + }), + ); + await fs.writeFile( + path.join(codeDir, `${name}.png`), + await rasterize(page, markup, baseline.width, baseline.height), + ); + rendered++; + } + + await browser.close(); + if (missing.length) { + console.warn( + pc.yellow(`no component exported for ${missing.length}: ${missing.join(', ')}`), + ); + } + console.log(pc.green(`rendered ${rendered} icons -> ${codeDir}`)); +} + +await main(); diff --git a/scripts/icon-extractor/tests/svgr-transformer.test.ts b/scripts/icon-extractor/tests/svgr-transformer.test.ts new file mode 100644 index 000000000..ce18a81d5 --- /dev/null +++ b/scripts/icon-extractor/tests/svgr-transformer.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { svgToIconComponent } from '~/transformer/svgr-transformer'; + +/** Shaped like Figma's export: root fill="none", black children, document-global mask id. */ +const MONO_SVG = ` + + + + + + +`; + +const COLOR_SVG = ` + + + +`; + +const convert = (svg: string, iconName: string, isColorIcon = false) => + svgToIconComponent({ svg, iconName, isColorIcon }); + +test('mono icons follow the consumer colour', async () => { + const out = await convert(MONO_SVG, 'SampleIcon'); + + assert.match(out, /fill="currentColor"/, 'black fill must become currentColor'); + assert.match(out, /stroke="currentColor"/, 'black stroke must become currentColor'); + assert.doesNotMatch(out, /"(black|#000|#000000)"/i, 'no literal black may survive'); +}); + +test('the root keeps fill="none" so stroked shapes stay hollow', async () => { + const out = await convert(MONO_SVG, 'SampleIcon'); + + // The attribute order is SVGR's, so match the root element rather than a fixed string. + const root = /]*)>/.exec(out); + assert.ok(root, 'output must wrap the SVG in IconBase'); + assert.match(root[1], /fill="none"/, 'dropping this fills every stroke-only icon solid'); +}); + +test('colour icons keep the Figma palette', async () => { + const out = await convert(COLOR_SVG, 'SampleColorIcon', true); + + assert.match(out, /fill="#D22730"/i); + // `black` is a real palette value here, not a placeholder for currentColor. + assert.doesNotMatch(out, /currentColor/); +}); + +test('ids are namespaced per icon so two icons on one page cannot collide', async () => { + const first = await convert(MONO_SVG, 'FirstIcon'); + const second = await convert(MONO_SVG, 'SecondIcon'); + + const idOf = (out: string) => /id="([^"]+)"/.exec(out)?.[1]; + assert.match(String(idOf(first)), /^vapor-icons-mono-FirstIcon/); + assert.match(String(idOf(second)), /^vapor-icons-mono-SecondIcon/); + assert.notEqual(idOf(first), idOf(second)); + // The reference has to move with the definition. + assert.match(first, new RegExp(`url\\(#${idOf(first)}\\)`)); + + const colour = await convert(COLOR_SVG, 'FirstIcon', true); + assert.doesNotMatch(colour, /vapor-icons-mono-/); +}); + +test('IconBase owns the size: viewBox stays, width/height go', async () => { + const out = await convert(MONO_SVG, 'SampleIcon'); + + assert.match(out, /viewBox="0 0 16 16"/, 'without viewBox the icon cannot scale'); + assert.doesNotMatch(out, /width="16"/); + assert.doesNotMatch(out, /height="16"/); +}); + +test('the component is an IconBase wrapper that forwards props', async () => { + const out = await convert(MONO_SVG, 'SampleIcon'); + + assert.match(out, /import IconBase from '~\/components\/icon-base';/); + assert.match(out, /const SampleIcon = \(props: IconProps\)/); + assert.match(out, /\{\.\.\.props\}/); + assert.match(out, /export default SampleIcon;/); + assert.doesNotMatch(out, / must be replaced, not nested'); +}); diff --git a/scripts/icon-extractor/tsconfig.json b/scripts/icon-extractor/tsconfig.json index 7e2d074fb..6e70e93dc 100644 --- a/scripts/icon-extractor/tsconfig.json +++ b/scripts/icon-extractor/tsconfig.json @@ -11,5 +11,5 @@ "lib": ["ES2023"], "types": ["node", "vitest/globals"] }, - "include": ["src", "icon-extractor.config.json"] + "include": ["src", "tests", "icon-extractor.config.json"] } diff --git a/scripts/icon-extractor/vitest.config.ts b/scripts/icon-extractor/vitest.config.ts index 29703523d..cb8042e48 100644 --- a/scripts/icon-extractor/vitest.config.ts +++ b/scripts/icon-extractor/vitest.config.ts @@ -4,12 +4,12 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ resolve: { alias: { - '~': path.resolve(__dirname, 'src'), + '~': path.resolve(__dirname, './src'), }, }, test: { environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], globals: true, }, });