305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
/**
|
|
* 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 crypto from 'crypto';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
import { test as baseTest, expect } from './fixtures';
|
|
import { killProcessGroup } from '../config/commonFixtures';
|
|
import { inheritAndCleanEnv } from '../config/utils';
|
|
|
|
import type { Page, Browser } from 'playwright-core';
|
|
import type { CommonFixtures } from '../config/commonFixtures';
|
|
|
|
export { expect } from './fixtures';
|
|
export const test = baseTest.extend<{
|
|
boundBrowser: Browser,
|
|
cliEnv: Record<string, string>,
|
|
startDashboardServer: (options?: { cwd?: string, session?: string }) => Promise<Page>,
|
|
connectToDashboard: (bindTitle: string) => Promise<Browser>;
|
|
cli: (...args: any[]) => Promise<{
|
|
output: string,
|
|
error: string,
|
|
exitCode: number | undefined,
|
|
inlineSnapshot?: string,
|
|
snapshot?: string,
|
|
attachments?: { name: string, data: Buffer | null }[],
|
|
daemonPid?: number,
|
|
dashboardPid?: number,
|
|
}>;
|
|
}>({
|
|
cliEnv: async ({}, use) => {
|
|
await use(cliEnv());
|
|
},
|
|
startDashboardServer: async ({ childProcess, page }, use) => {
|
|
await use(async (options?: { cwd?: string, session?: string }) => {
|
|
const testInfo = test.info();
|
|
const showArgs = options?.session ? [`-s=${options.session}`, 'show'] : ['show'];
|
|
const serverProcess = childProcess({
|
|
command: [process.execPath, require.resolve('../../packages/playwright-core/lib/tools/cli-client/cli.js'), ...showArgs, '--port=0'],
|
|
cwd: options?.cwd ?? testInfo.outputPath(),
|
|
env: inheritAndCleanEnv(cliEnv()),
|
|
});
|
|
await serverProcess.waitForOutput('Listening on ');
|
|
await page.goto(serverProcess.output.match(/Listening on (http:\/\/\S+)/)![1]);
|
|
return page;
|
|
});
|
|
},
|
|
connectToDashboard: async ({ cli, playwright }, use) => {
|
|
await use(async (bindTitle: string) => {
|
|
let endpoint = '';
|
|
await expect(async () => {
|
|
const { output } = await cli('list', '--all', '--json');
|
|
const { servers } = JSON.parse(output);
|
|
const server = servers.find(s => s.title === bindTitle);
|
|
endpoint = server.endpoint;
|
|
}).toPass();
|
|
return await playwright.chromium.connect(endpoint);
|
|
});
|
|
await cli('show', '--kill');
|
|
},
|
|
|
|
cli: async ({ mcpBrowser, mcpHeadless, childProcess }, use) => {
|
|
await fs.promises.mkdir(test.info().outputPath('.playwright'), { recursive: true });
|
|
const allPids: number[] = [];
|
|
|
|
await use(async (...args: string[]) => {
|
|
const cliArgs = args.filter(arg => typeof arg === 'string');
|
|
const cliOptions = args.findLast(arg => typeof arg === 'object') || {};
|
|
const result = await test.step(
|
|
`cli ${cliArgs.join(' ')}`,
|
|
() => runCli(childProcess, cliArgs, cliOptions, { mcpBrowser, mcpHeadless })
|
|
);
|
|
if (result.daemonPid)
|
|
allPids.push(result.daemonPid);
|
|
if (result.dashboardPid)
|
|
allPids.push(result.dashboardPid);
|
|
return result;
|
|
});
|
|
|
|
for (const pid of allPids)
|
|
killProcessGroup(pid);
|
|
|
|
const daemonDir = test.info().outputPath('daemon');
|
|
for (const dir of await fs.promises.readdir(daemonDir).catch<string[]>(() => [])) {
|
|
if (dir.startsWith('ud-')) {
|
|
await fs.promises.rm(path.join(daemonDir, dir), { recursive: true, force: true }).catch(() => {});
|
|
continue;
|
|
}
|
|
}
|
|
},
|
|
boundBrowser: async ({ mcpBrowser, playwright }, use) => {
|
|
const browserName = (mcpBrowser === 'chrome' || mcpBrowser === 'msedge') ? 'chromium' : mcpBrowser;
|
|
const channel = (mcpBrowser === 'chrome' || mcpBrowser === 'msedge') ? mcpBrowser : undefined;
|
|
const browser = await playwright[browserName].launch({ channel, headless: true });
|
|
for (const [name, value] of Object.entries(cliEnv()))
|
|
process.env[name] = value;
|
|
await browser.bind('default');
|
|
await use(browser);
|
|
for (const name of Object.keys(cliEnv()))
|
|
delete process.env[name];
|
|
await browser.close();
|
|
},
|
|
});
|
|
|
|
function cliEnv() {
|
|
return {
|
|
PLAYWRIGHT_SERVER_REGISTRY: test.info().outputPath('registry'),
|
|
PWTEST_DASHBOARD_SETTINGS_FILE: test.info().outputPath('dashboard.settings.json'),
|
|
PLAYWRIGHT_DAEMON_SESSION_DIR: test.info().outputPath('daemon'),
|
|
PLAYWRIGHT_SOCKETS_DIR: path.join(os.tmpdir(), 'ds-' + crypto.createHash('sha1').update(test.info().outputDir).digest('hex').slice(0, 16)),
|
|
PWTEST_CLI_CHANNEL_SCAN_DISABLED_FOR_TEST: '1',
|
|
};
|
|
}
|
|
|
|
async function runCli(childProcess: CommonFixtures['childProcess'], args: string[], cliOptions: { cwd?: string, env?: Record<string, string>, bindTitle?: string }, options: { mcpBrowser: string, mcpHeadless: boolean }) {
|
|
const testInfo = test.info();
|
|
const cli = childProcess({
|
|
command: [process.execPath, require.resolve('../../packages/playwright-core/lib/tools/cli-client/cli.js'), ...args],
|
|
cwd: cliOptions.cwd ?? testInfo.outputPath(),
|
|
env: inheritAndCleanEnv({
|
|
...cliEnv(),
|
|
PLAYWRIGHT_MCP_BROWSER: options.mcpBrowser,
|
|
PLAYWRIGHT_MCP_HEADLESS: String(options.mcpHeadless),
|
|
PWTEST_PRINT_DASHBOARD_PID_FOR_TEST: '1',
|
|
PWTEST_DASHBOARD_APP_BIND_TITLE: cliOptions.bindTitle,
|
|
...cliOptions.env,
|
|
}),
|
|
});
|
|
|
|
// Wait for the CLI to exit so stdout is complete before we parse it.
|
|
const exitCode = await cli.exitCode;
|
|
|
|
let snapshot: string | undefined;
|
|
let inlineSnapshot: string | undefined;
|
|
if (cli.stdout.includes('### Snapshot'))
|
|
({ snapshot, inlineSnapshot } = await loadSnapshot(cli.stdout));
|
|
const attachments = loadAttachments(cli.stdout);
|
|
|
|
const browserMatches = cli.stdout.includes('### Browser') ? cli.stdout.match(/Browser `(.+)` opened with pid (\d+)\./) : undefined;
|
|
const daemonPid = browserMatches?.[2] ?? parseJsonPid(cli.stdout);
|
|
const dashboardMatches = cli.stdout.includes('### Dashboard') ? cli.stdout.match(/Dashboard opened with pid (\d+)\./) : undefined;
|
|
const dashboardPid = dashboardMatches?.[1];
|
|
|
|
return {
|
|
exitCode,
|
|
output: cli.stdout.trim(),
|
|
error: cli.stderr.trim(),
|
|
snapshot,
|
|
inlineSnapshot,
|
|
attachments,
|
|
daemonPid: daemonPid ? +daemonPid : undefined,
|
|
dashboardPid: dashboardPid ? +dashboardPid : undefined,
|
|
};
|
|
}
|
|
|
|
function parseJsonPid(stdout: string) {
|
|
try {
|
|
return JSON.parse(stdout).pid;
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
function loadAttachments(output: string) {
|
|
// attachments look like md links - [Page as pdf](.playwright-cli/page-2026-01-22T23-13-56-347Z.pdf)
|
|
const match = output.match(/- \[(.+)\]\((.+)\)/g);
|
|
if (!match)
|
|
return [];
|
|
|
|
return match.map(m => {
|
|
const [, name, path] = m.match(/- \[(.+)\]\((.+)\)/)!;
|
|
try {
|
|
const data = fs.readFileSync(test.info().outputPath(path));
|
|
return { name, data };
|
|
} catch (e) {
|
|
return { name, data: null };
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadSnapshot(output: string): Promise<{ snapshot?: string, inlineSnapshot?: string }> {
|
|
const lines = output.split('\n');
|
|
if (!lines.includes('### Snapshot'))
|
|
throw new Error('Snapshot file not found');
|
|
const snapshotIndex = lines.indexOf('### Snapshot') + 1;
|
|
const fileLine = lines[snapshotIndex];
|
|
if (fileLine.startsWith('```yaml'))
|
|
return { inlineSnapshot: lines.slice(snapshotIndex + 1, lines.indexOf('```', snapshotIndex)).join('\n') };
|
|
const fileName = fileLine.match(/- \[(.+)\]\((.+)\)/)![2];
|
|
try {
|
|
return { snapshot: await fs.promises.readFile(test.info().outputPath(fileName), 'utf8').catch(() => undefined) };
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export const eventsPage = `<!DOCTYPE html>
|
|
<html>
|
|
<body style="width: 400px; height: 400px; margin: 0; padding: 0;">
|
|
<div id='square'style="width: 100px; height: 100px;"></div>
|
|
<div id='log'></div>
|
|
<script>
|
|
const logElement = document.querySelector('#log');
|
|
|
|
const log = (...args) => {
|
|
const el = document.createElement('div');
|
|
el.textContent = args.join(' ');
|
|
logElement.appendChild(el);
|
|
};
|
|
document.body.addEventListener('mousemove', (event) => {
|
|
log('mouse move', event.clientX, event.clientY);
|
|
});
|
|
document.body.addEventListener('mousedown', () => {
|
|
log('mouse down');
|
|
});
|
|
document.body.addEventListener('mouseup', () => {
|
|
log('mouse up');
|
|
});
|
|
document.body.addEventListener('wheel', (event) => {
|
|
log('wheel', event.deltaX, event.deltaY);
|
|
});
|
|
document.body.addEventListener('click', () => {
|
|
log('click', event.button);
|
|
});
|
|
document.body.addEventListener('dblclick', (event) => {
|
|
log('dblclick', event.button);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
export async function findDefaultSession() {
|
|
const daemonDir = await daemonFolder();
|
|
const fileName = path.join(daemonDir, 'default.session');
|
|
return await fs.promises.readFile(fileName, 'utf-8').then(JSON.parse).catch(() => null);
|
|
}
|
|
|
|
export async function daemonFolder() {
|
|
const daemonDir = test.info().outputPath('daemon');
|
|
const folders = await fs.promises.readdir(daemonDir);
|
|
for (const folder of folders) {
|
|
const fullName = path.join(daemonDir, folder);
|
|
if (fs.lstatSync(path.join(fullName)).isDirectory())
|
|
return fullName;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function installSaveFilePickerMock(page: import('playwright-core').Page): Promise<() => Promise<Buffer>> {
|
|
await page.evaluate(() => {
|
|
(window as any).__testCaptureBytes = undefined as string | undefined;
|
|
(window as any).showSaveFilePicker = async () => ({
|
|
createWritable: async () => {
|
|
const chunks: Uint8Array[] = [];
|
|
return {
|
|
write: async (chunk: Blob | BufferSource) => {
|
|
const buf = chunk instanceof Blob
|
|
? new Uint8Array(await chunk.arrayBuffer())
|
|
: new Uint8Array(chunk instanceof ArrayBuffer ? chunk : (chunk as ArrayBufferView).buffer);
|
|
chunks.push(buf);
|
|
},
|
|
close: async () => {
|
|
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
|
|
const merged = new Uint8Array(total);
|
|
let offset = 0;
|
|
for (const c of chunks) {
|
|
merged.set(c, offset);
|
|
offset += c.byteLength;
|
|
}
|
|
(window as any).__testCaptureBytes = (merged as any).toBase64();
|
|
},
|
|
};
|
|
},
|
|
});
|
|
});
|
|
return async () => {
|
|
await expect.poll(() => page.evaluate(() => !!(window as any).__testCaptureBytes), { timeout: 10000 }).toBe(true);
|
|
const b64: string = await page.evaluate(() => (window as any).__testCaptureBytes);
|
|
return Buffer.from(b64, 'base64');
|
|
};
|
|
}
|
|
|
|
export async function mockAbortingFilePicker(page: import('playwright-core').Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
(window as any).showSaveFilePicker = async () => {
|
|
throw new DOMException('The user aborted a request.', 'AbortError');
|
|
};
|
|
});
|
|
}
|