Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
+579
@@ -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;
|
||||
}
|
||||
+20
@@ -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');
|
||||
}
|
||||
+66
@@ -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`;
|
||||
+267
@@ -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(', ');
|
||||
}
|
||||
+966
@@ -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;
|
||||
}
|
||||
+29
@@ -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,
|
||||
}));
|
||||
}
|
||||
+63
@@ -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';
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
+138
@@ -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);
|
||||
|
||||
// Catmull–Rom 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 };
|
||||
}
|
||||
+46
@@ -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';
|
||||
+97
@@ -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;
|
||||
}
|
||||
Vendored
+733
@@ -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;
|
||||
}
|
||||
+248
@@ -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;
|
||||
}
|
||||
+78
@@ -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('')}`;
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
}
|
||||
+122
@@ -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;
|
||||
}
|
||||
+450
@@ -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']
|
||||
]);
|
||||
+82
@@ -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();
|
||||
}
|
||||
}
|
||||
+123
@@ -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 },
|
||||
};
|
||||
Vendored
+76
@@ -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;
|
||||
}
|
||||
Vendored
+345
@@ -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);
|
||||
}
|
||||
+29
@@ -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');
|
||||
}
|
||||
Vendored
+441
@@ -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;
|
||||
}
|
||||
+50
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -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);
|
||||
}
|
||||
+195
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
||||
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, '');
|
||||
}
|
||||
+40
@@ -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
|
||||
+61
@@ -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 };
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
[*]
|
||||
+60
@@ -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 }[];
|
||||
};
|
||||
Vendored
+646
@@ -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>`);
|
||||
Vendored
+129
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+93
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+158
@@ -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: '',
|
||||
};
|
||||
}
|
||||
Vendored
+469
@@ -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: '',
|
||||
},
|
||||
};
|
||||
Vendored
+441
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+56
@@ -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 };
|
||||
}
|
||||
Vendored
+164
@@ -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;
|
||||
Vendored
+225
@@ -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;
|
||||
Vendored
+225
@@ -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;
|
||||
Vendored
+239
@@ -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;
|
||||
Vendored
+260
@@ -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;
|
||||
Vendored
+260
@@ -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;
|
||||
+22
@@ -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[];
|
||||
+271
@@ -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 };
|
||||
}
|
||||
}
|
||||
+303
@@ -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 };
|
||||
}
|
||||
}
|
||||
+94
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user