Skip to content

Commit 47e7049

Browse files
authored
feat(testing): Sign in via email in tests (#6545)
1 parent d775436 commit 47e7049

File tree

7 files changed

+227
-61
lines changed

7 files changed

+227
-61
lines changed

.changeset/easy-parrots-slide.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@clerk/testing': minor
3+
---
4+
5+
Introduce new helper to allow signing a user in via email address:
6+
7+
```ts
8+
import { clerk } from '@clerk/testing/playwright'
9+
10+
test('sign in', async ({ page }) => {
11+
await clerk.signIn({ emailAddress: '[email protected]', page })
12+
})
13+
```

packages/clerk-js/sandbox/integration/helpers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,19 @@ export async function signInWithEmailCode(page: Page): Promise<void> {
1919
signInParams: { strategy: 'email_code', identifier: '[email protected]' },
2020
});
2121
}
22+
23+
/**
24+
* Signs in a user using the new email-based ticket strategy for integration tests.
25+
* Finds the user by email, creates a sign-in token, and uses the ticket strategy.
26+
* @param page - The Playwright page instance
27+
* @param emailAddress - The email address of the user to sign in (defaults to sandbox test user)
28+
* @example
29+
* ```ts
30+
* await signInWithEmail(page);
31+
* await page.goto('/protected-page');
32+
* ```
33+
*/
34+
export async function signInWithEmail(page: Page, emailAddress = '[email protected]'): Promise<void> {
35+
await page.goto('/sign-in');
36+
await clerk.signIn({ emailAddress, page });
37+
}

packages/clerk-js/sandbox/integration/sign-in.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,26 @@ test('sign in', async ({ page }) => {
1818
await page.locator(actionLinkElement).hover();
1919
await expect(page.locator(rootElement)).toHaveScreenshot('sign-in-action-link-hover.png');
2020
});
21+
22+
test('sign in with email', async ({ page }) => {
23+
await page.goto('/sign-in');
24+
25+
await clerk.signIn({
26+
emailAddress: '[email protected]',
27+
page,
28+
});
29+
30+
await page.waitForFunction(() => window.Clerk?.user !== null);
31+
32+
const userInfo = await page.evaluate(() => ({
33+
isSignedIn: window.Clerk?.user !== null && window.Clerk?.user !== undefined,
34+
email: window.Clerk?.user?.primaryEmailAddress?.emailAddress,
35+
userId: window.Clerk?.user?.id,
36+
isLoaded: window.Clerk?.loaded,
37+
}));
38+
39+
expect(userInfo.isSignedIn).toBe(true);
40+
expect(userInfo.email).toBe('[email protected]');
41+
expect(userInfo.userId).toBeTruthy();
42+
expect(userInfo.isLoaded).toBe(true);
43+
});

