chore: generate match snapshot (#33105)
This commit is contained in:
parent
23b1012c70
commit
4b1fbde2ad
|
@ -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)});`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)});`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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)})`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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 |
|
@ -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) {
|
||||
|
|
|
@ -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(() => {});
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -26,7 +26,8 @@ export type Mode =
|
|||
| 'recording-inspecting'
|
||||
| 'standby'
|
||||
| 'assertingVisibility'
|
||||
| 'assertingValue';
|
||||
| 'assertingValue'
|
||||
| 'assertingSnapshot';
|
||||
|
||||
export type EventData = {
|
||||
event:
|
||||
|
|
|
@ -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?"`);
|
||||
});
|
||||
|
|
|
@ -64,6 +64,7 @@ const iconNames = [
|
|||
'check',
|
||||
'close',
|
||||
'pass',
|
||||
'gist',
|
||||
];
|
||||
|
||||
(async () => {
|
||||
|
|
Loading…
Reference in New Issue