diff --git a/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts b/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts index 928c0b3b07..2dbbb28ee0 100644 --- a/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts +++ b/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts @@ -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 first, to ensure all 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 first, to ensure all 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')) diff --git a/packages/playwright-core/src/web/traceViewer/snapshotRenderer.ts b/packages/playwright-core/src/web/traceViewer/snapshotRenderer.ts index 3d3f1bf64d..b3fef09480 100644 --- a/packages/playwright-core/src/web/traceViewer/snapshotRenderer.ts +++ b/packages/playwright-core/src/web/traceViewer/snapshotRenderer.ts @@ -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()})()`; } diff --git a/tests/trace-viewer/trace-viewer.spec.ts b/tests/trace-viewer/trace-viewer.spec.ts index 3f5421e09f..a2741bcb36 100644 --- a/tests/trace-viewer/trace-viewer.spec.ts +++ b/tests/trace-viewer/trace-viewer.spec.ts @@ -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(` + + + + + + + `); + 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(`