Skip to content

Commit 392d3bf

Browse files
authored
feat(testing): Add Playwright page objects utilities (#5661)
1 parent 68e2d67 commit 392d3bf

File tree

22 files changed

+259
-175
lines changed

22 files changed

+259
-175
lines changed

.changeset/huge-plums-invent.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@clerk/testing': minor
3+
---
4+
5+
Add [Playwright page objects](https://playwright.dev/docs/pom) for Clerk functionality. This functionality is directly extracted from the end-to-end integration test suite that Clerk uses to develop Clerk components. While the API is being refined for public consumption, it will be available under the `@clerk/testing/playwright/unstable` import, and is not subject to [SemVer](https://semver.org) compatibility guidelines.
6+
7+
```ts
8+
import { test } from "@playwright/test";
9+
import { createPageObjects } from "@clerk/testing/playwright/unstable";
10+
11+
test("can sign up with email and password", async (context) => {
12+
const po = createPageObjects(context);
13+
14+
// Go to sign up page
15+
await po.signUp.goTo();
16+
17+
// Fill in sign up form
18+
await po.signUp.signUpWithEmailAndPassword({
19+
20+
password: Math.random().toString(36),
21+
});
22+
23+
// Verify email
24+
await po.signUp.enterTestOtpCode();
25+
26+
// Check if user is signed in
27+
await po.expect.toBeSignedIn();
28+
});
29+
```

integration/testUtils/index.ts

Lines changed: 10 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,14 @@
11
import { createClerkClient as backendCreateClerkClient } from '@clerk/backend';
2-
import { setupClerkTestingToken } from '@clerk/testing/playwright';
3-
import type { Browser, BrowserContext, Page, Response } from '@playwright/test';
4-
import { expect } from '@playwright/test';
2+
import { createPageObjects, createAppPageObject, type EnhancedPage } from '@clerk/testing/playwright/unstable';
3+
import type { Browser, BrowserContext, Page } from '@playwright/test';
54

65
import type { Application } from '../models/application';
7-
import { createAppPageObject } from './appPageObject';
86
import { createEmailService } from './emailService';
9-
import { createImpersonationPageObject } from './impersonationPageObjects';
107
import { createInvitationService } from './invitationsService';
11-
import { createKeylessPopoverPageObject } from './keylessPopoverPageObject';
128
import { createOrganizationsService } from './organizationsService';
13-
import { createOrganizationSwitcherComponentPageObject } from './organizationSwitcherPageObject';
14-
import { createSessionTaskComponentPageObject } from './sessionTaskPageObject';
15-
import type { EnchancedPage, TestArgs } from './signInPageObject';
16-
import { createSignInComponentPageObject } from './signInPageObject';
17-
import { createSignUpComponentPageObject } from './signUpPageObject';
18-
import { createUserButtonPageObject } from './userButtonPageObject';
19-
import { createUserProfileComponentPageObject } from './userProfilePageObject';
9+
2010
import type { FakeOrganization, FakeUser } from './usersService';
2111
import { createUserService } from './usersService';
22-
import { createUserVerificationComponentPageObject } from './userVerificationPageObject';
23-
import { createWaitlistComponentPageObject } from './waitlistPageObject';
2412

2513
export type { FakeUser, FakeOrganization };
2614
const createClerkClient = (app: Application) => {
@@ -31,91 +19,14 @@ const createClerkClient = (app: Application) => {
3119
});
3220
};
3321

34-
const createExpectPageObject = ({ page }: TestArgs) => {
35-
return {
36-
toBeHandshake: async (res: Response) => {
37-
// Travel the redirect chain until we find the handshake header
38-
// TODO: Loop through the redirects until we find a handshake header, or timeout trying
39-
const redirect = await res.request().redirectedFrom().redirectedFrom().response();
40-
expect(redirect.status()).toBe(307);
41-
expect(redirect.headers()['x-clerk-auth-status']).toContain('handshake');
42-
},
43-
toBeSignedOut: (args?: { timeOut: number }) => {
44-
return page.waitForFunction(
45-
() => {
46-
return !window.Clerk?.user;
47-
},
48-
null,
49-
{ timeout: args?.timeOut },
50-
);
51-
},
52-
toBeSignedIn: async () => {
53-
return page.waitForFunction(() => {
54-
return !!window.Clerk?.user;
55-
});
56-
},
57-
toBeSignedInAsActor: async () => {
58-
return page.waitForFunction(() => {
59-
return !!window.Clerk?.session?.actor;
60-
});
61-
},
62-
toHaveResolvedTask: async () => {
63-
return page.waitForFunction(() => {
64-
return !window.Clerk?.session?.currentTask;
65-
});
66-
},
67-
};
68-
};
69-
70-
const createClerkUtils = ({ page }: TestArgs) => {
71-
return {
72-
toBeLoaded: async () => {
73-
return page.waitForFunction(() => {
74-
return !!window.Clerk?.loaded;
75-
});
76-
},
77-
getClientSideActor: () => {
78-
return page.evaluate(() => {
79-
return window.Clerk?.session?.actor;
80-
});
81-
},
82-
toBeLoading: async () => {
83-
return page.waitForFunction(() => {
84-
return window.Clerk?.status === 'loading';
85-
});
86-
},
87-
toBeReady: async () => {
88-
return page.waitForFunction(() => {
89-
return window.Clerk?.status === 'ready';
90-
});
91-
},
92-
toBeDegraded: async () => {
93-
return page.waitForFunction(() => {
94-
return window.Clerk?.status === 'degraded';
95-
});
96-
},
97-
getClientSideUser: () => {
98-
return page.evaluate(() => {
99-
return window.Clerk?.user;
100-
});
101-
},
102-
};
103-
};
104-
105-
const createTestingTokenUtils = ({ context }: TestArgs) => {
106-
return {
107-
setup: async () => setupClerkTestingToken({ context }),
108-
};
109-
};
110-
11122
export type CreateAppPageObjectArgs = { page: Page; context: BrowserContext; browser: Browser };
11223

11324
export const createTestUtils = <
11425
Params extends { app: Application; useTestingToken?: boolean } & Partial<CreateAppPageObjectArgs>,
11526
Services = typeof services,
11627
PO = typeof pageObjects,
11728
BH = typeof browserHelpers,
118-
FullReturn = { services: Services; po: PO; tabs: BH; page: EnchancedPage; nextJsVersion: string },
29+
FullReturn = { services: Services; po: PO; tabs: BH; page: EnhancedPage; nextJsVersion: string },
11930
OnlyAppReturn = { services: Services },
12031
>(
12132
params: Params,
@@ -135,54 +46,37 @@ export const createTestUtils = <
13546
return { services } as any;
13647
}
13748

138-
const page = createAppPageObject({ page: params.page, useTestingToken }, app);
139-
const testArgs = { page, context, browser };
140-
141-
const pageObjects = {
142-
clerk: createClerkUtils(testArgs),
143-
expect: createExpectPageObject(testArgs),
144-
impersonation: createImpersonationPageObject(testArgs),
145-
keylessPopover: createKeylessPopoverPageObject(testArgs),
146-
organizationSwitcher: createOrganizationSwitcherComponentPageObject(testArgs),
147-
sessionTask: createSessionTaskComponentPageObject(testArgs),
148-
signIn: createSignInComponentPageObject(testArgs),
149-
signUp: createSignUpComponentPageObject(testArgs),
150-
testingToken: createTestingTokenUtils(testArgs),
151-
userButton: createUserButtonPageObject(testArgs),
152-
userProfile: createUserProfileComponentPageObject(testArgs),
153-
userVerification: createUserVerificationComponentPageObject(testArgs),
154-
waitlist: createWaitlistComponentPageObject(testArgs),
155-
};
49+
const pageObjects = createPageObjects({ page: params.page, useTestingToken, baseURL: app.serverUrl });
15650

15751
const browserHelpers = {
15852
runInNewTab: async (
159-
cb: (u: { services: Services; po: PO; page: EnchancedPage }, context: BrowserContext) => Promise<unknown>,
53+
cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise<unknown>,
16054
) => {
16155
const u = createTestUtils({
16256
app,
163-
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, app),
57+
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }),
16458
});
16559
await cb(u as any, context);
16660
return u;
16761
},
16862
runInNewBrowser: async (
169-
cb: (u: { services: Services; po: PO; page: EnchancedPage }, context: BrowserContext) => Promise<unknown>,
63+
cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise<unknown>,
17064
) => {
17165
if (!browser) {
17266
throw new Error('Browser is not defined. Did you forget to pass it to createPageObjects?');
17367
}
17468
const context = await browser.newContext();
17569
const u = createTestUtils({
17670
app,
177-
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, app),
71+
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }),
17872
});
17973
await cb(u as any, context);
18074
return u;
18175
},
18276
};
18377

