chore: implement pick locator in trace viewer (#20965)

Fixes https://github.com/microsoft/playwright/issues/7853
This commit is contained in:
Pavel Feldman 2023-02-17 11:19:53 -08:00 коммит произвёл GitHub
Родитель d96d3c3381
Коммит d7a0b3bb4e
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
44 изменённых файлов: 443 добавлений и 182 удалений

98
package-lock.json сгенерированный
Просмотреть файл

@ -21,6 +21,7 @@
"@babel/plugin-transform-modules-commonjs": "^7.19.6",
"@babel/plugin-transform-typescript": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@types/babel__core": "^7.20.0",
"@types/codemirror": "^5.60.5",
"@types/formidable": "^2.0.4",
"@types/node": "=14.18.34",
@ -1335,6 +1336,47 @@
"node": ">=6"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.0",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz",
"integrity": "sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==",
"dev": true,
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"node_modules/@types/babel__generator": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz",
"integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==",
"dev": true,
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz",
"integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==",
"dev": true,
"dependencies": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.18.3.tgz",
"integrity": "sha512-1kbcJ40lLB7MHsj39U4Sh1uTd2E7rLEa79kmDpI6cy+XiXsteB3POdQomoq4FxszMrO3ZYchkhYJw7A2862b3w==",
"dev": true,
"dependencies": {
"@babel/types": "^7.3.0"
}
},
"node_modules/@types/codemirror": {
"version": "5.60.5",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.5.tgz",
@ -5939,6 +5981,9 @@
"@vitejs/plugin-react": "^3.1.0",
"vite": "^4.1.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=14"
}
@ -5952,6 +5997,9 @@
"vite": "^4.1.1",
"vite-plugin-solid": "^2.5.0"
},
"bin": {
"playwright": "cli.js"
},
"devDependencies": {
"solid-js": "^1.6.10"
},
@ -5968,6 +6016,9 @@
"@sveltejs/vite-plugin-svelte": "^2.0.2",
"vite": "^4.1.1"
},
"bin": {
"playwright": "cli.js"
},
"devDependencies": {
"svelte": "^3.55.1"
},
@ -6004,6 +6055,9 @@
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.1.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=14"
}
@ -6053,6 +6107,9 @@
"@vitejs/plugin-vue2": "^2.2.0",
"vite": "^4.1.1"
},
"bin": {
"playwright": "cli.js"
},
"devDependencies": {
"vue": "^2.7.14"
},
@ -6942,6 +6999,47 @@
"defer-to-connect": "^1.0.1"
}
},
"@types/babel__core": {
"version": "7.20.0",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz",
"integrity": "sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==",
"dev": true,
"requires": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"@types/babel__generator": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz",
"integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==",
"dev": true,
"requires": {
"@babel/types": "^7.0.0"
}
},
"@types/babel__template": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz",
"integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==",
"dev": true,
"requires": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"@types/babel__traverse": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.18.3.tgz",
"integrity": "sha512-1kbcJ40lLB7MHsj39U4Sh1uTd2E7rLEa79kmDpI6cy+XiXsteB3POdQomoq4FxszMrO3ZYchkhYJw7A2862b3w==",
"dev": true,
"requires": {
"@babel/types": "^7.3.0"
}
},
"@types/codemirror": {
"version": "5.60.5",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.5.tgz",

Просмотреть файл

@ -37,7 +37,7 @@
"clean": "node utils/build/clean.js",
"build": "node utils/build/build.js",
"watch": "node utils/build/build.js --watch --lint",
"test-types": "node utils/generate_types/ && npx -p typescript@3.7.5 tsc -p utils/generate_types/test/tsconfig.json && tsc -p ./tests/",
"test-types": "node utils/generate_types/ && tsc -p utils/generate_types/test/tsconfig.json && tsc -p ./tests/",
"roll": "node utils/roll_browser.js",
"check-deps": "node utils/check_deps.js",
"build-android-driver": "./utils/build_android_driver.sh",
@ -56,6 +56,7 @@
"@babel/plugin-transform-modules-commonjs": "^7.19.6",
"@babel/plugin-transform-typescript": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@types/babel__core": "^7.20.0",
"@types/codemirror": "^5.60.5",
"@types/formidable": "^2.0.4",
"@types/node": "=14.18.34",

Просмотреть файл

@ -103,6 +103,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
const module = {};
${injectedScriptSource.source}
return new module.exports(
globalThis,
${isUnderTest()},
"${sdkLanguage}",
${JSON.stringify(this.frame._page.selectors.testIdAttributeName())},

Просмотреть файл

@ -0,0 +1,10 @@
module.exports = {
rules: {
"no-restricted-globals": [
"error",
{ "name": "window" },
{ "name": "document" },
{ "name": "globalThis" },
]
}
};

Просмотреть файл

@ -38,8 +38,8 @@ class Locator {
selector += ` >> internal:has=` + JSON.stringify((options.has as any)[selectorSymbol]);
if (selector) {
const parsed = injectedScript.parseSelector(selector);
this.element = injectedScript.querySelector(parsed, document, false);
this.elements = injectedScript.querySelectorAll(parsed, document);
this.element = injectedScript.querySelector(parsed, injectedScript.document, false);
this.elements = injectedScript.querySelectorAll(parsed, injectedScript.document);
}
const selectorBase = selector;
const self = this as any;
@ -73,9 +73,9 @@ class ConsoleAPI {
constructor(injectedScript: InjectedScript) {
this._injectedScript = injectedScript;
if (window.playwright)
if (this._injectedScript.window.playwright)
return;
window.playwright = {
this._injectedScript.window.playwright = {
$: (selector: string, strict?: boolean) => this._querySelector(selector, !!strict),
$$: (selector: string) => this._querySelectorAll(selector),
inspect: (selector: string) => this._inspect(selector),
@ -84,30 +84,30 @@ class ConsoleAPI {
resume: () => this._resume(),
...new Locator(injectedScript, ''),
};
delete window.playwright.filter;
delete window.playwright.first;
delete window.playwright.last;
delete window.playwright.nth;
delete this._injectedScript.window.playwright.filter;
delete this._injectedScript.window.playwright.first;
delete this._injectedScript.window.playwright.last;
delete this._injectedScript.window.playwright.nth;
}
private _querySelector(selector: string, strict: boolean): (Element | undefined) {
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.query('Playwright >> selector').`);
const parsed = this._injectedScript.parseSelector(selector);
return this._injectedScript.querySelector(parsed, document, strict);
return this._injectedScript.querySelector(parsed, this._injectedScript.document, strict);
}
private _querySelectorAll(selector: string): Element[] {
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
const parsed = this._injectedScript.parseSelector(selector);
return this._injectedScript.querySelectorAll(parsed, document);
return this._injectedScript.querySelectorAll(parsed, this._injectedScript.document);
}
private _inspect(selector: string) {
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.inspect('Playwright >> selector').`);
window.inspect(this._querySelector(selector, false));
this._injectedScript.window.inspect(this._querySelector(selector, false));
}
private _selector(element: Element) {
@ -124,7 +124,7 @@ class ConsoleAPI {
}
private _resume() {
window.__pw_resume().catch(() => {});
this._injectedScript.window.__pw_resume().catch(() => {});
}
}

Просмотреть файл

@ -105,7 +105,7 @@ export function isElementVisible(element: Element): boolean {
function isVisibleTextNode(node: Text) {
// https://stackoverflow.com/questions/1461059/is-there-an-equivalent-to-getboundingclientrect-for-text-nodes
const range = document.createRange();
const range = node.ownerDocument.createRange();
range.selectNode(node);
const rect = range.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;

Просмотреть файл

@ -42,6 +42,7 @@ export class Highlight {
constructor(injectedScript: InjectedScript) {
this._injectedScript = injectedScript;
const document = injectedScript.document;
this._isUnderTest = injectedScript.isUnderTest;
this._glassPaneElement = document.createElement('x-pw-glass');
this._glassPaneElement.style.position = 'fixed';
@ -100,7 +101,7 @@ export class Highlight {
}
install() {
document.documentElement.appendChild(this._glassPaneElement);
this._injectedScript.document.documentElement.appendChild(this._glassPaneElement);
}
setLanguage(language: Language) {
@ -110,7 +111,7 @@ export class Highlight {
runHighlightOnRaf(selector: ParsedSelector) {
if (this._rafRequest)
cancelAnimationFrame(this._rafRequest);
this.updateHighlight(this._injectedScript.querySelectorAll(selector, document.documentElement), stringifySelector(selector), false);
this.updateHighlight(this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement), stringifySelector(selector), false);
this._rafRequest = requestAnimationFrame(() => this.runHighlightOnRaf(selector));
}
@ -121,7 +122,7 @@ export class Highlight {
}
isInstalled(): boolean {
return this._glassPaneElement.parentElement === document.documentElement && !this._glassPaneElement.nextElementSibling;
return this._glassPaneElement.parentElement === this._injectedScript.document.documentElement && !this._glassPaneElement.nextElementSibling;
}
showActionPoint(x: number, y: number) {
@ -173,7 +174,7 @@ export class Highlight {
let tooltipElement;
if (options.tooltipText) {
tooltipElement = document.createElement('x-pw-tooltip');
tooltipElement = this._injectedScript.document.createElement('x-pw-tooltip');
this._glassPaneShadow.appendChild(tooltipElement);
const suffix = elements.length > 1 ? ` [${i + 1} of ${elements.length}]` : '';
tooltipElement.textContent = options.tooltipText + suffix;
@ -252,7 +253,7 @@ export class Highlight {
}
private _createHighlightElement(): HTMLElement {
const highlightElement = document.createElement('x-pw-highlight');
const highlightElement = this._injectedScript.document.createElement('x-pw-highlight');
highlightElement.style.position = 'absolute';
highlightElement.style.top = '0';
highlightElement.style.left = '0';

Просмотреть файл

@ -79,8 +79,14 @@ export class InjectedScript {
private _sdkLanguage: Language;
private _testIdAttributeNameForStrictErrorAndConsoleCodegen: string = 'data-testid';
private _markedTargetElements = new Set<Element>();
// eslint-disable-next-line no-restricted-globals
readonly window: Window & typeof globalThis;
readonly document: Document;
constructor(isUnderTest: boolean, sdkLanguage: Language, testIdAttributeNameForStrictErrorAndConsoleCodegen: string, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine }[]) {
// eslint-disable-next-line no-restricted-globals
constructor(window: Window & typeof globalThis, isUnderTest: boolean, sdkLanguage: Language, testIdAttributeNameForStrictErrorAndConsoleCodegen: string, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine }[]) {
this.window = window;
this.document = window.document;
this.isUnderTest = isUnderTest;
this._sdkLanguage = sdkLanguage;
this._testIdAttributeNameForStrictErrorAndConsoleCodegen = testIdAttributeNameForStrictErrorAndConsoleCodegen;
@ -124,11 +130,11 @@ export class InjectedScript {
this._setupHitTargetInterceptors();
if (isUnderTest)
(window as any).__injectedScript = this;
(this.window as any).__injectedScript = this;
}
eval(expression: string): any {
return globalThis.eval(expression);
return this.window.eval(expression);
}
testIdAttributeNameForStrictErrorAndConsoleCodegen(): string {
@ -370,7 +376,7 @@ export class InjectedScript {
}
extend(source: string, params: any): any {
const constrFunction = globalThis.eval(`
const constrFunction = this.window.eval(`
(() => {
const module = {};
${source}
@ -827,7 +833,7 @@ export class InjectedScript {
const elements: Element[] = root.elementsFromPoint(hitPoint.x, hitPoint.y);
const singleElement = root.elementFromPoint(hitPoint.x, hitPoint.y);
if (singleElement && elements[0] && parentElementOrShadowHost(singleElement) === elements[0]) {
const style = document.defaultView?.getComputedStyle(singleElement);
const style = this.window.getComputedStyle(singleElement);
if (style?.display === 'contents') {
// Workaround a case where elementsFromPoint misses the inner-most element with display:contents.
// https://bugs.chromium.org/p/chromium/issues/detail?id=1342092
@ -851,7 +857,7 @@ export class InjectedScript {
if (hitElement === targetElement)
return 'done';
const hitTargetDescription = this.previewNode(hitParents[0] || document.documentElement);
const hitTargetDescription = this.previewNode(hitParents[0] || this.document.documentElement);
// Root is the topmost element in the hitTarget's chain that is not in the
// element's chain. For example, it might be a dialog element that overlays
// the target.
@ -939,7 +945,7 @@ export class InjectedScript {
return;
// Determine the event point. Note that Firefox does not always have window.TouchEvent.
const point = (!!window.TouchEvent && (event instanceof window.TouchEvent)) ? event.touches[0] : (event as MouseEvent | PointerEvent);
const point = (!!this.window.TouchEvent && (event instanceof this.window.TouchEvent)) ? event.touches[0] : (event as MouseEvent | PointerEvent);
// Check that we hit the right element at the first event, and assume all
// subsequent events will be fine.
@ -1053,7 +1059,7 @@ export class InjectedScript {
this._highlight.install();
const elements = [];
for (const selector of selectors)
elements.push(this.querySelectorAll(selector, document.documentElement));
elements.push(this.querySelectorAll(selector, this.document.documentElement));
this._highlight.maskElements(elements.flat());
}
@ -1089,31 +1095,31 @@ export class InjectedScript {
let seenEvent = false;
const handleCustomEvent = () => seenEvent = true;
window.addEventListener(customEventName, handleCustomEvent);
this.window.addEventListener(customEventName, handleCustomEvent);
new MutationObserver(entries => {
const newDocumentElement = entries.some(entry => Array.from(entry.addedNodes).includes(document.documentElement));
const newDocumentElement = entries.some(entry => Array.from(entry.addedNodes).includes(this.document.documentElement));
if (!newDocumentElement)
return;
// New documentElement - let's check whether listeners are still here.
seenEvent = false;
window.dispatchEvent(new CustomEvent(customEventName));
this.window.dispatchEvent(new CustomEvent(customEventName));
if (seenEvent)
return;
// Listener did not fire. Reattach the listener and notify.
window.addEventListener(customEventName, handleCustomEvent);
this.window.addEventListener(customEventName, handleCustomEvent);
for (const callback of this.onGlobalListenersRemoved)
callback();
}).observe(document, { childList: true });
}).observe(this.document, { childList: true });
}
private _setupHitTargetInterceptors() {
const listener = (event: PointerEvent | MouseEvent | TouchEvent) => this._hitTargetInterceptor?.(event);
const addHitTargetInterceptorListeners = () => {
for (const event of kAllHitTargetInterceptorEvents)
window.addEventListener(event as any, listener, { capture: true, passive: false });
this.window.addEventListener(event as any, listener, { capture: true, passive: false });
};
addHitTargetInterceptorListeners();
this.onGlobalListenersRemoved.add(addHitTargetInterceptorListeners);
@ -1220,15 +1226,15 @@ export class InjectedScript {
} else if (expression === 'to.have.class') {
received = element.classList.toString();
} else if (expression === 'to.have.css') {
received = window.getComputedStyle(element).getPropertyValue(options.expressionArg);
received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg);
} else if (expression === 'to.have.id') {
received = element.id;
} else if (expression === 'to.have.text') {
received = options.useInnerText ? (element as HTMLElement).innerText : elementText(new Map(), element).full;
} else if (expression === 'to.have.title') {
received = document.title;
received = this.document.title;
} else if (expression === 'to.have.url') {
received = document.location.href;
received = this.document.location.href;
} else if (expression === 'to.have.value') {
element = this.retarget(element, 'follow-label')!;
if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT')

Просмотреть файл

@ -142,6 +142,7 @@ function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: Componen
}
function findReactRoots(root: Document | ShadowRoot, roots: ReactVNode[] = []): ReactVNode[] {
const document = root.ownerDocument || root;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
do {
const node = walker.currentNode;
@ -179,7 +180,7 @@ export const ReactEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, selector: string): Element[] {
const { name, attributes } = parseAttributeSelector(selector, false);
const reactRoots = findReactRoots(document);
const reactRoots = findReactRoots(scope.ownerDocument || scope);
const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot));
const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => {
const props = treeNode.props ?? {};

Просмотреть файл

@ -21,16 +21,13 @@ import type { Point } from '../../common/types';
import type { UIState } from '@recorder/recorderTypes';
import { Highlight } from '../injected/highlight';
declare module globalThis {
let __pw_recorderPerformAction: (action: actions.Action) => Promise<void>;
let __pw_recorderRecordAction: (action: actions.Action) => Promise<void>;
let __pw_recorderState: () => Promise<UIState>;
let __pw_recorderSetSelector: (selector: string) => Promise<void>;
let __pw_refreshOverlay: () => void;
interface RecorderDelegate {
performAction?(action: actions.Action): Promise<void>;
recordAction?(action: actions.Action): Promise<void>;
setSelector?(selector: string): Promise<void>;
}
class Recorder {
export class Recorder {
private _injectedScript: InjectedScript;
private _performingAction = false;
private _listeners: (() => void)[] = [];
@ -38,45 +35,43 @@ class Recorder {
private _hoveredElement: HTMLElement | null = null;
private _activeModel: HighlightModel | null = null;
private _expectProgrammaticKeyUp = false;
private _pollRecorderModeTimer: NodeJS.Timeout | undefined;
private _mode: 'none' | 'inspecting' | 'recording' = 'none';
private _actionPoint: Point | undefined;
private _actionSelector: string | undefined;
private _highlight: Highlight;
private _testIdAttributeName: string = 'data-testid';
readonly document: Document;
private _delegate: RecorderDelegate;
constructor(injectedScript: InjectedScript) {
constructor(injectedScript: InjectedScript, delegate: RecorderDelegate) {
this.document = injectedScript.document;
this._injectedScript = injectedScript;
this._delegate = delegate;
this._highlight = new Highlight(injectedScript);
this._refreshListenersIfNeeded();
injectedScript.onGlobalListenersRemoved.add(() => this._refreshListenersIfNeeded());
this.refreshListenersIfNeeded();
globalThis.__pw_refreshOverlay = () => {
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
};
globalThis.__pw_refreshOverlay();
if (injectedScript.isUnderTest)
console.error('Recorder script ready for test'); // eslint-disable-line no-console
}
private _refreshListenersIfNeeded() {
refreshListenersIfNeeded() {
// Ensure we are attached to the current document, and we are on top (last element);
if (this._highlight.isInstalled())
return;
removeEventListeners(this._listeners);
this._listeners = [
addEventListener(document, 'click', event => this._onClick(event as MouseEvent), true),
addEventListener(document, 'auxclick', event => this._onClick(event as MouseEvent), true),
addEventListener(document, 'input', event => this._onInput(event), true),
addEventListener(document, 'keydown', event => this._onKeyDown(event as KeyboardEvent), true),
addEventListener(document, 'keyup', event => this._onKeyUp(event as KeyboardEvent), true),
addEventListener(document, 'mousedown', event => this._onMouseDown(event as MouseEvent), true),
addEventListener(document, 'mouseup', event => this._onMouseUp(event as MouseEvent), true),
addEventListener(document, 'mousemove', event => this._onMouseMove(event as MouseEvent), true),
addEventListener(document, 'mouseleave', event => this._onMouseLeave(event as MouseEvent), true),
addEventListener(document, 'focus', event => event.isTrusted && this._onFocus(true), true),
addEventListener(document, 'scroll', event => {
addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true),
addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true),
addEventListener(this.document, 'input', event => this._onInput(event), true),
addEventListener(this.document, 'keydown', event => this._onKeyDown(event as KeyboardEvent), true),
addEventListener(this.document, 'keyup', event => this._onKeyUp(event as KeyboardEvent), true),
addEventListener(this.document, 'mousedown', event => this._onMouseDown(event as MouseEvent), true),
addEventListener(this.document, 'mouseup', event => this._onMouseUp(event as MouseEvent), true),
addEventListener(this.document, 'mousemove', event => this._onMouseMove(event as MouseEvent), true),
addEventListener(this.document, 'mouseleave', event => this._onMouseLeave(event as MouseEvent), true),
addEventListener(this.document, 'focus', event => event.isTrusted && this._onFocus(true), true),
addEventListener(this.document, 'scroll', event => {
if (!event.isTrusted)
return;
this._hoveredModel = null;
@ -87,16 +82,7 @@ class Recorder {
this._highlight.install();
}
private async _pollRecorderMode() {
const pollPeriod = 1000;
if (this._pollRecorderModeTimer)
clearTimeout(this._pollRecorderModeTimer);
const state = await globalThis.__pw_recorderState().catch(e => null);
if (!state) {
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
return;
}
setUIState(state: UIState) {
const { mode, actionPoint, actionSelector, language, testIdAttributeName } = state;
this._testIdAttributeName = testIdAttributeName;
this._highlight.setLanguage(language);
@ -121,11 +107,10 @@ class Recorder {
this._actionSelector = undefined;
if (actionSelector !== this._actionSelector) {
this._hoveredModel = actionSelector ? querySelector(this._injectedScript, actionSelector, document) : null;
this._hoveredModel = actionSelector ? querySelector(this._injectedScript, actionSelector, this.document) : null;
this._updateHighlight();
this._actionSelector = actionSelector;
}
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
}
private _clearHighlight() {
@ -161,7 +146,7 @@ class Recorder {
if (!event.isTrusted)
return;
if (this._mode === 'inspecting')
globalThis.__pw_recorderSetSelector(this._hoveredModel ? this._hoveredModel.selector : '');
this._delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : '');
if (this._shouldIgnoreMouseEvent(event))
return;
if (this._actionInProgress(event))
@ -242,7 +227,7 @@ class Recorder {
if (!event.isTrusted)
return;
// Leaving iframe.
if (window.top !== window && this._deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
if (this._injectedScript.window.top !== this._injectedScript.window && this._deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
this._hoveredElement = null;
this._updateModelForHoveredElement();
}
@ -251,10 +236,10 @@ class Recorder {
private _onFocus(userGesture: boolean) {
if (this._mode === 'none')
return;
const activeElement = this._deepActiveElement(document);
const activeElement = this._deepActiveElement(this.document);
// Firefox dispatches "focus" event to body when clicking on a backgrounded headed browser window.
// We'd like to ignore this stray event.
if (activeElement === document.body)
if (activeElement === this.document.body)
return;
const result = activeElement ? generateSelector(this._injectedScript, activeElement, this._testIdAttributeName) : null;
this._activeModel = result && result.selector ? result : null;
@ -289,7 +274,7 @@ class Recorder {
const target = this._deepEventTarget(event);
if (target.nodeName === 'INPUT' && (target as HTMLInputElement).type.toLowerCase() === 'file') {
globalThis.__pw_recorderRecordAction({
this._delegate.recordAction?.({
name: 'setInputFiles',
selector: this._activeModel!.selector,
signals: [],
@ -307,7 +292,7 @@ class Recorder {
// Non-navigating actions are simply recorded by Playwright.
if (this._consumedDueWrongTarget(event))
return;
globalThis.__pw_recorderRecordAction({
this._delegate.recordAction?.({
name: 'fill',
selector: this._activeModel!.selector,
signals: [],
@ -411,7 +396,7 @@ class Recorder {
private async _performAction(action: actions.Action) {
this._clearHighlight();
this._performingAction = true;
await globalThis.__pw_recorderPerformAction(action).catch(() => {});
await this._delegate.performAction?.(action).catch(() => {});
this._performingAction = false;
// If that was a keyboard action, it similarly requires new selectors for active model.
@ -494,4 +479,60 @@ function removeEventListeners(listeners: (() => void)[]) {
listeners.splice(0, listeners.length);
}
module.exports = Recorder;
interface Embedder {
__pw_recorderPerformAction(action: actions.Action): Promise<void>;
__pw_recorderRecordAction(action: actions.Action): Promise<void>;
__pw_recorderState(): Promise<UIState>;
__pw_recorderSetSelector(selector: string): Promise<void>;
__pw_refreshOverlay(): void;
}
class PollingRecorder implements RecorderDelegate {
private _recorder: Recorder;
private _embedder: Embedder;
private _pollRecorderModeTimer: NodeJS.Timeout | undefined;
constructor(injectedScript: InjectedScript) {
this._recorder = new Recorder(injectedScript, this);
this._embedder = injectedScript.window as any;
injectedScript.onGlobalListenersRemoved.add(() => this._recorder.refreshListenersIfNeeded());
const refreshOverlay = () => {
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
};
this._embedder.__pw_refreshOverlay = refreshOverlay;
refreshOverlay();
}
private async _pollRecorderMode() {
const pollPeriod = 1000;
if (this._pollRecorderModeTimer)
clearTimeout(this._pollRecorderModeTimer);
const state = await this._embedder.__pw_recorderState().catch(() => {});
if (!state) {
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
return;
}
this._recorder.setUIState(state);
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
}
async performAction(action: actions.Action) {
await this._embedder.__pw_recorderPerformAction(action);
}
async recordAction(action: actions.Action): Promise<void> {
await this._embedder.__pw_recorderRecordAction(action);
}
async __pw_recorderState(): Promise<UIState> {
return await this._embedder.__pw_recorderState();
}
async setSelector(selector: string): Promise<void> {
await this._embedder.__pw_recorderSetSelector(selector);
}
}
module.exports = PollingRecorder;

Просмотреть файл

@ -51,6 +51,7 @@ export function matchesAttributePart(value: any, attr: AttributeSelectorPart) {
}
export function shouldSkipForTextMatching(element: Element | ShadowRoot) {
const document = element.ownerDocument;
return element.nodeName === 'SCRIPT' || element.nodeName === 'NOSCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element);
}

Просмотреть файл

@ -27,6 +27,7 @@ export class UtilityScript {
if (exposeUtilityScript)
parameters.unshift(this);
// eslint-disable-next-line no-restricted-globals
let result = globalThis.eval(expression);
if (isFunction === true) {
result = result(...parameters);

Просмотреть файл

@ -208,6 +208,7 @@ function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: Componen
type VueRoot = {version: number, root: VueVNode};
function findVueRoots(root: Document | ShadowRoot, roots: VueRoot[] = []): VueRoot[] {
const document = root.ownerDocument || root;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
// Vue2 roots are referred to from elements.
const vue2Roots: Set<VueVNode> = new Set();
@ -233,6 +234,7 @@ function findVueRoots(root: Document | ShadowRoot, roots: VueRoot[] = []): VueRo
export const VueEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, selector: string): Element[] {
const document = scope.ownerDocument || scope;
const { name, attributes } = parseAttributeSelector(selector, false);
const vueRoots = findVueRoots(document);
const trees = vueRoots.map(vueRoot => vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root));

Просмотреть файл

@ -93,6 +93,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
waitForContentOnStop: false,
skipScripts: true,
});
const testIdAttributeName = ('selectors' in context) ? context.selectors().testIdAttributeName() : undefined;
this._contextCreatedEvent = {
version,
type: 'context-options',
@ -101,6 +102,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
platform: process.platform,
wallTime: 0,
sdkLanguage: (context as BrowserContext)?._browser?.options?.sdkLanguage,
testIdAttributeName
};
if (context instanceof BrowserContext) {
this._snapshotter = new Snapshotter(context, this);

Просмотреть файл

@ -25,6 +25,7 @@ import { CallLogView } from './callLog';
import './recorder.css';
import { asLocator } from '@isomorphic/locatorGenerators';
import { toggleTheme } from '@web/theme';
import { copy } from '@web/uiUtils';
declare global {
interface Window {
@ -171,14 +172,3 @@ function renderSourceOptions(sources: Source[]): React.ReactNode {
return sources.map(source => renderOption(source));
}
function copy(text: string) {
const textArea = document.createElement('textarea');
textArea.style.position = 'absolute';
textArea.style.zIndex = '-1000';
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
textArea.remove();
}

Просмотреть файл

@ -14,9 +14,6 @@
* limitations under the License.
*/
import fs, { existsSync } from 'fs';
import path from 'path';
/**
* @returns {import('vite').Plugin}
*/

Просмотреть файл

@ -26,6 +26,7 @@ export type ContextEntry = {
platform?: string;
wallTime?: number;
sdkLanguage?: Language;
testIdAttributeName?: string;
title?: string;
options: trace.BrowserContextEventOptions;
pages: PageEntry[];

Просмотреть файл

@ -15,6 +15,7 @@
*/
import '@web/third_party/vscode/codicon.css';
import React from 'react';
import * as ReactDOM from 'react-dom';
import { applyTheme } from '@web/theme';
import '@web/common.css';

Просмотреть файл

@ -132,6 +132,7 @@ export class TraceModel {
this.contextEntry.wallTime = event.wallTime;
this.contextEntry.sdkLanguage = event.sdkLanguage;
this.contextEntry.options = event.options;
this.contextEntry.testIdAttributeName = event.testIdAttributeName;
break;
}
case 'screencast-frame': {

Просмотреть файл

@ -1,4 +1,5 @@
[*]
@injected/**
@isomorphic/**
@web/**
../entries.ts

Просмотреть файл

@ -20,7 +20,6 @@ import { ListView } from '@web/components/listView';
import * as React from 'react';
import './actionList.css';
import * as modelUtil from './modelUtil';
import './tabbedPane.css';
import { asLocator } from '@isomorphic/locatorGenerators';
import type { Language } from '@isomorphic/locatorGenerators';

Просмотреть файл

@ -37,7 +37,6 @@
.call-section {
padding-left: 6px;
border-top: 1px solid var(--vscode-panel-border);
font-weight: bold;
text-transform: uppercase;
font-size: 10px;
@ -45,6 +44,10 @@
line-height: 24px;
}
.call-section:not(:first-child) {
border-top: 1px solid var(--vscode-panel-border);
}
.call-line {
padding: 4px 0 4px 6px;
display: flex;

Просмотреть файл

@ -38,12 +38,14 @@ export class MultiTraceModel {
readonly events: trace.ActionTraceEvent[];
readonly hasSource: boolean;
readonly sdkLanguage: Language | undefined;
readonly testIdAttributeName: string | undefined;
constructor(contexts: ContextEntry[]) {
contexts.forEach(contextEntry => indexModel(contextEntry));
this.browserName = contexts[0]?.browserName || '';
this.sdkLanguage = contexts[0]?.sdkLanguage;
this.testIdAttributeName = contexts[0]?.testIdAttributeName;
this.platform = contexts[0]?.platform || '';
this.title = contexts[0]?.title || '';
this.options = contexts[0]?.options || {};

Просмотреть файл

@ -150,3 +150,7 @@ iframe#snapshot {
body.dark-mode .window-header {
background: #444950;
}
.snapshot-tab .cm-wrapper {
line-height: 23px;
}

Просмотреть файл

@ -15,17 +15,31 @@
*/
import './snapshotTab.css';
import './tabbedPane.css';
import * as React from 'react';
import { useMeasure } from './helpers';
import type { ActionTraceEvent } from '@trace/trace';
import { context } from './modelUtil';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton } from '@web/components/toolbarButton';
import { copy } from '@web/uiUtils';
import { InjectedScript } from '@injected/injectedScript';
import { Recorder } from '@injected/recorder';
import { asLocator } from '@isomorphic/locatorGenerators';
import type { Language } from '@isomorphic/locatorGenerators';
import { locatorOrSelectorAsSelector } from '@isomorphic/locatorParser';
import { TabbedPaneTab } from '@web/components/tabbedPane';
export const SnapshotTab: React.FunctionComponent<{
action: ActionTraceEvent | undefined,
}> = ({ action }) => {
sdkLanguage: Language,
testIdAttributeName: string,
}> = ({ action, sdkLanguage, testIdAttributeName }) => {
const [mode, setMode] = React.useState<'none' | 'inspecting'>('none');
const [measure, ref] = useMeasure<HTMLDivElement>();
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
const [locator, setLocator] = React.useState<string>('');
const [pickerVisible, setPickerVisible] = React.useState(false);
const snapshotMap = new Map<string, { title: string, snapshotName: string }>();
for (const snapshot of action?.metadata.snapshots || [])
@ -93,6 +107,16 @@ export const SnapshotTab: React.FunctionComponent<{
x: (measure.width - snapshotContainerSize.width) / 2,
y: (measure.height - snapshotContainerSize.height) / 2,
};
const recorderGetter = () => {
if (!iframeRef.current)
return;
return getOrCreateRecorder(iframeRef.current.contentWindow!, true, sdkLanguage, testIdAttributeName, locator => {
setLocator(locator);
setMode('none');
});
};
return <div
className='snapshot-tab'
tabIndex={0}
@ -102,19 +126,45 @@ export const SnapshotTab: React.FunctionComponent<{
if (event.key === 'ArrowLeft')
setSnapshotIndex(Math.max(snapshotIndex - 1, 0));
}}
><div className='tab-strip'>
>
<Toolbar>
<ToolbarButton title='Pick locator' disabled={!popoutUrl} toggled={pickerVisible} onClick={() => {
setPickerVisible(!pickerVisible);
setMode(mode === 'inspecting' ? 'none' : 'inspecting');
const recorder = recorderGetter();
recorder?.setUIState({ mode: pickerVisible ? 'none' : 'inspecting', language: sdkLanguage, testIdAttributeName });
}}>Pick locator</ToolbarButton>
<div style={{ width: 5 }}></div>
{snapshots.map((snapshot, index) => {
return <div className={'tab-element ' + (snapshotIndex === index ? ' selected' : '')}
onClick={() => setSnapshotIndex(index)}
key={snapshot.title}>
<div className='tab-label'>{renderTitle(snapshot.title)}</div>
</div>;
return <TabbedPaneTab
id={snapshot.title}
title={renderTitle(snapshot.title)}
selected={snapshotIndex === index}
onSelect={() => setSnapshotIndex(index)}
></TabbedPaneTab>;
})}
</div>
<div style={{ flex: 'auto' }}></div>
<ToolbarButton icon='link-external' title='Open snapshot in a new tab' disabled={!popoutUrl} onClick={() => {
window.open(popoutUrl || '', '_blank');
}}></ToolbarButton>
</Toolbar>
{pickerVisible && <Toolbar>
<ToolbarButton icon='microscope' title='Pick locator' disabled={!popoutUrl} toggled={mode === 'inspecting'} onClick={() => {
setMode(mode === 'inspecting' ? 'none' : 'inspecting');
const recorder = recorderGetter();
recorder?.setUIState({ mode: mode === 'inspecting' ? 'none' : 'inspecting', language: sdkLanguage, testIdAttributeName });
}}></ToolbarButton>
<CodeMirrorWrapper text={locator} language={sdkLanguage} readOnly={!popoutUrl} focusOnChange={true} wrapLines={true} onChange={text => {
const recorder = recorderGetter();
const actionSelector = locatorOrSelectorAsSelector(sdkLanguage, text, testIdAttributeName);
recorder?.setUIState({ mode: 'none', language: sdkLanguage, testIdAttributeName, actionSelector });
setLocator(text);
}}></CodeMirrorWrapper>
<ToolbarButton icon='files' title='Copy locator' disabled={!popoutUrl} onClick={() => {
copy(locator);
}}></ToolbarButton>
</Toolbar>}
<div ref={ref} className='snapshot-wrapper'>
<a className={`popout-icon ${popoutUrl ? '' : 'popout-disabled'}`} href={popoutUrl} target='_blank' title='Open snapshot in a new tab'>
<span className='codicon codicon-link-external'/>
</a>
{ snapshots.length ? <div className='snapshot-container' style={{
width: snapshotContainerSize.width + 'px',
height: snapshotContainerSize.height + 'px',
@ -126,7 +176,7 @@ export const SnapshotTab: React.FunctionComponent<{
<span className='window-dot' style={{ backgroundColor: 'rgb(251, 190, 60)' }}></span>
<span className='window-dot' style={{ backgroundColor: 'rgb(88, 203, 66)' }}></span>
</div>
<div className='window-address-bar' title={snapshotInfo.url}>{snapshotInfo.url}</div>
<div className='window-address-bar' title={snapshotInfo.url || 'about:blank'}>{snapshotInfo.url || 'about:blank'}</div>
<div style={{ marginLeft: 'auto' }}>
<div>
<span className='window-menu-bar'></span>
@ -142,6 +192,25 @@ export const SnapshotTab: React.FunctionComponent<{
</div>;
};
function getOrCreateRecorder(contentWindow: Window, enabled: boolean, sdkLanguage: Language, testIdAttributeName: string, setLocator: (locator: string) => void): Recorder | undefined {
const win = contentWindow as any;
if (!enabled && !win._recorder)
return;
let recorder: Recorder | undefined = win._recorder;
if (!recorder) {
const injectedScript = new InjectedScript(contentWindow as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []);
recorder = new Recorder(injectedScript, {
async setSelector(selector: string) {
recorder!.setUIState({ mode: 'none', language: sdkLanguage, testIdAttributeName });
setLocator(asLocator('javascript', selector, false));
}
});
win._recorder = recorder;
}
return recorder;
}
function renderTitle(snapshotTitle: string): string {
if (snapshotTitle === 'before')
return 'Before';

Просмотреть файл

@ -90,17 +90,6 @@
padding: 8px 4px;
}
.workbench tab-content {
padding: 25px;
contain: size;
}
.workbench tab-strip {
margin-left: calc(-1*var(--sidebar-width));
padding-left: var(--sidebar-width);
box-shadow: var(--box-shadow);
}
.workbench .logo {
font-size: 20px;
margin-left: 16px;

Просмотреть файл

@ -28,7 +28,7 @@ import { MultiTraceModel } from './modelUtil';
import { NetworkTab } from './networkTab';
import { SnapshotTab } from './snapshotTab';
import { SourceTab } from './sourceTab';
import { TabbedPane } from './tabbedPane';
import { TabbedPane } from '@web/components/tabbedPane';
import { Timeline } from './timeline';
import './workbench.css';
import { toggleTheme } from '@web/theme';
@ -208,7 +208,7 @@ export const Workbench: React.FunctionComponent<{
</div>
<SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
<SplitView sidebarSize={300} orientation={view === 'embedded' ? 'vertical' : 'horizontal'}>
<SnapshotTab action={activeAction} />
<SnapshotTab action={activeAction} sdkLanguage={model.sdkLanguage || 'javascript'} testIdAttributeName={model.testIdAttributeName || 'data-testid'} />
<TabbedPane tabs={tabs} selectedTab={selectedPropertiesTab} setSelectedTab={setSelectedPropertiesTab}/>
</SplitView>
<TabbedPane tabs={

Просмотреть файл

@ -16,6 +16,7 @@
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@injected/*": ["../playwright-core/src/server/injected/*"],
"@isomorphic/*": ["../playwright-core/src/server/isomorphic/*"],
"@protocol/*": ["../protocol/src/*"],
"@recorder/*": ["../recorder/src/*"],

Просмотреть файл

@ -16,6 +16,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// @ts-ignore
import { bundle } from './bundle';
import * as path from 'path';
@ -28,6 +29,7 @@ export default defineConfig({
],
resolve: {
alias: {
'@injected': path.resolve(__dirname, '../playwright-core/src/server/injected'),
'@isomorphic': path.resolve(__dirname, '../playwright-core/src/server/isomorphic'),
'@protocol': path.resolve(__dirname, '../protocol/src'),
'@web': path.resolve(__dirname, '../web/src'),

Просмотреть файл

@ -16,6 +16,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// @ts-ignore
import { bundle } from './bundle';
import * as path from 'path';

Просмотреть файл

@ -39,6 +39,7 @@ export type ContextCreatedTraceEvent = {
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
};
export type ScreencastFrameTraceEvent = {

Просмотреть файл

@ -67,7 +67,7 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
if (language === 'csharp')
mode = 'text/x-csharp';
if (codemirror && codemirror.getOption('mode') === mode)
if (codemirror && codemirror.getOption('mode') === mode && codemirror.isReadOnly() === readOnly)
return;
if (!codemirrorElement.current)

Просмотреть файл

@ -14,10 +14,6 @@
limitations under the License.
*/
.list-view {
border-top: 1px solid var(--vscode-panel-border);
}
.list-view-content {
display: flex;
flex-direction: column;

Просмотреть файл

@ -20,29 +20,13 @@
overflow: hidden;
}
.tab-content {
.tabbed-pane .tab-content {
display: flex;
flex: auto;
overflow: hidden;
}
.tab-strip {
display: flex;
background-color: var(--vscode-sideBar-background);
color: var(--vscode-sideBarTitle-foreground);
height: 32px;
align-items: center;
padding-right: 10px;
flex: none;
width: 100%;
z-index: 2;
}
.tab-strip:focus {
outline: none;
}
.tab-element {
.tabbed-pane-tab {
padding: 2px 10px 0 10px;
margin-right: 4px;
cursor: pointer;
@ -56,7 +40,7 @@
height: 100%;
}
.tab-label {
.tabbed-pane-tab-label {
max-width: 250px;
white-space: pre;
overflow: hidden;
@ -64,13 +48,13 @@
display: inline-block;
}
.tab-count {
.tabbed-pane-tab-count {
font-size: 10px;
display: flex;
align-self: flex-start;
width: 0px;
}
.tab-element.selected {
.tabbed-pane-tab.selected {
background-color: var(--vscode-tab-activeBackground);
}

Просмотреть файл

@ -15,9 +15,10 @@
*/
import './tabbedPane.css';
import { Toolbar } from './toolbar';
import * as React from 'react';
export interface TabbedPaneTab {
export interface TabbedPaneTabModel {
id: string;
title: string | JSX.Element;
count?: number;
@ -25,24 +26,23 @@ export interface TabbedPaneTab {
}
export const TabbedPane: React.FunctionComponent<{
tabs: TabbedPaneTab[],
tabs: TabbedPaneTabModel[],
selectedTab: string,
setSelectedTab: (tab: string) => void
}> = ({ tabs, selectedTab, setSelectedTab }) => {
return <div className='tabbed-pane'>
<div className='vbox'>
<div className='hbox' style={{ flex: 'none' }}>
<div className='tab-strip'>{
tabs.map(tab => (
<div className={'tab-element ' + (selectedTab === tab.id ? 'selected' : '')}
onClick={() => setSelectedTab(tab.id)}
key={tab.id}>
<div className='tab-label'>{tab.title}</div>
<div className='tab-count'>{tab.count || ''}</div>
</div>
))
}</div>
</div>
<Toolbar>{
tabs.map(tab => (
<TabbedPaneTab
id={tab.id}
title={tab.title}
count={tab.count}
selected={selectedTab === tab.id}
onSelect={setSelectedTab}
></TabbedPaneTab>
))
}</Toolbar>
{
tabs.map(tab => {
if (selectedTab === tab.id)
@ -52,3 +52,18 @@ export const TabbedPane: React.FunctionComponent<{
</div>
</div>;
};
export const TabbedPaneTab: React.FunctionComponent<{
id: string,
title: string | JSX.Element,
count?: number,
selected?: boolean,
onSelect: (id: string) => void
}> = ({ id, title, count, selected, onSelect }) => {
return <div className={'tabbed-pane-tab ' + (selected ? 'selected' : '')}
onClick={() => onSelect(id)}
key={id}>
<div className='tabbed-pane-tab-label'>{title}</div>
<div className='tabbed-pane-tab-count'>{count || ''}</div>
</div>;
};

Просмотреть файл

@ -19,9 +19,8 @@
box-shadow: var(--box-shadow);
background-color: var(--vscode-sideBar-background);
color: var(--vscode-sideBarTitle-foreground);
min-height: 40px;
min-height: 32px;
align-items: center;
padding-right: 10px;
flex: none;
z-index: 2;
}

Просмотреть файл

@ -21,7 +21,7 @@
color: var(--vscode-sideBarTitle-foreground);
background: transparent;
padding: 4px;
margin-left: 10px;
margin: 0 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
@ -39,3 +39,7 @@
.toolbar-button:not(:disabled):active {
background-color: var(--vscode-toolbar-activeBackground);
}
.toolbar-button.toggled {
color: var(--vscode-inputOption-activeBorder);
}

Просмотреть файл

@ -20,7 +20,7 @@ import * as React from 'react';
export interface ToolbarButtonProps {
title: string,
icon: string,
icon?: string,
disabled?: boolean,
toggled?: boolean,
onClick: () => void,
@ -29,7 +29,7 @@ export interface ToolbarButtonProps {
export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>> = ({
children,
title = '',
icon = '',
icon,
disabled = false,
toggled = false,
onClick = () => {},
@ -38,7 +38,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
if (toggled)
className += ' toggled';
return <button className={className} onClick={onClick} title={title} disabled={!!disabled}>
<span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>
{icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>}
{children}
</button>;
};

Просмотреть файл

@ -26,7 +26,7 @@ body {
--vscode-widget-shadow: rgba(0, 0, 0, 0.16);
--vscode-input-background: #ffffff;
--vscode-input-foreground: #616161;
--vscode-inputOption-activeBorder: rgba(0, 122, 204, 0);
--vscode-inputOption-activeBorder: #007acc;
--vscode-inputOption-hoverBackground: rgba(184, 184, 184, 0.31);
--vscode-inputOption-activeBackground: rgba(0, 144, 241, 0.2);
--vscode-inputOption-activeForeground: #000000;
@ -567,7 +567,7 @@ body.dark-mode {
--vscode-widget-shadow: rgba(0, 0, 0, 0.36);
--vscode-input-background: #3c3c3c;
--vscode-input-foreground: #cccccc;
--vscode-inputOption-activeBorder: rgba(0, 122, 204, 0);
--vscode-inputOption-activeBorder: #007acc;
--vscode-inputOption-hoverBackground: rgba(90, 93, 94, 0.5);
--vscode-inputOption-activeBackground: rgba(0, 127, 212, 0.4);
--vscode-inputOption-activeForeground: #ffffff;

Просмотреть файл

@ -65,3 +65,14 @@ export function upperBound<S, T>(array: S[], object: T, comparator: (object: T,
}
return r;
}
export function copy(text: string) {
const textArea = document.createElement('textarea');
textArea.style.position = 'absolute';
textArea.style.zIndex = '-1000';
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
textArea.remove();
}

Просмотреть файл

@ -69,7 +69,7 @@ class TraceViewerPage {
}
async selectSnapshot(name: string) {
await this.page.click(`.snapshot-tab .tab-label:has-text("${name}")`);
await this.page.click(`.snapshot-tab .tabbed-pane-tab-label:has-text("${name}")`);
}
async showConsoleTab() {

Просмотреть файл

@ -683,7 +683,7 @@ test('should include requestUrl in route.fulfill', async ({ page, runAndTrace, b
// Render snapshot, check expectations.
await traceViewer.selectAction('route.fulfill');
await traceViewer.page.locator('.tab-label', { hasText: 'Call' }).click();
await traceViewer.page.locator('.tabbed-pane-tab-label', { hasText: 'Call' }).click();
const callLine = traceViewer.page.locator('.call-line');
await expect(callLine.getByText('status')).toContainText('200');
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
@ -699,7 +699,7 @@ test('should include requestUrl in route.continue', async ({ page, runAndTrace,
// Render snapshot, check expectations.
await traceViewer.selectAction('route.continue');
await traceViewer.page.locator('.tab-label', { hasText: 'Call' }).click();
await traceViewer.page.locator('.tabbed-pane-tab-label', { hasText: 'Call' }).click();
const callLine = traceViewer.page.locator('.call-line');
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
await expect(callLine.getByText(/^url:.*/)).toContainText(server.EMPTY_PAGE);
@ -715,7 +715,7 @@ test('should include requestUrl in route.abort', async ({ page, runAndTrace, ser
// Render snapshot, check expectations.
await traceViewer.selectAction('route.abort');
await traceViewer.page.locator('.tab-label', { hasText: 'Call' }).click();
await traceViewer.page.locator('.tabbed-pane-tab-label', { hasText: 'Call' }).click();
const callLine = traceViewer.page.locator('.call-line');
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
});
@ -765,3 +765,26 @@ test('should display language-specific locators', async ({ runAndTrace, server,
/locator.clickget_by_role\("button", name="Submit"\)/,
]);
});
test('should pick locator', async ({ page, runAndTrace, server }) => {
const traceViewer = await runAndTrace(async () => {
await page.goto(server.EMPTY_PAGE);
await page.setContent('<button>Submit</button>');
});
const snapshot = await traceViewer.snapshotFrame('page.setContent');
await traceViewer.page.getByTitle('Pick locator').click();
await snapshot.click('button');
await expect(traceViewer.page.locator('.cm-wrapper')).toContainText(`getByRole('button', { name: 'Submit' })`);
});
test('should update highlight when typing', async ({ page, runAndTrace, server }) => {
const traceViewer = await runAndTrace(async () => {
await page.goto(server.EMPTY_PAGE);
await page.setContent('<button>Submit</button>');
});
const snapshot = await traceViewer.snapshotFrame('page.setContent');
await traceViewer.page.getByTitle('Pick locator').click();
await traceViewer.page.locator('.CodeMirror').click();
await traceViewer.page.keyboard.type('button');
await expect(snapshot.locator('x-pw-glass')).toBeVisible();
});

Просмотреть файл

@ -11,10 +11,13 @@
- foo/lib means require dependency
*/
"@html-reporter/*": ["./packages/html-reporter/src/*"],
"@injected/*": ["./packages/playwright-core/src/server/injected/*"],
"@isomorphic/*": ["./packages/playwright-core/src/server/isomorphic/*"],
"@protocol/*": ["./packages/protocol/src/*"],
"@recorder/*": ["./packages/recorder/src/*"],
"@trace/*": ["./packages/trace/src/*"],
"playwright-core/lib/*": ["./packages/playwright-core/src/*"]
"@web/*": ["./packages/web/src/*"],
"playwright-core/lib/*": ["./packages/playwright-core/src/*"],
},
"esModuleInterop": true,
"strict": true,
@ -35,8 +38,6 @@
"packages/playwright-ct-svelte",
"packages/playwright-ct-vue",
"packages/playwright-ct-vue2",
"packages/recorder",
"packages/trace-viewer",
"packages/web",
],
}

Просмотреть файл

@ -27,6 +27,7 @@ const packagesDir = path.normalize(path.join(__dirname, '..', 'packages'));
const packages = new Map();
for (const package of fs.readdirSync(packagesDir))
packages.set(package, packagesDir + '/' + package + '/src/');
packages.set('injected', packagesDir + '/playwright-core/src/server/injected/');
packages.set('isomorphic', packagesDir + '/playwright-core/src/server/isomorphic/');
const peerDependencies = ['electron', 'react', 'react-dom', '@zip.js/zip.js'];