From 15e378278a878cc51d4dee284b3b84878994c7b0 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 14:05:41 +0800 Subject: [PATCH 001/168] ci: open an issue when stable Chrome ships another DevTools frontend A daily workflow (and on demand) asks Chromium Dash which devtools-frontend commit stable Chrome ships, as build.ts --latest does, and compares it with REVISION. When they differ, minor Chrome updates included, it opens an issue with the update command, once per version, and closes an older one not done yet. It builds nothing; a Chromium Dash answer without a version or a commit fails the run. DEV.md: the frontend follows every stable update, not once per milestone. --- .github/workflows/chrome-update.yml | 84 +++++++++++++++++++++++++++++ DEV.md | 2 +- DEV.zh-CN.md | 2 +- 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/chrome-update.yml diff --git a/.github/workflows/chrome-update.yml b/.github/workflows/chrome-update.yml new file mode 100644 index 0000000..e8a14f3 --- /dev/null +++ b/.github/workflows/chrome-update.yml @@ -0,0 +1,84 @@ +# Checks every day whether stable Chrome ships another devtools-frontend commit than the one pinned +# in packages/devtools-frontend/REVISION, minor versions included (DEV.md, "Working on the +# frontend"), and opens an issue for it. It builds nothing: the update itself is done by hand. +# +# Chromium Dash's JSON endpoint (the one build.ts --latest reads) has no documentation nor promise: +# its answer is checked, and a run fails, rather than passes, when it is not what is expected. +name: Chrome update + +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: chrome-update + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + packages/devtools-frontend/REVISION + packages/devtools-frontend/package.json + sparse-checkout-cone-mode: false + + - name: Stable Chrome + id: stable + run: | + curl -fsS --retry 3 --max-time 30 \ + 'https://chromiumdash.appspot.com/fetch_releases?channel=Stable&platform=Mac&num=1' > release.json + node -e ' + const [release] = JSON.parse(require("fs").readFileSync("release.json", "utf8")); + const version = release?.version, devtools = release?.hashes?.devtools; + if (!/^\d+(\.\d+){3}$/.test(version ?? "") || !/^[0-9a-f]{40}$/.test(devtools ?? "")) + throw new Error("Chromium Dash answered without a version or a devtools commit"); + console.log(`version=${version}\ndevtools=${devtools}`); + ' >> "$GITHUB_OUTPUT" + + - name: Open an issue if it ships another frontend + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.stable.outputs.version }} + DEVTOOLS: ${{ steps.stable.outputs.devtools }} + run: | + pinned=$(tr -d '[:space:]' < packages/devtools-frontend/REVISION) + chrome=$(node -p "require('./packages/devtools-frontend/package.json').chrome") + echo "Pinned: Chrome $chrome, devtools $pinned. Stable: Chrome $VERSION, devtools $DEVTOOLS." + [ "$pinned" = "$DEVTOOLS" ] && exit 0 + + title="Update the DevTools frontend to Chrome $VERSION" + open=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "in:title \"Update the DevTools frontend to Chrome\"" \ + --json number,title --jq '.[] | "\(.number)\t\(.title)"') + if printf '%s\n' "$open" | cut -f2 | grep -qxF "$title"; then + echo "Already open: $title" + exit 0 + fi + + new=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body "$(cat < ../../packages/devtools-frontend/patches/console-reveal-makes-room.patch ``` -The frontend follows stable Chrome, which is what most users have: once per milestone, a week or two after a new stable release, move to it with +The frontend follows stable Chrome, which is what most users have, minor updates included: each one ships another devtools-frontend commit. `.github/workflows/chrome-update.yml` checks every day and opens an issue when stable Chrome's commit is not the one in `REVISION` (closing an older one not done yet). Move to it with ```bash pnpm --filter @devtools-anywhere/devtools-frontend update diff --git a/DEV.zh-CN.md b/DEV.zh-CN.md index 9d61492..7174d56 100644 --- a/DEV.zh-CN.md +++ b/DEV.zh-CN.md @@ -176,7 +176,7 @@ cd devtools-frontend.ignore/devtools-frontend git diff HEAD -- front_end/panels/console/ConsolePanel.ts > ../../packages/devtools-frontend/patches/console-reveal-makes-room.patch ``` -前端跟随稳定版 Chrome,也就是大多数用户手上的版本:每个里程碑一次,在新稳定版发布一两周后,用下面的命令升级: +前端跟随稳定版 Chrome,也就是大多数用户手上的版本,小版本更新也要跟:每次都会换一个 devtools-frontend 提交。`.github/workflows/chrome-update.yml` 每天检查一次,稳定版 Chrome 的提交与 `REVISION` 不一致时开一个 issue(同时关闭还没处理的旧 issue)。用下面的命令升级: ```bash pnpm --filter @devtools-anywhere/devtools-frontend update From a793dc1b3d3f16c9e5a2218343e594b338759d02 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 14:06:29 +0800 Subject: [PATCH 002/168] docs(dev): develop the next minor on dev/x.y.0, released through release/x.y.0 main now holds what is released. Work for the next minor goes on dev/x.y.0, pushed freely, and reaches main through release/x.y.0 (dev -> release -> main); a fix that cannot wait still goes from main to main as a patch, and main is then merged into the dev branch. CI also runs on pushes to dev/**, so that the development branch is checked without a pull request. --- .github/workflows/ci.yml | 8 ++++---- DEV.md | 15 ++++++++------- DEV.zh-CN.md | 15 ++++++++------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b031ec6..07a3629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ -# Checks every push to main and every pull request: lint, types, the DevTools frontend built from -# source at the pinned REVISION, and all tests against Chrome for Testing at the version the -# frontend comes from (the packages' "chrome" field). +# Checks every push to main and to the development branches (dev/x.y.0), and every pull request: +# lint, types, the DevTools frontend built from source at the pinned REVISION, and all tests +# against Chrome for Testing at the version the frontend comes from (the packages' "chrome" field). # # Caches (actions/cache: a key that misses is saved at the end of the job; a hit is not saved # again): @@ -15,7 +15,7 @@ name: CI on: push: - branches: [main] + branches: [main, 'dev/**'] pull_request: workflow_dispatch: diff --git a/DEV.md b/DEV.md index 9c94e58..d5a6a67 100644 --- a/DEV.md +++ b/DEV.md @@ -210,19 +210,20 @@ When a dependency that ends up in a build output is added or upgraded, or code i ### Branches and pull requests -`main` is the only long-lived branch: it is where all work lands and what releases are tagged from, and it should always be releasable. There is no `develop` branch: merging into `main` does not release anything (only a pushed `vx.y.z` tag does, see "Releasing to npm"), so `main` itself holds what is merged and not yet released. +`main` holds what is released: releases are tagged on it, and it changes only through pull requests. Work for the next minor goes on its development branch, `dev/x.y.0` (now `dev/0.3.0`), pushed freely; it reaches `main` through `release/x.y.0` when that minor is released: `dev` → `release` → `main`. | Branch | For | Rules | | --- | --- | --- | -| `main` | All development; releases are tagged on it | Changed through pull requests only, CI must pass, no force pushes | -| `feat/…`, `fix/…`, `docs/…`, `ci/…`, … | One change each, short-lived; the prefix is the commit type | Branched from `main`, merged back through a pull request and deleted | -| `release/x.y.z` | The pull request that prepares release x.y.z (version and changelog) | Merged into `main`, then the tag goes on `main` | +| `main` | What is released; releases are tagged on it | Changed through pull requests only, CI must pass, no force pushes | +| `dev/x.y.0` | Development of the next minor x.y.0 | Branched from `main` after the previous release; committed to and pushed directly, or through `feat/…` branches; no force pushes | +| `feat/…`, `fix/…`, `docs/…`, `ci/…`, … | One change each, short-lived, when it deserves a pull request of its own; the prefix is the commit type | Branched from `dev/x.y.0` and merged back, or from `main` for a patch release (see below), then deleted | +| `release/x.y.z` | The pull request that prepares release x.y.z (version and changelog) | Branched from `dev/x.y.0` (or from `main` for a patch), merged into `main`, then the tag goes on `main` | | `release/x.y` | Patches to an older minor, only when needed (see "Patching an older release") | Branched from the tag `vx.y.0`, kept while that minor is patched | -A fix is not a branch type of its own: it goes through a `fix/…` branch into `main` like any change, and is released as a patch. CI (`.github/workflows/ci.yml`) runs on every pull request and every push to `main`: lint, typecheck, the frontend build and all the tests. +A fix that should not wait for the next minor goes through a `fix/…` branch from `main` into `main` and is released as a patch from there; then `main` is merged into `dev/x.y.0`, so the fix is there too. Once x.y.0 is released, `dev/x.y.0` is deleted and `dev/x.(y+1).0` branched from `main`. CI (`.github/workflows/ci.yml`) runs on every pull request and every push to `main` and `dev/**`: lint, typecheck, the frontend build and all the tests. ```bash -git switch -c feat/my-change main +git switch -c feat/my-change dev/0.3.0 # … commits … git push -u origin feat/my-change gh pr create --fill @@ -236,7 +237,7 @@ All packages share one version number and are released together, by `.github/wor Versions are semver for the library itself, starting at `0.1.0`: a fix is a patch; a new feature, or moving the frontend to a new Chrome milestone (its UI changes), is a minor; before 1.0 a minor may also break. Releases are cut when there is something worth releasing (features that add up to a minor, a fix users are waiting for, a new Chrome milestone), not on every merge. What is promised is what the README documents (`@public`). The Chrome version is not in the version number, where npm would drop it (`+chrome156`) or take it for a prerelease (`-chrome156`): each published package's `package.json` has it as `chrome`, which `update` writes, and each release says it first (`Based on Chrome 156`). Each release also gets the dist-tag `chrome-`, so that `npm i devtools-anywhere@chrome-156` installs the last release on that Chrome. -1. On a branch `release/x.y.z` from `main`, set the same `version` in every `packages/*/package.json`, move the `Unreleased` notes in `CHANGELOG.md` and `CHANGELOG.zh-CN.md` under `## x.y.z`, and commit (`build: release x.y.z`). `node scripts/release.ts check x.y.z` checks both. Open a pull request and merge it once CI passes. +1. On a branch `release/x.y.z` from `dev/x.y.0` (from `main` for a patch), set the same `version` in every `packages/*/package.json`, move the `Unreleased` notes in `CHANGELOG.md` and `CHANGELOG.zh-CN.md` under `## x.y.z`, and commit (`build: release x.y.z`). `node scripts/release.ts check x.y.z` checks both. Open a pull request and merge it once CI passes. 2. Tag the merged commit on `main` and push the tag: `git switch main && git pull && git tag vx.y.z && git push origin vx.y.z`. 3. The workflow checks the version, builds the frontend, runs the checks and tests as CI does and packs the packages (pnpm replaces `workspace:*` with the real version). Then approve its `publish` job in the Actions tab: it publishes the packages to npm, tags them `chrome-`, and publishes the GitHub release with that version's section of `CHANGELOG.md` followed by that of `CHANGELOG.zh-CN.md`. diff --git a/DEV.zh-CN.md b/DEV.zh-CN.md index 7174d56..615b6fc 100644 --- a/DEV.zh-CN.md +++ b/DEV.zh-CN.md @@ -210,19 +210,20 @@ pre-commit 钩子(husky + lint-staged)只处理暂存的文件:先用 oxfm ### 分支与 PR -`main` 是唯一的长期分支:所有改动都合进这里,发布也在这里打 tag,它应当随时可以发布。不设 `develop` 分支:合进 `main` 不会发布任何东西(只有推送 `vx.y.z` tag 才会,见「发布到 npm」),`main` 本身就存放着已合并、尚未发布的改动。 +`main` 存放已发布的内容:发布在它上面打 tag,只能通过 PR 修改。下一个 minor 的开发放在它的开发分支 `dev/x.y.0` 上(现在是 `dev/0.3.0`),可以随时推送;发布该 minor 时经 `release/x.y.0` 进入 `main`:`dev` → `release` → `main`。 | 分支 | 用途 | 规则 | | --- | --- | --- | -| `main` | 所有开发;发布在它上面打 tag | 只能通过 PR 修改,CI 必须通过,禁止强推 | -| `feat/…`、`fix/…`、`docs/…`、`ci/…` 等 | 每个分支只做一件事,短期存在;前缀与提交类型一致 | 从 `main` 拉出,通过 PR 合回后删除 | -| `release/x.y.z` | 准备 x.y.z 发布的 PR(版本号和 changelog) | 合进 `main`,然后在 `main` 上打 tag | +| `main` | 已发布的内容;发布在它上面打 tag | 只能通过 PR 修改,CI 必须通过,禁止强推 | +| `dev/x.y.0` | 下一个 minor x.y.0 的开发 | 上一次发布后从 `main` 拉出;可以直接提交、推送,也可以经 `feat/…` 分支合入;禁止强推 | +| `feat/…`、`fix/…`、`docs/…`、`ci/…` 等 | 值得单独开 PR 的改动,每个分支只做一件事,短期存在;前缀与提交类型一致 | 从 `dev/x.y.0` 拉出再合回;patch 发布则从 `main` 拉出(见下文);合入后删除 | +| `release/x.y.z` | 准备 x.y.z 发布的 PR(版本号和 changelog) | 从 `dev/x.y.0` 拉出(patch 从 `main` 拉出),合进 `main`,然后在 `main` 上打 tag | | `release/x.y` | 只在需要时用来给旧的 minor 打补丁(见「给旧版本打补丁」) | 从 tag `vx.y.0` 拉出,在该 minor 需要补丁期间保留 | -修复不单独设一类分支:和其它改动一样走 `fix/…` 分支合进 `main`,作为 patch 发布。CI(`.github/workflows/ci.yml`)在每个 PR 和每次推送到 `main` 时运行:lint、类型检查、构建前端、跑全部测试。 +不能等到下一个 minor 的修复,走从 `main` 拉出的 `fix/…` 分支合进 `main`,从那里作为 patch 发布;然后把 `main` 合并进 `dev/x.y.0`,让修复也进入开发分支。x.y.0 发布后删除 `dev/x.y.0`,再从 `main` 拉出 `dev/x.(y+1).0`。CI(`.github/workflows/ci.yml`)在每个 PR 以及每次推送到 `main` 和 `dev/**` 时运行:lint、类型检查、构建前端、跑全部测试。 ```bash -git switch -c feat/my-change main +git switch -c feat/my-change dev/0.3.0 # … 提交 … git push -u origin feat/my-change gh pr create --fill @@ -236,7 +237,7 @@ gh pr create --fill 版本号按 semver 表示库本身的变化,从 `0.1.0` 开始:修 bug 升 patch;新功能、或前端升级到新的 Chrome 里程碑(界面会变)升 minor;1.0 之前 minor 也可以有破坏性变更。有值得发布的内容时才发版(攒够一个 minor 的功能、用户在等的修复、新的 Chrome 里程碑),不是每次合并都发。承诺的范围是 README 写明的用法(`@public`)。Chrome 版本不放进版本号:写成 `+chrome156` 会被 npm 丢掉,写成 `-chrome156` 会被当成预发布版。它记在每个发布包 `package.json` 的 `chrome` 字段里(由 `update` 写入),每次发布的说明也在开头写明(「基于 Chrome 156」)。每次发布还会打上 dist-tag `chrome-<里程碑>`,`npm i devtools-anywhere@chrome-156` 就能装到基于该版 Chrome 的最后一个版本。 -1. 从 `main` 拉出 `release/x.y.z` 分支,把所有 `packages/*/package.json` 的 `version` 改成同一个版本,把 `CHANGELOG.md` 和 `CHANGELOG.zh-CN.md` 里 `Unreleased` 下的内容挪到 `## x.y.z` 下,然后提交(`build: release x.y.z`)。`node scripts/release.ts check x.y.z` 会检查这两项。开 PR,CI 通过后合并。 +1. 从 `dev/x.y.0`(patch 则从 `main`)拉出 `release/x.y.z` 分支,把所有 `packages/*/package.json` 的 `version` 改成同一个版本,把 `CHANGELOG.md` 和 `CHANGELOG.zh-CN.md` 里 `Unreleased` 下的内容挪到 `## x.y.z` 下,然后提交(`build: release x.y.z`)。`node scripts/release.ts check x.y.z` 会检查这两项。开 PR,CI 通过后合并。 2. 在 `main` 上给合并后的提交打 tag 并推送这个 tag:`git switch main && git pull && git tag vx.y.z && git push origin vx.y.z`。 3. workflow 会检查版本号、构建前端、像 CI 一样跑检查和测试,并打包(pnpm 会把 `workspace:*` 替换成实际版本)。然后在 Actions 页面批准它的 `publish` 任务:它把各包发布到 npm,打上 dist-tag `chrome-<里程碑>`,并发布 GitHub Release,说明是 `CHANGELOG.md` 中该版本的那一节,后面接上 `CHANGELOG.zh-CN.md` 的对应一节。 From a238e80b1cb0e2627d0ef2b5c1a5847e24ccb322 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 14:11:38 +0800 Subject: [PATCH 003/168] ci: run CI on pushes to main only, not to the dev branches With one developer, work in progress on dev/x.y.0 need not queue a run on every push: the release/x.y.0 pull request checks it all, and CI can be run on the branch by hand (gh workflow run ci.yml --ref dev/0.3.0). DEV.md says so. --- .github/workflows/ci.yml | 9 +++++---- DEV.md | 2 +- DEV.zh-CN.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07a3629..c3d3769 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ -# Checks every push to main and to the development branches (dev/x.y.0), and every pull request: -# lint, types, the DevTools frontend built from source at the pinned REVISION, and all tests -# against Chrome for Testing at the version the frontend comes from (the packages' "chrome" field). +# Checks every push to main and every pull request: lint, types, the DevTools frontend built from +# source at the pinned REVISION, and all tests against Chrome for Testing at the version the +# frontend comes from (the packages' "chrome" field). Not the pushes to the development branches +# (dev/x.y.0): they are checked by the release pull request, or by hand (workflow_dispatch). # # Caches (actions/cache: a key that misses is saved at the end of the job; a hit is not saved # again): @@ -15,7 +16,7 @@ name: CI on: push: - branches: [main, 'dev/**'] + branches: [main] pull_request: workflow_dispatch: diff --git a/DEV.md b/DEV.md index d5a6a67..bc8bb47 100644 --- a/DEV.md +++ b/DEV.md @@ -220,7 +220,7 @@ When a dependency that ends up in a build output is added or upgraded, or code i | `release/x.y.z` | The pull request that prepares release x.y.z (version and changelog) | Branched from `dev/x.y.0` (or from `main` for a patch), merged into `main`, then the tag goes on `main` | | `release/x.y` | Patches to an older minor, only when needed (see "Patching an older release") | Branched from the tag `vx.y.0`, kept while that minor is patched | -A fix that should not wait for the next minor goes through a `fix/…` branch from `main` into `main` and is released as a patch from there; then `main` is merged into `dev/x.y.0`, so the fix is there too. Once x.y.0 is released, `dev/x.y.0` is deleted and `dev/x.(y+1).0` branched from `main`. CI (`.github/workflows/ci.yml`) runs on every pull request and every push to `main` and `dev/**`: lint, typecheck, the frontend build and all the tests. +A fix that should not wait for the next minor goes through a `fix/…` branch from `main` into `main` and is released as a patch from there; then `main` is merged into `dev/x.y.0`, so the fix is there too. Once x.y.0 is released, `dev/x.y.0` is deleted and `dev/x.(y+1).0` branched from `main`. CI (`.github/workflows/ci.yml`) runs on every pull request and every push to `main`: lint, typecheck, the frontend build and all the tests. Pushes to `dev/x.y.0` are not checked, so that work in progress does not queue runs: the `release/x.y.0` pull request checks them, or run CI on the branch by hand (`gh workflow run ci.yml --ref dev/0.3.0`). ```bash git switch -c feat/my-change dev/0.3.0 diff --git a/DEV.zh-CN.md b/DEV.zh-CN.md index 615b6fc..9275aa6 100644 --- a/DEV.zh-CN.md +++ b/DEV.zh-CN.md @@ -220,7 +220,7 @@ pre-commit 钩子(husky + lint-staged)只处理暂存的文件:先用 oxfm | `release/x.y.z` | 准备 x.y.z 发布的 PR(版本号和 changelog) | 从 `dev/x.y.0` 拉出(patch 从 `main` 拉出),合进 `main`,然后在 `main` 上打 tag | | `release/x.y` | 只在需要时用来给旧的 minor 打补丁(见「给旧版本打补丁」) | 从 tag `vx.y.0` 拉出,在该 minor 需要补丁期间保留 | -不能等到下一个 minor 的修复,走从 `main` 拉出的 `fix/…` 分支合进 `main`,从那里作为 patch 发布;然后把 `main` 合并进 `dev/x.y.0`,让修复也进入开发分支。x.y.0 发布后删除 `dev/x.y.0`,再从 `main` 拉出 `dev/x.(y+1).0`。CI(`.github/workflows/ci.yml`)在每个 PR 以及每次推送到 `main` 和 `dev/**` 时运行:lint、类型检查、构建前端、跑全部测试。 +不能等到下一个 minor 的修复,走从 `main` 拉出的 `fix/…` 分支合进 `main`,从那里作为 patch 发布;然后把 `main` 合并进 `dev/x.y.0`,让修复也进入开发分支。x.y.0 发布后删除 `dev/x.y.0`,再从 `main` 拉出 `dev/x.(y+1).0`。CI(`.github/workflows/ci.yml`)在每个 PR 和每次推送到 `main` 时运行:lint、类型检查、构建前端、跑全部测试。推送到 `dev/x.y.0` 不运行,免得开发中的推送排队:由 `release/x.y.0` 的 PR 检查,或者手动在该分支上运行 CI(`gh workflow run ci.yml --ref dev/0.3.0`)。 ```bash git switch -c feat/my-change dev/0.3.0 From 9c51f7da0db881ef083faa22348e9d1670460079 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 14:42:29 +0800 Subject: [PATCH 004/168] docs(roadmap): record what is planned and not done yet A roadmap in both languages, linked from DEV.md, for the directions discussed so far: the missing accessibility tree (what dom-accessibility-api covers and what is left), real CDP on Android through ADB wireless debugging, the gaps in Emulation (no setDeviceMetricsOverride, no-ops that succeed), patches from an older release line, and what is shelved (dragging the divider in shell mode, touch screens). --- DEV.md | 2 +- DEV.zh-CN.md | 2 +- docs/roadmap.md | 57 +++++++++++++++++++++++++++++++++++++++++++ docs/roadmap.zh-CN.md | 57 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 docs/roadmap.md create mode 100644 docs/roadmap.zh-CN.md diff --git a/DEV.md b/DEV.md index bc8bb47..88a3e3e 100644 --- a/DEV.md +++ b/DEV.md @@ -2,7 +2,7 @@ English | [简体中文](DEV.zh-CN.md) -How to set up a development environment, work on the packages with live rebuilds, and get changes merged or released. For what the project does, see the [README](README.md); for how it works and why, the [design](docs/design.md). +How to set up a development environment, work on the packages with live rebuilds, and get changes merged or released. For what the project does, see the [README](README.md); for how it works and why, the [design](docs/design.md); for what is planned and not done yet, the [roadmap](docs/roadmap.md). ## 1. First-time setup diff --git a/DEV.zh-CN.md b/DEV.zh-CN.md index 9275aa6..87a79f7 100644 --- a/DEV.zh-CN.md +++ b/DEV.zh-CN.md @@ -2,7 +2,7 @@ [English](DEV.md) | 简体中文 -介绍如何搭建开发环境、在实时重新构建下开发各个包,以及改动如何合入或发布。项目做什么见 [README](README.zh-CN.md),工作原理和设计取舍见[设计文档](docs/design.zh-CN.md)。 +介绍如何搭建开发环境、在实时重新构建下开发各个包,以及改动如何合入或发布。项目做什么见 [README](README.zh-CN.md),工作原理和设计取舍见[设计文档](docs/design.zh-CN.md),计划中、还没做的事见[路线图](docs/roadmap.zh-CN.md)。 ## 1. 第一次开发:环境配置 diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..8d85c98 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,57 @@ +# Roadmap + +English | [简体中文](roadmap.zh-CN.md) + +What has been discussed and is not done yet, and why. Directions and known trade-offs only, no dates. Items move to the CHANGELOG when done. + +## The accessibility tree (the `Accessibility` domain) + +**Now**: missing. There is only `Accessibility.enable`, a no-op (`cdp-agent/domains/stubs.ts`), so that the frontend starts without errors. + +**What it means**: + +- The Elements panel's Accessibility pane (the tree, the computed role and name and where the name comes from) is empty. +- Most tools that read a page over CDP get its structure from `Accessibility.getFullAXTree` / `queryAXTree`, which they do not get here. They can still compute it in the page with `Runtime.evaluate`, so for agents it is not a hard blocker. + +**How**: compute CDP's `AXNode`s in the page from the DOM, ARIA attributes and implicit roles, and test them node by node against real Chrome's `getFullAXTree`, as the conformance tests do. + +- References: Blink's `third_party/blink/renderer/modules/accessibility/` (BSD), and the specifications HTML-AAM (implicit roles) and accname 1.2 (names). Blink's code depends on the layout tree: its rules can be rewritten in JS, not ported line by line. +- A library to start from: [`dom-accessibility-api`](https://github.com/eps1lon/dom-accessibility-api) (MIT, no dependencies, about 15 KB minified, runs in browsers) implements accname, `getRole` and `isInaccessible`. It can do names and descriptions, the hardest part to match. +- Still to write: + - the context rules of role mapping: for example `header` / `footer` in a `section` are generic, a `section` without a name is not a region, row and column headers for `th`, and elements without a mapping are generic; + - the tree: ignored nodes, `aria-owns`, text nodes as StaticText; + - node properties: focusable, checked, expanded, level, value, …; + - where a name comes from (`sources`), which the Accessibility pane shows. + +**In steps**: first `getPartialAXTree`, which the Elements pane uses, then `getFullAXTree` / `queryAXTree`. + +**Size**: not in the probe. Loaded next to the bundle when first used, like snapDOM. + +## Real CDP on Android, through ADB wireless debugging + +**Why**: some things a page's own CDP cannot do: screenshots (now redrawn from the DOM by snapDOM, marked `approximate`), real network timing, the Performance panel, breakpoints. Chrome on Android has all of CDP, and with Android's Wireless debugging (Android 11 and later) a computer reaches it without a cable. + +**The idea**: the remote server connects to that Chrome's real CDP through `adb` (pairing, connecting, `adb forward tcp: localabstract:chrome_devtools_remote`). The probe in the page still provides what real CDP does not have, such as the earliest logs; the frontend gets both, or switches to real CDP where it can. + +**Open questions**: + +- how the two sources of CDP are combined: by method, all or nothing, or two connections in the frontend; +- where pairing (pairing code, mDNS discovery) happens: the CLI or a UI; +- what to do without `adb` on the computer: ship one, or implement the ADB protocol in JS; +- how to test it: CI has no phone; whether an emulator will do. + +**Status**: a direction only; no design discussed yet. + +## Gaps in Emulation + +- `Emulation.setDeviceMetricsOverride` is not implemented: device mode in shell mode is laid out by the frontend, not through CDP, so tools that resize the viewport over CDP cannot. In shell mode it could reuse device mode's sizing of the page iframe. +- `setEmulatedMedia`, `setUserAgentOverride` and others are no-ops that succeed. That is fine for the DevTools frontend but misleading for scripts, which take them as done. Whether they should fail instead depends on whether the frontend relies on them succeeding. + +## Patches from an older release line + +The two gaps noted in DEV.md, "Patching an older release": CI does not run on `release/**` branches, and a release always moves npm's `latest` and `chrome-` and GitHub's Latest release. The plan is for `scripts/release.ts` to decide which tags to move, by semver. To do when an older release first needs a patch. + +## Shelved + +- **Dragging the divider in shell mode**: Chrome keeps mouse events in the frame where the button was pressed, so a fast drag loses the divider. It does not reproduce headless, and the earlier attempts were reverted. +- **Touch screens**: many small things to improve; low priority. diff --git a/docs/roadmap.zh-CN.md b/docs/roadmap.zh-CN.md new file mode 100644 index 0000000..a64032a --- /dev/null +++ b/docs/roadmap.zh-CN.md @@ -0,0 +1,57 @@ +# 路线图 + +[English](roadmap.md) | 简体中文 + +已经讨论过、还没做的事,以及没做的原因。只记录方向和已知的取舍,不承诺时间。做完的条目移到 CHANGELOG。 + +## 无障碍树(`Accessibility` 域) + +**现状**:完全空缺。只有 `Accessibility.enable` 这一个空操作(`cdp-agent/domains/stubs.ts`),让前端启动时不报错。 + +**影响**: + +- Elements 面板侧栏的 Accessibility 窗格(无障碍树、计算出的 role 和 name 及其来源)是空的。 +- 通过 CDP 读页面的自动化工具大多靠 `Accessibility.getFullAXTree` / `queryAXTree` 拿页面结构,在这里拿不到。它们仍然可以用 `Runtime.evaluate` 在页面里执行脚本自己算,所以对 agent 来说不算硬阻塞。 + +**做法**:在页面里根据 DOM、ARIA 属性和隐式 role 算出 CDP 的 `AXNode`,拿真 Chrome 的 `getFullAXTree` 逐个节点对照测试,和现有的 conformance 测试一样。 + +- 参考:Blink 的 `third_party/blink/renderer/modules/accessibility/`(BSD),以及规范 HTML-AAM(隐式 role)和 accname 1.2(name 计算)。Blink 的实现依赖布局树,只能照规则用 JS 重写,没法逐行移植。 +- 可用的现成库:[`dom-accessibility-api`](https://github.com/eps1lon/dom-accessibility-api)(MIT,零依赖,minify 后约 15 KB,能直接在浏览器里运行)实现了 accname、`getRole`、`isInaccessible`。name 和 description 的计算可以用它打底,这是最难对齐的部分。 +- 还要自己补的: + - role 映射的上下文规则:比如 `header` / `footer` 在 `section` 里是 generic,没有 name 的 `section` 不是 region,`th` 的行表头和列表头,以及没有映射的元素应当是 generic; + - 树结构:被忽略的节点、`aria-owns`、文本节点变成 StaticText; + - 节点属性:focusable、checked、expanded、level、value 等; + - name 的来源列表(`sources`),Accessibility 窗格会展示它。 + +**拆分**:先做 Elements 窗格用到的 `getPartialAXTree`,再做 `getFullAXTree` / `queryAXTree`。 + +**体积**:这块代码不该进探针。和 snapDOM 一样,用到时才从 bundle 旁边加载。 + +## Android 上通过 ADB 无线调试拿到真正的 CDP + +**动机**:页面内的 CDP 有些事情做不到,比如截图(现在是 snapDOM 按 DOM 重新绘制,标注 `approximate`)、真实的网络耗时、性能面板、断点。Android 上的 Chrome 本身有完整的 CDP,打开 Android 系统的「无线调试」(Wireless debugging,Android 11 起)后,电脑就能不插线连上手机里的 Chrome。 + +**设想**:remote-server 一侧借助 `adb`(配对、连接、`adb forward tcp:<端口> localabstract:chrome_devtools_remote`)连上手机里 Chrome 的真 CDP。页面里的探针仍然负责早期日志这类真 CDP 没有的东西,两边合起来给前端用,或者在能用真 CDP 时直接切换过去。 + +**还没定的问题**: + +- 两个 CDP 来源怎么合并,按方法分流、整体切换,还是前端开两个连接; +- 配对流程(配对码、mDNS 发现)放在 CLI 还是界面里; +- 用户的电脑上没有 `adb` 时怎么办,比如附带一个,或者用纯 JS 实现 ADB 协议; +- 怎么测试:CI 上没有真机,模拟器能否代替。 + +**状态**:只是方向,没有讨论具体方案。 + +## Emulation 的缺口 + +- `Emulation.setDeviceMetricsOverride` 没有实现。外壳模式下的设备模式由前端直接布局 iframe,没有走 CDP。通过 CDP 改视口的自动化工具因此改不了尺寸;在外壳模式下可以复用设备模式的 iframe 尺寸逻辑来补上。 +- `setEmulatedMedia`、`setUserAgentOverride` 等是空操作:返回成功,但实际没有效果。对 DevTools 前端来说没问题,对自动化脚本却有误导,因为它会以为已经设置好了。要不要改成返回错误,需要权衡前端是否依赖它们成功返回。 + +## 从旧版本线发布补丁 + +DEV.md「给旧版本打补丁」一节记下的两处缺口:CI 不在 `release/**` 分支上运行;发布时会无条件移动 npm 的 `latest`、`chrome-<里程碑>` 以及 GitHub 的 Latest release。方案是在 `scripts/release.ts` 里按 semver 判断该打哪些标签。等第一次真要给旧版本打补丁时再做。 + +## 搁置的问题 + +- **外壳模式下分隔条拖动**:Chrome 会把鼠标事件留在按下时所在的 frame,拖快了分隔条会跟丢。在 headless 下复现不了,之前的改动已全部撤回。 +- **触屏体验**:要优化的地方很多,但都很零碎,优先级低。 From 3e7bfe1d11a962dce4dcae4421a474acd207c769 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 15:09:06 +0800 Subject: [PATCH 005/168] docs(roadmap): add iframes and what else the page can fill in iframes: what the agent assumes now (one frame, one execution context), Chrome's model (same-origin iframes as more frames and contexts in the target, cross-origin ones as child targets), what each domain needs, the realm problem (about 166 instanceof checks and 180 uses of the global document or window), and a step 0 that changes no behavior but keeps new code from adding to it. What else can be done in the page and match Chrome, by value for effort: animations, vision deficiencies, network blocking and offline, the browser's default styles, layout overlays, emulated media, Application panel updates, service workers, violations. --- docs/roadmap.md | 46 +++++++++++++++++++++++++++++++++++++++++++ docs/roadmap.zh-CN.md | 46 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 8d85c98..7d705f3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,6 +42,52 @@ What has been discussed and is not done yet, and why. Directions and known trade **Status**: a direction only; no design discussed yet. +## iframes + +**Now**: the agent assumes one document per page. There is a single `FRAME_ID` and `EXECUTION_CONTEXT_ID` (`core/context.ts`) in every event; nodes have no `contentDocument`, so an iframe cannot be expanded in Elements; console, network capture and errors are hooked in the top `window` only. + +**Chrome's model**: a same-origin iframe is in the same target as its page, as more frames and execution contexts; only a cross-origin one (an out-of-process iframe) is a child target, through `Target.setAutoAttach`. + +**Same-origin iframes (first)**: + +| Domain | To do | +|---|---| +| DOM | iframe nodes with `contentDocument` and `frameId`; a MutationObserver per document; an iframe's navigation invalidates its old document and pushes the new one | +| Page | A frame registry; `childFrames` in `frameTree`; `frameAttached`, `frameNavigated`, `frameDetached` | +| Runtime | An execution context per window; `evaluate` and `callFunctionOn` run in the window of their `contextId`; console and exceptions captured in each window | +| CSS | `frameId` on style sheets; `getComputedStyle` of the node's window; the sheet registry and forced pseudo-classes per document | +| Network | `frameId` on requests; fetch, XHR, … wrapped in each window | +| Overlay, Input, geometry | Coordinates offset by the iframe and its scrolling | + +- **Realms**: an iframe's elements are not instances of the top page's `Element`. About 166 `instanceof` checks and about 180 uses of the global `document`, `window`, `getComputedStyle`, … in the agent have to use the node's own realm (`node.ownerDocument.defaultView`) or `nodeType` instead. +- **Not possible**: an iframe's earliest logs. The probe cannot run before the iframe's own scripts, only once it has loaded; usage.md has to say so. +- **Size**: large; domain by domain, each with tests against Chrome, about the main work of a minor. + +**Cross-origin iframes (later)**: their document is out of reach; the iframe has to load the probe too and be attached as a child target over postMessage (`Target.setAutoAttach`, `attachedToTarget`, messages routed by `sessionId`). The frontend handles child targets already; shell mode's relay works similarly. + +**Step 0, the earlier the better**: every new feature adds `instanceof` checks and uses of the global `document`. A step that changes no behavior stops that: + +1. A frame registry in the Context: `FRAME_ID` and `EXECUTION_CONTEXT_ID` looked up by document or window instead of constants, with only the top document registered for now; +2. A helper such as `realmOf(node)`, and a lint rule against `instanceof Element` and the global `document` in `cdp-agent`, the existing code exempted and migrated over time. + +## More that can be filled in the page + +All with standard page APIs, matching Chrome. By value for effort; the first three are small. + +| Item | Now | How, in the page | +|---|---|---| +| The Animations panel (`Animation` domain) | `enable` is a no-op | `document.getAnimations()` has CSS animations, transitions and Web Animations; listing, pausing, playback rate and seeking map onto it | +| Vision deficiencies (`setEmulatedVisionDeficiency`) | No-op | An SVG filter on ``; Chrome uses color matrices too | +| Network: blocked URLs, cache disabled, extra headers, offline | `setBlockedURLs`, `setCacheDisabled`, `setExtraHTTPHeaders`, `emulateNetworkConditions` are no-ops | For the fetch and XHR already intercepted; in shell mode, images and scripts too through the Service Worker. Throttling could only be approximate: not done | +| The browser's default styles | Not in Styles | A copy of Chromium's `html.css` (open source), loaded when used, matched to give the "user agent stylesheet" rules; to be kept in step with each Chrome | +| Grid, flex, container query and scroll-snap overlays | `Overlay.setShow*Overlays` are no-ops | Drawn in the page: `getComputedStyle` gives a grid's resolved tracks; the same layer as `highlightNode` | +| Emulated `prefers-color-scheme`, print and other media | `setEmulatedMedia` is a no-op | `@media` conditions rewritten through the CSSOM; the limits of forced `:hover`: not `@import`, cross-origin sheets or `matchMedia` | +| Application panel updates (`Storage.track*`) | No-ops: refreshed by hand | IDB writes and `caches.put` wrapped to send `indexedDBContentUpdated`, … | +| The Service Workers view | `ServiceWorker.enable` is a no-op | `navigator.serviceWorker.getRegistrations()`: listing, unregistering, updating; no debugging | +| Violations and long tasks (`Log.startViolationsReport`) | No-op | `PerformanceObserver` on `longtask` and `long-animation-frame`, reported in the Console | + +Of little value: the performance monitor (`Performance.getMetrics`: only the DOM node count and Chrome's `performance.memory`); event listeners' source locations (a stack captured on each add, which slows pages down). + ## Gaps in Emulation - `Emulation.setDeviceMetricsOverride` is not implemented: device mode in shell mode is laid out by the frontend, not through CDP, so tools that resize the viewport over CDP cannot. In shell mode it could reuse device mode's sizing of the page iframe. diff --git a/docs/roadmap.zh-CN.md b/docs/roadmap.zh-CN.md index a64032a..48f8b2a 100644 --- a/docs/roadmap.zh-CN.md +++ b/docs/roadmap.zh-CN.md @@ -42,6 +42,52 @@ **状态**:只是方向,没有讨论具体方案。 +## iframe + +**现状**:agent 是按「一个页面只有一个文档」写的。全局只有一个 `FRAME_ID` 和一个 `EXECUTION_CONTEXT_ID`(`core/context.ts`),所有事件都写死这两个值;DOM 节点描述里没有 `contentDocument`,Elements 里的 iframe 展不开;console、网络捕获、错误监听只挂在主页面的 `window` 上。 + +**Chrome 的模型**:同源 iframe 和主页面在同一个 target 里,是多个 frame 和执行上下文;只有跨域 iframe(进程外 iframe)才通过 `Target.setAutoAttach` 作为子 target 挂上来。 + +**同源 iframe(先做)**: + +| 域 | 要做的 | +|---|---| +| DOM | iframe 节点带 `contentDocument` 和 `frameId`;每个文档各自用 MutationObserver 监听;iframe 导航后旧文档失效、推送新文档 | +| Page | frame 表;`frameTree` 带 `childFrames`;`frameAttached`、`frameNavigated`、`frameDetached` 事件 | +| Runtime | 每个 window 一个执行上下文;`evaluate`、`callFunctionOn` 按 `contextId` 在对应 window 里执行;console 和异常的捕获挂到每个 window 上 | +| CSS | 样式表带 `frameId`;用节点所在 window 的 `getComputedStyle`;样式表登记和强制伪类区分文档 | +| Network | 请求带 `frameId`;每个 window 都包装 fetch、XHR 等 | +| Overlay、Input、几何计算 | 坐标加上 iframe 的偏移和内部滚动 | + +- **跨 realm**:iframe 里的元素不是主页面 `Element` 类的实例。agent 里约 166 处 `instanceof` 判断、约 180 处直接引用全局 `document`、`window`、`getComputedStyle` 等,都要改成用节点自己的 realm(`node.ownerDocument.defaultView`)或者按 `nodeType` 判断。 +- **做不到**:iframe 里的「最早日志」。探针没法先于 iframe 自己的脚本运行,只能在加载完成后挂上,要写进 usage 文档。 +- **工作量**:大,按域逐个迁移、各配 Chrome 对照测试,接近一个 minor 版本的主要工作。 + +**跨域 iframe(之后再说)**:页面拿不到它的文档,只能由 iframe 自己也加载探针,再通过 postMessage 作为子 target 接上来(`Target.setAutoAttach`、`attachedToTarget`、带 `sessionId` 的消息转发)。前端本来就支持子 target,外壳模式的 relay 有类似机制。 + +**第 0 步:越早越好**。每加一个功能,`instanceof` 和全局 `document` 的用法就多一些。先做一步不改变行为的铺垫,止住债务: + +1. Context 里引入 frame 注册表,`FRAME_ID`、`EXECUTION_CONTEXT_ID` 从常量改为按文档或 window 查询,暂时只登记主文档; +2. 加 `realmOf(node)` 之类的辅助函数,再加 lint 规则:在 `cdp-agent` 里禁止直接用 `instanceof Element` 和全局 `document`,现有代码先豁免,逐步迁移。 + +## 其他能在页面里补上的功能 + +都能用页面里的标准 API 实现,结果能和 Chrome 对齐。按性价比排序,前三项工作量小。 + +| 项目 | 现状 | 页面内的做法 | +|---|---|---| +| Animations 面板(`Animation` 域) | `enable` 是空操作 | `document.getAnimations()` 覆盖 CSS 动画、过渡和 Web Animations API;列出、暂停、调速、拖动进度都能对应上 | +| 色觉缺陷模拟(`setEmulatedVisionDeficiency`) | 空操作 | 在 `` 上加 SVG 滤镜,Chrome 用的也是颜色矩阵 | +| 网络:屏蔽 URL、禁用缓存、附加请求头、离线 | `setBlockedURLs`、`setCacheDisabled`、`setExtraHTTPHeaders`、`emulateNetworkConditions` 是空操作 | 对已经拦截的 fetch、XHR 生效;外壳模式下由 Service Worker 覆盖图片和脚本。限速只能近似,不做 | +| 浏览器默认样式 | Styles 里不显示 | 附带一份 Chromium 的 `html.css`(开源),用到时加载,用来匹配出「user agent stylesheet」规则;每次升级 Chrome 都要同步 | +| Grid / Flex / 容器查询 / scroll-snap 叠加层 | `Overlay.setShow*Overlays` 是空操作 | 在页面里画出来;`getComputedStyle` 返回 grid 解析后的轨道尺寸;和 `highlightNode` 共用叠加层 | +| 模拟 `prefers-color-scheme`、print 等媒体特性 | `setEmulatedMedia` 是空操作 | 通过 CSSOM 改写 `@media` 条件;限制和强制 `:hover` 相同:对 `@import`、跨域样式表和 `matchMedia` 不生效 | +| Application 面板自动刷新(`Storage.track*`) | 空操作,要手动刷新 | 包装 IDB 的写操作和 `caches.put`,发出 `indexedDBContentUpdated` 等事件 | +| Service Workers 视图 | `ServiceWorker.enable` 是空操作 | 用 `navigator.serviceWorker.getRegistrations()` 列出、注销、更新;不能调试 | +| 违规提示与长任务(`Log.startViolationsReport`) | 空操作 | `PerformanceObserver` 监听 `longtask`、`long-animation-frame`,在 Console 输出 | + +价值有限的:性能监视器(`Performance.getMetrics`,只有 DOM 节点数和 Chrome 的 `performance.memory`);事件监听器的源码位置(只能在添加时记录调用栈,影响性能)。 + ## Emulation 的缺口 - `Emulation.setDeviceMetricsOverride` 没有实现。外壳模式下的设备模式由前端直接布局 iframe,没有走 CDP。通过 CDP 改视口的自动化工具因此改不了尺寸;在外壳模式下可以复用设备模式的 iframe 尺寸逻辑来补上。 From 4ace447bd9946de91a0e64f69f3d8ec62bda0561 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 15:27:42 +0800 Subject: [PATCH 006/168] refactor(cdp-agent): look frames up in a registry, and count uses of the top realm The first step towards same-origin iframes (docs/roadmap.md, "iframes"), changing no behavior. - core/frames.ts: a FrameRegistry, looked up by id, execution context, window or node. Only the top document is registered for now. Domains take frame, loader and execution context ids from Context.frames instead of the FRAME_ID, LOADER_ID and EXECUTION_CONTEXT_ID constants; DOM.describeNode, DOM.getNodeForLocation and style sheet headers use the frame of their node. - core/realm.ts: node checks by nodeType (isElement, isDocument, isShadowRoot, ...) and a node's own document and window (documentOf, realmOf), which hold for an iframe's nodes too. - scripts/check-realms.ts, in `pnpm lint`: counts per file the `instanceof Element`-like checks and uses of the global document, window and getComputedStyle in the agent, against scripts/check-realms.json. A file may have fewer, not more. --- package.json | 2 +- .../src/cdp-agent/core/context.ts | 4 +- .../src/cdp-agent/core/frames.ts | 54 +++++++++++++++ .../src/cdp-agent/core/realm.ts | 36 ++++++++++ .../cdp-agent/domains/css/sheet-registry.ts | 4 +- .../cdp-agent/domains/debugger/debugger.ts | 6 +- .../src/cdp-agent/domains/dom/dom.ts | 10 ++- .../src/cdp-agent/domains/network/network.ts | 12 ++-- .../src/cdp-agent/domains/page/page.ts | 10 +-- .../src/cdp-agent/domains/runtime/runtime.ts | 16 ++--- .../src/cdp-agent/domains/webmcp/webmcp.ts | 14 ++-- .../devtools-anywhere/src/cdp-agent/index.ts | 2 + scripts/check-realms.json | 29 ++++++++ scripts/check-realms.ts | 68 +++++++++++++++++++ 14 files changed, 231 insertions(+), 36 deletions(-) create mode 100644 packages/devtools-anywhere/src/cdp-agent/core/frames.ts create mode 100644 packages/devtools-anywhere/src/cdp-agent/core/realm.ts create mode 100644 scripts/check-realms.json create mode 100644 scripts/check-realms.ts diff --git a/package.json b/package.json index 8867aab..dd6b67a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "playground": "node playground/server.ts", "remote": "node packages/remote-server/dist/cli.js --port 8790 --host 127.0.0.1 --no-token --public-url http://localhost:8790/", "licenses:update": "node scripts/third-party-licenses.ts", - "lint": "oxlint && node scripts/check-contracts.ts && node scripts/check-file-size.ts --all && node scripts/check-dir-size.ts --all", + "lint": "oxlint && node scripts/check-contracts.ts && node scripts/check-realms.ts && node scripts/check-file-size.ts --all && node scripts/check-dir-size.ts --all", "fmt": "oxfmt", "fmt:check": "oxfmt --check", "prepare": "husky" diff --git a/packages/devtools-anywhere/src/cdp-agent/core/context.ts b/packages/devtools-anywhere/src/cdp-agent/core/context.ts index 8eb380a..5413598 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/context.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/context.ts @@ -1,3 +1,4 @@ +import type { FrameRegistry } from './frames'; import type { NodeRegistry } from './nodes'; import type { ObjectRegistry } from './objects'; import type { ProbeView } from './probe-view'; @@ -9,11 +10,12 @@ export interface Context { objects: ObjectRegistry; nodes: NodeRegistry; probe: ProbeView; + frames: FrameRegistry; /** Re-checks which pseudo-elements exist, e.g. after a style edit. Set by the DOM domain. */ refreshPseudoElements?: (roots?: Node[]) => void; } -/** The page is exposed as a single frame with a single execution context. @internal */ +/** The top frame's id; its execution context is EXECUTION_CONTEXT_ID (core/frames.ts). @internal */ export const FRAME_ID = 'devtools-anywhere-frame'; /** * Like Chrome, every document gets its own loader id: each page load evaluates this module diff --git a/packages/devtools-anywhere/src/cdp-agent/core/frames.ts b/packages/devtools-anywhere/src/cdp-agent/core/frames.ts new file mode 100644 index 0000000..868441a --- /dev/null +++ b/packages/devtools-anywhere/src/cdp-agent/core/frames.ts @@ -0,0 +1,54 @@ +import { EXECUTION_CONTEXT_ID, FRAME_ID, LOADER_ID } from './context'; +import { documentOf } from './realm'; + +/** A document the agent exposes as a frame, with the execution context of its window. */ +export interface Frame { + id: string; + loaderId: string; + executionContextId: number; + window: Window; + parent?: Frame; +} + +/** + * The page's frames. Only the top document is registered so far: same-origin iframes are to + * come (docs/roadmap.md, "iframes"). Domains look frames up here rather than using FRAME_ID and + * EXECUTION_CONTEXT_ID, so that they are ready for more than one. + */ +export class FrameRegistry { + readonly top: Frame; + readonly #byWindow = new WeakMap(); + readonly #byId = new Map(); + readonly #byContext = new Map(); + + constructor(top: Window) { + this.top = { id: FRAME_ID, loaderId: LOADER_ID, executionContextId: EXECUTION_CONTEXT_ID, window: top }; + this.#add(this.top); + } + + #add(frame: Frame): void { + this.#byWindow.set(frame.window, frame); + this.#byId.set(frame.id, frame); + this.#byContext.set(frame.executionContextId, frame); + } + + byId(id: string): Frame | undefined { + return this.#byId.get(id); + } + + byContext(id: number): Frame | undefined { + return this.#byContext.get(id); + } + + ofWindow(win: Window | null | undefined): Frame { + return (win && this.#byWindow.get(win)) ?? this.top; + } + + /** The frame of the document a node is in (the top one for a node of no known document). */ + ofNode(node: Node | null | undefined): Frame { + return this.ofWindow(node && documentOf(node).defaultView); + } +} + +/** The page's frames, shared by all sessions: like frame ids, they belong to the page. */ +export const frames = new FrameRegistry(window); diff --git a/packages/devtools-anywhere/src/cdp-agent/core/realm.ts b/packages/devtools-anywhere/src/cdp-agent/core/realm.ts new file mode 100644 index 0000000..12ad7e5 --- /dev/null +++ b/packages/devtools-anywhere/src/cdp-agent/core/realm.ts @@ -0,0 +1,36 @@ +/** + * Node checks that hold across realms. A node of a same-origin iframe is not an instance of the + * top page's `Element`, `Document`, …, and its document is not the global `document`: the agent + * checks node types and uses the node's own window instead (docs/roadmap.md, "iframes"; + * scripts/check-realms.ts keeps new code from doing otherwise). + */ + +export function isDocument(node: Node): node is Document { + return node.nodeType === Node.DOCUMENT_NODE; +} + +export function isElement(node: Node | null | undefined): node is Element { + return node?.nodeType === Node.ELEMENT_NODE; +} + +export function isHtmlElement(node: Node | null | undefined): node is HTMLElement { + return isElement(node) && node.namespaceURI === 'http://www.w3.org/1999/xhtml'; +} + +export function isText(node: Node | null | undefined): node is Text { + return node?.nodeType === Node.TEXT_NODE; +} + +export function isShadowRoot(node: Node | null | undefined): node is ShadowRoot { + return node?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in node; +} + +/** The document a node is in, or the node itself if it is one. */ +export function documentOf(node: Node): Document { + return isDocument(node) ? node : (node.ownerDocument ?? document); +} + +/** The window of a node's document: its realm's globals (`getComputedStyle`, `Element`, …). */ +export function realmOf(node: Node): Window & typeof globalThis { + return documentOf(node).defaultView ?? window; +} diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/css/sheet-registry.ts b/packages/devtools-anywhere/src/cdp-agent/domains/css/sheet-registry.ts index ccf8c23..413e40f 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/css/sheet-registry.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/css/sheet-registry.ts @@ -1,5 +1,5 @@ import type { Protocol } from 'devtools-protocol'; -import { FRAME_ID } from '../../core/context'; +import { frames } from '../../core/frames'; import { isOwn } from '../../core/nodes'; import { CdpError } from '../../core/session'; import { adoptedOf } from '../../core/adopted'; @@ -269,7 +269,7 @@ export class SheetRegistry { (owner instanceof HTMLStyleElement && !this.fromParser(owner)); return { styleSheetId: this.idOf(sheet), - frameId: FRAME_ID, + frameId: frames.ofNode(owner).id, sourceURL: sheet.href ?? (isInspector || local ? '' : location.href), origin: this.origin(sheet), title: sheet.title ?? '', diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/debugger/debugger.ts b/packages/devtools-anywhere/src/cdp-agent/domains/debugger/debugger.ts index 2fa4370..26fc7d4 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/debugger/debugger.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/debugger/debugger.ts @@ -1,6 +1,6 @@ import type { Protocol } from 'devtools-protocol'; import { CdpError } from '../../core/session'; -import { EXECUTION_CONTEXT_ID, type Context } from '../../core/context'; +import type { Context } from '../../core/context'; import type { RequestRecord } from '../../probe/types'; import { refetch } from '../network/network'; @@ -13,7 +13,7 @@ import { refetch } from '../network/network'; * page uses and show in stack traces. The frontend ignore-lists them. * There are no breakpoints: the breakpoint commands are accepted and resolve to nothing. */ -export function registerDebugger({ session, probe }: Context) { +export function registerDebugger({ session, probe, frames }: Context) { /** Script id per URL, stable for the page's lifetime. */ const ids = new Map(); const sources = new Map>(); @@ -36,7 +36,7 @@ export function registerDebugger({ session, probe }: Context) { // Unknown until the source is loaded; the frontend reads the real extent from the source. endLine: 0, endColumn: 0, - executionContextId: EXECUTION_CONTEXT_ID, + executionContextId: frames.top.executionContextId, hash: '', buildId: '', isModule, diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/dom/dom.ts b/packages/devtools-anywhere/src/cdp-agent/domains/dom/dom.ts index 37c7b99..d3eb361 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/dom/dom.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/dom/dom.ts @@ -1,5 +1,5 @@ import type { Protocol } from 'devtools-protocol'; -import { FRAME_ID, type Context } from '../../core/context'; +import type { Context } from '../../core/context'; import { FORCE_ATTRIBUTE, PseudoElement, @@ -474,7 +474,7 @@ export function registerDom(ctx: Context) { const node = resolveNode(ctx, params); const described = nodes.serialize(node, params.depth ?? 0, params.pierce); // describeNode does not push the node: report a 0 node id unless the client knows it. - return { node: { ...described, nodeId: nodes.knownId(node) ?? 0, frameId: FRAME_ID } }; + return { node: { ...described, nodeId: nodes.knownId(node) ?? 0, frameId: ctx.frames.ofNode(node).id } }; }, resolveNode(params: Protocol.DOM.ResolveNodeRequest): Protocol.DOM.ResolveNodeResponse { @@ -649,7 +649,11 @@ export function registerDom(ctx: Context) { const el = document.elementsFromPoint(params.x, params.y).find(e => !closestOwn(e)); if (!el) throw new CdpError('No node found at given location'); const nodeId = nodes.knownId(el); - return { backendNodeId: nodes.backendIdOf(el), frameId: FRAME_ID, ...(nodeId !== undefined && { nodeId }) }; + return { + backendNodeId: nodes.backendIdOf(el), + frameId: ctx.frames.ofNode(el).id, + ...(nodeId !== undefined && { nodeId }), + }; }, collectClassNamesFromSubtree(params: Protocol.DOM.CollectClassNamesFromSubtreeRequest) { diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/network/network.ts b/packages/devtools-anywhere/src/cdp-agent/domains/network/network.ts index 1291689..836bc0d 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/network/network.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/network/network.ts @@ -1,5 +1,5 @@ import type { Protocol } from 'devtools-protocol'; -import { FRAME_ID, LOADER_ID, type Context } from '../../core/context'; +import type { Context } from '../../core/context'; import { base64, readBody } from '../../core/body'; import { CdpError } from '../../core/session'; import { parseStack } from '../../core/stack'; @@ -102,7 +102,7 @@ async function frameOf(data: unknown, mask: boolean): Promise void) | null = null; let unsubscribeSockets: (() => void) | null = null; const sent = new Map>(); @@ -160,7 +160,7 @@ export function registerNetwork({ session, probe }: Context) { const callFrames = parseStack(rec.stack, 1); session.emit('Network.requestWillBeSent', { requestId: rec.requestId, - loaderId: LOADER_ID, + loaderId: frames.top.loaderId, documentURL: location.href, request, timestamp: rec.startTime / 1000, @@ -169,7 +169,7 @@ export function registerNetwork({ session, probe }: Context) { initiator: callFrames.length ? { type: 'script', stack: { callFrames } } : { type: 'other' }, redirectHasExtraInfo: false, type: rec.type, - frameId: FRAME_ID, + frameId: frames.top.id, } satisfies Protocol.Network.RequestWillBeSentEvent); } @@ -177,7 +177,7 @@ export function registerNetwork({ session, probe }: Context) { done.add('response'); session.emit('Network.responseReceived', { requestId: rec.requestId, - loaderId: LOADER_ID, + loaderId: frames.top.loaderId, timestamp: (rec.responseTime ?? performance.now()) / 1000, type: rec.type, response: { @@ -193,7 +193,7 @@ export function registerNetwork({ session, probe }: Context) { securityState: 'unknown', }, hasExtraInfo: false, - frameId: FRAME_ID, + frameId: frames.top.id, } satisfies Protocol.Network.ResponseReceivedEvent); } diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts index 8bd8e4d..6255d5e 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts @@ -1,17 +1,17 @@ import type { Protocol } from 'devtools-protocol'; import { readBody } from '../../core/body'; -import { FRAME_ID, LOADER_ID, type Context } from '../../core/context'; +import type { Context } from '../../core/context'; import { isOwnRequest, refetch } from '../network/network'; -export function registerPage({ session, probe }: Context) { +export function registerPage({ session, probe, frames }: Context) { session.register('Page', { enable: () => {}, getResourceTree: (): Protocol.Page.GetResourceTreeResponse => ({ frameTree: { frame: { - id: FRAME_ID, - loaderId: LOADER_ID, + id: frames.top.id, + loaderId: frames.top.loaderId, url: location.href, domainAndRegistry: '', securityOrigin: location.origin, @@ -111,7 +111,7 @@ export function registerPage({ session, probe }: Context) { navigate(params: Protocol.Page.NavigateRequest): Protocol.Page.NavigateResponse { location.href = params.url; - return { frameId: FRAME_ID, loaderId: LOADER_ID }; + return { frameId: frames.top.id, loaderId: frames.top.loaderId }; }, addScriptToEvaluateOnNewDocument: (): Protocol.Page.AddScriptToEvaluateOnNewDocumentResponse => ({ diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.ts b/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.ts index e4e8917..9be45ca 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.ts @@ -1,5 +1,5 @@ import type { Protocol } from 'devtools-protocol'; -import { EXECUTION_CONTEXT_ID, FRAME_ID, type Context } from '../../core/context'; +import type { Context } from '../../core/context'; import { lastExpressionStatement, topLevelLexicalDeclarations, @@ -562,7 +562,7 @@ function globalLexicalNames(probe: Context['probe']) { } } -export function registerRuntime({ session, objects, nodes, probe }: Context) { +export function registerRuntime({ session, objects, nodes, probe, frames: { top: topFrame } }: Context) { const lexicalNames = globalLexicalNames(probe); const repl = replBindings(); let unsubscribe: (() => void) | null = null; @@ -619,7 +619,7 @@ export function registerRuntime({ session, objects, nodes, probe }: Context) { lineNumber: 0, columnNumber: 0, exception, - executionContextId: EXECUTION_CONTEXT_ID, + executionContextId: topFrame.executionContextId, ...(frames.length ? { stackTrace: { callFrames: frames } } : {}), }, }; @@ -643,7 +643,7 @@ export function registerRuntime({ session, objects, nodes, probe }: Context) { session.emit('Runtime.consoleAPICalled', { type: entry.type, args: consoleArgs(entry), - executionContextId: EXECUTION_CONTEXT_ID, + executionContextId: topFrame.executionContextId, timestamp: entry.time, // Drop the probe's console wrapper frame. stackTrace: { callFrames: parseStack(entry.stack, 1) }, @@ -663,7 +663,7 @@ export function registerRuntime({ session, objects, nodes, probe }: Context) { ...(url ? { url } : {}), scriptId: '', exception: objects.wrap(entry.error, { generatePreview: true, group: 'console' }), - executionContextId: EXECUTION_CONTEXT_ID, + executionContextId: topFrame.executionContextId, ...(frames.length ? { stackTrace: { callFrames: frames } } : {}), }, } satisfies Protocol.Runtime.ExceptionThrownEvent); @@ -674,11 +674,11 @@ export function registerRuntime({ session, objects, nodes, probe }: Context) { if (unsubscribe) return; session.emit('Runtime.executionContextCreated', { context: { - id: EXECUTION_CONTEXT_ID, + id: topFrame.executionContextId, origin: location.origin, name: '', uniqueId: 'devtools-anywhere', - auxData: { isDefault: true, type: 'default', frameId: FRAME_ID }, + auxData: { isDefault: true, type: 'default', frameId: topFrame.id }, }, } satisfies Protocol.Runtime.ExecutionContextCreatedEvent); // Like V8, report everything logged before the client connected. @@ -794,7 +794,7 @@ export function registerRuntime({ session, objects, nodes, probe }: Context) { text: 'Uncaught', lineNumber: 0, columnNumber: 0, - executionContextId: EXECUTION_CONTEXT_ID, + executionContextId: topFrame.executionContextId, }, }; } diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/webmcp/webmcp.ts b/packages/devtools-anywhere/src/cdp-agent/domains/webmcp/webmcp.ts index 255371c..5475f93 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/webmcp/webmcp.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/webmcp/webmcp.ts @@ -1,6 +1,6 @@ import type { Protocol } from 'devtools-protocol'; import { CdpError } from '../../core/session'; -import { FRAME_ID, type Context } from '../../core/context'; +import type { Context } from '../../core/context'; import { parseStack } from '../../core/stack'; import type { ToolOutcome, ToolRecord } from '../../probe/types'; @@ -124,7 +124,7 @@ const newInvocationId = () => * derives their input schema like Chrome does, fills them in and submits them. */ export function registerWebMcp(ctx: Context) { - const { session, nodes, objects, probe } = ctx; + const { session, nodes, objects, probe, frames } = ctx; const tools = new Map(); const invocations = new Map(); let stop: (() => void) | null = null; @@ -145,7 +145,7 @@ export function registerWebMcp(ctx: Context) { for (const tool of added) tools.set(tool.info.name, tool); if (removed.length) session.emit('WebMCP.toolsRemoved', { - tools: removed.map(name => ({ name, frameId: FRAME_ID })), + tools: removed.map(name => ({ name, frameId: frames.top.id })), } satisfies Protocol.WebMCP.ToolsRemovedEvent); if (added.length) session.emit('WebMCP.toolsAdded', { @@ -189,7 +189,7 @@ export function registerWebMcp(ctx: Context) { description: tool.description, ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}), ...(annotations ? { annotations } : {}), - frameId: FRAME_ID, + frameId: frames.top.id, ...(form ? { backendNodeId: nodes.backendIdOf(form) } : {}), ...(callFrames.length ? { stackTrace: { callFrames } } : {}), }, @@ -265,7 +265,7 @@ export function registerWebMcp(ctx: Context) { description, inputSchema: formSchema(form), ...(autosubmit ? { annotations: { autosubmit: true } } : {}), - frameId: FRAME_ID, + frameId: frames.top.id, backendNodeId: nodes.backendIdOf(form), }, invoke: (input, signal) => submitForm(form, input, autosubmit, signal), @@ -292,7 +292,7 @@ export function registerWebMcp(ctx: Context) { invokeTool(params: Protocol.WebMCP.InvokeToolRequest): Protocol.WebMCP.InvokeToolResponse { if (!stop) throw new CdpError('WebMCP domain is not enabled'); - const tool = params.frameId === FRAME_ID ? tools.get(params.toolName) : undefined; + const tool = params.frameId === frames.top.id ? tools.get(params.toolName) : undefined; if (!tool) throw new CdpError('Tool not found'); const invocationId = newInvocationId(); const controller = new AbortController(); @@ -302,7 +302,7 @@ export function registerWebMcp(ctx: Context) { queueMicrotask(async () => { session.emit('WebMCP.toolInvoked', { toolName: params.toolName, - frameId: FRAME_ID, + frameId: frames.top.id, invocationId, input: sortedJson(input), } satisfies Protocol.WebMCP.ToolInvokedEvent); diff --git a/packages/devtools-anywhere/src/cdp-agent/index.ts b/packages/devtools-anywhere/src/cdp-agent/index.ts index 8202899..d1675f9 100644 --- a/packages/devtools-anywhere/src/cdp-agent/index.ts +++ b/packages/devtools-anywhere/src/cdp-agent/index.ts @@ -1,4 +1,5 @@ import type { Context } from './core/context'; +import { frames } from './core/frames'; import { NodeRegistry } from './core/nodes'; import { ObjectRegistry } from './core/objects'; import { viewProbe } from './core/probe-view'; @@ -75,6 +76,7 @@ export function createCdpSession(options: CdpSessionOptions = {}): CdpSession { objects: new ObjectRegistry(), nodes: new NodeRegistry(host => host.shadowRoot ?? probe.shadowRoots.get(host) ?? null), probe: viewProbe(probe), + frames, }; ctx.session.onReset(() => { ctx.probe.close(); diff --git a/scripts/check-realms.json b/scripts/check-realms.json new file mode 100644 index 0000000..63cc306 --- /dev/null +++ b/scripts/check-realms.json @@ -0,0 +1,29 @@ +{ + "core/adopted.ts": 1, + "core/container-query.ts": 6, + "core/geometry.ts": 36, + "core/history.ts": 20, + "core/nodes.ts": 12, + "domains/css/cascade.ts": 7, + "domains/css/color.ts": 1, + "domains/css/css.ts": 25, + "domains/css/forced-pseudo.ts": 2, + "domains/css/sheet-registry.ts": 5, + "domains/css/source.ts": 3, + "domains/css/style.ts": 1, + "domains/debugger/debugger.ts": 1, + "domains/dom-debugger/dom-debugger.ts": 5, + "domains/dom/dom.ts": 34, + "domains/input/input.ts": 28, + "domains/network/network.ts": 3, + "domains/overlay/overlay.ts": 16, + "domains/page/page.ts": 3, + "domains/page/render.ts": 4, + "domains/page/screencast.ts": 1, + "domains/page/screenshot.ts": 1, + "domains/runtime/runtime.ts": 28, + "domains/storage/storage.ts": 2, + "domains/webmcp/webmcp.ts": 21, + "probe/index.ts": 12, + "probe/shell.ts": 8 +} diff --git a/scripts/check-realms.ts b/scripts/check-realms.ts new file mode 100644 index 0000000..ccd7b44 --- /dev/null +++ b/scripts/check-realms.ts @@ -0,0 +1,68 @@ +// Keeps the CDP agent from depending more on the top page's realm (`pnpm lint`). +// +// A same-origin iframe's nodes are not instances of the top page's `Element`, `Node`, …, and +// their document is not the global `document` (docs/roadmap.md, "iframes"). New code checks +// node types and uses the node's own window (cdp-agent/core/realm.ts, core/frames.ts) instead. +// The uses already there are counted per file in scripts/check-realms.json: a file may have +// fewer, not more. Run with `--update` after removing some, to lower the counts. +// +// Found line by line with regular expressions, outside lines that start a comment. +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const agent = path.join(root, 'packages/devtools-anywhere/src/cdp-agent'); +const baselineFile = path.join(root, 'scripts/check-realms.json'); +const PATTERNS = [ + /\binstanceof\s+(Node|Element|(HTML|SVG)\w*Element|Document|DocumentFragment|ShadowRoot|Text|Comment|CharacterData|Attr|Window)\b/g, + /(? = {}; +for (const file of sources(agent)) { + const rel = path.relative(agent, file).split(path.sep).join('/'); + if (EXEMPT.has(rel)) continue; + let count = 0; + for (const line of readFileSync(file, 'utf8').split('\n')) { + if (/^\s*(\/\/|\/?\*)/.test(line)) continue; + for (const pattern of PATTERNS) count += line.match(pattern)?.length ?? 0; + } + if (count) counts[rel] = count; +} + +if (process.argv.includes('--update')) { + const sorted = Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b))); + writeFileSync(baselineFile, `${JSON.stringify(sorted, null, 2)}\n`); + console.log(`Realm uses: ${Object.values(counts).reduce((a, b) => a + b, 0)}, written to scripts/check-realms.json.`); + process.exit(0); +} + +const baseline: Record = JSON.parse(readFileSync(baselineFile, 'utf8')); +const over = Object.entries(counts).filter(([file, count]) => count > (baseline[file] ?? 0)); +if (over.length) { + console.error("More uses of the top page's realm than in scripts/check-realms.json:\n"); + for (const [file, count] of over) console.error(` cdp-agent/${file}: ${count}, was ${baseline[file] ?? 0}`); + console.error( + "\nUse the node's own realm instead of `instanceof Element`, the global `document`, … " + + '(cdp-agent/core/realm.ts). Where the top page is meant, say so and run\n' + + '`node scripts/check-realms.ts --update`.' + ); + process.exit(1); +} +const total = Object.values(counts).reduce((a, b) => a + b, 0); +const allowed = Object.values(baseline).reduce((a, b) => a + b, 0); +console.log( + `Realm uses: ${total}${total < allowed ? `, down from ${allowed}: run with --update to lower the counts` : ''}.` +); From 1dea788520f280ca3e6983ed31d5c8d0c83ad7b3 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 15:34:22 +0800 Subject: [PATCH 007/168] feat(cdp-agent): register same-origin iframes in the frame registry FrameRegistry now finds the page's same-origin iframes (and s) as child frames, each with its own id, loader id, execution context id, parent, owner element, window and current document. Cross-origin iframes, whose contentDocument is out of reach, are not registered. - sync() walks the documents and registers, updates or unregisters frames. Like Chrome, a frame keeps its id when it loads another document but gets a new loader id and execution context id; a moved iframe gets a new browsing context, so it becomes a new frame. - onChange(listener) reports 'attached', 'navigated' and 'detached'. While anyone listens, the registry watches each frame document for iframes being added or removed and for their load events, and syncs by itself. - ofOwner, ofDocument, childrenOf and iteration in tree order serve the DOM domain now and Page/Runtime later. realm.ts gains isHtml(node, name), isNode (a node of any realm, through the top page's Node.prototype.nodeType getter, which Web IDL accepts across realms) and isFrameOwner. --- .../src/cdp-agent/core/frames.ts | 195 +++++++++++++++++- .../src/cdp-agent/core/realm.ts | 35 +++- 2 files changed, 221 insertions(+), 9 deletions(-) diff --git a/packages/devtools-anywhere/src/cdp-agent/core/frames.ts b/packages/devtools-anywhere/src/cdp-agent/core/frames.ts index 868441a..2a253fa 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/frames.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/frames.ts @@ -1,28 +1,81 @@ import { EXECUTION_CONTEXT_ID, FRAME_ID, LOADER_ID } from './context'; -import { documentOf } from './realm'; +import { closestOwn } from './nodes'; +import { type FrameOwner, documentOf, isElement, isFrameOwner, isNode } from './realm'; /** A document the agent exposes as a frame, with the execution context of its window. */ export interface Frame { - id: string; + readonly id: string; + /** Like Chrome, a new one for each document the frame loads. */ loaderId: string; + /** Like Chrome, a new one for each document the frame loads. */ executionContextId: number; - window: Window; - parent?: Frame; + /** The frame's WindowProxy: the same object across the frame's navigations. */ + readonly window: Window; + /** The document the frame shows now. */ + document: Document; + /** The `'; + frame.onload = resolve; + document.body.append(frame); + }) + ); + for (const side of [t.ours, t.chrome]) await side.send('Page.enable'); +}); +after(() => env?.close()); + +/** The Page events of both sides in the order they came, without the stack of frameAttached. */ +async function recorded() { + const read = async (side: TestPage['ours']) => { + const all: { method: string; params: unknown }[] = []; + for (const method of EVENTS) + for (const params of await side.events(method)) { + const { stack: _, ...rest } = params as { stack?: unknown }; + all.push({ method, params: rest }); + } + return all; + }; + return Promise.all([read(t.ours), read(t.chrome)]); +} + +/** Runs `action` in the page, then waits until both sides sent `count` frameStoppedLoading. */ +async function act(action: () => Promise, until: EventName = 'Page.frameStoppedLoading', count = 1) { + await Promise.all([t.ours.clear(), t.chrome.clear()]); + await action(); + await waitForBoth(t, until, count); +} + +const sequence = (events: { method: string }[]) => events.map(e => e.method); + +test('getFrameTree and getResourceTree list same-origin iframes as child frames', async () => { + const [ours, chrome] = await Promise.all([t.ours.send('Page.getFrameTree'), t.chrome.send('Page.getFrameTree')]); + assert.deepEqual(diffSubset(ours, chrome), []); + const pre = ours.frameTree.childFrames?.[0]; + assert.equal(pre?.frame.parentId, ours.frameTree.frame.id); + assert.equal(pre?.frame.name, 'pre'); + assert.equal(pre?.childFrames?.[0]?.frame.name, 'inner'); + assert.equal(pre?.childFrames?.[0]?.frame.parentId, pre?.frame.id); + + const [a, b] = await Promise.all([t.ours.send('Page.getResourceTree'), t.chrome.send('Page.getResourceTree')]); + assert.deepEqual(diffSubset(a.frameTree.childFrames, b.frameTree.childFrames), []); + assert.deepEqual(diffSubset(a.frameTree.frame, b.frameTree.frame), []); +}); + +test('an iframe with a src: attached, then navigated once its document commits', async () => { + await act(() => + t.page.evaluate( + src => + new Promise(resolve => { + const frame = Object.assign(document.createElement('iframe'), { id: 'a', src, onload: resolve }); + document.body.append(frame); + }), + CHILD + ) + ); + const [ours, chrome] = await recorded(); + assert.deepEqual(sequence(ours), sequence(chrome)); + assert.deepEqual(diffSubset(ours, chrome), []); +}); + +test('an empty iframe is navigated to about:blank right away', async () => { + await act(() => + t.page.evaluate(() => { + document.body.append(Object.assign(document.createElement('iframe'), { id: 'blank' })); + }) + ); + const [ours, chrome] = await recorded(); + // Chrome reports the navigation before it starts loading. + assert.deepEqual(sequence(ours), sequence(chrome)); + assert.deepEqual(diffSubset(ours, chrome), []); +}); + +test('an iframe navigating to another document, and its fragment in the frame tree', async () => { + await act(() => + t.page.evaluate( + src => + new Promise(resolve => { + const frame = document.querySelector('#a'); + if (!frame) throw new Error('no #a'); + frame.onload = resolve; + frame.src = `${src}#part`; + }), + OTHER + ) + ); + const [ours, chrome] = await recorded(); + assert.deepEqual(sequence(ours), sequence(chrome)); + assert.deepEqual(diffSubset(ours, chrome), []); + const navigated = ours.find(e => e.method === 'Page.frameNavigated')?.params as Protocol.Page.FrameNavigatedEvent; + assert.equal(navigated.frame.urlFragment, '#part'); + const [a, b] = await Promise.all([t.ours.send('Page.getFrameTree'), t.chrome.send('Page.getFrameTree')]); + assert.deepEqual(diffSubset(a, b), []); +}); + +test('removing an iframe detaches its frames, the innermost first', async () => { + await act( + () => + t.page.evaluate(() => { + document.querySelector('#pre')?.remove(); + }), + 'Page.frameDetached', + 2 + ); + const [ours, chrome] = await recorded(); + assert.deepEqual(sequence(ours), sequence(chrome)); + assert.deepEqual(diffSubset(ours, chrome), []); + const [a, b] = await Promise.all([t.ours.send('Page.getFrameTree'), t.chrome.send('Page.getFrameTree')]); + assert.equal(a.frameTree.childFrames?.length, b.frameTree.childFrames?.length); +}); + +test('Page.disable stops the frame events', async () => { + await t.ours.send('Page.disable'); + await t.ours.clear(); + await t.page.evaluate(() => document.querySelector('#blank')?.remove()); + await new Promise(resolve => setTimeout(resolve, 100)); + assert.deepEqual(await t.ours.events('Page.frameDetached'), []); + await t.ours.send('Page.enable'); +}); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts index 6255d5e..9bdb8d6 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts @@ -2,44 +2,59 @@ import type { Protocol } from 'devtools-protocol'; import { readBody } from '../../core/body'; import type { Context } from '../../core/context'; import { isOwnRequest, refetch } from '../network/network'; +import { frameEvents, frameTree } from './frame-tree'; + +/** Resource Timing initiators of documents: a frame's own, not resources of the page. */ +const DOCUMENT_INITIATORS = new Set(['navigation', 'iframe', 'frame']); export function registerPage({ session, probe, frames }: Context) { + let stopFrames: (() => void) | null = null; + const disable = () => { + stopFrames?.(); + stopFrames = null; + }; + session.onReset(disable); + session.register('Page', { - enable: () => {}, + /** Like Chrome, frames attached, navigated and detached are reported from now on. */ + enable() { + stopFrames ??= frames.onChange(frameEvents(session, frames)); + }, - getResourceTree: (): Protocol.Page.GetResourceTreeResponse => ({ - frameTree: { - frame: { - id: frames.top.id, - loaderId: frames.top.loaderId, - url: location.href, - domainAndRegistry: '', - securityOrigin: location.origin, - mimeType: 'text/html', - secureContextType: !isSecureContext - ? 'InsecureScheme' - : /^(.+\.)?localhost$|^127(\.\d+){3}$|^\[::1\]$/.test(location.hostname) - ? 'SecureLocalhost' - : 'Secure', - crossOriginIsolatedContextType: 'NotIsolated', - gatedAPIFeatures: [], - }, - // The page's own resources, as far as Resource Timing knows them (Sources panel). - resources: probe.requests - .filter( - rec => - rec.initiatorType && - rec.initiatorType !== 'navigation' && - rec.type !== 'Ping' && - !isOwnRequest(probe, rec) - ) - .map(rec => ({ - url: rec.url, - type: rec.type, - mimeType: rec.mimeType || '', - })), - }, - }), + disable, + + getFrameTree(): Protocol.Page.GetFrameTreeResponse { + frames.sync(); + return { frameTree: frameTree(frames, frames.top) }; + }, + + getResourceTree(): Protocol.Page.GetResourceTreeResponse { + frames.sync(); + // The top page's own resources, as far as Resource Timing knows them (Sources panel). + // Those of iframes are not known: the probe watches the top page's requests only. + const resources = probe.requests + .filter( + rec => + rec.initiatorType && + !DOCUMENT_INITIATORS.has(rec.initiatorType) && + rec.type !== 'Ping' && + !isOwnRequest(probe, rec) + ) + .map(rec => ({ + url: rec.url, + type: rec.type, + mimeType: rec.mimeType || '', + })); + const withResources = ( + tree: Protocol.Page.FrameTree, + own: Protocol.Page.FrameResource[] + ): Protocol.Page.FrameResourceTree => ({ + frame: tree.frame, + ...(tree.childFrames ? { childFrames: tree.childFrames.map(child => withResources(child, [])) } : {}), + resources: own, + }); + return { frameTree: withResources(frameTree(frames, frames.top), resources) }; + }, /** Re-fetches the resource, from the HTTP cache when possible; not recorded by the probe. */ async getResourceContent( From b81bacf167c15167a7ad1bd0522ad9c4b68615c2 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 15:53:11 +0800 Subject: [PATCH 015/168] feat(cdp-agent): let the probe hook the network APIs of a same-origin window Probe.hookNetwork(win, frame) installs the fetch, XHR and WebSocket hooks and the Resource Timing observer in another window (a same-origin iframe's), recording into the same lists. Each request record started there gets a FrameTag (frameId, loaderId, documentURL), from the `frame` callback called when the request starts: like Chrome, a request keeps the document that made it even if its frame navigates later. WebSocket records are not tagged: Chrome's webSocketCreated has no frame either. A window is hooked once per global object, marked with a symbol: when a navigation keeps the global (the initial about:blank document), calling again only replaces `frame`. Resource Timing in such a window: - entries are shifted from the iframe's time origin to the top page's, so timestamps of every frame are on one clock; - fetch() and XHR entries that started before the hooks are now reported (as Fetch / XHR), only later ones are left to the hooks. In an iframe these are the requests of its first scripts, which run before the agent learns of the new document; Chrome reports them too. The probe view recognizes Headers of any realm (an iframe's fetch() returns its own), and passes hookNetwork through. Optional in the Probe type: probes from before 0.3.0 lack it. --- .../src/cdp-agent/core/probe-view.ts | 6 ++- .../src/cdp-agent/probe/index.ts | 3 +- .../src/cdp-agent/probe/network.ts | 48 ++++++++++++++----- .../src/cdp-agent/probe/types.ts | 19 ++++++++ 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/packages/devtools-anywhere/src/cdp-agent/core/probe-view.ts b/packages/devtools-anywhere/src/cdp-agent/core/probe-view.ts index 9c36093..abec934 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/probe-view.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/probe-view.ts @@ -79,6 +79,9 @@ function consoleEntry(call: Extract): Omit Object.prototype.toString.call(value) === '[object Headers]'; + function headersToObject(headers: Record | Headers | string): Record { const obj: Record = {}; if (typeof headers === 'string') { @@ -86,7 +89,7 @@ function headersToObject(headers: Record | Headers | string): Re const i = line.indexOf(':'); if (i > 0) obj[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim(); } - } else if (headers instanceof Headers) headers.forEach((v, k) => (obj[k] = v)); + } else if (isHeaders(headers)) headers.forEach((v, k) => (obj[k] = v)); else Object.assign(obj, headers); return obj; } @@ -278,6 +281,7 @@ export function viewProbe(probe: Probe): ProbeView { listeners: probe.listeners, ownUrls: probe.ownUrls, fetch: probe.fetch, + ...(probe.hookNetwork ? { hookNetwork: probe.hookNetwork } : {}), tools: probe.tools, bodyLimits(totalSize, resourceSize) { maxTotal = totalSize; diff --git a/packages/devtools-anywhere/src/cdp-agent/probe/index.ts b/packages/devtools-anywhere/src/cdp-agent/probe/index.ts index 6676c20..ca4dbfc 100644 --- a/packages/devtools-anywhere/src/cdp-agent/probe/index.ts +++ b/packages/devtools-anywhere/src/cdp-agent/probe/index.ts @@ -114,7 +114,8 @@ export function installProbe(): Probe { requests, }; const shell = readShellConfig(); - hookNetwork(probe, sink, w, listen, shell?.serviceWorker); + hookNetwork(probe, sink, w, listen, undefined, shell?.serviceWorker); + probe.hookNetwork = (win, frame) => hookNetwork(probe, sink, win, listen, frame); if (shell && window === top) { // When it refuses (a loop), the page loads on its own: probe.ts sees no config left. diff --git a/packages/devtools-anywhere/src/cdp-agent/probe/network.ts b/packages/devtools-anywhere/src/cdp-agent/probe/network.ts index 763e23d..8a64481 100644 --- a/packages/devtools-anywhere/src/cdp-agent/probe/network.ts +++ b/packages/devtools-anywhere/src/cdp-agent/probe/network.ts @@ -2,7 +2,7 @@ // Resource Timing, for one window. Every global is taken from that window, so that the same hooks // work in a same-origin iframe, whose `fetch`, `XMLHttpRequest`, … are its own. import { RESPONSE_DETAILS } from './messages'; -import type { Probe, ProbeRequest, RequestType, SocketRecord } from './types'; +import type { FrameTag, Probe, ProbeRequest, RequestType, SocketRecord } from './types'; /** `performance.now()`, written once: it is called in many places, and the probe is kept small. */ export const now = () => performance.now(); @@ -30,24 +30,41 @@ export interface NetworkSink { requests: ProbeRequest[]; } +/** Marks a hooked window (its global object: a navigation may replace it), with what tags its requests. */ +const HOOKED = Symbol(); + /** - * Hooks the network APIs of `w`. `listen` is the page's addEventListener before it was hooked: - * the hooks' own listeners are not the page's. `serviceWorkerUrl` is shell mode's worker, which - * reports what Resource Timing does not expose (the top window only). + * Hooks the network APIs of `w`, once per global object. `listen` is the page's addEventListener + * before it was hooked: the hooks' own listeners are not the page's. `frame` tags the requests + * of an iframe's window. `serviceWorkerUrl` is shell mode's worker, which reports what Resource + * Timing does not expose (the top window only). */ export function hookNetwork( probe: Probe, sink: NetworkSink, w: Realm, listen: Listen, + frame?: () => FrameTag, serviceWorkerUrl?: string | undefined ): void { + const found: { frame?: (() => FrameTag) | undefined } | undefined = Reflect.get(w, HOOKED); + if (found) { + found.frame = frame; + return; + } + const state = { frame }; + Object.defineProperty(w, HOOKED, { value: state }); + const add = (rec: ProbeRequest) => { + const tag = state.frame?.(); + if (tag) rec.frame = tag; + return sink.add(rec); + }; const track: Track = record => - sink.add({ ...record, requestId: '', startTime: now(), wallTime: Date.now(), state: 'pending' }); + add({ ...record, requestId: '', startTime: now(), wallTime: Date.now(), state: 'pending' }); hookWebSocket(probe, w, listen, sink.socket); hookFetch(track, sink.update, probe, w); hookXhr(track, sink.update, listen, probe, w); - observeResources(sink, w, serviceWorkerUrl); + observeResources({ ...sink, add }, w, serviceWorkerUrl); } // ---- other requests (Resource Timing) ---- @@ -55,6 +72,8 @@ export function hookNetwork( /** CDP resource type of a Resource Timing entry, from its MIME type or initiator. */ function resourceType(entry: PerformanceResourceTiming, mimeType: string): RequestType { if (entry.entryType === 'navigation') return 'Document'; + if (entry.initiatorType === 'fetch') return 'Fetch'; + if (entry.initiatorType === 'xmlhttprequest') return 'XHR'; if (/^text\/css\b/.test(mimeType)) return 'Stylesheet'; if (/javascript|ecmascript/.test(mimeType)) return 'Script'; if (mimeType.startsWith('image/')) return 'Image'; @@ -85,11 +104,15 @@ function resourceType(entry: PerformanceResourceTiming, mimeType: string): Reque /** * Records the requests that cannot be hooked (the document, style sheets, scripts, images, * fonts, …) from Resource Timing. Entries are only created once a request has finished, and - * `buffered` also delivers those from before the probe ran, such as the document itself. + * `buffered` also delivers those from before the probe ran, such as the document itself and + * fetch() and XHR requests made before the hooks (in an iframe, those of its first scripts). */ function observeResources(sink: NetworkSink, w: Realm, serviceWorkerUrl: string | undefined) { const { add, update, requests } = sink; if (typeof w.PerformanceObserver === 'undefined') return; + // An iframe's entries are timed from its own time origin; records from the top one's. + const shift = w.performance.timeOrigin - performance.timeOrigin; + const hooked = w.performance.now(); // In shell mode with the Service Worker, it reports what Resource Timing does not expose // (headers, status text) of same-origin subresources, before the request's entry exists. // They are matched by URL; a late report still completes the record, but only one that was @@ -150,8 +173,9 @@ function observeResources(sink: NetworkSink, w: Realm, serviceWorkerUrl: string const record = (entry: PerformanceResourceTiming) => { if (seen.has(entry)) return; if (entry.entryType === 'navigation') seen.add(entry); - // fetch() and XHR are hooked, with more detail; DevTools' own fetches are unhooked. - if (entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') return; + // fetch() and XHR are hooked from then on, with more detail; DevTools' own fetches are unhooked. + if ((entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') && entry.startTime >= hooked) + return; // The browser's own favicon request is not the page's, and Chrome does not report it. if (entry.initiatorType === 'other' && (entry.name.split(/[?#]/)[0] ?? '').endsWith('/favicon.ico')) return; // Not in TypeScript's DOM types yet (Chrome 129+). @@ -165,16 +189,16 @@ function observeResources(sink: NetworkSink, w: Realm, serviceWorkerUrl: string method: 'GET', url: entry.name, requestHeaders: {}, - startTime: entry.startTime, + startTime: entry.startTime + shift, wallTime: w.performance.timeOrigin + entry.startTime, state: 'done', - responseTime: entry.responseStart || entry.responseEnd, + responseTime: (entry.responseStart || entry.responseEnd) + shift, status, statusText: '', responseHeaders: {}, mimeType, encodedDataLength: entry.transferSize, - endTime: entry.responseEnd, + endTime: entry.responseEnd + shift, ...patch, }; if (!patch && isOurs(container?.controller)) awaiting.add(rec); diff --git a/packages/devtools-anywhere/src/cdp-agent/probe/types.ts b/packages/devtools-anywhere/src/cdp-agent/probe/types.ts index db2c5e3..246d27f 100644 --- a/packages/devtools-anywhere/src/cdp-agent/probe/types.ts +++ b/packages/devtools-anywhere/src/cdp-agent/probe/types.ts @@ -137,6 +137,18 @@ export interface RequestRecord { errorText?: string; /** Whether a failed request was stopped by the page (abort), not by an error. */ canceled?: boolean; + /** The same-origin iframe document that made the request; none for the top one's. */ + frame?: FrameTag; +} + +/** + * Which document of a same-origin iframe a request belongs to, as the CDP agent names it: taken + * when the request starts, so a request keeps the document that made it after a navigation. + */ +export interface FrameTag { + frameId: string; + loaderId: string; + documentURL: string; } /** Something that happened on a WebSocket after it was created. */ @@ -201,6 +213,13 @@ export interface Probe { fetch: typeof fetch; /** The page's WebSocket before it was hooked: DevTools' own connections are not recorded. */ WebSocket: typeof WebSocket; + /** + * Hooks the network APIs of a same-origin iframe's window like the top one's: its requests are + * recorded here, tagged with what `frame` returns when each starts. Called again for a window + * already hooked (one kept across a navigation), it only replaces `frame`. Absent in probes + * from before 0.3.0. + */ + hookNetwork?(win: Window & typeof globalThis, frame: () => FrameTag): void; /** WebMCP tools registered with `document.modelContext.registerTool`, by name. */ tools: Map; /** From 53634971fd9743c8315a60369d5a79dc14e48922 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 15:54:10 +0800 Subject: [PATCH 016/168] feat(cdp-agent): announce the style sheets of same-origin iframes The CSS domain only looked at the top document. The sheet registry now takes the documents of all registered frames (and the shadow roots in them) as trees with sheets of their own, so CSS.enable announces the sheets of each same-origin iframe, and the domain watches every frame's document for