packages/testing/src/common/helpers-utils.ts

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,58 +9,114 @@ export const signInHelper = async ({ signInParams, windowObject }: SignInHelperP
99
if (!w.Clerk.client) {
1010
return;
1111
}
12+
1213
const signIn = w.Clerk.client.signIn;
13-
if (signInParams.strategy === 'password') {
14-
const res = await signIn.create(signInParams);
15-
await w.Clerk.setActive({
16-
session: res.createdSessionId,
17-
});
18-
} else {
19-
// Assert that the identifier is a test email or phone number
20-
if (signInParams.strategy === 'phone_code' && !/^\+1\d{3}55501\d{2}$/.test(signInParams.identifier)) {
21-
throw new Error(
22-
`Phone number should be a test phone number.\n
14+
15+
switch (signInParams.strategy) {
16+
case 'password': {
17+
const res = await signIn.create(signInParams);
18+
await w.Clerk.setActive({
19+
session: res.createdSessionId,
20+
});
21+
break;
22+
}
23+
24+
case 'ticket': {
25+
const res = await signIn.create({
26+
strategy: 'ticket',
27+
ticket: signInParams.ticket,
28+
});
29+
30+
if (res.status === 'complete') {
31+
await w.Clerk.setActive({
32+
session: res.createdSessionId,
33+
});
34+
} else {
35+
throw new Error(`Sign-in with ticket failed. Status: ${res.status}`);
36+
}
37+
break;
38+
}
39+
40+
case 'phone_code': {
41+
// Assert that the identifier is a test phone number
42+
if (!/^\+1\d{3}55501\d{2}$/.test(signInParams.identifier)) {
43+
throw new Error(
44+
`Phone number should be a test phone number.\n
2345
Example: +1XXX55501XX.\n
2446
Learn more here: https://clerk.com/docs/testing/test-emails-and-phones#phone-numbers`,
47+
);
48+
}
49+
50+
// Sign in with phone code
51+
const { supportedFirstFactors } = await signIn.create({
52+
identifier: signInParams.identifier,
53+
});
54+
const phoneFactor = supportedFirstFactors?.find(
55+
(factor: SignInFirstFactor): factor is PhoneCodeFactor => factor.strategy === 'phone_code',
2556
);
57+
58+
if (phoneFactor) {
59+
await signIn.prepareFirstFactor({
60+
strategy: 'phone_code',
61+
phoneNumberId: phoneFactor.phoneNumberId,
62+
});
63+
const signInAttempt = await signIn.attemptFirstFactor({
64+
strategy: 'phone_code',
65+
code: '424242',
66+
});
67+
68+
if (signInAttempt.status === 'complete') {
69+
await w.Clerk.setActive({ session: signInAttempt.createdSessionId });
70+
} else {
71+
throw new Error(`Status is ${signInAttempt.status}`);
72+
}
73+
} else {
74+
throw new Error('phone_code is not enabled.');
75+
}
76+
break;
2677
}
27-
if (signInParams.strategy === 'email_code' && !signInParams.identifier.includes('+clerk_test')) {
28-
throw new Error(
29-
`Email should be a test email.\n
78+
79+
case 'email_code': {
80+
// Assert that the identifier is a test email
81+
if (!signInParams.identifier.includes('+clerk_test')) {
82+
throw new Error(
83+
`Email should be a test email.\n
3084
Any email with the +clerk_test subaddress is a test email address.\n
3185
Learn more here: https://clerk.com/docs/testing/test-emails-and-phones#email-addresses`,
32-
);
33-
}
34-
35-
// Sign in with code (email_code or phone_code)
36-
const { supportedFirstFactors } = await signIn.create({
37-
identifier: signInParams.identifier,
38-
});
39-
const codeFactorFn =
40-
signInParams.strategy === 'phone_code'
41-
? (factor: SignInFirstFactor): factor is PhoneCodeFactor => factor.strategy === 'phone_code'
42-
: (factor: SignInFirstFactor): factor is EmailCodeFactor => factor.strategy === 'email_code';
43-
const codeFactor = supportedFirstFactors?.find(codeFactorFn);
44-
if (codeFactor) {
45-
const prepareParams =
46-
signInParams.strategy === 'phone_code'
47-
? { strategy: signInParams.strategy, phoneNumberId: (codeFactor as PhoneCodeFactor).phoneNumberId }
48-
: { strategy: signInParams.strategy, emailAddressId: (codeFactor as EmailCodeFactor).emailAddressId };
86+
);
87+
}
4988

50-
await signIn.prepareFirstFactor(prepareParams);
51-
const signInAttempt = await signIn.attemptFirstFactor({
52-
strategy: signInParams.strategy,
53-
code: '424242',
89+
// Sign in with email code
90+
const { supportedFirstFactors } = await signIn.create({
91+
identifier: signInParams.identifier,
5492
});
93+
const emailFactor = supportedFirstFactors?.find(
94+
(factor: SignInFirstFactor): factor is EmailCodeFactor => factor.strategy === 'email_code',
95+
);
96+
97+
if (emailFactor) {
98+
await signIn.prepareFirstFactor({
99+
strategy: 'email_code',
100+
emailAddressId: emailFactor.emailAddressId,
101+
});
102+
const signInAttempt = await signIn.attemptFirstFactor({
103+
strategy: 'email_code',
104+
code: '424242',
105+
});
55106

56-
if (signInAttempt.status === 'complete') {
57-
await w.Clerk.setActive({ session: signInAttempt.createdSessionId });
107+
if (signInAttempt.status === 'complete') {
108+
await w.Clerk.setActive({ session: signInAttempt.createdSessionId });
109+
} else {
110+
throw new Error(`Status is ${signInAttempt.status}`);
111+
}
58112
} else {
59-
throw new Error(`Status is ${signInAttempt.status}`);
113+
throw new Error('email_code is not enabled.');
60114
}
61-
} else {
62-
throw new Error(`${signInParams.strategy} is not enabled.`);
115+
break;
63116
}
117+
118+
default:
119+
throw new Error(`Unsupported strategy: ${(signInParams as any).strategy}`);
64120
}
65121
} catch (err: any) {
66122
throw new Error(`Clerk: Failed to sign in: ${err?.message}`);

packages/testing/src/common/setup.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createClerkClient } from '@clerk/backend';
2-
import { isProductionFromSecretKey, parsePublishableKey } from '@clerk/shared/keys';
2+
import { parsePublishableKey } from '@clerk/shared/keys';
33
import dotenv from 'dotenv';
44

