Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: allow editing aria template in recorder #33482

Merged
merged 1 commit into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,6 @@
"vite": "^5.4.6",
"ws": "^8.17.1",
"xml2js": "^0.5.0",
"yaml": "^2.5.1"
"yaml": "^2.6.0"
}
}
2 changes: 1 addition & 1 deletion packages/playwright-core/bundles/utils/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/playwright-core/bundles/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"socks-proxy-agent": "8.0.4",
"stack-utils": "2.0.5",
"ws": "8.17.1",
"yaml": "^2.5.1"
"yaml": "^2.6.0"
},
"devDependencies": {
"@types/debug": "^4.1.7",
Expand Down
243 changes: 3 additions & 240 deletions packages/playwright-core/src/server/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,250 +14,13 @@
* limitations under the License.
*/

import type { AriaTemplateNode, AriaTemplateRoleNode } from './injected/ariaSnapshot';
import { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot';
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
import { yaml } from '../utilsBundle';
import { assert } from '../utils';

export function parseAriaSnapshot(text: string): AriaTemplateNode {
const fragment = yaml.parse(text);
if (!Array.isArray(fragment))
throw new Error('Expected object key starting with "- ":\n\n' + text + '\n');
const result: AriaTemplateNode = { kind: 'role', role: 'fragment' };
populateNode(result, fragment);
return result;
}

function populateNode(node: AriaTemplateRoleNode, container: any[]) {
for (const object of container) {
if (typeof object === 'string') {
const childNode = KeyParser.parse(object);
node.children = node.children || [];
node.children.push(childNode);
continue;
}

for (const key of Object.keys(object)) {
node.children = node.children || [];
const value = object[key];

if (key === 'text') {
node.children.push({
kind: 'text',
text: valueOrRegex(value)
});
continue;
}

const childNode = KeyParser.parse(key);
if (childNode.kind === 'text') {
node.children.push({
kind: 'text',
text: valueOrRegex(value)
});
continue;
}

if (typeof value === 'string') {
node.children.push({
...childNode, children: [{
kind: 'text',
text: valueOrRegex(value)
}]
});
continue;
}

node.children.push(childNode);
populateNode(childNode, value);
}
}
}

function applyAttribute(node: AriaTemplateRoleNode, key: string, value: string) {
if (key === 'checked') {
assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "checked\" attribute must be a boolean or "mixed"');
node.checked = value === 'true' ? true : value === 'false' ? false : 'mixed';
return;
}
if (key === 'disabled') {
assert(value === 'true' || value === 'false', 'Value of "disabled" attribute must be a boolean');
node.disabled = value === 'true';
return;
}
if (key === 'expanded') {
assert(value === 'true' || value === 'false', 'Value of "expanded" attribute must be a boolean');
node.expanded = value === 'true';
return;
}
if (key === 'level') {
assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number');
node.level = Number(value);
return;
}
if (key === 'pressed') {
assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "pressed" attribute must be a boolean or "mixed"');
node.pressed = value === 'true' ? true : value === 'false' ? false : 'mixed';
return;
}
if (key === 'selected') {
assert(value === 'true' || value === 'false', 'Value of "selected" attribute must be a boolean');
node.selected = value === 'true';
return;
}
throw new Error(`Unsupported attribute [${key}]`);
}

function normalizeWhitespace(text: string) {
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
}

function valueOrRegex(value: string): string | RegExp {
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
}

export class KeyParser {
private _input: string;
private _pos: number;
private _length: number;

static parse(input: string): AriaTemplateNode {
return new KeyParser(input)._parse();
}

constructor(input: string) {
this._input = input;
this._pos = 0;
this._length = input.length;
}

private _peek() {
return this._input[this._pos] || '';
}

private _next() {
if (this._pos < this._length)
return this._input[this._pos++];
return null;
}

private _eof() {
return this._pos >= this._length;
}

private _skipWhitespace() {
while (!this._eof() && /\s/.test(this._peek()))
this._pos++;
}

private _readIdentifier(): string {
if (this._eof())
this._throwError('Unexpected end of input when expecting identifier');
const start = this._pos;
while (!this._eof() && /[a-zA-Z]/.test(this._peek()))
this._pos++;
return this._input.slice(start, this._pos);
}

private _readString(): string {
let result = '';
let escaped = false;
while (!this._eof()) {
const ch = this._next();
if (escaped) {
result += ch;
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === '"') {
return result;
} else {
result += ch;
}
}
this._throwError('Unterminated string');
}

private _throwError(message: string): never {
throw new Error(message + ':\n\n' + this._input + '\n' + ' '.repeat(this._pos) + '^\n');
}

private _readRegex(): string {
let result = '';
let escaped = false;
while (!this._eof()) {
const ch = this._next();
if (escaped) {
result += ch;
escaped = false;
} else if (ch === '\\') {
escaped = true;
result += ch;
} else if (ch === '/') {
return result;
} else {
result += ch;
}
}
this._throwError('Unterminated regex');
}

private _readStringOrRegex(): string | RegExp | null {
const ch = this._peek();
if (ch === '"') {
this._next();
return this._readString();
}

if (ch === '/') {
this._next();
return new RegExp(this._readRegex());
}

return null;
}

private _readFlags(): Map<string, string> {
const flags = new Map<string, string>();
while (true) {
this._skipWhitespace();
if (this._peek() === '[') {
this._next();
this._skipWhitespace();
const flagName = this._readIdentifier();
this._skipWhitespace();
let flagValue = '';
if (this._peek() === '=') {
this._next();
this._skipWhitespace();
while (this._peek() !== ']' && !this._eof())
flagValue += this._next();
}
this._skipWhitespace();
if (this._peek() !== ']')
this._throwError('Expected ]');

this._next(); // Consume ']'
flags.set(flagName, flagValue || 'true');
} else {
break;
}
}
return flags;
}

_parse(): AriaTemplateNode {
this._skipWhitespace();

const role = this._readIdentifier() as AriaTemplateRoleNode['role'];
this._skipWhitespace();
const name = this._readStringOrRegex() || '';
const result: AriaTemplateRoleNode = { kind: 'role', role, name };
const flags = this._readFlags();
for (const [name, value] of flags)
applyAttribute(result, name, value);
this._skipWhitespace();
if (!this._eof())
this._throwError('Unexpected input');
return result;
}
return parseYamlTemplate(fragment);
}
Loading
Loading