mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore: prepare to reuse test server from ui mode (5) (#30005)
This commit is contained in:
parent
1bb463163b
commit
0a22a86e2e
@ -210,10 +210,10 @@ class StdinServer implements Transport {
|
|||||||
sendEvent?: (method: string, params: any) => void;
|
sendEvent?: (method: string, params: any) => void;
|
||||||
close?: () => void;
|
close?: () => void;
|
||||||
|
|
||||||
private _loadTrace(url: string) {
|
private _loadTrace(traceUrl: string) {
|
||||||
this._traceUrl = url;
|
this._traceUrl = traceUrl;
|
||||||
clearTimeout(this._pollTimer);
|
clearTimeout(this._pollTimer);
|
||||||
this.sendEvent?.('loadTrace', { url });
|
this.sendEvent?.('loadTraceRequested', { traceUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
private _pollLoadTrace(url: string) {
|
private _pollLoadTrace(url: string) {
|
||||||
|
@ -16,67 +16,94 @@
|
|||||||
|
|
||||||
import type { TestServerInterface, TestServerInterfaceEvents } from '@testIsomorphic/testServerInterface';
|
import type { TestServerInterface, TestServerInterfaceEvents } from '@testIsomorphic/testServerInterface';
|
||||||
import type { Location, TestError } from 'playwright/types/testReporter';
|
import type { Location, TestError } from 'playwright/types/testReporter';
|
||||||
import { connect } from './wsPort';
|
import * as events from './events';
|
||||||
import type { Event } from '@testIsomorphic/events';
|
|
||||||
import { EventEmitter } from '@testIsomorphic/events';
|
|
||||||
|
|
||||||
export class TestServerConnection implements TestServerInterface, TestServerInterfaceEvents {
|
export class TestServerConnection implements TestServerInterface, TestServerInterfaceEvents {
|
||||||
readonly onClose: Event<void>;
|
readonly onClose: events.Event<void>;
|
||||||
readonly onListReport: Event<any>;
|
readonly onListReport: events.Event<any>;
|
||||||
readonly onTestReport: Event<any>;
|
readonly onTestReport: events.Event<any>;
|
||||||
readonly onStdio: Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>;
|
readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>;
|
||||||
readonly onListChanged: Event<void>;
|
readonly onListChanged: events.Event<void>;
|
||||||
readonly onTestFilesChanged: Event<string[]>;
|
readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>;
|
||||||
|
readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>;
|
||||||
|
|
||||||
private _onCloseEmitter = new EventEmitter<void>();
|
private _onCloseEmitter = new events.EventEmitter<void>();
|
||||||
private _onListReportEmitter = new EventEmitter<any>();
|
private _onListReportEmitter = new events.EventEmitter<any>();
|
||||||
private _onTestReportEmitter = new EventEmitter<any>();
|
private _onTestReportEmitter = new events.EventEmitter<any>();
|
||||||
private _onStdioEmitter = new EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>();
|
private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>();
|
||||||
private _onListChangedEmitter = new EventEmitter<void>();
|
private _onListChangedEmitter = new events.EventEmitter<void>();
|
||||||
private _onTestFilesChangedEmitter = new EventEmitter<string[]>();
|
private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>();
|
||||||
|
private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>();
|
||||||
|
|
||||||
private _send: Promise<(method: string, params?: any) => Promise<any>>;
|
private _lastId = 0;
|
||||||
|
private _ws: WebSocket;
|
||||||
|
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
|
||||||
|
private _connectedPromise: Promise<void>;
|
||||||
|
|
||||||
constructor() {
|
constructor(wsURL: string) {
|
||||||
this.onClose = this._onCloseEmitter.event;
|
this.onClose = this._onCloseEmitter.event;
|
||||||
this.onListReport = this._onListReportEmitter.event;
|
this.onListReport = this._onListReportEmitter.event;
|
||||||
this.onTestReport = this._onTestReportEmitter.event;
|
this.onTestReport = this._onTestReportEmitter.event;
|
||||||
this.onStdio = this._onStdioEmitter.event;
|
this.onStdio = this._onStdioEmitter.event;
|
||||||
this.onListChanged = this._onListChangedEmitter.event;
|
this.onListChanged = this._onListChangedEmitter.event;
|
||||||
this.onTestFilesChanged = this._onTestFilesChangedEmitter.event;
|
this.onTestFilesChanged = this._onTestFilesChangedEmitter.event;
|
||||||
|
this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event;
|
||||||
|
|
||||||
this._send = connect({
|
this._ws = new WebSocket(wsURL);
|
||||||
onEvent: (method, params) => this._dispatchEvent(method, params),
|
this._ws.addEventListener('message', event => {
|
||||||
onClose: () => this._onCloseEmitter.fire(),
|
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.send(JSON.stringify({ method: 'ready' }));
|
||||||
|
});
|
||||||
|
this._ws.addEventListener('error', r);
|
||||||
|
});
|
||||||
|
this._ws.addEventListener('close', () => {
|
||||||
|
this._onCloseEmitter.fire();
|
||||||
|
clearInterval(pingInterval);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _sendMessage(method: string, params?: any): Promise<any> {
|
private async _sendMessage(method: string, params?: any): Promise<any> {
|
||||||
if ((window as any)._sniffProtocolForTest)
|
const logForTest = (globalThis as any).__logForTest;
|
||||||
(window as any)._sniffProtocolForTest({ method, params }).catch(() => {});
|
|
||||||
|
|
||||||
const send = await this._send;
|
|
||||||
const logForTest = (window as any).__logForTest;
|
|
||||||
logForTest?.({ method, params });
|
logForTest?.({ method, params });
|
||||||
return send(method, params).catch((e: Error) => {
|
|
||||||
// eslint-disable-next-line no-console
|
await this._connectedPromise;
|
||||||
console.error(e);
|
const id = ++this._lastId;
|
||||||
|
const message = { id, method, params };
|
||||||
|
this._ws.send(JSON.stringify(message));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._callbacks.set(id, { resolve, reject });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _dispatchEvent(method: string, params?: any) {
|
private _dispatchEvent(method: string, params?: any) {
|
||||||
if (method === 'close')
|
if (method === 'listReport')
|
||||||
this._onCloseEmitter.fire(undefined);
|
|
||||||
else if (method === 'listReport')
|
|
||||||
this._onListReportEmitter.fire(params);
|
this._onListReportEmitter.fire(params);
|
||||||
else if (method === 'testReport')
|
else if (method === 'testReport')
|
||||||
this._onTestReportEmitter.fire(params);
|
this._onTestReportEmitter.fire(params);
|
||||||
else if (method === 'stdio')
|
else if (method === 'stdio')
|
||||||
this._onStdioEmitter.fire(params);
|
this._onStdioEmitter.fire(params);
|
||||||
else if (method === 'listChanged')
|
else if (method === 'listChanged')
|
||||||
this._onListChangedEmitter.fire(undefined);
|
this._onListChangedEmitter.fire(params);
|
||||||
else if (method === 'testFilesChanged')
|
else if (method === 'testFilesChanged')
|
||||||
this._onTestFilesChangedEmitter.fire(params.testFileNames);
|
this._onTestFilesChangedEmitter.fire(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ping(): Promise<void> {
|
async ping(): Promise<void> {
|
@ -80,5 +80,16 @@ export interface TestServerInterfaceEvents {
|
|||||||
onTestReport: Event<any>;
|
onTestReport: Event<any>;
|
||||||
onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>;
|
onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>;
|
||||||
onListChanged: Event<void>;
|
onListChanged: Event<void>;
|
||||||
onTestFilesChanged: Event<string[]>;
|
onTestFilesChanged: Event<{ testFiles: string[] }>;
|
||||||
|
onLoadTraceRequested: Event<{ traceUrl: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestServerInterfaceEventEmitters {
|
||||||
|
dispatchEvent(event: 'close', params: {}): void;
|
||||||
|
dispatchEvent(event: 'listReport', params: any): void;
|
||||||
|
dispatchEvent(event: 'testReport', params: any): void;
|
||||||
|
dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): void;
|
||||||
|
dispatchEvent(event: 'listChanged', params: {}): void;
|
||||||
|
dispatchEvent(event: 'testFilesChanged', params: { testFiles: string[] }): void;
|
||||||
|
dispatchEvent(event: 'loadTraceRequested', params: { traceUrl: string }): void;
|
||||||
}
|
}
|
||||||
|
@ -41,6 +41,7 @@ export type TestCaseItem = TreeItemBase & {
|
|||||||
children: TestItem[];
|
children: TestItem[];
|
||||||
test: reporterTypes.TestCase | undefined;
|
test: reporterTypes.TestCase | undefined;
|
||||||
project: reporterTypes.FullProject | undefined;
|
project: reporterTypes.FullProject | undefined;
|
||||||
|
tags: Array<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TestItem = TreeItemBase & {
|
export type TestItem = TreeItemBase & {
|
||||||
@ -112,6 +113,7 @@ export class TestTree {
|
|||||||
status: 'none',
|
status: 'none',
|
||||||
project: undefined,
|
project: undefined,
|
||||||
test: undefined,
|
test: undefined,
|
||||||
|
tags: test.tags,
|
||||||
};
|
};
|
||||||
this._addChild(parentGroup, testCaseItem);
|
this._addChild(parentGroup, testCaseItem);
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@ import ListReporter from '../reporters/list';
|
|||||||
import { Multiplexer } from '../reporters/multiplexer';
|
import { Multiplexer } from '../reporters/multiplexer';
|
||||||
import { SigIntWatcher } from './sigIntWatcher';
|
import { SigIntWatcher } from './sigIntWatcher';
|
||||||
import { Watcher } from '../fsWatcher';
|
import { Watcher } from '../fsWatcher';
|
||||||
import type { TestServerInterface } from '../isomorphic/testServerInterface';
|
import type { TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface';
|
||||||
import { Runner } from './runner';
|
import { Runner } from './runner';
|
||||||
import { serializeError } from '../util';
|
import { serializeError } from '../util';
|
||||||
import { prepareErrorStack } from '../reporters/base';
|
import { prepareErrorStack } from '../reporters/base';
|
||||||
@ -95,6 +95,7 @@ class TestServerDispatcher implements TestServerInterface {
|
|||||||
readonly transport: Transport;
|
readonly transport: Transport;
|
||||||
private _queue = Promise.resolve();
|
private _queue = Promise.resolve();
|
||||||
private _globalCleanup: (() => Promise<FullResult['status']>) | undefined;
|
private _globalCleanup: (() => Promise<FullResult['status']>) | undefined;
|
||||||
|
readonly _dispatchEvent: TestServerInterfaceEventEmitters['dispatchEvent'];
|
||||||
|
|
||||||
constructor(config: FullConfigInternal) {
|
constructor(config: FullConfigInternal) {
|
||||||
this._config = config;
|
this._config = config;
|
||||||
@ -106,8 +107,9 @@ class TestServerDispatcher implements TestServerInterface {
|
|||||||
this._testWatcher = new Watcher('flat', events => {
|
this._testWatcher = new Watcher('flat', events => {
|
||||||
const collector = new Set<string>();
|
const collector = new Set<string>();
|
||||||
events.forEach(f => collectAffectedTestFiles(f.file, collector));
|
events.forEach(f => collectAffectedTestFiles(f.file, collector));
|
||||||
this._dispatchEvent('testFilesChanged', { testFileNames: [...collector] });
|
this._dispatchEvent('testFilesChanged', { testFiles: [...collector] });
|
||||||
});
|
});
|
||||||
|
this._dispatchEvent = (method, params) => this.transport.sendEvent?.(method, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ping() {}
|
async ping() {}
|
||||||
@ -252,10 +254,6 @@ class TestServerDispatcher implements TestServerInterface {
|
|||||||
async closeGracefully() {
|
async closeGracefully() {
|
||||||
gracefullyProcessExitDoNotHang(0);
|
gracefullyProcessExitDoNotHang(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
_dispatchEvent(method: string, params?: any) {
|
|
||||||
this.transport.sendEvent?.(method, params);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runTestServer(config: FullConfigInternal, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>) => Promise<void>): Promise<FullResult['status']> {
|
export async function runTestServer(config: FullConfigInternal, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>) => Promise<void>): Promise<FullResult['status']> {
|
||||||
|
@ -42,7 +42,7 @@ import type { ActionTraceEvent } from '@trace/trace';
|
|||||||
import { statusEx, TestTree } from '@testIsomorphic/testTree';
|
import { statusEx, TestTree } from '@testIsomorphic/testTree';
|
||||||
import type { TreeItem } from '@testIsomorphic/testTree';
|
import type { TreeItem } from '@testIsomorphic/testTree';
|
||||||
import { testStatusIcon } from './testUtils';
|
import { testStatusIcon } from './testUtils';
|
||||||
import { TestServerConnection } from './testServerConnection';
|
import { TestServerConnection } from '@testIsomorphic/testServerConnection';
|
||||||
|
|
||||||
let updateRootSuite: (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress | undefined) => void = () => {};
|
let updateRootSuite: (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress | undefined) => void = () => {};
|
||||||
let runWatchedTests = (fileNames: string[]) => {};
|
let runWatchedTests = (fileNames: string[]) => {};
|
||||||
@ -90,7 +90,10 @@ export const UIModeView: React.FC<{}> = ({
|
|||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const reloadTests = React.useCallback(() => {
|
const reloadTests = React.useCallback(() => {
|
||||||
const connection = new TestServerConnection();
|
const guid = new URLSearchParams(window.location.search).get('ws');
|
||||||
|
const wsURL = new URL(`../${guid}`, window.location.toString());
|
||||||
|
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
|
||||||
|
const connection = new TestServerConnection(wsURL.toString());
|
||||||
wireConnectionListeners(connection);
|
wireConnectionListeners(connection);
|
||||||
connection.onClose(() => setIsDisconnected(true));
|
connection.onClose(() => setIsDisconnected(true));
|
||||||
setTestServerConnection(connection);
|
setTestServerConnection(connection);
|
||||||
@ -653,8 +656,8 @@ const wireConnectionListeners = (testServerConnection: TestServerConnection) =>
|
|||||||
testServerConnection.listTests({}).catch(() => {});
|
testServerConnection.listTests({}).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
testServerConnection.onTestFilesChanged(testFiles => {
|
testServerConnection.onTestFilesChanged(params => {
|
||||||
runWatchedTests(testFiles);
|
runWatchedTests(params.testFiles);
|
||||||
});
|
});
|
||||||
|
|
||||||
testServerConnection.onStdio(params => {
|
testServerConnection.onStdio(params => {
|
||||||
|
@ -21,7 +21,7 @@ import { MultiTraceModel } from './modelUtil';
|
|||||||
import './workbenchLoader.css';
|
import './workbenchLoader.css';
|
||||||
import { toggleTheme } from '@web/theme';
|
import { toggleTheme } from '@web/theme';
|
||||||
import { Workbench } from './workbench';
|
import { Workbench } from './workbench';
|
||||||
import { connect } from './wsPort';
|
import { TestServerConnection } from '@testIsomorphic/testServerConnection';
|
||||||
|
|
||||||
export const WorkbenchLoader: React.FunctionComponent<{
|
export const WorkbenchLoader: React.FunctionComponent<{
|
||||||
}> = () => {
|
}> = () => {
|
||||||
@ -84,17 +84,14 @@ export const WorkbenchLoader: React.FunctionComponent<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (params.has('isServer')) {
|
if (params.has('isServer')) {
|
||||||
connect({
|
const guid = new URLSearchParams(window.location.search).get('ws');
|
||||||
onEvent(method: string, params?: any) {
|
const wsURL = new URL(`../${guid}`, window.location.toString());
|
||||||
if (method === 'loadTrace') {
|
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
|
||||||
setTraceURLs(params!.url ? [params!.url] : []);
|
const testServerConnection = new TestServerConnection(wsURL.toString());
|
||||||
|
testServerConnection.onLoadTraceRequested(async params => {
|
||||||
|
setTraceURLs(params.traceUrl ? [params.traceUrl] : []);
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
setProcessingErrorMessage(null);
|
setProcessingErrorMessage(null);
|
||||||
}
|
|
||||||
},
|
|
||||||
onClose() {}
|
|
||||||
}).then(sendMessage => {
|
|
||||||
sendMessage('ready');
|
|
||||||
});
|
});
|
||||||
} else if (!newTraceURLs.some(url => url.startsWith('blob:'))) {
|
} else if (!newTraceURLs.some(url => url.startsWith('blob:'))) {
|
||||||
// Don't re-use blob file URLs on page load (results in Fetch error)
|
// Don't re-use blob file URLs on page load (results in Fetch error)
|
||||||
|
@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
let lastId = 0;
|
|
||||||
let _ws: WebSocket;
|
|
||||||
const callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
|
|
||||||
|
|
||||||
export async function connect(options: { onEvent: (method: string, params?: any) => void, onClose: () => void }): Promise<(method: string, params?: any) => Promise<any>> {
|
|
||||||
const guid = new URLSearchParams(window.location.search).get('ws');
|
|
||||||
const wsURL = new URL(`../${guid}`, window.location.toString());
|
|
||||||
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
|
|
||||||
const ws = new WebSocket(wsURL);
|
|
||||||
await new Promise(f => ws.addEventListener('open', f));
|
|
||||||
ws.addEventListener('close', options.onClose);
|
|
||||||
ws.addEventListener('message', event => {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
const { id, result, error, method, params } = message;
|
|
||||||
if (id) {
|
|
||||||
const callback = callbacks.get(id);
|
|
||||||
if (!callback)
|
|
||||||
return;
|
|
||||||
callbacks.delete(id);
|
|
||||||
if (error)
|
|
||||||
callback.reject(new Error(error));
|
|
||||||
else
|
|
||||||
callback.resolve(result);
|
|
||||||
} else {
|
|
||||||
options.onEvent(method, params);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_ws = ws;
|
|
||||||
setInterval(() => sendMessage('ping').catch(() => {}), 30000);
|
|
||||||
return sendMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendMessage = async (method: string, params?: any): Promise<any> => {
|
|
||||||
const id = ++lastId;
|
|
||||||
const message = { id, method, params };
|
|
||||||
_ws.send(JSON.stringify(message));
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
callbacks.set(id, { resolve, reject });
|
|
||||||
});
|
|
||||||
};
|
|
@ -169,7 +169,7 @@ test('should pick new / deleted nested tests', async ({ runUITest, writeFiles, d
|
|||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update test locations', async ({ runUITest, writeFiles, deleteFile }) => {
|
test('should update test locations', async ({ runUITest, writeFiles }) => {
|
||||||
const { page } = await runUITest({
|
const { page } = await runUITest({
|
||||||
'a.test.ts': `
|
'a.test.ts': `
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
@ -182,8 +182,8 @@ test('should update test locations', async ({ runUITest, writeFiles, deleteFile
|
|||||||
◯ passes
|
◯ passes
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const messages: any = [];
|
const messages: any[] = [];
|
||||||
await page.exposeBinding('_sniffProtocolForTest', (_, data) => messages.push(data));
|
await page.exposeBinding('__logForTest', (source, arg) => messages.push(arg));
|
||||||
|
|
||||||
const passesItemLocator = page.getByRole('listitem').filter({ hasText: 'passes' });
|
const passesItemLocator = page.getByRole('listitem').filter({ hasText: 'passes' });
|
||||||
await passesItemLocator.hover();
|
await passesItemLocator.hover();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user