참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View File

@@ -0,0 +1,50 @@
/**
* 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 { mergeTests } from '@playwright/test';
import { test } from '@playwright/test';
import type { CommonFixtures, CommonWorkerFixtures } from './commonFixtures';
import { commonFixtures } from './commonFixtures';
import type { ServerFixtures, ServerWorkerOptions } from './serverFixtures';
import { serverFixtures } from './serverFixtures';
import { platformTest } from './platformFixtures';
import { testModeTest } from './testModeFixtures';
import type { Builtins } from '../../packages/injected/src/utilityScript';
export const base = test;
export const baseTest = mergeTests(base, platformTest, testModeTest)
.extend<CommonFixtures, CommonWorkerFixtures>(commonFixtures)
.extend<ServerFixtures, ServerWorkerOptions>(serverFixtures);
export function step<This extends Object, Args extends any[], Return>(
target: (this: This, ...args: Args) => Promise<Return>,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Promise<Return>>
) {
function replacementMethod(this: This, ...args: Args): Promise<Return> {
const name = this.constructor.name + '.' + (context.name as string) + '(' + args.map(a => JSON.stringify(a)).join(',') + ')';
return test.step(name, async () => {
return await target.call(this, ...args);
});
}
return replacementMethod;
}
declare global {
interface Window {
builtins: Builtins;
}
}

View File

@@ -0,0 +1,220 @@
/**
* 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 * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { baseTest } from './baseTest';
import { RunServer, RemoteServer } from './remoteServer';
import { utils } from '../../packages/playwright-core/lib/coreBundle';
import { isBidiChannel, parseHar } from '../config/utils';
import { createSkipTestPredicate } from '../bidi/expectationUtil';
import type { PageTestFixtures, PageWorkerFixtures } from '../page/pageTestApi';
import type { RemoteServerOptions, PlaywrightServer } from './remoteServer';
import type { BrowserContext, BrowserContextOptions, BrowserType, Page } from 'playwright-core';
import type { Log } from '../../packages/trace/src/har';
import type { TestInfo } from '@playwright/test';
const { removeFolders, hostPlatform } = utils;
export type BrowserTestWorkerFixtures = PageWorkerFixtures & {
browserVersion: string;
defaultSameSiteCookieValue: string;
allowsThirdParty: boolean;
browserMajorVersion: number;
browserType: BrowserType;
isAndroid: boolean;
isElectron: boolean;
isHeadlessShell: boolean;
isFrozenWebkit: boolean;
nodeVersion: { major: number, minor: number, patch: number };
isBidi: boolean;
bidiTestSkipPredicate: (info: TestInfo) => boolean;
};
interface StartRemoteServer {
(kind: 'run-server' | 'launchServer', options?: RemoteServerOptions): Promise<PlaywrightServer>;
(kind: 'launchServer', options?: RemoteServerOptions): Promise<RemoteServer>;
}
type BrowserTestTestFixtures = PageTestFixtures & {
createUserDataDir: () => Promise<string>;
launchPersistent: (options?: Parameters<BrowserType['launchPersistentContext']>[1]) => Promise<{ context: BrowserContext, page: Page }>;
startRemoteServer: StartRemoteServer;
contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>;
pageWithHar(options?: { outputPath?: string, content?: 'embed' | 'attach' | 'omit', omitContent?: boolean }): Promise<{ context: BrowserContext, page: Page, getLog: () => Promise<Log>, getZip: () => Promise<Map<string, Buffer>> }>
autoSkipBidiTest: void;
};
const test = baseTest.extend<BrowserTestTestFixtures, BrowserTestWorkerFixtures>({
browserVersion: [async ({ browser }, run) => {
await run(browser.version());
}, { scope: 'worker' }],
browserType: [async ({ playwright, browserName, mode }, run) => {
await run(playwright[browserName]);
}, { scope: 'worker' }],
allowsThirdParty: [async ({ browserName, channel }, run) => {
if (browserName === 'firefox')
await run(true);
else
await run(false);
}, { scope: 'worker' }],
defaultSameSiteCookieValue: [async ({ browserName, platform, channel, isBidi }, run) => {
if (browserName === 'chromium' || isBidi)
await run('Lax');
else if (browserName === 'webkit' && (platform === 'linux' || channel === 'webkit-wsl'))
await run('Lax');
else if (browserName === 'webkit')
await run('None'); // Windows + older macOS
else if (browserName === 'firefox')
await run('None');
else
throw new Error('unknown browser - ' + browserName);
}, { scope: 'worker' }],
browserMajorVersion: [async ({ browserVersion }, run) => {
await run(Number(browserVersion.split('.')[0]));
}, { scope: 'worker' }],
nodeVersion: [async ({}, use) => {
const [major, minor, patch] = process.versions.node.split('.');
await use({ major: +major, minor: +minor, patch: +patch });
}, { scope: 'worker' }],
isBidi: [async ({ channel }, use) => {
await use(isBidiChannel(channel));
}, { scope: 'worker' }],
isAndroid: [false, { scope: 'worker' }],
isElectron: [false, { scope: 'worker' }],
electronMajorVersion: [0, { scope: 'worker' }],
isHeadlessShell: [async ({ browserName, channel, headless }, use) => {
const isShell = channel === 'chromium-headless-shell' || (!channel && headless);
const isToTShell = channel === 'chromium-tip-of-tree-headless-shell' || (channel === 'chromium-tip-of-tree' && headless);
await use(browserName === 'chromium' && (isShell || isToTShell));
}, { scope: 'worker' }],
isFrozenWebkit: [async ({ browserName, isMac, macVersion }, use) => {
await use(browserName === 'webkit' && (hostPlatform.startsWith('debian11') || hostPlatform.startsWith('ubuntu20.04') || (isMac && macVersion < 15)));
}, { scope: 'worker' }],
contextFactory: async ({ _contextFactory }: any, run) => {
await run(async options => {
const { context } = await _contextFactory(options);
return context;
});
},
createUserDataDir: async ({ mode }, run) => {
test.skip(mode.startsWith('service'));
const dirs: string[] = [];
// We do not put user data dir in testOutputPath,
// because we do not want to upload them as test result artifacts.
//
// Additionally, it is impossible to upload user data dir after test run:
// - Firefox removes lock file later, presumably from another watchdog process?
// - WebKit has circular symlinks that makes CI go crazy.
await run(async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-'));
dirs.push(dir);
return dir;
});
await removeFolders(dirs);
},
launchPersistent: async ({ createUserDataDir, browserType, mode }, run) => {
test.skip(mode !== 'default', 'Remote persistent contexts are not supported');
let persistentContext: BrowserContext | undefined;
await run(async options => {
if (persistentContext)
throw new Error('can only launch one persistent context');
const userDataDir = await createUserDataDir();
persistentContext = await browserType.launchPersistentContext(userDataDir, { ...options });
const page = persistentContext.pages()[0];
return { context: persistentContext, page };
});
if (persistentContext)
await persistentContext.close();
},
startRemoteServer: async ({ childProcess, browserType, channel, mode }, run) => {
test.skip(mode !== 'default', 'Starting remote server is not supported in remote modes');
let server: PlaywrightServer | undefined;
const fn = async (kind: 'launchServer' | 'run-server', options?: RemoteServerOptions) => {
if (server)
throw new Error('can only start one remote server');
if (kind === 'launchServer') {
const remoteServer = new RemoteServer();
await remoteServer._start(childProcess, browserType, channel, options);
server = remoteServer;
} else {
const runServer = new RunServer();
await runServer.start(childProcess, { artifactsDir: options?.artifactsDir });
server = runServer;
}
return server;
};
await run(fn as any);
if (server) {
await server.close();
// Give any connected browsers a chance to disconnect to avoid
// poisoning next test with quasy-alive browsers.
await new Promise(f => setTimeout(f, 1000));
}
},
pageWithHar: async ({ contextFactory }, use, testInfo) => {
const pageWithHar = async (options: { outputPath?: string, content?: 'embed' | 'attach' | 'omit', omitContent?: boolean } = {}) => {
const harPath = testInfo.outputPath(options.outputPath || 'test.har');
const context = await contextFactory({ recordHar: { path: harPath, content: options.content, omitContent: options.omitContent }, ignoreHTTPSErrors: true });
const page = await context.newPage();
return {
page,
context,
getLog: async () => {
await context.close();
return JSON.parse(fs.readFileSync(harPath).toString())['log'] as Log;
},
getZip: async () => {
await context.close();
return parseHar(harPath);
},
};
};
await use(pageWithHar);
},
bidiTestSkipPredicate: [async ({ }, run) => {
const filter = await createSkipTestPredicate(test.info().project.name);
await run(filter);
}, { scope: 'worker' }],
autoSkipBidiTest: [async ({ bidiTestSkipPredicate }, run) => {
test.fixme(bidiTestSkipPredicate(test.info()), 'marked as timeout in bidi expectations');
await run();
}, { auto: true, scope: 'test' }],
});
export const playwrightTest = test;
export const browserTest = test;
export const contextTest = test;
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,316 @@
/**
* 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 { Fixtures } from '@playwright/test';
import type { ChildProcess } from 'child_process';
import { execSync, spawn } from 'child_process';
import net from 'net';
import fs from 'fs';
import { inheritAndCleanEnv, stripAnsi } from './utils';
type TestChildParams = {
command: string[],
cwd?: string,
env?: NodeJS.ProcessEnv,
shell?: boolean,
onOutput?: () => void;
};
import childProcess from 'child_process';
type ProcessData = {
pid: number, // process ID
pgrp: number, // process group ID
children: Set<ProcessData>, // direct children of the process
};
function readAllProcessesLinux(): { pid: number, ppid: number, pgrp: number }[] {
const result: {pid: number, ppid: number, pgrp: number}[] = [];
for (const dir of fs.readdirSync('/proc')) {
const pid = +dir;
if (isNaN(pid))
continue;
try {
const statFile = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
// Format of /proc/*/stat is described https://man7.org/linux/man-pages/man5/proc.5.html
const match = statFile.match(/^(?<pid>\d+)\s+\((?<comm>.*)\)\s+(?<state>R|S|D|Z|T|t|W|X|x|K|W|P)\s+(?<ppid>\d+)\s+(?<pgrp>\d+)/);
if (match && match.groups) {
result.push({
pid: +match.groups.pid,
ppid: +match.groups.ppid,
pgrp: +match.groups.pgrp,
});
}
} catch (e) {
// We don't have access to some /proc/<pid>/stat file.
}
}
return result;
}
function readAllProcessesMacOS(): { pid: number, ppid: number, pgrp: number }[] {
const result: {pid: number, ppid: number, pgrp: number}[] = [];
const processTree = childProcess.spawnSync('ps', ['-eo', 'pid,pgid,ppid']);
const lines = processTree.stdout.toString().trim().split('\n');
for (const line of lines) {
const [pid, pgrp, ppid] = line.trim().split(/\s+/).map(token => +token);
// On linux, the very first line of `ps` is the header with "PID PGID PPID".
if (isNaN(pid) || isNaN(pgrp) || isNaN(ppid))
continue;
result.push({ pid, ppid, pgrp });
}
return result;
}
function buildProcessTreePosix(pid: number): ProcessData | undefined {
// Certain Linux distributions might not have `ps` installed.
const allProcesses = process.platform === 'darwin' ? readAllProcessesMacOS() : readAllProcessesLinux();
const pidToProcess = new Map<number, ProcessData>();
for (const { pid, pgrp } of allProcesses)
pidToProcess.set(pid, { pid, pgrp, children: new Set() });
for (const { pid, ppid } of allProcesses) {
const parent = pidToProcess.get(ppid);
const child = pidToProcess.get(pid);
// On POSIX, certain processes might not have parent (e.g. PID=1 and occasionally PID=2)
// or we might not have access to it proc info.
if (parent && child)
parent.children.add(child);
}
return pidToProcess.get(pid);
}
export class TestChildProcess {
params: TestChildParams;
process: ChildProcess;
output = '';
stdout = '';
stderr = '';
fullOutput = '';
onOutput?: (chunk: string | Buffer) => void;
exited: Promise<{ exitCode: number | null, signal: string | null }>;
exitCode: Promise<number | null>;
private _outputCallbacks = new Set<() => void>();
constructor(params: TestChildParams) {
this.params = params;
// See https://nodejs.org/api/deprecations.html#DEP0190
const command = params.shell ? params.command.join(' ') : params.command[0];
const args = params.shell ? [] : params.command.slice(1);
this.process = spawn(command, args, {
env: inheritAndCleanEnv(params.env),
cwd: params.cwd,
shell: params.shell,
// On non-windows platforms, `detached: true` makes child process a leader of a new
// process group, making it possible to kill child process tree with `.kill(-pid)` command.
// @see https://nodejs.org/api/child_process.html#child_process_options_detached
detached: process.platform !== 'win32',
});
if (process.env.PWTEST_DEBUG)
process.stdout.write(`\n\nLaunching ${params.command.join(' ')}\n`);
this.onOutput = params.onOutput;
const appendChunk = (type: 'stdout' | 'stderr', chunk: string | Buffer) => {
this.output += String(chunk);
if (type === 'stderr')
this.stderr += String(chunk);
else
this.stdout += String(chunk);
if (process.env.PWTEST_DEBUG)
process.stdout.write(String(chunk));
else
this.fullOutput += String(chunk);
this.onOutput?.(chunk);
for (const cb of this._outputCallbacks)
cb();
this._outputCallbacks.clear();
};
this.process.stderr!.on('data', appendChunk.bind(null, 'stderr'));
this.process.stdout!.on('data', appendChunk.bind(null, 'stdout'));
const killProcessGroup = this._killProcessTree.bind(this, 'SIGKILL');
process.on('exit', killProcessGroup);
this.exited = new Promise(f => {
this.process.on('exit', (exitCode, signal) => f({ exitCode, signal }));
process.off('exit', killProcessGroup);
});
this.exitCode = this.exited.then(r => r.exitCode);
}
outputLines(): string[] {
const strippedOutput = stripAnsi(this.output);
return strippedOutput.split('\n').filter(line => line.startsWith('%%')).map(line => line.substring(2).trim());
}
async kill(signal: 'SIGINT' | 'SIGKILL' = 'SIGKILL') {
this._killProcessTree(signal);
return this.exited;
}
private _killProcessTree(signal: 'SIGINT' | 'SIGKILL') {
if (!this.process.pid || !this.process.kill(0))
return;
killProcessGroup(this.process.pid, signal);
}
async cleanExit() {
const r = await this.exited;
if (r.exitCode)
throw new Error(`Process failed with exit code ${r.exitCode}. Output:\n${this.output}`);
if (r.signal)
throw new Error(`Process received signal: ${r.signal}. Output:\n${this.output}`);
}
async waitForOutput(substring: string, count = 1) {
while (countTimes(stripAnsi(this.output), substring) < count)
await new Promise<void>(f => this._outputCallbacks.add(f));
}
clearOutput() {
this.output = '';
}
write(chars: string) {
this.process.stdin!.write(chars);
}
}
export function killProcessGroup(pid: number, signal: 'SIGINT' | 'SIGKILL' = 'SIGKILL') {
// On Windows, we always call `taskkill` no matter signal.
if (process.platform === 'win32') {
try {
execSync(`taskkill /pid ${pid} /T /F /FI "MEMUSAGE gt 0"`, { stdio: 'ignore' });
} catch (e) {
// the process might have already stopped
}
return;
}
// In case of POSIX and `SIGINT` signal, send it to the main process group only.
if (signal === 'SIGINT') {
try {
process.kill(-pid, 'SIGINT');
} catch (e) {
// the process might have already stopped
}
return;
}
// In case of POSIX and `SIGKILL` signal, we should send it to all descendant process groups.
const rootProcess = buildProcessTreePosix(pid);
if (!rootProcess)
return;
const descendantProcessGroups = (function flatten(processData: ProcessData, result: Set<number> = new Set()) {
// Process can nullify its own process group with `setpgid`. Use its PID instead.
result.add(processData.pgrp || processData.pid);
processData.children.forEach(child => flatten(child, result));
return result;
})(rootProcess);
for (const pgrp of descendantProcessGroups) {
try {
process.kill(-pgrp, 'SIGKILL');
} catch (e) {
// the process might have already stopped
}
}
}
export type CommonFixtures = {
childProcess: (params: TestChildParams) => TestChildProcess;
waitForPort: (port: number) => Promise<void>;
findFreePort: () => Promise<number>;
};
export type CommonWorkerFixtures = {
daemonProcess: (params: TestChildParams) => TestChildProcess;
};
export const commonFixtures: Fixtures<CommonFixtures, CommonWorkerFixtures> = {
childProcess: async ({}, use, testInfo) => {
const processes: TestChildProcess[] = [];
await use(params => {
const process = new TestChildProcess(params);
processes.push(process);
return process;
});
await Promise.all(processes.map(async child => child.kill()));
if (testInfo.status !== 'passed' && testInfo.status !== 'skipped' && !process.env.PWTEST_DEBUG) {
for (const process of processes) {
console.log('====== ' + process.params.command.join(' '));
console.log(process.fullOutput.replace(/\x1Bc/g, ''));
console.log('=========================================');
}
}
},
daemonProcess: [async ({}, use) => {
const processes: TestChildProcess[] = [];
await use(params => {
const process = new TestChildProcess(params);
processes.push(process);
return process;
});
await Promise.all(processes.map(child => child.kill('SIGINT')));
}, { scope: 'worker' }],
waitForPort: async ({}, use) => {
const token = { canceled: false };
await use(async port => {
while (!token.canceled) {
const promise = new Promise<boolean>(resolve => {
const conn = net.connect(port, '127.0.0.1')
.on('error', () => resolve(false))
.on('connect', () => {
conn.end();
resolve(true);
});
});
if (await promise)
return;
await new Promise(x => setTimeout(x, 100));
}
});
token.canceled = true;
},
findFreePort: async ({}, use) => {
await use(async () => {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
});
},
};
export function countTimes(s: string, sub: string): number {
let result = 0;
for (let index = 0; index !== -1;) {
index = s.indexOf(sub, index);
if (index !== -1) {
result++;
index += sub.length;
}
}
return result;
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* 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 { utils } from '../../packages/playwright-core/lib/coreBundle';
const { getComparator } = utils;
const pngComparator = getComparator('image/png');
type ComparatorResult = { diff?: Buffer; errorMessage: string; } | null;
type ImageComparatorOptions = { threshold?: number, maxDiffPixels?: number, maxDiffPixelRatio?: number };
export function comparePNGs(actual: Buffer, expected: Buffer, options: ImageComparatorOptions = {}): ComparatorResult {
// Strict threshold by default in our tests.
return pngComparator(actual, expected, { comparator: 'ssim-cie94', threshold: 0, ...options });
}

View File

@@ -0,0 +1,163 @@
/**
* 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 WebSocket from 'ws';
import { EventEmitter } from 'events';
import type * as channels from '@protocol/channels';
export type ProtocolRequest = {
id: number;
method: string;
params: any;
};
export type ProtocolResponse = {
id?: number;
method?: string;
error?: { message: string; data: any; };
params?: any;
result?: any;
};
export interface ConnectionTransport {
send(s: ProtocolRequest): void;
close(): void; // Note: calling close is expected to issue onclose at some point.
isClosed(): boolean,
onmessage?: (message: ProtocolResponse) => void,
onclose?: () => void,
}
class WebSocketTransport implements ConnectionTransport {
private _ws: WebSocket;
onmessage?: (message: ProtocolResponse) => void;
onclose?: () => void;
readonly wsEndpoint: string;
static async connect(url: string, headers: Record<string, string> = {}): Promise<WebSocketTransport> {
const transport = new WebSocketTransport(url, headers);
await new Promise<WebSocketTransport>((fulfill, reject) => {
transport._ws.addEventListener('open', async () => {
fulfill(transport);
});
transport._ws.addEventListener('error', event => {
reject(new Error('WebSocket error: ' + event.message));
transport._ws.close();
});
});
return transport;
}
constructor(url: string, headers: Record<string, string> = {}) {
this.wsEndpoint = url;
this._ws = new WebSocket(url, [], {
perMessageDeflate: false,
maxPayload: 256 * 1024 * 1024, // 256Mb,
handshakeTimeout: 30000,
headers
});
this._ws.addEventListener('message', event => {
try {
if (this.onmessage)
this.onmessage.call(null, JSON.parse(event.data.toString()));
} catch (e) {
this._ws.close();
}
});
this._ws.addEventListener('close', event => {
if (this.onclose)
this.onclose.call(null);
});
// Prevent Error: read ECONNRESET.
this._ws.addEventListener('error', () => {});
}
isClosed() {
return this._ws.readyState === WebSocket.CLOSING || this._ws.readyState === WebSocket.CLOSED;
}
send(message: ProtocolRequest) {
this._ws.send(JSON.stringify(message));
}
close() {
this._ws.close();
}
async closeAndWait() {
const promise = new Promise(f => this._ws.once('close', f));
this.close();
await promise; // Make sure to await the actual disconnect.
}
}
export class Backend extends EventEmitter {
private static _lastId = 0;
private _callbacks = new Map<number, { fulfill: (a: any) => void, reject: (e: Error) => void }>();
private _transport!: WebSocketTransport;
channel: channels.DebugControllerChannel;
constructor() {
super();
}
async connect(wsEndpoint: string) {
this._transport = await WebSocketTransport.connect(wsEndpoint + '?debug-controller');
this._transport.onmessage = (message: any) => {
if (!message.id) {
this.emit(message.method, message.params);
return;
}
const pair = this._callbacks.get(message.id);
if (!pair)
return;
this._callbacks.delete(message.id);
if (message.error) {
const error = new Error(message.error.error?.message || message.error.value);
error.stack = message.error.error?.stack;
pair.reject(error);
} else {
pair.fulfill(message.result);
}
};
this.channel = new Proxy(this, {
get: (target, propKey) => {
if (['on', 'once'].includes(String(propKey)))
return target[propKey].bind(target);
return (...args: any) => this._send(String(propKey), ...args);
}
}) as any;
}
async initialize() {
await this.channel.initialize({ codegenId: 'playwright-test', sdkLanguage: 'javascript' });
}
async close() {
await this._transport.closeAndWait();
}
private _send(method: string, params: any = {}): Promise<any> {
return new Promise((fulfill, reject) => {
const id = ++Backend._lastId;
const command = { id, guid: 'DebugController', method, params, metadata: {} };
this._transport.send(command as any);
this._callbacks.set(id, { fulfill, reject });
});
}
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* 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.
*/
export const kTargetClosedErrorMessage = 'Target page, context or browser has been closed';

View File

@@ -0,0 +1,289 @@
/**
* 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 path from 'path';
import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
import type { IssueCommentEdge, Repository } from '@octokit/graphql-schema';
import type { FullConfig, FullResult, Reporter, Suite, TestCase } from '@playwright/test/reporter';
type MarkdownReporterOptions = {
configDir: string, // TODO: make it public?
outputFile?: string;
};
class MarkdownReporter implements Reporter {
private _options: MarkdownReporterOptions;
private _fatalErrors: TestError[] = [];
protected _config!: FullConfig;
private _suite!: Suite;
constructor(options: MarkdownReporterOptions) {
this._options = options;
}
printsToStdio() {
return false;
}
onBegin(config: FullConfig, suite: Suite) {
this._config = config;
this._suite = suite;
}
onError(error: TestError) {
this._fatalErrors.push(error);
}
async onEnd(result: FullResult) {
const summary = this._generateSummary();
const lines: string[] = [];
const incompleteWarning = this._incompleteRunWarning();
if (incompleteWarning) {
lines.push(incompleteWarning);
lines.push(``);
}
if (this._fatalErrors.length)
lines.push(`**${this._fatalErrors.length} fatal errors, not part of any test**`);
if (summary.unexpected.length) {
lines.push(`**${summary.unexpected.length} failed**`);
this._printTestList(':x:', summary.unexpected, lines);
}
if (summary.flaky.length) {
lines.push(`<details>`);
lines.push(`<summary><b>${summary.flaky.length} flaky</b></summary>`);
this._printTestList(':warning:', summary.flaky, lines, ' <br/>');
lines.push(`</details>`);
lines.push(``);
}
if (summary.interrupted.length) {
lines.push(`<details>`);
lines.push(`<summary><b>${summary.interrupted.length} interrupted</b></summary>`);
this._printTestList(':warning:', summary.interrupted, lines, ' <br/>');
lines.push(`</details>`);
lines.push(``);
}
const skipped = summary.skipped ? `, ${summary.skipped} skipped` : '';
const didNotRun = summary.didNotRun ? `, ${summary.didNotRun} did not run` : '';
lines.push(`**${summary.expected} passed${skipped}${didNotRun}**`);
lines.push(``);
await this.publishReport(lines.join('\n'));
}
protected async publishReport(report: string): Promise<void> {
const maybeRelativeFile = this._options.outputFile || 'report.md';
const reportFile = path.resolve(this._options.configDir, maybeRelativeFile);
await fs.promises.mkdir(path.dirname(reportFile), { recursive: true });
await fs.promises.writeFile(reportFile, report);
}
protected _incompleteRunWarning(): string | undefined {
const conclusion = process.env.WORKFLOW_RUN_CONCLUSION;
if (!conclusion || conclusion === 'success' || conclusion === 'failure')
return undefined;
return `> [!WARNING]\n> The triggering workflow run ended with status \`${conclusion}\`. Results below may be incomplete — blob reports from cancelled or timed-out shards are missing, so passing/failing counts do not reflect the full test suite.`;
}
protected _generateSummary() {
let didNotRun = 0;
let skipped = 0;
let expected = 0;
const interrupted: TestCase[] = [];
const interruptedToPrint: TestCase[] = [];
const unexpected: TestCase[] = [];
const flaky: TestCase[] = [];
this._suite.allTests().forEach(test => {
switch (test.outcome()) {
case 'skipped': {
if (test.results.some(result => result.status === 'interrupted')) {
if (test.results.some(result => !!result.error))
interruptedToPrint.push(test);
interrupted.push(test);
} else if (!test.results.length || test.expectedStatus !== 'skipped') {
++didNotRun;
} else {
++skipped;
}
break;
}
case 'expected': ++expected; break;
case 'unexpected': unexpected.push(test); break;
case 'flaky': flaky.push(test); break;
}
});
return {
didNotRun,
skipped,
expected,
interrupted,
unexpected,
flaky,
};
}
private _printTestList(prefix: string, tests: TestCase[], lines: string[], suffix?: string) {
for (const test of tests)
lines.push(`${prefix} ${formatTestTitle(this._config.rootDir, test)}${suffix || ''}`);
lines.push(``);
}
}
function formatTestTitle(rootDir: string, test: TestCase): string {
// root, project, file, ...describes, test
const [, projectName, , ...titles] = test.titlePath();
const relativeTestPath = path.relative(rootDir, test.location.file);
// intentionally leave out column to prevent writing test.spec.ts:100:5 - GitHub turns that into 💯
const location = `${relativeTestPath}:${test.location.line}`;
const projectTitle = projectName ? `[${projectName}] ` : '';
const testTitle = `${projectTitle}${location} ${titles.join(' ')}`;
const extraTags = test.tags.filter(t => !testTitle.includes(t));
const formattedTags = extraTags.map(t => `\`${t}\``).join(' ');
return `${testTitle}${extraTags.length ? ' ' + formattedTags : ''}`;
}
class GHAMarkdownReporter extends MarkdownReporter {
private octokit: ReturnType<typeof import('@actions/github').getOctokit>;
private context: typeof import('@actions/github').context;
private core: typeof import('@actions/core');
override async publishReport(report: string) {
this.core = await import('@actions/core');
const token = process.env.GITHUB_TOKEN || this.core.getInput('github-token');
if (!token) {
this.core.setFailed('Missing "github-token" input');
throw new Error('Missing "github-token" input');
}
const { context, getOctokit } = await import('@actions/github');
this.context = context;
this.octokit = getOctokit(token);
this.core.info('Publishing report to PR.');
const { prNumber, prHref } = this.pullRequestFromMetadata();
if (!prNumber) {
this.core.info(`No PR number found, skipping GHA comment. PR href: ${prHref}`);
return;
}
this.core.info(`Posting comment to PR ${prHref}`);
const prNodeId = await this.collapsePreviousComments(prNumber);
if (!prNodeId) {
this.core.warning(`No PR node ID found, skipping GHA comment. PR href: ${prHref}`);
return;
}
await this.addNewReportComment(prNodeId, report);
}
private async collapsePreviousComments(prNumber: number) {
const { owner, repo } = this.context.repo;
const data = await this.octokit.graphql<{ repository: Repository }>(`
query {
repository(owner: "${owner}", name: "${repo}") {
pullRequest(number: ${prNumber}) {
id
comments(last: 100) {
nodes {
id
body
author {
__typename
login
}
}
}
}
}
}
`);
const comments = data.repository.pullRequest?.comments.nodes?.filter(comment =>
comment?.author?.__typename === 'Bot' &&
comment?.author?.login === 'github-actions' &&
comment.body?.includes(this._magicComment()));
const prId = data.repository.pullRequest?.id;
if (!comments?.length)
return prId;
const mutations = comments.map((comment, i) =>
`m${i}: minimizeComment(input: { subjectId: "${comment!.id}", classifier: OUTDATED }) { clientMutationId }`);
await this.octokit.graphql(`
mutation {
${mutations.join('\n')}
}
`);
return prId;
}
private _magicComment() {
return `<!-- Generated by Playwright markdown reporter for ${this._workflowRunName()} in job ${process.env.GITHUB_JOB} -->`;
}
private _workflowRunName() {
// When used via 'workflow_run' event.
const workflowRunName = this.context.payload.workflow_run?.name;
if (workflowRunName)
return workflowRunName;
// When used via 'pull_request'/'push' event.
// This is the name of the workflow file, e.g. 'ci.yml' or name if set.
return process.env.GITHUB_WORKFLOW;
}
private async addNewReportComment(prNodeId: string, report: string) {
const reportUrl = process.env.HTML_REPORT_URL;
const mergeWorkflowUrl = `${this.context.serverUrl}/${this.context.repo.owner}/${this.context.repo.repo}/actions/runs/${this.context.runId}`;
const body = formatComment([
this._magicComment(),
`### ${reportUrl ? `[Test results](${reportUrl})` : 'Test results'} for "${this._workflowRunName()}"`,
report,
'',
'---',
'',
`Merge [workflow run](${mergeWorkflowUrl}).`
]);
const response = await this.octokit.graphql<{ addComment: { commentEdge: IssueCommentEdge } }>(`
mutation {
addComment(input: {subjectId: "${prNodeId}", body: """${body}"""}) {
commentEdge {
node {
... on IssueComment {
url
}
}
}
}
}
`);
this.core.info(`Posted comment: ${response.addComment.commentEdge.node?.url}`);
}
private pullRequestFromMetadata() {
const metadata = this._config.metadata as MetadataWithCommitInfo;
const prHref = metadata.ci?.prHref;
return { prNumber: parseInt(prHref?.split('/').pop() ?? '', 10), prHref };
}
}
function formatComment(lines: string[]) {
let body = lines.join('\n');
if (body.length > 65535)
body = body.substring(0, 65000) + `... ${body.length - 65000} more characters`;
return body;
}
export default GHAMarkdownReporter;

View File

@@ -0,0 +1,54 @@
/**
* 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 { test } from '@playwright/test';
import os from 'os';
export type PlatformWorkerFixtures = {
platform: 'win32' | 'darwin' | 'linux';
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
macVersion: number; // major only, 11 or later, zero if not mac
};
function platform(): 'win32' | 'darwin' | 'linux' {
if (process.env.PLAYWRIGHT_SERVICE_OS === 'linux')
return 'linux';
if (process.env.PLAYWRIGHT_SERVICE_OS === 'windows')
return 'win32';
if (process.env.PLAYWRIGHT_SERVICE_OS === 'macos')
return 'darwin';
return process.platform as 'win32' | 'darwin' | 'linux';
}
function macVersion() {
if (process.platform !== 'darwin')
return 0;
const darwinMajor = +os.release().split('.')[0];
// Apple jumped from macOS 15 (Sequoia) to macOS 26 (Tahoe), so Darwin 25 = macOS 26.
if (darwinMajor >= 25)
return darwinMajor + 1;
return darwinMajor - 9;
}
export const platformTest = test.extend<{}, PlatformWorkerFixtures>({
platform: [platform(), { scope: 'worker' }],
isWindows: [platform() === 'win32', { scope: 'worker' }],
isMac: [platform() === 'darwin', { scope: 'worker' }],
isLinux: [platform() === 'linux', { scope: 'worker' }],
macVersion: [macVersion(), { scope: 'worker' }],
});

View File

@@ -0,0 +1,182 @@
/**
* 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 { IncomingMessage } from 'http';
import type { ProxyServer } from '../third_party/proxy';
import { createProxy } from '../third_party/proxy';
import net from 'net';
import { utils } from '../../packages/playwright-core/lib/coreBundle';
type SocksSocketClosedPayload = utils.SocksSocketClosedPayload;
type SocksSocketDataPayload = utils.SocksSocketDataPayload;
type SocksSocketRequestedPayload = utils.SocksSocketRequestedPayload;
const { SocksProxy } = utils;
// Certain browsers perform telemetry requests which we want to ignore.
const kConnectHostsToIgnore = new Set([
'www.bing.com:443',
'www.google.com:443',
]);
export class TestProxy {
readonly HOST: string;
readonly PORT: number;
readonly URL: string;
connectHosts: string[] = [];
requestUrls: string[] = [];
wsUrls: string[] = [];
private readonly _server: ProxyServer;
private readonly _sockets = new Set<net.Socket>();
private _handlers: { event: string, handler: (...args: any[]) => void }[] = [];
static async create(port: number): Promise<TestProxy> {
const proxy = new TestProxy(port);
await new Promise<void>(f => proxy._server.listen(port, f));
return proxy;
}
private constructor(port: number) {
this.PORT = port;
this.URL = `http://localhost:${port}`;
this.HOST = new URL(this.URL).host;
this._server = createProxy();
this._server.on('connection', socket => this._onSocket(socket));
}
async stop(): Promise<void> {
this.reset();
for (const socket of this._sockets)
socket.destroy();
this._sockets.clear();
await new Promise(x => this._server.close(x));
}
forwardTo(port: number, options?: { allowConnectRequests?: boolean, removePrefix?: string, preserveHostname?: boolean }) {
this._prependHandler('request', (req: IncomingMessage) => {
this.requestUrls.push(req.url);
const url = new URL(req.url, `http://${req.headers.host}`);
if (options?.preserveHostname)
url.port = '' + port;
else
url.host = `127.0.0.1:${port}`;
if (options?.removePrefix)
url.pathname = url.pathname.replace(options.removePrefix, '');
req.url = url.toString();
});
this._prependHandler('connect', (req: IncomingMessage) => {
if (!options?.allowConnectRequests)
return;
if (kConnectHostsToIgnore.has(req.url))
return;
this.connectHosts.push(req.url);
req.url = `127.0.0.1:${port}`;
});
this._prependHandler('upgrade', (req: IncomingMessage) => {
this.wsUrls.push(req.url);
const url = new URL(req.url, `http://${req.headers.host}`);
if (options?.preserveHostname)
url.port = '' + port;
else
url.host = `127.0.0.1:${port}`;
if (options?.removePrefix)
url.pathname = url.pathname.replace(options.removePrefix, '');
if (url.protocol === 'ws:')
url.protocol = 'http:';
else if (url.protocol === 'wss:')
url.protocol = 'https:';
req.url = url.toString();
});
}
setAuthHandler(handler: (req: IncomingMessage) => boolean) {
this._server.authenticate = (req: IncomingMessage) => {
try {
return handler(req);
} catch (e) {
return false;
}
};
}
reset() {
this.connectHosts = [];
this.requestUrls = [];
for (const { event, handler } of this._handlers)
this._server.removeListener(event, handler);
this._handlers = [];
this._server.authenticate = undefined;
}
private _prependHandler(event: string, handler: (...args: any[]) => void) {
this._handlers.push({ event, handler });
this._server.prependListener(event, handler);
}
private _onSocket(socket: net.Socket) {
this._sockets.add(socket);
// ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given
// that tab closing aborts outgoing connections to the server.
socket.on('error', (error: any) => {
if (error.code !== 'ECONNRESET' && error.code !== 'HPE_INVALID_EOF_STATE')
throw error;
});
socket.once('close', () => this._sockets.delete(socket));
}
}
export async function setupSocksForwardingServer({
port, forwardPort, allowedTargetPort
}: {
port: number, forwardPort: number, allowedTargetPort: number
}) {
const connectHosts = [];
const connections = new Map<string, net.Socket>();
const socksProxy = new SocksProxy();
socksProxy.setPattern('*');
socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(payload.host) || payload.port !== allowedTargetPort) {
socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' });
return;
}
const target = new net.Socket();
target.on('error', error => socksProxy.sendSocketError({ uid: payload.uid, error: error.toString() }));
target.on('end', () => socksProxy.sendSocketEnd({ uid: payload.uid }));
target.on('data', data => socksProxy.sendSocketData({ uid: payload.uid, data }));
target.setKeepAlive(false);
target.connect(forwardPort, '127.0.0.1');
target.on('connect', () => {
connections.set(payload.uid, target);
if (!connectHosts.includes(`${payload.host}:${payload.port}`))
connectHosts.push(`${payload.host}:${payload.port}`);
socksProxy.socketConnected({ uid: payload.uid, host: target.localAddress, port: target.localPort });
});
});
socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketDataPayload) => {
connections.get(payload.uid)?.write(payload.data);
});
socksProxy.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => {
connections.get(payload.uid)?.destroy();
connections.delete(payload.uid);
});
await socksProxy.listen(port, '127.0.0.1');
return {
closeProxyServer: () => socksProxy.close(),
proxyServerAddr: `socks5://127.0.0.1:${port}`,
connectHosts,
};
}

View File

@@ -0,0 +1,42 @@
/**
* 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.
*/
export async function queryObjectCount(type: Function): Promise<number> {
globalThis.typeForQueryObjects = type;
const session: import('inspector').Session = new (require('node:inspector').Session)();
session.connect();
try {
await new Promise(f => session.post('Runtime.enable', f));
const { result: constructorFunction } = await new Promise(f => session.post('Runtime.evaluate', {
expression: `globalThis.typeForQueryObjects.prototype`,
includeCommandLineAPI: true,
}, (_, result) => f(result))) as any;
const { objects: instanceArray } = await new Promise(f => session.post('Runtime.queryObjects', {
prototypeObjectId: constructorFunction.objectId
}, (_, result) => f(result))) as any;
const { result: { value } } = await new Promise<any>(f => session.post('Runtime.callFunctionOn', {
functionDeclaration: 'function (arr) { return this.length; }',
objectId: instanceArray.objectId,
arguments: [{ objectId: instanceArray.objectId }],
}, (_, result) => f(result as any)));
return value;
} finally {
session.disconnect();
}
}

View File

@@ -0,0 +1,62 @@
const fs = require('fs');
const cluster = require('cluster');
async function start() {
const { browserTypeName, launchOptions, stallOnClose, disconnectOnSIGHUP, exitOnFile, exitOnWarning, startStopAndRunHttp } = JSON.parse(process.argv[2]);
if (stallOnClose) {
launchOptions.__testHookGracefullyClose = () => {
console.log(`(stalled=>true)`);
return new Promise(() => { });
};
}
if (exitOnWarning)
process.on('warning', () => process.exit(43));
if (disconnectOnSIGHUP)
launchOptions.handleSIGHUP = false;
const playwright = require('playwright-core');
if (startStopAndRunHttp) {
const browser = await playwright[browserTypeName].launch(launchOptions);
await browser.close();
console.log(`(wsEndpoint=>none)`);
console.log(`(closed=>success)`);
require('http').createServer(() => {}).listen();
return;
}
const browserServer = await playwright[browserTypeName].launchServer(launchOptions);
if (disconnectOnSIGHUP)
process.on('SIGHUP', () => browserServer._disconnectForTest());
if (exitOnFile) {
(async function waitForFileAndExit() {
while (true) {
if (fs.existsSync(exitOnFile))
break;
await new Promise(f => setTimeout(f, 100));
}
process.exit(42);
})();
}
browserServer.on('close', (exitCode, signal) => {
console.log(`(exitCode=>${exitCode})`);
console.log(`(signal=>${signal})`);
});
console.log(`(tempDir=>${browserServer._userDataDirForTest})`);
console.log(`(pid=>${browserServer.process().pid})`);
console.log(`(wsEndpoint=>${browserServer.wsEndpoint()})`);
}
process.on('uncaughtException', error => console.log(error));
process.on('unhandledRejection', reason => console.log(reason));
if (cluster.isWorker || !JSON.parse(process.argv[2]).inCluster) {
start();
} else {
cluster.fork();
cluster.on('exit', (worker, code, signal) => {
process.exit(0);
});
}

View File

@@ -0,0 +1,171 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* 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 path from 'path';
import type { BrowserType, Browser } from 'playwright-core';
import type { CommonFixtures, TestChildProcess } from './commonFixtures';
export interface PlaywrightServer {
wsEndpoint(): string;
close(): Promise<void>;
}
export class RunServer implements PlaywrightServer {
private _process!: TestChildProcess;
_wsEndpoint!: string;
async start(childProcess: CommonFixtures['childProcess'], options?: { mode?: 'extension' | 'default', env?: NodeJS.ProcessEnv, artifactsDir?: string }) {
const command = ['node', path.join(__dirname, '..', '..', 'packages', 'playwright-core', 'cli.js'), 'run-server'];
if (options?.mode === 'extension')
command.push('--mode=extension');
if (options?.artifactsDir)
command.push(`--artifacts-dir=${options.artifactsDir}`);
this._process = childProcess({
command,
env: { NODE_OPTIONS: process.env.NODE_OPTIONS, ...options?.env },
});
let wsEndpointCallback: (value: string) => void;
const wsEndpointPromise = new Promise<string>(f => wsEndpointCallback = f);
this._process.onOutput = data => {
const prefix = 'Listening on ';
const line = data.toString();
if (line.startsWith(prefix))
wsEndpointCallback(line.substr(prefix.length));
};
this._wsEndpoint = await wsEndpointPromise;
}
wsEndpoint() {
return this._wsEndpoint;
}
async close() {
await this._process.kill('SIGINT');
}
}
export type RemoteServerOptions = {
stallOnClose?: boolean;
disconnectOnSIGHUP?: boolean;
exitOnFile?: string;
exitOnWarning?: boolean;
inCluster?: boolean;
url?: string;
startStopAndRunHttp?: boolean;
sharedBrowser?: boolean;
artifactsDir?: string;
};
export class RemoteServer implements PlaywrightServer {
private _process!: TestChildProcess;
readonly _output = new Map<string, string>();
readonly _outputCallback = new Map<string, () => void>();
_browserType!: BrowserType;
_exitAndDisconnectPromise: Promise<any> | undefined;
_browser: Browser | undefined;
_wsEndpoint!: string;
async _start(childProcess: CommonFixtures['childProcess'], browserType: BrowserType, channel: string, remoteServerOptions: RemoteServerOptions = {}) {
this._browserType = browserType;
const browserOptions = (browserType as any)._playwright._defaultLaunchOptions;
// Copy options to prevent a large JSON string when launching subprocess.
// Otherwise, we get `Error: spawn ENAMETOOLONG` on Windows.
const launchOptions: Parameters<BrowserType['launchServer']>[0] = {
args: browserOptions.args,
headless: browserOptions.headless,
channel: browserOptions.channel,
executablePath: browserOptions.executablePath,
handleSIGINT: true,
handleSIGTERM: true,
handleSIGHUP: true,
logger: undefined,
};
if (remoteServerOptions.sharedBrowser)
(launchOptions as any)._sharedBrowser = true;
if (remoteServerOptions.artifactsDir)
launchOptions.artifactsDir = remoteServerOptions.artifactsDir;
const options = {
browserTypeName: browserType.name(),
channel,
launchOptions,
...remoteServerOptions,
};
this._process = childProcess({
command: ['node', path.join(__dirname, 'remote-server-impl.js'), JSON.stringify(options)],
env: { NODE_OPTIONS: process.env.NODE_OPTIONS },
});
let index = 0;
this._process.onOutput = () => {
let match;
while ((match = this._process.output.substring(index).match(/\(([^()]+)=>([^()]+)\)/))) {
const key = match[1];
const value = match[2];
this._addOutput(key, value);
index += match.index! + match[0].length;
}
};
this._wsEndpoint = await this.out('wsEndpoint');
if (remoteServerOptions.url) {
this._browser = await this._browserType.connect(this._wsEndpoint);
const page = await this._browser.newPage();
await page.goto(remoteServerOptions.url);
}
}
_addOutput(key: string, value: string) {
this._output.set(key, value);
const cb = this._outputCallback.get(key);
this._outputCallback.delete(key);
if (cb)
cb();
}
async out(key: string): Promise<string> {
if (!this._output.has(key))
await new Promise<void>(f => this._outputCallback.set(key, f));
return this._output.get(key)!;
}
wsEndpoint() {
return this._wsEndpoint;
}
child() {
return this._process.process;
}
async childExitCode() {
return await this._process.exitCode;
}
async childSignal() {
return (await this._process.exited).signal;
}
async close() {
if (this._browser) {
await this._browser.close();
this._browser = undefined;
}
await this._process.kill('SIGINT');
await this.childExitCode();
}
}

View File

@@ -0,0 +1,136 @@
/**
* 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 { Fixtures } from '@playwright/test';
import path from 'path';
import { TestServer } from './testserver';
import { TestProxy } from './proxy';
import { utils } from '../../packages/playwright-core/lib/coreBundle';
type SocksSocketRequestedPayload = utils.SocksSocketRequestedPayload;
const { SocksProxy } = utils;
type SocksProxy = InstanceType<typeof SocksProxy>;
export type ServerWorkerOptions = {
loopback?: string;
__servers: ServerFixtures;
};
export type ServerFixtures = {
server: TestServer;
httpsServer: TestServer;
socksPort: number;
proxyServer: TestProxy;
asset: (p: string) => string;
};
export const serverFixtures: Fixtures<ServerFixtures, ServerWorkerOptions> = {
loopback: [undefined, { scope: 'worker', option: true }],
__servers: [async ({ loopback }, run, workerInfo) => {
const assetsPath = path.join(__dirname, '..', 'assets');
const cachedPath = path.join(__dirname, '..', 'assets', 'cached');
const port = 8907 + workerInfo.workerIndex * 4;
const server = await TestServer.create(assetsPath, port, loopback);
server.enableHTTPCache(cachedPath);
const httpsPort = port + 1;
const httpsServer = await TestServer.createHTTPS(assetsPath, httpsPort, loopback);
httpsServer.enableHTTPCache(cachedPath);
const socksServer = new MockSocksServer();
const socksPort = port + 2;
await socksServer.listen(socksPort);
const proxyPort = port + 3;
const proxyServer = await TestProxy.create(proxyPort);
await run({
asset: (p: string) => path.join(__dirname, '..', 'assets', ...p.split('/')),
server,
httpsServer,
socksPort,
proxyServer,
});
await Promise.all([
server.stop(),
httpsServer.stop(),
socksServer.close(),
proxyServer.stop(),
]);
}, { scope: 'worker' }],
server: async ({ __servers }, run) => {
__servers.server.reset();
await run(__servers.server);
},
httpsServer: async ({ __servers }, run) => {
__servers.httpsServer.reset();
await run(__servers.httpsServer);
},
socksPort: async ({ __servers }, run) => {
await run(__servers.socksPort);
},
proxyServer: async ({ __servers }, run) => {
__servers.proxyServer.reset();
await run(__servers.proxyServer);
},
asset: async ({ __servers }, run) => {
await run(__servers.asset);
},
};
export class MockSocksServer {
private _socksProxy: SocksProxy;
constructor() {
this._socksProxy = new SocksProxy();
this._socksProxy.setPattern('*');
this._socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
this._socksProxy.socketConnected({
uid: payload.uid,
host: '127.0.0.1',
port: 0,
});
});
this._socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketRequestedPayload) => {
const body = '<html><title>Served by the SOCKS proxy</title></html>';
const data = Buffer.from([
'HTTP/1.1 200 OK',
'Connection: close',
'Content-Type: text/html',
'Content-Length: ' + Buffer.byteLength(body),
'',
body
].join('\r\n'));
this._socksProxy.sendSocketData({ uid: payload.uid, data });
this._socksProxy.sendSocketEnd({ uid: payload.uid });
});
}
async listen(port: number, hostname?: string) {
await this._socksProxy.listen(port, hostname);
}
async close() {
await this._socksProxy.close();
}
}

View File

@@ -0,0 +1,50 @@
/**
* 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 { oop, client } from '../../packages/playwright-core/lib/coreBundle';
export type TestModeName = 'default' | 'driver';
const { start } = oop;
interface TestMode {
setup(): Promise<client.Playwright>;
teardown(): Promise<void>;
}
export class DriverTestMode implements TestMode {
private _impl: { playwright: client.Playwright; stop: () => Promise<void>; };
async setup() {
this._impl = await start({
NODE_OPTIONS: undefined, // Hide driver process while debugging.
});
return this._impl.playwright;
}
async teardown() {
await this._impl.stop();
}
}
export class DefaultTestMode implements TestMode {
async setup() {
return require('playwright-core');
}
async teardown() {
}
}

View File

@@ -0,0 +1,55 @@
/**
* 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 { test } from '@playwright/test';
import type { TestModeName } from './testMode';
import { DefaultTestMode, DriverTestMode } from './testMode';
export type TestModeWorkerOptions = {
mode: TestModeName;
};
export type TestModeTestFixtures = {
toImpl: (rpcObject?: any) => any;
};
export type TestModeWorkerFixtures = {
toImplInWorkerScope: (rpcObject?: any) => any;
playwright: typeof import('@playwright/test');
};
export const testModeTest = test.extend<TestModeTestFixtures, TestModeWorkerOptions & TestModeWorkerFixtures>({
mode: ['default', { scope: 'worker', option: true }],
playwright: [async ({ mode }, run) => {
const testMode = {
'default': new DefaultTestMode(),
'driver': new DriverTestMode(),
}[mode];
const playwright = await testMode.setup();
await run(playwright);
await testMode.teardown();
}, { scope: 'worker' }],
toImplInWorkerScope: [async ({ playwright }, use) => {
await use((playwright as any)._connection.toImpl);
}, { scope: 'worker' }],
toImpl: async ({ toImplInWorkerScope: toImplWorker, mode }, use, testInfo) => {
if (mode !== 'default' || process.env.PW_TEST_REUSE_CONTEXT)
testInfo.skip();
await use(toImplWorker);
},
});

View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX
DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN
BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv
Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr
ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ
9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj
NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw
alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV
dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP
dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM
38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4
kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15
D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D
G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD
VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG
SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG
iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y
1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth
KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o
XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf
pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf
JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to
ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40
AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg
hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy
BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,350 @@
/**
* Copyright 2017 Google Inc. All rights reserved.
* Modifications 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 type http from 'http';
import mime from 'mime';
import type net from 'net';
import path from 'path';
import util from 'util';
import type stream from 'stream';
import { WebSocket, WebSocketServer } from 'ws';
import zlib, { gzip } from 'zlib';
import { utils } from '../../../packages/playwright-core/lib/coreBundle';
const { createHttpServer, createHttpsServer } = utils;
const fulfillSymbol = Symbol('fulfil callback');
const rejectSymbol = Symbol('reject callback');
const gzipAsync = util.promisify(gzip.bind(zlib));
type UpgradeActions = {
doUpgrade: () => void;
socket: stream.Duplex;
};
type IncomingMessageWithBody = http.IncomingMessage & { postBody: Promise<Buffer> };
export class TestServer {
private _server: http.Server;
private _wsServer: WebSocketServer;
private _dirPath: string;
readonly debugServer: any;
private _startTime: Date;
private _cachedPathPrefix: string | null;
private _routes = new Map<string, (arg0: IncomingMessageWithBody, arg1: http.ServerResponse) => any>();
private _auths = new Map<string, { username: string; password: string; }>();
private _csp = new Map<string, string>();
private _extraHeaders = new Map<string, object>();
private _gzipRoutes = new Set<string>();
private _requestSubscribers = new Map<string, Promise<any>>();
private _upgradeCallback: (actions: UpgradeActions) => void | undefined;
readonly PORT: number;
readonly PREFIX: string;
readonly CROSS_PROCESS_PREFIX: string;
readonly EMPTY_PAGE: string;
readonly HOST: string;
readonly HOSTNAME: string;
readonly HELLO_WORLD: string;
static async create(dirPath: string, port: number, loopback?: string): Promise<TestServer> {
const server = new TestServer(dirPath, port, loopback);
await server.waitUntilReady();
return server;
}
static async certOptions() {
return {
key: await fs.promises.readFile(path.join(__dirname, 'key.pem')),
cert: await fs.promises.readFile(path.join(__dirname, 'cert.pem')),
passphrase: 'aaaa',
};
}
static async createHTTPS(dirPath: string, port: number, loopback?: string): Promise<TestServer> {
const server = new TestServer(dirPath, port, loopback, await this.certOptions());
await server.waitUntilReady();
return server;
}
constructor(dirPath: string, port: number, loopback?: string, sslOptions?: object) {
if (sslOptions)
this._server = createHttpsServer(sslOptions, this._onRequest.bind(this));
else
this._server = createHttpServer(this._onRequest.bind(this));
this._server.on('connection', socket => this._onSocket(socket));
this._wsServer = new WebSocketServer({ noServer: true });
this._server.on('upgrade', async (request, socket, head) => {
const doUpgrade = () => {
this._wsServer.handleUpgrade(request, socket, head, ws => {
// Next emit is only for our internal 'connection' listeners.
this._wsServer.emit('connection', ws, request);
});
};
if (this._upgradeCallback) {
this._upgradeCallback({ doUpgrade, socket });
return;
}
const pathname = new URL(request.url, 'http://localhost').pathname;
if (pathname === '/ws-401') {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\nUnauthorized body');
socket.destroy();
return;
}
if (pathname === '/ws-slow')
await new Promise(f => setTimeout(f, 2000));
if (!['/ws', '/ws-slow'].includes(pathname)) {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
doUpgrade();
});
this._server.listen(port);
this._dirPath = dirPath;
this.debugServer = require('debug')('pw:testserver');
this._startTime = new Date();
this._cachedPathPrefix = null;
const cross_origin = loopback || '127.0.0.1';
const same_origin = loopback || 'localhost';
const protocol = sslOptions ? 'https' : 'http';
this.PORT = port;
this.PREFIX = `${protocol}://${same_origin}:${port}`;
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`;
this.EMPTY_PAGE = `${protocol}://${same_origin}:${port}/empty.html`;
this.HOST = new URL(this.EMPTY_PAGE).host;
this.HOSTNAME = new URL(this.EMPTY_PAGE).hostname;
this.HELLO_WORLD = `${this.PREFIX}/hello-world`;
}
async waitUntilReady() {
await new Promise(x => this._server.once('listening', x));
}
_onSocket(socket: net.Socket) {
// ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given
// that tab closing aborts outgoing connections to the server.
// HPE_INVALID_METHOD is a legit error when a client (e.g. Chromium which
// makes https requests to http sites) makes a https connection to a http server.
socket.on('error', error => {
if (!['ECONNRESET', 'HPE_INVALID_EOF_STATE', 'HPE_INVALID_METHOD'].includes((error as any).code))
throw error;
});
}
enableHTTPCache(pathPrefix: string) {
this._cachedPathPrefix = pathPrefix;
}
setAuth(path: string, username: string, password: string) {
this.debugServer(`set auth for ${path} to ${username}:${password}`);
this._auths.set(path, { username, password });
}
enableGzip(path: string) {
this._gzipRoutes.add(path);
}
setCSP(path: string, csp: string) {
this._csp.set(path, csp);
}
setExtraHeaders(path: string, object: Record<string, string>) {
this._extraHeaders.set(path, object);
}
async stop() {
this.reset();
await new Promise(x => this._server.close(x));
}
setContent(path: string, content: string, mimeType: string) {
this.setRoute(path, (req, res) => {
res.writeHead(200, { 'Content-Type': mimeType });
res.end(mimeType === 'text/html' ? `<!DOCTYPE html>${content}` : content);
});
}
setRoute(path: string, handler: (arg0: IncomingMessageWithBody, arg1: http.ServerResponse) => any) {
this._routes.set(path, handler);
}
setRedirect(from: string, to: string) {
this.setRoute(from, (req, res) => {
const headers = this._extraHeaders.get(req.url!) || {};
res.writeHead(302, { ...headers, location: to });
res.end();
});
}
waitForRequest(path: string): Promise<IncomingMessageWithBody> {
let promise = this._requestSubscribers.get(path);
if (promise)
return promise;
let fulfill;
let reject;
promise = new Promise((f, r) => {
fulfill = f;
reject = r;
});
promise[fulfillSymbol] = fulfill;
const error = new Error(`Request ${path} was not received before the test finished.`);
promise[rejectSymbol] = () => reject(error);
this._requestSubscribers.set(path, promise);
return promise;
}
reset() {
this._routes.clear();
this._auths.clear();
this._csp.clear();
this._extraHeaders.clear();
this._gzipRoutes.clear();
this._upgradeCallback = undefined;
this._wsServer.removeAllListeners('connection');
this._server.closeAllConnections();
for (const subscriber of this._requestSubscribers.values())
subscriber[rejectSymbol].call(null);
this._requestSubscribers.clear();
}
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
request.on('error', error => {
if ((error as any).code === 'ECONNRESET')
response.end();
else
throw error;
});
(request as any).postBody = new Promise(resolve => {
const chunks: Buffer[] = [];
request.on('data', chunk => {
chunks.push(chunk);
});
request.on('end', () => resolve(Buffer.concat(chunks)));
});
const url = new URL(request.url, 'http://localhost');
const pathWithSearch = url.pathname + url.search;
this.debugServer(`request ${request.method} ${pathWithSearch}`);
if (this._auths.has(pathWithSearch)) {
const auth = this._auths.get(pathWithSearch)!;
const credentials = Buffer.from((request.headers.authorization || '').split(' ')[1] || '', 'base64').toString();
this.debugServer(`request credentials ${credentials}`);
this.debugServer(`actual credentials ${auth.username}:${auth.password}`);
if (credentials !== `${auth.username}:${auth.password}`) {
this.debugServer(`request write www-auth`);
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Secure Area"' });
response.end('HTTP Error 401 Unauthorized: Access is denied');
return;
}
}
// Notify request subscriber.
if (this._requestSubscribers.has(pathWithSearch)) {
this._requestSubscribers.get(pathWithSearch)![fulfillSymbol].call(null, request);
this._requestSubscribers.delete(pathWithSearch);
}
const handler = this._routes.get(pathWithSearch);
if (handler)
handler.call(null, request as IncomingMessageWithBody, response);
else
this.serveFile(request, response);
}
serveFile(request: http.IncomingMessage, response: http.ServerResponse, filePath?: string): void {
this._serveFile(request, response, filePath).catch(e => {
this.debugServer(`error: ${e}`);
});
}
private async _serveFile(request: http.IncomingMessage, response: http.ServerResponse, filePath?: string): Promise<void> {
let pathName = new URL(request.url, 'http://localhost').pathname;
if (!filePath) {
if (pathName === '/')
pathName = '/index.html';
filePath = path.join(this._dirPath, pathName.substring(1));
}
if (this._cachedPathPrefix !== null && filePath.startsWith(this._cachedPathPrefix)) {
if (request.headers['if-modified-since']) {
response.statusCode = 304; // not modified
response.end();
return;
}
response.setHeader('Cache-Control', 'public, max-age=31536000, no-cache');
response.setHeader('Last-Modified', this._startTime.toISOString());
} else {
response.setHeader('Cache-Control', 'no-cache, no-store');
}
if (this._csp.has(pathName))
response.setHeader('Content-Security-Policy', this._csp.get(pathName)!);
if (this._extraHeaders.has(pathName)) {
const object = this._extraHeaders.get(pathName);
for (const key in object)
response.setHeader(key, object[key]);
}
const { err, data } = await fs.promises.readFile(filePath).then(data => ({ data, err: undefined })).catch(err => ({ data: undefined, err }));
// The HTTP transaction might be already terminated after async hop here - do nothing in this case.
if (response.writableEnded)
return;
if (err) {
response.statusCode = 404;
response.setHeader('Content-Type', 'text/plain');
response.end(request.method !== 'HEAD' ? `File not found: ${filePath}` : null);
return;
}
const extension = filePath.substring(filePath.lastIndexOf('.') + 1);
const mimeType = mime.getType(extension) || 'application/octet-stream';
const isTextEncoding = /^text\/|^application\/(javascript|json)/.test(mimeType);
const contentType = isTextEncoding ? `${mimeType}; charset=utf-8` : mimeType;
response.setHeader('Content-Type', contentType);
if (this._gzipRoutes.has(pathName)) {
response.setHeader('Content-Encoding', 'gzip');
const result = await gzipAsync(data);
// The HTTP transaction might be already terminated after async hop here.
if (!response.writableEnded)
response.end(request.method !== 'HEAD' ? result : null);
} else {
response.end(request.method !== 'HEAD' ? data : null);
}
}
onceWebSocketConnection(handler: (socket: WebSocket, request: http.IncomingMessage) => void) {
this._wsServer.once('connection', handler);
}
waitForWebSocketConnectionRequest() {
return new Promise<http.IncomingMessage & { headers: http.IncomingHttpHeaders }>(fulfil => {
this._wsServer.once('connection', (ws, req) => fulfil(req));
});
}
waitForUpgrade() {
return new Promise<UpgradeActions>(fulfill => this._upgradeCallback = fulfill);
}
waitForWebSocket() {
return new Promise<WebSocket>(fulfill => this._wsServer.once('connection', (ws, req) => fulfill(ws)));
}
sendOnWebSocketConnection(data) {
this.onceWebSocketConnection(ws => ws.send(data));
}
}

View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCttL32qMpycevk
bRlzbGENo5meNC8VuKrF/+dte3/tZqqvLOlUq7sBPVqKcG+48Sjsh/0OFWd8X//a
kaXSBtTfU5Tj+avKZCZzMYTJm84EOxmG0KJ/nijk6g7QGOE4M1Pw5Z5z1FPzufpG
QfnwweU6LzGw/FD2flTUI3+HYPJAXdpEq6cQUKC3A8pZKMb4n9MAj2LRpIQ1c+JH
zCNOuODv2zwSG6M03xaTqsotTex6kbcYgwgPm8uxVra33Gqfnraw/3kR75enZhff
Puhgr31AIl3NpzBqWEwehvUYIQqMPLGwPYgVGLsPKyK61/LPLekyjo0AvEWvApWF
ZfQvjNUUiwjomVV0rx0eYOTQxFKqAH2xZe8R9JNcAMR7yudwVIWkXPhnxE0uaflh
LNAnOiQ2fjUNQI91lOVORwZOq+fk2qhjiw5XyuQVuCZNyrKKNX+x5s+PEJsk1w2Z
pJZr0UnkiNFOnMzfzAubGf54oYrryfYkHc/sk/ou2wFKFASlcAXesV/I2w/VuOq6
8ZlVKNXzD9TA8TiRXl6rePcPj3Xdl/G/OOno8SaFK0bQRC0he/H9NGhilo1bKkiB
l1uRlCnUdQhAvXkPZZ5vidrM/eyjUULdmBuslnajOhp2Msynj01xIV2H5ZHJUq6j
QMipHTNqIDPN/YMbWrtMsqKYzdcExQIDAQABAoICAGqXttpdyZ1g+vg5WpzRrNzJ
v8KtExepMmI+Hq24U1BC6AqG7MfgeejQ1XaOeIBsvEgpSsgRqmdQIZjmN3Mibg59
I6ih1SFlQ5L8mBd/XHSML6Xi8VSOoVmXp29bVRk/pgr1XL6HVN0DCumCIvXyhc+m
lj+dFbGs5DEpd2CDxSRqcz4gd2wzjevAj7MWqsJ2kOyPEHzFD7wdWIXmZuQv3xhQ
2BPkkcon+5qx+07BupOcR1brUU8Cs4QnSgiZYXSB2GnU215+P/mhVJTR7ZcnGRz5
+cXxCmy3sj4pYs1juS1FMWSM3azUeDVeqvks+vrXmXpEr5H79mbmlwo8/hMPwNDO
07HRZwa8T01aT9EYVm0lIOYjMF/2f6j6cu2apJtjXICOksR2HefRBVXQirOxRHma
9XAYfNkZ/2164ZbgFmJv9khFnegPEuth9tLVdFIeGSmsG0aX9tH63zGT2NROyyLc
QXPqsDl2CxCYPRs2oiGkM9dnfP1wAOp96sq42GIuN7ykfqfRnwAIvvnLKvyCq1vR
pIno3CIX6vnzt+1/Hrmv13b0L6pJPitpXwKWHv9zJKBTpN8HEzP3Qmth2Ef60/7/
CBo1PVTd1A6zcU7816flg7SCY+Vk+OxVHV3dGBIIqN9SfrQ8BPcOl6FNV5Anbrnv
CpSw+LzH9n5xympDnk0BAoIBAQDjenvDfCnrNVeqx8+sYaYey4/WPVLXOQhREvRY
oOtX9eqlNSi20+Wl+iuXmyj8wdHrDET7rfjCbpDQ7u105yzLw4gy4qIRDKZ1nE45
YX+tm8mZgBqRnTp0DoGOArqmp3IKXJtUYmpbTz9tOfY7Usb1o1epb4winEB+Pl+8
mgXOEo8xvWBzKeRA7tE73V64Mwbvbo9Ff2EguhXweQP29yBkEjT4iViayuHUmyPt
hOVSMj2oFQuQGPdhAk7nUXojSGK/Zas/AGpH9CHH9De0h4m08vd3oM4vj0HwzgjU
Co9aRa9SAH7EiaocOTcjDRPxWdZPHhxmrVRIYlF0MNmOAkXJAoIBAQDDfEqu4sNi
pq74VXVatQqhzCILZo+o48bdgEjF7mF99mqPj8rwIDrEoEriDK861kenLc3vWKRY
5wh1iX3S896re9kUMoxx6p4heYTcsOJ9BbkcpT8bJPZx9gBJb4jJENeVf1exf6sG
RhFnulpzReRRaUjX2yAkyUPfc8YcUt+Nalrg+2W0fzeLCUpABCAcj2B1Vv7qRZHj
oEtlCV5Nz+iMhrwIa16g9c8wGt5DZb4PI+VIJ6EYkdsjhgqIF0T/wDq9/habGBPo
mHN+/DX3hCJWN2QgoVGJskHGt0zDMgiEgXfLZ2Grl02vQtq+mW2O2vGVeUd9Y5Ew
RUiY4bSRTrUdAoIBAHxL1wiP9c/By+9TUtScXssA681ioLtdPIAgXUd4VmAvzVEM
ZPzRd/BjbCJg89p4hZ1rjN4Ax6ZmB9dCVpnEH6QPaYJ0d53dTa+CAvQzpDJWp6eq
adobEW+M5ZmVQCwD3rpus6k+RWMzQDMMstDjgDeEU0gP3YCj5FGW/3TsrDNXzMqe
8e67ey9Hzyho43K+3xFBViPhYE8jnw1Q8quliRtlH3CWi8W5CgDD7LPCJBPvw+Tt
6u2H1tQ5EKgwyw4wZVSz1wiLz4cVjMfXWADa9pHbGQFS6pbuLlfIHObQBliLLysd
ficiGcNmOAx8/uKn9gQxLc+k8iLDJkLY1mdUMpECggEAJLl87k37ltTpmg2z9k58
qNjIrIugAYKJIaOwCD84YYmhi0bgQSxM3hOe/ciUQuFupKGeRpDIj0sX87zYvoDC
HEUwCvNUHzKMco15wFwasJIarJ7+tALFqbMlaqZhdCSN27AIsXfikVMogewoge9n
bUPyQ1sPNtn4vknptfh7tv18BTg1aytbK+ua31vnDHaDEIg/a5OWTMUYZOrVpJii
f4PwX0SMioCjY84oY1EB26ZKtLt9MDh2ir3rzJVSiRl776WEaa6kTtYVHI4VNWLF
cJ0HWnnz74JliQd2jFUh9IK+FqBdYPcTyREuNxBr3KKVMBeQrqW96OubL913JrU6
oQKCAQEA0yzORUouT0yleWs7RmzBlT9OLD/3cBYJMf/r1F8z8OQjB8fU1jKbO1Cs
q4l+o9FmI+eHkgc3xbEG0hahOFWm/hTTli9vzksxurgdawZELThRkK33uTU9pKla
Okqx3Ru/iMOW2+DQUx9UB+jK+hSAgq4gGqLeJVyaBerIdLQLlvqxrwSxjvvj+wJC
Y66mgRzdCi6VDF1vV0knCrQHK6tRwcPozu/k4zjJzvdbMJnKEy2S7Vh6vO8lEPJm
MQtaHPpmz+F4z14b9unNIiSbHO60Q4O+BwIBCzxApQQbFg63vBLYYwEMRd7hh92s
ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ==
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,19 @@
# openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = playwright-test
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1
IP.2 = ::1

View File

@@ -0,0 +1,206 @@
/**
* 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 { Fixtures, FrameLocator, Locator, Page, Browser, BrowserContext } from '@playwright/test';
import { step } from './baseTest';
import path from 'path';
import { CommonFixtures, TestChildProcess } from './commonFixtures';
type BaseTestFixtures = CommonFixtures & {
context: BrowserContext;
};
type BaseWorkerFixtures = {
headless: boolean;
browser: Browser;
browserName: 'chromium' | 'firefox' | 'webkit';
playwright: typeof import('@playwright/test');
};
export type TraceViewerFixtures = {
showTraceViewer: (trace: string | undefined, options?: {host?: string, port?: number, stdin?: boolean}) => Promise<TraceViewerPage>;
runAndTrace: (body: () => Promise<void>, optsOverrides?: Parameters<BrowserContext['tracing']['start']>[0]) => Promise<TraceViewerPage>;
};
class TraceViewerPage {
actionTitles: Locator;
actionsTree: Locator;
callLines: Locator;
consoleLines: Locator;
logLines: Locator;
errorMessages: Locator;
consoleLineMessages: Locator;
consoleStacks: Locator;
networkRequests: Locator;
metadataTab: Locator;
snapshotContainer: Locator;
sourceCodeTab: Locator;
networkTab: Locator;
settingsDialog: Locator;
themeSetting: Locator;
displayCanvasContentSetting: Locator;
constructor(public page: Page, public process: TestChildProcess) {
this.actionTitles = page.locator('.action-title');
this.actionsTree = page.getByTestId('actions-tree');
this.callLines = page.locator('.call-tab .call-line');
this.logLines = page.getByRole('listbox', { name: 'Log entries' }).getByRole('option');
this.consoleLines = page.getByRole('tabpanel', { name: 'Console' }).getByRole('option');
this.consoleLineMessages = page.locator('.console-line-message');
this.errorMessages = page.locator('.error-message');
this.consoleStacks = page.locator('.console-stack');
this.networkRequests = page.getByRole('listbox', { name: 'Network requests' }).getByRole('option');
this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]');
this.metadataTab = page.getByRole('tabpanel', { name: 'Metadata' });
this.sourceCodeTab = page.getByRole('tabpanel', { name: 'Source' });
this.networkTab = page.getByRole('tabpanel', { name: 'Network' });
this.settingsDialog = page.getByTestId('settings-toolbar-dialog');
this.themeSetting = this.settingsDialog.getByRole('combobox', { name: 'Theme' });
this.displayCanvasContentSetting = page.locator('.setting').getByText('Display canvas content');
}
@step
async showAllActions() {
await this.page.getByRole('button', { name: 'Filter actions' }).click();
await this.page.locator('.setting').getByText('Network routes').click();
await this.page.locator('.setting').getByText('Getters').click();
await this.page.locator('.setting').getByText('Configuration').click();
await this.page.getByRole('button', { name: 'Filter actions' }).click();
}
stackFrames(options: { selected?: boolean } = {}) {
return this.page.getByRole('listbox', { name: 'Stack trace' }).getByRole('option', options);
}
actionIconsText(action: string) {
const entry = this.actionsTree.getByRole('treeitem', { name: action });
return entry.locator('.action-icon-value').filter({ visible: true });
}
actionIcons(action: string) {
return this.actionsTree.getByRole('treeitem', { name: action }).locator('.action-icons').filter({ visible: true });
}
@step
async expandAction(title: string) {
await this.actionsTree.getByRole('treeitem', { name: title }).locator('.codicon-chevron-right').click();
}
@step
async selectAction(title: string, ordinal: number = 0) {
await this.actionsTree.getByTitle(title).nth(ordinal).click();
}
@step
async hoverAction(title: string, ordinal: number = 0) {
await this.actionsTree.getByRole('treeitem', { name: title }).nth(ordinal).hover();
}
@step
async selectSnapshot(name: string) {
await this.page.getByRole('tab', { name }).click();
}
async showErrorsTab() {
await this.page.getByRole('tab', { name: 'Errors' }).click();
}
async showConsoleTab() {
await this.page.getByRole('tab', { name: 'Console' }).click();
}
async showSourceTab() {
await this.page.getByRole('tab', { name: 'Source' }).click();
}
async showNetworkTab() {
await this.page.getByRole('tab', { name: 'Network' }).click();
}
async showMetadataTab() {
await this.page.getByRole('tab', { name: 'Metadata' }).click();
}
async showSettings() {
await this.page.getByRole('button', { name: 'Settings' }).click();
}
@step
async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise<FrameLocator> {
await this.selectAction(actionName, ordinal);
while (this.page.frames().length < (hasSubframe ? 4 : 3))
await this.page.waitForEvent('frameattached');
return this.page.frameLocator('iframe.snapshot-visible[name=snapshot]');
}
}
export const traceViewerFixtures: Fixtures<TraceViewerFixtures, {}, BaseTestFixtures, BaseWorkerFixtures> = {
showTraceViewer: async ({ playwright, childProcess, browserName }, use, testInfo) => {
const browsers: Browser[] = [];
const tracings: any[] = [];
await use(async (trace: string | undefined, { host, port, stdin } = {}) => {
const command = [
'node',
path.join(__dirname, '../../packages/playwright-core/cli.js'),
'show-trace',
'--port', '' + (port ?? '0'),
];
if (host)
command.push('--host', host);
if (stdin)
command.push('--stdin');
if (trace)
command.push(trace);
const cp = childProcess({ command });
await cp.waitForOutput('Listening on');
const browser = await playwright.chromium.launch({
...(browserName === 'chromium' ? {} : { channel: 'chromium' }),
executablePath: process.env.CRPATH, // without this, setting FFPATH makes us launch Firefox with Chromium args
});
browsers.push(browser);
const page = await browser.newPage();
if (process.env.PWTEST_DEBUG_TRACE_VIEWER) {
const tracing = page.context().tracing;
await tracing.start({ snapshots: true, screenshots: true });
tracings.push(tracing);
}
const url = cp.output.match(/Listening on (http:\/\/[^\s]+)/)![1];
await page.goto(url);
return new TraceViewerPage(page, cp);
});
for (const [index, tracing] of tracings.entries()) {
const path = testInfo.outputPath(`viewer-trace-${index}.zip`);
await tracing.stop({ path });
await testInfo.attach(`viewer-trace-${index}.zip`, { path, contentType: 'application/zip' });
}
for (const browser of browsers)
await browser.close();
},
runAndTrace: async ({ context, showTraceViewer }, use, testInfo) => {
await use(async (body: () => Promise<void>, optsOverrides = {}) => {
const traceFile = testInfo.outputPath('trace.zip');
await context.tracing.start({ snapshots: true, screenshots: true, sources: true, ...optsOverrides });
await body();
await context.tracing.stop({ path: traceFile });
if (process.env.PWTEST_DEBUG_TRACE_VIEWER)
await testInfo.attach('recorded-trace.zip', { path: traceFile, contentType: 'application/zip' });
return showTraceViewer(traceFile);
});
},
};

View File

@@ -0,0 +1,269 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* 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 { tools } from '../../packages/playwright-core/lib/coreBundle';
import { utils, iso } from '../../packages/playwright-core/lib/coreBundle';
import type { iso as isoType } from '../../packages/playwright-core/lib/coreBundle';
import type { Locator, Frame, Page } from 'playwright-core';
import type { StackFrame } from '../../packages/protocol/src/channels';
import type { ActionTraceEvent, TraceEvent } from '@trace/trace';
const { TraceLoader, TraceModel } = iso;
type TraceModel = InstanceType<typeof TraceModel>;
type SnapshotStorage = isoType.SnapshotStorage;
export type BoundingBox = Awaited<ReturnType<Locator['boundingBox']>>;
export async function attachFrame(page: Page, frameId: string, url: string): Promise<Frame> {
const handle = await page.evaluateHandle(async ({ frameId, url }) => {
const frame = document.createElement('iframe');
frame.src = url;
frame.id = frameId;
document.body.appendChild(frame);
await new Promise(x => frame.onload = x);
return frame;
}, { frameId, url });
return handle.asElement().contentFrame() as Promise<Frame>;
}
export async function detachFrame(page: Page, frameId: string) {
await page.evaluate(frameId => {
document.getElementById(frameId)!.remove();
}, frameId);
}
export async function verifyViewport(page: Page, width: number, height: number) {
// `expect` may clash in test runner tests if imported eagerly.
const { expect } = require('@playwright/test');
expect(page.viewportSize()!.width).toBe(width);
expect(page.viewportSize()!.height).toBe(height);
expect(await page.evaluate('window.innerWidth')).toBe(width);
expect(await page.evaluate('window.innerHeight')).toBe(height);
}
export function expectedSSLError(browserName: string, platform: string, channel: string | undefined): RegExp {
if (browserName === 'chromium')
return /net::(ERR_CERT_AUTHORITY_INVALID|ERR_CERT_INVALID)/;
if (browserName === 'webkit') {
if (platform === 'darwin')
return /The certificate for this server is invalid/;
else if (platform === 'win32' && channel !== 'webkit-wsl')
return /SSL peer certificate or SSH remote key was not OK/;
else
return /Unacceptable TLS certificate|Operation was cancelled/;
}
if (browserName === 'firefox' && isBidiChannel(channel))
return /MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT/;
return /SSL_ERROR_UNKNOWN/;
}
export function isBidiChannel(channel: string | undefined): boolean {
return channel?.startsWith('bidi-chrom') || channel?.startsWith('moz-firefox') || false;
}
export function chromiumVersionLessThan(a: string, b: string) {
const left: number[] = a.split('.').map(e => Number(e));
const right: number[] = b.split('.').map(e => Number(e));
for (let i = 0; i < 4; i++) {
if (left[i] > right[i])
return false;
if (left[i] < right[i])
return true;
}
return false;
}
let didSuppressUnverifiedCertificateWarning = false;
let originalEmitWarning: (warning: string | Error, ...args: any[]) => void;
export function suppressCertificateWarning() {
if (didSuppressUnverifiedCertificateWarning)
return;
didSuppressUnverifiedCertificateWarning = true;
// Suppress one-time warning:
// https://github.com/nodejs/node/blob/1bbe66f432591aea83555d27dd76c55fea040a0d/lib/internal/options.js#L37-L49
originalEmitWarning = process.emitWarning;
process.emitWarning = (warning, ...args) => {
if (typeof warning === 'string' && warning.includes('NODE_TLS_REJECT_UNAUTHORIZED')) {
process.emitWarning = originalEmitWarning;
return;
}
return originalEmitWarning.call(process, warning, ...args);
};
}
export async function parseTraceRaw(file: string): Promise<{ events: any[], resources: Map<string, Buffer>, actions: string[], actionObjects: ActionTraceEvent[], stacks: Map<string, StackFrame[]> }> {
const zipFS = new utils.ZipFile(file);
const resources = new Map<string, Buffer>();
for (const entry of await zipFS.entries())
resources.set(entry, await zipFS.read(entry));
zipFS.close();
const actionMap = new Map<string, ActionTraceEvent>();
const events: any[] = [];
for (const traceFile of [...resources.keys()].filter(name => name.endsWith('.trace'))) {
for (const line of resources.get(traceFile)!.toString().split('\n')) {
if (line) {
const event = JSON.parse(line) as TraceEvent;
events.push(event);
if (event.type === 'before') {
const action: ActionTraceEvent = {
...event,
type: 'action',
endTime: 0,
};
actionMap.set(event.callId, action);
} else if (event.type === 'input') {
const existing = actionMap.get(event.callId);
existing.inputSnapshot = event.inputSnapshot;
existing.point = event.point;
} else if (event.type === 'after') {
const existing = actionMap.get(event.callId);
existing.afterSnapshot = event.afterSnapshot;
existing.endTime = event.endTime;
existing.error = event.error;
existing.result = event.result;
}
}
}
}
for (const networkFile of [...resources.keys()].filter(name => name.endsWith('.network'))) {
for (const line of resources.get(networkFile)!.toString().split('\n')) {
if (line)
events.push(JSON.parse(line));
}
}
const stacks: Map<string, StackFrame[]> = new Map();
for (const stacksFile of [...resources.keys()].filter(name => name.endsWith('.stacks'))) {
for (const [key, value] of iso.parseClientSideCallMetadata(JSON.parse(resources.get(stacksFile)!.toString())))
stacks.set(key, value);
}
const actionObjects = [...actionMap.values()];
actionObjects.sort((a, b) => a.startTime - b.startTime);
return {
events,
resources,
actions: actionObjects.map(a => iso.renderTitleForCall({ ...a, type: a.class })),
actionObjects,
stacks,
};
}
export async function parseTrace(file: string): Promise<{ snapshots: SnapshotStorage, model: TraceModel }> {
const dir = file + '.extracted';
await tools.extractTrace(file, dir);
const backend = new tools.DirTraceLoaderBackend(dir);
const loader = new TraceLoader();
await loader.load(backend);
return { model: new TraceModel(dir, loader.contextEntries), snapshots: loader.storage() };
}
export async function parseHar(file: string): Promise<Map<string, Buffer>> {
const zipFS = new utils.ZipFile(file);
const resources = new Map<string, Buffer>();
for (const entry of await zipFS.entries())
resources.set(entry, await zipFS.read(entry));
zipFS.close();
return resources;
}
export function waitForTestLog<T>(page: Page, prefix: string): Promise<T> {
return new Promise<T>(resolve => {
page.on('console', message => {
const text = message.text();
if (text.startsWith(prefix)) {
const json = text.substring(prefix.length);
resolve(JSON.parse(json));
}
});
});
}
export async function rafraf(target: Page | Frame, count = 1) {
for (let i = 0; i < count; i++) {
await target.evaluate(async () => {
await new Promise(f => window.builtins.requestAnimationFrame(() => window.builtins.requestAnimationFrame(f)));
});
}
}
export async function ensureSomeFrames(page: Page) {
await rafraf(page, 100);
await page.screenshot();
}
export function roundBox(box: BoundingBox): BoundingBox {
return {
x: Math.round(box.x),
y: Math.round(box.y),
width: Math.round(box.width),
height: Math.round(box.height),
};
}
export function unshift(snapshot: string): string {
const lines = snapshot.split('\n');
let whitespacePrefixLength = 100;
for (const line of lines) {
if (!line.trim())
continue;
const match = line.match(/^(\s*)/);
if (match && match[1].length < whitespacePrefixLength)
whitespacePrefixLength = match[1].length;
}
return lines.filter(t => t.trim()).map(line => line.substring(whitespacePrefixLength)).join('\n');
}
const ansiRegex = new RegExp('[\\u001B\\u009B][[\\]()#?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{0,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', 'g');
export function stripAnsi(str: string): string {
return str.replace(ansiRegex, '');
}
export function inheritAndCleanEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
return {
...process.env,
// BEGIN: Reserved CI
CI: undefined,
BUILD_URL: undefined,
CI_COMMIT_SHA: undefined,
CI_JOB_URL: undefined,
CI_PROJECT_URL: undefined,
GITHUB_ACTIONS: undefined,
GITHUB_REPOSITORY: undefined,
GITHUB_RUN_ID: undefined,
GITHUB_SERVER_URL: undefined,
GITHUB_SHA: undefined,
GITHUB_EVENT_PATH: undefined,
// END: Reserved CI
PW_TEST_HTML_REPORT_OPEN: undefined,
PLAYWRIGHT_HTML_OPEN: undefined,
PW_TEST_DEBUG_REPORTERS: undefined,
PW_TEST_REPORTER: undefined,
PW_TEST_REPORTER_WS_ENDPOINT: undefined,
PW_TEST_SOURCE_TRANSFORM: undefined,
PW_TEST_SOURCE_TRANSFORM_SCOPE: undefined,
PWTEST_BOT_NAME: undefined,
PWTEST_SHARD_WEIGHTS: undefined,
TEST_WORKER_INDEX: undefined,
TEST_PARALLEL_INDEX: undefined,
NODE_OPTIONS: undefined,
...env,
};
}