diff --git a/.changeset/mosaic-visual-tests.md b/.changeset/mosaic-visual-tests.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-visual-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc4df9d2fb1..726af1e422e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -440,6 +440,68 @@ jobs: retention-days: 1 if-no-files-found: ignore + mosaic-visual-tests: + needs: [check-permissions, build-packages] + name: Mosaic Visual Tests + permissions: + contents: read + runs-on: 'blacksmith-8vcpu-ubuntu-2204' + defaults: + run: + shell: bash + timeout-minutes: 15 + + steps: + - name: Checkout Repo + uses: actions/checkout@v4.4.0 + with: + persist-credentials: false + fetch-depth: 1 + fetch-tags: false + filter: 'blob:none' + show-progress: false + + - name: Setup + uses: $/.github/actions/init-blacksmith + with: + cache-enabled: true + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + turbo-team: ${{ vars.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + + - name: Build Mosaic and its dependencies + run: pnpm turbo build $TURBO_ARGS --filter=@clerk/mosaic... + + - name: Run visual tests + id: visual + run: | + status=0 + bash packages/mosaic/scripts/test-visual.sh compare || status=$? + if [[ "$status" -ne 0 ]] && find packages/mosaic/.vitest/attachments -name '*.png' -print -quit 2>/dev/null | grep -q .; then + echo "differences=true" >> "$GITHUB_OUTPUT" + elif [[ "$status" -ne 0 ]]; then + exit "$status" + fi + + - name: Report visual differences + if: ${{ steps.visual.outputs.differences == 'true' }} + run: | + echo "::warning title=Mosaic visual differences::Screenshots differ from baselines. Download mosaic-visual-failure-artifacts-${{ github.run_id }}-${{ github.run_attempt }} to review." + echo "### Mosaic screenshots differ from baselines" >> "$GITHUB_STEP_SUMMARY" + find packages/mosaic/.vitest/attachments -name '*.png' -printf '- %P\n' >> "$GITHUB_STEP_SUMMARY" || true + + - name: Upload failure artifacts + if: ${{ steps.visual.outputs.differences == 'true' }} + uses: actions/upload-artifact@v4.6.2 + with: + name: mosaic-visual-failure-artifacts-${{ github.run_id }}-${{ github.run_attempt }} + path: | + packages/mosaic/.vitest/attachments + packages/mosaic/src/**/__screenshots__/**/*-chromium-linux.png + include-hidden-files: true + retention-days: 7 + if-no-files-found: warn + integration-tests: needs: [check-permissions, build-packages] if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/packages/mosaic/docs/testing.md b/packages/mosaic/docs/testing.md index 8ddb38e614e..6fd8f54ead2 100644 --- a/packages/mosaic/docs/testing.md +++ b/packages/mosaic/docs/testing.md @@ -36,8 +36,10 @@ Rule of thumb: **test each behavior once, at the boundary that owns it.** | Feature | `*.feature.test.tsx` | Chromium (`pnpm test:feature`) | A feature end to end: real `Clerk`, real layers, FAPI faked with MSW | | E2E | `/integration` (Playwright) | Real apps against a real Clerk backend | Framework wiring, redirects, real sessions. Not written per Mosaic flow | -Visual states (loading, empty, every error, every variant) are swingset stories -with plain props, reviewed by eye. Don't duplicate them as view tests. +Most visual states (loading, empty, every error, every variant) are swingset +stories with plain props, reviewed by eye. A small screenshot suite covers +representative compositions, as described in [Screenshot tests](#screenshot-tests). +Don't duplicate every story as a view or screenshot test. Each behavior lives in one tier. A feature test owns everything the feature does on screen, including edge cases, busy states, and errors. E2E only proves @@ -99,8 +101,8 @@ pnpm --filter @clerk/mosaic test:browser # layout tests (Chromium) pnpm --filter @clerk/mosaic exec vitest run # every project ``` -CI runs feature tests in their own `Mosaic Feature Tests` job, so the repo-wide -unit test job never needs a browser. +CI runs feature and screenshot tests in separate `Mosaic Feature Tests` and +`Mosaic Visual Tests` jobs, so the repo-wide unit test job never needs a browser. jsdom and happy-dom have no layout engine, so every rect is 0 and a test that asserts on position, size or scrolling always passes there. Name such a test @@ -111,6 +113,56 @@ import the built `@clerk/clerk-js`, so run `pnpm turbo build --filter=@clerk/cle after changing it. Failure screenshots land in `packages/mosaic/.vitest/`. Console output from passing tests only shows with `--reporter=verbose`. +## Screenshot tests + +The `visual` project runs `*.visual.test.tsx` in Chromium, using the feature +project's Clerk and MSW setup. Tests and their fixtures live in Mosaic, beside +the feature they cover. They do not import Swingset stories or fixtures. + +Screenshots are generated and compared only inside one digest-pinned Playwright +image, locally and in CI, because fonts and Chromium draw slightly differently +on each operating system. + +```bash +pnpm --filter @clerk/mosaic test:visual +pnpm --filter @clerk/mosaic test:visual:update +``` + +Both commands require Docker. Locally, they mount your checkout into the image +and install Linux dependencies into Docker volumes, so your own `node_modules` +is untouched and later runs reuse the install. The first command compares +screenshots without changing baselines. The second deletes the Linux baselines, +lets Vitest write every screenshot again, and restores the old ones if the run +fails, so new screenshots are created and ones no test produces are removed. +Review the changed images and commit them under each test's `__screenshots__/` +directory. Failed comparisons write actual, expected, and diff images under +`packages/mosaic/.vitest/attachments/`. + +Use these package commands for screenshots. Calling Vitest directly bypasses +the pinned image. + +Add a screenshot only when it covers a distinct layout or visual state that the +existing ones miss. Capture the element under test (a dialog, a popup, or the +rendered container), not `page`, so page padding and unrelated content stay out +of the baseline. Content taller than the viewport is cut off, so resize the +viewport to the content's height first, as the user profile tests do. + +The suite fixes the viewport before each test, the bundled Inter font, color +scheme, locale, time zone, and device scale, and compares with zero mismatched +pixels allowed. The browser setup disables motion and hides carets. Use deterministic +fixture data and avatar fallbacks, and wait for content before capturing the +page so portaled menus and overlays are included. + +Baselines include the browser and operating system in their filenames. The +`Mosaic Visual Tests` job builds the workspace on the runner, then compares in +the same pinned image. Its comparison is advisory: screenshot differences +appear as a warning and in the job summary without blocking the rest of CI. A +failed build, or a comparison that crashes before writing images, still fails +the job. On a difference, download +`mosaic-visual-failure-artifacts--` from the run and review +the images in `.vitest/attachments/`, then run `test:visual:update` locally and +commit the approved baselines. + ## Feature tests A feature test runs the real `Clerk` from `@clerk/clerk-js` in Chromium. MSW diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index a15e26ebdaa..48e2e092992 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -61,6 +61,8 @@ "test:browser": "vitest run --project browser", "test:ci": "vitest run --project primitives --project mosaic --maxWorkers=70%", "test:feature": "vitest run --project feature", + "test:visual": "bash ./scripts/test-visual.sh compare", + "test:visual:update": "bash ./scripts/test-visual.sh update", "test:webkit": "vitest run --project webkit", "typecheck": "tsc --noEmit" }, diff --git a/packages/mosaic/scripts/test-visual.sh b/packages/mosaic/scripts/test-visual.sh new file mode 100644 index 00000000000..2c7a4967b31 --- /dev/null +++ b/packages/mosaic/scripts/test-visual.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd) +image=mcr.microsoft.com/playwright:v1.62.1-noble@sha256:dcc5531e97840b9b5e794f2814476b21571c5124a3fca2267d73041f56e7580e +vitest='node /repo/node_modules/vitest/vitest.mjs run --project visual' + +case "${1:-}" in + compare) update=none ;; + update) update=all ;; + *) + printf 'Usage: %s compare|update\n' "$0" >&2 + exit 2 + ;; +esac + +run=(docker run --rm --init --ipc=host --platform linux/amd64 --volume "$root:/repo" --workdir /repo/packages/mosaic) + +if [[ "${CI:-}" == true ]]; then + run+=(--user "$(id -u):$(id -g)" --env HOME=/tmp "$image" sh -c "$vitest --update $update") +else + run+=(--volume clerk-mosaic-visual-store:/pnpm-store --volume clerk-mosaic-visual-modules:/repo/node_modules) + for package in "$root"/packages/*/ "$root"/packages/electron-passkeys/npm/*/; do + relative=${package#"$root"/} + run+=(--volume "clerk-mosaic-visual-${relative//\//-}modules:/repo/${relative}node_modules") + done + run+=("$image" sh -c "corepack enable && pnpm install --frozen-lockfile --store-dir /pnpm-store && pnpm turbo build --filter=@clerk/mosaic... && $vitest --update $update") +fi + +baselines=$(find "$root/packages/mosaic/src" -path '*/__screenshots__/*-chromium-linux.png') +rm -rf "$root/packages/mosaic/.vitest/attachments" +if [[ "$update" == all && -n "$baselines" ]]; then + printf '%s\n' "$baselines" | xargs rm +fi + +status=0 +"${run[@]}" || status=$? + +if [[ "$update" == all && "$status" -ne 0 ]]; then + git -C "$root" checkout -- ':(glob)packages/mosaic/src/**/__screenshots__/**' 2>/dev/null || true +fi +exit "$status" diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/__screenshots__/organization-switcher.visual.test.tsx/organization-switcher-open-chromium-linux.png b/packages/mosaic/src/features/organization-switcher/__tests__/__screenshots__/organization-switcher.visual.test.tsx/organization-switcher-open-chromium-linux.png new file mode 100644 index 00000000000..9c10350b68a Binary files /dev/null and b/packages/mosaic/src/features/organization-switcher/__tests__/__screenshots__/organization-switcher.visual.test.tsx/organization-switcher-open-chromium-linux.png differ diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.visual.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.visual.test.tsx new file mode 100644 index 00000000000..d9f312f8abd --- /dev/null +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.visual.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { openSwitcher, serveSwitcherFixture } from '../../switcher/__tests__/switcher.visual.fixtures'; +import { OrganizationSwitcher } from '../organization-switcher'; + +describe('OrganizationSwitcher visual state', () => { + it('shows the organization menu open', async () => { + serveSwitcherFixture(); + await renderWithClerk(); + const popup = await openSwitcher(/Open organization menu/); + + await expect(popup).toMatchScreenshot('organization-switcher-open'); + }); +}); diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.visual.fixtures.ts b/packages/mosaic/src/features/switcher/__tests__/switcher.visual.fixtures.ts new file mode 100644 index 00000000000..b5c29b733e6 --- /dev/null +++ b/packages/mosaic/src/features/switcher/__tests__/switcher.visual.fixtures.ts @@ -0,0 +1,56 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { page } from 'vitest/browser'; + +import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiInvitation, + fapiMembership, + fapiOrganization, + fapiSession, + fapiSuggestion, + fapiUser, +} from '../../../__tests__/feature/fapi'; + +type FapiSession = ReturnType; + +export function serveSwitcherFixture({ otherSessions = [] }: { otherSessions?: FapiSession[] } = {}) { + const acme = fapiOrganization({ id: 'org_acme', name: 'Acme Studio', members_count: 12 }); + const orbit = fapiOrganization({ id: 'org_orbit', name: 'Orbit Labs', members_count: 8 }); + const invitation = fapiOrganization({ id: 'org_north', name: 'Northstar Design' }); + const suggestion = fapiOrganization({ id: 'org_field', name: 'Fieldwork' }); + const memberships = [fapiMembership(acme, { permissions: ['org:sys_memberships:manage'] }), fapiMembership(orbit)]; + const user = fapiUser({ + id: 'user_alice', + first_name: 'Alice', + last_name: 'Rivera', + username: 'alice', + email_addresses: [fapiEmailAddress({ id: 'email_alice', email_address: 'alice@example.com' })], + organization_memberships: memberships, + }); + serveFapi({ + client: fapiClient([ + fapiSession({ id: 'session_alice', user, last_active_organization_id: acme.id }), + ...otherSessions, + ]), + memberships, + invitations: [fapiInvitation('invitation_north', invitation)], + suggestions: [fapiSuggestion('suggestion_field', suggestion)], + }); +} + +export async function openSwitcher(trigger: RegExp) { + await userEvent.setup().click(screen.getByRole('button', { name: trigger })); + await screen.findByRole('button', { name: 'Orbit Labs' }); + await screen.findByRole('button', { name: 'Accept' }); + await screen.findByText('Fieldwork'); + await waitFor(() => { + if (screen.queryByText('Loading organizations…')) { + throw new Error('Organizations are still loading'); + } + }); + await document.fonts.ready; + return page.elementLocator(screen.getByRole('dialog')); +} diff --git a/packages/mosaic/src/features/user-button/__tests__/__screenshots__/user-button.visual.test.tsx/user-button-combined-open-chromium-linux.png b/packages/mosaic/src/features/user-button/__tests__/__screenshots__/user-button.visual.test.tsx/user-button-combined-open-chromium-linux.png new file mode 100644 index 00000000000..f060d366702 Binary files /dev/null and b/packages/mosaic/src/features/user-button/__tests__/__screenshots__/user-button.visual.test.tsx/user-button-combined-open-chromium-linux.png differ diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.visual.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.visual.test.tsx new file mode 100644 index 00000000000..b1f7a3c4ced --- /dev/null +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.visual.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { fapiEmailAddress, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { openSwitcher, serveSwitcherFixture } from '../../switcher/__tests__/switcher.visual.fixtures'; +import { UserButton } from '../user-button'; + +describe('UserButton visual state', () => { + it('shows the combined account and organization menu open', async () => { + const bob = fapiUser({ + id: 'user_bob', + first_name: 'Bob', + last_name: 'Morgan', + email_addresses: [fapiEmailAddress({ id: 'email_bob', email_address: 'bob@example.com' })], + }); + serveSwitcherFixture({ otherSessions: [fapiSession({ id: 'session_bob', user: bob })] }); + await renderWithClerk(); + const popup = await openSwitcher(/Open account menu/); + + await expect(popup).toMatchScreenshot('user-button-combined-open'); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-account-desktop-dark-chromium-linux.png b/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-account-desktop-dark-chromium-linux.png new file mode 100644 index 00000000000..e334c393289 Binary files /dev/null and b/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-account-desktop-dark-chromium-linux.png differ diff --git a/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-account-desktop-light-chromium-linux.png b/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-account-desktop-light-chromium-linux.png new file mode 100644 index 00000000000..d4327bcde11 Binary files /dev/null and b/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-account-desktop-light-chromium-linux.png differ diff --git a/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-account-narrow-light-chromium-linux.png b/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-account-narrow-light-chromium-linux.png new file mode 100644 index 00000000000..e6a202e6ae1 Binary files /dev/null and b/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-account-narrow-light-chromium-linux.png differ diff --git a/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-edit-name-error-chromium-linux.png b/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-edit-name-error-chromium-linux.png new file mode 100644 index 00000000000..bcd2a7eb7eb Binary files /dev/null and b/packages/mosaic/src/features/user-profile/__tests__/__screenshots__/user-profile.visual.test.tsx/user-profile-edit-name-error-chromium-linux.png differ diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.visual.fixtures.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile.visual.fixtures.ts new file mode 100644 index 00000000000..85e38ef36f8 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.visual.fixtures.ts @@ -0,0 +1,86 @@ +import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnvironment, + fapiExternalAccount, + fapiPhoneNumber, + fapiSession, + fapiUser, + fapiVerification, + fapiWeb3Wallet, +} from '../../../__tests__/feature/fapi'; + +type VisualProfile = { + firstName: string; + lastName: string; + username: string; + emails: string[]; + phones: string[]; +}; + +export const shortProfile: VisualProfile = { + firstName: 'Alex', + lastName: 'Morgan', + username: 'alexmorgan', + emails: ['alex@example.com', 'alex.work@example.com'], + phones: ['+14155550123'], +}; + +export const longProfile: VisualProfile = { + firstName: 'Alexandria', + lastName: 'Montgomery-Wellington', + username: 'alexandria.montgomery', + emails: [ + 'alexandria.montgomery@longcompanydomain.example', + 'alexandria.montgomery.personal@example.com', + 'alexandria.montgomery.notifications@example.com', + ], + phones: ['+14155550123', '+14155550456'], +}; + +export function serveVisualProfile(profile: VisualProfile) { + const user = fapiUser({ + id: 'user_alex', + first_name: profile.firstName, + last_name: profile.lastName, + username: profile.username, + email_addresses: profile.emails.map((email_address, index) => + fapiEmailAddress({ id: `email_${index}`, email_address }), + ), + phone_numbers: profile.phones.map((phone_number, index) => fapiPhoneNumber({ id: `phone_${index}`, phone_number })), + primary_phone_number_id: 'phone_0', + web3_wallets: [ + fapiWeb3Wallet({ + id: 'wallet_primary', + web3_wallet: '0x1234567890abcdef1234567890abcdef12345678', + verification: fapiVerification('admin', { status: 'verified' }), + }), + ], + primary_web3_wallet_id: 'wallet_primary', + external_accounts: [ + fapiExternalAccount({ id: 'idn_google', provider: 'google', email_address: profile.emails[0] }), + ], + }); + serveFapi({ + environment: fapiEnvironment({ + attributes: { + phone_number: { enabled: true }, + web3_wallet: { enabled: true }, + }, + user_settings: { + social: { + oauth_google: { + enabled: true, + required: false, + authenticatable: true, + strategy: 'oauth_google', + name: 'Google', + logo_url: null, + }, + }, + }, + }), + client: fapiClient([fapiSession({ id: 'session_alex', user })]), + }); +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.visual.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.visual.test.tsx new file mode 100644 index 00000000000..fcfeed69590 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.visual.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; + +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicProvider } from '../../../mosaic-provider'; +import { SaveError } from '../../../utils/errors'; +import { UserProfile } from '../user-profile'; +import { useUserProfileEditNameController } from '../user-profile-profile-section/user-profile-edit-name.controller'; +import { UserProfileEditNameDialog } from '../user-profile-profile-section/user-profile-edit-name.dialog'; +import { longProfile, serveVisualProfile, shortProfile } from './user-profile.visual.fixtures'; + +async function renderProfile(profile = shortProfile) { + serveVisualProfile(profile); + const { container } = await renderWithClerk(); + await screen.findByRole('heading', { name: 'Profile' }); + await screen.findByRole('heading', { name: 'Email' }); + await screen.findByText('Google'); + await document.fonts.ready; + await page.viewport(window.innerWidth, document.documentElement.scrollHeight); + return container; +} + +function FailedEditName() { + const { form } = useUserProfileEditNameController({ + firstName: 'Alex', + lastName: 'Morgan', + onSubmit: () => + Promise.reject( + new SaveError({ + global: { code: 'internal_clerk_error', message: 'Your name could not be updated.' }, + fields: { + lastName: { code: 'form_param_max_length_exceeded', message: 'Last name must be 64 characters or fewer.' }, + }, + }), + ), + }); + return ( + {}} + form={form} + /> + ); +} + +afterEach(() => { + document.documentElement.style.colorScheme = ''; +}); + +describe('UserProfile visual states', () => { + it('shows the account page on desktop in light mode', async () => { + const profile = await renderProfile(); + + await expect(page.elementLocator(profile)).toMatchScreenshot('user-profile-account-desktop-light'); + }); + + it('shows the account page on desktop in dark mode', async () => { + document.documentElement.style.colorScheme = 'dark'; + const profile = await renderProfile(); + + await expect(page.elementLocator(profile)).toMatchScreenshot('user-profile-account-desktop-dark'); + }); + + it('shows long names and several contacts in the narrow account page', async () => { + await page.viewport(390, 844); + const profile = await renderProfile(longProfile); + await screen.findAllByText('alexandria.montgomery@longcompanydomain.example'); + const viewport = profile.querySelector('.cl-profile-content-viewport'); + if (!viewport) { + throw new Error('User profile content viewport is missing'); + } + expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth); + + await expect(page.elementLocator(profile)).toMatchScreenshot('user-profile-account-narrow-light'); + }); + + it('shows a failed edit with its banner and last name field error', async () => { + render( + + + , + ); + const user = userEvent.setup(); + await user.type(screen.getByRole('textbox', { name: 'Last name' }), 'x'); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Your name could not be updated.')); + expect(screen.getByRole('textbox', { name: 'Last name' })).toHaveAccessibleDescription( + 'Last name must be 64 characters or fewer.', + ); + await document.fonts.ready; + + await expect(page.elementLocator(screen.getByRole('dialog'))).toMatchScreenshot('user-profile-edit-name-error'); + }); +}); diff --git a/packages/mosaic/test/public/fonts/InterVariable.woff2 b/packages/mosaic/test/public/fonts/InterVariable.woff2 new file mode 100644 index 00000000000..5a8d3e72ad7 Binary files /dev/null and b/packages/mosaic/test/public/fonts/InterVariable.woff2 differ diff --git a/packages/mosaic/test/public/fonts/OFL.txt b/packages/mosaic/test/public/fonts/OFL.txt new file mode 100644 index 00000000000..9b2ca37b3ff --- /dev/null +++ b/packages/mosaic/test/public/fonts/OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts index 4d9b79a4415..0c4a45bea5e 100644 --- a/packages/mosaic/vitest.config.mts +++ b/packages/mosaic/vitest.config.mts @@ -8,8 +8,18 @@ import { mosaicLightningCssTargets } from './stylex-lightningcss.config.mjs'; const mosaicPath = resolve(import.meta.dirname, 'src'); const featureTests = 'src/**/*.feature.test.tsx'; +const visualTests = 'src/**/*.visual.test.tsx'; const webkitTests = 'src/**/*.webkit.test.tsx'; const browserTests = 'src/**/*.browser.test.tsx'; +const browserDependencies = [ + '@clerk/clerk-js', + '@wallet-standard/core', + '@testing-library/jest-dom/matchers', + '@testing-library/react', + '@testing-library/user-event', + 'msw', + 'msw/browser', +]; export default defineConfig({ plugins: [ @@ -44,7 +54,7 @@ export default defineConfig({ name: 'primitives', environment: 'happy-dom', include: ['src/primitives/**/*.test.?(c|m)[jt]s?(x)', 'src/primitives/**/*.spec.?(c|m)[jt]s?(x)'], - exclude: [webkitTests, browserTests], + exclude: [webkitTests, browserTests, visualTests], setupFiles: ['./src/primitives/test-utils/vitest.setup.ts'], }, }, @@ -53,7 +63,7 @@ export default defineConfig({ test: { name: 'mosaic', include: ['**/*.test.?(c|m)[jt]s?(x)', '**/*.spec.?(c|m)[jt]s?(x)'], - exclude: ['src/primitives/**', featureTests, webkitTests, browserTests], + exclude: ['src/primitives/**', featureTests, webkitTests, browserTests, visualTests], setupFiles: [resolve(import.meta.dirname, 'vitest.setup.mts')], }, }, @@ -61,15 +71,7 @@ export default defineConfig({ extends: true, publicDir: 'test/public', optimizeDeps: { - include: [ - '@clerk/clerk-js', - '@wallet-standard/core', - '@testing-library/jest-dom/matchers', - '@testing-library/react', - '@testing-library/user-event', - 'msw', - 'msw/browser', - ], + include: browserDependencies, }, test: { name: 'feature', @@ -83,6 +85,40 @@ export default defineConfig({ }, }, }, + { + extends: true, + publicDir: 'test/public', + optimizeDeps: { include: [...browserDependencies, 'qrcode.react'] }, + test: { + name: 'visual', + include: [visualTests], + setupFiles: [ + resolve(import.meta.dirname, 'vitest.setup.browser.mts'), + resolve(import.meta.dirname, 'vitest.setup.visual.mts'), + ], + browser: { + enabled: true, + headless: true, + viewport: { width: 1280, height: 900 }, + provider: playwright({ + contextOptions: { + colorScheme: 'light', + deviceScaleFactor: 1, + locale: 'en-US', + timezoneId: 'UTC', + reducedMotion: 'reduce', + }, + }), + instances: [{ browser: 'chromium' }], + expect: { + toMatchScreenshot: { + comparatorName: 'pixelmatch', + comparatorOptions: { threshold: 0, allowedMismatchedPixelRatio: 0 }, + }, + }, + }, + }, + }, { extends: true, test: { diff --git a/packages/mosaic/vitest.setup.visual.mts b/packages/mosaic/vitest.setup.visual.mts new file mode 100644 index 00000000000..1efeeb4f03e --- /dev/null +++ b/packages/mosaic/vitest.setup.visual.mts @@ -0,0 +1,38 @@ +import { beforeEach } from 'vitest'; +import { page } from 'vitest/browser'; + +import './dist/styles.css'; + +const styles = document.createElement('style'); +styles.textContent = ` + @font-face { + font-family: 'Inter Visual'; + src: url('/fonts/InterVariable.woff2') format('woff2'); + font-style: normal; + font-weight: 100 900; + } + :root { + font-family: 'Inter Visual', sans-serif; + background: light-dark(white, #171717); + color: light-dark(black, white); + } + *, *::before, *::after { + caret-color: transparent !important; + } + body { + margin: 0; + padding: 24px; + } +`; +document.head.append(styles); + +beforeEach(async () => { + await page.viewport(1280, 900); + for (const weight of [400, 500, 600]) { + const faces = await document.fonts.load(`${weight} 16px "Inter Visual"`); + if (faces.length === 0) { + throw new Error(`Inter Visual ${weight} failed to load`); + } + } + await document.fonts.ready; +});