diff --git a/.gitattributes b/.gitattributes index 1af8f5fc8..32c9dc206 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,5 +4,8 @@ # Snapshots are taken by our test suite and used to determine regressions. *.snap linguist-generated=true +# Visual regression baselines, generated by `pnpm test:visual:update`. +src/renderer/**/__screenshots__/**/*.png binary linguist-generated=true + # GraphQL Codegen outputs src/renderer/utils/api/graphql/generated/**/*.ts linguist-generated=true \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7b051d36e..c08107288 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,49 @@ jobs: name: code-coverage-report path: coverage/lcov.info + visual-regression: + name: Visual Regression + # arm64 to match `scripts/visual.sh`, which cannot generate amd64 baselines + # because Chromium segfaults under emulation on Apple Silicon. + runs-on: ubuntu-24.04-arm + permissions: + contents: read + + # The same image the baselines were generated with. The committed + # screenshots are comparable only against an identical browser build and + # font stack, which a bare runner does not provide. + # + # This tag must match the `playwright` devDependency, which ships the + # matching browser build. Renovate groups the two so they always move + # together; `scripts/visual.sh` reads the version straight from package.json. + container: mcr.microsoft.com/playwright:v1.62.1-noble + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: ./.github/actions/setup-node + + - name: Run visual regression tests + # The job already runs in the pinned image, so the script runs vitest + # directly instead of starting another container. + env: + GITIFY_VISUAL_IN_CONTAINER: '1' + run: pnpm test:visual + + # Vitest writes the actual and diff images here; the reference stays in + # __screenshots__. Download this to see what changed. + - name: Archive visual diffs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: visual-regression-diffs + path: .vitest-attachments/ + if-no-files-found: ignore + sonarqube: name: SonarQube Cloud Analysis runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 35490cbfd..e156babbc 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,15 @@ schema.graphql coverage *.lcov +# Visual regression screenshots. Only the Linux baselines CI compares against +# are committed; anything generated on a developer's own OS would never match +# it (font rendering differs) and must not become a baseline. Regenerate with +# `pnpm test:visual:update`, which runs in the pinned Linux container. +src/renderer/**/__screenshots__/**/*.png +!src/renderer/**/__screenshots__/**/*-chromium-linux.png + +# Actual/diff images written by a failing visual regression run +.vitest-attachments/ + # Mac Files .DS_Store \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb5f91d67..ca066eeb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,38 @@ pnpm test pnpm test -u ``` +#### Visual regression tests + +`pnpm test` covers the DOM snapshots. Pixel-level regressions (colour tokens, +spacing, theme changes) are covered separately by a browser-mode project that +screenshots every route across the light, dark, accessibility, and Glass themes. + +Screenshots only match when the browser build and font stack are identical, so +both commands below run the suite inside a pinned Playwright container, and CI +verifies against that same container. You do not need to install Playwright's +browsers locally. + +```shell +# Verify against the committed baselines +pnpm test:visual + +# Regenerate them after an intentional UI change +pnpm test:visual:update +``` + +Both need Docker on an arm64 host, because Chromium crashes under amd64 +emulation on Apple Silicon. On any other machine, push the branch and read the +diff from the Visual Regression job's `visual-regression-diffs` artifact. + +Do not invoke the `browser [visual]` project through `vitest` directly. Vitest +namespaces baselines per platform and creates missing ones automatically, so a +bare run on macOS writes a fresh set from your working tree and then passes +against it — reporting green even on a branch that has a real regression. A +guard fails the run outside Linux rather than letting that happen. + +Note that these baselines capture the `backdrop-filter` fallback for Glass, not +the macOS native vibrancy material, which Chromium cannot render. + ### Code Style & Conventions - Linting and formatting are configured in `vite.config.ts` (the `lint` and `fmt` blocks). Please run `pnpm check` before submitting a PR. diff --git a/package.json b/package.json index c84b68ccd..3cc25f3b9 100644 --- a/package.json +++ b/package.json @@ -64,9 +64,11 @@ "lint:check": "vp lint", "format": "vp fmt", "format:check": "vp fmt --check", - "test": "vp test --coverage --run", + "test": "vp test --coverage --run --project 'happy-dom [preload, renderer]' --project 'node [main, shared]'", "test:watch": "vp test --watch --coverage", - "test:ui": "vp test --ui" + "test:ui": "vp test --ui", + "test:visual": "scripts/visual.sh", + "test:visual:update": "scripts/visual.sh --update" }, "dependencies": { "electron-log": "5.4.4", @@ -107,6 +109,8 @@ "@types/react-router-dom": "5.3.3", "@types/semver": "7.8.0", "@vitejs/plugin-react": "6.0.5", + "@vitest/browser": "4.1.10", + "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "4.1.10", "clsx": "2.1.1", "concurrently": "10.0.4", @@ -118,6 +122,7 @@ "graphql": "17.0.2", "happy-dom": "20.11.1", "husky": "9.1.7", + "playwright": "1.62.1", "react-devtools": "7.0.1", "rimraf": "6.1.3", "semver": "7.8.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d70fc8e8..e03909044 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,12 @@ importers: '@vitejs/plugin-react': specifier: 6.0.5 version: 6.0.5(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/browser': + specifier: 4.1.10 + version: 4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/browser-playwright': + specifier: 4.1.10 + version: 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) '@vitest/coverage-v8': specifier: 4.1.10 version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) @@ -156,6 +162,9 @@ importers: husky: specifier: 9.1.7 version: 9.1.7 + playwright: + specifier: 1.62.1 + version: 1.62.1 react-devtools: specifier: 7.0.1 version: 7.0.1(supports-color@10.2.2) @@ -179,16 +188,16 @@ importers: version: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) vite-plugin-checker: specifier: 0.14.5 - version: 0.14.5(oxlint@1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)))(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) + version: 0.14.5(oxlint@1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)))(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) vite-plugin-electron: specifier: 1.1.1 version: 1.1.1 vite-plus: specifier: 0.2.7 - version: 0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0) + version: 0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0) vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) zustand: specifier: 5.0.14 version: 5.0.14(@types/react@19.2.18)(react@19.2.8) @@ -1718,6 +1727,12 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/browser-playwright@4.1.10': + resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==} + peerDependencies: + playwright: '*' + vitest: 4.1.10 + '@vitest/browser-preview@4.1.10': resolution: {integrity: sha512-14MJrL59ZFkqXLjwfSk6RzTDy5Czf9UG4+8q8L6Gxjs2aPjEce/cVNYV14bXAc2BvMjUNu904+ZEZA1Xc1wtvQ==} peerDependencies: @@ -2729,6 +2744,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3702,6 +3722,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -6034,12 +6064,25 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) + '@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) + playwright: 1.62.1 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/browser-preview@4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) - vitest: 4.1.10(@types/node@24.13.3)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw @@ -6055,7 +6098,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@24.13.3)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) ws: 8.21.2 transitivePeerDependencies: - bufferutil @@ -6075,7 +6118,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@24.13.3)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) optionalDependencies: '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) @@ -7019,6 +7062,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7759,7 +7805,7 @@ snapshots: dependencies: mimic-function: 5.0.1 - oxfmt@0.60.0(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)): + oxfmt@0.60.0(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -7782,7 +7828,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.60.0 '@oxfmt/binding-win32-ia32-msvc': 0.60.0 '@oxfmt/binding-win32-x64-msvc': 0.60.0 - vite-plus: 0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0) + vite-plus: 0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0) oxlint-tsgolint@7.0.2001: optionalDependencies: @@ -7793,7 +7839,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)): + oxlint@1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.75.0 '@oxlint/binding-android-arm64': 1.75.0 @@ -7815,7 +7861,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.75.0 '@oxlint/binding-win32-x64-msvc': 1.75.0 oxlint-tsgolint: 7.0.2001 - vite-plus: 0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0) + vite-plus: 0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -7915,6 +7961,14 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.13 @@ -8480,7 +8534,7 @@ snapshots: util-deprecate@1.0.2: {} - vite-plugin-checker@0.14.5(oxlint@1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)))(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)): + vite-plugin-checker@0.14.5(oxlint@1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)))(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 5.0.0 @@ -8491,14 +8545,14 @@ snapshots: tiny-invariant: 1.3.3 vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: - oxlint: 1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)) + oxlint: 1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)) typescript: 7.0.2 vite-plugin-electron@1.1.1: dependencies: local-pkg: 1.2.1 - vite-plus@0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0): + vite-plus@0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@oxc-project/types': 0.141.0 '@oxlint/plugins': 1.73.0 @@ -8512,11 +8566,12 @@ snapshots: '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 '@voidzero-dev/vite-plus-core': 0.2.7(@types/node@24.13.3)(jiti@2.7.0)(typescript@7.0.2)(yaml@2.9.0) - oxfmt: 0.60.0(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)) - oxlint: 1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)) + oxfmt: 0.60.0(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)) + oxlint: 1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jiti@2.7.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)) oxlint-tsgolint: 7.0.2001 - vitest: 4.1.10(@types/node@24.13.3)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) optionalDependencies: + '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) '@voidzero-dev/vite-plus-darwin-arm64': 0.2.7 '@voidzero-dev/vite-plus-darwin-x64': 0.2.7 '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.2.7 @@ -8569,7 +8624,7 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.10(@types/node@24.13.3)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) @@ -8593,6 +8648,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 + '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) '@vitest/browser-preview': 4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) happy-dom: 20.11.1 diff --git a/renovate.json b/renovate.json index aff1f100a..58bdf8b4e 100644 --- a/renovate.json +++ b/renovate.json @@ -34,6 +34,11 @@ "matchPackageNames": ["vite-plus", "vitest", "@vitest/coverage-v8"], "groupName": "vite-plus / vitest" }, + { + "description": "Group the playwright package with the Playwright container image that the visual regression job runs in. The image ships the browser build that exact release expects, so a mismatch fails to launch Chromium and would strand the committed screenshot baselines.", + "matchPackageNames": ["playwright", "mcr.microsoft.com/playwright"], + "groupName": "playwright" + }, { "description": "Give updates to core runtime dependencies (Electron shell, tray integration, UI primitives, GitHub API client) a distinct commit scope so release-please can surface them in the changelog instead of hiding them with routine dependency bumps", "matchPackageNames": [ diff --git a/scripts/visual.sh b/scripts/visual.sh new file mode 100755 index 000000000..bb58995d6 --- /dev/null +++ b/scripts/visual.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Runs the visual regression suite, or regenerates its baselines with --update. +# +# Screenshots are only comparable against an identical browser build and font +# stack, so the suite always runs inside the pinned Playwright image rather than +# on the host. This script puts it there; when it is already inside that image +# (CI, or the nested call below) it runs vitest directly. +# +# arm64 rather than amd64 because Chromium segfaults under amd64 emulation on +# Apple Silicon, which makes local runs impossible on the machines the +# maintainers actually use. The tradeoff is that running this locally needs an +# arm64 host; CI covers everyone else. +# +# Usage: +# scripts/visual.sh # verify against committed baselines +# scripts/visual.sh --update # regenerate every baseline +# scripts/visual.sh --update settings # regenerate baselines matching a name + +set -euo pipefail + +UPDATE="" +if [ "${1:-}" = "--update" ]; then + UPDATE="--update" + shift +fi +FILTER="${1:-}" + +# Already inside the pinned image (set by the CI job and by the docker run +# below), so run the suite rather than nesting another container. +if [ -n "${GITIFY_VISUAL_IN_CONTAINER:-}" ]; then + # shellcheck disable=SC2086 -- word splitting is intended for the optional flags + exec corepack pnpm exec vitest --project 'browser [visual]' --run ${UPDATE} ${FILTER:+-t "${FILTER}"} +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Derived from the `playwright` devDependency rather than pinned here: the image +# ships the browser build that exact release expects, and a mismatched pair +# fails to launch. Renovate bumping the dependency moves the image with it. +PLAYWRIGHT_VERSION="$(node -p "require('${REPO_ROOT}/package.json').devDependencies.playwright")" +IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" + +if ! docker info >/dev/null 2>&1; then + echo "Docker is not running. Visual regression tests only run inside the pinned Linux image." >&2 + echo "Push the branch and let the Visual Regression CI job report the diff instead." >&2 + exit 1 +fi + +HOST_ARCH="$(uname -m)" +if [ "${HOST_ARCH}" != "arm64" ] && [ "${HOST_ARCH}" != "aarch64" ]; then + echo "Visual regression tests need an arm64 host to match CI; this machine is ${HOST_ARCH}." >&2 + echo "Push the branch and let the Visual Regression CI job report the diff instead." >&2 + exit 1 +fi + +# HUSKY=0 skips the `prepare` hook, which cannot find .git when the repo is a +# worktree (its .git file points outside the mount). +# +# Only allocate a TTY when there is one, so the script also works from a +# non-interactive shell. +TTY_FLAGS=() +if [ -t 0 ] && [ -t 1 ]; then + TTY_FLAGS=(--interactive --tty) +fi + +# The host's node_modules holds darwin binaries (esbuild, @tailwindcss/oxide), +# so the container gets its own install in a named volume. It persists between +# runs, making everything after the first invocation fast. +docker run --rm "${TTY_FLAGS[@]}" \ + --platform linux/arm64 \ + --volume "${REPO_ROOT}":/gitify \ + --volume gitify-visual-node-modules:/gitify/node_modules \ + --volume gitify-visual-pnpm-store:/root/.local/share/pnpm/store \ + --workdir /gitify \ + --env HUSKY=0 \ + --env GITIFY_VISUAL_IN_CONTAINER=1 \ + "${IMAGE}" \ + bash -c " + set -euo pipefail + corepack enable + corepack pnpm install --frozen-lockfile + scripts/visual.sh ${UPDATE} ${FILTER} + " + +if [ -n "${UPDATE}" ]; then + echo + echo "Baselines updated. Review the diff before committing:" + echo " git status --short src/renderer" +fi diff --git a/src/renderer/__helpers__/visual-utils.tsx b/src/renderer/__helpers__/visual-utils.tsx new file mode 100644 index 000000000..59893c3b1 --- /dev/null +++ b/src/renderer/__helpers__/visual-utils.tsx @@ -0,0 +1,139 @@ +import { render } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { type InitialEntry, MemoryRouter } from 'react-router-dom'; + +import { BaseStyles, ThemeProvider } from '@primer/react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { useAppearance } from '../hooks/useAppearance'; +import { type FiltersStore, useAccountsStore, useFiltersStore, useSettingsStore } from '../stores'; + +import { AppLayout } from '../components/layout/AppLayout'; + +import type { Account, AccountNotifications, DesignLanguage, SettingsState, Theme } from '../types'; + +import { setNotificationsOverrides } from './hook-mocks'; +import '../App.css'; +import { stubImage } from './visual.setup'; + +const AVATAR_STUB = stubImage('#57606a'); + +export interface VisualRenderOptions { + /** Colour scheme to screenshot under. */ + theme: Theme; + /** Design language to screenshot under. */ + designLanguage: DesignLanguage; + initialEntries?: InitialEntry[]; + accounts?: Account[]; + settings?: Partial; + filters?: Partial; + notifications?: AccountNotifications[]; +} + +/** + * Rewrites every remote avatar in a fixture tree to an inline SVG. These would + * otherwise be fetched from githubusercontent during the run, making baselines + * depend on the network and on whatever that URL serves today. + * + * Matches on the key name rather than an exact list because the fixtures spell + * it both `avatar` (`GitifyUser`) and `avatarUrl` (`GitifyNotificationUser`). + */ +function isRemoteAvatar(key: string, value: unknown): boolean { + return ( + key.toLowerCase().includes('avatar') && typeof value === 'string' && /^https?:\/\//.test(value) + ); +} + +function withStubbedAvatars(value: T): T { + if (Array.isArray(value)) { + return value.map(withStubbedAvatars) as T; + } + + if (value === null || typeof value !== 'object') { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + isRemoteAvatar(key, item) ? AVATAR_STUB : withStubbedAvatars(item), + ]), + ) as T; +} + +/** Applies the theme side effects that `GlobalEffects` owns in the real app. */ +function Appearance() { + useAppearance(); + return null; +} + +/** + * Renders a route inside the full app chrome (Primer theme, sidebar, layout) at + * the menubar window size. + * + * Not wrapped in `act()`: `render` already applies its own, and awaiting this + * call yields long enough for the async effects to land (`EmojiText` fills its + * `innerHTML` from a promise). `toMatchScreenshot` retries until the page is + * stable anyway, so it is the backstop rather than this function. + */ +export async function renderRoute( + ui: ReactElement, + { + theme, + designLanguage, + initialEntries, + accounts, + settings, + filters, + notifications, + }: VisualRenderOptions, +) { + useSettingsStore.setState({ ...settings, theme, designLanguage }); + + if (accounts) { + useAccountsStore.setState({ accounts: withStubbedAvatars(accounts) }); + } + + if (filters) { + useFiltersStore.setState(filters); + } + + if (notifications) { + const stubbed = withStubbedAvatars(notifications); + setNotificationsOverrides({ + notifications: stubbed, + notificationCount: stubbed.reduce((sum, n) => sum + n.notifications.length, 0), + hasNotifications: stubbed.some((n) => n.notifications.length > 0), + }); + } + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false, refetchInterval: false }, + }, + }); + + // Pinned to the viewport so the captured element is exactly the menubar + // window, independent of how much content the route happens to render. + const container = document.createElement('div'); + container.dataset.testid = 'visual-root'; + container.style.cssText = 'position:fixed;inset:0;overflow:hidden'; + document.body.appendChild(container); + + render( + + + + + {/* Routes reading `location.state` (AccountScopes) get their + account through here, so it needs stubbing too. */} + + {ui} + + + + , + { container }, + ); +} diff --git a/src/renderer/__helpers__/visual.global-setup.ts b/src/renderer/__helpers__/visual.global-setup.ts new file mode 100644 index 000000000..398e2638e --- /dev/null +++ b/src/renderer/__helpers__/visual.global-setup.ts @@ -0,0 +1,33 @@ +/** + * Refuses to run the visual project anywhere but Linux. + * + * Vitest namespaces baselines by platform and silently creates any that are + * missing. Run bare on macOS, the first run therefore writes a fresh `-darwin` + * set from whatever the working tree happens to render and the second run + * passes against it — without ever consulting the committed Linux baselines. + * A branch carrying a real regression reports green. + * + * `scripts/visual.sh` puts the run inside the pinned Linux container, where + * this check passes. Failing loudly here means the trap cannot be reached even + * by invoking vitest directly. + */ +export default function setup(): void { + if (process.platform === 'linux') { + return; + } + + throw new Error( + [ + `Visual regression tests cannot run on ${process.platform}.`, + '', + 'Baselines are committed for Linux only, because font rendering differs', + 'per platform. Running here would create a separate set of baselines from', + 'the current working tree and then pass against them, hiding any real', + 'regression.', + '', + 'Run `pnpm test:visual`, which executes the suite inside the pinned', + 'Playwright container (requires Docker on an arm64 host). Otherwise push', + 'the branch and let the Visual Regression CI job report the diff.', + ].join('\n'), + ); +} diff --git a/src/renderer/__helpers__/visual.setup.ts b/src/renderer/__helpers__/visual.setup.ts new file mode 100644 index 000000000..cff711fa5 --- /dev/null +++ b/src/renderer/__helpers__/visual.setup.ts @@ -0,0 +1,173 @@ +import useFiltersStore from '../stores/useFiltersStore'; +import useSettingsStore from '../stores/useSettingsStore'; + +/** + * Frozen wall clock. `NotificationFooter` renders ``, which + * formats against "now" — without pinning it, every baseline containing a + * notification expires the moment the phrasing rolls over ("9 years ago" → + * "10 years ago"). Only `Date` is faked so React's scheduler keeps its real + * timers. + */ +const FIXED_NOW = new Date('2026-01-02T00:00:00Z'); + +/** + * Solid SVG stand-in for images the app would otherwise fetch over the network. + * Both Primer's `Avatar` and `img.emoji` size their images entirely from CSS, + * so substituting the source preserves layout. + */ +export function stubImage(color: string): string { + const svg = ``; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +// Keeps the emoji EmojiSplash picks stable across runs. +vi.mock('../utils/core/random', () => ({ + randomElement: vi.fn((arr: unknown[]) => arr[0]), +})); + +// Twemoji SVGs are resolved from the packaged app's asset directory, which the +// browser test server does not serve. +vi.mock('../utils/ui/emojis', () => ({ + convertTextToEmojiImgHtml: vi.fn( + async (text: string) => + `${text}`, + ), +})); + +/** + * Mirrors the app-level hook mocks in vitest.setup.ts so `renderRoute` can + * inject notification and login state through the same `hook-mocks` module. + */ +vi.mock('../hooks/useNotifications', async () => { + const actual = await vi.importActual( + '../hooks/useNotifications', + ); + const { getMockedNotificationsState } = await import('./hook-mocks'); + return { + ...actual, + useNotifications: () => getMockedNotificationsState(), + }; +}); + +vi.mock('../hooks/useLogins', async () => { + const actual = await vi.importActual('../hooks/useLogins'); + const { getMockedLoginsState } = await import('./hook-mocks'); + return { + ...actual, + useLogins: () => getMockedLoginsState(), + }; +}); + +vi.mock('../hooks/useOnlineStatus', async () => { + const actual = await vi.importActual( + '../hooks/useOnlineStatus', + ); + const { getMockedIsOnline } = await import('./hook-mocks'); + return { + ...actual, + useOnlineStatus: () => getMockedIsOnline(), + }; +}); + +/** + * Reports Linux/Windows rather than macOS, matching the platform the committed + * baselines are generated on. Glass therefore resolves to its `backdrop-filter` + * material; the macOS native vibrancy material cannot be captured by Chromium. + */ +function createGitifyBridgeApi(): Window['gitify'] { + return { + app: { + version: vi.fn().mockResolvedValue('v0.0.1'), + hide: vi.fn(), + quit: vi.fn(), + show: vi.fn(), + }, + twemojiDirectory: vi.fn().mockResolvedValue('/mock/images/assets'), + openExternalLink: vi.fn(), + decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted' }), + encryptValue: vi.fn().mockResolvedValue('encrypted'), + setWindowVibrancy: vi.fn().mockResolvedValue(undefined), + setNativeTheme: vi.fn().mockResolvedValue(undefined), + platform: { + isLinux: vi.fn().mockReturnValue(true), + isMacOS: vi.fn().mockReturnValue(false), + isWindows: vi.fn().mockReturnValue(false), + }, + zoom: { + getLevel: vi.fn(), + setLevel: vi.fn(), + }, + tray: { + updateColor: vi.fn(), + updateTitle: vi.fn(), + useAlternateIdleIcon: vi.fn(), + useUnreadActiveIcon: vi.fn(), + }, + notificationSoundPath: vi.fn(), + onAuthCallback: vi.fn(() => vi.fn()), + onResetApp: vi.fn(() => vi.fn()), + onSystemWake: vi.fn(() => vi.fn()), + setAutoLaunch: vi.fn(), + setKeepWindowOnBlur: vi.fn(), + applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }), + raiseNativeNotification: vi.fn(), + }; +} + +/** + * Settles animations to their end state before anything renders. + * + * Playwright freezes CSS animations when it takes the screenshot, but that is + * too late for the login route: its tab indicator measures tab positions with + * `getBoundingClientRect` in a layout effect, so a tab still mid-`fade-up` + * yields a different pill offset every run. Zeroing the durations up front + * makes that measurement read the settled layout. Every affected animation ends + * at the element's natural style, so nothing is hidden from the screenshot. + */ +function disableAnimations(): void { + const style = document.createElement('style'); + style.textContent = `*, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + }`; + document.head.appendChild(style); +} + +beforeEach(async () => { + disableAnimations(); + vi.useFakeTimers({ toFake: ['Date'], now: FIXED_NOW }); + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + throw new Error( + `Unexpected network request in visual test: ${String(input)}. Mock the network boundary explicitly.`, + ); + }), + ); + + window.gitify = createGitifyBridgeApi(); + + useFiltersStore.getState().reset(); + useSettingsStore.getState().reset(); + + const { default: useAccountsStore } = await import('../stores/useAccountsStore'); + useAccountsStore.getState().reset(); + + const { resetHookMocks } = await import('./hook-mocks'); + resetHookMocks(); + + const { useShortcutRegistrationStore } = await import('../hooks/useShortcutRegistration'); + useShortcutRegistrationStore.getState().reset(); +}); + +afterEach(async () => { + const { cleanup } = await import('@testing-library/react'); + cleanup(); + for (const root of document.querySelectorAll('[data-testid="visual-root"]')) { + root.remove(); + } + vi.useRealTimers(); +}); diff --git a/src/renderer/__mocks__/notifications-mocks.ts b/src/renderer/__mocks__/notifications-mocks.ts index 5728d50d9..974faaec4 100644 --- a/src/renderer/__mocks__/notifications-mocks.ts +++ b/src/renderer/__mocks__/notifications-mocks.ts @@ -1,4 +1,4 @@ -import { FeedPullRequestOpenIcon, IssueOpenedIcon } from '@primer/octicons-react'; +import { GitPullRequestIcon, IssueOpenedIcon, TagIcon } from '@primer/octicons-react'; import { type AccountNotifications, @@ -173,7 +173,7 @@ export const mockGithubEnterpriseGitifyNotifications: GitifyNotification[] = [ title: 'Release 0.0.1', type: 'Release', icon: { - type: IssueOpenedIcon, + type: TagIcon, color: IconColor.GREEN, }, defaultUserType: 'User', @@ -204,7 +204,7 @@ export const mockGithubEnterpriseGitifyNotifications: GitifyNotification[] = [ title: 'Bump Version [#123]', type: 'Pull Request', icon: { - type: FeedPullRequestOpenIcon, + type: GitPullRequestIcon, color: IconColor.GREEN, }, defaultUserType: 'User', diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/account-scopes-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/account-scopes-dark-classic-chromium-linux.png new file mode 100644 index 000000000..4fa47f2f1 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/account-scopes-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/account-scopes-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/account-scopes-light-classic-chromium-linux.png new file mode 100644 index 000000000..26473330f Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/account-scopes-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/accounts-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/accounts-dark-classic-chromium-linux.png new file mode 100644 index 000000000..57b797548 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/accounts-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/accounts-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/accounts-light-classic-chromium-linux.png new file mode 100644 index 000000000..8a8c4ba76 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/accounts-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/filters-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/filters-dark-classic-chromium-linux.png new file mode 100644 index 000000000..7ed6b856e Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/filters-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/filters-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/filters-light-classic-chromium-linux.png new file mode 100644 index 000000000..d7c63aa09 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/filters-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-bitbucket-personal-access-token-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-bitbucket-personal-access-token-dark-classic-chromium-linux.png new file mode 100644 index 000000000..36e9dc327 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-bitbucket-personal-access-token-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-bitbucket-personal-access-token-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-bitbucket-personal-access-token-light-classic-chromium-linux.png new file mode 100644 index 000000000..a3b02ce6e Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-bitbucket-personal-access-token-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png new file mode 100644 index 000000000..f451f8434 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitea-personal-access-token-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitea-personal-access-token-dark-classic-chromium-linux.png new file mode 100644 index 000000000..0710fe1ed Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitea-personal-access-token-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitea-personal-access-token-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitea-personal-access-token-light-classic-chromium-linux.png new file mode 100644 index 000000000..b78ac8950 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitea-personal-access-token-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-classic-chromium-linux.png new file mode 100644 index 000000000..443ca8057 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-classic-chromium-linux.png new file mode 100644 index 000000000..5bde943cf Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-dark-classic-chromium-linux.png new file mode 100644 index 000000000..c15719ab9 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-light-classic-chromium-linux.png new file mode 100644 index 000000000..d96ca178f Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-oauth-app-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-classic-chromium-linux.png new file mode 100644 index 000000000..214d4027a Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-classic-chromium-linux.png new file mode 100644 index 000000000..e2ee8a924 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png new file mode 100644 index 000000000..cdf73f703 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark-classic-chromium-linux.png new file mode 100644 index 000000000..6d5ce30a3 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark-glass-chromium-linux.png new file mode 100644 index 000000000..3ea7d7366 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark_colorblind-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark_colorblind-classic-chromium-linux.png new file mode 100644 index 000000000..45e96e6cf Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark_colorblind-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark_dimmed-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark_dimmed-classic-chromium-linux.png new file mode 100644 index 000000000..085a9f998 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark_dimmed-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark_tritanopia-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark_tritanopia-classic-chromium-linux.png new file mode 100644 index 000000000..c2aa46eaa Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-dark_tritanopia-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light-classic-chromium-linux.png new file mode 100644 index 000000000..21ce91271 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light-glass-chromium-linux.png new file mode 100644 index 000000000..a8e921847 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light_colorblind-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light_colorblind-classic-chromium-linux.png new file mode 100644 index 000000000..047a5806d Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light_colorblind-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light_tritanopia-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light_tritanopia-classic-chromium-linux.png new file mode 100644 index 000000000..9a78e6f32 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/notifications-light_tritanopia-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-classic-chromium-linux.png new file mode 100644 index 000000000..127bb1a3c Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-glass-chromium-linux.png new file mode 100644 index 000000000..0e4a03c48 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_colorblind-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_colorblind-classic-chromium-linux.png new file mode 100644 index 000000000..dd59b4e93 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_colorblind-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_dimmed-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_dimmed-classic-chromium-linux.png new file mode 100644 index 000000000..eff61b5cc Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_dimmed-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_tritanopia-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_tritanopia-classic-chromium-linux.png new file mode 100644 index 000000000..d8aa37f19 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_tritanopia-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-classic-chromium-linux.png new file mode 100644 index 000000000..8dc803869 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-glass-chromium-linux.png new file mode 100644 index 000000000..991bbdf32 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_colorblind-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_colorblind-classic-chromium-linux.png new file mode 100644 index 000000000..64abc4487 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_colorblind-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_tritanopia-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_tritanopia-classic-chromium-linux.png new file mode 100644 index 000000000..2bf476002 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_tritanopia-classic-chromium-linux.png differ diff --git a/src/renderer/routes/routes.visual.test.tsx b/src/renderer/routes/routes.visual.test.tsx new file mode 100644 index 000000000..2a4230ccf --- /dev/null +++ b/src/renderer/routes/routes.visual.test.tsx @@ -0,0 +1,147 @@ +import type { ReactElement } from 'react'; + +import { page } from 'vitest/browser'; + +import { renderRoute, type VisualRenderOptions } from '../__helpers__/visual-utils'; +import { + mockGitHubCloudAccount, + mockGitHubEnterpriseServerAccount, + mockPersonalAccessTokenAccount, +} from '../__mocks__/account-mocks'; +import { mockMultipleAccountNotifications } from '../__mocks__/notifications-mocks'; + +import { DesignLanguage, Theme } from '../types'; + +import { AccountsRoute } from './Accounts'; +import { AccountScopesRoute } from './AccountScopes'; +import { BitbucketLoginWithPersonalAccessTokenRoute } from './bitbucket/LoginWithPersonalAccessToken'; +import { FiltersRoute } from './Filters'; +import { GiteaLoginWithPersonalAccessTokenRoute } from './gitea/LoginWithPersonalAccessToken'; +import { GitHubLoginWithDeviceFlowRoute } from './github/LoginWithDeviceFlow'; +import { GitHubLoginWithOAuthAppRoute } from './github/LoginWithOAuthApp'; +import { GitHubLoginWithPersonalAccessTokenRoute } from './github/LoginWithPersonalAccessToken'; +import { LoginRoute } from './Login'; +import { NotificationsRoute } from './Notifications'; +import { SettingsRoute } from './Settings'; + +type RouteState = Omit; + +interface RouteCase { + /** Becomes the screenshot filename prefix. */ + name: string; + element: ReactElement; + state?: RouteState; +} + +const AUTHENTICATED: RouteState = { + accounts: [mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount], +}; + +/** Every route reachable from `App.tsx`, in the order they are declared there. */ +const ROUTES: RouteCase[] = [ + { + name: 'notifications', + element: , + state: { ...AUTHENTICATED, notifications: mockMultipleAccountNotifications }, + }, + { + name: 'filters', + element: , + state: { ...AUTHENTICATED, initialEntries: ['/filters'] }, + }, + { + name: 'settings', + element: , + state: { ...AUTHENTICATED, initialEntries: ['/settings'] }, + }, + { + name: 'accounts', + element: , + state: { ...AUTHENTICATED, initialEntries: ['/accounts'] }, + }, + { + name: 'account-scopes', + element: , + state: { + accounts: [mockPersonalAccessTokenAccount], + // The route reads its account off `location.state`, not the store. + initialEntries: [ + { pathname: '/account-scopes', state: { account: mockPersonalAccessTokenAccount } }, + ], + }, + }, + { name: 'login', element: , state: { initialEntries: ['/login'] } }, + { + name: 'login-github-device-flow', + element: , + state: { initialEntries: ['/login/github/device-flow'] }, + }, + { + name: 'login-github-personal-access-token', + element: , + state: { initialEntries: ['/login/github/personal-access-token'] }, + }, + { + name: 'login-github-oauth-app', + element: , + state: { initialEntries: ['/login/github/oauth-app'] }, + }, + { + name: 'login-gitea-personal-access-token', + element: , + state: { initialEntries: ['/login/gitea/personal-access-token'] }, + }, + { + name: 'login-bitbucket-personal-access-token', + element: , + state: { initialEntries: ['/login/bitbucket/personal-access-token'] }, + }, +]; + +/** + * The two densest routes carry the accessibility and Glass variants. Running + * every theme against every route would multiply the committed baselines + * without covering token combinations these two do not already exercise. + */ +const DENSE_ROUTES = ROUTES.filter( + (route) => route.name === 'notifications' || route.name === 'settings', +); + +const ACCESSIBILITY_THEMES = [ + Theme.LIGHT_COLORBLIND, + Theme.LIGHT_TRITANOPIA, + Theme.DARK_COLORBLIND, + Theme.DARK_TRITANOPIA, + Theme.DARK_DIMMED, +]; + +async function captureRoute(route: RouteCase, theme: Theme, designLanguage: DesignLanguage) { + await renderRoute(route.element, { ...route.state, theme, designLanguage }); + + await expect(page.getByTestId('visual-root')).toMatchScreenshot( + `${route.name}-${theme.toLowerCase()}-${designLanguage}`, + ); +} + +describe('renderer/routes visual regression', () => { + describe.each([Theme.LIGHT, Theme.DARK])('classic %s', (theme) => { + it.each(ROUTES)('$name', async (route) => { + await captureRoute(route, theme, DesignLanguage.CLASSIC); + }); + }); + + describe.each(ACCESSIBILITY_THEMES)('classic %s', (theme) => { + it.each(DENSE_ROUTES)('$name', async (route) => { + await captureRoute(route, theme, DesignLanguage.CLASSIC); + }); + }); + + // Captures the Glass content layer only. On macOS the window background is a + // native vibrancy material that Chromium cannot render, so these baselines + // exercise the `backdrop-filter` fallback that Linux and Windows receive. + describe.each([Theme.LIGHT, Theme.DARK])('glass %s', (theme) => { + it.each(DENSE_ROUTES)('$name', async (route) => { + await captureRoute(route, theme, DesignLanguage.GLASS); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c293d6662..3cd191487 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,18 @@ +import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vitest/config'; +import { playwright } from '@vitest/browser-playwright'; +import { defaultExclude, defineConfig } from 'vitest/config'; // Set a stable timezone before any workers start so that Intl (used by // @primer/react's RelativeTime component) always formats dates in UTC, // regardless of the local system timezone (e.g. when travelling). process.env.TZ = 'UTC'; +// Mirrors `WindowConfig` in src/main/config.ts, which cannot be imported here +// because it resolves `__dirname` at module scope. src/main/config.test.ts +// asserts these same values. +const MENUBAR_VIEWPORT = { width: 500, height: 400 }; + export default defineConfig({ plugins: [react()], test: { @@ -44,6 +51,9 @@ export default defineConfig({ environment: 'happy-dom', css: true, include: ['src/preload/**/*.test.{ts,tsx}', 'src/renderer/**/*.test.{ts,tsx}'], + // Visual tests share the `.test.tsx` suffix but need a real browser. + // Spread the defaults; assigning `exclude` replaces them outright. + exclude: [...defaultExclude, '**/*.visual.test.tsx'], setupFiles: ['./src/renderer/__helpers__/vitest.setup.ts'], }, }, @@ -56,6 +66,66 @@ export default defineConfig({ include: ['src/shared/**/*.test.{ts,tsx}', 'src/main/**/*.test.{ts,tsx}'], }, }, + { + extends: true, + // Tailwind is not in the root plugins because the other two projects never + // resolve `@import 'tailwindcss'`; pixel comparison does. + plugins: [tailwindcss()], + // `src/renderer/constants.ts` reads this, and unlike the other two + // projects there is no Node `process` in the browser to read it from. + define: { + 'process.env.OAUTH_CLIENT_ID': JSON.stringify('visual-test-client-id'), + }, + test: { + name: 'browser [visual]', + include: ['src/renderer/**/*.visual.test.tsx'], + setupFiles: ['./src/renderer/__helpers__/visual.setup.ts'], + // Hard-stops the run outside Linux, where Vitest would otherwise + // create per-platform baselines and pass against them. + globalSetup: ['./src/renderer/__helpers__/visual.global-setup.ts'], + browser: { + enabled: true, + headless: true, + provider: playwright({ + launchOptions: { + // Chromium's default font rendering varies with GPU availability. + // Force the software path so a developer's machine and CI agree. + args: ['--disable-lcd-text', '--font-render-hinting=none'], + }, + contextOptions: { + // The renderer formats relative timestamps via Intl; the Node-level + // `process.env.TZ` above does not reach the browser process. + timezoneId: 'UTC', + locale: 'en-US', + // Only reached by Theme.SYSTEM, which resolves via `prefers-color-scheme`. + colorScheme: 'light', + }, + }), + instances: [ + { + browser: 'chromium', + viewport: MENUBAR_VIEWPORT, + }, + ], + expect: { + toMatchScreenshot: { + comparatorName: 'pixelmatch', + comparatorOptions: { + // Per-pixel colour tolerance, to absorb antialiasing noise. + threshold: 0.2, + // But zero tolerance for the *number* of differing pixels. + // Rendering is deterministic here (fixed container, arch, + // fonts and clock), so any drift is a real change. A ratio of + // even 0.01 would permit ~2000 pixels on this 500x400 + // viewport — larger than a whole icon, which is enough to + // hide a completely swapped glyph. + allowedMismatchedPixels: 0, + }, + }, + }, + }, + }, + }, ], }, });