Skip to content

Commit 705d472

Browse files
committed
feat(testing): support per-instance testing tokens in setupClerkTestingToken
1 parent 478f110 commit 705d472

11 files changed

Lines changed: 283 additions & 30 deletions

File tree

.changeset/eighty-husky-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/testing': minor
3+
---
4+
5+
`setupClerkTestingToken` for Playwright now accepts `options.testingToken`, either as a string or as a function resolving one lazily, taking precedence over the `CLERK_TESTING_TOKEN` environment variable. It can also be called multiple times on the same browser context with different `frontendApiUrl` values, registering one bot-protection bypass per Clerk instance, so test suites spanning multiple instances no longer depend on a single process-wide `CLERK_FAPI`/`CLERK_TESTING_TOKEN` pair. The unstable `createPageObjects` and `createAppPageObject` helpers accept a new `testingTokenOptions` parameter forwarded to their automatic `setupClerkTestingToken` calls, and the Cypress `setupClerkTestingToken` accepts `options.testingToken` as a string.

integration/testUtils/index.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { createClerkClient as backendCreateClerkClient } from '@clerk/backend';
2-
import { createAppPageObject, createPageObjects, type EnhancedPage } from '@clerk/testing/playwright/unstable';
2+
import { parsePublishableKey } from '@clerk/shared/keys';
3+
import {
4+
createAppPageObject,
5+
createPageObjects,
6+
type EnhancedPage,
7+
type PlaywrightSetupClerkTestingTokenOptions,
8+
} from '@clerk/testing/playwright/unstable';
39
import type { Browser, BrowserContext, Page } from '@playwright/test';
410

511
import type { Application } from '../models/application';
@@ -21,6 +27,50 @@ const createClerkClient = (app: Application) => {
2127
});
2228
};
2329

30+
// One testing token per instance (keyed by publishable key), minted lazily on the first
31+
// FAPI request a test intercepts and shared across tests in this worker process.
32+
const testingTokenCache = new Map<string, Promise<string | undefined>>();
33+
34+
const getTestingToken = (
35+
app: Application,
36+
clerkClient: ReturnType<typeof createClerkClient>,
37+
publishableKey: string,
38+
): Promise<string | undefined> => {
39+
let promise = testingTokenCache.get(publishableKey);
40+
if (!promise) {
41+
promise = clerkClient.testingTokens
42+
.createTestingToken()
43+
.then(({ token }) => token)
44+
.catch(err => {
45+
console.warn(
46+
`Failed to mint a testing token for app "${app.name}". Falling back to the CLERK_TESTING_TOKEN env var.`,
47+
err,
48+
);
49+
return undefined;
50+
});
51+
testingTokenCache.set(publishableKey, promise);
52+
}
53+
return promise;
54+
};
55+
56+
// Builds per-app options so the testing-token bypass targets the instance THIS app talks
57+
// to, instead of the process-global CLERK_FAPI (which holds whichever app ran clerkSetup
58+
// last and silently misses every other instance, e.g. captcha-enabled ones).
59+
const createTestingTokenOptions = (
60+
app: Application,
61+
clerkClient: ReturnType<typeof createClerkClient>,
62+
): PlaywrightSetupClerkTestingTokenOptions | undefined => {
63+
const publishableKey = app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY');
64+
const parsedKey = publishableKey ? parsePublishableKey(publishableKey) : null;
65+
if (!publishableKey || parsedKey?.instanceType !== 'development') {
66+
return undefined;
67+
}
68+
return {
69+
frontendApiUrl: parsedKey.frontendApi,
70+
testingToken: () => getTestingToken(app, clerkClient, publishableKey),
71+
};
72+
};
73+
2474
export type CreateAppPageObjectArgs = { page: Page; context: BrowserContext; browser: Browser };
2575

2676
export const createTestUtils = <
@@ -49,15 +99,24 @@ export const createTestUtils = <
4999
return { services } as any;
50100
}
51101

52-
const pageObjects = createPageObjects({ page: params.page, useTestingToken, baseURL: app.serverUrl });
102+
const testingTokenOptions = createTestingTokenOptions(app, clerkClient);
103+
const pageObjects = createPageObjects({
104+
page: params.page,
105+
useTestingToken,
106+
baseURL: app.serverUrl,
107+
testingTokenOptions,
108+
});
53109

