/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'fs'; import { mime } from 'playwright-core/lib/utilsBundle'; import util from 'util'; import path from 'path'; import url from 'url'; import { colors, debug, minimatch } from 'playwright-core/lib/utilsBundle'; import type { TestError, Location } from './types'; import { calculateSha1, isRegExp } from 'playwright-core/lib/utils'; import { isInternalFileName } from 'playwright-core/lib/utils/stackTrace'; import { currentTestInfo } from './globals'; import type { ParsedStackTrace } from 'playwright-core/lib/utils/stackTrace'; import { captureStackTrace as coreCaptureStackTrace } from 'playwright-core/lib/utils/stackTrace'; export type { ParsedStackTrace }; const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core')); const EXPECT_PATH = require.resolve('./expectBundle'); const EXPECT_PATH_IMPL = require.resolve('./expectBundleImpl'); const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..'); function filterStackTrace(e: Error) { // This method filters internal stack frames using Error.prepareStackTrace // hook. Read more about the hook: https://v8.dev/docs/stack-trace-api // // NOTE: Error.prepareStackTrace will only be called if `e.stack` has not // been accessed before. This is the case for Jest Expect and simple throw // statements. // // If `e.stack` has been accessed, this method will be NOOP. const oldPrepare = Error.prepareStackTrace; const stackFormatter = oldPrepare || ((error, structuredStackTrace) => [ `${error.name}: ${error.message}`, ...structuredStackTrace.map(callSite => ' at ' + callSite.toString()), ].join('\n')); Error.prepareStackTrace = (error, structuredStackTrace) => { return stackFormatter(error, structuredStackTrace.filter(callSite => { const fileName = callSite.getFileName(); const functionName = callSite.getFunctionName() || undefined; if (!fileName) return true; return !fileName.startsWith(PLAYWRIGHT_TEST_PATH) && !fileName.startsWith(PLAYWRIGHT_CORE_PATH) && !isInternalFileName(fileName, functionName); })); }; // eslint-disable-next-line e.stack; // trigger Error.prepareStackTrace Error.prepareStackTrace = oldPrepare; } export function captureStackTrace(customApiName?: string): ParsedStackTrace { const stackTrace: ParsedStackTrace = coreCaptureStackTrace(); const frames = []; const frameTexts = []; for (let i = 0; i < stackTrace.frames.length; ++i) { const frame = stackTrace.frames[i]; if (frame.file === EXPECT_PATH || frame.file === EXPECT_PATH_IMPL) continue; frames.push(frame); frameTexts.push(stackTrace.frameTexts[i]); } return { allFrames: stackTrace.allFrames, frames, frameTexts, apiName: customApiName ?? stackTrace.apiName, }; } export function serializeError(error: Error | any): TestError { if (error instanceof Error) { filterStackTrace(error); return { message: error.message, stack: error.stack }; } return { value: util.inspect(error) }; } export type Matcher = (value: string) => boolean; export type FilePatternFilter = { re: RegExp; line: number | null; column: number | null; }; export function createFileMatcher(patterns: string | RegExp | (string | RegExp)[]): Matcher { const reList: RegExp[] = []; const filePatterns: string[] = []; for (const pattern of Array.isArray(patterns) ? patterns : [patterns]) { if (isRegExp(pattern)) { reList.push(pattern); } else { if (!pattern.startsWith('**/') && !pattern.startsWith('**/')) filePatterns.push('**/' + pattern); else filePatterns.push(pattern); } } return (filePath: string) => { for (const re of reList) { re.lastIndex = 0; if (re.test(filePath)) return true; } // Windows might still receive unix style paths from Cygwin or Git Bash. // Check against the file url as well. if (path.sep === '\\') { const fileURL = url.pathToFileURL(filePath).href; for (const re of reList) { re.lastIndex = 0; if (re.test(fileURL)) return true; } } for (const pattern of filePatterns) { if (minimatch(filePath, pattern, { nocase: true, dot: true })) return true; } return false; }; } export function createTitleMatcher(patterns: RegExp | RegExp[]): Matcher { const reList = Array.isArray(patterns) ? patterns : [patterns]; return (value: string) => { for (const re of reList) { re.lastIndex = 0; if (re.test(value)) return true; } return false; }; } export function filterUndefinedFixtures(o: T | undefined): T { // We don't want "undefined" values to actually mean "undefined", // but rather "no opinion about this option", like in all other // places in the config. return Object.fromEntries(Object.entries(o || {}).filter(entry => !Object.is(entry[1], undefined))) as any as T; } export function forceRegExp(pattern: string): RegExp { const match = pattern.match(/^\/(.*)\/([gi]*)$/); if (match) return new RegExp(match[1], match[2]); return new RegExp(pattern, 'g'); } export function relativeFilePath(file: string): string { if (!path.isAbsolute(file)) return file; return path.relative(process.cwd(), file); } export function formatLocation(location: Location) { return relativeFilePath(location.file) + ':' + location.line + ':' + location.column; } export function errorWithFile(file: string, message: string) { return new Error(`${relativeFilePath(file)}: ${message}`); } export function errorWithLocation(location: Location, message: string) { return new Error(`${formatLocation(location)}: ${message}`); } export function expectTypes(receiver: any, types: string[], matcherName: string) { if (typeof receiver !== 'object' || !types.includes(receiver.constructor.name)) { const commaSeparated = types.slice(); const lastType = commaSeparated.pop(); const typesString = commaSeparated.length ? commaSeparated.join(', ') + ' or ' + lastType : lastType; throw new Error(`${matcherName} can be only used with ${typesString} object${types.length > 1 ? 's' : ''}`); } } export function sanitizeForFilePath(s: string) { return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-'); } export function trimLongString(s: string, length = 100) { if (s.length <= length) return s; const hash = calculateSha1(s); const middle = `-${hash.substring(0, 5)}-`; const start = Math.floor((length - middle.length) / 2); const end = length - middle.length - start; return s.substring(0, start) + middle + s.slice(-end); } export function addSuffixToFilePath(filePath: string, suffix: string, customExtension?: string, sanitize = false): string { const dirname = path.dirname(filePath); const ext = path.extname(filePath); const name = path.basename(filePath, ext); const base = path.join(dirname, name); return (sanitize ? sanitizeForFilePath(base) : base) + suffix + (customExtension || ext); } /** * Returns absolute path contained within parent directory. */ export function getContainedPath(parentPath: string, subPath: string = ''): string | null { const resolvedPath = path.resolve(parentPath, subPath); if (resolvedPath === parentPath || resolvedPath.startsWith(parentPath + path.sep)) return resolvedPath; return null; } export const debugTest = debug('pw:test'); export function callLogText(log: string[] | undefined): string { if (!log) return ''; return ` Call log: ${colors.dim('- ' + (log || []).join('\n - '))} `; } export function currentExpectTimeout(options: { timeout?: number }) { const testInfo = currentTestInfo(); if (options.timeout !== undefined) return options.timeout; let defaultExpectTimeout = testInfo?.project._expect?.timeout; if (typeof defaultExpectTimeout === 'undefined') defaultExpectTimeout = 5000; return defaultExpectTimeout; } const folderToPackageJsonPath = new Map(); export function getPackageJsonPath(folderPath: string): string { const cached = folderToPackageJsonPath.get(folderPath); if (cached !== undefined) return cached; const packageJsonPath = path.join(folderPath, 'package.json'); if (fs.existsSync(packageJsonPath)) { folderToPackageJsonPath.set(folderPath, packageJsonPath); return packageJsonPath; } const parentFolder = path.dirname(folderPath); if (folderPath === parentFolder) { folderToPackageJsonPath.set(folderPath, ''); return ''; } const result = getPackageJsonPath(parentFolder); folderToPackageJsonPath.set(folderPath, result); return result; } export async function normalizeAndSaveAttachment(outputPath: string, name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}): Promise<{ name: string; path?: string | undefined; body?: Buffer | undefined; contentType: string; }> { if ((options.path !== undefined ? 1 : 0) + (options.body !== undefined ? 1 : 0) !== 1) throw new Error(`Exactly one of "path" and "body" must be specified`); if (options.path !== undefined) { const hash = calculateSha1(options.path); const dest = path.join(outputPath, 'attachments', hash + path.extname(options.path)); await fs.promises.mkdir(path.dirname(dest), { recursive: true }); await fs.promises.copyFile(options.path, dest); const contentType = options.contentType ?? (mime.getType(path.basename(options.path)) || 'application/octet-stream'); return { name, contentType, path: dest }; } else { const contentType = options.contentType ?? (typeof options.body === 'string' ? 'text/plain' : 'application/octet-stream'); return { name, contentType, body: typeof options.body === 'string' ? Buffer.from(options.body) : options.body }; } }