chore: experimental toMatchAriaSnapshot (#33014)
This commit is contained in:
Родитель
6cfcbe0d6d
Коммит
a38ff6e0d8
|
@ -2103,3 +2103,30 @@ Expected options currently selected.
|
|||
### option: LocatorAssertions.toHaveValues.timeout = %%-csharp-java-python-assertions-timeout-%%
|
||||
* since: v1.23
|
||||
|
||||
|
||||
## async method: LocatorAssertions.toMatchAriaSnapshot
|
||||
* since: v1.49
|
||||
* langs: js
|
||||
|
||||
Asserts that the target element matches the given accessibility snapshot.
|
||||
|
||||
**Usage**
|
||||
|
||||
```js
|
||||
import { role as x } from '@playwright/test';
|
||||
// ...
|
||||
await page.goto('https://demo.playwright.dev/todomvc/');
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "todos"
|
||||
- textbox "What needs to be done?"
|
||||
`);
|
||||
```
|
||||
|
||||
### param: LocatorAssertions.toMatchAriaSnapshot.expected
|
||||
* since: v1.49
|
||||
* langs: js
|
||||
- `expected` <string>
|
||||
|
||||
### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-js-assertions-timeout-%%
|
||||
* since: v1.49
|
||||
* langs: js
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
"vite": "^5.4.6",
|
||||
"ws": "^8.17.1",
|
||||
"xml2js": "^0.5.0",
|
||||
"yaml": "^2.2.2"
|
||||
"yaml": "^2.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
@ -7852,10 +7852,13 @@
|
|||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
|
||||
"integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
|
||||
"integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
|
|
|
@ -103,6 +103,6 @@
|
|||
"vite": "^5.4.6",
|
||||
"ws": "^8.17.1",
|
||||
"xml2js": "^0.5.0",
|
||||
"yaml": "^2.2.2"
|
||||
"yaml": "^2.5.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { escapeWithQuotes } from '@isomorphic/stringUtils';
|
||||
import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName, isElementIgnoredForAria } from './roleUtils';
|
||||
import { isElementVisible } from './domUtils';
|
||||
|
||||
type AriaNode = {
|
||||
role: string;
|
||||
name?: string;
|
||||
children?: (AriaNode | string)[];
|
||||
};
|
||||
|
||||
export type AriaTemplateNode = {
|
||||
role: string;
|
||||
name?: RegExp | string;
|
||||
children?: (AriaTemplateNode | string | RegExp)[];
|
||||
};
|
||||
|
||||
export function generateAriaTree(rootElement: Element): AriaNode {
|
||||
const toAriaNode = (element: Element): { ariaNode: AriaNode, isLeaf: boolean } | null => {
|
||||
const role = getAriaRole(element);
|
||||
if (!role)
|
||||
return null;
|
||||
|
||||
const name = role ? getElementAccessibleName(element, false) || undefined : undefined;
|
||||
const isLeaf = leafRoles.has(role);
|
||||
const result: AriaNode = { role, name };
|
||||
if (isLeaf && !name && element.textContent)
|
||||
result.children = [element.textContent];
|
||||
return { isLeaf, ariaNode: result };
|
||||
};
|
||||
|
||||
const visit = (ariaNode: AriaNode, node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
|
||||
ariaNode.children = ariaNode.children || [];
|
||||
ariaNode.children.push(node.nodeValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE)
|
||||
return;
|
||||
|
||||
const element = node as Element;
|
||||
if (isElementIgnoredForAria(element))
|
||||
return;
|
||||
|
||||
const visible = isElementVisible(element);
|
||||
const hasVisibleChildren = element.checkVisibility({
|
||||
opacityProperty: true,
|
||||
visibilityProperty: true,
|
||||
contentVisibilityAuto: true
|
||||
});
|
||||
|
||||
if (!hasVisibleChildren)
|
||||
return;
|
||||
|
||||
if (visible) {
|
||||
const childAriaNode = toAriaNode(element);
|
||||
const isHiddenContainer = childAriaNode && hiddenContainerRoles.has(childAriaNode.ariaNode.role);
|
||||
if (childAriaNode && !isHiddenContainer) {
|
||||
ariaNode.children = ariaNode.children || [];
|
||||
ariaNode.children.push(childAriaNode.ariaNode);
|
||||
}
|
||||
if (isHiddenContainer || !childAriaNode?.isLeaf)
|
||||
processChildNodes(childAriaNode?.ariaNode || ariaNode, element);
|
||||
} else {
|
||||
processChildNodes(ariaNode, element);
|
||||
}
|
||||
};
|
||||
|
||||
function processChildNodes(ariaNode: AriaNode, element: Element) {
|
||||
// Process light DOM children
|
||||
for (let child = element.firstChild; child; child = child.nextSibling)
|
||||
visit(ariaNode, child);
|
||||
// Process shadow DOM children, if any
|
||||
if (element.shadowRoot) {
|
||||
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
|
||||
visit(ariaNode, child);
|
||||
}
|
||||
}
|
||||
|
||||
beginAriaCaches();
|
||||
const result = toAriaNode(rootElement);
|
||||
const ariaRoot = result?.ariaNode || { role: '' };
|
||||
try {
|
||||
visit(ariaRoot, rootElement);
|
||||
} finally {
|
||||
endAriaCaches();
|
||||
}
|
||||
|
||||
normalizeStringChildren(ariaRoot);
|
||||
return ariaRoot;
|
||||
}
|
||||
|
||||
export function renderedAriaTree(rootElement: Element): string {
|
||||
return renderAriaTree(generateAriaTree(rootElement));
|
||||
}
|
||||
|
||||
function normalizeStringChildren(rootA11yNode: AriaNode) {
|
||||
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
|
||||
if (!buffer.length)
|
||||
return;
|
||||
const text = normalizeWhitespaceWithin(buffer.join('')).trim();
|
||||
if (text)
|
||||
normalizedChildren.push(text);
|
||||
buffer.length = 0;
|
||||
};
|
||||
|
||||
const visit = (ariaNode: AriaNode) => {
|
||||
const normalizedChildren: (AriaNode | string)[] = [];
|
||||
const buffer: string[] = [];
|
||||
for (const child of ariaNode.children || []) {
|
||||
if (typeof child === 'string') {
|
||||
buffer.push(child);
|
||||
} else {
|
||||
flushChildren(buffer, normalizedChildren);
|
||||
visit(child);
|
||||
normalizedChildren.push(child);
|
||||
}
|
||||
}
|
||||
flushChildren(buffer, normalizedChildren);
|
||||
ariaNode.children = normalizedChildren.length ? normalizedChildren : undefined;
|
||||
};
|
||||
visit(rootA11yNode);
|
||||
}
|
||||
|
||||
const hiddenContainerRoles = new Set(['none', 'presentation']);
|
||||
|
||||
const leafRoles = new Set([
|
||||
'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader',
|
||||
'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion',
|
||||
'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'option',
|
||||
'progressbar', 'radio', 'rowheader', 'scrollbar', 'searchbox', 'separator',
|
||||
'slider', 'spinbutton', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'term',
|
||||
'textbox', 'time', 'tooltip'
|
||||
]);
|
||||
|
||||
const normalizeWhitespaceWithin = (text: string) => text.replace(/[\s\n]+/g, ' ');
|
||||
|
||||
function matchesText(text: string | undefined, template: RegExp | string | undefined) {
|
||||
if (!template)
|
||||
return true;
|
||||
if (!text)
|
||||
return false;
|
||||
if (typeof template === 'string')
|
||||
return text === template;
|
||||
return !!text.match(template);
|
||||
}
|
||||
|
||||
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } {
|
||||
const root = generateAriaTree(rootElement);
|
||||
const matches = nodeMatches(root, template);
|
||||
return { matches, received: renderAriaTree(root) };
|
||||
}
|
||||
|
||||
function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean {
|
||||
if (typeof node === 'string' && (typeof template === 'string' || template instanceof RegExp))
|
||||
return matchesText(node, template);
|
||||
|
||||
if (typeof node === 'object' && typeof template === 'object' && !(template instanceof RegExp)) {
|
||||
if (template.role && template.role !== node.role)
|
||||
return false;
|
||||
if (!matchesText(node.name, template.name))
|
||||
return false;
|
||||
if (!containsList(node.children || [], template.children || [], depth))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function containsList(children: (AriaNode | string)[], template: (AriaTemplateNode | RegExp | string)[], depth: number): boolean {
|
||||
if (template.length > children.length)
|
||||
return false;
|
||||
const cc = children.slice();
|
||||
const tt = template.slice();
|
||||
for (const t of tt) {
|
||||
let c = cc.shift();
|
||||
while (c) {
|
||||
if (matchesNode(c, t, depth + 1))
|
||||
break;
|
||||
c = cc.shift();
|
||||
}
|
||||
if (!c)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nodeMatches(root: AriaNode, template: AriaTemplateNode): boolean {
|
||||
const results: (AriaNode | string)[] = [];
|
||||
const visit = (node: AriaNode | string): boolean => {
|
||||
if (matchesNode(node, template, 0)) {
|
||||
results.push(node);
|
||||
return true;
|
||||
}
|
||||
if (typeof node === 'string')
|
||||
return false;
|
||||
for (const child of node.children || []) {
|
||||
if (visit(child))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
visit(root);
|
||||
return !!results.length;
|
||||
}
|
||||
|
||||
export function renderAriaTree(ariaNode: AriaNode): string {
|
||||
const lines: string[] = [];
|
||||
const visit = (ariaNode: AriaNode, indent: string) => {
|
||||
let line = `${indent}- ${ariaNode.role}`;
|
||||
if (ariaNode.name)
|
||||
line += ` ${escapeWithQuotes(ariaNode.name, '"')}`;
|
||||
const noChild = !ariaNode.name && !ariaNode.children?.length;
|
||||
const oneChild = !ariaNode.name && ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string';
|
||||
if (noChild || oneChild) {
|
||||
if (oneChild)
|
||||
line += ': ' + escapeYamlString(ariaNode.children?.[0] as string);
|
||||
lines.push(line);
|
||||
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 + ' ');
|
||||
}
|
||||
};
|
||||
visit(ariaNode, '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function escapeYamlString(str: string) {
|
||||
if (str === '')
|
||||
return '""';
|
||||
|
||||
const needQuotes = (
|
||||
// Starts or ends with whitespace
|
||||
/^\s|\s$/.test(str) ||
|
||||
// Contains control characters
|
||||
/[\x00-\x1f]/.test(str) ||
|
||||
// Contains special YAML characters that could cause parsing issues
|
||||
/[\[\]{}&*!,|>%@`]/.test(str) ||
|
||||
// Contains a colon followed by a space (could be interpreted as a key-value pair)
|
||||
/:\s/.test(str) ||
|
||||
// Is a YAML boolean or null value
|
||||
/^(?:y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/.test(str) ||
|
||||
// Could be interpreted as a number
|
||||
/^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$/.test(str) ||
|
||||
// Contains a newline character
|
||||
/\n/.test(str) ||
|
||||
// Starts with a special character
|
||||
/^[\-?:,>|%@"`]/.test(str)
|
||||
);
|
||||
|
||||
if (needQuotes) {
|
||||
return `"${str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')}"`;
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
|
@ -20,6 +20,7 @@ import { escapeForTextSelector } from '../../utils/isomorphic/stringUtils';
|
|||
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
|
||||
import type { Language } from '../../utils/isomorphic/locatorGenerators';
|
||||
import type { InjectedScript } from './injectedScript';
|
||||
import { renderedAriaTree } from './ariaSnapshot';
|
||||
|
||||
const selectorSymbol = Symbol('selector');
|
||||
|
||||
|
@ -85,6 +86,7 @@ class ConsoleAPI {
|
|||
inspect: (selector: string) => this._inspect(selector),
|
||||
selector: (element: Element) => this._selector(element),
|
||||
generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language),
|
||||
ariaSnapshot: (element?: Element) => renderedAriaTree(element || this._injectedScript.document.body),
|
||||
resume: () => this._resume(),
|
||||
...new Locator(injectedScript, ''),
|
||||
};
|
||||
|
|
|
@ -29,13 +29,12 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
|
|||
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
|
||||
import type * as channels from '@protocol/channels';
|
||||
import { Highlight } from './highlight';
|
||||
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, beginAriaCaches, endAriaCaches } from './roleUtils';
|
||||
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription } from './roleUtils';
|
||||
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
|
||||
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
|
||||
import type { Language } from '../../utils/isomorphic/locatorGenerators';
|
||||
import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils';
|
||||
import { selectorForSimpleDomNodeId, generateSimpleDomNode } from './simpleDom';
|
||||
import type { SimpleDomNode } from './simpleDom';
|
||||
import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils';
|
||||
import { matchesAriaTree } from './ariaSnapshot';
|
||||
|
||||
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
|
||||
|
||||
|
@ -75,12 +74,8 @@ export class InjectedScript {
|
|||
// module-level globals will be duplicated, which leads to subtle bugs.
|
||||
readonly utils = {
|
||||
asLocator,
|
||||
beginAriaCaches,
|
||||
cacheNormalizedWhitespaces,
|
||||
elementText,
|
||||
endAriaCaches,
|
||||
escapeHTML,
|
||||
escapeHTMLAttribute,
|
||||
getAriaRole,
|
||||
getElementAccessibleDescription,
|
||||
getElementAccessibleName,
|
||||
|
@ -1255,6 +1250,11 @@ export class InjectedScript {
|
|||
}
|
||||
}
|
||||
|
||||
{
|
||||
if (expression === 'to.match.aria')
|
||||
return matchesAriaTree(element, options.expectedValue);
|
||||
}
|
||||
|
||||
{
|
||||
// Single text value.
|
||||
let received: string | undefined;
|
||||
|
@ -1332,17 +1332,6 @@ export class InjectedScript {
|
|||
}
|
||||
throw this.createStacklessError('Unknown expect matcher: ' + expression);
|
||||
}
|
||||
|
||||
generateSimpleDomNode(selector: string): SimpleDomNode | undefined {
|
||||
const element = this.querySelector(this.parseSelector(selector), this.document.documentElement, true);
|
||||
if (!element)
|
||||
return;
|
||||
return generateSimpleDomNode(this, element);
|
||||
}
|
||||
|
||||
selectorForSimpleDomNodeId(nodeId: string) {
|
||||
return selectorForSimpleDomNodeId(this, nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
||||
|
|
|
@ -261,7 +261,7 @@ function getAriaBoolean(attr: string | null) {
|
|||
return attr === null ? undefined : attr.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function isElementIgnoredForAria(element: Element) {
|
||||
export function isElementIgnoredForAria(element: Element) {
|
||||
return ['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(elementSafeTagName(element));
|
||||
}
|
||||
|
||||
|
|
|
@ -1,120 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { InjectedScript } from './injectedScript';
|
||||
|
||||
const leafRoles = new Set([
|
||||
'button',
|
||||
'checkbox',
|
||||
'combobox',
|
||||
'link',
|
||||
'textbox',
|
||||
]);
|
||||
|
||||
export type SimpleDom = {
|
||||
markup: string;
|
||||
elements: Map<string, Element>;
|
||||
};
|
||||
|
||||
export type SimpleDomNode = {
|
||||
dom: SimpleDom;
|
||||
id: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
let lastDom: SimpleDom | undefined;
|
||||
|
||||
export function generateSimpleDom(injectedScript: InjectedScript): SimpleDom {
|
||||
return generate(injectedScript).dom;
|
||||
}
|
||||
|
||||
export function generateSimpleDomNode(injectedScript: InjectedScript, target: Element): SimpleDomNode {
|
||||
return generate(injectedScript, target).node!;
|
||||
}
|
||||
|
||||
export function selectorForSimpleDomNodeId(injectedScript: InjectedScript, id: string): string {
|
||||
const element = lastDom?.elements.get(id);
|
||||
if (!element)
|
||||
throw new Error(`Internal error: element with id "${id}" not found`);
|
||||
return injectedScript.generateSelectorSimple(element);
|
||||
}
|
||||
|
||||
function generate(injectedScript: InjectedScript, target?: Element): { dom: SimpleDom, node?: SimpleDomNode } {
|
||||
const normalizeWhitespace = (text: string) => text.replace(/[\s\n]+/g, match => match.includes('\n') ? '\n' : ' ');
|
||||
const tokens: string[] = [];
|
||||
const elements = new Map<string, Element>();
|
||||
let lastId = 0;
|
||||
let resultTarget: { tag: string, id: string } | undefined;
|
||||
const visit = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
tokens.push(node.nodeValue!);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
|
||||
return;
|
||||
if (injectedScript.utils.isElementVisible(element)) {
|
||||
const role = injectedScript.utils.getAriaRole(element) as string;
|
||||
if (role && leafRoles.has(role)) {
|
||||
let value: string | undefined;
|
||||
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
|
||||
value = (element as HTMLInputElement | HTMLTextAreaElement).value;
|
||||
const name = injectedScript.utils.getElementAccessibleName(element, false);
|
||||
const structuralId = String(++lastId);
|
||||
elements.set(structuralId, element);
|
||||
tokens.push(renderTag(injectedScript, role, name, structuralId, { value }));
|
||||
if (element === target) {
|
||||
const tagNoValue = renderTag(injectedScript, role, name, structuralId);
|
||||
resultTarget = { tag: tagNoValue, id: structuralId };
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let child = element.firstChild; child; child = child.nextSibling)
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
injectedScript.utils.beginAriaCaches();
|
||||
try {
|
||||
visit(injectedScript.document.body);
|
||||
} finally {
|
||||
injectedScript.utils.endAriaCaches();
|
||||
}
|
||||
const dom = {
|
||||
markup: normalizeWhitespace(tokens.join(' ')),
|
||||
elements
|
||||
};
|
||||
|
||||
if (target && !resultTarget)
|
||||
throw new Error('Target element is not in the simple DOM');
|
||||
|
||||
lastDom = dom;
|
||||
|
||||
return { dom, node: resultTarget ? { dom, ...resultTarget } : undefined };
|
||||
}
|
||||
|
||||
function renderTag(injectedScript: InjectedScript, role: string, name: string, id: string, params?: { value?: string }): string {
|
||||
const escapedTextContent = injectedScript.utils.escapeHTML(name);
|
||||
const escapedValue = injectedScript.utils.escapeHTMLAttribute(params?.value || '');
|
||||
switch (role) {
|
||||
case 'button': return `<button id="${id}">${escapedTextContent}</button>`;
|
||||
case 'link': return `<a id="${id}">${escapedTextContent}</a>`;
|
||||
case 'textbox': return `<input id="${id}" title="${escapedTextContent}" value="${escapedValue}"></input>`;
|
||||
}
|
||||
return `<div role=${role} id="${id}">${escapedTextContent}</div>`;
|
||||
}
|
|
@ -155,6 +155,7 @@ This project incorporates components from the projects listed below. The origina
|
|||
- undici-types@6.19.8 (https://github.com/nodejs/undici)
|
||||
- update-browserslist-db@1.0.13 (https://github.com/browserslist/update-db)
|
||||
- yallist@3.1.1 (https://github.com/isaacs/yallist)
|
||||
- yaml@2.5.1 (https://github.com/eemeli/yaml)
|
||||
|
||||
%% @ampproject/remapping@2.2.1 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
|
@ -4397,8 +4398,26 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|||
=========================================
|
||||
END OF yallist@3.1.1 AND INFORMATION
|
||||
|
||||
%% yaml@2.5.1 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
Copyright Eemeli Aro <eemeli@gmail.com>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||
with or without fee is hereby granted, provided that the above copyright notice
|
||||
and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
THIS SOFTWARE.
|
||||
=========================================
|
||||
END OF yaml@2.5.1 AND INFORMATION
|
||||
|
||||
SUMMARY BEGIN HERE
|
||||
=========================================
|
||||
Total Packages: 151
|
||||
Total Packages: 152
|
||||
=========================================
|
||||
END OF SUMMARY
|
|
@ -40,6 +40,7 @@ export const matcherUtils = {
|
|||
};
|
||||
|
||||
export {
|
||||
EXPECTED_COLOR,
|
||||
INVERTED_COLOR,
|
||||
RECEIVED_COLOR,
|
||||
printReceived,
|
||||
|
|
|
@ -13,7 +13,8 @@
|
|||
"json5": "2.2.3",
|
||||
"pirates": "4.0.4",
|
||||
"source-map-support": "0.5.21",
|
||||
"stoppable": "1.1.0"
|
||||
"stoppable": "1.1.0",
|
||||
"yaml": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/source-map-support": "^0.5.4",
|
||||
|
@ -280,6 +281,17 @@
|
|||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
|
||||
"integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -464,6 +476,11 @@
|
|||
"requires": {
|
||||
"is-number": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"yaml": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
|
||||
"integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@
|
|||
"json5": "2.2.3",
|
||||
"pirates": "4.0.4",
|
||||
"source-map-support": "0.5.21",
|
||||
"stoppable": "1.1.0"
|
||||
"stoppable": "1.1.0",
|
||||
"yaml": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/source-map-support": "^0.5.4",
|
||||
|
|
|
@ -31,3 +31,6 @@ export const enquirer = enquirerLibrary;
|
|||
|
||||
import chokidarLibrary from 'chokidar';
|
||||
export const chokidar = chokidarLibrary;
|
||||
|
||||
import yamlLibrary from 'yaml';
|
||||
export const yaml = yamlLibrary;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
[*]
|
||||
../common/
|
||||
../util.ts
|
||||
../utilsBundle.ts
|
||||
../worker/testInfo.ts
|
||||
|
|
|
@ -62,6 +62,7 @@ import {
|
|||
import { zones } from 'playwright-core/lib/utils';
|
||||
import { TestInfoImpl } from '../worker/testInfo';
|
||||
import { ExpectError, isExpectError } from './matcherHint';
|
||||
import { toMatchAriaSnapshot } from './toMatchAriaSnapshot';
|
||||
|
||||
// #region
|
||||
// Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts
|
||||
|
@ -236,6 +237,7 @@ const customAsyncMatchers = {
|
|||
toHaveValue,
|
||||
toHaveValues,
|
||||
toHaveScreenshot,
|
||||
toMatchAriaSnapshot,
|
||||
toPass,
|
||||
};
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ import { TestInfoImpl } from '../worker/testInfo';
|
|||
import type { ExpectMatcherState } from '../../types/test';
|
||||
import { takeFirst } from '../common/config';
|
||||
|
||||
interface LocatorEx extends Locator {
|
||||
export interface LocatorEx extends Locator {
|
||||
_expect(expression: string, options: Omit<FrameExpectOptions, 'expectedValue'> & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Copyright Microsoft Corporation. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
import type { LocatorEx } from './matchers';
|
||||
import type { ExpectMatcherState } from '../../types/test';
|
||||
import { kNoElementsFoundError, matcherHint, type MatcherResult } from './matcherHint';
|
||||
import type { AriaTemplateNode } from 'playwright-core/lib/server/injected/ariaSnapshot';
|
||||
import { yaml } from '../utilsBundle';
|
||||
import { colors } from 'playwright-core/lib/utilsBundle';
|
||||
import { EXPECTED_COLOR } from '../common/expectBundle';
|
||||
import { callLogText } from '../util';
|
||||
import { printReceivedStringContainExpectedSubstring } from './expect';
|
||||
|
||||
export async function toMatchAriaSnapshot(
|
||||
this: ExpectMatcherState,
|
||||
receiver: LocatorEx,
|
||||
expected: string,
|
||||
options: { timeout?: number, matchSubstring?: boolean } = {},
|
||||
): Promise<MatcherResult<string | RegExp, string>> {
|
||||
const matcherName = 'toMatchAriaSnapshot';
|
||||
|
||||
const matcherOptions = {
|
||||
isNot: this.isNot,
|
||||
promise: this.promise,
|
||||
};
|
||||
|
||||
if (typeof expected !== 'string') {
|
||||
throw new Error([
|
||||
matcherHint(this, receiver, matcherName, receiver, expected, matcherOptions),
|
||||
`${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected',)} value must be a string`,
|
||||
this.utils.printWithType('Expected', expected, this.utils.printExpected)
|
||||
].join('\n\n'));
|
||||
}
|
||||
|
||||
const ariaTree = toAriaTree(expected) as AriaTemplateNode;
|
||||
const timeout = options.timeout ?? this.timeout;
|
||||
const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: ariaTree, isNot: this.isNot, timeout });
|
||||
|
||||
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
|
||||
const notFound = received === kNoElementsFoundError;
|
||||
const message = () => {
|
||||
if (pass) {
|
||||
if (notFound)
|
||||
return messagePrefix + `Expected: not ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
|
||||
const printedReceived = printReceivedStringContainExpectedSubstring(received, received.indexOf(expected), expected.length);
|
||||
return messagePrefix + `Expected: not ${this.utils.printExpected(expected)}\nReceived string: ${printedReceived}` + callLogText(log);
|
||||
} else {
|
||||
const labelExpected = `Expected`;
|
||||
if (notFound)
|
||||
return messagePrefix + `${labelExpected}: ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
|
||||
return messagePrefix + this.utils.printDiffOrStringify(expected, received, labelExpected, 'Received string', false) + callLogText(log);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: matcherName,
|
||||
expected,
|
||||
message,
|
||||
pass,
|
||||
actual: received,
|
||||
log,
|
||||
timeout: timedOut ? timeout : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseKey(key: string): AriaTemplateNode {
|
||||
if (!key)
|
||||
return { role: '' };
|
||||
|
||||
const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/);
|
||||
|
||||
if (!match)
|
||||
throw new Error(`Invalid key ${key}`);
|
||||
|
||||
const role = match[1];
|
||||
if (role && role !== 'text' && !allRoles.includes(role))
|
||||
throw new Error(`Invalid role ${role}`);
|
||||
|
||||
if (match[2])
|
||||
return { role, name: match[2] };
|
||||
if (match[3])
|
||||
return { role, name: new RegExp(match[3]) };
|
||||
return { role };
|
||||
}
|
||||
|
||||
function valueOrRegex(value: string): string | RegExp {
|
||||
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : value;
|
||||
}
|
||||
|
||||
type YamlNode = Record<string, Array<YamlNode> | string>;
|
||||
|
||||
function toAriaTree(text: string): AriaTemplateNode {
|
||||
const convert = (object: YamlNode | string): AriaTemplateNode | RegExp | string => {
|
||||
const key = typeof object === 'string' ? object : Object.keys(object)[0];
|
||||
const value = typeof object === 'string' ? undefined : object[key];
|
||||
const parsed = parseKey(key);
|
||||
if (parsed.role === 'text') {
|
||||
if (typeof value !== 'string')
|
||||
throw new Error(`Generic role must have a text value`);
|
||||
return valueOrRegex(value as string);
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
parsed.children = value.map(convert);
|
||||
else if (value)
|
||||
parsed.children = [valueOrRegex(value)];
|
||||
return parsed;
|
||||
};
|
||||
const fragment = yaml.parse(text) as YamlNode[];
|
||||
return convert({ '': fragment }) as AriaTemplateNode;
|
||||
}
|
||||
|
||||
const allRoles = [
|
||||
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'command',
|
||||
'complementary', 'composite', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid',
|
||||
'gridcell', 'group', 'heading', 'img', 'input', 'insertion', 'landmark', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'meter', 'menu',
|
||||
'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup',
|
||||
'range', 'region', 'roletype', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'section', 'sectionhead', 'select', 'separator', 'slider',
|
||||
'spinbutton', 'status', 'strong', 'structure', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer',
|
||||
'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem', 'widget', 'window'
|
||||
];
|
|
@ -20,3 +20,4 @@ export const sourceMapSupport: typeof import('../bundles/utils/node_modules/@typ
|
|||
export const stoppable: typeof import('../bundles/utils/node_modules/@types/stoppable') = require('./utilsBundleImpl').stoppable;
|
||||
export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer;
|
||||
export const chokidar: typeof import('../bundles/utils/node_modules/chokidar') = require('./utilsBundleImpl').chokidar;
|
||||
export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml;
|
||||
|
|
|
@ -7638,6 +7638,31 @@ interface LocatorAssertions {
|
|||
timeout?: number;
|
||||
}): Promise<void>;
|
||||
|
||||
/**
|
||||
* Asserts that the target element matches the given accessibility snapshot.
|
||||
*
|
||||
* **Usage**
|
||||
*
|
||||
* ```js
|
||||
* import { role as x } from '@playwright/test';
|
||||
* // ...
|
||||
* await page.goto('https://demo.playwright.dev/todomvc/');
|
||||
* await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
* - heading "todos"
|
||||
* - textbox "What needs to be done?"
|
||||
* `);
|
||||
* ```
|
||||
*
|
||||
* @param expected
|
||||
* @param options
|
||||
*/
|
||||
toMatchAriaSnapshot(expected: string, options?: {
|
||||
/**
|
||||
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
|
||||
*/
|
||||
timeout?: number;
|
||||
}): Promise<void>;
|
||||
|
||||
/**
|
||||
* Makes the assertion check for the opposite condition. For example, this code tests that the Locator doesn't contain
|
||||
* text `"error"`:
|
||||
|
|
|
@ -107,6 +107,7 @@ it('expected properties on playwright object', async ({ page }) => {
|
|||
'inspect',
|
||||
'selector',
|
||||
'generateLocator',
|
||||
'ariaSnapshot',
|
||||
'resume',
|
||||
'locator',
|
||||
'getByTestId',
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { stripAnsi } from 'tests/config/utils';
|
||||
import { test, expect } from './pageTest';
|
||||
|
||||
test('should match', async ({ page }) => {
|
||||
await page.setContent(`<h1>title</h1>`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "title"
|
||||
`);
|
||||
});
|
||||
|
||||
test('should match in list', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<h1>title</h1>
|
||||
<h1>title 2</h1>
|
||||
`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "title"
|
||||
`);
|
||||
});
|
||||
|
||||
test('should match list with accessible name', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<ul aria-label="my list">
|
||||
<li>one</li>
|
||||
<li>two</li>
|
||||
</ul>
|
||||
`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- list "my list":
|
||||
- listitem: one
|
||||
- listitem: two
|
||||
`);
|
||||
});
|
||||
|
||||
test('should match deep item', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>
|
||||
<h1>title</h1>
|
||||
<h1>title 2</h1>
|
||||
</div>
|
||||
`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "title"
|
||||
`);
|
||||
});
|
||||
|
||||
test('should match complex', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<ul>
|
||||
<li>
|
||||
<a href='about:blank'>link</a>
|
||||
</li>
|
||||
</ul>
|
||||
`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- list:
|
||||
- listitem:
|
||||
- link "link"
|
||||
`);
|
||||
});
|
||||
|
||||
test('should match regex', async ({ page }) => {
|
||||
await page.setContent(`<h1>Issues 12</h1>`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading /Issues \\d+/
|
||||
`);
|
||||
});
|
||||
|
||||
test('should allow text nodes', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<h1>Microsoft</h1>
|
||||
<div>Open source projects and samples from Microsoft</div>
|
||||
`);
|
||||
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "Microsoft"
|
||||
- text: Open source projects and samples from Microsoft
|
||||
`);
|
||||
});
|
||||
|
||||
test('integration test', async ({ page, browserName }) => {
|
||||
test.fixme(browserName === 'webkit');
|
||||
await page.setContent(`
|
||||
<h1>Microsoft</h1>
|
||||
<div>Open source projects and samples from Microsoft</div>
|
||||
<ul>
|
||||
<li>
|
||||
<details>
|
||||
<summary>
|
||||
Verified
|
||||
</summary>
|
||||
<div>
|
||||
<div>
|
||||
<p>
|
||||
We've verified that the organization <strong>microsoft</strong> controls the domain:
|
||||
</p>
|
||||
<ul>
|
||||
<li class="mb-1">
|
||||
<strong>opensource.microsoft.com</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div>
|
||||
<a href="about: blank">Learn more about verified organizations</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
<a href="about:blank">
|
||||
<summary title="Label: GitHub Sponsor">Sponsor</summary>
|
||||
</a>
|
||||
</li>
|
||||
</ul>`);
|
||||
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "Microsoft"
|
||||
- text: Open source projects and samples from Microsoft
|
||||
- list:
|
||||
- listitem:
|
||||
- group: Verified
|
||||
- listitem:
|
||||
- link "Sponsor"
|
||||
`);
|
||||
});
|
||||
|
||||
test('integration test 2', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>
|
||||
<header>
|
||||
<h1>todos</h1>
|
||||
<input placeholder="What needs to be done?">
|
||||
</header>
|
||||
</div>`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "todos"
|
||||
- textbox "What needs to be done?"
|
||||
`);
|
||||
});
|
||||
|
||||
test('expected formatter', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>
|
||||
<header>
|
||||
<h1>todos</h1>
|
||||
<input placeholder="What needs to be done?">
|
||||
</header>
|
||||
</div>`);
|
||||
const error = await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "todos"
|
||||
- textbox "Wrong text"
|
||||
`, { timeout: 1 }).catch(e => e);
|
||||
expect(stripAnsi(error.message)).toContain(`- Expected - 3
|
||||
+ Received string + 3
|
||||
|
||||
-
|
||||
+ - :
|
||||
+ - banner:
|
||||
- heading "todos"
|
||||
- - textbox "Wrong text"
|
||||
-
|
||||
+ - textbox "What needs to be done?"`);
|
||||
});
|
|
@ -152,6 +152,7 @@ class JSLintingService extends LintingService {
|
|||
'notice/notice': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'max-len': ['error', { code: 100 }],
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
},
|
||||
}
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче