diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c582b545..c52c38b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,9 +154,59 @@ jobs: if-no-files-found: error retention-days: 14 + frontend-screen-lab: + name: Visual / Screen Lab (${{ matrix.shard }}/2) + needs: build + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2] + + steps: + - name: Check out repository + uses: actions/checkout@v6.0.3 + + - name: Set up pnpm + uses: pnpm/action-setup@v6.0.9 + + - name: Set up Node.js + uses: actions/setup-node@v6.4.0 + with: + cache: pnpm + node-version: 24 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download dist artifacts + uses: actions/download-artifact@v8.0.1 + with: + name: dist + path: packages + + - name: Install Chromium + run: pnpm --filter @tinyrack/tinyauth-frontend exec playwright install chromium + + - name: Run Screen Lab visual shard + env: + PLAYWRIGHT_BLOB_OUTPUT_FILE: blob-report/report-${{ matrix.shard }}.zip + run: >- + pnpm --filter @tinyrack/tinyauth-frontend run test:e2e:screen + --shard=${{ matrix.shard }}/2 --workers=100% --reporter=blob + + - name: Upload Screen Lab blob report + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: frontend-screen-lab-${{ matrix.shard }} + path: packages/frontend/blob-report + if-no-files-found: error + retention-days: 14 + test: name: Test - needs: [test-suite, frontend-smoke] + needs: [test-suite, frontend-smoke, frontend-screen-lab] if: always() runs-on: ubuntu-latest @@ -196,10 +246,31 @@ jobs: if-no-files-found: warn retention-days: 14 + - name: Download Screen Lab reports + uses: actions/download-artifact@v8.0.1 + with: + pattern: frontend-screen-lab-* + path: packages/frontend/screen-lab-blob-report + merge-multiple: true + + - name: Merge Screen Lab report + working-directory: packages/frontend + run: pnpm exec playwright merge-reports --reporter=html screen-lab-blob-report + + - name: Upload Screen Lab report + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: frontend-screen-lab-report + path: packages/frontend/playwright-report + if-no-files-found: warn + retention-days: 14 + - name: Verify test results env: SUITE_RESULT: ${{ needs.test-suite.result }} SMOKE_RESULT: ${{ needs.frontend-smoke.result }} + SCREEN_LAB_RESULT: ${{ needs.frontend-screen-lab.result }} run: | if [ "$SUITE_RESULT" != "success" ]; then echo "Test suites finished with result: $SUITE_RESULT" @@ -209,6 +280,10 @@ jobs: echo "Frontend smoke shards finished with result: $SMOKE_RESULT" exit 1 fi + if [ "$SCREEN_LAB_RESULT" != "success" ]; then + echo "Screen Lab shards finished with result: $SCREEN_LAB_RESULT" + exit 1 + fi homepage: name: Homepage diff --git a/package.json b/package.json index a0016825..8cd32b06 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "test": "node packages/tools/src/scripts/run-validation-tests.ts", "build": "pnpm -r --workspace-concurrency=Infinity build", "biome": "biome", + "screen:dev": "pnpm --filter @tinyrack/tinyauth-frontend screen:dev", "tools": "tinyauth-tools", "test:setup:browsers": "pnpm --filter @tinyrack/tinyauth-frontend exec playwright install" }, diff --git a/packages/frontend/e2e/screen-lab/assets.ts b/packages/frontend/e2e/screen-lab/assets.ts new file mode 100644 index 00000000..e68c76f9 --- /dev/null +++ b/packages/frontend/e2e/screen-lab/assets.ts @@ -0,0 +1,33 @@ +import type { Page } from '@playwright/test'; + +/** + * Waits for runtime branding assets that CSS references rather than rendering + * as img elements. Playwright's screenshot font wait cannot observe these. + */ +export async function waitForScreenAssets(page: Page): Promise { + await page.evaluate(async () => { + const background = document.querySelector( + '[style*="background-image"]', + ); + const value = background?.style.backgroundImage; + const url = value?.match(/^url\(["']?(.*?)["']?\)$/)?.[1]; + if (!url) { + return; + } + + await new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(), { once: true }); + image.addEventListener( + 'error', + () => reject(new Error(`Failed to load Screen Lab asset: ${url}`)), + { once: true }, + ); + image.src = url; + }); + + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); +} diff --git a/packages/frontend/e2e/screen-lab/background.svg b/packages/frontend/e2e/screen-lab/background.svg new file mode 100644 index 00000000..49107403 --- /dev/null +++ b/packages/frontend/e2e/screen-lab/background.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/frontend/e2e/screen-lab/catalog.ts b/packages/frontend/e2e/screen-lab/catalog.ts new file mode 100644 index 00000000..d6e24753 --- /dev/null +++ b/packages/frontend/e2e/screen-lab/catalog.ts @@ -0,0 +1,265 @@ +import type { Page } from '@playwright/test'; +import { createProxyHandler } from '@tinyrack/tinyauth-server/frontend/proxy'; +import { + type ServerScreenScenarioId, + screenScenarioDefinitions, +} from '#frontend/test-utils/screen-scenario-catalog.ts'; +import { + createTestConfig, + E2E_BASE_CONFIG, + E2E_TEST_CLIENT_CONFIG, + E2E_TEST_USER, + E2E_TEST_USER_CONFIG, + type E2EConfigInput, +} from '#frontend-e2e/fixtures/index.ts'; +import { registerUserViaApi } from '#frontend-e2e/helpers/account.ts'; +import { buildOAuthAuthorizeUrl } from '#frontend-e2e/helpers/consent.ts'; +import { performLogin } from '#frontend-e2e/helpers/login.ts'; +import { setupTotpViaTestApi } from '#frontend-e2e/helpers/totp.ts'; +import type { + ScreenScenario, + ScreenScenarioConfigFactory, + ServerScreenScenarioAdapter, + ServerScreenScenarioContext, +} from '#frontend-e2e/screen-lab/types.ts'; + +const SCREEN_USER = E2E_TEST_USER; +const SCREEN_USER_CONFIG = E2E_TEST_USER_CONFIG; +const SCREEN_SECOND_USER = { + sub: 'screen-lab-second-user', + email: 'screen-lab-second@example.com', + password: E2E_TEST_USER.password, + role: 'user', +} satisfies NonNullable[number]; +const SCREEN_TOTP_EMAIL = 'screen-lab-totp@example.com'; + +function createScreenConfig( + overrides: Partial = {}, +): ScreenScenarioConfigFactory { + return (backendPort, frontendPort) => { + const { branding, ...restOverrides } = overrides; + return { + ...E2E_BASE_CONFIG, + ...createTestConfig(backendPort, { + ...restOverrides, + branding: branding ?? { + background_url: `http://127.0.0.1:${backendPort}/test/screen-lab-background.svg`, + }, + }), + frontend: createProxyHandler({ + upstream: `http://127.0.0.1:${frontendPort}`, + }), + }; + }; +} + +function createScreenConfigWithRecords( + options: { + overrides?: Partial; + users?: E2EConfigInput['users']; + clients?: E2EConfigInput['clients']; + } = {}, +): ScreenScenarioConfigFactory { + const configFactory = createScreenConfig(options.overrides); + return (backendPort, frontendPort, auxiliaryPort) => ({ + ...configFactory(backendPort, frontendPort, auxiliaryPort), + ...(options.users ? { users: options.users } : {}), + ...(options.clients ? { clients: options.clients } : {}), + }); +} + +async function gotoAndWait( + page: Page, + path: string, + selector: string, +): Promise { + await page.goto(path); + await page.locator(selector).first().waitFor({ state: 'visible' }); +} + +async function loginAndOpen({ + page, + scenario, +}: ServerScreenScenarioContext): Promise { + await performLogin(page, SCREEN_USER.email, SCREEN_USER.password); + await page.waitForURL('**/profile'); + await gotoAndWait(page, scenario.entryPath, scenario.readySelector); +} + +async function loginViaApi( + page: Page, + baseURL: string, + email: string, +): Promise { + const response = await page + .context() + .request.post(`${baseURL}/api/auth/login`, { + data: { email, password: SCREEN_USER.password }, + headers: { Origin: baseURL }, + }); + if (!response.ok()) { + throw new Error( + `Screen Lab login failed: ${response.status()} ${await response.text()}`, + ); + } +} + +const accountConfig = createScreenConfigWithRecords({ + overrides: { + registration: { + enabled: true, + allowed_email_patterns: ['*'], + }, + }, + users: [SCREEN_USER_CONFIG], +}); + +export const serverScreenScenarioAdapters = { + profile: { + config: accountConfig, + prepare: loginAndOpen, + }, + 'admin-users': { + config: createScreenConfigWithRecords({ + overrides: { admin: { enabled: true } }, + users: [SCREEN_USER_CONFIG, SCREEN_SECOND_USER], + }), + prepare: loginAndOpen, + }, + 'oauth-consent': { + config: createScreenConfigWithRecords({ + users: [SCREEN_USER_CONFIG], + clients: [E2E_TEST_CLIENT_CONFIG], + }), + prepare: async ({ page, scenario }) => { + await performLogin(page, SCREEN_USER.email, SCREEN_USER.password); + await page.waitForURL('**/profile'); + await gotoAndWait(page, buildOAuthAuthorizeUrl(), scenario.readySelector); + }, + }, + 'account-selection': { + config: createScreenConfigWithRecords({ + overrides: { + auth: { + account_selection: { + enabled: true, + mode: 'always', + allow_add_account: true, + allow_remove_account: true, + remember_accounts: { + enabled: true, + max_accounts: 5, + ttl: '30d', + }, + }, + }, + }, + users: [SCREEN_USER_CONFIG, SCREEN_SECOND_USER], + clients: [E2E_TEST_CLIENT_CONFIG], + }), + prepare: async ({ baseURL, page, scenario }) => { + await loginViaApi(page, baseURL, SCREEN_USER.email); + await loginViaApi(page, baseURL, SCREEN_SECOND_USER.email); + await gotoAndWait( + page, + buildOAuthAuthorizeUrl({ prompt: 'select_account' }), + scenario.readySelector, + ); + }, + }, + 'totp-verification': { + config: createScreenConfig({ + registration: { + enabled: true, + allowed_email_patterns: ['*'], + }, + auth: { + password: { + two_factor: { enrollment_required: true }, + totp: { enabled: true }, + }, + }, + }), + prepare: async ({ baseURL, page, scenario }) => { + await registerUserViaApi(baseURL, { + email: SCREEN_TOTP_EMAIL, + password: SCREEN_USER.password, + }); + await setupTotpViaTestApi(baseURL, SCREEN_TOTP_EMAIL); + await performLogin(page, SCREEN_TOTP_EMAIL, SCREEN_USER.password); + await page.waitForURL(`**${scenario.entryPath}`); + await page + .locator(scenario.readySelector) + .first() + .waitFor({ state: 'visible' }); + }, + }, + 'totp-setup': { + config: createScreenConfig({ + registration: { + enabled: true, + allowed_email_patterns: ['*'], + }, + auth: { + password: { + two_factor: { enrollment_required: true }, + totp: { enabled: true }, + }, + }, + }), + prepare: async ({ baseURL, page, scenario }) => { + const email = 'screen-lab-totp-setup@example.com'; + await registerUserViaApi(baseURL, { + email, + password: SCREEN_USER.password, + }); + await performLogin(page, email, SCREEN_USER.password); + await page.waitForURL(`**${scenario.entryPath}`); + await page.locator(scenario.readySelector).waitFor({ state: 'visible' }); + }, + }, + 'passkey-setup': { + config: createScreenConfig({ + registration: { + enabled: true, + allowed_email_patterns: ['*'], + }, + auth: { + password: { + two_factor: { enrollment_required: true }, + }, + passkey: { enabled: true }, + }, + }), + prepare: async ({ baseURL, page, scenario }) => { + const email = 'screen-lab-passkey-setup@example.com'; + await registerUserViaApi(baseURL, { + email, + password: SCREEN_USER.password, + }); + await performLogin(page, email, SCREEN_USER.password); + await page.waitForURL(`**${scenario.entryPath}**`); + await gotoAndWait(page, scenario.entryPath, scenario.readySelector); + }, + }, +} satisfies Record; + +function createScreenScenario( + definition: (typeof screenScenarioDefinitions)[number], +): ScreenScenario { + if (definition.runtime === 'route') { + return definition; + } + + return { + ...definition, + ...serverScreenScenarioAdapters[definition.id], + }; +} + +export const screenScenarios: readonly ScreenScenario[] = + screenScenarioDefinitions.map(createScreenScenario); + +export function findScreenScenario(id: string): ScreenScenario | undefined { + return screenScenarios.find((scenario) => scenario.id === id); +} diff --git a/packages/frontend/e2e/screen-lab/route-host.html b/packages/frontend/e2e/screen-lab/route-host.html new file mode 100644 index 00000000..e05e5834 --- /dev/null +++ b/packages/frontend/e2e/screen-lab/route-host.html @@ -0,0 +1,23 @@ + + + + + + + Screen Lab + + +
+ + + + diff --git a/packages/frontend/e2e/screen-lab/screen-dev.ts b/packages/frontend/e2e/screen-lab/screen-dev.ts new file mode 100644 index 00000000..b8b6c2e1 --- /dev/null +++ b/packages/frontend/e2e/screen-lab/screen-dev.ts @@ -0,0 +1,213 @@ +import type { AddressInfo } from 'node:net'; +import { parseArgs } from 'node:util'; +import { chromium } from '@playwright/test'; +import { createServer as createViteServer } from 'vite'; +import { AsyncCleanupStack } from '#frontend/test-utils/async-cleanup-stack.ts'; +import { findScreenScenarioVariant } from '#frontend/test-utils/screen-scenario-catalog.ts'; +import { waitForScreenAssets } from '#frontend-e2e/screen-lab/assets.ts'; +import { + findScreenScenario, + screenScenarios, +} from '#frontend-e2e/screen-lab/catalog.ts'; +import { createE2EServer } from '#frontend-e2e/setup/create-server.ts'; + +const SHARED_FRONTEND_PORT_ENV = 'E2E_SHARED_FRONTEND_PORT'; +const HEADLESS_ENV = 'SCREEN_LAB_HEADLESS'; +const EXIT_AFTER_READY_ENV = 'SCREEN_LAB_EXIT_AFTER_READY'; +const DESKTOP_VIEWPORT = { width: 1440, height: 900 }; +const MOBILE_VIEWPORT = { width: 390, height: 844 }; + +function printScenarioList(): void { + const longestId = Math.max( + ...screenScenarios.map((scenario) => scenario.id.length), + ); + for (const scenario of screenScenarios) { + const variants = scenario.variants.map((variant) => variant.id).join(', '); + process.stdout.write( + `${scenario.id.padEnd(longestId)} [${scenario.runtime}] ${scenario.title} — ${scenario.description}\n` + + `${''.padEnd(longestId)} variants: ${variants}\n`, + ); + } +} + +function getPort(address: AddressInfo | string | null): number { + if (typeof address === 'object' && address !== null) { + return address.port; + } + throw new Error('Screen Lab could not resolve the Vite server port.'); +} + +function getBrowserLocale(locale: 'en' | 'ko' | 'ja'): string { + if (locale === 'ko') { + return 'ko-KR'; + } + if (locale === 'ja') { + return 'ja-JP'; + } + return 'en-US'; +} + +const { values } = parseArgs({ + options: { + list: { type: 'boolean', default: false }, + scenario: { type: 'string', short: 's' }, + variant: { type: 'string', short: 'v' }, + }, + strict: true, +}); + +if (values.list) { + printScenarioList(); + process.exit(0); +} + +if (!values.scenario) { + process.stderr.write( + 'Choose a scenario with --scenario , or inspect them with --list.\n', + ); + process.exit(1); +} + +const scenario = findScreenScenario(values.scenario); +if (!scenario) { + process.stderr.write(`Unknown Screen Lab scenario: ${values.scenario}\n\n`); + printScenarioList(); + process.exit(1); +} + +const variant = findScreenScenarioVariant(scenario, values.variant); +if (!variant) { + process.stderr.write( + `Unknown variant for ${scenario.id}: ${values.variant}\n` + + `Available variants: ${scenario.variants + .map((candidate) => candidate.id) + .join(', ')}\n`, + ); + process.exit(1); +} + +const cleanups = new AsyncCleanupStack(); +let primaryError: unknown; +let cleanupError: unknown; + +try { + const viteServer = await createViteServer({ + server: { + host: '127.0.0.1', + port: 0, + strictPort: false, + }, + }); + await viteServer.listen(); + cleanups.defer(() => viteServer.close()); + + const frontendPort = getPort(viteServer.httpServer?.address() ?? null); + const frontendOrigin = `http://127.0.0.1:${frontendPort}`; + const previousFrontendPort = process.env[SHARED_FRONTEND_PORT_ENV]; + process.env[SHARED_FRONTEND_PORT_ENV] = String(frontendPort); + cleanups.defer(() => { + if (previousFrontendPort === undefined) { + delete process.env[SHARED_FRONTEND_PORT_ENV]; + return; + } + process.env[SHARED_FRONTEND_PORT_ENV] = previousFrontendPort; + }); + + const backend = + scenario.runtime === 'server' + ? await createE2EServer(scenario.config) + : undefined; + if (backend) { + cleanups.defer(() => backend.teardown()); + } + + const baseURL = backend + ? `http://127.0.0.1:${backend.backendPort}` + : frontendOrigin; + const browser = await chromium.launch({ + headless: process.env[HEADLESS_ENV] === '1', + }); + cleanups.defer(() => browser.close().catch(() => undefined)); + + const context = await browser.newContext({ + baseURL, + colorScheme: variant.colorScheme, + locale: getBrowserLocale(variant.locale), + viewport: + variant.viewport === 'mobile' ? MOBILE_VIEWPORT : DESKTOP_VIEWPORT, + }); + const page = await context.newPage(); + await page.emulateMedia({ + colorScheme: variant.colorScheme, + reducedMotion: 'reduce', + }); + await page.addInitScript( + ({ colorScheme, locale }) => { + localStorage.setItem('tinyauth-color-scheme', colorScheme); + localStorage.setItem('tinyauth-language', locale); + }, + { + colorScheme: variant.colorScheme, + locale: variant.locale, + }, + ); + + if (scenario.runtime === 'route') { + const url = new URL('/e2e/screen-lab/route-host.html', frontendOrigin); + url.searchParams.set('scenario', scenario.id); + url.searchParams.set('variant', variant.id); + await page.goto(url.href); + } else { + await scenario.prepare({ baseURL, page, scenario }); + } + + await waitForScreenAssets(page); + await page + .locator(scenario.readySelector) + .first() + .waitFor({ state: 'visible' }); + + process.stdout.write( + `Screen Lab opened "${scenario.title}" (${variant.id}) at ${page.url()}\n` + + 'Close the browser or press Ctrl+C to stop.\n', + ); + + if (process.env[EXIT_AFTER_READY_ENV] !== '1') { + await new Promise((resolve) => { + let resolved = false; + const finish = () => { + if (resolved) { + return; + } + resolved = true; + resolve(); + }; + browser.once('disconnected', finish); + process.once('SIGINT', finish); + process.once('SIGTERM', finish); + cleanups.defer(() => { + browser.off('disconnected', finish); + process.off('SIGINT', finish); + process.off('SIGTERM', finish); + }); + }); + } +} catch (error) { + primaryError = error; +} finally { + try { + await cleanups.dispose(); + } catch (error) { + cleanupError = error; + } +} + +if (primaryError !== undefined) { + if (cleanupError !== undefined) { + process.stderr.write(`Screen Lab cleanup error: ${String(cleanupError)}\n`); + } + throw primaryError; +} +if (cleanupError !== undefined) { + throw cleanupError; +} diff --git a/packages/frontend/e2e/screen-lab/types.ts b/packages/frontend/e2e/screen-lab/types.ts new file mode 100644 index 00000000..73a59ec3 --- /dev/null +++ b/packages/frontend/e2e/screen-lab/types.ts @@ -0,0 +1,30 @@ +import type { Page } from '@playwright/test'; +import type { + RouteScreenScenarioDefinition, + ServerScreenScenarioDefinition, +} from '#frontend/test-utils/screen-scenario-catalog.ts'; +import type { E2EConfigResult } from '#frontend-e2e/setup/create-server.ts'; + +export type ScreenScenarioConfigFactory = ( + backendPort: number, + frontendPort: number, + auxiliaryPort: number, +) => E2EConfigResult; + +export type ServerScreenScenarioContext = { + baseURL: string; + page: Page; + scenario: ServerScreenScenarioDefinition; +}; + +export type ServerScreenScenarioAdapter = { + config: ScreenScenarioConfigFactory; + prepare: (context: ServerScreenScenarioContext) => Promise; +}; + +export type ServerScreenScenario = ServerScreenScenarioDefinition & + ServerScreenScenarioAdapter; + +export type ScreenScenario = + | RouteScreenScenarioDefinition + | ServerScreenScenario; diff --git a/packages/frontend/e2e/setup/create-server.ts b/packages/frontend/e2e/setup/create-server.ts index 8746e0f2..cefa0e11 100644 --- a/packages/frontend/e2e/setup/create-server.ts +++ b/packages/frontend/e2e/setup/create-server.ts @@ -14,6 +14,22 @@ import { resolveTestEmailConfig } from '#frontend-e2e/setup/resolve-test-email.t const SHARED_FRONTEND_PORT_ENV = 'E2E_SHARED_FRONTEND_PORT'; const APPLE_STUB_KEY_ID = 'tinyauth-e2e-apple-stub-key'; const BACKEND_BIND_ATTEMPTS = 5; +const SCREEN_LAB_BACKGROUND_SVG = ` + + + + + + + + + + + + + + +`; const FRONTEND_PUBLIC_PATH = fileURLToPath( new URL('../../../server/public', import.meta.url), ); @@ -385,6 +401,12 @@ export async function createE2EServer(configFactory: ConfigFactory) { // 2. Register test-only API endpoints const testApp = app + .get('/test/screen-lab-background.svg', (c) => + c.body(SCREEN_LAB_BACKGROUND_SVG, 200, { + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Type': 'image/svg+xml', + }), + ) .get('/test/email-token/:email', async (c) => { const email = c.req.param('email'); const user = await services.mikro.user.findOne({ email }); diff --git a/packages/frontend/e2e/setup/global-setup.ts b/packages/frontend/e2e/setup/global-setup.ts index 636a05e2..0e1f88d5 100644 --- a/packages/frontend/e2e/setup/global-setup.ts +++ b/packages/frontend/e2e/setup/global-setup.ts @@ -2,12 +2,16 @@ import type { AddressInfo } from 'node:net'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { FullConfig } from '@playwright/test'; -import { preview as createVitePreviewServer } from 'vite'; +import { + createServer as createViteDevServer, + preview as createVitePreviewServer, +} from 'vite'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const frontendRoot = path.resolve(__dirname, '../..'); const frontendOutput = path.resolve(frontendRoot, '../server/public'); const SHARED_FRONTEND_PORT_ENV = 'E2E_SHARED_FRONTEND_PORT'; +const SCREEN_LAB_ROUTE_HOST_ORIGIN_ENV = 'SCREEN_LAB_ROUTE_HOST_ORIGIN'; function getListeningPort(address: AddressInfo | string | null): number { if (address === null || typeof address === 'string') { @@ -17,7 +21,7 @@ function getListeningPort(address: AddressInfo | string | null): number { return address.port; } -export default async function globalSetup(_config: FullConfig) { +export default async function globalSetup(config: FullConfig) { /* * E2E validation runs after the workspace build. Serving that immutable * output keeps parallel browser workers away from Vite's development-time @@ -38,8 +42,33 @@ export default async function globalSetup(_config: FullConfig) { const frontendPort = getListeningPort(frontendServer.httpServer.address()); process.env[SHARED_FRONTEND_PORT_ENV] = String(frontendPort); + const screenLabEnabled = config.projects.some( + (project) => project.name === 'screen-lab:chromium', + ); + const routeHostServer = screenLabEnabled + ? await createViteDevServer({ + root: frontendRoot, + server: { + host: '127.0.0.1', + port: 0, + strictPort: false, + }, + }) + : undefined; + + if (routeHostServer) { + await routeHostServer.listen(); + const routeHostPort = getListeningPort( + routeHostServer.httpServer?.address() ?? null, + ); + process.env[SCREEN_LAB_ROUTE_HOST_ORIGIN_ENV] = + `http://127.0.0.1:${routeHostPort}`; + } + return async () => { + delete process.env[SCREEN_LAB_ROUTE_HOST_ORIGIN_ENV]; delete process.env[SHARED_FRONTEND_PORT_ENV]; + await routeHostServer?.close(); await frontendServer.close(); }; } diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts new file mode 100644 index 00000000..4c656c86 --- /dev/null +++ b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts @@ -0,0 +1,104 @@ +import { expect, type Page, test as routeTest } from '@playwright/test'; +import type { ScreenScenarioVariant } from '#frontend/test-utils/screen-scenario-catalog.ts'; +import { createScenarioFixture } from '#frontend-e2e/fixtures/create-scenario-fixture.ts'; +import { waitForScreenAssets } from '#frontend-e2e/screen-lab/assets.ts'; +import { screenScenarios } from '#frontend-e2e/screen-lab/catalog.ts'; + +const DESKTOP_VIEWPORT = { width: 1440, height: 900 }; +const MOBILE_VIEWPORT = { width: 390, height: 844 }; +const ROUTE_HOST_ORIGIN_ENV = 'SCREEN_LAB_ROUTE_HOST_ORIGIN'; + +function getSnapshotName( + scenarioId: string, + variant: ScreenScenarioVariant, +): string { + if (variant.id === 'desktop' || variant.id === 'mobile') { + return `${scenarioId}-${variant.id}.png`; + } + return `${scenarioId}-${variant.id}-desktop.png`; +} + +async function configurePage( + page: Page, + variant: ScreenScenarioVariant, +): Promise { + await page.setViewportSize( + variant.viewport === 'mobile' ? MOBILE_VIEWPORT : DESKTOP_VIEWPORT, + ); + await page.emulateMedia({ + colorScheme: variant.colorScheme, + reducedMotion: 'reduce', + }); + await page.addInitScript( + ({ colorScheme, locale }) => { + localStorage.setItem('tinyauth-color-scheme', colorScheme); + localStorage.setItem('tinyauth-language', locale); + }, + { + colorScheme: variant.colorScheme, + locale: variant.locale, + }, + ); +} + +async function expectScreenScreenshot( + page: Page, + scenario: (typeof screenScenarios)[number], + variant: ScreenScenarioVariant, +): Promise { + await waitForScreenAssets(page); + await expect(page.locator(scenario.readySelector).first()).toBeVisible(); + await expect(page).toHaveScreenshot(getSnapshotName(scenario.id, variant), { + animations: 'disabled', + caret: 'hide', + fullPage: true, + mask: (scenario.maskSelectors ?? []).map((selector) => + page.locator(selector), + ), + }); +} + +for (const scenario of screenScenarios) { + if (scenario.runtime === 'route') { + routeTest.describe(`${scenario.group}: ${scenario.title}`, () => { + for (const variant of scenario.variants) { + routeTest(`matches the ${variant.id} reference`, async ({ page }) => { + await configurePage(page, variant); + + const routeHostOrigin = process.env[ROUTE_HOST_ORIGIN_ENV]; + if (!routeHostOrigin) { + throw new Error('Screen Lab route host origin is unavailable.'); + } + const url = new URL( + '/e2e/screen-lab/route-host.html', + routeHostOrigin, + ); + url.searchParams.set('scenario', scenario.id); + url.searchParams.set('variant', variant.id); + await page.goto(url.href); + + await expectScreenScreenshot(page, scenario, variant); + }); + } + }); + continue; + } + + const serverTest = createScenarioFixture(scenario.config); + serverTest.describe(`${scenario.group}: ${scenario.title}`, () => { + for (const variant of scenario.variants) { + serverTest( + `matches the ${variant.id} reference`, + async ({ page, baseURL }) => { + await configurePage(page, variant); + await scenario.prepare({ + baseURL: String(baseURL), + page, + scenario, + }); + await expectScreenScreenshot(page, scenario, variant); + }, + ); + } + }); +} diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/account-selection-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/account-selection-desktop.png new file mode 100644 index 00000000..b686eeeb Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/account-selection-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/account-selection-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/account-selection-mobile.png new file mode 100644 index 00000000..67d934f5 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/account-selection-mobile.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/admin-users-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/admin-users-desktop.png new file mode 100644 index 00000000..7bf3dfe6 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/admin-users-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/email-verification-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/email-verification-desktop.png new file mode 100644 index 00000000..8274f824 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/email-verification-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/email-verification-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/email-verification-mobile.png new file mode 100644 index 00000000..6041d298 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/email-verification-mobile.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/error-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/error-desktop.png new file mode 100644 index 00000000..74dc9346 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/error-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/error-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/error-mobile.png new file mode 100644 index 00000000..e8abb7b4 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/error-mobile.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-desktop.png new file mode 100644 index 00000000..a59ff416 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-en-dark-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-en-dark-desktop.png new file mode 100644 index 00000000..11573d1b Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-en-dark-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-ja-light-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-ja-light-desktop.png new file mode 100644 index 00000000..4941eedd Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-ja-light-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-ko-light-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-ko-light-desktop.png new file mode 100644 index 00000000..e2d7b55c Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-ko-light-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-mobile.png new file mode 100644 index 00000000..a47032aa Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/login-mobile.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/oauth-consent-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/oauth-consent-desktop.png new file mode 100644 index 00000000..2c6e136a Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/oauth-consent-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/oauth-consent-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/oauth-consent-mobile.png new file mode 100644 index 00000000..6b00c2a3 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/oauth-consent-mobile.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/passkey-setup-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/passkey-setup-desktop.png new file mode 100644 index 00000000..10ec51b0 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/passkey-setup-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/passkey-setup-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/passkey-setup-mobile.png new file mode 100644 index 00000000..8d80f64e Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/passkey-setup-mobile.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/password-reset-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/password-reset-desktop.png new file mode 100644 index 00000000..25b01c37 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/password-reset-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/password-reset-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/password-reset-mobile.png new file mode 100644 index 00000000..1e844f83 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/password-reset-mobile.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/profile-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/profile-desktop.png new file mode 100644 index 00000000..37f9a3c2 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/profile-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/profile-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/profile-mobile.png new file mode 100644 index 00000000..986a5903 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/profile-mobile.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/register-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/register-desktop.png new file mode 100644 index 00000000..31d3375d Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/register-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/register-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/register-mobile.png new file mode 100644 index 00000000..1b6df3c7 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/register-mobile.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-setup-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-setup-desktop.png new file mode 100644 index 00000000..c545921c Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-setup-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-setup-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-setup-mobile.png new file mode 100644 index 00000000..68a64988 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-setup-mobile.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-verification-desktop.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-verification-desktop.png new file mode 100644 index 00000000..56d965ef Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-verification-desktop.png differ diff --git a/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-verification-mobile.png b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-verification-mobile.png new file mode 100644 index 00000000..04294877 Binary files /dev/null and b/packages/frontend/e2e/tests/screen-lab/screen-lab.visual.test.ts-snapshots/totp-verification-mobile.png differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index c5fcdf30..a7c8b755 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -45,7 +45,9 @@ "test:e2e:sharded": "node e2e/run-sharded.ts", "test:e2e:smoke": "node --import tsx node_modules/@playwright/test/cli.js test --project minimal:chromium", "test:e2e:smoke:sharded": "node e2e/run-sharded.ts --project minimal:chromium", - "test:e2e:ui": "node --conditions=@tinyauth/source --import tsx node_modules/@playwright/test/cli.js test --ui" + "test:e2e:screen": "node --import tsx node_modules/@playwright/test/cli.js test --project screen-lab:chromium", + "test:e2e:ui": "node --conditions=@tinyauth/source --import tsx node_modules/@playwright/test/cli.js test --ui", + "screen:dev": "node --conditions=@tinyauth/source --import tsx e2e/screen-lab/screen-dev.ts" }, "dependencies": { "@fontsource-variable/ibm-plex-sans": "5.3.0", diff --git a/packages/frontend/playwright.config.ts b/packages/frontend/playwright.config.ts index edeae1bf..8d5f9de3 100644 --- a/packages/frontend/playwright.config.ts +++ b/packages/frontend/playwright.config.ts @@ -130,28 +130,44 @@ export default defineConfig({ timeout: 1000 * 90, expect: { timeout: 30_000, + toHaveScreenshot: { + maxDiffPixels: 100, + }, }, + snapshotPathTemplate: '{testDir}/{testFilePath}-snapshots/{arg}{ext}', use: { actionTimeout: 30_000, navigationTimeout: 45_000, }, - projects: configs.flatMap((config) => - browsers.map((browser) => ({ - name: `${config.name}:${browser.name}`, - testDir: config.testDir, + projects: [ + ...configs.flatMap((config) => + browsers.map((browser) => ({ + name: `${config.name}:${browser.name}`, + testDir: config.testDir, + use: { + trace: 'retain-on-failure' as const, + /* + * Auth screen content animates in, and Playwright waits for an element + * to stop moving before acting on it. That is correct, but it puts a + * stability window in front of nearly every interaction in this suite + * for the sake of decoration. Asking for reduced motion takes the app's + * own `prefers-reduced-motion` path, so content is final as soon as it + * mounts. + */ + reducedMotion: 'reduce' as const, + ...browser.device, + }, + })), + ), + { + name: 'screen-lab:chromium', + testDir: './e2e/tests/screen-lab', use: { - trace: 'retain-on-failure' as const, - /* - * Auth screen content animates in, and Playwright waits for an element - * to stop moving before acting on it. That is correct, but it puts a - * stability window in front of nearly every interaction in this suite - * for the sake of decoration. Asking for reduced motion takes the app's - * own `prefers-reduced-motion` path, so content is final as soon as it - * mounts. - */ - reducedMotion: 'reduce' as const, - ...browser.device, + colorScheme: 'light', + locale: 'en-US', + trace: 'retain-on-failure', + ...devices['Desktop Chrome'], }, - })), - ), + }, + ], }); diff --git a/packages/frontend/src/test-utils/async-cleanup-stack.test.ts b/packages/frontend/src/test-utils/async-cleanup-stack.test.ts new file mode 100644 index 00000000..a4e7a062 --- /dev/null +++ b/packages/frontend/src/test-utils/async-cleanup-stack.test.ts @@ -0,0 +1,32 @@ +import { expect, test, vi } from 'vitest'; +import { AsyncCleanupStack } from '#frontend/test-utils/async-cleanup-stack.ts'; + +test('disposes resources in reverse registration order', async () => { + const calls: string[] = []; + const cleanups = new AsyncCleanupStack(); + cleanups.defer(() => { + calls.push('vite'); + }); + cleanups.defer(() => { + calls.push('backend'); + }); + cleanups.defer(() => { + calls.push('browser'); + }); + + await cleanups.dispose(); + + expect(calls).toEqual(['browser', 'backend', 'vite']); +}); + +test('continues cleanup after a disposer fails', async () => { + const finalCleanup = vi.fn(); + const cleanups = new AsyncCleanupStack(); + cleanups.defer(finalCleanup); + cleanups.defer(() => { + throw new Error('prepare failure cleanup'); + }); + + await expect(cleanups.dispose()).rejects.toThrow('Screen Lab cleanup failed'); + expect(finalCleanup).toHaveBeenCalledOnce(); +}); diff --git a/packages/frontend/src/test-utils/async-cleanup-stack.ts b/packages/frontend/src/test-utils/async-cleanup-stack.ts new file mode 100644 index 00000000..46d00662 --- /dev/null +++ b/packages/frontend/src/test-utils/async-cleanup-stack.ts @@ -0,0 +1,26 @@ +export type AsyncCleanup = () => Promise | void; + +export class AsyncCleanupStack { + readonly #cleanups: AsyncCleanup[] = []; + + defer(cleanup: AsyncCleanup): void { + this.#cleanups.push(cleanup); + } + + async dispose(): Promise { + const errors: unknown[] = []; + + for (const cleanup of this.#cleanups.reverse()) { + try { + await cleanup(); + } catch (error) { + errors.push(error); + } + } + this.#cleanups.length = 0; + + if (errors.length > 0) { + throw new AggregateError(errors, 'Screen Lab cleanup failed.'); + } + } +} diff --git a/packages/frontend/src/test-utils/route-screen-fixtures.ts b/packages/frontend/src/test-utils/route-screen-fixtures.ts new file mode 100644 index 00000000..e9132092 --- /dev/null +++ b/packages/frontend/src/test-utils/route-screen-fixtures.ts @@ -0,0 +1,111 @@ +import type { AuthorizationContextSearch } from '#frontend/libs/oauth-search.ts'; +import { + type AuthorizationContextResponse, + getAuthorizationContextQueryOptions, +} from '#frontend/queries/authorization-context.ts'; +import type { AppConfigs } from '#frontend/queries/config.ts'; +import { appConfigQueryOptions } from '#frontend/queries/config.ts'; +import type { SessionUser } from '#frontend/queries/session.ts'; +import type { RouteTestQueryData } from '#frontend/test-utils/route-screen-renderer.tsx'; + +export const routeTestAppConfig = { + i18n: { + supported_languages: ['en'], + default_language: 'en', + fallback_language: 'en', + }, + branding: { + background_url: '', + icon_url: '', + title: {}, + subtitle: {}, + }, + registration: { + public_registration: true, + email_pattern_filter_enabled: false, + email_verification_required: true, + signup_notice: {}, + }, + database: { + enabled: true, + }, + email: { + enabled: true, + }, + admin: { + enabled: true, + }, + auth: { + password: { + enabled: true, + two_factor: { + enrollment_required: true, + }, + totp: { + enabled: true, + issuer: 'TinyAuth', + }, + policy: { + min_length: 8, + max_length: 64, + }, + }, + passkey: { + enabled: true, + }, + }, + identity_providers: [], + account_deletion: { + enabled: true, + retention: 'P30D', + }, +} satisfies AppConfigs; + +export const routeTestUser = { + managed_by: 'database', + sub: 'user-1', + email: 'alice@example.com', + role: 'user', + email_verified: true, + email_verification_required: false, + has_password: true, + second_factor_required: false, + totp_registered: false, + totp_recovery_codes_missing: false, + passkey_count: 0, +} satisfies SessionUser; + +export const routeTestAuthorizationContext = { + client: { + id: 'client-1', + clientId: 'client-web', + name: 'Client Web', + }, + redirect_uri: 'https://client.example/callback', + redirect_origin: 'https://client.example', + scopes: [ + { + name: 'openid', + description: 'Access your unique user identifier', + }, + ], +} satisfies AuthorizationContextResponse; + +export function appConfigQueryData( + config: AppConfigs = routeTestAppConfig, +): RouteTestQueryData { + return { + queryKey: appConfigQueryOptions.queryKey, + data: config, + }; +} + +export function authorizationContextQueryData( + search: AuthorizationContextSearch, + data: AuthorizationContextResponse = routeTestAuthorizationContext, +): RouteTestQueryData { + return { + queryKey: getAuthorizationContextQueryOptions(search).queryKey, + data, + }; +} diff --git a/packages/frontend/src/test-utils/route-screen-renderer.tsx b/packages/frontend/src/test-utils/route-screen-renderer.tsx new file mode 100644 index 00000000..ce3d1fa3 --- /dev/null +++ b/packages/frontend/src/test-utils/route-screen-renderer.tsx @@ -0,0 +1,91 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRouter, + RouterProvider, +} from '@tanstack/react-router'; +import { TRToast } from '@tinyrack/ui/components/toast'; +import type { ReactElement } from 'react'; +import i18n from '#frontend/i18n/index.ts'; +import type { AppRouterContext } from '#frontend/libs/router.ts'; +import type { SessionUser } from '#frontend/queries/session.ts'; +import { routeTree } from '#frontend/routeTree.gen.ts'; + +export type RouteTestQueryData = { + queryKey: readonly unknown[]; + data: unknown; +}; + +export type RouteScreenRenderOptions = { + initialLocation?: string; + queryClient?: QueryClient; + queryData?: RouteTestQueryData[]; + user?: SessionUser | null; +}; + +export type RouteScreenRenderState = { + content: ReactElement; + queryClient: QueryClient; + router: ReturnType; +}; + +export function createRouteScreenQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); +} + +function createRouteScreenRouter( + context: AppRouterContext, + initialLocation: string, +) { + const history = createMemoryHistory({ + initialEntries: [initialLocation], + }); + + return createRouter({ + routeTree, + context, + history, + scrollRestoration: false, + }); +} + +export async function createRouteScreen({ + initialLocation = '/', + queryClient = createRouteScreenQueryClient(), + queryData = [], + user = null, +}: RouteScreenRenderOptions = {}): Promise { + for (const seededQuery of queryData) { + queryClient.setQueryData(seededQuery.queryKey, seededQuery.data); + } + + const context = { + queryClient, + user, + i18n, + } satisfies AppRouterContext; + const router = createRouteScreenRouter(context, initialLocation); + + await router.load(); + + return { + content: ( + + + + + + ), + queryClient, + router, + }; +} diff --git a/packages/frontend/src/test-utils/route-test-utils.tsx b/packages/frontend/src/test-utils/route-test-utils.tsx index cc122b4c..11b12d02 100644 --- a/packages/frontend/src/test-utils/route-test-utils.tsx +++ b/packages/frontend/src/test-utils/route-test-utils.tsx @@ -1,169 +1,35 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { - createMemoryHistory, - createRouter, - RouterProvider, -} from '@tanstack/react-router'; -import { TRToast } from '@tinyrack/ui/components/toast'; +import type { QueryClient } from '@tanstack/react-query'; import type { RenderResult } from 'vitest-browser-react'; import { render } from 'vitest-browser-react'; -import i18n from '#frontend/i18n/index.ts'; -import type { AuthorizationContextSearch } from '#frontend/libs/oauth-search.ts'; -import type { AppRouterContext } from '#frontend/libs/router.ts'; -import { - type AuthorizationContextResponse, - getAuthorizationContextQueryOptions, -} from '#frontend/queries/authorization-context.ts'; -import type { AppConfigs } from '#frontend/queries/config.ts'; -import { appConfigQueryOptions } from '#frontend/queries/config.ts'; -import type { SessionUser } from '#frontend/queries/session.ts'; -import { routeTree } from '#frontend/routeTree.gen.ts'; import { initTestI18n } from '#frontend/test-utils/i18n.ts'; -type RouteTestQueryData = { - queryKey: readonly unknown[]; - data: unknown; -}; - -export const routeTestAppConfig = { - i18n: { - supported_languages: ['en'], - default_language: 'en', - fallback_language: 'en', - }, - branding: { - background_url: '', - icon_url: '', - title: {}, - subtitle: {}, - }, - registration: { - public_registration: true, - email_pattern_filter_enabled: false, - email_verification_required: true, - signup_notice: {}, - }, - database: { - enabled: true, - }, - email: { - enabled: true, - }, - admin: { - enabled: true, - }, - auth: { - password: { - enabled: true, - two_factor: { - enrollment_required: true, - }, - totp: { - enabled: true, - issuer: 'TinyAuth', - }, - policy: { - min_length: 8, - max_length: 64, - }, - }, - passkey: { - enabled: true, - }, - }, - identity_providers: [], - account_deletion: { - enabled: true, - retention: 'P30D', - }, -} satisfies AppConfigs; - -export const routeTestUser = { - managed_by: 'database', - sub: 'user-1', - email: 'alice@example.com', - role: 'user', - email_verified: true, - email_verification_required: false, - has_password: true, - second_factor_required: false, - totp_registered: false, - totp_recovery_codes_missing: false, - passkey_count: 0, -} satisfies SessionUser; +export { + appConfigQueryData, + authorizationContextQueryData, + routeTestAppConfig, + routeTestAuthorizationContext, + routeTestUser, +} from '#frontend/test-utils/route-screen-fixtures.ts'; -export const routeTestAuthorizationContext = { - client: { - id: 'client-1', - clientId: 'client-web', - name: 'Client Web', - }, - redirect_uri: 'https://client.example/callback', - redirect_origin: 'https://client.example', - scopes: [ - { - name: 'openid', - description: 'Access your unique user identifier', - }, - ], -} satisfies AuthorizationContextResponse; +import { + createRouteScreen, + createRouteScreenQueryClient, + type RouteScreenRenderOptions, + type RouteTestQueryData, +} from '#frontend/test-utils/route-screen-renderer.tsx'; -export function appConfigQueryData( - config: AppConfigs = routeTestAppConfig, -): RouteTestQueryData { - return { - queryKey: appConfigQueryOptions.queryKey, - data: config, - }; -} +export type { RouteTestQueryData }; -export function authorizationContextQueryData( - search: AuthorizationContextSearch, - data: AuthorizationContextResponse = routeTestAuthorizationContext, -): RouteTestQueryData { - return { - queryKey: getAuthorizationContextQueryOptions(search).queryKey, - data, - }; -} - -type RenderRouteOptions = { - initialLocation?: string; - queryClient?: QueryClient; - queryData?: RouteTestQueryData[]; - user?: SessionUser | null; -}; +type RenderRouteOptions = RouteScreenRenderOptions; type RenderRouteResult = { queryClient: QueryClient; - router: ReturnType; + router: Awaited>['router']; screen: RenderResult; }; export function createTestQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, - }, - }, - }); -} - -function createTestRouter(context: AppRouterContext, initialLocation: string) { - const history = createMemoryHistory({ - initialEntries: [initialLocation], - }); - - return createRouter({ - routeTree, - context, - history, - scrollRestoration: false, - }); + return createRouteScreenQueryClient(); } export async function renderRoute({ @@ -174,28 +40,16 @@ export async function renderRoute({ }: RenderRouteOptions = {}): Promise { initTestI18n(); - for (const seededQuery of queryData) { - queryClient.setQueryData(seededQuery.queryKey, seededQuery.data); - } - - const context = { + const { content, router } = await createRouteScreen({ + initialLocation, queryClient, + queryData, user, - i18n, - } satisfies AppRouterContext; - const router = createTestRouter(context, initialLocation); - - await router.load(); + }); // Mirrors the provider stack in main.tsx. AuthLayout renders the toast // viewport, which needs a manager in context. - const screen = await render( - - - - - , - ); + const screen = await render(content); return { queryClient, diff --git a/packages/frontend/src/test-utils/screen-lab-route-host.tsx b/packages/frontend/src/test-utils/screen-lab-route-host.tsx new file mode 100644 index 00000000..1371a571 --- /dev/null +++ b/packages/frontend/src/test-utils/screen-lab-route-host.tsx @@ -0,0 +1,77 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { initI18n, LANGUAGE_STORAGE_KEY } from '#frontend/i18n/index.ts'; +import type { AppConfigs } from '#frontend/queries/config.ts'; +import { getTermsQueryOptions } from '#frontend/queries/terms.ts'; +import '#frontend/index.css'; +import { + appConfigQueryData, + routeTestAppConfig, +} from '#frontend/test-utils/route-screen-fixtures.ts'; +import { createRouteScreen } from '#frontend/test-utils/route-screen-renderer.tsx'; +import { + findScreenScenarioDefinition, + findScreenScenarioVariant, +} from '#frontend/test-utils/screen-scenario-catalog.ts'; + +const COLOR_SCHEME_STORAGE_KEY = 'tinyauth-color-scheme'; +const RootElement = document.getElementById('root'); +const search = new URLSearchParams(window.location.search); +const scenario = findScreenScenarioDefinition(search.get('scenario') ?? ''); + +if (!RootElement) { + throw new Error('Screen Lab route host root element was not found.'); +} +if (scenario?.runtime !== 'route') { + throw new Error('Screen Lab route host requires a route scenario.'); +} + +const variant = findScreenScenarioVariant( + scenario, + search.get('variant') ?? undefined, +); +if (!variant) { + throw new Error(`Unknown Screen Lab variant for ${scenario.id}.`); +} + +localStorage.setItem(LANGUAGE_STORAGE_KEY, variant.locale); +localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, variant.colorScheme); +document.documentElement.lang = variant.locale; + +const screenLabConfig = { + ...routeTestAppConfig, + i18n: { + supported_languages: ['ko', 'en', 'ja'], + default_language: variant.locale, + fallback_language: 'en', + }, + branding: { + ...routeTestAppConfig.branding, + background_url: '/e2e/screen-lab/background.svg', + }, + auth: { + ...routeTestAppConfig.auth, + passkey: { + enabled: false, + }, + }, +} satisfies AppConfigs; + +initI18n({ + supportedLanguages: screenLabConfig.i18n.supported_languages, + defaultLanguage: screenLabConfig.i18n.default_language, + fallbackLanguage: screenLabConfig.i18n.fallback_language, +}); + +const { content } = await createRouteScreen({ + initialLocation: scenario.entryPath, + queryData: [ + appConfigQueryData(screenLabConfig), + { + queryKey: getTermsQueryOptions(variant.locale).queryKey, + data: { terms: [] }, + }, + ], +}); + +createRoot(RootElement).render({content}); diff --git a/packages/frontend/src/test-utils/screen-scenario-catalog.test.tsx b/packages/frontend/src/test-utils/screen-scenario-catalog.test.tsx new file mode 100644 index 00000000..be65545e --- /dev/null +++ b/packages/frontend/src/test-utils/screen-scenario-catalog.test.tsx @@ -0,0 +1,40 @@ +import { expect, test } from 'vitest'; +import { getTermsQueryOptions } from '#frontend/queries/terms.ts'; +import { + appConfigQueryData, + renderRoute, +} from '#frontend/test-utils/route-test-utils.tsx'; +import { screenScenarioDefinitions } from '#frontend/test-utils/screen-scenario-catalog.ts'; + +const routeScenarios = screenScenarioDefinitions.filter( + (scenario) => scenario.runtime === 'route', +); + +test('declares unique scenario and variant IDs', () => { + const scenarioIds = screenScenarioDefinitions.map((scenario) => scenario.id); + expect(new Set(scenarioIds).size).toBe(scenarioIds.length); + + for (const scenario of screenScenarioDefinitions) { + const variantIds = scenario.variants.map((variant) => variant.id); + expect(new Set(variantIds).size).toBe(variantIds.length); + } +}); + +for (const scenario of routeScenarios) { + test(`renders the ${scenario.id} Screen Lab route state`, async () => { + const { screen } = await renderRoute({ + initialLocation: scenario.entryPath, + queryData: [ + appConfigQueryData(), + { + queryKey: getTermsQueryOptions('en').queryKey, + data: { terms: [] }, + }, + ], + }); + + await expect + .element(screen.getByText(scenario.expectedText, { exact: true }).first()) + .toBeVisible(); + }); +} diff --git a/packages/frontend/src/test-utils/screen-scenario-catalog.ts b/packages/frontend/src/test-utils/screen-scenario-catalog.ts new file mode 100644 index 00000000..0baba4c1 --- /dev/null +++ b/packages/frontend/src/test-utils/screen-scenario-catalog.ts @@ -0,0 +1,237 @@ +export type ScreenScenarioGroup = + | 'public' + | 'account' + | 'security' + | 'oauth' + | 'admin'; + +export type ScreenScenarioLocale = 'en' | 'ko' | 'ja'; +export type ScreenScenarioColorScheme = 'light' | 'dark'; +export type ScreenScenarioViewport = 'desktop' | 'mobile'; + +export type ScreenScenarioVariant = { + id: string; + locale: ScreenScenarioLocale; + colorScheme: ScreenScenarioColorScheme; + viewport: ScreenScenarioViewport; +}; + +type ScreenScenarioBase = { + id: string; + title: string; + description: string; + group: ScreenScenarioGroup; + entryPath: string; + readySelector: string; + variants: readonly ScreenScenarioVariant[]; + maskSelectors?: readonly string[]; +}; + +export type RouteScreenScenarioDefinition = ScreenScenarioBase & { + runtime: 'route'; + expectedText: string; +}; + +export type ServerScreenScenarioDefinition = ScreenScenarioBase & { + runtime: 'server'; +}; + +export type ScreenScenarioDefinition = + | RouteScreenScenarioDefinition + | ServerScreenScenarioDefinition; + +const DESKTOP_VARIANT: ScreenScenarioVariant = { + id: 'desktop', + locale: 'en', + colorScheme: 'light', + viewport: 'desktop', +}; + +const MOBILE_VARIANT: ScreenScenarioVariant = { + id: 'mobile', + locale: 'en', + colorScheme: 'light', + viewport: 'mobile', +}; + +function createVariants( + mobile: boolean, + additional: readonly ScreenScenarioVariant[] = [], +): readonly ScreenScenarioVariant[] { + return [DESKTOP_VARIANT, ...(mobile ? [MOBILE_VARIANT] : []), ...additional]; +} + +function defineScreenScenarios< + const T extends readonly ScreenScenarioDefinition[], +>(scenarios: T): T { + return scenarios; +} + +export const screenScenarioDefinitions = defineScreenScenarios([ + { + id: 'login', + title: 'Password login', + description: 'Password-based login form.', + group: 'public', + runtime: 'route', + entryPath: '/login/password', + readySelector: 'input[name="email"]', + expectedText: 'Log in', + variants: createVariants(true, [ + { + id: 'ko-light', + locale: 'ko', + colorScheme: 'light', + viewport: 'desktop', + }, + { + id: 'ja-light', + locale: 'ja', + colorScheme: 'light', + viewport: 'desktop', + }, + { + id: 'en-dark', + locale: 'en', + colorScheme: 'dark', + viewport: 'desktop', + }, + ]), + }, + { + id: 'register', + title: 'Registration', + description: 'Public password registration form.', + group: 'public', + runtime: 'route', + entryPath: '/register', + readySelector: 'input[name="email"]', + expectedText: 'Create account', + variants: createVariants(true), + }, + { + id: 'email-verification', + title: 'Email verification', + description: 'Pending email-verification token entry.', + group: 'security', + runtime: 'route', + entryPath: '/verify/email?email=screen-lab-unverified%40example.com', + readySelector: 'input[name="token"]', + expectedText: 'Email Verification', + variants: createVariants(true), + }, + { + id: 'password-reset', + title: 'Password reset', + description: 'Password reset form with a token supplied by URL.', + group: 'security', + runtime: 'route', + entryPath: '/password/reset?token=screen-lab-reset-token', + readySelector: 'input[name="password"]', + expectedText: 'Reset Password', + variants: createVariants(true), + }, + { + id: 'profile', + title: 'Profile', + description: 'Authenticated account and security overview.', + group: 'account', + runtime: 'server', + entryPath: '/profile', + readySelector: 'main', + variants: createVariants(true), + }, + { + id: 'admin-users', + title: 'Admin users', + description: 'Administrative user directory.', + group: 'admin', + runtime: 'server', + entryPath: '/admin/users', + readySelector: '[data-testid="admin-users-directory"]', + variants: createVariants(false), + }, + { + id: 'oauth-consent', + title: 'OAuth consent', + description: 'Consent prompt for a configured OIDC client.', + group: 'oauth', + runtime: 'server', + entryPath: '/oauth/authorize', + readySelector: '[data-testid="consent-allow"]', + variants: createVariants(true), + }, + { + id: 'account-selection', + title: 'Account selection', + description: 'Remembered-account chooser for an OIDC authorization.', + group: 'oauth', + runtime: 'server', + entryPath: '/oauth/authorize', + readySelector: '[data-testid="account-list"]', + variants: createVariants(true), + }, + { + id: 'totp-verification', + title: 'TOTP verification', + description: 'Second-factor challenge for a user with TOTP enabled.', + group: 'security', + runtime: 'server', + entryPath: '/verify/totp', + readySelector: 'input[inputMode="numeric"]', + variants: createVariants(true), + }, + { + id: 'totp-setup', + title: 'TOTP setup', + description: 'Required TOTP enrollment at the QR-code step.', + group: 'security', + runtime: 'server', + entryPath: '/setup/totp', + readySelector: 'img[alt="TOTP QR Code"]', + variants: createVariants(true), + maskSelectors: ['img[alt="TOTP QR Code"]'], + }, + { + id: 'passkey-setup', + title: 'Passkey setup', + description: 'Required passkey enrollment screen.', + group: 'security', + runtime: 'server', + entryPath: '/setup/passkey', + readySelector: '#passkey-name', + variants: createVariants(true), + }, + { + id: 'error', + title: 'Error', + description: 'Public protocol error presentation.', + group: 'public', + runtime: 'route', + entryPath: '/error?code=SCREEN_LAB&message=Screen+Lab+example+error', + readySelector: '[data-testid="error-code"]', + expectedText: 'Screen Lab example error', + variants: createVariants(true), + }, +]); + +export type ScreenScenarioId = (typeof screenScenarioDefinitions)[number]['id']; + +export type ServerScreenScenarioId = Extract< + (typeof screenScenarioDefinitions)[number], + { runtime: 'server' } +>['id']; + +export function findScreenScenarioDefinition( + id: string, +): (typeof screenScenarioDefinitions)[number] | undefined { + return screenScenarioDefinitions.find((scenario) => scenario.id === id); +} + +export function findScreenScenarioVariant( + scenario: ScreenScenarioDefinition, + variantId: string | undefined, +): ScreenScenarioVariant | undefined { + const resolvedId = variantId ?? 'desktop'; + return scenario.variants.find((variant) => variant.id === resolvedId); +} diff --git a/packages/frontend/tsconfig.e2e.json b/packages/frontend/tsconfig.e2e.json index 757db80b..7f386d47 100644 --- a/packages/frontend/tsconfig.e2e.json +++ b/packages/frontend/tsconfig.e2e.json @@ -13,6 +13,7 @@ "e2e/helpers", "e2e/fixtures", "e2e/setup", - "e2e/configs" + "e2e/configs", + "e2e/screen-lab" ] } diff --git a/packages/frontend/vitest.config.ts b/packages/frontend/vitest.config.ts index 17204a6c..dcada4c7 100644 --- a/packages/frontend/vitest.config.ts +++ b/packages/frontend/vitest.config.ts @@ -7,6 +7,13 @@ import { defineConfig } from 'vitest/config'; const MODE = process.env['VITEST_BROWSER_MODE']; const IS_COVERAGE = process.env['VITEST_COVERAGE'] === '1'; const HOST = MODE === 'preview' ? '0.0.0.0' : '127.0.0.1'; +const BROWSER_API_PORT = Number( + process.env['VITEST_BROWSER_API_PORT'] ?? Number.NaN, +); +const browserApiPort = + Number.isInteger(BROWSER_API_PORT) && BROWSER_API_PORT > 0 + ? { port: BROWSER_API_PORT } + : {}; export default defineConfig({ server: { @@ -45,6 +52,7 @@ export default defineConfig({ enabled: true, api: { host: HOST, + ...browserApiPort, }, provider: MODE === 'preview' ? preview() : playwright(), headless: MODE !== 'preview',