54110
const browserHelpers = {
55111
runInNewTab: async (
56112
cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise<unknown>,
57113
) => {
58114
const u = createTestUtils({
59115
app,
60-
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }),
116+
page: createAppPageObject(
117+
{ page: await context.newPage(), useTestingToken, testingTokenOptions },
118+
{ baseURL: app.serverUrl },
119+
),
61120
});
62121
await cb(u as any, context);
63122
return u;
@@ -71,7 +130,10 @@ export const createTestUtils = <
71130
const context = await browser.newContext();
72131
const u = createTestUtils({
73132
app,
74-
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }),
133+
page: createAppPageObject(
134+
{ page: await context.newPage(), useTestingToken, testingTokenOptions },
135+
{ baseURL: app.serverUrl },
136+
),
75137
});
76138
await cb(u as any, context);
77139
return u;

packages/testing/src/common/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export type SetupClerkTestingTokenOptions = {
4848
* Example: 'relieved-chamois-66.clerk.accounts.dev'
4949
*/
5050
frontendApiUrl?: string;
51+
52+
/*
53+
* The testing token to append to Frontend API requests.
54+
* If provided, it takes precedence over the CLERK_TESTING_TOKEN environment variable.
55+
* Useful when a test suite spans multiple Clerk instances, each needing its own token.
56+
*/
57+
testingToken?: string;
5158
};
5259

5360
export type ClerkSignInParams =

