diff --git a/.changeset/fix-testing-concurrent-workers.md b/.changeset/fix-testing-concurrent-workers.md new file mode 100644 index 00000000000..b47c6c8304c --- /dev/null +++ b/.changeset/fix-testing-concurrent-workers.md @@ -0,0 +1,5 @@ +--- +"@clerk/testing": patch +--- + +Fix `signIn()` timing out with concurrent Playwright workers by de-duplicating route handler registration and adding retry with exponential backoff for transient FAPI errors (429, 502, 503, 504). diff --git a/packages/testing/src/playwright/__tests__/setupClerkTestingToken.test.ts b/packages/testing/src/playwright/__tests__/setupClerkTestingToken.test.ts new file mode 100644 index 00000000000..2758987257c --- /dev/null +++ b/packages/testing/src/playwright/__tests__/setupClerkTestingToken.test.ts @@ -0,0 +1,371 @@ +import type { BrowserContext, Request, Route } from '@playwright/test'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_MISSING_FRONTEND_API_URL } from '../../common/errors'; + +// We need to reset the module-level WeakSet between tests +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +let setupClerkTestingToken: (typeof import('../setupClerkTestingToken'))['setupClerkTestingToken']; + +function createMockRoute( + overrides: { url?: string; fetchStatus?: number; fetchJson?: unknown; fetchError?: Error } = {}, +) { + const { + url = 'https://clerk.example.com/v1/client', + fetchStatus = 200, + fetchJson = { response: { captcha_bypass: false } }, + fetchError, + } = overrides; + + const fulfilled: { response?: unknown; json: Record }[] = []; + const continued: { url?: string }[] = []; + let fetchCallCount = 0; + + const route: Route = { + request: () => + ({ + url: () => url, + }) as unknown as Request, + fetch: vi.fn(() => { + fetchCallCount++; + if (fetchError) { + return Promise.reject(fetchError); + } + return Promise.resolve({ + status: () => fetchStatus, + json: () => Promise.resolve(JSON.parse(JSON.stringify(fetchJson))), + }); + }), + fulfill: vi.fn((opts: any) => { + fulfilled.push(opts); + return Promise.resolve(); + }), + continue: vi.fn(() => Promise.resolve()), + } as unknown as Route; + + return { route, fulfilled, continued, getFetchCallCount: () => fetchCallCount }; +} + +function createMockContext() { + let routeHandler: ((route: Route) => Promise) | undefined; + + const context = { + route: vi.fn((_pattern: RegExp, handler: (route: Route) => Promise) => { + routeHandler = handler; + return Promise.resolve(); + }), + } as unknown as BrowserContext; + + return { + context, + getRouteHandler: () => routeHandler, + getRouteCallCount: () => (context.route as ReturnType).mock.calls.length, + }; +} + +describe('setupClerkTestingToken', () => { + const FAPI_URL = 'clerk.example.com'; + const TESTING_TOKEN = 'test_token_123'; + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + vi.stubEnv('CLERK_FAPI', FAPI_URL); + vi.stubEnv('CLERK_TESTING_TOKEN', TESTING_TOKEN); + + // Reset module to clear the WeakSet between tests + vi.resetModules(); + const mod = await import('../setupClerkTestingToken.js'); + setupClerkTestingToken = mod.setupClerkTestingToken; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + describe('validation', () => { + it('throws when neither context nor page is provided', async () => { + await expect(setupClerkTestingToken({} as any)).rejects.toThrow( + 'Either context or page must be provided to setup testing token', + ); + }); + + it('throws when CLERK_FAPI is not set', async () => { + vi.stubEnv('CLERK_FAPI', ''); + const { context } = createMockContext(); + await expect(setupClerkTestingToken({ context })).rejects.toThrow(ERROR_MISSING_FRONTEND_API_URL); + }); + + it('uses frontendApiUrl option over env var', async () => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context, options: { frontendApiUrl: 'custom.clerk.com' } }); + + const handler = getRouteHandler(); + expect(handler).toBeDefined(); + + const { route, fulfilled } = createMockRoute({ url: 'https://custom.clerk.com/v1/client' }); + await handler!(route); + + expect(route.fetch).toHaveBeenCalledWith({ + url: expect.stringContaining('custom.clerk.com'), + }); + expect(fulfilled).toHaveLength(1); + }); + }); + + describe('de-duplication', () => { + it('registers route handler only once per context', async () => { + const { context, getRouteCallCount } = createMockContext(); + + await setupClerkTestingToken({ context }); + await setupClerkTestingToken({ context }); + await setupClerkTestingToken({ context }); + + expect(getRouteCallCount()).toBe(1); + }); + + it('registers separate handlers for different contexts', async () => { + const ctx1 = createMockContext(); + const ctx2 = createMockContext(); + + await setupClerkTestingToken({ context: ctx1.context }); + await setupClerkTestingToken({ context: ctx2.context }); + + expect(ctx1.getRouteCallCount()).toBe(1); + expect(ctx2.getRouteCallCount()).toBe(1); + }); + + it('allows retry after route registration fails', async () => { + const routeFn = vi.fn(); + routeFn.mockRejectedValueOnce(new Error('context closed')); + routeFn.mockResolvedValueOnce(undefined); + + const context = { route: routeFn } as unknown as BrowserContext; + + await expect(setupClerkTestingToken({ context })).rejects.toThrow('context closed'); + await setupClerkTestingToken({ context }); + + expect(routeFn).toHaveBeenCalledTimes(2); + }); + + it('resolves context from page when context is not provided', async () => { + const { context, getRouteCallCount } = createMockContext(); + const page = { context: () => context } as any; + + await setupClerkTestingToken({ page }); + await setupClerkTestingToken({ page }); + + expect(getRouteCallCount()).toBe(1); + }); + }); + + describe('route handler', () => { + it('appends testing token to FAPI requests', async () => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context }); + + const { route } = createMockRoute(); + await getRouteHandler()!(route); + + expect(route.fetch).toHaveBeenCalledWith({ + url: expect.stringContaining(`__clerk_testing_token=${TESTING_TOKEN}`), + }); + }); + + it('overrides captcha_bypass in response', async () => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context }); + + const { route, fulfilled } = createMockRoute({ + fetchJson: { response: { captcha_bypass: false } }, + }); + await getRouteHandler()!(route); + + expect(fulfilled).toHaveLength(1); + expect(fulfilled[0].json.response.captcha_bypass).toBe(true); + }); + + it('overrides captcha_bypass in piggybacking response', async () => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context }); + + const { route, fulfilled } = createMockRoute({ + fetchJson: { client: { captcha_bypass: false } }, + }); + await getRouteHandler()!(route); + + expect(fulfilled).toHaveLength(1); + expect(fulfilled[0].json.client.captcha_bypass).toBe(true); + }); + + it('does not modify captcha_bypass when already true', async () => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context }); + + const { route, fulfilled } = createMockRoute({ + fetchJson: { response: { captcha_bypass: true } }, + }); + await getRouteHandler()!(route); + + expect(fulfilled[0].json.response.captcha_bypass).toBe(true); + }); + }); + + describe('retry on transient errors', () => { + it('retries on 429 status', async () => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context }); + + let callCount = 0; + const route = { + request: () => ({ url: () => 'https://clerk.example.com/v1/client' }), + fetch: vi.fn(() => { + callCount++; + if (callCount <= 2) { + return Promise.resolve({ status: () => 429, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ + status: () => 200, + json: () => Promise.resolve({ response: { captcha_bypass: false } }), + }); + }), + fulfill: vi.fn(() => Promise.resolve()), + continue: vi.fn(() => Promise.resolve()), + } as unknown as Route; + + const handlerPromise = getRouteHandler()!(route); + await vi.advanceTimersByTimeAsync(60_000); + await handlerPromise; + + expect(callCount).toBe(3); + expect(route.fulfill).toHaveBeenCalledTimes(1); + }); + + it.each([502, 503, 504])('retries on %d status', async status => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context }); + + let callCount = 0; + const route = { + request: () => ({ url: () => 'https://clerk.example.com/v1/client' }), + fetch: vi.fn(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ status: () => status, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ + status: () => 200, + json: () => Promise.resolve({ response: { captcha_bypass: false } }), + }); + }), + fulfill: vi.fn(() => Promise.resolve()), + continue: vi.fn(() => Promise.resolve()), + } as unknown as Route; + + const handlerPromise = getRouteHandler()!(route); + await vi.advanceTimersByTimeAsync(60_000); + await handlerPromise; + + expect(callCount).toBe(2); + expect(route.fulfill).toHaveBeenCalledTimes(1); + }); + + it('does not retry on non-retryable status codes', async () => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context }); + + const { route, fulfilled, getFetchCallCount } = createMockRoute({ fetchStatus: 401 }); + await getRouteHandler()!(route); + + expect(getFetchCallCount()).toBe(1); + expect(fulfilled).toHaveLength(1); + }); + + it('fulfills with raw response after exhausting retries on retryable status', async () => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const route = { + request: () => ({ url: () => 'https://clerk.example.com/v1/client' }), + fetch: vi.fn(() => + Promise.resolve({ + status: () => 429, + json: () => Promise.resolve({}), + }), + ), + fulfill: vi.fn(() => Promise.resolve()), + continue: vi.fn(() => Promise.resolve()), + } as unknown as Route; + + const handlerPromise = getRouteHandler()!(route); + await vi.advanceTimersByTimeAsync(60_000); + await handlerPromise; + + // 1 initial + 3 retries = 4 total + expect(route.fetch).toHaveBeenCalledTimes(4); + expect(route.fulfill).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('failed with status 429 after 4 attempts')); + + warnSpy.mockRestore(); + }); + + it('retries on thrown errors and warns after exhausting retries', async () => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const networkError = new Error('net::ERR_CONNECTION_REFUSED'); + const route = { + request: () => ({ url: () => 'https://clerk.example.com/v1/client' }), + fetch: vi.fn(() => Promise.reject(networkError)), + fulfill: vi.fn(() => Promise.resolve()), + continue: vi.fn(() => Promise.resolve()), + } as unknown as Route; + + const handlerPromise = getRouteHandler()!(route); + await vi.advanceTimersByTimeAsync(60_000); + await handlerPromise; + + expect(route.fetch).toHaveBeenCalledTimes(4); + expect(route.continue).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('failed after 4 attempts'), networkError); + + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('recovers after transient error on retry', async () => { + const { context, getRouteHandler } = createMockContext(); + await setupClerkTestingToken({ context }); + + let callCount = 0; + const route = { + request: () => ({ url: () => 'https://clerk.example.com/v1/client' }), + fetch: vi.fn(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error('network error')); + } + return Promise.resolve({ + status: () => 200, + json: () => Promise.resolve({ response: { captcha_bypass: false } }), + }); + }), + fulfill: vi.fn(() => Promise.resolve()), + continue: vi.fn(() => Promise.resolve()), + } as unknown as Route; + + const handlerPromise = getRouteHandler()!(route); + await vi.advanceTimersByTimeAsync(60_000); + await handlerPromise; + + expect(callCount).toBe(2); + expect(route.fulfill).toHaveBeenCalledTimes(1); + expect(route.continue).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/testing/src/playwright/setupClerkTestingToken.ts b/packages/testing/src/playwright/setupClerkTestingToken.ts index 5248357f4f6..2f40827c7ba 100644 --- a/packages/testing/src/playwright/setupClerkTestingToken.ts +++ b/packages/testing/src/playwright/setupClerkTestingToken.ts @@ -9,6 +9,13 @@ type SetupClerkTestingTokenParams = { options?: SetupClerkTestingTokenOptions; }; +const setupContexts = new WeakSet(); + +const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]); +const MAX_ROUTE_RETRIES = 3; +const BASE_DELAY_MS = 500; +const JITTER_MAX_MS = 250; + /** * Bypasses bot protection by appending the testing token in the Frontend API requests. * @@ -17,6 +24,7 @@ type SetupClerkTestingTokenParams = { * @param params.options.frontendApiUrl - The frontend API URL for your Clerk dev instance, without the protocol. * @returns A promise that resolves when the bot protection bypass is set up. * @throws An error if the Frontend API URL is not provided. + * @remarks Set the `CLERK_TESTING_DEBUG` environment variable to enable verbose logging of retry attempts and route handler registration. * @example * import { setupClerkTestingToken } from '@clerk/testing/playwright'; * @@ -34,6 +42,13 @@ export const setupClerkTestingToken = async ({ context, options, page }: SetupCl throw new Error('Either context or page must be provided to setup testing token'); } + if (setupContexts.has(browserContext)) { + if (process.env.CLERK_TESTING_DEBUG) { + console.log('[Clerk Testing] Route handler already registered for this context, skipping duplicate setup'); + } + return; + } + const fapiUrl = options?.frontendApiUrl || process.env.CLERK_FAPI; if (!fapiUrl) { throw new Error(ERROR_MISSING_FRONTEND_API_URL); @@ -42,41 +57,80 @@ export const setupClerkTestingToken = async ({ context, options, page }: SetupCl const escapedFapiUrl = fapiUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const apiUrl = new RegExp(`^https://${escapedFapiUrl}/v1/.*?(\\?.*)?$`); - await browserContext.route(apiUrl, async route => { - const originalUrl = new URL(route.request().url()); - const testingToken = process.env.CLERK_TESTING_TOKEN; + setupContexts.add(browserContext); + try { + await browserContext.route(apiUrl, async route => { + const originalUrl = new URL(route.request().url()); + const testingToken = process.env.CLERK_TESTING_TOKEN; - if (testingToken) { - originalUrl.searchParams.set(TESTING_TOKEN_PARAM, testingToken); - } + if (testingToken) { + originalUrl.searchParams.set(TESTING_TOKEN_PARAM, testingToken); + } - try { - const response = await route.fetch({ - url: originalUrl.toString(), - }); + const urlString = originalUrl.toString(); - const json = await response.json(); + for (let attempt = 0; attempt <= MAX_ROUTE_RETRIES; attempt++) { + try { + const response = await route.fetch({ url: urlString }); + const status = response.status(); - // Override captcha_bypass in /v1/client - if (json?.response?.captcha_bypass === false) { - json.response.captcha_bypass = true; - } + if (RETRYABLE_STATUS_CODES.has(status)) { + if (attempt < MAX_ROUTE_RETRIES) { + const delay = BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS; + if (process.env.CLERK_TESTING_DEBUG) { + console.log( + `[Clerk Testing] FAPI returned ${status}, retrying (attempt ${attempt + 1}/${MAX_ROUTE_RETRIES}, delay ${Math.round(delay)}ms): ${route.request().url()}`, + ); + } + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } - // Override captcha_bypass in piggybacking - if (json?.client?.captcha_bypass === false) { - json.client.captcha_bypass = true; - } + console.warn( + `[Clerk Testing] FAPI request failed with status ${status} after ${MAX_ROUTE_RETRIES + 1} attempts: ${route.request().url()}`, + ); + await route.fulfill({ response }); + return; + } - await route.fulfill({ - response, - json, - }); - } catch { - await route - .continue({ - url: originalUrl.toString(), - }) - .catch(console.error); - } - }); + const json = await response.json(); + + // Override captcha_bypass in /v1/client + if (json?.response?.captcha_bypass === false) { + json.response.captcha_bypass = true; + } + + // Override captcha_bypass in piggybacking + if (json?.client?.captcha_bypass === false) { + json.client.captcha_bypass = true; + } + + await route.fulfill({ response, json }); + return; + } catch (error) { + if (attempt < MAX_ROUTE_RETRIES) { + const delay = BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS; + if (process.env.CLERK_TESTING_DEBUG) { + console.log( + `[Clerk Testing] FAPI request error, retrying (attempt ${attempt + 1}/${MAX_ROUTE_RETRIES}, delay ${Math.round(delay)}ms): ${route.request().url()}`, + error, + ); + } + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + + console.warn( + `[Clerk Testing] FAPI request failed after ${MAX_ROUTE_RETRIES + 1} attempts: ${route.request().url()}`, + error, + ); + await route.continue({ url: urlString }).catch(console.error); + return; + } + } + }); + } catch (e) { + setupContexts.delete(browserContext); + throw e; + } }; diff --git a/packages/testing/tsconfig.test.json b/packages/testing/tsconfig.test.json new file mode 100644 index 00000000000..849e868fff0 --- /dev/null +++ b/packages/testing/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": true, + "emitDeclarationOnly": false + }, + "include": ["src/**/*"] +} diff --git a/packages/testing/vitest.config.mts b/packages/testing/vitest.config.mts index afa1ebd7d76..748ccf3c389 100644 --- a/packages/testing/vitest.config.mts +++ b/packages/testing/vitest.config.mts @@ -3,6 +3,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { watch: false, - include: ['**/*.{test,spec}.{ts,tsx}'], + include: ['**/*.test.{ts,tsx}'], + typecheck: { + enabled: true, + tsconfig: './tsconfig.test.json', + include: ['**/*.test.{ts,tsx}'], + }, + coverage: { + provider: 'v8', + }, }, });