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
10 changes: 5 additions & 5 deletions packages/playwright-core/src/server/trace/viewer/traceViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export type TraceViewerAppOptions = {

const tracesDirMarker = 'traces.dir';

function validateTraceUrl(traceFileOrUrl: string | undefined): string | undefined {
function validateTraceUrlOrPath(traceFileOrUrl: string | undefined): string | undefined {
if (!traceFileOrUrl)
return traceFileOrUrl;

Expand Down Expand Up @@ -152,7 +152,7 @@ export async function installRootRedirect(server: HttpServer, traceUrl: string |
}

export async function runTraceViewerApp(traceUrl: string | undefined, browserName: string, options: TraceViewerServerOptions & { headless?: boolean }, exitOnClose?: boolean) {
traceUrl = validateTraceUrl(traceUrl);
traceUrl = validateTraceUrlOrPath(traceUrl);
const server = await startTraceViewerServer(options);
await installRootRedirect(server, traceUrl, options);
const page = await openTraceViewerApp(server.urlPrefix('precise'), browserName, options);
Expand All @@ -162,7 +162,7 @@ export async function runTraceViewerApp(traceUrl: string | undefined, browserNam
}

export async function runTraceInBrowser(traceUrl: string | undefined, options: TraceViewerServerOptions) {
traceUrl = validateTraceUrl(traceUrl);
traceUrl = validateTraceUrlOrPath(traceUrl);
const server = await startTraceViewerServer(options);
await installRootRedirect(server, traceUrl, options);
await openTraceInBrowser(server.urlPrefix('human-readable'));
Expand Down Expand Up @@ -216,8 +216,8 @@ class StdinServer implements Transport {

constructor() {
process.stdin.on('data', data => {
const url = data.toString().trim();
if (url === this._traceUrl)
const url = validateTraceUrlOrPath(data.toString().trim());
if (!url || url === this._traceUrl)
return;
if (url.endsWith('.json'))
this._pollLoadTrace(url);
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/utils/httpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export class HttpServer {
this._port = address.port;
const resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
this._urlPrefixPrecise = `http://${resolvedHost}:${address.port}`;
this._urlPrefixHumanReadable = `http://${host}:${address.port}`;
this._urlPrefixHumanReadable = `http://${host ?? 'localhost'}:${address.port}`;
}
}

Expand Down
13 changes: 12 additions & 1 deletion packages/playwright/src/isomorphic/teleReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,19 @@ export class TeleReporterReceiver {
projectSuite = new TeleSuite(project.name, 'project');
this._rootSuite._addSuite(projectSuite);
}

const parsed = this._parseProject(project);
// Always update project in watch mode.
projectSuite._project = this._parseProject(project);
projectSuite._project = parsed;

let index = -1;
if (this._options.mergeProjects)
index = this._config.projects.findIndex(p => p.name === project.name);
if (index === -1)
this._config.projects.push(parsed);
else
this._config.projects[index] = parsed;

for (const suite of project.suites)
this._mergeSuiteInto(suite, projectSuite);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/playwright/src/mcp/terminal/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,9 @@ const sessionList = declareCommand({
description: 'List all sessions',
category: 'session',
args: z.object({}),
options: z.object({
all: z.boolean().optional().describe('List all sessions across all workspaces'),
}),
toolName: '',
toolParams: () => ({}),
});
Expand Down
148 changes: 107 additions & 41 deletions packages/playwright/src/mcp/terminal/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ export type SessionConfig = {
config?: string;
};
userDataDirPrefix?: string;
workspaceDir?: string;
};

type ClientInfo = {
version: string;
workspaceDirHash: string;
daemonProfilesDir: string;
workspaceDir: string | undefined;
};

class Session {
Expand Down Expand Up @@ -308,26 +310,12 @@ class SessionManager {
const sessions = new Map<string, Session>();
const files = await fs.promises.readdir(dir).catch(() => []);
for (const file of files) {
if (!file.endsWith('.session'))
continue;
try {
if (file.endsWith('.session')) {
const sessionName = path.basename(file, '.session');
const sessionConfig = await fs.promises.readFile(path.join(dir, file), 'utf-8').then(data => JSON.parse(data)) as SessionConfig;
sessions.set(sessionName, new Session(clientInfo, sessionName, sessionConfig));
continue;
}

// Legacy session support.
if (file.startsWith('ud-')) {
// Session is like ud-<sessionName>-browserName
const sessionName = file.split('-')[1];
if (!sessions.has(sessionName)) {
const sessionConfig = sessionConfigFromArgs({
...clientInfo,
version: '0.0.61'
}, sessionName, { _: [] });
sessions.set(sessionName, new Session(clientInfo, sessionName, sessionConfig));
}
}
const sessionName = path.basename(file, '.session');
const sessionConfig = await fs.promises.readFile(path.join(dir, file), 'utf-8').then(data => JSON.parse(data)) as SessionConfig;
sessions.set(sessionName, new Session(clientInfo, sessionName, sessionConfig));
} catch {
}
}
Expand Down Expand Up @@ -395,15 +383,16 @@ class SessionManager {

function createClientInfo(packageLocation: string): ClientInfo {
const packageJSON = require(packageLocation);
const workspaceDir = findWorkspaceDir(process.cwd()) || packageLocation;
const workspaceDir = findWorkspaceDir(process.cwd());
const version = process.env.PLAYWRIGHT_CLI_VERSION_FOR_TEST || packageJSON.version;

const hash = crypto.createHash('sha1');
hash.update(workspaceDir);
hash.update(workspaceDir || packageLocation);
const workspaceDirHash = hash.digest('hex').substring(0, 16);

return {
version,
workspaceDir,
workspaceDirHash,
daemonProfilesDir: daemonProfilesDir(workspaceDirHash),
};
Expand All @@ -422,9 +411,9 @@ function findWorkspaceDir(startDir: string): string | undefined {
return undefined;
}

const daemonProfilesDir = (workspaceDirHash: string) => {
const baseDaemonDir = (() => {
if (process.env.PLAYWRIGHT_DAEMON_SESSION_DIR)
return path.join(process.env.PLAYWRIGHT_DAEMON_SESSION_DIR, workspaceDirHash);
return process.env.PLAYWRIGHT_DAEMON_SESSION_DIR;

let localCacheDir: string | undefined;
if (process.platform === 'linux')
Expand All @@ -435,7 +424,11 @@ const daemonProfilesDir = (workspaceDirHash: string) => {
localCacheDir = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
if (!localCacheDir)
throw new Error('Unsupported platform: ' + process.platform);
return path.join(localCacheDir, 'ms-playwright', 'daemon', workspaceDirHash);
return path.join(localCacheDir, 'ms-playwright', 'daemon');
})();

const daemonProfilesDir = (workspaceDirHash: string) => {
return path.join(baseDaemonDir, workspaceDirHash);
};

type GlobalOptions = {
Expand Down Expand Up @@ -465,7 +458,8 @@ const globalOptions: (keyof (GlobalOptions & OpenOptions))[] = [
'version',
];

const booleanOptions: (keyof (GlobalOptions & OpenOptions))[] = [
const booleanOptions: (keyof (GlobalOptions & OpenOptions & { all?: boolean }))[] = [
'all',
'help',
'version',
'extension',
Expand Down Expand Up @@ -512,22 +506,10 @@ export async function program(packageLocation: string) {

switch (commandName) {
case 'session-list': {
const sessions = sessionManager.sessions;
console.log('Sessions:');
for (const session of sessions.values()) {
const canConnect = await session.canConnect();
if (!canConnect) {
console.log(` ${session.name} is stale, removing`);
await session.deleteSession();
} else {
const restartMarker = !session.isCompatible() ? ` - v${session.config().version}, please reopen` : '';
console.log(` ${session.name}${restartMarker}`);
const config = session.config();
configToFormattedArgs(config.cli).forEach(arg => console.log(` ${arg}`));
}
}
if (sessions.size === 0)
console.log(' (no sessions)');
if (args.all)
await listAllSessions(clientInfo);
else
await listSessions(sessionManager);
return;
}
case 'session-close-all': {
Expand Down Expand Up @@ -609,6 +591,7 @@ function sessionConfigFromArgs(clientInfo: ClientInfo, sessionName: string, args
config,
},
userDataDirPrefix: path.resolve(clientInfo.daemonProfilesDir, `ud-${sessionName}`),
workspaceDir: clientInfo.workspaceDir,
};
}

Expand Down Expand Up @@ -677,6 +660,89 @@ async function killAllDaemons(): Promise<void> {
console.log(`Killed ${killed} daemon process${killed === 1 ? '' : 'es'}.`);
}

async function listSessions(sessionManager: SessionManager): Promise<void> {
const sessions = sessionManager.sessions;
console.log('Sessions:');
for (const session of sessions.values()) {
const canConnect = await session.canConnect();
if (!canConnect) {
console.log(` ${session.name} is stale, removing`);
await session.deleteSession();
} else {
const restartMarker = !session.isCompatible() ? ` - v${session.config().version}, please reopen` : '';
console.log(` ${session.name}${restartMarker}`);
const config = session.config();
configToFormattedArgs(config.cli).forEach(arg => console.log(` ${arg}`));
}
}
if (sessions.size === 0)
console.log(' (no sessions)');
}

async function listAllSessions(clientInfo: ClientInfo): Promise<void> {
const hashes = await fs.promises.readdir(baseDaemonDir).catch(() => []);

// Group sessions by workspace folder
const sessionsByWorkspace = new Map<string, { name: string, config: SessionConfig, canConnect: boolean, isCompatible: boolean }[]>();

for (const hash of hashes) {
const hashDir = path.join(baseDaemonDir, hash);
const stat = await fs.promises.stat(hashDir).catch(() => null);
if (!stat?.isDirectory())
continue;

const files = await fs.promises.readdir(hashDir).catch(() => []);
for (const file of files) {
if (!file.endsWith('.session'))
continue;
try {
const sessionName = path.basename(file, '.session');
const sessionConfig = await fs.promises.readFile(path.join(hashDir, file), 'utf-8').then(data => JSON.parse(data)) as SessionConfig;
const session = new Session(clientInfo, sessionName, sessionConfig);
const canConnect = await session.canConnect();
const isCompatible = session.isCompatible();

// Use workspace folder from config, or empty string if not set (installation folder case)
const workspaceKey = sessionConfig.workspaceDir || '';
if (!sessionsByWorkspace.has(workspaceKey))
sessionsByWorkspace.set(workspaceKey, []);
sessionsByWorkspace.get(workspaceKey)!.push({ name: sessionName, config: sessionConfig, canConnect, isCompatible });
} catch {
}
}
}

if (sessionsByWorkspace.size === 0) {
console.log('No sessions found.');
return;
}

// Sort workspace keys: empty string (no workspace) last, others alphabetically
const sortedWorkspaces = [...sessionsByWorkspace.keys()].sort((a, b) => {
if (a === '' && b !== '')
return 1;
if (a !== '' && b === '')
return -1;
return a.localeCompare(b);
});

for (const workspace of sortedWorkspaces) {
const sessions = sessionsByWorkspace.get(workspace)!;
// Only print workspace folder if it's set
if (workspace)
console.log(`${workspace}:`);
for (const { name, config, canConnect, isCompatible } of sessions) {
if (!canConnect) {
console.log(` ${name} (stale)`);
} else {
const restartMarker = !isCompatible ? ` - v${config.version}, please reopen` : '';
console.log(` ${name}${restartMarker}`);
configToFormattedArgs(config.cli).forEach(arg => console.log(` ${arg}`));
}
}
}
}

function formatWithGap(prefix: string, text: string, threshold: number = 40) {
const indent = Math.max(1, threshold - prefix.length);
return prefix + ' '.repeat(indent) + text;
Expand Down
13 changes: 13 additions & 0 deletions tests/library/trace-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2248,3 +2248,16 @@ test('should capture iframe with srcdoc', async ({ page, server, runAndTrace })
const frame = await traceViewer.snapshotFrame('Evaluate');
await expect(frame.frameLocator('iframe').getByRole('button')).toHaveText('Hello iframe');
});

test('take trace paths via stdin', async ({ childProcess, page }) => {
const cliEntrypoint = path.join(__dirname, '../../packages/playwright-core/cli.js');
const cp = childProcess({ command: ['node', cliEntrypoint, 'show-trace', '--port', '0', '--stdin'] });
await cp.waitForOutput('Listening on');
const url = cp.output.match(/Listening on (http:\/\/[^\s]+)/)![1];
await page.goto(url);
await expect(page).toHaveTitle('Playwright Trace Viewer');
cp.write(traceFile);
await expect(page.locator('.action-title')).toContainText([
/Create page/,
]);
});
27 changes: 27 additions & 0 deletions tests/mcp/cli-session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,30 @@ test('workspace isolation - sessions in different workspaces are isolated', asyn

await cli('close', { cwd: workspace2 });
});

test('session-list --all lists sessions from all workspaces', async ({ cli, server }, testInfo) => {
// Create two separate workspaces with their own daemon dirs
const workspace1 = testInfo.outputPath('workspace1');
const workspace2 = testInfo.outputPath('workspace2');
await fs.promises.mkdir(workspace1, { recursive: true });
await fs.promises.mkdir(workspace2, { recursive: true });

await cli('install', { cwd: workspace1 });
await cli('install', { cwd: workspace2 });

// Open sessions in both workspaces
await cli('--session=session1', 'open', server.HELLO_WORLD, { cwd: workspace1 });
await cli('--session=session2', 'open', server.HELLO_WORLD, { cwd: workspace2 });

// List all sessions from workspace1
const { output: allList } = await cli('session-list', '--all', { cwd: workspace1 });

// Should include both workspace folders and sessions
expect(allList).toContain(workspace1);
expect(allList).toContain(workspace2);
expect(allList).toContain('session1');
expect(allList).toContain('session2');

await cli('--session=session1', 'close', { cwd: workspace1 });
await cli('--session=session2', 'close', { cwd: workspace2 });
});
39 changes: 39 additions & 0 deletions tests/playwright-test/reporter-blob.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2287,3 +2287,42 @@ test('shard chart', async ({ runInlineTest, writeFiles, showReport, page, mergeR
- listitem /@mac/
`);
});

test('should populate projects in config when merging reports', async ({ runInlineTest, mergeReports }) => {
const reportDir = test.info().outputPath('blob-report');
class CustomReporter {
onBegin(config, suite) {
const projectNames = config.projects.map(p => p.name);
console.log('%%' + JSON.stringify(projectNames));
}
}
const files = {
'reporter.js': `module.exports = ${CustomReporter.toString()};`,
'playwright.config.ts': `
module.exports = {
reporter: [['blob', { outputDir: '${reportDir.replace(/\\/g, '/')}' }]],
projects: [
{ name: 'setup' },
{ name: 'p1', dependencies: ['setup'] },
{ name: 'p2', dependencies: ['setup'] },
]
};
`,
'a.test.js': `
import { test } from '@playwright/test';
test('test 1', async ({}) => {});
`,
};

await runInlineTest(files, { shard: `1/2`, workers: 1 });
await runInlineTest(files, { shard: `2/2`, workers: 1 }, { PWTEST_BLOB_DO_NOT_REMOVE: '1' });

const reportFiles = await fs.promises.readdir(reportDir);
expect(reportFiles).toHaveLength(2);

const { exitCode, outputLines } = await mergeReports(reportDir, {}, { additionalArgs: ['--reporter', test.info().outputPath('reporter.js')] });
expect(exitCode).toBe(0);

const projectNames = JSON.parse(outputLines[0]);
expect(projectNames).toEqual(['setup', 'p1', 'setup', 'p2']);
});
Loading