diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c4b62ae..c582b545 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,12 +50,30 @@ jobs: uses: actions/upload-artifact@v7.0.1 with: name: dist - path: packages/*/dist + path: | + packages/*/dist + packages/server/public + include-hidden-files: true - test: - name: Test + test-suite: + name: Test / ${{ matrix.name }} needs: build runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Server + command: pnpm --filter @tinyrack/tinyauth-server test --run + - name: Tools + command: pnpm --filter @tinyrack/tinyauth-tools test --run + - name: Frontend unit + command: pnpm --filter @tinyrack/tinyauth-frontend test:unit --run + browsers: true + - name: Standalone + command: pnpm --filter @tinyrack/tinyauth-standalone test:prepared --run && pnpm --filter @tinyrack/tinyauth-standalone test:dist:prepared + - name: Example smoke + command: pnpm --filter @tinyauth-server-examples/node-hono-sqlite test:prepared steps: - name: Check out repository @@ -77,24 +95,120 @@ jobs: uses: actions/download-artifact@v8.0.1 with: name: dist + path: packages - - name: Backend tests - run: pnpm --filter @tinyrack/tinyauth-server test - - - name: Install Playwright browsers for frontend unit tests + - name: Install Playwright browsers + if: matrix.browsers == true run: pnpm --filter @tinyrack/tinyauth-frontend exec playwright install --with-deps chromium firefox - - name: Frontend unit tests - run: pnpm --filter @tinyrack/tinyauth-frontend test:unit + - name: Run ${{ matrix.name }} + run: ${{ matrix.command }} + + frontend-smoke: + name: Test / Frontend smoke (${{ matrix.shard }}/2) + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2] - - name: Standalone tests - run: pnpm --filter @tinyrack/tinyauth-standalone test + steps: + - name: Check out repository + uses: actions/checkout@v6.0.3 - - name: Standalone dist tests - run: pnpm --filter @tinyrack/tinyauth-standalone test:dist + - name: Set up pnpm + uses: pnpm/action-setup@v6.0.9 - - name: Frontend smoke E2E - run: pnpm --filter @tinyrack/tinyauth-frontend run test:e2e:smoke + - 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 --with-deps chromium + + - name: Run frontend smoke shard + env: + PLAYWRIGHT_BLOB_OUTPUT_FILE: blob-report/report-${{ matrix.shard }}.zip + run: >- + pnpm --filter @tinyrack/tinyauth-frontend run test:e2e:smoke + --shard=${{ matrix.shard }}/2 --workers=100% --reporter=blob + + - name: Upload blob report + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: frontend-smoke-${{ matrix.shard }} + path: packages/frontend/blob-report + if-no-files-found: error + retention-days: 14 + + test: + name: Test + needs: [test-suite, frontend-smoke] + if: always() + runs-on: ubuntu-latest + + 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 frontend smoke reports + uses: actions/download-artifact@v8.0.1 + with: + pattern: frontend-smoke-* + path: packages/frontend/blob-report + merge-multiple: true + + - name: Merge frontend smoke report + working-directory: packages/frontend + run: pnpm exec playwright merge-reports --reporter=html blob-report + + - name: Upload frontend smoke report + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: frontend-smoke-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 }} + run: | + if [ "$SUITE_RESULT" != "success" ]; then + echo "Test suites finished with result: $SUITE_RESULT" + exit 1 + fi + if [ "$SMOKE_RESULT" != "success" ]; then + echo "Frontend smoke shards finished with result: $SMOKE_RESULT" + exit 1 + fi homepage: name: Homepage @@ -263,8 +377,8 @@ jobs: echo "Performance aggregation finished with result: $AGGREGATION_RESULT" exit 1 fi - windows-compatibility: - name: Windows Compatibility + windows-build: + name: Windows / Build needs: build runs-on: windows-latest @@ -293,23 +407,177 @@ jobs: - name: Lint run: pnpm biome check . - - name: Backend tests - run: pnpm --filter @tinyrack/tinyauth-server test --run + - name: Upload Windows dist artifacts + uses: actions/upload-artifact@v7.0.1 + with: + name: dist-windows + path: | + packages/*/dist + packages/server/public + include-hidden-files: true + + windows-test-suite: + name: Windows / ${{ matrix.name }} + needs: windows-build + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - name: Server + command: pnpm --filter @tinyrack/tinyauth-server test --run + - name: Tools + command: pnpm --filter @tinyrack/tinyauth-tools test --run + - name: Standalone + command: pnpm --filter @tinyrack/tinyauth-standalone test:prepared --run && pnpm --filter @tinyrack/tinyauth-standalone test:dist:prepared + - name: Frontend unit + command: pnpm --filter @tinyrack/tinyauth-frontend test:unit --run + browsers: true - - name: Tools tests - run: pnpm --filter @tinyrack/tinyauth-tools test --run + steps: + - name: Disable Git CRLF conversion + run: git config --global core.autocrlf false - - name: Standalone tests - run: pnpm --filter @tinyrack/tinyauth-standalone test --run + - name: Check out repository + uses: actions/checkout@v6.0.3 - - name: Install Playwright browsers for frontend tests + - 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 Windows dist artifacts + uses: actions/download-artifact@v8.0.1 + with: + name: dist-windows + path: packages + + - name: Install Playwright browsers + if: matrix.browsers == true run: pnpm --filter @tinyrack/tinyauth-frontend exec playwright install chromium firefox - - name: Frontend unit tests - run: pnpm --filter @tinyrack/tinyauth-frontend test:unit --run + - name: Run ${{ matrix.name }} + run: ${{ matrix.command }} + + windows-frontend-smoke: + name: Windows / Frontend smoke (${{ matrix.shard }}/2) + needs: windows-build + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2] + + steps: + - name: Disable Git CRLF conversion + run: git config --global core.autocrlf false + + - 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 Windows dist artifacts + uses: actions/download-artifact@v8.0.1 + with: + name: dist-windows + path: packages + + - name: Install Chromium + run: pnpm --filter @tinyrack/tinyauth-frontend exec playwright install chromium + + - name: Run frontend smoke shard + env: + PLAYWRIGHT_BLOB_OUTPUT_FILE: blob-report/report-${{ matrix.shard }}.zip + run: >- + pnpm --filter @tinyrack/tinyauth-frontend run test:e2e:smoke + --shard=${{ matrix.shard }}/2 --workers=100% --reporter=blob + + - name: Upload blob report + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: windows-frontend-smoke-${{ matrix.shard }} + path: packages/frontend/blob-report + if-no-files-found: error + retention-days: 14 + + windows-compatibility: + name: Windows Compatibility + needs: [windows-test-suite, windows-frontend-smoke] + if: always() + runs-on: windows-latest + + steps: + - name: Disable Git CRLF conversion + run: git config --global core.autocrlf false + + - 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 Windows frontend smoke reports + uses: actions/download-artifact@v8.0.1 + with: + pattern: windows-frontend-smoke-* + path: packages/frontend/blob-report + merge-multiple: true + + - name: Merge Windows frontend smoke report + working-directory: packages/frontend + run: pnpm exec playwright merge-reports --reporter=html blob-report - - name: Frontend smoke E2E - run: pnpm --filter @tinyrack/tinyauth-frontend run test:e2e:smoke + - name: Upload Windows frontend smoke report + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: windows-frontend-smoke-report + path: packages/frontend/playwright-report + if-no-files-found: warn + retention-days: 14 + + - name: Verify Windows test results + env: + SUITE_RESULT: ${{ needs.windows-test-suite.result }} + SMOKE_RESULT: ${{ needs.windows-frontend-smoke.result }} + shell: bash + run: | + if [ "$SUITE_RESULT" != "success" ]; then + echo "Windows test suites finished with result: $SUITE_RESULT" + exit 1 + fi + if [ "$SMOKE_RESULT" != "success" ]; then + echo "Windows frontend smoke shards finished with result: $SMOKE_RESULT" + exit 1 + fi docker-build: name: Docker Build diff --git a/examples/servers/node-hono-sqlite/package.json b/examples/servers/node-hono-sqlite/package.json index 0668c4d8..d53aceae 100644 --- a/examples/servers/node-hono-sqlite/package.json +++ b/examples/servers/node-hono-sqlite/package.json @@ -8,7 +8,8 @@ "dev": "pnpm --filter @tinyrack/tinyauth-frontend build && cross-env NODE_OPTIONS=--conditions=@tinyauth/source tsx watch src/index.ts", "start": "pnpm --filter @tinyrack/tinyauth-frontend build && node --conditions=@tinyauth/source --import tsx src/index.ts", "build": "tsc --noEmit", - "test": "pnpm --filter @tinyrack/tinyauth-frontend build && node --conditions=@tinyauth/source --import tsx src/smoke-test.ts" + "test": "pnpm --filter @tinyrack/tinyauth-server build && pnpm --filter @tinyrack/tinyauth-frontend build && pnpm run test:prepared", + "test:prepared": "node --conditions=@tinyauth/source --import tsx src/smoke-test.ts" }, "dependencies": { "@hono/node-server": "catalog:", diff --git a/package.json b/package.json index 689294f6..d5678c25 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "packageManager": "pnpm@11.10.0", "scripts": { "dev": "pnpm --filter '@tinyrack/tinyauth-*' --parallel dev", - "test": "pnpm -r --parallel --workspace-concurrency=Infinity test && pnpm --filter @tinyrack/tinyauth-standalone test:dist", - "build": "pnpm -r build", + "test": "node packages/tools/src/scripts/run-validation-tests.ts", + "build": "pnpm -r --workspace-concurrency=Infinity build", "biome": "biome", "tools": "tinyauth-tools", "test:setup:browsers": "pnpm --filter @tinyrack/tinyauth-frontend exec playwright install" diff --git a/packages/frontend/e2e/helpers/journey.ts b/packages/frontend/e2e/helpers/journey.ts index 886167f3..61b43183 100644 --- a/packages/frontend/e2e/helpers/journey.ts +++ b/packages/frontend/e2e/helpers/journey.ts @@ -104,7 +104,7 @@ export async function completeTotpVerify( page: Page, secret: string, ): Promise { - const code = generateTotpCode(secret); + const code = await generateTotpCode(secret); await fillPinInput(page, code); } diff --git a/packages/frontend/e2e/helpers/profile-page.ts b/packages/frontend/e2e/helpers/profile-page.ts index 8963d120..f05366c3 100644 --- a/packages/frontend/e2e/helpers/profile-page.ts +++ b/packages/frontend/e2e/helpers/profile-page.ts @@ -30,9 +30,9 @@ export const changePasswordModal = { currentPassword: '#current-password', newPassword: '#new-password-change', confirmPassword: '#confirm-password-change', - cancelButton: '.tr-dialog-box [data-testid="change-password-cancel"]', - submitButton: '.tr-dialog-box [data-testid="change-password-submit"]', - fieldError: '.tr-dialog-box [data-testid^="change-password-error"]', + cancelButton: '[role="dialog"] [data-testid="change-password-cancel"]', + submitButton: '[role="dialog"] [data-testid="change-password-submit"]', + fieldError: '[role="dialog"] [data-testid^="change-password-error"]', } as const; /** @@ -41,9 +41,9 @@ export const changePasswordModal = { export const setPasswordModal = { newPassword: '#new-password', confirmPassword: '#confirm-password', - cancelButton: '.tr-dialog-box [data-testid="set-password-cancel"]', - submitButton: '.tr-dialog-box [data-testid="set-password-submit"]', - fieldError: '.tr-dialog-box [data-testid^="set-password-error"]', + cancelButton: '[role="dialog"] [data-testid="set-password-cancel"]', + submitButton: '[role="dialog"] [data-testid="set-password-submit"]', + fieldError: '[role="dialog"] [data-testid^="set-password-error"]', } as const; /** @@ -51,9 +51,9 @@ export const setPasswordModal = { */ export const removePasswordModal = { currentPassword: '#current-password-remove', - cancelButton: '.tr-dialog-box [data-testid="remove-password-cancel"]', - submitButton: '.tr-dialog-box [data-testid="remove-password-submit"]', - fieldError: '.tr-dialog-box [data-testid="remove-password-error"]', + cancelButton: '[role="dialog"] [data-testid="remove-password-cancel"]', + submitButton: '[role="dialog"] [data-testid="remove-password-submit"]', + fieldError: '[role="dialog"] [data-testid="remove-password-error"]', } as const; /** @@ -61,30 +61,30 @@ export const removePasswordModal = { */ export const disableTotpModal = { codeInput: '#disable-totp-code', - cancelButton: '.tr-dialog-box [data-testid="disable-totp-cancel"]', - submitButton: '.tr-dialog-box [data-testid="disable-totp-submit"]', - fieldError: '.tr-dialog-box [data-testid="disable-totp-error"]', - warningAlert: '.tr-dialog-box [data-testid="alert-banner-warning"]', + cancelButton: '[role="dialog"] [data-testid="disable-totp-cancel"]', + submitButton: '[role="dialog"] [data-testid="disable-totp-submit"]', + fieldError: '[role="dialog"] [data-testid="disable-totp-error"]', + warningAlert: '[role="dialog"] [data-testid="alert-banner-warning"]', } as const; export const regenerateTotpModal = { - fieldError: '.tr-dialog-box [data-testid="pin-input-error"]', - errorAlert: '.tr-dialog-box [data-testid="alert-banner-error"]', - recoveryCodesGrid: '.tr-dialog-box [data-testid="recovery-codes-grid"]', - confirmCheckbox: '.tr-dialog-box [data-testid="recovery-codes-confirm"]', - confirmButton: '.tr-dialog-box [data-testid="recovery-codes-submit"]', + fieldError: '[role="dialog"] [data-testid="pin-input-error"]', + errorAlert: '[role="dialog"] [data-testid="alert-banner-error"]', + recoveryCodesGrid: '[role="dialog"] [data-testid="recovery-codes-grid"]', + confirmCheckbox: '[role="dialog"] [data-testid="recovery-codes-confirm"]', + confirmButton: '[role="dialog"] [data-testid="recovery-codes-submit"]', } as const; /** * Selectors for the setup TOTP modal (from profile page). */ export const setupTotpModal = { - qrCodeImage: '.tr-dialog-box img[alt="TOTP QR Code"]', - nextButton: '.tr-dialog-box [data-testid="totp-qr-next"]', - pinInput: '.tr-dialog-box input[inputMode="numeric"]', - recoveryCodesGrid: '.tr-dialog-box [data-testid="recovery-codes-grid"]', - confirmCheckbox: '.tr-dialog-box [data-testid="recovery-codes-confirm"]', - confirmButton: '.tr-dialog-box [data-testid="recovery-codes-submit"]', + qrCodeImage: '[role="dialog"] img[alt="TOTP QR Code"]', + nextButton: '[role="dialog"] [data-testid="totp-qr-next"]', + pinInput: '[role="dialog"] input[inputMode="numeric"]', + recoveryCodesGrid: '[role="dialog"] [data-testid="recovery-codes-grid"]', + confirmCheckbox: '[role="dialog"] [data-testid="recovery-codes-confirm"]', + confirmButton: '[role="dialog"] [data-testid="recovery-codes-submit"]', } as const; /** @@ -92,10 +92,10 @@ export const setupTotpModal = { */ export const deleteAccountModal = { confirmInput: '#delete-confirmation', - cancelButton: '.tr-dialog-box [data-testid="delete-account-cancel"]', - submitButton: '.tr-dialog-box [data-testid="delete-account-submit"]', - fieldError: '.tr-dialog-box [data-testid="delete-account-error"]', - warningAlert: '.tr-dialog-box [data-testid="alert-banner-error"]', + cancelButton: '[role="dialog"] [data-testid="delete-account-cancel"]', + submitButton: '[role="dialog"] [data-testid="delete-account-submit"]', + fieldError: '[role="dialog"] [data-testid="delete-account-error"]', + warningAlert: '[role="dialog"] [data-testid="alert-banner-error"]', } as const; /** @@ -103,40 +103,40 @@ export const deleteAccountModal = { */ export const setupPasskeyModal = { nameInput: '#passkey-name', - cancelButton: '.tr-dialog-box [data-testid="setup-passkey-cancel"]', - continueButton: '.tr-dialog-box [data-testid="setup-passkey-continue"]', - waitingMessage: '.tr-dialog-box [data-testid="setup-passkey-loading"]', - fieldError: '.tr-dialog-box [data-testid="setup-passkey-error"]', + cancelButton: '[role="dialog"] [data-testid="setup-passkey-cancel"]', + continueButton: '[role="dialog"] [data-testid="setup-passkey-continue"]', + waitingMessage: '[role="dialog"] [data-testid="setup-passkey-loading"]', + fieldError: '[role="dialog"] [data-testid="setup-passkey-error"]', } as const; /** * Selectors for the manage passkeys modal. */ export const managePasskeysModal = { - closeButton: '.tr-dialog-box [data-testid="manage-passkeys-close"]', - addNewButton: '.tr-dialog-box [data-testid="manage-passkeys-add-new"]', - passkeyItem: '.tr-dialog-box [data-testid="passkey-item"]', - renameInput: '.tr-dialog-box [data-testid="passkey-rename-input"]', - deleteError: '.tr-dialog-box [data-testid="alert-banner-error"]', - emptyState: '.tr-dialog-box [data-testid="passkeys-empty"]', + closeButton: '[role="dialog"] [data-testid="manage-passkeys-close"]', + addNewButton: '[role="dialog"] [data-testid="manage-passkeys-add-new"]', + passkeyItem: '[role="dialog"] [data-testid="passkey-item"]', + renameInput: '[role="dialog"] [data-testid="passkey-rename-input"]', + deleteError: '[role="dialog"] [data-testid="alert-banner-error"]', + emptyState: '[role="dialog"] [data-testid="passkeys-empty"]', } as const; /** * Selectors for the unlink OAuth modal. */ export const unlinkOAuthModal = { - cancelButton: '.tr-dialog-box [data-testid="unlink-oauth-cancel"]', - unlinkButton: '.tr-dialog-box [data-testid="unlink-oauth-unlink"]', - warningAlert: '.tr-dialog-box [data-testid="alert-banner-warning"]', - errorAlert: '.tr-dialog-box [data-testid="alert-banner-error"]', + cancelButton: '[role="dialog"] [data-testid="unlink-oauth-cancel"]', + unlinkButton: '[role="dialog"] [data-testid="unlink-oauth-unlink"]', + warningAlert: '[role="dialog"] [data-testid="alert-banner-warning"]', + errorAlert: '[role="dialog"] [data-testid="alert-banner-error"]', } as const; /** * Generic modal selector. */ export const modal = { - openModal: '.tr-dialog-box', - closeButton: '.tr-dialog-box [data-testid="modal-close"]', + openModal: '[role="dialog"]', + closeButton: '[role="dialog"] [data-testid="modal-close"]', } as const; /** diff --git a/packages/frontend/e2e/helpers/totp.ts b/packages/frontend/e2e/helpers/totp.ts index 74ce1dd1..840a5628 100644 --- a/packages/frontend/e2e/helpers/totp.ts +++ b/packages/frontend/e2e/helpers/totp.ts @@ -22,10 +22,23 @@ export function interceptTotpSecret(page: Page): Promise { }); } +const TOTP_PERIOD_MS = 30_000; +const MINIMUM_TOTP_VALIDITY_MS = 3_000; + /** - * Generates a valid 6-digit TOTP code from a secret. + * Generates a valid 6-digit TOTP code with enough remaining lifetime for a + * browser submission. This removes the boundary race where a code generated + * at the end of a TOTP period expires while the form request is in flight. */ -export function generateTotpCode(secret: string): string { +export async function generateTotpCode(secret: string): Promise { + const elapsedInPeriod = Date.now() % TOTP_PERIOD_MS; + const remainingValidity = TOTP_PERIOD_MS - elapsedInPeriod; + if (remainingValidity < MINIMUM_TOTP_VALIDITY_MS) { + await new Promise((resolve) => { + setTimeout(resolve, remainingValidity); + }); + } + return generateSync({ secret }); } @@ -62,7 +75,7 @@ export async function setupTotpViaApi( const { secret } = (await setupRes.json()) as { secret: string }; // Step 2: Verify with valid code - const code = generateTotpCode(secret); + const code = await generateTotpCode(secret); const verifyRes = await request.post(`${baseURL}/api/user/totp/verify`, { data: { code }, }); diff --git a/packages/frontend/e2e/run-sharded.ts b/packages/frontend/e2e/run-sharded.ts new file mode 100644 index 00000000..1ffc485b --- /dev/null +++ b/packages/frontend/e2e/run-sharded.ts @@ -0,0 +1,91 @@ +import { spawn } from 'node:child_process'; +import { mkdir, rm } from 'node:fs/promises'; +import { availableParallelism } from 'node:os'; +import path from 'node:path'; + +const cpuCount = availableParallelism(); +const shardCount = Math.min(4, Math.max(1, Math.floor(cpuCount / 8))); +const testWorkerBudget = Math.max(1, cpuCount - shardCount); +const reportDirectory = path.resolve('blob-report'); +const viteCacheRoot = path.resolve('node_modules/.cache/e2e-vite-shards'); +const playwrightCacheRoot = path.resolve( + 'node_modules/.cache/e2e-playwright-shards', +); +const passthroughArgs = process.argv.slice(2); + +function runPlaywright( + args: string[], + environment: NodeJS.ProcessEnv = process.env, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ['--import', 'tsx', 'node_modules/@playwright/test/cli.js', ...args], + { + env: environment, + stdio: 'inherit', + }, + ); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `Playwright failed (${signal ? `signal ${signal}` : `exit ${code}`})`, + ), + ); + }); + }); +} + +await rm(reportDirectory, { recursive: true, force: true }); +await rm(viteCacheRoot, { recursive: true, force: true }); +await rm(playwrightCacheRoot, { recursive: true, force: true }); +await mkdir(reportDirectory, { recursive: true }); + +const baseWorkers = Math.floor(testWorkerBudget / shardCount); +const extraWorkers = testWorkerBudget % shardCount; +const shardResults = await Promise.allSettled( + Array.from({ length: shardCount }, (_, index) => { + const shard = index + 1; + const workers = baseWorkers + (index < extraWorkers ? 1 : 0); + process.stdout.write( + `[e2e] shard ${shard}/${shardCount}: ${workers} worker${workers === 1 ? '' : 's'}\n`, + ); + return runPlaywright( + [ + 'test', + `--shard=${shard}/${shardCount}`, + `--workers=${workers}`, + `--output=test-results/shard-${shard}`, + '--reporter=blob', + ...passthroughArgs, + ], + { + ...process.env, + E2E_VITE_CACHE_DIR: path.join(viteCacheRoot, `shard-${shard}`), + PLAYWRIGHT_BLOB_OUTPUT_FILE: path.join( + reportDirectory, + `report-${shard}.zip`, + ), + PWTEST_CACHE_DIR: path.join(playwrightCacheRoot, `shard-${shard}`), + }, + ); + }), +); + +await runPlaywright(['merge-reports', '--reporter=html', reportDirectory], { + ...process.env, + PLAYWRIGHT_HTML_OPEN: 'never', +}); + +const failures = shardResults.filter((result) => result.status === 'rejected'); +if (failures.length > 0) { + throw new AggregateError( + failures.map((failure) => String(failure.reason)), + 'One or more Playwright shards failed', + ); +} diff --git a/packages/frontend/e2e/setup/create-server.ts b/packages/frontend/e2e/setup/create-server.ts index b06f51b4..8746e0f2 100644 --- a/packages/frontend/e2e/setup/create-server.ts +++ b/packages/frontend/e2e/setup/create-server.ts @@ -3,16 +3,20 @@ import { once } from 'node:events'; import { Server as HttpServer } from 'node:http'; import type { AddressInfo } from 'node:net'; import { createServer as createNetServer } from 'node:net'; +import { fileURLToPath } from 'node:url'; import { serve } from '@hono/node-server'; import { createApp } from '@tinyrack/tinyauth-server'; import type { TinyAuthRuntimeConfig } from '@tinyrack/tinyauth-server/config'; -import { createProxyHandler } from '@tinyrack/tinyauth-server/frontend/proxy'; +import { createStaticHandler } from '@tinyrack/tinyauth-server/frontend/static'; import type { E2EConfigInput } from '#frontend-e2e/fixtures/index.ts'; import { resolveTestEmailConfig } from '#frontend-e2e/setup/resolve-test-email.ts'; 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 FRONTEND_PUBLIC_PATH = fileURLToPath( + new URL('../../../server/public', import.meta.url), +); export type TestHonoApp = Awaited>['app']; @@ -366,8 +370,8 @@ export async function createE2EServer(configFactory: ConfigFactory) { resolvedEmail = rawEmail; } - const defaultFrontend = createProxyHandler({ - upstream: `http://localhost:${frontendPort}`, + const defaultFrontend = createStaticHandler({ + publicPath: FRONTEND_PUBLIC_PATH, }); const config = { diff --git a/packages/frontend/e2e/setup/global-setup.ts b/packages/frontend/e2e/setup/global-setup.ts index 90227d75..636a05e2 100644 --- a/packages/frontend/e2e/setup/global-setup.ts +++ b/packages/frontend/e2e/setup/global-setup.ts @@ -2,86 +2,40 @@ import type { AddressInfo } from 'node:net'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { FullConfig } from '@playwright/test'; -import { createServer as createViteServer, type ViteDevServer } from 'vite'; +import { 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'; -/** - * Every worker shares this one Vite dev server, so any module still cold when - * the run starts gets transformed while the machine is at its busiest and every - * worker waiting on it stalls. Warming by directory rather than by filename - * keeps that from silently rotting: the previous hand-listed set still named - * components that had been deleted, so the login route — the first page nearly - * every test loads — was only partly warm. - * - * Test files are excluded; they are never requested by the browser. - */ -const E2E_FRONTEND_WARMUP_FILES = [ - 'src/main.tsx', - 'src/routeTree.gen.ts', - 'src/components/**/*.tsx', - 'src/features/**/*.tsx', - 'src/routes/**/*.tsx', - 'src/hooks/**/*.ts', - 'src/i18n/**/*.ts', - 'src/libs/**/*.ts', - 'src/queries/**/*.ts', - '!src/**/*.test.tsx', - '!src/**/*.test.ts', - '!src/test-utils/**', -]; - -/** - * TanStack Router serves route components from a `?tsr-split=component` URL, - * which the glob warmup above does not cover, so the two entry routes are - * requested in that exact form as well. - */ -const E2E_FRONTEND_WARMUP_URLS = [ - '/src/main.tsx', - '/src/routeTree.gen.ts', - '/src/routes/login/index.tsx?tsr-split=component', - '/src/routes/login/password/index.tsx?tsr-split=component', -]; function getListeningPort(address: AddressInfo | string | null): number { if (address === null || typeof address === 'string') { - throw new Error('Failed to resolve shared frontend port from Vite server'); + throw new Error('Failed to resolve shared frontend port'); } return address.port; } -async function warmupFrontendServer( - frontendServer: ViteDevServer, -): Promise { - await Promise.all( - E2E_FRONTEND_WARMUP_URLS.map((url) => frontendServer.warmupRequest(url)), - ); -} - export default async function globalSetup(_config: FullConfig) { - const frontendServer = await createViteServer({ - root: frontendRoot, - server: { - hmr: false, + /* + * E2E validation runs after the workspace build. Serving that immutable + * output keeps parallel browser workers away from Vite's development-time + * transform and dependency-optimization lifecycle. + */ + const frontendServer = await createVitePreviewServer({ + build: { + outDir: frontendOutput, + }, + configFile: false, + preview: { + host: '127.0.0.1', port: 0, strictPort: false, - warmup: { - clientFiles: E2E_FRONTEND_WARMUP_FILES, - }, }, + root: frontendRoot, }); - - await frontendServer.listen(); - - const httpServer = frontendServer.httpServer; - if (!httpServer) { - throw new Error('Vite HTTP server is not available after listen()'); - } - - const frontendPort = getListeningPort(httpServer.address()); - await warmupFrontendServer(frontendServer); + const frontendPort = getListeningPort(frontendServer.httpServer.address()); process.env[SHARED_FRONTEND_PORT_ENV] = String(frontendPort); return async () => { diff --git a/packages/frontend/e2e/tests/minimal/accessibility.test.ts b/packages/frontend/e2e/tests/minimal/accessibility.test.ts index aa1592d7..54db92dc 100644 --- a/packages/frontend/e2e/tests/minimal/accessibility.test.ts +++ b/packages/frontend/e2e/tests/minimal/accessibility.test.ts @@ -1,5 +1,5 @@ import AxeBuilder from '@axe-core/playwright'; -import { expect } from '@playwright/test'; +import { expect, type Page } from '@playwright/test'; import { createScenarioFixture } from '#frontend-e2e/fixtures/create-scenario-fixture.ts'; import { createTestConfig, @@ -17,6 +17,27 @@ const test = createScenarioFixture((backendPort) => ({ const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; +async function waitForRenderedTheme( + page: Page, + colorScheme: 'light' | 'dark', +): Promise { + await expect(page.locator('html')).toHaveAttribute( + 'data-theme', + `tinyrack-${colorScheme}`, + ); + await expect(page.getByRole('main')).toBeVisible(); + await expect(page.getByRole('heading').first()).toBeVisible(); + await page.waitForLoadState('networkidle'); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + }); +} + /** * Unauthenticated auth screens. Each is reachable without a session, so they * can be audited directly. @@ -55,6 +76,7 @@ test.describe('Auth screen accessibility', () => { }, colorScheme); await page.goto(route); + await waitForRenderedTheme(page, colorScheme); const results = await new AxeBuilder({ page }) .withTags(WCAG_TAGS) @@ -93,6 +115,7 @@ test.describe('Profile accessibility', () => { E2E_TEST_USER.email, E2E_TEST_USER.password, ); + await waitForRenderedTheme(page, colorScheme); const results = await new AxeBuilder({ page }) .withTags(WCAG_TAGS) diff --git a/packages/frontend/e2e/tests/totp-optional/profile-totp-modals.test.ts b/packages/frontend/e2e/tests/totp-optional/profile-totp-modals.test.ts index 7831b50f..29809afc 100644 --- a/packages/frontend/e2e/tests/totp-optional/profile-totp-modals.test.ts +++ b/packages/frontend/e2e/tests/totp-optional/profile-totp-modals.test.ts @@ -53,7 +53,7 @@ async function loginWithTotpAndGoToProfile( await performLogin(page, email, password); await page.waitForURL('**/verify/totp'); - const code = generateTotpCode(totpSecret); + const code = await generateTotpCode(totpSecret); await fillPinInput(page, code); await page.waitForURL('**/profile'); @@ -146,7 +146,7 @@ test.describe('SetupTotpModal (profile)', () => { await expect(page.locator(setupTotpModal.pinInput).first()).toBeVisible(); // Enter valid TOTP code - const code = generateTotpCode(secret); + const code = await generateTotpCode(secret); await fillPinInput(page, code); // Recovery codes grid should appear @@ -206,7 +206,7 @@ test.describe('SetupTotpModal (profile)', () => { await expect(page.locator(setupTotpModal.pinInput).first()).toBeVisible(); // Step 2: Verify with valid code - const code = generateTotpCode(secret); + const code = await generateTotpCode(secret); await fillPinInput(page, code); // Step 3: Recovery codes @@ -294,7 +294,7 @@ test.describe('DisableTotpModal (profile, optional 2FA)', () => { await expect(page.locator(disableTotpModal.warningAlert)).toBeVisible(); // Enter valid TOTP code - const code = generateTotpCode(secret); + const code = await generateTotpCode(secret); await page.locator(disableTotpModal.codeInput).fill(code); await page.locator(disableTotpModal.submitButton).click(); diff --git a/packages/frontend/e2e/tests/totp-optional/totp-optional.test.ts b/packages/frontend/e2e/tests/totp-optional/totp-optional.test.ts index b8769c5b..4933ea13 100644 --- a/packages/frontend/e2e/tests/totp-optional/totp-optional.test.ts +++ b/packages/frontend/e2e/tests/totp-optional/totp-optional.test.ts @@ -72,7 +72,7 @@ test.describe('TOTP optional configuration', () => { await page.waitForURL('**/verify/totp'); await expect(page).toHaveURL(/\/verify\/totp/); - const code = generateTotpCode(secret); + const code = await generateTotpCode(secret); await fillPinInput(page, code); await page.waitForURL('**/profile'); await expect(page).toHaveURL(/\/profile/); diff --git a/packages/frontend/e2e/tests/totp-required/login-totp-setup.test.ts b/packages/frontend/e2e/tests/totp-required/login-totp-setup.test.ts index 446a4fad..a069f1bd 100644 --- a/packages/frontend/e2e/tests/totp-required/login-totp-setup.test.ts +++ b/packages/frontend/e2e/tests/totp-required/login-totp-setup.test.ts @@ -74,7 +74,7 @@ test.describe('TOTP setup flow (DB user, 2FA required)', () => { await page.locator(totpSetupPage.nextButton).click(); // Verify step: enter valid TOTP code - const code = generateTotpCode(secret); + const code = await generateTotpCode(secret); await fillPinInput(page, code); // Recovery codes step: wait for grid to appear diff --git a/packages/frontend/e2e/tests/totp-required/login-totp-verify.test.ts b/packages/frontend/e2e/tests/totp-required/login-totp-verify.test.ts index 356e302c..fe17f59b 100644 --- a/packages/frontend/e2e/tests/totp-required/login-totp-verify.test.ts +++ b/packages/frontend/e2e/tests/totp-required/login-totp-verify.test.ts @@ -65,7 +65,7 @@ test.describe('TOTP verify flow (DB user with TOTP already set up)', () => { await page.waitForURL('**/verify/totp'); // Enter valid TOTP code - const code = generateTotpCode(totpSecret); + const code = await generateTotpCode(totpSecret); await fillPinInput(page, code); // Should navigate to profile diff --git a/packages/frontend/e2e/tests/totp-required/profile-totp.test.ts b/packages/frontend/e2e/tests/totp-required/profile-totp.test.ts index 042b0d22..0d37ab93 100644 --- a/packages/frontend/e2e/tests/totp-required/profile-totp.test.ts +++ b/packages/frontend/e2e/tests/totp-required/profile-totp.test.ts @@ -58,7 +58,7 @@ async function loginWithTotpAndGoToProfile( await performLogin(page, email, password); await page.waitForURL('**/verify/totp'); - const code = generateTotpCode(totpSecret); + const code = await generateTotpCode(totpSecret); await fillPinInput(page, code); await page.waitForURL('**/profile'); @@ -205,7 +205,7 @@ test.describe('Profile TOTP management (2FA required)', () => { await expect(page.locator(modal.openModal)).toBeVisible(); // Enter valid TOTP code - const code = generateTotpCode(secret); + const code = await generateTotpCode(secret); await page.locator(disableTotpModal.codeInput).fill(code); await page.locator(disableTotpModal.submitButton).click(); @@ -276,7 +276,7 @@ test.describe('Profile TOTP management (2FA required)', () => { await page.locator(profilePage.totpRegenerateButton).click(); await expect(page.locator(modal.openModal)).toBeVisible(); - await fillPinInput(page, generateTotpCode(secret)); + await fillPinInput(page, await generateTotpCode(secret)); await expect( page.locator(regenerateTotpModal.recoveryCodesGrid), ).toBeVisible(); @@ -345,7 +345,7 @@ test.describe('Profile TOTP management (2FA required)', () => { await page.locator(profilePage.totpRegenerateButton).click(); await expect(page.locator(modal.openModal)).toBeVisible(); - await fillPinInput(page, generateTotpCode(secret)); + await fillPinInput(page, await generateTotpCode(secret)); await expect( page.locator(regenerateTotpModal.recoveryCodesGrid), ).toBeVisible(); @@ -402,7 +402,7 @@ test.describe('Profile TOTP management (2FA required)', () => { await page.locator(profilePage.totpRegenerateButton).click(); await expect(page.locator(modal.openModal)).toBeVisible(); - await fillPinInput(page, generateTotpCode(secret)); + await fillPinInput(page, await generateTotpCode(secret)); await expect( page.locator(regenerateTotpModal.recoveryCodesGrid), ).toBeVisible(); diff --git a/packages/frontend/e2e/tests/totp-required/register-totp-setup.test.ts b/packages/frontend/e2e/tests/totp-required/register-totp-setup.test.ts index 66320972..8416a30c 100644 --- a/packages/frontend/e2e/tests/totp-required/register-totp-setup.test.ts +++ b/packages/frontend/e2e/tests/totp-required/register-totp-setup.test.ts @@ -61,7 +61,7 @@ test.describe('Registration + TOTP setup flow', () => { await page.locator(totpSetupPage.nextButton).click(); // Verify step: enter valid TOTP code - const code = generateTotpCode(secret); + const code = await generateTotpCode(secret); await fillPinInput(page, code); // Recovery codes step: wait for grid to appear diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 9aa162cb..8e935206 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -41,8 +41,10 @@ "test:coverage": "pnpm run test:unit:coverage", "test:unit:ui": "vitest --ui", "test:unit:preview": "cross-env VITEST_BROWSER_MODE=preview vitest", - "test:e2e": "node --conditions=@tinyauth/source --import tsx node_modules/@playwright/test/cli.js test", - "test:e2e:smoke": "node --conditions=@tinyauth/source --import tsx node_modules/@playwright/test/cli.js test --project minimal:chromium", + "test:e2e": "node --import tsx node_modules/@playwright/test/cli.js test", + "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" }, "dependencies": { diff --git a/packages/frontend/playwright.config.ts b/packages/frontend/playwright.config.ts index 72d1108c..edeae1bf 100644 --- a/packages/frontend/playwright.config.ts +++ b/packages/frontend/playwright.config.ts @@ -112,25 +112,13 @@ export default defineConfig({ fullyParallel: true, globalSetup: './e2e/setup/global-setup.ts', forbidOnly: !!process.env['CI'], - retries: process.env['CI'] ? 2 : 1, - /** - * A worker here is not just a browser: each scenario fixture also boots its - * own Hono server with a MikroORM SQLite database. One per core starves the - * machine, and the starvation surfaces as ordinary-looking action timeouts on - * whichever tests happened to be running. - * - * Raising the worker count also buys very little, because the bottleneck is - * the single Vite dev server every worker shares rather than the workers - * themselves. Measured on a 32-core machine, full suite, `--retries=0`: - * - * | 8 (25%) | 10.0-10.6 min | 3/3 runs green | - * | 16 (50%) | 8.3-8.5 min | 1/2 runs green | - * | 32(100%) | 7.8-8.3 min | 1/3 runs green | - * - * So the curve is flat past 8 and the whole cost of determinism is ~2 - * minutes. Do not raise this without re-measuring both columns. + retries: 0, + /* + * Standalone Playwright runs use every available CPU. The root validation + * command splits that same global budget across isolated shard processes so + * no single shared Vite server becomes the concurrency bottleneck. */ - workers: process.env['CI'] ? 1 : '25%', + workers: '100%', reporter: 'html', /* * Budgets are sized for a loaded machine, not an idle one. Raising them costs @@ -152,7 +140,7 @@ export default defineConfig({ name: `${config.name}:${browser.name}`, testDir: config.testDir, use: { - trace: 'on-first-retry' as const, + 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 diff --git a/packages/frontend/src/libs/oauth-search.test.ts b/packages/frontend/src/libs/oauth-search.test.ts index 0cb295e7..09937e96 100644 --- a/packages/frontend/src/libs/oauth-search.test.ts +++ b/packages/frontend/src/libs/oauth-search.test.ts @@ -142,7 +142,7 @@ describe('oauth-search helpers', () => { account_selection_state: 'chooser-state-123', }); - expect(parsed.account_selected).toBe('1'); + expect(parsed.account_selected).toBe(1); expect(buildAuthorizeUrl(parsed)).toContain('account_selected=1'); }); @@ -156,7 +156,7 @@ describe('oauth-search helpers', () => { ui_locales: 'ko en', id_token_hint: 'id-token-hint', acr_values: 'urn:mace:incommon:iap:silver', - account_selected: '1', + account_selected: 1, account_selection_state: 'chooser-state-123', }); diff --git a/packages/frontend/src/libs/oauth-search.ts b/packages/frontend/src/libs/oauth-search.ts index 16149eee..9702a5d5 100644 --- a/packages/frontend/src/libs/oauth-search.ts +++ b/packages/frontend/src/libs/oauth-search.ts @@ -48,10 +48,13 @@ export const OAuthSearchSchema = z.object({ .optional(), account_selected: z .preprocess((value) => { - if (value === 1) return '1'; + if (value === 1) return 1; if (typeof value !== 'string') return value; - return decodeURIComponent(value).replaceAll('"', '').replaceAll('\\', ''); - }, z.literal('1')) + const normalized = decodeURIComponent(value) + .replaceAll('"', '') + .replaceAll('\\', ''); + return normalized === '1' ? 1 : normalized; + }, z.literal(1)) .optional(), account_selection_state: z.string().min(1).max(200).optional(), display: z.enum(['page', 'popup', 'touch', 'wap']).optional(), @@ -159,7 +162,7 @@ export function buildAuthenticatedAuthorizeUrl(search: OAuthSearch): string { }; if (!search.prompt?.split(' ').includes('select_account')) { - authenticatedSearch.account_selected = '1'; + authenticatedSearch.account_selected = 1; } return buildAuthorizeUrl(authenticatedSearch); diff --git a/packages/frontend/src/routes/account/select/index.tsx b/packages/frontend/src/routes/account/select/index.tsx index 5d49eb8f..cb5365ad 100644 --- a/packages/frontend/src/routes/account/select/index.tsx +++ b/packages/frontend/src/routes/account/select/index.tsx @@ -58,7 +58,7 @@ function buildLoginHref(search: ReturnType) { for (const [key, value] of Object.entries({ ...oauthParams, prompt: appendLoginPrompt(oauthParams.prompt), - account_selected: '1', + account_selected: 1, })) { if (value !== undefined) { params.set(key, String(value)); @@ -78,7 +78,7 @@ function AccountSelect() { const continueWithSelectedAccount = () => { window.location.href = buildAuthorizeUrl({ ...search, - account_selected: '1', + account_selected: 1, }); }; diff --git a/packages/frontend/src/routes/consent/-index.test.tsx b/packages/frontend/src/routes/consent/-index.test.tsx index 311f3d71..72c66e2e 100644 --- a/packages/frontend/src/routes/consent/-index.test.tsx +++ b/packages/frontend/src/routes/consent/-index.test.tsx @@ -150,7 +150,7 @@ describe('/consent', () => { ui_locales: 'ko en', id_token_hint: 'header.payload.signature', acr_values: 'urn:mace:incommon:iap:silver', - account_selected: '1', + account_selected: 1, account_selection_state: 'chooser-state-ui', decision: 'allow', }); diff --git a/packages/frontend/src/routes/login/-index.test.tsx b/packages/frontend/src/routes/login/-index.test.tsx index 63290a45..a504d89d 100644 --- a/packages/frontend/src/routes/login/-index.test.tsx +++ b/packages/frontend/src/routes/login/-index.test.tsx @@ -1,4 +1,3 @@ -import { startAuthentication } from '@simplewebauthn/browser'; import { afterEach, describe, expect, test, vi } from 'vitest'; import type { AppConfigs } from '#frontend/queries/config.ts'; import { appConfigQueryOptions } from '#frontend/queries/config.ts'; @@ -11,11 +10,13 @@ import { renderRoute, } from '#frontend/test-utils/route-test-utils.tsx'; -vi.mock('@simplewebauthn/browser', () => ({ +const webauthnMocks = vi.hoisted(() => ({ startAuthentication: vi.fn(), startRegistration: vi.fn(), })); +vi.mock('@simplewebauthn/browser', () => webauthnMocks); + const baseConfig = { i18n: { supported_languages: ['en'], @@ -99,7 +100,7 @@ function seedOAuthRouteData(config: AppConfigs = baseConfig) { } afterEach(() => { - vi.mocked(startAuthentication).mockReset(); + webauthnMocks.startAuthentication.mockReset(); resetFetchMock(); }); @@ -188,7 +189,7 @@ describe('/login', () => { test('shows guidance when passkey sign in fails from the login page', async () => { const passkeyError = new Error('not allowed'); passkeyError.name = 'NotAllowedError'; - vi.mocked(startAuthentication).mockRejectedValue(passkeyError); + webauthnMocks.startAuthentication.mockRejectedValue(passkeyError); mockJsonResponses({ url: '/api/auth/passkey/options', method: 'POST', diff --git a/packages/frontend/src/routes/login/password/index.tsx b/packages/frontend/src/routes/login/password/index.tsx index 9e8d3765..c9040e9e 100644 --- a/packages/frontend/src/routes/login/password/index.tsx +++ b/packages/frontend/src/routes/login/password/index.tsx @@ -26,6 +26,7 @@ import { extractOAuthParams, hasAuthorizationContext, isOAuthFlow, + type OAuthSearch, OAuthSearchSchema, type SecondFactorMethod, } from '#frontend/libs/oauth-search.ts'; @@ -63,10 +64,10 @@ function LoginPassword() { const router = useRouter(); const queryClient = useQueryClient(); const search = Route.useSearch(); - const authorizeSearch = + const authorizeSearch: OAuthSearch = search.account_selection_state && search.prompt?.split(' ').includes('login') - ? { ...search, account_selected: '1' as const } + ? { ...search, account_selected: 1 } : search; const lang = search.lang ?? i18n.language; diff --git a/packages/frontend/src/routes/verify/passkey/-index.test.tsx b/packages/frontend/src/routes/verify/passkey/-index.test.tsx index e95e7223..a1859298 100644 --- a/packages/frontend/src/routes/verify/passkey/-index.test.tsx +++ b/packages/frontend/src/routes/verify/passkey/-index.test.tsx @@ -1,4 +1,3 @@ -import { startAuthentication } from '@simplewebauthn/browser'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { oauthAccountsQueryOptions } from '#frontend/queries/oauth.ts'; import { getSessionQueryOptions } from '#frontend/queries/session.ts'; @@ -13,11 +12,13 @@ import { routeTestUser, } from '#frontend/test-utils/route-test-utils.tsx'; -vi.mock('@simplewebauthn/browser', () => ({ +const webauthnMocks = vi.hoisted(() => ({ startAuthentication: vi.fn(), startRegistration: vi.fn(), })); +vi.mock('@simplewebauthn/browser', () => webauthnMocks); + function profileQueryData() { return [ appConfigQueryData(routeTestAppConfig), @@ -36,13 +37,13 @@ function profileQueryData() { } afterEach(() => { - vi.mocked(startAuthentication).mockReset(); + webauthnMocks.startAuthentication.mockReset(); resetFetchMock(); }); describe('/verify/passkey', () => { test('continues to profile after successful passkey verification', async () => { - vi.mocked(startAuthentication).mockResolvedValue({ + webauthnMocks.startAuthentication.mockResolvedValue({ id: 'credential-1', rawId: 'credential-1', response: { @@ -97,7 +98,7 @@ describe('/verify/passkey', () => { test('shows TOTP fallback after passkey verification cannot complete', async () => { const passkeyError = new Error('not allowed'); passkeyError.name = 'NotAllowedError'; - vi.mocked(startAuthentication).mockRejectedValue(passkeyError); + webauthnMocks.startAuthentication.mockRejectedValue(passkeyError); const fetchMock = mockJsonResponses( { url: '/api/auth/passkey/options', @@ -141,7 +142,7 @@ describe('/verify/passkey', () => { test('keeps passkey-only failures on the passkey screen', async () => { const passkeyError = new Error('not allowed'); passkeyError.name = 'NotAllowedError'; - vi.mocked(startAuthentication).mockRejectedValue(passkeyError); + webauthnMocks.startAuthentication.mockRejectedValue(passkeyError); const fetchMock = mockJsonResponses( { url: '/api/auth/passkey/options', diff --git a/packages/frontend/vitest.config.ts b/packages/frontend/vitest.config.ts index 519d9b29..17204a6c 100644 --- a/packages/frontend/vitest.config.ts +++ b/packages/frontend/vitest.config.ts @@ -6,10 +6,11 @@ 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'; export default defineConfig({ server: { - host: '0.0.0.0', + host: HOST, allowedHosts: ['desktop.server.lan'], }, test: { @@ -43,7 +44,7 @@ export default defineConfig({ browser: { enabled: true, api: { - host: '0.0.0.0', + host: HOST, }, provider: MODE === 'preview' ? preview() : playwright(), headless: MODE !== 'preview', diff --git a/packages/server/src/routes/api/oauth/oauth-provider.perf.test.ts b/packages/server/src/routes/api/oauth/oauth-provider.perf.test.ts index 47cb1db9..cb0a2b2d 100644 --- a/packages/server/src/routes/api/oauth/oauth-provider.perf.test.ts +++ b/packages/server/src/routes/api/oauth/oauth-provider.perf.test.ts @@ -16,6 +16,7 @@ import { getLocationHeader, MINIMAL_TEST_CONFIG, mockOAuthProviderFetch, + type OAuthMockTokens, TEST_USER_CONFIG, withMikroContext, } from '../../../test-utils/index.js'; @@ -124,6 +125,7 @@ async function createOAuthLinkCallbackFixture(index: number) { } return { + code: `oauth-link-perf-code-${index}`, returnUrl, state, userSub, @@ -154,6 +156,8 @@ async function createOAuthLoginCallbackFixture(index: number) { } return { + accessToken: `oauth-login-perf-access-${index}`, + code: `oauth-login-perf-code-${index}`, email, returnUrl, state, @@ -179,6 +183,7 @@ async function createAppleFormPostFixture(index: number) { } return { + code: `apple-form-post-perf-code-${index}`, oauthStateCookie: extractCookie(authorizeResponse, 'oauth_state'), state, }; @@ -318,6 +323,7 @@ async function requestCallbackGetMissingState() { async function requestCallbackGetLinkSuccess( fixture: { + code: string; returnUrl: string; sessionCookie: string; state: string; @@ -328,7 +334,7 @@ async function requestCallbackGetLinkSuccess( const response = await client.api.oauth[':provider'].callback.$get( { param: { provider: 'google' }, - query: { code: 'oauth-link-perf-code', state: fixture.state }, + query: { code: fixture.code, state: fixture.state }, }, { headers: { Cookie: `session=${fixture.sessionCookie}` } }, ); @@ -346,6 +352,7 @@ async function requestCallbackGetLinkSuccess( async function requestCallbackGetLoginSuccess( fixture: { + code: string; returnUrl: string; sessionCookie: string; state: string; @@ -356,7 +363,7 @@ async function requestCallbackGetLoginSuccess( const response = await client.api.oauth[':provider'].callback.$get( { param: { provider: 'google' }, - query: { code: 'oauth-login-perf-code', state: fixture.state }, + query: { code: fixture.code, state: fixture.state }, }, { headers: { Cookie: `session=${fixture.sessionCookie}` } }, ); @@ -374,6 +381,7 @@ async function requestCallbackGetLoginSuccess( async function requestAppleFormPostSuccess( fixture: { + code: string; oauthStateCookie: string; state: string; }, @@ -384,7 +392,7 @@ async function requestAppleFormPostSuccess( { param: { provider: 'apple' }, form: { - code: 'apple-form-post-perf-code', + code: fixture.code, state: fixture.state, }, }, @@ -484,12 +492,23 @@ describe('GET /api/oauth/:provider/callback perf', () => { const oauthMock = mockOAuthProviderFetch({ tokenUrl: GOOGLE_TOKEN_URL, userInfoUrl: GOOGLE_USERINFO_URL, - userInfoSequence: fixtures.map((fixture, index) => ({ - id: `google-login-perf-${crypto.randomUUID()}`, - email: fixture.email, - email_verified: true, - name: `OAuth Login Perf User ${index}`, - })), + tokensByCode: new Map( + fixtures.map((fixture) => [ + fixture.code, + { access_token: fixture.accessToken }, + ]), + ), + userInfoByAccessToken: new Map( + fixtures.map((fixture, index) => [ + fixture.accessToken, + { + id: `google-login-perf-${crypto.randomUUID()}`, + email: fixture.email, + email_verified: true, + name: `OAuth Login Perf User ${index}`, + }, + ]), + ), }); try { @@ -526,19 +545,35 @@ describe('GET /api/oauth/:provider/callback perf', () => { createOAuthLinkCallbackFixture(index), ), ); - const callbackSessions: OAuthCallbackSession[] = []; - - const oauthMock = mockOAuthProviderFetch({ - tokenUrl: GOOGLE_TOKEN_URL, - userInfoUrl: GOOGLE_USERINFO_URL, - userInfoSequence: fixtures.map((_, index) => ({ + const providerFixtures = fixtures.map((fixture, index) => ({ + accessToken: `oauth-link-perf-access-${index}`, + code: fixture.code, + userInfo: { id: `google-link-perf-${crypto.randomUUID()}`, email: generateUniqueEmail( `oauth-callback-link-perf-provider-${index}`, ), email_verified: true, name: 'OAuth Link Perf User', - })), + }, + })); + const callbackSessions: OAuthCallbackSession[] = []; + + const oauthMock = mockOAuthProviderFetch({ + tokenUrl: GOOGLE_TOKEN_URL, + userInfoUrl: GOOGLE_USERINFO_URL, + tokensByCode: new Map( + providerFixtures.map((fixture) => [ + fixture.code, + { access_token: fixture.accessToken }, + ]), + ), + userInfoByAccessToken: new Map( + providerFixtures.map((fixture) => [ + fixture.accessToken, + fixture.userInfo, + ]), + ), }); try { @@ -597,13 +632,19 @@ describe('POST /api/oauth/:provider/callback perf', () => { ), ); const callbackSessions: AppleCallbackSession[] = []; + const tokensByCode = new Map>(); + for (const [index, idTokenFixture] of idTokenFixtures.entries()) { + const fixture = fixtures[index]; + if (!fixture) { + throw new Error(`Missing Apple callback fixture at index ${index}`); + } + tokensByCode.set(fixture.code, { id_token: idTokenFixture.idToken }); + } const oauthMock = mockOAuthProviderFetch({ tokenUrl: APPLE_TOKEN_URL, userInfoUrl: null, - tokensSequence: idTokenFixtures.map((fixture) => ({ - id_token: fixture.idToken, - })), + tokensByCode, jwksUrl: APPLE_JWKS_URL, jwks: { keys: idTokenFixtures.map((fixture) => fixture.jwk), diff --git a/packages/server/src/test-utils/oauth-mock.test.ts b/packages/server/src/test-utils/oauth-mock.test.ts new file mode 100644 index 00000000..fbb1b4c8 --- /dev/null +++ b/packages/server/src/test-utils/oauth-mock.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { mockOAuthProviderFetch } from './oauth-mock.js'; + +const TOKEN_URL = 'https://provider.example/token'; +const USERINFO_URL = 'https://provider.example/userinfo'; + +describe('mockOAuthProviderFetch', () => { + let restore: (() => void) | undefined; + + afterEach(() => { + restore?.(); + restore = undefined; + }); + + test('matches concurrent token and userinfo responses by request identity', async () => { + const oauthMock = mockOAuthProviderFetch({ + tokenUrl: TOKEN_URL, + userInfoUrl: USERINFO_URL, + tokensByCode: new Map([ + ['code-1', { access_token: 'access-1' }], + ['code-2', { access_token: 'access-2' }], + ]), + userInfoByAccessToken: new Map([ + ['access-1', { id: 'user-1', email: 'one@example.com' }], + ['access-2', { id: 'user-2', email: 'two@example.com' }], + ]), + }); + restore = oauthMock.restore; + + const tokenResponses = await Promise.all( + ['code-2', 'code-1'].map(async (code) => { + const response = await fetch(TOKEN_URL, { + method: 'POST', + body: new URLSearchParams({ code }), + }); + return response.json(); + }), + ); + + expect(tokenResponses).toEqual([ + expect.objectContaining({ access_token: 'access-2' }), + expect.objectContaining({ access_token: 'access-1' }), + ]); + + const userInfoResponses = await Promise.all( + ['access-2', 'access-1'].map(async (accessToken) => { + const response = await fetch(USERINFO_URL, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + return response.json(); + }), + ); + + expect(userInfoResponses).toEqual([ + expect.objectContaining({ sub: 'user-2', email: 'two@example.com' }), + expect.objectContaining({ sub: 'user-1', email: 'one@example.com' }), + ]); + }); +}); diff --git a/packages/server/src/test-utils/oauth-mock.ts b/packages/server/src/test-utils/oauth-mock.ts index 99f027c3..65076477 100644 --- a/packages/server/src/test-utils/oauth-mock.ts +++ b/packages/server/src/test-utils/oauth-mock.ts @@ -21,9 +21,9 @@ export interface OAuthProviderFetchMockOptions { /** Set to null for providers without a userinfo endpoint (e.g. Apple). */ userInfoUrl: string | null; tokens?: Partial; - tokensSequence?: Array>; + tokensByCode?: ReadonlyMap>; userInfo?: Partial; - userInfoSequence?: Array>; + userInfoByAccessToken?: ReadonlyMap>; /** * Raw userinfo response body returned by the mock. * Use this to supply provider-specific field names @@ -73,6 +73,17 @@ function getAuthorizationHeader( return null; } +async function getRequestFormField( + input: string | URL | Request, + init: RequestInit | undefined, + field: string, +): Promise { + const request = + input instanceof Request ? input.clone() : new Request(input, init); + const value = (await request.formData()).get(field); + return typeof value === 'string' ? value : null; +} + function createOAuthMockUserInfo( userInfo?: Partial, ): OAuthMockUserInfo { @@ -107,17 +118,9 @@ export function mockOAuthProviderFetch( options: OAuthProviderFetchMockOptions, ): OAuthProviderFetchMock { const tokens = createOAuthMockTokens(options.tokens); - const tokensSequence = options.tokensSequence?.map((entry) => - createOAuthMockTokens(entry), - ); const issuedAccessTokens = new Set([tokens.access_token]); - let nextTokens = 0; const userInfo = createOAuthMockUserInfo(options.userInfo); - const userInfoSequence = options.userInfoSequence?.map((entry) => - createOAuthMockUserInfo(entry), - ); - let nextUserInfo = 0; const requestUrls: string[] = []; const fetchSpy = vi @@ -127,14 +130,17 @@ export function mockOAuthProviderFetch( requestUrls.push(url); if (url === options.tokenUrl) { - if (tokensSequence) { - const queuedTokens = tokensSequence[nextTokens]; - nextTokens += 1; - if (!queuedTokens) { - throw new Error('OAuth mock token sequence exhausted'); + if (options.tokensByCode) { + const code = await getRequestFormField(input, init, 'code'); + const configuredTokens = code + ? options.tokensByCode.get(code) + : undefined; + if (!configuredTokens) { + throw new Error(`OAuth mock has no tokens for code: ${code}`); } - issuedAccessTokens.add(queuedTokens.access_token); - return jsonResponse(queuedTokens); + const responseTokens = createOAuthMockTokens(configuredTokens); + issuedAccessTokens.add(responseTokens.access_token); + return jsonResponse(responseTokens); } return jsonResponse(tokens); @@ -152,13 +158,14 @@ export function mockOAuthProviderFetch( // Use rawUserInfoResponse if provided, otherwise default to // Google-style field names for backward compatibility. let responseUserInfo = userInfo; - if (userInfoSequence) { - const queuedUserInfo = userInfoSequence[nextUserInfo]; - nextUserInfo += 1; - if (!queuedUserInfo) { - throw new Error('OAuth mock userinfo sequence exhausted'); + if (options.userInfoByAccessToken) { + const configuredUserInfo = options.userInfoByAccessToken.get(token); + if (!configuredUserInfo) { + throw new Error( + `OAuth mock has no userinfo for access token: ${token}`, + ); } - responseUserInfo = queuedUserInfo; + responseUserInfo = createOAuthMockUserInfo(configuredUserInfo); } const body = options.rawUserInfoResponse ?? { sub: responseUserInfo.id, diff --git a/packages/standalone/e2e/config-loading.e2e.test.ts b/packages/standalone/e2e/config-loading.e2e.test.ts index 925fde68..a289500b 100644 --- a/packages/standalone/e2e/config-loading.e2e.test.ts +++ b/packages/standalone/e2e/config-loading.e2e.test.ts @@ -5,11 +5,14 @@ import { afterEach, describe, expect, it } from 'vitest'; import YAML from 'yaml'; import { createTestConfigFile, - getFreePort, removeDirectoryWithRetry, + reserveFreePort, } from './helpers/config-factory.ts'; -import { startCli, stopCliProcess } from './helpers/spawn-cli.ts'; -import { waitForReady } from './helpers/wait-for-ready.ts'; +import { + startCli, + stopCliProcess, + waitForCliReady, +} from './helpers/spawn-cli.ts'; async function createCustomConfigFile( config: Record, @@ -45,7 +48,7 @@ describe('config combinations', { timeout: 180_000 }, () => { timeout: 60_000, }); - await waitForReady(port); + await waitForCliReady(cliProcess, port); const res = await fetch(`http://localhost:${port}/api/config`); const body = await res.json(); @@ -64,7 +67,7 @@ describe('config combinations', { timeout: 180_000 }, () => { timeout: 60_000, }); - await waitForReady(port); + await waitForCliReady(cliProcess, port); const res = await fetch(`http://localhost:${port}/api/docs`); expect(res.status).toBe(404); @@ -81,7 +84,7 @@ describe('config combinations', { timeout: 180_000 }, () => { timeout: 60_000, }); - const res = await waitForReady(port); + const res = await waitForCliReady(cliProcess, port); expect(res.ok).toBe(true); }); @@ -96,7 +99,7 @@ describe('config combinations', { timeout: 180_000 }, () => { timeout: 60_000, }); - await waitForReady(port); + await waitForCliReady(cliProcess, port); const res = await fetch(`http://localhost:${port}/api/config`); const body = await res.json(); @@ -116,7 +119,7 @@ describe('config combinations', { timeout: 180_000 }, () => { timeout: 60_000, }); - await waitForReady(port); + await waitForCliReady(cliProcess, port); const res = await fetch(`http://localhost:${port}/api/config`); const body = await res.json(); @@ -153,25 +156,28 @@ describe('config loading priority', { timeout: 180_000 }, () => { } it('uses default public_origin when YAML omits it', async () => { - const port = await getFreePort(); + const { port, release } = await reserveFreePort(); const { configPath, cleanup } = await createCustomConfigFile( baseConfig(port), ); - configCleanup = cleanup; + configCleanup = async () => { + await cleanup(); + await release(); + }; cliProcess = startCli({ args: ['serve', '-c', configPath], timeout: 60_000, }); - const res = await waitForReady(port); + const res = await waitForCliReady(cliProcess, port); const body = await res.json(); expect(body.issuer).toBe('http://localhost:8080'); }); it('YAML public_origin overrides default', async () => { - const port = await getFreePort(); + const { port, release } = await reserveFreePort(); const { configPath, cleanup } = await createCustomConfigFile( baseConfig(port, { server: { @@ -180,25 +186,31 @@ describe('config loading priority', { timeout: 180_000 }, () => { }, }), ); - configCleanup = cleanup; + configCleanup = async () => { + await cleanup(); + await release(); + }; cliProcess = startCli({ args: ['serve', '-c', configPath], timeout: 60_000, }); - const res = await waitForReady(port); + const res = await waitForCliReady(cliProcess, port); const body = await res.json(); expect(body.issuer).toBe('https://custom-host:9999'); }); it('env var overrides default when YAML omits field', async () => { - const port = await getFreePort(); + const { port, release } = await reserveFreePort(); const { configPath, cleanup } = await createCustomConfigFile( baseConfig(port), ); - configCleanup = cleanup; + configCleanup = async () => { + await cleanup(); + await release(); + }; cliProcess = startCli({ args: ['serve', '-c', configPath], @@ -206,7 +218,7 @@ describe('config loading priority', { timeout: 180_000 }, () => { env: { TINYAUTH_PUBLIC_ORIGIN: 'https://env-host:5678' }, }); - const res = await waitForReady(port); + const res = await waitForCliReady(cliProcess, port); const body = await res.json(); expect(body.issuer).toBe('https://env-host:5678'); diff --git a/packages/standalone/e2e/dist-cli.e2e.test.ts b/packages/standalone/e2e/dist-cli.e2e.test.ts index 0fd0d43a..22052060 100644 --- a/packages/standalone/e2e/dist-cli.e2e.test.ts +++ b/packages/standalone/e2e/dist-cli.e2e.test.ts @@ -4,8 +4,8 @@ import { runBuiltCli, startBuiltCli, stopCliProcess, + waitForCliReady, } from './helpers/spawn-cli.ts'; -import { waitForReady } from './helpers/wait-for-ready.ts'; function expectGracefulShutdownExitCode(exitCode: number | undefined) { if (process.platform === 'win32') { @@ -45,7 +45,7 @@ describe('dist cli e2e', { timeout: 180_000 }, () => { timeout: 60_000, }); - const res = await waitForReady(port); + const res = await waitForCliReady(cliProcess, port); const body = await res.json(); expect(body).toHaveProperty('issuer', `http://localhost:${port}`); diff --git a/packages/standalone/e2e/helpers/config-factory.ts b/packages/standalone/e2e/helpers/config-factory.ts index e6deb9a4..c8135d34 100644 --- a/packages/standalone/e2e/helpers/config-factory.ts +++ b/packages/standalone/e2e/helpers/config-factory.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import YAML from 'yaml'; const RETRIABLE_REMOVE_ERROR_CODES = new Set(['EBUSY', 'ENOTEMPTY', 'EPERM']); +const PORT_RESERVATION_MAX_AGE_MS = 15 * 60 * 1000; function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -41,6 +42,49 @@ export function getFreePort(): Promise { }); } +async function removeStalePortReservation(lockPath: string): Promise { + try { + const stats = await fs.stat(lockPath); + if (Date.now() - stats.mtimeMs > PORT_RESERVATION_MAX_AGE_MS) { + await fs.rm(lockPath, { force: true }); + } + } catch (error) { + if (getErrorCode(error) !== 'ENOENT') { + throw error; + } + } +} + +export async function reserveFreePort(): Promise<{ + port: number; + release: () => Promise; +}> { + for (let attempt = 0; attempt < 100; attempt++) { + const port = await getFreePort(); + const lockPath = path.join(os.tmpdir(), `tinyauth-e2e-port-${port}.lock`); + + try { + await fs.writeFile(lockPath, String(process.pid), { + encoding: 'utf-8', + flag: 'wx', + }); + return { + port, + release: async () => { + await fs.rm(lockPath, { force: true }); + }, + }; + } catch (error) { + if (getErrorCode(error) !== 'EEXIST') { + throw error; + } + await removeStalePortReservation(lockPath); + } + } + + throw new Error('Unable to reserve a unique test server port'); +} + export async function removeDirectoryWithRetry(dir: string): Promise { for (let attempt = 0; attempt < 5; attempt++) { try { @@ -68,8 +112,14 @@ interface CreateTestConfigFileResult { export async function createTestConfigFile( overrides?: Record, ): Promise { - const port = await getFreePort(); - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tinyauth-e2e-')); + const { port, release } = await reserveFreePort(); + let tmpDir: string; + try { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tinyauth-e2e-')); + } catch (error) { + await release(); + throw error; + } const config: Record = { database: { @@ -109,7 +159,11 @@ export async function createTestConfigFile( configPath, port, cleanup: async () => { - await removeDirectoryWithRetry(tmpDir); + try { + await removeDirectoryWithRetry(tmpDir); + } finally { + await release(); + } }, }; } diff --git a/packages/standalone/e2e/helpers/spawn-cli.ts b/packages/standalone/e2e/helpers/spawn-cli.ts index b05a0b7b..0b9d2a31 100644 --- a/packages/standalone/e2e/helpers/spawn-cli.ts +++ b/packages/standalone/e2e/helpers/spawn-cli.ts @@ -1,6 +1,7 @@ import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { execaNode } from 'execa'; +import { waitForReady } from './wait-for-ready.ts'; const CLI_PATH = fileURLToPath(new URL('../../src/cli.ts', import.meta.url)); @@ -14,6 +15,7 @@ const require = createRequire(import.meta.url); const TSX_IMPORT = pathToFileURL(require.resolve('tsx')).href; const NODE_OPTIONS = ['--conditions=@tinyauth/source', '--import', TSX_IMPORT]; const LONG_RUNNING_CLI_TIMEOUT_MS = 180_000; +const USE_BUILT_CLI = process.env['TINYAUTH_E2E_BUILT_CLI'] === '1'; interface SpawnCliOptions { args: string[]; @@ -59,19 +61,35 @@ function spawnBuiltCli(options: SpawnCliOptions) { * Run a short-lived CLI command and wait for it to exit. */ export async function runCli(options: SpawnCliOptions) { - return await spawnCli(options); + return await (USE_BUILT_CLI ? spawnBuiltCli(options) : spawnCli(options)); } export async function runBuiltCli(options: SpawnCliOptions) { return await spawnBuiltCli(options); } +export async function waitForCliReady( + cliProcess: CliProcess, + port: number, +): Promise { + const processExit = cliProcess.then((result) => { + const output = [result.stdout, result.stderr].filter(Boolean).join('\n'); + const outputSuffix = output ? `\n${output}` : ''; + throw new Error( + `CLI exited before port ${port} became ready (exit ${result.exitCode})${outputSuffix}`, + ); + }); + + return await Promise.race([waitForReady(port), processExit]); +} + /** * Start a long-lived CLI command (e.g. serve). * Returns the subprocess handle — caller manages lifecycle. */ export function startCli(options: SpawnCliOptions) { - return spawnCli({ + const start = USE_BUILT_CLI ? spawnBuiltCli : spawnCli; + return start({ ...options, timeout: Math.max(options.timeout ?? 0, LONG_RUNNING_CLI_TIMEOUT_MS), }); diff --git a/packages/standalone/e2e/serve.e2e.test.ts b/packages/standalone/e2e/serve.e2e.test.ts index c5e8e020..7f23d5e5 100644 --- a/packages/standalone/e2e/serve.e2e.test.ts +++ b/packages/standalone/e2e/serve.e2e.test.ts @@ -1,7 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createTestConfigFile } from './helpers/config-factory.ts'; -import { runCli, startCli, stopCliProcess } from './helpers/spawn-cli.ts'; -import { waitForReady } from './helpers/wait-for-ready.ts'; +import { + runCli, + startCli, + stopCliProcess, + waitForCliReady, +} from './helpers/spawn-cli.ts'; function expectGracefulShutdownExitCode(exitCode: number | undefined) { if (process.platform === 'win32') { @@ -30,7 +34,7 @@ describe('serve e2e', { timeout: 180_000 }, () => { timeout: 60_000, }); - const res = await waitForReady(port); + const res = await waitForCliReady(cliProcess, port); const body = await res.json(); expect(body).toHaveProperty('issuer'); @@ -51,7 +55,7 @@ describe('serve e2e', { timeout: 180_000 }, () => { timeout: 60_000, }); - await waitForReady(port); + await waitForCliReady(cliProcess, port); cliProcess.kill('SIGTERM'); const result = await cliProcess; diff --git a/packages/standalone/package.json b/packages/standalone/package.json index bf595320..e1eb7ee2 100644 --- a/packages/standalone/package.json +++ b/packages/standalone/package.json @@ -42,8 +42,10 @@ "dev:cli": "cross-env CONFIG_PATH=./config.dev.yaml NODE_OPTIONS=--conditions=@tinyauth/source tsx src/cli.ts", "docker:build": "docker build -f ./Dockerfile ../.. -t tinyauth:local", "test": "node --conditions=@tinyauth/source node_modules/vitest/vitest.mjs --exclude e2e/dist-cli.e2e.test.ts", + "test:prepared": "cross-env TINYAUTH_E2E_BUILT_CLI=1 node --conditions=@tinyauth/source node_modules/vitest/vitest.mjs --exclude e2e/dist-cli.e2e.test.ts", "test:coverage": "node --conditions=@tinyauth/source node_modules/vitest/vitest.mjs run --coverage --exclude e2e/dist-cli.e2e.test.ts", - "test:dist": "pnpm --filter @tinyrack/tinyauth-server run build && pnpm run build && vitest run e2e/dist-cli.e2e.test.ts", + "test:dist": "pnpm --filter @tinyrack/tinyauth-server run build && pnpm run build && pnpm run test:dist:prepared", + "test:dist:prepared": "vitest run e2e/dist-cli.e2e.test.ts", "clean": "node ../../packages/tools/src/scripts/rm.ts dist node_modules/.cache/tsconfig.src.tsbuildinfo node_modules/.cache/tsconfig.src.build.tsbuildinfo node_modules/.cache/tsconfig.test.tsbuildinfo node_modules/.cache/tsconfig.e2e.tsbuildinfo", "build": "pnpm run clean && tsc -b tsconfig.build.json", "build:watch": "tsc -b -w tsconfig.json", diff --git a/packages/tools/src/scripts/run-validation-tests.ts b/packages/tools/src/scripts/run-validation-tests.ts new file mode 100644 index 00000000..5736574c --- /dev/null +++ b/packages/tools/src/scripts/run-validation-tests.ts @@ -0,0 +1,172 @@ +import { spawn } from 'node:child_process'; +import { availableParallelism } from 'node:os'; + +interface ValidationTask { + name: string; + weight: number; + args: (workers: number) => string[]; +} + +const tasks: ValidationTask[] = [ + { + name: 'server', + weight: 5, + args: (workers) => [ + '--filter', + '@tinyrack/tinyauth-server', + 'test', + '--run', + `--maxWorkers=${workers}`, + ], + }, + { + name: 'frontend unit', + weight: 4, + args: (workers) => [ + '--filter', + '@tinyrack/tinyauth-frontend', + 'test:unit', + '--run', + `--maxWorkers=${workers}`, + ], + }, + { + name: 'standalone', + weight: 2, + args: (workers) => [ + '--filter', + '@tinyrack/tinyauth-standalone', + 'test:prepared', + '--run', + `--maxWorkers=${workers}`, + ], + }, + { + name: 'tools', + weight: 1, + args: (workers) => [ + '--filter', + '@tinyrack/tinyauth-tools', + 'test', + '--run', + `--maxWorkers=${workers}`, + ], + }, + { + name: 'homepage', + weight: 1, + args: (workers) => [ + '--filter', + '@tinyrack/tinyauth-homepage', + 'test', + `--maxWorkers=${workers}`, + ], + }, + { + name: 'example smoke', + // This smoke test is a single process rather than a worker-pooled suite. + // Keep one slot for it and distribute the remaining CPU budget to suites + // that can actually consume more workers. + weight: 0, + args: () => [ + '--filter', + '@tinyauth-server-examples/node-hono-sqlite', + 'test:prepared', + ], + }, +]; + +function runPnpm(args: string[], label: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn('pnpm', args, { + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `${label} failed (${signal ? `signal ${signal}` : `exit ${code}`})`, + ), + ); + }); + }); +} + +function allocateWorkers( + selectedTasks: ValidationTask[], + cpuCount: number, +): number[] { + const allocations = selectedTasks.map(() => 1); + let remaining = cpuCount - selectedTasks.length; + const totalWeight = selectedTasks.reduce((sum, task) => sum + task.weight, 0); + if (totalWeight === 0) { + return allocations; + } + + for (const [index, task] of selectedTasks.entries()) { + const additional = Math.floor((remaining * task.weight) / totalWeight); + allocations[index] = (allocations[index] ?? 1) + additional; + } + + remaining = + cpuCount - allocations.reduce((sum, allocation) => sum + allocation, 0); + for ( + let index = 0; + remaining > 0; + index = (index + 1) % selectedTasks.length + ) { + const allocation = allocations[index]; + if (allocation !== undefined) { + allocations[index] = allocation + 1; + remaining -= 1; + } + } + + return allocations; +} + +async function runCpuBudgetedTasks(cpuCount: number): Promise { + for (let offset = 0; offset < tasks.length; offset += cpuCount) { + const selectedTasks = tasks.slice(offset, offset + cpuCount); + const allocations = allocateWorkers(selectedTasks, cpuCount); + const results = await Promise.allSettled( + selectedTasks.map((task, index) => { + const workers = allocations[index] ?? 1; + process.stdout.write( + `[validation] ${task.name}: ${workers} worker${workers === 1 ? '' : 's'}\n`, + ); + return runPnpm(task.args(workers), task.name); + }), + ); + const failures = results.filter((result) => result.status === 'rejected'); + if (failures.length > 0) { + throw new AggregateError( + failures.map((failure) => String(failure.reason)), + 'Validation test group failed', + ); + } + } +} + +const cpuCount = availableParallelism(); +process.stdout.write(`[validation] global CPU budget: ${cpuCount}\n`); + +await runPnpm(['build'], 'build'); +await runCpuBudgetedTasks(cpuCount); +await runPnpm( + [ + '--filter', + '@tinyrack/tinyauth-standalone', + 'test:dist:prepared', + `--maxWorkers=${cpuCount}`, + ], + 'standalone dist', +); +await runPnpm( + ['--filter', '@tinyrack/tinyauth-frontend', 'test:e2e:sharded'], + 'frontend e2e', +);