Skip to content

Commit 366dcd6

Browse files
committed
feat(repo): Introduce page objects and test utils
1 parent 4f1f350 commit 366dcd6

7 files changed

Lines changed: 359 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Page } from '@playwright/test';
2+
3+
import type { Application } from '../adapters/application';
4+
5+
export const createAppPageObject = (testArgs: { page: Page }, app: Application) => {
6+
const { page } = testArgs;
7+
const appPage = Object.create(page) as Page;
8+
const helpers = {
9+
goToStart: async () => {
10+
try {
11+
await page.goto(app.serverUrl);
12+
} catch (e) {
13+
// do not fail the test if interstitial is returned (401)
14+
}
15+
},
16+
goToRelative: async (path: string, opts: { searchParams?: URLSearchParams } = {}) => {
17+
const url = new URL(path, app.serverUrl);
18+
if (opts.searchParams) {
19+
url.search = opts.searchParams.toString();
20+
}
21+
await page.goto(url.toString(), { timeout: 10000 });
22+
},
23+
goToSignIn: (searchParams: URLSearchParams) => {
24+
return helpers.goToRelative('/sign-in', { searchParams });
25+
},
26+
goToSignUp: (searchParams: URLSearchParams) => {
27+
return helpers.goToRelative('/sign-up', { searchParams });
28+
},
29+
goToUserProfile: () => {
30+
return helpers.goToRelative('/user');
31+
},
32+
waitForClerkJsLoaded: async () => {
33+
return page.waitForFunction(() => {
34+
// @ts-ignore
35+
return window.Clerk?.isReady();
36+
});
37+
},
38+
waitForClerkComponentMounted: async () => {
39+
return page.waitForSelector('.cl-rootBox', { state: 'attached' });
40+
},
41+
};
42+
return Object.assign(appPage, helpers);
43+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export const common = ({ page }: TestArgs) => {
2+
return {
3+
continue: () => {
4+
return page.getByRole('button', { name: 'Continue', exact: true }).click();
5+
},
6+
setPassword: (val: string) => {
7+
return page.locator('input[name=password]').fill(val);
8+
},
9+
enterOtpCode: async (code: string) => {
10+
await page.getByRole('textbox', { name: /digit 1/i }).click();
11+
await page.keyboard.type(code, { delay: 50 });
12+
},
13+
getIdentifierInput: () => {
14+
return page.locator('input[name=identifier]');
15+
},
16+
getEmailAddressInput: () => {
17+
return page.locator('input[name=emailAddress]');
18+
},
19+
getPasswordInput: () => {
20+
return page.locator('input[name=password]');
21+
},
22+
getFirstNameInput: () => {
23+
return page.locator('input[name=firstName]');
24+
},
25+
getLastNameInput: () => {
26+
return page.locator('input[name=lastName]');
27+
},
28+
};
29+
};
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { runWithExponentialBackOff } from '@clerk/shared';
2+
3+
type Message = {
4+
_id: string;
5+
subject: string;
6+
};
7+
8+
export const createEmailService = () => {
9+
const cleanEmail = (email: string) => {
10+
return email.replace(/\+.*@/, '@');
11+
};
12+
13+
const fetcher = async (url: string | URL, init?: RequestInit) => {
14+
const headers = new Headers(init?.headers || {});
15+
headers.set('Mailsac-Key', process.env.MAILSAC_API_KEY as string);
16+
return fetch(url, { ...init, headers });
17+
};
18+
19+
const filterMessagesByAddress = async (email: string, sub?: string) => {
20+
const url = new URL('https://mailsac.com/api/inbox-filter');
21+
url.searchParams.set('andTo', email);
22+
if (sub) {
23+
url.searchParams.set('andSubjectIncludes', sub);
24+
}
25+
// Retry in case the email delivery is delayed
26+
await new Promise(res => setTimeout(res, 1500));
27+
return runWithExponentialBackOff(
28+
async () => {
29+
const res = await fetcher(url);
30+
const json = (await res.json()) as unknown as { messages: Message[] };
31+
const message = json.messages[0];
32+
if (!message) {
33+
throw new Error('message not found');
34+
}
35+
return message;
36+
},
37+
{
38+
firstDelay: 750,
39+
timeMultiple: 2,
40+
shouldRetry: (_, iterationsCount) => iterationsCount < 5,
41+
},
42+
);
43+
};
44+
45+
const getMessagePlaintextForAddress = async (email: string, id: string) => {
46+
const url = new URL(`https://mailsac.com/api/text/${cleanEmail(email)}/${id}`);
47+
const res = await fetcher(url);
48+
return res.text();
49+
};
50+
51+
const deleteMessage = async (email: string, id: string) => {
52+
// best-effort file-and-forget delete
53+
const url = new URL(`https://mailsac.com/api/addresses/${cleanEmail(email)}/messages/${id}`);
54+
return fetcher(url, { method: 'DELETE' });
55+
};
56+
57+
return {
58+
getCodeForEmailAddress: async (email: string) => {
59+
const message = await filterMessagesByAddress(email, 'verification code');
60+
const code = (message.subject.match(/\d{6}/)?.[0] || '').trim();
61+
void deleteMessage(email, message._id);
62+
return code;
63+
},
64+
getVerificationLinkForEmailAddress: async (email: string) => {
65+
const message = await filterMessagesByAddress(email, 'link');
66+
const body = await getMessagePlaintextForAddress(email, message._id);
67+
const link = (body.match(/https:\/\/.*\/verify\?.*/) || [''])[0].trim().replace(/&amp;/g, '&');
68+
void deleteMessage(email, message._id);
69+
return link;
70+
},
71+
};
72+
};

integration/testUtils/index.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Clerk } from '@clerk/backend';
2+
import type { Browser, BrowserContext, Page } from '@playwright/test';
3+
4+
import type { Application } from '../adapters/application';
5+
import { createAppPageObject } from './appPageObject';
6+
import { createEmailService } from './emailService';
7+
import type { EnchancedPage, TestArgs } from './signInPageObject';
8+
import { createSignInComponentPageObject } from './signInPageObject';
9+
import { createSignUpComponentPageObject } from './signUpPageObject';
10+
import { createUserService } from './usersService';
11+
12+
const createClerkClient = (app: Application) => {
13+
return Clerk({
14+
secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'),
15+
publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'),
16+
});
17+
};
18+
19+
const createExpectPageObject = ({ page }: TestArgs) => {
20+
return {
21+
toBeSignedOut: () => {
22+
return page.waitForFunction(() => {
23+
// @ts-ignore
24+
return !window.Clerk?.user;
25+
});
26+
},
27+
toBeSignedIn: async () => {
28+
return page.waitForFunction(() => {
29+
// @ts-ignore
30+
return !!window.Clerk?.user;
31+
});
32+
},
33+
};
34+
};
35+
36+
type CreateAppPageObjectArgs = { page: Page; context: BrowserContext; browser: Browser };
37+
38+
export const createTestUtils = <
39+
Params extends { app: Application } & Partial<CreateAppPageObjectArgs>,
40+
Services = typeof services,
41+
PO = typeof pageObjects,
42+
BH = typeof browserHelpers,
43+
FullReturn = { services: Services; po: PO; tabs: BH; page: EnchancedPage },
44+
OnlyAppReturn = { services: Services },
45+
>(
46+
params: Params,
47+
): Params extends Partial<CreateAppPageObjectArgs> ? FullReturn : OnlyAppReturn => {
48+
const { app, context, browser } = params || {};
49+
50+
const clerkClient = createClerkClient(app);
51+
const services = {
52+
email: createEmailService(),
53+
users: createUserService(clerkClient),
54+
clerk: clerkClient,
55+
};
56+
57+
if (!params.page) {
58+
return { services } as any;
59+
}
60+
61+
const page = createAppPageObject({ page: params.page }, app);
62+
const testArgs = { page, context, browser };
63+
64+
const pageObjects = {
65+
signUp: createSignUpComponentPageObject(testArgs),
66+
signIn: createSignInComponentPageObject(testArgs),
67+
expect: createExpectPageObject(testArgs),
68+
};
69+
70+
const browserHelpers = {
71+
runInNewTab: async (
72+
cb: (u: { services: Services; po: PO; page: EnchancedPage }, context: BrowserContext) => Promise<unknown>,
73+
) => {
74+
const u = createTestUtils({ app, page: createAppPageObject({ page: await context.newPage() }, app) });
75+
await cb(u as any, context);
76+
return u;
77+
},
78+
runInNewBrowser: async (
79+
cb: (u: { services: Services; po: PO; page: EnchancedPage }, context: BrowserContext) => Promise<unknown>,
80+
) => {
81+
if (!browser) {
82+
throw new Error('Browser is not defined. Did you forget to pass it to createPageObjects?');
83+
}
84+
const context = await browser.newContext();
85+
const u = createTestUtils({ app, page: createAppPageObject({ page: await context.newPage() }, app) });
86+
await cb(u as any, context);
87+
return u;
88+
},
89+
};
90+
91+
return { page, services, po: pageObjects, tabs: browserHelpers } as any;
92+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { Browser, BrowserContext } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
4+
import type { createAppPageObject } from './appPageObject';
5+
import { common } from './commonPageObject';
6+
7+
export type EnchancedPage = ReturnType<typeof createAppPageObject>;
8+
export type TestArgs = { page: EnchancedPage; context: BrowserContext; browser: Browser };
9+
10+
export const createSignInComponentPageObject = (testArgs: TestArgs) => {
11+
const { page } = testArgs;
12+
const self = {
13+
...common(testArgs),
14+
goTo: async (opts?: { searchParams: URLSearchParams }) => {
15+
await page.goToRelative('/sign-in', opts);
16+
return self.waitForMounted();
17+
},
18+
waitForMounted: () => {
19+
return page.waitForSelector('.cl-signIn-root', { state: 'attached' });
20+
},
21+
setIdentifier: (val: string) => {
22+
return self.getIdentifierInput().fill(val);
23+
},
24+
setInstantPassword: async (val: string) => {
25+
const passField = self.getPasswordInput();
26+
await passField.fill(val, { force: true });
27+
await expect(passField).toBeVisible();
28+
},
29+
getGoToSignUp: () => {
30+
return page.getByRole('link', { name: /sign up/i });
31+
},
32+
getUseAnotherMethodLink: () => {
33+
return page.getByRole('link', { name: /use another method/i });
34+
},
35+
getAltMethodsEmailCodeButton: () => {
36+
return page.getByRole('button', { name: /email code to/i });
37+
},
38+
getAltMethodsEmailLinkButton: () => {
39+
return page.getByRole('button', { name: /email link to/i });
40+
},
41+
signInWithOauth: (provider: string) => {
42+
return page.getByRole('button', { name: new RegExp(`continue with ${provider}`, 'gi') });
43+
},
44+
signInWithEmailAndInstantPassword: async (opts: { email: string; password: string }) => {
45+
await self.getIdentifierInput().fill(opts.email);
46+
await self.setInstantPassword(opts.password);
47+
await self.continue();
48+
},
49+
};
50+
return self;
51+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { common } from './commonPageObject';
2+
import type { TestArgs } from './signInPageObject';
3+
4+
export const createSignUpComponentPageObject = (testArgs: TestArgs) => {
5+
const { page } = testArgs;
6+
7+
const self = {
8+
...common(testArgs),
9+
goTo: async (opts?: { searchParams: URLSearchParams }) => {
10+
await page.goToRelative('/sign-up', opts);
11+
return self.waitForMounted();
12+
},
13+
waitForMounted: () => {
14+
return page.waitForSelector('.cl-signUp-root', { state: 'attached' });
15+
},
16+
signUpWithOauth: (provider: string) => {
17+
return page.getByRole('button', { name: new RegExp(`continue with ${provider}`, 'gi') });
18+
},
19+
signUpWithEmailAndPassword: async (opts: { email: string; password: string }) => {
20+
await self.getEmailAddressInput().fill(opts.email);
21+
await self.setPassword(opts.password);
22+
await self.continue();
23+
},
24+
waitForEmailVerificationScreen: async () => {
25+
await page.waitForURL(/verify/);
26+
await page.getByRole('heading', { name: /Verify your email/i }).waitFor();
27+
},
28+
};
29+
30+
return self;
31+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Clerk } from '@clerk/backend';
2+
import { faker } from '@faker-js/faker';
3+
4+
import { hash } from '../adapters/helpers';
5+
6+
export type FakeUser = ReturnType<ReturnType<typeof createUserService>['createFakeUser']>;
7+
8+
export const createUserService = (clerkClient: ReturnType<typeof Clerk>) => {
9+
const self = {
10+
createFakeUser: () => {
11+
const email = `clerkcookie+${hash()}@mailsac.com`;
12+
return {
13+
firstName: faker.person.firstName(),
14+
lastName: faker.person.lastName(),
15+
email,
16+
password: `${email}${email}`,
17+
// this generates a random fictional number that can be verified
18+
// using the 424242 code. Allowing 10^5 combinations should be enough
19+
// entropy for e2e purposes
20+
// https://clerk.com/docs/testing/e2e-testing#phone-numbers
21+
phoneNumber: faker.phone.number('+1###55501##'),
22+
deleteIfExists: () => self.deleteIfExists({ email }),
23+
};
24+
},
25+
createBapiUser: async (fakeUser: ReturnType<typeof self.createFakeUser>) => {
26+
return await clerkClient.users.createUser({
27+
emailAddress: [fakeUser.email],
28+
password: fakeUser.password,
29+
firstName: fakeUser.firstName,
30+
lastName: fakeUser.lastName,
31+
});
32+
},
33+
deleteIfExists: async (opts: { id?: string; email?: string }) => {
34+
const id = opts.id || (await clerkClient.users.getUserList({ emailAddress: [opts.email] }))[0]?.id;
35+
if (id) {
36+
await clerkClient.users.deleteUser(id);
37+
}
38+
},
39+
};
40+
return self;
41+
};

0 commit comments

Comments
 (0)