playwright-persona
Version:
Authentication in Playwright using personas.
238 lines (206 loc) • 6.61 kB
text/typescript
import * as fs from 'node:fs'
import * as path from 'node:path'
import { invariant } from 'outvariant'
import type {
TestFixture,
PlaywrightTestArgs,
PlaywrightWorkerArgs,
Page,
TestInfo,
} from '@playwright/test'
const STORAGE_STATE_DIRECTORY = path.join(process.cwd(), './playwright/.auth/')
type SessionValueType =
| string
| number
| Date
| null
| undefined
| Array<SessionValueType>
| { [key: string]: SessionValueType }
export type SessionType = Record<string, SessionValueType>
export interface PersonaOptions<Session extends SessionType> {
/**
* Create a new session for this persona.
* Returns the context object to share state across other methods.
*
* @example
* async createSession({ page }) {
* const user = await createMockUser()
* await page.goto('/login')
* await page.getByLabel('Username').fill(user.name)
* await page.getByLagbel('Password').fill(user.password)
* await page.getByRole('button', { name: 'Log in' }).click()
*
* return { user }
* }
*/
createSession: CreateSessionFunction<Session>
/**
* Verify that the given session belongs to this persona.
* Used to determine if the authentication state stored on disk
* should still be used or a new session should be created.
*
* @example
* async verifySession({ page, session }) {
* await page.goto('/dashboard')
* await expect(page.getByText(session.user.name)).toBeVisible()
* }
*/
verifySession: VerifySessionFunction<Session>
/**
* Destroy the given session.
* Used to properly dispose of test resources related to a stored session
* (e.g. test records in a database).
*
* @example
* async destroySession({ session }) {
* await deleteUser({ where: { id: session.user.id } })
* }
*/
destroySession?: DestroySessionFunction<Session>
}
export interface CreateSessionContext {
page: Page
}
export interface VerifySessionContext<Session extends SessionType> {
session: Session
page: Page
}
export interface DestroySessionContext<Session extends SessionType> {
session: Session
page: Page
}
export interface Persona<Name extends string, Session extends SessionType> {
name: Name
createSession: CreateSessionFunction<Session>
verifySession: VerifySessionFunction<Session>
destroySession?: DestroySessionFunction<Session>
}
export type CreateSessionFunction<Session extends SessionType> = (
context: CreateSessionContext,
testInfo: TestInfo,
) => Promise<Session>
export type VerifySessionFunction<Session extends SessionType> = (
context: VerifySessionContext<Session>,
testInfo: TestInfo,
) => Promise<void>
export type DestroySessionFunction<Session extends SessionType> = (
context: DestroySessionContext<Session>,
testInfo: TestInfo,
) => Promise<void>
export function definePersona<Name extends string, Session extends SessionType>(
name: Name,
options: PersonaOptions<Session>,
): Persona<Name, Session> {
return {
name,
createSession: options.createSession,
verifySession: options.verifySession,
destroySession: options.destroySession,
}
}
export type AuthenticateFunction<Personas extends Array<Persona<any, any>>> = <
Name extends ExtractPersonaNames<Personas>,
>(options: {
as: Name
}) => Promise<MapPersonasToRecord<Personas>[Name]>
type MapPersonasToRecord<Personas extends Array<Persona<any, any>>> =
Personas extends Array<infer P>
? {
[Name in P extends Persona<infer Name, any>
? Name
: never]: P extends Persona<Name, infer Session> ? Session : never
}
: never
type ExtractPersonaNames<Personas extends Array<Persona<any, any>>> =
Personas extends Array<infer P>
? P extends Persona<infer Name, any>
? Name
: never
: never
export function combinePersonas<Personas extends Array<Persona<any, any>>>(
...personas: Personas
): TestFixture<AuthenticateFunction<Personas>, any> {
return async (
{ browser, context, page }: PlaywrightTestArgs & PlaywrightWorkerArgs,
use,
testInfo,
) => {
const createSessionFilePath = (persona: Persona<any, any>) => {
return path.join(
STORAGE_STATE_DIRECTORY,
`./${testInfo.testId}-${persona.name}.json`,
)
}
const createSession = async (persona: Persona<any, any>) => {
const session = await persona.createSession({ page }, testInfo)
const sessionFilePath = createSessionFilePath(persona)
await context.storageState({
path: sessionFilePath,
})
const sessionFile = await readSessionFile(sessionFilePath)
sessionFile.session = session
await fs.promises.writeFile(
sessionFilePath,
JSON.stringify(sessionFile, null, 2),
)
return session
}
await use(async (options) => {
const persona = personas.find((persona) => {
return persona.name === options.as
})
invariant(
persona,
'Failed to authenticate: cannot find persona by name "%s" (known personas: %s)',
options.as,
personas.join(', '),
)
const sessionFilePath = createSessionFilePath(persona)
if (fs.existsSync(sessionFilePath)) {
const sessionFile = await readSessionFile(sessionFilePath)
// Verify the session in a new, isolated browser context.
const newContext = await browser.newContext({
storageState: sessionFile,
})
const newPage = await newContext.newPage()
await newContext.setStorageState(sessionFile)
return persona
.verifySession(
{
page: newPage,
session: sessionFile.session,
},
testInfo,
)
.then(async () => {
await context.setStorageState(sessionFile)
return sessionFile.session
})
.catch(async () => {
await persona.destroySession?.(
{
page,
session: sessionFile.session,
},
testInfo,
)
return createSession(persona)
})
.finally(async () => {
await newContext.close()
})
}
return createSession(persona)
})
}
}
type SessionFile = Awaited<
ReturnType<PlaywrightTestArgs['context']['storageState']>
> & {
session: Record<string, any>
}
async function readSessionFile(filePath: string): Promise<SessionFile> {
const textContent = await fs.promises.readFile(filePath, 'utf8')
return JSON.parse(textContent) as SessionFile
}