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..6da979c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + +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 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - 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@v7 + + - 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@v7 + 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@v7 + + - 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@v7 + 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..7d402aa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,110 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to build (defaults to the current ref)' + required: false + +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@v7 + 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 + + # 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 != '' && env.AMO_JWT_SECRET != '' + 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 *.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@v7 + 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/*.xpi + artifacts/SHA256SUMS.txt + draft: true + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 7a21a8d..87bcdd4 100644 --- a/.gitignore +++ b/.gitignore @@ -687,8 +687,11 @@ FodyWeavers.xsd dist -tools/syncConfig.js -tools/parse.js -tools/v2.js -tools/clean.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/ package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..30c80e0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# 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. +- 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**. +- 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. +- 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 + 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..f112f4e 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,157 @@ -# 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 - -```bash -git clone https://github.com/JosunLP/BrowserExtensionTemplate.git -cd BrowserExtensionTemplate -bun install -``` +## Quick start -### Development Setup +1. Enable the bridge in the app you want to inspect: -```bash -# Install dependencies -bun install + ```ts + import { connectDevtoolsBridge, enableDevtools } from '@bquery/bquery/devtools'; -# Start development mode with auto-rebuild -bun run dev + enableDevtools(true); + connectDevtoolsBridge(); // exposes protocol v1 over window.postMessage + ``` -# Type checking -bun run type-check + Requires `@bquery/bquery` **≥ 1.15.0**. -# Linting and formatting -bun run validate -``` +2. Install the extension (see [Installing](#installing)). -## Usage +3. Open DevTools on that page and select the **bQuery** panel. -### Project Structure +## Features -```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 -``` +- **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). -### 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'; +bun install +bun run deploy-v3 # Chromium / Edge (MV3) → dist/ +bun run deploy-v2 # Firefox (MV2) → dist/ ``` -### bQuery.js Integration +**Chromium / Edge:** open `chrome://extensions`, enable **Developer mode**, +click **Load unpacked** and pick `dist/`. -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/`: +**Firefox:** run `bun run deploy-v2`, then open `about:debugging` → **This +Firefox** → **Load Temporary Add-on** and pick `dist/manifest.json`. -```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 run package` writes a store-ready zip to `artifacts/`. -What ships out of the box: +## Permissions -- **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. +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. -### Error Handling +**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. -Built-in error boundary system, integrated with bQuery's `escapeHtml`: +| 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. | -```typescript -import { ErrorBoundary } from './classes/errorBoundary'; +## Security model -const errorBoundary = ErrorBoundary.getInstance(); +The inspected page is treated as untrusted, because it is: -// Wrap async functions -const safeAsyncFunction = errorBoundary.wrapAsync(asyncFunction); +- 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. -// Add custom error handlers -errorBoundary.addErrorHandler(error => { - console.log('Custom error handling:', error); -}); +## Development -// Render an error message safely (HTML-escaped via bQuery security) -element.innerHTML = ErrorBoundary.formatErrorMessage(unsafeMessage); +```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/ ``` -### Component System +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). -Type-safe, reusable components — both as imperative helpers and as native -Web Components built with bQuery: - -```typescript -import { BasicButton } from './components/button'; - -// 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', - '' -); -``` +## Protocol (v1) -## Browser Compatibility +Every message carries `source: 'bquery-devtools'` and the protocol version `v`. -- **Chrome**: Manifest v3 (recommended) -- **Firefox**: Manifest v2 (automatically converted) -- **Edge**: Manifest v3 compatible +| 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` | -## Contributing +**Methods:** `ping`, `getSnapshot`, `getTimeline` (`{ limit }`), +`getComponentTree`. Apps can add their own through +`connectDevtoolsBridge({ methods })`; the panel ignores methods it does not +know about. -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 +**Capabilities:** `signals`, `stores`, `components`, `timeline`, `time-travel`. -## Development Guidelines +## Partial bQuery apps -- 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 +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: -## License +- 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. -This project is licensed under the [MIT License](https://opensource.org/licenses/MIT). +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. -## Author +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#partial-implementations) for the +full degradation model. -**_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..cb42703 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,191 @@ +# 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. + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ 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. + +## 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_ +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..b068cda --- /dev/null +++ b/docs/PUBLISHING.md @@ -0,0 +1,142 @@ +# 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, 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 +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. + +## 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) + 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*: 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"; + - 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 704c43b..0000000 Binary files a/src/assets/logo.afdesign and /dev/null differ diff --git a/src/background.ts b/src/background.ts index 8453143..e6cf8d6 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,113 +1,64 @@ /** - * Background service worker. + * Background service worker (MV3) / background script (MV2). * - * Uses bQuery's reactive primitives to track lightweight runtime state - * (install reason, message counters) even outside of a DOM environment. - * Reactive state is convenient for diagnostics and can be inspected via the - * `getVersion` / `ping` messages from privileged extension pages. + * It exists only for the optional *port* transport: routing bridge traffic + * between a DevTools panel and the content script of the tab it inspects, + * and injecting that content script on demand once the user has granted the + * origin permission. The default eval transport does not involve this worker + * at all, which is why the extension ships without any static content script + * or host permission. + * + * @module background */ -import { computed, effect, signal } from '@bquery/bquery/reactive'; - -interface ExtensionMessage { - type: string; - payload?: unknown; -} - -class Background { - private readonly installReason = signal(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..a36ced3 --- /dev/null +++ b/src/panel/components/componentTree.ts @@ -0,0 +1,182 @@ +/** + * `` — 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 { emptyMessage } from '../features'; +import { buildSelectExpression, flattenTree, nodeAtPath, parsePathKey, pathKey } from '../tree'; +import { defineElement, PanelElement } from './base'; + +/** + * 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 { + 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; + + this.searchInput = el('input', { + class: 'tree-search', + attrs: { + type: 'search', + placeholder: 'Filter by tag or attribute…', + 'aria-label': 'Filter components', + }, + on: { + input: event => { + const target = event.target as HTMLInputElement; + state.treeSearch.value = target.value; + }, + }, + }); + this.countLabel = el('span', { class: 'muted' }); + this.listHost = el('div', { class: 'tree-list', attrs: { role: 'tree' } }); + + const header = el('div', { class: 'view-toolbar' }, [ + this.searchInput, + this.countLabel, + el('button', { + class: 'btn', + text: 'Refresh', + attrs: { type: 'button' }, + on: { + click: () => { + void state.refreshTree(); + }, + }, + }), + ]); + + 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; + + // 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 (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: emptyMessage( + state.feature('components'), + 'a component tree', + 'No custom elements found on the page.' + ), + }) + ); + } + + 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}` })); + } + rows.push(row); + } + + replaceChildren(list, rows); + } + + /** 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..9522f7e --- /dev/null +++ b/src/panel/components/inspector.ts @@ -0,0 +1,114 @@ +/** + * `` — 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 { emptyMessage } from '../features'; +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 => ({ + // `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.state as unknown, + 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: emptyMessage( + state.feature(capability), + this.kind, + `No ${this.kind} reported by the page.` + ), + }) + ); + } + 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..dd97f3c --- /dev/null +++ b/src/panel/components/shell.ts @@ -0,0 +1,121 @@ +/** + * `` — the panel shell: status bar plus tabbed views. + * + * 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 + */ +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 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 => { + // 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, + attrs: { + type: 'button', + role: 'tab', + 'aria-selected': String(tab.id === this.activeTab), + ...(supported + ? {} + : { title: `This page cannot serve "${tab.capability}": ${feature.detail}` }), + }, + 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..1445c51 --- /dev/null +++ b/src/panel/components/statusBar.ts @@ -0,0 +1,129 @@ +/** + * `` — 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, unknownCapabilities } from '../../protocol/messages'; +import { featureTitle } from '../features'; +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', + incompatible: 'Incompatible protocol', + 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 detail = state.bridge.detail.value; + const error = state.lastError.value; + + // 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: featureTitle(capability, feature), + }); + }); + + const foreign = unknownCapabilities(state.bridge.advertised.value); + + 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, + ]; + + 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', { + 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: () => { + // 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 }); + }, + }, + }) + ); + + 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..6207562 --- /dev/null +++ b/src/panel/components/timelineView.ts @@ -0,0 +1,334 @@ +/** + * `` — 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 { 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. +import './valueView'; +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; + // 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); + + 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 { + 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 updateFilters( + entries: readonly TimelineEntry[], + filter: { types: ReadonlySet; search: string } + ): 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' : ''}`, + 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 }; + }, + }, + }) + ); + + replaceChildren(chrome.chipsHost, chips); + } + + private updateScrubber(total: number, travelIndex: number | null): void { + const state = this.state; + const chrome = this.chrome; + if (!chrome) return; + const supported = state.canTimeTravel(); + const disabled = total === 0 || !supported; + const index = travelIndex ?? total - 1; + const replay = state.reconstruction.value; + + 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 + ? 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` : '' + }` + : '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: emptyMessage( + state.feature('timeline'), + 'a timeline', + 'No events recorded yet. Interact with the page to see reactive activity.' + ), + }) + ); + 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.canTimeTravel()) { + 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/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/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..8cae9cb --- /dev/null +++ b/src/panel/state.ts @@ -0,0 +1,392 @@ +/** + * 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 { + KNOWN_CAPABILITIES, + type ComponentTreeNode, + type TimelineEntry, +} from '../protocol/messages'; +import { + parseComponentTree, + parseSnapshot, + 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; + +/** + * 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 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(() => { + 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(), + }); + /** `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) { + 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; + } + + /** 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(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(); + }) + ); + 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 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(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'; + }); + } + + /** + * 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(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. */ + 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; + } + + /** + * 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; + // `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/panel/timeTravel.ts b/src/panel/timeTravel.ts new file mode 100644 index 0000000..4acb03e --- /dev/null +++ b/src/panel/timeTravel.ts @@ -0,0 +1,222 @@ +/** + * 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; + /** + * 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. */ +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; + 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; + + 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, + skippedCount, + 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..3cb9d38 --- /dev/null +++ b/src/panel/valueTree.ts @@ -0,0 +1,167 @@ +/** + * 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, + }; + } + // 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] })); + 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..8cdb0c9 --- /dev/null +++ b/src/protocol/client.ts @@ -0,0 +1,348 @@ +/** + * 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 { + BRIDGE_PROTOCOL_VERSION, + foreignProtocolVersion, + 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. + * + * `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' + | 'incompatible' + | '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()); + /** + * 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(''); + + 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; + /** + * 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; + /** Foreign protocol version already reported, so it is said once, not per message. */ + private reportedForeignVersion: 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.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); + } + + /** 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.handshakeComplete = false; + 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; + case 'error': + this.handshakeComplete = false; + 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) { + 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 = ''; + 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 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; + } + } + } + + /** + * 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 => { + if (this.disposed || this.handshakeComplete) 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..23893ad --- /dev/null +++ b/src/protocol/messages.ts @@ -0,0 +1,253 @@ +/** + * 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. + * + * 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'; + +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; + } +}; + +/** + * 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); + 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..abb621e --- /dev/null +++ b/src/protocol/results.ts @@ -0,0 +1,206 @@ +/** + * 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; +} + +/** + * 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[]; + readonly stores: readonly StoreView[]; + readonly components: readonly ComponentView[]; + readonly timeline: readonly TimelineEntry[]; + readonly exportedAt: number; + /** Which collections the page reported at all. */ + readonly reported: SnapshotPresence; +} + +/** 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 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()`. + * + * 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 => { + // `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), + stores: parseArray(value['stores'], parseStore), + 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']), + }, + }; +}; + +/** + * 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; `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']) { + 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..e398268 --- /dev/null +++ b/src/sass/panel.sass @@ -0,0 +1,410 @@ +// 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, &.status-incompatible + 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 + // 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 -------------------------------------------------------------- + +.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 + // 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 + 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..f620af4 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,185 +1,106 @@ +/** + * 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(); + }) + .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 new file mode 100644 index 0000000..4df73f5 --- /dev/null +++ b/src/transports/evalTransport.ts @@ -0,0 +1,204 @@ +/** + * 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; + +/** + * 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 }; + 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 ?? defaultEvaluator; + 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..f2d217b --- /dev/null +++ b/tests/e2e/fixture.ts @@ -0,0 +1,201 @@ +/** + * 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[]; + /** + * 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; +} + +/** + * 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: 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. + * + * 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 } => { + if (!implemented(name)) return { 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; + 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 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: reply.result }); + }); + + /** 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..5339dd5 --- /dev/null +++ b/tests/e2e/panel.spec.ts @@ -0,0 +1,412 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + FIXTURE_CAPABILITIES, + FIXTURE_SNAPSHOT, + FIXTURE_TIMELINE, + FIXTURE_TREE, + installFixture, +} from './fixture'; + +/** + * 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: 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'); +}; + +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('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[] = []; + 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; + // 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']); + 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 methods.entries()) { + window.postMessage( + { + source: 'bquery-devtools', + channel: 'panel', + v: 1, + kind: 'request', + id: 9000 + index, + method, + }, + '*' + ); + } + await Promise.race([collected, new Promise(resolve => setTimeout(resolve, 2000))]); + window.removeEventListener('message', collect); + return answers; + }); + + expect(replies).toHaveLength(3); + for (const reply of replies) { + expect(String(reply)).toContain('Unknown method'); + } + }); + + 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(() => { + 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(); + }); +}); + +/** + * 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/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.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.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..8e7175c --- /dev/null +++ b/tests/unit/panel.state.test.ts @@ -0,0 +1,396 @@ +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('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 = (): 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 () => { + 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', 'components', 'timeline']); + await flush(); + 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); + }); +}); + +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 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 }); + } + state.travelTo(9); + state.setBufferSize(50); + expect(state.bufferCapacity()).toBe(50); + // 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', () => { + 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', + // After `snapshot.exportedAt`, so the replay base does not supersede it. + timestamp: snapshot.exportedAt + 1, + 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..63bbe82 --- /dev/null +++ b/tests/unit/panel.timeTravel.test.ts @@ -0,0 +1,151 @@ +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'; + +// 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 = { + 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 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); + }); + + 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..1d8f14b --- /dev/null +++ b/tests/unit/protocol.client.test.ts @@ -0,0 +1,214 @@ +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('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(); + 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..e200002 --- /dev/null +++ b/tests/unit/protocol.messages.test.ts @@ -0,0 +1,146 @@ +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 => ({ + 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); + }); +}); + +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 new file mode 100644 index 0000000..dabda37 --- /dev/null +++ b/tests/unit/protocol.results.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from 'bun:test'; +import { + parseComponentTree, + parseSnapshot, + parseTimeline, + parseTimelineResult, +} 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 }], + }) ?? { tree: [] }; + 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: [] }) ?? { tree: [] }; + 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('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: [] }); + }); +}); + +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'); + }); +}); + +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 }); + }); +}); 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.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'), },