fix(tracing): preserve control values without modifying DOM (#12939)

Previously, we preserved input/textarea values by providing
`value` attribute or text child. This produces DOM that does not
actually match the original page.

This change starts using special attributes to modify values
directly when rendering.

Same treatment is also applied to options in `select` and
`checked` property of checkboxes and radio buttons.
This commit is contained in:
Dmitry Gozman 2022-03-21 18:51:48 -07:00 committed by GitHub
parent dea6528c0c
commit 4b877213a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 114 additions and 50 deletions

View File

@ -38,6 +38,9 @@ export function frameSnapshotStreamer(snapshotStreamer: string) {
// Attributes present in the snapshot.
const kShadowAttribute = '__playwright_shadow_root_';
const kValueAttribute = '__playwright_value_';
const kCheckedAttribute = '__playwright_checked_';
const kSelectedAttribute = '__playwright_selected_';
const kScrollTopAttribute = '__playwright_scroll_top_';
const kScrollLeftAttribute = '__playwright_scroll_left_';
const kStyleSheetAttribute = '__playwright_style_sheet_';
@ -363,15 +366,23 @@ export function frameSnapshotStreamer(snapshotStreamer: string) {
if (nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (nodeName === 'INPUT') {
if (nodeName === 'INPUT' || nodeName === 'TEXTAREA') {
const value = (element as HTMLInputElement).value;
expectValue('value');
expectValue(kValueAttribute);
expectValue(value);
attrs['value'] = value;
if ((element as HTMLInputElement).checked) {
expectValue('checked');
attrs['checked'] = '';
}
attrs[kValueAttribute] = value;
}
if (nodeName === 'INPUT' && ['checkbox', 'radio'].includes((element as HTMLInputElement).type)) {
const value = (element as HTMLInputElement).checked ? 'true' : 'false';
expectValue(kCheckedAttribute);
expectValue(value);
attrs[kCheckedAttribute] = value;
}
if (nodeName === 'OPTION') {
const value = (element as HTMLOptionElement).selected ? 'true' : 'false';
expectValue(kSelectedAttribute);
expectValue(value);
attrs[kSelectedAttribute] = value;
}
if (element.scrollTop) {
expectValue(kScrollTopAttribute);
@ -390,33 +401,26 @@ export function frameSnapshotStreamer(snapshotStreamer: string) {
}
}
if (nodeName === 'TEXTAREA') {
const value = (node as HTMLTextAreaElement).value;
expectValue(value);
extraNodes++; // Compensate for the extra text node.
result.push(value);
} else {
if (nodeName === 'HEAD') {
++headNesting;
// Insert fake <base> first, to ensure all <link> elements use the proper base uri.
this._fakeBase.setAttribute('href', document.baseURI);
visitChild(this._fakeBase);
}
for (let child = node.firstChild; child; child = child.nextSibling)
visitChild(child);
if (nodeName === 'HEAD')
--headNesting;
if (nodeName === 'HEAD') {
++headNesting;
// Insert fake <base> first, to ensure all <link> elements use the proper base uri.
this._fakeBase.setAttribute('href', document.baseURI);
visitChild(this._fakeBase);
}
for (let child = node.firstChild; child; child = child.nextSibling)
visitChild(child);
if (nodeName === 'HEAD')
--headNesting;
expectValue(kEndOfList);
let documentOrShadowRoot = null;
if (node.ownerDocument!.documentElement === node)
documentOrShadowRoot = node.ownerDocument;
else if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE)
documentOrShadowRoot = node;
if (documentOrShadowRoot) {
for (const sheet of (documentOrShadowRoot as any).adoptedStyleSheets || [])
visitChildStyleSheet(sheet);
expectValue(kEndOfList);
let documentOrShadowRoot = null;
if (node.ownerDocument!.documentElement === node)
documentOrShadowRoot = node.ownerDocument;
else if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE)
documentOrShadowRoot = node;
if (documentOrShadowRoot) {
for (const sheet of (documentOrShadowRoot as any).adoptedStyleSheets || [])
visitChildStyleSheet(sheet);
expectValue(kEndOfList);
}
}
// Process iframe src attribute before bailing out since it depends on a symbol, not the DOM.
@ -439,8 +443,6 @@ export function frameSnapshotStreamer(snapshotStreamer: string) {
const element = node as Element;
for (let i = 0; i < element.attributes.length; i++) {
const name = element.attributes[i].name;
if (name === 'value' && (nodeName === 'INPUT' || nodeName === 'TEXTAREA'))
continue;
if (nodeName === 'LINK' && name === 'integrity')
continue;
if (nodeName === 'IFRAME' && (name === 'src' || name === 'sandbox'))

View File

@ -173,17 +173,30 @@ function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
}
function snapshotScript() {
function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string, styleSheetAttribute: string) {
function applyPlaywrightAttributes() {
const scrollTops: Element[] = [];
const scrollLefts: Element[] = [];
const visit = (root: Document | ShadowRoot) => {
// Collect all scrolled elements for later use.
for (const e of root.querySelectorAll(`[${scrollTopAttribute}]`))
for (const e of root.querySelectorAll(`[__playwright_scroll_top_]`))
scrollTops.push(e);
for (const e of root.querySelectorAll(`[${scrollLeftAttribute}]`))
for (const e of root.querySelectorAll(`[__playwright_scroll_left_]`))
scrollLefts.push(e);
for (const element of root.querySelectorAll(`[__playwright_value_]`)) {
(element as HTMLInputElement | HTMLTextAreaElement).value = element.getAttribute('__playwright_value_')!;
element.removeAttribute('__playwright_value_');
}
for (const element of root.querySelectorAll(`[__playwright_checked_]`)) {
(element as HTMLInputElement).checked = element.getAttribute('__playwright_checked_') === 'true';
element.removeAttribute('__playwright_checked_');
}
for (const element of root.querySelectorAll(`[__playwright_selected_]`)) {
(element as HTMLOptionElement).selected = element.getAttribute('__playwright_selected_') === 'true';
element.removeAttribute('__playwright_selected_');
}
for (const iframe of root.querySelectorAll('iframe, frame')) {
const src = iframe.getAttribute('__playwright_src__');
if (!src) {
@ -202,7 +215,7 @@ function snapshotScript() {
}
}
for (const element of root.querySelectorAll(`template[${shadowAttribute}]`)) {
for (const element of root.querySelectorAll(`template[__playwright_shadow_root_]`)) {
const template = element as HTMLTemplateElement;
const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' });
shadowRoot.appendChild(template.content);
@ -212,10 +225,10 @@ function snapshotScript() {
if ('adoptedStyleSheets' in (root as any)) {
const adoptedSheets: CSSStyleSheet[] = [...(root as any).adoptedStyleSheets];
for (const element of root.querySelectorAll(`template[${styleSheetAttribute}]`)) {
for (const element of root.querySelectorAll(`template[__playwright_style_sheet_]`)) {
const template = element as HTMLTemplateElement;
const sheet = new CSSStyleSheet();
(sheet as any).replaceSync(template.getAttribute(styleSheetAttribute));
(sheet as any).replaceSync(template.getAttribute('__playwright_style_sheet_'));
adoptedSheets.push(sheet);
}
(root as any).adoptedStyleSheets = adoptedSheets;
@ -225,12 +238,12 @@ function snapshotScript() {
const onLoad = () => {
window.removeEventListener('load', onLoad);
for (const element of scrollTops) {
element.scrollTop = +element.getAttribute(scrollTopAttribute)!;
element.removeAttribute(scrollTopAttribute);
element.scrollTop = +element.getAttribute('__playwright_scroll_top_')!;
element.removeAttribute('__playwright_scroll_top_');
}
for (const element of scrollLefts) {
element.scrollLeft = +element.getAttribute(scrollLeftAttribute)!;
element.removeAttribute(scrollLeftAttribute);
element.scrollLeft = +element.getAttribute('__playwright_scroll_left_')!;
element.removeAttribute('__playwright_scroll_left_');
}
const search = new URL(window.location.href).searchParams;
@ -258,9 +271,5 @@ function snapshotScript() {
window.addEventListener('DOMContentLoaded', onDOMContentLoaded);
}
const kShadowAttribute = '__playwright_shadow_root_';
const kScrollTopAttribute = '__playwright_scroll_top_';
const kScrollLeftAttribute = '__playwright_scroll_left_';
const kStyleSheetAttribute = '__playwright_style_sheet_';
return `\n(${applyPlaywrightAttributes.toString()})('${kShadowAttribute}', '${kScrollTopAttribute}', '${kScrollLeftAttribute}', '${kStyleSheetAttribute}')`;
return `\n(${applyPlaywrightAttributes.toString()})()`;
}

View File

@ -484,6 +484,59 @@ test('should restore scroll positions', async ({ page, runAndTrace, browserName
expect(await div.evaluate(div => div.scrollTop)).toBe(136);
});
test('should restore control values', async ({ page, runAndTrace }) => {
const traceViewer = await runAndTrace(async () => {
await page.setContent(`
<input type=text value=old>
<input type=checkbox checked>
<input type=radio>
<textarea>old</textarea>
<select multiple>
<option value=opt1>Hi</option>
<option value=opt2 selected>Bye</option>
<option value=opt3>Hello</option>
</select>
<script>
document.querySelector('[type=text]').value = 'hi';
document.querySelector('[type=checkbox]').checked = false;
document.querySelector('[type=radio]').checked = true;
document.querySelector('textarea').value = 'hello';
document.querySelector('[value=opt1]').selected = true;
document.querySelector('[value=opt2]').selected = false;
document.querySelector('[value=opt3]').selected = true;
</script>
`);
await page.click('input');
});
// Render snapshot, check expectations.
const frame = await traceViewer.snapshotFrame('page.click');
const text = frame.locator('[type=text]');
await expect(text).toHaveAttribute('value', 'old');
await expect(text).toHaveValue('hi');
const checkbox = frame.locator('[type=checkbox]');
await expect(checkbox).not.toBeChecked();
expect(await checkbox.evaluate(c => c.hasAttribute('checked'))).toBe(true);
const radio = frame.locator('[type=radio]');
await expect(radio).toBeChecked();
expect(await radio.evaluate(c => c.hasAttribute('checked'))).toBe(false);
const textarea = frame.locator('textarea');
await expect(textarea).toHaveText('old');
await expect(textarea).toHaveValue('hello');
expect(await frame.$eval('option >> nth=0', o => o.hasAttribute('selected'))).toBe(false);
expect(await frame.$eval('option >> nth=1', o => o.hasAttribute('selected'))).toBe(true);
expect(await frame.$eval('option >> nth=2', o => o.hasAttribute('selected'))).toBe(false);
expect(await frame.locator('select').evaluate(s => {
const options = [...(s as HTMLSelectElement).selectedOptions];
return options.map(option => option.value);
})).toEqual(['opt1', 'opt3']);
});
test('should work with meta CSP', async ({ page, runAndTrace, browserName }) => {
const traceViewer = await runAndTrace(async () => {
await page.setContent(`