import type { Page, BrowserContextOptions, LaunchOptions } from 'playwright'; import merge from 'deepmerge'; import { state } from './state'; import { isInNode } from './is-in-node'; import { getPage } from './get-page'; import { SafeRequire, safeRequire } from './safe-require'; import { ensureDir } from './ensure-dir'; import { Hooks, SafePage } from './safepage'; import { makePause } from './make-pause'; import { getRetryAttempt } from './get-retry-attempt'; import { anythingProxy } from './anythingProxy'; import type { MatchImageSnapshotOptions } from 'jest-image-snapshot'; import { exposeFunction } from './expose-function'; import { deferred } from './defer'; export const SAFETEST_INTERFACE = '__safetestApi__'; const timeout = (ms: number) => new Promise((r) => setTimeout(r, ms).unref?.()); export interface Bridge { (callback: () => Return | Promise): Promise; ( args: Args, callback: (args: Args) => Return | Promise ): Promise; } export interface RenderOptions extends LaunchOptions, BrowserContextOptions { browser?: 'chromium' | 'firefox' | 'webkit'; /** The base URL of the running app. Defaults to `localhost:3000` per CRA. */ url?: string; /** Remote server to connect to. See https://playwright.dev/docs/api/class-browsertype#browser-type-launch-server */ browserServer?: string; /** The subpath to navigate to when opening the page. */ subPath?: string; /** options to use in CI. */ ciOptions?: | { /** * Shorthand for: * * failureScreenshotsDir: `${ARTIFACTS_DIR}/failure_screenshots`, * recordVideo: { dir: `${ARTIFACTS_DIR}/videos` }, * recordTraces: `${ARTIFACTS_DIR}/traces`, * matchImageSnapshotOptions: { * customDiffDir: `${ARTIFACTS_DIR}/image_diffs`, * storeReceivedOnFailure: true, * customReceivedDir: `${ARTIFACTS_DIR}/updated_snapshots`, * customReceivedPostfix: '', * failureThreshold: 0, * } */ usingArtifactsDir: string; } | RenderOptions; /** Path to record coverage json files to for each test */ // coverageDir?: string; /** Path to failure-screenshots, defaults to `failure-screenshots/`. */ failureScreenshotsDir?: string; hooks?: Partial; recordTraces?: string; ignoreConsoleMessages?: RegExp[]; /** The default timeout. See {@link Page.setDefaultTimeout} */ defaultTimeout?: number; /** The timeout for the first page render in case there are some network auth handshakes that take a while. */ initialNavigationTimeout?: number; /** The default timeout for navigation. See {@link Page.setDefaultNavigationTimeout} */ defaultNavigationTimeout?: number; matchImageSnapshotOptions?: MatchImageSnapshotOptions; /** Run the tests inside a docker container for consistency. */ useDocker?: boolean; /** HACK: enable workaround to allow screen casting the page without breaking playwright events. */ enableScreenCasting?: boolean; /** Only run test the following tests (full test name). */ debugTests?: string[]; } export interface RenderReturn { /** The Playwright page object of the rendered component. */ page: Page; /** Pause current test. */ pause: () => Promise; /** Bridge API to communicate with browser from node */ bridge: Bridge; /** Node require function, will return an `anything` proxy in the browser */ require: SafeRequire; } type RenderableThing = { __isRenderable: true; thing: any }; const IGNORE_CONSOLE_MESSAGES = [ /^\[vite\] connected\.$/, /^\[vite\] connecting\.\.\.$/, /^Download the Vue Devtools extension for a better development experience/, /^You are running Vue in development mode/, /^%cDownload the React DevTools for a better development experience/, /\[HMR\] Waiting for update signal from WDS\.\.\.$/, /^\[webpack-dev-server\] Live Reloading enabled.$/, /^\[webpack-dev-server\] Server started/, /^Go to .* to debug this test$/, /^Download the Apollo DevTools/, ]; const backoffMs = [500, 750, 1000, 2000] as const; export async function render( element: RenderableThing, options: RenderOptions, howToRender: (element: RenderableThing) => Promise ): Promise { if (state.options) { options = merge(state.options, options); } state.pauseAtEveryStep = !!options.debugTests; if (typeof process !== 'undefined' && process.env['SAFETEST_OPTIONS']) { options = merge( options, JSON.parse(process.env['SAFETEST_OPTIONS']) as RenderOptions ); } options.headless = state.debugging.has(state.activeTest ?? '') ? false : 'headless' in options ? options.headless : true; let url = options.url ?? (typeof process !== 'undefined' ? process.env['BASE_URL'] : 'http://localhost:3000') ?? 'http://localhost:3000'; if (isInNode) { if (options.subPath) { url = `${new URL(options.subPath, options.url)}`; } const console = safeRequire('console'); let page = state.browserContextInstance?.pages()[0] as SafePage; const inspector = safeRequire('inspector'); const path = safeRequire('path'); const videoDir = options.recordVideo?.dir ?? options.videosPath; const getFilePath = () => { const filename = state.getState().testPath ?? ''; state.testPath = filename; const bootstrapDir = path.dirname(state.bootstrappedAt); const filenameWithoutExt = filename.split('.').slice(0, -1).join('.'); let relative = path.relative(bootstrapDir, filenameWithoutExt); if (!relative.startsWith('.')) relative = `./${relative}`; return relative.replace(/\\/g, '/'); }; const attempt = getRetryAttempt(); const switchingHeadlessness = state.browserContextInstance && state.browserContextInstance.headless !== options.headless; if (!page || videoDir || switchingHeadlessness) { // If there is videoDir for this test, we need can't reuse an old browserContext that // wasn't recording video. ({ page } = await getPage(options, !!videoDir || switchingHeadlessness)); if (!page._safetest_internal.safeTestExposed) { page._safetest_internal.safeTestExposed = true; await exposeFunction( page, SAFETEST_INTERFACE, async (type: string, ...args: any[]) => { const safetest_internal = page._safetest_internal; if (type === 'READY') { return safetest_internal.renderIsReadyDeferred?.resolve(); } if (type === 'GET_INFO') { const info = { testName: state.activeTest, testPath: getFilePath(), retryAttempt: attempt, }; const hooks = safetest_internal.hooks; for (const beforeRender of hooks.beforeRender ?? []) { await beforeRender(page, info); } return info; } if (type === 'BRIDGE') { const [response] = args; if ('result' in response) { safetest_internal.pendingBridge.resolve(response.result); } else { safetest_internal.pendingBridge.reject(response.error); } return; } console.log( 'unhandled SAFETEST_INTERFACE call', JSON.stringify({ type, args }) ); return; } ); } } page._safetest_internal = merge(page._safetest_internal, { // coveragePath: options.coverageDir, videoDir, failureScreenshotDir: options.failureScreenshotsDir, } as Partial); if (options.recordTraces) { await page.context().tracing.start({ screenshots: true, snapshots: true, sources: true, title: state.activeTest!, }); const test = expect.getState().currentTestName ?? ''; const testPath = path .relative(process.cwd(), expect.getState().testPath!) .replace(/[^a-z0-9_]/g, '_'); const activeTest = state.activeTest; page._safetest_internal.hooks.afterTest.push(async () => { const safeName = activeTest?.replace(/[^a-z0-9_]/gi, '_'); const path = `${options.recordTraces}/traces/${testPath}_${safeName}-attempt-${attempt}.zip`; state.artifacts.push({ type: 'trace', test, path, confirmed: true }); try { await page.context().tracing.stop({ path }); } catch {} }); } const isDebugging = state.debugging.has(state.activeTest ?? ''); state.exposeGlobals['page'] = page; const pause = (state.pause = await makePause({ page, isDebugging })); for (const beforeNavigate of page._safetest_internal.hooks.beforeNavigate) { await beforeNavigate(page); } const oldPagePause = page.pause; page.pause = (async () => { const url = inspector.url(); let port = 0; if (url) { port = +new URL(url).port; } if (port) inspector.close(); await oldPagePause.call(page); if (port) { inspector.open(port); inspector.waitForDebugger(); } }) as any; const consoleMessagesGroups: Record = {}; page.on('console', async (msg) => { const text = msg.text(); for (const ignore of [ ...IGNORE_CONSOLE_MESSAGES, ...(options.ignoreConsoleMessages ?? []), ]) { if (ignore.test(text)) { return; } } try { const args = ( await Promise.all(msg.args().map((a) => a.jsonValue())) ).map((a) => JSON.stringify(a)); const params = [`${msg.type()}:`]; if (args.length) params.push(...args); else params.push(text); const key = JSON.stringify(params); if (!consoleMessagesGroups[key]) consoleMessagesGroups[key] = 0; consoleMessagesGroups[key]++; } catch { const params = [`${msg.type()}:`, text]; const key = JSON.stringify(params); if (!consoleMessagesGroups[key]) consoleMessagesGroups[key] = 0; consoleMessagesGroups[key]++; } }); page._safetest_internal.hooks.afterTest.push(async () => { const consoleLines: string[] = []; const entries = Object.entries(consoleMessagesGroups); consoleLines.push('Console messages: '); for (const [key, count] of entries) { const args = JSON.parse(key).join(' '); consoleLines.push((count > 1 ? `(${count}X) ` : '') + `${args}`); } if (entries.length) { console.log(consoleLines.map((l, i) => (i ? ` ${l}` : l)).join('\n')); } }); page._safetest_internal.hooks.afterTest.push(async () => { const errors: Error[] = []; for (const [key, value] of Object.entries(state.pendingExpects)) { if (value) errors.push( new Error( `Expected ${value} ${key}() calls to be awaited, but they were not.` ) ); } state.pendingExpects = {}; if (errors.length) { throw new Error(errors.map((e) => e.message).join('\n')); } }); const failDir = page._safetest_internal.failureScreenshotDir; if (failDir) { const activeTest = state.activeTest; page._safetest_internal.hooks.afterTest.push(async () => { const passed = state.passedTests.has(activeTest ?? ''); if (!passed) { const pages = state.browserContextInstance?.pages(); for (const [index, page] of pages?.entries() ?? []) { const suffix = index ? `_${index}` : ''; const path = `${failDir}/${activeTest}${suffix}.png`; await page.screenshot({ path }); } } }); } if (videoDir) { const test = expect.getState().currentTestName ?? ''; const testPath = path .relative(process.cwd(), expect.getState().testPath!) .replace(/[^a-z0-9_]/g, '_'); const activeTest = state.activeTest; page._safetest_internal.hooks.afterTest.push(async () => { const pages = state.browserContextInstance?.pages() as SafePage[]; for (const page of pages ?? []) { const index = page._safetest_internal.pageIndex; await ensureDir(videoDir); const suffix = (pages?.length ?? 0) > 1 ? `_tab${index}` : ''; const safeName = activeTest?.replace(/[^a-z0-9_]/gi, '_'); const newName = `${safeName}-attempt-${attempt}${suffix}.webm`; const path = `${videoDir}/${testPath}_${newName}`; state.artifacts.push({ type: 'video', test, path, confirmed: true }); page?.video()?.saveAs(path); } }); } // Workaround issue where Playwright sometimes doesn't goto the url after a call to page.goto() const gotoAttempts = 5; let attemptsLeft = gotoAttempts; class PageReadyTimeoutError extends Error { override name = 'PageReadyTimeoutError'; } const gotoTestUrl: () => Promise = async () => { const defer = deferred(); const rejectForTimeout = () => defer.reject(new PageReadyTimeoutError()); const getUrl = () => { return page.evaluate(() => location.href).catch(() => page.url()); }; const attempt = gotoAttempts - attemptsLeft; const initialNavigationTimeout = options.initialNavigationTimeout ?? options.defaultNavigationTimeout ?? backoffMs[attempt] ?? 5000; setTimeout(async () => { if (gotoAttempts - attemptsLeft !== attempt) return; // Sometimes the page.goto doesn't register, this will detect that. const halted = new URL(await getUrl()).origin !== new URL(url).origin; if (halted) return rejectForTimeout(); // The page has no pending network requests and didn't resolve `renderIsReadyDeferred`. page.waitForLoadState('networkidle').then(rejectForTimeout, () => {}); }, initialNavigationTimeout); page .goto(url, { waitUntil: 'commit', timeout: initialNavigationTimeout }) .catch(async (error) => { const halted = new URL(await getUrl()).origin !== new URL(url).origin; if (halted) defer.reject(error); }) .then(() => page._safetest_internal.renderIsReadyDeferred?.promise) .then(() => defer.resolve()); return defer.promise.catch((error) => { const shouldRetry = attemptsLeft-- > 0; const plan = shouldRetry ? `retrying (attempts left: ${attemptsLeft + 1})...` : 'giving up'; console.log( `page.goto error: ${error.name} on "${state.activeTest}" ${plan}` ); if (shouldRetry) return gotoTestUrl(); throw new PageReadyTimeoutError(); }); }; const testName = state.activeTest; page._safetest_internal.renderIsReadyDeferred = deferred(); await gotoTestUrl(); const debugUrl = await page.evaluate( ({ testName, testPath }) => { const url = new URL(window.location.href); url.searchParams.set('test_name', testName!); url.searchParams.set('test_path', testPath!); const debugUrl = url.toString().replace(/%2F/g, '/'); console.log(`Go to ${debugUrl} to debug this test`); return debugUrl; }, { testName: state.activeTest, testPath: getFilePath() } ); if (isDebugging) { console.log(`Go to ${debugUrl} to debug this test`); } page._safetest_internal.hooks.afterTest.push(async () => { const activeTest = testName ?? ''; timeout(100).then(() => { const passed = state.passedTests.has(activeTest); if (!passed && !isDebugging) { console.log( `'${state.activeTest}' Failed. Go to ${debugUrl.replace( 'host.docker.internal', 'localhost' )} to debug this test` ); } }); }); const bridge = (state.bridge = async (passed: any) => { if (typeof passed === 'function') passed = {}; page._safetest_internal.pendingBridge = deferred(); await page.evaluate( ({ passed, SAFETEST_INTERFACE }) => { const { callback, defer } = (window as any)[SAFETEST_INTERFACE] .bridgePending; Promise.resolve() .then(() => callback(passed)) .then((result: any) => { (window as any)[SAFETEST_INTERFACE]('BRIDGE', { result }); defer.resolve(result); }) .catch((error) => { (window as any) [SAFETEST_INTERFACE]('BRIDGE', { error })(window as any) .safeTestBridge({ error }); defer.reject(error); }); }, { passed, SAFETEST_INTERFACE } ); return await page._safetest_internal.pendingBridge?.promise; }); const rendered = { page, pause, bridge, require: safeRequire, }; return rendered; } else { if (!state.browserState) { throw new Error('App was not bootstrapped to use safetest correctly'); } if (options.subPath) { try { const dummyUrl = new URL(options.subPath, 'http://localhost:3000'); const pathname = dummyUrl.pathname; const dummyUrl2 = new URL(options.subPath, dummyUrl); const different = pathname !== dummyUrl2.pathname; // subPath is something like `foo/` and every page reload we'll end up adding another `foo/` to the url incorrectly. // This is detects and breaks out of that loop. const skip = different && location.pathname.includes(pathname); if (!skip) { const url = new URL(options.subPath, location.href); const search = new URLSearchParams([ ...url.searchParams, ...[...new URLSearchParams(location.search)], ]).toString(); url.search = search; url.hash = location.hash; history.pushState(null, '', `${url}`); } } catch {} } await howToRender(element); const bridge: any = (state.bridge = (passed: any, callback: any) => { if (!callback) callback = passed; const defer = deferred(); if (!(window as any)[SAFETEST_INTERFACE]) { console.log( 'Test is waiting for a bridge call, you can manually invoke it with: `bridged(...)`. Waiting by:', callback ); (window as any).bridged = (passed: any) => { delete (window as any).bridged; defer.resolve(passed); return callback(passed); }; } else { (window as any)[SAFETEST_INTERFACE].bridgePending = { callback, defer }; } return defer.promise; }); if (typeof (window as any)[SAFETEST_INTERFACE] === 'function') { (window as any)[SAFETEST_INTERFACE]?.('READY'); } // await timeout(0); return { page: anythingProxy, pause: () => Promise.resolve(), bridge, require: anythingProxy, }; } }