From 4b1fbde2adbfc3d9c9160d2b46671eb0c47c506e Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 15 Oct 2024 13:38:55 -0700 Subject: [PATCH] chore: generate match snapshot (#33105) --- .../src/server/codegen/csharp.ts | 2 + .../src/server/codegen/java.ts | 2 + .../src/server/codegen/javascript.ts | 19 +++++- .../src/server/codegen/python.ts | 2 + .../src/server/injected/ariaSnapshot.ts | 25 ++++---- .../src/server/injected/highlight.css | 5 ++ .../src/server/injected/injectedScript.ts | 6 +- .../src/server/injected/recorder/clipPaths.ts | 2 +- .../server/injected/recorder/icons/gist.svg | 1 + .../src/server/injected/recorder/recorder.ts | 59 ++++++++++++++++--- .../playwright-core/src/server/recorder.ts | 2 +- .../src/utils/isomorphic/recorderUtils.ts | 9 +++ packages/recorder/src/actions.ts | 12 +++- packages/recorder/src/recorder.tsx | 1 + packages/recorder/src/recorderTypes.ts | 3 +- tests/page/to-match-aria-snapshot.spec.ts | 15 +++-- utils/generate_clip_paths.js | 1 + 17 files changed, 132 insertions(+), 34 deletions(-) create mode 100644 packages/playwright-core/src/server/injected/recorder/icons/gist.svg diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index 8e6561f04ee8e..2e4526d0a2dd3 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -146,6 +146,8 @@ export class CSharpLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `ToHaveValueAsync(${quote(action.value)})` : `ToBeEmptyAsync()`; return `await Expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertSnapshot': + return `await Expect(${subject}.${this._asLocator(action.selector)}).ToMatchAriaSnapshotAsync(${quote(action.snapshot)});`; } } diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 5b417c6c3a82d..c6d41e607b384 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -133,6 +133,8 @@ export class JavaLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `hasValue(${quote(action.value)})` : `isEmpty()`; return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${assertion};`; } + case 'assertSnapshot': + return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).matchesAriaSnapshot(${quote(action.snapshot)});`; } } diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index b68a8104a86e2..c0e62d9df3020 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -117,6 +117,8 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `toHaveValue(${quote(action.value)})` : `toBeEmpty()`; return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertSnapshot': + return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).toMatchAriaSnapshot(${quoteMultiline(action.snapshot)});`; } } @@ -228,11 +230,13 @@ export class JavaScriptFormatter { } prepend(text: string) { - this._lines = text.trim().split('\n').map(line => line.trim()).concat(this._lines); + const trim = isMultilineString(text) ? (line: string) => line : (line: string) => line.trim(); + this._lines = text.trim().split('\n').map(trim).concat(this._lines); } add(text: string) { - this._lines.push(...text.trim().split('\n').map(line => line.trim())); + const trim = isMultilineString(text) ? (line: string) => line : (line: string) => line.trim(); + this._lines.push(...text.trim().split('\n').map(trim)); } newLine() { @@ -269,3 +273,14 @@ function wrapWithStep(description: string | undefined, body: string) { ${body} });` : body; } + +export function quoteMultiline(text: string, indent = ' ') { + const lines = text.split('\n'); + if (lines.length === 1) + return '`' + text.replace(/`/g, '\\`').replace(/\${/g, '\\${') + '`'; + return '`\n' + lines.map(line => indent + line.replace(/`/g, '\\`').replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``; +} + +function isMultilineString(text: string) { + return text.match(/`[\S\s]*`/)?.[0].includes('\n'); +} diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 38894695bcf49..50afe1b1a5225 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -126,6 +126,8 @@ export class PythonLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `to_have_value(${quote(action.value)})` : `to_be_empty()`; return `expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertSnapshot': + return `expect(${subject}.${this._asLocator(action.selector)}).to_match_aria_snapshot(${quote(action.snapshot)})`; } } diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index 8e08ae70160d0..22ec9b5c42273 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -90,8 +90,7 @@ export function generateAriaTree(rootElement: Element): AriaNode { } beginAriaCaches(); - const result = toAriaNode(rootElement); - const ariaRoot = result?.ariaNode || { role: '' }; + const ariaRoot: AriaNode = { role: '' }; try { visit(ariaRoot, rootElement); } finally { @@ -218,7 +217,11 @@ function nodeMatches(root: AriaNode, template: AriaTemplateNode): boolean { export function renderAriaTree(ariaNode: AriaNode): string { const lines: string[] = []; - const visit = (ariaNode: AriaNode, indent: string) => { + const visit = (ariaNode: AriaNode | string, indent: string) => { + if (typeof ariaNode === 'string') { + lines.push(indent + '- text: ' + escapeYamlString(ariaNode)); + return; + } let line = `${indent}- ${ariaNode.role}`; if (ariaNode.name) line += ` ${escapeWithQuotes(ariaNode.name, '"')}`; @@ -231,14 +234,16 @@ export function renderAriaTree(ariaNode: AriaNode): string { return; } lines.push(line + (ariaNode.children ? ':' : '')); - for (const child of ariaNode.children || []) { - if (typeof child === 'string') - lines.push(indent + ' - text: ' + escapeYamlString(child)); - else - visit(child, indent + ' '); - } + for (const child of ariaNode.children || []) + visit(child, indent + ' '); }; - visit(ariaNode, ''); + if (ariaNode.role === '') { + // Render fragment. + for (const child of ariaNode.children || []) + visit(child, ''); + } else { + visit(ariaNode, ''); + } return lines.join('\n'); } diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css index 83123011bc5b8..096f9311611c4 100644 --- a/packages/playwright-core/src/server/injected/highlight.css +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -220,6 +220,11 @@ x-pw-tool-item.value > x-div { clip-path: url(#icon-symbol-constant); } +x-pw-tool-item.snapshot > x-div { + /* codicon: eye */ + clip-path: url(#icon-gist); +} + x-pw-tool-item.accept > x-div { clip-path: url(#icon-check); } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 0f3308fe7c7f9..66a18848db964 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -34,7 +34,7 @@ import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } fr import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; -import { matchesAriaTree } from './ariaSnapshot'; +import { matchesAriaTree, renderedAriaTree } from './ariaSnapshot'; export type FrameExpectParams = Omit & { expectedValue?: any }; @@ -206,6 +206,10 @@ export class InjectedScript { return new Set(result.map(r => r.element)); } + renderedAriaTree(target: Element): string { + return renderedAriaTree(target); + } + querySelectorAll(selector: ParsedSelector, root: Node): Element[] { if (selector.capture !== undefined) { if (selector.parts.some(part => part.name === 'nth')) diff --git a/packages/playwright-core/src/server/injected/recorder/clipPaths.ts b/packages/playwright-core/src/server/injected/recorder/clipPaths.ts index faa77a63d6779..a1e016542a8fc 100644 --- a/packages/playwright-core/src/server/injected/recorder/clipPaths.ts +++ b/packages/playwright-core/src/server/injected/recorder/clipPaths.ts @@ -27,5 +27,5 @@ import type { SvgJson } from './recorder'; // eslint-disable-next-line key-spacing, object-curly-spacing, comma-spacing, quotes -const svgJson: SvgJson = {"tagName":"svg","children":[{"tagName":"defs","children":[{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-gripper"},"children":[{"tagName":"path","attrs":{"d":"M5 3h2v2H5zm0 4h2v2H5zm0 4h2v2H5zm4-8h2v2H9zm0 4h2v2H9zm0 4h2v2H9z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-circle-large-filled"},"children":[{"tagName":"path","attrs":{"d":"M8 1a6.8 6.8 0 0 1 1.86.253 6.899 6.899 0 0 1 3.083 1.805 6.903 6.903 0 0 1 1.804 3.083C14.916 6.738 15 7.357 15 8s-.084 1.262-.253 1.86a6.9 6.9 0 0 1-.704 1.674 7.157 7.157 0 0 1-2.516 2.509 6.966 6.966 0 0 1-1.668.71A6.984 6.984 0 0 1 8 15a6.984 6.984 0 0 1-1.86-.246 7.098 7.098 0 0 1-1.674-.711 7.3 7.3 0 0 1-1.415-1.094 7.295 7.295 0 0 1-1.094-1.415 7.098 7.098 0 0 1-.71-1.675A6.985 6.985 0 0 1 1 8c0-.643.082-1.262.246-1.86a6.968 6.968 0 0 1 .711-1.667 7.156 7.156 0 0 1 2.509-2.516 6.895 6.895 0 0 1 1.675-.704A6.808 6.808 0 0 1 8 1z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-inspect"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 3l1-1h12l1 1v6h-1V3H2v8h5v1H2l-1-1V3zm14.707 9.707L9 6v9.414l2.707-2.707h4zM10 13V8.414l3.293 3.293h-2L10 13z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-whole-word"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M0 11H1V13H15V11H16V14H15H1H0V11Z"}},{"tagName":"path","attrs":{"d":"M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"}},{"tagName":"path","attrs":{"d":"M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-eye"},"children":[{"tagName":"path","attrs":{"d":"M7.99993 6.00316C9.47266 6.00316 10.6666 7.19708 10.6666 8.66981C10.6666 10.1426 9.47266 11.3365 7.99993 11.3365C6.52715 11.3365 5.33324 10.1426 5.33324 8.66981C5.33324 7.19708 6.52715 6.00316 7.99993 6.00316ZM7.99993 7.00315C7.07946 7.00315 6.33324 7.74935 6.33324 8.66981C6.33324 9.59028 7.07946 10.3365 7.99993 10.3365C8.9204 10.3365 9.6666 9.59028 9.6666 8.66981C9.6666 7.74935 8.9204 7.00315 7.99993 7.00315ZM7.99993 3.66675C11.0756 3.66675 13.7307 5.76675 14.4673 8.70968C14.5344 8.97755 14.3716 9.24908 14.1037 9.31615C13.8358 9.38315 13.5643 9.22041 13.4973 8.95248C12.8713 6.45205 10.6141 4.66675 7.99993 4.66675C5.38454 4.66675 3.12664 6.45359 2.50182 8.95555C2.43491 9.22341 2.16348 9.38635 1.89557 9.31948C1.62766 9.25255 1.46471 8.98115 1.53162 8.71321C2.26701 5.76856 4.9229 3.66675 7.99993 3.66675Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-symbol-constant"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M4 6h8v1H4V6zm8 3H4v1h8V9z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 4l1-1h12l1 1v8l-1 1H2l-1-1V4zm1 0v8h12V4H2z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-check"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.764.646z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-close"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-pass"},"children":[{"tagName":"path","attrs":{"d":"M6.27 10.87h.71l4.56-4.56-.71-.71-4.2 4.21-1.92-1.92L4 8.6l2.27 2.27z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z"}}]}]}]}; +const svgJson: SvgJson = {"tagName":"svg","children":[{"tagName":"defs","children":[{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-gripper"},"children":[{"tagName":"path","attrs":{"d":"M5 3h2v2H5zm0 4h2v2H5zm0 4h2v2H5zm4-8h2v2H9zm0 4h2v2H9zm0 4h2v2H9z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-circle-large-filled"},"children":[{"tagName":"path","attrs":{"d":"M8 1a6.8 6.8 0 0 1 1.86.253 6.899 6.899 0 0 1 3.083 1.805 6.903 6.903 0 0 1 1.804 3.083C14.916 6.738 15 7.357 15 8s-.084 1.262-.253 1.86a6.9 6.9 0 0 1-.704 1.674 7.157 7.157 0 0 1-2.516 2.509 6.966 6.966 0 0 1-1.668.71A6.984 6.984 0 0 1 8 15a6.984 6.984 0 0 1-1.86-.246 7.098 7.098 0 0 1-1.674-.711 7.3 7.3 0 0 1-1.415-1.094 7.295 7.295 0 0 1-1.094-1.415 7.098 7.098 0 0 1-.71-1.675A6.985 6.985 0 0 1 1 8c0-.643.082-1.262.246-1.86a6.968 6.968 0 0 1 .711-1.667 7.156 7.156 0 0 1 2.509-2.516 6.895 6.895 0 0 1 1.675-.704A6.808 6.808 0 0 1 8 1z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-inspect"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 3l1-1h12l1 1v6h-1V3H2v8h5v1H2l-1-1V3zm14.707 9.707L9 6v9.414l2.707-2.707h4zM10 13V8.414l3.293 3.293h-2L10 13z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-whole-word"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M0 11H1V13H15V11H16V14H15H1H0V11Z"}},{"tagName":"path","attrs":{"d":"M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"}},{"tagName":"path","attrs":{"d":"M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-eye"},"children":[{"tagName":"path","attrs":{"d":"M7.99993 6.00316C9.47266 6.00316 10.6666 7.19708 10.6666 8.66981C10.6666 10.1426 9.47266 11.3365 7.99993 11.3365C6.52715 11.3365 5.33324 10.1426 5.33324 8.66981C5.33324 7.19708 6.52715 6.00316 7.99993 6.00316ZM7.99993 7.00315C7.07946 7.00315 6.33324 7.74935 6.33324 8.66981C6.33324 9.59028 7.07946 10.3365 7.99993 10.3365C8.9204 10.3365 9.6666 9.59028 9.6666 8.66981C9.6666 7.74935 8.9204 7.00315 7.99993 7.00315ZM7.99993 3.66675C11.0756 3.66675 13.7307 5.76675 14.4673 8.70968C14.5344 8.97755 14.3716 9.24908 14.1037 9.31615C13.8358 9.38315 13.5643 9.22041 13.4973 8.95248C12.8713 6.45205 10.6141 4.66675 7.99993 4.66675C5.38454 4.66675 3.12664 6.45359 2.50182 8.95555C2.43491 9.22341 2.16348 9.38635 1.89557 9.31948C1.62766 9.25255 1.46471 8.98115 1.53162 8.71321C2.26701 5.76856 4.9229 3.66675 7.99993 3.66675Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-symbol-constant"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M4 6h8v1H4V6zm8 3H4v1h8V9z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 4l1-1h12l1 1v8l-1 1H2l-1-1V4zm1 0v8h12V4H2z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-check"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.764.646z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-close"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-pass"},"children":[{"tagName":"path","attrs":{"d":"M6.27 10.87h.71l4.56-4.56-.71-.71-4.2 4.21-1.92-1.92L4 8.6l2.27 2.27z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-gist"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M10.57 1.14l3.28 3.3.15.36v9.7l-.5.5h-11l-.5-.5v-13l.5-.5h7.72l.35.14zM10 5h3l-3-3v3zM3 2v12h10V6H9.5L9 5.5V2H3zm2.062 7.533l1.817-1.828L6.17 7 4 9.179v.707l2.171 2.174.707-.707-1.816-1.82zM8.8 7.714l.7-.709 2.189 2.175v.709L9.5 12.062l-.705-.709 1.831-1.82L8.8 7.714z"}}]}]}]}; export default svgJson; \ No newline at end of file diff --git a/packages/playwright-core/src/server/injected/recorder/icons/gist.svg b/packages/playwright-core/src/server/injected/recorder/icons/gist.svg new file mode 100644 index 0000000000000..f6d50e43d4f1b --- /dev/null +++ b/packages/playwright-core/src/server/injected/recorder/icons/gist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index cdc29a105063e..d620ca273cd1a 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -608,9 +608,9 @@ class TextAssertionTool implements RecorderTool { private _action: actions.AssertAction | null = null; private _dialog: Dialog; private _textCache = new Map(); - private _kind: 'text' | 'value'; + private _kind: 'text' | 'value' | 'snapshot'; - constructor(recorder: Recorder, kind: 'text' | 'value') { + constructor(recorder: Recorder, kind: 'text' | 'value' | 'snapshot') { this._recorder = recorder; this._kind = kind; this._dialog = new Dialog(recorder); @@ -656,7 +656,7 @@ class TextAssertionTool implements RecorderTool { const target = this._recorder.deepEventTarget(event); if (this._hoverHighlight?.elements[0] === target) return; - if (this._kind === 'text') + if (this._kind === 'text' || this._kind === 'snapshot') this._hoverHighlight = this._recorder.injectedScript.utils.elementText(this._textCache, target).full ? { elements: [target], selector: '' } : null; else this._hoverHighlight = this._elementHasValue(target) ? this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null; @@ -704,6 +704,18 @@ class TextAssertionTool implements RecorderTool { value: (target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).value, }; } + } if (this._kind === 'snapshot') { + this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }); + this._hoverHighlight.color = '#8acae480'; + // forTextExpect can update the target, re-highlight it. + this._recorder.updateHighlight(this._hoverHighlight, true); + + return { + name: 'assertSnapshot', + selector: this._hoverHighlight.selector, + signals: [], + snapshot: this._recorder.injectedScript.renderedAriaTree(target), + }; } else { this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }); this._hoverHighlight.color = '#8acae480'; @@ -727,6 +739,8 @@ class TextAssertionTool implements RecorderTool { return String(action.checked); if (action?.name === 'assertValue') return action.value; + if (action?.name === 'assertSnapshot') + return action.snapshot; return ''; } @@ -742,13 +756,19 @@ class TextAssertionTool implements RecorderTool { if (!this._hoverHighlight?.elements[0]) return; this._action = this._generateAction(); - if (!this._action || this._action.name !== 'assertText') - return; + if (this._action?.name === 'assertText') { + this._showTextDialog(this._action); + } else if (this._action?.name === 'assertSnapshot') { + this._recorder.recordAction(this._action); + this._recorder.setMode('recording'); + this._recorder.overlay?.flashToolSucceeded('assertingSnapshot'); + } + } - const action = this._action; + private _showTextDialog(action: actions.AssertTextAction) { const textElement = this._recorder.document.createElement('textarea'); textElement.setAttribute('spellcheck', 'false'); - textElement.value = this._renderValue(this._action); + textElement.value = this._renderValue(action); textElement.classList.add('text-editor'); const updateAndValidate = () => { @@ -796,6 +816,7 @@ class Overlay { private _assertVisibilityToggle: HTMLElement; private _assertTextToggle: HTMLElement; private _assertValuesToggle: HTMLElement; + private _assertSnapshotToggle: HTMLElement; private _offsetX = 0; private _dragState: { offsetX: number, dragStart: { x: number, y: number } } | undefined; private _measure: { width: number, height: number } = { width: 0, height: 0 }; @@ -842,6 +863,12 @@ class Overlay { this._assertValuesToggle.appendChild(this._recorder.document.createElement('x-div')); toolsListElement.appendChild(this._assertValuesToggle); + this._assertSnapshotToggle = this._recorder.document.createElement('x-pw-tool-item'); + this._assertSnapshotToggle.title = 'Assert snapshot'; + this._assertSnapshotToggle.classList.add('snapshot'); + this._assertSnapshotToggle.appendChild(this._recorder.document.createElement('x-div')); + toolsListElement.appendChild(this._assertSnapshotToggle); + this._updateVisualPosition(); this._refreshListeners(); } @@ -865,6 +892,7 @@ class Overlay { 'assertingText': 'recording-inspecting', 'assertingVisibility': 'recording-inspecting', 'assertingValue': 'recording-inspecting', + 'assertingSnapshot': 'recording-inspecting', }; this._recorder.setMode(newMode[this._recorder.state.mode]); }), @@ -880,6 +908,10 @@ class Overlay { if (!this._assertValuesToggle.classList.contains('disabled')) this._recorder.setMode(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue'); }), + addEventListener(this._assertSnapshotToggle, 'click', () => { + if (!this._assertSnapshotToggle.classList.contains('disabled')) + this._recorder.setMode(this._recorder.state.mode === 'assertingSnapshot' ? 'recording' : 'assertingSnapshot'); + }), ]; } @@ -902,6 +934,8 @@ class Overlay { this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue'); this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); + this._assertSnapshotToggle.classList.toggle('active', state.mode === 'assertingSnapshot'); + this._assertSnapshotToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); if (this._offsetX !== state.overlay.offsetX) { this._offsetX = state.overlay.offsetX; this._updateVisualPosition(); @@ -912,8 +946,14 @@ class Overlay { this._showOverlay(); } - flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue') { - const element = tool === 'assertingVisibility' ? this._assertVisibilityToggle : this._assertValuesToggle; + flashToolSucceeded(tool: 'assertingVisibility' | 'assertingSnapshot' | 'assertingValue') { + let element: Element; + if (tool === 'assertingVisibility') + element = this._assertVisibilityToggle; + else if (tool === 'assertingSnapshot') + element = this._assertSnapshotToggle; + else + element = this._assertValuesToggle; element.classList.add('succeeded'); this._recorder.injectedScript.builtinSetTimeout(() => element.classList.remove('succeeded'), 2000); } @@ -1004,6 +1044,7 @@ export class Recorder { 'assertingText': new TextAssertionTool(this, 'text'), 'assertingVisibility': new InspectTool(this, true), 'assertingValue': new TextAssertionTool(this, 'value'), + 'assertingSnapshot': new TextAssertionTool(this, 'snapshot'), }; this._currentTool = this._tools.none; if (injectedScript.window.top === injectedScript.window) { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 386e4dece6f1d..c5e133c06908a 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -216,7 +216,7 @@ export class Recorder implements InstrumentationListener, IRecorder { this._highlightedSelector = ''; this._mode = mode; this._recorderApp?.setMode(this._mode); - this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue'); + this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue' || this._mode === 'assertingSnapshot'); this._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue'); if (this._mode !== 'none' && this._mode !== 'standby' && this._context.pages().length === 1) this._context.pages()[0].bringToFront().catch(() => {}); diff --git a/packages/playwright-core/src/utils/isomorphic/recorderUtils.ts b/packages/playwright-core/src/utils/isomorphic/recorderUtils.ts index 40ce3acd44c47..349afe95a56fd 100644 --- a/packages/playwright-core/src/utils/isomorphic/recorderUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/recorderUtils.ts @@ -130,6 +130,15 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo }; return { method: 'expect', params }; } + case 'assertSnapshot': { + const params: channels.FrameExpectParams = { + selector, + expression: 'to.match.snapshot', + expectedText: [], + isNot: false, + }; + return { method: 'expect', params }; + } } } diff --git a/packages/recorder/src/actions.ts b/packages/recorder/src/actions.ts index a17e0c172bad3..d4c74b26562ab 100644 --- a/packages/recorder/src/actions.ts +++ b/packages/recorder/src/actions.ts @@ -30,7 +30,8 @@ export type ActionName = 'assertText' | 'assertValue' | 'assertChecked' | - 'assertVisible'; + 'assertVisible' | + 'assertSnapshot'; export type ActionBase = { name: ActionName, @@ -113,8 +114,13 @@ export type AssertVisibleAction = ActionWithSelector & { name: 'assertVisible', }; -export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction; -export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction; +export type AssertSnapshotAction = ActionWithSelector & { + name: 'assertSnapshot', + snapshot: string, +}; + +export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction; +export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction; export type PerformOnRecordAction = ClickAction | CheckAction | UncheckAction | PressAction | SelectAction; // Signals. diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 19b7bc12a70ad..a8cd2b271987a 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -116,6 +116,7 @@ export const Recorder: React.FC = ({ 'assertingText': 'recording-inspecting', 'assertingVisibility': 'recording-inspecting', 'assertingValue': 'recording-inspecting', + 'assertingSnapshot': 'recording-inspecting', }[mode]; window.dispatch({ event: 'setMode', params: { mode: newMode } }).catch(() => { }); }}> diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index dd379f7ccd7ee..a0e04a72836fd 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -26,7 +26,8 @@ export type Mode = | 'recording-inspecting' | 'standby' | 'assertingVisibility' - | 'assertingValue'; + | 'assertingValue' + | 'assertingSnapshot'; export type EventData = { event: diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index 826f8cc90e9af..5e58ba94e0ebe 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -78,7 +78,7 @@ test('should match complex', async ({ page }) => { test('should match regex', async ({ page }) => { await page.setContent(`

Issues 12

`); await expect(page.locator('body')).toMatchAriaSnapshot(` - - heading /Issues \\d+/ + - heading ${/Issues \d+/} `); }); @@ -178,14 +178,17 @@ test('expected formatter', async ({ page }) => { - heading "todos" - textbox "Wrong text" `, { timeout: 1 }).catch(e => e); - expect(stripAnsi(error.message)).toContain(`- Expected - 3 + + expect(stripAnsi(error.message)).toContain(` +Locator: locator('body') +- Expected - 4 + Received string + 3 - -+ - : -+ - banner: - - heading "todos" ++ - banner: +- - heading "todos" ++ - heading "todos" - - textbox "Wrong text" - -+ - textbox "What needs to be done?"`); ++ - textbox "What needs to be done?"`); }); diff --git a/utils/generate_clip_paths.js b/utils/generate_clip_paths.js index cbef6ece9d027..83d26a905c7f1 100644 --- a/utils/generate_clip_paths.js +++ b/utils/generate_clip_paths.js @@ -64,6 +64,7 @@ const iconNames = [ 'check', 'close', 'pass', + 'gist', ]; (async () => {