18478
return {
185-
page,
79+
page: pageObjects.page,
18680
services,
18781
po: pageObjects,
18882
tabs: browserHelpers,

packages/testing/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@
3636
"default": "./dist/playwright/index.js"
3737
}
3838
},
39+
"./playwright/unstable": {
40+
"import": {
41+
"types": "./dist/types/playwright/unstable/index.d.ts",
42+
"default": "./dist/playwright/unstable/index.mjs"
43+
},
44+
"require": {
45+
"types": "./dist/types/playwright/unstable/index.d.ts",
46+
"default": "./dist/playwright/unstable/index.js"
47+
}
48+
},
3949
"./cypress": {
4050
"import": {
4151
"types": "./dist/types/cypress/index.d.ts",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createPageObjects } from './page-objects';
2+
import { createAppPageObject } from './page-objects/app';
3+
4+
export type { EnhancedPage } from './page-objects/app';
5+
export { createPageObjects, createAppPageObject };

integration/testUtils/appPageObject.ts renamed to packages/testing/src/playwright/unstable/page-objects/app.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
1-
import { setupClerkTestingToken } from '@clerk/testing/playwright';
21
import type { Page } from '@playwright/test';
32

4-
import type { Application } from '../models/application';
3+
import { setupClerkTestingToken } from '../../setupClerkTestingToken';
54

6-
export const createAppPageObject = (testArgs: { page: Page; useTestingToken?: boolean }, app: Application) => {
5+
export type EnhancedPage = ReturnType<typeof createAppPageObject>;
6+
export const createAppPageObject = (testArgs: { page: Page; useTestingToken?: boolean }, app: { baseURL?: string }) => {
77
const { page, useTestingToken = true } = testArgs;
88
const appPage = Object.create(page) as Page;
99
const helpers = {
1010
goToAppHome: async () => {
11+
if (!app.baseURL) {
12+
throw new Error(
13+
'Attempted to call method requiring baseURL, but baseURL was not provided to createPageObjects.',
14+
);
15+
}
16+
1117
try {
1218
if (useTestingToken) {
1319
await setupClerkTestingToken({ page });
1420
}
1521

16-
await page.goto(app.serverUrl);
22+
await page.goto(app.baseURL);
1723
} catch {
1824
// do not fail the test if interstitial is returned (401)
1925
}
@@ -22,13 +28,18 @@ export const createAppPageObject = (testArgs: { page: Page; useTestingToken?: bo
2228
path: string,
2329
opts: { waitUntil?: any; searchParams?: URLSearchParams; timeout?: number } = {},
2430
) => {
31+
if (!app.baseURL) {
32+
throw new Error(
33+
'Attempted to call method requiring baseURL, but baseURL was not provided to createPageObjects.',
34+
);
35+
}
2536
let url: URL;
2637

2738
try {
2839
// When testing applications using real domains we want to manually navigate to the domain first
2940
// and not follow serverUrl (localhost) by default, as this is usually proxied
3041
if (page.url().includes('about:blank')) {
31-
url = new URL(path, app.serverUrl);
42+
url = new URL(path, app.baseURL);
3243
} else {
3344
url = new URL(path, page.url());
3445
}
@@ -37,7 +48,7 @@ export const createAppPageObject = (testArgs: { page: Page; useTestingToken?: bo
3748
// as the test is using a localhost app directly
3849
// This handles the case where the page is at about:blank
3950
// and instead it uses the serverUrl
40-
url = new URL(path, app.serverUrl);
51+
url = new URL(path, app.baseURL);
4152
}
4253

4354
if (opts.searchParams) {
@@ -64,7 +75,12 @@ export const createAppPageObject = (testArgs: { page: Page; useTestingToken?: bo
6475
return page.waitForSelector('.cl-rootBox', { state: 'attached' });
6576
},
6677
waitForAppUrl: async (relativePath: string) => {
67-
return page.waitForURL(new URL(relativePath, app.serverUrl).toString());
78+
if (!app.baseURL) {
79+
throw new Error(
80+
'Attempted to call method requiring baseURL, but baseURL was not provided to createPageObjects.',
81+
);
82+
}
83+
return page.waitForURL(new URL(relativePath, app.baseURL).toString());
6884
},
6985
/**
7086
* Get the cookies for the URL the page is currently at.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { EnhancedPage } from './app';
2+
3+
export const createClerkPageObject = ({ page }: { page: EnhancedPage }) => {
4+
return {
5+
toBeLoaded: async () => {
6+
return page.waitForFunction(() => {
7+
return !!window.Clerk?.loaded;
8+
});
9+
},
10+
getClientSideActor: () => {
11+
return page.evaluate(() => {
12+
return window.Clerk?.session?.actor;
13+
});
14+
},
15+
toBeLoading: async () => {
16+
return page.waitForFunction(() => {
17+
return window.Clerk?.status === 'loading';
18+
});
19+
},
20+
toBeReady: async () => {
21+
return page.waitForFunction(() => {
22+
return window.Clerk?.status === 'ready';
23+
});
24+
},
25+
toBeDegraded: async () => {
26+
return page.waitForFunction(() => {
27+
return window.Clerk?.status === 'degraded';
28+
});
29+
},
30+
getClientSideUser: () => {
31+
return page.evaluate(() => {
32+
return window.Clerk?.user;
33+
});
34+
},
35+
};
36+
};

integration/testUtils/commonPageObject.ts renamed to packages/testing/src/playwright/unstable/page-objects/common.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { TestArgs } from './signInPageObject';
1+
import type { EnhancedPage } from './app';
22

3-
export const common = ({ page }: TestArgs) => {
3+
export const common = ({ page }: { page: EnhancedPage }) => {
44
const self = {
55
continue: () => {
66
return page.getByRole('button', { name: 'Continue', exact: true }).click();
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { Response } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
4+
import type { EnhancedPage } from './app';
5+
6+
export const createExpectPageObject = ({ page }: { page: EnhancedPage }) => {
7+
return {
8+
toBeHandshake: async (res: Response) => {
9+
// Travel the redirect chain until we find the handshake header
10+
// TODO: Loop through the redirects until we find a handshake header, or timeout trying
11+
const redirect = await res.request().redirectedFrom()?.redirectedFrom()?.response();
12+
expect(redirect?.status()).toBe(307);
13+
expect(redirect?.headers()['x-clerk-auth-status']).toContain('handshake');
14+
},
15+
toBeSignedOut: (args?: { timeOut: number }) => {
16+
return page.waitForFunction(
17+
() => {
18+
return !window.Clerk?.user;
19+
},
20+
null,
21+
{ timeout: args?.timeOut },
22+
);
23+
},
24+
toBeSignedIn: async () => {
25+
return page.waitForFunction(() => {
26+
return !!window.Clerk?.user;
27+
});
28+
},
29+
toBeSignedInAsActor: async () => {
30+
return page.waitForFunction(() => {
31+
return !!window.Clerk?.session?.actor;
32+
});
33+
},
34+
toHaveResolvedTask: async () => {
35+
return page.waitForFunction(() => {
36+
return !window.Clerk?.session?.currentTask;
37+
});
38+
},
39+
};
40+
};

0 commit comments

Comments
 (0)