UNPKG

@vitest/browser-playwright

Version:

Browser running for Vitest using playwright

1,113 lines (1,094 loc) 31.2 kB
import { parseKeyDef, resolveScreenshotPath, defineBrowserProvider } from '@vitest/browser'; export { defineBrowserCommand } from '@vitest/browser'; import { createManualModuleSource } from '@vitest/mocker/node'; import c from 'tinyrainbow'; import { createDebugger, isCSSRequest } from 'vitest/node'; import { mkdir, unlink } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; const _DRIVE_LETTER_START_RE = /^[A-Za-z]:\//; function normalizeWindowsPath(input = "") { if (!input) { return input; } return input.replace(/\\/g, "/").replace(_DRIVE_LETTER_START_RE, (r) => r.toUpperCase()); } const _UNC_REGEX = /^[/\\]{2}/; const _IS_ABSOLUTE_RE = /^[/\\](?![/\\])|^[/\\]{2}(?!\.)|^[A-Za-z]:[/\\]/; const _DRIVE_LETTER_RE = /^[A-Za-z]:$/; const _ROOT_FOLDER_RE = /^\/([A-Za-z]:)?$/; const normalize = function(path) { if (path.length === 0) { return "."; } path = normalizeWindowsPath(path); const isUNCPath = path.match(_UNC_REGEX); const isPathAbsolute = isAbsolute(path); const trailingSeparator = path[path.length - 1] === "/"; path = normalizeString(path, !isPathAbsolute); if (path.length === 0) { if (isPathAbsolute) { return "/"; } return trailingSeparator ? "./" : "."; } if (trailingSeparator) { path += "/"; } if (_DRIVE_LETTER_RE.test(path)) { path += "/"; } if (isUNCPath) { if (!isPathAbsolute) { return `//./${path}`; } return `//${path}`; } return isPathAbsolute && !isAbsolute(path) ? `/${path}` : path; }; function cwd() { if (typeof process !== "undefined" && typeof process.cwd === "function") { return process.cwd().replace(/\\/g, "/"); } return "/"; } const resolve = function(...arguments_) { arguments_ = arguments_.map((argument) => normalizeWindowsPath(argument)); let resolvedPath = ""; let resolvedAbsolute = false; for (let index = arguments_.length - 1; index >= -1 && !resolvedAbsolute; index--) { const path = index >= 0 ? arguments_[index] : cwd(); if (!path || path.length === 0) { continue; } resolvedPath = `${path}/${resolvedPath}`; resolvedAbsolute = isAbsolute(path); } resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute); if (resolvedAbsolute && !isAbsolute(resolvedPath)) { return `/${resolvedPath}`; } return resolvedPath.length > 0 ? resolvedPath : "."; }; function normalizeString(path, allowAboveRoot) { let res = ""; let lastSegmentLength = 0; let lastSlash = -1; let dots = 0; let char = null; for (let index = 0; index <= path.length; ++index) { if (index < path.length) { char = path[index]; } else if (char === "/") { break; } else { char = "/"; } if (char === "/") { if (lastSlash === index - 1 || dots === 1) ; else if (dots === 2) { if (res.length < 2 || lastSegmentLength !== 2 || res[res.length - 1] !== "." || res[res.length - 2] !== ".") { if (res.length > 2) { const lastSlashIndex = res.lastIndexOf("/"); if (lastSlashIndex === -1) { res = ""; lastSegmentLength = 0; } else { res = res.slice(0, lastSlashIndex); lastSegmentLength = res.length - 1 - res.lastIndexOf("/"); } lastSlash = index; dots = 0; continue; } else if (res.length > 0) { res = ""; lastSegmentLength = 0; lastSlash = index; dots = 0; continue; } } if (allowAboveRoot) { res += res.length > 0 ? "/.." : ".."; lastSegmentLength = 2; } } else { if (res.length > 0) { res += `/${path.slice(lastSlash + 1, index)}`; } else { res = path.slice(lastSlash + 1, index); } lastSegmentLength = index - lastSlash - 1; } lastSlash = index; dots = 0; } else if (char === "." && dots !== -1) { ++dots; } else { dots = -1; } } return res; } const isAbsolute = function(p) { return _IS_ABSOLUTE_RE.test(p); }; const relative = function(from, to) { const _from = resolve(from).replace(_ROOT_FOLDER_RE, "$1").split("/"); const _to = resolve(to).replace(_ROOT_FOLDER_RE, "$1").split("/"); if (_to[0][1] === ":" && _from[0][1] === ":" && _from[0] !== _to[0]) { return _to.join("/"); } const _fromCopy = [..._from]; for (const segment of _fromCopy) { if (_to[0] !== segment) { break; } _from.shift(); _to.shift(); } return [..._from.map(() => ".."), ..._to].join("/"); }; const dirname = function(p) { const segments = normalizeWindowsPath(p).replace(/\/$/, "").split("/").slice(0, -1); if (segments.length === 1 && _DRIVE_LETTER_RE.test(segments[0])) { segments[0] += "/"; } return segments.join("/") || (isAbsolute(p) ? "/" : "."); }; const basename = function(p, extension) { const segments = normalizeWindowsPath(p).split("/"); let lastSegment = ""; for (let i = segments.length - 1; i >= 0; i--) { const val = segments[i]; if (val) { lastSegment = val; break; } } return extension && lastSegment.endsWith(extension) ? lastSegment.slice(0, -extension.length) : lastSegment; }; const clear = async (context, selector) => { const { iframe } = context; const element = iframe.locator(selector); await element.clear(); }; const click = async (context, selector, options = {}) => { const tester = context.iframe; await tester.locator(selector).click(options); }; const dblClick = async (context, selector, options = {}) => { const tester = context.iframe; await tester.locator(selector).dblclick(options); }; const tripleClick = async (context, selector, options = {}) => { const tester = context.iframe; await tester.locator(selector).click({ ...options, clickCount: 3 }); }; const dragAndDrop = async (context, source, target, options_) => { const frame = await context.frame(); await frame.dragAndDrop(source, target, options_); }; const fill = async (context, selector, text, options = {}) => { const { iframe } = context; const element = iframe.locator(selector); await element.fill(text, options); }; const hover = async (context, selector, options = {}) => { await context.iframe.locator(selector).hover(options); }; const keyboard = async (context, text, state) => { const frame = await context.frame(); await frame.evaluate(focusIframe); const pressed = new Set(state.unreleased); await keyboardImplementation(pressed, context.provider, context.sessionId, text, async () => { const frame = await context.frame(); await frame.evaluate(selectAll); }, true); return { unreleased: Array.from(pressed) }; }; const keyboardCleanup = async (context, state) => { const { provider, sessionId } = context; if (!state.unreleased) { return; } const page = provider.getPage(sessionId); for (const key of state.unreleased) { await page.keyboard.up(key); } }; // fallback to insertText for non US key // https://github.com/microsoft/playwright/blob/50775698ae13642742f2a1e8983d1d686d7f192d/packages/playwright-core/src/server/input.ts#L95 const VALID_KEYS = new Set([ "Escape", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "Backquote", "`", "~", "Digit1", "1", "!", "Digit2", "2", "@", "Digit3", "3", "#", "Digit4", "4", "$", "Digit5", "5", "%", "Digit6", "6", "^", "Digit7", "7", "&", "Digit8", "8", "*", "Digit9", "9", "(", "Digit0", "0", ")", "Minus", "-", "_", "Equal", "=", "+", "Backslash", "\\", "|", "Backspace", "Tab", "KeyQ", "q", "Q", "KeyW", "w", "W", "KeyE", "e", "E", "KeyR", "r", "R", "KeyT", "t", "T", "KeyY", "y", "Y", "KeyU", "u", "U", "KeyI", "i", "I", "KeyO", "o", "O", "KeyP", "p", "P", "BracketLeft", "[", "{", "BracketRight", "]", "}", "CapsLock", "KeyA", "a", "A", "KeyS", "s", "S", "KeyD", "d", "D", "KeyF", "f", "F", "KeyG", "g", "G", "KeyH", "h", "H", "KeyJ", "j", "J", "KeyK", "k", "K", "KeyL", "l", "L", "Semicolon", ";", ":", "Quote", "'", "\"", "Enter", "\n", "\r", "ShiftLeft", "Shift", "KeyZ", "z", "Z", "KeyX", "x", "X", "KeyC", "c", "C", "KeyV", "v", "V", "KeyB", "b", "B", "KeyN", "n", "N", "KeyM", "m", "M", "Comma", ",", "<", "Period", ".", ">", "Slash", "/", "?", "ShiftRight", "ControlLeft", "Control", "MetaLeft", "Meta", "AltLeft", "Alt", "Space", " ", "AltRight", "AltGraph", "MetaRight", "ContextMenu", "ControlRight", "PrintScreen", "ScrollLock", "Pause", "PageUp", "PageDown", "Insert", "Delete", "Home", "End", "ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown", "NumLock", "NumpadDivide", "NumpadMultiply", "NumpadSubtract", "Numpad7", "Numpad8", "Numpad9", "Numpad4", "Numpad5", "Numpad6", "NumpadAdd", "Numpad1", "Numpad2", "Numpad3", "Numpad0", "NumpadDecimal", "NumpadEnter", "ControlOrMeta" ]); async function keyboardImplementation(pressed, provider, sessionId, text, selectAll, skipRelease) { const page = provider.getPage(sessionId); const actions = parseKeyDef(text); for (const { releasePrevious, releaseSelf, repeat, keyDef } of actions) { const key = keyDef.key; // TODO: instead of calling down/up for each key, join non special // together, and call `type` once for all non special keys, // and then `press` for special keys if (pressed.has(key)) { if (VALID_KEYS.has(key)) { await page.keyboard.up(key); } pressed.delete(key); } if (!releasePrevious) { if (key === "selectall") { await selectAll(); continue; } for (let i = 1; i <= repeat; i++) { if (VALID_KEYS.has(key)) { await page.keyboard.down(key); } else { await page.keyboard.insertText(key); } } if (releaseSelf) { if (VALID_KEYS.has(key)) { await page.keyboard.up(key); } } else { pressed.add(key); } } } if (!skipRelease && pressed.size) { for (const key of pressed) { if (VALID_KEYS.has(key)) { await page.keyboard.up(key); } } } return { pressed }; } function focusIframe() { if (!document.activeElement || document.activeElement.ownerDocument !== document || document.activeElement === document.body) { window.focus(); } } function selectAll() { const element = document.activeElement; if (element && typeof element.select === "function") { element.select(); } } /** * Takes a screenshot using the provided browser context and returns a buffer and the expected screenshot path. * * **Note**: the returned `path` indicates where the screenshot *might* be found. * It is not guaranteed to exist, especially if `options.save` is `false`. * * @throws {Error} If the function is not called within a test or if the browser provider does not support screenshots. */ async function takeScreenshot(context, name, options) { if (!context.testPath) { throw new Error(`Cannot take a screenshot without a test path`); } const path = resolveScreenshotPath(context.testPath, name, context.project.config, options.path); // playwright does not need a screenshot path if we don't intend to save it let savePath; if (options.save) { savePath = normalize(path); await mkdir(dirname(savePath), { recursive: true }); } const mask = options.mask?.map((selector) => context.iframe.locator(selector)); if (options.element) { const { element: selector, ...config } = options; const element = context.iframe.locator(selector); const buffer = await element.screenshot({ ...config, mask, path: savePath }); return { buffer, path }; } const buffer = await context.iframe.locator("body").screenshot({ ...options, mask, path: savePath }); return { buffer, path }; } const selectOptions = async (context, selector, userValues, options = {}) => { const value = userValues; const { iframe } = context; const selectElement = iframe.locator(selector); const values = await Promise.all(value.map(async (v) => { if (typeof v === "string") { return v; } const elementHandler = await iframe.locator(v.element).elementHandle(); if (!elementHandler) { throw new Error(`Element not found: ${v.element}`); } return elementHandler; })); await selectElement.selectOption(values, options); }; const tab = async (context, options = {}) => { const page = context.page; await page.keyboard.press(options.shift === true ? "Shift+Tab" : "Tab"); }; const startTracing = async ({ context, project, provider, sessionId }) => { if (isPlaywrightProvider(provider)) { if (provider.tracingContexts.has(sessionId)) { return; } provider.tracingContexts.add(sessionId); const options = project.config.browser.trace; await context.tracing.start({ screenshots: options.screenshots ?? true, snapshots: options.snapshots ?? true, sources: false }).catch(() => { provider.tracingContexts.delete(sessionId); }); return; } throw new TypeError(`The ${provider.name} provider does not support tracing.`); }; const startChunkTrace = async (command, { name, title }) => { const { provider, sessionId, testPath, context } = command; if (!testPath) { throw new Error(`stopChunkTrace cannot be called outside of the test file.`); } if (isPlaywrightProvider(provider)) { if (!provider.tracingContexts.has(sessionId)) { await startTracing(command); } const path = resolveTracesPath(command, name); provider.pendingTraces.set(path, sessionId); await context.tracing.startChunk({ name, title }); return; } throw new TypeError(`The ${provider.name} provider does not support tracing.`); }; const stopChunkTrace = async (context, { name }) => { if (isPlaywrightProvider(context.provider)) { const path = resolveTracesPath(context, name); context.provider.pendingTraces.delete(path); await context.context.tracing.stopChunk({ path }); return { tracePath: path }; } throw new TypeError(`The ${context.provider.name} provider does not support tracing.`); }; function resolveTracesPath({ testPath, project }, name) { if (!testPath) { throw new Error(`This command can only be called inside a test file.`); } const options = project.config.browser.trace; const sanitizedName = `${project.name.replace(/[^a-z0-9]/gi, "-")}-${name}.trace.zip`; if (options.tracesDir) { return resolve(options.tracesDir, sanitizedName); } const dir = dirname(testPath); const base = basename(testPath); return resolve(dir, "__traces__", base, `${project.name.replace(/[^a-z0-9]/gi, "-")}-${name}.trace.zip`); } const deleteTracing = async (context, { traces }) => { if (!context.testPath) { throw new Error(`stopChunkTrace cannot be called outside of the test file.`); } if (isPlaywrightProvider(context.provider)) { return Promise.all(traces.map((trace) => unlink(trace).catch((err) => { if (err.code === "ENOENT") { // Ignore the error if the file doesn't exist return; } // Re-throw other errors throw err; }))); } throw new Error(`provider ${context.provider.name} is not supported`); }; const annotateTraces = async ({ project }, { testId, traces }) => { const vitest = project.vitest; await Promise.all(traces.map((trace) => { const entity = vitest.state.getReportedEntityById(testId); const location = entity?.location ? { file: entity.module.moduleId, line: entity.location.line, column: entity.location.column } : undefined; return vitest._testRun.recordArtifact(testId, { type: "internal:annotation", annotation: { message: relative(project.config.root, trace), type: "traces", attachment: { path: trace, contentType: "application/octet-stream" }, location }, location }); })); }; function isPlaywrightProvider(provider) { return provider.name === "playwright"; } const type = async (context, selector, text, options = {}) => { const { skipClick = false, skipAutoClose = false } = options; const unreleased = new Set(Reflect.get(options, "unreleased") ?? []); const { iframe } = context; const element = iframe.locator(selector); if (!skipClick) { await element.focus(); } await keyboardImplementation(unreleased, context.provider, context.sessionId, text, () => element.selectText(), skipAutoClose); return { unreleased: Array.from(unreleased) }; }; const upload = async (context, selector, files, options) => { const testPath = context.testPath; if (!testPath) { throw new Error(`Cannot upload files outside of a test`); } const root = context.project.config.root; const { iframe } = context; const playwrightFiles = files.map((file) => { if (typeof file === "string") { return resolve(root, file); } return { name: file.name, mimeType: file.mimeType, buffer: Buffer.from(file.base64, "base64") }; }); await iframe.locator(selector).setInputFiles(playwrightFiles, options); }; var commands = { __vitest_upload: upload, __vitest_click: click, __vitest_dblClick: dblClick, __vitest_tripleClick: tripleClick, __vitest_takeScreenshot: takeScreenshot, __vitest_type: type, __vitest_clear: clear, __vitest_fill: fill, __vitest_tab: tab, __vitest_keyboard: keyboard, __vitest_selectOptions: selectOptions, __vitest_dragAndDrop: dragAndDrop, __vitest_hover: hover, __vitest_cleanup: keyboardCleanup, __vitest_deleteTracing: deleteTracing, __vitest_startChunkTrace: startChunkTrace, __vitest_startTracing: startTracing, __vitest_stopChunkTrace: stopChunkTrace, __vitest_annotateTraces: annotateTraces }; const pkgRoot = resolve(fileURLToPath(import.meta.url), "../.."); const distRoot = resolve(pkgRoot, "dist"); const debug = createDebugger("vitest:browser:playwright"); const playwrightBrowsers = [ "firefox", "webkit", "chromium" ]; // Enable intercepting of requests made by service workers - experimental API is only available in Chromium based browsers // Requests from service workers are only available on context.route() https://playwright.dev/docs/service-workers-experimental process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS ??= "1"; function playwright(options = {}) { return defineBrowserProvider({ name: "playwright", supportedBrowser: playwrightBrowsers, options, providerFactory(project) { return new PlaywrightBrowserProvider(project, options); } }); } class PlaywrightBrowserProvider { name = "playwright"; supportsParallelism = true; browser = null; contexts = new Map(); pages = new Map(); mocker; browserName; browserPromise = null; closing = false; tracingContexts = new Set(); pendingTraces = new Map(); initScripts = [resolve(distRoot, "locators.js")]; constructor(project, options) { this.project = project; this.options = options; this.browserName = project.config.browser.name; this.mocker = this.createMocker(); for (const [name, command] of Object.entries(commands)) { project.browser.registerCommand(name, command); } // make sure the traces are finished if the test hangs process.on("SIGTERM", () => { if (!this.browser) { return; } const promises = []; for (const [trace, contextId] of this.pendingTraces.entries()) { promises.push((() => { const context = this.contexts.get(contextId); return context?.tracing.stopChunk({ path: trace }); })()); } return Promise.allSettled(promises); }); } async openBrowser() { await this._throwIfClosing(); if (this.browserPromise) { debug?.("[%s] the browser is resolving, reusing the promise", this.browserName); return this.browserPromise; } if (this.browser) { debug?.("[%s] the browser is resolved, reusing it", this.browserName); return this.browser; } this.browserPromise = (async () => { const options = this.project.config.browser; const playwright = await import('playwright'); if (this.options.connectOptions) { if (this.options.launchOptions) { this.project.vitest.logger.warn(c.yellow(`Found both ${c.bold(c.italic(c.yellow("connect")))} and ${c.bold(c.italic(c.yellow("launch")))} options in browser instance configuration. Ignoring ${c.bold(c.italic(c.yellow("launch")))} options and using ${c.bold(c.italic(c.yellow("connect")))} mode. You probably want to remove one of the two options and keep only the one you want to use.`)); } const browser = await playwright[this.browserName].connect(this.options.connectOptions.wsEndpoint, this.options.connectOptions); this.browser = browser; this.browserPromise = null; return this.browser; } const launchOptions = { ...this.options.launchOptions, headless: options.headless }; if (typeof options.trace === "object" && options.trace.tracesDir) { launchOptions.tracesDir = options.trace?.tracesDir; } const inspector = this.project.vitest.config.inspector; if (inspector.enabled) { // NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector const port = inspector.port || 9229; const host = inspector.host || "127.0.0.1"; launchOptions.args ||= []; launchOptions.args.push(`--remote-debugging-port=${port}`); launchOptions.args.push(`--remote-debugging-address=${host}`); this.project.vitest.logger.log(`Debugger listening on ws://${host}:${port}`); } // start Vitest UI maximized only on supported browsers if (this.project.config.browser.ui && this.browserName === "chromium") { if (!launchOptions.args) { launchOptions.args = []; } if (!launchOptions.args.includes("--start-maximized") && !launchOptions.args.includes("--start-fullscreen")) { launchOptions.args.push("--start-maximized"); } } debug?.("[%s] initializing the browser with launch options: %O", this.browserName, launchOptions); this.browser = await playwright[this.browserName].launch(launchOptions); this.browserPromise = null; return this.browser; })(); return this.browserPromise; } createMocker() { const idPreficates = new Map(); const sessionIds = new Map(); function createPredicate(sessionId, url) { const moduleUrl = new URL(url, "http://localhost"); const predicate = (url) => { if (url.searchParams.has("_vitest_original")) { return false; } // different modules, ignore request if (url.pathname !== moduleUrl.pathname) { return false; } url.searchParams.delete("t"); url.searchParams.delete("v"); url.searchParams.delete("import"); // different search params, ignore request if (url.searchParams.size !== moduleUrl.searchParams.size) { return false; } // check that all search params are the same for (const [param, value] of url.searchParams.entries()) { if (moduleUrl.searchParams.get(param) !== value) { return false; } } return true; }; const ids = sessionIds.get(sessionId) || []; ids.push(moduleUrl.href); sessionIds.set(sessionId, ids); idPreficates.set(predicateKey(sessionId, moduleUrl.href), predicate); return predicate; } function predicateKey(sessionId, url) { return `${sessionId}:${url}`; } return { register: async (sessionId, module) => { const page = this.getPage(sessionId); await page.context().route(createPredicate(sessionId, module.url), async (route) => { if (module.type === "manual") { const exports$1 = Object.keys(await module.resolve()); const body = createManualModuleSource(module.url, exports$1); return route.fulfill({ body, headers: getHeaders(this.project.browser.vite.config) }); } // webkit doesn't support redirect responses // https://github.com/microsoft/playwright/issues/18318 const isWebkit = this.browserName === "webkit"; if (isWebkit) { let url; if (module.type === "redirect") { const redirect = new URL(module.redirect); url = redirect.href.slice(redirect.origin.length); } else { const request = new URL(route.request().url()); request.searchParams.set("mock", module.type); url = request.href.slice(request.origin.length); } const result = await this.project.browser.vite.transformRequest(url).catch(() => null); if (!result) { return route.continue(); } let content = result.code; if (result.map && "version" in result.map && result.map.mappings) { const type = isDirectCSSRequest(url) ? "css" : "js"; content = getCodeWithSourcemap(type, content.toString(), result.map); } return route.fulfill({ body: content, headers: getHeaders(this.project.browser.vite.config) }); } if (module.type === "redirect") { return route.fulfill({ status: 302, headers: { Location: module.redirect } }); } else if (module.type === "automock" || module.type === "autospy") { const url = new URL(route.request().url()); url.searchParams.set("mock", module.type); return route.fulfill({ status: 302, headers: { Location: url.href } }); } else ; }); }, delete: async (sessionId, id) => { const page = this.getPage(sessionId); const key = predicateKey(sessionId, id); const predicate = idPreficates.get(key); if (predicate) { await page.context().unroute(predicate).finally(() => idPreficates.delete(key)); } }, clear: async (sessionId) => { const page = this.getPage(sessionId); const ids = sessionIds.get(sessionId) || []; const promises = ids.map((id) => { const key = predicateKey(sessionId, id); const predicate = idPreficates.get(key); if (predicate) { return page.context().unroute(predicate).finally(() => idPreficates.delete(key)); } return null; }); await Promise.all(promises).finally(() => sessionIds.delete(sessionId)); } }; } async createContext(sessionId) { await this._throwIfClosing(); if (this.contexts.has(sessionId)) { debug?.("[%s][%s] the context already exists, reusing it", sessionId, this.browserName); return this.contexts.get(sessionId); } const browser = await this.openBrowser(); await this._throwIfClosing(browser); const actionTimeout = this.options.actionTimeout; const contextOptions = this.options.contextOptions ?? {}; const options = { ...contextOptions, ignoreHTTPSErrors: true }; if (this.project.config.browser.ui) { options.viewport = null; } // TODO: investigate the consequences for Vitest 5 // else { // if UI is disabled, keep the iframe scale to 1 // options.viewport ??= this.project.config.browser.viewport // } const context = await browser.newContext(options); await this._throwIfClosing(context); if (actionTimeout != null) { context.setDefaultTimeout(actionTimeout); } debug?.("[%s][%s] the context is ready", sessionId, this.browserName); this.contexts.set(sessionId, context); return context; } getPage(sessionId) { const page = this.pages.get(sessionId); if (!page) { throw new Error(`Page "${sessionId}" not found in ${this.browserName} browser.`); } return page; } getCommandsContext(sessionId) { const page = this.getPage(sessionId); return { page, context: this.contexts.get(sessionId), frame() { return new Promise((resolve, reject) => { const frame = page.frame("vitest-iframe"); if (frame) { return resolve(frame); } const timeout = setTimeout(() => { const err = new Error(`Cannot find "vitest-iframe" on the page. This is a bug in Vitest, please report it.`); reject(err); }, 1e3).unref(); page.on("frameattached", (frame) => { clearTimeout(timeout); resolve(frame); }); }); }, get iframe() { return page.frameLocator("[data-vitest=\"true\"]"); } }; } async openBrowserPage(sessionId) { await this._throwIfClosing(); if (this.pages.has(sessionId)) { debug?.("[%s][%s] the page already exists, closing the old one", sessionId, this.browserName); const page = this.pages.get(sessionId); await page.close(); this.pages.delete(sessionId); } const context = await this.createContext(sessionId); const page = await context.newPage(); debug?.("[%s][%s] the page is ready", sessionId, this.browserName); await this._throwIfClosing(page); this.pages.set(sessionId, page); if (process.env.VITEST_PW_DEBUG) { page.on("requestfailed", (request) => { console.error("[PW Error]", request.resourceType(), "request failed for", request.url(), "url:", request.failure()?.errorText); }); } return page; } async openPage(sessionId, url) { debug?.("[%s][%s] creating the browser page for %s", sessionId, this.browserName, url); const browserPage = await this.openBrowserPage(sessionId); debug?.("[%s][%s] browser page is created, opening %s", sessionId, this.browserName, url); await browserPage.goto(url, { timeout: 0 }); await this._throwIfClosing(browserPage); } async _throwIfClosing(disposable) { if (this.closing) { debug?.("[%s] provider was closed, cannot perform the action on %s", this.browserName, String(disposable)); await disposable?.close(); this.pages.clear(); this.contexts.clear(); this.browser = null; this.browserPromise = null; throw new Error(`[vitest] The provider was closed.`); } } async getCDPSession(sessionid) { const page = this.getPage(sessionid); const cdp = await page.context().newCDPSession(page); return { async send(method, params) { const result = await cdp.send(method, params); return result; }, on(event, listener) { cdp.on(event, listener); }, off(event, listener) { cdp.off(event, listener); }, once(event, listener) { cdp.once(event, listener); } }; } async close() { debug?.("[%s] closing provider", this.browserName); this.closing = true; if (this.browserPromise) { await this.browserPromise; this.browserPromise = null; } const browser = this.browser; this.browser = null; await Promise.all([...this.pages.values()].map((p) => p.close())); this.pages.clear(); await Promise.all([...this.contexts.values()].map((c) => c.close())); this.contexts.clear(); await browser?.close(); debug?.("[%s] provider is closed", this.browserName); } } function getHeaders(config) { const headers = { "Content-Type": "application/javascript" }; for (const name in config.server.headers) { headers[name] = String(config.server.headers[name]); } return headers; } function getCodeWithSourcemap(type, code, map) { if (type === "js") { code += `\n//# sourceMappingURL=${genSourceMapUrl(map)}`; } else if (type === "css") { code += `\n/*# sourceMappingURL=${genSourceMapUrl(map)} */`; } return code; } function genSourceMapUrl(map) { if (typeof map !== "string") { map = JSON.stringify(map); } return `data:application/json;base64,${Buffer.from(map).toString("base64")}`; } const directRequestRE = /[?&]direct\b/; function isDirectCSSRequest(request) { return isCSSRequest(request) && directRequestRE.test(request); } export { PlaywrightBrowserProvider, playwright };