From b42c3690d3a860d5ddc6f047d0cc58334050ef95 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 22 Feb 2021 11:38:49 -0800 Subject: [PATCH] fix(codegen): replace html lib with createElement (#5531) We are not using html that much, since most of our UI moved to the Recorder App. Getting rid of `innerHTML` assignment fixes the TrustedTypes issue. --- src/server/supplements/injected/html.ts | 196 -------------------- src/server/supplements/injected/recorder.ts | 63 +++---- test/cli/cli-codegen-1.spec.ts | 37 ++++ 3 files changed, 66 insertions(+), 230 deletions(-) delete mode 100644 src/server/supplements/injected/html.ts diff --git a/src/server/supplements/injected/html.ts b/src/server/supplements/injected/html.ts deleted file mode 100644 index bf5a0c3d7de6c..0000000000000 --- a/src/server/supplements/injected/html.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const templateCache = new Map(); - -export interface Element$ extends HTMLElement { - $(id: string): HTMLElement; - $$(id: string): Iterable -} - -const BOOLEAN_ATTRS = new Set([ - 'async', 'autofocus', 'autoplay', 'checked', 'contenteditable', 'controls', - 'default', 'defer', 'disabled', 'expanded', 'formNoValidate', 'frameborder', 'hidden', - 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate', - 'open', 'readonly', 'required', 'reversed', 'scoped', 'selected', 'typemustmatch', -]); - -type Sub = { - node: Element, - type?: string, - nameParts?: string[], - valueParts?: string[], - isSimpleValue?: boolean, - attr?: string, - nodeIndex?: number -}; - -export function onDOMEvent(target: EventTarget, name: string, listener: (e: any) => void, capturing = false): () => void { - target.addEventListener(name, listener, capturing); - return () => { - target.removeEventListener(name, listener, capturing); - }; -} - -export function onDOMResize(target: HTMLElement, callback: () => void) { - const resizeObserver = new (window as any).ResizeObserver(callback); - resizeObserver.observe(target); - return () => resizeObserver.disconnect(); -} - -export function html(strings: TemplateStringsArray, ...values: any): Element$ { - let cache = templateCache.get(strings); - if (!cache) { - cache = prepareTemplate(strings); - templateCache.set(strings, cache); - } - const node = renderTemplate(cache.template, cache.subs, values) as any; - if (node.querySelector) { - node.$ = node.querySelector.bind(node); - node.$$ = node.querySelectorAll.bind(node); - } - return node; -} - -const SPACE_REGEX = /^\s*\n\s*$/; -const MARKER_REGEX = /---dom-template-\d+---/; - -function prepareTemplate(strings: TemplateStringsArray) { - const template = document.createElement('template'); - let html = ''; - for (let i = 0; i < strings.length - 1; ++i) { - html += strings[i]; - html += `---dom-template-${i}---`; - } - html += strings[strings.length - 1]; - template.innerHTML = html; - - const walker = template.ownerDocument.createTreeWalker( - template.content, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, null, false); - const emptyTextNodes: Node[] = []; - const subs: Sub[] = []; - while (walker.nextNode()) { - const node = walker.currentNode; - if (node.nodeType === Node.ELEMENT_NODE && MARKER_REGEX.test((node as Element).tagName)) - throw new Error('Should not use a parameter as an html tag'); - - if (node.nodeType === Node.ELEMENT_NODE && (node as Element).hasAttributes()) { - const element = node as Element; - for (let i = 0; i < element.attributes.length; i++) { - const name = element.attributes[i].name; - - const nameParts = name.split(MARKER_REGEX); - const valueParts = element.attributes[i].value.split(MARKER_REGEX); - const isSimpleValue = valueParts.length === 2 && valueParts[0] === '' && valueParts[1] === ''; - - if (nameParts.length > 1 || valueParts.length > 1) - subs.push({ node: element, nameParts, valueParts, isSimpleValue, attr: name}); - } - } else if (node.nodeType === Node.TEXT_NODE && MARKER_REGEX.test((node as Text).data)) { - const text = node as Text; - const texts = text.data.split(MARKER_REGEX); - text.data = texts[0]; - const anchor = node.nextSibling; - for (let i = 1; i < texts.length; ++i) { - const span = document.createElement('span'); - node.parentNode!.insertBefore(span, anchor); - node.parentNode!.insertBefore(document.createTextNode(texts[i]), anchor); - subs.push({ - node: span, - type: 'replace-node', - }); - } - if (shouldRemoveTextNode(text)) - emptyTextNodes.push(text); - } else if (node.nodeType === Node.TEXT_NODE && shouldRemoveTextNode((node as Text))) { - emptyTextNodes.push(node); - } - } - - for (const emptyTextNode of emptyTextNodes) - (emptyTextNode as any).remove(); - - const markedNodes = new Map(); - for (const sub of subs) { - let index = markedNodes.get(sub.node); - if (index === undefined) { - index = markedNodes.size; - sub.node.setAttribute('dom-template-marked', 'true'); - markedNodes.set(sub.node, index); - } - sub.nodeIndex = index; - } - return {template, subs}; -} - -function shouldRemoveTextNode(node: Text) { - if (!node.previousSibling && !node.nextSibling) - return !node.data.length; - return (!node.previousSibling || node.previousSibling.nodeType === Node.ELEMENT_NODE) && - (!node.nextSibling || node.nextSibling.nodeType === Node.ELEMENT_NODE) && - (!node.data.length || SPACE_REGEX.test(node.data)); -} - -function renderTemplate(template: HTMLTemplateElement, subs: Sub[], values: (string | Node)[]): DocumentFragment | ChildNode { - const content = template.ownerDocument.importNode(template.content, true)!; - const boundElements = Array.from(content.querySelectorAll('[dom-template-marked]')); - for (const node of boundElements) - node.removeAttribute('dom-template-marked'); - - let valueIndex = 0; - const interpolateText = (texts: string[]) => { - let newText = texts[0]; - for (let i = 1; i < texts.length; ++i) { - newText += values[valueIndex++]; - newText += texts[i]; - } - return newText; - }; - - for (const sub of subs) { - const n = boundElements[sub.nodeIndex!]; - if (sub.attr) { - n.removeAttribute(sub.attr); - const name = interpolateText(sub.nameParts!); - const value = sub.isSimpleValue ? values[valueIndex++] : interpolateText(sub.valueParts!); - if (BOOLEAN_ATTRS.has(name)) - n.toggleAttribute(name, !!value); - else - n.setAttribute(name, String(value)); - } else if (sub.type === 'replace-node') { - const replacement = values[valueIndex++]; - if (Array.isArray(replacement)) { - const fragment = document.createDocumentFragment(); - for (const node of replacement) - fragment.appendChild(node); - n.replaceWith(fragment); - } else if (replacement instanceof Node) { - n.replaceWith(replacement); - } else { - n.replaceWith(document.createTextNode(replacement || '')); - } - } - } - - return content.firstChild && content.firstChild === content.lastChild ? content.firstChild : content; -} - -export function deepActiveElement() { - let activeElement = document.activeElement; - while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) - activeElement = activeElement.shadowRoot.activeElement; - return activeElement; -} diff --git a/src/server/supplements/injected/recorder.ts b/src/server/supplements/injected/recorder.ts index b4f929813375b..b93e51ac65b32 100644 --- a/src/server/supplements/injected/recorder.ts +++ b/src/server/supplements/injected/recorder.ts @@ -17,7 +17,6 @@ import type * as actions from '../recorder/recorderActions'; import type InjectedScript from '../../injected/injectedScript'; import { generateSelector, querySelector } from './selectorGenerator'; -import { html } from './html'; import type { Point } from '../../../common/types'; import type { UIState } from '../recorder/recorderTypes'; @@ -57,33 +56,30 @@ export class Recorder { constructor(injectedScript: InjectedScript, params: { isUnderTest: boolean }) { this._params = params; this._injectedScript = injectedScript; - this._outerGlassPaneElement = html` - - `; - - this._tooltipElement = html``; - this._actionPointElement = html``; - - this._innerGlassPaneElement = html` - - ${this._tooltipElement} - `; + this._outerGlassPaneElement = document.createElement('x-pw-glass'); + this._outerGlassPaneElement.style.position = 'fixed'; + this._outerGlassPaneElement.style.top = '0'; + this._outerGlassPaneElement.style.right = '0'; + this._outerGlassPaneElement.style.bottom = '0'; + this._outerGlassPaneElement.style.left = '0'; + this._outerGlassPaneElement.style.zIndex = '2147483647'; + this._outerGlassPaneElement.style.pointerEvents = 'none'; + this._outerGlassPaneElement.style.display = 'flex'; + + this._tooltipElement = document.createElement('x-pw-tooltip'); + this._actionPointElement = document.createElement('x-pw-action-point'); + this._actionPointElement.setAttribute('hidden', 'true'); + + this._innerGlassPaneElement = document.createElement('x-pw-glass-inner'); + this._innerGlassPaneElement.style.flex = 'auto'; + this._innerGlassPaneElement.appendChild(this._tooltipElement); // Use a closed shadow root to prevent selectors matching our internal previews. this._glassPaneShadow = this._outerGlassPaneElement.attachShadow({ mode: this._params.isUnderTest ? 'open' : 'closed' }); this._glassPaneShadow.appendChild(this._innerGlassPaneElement); this._glassPaneShadow.appendChild(this._actionPointElement); - this._glassPaneShadow.appendChild(html` - - `); + `; + this._glassPaneShadow.appendChild(styleElement); + this._refreshListenersIfNeeded(); setInterval(() => { this._refreshListenersIfNeeded(); @@ -394,15 +391,13 @@ export class Recorder { } private _createHighlightElement(): HTMLElement { - const highlightElement = html` - - `; + const highlightElement = document.createElement('x-pw-highlight'); + highlightElement.style.position = 'absolute'; + highlightElement.style.top = '0'; + highlightElement.style.left = '0'; + highlightElement.style.width = '0'; + highlightElement.style.height = '0'; + highlightElement.style.boxSizing = 'border-box'; this._glassPaneShadow.appendChild(highlightElement); return highlightElement; } diff --git a/test/cli/cli-codegen-1.spec.ts b/test/cli/cli-codegen-1.spec.ts index 9f1fa1f8d7e37..72a0a6e0550d4 100644 --- a/test/cli/cli-codegen-1.spec.ts +++ b/test/cli/cli-codegen-1.spec.ts @@ -83,6 +83,43 @@ await page.ClickAsync("text=Submit");`); expect(message.text()).toBe('click'); }); + it('should work with TrustedTypes', async ({ page, recorder }) => { + await recorder.setContentAndWait(` + + + + + + `); + + const selector = await recorder.hoverOverElement('button'); + expect(selector).toBe('text=Submit'); + + const [message, sources] = await Promise.all([ + page.waitForEvent('console'), + recorder.waitForOutput('', 'click'), + page.dispatchEvent('button', 'click', { detail: 1 }) + ]); + + expect(sources.get('').text).toContain(` + // Click text=Submit + await page.click('text=Submit');`); + + expect(sources.get('').text).toContain(` + # Click text=Submit + page.click("text=Submit")`); + + expect(sources.get('').text).toContain(` + # Click text=Submit + await page.click("text=Submit")`); + + expect(sources.get('').text).toContain(` +// Click text=Submit +await page.ClickAsync("text=Submit");`); + + expect(message.text()).toBe('click'); + }); + it('should not target selector preview by text regexp', async ({ page, recorder }) => { await recorder.setContentAndWait(`dummy`);