srcdoc

'; + }, + ].map( + init => + new Promise(resolve => { + const frame = document.createElement('iframe'); + init(frame); + frame.onload = resolve; + document.body.append(frame); + }) + ) + ), + STYLED + ); + for (const side of [t.ours, t.chrome]) { + await side.send('DOM.enable'); + await side.send('CSS.enable'); + } +}); +after(() => env?.close()); + +function find(node: DomNode, predicate: (n: DomNode) => boolean): DomNode | null { + if (predicate(node)) return node; + const next = [...(node.children ?? []), ...(node.shadowRoots ?? [])]; + if (node.contentDocument) next.push(node.contentDocument); + for (const child of next) { + const found = find(child, predicate); + if (found) return found; + } + return null; +} +const hasId = (id: string) => (n: DomNode) => + (n.attributes ?? []).some((v, i) => i % 2 === 0 && v === 'id' && n.attributes?.[i + 1] === id); + +/** The names of the frames: an iframe's id for its frame, `top` for the others. */ +async function frameNames(side: Side): Promise> { + const { root } = await side.send('DOM.getDocument', { depth: -1, pierce: true }); + const names = new Map(); + for (const id of ['styled', 'sd']) { + const frameId = find(root, hasId(id))?.frameId; + if (frameId) names.set(frameId, id); + } + return names; +} + +/** Headers in a form both sides share: frames by name, URLs without the origin. */ +async function headers(side: Side, events: Protocol.CSS.StyleSheetAddedEvent[]) { + const names = await frameNames(side); + return events + .map(({ header: h }) => ({ + frame: names.get(h.frameId) ?? 'top', + sourceURL: h.sourceURL.replace(/^http:\/\/[^/]+/, ''), + origin: h.origin, + isInline: h.isInline, + isMutable: h.isMutable, + isConstructed: h.isConstructed, + disabled: h.disabled, + hasOwner: h.ownerNode !== undefined, + })) + .filter(h => h.frame !== 'top') + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); +} + +async function compareAdded() { + const [o, c] = [await t.ours.events('CSS.styleSheetAdded'), await t.chrome.events('CSS.styleSheetAdded')]; + const [ours, chrome] = [await headers(t.ours, o), await headers(t.chrome, c)]; + assert.deepEqual(ours, chrome); + return chrome; +} + +const clear = () => Promise.all([t.ours.clear(), t.chrome.clear()]); + +describe('style sheets', () => { + test('the sheets of iframe documents are announced with their frame', async () => { + const chrome = await compareAdded(); + // The and the '; + }) + ); + const [removedOurs, removedChrome] = await waitForBoth(t, 'CSS.styleSheetRemoved'); + const [addedOurs, addedChrome] = await waitForBoth(t, 'CSS.styleSheetAdded', 2); + assert.equal(removedOurs.length, removedChrome.length); + assert.equal(addedOurs.length, addedChrome.length); + await compareAdded(); + }); + + test('a removed iframe takes its sheets along', async () => { + await clear(); + await t.page.evaluate(() => document.querySelector('#sd')?.remove()); + const [ours, chrome] = await waitForBoth(t, 'CSS.styleSheetRemoved', 2); + assert.equal(ours.length, chrome.length); + }); +}); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/css/css.ts b/packages/devtools-anywhere/src/cdp-agent/domains/css/css.ts index 6148148..4aecd9f 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/css/css.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/css/css.ts @@ -1,12 +1,12 @@ import type { Protocol } from 'devtools-protocol'; -import { isCssom } from '../../core/realm'; +import { documentOf, isCssom, isElement, isHtml, isNode } from '../../core/realm'; import { type Context } from '../../core/context'; import { OWN_ATTRIBUTE, PSEUDO_TYPES, isOwn, markOwn, PseudoElement, type PseudoType } from '../../core/nodes'; import { parentOf } from '../../core/container-query'; import { CdpError } from '../../core/session'; import { resolveElement } from '../dom/dom'; import { historyOf } from '../../core/history'; -import { quietly, watchAdopted } from '../../core/adopted'; +import { quietly, watchAdopted, watchTrees } from '../../core/adopted'; import { ForcedPseudoClasses } from './forced-pseudo'; import { backgroundsBehind } from './color'; import { Cascade } from './cascade'; @@ -26,8 +26,11 @@ export function registerCss(ctx: Context) { const { session, nodes } = ctx; const history = historyOf(ctx); + /** The documents of the frames, the top one first. */ + const documents = () => Array.from(ctx.frames, f => f.document); + /** - * Shadow roots in the document (closed ones too, when the probe recorded them), in tree order. + * Shadow roots in the frames' documents (closed ones too, when the probe recorded them), in tree order. * While the CSS domain is enabled they are kept until the observer sees an element come or go, * or a shadow root is attached. */ @@ -40,9 +43,9 @@ export function registerCss(ctx: Context) { if (shadowRootCache) return shadowRootCache; const roots: ShadowRoot[] = []; const visit = (root: Document | ShadowRoot) => { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + const walker = documentOf(root).createTreeWalker(root, NodeFilter.SHOW_ELEMENT); for (let node = walker.nextNode(); node; node = walker.nextNode()) { - if (!(node instanceof Element) || isOwn(node)) continue; + if (!isElement(node) || isOwn(node)) continue; const shadowRoot = nodes.shadowRootOf(node); if (!shadowRoot) continue; roots.push(shadowRoot); @@ -54,7 +57,7 @@ export function registerCss(ctx: Context) { visit(shadowRoot); } }; - visit(document); + documents().forEach(visit); if (observer) shadowRootCache = roots; return roots; } @@ -71,10 +74,17 @@ export function registerCss(ctx: Context) { // The DOM does not tell which + + +
+

inner text

+
+ + diff --git a/scripts/check-realms.json b/scripts/check-realms.json index 8206368..0dd8c28 100644 --- a/scripts/check-realms.json +++ b/scripts/check-realms.json @@ -1,13 +1,12 @@ { - "core/adopted.ts": 1, "core/container-query.ts": 6, "core/geometry.ts": 36, "core/history.ts": 1, "domains/css/cascade.ts": 7, "domains/css/color.ts": 1, - "domains/css/css.ts": 25, + "domains/css/css.ts": 9, "domains/css/forced-pseudo.ts": 2, - "domains/css/sheet-registry.ts": 5, + "domains/css/sheet-registry.ts": 3, "domains/css/source.ts": 3, "domains/css/style.ts": 1, "domains/debugger/debugger.ts": 1, From 46c561907c8734fde0acc0efa60adc5a07eef884 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 15:54:26 +0800 Subject: [PATCH 017/168] feat(cdp-agent): give each same-origin iframe document its own execution context Runtime.enable used to announce one context, the top page's. It now announces the default context of every frame in the registry, and while enabled follows the frames, as Chrome does (measured against it): - an iframe's context is created once its document is there; none is announced for the initial about:blank an iframe with a src or srcdoc is about to leave, like Chrome; - a navigated frame's context is destroyed, then the new document's created; - a removed frame's context is destroyed (descendants first, following the registry); - on enable, the contexts come newest first: Chrome lists them in that order. Each context has the frame's id in auxData, isDefault true, the origin of its document (inherited by about:blank and about:srcdoc, so the parent's, as in Chrome) and a uniqueId of its own, which executionContextDestroyed carries too. Runtime.disable and the end of the session stop following. The bookkeeping lives in domains/runtime/contexts.ts, which also resolves contextId / uniqueContextId to a frame for the commands that take one. --- .../src/cdp-agent/domains/runtime/contexts.ts | 83 ++++++++++++ .../runtime-frames.conformance.test.ts | 121 ++++++++++++++++++ .../src/cdp-agent/domains/runtime/runtime.ts | 17 +-- 3 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 packages/devtools-anywhere/src/cdp-agent/domains/runtime/contexts.ts create mode 100644 packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime-frames.conformance.test.ts diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/runtime/contexts.ts b/packages/devtools-anywhere/src/cdp-agent/domains/runtime/contexts.ts new file mode 100644 index 0000000..e98a1bc --- /dev/null +++ b/packages/devtools-anywhere/src/cdp-agent/domains/runtime/contexts.ts @@ -0,0 +1,83 @@ +import type { Protocol } from 'devtools-protocol'; +import { type Frame, type FrameChange, type FrameRegistry, awaitsFirstDocument } from '../../core/frames'; +import { CdpError } from '../../core/session'; + +const uniqueIdOf = (contextId: number) => `devtools-anywhere-context-${contextId}`; + +/** + * The execution contexts the client knows of: the default one of each frame's document (no + * isolated worlds). Like Chrome, Runtime.enable announces all of them, then each frame's + * document as it comes (executionContextCreated) and goes (executionContextDestroyed, for a + * frame navigated before the new document's context is created). + */ +export function executionContexts(frames: FrameRegistry, emit: (method: string, params: object) => void) { + /** The context announced for each frame: the registry has moved on once a frame navigated. */ + const announced = new Map(); + let stop: (() => void) | null = null; + + function created(frame: Frame) { + // Chrome creates none for the initial about:blank an iframe leaves for its src. + if (awaitsFirstDocument(frame)) return; + const id = frame.executionContextId; + announced.set(frame, id); + emit('Runtime.executionContextCreated', { + context: { + id, + // The document's origin, inherited by about:blank and about:srcdoc. + origin: frame.window.origin, + name: '', + uniqueId: uniqueIdOf(id), + auxData: { isDefault: true, type: 'default', frameId: frame.id }, + }, + } satisfies Protocol.Runtime.ExecutionContextCreatedEvent); + } + + function destroyed(frame: Frame) { + const id = announced.get(frame); + if (id === undefined) return; + announced.delete(frame); + emit('Runtime.executionContextDestroyed', { + executionContextId: id, + executionContextUniqueId: uniqueIdOf(id), + } satisfies Protocol.Runtime.ExecutionContextDestroyedEvent); + } + + const onChange = ({ type, frame }: FrameChange) => { + if (type !== 'attached') destroyed(frame); + if (type !== 'detached') created(frame); + }; + + return { + /** + * Announces every context, newest first like Chrome (whose contexts come out of a map in + * that order), and follows the frames from now on. + */ + enable() { + if (stop) return; + frames.sync(); + for (const frame of Array.from(frames).sort((a, b) => b.executionContextId - a.executionContextId)) + created(frame); + stop = frames.onChange(onChange); + }, + disable() { + stop?.(); + stop = null; + announced.clear(); + }, + /** + * The frame whose context `contextId` or `uniqueContextId` names, the top one when neither + * is given. Throws Chrome's error for a context that is gone or never was. + */ + frameOf(contextId?: number, uniqueContextId?: string): Frame { + if (uniqueContextId !== undefined) { + const frame = Array.from(frames).find(f => uniqueIdOf(f.executionContextId) === uniqueContextId); + if (!frame) throw new CdpError('Cannot find context with specified id'); + return frame; + } + if (contextId === undefined) return frames.top; + const frame = frames.byContext(contextId); + if (!frame) throw new CdpError('Cannot find context with specified id'); + return frame; + }, + }; +} diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime-frames.conformance.test.ts b/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime-frames.conformance.test.ts new file mode 100644 index 0000000..da39260 --- /dev/null +++ b/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime-frames.conformance.test.ts @@ -0,0 +1,121 @@ +// Same-origin iframes in the Runtime domain: an execution context per frame document, created and +// destroyed as frames come, navigate and go, against Chrome's. +import { after, before, describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { Protocol } from 'devtools-protocol'; +import { diffSubset, launch, openPage, waitForBoth } from '../../../../test-support/conformance.ts'; +import type { Env, Side, TestPage } from '../../../../test-support/conformance.ts'; + +const CHILD = '/packages/devtools-anywhere/test-support/frame-child.html'; +const OTHER = '/packages/devtools-anywhere/test-support/frame-other.html'; + +let env: Env | undefined; +let t: TestPage; + +before(async () => { + env = await launch(); + t = await openPage(env, [ + 'Runtime.executionContextCreated', + 'Runtime.executionContextDestroyed', + 'Runtime.consoleAPICalled', + 'Runtime.exceptionThrown', + ]); + // A srcdoc iframe, there before DevTools connects. + await t.page.evaluate( + () => + new Promise(resolve => { + const frame = Object.assign(document.createElement('iframe'), { id: 'pre', onload: resolve }); + frame.srcdoc = '

pre

'; + document.body.append(frame); + }) + ); + for (const side of [t.ours, t.chrome]) await side.send('Runtime.enable'); +}); +after(() => env?.close()); + +/** + * The default contexts a side announced and destroyed, in order, as frame paths (`top`, `#pre`, + * …) rather than ids: Chrome also has Puppeteer's isolated worlds. + */ +async function lifecycle(side: Side, frameName: (frameId: string) => Promise) { + const created = (await side.events('Runtime.executionContextCreated')).filter(e => e.context.auxData?.['isDefault']); + const ids = new Set(created.map(e => e.context.id)); + const destroyed = (await side.events('Runtime.executionContextDestroyed')).filter(e => ids.has(e.executionContextId)); + return { + created: await Promise.all( + created.map(async e => { + const { id: _, ...context } = e.context; + return { ...context, frame: await frameName(String(context.auxData?.['frameId'])) }; + }) + ), + destroyed: destroyed.length, + }; +} + +/** Names frames by the id of their ` +
far
+ + From ec62c653080b850c5d92fba35d4e03f6e552021d Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 15:55:13 +0800 Subject: [PATCH 019/168] feat(cdp-agent): hit test into same-origin iframes in DOM.getNodeForLocation DOM.getNodeForLocation only looked at the top document, so a point on an iframe found the + ` + ); + const frame = document.getElementById('ii-frame') as HTMLIFrameElement; + frame.onload = () => { + const events: string[] = []; + const record = (win: Window & typeof globalThis, name: string) => { + for (const type of RECORDED) + win.addEventListener( + type, + e => { + const target = e.target; + const id = target && 'id' in target ? String(target.id) : target === win ? 'window' : ''; + // The point, view and realm of the event as the page sees it. + const where = + e instanceof win.MouseEvent + ? `@${Math.round(e.clientX)},${Math.round(e.clientY)}${e.view === win ? '' : '!view'}` + : e instanceof win.Event + ? '' + : '!realm'; + events.push(`${name}:${type}:${id}${where}`); + }, + true + ); + }; + record(window, 'top'); + const inner = frame.contentWindow as (Window & typeof globalThis) | null; + if (inner) record(inner, 'in'); + window.__inputEvents = events; + resolve(); + }; + frame.src = src; + }), + FIXTURE, + RECORDED + ); + await locate(); +}); +after(() => env?.close()); + +/** The iframe's document. */ +declare global { + interface Window { + __inputEvents?: string[]; + } +} + +/** Resets the page, runs the steps through one side and returns what the page saw. */ +async function run(side: Side, steps: (side: Side) => Promise) { + await t.page.evaluate(() => { + const doc = (document.getElementById('ii-frame') as HTMLIFrameElement).contentDocument; + const field = doc?.getElementById('fg-field'); + if (field && 'value' in field) field.value = ''; + doc?.getElementById('fg-scroller')?.scrollTo(0, 0); + doc?.defaultView?.scrollTo(0, 0); + if (doc?.activeElement && 'blur' in doc.activeElement && typeof doc.activeElement.blur === 'function') + doc.activeElement.blur(); + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + if (window.__inputEvents) window.__inputEvents.length = 0; + }); + await steps(side); + // Chrome dispatches some input asynchronously (wheel scrolling): wait until what the page + // saw has not changed for 300 ms, for at most two seconds. + const read = () => + t.page.evaluate(() => { + const doc = (document.getElementById('ii-frame') as HTMLIFrameElement).contentDocument; + const field = doc?.getElementById('fg-field'); + return { + events: window.__inputEvents?.slice() ?? [], + value: field && 'value' in field ? field.value : null, + scrollTop: Math.round(doc?.getElementById('fg-scroller')?.scrollTop ?? 0), + frameScroll: Math.round(doc?.defaultView?.scrollY ?? 0), + focused: `${document.activeElement?.id ?? ''}/${doc?.activeElement?.id ?? ''}`, + }; + }); + const start = Date.now(); + let result = await read(); + for (let stable = 0; stable < 6 && Date.now() - start < 2000;) { + await new Promise(r => setTimeout(r, 50)); + const next = await read(); + stable = JSON.stringify(next) === JSON.stringify(result) ? stable + 1 : 0; + result = next; + } + return result; +} + +async function same( + steps: (side: Side) => Promise, + oursSteps = steps, + only: (event: string) => boolean = () => true +) { + const chrome = await run(t.chrome, steps); + const ours = await run(t.ours, oursSteps); + for (const result of [chrome, ours]) result.events = result.events.filter(only); + assert.deepEqual(ours, chrome); + return ours; +} + +/** The iframe's content box in the top viewport. */ +const IN = { x: 210, y: 60 }; +type Point = { x: number; y: number }; +/** Top viewport points on elements of the iframe's document, with the document not scrolled. */ +let BTN: Point; +let FIELD: Point; +let SCROLLER: Point; +/** Finds the points, once the page is set up. */ +async function locate() { + const centers = await t.page.evaluate( + (ids, IN) => + ids.map(id => { + const frame = document.getElementById('ii-frame') as HTMLIFrameElement; + const r = frame.contentDocument?.getElementById(id)?.getBoundingClientRect(); + return r ? { x: Math.round(IN.x + r.left + r.width / 2), y: Math.round(IN.y + r.top + r.height / 2) } : IN; + }), + ['fg-btn', 'fg-field', 'fg-scroller'], + IN + ); + [BTN = IN, FIELD = IN, SCROLLER = IN] = centers; +} + +const move = (side: Side, { x, y }: Point) => side.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); +const click = async (side: Side, { x, y }: Point) => { + await move(side, { x, y }); + await side.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }); + await side.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }); +}; +const KEY_CODES: Record = { Backspace: 8 }; +const press = async (side: Side, key: string, code: string, text?: string) => { + const windowsVirtualKeyCode = KEY_CODES[key] ?? key.toUpperCase().charCodeAt(0); + await side.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode }); + if (text) await side.send('Input.dispatchKeyEvent', { type: 'char', key, code, text }); + await side.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code }); +}; + +test('moving from the top page into an iframe and clicking there', async () => { + const result = await same(async side => { + await move(side, { x: 50, y: 30 }); + await click(side, BTN); + await move(side, { x: 50, y: 30 }); + }); + assert.ok(result.events.includes(`in:click:fg-btn@${BTN.x - IN.x},${BTN.y - IN.y}`)); +}); + +test('typing into a field of an iframe', async () => { + const result = await same(async side => { + await click(side, FIELD); + await press(side, 'h', 'KeyH', 'h'); + await press(side, 'i', 'KeyI', 'i'); + await press(side, 'Backspace', 'Backspace'); + await side.send('Input.insertText', { text: 'ey' }); + }); + assert.equal(result.value, 'hey'); +}); + +// With touch emulation on, Chrome's Input.dispatchMouseEvent waits for a gesture that never +// completes in headless mode; a real tap (Input.dispatchTouchEvent) is the reference. +test('a tap in an iframe while touch is emulated', async () => { + const result = await same( + async chrome => { + await chrome.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); + await chrome.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [BTN] }); + await chrome.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await chrome.send('Emulation.setTouchEmulationEnabled', { enabled: false }); + }, + async ours => { + await ours.send('Emulation.setEmitTouchEventsForMouse', { enabled: true, configuration: 'mobile' }); + await click(ours, BTN); + await ours.send('Emulation.setEmitTouchEventsForMouse', { enabled: false }); + }, + // Where each side's hover was after the tests before differs: the mouse's over and out are + // not compared. + e => !/:mouse(over|out):/.test(e) + ); + assert.ok(result.events.includes('in:touchstart:fg-btn')); +}); + +// Chrome also moves the hover after scrolling (asynchronously, so this test goes last), which +// is not compared: only the wheel events. +test('the wheel scrolls a scroller of an iframe, then the iframe', async () => { + const result = await same( + async side => { + await side.send('Input.dispatchMouseEvent', { type: 'mouseWheel', ...SCROLLER, deltaX: 0, deltaY: 100 }); + await side.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: IN.x + 300, + y: IN.y + 50, + deltaX: 0, + deltaY: 120, + }); + }, + undefined, + e => e.includes(':wheel:') + ); + assert.equal(result.scrollTop, 100); + assert.equal(result.frameScroll, 120); +}); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/input/input.ts b/packages/devtools-anywhere/src/cdp-agent/domains/input/input.ts index 102f7df..984af38 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/input/input.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/input/input.ts @@ -1,9 +1,14 @@ import type { Protocol } from 'devtools-protocol'; import type { Context } from '../../core/context'; import { closestOwn } from '../../core/nodes'; +import { frameDocumentOf, hitPath, parentFrameDocument, pointInFrame } from '../../core/geometry'; +import { documentOf, isElement, isHtml, isHtmlElement, realmOf } from '../../core/realm'; type MouseEventRequest = Protocol.Input.DispatchMouseEventRequest | Protocol.Input.EmulateTouchFromMouseEventRequest; +/** What receives a mouse event: an element, or a document where no element is hit. */ +type Target = Element | Document; + const BUTTON = { none: 0, left: 0, middle: 1, right: 2, back: 3, forward: 4 } as const; /** `MouseEvent.buttons` bit of each button. */ const BUTTONS = { none: 0, left: 1, middle: 4, right: 2, back: 8, forward: 16 } as const; @@ -16,35 +21,35 @@ const modifierKeys = (modifiers = 0) => ({ shiftKey: (modifiers & 8) !== 0, }); -/** The page's element at a viewport point, under DevTools' own elements. */ -function elementAt(x: number, y: number): Element | null { - for (const el of document.elementsFromPoint(x, y)) if (!closestOwn(el)) return el; - return null; -} - -/** The mouse event target at a viewport point: like Chrome, the document where no page element is hit (outside the viewport). */ -const hit = (x: number, y: number): Element | Document => elementAt(x, y) ?? document; - /** Scrolls the nearest scrollable ancestor that can still move in the direction of the delta. */ -function scrollBy(target: Element | Document, dx: number, dy: number) { - for (let el: Element | null = target instanceof Element ? target : null; el; el = el.parentElement) { - if (el === document.documentElement || el === document.body) break; - const style = getComputedStyle(el); - const canX = /auto|scroll/.test(style.overflowX) && el.scrollWidth > el.clientWidth; - const canY = /auto|scroll/.test(style.overflowY) && el.scrollHeight > el.clientHeight; - const left = el.scrollLeft; - const top = el.scrollTop; - if (canX || canY) el.scrollBy(canX ? dx : 0, canY ? dy : 0); - if (el.scrollLeft !== left || el.scrollTop !== top) return; +function scrollBy(target: Target, dx: number, dy: number) { + // From the target's document out through the iframes holding it, each viewport last. + for (let doc: Document | null = documentOf(target), from = isElement(target) ? target : null; doc;) { + for (let el = from; el; el = el.parentElement) { + if (el === doc.documentElement || el === doc.body) break; + const style = realmOf(el).getComputedStyle(el); + const canX = /auto|scroll/.test(style.overflowX) && el.scrollWidth > el.clientWidth; + const canY = /auto|scroll/.test(style.overflowY) && el.scrollHeight > el.clientHeight; + const left = el.scrollLeft; + const top = el.scrollTop; + if (canX || canY) el.scrollBy(canX ? dx : 0, canY ? dy : 0); + if (el.scrollLeft !== left || el.scrollTop !== top) return; + } + const win = realmOf(doc); + const parent = parentFrameDocument(doc); + const [x, y] = [win.scrollX, win.scrollY]; + win.scrollBy(dx, dy); + if (!parent || win.scrollX !== x || win.scrollY !== y) return; + // Like Chrome, a frame that cannot scroll further passes the scroll on to its parent. + from = win.frameElement; + doc = parent; } - window.scrollBy(dx, dy); } const isEditable = (el: Element | null) => - el instanceof HTMLTextAreaElement || - (el instanceof HTMLInputElement && - !/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/.test(el.type)) || - (el instanceof HTMLElement && el.isContentEditable); + isHtml(el, 'textarea') || + (isHtml(el, 'input') && !/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/.test(el.type)) || + (isHtmlElement(el) && el.isContentEditable); /** * Input events from the frontend (the screencast view), replayed as DOM events. They are @@ -53,26 +58,48 @@ const isEditable = (el: Element | null) => * click and double click on release, scrolling on wheel, text editing on key presses * (through `execCommand`, so it is undoable and fires `input`). Mouse events become touch * events while touch emulation is on (the screencast's "Use touch" toggle). + * + * Like Chrome, input over a same-origin iframe goes to the element under it in the iframe's + * document, at the point in that document's viewport, as events of that document's window; and + * keys and text go to the element focused in the focused frame. */ -export function registerInput({ session }: Context) { +export function registerInput({ session, frames }: Context) { + const top = frames.top; let emitTouch = false; - let hovered: Element | Document | null = null; - let pressed: Element | Document | null = null; + /** What the mouse is over: the element in each document from the top one in (see `hitAt`). */ + let hovered: Target[] = []; + let pressed: Target | null = null; /** Where the current touch started, to tell a tap from a drag. */ let touchStart: { x: number; y: number; moved: boolean; cancelled: boolean } | null = null; /** The current touch started off the page: Chrome then only sends its tap a mouseover. */ let touchOffPage = false; - function mouseInit(p: MouseEventRequest, buttons: number): MouseEventInit { + /** The page's elements at a top viewport point, under DevTools' own, into iframes' documents. */ + const elementsAt = (x: number, y: number): Element[] => + hitPath(top.document, x, y, el => !closestOwn(el)).map(hit => hit.el); + + /** + * What is under a top viewport point, from the top document in: like Chrome, the top document + * where no page element is hit (outside the viewport). + */ + const hitAt = (x: number, y: number): Target[] => { + const path = elementsAt(x, y); + return path.length ? path : [top.document]; + }; + + const innermost = (path: Target[]): Target => path[path.length - 1] ?? top.document; + + function mouseInit(p: MouseEventRequest, buttons: number, target: Target): MouseEventInit { + const at = pointInFrame(documentOf(target), p.x, p.y); return { bubbles: true, cancelable: true, composed: true, - view: window, - clientX: p.x, - clientY: p.y, - screenX: p.x + screenX, - screenY: p.y + screenY, + view: realmOf(target), + clientX: at.x, + clientY: at.y, + screenX: p.x + top.window.screenX, + screenY: p.y + top.window.screenY, button: BUTTON[p.button ?? 'none'], buttons, detail: p.clickCount ?? 0, @@ -80,65 +107,104 @@ export function registerInput({ session }: Context) { }; } + /** A mouse event of the target's own realm. */ + const mouseEvent = (target: Target, type: string, init: MouseEventInit) => + new (realmOf(target).MouseEvent)(type, init); + const pointerEvent = (target: Target, type: string, init: PointerEventInit) => + new (realmOf(target).PointerEvent)(type, init); + + const mousePointer = (init: MouseEventInit): PointerEventInit => ({ + ...init, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + }); + /** Fires the pointer event and its compatibility mouse event; false if either was cancelled. */ - function fire(target: Element | Document, type: 'down' | 'up' | 'move' | 'over' | 'out', init: MouseEventInit) { - const pointer = new PointerEvent(`pointer${type}`, { - ...init, - pointerId: 1, - pointerType: 'mouse', - isPrimary: true, + function fire(target: Target, type: 'down' | 'up' | 'move', init: MouseEventInit) { + return ( + target.dispatchEvent(pointerEvent(target, `pointer${type}`, mousePointer(init))) && + target.dispatchEvent(mouseEvent(target, `mouse${type}`, init)) + ); + } + + /** + * Moves the hover to what is under the point, document by document like Chrome: the documents + * the mouse left get an out (the innermost first), then from the top document in, each + * document whose hovered element changed gets the pointer's out and over, then the mouse's. + * A tap's compatibility mouse events come without pointer events (`pointer` false). + */ + function hover(next: Target[], p: MouseEventRequest, buttons: number, pointer = true) { + const sameDocument = (a: Target | undefined, b: Target | undefined) => + !!a && !!b && documentOf(a) === documentOf(b); + for (let d = hovered.length - 1; d >= 0; d--) { + const left = hovered[d]; + if (!left || sameDocument(left, next[d])) break; + const init = { ...mouseInit(p, buttons, left), relatedTarget: null }; + if (pointer) left.dispatchEvent(pointerEvent(left, 'pointerout', mousePointer(init))); + left.dispatchEvent(mouseEvent(left, 'mouseout', init)); + } + next.forEach((now, d) => { + const before = sameDocument(hovered[d], now) ? hovered[d] : undefined; + if (before === now) return; + const init = mouseInit(p, buttons, now); + if (pointer) { + if (before) + before.dispatchEvent(pointerEvent(before, 'pointerout', mousePointer({ ...init, relatedTarget: now }))); + now.dispatchEvent(pointerEvent(now, 'pointerover', mousePointer({ ...init, relatedTarget: before ?? null }))); + } + if (before) before.dispatchEvent(mouseEvent(before, 'mouseout', { ...init, relatedTarget: now })); + now.dispatchEvent(mouseEvent(now, 'mouseover', { ...init, relatedTarget: before ?? null })); }); - const mouse = new MouseEvent(`mouse${type}`, init); - return target.dispatchEvent(pointer) && target.dispatchEvent(mouse); + hovered = next; } - function wheel(target: Element | Document, p: MouseEventRequest) { + function wheel(target: Target, p: MouseEventRequest) { const dx = p.deltaX ?? 0; const dy = p.deltaY ?? 0; - const event = new WheelEvent('wheel', { ...mouseInit(p, 0), deltaX: dx, deltaY: dy, deltaMode: 0 }); - if (target.dispatchEvent(event)) scrollBy(target, dx, dy); + const init = { ...mouseInit(p, 0, target), deltaX: dx, deltaY: dy, deltaMode: 0 }; + if (target.dispatchEvent(new (realmOf(target).WheelEvent)('wheel', init))) scrollBy(target, dx, dy); } /** The held buttons, which only Input.dispatchMouseEvent reports. */ const buttonsOf = (p: MouseEventRequest) => ('buttons' in p ? p.buttons : undefined); function mouse(p: MouseEventRequest) { - const target = hit(p.x, p.y); + const path = hitAt(p.x, p.y); + const target = innermost(path); const button = p.button ?? 'none'; switch (p.type) { case 'mouseMoved': { - const init = mouseInit(p, buttonsOf(p) ?? (pressed ? BUTTONS.left : 0)); - if (target !== hovered) { - if (hovered) fire(hovered, 'out', { ...init, relatedTarget: target }); - fire(target, 'over', { ...init, relatedTarget: hovered }); - hovered = target; - } - fire(target, 'move', init); + const buttons = buttonsOf(p) ?? (pressed ? BUTTONS.left : 0); + hover(path, p, buttons); + fire(target, 'move', mouseInit(p, buttons, target)); break; } case 'mousePressed': { pressed = target; - if (fire(target, 'down', mouseInit(p, buttonsOf(p) ?? BUTTONS[button]))) focus(target); - if (button === 'right') target.dispatchEvent(new MouseEvent('contextmenu', mouseInit(p, BUTTONS.right))); + if (fire(target, 'down', mouseInit(p, buttonsOf(p) ?? BUTTONS[button], target))) focus(target); + if (button === 'right') + target.dispatchEvent(mouseEvent(target, 'contextmenu', mouseInit(p, BUTTONS.right, target))); break; } case 'mouseReleased': { - const init = mouseInit(p, buttonsOf(p) ?? 0); + const init = mouseInit(p, buttonsOf(p) ?? 0, target); fire(target, 'up', init); const down = pressed; pressed = null; if (button === 'none' || !down) break; - // The click goes to the nearest element that contains both the press and the release; - // other buttons than the left one get an auxclick instead. + // The click goes to the nearest element that contains both the press and the release + // (none when they are in different documents); other buttons than the left one get an + // auxclick instead. let clicked: Node | null = target; while (clicked && !clicked.contains(down)) clicked = clicked.parentNode; if (!clicked) break; if (button !== 'left') { - clicked.dispatchEvent(new MouseEvent('auxclick', init)); + clicked.dispatchEvent(mouseEvent(target, 'auxclick', init)); break; } - clicked.dispatchEvent(new MouseEvent('click', init)); - if (p.clickCount === 2) clicked.dispatchEvent(new MouseEvent('dblclick', init)); + clicked.dispatchEvent(mouseEvent(target, 'click', init)); + if (p.clickCount === 2) clicked.dispatchEvent(mouseEvent(target, 'dblclick', init)); break; } case 'mouseWheel': @@ -153,9 +219,9 @@ export function registerInput({ session }: Context) { // Once touching, the touch stays with the touched element even off the page, so that // the release still ends it. Like Chrome, a touch starting off the page (or on nothing) // goes to the root element. - const held = pressed instanceof Element ? pressed : null; - const found = elementAt(p.x, p.y); - const target = found ?? (p.type === 'mousePressed' ? null : held) ?? document.documentElement; + const held = pressed && isElement(pressed) ? pressed : null; + const found = elementsAt(p.x, p.y).pop() ?? null; + const target = found ?? (p.type === 'mousePressed' ? null : held) ?? top.document.documentElement; if (p.type === 'mouseWheel') return wheel(target, p); const type = p.type === 'mousePressed' ? 'touchstart' : p.type === 'mouseReleased' ? 'touchend' : 'touchmove'; if (type === 'touchstart') { @@ -166,21 +232,21 @@ export function registerInput({ session }: Context) { touchStart.moved = true; } const tapped = type === 'touchend' && touchStart && !touchStart.moved; - const touched = (pressed instanceof Element ? pressed : null) ?? target; + const touched = (pressed && isElement(pressed) ? pressed : null) ?? target; // Like a real touch: the pointer event first, then the touch event. const pointerInit: PointerEventInit = { - ...mouseInit(p, type === 'touchend' ? 0 : BUTTONS.left), + ...mouseInit(p, type === 'touchend' ? 0 : BUTTONS.left, touched), pointerId: 2, pointerType: 'touch', isPrimary: true, }; - if (type === 'touchstart') touched.dispatchEvent(new PointerEvent('pointerover', pointerInit)); + if (type === 'touchstart') touched.dispatchEvent(pointerEvent(touched, 'pointerover', pointerInit)); const pointerType = type === 'touchstart' ? 'pointerdown' : type === 'touchend' ? 'pointerup' : 'pointermove'; // Once scrolling has taken the touch over, the pointer is gone: only touch events follow. const cancelled = touchStart?.cancelled ?? false; - let proceed = cancelled || touched.dispatchEvent(new PointerEvent(pointerType, pointerInit)); + let proceed = cancelled || touched.dispatchEvent(pointerEvent(touched, pointerType, pointerInit)); // Like Chrome, the lifted pointer leaves before the touchend. - if (type === 'touchend' && !cancelled) touched.dispatchEvent(new PointerEvent('pointerout', pointerInit)); + if (type === 'touchend' && !cancelled) touched.dispatchEvent(pointerEvent(touched, 'pointerout', pointerInit)); // Touch and TouchEvent cannot be constructed in every browser: missing in desktop Safari, // present but throwing "Illegal constructor" in iOS Safari and some WebViews. Only the // pointer events are sent there. @@ -190,8 +256,8 @@ export function registerInput({ session }: Context) { // Like Chrome, the touchmove that starts the scroll is followed by the pointer's cancel. if (!touchStart.cancelled) { touchStart.cancelled = true; - touched.dispatchEvent(new PointerEvent('pointercancel', pointerInit)); - touched.dispatchEvent(new PointerEvent('pointerout', pointerInit)); + touched.dispatchEvent(pointerEvent(touched, 'pointercancel', pointerInit)); + touched.dispatchEvent(pointerEvent(touched, 'pointerout', pointerInit)); } // Dragging scrolls, unless the page cancelled the touch. scrollBy(target, touchStart.x - p.x, touchStart.y - p.y); @@ -204,38 +270,37 @@ export function registerInput({ session }: Context) { // A tap is followed by the compatibility mouse events (without pointer events) and a // click, unless cancelled. if (tapped && proceed) { - const init = mouseInit({ ...p, button: 'left', clickCount: 1 }, 0); - if (target !== hovered) { - target.dispatchEvent(new MouseEvent('mouseover', { ...init, relatedTarget: hovered })); - hovered = target; - } + const init = mouseInit({ ...p, button: 'left', clickCount: 1 }, 0, target); + hover(found ? hitAt(p.x, p.y) : [target], p, 0, false); if (touchOffPage) return; - if (target.dispatchEvent(new MouseEvent('mousedown', { ...init, buttons: BUTTONS.left }))) focus(target); - target.dispatchEvent(new MouseEvent('mouseup', init)); - target.dispatchEvent(new MouseEvent('click', init)); + if (target.dispatchEvent(mouseEvent(target, 'mousedown', { ...init, buttons: BUTTONS.left }))) focus(target); + target.dispatchEvent(mouseEvent(target, 'mouseup', init)); + target.dispatchEvent(mouseEvent(target, 'click', init)); } } } function touchEvent(type: 'touchstart' | 'touchmove' | 'touchend', touched: Element, p: MouseEventRequest) { - if (typeof Touch !== 'function' || typeof TouchEvent !== 'function') return null; + const win = realmOf(touched); + if (typeof win.Touch !== 'function' || typeof win.TouchEvent !== 'function') return null; + const at = pointInFrame(touched.ownerDocument, p.x, p.y); try { - const point = new Touch({ + const point = new win.Touch({ identifier: 1, target: touched, - clientX: p.x, - clientY: p.y, - pageX: p.x + scrollX, - pageY: p.y + scrollY, - screenX: p.x + screenX, - screenY: p.y + screenY, + clientX: at.x, + clientY: at.y, + pageX: at.x + win.scrollX, + pageY: at.y + win.scrollY, + screenX: p.x + top.window.screenX, + screenY: p.y + top.window.screenY, }); const touches = type === 'touchend' ? [] : [point]; - return new TouchEvent(type, { + return new win.TouchEvent(type, { bubbles: true, cancelable: true, composed: true, - view: window, + view: win, touches, targetTouches: touches, changedTouches: [point], @@ -246,33 +311,50 @@ export function registerInput({ session }: Context) { } } - function focus(target: Element | Document) { + function focus(target: Target) { const focusable = - target instanceof Element && + isElement(target) && target.closest( 'a[href], button, input, select, textarea, summary, [tabindex], [contenteditable=""], [contenteditable="true"]' ); if (focusable) focusable.focus(); - else if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + else { + const active = documentOf(target).activeElement; + if (isHtmlElement(active)) active.blur(); + } + } + + /** + * The element keys and text go to: the focused element of the top document, or, when that is + * an iframe, the one focused in its document, and so on; the body where nothing is focused. + */ + function focused(): Element | null { + for (let doc = top.document; ;) { + const active = doc.activeElement ?? doc.body; + const inner = active && frameDocumentOf(active); + if (!inner) return active; + doc = inner; + } } /** The default action of an editing key, as the browser would carry it out on keydown. */ function editingKey(target: Element, key: string) { if (!isEditable(target)) return; - if (key === 'Backspace') document.execCommand('delete'); - else if (key === 'Delete') document.execCommand('forwardDelete'); + if (key === 'Backspace') target.ownerDocument.execCommand('delete'); + else if (key === 'Delete') target.ownerDocument.execCommand('forwardDelete'); } function insertText(text: string) { - const target = document.activeElement; - if (!text || !isEditable(target)) return; + const target = focused(); + if (!text || !target || !isEditable(target)) return; + const doc = target.ownerDocument; // Enter's text: submits a field's form, breaks the line elsewhere. if (text === '\r') { - if (target instanceof HTMLInputElement) target.form?.requestSubmit(); - else document.execCommand('insertLineBreak'); + if (isHtml(target, 'input')) target.form?.requestSubmit(); + else doc.execCommand('insertLineBreak'); return; } - document.execCommand('insertText', false, text); + doc.execCommand('insertText', false, text); } session.register('Input', { @@ -283,12 +365,14 @@ export function registerInput({ session }: Context) { emulateTouchFromMouseEvent: (p: Protocol.Input.EmulateTouchFromMouseEventRequest) => touch(p), dispatchKeyEvent(p: Protocol.Input.DispatchKeyEventRequest) { - const target = document.activeElement ?? document.body; + const target = focused(); + if (!target) return; + const win = realmOf(target); const init: KeyboardEventInit = { bubbles: true, cancelable: true, composed: true, - view: window, + view: win, key: p.key ?? '', code: p.code ?? '', location: p.location ?? 0, @@ -298,22 +382,22 @@ export function registerInput({ session }: Context) { switch (p.type) { case 'keyDown': case 'rawKeyDown': - if (target.dispatchEvent(new KeyboardEvent('keydown', init))) { + if (target.dispatchEvent(new win.KeyboardEvent('keydown', init))) { editingKey(target, p.key ?? ''); // keyDown may carry the text of the key itself (rawKeyDown never does): it is // typed like a char event, keypress first. if (p.type === 'keyDown' && p.text) { const charCode = p.text.charCodeAt(0); - const keypress = new KeyboardEvent('keypress', { ...init, charCode, keyCode: charCode }); + const keypress = new win.KeyboardEvent('keypress', { ...init, charCode, keyCode: charCode }); if (target.dispatchEvent(keypress)) insertText(p.text); } } break; case 'char': - if (target.dispatchEvent(new KeyboardEvent('keypress', init))) insertText(p.text ?? ''); + if (target.dispatchEvent(new win.KeyboardEvent('keypress', init))) insertText(p.text ?? ''); break; case 'keyUp': - target.dispatchEvent(new KeyboardEvent('keyup', init)); + target.dispatchEvent(new win.KeyboardEvent('keyup', init)); break; } }, From 4ae864cfe57f9d9b36322b98fee826fbb784eec7 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 16:03:54 +0800 Subject: [PATCH 028/168] feat(cdp-agent): force pseudo-classes on iframe elements CSS.forcePseudoState copies the rules using a forced pseudo-class into a constructed companion sheet adopted by each tree. For the tree of an iframe that sheet has to come from the iframe's realm (a tree only adopts sheets of its own realm), and its adoptedStyleSheets accessors are those of the iframe's Document/ShadowRoot prototypes. Both now come from the tree's window. The media queries the companion depends on (whether a rule's @media holds, and the queries watched for layer order changes) are evaluated in the window of the tree being built and keyed by that window, so a rule in an @media holding only in a narrow iframe is replayed there. Like Chrome, which forgets a removed node's forced states, elements of a document left without a window (its iframe removed, or navigated away) are let go: removing the iframe does not disconnect them, so being connected is not enough. Tested against Chrome (each side forcing in turn): :hover and :focus on an iframe element, with a :focus rule inside a narrow-only @media of the iframe, matched rules and computed styles while forced and after. --- .../css/css-iframe.conformance.test.ts | 65 +++++++++++++++++ .../cdp-agent/domains/css/forced-pseudo.ts | 69 +++++++++++++------ scripts/check-realms.json | 1 - 3 files changed, 114 insertions(+), 21 deletions(-) diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/css/css-iframe.conformance.test.ts b/packages/devtools-anywhere/src/cdp-agent/domains/css/css-iframe.conformance.test.ts index b4ec01a..3555e48 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/css/css-iframe.conformance.test.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/css/css-iframe.conformance.test.ts @@ -334,3 +334,68 @@ describe('editing styles in iframes', () => { assert.equal(await innerStyle('font-weight'), '300'); }); }); + +// Chrome and we both act on the same page: each side forces, measures and un-forces in turn. +describe('forced pseudo-classes in iframes', () => { + before(() => + t.page.evaluate(() => { + const doc = document.querySelector('#styled')?.contentDocument; + if (!doc) throw new Error('no iframe'); + const style = doc.createElement('style'); + // Holds in the narrow iframe only. + style.textContent = '@media (max-width: 400px) { #inner:focus { outline-color: rgb(7, 8, 9) } }'; + doc.head.append(style); + }) + ); + + async function forced(side: Side, classes: string[]) { + const id = await nodeId(side, 'inner'); + await side.send('CSS.forcePseudoState', { nodeId: id, forcedPseudoClasses: classes }); + try { + const { matchedCSSRules } = await side.send('CSS.getMatchedStylesForNode', { nodeId: id }); + return { + selectors: rules(matchedCSSRules).map(r => r.selector), + decoration: await innerStyle('text-decoration-line'), + outline: await innerStyle('outline-color'), + }; + } finally { + await side.send('CSS.forcePseudoState', { nodeId: id, forcedPseudoClasses: [] }); + } + } + + test('forcePseudoState applies the rules of the iframe, in its viewport', async () => { + const ours = await forced(t.ours, ['hover', 'focus']); + const chrome = await forced(t.chrome, ['hover', 'focus']); + assert.deepEqual(ours, chrome); + assert.equal(chrome.decoration, 'underline'); + assert.equal(chrome.outline, 'rgb(7, 8, 9)'); + }); + + test('un-forcing takes the states back', async () => { + const ours = await forced(t.ours, []); + const chrome = await forced(t.chrome, []); + assert.deepEqual(ours, chrome); + assert.equal(chrome.decoration, 'none'); + }); + + test('removing the iframe un-forces its elements', async () => { + const id = await nodeId(t.ours, 'inner'); + await t.ours.send('CSS.forcePseudoState', { nodeId: id, forcedPseudoClasses: ['hover'] }); + assert.equal(await innerStyle('text-decoration-line'), 'underline'); + await t.page.evaluate(() => { + const frame = document.querySelector('#styled'); + Object.assign(window, { detachedInner: frame?.contentDocument?.querySelector('#inner') }); + frame?.remove(); + }); + // The forced element is let go once anything is forced again, as Chrome forgets removed nodes. + const { root } = await t.ours.send('DOM.getDocument', { depth: -1 }); + const app = find(root, hasId('app')); + assert.ok(app); + await t.ours.send('CSS.forcePseudoState', { nodeId: app.nodeId, forcedPseudoClasses: ['hover'] }); + await t.ours.send('CSS.forcePseudoState', { nodeId: app.nodeId, forcedPseudoClasses: [] }); + const marker = await t.page.evaluate(() => + (Reflect.get(window, 'detachedInner') as Element | undefined)?.getAttribute('data-devtools-anywhere-force') + ); + assert.equal(marker, null); + }); +}); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/css/forced-pseudo.ts b/packages/devtools-anywhere/src/cdp-agent/domains/css/forced-pseudo.ts index 04679a7..9ced88f 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/css/forced-pseudo.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/css/forced-pseudo.ts @@ -1,5 +1,6 @@ import { quietly } from '../../core/adopted'; -import { isCssom } from '../../core/realm'; +import { frames } from '../../core/frames'; +import { isCssom, isShadowRoot, realmOf } from '../../core/realm'; import { FORCE_ATTRIBUTE } from '../../core/nodes'; /** A document or shadow root: what style sheets apply to. */ @@ -27,7 +28,7 @@ const FORCEABLE = new RegExp(`:(${FORCEABLE_CLASSES.join('|')})(?![\\w-])`, 'g') /** Whether `el` is `node` or inside it, in its shadow trees too. */ function containsAcrossShadow(node: Node, el: Element): boolean { - for (let n: Node | null = el; n; n = n instanceof ShadowRoot ? n.host : n.parentNode) { + for (let n: Node | null = el; n; n = isShadowRoot(n) ? n.host : n.parentNode) { if (n === node) return true; } return false; @@ -59,8 +60,16 @@ class SharedForcedPseudoClasses { private sheetsOfCompanions = new WeakSet(); /** Media queries the companions depend on (for the layer order), and their change listeners. */ private queries = new Map void }>(); - private queried = new Set(); + /** By key (see mediaKey), with the window and the text of the query. */ + private queried = new Map(); private mediaLists = new Map(); + /** + * The window of the tree whose companion is being built: its media queries hold in its own + * viewport, an iframe's in the iframe's. + */ + private realm: Window | null = null; + private realmKeys = new WeakMap(); + private realmSeq = 0; /** Media queries the last rebuild looked at: the others are let go. */ private usedMedia = new Set(); /** `forced` and `active()` as of the last change of `owners`: a rebuild reads them for every rule. */ @@ -189,7 +198,8 @@ class SharedForcedPseudoClasses { private prune(): boolean { let pruned = false; for (const el of this.forced.keys()) { - if (el.isConnected) continue; + // An element of a document without a window (its iframe removed or navigated) is gone too. + if (el.isConnected && el.ownerDocument.defaultView) continue; this.forget(el); pruned = true; } @@ -229,12 +239,16 @@ class SharedForcedPseudoClasses { this.usedMedia.clear(); if (this.owners.size) { for (const { tree, sheets } of this.trees()) { + const realm = realmOf(tree); + this.realm = realm; const text = this.companionText(sheets); + this.realm = null; if (!text) continue; seen.add(tree); let companion = this.companions.get(tree); if (!companion) { - companion = { sheet: new CSSStyleSheet(), restore: null }; + // Only the realm's own constructed sheets can be adopted by its trees. + companion = { sheet: new realm.CSSStyleSheet(), restore: null }; this.sheetsOfCompanions.add(companion.sheet); this.companions.set(tree, companion); } @@ -253,30 +267,43 @@ class SharedForcedPseudoClasses { } } - private mediaList(text: string): MediaQueryList { - this.usedMedia.add(text); - let list = this.mediaLists.get(text); + /** The window media queries are evaluated in now: that of the tree being built, else the top one. */ + private currentRealm(): Window { + return this.realm ?? frames.top.window; + } + + /** A media query in a window, as a key of mediaLists, queried and queries. */ + private mediaKey(realm: Window, text: string): string { + let key = this.realmKeys.get(realm); + if (key === undefined) this.realmKeys.set(realm, (key = ++this.realmSeq)); + return `${key} ${text}`; + } + + private mediaList(text: string, realm = this.currentRealm()): MediaQueryList { + const key = this.mediaKey(realm, text); + this.usedMedia.add(key); + let list = this.mediaLists.get(key); if (!list) { - list = matchMedia(text); - this.mediaLists.set(text, list); + list = realm.matchMedia(text); + this.mediaLists.set(key, list); } return list; } /** Rebuilds when a media query the layer order depended on changes; only while something is forced. */ private watch() { - for (const [text, { list, listener }] of this.queries) { - if (this.queried.has(text)) continue; + for (const [key, { list, listener }] of this.queries) { + if (this.queried.has(key)) continue; list.removeEventListener('change', listener); - this.queries.delete(text); - this.mediaLists.delete(text); + this.queries.delete(key); + this.mediaLists.delete(key); } - for (const text of this.queried) { - if (this.queries.has(text)) continue; - const list = this.mediaList(text); + for (const [key, { realm, text }] of this.queried) { + if (this.queries.has(key)) continue; + const list = this.mediaList(text, realm); const listener = () => this.rebuild(); list.addEventListener('change', listener); - this.queries.set(text, { list, listener }); + this.queries.set(key, { list, listener }); } } @@ -295,7 +322,8 @@ class SharedForcedPseudoClasses { */ private attach(tree: TreeScope, companion: Companion) { const sheet = companion.sheet; - const proto = tree instanceof ShadowRoot ? ShadowRoot.prototype : Document.prototype; + const realm = realmOf(tree); + const proto = isShadowRoot(tree) ? realm.ShadowRoot.prototype : realm.Document.prototype; const descriptor = Object.getOwnPropertyDescriptor(proto, 'adoptedStyleSheets'); const get = descriptor?.get; const set = descriptor?.set; @@ -408,7 +436,8 @@ class SharedForcedPseudoClasses { * `conditions`, the preludes since `parent`) into it and each layer on the way. */ const enter = (rule: CSSRule, parent: Layer, name: string, conditions: string[], live: boolean) => { - for (const text of open) this.queried.add(text); + const realm = this.currentRealm(); + for (const text of open) this.queried.set(this.mediaKey(realm, text), { realm, text }); const names = name ? name.split('.') : ['']; const layers: Layer[] = []; let at = parent; diff --git a/scripts/check-realms.json b/scripts/check-realms.json index 6ed2013..d1ed059 100644 --- a/scripts/check-realms.json +++ b/scripts/check-realms.json @@ -1,7 +1,6 @@ { "core/geometry.ts": 36, "core/history.ts": 1, - "domains/css/forced-pseudo.ts": 2, "domains/css/sheet-registry.ts": 2, "domains/css/style.ts": 1, "domains/debugger/debugger.ts": 1, From b5e71086aa25d1117a28176e8adefcc691a13407 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 16:04:27 +0800 Subject: [PATCH 029/168] chore(cdp-agent): lower the realm use counts after the iframe geometry work core/geometry.ts and domains/input/input.ts no longer use instanceof, the global document/window or getComputedStyle (36 and 28 uses before); domains/overlay/overlay.ts is down from 16 to 8, the rest being the overlay layer, which belongs to the top document on purpose. Written by node scripts/check-realms.ts --update. --- scripts/check-realms.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/check-realms.json b/scripts/check-realms.json index 8206368..5001e38 100644 --- a/scripts/check-realms.json +++ b/scripts/check-realms.json @@ -1,7 +1,6 @@ { "core/adopted.ts": 1, "core/container-query.ts": 6, - "core/geometry.ts": 36, "core/history.ts": 1, "domains/css/cascade.ts": 7, "domains/css/color.ts": 1, @@ -12,9 +11,8 @@ "domains/css/style.ts": 1, "domains/debugger/debugger.ts": 1, "domains/dom-debugger/dom-debugger.ts": 5, - "domains/input/input.ts": 28, "domains/network/network.ts": 3, - "domains/overlay/overlay.ts": 16, + "domains/overlay/overlay.ts": 8, "domains/page/page.ts": 3, "domains/page/render.ts": 4, "domains/page/screencast.ts": 1, From 763c45a944ac11e7cec33e6c1176e653614766c2 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 16:05:55 +0800 Subject: [PATCH 030/168] refactor(cdp-agent): type the sheet registry's fetch without the global window SheetRegistry typed its fetch as `typeof window.fetch`, two of the remaining uses of the top page's realm that scripts/check-realms.ts counts in the CSS domain. `typeof globalThis.fetch` is the same type. The counts are lowered accordingly; the one left in the CSS domain (style.ts parses declarations with a top-page
's style, which does not depend on the realm) stays on purpose. --- .../src/cdp-agent/domains/css/sheet-registry.ts | 4 ++-- scripts/check-realms.json | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/css/sheet-registry.ts b/packages/devtools-anywhere/src/cdp-agent/domains/css/sheet-registry.ts index 6be794a..53f77c0 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/css/sheet-registry.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/css/sheet-registry.ts @@ -30,10 +30,10 @@ export class SheetRegistry { /** Whether a + + +

child page

+ + + + + diff --git a/packages/devtools-anywhere/test-support/oopif-grandchild.html b/packages/devtools-anywhere/test-support/oopif-grandchild.html new file mode 100644 index 0000000..97b9b11 --- /dev/null +++ b/packages/devtools-anywhere/test-support/oopif-grandchild.html @@ -0,0 +1,12 @@ + + + + oopif grandchild + + +

grandchild page

+ + + diff --git a/packages/devtools-anywhere/test-support/oopif-parent.html b/packages/devtools-anywhere/test-support/oopif-parent.html new file mode 100644 index 0000000..344b9d7 --- /dev/null +++ b/packages/devtools-anywhere/test-support/oopif-parent.html @@ -0,0 +1,17 @@ + + + + oopif parent + + +

parent page

+ + + + + diff --git a/packages/devtools-anywhere/test-support/oopif.ts b/packages/devtools-anywhere/test-support/oopif.ts new file mode 100644 index 0000000..06a799f --- /dev/null +++ b/packages/devtools-anywhere/test-support/oopif.ts @@ -0,0 +1,206 @@ +// Real Chrome with cross-site iframes out of process (OOPIFs), driven over a raw browser-level +// CDP connection, so every message is seen with its sessionId as a client such as the DevTools +// frontend sees it. Used to record what Chrome does for cross-origin iframes, as the reference +// for our agent's child targets. +import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'; +import { launch, type Env } from '../../../tests/support/browser.ts'; + +type Commands = ProtocolMapping.Commands; +/** A command's params; `{}` for the commands that take none. */ +type Params = Commands[M]['paramsType'] extends [] + ? object + : Exclude; + +/** The third site: a name the test browser resolves to 127.0.0.1. */ +export const THIRD_HOST = 'oopif.test'; + +/** + * Launches Chrome with every site in its own process, so a cross-site iframe becomes an OOPIF + * and a child target. The pages use three sites on the one server: localhost (the parent), + * 127.0.0.1 (the child) and oopif.test (the grandchild). + */ +export function launchOopif(): Promise { + return launch(['--site-per-process', `--host-resolver-rules=MAP ${THIRD_HOST} 127.0.0.1`]); +} + +export const FIXTURES = '/packages/devtools-anywhere/test-support/'; + +/** The URL of a fixture on another host of the same server: 'localhost', '127.0.0.1' or THIRD_HOST. */ +export function fixtureUrl(env: Env, file: string, host = 'localhost'): string { + const url = new URL(FIXTURES + file, env.baseUrl); + url.hostname = host; + return url.href; +} + +/** One message from Chrome, with the session it came on ('' for the browser's own). */ +export interface Recorded { + sessionId: string; + method: string; + params: Record; +} + +interface Reply { + id: number; + result?: unknown; + error?: { message: string }; +} + +/** A raw CDP connection to the browser: commands on any session, and a log of all events. */ +export class RawCdp { + readonly log: Recorded[] = []; + #ws: WebSocket; + #nextId = 1; + #pending = new Map void>(); + + private constructor(ws: WebSocket) { + this.#ws = ws; + ws.addEventListener('message', e => { + const msg: { id?: number; method?: string; params?: Record; sessionId?: string } = JSON.parse( + String(e.data) + ); + if (msg.id !== undefined) { + this.#pending.get(msg.id)?.(msg as Reply); + this.#pending.delete(msg.id); + } else if (msg.method) { + this.log.push({ sessionId: msg.sessionId ?? '', method: msg.method, params: msg.params ?? {} }); + } + }); + } + + static async connect(env: Env): Promise { + const ws = new WebSocket(env.browser.wsEndpoint()); + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }); + ws.addEventListener('error', reject, { once: true }); + }); + return new RawCdp(ws); + } + + send(method: M, params?: Params, sessionId = ''): Promise { + const id = this.#nextId++; + this.#ws.send(JSON.stringify({ id, method, params: params ?? {}, ...(sessionId ? { sessionId } : {}) })); + return new Promise((resolve, reject) => { + this.#pending.set(id, reply => { + if (reply.error) reject(new Error(`${method}: ${reply.error.message}`)); + // The reply is what the protocol says this method returns. + else resolve(reply.result as Commands[M]['returnType']); + }); + }); + } + + /** Events logged so far that match. */ + events(method: string, sessionId?: string): Recorded[] { + return this.log.filter(e => e.method === method && (sessionId === undefined || e.sessionId === sessionId)); + } + + /** Waits for the `count`th event that matches, counting those already logged. */ + async waitFor(match: (e: Recorded) => boolean, count = 1, timeout = 5000): Promise { + const start = Date.now(); + for (;;) { + const found = this.log.filter(match); + const hit = found[count - 1]; + if (hit) return hit; + if (Date.now() - start > timeout) throw new Error(`timed out waiting for event #${count}`); + await new Promise(r => setTimeout(r, 20)); + } + } + + /** Waits until no event has arrived for `quiet` ms. */ + async settle(quiet = 300): Promise { + let seen = -1; + while (seen !== this.log.length) { + seen = this.log.length; + await new Promise(r => setTimeout(r, quiet)); + } + } + + close(): void { + this.#ws.close(); + } +} + +/** What the DevTools frontend enables on every frame target (a subset that matters here). */ +export const FRONTEND_ENABLES = ['Page.enable', 'Runtime.enable', 'DOM.enable', 'Network.enable'] as const; + +/** + * A page target attached with flatten, as the frontend's root target. Each session it learns + * of through auto-attach is set up as the frontend sets up a child target: the domains enabled, + * auto-attach on it too (for grandchildren), then resumed. + */ +export class OopifPage { + readonly cdp: RawCdp; + readonly targetId: string; + readonly sessionId: string; + /** Child sessions, in the order they were attached. */ + readonly children: { sessionId: string; parent: string; targetId: string }[] = []; + + private constructor(cdp: RawCdp, targetId: string, sessionId: string) { + this.cdp = cdp; + this.targetId = targetId; + this.sessionId = sessionId; + } + + static async open(env: Env, url: string, { autoAttach = true } = {}): Promise { + const cdp = await RawCdp.connect(env); + const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }); + const page = new OopifPage(cdp, targetId, sessionId); + await page.setUp(sessionId, autoAttach); + await cdp.send('Page.navigate', { url }, sessionId); + await cdp.waitFor(e => e.method === 'Page.loadEventFired' && e.sessionId === sessionId); + return page; + } + + async setUp(sessionId: string, autoAttach: boolean): Promise { + for (const method of FRONTEND_ENABLES) await this.cdp.send(method, {}, sessionId); + if (!autoAttach) return; + // Children are set up as they come, before they are resumed, as the frontend does. + const onAttach = async (e: Recorded) => { + const childSession = String(e.params['sessionId']); + const info = e.params['targetInfo'] as { targetId: string }; + this.children.push({ sessionId: childSession, parent: e.sessionId, targetId: info.targetId }); + await this.setUp(childSession, true); + if (e.params['waitingForDebugger']) await this.cdp.send('Runtime.runIfWaitingForDebugger', {}, childSession); + }; + let handled = this.cdp.log.length; + const poll = setInterval(() => { + for (; handled < this.cdp.log.length; handled++) { + const e = this.cdp.log[handled]; + if (e?.method === 'Target.attachedToTarget' && e.sessionId === sessionId) void onAttach(e).catch(() => {}); + } + }, 5); + this.#polls.push(poll); + await this.cdp.send( + 'Target.setAutoAttach', + { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, + sessionId + ); + } + + #polls: ReturnType[] = []; + + /** + * The latest child session whose target's URL contains `part`. A child target is attached + * with an empty URL, which targetInfoChanged fills in once it commits. + */ + async child(part: string, timeout = 5000): Promise { + const start = Date.now(); + for (;;) { + const info = this.cdp.log.findLast( + e => + (e.method === 'Target.attachedToTarget' || e.method === 'Target.targetInfoChanged') && + String((e.params['targetInfo'] as { url: string }).url).includes(part) + ); + const targetId = (info?.params['targetInfo'] as { targetId: string } | undefined)?.targetId; + const found = this.children.findLast(c => c.targetId === targetId); + if (found) return found.sessionId; + if (Date.now() - start > timeout) throw new Error(`no child target for ${part}`); + await new Promise(r => setTimeout(r, 20)); + } + } + + close(): void { + for (const poll of this.#polls) clearInterval(poll); + this.cdp.close(); + } +} From 67b82a72bf5d21b53ac8a95d0c4fd964e7acebaa Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 20:11:50 +0800 Subject: [PATCH 046/168] test(cdp-agent): record Chrome's event sequences for cross-site iframes Adds the Chrome side of the conformance checks for cross-origin iframes as child targets, for our agent to be compared with once it has them: - compact() rewrites a raw log into steps that compare equal across runs: sessions named root, s1, s2... in attach order, target, frame and loader ids named id1, id2... in order of appearance (the page's own is "page"), ports dropped from URLs, volatile keys removed. - chromeSequence() opens the parent with its child and grandchild, waits for them to settle, runs an action and returns Chrome's lifecycle events (Target, frame, execution context, documentUpdated) for the setup and for the action. - SCENARIOS: the child reloads, goes to a page of its own origin, is sent to a third site, is sent back to the parent's site, is removed. oopif.conformance.test.ts pins what the agent will have to match: the iframe target's id is its frame id and it is attached paused with no URL yet, grandchildren are attached on the child's session, the parent keeps the + + + diff --git a/tests/globals.d.ts b/tests/globals.d.ts index ed79b06..ef7b069 100644 --- a/tests/globals.d.ts +++ b/tests/globals.d.ts @@ -64,5 +64,7 @@ declare global { __clicks?: number; __devtools?: DevtoolsAnywhereElement; resourceLoaded?: boolean; + /** The links frame-links.test.ts has seen, and how many of them have closed. */ + __links?: { frameId: string; closed: boolean }[]; } } From d111e73122ee16796ae53904b782fbc69bd142cd Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 20:36:48 +0800 Subject: [PATCH 054/168] feat(devtools-anywhere): close cross-origin iframe links, and link reloaded iframes again A MessagePort has no close event, so each end tells the other: the iframe's page closes its link on pagehide (navigating away, reloading, being removed), which posts a bye on the port, and either end's close() does the same. The parent does not rely on the bye alone: it watches the iframe element (cdp-agent/frame-owners.ts, watchOwner) and closes the link when the element is removed, along with a shadow host or same-origin frame that holds it, or moved, which gives it another window. The parent closes all its links on its own pagehide. A reloaded iframe says hello again with a new id and is linked again; childTargets.frameIdOf keeps its frameId, as Chrome keeps an iframe target across navigations. Back from the back/forward cache, the iframe says hello with a new id and the parent pings its iframes. Tests: a reload links again with the same frameId and closes the old link; removing the iframe closes its link even with the page's bye suppressed; the parent's close() unlinks the iframe and the iframe's close() closes the parent's link. --- .../src/frame-links/child.ts | 14 +++- .../src/frame-links/frame-links.test.ts | 71 +++++++++++++++++++ .../src/frame-links/parent.ts | 16 ++++- 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/packages/devtools-anywhere/src/frame-links/child.ts b/packages/devtools-anywhere/src/frame-links/child.ts index db60a03..2623eb6 100644 --- a/packages/devtools-anywhere/src/frame-links/child.ts +++ b/packages/devtools-anywhere/src/frame-links/child.ts @@ -17,7 +17,7 @@ const newId = () => Math.random().toString(36).slice(2); */ export function linkParent(patterns: readonly OriginPattern[], createSession: () => CdpSession): () => void { if (window.parent === window) return () => {}; - const id = newId(); + let id = newId(); let link: ParentLink | undefined; let session: CdpSession | undefined; @@ -62,10 +62,22 @@ export function linkParent(patterns: readonly OriginPattern[], createSession: () connect(port, welcome.frameId, welcome.parentFrameId); }; + // Leaving, the page ends its link: a port has no close event. Back from the back/forward cache, + // it is another document to the parent, which links it again. + const onPagehide = () => link?.close(); + const onPageshow = (event: PageTransitionEvent) => { + if (!event.persisted) return; + id = newId(); + hello(); + }; addEventListener('message', onMessage); + addEventListener('pagehide', onPagehide); + addEventListener('pageshow', onPageshow); hello(); return () => { removeEventListener('message', onMessage); + removeEventListener('pagehide', onPagehide); + removeEventListener('pageshow', onPageshow); link?.close(); }; } diff --git a/packages/devtools-anywhere/src/frame-links/frame-links.test.ts b/packages/devtools-anywhere/src/frame-links/frame-links.test.ts index c7c2a96..8abb4dc 100644 --- a/packages/devtools-anywhere/src/frame-links/frame-links.test.ts +++ b/packages/devtools-anywhere/src/frame-links/frame-links.test.ts @@ -52,6 +52,27 @@ async function childFrame(page: Page): Promise { return frame; } +/** Reloads the child page and returns its frame once the new document has its bundle. */ +async function reloadChild(page: Page, child: Frame): Promise { + // Marks the old document, and reloads once the evaluation has returned. + await child.evaluate(() => { + window.__pageScriptRan = true; + setTimeout(() => location.reload()); + }); + const start = Date.now(); + for (;;) { + for (const frame of page.frames()) { + if (!frame.url().split('?')[0]?.endsWith('frame-link-child.html')) continue; + const fresh = await frame + .evaluate(() => !window.__pageScriptRan && 'DevtoolsAnywhere' in window) + .catch(() => false); + if (fresh) return frame; + } + if (Date.now() - start > 5000) throw new Error('the child did not reload'); + await new Promise(resolve => setTimeout(resolve, 50)); + } +} + /** Has the parent record each link it gets in `__links`, and whether it has closed. */ async function recordLinks(page: Page): Promise { await page.evaluate(() => { @@ -204,3 +225,53 @@ test('a hello from a window that is not an iframe of the page, or made up by a s await page.close(); } }); + +test('the iframe reloaded links again, with the same frame id; the old link closes', async () => { + const { page, child } = await open(originOf('127.0.0.1'), originOf('localhost')); + try { + await waitForLink(page); + await reloadChild(page, child); + await waitForLink(page, 2); + const [first, second] = await links(page); + assert.equal(first?.closed, true); + assert.equal(second?.closed, false); + assert.equal(second?.frameId, first?.frameId); + const result = await command(page, 'Runtime.evaluate', { expression: 'window.marker' }); + assert.deepEqual(result, { result: { type: 'string', value: `child of ${originOf('127.0.0.1')}` } }); + } finally { + await page.close(); + } +}); + +test('removing the iframe closes its link, bye or not', async () => { + const { page, child } = await open(originOf('127.0.0.1'), originOf('localhost')); + try { + await waitForLink(page); + // The page's bye on pagehide is not what the parent goes by. + await child.evaluate(() => (MessagePort.prototype.postMessage = () => {})); + await page.evaluate(() => document.getElementById('child')?.remove()); + await page.waitForFunction(() => window.__links?.[0]?.closed === true, { timeout: 5000, polling: 50 }); + assert.equal(await page.evaluate(() => [...window.DevtoolsAnywhere.childTargets.children()].length), 0); + } finally { + await page.close(); + } +}); + +test('either end closing the link closes the other', async () => { + const { page, child } = await open(originOf('127.0.0.1'), originOf('localhost')); + try { + await waitForLink(page); + await waitForChildLinked(child); + // The parent closes: the child hears its bye. + await page.evaluate(() => [...window.DevtoolsAnywhere.childTargets.children()][0]?.close()); + await waitForChildLinked(child, false); + // Linked again by a reload, then the child closes: the parent hears its bye. + const reloaded = await reloadChild(page, child); + await waitForLink(page, 2); + await waitForChildLinked(reloaded); + await reloaded.evaluate(() => window.DevtoolsAnywhere.childTargets.parent?.close()); + await page.waitForFunction(() => window.__links?.[1]?.closed === true, { timeout: 5000, polling: 50 }); + } finally { + await page.close(); + } +}); diff --git a/packages/devtools-anywhere/src/frame-links/parent.ts b/packages/devtools-anywhere/src/frame-links/parent.ts index 1181b16..20120ed 100644 --- a/packages/devtools-anywhere/src/frame-links/parent.ts +++ b/packages/devtools-anywhere/src/frame-links/parent.ts @@ -1,6 +1,6 @@ // The parent's side of the handshake (protocol.ts): links the cross-origin iframes whose origin // data-frame-origins names, as child targets of this page's agent. -import { type ChildLink, childTargets, frameOwners, ownerOfWindow } from '../cdp-agent'; +import { type ChildLink, childTargets, frameOwners, ownerOfWindow, watchOwner } from '../cdp-agent'; import { type OriginPattern, matchOrigin } from './origins'; import { type PortEnd, openPort } from './port'; import { FRAME_PING, FRAME_WELCOME, type FrameWelcome, postToTrusted, readHello } from './protocol'; @@ -47,7 +47,10 @@ export function linkChildren(patterns: readonly OriginPattern[]): () => void { close: end.close, }; linked.set(owner, { id: hello.id, end }); + // The iframe's page says bye when it goes, but not when its element does. + const unwatch = watchOwner(owner, () => end.close()); end.onClose(() => { + unwatch(); if (linked.get(owner)?.end === end) linked.delete(owner); }); childTargets.addChild(link); @@ -57,10 +60,19 @@ export function linkChildren(patterns: readonly OriginPattern[]): () => void { for (const { window: target } of frameOwners()) if (target) postToTrusted(target, { type: FRAME_PING }, patterns); }; + const closeAll = () => { + for (const { end } of linked.values()) end.close(); + }; + // Leaving, this page ends its links; back from the back/forward cache, its iframes link again. + const onPageshow = (event: PageTransitionEvent) => event.persisted && ping(); addEventListener('message', onMessage); + addEventListener('pagehide', closeAll); + addEventListener('pageshow', onPageshow); ping(); return () => { removeEventListener('message', onMessage); - for (const { end } of linked.values()) end.close(); + removeEventListener('pagehide', closeAll); + removeEventListener('pageshow', onPageshow); + closeAll(); }; } From f8fa3c03b46d08fd7d2f98adc11feced9fba8d4c Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 20:37:56 +0800 Subject: [PATCH 055/168] feat(devtools-anywhere): hide an iframe's own DevTools while its parent debugs it A page in a cross-origin iframe linked to its parent is debugged from the parent's DevTools, as Chrome debugs an iframe from its page's: its own global , F12 button and panel, is hidden while the link lasts (display: none on the element, like a global element after the first) and shown again once it closes, unless another link has taken its place. Tested: the iframe's F12 button is hidden once linked and shown again when the parent closes the link; an iframe that trusts no parent keeps it. --- .../devtools-anywhere/src/element/index.ts | 19 +++++++++ .../src/frame-links/frame-links.test.ts | 41 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/devtools-anywhere/src/element/index.ts b/packages/devtools-anywhere/src/element/index.ts index 175ac04..3bdea10 100644 --- a/packages/devtools-anywhere/src/element/index.ts +++ b/packages/devtools-anywhere/src/element/index.ts @@ -1,4 +1,5 @@ import { + childTargets, installProbe, SHELL_KEY, SHELL_PAGE_ID, @@ -13,6 +14,7 @@ import { storeShellConfig, storeShellOpen, type CdpSession, + type ParentLink, type SnapdomLoader, } from '../cdp-agent'; import { connectFrontend, relayShell } from '../connection'; @@ -640,12 +642,29 @@ export class DevtoolsAnywhereElement extends HTMLElement { } } +/** + * Hides a global element, its F12 button and panel, while the page, in a cross-origin iframe, is + * debugged from its parent's DevTools (frame-links/); shows it again once the link closes. + */ +function hideWhileLinked(element: HTMLElement) { + const follow = (link: ParentLink) => { + element.style.setProperty('display', 'none', 'important'); + link.onClose(() => { + // Global elements after the first stay hidden. + if (!inactiveGlobals.has(element) && !childTargets.parent) element.style.removeProperty('display'); + }); + }; + if (childTargets.parent) follow(childTargets.parent); + childTargets.onParent(follow); +} + /** Creates a global element with the probe's options: only the first one connected works. @internal */ export function createGlobalElement(config: GlobalElementConfig): DevtoolsAnywhereElement | null { try { const element = new DevtoolsAnywhereElement(); globalElements.add(element); globalConfigs.set(element, config); + hideWhileLinked(element); if (config.frontend && !globals.probeFrontend) { globals.probeFrontend = config.frontend; customElementsLoading.forEach(load => load()); diff --git a/packages/devtools-anywhere/src/frame-links/frame-links.test.ts b/packages/devtools-anywhere/src/frame-links/frame-links.test.ts index 8abb4dc..22665c9 100644 --- a/packages/devtools-anywhere/src/frame-links/frame-links.test.ts +++ b/packages/devtools-anywhere/src/frame-links/frame-links.test.ts @@ -275,3 +275,44 @@ test('either end closing the link closes the other', async () => { await page.close(); } }); + +/** Whether the child page shows its F12 button: its global element's, hidden or not with it. */ +const f12Shown = (child: Frame) => + child.evaluate(() => { + const button = document.querySelector('devtools-anywhere')?.shadowRoot?.querySelector('.toggler'); + return button instanceof HTMLElement && button.checkVisibility(); + }); + +/** Waits for the child page to show its F12 button, or hide it. */ +const waitForF12 = (child: Frame, shown: boolean) => + child.waitForFunction( + shown => { + const button = document.querySelector('devtools-anywhere')?.shadowRoot?.querySelector('.toggler'); + return button instanceof HTMLElement && button.checkVisibility() === shown; + }, + { timeout: 5000, polling: 50 }, + shown + ); + +test("the iframe's own F12 button is hidden while linked, and back once the link closes", async () => { + const { page, child } = await open(originOf('127.0.0.1'), originOf('localhost')); + try { + await waitForChildLinked(child); + await waitForF12(child, false); + await page.evaluate(() => [...window.DevtoolsAnywhere.childTargets.children()][0]?.close()); + await waitForF12(child, true); + } finally { + await page.close(); + } +}); + +test('an iframe that trusts no parent keeps its F12 button', async () => { + const { page, child } = await open(originOf('127.0.0.1'), null); + try { + await waitForF12(child, true); + await settle(); + assert.equal(await f12Shown(child), true); + } finally { + await page.close(); + } +}); From 9317be19fecb9d3611f8bd33b60736d7f6e3b944 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 20:38:31 +0800 Subject: [PATCH 056/168] feat(cdp-agent): route a child target's messages by sessionId Cross-origin iframes are to be child targets of the page's agent, with their sessions flattened as Chrome does: every session talks over the one connection, and a message names its session with a top-level `sessionId`. CdpSession gets the means for that: - route(sessionId, deliver) sends the client's messages that carry `sessionId` to `deliver`, parsed, instead of the session's own domains; post(message) sends the client a message as it is (a child target's, carrying its session id). - A message for no known session is answered as Chrome answers it (measured on a flatten connection): code -32001, "Session with given id not found.", with the message's id and without the session id. A `sessionId` that is not a string gets -32600, "Message may have string 'sessionId' property"; an empty one is no session, as in Chrome. Both checks come after the id and method checks, as Chrome answers a message without an id with the id error even when its session is unknown. --- .../src/cdp-agent/core/session.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/devtools-anywhere/src/cdp-agent/core/session.ts b/packages/devtools-anywhere/src/cdp-agent/core/session.ts index 349f0d8..3be4eb7 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/session.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/session.ts @@ -29,6 +29,8 @@ export class CdpSession { private domains = new Map>(); private listeners = new Set<(message: string) => void>(); private resets = new Set<() => void>(); + /** Where the client's messages for other sessions (child targets') go, by session id. */ + private routes = new Map) => void>(); /** Adds the handlers of `domain`'s methods, keeping those registered before. */ register(domain: string, handlers: Record) { @@ -54,6 +56,22 @@ export class CdpSession { this.resets.forEach(fn => fn()); } + /** + * Routes the client's messages carrying `sessionId` (a child target's session, flattened as + * Chrome does) to `deliver`, as parsed, `sessionId` included; returns its removal. + */ + route(sessionId: string, deliver: (message: Record) => void): () => void { + this.routes.set(sessionId, deliver); + return () => { + if (this.routes.get(sessionId) === deliver) this.routes.delete(sessionId); + }; + } + + /** Sends the client a message as it is: a child target's, carrying its `sessionId`. */ + post(message: string) { + this.listeners.forEach(fn => fn(message)); + } + /** Sends the client an event. */ emit(method: string, params: object) { this.send({ method, params }); @@ -75,7 +93,12 @@ export class CdpSession { this.send({ error: { code: -32600, message: 'Message must be an object' } }); return; } - const { id, method, params } = parsed as { id?: unknown; method?: unknown; params?: object }; + const { id, method, params, sessionId } = parsed as { + id?: unknown; + method?: unknown; + params?: object; + sessionId?: unknown; + }; if (typeof id !== 'string' && (typeof id !== 'number' || !Number.isInteger(id))) { this.send({ error: { code: -32600, message: "Message must have integer 'id' property" } }); return; @@ -84,6 +107,18 @@ export class CdpSession { this.send({ id, error: { code: -32600, message: "Message must have string 'method' property" } }); return; } + // Like Chrome's flatten mode, a child target's messages carry its session id; '' is none. + if (sessionId !== undefined && typeof sessionId !== 'string') { + this.send({ id, error: { code: -32600, message: "Message may have string 'sessionId' property" } }); + return; + } + if (sessionId) { + const deliver = this.routes.get(sessionId); + // Chrome answers without the session id. + if (deliver) deliver(parsed as Record); + else this.send({ id, error: { code: -32001, message: 'Session with given id not found.' } }); + return; + } try { this.send({ id, result: await this.call(method, params) }); } catch (e) { From b2f18ca1e156f46806bcda7411f6708a3f156823 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 20:39:14 +0800 Subject: [PATCH 057/168] feat(cdp-agent): attach cross-origin iframes as child targets The Target domain of the page's agent, on the links that the handshake makes to cross-origin iframes' agents (core/child-targets.ts), following what Chrome does (test-support/oopif-notes.md, and measured on a flatten page session): - Target.setAutoAttach with flatten attaches every link, those there already at once and those made later as they come, each as a session with a random 32-hex id (unique across the page and the iframes' agents), and announces it with attachedToTarget: type "iframe", targetId = the frame id the parent gave the iframe, parentId = this target, parentFrameId = the frame holding the iframe, attached true, canAccessOpener false. Turning it on again attaches nothing twice; turning it off detaches with detachedFromTarget {sessionId, targetId}, as Chrome does. Chrome accepts setAutoAttach without flatten too and wraps the messages in receivedMessageFromTarget; that is not done here: it is accepted and nothing is attached (the frontend always asks for flatten). - Unlike Chrome, an iframe is announced only once its document has loaded the probe and the link is made, so it is never held at start: waitingForDebugger is false and the URL and title are filled in (the title is the URL while the document has none), as Chrome announces an iframe that exists when auto-attach is turned on. The URL and title come from the iframe's agent (Target.getTargetInfo over the link, under an id of the domain's own whose response no client sees), or from the iframe's src if it does not answer within a second. - The client's messages for the session go to the iframe's agent without the session id; its messages come back with it. The iframe's own children are attached by its agent on its session; their sessions are flat as in Chrome: the attachedToTarget it sends registers the nested session id to the same link, its messages carrying a session id pass through as they are, and detachedFromTarget removes the route. - A link that closes (the iframe removed or navigated away) is detached with detachedFromTarget. Links of an iframe whose agent is detached while it is still there get the calls that end a session (the domains' disables and setAutoAttach off), as a new Chrome session starts clean; the list is the one endSession uses, now END_SESSION_CALLS in core/session.ts. - setDiscoverTargets sends targetCreated for this page and each iframe (Chrome sends all of the browser's targets; the page knows its own), and targetDestroyed when a link closes. getTargets lists them; getTargetInfo answers this target (an "iframe" one in an iframe's agent linked to its parent), an iframe's by its id, or Chrome's -32602 "No target with given id found"; detachFromTarget detaches, or fails with Chrome's -32602 "No session with given id". - Runtime.runIfWaitingForDebugger on a child session goes through to the iframe's agent like any other message. - A client's end (endSession, then reset) detaches silently: there is no one left to tell. A link carries one session, unlike Chrome where every client gets its own: it is attached to one CDP session at a time, and another session that auto-attaches gets it when the first lets it go. The tests link stand-in iframe agents in the page (test-support/child-targets-harness.ts, bundled from the sources), each a CdpSession with a Target domain of its own, and check attach, routing both ways, nested sessions, Chrome's errors for unknown sessions, detach on close and on auto-attach off, discovery, a second client, and the round trip of a child session's messages through connectFrontend (embedding) and through the remote server. ShellRelay's constructor no longer uses parameter properties, so that connection.ts type-checks under the tests' erasableSyntaxOnly. --- .../src/cdp-agent/core/session.ts | 14 + .../src/cdp-agent/domains/stubs.ts | 2 +- .../cdp-agent/domains/target/target.test.ts | 306 ++++++++++++++++++ .../src/cdp-agent/domains/target/target.ts | 289 +++++++++++++++++ .../devtools-anywhere/src/cdp-agent/index.ts | 4 +- packages/devtools-anywhere/src/connection.ts | 23 +- .../test-support/child-targets-harness.ts | 163 ++++++++++ tests/globals.d.ts | 3 + 8 files changed, 793 insertions(+), 11 deletions(-) create mode 100644 packages/devtools-anywhere/src/cdp-agent/domains/target/target.test.ts create mode 100644 packages/devtools-anywhere/src/cdp-agent/domains/target/target.ts create mode 100644 packages/devtools-anywhere/test-support/child-targets-harness.ts diff --git a/packages/devtools-anywhere/src/cdp-agent/core/session.ts b/packages/devtools-anywhere/src/cdp-agent/core/session.ts index 3be4eb7..b2a2e4a 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/session.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/session.ts @@ -9,6 +9,20 @@ export type Handler = (params: never) => unknown; /** Message text of anything thrown. */ export const errorMessage = (e: unknown) => String(e instanceof Error ? e.message : e); +/** Domains that keep state while enabled; a new frontend instance must start from disabled. */ +const STATEFUL_DOMAINS = ['Runtime', 'Network', 'Debugger', 'CSS', 'DOM', 'DOMStorage', 'Overlay', 'WebMCP']; + +/** + * The calls that end what a frontend started in the page, like a new Chrome session: e.g. + * Debugger.enable and CSS.enable only report scripts and style sheets again after a disable. + * + * @internal + */ +export const END_SESSION_CALLS: readonly string[] = [ + ...STATEFUL_DOMAINS.map(domain => `${domain}.disable`), + 'Page.stopScreencast', +]; + /** An error answered to the client as a CDP error response. */ export class CdpError extends Error { readonly code: number; diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/stubs.ts b/packages/devtools-anywhere/src/cdp-agent/domains/stubs.ts index 2e0af6b..471713d 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/stubs.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/stubs.ts @@ -68,7 +68,7 @@ const NOOPS = { 'untrackIndexedDBForOrigin', 'untrackIndexedDBForStorageKey', ], - Target: ['setAutoAttach', 'setDiscoverTargets', 'setRemoteLocations'], + Target: ['setRemoteLocations'], } satisfies CommandsByDomain; /** No-ops the frontend sends that this devtools-protocol version does not declare. */ diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/target/target.test.ts b/packages/devtools-anywhere/src/cdp-agent/domains/target/target.test.ts new file mode 100644 index 0000000..445115e --- /dev/null +++ b/packages/devtools-anywhere/src/cdp-agent/domains/target/target.test.ts @@ -0,0 +1,306 @@ +// The Target domain of the page's agent: cross-origin iframes' agents attached as child targets +// and the client's messages routed to them by sessionId, flattened as Chrome does +// (test-support/oopif-notes.md). The iframe agents are stand-ins in the page, linked by +// test-support/child-targets-harness.ts; the real links are made by the handshake. +import { after, before, beforeEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import type { Page } from 'puppeteer-core'; +import { startRemoteServer, type RemoteServer } from '@devtools-anywhere/remote-server'; +import { launch, type Env } from '../../../../test-support/conformance.ts'; + +const support = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../test-support'); + +let env: Env | undefined; +let page: Page; +let harness = ''; + +before(async () => { + env = await launch(); + const bundled = await build({ + stdin: { + contents: + "import { Harness } from './child-targets-harness.ts'; Object.assign(window, { __harness: new Harness() });", + resolveDir: support, + loader: 'ts', + }, + bundle: true, + format: 'iife', + target: 'es2020', + write: false, + external: ['@zumer/snapdom'], + }); + harness = bundled.outputFiles[0]?.text ?? ''; +}); +after(() => env?.close()); + +beforeEach(async () => { + assert.ok(env); + page = await env.browser.newPage(); + await page.goto(env.baseUrl + '/packages/devtools-anywhere/test-support/fixture.html'); + await page.addScriptTag({ content: harness }); +}); + +type Message = Record; + +const send = (message: object) => page.evaluate(message => window.__harness?.send(message), message); +const add = (name: string, parent?: string) => + page.evaluate((name, parent) => window.__harness?.add(name, parent) ?? '', name, parent); +const out = (): Promise => page.evaluate(() => window.__harness?.out ?? []); +const received = (name: string): Promise => + page.evaluate(name => window.__harness?.received(name) ?? [], name); + +async function waitFor(get: () => Promise, timeout = 5000): Promise { + const start = Date.now(); + for (;;) { + const value = await get(); + if (value !== undefined) return value; + if (Date.now() - start > timeout) throw new Error('timed out'); + await new Promise(r => setTimeout(r, 20)); + } +} + +/** The first message the client got that matches, waiting for it. */ +const message = (match: (m: Message) => boolean) => waitFor(async () => (await out()).find(match)); +const reply = (id: number) => message(m => m['id'] === id); +const events = async (method: string) => (await out()).filter(m => m['method'] === method); +const field = (value: unknown, name: string): unknown => + typeof value === 'object' && value !== null ? Reflect.get(value, name) : undefined; + +/** Waits for the attachedToTarget of `frameId` and returns its session id. */ +async function attached(frameId: string): Promise { + const event = await message( + m => m['method'] === 'Target.attachedToTarget' && field(field(m['params'], 'targetInfo'), 'targetId') === frameId + ); + return String(field(event['params'], 'sessionId')); +} + +const AUTO_ATTACH = { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }; + +test('an iframe linked before auto-attach is attached when it is turned on, one linked after at once', async () => { + const first = await add('first'); + send({ id: 1, method: 'Target.setAutoAttach', params: AUTO_ATTACH }); + assert.deepEqual(await reply(1), { id: 1, result: {} }); + const event = await message(m => m['method'] === 'Target.attachedToTarget'); + const url = await page.evaluate(() => location.href); + const params = event['params']; + // Like Chrome for an iframe there when auto-attach is turned on: not paused, URL and title + // known (the title is the URL while the document has none). + assert.deepEqual(field(params, 'targetInfo'), { + targetId: first, + type: 'iframe', + title: await page.evaluate(() => document.title || location.href), + url, + attached: true, + parentId: 'devtools-anywhere-frame', + canAccessOpener: false, + parentFrameId: 'devtools-anywhere-frame', + }); + assert.equal(field(params, 'waitingForDebugger'), false); + assert.match(String(field(params, 'sessionId')), /^[0-9A-F]{32}$/); + const second = await add('second'); + const sessions = [await attached(first), await attached(second)]; + assert.notEqual(sessions[0], sessions[1]); + // Again on: nothing attached twice, as in Chrome. + send({ id: 2, method: 'Target.setAutoAttach', params: AUTO_ATTACH }); + await reply(2); + assert.equal((await events('Target.attachedToTarget')).length, 2); +}); + +test("the client's messages with a session id reach that iframe's agent without it, and come back with it", async () => { + const frameId = await add('child'); + send({ id: 1, method: 'Target.setAutoAttach', params: AUTO_ATTACH }); + const sessionId = await attached(frameId); + send({ id: 2, method: 'Runtime.evaluate', params: { expression: '1' }, sessionId }); + assert.deepEqual(await reply(2), { id: 2, result: { result: { type: 'string', value: 'child' } }, sessionId }); + send({ id: 3, method: 'Runtime.runIfWaitingForDebugger', sessionId }); + assert.deepEqual(await reply(3), { id: 3, result: {}, sessionId }); + const got = await received('child'); + assert.deepEqual(got.slice(-2), [ + { id: 2, method: 'Runtime.evaluate', params: { expression: '1' } }, + { id: 3, method: 'Runtime.runIfWaitingForDebugger' }, + ]); + // The domain's own command for the iframe's URL and title is not the client's to see. + assert.ok(!(await out()).some(m => typeof m['id'] === 'string')); +}); + +test('a message for no session gets Chrome’s errors, without the session id', async () => { + send({ id: 1, method: 'Runtime.evaluate', params: { expression: '1' }, sessionId: 'nope' }); + assert.deepEqual(await reply(1), { id: 1, error: { code: -32001, message: 'Session with given id not found.' } }); + send({ id: 2, method: 'Runtime.evaluate', params: { expression: '1' }, sessionId: 5 }); + assert.deepEqual(await reply(2), { + id: 2, + error: { code: -32600, message: "Message may have string 'sessionId' property" }, + }); + // An empty one is none: the page's own session. + send({ id: 3, method: 'Target.setDiscoverTargets', params: { discover: false }, sessionId: '' }); + assert.deepEqual(await reply(3), { id: 3, result: {} }); +}); + +test("an iframe's own children are attached on its session, and their sessions are flat", async () => { + const child = await add('child'); + const grandchild = await add('grandchild', 'child'); + send({ id: 1, method: 'Target.setAutoAttach', params: AUTO_ATTACH }); + const s1 = await attached(child); + send({ id: 2, method: 'Target.setAutoAttach', params: AUTO_ATTACH, sessionId: s1 }); + const event = await message(m => m['method'] === 'Target.attachedToTarget' && m['sessionId'] === s1); + const s2 = String(field(event['params'], 'sessionId')); + assert.deepEqual(field(field(event['params'], 'targetInfo'), 'targetId'), grandchild); + // Its parent is the child's frame. + assert.equal(field(field(event['params'], 'targetInfo'), 'parentFrameId'), child); + send({ id: 3, method: 'Runtime.evaluate', params: { expression: '1' }, sessionId: s2 }); + assert.deepEqual(await reply(3), { + id: 3, + result: { result: { type: 'string', value: 'grandchild' } }, + sessionId: s2, + }); + assert.deepEqual((await received('grandchild')).at(-1), { + id: 3, + method: 'Runtime.evaluate', + params: { expression: '1' }, + }); + // Removed: the grandchild first, on the child's session, as in Chrome; then the child. + await page.evaluate(() => window.__harness?.close('grandchild')); + await message(m => m['method'] === 'Target.detachedFromTarget' && m['sessionId'] === s1); + await page.evaluate(() => window.__harness?.close('child')); + const detached = await message(m => m['method'] === 'Target.detachedFromTarget' && m['sessionId'] === undefined); + assert.deepEqual(detached['params'], { sessionId: s1, targetId: child }); + for (const [id, sessionId] of [ + [4, s1], + [5, s2], + ] as const) { + send({ id, method: 'Runtime.evaluate', params: { expression: '1' }, sessionId }); + assert.deepEqual(field(await reply(id), 'error'), { code: -32001, message: 'Session with given id not found.' }); + } +}); + +test('turning auto-attach off detaches, as in Chrome; without flatten nothing is attached', async () => { + const frameId = await add('child'); + send({ id: 1, method: 'Target.setAutoAttach', params: AUTO_ATTACH }); + const sessionId = await attached(frameId); + send({ id: 2, method: 'Target.setAutoAttach', params: { autoAttach: false, waitForDebuggerOnStart: false } }); + const detached = await message(m => m['method'] === 'Target.detachedFromTarget'); + assert.deepEqual(detached['params'], { sessionId, targetId: frameId }); + // Its session in the iframe's agent ends: what the client enabled there is disabled. + await waitFor(async () => + (await received('child')).some(m => m['method'] === 'Runtime.disable') ? true : undefined + ); + send({ id: 3, method: 'Target.setAutoAttach', params: { ...AUTO_ATTACH, flatten: false } }); + await reply(3); + await new Promise(r => setTimeout(r, 200)); + assert.equal((await events('Target.attachedToTarget')).length, 1); +}); + +test('getTargets, getTargetInfo and setDiscoverTargets list the page and its iframes', async () => { + const frameId = await add('child'); + send({ id: 1, method: 'Target.getTargets' }); + const infos = field(field(await reply(1), 'result'), 'targetInfos'); + assert.ok(Array.isArray(infos)); + assert.deepEqual( + infos.map(info => [field(info, 'targetId'), field(info, 'type'), field(info, 'attached')]), + [ + ['devtools-anywhere-frame', 'page', true], + [frameId, 'iframe', false], + ] + ); + send({ id: 2, method: 'Target.getTargetInfo', params: {} }); + assert.equal(field(field(field(await reply(2), 'result'), 'targetInfo'), 'type'), 'page'); + send({ id: 3, method: 'Target.getTargetInfo', params: { targetId: frameId } }); + assert.equal(field(field(field(await reply(3), 'result'), 'targetInfo'), 'type'), 'iframe'); + send({ id: 4, method: 'Target.getTargetInfo', params: { targetId: 'x' } }); + assert.deepEqual(field(await reply(4), 'error'), { code: -32602, message: 'No target with given id found' }); + send({ id: 5, method: 'Target.setDiscoverTargets', params: { discover: true } }); + await reply(5); + const created = (await events('Target.targetCreated')).map(m => field(field(m['params'], 'targetInfo'), 'targetId')); + assert.deepEqual(created, ['devtools-anywhere-frame', frameId]); + await page.evaluate(() => window.__harness?.close('child')); + assert.deepEqual((await message(m => m['method'] === 'Target.targetDestroyed'))['params'], { targetId: frameId }); + // On the iframe's own session, its own info: an iframe target. + const own = await page.evaluate( + name => window.__harness?.agents.get(name)?.session.call('Target.getTargetInfo'), + 'child' + ); + assert.deepEqual(field(field(own, 'targetInfo'), 'targetId'), frameId); + assert.deepEqual(field(field(own, 'targetInfo'), 'type'), 'iframe'); +}); + +test("a client's end detaches silently, and another client's session can attach the iframe then", async () => { + const frameId = await add('child'); + const other = await page.evaluate(() => window.__harness?.another() ?? -1); + send({ id: 1, method: 'Target.setAutoAttach', params: AUTO_ATTACH }); + await attached(frameId); + // The link carries one session: the other client's waits for it. + await page.evaluate( + (other, params) => window.__harness?.others[other]?.call('Target.setAutoAttach', params), + other, + AUTO_ATTACH + ); + await new Promise(r => setTimeout(r, 200)); + assert.equal((await events('Target.attachedToTarget')).length, 1); + await page.evaluate(() => window.__harness?.end()); + const theirs = await message(m => m['method'] === 'Target.attachedToTarget' && m['other'] === other); + assert.equal(field(field(theirs['params'], 'targetInfo'), 'targetId'), frameId); + assert.equal((await events('Target.detachedFromTarget')).length, 0); +}); + +test('embedded: messages with a session id go through connectFrontend both ways', async () => { + const frameId = await add('child'); + await page.evaluate(() => { + const post = window.__harness?.embed(); + Object.assign(window, { __post: post }); + }); + const post = (message: object) => page.evaluate(message => Reflect.get(window, '__post')(message), message); + await post({ id: 1, method: 'Target.setAutoAttach', params: AUTO_ATTACH }); + const event = await message(m => m['embedded'] === true && m['method'] === 'Target.attachedToTarget'); + const sessionId = String(field(event['params'], 'sessionId')); + assert.equal(field(field(event['params'], 'targetInfo'), 'targetId'), frameId); + await post({ id: 2, method: 'Runtime.evaluate', params: { expression: '1' }, sessionId }); + const answer = await message(m => m['embedded'] === true && m['id'] === 2); + assert.deepEqual(answer, { + id: 2, + result: { result: { type: 'string', value: 'child' } }, + sessionId, + embedded: true, + }); +}); + +test('remote debugging: messages with a session id go through the server both ways', async () => { + const remote: RemoteServer = await startRemoteServer({ port: 0, host: '127.0.0.1', token: 'token' }); + try { + const frameId = await add('child'); + await page.evaluate(url => window.__harness?.remote(url), remote.url); + const list = await waitFor(async () => { + const targets: { webSocketDebuggerUrl: string; connected: boolean }[] = await ( + await fetch(`${new URL(remote.url).origin}/json/list?token=token`) + ).json(); + return targets.find(t => t.connected); + }); + const socket = new WebSocket(list.webSocketDebuggerUrl); + const messages: Message[] = []; + socket.onmessage = e => messages.push({ ...Object(JSON.parse(String(e.data))) }); + await new Promise((resolve, reject) => { + socket.onopen = resolve; + socket.onerror = reject; + }); + const find = (match: (m: Message) => boolean) => waitFor(async () => messages.find(match)); + socket.send(JSON.stringify({ id: 1, method: 'Target.setAutoAttach', params: AUTO_ATTACH })); + const event = await find(m => m['method'] === 'Target.attachedToTarget'); + const sessionId = String(field(event['params'], 'sessionId')); + assert.equal(field(field(event['params'], 'targetInfo'), 'targetId'), frameId); + socket.send(JSON.stringify({ id: 2, method: 'Runtime.evaluate', params: { expression: '1' }, sessionId })); + assert.deepEqual(await find(m => m['id'] === 2), { + id: 2, + result: { result: { type: 'string', value: 'child' } }, + sessionId, + }); + // The frontend leaves: the page's session ends, and the iframe's is let go. + socket.close(); + await waitFor(async () => + (await received('child')).some(m => m['method'] === 'Runtime.disable') ? true : undefined + ); + } finally { + await remote.close(); + } +}); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/target/target.ts b/packages/devtools-anywhere/src/cdp-agent/domains/target/target.ts new file mode 100644 index 0000000..c35a68a --- /dev/null +++ b/packages/devtools-anywhere/src/cdp-agent/domains/target/target.ts @@ -0,0 +1,289 @@ +import type { Protocol } from 'devtools-protocol'; +import { type ChildLink, type ChildTargets, childTargets } from '../../core/child-targets'; +import type { Context } from '../../core/context'; +import { CdpError, END_SESSION_CALLS } from '../../core/session'; + +/** + * The Target domain: the page's cross-origin iframes as child targets of type `iframe` + * (docs/roadmap.md, "iframes"; what Chrome does is in test-support/oopif-notes.md). Each link + * to an iframe's agent (core/child-targets.ts) is attached as a session of its own while + * auto-attach is on, and the client's messages are routed to it by `sessionId`, flattened as + * Chrome does: every session, the iframe's own children's too, talks over the one connection. + * + * Unlike Chrome, where every client gets a session of its own with each target, a link carries + * one session: it is attached to one CDP session at a time, and the others get it once that one + * lets it go. Unlike Chrome too, an iframe is announced only once its document has loaded the + * probe and the link is made, so it is never paused at start: `waitingForDebugger` is false, and + * the URL and title are known, as Chrome announces iframes that exist when auto-attach is turned + * on. + */ + +/** Prefix of the ids of the domain's own commands to the iframe's agent: their responses are not the client's. */ +const OWN_ID_PREFIX = 'target-own-'; +/** How long the iframe's agent has to tell its URL and title, in ms, before the iframe's `src` is used. */ +const INFO_TIMEOUT = 1000; + +/** The CDP session each link is attached to: a link carries one session. */ +const claims = new WeakMap(); +/** Listeners to links let go, which another CDP session may then attach. */ +const releases = new Set<(link: ChildLink) => void>(); + +let ownSeq = 0; + +/** A session id: random, unique across the page and the iframes' agents, like Chrome's. */ +function newSessionId() { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, b => b.toString(16).padStart(2, '0')) + .join('') + .toUpperCase(); +} + +function parse(message: string): Record | undefined { + try { + const value: unknown = JSON.parse(message); + return typeof value === 'object' && value !== null && !Array.isArray(value) ? { ...value } : undefined; + } catch { + return undefined; + } +} + +const field = (value: unknown, name: string): unknown => + typeof value === 'object' && value !== null ? Reflect.get(value, name) : undefined; + +/** + * Sends the iframe's agent a command of the domain's own, the response to which no client + * sees; resolves with its result, or undefined on an error or after `timeout` ms. + */ +function ask(link: ChildLink, method: string, params: object = {}, timeout = INFO_TIMEOUT): Promise { + const id = `${OWN_ID_PREFIX}${++ownSeq}`; + return new Promise(resolve => { + const done = (result: unknown) => { + off(); + offClose(); + clearTimeout(timer); + resolve(result); + }; + const off = link.onMessage(message => { + if (!message.includes(id)) return; + const parsed = parse(message); + if (parsed?.['id'] === id) done(parsed['result']); + }); + const offClose = link.onClose(() => done(undefined)); + const timer = setTimeout(() => done(undefined), timeout); + link.send(JSON.stringify({ id, method, params })); + }); +} + +/** The URL an iframe was given, for one whose agent does not tell its own. */ +function srcOf(owner: Element): string { + const src = owner.getAttribute(owner.localName === 'object' ? 'data' : 'src'); + try { + return src ? new URL(src, owner.baseURI).href : ''; + } catch { + return src ?? ''; + } +} + +/** Ends what the client started in the iframe's agent, as a new Chrome session would start clean. */ +function endChildSession(link: ChildLink) { + // Its own children are let go too; it reports that to no one now. + link.send( + JSON.stringify({ + id: `${OWN_ID_PREFIX}${++ownSeq}`, + method: 'Target.setAutoAttach', + params: { autoAttach: false, waitForDebuggerOnStart: false, flatten: true }, + }) + ); + for (const method of END_SESSION_CALLS) link.send(JSON.stringify({ id: `${OWN_ID_PREFIX}${++ownSeq}`, method })); +} + +/** A link attached as a session. */ +interface Attached { + readonly sessionId: string; + /** The sessions of the iframe's own child targets (and theirs), routed to the link as they are. */ + readonly nested: Map void>; + readonly offs: (() => void)[]; +} + +/** Registers the Target domain on `ctx.session`, for the links in `targets`. */ +export function registerTarget(ctx: Pick, targets: ChildTargets = childTargets) { + const { session } = ctx; + const claim = {}; + let autoAttach = false; + let discover = false; + const attached = new Map(); + + /** This page's target: an iframe's too, when its agent is linked to its parent's. */ + function ownInfo(): Protocol.Target.TargetInfo { + const { document: doc, window: win } = ctx.frames.top; + const url = win.location.href; + const parent = targets.parent; + const info: Protocol.Target.TargetInfo = { + targetId: parent?.frameId ?? ctx.frames.top.id, + type: parent ? 'iframe' : 'page', + // Chrome reports the URL while the document has no title. + title: doc.title || url, + url, + attached: true, + canAccessOpener: false, + }; + if (parent) { + // The parent page's target id is not known here: the frame holding the iframe stands in. + info.parentId = parent.parentFrameId; + info.parentFrameId = parent.parentFrameId; + } + return info; + } + + /** A child target's info: its URL and title from its agent, its place from the parent's frames. */ + async function infoOf(link: ChildLink): Promise { + const own = field(await ask(link, 'Target.getTargetInfo'), 'targetInfo'); + const told = field(own, 'url'); + const url = typeof told === 'string' ? told : srcOf(link.owner); + const title = field(own, 'title'); + const ownId = ownInfo().targetId; + // The top frame's id is this target's, in an iframe's agent too. + const holder = ctx.frames.ofNode(link.owner); + return { + targetId: link.frameId, + type: 'iframe', + title: typeof title === 'string' && title ? title : url, + url, + attached: claims.has(link), + parentId: ownId, + canAccessOpener: false, + parentFrameId: holder === ctx.frames.top ? ownId : holder.id, + }; + } + + /** A message from the iframe's agent, on to the client with the link's session id unless it has one. */ + function fromChild(link: ChildLink, entry: Attached, message: string) { + const parsed = parse(message); + if (!parsed) return; + const { id, method, params } = parsed; + if (typeof id === 'string' && id.startsWith(OWN_ID_PREFIX)) return; + // The iframe's own children: their sessions are the iframe's to route to. + const nested = field(params, 'sessionId'); + if (typeof nested === 'string') { + if (method === 'Target.attachedToTarget' && !entry.nested.has(nested)) { + entry.nested.set( + nested, + session.route(nested, m => link.send(JSON.stringify(m))) + ); + } else if (method === 'Target.detachedFromTarget') { + entry.nested.get(nested)?.(); + entry.nested.delete(nested); + } + } + const own = parsed['sessionId']; + session.post(typeof own === 'string' && own ? message : JSON.stringify({ ...parsed, sessionId: entry.sessionId })); + } + + async function attach(link: ChildLink) { + if (attached.has(link) || claims.has(link)) return; + claims.set(link, claim); + const entry: Attached = { sessionId: newSessionId(), nested: new Map(), offs: [] }; + attached.set(link, entry); + // Like Chrome when the iframe is removed (or goes back to the parent's origin). + entry.offs.push(link.onClose(() => detach(link, true, false))); + const targetInfo = await infoOf(link); + // Let go meanwhile: auto-attach turned off, the iframe gone, or the client. + if (attached.get(link) !== entry) return; + entry.offs.push( + // To the iframe's agent as its own client's, without the session id. + session.route(entry.sessionId, message => { + delete message['sessionId']; + link.send(JSON.stringify(message)); + }), + link.onMessage(message => fromChild(link, entry, message)) + ); + session.emit('Target.attachedToTarget', { + sessionId: entry.sessionId, + targetInfo, + waitingForDebugger: false, + } satisfies Protocol.Target.AttachedToTargetEvent); + } + + /** Lets a link go: with an event when the client is to hear of it, and ending its session in the iframe when it is still there. */ + function detach(link: ChildLink, notify: boolean, open = true) { + const entry = attached.get(link); + if (!entry) return; + attached.delete(link); + claims.delete(link); + entry.offs.forEach(off => off()); + entry.nested.forEach(off => off()); + if (open) endChildSession(link); + if (notify) { + session.emit('Target.detachedFromTarget', { + sessionId: entry.sessionId, + targetId: link.frameId, + } satisfies Protocol.Target.DetachedFromTargetEvent); + } + releases.forEach(listener => listener(link)); + } + + async function announce(link: ChildLink) { + const targetInfo = await infoOf(link); + if (discover) session.emit('Target.targetCreated', { targetInfo } satisfies Protocol.Target.TargetCreatedEvent); + } + + targets.onChild(link => { + if (discover) void announce(link); + link.onClose(() => { + if (discover) session.emit('Target.targetDestroyed', { targetId: link.frameId }); + }); + if (autoAttach) void attach(link); + }); + // A link another CDP session let go, still there: this one's if it auto-attaches. + releases.add(link => { + if (autoAttach && [...targets.children()].includes(link)) void attach(link); + }); + session.onReset(() => { + // The client is gone: no one to tell. + autoAttach = false; + discover = false; + for (const link of attached.keys()) detach(link, false); + }); + + session.register('Target', { + setAutoAttach(params: Protocol.Target.SetAutoAttachRequest) { + // Only flatten sessions are supported: Chrome accepts the others too, wrapping the + // messages in Target.receivedMessageFromTarget, which is not done here, so nothing is + // attached without flatten (the frontend always asks for it). + autoAttach = params.autoAttach === true && params.flatten === true; + if (autoAttach) for (const link of targets.children()) void attach(link); + // Like Chrome, turning it off detaches the targets it attached. + else for (const link of attached.keys()) detach(link, true); + }, + + async setDiscoverTargets(params: Protocol.Target.SetDiscoverTargetsRequest) { + const was = discover; + discover = params.discover === true; + if (!discover || was) return; + // Like Chrome, every target there is, this page's own included. + session.emit('Target.targetCreated', { targetInfo: ownInfo() } satisfies Protocol.Target.TargetCreatedEvent); + for (const link of targets.children()) await announce(link); + }, + + async getTargets(): Promise { + const children = await Promise.all([...targets.children()].map(infoOf)); + return { targetInfos: [ownInfo(), ...children] }; + }, + + async getTargetInfo(params: Protocol.Target.GetTargetInfoRequest): Promise { + const own = ownInfo(); + if (params.targetId === undefined || params.targetId === own.targetId) return { targetInfo: own }; + const link = [...targets.children()].find(child => child.frameId === params.targetId); + if (!link) throw new CdpError('No target with given id found', -32602); + return { targetInfo: await infoOf(link) }; + }, + + detachFromTarget(params: Protocol.Target.DetachFromTargetRequest) { + const link = [...attached].find( + ([child, entry]) => entry.sessionId === params.sessionId || child.frameId === params.targetId + )?.[0]; + if (!link) throw new CdpError('No session with given id', -32602); + detach(link, true); + }, + }); +} diff --git a/packages/devtools-anywhere/src/cdp-agent/index.ts b/packages/devtools-anywhere/src/cdp-agent/index.ts index a6340a5..d73ae22 100644 --- a/packages/devtools-anywhere/src/cdp-agent/index.ts +++ b/packages/devtools-anywhere/src/cdp-agent/index.ts @@ -20,11 +20,12 @@ import { registerScreencast } from './domains/page/screencast'; import { registerScreenshot } from './domains/page/screenshot'; import { registerStorage } from './domains/storage/storage'; import { registerStubs } from './domains/stubs'; +import { registerTarget } from './domains/target/target'; import { registerWebMcp } from './domains/webmcp/webmcp'; import { installProbe, keep } from './probe'; import type { Probe } from './probe/types'; -export { CdpSession, type Handler } from './core/session'; +export { CdpSession, END_SESSION_CALLS, type Handler } from './core/session'; export { installProbe } from './probe'; export { OWN_ATTRIBUTE, markOwn } from './core/nodes'; export { @@ -117,6 +118,7 @@ export function createCdpSession(options: CdpSessionOptions = {}): CdpSession { ctx.nodes.inspected = []; }); registerStubs(ctx.session); + registerTarget(ctx); registerRuntime(ctx); registerNetwork(ctx); registerPage(ctx); diff --git a/packages/devtools-anywhere/src/connection.ts b/packages/devtools-anywhere/src/connection.ts index 42e72c0..5105a0a 100644 --- a/packages/devtools-anywhere/src/connection.ts +++ b/packages/devtools-anywhere/src/connection.ts @@ -1,4 +1,4 @@ -import { FRAME_ID, LOADER_ID, type CdpSession } from './cdp-agent'; +import { END_SESSION_CALLS, FRAME_ID, LOADER_ID, type CdpSession } from './cdp-agent'; import { HELLO_METHOD, PING_METHOD, @@ -16,8 +16,6 @@ import { const HELLO = JSON.stringify({ method: HELLO_METHOD, params: { loaderId: LOADER_ID } satisfies Partial }); /** Sent by the shell on startup, in case the page started listening first. */ const PING = JSON.stringify({ method: PING_METHOD }); -/** Domains that keep state while enabled; a new frontend instance must start from disabled. */ -const STATEFUL_DOMAINS = ['Runtime', 'Network', 'Debugger', 'CSS', 'DOM', 'DOMStorage', 'Overlay', 'WebMCP']; interface Link { /** @@ -80,8 +78,7 @@ function link(session: CdpSession, peer: () => Window | null | undefined, origin * CSS.enable only report scripts and style sheets again after a disable. */ export async function endSession(session: CdpSession): Promise { - const calls = [...STATEFUL_DOMAINS.map(domain => `${domain}.disable`), 'Page.stopScreencast']; - await Promise.all(calls.map(method => session.call(method).catch(() => {}))); + await Promise.all(END_SESSION_CALLS.map(method => session.call(method).catch(() => {}))); } /** For each session, the end of its last frontend's session: the next frontend's messages wait for it. */ @@ -138,14 +135,22 @@ class ShellRelay extends RelayCore { private unknownLoaders = 0; private readonly toFrontend: string[] = []; private readonly session: Session; + private readonly page: HTMLIFrameElement; + private readonly devtools: HTMLIFrameElement; + private readonly frontendOrigin: string; + private readonly onMetrics: (metrics: DeviceMetrics | null) => void; constructor( - private readonly page: HTMLIFrameElement, - private readonly devtools: HTMLIFrameElement, - private readonly frontendOrigin: string, - private readonly onMetrics: (metrics: DeviceMetrics | null) => void + page: HTMLIFrameElement, + devtools: HTMLIFrameElement, + frontendOrigin: string, + onMetrics: (metrics: DeviceMetrics | null) => void ) { super(); + this.page = page; + this.devtools = devtools; + this.frontendOrigin = frontendOrigin; + this.onMetrics = onMetrics; this.session = this.openSession({ send: message => this.postFrontend(message) }); } diff --git a/packages/devtools-anywhere/test-support/child-targets-harness.ts b/packages/devtools-anywhere/test-support/child-targets-harness.ts new file mode 100644 index 0000000..556aa9e --- /dev/null +++ b/packages/devtools-anywhere/test-support/child-targets-harness.ts @@ -0,0 +1,163 @@ +// Runs in a test page (bundled from the sources by bundleHarness, in child-targets.ts): the page's +// agent with stand-in links to cross-origin iframes. Each stand-in iframe agent is a CDP session +// in the same page with the Target domain of its own (so it can have children too) and a +// Runtime.evaluate that answers its name; its link passes messages on asynchronously, as a +// MessagePort would. +import { CdpSession, FRAME_ID, childTargets, createCdpSession } from '../src/cdp-agent'; +import { ChildTargets, type ChildLink, type ParentLink } from '../src/cdp-agent/core/child-targets'; +import { frames } from '../src/cdp-agent/core/frames'; +import { registerTarget } from '../src/cdp-agent/domains/target/target'; +import { connectFrontend, endSession } from '../src/connection'; +import { connectRemote } from '../src/remote'; + +/** A stand-in iframe agent. */ +interface Agent { + session: CdpSession; + targets: ChildTargets; + link: ChildLink; + /** The messages it received, parsed. */ + received: Record[]; +} + +const parse = (message: string): Record => ({ ...Object(JSON.parse(message)) }); + +function newLink(owner: Element, frameId: string, agent: CdpSession): ChildLink { + const listeners = new Set<(message: string) => void>(); + const closes = new Set<() => void>(); + let open = true; + agent.onMessage(message => { + if (open) queueMicrotask(() => listeners.forEach(listener => listener(message))); + }); + return { + owner, + frameId, + send(message) { + if (open) queueMicrotask(() => void agent.dispatch(message).catch(() => {})); + }, + onMessage(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + onClose(listener) { + closes.add(listener); + return () => closes.delete(listener); + }, + close() { + if (!open) return; + open = false; + closes.forEach(listener => listener()); + }, + }; +} + +const parentLink = (frameId: string, parentFrameId: string): ParentLink => ({ + frameId, + parentFrameId, + send() {}, + onMessage: () => () => {}, + onClose: () => () => {}, + close() {}, +}); + +export class Harness { + /** What the page's agent sent its client, parsed. */ + readonly out: Record[] = []; + readonly agents = new Map(); + readonly session: CdpSession; + /** More sessions of the page's agent, as more clients. */ + readonly others: CdpSession[] = []; + + constructor() { + this.session = createCdpSession(); + this.session.onMessage(message => this.out.push(parse(message))); + } + + /** Sends the page's agent a client message. */ + send(message: object) { + void this.session.dispatch(JSON.stringify(message)).catch(() => {}); + } + + /** + * Adds a stand-in iframe agent called `name`, held by a new `')); + return ( + rows + .slice(from, to < 0 ? undefined : to) + .find( + row => + row.getAttribute('aria-expanded') === 'false' && + /^( { + await showPanel('elements'); + await element('.elements-tree-outline [role="treeitem"]', ' (DOMModel + // .requestDocument of the child, DOM.getFrameOwner on the parent): expanding it, by the rows' + // disclosure triangles, shows the document, fetched from the child's session. + for ( + let i = 0; + i < 8 && !(await texts('.elements-tree-outline [role="treeitem"]')).some(r => r.includes('child page')); + i++ + ) { + // asElement() is typed as a handle to any Node; the page function returns an Element or null. + const row = (await collapsedInIframe()).asElement() as ElementHandle | null; + await row?.evaluate(el => el.scrollIntoView({ block: 'center' })); + const box = await row?.boundingBox(); + if (!box) break; + // The triangle, left of the row's title. + await frontend.page().mouse.click(box.x + 20, box.y + 8); + await new Promise(r => setTimeout(r, 300)); + } + const rows = await waitFor(async () => { + const found = await texts('.elements-tree-outline [role="treeitem"]'); + return found.some(row => row.includes('child page')) ? found : null; + }); + const at = rows.findIndex(row => row.startsWith('. + assert.match(rows[at + 1] ?? '', /^#document \(http:\/\/127\.0\.0\.1:\d+\/.*frame-link-child\.html/); + assert.ok(rows.slice(at).some(row => row.startsWith('

'))); +}); + +test("the Console's context menu offers the cross-origin iframe's context", { skip }, async () => { + await showPanel('console'); + await (await element('[aria-label^="JavaScript context"]')).click(); + // The menu's items, their labels in shadow trees. + const items = await waitFor(async () => { + const found = await deepTexts('.console-context-selector-element'); + return found.length > 1 ? found : null; + }); + assert.equal(items.length, 2, items.join(' | ')); + assert.match(items[0] ?? '', /^top/); + // The child target's main frame's context, named by its document and origin, as with Chrome. + assert.match(items[1] ?? '', /^frame-link-child\.html127\.0\.0\.1:\d+$/); + await frontend.page().keyboard.press('Escape'); +}); diff --git a/tests/e2e/pages/oopif-frontend.html b/tests/e2e/pages/oopif-frontend.html new file mode 100644 index 0000000..23c815a --- /dev/null +++ b/tests/e2e/pages/oopif-frontend.html @@ -0,0 +1,28 @@ + + + + + oopif frontend + + + +

parent page

+ + + + + From 331af6e31f4a4f92e88e759e44f241f2b9390f73 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 21:36:06 +0800 Subject: [PATCH 065/168] fix(playground): listen on the loopback addresses themselves, not the wildcard The test server listened on the wildcard address (::, dual-stack) at a free port. macOS lets another process bind 127.0.0.1 at a port a wildcard socket already holds, and the kernel also hands out a wildcard port that is taken on 127.0.0.1; either way, connections to 127.0.0.1 at that port go to the other process. Tests running side by side bind such ports all the time (each Chrome's DevTools port is a free port on 127.0.0.1, as are the remote-server tests' servers). The OOPIF tests load their cross-site iframes from 127.0.0.1, so when the port collided the iframe's request went to a server that never answered, the parent's load event never came, and every test of the file timed out in page.goto: the environment was broken for the file's whole run. The server now listens on 127.0.0.1 and ::1 at one port (picking another port when ::1 is taken, and doing without ::1 where there is no IPv6), so no other process can bind those addresses at its port. --- playground/server.ts | 59 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/playground/server.ts b/playground/server.ts index 7bb9473..20099c9 100644 --- a/playground/server.ts +++ b/playground/server.ts @@ -115,7 +115,7 @@ export interface PlaygroundServer { } export function startServer(port = 0): Promise { - const server = http.createServer(async (req, res) => { + const handle: http.RequestListener = async (req, res) => { const url = new URL(req.url ?? '/', 'http://localhost'); const delay = Number(url.searchParams.get('delay')) || 0; if (delay) await sleep(delay); @@ -177,20 +177,63 @@ export function startServer(port = 0): Promise { } catch { res.writeHead(404).end('not found'); } - }); - server.on('upgrade', (req, socket) => { + }; + const upgrade = (req: http.IncomingMessage, socket: Duplex) => { if (new URL(req.url ?? '/', 'http://localhost').pathname === '/ws/echo') wsEcho(req, socket); else socket.destroy(); + }; + return listenOnLoopback(port, () => { + const server = http.createServer(handle); + server.on('upgrade', upgrade); + return server; }); +} + +/** Listens on `host` and `port`; resolves with the error code if that fails. */ +function listen(server: http.Server, port: number, host: string): Promise { return new Promise(resolve => { - server.listen(port, () => { - const address = server.address(); - const actual = typeof address === 'object' && address ? address.port : port; - resolve({ url: `http://localhost:${actual}`, close: () => server.close() }); - }); + server.once('error', (e: NodeJS.ErrnoException) => resolve(e.code ?? 'error')); + server.listen(port, host, () => resolve(null)); }); } +/** + * Listens on both loopback addresses, 127.0.0.1 and ::1, at one port: the pages reach the server + * as localhost and as 127.0.0.1. Not on the wildcard address: macOS lets another process bind + * 127.0.0.1 at a port a wildcard socket holds, and that process then gets every connection to + * 127.0.0.1 there. Tests running side by side do just that (each Chrome's DevTools port is a free + * port on 127.0.0.1), and a page's requests to 127.0.0.1 went to a server that never answered. + * Bound to the addresses themselves, the port is the server's alone. + */ +async function listenOnLoopback(port: number, create: () => http.Server): Promise { + for (let attempt = 0; ; attempt++) { + const v4 = create(); + const v4Error = await listen(v4, port, '127.0.0.1'); + if (v4Error) throw new Error(`the server cannot listen on 127.0.0.1:${port}: ${v4Error}`); + const address = v4.address(); + const actual = typeof address === 'object' && address ? address.port : port; + const v6 = create(); + const v6Error = await listen(v6, actual, '::1'); + // A free port on 127.0.0.1 may be taken on ::1: another one, when the port was ours to pick. + if (v6Error === 'EADDRINUSE' && port === 0 && attempt < 10) { + v4.close(); + continue; + } + // Without IPv6, localhost is 127.0.0.1 alone. + if (v6Error && v6Error !== 'EADDRNOTAVAIL' && v6Error !== 'EAFNOSUPPORT') { + v4.close(); + throw new Error(`the server cannot listen on [::1]:${actual}: ${v6Error}`); + } + return { + url: `http://localhost:${actual}`, + close: () => { + v4.close(); + if (!v6Error) v6.close(); + }, + }; + } +} + if (process.argv[1] === fileURLToPath(import.meta.url)) { const { url } = await startServer(Number(process.argv[2]) || 8788); console.log(`${url}/playground/`); From e150b26131f4940db899b8849d32bb06b3e7dfb6 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Mon, 2 Nov 2026 21:55:32 +0800 Subject: [PATCH 066/168] feat(cdp-agent): forward mouse and touch input over a cross-origin iframe to its agent Input.dispatchMouseEvent on the parent session at a point over a linked cross-origin iframe now goes to the iframe's agent, as Chrome routes it to the iframe's process: the same command at the point mapped into the iframe's viewport (through the iframe's content box and any transform), sent over the link as the agent's own command (an internal id the client never sees, as Target.getTargetInfo does) and answered once the iframe's agent carried it out. Wheel goes the same way; with touch emulation on, a touch that starts over the iframe (and Input.emulateTouchFromMouseEvent) stays with the iframe's agent till it ends, as a touch stays with what it touched. Measured in Chrome (site-per-process, parent on localhost, iframe on 127.0.0.1), and done the same: - Moving onto the iframe fires nothing in the parent's document, not even an over or out for the '; + }) + ), + 'Page.frameNavigated', + 2 + ); + await session.detach(); + const ours = await t.page.evaluate( + (names: string[]) => window.__ours.map(e => e.method).filter(m => names.includes(m)), + kept + ); + assert.deepEqual(ours, chrome); + assert.equal(ours[0], 'Page.frameDetached'); +}); + test('removing an iframe detaches its frames, the innermost first', async () => { await act( () => diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.conformance.test.ts b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.conformance.test.ts index 8eb4699..b2f19af 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.conformance.test.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.conformance.test.ts @@ -3,6 +3,9 @@ import assert from 'node:assert/strict'; import { launch, openPage } from '../../../../test-support/conformance.ts'; import type { Env, TestPage } from '../../../../test-support/conformance.ts'; +const CHILD = '/packages/devtools-anywhere/test-support/frame-child.html'; +const OTHER = '/packages/devtools-anywhere/test-support/frame-other.html'; + let env: Env | undefined; let t: TestPage; @@ -343,3 +346,41 @@ test('captureScreenshot draws again at half the size when drawing fails, not whe assert.equal(result.loads, 1); assert.match(result.error, /offline/); }); + +test('Page.navigate with a frameId navigates that frame, not the page', async () => { + await t.page.evaluate( + child => + new Promise(resolve => { + const frame = document.createElement('iframe'); + frame.id = 'navigated'; + frame.src = child; + frame.onload = resolve; + document.body.append(frame); + }), + CHILD + ); + const top = await t.page.evaluate(() => location.href); + // Absolute: Chrome checks the URL first, and rejects a relative one as invalid. + const url = new URL(OTHER, top).href; + const [ours, chrome] = await Promise.all( + [t.ours, t.chrome].map(async side => { + const { frameTree } = await side.send('Page.getFrameTree'); + const child = frameTree.childFrames?.find(c => c.frame.url.endsWith(CHILD)); + assert.ok(child); + await assert.rejects(side.send('Page.navigate', { url, frameId: 'nope' }), /No frame with given id found/); + await assert.rejects( + side.send('Page.navigate', { url: OTHER, frameId: 'nope' }), + /Cannot navigate to invalid URL/ + ); + const res = await side.send('Page.navigate', { url, frameId: child.frame.id }); + return res.frameId === child.frame.id; + }) + ); + assert.deepEqual([ours, chrome], [true, true]); + await t.page.waitForFunction( + other => document.querySelector('iframe#navigated')?.contentDocument?.URL.endsWith(other), + {}, + OTHER + ); + assert.equal(await t.page.evaluate(() => location.href), top); +}); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts index b247532..ec71a8f 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts @@ -2,15 +2,11 @@ import type { Protocol } from 'devtools-protocol'; import { readBody } from '../../core/body'; import type { Context } from '../../core/context'; import { CdpError } from '../../core/session'; +import { isChildDocument } from '../network/frames'; import { isOwnRequest, refetch } from '../network/network'; import { frameEvents, frameTree } from './frame-tree'; import { sameDocumentNavigations } from './history-navigations'; -/** Resource Timing initiators of documents: a frame's own, not resources of the page. */ -const DOCUMENT_INITIATORS = new Set(['navigation', 'iframe', 'frame']); -/** Initiators whose loads are a frame's document when they are documents, else the page's resources. */ -const EMBEDDING_INITIATORS = new Set(['object', 'embed']); - export function registerPage({ session, probe, frames }: Context) { let stopFrames: (() => void) | null = null; let stopSameDocument: (() => void) | null = null; @@ -47,8 +43,9 @@ export function registerPage({ session, probe, frames }: Context) { for (const rec of probe.requests) { if ( !rec.initiatorType || - DOCUMENT_INITIATORS.has(rec.initiatorType) || - (EMBEDDING_INITIATORS.has(rec.initiatorType) && rec.type === 'Document') || + // Documents are a frame's own, not resources of the page. + rec.initiatorType === 'navigation' || + isChildDocument(rec) || rec.type === 'Ping' || isOwnRequest(probe, rec) ) @@ -145,9 +142,17 @@ export function registerPage({ session, probe, frames }: Context) { reload: () => location.reload(), + /** The frame given, as in Chrome; the loader returned is that of the document it leaves. */ navigate(params: Protocol.Page.NavigateRequest): Protocol.Page.NavigateResponse { - location.href = params.url; - return { frameId: frames.top.id, loaderId: frames.top.loaderId }; + // The URL first, as in Chrome: it must be absolute. + if (!URL.canParse(params.url)) throw new CdpError('Cannot navigate to invalid URL'); + frames.sync(); + const frame = params.frameId === undefined ? frames.top : frames.byId(params.frameId); + if (!frame) throw new CdpError('No frame with given id found'); + const win = frame.document.defaultView; + if (!win) throw new CdpError('No frame with given id found'); + win.location.href = params.url; + return { frameId: frame.id, loaderId: frame.loaderId }; }, addScriptToEvaluateOnNewDocument: (): Protocol.Page.AddScriptToEvaluateOnNewDocumentResponse => ({ From 6c9d0938ba3b386d5a569000206dccb5ea687148 Mon Sep 17 00:00:00 2001 From: jerrychan7 Date: Wed, 4 Nov 2026 00:22:17 +0800 Subject: [PATCH 119/168] refactor(devtools-anywhere): deduplicate helpers and tidy frame links, shell and REPL code - Frame links: a re-handshake now waits for the previous link's endSession/reset before reusing the own session, so late disable/reset no longer wipes new commands; dedupe the session/channel forwarding wiring in child.ts. - Geometry: drop mapPoints in favor of mapQuad, export styleOf for realm-aware computed style, and reuse parentFrameDocument in overlay.ts. - DOM: queued iframe resend microtasks stop after DOM.disable; resendFrameOwner reuses previousVisibleSibling. - Input: clear touchIn when the link closes or touch mode ends; remove the unused links injection seam; share one hit test between touch() and mouse(). - Shell relay: honor sessionId when intercepting device metrics commands from child sessions and keep it in replies; the disconnect function clears the restoring timer and pagehide listener; gate test hooks behind DEVTOOLS_ANYWHERE_TEST; fix misplaced and stale comments; share the new-frontend-instance detection; document onDetach and pass options by name; rename DetachParams to match its use. - Fix the UNSUPPORTED comment in stubs.ts that contradicted the per-entry notes. - Probe: make hookNetwork required like hookWindow and fix the hookWindow doc comment. - Share the frame-document check with getResourceTree and replace network.ts classOf with objects.ts toTag. - REPL: let/const named like command line utilities persist across commands; share the global lexical declaration probe; dedupe stack trimming; drop a needless block; stop exposing __devtoolsAnywhereRepl__ as a writable page global. - CSS: take frames from ctx.frames only. - e2e: fix the misplaced showPanel JSDoc, and keep clicking in the Elements expand loop until the child target is grafted instead of breaking early. --- .../src/cdp-agent/core/container-query.ts | 13 +-- .../src/cdp-agent/core/geometry.ts | 59 ++++++---- .../src/cdp-agent/core/nodes.ts | 6 +- .../src/cdp-agent/core/objects.ts | 3 +- .../src/cdp-agent/core/probe-view.ts | 5 +- .../src/cdp-agent/core/realm.ts | 5 + .../src/cdp-agent/domains/css/cascade.ts | 2 +- .../src/cdp-agent/domains/css/css.ts | 4 +- .../src/cdp-agent/domains/css/rule-view.ts | 13 +-- .../src/cdp-agent/domains/css/rules.ts | 9 +- .../cdp-agent/domains/css/sheet-registry.ts | 19 +++- .../src/cdp-agent/domains/dom/dom.ts | 24 ++-- .../src/cdp-agent/domains/input/input.ts | 40 ++++--- .../src/cdp-agent/domains/network/frames.ts | 32 +++--- .../src/cdp-agent/domains/network/network.ts | 10 +- .../src/cdp-agent/domains/overlay/overlay.ts | 20 ++-- .../domains/runtime/lexical-names.ts | 67 ++++------- .../src/cdp-agent/domains/runtime/repl.ts | 72 +++++++----- .../src/cdp-agent/domains/stubs.ts | 3 +- .../src/cdp-agent/probe/index.ts | 4 +- .../src/cdp-agent/probe/types.ts | 10 +- packages/devtools-anywhere/src/connection.ts | 107 +++++++++++------- .../src/frame-links/child.ts | 41 ++++--- packages/devtools-anywhere/src/remote.ts | 4 +- packages/relay-core/src/protocol.ts | 2 +- packages/remote-server/src/relay.ts | 8 +- tests/e2e/oopif-frontend.test.ts | 23 ++-- 27 files changed, 332 insertions(+), 273 deletions(-) diff --git a/packages/devtools-anywhere/src/cdp-agent/core/container-query.ts b/packages/devtools-anywhere/src/cdp-agent/core/container-query.ts index 502c85e..030df21 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/container-query.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/container-query.ts @@ -1,4 +1,4 @@ -import { documentOf, isShadowRoot, realmOf } from './realm'; +import { documentOf, isShadowRoot, realmOf, styleOf } from './realm'; import { splitTopLevel } from './selectors'; /** @@ -102,7 +102,7 @@ function findContainer(start: Element | null, name: string, features: Set 0; const block = [...features].some(f => BLOCK_FEATURES.has(f)); for (let el = start; el; el = el.assignedSlot ?? parentOf(el)) { - const style = realmOf(el).getComputedStyle(el); + const style = styleOf(el); if (name && !style.containerName.split(/\s+/).includes(name)) continue; if (size) { const types = style.containerType.split(/\s+/); @@ -124,7 +124,7 @@ interface Box { function contentBox(el: Element): Box | null { // No box (display: none on it or an ancestor, display: contents): its size is unknown. if (!el.getClientRects().length) return null; - const style = realmOf(el).getComputedStyle(el); + const style = styleOf(el); let width = parseFloat(style.width); let height = parseFloat(style.height); if (Number.isNaN(width) || Number.isNaN(height)) return null; @@ -143,9 +143,8 @@ function length(value: string, box: Box): number | null { if (!m) return null; const n = Number(m[1]); const unit = (m[2] ?? '').toLowerCase(); - const realm = realmOf(box.el); - const { innerWidth, innerHeight } = realm; - const fontSize = (el: Element) => parseFloat(realm.getComputedStyle(el).fontSize); + const { innerWidth, innerHeight } = realmOf(box.el); + const fontSize = (el: Element) => parseFloat(styleOf(el).fontSize); switch (unit) { case '': return n === 0 ? 0 : null; @@ -268,7 +267,7 @@ function styleQuery(inner: string, container: Element | null): Kleene { const colon = inner.indexOf(':'); const name = (colon < 0 ? inner : inner.slice(0, colon)).trim(); if (!name.startsWith('--')) return null; - const actual = realmOf(container).getComputedStyle(container).getPropertyValue(name).trim(); + const actual = styleOf(container).getPropertyValue(name).trim(); if (colon < 0) return actual !== ''; return actual === inner.slice(colon + 1).trim(); } diff --git a/packages/devtools-anywhere/src/cdp-agent/core/geometry.ts b/packages/devtools-anywhere/src/cdp-agent/core/geometry.ts index 4386b41..d68e06c 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/geometry.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/geometry.ts @@ -1,4 +1,13 @@ -import { contentDocumentOf, isFrameOwner, isHtmlElement, isSvg, isSvgElement, realmOf } from './realm'; +import { + contentDocumentOf, + isFrameOwner, + isHtmlElement, + isShadowRoot, + isSvg, + isSvgElement, + realmOf, + styleOf, +} from './realm'; /** A CDP quad: x1 y1 … x4 y4, clockwise from the top left. */ export type Quad = number[]; @@ -13,21 +22,7 @@ export interface Box { export const rectQuad = (r: Box): Quad => [r.left, r.top, r.right, r.top, r.right, r.bottom, r.left, r.bottom]; /** The quad of `box` (in an element's local, untransformed coordinates) mapped through `m`. */ -export function mapQuad(m: DOMMatrixReadOnly, box: Box): Quad { - const corners: [number, number][] = [ - [box.left, box.top], - [box.right, box.top], - [box.right, box.bottom], - [box.left, box.bottom], - ]; - return corners.flatMap(([x, y]) => { - const p = m.transformPoint(new DOMPoint(x, y)); - return [p.x / p.w, p.y / p.w]; - }); -} - -/** The computed style of an element, in its own realm. */ -const styleOf = (el: Element) => realmOf(el).getComputedStyle(el); +export const mapQuad = (m: DOMMatrixReadOnly, box: Box): Quad => mapPoints(m, rectQuad(box)); /** An SVG element with a bounding box and screen CTM (`SVGGraphicsElement`), of any realm. */ const isSvgGraphics = (el: Element): el is SVGGraphicsElement => isSvgElement(el) && 'getBBox' in el; @@ -356,18 +351,36 @@ function expose(scroller: Element | Window, rect: Box, visible: Box): Box { }; } +/** The parent of an element in the flat tree: a slotted element's slot, a shadow root's host. */ +function flatParent(el: Element): Element | null { + const root = el.getRootNode(); + return el.assignedSlot ?? el.parentElement ?? (isShadowRoot(root) ? root.host : null); +} + /** * Scrolls an element into view like Chrome's DOM.scrollIntoViewIfNeeded: each scroll container - * holding it, the viewport of its document and then those of the documents of the iframes - * holding that one, from the inside out, each by itself "center if needed" ({@link exposeDelta}). + * holding it (up the flat tree, out of shadow trees), the viewport of its document and then those + * of the documents of the iframes holding that one, from the inside out, each by itself "center + * if needed" ({@link exposeDelta}). `part` is the area to expose, relative to the element's border + * box; the whole of it by default. */ -export function scrollIntoViewIfNeeded(el: Element): void { +export function scrollIntoViewIfNeeded( + el: Element, + part?: { x: number; y: number; width: number; height: number } +): void { const r = el.getBoundingClientRect(); - let rect: Box = { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; + let rect: Box = part + ? { + left: r.left + part.x, + top: r.top + part.y, + right: r.left + part.x + part.width, + bottom: r.top + part.y + part.height, + } + : { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; let doc = el.ownerDocument; - let from: Element | null = el.parentElement; + let from: Element | null = flatParent(el); for (;;) { - for (let a = from; a && a !== doc.documentElement && a !== doc.body; a = a.parentElement) { + for (let a = from; a && a !== doc.documentElement && a !== doc.body; a = flatParent(a)) { const style = styleOf(a); const clips = (v: string) => v !== 'visible' && v !== 'clip'; if (!clips(style.overflowX) && !clips(style.overflowY)) continue; @@ -383,7 +396,7 @@ export function scrollIntoViewIfNeeded(el: Element): void { if (!owner) return; rect = boundsOf(mapPoints(frameContentToViewport(owner), rectQuad(rect))); doc = owner.ownerDocument; - from = owner.parentElement; + from = flatParent(owner); } } diff --git a/packages/devtools-anywhere/src/cdp-agent/core/nodes.ts b/packages/devtools-anywhere/src/cdp-agent/core/nodes.ts index e7b7d28..aed694c 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/nodes.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/nodes.ts @@ -11,7 +11,7 @@ import { isFrameOwner, isHtml, isShadowRoot, - realmOf, + styleOf, type FrameOwner, } from './realm'; @@ -82,8 +82,8 @@ export type Inspectable = Node | PseudoElement; /** Whether `host` currently generates the pseudo-element. */ export function hasPseudoElement(host: Element, type: PseudoType): boolean { - if (type === 'marker') return realmOf(host).getComputedStyle(host).display.includes('list-item'); - const content = realmOf(host).getComputedStyle(host, `::${type}`).content; + if (type === 'marker') return styleOf(host).display.includes('list-item'); + const content = styleOf(host, `::${type}`).content; return content !== 'none' && content !== 'normal' && content !== ''; } diff --git a/packages/devtools-anywhere/src/cdp-agent/core/objects.ts b/packages/devtools-anywhere/src/cdp-agent/core/objects.ts index 4542254..78a80bb 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/objects.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/objects.ts @@ -37,7 +37,8 @@ class InternalEntry { } const TypedArray = Object.getPrototypeOf(Int8Array); -const toTag = (v: unknown) => Object.prototype.toString.call(v).slice(8, -1); +/** The class of a value of any realm, as `Object.prototype.toString` names it. */ +export const toTag = (v: unknown) => Object.prototype.toString.call(v).slice(8, -1); /** The native getter `proto[key]`; only used inside brand checks, where throwing means "no". */ const getter = (proto: object, key: PropertyKey) => { const get = Object.getOwnPropertyDescriptor(proto, key)?.get; diff --git a/packages/devtools-anywhere/src/cdp-agent/core/probe-view.ts b/packages/devtools-anywhere/src/cdp-agent/core/probe-view.ts index c07cf79..ea0e85c 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/probe-view.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/probe-view.ts @@ -1,6 +1,7 @@ // The probe records raw facts only, to stay small; this turns them into the entries and // records the domains use, once DevTools connects. import type { ConsoleEntry, ConsoleType, LogEntry, Probe, ProbeLog, ProbeRequest, RequestRecord } from '../probe/types'; +import { toTag } from './objects'; /** The probe as the domains see it: log entries and request records in their final form. */ export interface ProbeView extends Omit< @@ -84,7 +85,7 @@ function consoleEntry(call: Extract): Omit Object.prototype.toString.call(value) === '[object Headers]'; +const isHeaders = (value: object): value is Headers => toTag(value) === 'Headers'; function headersToObject(headers: Record | Headers | string): Record { const obj: Record = {}; @@ -285,7 +286,7 @@ export function viewProbe(probe: Probe): ProbeView { listeners: probe.listeners, ownUrls: probe.ownUrls, fetch: probe.fetch, - ...(probe.hookNetwork ? { hookNetwork: probe.hookNetwork } : {}), + hookNetwork: probe.hookNetwork, tools: probe.tools, bodyLimits(totalSize, resourceSize) { maxTotal = totalSize; diff --git a/packages/devtools-anywhere/src/cdp-agent/core/realm.ts b/packages/devtools-anywhere/src/cdp-agent/core/realm.ts index 3af8724..fcc452b 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/realm.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/realm.ts @@ -35,6 +35,11 @@ export function realmOf(node: Node): Window & typeof globalThis { return documentOf(node).defaultView ?? window; } +/** An element's computed style, from its own realm (the top page's `getComputedStyle` is not). */ +export function styleOf(el: Element, pseudo?: string): CSSStyleDeclaration { + return realmOf(el).getComputedStyle(el, pseudo); +} + const HTML_NS = 'http://www.w3.org/1999/xhtml'; /** Whether `node` is the HTML element ``, e.g. `isHtml(node, 'template')`. */ diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/css/cascade.ts b/packages/devtools-anywhere/src/cdp-agent/domains/css/cascade.ts index 243edc5..1698378 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/css/cascade.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/css/cascade.ts @@ -69,7 +69,7 @@ export class Cascade { const layerOrders = new Map>(); const pending: { sheet: CSSStyleSheet; tree: TreeScope; entry: Entry }[] = []; for (const { sheet, tree } of this.sheets.scoped()) { - if (sheet.disabled || !sheetApplies(sheet)) continue; + if (sheet.disabled || !sheetApplies(sheet, this.sheets.realmOf(sheet))) continue; let layerOrder = layerOrders.get(tree); if (!layerOrder) layerOrders.set(tree, (layerOrder = new Map())); for (const entry of this.sheets.entries(sheet, true, layerOrder)) pending.push({ sheet, tree, entry }); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/css/css.ts b/packages/devtools-anywhere/src/cdp-agent/domains/css/css.ts index 20f8dac..1b4ee1f 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/css/css.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/css/css.ts @@ -62,7 +62,7 @@ export function registerCss(ctx: Context) { return roots; } - const sheets = new SheetRegistry(ctx.probe.fetch, shadowRoots); + const sheets = new SheetRegistry(ctx.probe.fetch, shadowRoots, ctx.frames); const forced = new ForcedPseudoClasses( () => sheets.trees(), sheet => sheets.rulesOf(sheet) @@ -506,7 +506,7 @@ export function registerCss(ctx: Context) { trackComputedStyleUpdates: () => {}, takeComputedStyleUpdates: (): Protocol.CSS.TakeComputedStyleUpdatesResponse => ({ nodeIds: [] }), getEnvironmentVariables: (): Protocol.CSS.GetEnvironmentVariablesResponse => ({ - environmentVariables: environmentVariables(documents()[0]), + environmentVariables: environmentVariables(ctx.frames.top.document), }), setEffectivePropertyValueForNode(params: Protocol.CSS.SetEffectivePropertyValueForNodeRequest) { const el = element(params.nodeId); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/css/rule-view.ts b/packages/devtools-anywhere/src/cdp-agent/domains/css/rule-view.ts index b018a2d..2557578 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/css/rule-view.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/css/rule-view.ts @@ -4,16 +4,7 @@ import { queriedAxes } from '../../core/container-query'; import type { NodeRegistry } from '../../core/nodes'; import { specificity, splitTopLevel } from '../../core/selectors'; import type { TreeScope } from './forced-pseudo'; -import { - type StyleRule, - ancestorsOf, - mediaList, - realmOfSheet, - resolveSelector, - ruleType, - scopeText, - selectorLabel, -} from './rules'; +import { type StyleRule, ancestorsOf, mediaList, resolveSelector, ruleType, scopeText, selectorLabel } from './rules'; import type { SheetRegistry } from './sheet-registry'; import { sourceSelectors } from './source'; import { type CSSStyle, buildStyle } from './style'; @@ -85,7 +76,7 @@ export class RuleView { source: 'mediaRule', sourceURL: this.sheets.sourceURL(sheet), styleSheetId, - mediaList: mediaList(a.conditionText, realmOfSheet(sheet)), + mediaList: mediaList(a.conditionText, this.sheets.realmOf(sheet)), }); else if (isCssom(a, 'CSSSupportsRule')) supports.push({ text: a.conditionText, active: CSS.supports(a.conditionText), styleSheetId }); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/css/rules.ts b/packages/devtools-anywhere/src/cdp-agent/domains/css/rules.ts index 86ace8a..16c2f33 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/css/rules.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/css/rules.ts @@ -1,5 +1,5 @@ import type { Protocol } from 'devtools-protocol'; -import { frames } from '../../core/frames'; +import type { FrameRegistry } from '../../core/frames'; import { isCssom, realmOf } from '../../core/realm'; import { splitTopLevel } from '../../core/selectors'; @@ -118,9 +118,8 @@ export function importedLayer(sheet: CSSStyleSheet): string[] { * Whether a sheet's own conditions hold: the media of its ,