Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
@@ -0,0 +1,60 @@
|
||||
NomiFun browser engine — vendored Playwright InjectedScript
|
||||
============================================================
|
||||
|
||||
This directory (`crates/agent/nomi-browser-engine/injected/`) contains source
|
||||
code vendored from the Playwright project, plus a generated bundle of it.
|
||||
|
||||
Upstream
|
||||
--------
|
||||
- Project: Playwright
|
||||
- Owner: Microsoft Corporation
|
||||
- Repo: https://github.com/microsoft/playwright
|
||||
- License: Apache License, Version 2.0
|
||||
- Pinned commit: 4b1b9d681f8a7b1dffafa973ef705f28661d4607 (2026-04-14)
|
||||
- Vendored on: 2026-06-17
|
||||
|
||||
What is vendored
|
||||
----------------
|
||||
`vendor/injected/src/` — Playwright `packages/injected/src/**` (the in-page
|
||||
browser toolbox: InjectedScript + actionability,
|
||||
ARIA/role utils, aria-snapshot, selector engines).
|
||||
`vendor/isomorphic/` — Playwright `packages/isomorphic/**` (helpers that
|
||||
`@isomorphic/*` imports resolve to; esbuild inlines
|
||||
only the reachable subset at bundle time).
|
||||
`vendor/protocol/src/channels.d.ts`
|
||||
— Playwright `packages/protocol/src/channels.d.ts`,
|
||||
imported type-only (`import type ... from
|
||||
'@protocol/channels'`) and erased at build time.
|
||||
|
||||
Most vendored source files retain their original Apache-2.0 license header
|
||||
(Copyright (c) Microsoft Corporation). The following carry their own (preserved
|
||||
verbatim) third-party license headers:
|
||||
- `vendor/isomorphic/cssTokenizer.ts` — CC0 1.0 (public domain); original at
|
||||
https://github.com/tabatkins/parse-css.
|
||||
- `vendor/injected/src/clock.ts` — BSD-3-Clause (Christian Johansen / sinonjs),
|
||||
with Microsoft modifications.
|
||||
- `vendor/injected/src/recorder/clipPaths.ts` — MIT (Copyright (c) Microsoft).
|
||||
- `vendor/isomorphic/stackTrace.ts` — MIT (Isaac Z. Schlueter et al.).
|
||||
(clock.ts / clipPaths.ts / stackTrace.ts are not reachable from the
|
||||
`injectedScript.ts` entry and are tree-shaken out of `dist/injected.js`; they
|
||||
are vendored only because the package is vendored whole.)
|
||||
|
||||
Generated bundle
|
||||
----------------
|
||||
`dist/injected.js` is a GENERATED single-IIFE bundle of the above, produced by
|
||||
`build.sh` (esbuild via bun). It is checked in so that `cargo build` never
|
||||
depends on Node/bun — the Rust crate `include_str!`s it directly. It carries an
|
||||
attribution banner at its head. Do not edit it by hand; regenerate via
|
||||
`build.sh` after updating `vendor/`.
|
||||
|
||||
The bundle fixes `browserName='chromium'` at InjectedScript construction time
|
||||
(see `src/injected.rs`); Playwright's WebKit/Firefox runtime branches are thus
|
||||
dead-at-runtime. We vendor the package whole rather than hand-pruning, to keep
|
||||
upstream upgrades tractable.
|
||||
|
||||
Modifications
|
||||
-------------
|
||||
The vendored `.ts` sources are unmodified from upstream. The only transformation
|
||||
is the esbuild bundling step in `build.sh` (path-alias resolution mirroring
|
||||
Playwright's tsconfig `paths`, CSS-as-text loader, IIFE wrapping with a global
|
||||
name). A copy of the Apache-2.0 license text accompanies the repository.
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# 重新生成 vendored Playwright InjectedScript 的预编译 bundle。
|
||||
#
|
||||
# 这是**手动**步骤(DESIGN §24 / §6「build 管线」):产物 `dist/injected.js` 已 check-in
|
||||
# 进仓,`cargo build` 直接 `include_str!` 它,**绝不**在 cargo build 时依赖 Node/bun。
|
||||
# 仅在「升级 vendored PW 源」时才需重跑本脚本(换源 → 重 bundle → insta 快照 diff 人审)。
|
||||
#
|
||||
# 依赖:bun(自带 esbuild,`bunx esbuild`)。亦可换成 `npx esbuild`(需 Node + esbuild)。
|
||||
#
|
||||
# 取源:Playwright Apache-2.0,固定 commit 见 NOTICE。
|
||||
# vendor 布局(esbuild 别名解析,对齐 PW tsconfig paths):
|
||||
# @isomorphic/* -> vendor/isomorphic/*
|
||||
# @injected/* -> vendor/injected/src/*
|
||||
# @protocol/* -> vendor/protocol/src/* (channels.d.ts 是 type-only,编译期擦除)
|
||||
#
|
||||
# 关键 config(照搬 PW utils/generate_injected.js + 我们的混合架构需要):
|
||||
# --bundle 内联触达的 @isomorphic 子集(不抽子集,整包 vendor,esbuild tree-shake)
|
||||
# --format=iife 单 IIFE,注入到 Chromium isolated world 时整体 eval
|
||||
# --global-name=... IIFE 返回值挂到该全局,注入后 `new <global>.InjectedScript(...)`
|
||||
# --target=es2019 与 PW 一致
|
||||
# --loader:.css=text highlight.css 作字符串内联(PW inlineCSSPlugin 等价)
|
||||
# browserName 在运行时由 Rust 构造 InjectedScript 时固定为 'chromium'(WebKit/FF 分支
|
||||
# dead-at-runtime),故无需 esbuild define。
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
GLOBAL_NAME="__nomiInjectedExports"
|
||||
ENTRY="vendor/injected/src/injectedScript.ts"
|
||||
OUT="dist/injected.js"
|
||||
|
||||
echo "Building $OUT from $ENTRY (global=$GLOBAL_NAME)..."
|
||||
bunx esbuild "$ENTRY" \
|
||||
--bundle \
|
||||
--format=iife \
|
||||
--global-name="$GLOBAL_NAME" \
|
||||
--platform=browser \
|
||||
--target=es2019 \
|
||||
--alias:@isomorphic=./vendor/isomorphic \
|
||||
--alias:@injected=./vendor/injected/src \
|
||||
--alias:@protocol=./vendor/protocol/src \
|
||||
--loader:.css=text \
|
||||
--legal-comments=none \
|
||||
--outfile="$OUT.body"
|
||||
|
||||
# 在产物头部加 attribution banner(产物头部带署名,DESIGN「许可」要求)。
|
||||
{
|
||||
cat <<'BANNER'
|
||||
/*
|
||||
* NomiFun bundled Playwright InjectedScript.
|
||||
*
|
||||
* This file is a GENERATED bundle of vendored Playwright sources
|
||||
* (packages/injected + packages/isomorphic), Apache-2.0 licensed,
|
||||
* Copyright (c) Microsoft Corporation. See injected/NOTICE for the pinned
|
||||
* upstream commit and attribution. cssTokenizer is CC0 (public domain).
|
||||
*
|
||||
* DO NOT EDIT BY HAND. Regenerate via injected/build.sh after updating
|
||||
* injected/vendor/. browserName is fixed to 'chromium' at construction time
|
||||
* (WebKit/Firefox branches are dead-at-runtime).
|
||||
*/
|
||||
BANNER
|
||||
cat "$OUT.body"
|
||||
} > "$OUT"
|
||||
rm -f "$OUT.body"
|
||||
|
||||
echo "Wrote $OUT ($(wc -c < "$OUT") bytes). Exposes ${GLOBAL_NAME}.InjectedScript."
|
||||
@@ -0,0 +1,3 @@
|
||||
# Files in this folder are used in browser environment, they can only depend on isomorphic files.
|
||||
[*]
|
||||
@isomorphic/**
|
||||
@@ -0,0 +1,9 @@
|
||||
# Vendored Injected Sources
|
||||
|
||||
This directory contains helper JavaScript/TypeScript sources that are injected
|
||||
into browser pages by `nomi-browser-engine`.
|
||||
|
||||
The sources are vendored so the Rust crate can generate compile-time constants
|
||||
without a network step during normal builds. After updating the vendored files,
|
||||
run the generator documented in `utils/generate_injected` and review the
|
||||
generated Rust output before committing.
|
||||
@@ -0,0 +1,759 @@
|
||||
/**
|
||||
* 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 aria from '@isomorphic/ariaSnapshot';
|
||||
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
|
||||
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from '@isomorphic/yaml';
|
||||
|
||||
import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils';
|
||||
import * as roleUtils from './roleUtils';
|
||||
|
||||
export type AriaSnapshot = {
|
||||
root: aria.AriaNode;
|
||||
elements: Map<string, Element>;
|
||||
refs: Map<Element, string>;
|
||||
iframeRefs: string[];
|
||||
};
|
||||
|
||||
type AriaRef = {
|
||||
role: string;
|
||||
name: string;
|
||||
ref: string;
|
||||
};
|
||||
|
||||
let lastRef = 0;
|
||||
|
||||
export type AriaTreeOptions = {
|
||||
mode: 'ai' | 'default' | 'codegen' | 'autoexpect';
|
||||
refPrefix?: string;
|
||||
doNotRenderActive?: boolean;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
type InternalOptions = {
|
||||
visibility: 'aria' | 'ariaOrVisible' | 'ariaAndVisible',
|
||||
refs: 'all' | 'interactable' | 'none',
|
||||
refPrefix?: string,
|
||||
includeGenericRole?: boolean,
|
||||
renderCursorPointer?: boolean,
|
||||
renderActive?: boolean,
|
||||
renderStringsAsRegex?: boolean,
|
||||
};
|
||||
|
||||
function toInternalOptions(options: AriaTreeOptions): InternalOptions {
|
||||
if (options.mode === 'ai') {
|
||||
// For AI consumption.
|
||||
return {
|
||||
visibility: 'ariaOrVisible',
|
||||
refs: 'interactable',
|
||||
refPrefix: options.refPrefix,
|
||||
includeGenericRole: true,
|
||||
renderActive: !options.doNotRenderActive,
|
||||
renderCursorPointer: true,
|
||||
};
|
||||
}
|
||||
if (options.mode === 'autoexpect') {
|
||||
// To auto-generate assertions on visible elements.
|
||||
return { visibility: 'ariaAndVisible', refs: 'none' };
|
||||
}
|
||||
if (options.mode === 'codegen') {
|
||||
// To generate aria assertion with regex heurisitcs.
|
||||
return { visibility: 'aria', refs: 'none', renderStringsAsRegex: true };
|
||||
}
|
||||
// To match aria snapshot.
|
||||
return { visibility: 'aria', refs: 'none' };
|
||||
}
|
||||
|
||||
export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOptions): AriaSnapshot {
|
||||
const options = toInternalOptions(publicOptions);
|
||||
const visited = new Set<Node>();
|
||||
|
||||
const snapshot: AriaSnapshot = {
|
||||
root: { role: 'fragment', name: '', children: [], props: {}, box: computeBox(rootElement), receivesPointerEvents: true },
|
||||
elements: new Map<string, Element>(),
|
||||
refs: new Map<Element, string>(),
|
||||
iframeRefs: [],
|
||||
};
|
||||
setAriaNodeElement(snapshot.root, rootElement);
|
||||
|
||||
const visit = (ariaNode: aria.AriaNode, node: Node, parentElementVisible: boolean) => {
|
||||
if (visited.has(node))
|
||||
return;
|
||||
visited.add(node);
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
|
||||
if (!parentElementVisible)
|
||||
return;
|
||||
|
||||
const text = node.nodeValue;
|
||||
// <textarea>AAA</textarea> should not report AAA as a child of the textarea.
|
||||
if (ariaNode.role !== 'textbox' && text)
|
||||
ariaNode.children.push(node.nodeValue || '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE)
|
||||
return;
|
||||
|
||||
const element = node as Element;
|
||||
const isElementVisibleForAria = !roleUtils.isElementHiddenForAria(element);
|
||||
let visible = isElementVisibleForAria;
|
||||
if (options.visibility === 'ariaOrVisible')
|
||||
visible = isElementVisibleForAria || isElementVisible(element);
|
||||
if (options.visibility === 'ariaAndVisible')
|
||||
visible = isElementVisibleForAria && isElementVisible(element);
|
||||
|
||||
// Optimization: if we only consider aria visibility, we can skip child elements because
|
||||
// they will not be visible for aria as well.
|
||||
if (options.visibility === 'aria' && !visible)
|
||||
return;
|
||||
|
||||
const ariaChildren: Element[] = [];
|
||||
if (element.hasAttribute('aria-owns')) {
|
||||
const ids = element.getAttribute('aria-owns')!.split(/\s+/);
|
||||
for (const id of ids) {
|
||||
const ownedElement = rootElement.ownerDocument.getElementById(id);
|
||||
if (ownedElement)
|
||||
ariaChildren.push(ownedElement);
|
||||
}
|
||||
}
|
||||
|
||||
const childAriaNode = visible ? toAriaNode(element, options) : null;
|
||||
if (childAriaNode) {
|
||||
if (childAriaNode.ref) {
|
||||
snapshot.elements.set(childAriaNode.ref, element);
|
||||
snapshot.refs.set(element, childAriaNode.ref);
|
||||
if (childAriaNode.role === 'iframe')
|
||||
snapshot.iframeRefs.push(childAriaNode.ref);
|
||||
}
|
||||
ariaNode.children.push(childAriaNode);
|
||||
}
|
||||
processElement(childAriaNode || ariaNode, element, ariaChildren, visible);
|
||||
};
|
||||
|
||||
function processElement(ariaNode: aria.AriaNode, element: Element, ariaChildren: Element[], parentElementVisible: boolean) {
|
||||
// Surround every element with spaces for the sake of concatenated text nodes.
|
||||
const display = getElementComputedStyle(element)?.display || 'inline';
|
||||
const treatAsBlock = (display !== 'inline' || element.nodeName === 'BR') ? ' ' : '';
|
||||
if (treatAsBlock)
|
||||
ariaNode.children.push(treatAsBlock);
|
||||
|
||||
ariaNode.children.push(roleUtils.getCSSContent(element, '::before') || '');
|
||||
const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : [];
|
||||
if (assignedNodes.length) {
|
||||
for (const child of assignedNodes)
|
||||
visit(ariaNode, child, parentElementVisible);
|
||||
} else {
|
||||
for (let child = element.firstChild; child; child = child.nextSibling) {
|
||||
if (!(child as Element | Text).assignedSlot)
|
||||
visit(ariaNode, child, parentElementVisible);
|
||||
}
|
||||
if (element.shadowRoot) {
|
||||
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
|
||||
visit(ariaNode, child, parentElementVisible);
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of ariaChildren)
|
||||
visit(ariaNode, child, parentElementVisible);
|
||||
|
||||
ariaNode.children.push(roleUtils.getCSSContent(element, '::after') || '');
|
||||
|
||||
if (treatAsBlock)
|
||||
ariaNode.children.push(treatAsBlock);
|
||||
|
||||
if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0])
|
||||
ariaNode.children = [];
|
||||
|
||||
if (ariaNode.role === 'link' && element.hasAttribute('href')) {
|
||||
const href = element.getAttribute('href')!;
|
||||
ariaNode.props['url'] = href;
|
||||
}
|
||||
|
||||
if (ariaNode.role === 'textbox' && element.hasAttribute('placeholder') && element.getAttribute('placeholder') !== ariaNode.name) {
|
||||
const placeholder = element.getAttribute('placeholder')!;
|
||||
ariaNode.props['placeholder'] = placeholder;
|
||||
}
|
||||
}
|
||||
|
||||
roleUtils.beginAriaCaches();
|
||||
try {
|
||||
visit(snapshot.root, rootElement, true);
|
||||
} finally {
|
||||
roleUtils.endAriaCaches();
|
||||
}
|
||||
|
||||
normalizeStringChildren(snapshot.root);
|
||||
normalizeGenericRoles(snapshot.root);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function computeAriaRef(ariaNode: aria.AriaNode, options: InternalOptions) {
|
||||
if (options.refs === 'none')
|
||||
return;
|
||||
if (options.refs === 'interactable' && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents))
|
||||
return;
|
||||
|
||||
const element = ariaNodeElement(ariaNode);
|
||||
let ariaRef = (element as any)._ariaRef as AriaRef | undefined;
|
||||
if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) {
|
||||
ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: (options.refPrefix ?? '') + 'e' + (++lastRef) };
|
||||
(element as any)._ariaRef = ariaRef;
|
||||
}
|
||||
ariaNode.ref = ariaRef.ref;
|
||||
}
|
||||
|
||||
function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | null {
|
||||
const active = element.ownerDocument.activeElement === element;
|
||||
if (element.nodeName === 'IFRAME') {
|
||||
const ariaNode: aria.AriaNode = {
|
||||
role: 'iframe',
|
||||
name: '',
|
||||
children: [],
|
||||
props: {},
|
||||
box: computeBox(element),
|
||||
receivesPointerEvents: true,
|
||||
active
|
||||
};
|
||||
setAriaNodeElement(ariaNode, element);
|
||||
computeAriaRef(ariaNode, options);
|
||||
return ariaNode;
|
||||
}
|
||||
|
||||
const defaultRole = options.includeGenericRole ? 'generic' : null;
|
||||
const role = roleUtils.getAriaRole(element) ?? defaultRole;
|
||||
if (!role || role === 'presentation' || role === 'none')
|
||||
return null;
|
||||
|
||||
const name = normalizeWhiteSpace(roleUtils.getElementAccessibleName(element, false) || '');
|
||||
const receivesPointerEvents = roleUtils.receivesPointerEvents(element);
|
||||
|
||||
const box = computeBox(element);
|
||||
if (role === 'generic' && box.inline && element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE)
|
||||
return null;
|
||||
|
||||
const result: aria.AriaNode = {
|
||||
role,
|
||||
name,
|
||||
children: [],
|
||||
props: {},
|
||||
box,
|
||||
receivesPointerEvents,
|
||||
active
|
||||
};
|
||||
setAriaNodeElement(result, element);
|
||||
computeAriaRef(result, options);
|
||||
|
||||
if (roleUtils.kAriaCheckedRoles.includes(role))
|
||||
result.checked = roleUtils.getAriaChecked(element);
|
||||
|
||||
if (roleUtils.kAriaDisabledRoles.includes(role))
|
||||
result.disabled = roleUtils.getAriaDisabled(element);
|
||||
|
||||
if (roleUtils.kAriaExpandedRoles.includes(role))
|
||||
result.expanded = roleUtils.getAriaExpanded(element);
|
||||
|
||||
if (roleUtils.kAriaLevelRoles.includes(role))
|
||||
result.level = roleUtils.getAriaLevel(element);
|
||||
|
||||
if (roleUtils.kAriaPressedRoles.includes(role))
|
||||
result.pressed = roleUtils.getAriaPressed(element);
|
||||
|
||||
if (roleUtils.kAriaSelectedRoles.includes(role))
|
||||
result.selected = roleUtils.getAriaSelected(element);
|
||||
|
||||
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
||||
if (element.type !== 'checkbox' && element.type !== 'radio' && element.type !== 'file')
|
||||
result.children = [element.value];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeGenericRoles(node: aria.AriaNode) {
|
||||
const normalizeChildren = (node: aria.AriaNode) => {
|
||||
const result: (aria.AriaNode | string)[] = [];
|
||||
for (const child of node.children || []) {
|
||||
if (typeof child === 'string') {
|
||||
result.push(child);
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeChildren(child);
|
||||
result.push(...normalized);
|
||||
}
|
||||
|
||||
// Only remove generic that encloses one element, logical grouping still makes sense, even if it is not ref-able.
|
||||
const removeSelf = node.role === 'generic' && !node.name && result.length <= 1 && result.every(c => typeof c !== 'string' && !!c.ref);
|
||||
if (removeSelf)
|
||||
return result;
|
||||
node.children = result;
|
||||
return [node];
|
||||
};
|
||||
|
||||
normalizeChildren(node);
|
||||
}
|
||||
|
||||
function normalizeStringChildren(rootA11yNode: aria.AriaNode) {
|
||||
const flushChildren = (buffer: string[], normalizedChildren: (aria.AriaNode | string)[]) => {
|
||||
if (!buffer.length)
|
||||
return;
|
||||
const text = normalizeWhiteSpace(buffer.join(''));
|
||||
if (text)
|
||||
normalizedChildren.push(text);
|
||||
buffer.length = 0;
|
||||
};
|
||||
|
||||
const visit = (ariaNode: aria.AriaNode) => {
|
||||
const normalizedChildren: (aria.AriaNode | string)[] = [];
|
||||
const buffer: string[] = [];
|
||||
for (const child of ariaNode.children || []) {
|
||||
if (typeof child === 'string') {
|
||||
buffer.push(child);
|
||||
} else {
|
||||
flushChildren(buffer, normalizedChildren);
|
||||
visit(child);
|
||||
normalizedChildren.push(child);
|
||||
}
|
||||
}
|
||||
flushChildren(buffer, normalizedChildren);
|
||||
ariaNode.children = normalizedChildren.length ? normalizedChildren : [];
|
||||
if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name)
|
||||
ariaNode.children = [];
|
||||
};
|
||||
visit(rootA11yNode);
|
||||
}
|
||||
|
||||
function matchesStringOrRegex(text: string, template: aria.AriaRegex | string | undefined): boolean {
|
||||
if (!template)
|
||||
return true;
|
||||
if (!text)
|
||||
return false;
|
||||
if (typeof template === 'string')
|
||||
return text === template;
|
||||
return !!text.match(new RegExp(template.pattern));
|
||||
}
|
||||
|
||||
function matchesTextValue(text: string, template: aria.AriaTextValue | undefined) {
|
||||
if (!template?.normalized)
|
||||
return true;
|
||||
if (!text)
|
||||
return false;
|
||||
if (text === template.normalized)
|
||||
return true;
|
||||
// Accept pattern as value.
|
||||
if (text === template.raw)
|
||||
return true;
|
||||
|
||||
const regex = cachedRegex(template);
|
||||
if (regex)
|
||||
return !!text.match(regex);
|
||||
return false;
|
||||
}
|
||||
|
||||
const cachedRegexSymbol = Symbol('cachedRegex');
|
||||
|
||||
function cachedRegex(template: aria.AriaTextValue): RegExp | null {
|
||||
if ((template as any)[cachedRegexSymbol] !== undefined)
|
||||
return (template as any)[cachedRegexSymbol];
|
||||
|
||||
const { raw } = template;
|
||||
const canBeRegex = raw.startsWith('/') && raw.endsWith('/') && raw.length > 1;
|
||||
let regex: RegExp | null;
|
||||
try {
|
||||
regex = canBeRegex ? new RegExp(raw.slice(1, -1)) : null;
|
||||
} catch (e) {
|
||||
regex = null;
|
||||
}
|
||||
(template as any)[cachedRegexSymbol] = regex;
|
||||
return regex;
|
||||
}
|
||||
|
||||
export type MatcherReceived = {
|
||||
raw: string;
|
||||
regex: string;
|
||||
};
|
||||
|
||||
export function matchesExpectAriaTemplate(rootElement: Element, template: aria.AriaTemplateNode): { matches: aria.AriaNode[], received: MatcherReceived } {
|
||||
const snapshot = generateAriaTree(rootElement, { mode: 'default' });
|
||||
const matches = matchesNodeDeep(snapshot.root, template, false, false);
|
||||
return {
|
||||
matches,
|
||||
received: {
|
||||
raw: renderAriaTree(snapshot, { mode: 'default' }).text,
|
||||
regex: renderAriaTree(snapshot, { mode: 'codegen' }).text,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getAllElementsMatchingExpectAriaTemplate(rootElement: Element, template: aria.AriaTemplateNode): Element[] {
|
||||
const root = generateAriaTree(rootElement, { mode: 'default' }).root;
|
||||
const matches = matchesNodeDeep(root, template, true, false);
|
||||
return matches.map(n => ariaNodeElement(n));
|
||||
}
|
||||
|
||||
function matchesNode(node: aria.AriaNode | string, template: aria.AriaTemplateNode, isDeepEqual: boolean): boolean {
|
||||
if (typeof node === 'string' && template.kind === 'text')
|
||||
return matchesTextValue(node, template.text);
|
||||
|
||||
if (node === null || typeof node !== 'object' || template.kind !== 'role')
|
||||
return false;
|
||||
|
||||
if (template.role !== 'fragment' && template.role !== node.role)
|
||||
return false;
|
||||
if (template.checked !== undefined && template.checked !== node.checked)
|
||||
return false;
|
||||
if (template.disabled !== undefined && template.disabled !== node.disabled)
|
||||
return false;
|
||||
if (template.expanded !== undefined && template.expanded !== node.expanded)
|
||||
return false;
|
||||
if (template.level !== undefined && template.level !== node.level)
|
||||
return false;
|
||||
if (template.pressed !== undefined && template.pressed !== node.pressed)
|
||||
return false;
|
||||
if (template.selected !== undefined && template.selected !== node.selected)
|
||||
return false;
|
||||
if (!matchesStringOrRegex(node.name, template.name))
|
||||
return false;
|
||||
if (!matchesTextValue(node.props.url, template.props?.url))
|
||||
return false;
|
||||
|
||||
// Proceed based on the container mode.
|
||||
if (template.containerMode === 'contain')
|
||||
return containsList(node.children || [], template.children || []);
|
||||
if (template.containerMode === 'equal')
|
||||
return listEqual(node.children || [], template.children || [], false);
|
||||
if (template.containerMode === 'deep-equal' || isDeepEqual)
|
||||
return listEqual(node.children || [], template.children || [], true);
|
||||
return containsList(node.children || [], template.children || []);
|
||||
}
|
||||
|
||||
function listEqual(children: (aria.AriaNode | string)[], template: aria.AriaTemplateNode[], isDeepEqual: boolean): boolean {
|
||||
if (template.length !== children.length)
|
||||
return false;
|
||||
for (let i = 0; i < template.length; ++i) {
|
||||
if (!matchesNode(children[i], template[i], isDeepEqual))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function containsList(children: (aria.AriaNode | string)[], template: aria.AriaTemplateNode[]): boolean {
|
||||
if (template.length > children.length)
|
||||
return false;
|
||||
const cc = children.slice();
|
||||
const tt = template.slice();
|
||||
for (const t of tt) {
|
||||
let c = cc.shift();
|
||||
while (c) {
|
||||
if (matchesNode(c, t, false))
|
||||
break;
|
||||
c = cc.shift();
|
||||
}
|
||||
if (!c)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesNodeDeep(root: aria.AriaNode, template: aria.AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean): aria.AriaNode[] {
|
||||
const results: aria.AriaNode[] = [];
|
||||
const visit = (node: aria.AriaNode | string, parent: aria.AriaNode | null): boolean => {
|
||||
if (matchesNode(node, template, isDeepEqual)) {
|
||||
const result = typeof node === 'string' ? parent : node;
|
||||
if (result)
|
||||
results.push(result);
|
||||
return !collectAll;
|
||||
}
|
||||
if (typeof node === 'string')
|
||||
return false;
|
||||
for (const child of node.children || []) {
|
||||
if (visit(child, node))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
visit(root, null);
|
||||
return results;
|
||||
}
|
||||
|
||||
function buildByRefMap(root: aria.AriaNode | undefined, map: Map<string | undefined, aria.AriaNode> = new Map()): Map<string | undefined, aria.AriaNode> {
|
||||
if (root?.ref)
|
||||
map.set(root.ref, root);
|
||||
for (const child of root?.children || []) {
|
||||
if (typeof child !== 'string')
|
||||
buildByRefMap(child, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnapshot | undefined): Map<aria.AriaNode, 'skip' | 'same' | 'changed'> {
|
||||
const previousByRef = buildByRefMap(previousSnapshot?.root);
|
||||
const result = new Map<aria.AriaNode, 'skip' | 'same' | 'changed'>();
|
||||
|
||||
// Returns whether ariaNode is the same as previousNode.
|
||||
const visit = (ariaNode: aria.AriaNode, previousNode: aria.AriaNode | undefined): boolean => {
|
||||
let same: boolean = ariaNode.children.length === previousNode?.children.length && aria.ariaNodesEqual(ariaNode, previousNode);
|
||||
let canBeSkipped = same;
|
||||
|
||||
for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) {
|
||||
const child = ariaNode.children[childIndex];
|
||||
const previousChild = previousNode?.children[childIndex];
|
||||
if (typeof child === 'string') {
|
||||
same &&= child === previousChild;
|
||||
canBeSkipped &&= child === previousChild;
|
||||
} else {
|
||||
let previous = typeof previousChild !== 'string' ? previousChild : undefined;
|
||||
if (child.ref)
|
||||
previous = previousByRef.get(child.ref);
|
||||
const sameChild = visit(child, previous);
|
||||
// New child, different order of children, or changed child with no ref -
|
||||
// we have to include this node to list children in the right order.
|
||||
if (!previous || (!sameChild && !child.ref) || (previous !== previousChild))
|
||||
canBeSkipped = false;
|
||||
same &&= (sameChild && previous === previousChild);
|
||||
}
|
||||
}
|
||||
|
||||
result.set(ariaNode, same ? 'same' : (canBeSkipped ? 'skip' : 'changed'));
|
||||
return same;
|
||||
};
|
||||
|
||||
visit(ariaSnapshot.root, previousByRef.get(previousSnapshot?.root?.ref));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Chooses only the changed parts of the snapshot and returns them as new roots.
|
||||
function filterSnapshotDiff(nodes: (aria.AriaNode | string)[], statusMap: Map<aria.AriaNode, 'skip' | 'same' | 'changed'>): (aria.AriaNode | string)[] {
|
||||
const result: (aria.AriaNode | string)[] = [];
|
||||
|
||||
const visit = (ariaNode: aria.AriaNode) => {
|
||||
const status = statusMap.get(ariaNode);
|
||||
if (status === 'same') {
|
||||
// No need to render unchanged root at all.
|
||||
} else if (status === 'skip') {
|
||||
// Only render changed children.
|
||||
for (const child of ariaNode.children) {
|
||||
if (typeof child !== 'string')
|
||||
visit(child);
|
||||
}
|
||||
} else {
|
||||
// Render this node's subtree.
|
||||
result.push(ariaNode);
|
||||
}
|
||||
};
|
||||
|
||||
for (const node of nodes) {
|
||||
if (typeof node === 'string')
|
||||
result.push(node);
|
||||
else
|
||||
visit(node);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function indent(depth: number): string {
|
||||
return ' '.repeat(depth);
|
||||
}
|
||||
|
||||
export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previousSnapshot?: AriaSnapshot): { text: string, iframeDepths: Record<string, number> } {
|
||||
const options = toInternalOptions(publicOptions);
|
||||
const lines: string[] = [];
|
||||
const iframeDepths: Record<string, number> = {};
|
||||
const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true;
|
||||
const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str;
|
||||
|
||||
// Do not render the root fragment, just its children.
|
||||
let nodesToRender = ariaSnapshot.root.role === 'fragment' ? ariaSnapshot.root.children : [ariaSnapshot.root];
|
||||
|
||||
const statusMap = compareSnapshots(ariaSnapshot, previousSnapshot);
|
||||
if (previousSnapshot)
|
||||
nodesToRender = filterSnapshotDiff(nodesToRender, statusMap);
|
||||
|
||||
const visitText = (text: string, depth: number) => {
|
||||
if (publicOptions.depth && depth > publicOptions.depth)
|
||||
return;
|
||||
const escaped = yamlEscapeValueIfNeeded(renderString(text));
|
||||
if (escaped)
|
||||
lines.push(indent(depth) + '- text: ' + escaped);
|
||||
};
|
||||
|
||||
const createKey = (ariaNode: aria.AriaNode, renderCursorPointer: boolean): string => {
|
||||
let key = ariaNode.role;
|
||||
// Yaml has a limit of 1024 characters per key, and we leave some space for role and attributes.
|
||||
if (ariaNode.name && ariaNode.name.length <= 900) {
|
||||
const name = renderString(ariaNode.name);
|
||||
if (name) {
|
||||
const stringifiedName = name.startsWith('/') && name.endsWith('/') ? name : JSON.stringify(name);
|
||||
key += ' ' + stringifiedName;
|
||||
}
|
||||
}
|
||||
if (ariaNode.checked === 'mixed')
|
||||
key += ` [checked=mixed]`;
|
||||
if (ariaNode.checked === true)
|
||||
key += ` [checked]`;
|
||||
if (ariaNode.disabled)
|
||||
key += ` [disabled]`;
|
||||
if (ariaNode.expanded)
|
||||
key += ` [expanded]`;
|
||||
if (ariaNode.active && options.renderActive)
|
||||
key += ` [active]`;
|
||||
if (ariaNode.level)
|
||||
key += ` [level=${ariaNode.level}]`;
|
||||
if (ariaNode.pressed === 'mixed')
|
||||
key += ` [pressed=mixed]`;
|
||||
if (ariaNode.pressed === true)
|
||||
key += ` [pressed]`;
|
||||
if (ariaNode.selected === true)
|
||||
key += ` [selected]`;
|
||||
|
||||
if (ariaNode.ref) {
|
||||
key += ` [ref=${ariaNode.ref}]`;
|
||||
if (renderCursorPointer && aria.hasPointerCursor(ariaNode))
|
||||
key += ' [cursor=pointer]';
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const getSingleInlinedTextChild = (ariaNode: aria.AriaNode | undefined): string | undefined => {
|
||||
return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined;
|
||||
};
|
||||
|
||||
const visit = (ariaNode: aria.AriaNode, depth: number, renderCursorPointer: boolean) => {
|
||||
if (publicOptions.depth && depth > publicOptions.depth)
|
||||
return;
|
||||
|
||||
if (ariaNode.role === 'iframe' && ariaNode.ref)
|
||||
iframeDepths[ariaNode.ref] = depth;
|
||||
|
||||
// Replace the whole subtree with a single reference when possible.
|
||||
if (statusMap.get(ariaNode) === 'same' && ariaNode.ref) {
|
||||
lines.push(indent(depth) + `- ref=${ariaNode.ref} [unchanged]`);
|
||||
return;
|
||||
}
|
||||
|
||||
// When producing a diff, add <changed> marker to all diff roots.
|
||||
const isDiffRoot = !!previousSnapshot && !depth;
|
||||
const escapedKey = indent(depth) + '- ' + (isDiffRoot ? '<changed> ' : '') + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer));
|
||||
const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode);
|
||||
const isAtDepthLimit = !!publicOptions.depth && depth === publicOptions.depth;
|
||||
const hasNoChildren = !singleInlinedTextChild && (!ariaNode.children.length || isAtDepthLimit);
|
||||
|
||||
if (hasNoChildren && !Object.keys(ariaNode.props).length) {
|
||||
// Leaf node without children.
|
||||
lines.push(escapedKey);
|
||||
} else if (singleInlinedTextChild !== undefined) {
|
||||
// Leaf node with just some text inside.
|
||||
const shouldInclude = includeText(ariaNode, singleInlinedTextChild);
|
||||
if (shouldInclude)
|
||||
lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(renderString(singleInlinedTextChild)));
|
||||
else
|
||||
lines.push(escapedKey);
|
||||
} else {
|
||||
// Node with (optional) props and some children.
|
||||
lines.push(escapedKey + ':');
|
||||
for (const [name, value] of Object.entries(ariaNode.props))
|
||||
lines.push(indent(depth + 1) + '- /' + name + ': ' + yamlEscapeValueIfNeeded(value));
|
||||
|
||||
const inCursorPointer = !!ariaNode.ref && renderCursorPointer && aria.hasPointerCursor(ariaNode);
|
||||
for (const child of ariaNode.children) {
|
||||
if (typeof child === 'string')
|
||||
visitText(includeText(ariaNode, child) ? child : '', depth + 1);
|
||||
else
|
||||
visit(child, depth + 1, renderCursorPointer && !inCursorPointer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const nodeToRender of nodesToRender) {
|
||||
if (typeof nodeToRender === 'string')
|
||||
visitText(nodeToRender, 0);
|
||||
else
|
||||
visit(nodeToRender, 0, !!options.renderCursorPointer);
|
||||
}
|
||||
return { text: lines.join('\n'), iframeDepths };
|
||||
}
|
||||
|
||||
function convertToBestGuessRegex(text: string): string {
|
||||
const dynamicContent = [
|
||||
// 550e8400-e29b-41d4-a716-446655440000
|
||||
{ regex: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/, replacement: '[0-9a-fA-F-]+' },
|
||||
// 2mb
|
||||
{ regex: /\b[\d,.]+[bkmBKM]+\b/, replacement: '[\\d,.]+[bkmBKM]+' },
|
||||
// 2ms, 20s
|
||||
{ regex: /\b\d+[hmsp]+\b/, replacement: '\\d+[hmsp]+' },
|
||||
{ regex: /\b[\d,.]+[hmsp]+\b/, replacement: '[\\d,.]+[hmsp]+' },
|
||||
// Do not replace single digits with regex by default.
|
||||
// 2+ digits: [Issue 22, 22.3, 2.33, 2,333]
|
||||
{ regex: /\b\d+,\d+\b/, replacement: '\\d+,\\d+' },
|
||||
{ regex: /\b\d+\.\d{2,}\b/, replacement: '\\d+\\.\\d+' },
|
||||
{ regex: /\b\d{2,}\.\d+\b/, replacement: '\\d+\\.\\d+' },
|
||||
{ regex: /\b\d{2,}\b/, replacement: '\\d+' },
|
||||
];
|
||||
|
||||
let pattern = '';
|
||||
let lastIndex = 0;
|
||||
|
||||
const combinedRegex = new RegExp(dynamicContent.map(r => '(' + r.regex.source + ')').join('|'), 'g');
|
||||
text.replace(combinedRegex, (match, ...args) => {
|
||||
const offset = args[args.length - 2];
|
||||
const groups = args.slice(0, -2);
|
||||
pattern += escapeRegExp(text.slice(lastIndex, offset));
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
if (groups[i]) {
|
||||
const { replacement } = dynamicContent[i];
|
||||
pattern += replacement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
lastIndex = offset + match.length;
|
||||
return match;
|
||||
});
|
||||
if (!pattern)
|
||||
return text;
|
||||
|
||||
pattern += escapeRegExp(text.slice(lastIndex));
|
||||
return String(new RegExp(pattern));
|
||||
}
|
||||
|
||||
function textContributesInfo(node: aria.AriaNode, text: string): boolean {
|
||||
if (!text.length)
|
||||
return false;
|
||||
|
||||
if (!node.name)
|
||||
return true;
|
||||
|
||||
// Figure out if text adds any value. "longestCommonSubstring" is expensive, so limit strings length.
|
||||
const substr = (text.length <= 200 && node.name.length <= 200) ? longestCommonSubstring(text, node.name) : '';
|
||||
let filtered = text;
|
||||
while (substr && filtered.includes(substr))
|
||||
filtered = filtered.replace(substr, '');
|
||||
return filtered.trim().length / text.length > 0.1;
|
||||
}
|
||||
|
||||
const elementSymbol = Symbol('element');
|
||||
|
||||
function ariaNodeElement(ariaNode: aria.AriaNode): Element {
|
||||
return (ariaNode as any)[elementSymbol];
|
||||
}
|
||||
|
||||
function setAriaNodeElement(ariaNode: aria.AriaNode, element: Element) {
|
||||
(ariaNode as any)[elementSymbol] = element;
|
||||
}
|
||||
|
||||
export function findNewElement(from: aria.AriaNode | undefined, to: aria.AriaNode): Element | undefined {
|
||||
const node = aria.findNewNode(from, to);
|
||||
return node ? ariaNodeElement(node) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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 { serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers';
|
||||
|
||||
import type { SerializedValue } from '@isomorphic/utilityScriptSerializers';
|
||||
|
||||
export type BindingPayload = {
|
||||
name: string;
|
||||
seq: number;
|
||||
serializedArgs?: SerializedValue[],
|
||||
};
|
||||
|
||||
type BindingData = {
|
||||
callbacks: Map<number, { resolve: (value: any) => void, reject: (error: Error) => void }>;
|
||||
lastSeq: number;
|
||||
handles: Map<number, any>;
|
||||
removed: boolean;
|
||||
};
|
||||
|
||||
export class BindingsController {
|
||||
private _global: typeof globalThis;
|
||||
private _globalBindingName: string;
|
||||
private _bindings = new Map<string, BindingData>();
|
||||
|
||||
constructor(global: typeof globalThis, globalBindingName: string) {
|
||||
this._global = global;
|
||||
this._globalBindingName = globalBindingName;
|
||||
}
|
||||
|
||||
addBinding(bindingName: string, needsHandle: boolean) {
|
||||
const data: BindingData = {
|
||||
callbacks: new Map(),
|
||||
lastSeq: 0,
|
||||
handles: new Map(),
|
||||
removed: false,
|
||||
};
|
||||
this._bindings.set(bindingName, data);
|
||||
(this._global as any)[bindingName] = (...args: any[]) => {
|
||||
if (data.removed)
|
||||
throw new Error(`binding "${bindingName}" has been removed`);
|
||||
if (needsHandle && args.slice(1).some(arg => arg !== undefined))
|
||||
throw new Error(`exposeBindingHandle supports a single argument, ${args.length} received`);
|
||||
const seq = ++data.lastSeq;
|
||||
const promise = new Promise((resolve, reject) => data.callbacks.set(seq, { resolve, reject }));
|
||||
let payload: BindingPayload;
|
||||
if (needsHandle) {
|
||||
data.handles.set(seq, args[0]);
|
||||
payload = { name: bindingName, seq };
|
||||
} else {
|
||||
const serializedArgs = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
serializedArgs[i] = serializeAsCallArgument(args[i], v => {
|
||||
return { fallThrough: v };
|
||||
});
|
||||
}
|
||||
payload = { name: bindingName, seq, serializedArgs };
|
||||
}
|
||||
(this._global as any)[this._globalBindingName](JSON.stringify(payload));
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
removeBinding(bindingName: string) {
|
||||
const data = this._bindings.get(bindingName);
|
||||
if (data)
|
||||
data.removed = true;
|
||||
this._bindings.delete(bindingName);
|
||||
delete (this._global as any)[bindingName];
|
||||
}
|
||||
|
||||
takeBindingHandle(arg: { name: string, seq: number }) {
|
||||
const handles = this._bindings.get(arg.name)!.handles;
|
||||
const handle = handles.get(arg.seq);
|
||||
handles.delete(arg.seq);
|
||||
return handle;
|
||||
}
|
||||
|
||||
deliverBindingResult(arg: { name: string, seq: number, result?: any, error?: any }) {
|
||||
const callbacks = this._bindings.get(arg.name)!.callbacks;
|
||||
if ('error' in arg)
|
||||
callbacks.get(arg.seq)!.reject(arg.error);
|
||||
else
|
||||
callbacks.get(arg.seq)!.resolve(arg.result);
|
||||
callbacks.delete(arg.seq);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2014, Christian Johansen, christian@cjohansen.no. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
import type { Builtins } from './utilityScript';
|
||||
|
||||
export type ClockConfig = {
|
||||
now?: number;
|
||||
};
|
||||
|
||||
export type InstallConfig = ClockConfig & {
|
||||
toFake?: (keyof Builtins)[];
|
||||
browserName?: string;
|
||||
};
|
||||
|
||||
enum TimerType {
|
||||
Timeout = 'Timeout',
|
||||
Interval = 'Interval',
|
||||
Immediate = 'Immediate',
|
||||
AnimationFrame = 'AnimationFrame',
|
||||
IdleCallback = 'IdleCallback',
|
||||
}
|
||||
|
||||
type Timer = {
|
||||
type: TimerType;
|
||||
func: TimerHandler;
|
||||
args: any[];
|
||||
delay: number;
|
||||
callAt: Ticks;
|
||||
createdAt: Ticks;
|
||||
id: number;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
interface Embedder {
|
||||
dateNow(): number;
|
||||
performanceNow(): EmbedderTicks;
|
||||
setTimeout(task: () => void, timeout?: number): () => void;
|
||||
setInterval(task: () => void, delay: number): () => void;
|
||||
}
|
||||
|
||||
type Ticks = number & { readonly __brand: 'Ticks' };
|
||||
type EmbedderTicks = number & { readonly __brand: 'EmbedderTicks' };
|
||||
type WallTime = number & { readonly __brand: 'WallTime' };
|
||||
|
||||
type Time = {
|
||||
time: WallTime;
|
||||
ticks: Ticks;
|
||||
isFixedTime: boolean;
|
||||
origin: WallTime;
|
||||
};
|
||||
|
||||
type LogEntryType = 'fastForward' |'install' | 'pauseAt' | 'resume' | 'runFor' | 'setFixedTime' | 'setSystemTime';
|
||||
|
||||
type RealTimeTimer = {
|
||||
callAt: Ticks;
|
||||
cancel: () => void;
|
||||
promise: Promise<void> | undefined;
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
export class ClockController {
|
||||
readonly _now: Time;
|
||||
private _duringTick = false;
|
||||
private _timers: Map<number, Timer>;
|
||||
private _uniqueTimerId = idCounterStart;
|
||||
private _embedder: Embedder;
|
||||
readonly disposables: (() => void)[] = [];
|
||||
private _log: { type: LogEntryType, time: number, param?: number }[] = [];
|
||||
private _realTime: { startTicks: EmbedderTicks, lastSyncTicks: EmbedderTicks } | undefined;
|
||||
private _currentRealTimeTimer: RealTimeTimer | undefined;
|
||||
|
||||
constructor(embedder: Embedder) {
|
||||
this._timers = new Map();
|
||||
this._now = { time: asWallTime(0), isFixedTime: false, ticks: 0 as Ticks, origin: asWallTime(-1) };
|
||||
this._embedder = embedder;
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
this.disposables.forEach(dispose => dispose());
|
||||
this.disposables.length = 0;
|
||||
}
|
||||
|
||||
now(): number {
|
||||
this._replayLogOnce();
|
||||
// Sync real time to support calling Date.now() in a loop.
|
||||
this._syncRealTime();
|
||||
return this._now.time;
|
||||
}
|
||||
|
||||
install(time: number) {
|
||||
this._replayLogOnce();
|
||||
this._innerSetTime(asWallTime(time));
|
||||
}
|
||||
|
||||
setSystemTime(time: number) {
|
||||
this._replayLogOnce();
|
||||
this._innerSetTime(asWallTime(time));
|
||||
}
|
||||
|
||||
setFixedTime(time: number) {
|
||||
this._replayLogOnce();
|
||||
this._innerSetFixedTime(asWallTime(time));
|
||||
}
|
||||
|
||||
performanceNow(): DOMHighResTimeStamp {
|
||||
this._replayLogOnce();
|
||||
// Sync real time to support calling performance.now() in a loop.
|
||||
this._syncRealTime();
|
||||
return this._now.ticks;
|
||||
}
|
||||
|
||||
private _syncRealTime() {
|
||||
if (!this._realTime)
|
||||
return;
|
||||
const now = this._embedder.performanceNow();
|
||||
const sinceLastSync = now - this._realTime.lastSyncTicks;
|
||||
if (sinceLastSync > 0) {
|
||||
this._advanceNow(shiftTicks(this._now.ticks, sinceLastSync));
|
||||
this._realTime.lastSyncTicks = now;
|
||||
}
|
||||
}
|
||||
|
||||
private _innerSetTime(time: WallTime) {
|
||||
this._now.time = time;
|
||||
this._now.isFixedTime = false;
|
||||
if (this._now.origin < 0)
|
||||
this._now.origin = this._now.time;
|
||||
}
|
||||
|
||||
private _innerSetFixedTime(time: WallTime) {
|
||||
this._innerSetTime(time);
|
||||
this._now.isFixedTime = true;
|
||||
}
|
||||
|
||||
private _advanceNow(to: Ticks) {
|
||||
if (this._now.ticks > to) {
|
||||
// While running timers, `now` can advance by syncing with real time
|
||||
// from within now() or performance.now().
|
||||
// This makes it possible for `now` to be ahead of where we want to advance it.
|
||||
return;
|
||||
}
|
||||
if (!this._now.isFixedTime)
|
||||
this._now.time = asWallTime(this._now.time + to - this._now.ticks);
|
||||
this._now.ticks = to;
|
||||
}
|
||||
|
||||
async log(type: LogEntryType, time: number, param?: number) {
|
||||
this._log.push({ type, time, param });
|
||||
}
|
||||
|
||||
async runFor(ticks: number) {
|
||||
this._replayLogOnce();
|
||||
if (ticks < 0)
|
||||
throw new TypeError('Negative ticks are not supported');
|
||||
await this._runWithDisabledRealTimeSync(async () => {
|
||||
await this._runTo(shiftTicks(this._now.ticks, ticks));
|
||||
});
|
||||
}
|
||||
|
||||
private async _runTo(to: Ticks) {
|
||||
to = Math.ceil(to) as Ticks;
|
||||
|
||||
if (this._now.ticks > to)
|
||||
return;
|
||||
|
||||
let firstException: Error | undefined;
|
||||
while (true) {
|
||||
const result = await this._callFirstTimer(to);
|
||||
if (!result.timerFound)
|
||||
break;
|
||||
firstException = firstException || result.error;
|
||||
}
|
||||
|
||||
this._advanceNow(to);
|
||||
|
||||
if (firstException)
|
||||
throw firstException;
|
||||
}
|
||||
|
||||
async pauseAt(time: number): Promise<number> {
|
||||
this._replayLogOnce();
|
||||
await this._innerPause();
|
||||
const toConsume = time - this._now.time;
|
||||
await this._innerFastForwardTo(shiftTicks(this._now.ticks, toConsume));
|
||||
return toConsume;
|
||||
}
|
||||
|
||||
private async _innerPause() {
|
||||
this._realTime = undefined;
|
||||
await this._currentRealTimeTimer?.dispose();
|
||||
this._currentRealTimeTimer = undefined;
|
||||
}
|
||||
|
||||
resume() {
|
||||
this._replayLogOnce();
|
||||
this._innerResume();
|
||||
}
|
||||
|
||||
private _innerResume() {
|
||||
const now = this._embedder.performanceNow();
|
||||
this._realTime = { startTicks: now, lastSyncTicks: now };
|
||||
this._updateRealTimeTimer();
|
||||
}
|
||||
|
||||
private _updateRealTimeTimer() {
|
||||
if (this._currentRealTimeTimer?.promise) {
|
||||
// In progress, safe to return as it will call itself once promise is resolved.
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTimer = this._firstTimer();
|
||||
|
||||
// Either run the next timer or move time in 100ms chunks.
|
||||
const nextTick = Math.min(firstTimer ? firstTimer.callAt : this._now.ticks + maxTimeout, this._now.ticks + 100) as Ticks;
|
||||
const callAt = this._currentRealTimeTimer ? Math.min(this._currentRealTimeTimer.callAt, nextTick) as Ticks : nextTick;
|
||||
|
||||
if (this._currentRealTimeTimer) {
|
||||
// Cancel and reschedule.
|
||||
this._currentRealTimeTimer.cancel();
|
||||
this._currentRealTimeTimer = undefined;
|
||||
}
|
||||
|
||||
const realTimeTimer: RealTimeTimer = {
|
||||
callAt,
|
||||
promise: undefined,
|
||||
cancel: this._embedder.setTimeout(() => {
|
||||
this._syncRealTime();
|
||||
// eslint-disable-next-line no-console
|
||||
realTimeTimer.promise = this._runTo(this._now.ticks).catch(e => console.error(e));
|
||||
void realTimeTimer.promise.then(() => {
|
||||
this._currentRealTimeTimer = undefined;
|
||||
if (this._realTime)
|
||||
this._updateRealTimeTimer();
|
||||
});
|
||||
}, callAt - this._now.ticks),
|
||||
dispose: async () => {
|
||||
realTimeTimer.cancel();
|
||||
await realTimeTimer.promise;
|
||||
}
|
||||
};
|
||||
|
||||
this._currentRealTimeTimer = realTimeTimer;
|
||||
}
|
||||
|
||||
private async _runWithDisabledRealTimeSync(fn: () => Promise<void>) {
|
||||
if (!this._realTime) {
|
||||
await fn();
|
||||
return;
|
||||
}
|
||||
|
||||
await this._innerPause();
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
this._innerResume();
|
||||
}
|
||||
}
|
||||
|
||||
async fastForward(ticks: number) {
|
||||
this._replayLogOnce();
|
||||
await this._runWithDisabledRealTimeSync(async () => {
|
||||
await this._innerFastForwardTo(shiftTicks(this._now.ticks, ticks | 0));
|
||||
});
|
||||
}
|
||||
|
||||
private async _innerFastForwardTo(to: Ticks) {
|
||||
if (to < this._now.ticks)
|
||||
throw new Error('Cannot fast-forward to the past');
|
||||
for (const timer of this._timers.values()) {
|
||||
if (to > timer.callAt)
|
||||
timer.callAt = to;
|
||||
}
|
||||
await this._runTo(to);
|
||||
}
|
||||
|
||||
addTimer(options: { func: TimerHandler, type: TimerType, delay?: number | string, args?: any[] }): number {
|
||||
this._replayLogOnce();
|
||||
|
||||
if (options.type === TimerType.AnimationFrame && !options.func)
|
||||
throw new Error('Callback must be provided to requestAnimationFrame calls');
|
||||
if (options.type === TimerType.IdleCallback && !options.func)
|
||||
throw new Error('Callback must be provided to requestIdleCallback calls');
|
||||
if ([TimerType.Timeout, TimerType.Interval].includes(options.type) && !options.func && options.delay === undefined)
|
||||
throw new Error('Callback must be provided to timer calls');
|
||||
|
||||
let delay = options.delay ? +options.delay : 0;
|
||||
if (!Number.isFinite(delay))
|
||||
delay = 0;
|
||||
delay = delay > maxTimeout ? 1 : delay;
|
||||
delay = Math.max(0, delay);
|
||||
|
||||
const timer: Timer = {
|
||||
type: options.type,
|
||||
func: options.func,
|
||||
args: options.args || [],
|
||||
delay,
|
||||
callAt: shiftTicks(this._now.ticks, (delay || (this._duringTick ? 1 : 0))),
|
||||
createdAt: this._now.ticks,
|
||||
id: this._uniqueTimerId++,
|
||||
error: new Error(),
|
||||
};
|
||||
this._timers.set(timer.id, timer);
|
||||
if (this._realTime)
|
||||
this._updateRealTimeTimer();
|
||||
return timer.id;
|
||||
}
|
||||
|
||||
countTimers() {
|
||||
return this._timers.size;
|
||||
}
|
||||
|
||||
private _firstTimer(beforeTick?: number): Timer | null {
|
||||
let firstTimer: Timer | null = null;
|
||||
|
||||
for (const timer of this._timers.values()) {
|
||||
const isInRange = beforeTick === undefined || timer.callAt <= beforeTick;
|
||||
if (isInRange && (!firstTimer || compareTimers(firstTimer, timer) === 1))
|
||||
firstTimer = timer;
|
||||
}
|
||||
return firstTimer;
|
||||
}
|
||||
|
||||
private _takeFirstTimer(beforeTick?: number): Timer | null {
|
||||
const timer = this._firstTimer(beforeTick);
|
||||
if (!timer)
|
||||
return null;
|
||||
|
||||
this._advanceNow(timer.callAt);
|
||||
|
||||
if (timer.type === TimerType.Interval)
|
||||
timer.callAt = shiftTicks(timer.callAt, timer.delay);
|
||||
else
|
||||
this._timers.delete(timer.id);
|
||||
return timer;
|
||||
}
|
||||
|
||||
private async _callFirstTimer(beforeTick: number): Promise<{ timerFound: boolean, error?: Error }> {
|
||||
const timer = this._takeFirstTimer(beforeTick);
|
||||
if (!timer)
|
||||
return { timerFound: false };
|
||||
|
||||
this._duringTick = true;
|
||||
try {
|
||||
if (typeof timer.func !== 'function') {
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
// Using global this is not correct here,
|
||||
// but it is already broken since the eval scope is different from the one
|
||||
// on the original call site.
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
(() => { globalThis.eval(timer.func); })();
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
await new Promise<void>(f => this._embedder.setTimeout(f));
|
||||
return { timerFound: true, error };
|
||||
}
|
||||
|
||||
let args = timer.args;
|
||||
if (timer.type === TimerType.AnimationFrame)
|
||||
args = [this._now.ticks];
|
||||
else if (timer.type === TimerType.IdleCallback)
|
||||
args = [{ didTimeout: false, timeRemaining: () => 0 }];
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
timer.func.apply(null, args);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
await new Promise<void>(f => this._embedder.setTimeout(f));
|
||||
return { timerFound: true, error };
|
||||
} finally {
|
||||
this._duringTick = false;
|
||||
}
|
||||
}
|
||||
|
||||
getTimeToNextFrame() {
|
||||
// When `window.requestAnimationFrame` is the first call in the page,
|
||||
// this place is the first API call, so replay the log.
|
||||
this._replayLogOnce();
|
||||
return 16 - this._now.ticks % 16;
|
||||
}
|
||||
|
||||
clearTimer(timerId: number, type: TimerType) {
|
||||
this._replayLogOnce();
|
||||
|
||||
if (!timerId) {
|
||||
// null appears to be allowed in most browsers, and appears to be
|
||||
// relied upon by some libraries, like Bootstrap carousel
|
||||
return;
|
||||
}
|
||||
|
||||
// in Node, the ID is stored as the primitive value for `Timeout` objects
|
||||
// for `Immediate` objects, no ID exists, so it gets coerced to NaN
|
||||
const id = Number(timerId);
|
||||
|
||||
if (Number.isNaN(id) || id < idCounterStart) {
|
||||
const handlerName = getClearHandler(type);
|
||||
new Error(`Clock: ${handlerName} was invoked to clear a native timer instead of one created by the clock library.`);
|
||||
}
|
||||
|
||||
const timer = this._timers.get(id);
|
||||
if (timer) {
|
||||
if (
|
||||
timer.type === type ||
|
||||
(timer.type === 'Timeout' && type === 'Interval') ||
|
||||
(timer.type === 'Interval' && type === 'Timeout')
|
||||
) {
|
||||
this._timers.delete(id);
|
||||
} else {
|
||||
const clear = getClearHandler(type);
|
||||
const schedule = getScheduleHandler(timer.type);
|
||||
throw new Error(
|
||||
`Cannot clear timer: timer created with ${schedule}() but cleared with ${clear}()`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _replayLogOnce() {
|
||||
if (!this._log.length)
|
||||
return;
|
||||
|
||||
let lastLogTime = -1;
|
||||
let isPaused = false;
|
||||
|
||||
for (const { type, time, param } of this._log) {
|
||||
if (!isPaused && lastLogTime !== -1)
|
||||
this._advanceNow(shiftTicks(this._now.ticks, time - lastLogTime));
|
||||
lastLogTime = time;
|
||||
|
||||
if (type === 'install') {
|
||||
this._innerSetTime(asWallTime(param!));
|
||||
} else if (type === 'fastForward' || type === 'runFor') {
|
||||
this._advanceNow(shiftTicks(this._now.ticks, param!));
|
||||
} else if (type === 'pauseAt') {
|
||||
isPaused = true;
|
||||
this._innerSetTime(asWallTime(param!));
|
||||
} else if (type === 'resume') {
|
||||
isPaused = false;
|
||||
} else if (type === 'setFixedTime') {
|
||||
this._innerSetFixedTime(asWallTime(param!));
|
||||
} else if (type === 'setSystemTime') {
|
||||
this._innerSetTime(asWallTime(param!));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPaused) {
|
||||
if (lastLogTime > 0)
|
||||
this._advanceNow(shiftTicks(this._now.ticks, this._embedder.dateNow() - lastLogTime));
|
||||
this._innerResume();
|
||||
} else {
|
||||
this._realTime = undefined;
|
||||
}
|
||||
|
||||
this._log.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function mirrorDateProperties(target: any, source: Builtins['Date']): Builtins['Date'] {
|
||||
for (const prop in source) {
|
||||
if (source.hasOwnProperty(prop))
|
||||
target[prop] = (source as any)[prop];
|
||||
}
|
||||
target.toString = () => source.toString();
|
||||
target.prototype = source.prototype;
|
||||
target.parse = source.parse;
|
||||
target.UTC = source.UTC;
|
||||
target.prototype.toUTCString = source.prototype.toUTCString;
|
||||
target.isFake = true;
|
||||
return target;
|
||||
}
|
||||
|
||||
function createDate(clock: ClockController, NativeDate: Builtins['Date']): Builtins['Date'] {
|
||||
function ClockDate(this: typeof ClockDate, year: number, month: number, date: number, hour: number, minute: number, second: number, ms: number): Date | string {
|
||||
// the Date constructor called as a function, ref Ecma-262 Edition 5.1, section 15.9.2.
|
||||
// This remains so in the 10th edition of 2019 as well.
|
||||
if (!(this instanceof ClockDate))
|
||||
return new NativeDate(clock.now()).toString();
|
||||
|
||||
// if Date is called as a constructor with 'new' keyword
|
||||
// Defensive and verbose to avoid potential harm in passing
|
||||
// explicit undefined when user does not pass argument
|
||||
switch (arguments.length) {
|
||||
case 0:
|
||||
return new NativeDate(clock.now());
|
||||
case 1:
|
||||
return new NativeDate(year);
|
||||
case 2:
|
||||
return new NativeDate(year, month);
|
||||
case 3:
|
||||
return new NativeDate(year, month, date);
|
||||
case 4:
|
||||
return new NativeDate(year, month, date, hour);
|
||||
case 5:
|
||||
return new NativeDate(year, month, date, hour, minute);
|
||||
case 6:
|
||||
return new NativeDate(
|
||||
year,
|
||||
month,
|
||||
date,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
);
|
||||
default:
|
||||
return new NativeDate(
|
||||
year,
|
||||
month,
|
||||
date,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
ms,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ClockDate.now = () => clock.now();
|
||||
return mirrorDateProperties(ClockDate, NativeDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror Intl by default on our fake implementation
|
||||
*
|
||||
* Most of the properties are the original native ones,
|
||||
* but we need to take control of those that have a
|
||||
* dependency on the current clock.
|
||||
*/
|
||||
function createIntl(clock: ClockController, NativeIntl: Builtins['Intl']): Builtins['Intl'] {
|
||||
const ClockIntl: any = {};
|
||||
/*
|
||||
* All properties of Intl are non-enumerable, so we need
|
||||
* to do a bit of work to get them out.
|
||||
*/
|
||||
for (const key of Object.getOwnPropertyNames(NativeIntl) as (keyof Builtins['Intl'])[])
|
||||
ClockIntl[key] = NativeIntl[key];
|
||||
|
||||
ClockIntl.DateTimeFormat = function(...args: any[]) {
|
||||
const realFormatter = new NativeIntl.DateTimeFormat(...args);
|
||||
const formatter: Intl.DateTimeFormat = {
|
||||
formatRange: realFormatter.formatRange.bind(realFormatter),
|
||||
formatRangeToParts: realFormatter.formatRangeToParts.bind(realFormatter),
|
||||
resolvedOptions: realFormatter.resolvedOptions.bind(realFormatter),
|
||||
format: date => realFormatter.format(date || clock.now()),
|
||||
formatToParts: date => realFormatter.formatToParts(date || clock.now()),
|
||||
};
|
||||
|
||||
return formatter;
|
||||
};
|
||||
|
||||
ClockIntl.DateTimeFormat.prototype = Object.create(
|
||||
NativeIntl.DateTimeFormat.prototype,
|
||||
);
|
||||
|
||||
ClockIntl.DateTimeFormat.supportedLocalesOf =
|
||||
NativeIntl.DateTimeFormat.supportedLocalesOf;
|
||||
|
||||
return ClockIntl;
|
||||
}
|
||||
|
||||
function compareTimers(a: Timer, b: Timer) {
|
||||
// Sort first by absolute timing
|
||||
if (a.callAt < b.callAt)
|
||||
return -1;
|
||||
if (a.callAt > b.callAt)
|
||||
return 1;
|
||||
|
||||
// Sort next by immediate, immediate timers take precedence
|
||||
if (a.type === TimerType.Immediate && b.type !== TimerType.Immediate)
|
||||
return -1;
|
||||
if (a.type !== TimerType.Immediate && b.type === TimerType.Immediate)
|
||||
return 1;
|
||||
|
||||
// Sort next by creation time, earlier-created timers take precedence
|
||||
if (a.createdAt < b.createdAt)
|
||||
return -1;
|
||||
if (a.createdAt > b.createdAt)
|
||||
return 1;
|
||||
|
||||
// Sort next by id, lower-id timers take precedence
|
||||
if (a.id < b.id)
|
||||
return -1;
|
||||
if (a.id > b.id)
|
||||
return 1;
|
||||
|
||||
// As timer ids are unique, no fallback `0` is necessary
|
||||
}
|
||||
|
||||
const maxTimeout = Math.pow(2, 31) - 1; // see https://heycam.github.io/webidl/#abstract-opdef-converttoint
|
||||
const idCounterStart = 1e12; // arbitrarily large number to avoid collisions with native timer IDs
|
||||
|
||||
function platformOriginals(globalObject: WindowOrWorkerGlobalScope): { raw: Builtins, bound: Builtins } {
|
||||
const raw: Builtins = {
|
||||
setTimeout: globalObject.setTimeout,
|
||||
clearTimeout: globalObject.clearTimeout,
|
||||
setInterval: globalObject.setInterval,
|
||||
clearInterval: globalObject.clearInterval,
|
||||
requestAnimationFrame: (globalObject as any).requestAnimationFrame ? (globalObject as any).requestAnimationFrame : undefined,
|
||||
cancelAnimationFrame: (globalObject as any).cancelAnimationFrame ? (globalObject as any).cancelAnimationFrame : undefined,
|
||||
requestIdleCallback: (globalObject as any).requestIdleCallback ? (globalObject as any).requestIdleCallback : undefined,
|
||||
cancelIdleCallback: (globalObject as any).cancelIdleCallback ? (globalObject as any).cancelIdleCallback : undefined,
|
||||
Date: (globalObject as any).Date,
|
||||
performance: globalObject.performance,
|
||||
Intl: (globalObject as any).Intl,
|
||||
AbortSignal: (globalObject as any).AbortSignal,
|
||||
};
|
||||
const bound = { ...raw };
|
||||
for (const key of Object.keys(bound) as (keyof Builtins)[]) {
|
||||
if (key !== 'Date' && key !== 'AbortSignal' && typeof bound[key] === 'function')
|
||||
bound[key] = (bound[key] as any).bind(globalObject);
|
||||
}
|
||||
return { raw, bound };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets schedule handler name for a given timer type
|
||||
*/
|
||||
function getScheduleHandler(type: TimerType) {
|
||||
if (type === 'IdleCallback' || type === 'AnimationFrame')
|
||||
return `request${type}`;
|
||||
|
||||
return `set${type}`;
|
||||
}
|
||||
|
||||
function createApi(clock: ClockController, originals: Builtins, browserName?: string): Builtins {
|
||||
return {
|
||||
setTimeout: (func: TimerHandler, timeout?: number | undefined, ...args: any[]) => {
|
||||
const delay = timeout ? +timeout : timeout;
|
||||
return clock.addTimer({
|
||||
type: TimerType.Timeout,
|
||||
func,
|
||||
args,
|
||||
delay
|
||||
});
|
||||
},
|
||||
clearTimeout: (timerId: number | undefined): void => {
|
||||
if (timerId)
|
||||
clock.clearTimer(timerId, TimerType.Timeout);
|
||||
},
|
||||
setInterval: (func: TimerHandler, timeout?: number | undefined, ...args: any[]): number => {
|
||||
const delay = timeout ? +timeout : timeout;
|
||||
return clock.addTimer({
|
||||
type: TimerType.Interval,
|
||||
func,
|
||||
args,
|
||||
delay,
|
||||
});
|
||||
},
|
||||
clearInterval: (timerId: number | undefined): void => {
|
||||
if (timerId)
|
||||
return clock.clearTimer(timerId, TimerType.Interval);
|
||||
},
|
||||
requestAnimationFrame: (callback: FrameRequestCallback): number => {
|
||||
return clock.addTimer({
|
||||
type: TimerType.AnimationFrame,
|
||||
func: callback,
|
||||
delay: clock.getTimeToNextFrame(),
|
||||
});
|
||||
},
|
||||
cancelAnimationFrame: (timerId: number): void => {
|
||||
if (timerId)
|
||||
return clock.clearTimer(timerId, TimerType.AnimationFrame);
|
||||
},
|
||||
requestIdleCallback: (callback: IdleRequestCallback, options?: IdleRequestOptions | undefined): number => {
|
||||
let timeToNextIdlePeriod = 0;
|
||||
|
||||
if (clock.countTimers() > 0)
|
||||
timeToNextIdlePeriod = 50; // const for now
|
||||
return clock.addTimer({
|
||||
type: TimerType.IdleCallback,
|
||||
func: callback,
|
||||
delay: options?.timeout ? Math.min(options?.timeout, timeToNextIdlePeriod) : timeToNextIdlePeriod,
|
||||
});
|
||||
},
|
||||
cancelIdleCallback: (timerId: number): void => {
|
||||
if (timerId)
|
||||
return clock.clearTimer(timerId, TimerType.IdleCallback);
|
||||
},
|
||||
Intl: originals.Intl ? createIntl(clock, originals.Intl) : (undefined as unknown as Builtins['Intl']),
|
||||
Date: createDate(clock, originals.Date),
|
||||
performance: originals.performance ? fakePerformance(clock, originals.performance) : (undefined as unknown as Builtins['performance']),
|
||||
AbortSignal: originals.AbortSignal ? fakeAbortSignal(clock, originals.AbortSignal, browserName) : (undefined as unknown as Builtins['AbortSignal']),
|
||||
};
|
||||
}
|
||||
|
||||
function getClearHandler(type: TimerType) {
|
||||
if (type === 'IdleCallback' || type === 'AnimationFrame')
|
||||
return `cancel${type}`;
|
||||
|
||||
return `clear${type}`;
|
||||
}
|
||||
|
||||
class FakePerformanceEntry {
|
||||
name: string;
|
||||
entryType: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
|
||||
constructor(name: string, entryType: string, startTime: number, duration: number) {
|
||||
this.name = name;
|
||||
this.entryType = entryType;
|
||||
this.startTime = startTime;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return JSON.stringify({ ...this });
|
||||
}
|
||||
}
|
||||
|
||||
function fakePerformance(clock: ClockController, performance: Builtins['performance']): Builtins['performance'] {
|
||||
const result: any = {
|
||||
now: () => clock.performanceNow(),
|
||||
};
|
||||
result.__defineGetter__('timeOrigin', () => clock._now.origin || 0);
|
||||
// eslint-disable-next-line no-proto
|
||||
for (const key of Object.keys((performance as any).__proto__)) {
|
||||
if (key === 'now' || key === 'timeOrigin')
|
||||
continue;
|
||||
if (key === 'getEntries' || key === 'getEntriesByName' || key === 'getEntriesByType')
|
||||
result[key] = () => [];
|
||||
else if (key === 'mark')
|
||||
result[key] = (name: string) => new FakePerformanceEntry(name, 'mark', 0, 0);
|
||||
else if (key === 'measure')
|
||||
result[key] = (name: string) => new FakePerformanceEntry(name, 'measure', 0, 50);
|
||||
else
|
||||
result[key] = () => {};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function fakeAbortSignal(clock: ClockController, abortSignal: Builtins['AbortSignal'], browserName?: string): Builtins['AbortSignal'] {
|
||||
Object.defineProperty(abortSignal, 'timeout', {
|
||||
value(ms: number) {
|
||||
const controller = new AbortController();
|
||||
clock.addTimer({
|
||||
delay: ms,
|
||||
type: TimerType.Timeout,
|
||||
func: () => controller.abort(
|
||||
new DOMException(
|
||||
browserName === 'chromium' ? 'signal timed out' : 'The operation timed out.',
|
||||
'TimeoutError'
|
||||
)
|
||||
),
|
||||
});
|
||||
return controller.signal;
|
||||
}
|
||||
});
|
||||
return abortSignal;
|
||||
}
|
||||
|
||||
export function createClock(globalObject: WindowOrWorkerGlobalScope, config: InstallConfig = {}): { clock: ClockController, api: Builtins, originals: Builtins } {
|
||||
const originals = platformOriginals(globalObject);
|
||||
const embedder: Embedder = {
|
||||
dateNow: () => originals.raw.Date.now(),
|
||||
performanceNow: () => Math.ceil(originals.raw.performance!.now()) as EmbedderTicks,
|
||||
setTimeout: (task: () => void, timeout?: number) => {
|
||||
const timerId = originals.bound.setTimeout(task, timeout);
|
||||
return () => originals.bound.clearTimeout(timerId);
|
||||
},
|
||||
setInterval: (task: () => void, delay: number) => {
|
||||
const intervalId = originals.bound.setInterval(task, delay);
|
||||
return () => originals.bound.clearInterval(intervalId);
|
||||
},
|
||||
};
|
||||
|
||||
const clock = new ClockController(embedder);
|
||||
const api = createApi(clock, originals.bound, config.browserName);
|
||||
return { clock, api, originals: originals.raw };
|
||||
}
|
||||
|
||||
export function install(globalObject: WindowOrWorkerGlobalScope, config: InstallConfig = {}): { clock: ClockController, api: Builtins, originals: Builtins } {
|
||||
if ((globalObject as any).Date?.isFake) {
|
||||
// Timers are already faked; this is a problem.
|
||||
// Make the user reset timers before continuing.
|
||||
throw new TypeError(`Can't install fake timers twice on the same global object.`);
|
||||
}
|
||||
|
||||
const { clock, api, originals } = createClock(globalObject, config);
|
||||
const toFake = config.toFake?.length ? config.toFake : Object.keys(originals) as (keyof Builtins)[];
|
||||
|
||||
for (const method of toFake) {
|
||||
if (method === 'Date') {
|
||||
(globalObject as any).Date = mirrorDateProperties(api.Date, (globalObject as any).Date);
|
||||
} else if (method === 'Intl') {
|
||||
(globalObject as any).Intl = api[method]!;
|
||||
} else if (method === 'AbortSignal') {
|
||||
(globalObject as any).AbortSignal = api[method]!;
|
||||
} else if (method === 'performance') {
|
||||
(globalObject as any).performance = api[method]!;
|
||||
const kEventTimeStamp = Symbol('playwrightEventTimeStamp');
|
||||
Object.defineProperty(Event.prototype, 'timeStamp', {
|
||||
get() {
|
||||
if (!this[kEventTimeStamp])
|
||||
this[kEventTimeStamp] = api.performance?.now();
|
||||
return this[kEventTimeStamp];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
(globalObject as any)[method] = (...args: any[]) => {
|
||||
return (api[method] as any).apply(api, args);
|
||||
};
|
||||
}
|
||||
clock.disposables.push(() => {
|
||||
(globalObject as any)[method] = originals[method];
|
||||
});
|
||||
}
|
||||
|
||||
return { clock, api, originals };
|
||||
}
|
||||
|
||||
export function inject(globalObject: WindowOrWorkerGlobalScope, browserName?: string) {
|
||||
const builtins = platformOriginals(globalObject).bound;
|
||||
const { clock: controller } = install(globalObject, { browserName });
|
||||
controller.resume();
|
||||
return {
|
||||
controller,
|
||||
builtins,
|
||||
};
|
||||
}
|
||||
|
||||
function asWallTime(n: number): WallTime {
|
||||
return n as WallTime;
|
||||
}
|
||||
|
||||
function shiftTicks(ticks: Ticks, ms: number): Ticks {
|
||||
return ticks + ms as Ticks;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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 { asLocator } from '@isomorphic/locatorGenerators';
|
||||
import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '@isomorphic/locatorUtils';
|
||||
import { escapeForTextSelector } from '@isomorphic/stringUtils';
|
||||
|
||||
import type { InjectedScript } from './injectedScript';
|
||||
import type { Language } from '@isomorphic/locatorGenerators';
|
||||
import type { ByRoleOptions } from '@isomorphic/locatorUtils';
|
||||
import type { AriaTreeOptions } from './ariaSnapshot';
|
||||
|
||||
const selectorSymbol = Symbol('selector');
|
||||
|
||||
class Locator {
|
||||
[selectorSymbol]: string;
|
||||
element: Element | undefined;
|
||||
elements: Element[] | undefined;
|
||||
|
||||
constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator, visible?: boolean }) {
|
||||
if (options?.hasText)
|
||||
selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
|
||||
if (options?.hasNotText)
|
||||
selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`;
|
||||
if (options?.has)
|
||||
selector += ` >> internal:has=` + JSON.stringify(options.has[selectorSymbol]);
|
||||
if (options?.hasNot)
|
||||
selector += ` >> internal:has-not=` + JSON.stringify(options.hasNot[selectorSymbol]);
|
||||
if (options?.visible !== undefined)
|
||||
selector += ` >> visible=${options.visible ? 'true' : 'false'}`;
|
||||
this[selectorSymbol] = selector;
|
||||
if (selector) {
|
||||
const parsed = injectedScript.parseSelector(selector);
|
||||
this.element = injectedScript.querySelector(parsed, injectedScript.document, false);
|
||||
this.elements = injectedScript.querySelectorAll(parsed, injectedScript.document);
|
||||
}
|
||||
const selectorBase = selector;
|
||||
const self = this as any;
|
||||
self.locator = (selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator }): Locator => {
|
||||
return new Locator(injectedScript, selectorBase ? selectorBase + ' >> ' + selector : selector, options);
|
||||
};
|
||||
self.getByTestId = (testId: string): Locator => self.locator(getByTestIdSelector(injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen(), testId));
|
||||
self.getByAltText = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByAltTextSelector(text, options));
|
||||
self.getByLabel = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByLabelSelector(text, options));
|
||||
self.getByPlaceholder = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByPlaceholderSelector(text, options));
|
||||
self.getByText = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTextSelector(text, options));
|
||||
self.getByTitle = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTitleSelector(text, options));
|
||||
self.getByRole = (role: string, options: ByRoleOptions = {}): Locator => self.locator(getByRoleSelector(role, options));
|
||||
self.filter = (options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator, visible?: boolean }): Locator => new Locator(injectedScript, selector, options);
|
||||
self.first = (): Locator => self.locator('nth=0');
|
||||
self.last = (): Locator => self.locator('nth=-1');
|
||||
self.nth = (index: number): Locator => self.locator(`nth=${index}`);
|
||||
self.and = (locator: Locator): Locator => new Locator(injectedScript, selectorBase + ` >> internal:and=` + JSON.stringify(locator[selectorSymbol]));
|
||||
self.or = (locator: Locator): Locator => new Locator(injectedScript, selectorBase + ` >> internal:or=` + JSON.stringify(locator[selectorSymbol]));
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
playwright?: any;
|
||||
inspect: (element: Element | undefined) => void;
|
||||
__pw_resume?: () => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConsoleAPI {
|
||||
private _injectedScript: InjectedScript;
|
||||
|
||||
constructor(injectedScript: InjectedScript) {
|
||||
this._injectedScript = injectedScript;
|
||||
}
|
||||
|
||||
install() {
|
||||
if (this._injectedScript.window.playwright)
|
||||
return;
|
||||
this._injectedScript.window.playwright = {
|
||||
$: (selector: string, strict?: boolean) => this._querySelector(selector, !!strict),
|
||||
$$: (selector: string) => this._querySelectorAll(selector),
|
||||
inspect: (selector: string) => this._inspect(selector),
|
||||
selector: (element: Element) => this._selector(element),
|
||||
generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language),
|
||||
ariaSnapshot: (element?: Element, options?: AriaTreeOptions) => {
|
||||
return this._injectedScript.ariaSnapshot(element || this._injectedScript.document.body, options || { mode: 'default' });
|
||||
},
|
||||
resume: () => this._resume(),
|
||||
...new Locator(this._injectedScript, ''),
|
||||
};
|
||||
delete this._injectedScript.window.playwright.filter;
|
||||
delete this._injectedScript.window.playwright.first;
|
||||
delete this._injectedScript.window.playwright.last;
|
||||
delete this._injectedScript.window.playwright.nth;
|
||||
delete this._injectedScript.window.playwright.and;
|
||||
delete this._injectedScript.window.playwright.or;
|
||||
}
|
||||
|
||||
private _querySelector(selector: string, strict: boolean): (Element | undefined) {
|
||||
if (typeof selector !== 'string')
|
||||
throw new Error(`Usage: playwright.query('Playwright >> selector').`);
|
||||
const parsed = this._injectedScript.parseSelector(selector);
|
||||
return this._injectedScript.querySelector(parsed, this._injectedScript.document, strict);
|
||||
}
|
||||
|
||||
private _querySelectorAll(selector: string): Element[] {
|
||||
if (typeof selector !== 'string')
|
||||
throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
|
||||
const parsed = this._injectedScript.parseSelector(selector);
|
||||
return this._injectedScript.querySelectorAll(parsed, this._injectedScript.document);
|
||||
}
|
||||
|
||||
private _inspect(selector: string) {
|
||||
if (typeof selector !== 'string')
|
||||
throw new Error(`Usage: playwright.inspect('Playwright >> selector').`);
|
||||
this._injectedScript.window.inspect(this._querySelector(selector, false));
|
||||
}
|
||||
|
||||
private _selector(element: Element) {
|
||||
if (!(element instanceof Element))
|
||||
throw new Error(`Usage: playwright.selector(element).`);
|
||||
return this._injectedScript.generateSelectorSimple(element);
|
||||
}
|
||||
|
||||
private _generateLocator(element: Element, language?: Language) {
|
||||
if (!(element instanceof Element))
|
||||
throw new Error(`Usage: playwright.locator(element).`);
|
||||
const selector = this._injectedScript.generateSelectorSimple(element);
|
||||
return asLocator(language || 'javascript', selector);
|
||||
}
|
||||
|
||||
private _resume() {
|
||||
if (!this._injectedScript.window.__pw_resume)
|
||||
return false;
|
||||
this._injectedScript.window.__pw_resume().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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 GlobalOptions = {
|
||||
browserNameForWorkarounds?: string;
|
||||
};
|
||||
let globalOptions: GlobalOptions = {};
|
||||
export function setGlobalOptions(options: GlobalOptions) {
|
||||
globalOptions = options;
|
||||
}
|
||||
export function getGlobalOptions(): GlobalOptions {
|
||||
return globalOptions;
|
||||
}
|
||||
|
||||
export function isInsideScope(scope: Node, element: Element | undefined): boolean {
|
||||
while (element) {
|
||||
if (scope.contains(element))
|
||||
return true;
|
||||
element = enclosingShadowHost(element);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function enclosingElement(node: Node) {
|
||||
if (node.nodeType === 1 /* Node.ELEMENT_NODE */)
|
||||
return node as Element;
|
||||
return node.parentElement ?? undefined;
|
||||
}
|
||||
|
||||
export function parentElementOrShadowHost(element: Element): Element | undefined {
|
||||
if (element.parentElement)
|
||||
return element.parentElement;
|
||||
if (!element.parentNode)
|
||||
return;
|
||||
if (element.parentNode.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ && (element.parentNode as ShadowRoot).host)
|
||||
return (element.parentNode as ShadowRoot).host;
|
||||
}
|
||||
|
||||
export function enclosingShadowRootOrDocument(element: Element): Document | ShadowRoot | undefined {
|
||||
let node: Node = element;
|
||||
while (node.parentNode)
|
||||
node = node.parentNode;
|
||||
if (node.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ || node.nodeType === 9 /* Node.DOCUMENT_NODE */)
|
||||
return node as Document | ShadowRoot;
|
||||
}
|
||||
|
||||
function enclosingShadowHost(element: Element): Element | undefined {
|
||||
while (element.parentElement)
|
||||
element = element.parentElement;
|
||||
return parentElementOrShadowHost(element);
|
||||
}
|
||||
|
||||
// Assumption: if scope is provided, element must be inside scope's subtree.
|
||||
export function closestCrossShadow(element: Element | undefined, css: string, scope?: Document | Element): Element | undefined {
|
||||
while (element) {
|
||||
const closest = element.closest(css);
|
||||
if (scope && closest !== scope && closest?.contains(scope))
|
||||
return;
|
||||
if (closest)
|
||||
return closest;
|
||||
element = enclosingShadowHost(element);
|
||||
}
|
||||
}
|
||||
|
||||
export function getElementComputedStyle(element: Element, pseudo?: string): CSSStyleDeclaration | undefined {
|
||||
const cache = pseudo === '::before' ? cacheStyleBefore : pseudo === '::after' ? cacheStyleAfter : cacheStyle;
|
||||
if (cache && cache.has(element))
|
||||
return cache.get(element);
|
||||
const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element, pseudo) : undefined;
|
||||
cache?.set(element, style);
|
||||
return style;
|
||||
}
|
||||
|
||||
export function isElementStyleVisibilityVisible(element: Element, style?: CSSStyleDeclaration): boolean {
|
||||
style = style ?? getElementComputedStyle(element);
|
||||
if (!style)
|
||||
return true;
|
||||
// Element.checkVisibility checks for content-visibility and also looks at
|
||||
// styles up the flat tree including user-agent ShadowRoots, such as the
|
||||
// details element for example.
|
||||
// All the browser implement it, but WebKit has a bug which prevents us from using it:
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=264733
|
||||
// @ts-ignore
|
||||
if (Element.prototype.checkVisibility && globalOptions.browserNameForWorkarounds !== 'webkit') {
|
||||
if (!element.checkVisibility())
|
||||
return false;
|
||||
} else {
|
||||
// Manual workaround for WebKit that does not have checkVisibility.
|
||||
const detailsOrSummary = element.closest('details,summary');
|
||||
if (detailsOrSummary !== element && detailsOrSummary?.nodeName === 'DETAILS' && !(detailsOrSummary as HTMLDetailsElement).open)
|
||||
return false;
|
||||
}
|
||||
if (style.visibility !== 'visible')
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function computeBox(element: Element) {
|
||||
// Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises.
|
||||
const style = getElementComputedStyle(element);
|
||||
if (!style)
|
||||
return { visible: true, inline: false };
|
||||
const cursor = style.cursor;
|
||||
if (style.display === 'contents') {
|
||||
// display:contents is not rendered itself, but its child nodes are.
|
||||
for (let child = element.firstChild; child; child = child.nextSibling) {
|
||||
if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && isElementVisible(child as Element))
|
||||
return { visible: true, inline: false, cursor };
|
||||
if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text))
|
||||
return { visible: true, inline: true, cursor };
|
||||
}
|
||||
return { visible: false, inline: false, cursor };
|
||||
}
|
||||
if (!isElementStyleVisibilityVisible(element, style))
|
||||
return { cursor, visible: false, inline: false };
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === 'inline' };
|
||||
}
|
||||
|
||||
export function isElementVisible(element: Element): boolean {
|
||||
return computeBox(element).visible;
|
||||
}
|
||||
|
||||
export function isVisibleTextNode(node: Text) {
|
||||
// https://stackoverflow.com/questions/1461059/is-there-an-equivalent-to-getboundingclientrect-for-text-nodes
|
||||
const range = node.ownerDocument.createRange();
|
||||
range.selectNode(node);
|
||||
const rect = range.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
export function elementSafeTagName(element: Element) {
|
||||
const tagName = element.tagName;
|
||||
if (typeof tagName === 'string') // Fast path.
|
||||
return tagName.toUpperCase();
|
||||
// Named inputs, e.g. <input name=tagName>, will be exposed as fields on the parent <form>
|
||||
// and override its properties.
|
||||
if (element instanceof HTMLFormElement)
|
||||
return 'FORM';
|
||||
// Elements from the svg namespace do not have uppercase tagName right away.
|
||||
return element.tagName.toUpperCase();
|
||||
}
|
||||
|
||||
let cacheStyle: Map<Element, CSSStyleDeclaration | undefined> | undefined;
|
||||
let cacheStyleBefore: Map<Element, CSSStyleDeclaration | undefined> | undefined;
|
||||
let cacheStyleAfter: Map<Element, CSSStyleDeclaration | undefined> | undefined;
|
||||
let cachesCounter = 0;
|
||||
|
||||
export function beginDOMCaches() {
|
||||
++cachesCounter;
|
||||
cacheStyle ??= new Map();
|
||||
cacheStyleBefore ??= new Map();
|
||||
cacheStyleAfter ??= new Map();
|
||||
}
|
||||
|
||||
export function endDOMCaches() {
|
||||
if (!--cachesCounter) {
|
||||
cacheStyle = undefined;
|
||||
cacheStyleBefore = undefined;
|
||||
cacheStyleAfter = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
:host {
|
||||
font-size: 13px;
|
||||
font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
x-pw-tooltip {
|
||||
backdrop-filter: blur(5px);
|
||||
background-color: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0.5rem 1.2rem rgba(0,0,0,.3);
|
||||
display: none;
|
||||
font-size: 12.8px;
|
||||
font-weight: normal;
|
||||
left: 0;
|
||||
line-height: 1.5;
|
||||
max-width: 600px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
padding: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
x-pw-tooltip-line {
|
||||
display: flex;
|
||||
max-width: 600px;
|
||||
padding: 6px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
x-pw-tooltip-footer {
|
||||
display: flex;
|
||||
max-width: 600px;
|
||||
padding: 6px;
|
||||
user-select: none;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
x-pw-dialog {
|
||||
background-color: white;
|
||||
pointer-events: auto;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0.5rem 1.2rem rgba(0,0,0,.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
x-pw-dialog:not(.autosize) {
|
||||
width: 400px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
x-pw-dialog-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
x-pw-dialog-body label {
|
||||
margin: 5px 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
x-pw-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
x-pw-action-point {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: red;
|
||||
border-radius: 10px;
|
||||
margin: -10px 0 0 -10px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
x-pw-title {
|
||||
position: absolute;
|
||||
backdrop-filter: blur(5px);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
font-size: 24px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
x-pw-user-overlays, x-pw-user-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
@keyframes pw-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
x-pw-separator {
|
||||
height: 1px;
|
||||
margin: 6px 9px;
|
||||
background: rgb(148 148 148 / 90%);
|
||||
}
|
||||
|
||||
x-pw-tool-gripper {
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
margin: 2px 0;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
x-pw-tool-gripper:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
x-pw-tool-gripper > x-div {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 6px 4px;
|
||||
clip-path: url(#icon-gripper);
|
||||
background-color: #555555;
|
||||
}
|
||||
|
||||
x-pw-tools-list > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 10px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
x-pw-tools-list {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
x-pw-tool-item {
|
||||
pointer-events: auto;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
x-pw-tool-item:not(.disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
x-pw-tool-item:not(.disabled):hover {
|
||||
background-color: hsl(0, 0%, 86%);
|
||||
}
|
||||
|
||||
x-pw-tool-item.toggled {
|
||||
background-color: rgba(138, 202, 228, 0.5);
|
||||
}
|
||||
|
||||
x-pw-tool-item.toggled:not(.disabled):hover {
|
||||
background-color: #8acae4c4;
|
||||
}
|
||||
|
||||
x-pw-tool-item > x-div {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 6px;
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
x-pw-tool-item.disabled > x-div {
|
||||
background-color: rgba(97, 97, 97, 0.5);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
x-pw-tool-item.record.toggled {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
x-pw-tool-item.record.toggled:not(.disabled):hover {
|
||||
background-color: hsl(0, 0%, 86%);
|
||||
}
|
||||
|
||||
x-pw-tool-item.record.toggled > x-div {
|
||||
background-color: #a1260d;
|
||||
}
|
||||
|
||||
x-pw-tool-item.record.disabled.toggled > x-div {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
x-pw-tool-item.accept > x-div {
|
||||
background-color: #388a34;
|
||||
}
|
||||
|
||||
x-pw-tool-item.record > x-div {
|
||||
/* codicon: circle-large-filled */
|
||||
clip-path: url(#icon-circle-large-filled);
|
||||
}
|
||||
|
||||
x-pw-tool-item.record.toggled > x-div {
|
||||
/* codicon: stop-circle */
|
||||
clip-path: url(#icon-stop-circle);
|
||||
}
|
||||
|
||||
x-pw-tool-item.pick-locator > x-div {
|
||||
/* codicon: inspect */
|
||||
clip-path: url(#icon-inspect);
|
||||
}
|
||||
|
||||
x-pw-tool-item.text > x-div {
|
||||
/* codicon: whole-word */
|
||||
clip-path: url(#icon-whole-word);
|
||||
}
|
||||
|
||||
x-pw-tool-item.visibility > x-div {
|
||||
/* codicon: eye */
|
||||
clip-path: url(#icon-eye);
|
||||
}
|
||||
|
||||
x-pw-tool-item.value > x-div {
|
||||
/* codicon: symbol-constant */
|
||||
clip-path: url(#icon-symbol-constant);
|
||||
}
|
||||
|
||||
x-pw-tool-item.snapshot > x-div {
|
||||
/* codicon: eye */
|
||||
clip-path: url(#icon-gist);
|
||||
}
|
||||
|
||||
x-pw-tool-item.accept > x-div {
|
||||
clip-path: url(#icon-check);
|
||||
}
|
||||
|
||||
x-pw-tool-item.cancel > x-div {
|
||||
clip-path: url(#icon-close);
|
||||
}
|
||||
|
||||
x-pw-tool-item.succeeded > x-div {
|
||||
/* codicon: pass */
|
||||
clip-path: url(#icon-pass);
|
||||
background-color: #388a34 !important;
|
||||
}
|
||||
|
||||
x-pw-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
max-width: min-content;
|
||||
z-index: 2147483647;
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
x-pw-overlay x-pw-tools-list {
|
||||
background-color: #ffffffdd;
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 5px 5px;
|
||||
border-radius: 3px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
x-pw-overlay x-pw-tool-item {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
textarea.text-editor {
|
||||
font-family: system-ui,Ubuntu,Droid Sans,sans-serif;
|
||||
flex: auto;
|
||||
border: none;
|
||||
margin: 6px 10px;
|
||||
color: #333;
|
||||
outline: 1px solid transparent!important;
|
||||
resize: none;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
textarea.text-editor.does-not-match {
|
||||
outline: 1px solid red !important;
|
||||
}
|
||||
|
||||
x-div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
x-spacer {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
x-locator-editor {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
padding: 4px;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
|
||||
x-locator-editor.does-not-match {
|
||||
outline: 1px solid red;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
x-pw-action-list {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
x-pw-action-item {
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
x-pw-action-item:hover {
|
||||
background-color: hsl(0, 0%, 95%);
|
||||
}
|
||||
|
||||
x-pw-action-item:last-child {
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 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 { asLocator } from '@isomorphic/locatorGenerators';
|
||||
import { stringifySelector } from '@isomorphic/selectorParser';
|
||||
|
||||
import highlightCSS from './highlight.css?inline';
|
||||
|
||||
import type { Language } from '@isomorphic/locatorGenerators';
|
||||
import type { ParsedSelector } from '@isomorphic/selectorParser';
|
||||
import type { InjectedScript } from './injectedScript';
|
||||
|
||||
|
||||
type Rect = { x: number, y: number, width: number, height: number };
|
||||
|
||||
type RenderedHighlightEntry = {
|
||||
targetElement?: Element,
|
||||
color: string,
|
||||
borderColor?: string,
|
||||
fadeDuration?: number,
|
||||
highlightElement: HTMLElement,
|
||||
tooltipElement?: HTMLElement,
|
||||
box?: DOMRect,
|
||||
tooltipTop?: number,
|
||||
tooltipLeft?: number,
|
||||
tooltipText?: string,
|
||||
cssStyle?: string,
|
||||
};
|
||||
|
||||
export type HighlightEntry = {
|
||||
element?: Element,
|
||||
box?: Rect,
|
||||
color: string,
|
||||
borderColor?: string,
|
||||
fadeDuration?: number,
|
||||
tooltipText?: string,
|
||||
cssStyle?: string,
|
||||
};
|
||||
|
||||
export class Highlight {
|
||||
private _glassPaneElement: HTMLElement;
|
||||
private _glassPaneShadow: ShadowRoot;
|
||||
private _renderedEntries: RenderedHighlightEntry[] = [];
|
||||
private _actionPointElement: HTMLElement;
|
||||
private _titleElement: HTMLElement;
|
||||
private _userOverlayContainer: HTMLElement;
|
||||
private _userOverlays = new Map<string, HTMLElement>();
|
||||
private _userOverlayHidden = false;
|
||||
private _isUnderTest: boolean;
|
||||
private _injectedScript: InjectedScript;
|
||||
private _rafRequest: number | undefined;
|
||||
private _language: Language = 'javascript';
|
||||
|
||||
constructor(injectedScript: InjectedScript) {
|
||||
this._injectedScript = injectedScript;
|
||||
const document = injectedScript.document;
|
||||
this._isUnderTest = injectedScript.isUnderTest;
|
||||
this._glassPaneElement = document.createElement('x-pw-glass');
|
||||
this._glassPaneElement.setAttribute('popover', 'manual');
|
||||
this._glassPaneElement.style.inset = '0';
|
||||
this._glassPaneElement.style.width = '100%';
|
||||
this._glassPaneElement.style.height = '100%';
|
||||
this._glassPaneElement.style.maxWidth = 'none';
|
||||
this._glassPaneElement.style.maxHeight = 'none';
|
||||
this._glassPaneElement.style.padding = '0';
|
||||
this._glassPaneElement.style.margin = '0';
|
||||
this._glassPaneElement.style.border = 'none';
|
||||
this._glassPaneElement.style.overflow = 'visible';
|
||||
this._glassPaneElement.style.pointerEvents = 'none';
|
||||
this._glassPaneElement.style.display = 'flex';
|
||||
this._glassPaneElement.style.backgroundColor = 'transparent';
|
||||
this._actionPointElement = document.createElement('x-pw-action-point');
|
||||
this._actionPointElement.setAttribute('hidden', 'true');
|
||||
this._titleElement = document.createElement('x-pw-title');
|
||||
this._titleElement.setAttribute('hidden', 'true');
|
||||
this._userOverlayContainer = document.createElement('x-pw-user-overlays');
|
||||
this._userOverlayContainer.setAttribute('hidden', 'true');
|
||||
this._glassPaneShadow = this._glassPaneElement.attachShadow({ mode: this._isUnderTest ? 'open' : 'closed' });
|
||||
// workaround for firefox: when taking screenshots, it complains adoptedStyleSheets.push
|
||||
// is not a function, so we fallback to style injection
|
||||
if (typeof this._glassPaneShadow.adoptedStyleSheets.push === 'function') {
|
||||
const sheet = new this._injectedScript.window.CSSStyleSheet();
|
||||
sheet.replaceSync(highlightCSS);
|
||||
this._glassPaneShadow.adoptedStyleSheets.push(sheet);
|
||||
} else {
|
||||
const styleElement = this._injectedScript.document.createElement('style');
|
||||
styleElement.textContent = highlightCSS;
|
||||
this._glassPaneShadow.appendChild(styleElement);
|
||||
}
|
||||
this._glassPaneShadow.appendChild(this._actionPointElement);
|
||||
this._glassPaneShadow.appendChild(this._titleElement);
|
||||
this._glassPaneShadow.appendChild(this._userOverlayContainer);
|
||||
}
|
||||
|
||||
install() {
|
||||
// NOTE: document.documentElement can be null: https://github.com/microsoft/TypeScript/issues/50078
|
||||
if (!this._injectedScript.document.documentElement)
|
||||
return;
|
||||
if (!this._injectedScript.document.documentElement.contains(this._glassPaneElement) || this._glassPaneElement.nextElementSibling)
|
||||
this._injectedScript.document.documentElement.appendChild(this._glassPaneElement);
|
||||
this._bringToFront();
|
||||
}
|
||||
|
||||
private _bringToFront() {
|
||||
this._glassPaneElement.hidePopover();
|
||||
this._glassPaneElement.showPopover();
|
||||
}
|
||||
|
||||
setLanguage(language: Language) {
|
||||
this._language = language;
|
||||
}
|
||||
|
||||
runHighlightOnRaf(selector: ParsedSelector) {
|
||||
if (this._rafRequest)
|
||||
this._injectedScript.utils.builtins.cancelAnimationFrame(this._rafRequest);
|
||||
const elements = this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement);
|
||||
const locator = asLocator(this._language, stringifySelector(selector));
|
||||
const color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f';
|
||||
this.updateHighlight(elements.map((element, index) => {
|
||||
const suffix = elements.length > 1 ? ` [${index + 1} of ${elements.length}]` : '';
|
||||
return { element, color, tooltipText: locator + suffix };
|
||||
}));
|
||||
this._rafRequest = this._injectedScript.utils.builtins.requestAnimationFrame(() => this.runHighlightOnRaf(selector));
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
if (this._rafRequest)
|
||||
this._injectedScript.utils.builtins.cancelAnimationFrame(this._rafRequest);
|
||||
this._glassPaneElement.remove();
|
||||
}
|
||||
|
||||
showActionPoint(x: number, y: number, fadeDuration?: number) {
|
||||
this._actionPointElement.style.top = y + 'px';
|
||||
this._actionPointElement.style.left = x + 'px';
|
||||
this._actionPointElement.hidden = false;
|
||||
if (fadeDuration)
|
||||
this._actionPointElement.style.animation = `pw-fade-out ${fadeDuration}ms ease-out forwards`;
|
||||
else
|
||||
this._actionPointElement.style.animation = '';
|
||||
}
|
||||
|
||||
hideActionPoint() {
|
||||
this._actionPointElement.hidden = true;
|
||||
}
|
||||
|
||||
showActionTitle(text: string, fadeDuration: number, position?: string, fontSize?: number) {
|
||||
this._titleElement.textContent = text;
|
||||
this._titleElement.hidden = false;
|
||||
if (fadeDuration) {
|
||||
const fadeTime = fadeDuration / 4;
|
||||
this._titleElement.style.animation = `pw-fade-out ${fadeTime}ms ease-out ${fadeDuration - fadeTime}ms forwards`;
|
||||
} else {
|
||||
this._titleElement.style.animation = '';
|
||||
}
|
||||
|
||||
// Reset positioning
|
||||
this._titleElement.style.top = '';
|
||||
this._titleElement.style.bottom = '';
|
||||
this._titleElement.style.left = '';
|
||||
this._titleElement.style.right = '';
|
||||
this._titleElement.style.transform = '';
|
||||
|
||||
switch (position) {
|
||||
case 'top-left':
|
||||
this._titleElement.style.top = '6px';
|
||||
this._titleElement.style.left = '6px';
|
||||
break;
|
||||
case 'top':
|
||||
this._titleElement.style.top = '6px';
|
||||
this._titleElement.style.left = '50%';
|
||||
this._titleElement.style.transform = 'translateX(-50%)';
|
||||
break;
|
||||
case 'bottom-left':
|
||||
this._titleElement.style.bottom = '6px';
|
||||
this._titleElement.style.left = '6px';
|
||||
break;
|
||||
case 'bottom':
|
||||
this._titleElement.style.bottom = '6px';
|
||||
this._titleElement.style.left = '50%';
|
||||
this._titleElement.style.transform = 'translateX(-50%)';
|
||||
break;
|
||||
case 'bottom-right':
|
||||
this._titleElement.style.bottom = '6px';
|
||||
this._titleElement.style.right = '6px';
|
||||
break;
|
||||
case 'top-right':
|
||||
default:
|
||||
this._titleElement.style.top = '6px';
|
||||
this._titleElement.style.right = '6px';
|
||||
break;
|
||||
}
|
||||
|
||||
if (fontSize)
|
||||
this._titleElement.style.fontSize = fontSize + 'px';
|
||||
}
|
||||
|
||||
hideActionTitle() {
|
||||
this._titleElement.hidden = true;
|
||||
}
|
||||
|
||||
addUserOverlay(id: string, html: string) {
|
||||
const element = this._injectedScript.document.createElement('div');
|
||||
element.className = 'x-pw-user-overlay';
|
||||
element.innerHTML = html;
|
||||
// Mild sanitization for convenience.
|
||||
for (const script of element.querySelectorAll('script'))
|
||||
script.remove();
|
||||
for (const el of element.querySelectorAll('*')) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
if (attr.name.startsWith('on'))
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
this._userOverlays.set(id, element);
|
||||
this._userOverlayContainer.appendChild(element);
|
||||
this._userOverlayContainer.hidden = this._userOverlayHidden;
|
||||
return id;
|
||||
}
|
||||
|
||||
getUserOverlay(id: string): HTMLElement | undefined {
|
||||
return this._userOverlays.get(id);
|
||||
}
|
||||
|
||||
removeUserOverlay(id: string) {
|
||||
const element = this._userOverlays.get(id);
|
||||
if (element) {
|
||||
element.remove();
|
||||
this._userOverlays.delete(id);
|
||||
}
|
||||
if (this._userOverlays.size === 0)
|
||||
this._userOverlayContainer.hidden = true;
|
||||
}
|
||||
|
||||
setUserOverlaysVisible(visible: boolean) {
|
||||
this._userOverlayHidden = !visible;
|
||||
this._userOverlayContainer.hidden = !visible || this._userOverlays.size === 0;
|
||||
}
|
||||
|
||||
clearHighlight() {
|
||||
for (const entry of this._renderedEntries) {
|
||||
entry.highlightElement?.remove();
|
||||
entry.tooltipElement?.remove();
|
||||
}
|
||||
this._renderedEntries = [];
|
||||
}
|
||||
|
||||
maskElements(elements: Element[], color: string) {
|
||||
this.updateHighlight(elements.map(element => ({ element, color })));
|
||||
}
|
||||
|
||||
updateHighlight(entries: HighlightEntry[]) {
|
||||
// Code below should trigger one layout and leave with the
|
||||
// destroyed layout.
|
||||
|
||||
if (this._highlightIsUpToDate(entries))
|
||||
return;
|
||||
|
||||
// 1. Destroy the layout
|
||||
this.clearHighlight();
|
||||
|
||||
for (const entry of entries) {
|
||||
const highlightElement = this._createHighlightElement();
|
||||
this._glassPaneShadow.appendChild(highlightElement);
|
||||
|
||||
let tooltipElement;
|
||||
if (entry.tooltipText) {
|
||||
tooltipElement = this._injectedScript.document.createElement('x-pw-tooltip');
|
||||
this._glassPaneShadow.appendChild(tooltipElement);
|
||||
tooltipElement.style.top = '0';
|
||||
tooltipElement.style.left = '0';
|
||||
tooltipElement.style.display = 'flex';
|
||||
const lineElement = this._injectedScript.document.createElement('x-pw-tooltip-line');
|
||||
lineElement.textContent = entry.tooltipText;
|
||||
tooltipElement.appendChild(lineElement);
|
||||
}
|
||||
this._renderedEntries.push({ targetElement: entry.element, box: toDOMRect(entry.box), color: entry.color, borderColor: entry.borderColor, fadeDuration: entry.fadeDuration, cssStyle: entry.cssStyle, tooltipElement, highlightElement });
|
||||
}
|
||||
|
||||
// 2. Trigger layout while positioning tooltips and computing bounding boxes.
|
||||
for (const entry of this._renderedEntries) {
|
||||
if (!entry.box && !entry.targetElement)
|
||||
continue;
|
||||
entry.box = entry.box || entry.targetElement!.getBoundingClientRect();
|
||||
if (!entry.tooltipElement)
|
||||
continue;
|
||||
|
||||
// Position tooltip, if any.
|
||||
const { anchorLeft, anchorTop } = this.tooltipPosition(entry.box, entry.tooltipElement);
|
||||
entry.tooltipTop = anchorTop;
|
||||
entry.tooltipLeft = anchorLeft;
|
||||
}
|
||||
|
||||
// 3. Destroy the layout again.
|
||||
for (const entry of this._renderedEntries) {
|
||||
if (entry.tooltipElement) {
|
||||
entry.tooltipElement.style.top = entry.tooltipTop + 'px';
|
||||
entry.tooltipElement.style.left = entry.tooltipLeft + 'px';
|
||||
}
|
||||
const box = entry.box!;
|
||||
entry.highlightElement.style.backgroundColor = entry.color;
|
||||
entry.highlightElement.style.left = box.x + 'px';
|
||||
entry.highlightElement.style.top = box.y + 'px';
|
||||
entry.highlightElement.style.width = box.width + 'px';
|
||||
entry.highlightElement.style.height = box.height + 'px';
|
||||
entry.highlightElement.style.display = 'block';
|
||||
if (entry.borderColor)
|
||||
entry.highlightElement.style.border = '2px solid ' + entry.borderColor;
|
||||
if (entry.fadeDuration)
|
||||
entry.highlightElement.style.animation = `pw-fade-out ${entry.fadeDuration}ms ease-out forwards`;
|
||||
if (entry.cssStyle)
|
||||
entry.highlightElement.style.cssText += ';' + entry.cssStyle;
|
||||
|
||||
if (this._isUnderTest)
|
||||
console.error('Highlight box for test: ' + JSON.stringify({ x: box.x, y: box.y, width: box.width, height: box.height })); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
||||
firstBox(): DOMRect | undefined {
|
||||
return this._renderedEntries[0]?.box;
|
||||
}
|
||||
|
||||
firstTooltipBox(): DOMRect | undefined {
|
||||
const entry = this._renderedEntries[0];
|
||||
if (!entry || !entry.tooltipElement || entry.tooltipLeft === undefined || entry.tooltipTop === undefined)
|
||||
return;
|
||||
return {
|
||||
x: entry.tooltipLeft,
|
||||
y: entry.tooltipTop,
|
||||
left: entry.tooltipLeft,
|
||||
top: entry.tooltipTop,
|
||||
width: entry.tooltipElement.offsetWidth,
|
||||
height: entry.tooltipElement.offsetHeight,
|
||||
bottom: entry.tooltipTop + entry.tooltipElement.offsetHeight,
|
||||
right: entry.tooltipLeft + entry.tooltipElement.offsetWidth,
|
||||
toJSON: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// Note: there is a copy of this method in dialog.tsx. Please fix bugs in both places.
|
||||
tooltipPosition(box: DOMRect, tooltipElement: HTMLElement) {
|
||||
const tooltipWidth = tooltipElement.offsetWidth;
|
||||
const tooltipHeight = tooltipElement.offsetHeight;
|
||||
const totalWidth = this._glassPaneElement.offsetWidth;
|
||||
const totalHeight = this._glassPaneElement.offsetHeight;
|
||||
|
||||
let anchorLeft = Math.max(5, box.left);
|
||||
if (anchorLeft + tooltipWidth > totalWidth - 5)
|
||||
anchorLeft = totalWidth - tooltipWidth - 5;
|
||||
let anchorTop = Math.max(0, box.bottom) + 5;
|
||||
if (anchorTop + tooltipHeight > totalHeight - 5) {
|
||||
// If can't fit below, either position above...
|
||||
if (Math.max(0, box.top) > tooltipHeight + 5) {
|
||||
anchorTop = Math.max(0, box.top) - tooltipHeight - 5;
|
||||
} else {
|
||||
// Or on top in case of large element
|
||||
anchorTop = totalHeight - 5 - tooltipHeight;
|
||||
}
|
||||
}
|
||||
return { anchorLeft, anchorTop };
|
||||
}
|
||||
|
||||
private _highlightIsUpToDate(entries: HighlightEntry[]): boolean {
|
||||
if (entries.length !== this._renderedEntries.length)
|
||||
return false;
|
||||
for (let i = 0; i < this._renderedEntries.length; ++i) {
|
||||
if (entries[i].element !== this._renderedEntries[i].targetElement)
|
||||
return false;
|
||||
if (entries[i].color !== this._renderedEntries[i].color)
|
||||
return false;
|
||||
const oldBox = this._renderedEntries[i].box;
|
||||
if (!oldBox)
|
||||
return false;
|
||||
const box = entries[i].box ? toDOMRect(entries[i].box!) : entries[i].element!.getBoundingClientRect();
|
||||
if (box.top !== oldBox.top || box.right !== oldBox.right || box.bottom !== oldBox.bottom || box.left !== oldBox.left)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _createHighlightElement(): HTMLElement {
|
||||
return this._injectedScript.document.createElement('x-pw-highlight');
|
||||
}
|
||||
|
||||
appendChild(element: Element) {
|
||||
this._glassPaneShadow.appendChild(element);
|
||||
}
|
||||
|
||||
onGlassPaneClick(handler: (event: MouseEvent) => void) {
|
||||
this._glassPaneElement.style.pointerEvents = 'auto';
|
||||
this._glassPaneElement.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
|
||||
this._glassPaneElement.addEventListener('click', handler);
|
||||
}
|
||||
|
||||
offGlassPaneClick(handler: (event: MouseEvent) => void) {
|
||||
this._glassPaneElement.style.pointerEvents = 'none';
|
||||
this._glassPaneElement.style.backgroundColor = 'transparent';
|
||||
this._glassPaneElement.removeEventListener('click', handler);
|
||||
}
|
||||
}
|
||||
|
||||
function toDOMRect(box: Rect): DOMRect;
|
||||
function toDOMRect(box: Rect | undefined): DOMRect | undefined;
|
||||
function toDOMRect(box: Rect | undefined): DOMRect | undefined {
|
||||
if (!box)
|
||||
return undefined;
|
||||
return new DOMRect(box.x, box.y, box.width, box.height);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
function boxRightOf(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined {
|
||||
const distance = box1.left - box2.right;
|
||||
if (distance < 0 || (maxDistance !== undefined && distance > maxDistance))
|
||||
return;
|
||||
return distance + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0);
|
||||
}
|
||||
|
||||
function boxLeftOf(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined {
|
||||
const distance = box2.left - box1.right;
|
||||
if (distance < 0 || (maxDistance !== undefined && distance > maxDistance))
|
||||
return;
|
||||
return distance + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0);
|
||||
}
|
||||
|
||||
function boxAbove(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined {
|
||||
const distance = box2.top - box1.bottom;
|
||||
if (distance < 0 || (maxDistance !== undefined && distance > maxDistance))
|
||||
return;
|
||||
return distance + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0);
|
||||
}
|
||||
|
||||
function boxBelow(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined {
|
||||
const distance = box1.top - box2.bottom;
|
||||
if (distance < 0 || (maxDistance !== undefined && distance > maxDistance))
|
||||
return;
|
||||
return distance + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0);
|
||||
}
|
||||
|
||||
function boxNear(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined {
|
||||
const kThreshold = maxDistance === undefined ? 50 : maxDistance;
|
||||
let score = 0;
|
||||
if (box1.left - box2.right >= 0)
|
||||
score += box1.left - box2.right;
|
||||
if (box2.left - box1.right >= 0)
|
||||
score += box2.left - box1.right;
|
||||
if (box2.top - box1.bottom >= 0)
|
||||
score += box2.top - box1.bottom;
|
||||
if (box1.top - box2.bottom >= 0)
|
||||
score += box1.top - box2.bottom;
|
||||
return score > kThreshold ? undefined : score;
|
||||
}
|
||||
|
||||
export type LayoutSelectorName = 'left-of' | 'right-of' | 'above' | 'below' | 'near';
|
||||
export const kLayoutSelectorNames: LayoutSelectorName[] = ['left-of', 'right-of', 'above', 'below', 'near'];
|
||||
|
||||
export function layoutSelectorScore(name: LayoutSelectorName, element: Element, inner: Element[], maxDistance: number | undefined): number | undefined {
|
||||
const box = element.getBoundingClientRect();
|
||||
const scorer = { 'left-of': boxLeftOf, 'right-of': boxRightOf, 'above': boxAbove, 'below': boxBelow, 'near': boxNear }[name];
|
||||
let bestScore: number | undefined;
|
||||
for (const e of inner) {
|
||||
if (e === element)
|
||||
continue;
|
||||
const score = scorer(box, e.getBoundingClientRect(), maxDistance);
|
||||
if (score === undefined)
|
||||
continue;
|
||||
if (bestScore === undefined || score < bestScore)
|
||||
bestScore = score;
|
||||
}
|
||||
return bestScore;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Recorder must use any external dependencies through injectedScript.utils.
|
||||
# Otherwise it will end up with a copy of all modules it uses, and any
|
||||
# module-level globals will be duplicated, which leads to subtle bugs.
|
||||
[*]
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.764.646z"/></svg>
|
||||
|
After Width: | Height: | Size: 243 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M8 1a6.8 6.8 0 0 1 1.86.253 6.899 6.899 0 0 1 3.083 1.805 6.903 6.903 0 0 1 1.804 3.083C14.916 6.738 15 7.357 15 8s-.084 1.262-.253 1.86a6.9 6.9 0 0 1-.704 1.674 7.157 7.157 0 0 1-2.516 2.509 6.966 6.966 0 0 1-1.668.71A6.984 6.984 0 0 1 8 15a6.984 6.984 0 0 1-1.86-.246 7.098 7.098 0 0 1-1.674-.711 7.3 7.3 0 0 1-1.415-1.094 7.295 7.295 0 0 1-1.094-1.415 7.098 7.098 0 0 1-.71-1.675A6.985 6.985 0 0 1 1 8c0-.643.082-1.262.246-1.86a6.968 6.968 0 0 1 .711-1.667 7.156 7.156 0 0 1 2.509-2.516 6.895 6.895 0 0 1 1.675-.704A6.808 6.808 0 0 1 8 1z"/></svg>
|
||||
|
After Width: | Height: | Size: 662 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/></svg>
|
||||
|
After Width: | Height: | Size: 288 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M7.99993 6.00316C9.47266 6.00316 10.6666 7.19708 10.6666 8.66981C10.6666 10.1426 9.47266 11.3365 7.99993 11.3365C6.52715 11.3365 5.33324 10.1426 5.33324 8.66981C5.33324 7.19708 6.52715 6.00316 7.99993 6.00316ZM7.99993 7.00315C7.07946 7.00315 6.33324 7.74935 6.33324 8.66981C6.33324 9.59028 7.07946 10.3365 7.99993 10.3365C8.9204 10.3365 9.6666 9.59028 9.6666 8.66981C9.6666 7.74935 8.9204 7.00315 7.99993 7.00315ZM7.99993 3.66675C11.0756 3.66675 13.7307 5.76675 14.4673 8.70968C14.5344 8.97755 14.3716 9.24908 14.1037 9.31615C13.8358 9.38315 13.5643 9.22041 13.4973 8.95248C12.8713 6.45205 10.6141 4.66675 7.99993 4.66675C5.38454 4.66675 3.12664 6.45359 2.50182 8.95555C2.43491 9.22341 2.16348 9.38635 1.89557 9.31948C1.62766 9.25255 1.46471 8.98115 1.53162 8.71321C2.26701 5.76856 4.9229 3.66675 7.99993 3.66675Z"/></svg>
|
||||
|
After Width: | Height: | Size: 934 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.57 1.14l3.28 3.3.15.36v9.7l-.5.5h-11l-.5-.5v-13l.5-.5h7.72l.35.14zM10 5h3l-3-3v3zM3 2v12h10V6H9.5L9 5.5V2H3zm2.062 7.533l1.817-1.828L6.17 7 4 9.179v.707l2.171 2.174.707-.707-1.816-1.82zM8.8 7.714l.7-.709 2.189 2.175v.709L9.5 12.062l-.705-.709 1.831-1.82L8.8 7.714z"/></svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M5 3h2v2H5zm0 4h2v2H5zm0 4h2v2H5zm4-8h2v2H9zm0 4h2v2H9zm0 4h2v2H9z"/></svg>
|
||||
|
After Width: | Height: | Size: 187 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M1 3l1-1h12l1 1v6h-1V3H2v8h5v1H2l-1-1V3zm14.707 9.707L9 6v9.414l2.707-2.707h4zM10 13V8.414l3.293 3.293h-2L10 13z"/></svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M6.27 10.87h.71l4.56-4.56-.71-.71-4.2 4.21-1.92-1.92L4 8.6l2.27 2.27z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z"/></svg>
|
||||
|
After Width: | Height: | Size: 620 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M6 6h4v4H6z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z"/></svg>
|
||||
|
After Width: | Height: | Size: 562 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 6h8v1H4V6zm8 3H4v1h8V9z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M1 4l1-1h12l1 1v8l-1 1H2l-1-1V4zm1 0v8h12V4H2z"/></svg>
|
||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 11H1V13H15V11H16V14H15H1H0V11Z"/><path d="M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"/><path d="M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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 { Recorder } from './recorder';
|
||||
|
||||
import type { InjectedScript } from '../injectedScript';
|
||||
import type { RecorderDelegate } from './recorder';
|
||||
import type * as actions from '@recorder/actions';
|
||||
import type { ElementInfo, Mode, OverlayState, UIState } from '@recorder/recorderTypes';
|
||||
|
||||
interface Embedder {
|
||||
__pw_recorderPerformAction(action: actions.PerformOnRecordAction): Promise<void>;
|
||||
__pw_recorderRecordAction(action: actions.Action): Promise<void>;
|
||||
__pw_recorderState(): Promise<UIState>;
|
||||
__pw_recorderElementPicked(element: { selector: string, ariaSnapshot?: string }): Promise<void>;
|
||||
__pw_recorderSetMode(mode: Mode): Promise<void>;
|
||||
__pw_recorderSetOverlayState(state: OverlayState): Promise<void>;
|
||||
__pw_refreshOverlay(): void;
|
||||
}
|
||||
|
||||
export class PollingRecorder implements RecorderDelegate {
|
||||
private _recorder: Recorder;
|
||||
private _embedder: Embedder;
|
||||
private _pollRecorderModeTimer: number | undefined;
|
||||
private _lastStateJSON: string | undefined;
|
||||
|
||||
constructor(injectedScript: InjectedScript, options?: { recorderMode?: 'default' | 'api', hideToolbar?: boolean }) {
|
||||
this._recorder = new Recorder(injectedScript, options);
|
||||
this._embedder = injectedScript.window as any;
|
||||
|
||||
injectedScript.onGlobalListenersRemoved.add(() => this._recorder.installListeners());
|
||||
|
||||
const refreshOverlay = () => {
|
||||
this._lastStateJSON = undefined;
|
||||
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
|
||||
};
|
||||
this._embedder.__pw_refreshOverlay = refreshOverlay;
|
||||
refreshOverlay();
|
||||
}
|
||||
|
||||
private async _pollRecorderMode() {
|
||||
const pollPeriod = 1000;
|
||||
if (this._pollRecorderModeTimer)
|
||||
this._recorder.injectedScript.utils.builtins.clearTimeout(this._pollRecorderModeTimer);
|
||||
const state = await this._embedder.__pw_recorderState().catch(() => null);
|
||||
if (!state) {
|
||||
this._pollRecorderModeTimer = this._recorder.injectedScript.utils.builtins.setTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||
return;
|
||||
}
|
||||
|
||||
const stringifiedState = JSON.stringify(state);
|
||||
if (this._lastStateJSON !== stringifiedState) {
|
||||
this._lastStateJSON = stringifiedState;
|
||||
const win = this._recorder.document.defaultView!;
|
||||
if (win.top !== win) {
|
||||
// Only show action point in the main frame, since it is relative to the page's viewport.
|
||||
// Otherwise we'll see multiple action points at different locations.
|
||||
state.actionPoint = undefined;
|
||||
}
|
||||
this._recorder.setUIState(state, this);
|
||||
}
|
||||
|
||||
this._pollRecorderModeTimer = this._recorder.injectedScript.utils.builtins.setTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||
}
|
||||
|
||||
async performAction(action: actions.PerformOnRecordAction) {
|
||||
await this._embedder.__pw_recorderPerformAction(action);
|
||||
}
|
||||
|
||||
async recordAction(action: actions.Action): Promise<void> {
|
||||
await this._embedder.__pw_recorderRecordAction(action);
|
||||
}
|
||||
|
||||
async elementPicked(elementInfo: ElementInfo): Promise<void> {
|
||||
await this._embedder.__pw_recorderElementPicked(elementInfo);
|
||||
}
|
||||
|
||||
async setMode(mode: Mode): Promise<void> {
|
||||
await this._embedder.__pw_recorderSetMode(mode);
|
||||
}
|
||||
|
||||
async setOverlayState(state: OverlayState): Promise<void> {
|
||||
await this._embedder.__pw_recorderSetOverlayState(state);
|
||||
}
|
||||
}
|
||||
|
||||
export default PollingRecorder;
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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 } from '@isomorphic/selectorParser';
|
||||
import { normalizeWhiteSpace } from '@isomorphic/stringUtils';
|
||||
|
||||
import { beginAriaCaches, endAriaCaches, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils';
|
||||
import { matchesAttributePart } from './selectorUtils';
|
||||
|
||||
import type { AttributeSelectorOperator, AttributeSelectorPart } from '@isomorphic/selectorParser';
|
||||
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||
|
||||
type RoleEngineOptions = {
|
||||
role: string;
|
||||
name?: string | RegExp;
|
||||
nameOp?: '='|'*='|'|='|'^='|'$='|'~=';
|
||||
exact?: boolean;
|
||||
checked?: boolean | 'mixed';
|
||||
pressed?: boolean | 'mixed';
|
||||
selected?: boolean;
|
||||
expanded?: boolean;
|
||||
level?: number;
|
||||
disabled?: boolean;
|
||||
includeHidden?: boolean;
|
||||
};
|
||||
|
||||
const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden'];
|
||||
kSupportedAttributes.sort();
|
||||
|
||||
function validateSupportedRole(attr: string, roles: string[], role: string) {
|
||||
if (!roles.includes(role))
|
||||
throw new Error(`"${attr}" attribute is only supported for roles: ${roles.slice().sort().map(role => `"${role}"`).join(', ')}`);
|
||||
}
|
||||
|
||||
function validateSupportedValues(attr: AttributeSelectorPart, values: any[]) {
|
||||
if (attr.op !== '<truthy>' && !values.includes(attr.value))
|
||||
throw new Error(`"${attr.name}" must be one of ${values.map(v => JSON.stringify(v)).join(', ')}`);
|
||||
}
|
||||
|
||||
function validateSupportedOp(attr: AttributeSelectorPart, ops: AttributeSelectorOperator[]) {
|
||||
if (!ops.includes(attr.op))
|
||||
throw new Error(`"${attr.name}" does not support "${attr.op}" matcher`);
|
||||
}
|
||||
|
||||
function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleEngineOptions {
|
||||
const options: RoleEngineOptions = { role };
|
||||
for (const attr of attrs) {
|
||||
switch (attr.name) {
|
||||
case 'checked': {
|
||||
validateSupportedRole(attr.name, kAriaCheckedRoles, role);
|
||||
validateSupportedValues(attr, [true, false, 'mixed']);
|
||||
validateSupportedOp(attr, ['<truthy>', '=']);
|
||||
options.checked = attr.op === '<truthy>' ? true : attr.value;
|
||||
break;
|
||||
}
|
||||
case 'pressed': {
|
||||
validateSupportedRole(attr.name, kAriaPressedRoles, role);
|
||||
validateSupportedValues(attr, [true, false, 'mixed']);
|
||||
validateSupportedOp(attr, ['<truthy>', '=']);
|
||||
options.pressed = attr.op === '<truthy>' ? true : attr.value;
|
||||
break;
|
||||
}
|
||||
case 'selected': {
|
||||
validateSupportedRole(attr.name, kAriaSelectedRoles, role);
|
||||
validateSupportedValues(attr, [true, false]);
|
||||
validateSupportedOp(attr, ['<truthy>', '=']);
|
||||
options.selected = attr.op === '<truthy>' ? true : attr.value;
|
||||
break;
|
||||
}
|
||||
case 'expanded': {
|
||||
validateSupportedRole(attr.name, kAriaExpandedRoles, role);
|
||||
validateSupportedValues(attr, [true, false]);
|
||||
validateSupportedOp(attr, ['<truthy>', '=']);
|
||||
options.expanded = attr.op === '<truthy>' ? true : attr.value;
|
||||
break;
|
||||
}
|
||||
case 'level': {
|
||||
validateSupportedRole(attr.name, kAriaLevelRoles, role);
|
||||
// Level is a number, convert it from string.
|
||||
if (typeof attr.value === 'string')
|
||||
attr.value = +attr.value;
|
||||
if (attr.op !== '=' || typeof attr.value !== 'number' || Number.isNaN(attr.value))
|
||||
throw new Error(`"level" attribute must be compared to a number`);
|
||||
options.level = attr.value;
|
||||
break;
|
||||
}
|
||||
case 'disabled': {
|
||||
validateSupportedValues(attr, [true, false]);
|
||||
validateSupportedOp(attr, ['<truthy>', '=']);
|
||||
options.disabled = attr.op === '<truthy>' ? true : attr.value;
|
||||
break;
|
||||
}
|
||||
case 'name': {
|
||||
if (attr.op === '<truthy>')
|
||||
throw new Error(`"name" attribute must have a value`);
|
||||
if (typeof attr.value !== 'string' && !(attr.value instanceof RegExp))
|
||||
throw new Error(`"name" attribute must be a string or a regular expression`);
|
||||
options.name = attr.value;
|
||||
options.nameOp = attr.op;
|
||||
options.exact = attr.caseSensitive;
|
||||
break;
|
||||
}
|
||||
case 'include-hidden': {
|
||||
validateSupportedValues(attr, [true, false]);
|
||||
validateSupportedOp(attr, ['<truthy>', '=']);
|
||||
options.includeHidden = attr.op === '<truthy>' ? true : attr.value;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown attribute "${attr.name}", must be one of ${kSupportedAttributes.map(a => `"${a}"`).join(', ')}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: boolean): Element[] {
|
||||
const result: Element[] = [];
|
||||
const match = (element: Element) => {
|
||||
if (getAriaRole(element) !== options.role)
|
||||
return;
|
||||
if (options.selected !== undefined && getAriaSelected(element) !== options.selected)
|
||||
return;
|
||||
if (options.checked !== undefined && getAriaChecked(element) !== options.checked)
|
||||
return;
|
||||
if (options.pressed !== undefined && getAriaPressed(element) !== options.pressed)
|
||||
return;
|
||||
if (options.expanded !== undefined && getAriaExpanded(element) !== options.expanded)
|
||||
return;
|
||||
if (options.level !== undefined && getAriaLevel(element) !== options.level)
|
||||
return;
|
||||
if (options.disabled !== undefined && getAriaDisabled(element) !== options.disabled)
|
||||
return;
|
||||
if (!options.includeHidden) {
|
||||
const isHidden = isElementHiddenForAria(element);
|
||||
if (isHidden)
|
||||
return;
|
||||
}
|
||||
if (options.name !== undefined) {
|
||||
// Always normalize whitespace in the accessible name.
|
||||
const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden));
|
||||
if (typeof options.name === 'string')
|
||||
options.name = normalizeWhiteSpace(options.name);
|
||||
// internal:role assumes that [name="foo"i] also means substring.
|
||||
if (internal && !options.exact && options.nameOp === '=')
|
||||
options.nameOp = '*=';
|
||||
if (!matchesAttributePart(accessibleName, { name: '', jsonPath: [], op: options.nameOp || '=', value: options.name, caseSensitive: !!options.exact }))
|
||||
return;
|
||||
}
|
||||
result.push(element);
|
||||
};
|
||||
|
||||
const query = (root: Element | ShadowRoot | Document) => {
|
||||
const shadows: ShadowRoot[] = [];
|
||||
if ((root as Element).shadowRoot)
|
||||
shadows.push((root as Element).shadowRoot!);
|
||||
for (const element of root.querySelectorAll('*')) {
|
||||
match(element);
|
||||
if (element.shadowRoot)
|
||||
shadows.push(element.shadowRoot);
|
||||
}
|
||||
shadows.forEach(query);
|
||||
};
|
||||
|
||||
query(scope);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createRoleEngine(internal: boolean): SelectorEngine {
|
||||
return {
|
||||
queryAll: (scope: SelectorRoot, selector: string): Element[] => {
|
||||
const parsed = parseAttributeSelector(selector, true);
|
||||
const role = parsed.name.toLowerCase();
|
||||
if (!role)
|
||||
throw new Error(`Role must not be empty`);
|
||||
const options = validateAttributes(parsed.attributes, role);
|
||||
beginAriaCaches();
|
||||
try {
|
||||
return queryRole(scope, options, internal);
|
||||
} finally {
|
||||
endAriaCaches();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 SelectorRoot = Element | ShadowRoot | Document;
|
||||
|
||||
export interface SelectorEngine {
|
||||
queryAll(root: SelectorRoot, selector: string | any): Element[];
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
/**
|
||||
* 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 { customCSSNames } from '@isomorphic/selectorParser';
|
||||
import { normalizeWhiteSpace } from '@isomorphic/stringUtils';
|
||||
|
||||
import { isElementVisible, parentElementOrShadowHost } from './domUtils';
|
||||
import { layoutSelectorScore } from './layoutSelectorUtils';
|
||||
import { elementMatchesText, elementText, shouldSkipForTextMatching } from './selectorUtils';
|
||||
|
||||
import type { CSSComplexSelector, CSSComplexSelectorList, CSSFunctionArgument, CSSSimpleSelector } from '@isomorphic/cssParser';
|
||||
import type { LayoutSelectorName } from './layoutSelectorUtils';
|
||||
import type { ElementText } from './selectorUtils';
|
||||
|
||||
type QueryContext = {
|
||||
scope: Element | Document;
|
||||
pierceShadow: boolean;
|
||||
// When context expands to accommodate :scope matching, original scope is saved here.
|
||||
originalScope?: Element | Document;
|
||||
// Place for more options, e.g. normalizing whitespace.
|
||||
};
|
||||
export type Selector = any; // Opaque selector type.
|
||||
export interface SelectorEvaluator {
|
||||
query(context: QueryContext, selector: Selector): Element[];
|
||||
matches(element: Element, selector: Selector, context: QueryContext): boolean;
|
||||
}
|
||||
export interface SelectorEngine {
|
||||
matches?(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean;
|
||||
query?(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[];
|
||||
}
|
||||
|
||||
type QueryCache = Map<any, { rest: any[], result: any }[]>;
|
||||
|
||||
export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||
private _engines: Map<string, SelectorEngine>;
|
||||
private _cacheQueryCSS: QueryCache;
|
||||
private _cacheMatches: QueryCache;
|
||||
private _cacheQuery: QueryCache;
|
||||
private _cacheMatchesSimple: QueryCache;
|
||||
private _cacheMatchesParents: QueryCache;
|
||||
private _cacheCallMatches: QueryCache;
|
||||
private _cacheCallQuery: QueryCache;
|
||||
private _cacheQuerySimple: QueryCache;
|
||||
_cacheText: Map<Element | ShadowRoot, ElementText>;
|
||||
private _scoreMap: Map<Element, number> | undefined;
|
||||
private _retainCacheCounter = 0;
|
||||
|
||||
constructor() {
|
||||
this._cacheText = new Map();
|
||||
this._cacheQueryCSS = new Map();
|
||||
this._cacheMatches = new Map();
|
||||
this._cacheQuery = new Map();
|
||||
this._cacheMatchesSimple = new Map();
|
||||
this._cacheMatchesParents = new Map();
|
||||
this._cacheCallMatches = new Map();
|
||||
this._cacheCallQuery = new Map();
|
||||
this._cacheQuerySimple = new Map();
|
||||
|
||||
this._engines = new Map();
|
||||
this._engines.set('not', notEngine);
|
||||
this._engines.set('is', isEngine);
|
||||
this._engines.set('where', isEngine);
|
||||
this._engines.set('has', hasEngine);
|
||||
this._engines.set('scope', scopeEngine);
|
||||
this._engines.set('light', lightEngine);
|
||||
this._engines.set('visible', visibleEngine);
|
||||
this._engines.set('text', textEngine);
|
||||
this._engines.set('text-is', textIsEngine);
|
||||
this._engines.set('text-matches', textMatchesEngine);
|
||||
this._engines.set('has-text', hasTextEngine);
|
||||
this._engines.set('right-of', createLayoutEngine('right-of'));
|
||||
this._engines.set('left-of', createLayoutEngine('left-of'));
|
||||
this._engines.set('above', createLayoutEngine('above'));
|
||||
this._engines.set('below', createLayoutEngine('below'));
|
||||
this._engines.set('near', createLayoutEngine('near'));
|
||||
this._engines.set('nth-match', nthMatchEngine);
|
||||
|
||||
const allNames = [...this._engines.keys()];
|
||||
allNames.sort();
|
||||
const parserNames = [...customCSSNames];
|
||||
parserNames.sort();
|
||||
if (allNames.join('|') !== parserNames.join('|'))
|
||||
throw new Error(`Please keep customCSSNames in sync with evaluator engines: ${allNames.join('|')} vs ${parserNames.join('|')}`);
|
||||
}
|
||||
|
||||
begin() {
|
||||
++this._retainCacheCounter;
|
||||
}
|
||||
|
||||
end() {
|
||||
--this._retainCacheCounter;
|
||||
if (!this._retainCacheCounter) {
|
||||
this._cacheQueryCSS.clear();
|
||||
this._cacheMatches.clear();
|
||||
this._cacheQuery.clear();
|
||||
this._cacheMatchesSimple.clear();
|
||||
this._cacheMatchesParents.clear();
|
||||
this._cacheCallMatches.clear();
|
||||
this._cacheCallQuery.clear();
|
||||
this._cacheQuerySimple.clear();
|
||||
this._cacheText.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private _cached<T>(cache: QueryCache, main: any, rest: any[], cb: () => T): T {
|
||||
if (!cache.has(main))
|
||||
cache.set(main, []);
|
||||
const entries = cache.get(main)!;
|
||||
const entry = entries.find(e => rest.every((value, index) => e.rest[index] === value));
|
||||
if (entry)
|
||||
return entry.result as T;
|
||||
const result = cb();
|
||||
entries.push({ rest, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
private _checkSelector(s: Selector): CSSComplexSelector | CSSComplexSelectorList {
|
||||
const wellFormed = typeof s === 'object' && s &&
|
||||
(Array.isArray(s) || ('simples' in s) && (s.simples.length));
|
||||
if (!wellFormed)
|
||||
throw new Error(`Malformed selector "${s}"`);
|
||||
return s as CSSComplexSelector | CSSComplexSelectorList;
|
||||
}
|
||||
|
||||
matches(element: Element, s: Selector, context: QueryContext): boolean {
|
||||
const selector = this._checkSelector(s);
|
||||
this.begin();
|
||||
try {
|
||||
return this._cached<boolean>(this._cacheMatches, element, [selector, context.scope, context.pierceShadow, context.originalScope], () => {
|
||||
if (Array.isArray(selector))
|
||||
return this._matchesEngine(isEngine, element, selector, context);
|
||||
if (this._hasScopeClause(selector))
|
||||
context = this._expandContextForScopeMatching(context);
|
||||
if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context))
|
||||
return false;
|
||||
return this._matchesParents(element, selector, selector.simples.length - 2, context);
|
||||
});
|
||||
} finally {
|
||||
this.end();
|
||||
}
|
||||
}
|
||||
|
||||
query(context: QueryContext, s: any): Element[] {
|
||||
const selector = this._checkSelector(s);
|
||||
this.begin();
|
||||
try {
|
||||
return this._cached<Element[]>(this._cacheQuery, selector, [context.scope, context.pierceShadow, context.originalScope], () => {
|
||||
if (Array.isArray(selector))
|
||||
return this._queryEngine(isEngine, context, selector);
|
||||
if (this._hasScopeClause(selector))
|
||||
context = this._expandContextForScopeMatching(context);
|
||||
|
||||
// query() recursively calls itself, so we set up a new map for this particular query() call.
|
||||
const previousScoreMap = this._scoreMap;
|
||||
this._scoreMap = new Map();
|
||||
let elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector);
|
||||
elements = elements.filter(element => this._matchesParents(element, selector, selector.simples.length - 2, context));
|
||||
if (this._scoreMap.size) {
|
||||
elements.sort((a, b) => {
|
||||
const aScore = this._scoreMap!.get(a);
|
||||
const bScore = this._scoreMap!.get(b);
|
||||
if (aScore === bScore)
|
||||
return 0;
|
||||
if (aScore === undefined)
|
||||
return 1;
|
||||
if (bScore === undefined)
|
||||
return -1;
|
||||
return aScore - bScore;
|
||||
});
|
||||
}
|
||||
this._scoreMap = previousScoreMap;
|
||||
|
||||
return elements;
|
||||
});
|
||||
} finally {
|
||||
this.end();
|
||||
}
|
||||
}
|
||||
|
||||
_markScore(element: Element, score: number) {
|
||||
// HACK ALERT: temporary marks an element with a score, to be used
|
||||
// for sorting at the end of the query().
|
||||
if (this._scoreMap)
|
||||
this._scoreMap.set(element, score);
|
||||
}
|
||||
|
||||
private _hasScopeClause(selector: CSSComplexSelector): boolean {
|
||||
return selector.simples.some(simple => simple.selector.functions.some(f => f.name === 'scope'));
|
||||
}
|
||||
|
||||
private _expandContextForScopeMatching(context: QueryContext): QueryContext {
|
||||
if (context.scope.nodeType !== 1 /* Node.ELEMENT_NODE */)
|
||||
return context;
|
||||
const scope = parentElementOrShadowHost(context.scope as Element);
|
||||
if (!scope)
|
||||
return context;
|
||||
return { ...context, scope, originalScope: context.originalScope || context.scope };
|
||||
}
|
||||
|
||||
private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean {
|
||||
return this._cached<boolean>(this._cacheMatchesSimple, element, [simple, context.scope, context.pierceShadow, context.originalScope], () => {
|
||||
if (element === context.scope)
|
||||
return false;
|
||||
if (simple.css && !this._matchesCSS(element, simple.css))
|
||||
return false;
|
||||
for (const func of simple.functions) {
|
||||
if (!this._matchesEngine(this._getEngine(func.name), element, func.args, context))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private _querySimple(context: QueryContext, simple: CSSSimpleSelector): Element[] {
|
||||
if (!simple.functions.length)
|
||||
return this._queryCSS(context, simple.css || '*');
|
||||
|
||||
return this._cached<Element[]>(this._cacheQuerySimple, simple, [context.scope, context.pierceShadow, context.originalScope], () => {
|
||||
let css = simple.css;
|
||||
const funcs = simple.functions;
|
||||
if (css === '*' && funcs.length)
|
||||
css = undefined;
|
||||
|
||||
let elements: Element[];
|
||||
let firstIndex = -1;
|
||||
if (css !== undefined) {
|
||||
elements = this._queryCSS(context, css);
|
||||
} else {
|
||||
firstIndex = funcs.findIndex(func => this._getEngine(func.name).query !== undefined);
|
||||
if (firstIndex === -1)
|
||||
firstIndex = 0;
|
||||
elements = this._queryEngine(this._getEngine(funcs[firstIndex].name), context, funcs[firstIndex].args);
|
||||
}
|
||||
for (let i = 0; i < funcs.length; i++) {
|
||||
if (i === firstIndex)
|
||||
continue;
|
||||
const engine = this._getEngine(funcs[i].name);
|
||||
if (engine.matches !== undefined)
|
||||
elements = elements.filter(e => this._matchesEngine(engine, e, funcs[i].args, context));
|
||||
}
|
||||
for (let i = 0; i < funcs.length; i++) {
|
||||
if (i === firstIndex)
|
||||
continue;
|
||||
const engine = this._getEngine(funcs[i].name);
|
||||
if (engine.matches === undefined)
|
||||
elements = elements.filter(e => this._matchesEngine(engine, e, funcs[i].args, context));
|
||||
}
|
||||
return elements;
|
||||
});
|
||||
}
|
||||
|
||||
private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean {
|
||||
if (index < 0)
|
||||
return true;
|
||||
return this._cached<boolean>(this._cacheMatchesParents, element, [complex, index, context.scope, context.pierceShadow, context.originalScope], () => {
|
||||
const { selector: simple, combinator } = complex.simples[index];
|
||||
if (combinator === '>') {
|
||||
const parent = parentElementOrShadowHostInContext(element, context);
|
||||
if (!parent || !this._matchesSimple(parent, simple, context))
|
||||
return false;
|
||||
return this._matchesParents(parent, complex, index - 1, context);
|
||||
}
|
||||
if (combinator === '+') {
|
||||
const previousSibling = previousSiblingInContext(element, context);
|
||||
if (!previousSibling || !this._matchesSimple(previousSibling, simple, context))
|
||||
return false;
|
||||
return this._matchesParents(previousSibling, complex, index - 1, context);
|
||||
}
|
||||
if (combinator === '') {
|
||||
let parent = parentElementOrShadowHostInContext(element, context);
|
||||
while (parent) {
|
||||
if (this._matchesSimple(parent, simple, context)) {
|
||||
if (this._matchesParents(parent, complex, index - 1, context))
|
||||
return true;
|
||||
if (complex.simples[index - 1].combinator === '')
|
||||
break;
|
||||
}
|
||||
parent = parentElementOrShadowHostInContext(parent, context);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (combinator === '~') {
|
||||
let previousSibling = previousSiblingInContext(element, context);
|
||||
while (previousSibling) {
|
||||
if (this._matchesSimple(previousSibling, simple, context)) {
|
||||
if (this._matchesParents(previousSibling, complex, index - 1, context))
|
||||
return true;
|
||||
if (complex.simples[index - 1].combinator === '~')
|
||||
break;
|
||||
}
|
||||
previousSibling = previousSiblingInContext(previousSibling, context);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (combinator === '>=') {
|
||||
let parent: Element | undefined = element;
|
||||
while (parent) {
|
||||
if (this._matchesSimple(parent, simple, context)) {
|
||||
if (this._matchesParents(parent, complex, index - 1, context))
|
||||
return true;
|
||||
if (complex.simples[index - 1].combinator === '')
|
||||
break;
|
||||
}
|
||||
parent = parentElementOrShadowHostInContext(parent, context);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Unsupported combinator "${combinator}"`);
|
||||
});
|
||||
}
|
||||
|
||||
private _matchesEngine(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean {
|
||||
if (engine.matches)
|
||||
return this._callMatches(engine, element, args, context);
|
||||
if (engine.query)
|
||||
return this._callQuery(engine, args, context).includes(element);
|
||||
throw new Error(`Selector engine should implement "matches" or "query"`);
|
||||
}
|
||||
|
||||
private _queryEngine(engine: SelectorEngine, context: QueryContext, args: CSSFunctionArgument[]): Element[] {
|
||||
if (engine.query)
|
||||
return this._callQuery(engine, args, context);
|
||||
if (engine.matches)
|
||||
return this._queryCSS(context, '*').filter(element => this._callMatches(engine, element, args, context));
|
||||
throw new Error(`Selector engine should implement "matches" or "query"`);
|
||||
}
|
||||
|
||||
private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean {
|
||||
return this._cached<boolean>(this._cacheCallMatches, element, [engine, context.scope, context.pierceShadow, context.originalScope, ...args], () => {
|
||||
return engine.matches!(element, args, context, this);
|
||||
});
|
||||
}
|
||||
|
||||
private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] {
|
||||
return this._cached<Element[]>(this._cacheCallQuery, engine, [context.scope, context.pierceShadow, context.originalScope, ...args], () => {
|
||||
return engine.query!(context, args, this);
|
||||
});
|
||||
}
|
||||
|
||||
private _matchesCSS(element: Element, css: string): boolean {
|
||||
return element.matches(css);
|
||||
}
|
||||
|
||||
_queryCSS(context: QueryContext, css: string): Element[] {
|
||||
return this._cached<Element[]>(this._cacheQueryCSS, css, [context.scope, context.pierceShadow, context.originalScope], () => {
|
||||
let result: Element[] = [];
|
||||
function query(root: Element | ShadowRoot | Document) {
|
||||
result = result.concat([...root.querySelectorAll(css)]);
|
||||
if (!context.pierceShadow)
|
||||
return;
|
||||
if ((root as Element).shadowRoot)
|
||||
query((root as Element).shadowRoot!);
|
||||
for (const element of root.querySelectorAll('*')) {
|
||||
if (element.shadowRoot)
|
||||
query(element.shadowRoot);
|
||||
}
|
||||
}
|
||||
query(context.scope);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private _getEngine(name: string): SelectorEngine {
|
||||
const engine = this._engines.get(name);
|
||||
if (!engine)
|
||||
throw new Error(`Unknown selector engine "${name}"`);
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
||||
const isEngine: SelectorEngine = {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
if (args.length === 0)
|
||||
throw new Error(`"is" engine expects non-empty selector list`);
|
||||
return args.some(selector => evaluator.matches(element, selector, context));
|
||||
},
|
||||
|
||||
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
|
||||
if (args.length === 0)
|
||||
throw new Error(`"is" engine expects non-empty selector list`);
|
||||
let elements: Element[] = [];
|
||||
for (const arg of args)
|
||||
elements = elements.concat(evaluator.query(context, arg));
|
||||
return args.length === 1 ? elements : sortInDOMOrder(elements);
|
||||
},
|
||||
};
|
||||
|
||||
const hasEngine: SelectorEngine = {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
if (args.length === 0)
|
||||
throw new Error(`"has" engine expects non-empty selector list`);
|
||||
return evaluator.query({ ...context, scope: element }, args).length > 0;
|
||||
},
|
||||
|
||||
// TODO: we can implement efficient "query" by matching "args" and returning
|
||||
// all parents/descendants, just have to be careful with the ":scope" matching.
|
||||
};
|
||||
|
||||
const scopeEngine: SelectorEngine = {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
if (args.length !== 0)
|
||||
throw new Error(`"scope" engine expects no arguments`);
|
||||
const actualScope = context.originalScope || context.scope;
|
||||
if (actualScope.nodeType === 9 /* Node.DOCUMENT_NODE */)
|
||||
return element === (actualScope as Document).documentElement;
|
||||
return element === actualScope;
|
||||
},
|
||||
|
||||
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
|
||||
if (args.length !== 0)
|
||||
throw new Error(`"scope" engine expects no arguments`);
|
||||
const actualScope = context.originalScope || context.scope;
|
||||
if (actualScope.nodeType === 9 /* Node.DOCUMENT_NODE */) {
|
||||
const root = (actualScope as Document).documentElement;
|
||||
return root ? [root] : [];
|
||||
}
|
||||
if (actualScope.nodeType === 1 /* Node.ELEMENT_NODE */)
|
||||
return [actualScope as Element];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const notEngine: SelectorEngine = {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
if (args.length === 0)
|
||||
throw new Error(`"not" engine expects non-empty selector list`);
|
||||
return !evaluator.matches(element, args, context);
|
||||
},
|
||||
};
|
||||
|
||||
const lightEngine: SelectorEngine = {
|
||||
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
|
||||
return evaluator.query({ ...context, pierceShadow: false }, args);
|
||||
},
|
||||
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
return evaluator.matches(element, args, { ...context, pierceShadow: false });
|
||||
}
|
||||
};
|
||||
|
||||
const visibleEngine: SelectorEngine = {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
if (args.length)
|
||||
throw new Error(`"visible" engine expects no arguments`);
|
||||
return isElementVisible(element);
|
||||
}
|
||||
};
|
||||
|
||||
const textEngine: SelectorEngine = {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
if (args.length !== 1 || typeof args[0] !== 'string')
|
||||
throw new Error(`"text" engine expects a single string`);
|
||||
const text = normalizeWhiteSpace(args[0]).toLowerCase();
|
||||
const matcher = (elementText: ElementText) => elementText.normalized.toLowerCase().includes(text);
|
||||
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self';
|
||||
},
|
||||
};
|
||||
|
||||
const textIsEngine: SelectorEngine = {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
if (args.length !== 1 || typeof args[0] !== 'string')
|
||||
throw new Error(`"text-is" engine expects a single string`);
|
||||
const text = normalizeWhiteSpace(args[0]);
|
||||
const matcher = (elementText: ElementText) => {
|
||||
if (!text && !elementText.immediate.length)
|
||||
return true;
|
||||
return elementText.immediate.some(s => normalizeWhiteSpace(s) === text);
|
||||
};
|
||||
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) !== 'none';
|
||||
},
|
||||
};
|
||||
|
||||
const textMatchesEngine: SelectorEngine = {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string'))
|
||||
throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`);
|
||||
const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined);
|
||||
const matcher = (elementText: ElementText) => re.test(elementText.full);
|
||||
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self';
|
||||
},
|
||||
};
|
||||
|
||||
const hasTextEngine: SelectorEngine = {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
if (args.length !== 1 || typeof args[0] !== 'string')
|
||||
throw new Error(`"has-text" engine expects a single string`);
|
||||
if (shouldSkipForTextMatching(element))
|
||||
return false;
|
||||
const text = normalizeWhiteSpace(args[0]).toLowerCase();
|
||||
const matcher = (elementText: ElementText) => elementText.normalized.toLowerCase().includes(text);
|
||||
return matcher(elementText((evaluator as SelectorEvaluatorImpl)._cacheText, element));
|
||||
},
|
||||
};
|
||||
|
||||
function createLayoutEngine(name: LayoutSelectorName): SelectorEngine {
|
||||
return {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
const maxDistance = args.length && typeof args[args.length - 1] === 'number' ? args[args.length - 1] : undefined;
|
||||
const queryArgs = maxDistance === undefined ? args : args.slice(0, args.length - 1);
|
||||
if (args.length < 1 + (maxDistance === undefined ? 0 : 1))
|
||||
throw new Error(`"${name}" engine expects a selector list and optional maximum distance in pixels`);
|
||||
const inner = evaluator.query(context, queryArgs);
|
||||
const score = layoutSelectorScore(name, element, inner, maxDistance);
|
||||
if (score === undefined)
|
||||
return false;
|
||||
(evaluator as SelectorEvaluatorImpl)._markScore(element, score);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const nthMatchEngine: SelectorEngine = {
|
||||
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
|
||||
let index = args[args.length - 1];
|
||||
if (args.length < 2)
|
||||
throw new Error(`"nth-match" engine expects non-empty selector list and an index argument`);
|
||||
if (typeof index !== 'number' || index < 1)
|
||||
throw new Error(`"nth-match" engine expects a one-based index as the last argument`);
|
||||
const elements = isEngine.query!(context, args.slice(0, args.length - 1), evaluator);
|
||||
index--; // one-based
|
||||
return index < elements.length ? [elements[index]] : [];
|
||||
},
|
||||
};
|
||||
|
||||
function parentElementOrShadowHostInContext(element: Element, context: QueryContext): Element | undefined {
|
||||
if (element === context.scope)
|
||||
return;
|
||||
if (!context.pierceShadow)
|
||||
return element.parentElement || undefined;
|
||||
return parentElementOrShadowHost(element);
|
||||
}
|
||||
|
||||
function previousSiblingInContext(element: Element, context: QueryContext): Element | undefined {
|
||||
if (element === context.scope)
|
||||
return;
|
||||
return element.previousElementSibling || undefined;
|
||||
}
|
||||
|
||||
export function sortInDOMOrder(elements: Iterable<Element>): Element[] {
|
||||
type SortEntry = { children: Element[], taken: boolean };
|
||||
|
||||
const elementToEntry = new Map<Element, SortEntry>();
|
||||
const roots: Element[] = [];
|
||||
const result: Element[] = [];
|
||||
|
||||
function append(element: Element): SortEntry {
|
||||
let entry = elementToEntry.get(element);
|
||||
if (entry)
|
||||
return entry;
|
||||
const parent = parentElementOrShadowHost(element);
|
||||
if (parent) {
|
||||
const parentEntry = append(parent);
|
||||
parentEntry.children.push(element);
|
||||
} else {
|
||||
roots.push(element);
|
||||
}
|
||||
entry = { children: [], taken: false };
|
||||
elementToEntry.set(element, entry);
|
||||
return entry;
|
||||
}
|
||||
for (const e of elements)
|
||||
append(e).taken = true;
|
||||
|
||||
function visit(element: Element) {
|
||||
const entry = elementToEntry.get(element)!;
|
||||
if (entry.taken)
|
||||
result.push(element);
|
||||
if (entry.children.length > 1) {
|
||||
const set = new Set(entry.children);
|
||||
entry.children = [];
|
||||
let child = element.firstElementChild;
|
||||
while (child && entry.children.length < set.size) {
|
||||
if (set.has(child))
|
||||
entry.children.push(child);
|
||||
child = child.nextElementSibling;
|
||||
}
|
||||
child = element.shadowRoot ? element.shadowRoot.firstElementChild : null;
|
||||
while (child && entry.children.length < set.size) {
|
||||
if (set.has(child))
|
||||
entry.children.push(child);
|
||||
child = child.nextElementSibling;
|
||||
}
|
||||
}
|
||||
entry.children.forEach(visit);
|
||||
}
|
||||
roots.forEach(visit);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* 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, escapeRegExp, quoteCSSAttributeValue } from '@isomorphic/stringUtils';
|
||||
|
||||
import { beginDOMCaches, closestCrossShadow, endDOMCaches, isElementVisible, isInsideScope, parentElementOrShadowHost } from './domUtils';
|
||||
import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName } from './roleUtils';
|
||||
import { elementText, getElementLabels } from './selectorUtils';
|
||||
|
||||
import type { InjectedScript } from './injectedScript';
|
||||
|
||||
type SelectorToken = {
|
||||
engine: string;
|
||||
selector: string;
|
||||
score: number; // Lower is better.
|
||||
};
|
||||
|
||||
type Cache = {
|
||||
allowText: Map<Element, SelectorToken[] | null>;
|
||||
disallowText: Map<Element, SelectorToken[] | null>;
|
||||
};
|
||||
|
||||
const kTextScoreRange = 10;
|
||||
const kExactPenalty = kTextScoreRange / 2;
|
||||
|
||||
const kTestIdScore = 1; // testIdAttributeName
|
||||
const kOtherTestIdScore = 2; // other data-test* attributes
|
||||
|
||||
const kIframeByAttributeScore = 10;
|
||||
|
||||
const kBeginPenalizedScore = 50;
|
||||
const kRoleWithNameScore = 100;
|
||||
const kPlaceholderScore = 120;
|
||||
const kLabelScore = 140;
|
||||
const kAltTextScore = 160;
|
||||
const kTextScore = 180;
|
||||
const kTitleScore = 200;
|
||||
const kTextScoreRegex = 250;
|
||||
const kPlaceholderScoreExact = kPlaceholderScore + kExactPenalty;
|
||||
const kLabelScoreExact = kLabelScore + kExactPenalty;
|
||||
const kRoleWithNameScoreExact = kRoleWithNameScore + kExactPenalty;
|
||||
const kAltTextScoreExact = kAltTextScore + kExactPenalty;
|
||||
const kTextScoreExact = kTextScore + kExactPenalty;
|
||||
const kTitleScoreExact = kTitleScore + kExactPenalty;
|
||||
const kEndPenalizedScore = 300;
|
||||
|
||||
const kCSSIdScore = 500;
|
||||
const kRoleWithoutNameScore = 510;
|
||||
const kCSSInputTypeNameScore = 520;
|
||||
const kCSSTagNameScore = 530;
|
||||
const kNthScore = 10000;
|
||||
const kCSSFallbackScore = 10000000;
|
||||
|
||||
const kScoreThresholdForTextExpect = 1000;
|
||||
|
||||
export type GenerateSelectorOptions = {
|
||||
testIdAttributeName: string;
|
||||
omitInternalEngines?: boolean;
|
||||
root?: Element | Document;
|
||||
forTextExpect?: boolean;
|
||||
multiple?: boolean;
|
||||
};
|
||||
|
||||
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, selectors: string[], elements: Element[] } {
|
||||
injectedScript._evaluator.begin();
|
||||
const cache: Cache = { allowText: new Map(), disallowText: new Map() };
|
||||
beginAriaCaches();
|
||||
beginDOMCaches();
|
||||
try {
|
||||
let selectors: string[] = [];
|
||||
if (options.forTextExpect) {
|
||||
let targetTokens = cssFallback(injectedScript, targetElement.ownerDocument.documentElement, options);
|
||||
for (let element: Element | undefined = targetElement; element; element = parentElementOrShadowHost(element)) {
|
||||
const tokens = generateSelectorFor(cache, injectedScript, element, { ...options, noText: true });
|
||||
if (!tokens)
|
||||
continue;
|
||||
const score = combineScores(tokens);
|
||||
if (score <= kScoreThresholdForTextExpect) {
|
||||
targetTokens = tokens;
|
||||
break;
|
||||
}
|
||||
}
|
||||
selectors = [joinTokens(targetTokens)];
|
||||
} else {
|
||||
// Note: this matches InjectedScript.retarget().
|
||||
if (!targetElement.matches('input,textarea,select') && !(targetElement as any).isContentEditable) {
|
||||
const interactiveParent = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root);
|
||||
if (interactiveParent && isElementVisible(interactiveParent))
|
||||
targetElement = interactiveParent;
|
||||
}
|
||||
if (options.multiple) {
|
||||
const withText = generateSelectorFor(cache, injectedScript, targetElement, options);
|
||||
const withoutText = generateSelectorFor(cache, injectedScript, targetElement, { ...options, noText: true });
|
||||
let tokens = [withText, withoutText];
|
||||
|
||||
// Clear cache to re-generate without css id.
|
||||
cache.allowText.clear();
|
||||
cache.disallowText.clear();
|
||||
|
||||
if (withText && hasCSSIdToken(withText))
|
||||
tokens.push(generateSelectorFor(cache, injectedScript, targetElement, { ...options, noCSSId: true }));
|
||||
if (withoutText && hasCSSIdToken(withoutText))
|
||||
tokens.push(generateSelectorFor(cache, injectedScript, targetElement, { ...options, noText: true, noCSSId: true }));
|
||||
|
||||
tokens = tokens.filter(Boolean);
|
||||
if (!tokens.length) {
|
||||
const css = cssFallback(injectedScript, targetElement, options);
|
||||
tokens.push(css);
|
||||
if (hasCSSIdToken(css))
|
||||
tokens.push(cssFallback(injectedScript, targetElement, { ...options, noCSSId: true }));
|
||||
}
|
||||
selectors = [...new Set(tokens.map(t => joinTokens(t!)))];
|
||||
} else {
|
||||
const targetTokens = generateSelectorFor(cache, injectedScript, targetElement, options) || cssFallback(injectedScript, targetElement, options);
|
||||
selectors = [joinTokens(targetTokens)];
|
||||
}
|
||||
}
|
||||
const selector = selectors[0];
|
||||
const parsedSelector = injectedScript.parseSelector(selector);
|
||||
return {
|
||||
selector,
|
||||
selectors,
|
||||
elements: injectedScript.querySelectorAll(parsedSelector, options.root ?? targetElement.ownerDocument)
|
||||
};
|
||||
} finally {
|
||||
endDOMCaches();
|
||||
endAriaCaches();
|
||||
injectedScript._evaluator.end();
|
||||
}
|
||||
}
|
||||
|
||||
type InternalOptions = GenerateSelectorOptions & { noText?: boolean, noCSSId?: boolean, isRecursive?: boolean };
|
||||
|
||||
function generateSelectorFor(cache: Cache, injectedScript: InjectedScript, targetElement: Element, options: InternalOptions): SelectorToken[] | null {
|
||||
if (options.root && !isInsideScope(options.root, targetElement))
|
||||
throw new Error(`Target element must belong to the root's subtree`);
|
||||
|
||||
if (targetElement === options.root)
|
||||
return [{ engine: 'css', selector: ':scope', score: 1 }];
|
||||
if (targetElement.ownerDocument.documentElement === targetElement)
|
||||
return [{ engine: 'css', selector: 'html', score: 1 }];
|
||||
|
||||
let result: SelectorToken[] | null = null;
|
||||
const updateResult = (candidate: SelectorToken[]) => {
|
||||
if (!result || combineScores(candidate) < combineScores(result))
|
||||
result = candidate;
|
||||
};
|
||||
|
||||
const candidates: { candidate: SelectorToken[], isTextCandidate: boolean }[] = [];
|
||||
if (!options.noText) {
|
||||
for (const candidate of buildTextCandidates(injectedScript, targetElement, !options.isRecursive))
|
||||
candidates.push({ candidate, isTextCandidate: true });
|
||||
}
|
||||
for (const token of buildNoTextCandidates(injectedScript, targetElement, options)) {
|
||||
if (options.omitInternalEngines && token.engine.startsWith('internal:'))
|
||||
continue;
|
||||
candidates.push({ candidate: [token], isTextCandidate: false });
|
||||
}
|
||||
candidates.sort((a, b) => combineScores(a.candidate) - combineScores(b.candidate));
|
||||
|
||||
for (const { candidate, isTextCandidate } of candidates) {
|
||||
const elements = injectedScript.querySelectorAll(injectedScript.parseSelector(joinTokens(candidate)), options.root ?? targetElement.ownerDocument);
|
||||
if (!elements.includes(targetElement)) {
|
||||
// Somehow this selector just does not match the target. Oh well.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (elements.length === 1) {
|
||||
// Perfect strict match. All other candidates are strictly worse because they are sorted by score.
|
||||
updateResult(candidate);
|
||||
break;
|
||||
}
|
||||
|
||||
const index = elements.indexOf(targetElement);
|
||||
if (index > 5) {
|
||||
// Do not generate locators with nth=6 or worse.
|
||||
continue;
|
||||
}
|
||||
updateResult([...candidate, { engine: 'nth', selector: String(index), score: kNthScore }]);
|
||||
|
||||
if (options.isRecursive) {
|
||||
// Limit nesting to two levels: parent >>> target.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Now try nested selectors: (best selector for parent) >>> (this candidate selector).
|
||||
for (let parent = parentElementOrShadowHost(targetElement); parent && parent !== options.root; parent = parentElementOrShadowHost(parent)) {
|
||||
const filtered = elements.filter(e => isInsideScope(parent, e) && e !== parent);
|
||||
const newIndex = filtered.indexOf(targetElement);
|
||||
if (filtered.length > 5 || newIndex === -1 || (newIndex === index && filtered.length > 1)) {
|
||||
// Filtering to this parent is not an improvement - do not generate selector for parent.
|
||||
continue;
|
||||
}
|
||||
|
||||
const inParent = filtered.length === 1 ? candidate : [...candidate, { engine: 'nth', selector: String(newIndex), score: kNthScore }];
|
||||
const idealSelectorForParent = { engine: '', selector: '', score: 1 }; // Best theoretical score we could achieve for the parent.
|
||||
if (result && combineScores([idealSelectorForParent, ...inParent]) >= combineScores(result)) {
|
||||
// It is impossible to generate a better scoring selector through this parent.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Do not allow text in parent selector when using text in the target selector.
|
||||
const noText = !!options.noText || isTextCandidate;
|
||||
const cacheMap = noText ? cache.disallowText : cache.allowText;
|
||||
let parentTokens = cacheMap.get(parent);
|
||||
if (parentTokens === undefined) {
|
||||
parentTokens = generateSelectorFor(cache, injectedScript, parent, { ...options, isRecursive: true, noText }) || cssFallback(injectedScript, parent, options);
|
||||
cacheMap.set(parent, parentTokens);
|
||||
}
|
||||
if (!parentTokens)
|
||||
continue;
|
||||
|
||||
updateResult([...parentTokens, ...inParent]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, options: InternalOptions): SelectorToken[] {
|
||||
const candidates: SelectorToken[] = [];
|
||||
|
||||
// CSS selectors are applicable to elements via locator() and iframes via frameLocator().
|
||||
{
|
||||
for (const attr of ['data-testid', 'data-test-id', 'data-test']) {
|
||||
if (attr !== options.testIdAttributeName && element.getAttribute(attr))
|
||||
candidates.push({ engine: 'css', selector: `[${attr}=${quoteCSSAttributeValue(element.getAttribute(attr)!)}]`, score: kOtherTestIdScore });
|
||||
}
|
||||
|
||||
if (!options.noCSSId) {
|
||||
const idAttr = element.getAttribute('id');
|
||||
if (idAttr && !isGuidLike(idAttr))
|
||||
candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: kCSSIdScore });
|
||||
}
|
||||
|
||||
candidates.push({ engine: 'css', selector: escapeNodeName(element), score: kCSSTagNameScore });
|
||||
}
|
||||
|
||||
if (element.nodeName === 'IFRAME') {
|
||||
for (const attribute of ['name', 'title']) {
|
||||
if (element.getAttribute(attribute))
|
||||
candidates.push({ engine: 'css', selector: `${escapeNodeName(element)}[${attribute}=${quoteCSSAttributeValue(element.getAttribute(attribute)!)}]`, score: kIframeByAttributeScore });
|
||||
}
|
||||
|
||||
// Locate by testId via CSS selector.
|
||||
if (element.getAttribute(options.testIdAttributeName))
|
||||
candidates.push({ engine: 'css', selector: `[${options.testIdAttributeName}=${quoteCSSAttributeValue(element.getAttribute(options.testIdAttributeName)!)}]`, score: kTestIdScore });
|
||||
|
||||
penalizeScoreForLength([candidates]);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// Everything below is not applicable to iframes (getBy* methods).
|
||||
if (element.getAttribute(options.testIdAttributeName))
|
||||
candidates.push({ engine: 'internal:testid', selector: `[${options.testIdAttributeName}=${escapeForAttributeSelector(element.getAttribute(options.testIdAttributeName)!, true)}]`, score: kTestIdScore });
|
||||
|
||||
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
|
||||
const input = element as HTMLInputElement | HTMLTextAreaElement;
|
||||
if (input.placeholder) {
|
||||
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, true)}]`, score: kPlaceholderScoreExact });
|
||||
for (const alternative of suitableTextAlternatives(input.placeholder))
|
||||
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(alternative.text, false)}]`, score: kPlaceholderScore - alternative.scoreBonus });
|
||||
}
|
||||
}
|
||||
|
||||
const labels = getElementLabels(injectedScript._evaluator._cacheText, element);
|
||||
for (const label of labels) {
|
||||
const labelText = label.normalized;
|
||||
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact });
|
||||
for (const alternative of suitableTextAlternatives(labelText))
|
||||
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBonus });
|
||||
}
|
||||
|
||||
const ariaRole = getAriaRole(element);
|
||||
if (ariaRole && !['none', 'presentation'].includes(ariaRole))
|
||||
candidates.push({ engine: 'internal:role', selector: ariaRole, score: kRoleWithoutNameScore });
|
||||
|
||||
if (element.getAttribute('name') && ['BUTTON', 'FORM', 'FIELDSET', 'FRAME', 'IFRAME', 'INPUT', 'KEYGEN', 'OBJECT', 'OUTPUT', 'SELECT', 'TEXTAREA', 'MAP', 'META', 'PARAM'].includes(element.nodeName))
|
||||
candidates.push({ engine: 'css', selector: `${escapeNodeName(element)}[name=${quoteCSSAttributeValue(element.getAttribute('name')!)}]`, score: kCSSInputTypeNameScore });
|
||||
|
||||
if (['INPUT', 'TEXTAREA'].includes(element.nodeName) && element.getAttribute('type') !== 'hidden') {
|
||||
if (element.getAttribute('type'))
|
||||
candidates.push({ engine: 'css', selector: `${escapeNodeName(element)}[type=${quoteCSSAttributeValue(element.getAttribute('type')!)}]`, score: kCSSInputTypeNameScore });
|
||||
}
|
||||
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && element.getAttribute('type') !== 'hidden')
|
||||
candidates.push({ engine: 'css', selector: escapeNodeName(element), score: kCSSInputTypeNameScore + 1 });
|
||||
|
||||
penalizeScoreForLength([candidates]);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function buildTextCandidates(injectedScript: InjectedScript, element: Element, isTargetNode: boolean): SelectorToken[][] {
|
||||
if (element.nodeName === 'SELECT')
|
||||
return [];
|
||||
const candidates: SelectorToken[][] = [];
|
||||
|
||||
const title = element.getAttribute('title');
|
||||
if (title) {
|
||||
candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(title, true)}]`, score: kTitleScoreExact }]);
|
||||
for (const alternative of suitableTextAlternatives(title))
|
||||
candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(alternative.text, false)}]`, score: kTitleScore - alternative.scoreBonus }]);
|
||||
}
|
||||
|
||||
const alt = element.getAttribute('alt');
|
||||
if (alt && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) {
|
||||
candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alt, true)}]`, score: kAltTextScoreExact }]);
|
||||
for (const alternative of suitableTextAlternatives(alt))
|
||||
candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBonus }]);
|
||||
}
|
||||
|
||||
const text = elementText(injectedScript._evaluator._cacheText, element).normalized;
|
||||
const textAlternatives = text ? suitableTextAlternatives(text) : [];
|
||||
if (text) {
|
||||
if (isTargetNode) {
|
||||
if (text.length <= 80)
|
||||
candidates.push([{ engine: 'internal:text', selector: escapeForTextSelector(text, true), score: kTextScoreExact }]);
|
||||
for (const alternative of textAlternatives)
|
||||
candidates.push([{ engine: 'internal:text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]);
|
||||
}
|
||||
const cssToken: SelectorToken = { engine: 'css', selector: escapeNodeName(element), score: kCSSTagNameScore };
|
||||
for (const alternative of textAlternatives)
|
||||
candidates.push([cssToken, { engine: 'internal:has-text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]);
|
||||
if (isTargetNode && text.length <= 80) {
|
||||
// Do not use regex for parent elements (for performance).
|
||||
const re = new RegExp('^' + escapeRegExp(text) + '$');
|
||||
candidates.push([cssToken, { engine: 'internal:has-text', selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]);
|
||||
}
|
||||
}
|
||||
|
||||
const ariaRole = getAriaRole(element);
|
||||
if (ariaRole && !['none', 'presentation'].includes(ariaRole)) {
|
||||
const ariaName = getElementAccessibleName(element, false);
|
||||
// \p{Co} means "Private Use" characters - these are often used for icon fonts and make for bad locators.
|
||||
if (ariaName && !ariaName.match(/^\p{Co}+$/u)) {
|
||||
const roleToken = { engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact };
|
||||
candidates.push([roleToken]);
|
||||
for (const alternative of suitableTextAlternatives(ariaName))
|
||||
candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}]`, score: kRoleWithNameScore - alternative.scoreBonus }]);
|
||||
} else {
|
||||
const roleToken = { engine: 'internal:role', selector: `${ariaRole}`, score: kRoleWithoutNameScore };
|
||||
for (const alternative of textAlternatives)
|
||||
candidates.push([roleToken, { engine: 'internal:has-text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]);
|
||||
if (isTargetNode && text.length <= 80) {
|
||||
// Do not use regex for parent elements (for performance).
|
||||
const re = new RegExp('^' + escapeRegExp(text) + '$');
|
||||
candidates.push([roleToken, { engine: 'internal:has-text', selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
penalizeScoreForLength(candidates);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function makeSelectorForId(id: string) {
|
||||
return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? '#' + id : `[id=${quoteCSSAttributeValue(id)}]`;
|
||||
}
|
||||
|
||||
function hasCSSIdToken(tokens: SelectorToken[]) {
|
||||
return tokens.some(token => token.engine === 'css' && (token.selector.startsWith('#') || token.selector.startsWith('[id="')));
|
||||
}
|
||||
|
||||
function cssFallback(injectedScript: InjectedScript, targetElement: Element, options: InternalOptions): SelectorToken[] {
|
||||
const root: Node = options.root ?? targetElement.ownerDocument;
|
||||
const tokens: string[] = [];
|
||||
|
||||
function uniqueCSSSelector(prefix?: string): string | undefined {
|
||||
const path = tokens.slice();
|
||||
if (prefix)
|
||||
path.unshift(prefix);
|
||||
const selector = path.join(' > ');
|
||||
const parsedSelector = injectedScript.parseSelector(selector);
|
||||
const node = injectedScript.querySelector(parsedSelector, root, false);
|
||||
return node === targetElement ? selector : undefined;
|
||||
}
|
||||
|
||||
function makeStrict(selector: string): SelectorToken[] {
|
||||
const token = { engine: 'css', selector, score: kCSSFallbackScore };
|
||||
const parsedSelector = injectedScript.parseSelector(selector);
|
||||
const elements = injectedScript.querySelectorAll(parsedSelector, root);
|
||||
if (elements.length === 1)
|
||||
return [token];
|
||||
const nth = { engine: 'nth', selector: String(elements.indexOf(targetElement)), score: kNthScore };
|
||||
return [token, nth];
|
||||
}
|
||||
|
||||
for (let element: Element | undefined = targetElement; element && element !== root; element = parentElementOrShadowHost(element)) {
|
||||
let bestTokenForLevel: string = '';
|
||||
|
||||
// Element ID is the strongest signal, use it.
|
||||
if (element.id && !options.noCSSId) {
|
||||
const token = makeSelectorForId(element.id);
|
||||
const selector = uniqueCSSSelector(token);
|
||||
if (selector)
|
||||
return makeStrict(selector);
|
||||
bestTokenForLevel = token;
|
||||
}
|
||||
|
||||
const parent = element.parentNode as (Element | ShadowRoot);
|
||||
|
||||
// Combine class names until unique.
|
||||
const classes = [...element.classList].map(escapeClassName);
|
||||
for (let i = 0; i < classes.length; ++i) {
|
||||
const token = '.' + classes.slice(0, i + 1).join('.');
|
||||
const selector = uniqueCSSSelector(token);
|
||||
if (selector)
|
||||
return makeStrict(selector);
|
||||
// Even if not unique, does this subset of classes uniquely identify node as a child?
|
||||
if (!bestTokenForLevel && parent) {
|
||||
const sameClassSiblings = parent.querySelectorAll(token);
|
||||
if (sameClassSiblings.length === 1)
|
||||
bestTokenForLevel = token;
|
||||
}
|
||||
}
|
||||
|
||||
// Ordinal is the weakest signal.
|
||||
if (parent) {
|
||||
const siblings = [...parent.children];
|
||||
const nodeName = element.nodeName;
|
||||
const sameTagSiblings = siblings.filter(sibling => sibling.nodeName === nodeName);
|
||||
const token = sameTagSiblings.indexOf(element) === 0 ? escapeNodeName(element) : `${escapeNodeName(element)}:nth-child(${1 + siblings.indexOf(element)})`;
|
||||
const selector = uniqueCSSSelector(token);
|
||||
if (selector)
|
||||
return makeStrict(selector);
|
||||
if (!bestTokenForLevel)
|
||||
bestTokenForLevel = token;
|
||||
} else if (!bestTokenForLevel) {
|
||||
bestTokenForLevel = escapeNodeName(element);
|
||||
}
|
||||
tokens.unshift(bestTokenForLevel);
|
||||
}
|
||||
return makeStrict(uniqueCSSSelector()!);
|
||||
}
|
||||
|
||||
function penalizeScoreForLength(groups: SelectorToken[][]) {
|
||||
for (const group of groups) {
|
||||
for (const token of group) {
|
||||
if (token.score > kBeginPenalizedScore && token.score < kEndPenalizedScore)
|
||||
token.score += Math.min(kTextScoreRange, (token.selector.length / 10) | 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function joinTokens(tokens: SelectorToken[]): string {
|
||||
const parts = [];
|
||||
let lastEngine = '';
|
||||
for (const { engine, selector } of tokens) {
|
||||
if (parts.length && (lastEngine !== 'css' || engine !== 'css' || selector.startsWith(':nth-match(')))
|
||||
parts.push('>>');
|
||||
lastEngine = engine;
|
||||
if (engine === 'css')
|
||||
parts.push(selector);
|
||||
else
|
||||
parts.push(`${engine}=${selector}`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function combineScores(tokens: SelectorToken[]): number {
|
||||
let score = 0;
|
||||
for (let i = 0; i < tokens.length; i++)
|
||||
score += tokens[i].score * (tokens.length - i);
|
||||
return score;
|
||||
}
|
||||
|
||||
function isGuidLike(id: string): boolean {
|
||||
let lastCharacterType: 'lower' | 'upper' | 'digit' | 'other' | undefined;
|
||||
let transitionCount = 0;
|
||||
for (let i = 0; i < id.length; ++i) {
|
||||
const c = id[i];
|
||||
let characterType: 'lower' | 'upper' | 'digit' | 'other';
|
||||
if (c === '-' || c === '_')
|
||||
continue;
|
||||
if (c >= 'a' && c <= 'z')
|
||||
characterType = 'lower';
|
||||
else if (c >= 'A' && c <= 'Z')
|
||||
characterType = 'upper';
|
||||
else if (c >= '0' && c <= '9')
|
||||
characterType = 'digit';
|
||||
else
|
||||
characterType = 'other';
|
||||
|
||||
if (characterType === 'lower' && lastCharacterType === 'upper') {
|
||||
lastCharacterType = characterType;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastCharacterType && lastCharacterType !== characterType)
|
||||
++transitionCount;
|
||||
lastCharacterType = characterType;
|
||||
}
|
||||
return transitionCount >= id.length / 4;
|
||||
}
|
||||
|
||||
function trimWordBoundary(text: string, maxLength: number) {
|
||||
if (text.length <= maxLength)
|
||||
return text;
|
||||
text = text.substring(0, maxLength);
|
||||
// Find last word boundary in the text.
|
||||
const match = text.match(/^(.*)\b(.+?)$/);
|
||||
if (!match)
|
||||
return '';
|
||||
return match[1].trimEnd();
|
||||
}
|
||||
|
||||
function suitableTextAlternatives(text: string) {
|
||||
let result: { text: string, scoreBonus: number }[] = [];
|
||||
|
||||
{
|
||||
const match = text.match(/^([\d.,]+)[^.,\w]/);
|
||||
const leadingNumberLength = match ? match[1].length : 0;
|
||||
if (leadingNumberLength) {
|
||||
const alt = trimWordBoundary(text.substring(leadingNumberLength).trimStart(), 80);
|
||||
result.push({ text: alt, scoreBonus: alt.length <= 30 ? 2 : 1 });
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const match = text.match(/[^.,\w]([\d.,]+)$/);
|
||||
const trailingNumberLength = match ? match[1].length : 0;
|
||||
if (trailingNumberLength) {
|
||||
const alt = trimWordBoundary(text.substring(0, text.length - trailingNumberLength).trimEnd(), 80);
|
||||
result.push({ text: alt, scoreBonus: alt.length <= 30 ? 2 : 1 });
|
||||
}
|
||||
}
|
||||
|
||||
if (text.length <= 30) {
|
||||
result.push({ text, scoreBonus: 0 });
|
||||
} else {
|
||||
result.push({ text: trimWordBoundary(text, 80), scoreBonus: 0 });
|
||||
result.push({ text: trimWordBoundary(text, 30), scoreBonus: 1 });
|
||||
}
|
||||
|
||||
result = result.filter(r => r.text);
|
||||
if (!result.length)
|
||||
result.push({ text: text.substring(0, 80), scoreBonus: 0 });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeNodeName(node: Node): string {
|
||||
// We are escaping it for document.querySelectorAll, not for usage in CSS file.
|
||||
return node.nodeName.toLocaleLowerCase().replace(/[:\.]/g, char => '\\' + char);
|
||||
}
|
||||
|
||||
function escapeClassName(className: string): string {
|
||||
// We are escaping class names for document.querySelectorAll by following CSS.escape() rules.
|
||||
let result = '';
|
||||
for (let i = 0; i < className.length; i++)
|
||||
result += cssEscapeCharacter(className, i);
|
||||
return result;
|
||||
}
|
||||
|
||||
function cssEscapeCharacter(s: string, i: number): string {
|
||||
// https://drafts.csswg.org/cssom/#serialize-an-identifier
|
||||
const c = s.charCodeAt(i);
|
||||
if (c === 0x0000)
|
||||
return '\uFFFD';
|
||||
if ((c >= 0x0001 && c <= 0x001f) ||
|
||||
(c >= 0x0030 && c <= 0x0039 && (i === 0 || (i === 1 && s.charCodeAt(0) === 0x002d))))
|
||||
return '\\' + c.toString(16) + ' ';
|
||||
if (i === 0 && c === 0x002d && s.length === 1)
|
||||
return '\\' + s.charAt(i);
|
||||
if (c >= 0x0080 || c === 0x002d || c === 0x005f || (c >= 0x0030 && c <= 0x0039) ||
|
||||
(c >= 0x0041 && c <= 0x005a) || (c >= 0x0061 && c <= 0x007a))
|
||||
return s.charAt(i);
|
||||
return '\\' + s.charAt(i);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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 { normalizeWhiteSpace } from '@isomorphic/stringUtils';
|
||||
|
||||
import { getAriaLabelledByElements } from './roleUtils';
|
||||
|
||||
import type { AttributeSelectorPart } from '@isomorphic/selectorParser';
|
||||
|
||||
export function matchesComponentAttribute(obj: any, attr: AttributeSelectorPart) {
|
||||
for (const token of attr.jsonPath) {
|
||||
if (obj !== undefined && obj !== null)
|
||||
obj = obj[token];
|
||||
}
|
||||
return matchesAttributePart(obj, attr);
|
||||
}
|
||||
|
||||
export function matchesAttributePart(value: any, attr: AttributeSelectorPart) {
|
||||
const objValue = typeof value === 'string' && !attr.caseSensitive ? value.toUpperCase() : value;
|
||||
const attrValue = typeof attr.value === 'string' && !attr.caseSensitive ? attr.value.toUpperCase() : attr.value;
|
||||
|
||||
if (attr.op === '<truthy>')
|
||||
return !!objValue;
|
||||
if (attr.op === '=') {
|
||||
if (attrValue instanceof RegExp)
|
||||
return typeof objValue === 'string' && !!objValue.match(attrValue);
|
||||
return objValue === attrValue;
|
||||
}
|
||||
if (typeof objValue !== 'string' || typeof attrValue !== 'string')
|
||||
return false;
|
||||
if (attr.op === '*=')
|
||||
return objValue.includes(attrValue);
|
||||
if (attr.op === '^=')
|
||||
return objValue.startsWith(attrValue);
|
||||
if (attr.op === '$=')
|
||||
return objValue.endsWith(attrValue);
|
||||
if (attr.op === '|=')
|
||||
return objValue === attrValue || objValue.startsWith(attrValue + '-');
|
||||
if (attr.op === '~=')
|
||||
return objValue.split(' ').includes(attrValue);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldSkipForTextMatching(element: Element | ShadowRoot) {
|
||||
const document = element.ownerDocument;
|
||||
return element.nodeName === 'SCRIPT' || element.nodeName === 'NOSCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element);
|
||||
}
|
||||
|
||||
export type ElementText = { full: string, normalized: string, immediate: string[] };
|
||||
export type TextMatcher = (text: ElementText) => boolean;
|
||||
|
||||
export function elementText(cache: Map<Element | ShadowRoot, ElementText>, root: Element | ShadowRoot): ElementText {
|
||||
let value = cache.get(root);
|
||||
if (value === undefined) {
|
||||
value = { full: '', normalized: '', immediate: [] };
|
||||
if (!shouldSkipForTextMatching(root)) {
|
||||
let currentImmediate = '';
|
||||
if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) {
|
||||
value = { full: root.value, normalized: normalizeWhiteSpace(root.value), immediate: [root.value] };
|
||||
} else {
|
||||
for (let child = root.firstChild; child; child = child.nextSibling) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
value.full += child.nodeValue || '';
|
||||
currentImmediate += child.nodeValue || '';
|
||||
} else if (child.nodeType === Node.COMMENT_NODE) {
|
||||
continue;
|
||||
} else {
|
||||
if (currentImmediate)
|
||||
value.immediate.push(currentImmediate);
|
||||
currentImmediate = '';
|
||||
if (child.nodeType === Node.ELEMENT_NODE)
|
||||
value.full += elementText(cache, child as Element).full;
|
||||
}
|
||||
}
|
||||
if (currentImmediate)
|
||||
value.immediate.push(currentImmediate);
|
||||
if ((root as Element).shadowRoot)
|
||||
value.full += elementText(cache, (root as Element).shadowRoot!).full;
|
||||
if (value.full)
|
||||
value.normalized = normalizeWhiteSpace(value.full);
|
||||
}
|
||||
}
|
||||
cache.set(root, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function elementMatchesText(cache: Map<Element | ShadowRoot, ElementText>, element: Element, matcher: TextMatcher): 'none' | 'self' | 'selfAndChildren' {
|
||||
if (shouldSkipForTextMatching(element))
|
||||
return 'none';
|
||||
if (!matcher(elementText(cache, element)))
|
||||
return 'none';
|
||||
for (let child = element.firstChild; child; child = child.nextSibling) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(cache, child as Element)))
|
||||
return 'selfAndChildren';
|
||||
}
|
||||
if (element.shadowRoot && matcher(elementText(cache, element.shadowRoot)))
|
||||
return 'selfAndChildren';
|
||||
return 'self';
|
||||
}
|
||||
|
||||
export function getElementLabels(textCache: Map<Element | ShadowRoot, ElementText>, element: Element): ElementText[] {
|
||||
const labels = getAriaLabelledByElements(element);
|
||||
if (labels)
|
||||
return labels.map(label => elementText(textCache, label));
|
||||
const ariaLabel = element.getAttribute('aria-label');
|
||||
if (ariaLabel !== null && !!ariaLabel.trim())
|
||||
return [{ full: ariaLabel, normalized: normalizeWhiteSpace(ariaLabel), immediate: [ariaLabel] }];
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/forms.html#category-label
|
||||
const isNonHiddenInput = element.nodeName === 'INPUT' && (element as HTMLInputElement).type !== 'hidden';
|
||||
if (['BUTTON', 'METER', 'OUTPUT', 'PROGRESS', 'SELECT', 'TEXTAREA'].includes(element.nodeName) || isNonHiddenInput) {
|
||||
const labels = (element as HTMLInputElement).labels;
|
||||
if (labels)
|
||||
return [...labels].map(label => elementText(textCache, label));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 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 { parseEvaluationResultValue, serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers';
|
||||
|
||||
import type * as channels from '@protocol/channels';
|
||||
|
||||
export type SerializedStorage = Omit<channels.OriginStorage, 'origin'>;
|
||||
|
||||
export class StorageScript {
|
||||
private _isFirefox: boolean;
|
||||
private _global;
|
||||
|
||||
constructor(isFirefox: boolean) {
|
||||
this._isFirefox = isFirefox;
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
this._global = globalThis;
|
||||
}
|
||||
|
||||
private _idbRequestToPromise<T extends IDBOpenDBRequest | IDBRequest>(request: T) {
|
||||
return new Promise<T['result']>((resolve, reject) => {
|
||||
request.addEventListener('success', () => resolve(request.result));
|
||||
request.addEventListener('error', () => reject(request.error));
|
||||
});
|
||||
}
|
||||
|
||||
private _isPlainObject(v: any) {
|
||||
const ctor = v?.constructor;
|
||||
if (this._isFirefox) {
|
||||
const constructorImpl = ctor?.toString() as string | undefined;
|
||||
if (constructorImpl?.startsWith('function Object() {') && constructorImpl?.includes('[native code]'))
|
||||
return true;
|
||||
}
|
||||
return ctor === Object;
|
||||
}
|
||||
|
||||
private _trySerialize(value: any): { trivial?: any, encoded?: any } {
|
||||
let trivial = true;
|
||||
const encoded = serializeAsCallArgument(value, v => {
|
||||
const isTrivial = (
|
||||
this._isPlainObject(v)
|
||||
|| Array.isArray(v)
|
||||
|| typeof v === 'string'
|
||||
|| typeof v === 'number'
|
||||
|| typeof v === 'boolean'
|
||||
|| Object.is(v, null)
|
||||
);
|
||||
|
||||
if (!isTrivial)
|
||||
trivial = false;
|
||||
|
||||
return { fallThrough: v };
|
||||
});
|
||||
if (trivial)
|
||||
return { trivial: value };
|
||||
return { encoded };
|
||||
}
|
||||
|
||||
private async _collectDB(dbInfo: IDBDatabaseInfo) {
|
||||
if (!dbInfo.name)
|
||||
throw new Error('Database name is empty');
|
||||
if (!dbInfo.version)
|
||||
throw new Error('Database version is unset');
|
||||
|
||||
const db = await this._idbRequestToPromise(indexedDB.open(dbInfo.name));
|
||||
if (db.objectStoreNames.length === 0)
|
||||
return { name: dbInfo.name, version: dbInfo.version, stores: [] };
|
||||
|
||||
const transaction = db.transaction(db.objectStoreNames, 'readonly');
|
||||
const stores = await Promise.all([...db.objectStoreNames].map(async storeName => {
|
||||
const objectStore = transaction.objectStore(storeName);
|
||||
|
||||
const keys = await this._idbRequestToPromise(objectStore.getAllKeys());
|
||||
const records = await Promise.all(keys.map(async key => {
|
||||
const record: channels.IndexedDBDatabase['stores'][0]['records'][0] = {};
|
||||
|
||||
if (objectStore.keyPath === null) {
|
||||
const { encoded, trivial } = this._trySerialize(key);
|
||||
if (trivial)
|
||||
record.key = trivial;
|
||||
else
|
||||
record.keyEncoded = encoded;
|
||||
}
|
||||
|
||||
const value = await this._idbRequestToPromise(objectStore.get(key));
|
||||
const { encoded, trivial } = this._trySerialize(value);
|
||||
if (trivial)
|
||||
record.value = trivial;
|
||||
else
|
||||
record.valueEncoded = encoded;
|
||||
|
||||
return record;
|
||||
}));
|
||||
|
||||
const indexes = [...objectStore.indexNames].map(indexName => {
|
||||
const index = objectStore.index(indexName);
|
||||
return {
|
||||
name: index.name,
|
||||
keyPath: typeof index.keyPath === 'string' ? index.keyPath : undefined,
|
||||
keyPathArray: Array.isArray(index.keyPath) ? index.keyPath : undefined,
|
||||
multiEntry: index.multiEntry,
|
||||
unique: index.unique,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name: storeName,
|
||||
records: records,
|
||||
indexes,
|
||||
autoIncrement: objectStore.autoIncrement,
|
||||
keyPath: typeof objectStore.keyPath === 'string' ? objectStore.keyPath : undefined,
|
||||
keyPathArray: Array.isArray(objectStore.keyPath) ? objectStore.keyPath : undefined,
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
name: dbInfo.name,
|
||||
version: dbInfo.version,
|
||||
stores,
|
||||
};
|
||||
}
|
||||
|
||||
async collect(recordIndexedDB: boolean): Promise<SerializedStorage> {
|
||||
const localStorage = Object.keys(this._global.localStorage).map(name => ({ name, value: this._global.localStorage.getItem(name)! }));
|
||||
if (!recordIndexedDB)
|
||||
return { localStorage };
|
||||
try {
|
||||
const databases = await this._global.indexedDB.databases();
|
||||
const indexedDB = await Promise.all(databases.map(db => this._collectDB(db)));
|
||||
return { localStorage, indexedDB };
|
||||
} catch (e) {
|
||||
throw new Error('Unable to serialize IndexedDB: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async _restoreDB(dbInfo: channels.IndexedDBDatabase) {
|
||||
const openRequest = this._global.indexedDB.open(dbInfo.name, dbInfo.version);
|
||||
openRequest.addEventListener('upgradeneeded', () => {
|
||||
const db = openRequest.result;
|
||||
for (const store of dbInfo.stores) {
|
||||
const objectStore = db.createObjectStore(store.name, { autoIncrement: store.autoIncrement, keyPath: store.keyPathArray ?? store.keyPath });
|
||||
for (const index of store.indexes)
|
||||
objectStore.createIndex(index.name, index.keyPathArray ?? index.keyPath!, { unique: index.unique, multiEntry: index.multiEntry });
|
||||
}
|
||||
});
|
||||
|
||||
// after `upgradeneeded` finishes, `success` event is fired.
|
||||
const db = await this._idbRequestToPromise(openRequest);
|
||||
|
||||
if (db.objectStoreNames.length === 0)
|
||||
return;
|
||||
const transaction = db.transaction(db.objectStoreNames, 'readwrite');
|
||||
await Promise.all(dbInfo.stores.map(async store => {
|
||||
const objectStore = transaction.objectStore(store.name);
|
||||
await Promise.all(store.records.map(async record => {
|
||||
await this._idbRequestToPromise(
|
||||
objectStore.add(
|
||||
record.value ?? parseEvaluationResultValue(record.valueEncoded),
|
||||
record.key ?? parseEvaluationResultValue(record.keyEncoded),
|
||||
)
|
||||
);
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
async restore(originState: channels.SetOriginStorage | undefined) {
|
||||
// Clean Service Workers.
|
||||
const registrations = this._global.navigator.serviceWorker ? await this._global.navigator.serviceWorker.getRegistrations() : [];
|
||||
await Promise.all(registrations.map(async r => {
|
||||
// Heuristic for service workers that stalled during main script fetch or importScripts:
|
||||
// Waiting for them to finish unregistering takes ages so we do not await.
|
||||
// However, they will unregister immediately after fetch finishes and should not affect next page load.
|
||||
// Unfortunately, loading next page in Chromium still takes 5 seconds waiting for
|
||||
// some operation on this bogus service worker to finish.
|
||||
if (!r.installing && !r.waiting && !r.active)
|
||||
r.unregister().catch(() => {});
|
||||
else
|
||||
await r.unregister().catch(() => {});
|
||||
}));
|
||||
|
||||
try {
|
||||
for (const db of await this._global.indexedDB.databases?.() || []) {
|
||||
// Do not wait for the callback - it is called on timer in Chromium (slow).
|
||||
if (db.name)
|
||||
this._global.indexedDB.deleteDatabase(db.name!);
|
||||
}
|
||||
await Promise.all((originState?.indexedDB ?? []).map(dbInfo => this._restoreDB(dbInfo)));
|
||||
} catch (e) {
|
||||
throw new Error('Unable to restore IndexedDB: ' + e.message);
|
||||
}
|
||||
|
||||
this._global.sessionStorage.clear();
|
||||
this._global.localStorage.clear();
|
||||
for (const { name, value } of (originState?.localStorage || []))
|
||||
this._global.localStorage.setItem(name, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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 { parseEvaluationResultValue, serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers';
|
||||
|
||||
// Keep in sync with eslint.config.mjs
|
||||
export type Builtins = {
|
||||
setTimeout: Window['setTimeout'],
|
||||
clearTimeout: Window['clearTimeout'],
|
||||
setInterval: Window['setInterval'],
|
||||
clearInterval: Window['clearInterval'],
|
||||
requestAnimationFrame: Window['requestAnimationFrame'],
|
||||
cancelAnimationFrame: Window['cancelAnimationFrame'],
|
||||
requestIdleCallback: Window['requestIdleCallback'],
|
||||
cancelIdleCallback: Window['cancelIdleCallback'],
|
||||
performance: Window['performance'],
|
||||
Intl: typeof window['Intl'],
|
||||
Date: typeof window['Date'],
|
||||
AbortSignal: typeof window['AbortSignal'],
|
||||
};
|
||||
|
||||
export class UtilityScript {
|
||||
readonly global: typeof globalThis;
|
||||
// Builtins protect injected code from clock emulation.
|
||||
readonly builtins: Builtins;
|
||||
readonly isUnderTest: boolean;
|
||||
|
||||
constructor(global: typeof globalThis, isUnderTest: boolean) {
|
||||
this.global = global;
|
||||
this.isUnderTest = isUnderTest;
|
||||
if ((global as any).__pwClock) {
|
||||
this.builtins = (global as any).__pwClock.builtins;
|
||||
} else {
|
||||
this.builtins = {
|
||||
setTimeout: global.setTimeout?.bind(global),
|
||||
clearTimeout: global.clearTimeout?.bind(global),
|
||||
setInterval: global.setInterval?.bind(global),
|
||||
clearInterval: global.clearInterval?.bind(global),
|
||||
requestAnimationFrame: global.requestAnimationFrame?.bind(global),
|
||||
cancelAnimationFrame: global.cancelAnimationFrame?.bind(global),
|
||||
requestIdleCallback: global.requestIdleCallback?.bind(global),
|
||||
cancelIdleCallback: global.cancelIdleCallback?.bind(global),
|
||||
performance: global.performance,
|
||||
Intl: global.Intl,
|
||||
Date: global.Date,
|
||||
AbortSignal: global.AbortSignal,
|
||||
} satisfies Builtins;
|
||||
}
|
||||
if (this.isUnderTest)
|
||||
(global as any).builtins = this.builtins;
|
||||
}
|
||||
|
||||
evaluate(isFunction: boolean | undefined, returnByValue: boolean, expression: string, argCount: number, ...argsAndHandles: any[]) {
|
||||
const args = argsAndHandles.slice(0, argCount);
|
||||
const handles = argsAndHandles.slice(argCount);
|
||||
const parameters = [];
|
||||
for (let i = 0; i < args.length; i++)
|
||||
parameters[i] = parseEvaluationResultValue(args[i], handles);
|
||||
|
||||
let result = this.global.eval(expression);
|
||||
if (isFunction === true) {
|
||||
result = result(...parameters);
|
||||
} else if (isFunction === false) {
|
||||
result = result;
|
||||
} else {
|
||||
// auto detect.
|
||||
if (typeof result === 'function')
|
||||
result = result(...parameters);
|
||||
}
|
||||
return returnByValue ? this._promiseAwareJsonValueNoThrow(result) : result;
|
||||
}
|
||||
|
||||
jsonValue(returnByValue: true, value: any) {
|
||||
// Special handling of undefined to work-around multi-step returnByValue handling in WebKit.
|
||||
if (value === undefined)
|
||||
return undefined;
|
||||
return serializeAsCallArgument(value, (value: any) => ({ fallThrough: value }));
|
||||
}
|
||||
|
||||
private _promiseAwareJsonValueNoThrow(value: any) {
|
||||
const safeJson = (value: any) => {
|
||||
try {
|
||||
return this.jsonValue(true, value);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
if (value && typeof value === 'object' && typeof value.then === 'function') {
|
||||
return (async () => {
|
||||
// By using async function we ensure that return value is a native Promise,
|
||||
// and not some overridden Promise in the page.
|
||||
// This makes Firefox and WebKit debugging protocols recognize it as a Promise,
|
||||
// properly await and return the value.
|
||||
const promiseValue = await value;
|
||||
return safeJson(promiseValue);
|
||||
})();
|
||||
}
|
||||
return safeJson(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 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 WebSocketMessage = string | ArrayBufferLike | Blob | ArrayBufferView;
|
||||
export type WSData = { data: string, isBase64: boolean };
|
||||
|
||||
export type OnCreatePayload = { type: 'onCreate', id: string, url: string };
|
||||
export type OnMessageFromPagePayload = { type: 'onMessageFromPage', id: string, data: WSData };
|
||||
export type OnClosePagePayload = { type: 'onClosePage', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean };
|
||||
export type OnMessageFromServerPayload = { type: 'onMessageFromServer', id: string, data: WSData };
|
||||
export type OnCloseServerPayload = { type: 'onCloseServer', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean };
|
||||
export type BindingPayload = OnCreatePayload | OnMessageFromPagePayload | OnMessageFromServerPayload | OnClosePagePayload | OnCloseServerPayload;
|
||||
|
||||
export type ConnectRequest = { type: 'connect', id: string };
|
||||
export type PassthroughRequest = { type: 'passthrough', id: string };
|
||||
export type EnsureOpenedRequest = { type: 'ensureOpened', id: string };
|
||||
export type SendToPageRequest = { type: 'sendToPage', id: string, data: WSData };
|
||||
export type SendToServerRequest = { type: 'sendToServer', id: string, data: WSData };
|
||||
export type ClosePageRequest = { type: 'closePage', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean };
|
||||
export type CloseServerRequest = { type: 'closeServer', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean };
|
||||
export type APIRequest = ConnectRequest | PassthroughRequest | EnsureOpenedRequest | SendToPageRequest | SendToServerRequest | ClosePageRequest | CloseServerRequest;
|
||||
|
||||
type GlobalThis = typeof globalThis;
|
||||
|
||||
export function inject(globalThis: GlobalThis) {
|
||||
if ((globalThis as any).__pwWebSocketDispatch)
|
||||
return;
|
||||
|
||||
function generateId() {
|
||||
const bytes = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
const hex = '0123456789abcdef';
|
||||
return [...bytes].map(value => {
|
||||
const high = Math.floor(value / 16);
|
||||
const low = value % 16;
|
||||
return hex[high] + hex[low];
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function bufferToData(b: Uint8Array): WSData {
|
||||
let s = '';
|
||||
for (let i = 0; i < b.length; i++)
|
||||
s += String.fromCharCode(b[i]);
|
||||
return { data: globalThis.btoa(s), isBase64: true };
|
||||
}
|
||||
|
||||
function stringToBuffer(s: string): ArrayBuffer {
|
||||
s = globalThis.atob(s);
|
||||
const b = new Uint8Array(s.length);
|
||||
for (let i = 0; i < s.length; i++)
|
||||
b[i] = s.charCodeAt(i);
|
||||
return b.buffer;
|
||||
}
|
||||
|
||||
// Note: this function tries to be synchronous when it can to preserve the ability to send
|
||||
// multiple messages synchronously in the same order and then synchronously close.
|
||||
function messageToData(message: WebSocketMessage, cb: (data: WSData) => any) {
|
||||
if (message instanceof globalThis.Blob)
|
||||
return message.arrayBuffer().then(buffer => cb(bufferToData(new Uint8Array(buffer))));
|
||||
if (typeof message === 'string')
|
||||
return cb({ data: message, isBase64: false });
|
||||
if (ArrayBuffer.isView(message))
|
||||
return cb(bufferToData(new Uint8Array(message.buffer, message.byteOffset, message.byteLength)));
|
||||
return cb(bufferToData(new Uint8Array(message)));
|
||||
}
|
||||
|
||||
function dataToMessage(data: WSData, binaryType: 'blob' | 'arraybuffer'): WebSocketMessage {
|
||||
if (!data.isBase64)
|
||||
return data.data;
|
||||
const buffer = stringToBuffer(data.data);
|
||||
return binaryType === 'arraybuffer' ? buffer : new Blob([buffer]);
|
||||
}
|
||||
|
||||
const binding = (globalThis as any).__pwWebSocketBinding as (message: BindingPayload) => void;
|
||||
const NativeWebSocket: typeof WebSocket = globalThis.WebSocket;
|
||||
const idToWebSocket = new Map<string, WebSocketMock>();
|
||||
(globalThis as any).__pwWebSocketDispatch = (request: APIRequest) => {
|
||||
const ws = idToWebSocket.get(request.id);
|
||||
if (!ws)
|
||||
return;
|
||||
if (request.type === 'connect')
|
||||
ws._apiConnect();
|
||||
if (request.type === 'passthrough')
|
||||
ws._apiPassThrough();
|
||||
if (request.type === 'ensureOpened')
|
||||
ws._apiEnsureOpened();
|
||||
if (request.type === 'sendToPage')
|
||||
ws._apiSendToPage(dataToMessage(request.data, ws.binaryType));
|
||||
if (request.type === 'closePage')
|
||||
ws._apiClosePage(request.code, request.reason, request.wasClean);
|
||||
if (request.type === 'sendToServer')
|
||||
ws._apiSendToServer(dataToMessage(request.data, ws.binaryType));
|
||||
if (request.type === 'closeServer')
|
||||
ws._apiCloseServer(request.code, request.reason, request.wasClean);
|
||||
};
|
||||
|
||||
class WebSocketMock extends EventTarget {
|
||||
static readonly CONNECTING: 0 = 0; // WebSocket.CONNECTING
|
||||
static readonly OPEN: 1 = 1; // WebSocket.OPEN
|
||||
static readonly CLOSING: 2 = 2; // WebSocket.CLOSING
|
||||
static readonly CLOSED: 3 = 3; // WebSocket.CLOSED
|
||||
|
||||
CONNECTING: 0 = 0; // WebSocket.CONNECTING
|
||||
OPEN: 1 = 1; // WebSocket.OPEN
|
||||
CLOSING: 2 = 2; // WebSocket.CLOSING
|
||||
CLOSED: 3 = 3; // WebSocket.CLOSED
|
||||
|
||||
private _oncloseListener: WebSocket['onclose'] = null;
|
||||
private _onerrorListener: WebSocket['onerror'] = null;
|
||||
private _onmessageListener: WebSocket['onmessage'] = null;
|
||||
private _onopenListener: WebSocket['onopen'] = null;
|
||||
|
||||
bufferedAmount: number = 0;
|
||||
extensions: string = '';
|
||||
protocol: string = '';
|
||||
readyState: number = 0;
|
||||
readonly url: string;
|
||||
|
||||
private _id: string;
|
||||
private _origin: string = '';
|
||||
private _protocols?: string | string[];
|
||||
private _ws?: WebSocket;
|
||||
private _passthrough = false;
|
||||
private _wsBufferedMessages: WebSocketMessage[] = [];
|
||||
private _binaryType: BinaryType = 'blob';
|
||||
|
||||
constructor(url: string | URL, protocols?: string | string[]) {
|
||||
super();
|
||||
|
||||
// https://github.com/whatwg/websockets/issues/20
|
||||
this.url = new URL(url, globalThis.window.document.baseURI).href.replace(/^http/, 'ws');
|
||||
this._origin = URL.parse(this.url)?.origin ?? '';
|
||||
this._protocols = protocols;
|
||||
|
||||
this._id = generateId();
|
||||
idToWebSocket.set(this._id, this);
|
||||
binding({ type: 'onCreate', id: this._id, url: this.url });
|
||||
}
|
||||
|
||||
// --- native WebSocket implementation ---
|
||||
|
||||
get binaryType() {
|
||||
return this._binaryType;
|
||||
}
|
||||
|
||||
set binaryType(type) {
|
||||
this._binaryType = type;
|
||||
if (this._ws)
|
||||
this._ws.binaryType = type;
|
||||
}
|
||||
|
||||
get onclose() {
|
||||
return this._oncloseListener;
|
||||
}
|
||||
|
||||
set onclose(listener) {
|
||||
if (this._oncloseListener)
|
||||
this.removeEventListener('close', this._oncloseListener as any);
|
||||
this._oncloseListener = listener;
|
||||
if (this._oncloseListener)
|
||||
this.addEventListener('close', this._oncloseListener as any);
|
||||
}
|
||||
|
||||
get onerror() {
|
||||
return this._onerrorListener;
|
||||
}
|
||||
|
||||
set onerror(listener) {
|
||||
if (this._onerrorListener)
|
||||
this.removeEventListener('error', this._onerrorListener);
|
||||
this._onerrorListener = listener;
|
||||
if (this._onerrorListener)
|
||||
this.addEventListener('error', this._onerrorListener);
|
||||
}
|
||||
|
||||
get onopen() {
|
||||
return this._onopenListener;
|
||||
}
|
||||
|
||||
set onopen(listener) {
|
||||
if (this._onopenListener)
|
||||
this.removeEventListener('open', this._onopenListener);
|
||||
this._onopenListener = listener;
|
||||
if (this._onopenListener)
|
||||
this.addEventListener('open', this._onopenListener);
|
||||
}
|
||||
|
||||
get onmessage() {
|
||||
return this._onmessageListener;
|
||||
}
|
||||
|
||||
set onmessage(listener) {
|
||||
if (this._onmessageListener)
|
||||
this.removeEventListener('message', this._onmessageListener as any);
|
||||
this._onmessageListener = listener;
|
||||
if (this._onmessageListener)
|
||||
this.addEventListener('message', this._onmessageListener as any);
|
||||
}
|
||||
|
||||
send(message: WebSocketMessage): void {
|
||||
if (this.readyState === WebSocketMock.CONNECTING)
|
||||
throw new DOMException(`Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.`);
|
||||
if (this.readyState !== WebSocketMock.OPEN)
|
||||
throw new DOMException(`WebSocket is already in CLOSING or CLOSED state.`);
|
||||
if (this._passthrough) {
|
||||
if (this._ws)
|
||||
this._apiSendToServer(message);
|
||||
} else {
|
||||
messageToData(message, data => binding({ type: 'onMessageFromPage', id: this._id, data }));
|
||||
}
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void {
|
||||
if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999))
|
||||
throw new DOMException(`Failed to execute 'close' on 'WebSocket': The close code must be either 1000, or between 3000 and 4999. ${code} is neither.`);
|
||||
if (this.readyState === WebSocketMock.OPEN || this.readyState === WebSocketMock.CONNECTING)
|
||||
this.readyState = WebSocketMock.CLOSING;
|
||||
if (this._passthrough)
|
||||
this._apiCloseServer(code, reason, true);
|
||||
else
|
||||
binding({ type: 'onClosePage', id: this._id, code, reason, wasClean: true });
|
||||
}
|
||||
|
||||
// --- methods called from the routing API ---
|
||||
|
||||
_apiEnsureOpened() {
|
||||
// This is called at the end of the route handler. If we did not connect to the server,
|
||||
// assume that websocket will be fully mocked. In this case, pretend that server
|
||||
// connection is established right away.
|
||||
if (!this._ws)
|
||||
this._ensureOpened();
|
||||
}
|
||||
|
||||
_apiSendToPage(message: WebSocketMessage) {
|
||||
// Calling "sendToPage()" from the route handler. Allow this for easier testing.
|
||||
this._ensureOpened();
|
||||
if (this.readyState !== WebSocketMock.OPEN)
|
||||
throw new DOMException(`WebSocket is already in CLOSING or CLOSED state.`);
|
||||
this.dispatchEvent(new MessageEvent('message', { data: message, origin: this._origin, cancelable: true }));
|
||||
}
|
||||
|
||||
_apiSendToServer(message: WebSocketMessage) {
|
||||
if (!this._ws)
|
||||
throw new Error('Cannot send a message before connecting to the server');
|
||||
if (this._ws.readyState === WebSocketMock.CONNECTING)
|
||||
this._wsBufferedMessages.push(message);
|
||||
else
|
||||
this._ws.send(message);
|
||||
}
|
||||
|
||||
_apiConnect() {
|
||||
if (this._ws)
|
||||
throw new Error('Can only connect to the server once');
|
||||
|
||||
this._ws = new NativeWebSocket(this.url, this._protocols);
|
||||
this._ws.binaryType = this._binaryType;
|
||||
|
||||
this._ws.onopen = () => {
|
||||
for (const message of this._wsBufferedMessages)
|
||||
this._ws!.send(message);
|
||||
this._wsBufferedMessages = [];
|
||||
this._ensureOpened();
|
||||
};
|
||||
|
||||
this._ws.onclose = event => {
|
||||
this._onWSClose(event.code, event.reason, event.wasClean);
|
||||
};
|
||||
|
||||
this._ws.onmessage = event => {
|
||||
if (this._passthrough)
|
||||
this._apiSendToPage(event.data);
|
||||
else
|
||||
messageToData(event.data, data => binding({ type: 'onMessageFromServer', id: this._id, data }));
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
// We do not expose errors in the API, so short-curcuit the error event.
|
||||
const event = new Event('error', { cancelable: true });
|
||||
this.dispatchEvent(event);
|
||||
};
|
||||
}
|
||||
|
||||
// This method connects to the server, and passes all messages through,
|
||||
// as if WebSocketMock was not engaged.
|
||||
_apiPassThrough() {
|
||||
this._passthrough = true;
|
||||
this._apiConnect();
|
||||
}
|
||||
|
||||
_apiCloseServer(code: number | undefined, reason: string | undefined, wasClean: boolean) {
|
||||
if (!this._ws) {
|
||||
// Short-curcuit when there is no server.
|
||||
this._onWSClose(code, reason, wasClean);
|
||||
return;
|
||||
}
|
||||
if (this._ws.readyState === WebSocketMock.CONNECTING || this._ws.readyState === WebSocketMock.OPEN)
|
||||
this._ws.close(code, reason);
|
||||
}
|
||||
|
||||
_apiClosePage(code: number | undefined, reason: string | undefined, wasClean: boolean) {
|
||||
if (this.readyState === WebSocketMock.CLOSED)
|
||||
return;
|
||||
this.readyState = WebSocketMock.CLOSED;
|
||||
this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean, cancelable: true }));
|
||||
this._maybeCleanup();
|
||||
if (this._passthrough)
|
||||
this._apiCloseServer(code, reason, wasClean);
|
||||
else
|
||||
binding({ type: 'onClosePage', id: this._id, code, reason, wasClean });
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
_ensureOpened() {
|
||||
if (this.readyState !== WebSocketMock.CONNECTING)
|
||||
return;
|
||||
this.extensions = this._ws?.extensions || '';
|
||||
if (this._ws)
|
||||
this.protocol = this._ws.protocol;
|
||||
else if (Array.isArray(this._protocols))
|
||||
this.protocol = this._protocols[0] || '';
|
||||
else
|
||||
this.protocol = this._protocols || '';
|
||||
this.readyState = WebSocketMock.OPEN;
|
||||
this.dispatchEvent(new Event('open', { cancelable: true }));
|
||||
}
|
||||
|
||||
private _onWSClose(code: number | undefined, reason: string | undefined, wasClean: boolean) {
|
||||
if (this._passthrough)
|
||||
this._apiClosePage(code, reason, wasClean);
|
||||
else
|
||||
binding({ type: 'onCloseServer', id: this._id, code, reason, wasClean });
|
||||
if (this._ws) {
|
||||
this._ws.onopen = null;
|
||||
this._ws.onclose = null;
|
||||
this._ws.onmessage = null;
|
||||
this._ws.onerror = null;
|
||||
this._ws = undefined;
|
||||
this._wsBufferedMessages = [];
|
||||
}
|
||||
this._maybeCleanup();
|
||||
}
|
||||
|
||||
private _maybeCleanup() {
|
||||
if (this.readyState === WebSocketMock.CLOSED && !this._ws)
|
||||
idToWebSocket.delete(this._id);
|
||||
}
|
||||
}
|
||||
globalThis.WebSocket = class WebSocket extends WebSocketMock {};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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 { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||
|
||||
export const XPathEngine: SelectorEngine = {
|
||||
queryAll(root: SelectorRoot, selector: string): Element[] {
|
||||
if (selector.startsWith('/') && root.nodeType !== Node.DOCUMENT_NODE)
|
||||
selector = '.' + selector;
|
||||
const result: Element[] = [];
|
||||
const document = root.ownerDocument || root;
|
||||
if (!document)
|
||||
return result;
|
||||
const it = document.evaluate(selector, root, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
|
||||
for (let node = it.iterateNext(); node; node = it.iterateNext()) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE)
|
||||
result.push(node as Element);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// https://www.w3.org/TR/wai-aria-1.2/#role_definitions
|
||||
|
||||
export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'banner' | 'blockquote' | 'button' | 'caption' | 'cell' | 'checkbox' | 'code' | 'columnheader' | 'combobox' |
|
||||
'complementary' | 'contentinfo' | 'definition' | 'deletion' | 'dialog' | 'directory' | 'document' | 'emphasis' | 'feed' | 'figure' | 'form' | 'generic' | 'grid' |
|
||||
'gridcell' | 'group' | 'heading' | 'img' | 'insertion' | 'link' | 'list' | 'listbox' | 'listitem' | 'log' | 'main' | 'mark' | 'marquee' | 'math' | 'meter' | 'menu' |
|
||||
'menubar' | 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' | 'navigation' | 'none' | 'note' | 'option' | 'paragraph' | 'presentation' | 'progressbar' | 'radio' | 'radiogroup' |
|
||||
'region' | 'row' | 'rowgroup' | 'rowheader' | 'scrollbar' | 'search' | 'searchbox' | 'separator' | 'slider' |
|
||||
'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
|
||||
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
|
||||
|
||||
// Note: please keep in sync with ariaPropsEqual() below.
|
||||
export type AriaProps = {
|
||||
checked?: boolean | 'mixed';
|
||||
disabled?: boolean;
|
||||
expanded?: boolean;
|
||||
active?: boolean;
|
||||
level?: number;
|
||||
pressed?: boolean | 'mixed';
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export type AriaBox = {
|
||||
visible: boolean;
|
||||
inline: boolean;
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
// Note: please keep in sync with ariaNodesEqual() below.
|
||||
export type AriaNode = AriaProps & {
|
||||
role: AriaRole | 'fragment' | 'iframe';
|
||||
name: string;
|
||||
ref?: string;
|
||||
children: (AriaNode | string)[];
|
||||
box: AriaBox;
|
||||
receivesPointerEvents: boolean;
|
||||
props: Record<string, string>;
|
||||
};
|
||||
|
||||
export function ariaNodesEqual(a: AriaNode, b: AriaNode): boolean {
|
||||
if (a.role !== b.role || a.name !== b.name)
|
||||
return false;
|
||||
if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b))
|
||||
return false;
|
||||
const aKeys = Object.keys(a.props);
|
||||
const bKeys = Object.keys(b.props);
|
||||
return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]);
|
||||
}
|
||||
|
||||
export function hasPointerCursor(ariaNode: AriaNode): boolean {
|
||||
return ariaNode.box.cursor === 'pointer';
|
||||
}
|
||||
|
||||
function ariaPropsEqual(a: AriaProps, b: AriaProps): boolean {
|
||||
return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed;
|
||||
}
|
||||
|
||||
// We pass parsed template between worlds using JSON, make it easy.
|
||||
export type AriaRegex = { pattern: string };
|
||||
|
||||
// We can't tell apart pattern and text, so we pass both.
|
||||
export type AriaTextValue = {
|
||||
raw: string;
|
||||
normalized: string;
|
||||
};
|
||||
|
||||
export type AriaTemplateTextNode = {
|
||||
kind: 'text';
|
||||
text: AriaTextValue;
|
||||
};
|
||||
|
||||
export type AriaTemplateRoleNode = AriaProps & {
|
||||
kind: 'role';
|
||||
role: AriaRole | 'fragment';
|
||||
name?: AriaRegex | string;
|
||||
children?: AriaTemplateNode[];
|
||||
props?: Record<string, AriaTextValue>;
|
||||
containerMode?: 'contain' | 'equal' | 'deep-equal';
|
||||
};
|
||||
|
||||
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
|
||||
|
||||
import type * as yamlTypes from 'yaml';
|
||||
|
||||
type YamlLibrary = {
|
||||
parseDocument: typeof yamlTypes.parseDocument;
|
||||
Scalar: typeof yamlTypes.Scalar;
|
||||
YAMLMap: typeof yamlTypes.YAMLMap;
|
||||
YAMLSeq: typeof yamlTypes.YAMLSeq;
|
||||
LineCounter: typeof yamlTypes.LineCounter;
|
||||
};
|
||||
|
||||
type ParsedYamlPosition = { line: number; col: number; };
|
||||
type ParsingOptions = yamlTypes.ParseOptions;
|
||||
|
||||
export type ParsedYamlError = {
|
||||
message: string;
|
||||
range: [ParsedYamlPosition, ParsedYamlPosition];
|
||||
};
|
||||
|
||||
export function parseAriaSnapshotUnsafe(yaml: YamlLibrary, text: string, options: ParsingOptions = {}): AriaTemplateNode {
|
||||
const result = parseAriaSnapshot(yaml, text, options);
|
||||
if (result.errors.length)
|
||||
throw new Error(result.errors[0].message);
|
||||
return result.fragment;
|
||||
}
|
||||
|
||||
export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: ParsingOptions = {}): { fragment: AriaTemplateNode, errors: ParsedYamlError[] } {
|
||||
const lineCounter = new yaml.LineCounter();
|
||||
const parseOptions: ParsingOptions = {
|
||||
keepSourceTokens: true,
|
||||
lineCounter,
|
||||
...options,
|
||||
};
|
||||
const yamlDoc = yaml.parseDocument(text, parseOptions);
|
||||
const errors: ParsedYamlError[] = [];
|
||||
|
||||
const convertRange = (range: [number, number] | yamlTypes.Range): [ParsedYamlPosition, ParsedYamlPosition] => {
|
||||
return [lineCounter.linePos(range[0]), lineCounter.linePos(range[1])];
|
||||
};
|
||||
|
||||
const addError = (error: yamlTypes.YAMLError) => {
|
||||
errors.push({
|
||||
message: error.message,
|
||||
range: [lineCounter.linePos(error.pos[0]), lineCounter.linePos(error.pos[1])],
|
||||
});
|
||||
};
|
||||
|
||||
const convertSeq = (container: AriaTemplateRoleNode, seq: yamlTypes.YAMLSeq) => {
|
||||
for (const item of seq.items) {
|
||||
const itemIsString = item instanceof yaml.Scalar && typeof item.value === 'string';
|
||||
if (itemIsString) {
|
||||
const childNode = KeyParser.parse(item, parseOptions, errors);
|
||||
if (childNode) {
|
||||
container.children = container.children || [];
|
||||
container.children.push(childNode);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const itemIsMap = item instanceof yaml.YAMLMap;
|
||||
if (itemIsMap) {
|
||||
convertMap(container, item);
|
||||
continue;
|
||||
}
|
||||
errors.push({
|
||||
message: 'Sequence items should be strings or maps',
|
||||
range: convertRange((item as any).range || seq.range),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const convertMap = (container: AriaTemplateRoleNode, map: yamlTypes.YAMLMap) => {
|
||||
for (const entry of map.items) {
|
||||
container.children = container.children || [];
|
||||
// Key must by a string
|
||||
const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === 'string';
|
||||
if (!keyIsString) {
|
||||
errors.push({
|
||||
message: 'Only string keys are supported',
|
||||
range: convertRange((entry.key as any).range || map.range),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const key: yamlTypes.Scalar<string> = entry.key as yamlTypes.Scalar<string>;
|
||||
const value = entry.value;
|
||||
|
||||
// - text: "text"
|
||||
if (key.value === 'text') {
|
||||
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
|
||||
if (!valueIsString) {
|
||||
errors.push({
|
||||
message: 'Text value should be a string',
|
||||
range: convertRange(((entry.value as any).range || map.range)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
container.children.push({
|
||||
kind: 'text',
|
||||
text: textValue(value.value)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// - /children: equal
|
||||
if (key.value === '/children') {
|
||||
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
|
||||
if (!valueIsString || (value.value !== 'contain' && value.value !== 'equal' && value.value !== 'deep-equal')) {
|
||||
errors.push({
|
||||
message: 'Strict value should be "contain", "equal" or "deep-equal"',
|
||||
range: convertRange(((entry.value as any).range || map.range)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
container.containerMode = value.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// - /url: "about:blank"
|
||||
if (key.value.startsWith('/')) {
|
||||
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
|
||||
if (!valueIsString) {
|
||||
errors.push({
|
||||
message: 'Property value should be a string',
|
||||
range: convertRange(((entry.value as any).range || map.range)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
container.props = container.props ?? {};
|
||||
container.props[key.value.slice(1)] = textValue(value.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// role "name": ...
|
||||
const childNode = KeyParser.parse(key, parseOptions, errors);
|
||||
if (!childNode)
|
||||
continue;
|
||||
|
||||
// - role "name": "text"
|
||||
const valueIsScalar = value instanceof yaml.Scalar;
|
||||
if (valueIsScalar) {
|
||||
const type = typeof value.value;
|
||||
if (type !== 'string' && type !== 'number' && type !== 'boolean') {
|
||||
errors.push({
|
||||
message: 'Node value should be a string or a sequence',
|
||||
range: convertRange(((entry.value as any).range || map.range)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
container.children.push({
|
||||
...childNode,
|
||||
children: [{
|
||||
kind: 'text',
|
||||
text: textValue(String(value.value))
|
||||
}]
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// - role "name":
|
||||
// - child
|
||||
const valueIsSequence = value instanceof yaml.YAMLSeq;
|
||||
if (valueIsSequence) {
|
||||
container.children.push(childNode);
|
||||
convertSeq(childNode, value as yamlTypes.YAMLSeq);
|
||||
continue;
|
||||
}
|
||||
|
||||
errors.push({
|
||||
message: 'Map values should be strings or sequences',
|
||||
range: convertRange((entry.value as any).range || map.range),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fragment: AriaTemplateNode = { kind: 'role', role: 'fragment' };
|
||||
|
||||
yamlDoc.errors.forEach(addError);
|
||||
if (errors.length)
|
||||
return { errors, fragment };
|
||||
|
||||
if (!(yamlDoc.contents instanceof yaml.YAMLSeq)) {
|
||||
errors.push({
|
||||
message: 'Aria snapshot must be a YAML sequence, elements starting with " -"',
|
||||
range: yamlDoc.contents ? convertRange(yamlDoc.contents!.range) : [{ line: 0, col: 0 }, { line: 0, col: 0 }],
|
||||
});
|
||||
}
|
||||
if (errors.length)
|
||||
return { errors, fragment };
|
||||
|
||||
convertSeq(fragment, yamlDoc.contents as yamlTypes.YAMLSeq);
|
||||
if (errors.length)
|
||||
return { errors, fragment: emptyFragment };
|
||||
// `- button` should target the button, not its parent.
|
||||
if (fragment.children?.length === 1 && (!fragment.containerMode || fragment.containerMode === 'contain'))
|
||||
return { fragment: fragment.children[0], errors: [] };
|
||||
return { fragment, errors: [] };
|
||||
}
|
||||
|
||||
const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment' };
|
||||
|
||||
function normalizeWhitespace(text: string) {
|
||||
// TODO: why is this different from normalizeWhitespace in stringUtils.ts?
|
||||
return text.replace(/[\u200b\u00ad]/g, '').replace(/[\r\n\s\t]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function textValue(value: string): AriaTextValue {
|
||||
return {
|
||||
raw: value,
|
||||
normalized: normalizeWhitespace(value),
|
||||
};
|
||||
}
|
||||
|
||||
export class KeyParser {
|
||||
private _input: string;
|
||||
private _pos: number;
|
||||
private _length: number;
|
||||
|
||||
static parse(text: yamlTypes.Scalar<string>, options: ParsingOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null {
|
||||
try {
|
||||
return new KeyParser(text.value)._parse();
|
||||
} catch (e) {
|
||||
if (e instanceof ParserError) {
|
||||
const message = options.prettyErrors === false ? e.message : e.message + ':\n\n' + text.value + '\n' + ' '.repeat(e.pos) + '^\n';
|
||||
errors.push({
|
||||
message,
|
||||
range: [options.lineCounter!.linePos(text.range![0]), options.lineCounter!.linePos(text.range![0] + e.pos)],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(input: string) {
|
||||
this._input = input;
|
||||
this._pos = 0;
|
||||
this._length = input.length;
|
||||
}
|
||||
|
||||
private _peek() {
|
||||
return this._input[this._pos] || '';
|
||||
}
|
||||
|
||||
private _next() {
|
||||
if (this._pos < this._length)
|
||||
return this._input[this._pos++];
|
||||
return null;
|
||||
}
|
||||
|
||||
private _eof() {
|
||||
return this._pos >= this._length;
|
||||
}
|
||||
|
||||
private _isWhitespace() {
|
||||
return !this._eof() && /\s/.test(this._peek());
|
||||
}
|
||||
|
||||
private _skipWhitespace() {
|
||||
while (this._isWhitespace())
|
||||
this._pos++;
|
||||
}
|
||||
|
||||
private _readIdentifier(type: 'role' | 'attribute'): string {
|
||||
if (this._eof())
|
||||
this._throwError(`Unexpected end of input when expecting ${type}`);
|
||||
const start = this._pos;
|
||||
while (!this._eof() && /[a-zA-Z]/.test(this._peek()))
|
||||
this._pos++;
|
||||
return this._input.slice(start, this._pos);
|
||||
}
|
||||
|
||||
private _readString(): string {
|
||||
let result = '';
|
||||
let escaped = false;
|
||||
while (!this._eof()) {
|
||||
const ch = this._next();
|
||||
if (escaped) {
|
||||
result += ch;
|
||||
escaped = false;
|
||||
} else if (ch === '\\') {
|
||||
escaped = true;
|
||||
} else if (ch === '"') {
|
||||
return result;
|
||||
} else {
|
||||
result += ch;
|
||||
}
|
||||
}
|
||||
this._throwError('Unterminated string');
|
||||
}
|
||||
|
||||
private _throwError(message: string, offset: number = 0): never {
|
||||
throw new ParserError(message, offset || this._pos);
|
||||
}
|
||||
|
||||
private _readRegex(): AriaRegex {
|
||||
let result = '';
|
||||
let escaped = false;
|
||||
let insideClass = false;
|
||||
while (!this._eof()) {
|
||||
const ch = this._next();
|
||||
if (escaped) {
|
||||
result += ch;
|
||||
escaped = false;
|
||||
} else if (ch === '\\') {
|
||||
escaped = true;
|
||||
result += ch;
|
||||
} else if (ch === '/' && !insideClass) {
|
||||
return { pattern: result };
|
||||
} else if (ch === '[') {
|
||||
insideClass = true;
|
||||
result += ch;
|
||||
} else if (ch === ']' && insideClass) {
|
||||
result += ch;
|
||||
insideClass = false;
|
||||
} else {
|
||||
result += ch;
|
||||
}
|
||||
}
|
||||
this._throwError('Unterminated regex');
|
||||
}
|
||||
|
||||
private _readStringOrRegex(): string | AriaRegex | null {
|
||||
const ch = this._peek();
|
||||
if (ch === '"') {
|
||||
this._next();
|
||||
return normalizeWhitespace(this._readString());
|
||||
}
|
||||
|
||||
if (ch === '/') {
|
||||
this._next();
|
||||
return this._readRegex();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private _readAttributes(result: AriaTemplateRoleNode) {
|
||||
let errorPos = this._pos;
|
||||
while (true) {
|
||||
this._skipWhitespace();
|
||||
if (this._peek() === '[') {
|
||||
this._next();
|
||||
this._skipWhitespace();
|
||||
errorPos = this._pos;
|
||||
const flagName = this._readIdentifier('attribute');
|
||||
this._skipWhitespace();
|
||||
let flagValue = '';
|
||||
if (this._peek() === '=') {
|
||||
this._next();
|
||||
this._skipWhitespace();
|
||||
errorPos = this._pos;
|
||||
while (this._peek() !== ']' && !this._isWhitespace() && !this._eof())
|
||||
flagValue += this._next();
|
||||
}
|
||||
this._skipWhitespace();
|
||||
if (this._peek() !== ']')
|
||||
this._throwError('Expected ]');
|
||||
|
||||
this._next(); // Consume ']'
|
||||
this._applyAttribute(result, flagName, flagValue || 'true', errorPos);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_parse(): AriaTemplateRoleNode {
|
||||
this._skipWhitespace();
|
||||
|
||||
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role'];
|
||||
this._skipWhitespace();
|
||||
const name = this._readStringOrRegex() || '';
|
||||
const result: AriaTemplateRoleNode = { kind: 'role', role, name };
|
||||
this._readAttributes(result);
|
||||
this._skipWhitespace();
|
||||
if (!this._eof())
|
||||
this._throwError('Unexpected input');
|
||||
return result;
|
||||
}
|
||||
|
||||
private _applyAttribute(node: AriaTemplateRoleNode, key: string, value: string, errorPos: number) {
|
||||
if (key === 'checked') {
|
||||
this._assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "checked\" attribute must be a boolean or "mixed"', errorPos);
|
||||
node.checked = value === 'true' ? true : value === 'false' ? false : 'mixed';
|
||||
return;
|
||||
}
|
||||
if (key === 'disabled') {
|
||||
this._assert(value === 'true' || value === 'false', 'Value of "disabled" attribute must be a boolean', errorPos);
|
||||
node.disabled = value === 'true';
|
||||
return;
|
||||
}
|
||||
if (key === 'expanded') {
|
||||
this._assert(value === 'true' || value === 'false', 'Value of "expanded" attribute must be a boolean', errorPos);
|
||||
node.expanded = value === 'true';
|
||||
return;
|
||||
}
|
||||
if (key === 'active') {
|
||||
this._assert(value === 'true' || value === 'false', 'Value of "active" attribute must be a boolean', errorPos);
|
||||
node.active = value === 'true';
|
||||
return;
|
||||
}
|
||||
if (key === 'level') {
|
||||
this._assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number', errorPos);
|
||||
node.level = Number(value);
|
||||
return;
|
||||
}
|
||||
if (key === 'pressed') {
|
||||
this._assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "pressed" attribute must be a boolean or "mixed"', errorPos);
|
||||
node.pressed = value === 'true' ? true : value === 'false' ? false : 'mixed';
|
||||
return;
|
||||
}
|
||||
if (key === 'selected') {
|
||||
this._assert(value === 'true' || value === 'false', 'Value of "selected" attribute must be a boolean', errorPos);
|
||||
node.selected = value === 'true';
|
||||
return;
|
||||
}
|
||||
this._assert(false, `Unsupported attribute [${key}]`, errorPos);
|
||||
}
|
||||
|
||||
private _assert(value: any, message: string, valuePos: number): asserts value {
|
||||
if (!value)
|
||||
this._throwError(message || 'Assertion error', valuePos);
|
||||
}
|
||||
}
|
||||
|
||||
export class ParserError extends Error {
|
||||
readonly pos: number;
|
||||
|
||||
constructor(message: string, pos: number) {
|
||||
super(message);
|
||||
this.pos = pos;
|
||||
}
|
||||
}
|
||||
|
||||
export function findNewNode(from: AriaNode | undefined, to: AriaNode): AriaNode | undefined {
|
||||
type ByRoleAndName = Map<string, Map<string, { node: AriaNode, sizeAndPosition: number }>>;
|
||||
|
||||
function fillMap(root: AriaNode, map: ByRoleAndName, position: number) {
|
||||
let size = 1;
|
||||
let childPosition = position + size;
|
||||
for (const child of root.children || []) {
|
||||
if (typeof child === 'string') {
|
||||
size++;
|
||||
childPosition++;
|
||||
} else {
|
||||
size += fillMap(child, map, childPosition);
|
||||
childPosition += size;
|
||||
}
|
||||
}
|
||||
if (!['none', 'presentation', 'fragment', 'iframe', 'generic'].includes(root.role) && root.name) {
|
||||
let byRole = map.get(root.role);
|
||||
if (!byRole) {
|
||||
byRole = new Map();
|
||||
map.set(root.role, byRole);
|
||||
}
|
||||
const existing = byRole.get(root.name);
|
||||
// This heuristic prioritizes elements at the top of the page, even if somewhat smaller.
|
||||
const sizeAndPosition = size * 100 - position;
|
||||
if (!existing || existing.sizeAndPosition < sizeAndPosition)
|
||||
byRole.set(root.name, { node: root, sizeAndPosition });
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
const fromMap: ByRoleAndName = new Map();
|
||||
if (from)
|
||||
fillMap(from, fromMap, 0);
|
||||
|
||||
const toMap: ByRoleAndName = new Map();
|
||||
fillMap(to, toMap, 0);
|
||||
|
||||
const result: { node: AriaNode, sizeAndPosition: number }[] = [];
|
||||
for (const [role, byRole] of toMap) {
|
||||
for (const [name, byName] of byRole) {
|
||||
const inFrom = fromMap.get(role)?.get(name);
|
||||
if (!inFrom)
|
||||
result.push(byName);
|
||||
}
|
||||
}
|
||||
result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition);
|
||||
return result[0]?.node;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function assert(value: any, message?: string): asserts value {
|
||||
if (!value)
|
||||
throw new Error(message || 'Assertion error');
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export const webColors = {
|
||||
enabled: true,
|
||||
reset: (text: string) => applyStyle(0, 0, text),
|
||||
|
||||
bold: (text: string) => applyStyle(1, 22, text),
|
||||
dim: (text: string) => applyStyle(2, 22, text),
|
||||
italic: (text: string) => applyStyle(3, 23, text),
|
||||
underline: (text: string) => applyStyle(4, 24, text),
|
||||
inverse: (text: string) => applyStyle(7, 27, text),
|
||||
hidden: (text: string) => applyStyle(8, 28, text),
|
||||
strikethrough: (text: string) => applyStyle(9, 29, text),
|
||||
|
||||
black: (text: string) => applyStyle(30, 39, text),
|
||||
red: (text: string) => applyStyle(31, 39, text),
|
||||
green: (text: string) => applyStyle(32, 39, text),
|
||||
yellow: (text: string) => applyStyle(33, 39, text),
|
||||
blue: (text: string) => applyStyle(34, 39, text),
|
||||
magenta: (text: string) => applyStyle(35, 39, text),
|
||||
cyan: (text: string) => applyStyle(36, 39, text),
|
||||
white: (text: string) => applyStyle(37, 39, text),
|
||||
gray: (text: string) => applyStyle(90, 39, text),
|
||||
grey: (text: string) => applyStyle(90, 39, text),
|
||||
};
|
||||
|
||||
export type Colors = typeof webColors;
|
||||
|
||||
export const noColors: Colors = {
|
||||
enabled: false,
|
||||
reset: t => t,
|
||||
bold: t => t,
|
||||
dim: t => t,
|
||||
italic: t => t,
|
||||
underline: t => t,
|
||||
inverse: t => t,
|
||||
hidden: t => t,
|
||||
strikethrough: t => t,
|
||||
black: t => t,
|
||||
red: t => t,
|
||||
green: t => t,
|
||||
yellow: t => t,
|
||||
blue: t => t,
|
||||
magenta: t => t,
|
||||
cyan: t => t,
|
||||
white: t => t,
|
||||
gray: t => t,
|
||||
grey: t => t,
|
||||
};
|
||||
|
||||
|
||||
const applyStyle = (open: number, close: number, text: string) => `\u001b[${open}m${text}\u001b[${close}m`;
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as css from './cssTokenizer';
|
||||
|
||||
export class InvalidSelectorError extends Error {
|
||||
}
|
||||
|
||||
export function isInvalidSelectorError(error: Error) {
|
||||
return error instanceof InvalidSelectorError;
|
||||
}
|
||||
|
||||
// Note: '>=' is used internally for text engine to preserve backwards compatibility.
|
||||
type ClauseCombinator = '' | '>' | '+' | '~' | '>=';
|
||||
// TODO: consider
|
||||
// - key=value
|
||||
// - operators like `=`, `|=`, `~=`, `*=`, `/`
|
||||
// - <empty>~=value
|
||||
// - argument modes: "parse all", "parse commas", "just a string"
|
||||
export type CSSFunctionArgument = CSSComplexSelector | number | string;
|
||||
export type CSSFunction = { name: string, args: CSSFunctionArgument[] };
|
||||
export type CSSSimpleSelector = { css?: string, functions: CSSFunction[] };
|
||||
export type CSSComplexSelector = { simples: { selector: CSSSimpleSelector, combinator: ClauseCombinator }[] };
|
||||
export type CSSComplexSelectorList = CSSComplexSelector[];
|
||||
|
||||
export function parseCSS(selector: string, customNames: Set<string>): { selector: CSSComplexSelectorList, names: string[] } {
|
||||
let tokens: css.CSSTokenInterface[];
|
||||
try {
|
||||
tokens = css.tokenize(selector);
|
||||
if (!(tokens[tokens.length - 1] instanceof css.EOFToken))
|
||||
tokens.push(new css.EOFToken());
|
||||
} catch (e) {
|
||||
const newMessage = e.message + ` while parsing css selector "${selector}". Did you mean to CSS.escape it?`;
|
||||
const index = (e.stack || '').indexOf(e.message);
|
||||
if (index !== -1)
|
||||
e.stack = e.stack.substring(0, index) + newMessage + e.stack.substring(index + e.message.length);
|
||||
e.message = newMessage;
|
||||
throw e;
|
||||
}
|
||||
const unsupportedToken = tokens.find(token => {
|
||||
return (token instanceof css.AtKeywordToken) ||
|
||||
(token instanceof css.BadStringToken) ||
|
||||
(token instanceof css.BadURLToken) ||
|
||||
(token instanceof css.ColumnToken) ||
|
||||
(token instanceof css.CDOToken) ||
|
||||
(token instanceof css.CDCToken) ||
|
||||
(token instanceof css.SemicolonToken) ||
|
||||
// TODO: Consider using these for something, e.g. to escape complex strings.
|
||||
// For example :xpath{ (//div/bar[@attr="foo"])[2]/baz }
|
||||
// Or this way :xpath( {complex-xpath-goes-here("hello")} )
|
||||
(token instanceof css.OpenCurlyToken) ||
|
||||
(token instanceof css.CloseCurlyToken) ||
|
||||
// TODO: Consider treating these as strings?
|
||||
(token instanceof css.URLToken) ||
|
||||
(token instanceof css.PercentageToken);
|
||||
});
|
||||
if (unsupportedToken)
|
||||
throw new InvalidSelectorError(`Unsupported token "${unsupportedToken.toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?`);
|
||||
|
||||
let pos = 0;
|
||||
const names = new Set<string>();
|
||||
|
||||
function unexpected() {
|
||||
return new InvalidSelectorError(`Unexpected token "${tokens[pos].toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?`);
|
||||
}
|
||||
|
||||
function skipWhitespace() {
|
||||
while (tokens[pos] instanceof css.WhitespaceToken)
|
||||
pos++;
|
||||
}
|
||||
|
||||
function isIdent(p = pos) {
|
||||
return tokens[p] instanceof css.IdentToken;
|
||||
}
|
||||
|
||||
function isString(p = pos) {
|
||||
return tokens[p] instanceof css.StringToken;
|
||||
}
|
||||
|
||||
function isNumber(p = pos) {
|
||||
return tokens[p] instanceof css.NumberToken;
|
||||
}
|
||||
|
||||
function isComma(p = pos) {
|
||||
return tokens[p] instanceof css.CommaToken;
|
||||
}
|
||||
|
||||
function isOpenParen(p = pos) {
|
||||
return tokens[p] instanceof css.OpenParenToken;
|
||||
}
|
||||
|
||||
function isCloseParen(p = pos) {
|
||||
return tokens[p] instanceof css.CloseParenToken;
|
||||
}
|
||||
|
||||
function isFunction(p = pos) {
|
||||
return tokens[p] instanceof css.FunctionToken;
|
||||
}
|
||||
|
||||
function isStar(p = pos) {
|
||||
return (tokens[p] instanceof css.DelimToken) && tokens[p].value === '*';
|
||||
}
|
||||
|
||||
function isEOF(p = pos) {
|
||||
return tokens[p] instanceof css.EOFToken;
|
||||
}
|
||||
|
||||
function isClauseCombinator(p = pos) {
|
||||
return (tokens[p] instanceof css.DelimToken) && (['>', '+', '~'].includes(tokens[p].value as string));
|
||||
}
|
||||
|
||||
function isSelectorClauseEnd(p = pos) {
|
||||
return isComma(p) || isCloseParen(p) || isEOF(p) || isClauseCombinator(p) || (tokens[p] instanceof css.WhitespaceToken);
|
||||
}
|
||||
|
||||
function consumeFunctionArguments(): CSSFunctionArgument[] {
|
||||
const result = [consumeArgument()];
|
||||
while (true) {
|
||||
skipWhitespace();
|
||||
if (!isComma())
|
||||
break;
|
||||
pos++;
|
||||
result.push(consumeArgument());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function consumeArgument(): CSSFunctionArgument {
|
||||
skipWhitespace();
|
||||
if (isNumber())
|
||||
return tokens[pos++].value!;
|
||||
if (isString())
|
||||
return tokens[pos++].value!;
|
||||
return consumeComplexSelector();
|
||||
}
|
||||
|
||||
function consumeComplexSelector(): CSSComplexSelector {
|
||||
const result: CSSComplexSelector = { simples: [] };
|
||||
skipWhitespace();
|
||||
if (isClauseCombinator()) {
|
||||
// Put implicit ":scope" at the start. https://drafts.csswg.org/selectors-4/#relative
|
||||
result.simples.push({ selector: { functions: [{ name: 'scope', args: [] }] }, combinator: '' });
|
||||
} else {
|
||||
result.simples.push({ selector: consumeSimpleSelector(), combinator: '' });
|
||||
}
|
||||
while (true) {
|
||||
skipWhitespace();
|
||||
if (isClauseCombinator()) {
|
||||
result.simples[result.simples.length - 1].combinator = tokens[pos++].value as ClauseCombinator;
|
||||
skipWhitespace();
|
||||
} else if (isSelectorClauseEnd()) {
|
||||
break;
|
||||
}
|
||||
result.simples.push({ combinator: '', selector: consumeSimpleSelector() });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function consumeSimpleSelector(): CSSSimpleSelector {
|
||||
let rawCSSString = '';
|
||||
const functions: CSSFunction[] = [];
|
||||
|
||||
while (!isSelectorClauseEnd()) {
|
||||
if (isIdent() || isStar()) {
|
||||
rawCSSString += tokens[pos++].toSource();
|
||||
} else if (tokens[pos] instanceof css.HashToken) {
|
||||
rawCSSString += tokens[pos++].toSource();
|
||||
} else if ((tokens[pos] instanceof css.DelimToken) && tokens[pos].value === '.') {
|
||||
pos++;
|
||||
if (isIdent())
|
||||
rawCSSString += '.' + tokens[pos++].toSource();
|
||||
else
|
||||
throw unexpected();
|
||||
} else if (tokens[pos] instanceof css.ColonToken) {
|
||||
pos++;
|
||||
if (isIdent()) {
|
||||
if (!customNames.has((tokens[pos].value as string).toLowerCase())) {
|
||||
rawCSSString += ':' + tokens[pos++].toSource();
|
||||
} else {
|
||||
const name = (tokens[pos++].value as string).toLowerCase();
|
||||
functions.push({ name, args: [] });
|
||||
names.add(name);
|
||||
}
|
||||
} else if (isFunction()) {
|
||||
const name = (tokens[pos++].value as string).toLowerCase();
|
||||
if (!customNames.has(name)) {
|
||||
rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`;
|
||||
} else {
|
||||
functions.push({ name, args: consumeFunctionArguments() });
|
||||
names.add(name);
|
||||
}
|
||||
skipWhitespace();
|
||||
if (!isCloseParen())
|
||||
throw unexpected();
|
||||
pos++;
|
||||
} else {
|
||||
throw unexpected();
|
||||
}
|
||||
} else if (tokens[pos] instanceof css.OpenSquareToken) {
|
||||
rawCSSString += '[';
|
||||
pos++;
|
||||
while (!(tokens[pos] instanceof css.CloseSquareToken) && !isEOF())
|
||||
rawCSSString += tokens[pos++].toSource();
|
||||
if (!(tokens[pos] instanceof css.CloseSquareToken))
|
||||
throw unexpected();
|
||||
rawCSSString += ']';
|
||||
pos++;
|
||||
} else {
|
||||
throw unexpected();
|
||||
}
|
||||
}
|
||||
if (!rawCSSString && !functions.length)
|
||||
throw unexpected();
|
||||
return { css: rawCSSString || undefined, functions };
|
||||
}
|
||||
|
||||
function consumeBuiltinFunctionArguments(): string {
|
||||
let s = '';
|
||||
let balance = 1; // First open paren is a part of a function token.
|
||||
while (!isEOF()) {
|
||||
if (isOpenParen() || isFunction())
|
||||
balance++;
|
||||
if (isCloseParen())
|
||||
balance--;
|
||||
if (!balance)
|
||||
break;
|
||||
s += tokens[pos++].toSource();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const result = consumeFunctionArguments();
|
||||
if (!isEOF())
|
||||
throw unexpected();
|
||||
if (result.some(arg => typeof arg !== 'object' || !('simples' in arg)))
|
||||
throw new InvalidSelectorError(`Error while parsing css selector "${selector}". Did you mean to CSS.escape it?`);
|
||||
return { selector: result as CSSComplexSelector[], names: Array.from(names) };
|
||||
}
|
||||
|
||||
export function serializeSelector(args: CSSFunctionArgument[]) {
|
||||
return args.map(arg => {
|
||||
if (typeof arg === 'string')
|
||||
return `"${arg}"`;
|
||||
if (typeof arg === 'number')
|
||||
return String(arg);
|
||||
return arg.simples.map(({ selector, combinator }) => {
|
||||
let s = selector.css || '';
|
||||
s = s + selector.functions.map(func => `:${func.name}(${serializeSelector(func.args)})`).join('');
|
||||
if (combinator)
|
||||
s += ' ' + combinator;
|
||||
return s;
|
||||
}).join(' ');
|
||||
}).join(', ');
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
/* eslint-disable notice/notice */
|
||||
|
||||
/*
|
||||
* The code in this file is licensed under the CC0 license.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/
|
||||
* It is free to use for any purpose. No attribution, permission, or reproduction of this license is required.
|
||||
*/
|
||||
|
||||
// Original at https://github.com/tabatkins/parse-css
|
||||
// Changes:
|
||||
// - JS is replaced with TS.
|
||||
// - Universal Module Definition wrapper is removed.
|
||||
// - Everything not related to tokenizing - below the first exports block - is removed.
|
||||
|
||||
export interface CSSTokenInterface {
|
||||
toSource(): string;
|
||||
value: string | number | undefined;
|
||||
}
|
||||
|
||||
const between = function(num: number, first: number, last: number) { return num >= first && num <= last; };
|
||||
function digit(code: number) { return between(code, 0x30, 0x39); }
|
||||
function hexdigit(code: number) { return digit(code) || between(code, 0x41, 0x46) || between(code, 0x61, 0x66); }
|
||||
function uppercaseletter(code: number) { return between(code, 0x41, 0x5a); }
|
||||
function lowercaseletter(code: number) { return between(code, 0x61, 0x7a); }
|
||||
function letter(code: number) { return uppercaseletter(code) || lowercaseletter(code); }
|
||||
function nonascii(code: number) { return code >= 0x80; }
|
||||
function namestartchar(code: number) { return letter(code) || nonascii(code) || code === 0x5f; }
|
||||
function namechar(code: number) { return namestartchar(code) || digit(code) || code === 0x2d; }
|
||||
function nonprintable(code: number) { return between(code, 0, 8) || code === 0xb || between(code, 0xe, 0x1f) || code === 0x7f; }
|
||||
function newline(code: number) { return code === 0xa; }
|
||||
function whitespace(code: number) { return newline(code) || code === 9 || code === 0x20; }
|
||||
|
||||
const maximumallowedcodepoint = 0x10ffff;
|
||||
|
||||
export class InvalidCharacterError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidCharacterError';
|
||||
}
|
||||
}
|
||||
|
||||
function preprocess(str: string): number[] {
|
||||
// Turn a string into an array of code points,
|
||||
// following the preprocessing cleanup rules.
|
||||
const codepoints = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let code = str.charCodeAt(i);
|
||||
if (code === 0xd && str.charCodeAt(i + 1) === 0xa) {
|
||||
code = 0xa; i++;
|
||||
}
|
||||
if (code === 0xd || code === 0xc)
|
||||
code = 0xa;
|
||||
if (code === 0x0)
|
||||
code = 0xfffd;
|
||||
if (between(code, 0xd800, 0xdbff) && between(str.charCodeAt(i + 1), 0xdc00, 0xdfff)) {
|
||||
// Decode a surrogate pair into an astral codepoint.
|
||||
const lead = code - 0xd800;
|
||||
const trail = str.charCodeAt(i + 1) - 0xdc00;
|
||||
code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail;
|
||||
i++;
|
||||
}
|
||||
codepoints.push(code);
|
||||
}
|
||||
return codepoints;
|
||||
}
|
||||
|
||||
function stringFromCode(code: number) {
|
||||
if (code <= 0xffff)
|
||||
return String.fromCharCode(code);
|
||||
// Otherwise, encode astral char as surrogate pair.
|
||||
code -= Math.pow(2, 16);
|
||||
const lead = Math.floor(code / Math.pow(2, 10)) + 0xd800;
|
||||
const trail = code % Math.pow(2, 10) + 0xdc00;
|
||||
return String.fromCharCode(lead) + String.fromCharCode(trail);
|
||||
}
|
||||
|
||||
export function tokenize(str1: string): CSSTokenInterface[] {
|
||||
const str = preprocess(str1);
|
||||
let i = -1;
|
||||
const tokens: CSSTokenInterface[] = [];
|
||||
let code: number;
|
||||
|
||||
// Line number information.
|
||||
let line = 0;
|
||||
let column = 0;
|
||||
// The only use of lastLineLength is in reconsume().
|
||||
let lastLineLength = 0;
|
||||
const incrLineno = function() {
|
||||
line += 1;
|
||||
lastLineLength = column;
|
||||
column = 0;
|
||||
};
|
||||
const locStart = { line: line, column: column };
|
||||
|
||||
const codepoint = function(i: number): number {
|
||||
if (i >= str.length)
|
||||
return -1;
|
||||
|
||||
return str[i];
|
||||
};
|
||||
const next = function(num?: number) {
|
||||
if (num === undefined)
|
||||
num = 1;
|
||||
if (num > 3)
|
||||
throw 'Spec Error: no more than three codepoints of lookahead.';
|
||||
return codepoint(i + num);
|
||||
};
|
||||
const consume = function(num?: number): boolean {
|
||||
if (num === undefined)
|
||||
num = 1;
|
||||
i += num;
|
||||
code = codepoint(i);
|
||||
if (newline(code))
|
||||
incrLineno();
|
||||
else
|
||||
column += num;
|
||||
// console.log('Consume '+i+' '+String.fromCharCode(code) + ' 0x' + code.toString(16));
|
||||
return true;
|
||||
};
|
||||
const reconsume = function() {
|
||||
i -= 1;
|
||||
if (newline(code)) {
|
||||
line -= 1;
|
||||
column = lastLineLength;
|
||||
} else {
|
||||
column -= 1;
|
||||
}
|
||||
locStart.line = line;
|
||||
locStart.column = column;
|
||||
return true;
|
||||
};
|
||||
const eof = function(codepoint?: number): boolean {
|
||||
if (codepoint === undefined)
|
||||
codepoint = code;
|
||||
return codepoint === -1;
|
||||
};
|
||||
const donothing = function() { };
|
||||
const parseerror = function() {
|
||||
// Language bindings don't like writing to stdout!
|
||||
// console.log('Parse error at index ' + i + ', processing codepoint 0x' + code.toString(16) + '.'); return true;
|
||||
};
|
||||
|
||||
const consumeAToken = function(): CSSTokenInterface {
|
||||
consumeComments();
|
||||
consume();
|
||||
if (whitespace(code)) {
|
||||
while (whitespace(next()))
|
||||
consume();
|
||||
return new WhitespaceToken();
|
||||
} else if (code === 0x22) {return consumeAStringToken();} else if (code === 0x23) {
|
||||
if (namechar(next()) || areAValidEscape(next(1), next(2))) {
|
||||
const token = new HashToken('');
|
||||
if (wouldStartAnIdentifier(next(1), next(2), next(3)))
|
||||
token.type = 'id';
|
||||
token.value = consumeAName();
|
||||
return token;
|
||||
} else {
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (code === 0x24) {
|
||||
if (next() === 0x3d) {
|
||||
consume();
|
||||
return new SuffixMatchToken();
|
||||
} else {
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (code === 0x27) {return consumeAStringToken();} else if (code === 0x28) {return new OpenParenToken();} else if (code === 0x29) {return new CloseParenToken();} else if (code === 0x2a) {
|
||||
if (next() === 0x3d) {
|
||||
consume();
|
||||
return new SubstringMatchToken();
|
||||
} else {
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (code === 0x2b) {
|
||||
if (startsWithANumber()) {
|
||||
reconsume();
|
||||
return consumeANumericToken();
|
||||
} else {
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (code === 0x2c) {return new CommaToken();} else if (code === 0x2d) {
|
||||
if (startsWithANumber()) {
|
||||
reconsume();
|
||||
return consumeANumericToken();
|
||||
} else if (next(1) === 0x2d && next(2) === 0x3e) {
|
||||
consume(2);
|
||||
return new CDCToken();
|
||||
} else if (startsWithAnIdentifier()) {
|
||||
reconsume();
|
||||
return consumeAnIdentlikeToken();
|
||||
} else {
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (code === 0x2e) {
|
||||
if (startsWithANumber()) {
|
||||
reconsume();
|
||||
return consumeANumericToken();
|
||||
} else {
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (code === 0x3a) {return new ColonToken();} else if (code === 0x3b) {return new SemicolonToken();} else if (code === 0x3c) {
|
||||
if (next(1) === 0x21 && next(2) === 0x2d && next(3) === 0x2d) {
|
||||
consume(3);
|
||||
return new CDOToken();
|
||||
} else {
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (code === 0x40) {
|
||||
if (wouldStartAnIdentifier(next(1), next(2), next(3)))
|
||||
return new AtKeywordToken(consumeAName());
|
||||
else
|
||||
return new DelimToken(code);
|
||||
|
||||
} else if (code === 0x5b) {return new OpenSquareToken();} else if (code === 0x5c) {
|
||||
if (startsWithAValidEscape()) {
|
||||
reconsume();
|
||||
return consumeAnIdentlikeToken();
|
||||
} else {
|
||||
parseerror();
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (code === 0x5d) {return new CloseSquareToken();} else if (code === 0x5e) {
|
||||
if (next() === 0x3d) {
|
||||
consume();
|
||||
return new PrefixMatchToken();
|
||||
} else {
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (code === 0x7b) {return new OpenCurlyToken();} else if (code === 0x7c) {
|
||||
if (next() === 0x3d) {
|
||||
consume();
|
||||
return new DashMatchToken();
|
||||
} else if (next() === 0x7c) {
|
||||
consume();
|
||||
return new ColumnToken();
|
||||
} else {
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (code === 0x7d) {return new CloseCurlyToken();} else if (code === 0x7e) {
|
||||
if (next() === 0x3d) {
|
||||
consume();
|
||||
return new IncludeMatchToken();
|
||||
} else {
|
||||
return new DelimToken(code);
|
||||
}
|
||||
} else if (digit(code)) {
|
||||
reconsume();
|
||||
return consumeANumericToken();
|
||||
} else if (namestartchar(code)) {
|
||||
reconsume();
|
||||
return consumeAnIdentlikeToken();
|
||||
} else if (eof()) {return new EOFToken();} else {return new DelimToken(code);}
|
||||
};
|
||||
|
||||
const consumeComments = function() {
|
||||
while (next(1) === 0x2f && next(2) === 0x2a) {
|
||||
consume(2);
|
||||
while (true) {
|
||||
consume();
|
||||
if (code === 0x2a && next() === 0x2f) {
|
||||
consume();
|
||||
break;
|
||||
} else if (eof()) {
|
||||
parseerror();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const consumeANumericToken = function() {
|
||||
const num = consumeANumber();
|
||||
if (wouldStartAnIdentifier(next(1), next(2), next(3))) {
|
||||
const token = new DimensionToken();
|
||||
token.value = num.value;
|
||||
token.repr = num.repr;
|
||||
token.type = num.type;
|
||||
token.unit = consumeAName();
|
||||
return token;
|
||||
} else if (next() === 0x25) {
|
||||
consume();
|
||||
const token = new PercentageToken();
|
||||
token.value = num.value;
|
||||
token.repr = num.repr;
|
||||
return token;
|
||||
} else {
|
||||
const token = new NumberToken();
|
||||
token.value = num.value;
|
||||
token.repr = num.repr;
|
||||
token.type = num.type;
|
||||
return token;
|
||||
}
|
||||
};
|
||||
|
||||
const consumeAnIdentlikeToken = function(): CSSTokenInterface {
|
||||
const str = consumeAName();
|
||||
if (str.toLowerCase() === 'url' && next() === 0x28) {
|
||||
consume();
|
||||
while (whitespace(next(1)) && whitespace(next(2)))
|
||||
consume();
|
||||
if (next() === 0x22 || next() === 0x27)
|
||||
return new FunctionToken(str);
|
||||
else if (whitespace(next()) && (next(2) === 0x22 || next(2) === 0x27))
|
||||
return new FunctionToken(str);
|
||||
else
|
||||
return consumeAURLToken();
|
||||
|
||||
} else if (next() === 0x28) {
|
||||
consume();
|
||||
return new FunctionToken(str);
|
||||
} else {
|
||||
return new IdentToken(str);
|
||||
}
|
||||
};
|
||||
|
||||
const consumeAStringToken = function(endingCodePoint?: number): CSSParserToken {
|
||||
if (endingCodePoint === undefined)
|
||||
endingCodePoint = code;
|
||||
let string = '';
|
||||
while (consume()) {
|
||||
if (code === endingCodePoint || eof()) {
|
||||
return new StringToken(string);
|
||||
} else if (newline(code)) {
|
||||
parseerror();
|
||||
reconsume();
|
||||
return new BadStringToken();
|
||||
} else if (code === 0x5c) {
|
||||
if (eof(next()))
|
||||
donothing();
|
||||
else if (newline(next()))
|
||||
consume();
|
||||
else
|
||||
string += stringFromCode(consumeEscape());
|
||||
|
||||
} else {
|
||||
string += stringFromCode(code);
|
||||
}
|
||||
}
|
||||
throw new Error('Internal error');
|
||||
};
|
||||
|
||||
const consumeAURLToken = function(): CSSTokenInterface {
|
||||
const token = new URLToken('');
|
||||
while (whitespace(next()))
|
||||
consume();
|
||||
if (eof(next()))
|
||||
return token;
|
||||
while (consume()) {
|
||||
if (code === 0x29 || eof()) {
|
||||
return token;
|
||||
} else if (whitespace(code)) {
|
||||
while (whitespace(next()))
|
||||
consume();
|
||||
if (next() === 0x29 || eof(next())) {
|
||||
consume();
|
||||
return token;
|
||||
} else {
|
||||
consumeTheRemnantsOfABadURL();
|
||||
return new BadURLToken();
|
||||
}
|
||||
} else if (code === 0x22 || code === 0x27 || code === 0x28 || nonprintable(code)) {
|
||||
parseerror();
|
||||
consumeTheRemnantsOfABadURL();
|
||||
return new BadURLToken();
|
||||
} else if (code === 0x5c) {
|
||||
if (startsWithAValidEscape()) {
|
||||
token.value += stringFromCode(consumeEscape());
|
||||
} else {
|
||||
parseerror();
|
||||
consumeTheRemnantsOfABadURL();
|
||||
return new BadURLToken();
|
||||
}
|
||||
} else {
|
||||
token.value += stringFromCode(code);
|
||||
}
|
||||
}
|
||||
throw new Error('Internal error');
|
||||
};
|
||||
|
||||
const consumeEscape = function() {
|
||||
// Assume the current character is the \
|
||||
// and the next code point is not a newline.
|
||||
consume();
|
||||
if (hexdigit(code)) {
|
||||
// Consume 1-6 hex digits
|
||||
const digits = [code];
|
||||
for (let total = 0; total < 5; total++) {
|
||||
if (hexdigit(next())) {
|
||||
consume();
|
||||
digits.push(code);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (whitespace(next()))
|
||||
consume();
|
||||
let value = parseInt(digits.map(function(x) { return String.fromCharCode(x); }).join(''), 16);
|
||||
if (value > maximumallowedcodepoint)
|
||||
value = 0xfffd;
|
||||
return value;
|
||||
} else if (eof()) {
|
||||
return 0xfffd;
|
||||
} else {
|
||||
return code;
|
||||
}
|
||||
};
|
||||
|
||||
const areAValidEscape = function(c1: number, c2: number) {
|
||||
if (c1 !== 0x5c)
|
||||
return false;
|
||||
if (newline(c2))
|
||||
return false;
|
||||
return true;
|
||||
};
|
||||
const startsWithAValidEscape = function() {
|
||||
return areAValidEscape(code, next());
|
||||
};
|
||||
|
||||
const wouldStartAnIdentifier = function(c1: number, c2: number, c3: number) {
|
||||
if (c1 === 0x2d)
|
||||
return namestartchar(c2) || c2 === 0x2d || areAValidEscape(c2, c3);
|
||||
else if (namestartchar(c1))
|
||||
return true;
|
||||
else if (c1 === 0x5c)
|
||||
return areAValidEscape(c1, c2);
|
||||
else
|
||||
return false;
|
||||
|
||||
};
|
||||
const startsWithAnIdentifier = function() {
|
||||
return wouldStartAnIdentifier(code, next(1), next(2));
|
||||
};
|
||||
|
||||
const wouldStartANumber = function(c1: number, c2: number, c3: number) {
|
||||
if (c1 === 0x2b || c1 === 0x2d) {
|
||||
if (digit(c2))
|
||||
return true;
|
||||
if (c2 === 0x2e && digit(c3))
|
||||
return true;
|
||||
return false;
|
||||
} else if (c1 === 0x2e) {
|
||||
if (digit(c2))
|
||||
return true;
|
||||
return false;
|
||||
} else if (digit(c1)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const startsWithANumber = function() {
|
||||
return wouldStartANumber(code, next(1), next(2));
|
||||
};
|
||||
|
||||
const consumeAName = function(): string {
|
||||
let result = '';
|
||||
while (consume()) {
|
||||
if (namechar(code)) {
|
||||
result += stringFromCode(code);
|
||||
} else if (startsWithAValidEscape()) {
|
||||
result += stringFromCode(consumeEscape());
|
||||
} else {
|
||||
reconsume();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
throw new Error('Internal parse error');
|
||||
};
|
||||
|
||||
const consumeANumber = function() {
|
||||
let repr = '';
|
||||
let type = 'integer';
|
||||
if (next() === 0x2b || next() === 0x2d) {
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
}
|
||||
while (digit(next())) {
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
}
|
||||
if (next(1) === 0x2e && digit(next(2))) {
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
type = 'number';
|
||||
while (digit(next())) {
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
}
|
||||
}
|
||||
const c1 = next(1), c2 = next(2), c3 = next(3);
|
||||
if ((c1 === 0x45 || c1 === 0x65) && digit(c2)) {
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
type = 'number';
|
||||
while (digit(next())) {
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
}
|
||||
} else if ((c1 === 0x45 || c1 === 0x65) && (c2 === 0x2b || c2 === 0x2d) && digit(c3)) {
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
type = 'number';
|
||||
while (digit(next())) {
|
||||
consume();
|
||||
repr += stringFromCode(code);
|
||||
}
|
||||
}
|
||||
const value = convertAStringToANumber(repr);
|
||||
return { type: type, value: value, repr: repr };
|
||||
};
|
||||
|
||||
const convertAStringToANumber = function(string: string): number {
|
||||
// CSS's number rules are identical to JS, afaik.
|
||||
return +string;
|
||||
};
|
||||
|
||||
const consumeTheRemnantsOfABadURL = function() {
|
||||
while (consume()) {
|
||||
if (code === 0x29 || eof()) {
|
||||
return;
|
||||
} else if (startsWithAValidEscape()) {
|
||||
consumeEscape();
|
||||
donothing();
|
||||
} else {
|
||||
donothing();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let iterationCount = 0;
|
||||
while (!eof(next())) {
|
||||
tokens.push(consumeAToken());
|
||||
iterationCount++;
|
||||
if (iterationCount > str.length * 2)
|
||||
throw new Error("I'm infinite-looping!");
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export class CSSParserToken implements CSSTokenInterface {
|
||||
tokenType = '';
|
||||
value: string | number | undefined;
|
||||
toJSON(): any {
|
||||
return { token: this.tokenType };
|
||||
}
|
||||
toString() { return this.tokenType; }
|
||||
toSource() { return '' + this; }
|
||||
}
|
||||
|
||||
export class BadStringToken extends CSSParserToken {
|
||||
override tokenType = 'BADSTRING';
|
||||
}
|
||||
|
||||
export class BadURLToken extends CSSParserToken {
|
||||
override tokenType = 'BADURL';
|
||||
}
|
||||
|
||||
export class WhitespaceToken extends CSSParserToken {
|
||||
override tokenType = 'WHITESPACE';
|
||||
override toString() { return 'WS'; }
|
||||
override toSource() { return ' '; }
|
||||
}
|
||||
|
||||
export class CDOToken extends CSSParserToken {
|
||||
override tokenType = 'CDO';
|
||||
override toSource() { return '<!--'; }
|
||||
}
|
||||
|
||||
export class CDCToken extends CSSParserToken {
|
||||
override tokenType = 'CDC';
|
||||
override toSource() { return '-->'; }
|
||||
}
|
||||
|
||||
export class ColonToken extends CSSParserToken {
|
||||
override tokenType = ':';
|
||||
}
|
||||
|
||||
export class SemicolonToken extends CSSParserToken {
|
||||
override tokenType = ';';
|
||||
}
|
||||
|
||||
export class CommaToken extends CSSParserToken {
|
||||
override tokenType = ',';
|
||||
}
|
||||
|
||||
export class GroupingToken extends CSSParserToken {
|
||||
override value = '';
|
||||
mirror = '';
|
||||
}
|
||||
|
||||
export class OpenCurlyToken extends GroupingToken {
|
||||
override tokenType = '{';
|
||||
constructor() {
|
||||
super();
|
||||
this.value = '{';
|
||||
this.mirror = '}';
|
||||
}
|
||||
}
|
||||
|
||||
export class CloseCurlyToken extends GroupingToken {
|
||||
override tokenType = '}';
|
||||
constructor() {
|
||||
super();
|
||||
this.value = '}';
|
||||
this.mirror = '{';
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenSquareToken extends GroupingToken {
|
||||
override tokenType = '[';
|
||||
constructor() {
|
||||
super();
|
||||
this.value = '[';
|
||||
this.mirror = ']';
|
||||
}
|
||||
}
|
||||
|
||||
export class CloseSquareToken extends GroupingToken {
|
||||
override tokenType = ']';
|
||||
constructor() {
|
||||
super();
|
||||
this.value = ']';
|
||||
this.mirror = '[';
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenParenToken extends GroupingToken {
|
||||
override tokenType = '(';
|
||||
constructor() {
|
||||
super();
|
||||
this.value = '(';
|
||||
this.mirror = ')';
|
||||
}
|
||||
}
|
||||
|
||||
export class CloseParenToken extends GroupingToken {
|
||||
override tokenType = ')';
|
||||
constructor() {
|
||||
super();
|
||||
this.value = ')';
|
||||
this.mirror = '(';
|
||||
}
|
||||
}
|
||||
|
||||
export class IncludeMatchToken extends CSSParserToken {
|
||||
override tokenType = '~=';
|
||||
}
|
||||
|
||||
export class DashMatchToken extends CSSParserToken {
|
||||
override tokenType = '|=';
|
||||
}
|
||||
|
||||
export class PrefixMatchToken extends CSSParserToken {
|
||||
override tokenType = '^=';
|
||||
}
|
||||
|
||||
export class SuffixMatchToken extends CSSParserToken {
|
||||
override tokenType = '$=';
|
||||
}
|
||||
|
||||
export class SubstringMatchToken extends CSSParserToken {
|
||||
override tokenType = '*=';
|
||||
}
|
||||
|
||||
export class ColumnToken extends CSSParserToken {
|
||||
override tokenType = '||';
|
||||
}
|
||||
|
||||
export class EOFToken extends CSSParserToken {
|
||||
override tokenType = 'EOF';
|
||||
override toSource() { return ''; }
|
||||
}
|
||||
|
||||
export class DelimToken extends CSSParserToken {
|
||||
override tokenType = 'DELIM';
|
||||
override value: string = '';
|
||||
|
||||
constructor(code: number) {
|
||||
super();
|
||||
this.value = stringFromCode(code);
|
||||
}
|
||||
|
||||
override toString() { return 'DELIM(' + this.value + ')'; }
|
||||
|
||||
override toJSON() {
|
||||
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this);
|
||||
json.value = this.value;
|
||||
return json;
|
||||
}
|
||||
|
||||
override toSource() {
|
||||
if (this.value === '\\')
|
||||
return '\\\n';
|
||||
else
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class StringValuedToken extends CSSParserToken {
|
||||
override value: string = '';
|
||||
ASCIIMatch(str: string) {
|
||||
return this.value.toLowerCase() === str.toLowerCase();
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this);
|
||||
json.value = this.value;
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
export class IdentToken extends StringValuedToken {
|
||||
constructor(val: string) {
|
||||
super();
|
||||
this.value = val;
|
||||
}
|
||||
|
||||
override tokenType = 'IDENT';
|
||||
override toString() { return 'IDENT(' + this.value + ')'; }
|
||||
override toSource() {
|
||||
return escapeIdent(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
export class FunctionToken extends StringValuedToken {
|
||||
override tokenType = 'FUNCTION';
|
||||
mirror: string;
|
||||
constructor(val: string) {
|
||||
super();
|
||||
this.value = val;
|
||||
this.mirror = ')';
|
||||
}
|
||||
|
||||
override toString() { return 'FUNCTION(' + this.value + ')'; }
|
||||
|
||||
override toSource() {
|
||||
return escapeIdent(this.value) + '(';
|
||||
}
|
||||
}
|
||||
|
||||
export class AtKeywordToken extends StringValuedToken {
|
||||
override tokenType = 'AT-KEYWORD';
|
||||
constructor(val: string) {
|
||||
super();
|
||||
this.value = val;
|
||||
}
|
||||
override toString() { return 'AT(' + this.value + ')'; }
|
||||
override toSource() {
|
||||
return '@' + escapeIdent(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
export class HashToken extends StringValuedToken {
|
||||
override tokenType = 'HASH';
|
||||
type: string;
|
||||
constructor(val: string) {
|
||||
super();
|
||||
this.value = val;
|
||||
this.type = 'unrestricted';
|
||||
}
|
||||
|
||||
override toString() { return 'HASH(' + this.value + ')'; }
|
||||
|
||||
override toJSON() {
|
||||
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this);
|
||||
json.value = this.value;
|
||||
json.type = this.type;
|
||||
return json;
|
||||
}
|
||||
|
||||
override toSource() {
|
||||
if (this.type === 'id')
|
||||
return '#' + escapeIdent(this.value);
|
||||
else
|
||||
return '#' + escapeHash(this.value);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export class StringToken extends StringValuedToken {
|
||||
override tokenType = 'STRING';
|
||||
constructor(val: string) {
|
||||
super();
|
||||
this.value = val;
|
||||
}
|
||||
|
||||
override toString() {
|
||||
return '"' + escapeString(this.value) + '"';
|
||||
}
|
||||
}
|
||||
|
||||
export class URLToken extends StringValuedToken {
|
||||
override tokenType = 'URL';
|
||||
constructor(val: string) {
|
||||
super();
|
||||
this.value = val;
|
||||
}
|
||||
override toString() { return 'URL(' + this.value + ')'; }
|
||||
override toSource() {
|
||||
return 'url("' + escapeString(this.value) + '")';
|
||||
}
|
||||
}
|
||||
|
||||
export class NumberToken extends CSSParserToken {
|
||||
override tokenType = 'NUMBER';
|
||||
type: string;
|
||||
repr: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.type = 'integer';
|
||||
this.repr = '';
|
||||
}
|
||||
|
||||
override toString() {
|
||||
if (this.type === 'integer')
|
||||
return 'INT(' + this.value + ')';
|
||||
return 'NUMBER(' + this.value + ')';
|
||||
}
|
||||
override toJSON() {
|
||||
const json = super.toJSON();
|
||||
json.value = this.value;
|
||||
json.type = this.type;
|
||||
json.repr = this.repr;
|
||||
return json;
|
||||
}
|
||||
override toSource() { return this.repr; }
|
||||
}
|
||||
|
||||
|
||||
export class PercentageToken extends CSSParserToken {
|
||||
override tokenType = 'PERCENTAGE';
|
||||
repr: string;
|
||||
constructor() {
|
||||
super();
|
||||
this.repr = '';
|
||||
}
|
||||
override toString() { return 'PERCENTAGE(' + this.value + ')'; }
|
||||
override toJSON() {
|
||||
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this);
|
||||
json.value = this.value;
|
||||
json.repr = this.repr;
|
||||
return json;
|
||||
}
|
||||
override toSource() { return this.repr + '%'; }
|
||||
}
|
||||
|
||||
export class DimensionToken extends CSSParserToken {
|
||||
override tokenType = 'DIMENSION';
|
||||
type: string;
|
||||
repr: string;
|
||||
unit: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.type = 'integer';
|
||||
this.repr = '';
|
||||
this.unit = '';
|
||||
}
|
||||
|
||||
override toString() { return 'DIM(' + this.value + ',' + this.unit + ')'; }
|
||||
override toJSON() {
|
||||
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this);
|
||||
json.value = this.value;
|
||||
json.type = this.type;
|
||||
json.repr = this.repr;
|
||||
json.unit = this.unit;
|
||||
return json;
|
||||
}
|
||||
override toSource() {
|
||||
const source = this.repr;
|
||||
let unit = escapeIdent(this.unit);
|
||||
if (unit[0].toLowerCase() === 'e' && (unit[1] === '-' || between(unit.charCodeAt(1), 0x30, 0x39))) {
|
||||
// Unit is ambiguous with scinot
|
||||
// Remove the leading "e", replace with escape.
|
||||
unit = '\\65 ' + unit.slice(1, unit.length);
|
||||
}
|
||||
return source + unit;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeIdent(string: string) {
|
||||
string = '' + string;
|
||||
let result = '';
|
||||
const firstcode = string.charCodeAt(0);
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
const code = string.charCodeAt(i);
|
||||
if (code === 0x0)
|
||||
throw new InvalidCharacterError('Invalid character: the input contains U+0000.');
|
||||
|
||||
if (
|
||||
between(code, 0x1, 0x1f) || code === 0x7f ||
|
||||
(i === 0 && between(code, 0x30, 0x39)) ||
|
||||
(i === 1 && between(code, 0x30, 0x39) && firstcode === 0x2d)
|
||||
)
|
||||
result += '\\' + code.toString(16) + ' ';
|
||||
else if (
|
||||
code >= 0x80 ||
|
||||
code === 0x2d ||
|
||||
code === 0x5f ||
|
||||
between(code, 0x30, 0x39) ||
|
||||
between(code, 0x41, 0x5a) ||
|
||||
between(code, 0x61, 0x7a)
|
||||
)
|
||||
result += string[i];
|
||||
else
|
||||
result += '\\' + string[i];
|
||||
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeHash(string: string) {
|
||||
// Escapes the contents of "unrestricted"-type hash tokens.
|
||||
// Won't preserve the ID-ness of "id"-type hash tokens;
|
||||
// use escapeIdent() for that.
|
||||
string = '' + string;
|
||||
let result = '';
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
const code = string.charCodeAt(i);
|
||||
if (code === 0x0)
|
||||
throw new InvalidCharacterError('Invalid character: the input contains U+0000.');
|
||||
|
||||
if (
|
||||
code >= 0x80 ||
|
||||
code === 0x2d ||
|
||||
code === 0x5f ||
|
||||
between(code, 0x30, 0x39) ||
|
||||
between(code, 0x41, 0x5a) ||
|
||||
between(code, 0x61, 0x7a)
|
||||
)
|
||||
result += string[i];
|
||||
else
|
||||
result += '\\' + code.toString(16) + ' ';
|
||||
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeString(string: string) {
|
||||
string = '' + string;
|
||||
let result = '';
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
const code = string.charCodeAt(i);
|
||||
|
||||
if (code === 0x0)
|
||||
throw new InvalidCharacterError('Invalid character: the input contains U+0000.');
|
||||
|
||||
if (between(code, 0x1, 0x1f) || code === 0x7f)
|
||||
result += '\\' + code.toString(16) + ' ';
|
||||
else if (code === 0x22 || code === 0x5c)
|
||||
result += '\\' + string[i];
|
||||
else
|
||||
result += string[i];
|
||||
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isRegExp, isString } from './rtti';
|
||||
import type { ExpectedTextValue } from '@protocol/channels';
|
||||
|
||||
export function serializeExpectedTextValues(items: (string | RegExp)[], options: { matchSubstring?: boolean, normalizeWhiteSpace?: boolean, ignoreCase?: boolean } = {}): ExpectedTextValue[] {
|
||||
return items.map(i => ({
|
||||
string: isString(i) ? i : undefined,
|
||||
regexSource: isRegExp(i) ? i.source : undefined,
|
||||
regexFlags: isRegExp(i) ? i.flags : undefined,
|
||||
matchSubstring: options.matchSubstring,
|
||||
ignoreCase: options.ignoreCase,
|
||||
normalizeWhiteSpace: options.normalizeWhiteSpace,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function msToString(ms: number): string {
|
||||
if (ms < 0 || !isFinite(ms))
|
||||
return '-';
|
||||
|
||||
if (ms === 0)
|
||||
return '0ms';
|
||||
|
||||
if (ms < 1000)
|
||||
return ms.toFixed(0) + 'ms';
|
||||
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60)
|
||||
return seconds.toFixed(1) + 's';
|
||||
|
||||
const minutes = seconds / 60;
|
||||
if (minutes < 60)
|
||||
return minutes.toFixed(1) + 'm';
|
||||
|
||||
const hours = minutes / 60;
|
||||
if (hours < 24)
|
||||
return hours.toFixed(1) + 'h';
|
||||
|
||||
const days = hours / 24;
|
||||
return days.toFixed(1) + 'd';
|
||||
}
|
||||
|
||||
export function bytesToString(bytes: number): string {
|
||||
if (bytes < 0 || !isFinite(bytes))
|
||||
return '-';
|
||||
|
||||
if (bytes === 0)
|
||||
return '0';
|
||||
|
||||
if (bytes < 1000)
|
||||
return bytes.toFixed(0);
|
||||
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1000)
|
||||
return kb.toFixed(1) + 'K';
|
||||
|
||||
const mb = kb / 1024;
|
||||
if (mb < 1000)
|
||||
return mb.toFixed(1) + 'M';
|
||||
|
||||
const gb = mb / 1024;
|
||||
return gb.toFixed(1) + 'G';
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
type HeadersArray = { name: string, value: string }[];
|
||||
type HeadersObject = { [key: string]: string };
|
||||
|
||||
export function headersObjectToArray(headers: HeadersObject, separator?: string, setCookieSeparator?: string): HeadersArray {
|
||||
if (!setCookieSeparator)
|
||||
setCookieSeparator = separator;
|
||||
const result: HeadersArray = [];
|
||||
for (const name in headers) {
|
||||
const values = headers[name];
|
||||
if (values === undefined)
|
||||
continue;
|
||||
if (separator) {
|
||||
const sep = name.toLowerCase() === 'set-cookie' ? setCookieSeparator : separator;
|
||||
for (const value of values.split(sep!))
|
||||
result.push({ name, value: value.trim() });
|
||||
} else {
|
||||
result.push({ name, value: values });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function headersArrayToObject(headers: HeadersArray, lowerCase: boolean): HeadersObject {
|
||||
const result: HeadersObject = {};
|
||||
for (const { name, value } of headers)
|
||||
result[lowerCase ? name.toLowerCase() : name] = value;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type ImageData = { width: number, height: number, data: Buffer };
|
||||
|
||||
export function padImageToSize(image: ImageData, size: { width: number, height: number }): ImageData {
|
||||
if (image.width === size.width && image.height === size.height)
|
||||
return image;
|
||||
const buffer = new Uint8Array(size.width * size.height * 4);
|
||||
for (let y = 0; y < size.height; y++) {
|
||||
for (let x = 0; x < size.width; x++) {
|
||||
const to = (y * size.width + x) * 4;
|
||||
if (y < image.height && x < image.width) {
|
||||
const from = (y * image.width + x) * 4;
|
||||
buffer[to] = image.data[from];
|
||||
buffer[to + 1] = image.data[from + 1];
|
||||
buffer[to + 2] = image.data[from + 2];
|
||||
buffer[to + 3] = image.data[from + 3];
|
||||
} else {
|
||||
buffer[to] = 0;
|
||||
buffer[to + 1] = 0;
|
||||
buffer[to + 2] = 0;
|
||||
buffer[to + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data: Buffer.from(buffer), width: size.width, height: size.height };
|
||||
}
|
||||
|
||||
export function scaleImageToSize(image: ImageData, size: { width: number; height: number }): ImageData {
|
||||
const { data: src, width: w1, height: h1 } = image;
|
||||
const w2 = Math.max(1, Math.floor(size.width));
|
||||
const h2 = Math.max(1, Math.floor(size.height));
|
||||
|
||||
if (w1 === w2 && h1 === h2)
|
||||
return image;
|
||||
|
||||
if (w1 <= 0 || h1 <= 0)
|
||||
throw new Error('Invalid input image');
|
||||
if (size.width <= 0 || size.height <= 0 || !isFinite(size.width) || !isFinite(size.height))
|
||||
throw new Error('Invalid output dimensions');
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './ariaSnapshot';
|
||||
export * from './expectUtils';
|
||||
export * from './assert';
|
||||
export * from './colors';
|
||||
export * from './headers';
|
||||
export * from './imageUtils';
|
||||
export * from './jsonSchema';
|
||||
export * from './locatorGenerators';
|
||||
export * from './manualPromise';
|
||||
export * from './mimeType';
|
||||
export * from './multimap';
|
||||
export * from './protocolFormatter';
|
||||
export * from './protocolMetainfo';
|
||||
export * from './rtti';
|
||||
export * from './semaphore';
|
||||
export * from './stackTrace';
|
||||
export * from './stringUtils';
|
||||
export * from './formatUtils';
|
||||
export * from './time';
|
||||
export * from './timeoutRunner';
|
||||
export * from './trace/snapshotServer';
|
||||
export * from './urlMatch';
|
||||
export * from './cssParser';
|
||||
export * from './locatorParser';
|
||||
export * from './selectorParser';
|
||||
export * from './trace/snapshotStorage';
|
||||
export * from './trace/traceLoader';
|
||||
export * from './trace/traceModel';
|
||||
export * from './trace/traceUtils';
|
||||
export * from './yaml';
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type JsonSchema = {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
items?: JsonSchema;
|
||||
oneOf?: JsonSchema[];
|
||||
pattern?: string;
|
||||
patternError?: string;
|
||||
};
|
||||
|
||||
const regexCache = new Map<string, RegExp>();
|
||||
|
||||
export function validate(value: unknown, schema: JsonSchema, path: string): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (schema.oneOf) {
|
||||
let bestErrors: string[] | undefined;
|
||||
for (const variant of schema.oneOf) {
|
||||
const variantErrors = validate(value, variant, path);
|
||||
if (variantErrors.length === 0)
|
||||
return [];
|
||||
// Prefer the variant with fewest errors (closest match).
|
||||
if (!bestErrors || variantErrors.length < bestErrors.length)
|
||||
bestErrors = variantErrors;
|
||||
}
|
||||
// If the best match has only top-level type mismatches, use a generic message.
|
||||
if (bestErrors!.length === 1 && bestErrors![0].startsWith(`${path}: expected `))
|
||||
return [`${path}: does not match any of the expected types`];
|
||||
return bestErrors!;
|
||||
}
|
||||
|
||||
if (schema.type === 'string') {
|
||||
if (typeof value !== 'string') {
|
||||
errors.push(`${path}: expected string, got ${typeof value}`);
|
||||
return errors;
|
||||
}
|
||||
if (schema.pattern && !cachedRegex(schema.pattern).test(value))
|
||||
errors.push(schema.patternError || `${path}: must match pattern "${schema.pattern}"`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (schema.type === 'array') {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${path}: expected array, got ${typeof value}`);
|
||||
return errors;
|
||||
}
|
||||
if (schema.items) {
|
||||
for (let i = 0; i < value.length; i++)
|
||||
errors.push(...validate(value[i], schema.items, `${path}[${i}]`));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (schema.type === 'object') {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
errors.push(`${path}: expected object, got ${Array.isArray(value) ? 'array' : typeof value}`);
|
||||
return errors;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of schema.required || []) {
|
||||
if (obj[key] === undefined)
|
||||
errors.push(`${path}.${key}: required`);
|
||||
}
|
||||
for (const [key, propSchema] of Object.entries(schema.properties || {})) {
|
||||
if (obj[key] !== undefined)
|
||||
errors.push(...validate(obj[key], propSchema, `${path}.${key}`));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function cachedRegex(pattern: string): RegExp {
|
||||
let regex = regexCache.get(pattern);
|
||||
if (!regex) {
|
||||
regex = new RegExp(pattern);
|
||||
regexCache.set(pattern, regex);
|
||||
}
|
||||
return regex;
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { parseAttributeSelector, parseSelector, stringifySelector } from './selectorParser';
|
||||
import { escapeWithQuotes, normalizeEscapedRegexQuotes, toSnakeCase, toTitleCase } from './stringUtils';
|
||||
|
||||
import type { NestedSelectorBody } from './selectorParser';
|
||||
import type { ParsedSelector } from './selectorParser';
|
||||
|
||||
export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
|
||||
export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'visible' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain';
|
||||
export type LocatorBase = 'page' | 'locator' | 'frame-locator';
|
||||
export type Quote = '\'' | '"' | '`';
|
||||
|
||||
type LocatorOptions = {
|
||||
attrs?: { name: string, value: string | boolean | number }[],
|
||||
exact?: boolean,
|
||||
name?: string | RegExp,
|
||||
hasText?: string | RegExp,
|
||||
hasNotText?: string | RegExp,
|
||||
};
|
||||
export interface LocatorFactory {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: LocatorOptions): string;
|
||||
chainLocators(locators: string[]): string;
|
||||
}
|
||||
|
||||
export function asLocatorDescription(lang: Language, selector: string): string {
|
||||
try {
|
||||
const parsed = parseSelector(selector);
|
||||
const customDescription = parseCustomDescription(parsed);
|
||||
if (customDescription)
|
||||
return customDescription;
|
||||
return innerAsLocators(new generators[lang](), parsed, false, 1)[0];
|
||||
} catch (e) {
|
||||
// Tolerate invalid input.
|
||||
return selector;
|
||||
}
|
||||
}
|
||||
|
||||
export function locatorCustomDescription(selector: string): string | undefined {
|
||||
try {
|
||||
const parsed = parseSelector(selector);
|
||||
return parseCustomDescription(parsed);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCustomDescription(parsed: ParsedSelector): string | undefined {
|
||||
const lastPart = parsed.parts[parsed.parts.length - 1];
|
||||
if (lastPart?.name === 'internal:describe') {
|
||||
const description = JSON.parse(lastPart.body as string);
|
||||
if (typeof description === 'string')
|
||||
return description;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string {
|
||||
return asLocators(lang, selector, isFrameLocator, 1)[0];
|
||||
}
|
||||
|
||||
export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] {
|
||||
try {
|
||||
return innerAsLocators(new generators[lang](preferredQuote), parseSelector(selector), isFrameLocator, maxOutputSize);
|
||||
} catch (e) {
|
||||
// Tolerate invalid input.
|
||||
return [selector];
|
||||
}
|
||||
}
|
||||
|
||||
function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFrameLocator: boolean = false, maxOutputSize = 20): string[] {
|
||||
const parts = [...parsed.parts];
|
||||
const tokens: string[][] = [];
|
||||
let nextBase: LocatorBase = isFrameLocator ? 'frame-locator' : 'page';
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
const part = parts[index];
|
||||
const base = nextBase;
|
||||
nextBase = 'locator';
|
||||
|
||||
if (part.name === 'internal:describe')
|
||||
continue;
|
||||
if (part.name === 'nth') {
|
||||
if (part.body === '0')
|
||||
tokens.push([factory.generateLocator(base, 'first', ''), factory.generateLocator(base, 'nth', '0')]);
|
||||
else if (part.body === '-1')
|
||||
tokens.push([factory.generateLocator(base, 'last', ''), factory.generateLocator(base, 'nth', '-1')]);
|
||||
else
|
||||
tokens.push([factory.generateLocator(base, 'nth', part.body as string)]);
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'visible') {
|
||||
tokens.push([factory.generateLocator(base, 'visible', part.body as string), factory.generateLocator(base, 'default', `visible=${part.body}`)]);
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:text') {
|
||||
const { exact, text } = detectExact(part.body as string);
|
||||
tokens.push([factory.generateLocator(base, 'text', text, { exact })]);
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:has-text') {
|
||||
const { exact, text } = detectExact(part.body as string);
|
||||
// There is no locator equivalent for strict has-text, leave it as is.
|
||||
if (!exact) {
|
||||
tokens.push([factory.generateLocator(base, 'has-text', text, { exact })]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (part.name === 'internal:has-not-text') {
|
||||
const { exact, text } = detectExact(part.body as string);
|
||||
// There is no locator equivalent for strict has-not-text, leave it as is.
|
||||
if (!exact) {
|
||||
tokens.push([factory.generateLocator(base, 'has-not-text', text, { exact })]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (part.name === 'internal:has') {
|
||||
const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
|
||||
tokens.push(inners.map(inner => factory.generateLocator(base, 'has', inner)));
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:has-not') {
|
||||
const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
|
||||
tokens.push(inners.map(inner => factory.generateLocator(base, 'hasNot', inner)));
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:and') {
|
||||
const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
|
||||
tokens.push(inners.map(inner => factory.generateLocator(base, 'and', inner)));
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:or') {
|
||||
const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
|
||||
tokens.push(inners.map(inner => factory.generateLocator(base, 'or', inner)));
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:chain') {
|
||||
const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize);
|
||||
tokens.push(inners.map(inner => factory.generateLocator(base, 'chain', inner)));
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:label') {
|
||||
const { exact, text } = detectExact(part.body as string);
|
||||
tokens.push([factory.generateLocator(base, 'label', text, { exact })]);
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:role') {
|
||||
const attrSelector = parseAttributeSelector(part.body as string, true);
|
||||
const options: LocatorOptions = { attrs: [] };
|
||||
for (const attr of attrSelector.attributes) {
|
||||
if (attr.name === 'name') {
|
||||
options.exact = attr.caseSensitive;
|
||||
options.name = attr.value;
|
||||
} else {
|
||||
if (attr.name === 'level' && typeof attr.value === 'string')
|
||||
attr.value = +attr.value;
|
||||
options.attrs!.push({ name: attr.name === 'include-hidden' ? 'includeHidden' : attr.name, value: attr.value });
|
||||
}
|
||||
}
|
||||
tokens.push([factory.generateLocator(base, 'role', attrSelector.name, options)]);
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:testid') {
|
||||
const attrSelector = parseAttributeSelector(part.body as string, true);
|
||||
const { value } = attrSelector.attributes[0];
|
||||
tokens.push([factory.generateLocator(base, 'test-id', value)]);
|
||||
continue;
|
||||
}
|
||||
if (part.name === 'internal:attr') {
|
||||
const attrSelector = parseAttributeSelector(part.body as string, true);
|
||||
const { name, value, caseSensitive } = attrSelector.attributes[0];
|
||||
const text = value as string | RegExp;
|
||||
const exact = !!caseSensitive;
|
||||
if (name === 'placeholder') {
|
||||
tokens.push([factory.generateLocator(base, 'placeholder', text, { exact })]);
|
||||
continue;
|
||||
}
|
||||
if (name === 'alt') {
|
||||
tokens.push([factory.generateLocator(base, 'alt', text, { exact })]);
|
||||
continue;
|
||||
}
|
||||
if (name === 'title') {
|
||||
tokens.push([factory.generateLocator(base, 'title', text, { exact })]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (part.name === 'internal:control' && (part.body as string) === 'enter-frame') {
|
||||
// transform last tokens from `${selector}` into `${selector}.contentFrame()` and `frameLocator(${selector})`
|
||||
const lastTokens = tokens[tokens.length - 1];
|
||||
const lastPart = parts[index - 1];
|
||||
|
||||
const transformed = lastTokens.map(token => factory.chainLocators([token, factory.generateLocator(base, 'frame', '')]));
|
||||
if (['xpath', 'css'].includes(lastPart.name)) {
|
||||
transformed.push(
|
||||
factory.generateLocator(base, 'frame-locator', stringifySelector({ parts: [lastPart] })),
|
||||
factory.generateLocator(base, 'frame-locator', stringifySelector({ parts: [lastPart] }, true))
|
||||
);
|
||||
}
|
||||
|
||||
lastTokens.splice(0, lastTokens.length, ...transformed);
|
||||
nextBase = 'frame-locator';
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextPart = parts[index + 1];
|
||||
|
||||
const selectorPart = stringifySelector({ parts: [part] });
|
||||
const locatorPart = factory.generateLocator(base, 'default', selectorPart);
|
||||
|
||||
if (nextPart && ['internal:has-text', 'internal:has-not-text'].includes(nextPart.name)) {
|
||||
const { exact, text } = detectExact(nextPart.body as string);
|
||||
// There is no locator equivalent for strict has-text and has-not-text, leave it as is.
|
||||
if (!exact) {
|
||||
const nextLocatorPart = factory.generateLocator('locator', nextPart.name === 'internal:has-text' ? 'has-text' : 'has-not-text', text, { exact });
|
||||
const options: LocatorOptions = {};
|
||||
if (nextPart.name === 'internal:has-text')
|
||||
options.hasText = text;
|
||||
else
|
||||
options.hasNotText = text;
|
||||
const combinedPart = factory.generateLocator(base, 'default', selectorPart, options);
|
||||
// Two options:
|
||||
// - locator('div').filter({ hasText: 'foo' })
|
||||
// - locator('div', { hasText: 'foo' })
|
||||
tokens.push([factory.chainLocators([locatorPart, nextLocatorPart]), combinedPart]);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Selectors can be prefixed with engine name, e.g. xpath=//foo
|
||||
let locatorPartWithEngine: string | undefined;
|
||||
if (['xpath', 'css'].includes(part.name)) {
|
||||
const selectorPart = stringifySelector({ parts: [part] }, /* forceEngineName */ true);
|
||||
locatorPartWithEngine = factory.generateLocator(base, 'default', selectorPart);
|
||||
}
|
||||
|
||||
tokens.push([locatorPart, locatorPartWithEngine].filter(Boolean) as string[]);
|
||||
}
|
||||
|
||||
return combineTokens(factory, tokens, maxOutputSize);
|
||||
}
|
||||
|
||||
function combineTokens(factory: LocatorFactory, tokens: string[][], maxOutputSize: number): string[] {
|
||||
const currentTokens = tokens.map(() => '');
|
||||
const result: string[] = [];
|
||||
|
||||
const visit = (index: number) => {
|
||||
if (index === tokens.length) {
|
||||
result.push(factory.chainLocators(currentTokens));
|
||||
return result.length < maxOutputSize;
|
||||
}
|
||||
for (const taken of tokens[index]) {
|
||||
currentTokens[index] = taken;
|
||||
if (!visit(index + 1))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
visit(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
function detectExact(text: string): { exact?: boolean, text: string | RegExp } {
|
||||
let exact = false;
|
||||
const match = text.match(/^\/(.*)\/([igm]*)$/);
|
||||
if (match)
|
||||
return { text: new RegExp(match[1], match[2]) };
|
||||
if (text.endsWith('"')) {
|
||||
text = JSON.parse(text);
|
||||
exact = true;
|
||||
} else if (text.endsWith('"s')) {
|
||||
text = JSON.parse(text.substring(0, text.length - 1));
|
||||
exact = true;
|
||||
} else if (text.endsWith('"i')) {
|
||||
text = JSON.parse(text.substring(0, text.length - 1));
|
||||
exact = false;
|
||||
}
|
||||
return { exact, text };
|
||||
}
|
||||
|
||||
export class JavaScriptLocatorFactory implements LocatorFactory {
|
||||
constructor(private preferredQuote?: Quote) {}
|
||||
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
|
||||
switch (kind) {
|
||||
case 'default':
|
||||
if (options.hasText !== undefined)
|
||||
return `locator(${this.quote(body as string)}, { hasText: ${this.toHasText(options.hasText)} })`;
|
||||
if (options.hasNotText !== undefined)
|
||||
return `locator(${this.quote(body as string)}, { hasNotText: ${this.toHasText(options.hasNotText)} })`;
|
||||
return `locator(${this.quote(body as string)})`;
|
||||
case 'frame-locator':
|
||||
return `frameLocator(${this.quote(body as string)})`;
|
||||
case 'frame':
|
||||
return `contentFrame()`;
|
||||
case 'nth':
|
||||
return `nth(${body})`;
|
||||
case 'first':
|
||||
return `first()`;
|
||||
case 'last':
|
||||
return `last()`;
|
||||
case 'visible':
|
||||
return `filter({ visible: ${body === 'true' ? 'true' : 'false'} })`;
|
||||
case 'role':
|
||||
const attrs: string[] = [];
|
||||
if (isRegExp(options.name)) {
|
||||
attrs.push(`name: ${this.regexToSourceString(options.name)}`);
|
||||
} else if (typeof options.name === 'string') {
|
||||
attrs.push(`name: ${this.quote(options.name)}`);
|
||||
if (options.exact)
|
||||
attrs.push(`exact: true`);
|
||||
}
|
||||
for (const { name, value } of options.attrs!)
|
||||
attrs.push(`${name}: ${typeof value === 'string' ? this.quote(value) : value}`);
|
||||
const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '';
|
||||
return `getByRole(${this.quote(body as string)}${attrString})`;
|
||||
case 'has-text':
|
||||
return `filter({ hasText: ${this.toHasText(body)} })`;
|
||||
case 'has-not-text':
|
||||
return `filter({ hasNotText: ${this.toHasText(body)} })`;
|
||||
case 'has':
|
||||
return `filter({ has: ${body} })`;
|
||||
case 'hasNot':
|
||||
return `filter({ hasNot: ${body} })`;
|
||||
case 'and':
|
||||
return `and(${body})`;
|
||||
case 'or':
|
||||
return `or(${body})`;
|
||||
case 'chain':
|
||||
return `locator(${body})`;
|
||||
case 'test-id':
|
||||
return `getByTestId(${this.toTestIdValue(body)})`;
|
||||
case 'text':
|
||||
return this.toCallWithExact('getByText', body, !!options.exact);
|
||||
case 'alt':
|
||||
return this.toCallWithExact('getByAltText', body, !!options.exact);
|
||||
case 'placeholder':
|
||||
return this.toCallWithExact('getByPlaceholder', body, !!options.exact);
|
||||
case 'label':
|
||||
return this.toCallWithExact('getByLabel', body, !!options.exact);
|
||||
case 'title':
|
||||
return this.toCallWithExact('getByTitle', body, !!options.exact);
|
||||
default:
|
||||
throw new Error('Unknown selector kind ' + kind);
|
||||
}
|
||||
}
|
||||
|
||||
chainLocators(locators: string[]): string {
|
||||
return locators.join('.');
|
||||
}
|
||||
|
||||
private regexToSourceString(re: RegExp) {
|
||||
return normalizeEscapedRegexQuotes(String(re));
|
||||
}
|
||||
|
||||
private toCallWithExact(method: string, body: string | RegExp, exact?: boolean) {
|
||||
if (isRegExp(body))
|
||||
return `${method}(${this.regexToSourceString(body)})`;
|
||||
return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`;
|
||||
}
|
||||
|
||||
private toHasText(body: string | RegExp) {
|
||||
if (isRegExp(body))
|
||||
return this.regexToSourceString(body);
|
||||
return this.quote(body);
|
||||
}
|
||||
|
||||
private toTestIdValue(value: string | RegExp): string {
|
||||
if (isRegExp(value))
|
||||
return this.regexToSourceString(value);
|
||||
return this.quote(value);
|
||||
}
|
||||
|
||||
private quote(text: string) {
|
||||
return escapeWithQuotes(text, this.preferredQuote ?? '\'');
|
||||
}
|
||||
}
|
||||
|
||||
export class PythonLocatorFactory implements LocatorFactory {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
|
||||
switch (kind) {
|
||||
case 'default':
|
||||
if (options.hasText !== undefined)
|
||||
return `locator(${this.quote(body as string)}, has_text=${this.toHasText(options.hasText)})`;
|
||||
if (options.hasNotText !== undefined)
|
||||
return `locator(${this.quote(body as string)}, has_not_text=${this.toHasText(options.hasNotText)})`;
|
||||
return `locator(${this.quote(body as string)})`;
|
||||
case 'frame-locator':
|
||||
return `frame_locator(${this.quote(body as string)})`;
|
||||
case 'frame':
|
||||
return `content_frame`;
|
||||
case 'nth':
|
||||
return `nth(${body})`;
|
||||
case 'first':
|
||||
return `first`;
|
||||
case 'last':
|
||||
return `last`;
|
||||
case 'visible':
|
||||
return `filter(visible=${body === 'true' ? 'True' : 'False'})`;
|
||||
case 'role':
|
||||
const attrs: string[] = [];
|
||||
if (isRegExp(options.name)) {
|
||||
attrs.push(`name=${this.regexToString(options.name)}`);
|
||||
} else if (typeof options.name === 'string') {
|
||||
attrs.push(`name=${this.quote(options.name)}`);
|
||||
if (options.exact)
|
||||
attrs.push(`exact=True`);
|
||||
}
|
||||
for (const { name, value } of options.attrs!) {
|
||||
let valueString = typeof value === 'string' ? this.quote(value) : value;
|
||||
if (typeof value === 'boolean')
|
||||
valueString = value ? 'True' : 'False';
|
||||
attrs.push(`${toSnakeCase(name)}=${valueString}`);
|
||||
}
|
||||
const attrString = attrs.length ? `, ${attrs.join(', ')}` : '';
|
||||
return `get_by_role(${this.quote(body as string)}${attrString})`;
|
||||
case 'has-text':
|
||||
return `filter(has_text=${this.toHasText(body)})`;
|
||||
case 'has-not-text':
|
||||
return `filter(has_not_text=${this.toHasText(body)})`;
|
||||
case 'has':
|
||||
return `filter(has=${body})`;
|
||||
case 'hasNot':
|
||||
return `filter(has_not=${body})`;
|
||||
case 'and':
|
||||
return `and_(${body})`;
|
||||
case 'or':
|
||||
return `or_(${body})`;
|
||||
case 'chain':
|
||||
return `locator(${body})`;
|
||||
case 'test-id':
|
||||
return `get_by_test_id(${this.toTestIdValue(body)})`;
|
||||
case 'text':
|
||||
return this.toCallWithExact('get_by_text', body, !!options.exact);
|
||||
case 'alt':
|
||||
return this.toCallWithExact('get_by_alt_text', body, !!options.exact);
|
||||
case 'placeholder':
|
||||
return this.toCallWithExact('get_by_placeholder', body, !!options.exact);
|
||||
case 'label':
|
||||
return this.toCallWithExact('get_by_label', body, !!options.exact);
|
||||
case 'title':
|
||||
return this.toCallWithExact('get_by_title', body, !!options.exact);
|
||||
default:
|
||||
throw new Error('Unknown selector kind ' + kind);
|
||||
}
|
||||
}
|
||||
|
||||
chainLocators(locators: string[]): string {
|
||||
return locators.join('.');
|
||||
}
|
||||
|
||||
private regexToString(body: RegExp) {
|
||||
const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : '';
|
||||
return `re.compile(r"${normalizeEscapedRegexQuotes(body.source).replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix})`;
|
||||
}
|
||||
|
||||
private toCallWithExact(method: string, body: string | RegExp, exact: boolean) {
|
||||
if (isRegExp(body))
|
||||
return `${method}(${this.regexToString(body)})`;
|
||||
if (exact)
|
||||
return `${method}(${this.quote(body)}, exact=True)`;
|
||||
return `${method}(${this.quote(body)})`;
|
||||
}
|
||||
|
||||
private toHasText(body: string | RegExp) {
|
||||
if (isRegExp(body))
|
||||
return this.regexToString(body);
|
||||
return `${this.quote(body)}`;
|
||||
}
|
||||
|
||||
private toTestIdValue(value: string | RegExp) {
|
||||
if (isRegExp(value))
|
||||
return this.regexToString(value);
|
||||
return this.quote(value);
|
||||
}
|
||||
|
||||
private quote(text: string) {
|
||||
return escapeWithQuotes(text, '\"');
|
||||
}
|
||||
}
|
||||
|
||||
export class JavaLocatorFactory implements LocatorFactory {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
|
||||
let clazz: string;
|
||||
switch (base) {
|
||||
case 'page': clazz = 'Page'; break;
|
||||
case 'frame-locator': clazz = 'FrameLocator'; break;
|
||||
case 'locator': clazz = 'Locator'; break;
|
||||
}
|
||||
switch (kind) {
|
||||
case 'default':
|
||||
if (options.hasText !== undefined)
|
||||
return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasText(${this.toHasText(options.hasText)}))`;
|
||||
if (options.hasNotText !== undefined)
|
||||
return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasNotText(${this.toHasText(options.hasNotText)}))`;
|
||||
return `locator(${this.quote(body as string)})`;
|
||||
case 'frame-locator':
|
||||
return `frameLocator(${this.quote(body as string)})`;
|
||||
case 'frame':
|
||||
return `contentFrame()`;
|
||||
case 'nth':
|
||||
return `nth(${body})`;
|
||||
case 'first':
|
||||
return `first()`;
|
||||
case 'last':
|
||||
return `last()`;
|
||||
case 'visible':
|
||||
return `filter(new ${clazz}.FilterOptions().setVisible(${body === 'true' ? 'true' : 'false'}))`;
|
||||
case 'role':
|
||||
const attrs: string[] = [];
|
||||
if (isRegExp(options.name)) {
|
||||
attrs.push(`.setName(${this.regexToString(options.name)})`);
|
||||
} else if (typeof options.name === 'string') {
|
||||
attrs.push(`.setName(${this.quote(options.name)})`);
|
||||
if (options.exact)
|
||||
attrs.push(`.setExact(true)`);
|
||||
}
|
||||
for (const { name, value } of options.attrs!)
|
||||
attrs.push(`.set${toTitleCase(name)}(${typeof value === 'string' ? this.quote(value) : value})`);
|
||||
const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : '';
|
||||
return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`;
|
||||
case 'has-text':
|
||||
return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`;
|
||||
case 'has-not-text':
|
||||
return `filter(new ${clazz}.FilterOptions().setHasNotText(${this.toHasText(body)}))`;
|
||||
case 'has':
|
||||
return `filter(new ${clazz}.FilterOptions().setHas(${body}))`;
|
||||
case 'hasNot':
|
||||
return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`;
|
||||
case 'and':
|
||||
return `and(${body})`;
|
||||
case 'or':
|
||||
return `or(${body})`;
|
||||
case 'chain':
|
||||
return `locator(${body})`;
|
||||
case 'test-id':
|
||||
return `getByTestId(${this.toTestIdValue(body)})`;
|
||||
case 'text':
|
||||
return this.toCallWithExact(clazz, 'getByText', body, !!options.exact);
|
||||
case 'alt':
|
||||
return this.toCallWithExact(clazz, 'getByAltText', body, !!options.exact);
|
||||
case 'placeholder':
|
||||
return this.toCallWithExact(clazz, 'getByPlaceholder', body, !!options.exact);
|
||||
case 'label':
|
||||
return this.toCallWithExact(clazz, 'getByLabel', body, !!options.exact);
|
||||
case 'title':
|
||||
return this.toCallWithExact(clazz, 'getByTitle', body, !!options.exact);
|
||||
default:
|
||||
throw new Error('Unknown selector kind ' + kind);
|
||||
}
|
||||
}
|
||||
|
||||
chainLocators(locators: string[]): string {
|
||||
return locators.join('.');
|
||||
}
|
||||
|
||||
private regexToString(body: RegExp) {
|
||||
const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : '';
|
||||
return `Pattern.compile(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`;
|
||||
}
|
||||
|
||||
private toCallWithExact(clazz: string, method: string, body: string | RegExp, exact: boolean) {
|
||||
if (isRegExp(body))
|
||||
return `${method}(${this.regexToString(body)})`;
|
||||
if (exact)
|
||||
return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(true))`;
|
||||
return `${method}(${this.quote(body)})`;
|
||||
}
|
||||
|
||||
private toHasText(body: string | RegExp) {
|
||||
if (isRegExp(body))
|
||||
return this.regexToString(body);
|
||||
return this.quote(body);
|
||||
}
|
||||
|
||||
private toTestIdValue(value: string | RegExp) {
|
||||
if (isRegExp(value))
|
||||
return this.regexToString(value);
|
||||
return this.quote(value);
|
||||
}
|
||||
|
||||
private quote(text: string) {
|
||||
return escapeWithQuotes(text, '\"');
|
||||
}
|
||||
}
|
||||
|
||||
export class CSharpLocatorFactory implements LocatorFactory {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
|
||||
switch (kind) {
|
||||
case 'default':
|
||||
if (options.hasText !== undefined)
|
||||
return `Locator(${this.quote(body as string)}, new() { ${this.toHasText(options.hasText)} })`;
|
||||
if (options.hasNotText !== undefined)
|
||||
return `Locator(${this.quote(body as string)}, new() { ${this.toHasNotText(options.hasNotText)} })`;
|
||||
return `Locator(${this.quote(body as string)})`;
|
||||
case 'frame-locator':
|
||||
return `FrameLocator(${this.quote(body as string)})`;
|
||||
case 'frame':
|
||||
return `ContentFrame`;
|
||||
case 'nth':
|
||||
return `Nth(${body})`;
|
||||
case 'first':
|
||||
return `First`;
|
||||
case 'last':
|
||||
return `Last`;
|
||||
case 'visible':
|
||||
return `Filter(new() { Visible = ${body === 'true' ? 'true' : 'false'} })`;
|
||||
case 'role':
|
||||
const attrs: string[] = [];
|
||||
if (isRegExp(options.name)) {
|
||||
attrs.push(`NameRegex = ${this.regexToString(options.name)}`);
|
||||
} else if (typeof options.name === 'string') {
|
||||
attrs.push(`Name = ${this.quote(options.name)}`);
|
||||
if (options.exact)
|
||||
attrs.push(`Exact = true`);
|
||||
}
|
||||
for (const { name, value } of options.attrs!)
|
||||
attrs.push(`${toTitleCase(name)} = ${typeof value === 'string' ? this.quote(value) : value}`);
|
||||
const attrString = attrs.length ? `, new() { ${attrs.join(', ')} }` : '';
|
||||
return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`;
|
||||
case 'has-text':
|
||||
return `Filter(new() { ${this.toHasText(body)} })`;
|
||||
case 'has-not-text':
|
||||
return `Filter(new() { ${this.toHasNotText(body)} })`;
|
||||
case 'has':
|
||||
return `Filter(new() { Has = ${body} })`;
|
||||
case 'hasNot':
|
||||
return `Filter(new() { HasNot = ${body} })`;
|
||||
case 'and':
|
||||
return `And(${body})`;
|
||||
case 'or':
|
||||
return `Or(${body})`;
|
||||
case 'chain':
|
||||
return `Locator(${body})`;
|
||||
case 'test-id':
|
||||
return `GetByTestId(${this.toTestIdValue(body)})`;
|
||||
case 'text':
|
||||
return this.toCallWithExact('GetByText', body, !!options.exact);
|
||||
case 'alt':
|
||||
return this.toCallWithExact('GetByAltText', body, !!options.exact);
|
||||
case 'placeholder':
|
||||
return this.toCallWithExact('GetByPlaceholder', body, !!options.exact);
|
||||
case 'label':
|
||||
return this.toCallWithExact('GetByLabel', body, !!options.exact);
|
||||
case 'title':
|
||||
return this.toCallWithExact('GetByTitle', body, !!options.exact);
|
||||
default:
|
||||
throw new Error('Unknown selector kind ' + kind);
|
||||
}
|
||||
}
|
||||
|
||||
chainLocators(locators: string[]): string {
|
||||
return locators.join('.');
|
||||
}
|
||||
|
||||
private regexToString(body: RegExp): string {
|
||||
const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : '';
|
||||
return `new Regex(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`;
|
||||
}
|
||||
|
||||
private toCallWithExact(method: string, body: string | RegExp, exact: boolean) {
|
||||
if (isRegExp(body))
|
||||
return `${method}(${this.regexToString(body)})`;
|
||||
if (exact)
|
||||
return `${method}(${this.quote(body)}, new() { Exact = true })`;
|
||||
return `${method}(${this.quote(body)})`;
|
||||
}
|
||||
|
||||
private toHasText(body: string | RegExp) {
|
||||
if (isRegExp(body))
|
||||
return `HasTextRegex = ${this.regexToString(body)}`;
|
||||
return `HasText = ${this.quote(body)}`;
|
||||
}
|
||||
|
||||
private toTestIdValue(value: string | RegExp) {
|
||||
if (isRegExp(value))
|
||||
return this.regexToString(value);
|
||||
return this.quote(value);
|
||||
}
|
||||
|
||||
private toHasNotText(body: string | RegExp) {
|
||||
if (isRegExp(body))
|
||||
return `HasNotTextRegex = ${this.regexToString(body)}`;
|
||||
return `HasNotText = ${this.quote(body)}`;
|
||||
}
|
||||
|
||||
private quote(text: string) {
|
||||
return escapeWithQuotes(text, '\"');
|
||||
}
|
||||
}
|
||||
|
||||
export class JsonlLocatorFactory implements LocatorFactory {
|
||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
|
||||
return JSON.stringify({
|
||||
kind,
|
||||
body,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
chainLocators(locators: string[]): string {
|
||||
const objects = locators.map(l => JSON.parse(l));
|
||||
for (let i = 0; i < objects.length - 1; ++i)
|
||||
objects[i].next = objects[i + 1];
|
||||
return JSON.stringify(objects[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const generators: Record<Language, new (preferredQuote?: Quote) => LocatorFactory> = {
|
||||
javascript: JavaScriptLocatorFactory,
|
||||
python: PythonLocatorFactory,
|
||||
java: JavaLocatorFactory,
|
||||
csharp: CSharpLocatorFactory,
|
||||
jsonl: JsonlLocatorFactory,
|
||||
};
|
||||
|
||||
function isRegExp(obj: any): obj is RegExp {
|
||||
return obj instanceof RegExp;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { asLocators } from './locatorGenerators';
|
||||
import { parseSelector } from './selectorParser';
|
||||
import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils';
|
||||
|
||||
import type { Language, Quote } from './locatorGenerators';
|
||||
|
||||
type TemplateParams = { quote: string, text: string }[];
|
||||
function parseLocator(locator: string, testIdAttributeName: string): { selector: string, preferredQuote: Quote | undefined } {
|
||||
locator = locator
|
||||
.replace(/AriaRole\s*\.\s*([\w]+)/g, (_, group) => group.toLowerCase())
|
||||
.replace(/(get_by_role|getByRole)\s*\(\s*(?:["'`])([^'"`]+)['"`]/g, (_, group1, group2) => `${group1}(${group2.toLowerCase()}`);
|
||||
const params: TemplateParams = [];
|
||||
let template = '';
|
||||
for (let i = 0; i < locator.length; ++i) {
|
||||
const quote = locator[i];
|
||||
if (quote !== '"' && quote !== '\'' && quote !== '`' && quote !== '/') {
|
||||
template += quote;
|
||||
continue;
|
||||
}
|
||||
const isRegexEscaping = locator[i - 1] === 'r' || locator[i] === '/';
|
||||
++i;
|
||||
let text = '';
|
||||
while (i < locator.length) {
|
||||
if (locator[i] === '\\') {
|
||||
if (isRegexEscaping) {
|
||||
if (locator[i + 1] !== quote)
|
||||
text += locator[i];
|
||||
++i;
|
||||
text += locator[i];
|
||||
} else {
|
||||
++i;
|
||||
if (locator[i] === 'n')
|
||||
text += '\n';
|
||||
else if (locator[i] === 'r')
|
||||
text += '\r';
|
||||
else if (locator[i] === 't')
|
||||
text += '\t';
|
||||
else
|
||||
text += locator[i];
|
||||
}
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (locator[i] !== quote) {
|
||||
text += locator[i++];
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
params.push({ quote, text });
|
||||
template += (quote === '/' ? 'r' : '') + '$' + params.length;
|
||||
}
|
||||
|
||||
// Equalize languages.
|
||||
template = template.toLowerCase()
|
||||
.replace(/get_by_alt_text/g, 'getbyalttext')
|
||||
.replace(/get_by_test_id/g, 'getbytestid')
|
||||
.replace(/get_by_([\w]+)/g, 'getby$1')
|
||||
.replace(/has_not_text/g, 'hasnottext')
|
||||
.replace(/has_text/g, 'hastext')
|
||||
.replace(/has_not/g, 'hasnot')
|
||||
.replace(/frame_locator/g, 'framelocator')
|
||||
.replace(/content_frame/g, 'contentframe')
|
||||
.replace(/[{}\s]/g, '')
|
||||
.replace(/new\(\)/g, '')
|
||||
.replace(/new[\w]+\.[\w]+options\(\)/g, '')
|
||||
.replace(/\.set/g, ',set')
|
||||
.replace(/\.or_\(/g, 'or(') // Python has "or_" instead of "or".
|
||||
.replace(/\.and_\(/g, 'and(') // Python has "and_" instead of "and".
|
||||
.replace(/:/g, '=')
|
||||
.replace(/,re\.ignorecase/g, 'i')
|
||||
.replace(/,pattern.case_insensitive/g, 'i')
|
||||
.replace(/,regexoptions.ignorecase/g, 'i')
|
||||
.replace(/re.compile\(([^)]+)\)/g, '$1') // Python has regex strings as r"foo"
|
||||
.replace(/pattern.compile\(([^)]+)\)/g, 'r$1')
|
||||
.replace(/newregex\(([^)]+)\)/g, 'r$1')
|
||||
.replace(/string=/g, '=')
|
||||
.replace(/regex=/g, '=')
|
||||
.replace(/,,/g, ',')
|
||||
.replace(/,\)/g, ')');
|
||||
|
||||
const preferredQuote = params.map(p => p.quote).filter(quote => '\'"`'.includes(quote))[0] as Quote | undefined;
|
||||
return { selector: transform(template, params, testIdAttributeName), preferredQuote };
|
||||
}
|
||||
|
||||
function countParams(template: string) {
|
||||
return [...template.matchAll(/\$\d+/g)].length;
|
||||
}
|
||||
|
||||
function shiftParams(template: string, sub: number) {
|
||||
return template.replace(/\$(\d+)/g, (_, ordinal) => `$${ordinal - sub}`);
|
||||
}
|
||||
|
||||
function transform(template: string, params: TemplateParams, testIdAttributeName: string): string {
|
||||
// Recursively handle filter(has=, hasnot=, sethas(), sethasnot()).
|
||||
// TODO: handle and(locator), or(locator), locator(locator), locator(has=, hasnot=, sethas(), sethasnot()).
|
||||
while (true) {
|
||||
const hasMatch = template.match(/filter\(,?(has=|hasnot=|sethas\(|sethasnot\()/);
|
||||
if (!hasMatch)
|
||||
break;
|
||||
|
||||
// Extract inner locator based on balanced parens.
|
||||
const start = hasMatch.index! + hasMatch[0].length;
|
||||
let balance = 0;
|
||||
let end = start;
|
||||
for (; end < template.length; end++) {
|
||||
if (template[end] === '(')
|
||||
balance++;
|
||||
else if (template[end] === ')')
|
||||
balance--;
|
||||
if (balance < 0)
|
||||
break;
|
||||
}
|
||||
|
||||
// Replace Java sethas(...) and sethasnot(...) with has=... and hasnot=...
|
||||
let prefix = template.substring(0, start);
|
||||
let extraSymbol = 0;
|
||||
if (['sethas(', 'sethasnot('].includes(hasMatch[1])) {
|
||||
// Eat extra ) symbol at the end of sethas(...)
|
||||
extraSymbol = 1;
|
||||
prefix = prefix.replace(/sethas\($/, 'has=').replace(/sethasnot\($/, 'hasnot=');
|
||||
}
|
||||
|
||||
const paramsCountBeforeHas = countParams(template.substring(0, start));
|
||||
const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas);
|
||||
const paramsCountInHas = countParams(hasTemplate);
|
||||
const hasParams = params.slice(paramsCountBeforeHas, paramsCountBeforeHas + paramsCountInHas);
|
||||
const hasSelector = JSON.stringify(transform(hasTemplate, hasParams, testIdAttributeName));
|
||||
|
||||
// Replace filter(has=...) with filter(has2=$5). Use has2 to avoid matching the same filter again.
|
||||
// Replace filter(hasnot=...) with filter(hasnot2=$5). Use hasnot2 to avoid matching the same filter again.
|
||||
template = prefix.replace(/=$/, '2=') + `$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end + extraSymbol), paramsCountInHas - 1);
|
||||
|
||||
// Replace inner params with $5 value.
|
||||
const paramsBeforeHas = params.slice(0, paramsCountBeforeHas);
|
||||
const paramsAfterHas = params.slice(paramsCountBeforeHas + paramsCountInHas);
|
||||
params = paramsBeforeHas.concat([{ quote: '"', text: hasSelector }]).concat(paramsAfterHas);
|
||||
}
|
||||
|
||||
// Transform to selector engines.
|
||||
template = template
|
||||
.replace(/\,set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase())
|
||||
.replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame')
|
||||
.replace(/contentframe(\(\))?/g, 'internal:control=enter-frame')
|
||||
.replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2')
|
||||
.replace(/locator\(([^)]+),hasnottext=([^),]+)\)/g, 'locator($1).internal:has-not-text=$2')
|
||||
.replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2')
|
||||
.replace(/locator\(([^)]+)\)/g, '$1')
|
||||
.replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1')
|
||||
.replace(/getbytext\(([^)]+)\)/g, 'internal:text=$1')
|
||||
.replace(/getbylabel\(([^)]+)\)/g, 'internal:label=$1')
|
||||
.replace(/getbytestid\(([^)]+)\)/g, `internal:testid=[${testIdAttributeName}=$1]`)
|
||||
.replace(/getby(placeholder|alt|title)(?:text)?\(([^)]+)\)/g, 'internal:attr=[$1=$2]')
|
||||
.replace(/first(\(\))?/g, 'nth=0')
|
||||
.replace(/last(\(\))?/g, 'nth=-1')
|
||||
.replace(/nth\(([^)]+)\)/g, 'nth=$1')
|
||||
.replace(/filter\(,?visible=true\)/g, 'visible=true')
|
||||
.replace(/filter\(,?visible=false\)/g, 'visible=false')
|
||||
.replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1')
|
||||
.replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1')
|
||||
.replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1')
|
||||
.replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1')
|
||||
.replace(/,exact=false/g, '')
|
||||
.replace(/,exact=true/g, 's')
|
||||
.replace(/,includehidden=/g, ',include-hidden=')
|
||||
.replace(/\,/g, '][');
|
||||
|
||||
const parts = template.split('.');
|
||||
// Turn "internal:control=enter-frame >> nth=0" into "nth=0 >> internal:control=enter-frame"
|
||||
// because these are swapped in locators vs selectors.
|
||||
for (let index = 0; index < parts.length - 1; index++) {
|
||||
if (parts[index] === 'internal:control=enter-frame' && parts[index + 1].startsWith('nth=')) {
|
||||
// Swap nth and enter-frame.
|
||||
const [nth] = parts.splice(index, 1);
|
||||
parts.splice(index + 1, 0, nth);
|
||||
}
|
||||
}
|
||||
|
||||
// Substitute params.
|
||||
return parts.map(t => {
|
||||
if (!t.startsWith('internal:') || t === 'internal:control')
|
||||
return t.replace(/\$(\d+)/g, (_, ordinal) => { const param = params[+ordinal - 1]; return param.text; });
|
||||
t = t.includes('[') ? t.replace(/\]/, '') + ']' : t;
|
||||
t = t
|
||||
.replace(/(?:r)\$(\d+)(i)?/g, (_, ordinal, suffix) => {
|
||||
const param = params[+ordinal - 1];
|
||||
if (t.startsWith('internal:attr') || t.startsWith('internal:testid') || t.startsWith('internal:role'))
|
||||
return escapeForAttributeSelector(new RegExp(param.text), false) + (suffix || '');
|
||||
return escapeForTextSelector(new RegExp(param.text, suffix), false);
|
||||
})
|
||||
.replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => {
|
||||
const param = params[+ordinal - 1];
|
||||
if (t.startsWith('internal:has=') || t.startsWith('internal:has-not='))
|
||||
return param.text;
|
||||
if (t.startsWith('internal:testid'))
|
||||
return escapeForAttributeSelector(param.text, true);
|
||||
if (t.startsWith('internal:attr') || t.startsWith('internal:role'))
|
||||
return escapeForAttributeSelector(param.text, suffix === 's');
|
||||
return escapeForTextSelector(param.text, suffix === 's');
|
||||
});
|
||||
return t;
|
||||
}).join(' >> ');
|
||||
}
|
||||
|
||||
export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string {
|
||||
try {
|
||||
return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string {
|
||||
try {
|
||||
parseSelector(locator);
|
||||
return locator;
|
||||
} catch (e) {
|
||||
}
|
||||
const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName);
|
||||
const locators = asLocators(language, selector, undefined, undefined, preferredQuote);
|
||||
const digest = digestForComparison(language, locator);
|
||||
if (locators.some(candidate => digestForComparison(language, candidate) === digest))
|
||||
return selector;
|
||||
return '';
|
||||
}
|
||||
|
||||
function digestForComparison(language: Language, locator: string) {
|
||||
locator = locator.replace(/\s/g, '');
|
||||
if (language === 'javascript')
|
||||
locator = locator.replace(/\\?["`]/g, '\'').replace(/,{}/g, '');
|
||||
return locator;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils';
|
||||
|
||||
export type ByRoleOptions = {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
exact?: boolean;
|
||||
expanded?: boolean;
|
||||
includeHidden?: boolean;
|
||||
level?: number;
|
||||
name?: string | RegExp;
|
||||
pressed?: boolean;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
function getByAttributeTextSelector(attrName: string, text: string | RegExp, options?: { exact?: boolean }): string {
|
||||
return `internal:attr=[${attrName}=${escapeForAttributeSelector(text, options?.exact || false)}]`;
|
||||
}
|
||||
|
||||
export function getByTestIdSelector(testIdAttributeName: string, testId: string | RegExp): string {
|
||||
return `internal:testid=[${testIdAttributeName}=${escapeForAttributeSelector(testId, true)}]`;
|
||||
}
|
||||
|
||||
export function getByLabelSelector(text: string | RegExp, options?: { exact?: boolean }): string {
|
||||
return 'internal:label=' + escapeForTextSelector(text, !!options?.exact);
|
||||
}
|
||||
|
||||
export function getByAltTextSelector(text: string | RegExp, options?: { exact?: boolean }): string {
|
||||
return getByAttributeTextSelector('alt', text, options);
|
||||
}
|
||||
|
||||
export function getByTitleSelector(text: string | RegExp, options?: { exact?: boolean }): string {
|
||||
return getByAttributeTextSelector('title', text, options);
|
||||
}
|
||||
|
||||
export function getByPlaceholderSelector(text: string | RegExp, options?: { exact?: boolean }): string {
|
||||
return getByAttributeTextSelector('placeholder', text, options);
|
||||
}
|
||||
|
||||
export function getByTextSelector(text: string | RegExp, options?: { exact?: boolean }): string {
|
||||
return 'internal:text=' + escapeForTextSelector(text, !!options?.exact);
|
||||
}
|
||||
|
||||
export function getByRoleSelector(role: string, options: ByRoleOptions = {}): string {
|
||||
const props: string[][] = [];
|
||||
if (options.checked !== undefined)
|
||||
props.push(['checked', String(options.checked)]);
|
||||
if (options.disabled !== undefined)
|
||||
props.push(['disabled', String(options.disabled)]);
|
||||
if (options.selected !== undefined)
|
||||
props.push(['selected', String(options.selected)]);
|
||||
if (options.expanded !== undefined)
|
||||
props.push(['expanded', String(options.expanded)]);
|
||||
if (options.includeHidden !== undefined)
|
||||
props.push(['include-hidden', String(options.includeHidden)]);
|
||||
if (options.level !== undefined)
|
||||
props.push(['level', String(options.level)]);
|
||||
if (options.name !== undefined)
|
||||
props.push(['name', escapeForAttributeSelector(options.name, !!options.exact)]);
|
||||
if (options.pressed !== undefined)
|
||||
props.push(['pressed', String(options.pressed)]);
|
||||
return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join('')}`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class LRUCache<K, V> {
|
||||
private _maxSize: number;
|
||||
private _map: Map<K, { value: V, size: number }>;
|
||||
private _size: number;
|
||||
|
||||
constructor(maxSize: number) {
|
||||
this._maxSize = maxSize;
|
||||
this._map = new Map();
|
||||
this._size = 0;
|
||||
}
|
||||
|
||||
getOrCompute(key: K, compute: () => { value: V, size: number }): V {
|
||||
if (this._map.has(key)) {
|
||||
const result = this._map.get(key)!;
|
||||
// reinserting makes this the least recently used entry
|
||||
this._map.delete(key);
|
||||
this._map.set(key, result);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
const result = compute();
|
||||
|
||||
while (this._map.size && this._size + result.size > this._maxSize) {
|
||||
const [firstKey, firstValue] = this._map.entries().next().value!;
|
||||
this._size -= firstValue.size;
|
||||
this._map.delete(firstKey);
|
||||
}
|
||||
|
||||
this._map.set(key, result);
|
||||
this._size += result.size;
|
||||
return result.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { captureRawStack } from './stackTrace';
|
||||
|
||||
export class ManualPromise<T = void> extends Promise<T> {
|
||||
private _resolve!: (t: T) => void;
|
||||
private _reject!: (e: Error) => void;
|
||||
private _isDone: boolean;
|
||||
|
||||
constructor() {
|
||||
let resolve: (t: T) => void;
|
||||
let reject: (e: Error) => void;
|
||||
super((f, r) => {
|
||||
resolve = f;
|
||||
reject = r;
|
||||
});
|
||||
this._isDone = false;
|
||||
this._resolve = resolve!;
|
||||
this._reject = reject!;
|
||||
}
|
||||
|
||||
isDone() {
|
||||
return this._isDone;
|
||||
}
|
||||
|
||||
resolve(t: T) {
|
||||
this._isDone = true;
|
||||
this._resolve(t);
|
||||
}
|
||||
|
||||
reject(e: Error) {
|
||||
this._isDone = true;
|
||||
this._reject(e);
|
||||
}
|
||||
|
||||
static override get [Symbol.species]() {
|
||||
return Promise;
|
||||
}
|
||||
|
||||
override get [Symbol.toStringTag]() {
|
||||
return 'ManualPromise';
|
||||
}
|
||||
}
|
||||
|
||||
export class LongStandingScope {
|
||||
private _terminateError: Error | undefined;
|
||||
private _closeError: Error | undefined;
|
||||
private _terminatePromises = new Map<ManualPromise<Error>, string[]>();
|
||||
private _isClosed = false;
|
||||
|
||||
reject(error: Error) {
|
||||
this._isClosed = true;
|
||||
this._terminateError = error;
|
||||
for (const p of this._terminatePromises.keys())
|
||||
p.resolve(error);
|
||||
}
|
||||
|
||||
close(error: Error) {
|
||||
this._isClosed = true;
|
||||
this._closeError = error;
|
||||
for (const [p, frames] of this._terminatePromises)
|
||||
p.resolve(cloneError(error, frames));
|
||||
}
|
||||
|
||||
isClosed() {
|
||||
return this._isClosed;
|
||||
}
|
||||
|
||||
static async raceMultiple<T>(scopes: LongStandingScope[], promise: Promise<T>): Promise<T> {
|
||||
return Promise.race(scopes.map(s => s.race(promise)));
|
||||
}
|
||||
|
||||
async race<T>(promise: Promise<T> | Promise<T>[]): Promise<T> {
|
||||
return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise<T>;
|
||||
}
|
||||
|
||||
async safeRace<T>(promise: Promise<T>, defaultValue: T): Promise<T>;
|
||||
async safeRace<T>(promise: Promise<T>): Promise<T | undefined>;
|
||||
async safeRace<T>(promise: Promise<T>, defaultValue?: T): Promise<T | undefined> {
|
||||
return this._race([promise], true, defaultValue);
|
||||
}
|
||||
|
||||
private async _race(promises: Promise<any>[], safe: boolean, defaultValue?: any): Promise<any> {
|
||||
const terminatePromise = new ManualPromise<Error>();
|
||||
const frames = captureRawStack();
|
||||
if (this._terminateError)
|
||||
terminatePromise.resolve(this._terminateError);
|
||||
if (this._closeError)
|
||||
terminatePromise.resolve(cloneError(this._closeError, frames));
|
||||
this._terminatePromises.set(terminatePromise, frames);
|
||||
try {
|
||||
return await Promise.race([
|
||||
terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)),
|
||||
...promises
|
||||
]);
|
||||
} finally {
|
||||
this._terminatePromises.delete(terminatePromise);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cloneError(error: Error, frames: string[]) {
|
||||
const clone = new Error();
|
||||
clone.name = error.name;
|
||||
clone.message = error.message;
|
||||
clone.stack = [error.name + ':' + error.message, ...frames].join('\n');
|
||||
return clone;
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function isJsonMimeType(mimeType: string) {
|
||||
return !!mimeType.match(/^(application\/json|application\/.*?\+json|text\/(x-)?json)(;\s*charset=.*)?$/);
|
||||
}
|
||||
|
||||
export function isXmlMimeType(mimeType: string) {
|
||||
return !!mimeType.match(/^(application\/xml|application\/.*?\+xml|text\/xml)(;\s*charset=.*)?$/);
|
||||
}
|
||||
|
||||
export function isTextualMimeType(mimeType: string) {
|
||||
return !!mimeType.match(/^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$/);
|
||||
}
|
||||
export function getMimeTypeForPath(path: string): string | null {
|
||||
const dotIndex = path.lastIndexOf('.');
|
||||
if (dotIndex === -1)
|
||||
return null;
|
||||
const extension = path.substring(dotIndex + 1);
|
||||
return types.get(extension) || null;
|
||||
}
|
||||
|
||||
const types: Map<string, string> = new Map([
|
||||
['ez', 'application/andrew-inset'],
|
||||
['aw', 'application/applixware'],
|
||||
['atom', 'application/atom+xml'],
|
||||
['atomcat', 'application/atomcat+xml'],
|
||||
['atomdeleted', 'application/atomdeleted+xml'],
|
||||
['atomsvc', 'application/atomsvc+xml'],
|
||||
['dwd', 'application/atsc-dwd+xml'],
|
||||
['held', 'application/atsc-held+xml'],
|
||||
['rsat', 'application/atsc-rsat+xml'],
|
||||
['bdoc', 'application/bdoc'],
|
||||
['xcs', 'application/calendar+xml'],
|
||||
['ccxml', 'application/ccxml+xml'],
|
||||
['cdfx', 'application/cdfx+xml'],
|
||||
['cdmia', 'application/cdmi-capability'],
|
||||
['cdmic', 'application/cdmi-container'],
|
||||
['cdmid', 'application/cdmi-domain'],
|
||||
['cdmio', 'application/cdmi-object'],
|
||||
['cdmiq', 'application/cdmi-queue'],
|
||||
['cu', 'application/cu-seeme'],
|
||||
['mpd', 'application/dash+xml'],
|
||||
['davmount', 'application/davmount+xml'],
|
||||
['dbk', 'application/docbook+xml'],
|
||||
['dssc', 'application/dssc+der'],
|
||||
['xdssc', 'application/dssc+xml'],
|
||||
['ecma', 'application/ecmascript'],
|
||||
['es', 'application/ecmascript'],
|
||||
['emma', 'application/emma+xml'],
|
||||
['emotionml', 'application/emotionml+xml'],
|
||||
['epub', 'application/epub+zip'],
|
||||
['exi', 'application/exi'],
|
||||
['exp', 'application/express'],
|
||||
['fdt', 'application/fdt+xml'],
|
||||
['pfr', 'application/font-tdpfr'],
|
||||
['geojson', 'application/geo+json'],
|
||||
['gml', 'application/gml+xml'],
|
||||
['gpx', 'application/gpx+xml'],
|
||||
['gxf', 'application/gxf'],
|
||||
['gz', 'application/gzip'],
|
||||
['hjson', 'application/hjson'],
|
||||
['stk', 'application/hyperstudio'],
|
||||
['ink', 'application/inkml+xml'],
|
||||
['inkml', 'application/inkml+xml'],
|
||||
['ipfix', 'application/ipfix'],
|
||||
['its', 'application/its+xml'],
|
||||
['ear', 'application/java-archive'],
|
||||
['jar', 'application/java-archive'],
|
||||
['war', 'application/java-archive'],
|
||||
['ser', 'application/java-serialized-object'],
|
||||
['class', 'application/java-vm'],
|
||||
['js', 'application/javascript'],
|
||||
['mjs', 'application/javascript'],
|
||||
['json', 'application/json'],
|
||||
['map', 'application/json'],
|
||||
['json5', 'application/json5'],
|
||||
['jsonml', 'application/jsonml+json'],
|
||||
['jsonld', 'application/ld+json'],
|
||||
['lgr', 'application/lgr+xml'],
|
||||
['lostxml', 'application/lost+xml'],
|
||||
['hqx', 'application/mac-binhex40'],
|
||||
['cpt', 'application/mac-compactpro'],
|
||||
['mads', 'application/mads+xml'],
|
||||
['webmanifest', 'application/manifest+json'],
|
||||
['mrc', 'application/marc'],
|
||||
['mrcx', 'application/marcxml+xml'],
|
||||
['ma', 'application/mathematica'],
|
||||
['mb', 'application/mathematica'],
|
||||
['nb', 'application/mathematica'],
|
||||
['mathml', 'application/mathml+xml'],
|
||||
['mbox', 'application/mbox'],
|
||||
['mscml', 'application/mediaservercontrol+xml'],
|
||||
['metalink', 'application/metalink+xml'],
|
||||
['meta4', 'application/metalink4+xml'],
|
||||
['mets', 'application/mets+xml'],
|
||||
['maei', 'application/mmt-aei+xml'],
|
||||
['musd', 'application/mmt-usd+xml'],
|
||||
['mods', 'application/mods+xml'],
|
||||
['m21', 'application/mp21'],
|
||||
['mp21', 'application/mp21'],
|
||||
['m4p', 'application/mp4'],
|
||||
['mp4s', 'application/mp4'],
|
||||
['doc', 'application/msword'],
|
||||
['dot', 'application/msword'],
|
||||
['mxf', 'application/mxf'],
|
||||
['nq', 'application/n-quads'],
|
||||
['nt', 'application/n-triples'],
|
||||
['cjs', 'application/node'],
|
||||
['bin', 'application/octet-stream'],
|
||||
['bpk', 'application/octet-stream'],
|
||||
['buffer', 'application/octet-stream'],
|
||||
['deb', 'application/octet-stream'],
|
||||
['deploy', 'application/octet-stream'],
|
||||
['dist', 'application/octet-stream'],
|
||||
['distz', 'application/octet-stream'],
|
||||
['dll', 'application/octet-stream'],
|
||||
['dmg', 'application/octet-stream'],
|
||||
['dms', 'application/octet-stream'],
|
||||
['dump', 'application/octet-stream'],
|
||||
['elc', 'application/octet-stream'],
|
||||
['exe', 'application/octet-stream'],
|
||||
['img', 'application/octet-stream'],
|
||||
['iso', 'application/octet-stream'],
|
||||
['lrf', 'application/octet-stream'],
|
||||
['mar', 'application/octet-stream'],
|
||||
['msi', 'application/octet-stream'],
|
||||
['msm', 'application/octet-stream'],
|
||||
['msp', 'application/octet-stream'],
|
||||
['pkg', 'application/octet-stream'],
|
||||
['so', 'application/octet-stream'],
|
||||
['oda', 'application/oda'],
|
||||
['opf', 'application/oebps-package+xml'],
|
||||
['ogx', 'application/ogg'],
|
||||
['omdoc', 'application/omdoc+xml'],
|
||||
['onepkg', 'application/onenote'],
|
||||
['onetmp', 'application/onenote'],
|
||||
['onetoc', 'application/onenote'],
|
||||
['onetoc2', 'application/onenote'],
|
||||
['oxps', 'application/oxps'],
|
||||
['relo', 'application/p2p-overlay+xml'],
|
||||
['xer', 'application/patch-ops-error+xml'],
|
||||
['pdf', 'application/pdf'],
|
||||
['pgp', 'application/pgp-encrypted'],
|
||||
['asc', 'application/pgp-signature'],
|
||||
['sig', 'application/pgp-signature'],
|
||||
['prf', 'application/pics-rules'],
|
||||
['p10', 'application/pkcs10'],
|
||||
['p7c', 'application/pkcs7-mime'],
|
||||
['p7m', 'application/pkcs7-mime'],
|
||||
['p7s', 'application/pkcs7-signature'],
|
||||
['p8', 'application/pkcs8'],
|
||||
['ac', 'application/pkix-attr-cert'],
|
||||
['cer', 'application/pkix-cert'],
|
||||
['crl', 'application/pkix-crl'],
|
||||
['pkipath', 'application/pkix-pkipath'],
|
||||
['pki', 'application/pkixcmp'],
|
||||
['pls', 'application/pls+xml'],
|
||||
['ai', 'application/postscript'],
|
||||
['eps', 'application/postscript'],
|
||||
['ps', 'application/postscript'],
|
||||
['provx', 'application/provenance+xml'],
|
||||
['pskcxml', 'application/pskc+xml'],
|
||||
['raml', 'application/raml+yaml'],
|
||||
['owl', 'application/rdf+xml'],
|
||||
['rdf', 'application/rdf+xml'],
|
||||
['rif', 'application/reginfo+xml'],
|
||||
['rnc', 'application/relax-ng-compact-syntax'],
|
||||
['rl', 'application/resource-lists+xml'],
|
||||
['rld', 'application/resource-lists-diff+xml'],
|
||||
['rs', 'application/rls-services+xml'],
|
||||
['rapd', 'application/route-apd+xml'],
|
||||
['sls', 'application/route-s-tsid+xml'],
|
||||
['rusd', 'application/route-usd+xml'],
|
||||
['gbr', 'application/rpki-ghostbusters'],
|
||||
['mft', 'application/rpki-manifest'],
|
||||
['roa', 'application/rpki-roa'],
|
||||
['rsd', 'application/rsd+xml'],
|
||||
['rss', 'application/rss+xml'],
|
||||
['rtf', 'application/rtf'],
|
||||
['sbml', 'application/sbml+xml'],
|
||||
['scq', 'application/scvp-cv-request'],
|
||||
['scs', 'application/scvp-cv-response'],
|
||||
['spq', 'application/scvp-vp-request'],
|
||||
['spp', 'application/scvp-vp-response'],
|
||||
['sdp', 'application/sdp'],
|
||||
['senmlx', 'application/senml+xml'],
|
||||
['sensmlx', 'application/sensml+xml'],
|
||||
['setpay', 'application/set-payment-initiation'],
|
||||
['setreg', 'application/set-registration-initiation'],
|
||||
['shf', 'application/shf+xml'],
|
||||
['sieve', 'application/sieve'],
|
||||
['siv', 'application/sieve'],
|
||||
['smi', 'application/smil+xml'],
|
||||
['smil', 'application/smil+xml'],
|
||||
['rq', 'application/sparql-query'],
|
||||
['srx', 'application/sparql-results+xml'],
|
||||
['gram', 'application/srgs'],
|
||||
['grxml', 'application/srgs+xml'],
|
||||
['sru', 'application/sru+xml'],
|
||||
['ssdl', 'application/ssdl+xml'],
|
||||
['ssml', 'application/ssml+xml'],
|
||||
['swidtag', 'application/swid+xml'],
|
||||
['tei', 'application/tei+xml'],
|
||||
['teicorpus', 'application/tei+xml'],
|
||||
['tfi', 'application/thraud+xml'],
|
||||
['tsd', 'application/timestamped-data'],
|
||||
['toml', 'application/toml'],
|
||||
['trig', 'application/trig'],
|
||||
['ttml', 'application/ttml+xml'],
|
||||
['ubj', 'application/ubjson'],
|
||||
['rsheet', 'application/urc-ressheet+xml'],
|
||||
['td', 'application/urc-targetdesc+xml'],
|
||||
['vxml', 'application/voicexml+xml'],
|
||||
['wasm', 'application/wasm'],
|
||||
['wgt', 'application/widget'],
|
||||
['hlp', 'application/winhlp'],
|
||||
['wsdl', 'application/wsdl+xml'],
|
||||
['wspolicy', 'application/wspolicy+xml'],
|
||||
['xaml', 'application/xaml+xml'],
|
||||
['xav', 'application/xcap-att+xml'],
|
||||
['xca', 'application/xcap-caps+xml'],
|
||||
['xdf', 'application/xcap-diff+xml'],
|
||||
['xel', 'application/xcap-el+xml'],
|
||||
['xns', 'application/xcap-ns+xml'],
|
||||
['xenc', 'application/xenc+xml'],
|
||||
['xht', 'application/xhtml+xml'],
|
||||
['xhtml', 'application/xhtml+xml'],
|
||||
['xlf', 'application/xliff+xml'],
|
||||
['rng', 'application/xml'],
|
||||
['xml', 'application/xml'],
|
||||
['xsd', 'application/xml'],
|
||||
['xsl', 'application/xml'],
|
||||
['dtd', 'application/xml-dtd'],
|
||||
['xop', 'application/xop+xml'],
|
||||
['xpl', 'application/xproc+xml'],
|
||||
['*xsl', 'application/xslt+xml'],
|
||||
['xslt', 'application/xslt+xml'],
|
||||
['xspf', 'application/xspf+xml'],
|
||||
['mxml', 'application/xv+xml'],
|
||||
['xhvml', 'application/xv+xml'],
|
||||
['xvm', 'application/xv+xml'],
|
||||
['xvml', 'application/xv+xml'],
|
||||
['yang', 'application/yang'],
|
||||
['yin', 'application/yin+xml'],
|
||||
['zip', 'application/zip'],
|
||||
['*3gpp', 'audio/3gpp'],
|
||||
['adp', 'audio/adpcm'],
|
||||
['amr', 'audio/amr'],
|
||||
['au', 'audio/basic'],
|
||||
['snd', 'audio/basic'],
|
||||
['kar', 'audio/midi'],
|
||||
['mid', 'audio/midi'],
|
||||
['midi', 'audio/midi'],
|
||||
['rmi', 'audio/midi'],
|
||||
['mxmf', 'audio/mobile-xmf'],
|
||||
['*mp3', 'audio/mp3'],
|
||||
['m4a', 'audio/mp4'],
|
||||
['mp4a', 'audio/mp4'],
|
||||
['m2a', 'audio/mpeg'],
|
||||
['m3a', 'audio/mpeg'],
|
||||
['mp2', 'audio/mpeg'],
|
||||
['mp2a', 'audio/mpeg'],
|
||||
['mp3', 'audio/mpeg'],
|
||||
['mpga', 'audio/mpeg'],
|
||||
['oga', 'audio/ogg'],
|
||||
['ogg', 'audio/ogg'],
|
||||
['opus', 'audio/ogg'],
|
||||
['spx', 'audio/ogg'],
|
||||
['s3m', 'audio/s3m'],
|
||||
['sil', 'audio/silk'],
|
||||
['wav', 'audio/wav'],
|
||||
['*wav', 'audio/wave'],
|
||||
['weba', 'audio/webm'],
|
||||
['xm', 'audio/xm'],
|
||||
['ttc', 'font/collection'],
|
||||
['otf', 'font/otf'],
|
||||
['ttf', 'font/ttf'],
|
||||
['woff', 'font/woff'],
|
||||
['woff2', 'font/woff2'],
|
||||
['exr', 'image/aces'],
|
||||
['apng', 'image/apng'],
|
||||
['avif', 'image/avif'],
|
||||
['bmp', 'image/bmp'],
|
||||
['cgm', 'image/cgm'],
|
||||
['drle', 'image/dicom-rle'],
|
||||
['emf', 'image/emf'],
|
||||
['fits', 'image/fits'],
|
||||
['g3', 'image/g3fax'],
|
||||
['gif', 'image/gif'],
|
||||
['heic', 'image/heic'],
|
||||
['heics', 'image/heic-sequence'],
|
||||
['heif', 'image/heif'],
|
||||
['heifs', 'image/heif-sequence'],
|
||||
['hej2', 'image/hej2k'],
|
||||
['hsj2', 'image/hsj2'],
|
||||
['ief', 'image/ief'],
|
||||
['jls', 'image/jls'],
|
||||
['jp2', 'image/jp2'],
|
||||
['jpg2', 'image/jp2'],
|
||||
['jpe', 'image/jpeg'],
|
||||
['jpeg', 'image/jpeg'],
|
||||
['jpg', 'image/jpeg'],
|
||||
['jph', 'image/jph'],
|
||||
['jhc', 'image/jphc'],
|
||||
['jpm', 'image/jpm'],
|
||||
['jpf', 'image/jpx'],
|
||||
['jpx', 'image/jpx'],
|
||||
['jxr', 'image/jxr'],
|
||||
['jxra', 'image/jxra'],
|
||||
['jxrs', 'image/jxrs'],
|
||||
['jxs', 'image/jxs'],
|
||||
['jxsc', 'image/jxsc'],
|
||||
['jxsi', 'image/jxsi'],
|
||||
['jxss', 'image/jxss'],
|
||||
['ktx', 'image/ktx'],
|
||||
['ktx2', 'image/ktx2'],
|
||||
['png', 'image/png'],
|
||||
['sgi', 'image/sgi'],
|
||||
['svg', 'image/svg+xml'],
|
||||
['svgz', 'image/svg+xml'],
|
||||
['t38', 'image/t38'],
|
||||
['tif', 'image/tiff'],
|
||||
['tiff', 'image/tiff'],
|
||||
['tfx', 'image/tiff-fx'],
|
||||
['webp', 'image/webp'],
|
||||
['wmf', 'image/wmf'],
|
||||
['disposition-notification', 'message/disposition-notification'],
|
||||
['u8msg', 'message/global'],
|
||||
['u8dsn', 'message/global-delivery-status'],
|
||||
['u8mdn', 'message/global-disposition-notification'],
|
||||
['u8hdr', 'message/global-headers'],
|
||||
['eml', 'message/rfc822'],
|
||||
['mime', 'message/rfc822'],
|
||||
['3mf', 'model/3mf'],
|
||||
['gltf', 'model/gltf+json'],
|
||||
['glb', 'model/gltf-binary'],
|
||||
['iges', 'model/iges'],
|
||||
['igs', 'model/iges'],
|
||||
['mesh', 'model/mesh'],
|
||||
['msh', 'model/mesh'],
|
||||
['silo', 'model/mesh'],
|
||||
['mtl', 'model/mtl'],
|
||||
['obj', 'model/obj'],
|
||||
['stpx', 'model/step+xml'],
|
||||
['stpz', 'model/step+zip'],
|
||||
['stpxz', 'model/step-xml+zip'],
|
||||
['stl', 'model/stl'],
|
||||
['vrml', 'model/vrml'],
|
||||
['wrl', 'model/vrml'],
|
||||
['*x3db', 'model/x3d+binary'],
|
||||
['x3dbz', 'model/x3d+binary'],
|
||||
['x3db', 'model/x3d+fastinfoset'],
|
||||
['*x3dv', 'model/x3d+vrml'],
|
||||
['x3dvz', 'model/x3d+vrml'],
|
||||
['x3d', 'model/x3d+xml'],
|
||||
['x3dz', 'model/x3d+xml'],
|
||||
['x3dv', 'model/x3d-vrml'],
|
||||
['appcache', 'text/cache-manifest'],
|
||||
['manifest', 'text/cache-manifest'],
|
||||
['ics', 'text/calendar'],
|
||||
['ifb', 'text/calendar'],
|
||||
['coffee', 'text/coffeescript'],
|
||||
['litcoffee', 'text/coffeescript'],
|
||||
['css', 'text/css'],
|
||||
['csv', 'text/csv'],
|
||||
['htm', 'text/html'],
|
||||
['html', 'text/html'],
|
||||
['shtml', 'text/html'],
|
||||
['jade', 'text/jade'],
|
||||
['jsx', 'text/jsx'],
|
||||
['less', 'text/less'],
|
||||
['markdown', 'text/markdown'],
|
||||
['md', 'text/markdown'],
|
||||
['mml', 'text/mathml'],
|
||||
['mdx', 'text/mdx'],
|
||||
['n3', 'text/n3'],
|
||||
['conf', 'text/plain'],
|
||||
['def', 'text/plain'],
|
||||
['in', 'text/plain'],
|
||||
['ini', 'text/plain'],
|
||||
['list', 'text/plain'],
|
||||
['log', 'text/plain'],
|
||||
['text', 'text/plain'],
|
||||
['txt', 'text/plain'],
|
||||
['rtx', 'text/richtext'],
|
||||
['*rtf', 'text/rtf'],
|
||||
['sgm', 'text/sgml'],
|
||||
['sgml', 'text/sgml'],
|
||||
['shex', 'text/shex'],
|
||||
['slim', 'text/slim'],
|
||||
['slm', 'text/slim'],
|
||||
['spdx', 'text/spdx'],
|
||||
['styl', 'text/stylus'],
|
||||
['stylus', 'text/stylus'],
|
||||
['tsv', 'text/tab-separated-values'],
|
||||
['man', 'text/troff'],
|
||||
['me', 'text/troff'],
|
||||
['ms', 'text/troff'],
|
||||
['roff', 'text/troff'],
|
||||
['t', 'text/troff'],
|
||||
['tr', 'text/troff'],
|
||||
['ttl', 'text/turtle'],
|
||||
['uri', 'text/uri-list'],
|
||||
['uris', 'text/uri-list'],
|
||||
['urls', 'text/uri-list'],
|
||||
['vcard', 'text/vcard'],
|
||||
['vtt', 'text/vtt'],
|
||||
['*xml', 'text/xml'],
|
||||
['yaml', 'text/yaml'],
|
||||
['yml', 'text/yaml'],
|
||||
['3gp', 'video/3gpp'],
|
||||
['3gpp', 'video/3gpp'],
|
||||
['3g2', 'video/3gpp2'],
|
||||
['h261', 'video/h261'],
|
||||
['h263', 'video/h263'],
|
||||
['h264', 'video/h264'],
|
||||
['m4s', 'video/iso.segment'],
|
||||
['jpgv', 'video/jpeg'],
|
||||
['jpm', 'video/jpm'],
|
||||
['jpgm', 'video/jpm'],
|
||||
['mj2', 'video/mj2'],
|
||||
['mjp2', 'video/mj2'],
|
||||
['ts', 'application/typescript'],
|
||||
['mp4', 'video/mp4'],
|
||||
['mp4v', 'video/mp4'],
|
||||
['mpg4', 'video/mp4'],
|
||||
['m1v', 'video/mpeg'],
|
||||
['m2v', 'video/mpeg'],
|
||||
['mpe', 'video/mpeg'],
|
||||
['mpeg', 'video/mpeg'],
|
||||
['mpg', 'video/mpeg'],
|
||||
['ogv', 'video/ogg'],
|
||||
['mov', 'video/quicktime'],
|
||||
['qt', 'video/quicktime'],
|
||||
['webm', 'video/webm']
|
||||
]);
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class MultiMap<K, V> {
|
||||
private _map: Map<K, V[]>;
|
||||
|
||||
constructor() {
|
||||
this._map = new Map<K, V[]>();
|
||||
}
|
||||
|
||||
set(key: K, value: V) {
|
||||
let values = this._map.get(key);
|
||||
if (!values) {
|
||||
values = [];
|
||||
this._map.set(key, values);
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
get(key: K): V[] {
|
||||
return this._map.get(key) || [];
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this._map.has(key);
|
||||
}
|
||||
|
||||
delete(key: K, value: V) {
|
||||
const values = this._map.get(key);
|
||||
if (!values)
|
||||
return;
|
||||
if (values.includes(value))
|
||||
this._map.set(key, values.filter(v => value !== v));
|
||||
}
|
||||
|
||||
deleteAll(key: K) {
|
||||
this._map.delete(key);
|
||||
}
|
||||
|
||||
hasValue(key: K, value: V): boolean {
|
||||
const values = this._map.get(key);
|
||||
if (!values)
|
||||
return false;
|
||||
return values.includes(value);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._map.size;
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<[K, V[]]> {
|
||||
return this._map[Symbol.iterator]();
|
||||
}
|
||||
|
||||
keys(): IterableIterator<K> {
|
||||
return this._map.keys();
|
||||
}
|
||||
|
||||
values(): Iterable<V> {
|
||||
const result: V[] = [];
|
||||
for (const key of this.keys())
|
||||
result.push(...this.get(key));
|
||||
return result;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._map.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { webColors } from './colors';
|
||||
|
||||
import type * as fs from 'fs';
|
||||
import type * as path from 'path';
|
||||
import type { Readable, Writable } from 'stream';
|
||||
import type { Colors } from '@isomorphic/colors';
|
||||
import type * as channels from '@protocol/channels';
|
||||
|
||||
export type Zone = {
|
||||
push(data: unknown): Zone;
|
||||
pop(): Zone;
|
||||
run<R>(func: () => R): R;
|
||||
data<T>(): T | undefined;
|
||||
};
|
||||
|
||||
const noopZone: Zone = {
|
||||
push: () => noopZone,
|
||||
pop: () => noopZone,
|
||||
run: func => func(),
|
||||
data: () => undefined,
|
||||
};
|
||||
|
||||
export type Platform = {
|
||||
name: 'node' | 'web' | 'empty';
|
||||
|
||||
boxedStackPrefixes: () => string[];
|
||||
calculateSha1: (text: string) => Promise<string>;
|
||||
colors: Colors;
|
||||
coreDir?: string;
|
||||
createGuid: () => string;
|
||||
defaultMaxListeners: () => number;
|
||||
env: Record<string, string | undefined>;
|
||||
fs: () => typeof fs;
|
||||
inspectCustom: symbol | undefined;
|
||||
isDebugMode: () => boolean;
|
||||
isJSDebuggerAttached: () => boolean;
|
||||
isLogEnabled: (name: 'api' | 'channel') => boolean;
|
||||
isUnderTest: () => boolean,
|
||||
log: (name: 'api' | 'channel', message: string | Error | object) => void;
|
||||
path: () => typeof path;
|
||||
pathSeparator: string;
|
||||
showInternalStackFrames: () => boolean,
|
||||
streamFile: (path: string, writable: Writable) => Promise<void>,
|
||||
streamReadable: (channel: channels.StreamChannel) => Readable,
|
||||
streamWritable: (channel: channels.WritableStreamChannel) => Writable,
|
||||
zones: { empty: Zone, current: () => Zone; };
|
||||
};
|
||||
|
||||
export const emptyPlatform: Platform = {
|
||||
name: 'empty',
|
||||
|
||||
boxedStackPrefixes: () => [],
|
||||
|
||||
calculateSha1: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
|
||||
colors: webColors,
|
||||
|
||||
createGuid: () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
|
||||
defaultMaxListeners: () => 10,
|
||||
|
||||
env: {},
|
||||
|
||||
fs: () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
|
||||
inspectCustom: undefined,
|
||||
|
||||
isDebugMode: () => false,
|
||||
|
||||
isJSDebuggerAttached: () => false,
|
||||
|
||||
isLogEnabled(name: 'api' | 'channel') {
|
||||
return false;
|
||||
},
|
||||
|
||||
isUnderTest: () => false,
|
||||
|
||||
log(name: 'api' | 'channel', message: string | Error | object) { },
|
||||
|
||||
path: () => {
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
|
||||
pathSeparator: '/',
|
||||
|
||||
showInternalStackFrames: () => false,
|
||||
|
||||
streamFile(path: string, writable: Writable): Promise<void> {
|
||||
throw new Error('Streams are not available');
|
||||
},
|
||||
|
||||
streamReadable: (channel: channels.StreamChannel) => {
|
||||
throw new Error('Streams are not available');
|
||||
},
|
||||
|
||||
streamWritable: (channel: channels.WritableStreamChannel) => {
|
||||
throw new Error('Streams are not available');
|
||||
},
|
||||
|
||||
zones: { empty: noopZone, current: () => noopZone },
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getMetainfo } from './protocolMetainfo';
|
||||
|
||||
export function formatProtocolParam(params: Record<string, string> | undefined, alternatives: string): string | undefined {
|
||||
return _formatProtocolParam(params, alternatives)?.replaceAll('\n', '\\n');
|
||||
}
|
||||
|
||||
function _formatProtocolParam(params: Record<string, string> | undefined, alternatives: string): string | undefined {
|
||||
if (!params)
|
||||
return undefined;
|
||||
|
||||
for (const name of alternatives.split('|')) {
|
||||
if (name === 'url') {
|
||||
try {
|
||||
const urlObject = new URL(params[name]);
|
||||
if (urlObject.protocol === 'data:')
|
||||
return urlObject.protocol;
|
||||
if (['about:', 'chrome:', 'edge:'].includes(urlObject.protocol))
|
||||
return params[name];
|
||||
return urlObject.pathname + urlObject.search;
|
||||
} catch (error) {
|
||||
if (params[name] !== undefined)
|
||||
return params[name];
|
||||
}
|
||||
}
|
||||
if (name === 'timeNumber' && params[name] !== undefined) {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
return new Date(params[name]).toString();
|
||||
}
|
||||
|
||||
const value = deepParam(params, name);
|
||||
if (value !== undefined)
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function deepParam(params: Record<string, any>, name: string): string | undefined {
|
||||
const tokens = name.split('.');
|
||||
let current = params;
|
||||
for (const token of tokens) {
|
||||
if (typeof current !== 'object' || current === null)
|
||||
return undefined;
|
||||
current = current[token];
|
||||
}
|
||||
if (current === undefined)
|
||||
return undefined;
|
||||
return String(current);
|
||||
}
|
||||
|
||||
export function renderTitleForCall(metadata: { title?: string, type: string, method: string, params: Record<string, string> | undefined }) {
|
||||
const titleFormat = metadata.title ?? getMetainfo(metadata)?.title ?? metadata.method;
|
||||
return titleFormat.replace(/\{([^}]+)\}/g, (fullMatch, p1) => {
|
||||
return formatProtocolParam(metadata.params, p1) ?? fullMatch;
|
||||
});
|
||||
}
|
||||
|
||||
export type ActionGroup = 'configuration' | 'route' | 'getter';
|
||||
|
||||
export function getActionGroup(metadata: { type: string, method: string }) {
|
||||
return getMetainfo(metadata)?.group as undefined | ActionGroup;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// This file is generated by generate_channels.js, do not edit manually.
|
||||
|
||||
export type MethodMetainfo = { internal?: boolean, title?: string, slowMo?: boolean, snapshot?: boolean, pause?: boolean, isAutoWaiting?: boolean, input?: boolean, group?: string };
|
||||
|
||||
export const methodMetainfo = new Map<string, MethodMetainfo>([
|
||||
['APIRequestContext.fetch', { title: '{method} "{url}"', }],
|
||||
['APIRequestContext.fetchResponseBody', { title: 'Get response body', group: 'getter', }],
|
||||
['APIRequestContext.fetchLog', { internal: true, }],
|
||||
['APIRequestContext.storageState', { title: 'Get storage state', group: 'configuration', }],
|
||||
['APIRequestContext.disposeAPIResponse', { internal: true, }],
|
||||
['APIRequestContext.dispose', { internal: true, }],
|
||||
['LocalUtils.zip', { internal: true, }],
|
||||
['LocalUtils.harOpen', { internal: true, }],
|
||||
['LocalUtils.harLookup', { internal: true, }],
|
||||
['LocalUtils.harClose', { internal: true, }],
|
||||
['LocalUtils.harUnzip', { internal: true, }],
|
||||
['LocalUtils.connect', { internal: true, }],
|
||||
['LocalUtils.tracingStarted', { internal: true, }],
|
||||
['LocalUtils.addStackToTracingNoReply', { internal: true, }],
|
||||
['LocalUtils.traceDiscarded', { internal: true, }],
|
||||
['LocalUtils.globToRegex', { internal: true, }],
|
||||
['Root.initialize', { internal: true, }],
|
||||
['Playwright.newRequest', { title: 'Create request context', }],
|
||||
['DebugController.initialize', { internal: true, }],
|
||||
['DebugController.setReportStateChanged', { internal: true, }],
|
||||
['DebugController.setRecorderMode', { internal: true, }],
|
||||
['DebugController.highlight', { internal: true, }],
|
||||
['DebugController.hideHighlight', { internal: true, }],
|
||||
['DebugController.resume', { internal: true, }],
|
||||
['DebugController.kill', { internal: true, }],
|
||||
['SocksSupport.socksConnected', { internal: true, }],
|
||||
['SocksSupport.socksFailed', { internal: true, }],
|
||||
['SocksSupport.socksData', { internal: true, }],
|
||||
['SocksSupport.socksError', { internal: true, }],
|
||||
['SocksSupport.socksEnd', { internal: true, }],
|
||||
['BrowserType.launch', { title: 'Launch browser', }],
|
||||
['BrowserType.launchPersistentContext', { title: 'Launch persistent context', }],
|
||||
['BrowserType.connectOverCDP', { title: 'Connect over CDP', }],
|
||||
['BrowserType.connectOverCDPTransport', { title: 'Connect over CDP transport', }],
|
||||
['BrowserType.connectToWorker', { title: 'Connect to worker', }],
|
||||
['Browser.startServer', { title: 'Start server', }],
|
||||
['Browser.stopServer', { title: 'Stop server', }],
|
||||
['Browser.close', { title: 'Close browser', pause: true, }],
|
||||
['Browser.killForTests', { internal: true, }],
|
||||
['Browser.defaultUserAgentForTest', { internal: true, }],
|
||||
['Browser.newContext', { title: 'Create context', }],
|
||||
['Browser.newContextForReuse', { internal: true, }],
|
||||
['Browser.disconnectFromReusedContext', { internal: true, }],
|
||||
['Browser.newBrowserCDPSession', { title: 'Create CDP session', group: 'configuration', }],
|
||||
['Browser.startTracing', { title: 'Start browser tracing', group: 'configuration', }],
|
||||
['Browser.stopTracing', { title: 'Stop browser tracing', group: 'configuration', }],
|
||||
['EventTarget.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
|
||||
['BrowserContext.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
|
||||
['Page.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
|
||||
['Worker.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
|
||||
['WebSocket.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
|
||||
['Debugger.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
|
||||
['ElectronApplication.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
|
||||
['AndroidDevice.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
|
||||
['BrowserContext.addCookies', { title: 'Add cookies', group: 'configuration', }],
|
||||
['BrowserContext.addInitScript', { title: 'Add init script', group: 'configuration', }],
|
||||
['BrowserContext.clearCookies', { title: 'Clear cookies', group: 'configuration', }],
|
||||
['BrowserContext.clearPermissions', { title: 'Clear permissions', group: 'configuration', }],
|
||||
['BrowserContext.close', { title: 'Close context', pause: true, }],
|
||||
['BrowserContext.cookies', { title: 'Get cookies', group: 'getter', }],
|
||||
['BrowserContext.exposeBinding', { title: 'Expose binding', group: 'configuration', }],
|
||||
['BrowserContext.grantPermissions', { title: 'Grant permissions', group: 'configuration', }],
|
||||
['BrowserContext.newPage', { title: 'Create page', }],
|
||||
['BrowserContext.registerSelectorEngine', { internal: true, }],
|
||||
['BrowserContext.setTestIdAttributeName', { internal: true, }],
|
||||
['BrowserContext.setExtraHTTPHeaders', { title: 'Set extra HTTP headers', group: 'configuration', }],
|
||||
['BrowserContext.setGeolocation', { title: 'Set geolocation', group: 'configuration', }],
|
||||
['BrowserContext.setHTTPCredentials', { title: 'Set HTTP credentials', group: 'configuration', }],
|
||||
['BrowserContext.setNetworkInterceptionPatterns', { title: 'Route requests', group: 'route', }],
|
||||
['BrowserContext.setWebSocketInterceptionPatterns', { title: 'Route WebSockets', group: 'route', }],
|
||||
['BrowserContext.setOffline', { title: 'Set offline mode', }],
|
||||
['BrowserContext.storageState', { title: 'Get storage state', group: 'configuration', }],
|
||||
['BrowserContext.setStorageState', { title: 'Set storage state', group: 'configuration', }],
|
||||
['BrowserContext.pause', { title: 'Pause', }],
|
||||
['BrowserContext.enableRecorder', { internal: true, }],
|
||||
['BrowserContext.disableRecorder', { internal: true, }],
|
||||
['BrowserContext.exposeConsoleApi', { internal: true, }],
|
||||
['BrowserContext.newCDPSession', { title: 'Create CDP session', group: 'configuration', }],
|
||||
['BrowserContext.createTempFiles', { internal: true, }],
|
||||
['BrowserContext.updateSubscription', { internal: true, }],
|
||||
['BrowserContext.clockFastForward', { title: 'Fast forward clock "{ticksNumber|ticksString}"', }],
|
||||
['BrowserContext.clockInstall', { title: 'Install clock "{timeNumber|timeString}"', }],
|
||||
['BrowserContext.clockPauseAt', { title: 'Pause clock "{timeNumber|timeString}"', }],
|
||||
['BrowserContext.clockResume', { title: 'Resume clock', }],
|
||||
['BrowserContext.clockRunFor', { title: 'Run clock "{ticksNumber|ticksString}"', }],
|
||||
['BrowserContext.clockSetFixedTime', { title: 'Set fixed time "{timeNumber|timeString}"', }],
|
||||
['BrowserContext.clockSetSystemTime', { title: 'Set system time "{timeNumber|timeString}"', }],
|
||||
['Page.addInitScript', { title: 'Add init script', group: 'configuration', }],
|
||||
['Page.close', { title: 'Close page', pause: true, }],
|
||||
['Page.clearConsoleMessages', { title: 'Clear console messages', }],
|
||||
['Page.consoleMessages', { title: 'Get console messages', group: 'getter', }],
|
||||
['Page.emulateMedia', { title: 'Emulate media', snapshot: true, pause: true, }],
|
||||
['Page.exposeBinding', { title: 'Expose binding', group: 'configuration', }],
|
||||
['Page.goBack', { title: 'Go back', slowMo: true, snapshot: true, pause: true, }],
|
||||
['Page.goForward', { title: 'Go forward', slowMo: true, snapshot: true, pause: true, }],
|
||||
['Page.requestGC', { title: 'Request garbage collection', group: 'configuration', }],
|
||||
['Page.registerLocatorHandler', { title: 'Register locator handler', }],
|
||||
['Page.resolveLocatorHandlerNoReply', { internal: true, }],
|
||||
['Page.unregisterLocatorHandler', { title: 'Unregister locator handler', }],
|
||||
['Page.reload', { title: 'Reload', slowMo: true, snapshot: true, pause: true, }],
|
||||
['Page.expectScreenshot', { title: 'Expect screenshot', snapshot: true, pause: true, }],
|
||||
['Page.screenshot', { title: 'Screenshot', snapshot: true, pause: true, }],
|
||||
['Page.setExtraHTTPHeaders', { title: 'Set extra HTTP headers', group: 'configuration', }],
|
||||
['Page.setNetworkInterceptionPatterns', { title: 'Route requests', group: 'route', }],
|
||||
['Page.setWebSocketInterceptionPatterns', { title: 'Route WebSockets', group: 'route', }],
|
||||
['Page.setViewportSize', { title: 'Set viewport size', snapshot: true, pause: true, }],
|
||||
['Page.keyboardDown', { title: 'Key down "{key}"', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.keyboardUp', { title: 'Key up "{key}"', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.keyboardInsertText', { title: 'Insert "{text}"', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.keyboardType', { title: 'Type "{text}"', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.keyboardPress', { title: 'Press "{key}"', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.mouseMove', { title: 'Mouse move', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.mouseDown', { title: 'Mouse down', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.mouseUp', { title: 'Mouse up', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.mouseClick', { title: 'Click', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.mouseWheel', { title: 'Mouse wheel', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.touchscreenTap', { title: 'Tap', slowMo: true, snapshot: true, pause: true, input: true, }],
|
||||
['Page.clearPageErrors', { title: 'Clear page errors', }],
|
||||
['Page.pageErrors', { title: 'Get page errors', group: 'getter', }],
|
||||
['Page.pdf', { title: 'PDF', }],
|
||||
['Page.requests', { title: 'Get network requests', group: 'getter', }],
|
||||
['Page.startJSCoverage', { title: 'Start JS coverage', group: 'configuration', }],
|
||||
['Page.stopJSCoverage', { title: 'Stop JS coverage', group: 'configuration', }],
|
||||
['Page.startCSSCoverage', { title: 'Start CSS coverage', group: 'configuration', }],
|
||||
['Page.stopCSSCoverage', { title: 'Stop CSS coverage', group: 'configuration', }],
|
||||
['Page.bringToFront', { title: 'Bring to front', }],
|
||||
['Page.pickLocator', { title: 'Pick locator', group: 'configuration', }],
|
||||
['Page.cancelPickLocator', { title: 'Cancel pick locator', group: 'configuration', }],
|
||||
['Page.screencastShowOverlay', { title: 'Show overlay', group: 'configuration', }],
|
||||
['Page.screencastRemoveOverlay', { title: 'Remove overlay', group: 'configuration', }],
|
||||
['Page.screencastChapter', { title: 'Show chapter overlay', group: 'configuration', }],
|
||||
['Page.screencastSetOverlayVisible', { title: 'Set overlay visibility', group: 'configuration', }],
|
||||
['Page.screencastShowActions', { title: 'Show actions', group: 'configuration', }],
|
||||
['Page.screencastHideActions', { title: 'Remove actions', group: 'configuration', }],
|
||||
['Page.screencastStart', { title: 'Start screencast', group: 'configuration', }],
|
||||
['Page.screencastStop', { title: 'Stop screencast', group: 'configuration', }],
|
||||
['Page.updateSubscription', { internal: true, }],
|
||||
['Page.setDockTile', { internal: true, }],
|
||||
['Frame.evalOnSelector', { title: 'Evaluate', snapshot: true, pause: true, }],
|
||||
['Frame.evalOnSelectorAll', { title: 'Evaluate', snapshot: true, pause: true, }],
|
||||
['Frame.addScriptTag', { title: 'Add script tag', snapshot: true, pause: true, }],
|
||||
['Frame.addStyleTag', { title: 'Add style tag', snapshot: true, pause: true, }],
|
||||
['Frame.ariaSnapshot', { title: 'Aria snapshot', group: 'getter', }],
|
||||
['Frame.blur', { title: 'Blur', slowMo: true, snapshot: true, pause: true, }],
|
||||
['Frame.check', { title: 'Check', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.click', { title: 'Click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.content', { title: 'Get content', snapshot: true, pause: true, }],
|
||||
['Frame.dragAndDrop', { title: 'Drag and drop', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.dblclick', { title: 'Double click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.dispatchEvent', { title: 'Dispatch "{type}"', slowMo: true, snapshot: true, pause: true, }],
|
||||
['Frame.evaluateExpression', { title: 'Evaluate', snapshot: true, pause: true, }],
|
||||
['Frame.evaluateExpressionHandle', { title: 'Evaluate', snapshot: true, pause: true, }],
|
||||
['Frame.fill', { title: 'Fill "{value}"', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.focus', { title: 'Focus', slowMo: true, snapshot: true, pause: true, }],
|
||||
['Frame.frameElement', { title: 'Get frame element', group: 'getter', }],
|
||||
['Frame.resolveSelector', { internal: true, }],
|
||||
['Frame.highlight', { title: 'Highlight element', group: 'configuration', }],
|
||||
['Frame.getAttribute', { title: 'Get attribute "{name}"', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.goto', { title: 'Navigate to "{url}"', slowMo: true, snapshot: true, pause: true, }],
|
||||
['Frame.hover', { title: 'Hover', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.innerHTML', { title: 'Get HTML', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.innerText', { title: 'Get inner text', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.inputValue', { title: 'Get input value', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.isChecked', { title: 'Is checked', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.isDisabled', { title: 'Is disabled', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.isEnabled', { title: 'Is enabled', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.isHidden', { title: 'Is hidden', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.isVisible', { title: 'Is visible', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.isEditable', { title: 'Is editable', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.press', { title: 'Press "{key}"', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.querySelector', { title: 'Query selector', snapshot: true, }],
|
||||
['Frame.querySelectorAll', { title: 'Query selector all', snapshot: true, }],
|
||||
['Frame.queryCount', { title: 'Query count', snapshot: true, pause: true, }],
|
||||
['Frame.selectOption', { title: 'Select option', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.setContent', { title: 'Set content', snapshot: true, pause: true, }],
|
||||
['Frame.setInputFiles', { title: 'Set input files', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.tap', { title: 'Tap', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.textContent', { title: 'Get text content', snapshot: true, pause: true, group: 'getter', }],
|
||||
['Frame.title', { title: 'Get page title', group: 'getter', }],
|
||||
['Frame.type', { title: 'Type "{text}"', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.uncheck', { title: 'Uncheck', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['Frame.waitForTimeout', { title: 'Wait for timeout', snapshot: true, }],
|
||||
['Frame.waitForFunction', { title: 'Wait for function', snapshot: true, pause: true, }],
|
||||
['Frame.waitForSelector', { title: 'Wait for selector', snapshot: true, }],
|
||||
['Frame.expect', { title: 'Expect "{expression}"', snapshot: true, pause: true, }],
|
||||
['Worker.disconnect', { title: 'Disconnect from worker', }],
|
||||
['Worker.evaluateExpression', { title: 'Evaluate', }],
|
||||
['Worker.evaluateExpressionHandle', { title: 'Evaluate', }],
|
||||
['Worker.updateSubscription', { internal: true, }],
|
||||
['Disposable.dispose', { internal: true, }],
|
||||
['JSHandle.dispose', { internal: true, }],
|
||||
['ElementHandle.dispose', { internal: true, }],
|
||||
['JSHandle.evaluateExpression', { title: 'Evaluate', snapshot: true, pause: true, }],
|
||||
['ElementHandle.evaluateExpression', { title: 'Evaluate', snapshot: true, pause: true, }],
|
||||
['JSHandle.evaluateExpressionHandle', { title: 'Evaluate', snapshot: true, pause: true, }],
|
||||
['ElementHandle.evaluateExpressionHandle', { title: 'Evaluate', snapshot: true, pause: true, }],
|
||||
['JSHandle.getPropertyList', { title: 'Get property list', group: 'getter', }],
|
||||
['ElementHandle.getPropertyList', { title: 'Get property list', group: 'getter', }],
|
||||
['JSHandle.getProperty', { title: 'Get JS property', group: 'getter', }],
|
||||
['ElementHandle.getProperty', { title: 'Get JS property', group: 'getter', }],
|
||||
['JSHandle.jsonValue', { title: 'Get JSON value', group: 'getter', }],
|
||||
['ElementHandle.jsonValue', { title: 'Get JSON value', group: 'getter', }],
|
||||
['ElementHandle.evalOnSelector', { title: 'Evaluate', snapshot: true, pause: true, }],
|
||||
['ElementHandle.evalOnSelectorAll', { title: 'Evaluate', snapshot: true, pause: true, }],
|
||||
['ElementHandle.boundingBox', { title: 'Get bounding box', snapshot: true, pause: true, }],
|
||||
['ElementHandle.check', { title: 'Check', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.click', { title: 'Click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.contentFrame', { title: 'Get content frame', group: 'getter', }],
|
||||
['ElementHandle.dblclick', { title: 'Double click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.dispatchEvent', { title: 'Dispatch event', slowMo: true, snapshot: true, pause: true, }],
|
||||
['ElementHandle.fill', { title: 'Fill "{value}"', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.focus', { title: 'Focus', slowMo: true, snapshot: true, pause: true, }],
|
||||
['ElementHandle.getAttribute', { title: 'Get attribute', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.hover', { title: 'Hover', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.innerHTML', { title: 'Get HTML', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.innerText', { title: 'Get inner text', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.inputValue', { title: 'Get input value', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.isChecked', { title: 'Is checked', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.isDisabled', { title: 'Is disabled', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.isEditable', { title: 'Is editable', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.isEnabled', { title: 'Is enabled', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.isHidden', { title: 'Is hidden', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.isVisible', { title: 'Is visible', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.ownerFrame', { title: 'Get owner frame', group: 'getter', }],
|
||||
['ElementHandle.press', { title: 'Press "{key}"', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.querySelector', { title: 'Query selector', snapshot: true, }],
|
||||
['ElementHandle.querySelectorAll', { title: 'Query selector all', snapshot: true, }],
|
||||
['ElementHandle.screenshot', { title: 'Screenshot', snapshot: true, pause: true, }],
|
||||
['ElementHandle.scrollIntoViewIfNeeded', { title: 'Scroll into view', slowMo: true, snapshot: true, pause: true, }],
|
||||
['ElementHandle.selectOption', { title: 'Select option', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.selectText', { title: 'Select text', slowMo: true, snapshot: true, pause: true, }],
|
||||
['ElementHandle.setInputFiles', { title: 'Set input files', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.tap', { title: 'Tap', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.textContent', { title: 'Get text content', snapshot: true, pause: true, group: 'getter', }],
|
||||
['ElementHandle.type', { title: 'Type', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.uncheck', { title: 'Uncheck', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
|
||||
['ElementHandle.waitForElementState', { title: 'Wait for state', snapshot: true, pause: true, }],
|
||||
['ElementHandle.waitForSelector', { title: 'Wait for selector', snapshot: true, }],
|
||||
['Request.response', { internal: true, }],
|
||||
['Request.rawRequestHeaders', { internal: true, }],
|
||||
['Route.redirectNavigationRequest', { internal: true, }],
|
||||
['Route.abort', { title: 'Abort request', group: 'route', }],
|
||||
['Route.continue', { title: 'Continue request', group: 'route', }],
|
||||
['Route.fulfill', { title: 'Fulfill request', group: 'route', }],
|
||||
['WebSocketRoute.connect', { title: 'Connect WebSocket to server', group: 'route', }],
|
||||
['WebSocketRoute.ensureOpened', { internal: true, }],
|
||||
['WebSocketRoute.sendToPage', { title: 'Send WebSocket message', group: 'route', }],
|
||||
['WebSocketRoute.sendToServer', { title: 'Send WebSocket message', group: 'route', }],
|
||||
['WebSocketRoute.closePage', { internal: true, }],
|
||||
['WebSocketRoute.closeServer', { internal: true, }],
|
||||
['Response.body', { title: 'Get response body', group: 'getter', }],
|
||||
['Response.securityDetails', { internal: true, }],
|
||||
['Response.serverAddr', { internal: true, }],
|
||||
['Response.rawResponseHeaders', { internal: true, }],
|
||||
['Response.httpVersion', { internal: true, }],
|
||||
['Response.sizes', { internal: true, }],
|
||||
['BindingCall.reject', { internal: true, }],
|
||||
['BindingCall.resolve', { internal: true, }],
|
||||
['Debugger.requestPause', { title: 'Pause on next call', group: 'configuration', }],
|
||||
['Debugger.resume', { title: 'Resume', group: 'configuration', }],
|
||||
['Debugger.next', { title: 'Step to next call', group: 'configuration', }],
|
||||
['Debugger.runTo', { title: 'Run to location', group: 'configuration', }],
|
||||
['Dialog.accept', { title: 'Accept dialog', }],
|
||||
['Dialog.dismiss', { title: 'Dismiss dialog', }],
|
||||
['Tracing.tracingStart', { title: 'Start tracing', group: 'configuration', }],
|
||||
['Tracing.tracingStartChunk', { title: 'Start tracing', group: 'configuration', }],
|
||||
['Tracing.tracingGroup', { title: 'Trace "{name}"', }],
|
||||
['Tracing.tracingGroupEnd', { title: 'Group end', }],
|
||||
['Tracing.tracingStopChunk', { title: 'Stop tracing', group: 'configuration', }],
|
||||
['Tracing.tracingStop', { title: 'Stop tracing', group: 'configuration', }],
|
||||
['Tracing.harStart', { internal: true, }],
|
||||
['Tracing.harExport', { internal: true, }],
|
||||
['Artifact.pathAfterFinished', { internal: true, }],
|
||||
['Artifact.saveAs', { internal: true, }],
|
||||
['Artifact.saveAsStream', { internal: true, }],
|
||||
['Artifact.failure', { internal: true, }],
|
||||
['Artifact.stream', { internal: true, }],
|
||||
['Artifact.cancel', { internal: true, }],
|
||||
['Artifact.delete', { internal: true, }],
|
||||
['Stream.read', { internal: true, }],
|
||||
['Stream.close', { internal: true, }],
|
||||
['WritableStream.write', { internal: true, }],
|
||||
['WritableStream.close', { internal: true, }],
|
||||
['CDPSession.send', { title: 'Send CDP command', group: 'configuration', }],
|
||||
['CDPSession.detach', { title: 'Detach CDP session', group: 'configuration', }],
|
||||
['Electron.launch', { title: 'Launch electron', }],
|
||||
['ElectronApplication.browserWindow', { internal: true, }],
|
||||
['ElectronApplication.evaluateExpression', { title: 'Evaluate', }],
|
||||
['ElectronApplication.evaluateExpressionHandle', { title: 'Evaluate', }],
|
||||
['ElectronApplication.updateSubscription', { internal: true, }],
|
||||
['Android.devices', { internal: true, }],
|
||||
['AndroidSocket.write', { internal: true, }],
|
||||
['AndroidSocket.close', { internal: true, }],
|
||||
['AndroidDevice.wait', { title: 'Wait', }],
|
||||
['AndroidDevice.fill', { title: 'Fill "{text}"', }],
|
||||
['AndroidDevice.tap', { title: 'Tap', }],
|
||||
['AndroidDevice.drag', { title: 'Drag', }],
|
||||
['AndroidDevice.fling', { title: 'Fling', }],
|
||||
['AndroidDevice.longTap', { title: 'Long tap', }],
|
||||
['AndroidDevice.pinchClose', { title: 'Pinch close', }],
|
||||
['AndroidDevice.pinchOpen', { title: 'Pinch open', }],
|
||||
['AndroidDevice.scroll', { title: 'Scroll', }],
|
||||
['AndroidDevice.swipe', { title: 'Swipe', }],
|
||||
['AndroidDevice.info', { internal: true, }],
|
||||
['AndroidDevice.screenshot', { title: 'Screenshot', }],
|
||||
['AndroidDevice.inputType', { title: 'Type', }],
|
||||
['AndroidDevice.inputPress', { title: 'Press', }],
|
||||
['AndroidDevice.inputTap', { title: 'Tap', }],
|
||||
['AndroidDevice.inputSwipe', { title: 'Swipe', }],
|
||||
['AndroidDevice.inputDrag', { title: 'Drag', }],
|
||||
['AndroidDevice.launchBrowser', { title: 'Launch browser', }],
|
||||
['AndroidDevice.open', { title: 'Open app', }],
|
||||
['AndroidDevice.shell', { title: 'Execute shell command', group: 'configuration', }],
|
||||
['AndroidDevice.installApk', { title: 'Install apk', }],
|
||||
['AndroidDevice.push', { title: 'Push', }],
|
||||
['AndroidDevice.connectToWebView', { title: 'Connect to Web View', }],
|
||||
['AndroidDevice.close', { internal: true, }],
|
||||
['JsonPipe.send', { internal: true, }],
|
||||
['JsonPipe.close', { internal: true, }]
|
||||
]);
|
||||
|
||||
export function getMetainfo(metadata: { type: string, method: string }): MethodMetainfo | undefined {
|
||||
return methodMetainfo.get(metadata.type + '.' + metadata.method);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { isString } from './stringUtils';
|
||||
|
||||
export function isRegExp(obj: any): obj is RegExp {
|
||||
return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]';
|
||||
}
|
||||
|
||||
export function isObject(obj: any): obj is NonNullable<object> {
|
||||
return typeof obj === 'object' && obj !== null;
|
||||
}
|
||||
|
||||
export function isError(obj: any): obj is Error {
|
||||
return obj instanceof Error || (obj && Object.getPrototypeOf(obj)?.name === 'Error');
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { InvalidSelectorError, parseCSS } from './cssParser';
|
||||
|
||||
import type { CSSComplexSelectorList } from './cssParser';
|
||||
export { InvalidSelectorError, isInvalidSelectorError } from './cssParser';
|
||||
|
||||
export type NestedSelectorBody = { parsed: ParsedSelector, distance?: number };
|
||||
const kNestedSelectorNames = new Set(['internal:has', 'internal:has-not', 'internal:and', 'internal:or', 'internal:chain', 'left-of', 'right-of', 'above', 'below', 'near']);
|
||||
const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']);
|
||||
|
||||
export type ParsedSelectorPart = {
|
||||
name: string,
|
||||
body: string | CSSComplexSelectorList | NestedSelectorBody,
|
||||
source: string,
|
||||
};
|
||||
|
||||
export type ParsedSelector = {
|
||||
parts: ParsedSelectorPart[],
|
||||
capture?: number,
|
||||
};
|
||||
|
||||
type ParsedSelectorStrings = {
|
||||
parts: { name: string, body: string }[],
|
||||
capture?: number,
|
||||
};
|
||||
|
||||
export const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'has-text', 'above', 'below', 'right-of', 'left-of', 'near', 'nth-match']);
|
||||
|
||||
export function parseSelector(selector: string): ParsedSelector {
|
||||
const parsedStrings = parseSelectorString(selector);
|
||||
const parts: ParsedSelectorPart[] = [];
|
||||
for (const part of parsedStrings.parts) {
|
||||
if (part.name === 'css' || part.name === 'css:light') {
|
||||
if (part.name === 'css:light')
|
||||
part.body = ':light(' + part.body + ')';
|
||||
const parsedCSS = parseCSS(part.body, customCSSNames);
|
||||
parts.push({
|
||||
name: 'css',
|
||||
body: parsedCSS.selector,
|
||||
source: part.body
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (kNestedSelectorNames.has(part.name)) {
|
||||
let innerSelector: string;
|
||||
let distance: number | undefined;
|
||||
try {
|
||||
const unescaped = JSON.parse('[' + part.body + ']');
|
||||
if (!Array.isArray(unescaped) || unescaped.length < 1 || unescaped.length > 2 || typeof unescaped[0] !== 'string')
|
||||
throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body);
|
||||
innerSelector = unescaped[0];
|
||||
if (unescaped.length === 2) {
|
||||
if (typeof unescaped[1] !== 'number' || !kNestedSelectorNamesWithDistance.has(part.name))
|
||||
throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body);
|
||||
distance = unescaped[1];
|
||||
}
|
||||
} catch (e) {
|
||||
throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body);
|
||||
}
|
||||
const nested = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } };
|
||||
const lastFrame = [...nested.body.parsed.parts].reverse().find(part => part.name === 'internal:control' && part.body === 'enter-frame');
|
||||
const lastFrameIndex = lastFrame ? nested.body.parsed.parts.indexOf(lastFrame) : -1;
|
||||
// Allow nested selectors to start with the same frame selector.
|
||||
if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), parts.slice(0, lastFrameIndex + 1)))
|
||||
nested.body.parsed.parts.splice(0, lastFrameIndex + 1);
|
||||
parts.push(nested);
|
||||
continue;
|
||||
}
|
||||
parts.push({ ...part, source: part.body });
|
||||
}
|
||||
if (kNestedSelectorNames.has(parts[0].name))
|
||||
throw new InvalidSelectorError(`"${parts[0].name}" selector cannot be first`);
|
||||
return {
|
||||
capture: parsedStrings.capture,
|
||||
parts
|
||||
};
|
||||
}
|
||||
|
||||
export function splitSelectorByFrame(selectorText: string): ParsedSelector[] {
|
||||
const selector = parseSelector(selectorText);
|
||||
const result: ParsedSelector[] = [];
|
||||
let chunk: ParsedSelector = {
|
||||
parts: [],
|
||||
};
|
||||
let chunkStartIndex = 0;
|
||||
for (let i = 0; i < selector.parts.length; ++i) {
|
||||
const part = selector.parts[i];
|
||||
if (part.name === 'internal:control' && part.body === 'enter-frame') {
|
||||
if (!chunk.parts.length)
|
||||
throw new InvalidSelectorError('Selector cannot start with entering frame, select the iframe first');
|
||||
result.push(chunk);
|
||||
chunk = { parts: [] };
|
||||
chunkStartIndex = i + 1;
|
||||
continue;
|
||||
}
|
||||
if (selector.capture === i)
|
||||
chunk.capture = i - chunkStartIndex;
|
||||
chunk.parts.push(part);
|
||||
}
|
||||
if (!chunk.parts.length)
|
||||
throw new InvalidSelectorError(`Selector cannot end with entering frame, while parsing selector ${selectorText}`);
|
||||
result.push(chunk);
|
||||
if (typeof selector.capture === 'number' && typeof result[result.length - 1].capture !== 'number')
|
||||
throw new InvalidSelectorError(`Can not capture the selector before diving into the frame. Only use * after the last frame has been selected`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function selectorPartsEqual(list1: ParsedSelectorPart[], list2: ParsedSelectorPart[]) {
|
||||
return stringifySelector({ parts: list1 }) === stringifySelector({ parts: list2 });
|
||||
}
|
||||
|
||||
export function stringifySelector(selector: string | ParsedSelector, forceEngineName?: boolean): string {
|
||||
if (typeof selector === 'string')
|
||||
return selector;
|
||||
return selector.parts.map((p, i) => {
|
||||
let includeEngine = true;
|
||||
if (!forceEngineName && i !== selector.capture) {
|
||||
if (p.name === 'css')
|
||||
includeEngine = false;
|
||||
else if (p.name === 'xpath' && p.source.startsWith('//') || p.source.startsWith('..'))
|
||||
includeEngine = false;
|
||||
}
|
||||
const prefix = includeEngine ? p.name + '=' : '';
|
||||
return `${i === selector.capture ? '*' : ''}${prefix}${p.source}`;
|
||||
}).join(' >> ');
|
||||
}
|
||||
|
||||
export function visitAllSelectorParts(selector: ParsedSelector, visitor: (part: ParsedSelectorPart, nested: boolean) => void) {
|
||||
const visit = (selector: ParsedSelector, nested: boolean) => {
|
||||
for (const part of selector.parts) {
|
||||
visitor(part, nested);
|
||||
if (kNestedSelectorNames.has(part.name))
|
||||
visit((part.body as NestedSelectorBody).parsed, true);
|
||||
}
|
||||
};
|
||||
visit(selector, false);
|
||||
}
|
||||
|
||||
function parseSelectorString(selector: string): ParsedSelectorStrings {
|
||||
let index = 0;
|
||||
let quote: string | undefined;
|
||||
let start = 0;
|
||||
const result: ParsedSelectorStrings = { parts: [] };
|
||||
const append = () => {
|
||||
const part = selector.substring(start, index).trim();
|
||||
const eqIndex = part.indexOf('=');
|
||||
let name: string;
|
||||
let body: string;
|
||||
if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) {
|
||||
name = part.substring(0, eqIndex).trim();
|
||||
body = part.substring(eqIndex + 1);
|
||||
} else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') {
|
||||
name = 'text';
|
||||
body = part;
|
||||
} else if (part.length > 1 && part[0] === "'" && part[part.length - 1] === "'") {
|
||||
name = 'text';
|
||||
body = part;
|
||||
} else if (/^\(*\/\//.test(part) || part.startsWith('..')) {
|
||||
// If selector starts with '//' or '//' prefixed with multiple opening
|
||||
// parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817
|
||||
// If selector starts with '..', consider xpath as well.
|
||||
name = 'xpath';
|
||||
body = part;
|
||||
} else {
|
||||
name = 'css';
|
||||
body = part;
|
||||
}
|
||||
let capture = false;
|
||||
if (name[0] === '*') {
|
||||
capture = true;
|
||||
name = name.substring(1);
|
||||
}
|
||||
result.parts.push({ name, body });
|
||||
if (capture) {
|
||||
if (result.capture !== undefined)
|
||||
throw new InvalidSelectorError(`Only one of the selectors can capture using * modifier`);
|
||||
result.capture = result.parts.length - 1;
|
||||
}
|
||||
};
|
||||
|
||||
if (!selector.includes('>>')) {
|
||||
index = selector.length;
|
||||
append();
|
||||
return result;
|
||||
}
|
||||
|
||||
const shouldIgnoreTextSelectorQuote = () => {
|
||||
const prefix = selector.substring(start, index);
|
||||
const match = prefix.match(/^\s*text\s*=(.*)$/);
|
||||
// Must be a text selector with some text before the quote.
|
||||
return !!match && !!match[1];
|
||||
};
|
||||
|
||||
while (index < selector.length) {
|
||||
const c = selector[index];
|
||||
if (c === '\\' && index + 1 < selector.length) {
|
||||
index += 2;
|
||||
} else if (c === quote) {
|
||||
quote = undefined;
|
||||
index++;
|
||||
} else if (!quote && (c === '"' || c === '\'' || c === '`') && !shouldIgnoreTextSelectorQuote()) {
|
||||
quote = c;
|
||||
index++;
|
||||
} else if (!quote && c === '>' && selector[index + 1] === '>') {
|
||||
append();
|
||||
index += 2;
|
||||
start = index;
|
||||
} else {
|
||||
index++;
|
||||
}
|
||||
}
|
||||
append();
|
||||
return result;
|
||||
}
|
||||
|
||||
export type AttributeSelectorOperator = '<truthy>'|'='|'*='|'|='|'^='|'$='|'~=';
|
||||
export type AttributeSelectorPart = {
|
||||
name: string,
|
||||
jsonPath: string[],
|
||||
op: AttributeSelectorOperator,
|
||||
value: any,
|
||||
caseSensitive: boolean,
|
||||
};
|
||||
|
||||
export type AttributeSelector = {
|
||||
name: string,
|
||||
attributes: AttributeSelectorPart[],
|
||||
};
|
||||
|
||||
|
||||
export function parseAttributeSelector(selector: string, allowUnquotedStrings: boolean): AttributeSelector {
|
||||
let wp = 0;
|
||||
let EOL = selector.length === 0;
|
||||
|
||||
const next = () => selector[wp] || '';
|
||||
const eat1 = () => {
|
||||
const result = next();
|
||||
++wp;
|
||||
EOL = wp >= selector.length;
|
||||
return result;
|
||||
};
|
||||
|
||||
const syntaxError = (stage: string|undefined) => {
|
||||
if (EOL)
|
||||
throw new InvalidSelectorError(`Unexpected end of selector while parsing selector \`${selector}\``);
|
||||
throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + (stage ? ' during ' + stage : ''));
|
||||
};
|
||||
|
||||
function skipSpaces() {
|
||||
while (!EOL && /\s/.test(next()))
|
||||
eat1();
|
||||
}
|
||||
|
||||
function isCSSNameChar(char: string) {
|
||||
// https://www.w3.org/TR/css-syntax-3/#ident-token-diagram
|
||||
return (char >= '\u0080') // non-ascii
|
||||
|| (char >= '\u0030' && char <= '\u0039') // digit
|
||||
|| (char >= '\u0041' && char <= '\u005a') // uppercase letter
|
||||
|| (char >= '\u0061' && char <= '\u007a') // lowercase letter
|
||||
|| (char >= '\u0030' && char <= '\u0039') // digit
|
||||
|| char === '\u005f' // "_"
|
||||
|| char === '\u002d'; // "-"
|
||||
}
|
||||
|
||||
function readIdentifier() {
|
||||
let result = '';
|
||||
skipSpaces();
|
||||
while (!EOL && isCSSNameChar(next()))
|
||||
result += eat1();
|
||||
return result;
|
||||
}
|
||||
|
||||
function readQuotedString(quote: string) {
|
||||
let result = eat1();
|
||||
if (result !== quote)
|
||||
syntaxError('parsing quoted string');
|
||||
while (!EOL && next() !== quote) {
|
||||
if (next() === '\\')
|
||||
eat1();
|
||||
result += eat1();
|
||||
}
|
||||
if (next() !== quote)
|
||||
syntaxError('parsing quoted string');
|
||||
result += eat1();
|
||||
return result;
|
||||
}
|
||||
|
||||
function readRegularExpression() {
|
||||
if (eat1() !== '/')
|
||||
syntaxError('parsing regular expression');
|
||||
let source = '';
|
||||
let inClass = false;
|
||||
// https://262.ecma-international.org/11.0/#sec-literals-regular-expression-literals
|
||||
while (!EOL) {
|
||||
if (next() === '\\') {
|
||||
source += eat1();
|
||||
if (EOL)
|
||||
syntaxError('parsing regular expression');
|
||||
} else if (inClass && next() === ']') {
|
||||
inClass = false;
|
||||
} else if (!inClass && next() === '[') {
|
||||
inClass = true;
|
||||
} else if (!inClass && next() === '/') {
|
||||
break;
|
||||
}
|
||||
source += eat1();
|
||||
}
|
||||
if (eat1() !== '/')
|
||||
syntaxError('parsing regular expression');
|
||||
let flags = '';
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
|
||||
while (!EOL && next().match(/[dgimsuy]/))
|
||||
flags += eat1();
|
||||
try {
|
||||
return new RegExp(source, flags);
|
||||
} catch (e) {
|
||||
throw new InvalidSelectorError(`Error while parsing selector \`${selector}\`: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readAttributeToken() {
|
||||
let token = '';
|
||||
skipSpaces();
|
||||
if (next() === `'` || next() === `"`)
|
||||
token = readQuotedString(next()).slice(1, -1);
|
||||
else
|
||||
token = readIdentifier();
|
||||
if (!token)
|
||||
syntaxError('parsing property path');
|
||||
return token;
|
||||
}
|
||||
|
||||
function readOperator(): AttributeSelectorOperator {
|
||||
skipSpaces();
|
||||
let op = '';
|
||||
if (!EOL)
|
||||
op += eat1();
|
||||
if (!EOL && (op !== '='))
|
||||
op += eat1();
|
||||
if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op))
|
||||
syntaxError('parsing operator');
|
||||
return (op as AttributeSelectorOperator);
|
||||
}
|
||||
|
||||
function readAttribute(): AttributeSelectorPart {
|
||||
// skip leading [
|
||||
eat1();
|
||||
|
||||
// read attribute name:
|
||||
// foo.bar
|
||||
// 'foo' . "ba zz"
|
||||
const jsonPath = [];
|
||||
jsonPath.push(readAttributeToken());
|
||||
skipSpaces();
|
||||
while (next() === '.') {
|
||||
eat1();
|
||||
jsonPath.push(readAttributeToken());
|
||||
skipSpaces();
|
||||
}
|
||||
// check property is truthy: [enabled]
|
||||
if (next() === ']') {
|
||||
eat1();
|
||||
return { name: jsonPath.join('.'), jsonPath, op: '<truthy>', value: null, caseSensitive: false };
|
||||
}
|
||||
|
||||
const operator = readOperator();
|
||||
|
||||
let value = undefined;
|
||||
let caseSensitive = true;
|
||||
skipSpaces();
|
||||
if (next() === '/') {
|
||||
if (operator !== '=')
|
||||
throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression`);
|
||||
value = readRegularExpression();
|
||||
} else if (next() === `'` || next() === `"`) {
|
||||
value = readQuotedString(next()).slice(1, -1);
|
||||
skipSpaces();
|
||||
if (next() === 'i' || next() === 'I') {
|
||||
caseSensitive = false;
|
||||
eat1();
|
||||
} else if (next() === 's' || next() === 'S') {
|
||||
caseSensitive = true;
|
||||
eat1();
|
||||
}
|
||||
} else {
|
||||
value = '';
|
||||
while (!EOL && (isCSSNameChar(next()) || next() === '+' || next() === '.'))
|
||||
value += eat1();
|
||||
if (value === 'true') {
|
||||
value = true;
|
||||
} else if (value === 'false') {
|
||||
value = false;
|
||||
} else {
|
||||
if (!allowUnquotedStrings) {
|
||||
value = +value;
|
||||
if (Number.isNaN(value))
|
||||
syntaxError('parsing attribute value');
|
||||
}
|
||||
}
|
||||
}
|
||||
skipSpaces();
|
||||
if (next() !== ']')
|
||||
syntaxError('parsing attribute value');
|
||||
|
||||
eat1();
|
||||
if (operator !== '=' && typeof value !== 'string')
|
||||
throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`);
|
||||
return { name: jsonPath.join('.'), jsonPath, op: operator, value, caseSensitive };
|
||||
}
|
||||
|
||||
const result: AttributeSelector = {
|
||||
name: '',
|
||||
attributes: [],
|
||||
};
|
||||
result.name = readIdentifier();
|
||||
skipSpaces();
|
||||
while (next() === '[') {
|
||||
result.attributes.push(readAttribute());
|
||||
skipSpaces();
|
||||
}
|
||||
if (!EOL)
|
||||
syntaxError(undefined);
|
||||
if (!result.name && !result.attributes.length)
|
||||
throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - selector cannot be empty`);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ManualPromise } from './manualPromise';
|
||||
|
||||
export class Semaphore {
|
||||
private _max: number;
|
||||
private _acquired = 0;
|
||||
private _queue: ManualPromise[] = [];
|
||||
|
||||
constructor(max: number) {
|
||||
this._max = max;
|
||||
}
|
||||
|
||||
setMax(max: number) {
|
||||
this._max = max;
|
||||
}
|
||||
|
||||
acquire(): Promise<void> {
|
||||
const lock = new ManualPromise();
|
||||
this._queue.push(lock);
|
||||
this._flush();
|
||||
return lock;
|
||||
}
|
||||
|
||||
release() {
|
||||
--this._acquired;
|
||||
this._flush();
|
||||
}
|
||||
|
||||
private _flush() {
|
||||
while (this._acquired < this._max && this._queue.length) {
|
||||
++this._acquired;
|
||||
this._queue.shift()!.resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The MIT License (MIT)
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Copyright (c) 2016-2023 Isaac Z. Schlueter i@izs.me, James Talmage james@talmage.io (github.com/jamestalmage), and
|
||||
* Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
||||
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
||||
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
|
||||
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
||||
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
export type RawStack = string[];
|
||||
|
||||
export type StackFrame = {
|
||||
file: string,
|
||||
line: number,
|
||||
column: number,
|
||||
function?: string,
|
||||
};
|
||||
|
||||
export function captureRawStack(): RawStack {
|
||||
const stackTraceLimit = Error.stackTraceLimit;
|
||||
Error.stackTraceLimit = 50;
|
||||
const error = new Error();
|
||||
const stack = error.stack || '';
|
||||
Error.stackTraceLimit = stackTraceLimit;
|
||||
return stack.split('\n');
|
||||
}
|
||||
|
||||
export function parseStackFrame(text: string, pathSeparator: string, showInternalStackFrames: boolean): StackFrame | null {
|
||||
const match = text && text.match(re);
|
||||
if (!match)
|
||||
return null;
|
||||
|
||||
let fname = match[2];
|
||||
let file = match[7];
|
||||
if (!file)
|
||||
return null;
|
||||
if (!showInternalStackFrames && (file.startsWith('internal') || file.startsWith('node:')))
|
||||
return null;
|
||||
|
||||
const line = match[8];
|
||||
const column = match[9];
|
||||
const closeParen = match[11] === ')';
|
||||
|
||||
const frame: StackFrame = {
|
||||
file: '',
|
||||
line: 0,
|
||||
column: 0,
|
||||
};
|
||||
|
||||
if (line)
|
||||
frame.line = Number(line);
|
||||
|
||||
if (column)
|
||||
frame.column = Number(column);
|
||||
|
||||
if (closeParen && file) {
|
||||
// make sure parens are balanced
|
||||
// if we have a file like "asdf) [as foo] (xyz.js", then odds are
|
||||
// that the fname should be += " (asdf) [as foo]" and the file
|
||||
// should be just "xyz.js"
|
||||
// walk backwards from the end to find the last unbalanced (
|
||||
let closes = 0;
|
||||
for (let i = file.length - 1; i > 0; i--) {
|
||||
if (file.charAt(i) === ')') {
|
||||
closes++;
|
||||
} else if (file.charAt(i) === '(' && file.charAt(i - 1) === ' ') {
|
||||
closes--;
|
||||
if (closes === -1 && file.charAt(i - 1) === ' ') {
|
||||
const before = file.slice(0, i - 1);
|
||||
const after = file.slice(i + 1);
|
||||
file = after;
|
||||
fname += ` (${before}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fname) {
|
||||
const methodMatch = fname.match(methodRe);
|
||||
if (methodMatch)
|
||||
fname = methodMatch[1];
|
||||
}
|
||||
|
||||
if (file) {
|
||||
if (file.startsWith('file://'))
|
||||
file = fileURLToPath(file, pathSeparator);
|
||||
frame.file = file;
|
||||
}
|
||||
|
||||
if (fname)
|
||||
frame.function = fname;
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function rewriteErrorMessage<E extends Error>(e: E, newMessage: string): E {
|
||||
const lines: string[] = (e.stack?.split('\n') || []).filter(l => l.startsWith(' at '));
|
||||
e.message = newMessage;
|
||||
const errorTitle = `${e.name}: ${e.message}`;
|
||||
if (lines.length)
|
||||
e.stack = `${errorTitle}\n${lines.join('\n')}`;
|
||||
return e;
|
||||
}
|
||||
|
||||
export function stringifyStackFrames(frames: StackFrame[]): string[] {
|
||||
const stackLines: string[] = [];
|
||||
for (const frame of frames) {
|
||||
if (frame.function)
|
||||
stackLines.push(` at ${frame.function} (${frame.file}:${frame.line}:${frame.column})`);
|
||||
else
|
||||
stackLines.push(` at ${frame.file}:${frame.line}:${frame.column}`);
|
||||
}
|
||||
return stackLines;
|
||||
}
|
||||
|
||||
export function splitErrorMessage(message: string): { name: string, message: string } {
|
||||
const separationIdx = message.indexOf(':');
|
||||
return {
|
||||
name: separationIdx !== -1 ? message.slice(0, separationIdx) : '',
|
||||
message: separationIdx !== -1 && separationIdx + 2 <= message.length ? message.substring(separationIdx + 2) : message,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseErrorStack(stack: string, pathSeparator: string, showInternalStackFrames: boolean = false): {
|
||||
message: string;
|
||||
stackLines: string[];
|
||||
location?: StackFrame;
|
||||
} {
|
||||
const lines = stack.split('\n');
|
||||
let firstStackLine = lines.findIndex(line => line.startsWith(' at '));
|
||||
if (firstStackLine === -1)
|
||||
firstStackLine = lines.length;
|
||||
const message = lines.slice(0, firstStackLine).join('\n');
|
||||
const stackLines = lines.slice(firstStackLine);
|
||||
let location: StackFrame | undefined;
|
||||
for (const line of stackLines) {
|
||||
const frame = parseStackFrame(line, pathSeparator, showInternalStackFrames);
|
||||
if (!frame || !frame.file)
|
||||
continue;
|
||||
if (belongsToNodeModules(frame.file, pathSeparator))
|
||||
continue;
|
||||
location = { file: frame.file, column: frame.column || 0, line: frame.line || 0 };
|
||||
break;
|
||||
}
|
||||
return { message, stackLines, location };
|
||||
}
|
||||
|
||||
function belongsToNodeModules(file: string, pathSeparator: string) {
|
||||
return file.includes(`${pathSeparator}node_modules${pathSeparator}`);
|
||||
}
|
||||
|
||||
const re = new RegExp('^' +
|
||||
// Sometimes we strip out the ' at' because it's noisy
|
||||
'(?:\\s*at )?' +
|
||||
// $1 = ctor if 'new'
|
||||
'(?:(new) )?' +
|
||||
// $2 = function name (can be literally anything)
|
||||
// May contain method at the end as [as xyz]
|
||||
'(?:(.*?) \\()?' +
|
||||
// (eval at <anonymous> (file.js:1:1),
|
||||
// $3 = eval origin
|
||||
// $4:$5:$6 are eval file/line/col, but not normally reported
|
||||
'(?:eval at ([^ ]+) \\((.+?):(\\d+):(\\d+)\\), )?' +
|
||||
// file:line:col
|
||||
// $7:$8:$9
|
||||
// $10 = 'native' if native
|
||||
'(?:(.+?):(\\d+):(\\d+)|(native))' +
|
||||
// maybe close the paren, then end
|
||||
// if $11 is ), then we only allow balanced parens in the filename
|
||||
// any imbalance is placed on the fname. This is a heuristic, and
|
||||
// bound to be incorrect in some edge cases. The bet is that
|
||||
// having weird characters in method names is more common than
|
||||
// having weird characters in filenames, which seems reasonable.
|
||||
'(\\)?)$'
|
||||
);
|
||||
|
||||
const methodRe = /^(.*?) \[as (.*?)\]$/;
|
||||
|
||||
function fileURLToPath(fileUrl: string, pathSeparator: string): string {
|
||||
if (!fileUrl.startsWith('file://'))
|
||||
return fileUrl;
|
||||
|
||||
let path = decodeURIComponent(fileUrl.slice(7));
|
||||
if (path.startsWith('/') && /^[a-zA-Z]:/.test(path.slice(1)))
|
||||
path = path.slice(1);
|
||||
|
||||
return path.replace(/\//g, pathSeparator);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// NOTE: this function should not be used to escape any selectors.
|
||||
export function escapeWithQuotes(text: string, char: string = '\'') {
|
||||
const stringified = JSON.stringify(text);
|
||||
const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"');
|
||||
if (char === '\'')
|
||||
return char + escapedText.replace(/[']/g, '\\\'') + char;
|
||||
if (char === '"')
|
||||
return char + escapedText.replace(/["]/g, '\\"') + char;
|
||||
if (char === '`')
|
||||
return char + escapedText.replace(/[`]/g, '\\`') + char;
|
||||
throw new Error('Invalid escape char');
|
||||
}
|
||||
|
||||
export function escapeTemplateString(text: string): string {
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/\$\{/g, '\\${');
|
||||
}
|
||||
|
||||
export function isString(obj: any): obj is string {
|
||||
return typeof obj === 'string' || obj instanceof String;
|
||||
}
|
||||
|
||||
export function toTitleCase(name: string) {
|
||||
return name.charAt(0).toUpperCase() + name.substring(1);
|
||||
}
|
||||
|
||||
export function toSnakeCase(name: string): string {
|
||||
// E.g. ignoreHTTPSErrors => ignore_https_errors.
|
||||
return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z])([A-Z][a-z])/g, '$1_$2').toLowerCase();
|
||||
}
|
||||
|
||||
export function formatObject(value: any, indent = ' ', mode: 'multiline' | 'oneline' = 'multiline'): string {
|
||||
if (typeof value === 'string')
|
||||
return escapeWithQuotes(value, '\'');
|
||||
if (Array.isArray(value))
|
||||
return `[${value.map(o => formatObject(o)).join(', ')}]`;
|
||||
if (typeof value === 'object') {
|
||||
const keys = Object.keys(value).filter(key => key !== 'timeout' && value[key] !== undefined).sort();
|
||||
if (!keys.length)
|
||||
return '{}';
|
||||
const tokens: string[] = [];
|
||||
for (const key of keys)
|
||||
tokens.push(`${key}: ${formatObject(value[key])}`);
|
||||
if (mode === 'multiline')
|
||||
return `{\n${tokens.map(t => indent + t).join(`,\n`)}\n}`;
|
||||
return `{ ${tokens.join(', ')} }`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatObjectOrVoid(value: any, indent = ' '): string {
|
||||
const result = formatObject(value, indent);
|
||||
return result === '{}' ? '' : result;
|
||||
}
|
||||
|
||||
export function quoteCSSAttributeValue(text: string): string {
|
||||
return `"${text.replace(/["\\]/g, char => '\\' + char)}"`;
|
||||
}
|
||||
|
||||
let normalizedWhitespaceCache: Map<string, string> | undefined;
|
||||
|
||||
export function cacheNormalizedWhitespaces() {
|
||||
normalizedWhitespaceCache = new Map();
|
||||
}
|
||||
|
||||
export function normalizeWhiteSpace(text: string): string {
|
||||
let result = normalizedWhitespaceCache?.get(text);
|
||||
if (result === undefined) {
|
||||
result = text.replace(/[\u200b\u00ad]/g, '').trim().replace(/\s+/g, ' ');
|
||||
normalizedWhitespaceCache?.set(text, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function normalizeEscapedRegexQuotes(source: string) {
|
||||
// This function reverses the effect of escapeRegexForSelector below.
|
||||
// Odd number of backslashes followed by the quote -> remove unneeded backslash.
|
||||
return source.replace(/(^|[^\\])(\\\\)*\\(['"`])/g, '$1$2$3');
|
||||
}
|
||||
|
||||
function escapeRegexForSelector(re: RegExp): string {
|
||||
// Unicode mode does not allow "identity character escapes", so we do not escape and
|
||||
// hope that it does not contain quotes and/or >> signs.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_escape
|
||||
// TODO: rework RE usages in internal selectors away from literal representation to json, e.g. {source,flags}.
|
||||
if (re.unicode || (re as any).unicodeSets)
|
||||
return String(re);
|
||||
// Even number of backslashes followed by the quote -> insert a backslash.
|
||||
return String(re).replace(/(^|[^\\])(\\\\)*(["'`])/g, '$1$2\\$3').replace(/>>/g, '\\>\\>');
|
||||
}
|
||||
|
||||
export function escapeForTextSelector(text: string | RegExp, exact: boolean): string {
|
||||
if (typeof text !== 'string')
|
||||
return escapeRegexForSelector(text);
|
||||
return `${JSON.stringify(text)}${exact ? 's' : 'i'}`;
|
||||
}
|
||||
|
||||
export function escapeForAttributeSelector(value: string | RegExp, exact: boolean): string {
|
||||
if (typeof value !== 'string')
|
||||
return escapeRegexForSelector(value);
|
||||
// TODO: this should actually be
|
||||
// cssEscape(value).replace(/\\ /g, ' ')
|
||||
// However, our attribute selectors do not conform to CSS parsing spec,
|
||||
// so we escape them differently.
|
||||
return `"${value.replace(/\\/g, '\\\\').replace(/["]/g, '\\"')}"${exact ? 's' : 'i'}`;
|
||||
}
|
||||
|
||||
export function trimString(input: string, cap: number, suffix: string = ''): string {
|
||||
if (input.length <= cap)
|
||||
return input;
|
||||
const chars = [...input];
|
||||
if (chars.length > cap)
|
||||
return chars.slice(0, cap - suffix.length).join('') + suffix;
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
export function trimStringWithEllipsis(input: string, cap: number): string {
|
||||
return trimString(input, cap, '\u2026');
|
||||
}
|
||||
|
||||
export function escapeRegExp(s: string) {
|
||||
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
|
||||
const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
||||
export function escapeHTMLAttribute(s: string): string {
|
||||
return s.replace(/[&<>"']/ug, char => (escaped as any)[char]);
|
||||
}
|
||||
export function escapeHTML(s: string): string {
|
||||
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
|
||||
}
|
||||
|
||||
export function longestCommonSubstring(s1: string, s2: string): string {
|
||||
const n = s1.length;
|
||||
const m = s2.length;
|
||||
let maxLen = 0;
|
||||
let endingIndex = 0;
|
||||
|
||||
// Initialize a 2D array with zeros
|
||||
const dp = Array(n + 1)
|
||||
.fill(null)
|
||||
.map(() => Array(m + 1).fill(0));
|
||||
|
||||
// Build the dp table
|
||||
for (let i = 1; i <= n; i++) {
|
||||
for (let j = 1; j <= m; j++) {
|
||||
if (s1[i - 1] === s2[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
|
||||
if (dp[i][j] > maxLen) {
|
||||
maxLen = dp[i][j];
|
||||
endingIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the longest common substring
|
||||
return s1.slice(endingIndex - maxLen, endingIndex);
|
||||
}
|
||||
|
||||
export function parseRegex(regex: string): RegExp {
|
||||
if (regex[0] !== '/')
|
||||
throw new Error(`Invalid regex, must start with '/': ${regex}`);
|
||||
const lastSlash = regex.lastIndexOf('/');
|
||||
if (lastSlash <= 0)
|
||||
throw new Error(`Invalid regex, must end with '/' followed by optional flags: ${regex}`);
|
||||
const source = regex.slice(1, lastSlash);
|
||||
const flags = regex.slice(lastSlash + 1);
|
||||
return new RegExp(source, flags);
|
||||
}
|
||||
|
||||
export const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g');
|
||||
export function stripAnsiEscapes(str: string): string {
|
||||
return str.replace(ansiRegex, '');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Hopefully, this file is never used in injected sources,
|
||||
// because it does not use `builtins.performance`,
|
||||
// and can break when clock emulation is engaged.
|
||||
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
let _timeOrigin = performance.timeOrigin;
|
||||
let _timeShift = 0;
|
||||
|
||||
export function setTimeOrigin(origin: number) {
|
||||
_timeOrigin = origin;
|
||||
_timeShift = performance.timeOrigin - origin;
|
||||
}
|
||||
|
||||
export function timeOrigin(): number {
|
||||
return _timeOrigin;
|
||||
}
|
||||
|
||||
export function monotonicTime(): number {
|
||||
return Math.floor((performance.now() + _timeShift) * 1000) / 1000;
|
||||
}
|
||||
|
||||
export const DEFAULT_PLAYWRIGHT_TIMEOUT = 30_000;
|
||||
export const DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT = 3 * 60 * 1000; // 3 minutes
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Hopefully, this file is never used in injected sources,
|
||||
// because it does not use `builtins.setTimeout` and similar,
|
||||
// and can break when clock emulation is engaged.
|
||||
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
import { monotonicTime } from './time';
|
||||
|
||||
export async function raceAgainstDeadline<T>(cb: () => Promise<T>, deadline: number): Promise<{ result: T, timedOut: false } | { timedOut: true }> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
return Promise.race([
|
||||
cb().then(result => {
|
||||
return { result, timedOut: false };
|
||||
}),
|
||||
new Promise<{ timedOut: true }>(resolve => {
|
||||
if (!deadline)
|
||||
return;
|
||||
timer = setTimeout(() => resolve({ timedOut: true }), deadline - monotonicTime());
|
||||
}),
|
||||
]).finally(() => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
||||
export async function pollAgainstDeadline<T>(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, pollIntervals: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> {
|
||||
const lastPollInterval = pollIntervals.pop() ?? 1000;
|
||||
let lastResult: T|undefined;
|
||||
const wrappedCallback = () => Promise.resolve().then(callback);
|
||||
while (true) {
|
||||
const time = monotonicTime();
|
||||
if (deadline && time >= deadline)
|
||||
break;
|
||||
const received = await raceAgainstDeadline(wrappedCallback, deadline);
|
||||
if (received.timedOut)
|
||||
break;
|
||||
lastResult = (received as any).result.result;
|
||||
if (!(received as any).result.continuePolling)
|
||||
return { result: lastResult, timedOut: false };
|
||||
const interval = pollIntervals!.shift() ?? lastPollInterval;
|
||||
if (deadline && deadline <= monotonicTime() + interval)
|
||||
break;
|
||||
await new Promise(x => setTimeout(x, interval));
|
||||
}
|
||||
return { timedOut: true, result: lastResult };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[*]
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Language } from '../locatorGenerators';
|
||||
import type { ResourceSnapshot } from '@trace/snapshot';
|
||||
import type * as trace from '@trace/trace';
|
||||
|
||||
// *Entry structures are used to pass the trace between the sw and the page.
|
||||
|
||||
export type ContextEntry = {
|
||||
origin: 'testRunner'|'library';
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
browserName: string;
|
||||
channel?: string;
|
||||
platform?: string;
|
||||
playwrightVersion?: string;
|
||||
wallTime: number;
|
||||
sdkLanguage?: Language;
|
||||
testIdAttributeName?: string;
|
||||
title?: string;
|
||||
options: trace.BrowserContextEventOptions;
|
||||
pages: PageEntry[];
|
||||
resources: ResourceSnapshot[];
|
||||
actions: ActionEntry[];
|
||||
events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[];
|
||||
stdio: trace.StdioTraceEvent[];
|
||||
errors: trace.ErrorTraceEvent[];
|
||||
hasSource: boolean;
|
||||
contextId: string;
|
||||
testTimeout?: number;
|
||||
};
|
||||
|
||||
export type PageEntry = {
|
||||
pageId: string,
|
||||
screencastFrames: {
|
||||
sha1: string,
|
||||
timestamp: number,
|
||||
frameSwapWallTime?: number,
|
||||
width: number,
|
||||
height: number,
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ActionEntry = trace.ActionTraceEvent & {
|
||||
log: { time: number, message: string }[];
|
||||
};
|
||||
@@ -0,0 +1,646 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { escapeHTMLAttribute, escapeHTML } from '../stringUtils';
|
||||
|
||||
import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot';
|
||||
import type { PageEntry } from './entries';
|
||||
import type { LRUCache } from '../lruCache';
|
||||
|
||||
function findClosest<T>(items: T[], metric: (v: T) => number, target: number) {
|
||||
return items.find((item, index) => {
|
||||
if (index === items.length - 1)
|
||||
return true;
|
||||
const next = items[index + 1];
|
||||
return Math.abs(metric(item) - target) < Math.abs(metric(next) - target);
|
||||
});
|
||||
}
|
||||
|
||||
function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot {
|
||||
return Array.isArray(n) && typeof n[0] === 'string';
|
||||
}
|
||||
|
||||
function isSubtreeReferenceSnapshot(n: NodeSnapshot): n is SubtreeReferenceSnapshot {
|
||||
return Array.isArray(n) && Array.isArray(n[0]);
|
||||
}
|
||||
|
||||
export class SnapshotRenderer {
|
||||
private _htmlCache: LRUCache<SnapshotRenderer, string>;
|
||||
private _snapshots: FrameSnapshot[];
|
||||
private _index: number;
|
||||
readonly snapshotName: string | undefined;
|
||||
private _resources: ResourceSnapshot[];
|
||||
private _snapshot: FrameSnapshot;
|
||||
private _callId: string;
|
||||
private _screencastFrames: PageEntry['screencastFrames'];
|
||||
|
||||
constructor(htmlCache: LRUCache<SnapshotRenderer, string>, resources: ResourceSnapshot[], snapshots: FrameSnapshot[], screencastFrames: PageEntry['screencastFrames'], index: number) {
|
||||
this._htmlCache = htmlCache;
|
||||
this._resources = resources;
|
||||
this._snapshots = snapshots;
|
||||
this._index = index;
|
||||
this._snapshot = snapshots[index];
|
||||
this._callId = snapshots[index].callId;
|
||||
this._screencastFrames = screencastFrames;
|
||||
this.snapshotName = snapshots[index].snapshotName;
|
||||
}
|
||||
|
||||
snapshot(): FrameSnapshot {
|
||||
return this._snapshots[this._index];
|
||||
}
|
||||
|
||||
viewport(): { width: number, height: number } {
|
||||
return this._snapshots[this._index].viewport;
|
||||
}
|
||||
|
||||
closestScreenshot(): string | undefined {
|
||||
const { wallTime, timestamp } = this.snapshot();
|
||||
const closestFrame = (wallTime && this._screencastFrames[0]?.frameSwapWallTime)
|
||||
? findClosest(this._screencastFrames, frame => frame.frameSwapWallTime!, wallTime)
|
||||
: findClosest(this._screencastFrames, frame => frame.timestamp, timestamp);
|
||||
return closestFrame?.sha1;
|
||||
}
|
||||
|
||||
render(): RenderedFrameSnapshot {
|
||||
const result: string[] = [];
|
||||
const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined, parentAttrs: [string, string][] | undefined) => {
|
||||
// Text node.
|
||||
if (typeof n === 'string') {
|
||||
// Best-effort Electron support: rewrite custom protocol in url() links in stylesheets.
|
||||
// Old snapshotter was sending lower-case.
|
||||
if (parentTag === 'STYLE' || parentTag === 'style')
|
||||
result.push(escapeURLsInStyleSheet(rewriteURLsInStyleSheetForCustomProtocol(n)));
|
||||
else
|
||||
result.push(escapeHTML(n));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSubtreeReferenceSnapshot(n)) {
|
||||
// Node reference.
|
||||
const referenceIndex = snapshotIndex - n[0][0];
|
||||
if (referenceIndex >= 0 && referenceIndex <= snapshotIndex) {
|
||||
const nodes = snapshotNodes(this._snapshots[referenceIndex]);
|
||||
const nodeIndex = n[0][1];
|
||||
if (nodeIndex >= 0 && nodeIndex < nodes.length)
|
||||
return visit(nodes[nodeIndex], referenceIndex, parentTag, parentAttrs);
|
||||
}
|
||||
} else if (isNodeNameAttributesChildNodesSnapshot(n)) {
|
||||
const [name, nodeAttrs, ...children] = n;
|
||||
// Element node.
|
||||
// Note that <noscript> will not be rendered by default in the trace viewer, because
|
||||
// JS is enabled. So rename it to <x-noscript>.
|
||||
const nodeName = name === 'NOSCRIPT' ? 'X-NOSCRIPT' : name;
|
||||
const attrs = Object.entries(nodeAttrs || {});
|
||||
result.push('<', nodeName);
|
||||
const kCurrentSrcAttribute = '__playwright_current_src__';
|
||||
const isFrame = nodeName === 'IFRAME' || nodeName === 'FRAME';
|
||||
const isAnchor = nodeName === 'A';
|
||||
const isImg = nodeName === 'IMG';
|
||||
const isMeta = nodeName === 'META';
|
||||
const isImgWithCurrentSrc = isImg && attrs.some(a => a[0] === kCurrentSrcAttribute);
|
||||
const isSourceInsidePictureWithCurrentSrc = nodeName === 'SOURCE' && parentTag === 'PICTURE' && parentAttrs?.some(a => a[0] === kCurrentSrcAttribute);
|
||||
// For META, only allow a small whitelist of http-equiv directives so a malicious snapshot
|
||||
// cannot navigate the snapshot iframe via e.g. <meta http-equiv="refresh"> or otherwise
|
||||
// affect the trace viewer.
|
||||
const hasUnsafeHttpEquiv = isMeta && attrs.some(a => a[0].toLowerCase() === 'http-equiv' && !kAllowedMetaHttpEquivs.has(a[1].trim().toLowerCase()));
|
||||
for (const [attr, value] of attrs) {
|
||||
let attrName = attr;
|
||||
if (isFrame && attr.toLowerCase() === 'src') {
|
||||
// Never set relative URLs as <iframe src> - they start fetching frames immediately.
|
||||
attrName = '__playwright_src__';
|
||||
}
|
||||
if (isImg && attr === kCurrentSrcAttribute) {
|
||||
// Render currentSrc for images, so that trace viewer does not accidentally
|
||||
// resolve srcset to a different source.
|
||||
attrName = 'src';
|
||||
}
|
||||
if (['src', 'srcset'].includes(attr.toLowerCase()) && (isImgWithCurrentSrc || isSourceInsidePictureWithCurrentSrc)) {
|
||||
// Disable actual <img src>, <img srcset>, <source src> and <source srcset> if
|
||||
// we will be using the currentSrc instead.
|
||||
attrName = '_' + attrName;
|
||||
}
|
||||
if (hasUnsafeHttpEquiv && (attr.toLowerCase() === 'http-equiv' || attr.toLowerCase() === 'content')) {
|
||||
// Neutralize the META directive by renaming the attribute so the browser ignores it.
|
||||
attrName = '_' + attr;
|
||||
}
|
||||
let attrValue = value;
|
||||
if (!isAnchor && (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute))
|
||||
attrValue = rewriteURLForCustomProtocol(value);
|
||||
result.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"');
|
||||
}
|
||||
result.push('>');
|
||||
for (const child of children)
|
||||
visit(child, snapshotIndex, nodeName, attrs);
|
||||
if (!autoClosing.has(nodeName))
|
||||
result.push('</', nodeName, '>');
|
||||
return;
|
||||
} else {
|
||||
// Why are we here? Let's not throw, just in case.
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const snapshot = this._snapshot;
|
||||
const html = this._htmlCache.getOrCompute(this, () => {
|
||||
visit(snapshot.html, this._index, undefined, undefined);
|
||||
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
|
||||
const html = prefix + [
|
||||
// Hide the document in order to prevent flickering. We will unhide once script has processed shadow.
|
||||
'<style>*,*::before,*::after { visibility: hidden }</style>',
|
||||
`<script>${snapshotScript(this.viewport(), this._callId, this.snapshotName)}</script>`
|
||||
].join('') + result.join('');
|
||||
return { value: html, size: html.length };
|
||||
});
|
||||
|
||||
return { html, pageId: snapshot.pageId, frameId: snapshot.frameId, index: this._index };
|
||||
}
|
||||
|
||||
resourceByUrl(url: string, method: string): ResourceSnapshot | undefined {
|
||||
const snapshot = this._snapshot;
|
||||
let sameFrameResource: ResourceSnapshot | undefined;
|
||||
let otherFrameResource: ResourceSnapshot | undefined;
|
||||
|
||||
for (const resource of this._resources) {
|
||||
// Only use resources that received response before the snapshot.
|
||||
// Note that both snapshot time and request time are taken in the same Node process.
|
||||
if (typeof resource._monotonicTime === 'number' && resource._monotonicTime >= snapshot.timestamp)
|
||||
break;
|
||||
if (resource.response.status === 304) {
|
||||
// "Not Modified" responses are issued when browser requests the same resource
|
||||
// multiple times, meanwhile indicating that it has the response cached.
|
||||
//
|
||||
// When rendering the snapshot, browser most likely will not have the resource cached,
|
||||
// so we should respond with the real content instead, picking the last response that
|
||||
// is not 304.
|
||||
continue;
|
||||
}
|
||||
if (resource.request.url === url && resource.request.method === method) {
|
||||
// Pick the last resource with matching url - most likely it was used
|
||||
// at the time of snapshot, not the earlier aborted resource with the same url.
|
||||
if (resource._frameref === snapshot.frameId)
|
||||
sameFrameResource = resource;
|
||||
else
|
||||
otherFrameResource = resource;
|
||||
}
|
||||
}
|
||||
|
||||
// First try locating exact resource belonging to this frame,
|
||||
// then fall back to resource with this URL to account for memory cache.
|
||||
let result = sameFrameResource ?? otherFrameResource;
|
||||
if (result && method.toUpperCase() === 'GET') {
|
||||
// Patch override if necessary.
|
||||
let override = snapshot.resourceOverrides.find(o => o.url === url);
|
||||
if (override?.ref) {
|
||||
// "ref" means use the same content as "ref" snapshots ago.
|
||||
const index = this._index - override.ref;
|
||||
if (index >= 0 && index < this._snapshots.length)
|
||||
override = this._snapshots[index].resourceOverrides.find(o => o.url === url);
|
||||
}
|
||||
if (override?.sha1) {
|
||||
result = {
|
||||
...result,
|
||||
response: {
|
||||
...result.response,
|
||||
content: {
|
||||
...result.response.content,
|
||||
_sha1: override.sha1,
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
||||
|
||||
// Whitelist of META http-equiv directives that are safe to render in the trace viewer.
|
||||
// Notably excludes 'refresh' (auto-navigation), 'set-cookie' and 'content-security-policy'.
|
||||
const kAllowedMetaHttpEquivs = new Set(['content-type', 'content-language', 'default-style', 'x-ua-compatible']);
|
||||
|
||||
function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
|
||||
if (!(snapshot as any)._nodes) {
|
||||
const nodes: NodeSnapshot[] = [];
|
||||
const visit = (n: NodeSnapshot) => {
|
||||
if (typeof n === 'string') {
|
||||
nodes.push(n);
|
||||
} else if (isNodeNameAttributesChildNodesSnapshot(n)) {
|
||||
const [,, ...children] = n;
|
||||
for (const child of children)
|
||||
visit(child);
|
||||
nodes.push(n);
|
||||
}
|
||||
};
|
||||
visit(snapshot.html);
|
||||
(snapshot as any)._nodes = nodes;
|
||||
}
|
||||
return (snapshot as any)._nodes;
|
||||
}
|
||||
|
||||
type ViewportSize = { width: number, height: number };
|
||||
type BoundingRect = { left: number, top: number, right: number, bottom: number };
|
||||
type FrameBoundingRectsInfo = {
|
||||
viewport: ViewportSize;
|
||||
frames: WeakMap<Element, {
|
||||
boundingRect: BoundingRect;
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__playwright_frame_bounding_rects__: FrameBoundingRectsInfo;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
|
||||
function applyPlaywrightAttributes(blankSnapshotUrl: string, viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const win = window;
|
||||
const searchParams = new URLSearchParams(win.location.search);
|
||||
const shouldPopulateCanvasFromScreenshot = searchParams.has('shouldPopulateCanvasFromScreenshot');
|
||||
const isUnderTest = searchParams.has('isUnderTest');
|
||||
|
||||
// info to recursively compute canvas position relative to the top snapshot frame.
|
||||
// Before rendering each iframe, its parent extracts the '__playwright_canvas_render_info__' attribute
|
||||
// value and keeps in this variable. It can then remove the attribute and render the element,
|
||||
// which will eventually trigger the same process inside the iframe recursively.
|
||||
// When there's a canvas to render, we iterate over its ancestor frames to compute
|
||||
// its position relative to the top snapshot frame.
|
||||
const frameBoundingRectsInfo = {
|
||||
viewport,
|
||||
frames: new WeakMap(),
|
||||
};
|
||||
win['__playwright_frame_bounding_rects__'] = frameBoundingRectsInfo;
|
||||
|
||||
const kPointerWarningTitle = 'Recorded click position in absolute coordinates did not' +
|
||||
' match the center of the clicked element. This is either due to the use of provided offset,' +
|
||||
' or due to a difference between the test runner and the trace viewer operating systems.';
|
||||
|
||||
const scrollTops: Element[] = [];
|
||||
const scrollLefts: Element[] = [];
|
||||
const targetElements: Element[] = [];
|
||||
const canvasElements: HTMLCanvasElement[] = [];
|
||||
|
||||
let topSnapshotWindow: Window = win;
|
||||
while (topSnapshotWindow !== topSnapshotWindow.parent && !topSnapshotWindow.location.pathname.match(/\/page@[a-z0-9]+$/))
|
||||
topSnapshotWindow = topSnapshotWindow.parent;
|
||||
|
||||
const visit = (root: Document | ShadowRoot) => {
|
||||
// Collect all scrolled elements for later use.
|
||||
for (const e of root.querySelectorAll(`[__playwright_scroll_top_]`))
|
||||
scrollTops.push(e);
|
||||
for (const e of root.querySelectorAll(`[__playwright_scroll_left_]`))
|
||||
scrollLefts.push(e);
|
||||
|
||||
for (const element of root.querySelectorAll(`[__playwright_value_]`)) {
|
||||
const inputElement = element as HTMLInputElement | HTMLTextAreaElement;
|
||||
if (inputElement.type !== 'file')
|
||||
inputElement.value = inputElement.getAttribute('__playwright_value_')!;
|
||||
element.removeAttribute('__playwright_value_');
|
||||
}
|
||||
for (const element of root.querySelectorAll(`[__playwright_checked_]`)) {
|
||||
(element as HTMLInputElement).checked = element.getAttribute('__playwright_checked_') === 'true';
|
||||
element.removeAttribute('__playwright_checked_');
|
||||
}
|
||||
for (const element of root.querySelectorAll(`[__playwright_selected_]`)) {
|
||||
(element as HTMLOptionElement).selected = element.getAttribute('__playwright_selected_') === 'true';
|
||||
element.removeAttribute('__playwright_selected_');
|
||||
}
|
||||
for (const element of root.querySelectorAll(`[__playwright_popover_open_]`)) {
|
||||
try {
|
||||
(element as HTMLElement).showPopover();
|
||||
} catch {
|
||||
}
|
||||
element.removeAttribute('__playwright_popover_open_');
|
||||
}
|
||||
for (const element of root.querySelectorAll(`[__playwright_dialog_open_]`)) {
|
||||
try {
|
||||
if (element.getAttribute('__playwright_dialog_open_') === 'modal')
|
||||
(element as HTMLDialogElement).showModal();
|
||||
else
|
||||
(element as HTMLDialogElement).show();
|
||||
} catch {
|
||||
}
|
||||
element.removeAttribute('__playwright_dialog_open_');
|
||||
}
|
||||
|
||||
for (const targetId of targetIds) {
|
||||
for (const target of root.querySelectorAll(`[__playwright_target__="${targetId}"]`)) {
|
||||
const style = (target as HTMLElement).style;
|
||||
style.outline = '2px solid #006ab1';
|
||||
style.backgroundColor = '#6fa8dc7f';
|
||||
targetElements.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
for (const iframe of root.querySelectorAll('iframe, frame')) {
|
||||
const boundingRectJson = iframe.getAttribute('__playwright_bounding_rect__');
|
||||
iframe.removeAttribute('__playwright_bounding_rect__');
|
||||
const boundingRect = boundingRectJson ? JSON.parse(boundingRectJson) : undefined;
|
||||
if (boundingRect)
|
||||
frameBoundingRectsInfo.frames.set(iframe, { boundingRect, scrollLeft: 0, scrollTop: 0 });
|
||||
const src = iframe.getAttribute('__playwright_src__');
|
||||
if (!src) {
|
||||
iframe.setAttribute('src', blankSnapshotUrl);
|
||||
} else {
|
||||
// Retain query parameters to inherit name=, time=, pointX=, pointY= and other values from parent.
|
||||
const url = new URL(win.location.href);
|
||||
// We can be loading iframe from within iframe, reset base to be absolute.
|
||||
const index = url.pathname.lastIndexOf('/snapshot/');
|
||||
if (index !== -1)
|
||||
url.pathname = url.pathname.substring(0, index + 1);
|
||||
url.pathname += src.substring(1);
|
||||
iframe.setAttribute('src', url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const body = root.querySelector(`body[__playwright_custom_elements__]`);
|
||||
if (body && win.customElements) {
|
||||
const customElements = (body.getAttribute('__playwright_custom_elements__') || '').split(',');
|
||||
for (const elementName of customElements)
|
||||
win.customElements.define(elementName, class extends HTMLElement {});
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll(`template[__playwright_shadow_root_]`)) {
|
||||
const template = element as HTMLTemplateElement;
|
||||
const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' });
|
||||
shadowRoot.appendChild(template.content);
|
||||
template.remove();
|
||||
visit(shadowRoot);
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll('a'))
|
||||
element.addEventListener('click', event => { event.preventDefault(); });
|
||||
|
||||
if ('adoptedStyleSheets' in (root as any)) {
|
||||
const adoptedSheets: CSSStyleSheet[] = [...(root as any).adoptedStyleSheets];
|
||||
for (const element of root.querySelectorAll(`template[__playwright_style_sheet_]`)) {
|
||||
const template = element as HTMLTemplateElement;
|
||||
const sheet = new CSSStyleSheet();
|
||||
(sheet as any).replaceSync(template.getAttribute('__playwright_style_sheet_'));
|
||||
adoptedSheets.push(sheet);
|
||||
}
|
||||
(root as any).adoptedStyleSheets = adoptedSheets;
|
||||
}
|
||||
|
||||
canvasElements.push(...root.querySelectorAll('canvas'));
|
||||
};
|
||||
|
||||
const onLoad = () => {
|
||||
win.removeEventListener('load', onLoad);
|
||||
for (const element of scrollTops) {
|
||||
element.scrollTop = +element.getAttribute('__playwright_scroll_top_')!;
|
||||
element.removeAttribute('__playwright_scroll_top_');
|
||||
if (frameBoundingRectsInfo.frames.has(element))
|
||||
frameBoundingRectsInfo.frames.get(element)!.scrollTop = element.scrollTop;
|
||||
}
|
||||
for (const element of scrollLefts) {
|
||||
element.scrollLeft = +element.getAttribute('__playwright_scroll_left_')!;
|
||||
element.removeAttribute('__playwright_scroll_left_');
|
||||
if (frameBoundingRectsInfo.frames.has(element))
|
||||
frameBoundingRectsInfo.frames.get(element)!.scrollLeft = element.scrollLeft;
|
||||
}
|
||||
|
||||
win.document.styleSheets[0].disabled = true;
|
||||
|
||||
const search = new URL(win.location.href).searchParams;
|
||||
const isTopFrame = win === topSnapshotWindow;
|
||||
|
||||
if (isTopFrame && search.get('pointX') && search.get('pointY')) {
|
||||
const pointX = +search.get('pointX')!;
|
||||
const pointY = +search.get('pointY')!;
|
||||
|
||||
const pointElement = win.document.createElement('x-pw-pointer');
|
||||
pointElement.style.position = 'fixed';
|
||||
pointElement.style.backgroundColor = '#f44336';
|
||||
pointElement.style.width = '20px';
|
||||
pointElement.style.height = '20px';
|
||||
pointElement.style.borderRadius = '10px';
|
||||
pointElement.style.margin = '-10px 0 0 -10px';
|
||||
pointElement.style.zIndex = '2147483646';
|
||||
pointElement.style.display = 'flex';
|
||||
pointElement.style.alignItems = 'center';
|
||||
pointElement.style.justifyContent = 'center';
|
||||
|
||||
// Sometimes there are layout discrepancies between recording and rendering, e.g. fonts,
|
||||
// that may place the point at the wrong place. To avoid confusion, we just show the
|
||||
// point in the middle of the target element.
|
||||
const target = targetElements[0];
|
||||
const targetBox = target?.getBoundingClientRect();
|
||||
const targetCenter = target ? { x: targetBox.left + targetBox.width / 2, y: targetBox.top + targetBox.height / 2 } : null;
|
||||
pointElement.style.left = (targetCenter?.x ?? pointX) + 'px';
|
||||
pointElement.style.top = (targetCenter?.y ?? pointY) + 'px';
|
||||
|
||||
const isAligned = !targetCenter || (Math.abs(targetCenter.x - pointX) <= 10 && Math.abs(targetCenter.y - pointY) <= 10);
|
||||
if (!isAligned) {
|
||||
const warningElement = win.document.createElement('x-pw-pointer-warning');
|
||||
warningElement.textContent = '⚠';
|
||||
warningElement.style.fontSize = '19px';
|
||||
warningElement.style.color = 'white';
|
||||
warningElement.style.marginTop = '-3.5px';
|
||||
warningElement.style.userSelect = 'none';
|
||||
pointElement.appendChild(warningElement);
|
||||
pointElement.setAttribute('title', kPointerWarningTitle);
|
||||
}
|
||||
|
||||
win.document.documentElement.appendChild(pointElement);
|
||||
}
|
||||
|
||||
if (canvasElements.length > 0) {
|
||||
function drawCheckerboard(context: CanvasRenderingContext2D, canvas: HTMLCanvasElement) {
|
||||
function createCheckerboardPattern() {
|
||||
const pattern = win.document.createElement('canvas');
|
||||
pattern.width = pattern.width / Math.floor(pattern.width / 24);
|
||||
pattern.height = pattern.height / Math.floor(pattern.height / 24);
|
||||
const context = pattern.getContext('2d')!;
|
||||
context.fillStyle = 'lightgray';
|
||||
context.fillRect(0, 0, pattern.width, pattern.height);
|
||||
context.fillStyle = 'white';
|
||||
context.fillRect(0, 0, pattern.width / 2, pattern.height / 2);
|
||||
context.fillRect(pattern.width / 2, pattern.height / 2, pattern.width, pattern.height);
|
||||
return context.createPattern(pattern, 'repeat')!;
|
||||
}
|
||||
|
||||
context.fillStyle = createCheckerboardPattern();
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
for (const canvas of canvasElements) {
|
||||
const context = canvas.getContext('2d')!;
|
||||
|
||||
const boundingRectAttribute = canvas.getAttribute('__playwright_bounding_rect__');
|
||||
canvas.removeAttribute('__playwright_bounding_rect__');
|
||||
if (!boundingRectAttribute)
|
||||
continue;
|
||||
|
||||
let boundingRect: { left: number, top: number, right: number, bottom: number };
|
||||
try {
|
||||
boundingRect = JSON.parse(boundingRectAttribute);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let currWindow: Window = win;
|
||||
while (currWindow !== topSnapshotWindow) {
|
||||
const iframe = currWindow.frameElement!;
|
||||
currWindow = currWindow.parent;
|
||||
|
||||
const iframeInfo = currWindow['__playwright_frame_bounding_rects__']?.frames.get(iframe);
|
||||
if (!iframeInfo?.boundingRect)
|
||||
break;
|
||||
|
||||
const leftOffset = iframeInfo.boundingRect.left - iframeInfo.scrollLeft;
|
||||
const topOffset = iframeInfo.boundingRect.top - iframeInfo.scrollTop;
|
||||
|
||||
boundingRect.left += leftOffset;
|
||||
boundingRect.top += topOffset;
|
||||
boundingRect.right += leftOffset;
|
||||
boundingRect.bottom += topOffset;
|
||||
}
|
||||
|
||||
const { width, height } = topSnapshotWindow['__playwright_frame_bounding_rects__'].viewport;
|
||||
|
||||
boundingRect.left = boundingRect.left / width;
|
||||
boundingRect.top = boundingRect.top / height;
|
||||
boundingRect.right = boundingRect.right / width;
|
||||
boundingRect.bottom = boundingRect.bottom / height;
|
||||
|
||||
const partiallyUncaptured = boundingRect.right > 1 || boundingRect.bottom > 1;
|
||||
const fullyUncaptured = boundingRect.left > 1 || boundingRect.top > 1;
|
||||
if (fullyUncaptured) {
|
||||
canvas.title = `Playwright couldn't capture canvas contents because it's located outside the viewport.`;
|
||||
continue;
|
||||
}
|
||||
|
||||
drawCheckerboard(context, canvas);
|
||||
|
||||
if (shouldPopulateCanvasFromScreenshot) {
|
||||
context.drawImage(img, boundingRect.left * img.width, boundingRect.top * img.height, (boundingRect.right - boundingRect.left) * img.width, (boundingRect.bottom - boundingRect.top) * img.height, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (partiallyUncaptured)
|
||||
canvas.title = `Playwright couldn't capture full canvas contents because it's located partially outside the viewport.`;
|
||||
else
|
||||
canvas.title = `Canvas contents are displayed on a best-effort basis based on viewport screenshots taken during test execution.`;
|
||||
} else {
|
||||
canvas.title = 'Canvas content display is disabled.';
|
||||
}
|
||||
|
||||
if (isUnderTest)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`canvas drawn:`, JSON.stringify([boundingRect.left, boundingRect.top, (boundingRect.right - boundingRect.left), (boundingRect.bottom - boundingRect.top)].map(v => Math.floor(v * 100))));
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
for (const canvas of canvasElements) {
|
||||
const context = canvas.getContext('2d')!;
|
||||
drawCheckerboard(context, canvas);
|
||||
canvas.title = `Playwright couldn't show canvas contents because the screenshot failed to load.`;
|
||||
}
|
||||
};
|
||||
img.src = location.href.replace('/snapshot', '/closest-screenshot');
|
||||
}
|
||||
};
|
||||
|
||||
const onDOMContentLoaded = () => visit(win.document);
|
||||
|
||||
win.addEventListener('load', onLoad);
|
||||
win.addEventListener('DOMContentLoaded', onDOMContentLoaded);
|
||||
}
|
||||
|
||||
return `\n(${applyPlaywrightAttributes.toString()})(${JSON.stringify(blankSnapshotUrl)},${JSON.stringify(viewport)}${targetIds.map(id => `, "${id}"`).join('')})`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Best-effort Electron support: rewrite custom protocol in DOM.
|
||||
* vscode-file://vscode-app/ -> https://pw-vscode-file--vscode-app/
|
||||
*/
|
||||
const schemas = ['about:', 'blob:', 'data:', 'file:', 'ftp:', 'http:', 'https:', 'mailto:', 'sftp:', 'ws:', 'wss:'];
|
||||
const kLegacyBlobPrefix = 'http://playwright.bloburl/#';
|
||||
|
||||
export function rewriteURLForCustomProtocol(href: string): string {
|
||||
// Legacy support, we used to prepend this to blobs, strip it away.
|
||||
if (href.startsWith(kLegacyBlobPrefix))
|
||||
href = href.substring(kLegacyBlobPrefix.length);
|
||||
|
||||
try {
|
||||
const url = new URL(href);
|
||||
// Sanitize URL.
|
||||
if (url.protocol === 'javascript:' || url.protocol === 'vbscript:')
|
||||
return 'javascript:void(0)';
|
||||
|
||||
// Pass through if possible.
|
||||
const isBlob = url.protocol === 'blob:';
|
||||
const isFile = url.protocol === 'file:';
|
||||
if (!isBlob && !isFile && schemas.includes(url.protocol))
|
||||
return href;
|
||||
|
||||
// Rewrite blob, file and custom schemas.
|
||||
const prefix = 'pw-' + url.protocol.slice(0, url.protocol.length - 1);
|
||||
if (!isFile)
|
||||
url.protocol = 'https:';
|
||||
url.hostname = url.hostname ? `${prefix}--${url.hostname}` : prefix;
|
||||
if (isFile) {
|
||||
// File URIs can only have their protocol changed after the hostname
|
||||
// is set. (For all other URIs, we must set the protocol first.)
|
||||
url.protocol = 'https:';
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort Electron support: rewrite custom protocol in inline stylesheets.
|
||||
* vscode-file://vscode-app/ -> https://pw-vscode-file--vscode-app/
|
||||
*/
|
||||
const urlInCSSRegex = /url\(['"]?([\w-]+:)\/\//ig;
|
||||
|
||||
function rewriteURLsInStyleSheetForCustomProtocol(text: string): string {
|
||||
return text.replace(urlInCSSRegex, (match: string, protocol: string) => {
|
||||
const isBlob = protocol === 'blob:';
|
||||
const isFile = protocol === 'file:';
|
||||
if (!isBlob && !isFile && schemas.includes(protocol))
|
||||
return match;
|
||||
return match.replace(protocol + '//', `https://pw-${protocol.slice(0, -1)}--`);
|
||||
});
|
||||
}
|
||||
|
||||
// url() inside a <style> tag can mess up with html parsing, so we encode some of them.
|
||||
// As an example, the following url will close the </style> tag:
|
||||
// url('data:image/svg+xml,<svg><defs><style>.a{fill:none}</style></defs><g class="a"></g></svg>')
|
||||
const urlToEscapeRegex1 = /url\(\s*'([^']*)'\s*\)/ig;
|
||||
const urlToEscapeRegex2 = /url\(\s*"([^"]*)"\s*\)/ig;
|
||||
function escapeURLsInStyleSheet(text: string): string {
|
||||
const replacer = (match: string, url: string) => {
|
||||
// Conservatively encode only urls with a closing tag.
|
||||
if (url.includes('</'))
|
||||
return match.replace(url, encodeURI(url));
|
||||
return match;
|
||||
};
|
||||
return text.replace(urlToEscapeRegex1, replacer).replace(urlToEscapeRegex2, replacer);
|
||||
}
|
||||
|
||||
export const blankSnapshotUrl = 'data:text/html;base64,' + btoa(`<body></body><style>body { color-scheme: light dark; background: light-dark(white, #333) }</style>`);
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { URLSearchParams } from 'url';
|
||||
import type { SnapshotRenderer } from './snapshotRenderer';
|
||||
import type { SnapshotStorage } from './snapshotStorage';
|
||||
import type { ResourceSnapshot } from '@trace/snapshot';
|
||||
|
||||
export class SnapshotServer {
|
||||
private _snapshotStorage: SnapshotStorage;
|
||||
private _resourceLoader: (sha1: string) => Promise<Blob | undefined>;
|
||||
private _snapshotIds = new Map<string, SnapshotRenderer>();
|
||||
|
||||
constructor(snapshotStorage: SnapshotStorage, resourceLoader: (sha1: string) => Promise<Blob | undefined>) {
|
||||
this._snapshotStorage = snapshotStorage;
|
||||
this._resourceLoader = resourceLoader;
|
||||
}
|
||||
|
||||
serveSnapshot(pageOrFrameId: string, searchParams: URLSearchParams, snapshotUrl: string): Response {
|
||||
const snapshot = this._snapshot(pageOrFrameId, searchParams);
|
||||
if (!snapshot)
|
||||
return new Response(null, { status: 404 });
|
||||
|
||||
const renderedSnapshot = snapshot.render();
|
||||
this._snapshotIds.set(snapshotUrl, snapshot);
|
||||
return new Response(renderedSnapshot.html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
}
|
||||
|
||||
async serveClosestScreenshot(pageOrFrameId: string, searchParams: URLSearchParams): Promise<Response> {
|
||||
const snapshot = this._snapshot(pageOrFrameId, searchParams);
|
||||
const sha1 = snapshot?.closestScreenshot();
|
||||
if (!sha1)
|
||||
return new Response(null, { status: 404 });
|
||||
return new Response(await this._resourceLoader(sha1));
|
||||
}
|
||||
|
||||
serveSnapshotInfo(pageOrFrameId: string, searchParams: URLSearchParams): Response {
|
||||
const snapshot = this._snapshot(pageOrFrameId, searchParams);
|
||||
return this._respondWithJson(snapshot ? {
|
||||
viewport: snapshot.viewport(),
|
||||
url: snapshot.snapshot().frameUrl,
|
||||
timestamp: snapshot.snapshot().timestamp,
|
||||
wallTime: snapshot.snapshot().wallTime,
|
||||
} : {
|
||||
error: 'No snapshot found'
|
||||
});
|
||||
}
|
||||
|
||||
private _snapshot(pageOrFrameId: string, params: URLSearchParams) {
|
||||
const name = params.get('name')!;
|
||||
return this._snapshotStorage.snapshotByName(pageOrFrameId, name);
|
||||
}
|
||||
|
||||
private _respondWithJson(object: any): Response {
|
||||
return new Response(JSON.stringify(object), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async serveResource(requestUrlAlternatives: string[], method: string, snapshotUrl: string): Promise<Response> {
|
||||
let resource: ResourceSnapshot | undefined;
|
||||
const snapshot = this._snapshotIds.get(snapshotUrl)!;
|
||||
for (const requestUrl of requestUrlAlternatives) {
|
||||
resource = snapshot?.resourceByUrl(removeHash(requestUrl), method);
|
||||
if (resource)
|
||||
break;
|
||||
}
|
||||
if (!resource)
|
||||
return new Response(null, { status: 404 });
|
||||
|
||||
const sha1 = resource.response.content._sha1;
|
||||
const content = sha1 ? await this._resourceLoader(sha1) || new Blob([]) : new Blob([]);
|
||||
|
||||
let contentType = resource.response.content.mimeType;
|
||||
const isTextEncoding = /^text\/|^application\/(javascript|json)/.test(contentType);
|
||||
if (isTextEncoding && !contentType.includes('charset'))
|
||||
contentType = `${contentType}; charset=utf-8`;
|
||||
|
||||
const headers = new Headers();
|
||||
// "x-unknown" in the har means "no content type".
|
||||
if (contentType !== 'x-unknown')
|
||||
headers.set('Content-Type', contentType);
|
||||
for (const { name, value } of resource.response.headers)
|
||||
headers.set(name, value);
|
||||
headers.delete('Content-Encoding');
|
||||
headers.delete('Access-Control-Allow-Origin');
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.delete('Content-Length');
|
||||
headers.set('Content-Length', String(content.size));
|
||||
if (this._snapshotStorage.hasResourceOverride(resource.request.url))
|
||||
headers.set('Cache-Control', 'no-store, no-cache, max-age=0');
|
||||
else
|
||||
headers.set('Cache-Control', 'public, max-age=31536000');
|
||||
const { status } = resource.response;
|
||||
const isNullBodyStatus = status === 101 || status === 204 || status === 205 || status === 304;
|
||||
return new Response(isNullBodyStatus ? null : content, {
|
||||
headers,
|
||||
status: resource.response.status,
|
||||
statusText: resource.response.statusText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeHash(url: string) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.hash = '';
|
||||
return u.toString();
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { rewriteURLForCustomProtocol, SnapshotRenderer } from './snapshotRenderer';
|
||||
import { LRUCache } from '../lruCache';
|
||||
|
||||
import type { FrameSnapshot, ResourceSnapshot } from '@trace/snapshot';
|
||||
import type { PageEntry } from './entries';
|
||||
|
||||
|
||||
export class SnapshotStorage {
|
||||
private _frameSnapshots = new Map<string, {
|
||||
raw: FrameSnapshot[],
|
||||
renderers: SnapshotRenderer[],
|
||||
}>();
|
||||
private _cache = new LRUCache<SnapshotRenderer, string>(100_000_000); // 100MB per each trace
|
||||
private _contextToResources = new Map<string, ResourceSnapshot[]>();
|
||||
private _resourceUrlsWithOverrides = new Set<string>();
|
||||
|
||||
addResource(contextId: string, resource: ResourceSnapshot): void {
|
||||
resource.request.url = rewriteURLForCustomProtocol(resource.request.url);
|
||||
this._ensureResourcesForContext(contextId).push(resource);
|
||||
}
|
||||
|
||||
addFrameSnapshot(contextId: string, snapshot: FrameSnapshot, screencastFrames: PageEntry['screencastFrames']) {
|
||||
for (const override of snapshot.resourceOverrides)
|
||||
override.url = rewriteURLForCustomProtocol(override.url);
|
||||
let frameSnapshots = this._frameSnapshots.get(snapshot.frameId);
|
||||
if (!frameSnapshots) {
|
||||
frameSnapshots = {
|
||||
raw: [],
|
||||
renderers: [],
|
||||
};
|
||||
this._frameSnapshots.set(snapshot.frameId, frameSnapshots);
|
||||
if (snapshot.isMainFrame)
|
||||
this._frameSnapshots.set(snapshot.pageId, frameSnapshots);
|
||||
}
|
||||
frameSnapshots.raw.push(snapshot);
|
||||
const resources = this._ensureResourcesForContext(contextId);
|
||||
const renderer = new SnapshotRenderer(this._cache, resources, frameSnapshots.raw, screencastFrames, frameSnapshots.raw.length - 1);
|
||||
frameSnapshots.renderers.push(renderer);
|
||||
return renderer;
|
||||
}
|
||||
|
||||
snapshotByName(pageOrFrameId: string, snapshotName: string): SnapshotRenderer | undefined {
|
||||
const snapshot = this._frameSnapshots.get(pageOrFrameId);
|
||||
return snapshot?.renderers.find(r => r.snapshotName === snapshotName);
|
||||
}
|
||||
|
||||
snapshotsForTest() {
|
||||
return [...this._frameSnapshots.keys()];
|
||||
}
|
||||
|
||||
finalize() {
|
||||
// Resources are not necessarily sorted in the trace file, so sort them now.
|
||||
for (const resources of this._contextToResources.values())
|
||||
resources.sort((a, b) => (a._monotonicTime || 0) - (b._monotonicTime || 0));
|
||||
// Resources that have overrides should not be cached, otherwise we might get stale content
|
||||
// while serving snapshots with different override values.
|
||||
for (const frameSnapshots of this._frameSnapshots.values()) {
|
||||
for (const snapshot of frameSnapshots.raw) {
|
||||
for (const override of snapshot.resourceOverrides)
|
||||
this._resourceUrlsWithOverrides.add(override.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasResourceOverride(url: string) {
|
||||
return this._resourceUrlsWithOverrides.has(url);
|
||||
}
|
||||
|
||||
private _ensureResourcesForContext(contextId: string): ResourceSnapshot[] {
|
||||
let resources = this._contextToResources.get(contextId);
|
||||
if (!resources) {
|
||||
resources = [];
|
||||
this._contextToResources.set(contextId, resources);
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { parseClientSideCallMetadata } from './traceUtils';
|
||||
|
||||
import { SnapshotStorage } from './snapshotStorage';
|
||||
import { TraceModernizer } from './traceModernizer';
|
||||
|
||||
import type { ContextEntry } from './entries';
|
||||
|
||||
export interface TraceLoaderBackend {
|
||||
entryNames(): Promise<string[]>;
|
||||
hasEntry(entryName: string): Promise<boolean>;
|
||||
readText(entryName: string): Promise<string | undefined>;
|
||||
readBlob(entryName: string): Promise<Blob | undefined>;
|
||||
isLive(): boolean;
|
||||
}
|
||||
|
||||
export class TraceLoader {
|
||||
contextEntries: ContextEntry[] = [];
|
||||
private _snapshotStorage: SnapshotStorage | undefined;
|
||||
private _backend!: TraceLoaderBackend;
|
||||
private _resourceToContentType = new Map<string, string>();
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
async load(backend: TraceLoaderBackend, traceFile?: string, unzipProgress?: (done: number, total: number) => void) {
|
||||
this._backend = backend;
|
||||
|
||||
const prefix = traceFile?.match(/(.+)\.trace$/)?.[1];
|
||||
const prefixes: string[] = [];
|
||||
let hasSource = false;
|
||||
for (const entryName of await this._backend.entryNames()) {
|
||||
const match = entryName.match(/(.+)\.trace$/);
|
||||
if (match && (!prefix || prefix === match[1]))
|
||||
prefixes.push(match[1] || '');
|
||||
if (entryName.includes('src@'))
|
||||
hasSource = true;
|
||||
}
|
||||
if (!prefixes.length)
|
||||
throw new Error('Cannot find .trace file');
|
||||
|
||||
this._snapshotStorage = new SnapshotStorage();
|
||||
|
||||
// 3 * ordinals progress increments below.
|
||||
const total = prefixes.length * 3;
|
||||
let done = 0;
|
||||
for (const prefix of prefixes) {
|
||||
const contextEntry = createEmptyContext();
|
||||
contextEntry.hasSource = hasSource;
|
||||
const modernizer = new TraceModernizer(contextEntry, this._snapshotStorage);
|
||||
|
||||
const trace = await this._backend.readText(prefix + '.trace') || '';
|
||||
modernizer.appendTrace(trace);
|
||||
unzipProgress?.(++done, total);
|
||||
|
||||
const network = await this._backend.readText(prefix + '.network') || '';
|
||||
modernizer.appendTrace(network);
|
||||
unzipProgress?.(++done, total);
|
||||
|
||||
contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
|
||||
|
||||
if (!backend.isLive()) {
|
||||
// Terminate actions w/o after event gracefully.
|
||||
// This would close after hooks event that has not been closed because
|
||||
// the trace is usually saved before after hooks complete.
|
||||
for (const action of contextEntry.actions.slice().reverse()) {
|
||||
if (!action.endTime && !action.error) {
|
||||
for (const a of contextEntry.actions) {
|
||||
if (a.parentId === action.callId && action.endTime < a.endTime)
|
||||
action.endTime = a.endTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stacks = await this._backend.readText(prefix + '.stacks');
|
||||
if (stacks) {
|
||||
const callMetadata = parseClientSideCallMetadata(JSON.parse(stacks));
|
||||
for (const action of contextEntry.actions)
|
||||
action.stack = action.stack || callMetadata.get(action.callId);
|
||||
}
|
||||
unzipProgress?.(++done, total);
|
||||
|
||||
for (const resource of contextEntry.resources) {
|
||||
if (resource.request.postData?._sha1)
|
||||
this._resourceToContentType.set(resource.request.postData._sha1, stripEncodingFromContentType(resource.request.postData.mimeType));
|
||||
if (resource.response.content?._sha1)
|
||||
this._resourceToContentType.set(resource.response.content._sha1, stripEncodingFromContentType(resource.response.content.mimeType));
|
||||
}
|
||||
|
||||
this.contextEntries.push(contextEntry);
|
||||
}
|
||||
|
||||
this._snapshotStorage.finalize();
|
||||
}
|
||||
|
||||
async hasEntry(filename: string): Promise<boolean> {
|
||||
return this._backend.hasEntry(filename);
|
||||
}
|
||||
|
||||
async resourceForSha1(sha1: string): Promise<Blob | undefined> {
|
||||
const blob = await this._backend.readBlob('resources/' + sha1);
|
||||
const contentType = this._resourceToContentType.get(sha1);
|
||||
// "x-unknown" in the har means "no content type".
|
||||
if (!blob || contentType === undefined || contentType === 'x-unknown')
|
||||
return blob;
|
||||
return new Blob([blob], { type: contentType });
|
||||
}
|
||||
|
||||
storage(): SnapshotStorage {
|
||||
return this._snapshotStorage!;
|
||||
}
|
||||
}
|
||||
|
||||
function stripEncodingFromContentType(contentType: string) {
|
||||
const charset = contentType.match(/^(.*);\s*charset=.*$/);
|
||||
if (charset)
|
||||
return charset[1];
|
||||
return contentType;
|
||||
}
|
||||
|
||||
function createEmptyContext(): ContextEntry {
|
||||
return {
|
||||
origin: 'testRunner',
|
||||
startTime: Number.MAX_SAFE_INTEGER,
|
||||
wallTime: Number.MAX_SAFE_INTEGER,
|
||||
endTime: 0,
|
||||
browserName: '',
|
||||
options: {
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: false,
|
||||
viewport: { width: 1280, height: 800 },
|
||||
},
|
||||
pages: [],
|
||||
resources: [],
|
||||
actions: [],
|
||||
events: [],
|
||||
errors: [],
|
||||
stdio: [],
|
||||
hasSource: false,
|
||||
contextId: '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getActionGroup, renderTitleForCall } from '../protocolFormatter';
|
||||
|
||||
import type { Language } from '../locatorGenerators';
|
||||
import type { ResourceSnapshot } from '@trace/snapshot';
|
||||
import type * as trace from '@trace/trace';
|
||||
import type { ActionTraceEvent } from '@trace/trace';
|
||||
import type { ActionEntry, ContextEntry, PageEntry } from './entries';
|
||||
import type { StackFrame } from '@protocol/channels';
|
||||
import type { ActionGroup } from '../protocolFormatter';
|
||||
|
||||
const contextSymbol = Symbol('context');
|
||||
const nextInContextSymbol = Symbol('nextInContext');
|
||||
const prevByEndTimeSymbol = Symbol('prevByEndTime');
|
||||
const nextByStartTimeSymbol = Symbol('nextByStartTime');
|
||||
const eventsSymbol = Symbol('events');
|
||||
|
||||
export type SourceLocation = {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
source?: SourceModel;
|
||||
};
|
||||
|
||||
export type SourceModel = {
|
||||
errors: { line: number, message: string }[];
|
||||
content: string | undefined;
|
||||
};
|
||||
|
||||
export type ResourceEntry = ResourceSnapshot & { id: string };
|
||||
|
||||
export type ActionTraceEventInContext = ActionEntry & {
|
||||
context: ContextEntry;
|
||||
};
|
||||
|
||||
export type ActionTreeItem = {
|
||||
id: string;
|
||||
children: ActionTreeItem[];
|
||||
parent: ActionTreeItem | undefined;
|
||||
action: ActionTraceEventInContext;
|
||||
};
|
||||
|
||||
export type ErrorDescription = {
|
||||
action?: ActionTraceEventInContext;
|
||||
stack?: StackFrame[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type Attachment = trace.AfterActionTraceEventAttachment & { callId: string };
|
||||
|
||||
export class TraceModel {
|
||||
readonly startTime: number;
|
||||
readonly endTime: number;
|
||||
readonly browserName: string;
|
||||
readonly channel?: string;
|
||||
readonly platform?: string;
|
||||
readonly playwrightVersion?: string;
|
||||
readonly wallTime?: number;
|
||||
readonly title?: string;
|
||||
readonly options: trace.BrowserContextEventOptions;
|
||||
readonly pages: PageEntry[];
|
||||
readonly actions: ActionTraceEventInContext[];
|
||||
readonly attachments: Attachment[];
|
||||
readonly visibleAttachments: Attachment[];
|
||||
readonly events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[];
|
||||
readonly stdio: trace.StdioTraceEvent[];
|
||||
readonly errors: trace.ErrorTraceEvent[];
|
||||
readonly errorDescriptors: ErrorDescription[];
|
||||
readonly hasSource: boolean;
|
||||
readonly hasStepData: boolean;
|
||||
readonly sdkLanguage: Language | undefined;
|
||||
readonly testIdAttributeName: string | undefined;
|
||||
readonly sources: Map<string, SourceModel>;
|
||||
resources: ResourceEntry[];
|
||||
readonly actionCounters: Map<string, number>;
|
||||
readonly traceUri: string;
|
||||
readonly testTimeout?: number;
|
||||
|
||||
|
||||
constructor(traceUri: string, contexts: ContextEntry[]) {
|
||||
contexts.forEach(contextEntry => indexModel(contextEntry));
|
||||
const libraryContext = contexts.find(context => context.origin === 'library');
|
||||
|
||||
this.traceUri = traceUri;
|
||||
this.browserName = libraryContext?.browserName || '';
|
||||
this.sdkLanguage = libraryContext?.sdkLanguage;
|
||||
this.channel = libraryContext?.channel;
|
||||
this.testIdAttributeName = libraryContext?.testIdAttributeName;
|
||||
this.platform = libraryContext?.platform || '';
|
||||
this.playwrightVersion = contexts.find(c => c.playwrightVersion)?.playwrightVersion;
|
||||
this.title = libraryContext?.title || '';
|
||||
this.options = libraryContext?.options || {};
|
||||
this.testTimeout = contexts.find(c => c.origin === 'testRunner')?.testTimeout;
|
||||
// Next call updates all timestamps for all events in library contexts, so it must be done first.
|
||||
this.actions = mergeActionsAndUpdateTiming(contexts);
|
||||
this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages));
|
||||
this.wallTime = contexts.map(c => c.wallTime).reduce((prev, cur) => Math.min(prev || Number.MAX_VALUE, cur!), Number.MAX_VALUE);
|
||||
this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE);
|
||||
this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE);
|
||||
this.events = ([] as (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]).concat(...contexts.map(c => c.events));
|
||||
this.stdio = ([] as trace.StdioTraceEvent[]).concat(...contexts.map(c => c.stdio));
|
||||
this.errors = ([] as trace.ErrorTraceEvent[]).concat(...contexts.map(c => c.errors));
|
||||
this.hasSource = contexts.some(c => c.hasSource);
|
||||
this.hasStepData = contexts.some(context => context.origin === 'testRunner');
|
||||
this.resources = [...contexts.map(c => c.resources)].flat().map(entry => ({ ...entry, id: `${entry.pageref}-${entry.startedDateTime}-${entry.request.url}` }));
|
||||
this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUri })) ?? []);
|
||||
this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_'));
|
||||
|
||||
this.events.sort((a1, a2) => a1.time - a2.time);
|
||||
this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!);
|
||||
this.errorDescriptors = this.hasStepData ? this._errorDescriptorsFromTestRunner() : this._errorDescriptorsFromActions();
|
||||
this.sources = collectSources(this.actions, this.errorDescriptors);
|
||||
|
||||
this.actionCounters = new Map();
|
||||
for (const action of this.actions) {
|
||||
action.group = action.group ?? getActionGroup({ type: action.class, method: action.method });
|
||||
if (action.group)
|
||||
this.actionCounters.set(action.group, 1 + (this.actionCounters.get(action.group) || 0));
|
||||
}
|
||||
}
|
||||
|
||||
createRelativeUrl(path: string) {
|
||||
const url = new URL('http://localhost/' + path);
|
||||
url.searchParams.set('trace', this.traceUri);
|
||||
return url.toString().substring('http://localhost/'.length);
|
||||
}
|
||||
|
||||
failedAction() {
|
||||
// This find innermost action for nested ones.
|
||||
return this.actions.findLast(a => a.error);
|
||||
}
|
||||
|
||||
filteredActions(actionsFilter: ActionGroup[]) {
|
||||
const filter = new Set<string>(actionsFilter);
|
||||
return this.actions.filter(action => !action.group || filter.has(action.group));
|
||||
}
|
||||
|
||||
renderActionTree(filter?: ActionGroup[]) {
|
||||
const actions = this.filteredActions(filter ?? []);
|
||||
const { rootItem } = buildActionTree(actions);
|
||||
const actionTree: string[] = [];
|
||||
const visit = (actionItem: ActionTreeItem, indent: string) => {
|
||||
const title = renderTitleForCall({ ...actionItem.action, type: actionItem.action.class });
|
||||
actionTree.push(`${indent}${title || actionItem.id}`);
|
||||
for (const child of actionItem.children)
|
||||
visit(child, indent + ' ');
|
||||
};
|
||||
rootItem.children.forEach(a => visit(a, ''));
|
||||
return actionTree;
|
||||
}
|
||||
|
||||
private _errorDescriptorsFromActions(): ErrorDescription[] {
|
||||
const errors: ErrorDescription[] = [];
|
||||
for (const action of this.actions || []) {
|
||||
if (!action.error?.message)
|
||||
continue;
|
||||
errors.push({
|
||||
action,
|
||||
stack: action.stack,
|
||||
message: action.error.message,
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private _errorDescriptorsFromTestRunner(): ErrorDescription[] {
|
||||
return this.errors.filter(e => !!e.message).map((error, i) => ({
|
||||
stack: error.stack,
|
||||
message: error.message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function indexModel(context: ContextEntry) {
|
||||
for (const page of context.pages)
|
||||
(page as any)[contextSymbol] = context;
|
||||
for (let i = 0; i < context.actions.length; ++i) {
|
||||
const action = context.actions[i] as any;
|
||||
action[contextSymbol] = context;
|
||||
}
|
||||
let lastNonRouteAction = undefined;
|
||||
for (let i = context.actions.length - 1; i >= 0; i--) {
|
||||
const action = context.actions[i] as ActionTraceEvent;
|
||||
(action as any)[nextInContextSymbol] = lastNonRouteAction;
|
||||
if (action.class !== 'Route')
|
||||
lastNonRouteAction = action;
|
||||
}
|
||||
for (const event of context.events)
|
||||
(event as any)[contextSymbol] = context;
|
||||
for (const resource of context.resources)
|
||||
(resource as any)[contextSymbol] = context;
|
||||
}
|
||||
|
||||
function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) {
|
||||
const result: ActionTraceEventInContext[] = [];
|
||||
const actions = mergeActionsAndUpdateTimingSameTrace(contexts);
|
||||
result.push(...actions);
|
||||
|
||||
result.sort((a1, a2) => {
|
||||
if (a2.parentId === a1.callId)
|
||||
return 1;
|
||||
if (a1.parentId === a2.callId)
|
||||
return -1;
|
||||
return a1.endTime - a2.endTime;
|
||||
});
|
||||
|
||||
for (let i = 1; i < result.length; ++i)
|
||||
(result[i] as any)[prevByEndTimeSymbol] = result[i - 1];
|
||||
|
||||
result.sort((a1, a2) => {
|
||||
if (a2.parentId === a1.callId)
|
||||
return -1;
|
||||
if (a1.parentId === a2.callId)
|
||||
return 1;
|
||||
return a1.startTime - a2.startTime;
|
||||
});
|
||||
|
||||
for (let i = 0; i + 1 < result.length; ++i)
|
||||
(result[i] as any)[nextByStartTimeSymbol] = result[i + 1];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
let lastTmpStepId = 0;
|
||||
|
||||
function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionTraceEventInContext[] {
|
||||
const map = new Map<string, ActionTraceEventInContext>();
|
||||
|
||||
const libraryContexts = contexts.filter(context => context.origin === 'library');
|
||||
const testRunnerContexts = contexts.filter(context => context.origin === 'testRunner');
|
||||
|
||||
// With library-only or test-runner-only traces there is nothing to match.
|
||||
if (!testRunnerContexts.length || !libraryContexts.length) {
|
||||
return contexts.map(context => {
|
||||
return context.actions.map(action => ({ ...action, context }));
|
||||
}).flat();
|
||||
}
|
||||
|
||||
for (const context of libraryContexts) {
|
||||
for (const action of context.actions) {
|
||||
// Never merge stepless events.
|
||||
map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context });
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol call aka library contexts have startTime/endTime as server-side times.
|
||||
// Step aka test runner contexts have startTime/endTime as client-side times.
|
||||
// Adjust startTime/endTime on the library contexts to align them with the test
|
||||
// runner steps.
|
||||
const delta = monotonicTimeDeltaBetweenLibraryAndRunner(testRunnerContexts, map);
|
||||
if (delta)
|
||||
adjustMonotonicTime(libraryContexts, delta);
|
||||
|
||||
const nonPrimaryIdToPrimaryId = new Map<string, string>();
|
||||
for (const context of testRunnerContexts) {
|
||||
for (const action of context.actions) {
|
||||
const existing = action.stepId && map.get(action.stepId);
|
||||
if (existing) {
|
||||
nonPrimaryIdToPrimaryId.set(action.callId, existing.callId);
|
||||
if (action.error)
|
||||
existing.error = action.error;
|
||||
if (action.attachments)
|
||||
existing.attachments = action.attachments;
|
||||
if (action.annotations)
|
||||
existing.annotations = action.annotations;
|
||||
if (action.parentId)
|
||||
existing.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId;
|
||||
if (action.group)
|
||||
existing.group = action.group;
|
||||
// For the events that are present in the test runner context, always take
|
||||
// their time from the test runner context to preserve client side order.
|
||||
existing.startTime = action.startTime;
|
||||
existing.endTime = action.endTime;
|
||||
continue;
|
||||
}
|
||||
if (action.parentId)
|
||||
action.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId;
|
||||
map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context });
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
function adjustMonotonicTime(contexts: ContextEntry[], monotonicTimeDelta: number) {
|
||||
for (const context of contexts) {
|
||||
context.startTime += monotonicTimeDelta;
|
||||
context.endTime += monotonicTimeDelta;
|
||||
for (const action of context.actions) {
|
||||
if (action.startTime)
|
||||
action.startTime += monotonicTimeDelta;
|
||||
if (action.endTime)
|
||||
action.endTime += monotonicTimeDelta;
|
||||
}
|
||||
for (const event of context.events)
|
||||
event.time += monotonicTimeDelta;
|
||||
for (const event of context.stdio)
|
||||
event.timestamp += monotonicTimeDelta;
|
||||
for (const page of context.pages) {
|
||||
for (const frame of page.screencastFrames)
|
||||
frame.timestamp += monotonicTimeDelta;
|
||||
}
|
||||
for (const resource of context.resources) {
|
||||
if (resource._monotonicTime)
|
||||
resource._monotonicTime += monotonicTimeDelta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEntry[], libraryActions: Map<string, ActionTraceEventInContext>) {
|
||||
// We cannot rely on wall time or monotonic time to be the in sync
|
||||
// between library and test runner contexts. So we find first action
|
||||
// that is present in both runner and library contexts and use it
|
||||
// to calculate the time delta, assuming the two events happened at the
|
||||
// same instant.
|
||||
for (const context of nonPrimaryContexts) {
|
||||
for (const action of context.actions) {
|
||||
if (!action.startTime)
|
||||
continue;
|
||||
const libraryAction = action.stepId ? libraryActions.get(action.stepId) : undefined;
|
||||
if (libraryAction)
|
||||
return action.startTime - libraryAction.startTime;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildActionTree(actions: ActionTraceEventInContext[]): { rootItem: ActionTreeItem, itemMap: Map<string, ActionTreeItem> } {
|
||||
const itemMap = new Map<string, ActionTreeItem>();
|
||||
|
||||
for (const action of actions) {
|
||||
itemMap.set(action.callId, {
|
||||
id: action.callId,
|
||||
parent: undefined,
|
||||
children: [],
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
const rootItem: ActionTreeItem = { action: { ...kFakeRootAction }, id: '', parent: undefined, children: [] };
|
||||
for (const item of itemMap.values()) {
|
||||
rootItem.action.startTime = Math.min(rootItem.action.startTime, item.action.startTime);
|
||||
rootItem.action.endTime = Math.max(rootItem.action.endTime, item.action.endTime);
|
||||
const parent = item.action.parentId ? itemMap.get(item.action.parentId) || rootItem : rootItem;
|
||||
parent.children.push(item);
|
||||
item.parent = parent;
|
||||
}
|
||||
|
||||
const inheritStack = (item: ActionTreeItem) => {
|
||||
for (const child of item.children) {
|
||||
child.action.stack = child.action.stack ?? item.action.stack;
|
||||
inheritStack(child);
|
||||
}
|
||||
};
|
||||
inheritStack(rootItem);
|
||||
|
||||
return { rootItem, itemMap };
|
||||
}
|
||||
|
||||
export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry {
|
||||
return (action as any)[contextSymbol];
|
||||
}
|
||||
|
||||
function nextInContext(action: ActionTraceEvent): ActionTraceEvent {
|
||||
return (action as any)[nextInContextSymbol];
|
||||
}
|
||||
|
||||
export function previousActionByEndTime(action: ActionTraceEvent): ActionTraceEvent {
|
||||
return (action as any)[prevByEndTimeSymbol];
|
||||
}
|
||||
|
||||
export function nextActionByStartTime(action: ActionTraceEvent): ActionTraceEvent {
|
||||
return (action as any)[nextByStartTimeSymbol];
|
||||
}
|
||||
|
||||
export function stats(action: ActionTraceEvent): { errors: number, warnings: number } {
|
||||
let errors = 0;
|
||||
let warnings = 0;
|
||||
for (const event of eventsForAction(action)) {
|
||||
if (event.type === 'console') {
|
||||
const type = event.messageType;
|
||||
if (type === 'warning')
|
||||
++warnings;
|
||||
else if (type === 'error')
|
||||
++errors;
|
||||
}
|
||||
if (event.type === 'event' && event.method === 'pageError')
|
||||
++errors;
|
||||
}
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
export function eventsForAction(action: ActionTraceEvent): (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] {
|
||||
let result: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] = (action as any)[eventsSymbol];
|
||||
if (result)
|
||||
return result;
|
||||
|
||||
const nextAction = nextInContext(action);
|
||||
result = context(action).events.filter(event => {
|
||||
return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime);
|
||||
});
|
||||
(action as any)[eventsSymbol] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: ErrorDescription[]): Map<string, SourceModel> {
|
||||
const result = new Map<string, SourceModel>();
|
||||
for (const action of actions) {
|
||||
for (const frame of action.stack || []) {
|
||||
let source = result.get(frame.file);
|
||||
if (!source) {
|
||||
source = { errors: [], content: undefined };
|
||||
result.set(frame.file, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const error of errorDescriptors) {
|
||||
const { action, stack, message } = error;
|
||||
if (!action || !stack)
|
||||
continue;
|
||||
result.get(stack[0].file)?.errors.push({
|
||||
line: stack[0].line || 0,
|
||||
message
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const kFakeRootAction: ActionTraceEventInContext = {
|
||||
type: 'action',
|
||||
callId: '',
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
class: '',
|
||||
method: '',
|
||||
params: {},
|
||||
log: [],
|
||||
context: {
|
||||
origin: 'library',
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
browserName: '',
|
||||
wallTime: 0,
|
||||
options: {},
|
||||
pages: [],
|
||||
resources: [],
|
||||
actions: [],
|
||||
events: [],
|
||||
stdio: [],
|
||||
errors: [],
|
||||
hasSource: false,
|
||||
contextId: '',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type * as trace from '@trace/trace';
|
||||
import type * as traceV3 from './versions/traceV3';
|
||||
import type * as traceV4 from './versions/traceV4';
|
||||
import type * as traceV5 from './versions/traceV5';
|
||||
import type * as traceV6 from './versions/traceV6';
|
||||
import type * as traceV7 from './versions/traceV7';
|
||||
import type * as traceV8 from './versions/traceV8';
|
||||
import type { ActionEntry, ContextEntry, PageEntry } from './entries';
|
||||
import type { SnapshotStorage } from './snapshotStorage';
|
||||
|
||||
export class TraceVersionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TraceVersionError';
|
||||
}
|
||||
}
|
||||
|
||||
// 6 => 10/2023 ~1.40
|
||||
// 7 => 05/2024 ~1.45
|
||||
const latestVersion: trace.VERSION = 8;
|
||||
|
||||
export class TraceModernizer {
|
||||
private _contextEntry: ContextEntry;
|
||||
private _snapshotStorage: SnapshotStorage;
|
||||
private _actionMap = new Map<string, ActionEntry>();
|
||||
private _version: number | undefined;
|
||||
private _pageEntries = new Map<string, PageEntry>();
|
||||
private _jsHandles = new Map<string, { preview: string }>();
|
||||
private _consoleObjects = new Map<string, { type: string, text: string, location: { url: string, lineNumber: number, columnNumber: number }, args?: { preview: string, value: string }[] }>();
|
||||
|
||||
constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage) {
|
||||
this._contextEntry = contextEntry;
|
||||
this._snapshotStorage = snapshotStorage;
|
||||
}
|
||||
|
||||
appendTrace(trace: string) {
|
||||
for (const line of trace.split('\n'))
|
||||
this._appendEvent(line);
|
||||
}
|
||||
|
||||
actions(): ActionEntry[] {
|
||||
return [...this._actionMap.values()];
|
||||
}
|
||||
|
||||
private _pageEntry(pageId: string): PageEntry {
|
||||
let pageEntry = this._pageEntries.get(pageId);
|
||||
if (!pageEntry) {
|
||||
pageEntry = {
|
||||
pageId,
|
||||
screencastFrames: [],
|
||||
};
|
||||
this._pageEntries.set(pageId, pageEntry);
|
||||
this._contextEntry.pages.push(pageEntry);
|
||||
}
|
||||
return pageEntry;
|
||||
}
|
||||
|
||||
private _appendEvent(line: string) {
|
||||
if (!line)
|
||||
return;
|
||||
const events = this._modernize(JSON.parse(line));
|
||||
for (const event of events)
|
||||
this._innerAppendEvent(event);
|
||||
}
|
||||
|
||||
private _innerAppendEvent(event: trace.TraceEvent) {
|
||||
const contextEntry = this._contextEntry;
|
||||
switch (event.type) {
|
||||
case 'context-options': {
|
||||
if (event.version > latestVersion)
|
||||
throw new TraceVersionError('The trace was created by a newer version of Playwright and is not supported by this version of the viewer. Please use latest Playwright to open the trace.');
|
||||
this._version = event.version;
|
||||
contextEntry.origin = event.origin;
|
||||
contextEntry.browserName = event.browserName;
|
||||
contextEntry.channel = event.channel;
|
||||
contextEntry.title = event.title;
|
||||
contextEntry.platform = event.platform;
|
||||
contextEntry.playwrightVersion = event.playwrightVersion;
|
||||
contextEntry.wallTime = event.wallTime;
|
||||
contextEntry.startTime = event.monotonicTime;
|
||||
contextEntry.sdkLanguage = event.sdkLanguage;
|
||||
contextEntry.options = event.options;
|
||||
contextEntry.testIdAttributeName = event.testIdAttributeName;
|
||||
contextEntry.contextId = event.contextId ?? '';
|
||||
contextEntry.testTimeout = event.testTimeout;
|
||||
break;
|
||||
}
|
||||
case 'screencast-frame': {
|
||||
this._pageEntry(event.pageId).screencastFrames.push(event);
|
||||
break;
|
||||
}
|
||||
case 'before': {
|
||||
this._actionMap.set(event.callId, { ...event, type: 'action', endTime: 0, log: [] });
|
||||
break;
|
||||
}
|
||||
case 'input': {
|
||||
const existing = this._actionMap.get(event.callId);
|
||||
existing!.inputSnapshot = event.inputSnapshot;
|
||||
existing!.point = event.point;
|
||||
break;
|
||||
}
|
||||
case 'log': {
|
||||
const existing = this._actionMap.get(event.callId);
|
||||
// We have some corrupted traces out there, tolerate them.
|
||||
if (!existing)
|
||||
return;
|
||||
existing.log.push({
|
||||
time: event.time,
|
||||
message: event.message,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'after': {
|
||||
const existing = this._actionMap.get(event.callId);
|
||||
existing!.afterSnapshot = event.afterSnapshot;
|
||||
existing!.endTime = event.endTime;
|
||||
existing!.result = event.result;
|
||||
existing!.error = event.error;
|
||||
existing!.attachments = event.attachments;
|
||||
existing!.annotations = event.annotations;
|
||||
if (event.point)
|
||||
existing!.point = event.point;
|
||||
break;
|
||||
}
|
||||
case 'action': {
|
||||
this._actionMap.set(event.callId, { ...event, log: [] });
|
||||
break;
|
||||
}
|
||||
case 'event': {
|
||||
contextEntry.events.push(event);
|
||||
break;
|
||||
}
|
||||
case 'stdout': {
|
||||
contextEntry.stdio.push(event);
|
||||
break;
|
||||
}
|
||||
case 'stderr': {
|
||||
contextEntry.stdio.push(event);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
contextEntry.errors.push(event);
|
||||
break;
|
||||
}
|
||||
case 'console': {
|
||||
contextEntry.events.push(event);
|
||||
break;
|
||||
}
|
||||
case 'resource-snapshot':
|
||||
this._snapshotStorage.addResource(this._contextEntry.contextId, event.snapshot);
|
||||
contextEntry.resources.push(event.snapshot);
|
||||
break;
|
||||
case 'frame-snapshot':
|
||||
this._snapshotStorage.addFrameSnapshot(this._contextEntry.contextId, event.snapshot, this._pageEntry(event.snapshot.pageId).screencastFrames);
|
||||
break;
|
||||
}
|
||||
// Make sure there is a page entry for each page, even without screencast frames,
|
||||
// to show in the metadata view.
|
||||
if (('pageId' in event) && event.pageId)
|
||||
this._pageEntry(event.pageId);
|
||||
if (event.type === 'action' || event.type === 'before')
|
||||
contextEntry.startTime = Math.min(contextEntry.startTime, event.startTime);
|
||||
if (event.type === 'action' || event.type === 'after')
|
||||
contextEntry.endTime = Math.max(contextEntry.endTime, event.endTime);
|
||||
if (event.type === 'event') {
|
||||
contextEntry.startTime = Math.min(contextEntry.startTime, event.time);
|
||||
contextEntry.endTime = Math.max(contextEntry.endTime, event.time);
|
||||
}
|
||||
if (event.type === 'screencast-frame') {
|
||||
contextEntry.startTime = Math.min(contextEntry.startTime, event.timestamp);
|
||||
contextEntry.endTime = Math.max(contextEntry.endTime, event.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
private _processedContextCreatedEvent() {
|
||||
return this._version !== undefined;
|
||||
}
|
||||
|
||||
private _modernize(event: any): trace.TraceEvent[] {
|
||||
// First record does not have this._version, but should have a version in the event entry itself.
|
||||
// Test traces before 7 (including 6) did not have version in the first entry, run the modernizer for 6=>*.
|
||||
let version = this._version ?? event.version ?? 6;
|
||||
let events = [event];
|
||||
for (; version < latestVersion; ++version)
|
||||
events = (this as any)[`_modernize_${version}_to_${version + 1}`].call(this, events);
|
||||
return events;
|
||||
}
|
||||
|
||||
_modernize_0_to_1(events: any[]): any[] {
|
||||
for (const event of events) {
|
||||
if (event.type !== 'action')
|
||||
continue;
|
||||
if (typeof event.metadata.error === 'string')
|
||||
event.metadata.error = { error: { name: 'Error', message: event.metadata.error } };
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
_modernize_1_to_2(events: any[]): any[] {
|
||||
for (const event of events) {
|
||||
if (event.type !== 'frame-snapshot' || !event.snapshot.isMainFrame)
|
||||
continue;
|
||||
// Old versions had completely wrong viewport.
|
||||
event.snapshot.viewport = this._contextEntry.options?.viewport || { width: 1280, height: 720 };
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
_modernize_2_to_3(events: any[]): any[] {
|
||||
for (const event of events) {
|
||||
if (event.type !== 'resource-snapshot' || event.snapshot.request)
|
||||
continue;
|
||||
// Migrate from old ResourceSnapshot to new har entry format.
|
||||
const resource = event.snapshot;
|
||||
event.snapshot = {
|
||||
_frameref: resource.frameId,
|
||||
request: {
|
||||
url: resource.url,
|
||||
method: resource.method,
|
||||
headers: resource.requestHeaders,
|
||||
postData: resource.requestSha1 ? { _sha1: resource.requestSha1 } : undefined,
|
||||
},
|
||||
response: {
|
||||
status: resource.status,
|
||||
headers: resource.responseHeaders,
|
||||
content: {
|
||||
mimeType: resource.contentType,
|
||||
_sha1: resource.responseSha1,
|
||||
},
|
||||
},
|
||||
_monotonicTime: resource.timestamp,
|
||||
};
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
_modernize_3_to_4(events: traceV3.TraceEvent[]): traceV4.TraceEvent[] {
|
||||
const result: traceV4.TraceEvent[] = [];
|
||||
for (const event of events) {
|
||||
const e = this._modernize_event_3_to_4(event);
|
||||
if (e)
|
||||
result.push(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_modernize_event_3_to_4(event: traceV3.TraceEvent): traceV4.TraceEvent | null {
|
||||
if (event.type !== 'action' && event.type !== 'event') {
|
||||
return event as traceV3.ContextCreatedTraceEvent |
|
||||
traceV3.ScreencastFrameTraceEvent |
|
||||
traceV3.ResourceSnapshotTraceEvent |
|
||||
traceV3.FrameSnapshotTraceEvent;
|
||||
}
|
||||
|
||||
const metadata = event.metadata;
|
||||
if (metadata.internal || metadata.method.startsWith('tracing'))
|
||||
return null;
|
||||
|
||||
if (event.type === 'event') {
|
||||
if (metadata.method === '__create__' && metadata.type === 'ConsoleMessage') {
|
||||
return {
|
||||
type: 'object',
|
||||
class: metadata.type,
|
||||
guid: metadata.params.guid,
|
||||
initializer: metadata.params.initializer,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'event',
|
||||
time: metadata.startTime,
|
||||
class: metadata.type,
|
||||
method: metadata.method,
|
||||
params: metadata.params,
|
||||
pageId: metadata.pageId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'action',
|
||||
callId: metadata.id,
|
||||
startTime: metadata.startTime,
|
||||
endTime: metadata.endTime,
|
||||
apiName: metadata.apiName || metadata.type + '.' + metadata.method,
|
||||
class: metadata.type,
|
||||
method: metadata.method,
|
||||
params: metadata.params,
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
wallTime: metadata.wallTime || Date.now(),
|
||||
log: metadata.log,
|
||||
beforeSnapshot: metadata.snapshots.find(s => s.title === 'before')?.snapshotName,
|
||||
inputSnapshot: metadata.snapshots.find(s => s.title === 'input')?.snapshotName,
|
||||
afterSnapshot: metadata.snapshots.find(s => s.title === 'after')?.snapshotName,
|
||||
error: metadata.error?.error,
|
||||
result: metadata.result,
|
||||
point: metadata.point,
|
||||
pageId: metadata.pageId,
|
||||
};
|
||||
}
|
||||
|
||||
_modernize_4_to_5(events: traceV4.TraceEvent[]): traceV5.TraceEvent[] {
|
||||
const result: traceV5.TraceEvent[] = [];
|
||||
for (const event of events) {
|
||||
const e = this._modernize_event_4_to_5(event);
|
||||
if (e)
|
||||
result.push(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_modernize_event_4_to_5(event: traceV4.TraceEvent): traceV5.TraceEvent | null {
|
||||
if (event.type === 'event' && event.method === '__create__' && event.class === 'JSHandle')
|
||||
this._jsHandles.set(event.params.guid, event.params.initializer);
|
||||
if (event.type === 'object') {
|
||||
// We do not expect any other 'object' events.
|
||||
if (event.class !== 'ConsoleMessage')
|
||||
return null;
|
||||
// Older traces might have `args` inherited from the protocol initializer - guid of JSHandle,
|
||||
// but might also have modern `args` with preview and value.
|
||||
const args: { preview: string, value: string }[] = (event.initializer as any).args?.map((arg: any) => {
|
||||
if (arg.guid) {
|
||||
const handle = this._jsHandles.get(arg.guid);
|
||||
return { preview: handle?.preview || '', value: '' };
|
||||
}
|
||||
return { preview: arg.preview || '', value: arg.value || '' };
|
||||
});
|
||||
this._consoleObjects.set(event.guid, {
|
||||
type: event.initializer.type,
|
||||
text: event.initializer.text,
|
||||
location: event.initializer.location,
|
||||
args,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (event.type === 'event' && event.method === 'console') {
|
||||
const consoleMessage = this._consoleObjects.get(event.params.message?.guid || '');
|
||||
if (!consoleMessage)
|
||||
return null;
|
||||
return {
|
||||
type: 'console',
|
||||
time: event.time,
|
||||
pageId: event.pageId,
|
||||
messageType: consoleMessage.type,
|
||||
text: consoleMessage.text,
|
||||
args: consoleMessage.args,
|
||||
location: consoleMessage.location,
|
||||
};
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
_modernize_5_to_6(events: traceV5.TraceEvent[]): traceV6.TraceEvent[] {
|
||||
const result: traceV6.TraceEvent[] = [];
|
||||
for (const event of events) {
|
||||
result.push(event);
|
||||
if (event.type !== 'after' || !event.log.length)
|
||||
continue;
|
||||
for (const log of event.log) {
|
||||
result.push({
|
||||
type: 'log',
|
||||
callId: event.callId,
|
||||
message: log,
|
||||
time: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_modernize_6_to_7(events: traceV6.TraceEvent[]): traceV7.TraceEvent[] {
|
||||
const result: traceV7.TraceEvent[] = [];
|
||||
if (!this._processedContextCreatedEvent() && events[0].type !== 'context-options') {
|
||||
const event: traceV7.ContextCreatedTraceEvent = {
|
||||
type: 'context-options',
|
||||
origin: 'testRunner',
|
||||
version: 6,
|
||||
browserName: '',
|
||||
options: {},
|
||||
platform: 'unknown',
|
||||
wallTime: 0,
|
||||
monotonicTime: 0,
|
||||
sdkLanguage: 'javascript',
|
||||
contextId: '',
|
||||
};
|
||||
result.push(event);
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'context-options') {
|
||||
result.push({ ...event, monotonicTime: 0, origin: 'library', contextId: '' });
|
||||
continue;
|
||||
}
|
||||
if (event.type === 'before' || event.type === 'action') {
|
||||
// Take wall and monotonic time from the first event.
|
||||
if (!this._contextEntry.wallTime)
|
||||
this._contextEntry.wallTime = event.wallTime;
|
||||
const eventAsV6 = event as traceV6.BeforeActionTraceEvent;
|
||||
const eventAsV7 = event as traceV7.BeforeActionTraceEvent;
|
||||
eventAsV7.stepId = `${eventAsV6.apiName}@${eventAsV6.wallTime}`;
|
||||
result.push(eventAsV7);
|
||||
} else {
|
||||
result.push(event);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_modernize_7_to_8(events: traceV7.TraceEvent[]): traceV8.TraceEvent[] {
|
||||
const result: traceV8.TraceEvent[] = [];
|
||||
for (const event of events) {
|
||||
if (event.type === 'before' || event.type === 'action') {
|
||||
const eventAsV7 = event as traceV7.BeforeActionTraceEvent;
|
||||
const eventAsV8 = event as traceV8.BeforeActionTraceEvent;
|
||||
if (eventAsV7.apiName) {
|
||||
eventAsV8.title = eventAsV7.apiName;
|
||||
delete (eventAsV8 as any).apiName;
|
||||
}
|
||||
eventAsV8.stepId = eventAsV7.stepId ?? eventAsV7.callId;
|
||||
result.push(eventAsV8);
|
||||
} else {
|
||||
result.push(event);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ClientSideCallMetadata, StackFrame } from '@protocol/channels';
|
||||
|
||||
export type SerializedStackFrame = [number, number, number, string];
|
||||
export type SerializedStack = [number, SerializedStackFrame[]];
|
||||
|
||||
export type SerializedClientSideCallMetadata = {
|
||||
files: string[];
|
||||
stacks: SerializedStack[];
|
||||
};
|
||||
|
||||
export function parseClientSideCallMetadata(data: SerializedClientSideCallMetadata): Map<string, StackFrame[]> {
|
||||
const result = new Map<string, StackFrame[]>();
|
||||
const { files, stacks } = data;
|
||||
for (const s of stacks) {
|
||||
const [id, ff] = s;
|
||||
result.set(`call@${id}`, ff.map(f => ({ file: files[f[0]], line: f[1], column: f[2], function: f[3] })));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function serializeClientSideCallMetadata(metadatas: ClientSideCallMetadata[]): SerializedClientSideCallMetadata {
|
||||
const fileNames = new Map<string, number>();
|
||||
const stacks: SerializedStack[] = [];
|
||||
for (const m of metadatas) {
|
||||
if (!m.stack || !m.stack.length)
|
||||
continue;
|
||||
const stack: SerializedStackFrame[] = [];
|
||||
for (const frame of m.stack) {
|
||||
let ordinal = fileNames.get(frame.file);
|
||||
if (typeof ordinal !== 'number') {
|
||||
ordinal = fileNames.size;
|
||||
fileNames.set(frame.file, ordinal);
|
||||
}
|
||||
const stackFrame: SerializedStackFrame = [ordinal, frame.line || 0, frame.column || 0, frame.function || ''];
|
||||
stack.push(stackFrame);
|
||||
}
|
||||
stacks.push([m.id, stack]);
|
||||
}
|
||||
return { files: [...fileNames.keys()], stacks };
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entry as ResourceSnapshot } from '@trace/har';
|
||||
|
||||
type SerializedValue = {
|
||||
n?: number,
|
||||
b?: boolean,
|
||||
s?: string,
|
||||
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
|
||||
d?: string,
|
||||
u?: string,
|
||||
r?: {
|
||||
p: string,
|
||||
f: string,
|
||||
},
|
||||
a?: SerializedValue[],
|
||||
o?: {
|
||||
k: string,
|
||||
v: SerializedValue,
|
||||
}[],
|
||||
h?: number,
|
||||
id?: number,
|
||||
ref?: number,
|
||||
};
|
||||
|
||||
type Point = {
|
||||
x: number,
|
||||
y: number,
|
||||
};
|
||||
|
||||
type StackFrame = {
|
||||
file: string,
|
||||
line: number,
|
||||
column: number,
|
||||
function?: string,
|
||||
};
|
||||
|
||||
type SerializedError = {
|
||||
error?: {
|
||||
message: string,
|
||||
name: string,
|
||||
stack?: string,
|
||||
},
|
||||
value?: SerializedValue,
|
||||
};
|
||||
|
||||
type CallMetadata = {
|
||||
id: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
pauseStartTime?: number;
|
||||
pauseEndTime?: number;
|
||||
type: string;
|
||||
method: string;
|
||||
params: any;
|
||||
apiName?: string;
|
||||
internal?: boolean;
|
||||
isServerSide?: boolean;
|
||||
wallTime?: number;
|
||||
location?: { file: string, line?: number, column?: number };
|
||||
log: string[];
|
||||
afterSnapshot?: string;
|
||||
snapshots: { title: string, snapshotName: string }[];
|
||||
error?: SerializedError;
|
||||
result?: any;
|
||||
point?: Point;
|
||||
objectId?: string;
|
||||
pageId?: string;
|
||||
frameId?: string;
|
||||
};
|
||||
|
||||
export type NodeSnapshot =
|
||||
string |
|
||||
[ [number, number] ] |
|
||||
[ string ] |
|
||||
[ string, { [attr: string]: string }, ...any ];
|
||||
|
||||
|
||||
export type ResourceOverride = {
|
||||
url: string,
|
||||
sha1?: string,
|
||||
ref?: number
|
||||
};
|
||||
|
||||
export type FrameSnapshot = {
|
||||
// There was no callId in the original, we are intentionally regressing it.
|
||||
callId: string;
|
||||
snapshotName?: string,
|
||||
pageId: string,
|
||||
frameId: string,
|
||||
frameUrl: string,
|
||||
timestamp: number,
|
||||
collectionTime: number,
|
||||
doctype?: string,
|
||||
html: NodeSnapshot,
|
||||
resourceOverrides: ResourceOverride[],
|
||||
viewport: { width: number, height: number },
|
||||
isMainFrame: boolean,
|
||||
};
|
||||
|
||||
|
||||
export type BrowserContextEventOptions = {
|
||||
viewport?: { width: number, height: number },
|
||||
deviceScaleFactor?: number,
|
||||
isMobile?: boolean,
|
||||
userAgent?: string,
|
||||
};
|
||||
|
||||
export type ContextCreatedTraceEvent = {
|
||||
version: number,
|
||||
type: 'context-options',
|
||||
browserName: string,
|
||||
platform: string,
|
||||
wallTime: number,
|
||||
title?: string,
|
||||
options: BrowserContextEventOptions,
|
||||
sdkLanguage?: 'javascript' | 'python' | 'java' | 'csharp',
|
||||
testIdAttributeName?: string,
|
||||
};
|
||||
|
||||
export type ScreencastFrameTraceEvent = {
|
||||
type: 'screencast-frame',
|
||||
pageId: string,
|
||||
sha1: string,
|
||||
width: number,
|
||||
height: number,
|
||||
timestamp: number,
|
||||
};
|
||||
|
||||
export type ActionTraceEvent = {
|
||||
type: 'action' | 'event',
|
||||
metadata: CallMetadata & { stack?: StackFrame[] },
|
||||
};
|
||||
|
||||
export type ResourceSnapshotTraceEvent = {
|
||||
type: 'resource-snapshot',
|
||||
snapshot: ResourceSnapshot,
|
||||
};
|
||||
|
||||
export type FrameSnapshotTraceEvent = {
|
||||
type: 'frame-snapshot',
|
||||
snapshot: FrameSnapshot,
|
||||
};
|
||||
|
||||
export type TraceEvent =
|
||||
ContextCreatedTraceEvent |
|
||||
ScreencastFrameTraceEvent |
|
||||
ActionTraceEvent |
|
||||
ResourceSnapshotTraceEvent |
|
||||
FrameSnapshotTraceEvent;
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entry as ResourceSnapshot } from '@trace/har';
|
||||
|
||||
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
|
||||
type Point = { x: number, y: number };
|
||||
type Size = { width: number, height: number };
|
||||
|
||||
type StackFrame = {
|
||||
file: string,
|
||||
line: number,
|
||||
column: number,
|
||||
function?: string,
|
||||
};
|
||||
|
||||
type SerializedValue = {
|
||||
n?: number,
|
||||
b?: boolean,
|
||||
s?: string,
|
||||
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
|
||||
d?: string,
|
||||
u?: string,
|
||||
bi?: string,
|
||||
m?: SerializedValue,
|
||||
se?: SerializedValue,
|
||||
r?: {
|
||||
p: string,
|
||||
f: string,
|
||||
},
|
||||
a?: SerializedValue[],
|
||||
o?: {
|
||||
k: string,
|
||||
v: SerializedValue,
|
||||
}[],
|
||||
h?: number,
|
||||
id?: number,
|
||||
ref?: number,
|
||||
};
|
||||
|
||||
type SerializedError = {
|
||||
error?: {
|
||||
message: string,
|
||||
name: string,
|
||||
stack?: string,
|
||||
},
|
||||
value?: SerializedValue,
|
||||
};
|
||||
|
||||
type NodeSnapshot =
|
||||
// Text node.
|
||||
string |
|
||||
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
|
||||
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
|
||||
[ [number, number] ] |
|
||||
// Just node name.
|
||||
[ string ] |
|
||||
// Node name, attributes, child nodes.
|
||||
// Unfortunately, we cannot make this type definition recursive, therefore "any".
|
||||
[ string, { [attr: string]: string }, ...any ];
|
||||
|
||||
|
||||
type ResourceOverride = {
|
||||
url: string,
|
||||
sha1?: string,
|
||||
ref?: number
|
||||
};
|
||||
|
||||
type FrameSnapshot = {
|
||||
snapshotName?: string,
|
||||
callId: string,
|
||||
pageId: string,
|
||||
frameId: string,
|
||||
frameUrl: string,
|
||||
timestamp: number,
|
||||
collectionTime: number,
|
||||
doctype?: string,
|
||||
html: NodeSnapshot,
|
||||
resourceOverrides: ResourceOverride[],
|
||||
viewport: { width: number, height: number },
|
||||
isMainFrame: boolean,
|
||||
};
|
||||
|
||||
type BrowserContextEventOptions = {
|
||||
viewport?: Size,
|
||||
deviceScaleFactor?: number,
|
||||
isMobile?: boolean,
|
||||
userAgent?: string,
|
||||
};
|
||||
|
||||
type ContextCreatedTraceEvent = {
|
||||
version: number,
|
||||
type: 'context-options',
|
||||
browserName: string,
|
||||
channel?: string,
|
||||
platform: string,
|
||||
wallTime: number,
|
||||
title?: string,
|
||||
options: BrowserContextEventOptions,
|
||||
sdkLanguage?: Language,
|
||||
testIdAttributeName?: string,
|
||||
};
|
||||
|
||||
type ScreencastFrameTraceEvent = {
|
||||
type: 'screencast-frame',
|
||||
pageId: string,
|
||||
sha1: string,
|
||||
width: number,
|
||||
height: number,
|
||||
timestamp: number,
|
||||
};
|
||||
|
||||
type BeforeActionTraceEvent = {
|
||||
type: 'before',
|
||||
callId: string;
|
||||
startTime: number;
|
||||
apiName: string;
|
||||
class: string;
|
||||
method: string;
|
||||
params: Record<string, any>;
|
||||
wallTime: number;
|
||||
beforeSnapshot?: string;
|
||||
stack?: StackFrame[];
|
||||
pageId?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
type InputActionTraceEvent = {
|
||||
type: 'input',
|
||||
callId: string;
|
||||
inputSnapshot?: string;
|
||||
point?: Point;
|
||||
};
|
||||
|
||||
type AfterActionTraceEventAttachment = {
|
||||
name: string;
|
||||
contentType: string;
|
||||
path?: string;
|
||||
sha1?: string;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
type AfterActionTraceEvent = {
|
||||
type: 'after',
|
||||
callId: string;
|
||||
endTime: number;
|
||||
afterSnapshot?: string;
|
||||
log: string[];
|
||||
error?: SerializedError['error'];
|
||||
attachments?: AfterActionTraceEventAttachment[];
|
||||
result?: any;
|
||||
};
|
||||
|
||||
type EventTraceEvent = {
|
||||
type: 'event',
|
||||
time: number;
|
||||
class: string;
|
||||
method: string;
|
||||
params: any;
|
||||
pageId?: string;
|
||||
};
|
||||
|
||||
type ConsoleMessageTraceEvent = {
|
||||
type: 'object';
|
||||
class: string;
|
||||
initializer: {
|
||||
type: string,
|
||||
text: string,
|
||||
location: {
|
||||
url: string,
|
||||
lineNumber: number,
|
||||
columnNumber: number,
|
||||
},
|
||||
};
|
||||
guid: string;
|
||||
};
|
||||
|
||||
type ResourceSnapshotTraceEvent = {
|
||||
type: 'resource-snapshot',
|
||||
snapshot: ResourceSnapshot,
|
||||
};
|
||||
|
||||
type FrameSnapshotTraceEvent = {
|
||||
type: 'frame-snapshot',
|
||||
snapshot: FrameSnapshot,
|
||||
};
|
||||
|
||||
type ActionTraceEvent = {
|
||||
type: 'action',
|
||||
} & Omit<BeforeActionTraceEvent, 'type'>
|
||||
& Omit<AfterActionTraceEvent, 'type'>
|
||||
& Omit<InputActionTraceEvent, 'type'>;
|
||||
|
||||
type StdioTraceEvent = {
|
||||
type: 'stdout' | 'stderr';
|
||||
timestamp: number;
|
||||
text?: string;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
export type TraceEvent =
|
||||
ContextCreatedTraceEvent |
|
||||
ScreencastFrameTraceEvent |
|
||||
ActionTraceEvent |
|
||||
BeforeActionTraceEvent |
|
||||
InputActionTraceEvent |
|
||||
AfterActionTraceEvent |
|
||||
EventTraceEvent |
|
||||
ConsoleMessageTraceEvent |
|
||||
ResourceSnapshotTraceEvent |
|
||||
FrameSnapshotTraceEvent |
|
||||
StdioTraceEvent;
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
import type { Entry as ResourceSnapshot } from '@trace/har';
|
||||
|
||||
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
|
||||
type Point = { x: number, y: number };
|
||||
type Size = { width: number, height: number };
|
||||
|
||||
type StackFrame = {
|
||||
file: string,
|
||||
line: number,
|
||||
column: number,
|
||||
function?: string,
|
||||
};
|
||||
|
||||
type SerializedValue = {
|
||||
n?: number,
|
||||
b?: boolean,
|
||||
s?: string,
|
||||
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
|
||||
d?: string,
|
||||
u?: string,
|
||||
bi?: string,
|
||||
m?: SerializedValue,
|
||||
se?: SerializedValue,
|
||||
r?: {
|
||||
p: string,
|
||||
f: string,
|
||||
},
|
||||
a?: SerializedValue[],
|
||||
o?: {
|
||||
k: string,
|
||||
v: SerializedValue,
|
||||
}[],
|
||||
h?: number,
|
||||
id?: number,
|
||||
ref?: number,
|
||||
};
|
||||
|
||||
type SerializedError = {
|
||||
error?: {
|
||||
message: string,
|
||||
name: string,
|
||||
stack?: string,
|
||||
},
|
||||
value?: SerializedValue,
|
||||
};
|
||||
|
||||
type NodeSnapshot =
|
||||
// Text node.
|
||||
string |
|
||||
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
|
||||
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
|
||||
[ [number, number] ] |
|
||||
// Just node name.
|
||||
[ string ] |
|
||||
// Node name, attributes, child nodes.
|
||||
// Unfortunately, we cannot make this type definition recursive, therefore "any".
|
||||
[ string, { [attr: string]: string }, ...any ];
|
||||
|
||||
|
||||
type ResourceOverride = {
|
||||
url: string,
|
||||
sha1?: string,
|
||||
ref?: number
|
||||
};
|
||||
|
||||
type FrameSnapshot = {
|
||||
snapshotName?: string,
|
||||
callId: string,
|
||||
pageId: string,
|
||||
frameId: string,
|
||||
frameUrl: string,
|
||||
timestamp: number,
|
||||
collectionTime: number,
|
||||
doctype?: string,
|
||||
html: NodeSnapshot,
|
||||
resourceOverrides: ResourceOverride[],
|
||||
viewport: { width: number, height: number },
|
||||
isMainFrame: boolean,
|
||||
};
|
||||
|
||||
export type BrowserContextEventOptions = {
|
||||
viewport?: Size,
|
||||
deviceScaleFactor?: number,
|
||||
isMobile?: boolean,
|
||||
userAgent?: string,
|
||||
};
|
||||
|
||||
export type ContextCreatedTraceEvent = {
|
||||
version: number,
|
||||
type: 'context-options',
|
||||
browserName: string,
|
||||
channel?: string,
|
||||
platform: string,
|
||||
wallTime: number,
|
||||
title?: string,
|
||||
options: BrowserContextEventOptions,
|
||||
sdkLanguage?: Language,
|
||||
testIdAttributeName?: string,
|
||||
};
|
||||
|
||||
export type ScreencastFrameTraceEvent = {
|
||||
type: 'screencast-frame',
|
||||
pageId: string,
|
||||
sha1: string,
|
||||
width: number,
|
||||
height: number,
|
||||
timestamp: number,
|
||||
};
|
||||
|
||||
export type BeforeActionTraceEvent = {
|
||||
type: 'before',
|
||||
callId: string;
|
||||
startTime: number;
|
||||
apiName: string;
|
||||
class: string;
|
||||
method: string;
|
||||
params: Record<string, any>;
|
||||
wallTime: number;
|
||||
beforeSnapshot?: string;
|
||||
stack?: StackFrame[];
|
||||
pageId?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
export type InputActionTraceEvent = {
|
||||
type: 'input',
|
||||
callId: string;
|
||||
inputSnapshot?: string;
|
||||
point?: Point;
|
||||
};
|
||||
|
||||
export type AfterActionTraceEventAttachment = {
|
||||
name: string;
|
||||
contentType: string;
|
||||
path?: string;
|
||||
sha1?: string;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
export type AfterActionTraceEvent = {
|
||||
type: 'after',
|
||||
callId: string;
|
||||
endTime: number;
|
||||
afterSnapshot?: string;
|
||||
log: string[];
|
||||
error?: SerializedError['error'];
|
||||
attachments?: AfterActionTraceEventAttachment[];
|
||||
result?: any;
|
||||
};
|
||||
|
||||
export type EventTraceEvent = {
|
||||
type: 'event',
|
||||
time: number;
|
||||
class: string;
|
||||
method: string;
|
||||
params: any;
|
||||
pageId?: string;
|
||||
};
|
||||
|
||||
export type ConsoleMessageTraceEvent = {
|
||||
type: 'console';
|
||||
time: number;
|
||||
pageId?: string;
|
||||
messageType: string,
|
||||
text: string,
|
||||
args?: { preview: string, value: any }[],
|
||||
location: {
|
||||
url: string,
|
||||
lineNumber: number,
|
||||
columnNumber: number,
|
||||
},
|
||||
};
|
||||
|
||||
export type ResourceSnapshotTraceEvent = {
|
||||
type: 'resource-snapshot',
|
||||
snapshot: ResourceSnapshot,
|
||||
};
|
||||
|
||||
export type FrameSnapshotTraceEvent = {
|
||||
type: 'frame-snapshot',
|
||||
snapshot: FrameSnapshot,
|
||||
};
|
||||
|
||||
export type ActionTraceEvent = {
|
||||
type: 'action',
|
||||
} & Omit<BeforeActionTraceEvent, 'type'>
|
||||
& Omit<AfterActionTraceEvent, 'type'>
|
||||
& Omit<InputActionTraceEvent, 'type'>;
|
||||
|
||||
export type StdioTraceEvent = {
|
||||
type: 'stdout' | 'stderr';
|
||||
timestamp: number;
|
||||
text?: string;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
export type TraceEvent =
|
||||
ContextCreatedTraceEvent |
|
||||
ScreencastFrameTraceEvent |
|
||||
ActionTraceEvent |
|
||||
BeforeActionTraceEvent |
|
||||
InputActionTraceEvent |
|
||||
AfterActionTraceEvent |
|
||||
EventTraceEvent |
|
||||
ConsoleMessageTraceEvent |
|
||||
ResourceSnapshotTraceEvent |
|
||||
FrameSnapshotTraceEvent |
|
||||
StdioTraceEvent;
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entry as ResourceSnapshot } from '@trace/har';
|
||||
|
||||
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
|
||||
type Point = { x: number, y: number };
|
||||
type Size = { width: number, height: number };
|
||||
|
||||
type StackFrame = {
|
||||
file: string,
|
||||
line: number,
|
||||
column: number,
|
||||
function?: string,
|
||||
};
|
||||
|
||||
type SerializedValue = {
|
||||
n?: number,
|
||||
b?: boolean,
|
||||
s?: string,
|
||||
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
|
||||
d?: string,
|
||||
u?: string,
|
||||
bi?: string,
|
||||
m?: SerializedValue,
|
||||
se?: SerializedValue,
|
||||
r?: {
|
||||
p: string,
|
||||
f: string,
|
||||
},
|
||||
a?: SerializedValue[],
|
||||
o?: {
|
||||
k: string,
|
||||
v: SerializedValue,
|
||||
}[],
|
||||
h?: number,
|
||||
id?: number,
|
||||
ref?: number,
|
||||
};
|
||||
|
||||
type SerializedError = {
|
||||
error?: {
|
||||
message: string,
|
||||
name: string,
|
||||
stack?: string,
|
||||
},
|
||||
value?: SerializedValue,
|
||||
};
|
||||
|
||||
type NodeSnapshot =
|
||||
// Text node.
|
||||
string |
|
||||
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
|
||||
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
|
||||
[ [number, number] ] |
|
||||
// Just node name.
|
||||
[ string ] |
|
||||
// Node name, attributes, child nodes.
|
||||
// Unfortunately, we cannot make this type definition recursive, therefore "any".
|
||||
[ string, { [attr: string]: string }, ...any ];
|
||||
|
||||
|
||||
type ResourceOverride = {
|
||||
url: string,
|
||||
sha1?: string,
|
||||
ref?: number
|
||||
};
|
||||
|
||||
type FrameSnapshot = {
|
||||
snapshotName?: string,
|
||||
callId: string,
|
||||
pageId: string,
|
||||
frameId: string,
|
||||
frameUrl: string,
|
||||
timestamp: number,
|
||||
collectionTime: number,
|
||||
doctype?: string,
|
||||
html: NodeSnapshot,
|
||||
resourceOverrides: ResourceOverride[],
|
||||
viewport: { width: number, height: number },
|
||||
isMainFrame: boolean,
|
||||
};
|
||||
|
||||
export type BrowserContextEventOptions = {
|
||||
viewport?: Size,
|
||||
deviceScaleFactor?: number,
|
||||
isMobile?: boolean,
|
||||
userAgent?: string,
|
||||
};
|
||||
|
||||
export type ContextCreatedTraceEvent = {
|
||||
version: number,
|
||||
type: 'context-options',
|
||||
browserName: string,
|
||||
channel?: string,
|
||||
platform: string,
|
||||
wallTime: number,
|
||||
title?: string,
|
||||
options: BrowserContextEventOptions,
|
||||
sdkLanguage?: Language,
|
||||
testIdAttributeName?: string,
|
||||
};
|
||||
|
||||
export type ScreencastFrameTraceEvent = {
|
||||
type: 'screencast-frame',
|
||||
pageId: string,
|
||||
sha1: string,
|
||||
width: number,
|
||||
height: number,
|
||||
timestamp: number,
|
||||
};
|
||||
|
||||
export type BeforeActionTraceEvent = {
|
||||
type: 'before',
|
||||
callId: string;
|
||||
startTime: number;
|
||||
apiName: string;
|
||||
class: string;
|
||||
method: string;
|
||||
params: Record<string, any>;
|
||||
wallTime: number;
|
||||
beforeSnapshot?: string;
|
||||
stack?: StackFrame[];
|
||||
pageId?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
export type InputActionTraceEvent = {
|
||||
type: 'input',
|
||||
callId: string;
|
||||
inputSnapshot?: string;
|
||||
point?: Point;
|
||||
};
|
||||
|
||||
export type AfterActionTraceEventAttachment = {
|
||||
name: string;
|
||||
contentType: string;
|
||||
path?: string;
|
||||
sha1?: string;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
export type AfterActionTraceEvent = {
|
||||
type: 'after',
|
||||
callId: string;
|
||||
endTime: number;
|
||||
afterSnapshot?: string;
|
||||
error?: SerializedError['error'];
|
||||
attachments?: AfterActionTraceEventAttachment[];
|
||||
result?: any;
|
||||
point?: Point;
|
||||
};
|
||||
|
||||
export type LogTraceEvent = {
|
||||
type: 'log',
|
||||
callId: string;
|
||||
time: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type EventTraceEvent = {
|
||||
type: 'event',
|
||||
time: number;
|
||||
class: string;
|
||||
method: string;
|
||||
params: any;
|
||||
pageId?: string;
|
||||
};
|
||||
|
||||
export type ConsoleMessageTraceEvent = {
|
||||
type: 'console';
|
||||
time: number;
|
||||
pageId?: string;
|
||||
messageType: string,
|
||||
text: string,
|
||||
args?: { preview: string, value: any }[],
|
||||
location: {
|
||||
url: string,
|
||||
lineNumber: number,
|
||||
columnNumber: number,
|
||||
},
|
||||
};
|
||||
|
||||
export type ResourceSnapshotTraceEvent = {
|
||||
type: 'resource-snapshot',
|
||||
snapshot: ResourceSnapshot,
|
||||
};
|
||||
|
||||
export type FrameSnapshotTraceEvent = {
|
||||
type: 'frame-snapshot',
|
||||
snapshot: FrameSnapshot,
|
||||
};
|
||||
|
||||
export type ActionTraceEvent = {
|
||||
type: 'action',
|
||||
} & Omit<BeforeActionTraceEvent, 'type'>
|
||||
& Omit<AfterActionTraceEvent, 'type'>
|
||||
& Omit<InputActionTraceEvent, 'type'>;
|
||||
|
||||
export type StdioTraceEvent = {
|
||||
type: 'stdout' | 'stderr';
|
||||
timestamp: number;
|
||||
text?: string;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
export type ErrorTraceEvent = {
|
||||
type: 'error';
|
||||
message: string;
|
||||
stack?: StackFrame[];
|
||||
};
|
||||
|
||||
export type TraceEvent =
|
||||
ContextCreatedTraceEvent |
|
||||
ScreencastFrameTraceEvent |
|
||||
ActionTraceEvent |
|
||||
BeforeActionTraceEvent |
|
||||
InputActionTraceEvent |
|
||||
AfterActionTraceEvent |
|
||||
EventTraceEvent |
|
||||
LogTraceEvent |
|
||||
ConsoleMessageTraceEvent |
|
||||
ResourceSnapshotTraceEvent |
|
||||
FrameSnapshotTraceEvent |
|
||||
StdioTraceEvent |
|
||||
ErrorTraceEvent;
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entry as ResourceSnapshot } from '@trace/har';
|
||||
|
||||
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
|
||||
type Point = { x: number, y: number };
|
||||
export type Size = { width: number, height: number };
|
||||
|
||||
type StackFrame = {
|
||||
file: string,
|
||||
line: number,
|
||||
column: number,
|
||||
function?: string,
|
||||
};
|
||||
|
||||
type Binary = Buffer;
|
||||
|
||||
type SerializedValue = {
|
||||
n?: number,
|
||||
b?: boolean,
|
||||
s?: string,
|
||||
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
|
||||
d?: string,
|
||||
u?: string,
|
||||
bi?: string,
|
||||
ta?: {
|
||||
b: Binary,
|
||||
k: 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64',
|
||||
},
|
||||
e?: {
|
||||
m: string,
|
||||
n: string,
|
||||
s: string,
|
||||
},
|
||||
r?: {
|
||||
p: string,
|
||||
f: string,
|
||||
},
|
||||
a?: SerializedValue[],
|
||||
o?: {
|
||||
k: string,
|
||||
v: SerializedValue,
|
||||
}[],
|
||||
h?: number,
|
||||
id?: number,
|
||||
ref?: number,
|
||||
};
|
||||
|
||||
type SerializedError = {
|
||||
error?: {
|
||||
message: string,
|
||||
name: string,
|
||||
stack?: string,
|
||||
},
|
||||
value?: SerializedValue,
|
||||
};
|
||||
|
||||
// Text node.
|
||||
type TextNodeSnapshot = string;
|
||||
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
|
||||
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
|
||||
type SubtreeReferenceSnapshot = [ [number, number] ];
|
||||
// Node name, and optional attributes and child nodes.
|
||||
type NodeNameAttributesChildNodesSnapshot = [ string ] | [ string, Record<string, string>, ...NodeSnapshot[] ];
|
||||
|
||||
type NodeSnapshot =
|
||||
TextNodeSnapshot |
|
||||
SubtreeReferenceSnapshot |
|
||||
NodeNameAttributesChildNodesSnapshot;
|
||||
|
||||
type ResourceOverride = {
|
||||
url: string,
|
||||
sha1?: string,
|
||||
ref?: number
|
||||
};
|
||||
|
||||
type FrameSnapshot = {
|
||||
snapshotName?: string,
|
||||
callId: string,
|
||||
pageId: string,
|
||||
frameId: string,
|
||||
frameUrl: string,
|
||||
timestamp: number,
|
||||
wallTime?: number,
|
||||
collectionTime: number,
|
||||
doctype?: string,
|
||||
html: NodeSnapshot,
|
||||
resourceOverrides: ResourceOverride[],
|
||||
viewport: { width: number, height: number },
|
||||
isMainFrame: boolean,
|
||||
};
|
||||
|
||||
type BrowserContextEventOptions = {
|
||||
baseURL?: string,
|
||||
viewport?: Size,
|
||||
deviceScaleFactor?: number,
|
||||
isMobile?: boolean,
|
||||
userAgent?: string,
|
||||
};
|
||||
|
||||
export type ContextCreatedTraceEvent = {
|
||||
version: number,
|
||||
type: 'context-options',
|
||||
origin: 'testRunner' | 'library',
|
||||
browserName: string,
|
||||
channel?: string,
|
||||
platform: string,
|
||||
wallTime: number,
|
||||
monotonicTime: number,
|
||||
title?: string,
|
||||
options: BrowserContextEventOptions,
|
||||
sdkLanguage?: Language,
|
||||
testIdAttributeName?: string,
|
||||
contextId?: string,
|
||||
};
|
||||
|
||||
export type ScreencastFrameTraceEvent = {
|
||||
type: 'screencast-frame',
|
||||
pageId: string,
|
||||
sha1: string,
|
||||
width: number,
|
||||
height: number,
|
||||
timestamp: number,
|
||||
frameSwapWallTime?: number,
|
||||
};
|
||||
|
||||
export type BeforeActionTraceEvent = {
|
||||
type: 'before',
|
||||
callId: string;
|
||||
startTime: number;
|
||||
apiName: string;
|
||||
class: string;
|
||||
method: string;
|
||||
params: Record<string, any>;
|
||||
stepId?: string;
|
||||
beforeSnapshot?: string;
|
||||
stack?: StackFrame[];
|
||||
pageId?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
export type InputActionTraceEvent = {
|
||||
type: 'input',
|
||||
callId: string;
|
||||
inputSnapshot?: string;
|
||||
point?: Point;
|
||||
};
|
||||
|
||||
export type AfterActionTraceEventAttachment = {
|
||||
name: string;
|
||||
contentType: string;
|
||||
path?: string;
|
||||
sha1?: string;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
export type AfterActionTraceEventAnnotation = {
|
||||
type: string,
|
||||
description?: string
|
||||
};
|
||||
|
||||
export type AfterActionTraceEvent = {
|
||||
type: 'after',
|
||||
callId: string;
|
||||
endTime: number;
|
||||
afterSnapshot?: string;
|
||||
error?: SerializedError['error'];
|
||||
attachments?: AfterActionTraceEventAttachment[];
|
||||
annotations?: AfterActionTraceEventAnnotation[];
|
||||
result?: any;
|
||||
point?: Point;
|
||||
};
|
||||
|
||||
export type LogTraceEvent = {
|
||||
type: 'log',
|
||||
callId: string;
|
||||
time: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type EventTraceEvent = {
|
||||
type: 'event',
|
||||
time: number;
|
||||
class: string;
|
||||
method: string;
|
||||
params: any;
|
||||
pageId?: string;
|
||||
};
|
||||
|
||||
export type ConsoleMessageTraceEvent = {
|
||||
type: 'console';
|
||||
time: number;
|
||||
pageId?: string;
|
||||
messageType: string,
|
||||
text: string,
|
||||
args?: { preview: string, value: any }[],
|
||||
location: {
|
||||
url: string,
|
||||
lineNumber: number,
|
||||
columnNumber: number,
|
||||
},
|
||||
};
|
||||
|
||||
export type ResourceSnapshotTraceEvent = {
|
||||
type: 'resource-snapshot',
|
||||
snapshot: ResourceSnapshot,
|
||||
};
|
||||
|
||||
export type FrameSnapshotTraceEvent = {
|
||||
type: 'frame-snapshot',
|
||||
snapshot: FrameSnapshot,
|
||||
};
|
||||
|
||||
export type ActionTraceEvent = {
|
||||
type: 'action',
|
||||
} & Omit<BeforeActionTraceEvent, 'type'>
|
||||
& Omit<AfterActionTraceEvent, 'type'>
|
||||
& Omit<InputActionTraceEvent, 'type'>;
|
||||
|
||||
export type StdioTraceEvent = {
|
||||
type: 'stdout' | 'stderr';
|
||||
timestamp: number;
|
||||
text?: string;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
export type ErrorTraceEvent = {
|
||||
type: 'error';
|
||||
message: string;
|
||||
stack?: StackFrame[];
|
||||
};
|
||||
|
||||
export type TraceEvent =
|
||||
ContextCreatedTraceEvent |
|
||||
ScreencastFrameTraceEvent |
|
||||
ActionTraceEvent |
|
||||
BeforeActionTraceEvent |
|
||||
InputActionTraceEvent |
|
||||
AfterActionTraceEvent |
|
||||
EventTraceEvent |
|
||||
LogTraceEvent |
|
||||
ConsoleMessageTraceEvent |
|
||||
ResourceSnapshotTraceEvent |
|
||||
FrameSnapshotTraceEvent |
|
||||
StdioTraceEvent |
|
||||
ErrorTraceEvent;
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entry as ResourceSnapshot } from '@trace/har';
|
||||
|
||||
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
|
||||
type Point = { x: number, y: number };
|
||||
type Size = { width: number, height: number };
|
||||
|
||||
type StackFrame = {
|
||||
file: string,
|
||||
line: number,
|
||||
column: number,
|
||||
function?: string,
|
||||
};
|
||||
|
||||
type Binary = Buffer;
|
||||
|
||||
type SerializedValue = {
|
||||
n?: number,
|
||||
b?: boolean,
|
||||
s?: string,
|
||||
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
|
||||
d?: string,
|
||||
u?: string,
|
||||
bi?: string,
|
||||
ta?: {
|
||||
b: Binary,
|
||||
k: 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64',
|
||||
},
|
||||
e?: {
|
||||
m: string,
|
||||
n: string,
|
||||
s: string,
|
||||
},
|
||||
r?: {
|
||||
p: string,
|
||||
f: string,
|
||||
},
|
||||
a?: SerializedValue[],
|
||||
o?: {
|
||||
k: string,
|
||||
v: SerializedValue,
|
||||
}[],
|
||||
h?: number,
|
||||
id?: number,
|
||||
ref?: number,
|
||||
};
|
||||
|
||||
type SerializedError = {
|
||||
error?: {
|
||||
message: string,
|
||||
name: string,
|
||||
stack?: string,
|
||||
},
|
||||
value?: SerializedValue,
|
||||
};
|
||||
|
||||
// Text node.
|
||||
type TextNodeSnapshot = string;
|
||||
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
|
||||
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
|
||||
type SubtreeReferenceSnapshot = [ [number, number] ];
|
||||
// Node name, and optional attributes and child nodes.
|
||||
type NodeNameAttributesChildNodesSnapshot = [ string ] | [ string, Record<string, string>, ...NodeSnapshot[] ];
|
||||
|
||||
type NodeSnapshot =
|
||||
TextNodeSnapshot |
|
||||
SubtreeReferenceSnapshot |
|
||||
NodeNameAttributesChildNodesSnapshot;
|
||||
|
||||
type ResourceOverride = {
|
||||
url: string,
|
||||
sha1?: string,
|
||||
ref?: number
|
||||
};
|
||||
|
||||
type FrameSnapshot = {
|
||||
snapshotName?: string,
|
||||
callId: string,
|
||||
pageId: string,
|
||||
frameId: string,
|
||||
frameUrl: string,
|
||||
timestamp: number,
|
||||
wallTime?: number,
|
||||
collectionTime: number,
|
||||
doctype?: string,
|
||||
html: NodeSnapshot,
|
||||
resourceOverrides: ResourceOverride[],
|
||||
viewport: { width: number, height: number },
|
||||
isMainFrame: boolean,
|
||||
};
|
||||
|
||||
type BrowserContextEventOptions = {
|
||||
baseURL?: string,
|
||||
viewport?: Size,
|
||||
deviceScaleFactor?: number,
|
||||
isMobile?: boolean,
|
||||
userAgent?: string,
|
||||
};
|
||||
|
||||
export type ContextCreatedTraceEvent = {
|
||||
version: number,
|
||||
type: 'context-options',
|
||||
origin: 'testRunner' | 'library',
|
||||
browserName: string,
|
||||
channel?: string,
|
||||
platform: string,
|
||||
wallTime: number,
|
||||
monotonicTime: number,
|
||||
title?: string,
|
||||
options: BrowserContextEventOptions,
|
||||
sdkLanguage?: Language,
|
||||
testIdAttributeName?: string,
|
||||
contextId?: string,
|
||||
};
|
||||
|
||||
export type ScreencastFrameTraceEvent = {
|
||||
type: 'screencast-frame',
|
||||
pageId: string,
|
||||
sha1: string,
|
||||
width: number,
|
||||
height: number,
|
||||
timestamp: number,
|
||||
frameSwapWallTime?: number,
|
||||
};
|
||||
|
||||
export type BeforeActionTraceEvent = {
|
||||
type: 'before',
|
||||
callId: string;
|
||||
startTime: number;
|
||||
title?: string;
|
||||
class: string;
|
||||
method: string;
|
||||
params: Record<string, any>;
|
||||
stepId?: string;
|
||||
beforeSnapshot?: string;
|
||||
stack?: StackFrame[];
|
||||
pageId?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
export type InputActionTraceEvent = {
|
||||
type: 'input',
|
||||
callId: string;
|
||||
inputSnapshot?: string;
|
||||
point?: Point;
|
||||
};
|
||||
|
||||
export type AfterActionTraceEventAttachment = {
|
||||
name: string;
|
||||
contentType: string;
|
||||
path?: string;
|
||||
sha1?: string;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
export type AfterActionTraceEventAnnotation = {
|
||||
type: string,
|
||||
description?: string
|
||||
};
|
||||
|
||||
export type AfterActionTraceEvent = {
|
||||
type: 'after',
|
||||
callId: string;
|
||||
endTime: number;
|
||||
afterSnapshot?: string;
|
||||
error?: SerializedError['error'];
|
||||
attachments?: AfterActionTraceEventAttachment[];
|
||||
annotations?: AfterActionTraceEventAnnotation[];
|
||||
result?: any;
|
||||
point?: Point;
|
||||
};
|
||||
|
||||
export type LogTraceEvent = {
|
||||
type: 'log',
|
||||
callId: string;
|
||||
time: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type EventTraceEvent = {
|
||||
type: 'event',
|
||||
time: number;
|
||||
class: string;
|
||||
method: string;
|
||||
params: any;
|
||||
pageId?: string;
|
||||
};
|
||||
|
||||
export type ConsoleMessageTraceEvent = {
|
||||
type: 'console';
|
||||
time: number;
|
||||
pageId?: string;
|
||||
messageType: string,
|
||||
text: string,
|
||||
args?: { preview: string, value: any }[],
|
||||
location: {
|
||||
url: string,
|
||||
lineNumber: number,
|
||||
columnNumber: number,
|
||||
},
|
||||
};
|
||||
|
||||
export type ResourceSnapshotTraceEvent = {
|
||||
type: 'resource-snapshot',
|
||||
snapshot: ResourceSnapshot,
|
||||
};
|
||||
|
||||
export type FrameSnapshotTraceEvent = {
|
||||
type: 'frame-snapshot',
|
||||
snapshot: FrameSnapshot,
|
||||
};
|
||||
|
||||
export type ActionTraceEvent = {
|
||||
type: 'action',
|
||||
} & Omit<BeforeActionTraceEvent, 'type'>
|
||||
& Omit<AfterActionTraceEvent, 'type'>
|
||||
& Omit<InputActionTraceEvent, 'type'>;
|
||||
|
||||
export type StdioTraceEvent = {
|
||||
type: 'stdout' | 'stderr';
|
||||
timestamp: number;
|
||||
text?: string;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
export type ErrorTraceEvent = {
|
||||
type: 'error';
|
||||
message: string;
|
||||
stack?: StackFrame[];
|
||||
};
|
||||
|
||||
export type TraceEvent =
|
||||
ContextCreatedTraceEvent |
|
||||
ScreencastFrameTraceEvent |
|
||||
ActionTraceEvent |
|
||||
BeforeActionTraceEvent |
|
||||
InputActionTraceEvent |
|
||||
AfterActionTraceEvent |
|
||||
EventTraceEvent |
|
||||
LogTraceEvent |
|
||||
ConsoleMessageTraceEvent |
|
||||
ResourceSnapshotTraceEvent |
|
||||
FrameSnapshotTraceEvent |
|
||||
StdioTraceEvent |
|
||||
ErrorTraceEvent;
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type Size = { width: number, height: number };
|
||||
export type Point = { x: number, y: number };
|
||||
export type Rect = Size & Point;
|
||||
export type Quad = [ Point, Point, Point, Point ];
|
||||
export type NameValue = { name: string, value: string };
|
||||
export type HeadersArray = NameValue[];
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isString } from './stringUtils';
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
|
||||
const escapedChars = new Set(['$', '^', '+', '.', '*', '(', ')', '|', '\\', '?', '{', '}', '[', ']']);
|
||||
|
||||
export function globToRegexPattern(glob: string): string {
|
||||
const tokens = ['^'];
|
||||
let inGroup = false;
|
||||
for (let i = 0; i < glob.length; ++i) {
|
||||
const c = glob[i];
|
||||
if (c === '\\' && i + 1 < glob.length) {
|
||||
const char = glob[++i];
|
||||
tokens.push(escapedChars.has(char) ? '\\' + char : char);
|
||||
continue;
|
||||
}
|
||||
if (c === '*') {
|
||||
const charBefore = glob[i - 1];
|
||||
let starCount = 1;
|
||||
while (glob[i + 1] === '*') {
|
||||
starCount++;
|
||||
i++;
|
||||
}
|
||||
if (starCount > 1) {
|
||||
const charAfter = glob[i + 1];
|
||||
// Match either /..something../ or /.
|
||||
if (charAfter === '/') {
|
||||
if (charBefore === '/')
|
||||
tokens.push('((.+/)|)');
|
||||
else
|
||||
tokens.push('(.*/)');
|
||||
++i;
|
||||
} else {
|
||||
tokens.push('(.*)');
|
||||
}
|
||||
} else {
|
||||
tokens.push('([^/]*)');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (c) {
|
||||
case '{':
|
||||
inGroup = true;
|
||||
tokens.push('(');
|
||||
break;
|
||||
case '}':
|
||||
inGroup = false;
|
||||
tokens.push(')');
|
||||
break;
|
||||
case ',':
|
||||
if (inGroup) {
|
||||
tokens.push('|');
|
||||
break;
|
||||
}
|
||||
tokens.push('\\' + c);
|
||||
break;
|
||||
default:
|
||||
tokens.push(escapedChars.has(c) ? '\\' + c : c);
|
||||
}
|
||||
}
|
||||
tokens.push('$');
|
||||
return tokens.join('');
|
||||
}
|
||||
|
||||
function isRegExp(obj: any): obj is RegExp {
|
||||
return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]';
|
||||
}
|
||||
|
||||
export type URLMatch = string | RegExp | ((url: URL) => boolean) | URLPattern;
|
||||
// URLPattern is not in @types/node@18, so we polyfill it ourselves
|
||||
export type URLPattern = {
|
||||
test(input: string | URL): boolean;
|
||||
hash: string;
|
||||
hostname: string;
|
||||
password: string;
|
||||
pathname: string;
|
||||
port: string;
|
||||
protocol: string;
|
||||
search: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
// @ts-ignore URLPattern is not in @types/node yet
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
export const isURLPattern = (v: unknown): v is URLPattern => typeof globalThis.URLPattern === 'function' && v instanceof globalThis.URLPattern;
|
||||
|
||||
export function serializeURLPattern(v: URLPattern) {
|
||||
return {
|
||||
hash: v.hash,
|
||||
hostname: v.hostname,
|
||||
password: v.password,
|
||||
pathname: v.pathname,
|
||||
port: v.port,
|
||||
protocol: v.protocol,
|
||||
search: v.search,
|
||||
username: v.username,
|
||||
};
|
||||
}
|
||||
|
||||
export type SerializedURLMatch = { glob?: string, regexSource?: string, regexFlags?: string, urlPattern?: ReturnType<typeof serializeURLPattern> };
|
||||
|
||||
export function serializeURLMatch(match: URLMatch): SerializedURLMatch | undefined {
|
||||
if (isString(match))
|
||||
return { glob: match };
|
||||
if (isRegExp(match))
|
||||
return { regexSource: match.source, regexFlags: match.flags };
|
||||
if (isURLPattern(match))
|
||||
return { urlPattern: serializeURLPattern(match) };
|
||||
// Functions cannot be serialized
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function deserializeURLPattern(v: ReturnType<typeof serializeURLPattern>): URLPattern | ((url: URL) => boolean) {
|
||||
// Client is on Node 24+ and can use URLPattern, Server is not. Let's match all URLs on the server, they'll be filtered again on the client.
|
||||
// @ts-ignore URLPattern is not in @types/node yet
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
if (typeof globalThis.URLPattern !== 'function')
|
||||
return () => true;
|
||||
|
||||
// @ts-ignore URLPattern is not in @types/node yet
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
return new globalThis.URLPattern({
|
||||
hash: v.hash,
|
||||
hostname: v.hostname,
|
||||
password: v.password,
|
||||
pathname: v.pathname,
|
||||
port: v.port,
|
||||
protocol: v.protocol,
|
||||
search: v.search,
|
||||
username: v.username,
|
||||
});
|
||||
}
|
||||
|
||||
export function deserializeURLMatch(match: { glob?: string, regexSource?: string, regexFlags?: string, urlPattern?: ReturnType<typeof serializeURLPattern> }): URLMatch {
|
||||
if (match.regexSource)
|
||||
return new RegExp(match.regexSource, match.regexFlags);
|
||||
if (match.urlPattern)
|
||||
return deserializeURLPattern(match.urlPattern);
|
||||
return match.glob!;
|
||||
}
|
||||
|
||||
export function urlMatchesEqual(match1: URLMatch, match2: URLMatch) {
|
||||
if (isRegExp(match1) && isRegExp(match2))
|
||||
return match1.source === match2.source && match1.flags === match2.flags;
|
||||
return match1 === match2;
|
||||
}
|
||||
|
||||
export function urlMatches(baseURL: string | undefined, urlString: string, match: URLMatch | undefined, webSocketUrl?: boolean): boolean {
|
||||
if (match === undefined || match === '')
|
||||
return true;
|
||||
if (isString(match))
|
||||
match = new RegExp(resolveGlobToRegexPattern(baseURL, match, webSocketUrl));
|
||||
if (isRegExp(match)) {
|
||||
const r = match.test(urlString);
|
||||
return r;
|
||||
}
|
||||
const url = parseURL(urlString);
|
||||
if (!url)
|
||||
return false;
|
||||
if (isURLPattern(match))
|
||||
return match.test(url.href);
|
||||
if (typeof match !== 'function')
|
||||
throw new Error('url parameter should be string, RegExp, URLPattern or function');
|
||||
return match(url);
|
||||
}
|
||||
|
||||
export function resolveGlobToRegexPattern(baseURL: string | undefined, glob: string, webSocketUrl?: boolean): string {
|
||||
if (webSocketUrl)
|
||||
baseURL = toWebSocketBaseUrl(baseURL);
|
||||
glob = resolveGlobBase(baseURL, glob);
|
||||
return globToRegexPattern(glob);
|
||||
}
|
||||
|
||||
function toWebSocketBaseUrl(baseURL: string | undefined) {
|
||||
// Allow http(s) baseURL to match ws(s) urls.
|
||||
if (baseURL && /^https?:\/\//.test(baseURL))
|
||||
baseURL = baseURL.replace(/^http/, 'ws');
|
||||
return baseURL;
|
||||
}
|
||||
|
||||
function resolveGlobBase(baseURL: string | undefined, match: string): string {
|
||||
if (!match.startsWith('*')) {
|
||||
const tokenMap = new Map<string, string>();
|
||||
function mapToken(original: string, replacement: string) {
|
||||
if (original.length === 0)
|
||||
return '';
|
||||
tokenMap.set(replacement, original);
|
||||
return replacement;
|
||||
}
|
||||
// Escaped `\\?` behaves the same as `?` in our glob patterns.
|
||||
match = match.replaceAll(/\\\\\?/g, '?');
|
||||
// Special case about: URLs as they are not relative to baseURL
|
||||
if (match.startsWith('about:') || match.startsWith('data:')
|
||||
|| match.startsWith('chrome:') || match.startsWith('edge:')
|
||||
|| match.startsWith('file:'))
|
||||
return match;
|
||||
// Glob symbols may be escaped in the URL and some of them such as ? affect resolution,
|
||||
// so we replace them with safe components first.
|
||||
const relativePath = match.split('/').map((token, index) => {
|
||||
if (token === '.' || token === '..' || token === '')
|
||||
return token;
|
||||
// Handle special case of http*://, note that the new schema has to be
|
||||
// a web schema so that slashes are properly inserted after domain.
|
||||
if (index === 0 && token.endsWith(':')) {
|
||||
// Replace any pattern with http:
|
||||
if (token.indexOf('*') !== -1 || token.indexOf('{') !== -1)
|
||||
return mapToken(token, 'http:');
|
||||
// Preserve explicit schema as is as it may affect trailing slashes after domain.
|
||||
return token;
|
||||
}
|
||||
const questionIndex = token.indexOf('?');
|
||||
if (questionIndex === -1)
|
||||
return mapToken(token, `$_${index}_$`);
|
||||
const newPrefix = mapToken(token.substring(0, questionIndex), `$_${index}_$`);
|
||||
const newSuffix = mapToken(token.substring(questionIndex), `?$_${index}_$`);
|
||||
return newPrefix + newSuffix;
|
||||
}).join('/');
|
||||
const result = resolveBaseURL(baseURL, relativePath);
|
||||
let resolved = result.resolved;
|
||||
for (const [token, original] of tokenMap) {
|
||||
const normalize = result.caseInsensitivePart?.includes(token);
|
||||
resolved = resolved.replace(token, normalize ? original.toLowerCase() : original);
|
||||
}
|
||||
match = resolved;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
function parseURL(url: string): URL | null {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function constructURLBasedOnBaseURL(baseURL: string | undefined, givenURL: string): string {
|
||||
try {
|
||||
return resolveBaseURL(baseURL, givenURL).resolved;
|
||||
} catch (e) {
|
||||
return givenURL;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBaseURL(baseURL: string | undefined, givenURL: string) {
|
||||
try {
|
||||
const url = new URL(givenURL, baseURL);
|
||||
const resolved = url.toString();
|
||||
// Schema and domain are case-insensitive.
|
||||
const caseInsensitivePrefix = url.origin;
|
||||
return { resolved, caseInsensitivePart: caseInsensitivePrefix };
|
||||
} catch (e) {
|
||||
return { resolved: givenURL };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
type TypedArrayKind = 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64';
|
||||
|
||||
export type SerializedValue =
|
||||
undefined | boolean | number | string |
|
||||
{ v: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0' } |
|
||||
{ d: string } |
|
||||
{ u: string } |
|
||||
{ bi: string } |
|
||||
{ e: { n: string, m: string, s: string } } |
|
||||
{ r: { p: string, f: string } } |
|
||||
{ a: SerializedValue[], id: number } |
|
||||
{ o: { k: string, v: SerializedValue }[], id: number } |
|
||||
{ ref: number } |
|
||||
{ h: number } |
|
||||
{ ta: { b: string, k: TypedArrayKind } } |
|
||||
{ ab: { b: string } };
|
||||
|
||||
type HandleOrValue = { h: number } | { fallThrough: any };
|
||||
|
||||
type VisitorInfo = {
|
||||
visited: Map<object, number>;
|
||||
lastId: number;
|
||||
};
|
||||
|
||||
function isRegExp(obj: any): obj is RegExp {
|
||||
try {
|
||||
return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDate(obj: any): obj is Date {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
return obj instanceof Date || Object.prototype.toString.call(obj) === '[object Date]';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isURL(obj: any): obj is URL {
|
||||
try {
|
||||
return obj instanceof URL || Object.prototype.toString.call(obj) === '[object URL]';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isError(obj: any): obj is Error {
|
||||
try {
|
||||
return obj instanceof Error || (obj && Object.getPrototypeOf(obj)?.name === 'Error');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isTypedArray(obj: any, constructor: Function): boolean {
|
||||
try {
|
||||
return obj instanceof constructor || Object.prototype.toString.call(obj) === `[object ${constructor.name}]`;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isArrayBuffer(obj: any): obj is ArrayBuffer {
|
||||
try {
|
||||
return obj instanceof ArrayBuffer || Object.prototype.toString.call(obj) === '[object ArrayBuffer]';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const typedArrayConstructors: Record<TypedArrayKind, Function> = {
|
||||
i8: Int8Array,
|
||||
ui8: Uint8Array,
|
||||
ui8c: Uint8ClampedArray,
|
||||
i16: Int16Array,
|
||||
ui16: Uint16Array,
|
||||
i32: Int32Array,
|
||||
ui32: Uint32Array,
|
||||
// TODO: add Float16Array once it's in baseline
|
||||
f32: Float32Array,
|
||||
f64: Float64Array,
|
||||
bi64: BigInt64Array,
|
||||
bui64: BigUint64Array,
|
||||
};
|
||||
|
||||
function typedArrayToBase64(array: any) {
|
||||
/**
|
||||
* Firefox does not support iterating over typed arrays, so we use `.toBase64`.
|
||||
* Error: 'Accessing TypedArray data over Xrays is slow, and forbidden in order to encourage performant code. To copy TypedArrays across origin boundaries, consider using Components.utils.cloneInto().'
|
||||
*/
|
||||
if ('toBase64' in array)
|
||||
return array.toBase64();
|
||||
const binary = Array.from(new Uint8Array(array.buffer, array.byteOffset, array.byteLength)).map(b => String.fromCharCode(b)).join('');
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToTypedArray(base64: string, TypedArrayConstructor: any) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++)
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
return new TypedArrayConstructor(bytes.buffer);
|
||||
}
|
||||
|
||||
export function parseEvaluationResultValue(value: SerializedValue, handles: any[] = [], refs: Map<number, object> = new Map()): any {
|
||||
if (Object.is(value, undefined))
|
||||
return undefined;
|
||||
if (typeof value === 'object' && value) {
|
||||
if ('ref' in value)
|
||||
return refs.get(value.ref);
|
||||
if ('v' in value) {
|
||||
if (value.v === 'undefined')
|
||||
return undefined;
|
||||
if (value.v === 'null')
|
||||
return null;
|
||||
if (value.v === 'NaN')
|
||||
return NaN;
|
||||
if (value.v === 'Infinity')
|
||||
return Infinity;
|
||||
if (value.v === '-Infinity')
|
||||
return -Infinity;
|
||||
if (value.v === '-0')
|
||||
return -0;
|
||||
return undefined;
|
||||
}
|
||||
if ('d' in value) {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
return new Date(value.d);
|
||||
}
|
||||
if ('u' in value)
|
||||
return new URL(value.u);
|
||||
if ('bi' in value)
|
||||
return BigInt(value.bi);
|
||||
if ('e' in value) {
|
||||
const error = new Error(value.e.m);
|
||||
error.name = value.e.n;
|
||||
error.stack = value.e.s;
|
||||
return error;
|
||||
}
|
||||
if ('r' in value)
|
||||
return new RegExp(value.r.p, value.r.f);
|
||||
if ('a' in value) {
|
||||
const result: any[] = [];
|
||||
refs.set(value.id, result);
|
||||
for (const a of value.a)
|
||||
result.push(parseEvaluationResultValue(a, handles, refs));
|
||||
return result;
|
||||
}
|
||||
if ('o' in value) {
|
||||
const result: any = {};
|
||||
refs.set(value.id, result);
|
||||
for (const { k, v } of value.o) {
|
||||
if (k === '__proto__')
|
||||
continue;
|
||||
result[k] = parseEvaluationResultValue(v, handles, refs);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if ('h' in value)
|
||||
return handles[value.h];
|
||||
if ('ta' in value)
|
||||
return base64ToTypedArray(value.ta.b, typedArrayConstructors[value.ta.k]);
|
||||
if ('ab' in value)
|
||||
return base64ToTypedArray(value.ab.b, Uint8Array).buffer;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function serializeAsCallArgument(value: any, handleSerializer: (value: any) => HandleOrValue): SerializedValue {
|
||||
return serialize(value, handleSerializer, { visited: new Map(), lastId: 0 });
|
||||
}
|
||||
|
||||
function serialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue {
|
||||
if (value && typeof value === 'object') {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
if (typeof globalThis.Window === 'function' && value instanceof globalThis.Window)
|
||||
return 'ref: <Window>';
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
if (typeof globalThis.Document === 'function' && value instanceof globalThis.Document)
|
||||
return 'ref: <Document>';
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
if (typeof globalThis.Node === 'function' && value instanceof globalThis.Node)
|
||||
return 'ref: <Node>';
|
||||
}
|
||||
return innerSerialize(value, handleSerializer, visitorInfo);
|
||||
}
|
||||
|
||||
function innerSerialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue {
|
||||
const result = handleSerializer(value);
|
||||
if ('fallThrough' in result)
|
||||
value = result.fallThrough;
|
||||
else
|
||||
return result;
|
||||
|
||||
if (typeof value === 'symbol')
|
||||
return { v: 'undefined' };
|
||||
if (Object.is(value, undefined))
|
||||
return { v: 'undefined' };
|
||||
if (Object.is(value, null))
|
||||
return { v: 'null' };
|
||||
if (Object.is(value, NaN))
|
||||
return { v: 'NaN' };
|
||||
if (Object.is(value, Infinity))
|
||||
return { v: 'Infinity' };
|
||||
if (Object.is(value, -Infinity))
|
||||
return { v: '-Infinity' };
|
||||
if (Object.is(value, -0))
|
||||
return { v: '-0' };
|
||||
|
||||
if (typeof value === 'boolean')
|
||||
return value;
|
||||
if (typeof value === 'number')
|
||||
return value;
|
||||
if (typeof value === 'string')
|
||||
return value;
|
||||
if (typeof value === 'bigint')
|
||||
return { bi: value.toString() };
|
||||
|
||||
if (isError(value)) {
|
||||
let stack;
|
||||
if (value.stack?.startsWith(value.name + ': ' + value.message)) {
|
||||
// v8
|
||||
stack = value.stack;
|
||||
} else {
|
||||
stack = `${value.name}: ${value.message}\n${value.stack}`;
|
||||
}
|
||||
return { e: { n: value.name, m: value.message, s: stack } };
|
||||
}
|
||||
if (isDate(value))
|
||||
return { d: value.toJSON() };
|
||||
if (isURL(value))
|
||||
return { u: value.toJSON() };
|
||||
if (isRegExp(value))
|
||||
return { r: { p: value.source, f: value.flags } };
|
||||
for (const [k, ctor] of Object.entries(typedArrayConstructors) as [TypedArrayKind, Function][]) {
|
||||
if (isTypedArray(value, ctor))
|
||||
return { ta: { b: typedArrayToBase64(value), k } };
|
||||
}
|
||||
if (isArrayBuffer(value))
|
||||
return { ab: { b: typedArrayToBase64(new Uint8Array(value)) } };
|
||||
|
||||
const id = visitorInfo.visited.get(value);
|
||||
if (id)
|
||||
return { ref: id };
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const a = [];
|
||||
const id = ++visitorInfo.lastId;
|
||||
visitorInfo.visited.set(value, id);
|
||||
for (let i = 0; i < value.length; ++i)
|
||||
a.push(serialize(value[i], handleSerializer, visitorInfo));
|
||||
return { a, id };
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const o: { k: string, v: SerializedValue }[] = [];
|
||||
const id = ++visitorInfo.lastId;
|
||||
visitorInfo.visited.set(value, id);
|
||||
for (const name of Object.keys(value)) {
|
||||
let item;
|
||||
try {
|
||||
item = value[name];
|
||||
} catch (e) {
|
||||
continue; // native bindings will throw sometimes
|
||||
}
|
||||
if (name === 'toJSON' && typeof item === 'function')
|
||||
o.push({ k: name, v: { o: [], id: 0 } });
|
||||
else
|
||||
o.push({ k: name, v: serialize(item, handleSerializer, visitorInfo) });
|
||||
}
|
||||
|
||||
let jsonWrapper;
|
||||
try {
|
||||
// If Object.keys().length === 0 we fall back to toJSON if it exists
|
||||
if (o.length === 0 && value.toJSON && typeof value.toJSON === 'function')
|
||||
jsonWrapper = { value: value.toJSON() };
|
||||
} catch (e) {
|
||||
}
|
||||
if (jsonWrapper)
|
||||
return innerSerialize(jsonWrapper.value, handleSerializer, visitorInfo);
|
||||
|
||||
return { o, id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function yamlEscapeKeyIfNeeded(str: string): string {
|
||||
if (!yamlStringNeedsQuotes(str))
|
||||
return str;
|
||||
return `'` + str.replace(/'/g, `''`) + `'`;
|
||||
}
|
||||
|
||||
export function yamlEscapeValueIfNeeded(str: string): string {
|
||||
if (!yamlStringNeedsQuotes(str))
|
||||
return str;
|
||||
return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => {
|
||||
switch (c) {
|
||||
case '\\':
|
||||
return '\\\\';
|
||||
case '"':
|
||||
return '\\"';
|
||||
case '\b':
|
||||
return '\\b';
|
||||
case '\f':
|
||||
return '\\f';
|
||||
case '\n':
|
||||
return '\\n';
|
||||
case '\r':
|
||||
return '\\r';
|
||||
case '\t':
|
||||
return '\\t';
|
||||
default:
|
||||
const code = c.charCodeAt(0);
|
||||
return '\\x' + code.toString(16).padStart(2, '0');
|
||||
}
|
||||
}) + '"';
|
||||
}
|
||||
|
||||
function yamlStringNeedsQuotes(str: string): boolean {
|
||||
if (str.length === 0)
|
||||
return true;
|
||||
|
||||
// Strings with leading or trailing whitespace need quotes
|
||||
if (/^\s|\s$/.test(str))
|
||||
return true;
|
||||
|
||||
// Strings containing control characters need quotes
|
||||
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
|
||||
return true;
|
||||
|
||||
// Strings starting with '-' need quotes
|
||||
if (/^-/.test(str))
|
||||
return true;
|
||||
|
||||
// Strings containing ':' or '\n' followed by a space or at the end need quotes
|
||||
if (/[\n:](\s|$)/.test(str))
|
||||
return true;
|
||||
|
||||
// Strings containing '#' preceded by a space need quotes (comment indicator)
|
||||
if (/\s#/.test(str))
|
||||
return true;
|
||||
|
||||
// Strings that contain line breaks need quotes
|
||||
if (/[\n\r]/.test(str))
|
||||
return true;
|
||||
|
||||
// Strings starting with indicator characters or quotes need quotes
|
||||
if (/^[&*\],?!>|@"'#%]/.test(str))
|
||||
return true;
|
||||
|
||||
// Strings containing special characters that could cause ambiguity
|
||||
if (/[{}`]/.test(str))
|
||||
return true;
|
||||
|
||||
// YAML array starts with [
|
||||
if (/^\[/.test(str))
|
||||
return true;
|
||||
|
||||
// Non-string types recognized by YAML
|
||||
if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase()))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||