Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/eighty-husky-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/testing': minor
---

`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; the `clerk.signIn` helper accepts the same options via `setupClerkTestingTokenOptions`. 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.

Note: `setupClerkTestingToken` for Playwright now resolves the frontend API URL before its duplicate-call check, so calling it without a resolvable frontend API URL (no `options.frontendApiUrl` and no `CLERK_FAPI`) always throws; previously such repeat calls on an already-set-up context were silently ignored.
73 changes: 69 additions & 4 deletions integration/testUtils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { createClerkClient as backendCreateClerkClient } from '@clerk/backend';
import { createAppPageObject, createPageObjects, type EnhancedPage } from '@clerk/testing/playwright/unstable';
import { parsePublishableKey } from '@clerk/shared/keys';
import {
createAppPageObject,
createPageObjects,
type EnhancedPage,
type PlaywrightSetupClerkTestingTokenOptions,
} from '@clerk/testing/playwright/unstable';
import type { Browser, BrowserContext, Page } from '@playwright/test';

import type { Application } from '../models/application';
Expand All @@ -21,6 +27,53 @@ const createClerkClient = (app: Application) => {
});
};

// One testing token per instance (keyed by publishable key), minted lazily on the first
// FAPI request a test intercepts and shared across tests in this worker process.
const testingTokenCache = new Map<string, Promise<string | undefined>>();

const getTestingToken = (
app: Application,
clerkClient: ReturnType<typeof createClerkClient>,
publishableKey: string,
): Promise<string | undefined> => {
let promise = testingTokenCache.get(publishableKey);
if (!promise) {
promise = clerkClient.testingTokens
.createTestingToken()
.then(({ token }) => token)
.catch(err => {
console.warn(
`Failed to mint a testing token for app "${app.name}". Falling back to the CLERK_TESTING_TOKEN env var.`,
err,
);
// Evict so the next test re-attempts the mint; otherwise one transient
// failure would pin the env-var fallback for the whole worker process.
testingTokenCache.delete(publishableKey);
return undefined;
});
testingTokenCache.set(publishableKey, promise);
}
return promise;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

// Builds per-app options so the testing-token bypass targets the instance THIS app talks
// to, instead of the process-global CLERK_FAPI (which holds whichever app ran clerkSetup
// last and silently misses every other instance, e.g. captcha-enabled ones).
const createTestingTokenOptions = (
app: Application,
clerkClient: ReturnType<typeof createClerkClient>,
): PlaywrightSetupClerkTestingTokenOptions | undefined => {
const publishableKey = app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY');
const parsedKey = publishableKey ? parsePublishableKey(publishableKey) : null;
if (!publishableKey || parsedKey?.instanceType !== 'development') {
return undefined;
}
return {
frontendApiUrl: parsedKey.frontendApi,
testingToken: () => getTestingToken(app, clerkClient, publishableKey),
};
};

export type CreateAppPageObjectArgs = { page: Page; context: BrowserContext; browser: Browser };

export const createTestUtils = <
Expand Down Expand Up @@ -49,15 +102,24 @@ export const createTestUtils = <
return { services } as any;
}

const pageObjects = createPageObjects({ page: params.page, useTestingToken, baseURL: app.serverUrl });
const testingTokenOptions = createTestingTokenOptions(app, clerkClient);
const pageObjects = createPageObjects({
page: params.page,
useTestingToken,
baseURL: app.serverUrl,
testingTokenOptions,
});

const browserHelpers = {
runInNewTab: async (
cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise<unknown>,
) => {
const u = createTestUtils({
app,
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }),
page: createAppPageObject(
{ page: await context.newPage(), useTestingToken, testingTokenOptions },
{ baseURL: app.serverUrl },
),
});
await cb(u as any, context);
return u;
Expand All @@ -71,7 +133,10 @@ export const createTestUtils = <
const context = await browser.newContext();
const u = createTestUtils({
app,
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }),
page: createAppPageObject(
{ page: await context.newPage(), useTestingToken, testingTokenOptions },
{ baseURL: app.serverUrl },
),
});
await cb(u as any, context);
return u;
Expand Down
7 changes: 7 additions & 0 deletions packages/testing/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export type SetupClerkTestingTokenOptions = {
* Example: 'relieved-chamois-66.clerk.accounts.dev'
*/
frontendApiUrl?: string;

/*
* The testing token to append to Frontend API requests.
* If provided, it takes precedence over the CLERK_TESTING_TOKEN environment variable.
* Useful when a test suite spans multiple Clerk instances, each needing its own token.
*/
testingToken?: string;
};

export type ClerkSignInParams =
Expand Down
3 changes: 2 additions & 1 deletion packages/testing/src/cypress/setupClerkTestingToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type SetupClerkTestingTokenParams = {
* Bypasses bot protection by appending the testing token in the Frontend API requests.
*
* @param params.options.frontendApiUrl - The frontend API URL for your Clerk dev instance, without the protocol.
* @param params.options.testingToken - The testing token to append. Takes precedence over the `CLERK_TESTING_TOKEN` Cypress env variable.
* @returns A promise that resolves when the bot protection bypass is set up.
* @throws An error if the Frontend API URL is not provided.
* @example
Expand All @@ -29,7 +30,7 @@ export const setupClerkTestingToken = (params?: SetupClerkTestingTokenParams) =>
const apiUrl = `https://${fapiUrl}/v1/**`;

