chore: implement pick locator in trace viewer (#20965)
Fixes https://github.com/microsoft/playwright/issues/7853
This commit is contained in:
Родитель
d96d3c3381
Коммит
d7a0b3bb4e
|
@ -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'];
|
||||
|
|
Загрузка…
Ссылка в новой задаче