Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,579 @@
/**
* 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.
*/
// https://www.w3.org/TR/wai-aria-1.2/#role_definitions
export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'banner' | 'blockquote' | 'button' | 'caption' | 'cell' | 'checkbox' | 'code' | 'columnheader' | 'combobox' |
'complementary' | 'contentinfo' | 'definition' | 'deletion' | 'dialog' | 'directory' | 'document' | 'emphasis' | 'feed' | 'figure' | 'form' | 'generic' | 'grid' |
'gridcell' | 'group' | 'heading' | 'img' | 'insertion' | 'link' | 'list' | 'listbox' | 'listitem' | 'log' | 'main' | 'mark' | 'marquee' | 'math' | 'meter' | 'menu' |
'menubar' | 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' | 'navigation' | 'none' | 'note' | 'option' | 'paragraph' | 'presentation' | 'progressbar' | 'radio' | 'radiogroup' |
'region' | 'row' | 'rowgroup' | 'rowheader' | 'scrollbar' | 'search' | 'searchbox' | 'separator' | 'slider' |
'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
// Note: please keep in sync with ariaPropsEqual() below.
export type AriaProps = {
checked?: boolean | 'mixed';
disabled?: boolean;
expanded?: boolean;
active?: boolean;
level?: number;
pressed?: boolean | 'mixed';
selected?: boolean;
};
export type AriaBox = {
visible: boolean;
inline: boolean;
cursor?: string;
};
// Note: please keep in sync with ariaNodesEqual() below.
export type AriaNode = AriaProps & {
role: AriaRole | 'fragment' | 'iframe';
name: string;
ref?: string;
children: (AriaNode | string)[];
box: AriaBox;
receivesPointerEvents: boolean;
props: Record<string, string>;
};
export function ariaNodesEqual(a: AriaNode, b: AriaNode): boolean {
if (a.role !== b.role || a.name !== b.name)
return false;
if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b))
return false;
const aKeys = Object.keys(a.props);
const bKeys = Object.keys(b.props);
return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]);
}
export function hasPointerCursor(ariaNode: AriaNode): boolean {
return ariaNode.box.cursor === 'pointer';
}
function ariaPropsEqual(a: AriaProps, b: AriaProps): boolean {
return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed;
}
// We pass parsed template between worlds using JSON, make it easy.
export type AriaRegex = { pattern: string };
// We can't tell apart pattern and text, so we pass both.
export type AriaTextValue = {
raw: string;
normalized: string;
};
export type AriaTemplateTextNode = {
kind: 'text';
text: AriaTextValue;
};
export type AriaTemplateRoleNode = AriaProps & {
kind: 'role';
role: AriaRole | 'fragment';
name?: AriaRegex | string;
children?: AriaTemplateNode[];
props?: Record<string, AriaTextValue>;
containerMode?: 'contain' | 'equal' | 'deep-equal';
};
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
import type * as yamlTypes from 'yaml';
type YamlLibrary = {
parseDocument: typeof yamlTypes.parseDocument;
Scalar: typeof yamlTypes.Scalar;
YAMLMap: typeof yamlTypes.YAMLMap;
YAMLSeq: typeof yamlTypes.YAMLSeq;
LineCounter: typeof yamlTypes.LineCounter;
};
type ParsedYamlPosition = { line: number; col: number; };
type ParsingOptions = yamlTypes.ParseOptions;
export type ParsedYamlError = {
message: string;
range: [ParsedYamlPosition, ParsedYamlPosition];
};
export function parseAriaSnapshotUnsafe(yaml: YamlLibrary, text: string, options: ParsingOptions = {}): AriaTemplateNode {
const result = parseAriaSnapshot(yaml, text, options);
if (result.errors.length)
throw new Error(result.errors[0].message);
return result.fragment;
}
export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: ParsingOptions = {}): { fragment: AriaTemplateNode, errors: ParsedYamlError[] } {
const lineCounter = new yaml.LineCounter();
const parseOptions: ParsingOptions = {
keepSourceTokens: true,
lineCounter,
...options,
};
const yamlDoc = yaml.parseDocument(text, parseOptions);
const errors: ParsedYamlError[] = [];
const convertRange = (range: [number, number] | yamlTypes.Range): [ParsedYamlPosition, ParsedYamlPosition] => {
return [lineCounter.linePos(range[0]), lineCounter.linePos(range[1])];
};
const addError = (error: yamlTypes.YAMLError) => {
errors.push({
message: error.message,
range: [lineCounter.linePos(error.pos[0]), lineCounter.linePos(error.pos[1])],
});
};
const convertSeq = (container: AriaTemplateRoleNode, seq: yamlTypes.YAMLSeq) => {
for (const item of seq.items) {
const itemIsString = item instanceof yaml.Scalar && typeof item.value === 'string';
if (itemIsString) {
const childNode = KeyParser.parse(item, parseOptions, errors);
if (childNode) {
container.children = container.children || [];
container.children.push(childNode);
}
continue;
}
const itemIsMap = item instanceof yaml.YAMLMap;
if (itemIsMap) {
convertMap(container, item);
continue;
}
errors.push({
message: 'Sequence items should be strings or maps',
range: convertRange((item as any).range || seq.range),
});
}
};
const convertMap = (container: AriaTemplateRoleNode, map: yamlTypes.YAMLMap) => {
for (const entry of map.items) {
container.children = container.children || [];
// Key must by a string
const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === 'string';
if (!keyIsString) {
errors.push({
message: 'Only string keys are supported',
range: convertRange((entry.key as any).range || map.range),
});
continue;
}
const key: yamlTypes.Scalar<string> = entry.key as yamlTypes.Scalar<string>;
const value = entry.value;
// - text: "text"
if (key.value === 'text') {
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
if (!valueIsString) {
errors.push({
message: 'Text value should be a string',
range: convertRange(((entry.value as any).range || map.range)),
});
continue;
}
container.children.push({
kind: 'text',
text: textValue(value.value)
});
continue;
}
// - /children: equal
if (key.value === '/children') {
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
if (!valueIsString || (value.value !== 'contain' && value.value !== 'equal' && value.value !== 'deep-equal')) {
errors.push({
message: 'Strict value should be "contain", "equal" or "deep-equal"',
range: convertRange(((entry.value as any).range || map.range)),
});
continue;
}
container.containerMode = value.value;
continue;
}
// - /url: "about:blank"
if (key.value.startsWith('/')) {
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
if (!valueIsString) {
errors.push({
message: 'Property value should be a string',
range: convertRange(((entry.value as any).range || map.range)),
});
continue;
}
container.props = container.props ?? {};
container.props[key.value.slice(1)] = textValue(value.value);
continue;
}
// role "name": ...
const childNode = KeyParser.parse(key, parseOptions, errors);
if (!childNode)
continue;
// - role "name": "text"
const valueIsScalar = value instanceof yaml.Scalar;
if (valueIsScalar) {
const type = typeof value.value;
if (type !== 'string' && type !== 'number' && type !== 'boolean') {
errors.push({
message: 'Node value should be a string or a sequence',
range: convertRange(((entry.value as any).range || map.range)),
});
continue;
}
container.children.push({
...childNode,
children: [{
kind: 'text',
text: textValue(String(value.value))
}]
});
continue;
}
// - role "name":
// - child
const valueIsSequence = value instanceof yaml.YAMLSeq;
if (valueIsSequence) {
container.children.push(childNode);
convertSeq(childNode, value as yamlTypes.YAMLSeq);
continue;
}
errors.push({
message: 'Map values should be strings or sequences',
range: convertRange((entry.value as any).range || map.range),
});
}
};
const fragment: AriaTemplateNode = { kind: 'role', role: 'fragment' };
yamlDoc.errors.forEach(addError);
if (errors.length)
return { errors, fragment };
if (!(yamlDoc.contents instanceof yaml.YAMLSeq)) {
errors.push({
message: 'Aria snapshot must be a YAML sequence, elements starting with " -"',
range: yamlDoc.contents ? convertRange(yamlDoc.contents!.range) : [{ line: 0, col: 0 }, { line: 0, col: 0 }],
});
}
if (errors.length)
return { errors, fragment };
convertSeq(fragment, yamlDoc.contents as yamlTypes.YAMLSeq);
if (errors.length)
return { errors, fragment: emptyFragment };
// `- button` should target the button, not its parent.
if (fragment.children?.length === 1 && (!fragment.containerMode || fragment.containerMode === 'contain'))
return { fragment: fragment.children[0], errors: [] };
return { fragment, errors: [] };
}
const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment' };
function normalizeWhitespace(text: string) {
// TODO: why is this different from normalizeWhitespace in stringUtils.ts?
return text.replace(/[\u200b\u00ad]/g, '').replace(/[\r\n\s\t]+/g, ' ').trim();
}
export function textValue(value: string): AriaTextValue {
return {
raw: value,
normalized: normalizeWhitespace(value),
};
}
export class KeyParser {
private _input: string;
private _pos: number;
private _length: number;
static parse(text: yamlTypes.Scalar<string>, options: ParsingOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null {
try {
return new KeyParser(text.value)._parse();
} catch (e) {
if (e instanceof ParserError) {
const message = options.prettyErrors === false ? e.message : e.message + ':\n\n' + text.value + '\n' + ' '.repeat(e.pos) + '^\n';
errors.push({
message,
range: [options.lineCounter!.linePos(text.range![0]), options.lineCounter!.linePos(text.range![0] + e.pos)],
});
return null;
}
throw e;
}
}
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 _isWhitespace() {
return !this._eof() && /\s/.test(this._peek());
}
private _skipWhitespace() {
while (this._isWhitespace())
this._pos++;
}
private _readIdentifier(type: 'role' | 'attribute'): string {
if (this._eof())
this._throwError(`Unexpected end of input when expecting ${type}`);
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, offset: number = 0): never {
throw new ParserError(message, offset || this._pos);
}
private _readRegex(): AriaRegex {
let result = '';
let escaped = false;
let insideClass = false;
while (!this._eof()) {
const ch = this._next();
if (escaped) {
result += ch;
escaped = false;
} else if (ch === '\\') {
escaped = true;
result += ch;
} else if (ch === '/' && !insideClass) {
return { pattern: result };
} else if (ch === '[') {
insideClass = true;
result += ch;
} else if (ch === ']' && insideClass) {
result += ch;
insideClass = false;
} else {
result += ch;
}
}
this._throwError('Unterminated regex');
}
private _readStringOrRegex(): string | AriaRegex | null {
const ch = this._peek();
if (ch === '"') {
this._next();
return normalizeWhitespace(this._readString());
}
if (ch === '/') {
this._next();
return this._readRegex();
}
return null;
}
private _readAttributes(result: AriaTemplateRoleNode) {
let errorPos = this._pos;
while (true) {
this._skipWhitespace();
if (this._peek() === '[') {
this._next();
this._skipWhitespace();
errorPos = this._pos;
const flagName = this._readIdentifier('attribute');
this._skipWhitespace();
let flagValue = '';
if (this._peek() === '=') {
this._next();
this._skipWhitespace();
errorPos = this._pos;
while (this._peek() !== ']' && !this._isWhitespace() && !this._eof())
flagValue += this._next();
}
this._skipWhitespace();
if (this._peek() !== ']')
this._throwError('Expected ]');
this._next(); // Consume ']'
this._applyAttribute(result, flagName, flagValue || 'true', errorPos);
} else {
break;
}
}
}
_parse(): AriaTemplateRoleNode {
this._skipWhitespace();
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role'];
this._skipWhitespace();
const name = this._readStringOrRegex() || '';
const result: AriaTemplateRoleNode = { kind: 'role', role, name };
this._readAttributes(result);
this._skipWhitespace();
if (!this._eof())
this._throwError('Unexpected input');
return result;
}
private _applyAttribute(node: AriaTemplateRoleNode, key: string, value: string, errorPos: number) {
if (key === 'checked') {
this._assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "checked\" attribute must be a boolean or "mixed"', errorPos);
node.checked = value === 'true' ? true : value === 'false' ? false : 'mixed';
return;
}
if (key === 'disabled') {
this._assert(value === 'true' || value === 'false', 'Value of "disabled" attribute must be a boolean', errorPos);
node.disabled = value === 'true';
return;
}
if (key === 'expanded') {
this._assert(value === 'true' || value === 'false', 'Value of "expanded" attribute must be a boolean', errorPos);
node.expanded = value === 'true';
return;
}
if (key === 'active') {
this._assert(value === 'true' || value === 'false', 'Value of "active" attribute must be a boolean', errorPos);
node.active = value === 'true';
return;
}
if (key === 'level') {
this._assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number', errorPos);
node.level = Number(value);
return;
}
if (key === 'pressed') {
this._assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "pressed" attribute must be a boolean or "mixed"', errorPos);
node.pressed = value === 'true' ? true : value === 'false' ? false : 'mixed';
return;
}
if (key === 'selected') {
this._assert(value === 'true' || value === 'false', 'Value of "selected" attribute must be a boolean', errorPos);
node.selected = value === 'true';
return;
}
this._assert(false, `Unsupported attribute [${key}]`, errorPos);
}
private _assert(value: any, message: string, valuePos: number): asserts value {
if (!value)
this._throwError(message || 'Assertion error', valuePos);
}
}
export class ParserError extends Error {
readonly pos: number;
constructor(message: string, pos: number) {
super(message);
this.pos = pos;
}
}
export function findNewNode(from: AriaNode | undefined, to: AriaNode): AriaNode | undefined {
type ByRoleAndName = Map<string, Map<string, { node: AriaNode, sizeAndPosition: number }>>;
function fillMap(root: AriaNode, map: ByRoleAndName, position: number) {
let size = 1;
let childPosition = position + size;
for (const child of root.children || []) {
if (typeof child === 'string') {
size++;
childPosition++;
} else {
size += fillMap(child, map, childPosition);
childPosition += size;
}
}
if (!['none', 'presentation', 'fragment', 'iframe', 'generic'].includes(root.role) && root.name) {
let byRole = map.get(root.role);
if (!byRole) {
byRole = new Map();
map.set(root.role, byRole);
}
const existing = byRole.get(root.name);
// This heuristic prioritizes elements at the top of the page, even if somewhat smaller.
const sizeAndPosition = size * 100 - position;
if (!existing || existing.sizeAndPosition < sizeAndPosition)
byRole.set(root.name, { node: root, sizeAndPosition });
}
return size;
}
const fromMap: ByRoleAndName = new Map();
if (from)
fillMap(from, fromMap, 0);
const toMap: ByRoleAndName = new Map();
fillMap(to, toMap, 0);
const result: { node: AriaNode, sizeAndPosition: number }[] = [];
for (const [role, byRole] of toMap) {
for (const [name, byName] of byRole) {
const inFrom = fromMap.get(role)?.get(name);
if (!inFrom)
result.push(byName);
}
}
result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition);
return result[0]?.node;
}
@@ -0,0 +1,20 @@
/**
* 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.
*/
export function assert(value: any, message?: string): asserts value {
if (!value)
throw new Error(message || 'Assertion error');
}
@@ -0,0 +1,66 @@
/**
* 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.
*/
export const webColors = {
enabled: true,
reset: (text: string) => applyStyle(0, 0, text),
bold: (text: string) => applyStyle(1, 22, text),
dim: (text: string) => applyStyle(2, 22, text),
italic: (text: string) => applyStyle(3, 23, text),
underline: (text: string) => applyStyle(4, 24, text),
inverse: (text: string) => applyStyle(7, 27, text),
hidden: (text: string) => applyStyle(8, 28, text),
strikethrough: (text: string) => applyStyle(9, 29, text),
black: (text: string) => applyStyle(30, 39, text),
red: (text: string) => applyStyle(31, 39, text),
green: (text: string) => applyStyle(32, 39, text),
yellow: (text: string) => applyStyle(33, 39, text),
blue: (text: string) => applyStyle(34, 39, text),
magenta: (text: string) => applyStyle(35, 39, text),
cyan: (text: string) => applyStyle(36, 39, text),
white: (text: string) => applyStyle(37, 39, text),
gray: (text: string) => applyStyle(90, 39, text),
grey: (text: string) => applyStyle(90, 39, text),
};
export type Colors = typeof webColors;
export const noColors: Colors = {
enabled: false,
reset: t => t,
bold: t => t,
dim: t => t,
italic: t => t,
underline: t => t,
inverse: t => t,
hidden: t => t,
strikethrough: t => t,
black: t => t,
red: t => t,
green: t => t,
yellow: t => t,
blue: t => t,
magenta: t => t,
cyan: t => t,
white: t => t,
gray: t => t,
grey: t => t,
};
const applyStyle = (open: number, close: number, text: string) => `\u001b[${open}m${text}\u001b[${close}m`;
@@ -0,0 +1,267 @@
/**
* 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 * as css from './cssTokenizer';
export class InvalidSelectorError extends Error {
}
export function isInvalidSelectorError(error: Error) {
return error instanceof InvalidSelectorError;
}
// Note: '>=' is used internally for text engine to preserve backwards compatibility.
type ClauseCombinator = '' | '>' | '+' | '~' | '>=';
// TODO: consider
// - key=value
// - operators like `=`, `|=`, `~=`, `*=`, `/`
// - <empty>~=value
// - argument modes: "parse all", "parse commas", "just a string"
export type CSSFunctionArgument = CSSComplexSelector | number | string;
export type CSSFunction = { name: string, args: CSSFunctionArgument[] };
export type CSSSimpleSelector = { css?: string, functions: CSSFunction[] };
export type CSSComplexSelector = { simples: { selector: CSSSimpleSelector, combinator: ClauseCombinator }[] };
export type CSSComplexSelectorList = CSSComplexSelector[];
export function parseCSS(selector: string, customNames: Set<string>): { selector: CSSComplexSelectorList, names: string[] } {
let tokens: css.CSSTokenInterface[];
try {
tokens = css.tokenize(selector);
if (!(tokens[tokens.length - 1] instanceof css.EOFToken))
tokens.push(new css.EOFToken());
} catch (e) {
const newMessage = e.message + ` while parsing css selector "${selector}". Did you mean to CSS.escape it?`;
const index = (e.stack || '').indexOf(e.message);
if (index !== -1)
e.stack = e.stack.substring(0, index) + newMessage + e.stack.substring(index + e.message.length);
e.message = newMessage;
throw e;
}
const unsupportedToken = tokens.find(token => {
return (token instanceof css.AtKeywordToken) ||
(token instanceof css.BadStringToken) ||
(token instanceof css.BadURLToken) ||
(token instanceof css.ColumnToken) ||
(token instanceof css.CDOToken) ||
(token instanceof css.CDCToken) ||
(token instanceof css.SemicolonToken) ||
// TODO: Consider using these for something, e.g. to escape complex strings.
// For example :xpath{ (//div/bar[@attr="foo"])[2]/baz }
// Or this way :xpath( {complex-xpath-goes-here("hello")} )
(token instanceof css.OpenCurlyToken) ||
(token instanceof css.CloseCurlyToken) ||
// TODO: Consider treating these as strings?
(token instanceof css.URLToken) ||
(token instanceof css.PercentageToken);
});
if (unsupportedToken)
throw new InvalidSelectorError(`Unsupported token "${unsupportedToken.toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?`);
let pos = 0;
const names = new Set<string>();
function unexpected() {
return new InvalidSelectorError(`Unexpected token "${tokens[pos].toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?`);
}
function skipWhitespace() {
while (tokens[pos] instanceof css.WhitespaceToken)
pos++;
}
function isIdent(p = pos) {
return tokens[p] instanceof css.IdentToken;
}
function isString(p = pos) {
return tokens[p] instanceof css.StringToken;
}
function isNumber(p = pos) {
return tokens[p] instanceof css.NumberToken;
}
function isComma(p = pos) {
return tokens[p] instanceof css.CommaToken;
}
function isOpenParen(p = pos) {
return tokens[p] instanceof css.OpenParenToken;
}
function isCloseParen(p = pos) {
return tokens[p] instanceof css.CloseParenToken;
}
function isFunction(p = pos) {
return tokens[p] instanceof css.FunctionToken;
}
function isStar(p = pos) {
return (tokens[p] instanceof css.DelimToken) && tokens[p].value === '*';
}
function isEOF(p = pos) {
return tokens[p] instanceof css.EOFToken;
}
function isClauseCombinator(p = pos) {
return (tokens[p] instanceof css.DelimToken) && (['>', '+', '~'].includes(tokens[p].value as string));
}
function isSelectorClauseEnd(p = pos) {
return isComma(p) || isCloseParen(p) || isEOF(p) || isClauseCombinator(p) || (tokens[p] instanceof css.WhitespaceToken);
}
function consumeFunctionArguments(): CSSFunctionArgument[] {
const result = [consumeArgument()];
while (true) {
skipWhitespace();
if (!isComma())
break;
pos++;
result.push(consumeArgument());
}
return result;
}
function consumeArgument(): CSSFunctionArgument {
skipWhitespace();
if (isNumber())
return tokens[pos++].value!;
if (isString())
return tokens[pos++].value!;
return consumeComplexSelector();
}
function consumeComplexSelector(): CSSComplexSelector {
const result: CSSComplexSelector = { simples: [] };
skipWhitespace();
if (isClauseCombinator()) {
// Put implicit ":scope" at the start. https://drafts.csswg.org/selectors-4/#relative
result.simples.push({ selector: { functions: [{ name: 'scope', args: [] }] }, combinator: '' });
} else {
result.simples.push({ selector: consumeSimpleSelector(), combinator: '' });
}
while (true) {
skipWhitespace();
if (isClauseCombinator()) {
result.simples[result.simples.length - 1].combinator = tokens[pos++].value as ClauseCombinator;
skipWhitespace();
} else if (isSelectorClauseEnd()) {
break;
}
result.simples.push({ combinator: '', selector: consumeSimpleSelector() });
}
return result;
}
function consumeSimpleSelector(): CSSSimpleSelector {
let rawCSSString = '';
const functions: CSSFunction[] = [];
while (!isSelectorClauseEnd()) {
if (isIdent() || isStar()) {
rawCSSString += tokens[pos++].toSource();
} else if (tokens[pos] instanceof css.HashToken) {
rawCSSString += tokens[pos++].toSource();
} else if ((tokens[pos] instanceof css.DelimToken) && tokens[pos].value === '.') {
pos++;
if (isIdent())
rawCSSString += '.' + tokens[pos++].toSource();
else
throw unexpected();
} else if (tokens[pos] instanceof css.ColonToken) {
pos++;
if (isIdent()) {
if (!customNames.has((tokens[pos].value as string).toLowerCase())) {
rawCSSString += ':' + tokens[pos++].toSource();
} else {
const name = (tokens[pos++].value as string).toLowerCase();
functions.push({ name, args: [] });
names.add(name);
}
} else if (isFunction()) {
const name = (tokens[pos++].value as string).toLowerCase();
if (!customNames.has(name)) {
rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`;
} else {
functions.push({ name, args: consumeFunctionArguments() });
names.add(name);
}
skipWhitespace();
if (!isCloseParen())
throw unexpected();
pos++;
} else {
throw unexpected();
}
} else if (tokens[pos] instanceof css.OpenSquareToken) {
rawCSSString += '[';
pos++;
while (!(tokens[pos] instanceof css.CloseSquareToken) && !isEOF())
rawCSSString += tokens[pos++].toSource();
if (!(tokens[pos] instanceof css.CloseSquareToken))
throw unexpected();
rawCSSString += ']';
pos++;
} else {
throw unexpected();
}
}
if (!rawCSSString && !functions.length)
throw unexpected();
return { css: rawCSSString || undefined, functions };
}
function consumeBuiltinFunctionArguments(): string {
let s = '';
let balance = 1; // First open paren is a part of a function token.
while (!isEOF()) {
if (isOpenParen() || isFunction())
balance++;
if (isCloseParen())
balance--;
if (!balance)
break;
s += tokens[pos++].toSource();
}
return s;
}
const result = consumeFunctionArguments();
if (!isEOF())
throw unexpected();
if (result.some(arg => typeof arg !== 'object' || !('simples' in arg)))
throw new InvalidSelectorError(`Error while parsing css selector "${selector}". Did you mean to CSS.escape it?`);
return { selector: result as CSSComplexSelector[], names: Array.from(names) };
}
export function serializeSelector(args: CSSFunctionArgument[]) {
return args.map(arg => {
if (typeof arg === 'string')
return `"${arg}"`;
if (typeof arg === 'number')
return String(arg);
return arg.simples.map(({ selector, combinator }) => {
let s = selector.css || '';
s = s + selector.functions.map(func => `:${func.name}(${serializeSelector(func.args)})`).join('');
if (combinator)
s += ' ' + combinator;
return s;
}).join(' ');
}).join(', ');
}
@@ -0,0 +1,966 @@
/* eslint-disable notice/notice */
/*
* The code in this file is licensed under the CC0 license.
* http://creativecommons.org/publicdomain/zero/1.0/
* It is free to use for any purpose. No attribution, permission, or reproduction of this license is required.
*/
// Original at https://github.com/tabatkins/parse-css
// Changes:
// - JS is replaced with TS.
// - Universal Module Definition wrapper is removed.
// - Everything not related to tokenizing - below the first exports block - is removed.
export interface CSSTokenInterface {
toSource(): string;
value: string | number | undefined;
}
const between = function(num: number, first: number, last: number) { return num >= first && num <= last; };
function digit(code: number) { return between(code, 0x30, 0x39); }
function hexdigit(code: number) { return digit(code) || between(code, 0x41, 0x46) || between(code, 0x61, 0x66); }
function uppercaseletter(code: number) { return between(code, 0x41, 0x5a); }
function lowercaseletter(code: number) { return between(code, 0x61, 0x7a); }
function letter(code: number) { return uppercaseletter(code) || lowercaseletter(code); }
function nonascii(code: number) { return code >= 0x80; }
function namestartchar(code: number) { return letter(code) || nonascii(code) || code === 0x5f; }
function namechar(code: number) { return namestartchar(code) || digit(code) || code === 0x2d; }
function nonprintable(code: number) { return between(code, 0, 8) || code === 0xb || between(code, 0xe, 0x1f) || code === 0x7f; }
function newline(code: number) { return code === 0xa; }
function whitespace(code: number) { return newline(code) || code === 9 || code === 0x20; }
const maximumallowedcodepoint = 0x10ffff;
export class InvalidCharacterError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidCharacterError';
}
}
function preprocess(str: string): number[] {
// Turn a string into an array of code points,
// following the preprocessing cleanup rules.
const codepoints = [];
for (let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i);
if (code === 0xd && str.charCodeAt(i + 1) === 0xa) {
code = 0xa; i++;
}
if (code === 0xd || code === 0xc)
code = 0xa;
if (code === 0x0)
code = 0xfffd;
if (between(code, 0xd800, 0xdbff) && between(str.charCodeAt(i + 1), 0xdc00, 0xdfff)) {
// Decode a surrogate pair into an astral codepoint.
const lead = code - 0xd800;
const trail = str.charCodeAt(i + 1) - 0xdc00;
code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail;
i++;
}
codepoints.push(code);
}
return codepoints;
}
function stringFromCode(code: number) {
if (code <= 0xffff)
return String.fromCharCode(code);
// Otherwise, encode astral char as surrogate pair.
code -= Math.pow(2, 16);
const lead = Math.floor(code / Math.pow(2, 10)) + 0xd800;
const trail = code % Math.pow(2, 10) + 0xdc00;
return String.fromCharCode(lead) + String.fromCharCode(trail);
}
export function tokenize(str1: string): CSSTokenInterface[] {
const str = preprocess(str1);
let i = -1;
const tokens: CSSTokenInterface[] = [];
let code: number;
// Line number information.
let line = 0;
let column = 0;
// The only use of lastLineLength is in reconsume().
let lastLineLength = 0;
const incrLineno = function() {
line += 1;
lastLineLength = column;
column = 0;
};
const locStart = { line: line, column: column };
const codepoint = function(i: number): number {
if (i >= str.length)
return -1;
return str[i];
};
const next = function(num?: number) {
if (num === undefined)
num = 1;
if (num > 3)
throw 'Spec Error: no more than three codepoints of lookahead.';
return codepoint(i + num);
};
const consume = function(num?: number): boolean {
if (num === undefined)
num = 1;
i += num;
code = codepoint(i);
if (newline(code))
incrLineno();
else
column += num;
// console.log('Consume '+i+' '+String.fromCharCode(code) + ' 0x' + code.toString(16));
return true;
};
const reconsume = function() {
i -= 1;
if (newline(code)) {
line -= 1;
column = lastLineLength;
} else {
column -= 1;
}
locStart.line = line;
locStart.column = column;
return true;
};
const eof = function(codepoint?: number): boolean {
if (codepoint === undefined)
codepoint = code;
return codepoint === -1;
};
const donothing = function() { };
const parseerror = function() {
// Language bindings don't like writing to stdout!
// console.log('Parse error at index ' + i + ', processing codepoint 0x' + code.toString(16) + '.'); return true;
};
const consumeAToken = function(): CSSTokenInterface {
consumeComments();
consume();
if (whitespace(code)) {
while (whitespace(next()))
consume();
return new WhitespaceToken();
} else if (code === 0x22) {return consumeAStringToken();} else if (code === 0x23) {
if (namechar(next()) || areAValidEscape(next(1), next(2))) {
const token = new HashToken('');
if (wouldStartAnIdentifier(next(1), next(2), next(3)))
token.type = 'id';
token.value = consumeAName();
return token;
} else {
return new DelimToken(code);
}
} else if (code === 0x24) {
if (next() === 0x3d) {
consume();
return new SuffixMatchToken();
} else {
return new DelimToken(code);
}
} else if (code === 0x27) {return consumeAStringToken();} else if (code === 0x28) {return new OpenParenToken();} else if (code === 0x29) {return new CloseParenToken();} else if (code === 0x2a) {
if (next() === 0x3d) {
consume();
return new SubstringMatchToken();
} else {
return new DelimToken(code);
}
} else if (code === 0x2b) {
if (startsWithANumber()) {
reconsume();
return consumeANumericToken();
} else {
return new DelimToken(code);
}
} else if (code === 0x2c) {return new CommaToken();} else if (code === 0x2d) {
if (startsWithANumber()) {
reconsume();
return consumeANumericToken();
} else if (next(1) === 0x2d && next(2) === 0x3e) {
consume(2);
return new CDCToken();
} else if (startsWithAnIdentifier()) {
reconsume();
return consumeAnIdentlikeToken();
} else {
return new DelimToken(code);
}
} else if (code === 0x2e) {
if (startsWithANumber()) {
reconsume();
return consumeANumericToken();
} else {
return new DelimToken(code);
}
} else if (code === 0x3a) {return new ColonToken();} else if (code === 0x3b) {return new SemicolonToken();} else if (code === 0x3c) {
if (next(1) === 0x21 && next(2) === 0x2d && next(3) === 0x2d) {
consume(3);
return new CDOToken();
} else {
return new DelimToken(code);
}
} else if (code === 0x40) {
if (wouldStartAnIdentifier(next(1), next(2), next(3)))
return new AtKeywordToken(consumeAName());
else
return new DelimToken(code);
} else if (code === 0x5b) {return new OpenSquareToken();} else if (code === 0x5c) {
if (startsWithAValidEscape()) {
reconsume();
return consumeAnIdentlikeToken();
} else {
parseerror();
return new DelimToken(code);
}
} else if (code === 0x5d) {return new CloseSquareToken();} else if (code === 0x5e) {
if (next() === 0x3d) {
consume();
return new PrefixMatchToken();
} else {
return new DelimToken(code);
}
} else if (code === 0x7b) {return new OpenCurlyToken();} else if (code === 0x7c) {
if (next() === 0x3d) {
consume();
return new DashMatchToken();
} else if (next() === 0x7c) {
consume();
return new ColumnToken();
} else {
return new DelimToken(code);
}
} else if (code === 0x7d) {return new CloseCurlyToken();} else if (code === 0x7e) {
if (next() === 0x3d) {
consume();
return new IncludeMatchToken();
} else {
return new DelimToken(code);
}
} else if (digit(code)) {
reconsume();
return consumeANumericToken();
} else if (namestartchar(code)) {
reconsume();
return consumeAnIdentlikeToken();
} else if (eof()) {return new EOFToken();} else {return new DelimToken(code);}
};
const consumeComments = function() {
while (next(1) === 0x2f && next(2) === 0x2a) {
consume(2);
while (true) {
consume();
if (code === 0x2a && next() === 0x2f) {
consume();
break;
} else if (eof()) {
parseerror();
return;
}
}
}
};
const consumeANumericToken = function() {
const num = consumeANumber();
if (wouldStartAnIdentifier(next(1), next(2), next(3))) {
const token = new DimensionToken();
token.value = num.value;
token.repr = num.repr;
token.type = num.type;
token.unit = consumeAName();
return token;
} else if (next() === 0x25) {
consume();
const token = new PercentageToken();
token.value = num.value;
token.repr = num.repr;
return token;
} else {
const token = new NumberToken();
token.value = num.value;
token.repr = num.repr;
token.type = num.type;
return token;
}
};
const consumeAnIdentlikeToken = function(): CSSTokenInterface {
const str = consumeAName();
if (str.toLowerCase() === 'url' && next() === 0x28) {
consume();
while (whitespace(next(1)) && whitespace(next(2)))
consume();
if (next() === 0x22 || next() === 0x27)
return new FunctionToken(str);
else if (whitespace(next()) && (next(2) === 0x22 || next(2) === 0x27))
return new FunctionToken(str);
else
return consumeAURLToken();
} else if (next() === 0x28) {
consume();
return new FunctionToken(str);
} else {
return new IdentToken(str);
}
};
const consumeAStringToken = function(endingCodePoint?: number): CSSParserToken {
if (endingCodePoint === undefined)
endingCodePoint = code;
let string = '';
while (consume()) {
if (code === endingCodePoint || eof()) {
return new StringToken(string);
} else if (newline(code)) {
parseerror();
reconsume();
return new BadStringToken();
} else if (code === 0x5c) {
if (eof(next()))
donothing();
else if (newline(next()))
consume();
else
string += stringFromCode(consumeEscape());
} else {
string += stringFromCode(code);
}
}
throw new Error('Internal error');
};
const consumeAURLToken = function(): CSSTokenInterface {
const token = new URLToken('');
while (whitespace(next()))
consume();
if (eof(next()))
return token;
while (consume()) {
if (code === 0x29 || eof()) {
return token;
} else if (whitespace(code)) {
while (whitespace(next()))
consume();
if (next() === 0x29 || eof(next())) {
consume();
return token;
} else {
consumeTheRemnantsOfABadURL();
return new BadURLToken();
}
} else if (code === 0x22 || code === 0x27 || code === 0x28 || nonprintable(code)) {
parseerror();
consumeTheRemnantsOfABadURL();
return new BadURLToken();
} else if (code === 0x5c) {
if (startsWithAValidEscape()) {
token.value += stringFromCode(consumeEscape());
} else {
parseerror();
consumeTheRemnantsOfABadURL();
return new BadURLToken();
}
} else {
token.value += stringFromCode(code);
}
}
throw new Error('Internal error');
};
const consumeEscape = function() {
// Assume the current character is the \
// and the next code point is not a newline.
consume();
if (hexdigit(code)) {
// Consume 1-6 hex digits
const digits = [code];
for (let total = 0; total < 5; total++) {
if (hexdigit(next())) {
consume();
digits.push(code);
} else {
break;
}
}
if (whitespace(next()))
consume();
let value = parseInt(digits.map(function(x) { return String.fromCharCode(x); }).join(''), 16);
if (value > maximumallowedcodepoint)
value = 0xfffd;
return value;
} else if (eof()) {
return 0xfffd;
} else {
return code;
}
};
const areAValidEscape = function(c1: number, c2: number) {
if (c1 !== 0x5c)
return false;
if (newline(c2))
return false;
return true;
};
const startsWithAValidEscape = function() {
return areAValidEscape(code, next());
};
const wouldStartAnIdentifier = function(c1: number, c2: number, c3: number) {
if (c1 === 0x2d)
return namestartchar(c2) || c2 === 0x2d || areAValidEscape(c2, c3);
else if (namestartchar(c1))
return true;
else if (c1 === 0x5c)
return areAValidEscape(c1, c2);
else
return false;
};
const startsWithAnIdentifier = function() {
return wouldStartAnIdentifier(code, next(1), next(2));
};
const wouldStartANumber = function(c1: number, c2: number, c3: number) {
if (c1 === 0x2b || c1 === 0x2d) {
if (digit(c2))
return true;
if (c2 === 0x2e && digit(c3))
return true;
return false;
} else if (c1 === 0x2e) {
if (digit(c2))
return true;
return false;
} else if (digit(c1)) {
return true;
} else {
return false;
}
};
const startsWithANumber = function() {
return wouldStartANumber(code, next(1), next(2));
};
const consumeAName = function(): string {
let result = '';
while (consume()) {
if (namechar(code)) {
result += stringFromCode(code);
} else if (startsWithAValidEscape()) {
result += stringFromCode(consumeEscape());
} else {
reconsume();
return result;
}
}
throw new Error('Internal parse error');
};
const consumeANumber = function() {
let repr = '';
let type = 'integer';
if (next() === 0x2b || next() === 0x2d) {
consume();
repr += stringFromCode(code);
}
while (digit(next())) {
consume();
repr += stringFromCode(code);
}
if (next(1) === 0x2e && digit(next(2))) {
consume();
repr += stringFromCode(code);
consume();
repr += stringFromCode(code);
type = 'number';
while (digit(next())) {
consume();
repr += stringFromCode(code);
}
}
const c1 = next(1), c2 = next(2), c3 = next(3);
if ((c1 === 0x45 || c1 === 0x65) && digit(c2)) {
consume();
repr += stringFromCode(code);
consume();
repr += stringFromCode(code);
type = 'number';
while (digit(next())) {
consume();
repr += stringFromCode(code);
}
} else if ((c1 === 0x45 || c1 === 0x65) && (c2 === 0x2b || c2 === 0x2d) && digit(c3)) {
consume();
repr += stringFromCode(code);
consume();
repr += stringFromCode(code);
consume();
repr += stringFromCode(code);
type = 'number';
while (digit(next())) {
consume();
repr += stringFromCode(code);
}
}
const value = convertAStringToANumber(repr);
return { type: type, value: value, repr: repr };
};
const convertAStringToANumber = function(string: string): number {
// CSS's number rules are identical to JS, afaik.
return +string;
};
const consumeTheRemnantsOfABadURL = function() {
while (consume()) {
if (code === 0x29 || eof()) {
return;
} else if (startsWithAValidEscape()) {
consumeEscape();
donothing();
} else {
donothing();
}
}
};
let iterationCount = 0;
while (!eof(next())) {
tokens.push(consumeAToken());
iterationCount++;
if (iterationCount > str.length * 2)
throw new Error("I'm infinite-looping!");
}
return tokens;
}
export class CSSParserToken implements CSSTokenInterface {
tokenType = '';
value: string | number | undefined;
toJSON(): any {
return { token: this.tokenType };
}
toString() { return this.tokenType; }
toSource() { return '' + this; }
}
export class BadStringToken extends CSSParserToken {
override tokenType = 'BADSTRING';
}
export class BadURLToken extends CSSParserToken {
override tokenType = 'BADURL';
}
export class WhitespaceToken extends CSSParserToken {
override tokenType = 'WHITESPACE';
override toString() { return 'WS'; }
override toSource() { return ' '; }
}
export class CDOToken extends CSSParserToken {
override tokenType = 'CDO';
override toSource() { return '<!--'; }
}
export class CDCToken extends CSSParserToken {
override tokenType = 'CDC';
override toSource() { return '-->'; }
}
export class ColonToken extends CSSParserToken {
override tokenType = ':';
}
export class SemicolonToken extends CSSParserToken {
override tokenType = ';';
}
export class CommaToken extends CSSParserToken {
override tokenType = ',';
}
export class GroupingToken extends CSSParserToken {
override value = '';
mirror = '';
}
export class OpenCurlyToken extends GroupingToken {
override tokenType = '{';
constructor() {
super();
this.value = '{';
this.mirror = '}';
}
}
export class CloseCurlyToken extends GroupingToken {
override tokenType = '}';
constructor() {
super();
this.value = '}';
this.mirror = '{';
}
}
export class OpenSquareToken extends GroupingToken {
override tokenType = '[';
constructor() {
super();
this.value = '[';
this.mirror = ']';
}
}
export class CloseSquareToken extends GroupingToken {
override tokenType = ']';
constructor() {
super();
this.value = ']';
this.mirror = '[';
}
}
export class OpenParenToken extends GroupingToken {
override tokenType = '(';
constructor() {
super();
this.value = '(';
this.mirror = ')';
}
}
export class CloseParenToken extends GroupingToken {
override tokenType = ')';
constructor() {
super();
this.value = ')';
this.mirror = '(';
}
}
export class IncludeMatchToken extends CSSParserToken {
override tokenType = '~=';
}
export class DashMatchToken extends CSSParserToken {
override tokenType = '|=';
}
export class PrefixMatchToken extends CSSParserToken {
override tokenType = '^=';
}
export class SuffixMatchToken extends CSSParserToken {
override tokenType = '$=';
}
export class SubstringMatchToken extends CSSParserToken {
override tokenType = '*=';
}
export class ColumnToken extends CSSParserToken {
override tokenType = '||';
}
export class EOFToken extends CSSParserToken {
override tokenType = 'EOF';
override toSource() { return ''; }
}
export class DelimToken extends CSSParserToken {
override tokenType = 'DELIM';
override value: string = '';
constructor(code: number) {
super();
this.value = stringFromCode(code);
}
override toString() { return 'DELIM(' + this.value + ')'; }
override toJSON() {
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this);
json.value = this.value;
return json;
}
override toSource() {
if (this.value === '\\')
return '\\\n';
else
return this.value;
}
}
export abstract class StringValuedToken extends CSSParserToken {
override value: string = '';
ASCIIMatch(str: string) {
return this.value.toLowerCase() === str.toLowerCase();
}
override toJSON() {
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this);
json.value = this.value;
return json;
}
}
export class IdentToken extends StringValuedToken {
constructor(val: string) {
super();
this.value = val;
}
override tokenType = 'IDENT';
override toString() { return 'IDENT(' + this.value + ')'; }
override toSource() {
return escapeIdent(this.value);
}
}
export class FunctionToken extends StringValuedToken {
override tokenType = 'FUNCTION';
mirror: string;
constructor(val: string) {
super();
this.value = val;
this.mirror = ')';
}
override toString() { return 'FUNCTION(' + this.value + ')'; }
override toSource() {
return escapeIdent(this.value) + '(';
}
}
export class AtKeywordToken extends StringValuedToken {
override tokenType = 'AT-KEYWORD';
constructor(val: string) {
super();
this.value = val;
}
override toString() { return 'AT(' + this.value + ')'; }
override toSource() {
return '@' + escapeIdent(this.value);
}
}
export class HashToken extends StringValuedToken {
override tokenType = 'HASH';
type: string;
constructor(val: string) {
super();
this.value = val;
this.type = 'unrestricted';
}
override toString() { return 'HASH(' + this.value + ')'; }
override toJSON() {
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this);
json.value = this.value;
json.type = this.type;
return json;
}
override toSource() {
if (this.type === 'id')
return '#' + escapeIdent(this.value);
else
return '#' + escapeHash(this.value);
}
}
export class StringToken extends StringValuedToken {
override tokenType = 'STRING';
constructor(val: string) {
super();
this.value = val;
}
override toString() {
return '"' + escapeString(this.value) + '"';
}
}
export class URLToken extends StringValuedToken {
override tokenType = 'URL';
constructor(val: string) {
super();
this.value = val;
}
override toString() { return 'URL(' + this.value + ')'; }
override toSource() {
return 'url("' + escapeString(this.value) + '")';
}
}
export class NumberToken extends CSSParserToken {
override tokenType = 'NUMBER';
type: string;
repr: string;
constructor() {
super();
this.type = 'integer';
this.repr = '';
}
override toString() {
if (this.type === 'integer')
return 'INT(' + this.value + ')';
return 'NUMBER(' + this.value + ')';
}
override toJSON() {
const json = super.toJSON();
json.value = this.value;
json.type = this.type;
json.repr = this.repr;
return json;
}
override toSource() { return this.repr; }
}
export class PercentageToken extends CSSParserToken {
override tokenType = 'PERCENTAGE';
repr: string;
constructor() {
super();
this.repr = '';
}
override toString() { return 'PERCENTAGE(' + this.value + ')'; }
override toJSON() {
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this);
json.value = this.value;
json.repr = this.repr;
return json;
}
override toSource() { return this.repr + '%'; }
}
export class DimensionToken extends CSSParserToken {
override tokenType = 'DIMENSION';
type: string;
repr: string;
unit: string;
constructor() {
super();
this.type = 'integer';
this.repr = '';
this.unit = '';
}
override toString() { return 'DIM(' + this.value + ',' + this.unit + ')'; }
override toJSON() {
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this);
json.value = this.value;
json.type = this.type;
json.repr = this.repr;
json.unit = this.unit;
return json;
}
override toSource() {
const source = this.repr;
let unit = escapeIdent(this.unit);
if (unit[0].toLowerCase() === 'e' && (unit[1] === '-' || between(unit.charCodeAt(1), 0x30, 0x39))) {
// Unit is ambiguous with scinot
// Remove the leading "e", replace with escape.
unit = '\\65 ' + unit.slice(1, unit.length);
}
return source + unit;
}
}
function escapeIdent(string: string) {
string = '' + string;
let result = '';
const firstcode = string.charCodeAt(0);
for (let i = 0; i < string.length; i++) {
const code = string.charCodeAt(i);
if (code === 0x0)
throw new InvalidCharacterError('Invalid character: the input contains U+0000.');
if (
between(code, 0x1, 0x1f) || code === 0x7f ||
(i === 0 && between(code, 0x30, 0x39)) ||
(i === 1 && between(code, 0x30, 0x39) && firstcode === 0x2d)
)
result += '\\' + code.toString(16) + ' ';
else if (
code >= 0x80 ||
code === 0x2d ||
code === 0x5f ||
between(code, 0x30, 0x39) ||
between(code, 0x41, 0x5a) ||
between(code, 0x61, 0x7a)
)
result += string[i];
else
result += '\\' + string[i];
}
return result;
}
function escapeHash(string: string) {
// Escapes the contents of "unrestricted"-type hash tokens.
// Won't preserve the ID-ness of "id"-type hash tokens;
// use escapeIdent() for that.
string = '' + string;
let result = '';
for (let i = 0; i < string.length; i++) {
const code = string.charCodeAt(i);
if (code === 0x0)
throw new InvalidCharacterError('Invalid character: the input contains U+0000.');
if (
code >= 0x80 ||
code === 0x2d ||
code === 0x5f ||
between(code, 0x30, 0x39) ||
between(code, 0x41, 0x5a) ||
between(code, 0x61, 0x7a)
)
result += string[i];
else
result += '\\' + code.toString(16) + ' ';
}
return result;
}
function escapeString(string: string) {
string = '' + string;
let result = '';
for (let i = 0; i < string.length; i++) {
const code = string.charCodeAt(i);
if (code === 0x0)
throw new InvalidCharacterError('Invalid character: the input contains U+0000.');
if (between(code, 0x1, 0x1f) || code === 0x7f)
result += '\\' + code.toString(16) + ' ';
else if (code === 0x22 || code === 0x5c)
result += '\\' + string[i];
else
result += string[i];
}
return result;
}
@@ -0,0 +1,29 @@
/**
* 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 { isRegExp, isString } from './rtti';
import type { ExpectedTextValue } from '@protocol/channels';
export function serializeExpectedTextValues(items: (string | RegExp)[], options: { matchSubstring?: boolean, normalizeWhiteSpace?: boolean, ignoreCase?: boolean } = {}): ExpectedTextValue[] {
return items.map(i => ({
string: isString(i) ? i : undefined,
regexSource: isRegExp(i) ? i.source : undefined,
regexFlags: isRegExp(i) ? i.flags : undefined,
matchSubstring: options.matchSubstring,
ignoreCase: options.ignoreCase,
normalizeWhiteSpace: options.normalizeWhiteSpace,
}));
}
@@ -0,0 +1,63 @@
/**
* 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.
*/
export function msToString(ms: number): string {
if (ms < 0 || !isFinite(ms))
return '-';
if (ms === 0)
return '0ms';
if (ms < 1000)
return ms.toFixed(0) + 'ms';
const seconds = ms / 1000;
if (seconds < 60)
return seconds.toFixed(1) + 's';
const minutes = seconds / 60;
if (minutes < 60)
return minutes.toFixed(1) + 'm';
const hours = minutes / 60;
if (hours < 24)
return hours.toFixed(1) + 'h';
const days = hours / 24;
return days.toFixed(1) + 'd';
}
export function bytesToString(bytes: number): string {
if (bytes < 0 || !isFinite(bytes))
return '-';
if (bytes === 0)
return '0';
if (bytes < 1000)
return bytes.toFixed(0);
const kb = bytes / 1024;
if (kb < 1000)
return kb.toFixed(1) + 'K';
const mb = kb / 1024;
if (mb < 1000)
return mb.toFixed(1) + 'M';
const gb = mb / 1024;
return gb.toFixed(1) + 'G';
}
@@ -0,0 +1,44 @@
/**
* 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.
*/
type HeadersArray = { name: string, value: string }[];
type HeadersObject = { [key: string]: string };
export function headersObjectToArray(headers: HeadersObject, separator?: string, setCookieSeparator?: string): HeadersArray {
if (!setCookieSeparator)
setCookieSeparator = separator;
const result: HeadersArray = [];
for (const name in headers) {
const values = headers[name];
if (values === undefined)
continue;
if (separator) {
const sep = name.toLowerCase() === 'set-cookie' ? setCookieSeparator : separator;
for (const value of values.split(sep!))
result.push({ name, value: value.trim() });
} else {
result.push({ name, value: values });
}
}
return result;
}
export function headersArrayToObject(headers: HeadersArray, lowerCase: boolean): HeadersObject {
const result: HeadersObject = {};
for (const { name, value } of headers)
result[lowerCase ? name.toLowerCase() : name] = value;
return result;
}
@@ -0,0 +1,138 @@
/**
* 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.
*/
export type ImageData = { width: number, height: number, data: Buffer };
export function padImageToSize(image: ImageData, size: { width: number, height: number }): ImageData {
if (image.width === size.width && image.height === size.height)
return image;
const buffer = new Uint8Array(size.width * size.height * 4);
for (let y = 0; y < size.height; y++) {
for (let x = 0; x < size.width; x++) {
const to = (y * size.width + x) * 4;
if (y < image.height && x < image.width) {
const from = (y * image.width + x) * 4;
buffer[to] = image.data[from];
buffer[to + 1] = image.data[from + 1];
buffer[to + 2] = image.data[from + 2];
buffer[to + 3] = image.data[from + 3];
} else {
buffer[to] = 0;
buffer[to + 1] = 0;
buffer[to + 2] = 0;
buffer[to + 3] = 0;
}
}
}
return { data: Buffer.from(buffer), width: size.width, height: size.height };
}
export function scaleImageToSize(image: ImageData, size: { width: number; height: number }): ImageData {
const { data: src, width: w1, height: h1 } = image;
const w2 = Math.max(1, Math.floor(size.width));
const h2 = Math.max(1, Math.floor(size.height));
if (w1 === w2 && h1 === h2)
return image;
if (w1 <= 0 || h1 <= 0)
throw new Error('Invalid input image');
if (size.width <= 0 || size.height <= 0 || !isFinite(size.width) || !isFinite(size.height))
throw new Error('Invalid output dimensions');
const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v);
// CatmullRom weights
const weights = (t: number, o: Float32Array) => {
const t2 = t * t, t3 = t2 * t;
o[0] = -0.5 * t + 1.0 * t2 - 0.5 * t3;
o[1] = 1.0 - 2.5 * t2 + 1.5 * t3;
o[2] = 0.5 * t + 2.0 * t2 - 1.5 * t3;
o[3] = -0.5 * t2 + 0.5 * t3;
};
const srcRowStride = w1 * 4;
const dstRowStride = w2 * 4;
// Precompute X: indices, weights, and byte offsets (idx*4)
const xOff = new Int32Array(w2 * 4); // byte offsets = xIdx*4
const xW = new Float32Array(w2 * 4);
const wx = new Float32Array(4);
const xScale = w1 / w2;
for (let x = 0; x < w2; x++) {
const sx = (x + 0.5) * xScale - 0.5;
const sxi = Math.floor(sx);
const t = sx - sxi;
weights(t, wx);
const b = x * 4;
const i0 = clamp(sxi - 1, 0, w1 - 1);
const i1 = clamp(sxi + 0, 0, w1 - 1);
const i2 = clamp(sxi + 1, 0, w1 - 1);
const i3 = clamp(sxi + 2, 0, w1 - 1);
xOff[b + 0] = i0 << 2; xOff[b + 1] = i1 << 2; xOff[b + 2] = i2 << 2; xOff[b + 3] = i3 << 2;
xW[b + 0] = wx[0]; xW[b + 1] = wx[1]; xW[b + 2] = wx[2]; xW[b + 3] = wx[3];
}
// Precompute Y: indices, weights, and row-base byte offsets (y*rowStride)
const yRow = new Int32Array(h2 * 4); // row base in bytes
const yW = new Float32Array(h2 * 4);
const wy = new Float32Array(4);
const yScale = h1 / h2;
for (let y = 0; y < h2; y++) {
const sy = (y + 0.5) * yScale - 0.5;
const syi = Math.floor(sy);
const t = sy - syi;
weights(t, wy);
const b = y * 4;
const j0 = clamp(syi - 1, 0, h1 - 1);
const j1 = clamp(syi + 0, 0, h1 - 1);
const j2 = clamp(syi + 1, 0, h1 - 1);
const j3 = clamp(syi + 2, 0, h1 - 1);
yRow[b + 0] = j0 * srcRowStride;
yRow[b + 1] = j1 * srcRowStride;
yRow[b + 2] = j2 * srcRowStride;
yRow[b + 3] = j3 * srcRowStride;
yW[b + 0] = wy[0]; yW[b + 1] = wy[1]; yW[b + 2] = wy[2]; yW[b + 3] = wy[3];
}
const dst = new Uint8Array(w2 * h2 * 4);
for (let y = 0; y < h2; y++) {
const yb = y * 4;
const rb0 = yRow[yb + 0], rb1 = yRow[yb + 1], rb2 = yRow[yb + 2], rb3 = yRow[yb + 3];
const wy0 = yW[yb + 0], wy1 = yW[yb + 1], wy2 = yW[yb + 2], wy3 = yW[yb + 3];
const dstBase = y * dstRowStride;
for (let x = 0; x < w2; x++) {
const xb = x * 4;
const xo0 = xOff[xb + 0], xo1 = xOff[xb + 1], xo2 = xOff[xb + 2], xo3 = xOff[xb + 3];
const wx0 = xW[xb + 0], wx1 = xW[xb + 1], wx2 = xW[xb + 2], wx3 = xW[xb + 3];
const di = dstBase + (x << 2);
// unrolled RGBA
for (let c = 0; c < 4; c++) {
const r0 = src[rb0 + xo0 + c] * wx0 + src[rb0 + xo1 + c] * wx1 + src[rb0 + xo2 + c] * wx2 + src[rb0 + xo3 + c] * wx3;
const r1 = src[rb1 + xo0 + c] * wx0 + src[rb1 + xo1 + c] * wx1 + src[rb1 + xo2 + c] * wx2 + src[rb1 + xo3 + c] * wx3;
const r2 = src[rb2 + xo0 + c] * wx0 + src[rb2 + xo1 + c] * wx1 + src[rb2 + xo2 + c] * wx2 + src[rb2 + xo3 + c] * wx3;
const r3 = src[rb3 + xo0 + c] * wx0 + src[rb3 + xo1 + c] * wx1 + src[rb3 + xo2 + c] * wx2 + src[rb3 + xo3 + c] * wx3;
const v = r0 * wy0 + r1 * wy1 + r2 * wy2 + r3 * wy3;
dst[di + c] = v < 0 ? 0 : v > 255 ? 255 : v | 0;
}
}
}
return { data: Buffer.from(dst.buffer), width: w2, height: h2 };
}
@@ -0,0 +1,46 @@
/**
* 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.
*/
export * from './ariaSnapshot';
export * from './expectUtils';
export * from './assert';
export * from './colors';
export * from './headers';
export * from './imageUtils';
export * from './jsonSchema';
export * from './locatorGenerators';
export * from './manualPromise';
export * from './mimeType';
export * from './multimap';
export * from './protocolFormatter';
export * from './protocolMetainfo';
export * from './rtti';
export * from './semaphore';
export * from './stackTrace';
export * from './stringUtils';
export * from './formatUtils';
export * from './time';
export * from './timeoutRunner';
export * from './trace/snapshotServer';
export * from './urlMatch';
export * from './cssParser';
export * from './locatorParser';
export * from './selectorParser';
export * from './trace/snapshotStorage';
export * from './trace/traceLoader';
export * from './trace/traceModel';
export * from './trace/traceUtils';
export * from './yaml';
@@ -0,0 +1,97 @@
/**
* 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.
*/
export type JsonSchema = {
type?: string;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
oneOf?: JsonSchema[];
pattern?: string;
patternError?: string;
};
const regexCache = new Map<string, RegExp>();
export function validate(value: unknown, schema: JsonSchema, path: string): string[] {
const errors: string[] = [];
if (schema.oneOf) {
let bestErrors: string[] | undefined;
for (const variant of schema.oneOf) {
const variantErrors = validate(value, variant, path);
if (variantErrors.length === 0)
return [];
// Prefer the variant with fewest errors (closest match).
if (!bestErrors || variantErrors.length < bestErrors.length)
bestErrors = variantErrors;
}
// If the best match has only top-level type mismatches, use a generic message.
if (bestErrors!.length === 1 && bestErrors![0].startsWith(`${path}: expected `))
return [`${path}: does not match any of the expected types`];
return bestErrors!;
}
if (schema.type === 'string') {
if (typeof value !== 'string') {
errors.push(`${path}: expected string, got ${typeof value}`);
return errors;
}
if (schema.pattern && !cachedRegex(schema.pattern).test(value))
errors.push(schema.patternError || `${path}: must match pattern "${schema.pattern}"`);
return errors;
}
if (schema.type === 'array') {
if (!Array.isArray(value)) {
errors.push(`${path}: expected array, got ${typeof value}`);
return errors;
}
if (schema.items) {
for (let i = 0; i < value.length; i++)
errors.push(...validate(value[i], schema.items, `${path}[${i}]`));
}
return errors;
}
if (schema.type === 'object') {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
errors.push(`${path}: expected object, got ${Array.isArray(value) ? 'array' : typeof value}`);
return errors;
}
const obj = value as Record<string, unknown>;
for (const key of schema.required || []) {
if (obj[key] === undefined)
errors.push(`${path}.${key}: required`);
}
for (const [key, propSchema] of Object.entries(schema.properties || {})) {
if (obj[key] !== undefined)
errors.push(...validate(obj[key], propSchema, `${path}.${key}`));
}
return errors;
}
return errors;
}
function cachedRegex(pattern: string): RegExp {
let regex = regexCache.get(pattern);
if (!regex) {
regex = new RegExp(pattern);
regexCache.set(pattern, regex);
}
return regex;
}
@@ -0,0 +1,733 @@
/**
* 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 { parseAttributeSelector, parseSelector, stringifySelector } from './selectorParser';
import { escapeWithQuotes, normalizeEscapedRegexQuotes, toSnakeCase, toTitleCase } from './stringUtils';
import type { NestedSelectorBody } from './selectorParser';
import type { ParsedSelector } from './selectorParser';
export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'visible' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain';
export type LocatorBase = 'page' | 'locator' | 'frame-locator';
export type Quote = '\'' | '"' | '`';
type LocatorOptions = {
attrs?: { name: string, value: string | boolean | number }[],
exact?: boolean,
name?: string | RegExp,
hasText?: string | RegExp,
hasNotText?: string | RegExp,
};
export interface LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: LocatorOptions): string;
chainLocators(locators: string[]): string;
}
export function asLocatorDescription(lang: Language, selector: string): string {
try {
const parsed = parseSelector(selector);
const customDescription = parseCustomDescription(parsed);
if (customDescription)
return customDescription;
return innerAsLocators(new generators[lang](), parsed, false, 1)[0];
} catch (e) {
// Tolerate invalid input.
return selector;
}
}
export function locatorCustomDescription(selector: string): string | undefined {
try {
const parsed = parseSelector(selector);
return parseCustomDescription(parsed);
} catch (e) {
return undefined;
}
}
function parseCustomDescription(parsed: ParsedSelector): string | undefined {
const lastPart = parsed.parts[parsed.parts.length - 1];
if (lastPart?.name === 'internal:describe') {
const description = JSON.parse(lastPart.body as string);
if (typeof description === 'string')
return description;
}
return undefined;
}
export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string {
return asLocators(lang, selector, isFrameLocator, 1)[0];
}
export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] {
try {
return innerAsLocators(new generators[lang](preferredQuote), parseSelector(selector), isFrameLocator, maxOutputSize);
} catch (e) {
// Tolerate invalid input.
return [selector];
}
}
function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFrameLocator: boolean = false, maxOutputSize = 20): string[] {
const parts = [...parsed.parts];
const tokens: string[][] = [];
let nextBase: LocatorBase = isFrameLocator ? 'frame-locator' : 'page';
for (let index = 0; index < parts.length; index++) {
const part = parts[index];
const base = nextBase;
nextBase = 'locator';
if (part.name === 'internal:describe')
continue;
if (part.name === 'nth') {
if (part.body === '0')
tokens.push([factory.generateLocator(base, 'first', ''), factory.generateLocator(base, 'nth', '0')]);
else if (part.body === '-1')
tokens.push([factory.generateLocator(base, 'last', ''), factory.generateLocator(base, 'nth', '-1')]);
else
tokens.push([factory.generateLocator(base, 'nth', part.body as string)]);
continue;
}
if (part.name === 'visible') {
tokens.push([factory.generateLocator(base, 'visible', part.body as string), factory.generateLocator(base, 'default', `visible=${part.body}`)]);
continue;
}
if (part.name === 'internal:text') {
const { exact, text } = detectExact(part.body as string);
tokens.push([factory.generateLocator(base, 'text', text, { exact })]);
continue;
}
if (part.name === 'internal:has-text') {
const { exact, text } = detectExact(part.body as string);
// There is no locator equivalent for strict has-text, leave it as is.
if (!exact) {
tokens.push([factory.generateLocator(base, 'has-text', text, { exact })]);
continue;
}
}
if (part.name === 'internal:has-not-text') {
const { exact, text } = detectExact(part.body as string);
// There is no locator equivalent for strict has-not-text, leave it as is.
if (!exact) {
tokens.push([factory.generateLocator(base, 'has-not-text', text, { exact })]);
continue;
}
}
if (part.name === 'internal:has') {
const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
tokens.push(inners.map(inner => factory.generateLocator(base, 'has', inner)));
continue;
}
if (part.name === 'internal:has-not') {
const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
tokens.push(inners.map(inner => factory.generateLocator(base, 'hasNot', inner)));
continue;
}
if (part.name === 'internal:and') {
const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
tokens.push(inners.map(inner => factory.generateLocator(base, 'and', inner)));
continue;
}
if (part.name === 'internal:or') {
const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
tokens.push(inners.map(inner => factory.generateLocator(base, 'or', inner)));
continue;
}
if (part.name === 'internal:chain') {
const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
tokens.push(inners.map(inner => factory.generateLocator(base, 'chain', inner)));
continue;
}
if (part.name === 'internal:label') {
const { exact, text } = detectExact(part.body as string);
tokens.push([factory.generateLocator(base, 'label', text, { exact })]);
continue;
}
if (part.name === 'internal:role') {
const attrSelector = parseAttributeSelector(part.body as string, true);
const options: LocatorOptions = { attrs: [] };
for (const attr of attrSelector.attributes) {
if (attr.name === 'name') {
options.exact = attr.caseSensitive;
options.name = attr.value;
} else {
if (attr.name === 'level' && typeof attr.value === 'string')
attr.value = +attr.value;
options.attrs!.push({ name: attr.name === 'include-hidden' ? 'includeHidden' : attr.name, value: attr.value });
}
}
tokens.push([factory.generateLocator(base, 'role', attrSelector.name, options)]);
continue;
}
if (part.name === 'internal:testid') {
const attrSelector = parseAttributeSelector(part.body as string, true);
const { value } = attrSelector.attributes[0];
tokens.push([factory.generateLocator(base, 'test-id', value)]);
continue;
}
if (part.name === 'internal:attr') {
const attrSelector = parseAttributeSelector(part.body as string, true);
const { name, value, caseSensitive } = attrSelector.attributes[0];
const text = value as string | RegExp;
const exact = !!caseSensitive;
if (name === 'placeholder') {
tokens.push([factory.generateLocator(base, 'placeholder', text, { exact })]);
continue;
}
if (name === 'alt') {
tokens.push([factory.generateLocator(base, 'alt', text, { exact })]);
continue;
}
if (name === 'title') {
tokens.push([factory.generateLocator(base, 'title', text, { exact })]);
continue;
}
}
if (part.name === 'internal:control' && (part.body as string) === 'enter-frame') {
// transform last tokens from `${selector}` into `${selector}.contentFrame()` and `frameLocator(${selector})`
const lastTokens = tokens[tokens.length - 1];
const lastPart = parts[index - 1];
const transformed = lastTokens.map(token => factory.chainLocators([token, factory.generateLocator(base, 'frame', '')]));
if (['xpath', 'css'].includes(lastPart.name)) {
transformed.push(
factory.generateLocator(base, 'frame-locator', stringifySelector({ parts: [lastPart] })),
factory.generateLocator(base, 'frame-locator', stringifySelector({ parts: [lastPart] }, true))
);
}
lastTokens.splice(0, lastTokens.length, ...transformed);
nextBase = 'frame-locator';
continue;
}
const nextPart = parts[index + 1];
const selectorPart = stringifySelector({ parts: [part] });
const locatorPart = factory.generateLocator(base, 'default', selectorPart);
if (nextPart && ['internal:has-text', 'internal:has-not-text'].includes(nextPart.name)) {
const { exact, text } = detectExact(nextPart.body as string);
// There is no locator equivalent for strict has-text and has-not-text, leave it as is.
if (!exact) {
const nextLocatorPart = factory.generateLocator('locator', nextPart.name === 'internal:has-text' ? 'has-text' : 'has-not-text', text, { exact });
const options: LocatorOptions = {};
if (nextPart.name === 'internal:has-text')
options.hasText = text;
else
options.hasNotText = text;
const combinedPart = factory.generateLocator(base, 'default', selectorPart, options);
// Two options:
// - locator('div').filter({ hasText: 'foo' })
// - locator('div', { hasText: 'foo' })
tokens.push([factory.chainLocators([locatorPart, nextLocatorPart]), combinedPart]);
index++;
continue;
}
}
// Selectors can be prefixed with engine name, e.g. xpath=//foo
let locatorPartWithEngine: string | undefined;
if (['xpath', 'css'].includes(part.name)) {
const selectorPart = stringifySelector({ parts: [part] }, /* forceEngineName */ true);
locatorPartWithEngine = factory.generateLocator(base, 'default', selectorPart);
}
tokens.push([locatorPart, locatorPartWithEngine].filter(Boolean) as string[]);
}
return combineTokens(factory, tokens, maxOutputSize);
}
function combineTokens(factory: LocatorFactory, tokens: string[][], maxOutputSize: number): string[] {
const currentTokens = tokens.map(() => '');
const result: string[] = [];
const visit = (index: number) => {
if (index === tokens.length) {
result.push(factory.chainLocators(currentTokens));
return result.length < maxOutputSize;
}
for (const taken of tokens[index]) {
currentTokens[index] = taken;
if (!visit(index + 1))
return false;
}
return true;
};
visit(0);
return result;
}
function detectExact(text: string): { exact?: boolean, text: string | RegExp } {
let exact = false;
const match = text.match(/^\/(.*)\/([igm]*)$/);
if (match)
return { text: new RegExp(match[1], match[2]) };
if (text.endsWith('"')) {
text = JSON.parse(text);
exact = true;
} else if (text.endsWith('"s')) {
text = JSON.parse(text.substring(0, text.length - 1));
exact = true;
} else if (text.endsWith('"i')) {
text = JSON.parse(text.substring(0, text.length - 1));
exact = false;
}
return { exact, text };
}
export class JavaScriptLocatorFactory implements LocatorFactory {
constructor(private preferredQuote?: Quote) {}
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
switch (kind) {
case 'default':
if (options.hasText !== undefined)
return `locator(${this.quote(body as string)}, { hasText: ${this.toHasText(options.hasText)} })`;
if (options.hasNotText !== undefined)
return `locator(${this.quote(body as string)}, { hasNotText: ${this.toHasText(options.hasNotText)} })`;
return `locator(${this.quote(body as string)})`;
case 'frame-locator':
return `frameLocator(${this.quote(body as string)})`;
case 'frame':
return `contentFrame()`;
case 'nth':
return `nth(${body})`;
case 'first':
return `first()`;
case 'last':
return `last()`;
case 'visible':
return `filter({ visible: ${body === 'true' ? 'true' : 'false'} })`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
attrs.push(`name: ${this.regexToSourceString(options.name)}`);
} else if (typeof options.name === 'string') {
attrs.push(`name: ${this.quote(options.name)}`);
if (options.exact)
attrs.push(`exact: true`);
}
for (const { name, value } of options.attrs!)
attrs.push(`${name}: ${typeof value === 'string' ? this.quote(value) : value}`);
const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '';
return `getByRole(${this.quote(body as string)}${attrString})`;
case 'has-text':
return `filter({ hasText: ${this.toHasText(body)} })`;
case 'has-not-text':
return `filter({ hasNotText: ${this.toHasText(body)} })`;
case 'has':
return `filter({ has: ${body} })`;
case 'hasNot':
return `filter({ hasNot: ${body} })`;
case 'and':
return `and(${body})`;
case 'or':
return `or(${body})`;
case 'chain':
return `locator(${body})`;
case 'test-id':
return `getByTestId(${this.toTestIdValue(body)})`;
case 'text':
return this.toCallWithExact('getByText', body, !!options.exact);
case 'alt':
return this.toCallWithExact('getByAltText', body, !!options.exact);
case 'placeholder':
return this.toCallWithExact('getByPlaceholder', body, !!options.exact);
case 'label':
return this.toCallWithExact('getByLabel', body, !!options.exact);
case 'title':
return this.toCallWithExact('getByTitle', body, !!options.exact);
default:
throw new Error('Unknown selector kind ' + kind);
}
}
chainLocators(locators: string[]): string {
return locators.join('.');
}
private regexToSourceString(re: RegExp) {
return normalizeEscapedRegexQuotes(String(re));
}
private toCallWithExact(method: string, body: string | RegExp, exact?: boolean) {
if (isRegExp(body))
return `${method}(${this.regexToSourceString(body)})`;
return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`;
}
private toHasText(body: string | RegExp) {
if (isRegExp(body))
return this.regexToSourceString(body);
return this.quote(body);
}
private toTestIdValue(value: string | RegExp): string {
if (isRegExp(value))
return this.regexToSourceString(value);
return this.quote(value);
}
private quote(text: string) {
return escapeWithQuotes(text, this.preferredQuote ?? '\'');
}
}
export class PythonLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
switch (kind) {
case 'default':
if (options.hasText !== undefined)
return `locator(${this.quote(body as string)}, has_text=${this.toHasText(options.hasText)})`;
if (options.hasNotText !== undefined)
return `locator(${this.quote(body as string)}, has_not_text=${this.toHasText(options.hasNotText)})`;
return `locator(${this.quote(body as string)})`;
case 'frame-locator':
return `frame_locator(${this.quote(body as string)})`;
case 'frame':
return `content_frame`;
case 'nth':
return `nth(${body})`;
case 'first':
return `first`;
case 'last':
return `last`;
case 'visible':
return `filter(visible=${body === 'true' ? 'True' : 'False'})`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
attrs.push(`name=${this.regexToString(options.name)}`);
} else if (typeof options.name === 'string') {
attrs.push(`name=${this.quote(options.name)}`);
if (options.exact)
attrs.push(`exact=True`);
}
for (const { name, value } of options.attrs!) {
let valueString = typeof value === 'string' ? this.quote(value) : value;
if (typeof value === 'boolean')
valueString = value ? 'True' : 'False';
attrs.push(`${toSnakeCase(name)}=${valueString}`);
}
const attrString = attrs.length ? `, ${attrs.join(', ')}` : '';
return `get_by_role(${this.quote(body as string)}${attrString})`;
case 'has-text':
return `filter(has_text=${this.toHasText(body)})`;
case 'has-not-text':
return `filter(has_not_text=${this.toHasText(body)})`;
case 'has':
return `filter(has=${body})`;
case 'hasNot':
return `filter(has_not=${body})`;
case 'and':
return `and_(${body})`;
case 'or':
return `or_(${body})`;
case 'chain':
return `locator(${body})`;
case 'test-id':
return `get_by_test_id(${this.toTestIdValue(body)})`;
case 'text':
return this.toCallWithExact('get_by_text', body, !!options.exact);
case 'alt':
return this.toCallWithExact('get_by_alt_text', body, !!options.exact);
case 'placeholder':
return this.toCallWithExact('get_by_placeholder', body, !!options.exact);
case 'label':
return this.toCallWithExact('get_by_label', body, !!options.exact);
case 'title':
return this.toCallWithExact('get_by_title', body, !!options.exact);
default:
throw new Error('Unknown selector kind ' + kind);
}
}
chainLocators(locators: string[]): string {
return locators.join('.');
}
private regexToString(body: RegExp) {
const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : '';
return `re.compile(r"${normalizeEscapedRegexQuotes(body.source).replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix})`;
}
private toCallWithExact(method: string, body: string | RegExp, exact: boolean) {
if (isRegExp(body))
return `${method}(${this.regexToString(body)})`;
if (exact)
return `${method}(${this.quote(body)}, exact=True)`;
return `${method}(${this.quote(body)})`;
}
private toHasText(body: string | RegExp) {
if (isRegExp(body))
return this.regexToString(body);
return `${this.quote(body)}`;
}
private toTestIdValue(value: string | RegExp) {
if (isRegExp(value))
return this.regexToString(value);
return this.quote(value);
}
private quote(text: string) {
return escapeWithQuotes(text, '\"');
}
}
export class JavaLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
let clazz: string;
switch (base) {
case 'page': clazz = 'Page'; break;
case 'frame-locator': clazz = 'FrameLocator'; break;
case 'locator': clazz = 'Locator'; break;
}
switch (kind) {
case 'default':
if (options.hasText !== undefined)
return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasText(${this.toHasText(options.hasText)}))`;
if (options.hasNotText !== undefined)
return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasNotText(${this.toHasText(options.hasNotText)}))`;
return `locator(${this.quote(body as string)})`;
case 'frame-locator':
return `frameLocator(${this.quote(body as string)})`;
case 'frame':
return `contentFrame()`;
case 'nth':
return `nth(${body})`;
case 'first':
return `first()`;
case 'last':
return `last()`;
case 'visible':
return `filter(new ${clazz}.FilterOptions().setVisible(${body === 'true' ? 'true' : 'false'}))`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
attrs.push(`.setName(${this.regexToString(options.name)})`);
} else if (typeof options.name === 'string') {
attrs.push(`.setName(${this.quote(options.name)})`);
if (options.exact)
attrs.push(`.setExact(true)`);
}
for (const { name, value } of options.attrs!)
attrs.push(`.set${toTitleCase(name)}(${typeof value === 'string' ? this.quote(value) : value})`);
const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : '';
return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`;
case 'has-text':
return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`;
case 'has-not-text':
return `filter(new ${clazz}.FilterOptions().setHasNotText(${this.toHasText(body)}))`;
case 'has':
return `filter(new ${clazz}.FilterOptions().setHas(${body}))`;
case 'hasNot':
return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`;
case 'and':
return `and(${body})`;
case 'or':
return `or(${body})`;
case 'chain':
return `locator(${body})`;
case 'test-id':
return `getByTestId(${this.toTestIdValue(body)})`;
case 'text':
return this.toCallWithExact(clazz, 'getByText', body, !!options.exact);
case 'alt':
return this.toCallWithExact(clazz, 'getByAltText', body, !!options.exact);
case 'placeholder':
return this.toCallWithExact(clazz, 'getByPlaceholder', body, !!options.exact);
case 'label':
return this.toCallWithExact(clazz, 'getByLabel', body, !!options.exact);
case 'title':
return this.toCallWithExact(clazz, 'getByTitle', body, !!options.exact);
default:
throw new Error('Unknown selector kind ' + kind);
}
}
chainLocators(locators: string[]): string {
return locators.join('.');
}
private regexToString(body: RegExp) {
const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : '';
return `Pattern.compile(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`;
}
private toCallWithExact(clazz: string, method: string, body: string | RegExp, exact: boolean) {
if (isRegExp(body))
return `${method}(${this.regexToString(body)})`;
if (exact)
return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(true))`;
return `${method}(${this.quote(body)})`;
}
private toHasText(body: string | RegExp) {
if (isRegExp(body))
return this.regexToString(body);
return this.quote(body);
}
private toTestIdValue(value: string | RegExp) {
if (isRegExp(value))
return this.regexToString(value);
return this.quote(value);
}
private quote(text: string) {
return escapeWithQuotes(text, '\"');
}
}
export class CSharpLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
switch (kind) {
case 'default':
if (options.hasText !== undefined)
return `Locator(${this.quote(body as string)}, new() { ${this.toHasText(options.hasText)} })`;
if (options.hasNotText !== undefined)
return `Locator(${this.quote(body as string)}, new() { ${this.toHasNotText(options.hasNotText)} })`;
return `Locator(${this.quote(body as string)})`;
case 'frame-locator':
return `FrameLocator(${this.quote(body as string)})`;
case 'frame':
return `ContentFrame`;
case 'nth':
return `Nth(${body})`;
case 'first':
return `First`;
case 'last':
return `Last`;
case 'visible':
return `Filter(new() { Visible = ${body === 'true' ? 'true' : 'false'} })`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
attrs.push(`NameRegex = ${this.regexToString(options.name)}`);
} else if (typeof options.name === 'string') {
attrs.push(`Name = ${this.quote(options.name)}`);
if (options.exact)
attrs.push(`Exact = true`);
}
for (const { name, value } of options.attrs!)
attrs.push(`${toTitleCase(name)} = ${typeof value === 'string' ? this.quote(value) : value}`);
const attrString = attrs.length ? `, new() { ${attrs.join(', ')} }` : '';
return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`;
case 'has-text':
return `Filter(new() { ${this.toHasText(body)} })`;
case 'has-not-text':
return `Filter(new() { ${this.toHasNotText(body)} })`;
case 'has':
return `Filter(new() { Has = ${body} })`;
case 'hasNot':
return `Filter(new() { HasNot = ${body} })`;
case 'and':
return `And(${body})`;
case 'or':
return `Or(${body})`;
case 'chain':
return `Locator(${body})`;
case 'test-id':
return `GetByTestId(${this.toTestIdValue(body)})`;
case 'text':
return this.toCallWithExact('GetByText', body, !!options.exact);
case 'alt':
return this.toCallWithExact('GetByAltText', body, !!options.exact);
case 'placeholder':
return this.toCallWithExact('GetByPlaceholder', body, !!options.exact);
case 'label':
return this.toCallWithExact('GetByLabel', body, !!options.exact);
case 'title':
return this.toCallWithExact('GetByTitle', body, !!options.exact);
default:
throw new Error('Unknown selector kind ' + kind);
}
}
chainLocators(locators: string[]): string {
return locators.join('.');
}
private regexToString(body: RegExp): string {
const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : '';
return `new Regex(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`;
}
private toCallWithExact(method: string, body: string | RegExp, exact: boolean) {
if (isRegExp(body))
return `${method}(${this.regexToString(body)})`;
if (exact)
return `${method}(${this.quote(body)}, new() { Exact = true })`;
return `${method}(${this.quote(body)})`;
}
private toHasText(body: string | RegExp) {
if (isRegExp(body))
return `HasTextRegex = ${this.regexToString(body)}`;
return `HasText = ${this.quote(body)}`;
}
private toTestIdValue(value: string | RegExp) {
if (isRegExp(value))
return this.regexToString(value);
return this.quote(value);
}
private toHasNotText(body: string | RegExp) {
if (isRegExp(body))
return `HasNotTextRegex = ${this.regexToString(body)}`;
return `HasNotText = ${this.quote(body)}`;
}
private quote(text: string) {
return escapeWithQuotes(text, '\"');
}
}
export class JsonlLocatorFactory implements LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
return JSON.stringify({
kind,
body,
options,
});
}
chainLocators(locators: string[]): string {
const objects = locators.map(l => JSON.parse(l));
for (let i = 0; i < objects.length - 1; ++i)
objects[i].next = objects[i + 1];
return JSON.stringify(objects[0]);
}
}
const generators: Record<Language, new (preferredQuote?: Quote) => LocatorFactory> = {
javascript: JavaScriptLocatorFactory,
python: PythonLocatorFactory,
java: JavaLocatorFactory,
csharp: CSharpLocatorFactory,
jsonl: JsonlLocatorFactory,
};
function isRegExp(obj: any): obj is RegExp {
return obj instanceof RegExp;
}
@@ -0,0 +1,248 @@
/**
* 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 { asLocators } from './locatorGenerators';
import { parseSelector } from './selectorParser';
import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils';
import type { Language, Quote } from './locatorGenerators';
type TemplateParams = { quote: string, text: string }[];
function parseLocator(locator: string, testIdAttributeName: string): { selector: string, preferredQuote: Quote | undefined } {
locator = locator
.replace(/AriaRole\s*\.\s*([\w]+)/g, (_, group) => group.toLowerCase())
.replace(/(get_by_role|getByRole)\s*\(\s*(?:["'`])([^'"`]+)['"`]/g, (_, group1, group2) => `${group1}(${group2.toLowerCase()}`);
const params: TemplateParams = [];
let template = '';
for (let i = 0; i < locator.length; ++i) {
const quote = locator[i];
if (quote !== '"' && quote !== '\'' && quote !== '`' && quote !== '/') {
template += quote;
continue;
}
const isRegexEscaping = locator[i - 1] === 'r' || locator[i] === '/';
++i;
let text = '';
while (i < locator.length) {
if (locator[i] === '\\') {
if (isRegexEscaping) {
if (locator[i + 1] !== quote)
text += locator[i];
++i;
text += locator[i];
} else {
++i;
if (locator[i] === 'n')
text += '\n';
else if (locator[i] === 'r')
text += '\r';
else if (locator[i] === 't')
text += '\t';
else
text += locator[i];
}
++i;
continue;
}
if (locator[i] !== quote) {
text += locator[i++];
continue;
}
break;
}
params.push({ quote, text });
template += (quote === '/' ? 'r' : '') + '$' + params.length;
}
// Equalize languages.
template = template.toLowerCase()
.replace(/get_by_alt_text/g, 'getbyalttext')
.replace(/get_by_test_id/g, 'getbytestid')
.replace(/get_by_([\w]+)/g, 'getby$1')
.replace(/has_not_text/g, 'hasnottext')
.replace(/has_text/g, 'hastext')
.replace(/has_not/g, 'hasnot')
.replace(/frame_locator/g, 'framelocator')
.replace(/content_frame/g, 'contentframe')
.replace(/[{}\s]/g, '')
.replace(/new\(\)/g, '')
.replace(/new[\w]+\.[\w]+options\(\)/g, '')
.replace(/\.set/g, ',set')
.replace(/\.or_\(/g, 'or(') // Python has "or_" instead of "or".
.replace(/\.and_\(/g, 'and(') // Python has "and_" instead of "and".
.replace(/:/g, '=')
.replace(/,re\.ignorecase/g, 'i')
.replace(/,pattern.case_insensitive/g, 'i')
.replace(/,regexoptions.ignorecase/g, 'i')
.replace(/re.compile\(([^)]+)\)/g, '$1') // Python has regex strings as r"foo"
.replace(/pattern.compile\(([^)]+)\)/g, 'r$1')
.replace(/newregex\(([^)]+)\)/g, 'r$1')
.replace(/string=/g, '=')
.replace(/regex=/g, '=')
.replace(/,,/g, ',')
.replace(/,\)/g, ')');
const preferredQuote = params.map(p => p.quote).filter(quote => '\'"`'.includes(quote))[0] as Quote | undefined;
return { selector: transform(template, params, testIdAttributeName), preferredQuote };
}
function countParams(template: string) {
return [...template.matchAll(/\$\d+/g)].length;
}
function shiftParams(template: string, sub: number) {
return template.replace(/\$(\d+)/g, (_, ordinal) => `$${ordinal - sub}`);
}
function transform(template: string, params: TemplateParams, testIdAttributeName: string): string {
// Recursively handle filter(has=, hasnot=, sethas(), sethasnot()).
// TODO: handle and(locator), or(locator), locator(locator), locator(has=, hasnot=, sethas(), sethasnot()).
while (true) {
const hasMatch = template.match(/filter\(,?(has=|hasnot=|sethas\(|sethasnot\()/);
if (!hasMatch)
break;
// Extract inner locator based on balanced parens.
const start = hasMatch.index! + hasMatch[0].length;
let balance = 0;
let end = start;
for (; end < template.length; end++) {
if (template[end] === '(')
balance++;
else if (template[end] === ')')
balance--;
if (balance < 0)
break;
}
// Replace Java sethas(...) and sethasnot(...) with has=... and hasnot=...
let prefix = template.substring(0, start);
let extraSymbol = 0;
if (['sethas(', 'sethasnot('].includes(hasMatch[1])) {
// Eat extra ) symbol at the end of sethas(...)
extraSymbol = 1;
prefix = prefix.replace(/sethas\($/, 'has=').replace(/sethasnot\($/, 'hasnot=');
}
const paramsCountBeforeHas = countParams(template.substring(0, start));
const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas);
const paramsCountInHas = countParams(hasTemplate);
const hasParams = params.slice(paramsCountBeforeHas, paramsCountBeforeHas + paramsCountInHas);
const hasSelector = JSON.stringify(transform(hasTemplate, hasParams, testIdAttributeName));
// Replace filter(has=...) with filter(has2=$5). Use has2 to avoid matching the same filter again.
// Replace filter(hasnot=...) with filter(hasnot2=$5). Use hasnot2 to avoid matching the same filter again.
template = prefix.replace(/=$/, '2=') + `$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end + extraSymbol), paramsCountInHas - 1);
// Replace inner params with $5 value.
const paramsBeforeHas = params.slice(0, paramsCountBeforeHas);
const paramsAfterHas = params.slice(paramsCountBeforeHas + paramsCountInHas);
params = paramsBeforeHas.concat([{ quote: '"', text: hasSelector }]).concat(paramsAfterHas);
}
// Transform to selector engines.
template = template
.replace(/\,set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase())
.replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame')
.replace(/contentframe(\(\))?/g, 'internal:control=enter-frame')
.replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2')
.replace(/locator\(([^)]+),hasnottext=([^),]+)\)/g, 'locator($1).internal:has-not-text=$2')
.replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2')
.replace(/locator\(([^)]+)\)/g, '$1')
.replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1')
.replace(/getbytext\(([^)]+)\)/g, 'internal:text=$1')
.replace(/getbylabel\(([^)]+)\)/g, 'internal:label=$1')
.replace(/getbytestid\(([^)]+)\)/g, `internal:testid=[${testIdAttributeName}=$1]`)
.replace(/getby(placeholder|alt|title)(?:text)?\(([^)]+)\)/g, 'internal:attr=[$1=$2]')
.replace(/first(\(\))?/g, 'nth=0')
.replace(/last(\(\))?/g, 'nth=-1')
.replace(/nth\(([^)]+)\)/g, 'nth=$1')
.replace(/filter\(,?visible=true\)/g, 'visible=true')
.replace(/filter\(,?visible=false\)/g, 'visible=false')
.replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1')
.replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1')
.replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1')
.replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1')
.replace(/,exact=false/g, '')
.replace(/,exact=true/g, 's')
.replace(/,includehidden=/g, ',include-hidden=')
.replace(/\,/g, '][');
const parts = template.split('.');
// Turn "internal:control=enter-frame >> nth=0" into "nth=0 >> internal:control=enter-frame"
// because these are swapped in locators vs selectors.
for (let index = 0; index < parts.length - 1; index++) {
if (parts[index] === 'internal:control=enter-frame' && parts[index + 1].startsWith('nth=')) {
// Swap nth and enter-frame.
const [nth] = parts.splice(index, 1);
parts.splice(index + 1, 0, nth);
}
}
// Substitute params.
return parts.map(t => {
if (!t.startsWith('internal:') || t === 'internal:control')
return t.replace(/\$(\d+)/g, (_, ordinal) => { const param = params[+ordinal - 1]; return param.text; });
t = t.includes('[') ? t.replace(/\]/, '') + ']' : t;
t = t
.replace(/(?:r)\$(\d+)(i)?/g, (_, ordinal, suffix) => {
const param = params[+ordinal - 1];
if (t.startsWith('internal:attr') || t.startsWith('internal:testid') || t.startsWith('internal:role'))
return escapeForAttributeSelector(new RegExp(param.text), false) + (suffix || '');
return escapeForTextSelector(new RegExp(param.text, suffix), false);
})
.replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => {
const param = params[+ordinal - 1];
if (t.startsWith('internal:has=') || t.startsWith('internal:has-not='))
return param.text;
if (t.startsWith('internal:testid'))
return escapeForAttributeSelector(param.text, true);
if (t.startsWith('internal:attr') || t.startsWith('internal:role'))
return escapeForAttributeSelector(param.text, suffix === 's');
return escapeForTextSelector(param.text, suffix === 's');
});
return t;
}).join(' >> ');
}
export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string {
try {
return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName);
} catch (e) {
return '';
}
}
export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string {
try {
parseSelector(locator);
return locator;
} catch (e) {
}
const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName);
const locators = asLocators(language, selector, undefined, undefined, preferredQuote);
const digest = digestForComparison(language, locator);
if (locators.some(candidate => digestForComparison(language, candidate) === digest))
return selector;
return '';
}
function digestForComparison(language: Language, locator: string) {
locator = locator.replace(/\s/g, '');
if (language === 'javascript')
locator = locator.replace(/\\?["`]/g, '\'').replace(/,{}/g, '');
return locator;
}
@@ -0,0 +1,78 @@
/**
* 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 { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils';
export type ByRoleOptions = {
checked?: boolean;
disabled?: boolean;
exact?: boolean;
expanded?: boolean;
includeHidden?: boolean;
level?: number;
name?: string | RegExp;
pressed?: boolean;
selected?: boolean;
};
function getByAttributeTextSelector(attrName: string, text: string | RegExp, options?: { exact?: boolean }): string {
return `internal:attr=[${attrName}=${escapeForAttributeSelector(text, options?.exact || false)}]`;
}
export function getByTestIdSelector(testIdAttributeName: string, testId: string | RegExp): string {
return `internal:testid=[${testIdAttributeName}=${escapeForAttributeSelector(testId, true)}]`;
}
export function getByLabelSelector(text: string | RegExp, options?: { exact?: boolean }): string {
return 'internal:label=' + escapeForTextSelector(text, !!options?.exact);
}
export function getByAltTextSelector(text: string | RegExp, options?: { exact?: boolean }): string {
return getByAttributeTextSelector('alt', text, options);
}
export function getByTitleSelector(text: string | RegExp, options?: { exact?: boolean }): string {
return getByAttributeTextSelector('title', text, options);
}
export function getByPlaceholderSelector(text: string | RegExp, options?: { exact?: boolean }): string {
return getByAttributeTextSelector('placeholder', text, options);
}
export function getByTextSelector(text: string | RegExp, options?: { exact?: boolean }): string {
return 'internal:text=' + escapeForTextSelector(text, !!options?.exact);
}
export function getByRoleSelector(role: string, options: ByRoleOptions = {}): string {
const props: string[][] = [];
if (options.checked !== undefined)
props.push(['checked', String(options.checked)]);
if (options.disabled !== undefined)
props.push(['disabled', String(options.disabled)]);
if (options.selected !== undefined)
props.push(['selected', String(options.selected)]);
if (options.expanded !== undefined)
props.push(['expanded', String(options.expanded)]);
if (options.includeHidden !== undefined)
props.push(['include-hidden', String(options.includeHidden)]);
if (options.level !== undefined)
props.push(['level', String(options.level)]);
if (options.name !== undefined)
props.push(['name', escapeForAttributeSelector(options.name, !!options.exact)]);
if (options.pressed !== undefined)
props.push(['pressed', String(options.pressed)]);
return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join('')}`;
}
@@ -0,0 +1,49 @@
/**
* 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.
*/
export class LRUCache<K, V> {
private _maxSize: number;
private _map: Map<K, { value: V, size: number }>;
private _size: number;
constructor(maxSize: number) {
this._maxSize = maxSize;
this._map = new Map();
this._size = 0;
}
getOrCompute(key: K, compute: () => { value: V, size: number }): V {
if (this._map.has(key)) {
const result = this._map.get(key)!;
// reinserting makes this the least recently used entry
this._map.delete(key);
this._map.set(key, result);
return result.value;
}
const result = compute();
while (this._map.size && this._size + result.size > this._maxSize) {
const [firstKey, firstValue] = this._map.entries().next().value!;
this._size -= firstValue.size;
this._map.delete(firstKey);
}
this._map.set(key, result);
this._size += result.size;
return result.value;
}
}
@@ -0,0 +1,122 @@
/**
* 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 { captureRawStack } from './stackTrace';
export class ManualPromise<T = void> extends Promise<T> {
private _resolve!: (t: T) => void;
private _reject!: (e: Error) => void;
private _isDone: boolean;
constructor() {
let resolve: (t: T) => void;
let reject: (e: Error) => void;
super((f, r) => {
resolve = f;
reject = r;
});
this._isDone = false;
this._resolve = resolve!;
this._reject = reject!;
}
isDone() {
return this._isDone;
}
resolve(t: T) {
this._isDone = true;
this._resolve(t);
}
reject(e: Error) {
this._isDone = true;
this._reject(e);
}
static override get [Symbol.species]() {
return Promise;
}
override get [Symbol.toStringTag]() {
return 'ManualPromise';
}
}
export class LongStandingScope {
private _terminateError: Error | undefined;
private _closeError: Error | undefined;
private _terminatePromises = new Map<ManualPromise<Error>, string[]>();
private _isClosed = false;
reject(error: Error) {
this._isClosed = true;
this._terminateError = error;
for (const p of this._terminatePromises.keys())
p.resolve(error);
}
close(error: Error) {
this._isClosed = true;
this._closeError = error;
for (const [p, frames] of this._terminatePromises)
p.resolve(cloneError(error, frames));
}
isClosed() {
return this._isClosed;
}
static async raceMultiple<T>(scopes: LongStandingScope[], promise: Promise<T>): Promise<T> {
return Promise.race(scopes.map(s => s.race(promise)));
}
async race<T>(promise: Promise<T> | Promise<T>[]): Promise<T> {
return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise<T>;
}
async safeRace<T>(promise: Promise<T>, defaultValue: T): Promise<T>;
async safeRace<T>(promise: Promise<T>): Promise<T | undefined>;
async safeRace<T>(promise: Promise<T>, defaultValue?: T): Promise<T | undefined> {
return this._race([promise], true, defaultValue);
}
private async _race(promises: Promise<any>[], safe: boolean, defaultValue?: any): Promise<any> {
const terminatePromise = new ManualPromise<Error>();
const frames = captureRawStack();
if (this._terminateError)
terminatePromise.resolve(this._terminateError);
if (this._closeError)
terminatePromise.resolve(cloneError(this._closeError, frames));
this._terminatePromises.set(terminatePromise, frames);
try {
return await Promise.race([
terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)),
...promises
]);
} finally {
this._terminatePromises.delete(terminatePromise);
}
}
}
function cloneError(error: Error, frames: string[]) {
const clone = new Error();
clone.name = error.name;
clone.message = error.message;
clone.stack = [error.name + ':' + error.message, ...frames].join('\n');
return clone;
}
@@ -0,0 +1,450 @@
/**
* 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.
*/
export function isJsonMimeType(mimeType: string) {
return !!mimeType.match(/^(application\/json|application\/.*?\+json|text\/(x-)?json)(;\s*charset=.*)?$/);
}
export function isXmlMimeType(mimeType: string) {
return !!mimeType.match(/^(application\/xml|application\/.*?\+xml|text\/xml)(;\s*charset=.*)?$/);
}
export function isTextualMimeType(mimeType: string) {
return !!mimeType.match(/^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$/);
}
export function getMimeTypeForPath(path: string): string | null {
const dotIndex = path.lastIndexOf('.');
if (dotIndex === -1)
return null;
const extension = path.substring(dotIndex + 1);
return types.get(extension) || null;
}
const types: Map<string, string> = new Map([
['ez', 'application/andrew-inset'],
['aw', 'application/applixware'],
['atom', 'application/atom+xml'],
['atomcat', 'application/atomcat+xml'],
['atomdeleted', 'application/atomdeleted+xml'],
['atomsvc', 'application/atomsvc+xml'],
['dwd', 'application/atsc-dwd+xml'],
['held', 'application/atsc-held+xml'],
['rsat', 'application/atsc-rsat+xml'],
['bdoc', 'application/bdoc'],
['xcs', 'application/calendar+xml'],
['ccxml', 'application/ccxml+xml'],
['cdfx', 'application/cdfx+xml'],
['cdmia', 'application/cdmi-capability'],
['cdmic', 'application/cdmi-container'],
['cdmid', 'application/cdmi-domain'],
['cdmio', 'application/cdmi-object'],
['cdmiq', 'application/cdmi-queue'],
['cu', 'application/cu-seeme'],
['mpd', 'application/dash+xml'],
['davmount', 'application/davmount+xml'],
['dbk', 'application/docbook+xml'],
['dssc', 'application/dssc+der'],
['xdssc', 'application/dssc+xml'],
['ecma', 'application/ecmascript'],
['es', 'application/ecmascript'],
['emma', 'application/emma+xml'],
['emotionml', 'application/emotionml+xml'],
['epub', 'application/epub+zip'],
['exi', 'application/exi'],
['exp', 'application/express'],
['fdt', 'application/fdt+xml'],
['pfr', 'application/font-tdpfr'],
['geojson', 'application/geo+json'],
['gml', 'application/gml+xml'],
['gpx', 'application/gpx+xml'],
['gxf', 'application/gxf'],
['gz', 'application/gzip'],
['hjson', 'application/hjson'],
['stk', 'application/hyperstudio'],
['ink', 'application/inkml+xml'],
['inkml', 'application/inkml+xml'],
['ipfix', 'application/ipfix'],
['its', 'application/its+xml'],
['ear', 'application/java-archive'],
['jar', 'application/java-archive'],
['war', 'application/java-archive'],
['ser', 'application/java-serialized-object'],
['class', 'application/java-vm'],
['js', 'application/javascript'],
['mjs', 'application/javascript'],
['json', 'application/json'],
['map', 'application/json'],
['json5', 'application/json5'],
['jsonml', 'application/jsonml+json'],
['jsonld', 'application/ld+json'],
['lgr', 'application/lgr+xml'],
['lostxml', 'application/lost+xml'],
['hqx', 'application/mac-binhex40'],
['cpt', 'application/mac-compactpro'],
['mads', 'application/mads+xml'],
['webmanifest', 'application/manifest+json'],
['mrc', 'application/marc'],
['mrcx', 'application/marcxml+xml'],
['ma', 'application/mathematica'],
['mb', 'application/mathematica'],
['nb', 'application/mathematica'],
['mathml', 'application/mathml+xml'],
['mbox', 'application/mbox'],
['mscml', 'application/mediaservercontrol+xml'],
['metalink', 'application/metalink+xml'],
['meta4', 'application/metalink4+xml'],
['mets', 'application/mets+xml'],
['maei', 'application/mmt-aei+xml'],
['musd', 'application/mmt-usd+xml'],
['mods', 'application/mods+xml'],
['m21', 'application/mp21'],
['mp21', 'application/mp21'],
['m4p', 'application/mp4'],
['mp4s', 'application/mp4'],
['doc', 'application/msword'],
['dot', 'application/msword'],
['mxf', 'application/mxf'],
['nq', 'application/n-quads'],
['nt', 'application/n-triples'],
['cjs', 'application/node'],
['bin', 'application/octet-stream'],
['bpk', 'application/octet-stream'],
['buffer', 'application/octet-stream'],
['deb', 'application/octet-stream'],
['deploy', 'application/octet-stream'],
['dist', 'application/octet-stream'],
['distz', 'application/octet-stream'],
['dll', 'application/octet-stream'],
['dmg', 'application/octet-stream'],
['dms', 'application/octet-stream'],
['dump', 'application/octet-stream'],
['elc', 'application/octet-stream'],
['exe', 'application/octet-stream'],
['img', 'application/octet-stream'],
['iso', 'application/octet-stream'],
['lrf', 'application/octet-stream'],
['mar', 'application/octet-stream'],
['msi', 'application/octet-stream'],
['msm', 'application/octet-stream'],
['msp', 'application/octet-stream'],
['pkg', 'application/octet-stream'],
['so', 'application/octet-stream'],
['oda', 'application/oda'],
['opf', 'application/oebps-package+xml'],
['ogx', 'application/ogg'],
['omdoc', 'application/omdoc+xml'],
['onepkg', 'application/onenote'],
['onetmp', 'application/onenote'],
['onetoc', 'application/onenote'],
['onetoc2', 'application/onenote'],
['oxps', 'application/oxps'],
['relo', 'application/p2p-overlay+xml'],
['xer', 'application/patch-ops-error+xml'],
['pdf', 'application/pdf'],
['pgp', 'application/pgp-encrypted'],
['asc', 'application/pgp-signature'],
['sig', 'application/pgp-signature'],
['prf', 'application/pics-rules'],
['p10', 'application/pkcs10'],
['p7c', 'application/pkcs7-mime'],
['p7m', 'application/pkcs7-mime'],
['p7s', 'application/pkcs7-signature'],
['p8', 'application/pkcs8'],
['ac', 'application/pkix-attr-cert'],
['cer', 'application/pkix-cert'],
['crl', 'application/pkix-crl'],
['pkipath', 'application/pkix-pkipath'],
['pki', 'application/pkixcmp'],
['pls', 'application/pls+xml'],
['ai', 'application/postscript'],
['eps', 'application/postscript'],
['ps', 'application/postscript'],
['provx', 'application/provenance+xml'],
['pskcxml', 'application/pskc+xml'],
['raml', 'application/raml+yaml'],
['owl', 'application/rdf+xml'],
['rdf', 'application/rdf+xml'],
['rif', 'application/reginfo+xml'],
['rnc', 'application/relax-ng-compact-syntax'],
['rl', 'application/resource-lists+xml'],
['rld', 'application/resource-lists-diff+xml'],
['rs', 'application/rls-services+xml'],
['rapd', 'application/route-apd+xml'],
['sls', 'application/route-s-tsid+xml'],
['rusd', 'application/route-usd+xml'],
['gbr', 'application/rpki-ghostbusters'],
['mft', 'application/rpki-manifest'],
['roa', 'application/rpki-roa'],
['rsd', 'application/rsd+xml'],
['rss', 'application/rss+xml'],
['rtf', 'application/rtf'],
['sbml', 'application/sbml+xml'],
['scq', 'application/scvp-cv-request'],
['scs', 'application/scvp-cv-response'],
['spq', 'application/scvp-vp-request'],
['spp', 'application/scvp-vp-response'],
['sdp', 'application/sdp'],
['senmlx', 'application/senml+xml'],
['sensmlx', 'application/sensml+xml'],
['setpay', 'application/set-payment-initiation'],
['setreg', 'application/set-registration-initiation'],
['shf', 'application/shf+xml'],
['sieve', 'application/sieve'],
['siv', 'application/sieve'],
['smi', 'application/smil+xml'],
['smil', 'application/smil+xml'],
['rq', 'application/sparql-query'],
['srx', 'application/sparql-results+xml'],
['gram', 'application/srgs'],
['grxml', 'application/srgs+xml'],
['sru', 'application/sru+xml'],
['ssdl', 'application/ssdl+xml'],
['ssml', 'application/ssml+xml'],
['swidtag', 'application/swid+xml'],
['tei', 'application/tei+xml'],
['teicorpus', 'application/tei+xml'],
['tfi', 'application/thraud+xml'],
['tsd', 'application/timestamped-data'],
['toml', 'application/toml'],
['trig', 'application/trig'],
['ttml', 'application/ttml+xml'],
['ubj', 'application/ubjson'],
['rsheet', 'application/urc-ressheet+xml'],
['td', 'application/urc-targetdesc+xml'],
['vxml', 'application/voicexml+xml'],
['wasm', 'application/wasm'],
['wgt', 'application/widget'],
['hlp', 'application/winhlp'],
['wsdl', 'application/wsdl+xml'],
['wspolicy', 'application/wspolicy+xml'],
['xaml', 'application/xaml+xml'],
['xav', 'application/xcap-att+xml'],
['xca', 'application/xcap-caps+xml'],
['xdf', 'application/xcap-diff+xml'],
['xel', 'application/xcap-el+xml'],
['xns', 'application/xcap-ns+xml'],
['xenc', 'application/xenc+xml'],
['xht', 'application/xhtml+xml'],
['xhtml', 'application/xhtml+xml'],
['xlf', 'application/xliff+xml'],
['rng', 'application/xml'],
['xml', 'application/xml'],
['xsd', 'application/xml'],
['xsl', 'application/xml'],
['dtd', 'application/xml-dtd'],
['xop', 'application/xop+xml'],
['xpl', 'application/xproc+xml'],
['*xsl', 'application/xslt+xml'],
['xslt', 'application/xslt+xml'],
['xspf', 'application/xspf+xml'],
['mxml', 'application/xv+xml'],
['xhvml', 'application/xv+xml'],
['xvm', 'application/xv+xml'],
['xvml', 'application/xv+xml'],
['yang', 'application/yang'],
['yin', 'application/yin+xml'],
['zip', 'application/zip'],
['*3gpp', 'audio/3gpp'],
['adp', 'audio/adpcm'],
['amr', 'audio/amr'],
['au', 'audio/basic'],
['snd', 'audio/basic'],
['kar', 'audio/midi'],
['mid', 'audio/midi'],
['midi', 'audio/midi'],
['rmi', 'audio/midi'],
['mxmf', 'audio/mobile-xmf'],
['*mp3', 'audio/mp3'],
['m4a', 'audio/mp4'],
['mp4a', 'audio/mp4'],
['m2a', 'audio/mpeg'],
['m3a', 'audio/mpeg'],
['mp2', 'audio/mpeg'],
['mp2a', 'audio/mpeg'],
['mp3', 'audio/mpeg'],
['mpga', 'audio/mpeg'],
['oga', 'audio/ogg'],
['ogg', 'audio/ogg'],
['opus', 'audio/ogg'],
['spx', 'audio/ogg'],
['s3m', 'audio/s3m'],
['sil', 'audio/silk'],
['wav', 'audio/wav'],
['*wav', 'audio/wave'],
['weba', 'audio/webm'],
['xm', 'audio/xm'],
['ttc', 'font/collection'],
['otf', 'font/otf'],
['ttf', 'font/ttf'],
['woff', 'font/woff'],
['woff2', 'font/woff2'],
['exr', 'image/aces'],
['apng', 'image/apng'],
['avif', 'image/avif'],
['bmp', 'image/bmp'],
['cgm', 'image/cgm'],
['drle', 'image/dicom-rle'],
['emf', 'image/emf'],
['fits', 'image/fits'],
['g3', 'image/g3fax'],
['gif', 'image/gif'],
['heic', 'image/heic'],
['heics', 'image/heic-sequence'],
['heif', 'image/heif'],
['heifs', 'image/heif-sequence'],
['hej2', 'image/hej2k'],
['hsj2', 'image/hsj2'],
['ief', 'image/ief'],
['jls', 'image/jls'],
['jp2', 'image/jp2'],
['jpg2', 'image/jp2'],
['jpe', 'image/jpeg'],
['jpeg', 'image/jpeg'],
['jpg', 'image/jpeg'],
['jph', 'image/jph'],
['jhc', 'image/jphc'],
['jpm', 'image/jpm'],
['jpf', 'image/jpx'],
['jpx', 'image/jpx'],
['jxr', 'image/jxr'],
['jxra', 'image/jxra'],
['jxrs', 'image/jxrs'],
['jxs', 'image/jxs'],
['jxsc', 'image/jxsc'],
['jxsi', 'image/jxsi'],
['jxss', 'image/jxss'],
['ktx', 'image/ktx'],
['ktx2', 'image/ktx2'],
['png', 'image/png'],
['sgi', 'image/sgi'],
['svg', 'image/svg+xml'],
['svgz', 'image/svg+xml'],
['t38', 'image/t38'],
['tif', 'image/tiff'],
['tiff', 'image/tiff'],
['tfx', 'image/tiff-fx'],
['webp', 'image/webp'],
['wmf', 'image/wmf'],
['disposition-notification', 'message/disposition-notification'],
['u8msg', 'message/global'],
['u8dsn', 'message/global-delivery-status'],
['u8mdn', 'message/global-disposition-notification'],
['u8hdr', 'message/global-headers'],
['eml', 'message/rfc822'],
['mime', 'message/rfc822'],
['3mf', 'model/3mf'],
['gltf', 'model/gltf+json'],
['glb', 'model/gltf-binary'],
['iges', 'model/iges'],
['igs', 'model/iges'],
['mesh', 'model/mesh'],
['msh', 'model/mesh'],
['silo', 'model/mesh'],
['mtl', 'model/mtl'],
['obj', 'model/obj'],
['stpx', 'model/step+xml'],
['stpz', 'model/step+zip'],
['stpxz', 'model/step-xml+zip'],
['stl', 'model/stl'],
['vrml', 'model/vrml'],
['wrl', 'model/vrml'],
['*x3db', 'model/x3d+binary'],
['x3dbz', 'model/x3d+binary'],
['x3db', 'model/x3d+fastinfoset'],
['*x3dv', 'model/x3d+vrml'],
['x3dvz', 'model/x3d+vrml'],
['x3d', 'model/x3d+xml'],
['x3dz', 'model/x3d+xml'],
['x3dv', 'model/x3d-vrml'],
['appcache', 'text/cache-manifest'],
['manifest', 'text/cache-manifest'],
['ics', 'text/calendar'],
['ifb', 'text/calendar'],
['coffee', 'text/coffeescript'],
['litcoffee', 'text/coffeescript'],
['css', 'text/css'],
['csv', 'text/csv'],
['htm', 'text/html'],
['html', 'text/html'],
['shtml', 'text/html'],
['jade', 'text/jade'],
['jsx', 'text/jsx'],
['less', 'text/less'],
['markdown', 'text/markdown'],
['md', 'text/markdown'],
['mml', 'text/mathml'],
['mdx', 'text/mdx'],
['n3', 'text/n3'],
['conf', 'text/plain'],
['def', 'text/plain'],
['in', 'text/plain'],
['ini', 'text/plain'],
['list', 'text/plain'],
['log', 'text/plain'],
['text', 'text/plain'],
['txt', 'text/plain'],
['rtx', 'text/richtext'],
['*rtf', 'text/rtf'],
['sgm', 'text/sgml'],
['sgml', 'text/sgml'],
['shex', 'text/shex'],
['slim', 'text/slim'],
['slm', 'text/slim'],
['spdx', 'text/spdx'],
['styl', 'text/stylus'],
['stylus', 'text/stylus'],
['tsv', 'text/tab-separated-values'],
['man', 'text/troff'],
['me', 'text/troff'],
['ms', 'text/troff'],
['roff', 'text/troff'],
['t', 'text/troff'],
['tr', 'text/troff'],
['ttl', 'text/turtle'],
['uri', 'text/uri-list'],
['uris', 'text/uri-list'],
['urls', 'text/uri-list'],
['vcard', 'text/vcard'],
['vtt', 'text/vtt'],
['*xml', 'text/xml'],
['yaml', 'text/yaml'],
['yml', 'text/yaml'],
['3gp', 'video/3gpp'],
['3gpp', 'video/3gpp'],
['3g2', 'video/3gpp2'],
['h261', 'video/h261'],
['h263', 'video/h263'],
['h264', 'video/h264'],
['m4s', 'video/iso.segment'],
['jpgv', 'video/jpeg'],
['jpm', 'video/jpm'],
['jpgm', 'video/jpm'],
['mj2', 'video/mj2'],
['mjp2', 'video/mj2'],
['ts', 'application/typescript'],
['mp4', 'video/mp4'],
['mp4v', 'video/mp4'],
['mpg4', 'video/mp4'],
['m1v', 'video/mpeg'],
['m2v', 'video/mpeg'],
['mpe', 'video/mpeg'],
['mpeg', 'video/mpeg'],
['mpg', 'video/mpeg'],
['ogv', 'video/ogg'],
['mov', 'video/quicktime'],
['qt', 'video/quicktime'],
['webm', 'video/webm']
]);
@@ -0,0 +1,82 @@
/**
* 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.
*/
export class MultiMap<K, V> {
private _map: Map<K, V[]>;
constructor() {
this._map = new Map<K, V[]>();
}
set(key: K, value: V) {
let values = this._map.get(key);
if (!values) {
values = [];
this._map.set(key, values);
}
values.push(value);
}
get(key: K): V[] {
return this._map.get(key) || [];
}
has(key: K): boolean {
return this._map.has(key);
}
delete(key: K, value: V) {
const values = this._map.get(key);
if (!values)
return;
if (values.includes(value))
this._map.set(key, values.filter(v => value !== v));
}
deleteAll(key: K) {
this._map.delete(key);
}
hasValue(key: K, value: V): boolean {
const values = this._map.get(key);
if (!values)
return false;
return values.includes(value);
}
get size(): number {
return this._map.size;
}
[Symbol.iterator](): Iterator<[K, V[]]> {
return this._map[Symbol.iterator]();
}
keys(): IterableIterator<K> {
return this._map.keys();
}
values(): Iterable<V> {
const result: V[] = [];
for (const key of this.keys())
result.push(...this.get(key));
return result;
}
clear() {
this._map.clear();
}
}
@@ -0,0 +1,123 @@
/**
* 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 { webColors } from './colors';
import type * as fs from 'fs';
import type * as path from 'path';
import type { Readable, Writable } from 'stream';
import type { Colors } from '@isomorphic/colors';
import type * as channels from '@protocol/channels';
export type Zone = {
push(data: unknown): Zone;
pop(): Zone;
run<R>(func: () => R): R;
data<T>(): T | undefined;
};
const noopZone: Zone = {
push: () => noopZone,
pop: () => noopZone,
run: func => func(),
data: () => undefined,
};
export type Platform = {
name: 'node' | 'web' | 'empty';
boxedStackPrefixes: () => string[];
calculateSha1: (text: string) => Promise<string>;
colors: Colors;
coreDir?: string;
createGuid: () => string;
defaultMaxListeners: () => number;
env: Record<string, string | undefined>;
fs: () => typeof fs;
inspectCustom: symbol | undefined;
isDebugMode: () => boolean;
isJSDebuggerAttached: () => boolean;
isLogEnabled: (name: 'api' | 'channel') => boolean;
isUnderTest: () => boolean,
log: (name: 'api' | 'channel', message: string | Error | object) => void;
path: () => typeof path;
pathSeparator: string;
showInternalStackFrames: () => boolean,
streamFile: (path: string, writable: Writable) => Promise<void>,
streamReadable: (channel: channels.StreamChannel) => Readable,
streamWritable: (channel: channels.WritableStreamChannel) => Writable,
zones: { empty: Zone, current: () => Zone; };
};
export const emptyPlatform: Platform = {
name: 'empty',
boxedStackPrefixes: () => [],
calculateSha1: async () => {
throw new Error('Not implemented');
},
colors: webColors,
createGuid: () => {
throw new Error('Not implemented');
},
defaultMaxListeners: () => 10,
env: {},
fs: () => {
throw new Error('Not implemented');
},
inspectCustom: undefined,
isDebugMode: () => false,
isJSDebuggerAttached: () => false,
isLogEnabled(name: 'api' | 'channel') {
return false;
},
isUnderTest: () => false,
log(name: 'api' | 'channel', message: string | Error | object) { },
path: () => {
throw new Error('Function not implemented.');
},
pathSeparator: '/',
showInternalStackFrames: () => false,
streamFile(path: string, writable: Writable): Promise<void> {
throw new Error('Streams are not available');
},
streamReadable: (channel: channels.StreamChannel) => {
throw new Error('Streams are not available');
},
streamWritable: (channel: channels.WritableStreamChannel) => {
throw new Error('Streams are not available');
},
zones: { empty: noopZone, current: () => noopZone },
};
@@ -0,0 +1,76 @@
/**
* 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 { getMetainfo } from './protocolMetainfo';
export function formatProtocolParam(params: Record<string, string> | undefined, alternatives: string): string | undefined {
return _formatProtocolParam(params, alternatives)?.replaceAll('\n', '\\n');
}
function _formatProtocolParam(params: Record<string, string> | undefined, alternatives: string): string | undefined {
if (!params)
return undefined;
for (const name of alternatives.split('|')) {
if (name === 'url') {
try {
const urlObject = new URL(params[name]);
if (urlObject.protocol === 'data:')
return urlObject.protocol;
if (['about:', 'chrome:', 'edge:'].includes(urlObject.protocol))
return params[name];
return urlObject.pathname + urlObject.search;
} catch (error) {
if (params[name] !== undefined)
return params[name];
}
}
if (name === 'timeNumber' && params[name] !== undefined) {
// eslint-disable-next-line no-restricted-globals
return new Date(params[name]).toString();
}
const value = deepParam(params, name);
if (value !== undefined)
return value;
}
}
function deepParam(params: Record<string, any>, name: string): string | undefined {
const tokens = name.split('.');
let current = params;
for (const token of tokens) {
if (typeof current !== 'object' || current === null)
return undefined;
current = current[token];
}
if (current === undefined)
return undefined;
return String(current);
}
export function renderTitleForCall(metadata: { title?: string, type: string, method: string, params: Record<string, string> | undefined }) {
const titleFormat = metadata.title ?? getMetainfo(metadata)?.title ?? metadata.method;
return titleFormat.replace(/\{([^}]+)\}/g, (fullMatch, p1) => {
return formatProtocolParam(metadata.params, p1) ?? fullMatch;
});
}
export type ActionGroup = 'configuration' | 'route' | 'getter';
export function getActionGroup(metadata: { type: string, method: string }) {
return getMetainfo(metadata)?.group as undefined | ActionGroup;
}
@@ -0,0 +1,345 @@
/**
* 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.
*/
// This file is generated by generate_channels.js, do not edit manually.
export type MethodMetainfo = { internal?: boolean, title?: string, slowMo?: boolean, snapshot?: boolean, pause?: boolean, isAutoWaiting?: boolean, input?: boolean, group?: string };
export const methodMetainfo = new Map<string, MethodMetainfo>([
['APIRequestContext.fetch', { title: '{method} "{url}"', }],
['APIRequestContext.fetchResponseBody', { title: 'Get response body', group: 'getter', }],
['APIRequestContext.fetchLog', { internal: true, }],
['APIRequestContext.storageState', { title: 'Get storage state', group: 'configuration', }],
['APIRequestContext.disposeAPIResponse', { internal: true, }],
['APIRequestContext.dispose', { internal: true, }],
['LocalUtils.zip', { internal: true, }],
['LocalUtils.harOpen', { internal: true, }],
['LocalUtils.harLookup', { internal: true, }],
['LocalUtils.harClose', { internal: true, }],
['LocalUtils.harUnzip', { internal: true, }],
['LocalUtils.connect', { internal: true, }],
['LocalUtils.tracingStarted', { internal: true, }],
['LocalUtils.addStackToTracingNoReply', { internal: true, }],
['LocalUtils.traceDiscarded', { internal: true, }],
['LocalUtils.globToRegex', { internal: true, }],
['Root.initialize', { internal: true, }],
['Playwright.newRequest', { title: 'Create request context', }],
['DebugController.initialize', { internal: true, }],
['DebugController.setReportStateChanged', { internal: true, }],
['DebugController.setRecorderMode', { internal: true, }],
['DebugController.highlight', { internal: true, }],
['DebugController.hideHighlight', { internal: true, }],
['DebugController.resume', { internal: true, }],
['DebugController.kill', { internal: true, }],
['SocksSupport.socksConnected', { internal: true, }],
['SocksSupport.socksFailed', { internal: true, }],
['SocksSupport.socksData', { internal: true, }],
['SocksSupport.socksError', { internal: true, }],
['SocksSupport.socksEnd', { internal: true, }],
['BrowserType.launch', { title: 'Launch browser', }],
['BrowserType.launchPersistentContext', { title: 'Launch persistent context', }],
['BrowserType.connectOverCDP', { title: 'Connect over CDP', }],
['BrowserType.connectOverCDPTransport', { title: 'Connect over CDP transport', }],
['BrowserType.connectToWorker', { title: 'Connect to worker', }],
['Browser.startServer', { title: 'Start server', }],
['Browser.stopServer', { title: 'Stop server', }],
['Browser.close', { title: 'Close browser', pause: true, }],
['Browser.killForTests', { internal: true, }],
['Browser.defaultUserAgentForTest', { internal: true, }],
['Browser.newContext', { title: 'Create context', }],
['Browser.newContextForReuse', { internal: true, }],
['Browser.disconnectFromReusedContext', { internal: true, }],
['Browser.newBrowserCDPSession', { title: 'Create CDP session', group: 'configuration', }],
['Browser.startTracing', { title: 'Start browser tracing', group: 'configuration', }],
['Browser.stopTracing', { title: 'Stop browser tracing', group: 'configuration', }],
['EventTarget.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['BrowserContext.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['Page.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['Worker.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['WebSocket.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['Debugger.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['ElectronApplication.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['AndroidDevice.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['BrowserContext.addCookies', { title: 'Add cookies', group: 'configuration', }],
['BrowserContext.addInitScript', { title: 'Add init script', group: 'configuration', }],
['BrowserContext.clearCookies', { title: 'Clear cookies', group: 'configuration', }],
['BrowserContext.clearPermissions', { title: 'Clear permissions', group: 'configuration', }],
['BrowserContext.close', { title: 'Close context', pause: true, }],
['BrowserContext.cookies', { title: 'Get cookies', group: 'getter', }],
['BrowserContext.exposeBinding', { title: 'Expose binding', group: 'configuration', }],
['BrowserContext.grantPermissions', { title: 'Grant permissions', group: 'configuration', }],
['BrowserContext.newPage', { title: 'Create page', }],
['BrowserContext.registerSelectorEngine', { internal: true, }],
['BrowserContext.setTestIdAttributeName', { internal: true, }],
['BrowserContext.setExtraHTTPHeaders', { title: 'Set extra HTTP headers', group: 'configuration', }],
['BrowserContext.setGeolocation', { title: 'Set geolocation', group: 'configuration', }],
['BrowserContext.setHTTPCredentials', { title: 'Set HTTP credentials', group: 'configuration', }],
['BrowserContext.setNetworkInterceptionPatterns', { title: 'Route requests', group: 'route', }],
['BrowserContext.setWebSocketInterceptionPatterns', { title: 'Route WebSockets', group: 'route', }],
['BrowserContext.setOffline', { title: 'Set offline mode', }],
['BrowserContext.storageState', { title: 'Get storage state', group: 'configuration', }],
['BrowserContext.setStorageState', { title: 'Set storage state', group: 'configuration', }],
['BrowserContext.pause', { title: 'Pause', }],
['BrowserContext.enableRecorder', { internal: true, }],
['BrowserContext.disableRecorder', { internal: true, }],
['BrowserContext.exposeConsoleApi', { internal: true, }],
['BrowserContext.newCDPSession', { title: 'Create CDP session', group: 'configuration', }],
['BrowserContext.createTempFiles', { internal: true, }],
['BrowserContext.updateSubscription', { internal: true, }],
['BrowserContext.clockFastForward', { title: 'Fast forward clock "{ticksNumber|ticksString}"', }],
['BrowserContext.clockInstall', { title: 'Install clock "{timeNumber|timeString}"', }],
['BrowserContext.clockPauseAt', { title: 'Pause clock "{timeNumber|timeString}"', }],
['BrowserContext.clockResume', { title: 'Resume clock', }],
['BrowserContext.clockRunFor', { title: 'Run clock "{ticksNumber|ticksString}"', }],
['BrowserContext.clockSetFixedTime', { title: 'Set fixed time "{timeNumber|timeString}"', }],
['BrowserContext.clockSetSystemTime', { title: 'Set system time "{timeNumber|timeString}"', }],
['Page.addInitScript', { title: 'Add init script', group: 'configuration', }],
['Page.close', { title: 'Close page', pause: true, }],
['Page.clearConsoleMessages', { title: 'Clear console messages', }],
['Page.consoleMessages', { title: 'Get console messages', group: 'getter', }],
['Page.emulateMedia', { title: 'Emulate media', snapshot: true, pause: true, }],
['Page.exposeBinding', { title: 'Expose binding', group: 'configuration', }],
['Page.goBack', { title: 'Go back', slowMo: true, snapshot: true, pause: true, }],
['Page.goForward', { title: 'Go forward', slowMo: true, snapshot: true, pause: true, }],
['Page.requestGC', { title: 'Request garbage collection', group: 'configuration', }],
['Page.registerLocatorHandler', { title: 'Register locator handler', }],
['Page.resolveLocatorHandlerNoReply', { internal: true, }],
['Page.unregisterLocatorHandler', { title: 'Unregister locator handler', }],
['Page.reload', { title: 'Reload', slowMo: true, snapshot: true, pause: true, }],
['Page.expectScreenshot', { title: 'Expect screenshot', snapshot: true, pause: true, }],
['Page.screenshot', { title: 'Screenshot', snapshot: true, pause: true, }],
['Page.setExtraHTTPHeaders', { title: 'Set extra HTTP headers', group: 'configuration', }],
['Page.setNetworkInterceptionPatterns', { title: 'Route requests', group: 'route', }],
['Page.setWebSocketInterceptionPatterns', { title: 'Route WebSockets', group: 'route', }],
['Page.setViewportSize', { title: 'Set viewport size', snapshot: true, pause: true, }],
['Page.keyboardDown', { title: 'Key down "{key}"', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.keyboardUp', { title: 'Key up "{key}"', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.keyboardInsertText', { title: 'Insert "{text}"', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.keyboardType', { title: 'Type "{text}"', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.keyboardPress', { title: 'Press "{key}"', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.mouseMove', { title: 'Mouse move', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.mouseDown', { title: 'Mouse down', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.mouseUp', { title: 'Mouse up', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.mouseClick', { title: 'Click', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.mouseWheel', { title: 'Mouse wheel', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.touchscreenTap', { title: 'Tap', slowMo: true, snapshot: true, pause: true, input: true, }],
['Page.clearPageErrors', { title: 'Clear page errors', }],
['Page.pageErrors', { title: 'Get page errors', group: 'getter', }],
['Page.pdf', { title: 'PDF', }],
['Page.requests', { title: 'Get network requests', group: 'getter', }],
['Page.startJSCoverage', { title: 'Start JS coverage', group: 'configuration', }],
['Page.stopJSCoverage', { title: 'Stop JS coverage', group: 'configuration', }],
['Page.startCSSCoverage', { title: 'Start CSS coverage', group: 'configuration', }],
['Page.stopCSSCoverage', { title: 'Stop CSS coverage', group: 'configuration', }],
['Page.bringToFront', { title: 'Bring to front', }],
['Page.pickLocator', { title: 'Pick locator', group: 'configuration', }],
['Page.cancelPickLocator', { title: 'Cancel pick locator', group: 'configuration', }],
['Page.screencastShowOverlay', { title: 'Show overlay', group: 'configuration', }],
['Page.screencastRemoveOverlay', { title: 'Remove overlay', group: 'configuration', }],
['Page.screencastChapter', { title: 'Show chapter overlay', group: 'configuration', }],
['Page.screencastSetOverlayVisible', { title: 'Set overlay visibility', group: 'configuration', }],
['Page.screencastShowActions', { title: 'Show actions', group: 'configuration', }],
['Page.screencastHideActions', { title: 'Remove actions', group: 'configuration', }],
['Page.screencastStart', { title: 'Start screencast', group: 'configuration', }],
['Page.screencastStop', { title: 'Stop screencast', group: 'configuration', }],
['Page.updateSubscription', { internal: true, }],
['Page.setDockTile', { internal: true, }],
['Frame.evalOnSelector', { title: 'Evaluate', snapshot: true, pause: true, }],
['Frame.evalOnSelectorAll', { title: 'Evaluate', snapshot: true, pause: true, }],
['Frame.addScriptTag', { title: 'Add script tag', snapshot: true, pause: true, }],
['Frame.addStyleTag', { title: 'Add style tag', snapshot: true, pause: true, }],
['Frame.ariaSnapshot', { title: 'Aria snapshot', group: 'getter', }],
['Frame.blur', { title: 'Blur', slowMo: true, snapshot: true, pause: true, }],
['Frame.check', { title: 'Check', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.click', { title: 'Click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.content', { title: 'Get content', snapshot: true, pause: true, }],
['Frame.dragAndDrop', { title: 'Drag and drop', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.dblclick', { title: 'Double click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.dispatchEvent', { title: 'Dispatch "{type}"', slowMo: true, snapshot: true, pause: true, }],
['Frame.evaluateExpression', { title: 'Evaluate', snapshot: true, pause: true, }],
['Frame.evaluateExpressionHandle', { title: 'Evaluate', snapshot: true, pause: true, }],
['Frame.fill', { title: 'Fill "{value}"', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.focus', { title: 'Focus', slowMo: true, snapshot: true, pause: true, }],
['Frame.frameElement', { title: 'Get frame element', group: 'getter', }],
['Frame.resolveSelector', { internal: true, }],
['Frame.highlight', { title: 'Highlight element', group: 'configuration', }],
['Frame.getAttribute', { title: 'Get attribute "{name}"', snapshot: true, pause: true, group: 'getter', }],
['Frame.goto', { title: 'Navigate to "{url}"', slowMo: true, snapshot: true, pause: true, }],
['Frame.hover', { title: 'Hover', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.innerHTML', { title: 'Get HTML', snapshot: true, pause: true, group: 'getter', }],
['Frame.innerText', { title: 'Get inner text', snapshot: true, pause: true, group: 'getter', }],
['Frame.inputValue', { title: 'Get input value', snapshot: true, pause: true, group: 'getter', }],
['Frame.isChecked', { title: 'Is checked', snapshot: true, pause: true, group: 'getter', }],
['Frame.isDisabled', { title: 'Is disabled', snapshot: true, pause: true, group: 'getter', }],
['Frame.isEnabled', { title: 'Is enabled', snapshot: true, pause: true, group: 'getter', }],
['Frame.isHidden', { title: 'Is hidden', snapshot: true, pause: true, group: 'getter', }],
['Frame.isVisible', { title: 'Is visible', snapshot: true, pause: true, group: 'getter', }],
['Frame.isEditable', { title: 'Is editable', snapshot: true, pause: true, group: 'getter', }],
['Frame.press', { title: 'Press "{key}"', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.querySelector', { title: 'Query selector', snapshot: true, }],
['Frame.querySelectorAll', { title: 'Query selector all', snapshot: true, }],
['Frame.queryCount', { title: 'Query count', snapshot: true, pause: true, }],
['Frame.selectOption', { title: 'Select option', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.setContent', { title: 'Set content', snapshot: true, pause: true, }],
['Frame.setInputFiles', { title: 'Set input files', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.tap', { title: 'Tap', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.textContent', { title: 'Get text content', snapshot: true, pause: true, group: 'getter', }],
['Frame.title', { title: 'Get page title', group: 'getter', }],
['Frame.type', { title: 'Type "{text}"', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.uncheck', { title: 'Uncheck', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['Frame.waitForTimeout', { title: 'Wait for timeout', snapshot: true, }],
['Frame.waitForFunction', { title: 'Wait for function', snapshot: true, pause: true, }],
['Frame.waitForSelector', { title: 'Wait for selector', snapshot: true, }],
['Frame.expect', { title: 'Expect "{expression}"', snapshot: true, pause: true, }],
['Worker.disconnect', { title: 'Disconnect from worker', }],
['Worker.evaluateExpression', { title: 'Evaluate', }],
['Worker.evaluateExpressionHandle', { title: 'Evaluate', }],
['Worker.updateSubscription', { internal: true, }],
['Disposable.dispose', { internal: true, }],
['JSHandle.dispose', { internal: true, }],
['ElementHandle.dispose', { internal: true, }],
['JSHandle.evaluateExpression', { title: 'Evaluate', snapshot: true, pause: true, }],
['ElementHandle.evaluateExpression', { title: 'Evaluate', snapshot: true, pause: true, }],
['JSHandle.evaluateExpressionHandle', { title: 'Evaluate', snapshot: true, pause: true, }],
['ElementHandle.evaluateExpressionHandle', { title: 'Evaluate', snapshot: true, pause: true, }],
['JSHandle.getPropertyList', { title: 'Get property list', group: 'getter', }],
['ElementHandle.getPropertyList', { title: 'Get property list', group: 'getter', }],
['JSHandle.getProperty', { title: 'Get JS property', group: 'getter', }],
['ElementHandle.getProperty', { title: 'Get JS property', group: 'getter', }],
['JSHandle.jsonValue', { title: 'Get JSON value', group: 'getter', }],
['ElementHandle.jsonValue', { title: 'Get JSON value', group: 'getter', }],
['ElementHandle.evalOnSelector', { title: 'Evaluate', snapshot: true, pause: true, }],
['ElementHandle.evalOnSelectorAll', { title: 'Evaluate', snapshot: true, pause: true, }],
['ElementHandle.boundingBox', { title: 'Get bounding box', snapshot: true, pause: true, }],
['ElementHandle.check', { title: 'Check', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.click', { title: 'Click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.contentFrame', { title: 'Get content frame', group: 'getter', }],
['ElementHandle.dblclick', { title: 'Double click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.dispatchEvent', { title: 'Dispatch event', slowMo: true, snapshot: true, pause: true, }],
['ElementHandle.fill', { title: 'Fill "{value}"', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.focus', { title: 'Focus', slowMo: true, snapshot: true, pause: true, }],
['ElementHandle.getAttribute', { title: 'Get attribute', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.hover', { title: 'Hover', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.innerHTML', { title: 'Get HTML', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.innerText', { title: 'Get inner text', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.inputValue', { title: 'Get input value', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.isChecked', { title: 'Is checked', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.isDisabled', { title: 'Is disabled', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.isEditable', { title: 'Is editable', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.isEnabled', { title: 'Is enabled', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.isHidden', { title: 'Is hidden', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.isVisible', { title: 'Is visible', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.ownerFrame', { title: 'Get owner frame', group: 'getter', }],
['ElementHandle.press', { title: 'Press "{key}"', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.querySelector', { title: 'Query selector', snapshot: true, }],
['ElementHandle.querySelectorAll', { title: 'Query selector all', snapshot: true, }],
['ElementHandle.screenshot', { title: 'Screenshot', snapshot: true, pause: true, }],
['ElementHandle.scrollIntoViewIfNeeded', { title: 'Scroll into view', slowMo: true, snapshot: true, pause: true, }],
['ElementHandle.selectOption', { title: 'Select option', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.selectText', { title: 'Select text', slowMo: true, snapshot: true, pause: true, }],
['ElementHandle.setInputFiles', { title: 'Set input files', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.tap', { title: 'Tap', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.textContent', { title: 'Get text content', snapshot: true, pause: true, group: 'getter', }],
['ElementHandle.type', { title: 'Type', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.uncheck', { title: 'Uncheck', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
['ElementHandle.waitForElementState', { title: 'Wait for state', snapshot: true, pause: true, }],
['ElementHandle.waitForSelector', { title: 'Wait for selector', snapshot: true, }],
['Request.response', { internal: true, }],
['Request.rawRequestHeaders', { internal: true, }],
['Route.redirectNavigationRequest', { internal: true, }],
['Route.abort', { title: 'Abort request', group: 'route', }],
['Route.continue', { title: 'Continue request', group: 'route', }],
['Route.fulfill', { title: 'Fulfill request', group: 'route', }],
['WebSocketRoute.connect', { title: 'Connect WebSocket to server', group: 'route', }],
['WebSocketRoute.ensureOpened', { internal: true, }],
['WebSocketRoute.sendToPage', { title: 'Send WebSocket message', group: 'route', }],
['WebSocketRoute.sendToServer', { title: 'Send WebSocket message', group: 'route', }],
['WebSocketRoute.closePage', { internal: true, }],
['WebSocketRoute.closeServer', { internal: true, }],
['Response.body', { title: 'Get response body', group: 'getter', }],
['Response.securityDetails', { internal: true, }],
['Response.serverAddr', { internal: true, }],
['Response.rawResponseHeaders', { internal: true, }],
['Response.httpVersion', { internal: true, }],
['Response.sizes', { internal: true, }],
['BindingCall.reject', { internal: true, }],
['BindingCall.resolve', { internal: true, }],
['Debugger.requestPause', { title: 'Pause on next call', group: 'configuration', }],
['Debugger.resume', { title: 'Resume', group: 'configuration', }],
['Debugger.next', { title: 'Step to next call', group: 'configuration', }],
['Debugger.runTo', { title: 'Run to location', group: 'configuration', }],
['Dialog.accept', { title: 'Accept dialog', }],
['Dialog.dismiss', { title: 'Dismiss dialog', }],
['Tracing.tracingStart', { title: 'Start tracing', group: 'configuration', }],
['Tracing.tracingStartChunk', { title: 'Start tracing', group: 'configuration', }],
['Tracing.tracingGroup', { title: 'Trace "{name}"', }],
['Tracing.tracingGroupEnd', { title: 'Group end', }],
['Tracing.tracingStopChunk', { title: 'Stop tracing', group: 'configuration', }],
['Tracing.tracingStop', { title: 'Stop tracing', group: 'configuration', }],
['Tracing.harStart', { internal: true, }],
['Tracing.harExport', { internal: true, }],
['Artifact.pathAfterFinished', { internal: true, }],
['Artifact.saveAs', { internal: true, }],
['Artifact.saveAsStream', { internal: true, }],
['Artifact.failure', { internal: true, }],
['Artifact.stream', { internal: true, }],
['Artifact.cancel', { internal: true, }],
['Artifact.delete', { internal: true, }],
['Stream.read', { internal: true, }],
['Stream.close', { internal: true, }],
['WritableStream.write', { internal: true, }],
['WritableStream.close', { internal: true, }],
['CDPSession.send', { title: 'Send CDP command', group: 'configuration', }],
['CDPSession.detach', { title: 'Detach CDP session', group: 'configuration', }],
['Electron.launch', { title: 'Launch electron', }],
['ElectronApplication.browserWindow', { internal: true, }],
['ElectronApplication.evaluateExpression', { title: 'Evaluate', }],
['ElectronApplication.evaluateExpressionHandle', { title: 'Evaluate', }],
['ElectronApplication.updateSubscription', { internal: true, }],
['Android.devices', { internal: true, }],
['AndroidSocket.write', { internal: true, }],
['AndroidSocket.close', { internal: true, }],
['AndroidDevice.wait', { title: 'Wait', }],
['AndroidDevice.fill', { title: 'Fill "{text}"', }],
['AndroidDevice.tap', { title: 'Tap', }],
['AndroidDevice.drag', { title: 'Drag', }],
['AndroidDevice.fling', { title: 'Fling', }],
['AndroidDevice.longTap', { title: 'Long tap', }],
['AndroidDevice.pinchClose', { title: 'Pinch close', }],
['AndroidDevice.pinchOpen', { title: 'Pinch open', }],
['AndroidDevice.scroll', { title: 'Scroll', }],
['AndroidDevice.swipe', { title: 'Swipe', }],
['AndroidDevice.info', { internal: true, }],
['AndroidDevice.screenshot', { title: 'Screenshot', }],
['AndroidDevice.inputType', { title: 'Type', }],
['AndroidDevice.inputPress', { title: 'Press', }],
['AndroidDevice.inputTap', { title: 'Tap', }],
['AndroidDevice.inputSwipe', { title: 'Swipe', }],
['AndroidDevice.inputDrag', { title: 'Drag', }],
['AndroidDevice.launchBrowser', { title: 'Launch browser', }],
['AndroidDevice.open', { title: 'Open app', }],
['AndroidDevice.shell', { title: 'Execute shell command', group: 'configuration', }],
['AndroidDevice.installApk', { title: 'Install apk', }],
['AndroidDevice.push', { title: 'Push', }],
['AndroidDevice.connectToWebView', { title: 'Connect to Web View', }],
['AndroidDevice.close', { internal: true, }],
['JsonPipe.send', { internal: true, }],
['JsonPipe.close', { internal: true, }]
]);
export function getMetainfo(metadata: { type: string, method: string }): MethodMetainfo | undefined {
return methodMetainfo.get(metadata.type + '.' + metadata.method);
}
@@ -0,0 +1,29 @@
/**
* 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.
*/
export { isString } from './stringUtils';
export function isRegExp(obj: any): obj is RegExp {
return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]';
}
export function isObject(obj: any): obj is NonNullable<object> {
return typeof obj === 'object' && obj !== null;
}
export function isError(obj: any): obj is Error {
return obj instanceof Error || (obj && Object.getPrototypeOf(obj)?.name === 'Error');
}
@@ -0,0 +1,441 @@
/**
* 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 { InvalidSelectorError, parseCSS } from './cssParser';
import type { CSSComplexSelectorList } from './cssParser';
export { InvalidSelectorError, isInvalidSelectorError } from './cssParser';
export type NestedSelectorBody = { parsed: ParsedSelector, distance?: number };
const kNestedSelectorNames = new Set(['internal:has', 'internal:has-not', 'internal:and', 'internal:or', 'internal:chain', 'left-of', 'right-of', 'above', 'below', 'near']);
const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']);
export type ParsedSelectorPart = {
name: string,
body: string | CSSComplexSelectorList | NestedSelectorBody,
source: string,
};
export type ParsedSelector = {
parts: ParsedSelectorPart[],
capture?: number,
};
type ParsedSelectorStrings = {
parts: { name: string, body: string }[],
capture?: number,
};
export const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'has-text', 'above', 'below', 'right-of', 'left-of', 'near', 'nth-match']);
export function parseSelector(selector: string): ParsedSelector {
const parsedStrings = parseSelectorString(selector);
const parts: ParsedSelectorPart[] = [];
for (const part of parsedStrings.parts) {
if (part.name === 'css' || part.name === 'css:light') {
if (part.name === 'css:light')
part.body = ':light(' + part.body + ')';
const parsedCSS = parseCSS(part.body, customCSSNames);
parts.push({
name: 'css',
body: parsedCSS.selector,
source: part.body
});
continue;
}
if (kNestedSelectorNames.has(part.name)) {
let innerSelector: string;
let distance: number | undefined;
try {
const unescaped = JSON.parse('[' + part.body + ']');
if (!Array.isArray(unescaped) || unescaped.length < 1 || unescaped.length > 2 || typeof unescaped[0] !== 'string')
throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body);
innerSelector = unescaped[0];
if (unescaped.length === 2) {
if (typeof unescaped[1] !== 'number' || !kNestedSelectorNamesWithDistance.has(part.name))
throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body);
distance = unescaped[1];
}
} catch (e) {
throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body);
}
const nested = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } };
const lastFrame = [...nested.body.parsed.parts].reverse().find(part => part.name === 'internal:control' && part.body === 'enter-frame');
const lastFrameIndex = lastFrame ? nested.body.parsed.parts.indexOf(lastFrame) : -1;
// Allow nested selectors to start with the same frame selector.
if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), parts.slice(0, lastFrameIndex + 1)))
nested.body.parsed.parts.splice(0, lastFrameIndex + 1);
parts.push(nested);
continue;
}
parts.push({ ...part, source: part.body });
}
if (kNestedSelectorNames.has(parts[0].name))
throw new InvalidSelectorError(`"${parts[0].name}" selector cannot be first`);
return {
capture: parsedStrings.capture,
parts
};
}
export function splitSelectorByFrame(selectorText: string): ParsedSelector[] {
const selector = parseSelector(selectorText);
const result: ParsedSelector[] = [];
let chunk: ParsedSelector = {
parts: [],
};
let chunkStartIndex = 0;
for (let i = 0; i < selector.parts.length; ++i) {
const part = selector.parts[i];
if (part.name === 'internal:control' && part.body === 'enter-frame') {
if (!chunk.parts.length)
throw new InvalidSelectorError('Selector cannot start with entering frame, select the iframe first');
result.push(chunk);
chunk = { parts: [] };
chunkStartIndex = i + 1;
continue;
}
if (selector.capture === i)
chunk.capture = i - chunkStartIndex;
chunk.parts.push(part);
}
if (!chunk.parts.length)
throw new InvalidSelectorError(`Selector cannot end with entering frame, while parsing selector ${selectorText}`);
result.push(chunk);
if (typeof selector.capture === 'number' && typeof result[result.length - 1].capture !== 'number')
throw new InvalidSelectorError(`Can not capture the selector before diving into the frame. Only use * after the last frame has been selected`);
return result;
}
function selectorPartsEqual(list1: ParsedSelectorPart[], list2: ParsedSelectorPart[]) {
return stringifySelector({ parts: list1 }) === stringifySelector({ parts: list2 });
}
export function stringifySelector(selector: string | ParsedSelector, forceEngineName?: boolean): string {
if (typeof selector === 'string')
return selector;
return selector.parts.map((p, i) => {
let includeEngine = true;
if (!forceEngineName && i !== selector.capture) {
if (p.name === 'css')
includeEngine = false;
else if (p.name === 'xpath' && p.source.startsWith('//') || p.source.startsWith('..'))
includeEngine = false;
}
const prefix = includeEngine ? p.name + '=' : '';
return `${i === selector.capture ? '*' : ''}${prefix}${p.source}`;
}).join(' >> ');
}
export function visitAllSelectorParts(selector: ParsedSelector, visitor: (part: ParsedSelectorPart, nested: boolean) => void) {
const visit = (selector: ParsedSelector, nested: boolean) => {
for (const part of selector.parts) {
visitor(part, nested);
if (kNestedSelectorNames.has(part.name))
visit((part.body as NestedSelectorBody).parsed, true);
}
};
visit(selector, false);
}
function parseSelectorString(selector: string): ParsedSelectorStrings {
let index = 0;
let quote: string | undefined;
let start = 0;
const result: ParsedSelectorStrings = { parts: [] };
const append = () => {
const part = selector.substring(start, index).trim();
const eqIndex = part.indexOf('=');
let name: string;
let body: string;
if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) {
name = part.substring(0, eqIndex).trim();
body = part.substring(eqIndex + 1);
} else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') {
name = 'text';
body = part;
} else if (part.length > 1 && part[0] === "'" && part[part.length - 1] === "'") {
name = 'text';
body = part;
} else if (/^\(*\/\//.test(part) || part.startsWith('..')) {
// If selector starts with '//' or '//' prefixed with multiple opening
// parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817
// If selector starts with '..', consider xpath as well.
name = 'xpath';
body = part;
} else {
name = 'css';
body = part;
}
let capture = false;
if (name[0] === '*') {
capture = true;
name = name.substring(1);
}
result.parts.push({ name, body });
if (capture) {
if (result.capture !== undefined)
throw new InvalidSelectorError(`Only one of the selectors can capture using * modifier`);
result.capture = result.parts.length - 1;
}
};
if (!selector.includes('>>')) {
index = selector.length;
append();
return result;
}
const shouldIgnoreTextSelectorQuote = () => {
const prefix = selector.substring(start, index);
const match = prefix.match(/^\s*text\s*=(.*)$/);
// Must be a text selector with some text before the quote.
return !!match && !!match[1];
};
while (index < selector.length) {
const c = selector[index];
if (c === '\\' && index + 1 < selector.length) {
index += 2;
} else if (c === quote) {
quote = undefined;
index++;
} else if (!quote && (c === '"' || c === '\'' || c === '`') && !shouldIgnoreTextSelectorQuote()) {
quote = c;
index++;
} else if (!quote && c === '>' && selector[index + 1] === '>') {
append();
index += 2;
start = index;
} else {
index++;
}
}
append();
return result;
}
export type AttributeSelectorOperator = '<truthy>'|'='|'*='|'|='|'^='|'$='|'~=';
export type AttributeSelectorPart = {
name: string,
jsonPath: string[],
op: AttributeSelectorOperator,
value: any,
caseSensitive: boolean,
};
export type AttributeSelector = {
name: string,
attributes: AttributeSelectorPart[],
};
export function parseAttributeSelector(selector: string, allowUnquotedStrings: boolean): AttributeSelector {
let wp = 0;
let EOL = selector.length === 0;
const next = () => selector[wp] || '';
const eat1 = () => {
const result = next();
++wp;
EOL = wp >= selector.length;
return result;
};
const syntaxError = (stage: string|undefined) => {
if (EOL)
throw new InvalidSelectorError(`Unexpected end of selector while parsing selector \`${selector}\``);
throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + (stage ? ' during ' + stage : ''));
};
function skipSpaces() {
while (!EOL && /\s/.test(next()))
eat1();
}
function isCSSNameChar(char: string) {
// https://www.w3.org/TR/css-syntax-3/#ident-token-diagram
return (char >= '\u0080') // non-ascii
|| (char >= '\u0030' && char <= '\u0039') // digit
|| (char >= '\u0041' && char <= '\u005a') // uppercase letter
|| (char >= '\u0061' && char <= '\u007a') // lowercase letter
|| (char >= '\u0030' && char <= '\u0039') // digit
|| char === '\u005f' // "_"
|| char === '\u002d'; // "-"
}
function readIdentifier() {
let result = '';
skipSpaces();
while (!EOL && isCSSNameChar(next()))
result += eat1();
return result;
}
function readQuotedString(quote: string) {
let result = eat1();
if (result !== quote)
syntaxError('parsing quoted string');
while (!EOL && next() !== quote) {
if (next() === '\\')
eat1();
result += eat1();
}
if (next() !== quote)
syntaxError('parsing quoted string');
result += eat1();
return result;
}
function readRegularExpression() {
if (eat1() !== '/')
syntaxError('parsing regular expression');
let source = '';
let inClass = false;
// https://262.ecma-international.org/11.0/#sec-literals-regular-expression-literals
while (!EOL) {
if (next() === '\\') {
source += eat1();
if (EOL)
syntaxError('parsing regular expression');
} else if (inClass && next() === ']') {
inClass = false;
} else if (!inClass && next() === '[') {
inClass = true;
} else if (!inClass && next() === '/') {
break;
}
source += eat1();
}
if (eat1() !== '/')
syntaxError('parsing regular expression');
let flags = '';
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
while (!EOL && next().match(/[dgimsuy]/))
flags += eat1();
try {
return new RegExp(source, flags);
} catch (e) {
throw new InvalidSelectorError(`Error while parsing selector \`${selector}\`: ${e.message}`);
}
}
function readAttributeToken() {
let token = '';
skipSpaces();
if (next() === `'` || next() === `"`)
token = readQuotedString(next()).slice(1, -1);
else
token = readIdentifier();
if (!token)
syntaxError('parsing property path');
return token;
}
function readOperator(): AttributeSelectorOperator {
skipSpaces();
let op = '';
if (!EOL)
op += eat1();
if (!EOL && (op !== '='))
op += eat1();
if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op))
syntaxError('parsing operator');
return (op as AttributeSelectorOperator);
}
function readAttribute(): AttributeSelectorPart {
// skip leading [
eat1();
// read attribute name:
// foo.bar
// 'foo' . "ba zz"
const jsonPath = [];
jsonPath.push(readAttributeToken());
skipSpaces();
while (next() === '.') {
eat1();
jsonPath.push(readAttributeToken());
skipSpaces();
}
// check property is truthy: [enabled]
if (next() === ']') {
eat1();
return { name: jsonPath.join('.'), jsonPath, op: '<truthy>', value: null, caseSensitive: false };
}
const operator = readOperator();
let value = undefined;
let caseSensitive = true;
skipSpaces();
if (next() === '/') {
if (operator !== '=')
throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression`);
value = readRegularExpression();
} else if (next() === `'` || next() === `"`) {
value = readQuotedString(next()).slice(1, -1);
skipSpaces();
if (next() === 'i' || next() === 'I') {
caseSensitive = false;
eat1();
} else if (next() === 's' || next() === 'S') {
caseSensitive = true;
eat1();
}
} else {
value = '';
while (!EOL && (isCSSNameChar(next()) || next() === '+' || next() === '.'))
value += eat1();
if (value === 'true') {
value = true;
} else if (value === 'false') {
value = false;
} else {
if (!allowUnquotedStrings) {
value = +value;
if (Number.isNaN(value))
syntaxError('parsing attribute value');
}
}
}
skipSpaces();
if (next() !== ']')
syntaxError('parsing attribute value');
eat1();
if (operator !== '=' && typeof value !== 'string')
throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`);
return { name: jsonPath.join('.'), jsonPath, op: operator, value, caseSensitive };
}
const result: AttributeSelector = {
name: '',
attributes: [],
};
result.name = readIdentifier();
skipSpaces();
while (next() === '[') {
result.attributes.push(readAttribute());
skipSpaces();
}
if (!EOL)
syntaxError(undefined);
if (!result.name && !result.attributes.length)
throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - selector cannot be empty`);
return result;
}
@@ -0,0 +1,50 @@
/**
* 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 { ManualPromise } from './manualPromise';
export class Semaphore {
private _max: number;
private _acquired = 0;
private _queue: ManualPromise[] = [];
constructor(max: number) {
this._max = max;
}
setMax(max: number) {
this._max = max;
}
acquire(): Promise<void> {
const lock = new ManualPromise();
this._queue.push(lock);
this._flush();
return lock;
}
release() {
--this._acquired;
this._flush();
}
private _flush() {
while (this._acquired < this._max && this._queue.length) {
++this._acquired;
this._queue.shift()!.resolve();
}
}
}
@@ -0,0 +1,201 @@
/**
* The MIT License (MIT)
* Modifications copyright (c) Microsoft Corporation.
*
* Copyright (c) 2016-2023 Isaac Z. Schlueter i@izs.me, James Talmage james@talmage.io (github.com/jamestalmage), and
* Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
* Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export type RawStack = string[];
export type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
export function captureRawStack(): RawStack {
const stackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = 50;
const error = new Error();
const stack = error.stack || '';
Error.stackTraceLimit = stackTraceLimit;
return stack.split('\n');
}
export function parseStackFrame(text: string, pathSeparator: string, showInternalStackFrames: boolean): StackFrame | null {
const match = text && text.match(re);
if (!match)
return null;
let fname = match[2];
let file = match[7];
if (!file)
return null;
if (!showInternalStackFrames && (file.startsWith('internal') || file.startsWith('node:')))
return null;
const line = match[8];
const column = match[9];
const closeParen = match[11] === ')';
const frame: StackFrame = {
file: '',
line: 0,
column: 0,
};
if (line)
frame.line = Number(line);
if (column)
frame.column = Number(column);
if (closeParen && file) {
// make sure parens are balanced
// if we have a file like "asdf) [as foo] (xyz.js", then odds are
// that the fname should be += " (asdf) [as foo]" and the file
// should be just "xyz.js"
// walk backwards from the end to find the last unbalanced (
let closes = 0;
for (let i = file.length - 1; i > 0; i--) {
if (file.charAt(i) === ')') {
closes++;
} else if (file.charAt(i) === '(' && file.charAt(i - 1) === ' ') {
closes--;
if (closes === -1 && file.charAt(i - 1) === ' ') {
const before = file.slice(0, i - 1);
const after = file.slice(i + 1);
file = after;
fname += ` (${before}`;
break;
}
}
}
}
if (fname) {
const methodMatch = fname.match(methodRe);
if (methodMatch)
fname = methodMatch[1];
}
if (file) {
if (file.startsWith('file://'))
file = fileURLToPath(file, pathSeparator);
frame.file = file;
}
if (fname)
frame.function = fname;
return frame;
}
export function rewriteErrorMessage<E extends Error>(e: E, newMessage: string): E {
const lines: string[] = (e.stack?.split('\n') || []).filter(l => l.startsWith(' at '));
e.message = newMessage;
const errorTitle = `${e.name}: ${e.message}`;
if (lines.length)
e.stack = `${errorTitle}\n${lines.join('\n')}`;
return e;
}
export function stringifyStackFrames(frames: StackFrame[]): string[] {
const stackLines: string[] = [];
for (const frame of frames) {
if (frame.function)
stackLines.push(` at ${frame.function} (${frame.file}:${frame.line}:${frame.column})`);
else
stackLines.push(` at ${frame.file}:${frame.line}:${frame.column}`);
}
return stackLines;
}
export function splitErrorMessage(message: string): { name: string, message: string } {
const separationIdx = message.indexOf(':');
return {
name: separationIdx !== -1 ? message.slice(0, separationIdx) : '',
message: separationIdx !== -1 && separationIdx + 2 <= message.length ? message.substring(separationIdx + 2) : message,
};
}
export function parseErrorStack(stack: string, pathSeparator: string, showInternalStackFrames: boolean = false): {
message: string;
stackLines: string[];
location?: StackFrame;
} {
const lines = stack.split('\n');
let firstStackLine = lines.findIndex(line => line.startsWith(' at '));
if (firstStackLine === -1)
firstStackLine = lines.length;
const message = lines.slice(0, firstStackLine).join('\n');
const stackLines = lines.slice(firstStackLine);
let location: StackFrame | undefined;
for (const line of stackLines) {
const frame = parseStackFrame(line, pathSeparator, showInternalStackFrames);
if (!frame || !frame.file)
continue;
if (belongsToNodeModules(frame.file, pathSeparator))
continue;
location = { file: frame.file, column: frame.column || 0, line: frame.line || 0 };
break;
}
return { message, stackLines, location };
}
function belongsToNodeModules(file: string, pathSeparator: string) {
return file.includes(`${pathSeparator}node_modules${pathSeparator}`);
}
const re = new RegExp('^' +
// Sometimes we strip out the ' at' because it's noisy
'(?:\\s*at )?' +
// $1 = ctor if 'new'
'(?:(new) )?' +
// $2 = function name (can be literally anything)
// May contain method at the end as [as xyz]
'(?:(.*?) \\()?' +
// (eval at <anonymous> (file.js:1:1),
// $3 = eval origin
// $4:$5:$6 are eval file/line/col, but not normally reported
'(?:eval at ([^ ]+) \\((.+?):(\\d+):(\\d+)\\), )?' +
// file:line:col
// $7:$8:$9
// $10 = 'native' if native
'(?:(.+?):(\\d+):(\\d+)|(native))' +
// maybe close the paren, then end
// if $11 is ), then we only allow balanced parens in the filename
// any imbalance is placed on the fname. This is a heuristic, and
// bound to be incorrect in some edge cases. The bet is that
// having weird characters in method names is more common than
// having weird characters in filenames, which seems reasonable.
'(\\)?)$'
);
const methodRe = /^(.*?) \[as (.*?)\]$/;
function fileURLToPath(fileUrl: string, pathSeparator: string): string {
if (!fileUrl.startsWith('file://'))
return fileUrl;
let path = decodeURIComponent(fileUrl.slice(7));
if (path.startsWith('/') && /^[a-zA-Z]:/.test(path.slice(1)))
path = path.slice(1);
return path.replace(/\//g, pathSeparator);
}
@@ -0,0 +1,195 @@
/**
* 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.
*/
// NOTE: this function should not be used to escape any selectors.
export function escapeWithQuotes(text: string, char: string = '\'') {
const stringified = JSON.stringify(text);
const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"');
if (char === '\'')
return char + escapedText.replace(/[']/g, '\\\'') + char;
if (char === '"')
return char + escapedText.replace(/["]/g, '\\"') + char;
if (char === '`')
return char + escapedText.replace(/[`]/g, '\\`') + char;
throw new Error('Invalid escape char');
}
export function escapeTemplateString(text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$\{/g, '\\${');
}
export function isString(obj: any): obj is string {
return typeof obj === 'string' || obj instanceof String;
}
export function toTitleCase(name: string) {
return name.charAt(0).toUpperCase() + name.substring(1);
}
export function toSnakeCase(name: string): string {
// E.g. ignoreHTTPSErrors => ignore_https_errors.
return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z])([A-Z][a-z])/g, '$1_$2').toLowerCase();
}
export function formatObject(value: any, indent = ' ', mode: 'multiline' | 'oneline' = 'multiline'): string {
if (typeof value === 'string')
return escapeWithQuotes(value, '\'');
if (Array.isArray(value))
return `[${value.map(o => formatObject(o)).join(', ')}]`;
if (typeof value === 'object') {
const keys = Object.keys(value).filter(key => key !== 'timeout' && value[key] !== undefined).sort();
if (!keys.length)
return '{}';
const tokens: string[] = [];
for (const key of keys)
tokens.push(`${key}: ${formatObject(value[key])}`);
if (mode === 'multiline')
return `{\n${tokens.map(t => indent + t).join(`,\n`)}\n}`;
return `{ ${tokens.join(', ')} }`;
}
return String(value);
}
export function formatObjectOrVoid(value: any, indent = ' '): string {
const result = formatObject(value, indent);
return result === '{}' ? '' : result;
}
export function quoteCSSAttributeValue(text: string): string {
return `"${text.replace(/["\\]/g, char => '\\' + char)}"`;
}
let normalizedWhitespaceCache: Map<string, string> | undefined;
export function cacheNormalizedWhitespaces() {
normalizedWhitespaceCache = new Map();
}
export function normalizeWhiteSpace(text: string): string {
let result = normalizedWhitespaceCache?.get(text);
if (result === undefined) {
result = text.replace(/[\u200b\u00ad]/g, '').trim().replace(/\s+/g, ' ');
normalizedWhitespaceCache?.set(text, result);
}
return result;
}
export function normalizeEscapedRegexQuotes(source: string) {
// This function reverses the effect of escapeRegexForSelector below.
// Odd number of backslashes followed by the quote -> remove unneeded backslash.
return source.replace(/(^|[^\\])(\\\\)*\\(['"`])/g, '$1$2$3');
}
function escapeRegexForSelector(re: RegExp): string {
// Unicode mode does not allow "identity character escapes", so we do not escape and
// hope that it does not contain quotes and/or >> signs.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_escape
// TODO: rework RE usages in internal selectors away from literal representation to json, e.g. {source,flags}.
if (re.unicode || (re as any).unicodeSets)
return String(re);
// Even number of backslashes followed by the quote -> insert a backslash.
return String(re).replace(/(^|[^\\])(\\\\)*(["'`])/g, '$1$2\\$3').replace(/>>/g, '\\>\\>');
}
export function escapeForTextSelector(text: string | RegExp, exact: boolean): string {
if (typeof text !== 'string')
return escapeRegexForSelector(text);
return `${JSON.stringify(text)}${exact ? 's' : 'i'}`;
}
export function escapeForAttributeSelector(value: string | RegExp, exact: boolean): string {
if (typeof value !== 'string')
return escapeRegexForSelector(value);
// TODO: this should actually be
// cssEscape(value).replace(/\\ /g, ' ')
// However, our attribute selectors do not conform to CSS parsing spec,
// so we escape them differently.
return `"${value.replace(/\\/g, '\\\\').replace(/["]/g, '\\"')}"${exact ? 's' : 'i'}`;
}
export function trimString(input: string, cap: number, suffix: string = ''): string {
if (input.length <= cap)
return input;
const chars = [...input];
if (chars.length > cap)
return chars.slice(0, cap - suffix.length).join('') + suffix;
return chars.join('');
}
export function trimStringWithEllipsis(input: string, cap: number): string {
return trimString(input, cap, '\u2026');
}
export function escapeRegExp(s: string) {
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
const escaped = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
export function escapeHTMLAttribute(s: string): string {
return s.replace(/[&<>"']/ug, char => (escaped as any)[char]);
}
export function escapeHTML(s: string): string {
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
}
export function longestCommonSubstring(s1: string, s2: string): string {
const n = s1.length;
const m = s2.length;
let maxLen = 0;
let endingIndex = 0;
// Initialize a 2D array with zeros
const dp = Array(n + 1)
.fill(null)
.map(() => Array(m + 1).fill(0));
// Build the dp table
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (s1[i - 1] === s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
if (dp[i][j] > maxLen) {
maxLen = dp[i][j];
endingIndex = i;
}
}
}
}
// Extract the longest common substring
return s1.slice(endingIndex - maxLen, endingIndex);
}
export function parseRegex(regex: string): RegExp {
if (regex[0] !== '/')
throw new Error(`Invalid regex, must start with '/': ${regex}`);
const lastSlash = regex.lastIndexOf('/');
if (lastSlash <= 0)
throw new Error(`Invalid regex, must end with '/' followed by optional flags: ${regex}`);
const source = regex.slice(1, lastSlash);
const flags = regex.slice(lastSlash + 1);
return new RegExp(source, flags);
}
export const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g');
export function stripAnsiEscapes(str: string): string {
return str.replace(ansiRegex, '');
}
@@ -0,0 +1,40 @@
/**
* 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.
*/
// Hopefully, this file is never used in injected sources,
// because it does not use `builtins.performance`,
// and can break when clock emulation is engaged.
/* eslint-disable no-restricted-globals */
let _timeOrigin = performance.timeOrigin;
let _timeShift = 0;
export function setTimeOrigin(origin: number) {
_timeOrigin = origin;
_timeShift = performance.timeOrigin - origin;
}
export function timeOrigin(): number {
return _timeOrigin;
}
export function monotonicTime(): number {
return Math.floor((performance.now() + _timeShift) * 1000) / 1000;
}
export const DEFAULT_PLAYWRIGHT_TIMEOUT = 30_000;
export const DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT = 3 * 60 * 1000; // 3 minutes
@@ -0,0 +1,61 @@
/**
* 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.
*/
// Hopefully, this file is never used in injected sources,
// because it does not use `builtins.setTimeout` and similar,
// and can break when clock emulation is engaged.
/* eslint-disable no-restricted-globals */
import { monotonicTime } from './time';
export async function raceAgainstDeadline<T>(cb: () => Promise<T>, deadline: number): Promise<{ result: T, timedOut: false } | { timedOut: true }> {
let timer: NodeJS.Timeout | undefined;
return Promise.race([
cb().then(result => {
return { result, timedOut: false };
}),
new Promise<{ timedOut: true }>(resolve => {
if (!deadline)
return;
timer = setTimeout(() => resolve({ timedOut: true }), deadline - monotonicTime());
}),
]).finally(() => {
clearTimeout(timer);
});
}
export async function pollAgainstDeadline<T>(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, pollIntervals: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> {
const lastPollInterval = pollIntervals.pop() ?? 1000;
let lastResult: T|undefined;
const wrappedCallback = () => Promise.resolve().then(callback);
while (true) {
const time = monotonicTime();
if (deadline && time >= deadline)
break;
const received = await raceAgainstDeadline(wrappedCallback, deadline);
if (received.timedOut)
break;
lastResult = (received as any).result.result;
if (!(received as any).result.continuePolling)
return { result: lastResult, timedOut: false };
const interval = pollIntervals!.shift() ?? lastPollInterval;
if (deadline && deadline <= monotonicTime() + interval)
break;
await new Promise(x => setTimeout(x, interval));
}
return { timedOut: true, result: lastResult };
}
@@ -0,0 +1,60 @@
/**
* 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 { Language } from '../locatorGenerators';
import type { ResourceSnapshot } from '@trace/snapshot';
import type * as trace from '@trace/trace';
// *Entry structures are used to pass the trace between the sw and the page.
export type ContextEntry = {
origin: 'testRunner'|'library';
startTime: number;
endTime: number;
browserName: string;
channel?: string;
platform?: string;
playwrightVersion?: string;
wallTime: number;
sdkLanguage?: Language;
testIdAttributeName?: string;
title?: string;
options: trace.BrowserContextEventOptions;
pages: PageEntry[];
resources: ResourceSnapshot[];
actions: ActionEntry[];
events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[];
stdio: trace.StdioTraceEvent[];
errors: trace.ErrorTraceEvent[];
hasSource: boolean;
contextId: string;
testTimeout?: number;
};
export type PageEntry = {
pageId: string,
screencastFrames: {
sha1: string,
timestamp: number,
frameSwapWallTime?: number,
width: number,
height: number,
}[];
};
export type ActionEntry = trace.ActionTraceEvent & {
log: { time: number, message: string }[];
};
@@ -0,0 +1,646 @@
/**
* 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 { escapeHTMLAttribute, escapeHTML } from '../stringUtils';
import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot';
import type { PageEntry } from './entries';
import type { LRUCache } from '../lruCache';
function findClosest<T>(items: T[], metric: (v: T) => number, target: number) {
return items.find((item, index) => {
if (index === items.length - 1)
return true;
const next = items[index + 1];
return Math.abs(metric(item) - target) < Math.abs(metric(next) - target);
});
}
function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot {
return Array.isArray(n) && typeof n[0] === 'string';
}
function isSubtreeReferenceSnapshot(n: NodeSnapshot): n is SubtreeReferenceSnapshot {
return Array.isArray(n) && Array.isArray(n[0]);
}
export class SnapshotRenderer {
private _htmlCache: LRUCache<SnapshotRenderer, string>;
private _snapshots: FrameSnapshot[];
private _index: number;
readonly snapshotName: string | undefined;
private _resources: ResourceSnapshot[];
private _snapshot: FrameSnapshot;
private _callId: string;
private _screencastFrames: PageEntry['screencastFrames'];
constructor(htmlCache: LRUCache<SnapshotRenderer, string>, resources: ResourceSnapshot[], snapshots: FrameSnapshot[], screencastFrames: PageEntry['screencastFrames'], index: number) {
this._htmlCache = htmlCache;
this._resources = resources;
this._snapshots = snapshots;
this._index = index;
this._snapshot = snapshots[index];
this._callId = snapshots[index].callId;
this._screencastFrames = screencastFrames;
this.snapshotName = snapshots[index].snapshotName;
}
snapshot(): FrameSnapshot {
return this._snapshots[this._index];
}
viewport(): { width: number, height: number } {
return this._snapshots[this._index].viewport;
}
closestScreenshot(): string | undefined {
const { wallTime, timestamp } = this.snapshot();
const closestFrame = (wallTime && this._screencastFrames[0]?.frameSwapWallTime)
? findClosest(this._screencastFrames, frame => frame.frameSwapWallTime!, wallTime)
: findClosest(this._screencastFrames, frame => frame.timestamp, timestamp);
return closestFrame?.sha1;
}
render(): RenderedFrameSnapshot {
const result: string[] = [];
const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined, parentAttrs: [string, string][] | undefined) => {
// Text node.
if (typeof n === 'string') {
// Best-effort Electron support: rewrite custom protocol in url() links in stylesheets.
// Old snapshotter was sending lower-case.
if (parentTag === 'STYLE' || parentTag === 'style')
result.push(escapeURLsInStyleSheet(rewriteURLsInStyleSheetForCustomProtocol(n)));
else
result.push(escapeHTML(n));
return;
}
if (isSubtreeReferenceSnapshot(n)) {
// Node reference.
const referenceIndex = snapshotIndex - n[0][0];
if (referenceIndex >= 0 && referenceIndex <= snapshotIndex) {
const nodes = snapshotNodes(this._snapshots[referenceIndex]);
const nodeIndex = n[0][1];
if (nodeIndex >= 0 && nodeIndex < nodes.length)
return visit(nodes[nodeIndex], referenceIndex, parentTag, parentAttrs);
}
} else if (isNodeNameAttributesChildNodesSnapshot(n)) {
const [name, nodeAttrs, ...children] = n;
// Element node.
// Note that <noscript> will not be rendered by default in the trace viewer, because
// JS is enabled. So rename it to <x-noscript>.
const nodeName = name === 'NOSCRIPT' ? 'X-NOSCRIPT' : name;
const attrs = Object.entries(nodeAttrs || {});
result.push('<', nodeName);
const kCurrentSrcAttribute = '__playwright_current_src__';
const isFrame = nodeName === 'IFRAME' || nodeName === 'FRAME';
const isAnchor = nodeName === 'A';
const isImg = nodeName === 'IMG';
const isMeta = nodeName === 'META';
const isImgWithCurrentSrc = isImg && attrs.some(a => a[0] === kCurrentSrcAttribute);
const isSourceInsidePictureWithCurrentSrc = nodeName === 'SOURCE' && parentTag === 'PICTURE' && parentAttrs?.some(a => a[0] === kCurrentSrcAttribute);
// For META, only allow a small whitelist of http-equiv directives so a malicious snapshot
// cannot navigate the snapshot iframe via e.g. <meta http-equiv="refresh"> or otherwise
// affect the trace viewer.
const hasUnsafeHttpEquiv = isMeta && attrs.some(a => a[0].toLowerCase() === 'http-equiv' && !kAllowedMetaHttpEquivs.has(a[1].trim().toLowerCase()));
for (const [attr, value] of attrs) {
let attrName = attr;
if (isFrame && attr.toLowerCase() === 'src') {
// Never set relative URLs as <iframe src> - they start fetching frames immediately.
attrName = '__playwright_src__';
}
if (isImg && attr === kCurrentSrcAttribute) {
// Render currentSrc for images, so that trace viewer does not accidentally
// resolve srcset to a different source.
attrName = 'src';
}
if (['src', 'srcset'].includes(attr.toLowerCase()) && (isImgWithCurrentSrc || isSourceInsidePictureWithCurrentSrc)) {
// Disable actual <img src>, <img srcset>, <source src> and <source srcset> if
// we will be using the currentSrc instead.
attrName = '_' + attrName;
}
if (hasUnsafeHttpEquiv && (attr.toLowerCase() === 'http-equiv' || attr.toLowerCase() === 'content')) {
// Neutralize the META directive by renaming the attribute so the browser ignores it.
attrName = '_' + attr;
}
let attrValue = value;
if (!isAnchor && (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute))
attrValue = rewriteURLForCustomProtocol(value);
result.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"');
}
result.push('>');
for (const child of children)
visit(child, snapshotIndex, nodeName, attrs);
if (!autoClosing.has(nodeName))
result.push('</', nodeName, '>');
return;
} else {
// Why are we here? Let's not throw, just in case.
return;
}
};
const snapshot = this._snapshot;
const html = this._htmlCache.getOrCompute(this, () => {
visit(snapshot.html, this._index, undefined, undefined);
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
const html = prefix + [
// Hide the document in order to prevent flickering. We will unhide once script has processed shadow.
'<style>*,*::before,*::after { visibility: hidden }</style>',
`<script>${snapshotScript(this.viewport(), this._callId, this.snapshotName)}</script>`
].join('') + result.join('');
return { value: html, size: html.length };
});
return { html, pageId: snapshot.pageId, frameId: snapshot.frameId, index: this._index };
}
resourceByUrl(url: string, method: string): ResourceSnapshot | undefined {
const snapshot = this._snapshot;
let sameFrameResource: ResourceSnapshot | undefined;
let otherFrameResource: ResourceSnapshot | undefined;
for (const resource of this._resources) {
// Only use resources that received response before the snapshot.
// Note that both snapshot time and request time are taken in the same Node process.
if (typeof resource._monotonicTime === 'number' && resource._monotonicTime >= snapshot.timestamp)
break;
if (resource.response.status === 304) {
// "Not Modified" responses are issued when browser requests the same resource
// multiple times, meanwhile indicating that it has the response cached.
//
// When rendering the snapshot, browser most likely will not have the resource cached,
// so we should respond with the real content instead, picking the last response that
// is not 304.
continue;
}
if (resource.request.url === url && resource.request.method === method) {
// Pick the last resource with matching url - most likely it was used
// at the time of snapshot, not the earlier aborted resource with the same url.
if (resource._frameref === snapshot.frameId)
sameFrameResource = resource;
else
otherFrameResource = resource;
}
}
// First try locating exact resource belonging to this frame,
// then fall back to resource with this URL to account for memory cache.
let result = sameFrameResource ?? otherFrameResource;
if (result && method.toUpperCase() === 'GET') {
// Patch override if necessary.
let override = snapshot.resourceOverrides.find(o => o.url === url);
if (override?.ref) {
// "ref" means use the same content as "ref" snapshots ago.
const index = this._index - override.ref;
if (index >= 0 && index < this._snapshots.length)
override = this._snapshots[index].resourceOverrides.find(o => o.url === url);
}
if (override?.sha1) {
result = {
...result,
response: {
...result.response,
content: {
...result.response.content,
_sha1: override.sha1,
}
},
};
}
}
return result;
}
}
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
// Whitelist of META http-equiv directives that are safe to render in the trace viewer.
// Notably excludes 'refresh' (auto-navigation), 'set-cookie' and 'content-security-policy'.
const kAllowedMetaHttpEquivs = new Set(['content-type', 'content-language', 'default-style', 'x-ua-compatible']);
function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
if (!(snapshot as any)._nodes) {
const nodes: NodeSnapshot[] = [];
const visit = (n: NodeSnapshot) => {
if (typeof n === 'string') {
nodes.push(n);
} else if (isNodeNameAttributesChildNodesSnapshot(n)) {
const [,, ...children] = n;
for (const child of children)
visit(child);
nodes.push(n);
}
};
visit(snapshot.html);
(snapshot as any)._nodes = nodes;
}
return (snapshot as any)._nodes;
}
type ViewportSize = { width: number, height: number };
type BoundingRect = { left: number, top: number, right: number, bottom: number };
type FrameBoundingRectsInfo = {
viewport: ViewportSize;
frames: WeakMap<Element, {
boundingRect: BoundingRect;
scrollLeft: number;
scrollTop: number;
}>;
};
declare global {
interface Window {
__playwright_frame_bounding_rects__: FrameBoundingRectsInfo;
}
}
function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
function applyPlaywrightAttributes(blankSnapshotUrl: string, viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
// eslint-disable-next-line no-restricted-globals
const win = window;
const searchParams = new URLSearchParams(win.location.search);
const shouldPopulateCanvasFromScreenshot = searchParams.has('shouldPopulateCanvasFromScreenshot');
const isUnderTest = searchParams.has('isUnderTest');
// info to recursively compute canvas position relative to the top snapshot frame.
// Before rendering each iframe, its parent extracts the '__playwright_canvas_render_info__' attribute
// value and keeps in this variable. It can then remove the attribute and render the element,
// which will eventually trigger the same process inside the iframe recursively.
// When there's a canvas to render, we iterate over its ancestor frames to compute
// its position relative to the top snapshot frame.
const frameBoundingRectsInfo = {
viewport,
frames: new WeakMap(),
};
win['__playwright_frame_bounding_rects__'] = frameBoundingRectsInfo;
const kPointerWarningTitle = 'Recorded click position in absolute coordinates did not' +
' match the center of the clicked element. This is either due to the use of provided offset,' +
' or due to a difference between the test runner and the trace viewer operating systems.';
const scrollTops: Element[] = [];
const scrollLefts: Element[] = [];
const targetElements: Element[] = [];
const canvasElements: HTMLCanvasElement[] = [];
let topSnapshotWindow: Window = win;
while (topSnapshotWindow !== topSnapshotWindow.parent && !topSnapshotWindow.location.pathname.match(/\/page@[a-z0-9]+$/))
topSnapshotWindow = topSnapshotWindow.parent;
const visit = (root: Document | ShadowRoot) => {
// Collect all scrolled elements for later use.
for (const e of root.querySelectorAll(`[__playwright_scroll_top_]`))
scrollTops.push(e);
for (const e of root.querySelectorAll(`[__playwright_scroll_left_]`))
scrollLefts.push(e);
for (const element of root.querySelectorAll(`[__playwright_value_]`)) {
const inputElement = element as HTMLInputElement | HTMLTextAreaElement;
if (inputElement.type !== 'file')
inputElement.value = inputElement.getAttribute('__playwright_value_')!;
element.removeAttribute('__playwright_value_');
}
for (const element of root.querySelectorAll(`[__playwright_checked_]`)) {
(element as HTMLInputElement).checked = element.getAttribute('__playwright_checked_') === 'true';
element.removeAttribute('__playwright_checked_');
}
for (const element of root.querySelectorAll(`[__playwright_selected_]`)) {
(element as HTMLOptionElement).selected = element.getAttribute('__playwright_selected_') === 'true';
element.removeAttribute('__playwright_selected_');
}
for (const element of root.querySelectorAll(`[__playwright_popover_open_]`)) {
try {
(element as HTMLElement).showPopover();
} catch {
}
element.removeAttribute('__playwright_popover_open_');
}
for (const element of root.querySelectorAll(`[__playwright_dialog_open_]`)) {
try {
if (element.getAttribute('__playwright_dialog_open_') === 'modal')
(element as HTMLDialogElement).showModal();
else
(element as HTMLDialogElement).show();
} catch {
}
element.removeAttribute('__playwright_dialog_open_');
}
for (const targetId of targetIds) {
for (const target of root.querySelectorAll(`[__playwright_target__="${targetId}"]`)) {
const style = (target as HTMLElement).style;
style.outline = '2px solid #006ab1';
style.backgroundColor = '#6fa8dc7f';
targetElements.push(target);
}
}
for (const iframe of root.querySelectorAll('iframe, frame')) {
const boundingRectJson = iframe.getAttribute('__playwright_bounding_rect__');
iframe.removeAttribute('__playwright_bounding_rect__');
const boundingRect = boundingRectJson ? JSON.parse(boundingRectJson) : undefined;
if (boundingRect)
frameBoundingRectsInfo.frames.set(iframe, { boundingRect, scrollLeft: 0, scrollTop: 0 });
const src = iframe.getAttribute('__playwright_src__');
if (!src) {
iframe.setAttribute('src', blankSnapshotUrl);
} else {
// Retain query parameters to inherit name=, time=, pointX=, pointY= and other values from parent.
const url = new URL(win.location.href);
// We can be loading iframe from within iframe, reset base to be absolute.
const index = url.pathname.lastIndexOf('/snapshot/');
if (index !== -1)
url.pathname = url.pathname.substring(0, index + 1);
url.pathname += src.substring(1);
iframe.setAttribute('src', url.toString());
}
}
{
const body = root.querySelector(`body[__playwright_custom_elements__]`);
if (body && win.customElements) {
const customElements = (body.getAttribute('__playwright_custom_elements__') || '').split(',');
for (const elementName of customElements)
win.customElements.define(elementName, class extends HTMLElement {});
}
}
for (const element of root.querySelectorAll(`template[__playwright_shadow_root_]`)) {
const template = element as HTMLTemplateElement;
const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' });
shadowRoot.appendChild(template.content);
template.remove();
visit(shadowRoot);
}
for (const element of root.querySelectorAll('a'))
element.addEventListener('click', event => { event.preventDefault(); });
if ('adoptedStyleSheets' in (root as any)) {
const adoptedSheets: CSSStyleSheet[] = [...(root as any).adoptedStyleSheets];
for (const element of root.querySelectorAll(`template[__playwright_style_sheet_]`)) {
const template = element as HTMLTemplateElement;
const sheet = new CSSStyleSheet();
(sheet as any).replaceSync(template.getAttribute('__playwright_style_sheet_'));
adoptedSheets.push(sheet);
}
(root as any).adoptedStyleSheets = adoptedSheets;
}
canvasElements.push(...root.querySelectorAll('canvas'));
};
const onLoad = () => {
win.removeEventListener('load', onLoad);
for (const element of scrollTops) {
element.scrollTop = +element.getAttribute('__playwright_scroll_top_')!;
element.removeAttribute('__playwright_scroll_top_');
if (frameBoundingRectsInfo.frames.has(element))
frameBoundingRectsInfo.frames.get(element)!.scrollTop = element.scrollTop;
}
for (const element of scrollLefts) {
element.scrollLeft = +element.getAttribute('__playwright_scroll_left_')!;
element.removeAttribute('__playwright_scroll_left_');
if (frameBoundingRectsInfo.frames.has(element))
frameBoundingRectsInfo.frames.get(element)!.scrollLeft = element.scrollLeft;
}
win.document.styleSheets[0].disabled = true;
const search = new URL(win.location.href).searchParams;
const isTopFrame = win === topSnapshotWindow;
if (isTopFrame && search.get('pointX') && search.get('pointY')) {
const pointX = +search.get('pointX')!;
const pointY = +search.get('pointY')!;
const pointElement = win.document.createElement('x-pw-pointer');
pointElement.style.position = 'fixed';
pointElement.style.backgroundColor = '#f44336';
pointElement.style.width = '20px';
pointElement.style.height = '20px';
pointElement.style.borderRadius = '10px';
pointElement.style.margin = '-10px 0 0 -10px';
pointElement.style.zIndex = '2147483646';
pointElement.style.display = 'flex';
pointElement.style.alignItems = 'center';
pointElement.style.justifyContent = 'center';
// Sometimes there are layout discrepancies between recording and rendering, e.g. fonts,
// that may place the point at the wrong place. To avoid confusion, we just show the
// point in the middle of the target element.
const target = targetElements[0];
const targetBox = target?.getBoundingClientRect();
const targetCenter = target ? { x: targetBox.left + targetBox.width / 2, y: targetBox.top + targetBox.height / 2 } : null;
pointElement.style.left = (targetCenter?.x ?? pointX) + 'px';
pointElement.style.top = (targetCenter?.y ?? pointY) + 'px';
const isAligned = !targetCenter || (Math.abs(targetCenter.x - pointX) <= 10 && Math.abs(targetCenter.y - pointY) <= 10);
if (!isAligned) {
const warningElement = win.document.createElement('x-pw-pointer-warning');
warningElement.textContent = '⚠';
warningElement.style.fontSize = '19px';
warningElement.style.color = 'white';
warningElement.style.marginTop = '-3.5px';
warningElement.style.userSelect = 'none';
pointElement.appendChild(warningElement);
pointElement.setAttribute('title', kPointerWarningTitle);
}
win.document.documentElement.appendChild(pointElement);
}
if (canvasElements.length > 0) {
function drawCheckerboard(context: CanvasRenderingContext2D, canvas: HTMLCanvasElement) {
function createCheckerboardPattern() {
const pattern = win.document.createElement('canvas');
pattern.width = pattern.width / Math.floor(pattern.width / 24);
pattern.height = pattern.height / Math.floor(pattern.height / 24);
const context = pattern.getContext('2d')!;
context.fillStyle = 'lightgray';
context.fillRect(0, 0, pattern.width, pattern.height);
context.fillStyle = 'white';
context.fillRect(0, 0, pattern.width / 2, pattern.height / 2);
context.fillRect(pattern.width / 2, pattern.height / 2, pattern.width, pattern.height);
return context.createPattern(pattern, 'repeat')!;
}
context.fillStyle = createCheckerboardPattern();
context.fillRect(0, 0, canvas.width, canvas.height);
}
const img = new Image();
img.onload = () => {
for (const canvas of canvasElements) {
const context = canvas.getContext('2d')!;
const boundingRectAttribute = canvas.getAttribute('__playwright_bounding_rect__');
canvas.removeAttribute('__playwright_bounding_rect__');
if (!boundingRectAttribute)
continue;
let boundingRect: { left: number, top: number, right: number, bottom: number };
try {
boundingRect = JSON.parse(boundingRectAttribute);
} catch (e) {
continue;
}
let currWindow: Window = win;
while (currWindow !== topSnapshotWindow) {
const iframe = currWindow.frameElement!;
currWindow = currWindow.parent;
const iframeInfo = currWindow['__playwright_frame_bounding_rects__']?.frames.get(iframe);
if (!iframeInfo?.boundingRect)
break;
const leftOffset = iframeInfo.boundingRect.left - iframeInfo.scrollLeft;
const topOffset = iframeInfo.boundingRect.top - iframeInfo.scrollTop;
boundingRect.left += leftOffset;
boundingRect.top += topOffset;
boundingRect.right += leftOffset;
boundingRect.bottom += topOffset;
}
const { width, height } = topSnapshotWindow['__playwright_frame_bounding_rects__'].viewport;
boundingRect.left = boundingRect.left / width;
boundingRect.top = boundingRect.top / height;
boundingRect.right = boundingRect.right / width;
boundingRect.bottom = boundingRect.bottom / height;
const partiallyUncaptured = boundingRect.right > 1 || boundingRect.bottom > 1;
const fullyUncaptured = boundingRect.left > 1 || boundingRect.top > 1;
if (fullyUncaptured) {
canvas.title = `Playwright couldn't capture canvas contents because it's located outside the viewport.`;
continue;
}
drawCheckerboard(context, canvas);
if (shouldPopulateCanvasFromScreenshot) {
context.drawImage(img, boundingRect.left * img.width, boundingRect.top * img.height, (boundingRect.right - boundingRect.left) * img.width, (boundingRect.bottom - boundingRect.top) * img.height, 0, 0, canvas.width, canvas.height);
if (partiallyUncaptured)
canvas.title = `Playwright couldn't capture full canvas contents because it's located partially outside the viewport.`;
else
canvas.title = `Canvas contents are displayed on a best-effort basis based on viewport screenshots taken during test execution.`;
} else {
canvas.title = 'Canvas content display is disabled.';
}
if (isUnderTest)
// eslint-disable-next-line no-console
console.log(`canvas drawn:`, JSON.stringify([boundingRect.left, boundingRect.top, (boundingRect.right - boundingRect.left), (boundingRect.bottom - boundingRect.top)].map(v => Math.floor(v * 100))));
}
};
img.onerror = () => {
for (const canvas of canvasElements) {
const context = canvas.getContext('2d')!;
drawCheckerboard(context, canvas);
canvas.title = `Playwright couldn't show canvas contents because the screenshot failed to load.`;
}
};
img.src = location.href.replace('/snapshot', '/closest-screenshot');
}
};
const onDOMContentLoaded = () => visit(win.document);
win.addEventListener('load', onLoad);
win.addEventListener('DOMContentLoaded', onDOMContentLoaded);
}
return `\n(${applyPlaywrightAttributes.toString()})(${JSON.stringify(blankSnapshotUrl)},${JSON.stringify(viewport)}${targetIds.map(id => `, "${id}"`).join('')})`;
}
/**
* Best-effort Electron support: rewrite custom protocol in DOM.
* vscode-file://vscode-app/ -> https://pw-vscode-file--vscode-app/
*/
const schemas = ['about:', 'blob:', 'data:', 'file:', 'ftp:', 'http:', 'https:', 'mailto:', 'sftp:', 'ws:', 'wss:'];
const kLegacyBlobPrefix = 'http://playwright.bloburl/#';
export function rewriteURLForCustomProtocol(href: string): string {
// Legacy support, we used to prepend this to blobs, strip it away.
if (href.startsWith(kLegacyBlobPrefix))
href = href.substring(kLegacyBlobPrefix.length);
try {
const url = new URL(href);
// Sanitize URL.
if (url.protocol === 'javascript:' || url.protocol === 'vbscript:')
return 'javascript:void(0)';
// Pass through if possible.
const isBlob = url.protocol === 'blob:';
const isFile = url.protocol === 'file:';
if (!isBlob && !isFile && schemas.includes(url.protocol))
return href;
// Rewrite blob, file and custom schemas.
const prefix = 'pw-' + url.protocol.slice(0, url.protocol.length - 1);
if (!isFile)
url.protocol = 'https:';
url.hostname = url.hostname ? `${prefix}--${url.hostname}` : prefix;
if (isFile) {
// File URIs can only have their protocol changed after the hostname
// is set. (For all other URIs, we must set the protocol first.)
url.protocol = 'https:';
}
return url.toString();
} catch {
return href;
}
}
/**
* Best-effort Electron support: rewrite custom protocol in inline stylesheets.
* vscode-file://vscode-app/ -> https://pw-vscode-file--vscode-app/
*/
const urlInCSSRegex = /url\(['"]?([\w-]+:)\/\//ig;
function rewriteURLsInStyleSheetForCustomProtocol(text: string): string {
return text.replace(urlInCSSRegex, (match: string, protocol: string) => {
const isBlob = protocol === 'blob:';
const isFile = protocol === 'file:';
if (!isBlob && !isFile && schemas.includes(protocol))
return match;
return match.replace(protocol + '//', `https://pw-${protocol.slice(0, -1)}--`);
});
}
// url() inside a <style> tag can mess up with html parsing, so we encode some of them.
// As an example, the following url will close the </style> tag:
// url('data:image/svg+xml,<svg><defs><style>.a{fill:none}</style></defs><g class="a"></g></svg>')
const urlToEscapeRegex1 = /url\(\s*'([^']*)'\s*\)/ig;
const urlToEscapeRegex2 = /url\(\s*"([^"]*)"\s*\)/ig;
function escapeURLsInStyleSheet(text: string): string {
const replacer = (match: string, url: string) => {
// Conservatively encode only urls with a closing tag.
if (url.includes('</'))
return match.replace(url, encodeURI(url));
return match;
};
return text.replace(urlToEscapeRegex1, replacer).replace(urlToEscapeRegex2, replacer);
}
export const blankSnapshotUrl = 'data:text/html;base64,' + btoa(`<body></body><style>body { color-scheme: light dark; background: light-dark(white, #333) }</style>`);
@@ -0,0 +1,129 @@
/**
* 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 { URLSearchParams } from 'url';
import type { SnapshotRenderer } from './snapshotRenderer';
import type { SnapshotStorage } from './snapshotStorage';
import type { ResourceSnapshot } from '@trace/snapshot';
export class SnapshotServer {
private _snapshotStorage: SnapshotStorage;
private _resourceLoader: (sha1: string) => Promise<Blob | undefined>;
private _snapshotIds = new Map<string, SnapshotRenderer>();
constructor(snapshotStorage: SnapshotStorage, resourceLoader: (sha1: string) => Promise<Blob | undefined>) {
this._snapshotStorage = snapshotStorage;
this._resourceLoader = resourceLoader;
}
serveSnapshot(pageOrFrameId: string, searchParams: URLSearchParams, snapshotUrl: string): Response {
const snapshot = this._snapshot(pageOrFrameId, searchParams);
if (!snapshot)
return new Response(null, { status: 404 });
const renderedSnapshot = snapshot.render();
this._snapshotIds.set(snapshotUrl, snapshot);
return new Response(renderedSnapshot.html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
async serveClosestScreenshot(pageOrFrameId: string, searchParams: URLSearchParams): Promise<Response> {
const snapshot = this._snapshot(pageOrFrameId, searchParams);
const sha1 = snapshot?.closestScreenshot();
if (!sha1)
return new Response(null, { status: 404 });
return new Response(await this._resourceLoader(sha1));
}
serveSnapshotInfo(pageOrFrameId: string, searchParams: URLSearchParams): Response {
const snapshot = this._snapshot(pageOrFrameId, searchParams);
return this._respondWithJson(snapshot ? {
viewport: snapshot.viewport(),
url: snapshot.snapshot().frameUrl,
timestamp: snapshot.snapshot().timestamp,
wallTime: snapshot.snapshot().wallTime,
} : {
error: 'No snapshot found'
});
}
private _snapshot(pageOrFrameId: string, params: URLSearchParams) {
const name = params.get('name')!;
return this._snapshotStorage.snapshotByName(pageOrFrameId, name);
}
private _respondWithJson(object: any): Response {
return new Response(JSON.stringify(object), {
status: 200,
headers: {
'Cache-Control': 'public, max-age=31536000',
'Content-Type': 'application/json'
}
});
}
async serveResource(requestUrlAlternatives: string[], method: string, snapshotUrl: string): Promise<Response> {
let resource: ResourceSnapshot | undefined;
const snapshot = this._snapshotIds.get(snapshotUrl)!;
for (const requestUrl of requestUrlAlternatives) {
resource = snapshot?.resourceByUrl(removeHash(requestUrl), method);
if (resource)
break;
}
if (!resource)
return new Response(null, { status: 404 });
const sha1 = resource.response.content._sha1;
const content = sha1 ? await this._resourceLoader(sha1) || new Blob([]) : new Blob([]);
let contentType = resource.response.content.mimeType;
const isTextEncoding = /^text\/|^application\/(javascript|json)/.test(contentType);
if (isTextEncoding && !contentType.includes('charset'))
contentType = `${contentType}; charset=utf-8`;
const headers = new Headers();
// "x-unknown" in the har means "no content type".
if (contentType !== 'x-unknown')
headers.set('Content-Type', contentType);
for (const { name, value } of resource.response.headers)
headers.set(name, value);
headers.delete('Content-Encoding');
headers.delete('Access-Control-Allow-Origin');
headers.set('Access-Control-Allow-Origin', '*');
headers.delete('Content-Length');
headers.set('Content-Length', String(content.size));
if (this._snapshotStorage.hasResourceOverride(resource.request.url))
headers.set('Cache-Control', 'no-store, no-cache, max-age=0');
else
headers.set('Cache-Control', 'public, max-age=31536000');
const { status } = resource.response;
const isNullBodyStatus = status === 101 || status === 204 || status === 205 || status === 304;
return new Response(isNullBodyStatus ? null : content, {
headers,
status: resource.response.status,
statusText: resource.response.statusText,
});
}
}
function removeHash(url: string) {
try {
const u = new URL(url);
u.hash = '';
return u.toString();
} catch (e) {
return url;
}
}
@@ -0,0 +1,93 @@
/**
* 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 { rewriteURLForCustomProtocol, SnapshotRenderer } from './snapshotRenderer';
import { LRUCache } from '../lruCache';
import type { FrameSnapshot, ResourceSnapshot } from '@trace/snapshot';
import type { PageEntry } from './entries';
export class SnapshotStorage {
private _frameSnapshots = new Map<string, {
raw: FrameSnapshot[],
renderers: SnapshotRenderer[],
}>();
private _cache = new LRUCache<SnapshotRenderer, string>(100_000_000); // 100MB per each trace
private _contextToResources = new Map<string, ResourceSnapshot[]>();
private _resourceUrlsWithOverrides = new Set<string>();
addResource(contextId: string, resource: ResourceSnapshot): void {
resource.request.url = rewriteURLForCustomProtocol(resource.request.url);
this._ensureResourcesForContext(contextId).push(resource);
}
addFrameSnapshot(contextId: string, snapshot: FrameSnapshot, screencastFrames: PageEntry['screencastFrames']) {
for (const override of snapshot.resourceOverrides)
override.url = rewriteURLForCustomProtocol(override.url);
let frameSnapshots = this._frameSnapshots.get(snapshot.frameId);
if (!frameSnapshots) {
frameSnapshots = {
raw: [],
renderers: [],
};
this._frameSnapshots.set(snapshot.frameId, frameSnapshots);
if (snapshot.isMainFrame)
this._frameSnapshots.set(snapshot.pageId, frameSnapshots);
}
frameSnapshots.raw.push(snapshot);
const resources = this._ensureResourcesForContext(contextId);
const renderer = new SnapshotRenderer(this._cache, resources, frameSnapshots.raw, screencastFrames, frameSnapshots.raw.length - 1);
frameSnapshots.renderers.push(renderer);
return renderer;
}
snapshotByName(pageOrFrameId: string, snapshotName: string): SnapshotRenderer | undefined {
const snapshot = this._frameSnapshots.get(pageOrFrameId);
return snapshot?.renderers.find(r => r.snapshotName === snapshotName);
}
snapshotsForTest() {
return [...this._frameSnapshots.keys()];
}
finalize() {
// Resources are not necessarily sorted in the trace file, so sort them now.
for (const resources of this._contextToResources.values())
resources.sort((a, b) => (a._monotonicTime || 0) - (b._monotonicTime || 0));
// Resources that have overrides should not be cached, otherwise we might get stale content
// while serving snapshots with different override values.
for (const frameSnapshots of this._frameSnapshots.values()) {
for (const snapshot of frameSnapshots.raw) {
for (const override of snapshot.resourceOverrides)
this._resourceUrlsWithOverrides.add(override.url);
}
}
}
hasResourceOverride(url: string) {
return this._resourceUrlsWithOverrides.has(url);
}
private _ensureResourcesForContext(contextId: string): ResourceSnapshot[] {
let resources = this._contextToResources.get(contextId);
if (!resources) {
resources = [];
this._contextToResources.set(contextId, resources);
}
return resources;
}
}
@@ -0,0 +1,158 @@
/**
* 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 { parseClientSideCallMetadata } from './traceUtils';
import { SnapshotStorage } from './snapshotStorage';
import { TraceModernizer } from './traceModernizer';
import type { ContextEntry } from './entries';
export interface TraceLoaderBackend {
entryNames(): Promise<string[]>;
hasEntry(entryName: string): Promise<boolean>;
readText(entryName: string): Promise<string | undefined>;
readBlob(entryName: string): Promise<Blob | undefined>;
isLive(): boolean;
}
export class TraceLoader {
contextEntries: ContextEntry[] = [];
private _snapshotStorage: SnapshotStorage | undefined;
private _backend!: TraceLoaderBackend;
private _resourceToContentType = new Map<string, string>();
constructor() {
}
async load(backend: TraceLoaderBackend, traceFile?: string, unzipProgress?: (done: number, total: number) => void) {
this._backend = backend;
const prefix = traceFile?.match(/(.+)\.trace$/)?.[1];
const prefixes: string[] = [];
let hasSource = false;
for (const entryName of await this._backend.entryNames()) {
const match = entryName.match(/(.+)\.trace$/);
if (match && (!prefix || prefix === match[1]))
prefixes.push(match[1] || '');
if (entryName.includes('src@'))
hasSource = true;
}
if (!prefixes.length)
throw new Error('Cannot find .trace file');
this._snapshotStorage = new SnapshotStorage();
// 3 * ordinals progress increments below.
const total = prefixes.length * 3;
let done = 0;
for (const prefix of prefixes) {
const contextEntry = createEmptyContext();
contextEntry.hasSource = hasSource;
const modernizer = new TraceModernizer(contextEntry, this._snapshotStorage);
const trace = await this._backend.readText(prefix + '.trace') || '';
modernizer.appendTrace(trace);
unzipProgress?.(++done, total);
const network = await this._backend.readText(prefix + '.network') || '';
modernizer.appendTrace(network);
unzipProgress?.(++done, total);
contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
if (!backend.isLive()) {
// Terminate actions w/o after event gracefully.
// This would close after hooks event that has not been closed because
// the trace is usually saved before after hooks complete.
for (const action of contextEntry.actions.slice().reverse()) {
if (!action.endTime && !action.error) {
for (const a of contextEntry.actions) {
if (a.parentId === action.callId && action.endTime < a.endTime)
action.endTime = a.endTime;
}
}
}
}
const stacks = await this._backend.readText(prefix + '.stacks');
if (stacks) {
const callMetadata = parseClientSideCallMetadata(JSON.parse(stacks));
for (const action of contextEntry.actions)
action.stack = action.stack || callMetadata.get(action.callId);
}
unzipProgress?.(++done, total);
for (const resource of contextEntry.resources) {
if (resource.request.postData?._sha1)
this._resourceToContentType.set(resource.request.postData._sha1, stripEncodingFromContentType(resource.request.postData.mimeType));
if (resource.response.content?._sha1)
this._resourceToContentType.set(resource.response.content._sha1, stripEncodingFromContentType(resource.response.content.mimeType));
}
this.contextEntries.push(contextEntry);
}
this._snapshotStorage.finalize();
}
async hasEntry(filename: string): Promise<boolean> {
return this._backend.hasEntry(filename);
}
async resourceForSha1(sha1: string): Promise<Blob | undefined> {
const blob = await this._backend.readBlob('resources/' + sha1);
const contentType = this._resourceToContentType.get(sha1);
// "x-unknown" in the har means "no content type".
if (!blob || contentType === undefined || contentType === 'x-unknown')
return blob;
return new Blob([blob], { type: contentType });
}
storage(): SnapshotStorage {
return this._snapshotStorage!;
}
}
function stripEncodingFromContentType(contentType: string) {
const charset = contentType.match(/^(.*);\s*charset=.*$/);
if (charset)
return charset[1];
return contentType;
}
function createEmptyContext(): ContextEntry {
return {
origin: 'testRunner',
startTime: Number.MAX_SAFE_INTEGER,
wallTime: Number.MAX_SAFE_INTEGER,
endTime: 0,
browserName: '',
options: {
deviceScaleFactor: 1,
isMobile: false,
viewport: { width: 1280, height: 800 },
},
pages: [],
resources: [],
actions: [],
events: [],
errors: [],
stdio: [],
hasSource: false,
contextId: '',
};
}
@@ -0,0 +1,469 @@
/**
* 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 { getActionGroup, renderTitleForCall } from '../protocolFormatter';
import type { Language } from '../locatorGenerators';
import type { ResourceSnapshot } from '@trace/snapshot';
import type * as trace from '@trace/trace';
import type { ActionTraceEvent } from '@trace/trace';
import type { ActionEntry, ContextEntry, PageEntry } from './entries';
import type { StackFrame } from '@protocol/channels';
import type { ActionGroup } from '../protocolFormatter';
const contextSymbol = Symbol('context');
const nextInContextSymbol = Symbol('nextInContext');
const prevByEndTimeSymbol = Symbol('prevByEndTime');
const nextByStartTimeSymbol = Symbol('nextByStartTime');
const eventsSymbol = Symbol('events');
export type SourceLocation = {
file: string;
line: number;
column: number;
source?: SourceModel;
};
export type SourceModel = {
errors: { line: number, message: string }[];
content: string | undefined;
};
export type ResourceEntry = ResourceSnapshot & { id: string };
export type ActionTraceEventInContext = ActionEntry & {
context: ContextEntry;
};
export type ActionTreeItem = {
id: string;
children: ActionTreeItem[];
parent: ActionTreeItem | undefined;
action: ActionTraceEventInContext;
};
export type ErrorDescription = {
action?: ActionTraceEventInContext;
stack?: StackFrame[];
message: string;
};
export type Attachment = trace.AfterActionTraceEventAttachment & { callId: string };
export class TraceModel {
readonly startTime: number;
readonly endTime: number;
readonly browserName: string;
readonly channel?: string;
readonly platform?: string;
readonly playwrightVersion?: string;
readonly wallTime?: number;
readonly title?: string;
readonly options: trace.BrowserContextEventOptions;
readonly pages: PageEntry[];
readonly actions: ActionTraceEventInContext[];
readonly attachments: Attachment[];
readonly visibleAttachments: Attachment[];
readonly events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[];
readonly stdio: trace.StdioTraceEvent[];
readonly errors: trace.ErrorTraceEvent[];
readonly errorDescriptors: ErrorDescription[];
readonly hasSource: boolean;
readonly hasStepData: boolean;
readonly sdkLanguage: Language | undefined;
readonly testIdAttributeName: string | undefined;
readonly sources: Map<string, SourceModel>;
resources: ResourceEntry[];
readonly actionCounters: Map<string, number>;
readonly traceUri: string;
readonly testTimeout?: number;
constructor(traceUri: string, contexts: ContextEntry[]) {
contexts.forEach(contextEntry => indexModel(contextEntry));
const libraryContext = contexts.find(context => context.origin === 'library');
this.traceUri = traceUri;
this.browserName = libraryContext?.browserName || '';
this.sdkLanguage = libraryContext?.sdkLanguage;
this.channel = libraryContext?.channel;
this.testIdAttributeName = libraryContext?.testIdAttributeName;
this.platform = libraryContext?.platform || '';
this.playwrightVersion = contexts.find(c => c.playwrightVersion)?.playwrightVersion;
this.title = libraryContext?.title || '';
this.options = libraryContext?.options || {};
this.testTimeout = contexts.find(c => c.origin === 'testRunner')?.testTimeout;
// Next call updates all timestamps for all events in library contexts, so it must be done first.
this.actions = mergeActionsAndUpdateTiming(contexts);
this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages));
this.wallTime = contexts.map(c => c.wallTime).reduce((prev, cur) => Math.min(prev || Number.MAX_VALUE, cur!), Number.MAX_VALUE);
this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE);
this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE);
this.events = ([] as (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]).concat(...contexts.map(c => c.events));
this.stdio = ([] as trace.StdioTraceEvent[]).concat(...contexts.map(c => c.stdio));
this.errors = ([] as trace.ErrorTraceEvent[]).concat(...contexts.map(c => c.errors));
this.hasSource = contexts.some(c => c.hasSource);
this.hasStepData = contexts.some(context => context.origin === 'testRunner');
this.resources = [...contexts.map(c => c.resources)].flat().map(entry => ({ ...entry, id: `${entry.pageref}-${entry.startedDateTime}-${entry.request.url}` }));
this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUri })) ?? []);
this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_'));
this.events.sort((a1, a2) => a1.time - a2.time);
this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!);
this.errorDescriptors = this.hasStepData ? this._errorDescriptorsFromTestRunner() : this._errorDescriptorsFromActions();
this.sources = collectSources(this.actions, this.errorDescriptors);
this.actionCounters = new Map();
for (const action of this.actions) {
action.group = action.group ?? getActionGroup({ type: action.class, method: action.method });
if (action.group)
this.actionCounters.set(action.group, 1 + (this.actionCounters.get(action.group) || 0));
}
}
createRelativeUrl(path: string) {
const url = new URL('http://localhost/' + path);
url.searchParams.set('trace', this.traceUri);
return url.toString().substring('http://localhost/'.length);
}
failedAction() {
// This find innermost action for nested ones.
return this.actions.findLast(a => a.error);
}
filteredActions(actionsFilter: ActionGroup[]) {
const filter = new Set<string>(actionsFilter);
return this.actions.filter(action => !action.group || filter.has(action.group));
}
renderActionTree(filter?: ActionGroup[]) {
const actions = this.filteredActions(filter ?? []);
const { rootItem } = buildActionTree(actions);
const actionTree: string[] = [];
const visit = (actionItem: ActionTreeItem, indent: string) => {
const title = renderTitleForCall({ ...actionItem.action, type: actionItem.action.class });
actionTree.push(`${indent}${title || actionItem.id}`);
for (const child of actionItem.children)
visit(child, indent + ' ');
};
rootItem.children.forEach(a => visit(a, ''));
return actionTree;
}
private _errorDescriptorsFromActions(): ErrorDescription[] {
const errors: ErrorDescription[] = [];
for (const action of this.actions || []) {
if (!action.error?.message)
continue;
errors.push({
action,
stack: action.stack,
message: action.error.message,
});
}
return errors;
}
private _errorDescriptorsFromTestRunner(): ErrorDescription[] {
return this.errors.filter(e => !!e.message).map((error, i) => ({
stack: error.stack,
message: error.message,
}));
}
}
function indexModel(context: ContextEntry) {
for (const page of context.pages)
(page as any)[contextSymbol] = context;
for (let i = 0; i < context.actions.length; ++i) {
const action = context.actions[i] as any;
action[contextSymbol] = context;
}
let lastNonRouteAction = undefined;
for (let i = context.actions.length - 1; i >= 0; i--) {
const action = context.actions[i] as ActionTraceEvent;
(action as any)[nextInContextSymbol] = lastNonRouteAction;
if (action.class !== 'Route')
lastNonRouteAction = action;
}
for (const event of context.events)
(event as any)[contextSymbol] = context;
for (const resource of context.resources)
(resource as any)[contextSymbol] = context;
}
function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) {
const result: ActionTraceEventInContext[] = [];
const actions = mergeActionsAndUpdateTimingSameTrace(contexts);
result.push(...actions);
result.sort((a1, a2) => {
if (a2.parentId === a1.callId)
return 1;
if (a1.parentId === a2.callId)
return -1;
return a1.endTime - a2.endTime;
});
for (let i = 1; i < result.length; ++i)
(result[i] as any)[prevByEndTimeSymbol] = result[i - 1];
result.sort((a1, a2) => {
if (a2.parentId === a1.callId)
return -1;
if (a1.parentId === a2.callId)
return 1;
return a1.startTime - a2.startTime;
});
for (let i = 0; i + 1 < result.length; ++i)
(result[i] as any)[nextByStartTimeSymbol] = result[i + 1];
return result;
}
let lastTmpStepId = 0;
function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionTraceEventInContext[] {
const map = new Map<string, ActionTraceEventInContext>();
const libraryContexts = contexts.filter(context => context.origin === 'library');
const testRunnerContexts = contexts.filter(context => context.origin === 'testRunner');
// With library-only or test-runner-only traces there is nothing to match.
if (!testRunnerContexts.length || !libraryContexts.length) {
return contexts.map(context => {
return context.actions.map(action => ({ ...action, context }));
}).flat();
}
for (const context of libraryContexts) {
for (const action of context.actions) {
// Never merge stepless events.
map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context });
}
}
// Protocol call aka library contexts have startTime/endTime as server-side times.
// Step aka test runner contexts have startTime/endTime as client-side times.
// Adjust startTime/endTime on the library contexts to align them with the test
// runner steps.
const delta = monotonicTimeDeltaBetweenLibraryAndRunner(testRunnerContexts, map);
if (delta)
adjustMonotonicTime(libraryContexts, delta);
const nonPrimaryIdToPrimaryId = new Map<string, string>();
for (const context of testRunnerContexts) {
for (const action of context.actions) {
const existing = action.stepId && map.get(action.stepId);
if (existing) {
nonPrimaryIdToPrimaryId.set(action.callId, existing.callId);
if (action.error)
existing.error = action.error;
if (action.attachments)
existing.attachments = action.attachments;
if (action.annotations)
existing.annotations = action.annotations;
if (action.parentId)
existing.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId;
if (action.group)
existing.group = action.group;
// For the events that are present in the test runner context, always take
// their time from the test runner context to preserve client side order.
existing.startTime = action.startTime;
existing.endTime = action.endTime;
continue;
}
if (action.parentId)
action.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId;
map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context });
}
}
return [...map.values()];
}
function adjustMonotonicTime(contexts: ContextEntry[], monotonicTimeDelta: number) {
for (const context of contexts) {
context.startTime += monotonicTimeDelta;
context.endTime += monotonicTimeDelta;
for (const action of context.actions) {
if (action.startTime)
action.startTime += monotonicTimeDelta;
if (action.endTime)
action.endTime += monotonicTimeDelta;
}
for (const event of context.events)
event.time += monotonicTimeDelta;
for (const event of context.stdio)
event.timestamp += monotonicTimeDelta;
for (const page of context.pages) {
for (const frame of page.screencastFrames)
frame.timestamp += monotonicTimeDelta;
}
for (const resource of context.resources) {
if (resource._monotonicTime)
resource._monotonicTime += monotonicTimeDelta;
}
}
}
function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEntry[], libraryActions: Map<string, ActionTraceEventInContext>) {
// We cannot rely on wall time or monotonic time to be the in sync
// between library and test runner contexts. So we find first action
// that is present in both runner and library contexts and use it
// to calculate the time delta, assuming the two events happened at the
// same instant.
for (const context of nonPrimaryContexts) {
for (const action of context.actions) {
if (!action.startTime)
continue;
const libraryAction = action.stepId ? libraryActions.get(action.stepId) : undefined;
if (libraryAction)
return action.startTime - libraryAction.startTime;
}
}
return 0;
}
export function buildActionTree(actions: ActionTraceEventInContext[]): { rootItem: ActionTreeItem, itemMap: Map<string, ActionTreeItem> } {
const itemMap = new Map<string, ActionTreeItem>();
for (const action of actions) {
itemMap.set(action.callId, {
id: action.callId,
parent: undefined,
children: [],
action,
});
}
const rootItem: ActionTreeItem = { action: { ...kFakeRootAction }, id: '', parent: undefined, children: [] };
for (const item of itemMap.values()) {
rootItem.action.startTime = Math.min(rootItem.action.startTime, item.action.startTime);
rootItem.action.endTime = Math.max(rootItem.action.endTime, item.action.endTime);
const parent = item.action.parentId ? itemMap.get(item.action.parentId) || rootItem : rootItem;
parent.children.push(item);
item.parent = parent;
}
const inheritStack = (item: ActionTreeItem) => {
for (const child of item.children) {
child.action.stack = child.action.stack ?? item.action.stack;
inheritStack(child);
}
};
inheritStack(rootItem);
return { rootItem, itemMap };
}
export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry {
return (action as any)[contextSymbol];
}
function nextInContext(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[nextInContextSymbol];
}
export function previousActionByEndTime(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[prevByEndTimeSymbol];
}
export function nextActionByStartTime(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[nextByStartTimeSymbol];
}
export function stats(action: ActionTraceEvent): { errors: number, warnings: number } {
let errors = 0;
let warnings = 0;
for (const event of eventsForAction(action)) {
if (event.type === 'console') {
const type = event.messageType;
if (type === 'warning')
++warnings;
else if (type === 'error')
++errors;
}
if (event.type === 'event' && event.method === 'pageError')
++errors;
}
return { errors, warnings };
}
export function eventsForAction(action: ActionTraceEvent): (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] {
let result: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] = (action as any)[eventsSymbol];
if (result)
return result;
const nextAction = nextInContext(action);
result = context(action).events.filter(event => {
return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime);
});
(action as any)[eventsSymbol] = result;
return result;
}
function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: ErrorDescription[]): Map<string, SourceModel> {
const result = new Map<string, SourceModel>();
for (const action of actions) {
for (const frame of action.stack || []) {
let source = result.get(frame.file);
if (!source) {
source = { errors: [], content: undefined };
result.set(frame.file, source);
}
}
}
for (const error of errorDescriptors) {
const { action, stack, message } = error;
if (!action || !stack)
continue;
result.get(stack[0].file)?.errors.push({
line: stack[0].line || 0,
message
});
}
return result;
}
const kFakeRootAction: ActionTraceEventInContext = {
type: 'action',
callId: '',
startTime: 0,
endTime: 0,
class: '',
method: '',
params: {},
log: [],
context: {
origin: 'library',
startTime: 0,
endTime: 0,
browserName: '',
wallTime: 0,
options: {},
pages: [],
resources: [],
actions: [],
events: [],
stdio: [],
errors: [],
hasSource: false,
contextId: '',
},
};
@@ -0,0 +1,441 @@
/**
* 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 * as trace from '@trace/trace';
import type * as traceV3 from './versions/traceV3';
import type * as traceV4 from './versions/traceV4';
import type * as traceV5 from './versions/traceV5';
import type * as traceV6 from './versions/traceV6';
import type * as traceV7 from './versions/traceV7';
import type * as traceV8 from './versions/traceV8';
import type { ActionEntry, ContextEntry, PageEntry } from './entries';
import type { SnapshotStorage } from './snapshotStorage';
export class TraceVersionError extends Error {
constructor(message: string) {
super(message);
this.name = 'TraceVersionError';
}
}
// 6 => 10/2023 ~1.40
// 7 => 05/2024 ~1.45
const latestVersion: trace.VERSION = 8;
export class TraceModernizer {
private _contextEntry: ContextEntry;
private _snapshotStorage: SnapshotStorage;
private _actionMap = new Map<string, ActionEntry>();
private _version: number | undefined;
private _pageEntries = new Map<string, PageEntry>();
private _jsHandles = new Map<string, { preview: string }>();
private _consoleObjects = new Map<string, { type: string, text: string, location: { url: string, lineNumber: number, columnNumber: number }, args?: { preview: string, value: string }[] }>();
constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage) {
this._contextEntry = contextEntry;
this._snapshotStorage = snapshotStorage;
}
appendTrace(trace: string) {
for (const line of trace.split('\n'))
this._appendEvent(line);
}
actions(): ActionEntry[] {
return [...this._actionMap.values()];
}
private _pageEntry(pageId: string): PageEntry {
let pageEntry = this._pageEntries.get(pageId);
if (!pageEntry) {
pageEntry = {
pageId,
screencastFrames: [],
};
this._pageEntries.set(pageId, pageEntry);
this._contextEntry.pages.push(pageEntry);
}
return pageEntry;
}
private _appendEvent(line: string) {
if (!line)
return;
const events = this._modernize(JSON.parse(line));
for (const event of events)
this._innerAppendEvent(event);
}
private _innerAppendEvent(event: trace.TraceEvent) {
const contextEntry = this._contextEntry;
switch (event.type) {
case 'context-options': {
if (event.version > latestVersion)
throw new TraceVersionError('The trace was created by a newer version of Playwright and is not supported by this version of the viewer. Please use latest Playwright to open the trace.');
this._version = event.version;
contextEntry.origin = event.origin;
contextEntry.browserName = event.browserName;
contextEntry.channel = event.channel;
contextEntry.title = event.title;
contextEntry.platform = event.platform;
contextEntry.playwrightVersion = event.playwrightVersion;
contextEntry.wallTime = event.wallTime;
contextEntry.startTime = event.monotonicTime;
contextEntry.sdkLanguage = event.sdkLanguage;
contextEntry.options = event.options;
contextEntry.testIdAttributeName = event.testIdAttributeName;
contextEntry.contextId = event.contextId ?? '';
contextEntry.testTimeout = event.testTimeout;
break;
}
case 'screencast-frame': {
this._pageEntry(event.pageId).screencastFrames.push(event);
break;
}
case 'before': {
this._actionMap.set(event.callId, { ...event, type: 'action', endTime: 0, log: [] });
break;
}
case 'input': {
const existing = this._actionMap.get(event.callId);
existing!.inputSnapshot = event.inputSnapshot;
existing!.point = event.point;
break;
}
case 'log': {
const existing = this._actionMap.get(event.callId);
// We have some corrupted traces out there, tolerate them.
if (!existing)
return;
existing.log.push({
time: event.time,
message: event.message,
});
break;
}
case 'after': {
const existing = this._actionMap.get(event.callId);
existing!.afterSnapshot = event.afterSnapshot;
existing!.endTime = event.endTime;
existing!.result = event.result;
existing!.error = event.error;
existing!.attachments = event.attachments;
existing!.annotations = event.annotations;
if (event.point)
existing!.point = event.point;
break;
}
case 'action': {
this._actionMap.set(event.callId, { ...event, log: [] });
break;
}
case 'event': {
contextEntry.events.push(event);
break;
}
case 'stdout': {
contextEntry.stdio.push(event);
break;
}
case 'stderr': {
contextEntry.stdio.push(event);
break;
}
case 'error': {
contextEntry.errors.push(event);
break;
}
case 'console': {
contextEntry.events.push(event);
break;
}
case 'resource-snapshot':
this._snapshotStorage.addResource(this._contextEntry.contextId, event.snapshot);
contextEntry.resources.push(event.snapshot);
break;
case 'frame-snapshot':
this._snapshotStorage.addFrameSnapshot(this._contextEntry.contextId, event.snapshot, this._pageEntry(event.snapshot.pageId).screencastFrames);
break;
}
// Make sure there is a page entry for each page, even without screencast frames,
// to show in the metadata view.
if (('pageId' in event) && event.pageId)
this._pageEntry(event.pageId);
if (event.type === 'action' || event.type === 'before')
contextEntry.startTime = Math.min(contextEntry.startTime, event.startTime);
if (event.type === 'action' || event.type === 'after')
contextEntry.endTime = Math.max(contextEntry.endTime, event.endTime);
if (event.type === 'event') {
contextEntry.startTime = Math.min(contextEntry.startTime, event.time);
contextEntry.endTime = Math.max(contextEntry.endTime, event.time);
}
if (event.type === 'screencast-frame') {
contextEntry.startTime = Math.min(contextEntry.startTime, event.timestamp);
contextEntry.endTime = Math.max(contextEntry.endTime, event.timestamp);
}
}
private _processedContextCreatedEvent() {
return this._version !== undefined;
}
private _modernize(event: any): trace.TraceEvent[] {
// First record does not have this._version, but should have a version in the event entry itself.
// Test traces before 7 (including 6) did not have version in the first entry, run the modernizer for 6=>*.
let version = this._version ?? event.version ?? 6;
let events = [event];
for (; version < latestVersion; ++version)
events = (this as any)[`_modernize_${version}_to_${version + 1}`].call(this, events);
return events;
}
_modernize_0_to_1(events: any[]): any[] {
for (const event of events) {
if (event.type !== 'action')
continue;
if (typeof event.metadata.error === 'string')
event.metadata.error = { error: { name: 'Error', message: event.metadata.error } };
}
return events;
}
_modernize_1_to_2(events: any[]): any[] {
for (const event of events) {
if (event.type !== 'frame-snapshot' || !event.snapshot.isMainFrame)
continue;
// Old versions had completely wrong viewport.
event.snapshot.viewport = this._contextEntry.options?.viewport || { width: 1280, height: 720 };
}
return events;
}
_modernize_2_to_3(events: any[]): any[] {
for (const event of events) {
if (event.type !== 'resource-snapshot' || event.snapshot.request)
continue;
// Migrate from old ResourceSnapshot to new har entry format.
const resource = event.snapshot;
event.snapshot = {
_frameref: resource.frameId,
request: {
url: resource.url,
method: resource.method,
headers: resource.requestHeaders,
postData: resource.requestSha1 ? { _sha1: resource.requestSha1 } : undefined,
},
response: {
status: resource.status,
headers: resource.responseHeaders,
content: {
mimeType: resource.contentType,
_sha1: resource.responseSha1,
},
},
_monotonicTime: resource.timestamp,
};
}
return events;
}
_modernize_3_to_4(events: traceV3.TraceEvent[]): traceV4.TraceEvent[] {
const result: traceV4.TraceEvent[] = [];
for (const event of events) {
const e = this._modernize_event_3_to_4(event);
if (e)
result.push(e);
}
return result;
}
_modernize_event_3_to_4(event: traceV3.TraceEvent): traceV4.TraceEvent | null {
if (event.type !== 'action' && event.type !== 'event') {
return event as traceV3.ContextCreatedTraceEvent |
traceV3.ScreencastFrameTraceEvent |
traceV3.ResourceSnapshotTraceEvent |
traceV3.FrameSnapshotTraceEvent;
}
const metadata = event.metadata;
if (metadata.internal || metadata.method.startsWith('tracing'))
return null;
if (event.type === 'event') {
if (metadata.method === '__create__' && metadata.type === 'ConsoleMessage') {
return {
type: 'object',
class: metadata.type,
guid: metadata.params.guid,
initializer: metadata.params.initializer,
};
}
return {
type: 'event',
time: metadata.startTime,
class: metadata.type,
method: metadata.method,
params: metadata.params,
pageId: metadata.pageId,
};
}
return {
type: 'action',
callId: metadata.id,
startTime: metadata.startTime,
endTime: metadata.endTime,
apiName: metadata.apiName || metadata.type + '.' + metadata.method,
class: metadata.type,
method: metadata.method,
params: metadata.params,
// eslint-disable-next-line no-restricted-globals
wallTime: metadata.wallTime || Date.now(),
log: metadata.log,
beforeSnapshot: metadata.snapshots.find(s => s.title === 'before')?.snapshotName,
inputSnapshot: metadata.snapshots.find(s => s.title === 'input')?.snapshotName,
afterSnapshot: metadata.snapshots.find(s => s.title === 'after')?.snapshotName,
error: metadata.error?.error,
result: metadata.result,
point: metadata.point,
pageId: metadata.pageId,
};
}
_modernize_4_to_5(events: traceV4.TraceEvent[]): traceV5.TraceEvent[] {
const result: traceV5.TraceEvent[] = [];
for (const event of events) {
const e = this._modernize_event_4_to_5(event);
if (e)
result.push(e);
}
return result;
}
_modernize_event_4_to_5(event: traceV4.TraceEvent): traceV5.TraceEvent | null {
if (event.type === 'event' && event.method === '__create__' && event.class === 'JSHandle')
this._jsHandles.set(event.params.guid, event.params.initializer);
if (event.type === 'object') {
// We do not expect any other 'object' events.
if (event.class !== 'ConsoleMessage')
return null;
// Older traces might have `args` inherited from the protocol initializer - guid of JSHandle,
// but might also have modern `args` with preview and value.
const args: { preview: string, value: string }[] = (event.initializer as any).args?.map((arg: any) => {
if (arg.guid) {
const handle = this._jsHandles.get(arg.guid);
return { preview: handle?.preview || '', value: '' };
}
return { preview: arg.preview || '', value: arg.value || '' };
});
this._consoleObjects.set(event.guid, {
type: event.initializer.type,
text: event.initializer.text,
location: event.initializer.location,
args,
});
return null;
}
if (event.type === 'event' && event.method === 'console') {
const consoleMessage = this._consoleObjects.get(event.params.message?.guid || '');
if (!consoleMessage)
return null;
return {
type: 'console',
time: event.time,
pageId: event.pageId,
messageType: consoleMessage.type,
text: consoleMessage.text,
args: consoleMessage.args,
location: consoleMessage.location,
};
}
return event;
}
_modernize_5_to_6(events: traceV5.TraceEvent[]): traceV6.TraceEvent[] {
const result: traceV6.TraceEvent[] = [];
for (const event of events) {
result.push(event);
if (event.type !== 'after' || !event.log.length)
continue;
for (const log of event.log) {
result.push({
type: 'log',
callId: event.callId,
message: log,
time: -1,
});
}
}
return result;
}
_modernize_6_to_7(events: traceV6.TraceEvent[]): traceV7.TraceEvent[] {
const result: traceV7.TraceEvent[] = [];
if (!this._processedContextCreatedEvent() && events[0].type !== 'context-options') {
const event: traceV7.ContextCreatedTraceEvent = {
type: 'context-options',
origin: 'testRunner',
version: 6,
browserName: '',
options: {},
platform: 'unknown',
wallTime: 0,
monotonicTime: 0,
sdkLanguage: 'javascript',
contextId: '',
};
result.push(event);
}
for (const event of events) {
if (event.type === 'context-options') {
result.push({ ...event, monotonicTime: 0, origin: 'library', contextId: '' });
continue;
}
if (event.type === 'before' || event.type === 'action') {
// Take wall and monotonic time from the first event.
if (!this._contextEntry.wallTime)
this._contextEntry.wallTime = event.wallTime;
const eventAsV6 = event as traceV6.BeforeActionTraceEvent;
const eventAsV7 = event as traceV7.BeforeActionTraceEvent;
eventAsV7.stepId = `${eventAsV6.apiName}@${eventAsV6.wallTime}`;
result.push(eventAsV7);
} else {
result.push(event);
}
}
return result;
}
_modernize_7_to_8(events: traceV7.TraceEvent[]): traceV8.TraceEvent[] {
const result: traceV8.TraceEvent[] = [];
for (const event of events) {
if (event.type === 'before' || event.type === 'action') {
const eventAsV7 = event as traceV7.BeforeActionTraceEvent;
const eventAsV8 = event as traceV8.BeforeActionTraceEvent;
if (eventAsV7.apiName) {
eventAsV8.title = eventAsV7.apiName;
delete (eventAsV8 as any).apiName;
}
eventAsV8.stepId = eventAsV7.stepId ?? eventAsV7.callId;
result.push(eventAsV8);
} else {
result.push(event);
}
}
return result;
}
}
@@ -0,0 +1,56 @@
/**
* 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 { ClientSideCallMetadata, StackFrame } from '@protocol/channels';
export type SerializedStackFrame = [number, number, number, string];
export type SerializedStack = [number, SerializedStackFrame[]];
export type SerializedClientSideCallMetadata = {
files: string[];
stacks: SerializedStack[];
};
export function parseClientSideCallMetadata(data: SerializedClientSideCallMetadata): Map<string, StackFrame[]> {
const result = new Map<string, StackFrame[]>();
const { files, stacks } = data;
for (const s of stacks) {
const [id, ff] = s;
result.set(`call@${id}`, ff.map(f => ({ file: files[f[0]], line: f[1], column: f[2], function: f[3] })));
}
return result;
}
export function serializeClientSideCallMetadata(metadatas: ClientSideCallMetadata[]): SerializedClientSideCallMetadata {
const fileNames = new Map<string, number>();
const stacks: SerializedStack[] = [];
for (const m of metadatas) {
if (!m.stack || !m.stack.length)
continue;
const stack: SerializedStackFrame[] = [];
for (const frame of m.stack) {
let ordinal = fileNames.get(frame.file);
if (typeof ordinal !== 'number') {
ordinal = fileNames.size;
fileNames.set(frame.file, ordinal);
}
const stackFrame: SerializedStackFrame = [ordinal, frame.line || 0, frame.column || 0, frame.function || ''];
stack.push(stackFrame);
}
stacks.push([m.id, stack]);
}
return { files: [...fileNames.keys()], stacks };
}
@@ -0,0 +1,164 @@
/**
* 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 { Entry as ResourceSnapshot } from '@trace/har';
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type Point = {
x: number,
y: number,
};
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
type CallMetadata = {
id: string;
startTime: number;
endTime: number;
pauseStartTime?: number;
pauseEndTime?: number;
type: string;
method: string;
params: any;
apiName?: string;
internal?: boolean;
isServerSide?: boolean;
wallTime?: number;
location?: { file: string, line?: number, column?: number };
log: string[];
afterSnapshot?: string;
snapshots: { title: string, snapshotName: string }[];
error?: SerializedError;
result?: any;
point?: Point;
objectId?: string;
pageId?: string;
frameId?: string;
};
export type NodeSnapshot =
string |
[ [number, number] ] |
[ string ] |
[ string, { [attr: string]: string }, ...any ];
export type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
export type FrameSnapshot = {
// There was no callId in the original, we are intentionally regressing it.
callId: string;
snapshotName?: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
export type BrowserContextEventOptions = {
viewport?: { width: number, height: number },
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
export type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
browserName: string,
platform: string,
wallTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: 'javascript' | 'python' | 'java' | 'csharp',
testIdAttributeName?: string,
};
export type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
};
export type ActionTraceEvent = {
type: 'action' | 'event',
metadata: CallMetadata & { stack?: StackFrame[] },
};
export type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
export type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent;
@@ -0,0 +1,225 @@
/**
* 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 { Entry as ResourceSnapshot } from '@trace/har';
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
type Point = { x: number, y: number };
type Size = { width: number, height: number };
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
bi?: string,
m?: SerializedValue,
se?: SerializedValue,
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
type NodeSnapshot =
// Text node.
string |
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
[ [number, number] ] |
// Just node name.
[ string ] |
// Node name, attributes, child nodes.
// Unfortunately, we cannot make this type definition recursive, therefore "any".
[ string, { [attr: string]: string }, ...any ];
type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
type BrowserContextEventOptions = {
viewport?: Size,
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
browserName: string,
channel?: string,
platform: string,
wallTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
};
type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
};
type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
apiName: string;
class: string;
method: string;
params: Record<string, any>;
wallTime: number;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
type AfterActionTraceEventAttachment = {
name: string;
contentType: string;
path?: string;
sha1?: string;
base64?: string;
};
type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
log: string[];
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
result?: any;
};
type EventTraceEvent = {
type: 'event',
time: number;
class: string;
method: string;
params: any;
pageId?: string;
};
type ConsoleMessageTraceEvent = {
type: 'object';
class: string;
initializer: {
type: string,
text: string,
location: {
url: string,
lineNumber: number,
columnNumber: number,
},
};
guid: string;
};
type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
ConsoleMessageTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent |
StdioTraceEvent;
@@ -0,0 +1,225 @@
/**
* 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 { Entry as ResourceSnapshot } from '@trace/har';
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
type Point = { x: number, y: number };
type Size = { width: number, height: number };
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
bi?: string,
m?: SerializedValue,
se?: SerializedValue,
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
type NodeSnapshot =
// Text node.
string |
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
[ [number, number] ] |
// Just node name.
[ string ] |
// Node name, attributes, child nodes.
// Unfortunately, we cannot make this type definition recursive, therefore "any".
[ string, { [attr: string]: string }, ...any ];
type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
export type BrowserContextEventOptions = {
viewport?: Size,
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
export type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
browserName: string,
channel?: string,
platform: string,
wallTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
};
export type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
};
export type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
apiName: string;
class: string;
method: string;
params: Record<string, any>;
wallTime: number;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
export type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
export type AfterActionTraceEventAttachment = {
name: string;
contentType: string;
path?: string;
sha1?: string;
base64?: string;
};
export type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
log: string[];
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
result?: any;
};
export type EventTraceEvent = {
type: 'event',
time: number;
class: string;
method: string;
params: any;
pageId?: string;
};
export type ConsoleMessageTraceEvent = {
type: 'console';
time: number;
pageId?: string;
messageType: string,
text: string,
args?: { preview: string, value: any }[],
location: {
url: string,
lineNumber: number,
columnNumber: number,
},
};
export type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
export type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
export type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
export type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
ConsoleMessageTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent |
StdioTraceEvent;
@@ -0,0 +1,239 @@
/**
* 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 { Entry as ResourceSnapshot } from '@trace/har';
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
type Point = { x: number, y: number };
type Size = { width: number, height: number };
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
bi?: string,
m?: SerializedValue,
se?: SerializedValue,
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
type NodeSnapshot =
// Text node.
string |
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
[ [number, number] ] |
// Just node name.
[ string ] |
// Node name, attributes, child nodes.
// Unfortunately, we cannot make this type definition recursive, therefore "any".
[ string, { [attr: string]: string }, ...any ];
type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
export type BrowserContextEventOptions = {
viewport?: Size,
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
export type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
browserName: string,
channel?: string,
platform: string,
wallTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
};
export type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
};
export type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
apiName: string;
class: string;
method: string;
params: Record<string, any>;
wallTime: number;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
export type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
export type AfterActionTraceEventAttachment = {
name: string;
contentType: string;
path?: string;
sha1?: string;
base64?: string;
};
export type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
result?: any;
point?: Point;
};
export type LogTraceEvent = {
type: 'log',
callId: string;
time: number;
message: string;
};
export type EventTraceEvent = {
type: 'event',
time: number;
class: string;
method: string;
params: any;
pageId?: string;
};
export type ConsoleMessageTraceEvent = {
type: 'console';
time: number;
pageId?: string;
messageType: string,
text: string,
args?: { preview: string, value: any }[],
location: {
url: string,
lineNumber: number,
columnNumber: number,
},
};
export type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
export type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
export type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
export type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type ErrorTraceEvent = {
type: 'error';
message: string;
stack?: StackFrame[];
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
LogTraceEvent |
ConsoleMessageTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent |
StdioTraceEvent |
ErrorTraceEvent;
@@ -0,0 +1,260 @@
/**
* 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 { Entry as ResourceSnapshot } from '@trace/har';
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
type Point = { x: number, y: number };
export type Size = { width: number, height: number };
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type Binary = Buffer;
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
bi?: string,
ta?: {
b: Binary,
k: 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64',
},
e?: {
m: string,
n: string,
s: string,
},
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
// Text node.
type TextNodeSnapshot = string;
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
type SubtreeReferenceSnapshot = [ [number, number] ];
// Node name, and optional attributes and child nodes.
type NodeNameAttributesChildNodesSnapshot = [ string ] | [ string, Record<string, string>, ...NodeSnapshot[] ];
type NodeSnapshot =
TextNodeSnapshot |
SubtreeReferenceSnapshot |
NodeNameAttributesChildNodesSnapshot;
type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
wallTime?: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
type BrowserContextEventOptions = {
baseURL?: string,
viewport?: Size,
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
export type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
origin: 'testRunner' | 'library',
browserName: string,
channel?: string,
platform: string,
wallTime: number,
monotonicTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
contextId?: string,
};
export type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
frameSwapWallTime?: number,
};
export type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
apiName: string;
class: string;
method: string;
params: Record<string, any>;
stepId?: string;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
export type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
export type AfterActionTraceEventAttachment = {
name: string;
contentType: string;
path?: string;
sha1?: string;
base64?: string;
};
export type AfterActionTraceEventAnnotation = {
type: string,
description?: string
};
export type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
annotations?: AfterActionTraceEventAnnotation[];
result?: any;
point?: Point;
};
export type LogTraceEvent = {
type: 'log',
callId: string;
time: number;
message: string;
};
export type EventTraceEvent = {
type: 'event',
time: number;
class: string;
method: string;
params: any;
pageId?: string;
};
export type ConsoleMessageTraceEvent = {
type: 'console';
time: number;
pageId?: string;
messageType: string,
text: string,
args?: { preview: string, value: any }[],
location: {
url: string,
lineNumber: number,
columnNumber: number,
},
};
export type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
export type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
export type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
export type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type ErrorTraceEvent = {
type: 'error';
message: string;
stack?: StackFrame[];
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
LogTraceEvent |
ConsoleMessageTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent |
StdioTraceEvent |
ErrorTraceEvent;
@@ -0,0 +1,260 @@
/**
* 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 { Entry as ResourceSnapshot } from '@trace/har';
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
type Point = { x: number, y: number };
type Size = { width: number, height: number };
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type Binary = Buffer;
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
bi?: string,
ta?: {
b: Binary,
k: 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64',
},
e?: {
m: string,
n: string,
s: string,
},
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
// Text node.
type TextNodeSnapshot = string;
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
type SubtreeReferenceSnapshot = [ [number, number] ];
// Node name, and optional attributes and child nodes.
type NodeNameAttributesChildNodesSnapshot = [ string ] | [ string, Record<string, string>, ...NodeSnapshot[] ];
type NodeSnapshot =
TextNodeSnapshot |
SubtreeReferenceSnapshot |
NodeNameAttributesChildNodesSnapshot;
type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
wallTime?: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
type BrowserContextEventOptions = {
baseURL?: string,
viewport?: Size,
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
export type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
origin: 'testRunner' | 'library',
browserName: string,
channel?: string,
platform: string,
wallTime: number,
monotonicTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
contextId?: string,
};
export type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
frameSwapWallTime?: number,
};
export type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
title?: string;
class: string;
method: string;
params: Record<string, any>;
stepId?: string;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
export type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
export type AfterActionTraceEventAttachment = {
name: string;
contentType: string;
path?: string;
sha1?: string;
base64?: string;
};
export type AfterActionTraceEventAnnotation = {
type: string,
description?: string
};
export type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
annotations?: AfterActionTraceEventAnnotation[];
result?: any;
point?: Point;
};
export type LogTraceEvent = {
type: 'log',
callId: string;
time: number;
message: string;
};
export type EventTraceEvent = {
type: 'event',
time: number;
class: string;
method: string;
params: any;
pageId?: string;
};
export type ConsoleMessageTraceEvent = {
type: 'console';
time: number;
pageId?: string;
messageType: string,
text: string,
args?: { preview: string, value: any }[],
location: {
url: string,
lineNumber: number,
columnNumber: number,
},
};
export type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
export type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
export type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
export type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type ErrorTraceEvent = {
type: 'error';
message: string;
stack?: StackFrame[];
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
LogTraceEvent |
ConsoleMessageTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent |
StdioTraceEvent |
ErrorTraceEvent;
@@ -0,0 +1,22 @@
/**
* 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.
*/
export type Size = { width: number, height: number };
export type Point = { x: number, y: number };
export type Rect = Size & Point;
export type Quad = [ Point, Point, Point, Point ];
export type NameValue = { name: string, value: string };
export type HeadersArray = NameValue[];
@@ -0,0 +1,271 @@
/**
* 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 { isString } from './stringUtils';
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
const escapedChars = new Set(['$', '^', '+', '.', '*', '(', ')', '|', '\\', '?', '{', '}', '[', ']']);
export function globToRegexPattern(glob: string): string {
const tokens = ['^'];
let inGroup = false;
for (let i = 0; i < glob.length; ++i) {
const c = glob[i];
if (c === '\\' && i + 1 < glob.length) {
const char = glob[++i];
tokens.push(escapedChars.has(char) ? '\\' + char : char);
continue;
}
if (c === '*') {
const charBefore = glob[i - 1];
let starCount = 1;
while (glob[i + 1] === '*') {
starCount++;
i++;
}
if (starCount > 1) {
const charAfter = glob[i + 1];
// Match either /..something../ or /.
if (charAfter === '/') {
if (charBefore === '/')
tokens.push('((.+/)|)');
else
tokens.push('(.*/)');
++i;
} else {
tokens.push('(.*)');
}
} else {
tokens.push('([^/]*)');
}
continue;
}
switch (c) {
case '{':
inGroup = true;
tokens.push('(');
break;
case '}':
inGroup = false;
tokens.push(')');
break;
case ',':
if (inGroup) {
tokens.push('|');
break;
}
tokens.push('\\' + c);
break;
default:
tokens.push(escapedChars.has(c) ? '\\' + c : c);
}
}
tokens.push('$');
return tokens.join('');
}
function isRegExp(obj: any): obj is RegExp {
return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]';
}
export type URLMatch = string | RegExp | ((url: URL) => boolean) | URLPattern;
// URLPattern is not in @types/node@18, so we polyfill it ourselves
export type URLPattern = {
test(input: string | URL): boolean;
hash: string;
hostname: string;
password: string;
pathname: string;
port: string;
protocol: string;
search: string;
username: string;
};
// @ts-ignore URLPattern is not in @types/node yet
// eslint-disable-next-line no-restricted-globals
export const isURLPattern = (v: unknown): v is URLPattern => typeof globalThis.URLPattern === 'function' && v instanceof globalThis.URLPattern;
export function serializeURLPattern(v: URLPattern) {
return {
hash: v.hash,
hostname: v.hostname,
password: v.password,
pathname: v.pathname,
port: v.port,
protocol: v.protocol,
search: v.search,
username: v.username,
};
}
export type SerializedURLMatch = { glob?: string, regexSource?: string, regexFlags?: string, urlPattern?: ReturnType<typeof serializeURLPattern> };
export function serializeURLMatch(match: URLMatch): SerializedURLMatch | undefined {
if (isString(match))
return { glob: match };
if (isRegExp(match))
return { regexSource: match.source, regexFlags: match.flags };
if (isURLPattern(match))
return { urlPattern: serializeURLPattern(match) };
// Functions cannot be serialized
return undefined;
}
function deserializeURLPattern(v: ReturnType<typeof serializeURLPattern>): URLPattern | ((url: URL) => boolean) {
// Client is on Node 24+ and can use URLPattern, Server is not. Let's match all URLs on the server, they'll be filtered again on the client.
// @ts-ignore URLPattern is not in @types/node yet
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.URLPattern !== 'function')
return () => true;
// @ts-ignore URLPattern is not in @types/node yet
// eslint-disable-next-line no-restricted-globals
return new globalThis.URLPattern({
hash: v.hash,
hostname: v.hostname,
password: v.password,
pathname: v.pathname,
port: v.port,
protocol: v.protocol,
search: v.search,
username: v.username,
});
}
export function deserializeURLMatch(match: { glob?: string, regexSource?: string, regexFlags?: string, urlPattern?: ReturnType<typeof serializeURLPattern> }): URLMatch {
if (match.regexSource)
return new RegExp(match.regexSource, match.regexFlags);
if (match.urlPattern)
return deserializeURLPattern(match.urlPattern);
return match.glob!;
}
export function urlMatchesEqual(match1: URLMatch, match2: URLMatch) {
if (isRegExp(match1) && isRegExp(match2))
return match1.source === match2.source && match1.flags === match2.flags;
return match1 === match2;
}
export function urlMatches(baseURL: string | undefined, urlString: string, match: URLMatch | undefined, webSocketUrl?: boolean): boolean {
if (match === undefined || match === '')
return true;
if (isString(match))
match = new RegExp(resolveGlobToRegexPattern(baseURL, match, webSocketUrl));
if (isRegExp(match)) {
const r = match.test(urlString);
return r;
}
const url = parseURL(urlString);
if (!url)
return false;
if (isURLPattern(match))
return match.test(url.href);
if (typeof match !== 'function')
throw new Error('url parameter should be string, RegExp, URLPattern or function');
return match(url);
}
export function resolveGlobToRegexPattern(baseURL: string | undefined, glob: string, webSocketUrl?: boolean): string {
if (webSocketUrl)
baseURL = toWebSocketBaseUrl(baseURL);
glob = resolveGlobBase(baseURL, glob);
return globToRegexPattern(glob);
}
function toWebSocketBaseUrl(baseURL: string | undefined) {
// Allow http(s) baseURL to match ws(s) urls.
if (baseURL && /^https?:\/\//.test(baseURL))
baseURL = baseURL.replace(/^http/, 'ws');
return baseURL;
}
function resolveGlobBase(baseURL: string | undefined, match: string): string {
if (!match.startsWith('*')) {
const tokenMap = new Map<string, string>();
function mapToken(original: string, replacement: string) {
if (original.length === 0)
return '';
tokenMap.set(replacement, original);
return replacement;
}
// Escaped `\\?` behaves the same as `?` in our glob patterns.
match = match.replaceAll(/\\\\\?/g, '?');
// Special case about: URLs as they are not relative to baseURL
if (match.startsWith('about:') || match.startsWith('data:')
|| match.startsWith('chrome:') || match.startsWith('edge:')
|| match.startsWith('file:'))
return match;
// Glob symbols may be escaped in the URL and some of them such as ? affect resolution,
// so we replace them with safe components first.
const relativePath = match.split('/').map((token, index) => {
if (token === '.' || token === '..' || token === '')
return token;
// Handle special case of http*://, note that the new schema has to be
// a web schema so that slashes are properly inserted after domain.
if (index === 0 && token.endsWith(':')) {
// Replace any pattern with http:
if (token.indexOf('*') !== -1 || token.indexOf('{') !== -1)
return mapToken(token, 'http:');
// Preserve explicit schema as is as it may affect trailing slashes after domain.
return token;
}
const questionIndex = token.indexOf('?');
if (questionIndex === -1)
return mapToken(token, `$_${index}_$`);
const newPrefix = mapToken(token.substring(0, questionIndex), `$_${index}_$`);
const newSuffix = mapToken(token.substring(questionIndex), `?$_${index}_$`);
return newPrefix + newSuffix;
}).join('/');
const result = resolveBaseURL(baseURL, relativePath);
let resolved = result.resolved;
for (const [token, original] of tokenMap) {
const normalize = result.caseInsensitivePart?.includes(token);
resolved = resolved.replace(token, normalize ? original.toLowerCase() : original);
}
match = resolved;
}
return match;
}
function parseURL(url: string): URL | null {
try {
return new URL(url);
} catch (e) {
return null;
}
}
export function constructURLBasedOnBaseURL(baseURL: string | undefined, givenURL: string): string {
try {
return resolveBaseURL(baseURL, givenURL).resolved;
} catch (e) {
return givenURL;
}
}
function resolveBaseURL(baseURL: string | undefined, givenURL: string) {
try {
const url = new URL(givenURL, baseURL);
const resolved = url.toString();
// Schema and domain are case-insensitive.
const caseInsensitivePrefix = url.origin;
return { resolved, caseInsensitivePart: caseInsensitivePrefix };
} catch (e) {
return { resolved: givenURL };
}
}
@@ -0,0 +1,303 @@
/**
* 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.
*/
type TypedArrayKind = 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64';
export type SerializedValue =
undefined | boolean | number | string |
{ v: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0' } |
{ d: string } |
{ u: string } |
{ bi: string } |
{ e: { n: string, m: string, s: string } } |
{ r: { p: string, f: string } } |
{ a: SerializedValue[], id: number } |
{ o: { k: string, v: SerializedValue }[], id: number } |
{ ref: number } |
{ h: number } |
{ ta: { b: string, k: TypedArrayKind } } |
{ ab: { b: string } };
type HandleOrValue = { h: number } | { fallThrough: any };
type VisitorInfo = {
visited: Map<object, number>;
lastId: number;
};
function isRegExp(obj: any): obj is RegExp {
try {
return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]';
} catch (error) {
return false;
}
}
function isDate(obj: any): obj is Date {
try {
// eslint-disable-next-line no-restricted-globals
return obj instanceof Date || Object.prototype.toString.call(obj) === '[object Date]';
} catch (error) {
return false;
}
}
function isURL(obj: any): obj is URL {
try {
return obj instanceof URL || Object.prototype.toString.call(obj) === '[object URL]';
} catch (error) {
return false;
}
}
function isError(obj: any): obj is Error {
try {
return obj instanceof Error || (obj && Object.getPrototypeOf(obj)?.name === 'Error');
} catch (error) {
return false;
}
}
function isTypedArray(obj: any, constructor: Function): boolean {
try {
return obj instanceof constructor || Object.prototype.toString.call(obj) === `[object ${constructor.name}]`;
} catch (error) {
return false;
}
}
function isArrayBuffer(obj: any): obj is ArrayBuffer {
try {
return obj instanceof ArrayBuffer || Object.prototype.toString.call(obj) === '[object ArrayBuffer]';
} catch (error) {
return false;
}
}
const typedArrayConstructors: Record<TypedArrayKind, Function> = {
i8: Int8Array,
ui8: Uint8Array,
ui8c: Uint8ClampedArray,
i16: Int16Array,
ui16: Uint16Array,
i32: Int32Array,
ui32: Uint32Array,
// TODO: add Float16Array once it's in baseline
f32: Float32Array,
f64: Float64Array,
bi64: BigInt64Array,
bui64: BigUint64Array,
};
function typedArrayToBase64(array: any) {
/**
* Firefox does not support iterating over typed arrays, so we use `.toBase64`.
* Error: 'Accessing TypedArray data over Xrays is slow, and forbidden in order to encourage performant code. To copy TypedArrays across origin boundaries, consider using Components.utils.cloneInto().'
*/
if ('toBase64' in array)
return array.toBase64();
const binary = Array.from(new Uint8Array(array.buffer, array.byteOffset, array.byteLength)).map(b => String.fromCharCode(b)).join('');
return btoa(binary);
}
function base64ToTypedArray(base64: string, TypedArrayConstructor: any) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++)
bytes[i] = binary.charCodeAt(i);
return new TypedArrayConstructor(bytes.buffer);
}
export function parseEvaluationResultValue(value: SerializedValue, handles: any[] = [], refs: Map<number, object> = new Map()): any {
if (Object.is(value, undefined))
return undefined;
if (typeof value === 'object' && value) {
if ('ref' in value)
return refs.get(value.ref);
if ('v' in value) {
if (value.v === 'undefined')
return undefined;
if (value.v === 'null')
return null;
if (value.v === 'NaN')
return NaN;
if (value.v === 'Infinity')
return Infinity;
if (value.v === '-Infinity')
return -Infinity;
if (value.v === '-0')
return -0;
return undefined;
}
if ('d' in value) {
// eslint-disable-next-line no-restricted-globals
return new Date(value.d);
}
if ('u' in value)
return new URL(value.u);
if ('bi' in value)
return BigInt(value.bi);
if ('e' in value) {
const error = new Error(value.e.m);
error.name = value.e.n;
error.stack = value.e.s;
return error;
}
if ('r' in value)
return new RegExp(value.r.p, value.r.f);
if ('a' in value) {
const result: any[] = [];
refs.set(value.id, result);
for (const a of value.a)
result.push(parseEvaluationResultValue(a, handles, refs));
return result;
}
if ('o' in value) {
const result: any = {};
refs.set(value.id, result);
for (const { k, v } of value.o) {
if (k === '__proto__')
continue;
result[k] = parseEvaluationResultValue(v, handles, refs);
}
return result;
}
if ('h' in value)
return handles[value.h];
if ('ta' in value)
return base64ToTypedArray(value.ta.b, typedArrayConstructors[value.ta.k]);
if ('ab' in value)
return base64ToTypedArray(value.ab.b, Uint8Array).buffer;
}
return value;
}
export function serializeAsCallArgument(value: any, handleSerializer: (value: any) => HandleOrValue): SerializedValue {
return serialize(value, handleSerializer, { visited: new Map(), lastId: 0 });
}
function serialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue {
if (value && typeof value === 'object') {
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.Window === 'function' && value instanceof globalThis.Window)
return 'ref: <Window>';
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.Document === 'function' && value instanceof globalThis.Document)
return 'ref: <Document>';
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.Node === 'function' && value instanceof globalThis.Node)
return 'ref: <Node>';
}
return innerSerialize(value, handleSerializer, visitorInfo);
}
function innerSerialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue {
const result = handleSerializer(value);
if ('fallThrough' in result)
value = result.fallThrough;
else
return result;
if (typeof value === 'symbol')
return { v: 'undefined' };
if (Object.is(value, undefined))
return { v: 'undefined' };
if (Object.is(value, null))
return { v: 'null' };
if (Object.is(value, NaN))
return { v: 'NaN' };
if (Object.is(value, Infinity))
return { v: 'Infinity' };
if (Object.is(value, -Infinity))
return { v: '-Infinity' };
if (Object.is(value, -0))
return { v: '-0' };
if (typeof value === 'boolean')
return value;
if (typeof value === 'number')
return value;
if (typeof value === 'string')
return value;
if (typeof value === 'bigint')
return { bi: value.toString() };
if (isError(value)) {
let stack;
if (value.stack?.startsWith(value.name + ': ' + value.message)) {
// v8
stack = value.stack;
} else {
stack = `${value.name}: ${value.message}\n${value.stack}`;
}
return { e: { n: value.name, m: value.message, s: stack } };
}
if (isDate(value))
return { d: value.toJSON() };
if (isURL(value))
return { u: value.toJSON() };
if (isRegExp(value))
return { r: { p: value.source, f: value.flags } };
for (const [k, ctor] of Object.entries(typedArrayConstructors) as [TypedArrayKind, Function][]) {
if (isTypedArray(value, ctor))
return { ta: { b: typedArrayToBase64(value), k } };
}
if (isArrayBuffer(value))
return { ab: { b: typedArrayToBase64(new Uint8Array(value)) } };
const id = visitorInfo.visited.get(value);
if (id)
return { ref: id };
if (Array.isArray(value)) {
const a = [];
const id = ++visitorInfo.lastId;
visitorInfo.visited.set(value, id);
for (let i = 0; i < value.length; ++i)
a.push(serialize(value[i], handleSerializer, visitorInfo));
return { a, id };
}
if (typeof value === 'object') {
const o: { k: string, v: SerializedValue }[] = [];
const id = ++visitorInfo.lastId;
visitorInfo.visited.set(value, id);
for (const name of Object.keys(value)) {
let item;
try {
item = value[name];
} catch (e) {
continue; // native bindings will throw sometimes
}
if (name === 'toJSON' && typeof item === 'function')
o.push({ k: name, v: { o: [], id: 0 } });
else
o.push({ k: name, v: serialize(item, handleSerializer, visitorInfo) });
}
let jsonWrapper;
try {
// If Object.keys().length === 0 we fall back to toJSON if it exists
if (o.length === 0 && value.toJSON && typeof value.toJSON === 'function')
jsonWrapper = { value: value.toJSON() };
} catch (e) {
}
if (jsonWrapper)
return innerSerialize(jsonWrapper.value, handleSerializer, visitorInfo);
return { o, id };
}
}
@@ -0,0 +1,94 @@
/**
* 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.
*/
export function yamlEscapeKeyIfNeeded(str: string): string {
if (!yamlStringNeedsQuotes(str))
return str;
return `'` + str.replace(/'/g, `''`) + `'`;
}
export function yamlEscapeValueIfNeeded(str: string): string {
if (!yamlStringNeedsQuotes(str))
return str;
return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => {
switch (c) {
case '\\':
return '\\\\';
case '"':
return '\\"';
case '\b':
return '\\b';
case '\f':
return '\\f';
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
default:
const code = c.charCodeAt(0);
return '\\x' + code.toString(16).padStart(2, '0');
}
}) + '"';
}
function yamlStringNeedsQuotes(str: string): boolean {
if (str.length === 0)
return true;
// Strings with leading or trailing whitespace need quotes
if (/^\s|\s$/.test(str))
return true;
// Strings containing control characters need quotes
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
return true;
// Strings starting with '-' need quotes
if (/^-/.test(str))
return true;
// Strings containing ':' or '\n' followed by a space or at the end need quotes
if (/[\n:](\s|$)/.test(str))
return true;
// Strings containing '#' preceded by a space need quotes (comment indicator)
if (/\s#/.test(str))
return true;
// Strings that contain line breaks need quotes
if (/[\n\r]/.test(str))
return true;
// Strings starting with indicator characters or quotes need quotes
if (/^[&*\],?!>|@"'#%]/.test(str))
return true;
// Strings containing special characters that could cause ambiguity
if (/[{}`]/.test(str))
return true;
// YAML array starts with [
if (/^\[/.test(str))
return true;
// Non-string types recognized by YAML
if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase()))
return true;
return false;
}