Skip to content

feat(testing): Add Playwright page objects utilities #5661

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
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
29 changes: 29 additions & 0 deletions .changeset/huge-plums-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@clerk/testing': minor
---

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.

```ts
import { test } from "@playwright/test";
import { createPageObjects } from "@clerk/testing/playwright/unstable";

test("can sign up with email and password", async (context) => {
const po = createPageObjects(context);

// Go to sign up page
await po.signUp.goTo();

// Fill in sign up form
await po.signUp.signUpWithEmailAndPassword({
email: '[email protected]',
password: Math.random().toString(36),
});

// Verify email
await po.signUp.enterTestOtpCode();

// Check if user is signed in
await po.expect.toBeSignedIn();
});
```
126 changes: 10 additions & 116 deletions integration/testUtils/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
import { createClerkClient as backendCreateClerkClient } from '@clerk/backend';
import { setupClerkTestingToken } from '@clerk/testing/playwright';
import type { Browser, BrowserContext, Page, Response } from '@playwright/test';
import { expect } from '@playwright/test';
import { createPageObjects, createAppPageObject, type EnhancedPage } from '@clerk/testing/playwright/unstable';
import type { Browser, BrowserContext, Page } from '@playwright/test';

import type { Application } from '../models/application';
import { createAppPageObject } from './appPageObject';
import { createEmailService } from './emailService';
import { createImpersonationPageObject } from './impersonationPageObjects';
import { createInvitationService } from './invitationsService';
import { createKeylessPopoverPageObject } from './keylessPopoverPageObject';
import { createOrganizationsService } from './organizationsService';
import { createOrganizationSwitcherComponentPageObject } from './organizationSwitcherPageObject';
import { createSessionTaskComponentPageObject } from './sessionTaskPageObject';
import type { EnchancedPage, TestArgs } from './signInPageObject';
import { createSignInComponentPageObject } from './signInPageObject';
import { createSignUpComponentPageObject } from './signUpPageObject';
import { createUserButtonPageObject } from './userButtonPageObject';
import { createUserProfileComponentPageObject } from './userProfilePageObject';

import type { FakeOrganization, FakeUser } from './usersService';
import { createUserService } from './usersService';
import { createUserVerificationComponentPageObject } from './userVerificationPageObject';
import { createWaitlistComponentPageObject } from './waitlistPageObject';

export type { FakeUser, FakeOrganization };
const createClerkClient = (app: Application) => {
Expand All @@ -31,91 +19,14 @@ const createClerkClient = (app: Application) => {
});
};

const createExpectPageObject = ({ page }: TestArgs) => {
return {
toBeHandshake: async (res: Response) => {
// Travel the redirect chain until we find the handshake header
// TODO: Loop through the redirects until we find a handshake header, or timeout trying
const redirect = await res.request().redirectedFrom().redirectedFrom().response();
expect(redirect.status()).toBe(307);
expect(redirect.headers()['x-clerk-auth-status']).toContain('handshake');
},
toBeSignedOut: (args?: { timeOut: number }) => {
return page.waitForFunction(
() => {
return !window.Clerk?.user;
},
null,
{ timeout: args?.timeOut },
);
},
toBeSignedIn: async () => {
return page.waitForFunction(() => {
return !!window.Clerk?.user;
});
},
toBeSignedInAsActor: async () => {
return page.waitForFunction(() => {
return !!window.Clerk?.session?.actor;
});
},
toHaveResolvedTask: async () => {
return page.waitForFunction(() => {
return !window.Clerk?.session?.currentTask;
});
},
};
};

const createClerkUtils = ({ page }: TestArgs) => {
return {
toBeLoaded: async () => {
return page.waitForFunction(() => {
return !!window.Clerk?.loaded;
});
},
getClientSideActor: () => {
return page.evaluate(() => {
return window.Clerk?.session?.actor;
});
},
toBeLoading: async () => {
return page.waitForFunction(() => {
return window.Clerk?.status === 'loading';
});
},
toBeReady: async () => {
return page.waitForFunction(() => {
return window.Clerk?.status === 'ready';
});
},
toBeDegraded: async () => {
return page.waitForFunction(() => {
return window.Clerk?.status === 'degraded';
});
},
getClientSideUser: () => {
return page.evaluate(() => {
return window.Clerk?.user;
});
},
};
};

const createTestingTokenUtils = ({ context }: TestArgs) => {
return {
setup: async () => setupClerkTestingToken({ context }),
};
};

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

export const createTestUtils = <
Params extends { app: Application; useTestingToken?: boolean } & Partial<CreateAppPageObjectArgs>,
Services = typeof services,
PO = typeof pageObjects,
BH = typeof browserHelpers,
FullReturn = { services: Services; po: PO; tabs: BH; page: EnchancedPage; nextJsVersion: string },
FullReturn = { services: Services; po: PO; tabs: BH; page: EnhancedPage; nextJsVersion: string },
OnlyAppReturn = { services: Services },
>(
params: Params,
Expand All @@ -135,54 +46,37 @@ export const createTestUtils = <
return { services } as any;
}

const page = createAppPageObject({ page: params.page, useTestingToken }, app);
const testArgs = { page, context, browser };

const pageObjects = {
clerk: createClerkUtils(testArgs),
expect: createExpectPageObject(testArgs),
impersonation: createImpersonationPageObject(testArgs),
keylessPopover: createKeylessPopoverPageObject(testArgs),
organizationSwitcher: createOrganizationSwitcherComponentPageObject(testArgs),
sessionTask: createSessionTaskComponentPageObject(testArgs),
signIn: createSignInComponentPageObject(testArgs),
signUp: createSignUpComponentPageObject(testArgs),
testingToken: createTestingTokenUtils(testArgs),
userButton: createUserButtonPageObject(testArgs),
userProfile: createUserProfileComponentPageObject(testArgs),
userVerification: createUserVerificationComponentPageObject(testArgs),
waitlist: createWaitlistComponentPageObject(testArgs),
};
const pageObjects = createPageObjects({ page: params.page, useTestingToken, baseURL: app.serverUrl });

const browserHelpers = {
runInNewTab: async (
cb: (u: { services: Services; po: PO; page: EnchancedPage }, context: BrowserContext) => Promise<unknown>,
cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise<unknown>,
) => {
const u = createTestUtils({
app,
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, app),
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }),
});
await cb(u as any, context);
return u;
},
runInNewBrowser: async (
cb: (u: { services: Services; po: PO; page: EnchancedPage }, context: BrowserContext) => Promise<unknown>,
cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise<unknown>,
) => {
if (!browser) {
throw new Error('Browser is not defined. Did you forget to pass it to createPageObjects?');
}
const context = await browser.newContext();
const u = createTestUtils({
app,
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, app),
page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }),
});
await cb(u as any, context);
return u;
},
};

return {
page,
page: pageObjects.page,
services,
po: pageObjects,
tabs: browserHelpers,
Expand Down
10 changes: 10 additions & 0 deletions packages/testing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@
"default": "./dist/playwright/index.js"
}
},
"./playwright/unstable": {
"import": {
"types": "./dist/types/playwright/unstable/index.d.ts",
"default": "./dist/playwright/unstable/index.mjs"
},
"require": {
"types": "./dist/types/playwright/unstable/index.d.ts",
"default": "./dist/playwright/unstable/index.js"
}
},
"./cypress": {
"import": {
"types": "./dist/types/cypress/index.d.ts",
Expand Down
5 changes: 5 additions & 0 deletions packages/testing/src/playwright/unstable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createPageObjects } from './page-objects';
import { createAppPageObject } from './page-objects/app';

export type { EnhancedPage } from './page-objects/app';
export { createPageObjects, createAppPageObject };
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { setupClerkTestingToken } from '@clerk/testing/playwright';
import type { Page } from '@playwright/test';

import type { Application } from '../models/application';
import { setupClerkTestingToken } from '../../setupClerkTestingToken';

export const createAppPageObject = (testArgs: { page: Page; useTestingToken?: boolean }, app: Application) => {
export type EnhancedPage = ReturnType<typeof createAppPageObject>;
export const createAppPageObject = (testArgs: { page: Page; useTestingToken?: boolean }, app: { baseURL?: string }) => {
const { page, useTestingToken = true } = testArgs;
const appPage = Object.create(page) as Page;
const helpers = {
goToAppHome: async () => {
if (!app.baseURL) {
throw new Error(
'Attempted to call method requiring baseURL, but baseURL was not provided to createPageObjects.',
);
}

try {
if (useTestingToken) {
await setupClerkTestingToken({ page });
}

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

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

if (opts.searchParams) {
Expand All @@ -64,7 +75,12 @@ export const createAppPageObject = (testArgs: { page: Page; useTestingToken?: bo
return page.waitForSelector('.cl-rootBox', { state: 'attached' });
},
waitForAppUrl: async (relativePath: string) => {
return page.waitForURL(new URL(relativePath, app.serverUrl).toString());
if (!app.baseURL) {
throw new Error(
'Attempted to call method requiring baseURL, but baseURL was not provided to createPageObjects.',
);
}
return page.waitForURL(new URL(relativePath, app.baseURL).toString());
},
/**
* Get the cookies for the URL the page is currently at.
Expand Down
36 changes: 36 additions & 0 deletions packages/testing/src/playwright/unstable/page-objects/clerk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { EnhancedPage } from './app';

export const createClerkPageObject = ({ page }: { page: EnhancedPage }) => {
return {
toBeLoaded: async () => {
return page.waitForFunction(() => {
return !!window.Clerk?.loaded;
});
},
getClientSideActor: () => {
return page.evaluate(() => {
return window.Clerk?.session?.actor;
});
},
toBeLoading: async () => {
return page.waitForFunction(() => {
return window.Clerk?.status === 'loading';
});
},
toBeReady: async () => {
return page.waitForFunction(() => {
return window.Clerk?.status === 'ready';
});
},
toBeDegraded: async () => {
return page.waitForFunction(() => {
return window.Clerk?.status === 'degraded';
});
},
getClientSideUser: () => {
return page.evaluate(() => {
return window.Clerk?.user;
});
},
};
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { TestArgs } from './signInPageObject';
import type { EnhancedPage } from './app';

export const common = ({ page }: TestArgs) => {
export const common = ({ page }: { page: EnhancedPage }) => {
const self = {
continue: () => {
return page.getByRole('button', { name: 'Continue', exact: true }).click();
Expand Down
40 changes: 40 additions & 0 deletions packages/testing/src/playwright/unstable/page-objects/expect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Response } from '@playwright/test';
import { expect } from '@playwright/test';

import type { EnhancedPage } from './app';

export const createExpectPageObject = ({ page }: { page: EnhancedPage }) => {
return {
toBeHandshake: async (res: Response) => {
// Travel the redirect chain until we find the handshake header
// TODO: Loop through the redirects until we find a handshake header, or timeout trying
const redirect = await res.request().redirectedFrom()?.redirectedFrom()?.response();
expect(redirect?.status()).toBe(307);
expect(redirect?.headers()['x-clerk-auth-status']).toContain('handshake');
},
toBeSignedOut: (args?: { timeOut: number }) => {
return page.waitForFunction(
() => {
return !window.Clerk?.user;
},
null,
{ timeout: args?.timeOut },
);
},
toBeSignedIn: async () => {
return page.waitForFunction(() => {
return !!window.Clerk?.user;
});
},
toBeSignedInAsActor: async () => {
return page.waitForFunction(() => {
return !!window.Clerk?.session?.actor;
});
},
toHaveResolvedTask: async () => {
return page.waitForFunction(() => {
return !window.Clerk?.session?.currentTask;
});
},
};
};
Loading