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 .github/workflows/publish_release_docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Set up Docker QEMU for arm64 docker builds
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
with:
platforms: arm64
- run: npm ci
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/tests_bidi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ jobs:
PWTEST_USE_BIDI_EXPECTATIONS: ${{ matrix.isPullRequest && '1' || '' }}
- name: Upload csv report to GitHub
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: csv-report-${{ matrix.channel }}
path: test-results/report.csv
retention-days: 7

- name: Upload json report to GitHub
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: json-report-${{ matrix.channel }}
path: test-results/report.json
Expand Down
4 changes: 1 addition & 3 deletions docs/src/trace-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -686,9 +686,7 @@ public class WithTestNameAttribute : BeforeAfterTestAttribute
## Trace Viewer features
### Actions

In the Actions tab you can see what locator was used for every action and how long each one took to run. Use the **Filter actions** search field at the top of the list to filter the action hierarchy by text; only actions whose title matches the search (and their parent actions) are shown. Clear the filter to show all actions again; expanded nodes are preserved when the filter is removed.

Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action.
In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action.

<img src="https://github.com/microsoft/playwright/assets/13063165/948b65cd-f0fd-4c7f-8e53-2c632b5a07f1" alt="actions tab in trace viewer" width="3598" height="2218" />

Expand Down
45 changes: 18 additions & 27 deletions packages/devtools/src/grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { navigate } from './index';
import { Screencast } from './screencast';
import { SettingsButton } from './settingsView';

import type { SessionFile } from '../../playwright-core/src/cli/client/registry';
import type { BrowserDescriptor } from '../../playwright-core/src/serverRegistry';
import type { Tab } from './devtoolsChannel';
import type { SessionModel, SessionStatus } from './sessionModel';

