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
2 changes: 1 addition & 1 deletion packages/injected/src/roleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
if (labels.length)
return getAccessibleNameFromAssociatedLabels(labels, options);

const usePlaceholder = (tagName === 'INPUT' && ['text', 'password', 'search', 'tel', 'email', 'url'].includes((element as HTMLInputElement).type)) || tagName === 'TEXTAREA';
const usePlaceholder = (tagName === 'INPUT' && ['text', 'password', 'number', 'search', 'tel', 'email', 'url'].includes((element as HTMLInputElement).type)) || tagName === 'TEXTAREA';
const placeholder = element.getAttribute('placeholder') || '';
const title = element.getAttribute('title') || '';
if (!usePlaceholder || title)
Expand Down
2 changes: 1 addition & 1 deletion packages/isomorphic/selectorParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export function stringifySelector(selector: string | ParsedSelector, forceEngine
if (!forceEngineName && i !== selector.capture) {
if (p.name === 'css')
includeEngine = false;
else if (p.name === 'xpath' && p.source.startsWith('//') || p.source.startsWith('..'))
else if (p.name === 'xpath' && (p.source.startsWith('//') || p.source.startsWith('..')))
includeEngine = false;
}
const prefix = includeEngine ? p.name + '=' : '';
Expand Down
2 changes: 1 addition & 1 deletion packages/isomorphic/timeoutRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function raceAgainstDeadline<T>(cb: () => Promise<T>, deadline: num
});
}

