Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a8e8f2c
Add per-PR visual diff via Playwright + Chromatic
RisingOrange Apr 20, 2026
7188517
Intercept external APIs via MSW-node for visual-diff runs
RisingOrange Apr 23, 2026
1e0b1a5
Include /posts in visual-diff coverage
RisingOrange Apr 24, 2026
85c27aa
Override mobile deviceScaleFactor to 1 to stay under Chromatic's cap
RisingOrange Apr 24, 2026
a387bab
Post a visual-diff scope comment on each PR
RisingOrange Apr 24, 2026
4f97009
Default-deny cross-origin iframes + XHR at browser boundary
RisingOrange Apr 24, 2026
bd3b964
Note limitation: same fork branch with multiple simultaneous PRs
RisingOrange Apr 24, 2026
7778906
Update visual-diff README for default-deny + scope comment
RisingOrange Apr 24, 2026
7dd119e
Note bypass-vs-catch-all policy split in msw-handlers
RisingOrange Apr 24, 2026
f74adce
Merge MSW setup and handlers into one msw-setup.ts
RisingOrange Apr 24, 2026
e3e04e7
Add file docstrings to routes.ts and smoke.spec.ts
RisingOrange Apr 24, 2026
823c5f8
Let shell env beat .env under VISUAL_TEST so fixtures are actually used
RisingOrange Apr 24, 2026
101ece6
Make national-groups fixture actually exercise /communities render path
RisingOrange Apr 24, 2026
127fc33
Opt donate/pdoom/faq into visual-diff coverage
RisingOrange Apr 26, 2026
13c5c27
Merge remote-tracking branch 'origin/main' into visual-diff
RisingOrange Apr 26, 2026
40f9819
Resolve PR by head ref to avoid default-branch API quirk
RisingOrange Apr 26, 2026
6133410
Merge remote-tracking branch 'origin/main' into visual-diff
RisingOrange Apr 28, 2026
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
88 changes: 88 additions & 0 deletions .github/workflows/visual-diff-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Visual diff scope comment

# Posts (or updates) a sticky PR comment summarizing visual-diff coverage after
# each successful run of the Visual diff workflow. Runs via `workflow_run` so it
# executes in the base-repo context with write permissions, even for PRs from
# forks — GITHUB_TOKEN on the source `pull_request` workflow is read-only for
# forks.
#
# Safety: this workflow consumes an artifact produced by the fork's run, but
# only treats it as data — it renders the Markdown body into a PR comment and
# never executes any script from the fork's checkout. The PR number is derived
# from `workflow_run.head_sha` via the trusted GitHub API (not from anything
# the fork could tamper with), so a compromised artifact can't steer the
# comment onto an arbitrary PR.

on:
workflow_run:
workflows: [Visual diff (Chromatic)]
types: [completed]

permissions:
# Setting permissions at workflow level makes every unlisted scope `none` —
# actions: read is needed for the cross-run artifact download below.
actions: read
pull-requests: write