Expand All @@ -44,7 +44,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => {
const workspaceGroups = React.useMemo(() => {
const groups = new Map<string, SessionStatus[]>();
for (const session of sessions) {
const key = session.file.config.workspaceDir || 'Global';
const key = session.browserDescriptor.workspaceDir || 'Global';
let list = groups.get(key);
if (!list) {
list = [];
Expand All @@ -53,7 +53,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => {
list.push(session);
}
for (const list of groups.values())
list.sort((a, b) => a.file.config.name.localeCompare(b.file.config.name));
list.sort((a, b) => a.browserDescriptor.title.localeCompare(b.browserDescriptor.title));

// Current workspace first, then alphabetical.
const entries = [...groups.entries()];
Expand Down Expand Up @@ -91,7 +91,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => {
</div>
{isExpanded && (
<div className='session-chips'>
{entries.map(({ file, canConnect }) => <SessionChip key={file.config.socketPath} sessionFile={file} canConnect={canConnect} visible={isExpanded} model={model} />)}
{entries.map(session => <SessionChip key={session.browserDescriptor.pipeName} descriptor={session.browserDescriptor} wsUrl={session.wsUrl} visible={isExpanded} model={model} />)}
</div>
)}
</div>
Expand All @@ -102,16 +102,14 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => {
</div>);
};

const SessionChip: React.FC<{ sessionFile: SessionFile; canConnect: boolean; visible: boolean; model: SessionModel }> = ({ sessionFile, canConnect, visible, model }) => {
const { config } = sessionFile;
const href = '#session=' + encodeURIComponent(config.socketPath);
const wsUrl = model.wsUrls.get(config.socketPath);
const SessionChip: React.FC<{ descriptor: BrowserDescriptor; wsUrl: string | undefined; visible: boolean; model: SessionModel }> = ({ descriptor, wsUrl, visible, model }) => {
const href = '#session=' + encodeURIComponent(descriptor.pipeName!);

const channel = React.useMemo(() => {
if (!canConnect || !visible || !wsUrl)
if (!wsUrl || !visible)
return undefined;
return DevToolsClient.create(wsUrl);
}, [canConnect, visible, wsUrl]);
}, [wsUrl, visible]);

const [selectedTab, setSelectedTab] = React.useState<Tab | undefined>();

Expand All @@ -129,28 +127,27 @@ const SessionChip: React.FC<{ sessionFile: SessionFile; canConnect: boolean; vis
};
}, [channel]);

const chipTitle = selectedTab ? `[${config.name}] ${selectedTab.url} \u2014 ${selectedTab.title}` : config.name;
const clickable = canConnect && wsUrl !== null;
const chipTitle = selectedTab ? `[${descriptor.title}] ${selectedTab.url} \u2014 ${selectedTab.title}` : descriptor.title;

return (
<a className={'session-chip' + (canConnect ? '' : ' disconnected') + (wsUrl === null ? ' not-supported' : '')} href={clickable ? href : undefined} title={chipTitle} onClick={e => {
<a className={'session-chip' + (wsUrl ? '' : ' disconnected')} href={wsUrl ? href : undefined} title={chipTitle} onClick={e => {
e.preventDefault();
if (clickable)
if (wsUrl)
navigate(href);
}}>
<div className='session-chip-header'>
<div className={'session-status-dot ' + (canConnect ? 'open' : 'closed')} />
<div className={'session-status-dot ' + (wsUrl ? 'open' : 'closed')} />
<span className='session-chip-name'>
{selectedTab ? <>[{config.name}] {selectedTab.url} <span className='session-chip-title'>&mdash; {selectedTab.title}</span></> : config.name}
{selectedTab ? <>[{descriptor.title}] {selectedTab.url} <span className='session-chip-title'>&mdash; {selectedTab.title}</span></> : descriptor.title}
</span>
{canConnect && (
{wsUrl && (
<button
className='session-chip-action'
title='Close session'
onClick={e => {
e.preventDefault();
e.stopPropagation();
void model.closeSession(sessionFile);
void model.closeSession(descriptor);
}}
>
<svg viewBox='0 0 12 12' fill='none' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round'>
Expand All @@ -159,14 +156,14 @@ const SessionChip: React.FC<{ sessionFile: SessionFile; canConnect: boolean; vis
</svg>
</button>
)}
{!canConnect && (
{!wsUrl && (
<button
className='session-chip-action'
title='Delete session data'
onClick={e => {
e.preventDefault();
e.stopPropagation();
void model.deleteSessionData(sessionFile);
void model.deleteSessionData(descriptor);
}}
>
<svg viewBox='0 0 16 16' fill='none' stroke='currentColor' strokeWidth='1.2' strokeLinecap='round' strokeLinejoin='round'>
Expand All @@ -179,13 +176,7 @@ const SessionChip: React.FC<{ sessionFile: SessionFile; canConnect: boolean; vis
</div>
<div className='screencast-container'>
{channel && <Screencast channel={channel} />}
{!canConnect && <div className='screencast-placeholder'>Session closed</div>}
{canConnect && !channel && wsUrl === null && <div className='screencast-placeholder'>
Session v{sessionFile.config.version} is not compatible with this viewer{model.clientInfo ? ` v${model.clientInfo.version}` : ''}.
<br />
Please update playwright-cli and restart this with "playwright-cli show".
</div>}
{canConnect && !channel && wsUrl === undefined && <div className='screencast-placeholder'>Connecting</div>}
{!wsUrl && <div className='screencast-placeholder'>Session closed</div>}
</div>
</a>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/devtools/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const App: React.FC = () => {
}, []);

if (socketPath) {
const wsUrl = model.wsUrls.get(socketPath);
const wsUrl = model.sessionBySocketPath(socketPath)?.wsUrl;
return <DevTools wsUrl={wsUrl || undefined} />;
}
return <Grid model={model} />;
Expand Down
47 changes: 11 additions & 36 deletions packages/devtools/src/sessionModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,23 @@
* limitations under the License.
*/

import type { ClientInfo, SessionFile } from '../../playwright-core/src/cli/client/registry';
import type { ClientInfo } from '../../playwright-core/src/cli/client/registry';
import type { BrowserDescriptor } from '../../playwright-core/src/serverRegistry';

export type SessionStatus = {
file: SessionFile;
canConnect: boolean;
browserDescriptor: BrowserDescriptor;
wsUrl?: string;
};


type Listener = () => void;

export class SessionModel {
sessions: SessionStatus[] = [];
readonly wsUrls: Map<string, string | null> = new Map();
clientInfo: ClientInfo | undefined;
error: string | undefined;
loading = true;

private _knownTimestamps = new Map<string, number>();
private _pollActive = false;
private _pollTimeout: ReturnType<typeof setTimeout> | undefined;
private _lastJson = '';
Expand Down Expand Up @@ -67,7 +67,7 @@ export class SessionModel {
}

sessionBySocketPath(socketPath: string): SessionStatus | undefined {
return this.sessions.find(s => s.file.config.socketPath === socketPath);
return this.sessions.find(s => s.browserDescriptor.pipeName === socketPath);
}

private async _fetchSessions() {
Expand All @@ -84,10 +84,7 @@ export class SessionModel {
this.clientInfo = data.clientInfo;
this._notify();

for (const session of this.sessions) {
if (session.canConnect)
this._obtainDevtoolsUrl(session.file);
}

}
this.error = undefined;
} catch (e: any) {
Expand All @@ -102,46 +99,24 @@ export class SessionModel {
await this._fetchSessions();
}

async closeSession(sessionFile: SessionFile) {
async closeSession(descriptor: BrowserDescriptor) {
await fetch('/api/sessions/close', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionFile }),
body: JSON.stringify({ browserDescriptor: descriptor }),
});
await this._fetchSessions();
}

async deleteSessionData(sessionFile: SessionFile) {
async deleteSessionData(descriptor: BrowserDescriptor) {
await fetch('/api/sessions/delete-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionFile }),
body: JSON.stringify({ browserDescriptor: descriptor }),
});
await this._fetchSessions();
}

private _obtainDevtoolsUrl(sessionFile: SessionFile) {
const { config } = sessionFile;
if (this._knownTimestamps.get(config.socketPath) === config.timestamp)
return;
this._knownTimestamps.set(config.socketPath, config.timestamp);
fetch('/api/sessions/devtools-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionFile }),
}).then(async resp => {
if (resp.ok) {
const { url } = await resp.json();
this.wsUrls.set(config.socketPath, url);
} else {
this.wsUrls.set(config.socketPath, null);
}
this._notify();
}).catch(() => {
this._knownTimestamps.delete(config.socketPath);
});
}

dispose() {
this.stopPolling();
this._listeners.clear();
Expand Down
22 changes: 14 additions & 8 deletions packages/playwright-core/src/cli/daemon/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,17 @@ export function decorateCLICommand(command: Command, version: string) {
try {
const browser = await createBrowser(mcpConfig, mcpClientInfo);
const browserContext = mcpConfig.browser.isolated ? await browser.newContext(mcpConfig.browser.contextOptions) : browser.contexts()[0];
const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { ...options, exitOnClose: true });
const persistent = options.persistent || options.profile || mcpConfig.browser.userDataDir ? true : undefined;
const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { persistent, exitOnClose: true });
console.log(`### Success\nDaemon listening on ${socketPath}`);
console.log('<EOF>');
try {
await (browser as any)._startServer(sessionName, { workspaceDir: clientInfo.workspaceDir });
browserContext.on('close', () => (browser as any)._stopServer().catch(() => {}));
} catch (error) {
if (!error.message.includes('Server is already running'))
throw error;
}
} catch (error) {
const message = process.env.PWDEBUGIMPL ? (error as Error).stack || (error as Error).message : (error as Error).message;
console.log(`### Error\n${message}`);
Expand All @@ -72,13 +80,9 @@ export async function resolveCLIConfig(clientInfo: ClientInfo, sessionName: stri
} catch {
}

if (!options.persistent && options.profile)
options.persistent = true;

const daemonOverrides = configUtils.configFromCLIOptions({
config: options.config,
browser: options.browser,
isolated: options.persistent === true ? false : undefined,
headless: options.headed ? false : undefined,
extension: options.extension,
userDataDir: options.profile,
Expand All @@ -95,16 +99,18 @@ export async function resolveCLIConfig(clientInfo: ClientInfo, sessionName: stri
browser: {
launchOptions: {
headless: true,
},
isolated: true,
}
}
});

result = configUtils.mergeConfig(result, configInFile);
result = configUtils.mergeConfig(result, daemonOverrides);
result = configUtils.mergeConfig(result, envOverrides);

if (!result.extension && !result.browser.userDataDir) {
if (result.browser.isolated === undefined)
result.browser.isolated = !options.profile && !options.persistent && !result.browser.userDataDir;

if (!result.extension && !result.browser.isolated && !result.browser.userDataDir) {
// No custom value provided, use the daemon data dir.
const browserToken = result.browser.launchOptions?.channel ?? result.browser?.browserName;
const userDataDir = path.resolve(clientInfo.daemonProfilesDir, `ud-${sessionName}-${browserToken}`);
Expand Down
4 changes: 0 additions & 4 deletions packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,10 +589,6 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
return;
throw new Error(`File access denied: ${filePath} is outside allowed roots. Allowed roots: ${this._allowedDirectories.length ? this._allowedDirectories.join(', ') : 'none'}`);
}

async _devtoolsStart(): Promise<{ url: string }> {
return await this._channel.devtoolsStart();
}
}

async function prepareStorageState(platform: Platform, storageState: string | SetStorageState): Promise<NonNullable<channels.BrowserNewContextParams['storageState']>> {
Expand Down
9 changes: 7 additions & 2 deletions packages/playwright-core/src/client/eventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ import type { EventEmitter as EventEmitterType } from 'events';
import type { Platform } from './platform';
import type { Disposable } from './disposable';

type EventEmitterLike = {
on(eventName: string | symbol, handler: (...args: any[]) => unknown): unknown;
removeListener(eventName: string | symbol, handler: (...args: any[]) => unknown): unknown;
};

type EventType = string | symbol;
type Listener = (...args: any[]) => any;
type EventMap = Record<EventType, Listener | Listener[]>;
Expand Down Expand Up @@ -400,9 +405,9 @@ function wrappedListener(l: Listener): Listener {

class EventsHelper {
static addEventListener(
emitter: EventEmitterType,
emitter: EventEmitterLike,
eventName: (string | symbol),
handler: (...args: any[]) => void): Disposable {
handler: (...args: any[]) => any): Disposable {
emitter.on(eventName, handler);
return {
dispose: async () => { emitter.removeListener(eventName, handler); }
Expand Down
5 changes: 3 additions & 2 deletions packages/playwright-core/src/devtools/DEPS.list
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
../../
../server/registry/index.ts
../server/utils/
../serverRegistry.ts
../utils/
../cli/client/registry.ts
../cli/client/session.ts
../client/connect.ts
../client/eventEmitter.ts
Loading
Loading