Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion packages/isomorphic/stringUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ export function parseRegex(regex: string): RegExp {
return new RegExp(source, flags);
}

export const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g');
// Semicolons removed from [[\]()#;?] to avoid polynomial backtracking
// when both that group and (?:;...)* can match runs of semicolons.
// \d{1,4} relaxed to \d{0,4} so empty params (e.g. ESC[;H) still match.
export 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 stripAnsiEscapes(str: string): string {
return str.replace(ansiRegex, '');
}
32 changes: 21 additions & 11 deletions packages/playwright-core/src/tools/cli-client/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import { minimist } from './minimist';
import type { ListData, ListedBrowser, Output } from './output';
import type { ClientInfo, SessionFile } from './registry';
import type { MinimistArgs } from './minimist';
import type { Readable } from 'stream';

type GlobalOptions = {
help?: boolean;
Expand Down Expand Up @@ -230,35 +229,46 @@ export async function program(options?: { embedderVersion?: string}) {
const foreground = args.port !== undefined;
const child = spawn(process.execPath, daemonArgs, {
detached: !foreground,
stdio: foreground ? 'inherit' : ['ignore', 'ignore', 'ignore', 'pipe'],
stdio: foreground ? 'inherit' : ['pipe', 'pipe', 'ignore'],
});
if (foreground) {
await new Promise<void>(resolve => child.on('exit', () => resolve()));
return;
}
const readyStream = (child.stdio as unknown as Readable[])[3];
try {
await new Promise<void>((resolve, reject) => {
let outLog = '';
const settle = (err?: Error) => {
clearTimeout(timer);
readyStream.destroy();
child.stdout!.removeAllListeners();
child.removeAllListeners('exit');
if (err)
reject(err);
else
resolve();
};
const timer = setTimeout(() => settle(new Error('Dashboard daemon did not spin up within 60s, killing it')), 60_000);
readyStream.once('data', () => settle());
readyStream.once('error', err => settle(err));
child.once('exit', (code, signal) => settle(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY`)));
const timer = setTimeout(() => settle(new Error('Dashboard daemon did not spin up within 60s')), 60_000);
child.stdout!.on('data', data => {
outLog += data.toString();
if (!outLog.includes('<EOF>'))
return;
if (outLog.match(/### Success\n[\s\S]*<EOF>/))
settle();
else
settle(new Error(outLog.trim()));
});
child.stdout!.once('error', err => settle(err));
child.once('exit', (code, signal) => settle(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY${outLog ? '\n' + outLog : ''}`)));
});
} catch (err) {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
child.stdin!.destroy();
child.stdout!.destroy();
if (child.exitCode === null && child.signalCode === null)
await new Promise<void>(resolve => child.once('exit', () => resolve()));
}
throw err;
}
child.stdin!.destroy();
child.stdout!.destroy();
child.unref();
output.show(sessionName, child.pid);
return;
Expand Down
16 changes: 6 additions & 10 deletions packages/playwright-core/src/tools/cli-client/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,21 +168,17 @@ export class Session {
outLog += data.toString();
if (!outLog.includes('<EOF>'))
return;
const errorMatch = outLog.match(/### Error\n([\s\S]*)<EOF>/);
const error = errorMatch ? errorMatch[1].trim() : undefined;
if (error) {
const errLogContent = fs.readFileSync(errLog, 'utf-8');
rejectWithPid(reject, error + (errLogContent ? '\n' + errLogContent : ''));
}

const successMatch = outLog.match(/### Success\nDaemon listening on (.*)\n<EOF>/);
if (successMatch)
if (outLog.match(/### Success\nDaemon listening on (.*)\n<EOF>/)) {
resolve();
return;
}
const errLogContent = fs.readFileSync(errLog, 'utf-8');
rejectWithPid(reject, outLog.trim() + (errLogContent ? '\n' + errLogContent : ''));
});
child.on('close', code => {
if (!signalled) {
const errLogContent = fs.readFileSync(errLog, 'utf-8');
rejectWithPid(reject, `Daemon process exited with code ${code}` + (errLogContent ? '\n' + errLogContent : ''));
rejectWithPid(reject, `Daemon process exited with code ${code}` + (outLog ? '\n' + outLog : '') + (errLogContent ? '\n' + errLogContent : ''));
}
});
});
Expand Down
44 changes: 36 additions & 8 deletions packages/playwright-core/src/tools/dashboard/dashboardApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,15 +311,44 @@ export async function openDashboardApp() {
selfDestructOnParentGone();
return;
}
let server: net.Server | undefined;
process.on('exit', () => server?.close());
// Self-destruct if the parent CLI dies before we signal READY. Unregistered
// before we signal so the daemon outlives the parent.
const stopSelfDestruct = selfDestructOnParentGone();
let server: net.Server;
try {
server = await acquireSingleton(options);
} catch {
// Another daemon is already running; acquireSingleton forwarded our
// options to it. Signal success so the parent doesn't treat our clean
// exit as a startup failure.
stopSelfDestruct();
// eslint-disable-next-line no-console
console.log('### Success\nDashboard already running');
// eslint-disable-next-line no-console
console.log('<EOF>');
return;
}
process.on('exit', () => server.close());
try {
await startApp(server, options);
stopSelfDestruct();
// eslint-disable-next-line no-console
console.log('### Success\nDashboard ready');
// eslint-disable-next-line no-console
console.log('<EOF>');
} catch (error) {
const message = (error as Error).stack || (error as Error).message;
// eslint-disable-next-line no-console
console.log(`### Error\n${message}`);
// eslint-disable-next-line no-console
console.log('<EOF>');
gracefullyProcessExitDoNotHang(1);
}
}

