From 3ff6407377803f96db86b08d7dcdf9385a107de6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 09:57:33 +0000 Subject: [PATCH 1/9] feat: rebuild the bQuery DevTools extension on BrowserExtensionTemplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the template scaffold with a typed, tested DevTools extension that speaks the stable `@bquery/bquery/devtools` bridge protocol (v1), fulfilling the bootstrap, rebuild, UI, quality-gate and documentation phases of the extraction ticket. Protocol - Typed panel-side client: handshake with retry, capability negotiation, request/response correlation, per-request timeouts and reconnection. - The published protocol version and capability union are referenced through `typeof import(...)` type queries, so an upstream protocol bump becomes a compile error here instead of a silent runtime mismatch — and no page-side runtime is bundled into the extension. - Every message and method result from the page is schema-validated; malformed members are dropped and tree recursion is depth-capped. Transports and permissions - Default `EvalTransport` talks to the page through `inspectedWindow.eval`, which needs no host permission; the manifest ships with an empty `host_permissions`. - Opt-in `PortTransport` upgrades to push streaming after the user grants a single origin permission, via an on-demand content-script injection. - Background router isolates routes per inspected tab and checks the session token it issues on attach. Panel UI (Web Components) - Component tree with tag/attribute search and click-to-reveal in the Elements panel, addressed by structural path rather than the non-unique node id. - Signals and stores inspectors with lazy drill-down into nested values. - Timeline with a configurable ring buffer, type chips, search, pause, clear. - Time travel replays state onto the connect-time snapshot, labelling each row as replayed, unchanged or not recorded rather than inventing values. - Page-derived text only ever reaches text sinks; the CSP forbids inline script and inline style. Quality gates - 153 unit tests (bun test) plus 9 Playwright E2E smoke tests that drive the real built panel against a fixture page speaking protocol v1. - CI runs type-check, lint, format check, unit tests, both build targets with a manifest verifier, and the E2E suite; a release workflow packages signed store artifacts for Chromium (MV3) and Firefox (MV2). Docs - README, CONTRIBUTING, architecture notes and a publishing guide for the Chrome Web Store and AMO. --- .eslintrc.json | 31 -- .github/workflows/ci.yml | 99 +++++++ .github/workflows/release.yml | 68 +++++ .gitignore | 5 + CHANGELOG.md | 45 +++ CONTRIBUTING.md | 75 +++++ README.md | 315 +++++++------------- app.config.json | 26 +- bun.lock | 23 +- docs/ARCHITECTURE.md | 142 +++++++++ docs/PUBLISHING.md | 89 ++++++ eslint.config.js | 36 +-- package.json | 49 ++-- playwright.config.ts | 44 +++ public/devtools.html | 10 + public/manifest.json | 33 +-- public/options.html | 20 +- public/panel.html | 15 + public/popup.html | 25 -- src/app.ts | 70 ----- src/assets/logo.afdesign | Bin 24737 -> 0 bytes src/background.ts | 155 ++++------ src/background/router.ts | 158 ++++++++++ src/browser.ts | 35 +++ src/classes/errorBoundary.ts | 111 ------- src/classes/session.ts | 194 ------------- src/components/button.ts | 174 ----------- src/content.ts | 59 ++++ src/devtools.ts | 13 + src/panel.ts | 160 +++++++++++ src/panel/components/base.ts | 70 +++++ src/panel/components/componentTree.ts | 126 ++++++++ src/panel/components/inspector.ts | 109 +++++++ src/panel/components/shell.ts | 115 ++++++++ src/panel/components/statusBar.ts | 106 +++++++ src/panel/components/timelineView.ts | 284 ++++++++++++++++++ src/panel/components/valueView.ts | 82 ++++++ src/panel/dom.ts | 64 +++++ src/panel/settings.ts | 73 +++++ src/panel/state.ts | 232 +++++++++++++++ src/panel/timeTravel.ts | 207 +++++++++++++ src/panel/timeline.ts | 126 ++++++++ src/panel/tree.ts | 132 +++++++++ src/panel/valueTree.ts | 141 +++++++++ src/protocol/client.ts | 280 ++++++++++++++++++ src/protocol/envelope.ts | 146 ++++++++++ src/protocol/messages.ts | 212 ++++++++++++++ src/protocol/results.ts | 167 +++++++++++ src/protocol/transport.ts | 45 +++ src/sass/_content.sass | 6 - src/sass/_mixin.sass | 106 +------ src/sass/_root.sass | 121 ++++---- src/sass/app.sass | 51 ---- src/sass/panel.sass | 399 ++++++++++++++++++++++++++ src/settings.ts | 262 ++++++----------- src/transports/evalTransport.ts | 167 +++++++++++ src/transports/portTransport.ts | 194 +++++++++++++ src/types/buttonType.ts | 10 - tests/e2e/fixture.ts | 170 +++++++++++ tests/e2e/panel.spec.ts | 228 +++++++++++++++ tests/e2e/server.ts | 34 +++ tests/helpers/bridge.ts | 84 ++++++ tests/unit/background.router.test.ts | 271 +++++++++++++++++ tests/unit/panel.settings.test.ts | 37 +++ tests/unit/panel.state.test.ts | 244 ++++++++++++++++ tests/unit/panel.timeTravel.test.ts | 131 +++++++++ tests/unit/panel.timeline.test.ts | 111 +++++++ tests/unit/panel.tree.test.ts | 156 ++++++++++ tests/unit/panel.valueTree.test.ts | 101 +++++++ tests/unit/protocol.client.test.ts | 143 +++++++++ tests/unit/protocol.messages.test.ts | 114 ++++++++ tests/unit/protocol.results.test.ts | 95 ++++++ tests/unit/transports.eval.test.ts | 147 ++++++++++ tests/unit/transports.port.test.ts | 205 +++++++++++++ tools/content.ts | 22 ++ tools/package.ts | 35 +++ tools/v2.ts | 36 ++- tools/verifyBuild.js | 64 +++++ tools/verifyBuild.ts | 95 ++++++ tsconfig.json | 4 +- vite.config.ts | 7 +- 81 files changed, 7420 insertions(+), 1426 deletions(-) delete mode 100644 .eslintrc.json create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/PUBLISHING.md create mode 100644 playwright.config.ts create mode 100644 public/devtools.html create mode 100644 public/panel.html delete mode 100644 public/popup.html delete mode 100644 src/app.ts delete mode 100644 src/assets/logo.afdesign create mode 100644 src/background/router.ts create mode 100644 src/browser.ts delete mode 100644 src/classes/errorBoundary.ts delete mode 100644 src/classes/session.ts delete mode 100644 src/components/button.ts create mode 100644 src/content.ts create mode 100644 src/devtools.ts create mode 100644 src/panel.ts create mode 100644 src/panel/components/base.ts create mode 100644 src/panel/components/componentTree.ts create mode 100644 src/panel/components/inspector.ts create mode 100644 src/panel/components/shell.ts create mode 100644 src/panel/components/statusBar.ts create mode 100644 src/panel/components/timelineView.ts create mode 100644 src/panel/components/valueView.ts create mode 100644 src/panel/dom.ts create mode 100644 src/panel/settings.ts create mode 100644 src/panel/state.ts create mode 100644 src/panel/timeTravel.ts create mode 100644 src/panel/timeline.ts create mode 100644 src/panel/tree.ts create mode 100644 src/panel/valueTree.ts create mode 100644 src/protocol/client.ts create mode 100644 src/protocol/envelope.ts create mode 100644 src/protocol/messages.ts create mode 100644 src/protocol/results.ts create mode 100644 src/protocol/transport.ts delete mode 100644 src/sass/_content.sass delete mode 100644 src/sass/app.sass create mode 100644 src/sass/panel.sass create mode 100644 src/transports/evalTransport.ts create mode 100644 src/transports/portTransport.ts delete mode 100644 src/types/buttonType.ts create mode 100644 tests/e2e/fixture.ts create mode 100644 tests/e2e/panel.spec.ts create mode 100644 tests/e2e/server.ts create mode 100644 tests/helpers/bridge.ts create mode 100644 tests/unit/background.router.test.ts create mode 100644 tests/unit/panel.settings.test.ts create mode 100644 tests/unit/panel.state.test.ts create mode 100644 tests/unit/panel.timeTravel.test.ts create mode 100644 tests/unit/panel.timeline.test.ts create mode 100644 tests/unit/panel.tree.test.ts create mode 100644 tests/unit/panel.valueTree.test.ts create mode 100644 tests/unit/protocol.client.test.ts create mode 100644 tests/unit/protocol.messages.test.ts create mode 100644 tests/unit/protocol.results.test.ts create mode 100644 tests/unit/transports.eval.test.ts create mode 100644 tests/unit/transports.port.test.ts create mode 100644 tools/content.ts create mode 100644 tools/package.ts create mode 100644 tools/verifyBuild.js create mode 100644 tools/verifyBuild.ts diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 83d0a6a..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "env": { - "browser": true, - "es2022": true, - "webextensions": true - }, - "extends": ["eslint:recommended", "@typescript-eslint/recommended"], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": ["@typescript-eslint"], - "rules": { - "@typescript-eslint/no-unused-vars": [ - "error", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - } - ], - "@typescript-eslint/explicit-function-return-type": "warn", - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/prefer-const": "error", - "@typescript-eslint/no-var-requires": "error", - "prefer-const": "error", - "no-var": "error", - "no-console": "warn" - }, - "ignorePatterns": ["dist/", "node_modules/", "*.js"] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51955ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +name: CI + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Typecheck, lint and unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Type-check + run: bun run type-check + + - name: Lint + run: bun run lint:check + + - name: Check formatting + run: bun run format:check + + - name: Unit tests + run: bun run test + + build: + name: Build ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: chromium-mv3 + script: deploy-v3 + - target: firefox-mv2 + script: deploy-v2 + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run ${{ matrix.script }} + + - name: Verify the built manifest + run: bun ./tools/verifyBuild.js + + - name: Package + run: bun run package + + - uses: actions/upload-artifact@v4 + with: + name: bquery-devtools-${{ matrix.target }} + path: artifacts/*.zip + if-no-files-found: error + + e2e: + name: E2E smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Playwright browser + run: bun x playwright install --with-deps chromium + + - name: Run E2E tests + run: bun run test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e0949bf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to build (defaults to the current ref)' + required: false + +permissions: + contents: write + +jobs: + release: + name: Build and publish store artifacts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Validate + run: bun run validate + + - name: Build Chromium (MV3) + run: bun run deploy-v3 + + - name: Verify and package Chromium (MV3) + run: | + bun ./tools/verifyBuild.js + bun run package + + - name: Build Firefox (MV2) + run: bun run deploy-v2 + + - name: Verify and package Firefox (MV2) + run: | + bun ./tools/verifyBuild.js + bun run package + + - name: Checksums + run: | + cd artifacts + sha256sum *.zip | tee SHA256SUMS.txt + + - uses: actions/upload-artifact@v4 + with: + name: store-artifacts + path: artifacts/ + + - name: Attach artifacts to the release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/*.zip + artifacts/SHA256SUMS.txt + draft: true + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 7a21a8d..6a05448 100644 --- a/.gitignore +++ b/.gitignore @@ -691,4 +691,9 @@ tools/syncConfig.js tools/parse.js tools/v2.js tools/clean.js +tools/content.js +tools/package.js +artifacts/ +test-results/ +playwright-report/ package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d4e0683 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +The extension versions independently of `@bquery/bquery`; the bridge protocol +version is what ties the two together. + +## [Unreleased] + +## [1.0.0] + +First release as a standalone repository, rebuilt on +[BrowserExtensionTemplate](https://github.com/JosunLP/BrowserExtensionTemplate). +It replaces the untyped reference scaffold that used to live in the framework's +`extension/` folder. + +### Added + +- Typed bridge client for protocol **v1** with handshake retry, capability + negotiation, request/response correlation, per-request timeouts and + reconnection. +- Component tree with search over tags and attributes, and click-to-reveal in + the Elements panel. +- Signals and stores inspector with lazy drill-down into nested values. +- Timeline with a configurable ring buffer, type filter chips, free-text + search, pause and clear. +- Time travel: replay signal and store state as of any recorded event, + reconstructed from the connect-time snapshot. +- Two transports behind one interface — a permission-free + `inspectedWindow.eval` poller (default) and an opt-in push transport over an + injected content script — so the extension ships with **no host permissions**. +- Background router with per-tab isolation and session-token checks. +- Options page for buffer size, poll interval and the live-streaming + preference. +- Chromium (MV3) and Firefox (MV2) build targets, a build verifier, and store + packaging. +- Unit tests (`bun test`) and Playwright E2E smoke tests, both run in CI along + with type-check, lint, format check and both build targets. +- Documentation: README, contributing guide, architecture notes and publishing + guide. + +[Unreleased]: https://github.com/bQuery/devtools-extension/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/bQuery/devtools-extension/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..09a5aef --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing + +Thanks for helping out. This repository holds the bQuery DevTools browser +extension; the framework itself lives in +[bQuery/bQuery](https://github.com/bQuery/bQuery). + +## Setup + +```bash +bun install # Bun 1.3+, see mise.toml +bun run dev # rebuild dist/ on change +``` + +Load `dist/` as an unpacked extension (see the README) and reload it from the +browser's extension page after a rebuild. + +## Before you push + +```bash +bun run validate # type-check + lint + unit tests +bun run format # prettier +bun run test:e2e # Playwright smoke tests (builds dist/ first) +``` + +CI runs the same checks plus both build targets, so a green `validate` is +usually enough to predict a green pipeline. + +If your environment already ships a Chromium that does not match the pinned +Playwright version, point the tests at it: + +```bash +PLAYWRIGHT_CHROMIUM_EXECUTABLE=/path/to/chromium bun run test:e2e +``` + +## House rules + +- **TypeScript, strict.** `tsconfig.json` runs with `noUncheckedIndexedAccess`, + `exactOptionalPropertyTypes` and friends. Please don't loosen it locally. +- **The page is untrusted.** Anything crossing the bridge is validated before + use, and rendered through text sinks — never `innerHTML`. New views should + build DOM through `panel/dom.ts`. +- **Layering.** Views read `PanelState`; state talks to `BridgeClient`; the + client drives a `BridgeTransport`. Please don't shortcut across layers. +- **Protocol changes belong upstream.** The wire contract is owned by + `@bquery/bquery/devtools`. This repository consumes it and pins the version + through a type query, so a protocol bump shows up here as a compile error — + handle it deliberately rather than by widening a type. +- **Permissions.** The extension declares no host permissions. A change that + needs static site access needs a discussion first; `tools/verifyBuild.ts` + fails the build if `host_permissions` reappears. + +## Tests + +- **Unit tests** (`bun test`, files in `tests/unit/`) cover the protocol, + transports, router and panel logic. These modules are deliberately free of + DOM dependencies so they can be tested directly. +- **E2E tests** (`tests/e2e/`) serve the built `dist/` over http and drive the + real panel bundle with a mocked `chrome` API and a fixture page that speaks + protocol v1. Playwright cannot open a real DevTools panel, so this is how the + UI is covered end to end. + +New behaviour needs a test. Bug fixes need the test that would have caught the +bug. + +## Commits and pull requests + +- Conventional-commit style subjects (`feat:`, `fix:`, `docs:`, `chore:`) keep + the changelog readable. +- Describe user-visible changes in `CHANGELOG.md` under *Unreleased*. +- Keep pull requests focused; a protocol change and a UI redesign are two pull + requests. + +## Releasing + +See [docs/PUBLISHING.md](./docs/PUBLISHING.md). diff --git a/README.md b/README.md index 4aa27b4..dec30d0 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,135 @@ -# BrowserExtensionTemplate +# bQuery DevTools -[![GitHub issues](https://img.shields.io/github/issues/JosunLP/BrowserExtensionTemplate?style=for-the-badge)](https://github.com/JosunLP/BrowserExtensionTemplate/issues) -[![GitHub forks](https://img.shields.io/github/forks/JosunLP/BrowserExtensionTemplate?style=for-the-badge)](https://github.com/JosunLP/BrowserExtensionTemplate/network) -[![GitHub stars](https://img.shields.io/github/stars/JosunLP/BrowserExtensionTemplate?style=for-the-badge)](https://github.com/JosunLP/BrowserExtensionTemplate/stargazers) -[![GitHub license](https://img.shields.io/github/license/JosunLP/BrowserExtensionTemplate?style=for-the-badge)](https://github.com/JosunLP/BrowserExtensionTemplate) -[![CodeFactor](https://www.codefactor.io/repository/github/josunlp/browserextensiontemplate/badge?style=for-the-badge)](https://www.codefactor.io/repository/github/josunlp/browserextensiontemplate) +[![CI](https://github.com/bQuery/devtools-extension/actions/workflows/ci.yml/badge.svg)](https://github.com/bQuery/devtools-extension/actions/workflows/ci.yml) +[![License](https://img.shields.io/github/license/bQuery/devtools-extension?style=flat-square)](./LICENSE) -## Description +A browser DevTools extension for inspecting [bQuery](https://bquery.flausch-code.de) +applications: the **component tree**, live **signal** and **store** values, and +the reactive **timeline** — with time travel over recorded events. -A modern, production-ready template for building browser extensions using TypeScript, SASS, and Vite. This template provides a solid foundation with best practices, type safety, and modern development tools. +It speaks the stable bridge protocol (**v1**) exported by +`@bquery/bquery/devtools`, and nothing else. The framework and the extension +ship on separate release cadences; the protocol is the contract between them. -## Features - -- 🚀 **Modern Tech Stack**: TypeScript, SASS, Vite, Bootstrap -- ⚡ **bQuery.js Built-in**: First-class integration of [`@bquery/bquery`](https://bquery.flausch-code.de) — signals, reactive forms, Web Components, sanitized DOM, and the unified storage adapter ship with the template -- 🛡️ **Type Safety**: Strict TypeScript configuration with comprehensive error checking -- 🔧 **Development Tools**: ESLint, Prettier, automated workflows -- 🎯 **Cross-Browser**: Supports both Chrome (Manifest v3) and Firefox (Manifest v2) -- 📦 **Component System**: Reusable UI components with type safety, including a native `` bQuery Web Component -- 💾 **Session Management**: Reactive session powered by bQuery's `platform/storage` adapter and signals -- 🛠️ **Build System**: Optimized Vite configuration with code splitting -- 🎨 **Modern CSS**: CSS Custom Properties with SASS preprocessing -- 🔒 **Security**: Content Security Policy plus bQuery `safeHtml`/text sinks for DOM rendering and `sanitizeHtml` for settings persistence normalization -- ⚡ **Error Handling**: Comprehensive error boundary system - -## Installation +## Quick start -### Quick Start - -```bash -git clone https://github.com/JosunLP/BrowserExtensionTemplate.git -cd BrowserExtensionTemplate -bun install -``` +1. Enable the bridge in the app you want to inspect: -### Development Setup + ```ts + import { connectDevtoolsBridge, enableDevtools } from '@bquery/bquery/devtools'; -```bash -# Install dependencies -bun install + enableDevtools(true); + connectDevtoolsBridge(); // exposes protocol v1 over window.postMessage + ``` -# Start development mode with auto-rebuild -bun run dev + Requires `@bquery/bquery` **≥ 1.15.0**. -# Type checking -bun run type-check +2. Install the extension (see [Installing](#installing)). -# Linting and formatting -bun run validate -``` +3. Open DevTools on that page and select the **bQuery** panel. -## Usage +## Features -### Project Structure +- **Component tree** — every custom element on the page, with its attributes, + filterable by tag or attribute. Clicking a node reveals the real element in + the Elements panel. +- **Signals & stores** — live values with drill-down into nested objects and + arrays, plus subscriber counts. +- **Timeline** — reactive events as they happen, with a configurable ring + buffer, per-type filter chips, free-text search, pause and clear. +- **Time travel** — scrub back through recorded events and see the signal and + store state as of that moment, reconstructed from the connect-time snapshot. +- **Capability negotiation** — the panel only offers what the page's `init` + handshake advertises, and says so when something is missing. +- **No host permissions by default** — see [Permissions](#permissions). -```bash -src/ -├── classes/ # Core classes (Session, ErrorBoundary) -├── components/ # Reusable UI components -├── sass/ # SASS styles with CSS custom properties -├── types/ # TypeScript type definitions -├── app.ts # Popup entry point -├── settings.ts # Options page entry point -└── background.ts # Background service worker - -public/ -├── icons/ # Extension icons -├── manifest.json # Extension manifest -├── popup.html # Popup HTML template -└── options.html # Options page HTML template - -tools/ # Build and automation scripts -``` - -### Configuration - -The main configuration is in `app.config.json`. This file is automatically synchronized with `package.json` and `manifest.json`: - -```json -{ - "AppData": { - "id": "your_extension_id", - "name": "Your Extension Name", - "version": "1.0.0", - "description": "Your extension description" - }, - "htmlTemplatePairs": [ - { - "key": "{{PLACEHOLDER}}", - "value": "Replacement Value" - } - ] -} -``` +## Installing -### Build Commands +### From source ```bash -# Development -bun run dev # Start development with watch mode -bun run sync # Sync configuration files - -# Production -bun run deploy-v3 # Build for Chrome (Manifest v3) -bun run deploy-v2 # Build for Firefox (Manifest v2) - -# Quality Assurance -bun run validate # Type check + lint -bun run lint # ESLint with auto-fix -bun run format # Prettier formatting - -# Utilities -bun run clean # Clean dist folder -bun run build-tooling # Compile TypeScript tools -``` - -### Development Workflow - -1. **Configure your extension** in `app.config.json` -2. **Run sync** to update all config files: `bun run sync` -3. **Start development**: `bun run dev` -4. **Write your code** in the `src/` directory -5. **Build for production**: `bun run deploy-v3` or `bun run deploy-v2` -6. **Load the extension** from the `dist/` folder in your browser - -### Session Management - -Sessions are powered by bQuery's reactive primitives and the unified -`platform/storage` adapter. The `contentTest` field is exposed both as a -plain accessor and as a reactive `Signal`, so consumers can subscribe to -updates without polling: - -```typescript -import { Session } from './classes/session'; -import { effect } from '@bquery/bquery/reactive'; - -const session = await Session.getInstance(); - -// Reactive subscription — re-runs on every change. -effect(() => { - console.log('Content changed:', session.contentTest$.value); -}); - -// Either accessor or signal write; both persist via storage.local() automatically. -session.contentTest = 'New value'; -// Or: session.contentTest$.value = 'New value'; -``` - -### bQuery.js Integration - -The template wires up [`@bquery/bquery`](https://bquery.flausch-code.de) as a -first-class dependency. Use any of its tree-shakeable entry points directly -from `src/`: - -```typescript -import { $, $$ } from '@bquery/bquery/core'; -import { signal, computed, effect } from '@bquery/bquery/reactive'; -import { createForm, required } from '@bquery/bquery/forms'; -import { component, safeHtml, bool } from '@bquery/bquery/component'; -import { escapeHtml, sanitizeHtml } from '@bquery/bquery/security'; -import { storage, useAnnouncer } from '@bquery/bquery/platform'; +bun install +bun run deploy-v3 # Chromium / Edge (MV3) → dist/ +bun run deploy-v2 # Firefox (MV2) → dist/ ``` -What ships out of the box: - -- **Reactive popup (`src/app.ts`)** — uses `$` for DOM scripting and `effect` - to keep the popup in sync with the session signal. -- **Reactive options page (`src/settings.ts`)** — uses `createForm` for - validated form state, `useAnnouncer` for accessible status messages, and - bQuery security helpers for render-time escaping plus form-time value - normalization before persistence. -- **`` Web Component (`src/components/button.ts`)** — defined via - `component()` with typed props (`variant`, `text`, `disabled`) and - rendered through `safeHtml` + `bool()`. -- **Reactive background worker (`src/background.ts`)** — tracks runtime - state (`installReason`, `messageCount`) with signals and `computed`. -- **Security helpers included** — error rendering uses `escapeHtml`, the - options form normalizes persisted text with `sanitizeHtml`, and DOM writes - still use context-appropriate escaping/sanitization at the sink. +**Chromium / Edge:** open `chrome://extensions`, enable **Developer mode**, +click **Load unpacked** and pick `dist/`. -### Error Handling +**Firefox:** run `bun run deploy-v2`, then open `about:debugging` → **This +Firefox** → **Load Temporary Add-on** and pick `dist/manifest.json`. -Built-in error boundary system, integrated with bQuery's `escapeHtml`: +`bun run package` writes a store-ready zip to `artifacts/`. -```typescript -import { ErrorBoundary } from './classes/errorBoundary'; +## Permissions -const errorBoundary = ErrorBoundary.getInstance(); +The extension declares **no host permissions**. By default the panel talks to +the page through `chrome.devtools.inspectedWindow.eval`, which a DevTools panel +may use on the page it is inspecting without any site access, and drains +buffered bridge messages on a short poll. -// Wrap async functions -const safeAsyncFunction = errorBoundary.wrapAsync(asyncFunction); +**Enable live streaming** in the panel's status bar upgrades that to a push +transport: the panel asks for permission for the current site only, injects a +small content-script relay, and events arrive as they happen. The permission is +per-site, requested on a click, and revocable from the browser's extension +settings. Everything works without it — you just get polling instead of push. -// Add custom error handlers -errorBoundary.addErrorHandler(error => { - console.log('Custom error handling:', error); -}); +| Permission | Why | +| ----------------------------- | ------------------------------------------------------- | +| `storage` | Panel preferences (buffer size, poll interval). No site access. | +| `scripting` | Injecting the relay when you opt into live streaming. | +| `optional_host_permissions` | Requested at runtime, one origin at a time. | -// Render an error message safely (HTML-escaped via bQuery security) -element.innerHTML = ErrorBoundary.formatErrorMessage(unsafeMessage); -``` +## Security model -### Component System +The inspected page is treated as untrusted, because it is: -Type-safe, reusable components — both as imperative helpers and as native -Web Components built with bQuery: +- every message from the page is schema-validated before use, and messages + from a foreign protocol version are rejected outright; +- every value the panel displays is written through text sinks — the panel + never assigns page-derived strings to `innerHTML`; +- the panel's CSP forbids inline script and inline style; +- panel → page messages are embedded as JSON *data* in the evaluated + expression, never spliced into its source; +- the background router forwards a panel's messages only to the tab that panel + attached to, and only when they carry the session token it issued. -```typescript -import { BasicButton } from './components/button'; +## Development -// Imperative helper (kept for backward compatibility) -const button = new BasicButton('primary', 'Click me', 'my-button'); -const buttonElement = button.createElement(); - -// Or use the Web Component directly in HTML/templates. -// Importing the module auto-registers the custom element in page contexts. -document.body.insertAdjacentHTML( - 'beforeend', - '' -); +```bash +bun install +bun run dev # rebuild on change +bun run validate # type-check + lint + unit tests +bun run test # unit tests (bun test) +bun run test:e2e # build, then Playwright smoke tests +bun run verify # sanity-check a built dist/ ``` -## Browser Compatibility - -- **Chrome**: Manifest v3 (recommended) -- **Firefox**: Manifest v2 (automatically converted) -- **Edge**: Manifest v3 compatible - -## Contributing - -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/amazing-feature` -3. Make your changes and ensure tests pass: `bun run validate` -4. Commit your changes: `git commit -m 'Add amazing feature'` -5. Push to the branch: `git push origin feature/amazing-feature` -6. Open a Pull Request +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full workflow and +[docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for how the pieces fit together. +Publishing is documented in [docs/PUBLISHING.md](./docs/PUBLISHING.md). -## Development Guidelines +## Protocol (v1) -- Follow TypeScript best practices -- Use meaningful variable and function names -- Add proper error handling -- Write self-documenting code -- Follow the established project structure -- Run `bun run validate` before committing +Every message carries `source: 'bquery-devtools'` and the protocol version `v`. -## License +| Direction | `kind` | Purpose | +| ------------ | ---------- | -------------------------------------------- | +| panel → page | `hello` | Announce the panel; the page replies `init` | +| panel → page | `request` | `{ id, method, params }` | +| page → panel | `init` | `{ capabilities }` handshake | +| page → panel | `response` | `{ id, result \| error }` | +| page → panel | `event` | One streamed timeline `entry` | -This project is licensed under the [MIT License](https://opensource.org/licenses/MIT). +**Methods:** `ping`, `getSnapshot`, `getTimeline` (`{ limit }`), +`getComponentTree`. Apps can add their own through +`connectDevtoolsBridge({ methods })`; the panel ignores methods it does not +know about. -## Author +**Capabilities:** `signals`, `stores`, `components`, `timeline`, `time-travel`. -**_Jonas Pfalzgraf_** +## Credits -- Email: -- GitHub: [@JosunLP](https://github.com/JosunLP) +Bootstrapped from +[BrowserExtensionTemplate](https://github.com/JosunLP/BrowserExtensionTemplate) +by Jonas Pfalzgraf. Licensed under the [MIT License](./LICENSE). diff --git a/app.config.json b/app.config.json index 96a13dc..da4e701 100644 --- a/app.config.json +++ b/app.config.json @@ -1,29 +1,33 @@ { "AppData": { - "id": "browser_extension_template", - "name": "Browser Extension Template", - "version": "0.0.1", - "description": "A basic template based on SASS and TypeScript to create browser extensions without directly relying on a larger framework.", + "id": "bquery-devtools-extension", + "name": "bQuery DevTools", + "version": "1.0.0", + "description": "Inspect bQuery apps: component tree, signals, stores, and the reactive timeline over the stable devtools bridge protocol (v1).", "repository": { "type": "git", - "url": "git+ssh://git@github.com:JosunLP/BrowserExtensionTemplate.git" + "url": "git+https://github.com/bQuery/devtools-extension.git" }, "license": "MIT", - "homepage": "https://github.com/JosunLP/BrowserExtensionTemplate", + "homepage": "https://github.com/bQuery/devtools-extension", "bugs": { - "url": "https://github.com/JosunLP/BrowserExtensionTemplate/issues" + "url": "https://github.com/bQuery/devtools-extension/issues" }, "authors": [ { - "name": "Jonas Pfalzgraf", + "name": "bQuery contributors", "email": "info@josunlp.de" } - ] + ], + "firefox": { + "geckoId": "bquery-devtools@bquery.js", + "strictMinVersion": "115.0" + } }, "htmlTemplatePairs": [ { - "key": "{{BET}}", - "value": "Browser Extension Template" + "key": "{{BQD}}", + "value": "bQuery DevTools" } ] } diff --git a/bun.lock b/bun.lock index f06b4ec..5e79936 100644 --- a/bun.lock +++ b/bun.lock @@ -6,17 +6,16 @@ "name": "browser_extension_template", "dependencies": { "@bquery/bquery": "^1.16.0", - "@webcomponents/custom-elements": "^1.6.0", - "bootstrap": "^5.3.8", }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.56.0", + "@types/bun": "^1.3.6", "@types/chrome": "^0.2.6", "@types/node": "^26.2.0", "@typescript-eslint/eslint-plugin": "^8.67.0", "@typescript-eslint/parser": "^8.67.0", - "@webcomponents/webcomponentsjs": "^2.8.0", "esbuild": "^0.28.2", "eslint": "^10.8.1", "prettier": "^3.9.6", @@ -140,7 +139,7 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], - "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="], @@ -172,6 +171,8 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/chrome": ["@types/chrome@0.2.6", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-8OgXtL+OcR2IpY2ul5itg0R3xmdceAM4ldcHxh4BFL20p5z7fjhpyP5KuXAp1LIxA7oQgyViKTS9d+W8Sn8jdQ=="], "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], @@ -208,10 +209,6 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="], - "@webcomponents/custom-elements": ["@webcomponents/custom-elements@1.6.0", "", {}, "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww=="], - - "@webcomponents/webcomponentsjs": ["@webcomponents/webcomponentsjs@2.8.0", "", {}, "sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -222,10 +219,10 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "bootstrap": ["bootstrap@5.3.8", "", { "peerDependencies": { "@popperjs/core": "^2.11.8" } }, "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg=="], - "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -372,6 +369,10 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -440,6 +441,8 @@ "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..766ee66 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,142 @@ +# Architecture + +## The shape of the problem + +A DevTools panel and the page it inspects live in different worlds. The page +runs the app (and the bQuery bridge); the panel runs in the DevTools window. +Everything between them is a message channel with an untrusted party on one +end. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ inspected page │ +│ app code → connectDevtoolsBridge() ← the stable contract (v1) │ +└───────────────▲───────────────────────────────────┬─────────────────┘ + │ window.postMessage │ + ┌────────────┴─────────────┐ ┌────────────▼───────────────┐ + │ EvalTransport (default) │ │ content.js relay (opt-in) │ + │ inspectedWindow.eval │ │ chrome.runtime │ + └────────────┬─────────────┘ └────────────┬───────────────┘ + │ │ port + session token + │ ┌───────────▼───────────────┐ + │ │ background/router.ts │ + │ └───────────┬───────────────┘ + ┌────────────▼───────────────────────────────────▼───────────────┐ + │ BridgeClient — handshake, capabilities, request/response, │ + │ timeouts, reconnection │ + └────────────────────────────┬───────────────────────────────────┘ + ┌────────────────────────────▼───────────────────────────────────┐ + │ PanelState — signals, stores, tree, timeline buffer, │ + │ time-travel reconstruction │ + └────────────────────────────┬───────────────────────────────────┘ + ┌────────────────────────────▼───────────────────────────────────┐ + │ Web Components — , , │ + │ , , , │ + └────────────────────────────────────────────────────────────────┘ +``` + +## Layers + +| Layer | Module | Responsibility | +| -------------- | ----------------------------- | ----------------------------------------------------- | +| Protocol | `src/protocol/messages.ts` | Message shapes, builders, and validation of page input | +| Protocol | `src/protocol/results.ts` | Validation of method *results* | +| Protocol | `src/protocol/client.ts` | Handshake, capabilities, request correlation, timeouts | +| Transport | `src/transports/*.ts` | Two ways to move bytes between panel and page | +| Routing | `src/background/router.ts` | Tab-scoped, token-checked routing for the port transport | +| State | `src/panel/*.ts` | Buffering, filtering, time travel, preferences | +| View | `src/panel/components/*.ts` | Custom elements rendering from panel state | + +Each layer only knows the one below it. The views never talk to a transport; +the client never touches the DOM; the protocol modules have no browser +dependencies at all, which is why most of them are unit-testable without a DOM. + +## The protocol is imported, not copied + +`src/protocol/messages.ts` declares the version and capability list using +`typeof import('@bquery/bquery/devtools')` type queries: + +```ts +export const BRIDGE_PROTOCOL_VERSION: + typeof import('@bquery/bquery/devtools').BRIDGE_PROTOCOL_VERSION = 1; +``` + +This is a type-only reference, so the framework's page-side bridge runtime is +never bundled into the extension — but if upstream bumps the protocol or +changes the capability union, this repository fails to compile. The contract is +enforced by the type-checker rather than by a comment. + +## Two transports, one interface + +Both implement `BridgeTransport` (`start` / `send` / `dispose` plus a status +callback), so `BridgeClient` is unaware of which one it drives. + +**`EvalTransport` (default).** A DevTools panel may evaluate expressions in the +page it inspects without any host permission. The transport evaluates a small +expression that installs a `message` listener buffering page-channel bridge +messages into an array, and returns (and clears) that array as JSON. The +install is idempotent and re-runs on every poll, which makes the transport +self-healing across navigations. Outbound messages are evaluated as +`window.postMessage(JSON.parse("…"), '*')` — the message is *data* inside the +expression, never source. + +**`PortTransport` (opt-in).** A long-lived `chrome.runtime` port to the +background worker, which relays to a content script injected into the inspected +tab. Push instead of poll, at the cost of one per-site permission. MV3 service +workers are evicted aggressively, so a dropped port reconnects with backoff and +re-attaches; the client re-runs the handshake on the fresh route. + +## Routing and its trust boundaries + +One background worker serves every open panel, so `BridgeRouter` keys its table +by inspected tab id and enforces two rules: + +- **panel → page**: forwarded only to the tab that port attached to, and only + when the envelope carries the session token the router issued on attach. A + message that arrives without the token — or with another port's — is dropped. +- **page → panel**: routed by `sender.tab.id`, which the browser fills in and a + page script cannot forge, and only to the panel registered for that tab. + +The token is defence in depth, not the primary boundary (that is the browser's +own port isolation): it means a stray message inside the extension's own +message space cannot steer another panel's route. + +## Untrusted input + +Everything arriving from the page is attacker-controlled — a hostile page can +name a component `` or return a cyclic value. Three rules +hold throughout: + +1. **Validate, don't cast.** `parseOutbound` and the `results.ts` parsers narrow + unknown input, dropping malformed members instead of rendering them. Tree + recursion is depth-capped. +2. **Text sinks only.** `panel/dom.ts` sets `textContent`, never `innerHTML`. + Inline styles go through `style.setProperty`, since the panel's CSP forbids + `style` attributes. +3. **Bound everything.** Previews are truncated, child lists capped, the + timeline is a ring buffer, and the in-page relay queue is bounded too. + +## Time travel + +The bridge exposes primitives, not history: `getSnapshot` is the state *now* +and `event` messages stream what changed after. Time travel is reconstructed in +the panel — the connect-time snapshot is the base, and `panel/timeTravel.ts` +replays recorded events onto it up to a chosen index. + +Event payloads are app-defined (`payload?: unknown`), so replay is deliberately +tolerant: it recognizes `{ value }`, `{ next }`, `{ to }` and bare payloads for +signals, and `{ patch }`, `{ state }`, `{ next }` or a plain object for stores. +When a payload cannot be interpreted, the value is reported as *not recorded* +and the previous value is kept — the panel never invents state, and the UI +labels every row as `replayed`, `unchanged` or `not recorded`. + +Reconstruction is read-only: nothing is ever written back into the page. + +## Build + +`vite` builds the module entries (`panel`, `devtools`, `settings`, +`background`). The content script cannot be an ES module, so `tools/content.ts` +bundles it separately with esbuild as a self-contained IIFE. `tools/parse.ts` +substitutes the branding tokens into the HTML pages and links the emitted CSS; +`tools/v2.ts` rewrites the manifest for Firefox (MV2); `tools/verifyBuild.ts` +checks the result is actually loadable before it is packaged. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md new file mode 100644 index 0000000..37f7d9b --- /dev/null +++ b/docs/PUBLISHING.md @@ -0,0 +1,89 @@ +# Publishing + +The extension versions independently of `@bquery/bquery`: store review is slow +and unpredictable, and the bridge protocol — not the release train — is what +keeps the two compatible. + +## Versioning + +`app.config.json` is the single source of truth. `bun run sync` copies its +`AppData.version` into `package.json` and `public/manifest.json`, and every +`deploy-*` script runs `sync` first. + +To cut a release: + +```bash +# 1. bump AppData.version in app.config.json (semver, e.g. 1.1.0) +bun run sync +# 2. update CHANGELOG.md +git commit -am "chore: release 1.1.0" +git tag v1.1.0 +git push --follow-tags +``` + +The tag triggers `.github/workflows/release.yml`, which validates, builds both +targets, verifies and packages them, writes `SHA256SUMS.txt` and opens a +**draft** GitHub release with the zips attached. Review it, then publish. + +Store manifests must use a numeric `major.minor.patch[.build]` version. Do not +put pre-release suffixes (`1.1.0-beta.1`) in `AppData.version`; use a build +segment (`1.1.0.1`) if you need one. + +## Building locally + +```bash +bun run deploy-v3 && bun run verify && bun run package # chromium-mv3 +bun run deploy-v2 && bun run verify && bun run package # firefox-mv2 +``` + +Both zips land in `artifacts/`. `bun run verify` is not optional — it catches +a missing entry point, an unreplaced branding token, a content script that +accidentally became an ES module, and host permissions creeping back in. + +## Chrome Web Store + +1. Sign in to the [Developer Dashboard](https://chrome.google.com/webstore/devconsole) + with the account that owns the listing. +2. **Upload new package** → `artifacts/bquery-devtools--chromium-mv3.zip`. +3. Check the listing fields (they change less often than the code): + - *Category*: Developer Tools. + - *Screenshots*: the panel on a page running a bQuery app — component tree, + signals, and timeline are the three that matter. + - *Privacy*: the extension collects and transmits **nothing**. Declare no + data collection. +4. Justify the permissions. Reviewers ask about these two: + - `scripting` — "injects a small relay into the inspected tab, only after + the user explicitly enables live streaming and grants that site's origin"; + - the optional host permission — "requested at runtime for one origin at a + time; the extension declares no host permissions and works without any". +5. Submit. Review typically takes a few days; a permission change resets it. + +Edge Add-ons accepts the same MV3 zip through +[Partner Center](https://partner.microsoft.com/dashboard/microsoftedge). + +## Firefox Add-ons (AMO) + +1. Build the MV2 target — `browser_specific_settings.gecko.id` comes from + `AppData.firefox.geckoId` in `app.config.json` and must stay stable across + releases, or AMO treats the upload as a different add-on. +2. Sign in at [addons.mozilla.org/developers](https://addons.mozilla.org/developers/) + and upload `artifacts/bquery-devtools--firefox-mv2.zip`. +3. AMO requires reviewable sources for a bundled build. Provide the repository + tag plus these build instructions: + + ```bash + bun install --frozen-lockfile + bun run deploy-v2 + ``` + + Note the Bun version from `mise.toml` in the source-upload notes. +4. Self-distribution (unlisted signing) uses the same zip via `web-ext sign` if + you need a signed build outside AMO. + +## After publishing + +- Verify the published version loads in a clean profile in both browsers. +- Confirm the panel connects to a page running `connectDevtoolsBridge()`, and + that **Enable live streaming** still prompts for the origin permission — a + store-side permission change can silently alter that prompt. +- Move the release notes from *Unreleased* in `CHANGELOG.md`. diff --git a/eslint.config.js b/eslint.config.js index 66743e0..fd93a10 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,42 +4,25 @@ import tsParser from '@typescript-eslint/parser'; export default [ { - ignores: ['dist/', 'node_modules/', '**/*.js'], + ignores: ['dist/', 'artifacts/', 'node_modules/', 'tools/*.js', '**/*.js'], }, js.configs.recommended, { - files: ['src/**/*.ts'], + files: ['src/**/*.ts', 'tests/**/*.ts'], languageOptions: { parser: tsParser, ecmaVersion: 'latest', sourceType: 'module', - globals: { - chrome: 'readonly', - browser: 'readonly', - console: 'readonly', - document: 'readonly', - window: 'readonly', - localStorage: 'readonly', - sessionStorage: 'readonly', - HTMLElement: 'readonly', - HTMLDivElement: 'readonly', - HTMLButtonElement: 'readonly', - HTMLInputElement: 'readonly', - setTimeout: 'readonly', - clearTimeout: 'readonly', - crypto: 'readonly', - customElements: 'readonly', - Error: 'readonly', - JSON: 'readonly', - Date: 'readonly', - String: 'readonly', - }, }, plugins: { '@typescript-eslint': tsPlugin, }, rules: { ...tsPlugin.configs.recommended.rules, + // TypeScript resolves every identifier against `lib`/`types`, so + // `no-undef` only duplicates that check — badly, since it has no view of + // the DOM and WebExtension type libraries. + 'no-undef': 'off', '@typescript-eslint/no-unused-vars': [ 'error', { @@ -53,4 +36,11 @@ export default [ 'no-console': 'off', }, }, + { + // Test doubles legitimately model partial browser APIs. + files: ['tests/**/*.ts'], + rules: { + '@typescript-eslint/no-empty-function': 'off', + }, + }, ]; diff --git a/package.json b/package.json index 56e8113..088d79d 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,41 @@ { - "name": "browser_extension_template", - "version": "0.0.1", + "name": "bquery-devtools-extension", + "version": "1.0.0", "private": true, "type": "module", "scripts": { - "deploy-v3": "bun run clean && bun run build-tooling && bun run sync && bun run build && bun run parse", + "deploy-v3": "bun run clean && bun run build-tooling && bun run sync && bun run build && bun run build-content && bun run parse", "deploy-v2": "bun run deploy-v3 && bun ./tools/v2.js", "build": "vite build", + "build-content": "bun run build-tooling && bun ./tools/content.js", "build-tooling": "tsc --project ./tooling.tsconfig.json", "watch": "vite build --watch", "sync": "bun run build-tooling && bun ./tools/syncConfig.js", "parse": "bun ./tools/parse.js", "clean": "bunx rimraf ./dist/", "dev": "bun run sync && bun run watch", - "lint": "eslint src/**/*.ts --fix", - "format": "prettier --write --no-error-on-unmatched-pattern src/**/*.{ts,json}", - "format:ts": "prettier --write --no-error-on-unmatched-pattern src/**/*.ts", - "format:json": "prettier --write --no-error-on-unmatched-pattern src/**/*.json", + "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" --fix", + "lint:check": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"", + "format": "prettier --write --no-error-on-unmatched-pattern \"src/**/*.{ts,json}\" \"tests/**/*.ts\" \"tools/*.ts\"", + "format:check": "prettier --check --no-error-on-unmatched-pattern \"src/**/*.{ts,json}\" \"tests/**/*.ts\" \"tools/*.ts\"", + "test": "bun test tests/unit", + "test:e2e": "bun run deploy-v3 && bun x playwright test", "type-check": "tsc --noEmit", - "validate": "bun run type-check && bun run lint", - "prepare": "bun run validate && bun run build-tooling" + "validate": "bun run type-check && bun run lint:check && bun run test", + "package": "bun ./tools/package.js", + "prepare": "bun run build-tooling", + "clean:all": "bunx rimraf ./dist/ ./artifacts/ ./test-results/ ./playwright-report/", + "verify": "bun run build-tooling && bun ./tools/verifyBuild.js" }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.56.0", + "@types/bun": "^1.3.6", "@types/chrome": "^0.2.6", "@types/node": "^26.2.0", "@typescript-eslint/eslint-plugin": "^8.67.0", "@typescript-eslint/parser": "^8.67.0", - "@webcomponents/webcomponentsjs": "^2.8.0", "esbuild": "^0.28.2", "eslint": "^10.8.1", "prettier": "^3.9.6", @@ -37,26 +44,28 @@ "typescript": "^6.0.3", "vite": "^8.2.1" }, - "browserslist": ["> 1%", "last 2 versions", "not dead"], + "browserslist": [ + "> 1%", + "last 2 versions", + "not dead" + ], "authors": [ { - "name": "Jonas Pfalzgraf", + "name": "bQuery contributors", "email": "info@josunlp.de" } ], - "description": "A basic template based on SASS and TypeScript to create browser extensions without directly relying on a larger framework.", - "homepage": "https://github.com/JosunLP/BrowserExtensionTemplate", + "description": "Inspect bQuery apps: component tree, signals, stores, and the reactive timeline over the stable devtools bridge protocol (v1).", + "homepage": "https://github.com/bQuery/devtools-extension", "license": "MIT", "repository": { "type": "git", - "url": "git+ssh://git@github.com:JosunLP/BrowserExtensionTemplate.git" + "url": "git+https://github.com/bQuery/devtools-extension.git" }, "bugs": { - "url": "https://github.com/JosunLP/BrowserExtensionTemplate/issues" + "url": "https://github.com/bQuery/devtools-extension/issues" }, "dependencies": { - "@bquery/bquery": "^1.16.0", - "@webcomponents/custom-elements": "^1.6.0", - "bootstrap": "^5.3.8" + "@bquery/bquery": "^1.16.0" } -} +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..897a7d9 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = Number(process.env['PORT'] ?? 4173); + +/** + * Escape hatch for environments that already ship a Chromium build which does + * not match the one this Playwright version downloads. CI installs the + * matching browser and leaves this unset. + */ +const executablePath = process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE']; + +/** + * E2E configuration. + * + * `dist/` must be built first; `bun run test:e2e` does that for you. + */ +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.spec.ts', + fullyParallel: true, + forbidOnly: Boolean(process.env['CI']), + retries: process.env['CI'] ? 1 : 0, + reporter: process.env['CI'] ? 'list' : 'line', + use: { + baseURL: `http://127.0.0.1:${PORT}`, + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + ...(executablePath ? { launchOptions: { executablePath } } : {}), + }, + }, + ], + webServer: { + command: `bun run tests/e2e/server.ts`, + url: `http://127.0.0.1:${PORT}/panel.html`, + reuseExistingServer: !process.env['CI'], + env: { PORT: String(PORT) }, + stdout: 'ignore', + }, +}); diff --git a/public/devtools.html b/public/devtools.html new file mode 100644 index 0000000..5cdb70a --- /dev/null +++ b/public/devtools.html @@ -0,0 +1,10 @@ + + + + + {{BQD}} + + + + + diff --git a/public/manifest.json b/public/manifest.json index fdba02c..ae1aff4 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,42 +1,33 @@ { - "name": "Browser Extension Template", - "version": "0.0.1", + "name": "bQuery DevTools", + "version": "1.0.0", "manifest_version": 3, - "description": "A basic template based on SASS and TypeScript to create browser extensions without directly relying on a larger framework.", - "homepage_url": "https://github.com/JosunLP/BrowserExtensionTemplate", + "description": "Inspect bQuery apps: component tree, signals, stores, and the reactive timeline over the stable devtools bridge protocol (v1).", + "homepage_url": "https://github.com/bQuery/devtools-extension", + "minimum_chrome_version": "102", "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "action": { - "default_icon": "icons/icon16.png", - "default_title": "BrowserExtensionTemplate", - "default_popup": "popup.html" - }, + "devtools_page": "devtools.html", "options_ui": { "page": "options.html", "open_in_tab": false }, "permissions": [ "storage", - "notifications" + "scripting" + ], + "optional_host_permissions": [ + "*://*/*" ], "background": { "service_worker": "background.js", "type": "module" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; style-src 'self' 'unsafe-inline';" + "extension_pages": "script-src 'self'; object-src 'self'; style-src 'self';" }, - "web_accessible_resources": [ - { - "resources": [ - "icons/*.png" - ], - "matches": [ - "" - ] - } - ] + "web_accessible_resources": [] } \ No newline at end of file diff --git a/public/options.html b/public/options.html index c9a0e6b..a6bf3a4 100644 --- a/public/options.html +++ b/public/options.html @@ -4,22 +4,20 @@ - {{BET}} Options + {{BQD}} Options -
- -

Settings

+
+

{{BQD}}

+

+ Open the bQuery panel in your browser's DevTools to inspect an app that + calls connectDevtoolsBridge(). +

-
- -
+ diff --git a/public/panel.html b/public/panel.html new file mode 100644 index 0000000..fc4d3dd --- /dev/null +++ b/public/panel.html @@ -0,0 +1,15 @@ + + + + + + {{BQD}} + + + +
+ + + diff --git a/public/popup.html b/public/popup.html deleted file mode 100644 index a59f164..0000000 --- a/public/popup.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - {{BET}} - - - -
- -

{{BET}}

-
-
-
- -
- - diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index 20e45a6..0000000 --- a/src/app.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { safeHtml } from '@bquery/bquery/component'; -import { $ } from '@bquery/bquery/core'; -import { effect } from '@bquery/bquery/reactive'; -import { Session } from './classes/session'; -import './sass/app.sass'; - -class App { - private static readonly CONTENT_ENTRY = 'content'; - private session: Session | null = null; - - constructor() { - void this.init(); - } - - private async init(): Promise { - try { - this.session = await Session.getInstance(); - this.drawData(); - await this.main(); - } catch (error) { - console.error('Failed to initialize app:', error); - this.handleError('Failed to initialize application'); - } - } - - private async main(): Promise { - console.log('Hello World'); - } - - private drawData(): void { - const session = this.session; - if (!session) { - throw new Error('Session not initialized'); - } - - if (!document.getElementById(App.CONTENT_ENTRY)) { - throw new Error(`Element with id '${App.CONTENT_ENTRY}' not found`); - } - - // Scaffold the static structure once using bQuery's chainable DOM API. - $(`#${App.CONTENT_ENTRY}`) - .empty() - .append( - `
-

Hello World

-

-
` - ); - - // Cache the target element wrapper once so the reactive effect does not - // re-query the DOM on every signal update. - const contentTest = $('#bet-content-test'); - - // Reactively mirror this popup's in-memory session signal into the DOM. - // This keeps the UI in sync with updates made through the same Session - // instance, but it does not add cross-page storage synchronization. - effect(() => { - contentTest.text(session.contentTest$.value); - }); - } - - private handleError(message: string): void { - console.error(message); - if (document.getElementById(App.CONTENT_ENTRY)) { - $(`#${App.CONTENT_ENTRY}`).html(safeHtml`
${message}
`); - } - } -} - -new App(); diff --git a/src/assets/logo.afdesign b/src/assets/logo.afdesign deleted file mode 100644 index 704c43b45d7fdf0faf95360747670a7ef360da08..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24737 zcmbTdcRbbq`#=6TgJWfNj6`O(LKN9ED|=<+G?W!0Gn|9SXrPF)Q})UXIfoJ{BPE2K zjF)V($2rgM)9dyA{Q3Lm_qg4T=X%WRx*pf#y6)EnK*m~(003`)KXYM2?;Bm5@B>`V za{YG={@?R|PXHKr>|H&)NB-|;?&os{1>XznyL*Y(ou&YT@EQ;rmSJcOh6k1CNKrUh zJvn;8s+Vob8ISes+U_!>+doeC@sne)G9T0;@ruvHjSL@osW7({%B83CBUSga@Y!=G zGuUZoc=Y~}ZCDR#ll!{zrjSvu=xbNdiI3|ym&EJ6Mc+1_bPtDiH(_n3#-dxSl-WHCrG4djyzlNyOW3U zT7Bf}!oW(#ZQ){XA87bi|8$&~2xUcy$;qGVmAhf)vd z1#(wZ1(B-vG9`^TbIB+k_Tjg}vDZmDnk*ttZ%^RC!Rmv~xf2%KONQJZ54or<>&{M5}a9H_sp4 zOOtq9HzYcNTg7g=1EWBvMgO}yIYB_^!7+4*<*b2Vqns{-#I$e8nHD7#sk6gxAL$=a zl0_X4CRn*%G2{?-)7i7-;MS*$I_k}I1*X6(L8c%#WeVx7c+W;sckw=@TMGBl;yhCJ{fkMIW_l^6UHJH{x*kg)w^caiz96S|@U5?pke?4$5|` zA@-UG0Jq7JY;a>IENO~?U(=XW_u;$<&i-}{aZs0rIapUpeygE32L0!I6+wGfdCjUOStgnm~KqBA7v0cGSNxq7bRhPlScSl%N#hu$CT@P}# zxqURZ8=_p3Vyol`M;Cpv9LIAJngz-qlpb|)8T)D?9#oyiJpZbbvO#}r?56IMbJl5s zAJ-}y?U&39Lx^&GcU$rbryWkL{kyD0wzp zKJiwUCK_4^@QbnjRr2cEyT9ls z_vwlSf4p|Umdkf~*U5vIroS@&KK9Oa`_x@YtuW#H^fC0!-?4d?_U^y%I0=yA;W(v4 z^8I?M`y!E#E=>vI`;#Rx#`03J>fdtbwcI};%>z!i>?-svxKwMkE^vG1e$wR9Qiw$J zo{<*hP1Q;`jxf00DEs&PwF^vBLjIKFp@k_=U9Wre--5!|Ohja;lxKG*@8i{;FwO{d zv7`w|^Tk^u*>!7CbaRYvqV?iWs0Sw56XF8n!m8pl1*A11ge|MSas9-*Ts@P^DbP#Z zPkAorUR~@T`#tJpz)34dMdPnMdjAM*+ht}|Kuu7nff3gu7G-7Sm+a{oe^>Hw(&i>& z-`Wz`tdfT1ogE2c&%Nb7d4-Qoq(2&cZB+cA{-mAcOG$x63n&g^72EDw- z?YX%bzTJ3u=GRuTn-n(iJZE2perMUydtYy4;oLrGEQn;}@?#b@zL-*+ub=wMfAYoc z-R&oJPShZcOP+NUJ1*|OW1R7!aq8;oIsPV;^I_MFVs)iOrT?j9OS{H%-q4>mHf5qy zG$x~JC~*TNpz2!DKP3J}C^bEcsE?6y&YE}y176E$bwdsXD~(>C)bCZ0xO@u!lr#*?vU{YCUqDz0vZAFsX)*6rFBV|yf#lBA5% zF&y%o7JQMMs=GJ$NF(|^FS;{g#BQ)Ub)sh;mWYN7W zU7KR-GYbx0H*yRA<`U>t%zqfgeE5sLO3p!7eBi_$E6$YEbrPMEcq9M83VmRkEhMF5 z8(YQ{nW}xc!rwK{OW&5$!qE8nnqE`j_afE|xlT)iW2L*K?pl##R|eylTBlR?4;VRX zch_ZsMn&$gpK`P%{XKzyQ+3Sx$(oVe1lwF z-g3F>&Ph?vH38S_70DQnR@~g|`fixDbzFghe1=8W=YR;4W71h|y_`l7Iend0x-^KQ zw=Zv)Ts(fK^K-y5YrL(lnr#%C{!RIpO!*Vi#=+8;-duOp>yo~hT*Mmk)^yd8KICn$ z;J-~@VIK3VMuc@){S)5)l#N5kaNtszzCq6gS67oy>8#(e+TC4FFBy_~1dA?x^iaQM z^zlYDkD#KCs-C`Ng+RH8iGwtks8EQ!d!(S?yGtb(<}W;8VB=bR$$sxdl2%L{{R=@? zb%~4u#a<76omQhOA{-HUd;4{k6GYbikYb&?a$fv zSl%gB?#;_rXRphPDnjm&-K5qF#3Tg?y057vy>q1_=}_+MpjsG z`X!#rZyqF?*q_batFsoar=qG}@l@9>*;A$JjW#~rcTMmh-g9kQx*{Q8Ong{%eS|>e zZRe4tZIUv4wzb0Wut&4-qr!MS*7()Ww5x^Y`_m!6ZS(B4)?6%O%nuwbWB$>2Br{la z!;vC07+j49i$t8X-tGBHwiU&7=snNcZGx4ZOMro%q1< ze7Wy^g}!y|yI0R^3qllTM>1yC*B!e#nLmO?l_HQ z;kYl=&ZP4pQp~O=_B~I4yh~P&KEqk#$Sq;xlph1TH=VsWzwO>4+?1vnoalR3N{~{Z3|+Akmh_hDHBI}-r!23c!Y7sdfhDa{coV4cYXzd$ zjK&}JFiu{(kR;OL-N}+=bV9E~}`(^VIVFQF$~EFo?7- z=)K4EecE(poy|80-t>DN+-$~d+78G&eL&k|}BzkN~+m-iQGU^n4n z1=7jR0-eV;)39ad`=1|s<6j`on~O?dY`<);3349j-$>XSBC)+$?I*;pV##}^hMgsNtx7(#e8CUiJq8q*zwL~XoExOVWDdbW- z8_&%-SI9=o*<{{v5i9APzqq6F@{IYk)TTh&`%f3KQr`LEWb`=;6W^nHGfWNentaz_N1R`#Zel+S# zUan>&@+3W;<=@q2WK29NMG39PvpLrWuWjW?DD!jE&VbS&&ea;-QA0xncP_J=;Voz=;7f(2IOV8qZ zethxMFEsPMesHmW^KIyoNVRDpU8th5%B?#e>7>+TrvLmcOV2i}6_NhBK4i^q%W#L` zV*Ag)JMW`fW%|Z9Bv8K9M&#?n_l%JM&BF^ru%+5Ui}+mk5T(M2`VPqy_gOooN`J zCUa#f-Jbjq|Kuy@<;tOjO&-_Cn8V_FFGlV;sU0qp*Y2RjOyOF79d4Mz>*RC->JK?% zu28tw$RE$=Xeji6{*qyNnnWng&ko^8kuTgSk?tn_85KQe7;a?$93>nxzLP)Rp1Kel*YA zlS(q6MneKSUNFs`s^qCg!Vu(N$cp*dHPdj)!DB2@DYQnW?b*+r>}y2&Cs{3q}n z&Cim}>Adp~B(6TI&_3Q6KQJxIz>dGLIAHZ>eYeygv|9U_#|`~j#h&)mdNC`nuO1%h z2NFeJGwD-X`3e>)1-p9{HY(3wI+Kb2Q5G}#ed5Vn$*veF+r+LX?MJ~M&R5FO-WGVR zwB$jEmGO;-?I>f7$N}|AJ2sr%SPMFJ?Ew>IK?s$dg_l!%YdOCn<{1y6hVdH7UcV2N zcYq%EQ65O%n^jMsBdWu1hs*tXK3$$feEwAOkN6XUDE@b+4GE-`6O0HKNT)NyooB?2d+J>W(@mF-+Fx~tek(;^&86mF&zYa-%D-MRvURF}`&s?{ zgxB?AH65=8U2MflsMpd?W|us)hQd1rn4Ku^r5PWTTfSf~jBt%F-htjo%hiosev+OI zO)e^|U&q_ROx;gBo_)s0k-Ja#Ww{-^ldpWZR2+WfLBdCtz*q5u$6iZa{OR&VsxIcr zAmOJhtRrMh?3!w03?CNO%Zl=(*UJ{X`L3VghWimQhUqa}}+&w7Q{sOO54$ zyQ5#S`A=xOT+{p^*BRF(Ey8a5li8C@X4!lD$f*pdG(2+c%TlL>MpeO2oHiu6Qozt7 z6e?FYq>>u;`&hQwuT_-Jl8j@e)&6!sy=gyjcF`+<+LyLCxVs?X7!^qUnUY%F%XIO1 z=={nvzjxHE;nQ31>bLhvf5q^uiWl@EUEeOzQ!eu~>_H<;TWgu86gqSM7RJen7658R z;3nSET^(^g!>wdWk1SI>TAwWLYGduC!D)Qz}p< zeJ;%ev+VP;x+=ii7j}Vun!nPEFYal8zclQ9PMIU&eelP}=d^D-&Hj<^v_A0nJ$CJS z5%tY=)m7ps6l<3zZc1_eM3~cXza)?Ia{PKy)qO=m)ezpEp3-X2cw9;rzS`!^dB#|= z;^NDvzsHF!n> z(Wm0@=T|!1oQ`rf{(YT3MIF+8^3kMC@sgSCN0YTsoKi(6Qs=95pYBhpg>#Gu>vW(^ zK}Fnm4FgJQ5uSY$vhchw%p0lTQC>Gtm8VmVfKNnLDVs-CPd0)$2l1tKC)OZQ%je53 z*ENpPk7b|qOs2*!R&p(P)Iv65#wvK{@*4xaId86hx{*b!Ft4n=)?~?tf7E^cg1?H$ z#bmnU!{pesy@glVZj<;fcB$*lPxKF!6T^*!QM`xllRI0KF58_ad;7^UBlx87HK}h) zE_aSy%ZuT~eL(58T3VEdeiV)@*l>xbn7IpHai2|*u=3d4R(UsqXWvJ|CyuR1!u)}R z6uWCAmG&{Qwczwg?V$SVYCTj<5Bq23p)67n^w^^cj2?A{#tVqi|uYcn5!9#c51MiyO6Tx-NOpDA|7LF!u# zc`5P8jy%EFVqS?BJSOrW%J7(soiC%T|Gi-C$oZmbSvOW8T>}6#ak>K65wu<6);hZ0 z>;jPj+(ycDQ3BdRIsmW&UHAuNlhDW;1@ohU6=&^kF)6<+d#ZCjrs6pAd!hVGL_PD- zPU0J`PdSngD_H{z(TMc(Ur6uImp;eT$fAWWGR2(q=k>WlS}WplvO6lUxHz)hkRNOQ z^6gW%QzxIR7>P!;wp_l%C3esw`QvK!5rcQ`av;?~$8gCcQdefLUBhPy$A60Nl?`tx zed(rT#7dl}?0~eb`CEBPP_M?NugIf-KQH#4h!Fow)T=PTTpFSQ*?{7FLv+dA@zPOu zj+Lupp^5g!s{CxJY3wc~ir5F6xs<25R@UlC>yKS zxo^_uXDW8uB<@^3byD;vV=8a&XQRg`&)55L;73~8E`Co>$D`qVEC`Q$Gku2xS`mMW zbAoQuU`rYLD#LRlawAwhF3XAWihEBch^vzN?UO(H?7a8*{JQWu?YoQkk4c~D+SBeP zrhHW_{K+OL#VB6$b)Bs99rfbc5stQ+O@n;1F(#xV=|EfTJ?Z^izvT6bVYB;TmK9+` zrY}7i-}%qQ@fgSb={Y#K-TO(0-eM<2(doj*sdZo7cj&h@LpjTYD}Ud1wKL;fN=dfp z+*1RB4sGT@UVbwA;tl&zwq$o-@k)K{HE0f#8uI3D=T+8%qMSJNkr#DylZC>&^AWf= z%FUYZ=tSioYSee%3>fB}t({T4MQ`TPKN*jV-=Bu^O=hBcn2s%cc?yxIZ{(2^?)<5Z zKz|u}Y_B)7{E$%?fgg~(ATO*#dn&FTv=nk7G^+n7e2}}7nBa~*$1RR#bekl|_=<|C`->i(=P@`Jq;W58x{Ee<;+yU=es;ihF>+=-A~!NvW1gb z%u-d)m^QT(wk2VYh$M^Zh3vY;%A8akeg4*fJ(|}rEM79~GxGUKxgu3}$?8k;@dC+$ zCzDf#Pd(FoHYV~^KluxLPQDR$yJlQBVY>O zBjU|XFC1auVSwKqF*MM%{6Daqjuw8i5&V4*zR>#`T=EBi`NQE4kt)S}8~_1esH$!Qd1_vB0 zxJ(Dvhzx>M#(xfYod<|jzn1sgOU>TnQW1~02d^Q(W%OYevE9pfJ3*x1>IKk}dEaAP z^26Y*zNwveNK&E}Al)_RqVO5+oh{e-4Z>vBc?pZ?F}sEZhse;)acH2^RM9b7wwyYl z#11@>Nbsv$_tl@ih3Ene+-Rl5c5b}s zN11&(9UG9fX9cUa(%|g3^R{#Z=;Rzes9}BT_ohfWsf5xDn0k7!x zhQ^l|Qw3I5W{1+rEVAZ@1ByyUPS`-5DXmtE2aw(XWbim<`=kn@4m5uCTFu_x?8)rh z7;i24@wNbExGzbQvp&EYGFW$nj)rhD8W0-Zuj_Jf$Ifk0#r4W!X%(89q8av-RP6Nwx~ z^YVU16JfQcUFQ7qkJfJQ2{HPDksEs7=85p!!QuQ@ump{MHh_B*qhWEIPQ3`dRjjSL zDEZ4c2TKzrCaD)4ev}bGmO7mM+yV-wOYkF^j=R@OCWU6m(8B_kfl%|>6TRVdbkzSk zj8O-xmVP-%Mu4H9dT1ej?8>Y0<+W!CwQbIOK4bt|5b!ipLlnUYCRo^b67?~VupTaG zb=!g@tVbZ47oh&ma&#gQzu-N7+4zL&84L98xc5@=;&d32pbYx&J96wljnhaRQ+0E( zVA|1pbwM**&Nq*ho(@5%L?b|D2j8!jtpPvs_HwTff~+hp*m@B zsBW9!5JUMH!FmJ%kCB*2t+y$|V;~1W1|-kbHLb)*76ydxMEHv4z(mt%1H(v%R#a2{HA=m`fV1{ z5I1EAJ3VJM9)4~RN#G&?!ca=zt>)BHDJVpv?e65G$ZR%&j{POa7_eiJETpuOhMhxX z0shQ=GC51-8gHGzp+)rS+#tZDRn-c6?=o6*2jIEXmOy}*CwG9{m zndXopsGyt}LSf;@pm61MqChvMdW`v@%fIF4aNqZN9eF?Cyj873_J z!T7dT8Xw@g##dYbf;HOm*GRc)F{rA_V2w~J8m`0m8+APUn&tTN0{Rcj6?oDhif}cHMOvQu5^cAqgOD zZ!Z!ML^@BCqy>M3#U;D_+i(l-A2r&YT*u=HEE5l@5EH5S_xAH~V3s@f!)r+MG(8*W zhmWxA_GTG;~j!`lX zdgsz{6m5?=?8p?cGe+z?fM<@>kWO^)+OyWIxLCc3KmF?k@Q0<+YWlU*g@i|*_s54e ztEC7wNsnnAihfC;3C%P}O%_<+FfbCrQ`d+WB#{IbJA1`&B-xhjKsf`siaxkavhpgj zw&ge6E9!*lEID@+)Y8%hBf<#pt*&EWZK5+RfzZ4rNe~mD0cIAAgdbKQV2bM!|!R4!UfoLh02IKimSM*argO0X(Q0y9{&3%RWr$XYdnQ`-r#^5;@2ngeQbp@wMd z<)X&q)?a1bKD{AjL+MqT&34liAFLu!Kp=x@VRq=bFp%B>hois}&4fvE>?qr{GQ8>j zPAr6L<+X9ht-^(tna(Db2+zt-{?`${rNNYCYg42E0RJ40Ic%*2);)m38@nYiX4GW4 zxI;A9d-`KGh>E{~NV+K#y!5(s=yJf_3n#lC%IqBH-+hk&!89<15<-|Y6D2?~9l^6> z8PXl_Nr_0?cv9f6T;}KUQnXXyC2T4kS1iq(^R`3pV@9L}ON88nnN!Hsrv{Yx`LuQqIJ^ z)_-aoN;Tral?-rl14&kplwtkZW-uH1XPii*X&gDD)j`#;8K#U-A?M$;EnEQ{cvFN| z;ixI1T2!|z*-mI7{ z`A9Rnm=0bJY%6trmw;8`a0#e#b;>zOG>7elN2;3jkH$$~=bodu^`KU9D;7GAgt zSl*v#LJ6djbMAh+i6IMx`xG$~6r|u?)aZ)9VFVZfFCf@O!IZTE7)fOAAP-^e!Y?iL z*)$n2|M85Da0A;GQ=~1dgNsIHLH|Dg;khdC%?_2c4{Glvesa>wmZD~@_qE~0BVT3n zSCt1pR5#kYEO(TAe|bTzyur70L1CGr@+(&d?sZPnrr|4Q_#`**D*;hdn9I>62IYs3s&*^?(khQ#M`nino~EHp0Tr{0=Q+3JA<$+?vjYF_$R-;!Xp z`+Lt3OpgK}U&8jw3PhJ4L52bFXVr|aRN!}&GLBiAe>!tqReQ($_7;SR0afskvq?Q3 zv>NpMBa&Rxj@Y@-U>bu0s~$gKRq^XtNGEVqqeVFQ;83!|nBtfu2r-Xj@W8N5*Yz}AfJfE~7W(XNquR|5hK4vvXd52O} zdYEtQm38Xjy&)CrPFh^mVRz%2ACV*>l1i6Modj4`lmfB7nPp~Amr7eWE4KO;FnX&E zK*@TJrfRuzG^_hT^aM5ruMy;uQ_SS7qtqIrRN-ElR_~H!7^a z-SMHfrnx6QFgODVfbnsJb!vO74~GpE|8=nf(q!9VX&$An7%WhllxbG)!;~Ldp2uni z0zgiM*OREysXW{~IiZIyXMQ3V^7l7JPax~{K1<#3HP?XF31!$MEZ-PY7hj~ZBxFK4 zSW{BaIEB;u)DNf5T$do%7CVWp&s&gK%(bas5PU|%#;5FMzel?UY);HJP1N#htRY93 zt0u$)Q;v45{C3pyrIZ=aNGCk>m?5o<_U`k)UO4AIW5v_EUAeHZl{=8I+tf@=`%yW5 z-sZMP*FFlilS#p!l>ZU#F#T(OGPlfAAVk7DZ~vjt&f=Yh)7Jk0X^%z|Q{n~fzD%|Ji}qg zdClQPbeiYfWv>fx$bqG$u3(fiaBdRl`-ds8xD9HJtl~=V>5jrkPB7|+Fow$8zf=~ z=LmrN^xN(+34-cwDgb_s&n?D=w8nTzW4iayTL( zNs42X21(_xrMJ`zD{##ZgpKuj{aM=?*=ADMTsgx<9c&|RPQ0@YJP?yy3ddLc@JSel zxKfWEgZTmbXR?>GjSvqy5M8_LUc0AX7Mm=u1EWhN&U9_A09=3n2xpa1o7;lW## zm&X;@4iLZC;1wR*m(yI?2~SPq$Ni(Y;7$oT&4D35F?vRX<||smD+U&F65!5h;m+r6 zYXTT@H?4r>@$tX0Mw~X;^Rrf*4GFw><}JKkHAO>W0BMIL#6-diFxy0MN25UE%gT1B z)}d}@MNotJfOidLFH1+bJvp}Cf+cao_ULV;ox9Fb$Yy_v`sA}ApXWW>!T@kGA#q{T z`U(LIB!7f2Aj@3GkaV7zVk46r*a?-!XR#k{`S#EJNk{~ ze}eKx1dTQ~6K~wzgDU1D1e0er>sZPC=cj+@yfNLkq{p@hf6ownXG)|%+_Qx@SLy^)BEo;-Z{ZgYxJOE;kI~k0O99D=U~HF_W^5hKP#*2kHFU zRnhQ4tkz6&PfRpdzMF!|=yo>cNRFvfkqwe*8g2YIO?Z?xT?#XhfGsD^)@?~78CJxC zbCAc`2Ix}t=WXKPnFhpv)%+Db?kK@*YPy%VQM@kWuk_jkvN3P7eMAVjudAf;iO z#YyV-a@hJcUHctTCPWM7)mcvo^mJZWe-pQH5a%A$gNjs|Ws%kNg=ZUkq7e9kT$XbW zL(P@Ii-asFC}BAq23lU^8~7IPZ=aJylAZtrj&Qg%e+l3LG(%P82F?g=*#xd*61J1X z3{Dh%dA7R$N4KVZpcgy>B!VWWHLmv?rwuk+i@Y_8H*=+9)qEKAw{_|2KzPqTn_>(> za|XY$VI@D}Z;S$$#CAU#|Sg-J<(z+BErN@)T9$%Y2@6sfga8hjA=BCLX99y67? zCG|@K1ACknuR3bQjGL;=C2P6THX(dupc$6UNy*a~~~F zF}D~k98cZ03uHclZG)~BYaqZz6mWaivi{RNjJuhZgv6bZA%ws(x=S5j_RQJr8@k4E zWlm6=g<&7c-&;YD65!>;FI}ocshyxK{gQcKF;7-fXaHrp=@Jj#&D-g1Dh+xLI z=H?08S&kU|Xt@_;{Azhx|EoGTNMl0gUd%+UE=v`H7L=#*9~CYcneE0);|7JTm9BsL z=Dt7K&Fy?h2ooYZ$vG&o2u{Wckl5h`8ghcp1QwhM2;us{5I}CUnttjW+4`r~U-{LW z`uqL~=k)i&{VMyi8(y|($C_}F!K73adayVT>K)iyh0_kq&RgmIHK z8p^RYrJt6FO1yImXwngEKS>aT>#~qlGdccLu>^~{6Z-!3B#8Lf>7P0~nVj&-1=OA; zW_Q8%vRI*E*!hleq=sZHhLY=x>TQ~-hy+!dtl-vPCpMh%#>@fU?n@C=+#GR<3{8YD zulsQvcsGqd6D^9-e&^uv)hUQ&%%bHS(T=I4$)d&Eks;Do!U+&!1{`Dwok}%{|vXiB( zMj}WOXrh7J69-W1gi7KAvj;MOsxttCims6m6uRLTE=f|Mh6X_o^R;?|+{a7N(0dy7 zSsU|h1K4bH4w`tU%@k()ViJQhKV*j@@J4_4<0mYFOsT&RQECw)8tvgzsIAkp)AM*2 zwIp{9XFP0Z>h+H5_^WzvYqBvps>r9~IaNTAB=IGq-qOcbU-+zJM&=%w=PO$8V^-P} z$W7})l1-9VG^~cZN;Bo3y*Rq7+*IXLBLC#~<(aO0|6e1B^dWP%@lU4)-{sSi&PW|J ze|mQmMq9N&nZ~B~H)S`j48Hj_k@~d2)Xi!bUwQn(*;VHl28uU&ezk-yxV}Zk$+Q_i z_0n&msDSnF&Of%kB~!Wkd$u(?(Ff|`26iC$0?aFOuKYpRH#R!eWP>4F!m!Kna((c< zts60MbIgY$ifqKT1DQ|1d$$hR3f`EH)t33Kkm_T$S&JgTc)?9pu<-a69)n-uUII!q zNzPGeyo6RBLMGBa6}4(U9@xLV5b$aw8{C&v!4J_c20`lQupTswgVJbe5V)}uZnTOC zzUi=UeoXimWp?Vuegcclia^8Q@|~tkB+Q9a+gWh^pNoynOS^lC?QBYRbm^X{LxF=D zop+x&m&$bXxe#xTA0_-@aMp<=jF;P82O=sUBfGrI9qmoyv@sh+WRdK!?tS%~GlsLf{YxOahTk zAD+_?@M4XrUQ*4MalO%aWT z=AJ9p2&vJ)9`DD7%R%-3#1!@oG|P|e{tdu_-f&|*FwNO}f4*|YEft1mq$0K)>xR(u zI937hBd3Y3DgZ$yt#q$S18HQ1O-bi{kE~g;%Sr?b?DjfjG1-5c1y059b~!-wA&I|~ zBHWZ|$SqYg1U&*DVRWNWPzBa=!iDM>(#=W7yL_JWJU|7gv?G8$9UWn_^Dr#%pbgOl zo4hhPSuXbz)>I{fja|KJRFMCy^<1MTTtZI|?iq3tQVH+^JTN3Z!Y(&XN(sW356`(s zo}YDg+fjxL1Mb?boewF5vI_928!smnH+qN0|9)fz&cn(ad;kG&EgU7o4%P3m*;qL5 zEFN_T-NfRjPtP6%aa0a&r$m@R6t?E zZw*ecfTO6)5(|UNsnT;U1Kmi06>6sd*o)GDVZkU(cijSKHfxulw5a>h(JC`V1F8$nQ0U4}w8=(Dsh78qr(k zp3Rf%-E{;H@@{#BD@b!`{c@{r*29EjJTT#j)qJVB%bONgb|DGptdg}!pK1t8P*|uf zJHyiFgh=adg0vp8%~Hls|57pJdQ{h2(XI_?v5COSVE#!hrwlkdFvzUQJk9Cadctt; zwWIfnO1-E?-|tg|Kyzk}l_k~VBV1Qu(~75e1BIG9g}`4C@WITSLqx{LMtVDW?x3hHV#FZ)K@l59w*# zZ>?Nt1SIV9 zLH(Q|ngiPj*1gF_tz$z0)20I%`JS&TnW^V^CgNGR>8{anmnVC|DyXsQ0f}y*TlWeD zUpma@BkrN%kj@Y+GiTLMuXy~R;T{SNJ5E&*55^IMvm@qo1fPLh+d=l*@q}j#$;)JX z`9PP)*YZ-tKiVXFjXhq193Sf5+1ven+PNoaYb%gs?V2{oWM&!;4cpQpaqoQD$X11* z>gW}|wYcebHX6w^z9mJd#-|&413r=+W#wGU?F21>3l~=b_%9bAq=G+O0z9U6AOQCX z7Rja3K8~IeQ$>|e?6^5HD8uYF+K;mDi_xD?fqH7d(qhgz5^C4lGGX3vVOCOi6mPER#}DNAS0fUp!yk!n-c)B5BUHF z(&;%5c6sGsIGS5&cBL2}vYo?6Q2oI0Z;gx2oA{zh*eG7*K{6#4CxFI*DJ-cj9nhf& z`PEmZ+BGoH%=WU^62*CZDap_=YF!)*T*qu^j~tZ`u0uu5J@g!zwH(xTr#% z*6lF76Ac4g(tfMk0=d%QQFEo*zpIfAHjv^%{6wts683G>vVJd2jR#d;y8qvtbt?8I?3DNH zxF~EJh}iem9e;zeA?@n@2C!O@LuQGF0SjKIO=4-)p8p_`52g{RstT1PgWsl9(h%;^ z5fHE-ayl{~$iFo%0k4D-JRvWchM)osN)X&brAauL@t8v^!yX1OG=Qx1>x334_3A7> zzvzItxLo*$g_cpJ`}ZNlaCM_n1wJwW;fkCY?0cp6LWQ7|1}5Adnve+C0C+#8r3W$% z_wp}c2p=^LGeq9EF%$NwQ?SJFtAJ+={{iUS3NVp0832ybB|%XCm;8sl7$D%CxNZr) z0}@#aFx}t70$4OwLnLY7Xm2zOO7Z#~fzbrQCb{_V%fC_vho9hPOwU@eq+*!IM{?hO zsamzJzT0uX^I?KP^GfRpmE-=lCtfmD8TdK6K;@U`Zi{LDC!xN<=Cw!Fo^0GNaKYZv z4dRdhE}dU=T^;hpME9DqT50XEoO36Z{Y!V6;W)<7l$PdyLgJ0(7l6aPl99Jcn;&IG zdxZE+W?$Wg1ZYLuCVk07yHC(v_FP89EodVCEJ+pzlK_{$liO%<8!%(}MUN*OXuKy( zZf|J4Qg0t}-&RAjOY^9JfRa-xU}$_>njO&6BXOI-4Gx8%o+G?NY;fx1m)HWIli@=$ zD^ZIuigHzy!%9^%?VM|_0pXd%IP%{vF;pDsZ z3nFcAeC3t{8l2Yf z$Ch#vk;KpPFAD`333od%3^$>hBe1*Z!$B;?qzc2 z$Yi`|lta&Jp`9F0TOfeMU4nz$)^sqN31k*e1QMq-e7WWBa-^_pJ}Xeax|!>z;AgCd zuGLldS0D7+d|QcrF|(E{-ruuuPOqmorhA_qC!+~S{hySnM^Xbn?I&oHbo4gyiR$({; zc<`hk=i8>2YoB-KnDyszs5gc?S`h_Dg~e3B@#h>M5d1aIO{;=K5UZsHM8iE6LujPD z?{H!wL%5xKu5$d9a(ERYBQhe){E6B<(F)t$#Q zr1~3=sdeJW`PXN1Htm70*PY$(+5!`psOI87wK0=;K+UJqZ!W#- z(epdX*u_Ud<;Jc)9dP4?1vtJ1_3L}tVL&d!))PrO^ojGd!B~(B-hSN5^Uv(cX3 z+CI@n;c}DnNDay!PP^b{|AkNiKae<=8i}YhI1^8!9$UW=S+K%EOuS}@cNnShWXY*r zJ#TeD^=TS?0gnsd|80XO*bLoDZ6PvJGq)!$kJLCa*U=5bIdbR8QwiC2iB6>(mm19v zS|M|H`N5P%^dB0y$yMW%tNfCI?=X^ZplON><+2m}9PaTDwx_h$?zUPT{8DzHe|fl1 zuH>^%^B#UDy%B7Fw?}9hDQ)Pxwn3MVVKCwf4C~_zlvcuv*62You|`nKU2aS&il|~V0O<5Fuuy25 zB6!84pz7`sNDfv!;`)wEn?*C!UHE4rbbp1^jv`I7$)1}y{9)kZrua5KeJVF5Na|K&b*9u8=D0$d2ZdrE{3Z3~6N znZs#sbh1OR1w+^tEd;#^FOVbz!9tTwfQkMvQ-^sGmV4{3ym46Y0M77WiV4vpz(?KP zk(crOtSmE&?9Z!@v#!-i9=b`Hw7OrDJu0aiMq_xNC) zyv&aQ`f%QkZOrJerp7i240tC*+3bY!t`?4OUS`{QH$}7<+1$Skdf<#So9et^ zC}g-NqG5iG=qL%Nka<>t#-$$*yT!@JJ049$!uG^VSQJXzU-|?!rAFg9K?E-nhC{q| zT>?JS;Cpu`JOf2!C4d{OT+0|{53;5-%!w#jLq@V^u=(O&8PID3%NsbbCs@EVwE2xC z1VL~n7KP<7Q?!Dk9tvr2!jM+^xldwk+e)e^#LA*AD7T0@VKuUgYQbEB@wFyu7M!xP zgeQE3bsIckR)B#GO@13RDGg2nihx01s)hv{ICpiMMHm*r(n*O!17hV<^wq)~8WL>x z9uhqYZh8d+ZvbXeg#m)JZ&?UD4y(OHAGT1&z#MAf3O|fZaI1xNwU-QAFK#RWb2MGz z)sjv{V2XfmUmo@@#DzGKD5Po6YnFA$&98?YCtJbMiYkL_V+0wH#NouF64;P^ON)bZ z9adX6_)uRCqX@XC{q9H744LQOGLrMO(Y$nULgH**Qz$b*1(97`i`>F${<}g~x#9{_ z#G-^HfZ;T=zvekAsaR4>0`NVKvjnn6tf0!|4PBQae8Q;G!>spdi^P|V{J&DJ{Sm6Q z`#*cem}!Uw`P}D zDut4e%M20aHr))y%>J(JyzgJ|^`qI(v!7?J^{i*D&-$#r35OFS4%lIBu7=Le1bU>d z+vvb7*Z}& ziMIt(ibnGh&l2hkH%K*4J$&Cq>1k<|1K;*r5Jy%EF<)@%n>opk?KOJ#$@!Z-<^D)w z`-ij#P*G7<+BZ?@6OEEtk2&0#oQga>if=Sk$KqkcFsJiU06pyX47k1_Ndb}9Liovm zSaIo06#%6&*^~`j5Xa0;er(MYb3tDUScDPax(HuF`=d86b&FC@L( z3heK;xcI%)ZOmRxDbN;4yE00D)SiR{_I(IyYWQCfyt#5-QsnJBE6fY zkI-sL@Dp1=Z+_Oa{<*^Dq;0b&HWb^cm>jz}x#&}K!HLV?9iV{t6ZPEFkLU>NTusAP zK!EE1I`R44(2JXavVT4JaoSNL+2Nwtc4D?g#4H*5{f~>w`!cA93ukY&9Q&2U5`e!?&<7?|wd|z>-ioz2Cx)CE1mBtL z7OmP?e&UoJmj+J|G@es_IT@&>k#~7s+k2MS%ElEt{wZ0daRx$pTVTJem0qV5Hyx|_ zH@=0MO0Jd7uFQPhd-OY$x!`2Ncp`#4JzSQ#kx@`GP!E^+F+Ed6yx46jWmge2G5573 z6LLgJs5{HL7nz?^gVYlY;nZ|3ChsN+UMA7CLO+(Twt{nqd@bQXvktPwz87hb!b@SD+j=uJ?9eJ@@4;CjJ<_lY{sc9~dm2tCyz$-)>;o8p)1V1yv8gruNBg8`m6ThowXSiZL+wmMrq;3NV=1r3TTvp&W zF`hbi#_a`^^aQ!Ayk1SbhCEirJ^nM?ij7OH7SRBrINrh^*IoBB#28jxlV93Nk-OxS+lwcA{x8RYt=0~{{?7L7?>dlTk2c+>n|rm62Youg=K1WCzsysNMTcNvDZ|29Bh8dc zm-5nDhfk;-ld)$Lqh!%qy@HJjqTStMp2%?`|3$s^r=v>=qLd&9Am z>`I{rMMn#*RwI%PSnm~)V(I&h*%|u5m{=6E&yz_yIk-T42edSgASi)ea+Huum53$a z{-Q20xRtWv8No*VyY?{c9b`WchczrvxtiLiy)=qzbKAvi|C?bS5USWi3ZNbkg;iAKQ0f)BOQ$v zYl-*4;_?NXI9}lUzRaWR7z_s<$oeJkg&8jM<$jQKsfHR_q0^vGR}8ZGXQSlRo#4fY zjpC}Gz>gACE$Lk>Ty+*+=?Ln9fgK((J073@wIXS%XmS&Nj(cIv#OL4|)PJ=&*x~5} zvkQ{&8^D9>gQK_}aq1-AA1xu*hUED=O`yz64^y4d`J!SD;?V8kpNUX8HNs80&{;Oc zY0q@U8km4P9U086L82vJJ5l)#N;gm(+D|`D_hNwtXZbc+W?+xUDs9zs6B~_ zId>W7AYN(yWmEH+ZkvKzGtfMxg60mPQCv5=2l=w)Skw*~XT35Inrq`;jJ3HqG>Cl8 zPIkCYo7A1typW5heih7|tkQ0 zk-3^QvHg77QZ!8AqrjdLuW(`$k8&FXreg&AHH|$n zcob>H;K8+=UA-7xr&+w-%Z81m!wiLcjcJuuzLUfAwN9dyKs~!4%wA<{ zZL&cNXUX}=-tByrBi`kni5~rFz?}ixslN?s9R?Ekdj(B35m>9S8?m#%b*WHJk%_!^ zPXMl)Kq8*S+ZuELac9DaL<*m^pfD11=K!T=%eYtI1mqevBPay#0ZU-kpw!v6d0mA) za}Y!`a0l{&OHzbw$RAyg_dvjEyFac~tarr6N}-BM;kr$P#y zLTj${tl>fk>-`p;odfe3_Dlos9=1Tgn6`+4@{Zj$hR}rd74obdkuq0}$)Ds_;Ke0+ zrJ4Mj^vx>xT=0K!c($U_%=0PPnJzO0P6-vkxh>1`)<&gTd5bXvV96w!@<%4?y&~k= zsB=8(F$+AY(=!@jgj9eJXoO!&W6j_-^>o<*m;_$l+m~zrPu%zHv7H<3r4fGayAJX- zl^Wk5%^UKsA*WA zGbL@36r}Cf;+$>cvnBy;t9&sm0(hE*?tmS8^rQ*bCEd1*f^?;b!s~PZ+jx7A9%iV7 z7Q$;ZwNa5GQEM%v&Ic3WRSJ-Roa zc)=^B?uiUIcL_%+nDZsUw6Ly@d-7T2p@D`cPs3HTC^{3hPX}Cdaur2NFHz>*q~mgR zbL;E0Jo@HlxOQ-A3oa?hVkUrO^M9>}7>H2S#GZIqFgB}DT4mM(f)b=u4@O%9`0g>&UZU{ug|i;qAj)V zQuCTafsxhf@r13*K)?>RAk@wis<+7PV0Memq}eB-XB=%PqdF?s<^Z3W8(1B(JGFVs zxzjNfKEwZx%=(KHv|%!ThlT`#EwfMg-F*gQ>wZ3dq{^cmNbB^ZcZ5~PSFXOVUBKJ% z2j%lj>>*5*Tlb|h_MQ_(G|$?^PY-`smN~lUVaWs5SoT^pnHsEECpo>9(q3`=q5lYt z?sbPG%}vWSWt>^|B`2Lz9^Tf`$Nu7?HNSyY^gL86Hmo=vClk{7hX;z2Y|4w>%XK|A z0UO}A#JN=O!Zc7ptOvSg%=!D<*Qa?~C+|E{7iC=^efF4tH=_MUfL`P+R(udR96Lj~ zT`bFY!^9rs+L-3b{0NyvX4w7j&R^yqy!2>;I4E?b<~uT@JABwgX>aVr-pNu&wm7o- z{CMWnTWA033fWXfzg)c@r!)~*y~x<=O!BEJovTYu#n6ROHa%;JWJ&9oNz#{+ptA6^ z7NeA$!Pd$f6U$>7^_I&|E{1dE(erDyNA+p;V}A7W+#8Xn3sKnbyPR_KV+WI73`4mf zd^QSCB5%AX1x)`XbyB<3p|_U4H^Px=x^DEg>{Nt zZur%bPH=@(LhSwYr0yhiAplez#^&xRR_B`04*!7qE#*hKGG|TxfK@!fPuCe3e8G1+ zx>qY@+hYuEgn{pOt@~2^D3dkG(FDG{pjw<#FO+HGi_uOj%miRoBU42eomhIa8!ANZ z%jESmLE(1(PbmrYMtI}!F++G_87yJwgC=(f>D_U`E?n#WJ`EC&{Z|v{Q@Lk@E!-Sa zDYD;T+JHvM{@u~3-Kagg;3Y!`!ghQVx_1))&cT9TVORF7EJ?|Bt|{LnZ8YJmpH4~R zGjQc0@plkNS3~<_u2wQ%P0}Hw^0RVRfRTq|+-lN~|*+s|sF&P(ghKaA^|I z$cna!W;L!W=8HD|344JtMF*lpkDz>kTKw3&yR7e*r=ZL$2dC+Spt5_;oX|C1frgd( zy$Mq`l<`ZtNqYy>erB#6S~Bu7x=}$KJqmDiO#>)jaRE{k*sNR;_0cXx7WpN} z$=b7aA8x}W%K7yefg!k`1rFeD(U@Qcg2ZknQk!TzT-mhOM0_aeIS#8-gUv-jT^0}{ zOGd+A=t4Y8@^3q;@-aZBm5U$-+pvkFg~mY5(!VoH%^%Xk7JW*HN6(-Q!4vfJF6E3PpPZg#iK2iVdGdvh z*rgDNubMbjP;!Cr3B`e-@+uEVM9#HpcqTUQurxFaBuEo)(HN6~1{$CVjVFI@z;k-g znCpRZ&Z84%4A|Ti>i?Bj?>LifUReqez@V8mMNccHN9ug{1~zs9no?91 zZ|=dO@I)$P_z1&c@$p+|ZVk6Euvh@>i|0GSo@u;pC{Ksuu1_TF?9M+LheWA5*oI{o&mbiLK z2-j)Aw1yf>DwH}a$`c873ENK=hIDkH$J;vu4FfK-yV$HuHtaoCf!>{eXRC*vt`DR) zOe`aSymq*jhM3Um35Uuz6u=2)JRk#Xa40t+M1yWgs!0QCaKl3*1N#fD84Wg+jjKO_ z=CD=NZmZ)A@cg)oq5N31; zpHm2ccnsPeshQP_6vdh+JCgxdEbovm-d$+#inU;pWsqy*K9ppBeu!FpG$(%pR;9>~ z3zn#b4Ow6XbXwbU^mufR|v-=OwF=jQVMwIW5tQcH)xzFlvH6d{9`>F2&yxDes zhG0h#+_BsPA4Nd<85*7hJrT1|7S-jpy=56Hum_4KTZ4x+d==&A$bqot)CJwUe<$0S zBfsrwExjXmSon5_)r1;1#DV{2?^dBUS{x4?A9M$%fR-ZsN17{EFo=#!q~sPGdyv-C z8>;!zj_pVsTQGVOYglbYd#MIBQU7epa&kjnohK`YhEwjc;0po&44-Pk$&uTX_mAB6 zzid3N(2YfsIu3>;j(#!%1z&|O;&I5_DdcazX1U^!791jij#t1dreyJ;2`aV$#%vPw zh$LE5uQj-I_!Uij$6vOmSQ&a7zt=B}{&e4`Lq0f$b)s`PihGq_CiettD2sU4$vdX3*py;F*(f#&XIEnM+cd@k0@k%Kwd)v(?TDm{#od^|5p2T zxiIjWi^JXh>mJlXUO3{Fxcf}s9zZ`)Cm`0mk|W=9BgdkQKk)aB_lZSshJ%g)JeSsu z#>P14xFLwKYa5n#YvyDKb|HqJK%>$R#z}~E&B9-cE&{}@9V4`d#3L0p99J9nL=pK~ zl)9GnmL~AJX{Ef8jl|QbUlJfAHW=f3mO#&pJV#5uDSwSk3eCdjELpkv6Ai$0i85Gk zRW*%AwcZ>5!Of=WOTR_xiv7WlWg6$;Ml_`(3#ip}-{RMia7QK_00`Fw&& zba-ftiZ`kZGtqg?7=F73hTnM=o0Zo% zdj-(`H?$f0Q{zoD4Lq*+dr7VPU=ir{P`Z z?$d^;*8Ne7d8}Q}&`ZRq^3N8(EVL%b2*|-DczS)lbBRjy*rSec4hPhXZ!lz@k`?QJT zV*v|U6fqA{`yS|TdGCs~n?%Sj$0rLH8&gIB*{JqLrz(5&?rP>1s$LpRBfO~AEZ{jK z9p&v-%46536krbNn)kZlfsvI21W~y&uugeswhbnpgWF=<4;}Nxao~yyBRQ>?{sZQVBQI1gbaKa>e+hpjQ>!@%vzMz%kq{U#HBwQ}UG`bfA;GHO~r} zUP?f2E2zpE@`dj}An&R}YTN;uAXlBp0N?C8hgt$ac@e06EOP}W5wv1<#m1M&IxPdS zcNe=|1@84QILg_Njc|J52ZM!z#R$qwDX{K)aqqEyD@FqwveXlq zExn>FCD`jjF7;aTsaGVR#O3})40xkY^%3wifEqEIjp4oO zc{EsSEmVppl(^YOh$FM|SuVHqxDL@N2ihy(NYz%Ktjtv=V(_P8be#>m1Iz{0&R3US zU7{FWirQ6p<>kT^nD{;)z=$p1u zR3uY@HWlaV(Up9uo36Jz>qv1tZ|rn>iq4x6ozl zS~C^(CdancinqKGEtFqQPti>4^Q@*$|9{_IDGAjYRh&r=DjNyPXfr*S(K1fH&v9c* ztMU=7+XTVh^=`*=`?4Yzcfi4!`=u-XX7ev4UXv9^1G8BM0EKyTonqIcbs1nRu7!Tu zA3SVsq9eK8XG_dzD0yQoXEj>)WKCUpN84et^1Z_Uy7Bcy|LS_j257P}<@ZA|UT^Y{ z_|QJ$S1nJUnrf4StNs18yi@-Nbi(null); - private readonly lifecycleEvent = signal(null); - private readonly messageCount = signal(0); - private readonly isReady = computed(() => this.lifecycleEvent.value !== null); - - constructor() { - void this.init(); - } - - private async init(): Promise { - try { - effect(() => { - if (this.isReady.value) { - console.log( - `Background ready (lifecycleEvent=${String(this.lifecycleEvent.value)}, installReason=${String(this.installReason.value)})` - ); - } - }); - - await this.setupEventListeners(); - await this.main(); - console.log('Background service worker initialized'); - } catch (error) { - console.error('Failed to initialize background service worker:', error); - } - } +import { extensionApi } from './browser'; +import { ENVELOPE_SOURCE } from './protocol/envelope'; +import { BridgeRouter, type RouterPort, type RouterSender } from './background/router'; - private async setupEventListeners(): Promise { - // Install event - chrome.runtime.onInstalled.addListener(details => { - console.log('Extension installed:', details.reason); - this.installReason.value = details.reason; - this.lifecycleEvent.value = details.reason; - this.handleInstall(details.reason); - }); +const CONTENT_SCRIPT_FILE = 'content.js'; - // Message handling - chrome.runtime.onMessage.addListener((message: ExtensionMessage, sender, sendResponse) => { - this.messageCount.value += 1; - this.handleMessage(message, sender) - .then(response => sendResponse(response)) - .catch(error => { - console.error('Error handling message:', error); - sendResponse({ error: error.message }); - }); - return true; // Indicates we will send a response asynchronously - }); +const api = extensionApi(); - // Startup event - chrome.runtime.onStartup.addListener(() => { - console.log('Extension started'); - this.lifecycleEvent.value = 'startup'; +const router = new BridgeRouter({ + extensionId: api.runtime.id, + createToken: () => crypto.randomUUID(), + sendToTab: async (tabId, payload) => { + await api.tabs.sendMessage(tabId, { + source: ENVELOPE_SOURCE, + type: 'to-page', + payload, }); - } - - private handleInstall(reason: string): void { - if (reason === 'install') { - console.log('Extension installed for the first time'); - } else if (reason === 'update') { - console.log('Extension updated'); + }, + injectContentScript: async tabId => { + const scripting = (api as { scripting?: typeof chrome.scripting }).scripting; + if (scripting?.executeScript) { + await scripting.executeScript({ + target: { tabId }, + files: [CONTENT_SCRIPT_FILE], + injectImmediately: true, + }); + return; } - } - - private async handleMessage( - message: ExtensionMessage, - sender: chrome.runtime.MessageSender - ): Promise { - console.log('Received message:', message, 'from:', sender); - - switch (message.type) { - case 'ping': - return { - type: 'pong', - timestamp: Date.now(), - messageCount: this.messageCount.value, - lifecycleEvent: this.lifecycleEvent.value, - }; - - case 'getVersion': - return { - type: 'version', - version: chrome.runtime.getManifest().version, - installReason: this.installReason.value, - lifecycleEvent: this.lifecycleEvent.value, - }; - - default: - throw new Error(`Unknown message type: ${message.type}`); + // MV2 (Firefox) fallback. + const legacyTabs = api.tabs as unknown as { + executeScript?: ( + tabId: number, + details: { file: string; runAt?: string } + ) => Promise; + }; + if (!legacyTabs.executeScript) { + throw new Error('script injection is not supported in this browser'); } - } + await legacyTabs.executeScript(tabId, { file: CONTENT_SCRIPT_FILE, runAt: 'document_start' }); + }, +}); - private async main(): Promise { - // Main background logic can be implemented here. - } -} +api.runtime.onConnect.addListener(port => { + router.handleConnect(port as unknown as RouterPort); +}); -new Background(); +api.runtime.onMessage.addListener((message: unknown, sender) => { + router.handleContentMessage(message, sender as RouterSender); + // Nothing here answers synchronously; returning `undefined` keeps the + // message channel from being held open. + return undefined; +}); diff --git a/src/background/router.ts b/src/background/router.ts new file mode 100644 index 0000000..a85fdf3 --- /dev/null +++ b/src/background/router.ts @@ -0,0 +1,158 @@ +/** + * Message router for the port transport. + * + * One background worker serves every open DevTools panel, so the routing + * table is keyed by inspected tab and the two directions are kept strictly + * apart: + * + * - **panel → page** is only ever forwarded to the tab that panel attached to, + * and only when the envelope carries the session token issued to that port; + * - **page → panel** is only ever forwarded to the port registered for + * `sender.tab.id`, which the browser fills in — a page cannot forge it. + * + * The logic is written against small structural interfaces instead of the + * `chrome.*` globals so it can be unit-tested without a browser. + * + * @module background/router + */ +import { + ENVELOPE_SOURCE, + PANEL_PORT_NAME, + parseContentEnvelope, + parsePanelEnvelope, + type BackgroundEnvelope, +} from '../protocol/envelope'; + +/** The slice of `chrome.runtime.Port` the router needs. */ +export interface RouterPort { + readonly name: string; + postMessage(message: BackgroundEnvelope): void; + readonly onMessage: { + addListener(listener: (message: unknown) => void): void; + }; + readonly onDisconnect: { + addListener(listener: () => void): void; + }; +} + +/** The slice of `chrome.runtime.MessageSender` the router trusts. */ +export interface RouterSender { + readonly id?: string | undefined; + readonly tab?: { readonly id?: number | undefined } | undefined; +} + +/** Host services the router depends on. */ +export interface RouterHost { + /** Deliver one bridge payload to the content script of `tabId`. */ + sendToTab(tabId: number, payload: unknown): Promise; + /** (Re)inject the content script into `tabId`. */ + injectContentScript(tabId: number): Promise; + /** This extension's own id, used to reject foreign senders. */ + readonly extensionId: string; + /** Random session token generator. */ + createToken(): string; +} + +interface Attachment { + readonly port: RouterPort; + readonly token: string; + readonly tabId: number; +} + +/** Routes bridge traffic between DevTools panels and inspected tabs. */ +export class BridgeRouter { + private readonly host: RouterHost; + private readonly byTab = new Map(); + + constructor(host: RouterHost) { + this.host = host; + } + + /** Number of currently attached panels; exposed for tests and diagnostics. */ + public get attachedTabs(): number { + return this.byTab.size; + } + + /** + * Handle one incoming port connection. + * + * Ports with a different name belong to another feature (or another + * extension's page) and are ignored outright. + */ + public handleConnect(port: RouterPort): void { + if (port.name !== PANEL_PORT_NAME) return; + + let attachment: Attachment | null = null; + + port.onMessage.addListener((message: unknown) => { + const envelope = parsePanelEnvelope(message); + if (!envelope) return; + + if (envelope.type === 'attach') { + if (attachment) return; // A port attaches exactly once. + const token = this.host.createToken(); + attachment = { port, token, tabId: envelope.tabId }; + // Last panel wins: reopening DevTools on a tab replaces the old route. + this.byTab.set(envelope.tabId, attachment); + port.postMessage({ + source: ENVELOPE_SOURCE, + type: 'attached', + token, + tabId: envelope.tabId, + }); + return; + } + + // Everything past `attach` must present the issued token. + if (!attachment || envelope.token !== attachment.token) return; + const { tabId } = attachment; + + if (envelope.type === 'inject') { + void this.host + .injectContentScript(tabId) + .then(() => { + port.postMessage({ source: ENVELOPE_SOURCE, type: 'inject-result', ok: true }); + }) + .catch((error: unknown) => { + port.postMessage({ + source: ENVELOPE_SOURCE, + type: 'inject-result', + ok: false, + reason: error instanceof Error ? error.message : String(error), + }); + }); + return; + } + + void this.host.sendToTab(tabId, envelope.payload).catch(() => { + // The content script may not be injected (yet). The panel keeps + // retrying its handshake, so a dropped frame is not fatal. + }); + }); + + port.onDisconnect.addListener(() => { + if (!attachment) return; + // Only drop the entry if this port still owns it. + if (this.byTab.get(attachment.tabId) === attachment) this.byTab.delete(attachment.tabId); + attachment = null; + }); + } + + /** + * Handle one message from a content script. + * + * @returns `true` when the message was routed to a panel. + */ + public handleContentMessage(message: unknown, sender: RouterSender): boolean { + // `sender.id` is filled in by the browser; a page script cannot set it. + if (sender.id !== undefined && sender.id !== this.host.extensionId) return false; + const tabId = sender.tab?.id; + if (typeof tabId !== 'number') return false; + const envelope = parseContentEnvelope(message); + if (!envelope) return false; + const attachment = this.byTab.get(tabId); + if (!attachment) return false; + attachment.port.postMessage(envelope); + return true; + } +} diff --git a/src/browser.ts b/src/browser.ts new file mode 100644 index 0000000..339d932 --- /dev/null +++ b/src/browser.ts @@ -0,0 +1,35 @@ +/** + * Cross-browser API access. + * + * Firefox exposes the WebExtension API as `browser`, Chromium as `chrome`; + * both ship the `chrome.*` alias for the callback-style APIs this extension + * uses, so a single typed accessor is enough — no polyfill dependency. + * + * @module browser + */ + +/** The subset of `chrome.*` this extension touches. */ +type ExtensionApi = typeof chrome; + +interface GlobalWithBrowser { + browser?: ExtensionApi; + chrome?: ExtensionApi; +} + +/** + * The WebExtension API for the current browser. + * + * @throws When called outside an extension context (e.g. a plain web page). + */ +export const extensionApi = (): ExtensionApi => { + const scope = globalThis as unknown as GlobalWithBrowser; + const api = scope.browser ?? scope.chrome; + if (!api) throw new Error('bQuery DevTools: no WebExtension API available'); + return api; +}; + +/** `true` when a WebExtension API is reachable at all. */ +export const hasExtensionApi = (): boolean => { + const scope = globalThis as unknown as GlobalWithBrowser; + return Boolean(scope.browser ?? scope.chrome); +}; diff --git a/src/classes/errorBoundary.ts b/src/classes/errorBoundary.ts deleted file mode 100644 index 47bddac..0000000 --- a/src/classes/errorBoundary.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { escapeHtml } from '@bquery/bquery/security'; - -export class ErrorBoundary { - private static instance: ErrorBoundary; - private errorHandlers: Array<(error: Error) => void> = []; - - /** - * Produces an HTML-escaped representation of an error message using - * bQuery's security primitives. Use this when surfacing untrusted error - * text inside HTML text content; other sinks (for example URLs, styles, or - * scriptable attributes) still need context-specific validation/encoding. - */ - public static formatErrorMessage(message: string): string { - return escapeHtml(message); - } - - private constructor() { - this.setupGlobalErrorHandlers(); - } - - public static getInstance(): ErrorBoundary { - if (!ErrorBoundary.instance) { - ErrorBoundary.instance = new ErrorBoundary(); - } - return ErrorBoundary.instance; - } - - private setupGlobalErrorHandlers(): void { - // Handle uncaught errors - window.addEventListener('error', event => { - this.handleError(new Error(event.message), { - filename: event.filename, - lineno: event.lineno, - colno: event.colno, - }); - }); - - // Handle unhandled promise rejections - window.addEventListener('unhandledrejection', event => { - this.handleError( - event.reason instanceof Error ? event.reason : new Error(String(event.reason)), - { type: 'unhandledrejection' } - ); - }); - } - - public addErrorHandler(handler: (error: Error) => void): void { - this.errorHandlers.push(handler); - } - - public removeErrorHandler(handler: (error: Error) => void): void { - const index = this.errorHandlers.indexOf(handler); - if (index > -1) { - this.errorHandlers.splice(index, 1); - } - } - - public handleError(error: Error, context?: Record): void { - console.error('Error caught by ErrorBoundary:', error, context); - - // Call all registered error handlers - this.errorHandlers.forEach(handler => { - try { - handler(error); - } catch (handlerError) { - console.error('Error in error handler:', handlerError); - } - }); - - // Send to background script if available - if (chrome.runtime) { - chrome.runtime - .sendMessage({ - type: 'error', - payload: { - message: error.message, - stack: error.stack, - context, - timestamp: Date.now(), - }, - }) - .catch(() => { - // Ignore errors when sending to background - }); - } - } - - public wrapAsync( - fn: (...args: T) => Promise - ): (...args: T) => Promise { - return async (...args: T): Promise => { - try { - return await fn(...args); - } catch (error) { - this.handleError(error instanceof Error ? error : new Error(String(error))); - throw error; - } - }; - } - - public wrapSync(fn: (...args: T) => R): (...args: T) => R { - return (...args: T): R => { - try { - return fn(...args); - } catch (error) { - this.handleError(error instanceof Error ? error : new Error(String(error))); - throw error; - } - }; - } -} diff --git a/src/classes/session.ts b/src/classes/session.ts deleted file mode 100644 index 7539234..0000000 --- a/src/classes/session.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Session management built on top of the bQuery platform/storage adapter and - * reactive signals from `@bquery/bquery/reactive`. - * - * The session exposes its mutable fields as `Signal`s so views and components - * can subscribe to changes without manual polling. Persistence is handled - * through `@bquery/bquery/platform`'s unified `StorageAdapter` so the - * underlying backend (localStorage, sessionStorage, IndexedDB, …) can be - * swapped without touching consumers. - */ -import type { StorageAdapter } from '@bquery/bquery/platform'; -import { storage } from '@bquery/bquery/platform'; -import { effect, signal, type Signal } from '@bquery/bquery/reactive'; - -interface SessionData { - sessionId: string; - contentTest: string; -} - -export class Session implements SessionData { - private static instance: Session | null = null; - private static readonly STORAGE_KEY = 'browser_extension_session'; - private static readonly storageAdapter: StorageAdapter = storage.local(); - - private static isSameData( - left: SessionData | null | undefined, - right: SessionData | null | undefined - ): boolean { - return ( - !!left && - !!right && - left.sessionId === right.sessionId && - left.contentTest === right.contentTest - ); - } - - public readonly sessionId: string; - /** Reactive signal holding the current `contentTest` value. */ - public readonly contentTest$: Signal; - /** - * Chains successive persistence writes so that a slower backend (e.g. - * IndexedDB) cannot let an older value overwrite a newer one when the - * signal updates faster than the storage adapter can flush. - */ - private writeQueue: Promise = Promise.resolve(); - private lastQueuedData: SessionData | null = null; - private lastQueuedWriteFailed = false; - private isActive = true; - private readonly stopAutoPersist: () => void; - - private static matchesNormalizedSnapshot( - source: Partial | null | undefined, - snapshot: SessionData - ): boolean { - return ( - typeof source?.sessionId === 'string' && - typeof source?.contentTest === 'string' && - source.sessionId === snapshot.sessionId && - source.contentTest === snapshot.contentTest - ); - } - - private constructor(data?: Partial, options?: { skipInitialPersist?: boolean }) { - this.sessionId = data?.sessionId ?? crypto.randomUUID(); - this.contentTest$ = signal( - data?.contentTest ?? 'This is a simple example of a web application' - ); - const initialSnapshot = this.snapshot(); - - if (options?.skipInitialPersist && Session.matchesNormalizedSnapshot(data, initialSnapshot)) { - this.lastQueuedData = initialSnapshot; - } - - // Auto-persist whenever the reactive value changes. Fresh sessions write - // their seeded snapshot immediately; sessions loaded from storage seed the - // queue state first so unchanged startup snapshots do not write again. - this.stopAutoPersist = effect(() => { - void this.enqueueWrite(this.snapshot()).catch(error => { - console.error('Failed to persist session:', error); - }); - }); - } - - private snapshot(): SessionData { - return { - sessionId: this.sessionId, - contentTest: this.contentTest$.value, - }; - } - - private enqueueWrite(data: SessionData): Promise { - if (!this.isActive) { - return Promise.reject(new Error('Session instance is no longer active.')); - } - - if (Session.isSameData(this.lastQueuedData, data) && !this.lastQueuedWriteFailed) { - return this.writeQueue; - } - - this.lastQueuedData = data; - this.lastQueuedWriteFailed = false; - - const write = this.writeQueue - .catch(() => undefined) - .then(() => Session.storageAdapter.set(Session.STORAGE_KEY, data)); - - this.writeQueue = write.catch(error => { - if (Session.isSameData(this.lastQueuedData, data)) { - this.lastQueuedWriteFailed = true; - } - throw error; - }); - - return this.writeQueue; - } - - private async waitForQueuedWrites(): Promise { - await this.writeQueue.catch(() => undefined); - } - - private deactivate(): void { - if (!this.isActive) { - return; - } - - this.stopAutoPersist(); - this.isActive = false; - } - - /** Backwards compatible accessor for the non-reactive content value. */ - public get contentTest(): string { - return this.contentTest$.value; - } - - public set contentTest(value: string) { - this.contentTest$.value = value; - } - - public static async getInstance(): Promise { - if (!Session.instance) { - await Session.loadOrCreate(); - } - return Session.instance!; - } - - private static async loadOrCreate(): Promise { - let savedData: SessionData | null | undefined; - - try { - savedData = await Session.storageAdapter.get(Session.STORAGE_KEY); - } catch (error) { - console.error('Failed to load session, creating new one:', error); - } - - const instance = new Session(savedData ?? undefined, { - skipInitialPersist: savedData != null, - }); - Session.instance = instance; - - if (savedData == null) { - await instance.save(); - } - } - - /** Explicit save kept for backwards compatibility with the previous API. */ - public async save(): Promise { - await this.enqueueWrite(this.snapshot()); - } - - public static async reset(): Promise { - try { - const previousInstance = Session.instance; - if (previousInstance) { - previousInstance.deactivate(); - await previousInstance.waitForQueuedWrites(); - } - - await Session.storageAdapter.remove(Session.STORAGE_KEY); - Session.instance = new Session(); - await Session.instance.save(); - - if (typeof window !== 'undefined' && window.location) { - window.location.reload(); - } - } catch (error) { - console.error('Failed to reset session:', error); - throw error; - } - } - - public toJSON(): SessionData { - return this.snapshot(); - } -} diff --git a/src/components/button.ts b/src/components/button.ts deleted file mode 100644 index a182dfa..0000000 --- a/src/components/button.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Button component implemented as a bQuery Web Component (`bet-button`). - * - * The custom element is registered via `@bquery/bquery/component`'s - * `component()` helper, leveraging: - * - Typed `props` with automatic attribute coercion - * - `safeHtml` for sanitized template literals - * - `bool()` helper for boolean-attribute shorthand - * - * A thin `BasicButton` class is preserved to keep the historical imperative - * API working for callers that still build HTML strings or HTMLButtonElements. - * Its internals also route through bQuery's `safeHtml` template tag and the - * `$`/chainable DOM API instead of raw `innerHTML` / `setAttribute` calls. - */ -import { bool, component, safeHtml } from '@bquery/bquery/component'; -import { $ } from '@bquery/bquery/core'; -import { customButton } from '../types/buttonType'; - -export interface ButtonConfig { - type: customButton; - text: string; - id?: string | undefined; - className?: string | undefined; - disabled?: boolean | undefined; - onClick?: (() => void) | undefined; -} - -const BOOTSTRAP_CLASS_MAP: Record = { - neutral: 'btn btn-secondary', - primary: 'btn btn-primary', - secondary: 'btn btn-secondary', - success: 'btn btn-success', - danger: 'btn btn-danger', - warning: 'btn btn-warning', - info: 'btn btn-info', - light: 'btn btn-light', - dark: 'btn btn-dark', -}; - -const KNOWN_BUTTON_TYPES = new Set( - Object.keys(BOOTSTRAP_CLASS_MAP) as customButton[] -); - -function resolveBootstrapClass(type: customButton): string { - return BOOTSTRAP_CLASS_MAP[type] ?? BOOTSTRAP_CLASS_MAP.primary; -} - -function splitClassNames(value: string): string[] { - return value.split(/\s+/).filter(Boolean); -} - -let registered = false; - -/** - * Registers the `` custom element. Importing this module already - * attempts registration in browsing contexts, so calling this explicitly is - * optional; it remains available for tests or callers that want idempotent, - * explicit control over when registration happens. - */ -export function registerBetButton(): void { - if (registered || typeof customElements === 'undefined') { - return; - } - - if (customElements.get('bet-button')) { - registered = true; - return; - } - - component<{ variant: customButton; text: string; disabled: boolean }>('bet-button', { - shadow: false, - props: { - variant: { - type: (value: unknown): customButton => { - if (typeof value === 'string' && KNOWN_BUTTON_TYPES.has(value as customButton)) { - return value as customButton; - } - return 'primary'; - }, - default: 'primary' as customButton, - validator: (value: unknown): boolean => - typeof value === 'string' && KNOWN_BUTTON_TYPES.has(value as customButton), - }, - text: { type: String, default: '' }, - disabled: { type: Boolean, default: false }, - }, - render({ props }) { - const cls = resolveBootstrapClass(props.variant); - return safeHtml` - - `; - }, - }); - - registered = true; -} - -// Auto-register on import for popup/options page consumers. The runtime guard -// keeps the side effect safe in contexts like the background worker where -// `customElements` is unavailable, and explicit `registerBetButton()` calls -// remain optional/idempotent for callers that prefer them. -registerBetButton(); - -/** - * Imperative button helper. Internally uses the same bootstrap class map as - * the `` web component so both APIs stay visually consistent. - */ -export class BasicButton { - private readonly config: ButtonConfig; - - constructor(type: customButton, text: string, id?: string, className?: string) { - this.config = { - type, - text, - id, - className, - }; - } - - public render(): string { - const baseClass = resolveBootstrapClass(this.config.type); - const extraClass = this.config.className ?? ''; - const id = this.config.id ?? ''; - return safeHtml``; - } - - public createElement(): HTMLButtonElement { - const button = document.createElement('button'); - const $button = $(button); - - $button.attr('type', 'button'); - $button.text(this.config.text); - $button.addClass(...splitClassNames(resolveBootstrapClass(this.config.type))); - - if (this.config.id) { - $button.attr('id', this.config.id); - } - - if (this.config.className) { - $button.addClass(...splitClassNames(this.config.className)); - } - - if (this.config.disabled) { - // Use the DOM property so the button reflects the disabled state via - // its `HTMLButtonElement.disabled` flag, matching browser semantics - // rather than relying on attribute string coercion. - button.disabled = true; - } - - if (this.config.onClick) { - $button.on('click', this.config.onClick); - } - - return button; - } - - public static create(config: ButtonConfig): BasicButton { - const button = new BasicButton(config.type, config.text, config.id, config.className); - if (config.disabled !== undefined) { - button.config.disabled = config.disabled; - } - if (config.onClick !== undefined) { - button.config.onClick = config.onClick; - } - return button; - } -} diff --git a/src/content.ts b/src/content.ts new file mode 100644 index 0000000..0e677a7 --- /dev/null +++ b/src/content.ts @@ -0,0 +1,59 @@ +/** + * Content script for the optional port transport. + * + * It is *not* declared in the manifest: the panel injects it on demand, after + * the user grants the origin permission for the site they are debugging. Its + * only job is to relay the bridge protocol between the page's `window` + * (where `connectDevtoolsBridge()` listens) and the background router. + * + * The script is idempotent — re-injection after a navigation must not install + * a second listener pair. + * + * @module content + */ +import { extensionApi } from './browser'; +import { BRIDGE_SOURCE } from './protocol/messages'; +import { ENVELOPE_SOURCE } from './protocol/envelope'; + +const INSTALLED_FLAG = '__bqueryDevtoolsRelayInstalled'; + +interface RelayScope { + [INSTALLED_FLAG]?: boolean; +} + +const scope = window as unknown as RelayScope; + +if (!scope[INSTALLED_FLAG]) { + scope[INSTALLED_FLAG] = true; + const api = extensionApi(); + + // Page → background. Only same-window page-channel bridge messages qualify; + // everything else on the very busy `message` bus is ignored. + window.addEventListener('message', event => { + const data: unknown = event.data; + if (event.source !== window || typeof data !== 'object' || data === null) return; + const record = data as Record; + if (record['source'] !== BRIDGE_SOURCE || record['channel'] !== 'page') return; + try { + void api.runtime + .sendMessage({ source: ENVELOPE_SOURCE, type: 'from-page', payload: data }) + .catch(() => { + // The worker may be asleep or the panel closed; the panel re-handshakes. + }); + } catch { + // Extension context invalidated (reloaded/updated) — drop the frame. + } + }); + + // Background (panel) → page. + api.runtime.onMessage.addListener((message: unknown) => { + if (typeof message !== 'object' || message === null) return undefined; + const record = message as Record; + if (record['source'] !== ENVELOPE_SOURCE || record['type'] !== 'to-page') return undefined; + // Same-window delivery; opaque origins (`file:`, sandboxed frames) + // report "null", for which an explicit target origin is invalid. + const origin = window.location.origin; + window.postMessage(record['payload'], origin && origin !== 'null' ? origin : '*'); + return undefined; + }); +} diff --git a/src/devtools.ts b/src/devtools.ts new file mode 100644 index 0000000..6417a10 --- /dev/null +++ b/src/devtools.ts @@ -0,0 +1,13 @@ +/** + * DevTools page — registers the "bQuery" panel. + * + * This is the only script the browser loads when DevTools opens; the panel + * itself is created lazily by the browser when the tab is first selected. + * + * @module devtools + */ +import { extensionApi } from './browser'; + +extensionApi().devtools.panels.create('bQuery', 'icons/icon48.png', 'panel.html', () => { + // The panel drives its own connection; nothing to do here. +}); diff --git a/src/panel.ts b/src/panel.ts new file mode 100644 index 0000000..800a19d --- /dev/null +++ b/src/panel.ts @@ -0,0 +1,160 @@ +/** + * DevTools panel entry point. + * + * Wires a transport → {@link BridgeClient} → {@link PanelState} → Web + * Components, and owns the two things only the entry point can do: reacting + * to page navigation, and upgrading from the permission-free polling + * transport to live streaming once the user grants the origin permission. + * + * @module panel + */ +import { extensionApi, hasExtensionApi } from './browser'; +import { BridgeClient } from './protocol/client'; +import type { BridgeTransport } from './protocol/transport'; +import { EvalTransport } from './transports/evalTransport'; +import { PortTransport } from './transports/portTransport'; +import { providePanelState } from './panel/components/base'; +import type { PanelShell } from './panel/components/shell'; +import './panel/components/shell'; +import { loadSettings, type PanelSettings } from './panel/settings'; +import { PanelState } from './panel/state'; +import { TimelineBuffer } from './panel/timeline'; +import './sass/panel.sass'; + +/** Root element the shell is mounted into. */ +const ROOT_ID = 'panel-root'; + +interface Mounted { + readonly client: BridgeClient; + readonly state: PanelState; + readonly shell: PanelShell; +} + +/** The buffer survives transport swaps, so no history is lost on upgrade. */ +let buffer: TimelineBuffer; +let mounted: Mounted | null = null; +let settings: PanelSettings; + +const inspectedTabId = (): number => { + if (!hasExtensionApi()) return -1; + return extensionApi().devtools?.inspectedWindow?.tabId ?? -1; +}; + +/** Read the inspected page's origin without needing a host permission. */ +const inspectedOrigin = (): Promise => + new Promise(resolve => { + if (!hasExtensionApi()) { + resolve(null); + return; + } + const devtools = extensionApi().devtools; + if (!devtools?.inspectedWindow?.eval) { + resolve(null); + return; + } + devtools.inspectedWindow.eval('location.origin', (result: unknown) => { + resolve(typeof result === 'string' && result !== 'null' ? result : null); + }); + }); + +const mount = (transport: BridgeTransport, streaming: boolean): Mounted => { + const client = new BridgeClient(transport); + const state = new PanelState(client, buffer); + providePanelState(state); + + const root = document.getElementById(ROOT_ID); + if (!root) throw new Error(`bQuery DevTools: #${ROOT_ID} is missing from panel.html`); + root.textContent = ''; + + const shell = document.createElement('bq-panel') as PanelShell; + shell.streaming = streaming; + shell.onUpgrade = streaming ? null : upgradeToLiveStreaming; + root.appendChild(shell); + + state.start(); + return { client, state, shell }; +}; + +const unmount = (): void => { + if (!mounted) return; + mounted.state.dispose(); + mounted.client.dispose(); + mounted = null; +}; + +/** + * Swap the polling transport for the push transport. + * + * Runs on a click, because `permissions.request()` requires a user gesture. + * Any failure leaves the polling transport in place — the panel keeps working + * either way. + */ +async function upgradeToLiveStreaming(): Promise { + try { + const origin = await inspectedOrigin(); + if (!origin) throw new Error('the inspected page has no addressable origin'); + const api = extensionApi(); + const pattern = `${origin}/*`; + const granted = + (await api.permissions.contains({ origins: [pattern] })) || + (await api.permissions.request({ origins: [pattern] })); + if (!granted) throw new Error('permission for this site was declined'); + + const transport = new PortTransport({ tabId: inspectedTabId() }); + unmount(); + mounted = mount(transport, true); + await transport.requestInjection(); + } catch (error) { + // Always land on a working transport: fall back to polling and say why. + unmount(); + mounted = mount(createEvalTransport(), false); + mounted.state.lastError.value = `Live streaming unavailable: ${ + error instanceof Error ? error.message : String(error) + }`; + } +} + +const createEvalTransport = (): EvalTransport => + new EvalTransport({ pollIntervalMs: settings.pollIntervalMs }); + +/** + * Surface uncaught panel errors in the status bar. + * + * A DevTools panel has no visible console of its own — an error that only + * reaches `console.error` is an error nobody sees. + */ +const installErrorBoundary = (): void => { + const report = (reason: unknown): void => { + const message = reason instanceof Error ? reason.message : String(reason); + console.error('bQuery DevTools:', reason); + if (mounted) mounted.state.lastError.value = message; + }; + window.addEventListener('error', event => report(event.error ?? event.message)); + window.addEventListener('unhandledrejection', event => report(event.reason)); +}; + +const start = async (): Promise => { + installErrorBoundary(); + settings = await loadSettings(); + buffer = new TimelineBuffer(settings.bufferSize); + mounted = mount(createEvalTransport(), false); + + if (hasExtensionApi()) { + // A navigation tears down the page-side bridge; restart the handshake so + // the panel reconnects to the new document instead of going stale. + extensionApi().devtools?.network?.onNavigated?.addListener(() => { + mounted?.state.clearTimeline(); + mounted?.client.resetHandshake('page navigated'); + }); + } + + if (settings.preferLiveStreaming) { + const origin = await inspectedOrigin(); + if (origin && (await extensionApi().permissions.contains({ origins: [`${origin}/*`] }))) { + // Already granted for this site: no gesture needed. + await upgradeToLiveStreaming(); + } + } +}; + +void start(); diff --git a/src/panel/components/base.ts b/src/panel/components/base.ts new file mode 100644 index 0000000..0a866f9 --- /dev/null +++ b/src/panel/components/base.ts @@ -0,0 +1,70 @@ +/** + * Base class for the panel's Web Components. + * + * Each view is a custom element that renders from {@link PanelState} signals. + * Subscriptions are created in `connectedCallback` and disposed in + * `disconnectedCallback`, so switching tabs cannot leak effects. + * + * @module panel/components/base + */ +import { effect } from '@bquery/bquery/reactive'; +import type { PanelState } from '../state'; + +/** Panel state is injected once, before the first element is upgraded. */ +let sharedState: PanelState | null = null; + +/** Provide the state every panel element renders from. */ +export const providePanelState = (state: PanelState): void => { + sharedState = state; +}; + +/** The injected panel state. */ +export const usePanelState = (): PanelState => { + if (!sharedState) throw new Error('bQuery DevTools: panel state has not been provided'); + return sharedState; +}; + +/** A custom element that re-renders when the signals it reads change. */ +export abstract class PanelElement extends HTMLElement { + private disposers: Array<() => void> = []; + + /** The shared panel state. */ + protected get state(): PanelState { + return usePanelState(); + } + + public connectedCallback(): void { + this.track(effect(() => this.render())); + this.onConnected(); + } + + public disconnectedCallback(): void { + for (const dispose of this.disposers) dispose(); + this.disposers = []; + this.onDisconnected(); + } + + /** Register a teardown callback tied to this element's lifetime. */ + protected track(dispose: () => void): void { + this.disposers.push(dispose); + } + + /** Extra setup after the first render. */ + protected onConnected(): void { + /* optional */ + } + + /** Extra teardown. */ + protected onDisconnected(): void { + /* optional */ + } + + /** Render the element. Must read every signal it wants to react to. */ + protected abstract render(): void; +} + +/** Define a custom element once; re-definition is a no-op. */ +export const defineElement = (tag: string, ctor: CustomElementConstructor): void => { + if (typeof customElements === 'undefined' || customElements.get(tag)) return; + customElements.define(tag, ctor); +}; diff --git a/src/panel/components/componentTree.ts b/src/panel/components/componentTree.ts new file mode 100644 index 0000000..2d00b3b --- /dev/null +++ b/src/panel/components/componentTree.ts @@ -0,0 +1,126 @@ +/** + * `` — searchable component tree with in-page selection. + * + * Clicking a node calls DevTools' `inspect()` on the matching element in the + * page, which reveals it in the Elements panel — the tree is a navigation + * aid, not just a listing. + * + * @module panel/components/componentTree + */ +import { extensionApi, hasExtensionApi } from '../../browser'; +import { el, replaceChildren } from '../dom'; +import { buildSelectExpression, flattenTree, nodeAtPath, parsePathKey, pathKey } from '../tree'; +import { defineElement, PanelElement } from './base'; + +/** Component tree view. */ +export class ComponentTreeView extends PanelElement { + protected render(): void { + const state = this.state; + const search = state.treeSearch.value; + const nodes = state.tree.value; + const selected = state.selectedPath.value; + const flat = flattenTree(nodes, search); + + const searchInput = el('input', { + class: 'tree-search', + attrs: { + type: 'search', + placeholder: 'Filter by tag or attribute…', + value: search, + 'aria-label': 'Filter components', + }, + on: { + input: event => { + const target = event.target as HTMLInputElement; + state.treeSearch.value = target.value; + }, + }, + }); + + const header = el('div', { class: 'view-toolbar' }, [ + searchInput, + el('span', { + class: 'muted', + text: search ? `${flat.length} matching` : `${flat.length} components`, + }), + el('button', { + class: 'btn', + text: 'Refresh', + attrs: { type: 'button' }, + on: { + click: () => { + void state.refreshTree(); + }, + }, + }), + ]); + + const list = el('div', { class: 'tree-list', attrs: { role: 'tree' } }); + if (flat.length === 0) { + list.appendChild( + el('p', { + class: 'empty', + text: state.supports('components') + ? 'No custom elements found on the page.' + : 'The page does not advertise the "components" capability.', + }) + ); + } + + for (const item of flat) { + const key = pathKey(item.path); + const row = el('button', { + class: ['tree-row', item.matched ? 'is-match' : '', key === selected ? 'is-selected' : ''] + .filter(Boolean) + .join(' '), + attrs: { + type: 'button', + role: 'treeitem', + 'data-path': key, + 'aria-level': String(item.depth + 1), + title: `Reveal <${item.node.tag}> in the Elements panel`, + }, + style: { 'padding-left': `${8 + item.depth * 14}px` }, + on: { click: () => void this.selectNode(key) }, + }); + row.appendChild(el('span', { class: 'tree-tag', text: `<${item.node.tag}>` })); + const attrs = Object.entries(item.node.attrs); + if (attrs.length > 0) { + row.appendChild( + el('span', { + class: 'tree-attrs', + text: attrs.map(([name, value]) => (value ? `${name}="${value}"` : name)).join(' '), + }) + ); + } + if (item.node.children.length > 0) { + row.appendChild(el('span', { class: 'tree-count', text: `${item.node.children.length}` })); + } + list.appendChild(row); + } + + replaceChildren(this, [header, list]); + } + + /** Reveal the node in the page's Elements panel. */ + private async selectNode(key: string): Promise { + const state = this.state; + state.selectedPath.value = key; + const path = parsePathKey(key); + const node = nodeAtPath(state.tree.value, path); + const expression = buildSelectExpression(path); + if (!expression || !node) return; + if (!hasExtensionApi()) return; + const devtools = extensionApi().devtools; + if (!devtools?.inspectedWindow?.eval) return; + devtools.inspectedWindow.eval(expression, (result: unknown) => { + if (result === null || result === undefined) { + state.lastError.value = `Could not locate <${node.tag}> in the page; try refreshing the tree.`; + } else { + state.lastError.value = ''; + } + }); + } +} + +defineElement('bq-component-tree', ComponentTreeView); diff --git a/src/panel/components/inspector.ts b/src/panel/components/inspector.ts new file mode 100644 index 0000000..3ade292 --- /dev/null +++ b/src/panel/components/inspector.ts @@ -0,0 +1,109 @@ +/** + * `` — signals and stores, with drill-down. + * + * Reads from the live snapshot, or from the time-travel reconstruction when + * the timeline scrubber is engaged; in the latter case each row is marked as + * replayed, unchanged-since-base, or "not recorded" so a reconstructed value + * is never mistaken for a measured one. + * + * @module panel/components/inspector + */ +import { el, replaceChildren } from '../dom'; +import { UNKNOWN_VALUE } from '../timeTravel'; +import { defineElement, PanelElement } from './base'; +// Registers , which the rows below instantiate. +import './valueView'; +import type { ValueView } from './valueView'; + +/** Which collection this inspector shows. */ +export type InspectorKind = 'signals' | 'stores'; + +/** Signals / stores inspector. */ +export class InspectorView extends PanelElement { + /** Set by the panel shell before the element is connected. */ + public kind: InspectorKind = 'signals'; + + protected render(): void { + const state = this.state; + const replay = state.reconstruction.value; + const capability = this.kind === 'signals' ? 'signals' : 'stores'; + + const rows: Node[] = []; + if (this.kind === 'signals') { + const entries = replay + ? replay.signals.map(item => ({ + key: item.label, + value: item.value, + meta: item.unresolved ? 'not recorded' : item.fromBase ? 'unchanged' : 'replayed', + })) + : state.signals.value.map(item => ({ + key: item.label, + value: item.value, + meta: `${item.subscriberCount} subscriber${item.subscriberCount === 1 ? '' : 's'}`, + })); + for (const entry of entries) rows.push(this.row(entry.key, entry.value, entry.meta)); + } else { + const entries = replay + ? replay.stores.map(item => ({ + key: item.id, + value: item.unresolved ? UNKNOWN_VALUE : item.state, + meta: item.unresolved ? 'not recorded' : item.fromBase ? 'unchanged' : 'replayed', + })) + : state.stores.value.map(item => ({ + key: item.id, + value: item.state as unknown, + meta: `${Object.keys(item.state).length} keys`, + })); + for (const entry of entries) rows.push(this.row(entry.key, entry.value, entry.meta)); + } + + const header = el('div', { class: 'view-toolbar' }, [ + el('span', { + class: 'muted', + text: replay + ? `Replayed state · ${rows.length} ${this.kind}` + : `${rows.length} ${this.kind}`, + }), + el('button', { + class: 'btn', + text: 'Refresh', + attrs: { type: 'button', ...(replay ? { disabled: 'true' } : {}) }, + on: { + click: () => { + void state.refreshSnapshot(); + }, + }, + }), + ]); + + const body = el('div', { class: 'inspector-list' }); + if (rows.length === 0) { + body.appendChild( + el('p', { + class: 'empty', + text: state.supports(capability) + ? `No ${this.kind} reported by the page.` + : `The page does not advertise the "${capability}" capability.`, + }) + ); + } + for (const row of rows) body.appendChild(row); + + replaceChildren(this, [header, body]); + } + + private row(key: string, value: unknown, meta: string): Node { + const view = document.createElement('bq-value') as ValueView; + const wrapper = el('div', { class: 'inspector-row' }, [ + el('div', { class: 'inspector-meta' }, [ + el('span', { class: 'inspector-key', text: key }), + el('span', { class: 'badge', text: meta }), + ]), + view, + ]); + view.setValue(value); + return wrapper; + } +} + +defineElement('bq-inspector', InspectorView); diff --git a/src/panel/components/shell.ts b/src/panel/components/shell.ts new file mode 100644 index 0000000..d9761aa --- /dev/null +++ b/src/panel/components/shell.ts @@ -0,0 +1,115 @@ +/** + * `` — the panel shell: status bar plus tabbed views. + * + * Tabs whose capability the page did not advertise stay visible but are + * marked unsupported, so the user can tell "the app has no stores" apart from + * "this panel cannot show stores". + * + * @module panel/components/shell + */ +import { el, replaceChildren } from '../dom'; +import type { BridgeCapability } from '../../protocol/messages'; +import { defineElement, PanelElement } from './base'; +// Side-effect imports: each module registers its custom element on load, and +// the classes below are referenced only as types — without these the elements +// would never be defined. +import './componentTree'; +import './inspector'; +import './statusBar'; +import './timelineView'; +import './valueView'; +import type { InspectorView } from './inspector'; +import type { StatusBar } from './statusBar'; + +interface TabDefinition { + readonly id: string; + readonly label: string; + readonly capability: BridgeCapability; + readonly create: () => HTMLElement; +} + +const TABS: readonly TabDefinition[] = [ + { + id: 'components', + label: 'Components', + capability: 'components', + create: () => document.createElement('bq-component-tree'), + }, + { + id: 'signals', + label: 'Signals', + capability: 'signals', + create: () => { + const view = document.createElement('bq-inspector') as InspectorView; + view.kind = 'signals'; + return view; + }, + }, + { + id: 'stores', + label: 'Stores', + capability: 'stores', + create: () => { + const view = document.createElement('bq-inspector') as InspectorView; + view.kind = 'stores'; + return view; + }, + }, + { + id: 'timeline', + label: 'Timeline', + capability: 'timeline', + create: () => document.createElement('bq-timeline'), + }, +]; + +/** Panel shell. */ +export class PanelShell extends PanelElement { + /** Injected by the entry point; forwarded to the status bar. */ + public onUpgrade: (() => Promise) | null = null; + /** Injected by the entry point; forwarded to the status bar. */ + public streaming = false; + + private activeTab = 'components'; + + protected render(): void { + const state = this.state; + const capabilities = state.bridge.capabilities.value; + + const statusBar = document.createElement('bq-status-bar') as StatusBar; + statusBar.onUpgrade = this.onUpgrade; + statusBar.streaming = this.streaming; + + const tabs = el( + 'div', + { class: 'tabs', attrs: { role: 'tablist' } }, + TABS.map(tab => { + const supported = capabilities.size === 0 || capabilities.has(tab.capability); + return el('button', { + class: `tab${tab.id === this.activeTab ? ' is-active' : ''}${supported ? '' : ' is-unsupported'}`, + text: tab.label, + attrs: { + type: 'button', + role: 'tab', + 'aria-selected': String(tab.id === this.activeTab), + ...(supported ? {} : { title: `The page does not advertise "${tab.capability}"` }), + }, + on: { + click: () => { + this.activeTab = tab.id; + this.render(); + }, + }, + }); + }) + ); + + const definition = TABS.find(tab => tab.id === this.activeTab) ?? TABS[0]; + const body = el('div', { class: 'tab-body', attrs: { role: 'tabpanel' } }); + if (definition) body.appendChild(definition.create()); + + replaceChildren(this, [statusBar, tabs, body]); + } +} + +defineElement('bq-panel', PanelShell); diff --git a/src/panel/components/statusBar.ts b/src/panel/components/statusBar.ts new file mode 100644 index 0000000..8e4d6b0 --- /dev/null +++ b/src/panel/components/statusBar.ts @@ -0,0 +1,106 @@ +/** + * `` — connection state, negotiated capabilities, transport. + * + * Also hosts the upgrade path to live streaming: the button asks for the + * inspected origin's permission *on a user gesture* (the only moment a + * browser will grant one) and then swaps the transport. + * + * @module panel/components/statusBar + */ +import { el, replaceChildren } from '../dom'; +import { KNOWN_CAPABILITIES } from '../../protocol/messages'; +import { defineElement, PanelElement } from './base'; + +/** Labels for each connection state. */ +const STATE_LABEL: Record = { + idle: 'Idle', + connecting: 'Connecting…', + 'waiting-for-page': 'Waiting for the page', + connected: 'Connected', + disconnected: 'Disconnected', + error: 'Error', +}; + +/** Status bar view. */ +export class StatusBar extends PanelElement { + /** Injected by the shell: swap to the live-streaming transport. */ + public onUpgrade: (() => Promise) | null = null; + /** Injected by the shell: `true` once live streaming is active. */ + public streaming = false; + + protected render(): void { + const state = this.state; + const connection = state.bridge.state.value; + const capabilities = state.bridge.capabilities.value; + const detail = state.bridge.detail.value; + const error = state.lastError.value; + + const badges = KNOWN_CAPABILITIES.map(capability => + el('span', { + class: `badge${capabilities.has(capability) ? ' is-on' : ' is-off'}`, + text: capability, + title: capabilities.has(capability) + ? `The page supports "${capability}"` + : `The page did not advertise "${capability}"`, + }) + ); + + const children: Node[] = [ + el('span', { + class: `status status-${connection}`, + text: STATE_LABEL[connection] ?? connection, + }), + el('span', { class: 'badge', text: `protocol v1` }), + el('span', { + class: 'badge', + text: this.streaming ? 'live streaming' : 'polling', + title: this.streaming + ? 'Events are pushed from the page through a content script.' + : 'Events are polled through the DevTools evaluation channel (no host permission needed).', + }), + ...badges, + el('span', { class: 'spacer' }), + ]; + + if (!this.streaming && this.onUpgrade) { + children.push( + el('button', { + class: 'btn', + text: 'Enable live streaming', + attrs: { + type: 'button', + title: + 'Requests permission for this site and injects a content script so events arrive as they happen.', + }, + on: { + click: () => { + void this.onUpgrade?.(); + }, + }, + }) + ); + } + + children.push( + el('button', { + class: 'btn', + text: state.loading.value ? 'Refreshing…' : 'Refresh all', + attrs: { type: 'button', ...(state.loading.value ? { disabled: 'true' } : {}) }, + on: { + click: () => { + void state.refreshAll(); + }, + }, + }) + ); + + const bar = el('div', { class: 'status-bar' }, children); + const message = error || (connection !== 'connected' ? detail : ''); + replaceChildren(this, [ + bar, + message ? el('p', { class: 'status-message', text: message }) : null, + ]); + } +} + +defineElement('bq-status-bar', StatusBar); diff --git a/src/panel/components/timelineView.ts b/src/panel/components/timelineView.ts new file mode 100644 index 0000000..84f3312 --- /dev/null +++ b/src/panel/components/timelineView.ts @@ -0,0 +1,284 @@ +/** + * `` — reactive event log, buffering controls and time travel. + * + * The scrubber addresses buffered entries by index; moving it pauses live + * streaming and asks {@link PanelState} for the reconstruction at that point, + * which the signals and stores views pick up automatically. + * + * @module panel/components/timelineView + */ +import type { TimelineEntry } from '../../protocol/messages'; +import { el, formatTime, replaceChildren } from '../dom'; +import { collectTypes, filterEntries, MAX_BUFFER_SIZE, MIN_BUFFER_SIZE } from '../timeline'; +import { defineElement, PanelElement } from './base'; +// Registers , which the rows below instantiate. +import './valueView'; +import type { ValueView } from './valueView'; + +/** Most rows rendered at once; the buffer itself may hold far more. */ +const MAX_RENDERED_ROWS = 300; + +/** Timeline view. */ +export class TimelineView extends PanelElement { + private expandedRow = -1; + + protected render(): void { + const state = this.state; + // Read the revision so the effect re-runs whenever the buffer changes. + void state.timelineRevision.value; + const filter = state.timelineFilter.value; + const paused = state.paused.value; + const travelIndex = state.timeTravelIndex.value; + const entries = state.entries(); + const visible = filterEntries(entries, filter); + const rendered = visible.slice(-MAX_RENDERED_ROWS); + + replaceChildren(this, [ + this.toolbar(paused, entries.length), + this.filters(entries, filter), + this.scrubber(entries.length, travelIndex), + this.list(rendered, entries, visible.length), + ]); + } + + private toolbar(paused: boolean, bufferedCount: number): Node { + const state = this.state; + const dropped = state.droppedEntries(); + return el('div', { class: 'view-toolbar' }, [ + el('button', { + class: `btn${paused ? ' is-active' : ''}`, + text: paused ? 'Resume' : 'Pause', + attrs: { type: 'button' }, + on: { + click: () => { + if (state.timeTravelIndex.value !== null) state.resumeLive(); + else state.paused.value = !state.paused.value; + }, + }, + }), + el('button', { + class: 'btn', + text: 'Clear', + attrs: { type: 'button' }, + on: { click: () => state.clearTimeline() }, + }), + el('label', { class: 'field' }, [ + el('span', { text: 'Buffer' }), + el('input', { + class: 'buffer-input', + attrs: { + type: 'number', + min: String(MIN_BUFFER_SIZE), + max: String(MAX_BUFFER_SIZE), + step: '50', + value: String(state.bufferCapacity()), + 'aria-label': 'Timeline buffer size', + }, + on: { + change: event => { + const target = event.target as HTMLInputElement; + state.setBufferSize(Number(target.value)); + }, + }, + }), + ]), + el('span', { + class: 'muted', + text: `${bufferedCount} buffered${dropped > 0 ? ` · ${dropped} dropped` : ''}`, + }), + ]); + } + + private filters( + entries: readonly TimelineEntry[], + filter: { types: ReadonlySet; search: string } + ): Node { + const state = this.state; + const types = collectTypes(entries); + + const chips = types.map(type => + el('button', { + class: `chip${filter.types.has(type) ? ' is-on' : ''}`, + text: type, + attrs: { type: 'button', 'aria-pressed': String(filter.types.has(type)) }, + on: { + click: () => { + const next = new Set(filter.types); + if (next.has(type)) next.delete(type); + else next.add(type); + state.timelineFilter.value = { types: next, search: filter.search }; + }, + }, + }) + ); + + return el('div', { class: 'timeline-filters' }, [ + el('input', { + class: 'tree-search', + attrs: { + type: 'search', + placeholder: 'Filter events…', + value: filter.search, + 'aria-label': 'Filter timeline events', + }, + on: { + input: event => { + const target = event.target as HTMLInputElement; + state.timelineFilter.value = { types: filter.types, search: target.value }; + }, + }, + }), + el('div', { class: 'chips' }, chips), + ]); + } + + private scrubber(total: number, travelIndex: number | null): Node { + const state = this.state; + const supported = state.supports('time-travel'); + const disabled = total === 0 || !supported; + const index = travelIndex ?? total - 1; + const replay = state.reconstruction.value; + + return el('div', { class: 'scrubber' }, [ + el('label', { class: 'field scrubber-field' }, [ + el('span', { text: 'Time travel' }), + el('input', { + class: 'scrubber-range', + attrs: { + type: 'range', + min: '0', + max: String(Math.max(total - 1, 0)), + value: String(Math.max(index, 0)), + 'aria-label': 'Replay position', + ...(disabled ? { disabled: 'true' } : {}), + }, + on: { + input: event => { + const target = event.target as HTMLInputElement; + state.travelTo(Number(target.value)); + }, + }, + }), + ]), + el('button', { + class: 'btn', + text: 'Live', + attrs: { type: 'button', ...(travelIndex === null ? { disabled: 'true' } : {}) }, + on: { click: () => state.resumeLive() }, + }), + el('span', { + class: 'muted', + text: !supported + ? 'The page does not advertise the "time-travel" capability.' + : replay + ? `@ ${formatTime(replay.timestamp)} · ${replay.appliedCount} applied${ + replay.unresolvedCount > 0 ? ` · ${replay.unresolvedCount} not recorded` : '' + }` + : 'Following live state', + }), + ]); + } + + private list( + rendered: readonly TimelineEntry[], + all: readonly TimelineEntry[], + visibleCount: number + ): Node { + const state = this.state; + const travelIndex = state.timeTravelIndex.value; + // One pass to address buffered entries by identity, instead of an + // `indexOf` scan per rendered row. + const bufferIndexOf = new Map(); + all.forEach((entry, index) => bufferIndexOf.set(entry, index)); + const list = el('div', { class: 'timeline-list' }); + + if (rendered.length === 0) { + list.appendChild( + el('p', { + class: 'empty', + text: state.supports('timeline') + ? 'No events recorded yet. Interact with the page to see reactive activity.' + : 'The page does not advertise the "timeline" capability.', + }) + ); + return list; + } + + if (visibleCount > rendered.length) { + list.appendChild( + el('p', { + class: 'muted', + text: `Showing the ${rendered.length} most recent of ${visibleCount} matching events.`, + }) + ); + } + + // Newest first reads better in a log, but indices address the buffer. + for (let offset = rendered.length - 1; offset >= 0; offset -= 1) { + const entry = rendered[offset]; + if (!entry) continue; + const bufferIndex = bufferIndexOf.get(entry) ?? -1; + const isCurrent = travelIndex !== null && bufferIndex === travelIndex; + const expanded = this.expandedRow === bufferIndex; + + const row = el('div', { + class: `timeline-row${isCurrent ? ' is-current' : ''}`, + }); + row.appendChild( + el( + 'button', + { + class: 'timeline-head', + attrs: { type: 'button', 'aria-expanded': String(expanded) }, + on: { + click: () => { + this.expandedRow = expanded ? -1 : bufferIndex; + this.render(); + }, + }, + }, + [ + el('span', { class: 'timeline-time', text: formatTime(entry.timestamp) }), + el('span', { + class: `timeline-type type-${entry.type.split(':')[0] ?? 'other'}`, + text: entry.type, + }), + el('span', { class: 'timeline-detail', text: entry.detail }), + entry.source ? el('span', { class: 'badge', text: entry.source }) : null, + entry.duration !== undefined + ? el('span', { class: 'badge', text: `${entry.duration.toFixed(1)}ms` }) + : null, + ] + ) + ); + + if (expanded) { + const details = el('div', { class: 'timeline-payload' }); + if (entry.payload === undefined) { + details.appendChild(el('p', { class: 'muted', text: 'No payload recorded.' })); + } else { + const value = document.createElement('bq-value') as ValueView; + details.appendChild(value); + value.setValue(entry.payload, 'payload'); + } + if (bufferIndex >= 0 && state.supports('time-travel')) { + details.appendChild( + el('button', { + class: 'btn', + text: 'Replay state at this event', + attrs: { type: 'button' }, + on: { click: () => state.travelTo(bufferIndex) }, + }) + ); + } + row.appendChild(details); + } + + list.appendChild(row); + } + + return list; + } +} + +defineElement('bq-timeline', TimelineView); diff --git a/src/panel/components/valueView.ts b/src/panel/components/valueView.ts new file mode 100644 index 0000000..d388a60 --- /dev/null +++ b/src/panel/components/valueView.ts @@ -0,0 +1,82 @@ +/** + * `` — expandable view of one signal or store value. + * + * Children are described lazily, so a large or deeply nested object costs + * nothing until the user drills into it. + * + * @module panel/components/valueView + */ +import { el, replaceChildren } from '../dom'; +import { describeValue, type ValueEntry } from '../valueTree'; +import { defineElement } from './base'; + +/** Expandable value renderer. */ +export class ValueView extends HTMLElement { + private currentValue: unknown = undefined; + private label = ''; + private expanded = false; + private depth = 0; + + /** Point the view at a value. */ + public setValue(value: unknown, label = '', depth = 0): void { + this.currentValue = value; + this.label = label; + this.depth = depth; + this.expanded = false; + this.render(); + } + + public connectedCallback(): void { + this.render(); + } + + private render(): void { + const described = describeValue(this.currentValue); + const expandable = described.entries !== null && described.entries.length > 0; + + const toggle = el('button', { + class: `value-toggle${expandable ? '' : ' is-leaf'}`, + text: expandable ? (this.expanded ? '▾' : '▸') : '•', + attrs: { + type: 'button', + 'aria-expanded': String(this.expanded), + ...(expandable ? {} : { disabled: 'true' }), + }, + on: expandable + ? { + click: () => { + this.expanded = !this.expanded; + this.render(); + }, + } + : {}, + }); + + const header = el('div', { class: 'value-row' }, [ + toggle, + this.label ? el('span', { class: 'value-key', text: this.label }) : null, + this.label ? el('span', { class: 'value-sep', text: ':' }) : null, + el('span', { class: `value-preview value-${described.kind}`, text: described.preview }), + ]); + + const children: Node[] = [header]; + if (expandable && this.expanded && described.entries) { + const list = el('div', { class: 'value-children' }); + // Depth guard: pathological nesting must not build an unbounded DOM. + if (this.depth >= 12) { + list.appendChild(el('div', { class: 'value-note', text: '(max depth reached)' })); + } else { + for (const entry of described.entries as readonly ValueEntry[]) { + const child = document.createElement('bq-value') as ValueView; + list.appendChild(child); + child.setValue(entry.value, entry.key, this.depth + 1); + } + } + children.push(list); + } + + replaceChildren(this, children); + } +} + +defineElement('bq-value', ValueView); diff --git a/src/panel/dom.ts b/src/panel/dom.ts new file mode 100644 index 0000000..65abc2f --- /dev/null +++ b/src/panel/dom.ts @@ -0,0 +1,64 @@ +/** + * Tiny DOM builder used by the panel views. + * + * Everything the panel renders is derived from the inspected page, which is + * untrusted: the builder therefore only ever sets **text**, never markup, so + * a component named `` is displayed rather than + * executed. `safeHtml` is used for the static chrome around it. + * + * @module panel/dom + */ +import { $ } from '@bquery/bquery/core'; + +/** Attributes and listeners accepted by {@link el}. */ +export interface ElementOptions { + readonly class?: string; + readonly text?: string; + readonly title?: string; + readonly attrs?: Readonly>; + /** + * Inline styles, applied through the CSSOM. + * + * A `style` *attribute* would be refused by the panel's CSP + * (`style-src 'self'`, no `unsafe-inline`); `style.setProperty` is not. + */ + readonly style?: Readonly>; + readonly on?: Readonly void>>; +} + +/** Create an element with text content, attributes and listeners. */ +export const el = ( + tag: K, + options: ElementOptions = {}, + children: readonly (Node | null)[] = [] +): HTMLElementTagNameMap[K] => { + const node = document.createElement(tag); + if (options.class) node.className = options.class; + if (options.text !== undefined) node.textContent = options.text; + if (options.title !== undefined) node.title = options.title; + for (const [name, value] of Object.entries(options.attrs ?? {})) node.setAttribute(name, value); + for (const [name, value] of Object.entries(options.style ?? {})) { + node.style.setProperty(name, value); + } + for (const [name, listener] of Object.entries(options.on ?? {})) { + node.addEventListener(name, listener); + } + for (const child of children) if (child) node.appendChild(child); + return node; +}; + +/** Replace an element's children in one shot. */ +export const replaceChildren = (host: Element, children: readonly (Node | null)[]): void => { + $(host as HTMLElement).empty(); + for (const child of children) if (child) host.appendChild(child); +}; + +/** Format a timestamp as `hh:mm:ss.mmm` in the local timezone. */ +export const formatTime = (timestamp: number): string => { + const date = new Date(timestamp); + const pad = (value: number, size = 2): string => String(value).padStart(size, '0'); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad( + date.getMilliseconds(), + 3 + )}`; +}; diff --git a/src/panel/settings.ts b/src/panel/settings.ts new file mode 100644 index 0000000..fc14e35 --- /dev/null +++ b/src/panel/settings.ts @@ -0,0 +1,73 @@ +/** + * Persisted panel preferences. + * + * Stored with `chrome.storage.local` (the `storage` permission grants no + * access to page data). Reads never reject: a missing or corrupt record + * falls back to the defaults so the panel always opens. + * + * @module panel/settings + */ +import { hasExtensionApi, extensionApi } from '../browser'; +import { clampBufferSize, DEFAULT_BUFFER_SIZE } from './timeline'; + +/** User-configurable panel preferences. */ +export interface PanelSettings { + /** Timeline ring-buffer capacity. */ + readonly bufferSize: number; + /** Poll interval of the eval transport, in ms. */ + readonly pollIntervalMs: number; + /** Try the live-streaming (port) transport when its permission is granted. */ + readonly preferLiveStreaming: boolean; +} + +/** Defaults used before anything is stored. */ +export const DEFAULT_SETTINGS: PanelSettings = { + bufferSize: DEFAULT_BUFFER_SIZE, + pollIntervalMs: 250, + preferLiveStreaming: false, +}; + +/** Storage key holding {@link PanelSettings}. */ +export const SETTINGS_KEY = 'bquery-devtools.settings'; + +/** Smallest / largest poll interval offered by the options page. */ +export const MIN_POLL_INTERVAL_MS = 50; +export const MAX_POLL_INTERVAL_MS = 5000; + +const clampPollInterval = (value: unknown): number => { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed)) return DEFAULT_SETTINGS.pollIntervalMs; + return Math.min(MAX_POLL_INTERVAL_MS, Math.max(MIN_POLL_INTERVAL_MS, Math.floor(parsed))); +}; + +/** Coerce an arbitrary stored record into valid settings. */ +export const normalizeSettings = (value: unknown): PanelSettings => { + if (typeof value !== 'object' || value === null) return DEFAULT_SETTINGS; + const record = value as Record; + return { + bufferSize: clampBufferSize(record['bufferSize'] ?? DEFAULT_SETTINGS.bufferSize), + pollIntervalMs: clampPollInterval(record['pollIntervalMs'] ?? DEFAULT_SETTINGS.pollIntervalMs), + preferLiveStreaming: record['preferLiveStreaming'] === true, + }; +}; + +/** Load the stored settings, falling back to {@link DEFAULT_SETTINGS}. */ +export const loadSettings = async (): Promise => { + if (!hasExtensionApi()) return DEFAULT_SETTINGS; + try { + const stored = await extensionApi().storage.local.get(SETTINGS_KEY); + return normalizeSettings(stored?.[SETTINGS_KEY]); + } catch { + return DEFAULT_SETTINGS; + } +}; + +/** Persist settings; resolves even when storage is unavailable. */ +export const saveSettings = async (settings: PanelSettings): Promise => { + if (!hasExtensionApi()) return; + try { + await extensionApi().storage.local.set({ [SETTINGS_KEY]: normalizeSettings(settings) }); + } catch { + // Nothing actionable: the panel keeps working with in-memory settings. + } +}; diff --git a/src/panel/state.ts b/src/panel/state.ts new file mode 100644 index 0000000..58a6118 --- /dev/null +++ b/src/panel/state.ts @@ -0,0 +1,232 @@ +/** + * Panel state container. + * + * Holds every piece of state the panel views render from, as bQuery signals, + * and owns the conversation with the {@link BridgeClient}. Views subscribe; + * they never talk to the bridge directly. + * + * @module panel/state + */ +import { computed, signal, type Signal } from '@bquery/bquery/reactive'; +import type { BridgeClient } from '../protocol/client'; +import type { BridgeCapability, ComponentTreeNode, TimelineEntry } from '../protocol/messages'; +import { + parseComponentTree, + parseSnapshot, + parseTimeline, + type ComponentView, + type SignalView, + type StoreView, +} from '../protocol/results'; +import { TimelineBuffer, type TimelineFilterState } from './timeline'; +import { reconstructAt, type Reconstruction, type TimeTravelBase } from './timeTravel'; + +/** How many entries the initial `getTimeline` request asks for. */ +const TIMELINE_SEED_LIMIT = 200; + +/** + * Identity of a timeline entry across the two ways it can reach the panel + * (streamed as an `event`, or returned by `getTimeline`). The framework does + * not assign ids, so the recorded fields are the identity. + */ +const entryKey = (entry: TimelineEntry): string => + `${entry.timestamp}|${entry.type}|${entry.detail}|${entry.source ?? ''}`; + +/** Panel state and the commands the views issue against it. */ +export class PanelState { + /** Component tree as last fetched. */ + public readonly tree: Signal = signal( + [] + ); + /** Flat component registry (tag → instance count). */ + public readonly components: Signal = signal( + [] + ); + /** Live signal snapshots. */ + public readonly signals: Signal = signal([]); + /** Live store snapshots. */ + public readonly stores: Signal = signal([]); + /** Search box contents for the component tree. */ + public readonly treeSearch: Signal = signal(''); + /** Currently selected tree path, as a dotted key. */ + public readonly selectedPath: Signal = signal(''); + /** Timeline filter. */ + public readonly timelineFilter: Signal = signal({ + types: new Set(), + search: '', + }); + /** Bumped whenever the timeline buffer changes, to drive re-renders. */ + public readonly timelineRevision: Signal = signal(0); + /** When `true`, streamed events are dropped instead of buffered. */ + public readonly paused: Signal = signal(false); + /** Index being replayed, or `null` while following live state. */ + public readonly timeTravelIndex: Signal = signal(null); + /** Last error surfaced to the user. */ + public readonly lastError: Signal = signal(''); + /** `true` while a refresh request is in flight. */ + public readonly loading: Signal = signal(false); + + /** The reconstruction for {@link timeTravelIndex}, or `null` when live. */ + public readonly reconstruction = computed(() => { + const index = this.timeTravelIndex.value; + if (index === null) return null; + // Touch the revision so the view recomputes as the buffer grows. + void this.timelineRevision.value; + return reconstructAt(this.base.value, this.buffer.all(), index); + }); + + private readonly client: BridgeClient; + private readonly buffer: TimelineBuffer; + /** Reactive so a refreshed snapshot re-bases an active replay. */ + private readonly base: Signal = signal({ + signals: [], + stores: [], + capturedAt: Date.now(), + }); + private disposers: Array<() => void> = []; + + constructor(client: BridgeClient, buffer: TimelineBuffer) { + this.client = client; + this.buffer = buffer; + } + + /** The bridge client backing this state. */ + public get bridge(): BridgeClient { + return this.client; + } + + /** Buffered timeline entries, oldest first. */ + public entries(): readonly TimelineEntry[] { + return this.buffer.all(); + } + + /** Entries evicted from the buffer since the last clear. */ + public droppedEntries(): number { + return this.buffer.dropped; + } + + /** Buffer capacity. */ + public bufferCapacity(): number { + return this.buffer.capacity; + } + + /** `true` when the page advertised `capability`. */ + public supports(capability: BridgeCapability): boolean { + return this.client.capabilities.value.has(capability); + } + + /** Start listening to the bridge; refetches on every (re)connect. */ + public start(): void { + this.disposers.push( + this.client.onReady(() => { + void this.refreshAll(); + }) + ); + this.disposers.push( + this.client.onEvent(entry => { + if (this.paused.value) return; + this.buffer.push(entry); + this.timelineRevision.value += 1; + }) + ); + this.client.start(); + } + + /** Stop listening; the client itself is disposed by the caller. */ + public dispose(): void { + for (const dispose of this.disposers) dispose(); + this.disposers = []; + } + + /** Refetch tree, snapshot and timeline seed. */ + public async refreshAll(): Promise { + this.loading.value = true; + try { + await Promise.all([this.refreshTree(), this.refreshSnapshot()]); + await this.seedTimeline(); + this.lastError.value = ''; + } catch (error) { + this.lastError.value = error instanceof Error ? error.message : String(error); + } finally { + this.loading.value = false; + } + } + + /** Refetch the component tree. */ + public async refreshTree(): Promise { + if (!this.supports('components')) return; + const result = parseComponentTree(await this.client.request('getComponentTree')); + this.tree.value = result.tree; + if (result.flat.length > 0) this.components.value = result.flat; + } + + /** Refetch signals, stores and components, and re-base time travel. */ + public async refreshSnapshot(): Promise { + const snapshot = parseSnapshot(await this.client.request('getSnapshot')); + if (!snapshot) return; + this.signals.value = snapshot.signals; + this.stores.value = snapshot.stores; + if (snapshot.components.length > 0) this.components.value = snapshot.components; + this.base.value = { + signals: snapshot.signals, + stores: snapshot.stores, + capturedAt: snapshot.exportedAt, + }; + } + + /** + * Seed the buffer from the page's own timeline. + * + * Called on connect only: afterwards the buffer is authoritative, because + * it also holds entries the page has already evicted from its own ring. + * + * Events streamed *while* the seed request is in flight are carried over + * rather than overwritten — a page that emits during the handshake would + * otherwise lose exactly the events the user was waiting for. + */ + public async seedTimeline(): Promise { + if (!this.supports('timeline')) return; + const streamed = [...this.buffer.all()]; + const entries = parseTimeline( + await this.client.request('getTimeline', { limit: TIMELINE_SEED_LIMIT }) + ); + const seeded = new Set(entries.map(entryKey)); + this.buffer.reset(entries); + this.buffer.extend(streamed.filter(entry => !seeded.has(entryKey(entry)))); + this.timelineRevision.value += 1; + } + + /** Drop every buffered entry and leave time travel. */ + public clearTimeline(): void { + this.buffer.clear(); + this.timeTravelIndex.value = null; + this.timelineRevision.value += 1; + } + + /** Change the ring-buffer capacity. */ + public setBufferSize(size: number): void { + this.buffer.resize(size); + const index = this.timeTravelIndex.value; + if (index !== null && index >= this.buffer.size) { + this.timeTravelIndex.value = this.buffer.size - 1; + } + this.timelineRevision.value += 1; + } + + /** Replay state as of `index`; pauses streaming so the view holds still. */ + public travelTo(index: number): void { + if (this.buffer.size === 0) return; + const clamped = Math.min(Math.max(index, 0), this.buffer.size - 1); + this.paused.value = true; + this.timeTravelIndex.value = clamped; + } + + /** Leave time travel and resume following live state. */ + public resumeLive(): void { + this.timeTravelIndex.value = null; + this.paused.value = false; + void this.refreshSnapshot().catch(() => { + // A failed refresh leaves the last known values on screen. + }); + } +} diff --git a/src/panel/timeTravel.ts b/src/panel/timeTravel.ts new file mode 100644 index 0000000..162183c --- /dev/null +++ b/src/panel/timeTravel.ts @@ -0,0 +1,207 @@ +/** + * Time travel over the reactive timeline. + * + * The bridge exposes primitives, not history: `getSnapshot` gives the state + * *now*, and `event` messages stream what changed afterwards. Time travel is + * therefore reconstructed on the panel side — take the snapshot captured at + * connect as the base, then replay recorded events up to a chosen point. + * + * Event payloads are app-defined (`payload?: unknown`), so replay is + * deliberately tolerant: a value that cannot be derived is reported as + * {@link UNKNOWN_VALUE} rather than guessed, and the UI marks it as such. The + * reconstruction never mutates the page — it is a read-only view of history. + * + * @module panel/timeTravel + */ +import type { TimelineEntry } from '../protocol/messages'; +import type { SignalView, StoreView } from '../protocol/results'; + +/** Marker for a value the replay could not derive from the recorded payload. */ +export const UNKNOWN_VALUE = Symbol('bquery-devtools/unknown-value'); + +/** One reconstructed signal at a point in time. */ +export interface ReconstructedSignal { + readonly label: string; + readonly value: unknown; + /** `true` when the value is the base snapshot's, untouched by replay. */ + readonly fromBase: boolean; + /** `true` when an event changed this signal but carried no usable payload. */ + readonly unresolved: boolean; +} + +/** One reconstructed store at a point in time. */ +export interface ReconstructedStore { + readonly id: string; + readonly state: Record; + readonly fromBase: boolean; + readonly unresolved: boolean; +} + +/** Result of {@link reconstructAt}. */ +export interface Reconstruction { + /** Index (inclusive) of the last replayed entry; `-1` for "base state". */ + readonly index: number; + /** Timestamp of that entry, or the snapshot time for the base state. */ + readonly timestamp: number; + readonly signals: readonly ReconstructedSignal[]; + readonly stores: readonly ReconstructedStore[]; + /** Number of replayed entries that changed something. */ + readonly appliedCount: number; + /** Number of replayed entries whose payload could not be interpreted. */ + readonly unresolvedCount: number; +} + +/** The base state time travel replays from. */ +export interface TimeTravelBase { + readonly signals: readonly SignalView[]; + readonly stores: readonly StoreView[]; + readonly capturedAt: number; +} + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Derive the new signal value carried by an entry. + * + * Recognized shapes, in order: `{ value }`, `{ next }`, `{ to }`, and finally + * the payload itself when it is not an object wrapper. Anything else is + * unknown — the framework does not mandate a payload shape. + */ +export const extractSignalValue = (entry: TimelineEntry): unknown => { + const payload: unknown = entry.payload; + if (payload === undefined) return UNKNOWN_VALUE; + if (isPlainObject(payload)) { + if ('value' in payload) return payload['value']; + if ('next' in payload) return payload['next']; + if ('to' in payload) return payload['to']; + return payload; + } + return payload; +}; + +/** + * Derive the state patch carried by a store entry. + * + * Recognized shapes: `{ patch }`, `{ state }`, `{ next }`, or a plain object + * payload used directly as the patch. + */ +export const extractStorePatch = (entry: TimelineEntry): Record | undefined => { + const payload: unknown = entry.payload; + if (!isPlainObject(payload)) return undefined; + for (const key of ['patch', 'state', 'next'] as const) { + const nested: unknown = payload[key]; + if (isPlainObject(nested)) return nested; + } + return payload; +}; + +/** `true` when this entry type participates in state reconstruction. */ +export const isReplayable = (entry: TimelineEntry): boolean => + entry.type === 'signal:update' || + entry.type === 'signal:create' || + entry.type === 'store:patch' || + entry.type === 'store:action'; + +interface SignalCell { + value: unknown; + fromBase: boolean; + unresolved: boolean; +} + +interface StoreCell { + state: Record; + fromBase: boolean; + unresolved: boolean; +} + +/** + * Reconstruct signal and store state as of `index` in `entries`. + * + * @param base State captured when the panel connected. + * @param entries Events recorded after the base, oldest first. + * @param index Inclusive index to replay up to; `-1` yields the base state, + * values past the end are clamped to the last entry. + */ +export const reconstructAt = ( + base: TimeTravelBase, + entries: readonly TimelineEntry[], + index: number +): Reconstruction => { + const upto = Math.min(index, entries.length - 1); + const signals = new Map(); + for (const signal of base.signals) { + signals.set(signal.label, { value: signal.value, fromBase: true, unresolved: false }); + } + const stores = new Map(); + for (const store of base.stores) { + stores.set(store.id, { state: { ...store.state }, fromBase: true, unresolved: false }); + } + + let appliedCount = 0; + let unresolvedCount = 0; + + for (let cursor = 0; cursor <= upto; cursor += 1) { + const entry = entries[cursor]; + if (!entry || !isReplayable(entry)) continue; + const key = entry.source ?? entry.detail; + if (!key) continue; + + if (entry.type === 'signal:update' || entry.type === 'signal:create') { + const value = extractSignalValue(entry); + const unresolved = value === UNKNOWN_VALUE; + if (unresolved) unresolvedCount += 1; + else appliedCount += 1; + const previous = signals.get(key); + signals.set(key, { + value: unresolved ? (previous?.value ?? UNKNOWN_VALUE) : value, + fromBase: false, + unresolved, + }); + continue; + } + + const patch = extractStorePatch(entry); + const previous = stores.get(key); + if (!patch) { + unresolvedCount += 1; + stores.set(key, { + state: previous?.state ?? {}, + fromBase: false, + unresolved: true, + }); + continue; + } + appliedCount += 1; + stores.set(key, { + state: { ...(previous?.state ?? {}), ...patch }, + fromBase: false, + unresolved: false, + }); + } + + const at = upto >= 0 ? entries[upto] : undefined; + + return { + index: upto, + timestamp: at?.timestamp ?? base.capturedAt, + appliedCount, + unresolvedCount, + signals: [...signals.entries()] + .map(([label, cell]) => ({ + label, + value: cell.value, + fromBase: cell.fromBase, + unresolved: cell.unresolved, + })) + .sort((left, right) => left.label.localeCompare(right.label)), + stores: [...stores.entries()] + .map(([id, cell]) => ({ + id, + state: cell.state, + fromBase: cell.fromBase, + unresolved: cell.unresolved, + })) + .sort((left, right) => left.id.localeCompare(right.id)), + }; +}; diff --git a/src/panel/timeline.ts b/src/panel/timeline.ts new file mode 100644 index 0000000..4cdf807 --- /dev/null +++ b/src/panel/timeline.ts @@ -0,0 +1,126 @@ +/** + * Timeline buffering and filtering. + * + * A busy app can emit thousands of reactive events per second, so the panel + * keeps a bounded ring buffer instead of an ever-growing array. The buffer + * size is user-configurable (see the options page); dropping the oldest + * entries is preferred over pausing the app or the panel. + * + * @module panel/timeline + */ +import type { TimelineEntry } from '../protocol/messages'; + +/** Filter applied to the buffered entries before rendering. */ +export interface TimelineFilterState { + /** Restrict to these event types; empty means "all types". */ + readonly types: ReadonlySet; + /** Case-insensitive substring match over `type`, `detail` and `source`. */ + readonly search: string; +} + +/** Smallest buffer the UI offers. */ +export const MIN_BUFFER_SIZE = 50; +/** Largest buffer the UI offers. */ +export const MAX_BUFFER_SIZE = 20000; +/** Buffer size used when nothing is configured. */ +export const DEFAULT_BUFFER_SIZE = 1000; + +/** Clamp an arbitrary (possibly persisted) value into the supported range. */ +export const clampBufferSize = (value: unknown): number => { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed)) return DEFAULT_BUFFER_SIZE; + return Math.min(MAX_BUFFER_SIZE, Math.max(MIN_BUFFER_SIZE, Math.floor(parsed))); +}; + +/** + * Bounded, append-only timeline buffer. + * + * Entries are held oldest-first, matching the order the framework records + * them, so index 0 is always the earliest retained event. + */ +export class TimelineBuffer { + private entries: TimelineEntry[] = []; + private limit: number; + private droppedCount = 0; + + constructor(limit: number = DEFAULT_BUFFER_SIZE) { + this.limit = clampBufferSize(limit); + } + + /** Current buffer capacity. */ + public get capacity(): number { + return this.limit; + } + + /** Number of entries evicted since the last {@link clear}. */ + public get dropped(): number { + return this.droppedCount; + } + + /** Number of retained entries. */ + public get size(): number { + return this.entries.length; + } + + /** All retained entries, oldest first. */ + public all(): readonly TimelineEntry[] { + return this.entries; + } + + /** Append one entry, evicting the oldest when the buffer is full. */ + public push(entry: TimelineEntry): void { + this.entries.push(entry); + this.trim(); + } + + /** Append many entries at once (used when seeding from `getTimeline`). */ + public extend(entries: readonly TimelineEntry[]): void { + for (const entry of entries) this.entries.push(entry); + this.trim(); + } + + /** Replace the buffer contents wholesale. */ + public reset(entries: readonly TimelineEntry[] = []): void { + this.entries = [...entries]; + this.droppedCount = 0; + this.trim(); + } + + /** Drop everything, including the eviction counter. */ + public clear(): void { + this.entries = []; + this.droppedCount = 0; + } + + /** Change the capacity, trimming immediately if it shrank. */ + public resize(limit: number): void { + this.limit = clampBufferSize(limit); + this.trim(); + } + + private trim(): void { + const excess = this.entries.length - this.limit; + if (excess > 0) { + this.entries.splice(0, excess); + this.droppedCount += excess; + } + } +} + +/** Every event type present in the given entries, sorted for stable display. */ +export const collectTypes = (entries: readonly TimelineEntry[]): string[] => + [...new Set(entries.map(entry => entry.type))].sort(); + +/** Apply a {@link TimelineFilterState} to buffered entries. */ +export const filterEntries = ( + entries: readonly TimelineEntry[], + filter: TimelineFilterState +): TimelineEntry[] => { + const search = filter.search.trim().toLowerCase(); + return entries.filter(entry => { + if (filter.types.size > 0 && !filter.types.has(entry.type)) return false; + if (!search) return true; + const haystack = `${entry.type} ${entry.detail} ${entry.source ?? ''}`.toLowerCase(); + return haystack.includes(search); + }); +}; diff --git a/src/panel/tree.ts b/src/panel/tree.ts new file mode 100644 index 0000000..1a79002 --- /dev/null +++ b/src/panel/tree.ts @@ -0,0 +1,132 @@ +/** + * Component-tree helpers: search/filter and in-page selection. + * + * Nodes are addressed by their **structural path** — the chain of indices + * into the serialized tree (`[2, 0, 1]` = third root, first child, second + * grandchild). The `id` the framework assigns is derived from DOM child + * indices and is not unique across sibling subtrees, so it cannot be used to + * address a node; the structural path always can. + * + * @module panel/tree + */ +import type { ComponentTreeNode } from '../protocol/messages'; + +/** A node together with the path that addresses it. */ +export interface FlatTreeNode { + readonly node: ComponentTreeNode; + readonly path: readonly number[]; + readonly depth: number; + /** `true` when this node matched the active search itself. */ + readonly matched: boolean; +} + +/** Serialize a path for use as a DOM id / dataset value. */ +export const pathKey = (path: readonly number[]): string => path.join('.'); + +/** Parse a path previously produced by {@link pathKey}. */ +export const parsePathKey = (key: string): number[] => + key + .split('.') + .filter(part => part !== '') + .map(part => Number.parseInt(part, 10)) + .filter(part => Number.isInteger(part) && part >= 0); + +const nodeMatches = (node: ComponentTreeNode, needle: string): boolean => { + if (node.tag.includes(needle)) return true; + for (const [name, value] of Object.entries(node.attrs)) { + if (name.toLowerCase().includes(needle) || value.toLowerCase().includes(needle)) return true; + } + return false; +}; + +/** + * Flatten the tree for rendering, applying an optional search. + * + * A node survives the search when it matches itself or has a matching + * descendant, so matches stay reachable through their ancestors. + */ +export const flattenTree = (nodes: readonly ComponentTreeNode[], search = ''): FlatTreeNode[] => { + const needle = search.trim().toLowerCase(); + const out: FlatTreeNode[] = []; + + const visit = (node: ComponentTreeNode, path: number[], depth: number): boolean => { + const selfMatch = needle === '' || nodeMatches(node, needle); + const start = out.length; + // Reserve this node's slot; it is dropped again if nothing below matched. + out.push({ node, path: [...path], depth, matched: needle !== '' && selfMatch }); + + let childMatch = false; + node.children.forEach((child, index) => { + if (visit(child, [...path, index], depth + 1)) childMatch = true; + }); + + if (needle !== '' && !selfMatch && !childMatch) { + out.length = start; + return false; + } + return true; + }; + + nodes.forEach((node, index) => visit(node, [index], 0)); + return out; +}; + +/** Look up a node by its structural path. */ +export const nodeAtPath = ( + nodes: readonly ComponentTreeNode[], + path: readonly number[] +): ComponentTreeNode | null => { + let current: ComponentTreeNode | undefined; + let level: readonly ComponentTreeNode[] = nodes; + for (const index of path) { + current = level[index]; + if (!current) return null; + level = current.children; + } + return current ?? null; +}; + +/** + * Build the expression that selects a node in the page's Elements panel. + * + * It re-walks the live DOM with the same rule `serializeComponentTree` uses + * (custom elements only, non-custom elements flattened away), follows the + * structural path, then scrolls the element into view and hands it to + * DevTools' `inspect()`. + * + * @returns The expression, or `null` for a malformed path. + */ +export const buildSelectExpression = (path: readonly number[]): string | null => { + if (path.length === 0) return null; + if (!path.every(index => Number.isInteger(index) && index >= 0)) return null; + const literal = JSON.stringify(path); + return `(function () { + function collect(parent) { + var out = []; + var children = parent.children; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + var nested = collect(child); + if (child.tagName.indexOf('-') !== -1) out.push({ el: child, children: nested }); + else out.push.apply(out, nested); + } + return out; + } + var path = ${literal}; + var level = document.body ? collect(document.body) : []; + var node = null; + for (var i = 0; i < path.length; i++) { + node = level[path[i]]; + if (!node) return null; + level = node.children; + } + if (!node) return null; + try { + node.el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } catch (error) { + /* scrollIntoView options are optional in older engines */ + } + if (typeof inspect === 'function') inspect(node.el); + return node.el.tagName.toLowerCase(); +})()`; +}; diff --git a/src/panel/valueTree.ts b/src/panel/valueTree.ts new file mode 100644 index 0000000..182d331 --- /dev/null +++ b/src/panel/valueTree.ts @@ -0,0 +1,141 @@ +/** + * Value formatting and drill-down. + * + * Signal and store values arrive as arbitrary JSON from the inspected page, + * so rendering is done from a *described* model rather than from the raw + * value: previews are truncated, cycles are broken, and every string that + * reaches the DOM does so through a text sink. + * + * @module panel/valueTree + */ +import { UNKNOWN_VALUE } from './timeTravel'; + +/** Broad category of a described value, used for styling. */ +export type ValueKind = + | 'string' + | 'number' + | 'boolean' + | 'null' + | 'undefined' + | 'array' + | 'object' + | 'function' + | 'unknown'; + +/** One expandable child of a described value. */ +export interface ValueEntry { + readonly key: string; + readonly value: unknown; +} + +/** A value as the panel renders it. */ +export interface DescribedValue { + readonly kind: ValueKind; + /** Single-line, length-capped preview. */ + readonly preview: string; + /** Children, or `null` when the value is a leaf. */ + readonly entries: readonly ValueEntry[] | null; +} + +/** Longest preview string rendered before truncation. */ +export const PREVIEW_LIMIT = 120; + +/** Most children listed for one container. */ +export const ENTRY_LIMIT = 100; + +const truncate = (text: string, limit = PREVIEW_LIMIT): string => + text.length > limit ? `${text.slice(0, limit - 1)}…` : text; + +const previewPrimitive = (value: unknown): string => { + if (typeof value === 'string') return truncate(JSON.stringify(value)); + if (typeof value === 'bigint') return `${value.toString()}n`; + return String(value); +}; + +/** + * Describe an arbitrary value for display. + * + * Never throws: exotic values (getters that throw, revoked proxies, cyclic + * structures) degrade to an `unknown` description instead of breaking the + * panel. + */ +export const describeValue = (value: unknown): DescribedValue => { + if (value === UNKNOWN_VALUE) { + return { kind: 'unknown', preview: '(not recorded)', entries: null }; + } + if (value === null) return { kind: 'null', preview: 'null', entries: null }; + switch (typeof value) { + case 'undefined': + return { kind: 'undefined', preview: 'undefined', entries: null }; + case 'string': + return { kind: 'string', preview: previewPrimitive(value), entries: null }; + case 'number': + return { kind: 'number', preview: previewPrimitive(value), entries: null }; + case 'boolean': + return { kind: 'boolean', preview: previewPrimitive(value), entries: null }; + case 'bigint': + return { kind: 'number', preview: previewPrimitive(value), entries: null }; + case 'symbol': + return { kind: 'unknown', preview: String(value), entries: null }; + case 'function': + return { kind: 'function', preview: 'ƒ ()', entries: null }; + default: + break; + } + + try { + if (Array.isArray(value)) { + const entries = value + .slice(0, ENTRY_LIMIT) + .map((item, index) => ({ key: String(index), value: item })); + return { + kind: 'array', + preview: truncate( + `Array(${value.length}) [${value.slice(0, 5).map(shortPreview).join(', ')}${value.length > 5 ? ', …' : ''}]` + ), + entries, + }; + } + const record = value as Record; + const keys = Object.keys(record).slice(0, ENTRY_LIMIT); + const entries = keys.map(key => ({ key, value: record[key] })); + const head = keys + .slice(0, 5) + .map(key => `${key}: ${shortPreview(record[key])}`) + .join(', '); + return { + kind: 'object', + preview: truncate(`{${head}${keys.length > 5 ? ', …' : ''}}`), + entries, + }; + } catch { + return { kind: 'unknown', preview: '(unreadable)', entries: null }; + } +}; + +/** A very short preview used inside container previews. */ +export const shortPreview = (value: unknown): string => { + if (value === UNKNOWN_VALUE) return '?'; + if (value === null) return 'null'; + switch (typeof value) { + case 'undefined': + return 'undefined'; + case 'string': + return truncate(JSON.stringify(value), 24); + case 'function': + return 'ƒ'; + case 'object': + return Array.isArray(value) ? `Array(${(value as unknown[]).length})` : '{…}'; + default: + return truncate(String(value), 24); + } +}; + +/** + * `true` when the value can be expanded in the UI. + * + * An empty container is a leaf: there is nothing to drill into, and rendering + * a toggle that opens onto nothing is worse than rendering none. + */ +export const isExpandable = (value: unknown): boolean => + (describeValue(value).entries?.length ?? 0) > 0; diff --git a/src/protocol/client.ts b/src/protocol/client.ts new file mode 100644 index 0000000..560117a --- /dev/null +++ b/src/protocol/client.ts @@ -0,0 +1,280 @@ +/** + * Typed bridge client — the panel's half of the v1 protocol. + * + * Responsibilities: + * - handshake: send `hello`, retry until the page answers `init` + * (the panel is routinely open *before* the app boots); + * - capability negotiation from the `init` payload; + * - request/response correlation with per-request timeouts, so a page that + * never answers cannot leak pending promises forever; + * - reconnection: a dropped transport (service-worker sleep, navigation) + * restarts the handshake with backoff and rejects everything in flight. + * + * @module protocol/client + */ +import { signal, type Signal } from '@bquery/bquery/reactive'; +import { + helloMessage, + negotiateCapabilities, + parseOutbound, + requestMessage, + type BridgeCapability, + type BridgeMethodName, + type TimelineEntry, +} from './messages'; +import type { BridgeTransport, TransportStatus } from './transport'; + +/** Connection state as displayed by the panel. */ +export type ConnectionState = + 'idle' | 'connecting' | 'waiting-for-page' | 'connected' | 'disconnected' | 'error'; + +/** Options for {@link BridgeClient}. */ +export interface BridgeClientOptions { + /** How long a single request may stay unanswered. @default 5000 */ + readonly requestTimeoutMs?: number; + /** Delay between `hello` retries while the page has not answered. @default 1000 */ + readonly helloIntervalMs?: number; + /** Injectable timers, so tests do not have to wait in real time. */ + readonly setTimeout?: (handler: () => void, ms: number) => number; + readonly clearTimeout?: (handle: number) => void; +} + +/** Raised when a request outlives {@link BridgeClientOptions.requestTimeoutMs}. */ +export class BridgeTimeoutError extends Error { + constructor(method: string, ms: number) { + super(`bQuery DevTools: "${method}" did not answer within ${ms}ms`); + this.name = 'BridgeTimeoutError'; + } +} + +/** Raised when the page answers a request with an error string. */ +export class BridgeMethodError extends Error { + constructor(method: string, reason: string) { + super(`bQuery DevTools: "${method}" failed: ${reason}`); + this.name = 'BridgeMethodError'; + } +} + +interface PendingRequest { + readonly method: string; + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; + readonly timer: number; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 5000; +const DEFAULT_HELLO_INTERVAL_MS = 1000; + +/** + * Drives one bridge conversation over a {@link BridgeTransport}. + * + * The client owns no UI; the panel subscribes to {@link BridgeClient.state}, + * {@link BridgeClient.capabilities} and {@link BridgeClient.onEvent}. + */ +export class BridgeClient { + /** Current connection state (reactive). */ + public readonly state: Signal = signal('idle'); + /** Capabilities advertised by the page and understood here (reactive). */ + public readonly capabilities: Signal> = signal< + ReadonlySet + >(new Set()); + /** Human-readable detail for the current state (reactive). */ + public readonly detail: Signal = signal(''); + + private readonly transport: BridgeTransport; + private readonly requestTimeoutMs: number; + private readonly helloIntervalMs: number; + private readonly setTimer: (handler: () => void, ms: number) => number; + private readonly clearTimer: (handle: number) => void; + + private readonly pending = new Map(); + private readonly eventListeners = new Set<(entry: TimelineEntry) => void>(); + private readonly readyListeners = new Set< + (capabilities: ReadonlySet) => void + >(); + + private nextRequestId = 1; + private helloTimer: number | null = null; + private disposed = false; + private started = false; + + constructor(transport: BridgeTransport, options: BridgeClientOptions = {}) { + this.transport = transport; + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + this.helloIntervalMs = options.helloIntervalMs ?? DEFAULT_HELLO_INTERVAL_MS; + this.setTimer = + options.setTimeout ?? + ((handler, ms) => globalThis.setTimeout(handler, ms) as unknown as number); + this.clearTimer = options.clearTimeout ?? (handle => globalThis.clearTimeout(handle)); + } + + /** Which transport is in use — `'eval'` or `'port'`. */ + public get transportKind(): BridgeTransport['kind'] { + return this.transport.kind; + } + + /** Subscribe to streamed timeline entries. Returns an unsubscribe function. */ + public onEvent(listener: (entry: TimelineEntry) => void): () => void { + this.eventListeners.add(listener); + return () => this.eventListeners.delete(listener); + } + + /** + * Subscribe to successful handshakes. Fires on every (re)connect, so + * consumers can refetch after a page navigation or a transport restart. + */ + public onReady(listener: (capabilities: ReadonlySet) => void): () => void { + this.readyListeners.add(listener); + return () => this.readyListeners.delete(listener); + } + + /** Start the transport and the handshake. */ + public start(): void { + if (this.started || this.disposed) return; + this.started = true; + this.state.value = 'connecting'; + this.transport.start({ + onMessage: data => this.handleMessage(data), + onStatus: status => this.handleStatus(status), + }); + } + + /** + * Restart the handshake without recreating the transport. + * + * Used on page navigation: the page-side bridge is gone, so anything in + * flight is rejected and `hello` starts over. + */ + public resetHandshake(reason = 'page navigated'): void { + if (this.disposed) return; + this.rejectAllPending(new Error(`bQuery DevTools: ${reason}`)); + this.capabilities.value = new Set(); + this.state.value = 'waiting-for-page'; + this.detail.value = reason; + this.scheduleHello(true); + } + + /** Invoke a bridge method and await its result. */ + public async request( + method: BridgeMethodName | (string & {}), + params?: unknown + ): Promise { + if (this.disposed) throw new Error('bQuery DevTools: client disposed'); + const id = this.nextRequestId++; + return new Promise((resolve, reject) => { + const timer = this.setTimer(() => { + this.pending.delete(id); + reject(new BridgeTimeoutError(method, this.requestTimeoutMs)); + }, this.requestTimeoutMs); + this.pending.set(id, { + method, + resolve: value => resolve(value as T), + reject, + timer, + }); + this.transport.send(requestMessage(id, method, params)); + }); + } + + /** Tear down the client and its transport. */ + public dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.cancelHello(); + this.rejectAllPending(new Error('bQuery DevTools: client disposed')); + this.eventListeners.clear(); + this.readyListeners.clear(); + this.transport.dispose(); + this.state.value = 'idle'; + } + + private handleStatus(status: TransportStatus): void { + if (this.disposed) return; + switch (status.kind) { + case 'connecting': + this.state.value = 'connecting'; + return; + case 'open': + // The transport is up; the *page* still has to answer `hello`. + this.state.value = 'waiting-for-page'; + this.detail.value = ''; + this.scheduleHello(true); + return; + case 'closed': + this.cancelHello(); + this.rejectAllPending(new Error(`bQuery DevTools: ${status.reason}`)); + this.capabilities.value = new Set(); + this.state.value = 'disconnected'; + this.detail.value = status.reason; + return; + case 'error': + this.cancelHello(); + this.rejectAllPending(new Error(`bQuery DevTools: ${status.reason}`)); + this.state.value = 'error'; + this.detail.value = status.reason; + return; + } + } + + private handleMessage(data: unknown): void { + if (this.disposed) return; + const message = parseOutbound(data); + if (!message) return; + + switch (message.kind) { + case 'init': { + const negotiated = negotiateCapabilities(message.capabilities); + this.cancelHello(); + this.capabilities.value = negotiated; + this.state.value = 'connected'; + this.detail.value = ''; + for (const listener of this.readyListeners) listener(negotiated); + return; + } + case 'response': { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + this.clearTimer(pending.timer); + if (message.error !== undefined) { + pending.reject(new BridgeMethodError(pending.method, message.error)); + } else { + pending.resolve(message.result); + } + return; + } + case 'event': { + // A streamed event also proves the page is alive: stop retrying hello. + if (this.state.value !== 'connected') this.state.value = 'connected'; + for (const listener of this.eventListeners) listener(message.entry); + return; + } + } + } + + private scheduleHello(immediate: boolean): void { + this.cancelHello(); + const fire = (): void => { + if (this.disposed || this.state.value === 'connected') return; + this.transport.send(helloMessage()); + this.helloTimer = this.setTimer(fire, this.helloIntervalMs); + }; + if (immediate) fire(); + else this.helloTimer = this.setTimer(fire, this.helloIntervalMs); + } + + private cancelHello(): void { + if (this.helloTimer !== null) { + this.clearTimer(this.helloTimer); + this.helloTimer = null; + } + } + + private rejectAllPending(error: Error): void { + for (const [, pending] of this.pending) { + this.clearTimer(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } +} diff --git a/src/protocol/envelope.ts b/src/protocol/envelope.ts new file mode 100644 index 0000000..5cd7e32 --- /dev/null +++ b/src/protocol/envelope.ts @@ -0,0 +1,146 @@ +/** + * Envelopes for the *internal* hop of the port transport + * (panel ⇄ background ⇄ content script). + * + * The bridge protocol itself is carried as an opaque `payload`; these + * envelopes only describe routing. Every panel → background envelope after + * the handshake carries the session `token` the background issued, so a + * message that did not come from the panel this port belongs to is dropped + * rather than routed into someone's page. + * + * @module protocol/envelope + */ + +/** Port name the panel connects with; the background rejects any other. */ +export const PANEL_PORT_NAME = 'bquery-devtools-panel'; + +/** Discriminator shared by every internal envelope. */ +export const ENVELOPE_SOURCE = 'bquery-devtools-internal' as const; + +/** Panel → background: claim the inspected tab and open the route. */ +export interface AttachEnvelope { + source: typeof ENVELOPE_SOURCE; + type: 'attach'; + tabId: number; +} + +/** Background → panel: the route is open; here is the session token. */ +export interface AttachedEnvelope { + source: typeof ENVELOPE_SOURCE; + type: 'attached'; + token: string; + tabId: number; +} + +/** Background → panel: the route could not be opened. */ +export interface AttachFailedEnvelope { + source: typeof ENVELOPE_SOURCE; + type: 'attach-failed'; + reason: string; +} + +/** Panel → background → content script: one bridge message for the page. */ +export interface ToPageEnvelope { + source: typeof ENVELOPE_SOURCE; + type: 'to-page'; + token: string; + payload: unknown; +} + +/** Content script → background → panel: one bridge message from the page. */ +export interface FromPageEnvelope { + source: typeof ENVELOPE_SOURCE; + type: 'from-page'; + payload: unknown; +} + +/** Panel → background: (re)inject the content script into the inspected tab. */ +export interface InjectEnvelope { + source: typeof ENVELOPE_SOURCE; + type: 'inject'; + token: string; +} + +/** Background → panel: result of an {@link InjectEnvelope}. */ +export interface InjectResultEnvelope { + source: typeof ENVELOPE_SOURCE; + type: 'inject-result'; + ok: boolean; + reason?: string; +} + +/** Anything the panel sends over its port. */ +export type PanelEnvelope = AttachEnvelope | ToPageEnvelope | InjectEnvelope; + +/** Anything the background sends back over a panel port. */ +export type BackgroundEnvelope = + AttachedEnvelope | AttachFailedEnvelope | FromPageEnvelope | InjectResultEnvelope; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +/** Narrow an untrusted value to a panel → background envelope. */ +export const parsePanelEnvelope = (value: unknown): PanelEnvelope | null => { + if (!isRecord(value) || value['source'] !== ENVELOPE_SOURCE) return null; + switch (value['type']) { + case 'attach': + return typeof value['tabId'] === 'number' + ? { source: ENVELOPE_SOURCE, type: 'attach', tabId: value['tabId'] } + : null; + case 'to-page': + return typeof value['token'] === 'string' + ? { + source: ENVELOPE_SOURCE, + type: 'to-page', + token: value['token'], + payload: value['payload'], + } + : null; + case 'inject': + return typeof value['token'] === 'string' + ? { source: ENVELOPE_SOURCE, type: 'inject', token: value['token'] } + : null; + default: + return null; + } +}; + +/** Narrow an untrusted value to a background → panel envelope. */ +export const parseBackgroundEnvelope = (value: unknown): BackgroundEnvelope | null => { + if (!isRecord(value) || value['source'] !== ENVELOPE_SOURCE) return null; + switch (value['type']) { + case 'attached': + return typeof value['token'] === 'string' && typeof value['tabId'] === 'number' + ? { + source: ENVELOPE_SOURCE, + type: 'attached', + token: value['token'], + tabId: value['tabId'], + } + : null; + case 'attach-failed': + return { + source: ENVELOPE_SOURCE, + type: 'attach-failed', + reason: typeof value['reason'] === 'string' ? value['reason'] : 'unknown error', + }; + case 'from-page': + return { source: ENVELOPE_SOURCE, type: 'from-page', payload: value['payload'] }; + case 'inject-result': + return { + source: ENVELOPE_SOURCE, + type: 'inject-result', + ok: value['ok'] === true, + ...(typeof value['reason'] === 'string' ? { reason: value['reason'] } : {}), + }; + default: + return null; + } +}; + +/** Narrow an untrusted value to a content-script → background envelope. */ +export const parseContentEnvelope = (value: unknown): FromPageEnvelope | null => { + if (!isRecord(value) || value['source'] !== ENVELOPE_SOURCE) return null; + if (value['type'] !== 'from-page') return null; + return { source: ENVELOPE_SOURCE, type: 'from-page', payload: value['payload'] }; +}; diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts new file mode 100644 index 0000000..d8eb296 --- /dev/null +++ b/src/protocol/messages.ts @@ -0,0 +1,212 @@ +/** + * Typed mirror of the stable bQuery DevTools bridge protocol (v1). + * + * The wire contract itself lives in `@bquery/bquery/devtools` + * (`createBridgeServer` / `connectDevtoolsBridge`). This module re-states it + * for the *panel* side of the wire, with two deliberate differences: + * + * 1. **No runtime import.** Only `typeof import(...)` type queries are used, + * so the page-side bridge runtime is never bundled into the extension — + * while the compiler still fails the build if the published protocol + * version or capability list changes underneath us. + * 2. **Validation, not casts.** Everything arriving from the inspected page + * is attacker-controlled. `parseOutbound` narrows unknown input to a + * well-formed message or returns `null`; nothing else in the panel may + * assume a shape it did not check. + * + * @module protocol/messages + */ +import type { ComponentTreeNode, TimelineEntry } from '@bquery/bquery/devtools'; + +/** + * Protocol version spoken by this panel. + * + * Typed as the published `BRIDGE_PROTOCOL_VERSION` literal, so bumping the + * protocol upstream turns into a compile error here instead of a silent + * runtime mismatch. + */ +export const BRIDGE_PROTOCOL_VERSION: typeof import('@bquery/bquery/devtools').BRIDGE_PROTOCOL_VERSION = 1; + +/** Shared `source` discriminator carried by every bridge message. */ +export const BRIDGE_SOURCE = 'bquery-devtools' as const; + +/** A capability the inspected page can advertise in its `init` handshake. */ +export type BridgeCapability = + (typeof import('@bquery/bquery/devtools').BRIDGE_CAPABILITIES)[number]; + +/** Every capability this panel knows how to make use of. */ +export const KNOWN_CAPABILITIES: readonly BridgeCapability[] = [ + 'signals', + 'stores', + 'components', + 'timeline', + 'time-travel', +]; + +/** Built-in bridge methods (see `createBridgeServer`). */ +export type BridgeMethodName = 'ping' | 'getSnapshot' | 'getTimeline' | 'getComponentTree'; + +export type { ComponentTreeNode, TimelineEntry }; + +/** Panel → page: announce the panel; the page answers with `init`. */ +export interface HelloMessage { + source: typeof BRIDGE_SOURCE; + channel: 'panel'; + v: number; + kind: 'hello'; +} + +/** Panel → page: invoke a bridge method. */ +export interface RequestMessage { + source: typeof BRIDGE_SOURCE; + channel: 'panel'; + v: number; + kind: 'request'; + id: number; + method: string; + params?: unknown; +} + +/** Anything the panel puts on the wire. */ +export type InboundMessage = HelloMessage | RequestMessage; + +/** Page → panel: handshake carrying the advertised capabilities. */ +export interface InitMessage { + source: typeof BRIDGE_SOURCE; + channel: 'page'; + v: number; + kind: 'init'; + capabilities: readonly string[]; +} + +/** Page → panel: the answer to one {@link RequestMessage}. */ +export interface ResponseMessage { + source: typeof BRIDGE_SOURCE; + channel: 'page'; + v: number; + kind: 'response'; + id: number; + result?: unknown; + error?: string; +} + +/** Page → panel: a streamed timeline entry. */ +export interface EventMessage { + source: typeof BRIDGE_SOURCE; + channel: 'page'; + v: number; + kind: 'event'; + entry: TimelineEntry; +} + +/** Anything the page may put on the wire. */ +export type OutboundMessage = InitMessage | ResponseMessage | EventMessage; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +/** Build the `hello` handshake message. */ +export const helloMessage = (): HelloMessage => ({ + source: BRIDGE_SOURCE, + channel: 'panel', + v: BRIDGE_PROTOCOL_VERSION, + kind: 'hello', +}); + +/** Build a `request` message. */ +export const requestMessage = (id: number, method: string, params?: unknown): RequestMessage => ({ + source: BRIDGE_SOURCE, + channel: 'panel', + v: BRIDGE_PROTOCOL_VERSION, + kind: 'request', + id, + method, + ...(params !== undefined ? { params } : {}), +}); + +/** + * Normalize a timeline entry coming off the wire. + * + * Only the fields the protocol defines survive; `payload` is kept as opaque + * `unknown` (it is app-defined) and every displayed field is coerced to a + * primitive so the renderer can never be handed an exotic object. + */ +const parseTimelineEntry = (value: unknown): TimelineEntry | null => { + if (!isRecord(value)) return null; + if (typeof value['type'] !== 'string') return null; + const timestamp = typeof value['timestamp'] === 'number' ? value['timestamp'] : Date.now(); + const detail = + typeof value['detail'] === 'string' ? value['detail'] : String(value['detail'] ?? ''); + const entry: Record = { + timestamp, + type: value['type'], + detail, + }; + if (value['payload'] !== undefined) entry['payload'] = value['payload']; + if (typeof value['source'] === 'string') entry['source'] = value['source']; + if (typeof value['duration'] === 'number') entry['duration'] = value['duration']; + return entry as unknown as TimelineEntry; +}; + +/** + * Narrow an untrusted value to a well-formed page → panel message. + * + * Returns `null` for anything that is not a fully-formed message of a kind + * this panel understands — including messages from a different protocol + * version, which are rejected rather than best-effort parsed. + */ +export const parseOutbound = (data: unknown): OutboundMessage | null => { + if (!isRecord(data)) return null; + if (data['source'] !== BRIDGE_SOURCE || data['channel'] !== 'page') return null; + if (data['v'] !== BRIDGE_PROTOCOL_VERSION) return null; + + switch (data['kind']) { + case 'init': { + const capabilities = Array.isArray(data['capabilities']) + ? data['capabilities'].filter((entry): entry is string => typeof entry === 'string') + : []; + return { + source: BRIDGE_SOURCE, + channel: 'page', + v: BRIDGE_PROTOCOL_VERSION, + kind: 'init', + capabilities, + }; + } + case 'response': { + if (typeof data['id'] !== 'number') return null; + const message: ResponseMessage = { + source: BRIDGE_SOURCE, + channel: 'page', + v: BRIDGE_PROTOCOL_VERSION, + kind: 'response', + id: data['id'], + }; + if (typeof data['error'] === 'string') return { ...message, error: data['error'] }; + return { ...message, result: data['result'] }; + } + case 'event': { + const entry = parseTimelineEntry(data['entry']); + if (!entry) return null; + return { + source: BRIDGE_SOURCE, + channel: 'page', + v: BRIDGE_PROTOCOL_VERSION, + kind: 'event', + entry, + }; + } + default: + return null; + } +}; + +/** Keep only the capabilities this panel actually implements a view for. */ +export const negotiateCapabilities = (advertised: readonly string[]): Set => { + const known = new Set(KNOWN_CAPABILITIES); + const out = new Set(); + for (const capability of advertised) { + if (known.has(capability)) out.add(capability as BridgeCapability); + } + return out; +}; diff --git a/src/protocol/results.ts b/src/protocol/results.ts new file mode 100644 index 0000000..6cd2223 --- /dev/null +++ b/src/protocol/results.ts @@ -0,0 +1,167 @@ +/** + * Validators for bridge *method results*. + * + * `getSnapshot`, `getTimeline` and `getComponentTree` all return data shaped + * by the inspected page. The page is untrusted, so every result is narrowed + * here before it reaches panel state; malformed members are dropped rather + * than rendered. + * + * @module protocol/results + */ +import type { ComponentTreeNode, TimelineEntry } from './messages'; + +/** A signal as displayed by the panel. */ +export interface SignalView { + readonly label: string; + readonly value: unknown; + readonly subscriberCount: number; +} + +/** A store as displayed by the panel. */ +export interface StoreView { + readonly id: string; + readonly state: Record; +} + +/** A registered component (flat view) as displayed by the panel. */ +export interface ComponentView { + readonly tagName: string; + readonly instanceCount: number; +} + +/** Normalized `getSnapshot` result. */ +export interface SnapshotView { + readonly signals: readonly SignalView[]; + readonly stores: readonly StoreView[]; + readonly components: readonly ComponentView[]; + readonly timeline: readonly TimelineEntry[]; + readonly exportedAt: number; +} + +/** Normalized `getComponentTree` result. */ +export interface ComponentTreeView { + readonly tree: readonly ComponentTreeNode[]; + readonly flat: readonly ComponentView[]; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const toNumber = (value: unknown, fallback = 0): number => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + +const toStringValue = (value: unknown, fallback = ''): string => + typeof value === 'string' ? value : fallback; + +/** Narrow one signal snapshot; `null` when it is unusable. */ +export const parseSignal = (value: unknown): SignalView | null => { + if (!isRecord(value)) return null; + const label = toStringValue(value['label'], toStringValue(value['id'], '(unlabeled)')); + return { + label, + value: value['value'], + subscriberCount: toNumber(value['subscriberCount']), + }; +}; + +/** Narrow one store snapshot; `null` when it is unusable. */ +export const parseStore = (value: unknown): StoreView | null => { + if (!isRecord(value)) return null; + const id = toStringValue(value['id']); + if (!id) return null; + const state = isRecord(value['state']) ? value['state'] : {}; + return { id, state }; +}; + +/** Narrow one component snapshot; `null` when it is unusable. */ +export const parseComponent = (value: unknown): ComponentView | null => { + if (!isRecord(value)) return null; + const tagName = toStringValue(value['tagName']); + if (!tagName) return null; + return { tagName, instanceCount: toNumber(value['instanceCount']) }; +}; + +const parseArray = (value: unknown, parse: (entry: unknown) => T | null): T[] => { + if (!Array.isArray(value)) return []; + const out: T[] = []; + for (const entry of value) { + const parsed = parse(entry); + if (parsed) out.push(parsed); + } + return out; +}; + +/** Narrow one timeline entry; `null` when it is unusable. */ +export const parseEntry = (value: unknown): TimelineEntry | null => { + if (!isRecord(value)) return null; + if (typeof value['type'] !== 'string') return null; + const entry: Record = { + timestamp: toNumber(value['timestamp'], Date.now()), + type: value['type'], + detail: toStringValue(value['detail']), + }; + if (value['payload'] !== undefined) entry['payload'] = value['payload']; + if (typeof value['source'] === 'string') entry['source'] = value['source']; + if (typeof value['duration'] === 'number') entry['duration'] = value['duration']; + return entry as unknown as TimelineEntry; +}; + +/** Narrow a `getTimeline` result. */ +export const parseTimeline = (value: unknown): TimelineEntry[] => parseArray(value, parseEntry); + +/** + * Narrow a `DevtoolsSnapshot` as produced by `exportDevtoolsSnapshot()`. + * + * The nested `state.timeline` is lifted to the top level so consumers do not + * have to know where the framework happens to keep it. + */ +export const parseSnapshot = (value: unknown): SnapshotView | null => { + if (!isRecord(value)) return null; + const state = isRecord(value['state']) ? value['state'] : {}; + return { + signals: parseArray(value['signals'], parseSignal), + stores: parseArray(value['stores'], parseStore), + components: parseArray(value['components'], parseComponent), + timeline: parseTimeline(state['timeline']), + exportedAt: toNumber(value['exportedAt'], Date.now()), + }; +}; + +/** + * Narrow one component-tree node, recursively. + * + * Depth is capped so a hostile (or merely pathological) page cannot blow the + * stack of the panel with a deeply self-nested tree. + */ +const parseTreeNode = (value: unknown, depth = 0): ComponentTreeNode | null => { + if (depth > 100 || !isRecord(value)) return null; + const tag = toStringValue(value['tag']); + if (!tag) return null; + const attrs: Record = {}; + if (isRecord(value['attrs'])) { + for (const [key, attrValue] of Object.entries(value['attrs'])) { + if (typeof attrValue === 'string') attrs[key] = attrValue; + } + } + const children: ComponentTreeNode[] = []; + if (Array.isArray(value['children'])) { + for (const child of value['children']) { + const parsed = parseTreeNode(child, depth + 1); + if (parsed) children.push(parsed); + } + } + return { tag, id: toStringValue(value['id']), attrs, children }; +}; + +/** Narrow a `getComponentTree` result. */ +export const parseComponentTree = (value: unknown): ComponentTreeView => { + if (!isRecord(value)) return { tree: [], flat: [] }; + const tree: ComponentTreeNode[] = []; + if (Array.isArray(value['tree'])) { + for (const node of value['tree']) { + const parsed = parseTreeNode(node); + if (parsed) tree.push(parsed); + } + } + return { tree, flat: parseArray(value['flat'], parseComponent) }; +}; diff --git a/src/protocol/transport.ts b/src/protocol/transport.ts new file mode 100644 index 0000000..635e954 --- /dev/null +++ b/src/protocol/transport.ts @@ -0,0 +1,45 @@ +/** + * Transport abstraction for the panel side of the bridge. + * + * Two transports implement this interface: + * + * - {@link ../transports/evalTransport | EvalTransport} — the default. Talks to + * the page through `chrome.devtools.inspectedWindow.eval`, which needs **no + * host permission at all**, and polls for page → panel messages. + * - {@link ../transports/portTransport | PortTransport} — opt-in live + * streaming through an injected content script and the background router. + * Costs one origin permission, granted per site by the user. + * + * Keeping both behind one interface is what lets the extension ship with an + * empty `host_permissions` list and still offer push-based streaming. + * + * @module protocol/transport + */ +import type { InboundMessage } from './messages'; + +/** Lifecycle state of a transport. */ +export type TransportStatus = + | { kind: 'connecting' } + | { kind: 'open' } + | { kind: 'closed'; reason: string } + | { kind: 'error'; reason: string }; + +/** Callbacks a transport reports back into the client. */ +export interface TransportHandlers { + /** One raw, still-untrusted message from the page. */ + readonly onMessage: (data: unknown) => void; + /** Transport lifecycle change. */ + readonly onStatus: (status: TransportStatus) => void; +} + +/** A panel ⇄ page message channel. */ +export interface BridgeTransport { + /** Which transport this is; surfaced in the panel status bar. */ + readonly kind: 'eval' | 'port'; + /** Begin connecting. Safe to call once per instance. */ + start(handlers: TransportHandlers): void; + /** Put one panel → page message on the wire. */ + send(message: InboundMessage): void; + /** Tear everything down; the instance is unusable afterwards. */ + dispose(): void; +} diff --git a/src/sass/_content.sass b/src/sass/_content.sass deleted file mode 100644 index 78b7aea..0000000 --- a/src/sass/_content.sass +++ /dev/null @@ -1,6 +0,0 @@ -@import mixin - -#textbox - text-align: left - padding: 2rem - @include noselect \ No newline at end of file diff --git a/src/sass/_mixin.sass b/src/sass/_mixin.sass index ddd1d41..e1d87d0 100644 --- a/src/sass/_mixin.sass +++ b/src/sass/_mixin.sass @@ -1,93 +1,17 @@ @import "root" -@mixin respond-to($media) - @if $media == handhelds - @media only screen and (max-device-width: 40rem) - @content - - @else if $media == medium-screens - @media only screen and (min-device-width: 40rem) - @content - - @else if $media == wide-screens - @media only screen and (min-width: 1000px) - @content - -@mixin partialButton - width: 5rem !important - height: 2rem !important - text-align: center !important - margin: 0.5rem !important - border-color: $seccond-color !important - border-radius: 0.5rem !important - - @include respond-to(handhelds) - font-size: 3rem - - @include respond-to(medium-screens) - font-size: 1.5rem - -@mixin hoverMe - &:hover - button - color: grey !important - -@mixin shadow - box-shadow: 0px 0px 30px silver - -@mixin noselect - -webkit-touch-callout: none - -webkit-user-select: none - -khtml-user-select: none - -moz-user-select: none - -ms-user-select: none - user-select: none - pointer-events: none - -.form-group - margin-left: 2rem - margin-right: 2rem - margin-bottom: 0.5rem - flex-wrap: wrap - justify-content: center - display: flex - -@mixin formBasic - display: block - padding: 2rem - background: $background-color-content - border-radius: 0.7rem - min-height: 20rem - margin: auto - margin-top: 2rem - margin-bottom: 2rem - @include shadow - - @include respond-to(handhelds) - font-size: 2.5em - margin-left: -0.8em - margin-right: -0.8em - border-radius: 0 - - @include respond-to(medium-screens) - max-width: 40rem - - @include respond-to(wide-screens) - max-width: 40rem - - input - @include respond-to(handhelds) - font-size: 3rem - border-radius: 0.5rem - - .check - position: static - @include respond-to(handhelds) - width: 2rem !important - height: 2rem !important - - button - @include respond-to(handhelds) - font-size: 3rem - padding: 1rem - border-radius: 1rem +// Single-line text that truncates instead of wrapping — the panel is narrow +// and reflowing rows on every event is unreadable. +@mixin ellipsis + overflow: hidden + text-overflow: ellipsis + white-space: nowrap + +@mixin monospace + font-family: var(--font-mono) + font-size: var(--font-size-small) + +@mixin focus-ring + &:focus-visible + outline: 2px solid var(--accent) + outline-offset: -1px diff --git a/src/sass/_root.sass b/src/sass/_root.sass index e96d116..83a0c1e 100644 --- a/src/sass/_root.sass +++ b/src/sass/_root.sass @@ -1,64 +1,59 @@ -// CSS Custom Properties for theming support +// Design tokens for the DevTools panel. +// +// The panel lives inside the browser's own DevTools window, so it follows the +// host theme instead of imposing one: light by default, dark under +// `prefers-color-scheme: dark`. :root - --main-font: 'Ubuntu', 'Segoe UI', 'Roboto', sans-serif - --main-font-color: #ffffff - --main-font-color-hover: #f8f9fa - --main-font-color-focus: #e9ecef - --main-font-color-disabled: #6c757d - --main-font-color-active: #f8f9fa - - --primary-color: #007bff - --primary-color-hover: #0056b3 - --primary-color-focus: #004085 - --primary-color-disabled: #6c757d - --primary-color-active: #004085 - - --secondary-color: #6c757d - --secondary-color-hover: #545b62 - --secondary-color-focus: #4e555b - --secondary-color-disabled: #adb5bd - --secondary-color-active: #4e555b - - --background-color: #77B2FF - --background-color-content: #c6dfff - --background-color-content-hover: #b3d7ff - --background-color-content-focus: #9fcdff - --background-color-content-active: #8cc4ff - --background-color-content-disabled: #e9ecef - - --shadow-color: rgba(0, 0, 0, 0.1) - --border-radius: 0.375rem - --transition-duration: 0.15s - --font-size-base: 1rem - --line-height-base: 1.5 - -// SASS Variables (for backwards compatibility) -$main-font: var(--main-font) -$main-font-color: var(--main-font-color) -$main-font-color-hover: var(--main-font-color-hover) -$main-font-color-focus: var(--main-font-color-focus) -$main-font-color-disabled: var(--main-font-color-disabled) -$main-font-color-active: var(--main-font-color-active) - -$primary-color: var(--primary-color) -$primary-color-hover: var(--primary-color-hover) -$primary-color-focus: var(--primary-color-focus) -$primary-color-disabled: var(--primary-color-disabled) -$primary-color-active: var(--primary-color-active) - -$secondary-color: var(--secondary-color) -$secondary-color-hover: var(--secondary-color-hover) -$secondary-color-focus: var(--secondary-color-focus) -$secondary-color-disabled: var(--secondary-color-disabled) -$secondary-color-active: var(--secondary-color-active) - -$background-color: var(--background-color) -$background-color-content: var(--background-color-content) -$background-color-content-hover: var(--background-color-content-hover) -$background-color-content-focus: var(--background-color-content-focus) -$background-color-content-active: var(--background-color-content-active) -$background-color-content-disabled: var(--background-color-content-disabled) - -$shadow-color: var(--shadow-color) -$border-radius: var(--border-radius) -$transition-duration: var(--transition-duration) + --font-ui: 'Segoe UI', system-ui, -apple-system, 'Roboto', sans-serif + --font-mono: 'SFMono-Regular', 'Consolas', 'Liberation Mono', monospace + --font-size: 12px + --font-size-small: 11px + + --surface: #ffffff + --surface-raised: #f3f3f3 + --surface-sunken: #fafafa + --border: #d0d0d0 + --border-strong: #b4b4b4 + + --text: #202124 + --text-muted: #5f6368 + --text-inverted: #ffffff + + --accent: #1a73e8 + --accent-soft: rgba(26, 115, 232, 0.12) + --ok: #188038 + --warn: #b06000 + --error: #c5221f + + --tag: #881280 + --attr: #994500 + --value-string: #c41a16 + --value-number: #1c00cf + --value-keyword: #5f6368 + + --radius: 4px + --row-height: 22px + +@media (prefers-color-scheme: dark) + :root + --surface: #202124 + --surface-raised: #292a2d + --surface-sunken: #1b1b1c + --border: #3c4043 + --border-strong: #5f6368 + + --text: #e8eaed + --text-muted: #9aa0a6 + --text-inverted: #202124 + + --accent: #8ab4f8 + --accent-soft: rgba(138, 180, 248, 0.16) + --ok: #81c995 + --warn: #fdd663 + --error: #f28b82 + + --tag: #5db0d7 + --attr: #9bbbdc + --value-string: #f28b82 + --value-number: #9ab8f7 + --value-keyword: #9aa0a6 diff --git a/src/sass/app.sass b/src/sass/app.sass deleted file mode 100644 index c0c29d1..0000000 --- a/src/sass/app.sass +++ /dev/null @@ -1,51 +0,0 @@ -@import 'root' -@import 'mixin' -@import 'content' - -// Bootstrap with legacy import (suppressed warnings via Vite config) -@import "../../node_modules/bootstrap/scss/bootstrap" - -body - height: 30rem - width: 30rem - background-color: $background-color - text-align: center - margin: auto - padding: 1rem - color: $main-font-color - - p - font-size: 1rem - font-weight: bold - margin: auto - padding: auto - color: $main-font-color - text-align: center - font-family: 'Roboto', sans-serif - -h1, h2 - @include noselect - -form - @include formBasic - -.logo - width: 5rem - height: auto - padding-top: 2rem - @include noselect - -svg - @include noselect - -table - color: $main-font-color !important - - th - @include noselect - -a - color: $main-font-color - - &:hover - color: $background-color-content diff --git a/src/sass/panel.sass b/src/sass/panel.sass new file mode 100644 index 0000000..7be47c7 --- /dev/null +++ b/src/sass/panel.sass @@ -0,0 +1,399 @@ +// Panel and options-page styles. +// +// `_root.sass` and `_mixin.sass` are injected by the Vite SASS config, so the +// tokens and mixins below are available without an explicit import. + +* + box-sizing: border-box + +html, body + margin: 0 + padding: 0 + height: 100% + +body + font-family: var(--font-ui) + font-size: var(--font-size) + color: var(--text) + background: var(--surface) + +#panel-root + display: flex + flex-direction: column + height: 100vh + +bq-panel + display: flex + flex-direction: column + height: 100% + min-height: 0 + +// --- status bar ------------------------------------------------------------ + +bq-status-bar + display: block + border-bottom: 1px solid var(--border) + background: var(--surface-raised) + +.status-bar + display: flex + align-items: center + gap: 6px + padding: 4px 8px + flex-wrap: wrap + +.spacer + flex: 1 1 auto + +.status + font-weight: 600 + &.status-connected + color: var(--ok) + &.status-error, &.status-disconnected + color: var(--error) + &.status-connecting, &.status-waiting-for-page + color: var(--warn) + +.status-message + margin: 0 + padding: 4px 8px + border-top: 1px solid var(--border) + color: var(--error) + @include monospace + +.badge + @include monospace + padding: 1px 5px + border-radius: 10px + border: 1px solid var(--border) + color: var(--text-muted) + background: var(--surface) + &.is-on + color: var(--accent) + border-color: var(--accent) + background: var(--accent-soft) + &.is-off + opacity: 0.55 + +// --- controls -------------------------------------------------------------- + +.btn + font: inherit + padding: 2px 8px + border: 1px solid var(--border-strong) + border-radius: var(--radius) + background: var(--surface) + color: var(--text) + cursor: pointer + @include focus-ring + &:hover:not(:disabled) + background: var(--surface-sunken) + &:disabled + opacity: 0.5 + cursor: default + &.is-active + border-color: var(--accent) + color: var(--accent) + +.chip + font: inherit + @include monospace + padding: 1px 6px + border: 1px solid var(--border) + border-radius: 10px + background: transparent + color: var(--text-muted) + cursor: pointer + @include focus-ring + &.is-on + color: var(--accent) + border-color: var(--accent) + background: var(--accent-soft) + +.field + display: inline-flex + align-items: center + gap: 4px + color: var(--text-muted) + +input[type='search'], input[type='number'] + font: inherit + padding: 2px 6px + border: 1px solid var(--border-strong) + border-radius: var(--radius) + background: var(--surface) + color: var(--text) + @include focus-ring + +.tree-search + flex: 1 1 180px + min-width: 120px + +.buffer-input + width: 76px + +.muted + color: var(--text-muted) + +.empty + padding: 12px 8px + color: var(--text-muted) + +// --- tabs ------------------------------------------------------------------ + +.tabs + display: flex + gap: 2px + padding: 0 4px + border-bottom: 1px solid var(--border) + background: var(--surface-raised) + +.tab + font: inherit + padding: 4px 10px + border: none + border-bottom: 2px solid transparent + background: transparent + color: var(--text-muted) + cursor: pointer + @include focus-ring + &.is-active + color: var(--text) + border-bottom-color: var(--accent) + &.is-unsupported + opacity: 0.5 + +.tab-body + flex: 1 1 auto + min-height: 0 + overflow: auto + +.view-toolbar + display: flex + align-items: center + gap: 8px + padding: 4px 8px + border-bottom: 1px solid var(--border) + background: var(--surface-sunken) + position: sticky + top: 0 + z-index: 1 + +// --- component tree -------------------------------------------------------- + +.tree-list + display: flex + flex-direction: column + +.tree-row + display: flex + align-items: center + gap: 6px + min-height: var(--row-height) + padding: 2px 8px + border: none + background: transparent + text-align: left + cursor: pointer + color: var(--text) + @include ellipsis + @include focus-ring + &:hover + background: var(--surface-raised) + &.is-selected + background: var(--accent-soft) + &.is-match .tree-tag + text-decoration: underline + +.tree-tag + @include monospace + color: var(--tag) + +.tree-attrs + @include monospace + @include ellipsis + color: var(--attr) + flex: 1 1 auto + +.tree-count + @include monospace + color: var(--text-muted) + +// --- inspector ------------------------------------------------------------- + +.inspector-list + display: flex + flex-direction: column + +.inspector-row + padding: 4px 8px + border-bottom: 1px solid var(--border) + +.inspector-meta + display: flex + align-items: center + gap: 6px + +.inspector-key + @include monospace + font-weight: 600 + color: var(--accent) + +// --- value tree ------------------------------------------------------------ + +bq-value + display: block + +.value-row + display: flex + align-items: baseline + gap: 4px + min-height: var(--row-height) + +.value-toggle + font: inherit + width: 14px + border: none + background: transparent + color: var(--text-muted) + cursor: pointer + padding: 0 + &.is-leaf + cursor: default + opacity: 0.4 + +.value-key + @include monospace + color: var(--attr) + +.value-sep + color: var(--text-muted) + +.value-preview + @include monospace + @include ellipsis + &.value-string + color: var(--value-string) + &.value-number + color: var(--value-number) + &.value-boolean, &.value-null, &.value-undefined + color: var(--value-keyword) + &.value-unknown + color: var(--warn) + +.value-children + margin-left: 14px + border-left: 1px dotted var(--border) + padding-left: 6px + +.value-note + color: var(--text-muted) + @include monospace + +// --- timeline -------------------------------------------------------------- + +.timeline-filters + display: flex + align-items: center + gap: 8px + padding: 4px 8px + border-bottom: 1px solid var(--border) + flex-wrap: wrap + +.chips + display: flex + gap: 4px + flex-wrap: wrap + +.scrubber + display: flex + align-items: center + gap: 8px + padding: 4px 8px + border-bottom: 1px solid var(--border) + background: var(--surface-sunken) + +.scrubber-field + flex: 1 1 auto + +.scrubber-range + flex: 1 1 auto + accent-color: var(--accent) + +.timeline-list + display: flex + flex-direction: column + +.timeline-row + border-bottom: 1px solid var(--border) + &.is-current + background: var(--accent-soft) + +.timeline-head + display: flex + align-items: center + gap: 8px + width: 100% + min-height: var(--row-height) + padding: 2px 8px + border: none + background: transparent + color: var(--text) + text-align: left + cursor: pointer + @include focus-ring + &:hover + background: var(--surface-raised) + +.timeline-time + @include monospace + color: var(--text-muted) + +.timeline-type + @include monospace + color: var(--accent) + &.type-error + color: var(--error) + &.type-measure, &.type-mark + color: var(--warn) + +.timeline-detail + @include ellipsis + flex: 1 1 auto + +.timeline-payload + padding: 4px 8px 8px 24px + background: var(--surface-sunken) + display: flex + flex-direction: column + gap: 6px + align-items: flex-start + +// --- options page ---------------------------------------------------------- + +.options-page + max-width: 44rem + margin: 0 auto + padding: 16px + +.settings-form + display: flex + flex-direction: column + gap: 16px + +.field-block + display: flex + flex-direction: column + gap: 4px + label + font-weight: 600 + small + color: var(--text-muted) + +.field-inline + display: grid + grid-template-columns: auto 1fr + gap: 4px 8px + align-items: center + small + grid-column: 1 / -1 diff --git a/src/settings.ts b/src/settings.ts index 6d544bd..55a046c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,185 +1,91 @@ +/** + * Options page — defaults for the DevTools panel. + * + * Written with bQuery's reactive forms and `safeHtml` sinks, and persisted + * through `chrome.storage.local`. Nothing here touches page data; the + * `storage` permission grants no access to any site. + * + * @module settings + */ import { safeHtml } from '@bquery/bquery/component'; -import { $, sleep } from '@bquery/bquery/core'; -import { createForm, required } from '@bquery/bquery/forms'; -import { useAnnouncer } from '@bquery/bquery/platform'; +import { $ } from '@bquery/bquery/core'; import { effect } from '@bquery/bquery/reactive'; -import { sanitizeHtml } from '@bquery/bquery/security'; -import { Session } from './classes/session'; -import './components/button'; -import './sass/app.sass'; - -const CONTENT_TEST_REQUIRED_MESSAGE = 'Content test must not be empty'; -const requiredContentTestValidator = required(CONTENT_TEST_REQUIRED_MESSAGE); -const validateRequiredContentTest = ( - value: string -): true | typeof CONTENT_TEST_REQUIRED_MESSAGE => { - return requiredContentTestValidator(value) === true ? true : CONTENT_TEST_REQUIRED_MESSAGE; -}; - -class Settings { - private session: Session | null = null; - - constructor() { - void this.init(); - } - - private async init(): Promise { - try { - this.session = await Session.getInstance(); - this.renderSettings(); - } catch (error) { - console.error('Failed to initialize settings:', error); - this.handleError('Failed to load settings'); - } - } - - private renderSettings(): void { - const session = this.session; - if (!session) { - throw new Error('Session not initialized'); - } - - if (!document.getElementById('settings')) { - throw new Error('Settings element not found'); - } - const root = $('#settings'); - const announcer = useAnnouncer({ politeness: 'polite' }); - - // Render the surrounding form scaffold with `safeHtml` so interpolated - // values in this template are escaped here. The nested `` - // renders its own internal markup separately. - root.empty().append( - safeHtml`
-
- - - -
- -
` +import { + DEFAULT_SETTINGS, + loadSettings, + MAX_POLL_INTERVAL_MS, + MIN_POLL_INTERVAL_MS, + normalizeSettings, + saveSettings, + type PanelSettings, +} from './panel/settings'; +import { MAX_BUFFER_SIZE, MIN_BUFFER_SIZE } from './panel/timeline'; +import { signal } from '@bquery/bquery/reactive'; +import './sass/panel.sass'; + +const HOST_ID = 'settings'; + +const current = signal(DEFAULT_SETTINGS); +const status = signal(''); + +const render = (): void => { + const host = document.getElementById(HOST_ID); + if (!host) throw new Error(`bQuery DevTools: #${HOST_ID} is missing from options.html`); + + $(`#${HOST_ID}`) + .empty() + .append( + safeHtml`
+
+ + + How many reactive events the panel keeps. Older entries are dropped first. +
+
+ + + How often the permission-free transport drains events from the page. +
+
+ + + Live streaming pushes events through a content script. It needs per-site permission, which the panel asks for on demand. +
+ +

+
` ); - const formElement = $('#bet-settings-form'); - const input = $('#contentTest'); - const errorLabel = $('#contentTest-error'); - const submitButton = $('#saveSettings'); - - // Build a reactive form with field-level validation. The initial value - // is seeded from the persisted session so existing data round-trips. - const form = createForm<{ contentTest: string }>({ - fields: { - contentTest: { - initialValue: session.contentTest$.value, - validators: [validateRequiredContentTest], - }, - }, - onSubmit: async values => { - // Defense in depth: normalize stored markup before persistence, while - // still requiring context-appropriate escaping/sanitization at every - // render sink. - const sanitizedValue = sanitizeHtml(values.contentTest); - form.setValues({ contentTest: sanitizedValue }); - input.val(sanitizedValue); - const sanitizedValidationResult = validateRequiredContentTest(sanitizedValue); - - if (sanitizedValidationResult !== true) { - const validationMessage = sanitizedValidationResult; - form.fields.contentTest.touch(); - form.setErrors({ contentTest: validationMessage }); - this.showNotification(validationMessage, 'error'); - return; - } - - session.contentTest = sanitizedValue; - await session.save(); - announcer.announce('Settings saved successfully'); - this.showNotification('Settings saved successfully!', 'success'); - }, + const bufferInput = document.getElementById('bufferSize') as HTMLInputElement; + const pollInput = document.getElementById('pollIntervalMs') as HTMLInputElement; + const streamInput = document.getElementById('preferLiveStreaming') as HTMLInputElement; + + effect(() => { + const settings = current.value; + bufferInput.value = String(settings.bufferSize); + pollInput.value = String(settings.pollIntervalMs); + streamInput.checked = settings.preferLiveStreaming; + }); + + effect(() => { + $('#settings-status').text(status.value); + }); + + $('#settings-form').on('submit', event => { + event.preventDefault(); + const next = normalizeSettings({ + bufferSize: Number(bufferInput.value), + pollIntervalMs: Number(pollInput.value), + preferLiveStreaming: streamInput.checked, }); - const submitSettings = async (event: Event): Promise => { - event.preventDefault(); - try { - await form.handleSubmit(); - } catch (error) { - console.error('Failed to save settings:', error); - announcer.announce('Failed to save settings', { politeness: 'assertive' }); - this.showNotification('Failed to save settings', 'error'); - } - }; - - // Two-way binding between the input and the reactive form field. - input.on('input', event => { - const target = event.target as HTMLInputElement | null; - if (target) { - form.fields.contentTest.value.value = target.value; - } + current.value = next; + void saveSettings(next).then(() => { + status.value = 'Saved. Reopen the bQuery panel to apply.'; }); + }); +}; - input.on('blur', () => { - form.fields.contentTest.touch(); - }); - - // Reflect field validation state into the DOM reactively. - effect(() => { - const error = form.fields.contentTest.error.value; - const touched = form.fields.contentTest.isTouched.value; - const visibleError = touched ? error : ''; - errorLabel.text(visibleError); - input.attr('aria-invalid', visibleError ? 'true' : 'false'); - }); - - // Disable the submit button while submission is in flight. - effect(() => { - const submitting = form.isSubmitting.value; - if (submitting) { - submitButton.attr('disabled', 'true'); - } else { - submitButton.removeAttr('disabled'); - } - }); - - formElement.on('submit', submitSettings); - submitButton.on('click', submitSettings); - } - - private showNotification(message: string, type: 'success' | 'error'): void { - // Everything past the initial host attach goes through bQuery: class - // toggles, safe text content (no `innerHTML`), inline styling, and the - // teardown timer (`sleep` instead of a raw `setTimeout`). - const host = document.body.appendChild(document.createElement('div')); - const $host = $(host); - $host.addClass('notification', `notification-${type}`); - $host.text(message); - $host.css({ - position: 'fixed', - top: '20px', - right: '20px', - padding: '10px 20px', - 'border-radius': '4px', - color: 'white', - 'background-color': type === 'success' ? '#28a745' : '#dc3545', - 'z-index': '1000', - }); - - void sleep(3000).then(() => { - $host.remove(); - }); - } - - private handleError(message: string): void { - console.error(message); - if (document.getElementById('settings')) { - $('#settings').html(safeHtml`
${message}
`); - } - } -} - -new Settings(); +void loadSettings().then(settings => { + current.value = settings; + render(); +}); diff --git a/src/transports/evalTransport.ts b/src/transports/evalTransport.ts new file mode 100644 index 0000000..61f6f7e --- /dev/null +++ b/src/transports/evalTransport.ts @@ -0,0 +1,167 @@ +/** + * Default transport: `chrome.devtools.inspectedWindow.eval`. + * + * A DevTools panel may evaluate script in the page it is inspecting without + * any host permission — that is what lets this extension ship with an empty + * `host_permissions` list. The trade-off is that the page cannot push to us, + * so a tiny in-page relay buffers bridge messages and the panel drains that + * buffer on a timer. + * + * The relay is (re)installed on every poll, which also makes the transport + * self-healing across page navigations: a reload wipes the relay, the next + * poll puts it back. + * + * @module transports/evalTransport + */ +import { extensionApi } from '../browser'; +import { BRIDGE_SOURCE, type InboundMessage } from '../protocol/messages'; +import type { BridgeTransport, TransportHandlers } from '../protocol/transport'; + +/** Options for {@link EvalTransport}. */ +export interface EvalTransportOptions { + /** How often to drain the in-page queue, in ms. @default 250 */ + readonly pollIntervalMs?: number; + /** Cap on messages buffered in the page between two polls. @default 2000 */ + readonly queueLimit?: number; +} + +/** Global the relay hangs off in the inspected page. */ +const RELAY_GLOBAL = '__BQUERY_DEVTOOLS_PANEL__'; + +const DEFAULT_POLL_INTERVAL_MS = 250; +const DEFAULT_QUEUE_LIMIT = 2000; + +/** + * Build the expression that installs the relay (if absent) and returns the + * buffered messages as a JSON string. + * + * Exported for unit testing: the expression is built by string concatenation + * and must stay syntactically valid and free of interpolation holes. + */ +export const buildDrainExpression = (queueLimit = DEFAULT_QUEUE_LIMIT): string => `(function () { + var g = window[${JSON.stringify(RELAY_GLOBAL)}]; + if (!g) { + g = window[${JSON.stringify(RELAY_GLOBAL)}] = { queue: [] }; + window.addEventListener('message', function (event) { + var data = event.data; + if (event.source !== window) return; + if (!data || data.source !== ${JSON.stringify(BRIDGE_SOURCE)} || data.channel !== 'page') return; + g.queue.push(data); + if (g.queue.length > ${queueLimit}) g.queue.splice(0, g.queue.length - ${queueLimit}); + }); + } + var drained = g.queue; + g.queue = []; + try { + return JSON.stringify(drained); + } catch (error) { + return JSON.stringify([]); + } +})()`; + +/** + * Build the expression that posts one panel → page message. + * + * The message is embedded as a JSON *string literal* and parsed in the page, + * so no value from panel state is ever spliced into evaluated source. + */ +export const buildSendExpression = (message: InboundMessage): string => { + const literal = JSON.stringify(JSON.stringify(message)) + // U+2028/U+2029 are legal inside JSON strings; escape them so the + // embedded literal is unambiguous in every JavaScript parser. + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); + return `window.postMessage(JSON.parse(${literal}), '*')`; +}; + +/** A `chrome.devtools.inspectedWindow.eval`-compatible evaluator. */ +export type Evaluator = ( + expression: string, + callback: (result: unknown, exceptionInfo?: unknown) => void +) => void; + +const isFailure = (exceptionInfo: unknown): boolean => { + if (!exceptionInfo || typeof exceptionInfo !== 'object') return false; + const info = exceptionInfo as { isError?: unknown; isException?: unknown }; + return Boolean(info.isError) || Boolean(info.isException); +}; + +/** Polling transport built on `inspectedWindow.eval`. */ +export class EvalTransport implements BridgeTransport { + public readonly kind = 'eval' as const; + + private readonly evaluate: Evaluator; + private readonly pollIntervalMs: number; + private readonly drainExpression: string; + + private handlers: TransportHandlers | null = null; + private timer: ReturnType | null = null; + private disposed = false; + private lastFailure = ''; + + constructor(options: EvalTransportOptions & { evaluate?: Evaluator } = {}) { + this.evaluate = + options.evaluate ?? + ((expression, callback) => { + extensionApi().devtools.inspectedWindow.eval(expression, callback); + }); + this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + this.drainExpression = buildDrainExpression(options.queueLimit ?? DEFAULT_QUEUE_LIMIT); + } + + public start(handlers: TransportHandlers): void { + if (this.disposed || this.handlers) return; + this.handlers = handlers; + handlers.onStatus({ kind: 'connecting' }); + // The relay is installed by the first drain, so the transport is "open" + // as soon as one poll round-trips without an evaluation error. + this.poll(true); + this.timer = setInterval(() => this.poll(false), this.pollIntervalMs); + } + + public send(message: InboundMessage): void { + if (this.disposed) return; + this.evaluate(buildSendExpression(message), (_result, exceptionInfo) => { + if (isFailure(exceptionInfo)) this.reportFailure('cannot evaluate in the inspected page'); + }); + } + + public dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.timer !== null) clearInterval(this.timer); + this.timer = null; + this.handlers = null; + } + + private poll(announceOpen: boolean): void { + this.evaluate(this.drainExpression, (result, exceptionInfo) => { + if (this.disposed || !this.handlers) return; + if (isFailure(exceptionInfo)) { + this.reportFailure('cannot evaluate in the inspected page'); + return; + } + if (announceOpen || this.lastFailure) { + this.lastFailure = ''; + this.handlers.onStatus({ kind: 'open' }); + } + for (const message of this.decode(result)) this.handlers.onMessage(message); + }); + } + + private decode(result: unknown): unknown[] { + if (typeof result !== 'string') return Array.isArray(result) ? result : []; + try { + const parsed: unknown = JSON.parse(result); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private reportFailure(reason: string): void { + if (!this.handlers || this.lastFailure === reason) return; + this.lastFailure = reason; + this.handlers.onStatus({ kind: 'error', reason }); + } +} diff --git a/src/transports/portTransport.ts b/src/transports/portTransport.ts new file mode 100644 index 0000000..5a4fbc1 --- /dev/null +++ b/src/transports/portTransport.ts @@ -0,0 +1,194 @@ +/** + * Optional transport: a long-lived port to the background router. + * + * This is the push-based path — the page's timeline events reach the panel as + * they happen instead of on the next poll. It costs one host permission for + * the inspected origin (requested from the panel, on a user gesture) plus an + * on-demand content-script injection, so it is opt-in per site. + * + * The port is expected to die: MV3 service workers are evicted aggressively. + * A disconnect therefore reconnects with backoff and re-runs `attach`, and the + * client above re-runs the bridge handshake on the fresh route. + * + * @module transports/portTransport + */ +import { extensionApi } from '../browser'; +import { ENVELOPE_SOURCE, PANEL_PORT_NAME, parseBackgroundEnvelope } from '../protocol/envelope'; +import type { InboundMessage } from '../protocol/messages'; +import type { BridgeTransport, TransportHandlers } from '../protocol/transport'; + +/** The slice of `chrome.runtime.Port` the panel uses. */ +export interface PanelPort { + postMessage(message: unknown): void; + disconnect(): void; + readonly onMessage: { addListener(listener: (message: unknown) => void): void }; + readonly onDisconnect: { addListener(listener: () => void): void }; +} + +/** Options for {@link PortTransport}. */ +export interface PortTransportOptions { + /** Tab id of the inspected window. */ + readonly tabId: number; + /** Opens a port to the background worker. Injected for tests. */ + readonly connect?: () => PanelPort; + /** Reconnect backoff, in ms. @default [250, 500, 1000, 2000, 5000] */ + readonly backoffMs?: readonly number[]; + /** Injectable timer, so tests do not wait in real time. */ + readonly setTimeout?: (handler: () => void, ms: number) => number; +} + +const DEFAULT_BACKOFF_MS = [250, 500, 1000, 2000, 5000] as const; + +/** Push transport over `chrome.runtime.connect`. */ +export class PortTransport implements BridgeTransport { + public readonly kind = 'port' as const; + + private readonly tabId: number; + private readonly connectPort: () => PanelPort; + private readonly backoffMs: readonly number[]; + private readonly setTimer: (handler: () => void, ms: number) => number; + + private handlers: TransportHandlers | null = null; + private port: PanelPort | null = null; + private token: string | null = null; + private queued: InboundMessage[] = []; + private attempt = 0; + private disposed = false; + + constructor(options: PortTransportOptions) { + this.tabId = options.tabId; + this.connectPort = + options.connect ?? + (() => extensionApi().runtime.connect({ name: PANEL_PORT_NAME }) as unknown as PanelPort); + this.backoffMs = options.backoffMs ?? DEFAULT_BACKOFF_MS; + this.setTimer = + options.setTimeout ?? + ((handler, ms) => globalThis.setTimeout(handler, ms) as unknown as number); + } + + public start(handlers: TransportHandlers): void { + if (this.disposed || this.handlers) return; + this.handlers = handlers; + this.open(); + } + + public send(message: InboundMessage): void { + if (this.disposed) return; + if (!this.port || !this.token) { + // Not attached yet — hold the frame until the route is open. + this.queued.push(message); + if (this.queued.length > 32) this.queued.shift(); + return; + } + this.port.postMessage({ + source: ENVELOPE_SOURCE, + type: 'to-page', + token: this.token, + payload: message, + }); + } + + /** + * Ask the background worker to (re)inject the content script. + * + * Resolves with the router's verdict; a rejection means the route never + * opened (no permission, restricted page, …). + */ + public requestInjection(): Promise { + return new Promise((resolve, reject) => { + if (!this.port || !this.token) { + reject(new Error('bQuery DevTools: not attached to the inspected tab')); + return; + } + this.injectionWaiters.push({ resolve, reject }); + this.port.postMessage({ source: ENVELOPE_SOURCE, type: 'inject', token: this.token }); + }); + } + + public dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.settleInjection(new Error('bQuery DevTools: transport disposed')); + this.port?.disconnect(); + this.port = null; + this.token = null; + this.handlers = null; + } + + private readonly injectionWaiters: Array<{ + resolve: () => void; + reject: (error: Error) => void; + }> = []; + + private open(): void { + if (this.disposed || !this.handlers) return; + this.handlers.onStatus({ kind: 'connecting' }); + + let port: PanelPort; + try { + port = this.connectPort(); + } catch (error) { + this.handlers.onStatus({ + kind: 'error', + reason: error instanceof Error ? error.message : 'cannot reach the background worker', + }); + return; + } + + this.port = port; + this.token = null; + + port.onMessage.addListener((message: unknown) => this.handlePortMessage(message)); + port.onDisconnect.addListener(() => this.handleDisconnect()); + + port.postMessage({ source: ENVELOPE_SOURCE, type: 'attach', tabId: this.tabId }); + } + + private handlePortMessage(message: unknown): void { + if (this.disposed || !this.handlers) return; + const envelope = parseBackgroundEnvelope(message); + if (!envelope) return; + + switch (envelope.type) { + case 'attached': { + this.token = envelope.token; + this.attempt = 0; + this.handlers.onStatus({ kind: 'open' }); + const queued = this.queued; + this.queued = []; + for (const pending of queued) this.send(pending); + return; + } + case 'attach-failed': + this.handlers.onStatus({ kind: 'error', reason: envelope.reason }); + return; + case 'inject-result': + if (envelope.ok) this.settleInjection(null); + else this.settleInjection(new Error(envelope.reason ?? 'injection failed')); + return; + case 'from-page': + this.handlers.onMessage(envelope.payload); + return; + } + } + + private handleDisconnect(): void { + if (this.disposed || !this.handlers) return; + this.port = null; + this.token = null; + this.settleInjection(new Error('bQuery DevTools: background worker disconnected')); + this.handlers.onStatus({ kind: 'closed', reason: 'background worker disconnected' }); + + const delay = this.backoffMs[Math.min(this.attempt, this.backoffMs.length - 1)] ?? 1000; + this.attempt += 1; + this.setTimer(() => this.open(), delay); + } + + private settleInjection(error: Error | null): void { + const waiters = this.injectionWaiters.splice(0, this.injectionWaiters.length); + for (const waiter of waiters) { + if (error) waiter.reject(error); + else waiter.resolve(); + } + } +} diff --git a/src/types/buttonType.ts b/src/types/buttonType.ts deleted file mode 100644 index efdfd5f..0000000 --- a/src/types/buttonType.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type customButton = - | 'neutral' - | 'primary' - | 'secondary' - | 'success' - | 'danger' - | 'warning' - | 'info' - | 'light' - | 'dark'; diff --git a/tests/e2e/fixture.ts b/tests/e2e/fixture.ts new file mode 100644 index 0000000..2d6d7d1 --- /dev/null +++ b/tests/e2e/fixture.ts @@ -0,0 +1,170 @@ +/** + * The page-side half of the E2E smoke test. + * + * `installFixture` is serialized into the browser and runs *before* the panel + * bundle, where it installs two things: + * + * 1. a `chrome` mock providing exactly the APIs the panel touches — most + * importantly `devtools.inspectedWindow.eval`, which here evaluates in the + * same page, so the real `EvalTransport` expressions really do run; + * 2. a bridge server that speaks protocol v1 over `window.postMessage`, + * mirroring `createBridgeServer()` from `@bquery/bquery/devtools`. + * + * The result is the whole panel stack under test against a faithful page. + */ + +/** Snapshot the fake page reports, mirroring `exportDevtoolsSnapshot()`. */ +export const FIXTURE_SNAPSHOT = { + version: 1, + exportedAt: 1_700_000_000_000, + state: { enabled: true, options: {}, timeline: [] }, + signals: [ + { label: 'count', value: 1, subscriberCount: 2 }, + { label: 'user', value: { name: 'Ada', roles: ['admin', 'dev'] }, subscriberCount: 1 }, + ], + stores: [{ id: 'cart', state: { items: 0, open: false } }], + components: [ + { tagName: 'my-app', instanceCount: 1 }, + { tagName: 'my-item', instanceCount: 2 }, + ], +}; + +/** Component tree the fake page reports. */ +export const FIXTURE_TREE = { + tree: [ + { + tag: 'my-app', + id: '0', + attrs: { theme: 'dark' }, + children: [ + { tag: 'my-header', id: '0/0', attrs: { title: 'Dashboard' }, children: [] }, + { + tag: 'my-list', + id: '0/1', + attrs: {}, + children: [ + { tag: 'my-item', id: '0/1/0', attrs: { label: 'first' }, children: [] }, + { tag: 'my-item', id: '0/1/1', attrs: { label: 'second' }, children: [] }, + ], + }, + ], + }, + ], + flat: FIXTURE_SNAPSHOT.components, +}; + +/** Timeline the fake page has already recorded when the panel connects. */ +export const FIXTURE_TIMELINE = [ + { timestamp: 1_700_000_000_001, type: 'component:mount', detail: 'my-app', source: 'my-app' }, + { + timestamp: 1_700_000_000_002, + type: 'signal:update', + detail: 'count → 1', + source: 'count', + payload: { value: 1 }, + }, +]; + +/** Capabilities the fake page advertises. */ +export const FIXTURE_CAPABILITIES = ['signals', 'stores', 'components', 'timeline', 'time-travel']; + +interface FixtureData { + snapshot: unknown; + tree: unknown; + timeline: unknown; + capabilities: readonly string[]; +} + +/** + * Runs in the browser before the panel bundle. Kept dependency-free and + * self-contained: Playwright serializes it as source. + */ +export const installFixture = (data: FixtureData): void => { + const SOURCE = 'bquery-devtools'; + const scope = window as unknown as Record; + const storage: Record = {}; + const inspected: unknown[] = []; + + scope['__inspected'] = inspected; + scope['inspect'] = (element: unknown): void => { + inspected.push(element); + }; + + scope['chrome'] = { + runtime: { + id: 'e2e-extension', + connect: () => ({ + postMessage: () => undefined, + disconnect: () => undefined, + onMessage: { addListener: () => undefined }, + onDisconnect: { addListener: () => undefined }, + }), + onMessage: { addListener: () => undefined }, + onConnect: { addListener: () => undefined }, + sendMessage: () => Promise.resolve(), + }, + storage: { + local: { + get: (key: string) => Promise.resolve({ [key]: storage[key] }), + set: (values: Record) => { + Object.assign(storage, values); + return Promise.resolve(); + }, + }, + }, + permissions: { + contains: () => Promise.resolve(false), + request: () => Promise.resolve(false), + }, + devtools: { + inspectedWindow: { + tabId: 1, + eval: (expression: string, callback: (result: unknown, info?: unknown) => void): void => { + try { + // Indirect eval: the fixture page *is* the inspected page here. + const evaluate = eval; + callback(evaluate(expression), undefined); + } catch (error) { + callback(undefined, { isException: true, value: String(error) }); + } + }, + }, + network: { onNavigated: { addListener: () => undefined } }, + panels: { create: () => undefined }, + }, + }; + + // --- page-side bridge server (protocol v1) -------------------------------- + + const post = (message: Record): void => { + window.postMessage({ source: SOURCE, channel: 'page', v: 1, ...message }, '*'); + }; + + const methods: Record unknown> = { + ping: () => ({ v: 1, ok: true }), + getSnapshot: () => data.snapshot, + getComponentTree: () => data.tree, + getTimeline: () => data.timeline, + }; + + window.addEventListener('message', event => { + const message = event.data as Record | null; + if (!message || message['source'] !== SOURCE || message['channel'] !== 'panel') return; + if (message['kind'] === 'hello') { + post({ kind: 'init', capabilities: data.capabilities }); + return; + } + if (message['kind'] !== 'request') return; + const method = methods[String(message['method'])]; + if (!method) { + post({ kind: 'response', id: message['id'], error: `Unknown method: ${message['method']}` }); + return; + } + post({ kind: 'response', id: message['id'], result: method(message['params']) }); + }); + + /** Lets the test stream a timeline event from the fake page. */ + scope['__emit'] = (entry: unknown): void => { + post({ kind: 'event', entry }); + }; +}; diff --git a/tests/e2e/panel.spec.ts b/tests/e2e/panel.spec.ts new file mode 100644 index 0000000..623ead4 --- /dev/null +++ b/tests/e2e/panel.spec.ts @@ -0,0 +1,228 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + FIXTURE_CAPABILITIES, + FIXTURE_SNAPSHOT, + FIXTURE_TIMELINE, + FIXTURE_TREE, + installFixture, +} from './fixture'; + +const openPanel = async (page: Page): Promise => { + await page.addInitScript(installFixture, { + snapshot: FIXTURE_SNAPSHOT, + tree: FIXTURE_TREE, + timeline: FIXTURE_TIMELINE, + capabilities: FIXTURE_CAPABILITIES, + }); + await page.goto('/panel.html'); + await expect(page.locator('.status')).toHaveText('Connected'); +}; + +const openTab = async (page: Page, label: string): Promise => { + await page.getByRole('tab', { name: label }).click(); +}; + +/** Stream one timeline entry from the fake page. */ +const emit = async (page: Page, entry: Record): Promise => { + await page.evaluate( + payload => (window as unknown as { __emit: (entry: unknown) => void }).__emit(payload), + entry + ); +}; + +test.describe('bQuery DevTools panel', () => { + test('connects over the bridge and negotiates capabilities', async ({ page }) => { + await openPanel(page); + await expect(page.locator('.badge', { hasText: 'protocol v1' })).toBeVisible(); + for (const capability of FIXTURE_CAPABILITIES) { + await expect(page.locator(`.badge.is-on`, { hasText: capability })).toBeVisible(); + } + // Without a granted host permission the panel stays on the polling transport. + await expect(page.locator('.badge', { hasText: 'polling' })).toBeVisible(); + }); + + test('renders and filters the component tree', async ({ page }) => { + await openPanel(page); + await expect(page.locator('.tree-row')).toHaveCount(5); + await expect(page.locator('.tree-tag').first()).toHaveText(''); + await expect(page.locator('.tree-attrs').first()).toContainText('theme="dark"'); + + await page.getByLabel('Filter components').fill('item'); + // my-app and my-list are kept as ancestors of the two matching items. + await expect(page.locator('.tree-row')).toHaveCount(4); + await expect(page.locator('.tree-row.is-match')).toHaveCount(2); + + await page.getByLabel('Filter components').fill('nothing-matches'); + await expect(page.locator('.empty')).toBeVisible(); + }); + + test('shows signals and drills into a nested value', async ({ page }) => { + await openPanel(page); + await openTab(page, 'Signals'); + + await expect(page.locator('.inspector-row')).toHaveCount(2); + const countRow = page.locator('.inspector-row', { hasText: 'count' }).first(); + await expect(countRow.locator('.value-preview')).toHaveText('1'); + await expect(countRow.locator('.badge')).toHaveText('2 subscribers'); + + const userRow = page.locator('.inspector-row', { hasText: 'user' }).first(); + await userRow.locator('.value-toggle').first().click(); + await expect(userRow.locator('.value-preview.value-string', { hasText: 'Ada' })).toBeVisible(); + await expect(userRow.locator('.value-key', { hasText: 'roles' })).toBeVisible(); + }); + + test('shows stores', async ({ page }) => { + await openPanel(page); + await openTab(page, 'Stores'); + const row = page.locator('.inspector-row', { hasText: 'cart' }).first(); + await expect(row.locator('.badge')).toHaveText('2 keys'); + await row.locator('.value-toggle').first().click(); + await expect(row.locator('.value-key', { hasText: 'items' })).toBeVisible(); + }); + + test('seeds the timeline, streams new events and filters them', async ({ page }) => { + await openPanel(page); + await openTab(page, 'Timeline'); + await expect(page.locator('.timeline-row')).toHaveCount(FIXTURE_TIMELINE.length); + + await emit(page, { + timestamp: 1_700_000_000_003, + type: 'store:patch', + detail: 'cart items', + source: 'cart', + payload: { patch: { items: 3 } }, + }); + await expect(page.locator('.timeline-row')).toHaveCount(3); + // Newest first. + await expect(page.locator('.timeline-detail').first()).toHaveText('cart items'); + + await page.getByLabel('Filter timeline events').fill('count'); + await expect(page.locator('.timeline-row')).toHaveCount(1); + await page.getByLabel('Filter timeline events').fill(''); + + await page.locator('.chip', { hasText: 'store:patch' }).click(); + await expect(page.locator('.timeline-row')).toHaveCount(1); + await page.locator('.chip', { hasText: 'store:patch' }).click(); + await expect(page.locator('.timeline-row')).toHaveCount(3); + }); + + test('pauses and clears the buffer', async ({ page }) => { + await openPanel(page); + await openTab(page, 'Timeline'); + await page.getByRole('button', { name: 'Pause' }).click(); + await emit(page, { timestamp: 4, type: 'mark', detail: 'ignored' }); + await expect(page.locator('.timeline-row')).toHaveCount(FIXTURE_TIMELINE.length); + + await page.getByRole('button', { name: 'Clear' }).click(); + await expect(page.locator('.timeline-row')).toHaveCount(0); + await expect(page.locator('.empty')).toBeVisible(); + }); + + test('replays state at an earlier event', async ({ page }) => { + await openPanel(page); + await openTab(page, 'Timeline'); + await emit(page, { + timestamp: 1_700_000_000_004, + type: 'signal:update', + detail: 'count → 99', + source: 'count', + payload: { value: 99 }, + }); + await expect(page.locator('.timeline-row')).toHaveCount(3); + + // Replay the state as of the *first* recorded event. + await page.locator('.timeline-head').last().click(); + await page.getByRole('button', { name: 'Replay state at this event' }).click(); + await expect(page.locator('.scrubber .muted')).toContainText('applied'); + + await openTab(page, 'Signals'); + const countRow = page.locator('.inspector-row', { hasText: 'count' }).first(); + await expect(countRow.locator('.value-preview')).toHaveText('1'); + await expect(countRow.locator('.badge')).toHaveText('unchanged'); + + // Back to the newest event: the replayed value follows the stream. + await openTab(page, 'Timeline'); + await page.locator('.scrubber-range').fill('2'); + await openTab(page, 'Signals'); + await expect(countRow.locator('.value-preview')).toHaveText('99'); + await expect(countRow.locator('.badge')).toHaveText('replayed'); + + await openTab(page, 'Timeline'); + await page.getByRole('button', { name: 'Live', exact: true }).click(); + await openTab(page, 'Signals'); + await expect(countRow.locator('.value-preview')).toHaveText('1'); + }); + + test('never renders page-supplied markup as HTML', async ({ page }) => { + await page.addInitScript(installFixture, { + snapshot: { + ...FIXTURE_SNAPSHOT, + signals: [ + { + label: 'label', + value: '', + subscriberCount: 0, + }, + ], + }, + tree: { + tree: [ + { + tag: 'img src=x onerror=window.__xss=1', + id: '0', + attrs: { onerror: 'window.__xss = 1' }, + children: [], + }, + ], + flat: [], + }, + timeline: [{ timestamp: 1, type: 'mark', detail: '' }], + capabilities: FIXTURE_CAPABILITIES, + }); + await page.goto('/panel.html'); + await expect(page.locator('.status')).toHaveText('Connected'); + + await expect(page.locator('.tree-tag').first()).toHaveText( + '' + ); + await expect(page.locator('.tree-list img')).toHaveCount(0); + + await openTab(page, 'Signals'); + await expect(page.locator('.inspector-key').first()).toHaveText('label'); + await expect(page.locator('.inspector-list b')).toHaveCount(0); + + await openTab(page, 'Timeline'); + await expect(page.locator('.timeline-detail').first()).toHaveText( + '' + ); + + expect( + await page.evaluate(() => (window as unknown as Record)['__xss']) + ).toBeUndefined(); + }); + + test('reports a page that never answers the handshake', async ({ page }) => { + // No fixture bridge: only the chrome mock, so `hello` goes unanswered. + await page.addInitScript(() => { + const scope = window as unknown as Record; + scope['chrome'] = { + runtime: { id: 'e2e', onMessage: { addListener: () => undefined } }, + storage: { local: { get: () => Promise.resolve({}), set: () => Promise.resolve() } }, + permissions: { contains: () => Promise.resolve(false) }, + devtools: { + inspectedWindow: { + tabId: 1, + eval: (expression: string, callback: (result: unknown) => void) => { + const evaluate = eval; + callback(evaluate(expression)); + }, + }, + network: { onNavigated: { addListener: () => undefined } }, + }, + }; + }); + await page.goto('/panel.html'); + await expect(page.locator('.status')).toHaveText('Waiting for the page'); + await expect(page.getByRole('tab', { name: 'Components' })).toBeVisible(); + }); +}); diff --git a/tests/e2e/server.ts b/tests/e2e/server.ts new file mode 100644 index 0000000..3f04c87 --- /dev/null +++ b/tests/e2e/server.ts @@ -0,0 +1,34 @@ +/** + * Static server for the E2E smoke test. + * + * Playwright cannot open a DevTools panel, but the panel is an ordinary + * extension page: served over http with a mocked `chrome` API it exercises + * the real bundle — transport, protocol client, state and Web Components — + * end to end. This serves `dist/` for that purpose. + */ +import { file } from 'bun'; +import { existsSync } from 'fs'; +import { join, normalize } from 'path'; + +const ROOT = normalize(join(import.meta.dir, '../../dist')); +const PORT = Number(process.env['PORT'] ?? 4173); + +if (!existsSync(ROOT)) { + throw new Error('dist/ is missing — run `bun run deploy-v3` before the E2E tests'); +} + +const server = Bun.serve({ + port: PORT, + hostname: '127.0.0.1', + fetch(request) { + const { pathname } = new URL(request.url); + const relative = pathname === '/' ? '/panel.html' : pathname; + // Contain the server to dist/ regardless of what the request asks for. + const resolved = normalize(join(ROOT, relative)); + if (!resolved.startsWith(ROOT)) return new Response('Forbidden', { status: 403 }); + if (!existsSync(resolved)) return new Response('Not found', { status: 404 }); + return new Response(file(resolved)); + }, +}); + +console.log(`E2E fixture server listening on http://127.0.0.1:${server.port}`); diff --git a/tests/helpers/bridge.ts b/tests/helpers/bridge.ts new file mode 100644 index 0000000..8e2fe37 --- /dev/null +++ b/tests/helpers/bridge.ts @@ -0,0 +1,84 @@ +/** + * Shared doubles for bridge tests: a transport whose wire the test drives by + * hand, and a clock that never actually waits. + */ +import { BRIDGE_SOURCE, type InboundMessage } from '../../src/protocol/messages'; +import type { BridgeTransport, TransportHandlers } from '../../src/protocol/transport'; + +/** A transport whose wire the test drives by hand. */ +export class FakeTransport implements BridgeTransport { + public readonly kind = 'eval' as const; + public readonly sent: InboundMessage[] = []; + /** Request ids already answered, so a test helper can answer only new ones. */ + public readonly answered = new Set(); + public disposed = false; + private handlers: TransportHandlers | null = null; + + public start(handlers: TransportHandlers): void { + this.handlers = handlers; + handlers.onStatus({ kind: 'connecting' }); + } + + public send(message: InboundMessage): void { + this.sent.push(message); + } + + public dispose(): void { + this.disposed = true; + } + + public open(): void { + this.handlers?.onStatus({ kind: 'open' }); + } + + public close(reason = 'gone'): void { + this.handlers?.onStatus({ kind: 'closed', reason }); + } + + public deliver(message: unknown): void { + this.handlers?.onMessage(message); + } + + public init(capabilities: readonly string[]): void { + this.deliver({ source: BRIDGE_SOURCE, channel: 'page', v: 1, kind: 'init', capabilities }); + } + + public event(entry: Record): void { + this.deliver({ source: BRIDGE_SOURCE, channel: 'page', v: 1, kind: 'event', entry }); + } + + public respond(id: number, body: Record): void { + this.deliver({ source: BRIDGE_SOURCE, channel: 'page', v: 1, kind: 'response', id, ...body }); + } +} + +/** Manually advanced clock, so tests never wait in real time. */ +export class FakeClock { + private handle = 1; + private readonly timers = new Map void }>(); + private now = 0; + + public readonly setTimeout = (run: () => void, ms: number): number => { + const id = this.handle++; + this.timers.set(id, { at: this.now + ms, run }); + return id; + }; + + public readonly clearTimeout = (id: number): void => { + this.timers.delete(id); + }; + + public advance(ms: number): void { + this.now += ms; + for (const [id, timer] of [...this.timers]) { + if (timer.at <= this.now) { + this.timers.delete(id); + timer.run(); + } + } + } + + public get pending(): number { + return this.timers.size; + } +} diff --git a/tests/unit/background.router.test.ts b/tests/unit/background.router.test.ts new file mode 100644 index 0000000..ccc6a46 --- /dev/null +++ b/tests/unit/background.router.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { BridgeRouter, type RouterPort, type RouterSender } from '../../src/background/router'; +import { ENVELOPE_SOURCE, PANEL_PORT_NAME } from '../../src/protocol/envelope'; + +class FakePort implements RouterPort { + public readonly received: unknown[] = []; + private messageListener: ((message: unknown) => void) | null = null; + private disconnectListener: (() => void) | null = null; + + constructor(public readonly name: string = PANEL_PORT_NAME) {} + + public postMessage(message: unknown): void { + this.received.push(message); + } + + public readonly onMessage = { + addListener: (listener: (message: unknown) => void): void => { + this.messageListener = listener; + }, + }; + + public readonly onDisconnect = { + addListener: (listener: () => void): void => { + this.disconnectListener = listener; + }, + }; + + public emit(message: unknown): void { + this.messageListener?.(message); + } + + public disconnect(): void { + this.disconnectListener?.(); + } + + /** The token handed out by the router, if this port attached. */ + public get token(): string { + const attached = this.received.find( + (message): message is { type: string; token: string } => + typeof message === 'object' && + message !== null && + (message as { type?: unknown }).type === 'attached' + ); + return attached?.token ?? ''; + } +} + +const attach = (port: FakePort, tabId: number): void => { + port.emit({ source: ENVELOPE_SOURCE, type: 'attach', tabId }); +}; + +let router: BridgeRouter; +let delivered: Array<{ tabId: number; payload: unknown }>; +let injected: number[]; +let injectionError: Error | null; +let tokenCounter: number; + +beforeEach(() => { + delivered = []; + injected = []; + injectionError = null; + tokenCounter = 0; + router = new BridgeRouter({ + extensionId: 'test-extension', + createToken: () => `token-${++tokenCounter}`, + sendToTab: async (tabId, payload) => { + delivered.push({ tabId, payload }); + }, + injectContentScript: async tabId => { + if (injectionError) throw injectionError; + injected.push(tabId); + }, + }); +}); + +describe('connections', () => { + test('ignores ports that are not the panel port', () => { + const port = new FakePort('some-other-extension'); + router.handleConnect(port); + attach(port, 1); + expect(port.received).toHaveLength(0); + expect(router.attachedTabs).toBe(0); + }); + + test('attaching issues a session token once', () => { + const port = new FakePort(); + router.handleConnect(port); + attach(port, 7); + attach(port, 8); + expect(port.received).toEqual([ + { source: ENVELOPE_SOURCE, type: 'attached', token: 'token-1', tabId: 7 }, + ]); + expect(router.attachedTabs).toBe(1); + }); + + test('disconnecting frees the route', () => { + const port = new FakePort(); + router.handleConnect(port); + attach(port, 3); + port.disconnect(); + expect(router.attachedTabs).toBe(0); + }); + + test('a reconnecting panel replaces the old route for its tab', () => { + const first = new FakePort(); + const second = new FakePort(); + router.handleConnect(first); + router.handleConnect(second); + attach(first, 5); + attach(second, 5); + expect(router.attachedTabs).toBe(1); + + // The stale port disconnecting must not tear down the live route. + first.disconnect(); + expect(router.attachedTabs).toBe(1); + }); +}); + +describe('panel → page', () => { + test('forwards a payload to the attached tab', async () => { + const port = new FakePort(); + router.handleConnect(port); + attach(port, 42); + port.emit({ + source: ENVELOPE_SOURCE, + type: 'to-page', + token: port.token, + payload: { kind: 'hello' }, + }); + await Promise.resolve(); + expect(delivered).toEqual([{ tabId: 42, payload: { kind: 'hello' } }]); + }); + + test('drops a payload carrying the wrong token', async () => { + const port = new FakePort(); + router.handleConnect(port); + attach(port, 42); + port.emit({ + source: ENVELOPE_SOURCE, + type: 'to-page', + token: 'stolen', + payload: { kind: 'hello' }, + }); + await Promise.resolve(); + expect(delivered).toEqual([]); + }); + + test('drops a payload sent before attaching', async () => { + const port = new FakePort(); + router.handleConnect(port); + port.emit({ source: ENVELOPE_SOURCE, type: 'to-page', token: '', payload: {} }); + await Promise.resolve(); + expect(delivered).toEqual([]); + }); + + test("one panel cannot reach another panel's tab", async () => { + const a = new FakePort(); + const b = new FakePort(); + router.handleConnect(a); + router.handleConnect(b); + attach(a, 1); + attach(b, 2); + a.emit({ source: ENVELOPE_SOURCE, type: 'to-page', token: a.token, payload: 'from-a' }); + b.emit({ source: ENVELOPE_SOURCE, type: 'to-page', token: b.token, payload: 'from-b' }); + await Promise.resolve(); + expect(delivered).toEqual([ + { tabId: 1, payload: 'from-a' }, + { tabId: 2, payload: 'from-b' }, + ]); + }); +}); + +describe('page → panel', () => { + const sender = (tabId?: number, id = 'test-extension'): RouterSender => ({ + id, + ...(tabId === undefined ? {} : { tab: { id: tabId } }), + }); + + test('routes to the panel attached to the sending tab', () => { + const port = new FakePort(); + router.handleConnect(port); + attach(port, 9); + const routed = router.handleContentMessage( + { source: ENVELOPE_SOURCE, type: 'from-page', payload: { kind: 'init' } }, + sender(9) + ); + expect(routed).toBe(true); + expect(port.received.at(-1)).toEqual({ + source: ENVELOPE_SOURCE, + type: 'from-page', + payload: { kind: 'init' }, + }); + }); + + test.each([ + ['an unattached tab', 9, 4, 'test-extension'], + ['a foreign extension', 9, 9, 'other-extension'], + ])('drops a message from %s', (_label, attachedTab, senderTab, senderId) => { + const port = new FakePort(); + router.handleConnect(port); + attach(port, attachedTab as number); + const routed = router.handleContentMessage( + { source: ENVELOPE_SOURCE, type: 'from-page', payload: {} }, + sender(senderTab as number, senderId as string) + ); + expect(routed).toBe(false); + }); + + test('drops a message with no tab (i.e. not from a content script)', () => { + const port = new FakePort(); + router.handleConnect(port); + attach(port, 1); + expect( + router.handleContentMessage( + { source: ENVELOPE_SOURCE, type: 'from-page', payload: {} }, + sender(undefined) + ) + ).toBe(false); + }); + + test('drops an envelope of the wrong type', () => { + const port = new FakePort(); + router.handleConnect(port); + attach(port, 1); + expect( + router.handleContentMessage({ source: ENVELOPE_SOURCE, type: 'attach' }, sender(1)) + ).toBe(false); + }); +}); + +describe('injection', () => { + test('reports success back to the panel', async () => { + const port = new FakePort(); + router.handleConnect(port); + attach(port, 11); + port.emit({ source: ENVELOPE_SOURCE, type: 'inject', token: port.token }); + await Promise.resolve(); + await Promise.resolve(); + expect(injected).toEqual([11]); + expect(port.received.at(-1)).toEqual({ + source: ENVELOPE_SOURCE, + type: 'inject-result', + ok: true, + }); + }); + + test('reports the failure reason', async () => { + injectionError = new Error('Cannot access contents of the page'); + const port = new FakePort(); + router.handleConnect(port); + attach(port, 11); + port.emit({ source: ENVELOPE_SOURCE, type: 'inject', token: port.token }); + await Promise.resolve(); + await Promise.resolve(); + expect(port.received.at(-1)).toEqual({ + source: ENVELOPE_SOURCE, + type: 'inject-result', + ok: false, + reason: 'Cannot access contents of the page', + }); + }); + + test('refuses an injection request without the session token', async () => { + const port = new FakePort(); + router.handleConnect(port); + attach(port, 11); + port.emit({ source: ENVELOPE_SOURCE, type: 'inject', token: 'guessed' }); + await Promise.resolve(); + expect(injected).toEqual([]); + }); +}); diff --git a/tests/unit/panel.settings.test.ts b/tests/unit/panel.settings.test.ts new file mode 100644 index 0000000..700cba6 --- /dev/null +++ b/tests/unit/panel.settings.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import { + DEFAULT_SETTINGS, + MAX_POLL_INTERVAL_MS, + MIN_POLL_INTERVAL_MS, + normalizeSettings, +} from '../../src/panel/settings'; +import { MAX_BUFFER_SIZE, MIN_BUFFER_SIZE } from '../../src/panel/timeline'; + +describe('normalizeSettings', () => { + test('junk falls back to the defaults', () => { + expect(normalizeSettings(null)).toEqual(DEFAULT_SETTINGS); + expect(normalizeSettings('corrupt')).toEqual(DEFAULT_SETTINGS); + expect(normalizeSettings({})).toEqual(DEFAULT_SETTINGS); + }); + + test('out-of-range values are clamped, not rejected', () => { + expect(normalizeSettings({ bufferSize: 1, pollIntervalMs: 1 })).toMatchObject({ + bufferSize: MIN_BUFFER_SIZE, + pollIntervalMs: MIN_POLL_INTERVAL_MS, + }); + expect(normalizeSettings({ bufferSize: 1e9, pollIntervalMs: 1e9 })).toMatchObject({ + bufferSize: MAX_BUFFER_SIZE, + pollIntervalMs: MAX_POLL_INTERVAL_MS, + }); + }); + + test('valid values round-trip', () => { + const settings = { bufferSize: 750, pollIntervalMs: 500, preferLiveStreaming: true }; + expect(normalizeSettings(settings)).toEqual(settings); + }); + + test('preferLiveStreaming is strictly boolean', () => { + expect(normalizeSettings({ preferLiveStreaming: 'yes' }).preferLiveStreaming).toBe(false); + expect(normalizeSettings({ preferLiveStreaming: 1 }).preferLiveStreaming).toBe(false); + }); +}); diff --git a/tests/unit/panel.state.test.ts b/tests/unit/panel.state.test.ts new file mode 100644 index 0000000..3b1b943 --- /dev/null +++ b/tests/unit/panel.state.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { BridgeClient } from '../../src/protocol/client'; +import { PanelState } from '../../src/panel/state'; +import { TimelineBuffer } from '../../src/panel/timeline'; +import { FakeClock, FakeTransport } from '../helpers/bridge'; + +const snapshot = { + version: 1, + exportedAt: 500, + state: { timeline: [] }, + signals: [{ label: 'count', value: 0, subscriberCount: 1 }], + stores: [{ id: 'cart', state: { items: 0 } }], + components: [{ tagName: 'my-app', instanceCount: 1 }], +}; + +const componentTree = { + tree: [{ tag: 'my-app', id: '0', attrs: {}, children: [] }], + flat: [{ tagName: 'my-app', instanceCount: 1 }], +}; + +let transport: FakeTransport; +let client: BridgeClient; +let state: PanelState; +let buffer: TimelineBuffer; + +/** Let queued microtasks and timers run. */ +const flush = (): Promise => new Promise(resolve => setTimeout(resolve, 0)); + +/** + * Answer every request the state has issued so far, then keep going while it + * issues follow-up requests (the snapshot fetch gates the timeline seed). + */ +const answerAll = async (results: Record): Promise => { + for (let pass = 0; pass < 6; pass += 1) { + await flush(); + let answeredAny = false; + for (const message of transport.sent) { + if (message.kind !== 'request' || transport.answered.has(message.id)) continue; + transport.answered.add(message.id); + transport.respond(message.id, { result: results[message.method] }); + answeredAny = true; + } + if (!answeredAny && pass > 1) break; + } + await flush(); +}; + +beforeEach(() => { + transport = new FakeTransport(); + client = new BridgeClient(transport, { + requestTimeoutMs: 1000, + helloIntervalMs: 1000, + setTimeout: new FakeClock().setTimeout, + clearTimeout: () => undefined, + }); + buffer = new TimelineBuffer(100); + state = new PanelState(client, buffer); +}); + +describe('connect', () => { + test('fetches tree, snapshot and timeline seed once the page answers', async () => { + state.start(); + transport.open(); + transport.init(['components', 'signals', 'stores', 'timeline', 'time-travel']); + await answerAll({ + getComponentTree: componentTree, + getSnapshot: snapshot, + getTimeline: [{ type: 'mark', detail: 'boot', timestamp: 1 }], + }); + + expect(state.tree.value).toHaveLength(1); + expect(state.signals.value[0]?.label).toBe('count'); + expect(state.stores.value[0]?.id).toBe('cart'); + expect(state.components.value[0]?.tagName).toBe('my-app'); + expect(state.entries()).toHaveLength(1); + expect(state.lastError.value).toBe(''); + expect(state.loading.value).toBe(false); + }); + + test('skips requests for capabilities the page did not advertise', async () => { + state.start(); + transport.open(); + transport.init(['signals']); + await answerAll({ getSnapshot: snapshot }); + + const methods = transport.sent + .filter(message => message.kind === 'request') + .map(message => message.method); + expect(methods).toEqual(['getSnapshot']); + expect(state.tree.value).toEqual([]); + }); + + test('events streamed during the seed fetch are not overwritten by it', async () => { + state.start(); + transport.open(); + transport.init(['timeline']); + await flush(); + + // The page emits while `getTimeline` is still in flight. + transport.event({ type: 'signal:update', detail: 'late', timestamp: 30 }); + await answerAll({ + getSnapshot: snapshot, + getTimeline: [{ type: 'mark', detail: 'seeded', timestamp: 10 }], + }); + + expect(state.entries().map(entry => entry.detail)).toEqual(['seeded', 'late']); + }); + + test('an entry present in both the seed and the stream is kept once', async () => { + state.start(); + transport.open(); + transport.init(['timeline']); + await flush(); + + const duplicate = { type: 'mark', detail: 'boot', timestamp: 10, source: 'app' }; + transport.event(duplicate); + await answerAll({ getSnapshot: snapshot, getTimeline: [duplicate] }); + + expect(state.entries()).toHaveLength(1); + }); + + test('a failed request surfaces as an error instead of throwing', async () => { + state.start(); + transport.open(); + transport.init(['signals']); + await flush(); + const request = transport.sent.find(message => message.kind === 'request'); + transport.respond(request?.id ?? 1, { error: 'devtools are disabled' }); + await flush(); + expect(state.lastError.value).toMatch(/devtools are disabled/); + expect(state.loading.value).toBe(false); + }); +}); + +describe('streaming', () => { + const connect = async (): Promise => { + state.start(); + transport.open(); + transport.init(['components', 'signals', 'stores', 'timeline', 'time-travel']); + await answerAll({ getComponentTree: componentTree, getSnapshot: snapshot, getTimeline: [] }); + }; + + test('buffers streamed events and bumps the revision', async () => { + await connect(); + const before = state.timelineRevision.value; + transport.event({ type: 'signal:update', detail: 'count', timestamp: 2 }); + expect(state.entries()).toHaveLength(1); + expect(state.timelineRevision.value).toBeGreaterThan(before); + }); + + test('pausing drops streamed events', async () => { + await connect(); + state.paused.value = true; + transport.event({ type: 'signal:update', detail: 'count', timestamp: 2 }); + expect(state.entries()).toHaveLength(0); + }); + + test('clearing empties the buffer and leaves time travel', async () => { + await connect(); + transport.event({ type: 'signal:update', detail: 'count', timestamp: 2 }); + state.travelTo(0); + state.clearTimeline(); + expect(state.entries()).toHaveLength(0); + expect(state.timeTravelIndex.value).toBeNull(); + }); + + test('resizing the buffer clamps an out-of-range replay position', async () => { + await connect(); + for (let index = 0; index < 10; index += 1) { + transport.event({ type: 'signal:update', detail: `#${index}`, timestamp: index }); + } + state.travelTo(9); + state.setBufferSize(50); + expect(state.bufferCapacity()).toBe(50); + // 50 is below MIN_BUFFER_SIZE-clamped range only if smaller; here the + // buffer still holds every entry, so the position survives. + expect(state.timeTravelIndex.value).toBe(9); + }); +}); + +describe('time travel', () => { + const connect = async (): Promise => { + state.start(); + transport.open(); + transport.init(['components', 'signals', 'stores', 'timeline', 'time-travel']); + await answerAll({ getComponentTree: componentTree, getSnapshot: snapshot, getTimeline: [] }); + }; + + test('is null while following live state', async () => { + await connect(); + expect(state.reconstruction.value).toBeNull(); + }); + + test('replays from the connect-time snapshot', async () => { + await connect(); + transport.event({ + type: 'signal:update', + detail: 'count', + timestamp: 2, + source: 'count', + payload: { value: 42 }, + }); + state.travelTo(0); + + const replay = state.reconstruction.value; + expect(replay?.index).toBe(0); + expect(replay?.signals.find(item => item.label === 'count')?.value).toBe(42); + // Travelling pauses streaming so the replayed view holds still. + expect(state.paused.value).toBe(true); + }); + + test('resuming clears the replay and unpauses', async () => { + await connect(); + transport.event({ type: 'signal:update', detail: 'count', timestamp: 2 }); + state.travelTo(0); + state.resumeLive(); + expect(state.timeTravelIndex.value).toBeNull(); + expect(state.paused.value).toBe(false); + expect(state.reconstruction.value).toBeNull(); + }); + + test('travelling with an empty buffer is a no-op', async () => { + await connect(); + state.travelTo(0); + expect(state.timeTravelIndex.value).toBeNull(); + }); +}); + +describe('reconnect', () => { + test('refetches everything on a second handshake', async () => { + state.start(); + transport.open(); + transport.init(['signals']); + await answerAll({ getSnapshot: snapshot }); + const first = transport.sent.filter(message => message.kind === 'request').length; + + client.resetHandshake('page navigated'); + transport.init(['signals']); + await answerAll({ getSnapshot: snapshot }); + expect(transport.sent.filter(message => message.kind === 'request').length).toBeGreaterThan( + first + ); + }); +}); diff --git a/tests/unit/panel.timeTravel.test.ts b/tests/unit/panel.timeTravel.test.ts new file mode 100644 index 0000000..b61e10b --- /dev/null +++ b/tests/unit/panel.timeTravel.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'bun:test'; +import type { TimelineEntry } from '../../src/protocol/messages'; +import { + extractSignalValue, + extractStorePatch, + isReplayable, + reconstructAt, + UNKNOWN_VALUE, + type TimeTravelBase, +} from '../../src/panel/timeTravel'; + +const entry = (type: string, source: string, payload?: unknown, timestamp = 0): TimelineEntry => + ({ timestamp, type, detail: source, source, payload }) as TimelineEntry; + +const base: TimeTravelBase = { + signals: [ + { label: 'count', value: 0, subscriberCount: 1 }, + { label: 'name', value: 'ada', subscriberCount: 0 }, + ], + stores: [{ id: 'cart', state: { items: 0, open: false } }], + capturedAt: 1000, +}; + +describe('payload extraction', () => { + test.each([ + [{ value: 5 }, 5], + [{ next: 6 }, 6], + [{ to: 7 }, 7], + [8, 8], + ['done', 'done'], + [{ unrelated: 1 }, { unrelated: 1 }], + ])('reads %p as %p', (payload, expected) => { + expect(extractSignalValue(entry('signal:update', 'count', payload))).toEqual(expected); + }); + + test('an absent payload is not guessed', () => { + expect(extractSignalValue(entry('signal:update', 'count'))).toBe(UNKNOWN_VALUE); + }); + + test('store patches are read from patch/state/next or the payload itself', () => { + expect(extractStorePatch(entry('store:patch', 'cart', { patch: { items: 2 } }))).toEqual({ + items: 2, + }); + expect(extractStorePatch(entry('store:patch', 'cart', { state: { items: 3 } }))).toEqual({ + items: 3, + }); + expect(extractStorePatch(entry('store:patch', 'cart', { items: 4 }))).toEqual({ items: 4 }); + expect(extractStorePatch(entry('store:patch', 'cart', 'nope'))).toBeUndefined(); + }); + + test('only state-changing events replay', () => { + expect(isReplayable(entry('signal:update', 'a'))).toBe(true); + expect(isReplayable(entry('store:action', 'a'))).toBe(true); + expect(isReplayable(entry('component:mount', 'a'))).toBe(false); + expect(isReplayable(entry('measure', 'a'))).toBe(false); + }); +}); + +describe('reconstructAt', () => { + const entries = [ + entry('signal:update', 'count', { value: 1 }, 1001), + entry('component:mount', 'my-app', undefined, 1002), + entry('store:patch', 'cart', { patch: { items: 2 } }, 1003), + entry('signal:update', 'count', { value: 2 }, 1004), + ]; + + test('index -1 yields the untouched base state', () => { + const result = reconstructAt(base, entries, -1); + expect(result.index).toBe(-1); + expect(result.timestamp).toBe(1000); + expect(result.appliedCount).toBe(0); + expect(result.signals.find(item => item.label === 'count')?.value).toBe(0); + expect(result.signals.every(item => item.fromBase)).toBe(true); + }); + + test('replays signals and stores up to the given index', () => { + const result = reconstructAt(base, entries, 2); + expect(result.timestamp).toBe(1003); + expect(result.signals.find(item => item.label === 'count')).toMatchObject({ + value: 1, + fromBase: false, + unresolved: false, + }); + // Untouched entries stay flagged as base state. + expect(result.signals.find(item => item.label === 'name')?.fromBase).toBe(true); + // Patches merge onto the base state instead of replacing it. + expect(result.stores[0]?.state).toEqual({ items: 2, open: false }); + expect(result.appliedCount).toBe(2); + }); + + test('replaying further advances the value', () => { + expect( + reconstructAt(base, entries, 3).signals.find(item => item.label === 'count')?.value + ).toBe(2); + }); + + test('an index past the end is clamped', () => { + expect(reconstructAt(base, entries, 99).index).toBe(entries.length - 1); + }); + + test('signals the page never mentioned in the base still appear', () => { + const result = reconstructAt(base, [entry('signal:create', 'fresh', { value: 'new' })], 0); + expect(result.signals.find(item => item.label === 'fresh')?.value).toBe('new'); + }); + + test('unrecorded payloads are reported, never invented', () => { + const result = reconstructAt(base, [entry('signal:update', 'count')], 0); + const count = result.signals.find(item => item.label === 'count'); + expect(count?.unresolved).toBe(true); + // The last known value is kept rather than replaced with a guess. + expect(count?.value).toBe(0); + expect(result.unresolvedCount).toBe(1); + expect(result.appliedCount).toBe(0); + }); + + test('an unusable store payload marks the store unresolved', () => { + const result = reconstructAt(base, [entry('store:patch', 'cart', 'garbage')], 0); + expect(result.stores[0]).toMatchObject({ id: 'cart', unresolved: true }); + expect(result.stores[0]?.state).toEqual({ items: 0, open: false }); + }); + + test('entries without a source or detail key are skipped', () => { + const orphan = { timestamp: 1, type: 'signal:update', detail: '' } as TimelineEntry; + expect(reconstructAt(base, [orphan], 0).appliedCount).toBe(0); + }); + + test('output is sorted for a stable UI', () => { + const result = reconstructAt(base, entries, 3); + expect(result.signals.map(item => item.label)).toEqual(['count', 'name']); + }); +}); diff --git a/tests/unit/panel.timeline.test.ts b/tests/unit/panel.timeline.test.ts new file mode 100644 index 0000000..50241d2 --- /dev/null +++ b/tests/unit/panel.timeline.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from 'bun:test'; +import type { TimelineEntry } from '../../src/protocol/messages'; +import { + clampBufferSize, + collectTypes, + DEFAULT_BUFFER_SIZE, + filterEntries, + MAX_BUFFER_SIZE, + MIN_BUFFER_SIZE, + TimelineBuffer, +} from '../../src/panel/timeline'; + +const entry = (type: string, detail = '', source?: string): TimelineEntry => + ({ timestamp: 0, type, detail, ...(source ? { source } : {}) }) as TimelineEntry; + +describe('clampBufferSize', () => { + test.each([ + [10, MIN_BUFFER_SIZE], + [999999, MAX_BUFFER_SIZE], + [500, 500], + [500.7, 500], + ['nonsense', DEFAULT_BUFFER_SIZE], + [Number.NaN, DEFAULT_BUFFER_SIZE], + ])('clamps %p to %p', (input, expected) => { + expect(clampBufferSize(input)).toBe(expected as number); + }); +}); + +describe('TimelineBuffer', () => { + test('evicts the oldest entries when full and counts the drops', () => { + const buffer = new TimelineBuffer(MIN_BUFFER_SIZE); + for (let index = 0; index < MIN_BUFFER_SIZE + 5; index += 1) { + buffer.push(entry('signal:update', `#${index}`)); + } + expect(buffer.size).toBe(MIN_BUFFER_SIZE); + expect(buffer.dropped).toBe(5); + expect(buffer.all()[0]?.detail).toBe('#5'); + expect(buffer.all().at(-1)?.detail).toBe(`#${MIN_BUFFER_SIZE + 4}`); + }); + + test('shrinking the capacity trims immediately', () => { + const buffer = new TimelineBuffer(200); + buffer.extend(Array.from({ length: 120 }, (_, index) => entry('mark', `#${index}`))); + buffer.resize(60); + expect(buffer.size).toBe(60); + expect(buffer.capacity).toBe(60); + expect(buffer.all()[0]?.detail).toBe('#60'); + }); + + test('growing the capacity keeps what is there', () => { + const buffer = new TimelineBuffer(MIN_BUFFER_SIZE); + buffer.extend(Array.from({ length: 20 }, () => entry('mark'))); + buffer.resize(500); + expect(buffer.size).toBe(20); + }); + + test('reset replaces the contents and clears the drop counter', () => { + const buffer = new TimelineBuffer(MIN_BUFFER_SIZE); + buffer.extend(Array.from({ length: MIN_BUFFER_SIZE + 10 }, () => entry('mark'))); + expect(buffer.dropped).toBeGreaterThan(0); + buffer.reset([entry('signal:create', 'seed')]); + expect(buffer.size).toBe(1); + expect(buffer.dropped).toBe(0); + }); + + test('clear empties the buffer', () => { + const buffer = new TimelineBuffer(); + buffer.push(entry('mark')); + buffer.clear(); + expect(buffer.size).toBe(0); + expect(buffer.all()).toEqual([]); + }); +}); + +describe('filterEntries', () => { + const entries = [ + entry('signal:update', 'count → 2', 'count'), + entry('store:patch', 'cart items', 'cart'), + entry('component:mount', ''), + ]; + + test('an empty filter keeps everything', () => { + expect(filterEntries(entries, { types: new Set(), search: '' })).toHaveLength(3); + }); + + test('filters by type', () => { + const filtered = filterEntries(entries, { types: new Set(['store:patch']), search: '' }); + expect(filtered.map(item => item.type)).toEqual(['store:patch']); + }); + + test('searches type, detail and source case-insensitively', () => { + expect(filterEntries(entries, { types: new Set(), search: 'CART' })).toHaveLength(1); + expect(filterEntries(entries, { types: new Set(), search: 'my-app' })).toHaveLength(1); + expect(filterEntries(entries, { types: new Set(), search: 'signal' })).toHaveLength(1); + }); + + test('type and search compose', () => { + expect( + filterEntries(entries, { types: new Set(['signal:update']), search: 'cart' }) + ).toHaveLength(0); + }); +}); + +describe('collectTypes', () => { + test('returns each type once, sorted', () => { + expect(collectTypes([entry('mark'), entry('signal:update'), entry('mark')])).toEqual([ + 'mark', + 'signal:update', + ]); + }); +}); diff --git a/tests/unit/panel.tree.test.ts b/tests/unit/panel.tree.test.ts new file mode 100644 index 0000000..201fd2a --- /dev/null +++ b/tests/unit/panel.tree.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from 'bun:test'; +import type { ComponentTreeNode } from '../../src/protocol/messages'; +import { + buildSelectExpression, + flattenTree, + nodeAtPath, + parsePathKey, + pathKey, +} from '../../src/panel/tree'; + +const node = ( + tag: string, + children: ComponentTreeNode[] = [], + attrs: Record = {} +): ComponentTreeNode => ({ tag, id: tag, attrs, children }); + +const tree: ComponentTreeNode[] = [ + node('my-app', [ + node('my-header', [], { title: 'Dashboard' }), + node('my-list', [node('my-item', [], { 'data-id': '1' }), node('my-item')]), + ]), + node('my-footer'), +]; + +describe('path keys', () => { + test('round-trip', () => { + expect(parsePathKey(pathKey([0, 1, 2]))).toEqual([0, 1, 2]); + }); + + test('junk is discarded', () => { + expect(parsePathKey('')).toEqual([]); + expect(parsePathKey('0..x.-1.2')).toEqual([0, 2]); + }); +}); + +describe('flattenTree', () => { + test('walks depth-first and records depth and path', () => { + const flat = flattenTree(tree); + expect(flat.map(item => item.node.tag)).toEqual([ + 'my-app', + 'my-header', + 'my-list', + 'my-item', + 'my-item', + 'my-footer', + ]); + expect(flat[3]).toMatchObject({ depth: 2, path: [0, 1, 0] }); + expect(flat.every(item => item.matched === false)).toBe(true); + }); + + test('a search keeps matches and their ancestors', () => { + const flat = flattenTree(tree, 'item'); + expect(flat.map(item => item.node.tag)).toEqual(['my-app', 'my-list', 'my-item', 'my-item']); + expect(flat.filter(item => item.matched).map(item => item.node.tag)).toEqual([ + 'my-item', + 'my-item', + ]); + }); + + test('search matches attribute names and values', () => { + expect(flattenTree(tree, 'dashboard').map(item => item.node.tag)).toEqual([ + 'my-app', + 'my-header', + ]); + expect(flattenTree(tree, 'data-id').map(item => item.node.tag)).toEqual([ + 'my-app', + 'my-list', + 'my-item', + ]); + }); + + test('a search with no hits yields nothing', () => { + expect(flattenTree(tree, 'zzz')).toEqual([]); + }); + + test('whitespace-only searches are treated as empty', () => { + expect(flattenTree(tree, ' ')).toHaveLength(6); + }); +}); + +describe('nodeAtPath', () => { + test('resolves a nested path', () => { + expect(nodeAtPath(tree, [0, 1, 1])?.tag).toBe('my-item'); + expect(nodeAtPath(tree, [1])?.tag).toBe('my-footer'); + }); + + test('returns null for a path that does not exist', () => { + expect(nodeAtPath(tree, [5])).toBeNull(); + expect(nodeAtPath(tree, [0, 9, 0])).toBeNull(); + }); +}); + +describe('buildSelectExpression', () => { + test('refuses malformed paths', () => { + expect(buildSelectExpression([])).toBeNull(); + expect(buildSelectExpression([-1])).toBeNull(); + expect(buildSelectExpression([1.5])).toBeNull(); + }); + + test('embeds the path as a literal', () => { + expect(buildSelectExpression([0, 1, 2])).toContain('var path = [0,1,2];'); + }); + + test('produces a syntactically valid expression', () => { + const expression = buildSelectExpression([0]); + expect(expression).not.toBeNull(); + expect(() => new Function(`return ${expression as string};`)).not.toThrow(); + }); + + test('the generated walker mirrors the framework serialization', () => { + // Reproduce `serializeComponentTree`'s rule: custom elements become nodes, + // plain elements are flattened away. The generated source is executed + // against a minimal fake DOM to prove the panel and the page agree. + interface FakeElement { + tagName: string; + children: FakeElement[]; + scrollIntoView(): void; + } + const make = (tagName: string, children: FakeElement[] = []): FakeElement => ({ + tagName: tagName.toUpperCase(), + children, + scrollIntoView: () => undefined, + }); + + const target = make('my-item'); + const body = make('body', [ + make('div', [make('my-app', [make('span', [make('my-header')]), make('my-list', [target])])]), + ]); + + const inspected: FakeElement[] = []; + const expression = buildSelectExpression([0, 1, 0]) as string; + const run = new Function('document', 'inspect', `return ${expression};`) as ( + doc: { body: FakeElement }, + inspect: (el: FakeElement) => void + ) => string | null; + + const result = run({ body }, element => inspected.push(element)); + expect(result).toBe('my-item'); + expect(inspected).toEqual([target]); + }); + + test('an out-of-range path resolves to null in the page', () => { + interface FakeElement { + tagName: string; + children: FakeElement[]; + scrollIntoView(): void; + } + const body: FakeElement = { tagName: 'BODY', children: [], scrollIntoView: () => undefined }; + const expression = buildSelectExpression([3]) as string; + const run = new Function('document', 'inspect', `return ${expression};`) as ( + doc: { body: FakeElement }, + inspect: (el: FakeElement) => void + ) => string | null; + expect(run({ body }, () => undefined)).toBeNull(); + }); +}); diff --git a/tests/unit/panel.valueTree.test.ts b/tests/unit/panel.valueTree.test.ts new file mode 100644 index 0000000..b2f7db5 --- /dev/null +++ b/tests/unit/panel.valueTree.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'bun:test'; +import { UNKNOWN_VALUE } from '../../src/panel/timeTravel'; +import { + describeValue, + ENTRY_LIMIT, + isExpandable, + PREVIEW_LIMIT, + shortPreview, +} from '../../src/panel/valueTree'; + +describe('describeValue', () => { + test.each([ + ['a string', 'hi', 'string', '"hi"'], + ['a number', 42, 'number', '42'], + ['a boolean', true, 'boolean', 'true'], + ['null', null, 'null', 'null'], + ['undefined', undefined, 'undefined', 'undefined'], + ])('describes %s', (_label, value, kind, preview) => { + expect(describeValue(value)).toEqual({ kind: kind as never, preview, entries: null }); + }); + + test('primitives are leaves', () => { + expect(isExpandable('hi')).toBe(false); + expect(isExpandable({ a: 1 })).toBe(true); + expect(isExpandable([])).toBe(false); + }); + + test('objects expose their entries', () => { + const described = describeValue({ a: 1, b: 'two' }); + expect(described.kind).toBe('object'); + expect(described.entries).toEqual([ + { key: 'a', value: 1 }, + { key: 'b', value: 'two' }, + ]); + expect(described.preview).toBe('{a: 1, b: "two"}'); + }); + + test('arrays expose indexed entries', () => { + const described = describeValue([1, 2, 3]); + expect(described.kind).toBe('array'); + expect(described.entries).toHaveLength(3); + expect(described.entries?.[0]).toEqual({ key: '0', value: 1 }); + expect(described.preview).toContain('Array(3)'); + }); + + test('previews are length-capped', () => { + const described = describeValue('x'.repeat(500)); + expect(described.preview.length).toBeLessThanOrEqual(PREVIEW_LIMIT); + expect(described.preview.endsWith('…')).toBe(true); + }); + + test('entry lists are capped', () => { + const big = Object.fromEntries( + Array.from({ length: ENTRY_LIMIT + 50 }, (_, index) => [`k${index}`, index]) + ); + expect(describeValue(big).entries).toHaveLength(ENTRY_LIMIT); + }); + + test('cyclic structures do not hang or throw', () => { + const cyclic: Record = { name: 'root' }; + cyclic['self'] = cyclic; + const described = describeValue(cyclic); + expect(described.kind).toBe('object'); + expect(described.entries?.map(item => item.key)).toEqual(['name', 'self']); + }); + + test('the time-travel unknown marker is labelled, not rendered as a symbol', () => { + expect(describeValue(UNKNOWN_VALUE)).toEqual({ + kind: 'unknown', + preview: '(not recorded)', + entries: null, + }); + }); + + test('functions and symbols degrade gracefully', () => { + expect(describeValue(() => 1).kind).toBe('function'); + expect(describeValue(Symbol('x')).kind).toBe('unknown'); + }); + + test('markup in a value stays data', () => { + const described = describeValue(''); + expect(described.preview).toBe('""'); + expect(described.entries).toBeNull(); + }); +}); + +describe('shortPreview', () => { + test.each([ + [null, 'null'], + [undefined, 'undefined'], + [[1, 2], 'Array(2)'], + [{ a: 1 }, '{…}'], + [7, '7'], + ])('previews %p as %p', (value, expected) => { + expect(shortPreview(value)).toBe(expected as string); + }); + + test('long strings are trimmed hard', () => { + expect(shortPreview('y'.repeat(100)).length).toBeLessThanOrEqual(24); + }); +}); diff --git a/tests/unit/protocol.client.test.ts b/tests/unit/protocol.client.test.ts new file mode 100644 index 0000000..02b74ba --- /dev/null +++ b/tests/unit/protocol.client.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { BridgeClient, BridgeMethodError, BridgeTimeoutError } from '../../src/protocol/client'; +import { FakeClock, FakeTransport } from '../helpers/bridge'; + +let transport: FakeTransport; +let clock: FakeClock; +let client: BridgeClient; + +beforeEach(() => { + transport = new FakeTransport(); + clock = new FakeClock(); + client = new BridgeClient(transport, { + requestTimeoutMs: 1000, + helloIntervalMs: 100, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + }); +}); + +describe('handshake', () => { + test('retries hello until the page answers', () => { + client.start(); + transport.open(); + expect(client.state.value).toBe('waiting-for-page'); + expect(transport.sent).toHaveLength(1); + + clock.advance(100); + clock.advance(100); + expect(transport.sent).toHaveLength(3); + + transport.init(['signals', 'timeline']); + expect(client.state.value).toBe('connected'); + + clock.advance(1000); + expect(transport.sent).toHaveLength(3); + }); + + test('negotiates capabilities and notifies onReady on every connect', () => { + const seen: string[][] = []; + client.onReady(capabilities => seen.push([...capabilities].sort())); + client.start(); + transport.open(); + transport.init(['signals', 'stores', 'wormhole']); + + expect([...client.capabilities.value].sort()).toEqual(['signals', 'stores']); + + client.resetHandshake('page navigated'); + transport.init(['timeline']); + expect(seen).toEqual([['signals', 'stores'], ['timeline']]); + }); +}); + +describe('requests', () => { + test('correlates responses by id', async () => { + client.start(); + transport.open(); + transport.init(['signals']); + + const first = client.request('getSnapshot'); + const second = client.request('getTimeline'); + // Answered out of order on purpose. + transport.respond(2, { result: 22 }); + transport.respond(1, { result: 11 }); + + expect(await first).toBe(11); + expect(await second).toBe(22); + }); + + test('rejects with the page-supplied error', async () => { + client.start(); + transport.open(); + const pending = client.request('nope'); + transport.respond(1, { error: 'Unknown method: nope' }); + await expect(pending).rejects.toBeInstanceOf(BridgeMethodError); + }); + + test('times out instead of leaking the promise', async () => { + client.start(); + transport.open(); + const pending = client.request('getSnapshot'); + clock.advance(1000); + await expect(pending).rejects.toBeInstanceOf(BridgeTimeoutError); + }); + + test('clears the timeout once answered', async () => { + client.start(); + transport.open(); + const pending = client.request('ping'); + transport.respond(1, { result: 'pong' }); + await pending; + // Only the hello retry timer may remain. + clock.advance(5000); + expect(client.state.value).not.toBe('error'); + }); +}); + +describe('reconnection', () => { + test('a closed transport rejects everything in flight', async () => { + client.start(); + transport.open(); + transport.init(['signals']); + const pending = client.request('getSnapshot'); + transport.close('background worker disconnected'); + await expect(pending).rejects.toThrow(/background worker disconnected/); + expect(client.state.value).toBe('disconnected'); + expect(client.capabilities.value.size).toBe(0); + }); + + test('resetHandshake restarts hello and clears capabilities', () => { + client.start(); + transport.open(); + transport.init(['signals']); + const before = transport.sent.length; + + client.resetHandshake(); + expect(client.state.value).toBe('waiting-for-page'); + expect(client.capabilities.value.size).toBe(0); + expect(transport.sent.length).toBe(before + 1); + }); + + test('a streamed event proves the page is alive', () => { + const entries: string[] = []; + client.onEvent(entry => entries.push(entry.type)); + client.start(); + transport.open(); + transport.event({ type: 'signal:update', detail: 'count', timestamp: 1 }); + expect(entries).toEqual(['signal:update']); + expect(client.state.value).toBe('connected'); + }); +}); + +describe('dispose', () => { + test('tears down timers, listeners and the transport', async () => { + client.start(); + transport.open(); + const pending = client.request('ping'); + client.dispose(); + await expect(pending).rejects.toThrow(/disposed/); + expect(transport.disposed).toBe(true); + expect(clock.pending).toBe(0); + await expect(client.request('ping')).rejects.toThrow(/disposed/); + }); +}); diff --git a/tests/unit/protocol.messages.test.ts b/tests/unit/protocol.messages.test.ts new file mode 100644 index 0000000..dfa45ec --- /dev/null +++ b/tests/unit/protocol.messages.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from 'bun:test'; +import { + BRIDGE_PROTOCOL_VERSION, + BRIDGE_SOURCE, + helloMessage, + negotiateCapabilities, + parseOutbound, + requestMessage, +} from '../../src/protocol/messages'; + +const pageMessage = (extra: Record): Record => ({ + source: BRIDGE_SOURCE, + channel: 'page', + v: BRIDGE_PROTOCOL_VERSION, + ...extra, +}); + +describe('message builders', () => { + test('hello carries the negotiated version and panel channel', () => { + expect(helloMessage()).toEqual({ + source: BRIDGE_SOURCE, + channel: 'panel', + v: 1, + kind: 'hello', + }); + }); + + test('request omits params when none are given', () => { + expect(requestMessage(7, 'ping')).not.toHaveProperty('params'); + expect(requestMessage(7, 'getTimeline', { limit: 5 })).toMatchObject({ + id: 7, + method: 'getTimeline', + params: { limit: 5 }, + }); + }); +}); + +describe('parseOutbound', () => { + test('accepts an init handshake', () => { + const parsed = parseOutbound(pageMessage({ kind: 'init', capabilities: ['signals', 42] })); + expect(parsed).toEqual({ + source: BRIDGE_SOURCE, + channel: 'page', + v: 1, + kind: 'init', + capabilities: ['signals'], + }); + }); + + test('accepts a result response and an error response', () => { + expect( + parseOutbound(pageMessage({ kind: 'response', id: 1, result: { ok: true } })) + ).toMatchObject({ id: 1, result: { ok: true } }); + const failed = parseOutbound(pageMessage({ kind: 'response', id: 2, error: 'boom' })); + expect(failed).toMatchObject({ id: 2, error: 'boom' }); + expect(failed).not.toHaveProperty('result'); + }); + + test('normalizes a streamed timeline entry', () => { + const parsed = parseOutbound( + pageMessage({ + kind: 'event', + entry: { + type: 'signal:update', + detail: 'count', + timestamp: 5, + source: 'count', + duration: 1.5, + payload: { value: 2 }, + // Unknown fields must not survive. + rogue: '', + }, + }) + ); + expect(parsed?.kind).toBe('event'); + const entry = (parsed as unknown as { entry: Record }).entry; + expect(entry).toEqual({ + timestamp: 5, + type: 'signal:update', + detail: 'count', + payload: { value: 2 }, + source: 'count', + duration: 1.5, + }); + }); + + test.each([ + ['not an object', 42], + ['foreign source', { source: 'evil', channel: 'page', v: 1, kind: 'init' }], + ['panel channel echoed back', pageMessage({ channel: 'panel', kind: 'init' })], + ['unknown kind', pageMessage({ kind: 'shutdown' })], + ['response without a numeric id', pageMessage({ kind: 'response', id: 'one' })], + ['event without an entry type', pageMessage({ kind: 'event', entry: { detail: 'x' } })], + ])('rejects %s', (_label, input) => { + expect(parseOutbound(input)).toBeNull(); + }); + + test('rejects another protocol version instead of guessing', () => { + expect( + parseOutbound({ source: BRIDGE_SOURCE, channel: 'page', v: 2, kind: 'init' }) + ).toBeNull(); + }); +}); + +describe('negotiateCapabilities', () => { + test('keeps known capabilities and drops the rest', () => { + const negotiated = negotiateCapabilities(['signals', 'stores', 'teleport']); + expect([...negotiated].sort()).toEqual(['signals', 'stores']); + }); + + test('an empty handshake negotiates nothing', () => { + expect(negotiateCapabilities([]).size).toBe(0); + }); +}); diff --git a/tests/unit/protocol.results.test.ts b/tests/unit/protocol.results.test.ts new file mode 100644 index 0000000..32017e7 --- /dev/null +++ b/tests/unit/protocol.results.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { parseComponentTree, parseSnapshot, parseTimeline } from '../../src/protocol/results'; + +describe('parseSnapshot', () => { + test('lifts the timeline out of the nested devtools state', () => { + const snapshot = parseSnapshot({ + version: 1, + exportedAt: 1234, + state: { enabled: true, timeline: [{ type: 'mark', detail: 'boot', timestamp: 1 }] }, + signals: [{ label: 'count', value: 3, subscriberCount: 2 }], + stores: [{ id: 'cart', state: { items: 1 } }], + components: [{ tagName: 'my-app', instanceCount: 1 }], + }); + expect(snapshot).not.toBeNull(); + expect(snapshot?.exportedAt).toBe(1234); + expect(snapshot?.signals).toEqual([{ label: 'count', value: 3, subscriberCount: 2 }]); + expect(snapshot?.stores).toEqual([{ id: 'cart', state: { items: 1 } }]); + expect(snapshot?.timeline).toHaveLength(1); + }); + + test('drops malformed members instead of rendering them', () => { + const snapshot = parseSnapshot({ + state: {}, + signals: [null, 'nope', { label: 'ok', value: 1, subscriberCount: 'many' }], + stores: [{ state: {} }, { id: 'valid', state: 'not-an-object' }], + components: [{ instanceCount: 3 }, { tagName: 'x-y', instanceCount: 2 }], + }); + expect(snapshot?.signals).toEqual([{ label: 'ok', value: 1, subscriberCount: 0 }]); + expect(snapshot?.stores).toEqual([{ id: 'valid', state: {} }]); + expect(snapshot?.components).toEqual([{ tagName: 'x-y', instanceCount: 2 }]); + }); + + test('returns null for a non-object result', () => { + expect(parseSnapshot('nope')).toBeNull(); + }); +}); + +describe('parseComponentTree', () => { + test('keeps string attributes only', () => { + const { tree } = parseComponentTree({ + tree: [ + { + tag: 'my-app', + id: '0', + attrs: { class: 'root', count: 3 }, + children: [{ tag: 'my-child', id: '0/1', attrs: {}, children: [] }], + }, + { id: 'no-tag' }, + ], + flat: [{ tagName: 'my-app', instanceCount: 1 }], + }); + expect(tree).toHaveLength(1); + expect(tree[0]?.attrs).toEqual({ class: 'root' }); + expect(tree[0]?.children[0]?.tag).toBe('my-child'); + }); + + test('caps recursion so a self-nested tree cannot blow the stack', () => { + interface Node { + tag: string; + id: string; + attrs: Record; + children: Node[]; + } + const root: Node = { tag: 'deep-node', id: '0', attrs: {}, children: [] }; + let cursor = root; + for (let depth = 0; depth < 500; depth += 1) { + const child: Node = { tag: 'deep-node', id: String(depth), attrs: {}, children: [] }; + cursor.children.push(child); + cursor = child; + } + + const { tree } = parseComponentTree({ tree: [root], flat: [] }); + let depth = 0; + let node = tree[0]; + while (node && node.children.length > 0) { + node = node.children[0]; + depth += 1; + } + expect(depth).toBeLessThan(500); + expect(depth).toBeGreaterThan(0); + }); + + test('degrades to empty collections for junk', () => { + expect(parseComponentTree(null)).toEqual({ tree: [], flat: [] }); + }); +}); + +describe('parseTimeline', () => { + test('fills in missing fields and drops untyped entries', () => { + const entries = parseTimeline([{ type: 'mark' }, { detail: 'no type' }, 7]); + expect(entries).toHaveLength(1); + expect(entries[0]?.detail).toBe(''); + expect(typeof entries[0]?.timestamp).toBe('number'); + }); +}); diff --git a/tests/unit/transports.eval.test.ts b/tests/unit/transports.eval.test.ts new file mode 100644 index 0000000..3ec2a45 --- /dev/null +++ b/tests/unit/transports.eval.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from 'bun:test'; +import { + buildDrainExpression, + buildSendExpression, + EvalTransport, + type Evaluator, +} from '../../src/transports/evalTransport'; +import { helloMessage, requestMessage } from '../../src/protocol/messages'; +import type { TransportStatus } from '../../src/protocol/transport'; + +/** Records evaluated expressions and lets the test answer them. */ +class FakeEvaluator { + public readonly expressions: string[] = []; + public queued: unknown[] = []; + public failing = false; + + public readonly evaluate: Evaluator = (expression, callback) => { + this.expressions.push(expression); + if (this.failing) { + callback(undefined, { isError: true }); + return; + } + if (expression.startsWith('window.postMessage')) { + callback(undefined, undefined); + return; + } + const drained = this.queued; + this.queued = []; + callback(JSON.stringify(drained), undefined); + }; +} + +describe('generated expressions', () => { + test('the drain expression is syntactically valid', () => { + expect(() => new Function(`return ${buildDrainExpression(10)};`)).not.toThrow(); + }); + + test('the drain expression installs the relay and empties the queue', () => { + const listeners: Array<(event: { source: unknown; data: unknown }) => void> = []; + const fakeWindow: Record = { + addEventListener: (_type: string, listener: (event: never) => void) => { + listeners.push(listener as never); + }, + }; + const run = new Function('window', `return ${buildDrainExpression(3)};`) as ( + win: Record + ) => string; + + expect(JSON.parse(run(fakeWindow))).toEqual([]); + expect(listeners).toHaveLength(1); + + const pageMessage = { source: 'bquery-devtools', channel: 'page', v: 1, kind: 'init' }; + listeners[0]?.({ source: fakeWindow, data: pageMessage }); + // Foreign traffic on the same bus is ignored. + listeners[0]?.({ source: fakeWindow, data: { source: 'other' } }); + listeners[0]?.({ source: {}, data: pageMessage }); + + expect(JSON.parse(run(fakeWindow))).toEqual([pageMessage]); + // A second call does not add another listener. + expect(listeners).toHaveLength(1); + }); + + test('the in-page queue is bounded', () => { + const listeners: Array<(event: { source: unknown; data: unknown }) => void> = []; + const fakeWindow: Record = { + addEventListener: (_type: string, listener: (event: never) => void) => { + listeners.push(listener as never); + }, + }; + const run = new Function('window', `return ${buildDrainExpression(3)};`) as ( + win: Record + ) => string; + run(fakeWindow); + for (let index = 0; index < 10; index += 1) { + listeners[0]?.({ + source: fakeWindow, + data: { source: 'bquery-devtools', channel: 'page', v: 1, kind: 'event', index }, + }); + } + const drained = JSON.parse(run(fakeWindow)) as Array<{ index: number }>; + expect(drained).toHaveLength(3); + expect(drained[0]?.index).toBe(7); + }); + + test('a message is embedded as data, never as source', () => { + const hostile = requestMessage(1, "');globalThis.pwned=true;('", { note: '' }); + const expression = buildSendExpression(hostile); + let posted: unknown = null; + const run = new Function('window', `return ${expression};`) as (win: { + postMessage: (data: unknown, origin: string) => void; + }) => void; + run({ postMessage: data => (posted = data) }); + expect(posted).toEqual(hostile as unknown as Record); + expect((globalThis as Record)['pwned']).toBeUndefined(); + }); +}); + +describe('EvalTransport', () => { + test('reports open on the first successful poll and dispatches messages', () => { + const evaluator = new FakeEvaluator(); + const transport = new EvalTransport({ evaluate: evaluator.evaluate, pollIntervalMs: 10_000 }); + const statuses: TransportStatus[] = []; + const received: unknown[] = []; + + evaluator.queued = [{ kind: 'init' }]; + transport.start({ + onMessage: message => received.push(message), + onStatus: status => statuses.push(status), + }); + + expect(statuses.map(status => status.kind)).toEqual(['connecting', 'open']); + expect(received).toEqual([{ kind: 'init' }]); + transport.dispose(); + }); + + test('send evaluates a postMessage expression', () => { + const evaluator = new FakeEvaluator(); + const transport = new EvalTransport({ evaluate: evaluator.evaluate, pollIntervalMs: 10_000 }); + transport.start({ onMessage: () => undefined, onStatus: () => undefined }); + transport.send(helloMessage()); + expect(evaluator.expressions.at(-1)).toStartWith('window.postMessage(JSON.parse('); + transport.dispose(); + }); + + test('an evaluation failure is reported once, not on every poll', () => { + const evaluator = new FakeEvaluator(); + evaluator.failing = true; + const transport = new EvalTransport({ evaluate: evaluator.evaluate, pollIntervalMs: 10_000 }); + const statuses: TransportStatus[] = []; + transport.start({ onMessage: () => undefined, onStatus: status => statuses.push(status) }); + transport.send(helloMessage()); + transport.send(helloMessage()); + expect(statuses.filter(status => status.kind === 'error')).toHaveLength(1); + transport.dispose(); + }); + + test('nothing is dispatched after dispose', () => { + const evaluator = new FakeEvaluator(); + const transport = new EvalTransport({ evaluate: evaluator.evaluate, pollIntervalMs: 10_000 }); + const received: unknown[] = []; + transport.start({ onMessage: message => received.push(message), onStatus: () => undefined }); + transport.dispose(); + const before = evaluator.expressions.length; + transport.send(helloMessage()); + expect(evaluator.expressions).toHaveLength(before); + }); +}); diff --git a/tests/unit/transports.port.test.ts b/tests/unit/transports.port.test.ts new file mode 100644 index 0000000..d80c2c3 --- /dev/null +++ b/tests/unit/transports.port.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from 'bun:test'; +import { ENVELOPE_SOURCE } from '../../src/protocol/envelope'; +import { helloMessage } from '../../src/protocol/messages'; +import type { TransportStatus } from '../../src/protocol/transport'; +import { PortTransport, type PanelPort } from '../../src/transports/portTransport'; + +class FakePort implements PanelPort { + public readonly sent: Array> = []; + public disconnected = false; + private messageListener: ((message: unknown) => void) | null = null; + private disconnectListener: (() => void) | null = null; + + public postMessage(message: unknown): void { + this.sent.push(message as Record); + } + + public disconnect(): void { + this.disconnected = true; + } + + public readonly onMessage = { + addListener: (listener: (message: unknown) => void): void => { + this.messageListener = listener; + }, + }; + + public readonly onDisconnect = { + addListener: (listener: () => void): void => { + this.disconnectListener = listener; + }, + }; + + public attached(token: string, tabId: number): void { + this.messageListener?.({ source: ENVELOPE_SOURCE, type: 'attached', token, tabId }); + } + + public emit(message: unknown): void { + this.messageListener?.(message); + } + + public drop(): void { + this.disconnectListener?.(); + } +} + +interface Harness { + readonly transport: PortTransport; + readonly ports: FakePort[]; + readonly statuses: TransportStatus[]; + readonly received: unknown[]; + runTimers(): void; +} + +const harness = (): Harness => { + const ports: FakePort[] = []; + const statuses: TransportStatus[] = []; + const received: unknown[] = []; + const timers: Array<() => void> = []; + const transport = new PortTransport({ + tabId: 17, + connect: () => { + const port = new FakePort(); + ports.push(port); + return port; + }, + backoffMs: [1], + setTimeout: handler => { + timers.push(handler); + return timers.length; + }, + }); + transport.start({ + onMessage: message => received.push(message), + onStatus: status => statuses.push(status), + }); + return { + transport, + ports, + statuses, + received, + runTimers: () => { + const pending = timers.splice(0, timers.length); + for (const run of pending) run(); + }, + }; +}; + +describe('attach', () => { + test('claims the inspected tab on connect', () => { + const { ports } = harness(); + expect(ports[0]?.sent[0]).toEqual({ source: ENVELOPE_SOURCE, type: 'attach', tabId: 17 }); + }); + + test('messages sent before attach are queued, then flushed with the token', () => { + const { transport, ports } = harness(); + transport.send(helloMessage()); + expect(ports[0]?.sent).toHaveLength(1); + + ports[0]?.attached('token-1', 17); + expect(ports[0]?.sent[1]).toEqual({ + source: ENVELOPE_SOURCE, + type: 'to-page', + token: 'token-1', + payload: helloMessage(), + }); + }); + + test('attach opens the transport', () => { + const { ports, statuses } = harness(); + ports[0]?.attached('token-1', 17); + expect(statuses.map(status => status.kind)).toEqual(['connecting', 'open']); + }); +}); + +describe('routing', () => { + test('page payloads are handed to the client', () => { + const { ports, received } = harness(); + ports[0]?.attached('token-1', 17); + ports[0]?.emit({ source: ENVELOPE_SOURCE, type: 'from-page', payload: { kind: 'init' } }); + expect(received).toEqual([{ kind: 'init' }]); + }); + + test('foreign envelopes are ignored', () => { + const { ports, received } = harness(); + ports[0]?.attached('token-1', 17); + ports[0]?.emit({ source: 'somewhere-else', type: 'from-page', payload: 'nope' }); + expect(received).toEqual([]); + }); +}); + +describe('reconnection', () => { + test('a dropped port is reported and reopened', () => { + const { ports, statuses, runTimers } = harness(); + ports[0]?.attached('token-1', 17); + ports[0]?.drop(); + + expect(statuses.at(-1)).toEqual({ kind: 'closed', reason: 'background worker disconnected' }); + runTimers(); + expect(ports).toHaveLength(2); + expect(ports[1]?.sent[0]).toEqual({ source: ENVELOPE_SOURCE, type: 'attach', tabId: 17 }); + }); + + test('the new port issues a new token, and the old one is not reused', () => { + const { transport, ports, runTimers } = harness(); + ports[0]?.attached('token-1', 17); + ports[0]?.drop(); + runTimers(); + ports[1]?.attached('token-2', 17); + transport.send(helloMessage()); + expect(ports[1]?.sent.at(-1)).toMatchObject({ token: 'token-2' }); + }); +}); + +describe('injection', () => { + test('resolves when the router reports success', async () => { + const { transport, ports } = harness(); + ports[0]?.attached('token-1', 17); + const pending = transport.requestInjection(); + expect(ports[0]?.sent.at(-1)).toEqual({ + source: ENVELOPE_SOURCE, + type: 'inject', + token: 'token-1', + }); + ports[0]?.emit({ source: ENVELOPE_SOURCE, type: 'inject-result', ok: true }); + await pending; + }); + + test('rejects with the reported reason', async () => { + const { transport, ports } = harness(); + ports[0]?.attached('token-1', 17); + const pending = transport.requestInjection(); + ports[0]?.emit({ + source: ENVELOPE_SOURCE, + type: 'inject-result', + ok: false, + reason: 'no permission for this site', + }); + await expect(pending).rejects.toThrow(/no permission for this site/); + }); + + test('rejects when the port dies mid-flight', async () => { + const { transport, ports } = harness(); + ports[0]?.attached('token-1', 17); + const pending = transport.requestInjection(); + ports[0]?.drop(); + await expect(pending).rejects.toThrow(/disconnected/); + }); + + test('rejects when not attached', async () => { + const { transport } = harness(); + await expect(transport.requestInjection()).rejects.toThrow(/not attached/); + }); +}); + +describe('dispose', () => { + test('disconnects the port and stops sending', () => { + const { transport, ports } = harness(); + ports[0]?.attached('token-1', 17); + const before = ports[0]?.sent.length ?? 0; + transport.dispose(); + transport.send(helloMessage()); + expect(ports[0]?.disconnected).toBe(true); + expect(ports[0]?.sent).toHaveLength(before); + }); +}); diff --git a/tools/content.ts b/tools/content.ts new file mode 100644 index 0000000..69eb6ad --- /dev/null +++ b/tools/content.ts @@ -0,0 +1,22 @@ +/** + * Bundles the content script. + * + * Content scripts are injected as classic scripts — they cannot be ES modules + * and cannot share Rollup chunks with the rest of the build — so this entry is + * bundled separately, as a self-contained IIFE. + */ +import { buildSync } from 'esbuild'; + +buildSync({ + entryPoints: ['./src/content.ts'], + outfile: './dist/content.js', + bundle: true, + format: 'iife', + platform: 'browser', + target: ['chrome102', 'firefox102', 'es2022'], + sourcemap: true, + minify: true, + legalComments: 'none', +}); + +console.log('✓ content.js bundled'); diff --git a/tools/package.ts b/tools/package.ts new file mode 100644 index 0000000..8336b6d --- /dev/null +++ b/tools/package.ts @@ -0,0 +1,35 @@ +/** + * Packs `dist/` into a store-ready zip under `artifacts/`. + * + * The file name carries the manifest version and target so a release workflow + * can upload MV3 and MV2 builds side by side. + */ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +const DIST = './dist'; +const OUT_DIR = './artifacts'; + +interface Manifest { + version: string; + manifest_version: number; +} + +if (!fs.existsSync(DIST)) { + throw new Error('dist/ is missing — run `bun run deploy-v3` first'); +} + +const manifest = JSON.parse(fs.readFileSync(path.join(DIST, 'manifest.json'), 'utf8')) as Manifest; + +fs.mkdirSync(OUT_DIR, { recursive: true }); + +const target = manifest.manifest_version === 2 ? 'firefox-mv2' : 'chromium-mv3'; +const outFile = path.resolve(OUT_DIR, `bquery-devtools-${manifest.version}-${target}.zip`); +fs.rmSync(outFile, { force: true }); + +// `zip` keeps the archive flat-rooted (no `dist/` prefix), which is what both +// stores expect. +execFileSync('zip', ['-r', '-q', outFile, '.'], { cwd: DIST, stdio: 'inherit' }); + +console.log(`✓ packaged ${path.relative('.', outFile)}`); diff --git a/tools/v2.ts b/tools/v2.ts index 92da789..92ded70 100644 --- a/tools/v2.ts +++ b/tools/v2.ts @@ -22,6 +22,12 @@ interface ManifestJson { } const FIREFOX_BACKGROUND_BUNDLE = 'background.firefox.js'; + +/** Manifest keys that only exist in MV3 and would warn in Firefox. */ +const MV3_ONLY_KEYS = ['minimum_chrome_version'] as const; + +/** Permissions that only exist in MV3; MV2 uses `tabs.executeScript`. */ +const MV3_ONLY_PERMISSIONS = new Set(['scripting']); const DEFAULT_MV2_CONTENT_SECURITY_POLICY = "default-src 'self'"; const toPermissionList = (value: unknown): string[] => { @@ -69,7 +75,14 @@ buildSync({ legalComments: 'none', }); +interface AppConfig { + AppData?: { + firefox?: { geckoId?: string; strictMinVersion?: string }; + }; +} + const manifest = JSON.parse(fs.readFileSync('./dist/manifest.json', 'utf8')) as ManifestJson; +const appConfig = JSON.parse(fs.readFileSync('./app.config.json', 'utf8')) as AppConfig; manifest.manifest_version = 2; manifest.background.scripts = [FIREFOX_BACKGROUND_BUNDLE]; @@ -79,6 +92,20 @@ delete manifest.background.service_worker; manifest.background.persistent = true; +for (const key of MV3_ONLY_KEYS) delete manifest[key]; + +// AMO requires a stable add-on id, and the devtools APIs used here need a +// reasonably recent Gecko. +const firefox = appConfig.AppData?.firefox; +if (firefox?.geckoId) { + manifest.browser_specific_settings = { + gecko: { + id: firefox.geckoId, + ...(firefox.strictMinVersion ? { strict_min_version: firefox.strictMinVersion } : {}), + }, + }; +} + if (manifest.host_permissions) { manifest.permissions ??= []; manifest.permissions.push(...toPermissionList(manifest.host_permissions)); @@ -92,12 +119,19 @@ if (manifest.optional_host_permissions) { delete manifest.host_permissions; delete manifest.optional_host_permissions; +manifest.permissions = toPermissionList(manifest.permissions).filter( + permission => !MV3_ONLY_PERMISSIONS.has(permission) +); + let newContentSecurityPolicy = ''; try { if (typeof manifest.content_security_policy === 'string') { newContentSecurityPolicy = manifest.content_security_policy; - } else if (manifest.content_security_policy && typeof manifest.content_security_policy === 'object') { + } else if ( + manifest.content_security_policy && + typeof manifest.content_security_policy === 'object' + ) { const policyMap = manifest.content_security_policy as Record; if (typeof policyMap.extension_pages === 'string') { newContentSecurityPolicy = policyMap.extension_pages; diff --git a/tools/verifyBuild.js b/tools/verifyBuild.js new file mode 100644 index 0000000..0fc7de7 --- /dev/null +++ b/tools/verifyBuild.js @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as path from 'path'; +const DIST = './dist'; +const errors = []; +const require_ = (relative, why) => { + if (!fs.existsSync(path.join(DIST, relative))) + errors.push(`missing ${relative} (${why})`); +}; +if (!fs.existsSync(path.join(DIST, 'manifest.json'))) { + throw new Error('dist/manifest.json is missing — run `bun run deploy-v3` first'); +} +const manifest = JSON.parse(fs.readFileSync(path.join(DIST, 'manifest.json'), 'utf8')); +require_('panel.html', 'the DevTools panel'); +require_('panel.js', 'the panel bundle'); +require_('content.js', 'the opt-in live-streaming relay'); +if (manifest.devtools_page) + require_(manifest.devtools_page, 'manifest.devtools_page'); +if (manifest.options_ui?.page) + require_(manifest.options_ui.page, 'manifest.options_ui.page'); +if (manifest.background?.service_worker) { + require_(manifest.background.service_worker, 'manifest.background.service_worker'); +} +for (const script of manifest.background?.scripts ?? []) { + require_(script, 'manifest.background.scripts'); +} +const contentScript = fs.readFileSync(path.join(DIST, 'content.js'), 'utf8'); +if (/\bimport\s*[({'"]/.test(contentScript) || /\bexport\s[{*]/.test(contentScript)) { + errors.push('content.js contains module syntax; it must be a self-contained classic script'); +} +for (const file of fs.readdirSync(DIST)) { + if (!file.endsWith('.html')) + continue; + const html = fs.readFileSync(path.join(DIST, file), 'utf8'); + if (html.includes('{{')) + errors.push(`${file} still contains an unreplaced template token`); +} +if ((manifest.host_permissions ?? []).length > 0) { + errors.push('host_permissions must stay empty; the panel requests one origin at runtime'); +} +const optional = manifest.optional_host_permissions ?? manifest.optional_permissions ?? []; +if (!optional.some(entry => entry.includes('://'))) { + errors.push('no optional host permission declared; live streaming could never be enabled'); +} +if (manifest.manifest_version === 3) { + if (!manifest.background?.service_worker) + errors.push('MV3 build has no service worker'); +} +else if (manifest.manifest_version === 2) { + if (!manifest.background?.scripts?.length) + errors.push('MV2 build has no background scripts'); + if ((manifest.permissions ?? []).includes('scripting')) { + errors.push('MV2 build still declares the MV3-only "scripting" permission'); + } +} +else { + errors.push(`unexpected manifest_version: ${manifest.manifest_version}`); +} +if (errors.length > 0) { + console.error('✗ build verification failed:'); + for (const error of errors) + console.error(` - ${error}`); + process.exit(1); +} +console.log(`✓ ${manifest.name} ${manifest.version} (MV${manifest.manifest_version}) looks loadable`); diff --git a/tools/verifyBuild.ts b/tools/verifyBuild.ts new file mode 100644 index 0000000..c920bd1 --- /dev/null +++ b/tools/verifyBuild.ts @@ -0,0 +1,95 @@ +/** + * Sanity-checks a built `dist/` before it is packaged or uploaded. + * + * Catches the failures a type-checker cannot see: a missing entry point, an + * HTML page still carrying an unreplaced template token, a manifest that + * points at a file the build did not emit, or host permissions creeping back + * into a build that is meant to ask for them at runtime. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const DIST = './dist'; + +interface Manifest { + manifest_version: number; + name: string; + version: string; + devtools_page?: string; + options_ui?: { page?: string }; + background?: { service_worker?: string; scripts?: string[] }; + permissions?: string[]; + host_permissions?: string[]; + optional_host_permissions?: string[]; + optional_permissions?: string[]; +} + +const errors: string[] = []; + +const require_ = (relative: string, why: string): void => { + if (!fs.existsSync(path.join(DIST, relative))) errors.push(`missing ${relative} (${why})`); +}; + +if (!fs.existsSync(path.join(DIST, 'manifest.json'))) { + throw new Error('dist/manifest.json is missing — run `bun run deploy-v3` first'); +} + +const manifest = JSON.parse(fs.readFileSync(path.join(DIST, 'manifest.json'), 'utf8')) as Manifest; + +// Entry points the extension cannot start without. +require_('panel.html', 'the DevTools panel'); +require_('panel.js', 'the panel bundle'); +require_('content.js', 'the opt-in live-streaming relay'); +if (manifest.devtools_page) require_(manifest.devtools_page, 'manifest.devtools_page'); +if (manifest.options_ui?.page) require_(manifest.options_ui.page, 'manifest.options_ui.page'); +if (manifest.background?.service_worker) { + require_(manifest.background.service_worker, 'manifest.background.service_worker'); +} +for (const script of manifest.background?.scripts ?? []) { + require_(script, 'manifest.background.scripts'); +} + +// The content script is injected on demand and must be a classic script. +const contentScript = fs.readFileSync(path.join(DIST, 'content.js'), 'utf8'); +if (/\bimport\s*[({'"]/.test(contentScript) || /\bexport\s[{*]/.test(contentScript)) { + errors.push('content.js contains module syntax; it must be a self-contained classic script'); +} + +// Template tokens must have been replaced by `tools/parse.ts`. +for (const file of fs.readdirSync(DIST)) { + if (!file.endsWith('.html')) continue; + const html = fs.readFileSync(path.join(DIST, file), 'utf8'); + if (html.includes('{{')) errors.push(`${file} still contains an unreplaced template token`); +} + +// Permissions: the panel works without host access, and asks for a single +// origin at runtime when the user opts into live streaming. +if ((manifest.host_permissions ?? []).length > 0) { + errors.push('host_permissions must stay empty; the panel requests one origin at runtime'); +} +const optional = manifest.optional_host_permissions ?? manifest.optional_permissions ?? []; +if (!optional.some(entry => entry.includes('://'))) { + errors.push('no optional host permission declared; live streaming could never be enabled'); +} + +// MV-specific shape. +if (manifest.manifest_version === 3) { + if (!manifest.background?.service_worker) errors.push('MV3 build has no service worker'); +} else if (manifest.manifest_version === 2) { + if (!manifest.background?.scripts?.length) errors.push('MV2 build has no background scripts'); + if ((manifest.permissions ?? []).includes('scripting')) { + errors.push('MV2 build still declares the MV3-only "scripting" permission'); + } +} else { + errors.push(`unexpected manifest_version: ${manifest.manifest_version}`); +} + +if (errors.length > 0) { + console.error('✗ build verification failed:'); + for (const error of errors) console.error(` - ${error}`); + process.exit(1); +} + +console.log( + `✓ ${manifest.name} ${manifest.version} (MV${manifest.manifest_version}) looks loadable` +); diff --git a/tsconfig.json b/tsconfig.json index fbde4a8..c681ff7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "target": "ES2022", "module": "ESNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["chrome"], + "types": ["chrome", "bun"], "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, @@ -45,6 +45,6 @@ /* Completeness */ "skipLibCheck": true }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "tests/**/*.ts"], "exclude": ["node_modules", "dist", "tools/*.js"] } diff --git a/vite.config.ts b/vite.config.ts index 6967d42..59d46aa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,7 +5,8 @@ export default defineConfig({ build: { rollupOptions: { input: { - app: resolve(__dirname, 'src/app.ts'), + panel: resolve(__dirname, 'src/panel.ts'), + devtools: resolve(__dirname, 'src/devtools.ts'), settings: resolve(__dirname, 'src/settings.ts'), background: resolve(__dirname, 'src/background.ts'), }, @@ -33,8 +34,8 @@ export default defineConfig({ extensions: ['.ts', '.tsx', '.js', '.jsx', '.scss', '.sass'], alias: { '@': resolve(__dirname, './src'), - '@components': resolve(__dirname, './src/components'), - '@classes': resolve(__dirname, './src/classes'), + '@panel': resolve(__dirname, './src/panel'), + '@protocol': resolve(__dirname, './src/protocol'), '@types': resolve(__dirname, './src/types'), '@sass': resolve(__dirname, './src/sass'), }, From 3d0f4139b3e742e86a3e282604d4ced7654dd859 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:23:40 +0000 Subject: [PATCH 2/9] ci: sign release artifacts with build provenance and optional AMO signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release workflow packaged zips but produced nothing verifiable, which left the "signed artifacts" half of the release requirement unmet. Browser extensions are not signed like binaries — each store signs what it distributes — so this does what is actually available per target: - Build provenance for every artifact via actions/attest-build-provenance (Sigstore). No secrets, so it runs on every release, and anyone can tie a zip to the workflow and commit that produced it with `gh attestation verify --repo bQuery/devtools-extension`. - An AMO-signed .xpi via `web-ext sign`, gated on AMO_JWT_ISSUER / AMO_JWT_SECRET. Absent those secrets the step is skipped, not failed, and the release still ships the unsigned MV2 zip for manual upload. - Nothing for Chrome: the Web Store re-signs every upload with its own key, so a self-signed CRX would be discarded. The .xpi only exists when signing ran, so the checksum and attestation steps build their file list with `nullglob` instead of assuming the glob matches; both cases were exercised before committing. Documents the whole story in docs/PUBLISHING.md. --- .github/workflows/release.yml | 46 +++++++++++++++++++++++++++++++-- CHANGELOG.md | 3 +++ docs/PUBLISHING.md | 48 +++++++++++++++++++++++++++++++++-- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0949bf..f8de2da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,11 +11,20 @@ on: permissions: contents: write + # Required by actions/attest-build-provenance to mint a Sigstore signing + # certificate and record the attestation. + id-token: write + attestations: write jobs: release: name: Build and publish store artifacts runs-on: ubuntu-latest + env: + # Present only when the repository has AMO credentials configured; the + # signing step is skipped rather than failed when they are absent. + AMO_JWT_ISSUER: ${{ secrets.AMO_JWT_ISSUER }} + AMO_JWT_SECRET: ${{ secrets.AMO_JWT_SECRET }} steps: - uses: actions/checkout@v4 with: @@ -47,10 +56,42 @@ jobs: bun ./tools/verifyBuild.js bun run package - - name: Checksums + # AMO is the only party that can produce a signed, installable Firefox + # artifact: signing is a service, not a local key operation. Without + # credentials the release still ships the unsigned zip for manual upload. + - name: Sign the Firefox build (AMO) + if: env.AMO_JWT_ISSUER != '' run: | + bunx web-ext@8 sign \ + --source-dir dist \ + --artifacts-dir artifacts \ + --channel unlisted \ + --api-key "$AMO_JWT_ISSUER" \ + --api-secret "$AMO_JWT_SECRET" + + # The .xpi exists only when AMO signing ran, so every consumer of the + # artifact list has to tolerate its absence. + - name: Collect artifacts + id: artifacts + run: | + shopt -s nullglob cd artifacts - sha256sum *.zip | tee SHA256SUMS.txt + sha256sum *.zip *.xpi | tee SHA256SUMS.txt + cd .. + { + echo 'paths<> "$GITHUB_OUTPUT" + + # Signed build provenance, verifiable with: + # gh attestation verify --repo bQuery/devtools-extension + # This proves which workflow, commit and runner produced each artifact — + # the property that matters for an extension users install from a store. + - name: Attest build provenance + uses: actions/attest-build-provenance@v2 + with: + subject-path: ${{ steps.artifacts.outputs.paths }} - uses: actions/upload-artifact@v4 with: @@ -63,6 +104,7 @@ jobs: with: files: | artifacts/*.zip + artifacts/*.xpi artifacts/SHA256SUMS.txt draft: true generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e0683..785defb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ It replaces the untyped reference scaffold that used to live in the framework's preference. - Chromium (MV3) and Firefox (MV2) build targets, a build verifier, and store packaging. +- Signed releases: Sigstore build-provenance attestation for every artifact + (verifiable with `gh attestation verify`), plus an optional AMO-signed `.xpi` + when `AMO_JWT_ISSUER` / `AMO_JWT_SECRET` are configured. - Unit tests (`bun test`) and Playwright E2E smoke tests, both run in CI along with type-check, lint, format check and both build targets. - Documentation: README, contributing guide, architecture notes and publishing diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 37f7d9b..62b55bc 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -22,8 +22,9 @@ git push --follow-tags ``` The tag triggers `.github/workflows/release.yml`, which validates, builds both -targets, verifies and packages them, writes `SHA256SUMS.txt` and opens a -**draft** GitHub release with the zips attached. Review it, then publish. +targets, verifies and packages them, signs what can be signed (see +[Signing](#signing)), writes `SHA256SUMS.txt` and opens a **draft** GitHub +release with the artifacts attached. Review it, then publish. Store manifests must use a numeric `major.minor.patch[.build]` version. Do not put pre-release suffixes (`1.1.0-beta.1`) in `AppData.version`; use a build @@ -40,6 +41,49 @@ Both zips land in `artifacts/`. `bun run verify` is not optional — it catches a missing entry point, an unreplaced branding token, a content script that accidentally became an ES module, and host permissions creeping back in. +## Signing + +Browser extensions are not signed the way a binary is — each store signs what +it distributes, so what a release workflow can produce differs per target. + +### Build provenance (always) + +Every release attests its artifacts with +[`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance), +which signs a provenance statement through Sigstore. No secrets are involved, +so it runs on every release. Anyone can check which workflow, commit and runner +produced a given file: + +```bash +gh attestation verify bquery-devtools-1.0.0-chromium-mv3.zip \ + --repo bQuery/devtools-extension +``` + +This is the guarantee that actually matters for a store-distributed extension: +it ties the zip you are about to upload to the commit it was built from. + +### Firefox / AMO (when credentials are configured) + +AMO is the only party that can produce an installable, signed Firefox artifact +— signing there is a service, not a local key operation. When the repository +has `AMO_JWT_ISSUER` and `AMO_JWT_SECRET` +([API credentials](https://addons.mozilla.org/developers/addon/api/key/)) +configured as secrets, the workflow runs `web-ext sign --channel unlisted` and +attaches the resulting signed `.xpi` to the release. + +Without those secrets the step is **skipped, not failed**: the release still +ships the unsigned MV2 zip for manual upload through the AMO dashboard. Use the +signed `.xpi` for self-distribution; a listed AMO release is signed by AMO on +upload either way. + +### Chrome / Edge + +There is nothing to sign locally. The Chrome Web Store re-signs every upload +with its own key and assigns the extension id, so a self-signed CRX would be +discarded. Self-hosted CRX distribution is a different (and much rarer) +workflow that needs a private key this repository deliberately does not carry — +if you need it, keep the key outside CI. + ## Chrome Web Store 1. Sign in to the [Developer Dashboard](https://chrome.google.com/webstore/devconsole) From 4220a27c080a86a8fb8194a9276fec741a2be282 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:27:51 +0000 Subject: [PATCH 3/9] fix(test): dispatch bridge methods from a Map, not an object literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL (js/unvalidated-dynamic-method-call, high) on tests/e2e/fixture.ts: the E2E fixture's page-side bridge looked its handler up as `methods[String(message['method'])]` on a plain object literal, so a method name off the wire resolved through the prototype chain. The finding is real, and reproducing it is instructive: - `method: "constructor"` dispatched to `Object` and answered `{}` - `method: "toString"` answered `"[object Undefined]"` - `method: "__proto__"` resolved to a non-callable, threw, and sent no reply at all — the request simply hung A `Map` has no prototype-chain lookup, so unknown names now fall through to the existing "Unknown method" error path. The fixture stands in for the inspected page, which the panel treats as untrusted throughout; it should not be sloppier than the thing it models. Adds an E2E test that sends all three names and asserts each is answered with an error. Verified it fails against the object-literal version before the fix and passes after. --- tests/e2e/fixture.ts | 19 +++++++++++------- tests/e2e/panel.spec.ts | 43 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/tests/e2e/fixture.ts b/tests/e2e/fixture.ts index 2d6d7d1..bd4e9a4 100644 --- a/tests/e2e/fixture.ts +++ b/tests/e2e/fixture.ts @@ -140,12 +140,17 @@ export const installFixture = (data: FixtureData): void => { window.postMessage({ source: SOURCE, channel: 'page', v: 1, ...message }, '*'); }; - const methods: Record unknown> = { - ping: () => ({ v: 1, ok: true }), - getSnapshot: () => data.snapshot, - getComponentTree: () => data.tree, - getTimeline: () => data.timeline, - }; + // A Map, not an object literal: the method name comes off the wire, and a + // plain object would resolve names like "constructor" or "toString" through + // the prototype chain and dispatch to them. The real page-side bridge is + // equally untrusting, so the fixture should not be sloppier than what it + // stands in for. + const methods = new Map unknown>([ + ['ping', () => ({ v: 1, ok: true })], + ['getSnapshot', () => data.snapshot], + ['getComponentTree', () => data.tree], + ['getTimeline', () => data.timeline], + ]); window.addEventListener('message', event => { const message = event.data as Record | null; @@ -155,7 +160,7 @@ export const installFixture = (data: FixtureData): void => { return; } if (message['kind'] !== 'request') return; - const method = methods[String(message['method'])]; + const method = methods.get(String(message['method'])); if (!method) { post({ kind: 'response', id: message['id'], error: `Unknown method: ${message['method']}` }); return; diff --git a/tests/e2e/panel.spec.ts b/tests/e2e/panel.spec.ts index 623ead4..5945aa1 100644 --- a/tests/e2e/panel.spec.ts +++ b/tests/e2e/panel.spec.ts @@ -201,6 +201,49 @@ test.describe('bQuery DevTools panel', () => { ).toBeUndefined(); }); + test('a prototype-chain method name is answered with an error, not dispatched', async ({ + page, + }) => { + await openPanel(page); + // The fixture's method table is keyed off the wire. With a plain object it + // would resolve "constructor" through the prototype chain and invoke it; + // the panel would then be handed `Object` as a result. + const replies = await page.evaluate(async () => { + const answers: unknown[] = []; + const collect = (event: MessageEvent): void => { + const data = event.data as Record | null; + if (!data || data['source'] !== 'bquery-devtools' || data['kind'] !== 'response') return; + // Only this test's requests: the panel's own connect-time fetches are + // still in flight on the same bus, and collecting those made the + // assertion race. + if (typeof data['id'] !== 'number' || data['id'] < 9000) return; + answers.push(data['error'] ?? data['result']); + }; + window.addEventListener('message', collect); + for (const [index, method] of ['constructor', 'toString', '__proto__'].entries()) { + window.postMessage( + { + source: 'bquery-devtools', + channel: 'panel', + v: 1, + kind: 'request', + id: 9000 + index, + method, + }, + '*' + ); + } + await new Promise(resolve => setTimeout(resolve, 200)); + window.removeEventListener('message', collect); + return answers; + }); + + expect(replies).toHaveLength(3); + for (const reply of replies) { + expect(String(reply)).toContain('Unknown method'); + } + }); + test('reports a page that never answers the handshake', async ({ page }) => { // No fixture bridge: only the chrome mock, so `hello` goes unanswered. await page.addInitScript(() => { From d3b42bda946fe4762447312109a91771e2996d21 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:31:03 +0000 Subject: [PATCH 4/9] ci: scope the GITHUB_TOKEN down to contents:read in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL (actions/missing-workflow-permissions) flagged all three ci.yml jobs: without an explicit permissions block the workflow inherits the repository default, which can be write. Nothing in CI writes to the repository — it checks out, installs, builds, tests and uploads artifacts (upload-artifact uses the runtime token, not the GITHUB_TOKEN scopes) — so one workflow-level `contents: read` covers every job. release.yml already declared its own, narrower-by-intent block and was not flagged. --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51955ec..19a3733 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,11 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# CI only reads the checkout; nothing here writes to the repository, so the +# GITHUB_TOKEN is scoped down from the repository default for every job. +permissions: + contents: read + jobs: validate: name: Typecheck, lint and unit tests From 07f29323b5096029eea3e653fa08643e913a3389 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:42:15 +0000 Subject: [PATCH 5/9] fix(test): answer bridge methods with a switch, not a keyed lookup Follow-up to 4220a27. Swapping the object literal for a Map removed the prototype-chain dispatch, but CodeQL still reported the high alert on the invocation site after a fresh analysis of the fixed head, and the alert count did not move across a re-run. Rather than argue with the query about whether a Map lookup counts as a user-controlled method name, the dispatch is gone entirely: a switch returns data, so user input picks a branch and never selects a callable. Nothing is left for js/unvalidated-dynamic-method-call to point at. The existing regression test (constructor / toString / __proto__ each get an "Unknown method" error) still covers the behaviour, and the default branch is now what produces that error. --- tests/e2e/fixture.ts | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/tests/e2e/fixture.ts b/tests/e2e/fixture.ts index bd4e9a4..a7aeb7c 100644 --- a/tests/e2e/fixture.ts +++ b/tests/e2e/fixture.ts @@ -140,17 +140,31 @@ export const installFixture = (data: FixtureData): void => { window.postMessage({ source: SOURCE, channel: 'page', v: 1, ...message }, '*'); }; - // A Map, not an object literal: the method name comes off the wire, and a - // plain object would resolve names like "constructor" or "toString" through - // the prototype chain and dispatch to them. The real page-side bridge is - // equally untrusting, so the fixture should not be sloppier than what it - // stands in for. - const methods = new Map unknown>([ - ['ping', () => ({ v: 1, ok: true })], - ['getSnapshot', () => data.snapshot], - ['getComponentTree', () => data.tree], - ['getTimeline', () => data.timeline], - ]); + /** + * Answer one bridge method. + * + * A `switch` rather than a lookup table: the method name arrives off the + * wire, and neither an object literal (which resolves "constructor" and + * friends through the prototype chain) nor a keyed table selecting a + * function value should decide what gets invoked. Here user input picks a + * branch, never a callable. The real page-side bridge treats the panel as + * untrusted too, so the fixture should not be sloppier than what it stands + * in for. + */ + const answer = (name: string): { known: true; result: unknown } | { known: false } => { + switch (name) { + case 'ping': + return { known: true, result: { v: 1, ok: true } }; + case 'getSnapshot': + return { known: true, result: data.snapshot }; + case 'getComponentTree': + return { known: true, result: data.tree }; + case 'getTimeline': + return { known: true, result: data.timeline }; + default: + return { known: false }; + } + }; window.addEventListener('message', event => { const message = event.data as Record | null; @@ -160,12 +174,12 @@ export const installFixture = (data: FixtureData): void => { return; } if (message['kind'] !== 'request') return; - const method = methods.get(String(message['method'])); - if (!method) { + const reply = answer(String(message['method'])); + if (!reply.known) { post({ kind: 'response', id: message['id'], error: `Unknown method: ${message['method']}` }); return; } - post({ kind: 'response', id: message['id'], result: method(message['params']) }); + post({ kind: 'response', id: message['id'], result: reply.result }); }); /** Lets the test stream a timeline event from the fake page. */ From c75120441423ec68fd3933591c7adbfed64ac089 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:49:17 +0000 Subject: [PATCH 6/9] ci: move off the Node 20 actions flagged by the runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner reports these on every CI job: Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: actions/checkout@v4, actions/upload-artifact@v4 Both move to v7, matching what bQuery/bQuery already runs and what GitHub's own generated codeql.yml pins here. Only the parameters this repo uses (ref, name, path, if-no-files-found, retention-days) are involved, and those are unchanged across the majors. oven-sh/setup-bun@v2 is deliberately left alone — the runner does not list it, so it is already on a supported Node. --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19a3733..6da979c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: name: Typecheck, lint and unit tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: @@ -53,7 +53,7 @@ jobs: - target: firefox-mv2 script: deploy-v2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: @@ -71,7 +71,7 @@ jobs: - name: Package run: bun run package - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: bquery-devtools-${{ matrix.target }} path: artifacts/*.zip @@ -81,7 +81,7 @@ jobs: name: E2E smoke test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: @@ -96,7 +96,7 @@ jobs: - name: Run E2E tests run: bun run test:e2e - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: failure() with: name: playwright-traces diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8de2da..83cd73d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: AMO_JWT_ISSUER: ${{ secrets.AMO_JWT_ISSUER }} AMO_JWT_SECRET: ${{ secrets.AMO_JWT_SECRET }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: ref: ${{ github.event.inputs.tag || github.ref }} @@ -93,7 +93,7 @@ jobs: with: subject-path: ${{ steps.artifacts.outputs.paths }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: store-artifacts path: artifacts/ From 5ec9f9119cecfb274d0c16d6a75abe5a9a8a412c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:43:40 +0000 Subject: [PATCH 7/9] chore: stop tracking the compiled tools/verifyBuild.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tools/*.ts` is compiled to `tools/*.js` by `bun run build-tooling`, and every one of those outputs was listed in .gitignore — except this one. tools/verifyBuild.ts was added after that list was written, the matching entry was missed, and the generated file went into the repository. Replaces the enumeration with a `tools/*.js` glob so the next tool cannot repeat the mistake, and untracks the file. Nothing depends on it being committed: `deploy-v3` and `deploy-v2` both run `build-tooling` before the verify step, and `bun run verify` regenerates it itself. Checked by deleting every tools/*.js and running the CI sequence from that state — build-tooling regenerates them and verifyBuild reports the manifest loadable for both MV3 and MV2. --- .gitignore | 10 +++---- tools/verifyBuild.js | 64 -------------------------------------------- 2 files changed, 4 insertions(+), 70 deletions(-) delete mode 100644 tools/verifyBuild.js diff --git a/.gitignore b/.gitignore index 6a05448..87bcdd4 100644 --- a/.gitignore +++ b/.gitignore @@ -687,12 +687,10 @@ FodyWeavers.xsd dist -tools/syncConfig.js -tools/parse.js -tools/v2.js -tools/clean.js -tools/content.js -tools/package.js +# Compiled output of tools/*.ts (produced by `bun run build-tooling`). +# A glob rather than a list: enumerating them meant every new tool needed a +# matching entry, and tools/verifyBuild.js was committed when one was missed. +tools/*.js artifacts/ test-results/ playwright-report/ diff --git a/tools/verifyBuild.js b/tools/verifyBuild.js deleted file mode 100644 index 0fc7de7..0000000 --- a/tools/verifyBuild.js +++ /dev/null @@ -1,64 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -const DIST = './dist'; -const errors = []; -const require_ = (relative, why) => { - if (!fs.existsSync(path.join(DIST, relative))) - errors.push(`missing ${relative} (${why})`); -}; -if (!fs.existsSync(path.join(DIST, 'manifest.json'))) { - throw new Error('dist/manifest.json is missing — run `bun run deploy-v3` first'); -} -const manifest = JSON.parse(fs.readFileSync(path.join(DIST, 'manifest.json'), 'utf8')); -require_('panel.html', 'the DevTools panel'); -require_('panel.js', 'the panel bundle'); -require_('content.js', 'the opt-in live-streaming relay'); -if (manifest.devtools_page) - require_(manifest.devtools_page, 'manifest.devtools_page'); -if (manifest.options_ui?.page) - require_(manifest.options_ui.page, 'manifest.options_ui.page'); -if (manifest.background?.service_worker) { - require_(manifest.background.service_worker, 'manifest.background.service_worker'); -} -for (const script of manifest.background?.scripts ?? []) { - require_(script, 'manifest.background.scripts'); -} -const contentScript = fs.readFileSync(path.join(DIST, 'content.js'), 'utf8'); -if (/\bimport\s*[({'"]/.test(contentScript) || /\bexport\s[{*]/.test(contentScript)) { - errors.push('content.js contains module syntax; it must be a self-contained classic script'); -} -for (const file of fs.readdirSync(DIST)) { - if (!file.endsWith('.html')) - continue; - const html = fs.readFileSync(path.join(DIST, file), 'utf8'); - if (html.includes('{{')) - errors.push(`${file} still contains an unreplaced template token`); -} -if ((manifest.host_permissions ?? []).length > 0) { - errors.push('host_permissions must stay empty; the panel requests one origin at runtime'); -} -const optional = manifest.optional_host_permissions ?? manifest.optional_permissions ?? []; -if (!optional.some(entry => entry.includes('://'))) { - errors.push('no optional host permission declared; live streaming could never be enabled'); -} -if (manifest.manifest_version === 3) { - if (!manifest.background?.service_worker) - errors.push('MV3 build has no service worker'); -} -else if (manifest.manifest_version === 2) { - if (!manifest.background?.scripts?.length) - errors.push('MV2 build has no background scripts'); - if ((manifest.permissions ?? []).includes('scripting')) { - errors.push('MV2 build still declares the MV3-only "scripting" permission'); - } -} -else { - errors.push(`unexpected manifest_version: ${manifest.manifest_version}`); -} -if (errors.length > 0) { - console.error('✗ build verification failed:'); - for (const error of errors) - console.error(` - ${error}`); - process.exit(1); -} -console.log(`✓ ${manifest.name} ${manifest.version} (MV${manifest.manifest_version}) looks loadable`); From 2614f6560464e580e77e2d3b517361f67f5e49cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:07:29 +0000 Subject: [PATCH 8/9] fix: address the CodeRabbit review on the panel, protocol and transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirteen findings, each verified against the code before acting. Three were outright bugs my own tests were shaped to miss. Bugs - evalTransport: Firefox's `browser.devtools.inspectedWindow.eval` is promisified and treats the second argument as options, so the callback was never invoked, the transport never left `connecting`, and every request timed out. The Firefox target was effectively dead. Both shapes are now normalized onto the Evaluator contract. - Interactive controls were destroyed by their own reactive re-render: each view reads the signal its input writes, then rebuilt the subtree, detaching the focused element. Typing "item" into the component filter left "i". Reproduced first, then fixed by building the toolbars once and rebuilding only the lists. The E2E suite missed this because `fill()` sets a value in one operation; the new regression test types character by character. - client: a streamed event arriving before `init` set the state to connected, which stopped the hello retries for good — capabilities stayed empty while the status bar claimed success. The hello loop now runs off an explicit handshake flag that only `init` sets. Correctness - state: `seedTimeline` read the buffer before awaiting `getTimeline`, so events arriving during the request were dropped by the reset. This is the same class of bug as 3c69094's, one window further in; the read now happens after the await. - timeTravel: entries older than the base snapshot are no longer replayed — they describe state the snapshot already supersedes, so applying them moved signals backwards onto known-stale values. Reported as `skippedCount`. - state: an empty component list from a successful refresh now clears the registry instead of leaving stale counts on screen. - results: `parseSnapshot` rejects arrays, which `isRecord` admitted and which parsed into an empty snapshot that wiped signals and stores. - inspector: unresolved stores keep the state `reconstructAt` preserved, matching the signals branch; the badge already says "not recorded". - valueTree: Date, RegExp, Error, Map and Set no longer describe as `{}`. - settings: a failed render is caught and shown instead of becoming an unhandled rejection behind a blank options page. Release and docs - release: signing requires both AMO credentials, so a missing secret no longer runs web-ext with an empty one. - PUBLISHING: the store privacy guidance said the extension "collects and transmits nothing". Chrome counts website content as user data and the panel reads plenty of it; what is true is that none of it leaves the machine. The guidance now says that instead. - ARCHITECTURE: the diagram fence is labelled (MD040). Tests - Timestamps in the time-travel tests were incidental defaults that predated the base snapshot; they are explicit now, with dedicated cases for the new skip rule and its boundary. - The buffer-resize test never reached the clamping branch it named, because capacity cannot fall below MIN_BUFFER_SIZE. Renamed, and a real case added that fills past the minimum first. - The prototype-chain E2E case settles on the expected reply count rather than a fixed 200ms delay. 156 unit tests, 11 E2E tests, both build targets verified loadable. --- .github/workflows/release.yml | 2 +- docs/ARCHITECTURE.md | 2 +- docs/PUBLISHING.md | 13 +- src/panel/components/componentTree.ts | 60 ++++++--- src/panel/components/inspector.ts | 6 +- src/panel/components/timelineView.ts | 173 ++++++++++++++++---------- src/panel/state.ts | 11 +- src/panel/timeTravel.ts | 15 +++ src/panel/valueTree.ts | 26 ++++ src/protocol/client.ts | 18 ++- src/protocol/results.ts | 4 +- src/settings.ts | 23 +++- src/transports/evalTransport.ts | 47 ++++++- tests/e2e/panel.spec.ts | 35 +++++- tests/unit/panel.state.test.ts | 21 +++- tests/unit/panel.timeTravel.test.ts | 22 +++- 16 files changed, 369 insertions(+), 109 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83cd73d..7d402aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: # artifact: signing is a service, not a local key operation. Without # credentials the release still ships the unsigned zip for manual upload. - name: Sign the Firefox build (AMO) - if: env.AMO_JWT_ISSUER != '' + if: env.AMO_JWT_ISSUER != '' && env.AMO_JWT_SECRET != '' run: | bunx web-ext@8 sign \ --source-dir dist \ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 766ee66..953f48c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,7 +7,7 @@ runs the app (and the bQuery bridge); the panel runs in the DevTools window. Everything between them is a message channel with an untrusted party on one end. -``` +```text ┌─────────────────────────────────────────────────────────────────────┐ │ inspected page │ │ app code → connectDevtoolsBridge() ← the stable contract (v1) │ diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 62b55bc..b068cda 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -93,8 +93,17 @@ if you need it, keep the key outside CI. - *Category*: Developer Tools. - *Screenshots*: the panel on a page running a bQuery app — component tree, signals, and timeline are the three that matter. - - *Privacy*: the extension collects and transmits **nothing**. Declare no - data collection. + - *Privacy*: be precise here, because "collects nothing" is the wrong + answer. Chrome Web Store policy counts **website content** as user data, + and the panel reads plenty of it — the component tree, signal and store + values, and timeline entries from the inspected page. What the extension + does *not* do is send any of it anywhere: everything stays in the DevTools + process on the user's machine, there is no remote endpoint, and the only + thing written to `chrome.storage.local` is the panel's own preferences + (buffer size, poll interval, live-streaming toggle). Declare handling of + website content, declare no transmission, and answer the certification + questions accordingly — the extension neither sells data nor uses it for + anything beyond the panel's displayed purpose. 4. Justify the permissions. Reviewers ask about these two: - `scripting` — "injects a small relay into the inspected tab, only after the user explicitly enables live streaming and grants that site's origin"; diff --git a/src/panel/components/componentTree.ts b/src/panel/components/componentTree.ts index 2d00b3b..29b6f14 100644 --- a/src/panel/components/componentTree.ts +++ b/src/panel/components/componentTree.ts @@ -12,21 +12,28 @@ import { el, replaceChildren } from '../dom'; import { buildSelectExpression, flattenTree, nodeAtPath, parsePathKey, pathKey } from '../tree'; import { defineElement, PanelElement } from './base'; -/** Component tree view. */ +/** + * Component tree view. + * + * The toolbar is built once and never re-created. `render()` reads the search + * signal that the input's own handler writes, so rebuilding the whole subtree + * would detach the focused field on the first keystroke and silently swallow + * everything the user typed after it. Only the list below is rebuilt. + */ export class ComponentTreeView extends PanelElement { - protected render(): void { + private searchInput: HTMLInputElement | null = null; + private countLabel: HTMLElement | null = null; + private listHost: HTMLElement | null = null; + + private buildChrome(): void { + if (this.listHost) return; const state = this.state; - const search = state.treeSearch.value; - const nodes = state.tree.value; - const selected = state.selectedPath.value; - const flat = flattenTree(nodes, search); - const searchInput = el('input', { + this.searchInput = el('input', { class: 'tree-search', attrs: { type: 'search', placeholder: 'Filter by tag or attribute…', - value: search, 'aria-label': 'Filter components', }, on: { @@ -36,13 +43,12 @@ export class ComponentTreeView extends PanelElement { }, }, }); + this.countLabel = el('span', { class: 'muted' }); + this.listHost = el('div', { class: 'tree-list', attrs: { role: 'tree' } }); const header = el('div', { class: 'view-toolbar' }, [ - searchInput, - el('span', { - class: 'muted', - text: search ? `${flat.length} matching` : `${flat.length} components`, - }), + this.searchInput, + this.countLabel, el('button', { class: 'btn', text: 'Refresh', @@ -55,9 +61,29 @@ export class ComponentTreeView extends PanelElement { }), ]); - const list = el('div', { class: 'tree-list', attrs: { role: 'tree' } }); + replaceChildren(this, [header, this.listHost]); + } + + protected render(): void { + const state = this.state; + const search = state.treeSearch.value; + const nodes = state.tree.value; + const selected = state.selectedPath.value; + const flat = flattenTree(nodes, search); + + this.buildChrome(); + const searchInput = this.searchInput; + const list = this.listHost; + if (!searchInput || !list || !this.countLabel) return; + + // Only push a value the user did not type themselves, so an in-progress + // edit (and its caret) is never disturbed. + if (searchInput.value !== search) searchInput.value = search; + this.countLabel.textContent = search ? `${flat.length} matching` : `${flat.length} components`; + + const rows: Node[] = []; if (flat.length === 0) { - list.appendChild( + rows.push( el('p', { class: 'empty', text: state.supports('components') @@ -96,10 +122,10 @@ export class ComponentTreeView extends PanelElement { if (item.node.children.length > 0) { row.appendChild(el('span', { class: 'tree-count', text: `${item.node.children.length}` })); } - list.appendChild(row); + rows.push(row); } - replaceChildren(this, [header, list]); + replaceChildren(list, rows); } /** Reveal the node in the page's Elements panel. */ diff --git a/src/panel/components/inspector.ts b/src/panel/components/inspector.ts index 3ade292..84d5fd4 100644 --- a/src/panel/components/inspector.ts +++ b/src/panel/components/inspector.ts @@ -9,7 +9,6 @@ * @module panel/components/inspector */ import { el, replaceChildren } from '../dom'; -import { UNKNOWN_VALUE } from '../timeTravel'; import { defineElement, PanelElement } from './base'; // Registers , which the rows below instantiate. import './valueView'; @@ -45,8 +44,11 @@ export class InspectorView extends PanelElement { } else { const entries = replay ? replay.stores.map(item => ({ + // `reconstructAt` keeps the last known state when a patch is + // unusable, so show it and let the badge say it is unresolved — + // the same contract the signals branch above follows. key: item.id, - value: item.unresolved ? UNKNOWN_VALUE : item.state, + value: item.state as unknown, meta: item.unresolved ? 'not recorded' : item.fromBase ? 'unchanged' : 'replayed', })) : state.stores.value.map(item => ({ diff --git a/src/panel/components/timelineView.ts b/src/panel/components/timelineView.ts index 84f3312..1aaae3c 100644 --- a/src/panel/components/timelineView.ts +++ b/src/panel/components/timelineView.ts @@ -18,9 +18,20 @@ import type { ValueView } from './valueView'; /** Most rows rendered at once; the buffer itself may hold far more. */ const MAX_RENDERED_ROWS = 300; +interface TimelineChrome { + readonly toolbarHost: HTMLElement; + readonly searchInput: HTMLInputElement; + readonly chipsHost: HTMLElement; + readonly range: HTMLInputElement; + readonly liveButton: HTMLButtonElement; + readonly scrubberStatus: HTMLElement; + readonly listHost: HTMLElement; +} + /** Timeline view. */ export class TimelineView extends PanelElement { private expandedRow = -1; + private chrome: TimelineChrome | null = null; protected render(): void { const state = this.state; @@ -33,12 +44,81 @@ export class TimelineView extends PanelElement { const visible = filterEntries(entries, filter); const rendered = visible.slice(-MAX_RENDERED_ROWS); - replaceChildren(this, [ - this.toolbar(paused, entries.length), - this.filters(entries, filter), - this.scrubber(entries.length, travelIndex), - this.list(rendered, entries, visible.length), + this.buildChrome(); + const chrome = this.chrome; + if (!chrome) return; + + replaceChildren(chrome.toolbarHost, [this.toolbar(paused, entries.length)]); + this.updateFilters(entries, filter); + this.updateScrubber(entries.length, travelIndex); + replaceChildren(chrome.listHost, [this.list(rendered, entries, visible.length)]); + } + + /** + * Build the parts that must survive a re-render. + * + * `render()` reads the very signals these controls write, so rebuilding them + * would detach whatever the user is interacting with: the search field loses + * its caret after one keystroke, and the scrubber's drag ends the moment it + * moves. Their containers are created once and only their contents change. + */ + private buildChrome(): void { + if (this.chrome) return; + const state = this.state; + + const toolbarHost = el('div'); + const searchInput = el('input', { + class: 'tree-search', + attrs: { + type: 'search', + placeholder: 'Filter events…', + 'aria-label': 'Filter timeline events', + }, + on: { + input: event => { + const target = event.target as HTMLInputElement; + const current = state.timelineFilter.value; + state.timelineFilter.value = { types: current.types, search: target.value }; + }, + }, + }); + const chipsHost = el('div', { class: 'chips' }); + const range = el('input', { + class: 'scrubber-range', + attrs: { type: 'range', min: '0', 'aria-label': 'Replay position' }, + on: { + input: event => { + const target = event.target as HTMLInputElement; + state.travelTo(Number(target.value)); + }, + }, + }); + const liveButton = el('button', { + class: 'btn', + text: 'Live', + attrs: { type: 'button' }, + on: { click: () => state.resumeLive() }, + }); + const scrubberStatus = el('span', { class: 'muted' }); + const listHost = el('div'); + + const filtersRow = el('div', { class: 'timeline-filters' }, [searchInput, chipsHost]); + const scrubberRow = el('div', { class: 'scrubber' }, [ + el('label', { class: 'field scrubber-field' }, [el('span', { text: 'Time travel' }), range]), + liveButton, + scrubberStatus, ]); + + this.chrome = { + toolbarHost, + searchInput, + chipsHost, + range, + liveButton, + scrubberStatus, + listHost, + }; + replaceChildren(this, [toolbarHost, filtersRow, scrubberRow, listHost]); } private toolbar(paused: boolean, bufferedCount: number): Node { @@ -89,13 +169,17 @@ export class TimelineView extends PanelElement { ]); } - private filters( + private updateFilters( entries: readonly TimelineEntry[], filter: { types: ReadonlySet; search: string } - ): Node { + ): void { const state = this.state; + const chrome = this.chrome; + if (!chrome) return; const types = collectTypes(entries); + if (chrome.searchInput.value !== filter.search) chrome.searchInput.value = filter.search; + const chips = types.map(type => el('button', { class: `chip${filter.types.has(type) ? ' is-on' : ''}`, @@ -112,71 +196,32 @@ export class TimelineView extends PanelElement { }) ); - return el('div', { class: 'timeline-filters' }, [ - el('input', { - class: 'tree-search', - attrs: { - type: 'search', - placeholder: 'Filter events…', - value: filter.search, - 'aria-label': 'Filter timeline events', - }, - on: { - input: event => { - const target = event.target as HTMLInputElement; - state.timelineFilter.value = { types: filter.types, search: target.value }; - }, - }, - }), - el('div', { class: 'chips' }, chips), - ]); + replaceChildren(chrome.chipsHost, chips); } - private scrubber(total: number, travelIndex: number | null): Node { + private updateScrubber(total: number, travelIndex: number | null): void { const state = this.state; + const chrome = this.chrome; + if (!chrome) return; const supported = state.supports('time-travel'); const disabled = total === 0 || !supported; const index = travelIndex ?? total - 1; const replay = state.reconstruction.value; - return el('div', { class: 'scrubber' }, [ - el('label', { class: 'field scrubber-field' }, [ - el('span', { text: 'Time travel' }), - el('input', { - class: 'scrubber-range', - attrs: { - type: 'range', - min: '0', - max: String(Math.max(total - 1, 0)), - value: String(Math.max(index, 0)), - 'aria-label': 'Replay position', - ...(disabled ? { disabled: 'true' } : {}), - }, - on: { - input: event => { - const target = event.target as HTMLInputElement; - state.travelTo(Number(target.value)); - }, - }, - }), - ]), - el('button', { - class: 'btn', - text: 'Live', - attrs: { type: 'button', ...(travelIndex === null ? { disabled: 'true' } : {}) }, - on: { click: () => state.resumeLive() }, - }), - el('span', { - class: 'muted', - text: !supported - ? 'The page does not advertise the "time-travel" capability.' - : replay - ? `@ ${formatTime(replay.timestamp)} · ${replay.appliedCount} applied${ - replay.unresolvedCount > 0 ? ` · ${replay.unresolvedCount} not recorded` : '' - }` - : 'Following live state', - }), - ]); + const { range, liveButton, scrubberStatus } = chrome; + range.max = String(Math.max(total - 1, 0)); + // Leave the thumb alone while it is being dragged, or the value written + // back mid-gesture fights the pointer. + if (document.activeElement !== range) range.value = String(Math.max(index, 0)); + range.disabled = disabled; + liveButton.disabled = travelIndex === null; + scrubberStatus.textContent = !supported + ? 'The page does not advertise the "time-travel" capability.' + : replay + ? `@ ${formatTime(replay.timestamp)} · ${replay.appliedCount} applied${ + replay.unresolvedCount > 0 ? ` · ${replay.unresolvedCount} not recorded` : '' + }` + : 'Following live state'; } private list( diff --git a/src/panel/state.ts b/src/panel/state.ts index 58a6118..0964f52 100644 --- a/src/panel/state.ts +++ b/src/panel/state.ts @@ -157,7 +157,9 @@ export class PanelState { if (!this.supports('components')) return; const result = parseComponentTree(await this.client.request('getComponentTree')); this.tree.value = result.tree; - if (result.flat.length > 0) this.components.value = result.flat; + // Assigned even when empty: a page that unmounted every component must + // clear the registry, not keep showing the previous counts. + this.components.value = result.flat; } /** Refetch signals, stores and components, and re-base time travel. */ @@ -166,7 +168,7 @@ export class PanelState { if (!snapshot) return; this.signals.value = snapshot.signals; this.stores.value = snapshot.stores; - if (snapshot.components.length > 0) this.components.value = snapshot.components; + this.components.value = snapshot.components; this.base.value = { signals: snapshot.signals, stores: snapshot.stores, @@ -186,10 +188,13 @@ export class PanelState { */ public async seedTimeline(): Promise { if (!this.supports('timeline')) return; - const streamed = [...this.buffer.all()]; const entries = parseTimeline( await this.client.request('getTimeline', { limit: TIMELINE_SEED_LIMIT }) ); + // Read the buffer *after* awaiting, not before: events that arrive while + // the request is in flight are in it by now, and a snapshot taken earlier + // would silently drop exactly those. + const streamed = [...this.buffer.all()]; const seeded = new Set(entries.map(entryKey)); this.buffer.reset(entries); this.buffer.extend(streamed.filter(entry => !seeded.has(entryKey(entry)))); diff --git a/src/panel/timeTravel.ts b/src/panel/timeTravel.ts index 162183c..4acb03e 100644 --- a/src/panel/timeTravel.ts +++ b/src/panel/timeTravel.ts @@ -49,6 +49,12 @@ export interface Reconstruction { readonly appliedCount: number; /** Number of replayed entries whose payload could not be interpreted. */ readonly unresolvedCount: number; + /** + * Number of entries older than the base snapshot, which are therefore not + * replayed. The page's own timeline can reach back before the snapshot was + * taken; applying those would write stale values over newer measured ones. + */ + readonly skippedCount: number; } /** The base state time travel replays from. */ @@ -140,10 +146,18 @@ export const reconstructAt = ( let appliedCount = 0; let unresolvedCount = 0; + let skippedCount = 0; for (let cursor = 0; cursor <= upto; cursor += 1) { const entry = entries[cursor]; if (!entry || !isReplayable(entry)) continue; + // The base is a measurement taken at `capturedAt`; an entry recorded + // before it describes a state the snapshot already supersedes. Replaying + // it would move a signal *backwards* onto a value that is known to be old. + if (entry.timestamp < base.capturedAt) { + skippedCount += 1; + continue; + } const key = entry.source ?? entry.detail; if (!key) continue; @@ -187,6 +201,7 @@ export const reconstructAt = ( timestamp: at?.timestamp ?? base.capturedAt, appliedCount, unresolvedCount, + skippedCount, signals: [...signals.entries()] .map(([label, cell]) => ({ label, diff --git a/src/panel/valueTree.ts b/src/panel/valueTree.ts index 182d331..3cb9d38 100644 --- a/src/panel/valueTree.ts +++ b/src/panel/valueTree.ts @@ -96,6 +96,32 @@ export const describeValue = (value: unknown): DescribedValue => { entries, }; } + // Built-ins carry their data outside own enumerable keys, so the generic + // path below would describe every one of them as an empty `{}` and hide + // real store and signal contents. + if (value instanceof Date) { + return { kind: 'object', preview: value.toISOString(), entries: null }; + } + if (value instanceof RegExp || value instanceof Error) { + return { kind: 'object', preview: truncate(String(value)), entries: null }; + } + if (value instanceof Map) { + const items = [...value.entries()].slice(0, ENTRY_LIMIT); + return { + kind: 'object', + preview: truncate(`Map(${value.size})`), + entries: items.map(([key, item]) => ({ key: shortPreview(key), value: item })), + }; + } + if (value instanceof Set) { + const items = [...value.values()].slice(0, ENTRY_LIMIT); + return { + kind: 'object', + preview: truncate(`Set(${value.size})`), + entries: items.map((item, index) => ({ key: String(index), value: item })), + }; + } + const record = value as Record; const keys = Object.keys(record).slice(0, ENTRY_LIMIT); const entries = keys.map(key => ({ key, value: record[key] })); diff --git a/src/protocol/client.ts b/src/protocol/client.ts index 560117a..54a41ec 100644 --- a/src/protocol/client.ts +++ b/src/protocol/client.ts @@ -95,6 +95,14 @@ export class BridgeClient { private nextRequestId = 1; private helloTimer: number | null = null; + /** + * Set only by an `init` reply. The hello loop is driven by this rather than + * by the displayed state: a page that streams an event before its handshake + * reply arrives would otherwise stop the retries with no capabilities + * negotiated, leaving every capability-gated view reporting "unsupported" + * while the status bar claims the panel is connected. + */ + private handshakeComplete = false; private disposed = false; private started = false; @@ -147,6 +155,7 @@ export class BridgeClient { */ public resetHandshake(reason = 'page navigated'): void { if (this.disposed) return; + this.handshakeComplete = false; this.rejectAllPending(new Error(`bQuery DevTools: ${reason}`)); this.capabilities.value = new Set(); this.state.value = 'waiting-for-page'; @@ -201,6 +210,7 @@ export class BridgeClient { this.scheduleHello(true); return; case 'closed': + this.handshakeComplete = false; this.cancelHello(); this.rejectAllPending(new Error(`bQuery DevTools: ${status.reason}`)); this.capabilities.value = new Set(); @@ -208,6 +218,7 @@ export class BridgeClient { this.detail.value = status.reason; return; case 'error': + this.handshakeComplete = false; this.cancelHello(); this.rejectAllPending(new Error(`bQuery DevTools: ${status.reason}`)); this.state.value = 'error'; @@ -224,6 +235,7 @@ export class BridgeClient { switch (message.kind) { case 'init': { const negotiated = negotiateCapabilities(message.capabilities); + this.handshakeComplete = true; this.cancelHello(); this.capabilities.value = negotiated; this.state.value = 'connected'; @@ -244,7 +256,9 @@ export class BridgeClient { return; } case 'event': { - // A streamed event also proves the page is alive: stop retrying hello. + // A streamed event proves the page is alive, so surface that — but it + // is not a handshake, so the hello retries deliberately continue until + // `init` answers with the capability list. if (this.state.value !== 'connected') this.state.value = 'connected'; for (const listener of this.eventListeners) listener(message.entry); return; @@ -255,7 +269,7 @@ export class BridgeClient { private scheduleHello(immediate: boolean): void { this.cancelHello(); const fire = (): void => { - if (this.disposed || this.state.value === 'connected') return; + if (this.disposed || this.handshakeComplete) return; this.transport.send(helloMessage()); this.helloTimer = this.setTimer(fire, this.helloIntervalMs); }; diff --git a/src/protocol/results.ts b/src/protocol/results.ts index 6cd2223..0df61f3 100644 --- a/src/protocol/results.ts +++ b/src/protocol/results.ts @@ -116,7 +116,9 @@ export const parseTimeline = (value: unknown): TimelineEntry[] => parseArray(val * have to know where the framework happens to keep it. */ export const parseSnapshot = (value: unknown): SnapshotView | null => { - if (!isRecord(value)) return null; + // `isRecord` admits arrays, and an array result would parse into an + // all-empty snapshot that then wipes the panel's signals and stores. + if (!isRecord(value) || Array.isArray(value)) return null; const state = isRecord(value['state']) ? value['state'] : {}; return { signals: parseArray(value['signals'], parseSignal), diff --git a/src/settings.ts b/src/settings.ts index 55a046c..f620af4 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -85,7 +85,22 @@ const render = (): void => { }); }; -void loadSettings().then(settings => { - current.value = settings; - render(); -}); +void loadSettings() + .then(settings => { + current.value = settings; + render(); + }) + .catch((error: unknown) => { + // `render()` throws when the host element is missing. Without this the + // options page would stay blank with the reason only in an unhandled + // rejection nobody sees. + console.error('bQuery DevTools: the options page failed to render', error); + const host = document.getElementById(HOST_ID) ?? document.body; + $(host) + .empty() + .append( + safeHtml`

Could not load the options page: ${ + error instanceof Error ? error.message : String(error) + }

` + ); + }); diff --git a/src/transports/evalTransport.ts b/src/transports/evalTransport.ts index 61f6f7e..4df73f5 100644 --- a/src/transports/evalTransport.ts +++ b/src/transports/evalTransport.ts @@ -80,6 +80,47 @@ export type Evaluator = ( callback: (result: unknown, exceptionInfo?: unknown) => void ) => void; +/** + * Evaluate through whichever shape the browser implements. + * + * Chromium's `chrome.devtools.inspectedWindow.eval(expression, callback)` is + * callback-based. Firefox's `browser.*` namespace is promisified and treats the + * second argument as *options*, resolving to a `[result, exceptionInfo]` pair — + * so passing a callback there means it is never called, the transport never + * leaves `connecting`, and every request times out. + * + * Both are normalized onto the callback contract the transport expects. + */ +const defaultEvaluator: Evaluator = (expression, callback) => { + const inspectedWindow = extensionApi().devtools.inspectedWindow; + let settled = false; + const settle = (result: unknown, exceptionInfo?: unknown): void => { + if (settled) return; + settled = true; + callback(result, exceptionInfo); + }; + + const returned: unknown = ( + inspectedWindow.eval as unknown as ( + expression: string, + callback?: (result: unknown, exceptionInfo?: unknown) => void + ) => unknown + )(expression, settle); + + // Firefox returns a thenable and ignores the callback; Chromium returns + // undefined and invokes it. Whichever answers first wins, so a browser that + // does both cannot deliver the same result twice. + if (returned && typeof (returned as PromiseLike).then === 'function') { + void Promise.resolve(returned).then( + value => { + const pair = Array.isArray(value) ? value : [value, undefined]; + settle(pair[0], pair[1]); + }, + (error: unknown) => settle(undefined, { isError: true, value: error }) + ); + } +}; + const isFailure = (exceptionInfo: unknown): boolean => { if (!exceptionInfo || typeof exceptionInfo !== 'object') return false; const info = exceptionInfo as { isError?: unknown; isException?: unknown }; @@ -100,11 +141,7 @@ export class EvalTransport implements BridgeTransport { private lastFailure = ''; constructor(options: EvalTransportOptions & { evaluate?: Evaluator } = {}) { - this.evaluate = - options.evaluate ?? - ((expression, callback) => { - extensionApi().devtools.inspectedWindow.eval(expression, callback); - }); + this.evaluate = options.evaluate ?? defaultEvaluator; this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; this.drainExpression = buildDrainExpression(options.queueLimit ?? DEFAULT_QUEUE_LIMIT); } diff --git a/tests/e2e/panel.spec.ts b/tests/e2e/panel.spec.ts index 5945aa1..5049250 100644 --- a/tests/e2e/panel.spec.ts +++ b/tests/e2e/panel.spec.ts @@ -210,6 +210,7 @@ test.describe('bQuery DevTools panel', () => { // the panel would then be handed `Object` as a result. const replies = await page.evaluate(async () => { const answers: unknown[] = []; + let settle = (): void => undefined; const collect = (event: MessageEvent): void => { const data = event.data as Record | null; if (!data || data['source'] !== 'bquery-devtools' || data['kind'] !== 'response') return; @@ -218,9 +219,18 @@ test.describe('bQuery DevTools panel', () => { // assertion race. if (typeof data['id'] !== 'number' || data['id'] < 9000) return; answers.push(data['error'] ?? data['result']); + settle(); }; + const methods = ['constructor', 'toString', '__proto__']; + // Settle on the expected count rather than a fixed delay: a slow task + // queue would otherwise make this assert against a partial result. + const collected = new Promise(resolve => { + settle = () => { + if (answers.length >= methods.length) resolve(); + }; + }); window.addEventListener('message', collect); - for (const [index, method] of ['constructor', 'toString', '__proto__'].entries()) { + for (const [index, method] of methods.entries()) { window.postMessage( { source: 'bquery-devtools', @@ -233,7 +243,7 @@ test.describe('bQuery DevTools panel', () => { '*' ); } - await new Promise(resolve => setTimeout(resolve, 200)); + await Promise.race([collected, new Promise(resolve => setTimeout(resolve, 2000))]); window.removeEventListener('message', collect); return answers; }); @@ -244,6 +254,27 @@ test.describe('bQuery DevTools panel', () => { } }); + test('interactive controls survive their own reactive re-render', async ({ page }) => { + await openPanel(page); + + // Each of these writes the signal its own view reads, so a full-subtree + // re-render would detach the focused control mid-interaction. Typing + // character by character is what exposes it — `fill()` sets the value in + // one operation and passes either way. + const treeSearch = page.getByLabel('Filter components'); + await treeSearch.click(); + await page.keyboard.type('item', { delay: 20 }); + await expect(treeSearch).toHaveValue('item'); + await expect(page.locator('.tree-row')).toHaveCount(4); + + await openTab(page, 'Timeline'); + const eventSearch = page.getByLabel('Filter timeline events'); + await eventSearch.click(); + await page.keyboard.type('count', { delay: 20 }); + await expect(eventSearch).toHaveValue('count'); + await expect(page.locator('.timeline-row')).toHaveCount(1); + }); + test('reports a page that never answers the handshake', async ({ page }) => { // No fixture bridge: only the chrome mock, so `hello` goes unanswered. await page.addInitScript(() => { diff --git a/tests/unit/panel.state.test.ts b/tests/unit/panel.state.test.ts index 3b1b943..97a6214 100644 --- a/tests/unit/panel.state.test.ts +++ b/tests/unit/panel.state.test.ts @@ -164,7 +164,7 @@ describe('streaming', () => { expect(state.timeTravelIndex.value).toBeNull(); }); - test('resizing the buffer clamps an out-of-range replay position', async () => { + test('resizing the buffer keeps an in-range replay position', async () => { await connect(); for (let index = 0; index < 10; index += 1) { transport.event({ type: 'signal:update', detail: `#${index}`, timestamp: index }); @@ -172,10 +172,22 @@ describe('streaming', () => { state.travelTo(9); state.setBufferSize(50); expect(state.bufferCapacity()).toBe(50); - // 50 is below MIN_BUFFER_SIZE-clamped range only if smaller; here the - // buffer still holds every entry, so the position survives. + // The buffer still holds every entry, so the position is untouched. expect(state.timeTravelIndex.value).toBe(9); }); + + test('shrinking the buffer past the replay position clamps it', async () => { + await connect(); + // More than MIN_BUFFER_SIZE entries, or the capacity cannot drop below + // the number buffered and the clamping branch stays unreachable. + for (let index = 0; index < 60; index += 1) { + transport.event({ type: 'signal:update', detail: `#${index}`, timestamp: index }); + } + state.travelTo(59); + state.setBufferSize(50); + expect(state.entries()).toHaveLength(50); + expect(state.timeTravelIndex.value).toBe(49); + }); }); describe('time travel', () => { @@ -196,7 +208,8 @@ describe('time travel', () => { transport.event({ type: 'signal:update', detail: 'count', - timestamp: 2, + // After `snapshot.exportedAt`, so the replay base does not supersede it. + timestamp: snapshot.exportedAt + 1, source: 'count', payload: { value: 42 }, }); diff --git a/tests/unit/panel.timeTravel.test.ts b/tests/unit/panel.timeTravel.test.ts index b61e10b..63bbe82 100644 --- a/tests/unit/panel.timeTravel.test.ts +++ b/tests/unit/panel.timeTravel.test.ts @@ -9,7 +9,10 @@ import { type TimeTravelBase, } from '../../src/panel/timeTravel'; -const entry = (type: string, source: string, payload?: unknown, timestamp = 0): TimelineEntry => +// Defaults to a timestamp *after* `base.capturedAt`: entries older than the +// base are deliberately not replayed, and these cases are about payload +// interpretation rather than clock ordering. +const entry = (type: string, source: string, payload?: unknown, timestamp = 1001): TimelineEntry => ({ timestamp, type, detail: source, source, payload }) as TimelineEntry; const base: TimeTravelBase = { @@ -119,6 +122,23 @@ describe('reconstructAt', () => { expect(result.stores[0]?.state).toEqual({ items: 0, open: false }); }); + test('entries recorded before the base snapshot are not replayed', () => { + // The page's own timeline reaches back before the snapshot was taken. + // Replaying those would write a known-stale value over a measured one. + const stale = entry('signal:update', 'count', { value: 999 }, base.capturedAt - 1); + const result = reconstructAt(base, [stale], 0); + expect(result.signals.find(item => item.label === 'count')?.value).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.appliedCount).toBe(0); + }); + + test('an entry exactly at the base timestamp still replays', () => { + const boundary = entry('signal:update', 'count', { value: 7 }, base.capturedAt); + const result = reconstructAt(base, [boundary], 0); + expect(result.signals.find(item => item.label === 'count')?.value).toBe(7); + expect(result.skippedCount).toBe(0); + }); + test('entries without a source or detail key are skipped', () => { const orphan = { timestamp: 1, type: 'signal:update', detail: '' } as TimelineEntry; expect(reconstructAt(base, [orphan], 0).appliedCount).toBe(0); From ba0f53a9e5fc1da22785d02cfe970c6c4ff806ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:42:23 +0000 Subject: [PATCH 9/9] feat(panel): degrade gracefully against partially implemented bQuery apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bQuery is modular and its bridge is a public contract, so the page on the other end is often not a complete framework: an app may load `reactive` without `store`, run devtools without mounting a component, or hand-roll a bridge server implementing two of the four methods. The panel assumed a complete one. The worst of it was structural: `refreshAll` chained the three fetches through `Promise.all`, so a page that implements `getTimeline` but not `getSnapshot` got no timeline either — one missing method took the whole panel down. The fetches now run independently and none of them rejects. Capabilities from the `init` handshake are now a hint rather than a gate. `createBridgeServer` advertises the full list regardless of which modules an app actually loaded, and a trimmed bridge may advertise nothing while answering everything, so `panel/features.ts` tracks per feature what the page has actually proved: - a capability the page never advertised is probed once per connection, so a bridge that advertises nothing still lights up; - a method the page refuses is not asked again until the next handshake or an explicit "Refresh all", so an absent feature costs exactly one request; - a snapshot carrying `signals` but no `stores` key reads as "the page does not report stores", not a confident and wrong "0 stores" — and no longer wipes a component registry that `getComponentTree` filled in; - a section written off comes back by itself once a later snapshot carries it; - with no component tree but a snapshot that lists components, the tree view shows that flat registry instead of an empty panel; - time travel is gated on having a base snapshot and recorded events, since the panel reconstructs it — the page is never asked to do anything. A page answering in a protocol version this panel cannot read is now named ("the page speaks bridge protocol v2…") instead of leaving the panel in "waiting for the page" while the page answers every hello. The messages are still discarded, and the handshake keeps retrying so a navigation recovers. Capabilities the page advertises that this build has no view for are surfaced too — the visible symptom of an extension older than the app it inspects. `KNOWN_CAPABILITIES` is now derived from a total `Record` over the published capability union, so a capability added upstream fails to compile here rather than silently producing a feature nobody renders. Adds 7 unit tests for the feature model, 12 covering the state machine and protocol, and 5 E2E tests driving the panel against partial bridges. --- CHANGELOG.md | 7 + README.md | 48 +++-- docs/ARCHITECTURE.md | 77 ++++++-- src/panel/components/componentTree.ts | 40 +++- src/panel/components/inspector.ts | 9 +- src/panel/components/shell.ts | 18 +- src/panel/components/statusBar.ts | 47 +++-- src/panel/components/timelineView.ts | 17 +- src/panel/features.ts | 180 +++++++++++++++++ src/panel/state.ts | 267 ++++++++++++++++++++------ src/protocol/client.ts | 60 +++++- src/protocol/messages.ts | 57 +++++- src/protocol/results.ts | 45 ++++- src/sass/panel.sass | 13 +- tests/e2e/fixture.ts | 14 +- tests/e2e/panel.spec.ts | 114 ++++++++++- tests/unit/panel.features.test.ts | 129 +++++++++++++ tests/unit/panel.state.test.ts | 155 ++++++++++++++- tests/unit/protocol.client.test.ts | 71 +++++++ tests/unit/protocol.messages.test.ts | 32 +++ tests/unit/protocol.results.test.ts | 53 ++++- 21 files changed, 1306 insertions(+), 147 deletions(-) create mode 100644 src/panel/features.ts create mode 100644 tests/unit/panel.features.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 785defb..30c80e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ It replaces the untyped reference scaffold that used to live in the framework's search, pause and clear. - Time travel: replay signal and store state as of any recorded event, reconstructed from the connect-time snapshot. +- Graceful degradation against partially implemented bQuery apps: capabilities + advertised in the handshake are a hint, and every section is graded on what + the page actually answers. Sections load independently, a capability the page + never advertised is probed once, a snapshot that omits a collection reads as + "not reported" rather than empty, a missing `getComponentTree` falls back to + the flat component registry, and a page speaking a newer protocol version is + named as incompatible instead of leaving the panel waiting. - Two transports behind one interface — a permission-free `inspectedWindow.eval` poller (default) and an opt-in push transport over an injected content script — so the extension ships with **no host permissions**. diff --git a/README.md b/README.md index dec30d0..f112f4e 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,11 @@ small content-script relay, and events arrive as they happen. The permission is per-site, requested on a click, and revocable from the browser's extension settings. Everything works without it — you just get polling instead of push. -| Permission | Why | -| ----------------------------- | ------------------------------------------------------- | -| `storage` | Panel preferences (buffer size, poll interval). No site access. | -| `scripting` | Injecting the relay when you opt into live streaming. | -| `optional_host_permissions` | Requested at runtime, one origin at a time. | +| Permission | Why | +| --------------------------- | --------------------------------------------------------------- | +| `storage` | Panel preferences (buffer size, poll interval). No site access. | +| `scripting` | Injecting the relay when you opt into live streaming. | +| `optional_host_permissions` | Requested at runtime, one origin at a time. | ## Security model @@ -89,7 +89,7 @@ The inspected page is treated as untrusted, because it is: - every value the panel displays is written through text sinks — the panel never assigns page-derived strings to `innerHTML`; - the panel's CSP forbids inline script and inline style; -- panel → page messages are embedded as JSON *data* in the evaluated +- panel → page messages are embedded as JSON _data_ in the evaluated expression, never spliced into its source; - the background router forwards a panel's messages only to the tab that panel attached to, and only when they carry the session token it issued. @@ -113,13 +113,13 @@ Publishing is documented in [docs/PUBLISHING.md](./docs/PUBLISHING.md). Every message carries `source: 'bquery-devtools'` and the protocol version `v`. -| Direction | `kind` | Purpose | -| ------------ | ---------- | -------------------------------------------- | -| panel → page | `hello` | Announce the panel; the page replies `init` | -| panel → page | `request` | `{ id, method, params }` | -| page → panel | `init` | `{ capabilities }` handshake | -| page → panel | `response` | `{ id, result \| error }` | -| page → panel | `event` | One streamed timeline `entry` | +| Direction | `kind` | Purpose | +| ------------ | ---------- | ------------------------------------------- | +| panel → page | `hello` | Announce the panel; the page replies `init` | +| panel → page | `request` | `{ id, method, params }` | +| page → panel | `init` | `{ capabilities }` handshake | +| page → panel | `response` | `{ id, result \| error }` | +| page → panel | `event` | One streamed timeline `entry` | **Methods:** `ping`, `getSnapshot`, `getTimeline` (`{ limit }`), `getComponentTree`. Apps can add their own through @@ -128,6 +128,28 @@ know about. **Capabilities:** `signals`, `stores`, `components`, `timeline`, `time-travel`. +## Partial bQuery apps + +The panel does not require a complete framework on the other end. Capabilities +advertised in the handshake are treated as a hint; each section is graded on +what the page actually answers, and they are fetched independently: + +- an app that loaded `reactive` but not `store` shows its signals, and the + stores view says the page does not report any — not "0 stores"; +- a bridge implementing only `getTimeline` still gets a working timeline; +- a bridge that advertises nothing is probed once, and lights up if it answers; +- with no `getComponentTree`, the components view falls back to the flat + registry the snapshot carries; +- a page speaking a newer protocol version is named as incompatible instead of + leaving the panel waiting. + +A section the page refuses is asked for exactly once per connection. **Refresh +all** re-probes everything, so enabling devtools or mounting your first +component and pressing it is enough — no need to reopen DevTools. + +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#partial-implementations) for the +full degradation model. + ## Credits Bootstrapped from diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 953f48c..cb42703 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,15 +37,15 @@ end. ## Layers -| Layer | Module | Responsibility | -| -------------- | ----------------------------- | ----------------------------------------------------- | -| Protocol | `src/protocol/messages.ts` | Message shapes, builders, and validation of page input | -| Protocol | `src/protocol/results.ts` | Validation of method *results* | -| Protocol | `src/protocol/client.ts` | Handshake, capabilities, request correlation, timeouts | -| Transport | `src/transports/*.ts` | Two ways to move bytes between panel and page | -| Routing | `src/background/router.ts` | Tab-scoped, token-checked routing for the port transport | -| State | `src/panel/*.ts` | Buffering, filtering, time travel, preferences | -| View | `src/panel/components/*.ts` | Custom elements rendering from panel state | +| Layer | Module | Responsibility | +| --------- | --------------------------- | -------------------------------------------------------- | +| Protocol | `src/protocol/messages.ts` | Message shapes, builders, and validation of page input | +| Protocol | `src/protocol/results.ts` | Validation of method _results_ | +| Protocol | `src/protocol/client.ts` | Handshake, capabilities, request correlation, timeouts | +| Transport | `src/transports/*.ts` | Two ways to move bytes between panel and page | +| Routing | `src/background/router.ts` | Tab-scoped, token-checked routing for the port transport | +| State | `src/panel/*.ts` | Buffering, filtering, time travel, preferences | +| View | `src/panel/components/*.ts` | Custom elements rendering from panel state | Each layer only knows the one below it. The views never talk to a transport; the client never touches the DOM; the protocol modules have no browser @@ -57,8 +57,7 @@ dependencies at all, which is why most of them are unit-testable without a DOM. `typeof import('@bquery/bquery/devtools')` type queries: ```ts -export const BRIDGE_PROTOCOL_VERSION: - typeof import('@bquery/bquery/devtools').BRIDGE_PROTOCOL_VERSION = 1; +export const BRIDGE_PROTOCOL_VERSION: typeof import('@bquery/bquery/devtools').BRIDGE_PROTOCOL_VERSION = 1; ``` This is a type-only reference, so the framework's page-side bridge runtime is @@ -77,7 +76,7 @@ expression that installs a `message` listener buffering page-channel bridge messages into an array, and returns (and clears) that array as JSON. The install is idempotent and re-runs on every poll, which makes the transport self-healing across navigations. Outbound messages are evaluated as -`window.postMessage(JSON.parse("…"), '*')` — the message is *data* inside the +`window.postMessage(JSON.parse("…"), '*')` — the message is _data_ inside the expression, never source. **`PortTransport` (opt-in).** A long-lived `chrome.runtime` port to the @@ -116,9 +115,59 @@ hold throughout: 3. **Bound everything.** Previews are truncated, child lists capped, the timeline is a ring buffer, and the in-page relay queue is bounded too. +## Partial implementations + +bQuery is modular, and its bridge is a public contract. An app may load +`reactive` without `store`, run devtools without ever mounting a component, or +hand-roll a bridge server that implements two of the four methods. The panel is +built so that none of that produces a blank window, a spinner that never stops, +or a claim the page never made. + +**Advertisement is a hint; evidence decides.** `createBridgeServer` advertises +the full capability list regardless of which modules the app actually loaded, +and a trimmed bridge may advertise nothing while answering everything. So the +`init` capability list is recorded but not obeyed: `panel/features.ts` tracks, +per feature, what the page has actually _proved_ it can serve. + +| Status | Meaning | Retried? | +| ------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `unknown` | Not attempted yet on this connection | yes — including a one-shot probe of capabilities the page never advertised | +| `available` | The page returned data the panel could parse | yes, on every refresh | +| `unsupported` | The page cannot serve it (no such method, or an unusable answer) | no — until the next handshake or an explicit **Refresh all** | +| `failed` | It should work but the last attempt did not | yes, on every refresh | + +Four rules follow from that model: + +1. **Sections fail independently.** `refreshAll` runs the three fetches + concurrently and none of them rejects; a page that implements `getTimeline` + but not `getSnapshot` still gets a timeline. (This was a real defect: the + fetches used to be chained through `Promise.all`, so one missing method took + the whole panel down with it.) +2. **Probe once, then stop asking.** A capability that was never advertised is + still tried once per connection — that is what lets a bridge advertising + nothing light up. A method the page refuses is not asked again until the + next handshake or an explicit refresh, so an absent feature costs exactly + one request. +3. **Absent is not empty.** A snapshot carrying `signals` but no `stores` key + leaves the stores view saying _the page does not report stores_, rather than + a confident and wrong "0 stores" — and does not wipe a component registry + that `getComponentTree` filled in. +4. **Degrade to what is there.** With no component tree but a snapshot that + lists components, the tree view shows that flat registry instead of an empty + panel. Time travel is gated on having a base snapshot and recorded events — + it is reconstructed by the panel, so it works whether or not the page claims + a `time-travel` capability. + +A page speaking a protocol version this panel does not know is a related case. +Its messages are still discarded — parsing a contract you do not understand is +how a validator becomes an attack surface — but the panel says so +("the page speaks bridge protocol v2…") instead of sitting in _waiting for the +page_ while the page answers every `hello`. The handshake keeps retrying, so +navigating to a compatible app recovers without reopening DevTools. + ## Time travel -The bridge exposes primitives, not history: `getSnapshot` is the state *now* +The bridge exposes primitives, not history: `getSnapshot` is the state _now_ and `event` messages stream what changed after. Time travel is reconstructed in the panel — the connect-time snapshot is the base, and `panel/timeTravel.ts` replays recorded events onto it up to a chosen index. @@ -126,7 +175,7 @@ replays recorded events onto it up to a chosen index. Event payloads are app-defined (`payload?: unknown`), so replay is deliberately tolerant: it recognizes `{ value }`, `{ next }`, `{ to }` and bare payloads for signals, and `{ patch }`, `{ state }`, `{ next }` or a plain object for stores. -When a payload cannot be interpreted, the value is reported as *not recorded* +When a payload cannot be interpreted, the value is reported as _not recorded_ and the previous value is kept — the panel never invents state, and the UI labels every row as `replayed`, `unchanged` or `not recorded`. diff --git a/src/panel/components/componentTree.ts b/src/panel/components/componentTree.ts index 29b6f14..a36ced3 100644 --- a/src/panel/components/componentTree.ts +++ b/src/panel/components/componentTree.ts @@ -9,6 +9,7 @@ */ import { extensionApi, hasExtensionApi } from '../../browser'; import { el, replaceChildren } from '../dom'; +import { emptyMessage } from '../features'; import { buildSelectExpression, flattenTree, nodeAtPath, parsePathKey, pathKey } from '../tree'; import { defineElement, PanelElement } from './base'; @@ -79,16 +80,45 @@ export class ComponentTreeView extends PanelElement { // Only push a value the user did not type themselves, so an in-progress // edit (and its caret) is never disturbed. if (searchInput.value !== search) searchInput.value = search; - this.countLabel.textContent = search ? `${flat.length} matching` : `${flat.length} components`; + + // A page whose bridge answers `getSnapshot` but not `getComponentTree` + // still knows which components are mounted — it just cannot say how they + // nest. Show that flat registry rather than an empty panel. + const needle = search.toLowerCase(); + const registry = + nodes.length === 0 + ? state.components.value.filter(item => item.tagName.toLowerCase().includes(needle)) + : []; + const usingRegistry = registry.length > 0; + const shown = usingRegistry ? registry.length : flat.length; + + this.countLabel.textContent = search ? `${shown} matching` : `${shown} components`; const rows: Node[] = []; - if (flat.length === 0) { + if (usingRegistry) { + rows.push( + el('p', { + class: 'muted', + text: 'No component tree from this page; showing the components it does report.', + }) + ); + for (const item of registry) { + rows.push( + el('div', { class: 'tree-row is-flat', attrs: { role: 'treeitem' } }, [ + el('span', { class: 'tree-tag', text: `<${item.tagName}>` }), + el('span', { class: 'tree-count', text: `${item.instanceCount}` }), + ]) + ); + } + } else if (flat.length === 0) { rows.push( el('p', { class: 'empty', - text: state.supports('components') - ? 'No custom elements found on the page.' - : 'The page does not advertise the "components" capability.', + text: emptyMessage( + state.feature('components'), + 'a component tree', + 'No custom elements found on the page.' + ), }) ); } diff --git a/src/panel/components/inspector.ts b/src/panel/components/inspector.ts index 84d5fd4..9522f7e 100644 --- a/src/panel/components/inspector.ts +++ b/src/panel/components/inspector.ts @@ -9,6 +9,7 @@ * @module panel/components/inspector */ import { el, replaceChildren } from '../dom'; +import { emptyMessage } from '../features'; import { defineElement, PanelElement } from './base'; // Registers , which the rows below instantiate. import './valueView'; @@ -83,9 +84,11 @@ export class InspectorView extends PanelElement { body.appendChild( el('p', { class: 'empty', - text: state.supports(capability) - ? `No ${this.kind} reported by the page.` - : `The page does not advertise the "${capability}" capability.`, + text: emptyMessage( + state.feature(capability), + this.kind, + `No ${this.kind} reported by the page.` + ), }) ); } diff --git a/src/panel/components/shell.ts b/src/panel/components/shell.ts index d9761aa..dd97f3c 100644 --- a/src/panel/components/shell.ts +++ b/src/panel/components/shell.ts @@ -1,9 +1,10 @@ /** * `` — the panel shell: status bar plus tabbed views. * - * Tabs whose capability the page did not advertise stay visible but are - * marked unsupported, so the user can tell "the app has no stores" apart from - * "this panel cannot show stores". + * Every tab stays visible and reachable. One whose section the page has + * *proved* it cannot serve is marked unsupported, so the user can tell "the + * app has no stores" apart from "this page cannot report stores" — a + * distinction that matters when only part of bQuery is loaded. * * @module panel/components/shell */ @@ -74,7 +75,6 @@ export class PanelShell extends PanelElement { protected render(): void { const state = this.state; - const capabilities = state.bridge.capabilities.value; const statusBar = document.createElement('bq-status-bar') as StatusBar; statusBar.onUpgrade = this.onUpgrade; @@ -84,7 +84,11 @@ export class PanelShell extends PanelElement { 'div', { class: 'tabs', attrs: { role: 'tablist' } }, TABS.map(tab => { - const supported = capabilities.size === 0 || capabilities.has(tab.capability); + // Only a proven-unavailable section is marked: a capability the page + // did not advertise may still answer, so it is not written off before + // it has been tried. + const feature = state.feature(tab.capability); + const supported = feature.status !== 'unsupported'; return el('button', { class: `tab${tab.id === this.activeTab ? ' is-active' : ''}${supported ? '' : ' is-unsupported'}`, text: tab.label, @@ -92,7 +96,9 @@ export class PanelShell extends PanelElement { type: 'button', role: 'tab', 'aria-selected': String(tab.id === this.activeTab), - ...(supported ? {} : { title: `The page does not advertise "${tab.capability}"` }), + ...(supported + ? {} + : { title: `This page cannot serve "${tab.capability}": ${feature.detail}` }), }, on: { click: () => { diff --git a/src/panel/components/statusBar.ts b/src/panel/components/statusBar.ts index 8e4d6b0..1445c51 100644 --- a/src/panel/components/statusBar.ts +++ b/src/panel/components/statusBar.ts @@ -8,7 +8,8 @@ * @module panel/components/statusBar */ import { el, replaceChildren } from '../dom'; -import { KNOWN_CAPABILITIES } from '../../protocol/messages'; +import { KNOWN_CAPABILITIES, unknownCapabilities } from '../../protocol/messages'; +import { featureTitle } from '../features'; import { defineElement, PanelElement } from './base'; /** Labels for each connection state. */ @@ -17,6 +18,7 @@ const STATE_LABEL: Record = { connecting: 'Connecting…', 'waiting-for-page': 'Waiting for the page', connected: 'Connected', + incompatible: 'Incompatible protocol', disconnected: 'Disconnected', error: 'Error', }; @@ -31,19 +33,24 @@ export class StatusBar extends PanelElement { protected render(): void { const state = this.state; const connection = state.bridge.state.value; - const capabilities = state.bridge.capabilities.value; const detail = state.bridge.detail.value; const error = state.lastError.value; - const badges = KNOWN_CAPABILITIES.map(capability => - el('span', { - class: `badge${capabilities.has(capability) ? ' is-on' : ' is-off'}`, + // Badges report what the page *did*, not what it claimed: a capability it + // advertised but cannot serve reads as off, and one it never advertised + // but answers anyway reads as on. + const badges = KNOWN_CAPABILITIES.map(capability => { + const feature = state.feature(capability); + const on = + capability === 'time-travel' ? state.canTimeTravel() : feature.status === 'available'; + return el('span', { + class: `badge${on ? ' is-on' : feature.status === 'failed' ? ' is-warn' : ' is-off'}`, text: capability, - title: capabilities.has(capability) - ? `The page supports "${capability}"` - : `The page did not advertise "${capability}"`, - }) - ); + title: featureTitle(capability, feature), + }); + }); + + const foreign = unknownCapabilities(state.bridge.advertised.value); const children: Node[] = [ el('span', { @@ -59,9 +66,22 @@ export class StatusBar extends PanelElement { : 'Events are polled through the DevTools evaluation channel (no host permission needed).', }), ...badges, - el('span', { class: 'spacer' }), ]; + if (foreign.length > 0) { + // The page offers something this build has no view for — the visible + // symptom of an extension older than the app it is inspecting. + children.push( + el('span', { + class: 'badge is-warn', + text: `+${foreign.length} unknown`, + title: `This page also advertises ${foreign.join(', ')}, which this version of the extension has no view for.`, + }) + ); + } + + children.push(el('span', { class: 'spacer' })); + if (!this.streaming && this.onUpgrade) { children.push( el('button', { @@ -88,7 +108,10 @@ export class StatusBar extends PanelElement { attrs: { type: 'button', ...(state.loading.value ? { disabled: 'true' } : {}) }, on: { click: () => { - void state.refreshAll(); + // An explicit refresh re-probes sections previously written off, + // so enabling devtools (or mounting a store) and pressing Refresh + // is enough to bring a section back without reopening the panel. + void state.refreshAll({ retry: true }); }, }, }) diff --git a/src/panel/components/timelineView.ts b/src/panel/components/timelineView.ts index 1aaae3c..6207562 100644 --- a/src/panel/components/timelineView.ts +++ b/src/panel/components/timelineView.ts @@ -9,6 +9,7 @@ */ import type { TimelineEntry } from '../../protocol/messages'; import { el, formatTime, replaceChildren } from '../dom'; +import { emptyMessage } from '../features'; import { collectTypes, filterEntries, MAX_BUFFER_SIZE, MIN_BUFFER_SIZE } from '../timeline'; import { defineElement, PanelElement } from './base'; // Registers , which the rows below instantiate. @@ -203,7 +204,7 @@ export class TimelineView extends PanelElement { const state = this.state; const chrome = this.chrome; if (!chrome) return; - const supported = state.supports('time-travel'); + const supported = state.canTimeTravel(); const disabled = total === 0 || !supported; const index = travelIndex ?? total - 1; const replay = state.reconstruction.value; @@ -216,7 +217,9 @@ export class TimelineView extends PanelElement { range.disabled = disabled; liveButton.disabled = travelIndex === null; scrubberStatus.textContent = !supported - ? 'The page does not advertise the "time-travel" capability.' + ? total === 0 + ? 'Nothing recorded yet to replay.' + : 'No snapshot to replay onto: this page reports neither signals nor stores.' : replay ? `@ ${formatTime(replay.timestamp)} · ${replay.appliedCount} applied${ replay.unresolvedCount > 0 ? ` · ${replay.unresolvedCount} not recorded` : '' @@ -241,9 +244,11 @@ export class TimelineView extends PanelElement { list.appendChild( el('p', { class: 'empty', - text: state.supports('timeline') - ? 'No events recorded yet. Interact with the page to see reactive activity.' - : 'The page does not advertise the "timeline" capability.', + text: emptyMessage( + state.feature('timeline'), + 'a timeline', + 'No events recorded yet. Interact with the page to see reactive activity.' + ), }) ); return list; @@ -306,7 +311,7 @@ export class TimelineView extends PanelElement { details.appendChild(value); value.setValue(entry.payload, 'payload'); } - if (bufferIndex >= 0 && state.supports('time-travel')) { + if (bufferIndex >= 0 && state.canTimeTravel()) { details.appendChild( el('button', { class: 'btn', diff --git a/src/panel/features.ts b/src/panel/features.ts new file mode 100644 index 0000000..bd52c55 --- /dev/null +++ b/src/panel/features.ts @@ -0,0 +1,180 @@ +/** + * Feature availability, decided by evidence rather than by advertisement. + * + * bQuery is modular: an app may load `reactive` without `store`, `component` + * without `router`, or the devtools bridge on its own. The framework's + * `createBridgeServer` nevertheless advertises the *full* capability list in + * its `init` handshake, and a trimmed or hand-rolled bridge may do the + * opposite — advertise nothing while answering every method. + * + * The advertised set is therefore treated as a hint. What the panel renders is + * decided by what the page actually answered: + * + * - `unknown` — not attempted yet on this connection. + * - `available` — the page returned data this panel could parse. + * - `unsupported` — the page cannot serve it at all (no such method, or an + * unusable result). Not retried until the next handshake or an explicit + * user refresh, so an absent feature costs one request per connection. + * - `failed` — it *should* work but the last attempt did not. Retried on the + * next refresh. + * + * Every feature is independent: one unsupported section never stops another + * from loading. + * + * @module panel/features + */ +import { BridgeMethodError, BridgeTimeoutError } from '../protocol/client'; +import { KNOWN_CAPABILITIES, type BridgeCapability } from '../protocol/messages'; + +/** A panel feature. One per advertised bridge capability. */ +export type FeatureName = BridgeCapability; + +/** How a feature came out the last time the panel tried to use it. */ +export type FeatureStatus = 'unknown' | 'available' | 'unsupported' | 'failed'; + +/** Availability of one feature on the current connection. */ +export interface FeatureState { + readonly status: FeatureStatus; + /** Why, in the page's own words where there are any. Empty when uneventful. */ + readonly detail: string; + /** Whether the page listed this capability in its `init` handshake. */ + readonly advertised: boolean; +} + +/** Availability of every feature. */ +export type FeatureMap = Readonly>; + +const UNKNOWN_METHOD = /unknown method/i; + +/** The initial map: nothing attempted, nothing advertised. */ +export const initialFeatures = (): FeatureMap => { + const out = {} as Record; + for (const name of KNOWN_CAPABILITIES) { + out[name] = { status: 'unknown', detail: '', advertised: false }; + } + return out; +}; + +/** Reset every feature for a fresh handshake, recording what was advertised. */ +export const featuresForHandshake = (advertised: ReadonlySet): FeatureMap => { + const out = {} as Record; + for (const name of KNOWN_CAPABILITIES) { + out[name] = { status: 'unknown', detail: '', advertised: advertised.has(name) }; + } + return out; +}; + +/** Replace one feature's state, leaving the others untouched. */ +export const withFeature = (map: FeatureMap, name: FeatureName, next: FeatureState): FeatureMap => { + const status = map[name]; + if (status.status === next.status && status.detail === next.detail) return map; + return { ...map, [name]: next }; +}; + +/** + * Whether the panel should issue a request for this feature. + * + * `unsupported` is the one status that stops the panel asking again: the page + * has already said it cannot serve this, and repeating the request every + * refresh would only buy a second timeout. An explicit user refresh clears it + * (see {@link retryFeature}). + */ +export const shouldAttempt = (state: FeatureState, force: boolean): boolean => + force || state.status !== 'unsupported'; + +/** Clear a permanent verdict so the next refresh probes again. */ +export const retryFeature = (state: FeatureState): FeatureState => + state.status === 'unsupported' ? { ...state, status: 'unknown', detail: '' } : state; + +/** Clear every permanent verdict. Used by the explicit "Refresh all" button. */ +export const retryAll = (map: FeatureMap): FeatureMap => { + const out = {} as Record; + for (const name of KNOWN_CAPABILITIES) out[name] = retryFeature(map[name]); + return out; +}; + +/** The page answered with data this panel could use. */ +export const featureAvailable = (state: FeatureState, detail = ''): FeatureState => ({ + ...state, + status: 'available', + detail, +}); + +/** The page cannot serve this feature at all. */ +export const featureUnsupported = (state: FeatureState, detail: string): FeatureState => ({ + ...state, + status: 'unsupported', + detail, +}); + +/** + * Classify a failed request. + * + * The distinction that matters is permanent versus transient, because it + * decides whether the panel asks again: + * + * - "Unknown method" is the bridge server's own answer for a method it does + * not implement — permanent, and the single most likely outcome against a + * partially implemented bridge. + * - A timeout on a feature the page never advertised is treated as permanent + * too: nothing suggests it exists, and re-probing would stall every refresh + * for the request timeout. A timeout on an *advertised* feature is + * transient — the page said it has it, so a slow answer deserves a retry. + * - Anything else (the page threw, devtools are disabled, a store registry is + * empty) is transient: the user can fix it and hit Refresh. + */ +export const classifyFailure = (state: FeatureState, error: unknown): FeatureState => { + if (error instanceof BridgeMethodError && UNKNOWN_METHOD.test(error.message)) { + return featureUnsupported(state, 'the page does not implement this bridge method'); + } + if (error instanceof BridgeTimeoutError) { + return state.advertised + ? { ...state, status: 'failed', detail: 'the page did not answer in time' } + : featureUnsupported(state, 'the page did not answer, and never advertised it'); + } + const detail = error instanceof Error ? error.message : String(error); + return { ...state, status: 'failed', detail: stripPrefix(detail) }; +}; + +/** The page answered, but with something this panel cannot read. */ +export const featureUnusable = (state: FeatureState): FeatureState => + featureUnsupported(state, 'the page answered with a result this panel cannot read'); + +/** Trim the client's own `bQuery DevTools: "method" failed:` framing. */ +const stripPrefix = (message: string): string => + message.replace(/^bQuery DevTools: (?:"[^"]*" failed: )?/, ''); + +/** Tooltip for a capability badge, in the same evidence-first terms. */ +export const featureTitle = (name: FeatureName, state: FeatureState): string => { + switch (state.status) { + case 'available': + return `The page serves "${name}".`; + case 'unsupported': + return `"${name}" is not available: ${state.detail}.`; + case 'failed': + return `The last attempt at "${name}" failed: ${state.detail}.`; + case 'unknown': + return state.advertised + ? `The page advertised "${name}"; the panel has not loaded it yet.` + : `The page did not advertise "${name}".`; + } +}; + +/** + * The sentence a view shows when it has nothing to display. + * + * `whenAvailable` covers the ordinary case — the feature works, the app simply + * has none of whatever it lists. + */ +export const emptyMessage = (state: FeatureState, label: string, whenAvailable: string): string => { + switch (state.status) { + case 'available': + return whenAvailable; + case 'unsupported': + return `This page does not provide ${label}: ${state.detail}.`; + case 'failed': + return `Could not load ${label}: ${state.detail}.`; + case 'unknown': + return state.advertised ? `Loading ${label}…` : `This page has not reported ${label} yet.`; + } +}; diff --git a/src/panel/state.ts b/src/panel/state.ts index 0964f52..8cae9cb 100644 --- a/src/panel/state.ts +++ b/src/panel/state.ts @@ -9,18 +9,39 @@ */ import { computed, signal, type Signal } from '@bquery/bquery/reactive'; import type { BridgeClient } from '../protocol/client'; -import type { BridgeCapability, ComponentTreeNode, TimelineEntry } from '../protocol/messages'; +import { + KNOWN_CAPABILITIES, + type ComponentTreeNode, + type TimelineEntry, +} from '../protocol/messages'; import { parseComponentTree, parseSnapshot, - parseTimeline, + parseTimelineResult, type ComponentView, type SignalView, type StoreView, } from '../protocol/results'; +import { + classifyFailure, + featureAvailable, + featuresForHandshake, + featureUnsupported, + featureUnusable, + initialFeatures, + retryAll, + shouldAttempt, + withFeature, + type FeatureMap, + type FeatureName, + type FeatureState, +} from './features'; import { TimelineBuffer, type TimelineFilterState } from './timeline'; import { reconstructAt, type Reconstruction, type TimeTravelBase } from './timeTravel'; +/** Outcome of one section fetch: usable data, or an answer the panel cannot read. */ +type AttemptOutcome = 'ok' | 'unusable'; + /** How many entries the initial `getTimeline` request asks for. */ const TIMELINE_SEED_LIMIT = 200; @@ -63,8 +84,15 @@ export class PanelState { public readonly timeTravelIndex: Signal = signal(null); /** Last error surfaced to the user. */ public readonly lastError: Signal = signal(''); - /** `true` while a refresh request is in flight. */ + /** `true` while at least one section fetch is in flight. */ public readonly loading: Signal = signal(false); + /** + * What the page has actually proved it can serve. + * + * Kept apart from {@link BridgeClient.capabilities}, which is only what the + * page *claimed* in its handshake. See `panel/features`. + */ + public readonly features: Signal = signal(initialFeatures()); /** The reconstruction for {@link timeTravelIndex}, or `null` when live. */ public readonly reconstruction = computed(() => { @@ -83,6 +111,10 @@ export class PanelState { stores: [], capturedAt: Date.now(), }); + /** `true` once a snapshot the panel could read has established a replay base. */ + private baseCaptured = false; + /** Section fetches in flight, so `loading` reflects all of them, not the last. */ + private inflight = 0; private disposers: Array<() => void> = []; constructor(client: BridgeClient, buffer: TimelineBuffer) { @@ -110,15 +142,31 @@ export class PanelState { return this.buffer.capacity; } - /** `true` when the page advertised `capability`. */ - public supports(capability: BridgeCapability): boolean { - return this.client.capabilities.value.has(capability); + /** Availability of one feature on the current connection. */ + public feature(name: FeatureName): FeatureState { + return this.features.value[name]; + } + + /** + * Whether the scrubber can reconstruct anything. + * + * Time travel is performed *by the panel*, by replaying recorded events onto + * the connect-time snapshot — the page is never asked to do anything. So it + * is gated on having the two ingredients, not on the page advertising a + * `time-travel` capability: a partial bridge that answers `getSnapshot` and + * streams events supports it whether or not it says so. + */ + public canTimeTravel(): boolean { + return this.baseCaptured && this.buffer.size > 0; } /** Start listening to the bridge; refetches on every (re)connect. */ public start(): void { this.disposers.push( - this.client.onReady(() => { + this.client.onReady(capabilities => { + // A new handshake is a new page: nothing it proved before still holds. + this.features.value = featuresForHandshake(capabilities); + this.baseCaptured = false; void this.refreshAll(); }) ); @@ -138,42 +186,72 @@ export class PanelState { this.disposers = []; } - /** Refetch tree, snapshot and timeline seed. */ - public async refreshAll(): Promise { - this.loading.value = true; - try { - await Promise.all([this.refreshTree(), this.refreshSnapshot()]); - await this.seedTimeline(); - this.lastError.value = ''; - } catch (error) { - this.lastError.value = error instanceof Error ? error.message : String(error); - } finally { - this.loading.value = false; - } + /** + * Refetch every section. + * + * The three fetches are independent and are *not* chained: a page that + * implements `getTimeline` but not `getSnapshot` must still get a timeline. + * Nothing here rejects — each section records its own verdict — so one + * missing bridge method can never take the rest of the panel down with it. + * + * @param options - `retry` clears the "this page cannot serve it" verdicts + * first, so an explicit user refresh probes everything again. + */ + public async refreshAll(options: { retry?: boolean } = {}): Promise { + const force = options.retry === true; + if (force) this.features.value = retryAll(this.features.value); + await Promise.all([ + this.refreshTree(force), + this.refreshSnapshot(force), + this.seedTimeline(force), + ]); + this.lastError.value = this.summarizeFailures(); } /** Refetch the component tree. */ - public async refreshTree(): Promise { - if (!this.supports('components')) return; - const result = parseComponentTree(await this.client.request('getComponentTree')); - this.tree.value = result.tree; - // Assigned even when empty: a page that unmounted every component must - // clear the registry, not keep showing the previous counts. - this.components.value = result.flat; - } - - /** Refetch signals, stores and components, and re-base time travel. */ - public async refreshSnapshot(): Promise { - const snapshot = parseSnapshot(await this.client.request('getSnapshot')); - if (!snapshot) return; - this.signals.value = snapshot.signals; - this.stores.value = snapshot.stores; - this.components.value = snapshot.components; - this.base.value = { - signals: snapshot.signals, - stores: snapshot.stores, - capturedAt: snapshot.exportedAt, - }; + public async refreshTree(force = false): Promise { + await this.attempt(['components'], force, async () => { + const result = parseComponentTree(await this.client.request('getComponentTree')); + if (!result) return 'unusable'; + this.tree.value = result.tree; + // Assigned even when empty: a page that unmounted every component must + // clear the registry, not keep showing the previous counts. + this.components.value = result.flat; + return 'ok'; + }); + } + + /** + * Refetch signals, stores and components, and re-base time travel. + * + * One request backs two features, and they are graded separately: a snapshot + * that carries `signals` but omits `stores` — exactly what an app that never + * loaded the store module produces — leaves the signals view working and + * tells the stores view the page does not report any, rather than showing a + * confident and wrong "0 stores". + */ + public async refreshSnapshot(force = false): Promise { + await this.attempt(['signals', 'stores'], force, async () => { + const snapshot = parseSnapshot(await this.client.request('getSnapshot')); + if (!snapshot) return 'unusable'; + + if (snapshot.reported.signals) this.signals.value = snapshot.signals; + if (snapshot.reported.stores) this.stores.value = snapshot.stores; + // Only overwrite the registry when the snapshot actually carried one; + // otherwise a snapshot-only page would wipe what `getComponentTree` found. + if (snapshot.reported.components) this.components.value = snapshot.components; + + this.base.value = { + signals: snapshot.signals, + stores: snapshot.stores, + capturedAt: snapshot.exportedAt, + }; + this.baseCaptured = snapshot.reported.signals || snapshot.reported.stores; + + this.gradeSnapshotSection('signals', snapshot.reported.signals); + this.gradeSnapshotSection('stores', snapshot.reported.stores); + return 'ok'; + }); } /** @@ -186,19 +264,22 @@ export class PanelState { * rather than overwritten — a page that emits during the handshake would * otherwise lose exactly the events the user was waiting for. */ - public async seedTimeline(): Promise { - if (!this.supports('timeline')) return; - const entries = parseTimeline( - await this.client.request('getTimeline', { limit: TIMELINE_SEED_LIMIT }) - ); - // Read the buffer *after* awaiting, not before: events that arrive while - // the request is in flight are in it by now, and a snapshot taken earlier - // would silently drop exactly those. - const streamed = [...this.buffer.all()]; - const seeded = new Set(entries.map(entryKey)); - this.buffer.reset(entries); - this.buffer.extend(streamed.filter(entry => !seeded.has(entryKey(entry)))); - this.timelineRevision.value += 1; + public async seedTimeline(force = false): Promise { + await this.attempt(['timeline'], force, async () => { + const entries = parseTimelineResult( + await this.client.request('getTimeline', { limit: TIMELINE_SEED_LIMIT }) + ); + if (!entries) return 'unusable'; + // Read the buffer *after* awaiting, not before: events that arrive while + // the request is in flight are in it by now, and a snapshot taken earlier + // would silently drop exactly those. + const streamed = [...this.buffer.all()]; + const seeded = new Set(entries.map(entryKey)); + this.buffer.reset(entries); + this.buffer.extend(streamed.filter(entry => !seeded.has(entryKey(entry)))); + this.timelineRevision.value += 1; + return 'ok'; + }); } /** Drop every buffered entry and leave time travel. */ @@ -226,12 +307,86 @@ export class PanelState { this.timeTravelIndex.value = clamped; } + /** + * Run one section fetch and record what it proved. + * + * `names` are the features this request backs — more than one when a single + * method feeds several views. Every outcome, success or failure, is recorded + * against each of them; nothing propagates out, because a caller that has to + * catch is a caller that can forget to. + */ + private async attempt( + names: readonly FeatureName[], + force: boolean, + run: () => Promise + ): Promise { + const wanted = names.filter(name => shouldAttempt(this.features.value[name], force)); + if (wanted.length === 0) return; + + this.inflight += 1; + this.loading.value = true; + const before = this.features.value; + try { + const outcome = await run(); + for (const name of wanted) { + // A run that graded a feature itself — a snapshot that parsed but + // carried no stores, say — knows more than "the request succeeded", + // so its verdict stands. + if (this.features.value[name] !== before[name]) continue; + this.updateFeature(name, current => + outcome === 'ok' ? featureAvailable(current) : featureUnusable(current) + ); + } + } catch (error) { + for (const name of wanted) { + this.updateFeature(name, current => classifyFailure(current, error)); + } + } finally { + this.inflight -= 1; + if (this.inflight === 0) this.loading.value = false; + } + } + + /** + * Grade one collection of a snapshot that parsed but may not carry it. + * + * Graded in both directions, so the verdict follows the page: a section + * written off when the app had not loaded that module comes back by itself + * once a later snapshot carries it. + */ + private gradeSnapshotSection(name: FeatureName, reported: boolean): void { + this.updateFeature(name, current => + reported + ? featureAvailable(current) + : featureUnsupported(current, "the page's snapshot does not include them") + ); + } + + private updateFeature(name: FeatureName, next: (current: FeatureState) => FeatureState): void { + const map = this.features.value; + this.features.value = withFeature(map, name, next(map[name])); + } + + /** + * One line for the status bar covering sections that *should* work and did + * not. Features the page simply cannot serve are not errors — the views say + * so themselves — so they are deliberately left out. + */ + private summarizeFailures(): string { + const map = this.features.value; + const failed = KNOWN_CAPABILITIES.filter(name => map[name].status === 'failed'); + const first = failed[0]; + if (first === undefined) return ''; + const others = failed.length > 1 ? ` (and ${failed.length - 1} more)` : ''; + return `Could not load ${failed.join(', ')}: ${map[first].detail}${others}`; + } + /** Leave time travel and resume following live state. */ public resumeLive(): void { this.timeTravelIndex.value = null; this.paused.value = false; - void this.refreshSnapshot().catch(() => { - // A failed refresh leaves the last known values on screen. - }); + // `refreshSnapshot` records its own verdict and never rejects; a failure + // leaves the last known values on screen and says so in the status bar. + void this.refreshSnapshot(); } } diff --git a/src/protocol/client.ts b/src/protocol/client.ts index 54a41ec..8cdb0c9 100644 --- a/src/protocol/client.ts +++ b/src/protocol/client.ts @@ -14,6 +14,8 @@ */ import { signal, type Signal } from '@bquery/bquery/reactive'; import { + BRIDGE_PROTOCOL_VERSION, + foreignProtocolVersion, helloMessage, negotiateCapabilities, parseOutbound, @@ -24,9 +26,22 @@ import { } from './messages'; import type { BridgeTransport, TransportStatus } from './transport'; -/** Connection state as displayed by the panel. */ +/** + * Connection state as displayed by the panel. + * + * `incompatible` is its own state rather than an error: the page *is* + * answering, it simply speaks a bridge protocol this panel does not. The + * distinction is what the status bar needs to tell the user to update the + * extension instead of debugging their app. + */ export type ConnectionState = - 'idle' | 'connecting' | 'waiting-for-page' | 'connected' | 'disconnected' | 'error'; + | 'idle' + | 'connecting' + | 'waiting-for-page' + | 'connected' + | 'incompatible' + | 'disconnected' + | 'error'; /** Options for {@link BridgeClient}. */ export interface BridgeClientOptions { @@ -78,6 +93,15 @@ export class BridgeClient { public readonly capabilities: Signal> = signal< ReadonlySet >(new Set()); + /** + * Every capability string the page advertised, including ones this panel has + * no view for (reactive). + * + * {@link capabilities} is the negotiated subset the panel can act on; this is + * the raw list, so the UI can point out that the page offers more than this + * version of the extension understands. + */ + public readonly advertised: Signal = signal([]); /** Human-readable detail for the current state (reactive). */ public readonly detail: Signal = signal(''); @@ -103,6 +127,8 @@ export class BridgeClient { * while the status bar claims the panel is connected. */ private handshakeComplete = false; + /** Foreign protocol version already reported, so it is said once, not per message. */ + private reportedForeignVersion: number | null = null; private disposed = false; private started = false; @@ -156,8 +182,10 @@ export class BridgeClient { public resetHandshake(reason = 'page navigated'): void { if (this.disposed) return; this.handshakeComplete = false; + this.reportedForeignVersion = null; this.rejectAllPending(new Error(`bQuery DevTools: ${reason}`)); this.capabilities.value = new Set(); + this.advertised.value = []; this.state.value = 'waiting-for-page'; this.detail.value = reason; this.scheduleHello(true); @@ -214,6 +242,7 @@ export class BridgeClient { this.cancelHello(); this.rejectAllPending(new Error(`bQuery DevTools: ${status.reason}`)); this.capabilities.value = new Set(); + this.advertised.value = []; this.state.value = 'disconnected'; this.detail.value = status.reason; return; @@ -230,13 +259,17 @@ export class BridgeClient { private handleMessage(data: unknown): void { if (this.disposed) return; const message = parseOutbound(data); - if (!message) return; + if (!message) { + this.reportIfIncompatible(data); + return; + } switch (message.kind) { case 'init': { const negotiated = negotiateCapabilities(message.capabilities); this.handshakeComplete = true; this.cancelHello(); + this.advertised.value = message.capabilities; this.capabilities.value = negotiated; this.state.value = 'connected'; this.detail.value = ''; @@ -266,6 +299,27 @@ export class BridgeClient { } } + /** + * Surface a page that answers in a protocol version this panel cannot read. + * + * The message is still discarded — parsing a contract you do not understand + * is how a validator becomes an attack surface — but the panel says so once + * per distinct version instead of sitting in "waiting for the page" while + * the page answers every `hello`. + * + * The handshake is deliberately *not* completed: `hello` keeps retrying, so + * navigating to a compatible app recovers without reopening DevTools. + */ + private reportIfIncompatible(data: unknown): void { + const version = foreignProtocolVersion(data); + if (version === null || version === this.reportedForeignVersion) return; + this.reportedForeignVersion = version; + this.state.value = 'incompatible'; + this.detail.value = + `The page speaks bridge protocol v${version}; this panel speaks ` + + `v${BRIDGE_PROTOCOL_VERSION}. Update the extension (or the app) so the two match.`; + } + private scheduleHello(immediate: boolean): void { this.cancelHello(); const fire = (): void => { diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index d8eb296..23893ad 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -34,14 +34,24 @@ export const BRIDGE_SOURCE = 'bquery-devtools' as const; export type BridgeCapability = (typeof import('@bquery/bquery/devtools').BRIDGE_CAPABILITIES)[number]; -/** Every capability this panel knows how to make use of. */ -export const KNOWN_CAPABILITIES: readonly BridgeCapability[] = [ - 'signals', - 'stores', - 'components', - 'timeline', - 'time-travel', -]; +/** + * Every capability this panel knows how to make use of. + * + * Declared as a total `Record` over the published capability union rather than + * a plain array: adding a capability upstream then fails to compile here, + * instead of silently producing a panel with a feature it never renders and a + * feature map missing an entry. Insertion order is the display order. + */ +const CAPABILITY_VIEWS: Readonly> = { + signals: true, + stores: true, + components: true, + timeline: true, + 'time-travel': true, +}; + +/** Every capability this panel knows how to make use of, in display order. */ +export const KNOWN_CAPABILITIES = Object.keys(CAPABILITY_VIEWS) as readonly BridgeCapability[]; /** Built-in bridge methods (see `createBridgeServer`). */ export type BridgeMethodName = 'ping' | 'getSnapshot' | 'getTimeline' | 'getComponentTree'; @@ -201,6 +211,37 @@ export const parseOutbound = (data: unknown): OutboundMessage | null => { } }; +/** + * The protocol version of a bridge message this panel cannot speak. + * + * `parseOutbound` drops such messages, which is the safe thing to do with a + * contract you do not understand — but dropping them silently leaves the panel + * waiting forever on a page that is, in fact, answering. This lets the client + * say so instead. + * + * Returns `null` for anything that is not a page-side bridge message, and for + * messages this panel *can* speak. + */ +export const foreignProtocolVersion = (data: unknown): number | null => { + if (!isRecord(data)) return null; + if (data['source'] !== BRIDGE_SOURCE || data['channel'] !== 'page') return null; + const version = data['v']; + if (typeof version !== 'number' || version === BRIDGE_PROTOCOL_VERSION) return null; + return version; +}; + +/** + * Capabilities the page advertised that this panel has no view for. + * + * Not an error — a newer framework may advertise more than this panel knows — + * but worth surfacing, because it is the visible symptom of an extension that + * has fallen behind the app it is inspecting. + */ +export const unknownCapabilities = (advertised: readonly string[]): string[] => { + const known = new Set(KNOWN_CAPABILITIES); + return advertised.filter(capability => !known.has(capability)); +}; + /** Keep only the capabilities this panel actually implements a view for. */ export const negotiateCapabilities = (advertised: readonly string[]): Set => { const known = new Set(KNOWN_CAPABILITIES); diff --git a/src/protocol/results.ts b/src/protocol/results.ts index 0df61f3..abb621e 100644 --- a/src/protocol/results.ts +++ b/src/protocol/results.ts @@ -29,6 +29,20 @@ export interface ComponentView { readonly instanceCount: number; } +/** + * Which top-level collections a snapshot actually carried. + * + * An app that loads `reactive` but not `store` produces a snapshot with no + * usable `stores` array. "Absent" and "empty" mean different things to a + * reader — *the page does not report stores* versus *the page has no stores* — + * so the two are kept apart here instead of both collapsing to `[]`. + */ +export interface SnapshotPresence { + readonly signals: boolean; + readonly stores: boolean; + readonly components: boolean; +} + /** Normalized `getSnapshot` result. */ export interface SnapshotView { readonly signals: readonly SignalView[]; @@ -36,6 +50,8 @@ export interface SnapshotView { readonly components: readonly ComponentView[]; readonly timeline: readonly TimelineEntry[]; readonly exportedAt: number; + /** Which collections the page reported at all. */ + readonly reported: SnapshotPresence; } /** Normalized `getComponentTree` result. */ @@ -106,9 +122,19 @@ export const parseEntry = (value: unknown): TimelineEntry | null => { return entry as unknown as TimelineEntry; }; -/** Narrow a `getTimeline` result. */ +/** Narrow a timeline array wherever one is embedded (e.g. inside a snapshot). */ export const parseTimeline = (value: unknown): TimelineEntry[] => parseArray(value, parseEntry); +/** + * Narrow a `getTimeline` *result*. + * + * `null` when the page answered with something that is not a list at all — + * which the caller reads as "this page cannot serve a timeline", as opposed to + * an empty list, which means "nothing has happened yet". + */ +export const parseTimelineResult = (value: unknown): TimelineEntry[] | null => + Array.isArray(value) ? parseArray(value, parseEntry) : null; + /** * Narrow a `DevtoolsSnapshot` as produced by `exportDevtoolsSnapshot()`. * @@ -126,6 +152,11 @@ export const parseSnapshot = (value: unknown): SnapshotView | null => { components: parseArray(value['components'], parseComponent), timeline: parseTimeline(state['timeline']), exportedAt: toNumber(value['exportedAt'], Date.now()), + reported: { + signals: Array.isArray(value['signals']), + stores: Array.isArray(value['stores']), + components: Array.isArray(value['components']), + }, }; }; @@ -155,9 +186,15 @@ const parseTreeNode = (value: unknown, depth = 0): ComponentTreeNode | null => { return { tag, id: toStringValue(value['id']), attrs, children }; }; -/** Narrow a `getComponentTree` result. */ -export const parseComponentTree = (value: unknown): ComponentTreeView => { - if (!isRecord(value)) return { tree: [], flat: [] }; +/** + * Narrow a `getComponentTree` result; `null` when it is not a result at all. + * + * A page that answers `undefined` (or a string, or a number) has not given the + * panel a tree — reporting that as an empty tree would claim, wrongly, that + * the page has no components. + */ +export const parseComponentTree = (value: unknown): ComponentTreeView | null => { + if (!isRecord(value) || Array.isArray(value)) return null; const tree: ComponentTreeNode[] = []; if (Array.isArray(value['tree'])) { for (const node of value['tree']) { diff --git a/src/sass/panel.sass b/src/sass/panel.sass index 7be47c7..e398268 100644 --- a/src/sass/panel.sass +++ b/src/sass/panel.sass @@ -51,7 +51,7 @@ bq-status-bar color: var(--ok) &.status-error, &.status-disconnected color: var(--error) - &.status-connecting, &.status-waiting-for-page + &.status-connecting, &.status-waiting-for-page, &.status-incompatible color: var(--warn) .status-message @@ -74,6 +74,11 @@ bq-status-bar background: var(--accent-soft) &.is-off opacity: 0.55 + // A section that should work and did not — distinct from one the page + // simply does not have. + &.is-warn + color: var(--warn) + border-color: var(--warn) // --- controls -------------------------------------------------------------- @@ -204,6 +209,12 @@ input[type='search'], input[type='number'] background: var(--accent-soft) &.is-match .tree-tag text-decoration: underline + // The flat fallback is a listing, not a target: nothing to reveal in the + // Elements panel, so it must not look clickable. + &.is-flat + cursor: default + &:hover + background: transparent .tree-tag @include monospace diff --git a/tests/e2e/fixture.ts b/tests/e2e/fixture.ts index a7aeb7c..f2d217b 100644 --- a/tests/e2e/fixture.ts +++ b/tests/e2e/fixture.ts @@ -73,6 +73,14 @@ interface FixtureData { tree: unknown; timeline: unknown; capabilities: readonly string[]; + /** + * Bridge methods this page implements. Anything outside the list answers + * `Unknown method`, exactly as `createBridgeServer` does — which is how a + * partially implemented bridge behaves. Defaults to all of them. + */ + methods?: readonly string[]; + /** Protocol version the page speaks. Defaults to the v1 the panel expects. */ + version?: number; } /** @@ -137,9 +145,12 @@ export const installFixture = (data: FixtureData): void => { // --- page-side bridge server (protocol v1) -------------------------------- const post = (message: Record): void => { - window.postMessage({ source: SOURCE, channel: 'page', v: 1, ...message }, '*'); + window.postMessage({ source: SOURCE, channel: 'page', v: data.version ?? 1, ...message }, '*'); }; + /** `undefined` means "every built-in", matching a complete bridge server. */ + const implemented = (name: string): boolean => !data.methods || data.methods.indexOf(name) >= 0; + /** * Answer one bridge method. * @@ -152,6 +163,7 @@ export const installFixture = (data: FixtureData): void => { * in for. */ const answer = (name: string): { known: true; result: unknown } | { known: false } => { + if (!implemented(name)) return { known: false }; switch (name) { case 'ping': return { known: true, result: { v: 1, ok: true } }; diff --git a/tests/e2e/panel.spec.ts b/tests/e2e/panel.spec.ts index 5049250..5339dd5 100644 --- a/tests/e2e/panel.spec.ts +++ b/tests/e2e/panel.spec.ts @@ -7,14 +7,34 @@ import { installFixture, } from './fixture'; -const openPanel = async (page: Page): Promise => { +/** + * Serve the panel against a page whose bridge is described by `overrides`. + * + * The default is a complete bQuery bridge. Passing `methods` or `capabilities` + * models the partial ones — an app that loaded only part of the framework, or + * a hand-rolled bridge that implements a subset. + */ +const openPanelAgainst = async ( + page: Page, + overrides: { + methods?: readonly string[]; + capabilities?: readonly string[]; + version?: number; + } = {} +): Promise => { await page.addInitScript(installFixture, { snapshot: FIXTURE_SNAPSHOT, tree: FIXTURE_TREE, timeline: FIXTURE_TIMELINE, - capabilities: FIXTURE_CAPABILITIES, + capabilities: overrides.capabilities ?? FIXTURE_CAPABILITIES, + ...(overrides.methods ? { methods: overrides.methods } : {}), + ...(overrides.version !== undefined ? { version: overrides.version } : {}), }); await page.goto('/panel.html'); +}; + +const openPanel = async (page: Page): Promise => { + await openPanelAgainst(page); await expect(page.locator('.status')).toHaveText('Connected'); }; @@ -300,3 +320,93 @@ test.describe('bQuery DevTools panel', () => { await expect(page.getByRole('tab', { name: 'Components' })).toBeVisible(); }); }); + +/** + * bQuery is modular, and its bridge is a contract anyone can implement. These + * cover the pages that only implement part of it: nothing here may leave the + * panel blank, stuck, or claiming something the page never said. + */ +test.describe('partially implemented bridges', () => { + test('a bridge with only getTimeline still shows a timeline', async ({ page }) => { + // Advertises nothing at all, so the panel has to find out by asking. + await openPanelAgainst(page, { methods: ['getTimeline'], capabilities: [] }); + await expect(page.locator('.status')).toHaveText('Connected'); + + await openTab(page, 'Timeline'); + await expect(page.locator('.timeline-row')).toHaveCount(FIXTURE_TIMELINE.length); + + // …and the sections it cannot serve say exactly that, rather than + // pretending the app has no signals. + await openTab(page, 'Signals'); + await expect(page.locator('.empty')).toContainText('does not provide signals'); + await expect(page.locator('.empty')).toContainText('does not implement this bridge method'); + }); + + test('a snapshot-only bridge falls back to the flat component registry', async ({ page }) => { + await openPanelAgainst(page, { methods: ['getSnapshot', 'getTimeline'] }); + await expect(page.locator('.status')).toHaveText('Connected'); + + await openTab(page, 'Components'); + // No tree to nest, but the snapshot knows which components are mounted. + await expect(page.locator('.muted', { hasText: 'No component tree' })).toBeVisible(); + await expect(page.locator('.tree-row.is-flat')).toHaveCount(FIXTURE_SNAPSHOT.components.length); + await expect(page.locator('.tree-tag').first()).toHaveText(''); + + // The filter works on the fallback too, and the count agrees with it. + await page.getByLabel('Filter components').fill('item'); + await expect(page.locator('.tree-row.is-flat')).toHaveCount(1); + await expect(page.locator('.view-toolbar .muted')).toHaveText('1 matching'); + + // The rest of the panel is unaffected by the missing method. + await openTab(page, 'Signals'); + await expect(page.locator('.inspector-row')).toHaveCount(FIXTURE_SNAPSHOT.signals.length); + }); + + test('a section the page refused is re-probed only when the user asks', async ({ page }) => { + await openPanelAgainst(page, { methods: ['getSnapshot'] }); + await expect(page.locator('.status')).toHaveText('Connected'); + await expect(page.locator('.badge.is-on', { hasText: 'signals' })).toBeVisible(); + + const asked = (): Promise => + page.evaluate(() => (window as unknown as { __asked: number }).__asked); + await page.evaluate(() => { + const scope = window as unknown as { __asked: number }; + scope.__asked = 0; + window.addEventListener('message', event => { + const data = event.data as Record | null; + if (data && data['kind'] === 'request' && data['method'] === 'getComponentTree') { + scope.__asked += 1; + } + }); + }); + + // Nothing is asked while the panel simply sits there: the page already + // refused this method, and the verdict holds until someone overrides it. + await page.waitForTimeout(600); + expect(await asked()).toBe(0); + + // "Refresh all" is that override — and it re-probes exactly once, so a + // user who just mounted their first component gets it back. Polled: the + // click only schedules the request, and the signals badge was already lit + // before it, so nothing else here waits for the round trip. + await page.getByRole('button', { name: 'Refresh all' }).click(); + await expect.poll(asked).toBe(1); + await page.waitForTimeout(400); + expect(await asked()).toBe(1); + }); + + test('a page speaking a newer protocol is named, not waited on', async ({ page }) => { + await openPanelAgainst(page, { version: 2 }); + await expect(page.locator('.status')).toHaveText('Incompatible protocol'); + await expect(page.locator('.status-message')).toContainText('protocol v2'); + await expect(page.locator('.status-message')).toContainText('Update the extension'); + }); + + test('a page advertising capabilities this build has no view for says so', async ({ page }) => { + await openPanelAgainst(page, { + capabilities: [...FIXTURE_CAPABILITIES, 'router', 'hydration'], + }); + await expect(page.locator('.status')).toHaveText('Connected'); + await expect(page.locator('.badge', { hasText: '+2 unknown' })).toBeVisible(); + }); +}); diff --git a/tests/unit/panel.features.test.ts b/tests/unit/panel.features.test.ts new file mode 100644 index 0000000..67c9036 --- /dev/null +++ b/tests/unit/panel.features.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'bun:test'; +import { + classifyFailure, + emptyMessage, + featureAvailable, + featuresForHandshake, + featureTitle, + featureUnsupported, + initialFeatures, + retryAll, + shouldAttempt, + withFeature, + type FeatureState, +} from '../../src/panel/features'; +import { BridgeMethodError, BridgeTimeoutError } from '../../src/protocol/client'; + +const state = (overrides: Partial = {}): FeatureState => ({ + status: 'unknown', + detail: '', + advertised: false, + ...overrides, +}); + +describe('handshake', () => { + test('starts with nothing attempted and nothing advertised', () => { + const features = initialFeatures(); + expect(features.signals.status).toBe('unknown'); + expect(features.signals.advertised).toBe(false); + }); + + test('records what the page advertised without asserting it works', () => { + const features = featuresForHandshake(new Set(['signals', 'timeline'] as const)); + expect(features.signals.advertised).toBe(true); + expect(features.stores.advertised).toBe(false); + // Advertised is not the same as proven. + expect(features.signals.status).toBe('unknown'); + }); + + test('replacing one feature leaves the others untouched', () => { + const before = initialFeatures(); + const after = withFeature(before, 'stores', featureAvailable(before.stores)); + expect(after.stores.status).toBe('available'); + expect(after.signals).toBe(before.signals); + }); +}); + +describe('classifyFailure', () => { + test('treats an unimplemented method as permanent', () => { + const next = classifyFailure( + state({ advertised: true }), + new BridgeMethodError('getSnapshot', 'Unknown method: getSnapshot') + ); + expect(next.status).toBe('unsupported'); + }); + + test('a timeout on an advertised feature is transient', () => { + const next = classifyFailure(state({ advertised: true }), new BridgeTimeoutError('x', 5000)); + expect(next.status).toBe('failed'); + }); + + test('a timeout on a feature nobody advertised is permanent', () => { + // Nothing suggests the page has it, so re-probing would only stall every + // future refresh for the request timeout. + const next = classifyFailure(state(), new BridgeTimeoutError('x', 5000)); + expect(next.status).toBe('unsupported'); + }); + + test('an application error is transient and keeps the page’s wording', () => { + const next = classifyFailure( + state({ advertised: true }), + new BridgeMethodError('getSnapshot', 'devtools are disabled') + ); + expect(next.status).toBe('failed'); + // The client's own framing is stripped; the page's sentence survives. + expect(next.detail).toBe('devtools are disabled'); + }); + + test('a non-Error rejection still classifies', () => { + expect(classifyFailure(state(), 'boom').status).toBe('failed'); + }); +}); + +describe('retry policy', () => { + test('only an unsupported verdict stops the panel asking again', () => { + expect(shouldAttempt(state(), false)).toBe(true); + expect(shouldAttempt(state({ status: 'failed' }), false)).toBe(true); + expect(shouldAttempt(state({ status: 'available' }), false)).toBe(true); + expect(shouldAttempt(state({ status: 'unsupported' }), false)).toBe(false); + }); + + test('an explicit refresh overrides it', () => { + expect(shouldAttempt(state({ status: 'unsupported' }), true)).toBe(true); + }); + + test('retrying clears permanent verdicts and leaves the rest alone', () => { + const before = withFeature( + initialFeatures(), + 'stores', + featureUnsupported(state(), 'no such method') + ); + const graded = withFeature(before, 'signals', featureAvailable(before.signals)); + const after = retryAll(graded); + expect(after.stores.status).toBe('unknown'); + expect(after.signals.status).toBe('available'); + }); +}); + +describe('wording', () => { + test('separates "the page cannot" from "the app has none"', () => { + expect(emptyMessage(state({ status: 'available' }), 'stores', 'No stores.')).toBe('No stores.'); + expect( + emptyMessage(state({ status: 'unsupported', detail: 'no such method' }), 'stores', '') + ).toMatch(/does not provide stores: no such method/); + expect(emptyMessage(state({ status: 'failed', detail: 'boom' }), 'stores', '')).toMatch( + /Could not load stores: boom/ + ); + }); + + test('an unadvertised, untried feature is reported as not yet seen', () => { + expect(emptyMessage(state(), 'stores', '')).toMatch(/has not reported stores yet/); + expect(emptyMessage(state({ advertised: true }), 'stores', '')).toMatch(/Loading stores/); + }); + + test('badge titles describe evidence, not advertisement', () => { + expect(featureTitle('stores', state({ status: 'available' }))).toMatch(/serves "stores"/); + expect(featureTitle('stores', state({ advertised: true }))).toMatch(/advertised "stores"/); + expect(featureTitle('stores', state())).toMatch(/did not advertise/); + }); +}); diff --git a/tests/unit/panel.state.test.ts b/tests/unit/panel.state.test.ts index 97a6214..8e7175c 100644 --- a/tests/unit/panel.state.test.ts +++ b/tests/unit/panel.state.test.ts @@ -77,17 +77,43 @@ describe('connect', () => { expect(state.loading.value).toBe(false); }); - test('skips requests for capabilities the page did not advertise', async () => { + test('probes a capability the page did not advertise, exactly once', async () => { + // The framework's own bridge advertises every capability, so a page that + // advertises less is either partial or hand-rolled — and may still answer. + // Advertisement is a hint; the panel asks before writing a section off. state.start(); transport.open(); transport.init(['signals']); await answerAll({ getSnapshot: snapshot }); - const methods = transport.sent - .filter(message => message.kind === 'request') - .map(message => message.method); - expect(methods).toEqual(['getSnapshot']); + const methods = (): string[] => + transport.sent.filter(message => message.kind === 'request').map(message => message.method); + expect(methods()).toContain('getComponentTree'); + expect(state.feature('components').status).toBe('unsupported'); expect(state.tree.value).toEqual([]); + + // The probe failed, so a routine refresh must not pay for it again. + const before = methods().length; + void state.refreshAll(); + await flush(); + expect(methods().filter(method => method === 'getComponentTree')).toHaveLength(1); + expect(methods().length).toBeLessThan(before + 3); + }); + + test('a page that answers without advertising anything still populates', async () => { + state.start(); + transport.open(); + transport.init([]); + await answerAll({ + getComponentTree: componentTree, + getSnapshot: snapshot, + getTimeline: [{ type: 'mark', detail: 'boot', timestamp: 1 }], + }); + + expect(state.tree.value).toHaveLength(1); + expect(state.signals.value[0]?.label).toBe('count'); + expect(state.entries()).toHaveLength(1); + expect(state.feature('components').status).toBe('available'); }); test('events streamed during the seed fetch are not overwritten by it', async () => { @@ -122,13 +148,126 @@ describe('connect', () => { test('a failed request surfaces as an error instead of throwing', async () => { state.start(); transport.open(); - transport.init(['signals']); + transport.init(['signals', 'components', 'timeline']); await flush(); - const request = transport.sent.find(message => message.kind === 'request'); - transport.respond(request?.id ?? 1, { error: 'devtools are disabled' }); + for (const message of transport.sent) { + if (message.kind !== 'request') continue; + transport.respond(message.id, { error: 'devtools are disabled' }); + } await flush(); expect(state.lastError.value).toMatch(/devtools are disabled/); expect(state.loading.value).toBe(false); + // An app-level failure is transient: the user can enable devtools and + // refresh, so the section must not be written off permanently. + expect(state.feature('signals').status).toBe('failed'); + }); + + test('one missing bridge method does not stop the others loading', async () => { + // The failure this replaces: `getSnapshot` rejecting used to abort the + // whole refresh, so a page with a perfectly good timeline showed none. + state.start(); + transport.open(); + transport.init(['components', 'signals', 'stores', 'timeline']); + await flush(); + for (const message of transport.sent) { + if (message.kind !== 'request' || transport.answered.has(message.id)) continue; + transport.answered.add(message.id); + if (message.method === 'getSnapshot') { + transport.respond(message.id, { error: 'Unknown method: getSnapshot' }); + } else { + transport.respond(message.id, { + result: + message.method === 'getTimeline' + ? [{ type: 'mark', detail: 'boot', timestamp: 1 }] + : componentTree, + }); + } + } + await flush(); + + expect(state.entries()).toHaveLength(1); + expect(state.tree.value).toHaveLength(1); + expect(state.feature('timeline').status).toBe('available'); + expect(state.feature('components').status).toBe('available'); + // "Unknown method" is the bridge's own answer for something it does not + // implement: permanent, so it is not retried on every refresh. + expect(state.feature('signals').status).toBe('unsupported'); + expect(state.feature('stores').status).toBe('unsupported'); + }); + + test('a snapshot without stores leaves the stores view honest', async () => { + // An app that loaded `reactive` but never `store`. + state.start(); + transport.open(); + transport.init(['signals', 'stores']); + await answerAll({ + getSnapshot: { + version: 1, + exportedAt: 500, + state: { timeline: [] }, + signals: [{ label: 'count', value: 0, subscriberCount: 1 }], + }, + }); + + expect(state.signals.value).toHaveLength(1); + expect(state.feature('signals').status).toBe('available'); + expect(state.feature('stores').status).toBe('unsupported'); + expect(state.feature('stores').detail).toMatch(/snapshot does not include/); + }); + + test('a snapshot that omits components does not wipe the tree registry', async () => { + state.start(); + transport.open(); + transport.init(['components', 'signals']); + await answerAll({ + getComponentTree: componentTree, + getSnapshot: { version: 1, exportedAt: 500, signals: [] }, + }); + + expect(state.components.value[0]?.tagName).toBe('my-app'); + }); + + test('a section written off comes back once the page reports it', async () => { + // The user loads the store module and the app registers its first store. + // The next snapshot carries `stores`, and the panel must believe the page + // over its own earlier verdict — without waiting for a manual refresh. + state.start(); + transport.open(); + transport.init(['signals', 'stores']); + await answerAll({ getSnapshot: { version: 1, exportedAt: 500, signals: [] } }); + expect(state.feature('stores').status).toBe('unsupported'); + + void state.refreshSnapshot(); + await answerAll({ getSnapshot: snapshot }); + expect(state.feature('stores').status).toBe('available'); + expect(state.stores.value[0]?.id).toBe('cart'); + }); + + test('streamed events are buffered even when getTimeline is unusable', async () => { + // Streaming and the seed fetch are separate paths: a bridge that pushes + // events but cannot answer `getTimeline` still fills the timeline view. + state.start(); + transport.open(); + transport.init(['timeline']); + await answerAll({ getTimeline: { not: 'a list' } }); + expect(state.feature('timeline').status).toBe('unsupported'); + + transport.event({ type: 'signal:update', detail: 'count', timestamp: 5 }); + expect(state.entries()).toHaveLength(1); + }); + + test('an explicit refresh re-probes a section written off earlier', async () => { + state.start(); + transport.open(); + transport.init(['signals']); + await answerAll({ getSnapshot: snapshot }); + expect(state.feature('components').status).toBe('unsupported'); + + // The user mounted a component and pressed "Refresh all". + void state.refreshAll({ retry: true }); + await answerAll({ getComponentTree: componentTree, getSnapshot: snapshot }); + expect(state.feature('components').status).toBe('available'); + expect(state.tree.value).toHaveLength(1); }); }); diff --git a/tests/unit/protocol.client.test.ts b/tests/unit/protocol.client.test.ts index 02b74ba..1d8f14b 100644 --- a/tests/unit/protocol.client.test.ts +++ b/tests/unit/protocol.client.test.ts @@ -50,6 +50,77 @@ describe('handshake', () => { }); }); +describe('protocol mismatch', () => { + const speakV2 = (): void => { + transport.deliver({ + source: 'bquery-devtools', + channel: 'page', + v: 2, + kind: 'init', + capabilities: ['signals'], + }); + }; + + test('says so instead of waiting forever on a page that is answering', () => { + client.start(); + transport.open(); + speakV2(); + + expect(client.state.value).toBe('incompatible'); + expect(client.detail.value).toMatch(/protocol v2/); + expect(client.detail.value).toMatch(/v1/); + // The message is still discarded: nothing was negotiated from it. + expect(client.capabilities.value.size).toBe(0); + }); + + test('keeps retrying, so navigating to a compatible app recovers', () => { + client.start(); + transport.open(); + speakV2(); + const before = transport.sent.length; + + clock.advance(100); + expect(transport.sent.length).toBeGreaterThan(before); + + transport.init(['signals']); + expect(client.state.value).toBe('connected'); + }); + + test('reports one version once, however many messages arrive', () => { + client.start(); + transport.open(); + speakV2(); + + // A v2 page answers every `hello`. Overwrite the detail and watch that + // the repeats do not keep stamping over whatever the panel says next. + client.detail.value = 'untouched'; + speakV2(); + speakV2(); + expect(client.detail.value).toBe('untouched'); + }); +}); + +describe('capabilities', () => { + test('keeps the raw advertised list, including entries it has no view for', () => { + client.start(); + transport.open(); + transport.init(['signals', 'router-devtools']); + + expect(client.capabilities.value.has('signals')).toBe(true); + expect([...client.capabilities.value]).toHaveLength(1); + // The unknown one is not negotiated, but it is not forgotten either. + expect(client.advertised.value).toEqual(['signals', 'router-devtools']); + }); + + test('a reconnect clears what the last page advertised', () => { + client.start(); + transport.open(); + transport.init(['signals', 'router-devtools']); + client.resetHandshake('page navigated'); + expect(client.advertised.value).toEqual([]); + }); +}); + describe('requests', () => { test('correlates responses by id', async () => { client.start(); diff --git a/tests/unit/protocol.messages.test.ts b/tests/unit/protocol.messages.test.ts index dfa45ec..e200002 100644 --- a/tests/unit/protocol.messages.test.ts +++ b/tests/unit/protocol.messages.test.ts @@ -2,10 +2,12 @@ import { describe, expect, test } from 'bun:test'; import { BRIDGE_PROTOCOL_VERSION, BRIDGE_SOURCE, + foreignProtocolVersion, helloMessage, negotiateCapabilities, parseOutbound, requestMessage, + unknownCapabilities, } from '../../src/protocol/messages'; const pageMessage = (extra: Record): Record => ({ @@ -112,3 +114,33 @@ describe('negotiateCapabilities', () => { expect(negotiateCapabilities([]).size).toBe(0); }); }); + +describe('foreignProtocolVersion', () => { + test('names the version of a bridge message this panel cannot read', () => { + expect( + foreignProtocolVersion({ source: BRIDGE_SOURCE, channel: 'page', v: 2, kind: 'init' }) + ).toBe(2); + }); + + test('is silent about messages this panel can read, and about foreign traffic', () => { + expect( + foreignProtocolVersion({ source: BRIDGE_SOURCE, channel: 'page', v: 1, kind: 'init' }) + ).toBeNull(); + expect(foreignProtocolVersion({ source: 'other-extension', channel: 'page', v: 9 })).toBeNull(); + expect(foreignProtocolVersion({ source: BRIDGE_SOURCE, channel: 'panel', v: 9 })).toBeNull(); + expect(foreignProtocolVersion('nope')).toBeNull(); + }); + + test('ignores a non-numeric version rather than reporting NaN at the user', () => { + expect( + foreignProtocolVersion({ source: BRIDGE_SOURCE, channel: 'page', v: 'two', kind: 'init' }) + ).toBeNull(); + }); +}); + +describe('unknownCapabilities', () => { + test('lists what the page offers that this build has no view for', () => { + expect(unknownCapabilities(['signals', 'router', 'ssr'])).toEqual(['router', 'ssr']); + expect(unknownCapabilities(['signals'])).toEqual([]); + }); +}); diff --git a/tests/unit/protocol.results.test.ts b/tests/unit/protocol.results.test.ts index 32017e7..dabda37 100644 --- a/tests/unit/protocol.results.test.ts +++ b/tests/unit/protocol.results.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test'; -import { parseComponentTree, parseSnapshot, parseTimeline } from '../../src/protocol/results'; +import { + parseComponentTree, + parseSnapshot, + parseTimeline, + parseTimelineResult, +} from '../../src/protocol/results'; describe('parseSnapshot', () => { test('lifts the timeline out of the nested devtools state', () => { @@ -48,7 +53,7 @@ describe('parseComponentTree', () => { { id: 'no-tag' }, ], flat: [{ tagName: 'my-app', instanceCount: 1 }], - }); + }) ?? { tree: [] }; expect(tree).toHaveLength(1); expect(tree[0]?.attrs).toEqual({ class: 'root' }); expect(tree[0]?.children[0]?.tag).toBe('my-child'); @@ -69,7 +74,7 @@ describe('parseComponentTree', () => { cursor = child; } - const { tree } = parseComponentTree({ tree: [root], flat: [] }); + const { tree } = parseComponentTree({ tree: [root], flat: [] }) ?? { tree: [] }; let depth = 0; let node = tree[0]; while (node && node.children.length > 0) { @@ -80,8 +85,18 @@ describe('parseComponentTree', () => { expect(depth).toBeGreaterThan(0); }); - test('degrades to empty collections for junk', () => { - expect(parseComponentTree(null)).toEqual({ tree: [], flat: [] }); + test('rejects a result that is not a result', () => { + // Distinct from "the page has no components": a page that answers + // `undefined` has not told the panel anything, and reporting that as an + // empty tree would be the panel inventing an answer. + expect(parseComponentTree(null)).toBeNull(); + expect(parseComponentTree(undefined)).toBeNull(); + expect(parseComponentTree('nope')).toBeNull(); + expect(parseComponentTree([])).toBeNull(); + }); + + test('degrades to empty collections when the members are junk', () => { + expect(parseComponentTree({ tree: 'nope', flat: 7 })).toEqual({ tree: [], flat: [] }); }); }); @@ -93,3 +108,31 @@ describe('parseTimeline', () => { expect(typeof entries[0]?.timestamp).toBe('number'); }); }); + +describe('parseTimelineResult', () => { + test('separates "no events" from "not a timeline"', () => { + // An empty list means the page has recorded nothing; a non-list means the + // page cannot serve a timeline at all. The panel says different things. + expect(parseTimelineResult([])).toEqual([]); + expect(parseTimelineResult(undefined)).toBeNull(); + expect(parseTimelineResult({ entries: [] })).toBeNull(); + }); +}); + +describe('snapshot presence', () => { + test('reports which collections the page actually carried', () => { + // An app that loaded `reactive` but not `store`. + const partial = parseSnapshot({ + exportedAt: 1, + signals: [{ label: 'count', value: 1, subscriberCount: 0 }], + }); + expect(partial?.reported).toEqual({ signals: true, stores: false, components: false }); + expect(partial?.signals).toHaveLength(1); + expect(partial?.stores).toEqual([]); + }); + + test('an empty array is reported, not treated as absent', () => { + const empty = parseSnapshot({ exportedAt: 1, signals: [], stores: [], components: [] }); + expect(empty?.reported).toEqual({ signals: true, stores: true, components: true }); + }); +});