UNPKG

playwright-persona

Version:
224 lines (189 loc) 6.14 kB
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 export type SessionType = Record< string, SessionValueType | { [key: string]: SessionValueType } > export interface PersonaOptions<Session extends SessionType> { createSession: CreateSessionFunction<Session> verifySession: VerifySessionFunction<Session> 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, } } export type AuthenticateFunction<Personas extends Array<Persona<any, any>>> = (options: { as: ExtractPersonaNames<Personas> }) => Promise<ExtractSessionTypes<Personas>[(typeof options)['as']]> type ExtractPersonaNames<Personas extends Array<Persona<any, any>>> = Personas extends Array<infer P> ? P extends Persona<infer Name, any> ? Name : never : never type ExtractSessionTypes<Personas extends Array<Persona<any, any>>> = Personas extends Array<infer P> ? P extends Persona<infer Name, infer Session> ? Record<Name, Session> : 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 restoreSessionState(sessionFile, newPage) return persona .verifySession( { page: newPage, session: sessionFile.session, }, testInfo, ) .then(async () => { await restoreSessionState(sessionFile, page) 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 } async function restoreSessionState( sesionFile: SessionFile, page: Page, ): Promise<void> { await page.context().addCookies(sesionFile.cookies) if (sesionFile.origins.length > 0) { const newPage = await page.context().newPage() await newPage.route(/.+/, async (route) => { await route.fulfill({ body: `<html></html>` }).catch(() => {}) }) await Promise.allSettled( sesionFile.origins.map(async (state) => { const frame = newPage.mainFrame() await frame.goto(state.origin) for (const item of state.localStorage) { await frame.evaluate( ([key, value]) => { localStorage.setItem(key, value) }, [item.name, item.value], ) } }), ) await newPage.close() } }