/** * 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 type { SpawnOptions } from 'child_process'; import { spawn } from 'child_process'; import * as crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; import removeFolder from 'rimraf'; import type stream from 'stream'; import * as URL from 'url'; import type { NameValue } from '../protocol/channels'; import { getUbuntuVersionSync } from './ubuntuVersion'; export { eventsHelper } from './eventsHelper'; export type { RegisteredListener } from './eventsHelper'; export { ManualPromise } from './manualPromise'; export { MultiMap } from './multimap'; export { raceAgainstTimeout, TimeoutRunner, TimeoutRunnerError } from './timeoutRunner'; export { zones } from './zones'; export const existsAsync = (path: string): Promise => new Promise(resolve => fs.stat(path, err => resolve(!err))); export function spawnAsync(cmd: string, args: string[], options: SpawnOptions = {}): Promise<{stdout: string, stderr: string, code: number | null, error?: Error}> { const process = spawn(cmd, args, Object.assign({ windowsHide: true }, options)); return new Promise(resolve => { let stdout = ''; let stderr = ''; if (process.stdout) process.stdout.on('data', data => stdout += data); if (process.stderr) process.stderr.on('data', data => stderr += data); process.on('close', code => resolve({ stdout, stderr, code })); process.on('error', error => resolve({ stdout, stderr, code: 0, error })); }); } // See https://joel.tools/microtasks/ export function makeWaitForNextTask() { // As of Mar 2021, Electron v12 doesn't create new task with `setImmediate` despite // using Node 14 internally, so we fallback to `setTimeout(0)` instead. // @see https://github.com/electron/electron/issues/28261 if ((process.versions as any).electron) return (callback: () => void) => setTimeout(callback, 0); if (parseInt(process.versions.node, 10) >= 11) return setImmediate; // Unlike Node 11, Node 10 and less have a bug with Task and MicroTask execution order: // - https://github.com/nodejs/node/issues/22257 // // So we can't simply run setImmediate to dispatch code in a following task. // However, we can run setImmediate from-inside setImmediate to make sure we're getting // in the following task. let spinning = false; const callbacks: (() => void)[] = []; const loop = () => { const callback = callbacks.shift(); if (!callback) { spinning = false; return; } setImmediate(loop); // Make sure to call callback() as the last thing since it's // untrusted code that might throw. callback(); }; return (callback: () => void) => { callbacks.push(callback); if (!spinning) { spinning = true; setImmediate(loop); } }; } export function assert(value: any, message?: string): asserts value { if (!value) throw new Error(message || 'Assertion error'); } export function debugAssert(value: any, message?: string): asserts value { if (isUnderTest() && !value) throw new Error(message); } export function isString(obj: any): obj is string { return typeof obj === 'string' || obj instanceof String; } export function isRegExp(obj: any): obj is RegExp { return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]'; } export function isObject(obj: any): obj is NonNullable { return typeof obj === 'object' && obj !== null; } export function isError(obj: any): obj is Error { return obj instanceof Error || (obj && obj.__proto__ && obj.__proto__.name === 'Error'); } const debugEnv = getFromENV('PWDEBUG') || ''; export function debugMode() { if (debugEnv === 'console') return 'console'; if (debugEnv === '0' || debugEnv === 'false') return ''; return debugEnv ? 'inspector' : ''; } let _isUnderTest = false; export function setUnderTest() { _isUnderTest = true; } export function isUnderTest(): boolean { return _isUnderTest; } export function experimentalFeaturesEnabled() { return isUnderTest() || !!process.env.PLAYWRIGHT_EXPERIMENTAL_FEATURES; } export function getFromENV(name: string): string | undefined { let value = process.env[name]; value = value === undefined ? process.env[`npm_config_${name.toLowerCase()}`] : value; value = value === undefined ? process.env[`npm_package_config_${name.toLowerCase()}`] : value; return value; } export function getAsBooleanFromENV(name: string): boolean { const value = getFromENV(name); return !!value && value !== 'false' && value !== '0'; } export async function mkdirIfNeeded(filePath: string) { // This will harmlessly throw on windows if the dirname is the root directory. await fs.promises.mkdir(path.dirname(filePath), { recursive: true }).catch(() => {}); } type HeadersArray = { name: string, value: string }[]; type HeadersObject = { [key: string]: string }; export function headersObjectToArray(headers: HeadersObject, separator?: string, setCookieSeparator?: string): HeadersArray { if (!setCookieSeparator) setCookieSeparator = separator; const result: HeadersArray = []; for (const name in headers) { const values = headers[name]; if (separator) { const sep = name.toLowerCase() === 'set-cookie' ? setCookieSeparator : separator; for (const value of values.split(sep!)) result.push({ name, value: value.trim() }); } else { result.push({ name, value: values }); } } return result; } export function headersArrayToObject(headers: HeadersArray, lowerCase: boolean): HeadersObject { const result: HeadersObject = {}; for (const { name, value } of headers) result[lowerCase ? name.toLowerCase() : name] = value; return result; } export function monotonicTime(): number { const [seconds, nanoseconds] = process.hrtime(); return seconds * 1000 + (nanoseconds / 1000 | 0) / 1000; } export function objectToArray(map?: { [key: string]: any }): NameValue[] | undefined { if (!map) return undefined; const result = []; for (const [name, value] of Object.entries(map)) result.push({ name, value: String(value) }); return result; } export function arrayToObject(array?: NameValue[]): { [key: string]: string } | undefined { if (!array) return undefined; const result: { [key: string]: string } = {}; for (const { name, value } of array) result[name] = value; return result; } export function calculateSha1(buffer: Buffer | string): string { const hash = crypto.createHash('sha1'); hash.update(buffer); return hash.digest('hex'); } export function createGuid(): string { return crypto.randomBytes(16).toString('hex'); } export async function removeFolders(dirs: string[]): Promise> { return await Promise.all(dirs.map((dir: string) => { return new Promise(fulfill => { removeFolder(dir, { maxBusyTries: 10 }, error => { fulfill(error ?? undefined); }); }); })); } export function canAccessFile(file: string) { if (!file) return false; try { fs.accessSync(file); return true; } catch (e) { return false; } } export function constructURLBasedOnBaseURL(baseURL: string | undefined, givenURL: string): string { try { return (new URL.URL(givenURL, baseURL)).toString(); } catch (e) { return givenURL; } } export type HostPlatform = 'win64' | 'mac10.13' | 'mac10.14' | 'mac10.15' | 'mac11' | 'mac11-arm64' | 'mac12' | 'mac12-arm64' | 'ubuntu18.04' | 'ubuntu18.04-arm64' | 'ubuntu20.04' | 'ubuntu20.04-arm64' | 'generic-linux' | 'generic-linux-arm64' | ''; export const hostPlatform = ((): HostPlatform => { const platform = os.platform(); if (platform === 'darwin') { const ver = os.release().split('.').map((a: string) => parseInt(a, 10)); let macVersion = ''; if (ver[0] < 18) { // Everything before 10.14 is considered 10.13. macVersion = 'mac10.13'; } else if (ver[0] === 18) { macVersion = 'mac10.14'; } else if (ver[0] === 19) { macVersion = 'mac10.15'; } else { // ver[0] >= 20 const LAST_STABLE_MAC_MAJOR_VERSION = 12; // Best-effort support for MacOS beta versions. macVersion = 'mac' + Math.min(ver[0] - 9, LAST_STABLE_MAC_MAJOR_VERSION); // BigSur is the first version that might run on Apple Silicon. if (os.cpus().some(cpu => cpu.model.includes('Apple'))) macVersion += '-arm64'; } return macVersion as HostPlatform; } if (platform === 'linux') { const archSuffix = os.arch() === 'arm64' ? '-arm64' : ''; const ubuntuVersion = getUbuntuVersionSync(); if (!ubuntuVersion) return ('generic-linux' + archSuffix) as HostPlatform; if (parseInt(ubuntuVersion, 10) <= 19) return ('ubuntu18.04' + archSuffix) as HostPlatform; return ('ubuntu20.04' + archSuffix) as HostPlatform; } if (platform === 'win32') return 'win64'; return ''; })(); export function wrapInASCIIBox(text: string, padding = 0): string { const lines = text.split('\n'); const maxLength = Math.max(...lines.map(line => line.length)); return [ '╔' + '═'.repeat(maxLength + padding * 2) + '╗', ...lines.map(line => '║' + ' '.repeat(padding) + line + ' '.repeat(maxLength - line.length + padding) + '║'), '╚' + '═'.repeat(maxLength + padding * 2) + '╝', ].join('\n'); } export function isFilePayload(value: any): boolean { return typeof value === 'object' && value['name'] && value['mimeType'] && value['buffer']; } export function streamToString(stream: stream.Readable): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on('data', chunk => chunks.push(Buffer.from(chunk))); stream.on('error', reject); stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); }); } export async function transformCommandsForRoot(commands: string[]): Promise<{ command: string, args: string[], elevatedPermissions: boolean}> { const isRoot = process.getuid() === 0; if (isRoot) return { command: 'sh', args: ['-c', `${commands.join('&& ')}`], elevatedPermissions: false }; const sudoExists = await spawnAsync('which', ['sudo']); if (sudoExists.code === 0) return { command: 'sudo', args: ['--', 'sh', '-c', `${commands.join('&& ')}`], elevatedPermissions: true }; return { command: 'su', args: ['root', '-c', `${commands.join('&& ')}`], elevatedPermissions: true }; } export class SigIntWatcher { private _hadSignal: boolean = false; private _sigintPromise: Promise; private _sigintHandler: () => void; constructor() { let sigintCallback: () => void; this._sigintPromise = new Promise(f => sigintCallback = f); this._sigintHandler = () => { // We remove the handler so that second Ctrl+C immediately kills the runner // via the default sigint handler. This is handy in the case where our shutdown // takes a lot of time or is buggy. // // When running through NPM we might get multiple SIGINT signals // for a single Ctrl+C - this is an NPM bug present since at least NPM v6. // https://github.com/npm/cli/issues/1591 // https://github.com/npm/cli/issues/2124 // // Therefore, removing the handler too soon will just kill the process // with default handler without printing the results. // We work around this by giving NPM 1000ms to send us duplicate signals. // The side effect is that slow shutdown or bug in our runner will force // the user to hit Ctrl+C again after at least a second. setTimeout(() => process.off('SIGINT', this._sigintHandler), 1000); this._hadSignal = true; sigintCallback(); }; process.on('SIGINT', this._sigintHandler); } promise(): Promise { return this._sigintPromise; } hadSignal(): boolean { return this._hadSignal; } disarm() { process.off('SIGINT', this._sigintHandler); } }