diff --git a/package-lock.json b/package-lock.json index 0d8bfb416b..c3036072f4 100644 --- a/package-lock.json +++ b/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", diff --git a/package.json b/package.json index ac3ece0e7e..7db646c4e5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index c8cdaa7867..bccb829dca 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -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())}, diff --git a/packages/playwright-core/src/server/injected/.eslintrc.js b/packages/playwright-core/src/server/injected/.eslintrc.js new file mode 100644 index 0000000000..e96e2a9f80 --- /dev/null +++ b/packages/playwright-core/src/server/injected/.eslintrc.js @@ -0,0 +1,10 @@ +module.exports = { + rules: { + "no-restricted-globals": [ + "error", + { "name": "window" }, + { "name": "document" }, + { "name": "globalThis" }, + ] + } +}; diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index 2ab0da8dd2..fd7f751655 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -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(() => {}); } } diff --git a/packages/playwright-core/src/server/injected/domUtils.ts b/packages/playwright-core/src/server/injected/domUtils.ts index e4a8e66e71..f22f8e5e4c 100644 --- a/packages/playwright-core/src/server/injected/domUtils.ts +++ b/packages/playwright-core/src/server/injected/domUtils.ts @@ -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; diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts index aaf7f422e8..eb74390342 100644 --- a/packages/playwright-core/src/server/injected/highlight.ts +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -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'; diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 9c6dabb652..b2708b7543 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -79,8 +79,14 @@ export class InjectedScript { private _sdkLanguage: Language; private _testIdAttributeNameForStrictErrorAndConsoleCodegen: string = 'data-testid'; private _markedTargetElements = new Set(); + // 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') diff --git a/packages/playwright-core/src/server/injected/reactSelectorEngine.ts b/packages/playwright-core/src/server/injected/reactSelectorEngine.ts index ac0015265c..57d4262089 100644 --- a/packages/playwright-core/src/server/injected/reactSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/reactSelectorEngine.ts @@ -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 ?? {}; diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index 428deaf7ea..9d1d323b7c 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -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; - let __pw_recorderRecordAction: (action: actions.Action) => Promise; - let __pw_recorderState: () => Promise; - let __pw_recorderSetSelector: (selector: string) => Promise; - let __pw_refreshOverlay: () => void; +interface RecorderDelegate { + performAction?(action: actions.Action): Promise; + recordAction?(action: actions.Action): Promise; + setSelector?(selector: string): Promise; } -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; + __pw_recorderRecordAction(action: actions.Action): Promise; + __pw_recorderState(): Promise; + __pw_recorderSetSelector(selector: string): Promise; + __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 { + await this._embedder.__pw_recorderRecordAction(action); + } + + async __pw_recorderState(): Promise { + return await this._embedder.__pw_recorderState(); + } + + async setSelector(selector: string): Promise { + await this._embedder.__pw_recorderSetSelector(selector); + } +} + +module.exports = PollingRecorder; diff --git a/packages/playwright-core/src/server/injected/selectorUtils.ts b/packages/playwright-core/src/server/injected/selectorUtils.ts index edfce2a34a..dcfe1942a6 100644 --- a/packages/playwright-core/src/server/injected/selectorUtils.ts +++ b/packages/playwright-core/src/server/injected/selectorUtils.ts @@ -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); } diff --git a/packages/playwright-core/src/server/injected/utilityScript.ts b/packages/playwright-core/src/server/injected/utilityScript.ts index 815cf61219..08231ba594 100644 --- a/packages/playwright-core/src/server/injected/utilityScript.ts +++ b/packages/playwright-core/src/server/injected/utilityScript.ts @@ -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); diff --git a/packages/playwright-core/src/server/injected/vueSelectorEngine.ts b/packages/playwright-core/src/server/injected/vueSelectorEngine.ts index ca2fd53b9a..94b4677128 100644 --- a/packages/playwright-core/src/server/injected/vueSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/vueSelectorEngine.ts @@ -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 = 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)); diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 23e7b046e5..85a119f751 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -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); diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 332823c8f4..ec51aa5537 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -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(); -} diff --git a/packages/trace-viewer/bundle.js b/packages/trace-viewer/bundle.js index cf02d28cfb..f751f76c5c 100644 --- a/packages/trace-viewer/bundle.js +++ b/packages/trace-viewer/bundle.js @@ -14,9 +14,6 @@ * limitations under the License. */ -import fs, { existsSync } from 'fs'; -import path from 'path'; - /** * @returns {import('vite').Plugin} */ diff --git a/packages/trace-viewer/src/entries.ts b/packages/trace-viewer/src/entries.ts index 5faf5f7fea..2b9670d306 100644 --- a/packages/trace-viewer/src/entries.ts +++ b/packages/trace-viewer/src/entries.ts @@ -26,6 +26,7 @@ export type ContextEntry = { platform?: string; wallTime?: number; sdkLanguage?: Language; + testIdAttributeName?: string; title?: string; options: trace.BrowserContextEventOptions; pages: PageEntry[]; diff --git a/packages/trace-viewer/src/index.tsx b/packages/trace-viewer/src/index.tsx index 2b93b3d6a6..b3e2b65100 100644 --- a/packages/trace-viewer/src/index.tsx +++ b/packages/trace-viewer/src/index.tsx @@ -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'; diff --git a/packages/trace-viewer/src/traceModel.ts b/packages/trace-viewer/src/traceModel.ts index cfa1a1a705..30f9cb35ed 100644 --- a/packages/trace-viewer/src/traceModel.ts +++ b/packages/trace-viewer/src/traceModel.ts @@ -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': { diff --git a/packages/trace-viewer/src/ui/DEPS.list b/packages/trace-viewer/src/ui/DEPS.list index 9eb15c9952..8271051e09 100644 --- a/packages/trace-viewer/src/ui/DEPS.list +++ b/packages/trace-viewer/src/ui/DEPS.list @@ -1,4 +1,5 @@ [*] +@injected/** @isomorphic/** @web/** ../entries.ts diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 29bfc60f74..6037bead92 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -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'; diff --git a/packages/trace-viewer/src/ui/callTab.css b/packages/trace-viewer/src/ui/callTab.css index d04c03b806..d36e150a14 100644 --- a/packages/trace-viewer/src/ui/callTab.css +++ b/packages/trace-viewer/src/ui/callTab.css @@ -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; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 4f5ab5bf15..71848a3024 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -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 || {}; diff --git a/packages/trace-viewer/src/ui/snapshotTab.css b/packages/trace-viewer/src/ui/snapshotTab.css index 607666cf1a..a83e83d792 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.css +++ b/packages/trace-viewer/src/ui/snapshotTab.css @@ -150,3 +150,7 @@ iframe#snapshot { body.dark-mode .window-header { background: #444950; } + +.snapshot-tab .cm-wrapper { + line-height: 23px; +} diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index f151cc7500..8dc711a630 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -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(); const [snapshotIndex, setSnapshotIndex] = React.useState(0); + const [locator, setLocator] = React.useState(''); + const [pickerVisible, setPickerVisible] = React.useState(false); const snapshotMap = new Map(); 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
+ > + + { + setPickerVisible(!pickerVisible); + setMode(mode === 'inspecting' ? 'none' : 'inspecting'); + const recorder = recorderGetter(); + recorder?.setUIState({ mode: pickerVisible ? 'none' : 'inspecting', language: sdkLanguage, testIdAttributeName }); + }}>Pick locator +
{snapshots.map((snapshot, index) => { - return
setSnapshotIndex(index)} - key={snapshot.title}> -
{renderTitle(snapshot.title)}
-
; + return setSnapshotIndex(index)} + >; })} -
+
+ { + window.open(popoutUrl || '', '_blank'); + }}> + + {pickerVisible && + { + setMode(mode === 'inspecting' ? 'none' : 'inspecting'); + const recorder = recorderGetter(); + recorder?.setUIState({ mode: mode === 'inspecting' ? 'none' : 'inspecting', language: sdkLanguage, testIdAttributeName }); + }}> + { + const recorder = recorderGetter(); + const actionSelector = locatorOrSelectorAsSelector(sdkLanguage, text, testIdAttributeName); + recorder?.setUIState({ mode: 'none', language: sdkLanguage, testIdAttributeName, actionSelector }); + setLocator(text); + }}> + { + copy(locator); + }}> + }
- - - { snapshots.length ?
-
{snapshotInfo.url}
+
{snapshotInfo.url || 'about:blank'}
@@ -142,6 +192,25 @@ export const SnapshotTab: React.FunctionComponent<{
; }; +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'; diff --git a/packages/trace-viewer/src/ui/workbench.css b/packages/trace-viewer/src/ui/workbench.css index bae050a2e5..c27c5168e8 100644 --- a/packages/trace-viewer/src/ui/workbench.css +++ b/packages/trace-viewer/src/ui/workbench.css @@ -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; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index e37994c6d3..028cc46f95 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -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<{
- + = ({ 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) diff --git a/packages/web/src/components/listView.css b/packages/web/src/components/listView.css index fad0fd62f6..a0bb1a9a76 100644 --- a/packages/web/src/components/listView.css +++ b/packages/web/src/components/listView.css @@ -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; diff --git a/packages/trace-viewer/src/ui/tabbedPane.css b/packages/web/src/components/tabbedPane.css similarity index 77% rename from packages/trace-viewer/src/ui/tabbedPane.css rename to packages/web/src/components/tabbedPane.css index ce5032f54c..7c3f164276 100644 --- a/packages/trace-viewer/src/ui/tabbedPane.css +++ b/packages/web/src/components/tabbedPane.css @@ -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); } diff --git a/packages/trace-viewer/src/ui/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx similarity index 57% rename from packages/trace-viewer/src/ui/tabbedPane.tsx rename to packages/web/src/components/tabbedPane.tsx index 077a170f3d..32d73078bc 100644 --- a/packages/trace-viewer/src/ui/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -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
-
-
{ - tabs.map(tab => ( -
setSelectedTab(tab.id)} - key={tab.id}> -
{tab.title}
-
{tab.count || ''}
-
- )) - }
-
+ { + tabs.map(tab => ( + + )) + } { tabs.map(tab => { if (selectedTab === tab.id) @@ -52,3 +52,18 @@ export const TabbedPane: React.FunctionComponent<{
; }; + +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
onSelect(id)} + key={id}> +
{title}
+
{count || ''}
+
; +}; diff --git a/packages/web/src/components/toolbar.css b/packages/web/src/components/toolbar.css index 0cc56677b3..522c638d46 100644 --- a/packages/web/src/components/toolbar.css +++ b/packages/web/src/components/toolbar.css @@ -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; } diff --git a/packages/web/src/components/toolbarButton.css b/packages/web/src/components/toolbarButton.css index 7afe5a803b..70b7e3196c 100644 --- a/packages/web/src/components/toolbarButton.css +++ b/packages/web/src/components/toolbarButton.css @@ -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); +} diff --git a/packages/web/src/components/toolbarButton.tsx b/packages/web/src/components/toolbarButton.tsx index 0eb75e24af..b31b444baa 100644 --- a/packages/web/src/components/toolbarButton.tsx +++ b/packages/web/src/components/toolbarButton.tsx @@ -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> = ({ children, title = '', - icon = '', + icon, disabled = false, toggled = false, onClick = () => {}, @@ -38,7 +38,7 @@ export const ToolbarButton: React.FC if (toggled) className += ' toggled'; return ; }; diff --git a/packages/web/src/third_party/vscode/colors.css b/packages/web/src/third_party/vscode/colors.css index a5d8e81274..e6aa7a49c4 100644 --- a/packages/web/src/third_party/vscode/colors.css +++ b/packages/web/src/third_party/vscode/colors.css @@ -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; diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 9af12be9cd..1e933586d5 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -65,3 +65,14 @@ export function upperBound(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(); +} diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index 5de26e190c..307ccf593a 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -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() { diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 6b541c5bbf..0a614e311d 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -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(''); + }); + 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(''); + }); + 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(); +}); diff --git a/tsconfig.json b/tsconfig.json index 4dd48cd575..d2d4e9e26b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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", ], } diff --git a/utils/check_deps.js b/utils/check_deps.js index 09cb33852d..3aec6ef620 100644 --- a/utils/check_deps.js +++ b/utils/check_deps.js @@ -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'];