55
import type { ClerkSetupOptions, ClerkSetupReturn } from './types';
@@ -39,12 +39,6 @@ export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSe
3939
}
4040

4141
if (secretKey && !testingToken) {
42-
if (isProductionFromSecretKey(secretKey)) {
43-
throw new Error(
44-
'You are using a secret key from a production instance, but Testing Tokens only work in development instances.',
45-
);
46-
}
47-
4842
log('Fetching testing token from Clerk Backend API...');
4943

5044
try {

packages/testing/src/common/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export type ClerkSignInParams =
5959
| {
6060
strategy: 'phone_code' | 'email_code';
6161
identifier: string;
62+
}
63+
| {
64+
strategy: 'ticket';
65+
ticket: string;
6266
};
6367

6468
export type SignInHelperParams = {

packages/testing/src/playwright/helpers.ts

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createClerkClient } from '@clerk/backend';
12
import type { Clerk, SignOutOptions } from '@clerk/types';
23
import type { Page } from '@playwright/test';
34

@@ -15,36 +16,55 @@ type PlaywrightClerkLoadedParams = {
1516
page: Page;
1617
};
1718

19+
type PlaywrightClerkSignInParamsWithEmail = {
20+
page: Page;
21+
emailAddress: string;
22+
setupClerkTestingTokenOptions?: SetupClerkTestingTokenOptions;
23+
};
24+
1825
type ClerkHelperParams = {
1926
/**
20-
* Signs in a user using Clerk. This helper supports only password, phone_code and email_code first factor strategies.
27+
* Signs in a user using Clerk. This helper supports multiple sign-in strategies:
28+
* 1. Using signInParams object (password, phone_code, email_code strategies)
29+
* 2. Using emailAddress for automatic ticket-based sign-in
30+
*
2131
* Multi-factor is not supported.
2232
* This helper is using the `setupClerkTestingToken` internally.
2333
* It is required to call `page.goto` before calling this helper, and navigate to a not protected page that loads Clerk.
2434
*
35+
* For strategy-based sign-in:
2536
* If the strategy is password, the helper will sign in the user using the provided password and identifier.
2637
* If the strategy is phone_code, you are required to have a user with a test phone number as an identifier (e.g. +15555550100).
2738
* If the strategy is email_code, you are required to have a user with a test email as an identifier (e.g. [email protected]).
2839
*
29-
* @param opts.signInParams.strategy - The sign in strategy. Supported strategies are 'password', 'phone_code' and 'email_code'.
30-
* @param opts.signInParams.identifier - The user's identifier. Could be a username, a phone number or an email.
31-
* @param opts.signInParams.password - The user's password. Required only if the strategy is 'password'.
32-
* @param opts.page - The Playwright page object.
33-
* @param opts.setupClerkTestingTokenOptions - The options for the `setupClerkTestingToken` function. Optional.
40+
* For email-based sign-in:
41+
* The helper finds the user by email, creates a sign-in token using Clerk's backend API, and uses the ticket strategy.
3442
*
35-
* @example
43+
* @example Strategy-based sign-in
3644
* import { clerk } from "@clerk/testing/playwright";
3745
*
38-
* test("sign in", async ({ page }) => {
46+
* test("sign in with strategy", async ({ page }) => {
3947
* await page.goto("/");
4048
* await clerk.signIn({
4149
* page,
4250
* signInParams: { strategy: 'phone_code', identifier: '+15555550100' },
4351
* });
4452
* await page.goto("/protected");
4553
* });
54+
*
55+
* @example Email-based sign-in
56+
* import { clerk } from "@clerk/testing/playwright";
57+
*
58+
* test("sign in with email", async ({ page }) => {
59+
* await page.goto("/");
60+
* await clerk.signIn({ emailAddress: "bryce@clerk.dev", page });
61+
* await page.goto("/protected");
62+
* });
4663
*/
47-
signIn: (opts: PlaywrightClerkSignInParams) => Promise<void>;
64+
signIn: {
65+
(opts: PlaywrightClerkSignInParams): Promise<void>;
66+
(opts: PlaywrightClerkSignInParamsWithEmail): Promise<void>;
67+
};
4868
/**
4969
* Signs out the current user using Clerk.
5070
* It is required to call `page.goto` before calling this helper, and navigate to a page that loads Clerk.
@@ -87,16 +107,56 @@ type PlaywrightClerkSignInParams = {
87107
setupClerkTestingTokenOptions?: SetupClerkTestingTokenOptions;
88108
};
89109

90-
const signIn = async ({ page, signInParams, setupClerkTestingTokenOptions }: PlaywrightClerkSignInParams) => {
91-
const context = page.context();
110+
const signIn = async (opts: PlaywrightClerkSignInParams | PlaywrightClerkSignInParamsWithEmail) => {
111+
const context = opts.page.context();
92112
if (!context) {
93113
throw new Error('Page context is not available. Make sure the page is properly initialized.');
94114
}
95115

96-
await setupClerkTestingToken({ context, options: setupClerkTestingTokenOptions });
97-
await loaded({ page });
116+
await setupClerkTestingToken({
117+
context,
118+
options: 'setupClerkTestingTokenOptions' in opts ? opts.setupClerkTestingTokenOptions : undefined,
119+
});
120+
await loaded({ page: opts.page });
121+
122+
if ('emailAddress' in opts) {
123+
// Email-based sign-in using ticket strategy
124+
const { emailAddress, page } = opts;
125+
126+
const secretKey = process.env.CLERK_SECRET_KEY;
127+
if (!secretKey) {
128+
throw new Error('CLERK_SECRET_KEY environment variable is required for email-based sign-in');
129+
}
130+
131+
const clerkClient = createClerkClient({ secretKey });
98132

99-
await page.evaluate(signInHelper, { signInParams });
133+
try {
134+
// Find user by email
135+
const userList = await clerkClient.users.getUserList({ emailAddress: [emailAddress] });
136+
if (!userList.data || userList.data.length === 0) {
137+
throw new Error(`No user found with email: ${emailAddress}`);
138+
}
139+
140+
const user = userList.data[0];
141+
142+
const signInToken = await clerkClient.signInTokens.createSignInToken({
143+
userId: user.id,
144+
expiresInSeconds: 300, // 5 minutes
145+
});
146+
147+
await page.evaluate(signInHelper, {
148+
signInParams: { strategy: 'ticket' as const, ticket: signInToken.token },
149+
});
150+
151+
await page.waitForFunction(() => window.Clerk?.user !== null);
152+
} catch (err: any) {
153+
throw new Error(`Failed to sign in with email ${emailAddress}: ${err?.message}`);
154+
}
155+
} else {
156+
// Strategy-based sign-in: signIn(opts)
157+
const { page, signInParams } = opts;
158+
await page.evaluate(signInHelper, { signInParams });
159+
}
100160
};
101161

102162
type PlaywrightClerkSignOutParams = {
@@ -113,7 +173,7 @@ const signOut = async ({ page, signOutOptions }: PlaywrightClerkSignOutParams) =
113173
};
114174

115175
export const clerk: ClerkHelperParams = {
116-
signIn,
176+
signIn: signIn as ClerkHelperParams['signIn'],
117177
signOut,
118178
loaded,
119179
};

0 commit comments

Comments
 (0)