packages/testing/src/cypress/setupClerkTestingToken.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type SetupClerkTestingTokenParams = {
1010
* Bypasses bot protection by appending the testing token in the Frontend API requests.
1111
*
1212
* @param params.options.frontendApiUrl - The frontend API URL for your Clerk dev instance, without the protocol.
13+
* @param params.options.testingToken - The testing token to append. Takes precedence over the `CLERK_TESTING_TOKEN` Cypress env variable.
1314
* @returns A promise that resolves when the bot protection bypass is set up.
1415
* @throws An error if the Frontend API URL is not provided.
1516
* @example
@@ -29,7 +30,7 @@ export const setupClerkTestingToken = (params?: SetupClerkTestingTokenParams) =>
2930
const apiUrl = `https://${fapiUrl}/v1/**`;
3031

3132
cy.intercept(apiUrl, req => {
32-
const testingToken = Cypress.env('CLERK_TESTING_TOKEN');
33+
const testingToken = params?.options?.testingToken || Cypress.env('CLERK_TESTING_TOKEN');
3334
if (testingToken) {
3435
req.query[TESTING_TOKEN_PARAM] = testingToken;
3536
}

packages/testing/src/playwright/__tests__/setupClerkTestingToken.test.ts

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,19 @@ function createMockRoute(
4747
}
4848

4949
function createMockContext() {
50-
let routeHandler: ((route: Route) => Promise<void>) | undefined;
50+
const registrations: { pattern: RegExp; handler: (route: Route) => Promise<void> }[] = [];
5151

5252
const context = {
53-
route: vi.fn((_pattern: RegExp, handler: (route: Route) => Promise<void>) => {
54-
routeHandler = handler;
53+
route: vi.fn((pattern: RegExp, handler: (route: Route) => Promise<void>) => {
54+
registrations.push({ pattern, handler });
5555
return Promise.resolve();
5656
}),
5757
} as unknown as BrowserContext;
5858

5959
return {
6060
context,
61-
getRouteHandler: () => routeHandler,
61+
getRouteHandler: () => registrations.at(-1)?.handler,
62+
getRegistrations: () => registrations,
6263
getRouteCallCount: () => (context.route as ReturnType<typeof vi.fn>).mock.calls.length,
6364
};
6465
}
@@ -157,6 +158,129 @@ describe('setupClerkTestingToken', () => {
157158

158159
expect(getRouteCallCount()).toBe(1);
159160
});
161+
162+
it('no-ops on a second call with the same explicit frontendApiUrl', async () => {
163+
const { context, getRouteCallCount } = createMockContext();
164+
165+
await setupClerkTestingToken({ context, options: { frontendApiUrl: 'a.clerk.com' } });
166+
await setupClerkTestingToken({ context, options: { frontendApiUrl: 'a.clerk.com' } });
167+
168+
expect(getRouteCallCount()).toBe(1);
169+
});
170+
171+
it('registers an additional route for a different frontendApiUrl on the same context', async () => {
172+
const { context, getRegistrations } = createMockContext();
173+
174+
await setupClerkTestingToken({ context, options: { frontendApiUrl: 'a.clerk.com' } });
175+
await setupClerkTestingToken({ context, options: { frontendApiUrl: 'b.clerk.com' } });
176+
177+
const registrations = getRegistrations();
178+
expect(registrations).toHaveLength(2);
179+
expect(registrations[0].pattern.test('https://a.clerk.com/v1/client')).toBe(true);
180+
expect(registrations[0].pattern.test('https://b.clerk.com/v1/client')).toBe(false);
181+
expect(registrations[1].pattern.test('https://b.clerk.com/v1/client')).toBe(true);
182+
expect(registrations[1].pattern.test('https://a.clerk.com/v1/client')).toBe(false);
183+
});
184+
});
185+
186+
describe('per-instance testing tokens', () => {
187+
it('uses options.testingToken string over the env var', async () => {
188+
const { context, getRouteHandler } = createMockContext();
189+
await setupClerkTestingToken({ context, options: { testingToken: 'option_token' } });
190+
191+
const { route } = createMockRoute();
192+
await getRouteHandler()!(route);
193+
194+
expect(route.fetch).toHaveBeenCalledWith({
195+
url: expect.stringContaining('__clerk_testing_token=option_token'),
196+
});
197+
});
198+
199+
it('resolves an async testing token provider', async () => {
200+
const { context, getRouteHandler } = createMockContext();
201+
await setupClerkTestingToken({ context, options: { testingToken: () => Promise.resolve('provider_token') } });
202+
203+
const { route } = createMockRoute();
204+
await getRouteHandler()!(route);
205+
206+
expect(route.fetch).toHaveBeenCalledWith({
207+
url: expect.stringContaining('__clerk_testing_token=provider_token'),
208+
});
209+
});
210+
211+
it('invokes the provider once per registration across multiple requests', async () => {
212+
const { context, getRouteHandler } = createMockContext();
213+
const provider = vi.fn(() => Promise.resolve('provider_token'));
214+
await setupClerkTestingToken({ context, options: { testingToken: provider } });
215+
216+
const handler = getRouteHandler()!;
217+
await handler(createMockRoute().route);
218+
await handler(createMockRoute().route);
219+
220+
expect(provider).toHaveBeenCalledTimes(1);
221+
});
222+
223+
it('falls back to the env var and warns when the provider rejects', async () => {
224+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
225+
const { context, getRouteHandler } = createMockContext();
226+
await setupClerkTestingToken({
227+
context,
228+
options: { testingToken: () => Promise.reject(new Error('mint failed')) },
229+
});
230+
231+
const { route, fulfilled } = createMockRoute();
232+
await getRouteHandler()!(route);
233+
234+
expect(warnSpy).toHaveBeenCalledWith(
235+
expect.stringContaining('Failed to resolve the testing token'),
236+
expect.any(Error),
237+
);
238+
expect(route.fetch).toHaveBeenCalledWith({
239+
url: expect.stringContaining(`__clerk_testing_token=${TESTING_TOKEN}`),
240+
});
241+
expect(fulfilled[0].json.response.captcha_bypass).toBe(true);
242+
243+
warnSpy.mockRestore();
244+
});
245+
246+
it('falls back to the env var when the provider resolves undefined', async () => {
247+
const { context, getRouteHandler } = createMockContext();
248+
await setupClerkTestingToken({ context, options: { testingToken: () => undefined } });
249+
250+
const { route } = createMockRoute();
251+
await getRouteHandler()!(route);
252+
253+
expect(route.fetch).toHaveBeenCalledWith({
254+
url: expect.stringContaining(`__clerk_testing_token=${TESTING_TOKEN}`),
255+
});
256+
});
257+
258+
it('appends each registration its own token when two instances share a context', async () => {
259+
const { context, getRegistrations } = createMockContext();
260+
261+
await setupClerkTestingToken({
262+
context,
263+
options: { frontendApiUrl: 'a.clerk.com', testingToken: 'token_a' },
264+
});
265+
await setupClerkTestingToken({
266+
context,
267+
options: { frontendApiUrl: 'b.clerk.com', testingToken: () => Promise.resolve('token_b') },
268+
});
269+
270+
const [first, second] = getRegistrations();
271+
272+
const routeA = createMockRoute({ url: 'https://a.clerk.com/v1/client' });
273+
await first.handler(routeA.route);
274+
expect(routeA.route.fetch).toHaveBeenCalledWith({
275+
url: expect.stringContaining('__clerk_testing_token=token_a'),
276+
});
277+
278+
const routeB = createMockRoute({ url: 'https://b.clerk.com/v1/client' });
279+
await second.handler(routeB.route);
280+
expect(routeB.route.fetch).toHaveBeenCalledWith({
281+
url: expect.stringContaining('__clerk_testing_token=token_b'),
282+
});
283+
});
160284
});
161285

162286
describe('route handler', () => {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { clerkSetup } from './setup';
22
export { createAgentTestingTask } from './agent-task';
33
export { setupClerkTestingToken } from './setupClerkTestingToken';
4+
export type { PlaywrightSetupClerkTestingTokenOptions, TestingTokenProvider } from './setupClerkTestingToken';
45
export { clerk } from './helpers';

0 commit comments

Comments
 (0)