async function startApp(server: net.Server, options: DashboardOptions) {
const statePromise = innerOpenDashboardApp(options);
server?.on('connection', socket => {
server.on('connection', socket => {
let buffer = '';
socket.on('data', data => {
buffer += data.toString();
Expand Down Expand Up @@ -356,7 +385,6 @@ export async function openDashboardApp() {
});
});
await statePromise;
try { fs.writeSync(3, '.'); fs.closeSync(3); } catch {}
}

export async function openDashboardForContext(context: api.BrowserContext): Promise<void> {
Expand Down Expand Up @@ -426,8 +454,8 @@ async function runAnnotateClient(options: DashboardOptions): Promise<void> {
console.log(text);
}

function selfDestructOnParentGone() {
process.stdin.on('close', () => {
gracefullyProcessExitDoNotHang(0);
});
function selfDestructOnParentGone(): () => void {
const onClose = () => gracefullyProcessExitDoNotHang(0);
process.stdin.on('close', onClose);
return () => process.stdin.off('close', onClose);
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ export class DashboardConnection implements Transport {
this._pushSessions();
void this._tryRevealPending();
});
this._provider.on(SessionProviderEvent.TabsChanged, () => this._pushTabs());
this._provider.on(SessionProviderEvent.TabsChanged, () => {
this._pushTabs();
void this._tryRevealPending();
});
this._provider.on(SessionProviderEvent.ContextClosed, context => {
if (this._attachedPage?.page.context() === context) {
this._attachedPage.dispose();
Expand Down
2 changes: 1 addition & 1 deletion tests/config/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export function unshift(snapshot: string): string {
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{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', 'g');
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, '');
}
Expand Down
19 changes: 19 additions & 0 deletions tests/library/snapshot-renderer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { test, expect } from '@playwright/test';
import { SnapshotRenderer } from '../../packages/isomorphic/trace/snapshotRenderer';
import { LRUCache } from '../../packages/isomorphic/lruCache';
import { stripAnsiEscapes } from '../../packages/isomorphic/stringUtils';
import type { FrameSnapshot } from '../../packages/trace/src/snapshot';

function makeSnapshot(overrides: Partial<FrameSnapshot> = {}): FrameSnapshot {
Expand Down Expand Up @@ -111,3 +112,21 @@ test('snapshot renderer handles case-insensitive iframe tag names', () => {
expect(html).toContain('__playwright_srcdoc__');
expect(html).toContain('__playwright_src__');
});

test('stripAnsiEscapes should not exhibit polynomial backtracking', () => {
// \x1b[ + 50000 semicolons + non-terminal character.
// Before the fix this took >3 seconds due to O(n^2) backtracking.
const payload = '\x1b[' + ';'.repeat(50000) + '!';
const result = stripAnsiEscapes(payload);
// The ESC[ prefix is not a complete ANSI sequence, so it stays in the output.
expect(result).toContain('!');
});

test('stripAnsiEscapes handles common ANSI sequences after fix', () => {
expect(stripAnsiEscapes('\x1b[31mred\x1b[0m')).toBe('red');
expect(stripAnsiEscapes('\x1b[0;31mbold red\x1b[0m')).toBe('bold red');
expect(stripAnsiEscapes('\x1b[;H')).toBe('');
expect(stripAnsiEscapes('\x1b[2J')).toBe('');
expect(stripAnsiEscapes('\x1b[38;2;255;0;0mcolored\x1b[0m')).toBe('colored');
expect(stripAnsiEscapes('hello\x1b[32mworld\x1b[0m!')).toBe('helloworld!');
});
19 changes: 8 additions & 11 deletions utils/build/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -870,17 +870,14 @@ steps.push(new ProgramStep({
concurrent: true,
}));

// Build/watch web packages.
// HMR: in watch mode the dashboard, html-reporter, and trace viewer (incl. UI
// mode) are served by embedded Vite dev servers, so skip their
// `vite build --watch` steps. Set PW_HMR_STATIC=1 to keep the watch builds for
// testing the bundled output. Recorder is not yet HMR'd. The trace viewer
// service worker still builds via vite.sw.config.ts above — that step is not
// in this loop.
const hmrReplacesWebBuilds = watchMode && process.env.PW_HMR_STATIC !== '1';
const hmrHandledPackages = new Set(['dashboard', 'html-reporter', 'trace-viewer']);
const webPackages = ['html-reporter', 'recorder', 'trace-viewer', 'dashboard']
.filter(pkg => !(hmrReplacesWebBuilds && hmrHandledPackages.has(pkg)));
// Build/watch web packages. The html-reporter, trace-viewer, and dashboard
// also have embedded Vite dev servers used when viewing reports/traces/the
// dashboard live, but their bundled output is consumed as a static artifact
// in other code paths (e.g. HtmlBuilder.build() reads lib/vite/htmlReport/
// and lib/vite/traceViewer/), so we always keep the static build alongside
// HMR. Recorder is not yet HMR'd. The trace viewer service worker still
// builds via vite.sw.config.ts above — that step is not in this loop.
const webPackages = ['html-reporter', 'recorder', 'trace-viewer', 'dashboard'];
for (const webPackage of webPackages) {
steps.push(new ProgramStep({
command: 'npx',
Expand Down
Loading