feat(browser): attribute native execution and listener sources (#917)
* feat(browser): attribute native execution and listener sources Integrate finite native precise-coverage windows and direct DOM listener inspection through shared CLI/MCP workflows, exact session script identities, inline source evidence and owned instrumentation cleanup. Refs #881, #774 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): preserve runtime identity and settle fixture cleanup Retain complete reported initiator objects, reject changing loader availability, propagate the selected operation through main-frame authorization and complete effect/help inventories. Close only the synthetic owned Chrome via its native endpoint, await process-group settlement and preserve primary verification errors during cleanup. Refs #881 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): settle invalid events and preserve document evidence Accept same-document URL changes with proven stable loader identity, retain metadata without inventing ownership, terminate malformed event collection immediately, and sanitize known transport URLs without erasing sourceURL declarations. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): end detached target windows and sanitize coverage identities Reuse the existing flat-session event matcher, retain prearming termination, and apply the source identity policy to coverage URLs. Add real MCP target-close verification and producer regressions for detachment orderings. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): retain final sample metadata and settle fatal protocol failures Keep source inventory open through the resetting sample, propagate fatal wire failures without outstanding commands, and check listener collection failures after final document validation. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): require default execution worlds for website attribution Preserve native context and world metadata without treating same-frame isolated or unknown contexts as website sources. Verify actual Chrome isolated function execution before excluding its coverage, and cover ambiguous world metadata with producer regressions. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): revalidate runtime source joins and selector failures Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
parent
ef1a0cc65a
commit
b5afa5f91e
|
|
@ -0,0 +1,54 @@
|
|||
name: Real website runtime attribution
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-web-runtime.yml"
|
||||
- "scripts/verify-browser-runtime.mjs"
|
||||
- "scripts/lib/browser-runtime-*.mjs"
|
||||
- "src/process/ProviderProcess.ts"
|
||||
- "src/process/ProcessOwnership*.ts"
|
||||
- "scripts/fixtures/browser-runtime-site.mjs"
|
||||
- "src/browser/execution/**"
|
||||
- "src/browser/CdpConnection.ts"
|
||||
- "src/browser/CdpEndpoint.ts"
|
||||
- "src/browser/CdpTarget*.ts"
|
||||
- "src/browser/CdpCaptureValues.ts"
|
||||
- "src/browser/CdpAuthorized*.ts"
|
||||
- "src/domain/webRuntime.ts"
|
||||
- "src/domain/webExecution.ts"
|
||||
- "src/domain/webEventListeners.ts"
|
||||
- "src/domain/browserObservationError*.ts"
|
||||
- "src/application/WebRuntime*.ts"
|
||||
- "src/composition/webRuntime.ts"
|
||||
- "src/contracts/webRuntimeToolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/server/registerWebRuntimeTools.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/cli/webRuntimeCommands.ts"
|
||||
- "src/cli/commandCancellation.ts"
|
||||
- "src/cli.ts"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: real-web-runtime-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
HUSKY: "0"
|
||||
jobs:
|
||||
real-web-runtime:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run verify:browser:runtime
|
||||
env:
|
||||
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
|
||||
REA_BROWSER_NO_SANDBOX: "true"
|
||||
|
|
@ -410,7 +410,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
|||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||
| Browser observation | 11 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
||||
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
||||
| Application workflows | 13 | captured website script export; Android/Apple inventory projections; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
|
|
@ -434,6 +434,10 @@ Static Android APK inspection supports Linux and macOS; the current metadata bri
|
|||
and tracing tools. Source URLs, frame or transaction references, competing
|
||||
versions, and missing-source states remain inline. See
|
||||
[captured website scripts](docs/website-script-export.md).
|
||||
Inspect a selected node's listener sources or observe an externally triggered
|
||||
execution window with `inspect_web_event_listeners` / `observe_web_execution`.
|
||||
Precise coverage resets counters and affects optimized execution; see
|
||||
[website runtime attribution](docs/web-runtime.md).
|
||||
Trace one exported script's native imports with `trace_web_module_imports` /
|
||||
`rea trace-web-module-imports`, preserving query/fragment identity and optional
|
||||
import-map context. Requires caller-supplied Chromium via
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ rea setup
|
|||
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
|
||||
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
|
||||
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| مراقبة المتصفح | 11 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
||||
| سير عمل التطبيقات | 13 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
|||
| マネージド PE/CLI | 7 | .NET の識別、メタデータ、CIL 命令、ネイティブ依存関係、再構築インポート、ビルド比較 |
|
||||
| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
|
||||
| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッド逆コンパイル、静的参照 |
|
||||
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
||||
| ブラウザー観察 | 11 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
||||
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
|
||||
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
|
||||
| アプリワークフロー | 13 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
|||
| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
|
||||
| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
|
||||
| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
|
||||
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| 브라우저 관찰 | 11 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
||||
| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
|||
| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
|
||||
| 固件 | 2 | Linux 固件区域检查与显式提取 |
|
||||
| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
|
||||
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| 浏览器观察 | 11 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
||||
| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ graph TD
|
|||
IDA["src/ida/<br/>Upstream MCP contract adaptation<br/>live GUI binding or owned headless database"]
|
||||
IDA_MCP["External ida-pro-mcp<br/>GUI proxy or database supervisor<br/>upstream installation and engine lifecycle"]
|
||||
AUX["src/artifacts/ + src/native/<br/>disjoint artifact and native capabilities"]
|
||||
BROWSER["src/browser/<br/>CDP / Playwright capture<br/>captured scripts and native module resolution"]
|
||||
BROWSER["src/browser/<br/>CDP / Playwright capture<br/>captured scripts and native module resolution<br/>execution/: coverage, listener and source attribution"]
|
||||
JS_CODEC["src/javascript/sourceMaps/<br/>pinned upstream map codec<br/>owned heap-limited Node process, complete point evidence"]
|
||||
INSPECTOR["src/inspector/<br/>passive V8 Inspector discovery and capture<br/>script and target locations"]
|
||||
DOTNET["src/dotnet/<br/>Managed static metadata provider<br/>PE/CLI metadata inventory, members, IL"]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"skill_version": "27"
|
||||
},
|
||||
"tools": {
|
||||
"total": 131,
|
||||
"total": 133,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
|
|
@ -142,7 +142,7 @@
|
|||
{
|
||||
"id": "browser",
|
||||
"surface": "browser-provider",
|
||||
"count": 9,
|
||||
"count": 11,
|
||||
"tools": [
|
||||
"analyze_web_bundle",
|
||||
"capture_browser_scenario",
|
||||
|
|
@ -150,8 +150,10 @@
|
|||
"compare_web_captures",
|
||||
"compare_web_screenshots",
|
||||
"discover_webmcp_tools",
|
||||
"inspect_web_event_listeners",
|
||||
"inspect_web_page",
|
||||
"list_browser_targets",
|
||||
"observe_web_execution",
|
||||
"observe_web_session"
|
||||
]
|
||||
},
|
||||
|
|
@ -389,8 +391,10 @@
|
|||
"compare_web_captures",
|
||||
"compare_web_screenshots",
|
||||
"discover_webmcp_tools",
|
||||
"inspect_web_event_listeners",
|
||||
"inspect_web_page",
|
||||
"list_browser_targets",
|
||||
"observe_web_execution",
|
||||
"observe_web_session"
|
||||
]
|
||||
},
|
||||
|
|
@ -566,7 +570,7 @@
|
|||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 86,
|
||||
"primary_count": 88,
|
||||
"commands": [
|
||||
"address-to-file-offset",
|
||||
"analyze",
|
||||
|
|
@ -622,6 +626,7 @@
|
|||
"inspect-native-load-image",
|
||||
"inspect-plist",
|
||||
"inspect-signature",
|
||||
"inspect-web-event-listeners",
|
||||
"inspect-web-page",
|
||||
"instructions",
|
||||
"list-architectures",
|
||||
|
|
@ -630,6 +635,7 @@
|
|||
"list-javascript-runtime-targets",
|
||||
"observe-javascript-runtime",
|
||||
"observe-native-ui",
|
||||
"observe-web-execution",
|
||||
"observe-web-session",
|
||||
"project-android-application-graph",
|
||||
"project-apple-application-graph",
|
||||
|
|
@ -664,15 +670,15 @@
|
|||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 86,
|
||||
"mcp_tools": 131,
|
||||
"cli_commands": 88,
|
||||
"mcp_tools": 133,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "f49e05c52c6fd9955edcede27fbaaa248fdb917caa7b27edb8d97f7a30ad250e",
|
||||
"tools_sha256": "4e80c4f1c50b30bd69b521b19f442d2c2ffb4211e8948d555f7e9450ae2b6958",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "54ac160db3b96580a9a0203a07fe3db01ffc05224db621f48b4abdf979e4f50b",
|
||||
"providers_sha256": "a7d885531ee25d42c51385444faec544d34cd7eba6250a7dc75c4faedd4454a3"
|
||||
"combined_sha256": "f2d8d03be076d9b0483e6a167cc695f91302b6155ef6bba040d33917062c866c",
|
||||
"providers_sha256": "aea15f517c822d80918ccaba57a4fa06557f484cf27b8acbaed4e1a660fac896"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -543,3 +543,19 @@ checks an installed package after building the verifier dependencies. The separa
|
|||
conditional `real-web-source-map` CI job supplies Chrome and an isolated pinned
|
||||
fixture compiler; static/unit checks do not acquire a browser. See
|
||||
[the source location guide](web-source-location.md) for the verified decoder profile.
|
||||
|
||||
### Website runtime attribution lane
|
||||
|
||||
`npm run verify:browser:runtime` uses caller-supplied
|
||||
`REA_BROWSER_EXECUTABLE` and an owned synthetic site/profile. It exercises public
|
||||
CLI and stdio MCP for precise execution and native listener source locations,
|
||||
including actual armed progress, Unicode/CRLF digests, repeated source URLs with
|
||||
distinct script IDs, zero branches and function-only unknowns on repeated
|
||||
coverage, request initiators and an externally owned page that remains open.
|
||||
|
||||
An optional entrypoint argument to `scripts/verify-browser-runtime.mjs` runs the
|
||||
same checks through an isolated installed package. The conditional
|
||||
`real-web-runtime` CI job runs only for relevant changes and needs no fixture
|
||||
compiler. Ordinary unit/static gates acquire no browser. See
|
||||
[website runtime attribution](web-runtime.md) for effects, resource bounds and
|
||||
coverage limits.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"manifest_id": "ecm_e7e60d5d08bfe335c4c0b38ab4598eb634444b835202e325241749d152808c94",
|
||||
"manifest_id": "ecm_f7875d899da42bc030f7bdf8c688d6df097a1cd8c02a179f9911f1cad89d86b0",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "265556bd52f52ad829187fe71831cf05329248286c86e3102f0cdbf8841e1d6d"
|
||||
"sha256": "317c4116deb414b40955ab1dd00012950d78eaa7a2de2cbf8ae0f7ecc6dfb5a9"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
# Website runtime source attribution
|
||||
|
||||
REA integrates native Chrome DevTools Protocol inspection and precise V8 coverage through two composable tools. Supply an existing loopback CDP endpoint and a page target from `list_browser_targets`. With omitted origins, REA selects the page's current HTTP(S) origin. The browser and page remain externally owned.
|
||||
|
||||
## Observe execution
|
||||
|
||||
```bash
|
||||
rea observe-web-execution http://127.0.0.1:9222 TARGET_ID \
|
||||
--observation-ms 10000 --json
|
||||
```
|
||||
|
||||
MCP: `observe_web_execution` with `cdp_endpoint`, `target_id`, optional `allowed_origins` and optional `observation_ms` (default 10000). The operation itself selects instrumentation; no extra approval flag is needed.
|
||||
|
||||
Starting precise coverage resets counters and prevents optimized execution until REA stops it. This changes timing/JIT behavior. REA requests accurate call counts and detailed ranges, takes one resetting sample, stops coverage, disables enabled domains and detaches its own connection. It does not navigate, click, evaluate selected application code or close the page.
|
||||
|
||||
Wait for the actual armed progress event before performing an action. MCP emits negotiated progress notifications; CLI emits `rea_progress` JSON on stderr while the Evidence result stays on stdout. Use ordinary commands, scripts or user actions to trigger behavior. CLI SIGINT/SIGTERM and MCP cancellation await instrumentation cleanup; unconfirmed cleanup is an error with the target, failed commands and previous failure.
|
||||
|
||||
Results retain:
|
||||
|
||||
- The selected target, frame and document loader identity; local armed/end timestamps and separate backend monotonic coverage timestamps.
|
||||
- Every admitted producer function/block range, exclusive end offset and execution count in UTF-16 code units, with verified bounds when exact text is available. Nested/overlapping counts are preserved and must not be summed.
|
||||
- Main-document script inventory, exact session script IDs, resource start coordinates, sourceURL/source-map declarations, producer hash and independently computed UTF-8 text digest.
|
||||
- Armed-window requests from the selected main frame and selected origins, complete reported initiator objects (including stack hierarchy/descriptions), derived callsites, exact script-ID associations and unresolved async parent IDs. Source association does not establish UI causality.
|
||||
|
||||
The coverage counter interval starts at backend acceptance before the armed receipt and ends at the resetting sample after the local timer. Counts may include execution during these command intervals; request events are restricted to the locally armed window. The result retains both backend timestamps and the distinct local clock.
|
||||
|
||||
Source metadata remains collected through receipt of the resetting sample, including scripts parsed during the final command. REA then freezes this inventory for source joins. Fatal protocol parsing failures terminate the observer even when no command is outstanding; listener inspection checks producer failures after its final document assertion.
|
||||
|
||||
A document replacement ends the window; final coverage then remains unavailable. Missing coverage is unknown: an already loaded script may be absent from a sample. A detailed request also does not guarantee block granularity for already compiled functions. If `is_block_coverage` is false, the function-only ranges are observed and branch execution remains unknown. Native Chrome verification reproduced block coverage in the first window and function-only coverage in a subsequent window, including without REA.
|
||||
|
||||
Same-document navigation such as `history.pushState()` remains valid when the frame and known loader identity stay stable and the current origin is allowed. Unknown loader identity retains a conservative URL check; newly reported or replaced loaders are rejected. Malformed producer events end an armed window immediately and trigger cleanup, independently of the selected duration.
|
||||
|
||||
Target detachment ends the window even when the browser's root connection stays open. When the closed session cannot acknowledge cleanup, the operation returns a failure with the selected target, failed commands and original producer reason; it does not claim successful cleanup.
|
||||
|
||||
Child frames, workers, service workers, WebAssembly, chronological execution order and framework-specific causal interpretation are outside this operation. Script ownership comes from frame/context metadata, never URL equality. Repeated URLs and synthetic names therefore retain separate identities.
|
||||
|
||||
Observed script metadata remains available when ownership is unknown; source text and source association remain excluded or unknown. Request/initiator resource URLs omit transport userinfo. Stack and script URLs can also be inert `sourceURL` names: REA preserves declared names and unknown representations, and only removes script URL userinfo when stable producer metadata explicitly reports `hasSourceURL: false`. This policy also applies when metadata arrives after a request. Unknown producer extension fields and source-map declarations are preserved.
|
||||
|
||||
## Inspect a node's listeners
|
||||
|
||||
```bash
|
||||
rea inspect-web-event-listeners http://127.0.0.1:9222 TARGET_ID '#submit' --json
|
||||
```
|
||||
|
||||
MCP: `inspect_web_event_listeners` with the same scope and an explicit CSS `selector`. Native DOM selection inspects the first matching node in the main document. A missing match is an explicit empty result.
|
||||
|
||||
The result contains directly registered listener types/flags, callback coordinates, source identity/text/digest and execution unknowns. REA uses native DOM/DOMDebugger operations and releases its own remote object group. It never invokes a listener or dispatches an event. Delegated ancestor listeners, descendants, closed shadow roots and framework registries are not enumerated.
|
||||
|
||||
## Bounds and verification
|
||||
|
||||
Each protocol message has a 64 MiB budget; script/context metadata and request metadata each have an 8 MiB retained budget. Complete retained source text has a 32 MiB budget. Oversized or malformed evidence returns no partial successful result. Each command and the complete inspection/source-join phase have a 20-second deadline; cleanup has a separate 5-second command deadline plus transport closure. The selected observation duration must fit the host timer's positive 32-bit millisecond range. REA does not control the externally owned browser's CPU or memory.
|
||||
|
||||
`npm run verify:browser:runtime` uses caller-supplied `REA_BROWSER_EXECUTABLE` and the native `ps` command on Linux/macOS against an owned synthetic fixture, through real public CLI and stdio MCP. It checks exact Unicode/CRLF text/digests, same-URL identities, same-document navigation, inert declared names, executed and zero-count branches, function-only unknowns on repeated observation, listener selection, initiator association, actual arming and preservation of the external page. A separate fixture page closes during an armed MCP window to verify prompt termination and inline cleanup diagnostics. The optional positional entrypoint to `scripts/verify-browser-runtime.mjs` permits the same checks against an isolated installed tarball.
|
||||
|
||||
Real workflow coverage is Linux x64 / Chrome 153 / Node 24. Other browser engines and host workflows remain unverified. The conditional `real-web-runtime` CI lane acquires Chrome only for relevant changes; ordinary unit/static checks need no browser.
|
||||
|
||||
Primary protocol references: [Profiler and Debugger](https://raw.githubusercontent.com/ChromeDevTools/devtools-protocol/master/pdl/js_protocol.pdl), [DOMDebugger](https://raw.githubusercontent.com/ChromeDevTools/devtools-protocol/master/pdl/domains/DOMDebugger.pdl).
|
||||
|
||||
Source ownership requires both the selected main frame and producer evidence for its default execution world. Extension/automation isolated worlds, worker worlds and unknown worlds cannot establish website source joins. Contradictory world metadata stays unowned. Referenced source records retain the reported execution context and script auxiliary context metadata; source text is read only for proven main-document default worlds.
|
||||
|
||||
Known script/context identities are monitored through asynchronous source completion after the sample cutoff. A changed identity excludes its retained text, frame attribution, coverage and request/source joins. Chromium's specific native CSS syntax rejection returns an input error for `selector`, including the selected target; unrelated node, command and transport failures retain their original category.
|
||||
|
|
@ -187,7 +187,8 @@
|
|||
"verify:test-discovery": "node scripts/verify-test-discovery.mjs",
|
||||
"verify:javascript:recovery": "node scripts/verify/javascript/recovery.mjs",
|
||||
"verify:module-boundaries": "node scripts/verify-module-boundaries.mjs",
|
||||
"verify:browser:source-maps": "npm run build:cached && node scripts/verify-browser-source-maps.mjs"
|
||||
"verify:browser:source-maps": "npm run build:cached && node scripts/verify-browser-source-maps.mjs",
|
||||
"verify:browser:runtime": "npm run build:cached && node scripts/verify-browser-runtime.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import { createServer } from "node:http";
|
||||
|
||||
/** Owned synthetic site keeps source bytes, repeated URLs and expected execution independent of REA. */
|
||||
export async function startBrowserRuntimeSite() {
|
||||
let origin;
|
||||
let evidenceRequests = 0;
|
||||
const sources = () => ({
|
||||
selected: `// 🔥 UTF-16 prefix\r\nfunction chosen() {\r\n history.pushState({}, '', '/same-document?caller=value#fragment');\r\n fetch('/evidence?marker=chosen');\r\n if (document.body.dataset.branch === 'unused') { fetch('/never'); }\r\n}\r\ndocument.querySelector('#run').addEventListener('click', chosen, { passive: true });\r\n//# sourceURL=${origin.replace("http://", "http://declared:label@")}/same.js\r\n`,
|
||||
other: `function untouched() { return 'other source'; }\r\ndocument.querySelector('#child').addEventListener('click', untouched);\r\n//# sourceURL=${origin.replace("http://", "http://declared:label@")}/same.js\r\n`,
|
||||
});
|
||||
const server = createServer((request, response) => {
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
if (request.url?.startsWith("/evidence")) {
|
||||
evidenceRequests += 1;
|
||||
response.end("ok");
|
||||
return;
|
||||
}
|
||||
if (request.url === "/a.js" || request.url === "/b.js") {
|
||||
response.setHeader(
|
||||
"content-type",
|
||||
"application/javascript; charset=utf-8",
|
||||
);
|
||||
response.end(
|
||||
request.url === "/a.js" ? sources().selected : sources().other,
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.end(
|
||||
'<!doctype html><meta charset="utf-8"><button id="run"><span id="child">Run</span></button><script src="/a.js"></script><script src="/b.js"></script>',
|
||||
);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Runtime fixture did not bind TCP");
|
||||
origin = `http://127.0.0.1:${address.port}`;
|
||||
return {
|
||||
origin,
|
||||
sources: sources(),
|
||||
evidenceRequests: () => evidenceRequests,
|
||||
close: () =>
|
||||
new Promise((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,445 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawn, execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { access } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { CdpBrowserProvider } from "../../dist/browser/CdpBrowserProvider.js";
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
import { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js";
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
import { webExecutionSchema } from "../../dist/domain/webExecution.js";
|
||||
import { webEventListenersSchema } from "../../dist/domain/webEventListeners.js";
|
||||
import { startBrowserRuntimeSite } from "../fixtures/browser-runtime-site.mjs";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
import {
|
||||
startRuntimeFixtureBrowser,
|
||||
closeRuntimeFixtureResources,
|
||||
} from "./browser-runtime-fixture-lifecycle.mjs";
|
||||
import { verifyRuntimeTargetTermination } from "./browser-runtime-termination-e2e.mjs";
|
||||
import { verifyRuntimeInvalidSelectors } from "./browser-runtime-selector-e2e.mjs";
|
||||
import {
|
||||
createRuntimeIsolatedWorld,
|
||||
triggerRuntimeFixtureAction,
|
||||
isolatedFixtureUrl,
|
||||
} from "./browser-runtime-world-e2e.mjs";
|
||||
|
||||
/** Exercise native browser runtime attribution through public CLI and stdio MCP, including arming. */
|
||||
export async function verifyBrowserRuntime(
|
||||
executable,
|
||||
entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
|
||||
) {
|
||||
await access(executable);
|
||||
const site = await startBrowserRuntimeSite();
|
||||
let fixture;
|
||||
let primaryError;
|
||||
let stderr = "";
|
||||
let action;
|
||||
let client;
|
||||
try {
|
||||
fixture = await startRuntimeFixtureBrowser(executable, site.origin);
|
||||
const { browser, profile } = fixture;
|
||||
browser.stderr.on("data", (chunk) => {
|
||||
if (stderr.length < 65536) stderr += chunk;
|
||||
});
|
||||
const port = await waitForBrowserDevtoolsPort({
|
||||
child: browser,
|
||||
executable,
|
||||
activePortPath: join(profile, "DevToolsActivePort"),
|
||||
stderr: () => stderr,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const endpoint = `http://127.0.0.1:${port}`;
|
||||
const targets = await new CdpBrowserProvider().listTargets({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [site.origin],
|
||||
});
|
||||
if (!targets.ok) throw targets.error;
|
||||
const target = targets.value.targets[0];
|
||||
assert.ok(target, "owned fixture page target missing");
|
||||
const pages = await (await fetch(`${endpoint}/json/list`)).json();
|
||||
const page = pages.find((candidate) => candidate.id === target.target_id);
|
||||
action = await CdpConnection.connect(
|
||||
page.webSocketDebuggerUrl,
|
||||
"inspect_web_page",
|
||||
);
|
||||
await ready(action);
|
||||
let isolatedContext = await createRuntimeIsolatedWorld(action);
|
||||
const env = {
|
||||
...process.env,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper",
|
||||
};
|
||||
const input = { cdp_endpoint: endpoint, target_id: target.target_id };
|
||||
const cliListener = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-web-event-listeners",
|
||||
endpoint,
|
||||
target.target_id,
|
||||
"#run",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 40_000, maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
const cliListenerProof = assertListeners(
|
||||
parseEvidence(JSON.parse(cliListener.stdout)),
|
||||
site,
|
||||
);
|
||||
const cliExecution = await executionCli(
|
||||
entrypoint,
|
||||
input,
|
||||
env,
|
||||
action,
|
||||
isolatedContext,
|
||||
);
|
||||
const cliProof = assertExecution(
|
||||
parseEvidence(JSON.parse(cliExecution)),
|
||||
site,
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
client = new Client({ name: "web-runtime-real-e2e", version: "1" });
|
||||
await client.connect(transport);
|
||||
const invalidSelectors = await verifyRuntimeInvalidSelectors(
|
||||
client,
|
||||
entrypoint,
|
||||
input,
|
||||
env,
|
||||
);
|
||||
const listenerResponse = await client.callTool({
|
||||
name: "inspect_web_event_listeners",
|
||||
arguments: { ...input, selector: "#run" },
|
||||
});
|
||||
assert.notEqual(
|
||||
listenerResponse.isError,
|
||||
true,
|
||||
mcpTextValue(listenerResponse),
|
||||
);
|
||||
const mcpListenerProof = assertListeners(
|
||||
parseEvidence(JSON.parse(mcpTextValue(listenerResponse)).evidence),
|
||||
site,
|
||||
);
|
||||
const mcpProof = await executionMcp(
|
||||
client,
|
||||
input,
|
||||
action,
|
||||
site,
|
||||
isolatedContext,
|
||||
false,
|
||||
);
|
||||
assert.equal(cliProof.selected_sha256, mcpProof.selected_sha256);
|
||||
assert.equal(cliListenerProof.sha256, mcpListenerProof.sha256);
|
||||
await reloadFixture(action);
|
||||
isolatedContext = await createRuntimeIsolatedWorld(action);
|
||||
const freshMcpProof = await executionMcp(
|
||||
client,
|
||||
input,
|
||||
action,
|
||||
site,
|
||||
isolatedContext,
|
||||
);
|
||||
assert.equal(site.evidenceRequests(), 3);
|
||||
const termination = await verifyRuntimeTargetTermination(
|
||||
client,
|
||||
input,
|
||||
site.origin,
|
||||
);
|
||||
const stillOpen = await action.send("Runtime.evaluate", {
|
||||
expression: "Boolean(document.querySelector('#run'))",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.equal(
|
||||
stillOpen.result.value,
|
||||
true,
|
||||
"REA must preserve the externally owned page",
|
||||
);
|
||||
const location = await action.send("Runtime.evaluate", {
|
||||
expression: "location.href",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.equal(
|
||||
location.result.value,
|
||||
`${site.origin}/same-document?caller=value#fragment`,
|
||||
);
|
||||
return {
|
||||
product: target,
|
||||
cli: cliProof,
|
||||
mcp: mcpProof,
|
||||
listener: cliListenerProof,
|
||||
public_cases: 8,
|
||||
invalid_selectors: invalidSelectors,
|
||||
termination,
|
||||
fresh_mcp: freshMcpProof,
|
||||
page_remained_open: true,
|
||||
};
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await closeRuntimeFixtureResources(
|
||||
[
|
||||
() => client?.close(),
|
||||
() => action?.close(),
|
||||
() => fixture?.close(),
|
||||
() => site.close(),
|
||||
],
|
||||
primaryError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function executionMcp(
|
||||
client,
|
||||
input,
|
||||
action,
|
||||
site,
|
||||
isolatedContext,
|
||||
requireBlock = true,
|
||||
) {
|
||||
let actionPromise;
|
||||
const response = await client.callTool(
|
||||
{
|
||||
name: "observe_web_execution",
|
||||
arguments: { ...input, observation_ms: 300 },
|
||||
},
|
||||
{
|
||||
timeout: 40_000,
|
||||
onprogress: (notification) => {
|
||||
if (
|
||||
actionPromise === undefined &&
|
||||
notification.message?.includes(
|
||||
"browser_execution: Browser execution observation armed",
|
||||
)
|
||||
) {
|
||||
actionPromise = triggerRuntimeFixtureAction(action, isolatedContext);
|
||||
void actionPromise.catch(() => undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(actionPromise, "MCP did not emit an actual armed notification");
|
||||
await actionPromise;
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
return assertExecution(
|
||||
parseEvidence(JSON.parse(mcpTextValue(response)).evidence),
|
||||
site,
|
||||
requireBlock,
|
||||
);
|
||||
}
|
||||
|
||||
async function ready(connection) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const result = await connection.send("Runtime.evaluate", {
|
||||
expression:
|
||||
"typeof chosen === 'function' && typeof untouched === 'function'",
|
||||
returnByValue: true,
|
||||
});
|
||||
if (result.result.value === true) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("Runtime fixture sources did not load");
|
||||
}
|
||||
|
||||
async function executionCli(entrypoint, input, env, action, isolatedContext) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"observe-web-execution",
|
||||
input.cdp_endpoint,
|
||||
input.target_id,
|
||||
"--observation-ms",
|
||||
"300",
|
||||
"--json",
|
||||
],
|
||||
{ env, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let actionPromise;
|
||||
const timeout = setTimeout(() => child.kill("SIGTERM"), 40_000);
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
if (actionPromise === undefined && stderr.includes('"completed":1')) {
|
||||
actionPromise = triggerRuntimeFixtureAction(action, isolatedContext);
|
||||
void actionPromise.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const [code] = await once(child, "close");
|
||||
assert.equal(code, 0, stderr);
|
||||
assert.ok(actionPromise, "CLI did not report an actual armed point");
|
||||
await actionPromise;
|
||||
return stdout;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
function sourceByText(result, text) {
|
||||
const source = result.sources.find(
|
||||
(item) => item.source.state === "captured" && item.source.text === text,
|
||||
);
|
||||
assert.ok(source, "Exact independently known source text must be retained");
|
||||
assert.equal(
|
||||
source.source.sha256,
|
||||
createHash("sha256").update(text).digest("hex"),
|
||||
);
|
||||
assert.equal(source.source.utf16_units, text.length);
|
||||
assert.equal(source.source.utf8_bytes, Buffer.byteLength(text));
|
||||
return source;
|
||||
}
|
||||
function assertListeners(evidence, site) {
|
||||
const result = webEventListenersSchema.parse(evidence.normalized_result);
|
||||
assert.equal(
|
||||
result.listeners.length,
|
||||
1,
|
||||
"The selected node's child listener must not be attributed to the selected node",
|
||||
);
|
||||
const listener = result.listeners.find((item) => item.type === "click");
|
||||
assert.ok(listener);
|
||||
assert.equal(listener.passive, true);
|
||||
const source = sourceByText(result, site.sources.selected);
|
||||
assert.equal(listener.location.script_id, source.script_id);
|
||||
assert.equal(listener.location.source_association, "script_id");
|
||||
assert.equal(listener.execution, "unknown");
|
||||
return {
|
||||
sha256: source.source.sha256,
|
||||
line: listener.location.line_number,
|
||||
column: listener.location.column_number,
|
||||
};
|
||||
}
|
||||
function assertExecution(evidence, site, requireBlock = true) {
|
||||
const result = webExecutionSchema.parse(evidence.normalized_result);
|
||||
assert.ok(
|
||||
result.coverage.scripts.every(
|
||||
(item) => item.reported_url !== isolatedFixtureUrl,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
result.sources.every(
|
||||
(item) =>
|
||||
item.url !== isolatedFixtureUrl || item.source.state !== "captured",
|
||||
),
|
||||
);
|
||||
const selected = sourceByText(result, site.sources.selected);
|
||||
const other = sourceByText(result, site.sources.other);
|
||||
assert.equal(selected.url, other.url);
|
||||
assert.equal(selected.has_source_url, true);
|
||||
assert.equal(
|
||||
selected.url,
|
||||
`${site.origin.replace("http://", "http://declared:label@")}/same.js`,
|
||||
);
|
||||
assert.notEqual(selected.script_id, other.script_id);
|
||||
assert.ok(
|
||||
selected.source.utf8_bytes > selected.source.utf16_units,
|
||||
"Unicode oracle must distinguish UTF-8 bytes from UTF-16 units",
|
||||
);
|
||||
const coverage = result.coverage.scripts.find(
|
||||
(script) => script.script_id === selected.script_id,
|
||||
);
|
||||
const fn = coverage?.functions.find((item) => item.name === "chosen");
|
||||
assert.ok(
|
||||
fn,
|
||||
"Externally invoked callback must have precise function evidence",
|
||||
);
|
||||
assert.ok(fn.ranges.some((range) => range.count === 1));
|
||||
if (requireBlock)
|
||||
assert.equal(
|
||||
fn.is_block_coverage,
|
||||
true,
|
||||
"Fresh fixture function must supply block granularity",
|
||||
);
|
||||
if (fn.is_block_coverage)
|
||||
assert.ok(
|
||||
fn.ranges.some((range) => range.count === 0),
|
||||
`Unexecuted branch must remain zero within block coverage: ${JSON.stringify(fn)}`,
|
||||
);
|
||||
assert.equal(
|
||||
fn.ranges[0].start_offset,
|
||||
site.sources.selected.indexOf("function chosen"),
|
||||
"Coverage offsets must index the retained UTF-16 source, including the astral prefix",
|
||||
);
|
||||
for (const range of fn.ranges) {
|
||||
assert.equal(range.source_bounds, "verified");
|
||||
assert.ok(range.end_offset <= selected.source.utf16_units);
|
||||
}
|
||||
const request = result.requests.find((item) =>
|
||||
item.url.endsWith("/evidence?marker=chosen"),
|
||||
);
|
||||
assert.ok(request, "The selected request initiator must be retained");
|
||||
assert.ok(
|
||||
request.callsites.some(
|
||||
(site) =>
|
||||
site.script_id === selected.script_id &&
|
||||
site.source_association === "script_id",
|
||||
),
|
||||
);
|
||||
assert.equal(request.causal_attribution, "unknown");
|
||||
assert.ok(
|
||||
request.callsites.some(
|
||||
(site) =>
|
||||
site.script_id === selected.script_id && site.url === selected.url,
|
||||
),
|
||||
);
|
||||
assert.equal(result.script_inventory.coverage_absence, "unknown");
|
||||
assert.equal(result.instrumentation.cleanup, "confirmed");
|
||||
return {
|
||||
selected_sha256: selected.source.sha256,
|
||||
other_sha256: other.source.sha256,
|
||||
zero_branch: fn.is_block_coverage
|
||||
? "observed-zero"
|
||||
: "unknown-function-only",
|
||||
granularity: fn.is_block_coverage ? "block" : "function",
|
||||
repeated_url_identity: true,
|
||||
same_document_navigation: true,
|
||||
declared_source_url_preserved: true,
|
||||
request_script_id: selected.script_id,
|
||||
isolated_execution_proved_and_excluded: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function reloadFixture(connection) {
|
||||
await connection.send("Page.enable");
|
||||
let dispose;
|
||||
const committed = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("Fixture reload did not commit")),
|
||||
5000,
|
||||
);
|
||||
const remove = connection.onEvent((event) => {
|
||||
if (
|
||||
event.method === "Page.frameNavigated" &&
|
||||
!event.params.frame?.parentId
|
||||
)
|
||||
resolve();
|
||||
});
|
||||
dispose = () => {
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
};
|
||||
});
|
||||
void committed.catch(() => undefined);
|
||||
try {
|
||||
await connection.send("Page.reload", { ignoreCache: true });
|
||||
await committed;
|
||||
await ready(connection);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnOwnedProviderProcess } from "../../dist/process/ProviderProcess.js";
|
||||
import { createSystemProcessOwnershipHost } from "../../dist/process/ProcessOwnershipObservation.js";
|
||||
import { liveProcesses } from "../../dist/process/ProcessOwnershipProcessTree.js";
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
|
||||
/** Launch only the synthetic fixture browser in its own verified process group. */
|
||||
export async function startRuntimeFixtureBrowser(executable, origin) {
|
||||
const profile = await mkdtemp(join(tmpdir(), "rea-real-web-runtime-"));
|
||||
let owned;
|
||||
try {
|
||||
owned = await spawnOwnedProviderProcess({
|
||||
command: executable,
|
||||
arguments: [
|
||||
"--headless=new",
|
||||
...(process.env.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--remote-debugging-port=0",
|
||||
`--user-data-dir=${profile}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-sync",
|
||||
origin,
|
||||
],
|
||||
runId: randomUUID(),
|
||||
// Installed Chrome launch scripts exec another binary; do not require an argv prefix.
|
||||
expectedCommand: null,
|
||||
});
|
||||
owned.process.stdout?.resume();
|
||||
return {
|
||||
browser: owned.process,
|
||||
profile,
|
||||
close: async () => {
|
||||
await stopOwnedBrowser(owned, profile);
|
||||
await rm(profile, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
});
|
||||
},
|
||||
};
|
||||
} catch (cause) {
|
||||
if (owned !== undefined) await stopOwnedBrowser(owned, profile);
|
||||
await rm(profile, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopOwnedBrowser(owned, profile) {
|
||||
const child = owned.process;
|
||||
const exited =
|
||||
child.exitCode === null && child.signalCode === null
|
||||
? once(child, "exit")
|
||||
: Promise.resolve();
|
||||
void exited.catch(() => undefined);
|
||||
let connection;
|
||||
let timer;
|
||||
const escalation = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGKILL");
|
||||
}, 2_000);
|
||||
try {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
try {
|
||||
const [port, browserPath] = (
|
||||
await readFile(join(profile, "DevToolsActivePort"), "utf8")
|
||||
)
|
||||
.trim()
|
||||
.split("\n");
|
||||
assert.match(port, /^\d+$/);
|
||||
assert.match(browserPath, /^\/devtools\/browser\/[^\s]+$/);
|
||||
const signal = AbortSignal.timeout(2_000);
|
||||
connection = await CdpConnection.connect(
|
||||
`ws://127.0.0.1:${port}${browserPath}`,
|
||||
"inspect_web_page",
|
||||
signal,
|
||||
);
|
||||
// This browser belongs to the verifier; public REA operations never close it.
|
||||
await connection.send("Browser.close", {}, undefined, signal);
|
||||
} catch {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
if (owned.cleanup !== undefined) {
|
||||
const result = await owned.cleanup();
|
||||
assert.equal(result.cleaned, true, JSON.stringify(result));
|
||||
}
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error("Owned fixture browser did not exit after shutdown"),
|
||||
),
|
||||
5_000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(escalation);
|
||||
clearTimeout(timer);
|
||||
await connection?.close();
|
||||
}
|
||||
if (process.platform === "win32") return;
|
||||
const host = createSystemProcessOwnershipHost();
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (true) {
|
||||
const members = liveProcesses(await host.listProcesses()).filter(
|
||||
(member) => member.processGroupId === owned.ownership.processGroupId,
|
||||
);
|
||||
if (members.length === 0) return;
|
||||
if (Date.now() >= deadline)
|
||||
throw new Error(
|
||||
`Owned fixture group remains: ${JSON.stringify(members.map(({ pid }) => pid))}`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
/** Attempt every owned cleanup and retain the original verification failure if cleanup also fails. */
|
||||
export async function closeRuntimeFixtureResources(tasks, primaryError) {
|
||||
const failures = [];
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
await task();
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(
|
||||
primaryError === undefined ? failures : [primaryError, ...failures],
|
||||
"Runtime fixture cleanup failed",
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Verify native malformed-selector classification through the public CLI and MCP. */
|
||||
export async function verifyRuntimeInvalidSelectors(
|
||||
client,
|
||||
entrypoint,
|
||||
input,
|
||||
env,
|
||||
) {
|
||||
const cli = await new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-web-event-listeners",
|
||||
input.cdp_endpoint,
|
||||
input.target_id,
|
||||
"[",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 40_000, maxBuffer: 64 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
|
||||
);
|
||||
});
|
||||
assert.ok(cli.error, "Invalid native CSS must fail in the CLI");
|
||||
assert.equal(typeof cli.error.code, "number", cli.stderr);
|
||||
assertInputFailure(JSON.parse(cli.stdout), input.target_id);
|
||||
const response = await client.callTool({
|
||||
name: "inspect_web_event_listeners",
|
||||
arguments: { ...input, selector: "[" },
|
||||
});
|
||||
assert.equal(response.isError, true, mcpTextValue(response));
|
||||
assertInputFailure(JSON.parse(mcpTextValue(response)).error, input.target_id);
|
||||
return { cli: "invalid_input", mcp: "invalid_input", selector: "[" };
|
||||
}
|
||||
|
||||
function assertInputFailure(failure, targetId) {
|
||||
assert.equal(failure.code, "invalid_request");
|
||||
assert.equal(failure.category, "invalid_input");
|
||||
assert.equal(failure.details.operation, "inspect_web_event_listeners");
|
||||
assert.ok(
|
||||
failure.details.issues.some(
|
||||
(issue) =>
|
||||
issue.path[0] === "selector" && issue.message.includes(targetId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
import { closeRuntimeFixtureResources } from "./browser-runtime-fixture-lifecycle.mjs";
|
||||
|
||||
/** Close a separately owned fixture page after actual MCP arming and inspect native cleanup diagnostics. */
|
||||
export async function verifyRuntimeTargetTermination(client, input, origin) {
|
||||
const version = await (
|
||||
await fetch(`${input.cdp_endpoint}/json/version`)
|
||||
).json();
|
||||
const root = await CdpConnection.connect(
|
||||
version.webSocketDebuggerUrl,
|
||||
"observe_web_execution",
|
||||
);
|
||||
let targetId;
|
||||
let closed = false;
|
||||
let primaryError;
|
||||
try {
|
||||
({ targetId } = await root.send("Target.createTarget", { url: origin }));
|
||||
assert.equal(typeof targetId, "string");
|
||||
await waitForTarget(input.cdp_endpoint, targetId, origin);
|
||||
let closePromise;
|
||||
const response = await client.callTool(
|
||||
{
|
||||
name: "observe_web_execution",
|
||||
arguments: {
|
||||
...input,
|
||||
target_id: targetId,
|
||||
observation_ms: 2_147_483_647,
|
||||
},
|
||||
},
|
||||
{
|
||||
timeout: 20_000,
|
||||
onprogress: (notification) => {
|
||||
if (
|
||||
closePromise === undefined &&
|
||||
notification.message?.includes(
|
||||
"browser_execution: Browser execution observation armed",
|
||||
)
|
||||
) {
|
||||
closePromise = root
|
||||
.send("Target.closeTarget", { targetId })
|
||||
.then((result) => {
|
||||
assert.equal(result.success, true);
|
||||
closed = true;
|
||||
});
|
||||
void closePromise.catch(() => undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(
|
||||
closePromise,
|
||||
"The separate page must be closed after actual arming",
|
||||
);
|
||||
await closePromise;
|
||||
assert.equal(
|
||||
response.isError,
|
||||
true,
|
||||
"Detached instrumentation cannot claim confirmed cleanup",
|
||||
);
|
||||
const failure = mcpTextValue(response);
|
||||
assert.match(failure, /cleanup.*confirmed/u);
|
||||
assert.ok(
|
||||
failure.includes(targetId),
|
||||
"Failure must retain the selected target identity",
|
||||
);
|
||||
assert.match(failure, /Profiler\.stopPreciseCoverage/u);
|
||||
return {
|
||||
target_id: targetId,
|
||||
page_closed_after_arming: true,
|
||||
failure_inline: true,
|
||||
cleanup: "unconfirmed_reported",
|
||||
};
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await closeRuntimeFixtureResources(
|
||||
[
|
||||
() =>
|
||||
targetId === undefined || closed
|
||||
? undefined
|
||||
: root.send("Target.closeTarget", { targetId }),
|
||||
() => root.close(),
|
||||
],
|
||||
primaryError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTarget(endpoint, targetId, origin) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const pages = await (await fetch(`${endpoint}/json/list`)).json();
|
||||
if (
|
||||
pages.some((page) => page.id === targetId && page.url.startsWith(origin))
|
||||
)
|
||||
return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(
|
||||
"Separate fixture target did not settle on its selected origin",
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
export const isolatedFixtureUrl = "rea-isolated-fixture.js";
|
||||
|
||||
/** Create an explicitly owned native isolated-world fixture outside REA's public operations. */
|
||||
export async function createRuntimeIsolatedWorld(connection) {
|
||||
const tree = await connection.send("Page.getFrameTree");
|
||||
const world = await connection.send("Page.createIsolatedWorld", {
|
||||
frameId: tree.frameTree.frame.id,
|
||||
worldName: "rea-verifier-isolated",
|
||||
});
|
||||
const contextId = world.executionContextId;
|
||||
assert.ok(Number.isInteger(contextId));
|
||||
const initialized = await connection.send("Runtime.evaluate", {
|
||||
contextId,
|
||||
expression: `function reaIsolatedFixture() { return 'isolated-proof'; }
|
||||
//# sourceURL=${isolatedFixtureUrl}`,
|
||||
});
|
||||
assert.equal(initialized.exceptionDetails, undefined);
|
||||
return contextId;
|
||||
}
|
||||
|
||||
/** Invoke a known isolated callback and the selected site's callback after actual REA arming. */
|
||||
export async function triggerRuntimeFixtureAction(connection, contextId) {
|
||||
const proof = await connection.send("Runtime.evaluate", {
|
||||
contextId,
|
||||
expression: "reaIsolatedFixture()",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.equal(proof.result.value, "isolated-proof");
|
||||
return connection.send("Runtime.evaluate", {
|
||||
expression: "document.querySelector('#run').click()",
|
||||
});
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ export const toolFamilyCatalog = (sources) => {
|
|||
surface: "browser-provider",
|
||||
contracts: [
|
||||
...sources.browserContracts.BROWSER_TOOL_CONTRACTS,
|
||||
...sources.webRuntimeContracts.WEB_RUNTIME_TOOL_CONTRACTS,
|
||||
...sources.browserScenarioContracts.BROWSER_SCENARIO_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
|
|
@ -171,7 +172,10 @@ export const providerCatalog = (sources) => {
|
|||
},
|
||||
{
|
||||
identity: sources.browserProvider.CDP_BROWSER_PROVIDER_IDENTITY,
|
||||
contracts: sources.browserContracts.BROWSER_TOOL_CONTRACTS,
|
||||
contracts: [
|
||||
...sources.browserContracts.BROWSER_TOOL_CONTRACTS,
|
||||
...sources.webRuntimeContracts.WEB_RUNTIME_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
{
|
||||
identity:
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ const SOURCE_PATHS = {
|
|||
managedWorkflowContracts:
|
||||
"dist/contracts/managed/managedWorkflowToolContracts.js",
|
||||
browserContracts: "dist/contracts/browserToolContracts.js",
|
||||
webRuntimeContracts: "dist/contracts/webRuntimeToolContracts.js",
|
||||
browserScenarioContracts: "dist/contracts/browserScenarioToolContracts.js",
|
||||
electronContracts: "dist/contracts/javascript/electronToolContracts.js",
|
||||
javascriptRuntimeObservationContracts:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env node
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { verifyBrowserRuntime } from "./lib/browser-runtime-e2e.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const executable = process.env.REA_BROWSER_EXECUTABLE;
|
||||
if (executable === undefined)
|
||||
throw new Error(
|
||||
"verify:browser:runtime requires caller-supplied REA_BROWSER_EXECUTABLE",
|
||||
);
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
await promisify(execFile)("ps", ["-p", String(process.pid), "-o", "pid="], {
|
||||
timeout: 5_000,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:browser:runtime requires the host ps command for owned fixture cleanup",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
const run = createVerifierRun();
|
||||
const result = await verifyBrowserRuntime(executable, process.argv[2]);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ status: "passed", ...result, verifier: await completeVerifierRun(run) },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
|
@ -3,8 +3,8 @@ name: reverse-engineer-anything
|
|||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "27"
|
||||
tool_count: 131
|
||||
catalog_digest: "54ac160db3b96580a9a0203a07fe3db01ffc05224db621f48b4abdf979e4f50b"
|
||||
tool_count: 133
|
||||
catalog_digest: "f2d8d03be076d9b0483e6a167cc695f91302b6155ef6bba040d33917062c866c"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
|
|
|||
|
|
@ -189,6 +189,12 @@ export const buildCapabilityInventory = (
|
|||
};
|
||||
|
||||
const availabilityFor = (context: AvailabilityContext): Availability => {
|
||||
// These built-in workflows accept a caller-selected endpoint; they do not load the optional passive provider.
|
||||
if (
|
||||
context.name === "observe_web_execution" ||
|
||||
context.name === "inspect_web_event_listeners"
|
||||
)
|
||||
return { reason: "available", remediation: null };
|
||||
if (context.name === "get_navigation_context")
|
||||
return navigationContextAvailability(context.descriptors);
|
||||
const javascriptApplication = javascriptApplicationAvailability(context);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import type { ExecutionOptions, ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type {
|
||||
ObserveWebExecutionInput,
|
||||
WebExecution,
|
||||
} from "../domain/webExecution.js";
|
||||
import type {
|
||||
InspectWebEventListenersInput,
|
||||
WebEventListeners,
|
||||
} from "../domain/webEventListeners.js";
|
||||
|
||||
/** Provider-neutral live source attribution operations with distinct instrumentation authority. */
|
||||
export interface WebRuntimePort {
|
||||
identity(): ProviderIdentity;
|
||||
observeExecution(
|
||||
input: ObserveWebExecutionInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebExecution, AnalysisError>>;
|
||||
inspectEventListeners(
|
||||
input: InspectWebEventListenersInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebEventListeners, AnalysisError>>;
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import type { WebRuntimePort } from "./WebRuntimePort.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { WebExecution } from "../domain/webExecution.js";
|
||||
import type { WebEventListeners } from "../domain/webEventListeners.js";
|
||||
|
||||
const browser = {
|
||||
product: "RecordingChrome/1",
|
||||
protocol_version: "1.3",
|
||||
revision: "fixture",
|
||||
user_agent: "fixture",
|
||||
js_version: "fixture",
|
||||
};
|
||||
const target = {
|
||||
target_id: "selected-page",
|
||||
initial_url: "http://127.0.0.1:9222/app",
|
||||
origin: "http://127.0.0.1:9222",
|
||||
frame_id: "main-frame",
|
||||
loader_id: "fixture-document",
|
||||
};
|
||||
const at = "2026-01-01T00:00:00.000Z";
|
||||
|
||||
/** Immutable application-port result; it makes no real browser or socket claim. */
|
||||
export const webExecutionFixture = (
|
||||
targetId = target.target_id,
|
||||
duration = 5,
|
||||
): WebExecution => ({
|
||||
browser,
|
||||
target: { ...target, target_id: targetId },
|
||||
window: {
|
||||
armed_at: at,
|
||||
ended_at: at,
|
||||
requested_ms: duration,
|
||||
end_reason: "window_elapsed",
|
||||
producer_started_seconds: 1,
|
||||
producer_sampled_seconds: 2,
|
||||
},
|
||||
coverage: {
|
||||
state: "captured",
|
||||
reason: null,
|
||||
offset_units: "utf16-code-units",
|
||||
end_offset: "exclusive",
|
||||
scripts: [],
|
||||
excluded_scripts: 0,
|
||||
},
|
||||
requests: [],
|
||||
excluded_requests: 0,
|
||||
sources: [],
|
||||
script_inventory: {
|
||||
main_document_scripts: 0,
|
||||
not_reported_script_ids: [],
|
||||
coverage_absence: "unknown",
|
||||
},
|
||||
instrumentation: {
|
||||
resets_execution_counters: true,
|
||||
disables_optimized_execution: true,
|
||||
takes_one_resetting_sample: true,
|
||||
executes_selected_code: false,
|
||||
cleanup: "confirmed",
|
||||
page_ownership: "external",
|
||||
},
|
||||
limitations: ["Recording port fixture, not real browser evidence."],
|
||||
});
|
||||
|
||||
/** Immutable listener port result for application and SDK schema boundaries. */
|
||||
export const webListenersFixture = (
|
||||
targetId = target.target_id,
|
||||
selector = "#selected",
|
||||
): WebEventListeners => ({
|
||||
browser,
|
||||
target: { ...target, target_id: targetId },
|
||||
inspected_at: at,
|
||||
selected_node: { selector, match: "found", backend_node_id: 22 },
|
||||
listeners: [],
|
||||
sources: [],
|
||||
cleanup: "confirmed",
|
||||
limitations: ["Recording port fixture, not real listener evidence."],
|
||||
});
|
||||
|
||||
/** Provider-neutral recording seam preserves the exact parsed caller target and window. */
|
||||
export const recordingWebRuntimePort = (): WebRuntimePort => ({
|
||||
identity: () => ({
|
||||
id: "runtime-recording",
|
||||
name: "Runtime recording fixture",
|
||||
version: "1",
|
||||
}),
|
||||
observeExecution: async (input) =>
|
||||
ok(webExecutionFixture(input.target_id, input.observation_ms)),
|
||||
inspectEventListeners: async (input) =>
|
||||
ok(webListenersFixture(input.target_id, input.selector)),
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { WebRuntimeService } from "./WebRuntimeService.js";
|
||||
import {
|
||||
recordingWebRuntimePort,
|
||||
webListenersFixture,
|
||||
} from "./WebRuntimeService.fixture.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
it("rejects malformed input and prior cancellation without calling a provider", async () => {
|
||||
const provider = recordingWebRuntimePort();
|
||||
const service = new WebRuntimeService({
|
||||
identity: () => provider.identity(),
|
||||
observeExecution: () => {
|
||||
throw new Error("Provider must not be called");
|
||||
},
|
||||
inspectEventListeners: () => {
|
||||
throw new Error("Provider must not be called");
|
||||
},
|
||||
});
|
||||
const malformed = await service.observe({
|
||||
cdp_endpoint: "https://remote.example",
|
||||
target_id: "page",
|
||||
});
|
||||
if (malformed.ok)
|
||||
throw new Error("Invalid endpoint must fail before execution");
|
||||
expect(malformed.error._tag).toBe("AnalysisInputError");
|
||||
const cancelled = await service.inspect({}, { signal: AbortSignal.abort() });
|
||||
if (cancelled.ok)
|
||||
throw new Error("Prior cancellation must fail before inspection");
|
||||
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
|
||||
it("preserves actual provider failures and rejects changed target/selector identities", async () => {
|
||||
const provider = recordingWebRuntimePort();
|
||||
const args = {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
selector: "#selected",
|
||||
};
|
||||
const captured = webListenersFixture();
|
||||
const timeout = new AnalysisTimeoutError("observe_web_execution", 20_000);
|
||||
const service = new WebRuntimeService({
|
||||
identity: () => provider.identity(),
|
||||
observeExecution: async () => err(timeout),
|
||||
inspectEventListeners: async () =>
|
||||
ok({
|
||||
...captured,
|
||||
selected_node: { ...captured.selected_node, selector: "#different" },
|
||||
}),
|
||||
});
|
||||
const failed = await service.observe({
|
||||
cdp_endpoint: args.cdp_endpoint,
|
||||
target_id: args.target_id,
|
||||
observation_ms: 5,
|
||||
});
|
||||
expect(failed).toEqual(err(timeout));
|
||||
const changed = await service.inspect(args);
|
||||
if (changed.ok) throw new Error("Changed selection must be rejected");
|
||||
expect(changed.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { z } from "zod";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { WebRuntimePort } from "./WebRuntimePort.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
observeWebExecutionInputSchema,
|
||||
webExecutionSchema,
|
||||
} from "../domain/webExecution.js";
|
||||
import {
|
||||
inspectWebEventListenersInputSchema,
|
||||
webEventListenersSchema,
|
||||
} from "../domain/webEventListeners.js";
|
||||
|
||||
/** Shared public workflows keep instrumentation and listener inspection independently selectable. */
|
||||
export class WebRuntimeService {
|
||||
constructor(readonly provider: WebRuntimePort) {}
|
||||
|
||||
/** Observe externally triggered execution; selection authorizes the declared instrumentation. */
|
||||
async observe(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const operation = "observe_web_execution";
|
||||
const parsed = parseInput(
|
||||
observeWebExecutionInputSchema,
|
||||
rawInput,
|
||||
operation,
|
||||
options,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const result = await this.provider.observeExecution(parsed.value, options);
|
||||
if (!result.ok) return result;
|
||||
const verified = webExecutionSchema.safeParse(result.value);
|
||||
if (!verified.success)
|
||||
return err(new AnalysisOutputError(operation, verified.error.message));
|
||||
if (
|
||||
verified.data.target.target_id !== parsed.value.target_id ||
|
||||
verified.data.window.requested_ms !== parsed.value.observation_ms
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider changed the selected target or requested window.",
|
||||
),
|
||||
);
|
||||
return this.evidence(operation, parsed.value, verified.data, options);
|
||||
}
|
||||
|
||||
/** Inspect one selected node's listener declarations without dispatching an event. */
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const operation = "inspect_web_event_listeners";
|
||||
const parsed = parseInput(
|
||||
inspectWebEventListenersInputSchema,
|
||||
rawInput,
|
||||
operation,
|
||||
options,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const result = await this.provider.inspectEventListeners(
|
||||
parsed.value,
|
||||
options,
|
||||
);
|
||||
if (!result.ok) return result;
|
||||
const verified = webEventListenersSchema.safeParse(result.value);
|
||||
if (!verified.success)
|
||||
return err(new AnalysisOutputError(operation, verified.error.message));
|
||||
if (
|
||||
verified.data.target.target_id !== parsed.value.target_id ||
|
||||
verified.data.selected_node.selector !== parsed.value.selector
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider changed the selected target or node selector.",
|
||||
),
|
||||
);
|
||||
return this.evidence(operation, parsed.value, verified.data, options);
|
||||
}
|
||||
|
||||
private evidence(
|
||||
operation: string,
|
||||
input:
|
||||
| z.infer<typeof observeWebExecutionInputSchema>
|
||||
| z.infer<typeof inspectWebEventListenersInputSchema>,
|
||||
result:
|
||||
| z.infer<typeof webExecutionSchema>
|
||||
| z.infer<typeof webEventListenersSchema>,
|
||||
options?: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (
|
||||
input.allowed_origins.length > 0 &&
|
||||
!input.allowed_origins.includes(result.target.origin)
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider returned a document outside the selected origin scope.",
|
||||
),
|
||||
);
|
||||
return ok(
|
||||
createEvidence(undefined, this.provider.identity(), {
|
||||
predicateType: `rea.${operation.replaceAll("_", "-")}`,
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(input),
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
limitations: result.limitations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const parseInput = <T>(
|
||||
schema: z.ZodType<T>,
|
||||
rawInput: unknown,
|
||||
operation: string,
|
||||
options: ExecutionOptions | undefined,
|
||||
): Result<T, AnalysisError> => {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const parsed = schema.safeParse(rawInput);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{ cause: parsed.error },
|
||||
projectInputIssues(parsed.error.issues, rawInput),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
|
@ -13,7 +13,7 @@ interface AuthorizedMainFrameOptions {
|
|||
readonly sessionId: string | undefined;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly allowedOrigins: ReadonlySet<string>;
|
||||
readonly delayOperation: BrowserObservationOperation;
|
||||
readonly operation: BrowserObservationOperation;
|
||||
}
|
||||
|
||||
/** Wait for the attached target's main frame to enter its approved origin. */
|
||||
|
|
@ -22,7 +22,7 @@ export const authorizedMainFrame = async ({
|
|||
sessionId,
|
||||
signal,
|
||||
allowedOrigins,
|
||||
delayOperation,
|
||||
operation,
|
||||
}: AuthorizedMainFrameOptions): Promise<unknown> => {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const result = await connection.send(
|
||||
|
|
@ -34,11 +34,8 @@ export const authorizedMainFrame = async ({
|
|||
const url = mainFrameUrl(result);
|
||||
if (allowedSanitizedUrl(url, allowedOrigins) !== undefined) return result;
|
||||
if (isHttpUrl(url))
|
||||
throw new BrowserObservationError(
|
||||
"inspect_web_page",
|
||||
"target_not_allowed",
|
||||
);
|
||||
await delayWithCancellation(25, delayOperation, signal);
|
||||
throw new BrowserObservationError(operation, "target_not_allowed");
|
||||
await delayWithCancellation(25, operation, signal);
|
||||
}
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
throw new BrowserObservationError(operation, "target_not_allowed");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
|
||||
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
|
||||
/** Select one page target and derive its origin scope before opening a CDP session. */
|
||||
export const authorizeCdpTarget = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
input: {
|
||||
readonly target_id: string;
|
||||
readonly allowed_origins: readonly string[];
|
||||
},
|
||||
operation: BrowserObservationOperation,
|
||||
): {
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly allowedOrigins: ReadonlySet<string>;
|
||||
} => {
|
||||
const target = discovery.targets.find(
|
||||
(candidate) => candidate.id === input.target_id,
|
||||
);
|
||||
if (target === undefined)
|
||||
throw new BrowserObservationError(operation, "target_not_found", {
|
||||
detail: `Target ${input.target_id} was not found at the selected endpoint.`,
|
||||
});
|
||||
const origin = sanitizeBrowserUrl(target.url).origin;
|
||||
if (
|
||||
target.type !== "page" ||
|
||||
origin === null ||
|
||||
(input.allowed_origins.length > 0 &&
|
||||
!input.allowed_origins.includes(origin))
|
||||
)
|
||||
throw new BrowserObservationError(operation, "target_not_allowed", {
|
||||
detail: `Target ${input.target_id} must be an HTTP(S) page in the selected origin scope.`,
|
||||
});
|
||||
return {
|
||||
target,
|
||||
allowedOrigins: new Set(
|
||||
input.allowed_origins.length > 0 ? input.allowed_origins : [origin],
|
||||
),
|
||||
};
|
||||
};
|
||||
|
|
@ -58,6 +58,7 @@ import {
|
|||
} from "./CdpTargetSession.js";
|
||||
import { capturePage, type CapturedPage } from "./CdpPageCapture.js";
|
||||
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
|
||||
import { authorizeCdpTarget } from "./CdpAuthorizedTarget.js";
|
||||
import { observeCdpSession } from "./CdpObservationSession.js";
|
||||
import { discoverWebMcp } from "./CdpWebMcpDiscovery.js";
|
||||
import { captureCdpScreenshot } from "./CdpScreenshot.js";
|
||||
|
|
@ -417,20 +418,7 @@ const authorizeTarget = (
|
|||
discovery: CdpEndpointDiscovery,
|
||||
input: Pick<InspectWebPageInput, "target_id" | "allowed_origins">,
|
||||
): CdpEndpointTarget => {
|
||||
const target = discovery.targets.find(
|
||||
(candidate) => candidate.id === input.target_id,
|
||||
);
|
||||
if (target === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_found");
|
||||
const origin = sanitizeBrowserUrl(target.url).origin;
|
||||
if (
|
||||
target.type !== "page" ||
|
||||
origin === null ||
|
||||
(input.allowed_origins.length > 0 &&
|
||||
!input.allowed_origins.includes(origin))
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
return target;
|
||||
return authorizeCdpTarget(discovery, input, "inspect_web_page").target;
|
||||
};
|
||||
|
||||
const scopeToTargetOrigin = <T extends { allowed_origins: readonly string[] }>(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
|
||||
|
||||
/** Preserve a producer's command rejection independently from fatal transport parsing errors. */
|
||||
export class CdpCommandRejection extends BrowserObservationError {
|
||||
constructor(
|
||||
operation: BrowserObservationOperation,
|
||||
readonly command: string,
|
||||
readonly code: number | null,
|
||||
readonly reportedMessage: string | null,
|
||||
) {
|
||||
super(operation, "protocol_error", {
|
||||
detail:
|
||||
reportedMessage === null
|
||||
? "CDP returned a malformed command error."
|
||||
: `CDP ${command} failed (${code === null ? "unknown code" : String(code)}): ${reportedMessage}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import WebSocket, { type RawData } from "ws";
|
|||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { CdpCommandRejection } from "./CdpCommandRejection.js";
|
||||
import { type BrowserObservationOperation } from "../domain/browserObservationErrors.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export interface CdpEvent {
|
|||
}
|
||||
|
||||
interface PendingCommand {
|
||||
readonly method: string;
|
||||
readonly resolve: (value: unknown) => void;
|
||||
readonly reject: (error: AnalysisError) => void;
|
||||
readonly removeAbort: () => void;
|
||||
|
|
@ -23,17 +25,28 @@ export class CdpConnection {
|
|||
readonly #pending = new Map<number, PendingCommand>();
|
||||
readonly #listeners = new Set<(event: CdpEvent) => void>();
|
||||
readonly #disconnectListeners = new Set<() => void>();
|
||||
readonly #protocolFailureListeners = new Set<
|
||||
(error: BrowserObservationError) => void
|
||||
>();
|
||||
#nextId = 1;
|
||||
#closed = false;
|
||||
#protocolFailed = false;
|
||||
#transportFailure: "disconnected" | "payload_limit" = "disconnected";
|
||||
|
||||
private constructor(
|
||||
private readonly socket: WebSocket,
|
||||
private readonly operation: BrowserObservationOperation,
|
||||
private readonly maxPayloadBytes: number,
|
||||
) {
|
||||
socket.on("message", (data) => this.#receive(data));
|
||||
socket.on("close", () => this.#disconnect());
|
||||
socket.on("error", () => this.#disconnect());
|
||||
socket.on("error", (cause) =>
|
||||
this.#disconnect(
|
||||
isRecord(cause) && cause.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"
|
||||
? "payload_limit"
|
||||
: "disconnected",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Connect to one already-validated loopback CDP WebSocket. */
|
||||
|
|
@ -41,15 +54,16 @@ export class CdpConnection {
|
|||
url: string,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
limits?: { readonly maxPayloadBytes: number },
|
||||
): Promise<CdpConnection> {
|
||||
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||
const socket = new WebSocket(url, {
|
||||
handshakeTimeout: 0,
|
||||
maxPayload: 0,
|
||||
maxPayload: limits?.maxPayloadBytes ?? 0,
|
||||
perMessageDeflate: false,
|
||||
});
|
||||
await waitForOpen(socket, operation, signal);
|
||||
return new CdpConnection(socket, operation);
|
||||
return new CdpConnection(socket, operation, limits?.maxPayloadBytes ?? 0);
|
||||
}
|
||||
|
||||
/** Subscribe to validated CDP event envelopes. */
|
||||
|
|
@ -68,6 +82,18 @@ export class CdpConnection {
|
|||
return () => this.#disconnectListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Notify fatal wire parsing failure even when no command is outstanding. */
|
||||
onProtocolFailure(
|
||||
listener: (error: BrowserObservationError) => void,
|
||||
): () => void {
|
||||
if (this.#protocolFailed) {
|
||||
listener(this.#transportError("protocol_error"));
|
||||
return () => undefined;
|
||||
}
|
||||
this.#protocolFailureListeners.add(listener);
|
||||
return () => this.#protocolFailureListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Execute one command, optionally within a flat target session. */
|
||||
async send(
|
||||
method: string,
|
||||
|
|
@ -78,7 +104,7 @@ export class CdpConnection {
|
|||
if (this.#protocolFailed)
|
||||
throw new BrowserObservationError(this.operation, "protocol_error");
|
||||
if (this.#closed || this.socket.readyState !== WebSocket.OPEN)
|
||||
throw new BrowserObservationError(this.operation, "disconnected");
|
||||
throw this.#transportError(this.#transportFailure);
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError(this.operation);
|
||||
const id = this.#nextId;
|
||||
|
|
@ -93,6 +119,7 @@ export class CdpConnection {
|
|||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
this.#pending.set(id, {
|
||||
method,
|
||||
resolve,
|
||||
reject,
|
||||
removeAbort: () => signal?.removeEventListener("abort", onAbort),
|
||||
|
|
@ -139,6 +166,7 @@ export class CdpConnection {
|
|||
}
|
||||
|
||||
#receive(data: RawData): void {
|
||||
if (this.#closed || this.#protocolFailed) return;
|
||||
const parsed = safeParseJson(rawText(data));
|
||||
if (!parsed.ok) {
|
||||
this.#failPending("protocol_error");
|
||||
|
|
@ -185,8 +213,18 @@ export class CdpConnection {
|
|||
if (pending === undefined) return;
|
||||
this.#complete(id, pending);
|
||||
if ("error" in message) {
|
||||
const reported = message.error;
|
||||
pending.reject(
|
||||
new BrowserObservationError(this.operation, "protocol_error"),
|
||||
new CdpCommandRejection(
|
||||
this.operation,
|
||||
pending.method,
|
||||
isRecord(reported) && typeof reported.code === "number"
|
||||
? reported.code
|
||||
: null,
|
||||
isRecord(reported) && typeof reported.message === "string"
|
||||
? reported.message
|
||||
: null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -198,22 +236,45 @@ export class CdpConnection {
|
|||
this.#pending.delete(id);
|
||||
}
|
||||
|
||||
#failPending(reason: "disconnected" | "protocol_error"): void {
|
||||
if (reason === "disconnected") this.#closed = true;
|
||||
#failPending(
|
||||
reason: "disconnected" | "protocol_error" | "payload_limit",
|
||||
): void {
|
||||
const wasProtocolFailed = this.#protocolFailed;
|
||||
if (reason !== "protocol_error") this.#closed = true;
|
||||
else this.#protocolFailed = true;
|
||||
for (const [id, pending] of this.#pending) {
|
||||
this.#complete(id, pending);
|
||||
pending.reject(new BrowserObservationError(this.operation, reason));
|
||||
pending.reject(this.#transportError(reason));
|
||||
}
|
||||
if (reason === "protocol_error" && !wasProtocolFailed) {
|
||||
const error = this.#transportError(reason);
|
||||
for (const listener of this.#protocolFailureListeners) listener(error);
|
||||
this.#protocolFailureListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#disconnect(): void {
|
||||
#disconnect(reason: "disconnected" | "payload_limit" = "disconnected"): void {
|
||||
const wasClosed = this.#closed;
|
||||
this.#failPending("disconnected");
|
||||
if (!wasClosed) this.#transportFailure = reason;
|
||||
this.#failPending(reason);
|
||||
if (wasClosed) return;
|
||||
for (const listener of this.#disconnectListeners) listener();
|
||||
this.#disconnectListeners.clear();
|
||||
}
|
||||
|
||||
#transportError(
|
||||
reason: "disconnected" | "protocol_error" | "payload_limit",
|
||||
): BrowserObservationError {
|
||||
return new BrowserObservationError(
|
||||
this.operation,
|
||||
reason,
|
||||
reason === "payload_limit"
|
||||
? {
|
||||
detail: `CDP message exceeded the selected ${String(this.maxPayloadBytes)} byte protocol budget.`,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rawText = (data: RawData): string =>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "./CdpCaptureValues.js";
|
||||
import { mainFrameUrl } from "./CdpCaptureDocuments.js";
|
||||
import { isMainFrameNavigation } from "./CdpCaptureEventHelpers.js";
|
||||
import { cdpTargetEventMatches } from "./CdpTargetEvents.js";
|
||||
|
||||
interface ObservationContext {
|
||||
readonly connection: CdpConnection;
|
||||
|
|
@ -51,7 +52,7 @@ export const observeCdpSession = async (
|
|||
sessionId: context.sessionId,
|
||||
signal: context.signal,
|
||||
allowedOrigins,
|
||||
delayOperation: "observe_web_session",
|
||||
operation: "observe_web_session",
|
||||
});
|
||||
const initialUrl = mainFrameUrl(initial) ?? "";
|
||||
const mainFrameId = frameId(initial);
|
||||
|
|
@ -59,8 +60,7 @@ export const observeCdpSession = async (
|
|||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
const capture = new TimelineCapture(allowedOrigins, mainFrameId, initialUrl);
|
||||
const removeListener = context.connection.onEvent((event) => {
|
||||
if (observationEventMatches(event, context.sessionId))
|
||||
capture.ingest(event);
|
||||
if (cdpTargetEventMatches(event, context.sessionId)) capture.ingest(event);
|
||||
});
|
||||
const removeDisconnectListener = context.connection.onDisconnect(() =>
|
||||
capture.targetTerminated(),
|
||||
|
|
@ -336,18 +336,6 @@ class TimelineCapture {
|
|||
}
|
||||
}
|
||||
|
||||
const observationEventMatches = (
|
||||
event: CdpEvent,
|
||||
sessionId: string | undefined,
|
||||
): boolean => {
|
||||
if (event.method !== "Target.detachedFromTarget")
|
||||
return event.sessionId === sessionId;
|
||||
return (
|
||||
sessionId !== undefined &&
|
||||
cdpStringValue(recordValue(event.params)?.sessionId) === sessionId
|
||||
);
|
||||
};
|
||||
|
||||
const waitForWindow = async (
|
||||
durationMs: number,
|
||||
capture: TimelineCapture,
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ const captureAuthorizedPage = async (
|
|||
sessionId: context.sessionId,
|
||||
signal: context.signal,
|
||||
allowedOrigins,
|
||||
delayOperation: context.operation,
|
||||
operation: context.operation,
|
||||
});
|
||||
const attachedUrl = mainFrameUrl(frameResult) ?? "";
|
||||
const frameCapture = captureFrames(
|
||||
|
|
@ -154,7 +154,7 @@ const captureAuthorizedPage = async (
|
|||
sessionId: context.sessionId,
|
||||
signal: context.signal,
|
||||
allowedOrigins,
|
||||
delayOperation: context.operation,
|
||||
operation: context.operation,
|
||||
});
|
||||
const completedUrl = mainFrameUrl(completedFrameResult) ?? "";
|
||||
if (state.events.originViolation)
|
||||
|
|
@ -212,7 +212,7 @@ const authorizeObservationWindow = async (
|
|||
sessionId: context.sessionId,
|
||||
signal: context.signal,
|
||||
allowedOrigins,
|
||||
delayOperation: context.operation,
|
||||
operation: context.operation,
|
||||
});
|
||||
const mainFrame = captureFrames(initialFrameResult, allowedOrigins).items[0];
|
||||
if (mainFrame === undefined)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import type { CdpEvent } from "./CdpConnection.js";
|
||||
import { cdpStringValue, recordValue } from "./CdpCaptureValues.js";
|
||||
|
||||
/** Match ordinary session envelopes and root detachment's parameter-scoped session identity. */
|
||||
export const cdpTargetEventMatches = (
|
||||
event: CdpEvent,
|
||||
sessionId: string | undefined,
|
||||
): boolean => {
|
||||
if (event.method !== "Target.detachedFromTarget")
|
||||
return event.sessionId === sessionId;
|
||||
return (
|
||||
sessionId !== undefined &&
|
||||
cdpStringValue(recordValue(event.params)?.sessionId) === sessionId
|
||||
);
|
||||
};
|
||||
|
|
@ -19,12 +19,14 @@ export const openCdpTargetSession = async (
|
|||
target: CdpEndpointTarget,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
limits?: { readonly maxPayloadBytes: number },
|
||||
): Promise<CdpTargetSession> => {
|
||||
const webSocket = cdpTargetWebSocket(discovery, target, operation);
|
||||
const connection = await CdpConnection.connect(
|
||||
webSocket.url,
|
||||
operation,
|
||||
signal,
|
||||
limits,
|
||||
);
|
||||
if (webSocket.scope === "page") return { connection, sessionId: undefined };
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
InspectWebEventListenersInput,
|
||||
WebEventListeners,
|
||||
} from "../../domain/webEventListeners.js";
|
||||
import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
|
||||
import { runtimeListenersSchema } from "./CdpRuntimeProtocol.js";
|
||||
import { queryListenerSelector } from "./CdpListenerSelector.js";
|
||||
|
||||
/** Inspect native DOM listener descriptors without evaluating JavaScript or invoking handlers. */
|
||||
export const captureWebEventListeners = async (
|
||||
input: InspectWebEventListenersInput,
|
||||
sources: CdpRuntimeSources,
|
||||
group: string,
|
||||
): Promise<WebEventListeners> => {
|
||||
const session = sources.session;
|
||||
session.beginCompletion();
|
||||
const unsubscribe = session.transport.connection.onEvent((event) =>
|
||||
sources.ingest(event),
|
||||
);
|
||||
try {
|
||||
for (const domain of ["Page", "Runtime", "Debugger", "DOM"])
|
||||
await session.enable(domain);
|
||||
await session.assertDocument();
|
||||
const document = z
|
||||
.object({ root: z.object({ nodeId: z.number().int().min(1) }) })
|
||||
.parse(await session.command("DOM.getDocument", { depth: 0 }));
|
||||
const selected = z
|
||||
.object({ nodeId: z.number().int().min(0) })
|
||||
.parse(
|
||||
await queryListenerSelector(
|
||||
session,
|
||||
document.root.nodeId,
|
||||
input.selector,
|
||||
),
|
||||
);
|
||||
let backendNodeId: number | null = null;
|
||||
let descriptors: z.infer<typeof runtimeListenersSchema>["listeners"] = [];
|
||||
if (selected.nodeId !== 0) {
|
||||
const described = z
|
||||
.object({ node: z.object({ backendNodeId: z.number().int().min(1) }) })
|
||||
.parse(
|
||||
await session.command("DOM.describeNode", {
|
||||
nodeId: selected.nodeId,
|
||||
}),
|
||||
);
|
||||
backendNodeId = described.node.backendNodeId;
|
||||
const resolved = z
|
||||
.object({ object: z.object({ objectId: z.string().min(1) }) })
|
||||
.parse(
|
||||
await session.command("DOM.resolveNode", {
|
||||
nodeId: selected.nodeId,
|
||||
objectGroup: group,
|
||||
}),
|
||||
);
|
||||
descriptors = runtimeListenersSchema.parse(
|
||||
await session.command("DOMDebugger.getEventListeners", {
|
||||
objectId: resolved.object.objectId,
|
||||
depth: 0,
|
||||
pierce: false,
|
||||
}),
|
||||
).listeners;
|
||||
}
|
||||
const retained = await sources.read(
|
||||
descriptors
|
||||
.map((listener) => listener.scriptId)
|
||||
.filter((id) => id.length > 0),
|
||||
);
|
||||
await session.assertDocument();
|
||||
sources.check();
|
||||
return {
|
||||
browser: session.discovery.version,
|
||||
target: session.target,
|
||||
inspected_at: new Date().toISOString(),
|
||||
selected_node: {
|
||||
selector: input.selector,
|
||||
match: selected.nodeId === 0 ? "not_found" : "found",
|
||||
backend_node_id: backendNodeId,
|
||||
},
|
||||
listeners: descriptors.map((listener) => ({
|
||||
type: listener.type,
|
||||
use_capture: listener.useCapture,
|
||||
passive: listener.passive,
|
||||
once: listener.once,
|
||||
location: sources.location(
|
||||
listener.scriptId,
|
||||
sources.scripts.get(listener.scriptId)?.url ?? null,
|
||||
listener.lineNumber,
|
||||
listener.columnNumber,
|
||||
),
|
||||
execution: "unknown",
|
||||
})),
|
||||
sources: retained,
|
||||
cleanup: "confirmed",
|
||||
limitations: [
|
||||
"Inspects listeners registered directly on the first native CSS match in the selected main document. Delegated ancestor listeners, descendants, closed shadow roots and framework registries are not enumerated.",
|
||||
"Native CDP listener descriptors report callback locations; registration time, dispatch history and UI causality remain unknown. REA does not dispatch an event, evaluate JavaScript or invoke a handler.",
|
||||
"Source identity uses this session's script ID and proven main-frame default-world metadata. Isolated or unknown worlds cannot establish website callback/source joins. A URL or sourceURL declaration never establishes identity or execution.",
|
||||
"Source text is retained inline with its independently computed UTF-8 digest; the producer hash is preserved separately. Missing source ownership or collected scripts remain explicit.",
|
||||
"REA releases its remote object group, disables enabled domains and detaches its transport; the externally owned page remains open.",
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
|
||||
/** Use an owned remote object namespace so cleanup does not release caller objects. */
|
||||
export const eventListenerObjectGroup = (): string =>
|
||||
`rea-listeners-${randomUUID()}`;
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { z } from "zod";
|
||||
import { BrowserObservationError } from "../../domain/browserObservationError.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type {
|
||||
ObserveWebExecutionInput,
|
||||
WebExecution,
|
||||
} from "../../domain/webExecution.js";
|
||||
import { CdpExecutionCollection } from "./CdpExecutionCollection.js";
|
||||
import { preciseCoverageSchema } from "./CdpRuntimeProtocol.js";
|
||||
import { normalizePreciseCoverage } from "./CdpPreciseCoverage.js";
|
||||
import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
|
||||
|
||||
/** Capture one explicit resetting coverage sample and its request-source relationships. */
|
||||
export const captureWebExecution = async (
|
||||
input: ObserveWebExecutionInput,
|
||||
sources: CdpRuntimeSources,
|
||||
): Promise<WebExecution> => {
|
||||
const session = sources.session;
|
||||
const collection = new CdpExecutionCollection(sources);
|
||||
const { window, requests } = collection;
|
||||
try {
|
||||
for (const domain of ["Page", "Runtime", "Debugger", "Network", "Profiler"])
|
||||
await session.enable(domain);
|
||||
await session.assertDocument();
|
||||
sources.check();
|
||||
session.preciseCoverageMayBeActive = true;
|
||||
const started = z.object({ timestamp: z.number().min(0) }).parse(
|
||||
await session.command("Profiler.startPreciseCoverage", {
|
||||
callCount: true,
|
||||
detailed: true,
|
||||
allowTriggeredUpdates: false,
|
||||
}),
|
||||
);
|
||||
sources.check();
|
||||
window.check();
|
||||
if (window.reason !== undefined)
|
||||
throw new BrowserObservationError(session.operation, "target_changed", {
|
||||
detail: `Selected target ${session.target.target_id} ended with ${window.reason} before execution observation could be armed.`,
|
||||
});
|
||||
const waiting = window.start(input.observation_ms);
|
||||
// Cancellation may arrive while asynchronous progress is being delivered.
|
||||
void waiting.catch(() => undefined);
|
||||
await session.options.progress?.report({
|
||||
phase: "browser_execution",
|
||||
completed: 1,
|
||||
total: 2,
|
||||
message:
|
||||
"Browser execution observation armed; perform the requested action now",
|
||||
});
|
||||
const reason = await waiting;
|
||||
session.beginCompletion();
|
||||
if (session.options.signal?.aborted)
|
||||
throw new AnalysisCancelledError(session.operation);
|
||||
sources.check();
|
||||
requests.check();
|
||||
let raw: unknown;
|
||||
if (reason === "window_elapsed") {
|
||||
await session.assertDocument();
|
||||
raw = await session.command("Profiler.takePreciseCoverage");
|
||||
}
|
||||
collection.freezeMetadata();
|
||||
await session.command("Profiler.stopPreciseCoverage");
|
||||
session.preciseCoverageMayBeActive = false;
|
||||
const requestItems = requests.result();
|
||||
const parsed =
|
||||
raw === undefined ? undefined : preciseCoverageSchema.safeParse(raw);
|
||||
if (parsed !== undefined && !parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
session.operation,
|
||||
`Malformed precise coverage: ${parsed.error.message}`,
|
||||
);
|
||||
const mainScripts = [...sources.scripts.keys()].filter((id) =>
|
||||
sources.belongsToDocument(id),
|
||||
);
|
||||
const reportedIds = new Set(
|
||||
parsed?.success === true
|
||||
? parsed.data.result.map((script) => script.scriptId)
|
||||
: [],
|
||||
);
|
||||
const ids = [
|
||||
...mainScripts,
|
||||
...requestItems
|
||||
.flatMap((request) => request.callsites.map((site) => site.script_id))
|
||||
.filter((id) => id.length > 0),
|
||||
];
|
||||
const retained = await sources.read(ids);
|
||||
if (reason === "window_elapsed") await session.assertDocument();
|
||||
sources.check();
|
||||
const verifiedRequests = requests.result();
|
||||
const verifiedMainScripts = mainScripts.filter((id) =>
|
||||
sources.belongsToDocument(id),
|
||||
);
|
||||
const sample =
|
||||
raw === undefined
|
||||
? undefined
|
||||
: normalizePreciseCoverage(raw, sources, retained);
|
||||
await session.options.progress?.report({
|
||||
phase: "browser_execution",
|
||||
completed: 2,
|
||||
total: 2,
|
||||
message: "Browser execution observation ended",
|
||||
terminal: true,
|
||||
});
|
||||
return {
|
||||
browser: session.discovery.version,
|
||||
target: session.target,
|
||||
window: {
|
||||
armed_at: window.armedAt ?? new Date().toISOString(),
|
||||
ended_at: window.endedAt ?? new Date().toISOString(),
|
||||
requested_ms: input.observation_ms,
|
||||
end_reason: reason,
|
||||
producer_started_seconds: started.timestamp,
|
||||
producer_sampled_seconds: sample?.timestamp ?? null,
|
||||
},
|
||||
coverage: sample?.coverage ?? {
|
||||
state: "unavailable",
|
||||
reason: `The observation ended with ${reason}; no complete final precise-coverage sample was retained.`,
|
||||
offset_units: "utf16-code-units",
|
||||
end_offset: "exclusive",
|
||||
scripts: [],
|
||||
excluded_scripts: 0,
|
||||
},
|
||||
requests: verifiedRequests,
|
||||
excluded_requests: requests.excluded,
|
||||
script_inventory: {
|
||||
main_document_scripts: verifiedMainScripts.length,
|
||||
not_reported_script_ids: verifiedMainScripts.filter(
|
||||
(id) => !reportedIds.has(id),
|
||||
),
|
||||
coverage_absence: "unknown",
|
||||
},
|
||||
sources: retained,
|
||||
instrumentation: {
|
||||
resets_execution_counters: true,
|
||||
disables_optimized_execution: true,
|
||||
takes_one_resetting_sample: sample !== undefined,
|
||||
executes_selected_code: false,
|
||||
cleanup: "confirmed",
|
||||
page_ownership: "external",
|
||||
},
|
||||
limitations: [
|
||||
"Website source ownership requires the selected main frame and a proven default execution world. Isolated, worker and unknown worlds are excluded from coverage/source joins; referenced producer context metadata is retained.",
|
||||
"Precise coverage resets counters, retains execution records and prevents optimized execution until stopped. Timing and JIT behavior are affected.",
|
||||
"Only the selected main document's proven frame/context scripts are attributed. Child frames, workers, service workers, WebAssembly and scripts without ownership metadata remain outside this operation.",
|
||||
"V8 reports nested function/block ranges and counts, not a chronological trace. Counts must not be summed across overlapping ranges; zero counts concern this instrumented window only.",
|
||||
"Previously parsed scripts may be absent from a precise sample. Their source inventory is retained, but missing coverage never means zero execution or an unexecuted function.",
|
||||
"A detailed-coverage request does not guarantee block granularity for already compiled functions. When is_block_coverage is false, branch execution is unknown; REA preserves the function-only producer ranges.",
|
||||
"The local armed/end clock and backend coverage timestamps are distinct clocks. Counters start when the backend accepts instrumentation, before the armed receipt, and end at the resetting sample after the requested window. Counts may include execution during those command intervals; requests are restricted to the locally armed window.",
|
||||
"Source metadata is collected through the final resetting sample, then frozen for source joins. Known identities remain checked during asynchronous source completion; newly parsed scripts after that cutoff are outside this evidence.",
|
||||
"Request callsites are producer-reported initiators associated by session script ID; they do not establish UI causality. Unresolved asynchronous parent IDs are retained without fetching their stacks.",
|
||||
"Producer hashes and UTF-8 digests of retained text are distinct identities. Inline script locations use reported resource offsets; anonymous/eval/sourceURL names are declarations.",
|
||||
"Metadata has an 8 MiB script budget and 8 MiB request budget; complete retained sources have a 32 MiB budget. Resource failures return no partial success. The externally owned browser's CPU and memory are not controlled by REA.",
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
collection.close();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { cdpTargetEventMatches } from "../CdpTargetEvents.js";
|
||||
import { CdpExecutionWindow } from "./CdpExecutionWindow.js";
|
||||
import { CdpRuntimeRequests } from "./CdpRuntimeRequests.js";
|
||||
import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
|
||||
|
||||
/** Own source/request collection and failure subscriptions through a precise-sample cutoff. */
|
||||
export class CdpExecutionCollection {
|
||||
readonly window: CdpExecutionWindow;
|
||||
readonly requests: CdpRuntimeRequests;
|
||||
readonly #unsubscribers: readonly (() => void)[];
|
||||
#metadataOpen = true;
|
||||
|
||||
constructor(readonly sources: CdpRuntimeSources) {
|
||||
const session = sources.session;
|
||||
this.window = new CdpExecutionWindow(session);
|
||||
this.requests = new CdpRuntimeRequests(sources);
|
||||
this.#unsubscribers = [
|
||||
session.transport.connection.onEvent((event) => {
|
||||
if (!cdpTargetEventMatches(event, session.transport.sessionId)) return;
|
||||
try {
|
||||
if (
|
||||
this.#metadataOpen &&
|
||||
(this.window.reason === undefined ||
|
||||
this.window.reason === "window_elapsed")
|
||||
)
|
||||
sources.ingest(event);
|
||||
else if (this.window.reason === "window_elapsed")
|
||||
sources.verifyKnownIdentity(event);
|
||||
if (this.window.active) this.requests.ingest(event);
|
||||
sources.check();
|
||||
this.requests.check();
|
||||
} catch (cause: unknown) {
|
||||
this.window.fail(
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new AnalysisOutputError(session.operation, String(cause)),
|
||||
);
|
||||
}
|
||||
this.window.ingest(event);
|
||||
}),
|
||||
session.transport.connection.onDisconnect(() =>
|
||||
this.window.end("target_terminated"),
|
||||
),
|
||||
session.transport.connection.onProtocolFailure((error) =>
|
||||
this.window.fail(error),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** Freeze metadata after receiving the final sample so source joins use one fixed inventory. */
|
||||
freezeMetadata(): void {
|
||||
this.#metadataOpen = false;
|
||||
this.sources.check();
|
||||
}
|
||||
|
||||
/** Remove every owned subscription and local timer on success, cancellation or failure. */
|
||||
close(): void {
|
||||
this.#metadataOpen = false;
|
||||
this.window.dispose();
|
||||
for (const unsubscribe of this.#unsubscribers) unsubscribe();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { expect, it, vi } from "vitest";
|
||||
import { CdpExecutionWindow } from "./CdpExecutionWindow.js";
|
||||
|
||||
it("settles an already lost transport without arming a timer that cannot resolve", async () => {
|
||||
const window = new CdpExecutionWindow({
|
||||
target: { frame_id: "selected-main" },
|
||||
operation: "observe_web_execution",
|
||||
options: {},
|
||||
});
|
||||
window.end("target_terminated");
|
||||
await expect(window.start(30_000)).resolves.toBe("target_terminated");
|
||||
expect(window.active).toBe(false);
|
||||
});
|
||||
|
||||
it("ends the actual armed window at its selected duration and disposes its timer", async () => {
|
||||
vi.useFakeTimers();
|
||||
const window = new CdpExecutionWindow({
|
||||
target: { frame_id: "selected-main" },
|
||||
operation: "observe_web_execution",
|
||||
options: {},
|
||||
});
|
||||
try {
|
||||
const result = window.start(10);
|
||||
expect(window.active).toBe(true);
|
||||
vi.advanceTimersByTime(10);
|
||||
await expect(result).resolves.toBe("window_elapsed");
|
||||
expect(window.active).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
window.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("settles caller cancellation and removes its local timer", async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
const window = new CdpExecutionWindow({
|
||||
target: { frame_id: "selected-main" },
|
||||
operation: "observe_web_execution",
|
||||
options: { signal: controller.signal },
|
||||
});
|
||||
try {
|
||||
const assertion = expect(window.start(30_000)).rejects.toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
});
|
||||
controller.abort();
|
||||
await assertion;
|
||||
expect(window.active).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
window.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { WebExecution } from "../../domain/webExecution.js";
|
||||
import type { CdpEvent } from "../CdpConnection.js";
|
||||
import type { CdpRuntimeSession } from "./CdpRuntimeSession.js";
|
||||
|
||||
type EndReason = WebExecution["window"]["end_reason"];
|
||||
/** One finite document-scoped window, with an actual armed point and event-driven termination. */
|
||||
export class CdpExecutionWindow {
|
||||
armedAt: string | undefined;
|
||||
endedAt: string | undefined;
|
||||
reason: EndReason | undefined;
|
||||
#timer: NodeJS.Timeout | undefined;
|
||||
#resolve: ((reason: EndReason) => void) | undefined;
|
||||
#reject: ((error: AnalysisError) => void) | undefined;
|
||||
#failure: AnalysisError | undefined;
|
||||
#removeAbort: (() => void) | undefined;
|
||||
constructor(
|
||||
readonly session: Pick<CdpRuntimeSession, "operation" | "options"> & {
|
||||
readonly target: { readonly frame_id: string };
|
||||
},
|
||||
) {}
|
||||
|
||||
/** Collection ends on a main-document replacement, never follows source URLs into another context. */
|
||||
ingest(event: CdpEvent): void {
|
||||
if (
|
||||
event.method === "Target.detachedFromTarget" ||
|
||||
event.method === "Inspector.targetCrashed"
|
||||
) {
|
||||
this.end("target_terminated");
|
||||
return;
|
||||
}
|
||||
if (!this.active) return;
|
||||
if (event.method === "Runtime.executionContextsCleared")
|
||||
this.end("document_changed");
|
||||
if (
|
||||
event.method === "Page.frameNavigated" &&
|
||||
typeof event.params === "object" &&
|
||||
event.params !== null &&
|
||||
"frame" in event.params
|
||||
) {
|
||||
const frame = event.params.frame;
|
||||
if (typeof frame === "object" && frame !== null && !("parentId" in frame))
|
||||
this.end("document_changed");
|
||||
}
|
||||
if (
|
||||
event.method === "Page.frameDetached" &&
|
||||
typeof event.params === "object" &&
|
||||
event.params !== null &&
|
||||
"frameId" in event.params &&
|
||||
event.params.frameId === this.session.target.frame_id
|
||||
)
|
||||
this.end("document_changed");
|
||||
}
|
||||
/** Metadata before arming is inventory; request events outside the window are excluded. */
|
||||
get active(): boolean {
|
||||
return (
|
||||
this.armedAt !== undefined &&
|
||||
this.reason === undefined &&
|
||||
this.endedAt === undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** The timer starts at arming, before progress delivery can delay the external action. */
|
||||
start(duration: number): Promise<EndReason> {
|
||||
this.armedAt = new Date().toISOString();
|
||||
if (this.#failure !== undefined) return Promise.reject(this.#failure);
|
||||
if (this.reason !== undefined) return Promise.resolve(this.reason);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#resolve = resolve;
|
||||
this.#reject = reject;
|
||||
this.#timer = setTimeout(() => this.end("window_elapsed"), duration);
|
||||
const signal = this.session.options.signal;
|
||||
const abort = (): void => {
|
||||
this.endedAt = new Date().toISOString();
|
||||
this.dispose();
|
||||
this.#reject?.(new AnalysisCancelledError(this.session.operation));
|
||||
};
|
||||
this.#removeAbort = () => signal?.removeEventListener("abort", abort);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
});
|
||||
}
|
||||
|
||||
/** Transport loss ends the window; instrumentation cleanup still requires confirmation. */
|
||||
end(reason: EndReason): void {
|
||||
if (this.reason !== undefined || this.#failure !== undefined) return;
|
||||
this.reason = reason;
|
||||
this.endedAt = new Date().toISOString();
|
||||
this.dispose();
|
||||
this.#resolve?.(reason);
|
||||
}
|
||||
/** Invalid producer data ends instrumentation immediately with its original failure. */
|
||||
fail(error: AnalysisError): void {
|
||||
if (this.reason !== undefined || this.#failure !== undefined) return;
|
||||
this.#failure = error;
|
||||
this.endedAt = new Date().toISOString();
|
||||
this.dispose();
|
||||
this.#reject?.(error);
|
||||
}
|
||||
/** Surface a failure recorded before local arming without emitting misleading progress. */
|
||||
check(): void {
|
||||
if (this.#failure !== undefined) throw this.#failure;
|
||||
}
|
||||
/** Release local timer and abort listener on every setup/failure path. */
|
||||
dispose(): void {
|
||||
if (this.#timer !== undefined) clearTimeout(this.#timer);
|
||||
this.#removeAbort?.();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { CdpCommandRejection } from "../CdpCommandRejection.js";
|
||||
import type { CdpRuntimeSession } from "./CdpRuntimeSession.js";
|
||||
|
||||
/** Classify Chromium's selector syntax rejection without changing other command/transport failures. */
|
||||
export const queryListenerSelector = async (
|
||||
session: CdpRuntimeSession,
|
||||
nodeId: number,
|
||||
selector: string,
|
||||
): Promise<unknown> => {
|
||||
try {
|
||||
return await session.command("DOM.querySelector", { nodeId, selector });
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof CdpCommandRejection &&
|
||||
cause.command === "DOM.querySelector" &&
|
||||
cause.code === -32000 &&
|
||||
cause.reportedMessage === "DOM Error while querying"
|
||||
)
|
||||
throw new AnalysisInputError(session.operation, { cause }, [
|
||||
{
|
||||
path: ["selector"],
|
||||
reason: "invalid_format",
|
||||
message: `Chromium rejected the CSS selector syntax for target ${session.target.target_id}: ${selector}. ${cause.userMessage ?? cause.message}`,
|
||||
},
|
||||
]);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import type { WebExecution } from "../../domain/webExecution.js";
|
||||
import type { WebRuntimeSource } from "../../domain/webRuntime.js";
|
||||
import { preciseCoverageSchema } from "./CdpRuntimeProtocol.js";
|
||||
import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
|
||||
|
||||
/** Validate source-range bounds against exact retained UTF-16 text without flattening nested counts. */
|
||||
export const normalizePreciseCoverage = (
|
||||
raw: unknown,
|
||||
sources: CdpRuntimeSources,
|
||||
retained: readonly WebRuntimeSource[],
|
||||
): {
|
||||
readonly coverage: WebExecution["coverage"];
|
||||
readonly timestamp: number;
|
||||
} => {
|
||||
const parsed = preciseCoverageSchema.safeParse(raw);
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
sources.session.operation,
|
||||
`Malformed precise coverage: ${parsed.error.message}`,
|
||||
);
|
||||
const scripts: WebExecution["coverage"]["scripts"] = [];
|
||||
let excluded = 0;
|
||||
for (const script of parsed.data.result) {
|
||||
if (!sources.belongsToDocument(script.scriptId)) {
|
||||
excluded += 1;
|
||||
continue;
|
||||
}
|
||||
const source = retained.find(
|
||||
(item) => item.script_id === script.scriptId,
|
||||
)?.source;
|
||||
scripts.push({
|
||||
script_id: script.scriptId,
|
||||
reported_url: sources.sourceUrl(script.scriptId, script.url),
|
||||
functions: script.functions.map((fn) => ({
|
||||
name: fn.functionName,
|
||||
is_block_coverage: fn.isBlockCoverage,
|
||||
ranges: fn.ranges.map((range) => {
|
||||
if (
|
||||
range.endOffset < range.startOffset ||
|
||||
(source?.state === "captured" &&
|
||||
range.endOffset > source.utf16_units)
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
sources.session.operation,
|
||||
`Script ${script.scriptId} coverage range ${range.startOffset}:${range.endOffset} is outside retained source bounds.`,
|
||||
);
|
||||
return {
|
||||
start_offset: range.startOffset,
|
||||
end_offset: range.endOffset,
|
||||
count: range.count,
|
||||
source_bounds:
|
||||
source?.state === "captured" ? "verified" : "unknown",
|
||||
};
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
return {
|
||||
timestamp: parsed.data.timestamp,
|
||||
coverage: {
|
||||
state: "captured",
|
||||
reason: null,
|
||||
offset_units: "utf16-code-units",
|
||||
end_offset: "exclusive",
|
||||
scripts,
|
||||
excluded_scripts: excluded,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { WebExecution } from "../../domain/webExecution.js";
|
||||
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
|
||||
import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
|
||||
|
||||
type Initiator = WebExecution["requests"][number]["reported_initiator"];
|
||||
const isObject = (value: Initiator[string] | undefined): value is Initiator =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Sanitize known transport fields while retaining declarations and every unknown producer field. */
|
||||
export const sanitizeRuntimeInitiator = (
|
||||
initiator: Initiator,
|
||||
sources: CdpRuntimeSources,
|
||||
): Initiator => {
|
||||
if (typeof initiator.url === "string")
|
||||
initiator.url = sanitizeBrowserUrl(initiator.url).url;
|
||||
let stack = initiator.stack;
|
||||
while (isObject(stack)) {
|
||||
if (Array.isArray(stack.callFrames))
|
||||
for (const frame of stack.callFrames)
|
||||
if (
|
||||
isObject(frame) &&
|
||||
typeof frame.url === "string" &&
|
||||
typeof frame.scriptId === "string"
|
||||
)
|
||||
frame.url = sources.sourceUrl(frame.scriptId, frame.url);
|
||||
stack = stack.parent;
|
||||
}
|
||||
return initiator;
|
||||
};
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const integer = z.number().int().min(0);
|
||||
const contextAuxDataSchema = z
|
||||
.object({
|
||||
frameId: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
type: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
/** Validated producer script metadata; optional declarations remain unknown when absent. */
|
||||
export const runtimeScriptParsedSchema = z.object({
|
||||
scriptId: z.string().min(1),
|
||||
url: z.string(),
|
||||
executionContextId: integer,
|
||||
startLine: integer,
|
||||
startColumn: integer,
|
||||
endLine: integer,
|
||||
endColumn: integer,
|
||||
hash: z.string().optional(),
|
||||
sourceMapURL: z.string().optional(),
|
||||
hasSourceURL: z.boolean().optional(),
|
||||
scriptLanguage: z.string().optional(),
|
||||
executionContextAuxData: contextAuxDataSchema.optional(),
|
||||
});
|
||||
/** Frame ownership is taken from producer context metadata, independently of source URLs. */
|
||||
export const runtimeContextSchema = z.object({
|
||||
context: z
|
||||
.object({
|
||||
id: integer,
|
||||
origin: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
uniqueId: z.string().optional(),
|
||||
auxData: contextAuxDataSchema.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
});
|
||||
const range = z.object({
|
||||
startOffset: integer,
|
||||
endOffset: integer,
|
||||
count: integer,
|
||||
});
|
||||
/** CDP precise coverage preserves every nested producer range. */
|
||||
export const preciseCoverageSchema = z.object({
|
||||
timestamp: z.number().min(0),
|
||||
result: z.array(
|
||||
z.object({
|
||||
scriptId: z.string().min(1),
|
||||
url: z.string(),
|
||||
functions: z.array(
|
||||
z.object({
|
||||
functionName: z.string(),
|
||||
isBlockCoverage: z.boolean(),
|
||||
ranges: z.array(range),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
/** The full reported synchronous and embedded asynchronous stack is retained. */
|
||||
export const runtimeStackSchema = z.object({
|
||||
callFrames: z.array(
|
||||
z.object({
|
||||
scriptId: z.string(),
|
||||
url: z.string(),
|
||||
lineNumber: integer,
|
||||
columnNumber: integer,
|
||||
functionName: z.string(),
|
||||
}),
|
||||
),
|
||||
parent: z.unknown().optional(),
|
||||
parentId: z
|
||||
.object({ id: z.string(), debuggerId: z.string().optional() })
|
||||
.optional(),
|
||||
});
|
||||
/** Request evidence contains metadata and producer initiators; bodies and headers are outside this tool. */
|
||||
export const runtimeRequestSchema = z.object({
|
||||
requestId: z.string().min(1),
|
||||
frameId: z.string().optional(),
|
||||
timestamp: z.number().min(0),
|
||||
request: z.object({ url: z.string(), method: z.string() }),
|
||||
initiator: z
|
||||
.object({
|
||||
type: z.string(),
|
||||
stack: z.unknown().optional(),
|
||||
url: z.string().optional(),
|
||||
lineNumber: integer.optional(),
|
||||
columnNumber: integer.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
});
|
||||
/** Native listener descriptors omit live remote handles from portable evidence. */
|
||||
export const runtimeListenersSchema = z.object({
|
||||
listeners: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
useCapture: z.boolean(),
|
||||
passive: z.boolean(),
|
||||
once: z.boolean(),
|
||||
scriptId: z.string(),
|
||||
lineNumber: integer,
|
||||
columnNumber: integer,
|
||||
}),
|
||||
),
|
||||
});
|
||||
/** Current main frame identity protects same-URL reloads as well as URL changes. */
|
||||
export const runtimeFrameTreeSchema = z.object({
|
||||
frameTree: z.object({
|
||||
frame: z.object({
|
||||
id: z.string().min(1),
|
||||
url: z.string(),
|
||||
loaderId: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import type { WebExecution } from "../../domain/webExecution.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import { WEB_RUNTIME_LIMITS } from "../../domain/webRuntime.js";
|
||||
import type { CdpEvent } from "../CdpConnection.js";
|
||||
import {
|
||||
runtimeRequestSchema,
|
||||
runtimeStackSchema,
|
||||
} from "./CdpRuntimeProtocol.js";
|
||||
import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
|
||||
import { sanitizeRuntimeInitiator } from "./CdpRuntimeInitiator.js";
|
||||
|
||||
/** Join producer initiators by script ID while preserving unresolved async parent identities. */
|
||||
export class CdpRuntimeRequests {
|
||||
readonly items: WebExecution["requests"] = [];
|
||||
excluded = 0;
|
||||
#bytes = 0;
|
||||
#failure: AnalysisOutputError | undefined;
|
||||
constructor(readonly sources: CdpRuntimeSources) {}
|
||||
|
||||
/** Retain only armed-window requests from the selected main frame and selected origins. */
|
||||
ingest(event: CdpEvent): void {
|
||||
if (
|
||||
event.method !== "Network.requestWillBeSent" ||
|
||||
this.#failure !== undefined
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const request = runtimeRequestSchema.parse(event.params);
|
||||
const url = sanitizeBrowserUrl(request.request.url);
|
||||
if (
|
||||
request.frameId !== this.sources.session.target.frame_id ||
|
||||
url.origin === null ||
|
||||
!this.sources.session.allowedOrigins.has(url.origin)
|
||||
) {
|
||||
this.excluded += 1;
|
||||
return;
|
||||
}
|
||||
this.#bytes += Buffer.byteLength(JSON.stringify(event.params));
|
||||
if (this.#bytes > WEB_RUNTIME_LIMITS.retainedEventBytes)
|
||||
throw new Error(
|
||||
"Request initiator evidence exceeds its complete 8 MiB event budget.",
|
||||
);
|
||||
const callsites: WebExecution["requests"][number]["callsites"] = [];
|
||||
const parents: WebExecution["requests"][number]["async_parent_ids"] = [];
|
||||
let current: unknown = request.initiator.stack;
|
||||
while (current !== undefined) {
|
||||
const stack = runtimeStackSchema.parse(current);
|
||||
for (const frame of stack.callFrames)
|
||||
callsites.push(
|
||||
this.sources.location(
|
||||
frame.scriptId,
|
||||
frame.url,
|
||||
frame.lineNumber,
|
||||
frame.columnNumber,
|
||||
frame.functionName,
|
||||
),
|
||||
);
|
||||
if (stack.parentId !== undefined)
|
||||
parents.push({
|
||||
id: stack.parentId.id,
|
||||
debugger_id: stack.parentId.debuggerId ?? null,
|
||||
});
|
||||
current = stack.parent;
|
||||
}
|
||||
if (
|
||||
request.initiator.url !== undefined &&
|
||||
request.initiator.lineNumber !== undefined
|
||||
)
|
||||
callsites.push(
|
||||
this.sources.location(
|
||||
"",
|
||||
sanitizeBrowserUrl(request.initiator.url).url,
|
||||
request.initiator.lineNumber,
|
||||
request.initiator.columnNumber ?? null,
|
||||
),
|
||||
);
|
||||
this.items.push({
|
||||
request_id: request.requestId,
|
||||
url: url.url,
|
||||
method: request.request.method,
|
||||
timestamp_seconds: request.timestamp,
|
||||
initiator_type: request.initiator.type,
|
||||
reported_initiator: sanitizeRuntimeInitiator(
|
||||
jsonObjectSchema.parse(request.initiator),
|
||||
this.sources,
|
||||
),
|
||||
callsites,
|
||||
async_parent_ids: parents,
|
||||
causal_attribution: "unknown",
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
this.#failure = new AnalysisOutputError(
|
||||
this.sources.session.operation,
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve all repeated/redirect requests and bind callsites after metadata collection ends. */
|
||||
result(): WebExecution["requests"] {
|
||||
this.check();
|
||||
return this.items.map((request) => ({
|
||||
...request,
|
||||
reported_initiator: sanitizeRuntimeInitiator(
|
||||
jsonObjectSchema.parse(request.reported_initiator),
|
||||
this.sources,
|
||||
),
|
||||
callsites: request.callsites.map((site) =>
|
||||
this.sources.location(
|
||||
site.script_id,
|
||||
site.url,
|
||||
site.line_number,
|
||||
site.column_number,
|
||||
site.function_name,
|
||||
),
|
||||
),
|
||||
}));
|
||||
}
|
||||
/** Report malformed or oversized producer evidence without partial success. */
|
||||
check(): void {
|
||||
if (this.#failure !== undefined) throw this.#failure;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisTimeoutError } from "../../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../../domain/browserObservationError.js";
|
||||
import type { BrowserObservationOperation } from "../../domain/browserObservationErrors.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
|
||||
import {
|
||||
WEB_RUNTIME_LIMITS,
|
||||
type webRuntimeTargetSchema,
|
||||
} from "../../domain/webRuntime.js";
|
||||
import type { z } from "zod";
|
||||
import { authorizeCdpTarget } from "../CdpAuthorizedTarget.js";
|
||||
import { authorizedMainFrame } from "../CdpAuthorizedMainFrame.js";
|
||||
import {
|
||||
discoverCdpEndpoint,
|
||||
type CdpEndpointDiscovery,
|
||||
} from "../CdpEndpoint.js";
|
||||
import {
|
||||
openCdpTargetSession,
|
||||
type CdpTargetSession,
|
||||
} from "../CdpTargetSession.js";
|
||||
import { runtimeFrameTreeSchema } from "./CdpRuntimeProtocol.js";
|
||||
|
||||
type Scope = {
|
||||
readonly cdp_endpoint: string;
|
||||
readonly target_id: string;
|
||||
readonly allowed_origins: readonly string[];
|
||||
};
|
||||
|
||||
/** Owned transport and instrumentation; the selected browser/page remain externally owned. */
|
||||
export class CdpRuntimeSession {
|
||||
readonly enabledDomains: string[] = [];
|
||||
preciseCoverageMayBeActive = false;
|
||||
#completion: AbortController | undefined;
|
||||
#completionTimer: NodeJS.Timeout | undefined;
|
||||
constructor(
|
||||
readonly transport: CdpTargetSession,
|
||||
readonly discovery: CdpEndpointDiscovery,
|
||||
readonly target: z.infer<typeof webRuntimeTargetSchema>,
|
||||
readonly allowedOrigins: ReadonlySet<string>,
|
||||
readonly operation: BrowserObservationOperation,
|
||||
readonly options: ExecutionOptions,
|
||||
) {}
|
||||
|
||||
/** Acquire a validated target; failed setup releases its owned socket. */
|
||||
static async open(
|
||||
input: Scope,
|
||||
operation: BrowserObservationOperation,
|
||||
options: ExecutionOptions,
|
||||
): Promise<CdpRuntimeSession> {
|
||||
return runtimeDeadline(operation, options.signal, async (signal) => {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
operation,
|
||||
signal,
|
||||
);
|
||||
const { target, allowedOrigins } = authorizeCdpTarget(
|
||||
discovery,
|
||||
input,
|
||||
operation,
|
||||
);
|
||||
const transport = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
operation,
|
||||
signal,
|
||||
{ maxPayloadBytes: WEB_RUNTIME_LIMITS.protocolBytes },
|
||||
);
|
||||
try {
|
||||
const tree = runtimeFrameTreeSchema.parse(
|
||||
await authorizedMainFrame({
|
||||
...transport,
|
||||
allowedOrigins,
|
||||
signal,
|
||||
operation,
|
||||
}),
|
||||
);
|
||||
const frame = tree.frameTree.frame;
|
||||
const url = sanitizeBrowserUrl(frame.url);
|
||||
if (url.origin === null)
|
||||
throw new BrowserObservationError(operation, "target_not_allowed");
|
||||
return new CdpRuntimeSession(
|
||||
transport,
|
||||
discovery,
|
||||
{
|
||||
target_id: target.id,
|
||||
initial_url: url.url,
|
||||
origin: url.origin,
|
||||
frame_id: frame.id,
|
||||
loader_id: frame.loaderId ?? null,
|
||||
},
|
||||
allowedOrigins,
|
||||
operation,
|
||||
options,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
await transport.connection.close();
|
||||
throw cause;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Bound each command separately from the caller-selected observation duration. */
|
||||
async command(
|
||||
method: string,
|
||||
params: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<unknown> {
|
||||
const signals = [this.options.signal, this.#completion?.signal].filter(
|
||||
(signal): signal is AbortSignal => signal !== undefined,
|
||||
);
|
||||
try {
|
||||
return await runtimeDeadline(
|
||||
this.operation,
|
||||
signals.length === 0 ? undefined : AbortSignal.any(signals),
|
||||
(signal) =>
|
||||
this.transport.connection.send(
|
||||
method,
|
||||
params,
|
||||
this.transport.sessionId,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (this.#completion?.signal.aborted && !this.options.signal?.aborted)
|
||||
throw new AnalysisTimeoutError(
|
||||
this.operation,
|
||||
WEB_RUNTIME_LIMITS.commandTimeoutMs,
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bound the complete inspection/source-join phase as well as each individual command. */
|
||||
beginCompletion(): void {
|
||||
if (this.#completion !== undefined) return;
|
||||
this.#completion = new AbortController();
|
||||
this.#completionTimer = setTimeout(
|
||||
() => this.#completion?.abort(),
|
||||
WEB_RUNTIME_LIMITS.commandTimeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
/** Register cleanup before sending: cancellation can occur after producer acceptance. */
|
||||
async enable(domain: string): Promise<void> {
|
||||
this.enabledDomains.push(domain);
|
||||
await this.command(`${domain}.enable`);
|
||||
}
|
||||
|
||||
/** Check document identity before joining any live node or final coverage evidence. */
|
||||
async assertDocument(): Promise<void> {
|
||||
const frame = runtimeFrameTreeSchema.parse(
|
||||
await this.command("Page.getFrameTree"),
|
||||
).frameTree.frame;
|
||||
const url = sanitizeBrowserUrl(frame.url);
|
||||
if (
|
||||
frame.id !== this.target.frame_id ||
|
||||
(frame.loaderId ?? null) !== this.target.loader_id ||
|
||||
(this.target.loader_id === null && url.url !== this.target.initial_url)
|
||||
)
|
||||
throw new BrowserObservationError(this.operation, "target_changed", {
|
||||
detail: `Selected target ${this.target.target_id} changed document during ${this.operation}.`,
|
||||
});
|
||||
if (url.origin === null || !this.allowedOrigins.has(url.origin))
|
||||
throw new BrowserObservationError(this.operation, "target_not_allowed", {
|
||||
detail: `Selected target ${this.target.target_id} moved to an unselected origin during ${this.operation}.`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cleanup uses its own deadline after caller cancellation, then closes REA's transport. */
|
||||
async close(
|
||||
previousError: AnalysisError | undefined,
|
||||
objectGroup?: string,
|
||||
): Promise<ProviderCleanupError | undefined> {
|
||||
if (this.#completionTimer !== undefined)
|
||||
clearTimeout(this.#completionTimer);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
WEB_RUNTIME_LIMITS.cleanupTimeoutMs,
|
||||
);
|
||||
const failures: string[] = [];
|
||||
const send = async (
|
||||
method: string,
|
||||
params: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await this.transport.connection.send(
|
||||
method,
|
||||
params,
|
||||
this.transport.sessionId,
|
||||
controller.signal,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
failures.push(
|
||||
`${method}: ${cause instanceof AnalysisError ? (cause.userMessage ?? cause.message) : cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (this.preciseCoverageMayBeActive)
|
||||
await send("Profiler.stopPreciseCoverage");
|
||||
if (objectGroup !== undefined)
|
||||
await send("Runtime.releaseObjectGroup", { objectGroup });
|
||||
for (const domain of [...this.enabledDomains].reverse())
|
||||
await send(`${domain}.disable`);
|
||||
if (this.transport.sessionId !== undefined) {
|
||||
try {
|
||||
await this.transport.connection.send(
|
||||
"Target.detachFromTarget",
|
||||
{ sessionId: this.transport.sessionId },
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
failures.push(
|
||||
`Target.detachFromTarget: ${cause instanceof AnalysisError ? (cause.userMessage ?? cause.message) : cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
await this.transport.connection.close();
|
||||
}
|
||||
return failures.length === 0
|
||||
? undefined
|
||||
: new ProviderCleanupError(
|
||||
"rea-cdp-browser",
|
||||
[this.target.target_id, "runtime-instrumentation"],
|
||||
{
|
||||
target_id: this.target.target_id,
|
||||
cleanup_failures: failures,
|
||||
previous_error:
|
||||
previousError?.userMessage ?? previousError?.message ?? null,
|
||||
},
|
||||
{ operation: this.operation },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a disposable command deadline without turning timeout into caller cancellation. */
|
||||
export const runtimeDeadline = async <T>(
|
||||
operation: BrowserObservationOperation,
|
||||
signal: AbortSignal | undefined,
|
||||
run: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
WEB_RUNTIME_LIMITS.commandTimeoutMs,
|
||||
);
|
||||
try {
|
||||
return await run(
|
||||
signal === undefined
|
||||
? controller.signal
|
||||
: AbortSignal.any([signal, controller.signal]),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (controller.signal.aborted && signal?.aborted !== true)
|
||||
throw new AnalysisTimeoutError(
|
||||
operation,
|
||||
WEB_RUNTIME_LIMITS.commandTimeoutMs,
|
||||
);
|
||||
throw cause;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
WEB_RUNTIME_LIMITS,
|
||||
type WebRuntimeSource,
|
||||
type webRuntimeLocationSchema,
|
||||
} from "../../domain/webRuntime.js";
|
||||
import type { CdpEvent } from "../CdpConnection.js";
|
||||
import type { CdpRuntimeSession } from "./CdpRuntimeSession.js";
|
||||
import {
|
||||
runtimeContextSchema,
|
||||
runtimeScriptParsedSchema,
|
||||
} from "./CdpRuntimeProtocol.js";
|
||||
|
||||
const scriptIdentitySchema = runtimeScriptParsedSchema.pick({ scriptId: true });
|
||||
const contextIdentitySchema = z.object({
|
||||
context: runtimeContextSchema.shape.context.pick({ id: true }),
|
||||
});
|
||||
|
||||
/** Source ownership is proven by execution context/frame metadata, independently of URL aliases. */
|
||||
export class CdpRuntimeSources {
|
||||
readonly scripts = new Map<
|
||||
string,
|
||||
z.infer<typeof runtimeScriptParsedSchema>
|
||||
>();
|
||||
readonly #contexts = new Map<
|
||||
number,
|
||||
z.infer<typeof runtimeContextSchema>["context"]
|
||||
>();
|
||||
readonly #changed = new Set<string>();
|
||||
readonly #changedContexts = new Set<number>();
|
||||
readonly #sources = new Map<string, WebRuntimeSource>();
|
||||
#retainedBytes = 0;
|
||||
#sourceBytes = 0;
|
||||
#failure: AnalysisError | undefined;
|
||||
constructor(readonly session: CdpRuntimeSession) {}
|
||||
|
||||
/** Subscribe before enabling Debugger; existing uncollected scripts are replayed by the producer. */
|
||||
ingest(event: CdpEvent, retainNewMetadata = true): void {
|
||||
if (
|
||||
event.sessionId !== this.session.transport.sessionId ||
|
||||
this.#failure !== undefined
|
||||
)
|
||||
return;
|
||||
try {
|
||||
if (event.method === "Runtime.executionContextCreated") {
|
||||
this.#retainedBytes += Buffer.byteLength(JSON.stringify(event.params));
|
||||
if (this.#retainedBytes > WEB_RUNTIME_LIMITS.retainedEventBytes)
|
||||
throw new Error(
|
||||
"Context/script metadata exceeds the complete 8 MiB event budget.",
|
||||
);
|
||||
const context = runtimeContextSchema.parse(event.params).context;
|
||||
const previous = this.#contexts.get(context.id);
|
||||
if (
|
||||
previous !== undefined &&
|
||||
JSON.stringify(previous) !== JSON.stringify(context)
|
||||
) {
|
||||
this.#changedContexts.add(context.id);
|
||||
for (const item of this.#sources.values())
|
||||
if (item.execution_context_id === context.id) this.revalidate(item);
|
||||
}
|
||||
if (retainNewMetadata) this.#contexts.set(context.id, context);
|
||||
} else if (event.method === "Debugger.scriptParsed") {
|
||||
this.#retainedBytes += Buffer.byteLength(JSON.stringify(event.params));
|
||||
if (this.#retainedBytes > WEB_RUNTIME_LIMITS.retainedEventBytes)
|
||||
throw new Error(
|
||||
"Script metadata exceeds the complete 8 MiB event budget.",
|
||||
);
|
||||
const script = runtimeScriptParsedSchema.parse(event.params);
|
||||
const previous = this.scripts.get(script.scriptId);
|
||||
if (
|
||||
previous !== undefined &&
|
||||
JSON.stringify(previous) !== JSON.stringify(script)
|
||||
) {
|
||||
this.#changed.add(script.scriptId);
|
||||
const retained = this.#sources.get(script.scriptId);
|
||||
if (retained !== undefined) this.revalidate(retained);
|
||||
}
|
||||
if (retainNewMetadata) this.scripts.set(script.scriptId, script);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
this.#failure = new AnalysisOutputError(
|
||||
this.session.operation,
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Producer parse/resource failures are surfaced outside the transport event callback. */
|
||||
check(): void {
|
||||
if (this.#failure !== undefined) throw this.#failure;
|
||||
}
|
||||
|
||||
/** Frozen inventories still monitor known identities through asynchronous source reads. */
|
||||
verifyKnownIdentity(event: CdpEvent): void {
|
||||
if (event.method === "Debugger.scriptParsed") {
|
||||
const id = scriptIdentitySchema.safeParse(event.params);
|
||||
if (id.success && this.scripts.has(id.data.scriptId))
|
||||
this.ingest(event, false);
|
||||
} else if (event.method === "Runtime.executionContextCreated") {
|
||||
const id = contextIdentitySchema.safeParse(event.params);
|
||||
if (id.success && this.#contexts.has(id.data.context.id))
|
||||
this.ingest(event, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Only proven default worlds of the selected main frame establish website source ownership. */
|
||||
belongsToDocument(scriptId: string): boolean {
|
||||
const script = this.scripts.get(scriptId);
|
||||
if (
|
||||
script === undefined ||
|
||||
this.#changed.has(scriptId) ||
|
||||
this.#changedContexts.has(script.executionContextId)
|
||||
)
|
||||
return false;
|
||||
const scriptContext = script.executionContextAuxData;
|
||||
const context = this.#contexts.get(script.executionContextId)?.auxData;
|
||||
const worlds = [scriptContext, context];
|
||||
if (
|
||||
worlds.some(
|
||||
(world) =>
|
||||
world?.isDefault === false ||
|
||||
(world?.type !== undefined && world.type !== "default"),
|
||||
) ||
|
||||
!worlds.some(
|
||||
(world) => world?.isDefault === true || world?.type === "default",
|
||||
)
|
||||
)
|
||||
return false;
|
||||
const reported = scriptContext?.frameId;
|
||||
const contextFrame = context?.frameId;
|
||||
return (
|
||||
(reported === undefined ||
|
||||
contextFrame === undefined ||
|
||||
reported === contextFrame) &&
|
||||
(reported ?? contextFrame) === this.session.target.frame_id
|
||||
);
|
||||
}
|
||||
|
||||
/** Retain complete script text and compute a digest distinct from the producer hash. */
|
||||
async read(scriptIds: Iterable<string>): Promise<WebRuntimeSource[]> {
|
||||
this.check();
|
||||
for (const scriptId of new Set(scriptIds)) {
|
||||
if (this.#sources.has(scriptId)) continue;
|
||||
const script = this.scripts.get(scriptId);
|
||||
const owned = this.belongsToDocument(scriptId);
|
||||
const context =
|
||||
script === undefined
|
||||
? undefined
|
||||
: this.#contexts.get(script.executionContextId);
|
||||
const item: WebRuntimeSource = {
|
||||
script_id: scriptId,
|
||||
url: this.sourceUrl(scriptId, script?.url ?? ""),
|
||||
execution_context_id: script?.executionContextId ?? null,
|
||||
reported_execution_context:
|
||||
context === undefined ? null : jsonObjectSchema.parse(context),
|
||||
reported_script_context_aux_data:
|
||||
script?.executionContextAuxData === undefined
|
||||
? null
|
||||
: jsonObjectSchema.parse(script.executionContextAuxData),
|
||||
frame_id: owned ? this.session.target.frame_id : null,
|
||||
producer_hash: script?.hash ?? null,
|
||||
source_map_url: script?.sourceMapURL ?? null,
|
||||
has_source_url: script?.hasSourceURL ?? null,
|
||||
language: script?.scriptLanguage ?? null,
|
||||
resource_start:
|
||||
script !== undefined
|
||||
? {
|
||||
line_number: script.startLine,
|
||||
column_number: script.startColumn,
|
||||
}
|
||||
: null,
|
||||
source: {
|
||||
state: "excluded",
|
||||
reason: this.#changed.has(scriptId)
|
||||
? "The script changed identity during observation; source attribution is unknown."
|
||||
: "Selected main-document default-world ownership was not established.",
|
||||
},
|
||||
};
|
||||
if (owned && script?.scriptLanguage !== "WebAssembly")
|
||||
item.source = await this.capture(scriptId);
|
||||
else if (owned)
|
||||
item.source = {
|
||||
state: "excluded",
|
||||
reason:
|
||||
"WebAssembly bytecode is outside this JavaScript source operation.",
|
||||
};
|
||||
this.revalidate(item);
|
||||
this.#sources.set(scriptId, item);
|
||||
}
|
||||
this.check();
|
||||
return [...this.#sources.values()];
|
||||
}
|
||||
|
||||
/** Resolve an exact producer script ID; URL equality is never an identity fallback. */
|
||||
location(
|
||||
scriptId: string,
|
||||
url: string | null,
|
||||
line: number,
|
||||
column: number | null,
|
||||
functionName: string | null = null,
|
||||
): z.infer<typeof webRuntimeLocationSchema> {
|
||||
return {
|
||||
script_id: scriptId,
|
||||
url: url === null ? null : this.sourceUrl(scriptId, url),
|
||||
line_number: line,
|
||||
column_number: column,
|
||||
function_name: functionName,
|
||||
source_association: this.belongsToDocument(scriptId)
|
||||
? "script_id"
|
||||
: "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
/** Declared script names are inert evidence; known resource URLs omit transport userinfo. */
|
||||
sourceUrl(scriptId: string, url: string): string {
|
||||
return !this.#changed.has(scriptId) &&
|
||||
this.scripts.get(scriptId)?.hasSourceURL === false
|
||||
? sanitizeBrowserUrl(url).url
|
||||
: url;
|
||||
}
|
||||
|
||||
private revalidate(item: WebRuntimeSource): void {
|
||||
if (item.frame_id === null || this.belongsToDocument(item.script_id))
|
||||
return;
|
||||
const previous =
|
||||
item.source.state === "unavailable"
|
||||
? ` Previous source read failure: ${item.source.reason}`
|
||||
: "";
|
||||
item.frame_id = null;
|
||||
item.source = {
|
||||
state: "excluded",
|
||||
reason: `The script or context changed identity during source collection; attribution is unknown.${previous}`,
|
||||
};
|
||||
}
|
||||
|
||||
private async capture(scriptId: string): Promise<WebRuntimeSource["source"]> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await this.session.command("Debugger.getScriptSource", {
|
||||
scriptId,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
this.session.options.signal?.aborted ||
|
||||
!(cause instanceof AnalysisError)
|
||||
)
|
||||
throw cause;
|
||||
return {
|
||||
state: "unavailable",
|
||||
reason: cause.userMessage ?? cause.message,
|
||||
};
|
||||
}
|
||||
const source = z.object({ scriptSource: z.string() }).safeParse(raw);
|
||||
if (!source.success)
|
||||
throw new AnalysisOutputError(
|
||||
this.session.operation,
|
||||
"Debugger.getScriptSource returned malformed source text.",
|
||||
);
|
||||
const text = source.data.scriptSource;
|
||||
const bytes = Buffer.byteLength(text);
|
||||
this.#sourceBytes += bytes;
|
||||
if (this.#sourceBytes > WEB_RUNTIME_LIMITS.sourceBytes)
|
||||
throw new AnalysisOutputError(
|
||||
this.session.operation,
|
||||
"Complete runtime source evidence exceeds its 32 MiB budget; no partial evidence is returned.",
|
||||
);
|
||||
return {
|
||||
state: "captured",
|
||||
text,
|
||||
sha256: createHash("sha256").update(text).digest("hex"),
|
||||
utf8_bytes: bytes,
|
||||
utf16_units: text.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { z } from "zod";
|
||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
import type { WebRuntimePort } from "../../application/WebRuntimePort.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import type { BrowserObservationOperation } from "../../domain/browserObservationErrors.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
webExecutionSchema,
|
||||
type ObserveWebExecutionInput,
|
||||
type WebExecution,
|
||||
} from "../../domain/webExecution.js";
|
||||
import {
|
||||
webEventListenersSchema,
|
||||
type InspectWebEventListenersInput,
|
||||
type WebEventListeners,
|
||||
} from "../../domain/webEventListeners.js";
|
||||
import { CDP_BROWSER_PROVIDER_IDENTITY } from "../providerIdentities.js";
|
||||
import { CdpRuntimeSession } from "./CdpRuntimeSession.js";
|
||||
import { CdpRuntimeSources } from "./CdpRuntimeSources.js";
|
||||
import { captureWebExecution } from "./CdpExecutionCapture.js";
|
||||
import {
|
||||
captureWebEventListeners,
|
||||
eventListenerObjectGroup,
|
||||
} from "./CdpEventListeners.js";
|
||||
|
||||
/** Native CDP integration with distinct inspection/instrumentation authority and confirmed cleanup. */
|
||||
export class CdpWebRuntimeProvider implements WebRuntimePort {
|
||||
/** Identify this adapter independently of the captured browser version. */
|
||||
identity() {
|
||||
return CDP_BROWSER_PROVIDER_IDENTITY;
|
||||
}
|
||||
|
||||
/** Observe one instrumented window while preserving the externally owned page. */
|
||||
observeExecution(
|
||||
input: ObserveWebExecutionInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebExecution, AnalysisError>> {
|
||||
return this.run(
|
||||
input,
|
||||
"observe_web_execution",
|
||||
options,
|
||||
webExecutionSchema,
|
||||
(sources) => captureWebExecution(input, sources),
|
||||
);
|
||||
}
|
||||
/** Inspect one selected node's registered callback locations. */
|
||||
inspectEventListeners(
|
||||
input: InspectWebEventListenersInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebEventListeners, AnalysisError>> {
|
||||
const group = eventListenerObjectGroup();
|
||||
return this.run(
|
||||
input,
|
||||
"inspect_web_event_listeners",
|
||||
options,
|
||||
webEventListenersSchema,
|
||||
(sources) => captureWebEventListeners(input, sources, group),
|
||||
group,
|
||||
);
|
||||
}
|
||||
|
||||
private async run<T>(
|
||||
input: ObserveWebExecutionInput | InspectWebEventListenersInput,
|
||||
operation: BrowserObservationOperation,
|
||||
options: ExecutionOptions,
|
||||
schema: z.ZodType<T>,
|
||||
capture: (sources: CdpRuntimeSources) => Promise<T>,
|
||||
group?: string,
|
||||
): Promise<Result<T, AnalysisError>> {
|
||||
let session: CdpRuntimeSession | undefined;
|
||||
let result: Result<T, AnalysisError>;
|
||||
try {
|
||||
session = await CdpRuntimeSession.open(input, operation, options);
|
||||
result = ok(schema.parse(await capture(new CdpRuntimeSources(session))));
|
||||
} catch (cause: unknown) {
|
||||
result = err(
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: cause instanceof z.ZodError
|
||||
? new AnalysisOutputError(operation, cause.message)
|
||||
: new ProviderAdapterError(this.identity().id, operation, {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (session !== undefined) {
|
||||
const failure = await session.close(
|
||||
result.ok ? undefined : result.error,
|
||||
group,
|
||||
);
|
||||
if (failure !== undefined) return err(failure);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { registerBrowserCommands } from "./cli/browserCommands.js";
|
|||
import { registerWebScriptCommands } from "./cli/webScriptCommands.js";
|
||||
import { registerWebModuleCommands } from "./cli/webModuleCommands.js";
|
||||
import { registerWebSourceLocationCommands } from "./cli/webSourceLocationCommands.js";
|
||||
import { registerWebRuntimeCommands } from "./cli/webRuntimeCommands.js";
|
||||
import { registerJavaScriptRecoveryCommands } from "./cli/javascriptRecoveryCommands.js";
|
||||
import { registerAdvancedBrowserCommands } from "./cli/browserAdvancedCommands.js";
|
||||
import { registerBrowserScenarioCommands } from "./cliBrowserScenarioCommands.js";
|
||||
|
|
@ -61,6 +62,7 @@ export const createCli = (
|
|||
registerWebScriptCommands(cli, logger);
|
||||
registerWebModuleCommands(cli, logger, environment);
|
||||
registerWebSourceLocationCommands(cli, logger, environment);
|
||||
registerWebRuntimeCommands(cli, logger);
|
||||
registerJavaScriptRecoveryCommands(cli, logger, environment);
|
||||
registerAdvancedBrowserCommands(cli, logger);
|
||||
registerBrowserScenarioCommands(cli, logger);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { Cli, z } from "incur";
|
||||
import { createWebRuntimeService } from "../composition/webRuntime.js";
|
||||
import { browserCliError } from "../cliBrowserContext.js";
|
||||
import {
|
||||
browserScopeOptions,
|
||||
observationDuration,
|
||||
} from "../cliObservationOptions.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
|
||||
/** CLI progress exposes the actual armed point on stderr without corrupting JSON results. */
|
||||
const runtimeProgress: ProgressReporter = {
|
||||
report: async (update) => {
|
||||
process.stderr.write(`${JSON.stringify({ rea_progress: update })}\n`);
|
||||
},
|
||||
};
|
||||
|
||||
/** Register public equivalents of the two modular website runtime tools. */
|
||||
export const registerWebRuntimeCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.observeWebExecution, {
|
||||
description:
|
||||
"Observe precise page execution; resets counters and temporarily disables optimization",
|
||||
args: z.object({
|
||||
endpoint: z.string().describe("Existing loopback browser CDP endpoint"),
|
||||
targetId: z
|
||||
.string()
|
||||
.describe("Selected page target ID from list-browser-targets"),
|
||||
}),
|
||||
options: z.object({
|
||||
...browserScopeOptions,
|
||||
observationMs: observationDuration(10_000, 1),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "observe-web-execution", async () => {
|
||||
const result = await withCommandCancellation((signal) =>
|
||||
createWebRuntimeService().observe(
|
||||
{
|
||||
cdp_endpoint: args.endpoint,
|
||||
target_id: args.targetId,
|
||||
allowed_origins: options.allowedOrigins,
|
||||
observation_ms: options.observationMs,
|
||||
},
|
||||
{ progress: runtimeProgress, signal },
|
||||
),
|
||||
);
|
||||
return result.ok ? result.value : browserCliError(result.error);
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectWebEventListeners, {
|
||||
description:
|
||||
"Inspect native listener source locations on the first main-document CSS match",
|
||||
args: z.object({
|
||||
endpoint: z.string().describe("Existing loopback browser CDP endpoint"),
|
||||
targetId: z
|
||||
.string()
|
||||
.describe("Selected page target ID from list-browser-targets"),
|
||||
selector: z
|
||||
.string()
|
||||
.describe("Native CSS selector; inspect the first main-document match"),
|
||||
}),
|
||||
options: z.object({ ...browserScopeOptions }),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "inspect-web-event-listeners", async () => {
|
||||
const result = await withCommandCancellation((signal) =>
|
||||
createWebRuntimeService().inspect(
|
||||
{
|
||||
cdp_endpoint: args.endpoint,
|
||||
target_id: args.targetId,
|
||||
selector: args.selector,
|
||||
allowed_origins: options.allowedOrigins,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
);
|
||||
return result.ok ? result.value : browserCliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
/** Canonical names used by every one-shot CLI command registration. */
|
||||
export const CLI_COMMANDS = Object.freeze({
|
||||
observeWebExecution: "observe-web-execution",
|
||||
inspectWebEventListeners: "inspect-web-event-listeners",
|
||||
traceWebSourceLocation: "trace-web-source-location",
|
||||
traceWebModuleImports: "trace-web-module-imports",
|
||||
analyze: "analyze",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { WebRuntimeService } from "../application/WebRuntimeService.js";
|
||||
import { CdpWebRuntimeProvider } from "../browser/execution/CdpWebRuntimeProvider.js";
|
||||
|
||||
/** Construct the live attribution workflow without opening a browser connection. */
|
||||
export const createWebRuntimeService = (): WebRuntimeService =>
|
||||
new WebRuntimeService(new CdpWebRuntimeProvider());
|
||||
|
|
@ -15,6 +15,7 @@ import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "./browserScenarioToolContracts.
|
|||
import { ELECTRON_TOOL_CONTRACTS } from "./javascript/electronToolContracts.js";
|
||||
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascript/javascriptRuntimeObservationToolContracts.js";
|
||||
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
||||
import { WEB_RUNTIME_TOOL_CONTRACTS } from "./webRuntimeToolContracts.js";
|
||||
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
|
||||
import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascript/javascriptRecoveryToolContracts.js";
|
||||
import { TOOL_EFFECTS } from "./toolEffects.js";
|
||||
|
|
@ -78,6 +79,7 @@ describe("tool contract surface", () => {
|
|||
...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
||||
...APPLICATION_TOOL_CONTRACTS,
|
||||
...WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
...WEB_RUNTIME_TOOL_CONTRACTS,
|
||||
...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
].map(({ name }) => name);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascript/java
|
|||
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
||||
import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascript/javascriptRecoveryToolContracts.js";
|
||||
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
|
||||
import { WEB_RUNTIME_TOOL_CONTRACTS } from "./webRuntimeToolContracts.js";
|
||||
|
||||
const GROUPS = {
|
||||
official: OFFICIAL_TOOL_CONTRACTS,
|
||||
|
|
@ -32,6 +33,7 @@ const GROUPS = {
|
|||
application: APPLICATION_TOOL_CONTRACTS,
|
||||
javascript_recovery: JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||
web_script_export: WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
web_runtime: WEB_RUNTIME_TOOL_CONTRACTS,
|
||||
session: SESSION_TOOL_CONTRACTS,
|
||||
} as const;
|
||||
|
||||
|
|
@ -85,6 +87,8 @@ const PUBLISHED_TOOL_NAME_FLOOR = [
|
|||
"export_web_scripts",
|
||||
"trace_web_module_imports",
|
||||
"trace_web_source_location",
|
||||
"observe_web_execution",
|
||||
"inspect_web_event_listeners",
|
||||
"find_changed_behavior",
|
||||
"find_xrefs_to_name",
|
||||
"get_call_graph",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { ELECTRON_TOOL_CONTRACTS } from "./javascript/electronToolContracts.js";
|
|||
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascript/javascriptRuntimeObservationToolContracts.js";
|
||||
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
||||
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
|
||||
import { WEB_RUNTIME_TOOL_CONTRACTS } from "./webRuntimeToolContracts.js";
|
||||
import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascript/javascriptRecoveryToolContracts.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "./officialToolContracts.js";
|
||||
import { ENHANCED_TOOL_CONTRACTS } from "./enhancedToolContracts.js";
|
||||
|
|
@ -31,6 +32,7 @@ export const TOOL_CONTRACTS = [
|
|||
...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
||||
...APPLICATION_TOOL_CONTRACTS,
|
||||
...WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
...WEB_RUNTIME_TOOL_CONTRACTS,
|
||||
...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,14 @@ const sessionEvidence = effects({ mutatesSession: true });
|
|||
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
observe_web_execution: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
accessesNetwork: true,
|
||||
mayDiscardData: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
inspect_web_event_listeners: browserEvidence,
|
||||
trace_web_source_location: effects({
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import type { ToolContract } from "./toolContracts.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemas.js";
|
||||
import {
|
||||
observeWebExecutionInputSchema,
|
||||
webExecutionSchema,
|
||||
} from "../domain/webExecution.js";
|
||||
import {
|
||||
inspectWebEventListenersInputSchema,
|
||||
webEventListenersSchema,
|
||||
} from "../domain/webEventListeners.js";
|
||||
|
||||
/** Native website runtime primitives distinguish instrumentation from listener inspection. */
|
||||
export const WEB_RUNTIME_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "observe_web_execution",
|
||||
...toolContractMetadata("observe_web_execution"),
|
||||
description:
|
||||
"Instrument one selected CDP page for a finite, externally driven precise coverage window. Resets execution counters and prevents optimized execution until stopped. Returns complete nested UTF-16 ranges/counts, independently hashed script text and request initiator associations by session script ID. Does not navigate, click, evaluate selected code or close the externally owned page. Navigation ends document-scoped collection; workers and UI causality remain outside this claim. Emits an actual armed progress notification before external actions.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: observeWebExecutionInputSchema,
|
||||
outputSchema: evidenceResultOf(webExecutionSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Attribute externally triggered page execution",
|
||||
input: {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
observation_ms: 10_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "inspect_web_event_listeners",
|
||||
...toolContractMetadata("inspect_web_event_listeners"),
|
||||
description:
|
||||
"Inspect listeners registered directly on the first main-document CSS match in one selected CDP page. Returns event flags, native callback locations, source ownership and exact script text/digests inline. Does not dispatch events, evaluate JavaScript or invoke handlers. Delegated ancestor/framework listeners and execution history remain unknown. Releases REA's object group and transport, preserving the external page.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: inspectWebEventListenersInputSchema,
|
||||
outputSchema: evidenceResultOf(webEventListenersSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Find a selected button's callback source",
|
||||
input: {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
selector: "#submit",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
|
@ -11,13 +11,15 @@ export class BrowserObservationError extends AnalysisError {
|
|||
override readonly cleanupIncomplete: boolean;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
override readonly userCategory: "cancelled" | undefined;
|
||||
override readonly userMessage: string | undefined;
|
||||
|
||||
constructor(
|
||||
readonly operation: BrowserObservationOperation,
|
||||
readonly reason: BrowserObservationFailureReason,
|
||||
options?: ErrorOptions,
|
||||
options?: ErrorOptions & { readonly detail?: string },
|
||||
) {
|
||||
super(`Browser observation ${operation} failed: ${reason}`, options);
|
||||
this.userMessage = options?.detail;
|
||||
this.cleanupIncomplete = reason === "cleanup_failed";
|
||||
this.cleanupResources =
|
||||
reason === "cleanup_failed" ? ["browser_transport"] : [];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ export type BrowserObservationOperation =
|
|||
| "analyze_web_bundle"
|
||||
| "trace_web_module_imports"
|
||||
| "observe_web_session"
|
||||
| "observe_web_execution"
|
||||
| "inspect_web_event_listeners"
|
||||
| "discover_webmcp_tools"
|
||||
| "compare_web_captures"
|
||||
| "capture_web_screenshot"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
webRuntimeBrowserSchema,
|
||||
webRuntimeLocationSchema,
|
||||
webRuntimeScopeSchema,
|
||||
webRuntimeSourceSchema,
|
||||
webRuntimeTargetSchema,
|
||||
} from "./webRuntime.js";
|
||||
|
||||
/** Inspect listeners registered directly on the first main-document CSS match. */
|
||||
export const inspectWebEventListenersInputSchema = webRuntimeScopeSchema.extend(
|
||||
{
|
||||
selector: z.string().min(1),
|
||||
},
|
||||
);
|
||||
export type InspectWebEventListenersInput = z.infer<
|
||||
typeof inspectWebEventListenersInputSchema
|
||||
>;
|
||||
|
||||
/** A listener declaration and its source location do not establish that it ran. */
|
||||
export const webEventListenersSchema = z.object({
|
||||
browser: webRuntimeBrowserSchema,
|
||||
target: webRuntimeTargetSchema,
|
||||
inspected_at: z.iso.datetime(),
|
||||
selected_node: z.object({
|
||||
selector: z.string(),
|
||||
match: z.enum(["found", "not_found"]),
|
||||
backend_node_id: z.number().int().min(1).nullable(),
|
||||
}),
|
||||
listeners: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
use_capture: z.boolean(),
|
||||
passive: z.boolean(),
|
||||
once: z.boolean(),
|
||||
location: webRuntimeLocationSchema,
|
||||
execution: z.literal("unknown"),
|
||||
}),
|
||||
),
|
||||
sources: z.array(webRuntimeSourceSchema),
|
||||
cleanup: z.literal("confirmed"),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type WebEventListeners = z.infer<typeof webEventListenersSchema>;
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { z } from "zod";
|
||||
import { jsonObjectSchema } from "./jsonValue.js";
|
||||
import {
|
||||
webRuntimeBrowserSchema,
|
||||
webRuntimeLocationSchema,
|
||||
webRuntimeScopeSchema,
|
||||
webRuntimeSourceSchema,
|
||||
webRuntimeTargetSchema,
|
||||
} from "./webRuntime.js";
|
||||
|
||||
/** Explicit instrumentation request for one finite, externally driven execution window. */
|
||||
export const observeWebExecutionInputSchema = webRuntimeScopeSchema.extend({
|
||||
observation_ms: z.number().int().min(1).max(2_147_483_647).default(10_000),
|
||||
});
|
||||
export type ObserveWebExecutionInput = z.infer<
|
||||
typeof observeWebExecutionInputSchema
|
||||
>;
|
||||
|
||||
const rangeSchema = z.object({
|
||||
start_offset: z.number().int().min(0),
|
||||
end_offset: z.number().int().min(0),
|
||||
count: z.number().int().min(0),
|
||||
source_bounds: z.enum(["verified", "unknown"]),
|
||||
});
|
||||
const functionSchema = z.object({
|
||||
name: z.string(),
|
||||
is_block_coverage: z.boolean(),
|
||||
ranges: z.array(rangeSchema),
|
||||
});
|
||||
|
||||
/** Precise V8 ranges retain their nested structure and counts without summing them. */
|
||||
export const webExecutionSchema = z.object({
|
||||
browser: webRuntimeBrowserSchema,
|
||||
target: webRuntimeTargetSchema,
|
||||
window: z.object({
|
||||
armed_at: z.iso.datetime(),
|
||||
ended_at: z.iso.datetime(),
|
||||
requested_ms: z.number().int().min(1),
|
||||
end_reason: z.enum([
|
||||
"window_elapsed",
|
||||
"document_changed",
|
||||
"target_terminated",
|
||||
]),
|
||||
producer_started_seconds: z.number().min(0),
|
||||
producer_sampled_seconds: z.number().min(0).nullable(),
|
||||
}),
|
||||
coverage: z.object({
|
||||
state: z.enum(["captured", "unavailable"]),
|
||||
reason: z.string().nullable(),
|
||||
offset_units: z.literal("utf16-code-units"),
|
||||
end_offset: z.literal("exclusive"),
|
||||
scripts: z.array(
|
||||
z.object({
|
||||
script_id: z.string().min(1),
|
||||
reported_url: z.string(),
|
||||
functions: z.array(functionSchema),
|
||||
}),
|
||||
),
|
||||
excluded_scripts: z.number().int().min(0),
|
||||
}),
|
||||
requests: z.array(
|
||||
z.object({
|
||||
request_id: z.string().min(1),
|
||||
url: z.string(),
|
||||
method: z.string(),
|
||||
timestamp_seconds: z.number().min(0),
|
||||
initiator_type: z.string(),
|
||||
reported_initiator: jsonObjectSchema,
|
||||
callsites: z.array(webRuntimeLocationSchema),
|
||||
async_parent_ids: z.array(
|
||||
z.object({ id: z.string(), debugger_id: z.string().nullable() }),
|
||||
),
|
||||
causal_attribution: z.literal("unknown"),
|
||||
}),
|
||||
),
|
||||
excluded_requests: z.number().int().min(0),
|
||||
script_inventory: z.object({
|
||||
main_document_scripts: z.number().int().min(0),
|
||||
not_reported_script_ids: z.array(z.string().min(1)),
|
||||
coverage_absence: z.literal("unknown"),
|
||||
}),
|
||||
sources: z.array(webRuntimeSourceSchema),
|
||||
instrumentation: z.object({
|
||||
resets_execution_counters: z.literal(true),
|
||||
disables_optimized_execution: z.literal(true),
|
||||
takes_one_resetting_sample: z.boolean(),
|
||||
executes_selected_code: z.literal(false),
|
||||
cleanup: z.literal("confirmed"),
|
||||
page_ownership: z.literal("external"),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type WebExecution = z.infer<typeof webExecutionSchema>;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
browserAllowedOriginsSchema,
|
||||
browserEndpointSchema,
|
||||
} from "./browserObservation.js";
|
||||
import { browserVersionSchema } from "./browserObservationSchemas.js";
|
||||
import { jsonObjectSchema } from "./jsonValue.js";
|
||||
|
||||
/** Scope for one externally owned page; omitted origins select its current origin. */
|
||||
export const webRuntimeScopeSchema = z.strictObject({
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
allowed_origins: browserAllowedOriginsSchema,
|
||||
target_id: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Source evidence is bound by the selected CDP session's script ID, never URL equality. */
|
||||
export const webRuntimeSourceSchema = z.object({
|
||||
script_id: z.string().min(1),
|
||||
url: z.string(),
|
||||
execution_context_id: z.number().int().nullable(),
|
||||
reported_execution_context: jsonObjectSchema.nullable(),
|
||||
reported_script_context_aux_data: jsonObjectSchema.nullable(),
|
||||
frame_id: z.string().nullable(),
|
||||
producer_hash: z.string().nullable(),
|
||||
source_map_url: z.string().nullable(),
|
||||
has_source_url: z.boolean().nullable(),
|
||||
language: z.string().nullable(),
|
||||
resource_start: z
|
||||
.object({
|
||||
line_number: z.number().int().min(0),
|
||||
column_number: z.number().int().min(0),
|
||||
})
|
||||
.nullable(),
|
||||
source: z.discriminatedUnion("state", [
|
||||
z.object({
|
||||
state: z.literal("captured"),
|
||||
text: z.string(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
utf8_bytes: z.number().int().min(0),
|
||||
utf16_units: z.number().int().min(0),
|
||||
}),
|
||||
z.object({
|
||||
state: z.enum(["unavailable", "excluded"]),
|
||||
reason: z.string().min(1),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
export type WebRuntimeSource = z.infer<typeof webRuntimeSourceSchema>;
|
||||
|
||||
/** Producer callsite coordinates are zero-based resource lines and UTF-16 columns. */
|
||||
export const webRuntimeLocationSchema = z.object({
|
||||
script_id: z.string(),
|
||||
url: z.string().nullable(),
|
||||
line_number: z.number().int().min(0),
|
||||
column_number: z.number().int().min(0).nullable(),
|
||||
function_name: z.string().nullable(),
|
||||
source_association: z.enum(["script_id", "unknown"]),
|
||||
});
|
||||
|
||||
/** Common identity for a selected live page document. */
|
||||
export const webRuntimeTargetSchema = z.object({
|
||||
target_id: z.string().min(1),
|
||||
origin: z.string().min(1),
|
||||
initial_url: z.string(),
|
||||
frame_id: z.string().min(1),
|
||||
loader_id: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Producer product identity is observed separately from the unknown host platform. */
|
||||
export const webRuntimeBrowserSchema = browserVersionSchema;
|
||||
|
||||
/** Complete runtime evidence has explicit byte budgets rather than silent item truncation. */
|
||||
export const WEB_RUNTIME_LIMITS = {
|
||||
protocolBytes: 64 * 1024 * 1024,
|
||||
retainedEventBytes: 8 * 1024 * 1024,
|
||||
sourceBytes: 32 * 1024 * 1024,
|
||||
commandTimeoutMs: 20_000,
|
||||
cleanupTimeoutMs: 5_000,
|
||||
} as const;
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -22,6 +22,9 @@ import { createWebModuleTraceService } from "../composition/webModules.js";
|
|||
import { createWebSourceLocationService } from "../composition/webSourceLocations.js";
|
||||
import { registerWebSourceLocationTool } from "./registerWebSourceLocationTool.js";
|
||||
import type { WebSourceLocationService } from "../application/WebSourceLocationService.js";
|
||||
import type { WebRuntimeService } from "../application/WebRuntimeService.js";
|
||||
import { createWebRuntimeService } from "../composition/webRuntime.js";
|
||||
import { registerWebRuntimeTools } from "./registerWebRuntimeTools.js";
|
||||
import { registerJavaScriptRecoveryTool } from "./registerJavaScriptRecoveryTool.js";
|
||||
import { JavaScriptRecoveryService } from "../application/javascript/JavaScriptRecoveryService.js";
|
||||
import type { JavaScriptRecoveryPort } from "../application/javascript/JavaScriptRecoveryPort.js";
|
||||
|
|
@ -58,6 +61,7 @@ export interface CreateServerOptions {
|
|||
readonly javascriptRecovery?: JavaScriptRecoveryPort;
|
||||
readonly webModuleTrace?: WebModuleTraceService;
|
||||
readonly webSourceLocation?: WebSourceLocationService;
|
||||
readonly webRuntime?: WebRuntimeService;
|
||||
readonly androidAnalysis?: AndroidAnalysisPort;
|
||||
readonly browserObservation?: BrowserObservationPort;
|
||||
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
|
||||
|
|
@ -203,6 +207,12 @@ export const createServer = (
|
|||
toolLogger,
|
||||
recordEvidence,
|
||||
);
|
||||
registerWebRuntimeTools(
|
||||
server,
|
||||
options.webRuntime ?? createWebRuntimeService(),
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
);
|
||||
registerJavaScriptRecoveryTool(
|
||||
server,
|
||||
new JavaScriptRecoveryService(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
import type { WebRuntimeService } from "../application/WebRuntimeService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { mcpProgressReporter } from "./mcpProgress.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
|
||||
/** Bind distinct runtime operations to named contracts and the session's Evidence owner. */
|
||||
export const registerWebRuntimeTools = (
|
||||
server: McpServer,
|
||||
service: WebRuntimeService,
|
||||
logger: Logger,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
): void => {
|
||||
const execution = toolContract("observe_web_execution");
|
||||
const listeners = toolContract("inspect_web_event_listeners");
|
||||
server.registerTool(
|
||||
execution.name,
|
||||
toolRegistrationOptions(execution),
|
||||
async (input, context) => {
|
||||
const result = await logToolExecution(logger, execution.name, () =>
|
||||
service.observe(input, {
|
||||
signal: context.mcpReq.signal,
|
||||
progress: mcpProgressReporter(context),
|
||||
}),
|
||||
);
|
||||
if (!result.ok) return toCallToolResult(result, execution);
|
||||
const recorded = recordEvidence?.(result.value);
|
||||
return recorded !== undefined && !recorded.ok
|
||||
? toCallToolResult(recorded, execution)
|
||||
: toCallToolResult(result, execution);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
listeners.name,
|
||||
toolRegistrationOptions(listeners),
|
||||
async (input, context) => {
|
||||
const result = await logToolExecution(logger, listeners.name, () =>
|
||||
service.inspect(input, { signal: context.mcpReq.signal }),
|
||||
);
|
||||
if (!result.ok) return toCallToolResult(result, listeners);
|
||||
const recorded = recordEvidence?.(result.value);
|
||||
return recorded !== undefined && !recorded.ok
|
||||
? toCallToolResult(recorded, listeners)
|
||||
: toCallToolResult(result, listeners);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { execFile, spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { webEventListenersSchema } from "../../src/domain/webEventListeners.js";
|
||||
import { startRuntimeBrowser } from "../fixtures/webRuntime.js";
|
||||
|
||||
const entrypoint = fileURLToPath(
|
||||
new URL("../../scripts/rea.mjs", import.meta.url),
|
||||
);
|
||||
const environment = () => ({
|
||||
...process.env,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper",
|
||||
});
|
||||
|
||||
it("runs the compiled public listener command through its default native CDP composition", async () => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-web-event-listeners",
|
||||
browser.endpoint,
|
||||
"allowed-page",
|
||||
"#selected",
|
||||
"--json",
|
||||
],
|
||||
{ env: environment(), timeout: 20_000 },
|
||||
);
|
||||
const result = webEventListenersSchema.parse(
|
||||
parseEvidence(JSON.parse(stdout)).normalized_result,
|
||||
);
|
||||
expect(result.listeners[0]?.location).toMatchObject({
|
||||
script_id: "script-a",
|
||||
source_association: "script_id",
|
||||
});
|
||||
expect(result.selected_node.selector).toBe("#selected");
|
||||
});
|
||||
|
||||
it("translates SIGINT after real CLI arming into awaited instrumentation cleanup and a nonzero CLI failure", async () => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"observe-web-execution",
|
||||
browser.endpoint,
|
||||
"allowed-page",
|
||||
"--observation-ms",
|
||||
"30000",
|
||||
"--json",
|
||||
],
|
||||
{ env: environment(), stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
onTestFinished(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGKILL");
|
||||
});
|
||||
let stderr = "";
|
||||
let stdout = "";
|
||||
let armed = false;
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), 20_000);
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
if (
|
||||
!armed &&
|
||||
stderr.includes('"completed":1') &&
|
||||
stderr.includes("browser_execution")
|
||||
) {
|
||||
armed = true;
|
||||
child.kill("SIGINT");
|
||||
}
|
||||
});
|
||||
try {
|
||||
const [code, signal] = await once(child, "close");
|
||||
expect(armed, stderr).toBe(true);
|
||||
expect(code, stderr).not.toBe(0);
|
||||
expect(signal).toBeNull();
|
||||
const result: unknown = JSON.parse(stdout);
|
||||
expect(result).toMatchObject({
|
||||
code: "cancelled",
|
||||
category: "cancelled",
|
||||
details: { cleanup: "complete", operation: "observe_web_execution" },
|
||||
});
|
||||
expect(
|
||||
browser.commands.some(
|
||||
(command) => command.method === "Profiler.stopPreciseCoverage",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
browser.commands.some(
|
||||
(command) => command.method === "Target.detachFromTarget",
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
|
@ -62,4 +62,26 @@ describe("CDP connection", () => {
|
|||
await connection.close();
|
||||
}
|
||||
});
|
||||
it("preserves the selected payload limit reason for pending and subsequent commands", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
commandResult: () => ({ oversized: "x".repeat(2_048) }),
|
||||
});
|
||||
browsers.push(browser);
|
||||
const connection = await CdpConnection.connect(
|
||||
browser.browserWebSocketUrl,
|
||||
"observe_web_execution",
|
||||
undefined,
|
||||
{ maxPayloadBytes: 512 },
|
||||
);
|
||||
try {
|
||||
for (const method of ["Runtime.enable", "Debugger.enable"]) {
|
||||
await expect(connection.send(method)).rejects.toMatchObject({
|
||||
reason: "payload_limit",
|
||||
userMessage: expect.stringContaining("512 byte protocol budget"),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { CdpConnection } from "../../../src/browser/CdpConnection.js";
|
||||
import type { BrowserObservationError } from "../../../src/domain/browserObservationError.js";
|
||||
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
||||
|
||||
it("reports one fatal wire failure without a pending command and retains it for late subscribers", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const connection = await CdpConnection.connect(
|
||||
browser.browserWebSocketUrl,
|
||||
"observe_web_execution",
|
||||
);
|
||||
onTestFinished(() => connection.close());
|
||||
const failures: BrowserObservationError[] = [];
|
||||
const failure = new Promise<BrowserObservationError>((resolve) => {
|
||||
connection.onProtocolFailure((error) => {
|
||||
failures.push(error);
|
||||
resolve(error);
|
||||
});
|
||||
});
|
||||
browser.emitRawMessage("{not-json");
|
||||
browser.emitRawMessage(JSON.stringify({ method: 42 }));
|
||||
expect(await failure).toMatchObject({ reason: "protocol_error" });
|
||||
let lateFailure: BrowserObservationError | undefined;
|
||||
connection.onProtocolFailure((error) => {
|
||||
lateFailure = error;
|
||||
});
|
||||
expect(lateFailure).toMatchObject({ reason: "protocol_error" });
|
||||
await expect(connection.send("Page.getFrameTree")).rejects.toMatchObject({
|
||||
reason: "protocol_error",
|
||||
});
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(browser.commands).toEqual([]);
|
||||
});
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
||||
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
|
||||
import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js";
|
||||
import {
|
||||
runtimeSourceA,
|
||||
runtimeSourceB,
|
||||
startRuntimeBrowser,
|
||||
} from "../../fixtures/webRuntime.js";
|
||||
import type { FakeOptions } from "../../fixtures/fakeCdpBrowser.js";
|
||||
|
||||
const fixture = async (options: FakeOptions = {}) => {
|
||||
const browser = await startRuntimeBrowser(options);
|
||||
onTestFinished(() => browser.close());
|
||||
const provider = new CdpWebRuntimeProvider();
|
||||
const input = {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 5,
|
||||
};
|
||||
return {
|
||||
browser,
|
||||
provider,
|
||||
execution: observeWebExecutionInputSchema.parse(input),
|
||||
listeners: inspectWebEventListenersInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
selector: "#selected",
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe("native runtime producer boundary", () => {
|
||||
it("binds complete producer stacks by script ID and ignores foreign sessions without URL guessing", async () => {
|
||||
const { browser, provider, execution } = await fixture();
|
||||
const params = {
|
||||
requestId: "selected-request",
|
||||
frameId: "runtime-main",
|
||||
timestamp: 12.5,
|
||||
request: {
|
||||
url: `${browser.endpoint}/request?caller-value=retained`,
|
||||
method: "POST",
|
||||
},
|
||||
initiator: {
|
||||
type: "script",
|
||||
producerExtension: "retain-unknown-fields",
|
||||
stack: {
|
||||
description: "observed-sync-stack",
|
||||
callFrames: [
|
||||
{
|
||||
scriptId: "script-b",
|
||||
url: `${browser.endpoint}/same.js`,
|
||||
functionName: "alias",
|
||||
lineNumber: 0,
|
||||
columnNumber: 2,
|
||||
},
|
||||
],
|
||||
parent: {
|
||||
description: "observed-async-stack",
|
||||
callFrames: [
|
||||
{
|
||||
scriptId: "unknown-script",
|
||||
url: `${browser.endpoint}/same.js`,
|
||||
functionName: "unknown",
|
||||
lineNumber: 0,
|
||||
columnNumber: 3,
|
||||
},
|
||||
],
|
||||
parentId: { id: "async-parent", debuggerId: "other-debugger" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await provider.observeExecution(
|
||||
{ ...execution, observation_ms: 30 },
|
||||
{
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed !== 1) return;
|
||||
browser.emitEvent({
|
||||
method: "Network.requestWillBeSent",
|
||||
params,
|
||||
sessionId: "foreign-session",
|
||||
});
|
||||
browser.emitEvent({
|
||||
method: "Network.requestWillBeSent",
|
||||
params,
|
||||
sessionId: "session-1",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.requests).toHaveLength(1);
|
||||
expect(result.value.requests[0]?.reported_initiator).toEqual(
|
||||
params.initiator,
|
||||
);
|
||||
expect(result.value.requests[0]?.callsites).toMatchObject([
|
||||
{ script_id: "script-b", source_association: "script_id" },
|
||||
{ script_id: "unknown-script", source_association: "unknown" },
|
||||
]);
|
||||
expect(result.value.requests[0]?.async_parent_ids).toEqual([
|
||||
{ id: "async-parent", debugger_id: "other-debugger" },
|
||||
]);
|
||||
expect(result.value.requests[0]?.causal_attribution).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime window and producer granularity", () => {
|
||||
it("ends at document replacement and leaves coverage explicitly unavailable", async () => {
|
||||
const { browser, provider, execution } = await fixture();
|
||||
const result = await provider.observeExecution(
|
||||
{ ...execution, observation_ms: 30_000 },
|
||||
{
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed === 1)
|
||||
browser.emitEvent({
|
||||
method: "Page.frameNavigated",
|
||||
sessionId: "session-1",
|
||||
params: {
|
||||
frame: {
|
||||
id: "runtime-main",
|
||||
url: `${browser.endpoint}/new-document`,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("document_changed");
|
||||
expect(result.value.coverage.state).toBe("unavailable");
|
||||
expect(result.value.instrumentation.takes_one_resetting_sample).toBe(false);
|
||||
});
|
||||
|
||||
it("reports disconnected cleanup as unknown instead of successful detachment", async () => {
|
||||
const { provider, execution } = await fixture({
|
||||
closeOnMethod: "Profiler.takePreciseCoverage",
|
||||
});
|
||||
const result = await provider.observeExecution(execution);
|
||||
if (result.ok)
|
||||
throw new Error("Disconnect cannot confirm instrumentation cleanup");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("retains function-only producer granularity without fabricating zero branches", async () => {
|
||||
const { provider, execution } = await fixture({
|
||||
commandResult: (command, origin) =>
|
||||
command.method === "Profiler.takePreciseCoverage"
|
||||
? {
|
||||
timestamp: 13,
|
||||
result: [
|
||||
{
|
||||
scriptId: "script-a",
|
||||
url: `${origin}/same.js`,
|
||||
functions: [
|
||||
{
|
||||
functionName: "selected",
|
||||
isBlockCoverage: false,
|
||||
ranges: [
|
||||
{
|
||||
startOffset: 0,
|
||||
endOffset: runtimeSourceA.length,
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const result = await provider.observeExecution(execution);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.coverage.scripts[0]?.functions[0]).toMatchObject({
|
||||
is_block_coverage: false,
|
||||
ranges: [{ count: 1 }],
|
||||
});
|
||||
expect(result.value.script_inventory.not_reported_script_ids).toContain(
|
||||
"script-b",
|
||||
);
|
||||
expect(result.value.script_inventory.coverage_absence).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime source and listener identity", () => {
|
||||
it("retains same-URL script identities, UTF-16 bounds, nested zero counts and actual cleanup", async () => {
|
||||
const { browser, provider, execution } = await fixture();
|
||||
const result = await provider.observeExecution(execution);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(
|
||||
result.value.coverage.scripts.map((script) => script.script_id),
|
||||
).toEqual(["script-a", "script-b"]);
|
||||
expect(result.value.coverage.excluded_scripts).toBe(1);
|
||||
expect(
|
||||
result.value.sources.map((script) =>
|
||||
script.source.state === "captured" ? script.source.text : null,
|
||||
),
|
||||
).toEqual([runtimeSourceA, runtimeSourceB]);
|
||||
const source = result.value.sources[0];
|
||||
expect(source?.producer_hash).toBe("producer-reported-hash");
|
||||
expect(source?.source).toMatchObject({
|
||||
sha256: createHash("sha256").update(runtimeSourceA).digest("hex"),
|
||||
utf16_units: runtimeSourceA.length,
|
||||
utf8_bytes: Buffer.byteLength(runtimeSourceA),
|
||||
});
|
||||
expect(
|
||||
result.value.coverage.scripts[0]?.functions[0]?.ranges[1],
|
||||
).toMatchObject({ count: 0, source_bounds: "verified" });
|
||||
expect(
|
||||
browser.commands.some(
|
||||
(command) => command.method === "Profiler.stopPreciseCoverage",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
browser.commands.some(
|
||||
(command) => command.method === "Target.detachFromTarget",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
browser.commands.some((command) =>
|
||||
["Browser.close", "Target.closeTarget", "Runtime.evaluate"].includes(
|
||||
command.method,
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("inspects listener source and releases only its owned object group", async () => {
|
||||
const { browser, provider, listeners } = await fixture();
|
||||
const result = await provider.inspectEventListeners(listeners);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.listeners[0]).toMatchObject({
|
||||
type: "click",
|
||||
location: {
|
||||
script_id: "script-a",
|
||||
source_association: "script_id",
|
||||
line_number: 1,
|
||||
},
|
||||
execution: "unknown",
|
||||
});
|
||||
const resolve = browser.commands.find(
|
||||
(command) => command.method === "DOM.resolveNode",
|
||||
);
|
||||
const release = browser.commands.find(
|
||||
(command) => command.method === "Runtime.releaseObjectGroup",
|
||||
);
|
||||
expect(release?.params.objectGroup).toBe(resolve?.params.objectGroup);
|
||||
expect(resolve?.params.objectGroup).toMatch(/^rea-listeners-/u);
|
||||
});
|
||||
|
||||
it("returns an explicit missing node without source/handler evaluation", async () => {
|
||||
const { provider, listeners } = await fixture();
|
||||
const result = await provider.inspectEventListeners({
|
||||
...listeners,
|
||||
selector: "#missing",
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.selected_node.match).toBe("not_found");
|
||||
expect(result.value.listeners).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime lifecycle and invalid producer data", () => {
|
||||
it("releases instrumentation after cancellation delivered at the actual armed point", async () => {
|
||||
const { browser, provider, execution } = await fixture();
|
||||
const controller = new AbortController();
|
||||
const result = await provider.observeExecution(
|
||||
{ ...execution, observation_ms: 30_000 },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed === 1) controller.abort();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok)
|
||||
throw new Error("Cancellation must not produce successful evidence");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(
|
||||
browser.commands.some(
|
||||
(command) => command.method === "Profiler.stopPreciseCoverage",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns cleanup failure when coverage stop cannot be confirmed", async () => {
|
||||
const { browser, provider, execution } = await fixture({
|
||||
unsupportedMethods: ["Profiler.stopPreciseCoverage"],
|
||||
});
|
||||
const result = await provider.observeExecution(execution);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Failed cleanup must not return success");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(
|
||||
browser.commands.some(
|
||||
(command) => command.method === "Target.detachFromTarget",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the unsupported command reason", async () => {
|
||||
const { provider, execution } = await fixture({
|
||||
unsupportedMethods: ["Profiler.startPreciseCoverage"],
|
||||
});
|
||||
const result = await provider.observeExecution(execution);
|
||||
if (result.ok) throw new Error("Unsupported instrumentation must fail");
|
||||
expect(result.error.userMessage).toContain("Profiler.startPreciseCoverage");
|
||||
expect(result.error.userMessage).toContain("Method not found");
|
||||
});
|
||||
|
||||
it("rejects a malformed sample and still stops coverage", async () => {
|
||||
const { browser, provider, execution } = await fixture({
|
||||
commandResult: (command) =>
|
||||
command.method === "Profiler.takePreciseCoverage"
|
||||
? { timestamp: 1, result: "wrong" }
|
||||
: undefined,
|
||||
});
|
||||
const result = await provider.observeExecution(execution);
|
||||
if (result.ok) throw new Error("Malformed producer coverage must fail");
|
||||
expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
expect(
|
||||
browser.commands.some(
|
||||
(command) => command.method === "Profiler.stopPreciseCoverage",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a source range measured as UTF-8 bytes instead of UTF-16 units", async () => {
|
||||
const { provider, execution } = await fixture({
|
||||
commandResult: (command, origin) =>
|
||||
command.method === "Profiler.takePreciseCoverage"
|
||||
? {
|
||||
timestamp: 13,
|
||||
result: [
|
||||
{
|
||||
scriptId: "script-a",
|
||||
url: `${origin}/same.js`,
|
||||
functions: [
|
||||
{
|
||||
functionName: "bad",
|
||||
isBlockCoverage: true,
|
||||
ranges: [
|
||||
{
|
||||
startOffset: 0,
|
||||
endOffset: Buffer.byteLength(runtimeSourceA),
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const result = await provider.observeExecution(execution);
|
||||
if (result.ok)
|
||||
throw new Error("Byte offsets must not be mislabeled UTF-16");
|
||||
expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime document identity and authorization failures", () => {
|
||||
it.each(["execution", "listeners"] as const)(
|
||||
"rejects newly reported loader identity for %s when the initial loader was unknown",
|
||||
async (action) => {
|
||||
const { provider, execution, listeners } = await fixture({
|
||||
commandResult: (command, origin, frameReads) =>
|
||||
command.method === "Page.getFrameTree"
|
||||
? {
|
||||
frameTree: {
|
||||
frame: {
|
||||
id: "runtime-main",
|
||||
url: `${origin}/allowed`,
|
||||
...(frameReads === 1 ? {} : { loaderId: "new-document" }),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const result =
|
||||
action === "execution"
|
||||
? await provider.observeExecution(execution)
|
||||
: await provider.inspectEventListeners(listeners);
|
||||
if (result.ok)
|
||||
throw new Error(
|
||||
"Unknown loader must not accept a newly reported document identity",
|
||||
);
|
||||
expect(result.error).toMatchObject({ reason: "target_changed" });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["execution", "listeners"] as const)(
|
||||
"reports the selected %s operation when the live frame origin is denied",
|
||||
async (action) => {
|
||||
const { provider, execution, listeners } = await fixture({
|
||||
commandResult: (command) =>
|
||||
command.method === "Page.getFrameTree"
|
||||
? {
|
||||
frameTree: {
|
||||
frame: {
|
||||
id: "runtime-main",
|
||||
url: "https://unselected.example/document",
|
||||
loaderId: "denied",
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const result =
|
||||
action === "execution"
|
||||
? await provider.observeExecution(execution)
|
||||
: await provider.inspectEventListeners(listeners);
|
||||
if (result.ok) throw new Error("Unselected live origin must be denied");
|
||||
expect(result.error).toMatchObject({
|
||||
reason: "target_not_allowed",
|
||||
operation:
|
||||
action === "execution"
|
||||
? "observe_web_execution"
|
||||
: "inspect_web_event_listeners",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
||||
import type { CdpEvent } from "../../../src/browser/CdpConnection.js";
|
||||
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
|
||||
import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js";
|
||||
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
|
||||
|
||||
describe("runtime final collection boundaries", () => {
|
||||
it("retains a script parsed during the final resetting coverage command", async () => {
|
||||
let emitEvent: ((event: CdpEvent) => void) | undefined;
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandResult: (command, origin) => {
|
||||
if (command.method === "Profiler.takePreciseCoverage") {
|
||||
emitEvent?.({
|
||||
method: "Debugger.scriptParsed",
|
||||
sessionId: "session-1",
|
||||
params: {
|
||||
scriptId: "late-script",
|
||||
url: `${origin}/late.js`,
|
||||
executionContextId: 1,
|
||||
executionContextAuxData: { frameId: "runtime-main" },
|
||||
startLine: 0,
|
||||
startColumn: 0,
|
||||
endLine: 0,
|
||||
endColumn: 1,
|
||||
},
|
||||
});
|
||||
return {
|
||||
timestamp: 12,
|
||||
result: [
|
||||
{
|
||||
scriptId: "late-script",
|
||||
url: `${origin}/late.js`,
|
||||
functions: [
|
||||
{
|
||||
functionName: "late",
|
||||
isBlockCoverage: true,
|
||||
ranges: [{ startOffset: 0, endOffset: 1, count: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (
|
||||
command.method === "Debugger.getScriptSource" &&
|
||||
command.params.scriptId === "late-script"
|
||||
)
|
||||
return { scriptSource: ";" };
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
emitEvent = browser.emitEvent;
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 5,
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.coverage.scripts[0]).toMatchObject({
|
||||
script_id: "late-script",
|
||||
functions: [{ ranges: [{ count: 1, source_bounds: "verified" }] }],
|
||||
});
|
||||
expect(
|
||||
result.value.sources.find(({ script_id }) => script_id === "late-script")
|
||||
?.source,
|
||||
).toMatchObject({ state: "captured", text: ";" });
|
||||
expect(result.value.coverage.excluded_scripts).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["#selected", "#missing"])(
|
||||
"rejects known malformed source metadata during final listener assertion for %s",
|
||||
async (selector) => {
|
||||
let emitEvent: ((event: CdpEvent) => void) | undefined;
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandResult: (command, _origin, reads) => {
|
||||
if (command.method === "Page.getFrameTree" && reads === 3)
|
||||
emitEvent?.({
|
||||
method: "Debugger.scriptParsed",
|
||||
sessionId: "session-1",
|
||||
params: {},
|
||||
});
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
emitEvent = browser.emitEvent;
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().inspectEventListeners(
|
||||
inspectWebEventListenersInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
selector,
|
||||
}),
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error(
|
||||
"Known producer failure cannot return partial listener success",
|
||||
);
|
||||
expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("runtime fatal protocol stream", () => {
|
||||
it.each(["{not-json", JSON.stringify({ method: 42 })])(
|
||||
"stops an armed command-free window on malformed wire data %s",
|
||||
async (message) => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 2_147_483_647,
|
||||
}),
|
||||
{
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed === 1) browser.emitRawMessage(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error(
|
||||
"Malformed wire data must not produce successful evidence",
|
||||
);
|
||||
expect(result.error).toMatchObject({
|
||||
cleanupIncomplete: true,
|
||||
diagnostics: {
|
||||
previous_error: expect.stringMatching(/protocol|malformed/iu),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
||||
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
|
||||
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
|
||||
|
||||
describe("runtime initiator URL representation", () => {
|
||||
it.each(["before", "after"])(
|
||||
"omits proven transport userinfo and preserves declared names with metadata %s the request",
|
||||
async (ordering) => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const resourceUrl =
|
||||
browser.endpoint.replace("http://", "http://transport:credential@") +
|
||||
"/resource.js?caller=value#fragment";
|
||||
const declaredUrl =
|
||||
browser.endpoint.replace("http://", "http://declared:label@") +
|
||||
"/declaration.js?caller=value#fragment";
|
||||
const metadata = (
|
||||
scriptId: string,
|
||||
url: string,
|
||||
hasSourceURL: boolean,
|
||||
) => ({
|
||||
scriptId,
|
||||
url,
|
||||
hasSourceURL,
|
||||
executionContextId: 999,
|
||||
startLine: 0,
|
||||
startColumn: 0,
|
||||
endLine: 1,
|
||||
endColumn: 0,
|
||||
});
|
||||
const frames = [
|
||||
{
|
||||
scriptId: "resource-script",
|
||||
url: resourceUrl,
|
||||
functionName: "resource",
|
||||
lineNumber: 0,
|
||||
columnNumber: 0,
|
||||
},
|
||||
{
|
||||
scriptId: "declared-script",
|
||||
url: declaredUrl,
|
||||
functionName: "declared",
|
||||
lineNumber: 0,
|
||||
columnNumber: 0,
|
||||
},
|
||||
];
|
||||
const initiator = {
|
||||
type: "script",
|
||||
url: resourceUrl,
|
||||
lineNumber: 0,
|
||||
producerExtension: { url: declaredUrl, opaque: "keep caller data" },
|
||||
stack: {
|
||||
description: "producer sync",
|
||||
callFrames: [frames[0]],
|
||||
parent: {
|
||||
description: "producer async",
|
||||
callFrames: [frames[1]],
|
||||
parentId: { id: "retained-parent" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 30,
|
||||
}),
|
||||
{
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed !== 1) return;
|
||||
const scripts = () => {
|
||||
for (const params of [
|
||||
metadata("resource-script", resourceUrl, false),
|
||||
metadata("declared-script", declaredUrl, true),
|
||||
])
|
||||
browser.emitEvent({
|
||||
method: "Debugger.scriptParsed",
|
||||
params,
|
||||
sessionId: "session-1",
|
||||
});
|
||||
};
|
||||
if (ordering === "before") scripts();
|
||||
browser.emitEvent({
|
||||
method: "Network.requestWillBeSent",
|
||||
sessionId: "session-1",
|
||||
params: {
|
||||
requestId: "request",
|
||||
frameId: "runtime-main",
|
||||
timestamp: 12,
|
||||
request: {
|
||||
url: `${browser.endpoint}/selected`,
|
||||
method: "GET",
|
||||
},
|
||||
initiator,
|
||||
},
|
||||
});
|
||||
if (ordering === "after") scripts();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
const sanitized = `${browser.endpoint}/resource.js?caller=value#fragment`;
|
||||
expect(result.value.requests[0]?.reported_initiator).toEqual({
|
||||
...initiator,
|
||||
url: sanitized,
|
||||
stack: {
|
||||
...initiator.stack,
|
||||
callFrames: [{ ...frames[0], url: sanitized }],
|
||||
},
|
||||
});
|
||||
expect(result.value.requests[0]?.callsites.map(({ url }) => url)).toEqual(
|
||||
[sanitized, declaredUrl, sanitized],
|
||||
);
|
||||
expect(
|
||||
result.value.sources.find(
|
||||
({ script_id }) => script_id === "resource-script",
|
||||
)?.url,
|
||||
).toBe(sanitized);
|
||||
expect(
|
||||
result.value.sources.find(
|
||||
({ script_id }) => script_id === "declared-script",
|
||||
)?.url,
|
||||
).toBe(declaredUrl);
|
||||
expect(JSON.stringify(result.value)).not.toContain(
|
||||
"transport:credential@",
|
||||
);
|
||||
expect(JSON.stringify(result.value)).toContain("declared:label@");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
||||
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
|
||||
import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js";
|
||||
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
|
||||
|
||||
describe("runtime document identity across same-document URL changes", () => {
|
||||
it.each(["execution", "listeners"] as const)(
|
||||
"accepts a stable loader for %s while retaining the initial location",
|
||||
async (operation) => {
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandResult: (command, origin, reads) =>
|
||||
command.method === "Page.getFrameTree"
|
||||
? {
|
||||
frameTree: {
|
||||
frame: {
|
||||
id: "runtime-main",
|
||||
url: `${origin}/${reads === 1 ? "allowed" : "new-path?caller=value#fragment"}`,
|
||||
loaderId: "same-document",
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
const provider = new CdpWebRuntimeProvider();
|
||||
const scope = {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
};
|
||||
const result =
|
||||
operation === "execution"
|
||||
? await provider.observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
...scope,
|
||||
observation_ms: 5,
|
||||
}),
|
||||
)
|
||||
: await provider.inspectEventListeners(
|
||||
inspectWebEventListenersInputSchema.parse({
|
||||
...scope,
|
||||
selector: "#selected",
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.target.initial_url).toBe(
|
||||
`${browser.endpoint}/allowed`,
|
||||
);
|
||||
expect(result.value.target.loader_id).toBe("same-document");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ loader: undefined, denied: false, reason: "target_changed" },
|
||||
{ loader: "same-document", denied: true, reason: "target_not_allowed" },
|
||||
])(
|
||||
"preserves conservative identity and origin checks: $reason",
|
||||
async ({ loader, denied, reason }) => {
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandResult: (command, origin, reads) =>
|
||||
command.method === "Page.getFrameTree"
|
||||
? {
|
||||
frameTree: {
|
||||
frame: {
|
||||
id: "runtime-main",
|
||||
url:
|
||||
reads === 1
|
||||
? `${origin}/allowed`
|
||||
: denied
|
||||
? "https://unselected.example/new"
|
||||
: `${origin}/new`,
|
||||
...(loader === undefined ? {} : { loaderId: loader }),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().inspectEventListeners(
|
||||
inspectWebEventListenersInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
selector: "#selected",
|
||||
}),
|
||||
);
|
||||
if (result.ok) throw new Error("Unverified document or origin must fail");
|
||||
expect(result.error).toMatchObject({ reason });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("runtime collector failure and uncertain source metadata", () => {
|
||||
it.each(["Debugger.scriptParsed", "Network.requestWillBeSent"])(
|
||||
"stops an armed long window immediately for malformed %s",
|
||||
async (method) => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 2_147_483_647,
|
||||
}),
|
||||
{
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed === 1)
|
||||
browser.emitEvent({
|
||||
method,
|
||||
params: {},
|
||||
sessionId: "session-1",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok) throw new Error("Malformed producer data must fail");
|
||||
expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
expect(
|
||||
browser.commands.some(
|
||||
({ method: command }) => command === "Profiler.stopPreciseCoverage",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
browser.commands.some(
|
||||
({ method: command }) => command === "Target.detachFromTarget",
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains reported metadata without claiming ownership or reading unowned source", async () => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const script = {
|
||||
scriptId: "uncertain-script",
|
||||
url: `${browser.endpoint}/declared.js?caller=value#fragment`,
|
||||
executionContextId: 999,
|
||||
hash: "observed-hash",
|
||||
sourceMapURL: "../declared.map?query=value#fragment",
|
||||
hasSourceURL: true,
|
||||
scriptLanguage: "JavaScript",
|
||||
startLine: 4,
|
||||
startColumn: 8,
|
||||
endLine: 8,
|
||||
endColumn: 0,
|
||||
};
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 30,
|
||||
}),
|
||||
{
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed !== 1) return;
|
||||
browser.emitEvent({
|
||||
method: "Debugger.scriptParsed",
|
||||
params: script,
|
||||
sessionId: "session-1",
|
||||
});
|
||||
browser.emitEvent({
|
||||
method: "Network.requestWillBeSent",
|
||||
sessionId: "session-1",
|
||||
params: {
|
||||
requestId: "uncertain-request",
|
||||
frameId: "runtime-main",
|
||||
timestamp: 12,
|
||||
request: { url: `${browser.endpoint}/request`, method: "GET" },
|
||||
initiator: {
|
||||
type: "script",
|
||||
stack: {
|
||||
callFrames: [
|
||||
{
|
||||
scriptId: script.scriptId,
|
||||
url: script.url,
|
||||
functionName: "unknown",
|
||||
lineNumber: 4,
|
||||
columnNumber: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(
|
||||
result.value.sources.find(
|
||||
({ script_id }) => script_id === script.scriptId,
|
||||
),
|
||||
).toMatchObject({
|
||||
url: script.url,
|
||||
execution_context_id: 999,
|
||||
frame_id: null,
|
||||
producer_hash: script.hash,
|
||||
source_map_url: script.sourceMapURL,
|
||||
has_source_url: true,
|
||||
language: "JavaScript",
|
||||
resource_start: { line_number: 4, column_number: 8 },
|
||||
source: { state: "excluded" },
|
||||
});
|
||||
expect(result.value.requests[0]?.callsites[0]?.source_association).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(
|
||||
browser.commands.some(
|
||||
({ method, params }) =>
|
||||
method === "Debugger.getScriptSource" &&
|
||||
params.scriptId === script.scriptId,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
||||
import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js";
|
||||
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: -32000,
|
||||
message: "DOM Error while querying",
|
||||
expected: "AnalysisInputError",
|
||||
},
|
||||
{
|
||||
code: -32000,
|
||||
message: "Could not find node with given id",
|
||||
expected: "BrowserObservationError",
|
||||
},
|
||||
{
|
||||
code: -32601,
|
||||
message: "Method not found",
|
||||
expected: "BrowserObservationError",
|
||||
},
|
||||
])(
|
||||
"classifies the actual selector rejection $message",
|
||||
async ({ code, message, expected }) => {
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandError: ({ method }) =>
|
||||
method === "DOM.querySelector" ? { code, message } : undefined,
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().inspectEventListeners(
|
||||
inspectWebEventListenersInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
selector: "[",
|
||||
}),
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error("Rejected selectors cannot produce listener evidence");
|
||||
expect(result.error._tag).toBe(expected);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
if (result.error._tag === "AnalysisInputError")
|
||||
expect(result.error.message).toContain("inspect_web_event_listeners");
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { expect, it, onTestFinished } from "vitest";
|
||||
import type { CdpEvent } from "../../../src/browser/CdpConnection.js";
|
||||
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
||||
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
|
||||
import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js";
|
||||
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
|
||||
|
||||
it.each(
|
||||
["listeners", "execution"].flatMap((operation) =>
|
||||
["source_read", "other_source_read", "final_assertion"].map((phase) => ({
|
||||
operation,
|
||||
phase,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
"revalidates $operation joins when a known script changes at $phase",
|
||||
async ({ operation, phase }) => {
|
||||
let emit: ((event: CdpEvent) => void) | undefined;
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandResult: (command, origin, reads) => {
|
||||
if (
|
||||
(phase === "source_read" &&
|
||||
command.method === "Debugger.getScriptSource" &&
|
||||
command.params.scriptId === "script-a") ||
|
||||
(phase === "other_source_read" &&
|
||||
command.method === "Debugger.getScriptSource" &&
|
||||
command.params.scriptId === "script-b") ||
|
||||
(phase === "final_assertion" &&
|
||||
command.method === "Page.getFrameTree" &&
|
||||
reads === (operation === "listeners" ? 3 : 4))
|
||||
)
|
||||
emit?.({
|
||||
sessionId: "session-1",
|
||||
method: "Debugger.scriptParsed",
|
||||
params: {
|
||||
scriptId: "script-a",
|
||||
url: `${origin}/changed.js`,
|
||||
executionContextId: 1,
|
||||
executionContextAuxData: { frameId: "runtime-main" },
|
||||
startLine: 0,
|
||||
startColumn: 0,
|
||||
endLine: 1,
|
||||
endColumn: 0,
|
||||
hash: "changed-producer-hash",
|
||||
hasSourceURL: true,
|
||||
scriptLanguage: "JavaScript",
|
||||
},
|
||||
});
|
||||
if (command.method === "DOMDebugger.getEventListeners")
|
||||
return {
|
||||
listeners: ["script-a", "script-b"].map((scriptId) => ({
|
||||
type: "click",
|
||||
useCapture: false,
|
||||
passive: true,
|
||||
once: false,
|
||||
scriptId,
|
||||
lineNumber: 0,
|
||||
columnNumber: 0,
|
||||
})),
|
||||
};
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
emit = browser.emitEvent;
|
||||
onTestFinished(() => browser.close());
|
||||
const provider = new CdpWebRuntimeProvider();
|
||||
const scope = { cdp_endpoint: browser.endpoint, target_id: "allowed-page" };
|
||||
if (operation === "listeners") {
|
||||
const result = await provider.inspectEventListeners(
|
||||
inspectWebEventListenersInputSchema.parse({
|
||||
...scope,
|
||||
selector: "#selected",
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.listeners[0]?.location.source_association).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(result.value.sources[0]).toMatchObject({
|
||||
frame_id: null,
|
||||
source: { state: "excluded" },
|
||||
});
|
||||
} else {
|
||||
const result = await provider.observeExecution(
|
||||
observeWebExecutionInputSchema.parse({ ...scope, observation_ms: 5 }),
|
||||
{
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed !== 1) return;
|
||||
browser.emitEvent({
|
||||
sessionId: "session-1",
|
||||
method: "Network.requestWillBeSent",
|
||||
params: {
|
||||
requestId: "source-completion-request",
|
||||
frameId: "runtime-main",
|
||||
timestamp: 12,
|
||||
request: {
|
||||
url: `${new URL(browser.endpoint).origin}/selected`,
|
||||
method: "GET",
|
||||
},
|
||||
initiator: {
|
||||
type: "script",
|
||||
stack: {
|
||||
callFrames: [
|
||||
{
|
||||
scriptId: "script-a",
|
||||
url: `${new URL(browser.endpoint).origin}/same.js`,
|
||||
lineNumber: 0,
|
||||
columnNumber: 0,
|
||||
functionName: "selected",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(
|
||||
result.value.coverage.scripts.some(
|
||||
({ script_id }) => script_id === "script-a",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(result.value.requests[0]?.callsites[0]?.source_association).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(result.value.script_inventory.main_document_scripts).toBe(1);
|
||||
expect(
|
||||
result.value.sources.find(({ script_id }) => script_id === "script-a"),
|
||||
).toMatchObject({ frame_id: null, source: { state: "excluded" } });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
||||
import type { CdpEvent } from "../../../src/browser/CdpConnection.js";
|
||||
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
|
||||
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
|
||||
|
||||
describe("runtime flat-session lifecycle", () => {
|
||||
it("rejects a producer detachment between counter startup and local arming", async () => {
|
||||
let emitEvent: ((event: CdpEvent) => void) | undefined;
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandResult: (command) => {
|
||||
if (command.method !== "Profiler.startPreciseCoverage")
|
||||
return undefined;
|
||||
emitEvent?.({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "session-1" },
|
||||
});
|
||||
return { timestamp: 12 };
|
||||
},
|
||||
});
|
||||
emitEvent = browser.emitEvent;
|
||||
onTestFinished(() => browser.close());
|
||||
let reportedArmed = false;
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 2_147_483_647,
|
||||
}),
|
||||
{
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed === 1) reportedArmed = true;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error("A terminated target cannot arm an execution window");
|
||||
expect(result.error).toMatchObject({ reason: "target_changed" });
|
||||
expect(reportedArmed).toBe(false);
|
||||
});
|
||||
|
||||
it("ends immediately on its root detachment without confusing another session", async () => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 2_147_483_647,
|
||||
}),
|
||||
{
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed !== 1) return;
|
||||
browser.emitEvent({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "another-session" },
|
||||
});
|
||||
browser.emitEvent({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "session-1" },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_terminated");
|
||||
expect(result.value.coverage.state).toBe("unavailable");
|
||||
expect(result.value.instrumentation.takes_one_resetting_sample).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores root detachment for a different flat session", async () => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 30,
|
||||
}),
|
||||
{
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed === 1)
|
||||
browser.emitEvent({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "another-session" },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("window_elapsed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage URL representation", () => {
|
||||
it("sanitizes proven resource URLs while preserving declared source names", async () => {
|
||||
let resourceUrl = "";
|
||||
let declaredUrl = "";
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandResult: (command) => {
|
||||
if (command.method === "Profiler.takePreciseCoverage")
|
||||
return {
|
||||
timestamp: 12,
|
||||
result: [
|
||||
{ scriptId: "transport-script", url: resourceUrl, functions: [] },
|
||||
{ scriptId: "declared-script", url: declaredUrl, functions: [] },
|
||||
],
|
||||
};
|
||||
if (
|
||||
command.method === "Debugger.getScriptSource" &&
|
||||
["transport-script", "declared-script"].includes(
|
||||
String(command.params.scriptId),
|
||||
)
|
||||
)
|
||||
return { scriptSource: ";" };
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
resourceUrl =
|
||||
browser.endpoint.replace("http://", "http://transport:credential@") +
|
||||
"/resource.js?caller=value#fragment";
|
||||
declaredUrl =
|
||||
browser.endpoint.replace("http://", "http://declared:label@") +
|
||||
"/declared.js?caller=value#fragment";
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 30,
|
||||
}),
|
||||
{
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed !== 1) return;
|
||||
for (const [scriptId, url, hasSourceURL] of [
|
||||
["transport-script", resourceUrl, false],
|
||||
["declared-script", declaredUrl, true],
|
||||
] as const)
|
||||
browser.emitEvent({
|
||||
method: "Debugger.scriptParsed",
|
||||
sessionId: "session-1",
|
||||
params: {
|
||||
scriptId,
|
||||
url,
|
||||
hasSourceURL,
|
||||
executionContextId: 1,
|
||||
executionContextAuxData: { frameId: "runtime-main" },
|
||||
startLine: 0,
|
||||
startColumn: 0,
|
||||
endLine: 0,
|
||||
endColumn: 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(
|
||||
result.value.coverage.scripts.map(({ reported_url }) => reported_url),
|
||||
).toEqual([
|
||||
`${browser.endpoint}/resource.js?caller=value#fragment`,
|
||||
declaredUrl,
|
||||
]);
|
||||
expect(JSON.stringify(result.value)).not.toContain("transport:credential@");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
||||
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
|
||||
import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js";
|
||||
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
|
||||
|
||||
const unknownWorld = { frameId: "runtime-main" };
|
||||
const defaultWorld = { ...unknownWorld, isDefault: true, type: "default" };
|
||||
const isolatedWorld = { ...unknownWorld, isDefault: false, type: "isolated" };
|
||||
|
||||
describe("runtime execution world ownership", () => {
|
||||
it.each([
|
||||
{ name: "isolated context", context: isolatedWorld, script: isolatedWorld },
|
||||
{
|
||||
name: "isolated script metadata",
|
||||
context: undefined,
|
||||
script: isolatedWorld,
|
||||
},
|
||||
{ name: "unknown world", context: unknownWorld, script: unknownWorld },
|
||||
{
|
||||
name: "contradictory worlds",
|
||||
context: isolatedWorld,
|
||||
script: defaultWorld,
|
||||
},
|
||||
{
|
||||
name: "isolated script with default context",
|
||||
context: defaultWorld,
|
||||
script: isolatedWorld,
|
||||
},
|
||||
{
|
||||
name: "worker context",
|
||||
context: { ...unknownWorld, type: "worker" },
|
||||
script: unknownWorld,
|
||||
},
|
||||
])(
|
||||
"does not attribute $name to website execution or listeners",
|
||||
async ({ context, script }) => {
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandEvents: (command, origin) => {
|
||||
if (command.method === "Runtime.enable")
|
||||
return context === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
method: "Runtime.executionContextCreated",
|
||||
params: {
|
||||
context: {
|
||||
id: 1,
|
||||
origin,
|
||||
name: "reported-world",
|
||||
auxData: context,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
if (command.method === "Debugger.enable")
|
||||
return ["script-a", "script-b"].map((scriptId) => ({
|
||||
sessionId: "session-1",
|
||||
method: "Debugger.scriptParsed",
|
||||
params: {
|
||||
scriptId,
|
||||
url: `${origin}/same.js`,
|
||||
executionContextId: 1,
|
||||
executionContextAuxData: script,
|
||||
startLine: 0,
|
||||
startColumn: 0,
|
||||
endLine: 1,
|
||||
endColumn: 0,
|
||||
},
|
||||
}));
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
const provider = new CdpWebRuntimeProvider();
|
||||
const scope = {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
};
|
||||
const execution = await provider.observeExecution(
|
||||
observeWebExecutionInputSchema.parse({ ...scope, observation_ms: 5 }),
|
||||
);
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.coverage.scripts).toEqual([]);
|
||||
expect(execution.value.coverage.excluded_scripts).toBe(3);
|
||||
expect(execution.value.script_inventory.main_document_scripts).toBe(0);
|
||||
const listeners = await provider.inspectEventListeners(
|
||||
inspectWebEventListenersInputSchema.parse({
|
||||
...scope,
|
||||
selector: "#selected",
|
||||
}),
|
||||
);
|
||||
if (!listeners.ok) throw listeners.error;
|
||||
expect(listeners.value.listeners[0]?.location.source_association).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(listeners.value.sources[0]).toMatchObject({
|
||||
source: { state: "excluded" },
|
||||
});
|
||||
expect(
|
||||
listeners.value.sources[0]?.reported_script_context_aux_data,
|
||||
).toEqual(script);
|
||||
expect(listeners.value.sources[0]?.reported_execution_context).toEqual(
|
||||
context === undefined
|
||||
? null
|
||||
: {
|
||||
id: 1,
|
||||
origin: new URL(browser.endpoint).origin,
|
||||
name: "reported-world",
|
||||
auxData: context,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
browser.commands.filter(
|
||||
({ method }) => method === "Debugger.getScriptSource",
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("attempts every fixture cleanup and retains the primary capture failure", async () => {
|
||||
const helper = new URL(
|
||||
"../../../scripts/lib/browser-runtime-fixture-lifecycle.mjs",
|
||||
import.meta.url,
|
||||
);
|
||||
const script = `
|
||||
const { closeRuntimeFixtureResources } = await import(${JSON.stringify(helper.href)});
|
||||
const seen = [];
|
||||
try {
|
||||
await closeRuntimeFixtureResources([
|
||||
async () => { seen.push('first'); throw new Error('first cleanup failed'); },
|
||||
async () => { seen.push('second'); throw new Error('second cleanup failed'); },
|
||||
async () => { seen.push('third'); },
|
||||
], new Error('capture failed'));
|
||||
throw new Error('Expected aggregate cleanup failure');
|
||||
} catch (error) {
|
||||
if (!(error instanceof AggregateError)) throw error;
|
||||
console.log(JSON.stringify({ seen, errors: error.errors.map(item => item.message) }));
|
||||
}
|
||||
`;
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
["--input-type=module", "-e", script],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const result: unknown = JSON.parse(stdout);
|
||||
expect(result).toEqual({
|
||||
seen: ["first", "second", "third"],
|
||||
errors: ["capture failed", "first cleanup failed", "second cleanup failed"],
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { WebRuntimeService } from "../../../src/application/WebRuntimeService.js";
|
||||
import { recordingWebRuntimePort } from "../../../src/application/WebRuntimeService.fixture.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
|
||||
it("advertises actual SDK schemas, instrumentation effects and session-owned inline runtime evidence", async () => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Binary provider must not start");
|
||||
});
|
||||
const server = createServer(session, session, {
|
||||
webRuntime: new WebRuntimeService(recordingWebRuntimePort()),
|
||||
});
|
||||
const client = new Client({ name: "web-runtime-contract", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const tools = (await client.listTools()).tools;
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const input = {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 5,
|
||||
};
|
||||
for (const name of [
|
||||
"observe_web_execution",
|
||||
"inspect_web_event_listeners",
|
||||
] as const) {
|
||||
const advertised = tools.find((tool) => tool.name === name);
|
||||
if (advertised?.outputSchema === undefined)
|
||||
throw new Error(`Missing advertised runtime schemas: ${name}`);
|
||||
const inputSchema: Record<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
const args =
|
||||
name === "observe_web_execution"
|
||||
? input
|
||||
: {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "allowed-page",
|
||||
selector: "#selected",
|
||||
};
|
||||
expect(ajv.validate(inputSchema, args)).toBe(true);
|
||||
const response = await client.callTool({ name, arguments: args });
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
ajv.validate(outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
const parsed = toolContract(name).outputSchema.parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
const evidence = parseEvidence(parsed.evidence);
|
||||
expect(parsed.result).toEqual(evidence.normalized_result);
|
||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
}
|
||||
expect(toolContract("observe_web_execution").effects).toMatchObject({
|
||||
mutatesTarget: true,
|
||||
mayDiscardData: true,
|
||||
idempotent: false,
|
||||
});
|
||||
const invalid = await client.callTool({
|
||||
name: "observe_web_execution",
|
||||
arguments: { ...input, observation_ms: 2_147_483_648 },
|
||||
});
|
||||
expect(invalid.isError).toBe(true);
|
||||
});
|
||||
|
|
@ -92,6 +92,11 @@ export const startFakeCdpBrowser = async (
|
|||
socket.on("message", (raw) => {
|
||||
const command = parseCommand(raw.toString());
|
||||
commands.push(command);
|
||||
const commandError = options.commandError?.(command);
|
||||
if (commandError !== undefined) {
|
||||
socket.send(JSON.stringify({ id: command.id, error: commandError }));
|
||||
return;
|
||||
}
|
||||
if (options.closeOnMethod === command.method) {
|
||||
socket.close();
|
||||
return;
|
||||
|
|
@ -124,19 +129,7 @@ export const startFakeCdpBrowser = async (
|
|||
return;
|
||||
}
|
||||
if (command.method === "Page.getFrameTree") frameTreeReads += 1;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
result: resultFor(command, port, options, frameTreeReads),
|
||||
}),
|
||||
);
|
||||
if (options.malformedEventOnMethod === command.method)
|
||||
socket.send("{not-json");
|
||||
if (options.malformedEventShapeOnMethod === command.method)
|
||||
socket.send(JSON.stringify({ method: 42 }));
|
||||
emitEvents(socket, command, port, options);
|
||||
if (options.closeAfterMethod === command.method)
|
||||
setImmediate(() => socket.close());
|
||||
replyToCommand(socket, command, port, options, frameTreeReads);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
|
@ -150,6 +143,12 @@ export const startFakeCdpBrowser = async (
|
|||
browserWebSocketUrl: `ws://127.0.0.1:${String(port)}/devtools/browser/fake`,
|
||||
allowedOrigin: endpoint,
|
||||
commands,
|
||||
emitRawMessage(message) {
|
||||
for (const socket of sockets) socket.send(message);
|
||||
},
|
||||
emitEvent(event) {
|
||||
for (const socket of sockets) socket.send(JSON.stringify(event));
|
||||
},
|
||||
httpRequests,
|
||||
async close() {
|
||||
for (const socket of sockets) socket.terminate();
|
||||
|
|
@ -170,3 +169,36 @@ const boundPort = (
|
|||
throw new Error("Fake CDP server did not bind a TCP address");
|
||||
return address.port;
|
||||
};
|
||||
|
||||
/** Emit a configured producer reply and its associated events. */
|
||||
const replyToCommand = (
|
||||
socket: WebSocket,
|
||||
command: FakeCdpCommand,
|
||||
port: number,
|
||||
options: FakeOptions,
|
||||
frameTreeReads: number,
|
||||
): void => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
result:
|
||||
options.commandResult?.(
|
||||
command,
|
||||
`http://127.0.0.1:${String(port)}`,
|
||||
frameTreeReads,
|
||||
) ?? resultFor(command, port, options, frameTreeReads),
|
||||
}),
|
||||
);
|
||||
if (options.malformedEventOnMethod === command.method)
|
||||
socket.send("{not-json");
|
||||
if (options.malformedEventShapeOnMethod === command.method)
|
||||
socket.send(JSON.stringify({ method: 42 }));
|
||||
const customEvents = options.commandEvents?.(
|
||||
command,
|
||||
`http://127.0.0.1:${String(port)}`,
|
||||
);
|
||||
if (customEvents === undefined) emitEvents(socket, command, port, options);
|
||||
else for (const event of customEvents) socket.send(JSON.stringify(event));
|
||||
if (options.closeAfterMethod === command.method)
|
||||
setImmediate(() => socket.close());
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ export interface FakeCdpBrowser {
|
|||
readonly browserWebSocketUrl: string;
|
||||
readonly allowedOrigin: string;
|
||||
readonly commands: readonly FakeCdpCommand[];
|
||||
/** Deliver actual malformed wire data without bypassing the production transport parser. */
|
||||
emitRawMessage(message: string): void;
|
||||
emitEvent(event: {
|
||||
readonly method: string;
|
||||
readonly params: unknown;
|
||||
readonly sessionId?: string;
|
||||
}): void;
|
||||
readonly httpRequests: readonly {
|
||||
readonly url: string;
|
||||
readonly authorization: string | undefined;
|
||||
|
|
@ -20,6 +27,27 @@ export interface FakeCdpBrowser {
|
|||
}
|
||||
|
||||
export interface FakeOptions {
|
||||
/** Return a native-shaped command rejection through the actual wire parser. */
|
||||
readonly commandError?: (
|
||||
command: FakeCdpCommand,
|
||||
) => { readonly code: number; readonly message: string } | undefined;
|
||||
/** Producer reply seam preserves the real HTTP/WebSocket transport and command parser. */
|
||||
readonly commandResult?: (
|
||||
command: FakeCdpCommand,
|
||||
origin: string,
|
||||
frameReads: number,
|
||||
) => Readonly<Record<string, unknown>> | undefined;
|
||||
/** Return undefined to use the stock producer events; an empty array suppresses them. */
|
||||
readonly commandEvents?: (
|
||||
command: FakeCdpCommand,
|
||||
origin: string,
|
||||
) =>
|
||||
| readonly {
|
||||
readonly method: string;
|
||||
readonly params: unknown;
|
||||
readonly sessionId?: string;
|
||||
}[]
|
||||
| undefined;
|
||||
readonly malformedDiscovery?: boolean;
|
||||
readonly oversizedDiscovery?: boolean;
|
||||
readonly invalidBrowserWebSocket?: boolean;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
import { startFakeCdpBrowser, type FakeOptions } from "./fakeCdpBrowser.js";
|
||||
import type { FakeCdpCommand } from "./fakeCdpBrowserTypes.js";
|
||||
|
||||
export const runtimeSourceA = "// 🔥\r\nfunction selected(){ return 'A'; }";
|
||||
export const runtimeSourceB = "function untouched(){ return 'B'; }";
|
||||
|
||||
/** Producer fixture uses same-URL distinct scripts and a producer hash unlike the UTF-8 digest. */
|
||||
export const startRuntimeBrowser = (overrides: FakeOptions = {}) =>
|
||||
startFakeCdpBrowser({
|
||||
...overrides,
|
||||
commandResult: (command, origin, reads) =>
|
||||
overrides.commandResult?.(command, origin, reads) ??
|
||||
runtimeResult(command, origin),
|
||||
commandEvents: (command, origin) =>
|
||||
overrides.commandEvents?.(command, origin) ??
|
||||
runtimeEvents(command, origin),
|
||||
});
|
||||
|
||||
const runtimeResult = (
|
||||
command: FakeCdpCommand,
|
||||
origin: string,
|
||||
): Readonly<Record<string, unknown>> | undefined => {
|
||||
switch (command.method) {
|
||||
case "Page.getFrameTree":
|
||||
return {
|
||||
frameTree: {
|
||||
frame: {
|
||||
id: "runtime-main",
|
||||
url: `${origin}/app`,
|
||||
loaderId: "document-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
case "Profiler.startPreciseCoverage":
|
||||
return { timestamp: 12 };
|
||||
case "Profiler.takePreciseCoverage":
|
||||
return {
|
||||
timestamp: 13,
|
||||
result: [
|
||||
{
|
||||
scriptId: "script-a",
|
||||
url: `${origin}/same.js`,
|
||||
functions: [
|
||||
{
|
||||
functionName: "selected",
|
||||
isBlockCoverage: true,
|
||||
ranges: [
|
||||
{
|
||||
startOffset: 0,
|
||||
endOffset: runtimeSourceA.length,
|
||||
count: 2,
|
||||
},
|
||||
{ startOffset: 7, endOffset: 8, count: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
scriptId: "script-b",
|
||||
url: `${origin}/same.js`,
|
||||
functions: [
|
||||
{
|
||||
functionName: "untouched",
|
||||
isBlockCoverage: true,
|
||||
ranges: [
|
||||
{
|
||||
startOffset: 0,
|
||||
endOffset: runtimeSourceB.length,
|
||||
count: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
scriptId: "script-foreign",
|
||||
url: `${origin}/same.js`,
|
||||
functions: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
case "Debugger.getScriptSource":
|
||||
return {
|
||||
scriptSource:
|
||||
command.params.scriptId === "script-a"
|
||||
? runtimeSourceA
|
||||
: runtimeSourceB,
|
||||
};
|
||||
case "DOM.getDocument":
|
||||
return { root: { nodeId: 1 } };
|
||||
case "DOM.querySelector":
|
||||
return { nodeId: command.params.selector === "#missing" ? 0 : 2 };
|
||||
case "DOM.describeNode":
|
||||
return { node: { backendNodeId: 22 } };
|
||||
case "DOM.resolveNode":
|
||||
return { object: { objectId: "selected-node-object" } };
|
||||
case "DOMDebugger.getEventListeners":
|
||||
return {
|
||||
listeners: [
|
||||
{
|
||||
type: "click",
|
||||
useCapture: false,
|
||||
passive: true,
|
||||
once: false,
|
||||
scriptId: "script-a",
|
||||
lineNumber: 1,
|
||||
columnNumber: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const runtimeEvents = (command: FakeCdpCommand, origin: string) => {
|
||||
const session =
|
||||
command.sessionId === undefined ? {} : { sessionId: command.sessionId };
|
||||
if (command.method === "Runtime.enable")
|
||||
return [
|
||||
{
|
||||
...session,
|
||||
method: "Runtime.executionContextCreated",
|
||||
params: {
|
||||
context: {
|
||||
id: 1,
|
||||
origin,
|
||||
name: "",
|
||||
auxData: {
|
||||
frameId: "runtime-main",
|
||||
isDefault: true,
|
||||
type: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
if (command.method !== "Debugger.enable") return [];
|
||||
return ["script-a", "script-b", "script-foreign"].map((scriptId) => ({
|
||||
...session,
|
||||
method: "Debugger.scriptParsed",
|
||||
params: {
|
||||
scriptId,
|
||||
url: `${origin}/same.js`,
|
||||
executionContextId: scriptId === "script-foreign" ? 99 : 1,
|
||||
executionContextAuxData: {
|
||||
frameId:
|
||||
scriptId === "script-foreign" ? "foreign-frame" : "runtime-main",
|
||||
},
|
||||
startLine: 0,
|
||||
startColumn: 0,
|
||||
endLine: 1,
|
||||
endColumn: 0,
|
||||
hash: "producer-reported-hash",
|
||||
hasSourceURL: true,
|
||||
scriptLanguage: "JavaScript",
|
||||
},
|
||||
}));
|
||||
};
|
||||
Loading…
Reference in New Issue