jobs:
comment:
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- name: Resolve PR from trusted workflow_run payload
id: pr
uses: actions/github-script@v7
with:
script: |
const run = context.payload.workflow_run
// Look up open PRs by head ref directly. We deliberately don't use
// `listPullRequestsAssociatedWithCommit` here: when the commit is
// on the repo's default branch, that endpoint only returns merged
// PRs, so an open PR whose head is at this commit gets filtered
// out (hit on a fork where the feature branch was the default).
// `pulls?head=` has no such quirk.
const headOwner = run.head_repository?.owner?.login
const headBranch = run.head_branch
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${headOwner}:${headBranch}`
})
// Narrow by trusted workflow_run fields — head_repository.id and
// head_sha — so a commit shared between multiple open PRs routes
// the comment to the right one. Edge case we don't handle: same
// fork branch opened as TWO open PRs against different base
// branches simultaneously (rare). In that case `.find()` picks
// the first match and the comment may land on the wrong PR
// until one is closed.
const match = prs.find((p) => {
if (run.head_repository?.id && p.head.repo?.id !== run.head_repository.id) return false
if (p.head.sha !== run.head_sha) return false
return true
})
if (!match) {
core.info(`No open PR matches ${run.head_repository?.full_name}:${run.head_branch}@${run.head_sha}; skipping comment.`)
return ''
}
core.setOutput('number', String(match.number))
return String(match.number)
- name: Download scope artifact
id: download
if: steps.pr.outputs.number != ''
uses: actions/download-artifact@v4
continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: visual-diff-scope
path: scope
- name: Post or update sticky comment
if: steps.pr.outputs.number != '' && steps.download.outcome == 'success'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: visual-diff-scope
number: ${{ steps.pr.outputs.number }}
path: scope/body.md
118 changes: 118 additions & 0 deletions .github/workflows/visual-diff.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
name: Visual diff (Chromatic)

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

concurrency:
group: visual-diff-${{ github.ref }}
cancel-in-progress: true

jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: pnpm/action-setup@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Copy environment file
run: cp template.env .env
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache imagetools transforms
uses: actions/cache@v4
with:
path: node_modules/.cache/imagetools
key: imagetools-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'vite.config.ts', 'src/**/*.{png,jpg,jpeg,webp,avif,svg}', 'static/**/*.{png,jpg,jpeg,webp,avif,svg}') }}
restore-keys: |
imagetools-${{ runner.os }}-
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright (browser + deps on cache miss)
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps (cache hit)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run Playwright tests
run: pnpm exec playwright test
env:
VISUAL_TEST: '1'
# NPM_CONFIG_NODE_OPTIONS is the only way to propagate --import through
# pnpm: pnpm overwrites NODE_OPTIONS (with --experimental-global-webcrypto)
# when spawning scripts, but npm config values pass through untouched.
# See https://github.com/pnpm/pnpm/issues/6210
NPM_CONFIG_NODE_OPTIONS: '--import ./tests/visual/msw-setup.ts'
# Fake keys so the SDKs actually make HTTP requests (they short-circuit
# to a "no key" error otherwise, bypassing MSW entirely). The real
# requests are intercepted by MSW and served from fixtures.
AIRTABLE_API_KEY: 'fake-visual-test-key'
NOTION_API_KEY: 'fake-visual-test-key'
# Log of un-fixtured outbound requests (catch-all hits + unhandled
# bypasses). Surfaced by the scope-comment companion workflow.
MSW_WARN_LOG: ${{ github.workspace }}/msw-warn.log
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
- name: Publish to Chromatic
id: chromatic
# Inlined plaintext token — Chromatic's own recommended pattern for fork-PR
# support (forks can't read repo secrets). The token is a project-scoped
# write credential (anyone with it can run builds and consume snapshot
# quota for this project), rotatable from the Chromatic UI. See
# https://www.chromatic.com/docs/github-actions/ for the full rationale
# and any capability/scope details.
uses: chromaui/action@v16
with:
playwright: true
projectToken: chpt_cefd614c783fb5c
exitZeroOnChanges: true
exitOnceUploaded: true
- name: Generate scope comment
# Only on successful test runs — the comment describes what was
# covered, and an early-failed run has nothing meaningful to report.
# Runs after Chromatic publish so the scope comment can link to the
# Chromatic build URL (where reviewers accept/deny snapshots).
if: success() && github.event_name == 'pull_request'
env:
MSW_WARN_LOG: ${{ github.workspace }}/msw-warn.log
# The head SHA of the PR. The runner's GITHUB_SHA on pull_request
# events points at the auto-generated merge-ref commit, and a
# step-level env override of GITHUB_SHA doesn't beat the runner
# injection, so pass through a distinct variable.
SCOPE_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
CHROMATIC_BUILD_URL: ${{ steps.chromatic.outputs.buildUrl }}
run: |
mkdir -p scope
node --experimental-strip-types tests/visual/scope-comment.ts > scope/body.md
- name: Upload scope comment artifact
# Fork PRs can't be trusted to write an honest pr-number.txt, so the
# companion workflow derives the PR number from the workflow_run
# payload's head_sha (trusted, served by the base repo's API) instead.
if: success() && github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: visual-diff-scope
path: scope/
retention-days: 1
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,12 @@ CLAUDE.md
.env.sentry-build-plugin

.prettierignore

# Playwright
/test-results
/playwright-report

# Chromatic (only produced by local CLI runs, not CI)
/build-archive.log
/chromatic.log
/chromatic-diagnostics.json
3 changes: 2 additions & 1 deletion knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { getIgnores } from './scripts/utils/ignores.js'
const ADDITIONALLY_ENTRY_POINTS = [
'src/routes/sayno/SelfieUX.svelte', // dynamically imported
'src/lib/components/NationalGroupItem.svelte', // imported only in Markdown file
'src/lib/components/PressCoveragePanelLoader.svelte' // imported only in Markdown file
'src/lib/components/PressCoveragePanelLoader.svelte', // imported only in Markdown file
'tests/visual/msw-setup.ts' // loaded via NPM_CONFIG_NODE_OPTIONS=--import in the visual-diff workflow
]

const config: KnipConfig = {
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@
}
},
"devDependencies": {
"@chromatic-com/playwright": "^0.13.1",
"@eslint/js": "^10.0.1",
"@eslint/markdown": "^8.0.1",
"@inlang/paraglide-js": "^2.16.1",
"@netlify/edge-functions": "^3.0.6",
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-netlify": "^5.2.4",
"@sveltejs/kit": "^2.58.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
Expand All @@ -64,6 +66,7 @@
"mdsvex": "^0.12.7",
"minimatch": "^9.0.9",
"minimist": "^1.2.8",
"msw": "^2.13.5",
"npm-run-all2": "^8.0.4",
"p-queue": "^8.1.1",
"prettier": "^3.8.3",
Expand Down
46 changes: 46 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
testDir: 'tests/visual',
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:4173',
// Pin locale and timezone so any date / number formatting that depends
// on the browser context renders the same on every run.
locale: 'en-US',
timezoneId: 'UTC',
// Opts into the site's `@media (prefers-reduced-motion: reduce)` block
// (src/styles/reset.css), which zeros out animation and transition
// durations. Keeps snapshots from capturing mid-animation state without
// needing a custom style-injection in tests.
contextOptions: { reducedMotion: 'reduce' }
},
projects: [
{
name: 'desktop',
use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } }
},
{
name: 'mobile',
// deviceScaleFactor override: devices['Pixel 7'] sets DPR=2.625, which
// multiplies the captured-pixel dimensions by ~2.6×. Full-page
// snapshots of long pages (e.g. /dear-sir-demis-2025, /posts, /learn)
// then blow past Chromatic's 25M-pixel cap. DPR=1 keeps the mobile
// viewport (412 CSS px wide) and its layout-relevant behavior, just
// captures at a lower resolution.
use: {
...devices['Pixel 7'],
viewport: { width: 412, height: 839 },
deviceScaleFactor: 1
}
}
],
webServer: {
command: 'pnpm build && pnpm preview',
port: 4173,
timeout: 360_000,
reuseExistingServer: !process.env.CI
}
})
Loading
Loading