diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6a1a9d0f8b..6ec603beece 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: - name: Run tests defined in vitest uses: docker://mcr.microsoft.com/playwright:v1.55.0-jammy with: - args: npm test + args: npm test -- --project '!@primer/styled-react (vrt)' continue-on-error: ${{ matrix.react-version == 'react-19' }} type-check: diff --git a/.github/workflows/vrt-upload-snapshots.yml b/.github/workflows/vrt-upload-snapshots.yml new file mode 100644 index 00000000000..07ac9d09b30 --- /dev/null +++ b/.github/workflows/vrt-upload-snapshots.yml @@ -0,0 +1,50 @@ +name: Visual Regression Snapshots + +on: + workflow_dispatch: + push: + branches: + - 'main' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + snapshots-runner: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2] + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 + with: + node-version-file: '.nvmrc' + cache: 'npm' + - name: Set up turbo cache + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build + - name: Run tests defined in vitest + uses: docker://mcr.microsoft.com/playwright:v1.55.0-jammy + with: + args: npx vitest --project '@primer/styled-react (vrt)' --update --shard="${{ matrix.shard }}/${{ strategy.job-total }}" + - name: Upload images + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: vrt-snapshots-${{ strategy.shard }} + path: '**/__screenshots__/**/*-linux.png' diff --git a/.gitignore b/.gitignore index ce59f6f054a..b72ff0bbe44 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,8 @@ results.json *.module.css.d.ts # Vitest -**/__screenshots__/** +**/__screenshots__/**/*.png +**/.vitest-attachments/** # Turbo .turbo diff --git a/package-lock.json b/package-lock.json index 80e38040423..85a3b417c20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@vitest/browser": "^4.0.3", "@vitest/browser-playwright": "^4.0.3", "@vitest/eslint-plugin": "^1.3.24", + "@vitest/ui": "^4.0.3", "babel-plugin-react-compiler": "^1.0.0", "eslint": "^9.39.1", "eslint-import-resolver-typescript": "3.7.0", @@ -9876,6 +9877,65 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.3.tgz", + "integrity": "sha512-HURRrgGVzz2GQ2Imurp55FA+majHXgCXMzcwtojUZeRsAXyHNgEvxGRJf4QQY4kJeVakiugusGYeUqBgZ/xylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.3", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.3" + } + }, + "node_modules/@vitest/ui/node_modules/@vitest/pretty-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.3.tgz", + "integrity": "sha512-N7gly/DRXzxa9w9sbDXwD9QNFYP2hw90LLLGDobPNwiWgyW95GMxsCt29/COIKKh3P7XJICR38PSDePenMBtsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui/node_modules/@vitest/utils": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.3.tgz", + "integrity": "sha512-qV6KJkq8W3piW6MDIbGOmn1xhvcW4DuA07alqaQ+vdx7YA49J85pnwnxigZVQFQw3tWnQNRKWwhz5wbP6iv/GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.3", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@vitest/utils": { "version": "3.2.4", "dev": true, @@ -14651,6 +14711,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/figures": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", @@ -27527,6 +27594,7 @@ "@types/styled-components": "^5.1.26", "@vitejs/plugin-react": "^4.3.3", "babel-plugin-styled-components": "2.1.4", + "postcss-preset-primer": "^0.0.0", "publint": "^0.3.15", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/package.json b/package.json index 6836581153f..5f62c14807c 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@vitest/browser": "^4.0.3", "@vitest/browser-playwright": "^4.0.3", "@vitest/eslint-plugin": "^1.3.24", + "@vitest/ui": "^4.0.3", "babel-plugin-react-compiler": "^1.0.0", "eslint": "^9.39.1", "eslint-import-resolver-typescript": "3.7.0", diff --git a/packages/styled-react/config/vitest/visual/global.css b/packages/styled-react/config/vitest/visual/global.css new file mode 100644 index 00000000000..bfece00e01e --- /dev/null +++ b/packages/styled-react/config/vitest/visual/global.css @@ -0,0 +1,88 @@ +/* stylelint-disable selector-pseudo-class-no-unknown */ + +@import '@primer/primitives/dist/css/base/motion/motion.css'; +@import '@primer/primitives/dist/css/base/size/size.css'; +@import '@primer/primitives/dist/css/base/typography/typography.css'; +@import '@primer/primitives/dist/css/functional/size/border.css'; +@import '@primer/primitives/dist/css/functional/size/breakpoints.css'; +@import '@primer/primitives/dist/css/functional/size/size-coarse.css'; +@import '@primer/primitives/dist/css/functional/size/size-fine.css'; +@import '@primer/primitives/dist/css/functional/size/size.css'; +@import '@primer/primitives/dist/css/functional/size/viewport.css'; +@import '@primer/primitives/dist/css/functional/themes/dark-colorblind.css'; +@import '@primer/primitives/dist/css/functional/themes/dark-dimmed.css'; +@import '@primer/primitives/dist/css/functional/themes/dark-high-contrast.css'; +@import '@primer/primitives/dist/css/functional/themes/dark-tritanopia.css'; +@import '@primer/primitives/dist/css/functional/themes/dark.css'; +@import '@primer/primitives/dist/css/functional/themes/light-colorblind.css'; +@import '@primer/primitives/dist/css/functional/themes/light-high-contrast.css'; +@import '@primer/primitives/dist/css/functional/themes/light-tritanopia.css'; +@import '@primer/primitives/dist/css/functional/themes/light.css'; +@import '@primer/primitives/dist/css/functional/typography/typography.css'; + +* { + box-sizing: border-box; +} + +body { + color: var(--fgColor-default); + background-color: var(--bgColor-default); + font-family: var(--fontStack-system); + line-height: var(--text-body-lineHeight-large); + margin: 0; +} + +table { + /* stylelint-disable-next-line primer/borders */ + border-collapse: collapse; +} + +[data-color-mode='light'] input { + color-scheme: light; +} + +[data-color-mode='dark'] input { + color-scheme: dark; +} + +@media (prefers-color-scheme: light) { + [data-color-mode='auto'][data-light-theme*='light'] { + color-scheme: light; + } +} + +@media (prefers-color-scheme: dark) { + [data-color-mode='auto'][data-dark-theme*='dark'] { + color-scheme: dark; + } +} + +[role='button']:focus:not(:focus-visible):not(:global(.focus-visible)), +/* stylelint-disable-next-line selector-max-specificity */ +[role='tabpanel'][tabindex='0']:focus:not(:focus-visible):not(:global(.focus-visible)), +button:focus:not(:focus-visible):not(:global(.focus-visible)), +summary:focus:not(:focus-visible):not(:global(.focus-visible)), +a:focus:not(:focus-visible):not(:global(.focus-visible)) { + outline: none; + box-shadow: none; +} + +[tabindex='0']:focus:not(:focus-visible):not(:global(.focus-visible)), +details-dialog:focus:not(:focus-visible):not(:global(.focus-visible)) { + outline: none; +} + +/* https://vitest.dev/guide/browser/visual-regression-testing.html#disable-animations */ +*, +*::before, +*::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; +} + +[data-testid='screenshot'] { + display: inline-flex; + padding: 1rem; +} diff --git a/packages/styled-react/config/vitest/visual/setup.ts b/packages/styled-react/config/vitest/visual/setup.ts new file mode 100644 index 00000000000..7a36d76957b --- /dev/null +++ b/packages/styled-react/config/vitest/visual/setup.ts @@ -0,0 +1,14 @@ +import {beforeEach} from 'vitest' +import './global.css' +import {updateGlobalTheme} from '../../../src/test-helpers/themes' + +// eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope +document.documentElement.setAttribute('data-color-mode', 'auto') +// eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope +document.documentElement.setAttribute('data-light-theme', 'light') +// eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope +document.documentElement.setAttribute('data-dark-theme', 'dark') + +beforeEach(() => { + updateGlobalTheme('light') +}) diff --git a/packages/styled-react/package.json b/packages/styled-react/package.json index 252f4986a42..94a12fab88d 100644 --- a/packages/styled-react/package.json +++ b/packages/styled-react/package.json @@ -50,6 +50,7 @@ "@types/styled-components": "^5.1.26", "@vitejs/plugin-react": "^4.3.3", "babel-plugin-styled-components": "2.1.4", + "postcss-preset-primer": "^0.0.0", "publint": "^0.3.15", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/packages/styled-react/src/components/__tests__/Button.visual.test.tsx b/packages/styled-react/src/components/__tests__/Button.visual.test.tsx new file mode 100644 index 00000000000..bc131926f9c --- /dev/null +++ b/packages/styled-react/src/components/__tests__/Button.visual.test.tsx @@ -0,0 +1,31 @@ +import {render} from '@testing-library/react' +import {describe, test, expect} from 'vitest' +import {page} from 'vitest/browser' +import {ButtonComponent as Button} from '../Button' +import {themes, updateGlobalTheme} from '../../test-helpers/themes' + +describe('Button', () => { + test.each(themes)('color with sx prop (%s) @vrt', async theme => { + updateGlobalTheme(theme) + + render( +
+ +
, + ) + + await expect(page.getByTestId('screenshot')).toMatchScreenshot() + }) + + test.each(themes)('font size with sx prop (%s) @vrt', async theme => { + updateGlobalTheme(theme) + + render( +
+ +
, + ) + + await expect(page.getByTestId('screenshot')).toMatchScreenshot() + }) +}) diff --git a/packages/styled-react/src/test-helpers/themes.ts b/packages/styled-react/src/test-helpers/themes.ts new file mode 100644 index 00000000000..9f3feb127bf --- /dev/null +++ b/packages/styled-react/src/test-helpers/themes.ts @@ -0,0 +1,31 @@ +const lightThemes = ['light', 'light_high_contrast', 'light_colorblind', 'light_tritanopia'] as const + +type LightTheme = (typeof lightThemes)[number] + +const darkThemes = ['dark', 'dark_dimmed', 'dark_high_contrast', 'dark_colorblind', 'dark_tritanopia'] as const + +type DarkTheme = (typeof darkThemes)[number] + +const themes = [...lightThemes, ...darkThemes] + +type Theme = (typeof themes)[number] + +type ColorMode = 'light' | 'dark' | 'auto' + +type ThemeOptions = { + colorMode?: ColorMode + lightTheme?: LightTheme + darkTheme?: DarkTheme +} + +function updateGlobalTheme(theme: Theme) { + if (lightThemes.includes(theme as LightTheme)) { + document.documentElement.setAttribute('data-color-mode', 'light') + document.documentElement.setAttribute('data-light-theme', theme) + } else { + document.documentElement.setAttribute('data-color-mode', 'dark') + document.documentElement.setAttribute('data-dark-theme', theme) + } +} + +export {themes, updateGlobalTheme} diff --git a/packages/styled-react/vitest.config.ts b/packages/styled-react/vitest.config.ts index 3b3a6e4c3d0..fef73223966 100644 --- a/packages/styled-react/vitest.config.ts +++ b/packages/styled-react/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { name: '@primer/styled-react (node)', environment: 'node', - exclude: ['src/**/*.browser.test.?(c|m)[jt]s?(x)'], + exclude: ['src/**/*.browser.test.?(c|m)[jt]s?(x)', 'src/**/*.visual.test.?(c|m)[jt]s?(x)'], }, }) diff --git a/packages/styled-react/vitest.config.vrt.ts b/packages/styled-react/vitest.config.vrt.ts new file mode 100644 index 00000000000..1bd253d1fa7 --- /dev/null +++ b/packages/styled-react/vitest.config.vrt.ts @@ -0,0 +1,56 @@ +import path from 'node:path' +import react from '@vitejs/plugin-react' +import {playwright} from '@vitest/browser-playwright' +import {defineConfig} from 'vitest/config' +import postcssPresetPrimer from 'postcss-preset-primer' + +export default defineConfig({ + css: { + modules: { + generateScopedName: 'prc-[folder]-[local]-[hash:base64:5]', + }, + postcss: { + plugins: [postcssPresetPrimer()], + }, + }, + plugins: [react()], + define: { + __DEV__: true, + }, + resolve: { + alias: [ + { + find: '@primer/react/experimental', + replacement: path.resolve(import.meta.dirname, '..', 'react', 'src', 'experimental', 'index.ts'), + }, + { + find: '@primer/react/deprecated', + replacement: path.resolve(import.meta.dirname, '..', 'react', 'src', 'deprecated', 'index.ts'), + }, + { + find: '@primer/react', + replacement: path.resolve(import.meta.dirname, '..', 'react', 'src', 'index.ts'), + }, + ], + }, + test: { + name: '@primer/styled-react (vrt)', + include: ['src/**/*.visual.test.?(c|m)[jt]s?(x)'], + setupFiles: ['config/vitest/browser/setup.ts', 'config/vitest/visual/setup.ts'], + browser: { + provider: playwright(), + enabled: true, + headless: process.env.DEBUG_BROWSER_TESTS === 'true' ? false : true, + instances: [ + { + browser: 'chromium', + viewport: { + width: 320, + height: 320, + }, + }, + ], + screenshotFailures: false, + }, + }, +}) diff --git a/script/test-vrt b/script/test-vrt new file mode 100755 index 00000000000..d24bf2097df --- /dev/null +++ b/script/test-vrt @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +PLAYWRIGHT_VERSION=$(npm --json list @playwright/test | jq --raw-output '.dependencies["@playwright/test"].version') + +if [ -z "$PLAYWRIGHT_VERSION" ]; then + echo "Unable to find Playwright version. Make sure @playwright/test is installed." + exit 1 +fi + +echo "Using Playwright version $PLAYWRIGHT_VERSION" + +docker run --rm \ + --network host \ + -v $(pwd):/workspace \ + -w /workspace \ + -it "mcr.microsoft.com/playwright:v$PLAYWRIGHT_VERSION-jammy" \ + /bin/bash -c "npm install && npx vitest --project '@primer/styled-react (vrt)' --run $@" + diff --git a/vitest.config.mts b/vitest.config.mts index 56ca8d03eb7..1a1b400f201 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -7,6 +7,7 @@ export default defineConfig({ 'packages/*/vitest.config.mts', 'packages/*/vitest.config.browser.ts', 'packages/*/vitest.config.browser.mts', + 'packages/*/vitest.config.vrt.ts', ], }, })