export async function pollAgainstDeadline<T>(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, pollIntervals: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> {
export async function pollAgainstDeadline<T>(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, [...pollIntervals]: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> {
const lastPollInterval = pollIntervals.pop() ?? 1000;
let lastResult: T|undefined;
const wrappedCallback = () => Promise.resolve().then(callback);
Expand Down
4 changes: 2 additions & 2 deletions packages/isomorphic/urlMatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,8 @@ export function urlMatches(baseURL: string | undefined, urlString: string, match
if (isString(match))
match = new RegExp(resolveGlobToRegexPattern(baseURL, match, webSocketUrl));
if (isRegExp(match)) {
const r = match.test(urlString);
return r;
match.lastIndex = 0;
return match.test(urlString);
}
const url = parseURL(urlString);
if (!url)
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/client/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
if (typeof optionsOrEndpoint === 'string')
return await this._connect({ ...options, endpoint: optionsOrEndpoint });
assert(optionsOrEndpoint.wsEndpoint, 'options.wsEndpoint is required');
return await this._connect({ ...options, endpoint: optionsOrEndpoint.wsEndpoint });
return await this._connect({ ...optionsOrEndpoint, endpoint: optionsOrEndpoint.wsEndpoint });
}

async _connect(params: ConnectOptions): Promise<Browser> {
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ export abstract class APIRequestContext extends SdkObject {

let body: Readable = response;
let transform: Transform | undefined;
const encoding = response.headers['content-encoding'];
const encoding = response.headers['content-encoding']?.toLowerCase();
if (encoding === 'gzip' || encoding === 'x-gzip') {
transform = zlib.createGunzip({
flush: zlib.constants.Z_SYNC_FLUSH,
Expand Down
42 changes: 26 additions & 16 deletions packages/playwright-core/src/server/har/harTracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ export class HarTracer {
if (!this._options.omitCookies)
harEntry.request.cookies = event.cookies;
harEntry.request.headers = Object.entries(event.headers).map(([name, value]) => ({ name, value }));
harEntry.request.postData = this._postDataForBuffer(event.postData || null, event.headers['content-type'], this._options.content);
const contentType = Object.entries(event.headers).find(([name]) => name.toLowerCase() === 'content-type')?.[1];
harEntry.request.postData = this._postDataForBuffer(event.postData || null, contentType, this._options.content);
if (!this._options.omitSizes)
harEntry.request.bodySize = event.postData?.length || 0;
(event as any)[this._entrySymbol] = harEntry;
Expand Down Expand Up @@ -255,7 +256,7 @@ export class HarTracer {
harEntry.response.cookies = this._options.omitCookies ? [] : event.cookies.map(c => {
return {
...c,
expires: c.expires === -1 ? undefined : safeDateToISOString(c.expires)
expires: c.expires === -1 ? undefined : safeDateToISOString(c.expires * 1000)
};
});

Expand Down Expand Up @@ -800,20 +801,29 @@ function parseCookie(c: string): har.Cookie {
continue;
}

if (name === 'Domain')
cookie.domain = value;
if (name === 'Expires')
cookie.expires = safeDateToISOString(value);
if (name === 'HttpOnly')
cookie.httpOnly = true;
if (name === 'Max-Age')
cookie.expires = safeDateToISOString(Date.now() + (+value) * 1000);
if (name === 'Path')
cookie.path = value;
if (name === 'SameSite')
cookie.sameSite = value;
if (name === 'Secure')
cookie.secure = true;
switch (name.toLowerCase()) {
case 'domain':
cookie.domain = value;
break;
case 'expires':
cookie.expires = safeDateToISOString(value);
break;
case 'httponly':
cookie.httpOnly = true;
break;
case 'max-age':
cookie.expires = safeDateToISOString(Date.now() + (+value) * 1000);
break;
case 'path':
cookie.path = value;
break;
case 'samesite':
cookie.sameSite = value;
break;
case 'secure':
cookie.secure = true;
break;
}
}
return cookie;
}
Expand Down
1 change: 0 additions & 1 deletion packages/playwright-core/src/tools/backend/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,6 @@ export class Tab extends EventEmitter<TabEventsInterface> {
}

private _handleRequestFailed(request: playwright.Request) {
this._requests.push(request);
const timing = request.timing();
const wallTime = timing.responseEnd + timing.startTime;
this._addLogEntry({ type: 'request', wallTime, request });
Expand Down
156 changes: 76 additions & 80 deletions packages/playwright-core/src/tools/dashboard/dashboardApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ declare const __PW_HMR__: boolean;

type DashboardServer = {
url: string;
reveal: (options: DashboardOptions) => Promise<void>;
reveal: (options: DashboardOptions) => void;
triggerAnnotate: (signal: AbortSignal) => Promise<AnnotateResult>;
close: () => Promise<void>;
};
Expand Down Expand Up @@ -86,21 +86,22 @@ async function startDashboardServer(provider: SessionProvider, options: Dashboar
attachDashboardStaticServer(httpServer, dashboardDir);
await httpServer.start({ port: options.port, host: options.host });

const reveal = async (next: DashboardOptions): Promise<void> => {
await connectionLanded;
await Promise.all([...connections].map(async c => {
if (next.pageId)
await c.revealPage(next.pageId);
else if (next.sessionName)
await c.revealSession(next.sessionName, next.workspaceDir);
}));
const reveal = (next: DashboardOptions): void => {
void connectionLanded.then(() => {
for (const c of connections) {
if (next.pageId)
c.revealPage(next.pageId);
else if (next.sessionName)
c.revealSession(next.sessionName, next.workspaceDir);
}
});
};

const triggerAnnotate = async (cancellation: AbortSignal): Promise<AnnotateResult> => {
await connectionLanded;
if (cancellation.aborted || connections.size === 0)
return { type: 'cancelled' };
// Multiple dashboard connections is theoretical today (one UI per daemon), server mode does not support annotate.
// Multiple dashboard connections is theoretical today (one UI per daemon).
// If two ever land, the first to submit wins but the losers stay in
// annotation mode until their UI reloads — revisit if that becomes a real
// scenario.
Expand Down Expand Up @@ -135,13 +136,7 @@ async function attachDashboardDevServer(httpServer: HttpServer) {
}
// HMR end

async function innerOpenDashboardApp(options: DashboardOptions): Promise<{ page: api.Page; server: DashboardServer }> {
const server = await startDashboardServer(new RegistrySessionProvider(), options);
void server.reveal(options).catch(() => {});
const { page } = await launchApp('dashboard', { onClose: () => gracefullyProcessExitDoNotHang(0) });
await page.goto(server.url);
return { page, server };
}
type AppState = { page?: api.Page; server: DashboardServer };

async function launchApp(appName: string, options?: { onClose?: () => void }) {
const channel = findChromiumChannelBestEffort('javascript');
Expand Down Expand Up @@ -240,13 +235,15 @@ type AcquireResult =
| { role: 'winner', server: net.Server }
| { role: 'loser', daemonPid: number };

async function acquireSingleton(options: DashboardOptions): Promise<AcquireResult> {
async function acquireSingleton(options: DashboardOptions, onConnection: (socket: net.Socket) => void): Promise<AcquireResult> {
const socketPath = dashboardSocketPath();
if (process.platform !== 'win32')
await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });

return await new Promise((resolve, reject) => {
const server = net.createServer();
// Attach the connection handler at creation — before listen() — so a loser
// that connects the instant we win the socket is never dropped.
const server = net.createServer(onConnection);
server.listen(socketPath, () => resolve({ role: 'winner', server }));
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code !== 'EADDRINUSE' && err.code !== 'EEXIST')
Expand Down Expand Up @@ -287,20 +284,13 @@ export async function openDashboardApp() {
// eslint-disable-next-line no-console
console.error('Unhandled promise rejection:', error);
});
if (options.port !== undefined) {
const server = await startDashboardServer(new RegistrySessionProvider(), options);
void server.reveal(options).catch(() => {});
// eslint-disable-next-line no-console
console.log(`Listening on ${server.url}`);
// eslint-disable-next-line no-restricted-properties
await new Promise(f => process.stdout.write('', f)); // Make sure stdout is flushed.
selfDestructOnParentGone();
return;
}

// Self-destruct if the parent CLI dies before we signal READY. Unregistered
// before we signal so the daemon outlives the parent.
const stopSelfDestruct = selfDestructOnParentGone();
const acquired = await acquireSingleton(options);

const statePromise = new ManualPromise<AppState>();
const acquired = await acquireSingleton(options, socket => handleConnection(socket, statePromise));
if (acquired.role === 'loser') {
// Another daemon is already running, signal success.
stopSelfDestruct();
Expand All @@ -313,66 +303,72 @@ export async function openDashboardApp() {
const { server } = acquired;
process.on('exit', () => server.close());
try {
await startApp(server, options);
stopSelfDestruct();
// eslint-disable-next-line no-console
console.log(`Dashboard is running pid=${process.pid}`);
const dashboard = await startDashboardServer(new RegistrySessionProvider(), options);
dashboard.reveal(options);
if (options.port !== undefined) {
// Server mode serves HTTP in the foreground and stays tied to the parent CLI.
statePromise.resolve({ server: dashboard });
// eslint-disable-next-line no-console
console.log(`Listening on ${dashboard.url}`);
} else {
// Windowed daemon launches a browser window and detaches from the parent CLI.
const { page } = await launchApp('dashboard', { onClose: () => gracefullyProcessExitDoNotHang(0) });
await page.goto(dashboard.url);
statePromise.resolve({ page, server: dashboard });
stopSelfDestruct();
// eslint-disable-next-line no-console
console.log(`Dashboard is running pid=${process.pid}`);
}
// eslint-disable-next-line no-restricted-properties
await new Promise(f => process.stdout.write('', f)); // Make sure stdout is flushed.
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
gracefullyProcessExitDoNotHang(1);
}
}

async function startApp(server: net.Server, options: DashboardOptions) {
const statePromise = innerOpenDashboardApp(options);
server.on('connection', socket => {
let buffer = '';
socket.on('data', async data => {
buffer += data.toString();
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex === -1)
return;
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
let parsed: DashboardOptions | undefined;
function handleConnection(socket: net.Socket, statePromise: Promise<AppState>) {
let buffer = '';
socket.on('data', async data => {
buffer += data.toString();
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex === -1)
return;
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
let parsed: DashboardOptions | undefined;
try {
parsed = JSON.parse(line);
} catch {
// no-op
}
if (!parsed) {
socket.end();
return;
}
const { page, server: dashboard } = await statePromise;
if (parsed.annotate) {
const cancellation = new AbortController();
socket.on('close', () => cancellation.abort());
socket.on('error', () => cancellation.abort());
try {
parsed = JSON.parse(line);
} catch {
// no-op
await page?.bringToFront();
dashboard.reveal(parsed);
const result = await dashboard.triggerAnnotate(cancellation.signal);
socket.end(JSON.stringify(result));
} catch (e) {
socket.end(e);
}
if (!parsed) {
socket.end();
return;
}
const { page, server: dashboard } = await statePromise;
if (parsed.annotate) {
const cancellation = new AbortController();
socket.on('close', () => cancellation.abort());
socket.on('error', () => cancellation.abort());
try {
await page?.bringToFront();
await dashboard.reveal(parsed);
const result = await dashboard.triggerAnnotate(cancellation.signal);
socket.end(JSON.stringify(result));
} catch (e) {
socket.end(e);
}
} else if (parsed.kill) {
await dashboard.close().catch(() => {});
gracefullyProcessExitDoNotHang(0, () => new Promise(r => socket.end(r)));
} else {
try {
await page?.bringToFront();
await dashboard.reveal(parsed);
socket.end(JSON.stringify({ pid: process.pid }) + '\n');
} catch (e) {
socket.end(e);
}
}
});
} else if (parsed.kill) {
await dashboard.close().catch(() => {});
gracefullyProcessExitDoNotHang(0, () => new Promise(r => socket.end(r)));
} else {
void page?.bringToFront().catch(() => {});
dashboard.reveal(parsed);
socket.end(JSON.stringify({ pid: process.pid }) + '\n');
}
});
await statePromise;
}

export async function openDashboardForContext(context: api.BrowserContext): Promise<void> {
Expand Down
Loading
Loading