cy.intercept(apiUrl, req => {
const testingToken = Cypress.env('CLERK_TESTING_TOKEN');
const testingToken = params?.options?.testingToken || Cypress.env('CLERK_TESTING_TOKEN');
if (testingToken) {
req.query[TESTING_TOKEN_PARAM] = testingToken;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,19 @@ function createMockRoute(
}

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

const context = {
route: vi.fn((_pattern: RegExp, handler: (route: Route) => Promise<void>) => {
routeHandler = handler;
route: vi.fn((pattern: RegExp, handler: (route: Route) => Promise<void>) => {
registrations.push({ pattern, handler });
return Promise.resolve();
}),
} as unknown as BrowserContext;

return {
context,
getRouteHandler: () => routeHandler,
getRouteHandler: () => registrations.at(-1)?.handler,
getRegistrations: () => registrations,
getRouteCallCount: () => (context.route as ReturnType<typeof vi.fn>).mock.calls.length,
};
}
Expand Down Expand Up @@ -148,6 +149,25 @@ describe('setupClerkTestingToken', () => {
expect(routeFn).toHaveBeenCalledTimes(2);
});

it('rolls back only the failed host, keeping other hosts registered', async () => {
const routeFn = vi.fn();
routeFn.mockResolvedValueOnce(undefined);
routeFn.mockRejectedValueOnce(new Error('context closed'));
routeFn.mockResolvedValueOnce(undefined);

const context = { route: routeFn } as unknown as BrowserContext;

await setupClerkTestingToken({ context, options: { frontendApiUrl: 'a.clerk.com' } });
await expect(setupClerkTestingToken({ context, options: { frontendApiUrl: 'b.clerk.com' } })).rejects.toThrow(
'context closed',
);
// The failed host can retry; the successful host stays deduped.
await setupClerkTestingToken({ context, options: { frontendApiUrl: 'b.clerk.com' } });
await setupClerkTestingToken({ context, options: { frontendApiUrl: 'a.clerk.com' } });

expect(routeFn).toHaveBeenCalledTimes(3);
});

it('resolves context from page when context is not provided', async () => {
const { context, getRouteCallCount } = createMockContext();
const page = { context: () => context } as any;
Expand All @@ -157,6 +177,129 @@ describe('setupClerkTestingToken', () => {

expect(getRouteCallCount()).toBe(1);
});

it('no-ops on a second call with the same explicit frontendApiUrl', async () => {
const { context, getRouteCallCount } = createMockContext();

await setupClerkTestingToken({ context, options: { frontendApiUrl: 'a.clerk.com' } });
await setupClerkTestingToken({ context, options: { frontendApiUrl: 'a.clerk.com' } });

expect(getRouteCallCount()).toBe(1);
});

it('registers an additional route for a different frontendApiUrl on the same context', async () => {
const { context, getRegistrations } = createMockContext();

await setupClerkTestingToken({ context, options: { frontendApiUrl: 'a.clerk.com' } });
await setupClerkTestingToken({ context, options: { frontendApiUrl: 'b.clerk.com' } });

const registrations = getRegistrations();
expect(registrations).toHaveLength(2);
expect(registrations[0].pattern.test('https://a.clerk.com/v1/client')).toBe(true);
expect(registrations[0].pattern.test('https://b.clerk.com/v1/client')).toBe(false);
expect(registrations[1].pattern.test('https://b.clerk.com/v1/client')).toBe(true);
expect(registrations[1].pattern.test('https://a.clerk.com/v1/client')).toBe(false);
});
});

describe('per-instance testing tokens', () => {
it('uses options.testingToken string over the env var', async () => {
const { context, getRouteHandler } = createMockContext();
await setupClerkTestingToken({ context, options: { testingToken: 'option_token' } });

const { route } = createMockRoute();
await getRouteHandler()!(route);

expect(route.fetch).toHaveBeenCalledWith({
url: expect.stringContaining('__clerk_testing_token=option_token'),
});
});

it('resolves an async testing token provider', async () => {
const { context, getRouteHandler } = createMockContext();
await setupClerkTestingToken({ context, options: { testingToken: () => Promise.resolve('provider_token') } });

const { route } = createMockRoute();
await getRouteHandler()!(route);

expect(route.fetch).toHaveBeenCalledWith({
url: expect.stringContaining('__clerk_testing_token=provider_token'),
});
});

it('invokes the provider once per registration across multiple requests', async () => {
const { context, getRouteHandler } = createMockContext();
const provider = vi.fn(() => Promise.resolve('provider_token'));
await setupClerkTestingToken({ context, options: { testingToken: provider } });

const handler = getRouteHandler()!;
await handler(createMockRoute().route);
await handler(createMockRoute().route);

expect(provider).toHaveBeenCalledTimes(1);
});

it('falls back to the env var and warns when the provider rejects', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { context, getRouteHandler } = createMockContext();
await setupClerkTestingToken({
context,
options: { testingToken: () => Promise.reject(new Error('mint failed')) },
});

const { route, fulfilled } = createMockRoute();
await getRouteHandler()!(route);

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to resolve the testing token'),
expect.any(Error),
);
expect(route.fetch).toHaveBeenCalledWith({
url: expect.stringContaining(`__clerk_testing_token=${TESTING_TOKEN}`),
});
expect(fulfilled[0].json.response.captcha_bypass).toBe(true);

warnSpy.mockRestore();
});

it('falls back to the env var when the provider resolves undefined', async () => {
const { context, getRouteHandler } = createMockContext();
await setupClerkTestingToken({ context, options: { testingToken: () => undefined } });

const { route } = createMockRoute();
await getRouteHandler()!(route);

expect(route.fetch).toHaveBeenCalledWith({
url: expect.stringContaining(`__clerk_testing_token=${TESTING_TOKEN}`),
});
});

it('appends each registration its own token when two instances share a context', async () => {
const { context, getRegistrations } = createMockContext();

await setupClerkTestingToken({
context,
options: { frontendApiUrl: 'a.clerk.com', testingToken: 'token_a' },
});
await setupClerkTestingToken({
context,
options: { frontendApiUrl: 'b.clerk.com', testingToken: () => Promise.resolve('token_b') },
});

const [first, second] = getRegistrations();

const routeA = createMockRoute({ url: 'https://a.clerk.com/v1/client' });
await first.handler(routeA.route);
expect(routeA.route.fetch).toHaveBeenCalledWith({
url: expect.stringContaining('__clerk_testing_token=token_a'),
});

const routeB = createMockRoute({ url: 'https://b.clerk.com/v1/client' });
await second.handler(routeB.route);
expect(routeB.route.fetch).toHaveBeenCalledWith({
url: expect.stringContaining('__clerk_testing_token=token_b'),
});
});
});

describe('route handler', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/testing/src/playwright/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { createClerkClient } from '@clerk/backend';
import type { Clerk, SignOutOptions } from '@clerk/shared/types';
import type { Page } from '@playwright/test';

import type { ClerkSignInParams, SetupClerkTestingTokenOptions } from '../common';
import type { ClerkSignInParams } from '../common';
import { signInHelper } from '../common';
import type { PlaywrightSetupClerkTestingTokenOptions } from './setupClerkTestingToken';
import { setupClerkTestingToken } from './setupClerkTestingToken';

declare global {
Expand All @@ -19,7 +20,7 @@ type PlaywrightClerkLoadedParams = {
type PlaywrightClerkSignInParamsWithEmail = {
page: Page;
emailAddress: string;
setupClerkTestingTokenOptions?: SetupClerkTestingTokenOptions;
setupClerkTestingTokenOptions?: PlaywrightSetupClerkTestingTokenOptions;
};

type ClerkHelperParams = {
Expand Down Expand Up @@ -104,7 +105,7 @@ const loaded = async ({ page }: PlaywrightClerkLoadedParams) => {
type PlaywrightClerkSignInParams = {
page: Page;
signInParams: ClerkSignInParams;
setupClerkTestingTokenOptions?: SetupClerkTestingTokenOptions;
setupClerkTestingTokenOptions?: PlaywrightSetupClerkTestingTokenOptions;
};

const signIn = async (opts: PlaywrightClerkSignInParams | PlaywrightClerkSignInParamsWithEmail) => {
Expand Down
1 change: 1 addition & 0 deletions packages/testing/src/playwright/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { clerkSetup } from './setup';
export { createAgentTestingTask } from './agent-task';
export { setupClerkTestingToken } from './setupClerkTestingToken';
export type { PlaywrightSetupClerkTestingTokenOptions, TestingTokenProvider } from './setupClerkTestingToken';
export { clerk } from './helpers';
Loading
Loading