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.
This commit is contained in:
Родитель
eb9c8ce20c
Коммит
b42c3690d3
|
@ -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<HTMLElement>
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
|
@ -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`
|
||||
<x-pw-glass style="
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2147483647;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
">
|
||||
</x-pw-glass>`;
|
||||
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 = html`<x-pw-tooltip></x-pw-tooltip>`;
|
||||
this._actionPointElement = html`<x-pw-action-point hidden=true></x-pw-action-point>`;
|
||||
this._tooltipElement = document.createElement('x-pw-tooltip');
|
||||
this._actionPointElement = document.createElement('x-pw-action-point');
|
||||
this._actionPointElement.setAttribute('hidden', 'true');
|
||||
|
||||
this._innerGlassPaneElement = html`
|
||||
<x-pw-glass-inner style="flex: auto">
|
||||
${this._tooltipElement}
|
||||
</x-pw-glass-inner>`;
|
||||
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`
|
||||
<style>
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = `
|
||||
x-pw-tooltip {
|
||||
align-items: center;
|
||||
backdrop-filter: blur(5px);
|
||||
|
@ -120,8 +116,9 @@ export class Recorder {
|
|||
*[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
`;
|
||||
this._glassPaneShadow.appendChild(styleElement);
|
||||
|
||||
this._refreshListenersIfNeeded();
|
||||
setInterval(() => {
|
||||
this._refreshListenersIfNeeded();
|
||||
|
@ -394,15 +391,13 @@ export class Recorder {
|
|||
}
|
||||
|
||||
private _createHighlightElement(): HTMLElement {
|
||||
const highlightElement = html`
|
||||
<x-pw-highlight style="
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
box-sizing: border-box;">
|
||||
</x-pw-highlight>`;
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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(`
|
||||
<head>
|
||||
<meta http-equiv="Content-Security-Policy" content="trusted-types unsafe escape; require-trusted-types-for 'script'">
|
||||
</head>
|
||||
<body>
|
||||
<button onclick="console.log('click')">Submit</button>
|
||||
</body>`);
|
||||
|
||||
const selector = await recorder.hoverOverElement('button');
|
||||
expect(selector).toBe('text=Submit');
|
||||
|
||||
const [message, sources] = await Promise.all([
|
||||
page.waitForEvent('console'),
|
||||
recorder.waitForOutput('<javascript>', 'click'),
|
||||
page.dispatchEvent('button', 'click', { detail: 1 })
|
||||
]);
|
||||
|
||||
expect(sources.get('<javascript>').text).toContain(`
|
||||
// Click text=Submit
|
||||
await page.click('text=Submit');`);
|
||||
|
||||
expect(sources.get('<python>').text).toContain(`
|
||||
# Click text=Submit
|
||||
page.click("text=Submit")`);
|
||||
|
||||
expect(sources.get('<async python>').text).toContain(`
|
||||
# Click text=Submit
|
||||
await page.click("text=Submit")`);
|
||||
|
||||
expect(sources.get('<csharp>').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(`<span>dummy</span>`);
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче