chore: generate match snapshot (#33105)

This commit is contained in:
Pavel Feldman 2024-10-15 13:38:55 -07:00 committed by GitHub
parent 23b1012c70
commit 4b1fbde2ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 132 additions and 34 deletions

View File

@ -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)});`;
}
}

View File

@ -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)});`;
}
}

View File

@ -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');
}

View File

@ -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)})`;
}
}

View File

@ -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');
}

View File

@ -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);
}

View File

@ -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<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
@ -206,6 +206,10 @@ export class InjectedScript {
return new Set<Element>(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'))

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path 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"/></svg>

After

Width:  |  Height:  |  Size: 429 B

View File

@ -608,9 +608,9 @@ class TextAssertionTool implements RecorderTool {
private _action: actions.AssertAction | null = null;
private _dialog: Dialog;
private _textCache = new Map<Element | ShadowRoot, ElementText>();
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) {

View File

@ -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(() => {});

View File

@ -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 };
}
}
}

View File

@ -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.

View File

@ -116,6 +116,7 @@ export const Recorder: React.FC<RecorderProps> = ({
'assertingText': 'recording-inspecting',
'assertingVisibility': 'recording-inspecting',
'assertingValue': 'recording-inspecting',
'assertingSnapshot': 'recording-inspecting',
}[mode];
window.dispatch({ event: 'setMode', params: { mode: newMode } }).catch(() => { });
}}></ToolbarButton>

View File

@ -26,7 +26,8 @@ export type Mode =
| 'recording-inspecting'
| 'standby'
| 'assertingVisibility'
| 'assertingValue';
| 'assertingValue'
| 'assertingSnapshot';
export type EventData = {
event:

View File

@ -78,7 +78,7 @@ test('should match complex', async ({ page }) => {
test('should match regex', async ({ page }) => {
await page.setContent(`<h1>Issues 12</h1>`);
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?"`);
});

View File

@ -64,6 +64,7 @@ const iconNames = [
'check',
'close',
'pass',
'gist',
];
(async () => {