Playwright vscode插件主要功能
Playwright是微软开发的一款主要用于UI自动化测试的工具,在vscode中上安装playwright vscode插件,可以运行,录制UI自动化测试。
playwright vscode插件主要包括两块功能,功能一是在Test Explorer中显示项目中所有的测试文件,选择某个测试文件,可以执行这些由playwright编写的测试。功能二是Playwright webview视图,里面可以选择projects,即选择执行测试的浏览器,settings设置,选择显示浏览器,还是trace viewer,Tools目录下有Pick locator,Record new,Record at cursor菜单,点击Pick locator或者录制菜单,会启动一个浏览器,在浏览器中输入被测web应用的url,就可以开始录制了,录制的代码会写到vscode管理下的测试文件中。
Test Explorer实现原理
那么,如何实现在Test Explorer中显示测试文件并执行的呢?查看playwright vscode插件源码,testTree and testModel文件主要负责以treeview的方式显示测试文件,testModel里面封装了运行或者调试测试的代码。settingView里面是一个webview,palaywright下面的projects,settings,tools等UI的显示,都是由settingView里面编写。playwrightTestServer.ts 和playwrightTestCLI.ts主要是实现运行,录制测试的具体逻辑。backend.ts里面是启动一个websocket服务,通过发送和监听消息来执行测试。
关于如何通过vscode提供的treeview和testcontroller来实现Test explorer下显示测试文件的部分,可以先阅读这两篇博客,treeview使用,testcontroller使用,这两篇博客中给出了构建treeview和testcontroller的简单易懂的例子。Test explorer中显示的测试文件实现思路和上面例子大致相同,解析source code中测试文件信息,并以treeview的方式显示出来。当选择某个文件执行后,会在测试文件名称下面显示测试执行时间,测试名称等。在测试执行是,获取测试时间,执行状态等信息,组装成testItem,再添加到testcontroller中,通过testcontroller来管理整个测试的生命周期。
测试执行的底层逻辑是什么
上面主要介绍的playwright vscode插件的UI部分,那么底层是如何实现测试执行的呢?下面是playwrightTestCLI.ts文件中runTests方法的代码。下面code中,首先设置了一些测试执行参数,例如--headed,--workers,--trace等,然后通过__innerSpawn方法执行测试,另外还创建ReportServer,监听测试执行结果信息。__innerSpawn通过child_process.spawn 方法启动一个新的 Node.js 进程来运行 Playwright 测试。具体步骤和效果如下:
启动一个 Node.js 进程:使用 node 可执行文件和 Playwright CLI 来运行测试。
传递命令行参数:
this._model.config.cli: Playwright CLI 路径。
'test': 运行测试的命令。
'-c', configFile: 指定配置文件。
...extraArgs: 额外的命令行参数。
...escapedLocations: 要运行的测试文件或目录。
设置工作目录:使用 configFolder 作为当前工作目录。
设置标准输入输出:重定向子进程的所有标准输入输出流(包括标准输入、标准输出、标准错误输出和额外的自定义流)。
配置环境变量:通过扩展当前进程的环境变量,并添加或覆盖一些特定的环境变量,例如 CI, NODE_OPTIONS, PW_TEST_REUSE_CONTEXT, PW_TEST_CONNECT_WS_ENDPOINT 等。
async runTests(items: vscodeTypes.TestItem[], options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise<void> {
const { locations, parametrizedTestTitle } = this._narrowDownLocations(items);
if (!locations)
return;
const args = [];
this._model.enabledProjectsFilter().forEach(p => args.push(`--project=${p}`));
if (parametrizedTestTitle)
args.push(`--grep=${escapeRegex(parametrizedTestTitle)}`);
args.push('--repeat-each=1');
args.push('--retries=0');
if (options.headed)
args.push('--headed');
if (options.workers)
args.push(`--workers=${options.workers}`);
if (options.trace)
args.push(`--trace=${options.trace}`);
await this._innerSpawn(locations, args, options, reporter, token);
}
async _innerSpawn(locations: string[], extraArgs: string[], options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken) {
if (token?.isCancellationRequested)
return;
// Playwright will restart itself as child process in the ESM mode and won't inherit the 3/4 pipes.
// Always use ws transport to mitigate it.
const reporterServer = new ReporterServer(this._vscode);
const node = await findNode(this._vscode, this._model.config.workspaceFolder);
const configFolder = path.dirname(this._model.config.configFile);
const configFile = path.basename(this._model.config.configFile);
const escapedLocations = locations.map(escapeRegex).sort();
{
// For tests.
const relativeLocations = locations.map(f => path.relative(configFolder, f)).map(escapeRegex).sort();
const printArgs = extraArgs.filter(a => !a.includes('--repeat-each') && !a.includes('--retries') && !a.includes('--workers') && !a.includes('--trace'));
this._log(`${escapeRegex(path.relative(this._model.config.workspaceFolder, configFolder))}> playwright test -c ${configFile}${printArgs.length ? ' ' + printArgs.join(' ') : ''}${relativeLocations.length ? ' ' + relativeLocations.join(' ') : ''}`);
}
const childProcess = spawn(node, [
this._model.config.cli,
'test',
'-c', configFile,
...extraArgs,
...escapedLocations,
], {
cwd: configFolder,
stdio: ['pipe', 'pipe', 'pipe', 'pipe', 'pipe'],
env: {
...process.env,
CI: this._options.isUnderTest ? undefined : process.env.CI,
// Don't debug tests when running them.
NODE_OPTIONS: undefined,
...this._options.envProvider(),
PW_TEST_REUSE_CONTEXT: options.reuseContext ? '1' : undefined,
PW_TEST_CONNECT_WS_ENDPOINT: options.connectWsEndpoint,
...(await reporterServer.env()),
// Reset VSCode's options that affect nested Electron.
ELECTRON_RUN_AS_NODE: undefined,
FORCE_COLOR: '1',
PW_TEST_HTML_REPORT_OPEN: 'never',
PW_TEST_NO_REMOVE_OUTPUT_DIRS: '1',
}
});
const stdio = childProcess.stdio;
stdio[1].on('data', data => reporter.onStdOut?.(data));
stdio[2].on('data', data => reporter.onStdErr?.(data));
await reporterServer.wireTestListener(reporter, token);
}
在test-explorer中选择某个文件,选择dubug test,会在debug output窗口中显示如下信息,这段信息和上面的代码是完全匹配的,从这里可以看到当在vscode extension窗口中选择某个测试文件,点击执行按钮时,实际背后是通过node执行playwright的cli命令完成执行的。
有了CLI为什么还要启动Backend呢
查看testModel.ts文件中的code,会有这段代码,通过这行代码可以知道PlaywrightTestCLI方式是遗留的老方式,新方式是调用PlaywrightTestServer来执行测试。而PlaywrightTestServer.ts文件里面执行测试的时候需要启动Backend server。说明CLI方式是早期的方式,现在又构建了新的方式,即启动backend server的方式来执行测试。
this._playwrightTest = this._useLegacyCLIDriver ? new PlaywrightTestCLI(vscode, this, options) : new PlaywrightTestServer(vscode, this, options);
BackendServer的实现逻辑是什么?
查看PlaywrightTestServer.ts中runTest方法,实际调用的是testServerConnection.ts中的runTest方法,代码细节如下所示,这表明,启动backend server后,通过发送和监听消息的方式来执行或者录制测试。
async runTests(params: Parameters<TestServerInterface['runTests']>[0]): ReturnType<TestServerInterface['runTests']> {
return await this._sendMessage('runTests', params);
}
查看backend.ts的代码,如下所示,这里是启动backend server的方法,可以看到启动backend server本质上也是使用spawn调用node进程而已。
export async function startBackend(vscode: vscodeTypes.VSCode, options: BackendServerOptions & { onError: (error: Error) => void, onClose: () => void }): Promise<string | null> {
const node = await findNode(vscode, options.cwd);
const serverProcess = spawn(node, options.args, {
cwd: options.cwd,
stdio: 'pipe',
env: {
...process.env,
...options.envProvider(),
},
});
serverProcess.stderr?.on('data', data => {
if (options.dumpIO)
console.log('[server err]', data.toString());
});
serverProcess.on('error', options.onError);
serverProcess.on('close', options.onClose);
return new Promise(fulfill => {
serverProcess.stdout?.on('data', async data => {
if (options.dumpIO)
console.log('[server out]', data.toString());
const match = data.toString().match(/Listening on (.*)/);
if (!match)
return;
const wse = match[1];
fulfill(wse);
});
serverProcess.on('exit', () => fulfill(null));
});
}
查看testServerConnection的构造方法,可以看到,这里通过new WebSocket启动了一个websocket服务,且这个服务队message进行监听处理。具体代码如下所示:
constructor(wsURL: string) {
this.onClose = this._onCloseEmitter.event;
this.onReport = this._onReportEmitter.event;
this.onStdio = this._onStdioEmitter.event;
this.onListChanged = this._onListChangedEmitter.event;
this.onTestFilesChanged = this._onTestFilesChangedEmitter.event;
this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event;
this._ws = new WebSocket(wsURL);
this._ws.addEventListener('message', event => {
const message = JSON.parse(String(event.data));
const { id, result, error, method, params } = message;
if (id) {
const callback = this._callbacks.get(id);
if (!callback)
return;
this._callbacks.delete(id);
if (error)
callback.reject(new Error(error));
else
callback.resolve(result);
} else {
this._dispatchEvent(method, params);
}
});
const pingInterval = setInterval(() => this._sendMessage('ping').catch(() => {}), 30000);
this._connectedPromise = new Promise<void>((f, r) => {
this._ws.addEventListener('open', () => f());
this._ws.addEventListener('error', r);
});
this._ws.addEventListener('close', () => {
this._isClosed = true;
this._onCloseEmitter.fire();
clearInterval(pingInterval);
});
}
在playwrightTestServer.ts文件中有一个createTestServer的私有方法,该方法返回的是TestServerConnection对象,在这个方法中,首先调用startBackend 得到wsEnpoint,即websocket服务的endpoint信息,在将wsEnpoint传入TestServerConnection这个class中。最终实现启动一个websocket,通过向websocket服务发送消息的方式来执行、停止测试等操作。
private async _createTestServer(): Promise<TestServerConnection | null> {
const args = [this._model.config.cli, 'test-server', '-c', this._model.config.configFile];
const wsEndpoint = await startBackend(this._vscode, {
args,
cwd: this._model.config.workspaceFolder,
envProvider: () => {
return {
...this._options.envProvider(),
FORCE_COLOR: '1',
};
},
dumpIO: false,
onClose: () => {
this._testServerPromise = undefined;
},
onError: error => {
this._testServerPromise = undefined;
},
});
if (!wsEndpoint)
return null;
const testServer = new TestServerConnection(wsEndpoint);
testServer.onTestFilesChanged(params => this._testFilesChanged(params.testFiles));
await testServer.initialize({
serializer: require.resolve('./oopReporter'),
interceptStdio: true,
closeOnDisconnect: true,
});
return testServer;
}
查看testserverConnection,可以看到定义了很多message,除了运行和停止测试外,还包括listFile,listtest等。
总结而言,不管是直接调用playwrighttestCLI文件里面的方法执行测试,还是启动backend的websocket服务,本质上都是启动node,调用playwright.cli.js文件,传入测试文件名称等参数来实现测试执行的。那么如何类似下面的node命令封装成websocket服务来执行测试呢?下一篇博客将介绍这个点的详细内容。
/opt/homebrew/bin/node ./node_modules/@playwright/test/cli.js test -c playwright.config.js --headed --project=chromium --repeat-each=1 --retries=0 --timeout=0 --workers=1 /Users/taoli/study/playwrightDemo/tests/test-1.spec.ts:3