diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc2a7dd..17217f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, ux-revamp] + branches: [trunk] pull_request: - branches: [main] + branches: [trunk] concurrency: group: ci-${{ github.ref }} @@ -73,6 +73,9 @@ jobs: - name: Build extension run: npm run build + - name: Check bundle size budgets + run: npm run check:bundle + - name: Upload build artifact uses: actions/upload-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d7ffe..9df4042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to the APQ Debugger extension will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2026-06-13 + +### Added +- Passive mode: resolve APQ hashes from the registry without contaminating live traffic +- GraphQL schema introspection and SDL viewer in a dedicated Schema tab +- Copy as cURL for captured requests +- Keyboard navigation and shortcuts across the DevTools panel +- Responsive layout for narrower DevTools dock sizes +- Bundle size budget check in CI (`npm run check:bundle`) + +### Changed +- **Major UI redesign** — tabbed layout separating requests, schema, and settings +- Schema moved from inline controls to its own tab and viewer +- Optimized production bundle sizes for the service worker and DevTools panel + +### Fixed +- Schema endpoint detection when multiple GraphQL endpoints are observed +- Prettier formatting violations that failed the CI lint job + ## [2.0.0] - 2026-02-07 ### Added @@ -83,6 +102,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | Version | Commit | Date | |---------|--------|------| +| 3.0.0 | — | 2026-06-13 | | 2.0.0 | — | 2026-02-07 | | 1.1.0 | `5b035ba` | 2026-01-30 | | 1.0.0 | `96f5ff3` | 2026-01-26 | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..826a28f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Chrome MV3 DevTools extension that reveals the full GraphQL operations behind Apollo Automatic Persisted Query (APQ) hashes. It attaches the Chrome Debugger API to a tab, intercepts requests via `Fetch.requestPaused`, and replaces the APQ hash with a bogus one so the server responds `PersistedQueryNotFound`, forcing Apollo Client to retry with the full query text — which is then captured and shown in a DevTools panel. + +## Commands + +```bash +npm run build # Bundle & minify into extension_build/ (the only loadable folder) +npm run build:dev # Build with inline source maps, no minification +npm run watch # Rebuild on change (reload extension in chrome://extensions after) +npm test # Jest unit tests (coverage always collected) +npx jest tests/unit/hash-registry.test.js # Single test file +npx jest -t "should evict the oldest hash" # Single test by name +npm run test:e2e # Playwright e2e (launches real Chrome with the built extension) +npm run lint # ESLint +npm run format:check # Prettier check (see Windows note below) +``` + +Releases: `npm version patch|minor|major` (syncs manifest.json via `scripts/sync-version.js`), then `git push --follow-tags` — the release workflow builds, tests, and publishes the zip. + +- The default branch is **`trunk`**, not `main`. CI runs on pushes/PRs to trunk only. +- `package.json` is the canonical version; `manifest.json` is synced from it (also patched at build time). +- **Windows note:** the repo checks out with CRLF (`core.autocrlf=true`), so `npm run format:check` fails locally on line endings alone. Use `npx prettier --check --end-of-line auto ` to see real violations; commits are normalized to LF so CI is unaffected. + +## Architecture + +Two independently bundled contexts that communicate only via `chrome.runtime.sendMessage`: + +- **Service worker** (`js/sw/`, entry `index.js` → built as `service-worker.min.js`): owns all Chrome Debugger interaction. `debugger-manager.js` handles attach/detach lifecycle, `interceptor.js` handles `Fetch.requestPaused` and hash contamination, `messaging.js` routes incoming messages, `chrome-api.js` wraps callback APIs in promises. +- **DevTools panel** (`js/ui/`, entry `index.js` → built as `devtools.min.js` loaded by `frontend/devtools.html`): one module per UI concern, all sharing the mutable `state` object in `js/ui/state.js`. The panel identifies its tab via `chrome.devtools.inspectedWindow.tabId` (reliable even when undocked). +- **`js/shared/`**: constants and logger used by both bundles. The GraphQL introspection query is a string literal here specifically so the `graphql` npm package stays out of the service worker bundle. + +Key cross-cutting behaviors: + +- **MV3 service worker restarts:** all state that must survive (attached tabs, URL patterns, hash registry, passive-mode flag) lives in `chrome.storage.local`; observed endpoints live in `chrome.storage.session` (right lifetime: survives SW restarts, cleared on browser exit). `isDebuggerActive()` checks memory → storage → live `chrome.debugger.getTargets()` and self-heals. `initRegistry()` and `restoreDebuggerState()` run on every SW start. Never keep must-survive state in module-level variables alone — a user-triggered message often wakes a *fresh* SW. +- **Hash registry / passive mode** (`js/sw/hash-registry.js`): active mode contaminates hashes and records hash→query mappings from the retries; passive mode resolves hashes from the registry without touching traffic. Capped at `HASH_REGISTRY_MAX_ENTRIES` (LRU eviction; insertion order is recency — re-registering deletes then re-sets the key). +- **Messaging convention:** SW→panel messages are broadcast with a `tabId` field and filtered on the receiving side; panel→SW messages are validated in `messaging.js` (`validateMessage`) before routing. Every `sendMessage` callback checks `chrome.runtime.lastError` because the panel is frequently not open. +- **Schema loading** (`js/sw/schema-loader.js`): runs in the SW (which has `` host permission for cross-origin fetch). Endpoints observed by the interceptor (`endpoint-tracker.js`) are known GraphQL endpoints, so they are introspected directly, replaying the request headers captured from live traffic (production gateways reject bare requests); only the common-path guesses on the page's origin are probed with a bare `{__typename}` request. +- **Build** (`build-enhanced.js`): esbuild bundles both entries as IIFEs; production drops `debugger` statements and treats `console.info`/`console.debug` as pure (stripped) — so use those for dev-only logging and `console.log`/`warn`/`error` for messages that should survive in release builds. The HTML/manifest are patched for the flat `extension_build/` layout; loading the repo root unpacked does not work. + +## Tests + +- Jest's default environment is **node**; DOM tests must start with a `/** @jest-environment jsdom */` docblock (see `tests/unit/devtools.test.js`, `tests/unit/curl-copy.test.js`). +- Chrome APIs are mocked manually as `global.chrome` in `tests/unit/setup.js` (not via jest-chrome) — extend that file when a test needs an API surface that isn't mocked yet. +- Files are transformed with esbuild (`jest-esbuild-transform.js`), so ES modules in `js/` import cleanly. +- Test DOM fixtures are built inline with `document.body.innerHTML` mirroring the relevant fragment of `frontend/devtools.html`. + +## Conventions + +From CONTRIBUTING.md, the ones that matter in practice: promise-wrap Chrome APIs via `chrome-api.js` rather than using raw callbacks; JSDoc on all exported functions; wrap `JSON.parse` in try/catch; handle `chrome.runtime.lastError` in every Chrome API callback; one responsibility per module. diff --git a/README.md b/README.md index 57ca077..d2c7f6a 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,18 @@ - **DevTools Integration**: Seamless integration with Chrome DevTools - **APQ Fallback Visualization**: See both the hash-only and full-query phases of APQ requests - **Request History**: Browse, filter, and inspect intercepted requests with syntax highlighting +- **Passive Mode**: Resolve APQ hashes from a persisted hash registry without modifying any traffic +- **Schema Explorer**: Auto-detect the GraphQL endpoint and browse its introspected schema +- **Copy as cURL**: Replay any captured request from Bash or PowerShell, including captured request headers ## Installation 1. Clone this repository -2. Open Chrome and go to `chrome://extensions/` -3. Enable "Developer mode" -4. Click "Load unpacked" and select the project folder (or `extension_build/` after building) -5. Open DevTools and look for the "APQ Debugger" panel +2. Run `npm install` and `npm run build` to produce the `extension_build/` folder +3. Open Chrome and go to `chrome://extensions/` +4. Enable "Developer mode" +5. Click "Load unpacked" and select the `extension_build/` folder +6. Open DevTools and look for the "APQ Debugger" panel ## Usage @@ -41,6 +45,7 @@ apq-debugger/ │ ├── devtools.html # Main DevTools panel HTML │ └── devtools.css # Panel styles (dark/light theme) ├── js/ +│ ├── shared/ # Constants & logger shared by both bundles │ ├── sw/ # Service worker modules │ │ ├── index.js # Entry point – event wiring │ │ ├── chrome-api.js # Promise wrappers for Chrome APIs @@ -48,7 +53,10 @@ apq-debugger/ │ │ ├── interceptor.js # Fetch.requestPaused handler │ │ ├── messaging.js # onMessage router & action toggle │ │ ├── storage.js # chrome.storage.local helpers -│ │ └── hash.js # SHA-256 digest utility +│ │ ├── hash.js # SHA-256 digest utility +│ │ ├── hash-registry.js # Persisted hash → query registry (passive mode) +│ │ ├── endpoint-tracker.js # GraphQL endpoints observed per tab +│ │ └── schema-loader.js # Endpoint auto-detection & introspection │ ├── ui/ # DevTools panel modules │ │ ├── index.js # Entry point – panel creation │ │ ├── state.js # Shared reactive state @@ -56,10 +64,17 @@ apq-debugger/ │ │ ├── patterns.js # URL pattern CRUD & storage sync │ │ ├── request-list.js # Request list rendering & filtering │ │ ├── request-detail.js # Detail panel & copy-to-clipboard +│ │ ├── curl-copy.js # Copy-as-cURL generators & modal │ │ ├── status.js # Status badge & banner management -│ │ └── debugger-controls.js # Start/stop button orchestration -│ ├── devtools.js # Legacy monolithic panel script -│ └── service-worker.js # Legacy monolithic service worker +│ │ ├── debugger-controls.js # Start/stop button orchestration +│ │ ├── registry-controls.js # Hash registry & passive mode controls +│ │ ├── schema-controls.js # Schema loading controls & progress +│ │ ├── schema-viewer.js # Introspected schema explorer +│ │ ├── settings.js # Panel settings +│ │ ├── toolbar.js # Toolbar actions +│ │ ├── keyboard.js # Keyboard navigation +│ │ ├── layout.js # Panel layout management +│ │ └── resize.js # Resizable panel dividers ├── scripts/ │ ├── sync-version.js # Sync package.json → manifest.json version │ └── package-extension.js # Zip extension_build/ for Web Store upload @@ -141,6 +156,13 @@ The GitHub Actions [release workflow](.github/workflows/release.yml) will automa - **Message Passing**: Bidirectional communication between the panel and service worker via `chrome.runtime.sendMessage` - **Persistent State**: URL patterns and attached-tab state are persisted in `chrome.storage.local` to survive service worker restarts +### Privacy Note + +The **Copy as cURL** feature includes the captured request headers — which may contain +cookies and authorization tokens — in the generated command so the request can be +replayed as-is. This data never leaves your browser; it is only written to your +clipboard when you click **Copy**. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture details, and contribution guidelines. diff --git a/build-enhanced.js b/build-enhanced.js index 1d37f9a..b8ba729 100644 --- a/build-enhanced.js +++ b/build-enhanced.js @@ -76,6 +76,7 @@ async function bundleJS(entry, outfile) { sourcemap: isDev ? 'inline' : false, drop: isDev ? [] : ['debugger'], pure: isDev ? [] : ['console.info', 'console.debug'], + legalComments: 'none', logLevel: 'warning', metafile: true, }); diff --git a/frontend/devtools.css b/frontend/devtools.css index b6f7308..5ae7693 100644 --- a/frontend/devtools.css +++ b/frontend/devtools.css @@ -39,11 +39,56 @@ --transition-fast: 0.15s ease; --transition-normal: 0.2s ease; + + /* Density (overridden by [data-density="compact"]) */ + --font-size-base: 13px; + --header-height: 40px; + --control-pad-y: 8px; + --item-pad-y: 10px; + --panel-pad: 12px; + --section-pad: 10px; +} + +/* Compact density */ +:root[data-density="compact"] { + --font-size-base: 12px; + --header-height: 32px; + --control-pad-y: 5px; + --item-pad-y: 5px; + --panel-pad: 8px; + --section-pad: 6px; +} + +/* Dark Mode. + The variable block is intentionally duplicated: once for the explicit + user override ([data-theme="dark"]) and once for system preference + (when no explicit "light" override is set). */ + :root[data-theme="dark"] { + --color-primary: #6366F1; + --color-primary-hover: #818CF8; + --color-success: #22C55E; + --color-success-bg: rgba(34, 197, 94, 0.15); + --color-warning: #F59E0B; + --color-warning-bg: rgba(245, 158, 11, 0.15); + --color-error: #EF4444; + --color-error-bg: rgba(239, 68, 68, 0.15); + --color-surface: #1E1E2E; + --color-surface-alt: #2A2A3E; + --color-surface-hover: #3A3A4E; + --color-border: #3A3A4E; + --color-border-strong: #4A4A5E; + --color-text-primary: #E4E4E7; + --color-text-secondary: #A1A1AA; + --color-text-muted: #71717A; + --color-text-inverse: #18181B; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5); } /* Dark Mode */ @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="light"]) { --color-primary: #6366F1; --color-primary-hover: #818CF8; --color-success: #22C55E; @@ -79,7 +124,7 @@ body { font-family: var(--font-sans); - font-size: 13px; + font-size: var(--font-size-base); line-height: 1.5; color: var(--color-text-primary); background: var(--color-surface); @@ -102,7 +147,7 @@ body { display: flex; align-items: center; justify-content: space-between; - height: 40px; + height: var(--header-height); padding: 0 12px; background: var(--color-surface-alt); border-bottom: 1px solid var(--color-border); @@ -134,6 +179,7 @@ body { .header-right { display: flex; align-items: center; + gap: 8px; } /* Status Badge */ @@ -233,7 +279,10 @@ body { } .sidebar-header { - padding: 12px; + display: flex; + align-items: center; + gap: 6px; + padding: var(--panel-pad); border-bottom: 1px solid var(--color-border); } @@ -260,7 +309,8 @@ body { .urlPattern { flex: 1; - padding: 8px 10px; + min-width: 0; + padding: var(--control-pad-y) 10px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); @@ -353,6 +403,181 @@ body { display: block; } +/* ================================================ + Sidebar Sections (Passive Mode, Schema) + ================================================ */ +.sidebar-section { + padding: var(--section-pad) 8px; + border-top: 1px solid var(--color-border); +} + +.sidebar-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.sidebar-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-secondary); +} + +.sidebar-section-desc { + margin-top: 4px; + font-size: 11px; + line-height: 1.4; + color: var(--color-text-muted); +} + +/* Toggle switch */ +.toggle-switch { + position: relative; + display: inline-block; + width: 32px; + height: 18px; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + inset: 0; + background: var(--color-border-strong); + border-radius: var(--radius-full); + cursor: pointer; + transition: background var(--transition-normal); +} + +.toggle-slider::before { + content: ''; + position: absolute; + width: 14px; + height: 14px; + left: 2px; + top: 2px; + background: var(--color-surface); + border-radius: 50%; + box-shadow: var(--shadow-sm); + transition: transform var(--transition-normal); +} + +.toggle-switch input:checked+.toggle-slider { + background: var(--color-primary); +} + +.toggle-switch input:checked+.toggle-slider::before { + transform: translateX(14px); +} + +.toggle-switch input:focus-visible+.toggle-slider { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Registry info row */ +.registry-info { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + font-size: 11px; + color: var(--color-text-secondary); +} + +.btn-link { + border: none; + background: none; + padding: 0; + font-size: 11px; + color: var(--color-primary); + cursor: pointer; +} + +.btn-link:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +.btn-link:disabled { + color: var(--color-text-muted); + cursor: default; + text-decoration: none; +} + +/* Schema form */ +.schema-load-bar { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 8px var(--panel-pad); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; + min-width: 0; +} + +.schema-actions { + display: flex; + align-items: stretch; + gap: 6px; + width: 100%; + min-width: 0; +} + +.schema-url-input { + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 6px 8px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text-primary); + font-size: 11px; + font-family: var(--font-mono); +} + +.schema-actions .schema-search, +.schema-actions .btn { + flex: 1; + min-width: 0; + box-sizing: border-box; +} + +.schema-actions .schema-search { + padding: 5px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text-primary); + font-size: 12px; +} + +.schema-url-input:focus, +.schema-actions .schema-search:focus { + outline: none; + border-color: var(--color-primary); +} + +.schema-status { + font-size: 11px; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + width: 100%; +} + /* ================================================ Buttons ================================================ */ @@ -361,7 +586,7 @@ body { align-items: center; justify-content: center; gap: 6px; - padding: 8px 16px; + padding: var(--control-pad-y) 16px; border: none; border-radius: var(--radius-md); font-size: 12px; @@ -445,7 +670,7 @@ body { display: flex; align-items: center; justify-content: space-between; - padding: 10px 12px; + padding: var(--section-pad) var(--panel-pad); border-bottom: 1px solid var(--color-border); gap: 12px; flex-wrap: wrap; @@ -460,13 +685,43 @@ body { white-space: nowrap; } -/* Filter Bar */ +.main-panel-header { + flex-wrap: nowrap; +} + +.main-panel-header .panel-toolbar { + margin-left: auto; +} + +.panel-toolbar.hidden { + display: none; +} + +.main-tab-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +.main-tab-content.hidden { + display: none; +} + .filter-bar { display: flex; align-items: center; gap: 8px; - flex: 1; justify-content: flex-end; + padding: 8px var(--panel-pad); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.main-tab-content .request-list { + flex: 1; + min-height: 0; } .filter-input { @@ -524,7 +779,7 @@ body { display: flex; align-items: center; gap: 10px; - padding: 10px 12px; + padding: var(--item-pad-y) 12px; border-radius: var(--radius-md); cursor: pointer; transition: background var(--transition-fast); @@ -643,7 +898,7 @@ body { .detail-content { flex: 1; overflow-y: auto; - padding: 12px; + padding: var(--panel-pad); } .detail-placeholder { @@ -881,31 +1136,6 @@ body.resizing * { font-size: 36px; } -.empty-state .empty-hint { - display: inline-flex; - align-items: center; - gap: 4px; - margin-top: 12px; - padding: 6px 12px; - border-radius: var(--radius-full); - background: var(--color-surface-alt); - border: 1px solid var(--color-border); - font-size: 11px; - color: var(--color-text-muted); -} - -.empty-state .empty-hint kbd { - display: inline-block; - padding: 1px 5px; - border: 1px solid var(--color-border-strong); - border-radius: 3px; - background: var(--color-surface); - font-family: var(--font-mono); - font-size: 10px; - color: var(--color-text-secondary); - box-shadow: var(--shadow-sm); -} - /* ================================================ Scrollbar Styling ================================================ */ @@ -928,28 +1158,543 @@ body.resizing * { } /* ================================================ - Responsive - Narrow DevTools + Inline SVG Icons ================================================ */ -@media (max-width: 600px) { - .sidebar { - width: 180px; - min-width: 150px; - } +.icon { + width: 14px; + height: 14px; + flex-shrink: 0; + vertical-align: -2px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} - .detail-panel { - width: 280px; - min-width: 200px; - } +.empty-icon .icon { + width: 42px; + height: 42px; + stroke-width: 1.2; +} - .filter-input { - width: 120px; - } +.panel-title .icon, +.sidebar-title .icon { + margin-right: 4px; +} - .panel-toolbar { - display: none; - } +/* ================================================ + Header Buttons & Settings Popover + ================================================ */ +.btn-header { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-header:hover, +.btn-header[aria-expanded="true"] { + background: var(--color-surface-hover); + color: var(--color-text-primary); +} + +.settings-wrap { + position: relative; +} + +.settings-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 100; + width: 220px; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.settings-popover.hidden { + display: none; +} + +.settings-group+.settings-group { + margin-top: 12px; +} + +.settings-group-label { + display: block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-muted); + margin-bottom: 6px; +} + +.settings-options { + display: flex; + gap: 4px; +} + +.settings-option { + flex: 1; +} + +.settings-option input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.settings-option span { + display: block; + padding: 5px 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + text-align: center; + font-size: 11px; + font-weight: 500; + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.settings-option input:checked+span { + background: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-text-inverse); +} + +.settings-option input:focus-visible+span { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} - .resize-handle { - width: 3px; +/* ================================================ + Panel Tabs (Requests / Schema) + ================================================ */ +.panel-tabs { + display: flex; + gap: 2px; + padding: 2px; + border-radius: var(--radius-md); + background: var(--color-surface-alt); + flex-shrink: 0; +} + +.panel-tab { + padding: 4px 12px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); +} + +.panel-tab[aria-selected="true"] { + background: var(--color-surface); + color: var(--color-text-primary); + box-shadow: var(--shadow-sm); +} + +.panel-tab:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ================================================ + Schema Viewer + ================================================ */ +#schema-content { + min-height: 0; +} + +#schema-content.hidden { + display: none; +} + +.schema-explorer { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +.schema-explorer.hidden { + display: none; +} + +.schema-body { + flex: 1; + display: flex; + overflow: hidden; + min-height: 0; +} + +.schema-type-list { + width: 220px; + min-width: 140px; + flex-shrink: 0; + overflow-y: auto; + padding: 4px 0; +} + +.schema-type-item { + display: flex; + align-items: baseline; + gap: 6px; + width: 100%; + padding: 4px 10px; + border: none; + background: transparent; + text-align: left; + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-text-secondary); + cursor: pointer; + min-width: 0; +} + +.schema-type-item:hover { + background: var(--color-surface-hover); + color: var(--color-text-primary); +} + +.schema-type-item.selected { + background: var(--color-surface-alt); + color: var(--color-text-primary); + font-weight: 600; +} + +.schema-type-item .kind { + flex: 0 0 6em; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.schema-type-item .name { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schema-type-list-empty { + padding: 16px 10px; + font-size: 11px; + color: var(--color-text-muted); + text-align: center; +} + +.schema-types { + flex: 1; + overflow-y: auto; + padding: var(--panel-pad); +} + +#schema-sdl-view { + flex: 1; + min-width: 0; + margin: 0; + padding: 12px var(--panel-pad); + overflow: auto; + border: none; + border-radius: 0; + background: transparent; +} + +.schema-sdl { + margin: 0; + padding: 12px; + border-radius: var(--radius-md); + background: var(--color-surface-alt); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.55; + color: var(--color-text-primary); + white-space: pre-wrap; + word-break: break-word; +} + +.schema-sdl .keyword { + color: #C678DD; +} + +.schema-sdl .builtin { + color: #E5C07B; +} + +.schema-sdl .type-name { + color: #E5C07B; + font-weight: 500; +} + +.schema-sdl .field { + color: #61AFEF; +} + +.schema-sdl .directive { + color: #C678DD; + font-style: italic; +} + +.schema-sdl .string { + color: #98C379; +} + +.schema-sdl .comment { + color: #5C6370; + font-style: italic; +} + +.schema-sdl .enum-value { + color: #D19A66; +} + +.schema-sdl .punct { + color: #ABB2BF; +} + +.schema-meta { + padding: 6px var(--panel-pad); + font-size: 11px; + color: var(--color-text-muted); + border-bottom: 1px solid var(--color-border); + word-break: break-all; +} + +.schema-empty { + padding: 24px; + text-align: center; + font-size: 12px; + color: var(--color-text-muted); +} + +/* ================================================ + Responsive Layout (class-driven, set by layout.js) + ================================================ */ +.main-content { + position: relative; +} + +/* Manual sidebar collapse (any width) */ +.app.sidebar-collapsed .sidebar, +.app.sidebar-collapsed #resize-handle-left { + display: none; +} + +/* Narrow: detail panel becomes an overlay */ +.app.narrow .detail-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 20; + width: min(85%, 360px); + border-left: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); +} + +.app.narrow #resize-handle-right { + display: none; +} + +.app.narrow .request-panel { + min-width: 0; +} + +.app.narrow .schema-body { + flex-direction: column; +} + +.app.narrow .schema-type-list { + width: auto !important; + min-width: 0; + max-height: 38%; + flex-shrink: 0; + border-bottom: 1px solid var(--color-border); +} + +.app.narrow #resize-handle-schema { + display: none; +} + +.app.narrow #schema-sdl-view { + flex: 1; + min-height: 0; +} + +/* Very narrow: sidebar becomes a slide-in drawer */ +.app.very-narrow .sidebar { + position: absolute; + top: 0; + left: 0; + bottom: 0; + z-index: 30; + width: min(85%, 260px); + border-right: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); + transform: translateX(-105%); + transition: transform var(--transition-normal); +} + +.app.very-narrow.sidebar-open .sidebar { + transform: none; +} + +.app.very-narrow #resize-handle-left { + display: none; +} + +.app.very-narrow .filter-input { + width: 110px; +} + +.app.very-narrow .header-title { + display: none; +} + +/* ================================================ + Detail actions + ================================================ */ +.detail-actions { + display: flex; + gap: 8px; +} + +/* ================================================ + Modal (Copy as cURL) + ================================================ */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.modal.hidden { + display: none; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); +} + +.modal-panel { + position: relative; + z-index: 1; + width: min(560px, 100%); + max-height: min(80vh, 520px); + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--color-border); +} + +.modal-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.modal-desc { + padding: 10px 14px 0; + font-size: 12px; + color: var(--color-text-muted); +} + +.modal-options { + display: flex; + gap: 6px; + padding: 10px 14px; +} + +.curl-shell-option { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-alt); + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); +} + +.curl-shell-option.active, +.curl-shell-option[aria-pressed="true"] { + background: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-text-inverse); +} + +.modal-preview { + margin: 0 14px; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-alt); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + color: var(--color-text-primary); + white-space: pre-wrap; + word-break: break-all; + overflow: auto; + max-height: 240px; + flex: 1; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + padding: 12px 14px; + border-top: 1px solid var(--color-border); +} + +/* ================================================ + Reduced Motion + ================================================ */ +@media (prefers-reduced-motion: reduce) { + + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; } } \ No newline at end of file diff --git a/frontend/devtools.html b/frontend/devtools.html index b31c8bc..db59dbd 100644 --- a/frontend/devtools.html +++ b/frontend/devtools.html @@ -13,6 +13,13 @@ @@ -36,7 +87,10 @@ - -
-
-

📊 Intercepted Requests

-
+ +
+
+
+ + +
+
+
+
-
+
+
+
+ +

No requests intercepted yet

+

Start debugging to capture GraphQL requests

+
+
-
-
- -

No requests intercepted yet

-

Start debugging to capture GraphQL requests

+
@@ -107,7 +232,7 @@

📊 Intercepted Requests

diff --git a/js/shared/constants.js b/js/shared/constants.js new file mode 100644 index 0000000..d67d314 --- /dev/null +++ b/js/shared/constants.js @@ -0,0 +1,128 @@ +/** + * Shared constants used across service-worker and UI bundles. + * @module shared/constants + */ + +// ── Interceptor ─────────────────────────────────────────────────── + +export const BOGUS_HASH_SEED = '1234567890'; +export const BASE64_CHUNK_SIZE = 8192; +export const DEFAULT_OPERATION_NAME = 'Anonymous Query'; + +// ── Hash Registry / Passive Mode ────────────────────────────────── + +export const HASH_REGISTRY_STORAGE_KEY = 'apqHashRegistry'; +export const PASSIVE_MODE_STORAGE_KEY = 'apqPassiveMode'; +/** Maximum hashes retained in the registry; least recently registered evicted first. */ +export const HASH_REGISTRY_MAX_ENTRIES = 500; + +// ── Schema ──────────────────────────────────────────────────────── + +export const OBSERVED_ENDPOINTS_STORAGE_KEY = 'apqObservedEndpoints'; +export const SCHEMA_STORAGE_KEY = 'apqSchemaUrl'; +export const SCHEMA_CACHE_STORAGE_KEY = 'apqSchemaCache'; +/** Maximum SDL size (bytes) persisted to chrome.storage.local. */ +export const SCHEMA_CACHE_MAX_BYTES = 2 * 1024 * 1024; +/** Common GraphQL endpoint paths probed during auto-detection. */ +export const COMMON_GRAPHQL_PATHS = [ + '/graphql', + '/api/graphql', + '/gql', + '/api/gql', + '/query', + '/v1/graphql', + '/graphql/v1', +]; + +/** Timeout for a single endpoint probe request (ms). */ +export const SCHEMA_PROBE_TIMEOUT_MS = 4000; +/** Maximum number of candidate endpoints probed per detection run. */ +export const SCHEMA_MAX_CANDIDATES = 10; +/** + * Standard GraphQL introspection query (classic graphql-js form, compatible + * with {@link module:shared/introspection-to-sdl}). Kept as a string literal + * so bundles do not need the `graphql` npm package. + */ +export const INTROSPECTION_QUERY = ` + query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + types { ...FullType } + directives { + name + description + locations + args { ...InputValue } + } + } + } + + fragment FullType on __Type { + kind + name + description + fields(includeDeprecated: true) { + name + description + args { ...InputValue } + type { ...TypeRef } + isDeprecated + deprecationReason + } + inputFields { ...InputValue } + interfaces { ...TypeRef } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + deprecationReason + } + possibleTypes { ...TypeRef } + } + + fragment InputValue on __InputValue { + name + description + type { ...TypeRef } + defaultValue + } + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + } + } + } + } +`; + +// ── UI Settings ─────────────────────────────────────────────────── +export const UI_SETTINGS_STORAGE_KEY = 'apqUiSettings'; +export const PANEL_WIDTHS_STORAGE_KEY = 'apqPanelWidths'; diff --git a/js/shared/introspection-to-sdl.js b/js/shared/introspection-to-sdl.js new file mode 100644 index 0000000..a5e5540 --- /dev/null +++ b/js/shared/introspection-to-sdl.js @@ -0,0 +1,319 @@ +/** + * Convert GraphQL introspection JSON to Schema Definition Language (SDL). + * Lightweight replacement for graphql's buildClientSchema + printSchema. + * @module shared/introspection-to-sdl + */ + +const SPECIFIED_SCALARS = new Set(['String', 'Int', 'Float', 'Boolean', 'ID']); +const SPECIFIED_DIRECTIVES = new Set(['include', 'skip', 'deprecated', 'specifiedBy']); +const DEFAULT_DEPRECATION_REASON = 'No longer supported'; + +/** + * @param {string} name + * @returns {boolean} + */ +function isIntrospectionTypeName(name) { + return typeof name === 'string' && name.startsWith('__'); +} + +/** + * @param {{ name: string }} type + * @returns {boolean} + */ +function isDefinedType(type) { + return !SPECIFIED_SCALARS.has(type.name) && !isIntrospectionTypeName(type.name); +} + +/** + * @param {{ kind: string, name?: string, ofType?: object } | null | undefined} type + * @returns {string} + */ +export function printTypeRef(type) { + if (!type) return 'Unknown'; + switch (type.kind) { + case 'NON_NULL': + return `${printTypeRef(type.ofType)}!`; + case 'LIST': + return `[${printTypeRef(type.ofType)}]`; + default: + return type.name || 'Unknown'; + } +} + +/** + * @param {string | null | undefined} description + * @param {string} [indent] + * @param {boolean} [firstInBlock] + * @returns {string} + */ +function printDescription(description, indent = '', firstInBlock = true) { + if (!description || typeof description !== 'string') return ''; + + const prefix = indent && !firstInBlock ? `\n${indent}` : indent; + if (description.includes('\n')) { + const body = description + .split('\n') + .map((line) => indent + line) + .join('\n'); + return `${prefix}"""\n${body}\n${indent}"""\n`; + } + + return `${prefix}"""${description}"""\n`; +} + +/** + * @param {string | null | undefined} reason + * @returns {string} + */ +function printDeprecated(reason) { + if (reason == null) return ''; + if (reason === DEFAULT_DEPRECATION_REASON) return ' @deprecated'; + const escaped = reason.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return ` @deprecated(reason: "${escaped}")`; +} + +/** + * @param {{ name: string, description?: string | null, type: object, defaultValue?: string | null, isDeprecated?: boolean, deprecationReason?: string | null }} arg + * @returns {string} + */ +function printInputValue(arg) { + let decl = `${arg.name}: ${printTypeRef(arg.type)}`; + if (arg.defaultValue != null && arg.defaultValue !== '') { + decl += ` = ${arg.defaultValue}`; + } + return decl + printDeprecated(arg.deprecationReason); +} + +/** + * @param {object[]} args + * @param {string} [indentation] + * @returns {string} + */ +function printArgs(args, indentation = '') { + if (!args || args.length === 0) return ''; + + if (args.every((arg) => !arg.description)) { + return `(${args.map(printInputValue).join(', ')})`; + } + + return ( + '(\n' + + args + .map( + (arg, index) => + printDescription(arg.description, ` ${indentation}`, index === 0) + + ` ${indentation}${printInputValue(arg)}` + ) + .join('\n') + + `\n${indentation})` + ); +} + +/** + * @param {string[]} items + * @returns {string} + */ +function printBlock(items) { + return items.length !== 0 ? ` {\n${items.join('\n')}\n}` : ''; +} + +/** + * @param {{ name: string, description?: string | null, args?: object[], type: object, isDeprecated?: boolean, deprecationReason?: string | null }} field + * @param {number} index + * @returns {string} + */ +function printField(field, index) { + return ( + printDescription(field.description, ' ', index === 0) + + ` ${field.name}` + + printArgs(field.args || [], ' ') + + `: ${printTypeRef(field.type)}` + + printDeprecated(field.deprecationReason) + ); +} + +/** + * @param {{ name: string, description?: string | null, interfaces?: { name: string }[] }} type + * @returns {string} + */ +function printImplementedInterfaces(type) { + const interfaces = type.interfaces || []; + return interfaces.length ? ` implements ${interfaces.map((i) => i.name).join(' & ')}` : ''; +} + +/** + * @param {object} type + * @returns {string} + */ +function printScalar(type) { + let line = printDescription(type.description) + `scalar ${type.name}`; + if (type.specifiedByURL) { + const escaped = type.specifiedByURL.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + line += ` @specifiedBy(url: "${escaped}")`; + } + return line; +} + +/** + * @param {object} type + * @returns {string} + */ +function printObject(type) { + const fields = (type.fields || []).map(printField); + return ( + printDescription(type.description) + + `type ${type.name}` + + printImplementedInterfaces(type) + + printBlock(fields) + ); +} + +/** + * @param {object} type + * @returns {string} + */ +function printInterface(type) { + const fields = (type.fields || []).map(printField); + return ( + printDescription(type.description) + + `interface ${type.name}` + + printImplementedInterfaces(type) + + printBlock(fields) + ); +} + +/** + * @param {object} type + * @returns {string} + */ +function printUnion(type) { + const members = (type.possibleTypes || []).map((t) => t.name); + const possibleTypes = members.length ? ` = ${members.join(' | ')}` : ''; + return printDescription(type.description) + `union ${type.name}${possibleTypes}`; +} + +/** + * @param {object} type + * @returns {string} + */ +function printEnum(type) { + const values = (type.enumValues || []).map( + (value, index) => + printDescription(value.description, ' ', index === 0) + + ` ${value.name}` + + printDeprecated(value.deprecationReason) + ); + return printDescription(type.description) + `enum ${type.name}` + printBlock(values); +} + +/** + * @param {object} type + * @returns {string} + */ +function printInputObject(type) { + const fields = (type.inputFields || []).map( + (field, index) => + printDescription(field.description, ' ', index === 0) + ` ${printInputValue(field)}` + ); + let header = printDescription(type.description) + `input ${type.name}`; + if (type.isOneOf) header += ' @oneOf'; + return header + printBlock(fields); +} + +/** + * @param {object} type + * @returns {string} + */ +function printTypeDefinition(type) { + switch (type.kind) { + case 'SCALAR': + return printScalar(type); + case 'OBJECT': + return printObject(type); + case 'INTERFACE': + return printInterface(type); + case 'UNION': + return printUnion(type); + case 'ENUM': + return printEnum(type); + case 'INPUT_OBJECT': + return printInputObject(type); + default: + return ''; + } +} + +/** + * @param {object} schema + * @returns {string} + */ +function printSchemaDefinition(schema) { + const queryName = schema.queryType?.name; + const mutationName = schema.mutationType?.name; + const subscriptionName = schema.subscriptionType?.name; + + const commonNames = + (!queryName || queryName === 'Query') && + (!mutationName || mutationName === 'Mutation') && + (!subscriptionName || subscriptionName === 'Subscription'); + + if (commonNames) return ''; + + const operationTypes = []; + if (queryName) operationTypes.push(` query: ${queryName}`); + if (mutationName) operationTypes.push(` mutation: ${mutationName}`); + if (subscriptionName) operationTypes.push(` subscription: ${subscriptionName}`); + + return `schema {\n${operationTypes.join('\n')}\n}`; +} + +/** + * @param {object} directive + * @returns {string} + */ +function printDirective(directive) { + let line = + printDescription(directive.description) + + `directive @${directive.name}` + + printArgs(directive.args || []) + + printDeprecated(directive.deprecationReason); + if (directive.isRepeatable) line += ' repeatable'; + line += ` on ${(directive.locations || []).join(' | ')}`; + return line; +} + +/** + * Count user-visible types in an introspection result. + * @param {{ __schema?: { types?: { name: string }[] } }} introspection + * @returns {number} + */ +export function countIntrospectionTypes(introspection) { + const types = introspection?.__schema?.types; + if (!Array.isArray(types)) return 0; + return types.filter(isDefinedType).length; +} + +/** + * Convert introspection JSON (`{ __schema: ... }`) to SDL text. + * @param {{ __schema?: object }} introspection + * @returns {string} + */ +export function introspectionToSDL(introspection) { + const schema = introspection?.__schema; + if (!schema || typeof schema !== 'object') { + throw new Error('Invalid introspection result: missing __schema'); + } + + const types = Array.isArray(schema.types) ? schema.types : []; + const directives = Array.isArray(schema.directives) ? schema.directives : []; + + const parts = [ + printSchemaDefinition(schema), + ...directives + .filter((directive) => directive?.name && !SPECIFIED_DIRECTIVES.has(directive.name)) + .map(printDirective), + ...types.filter(isDefinedType).map(printTypeDefinition), + ].filter(Boolean); + + return parts.join('\n\n'); +} diff --git a/js/shared/logger.js b/js/shared/logger.js new file mode 100644 index 0000000..6cd7080 --- /dev/null +++ b/js/shared/logger.js @@ -0,0 +1,13 @@ +/** + * Minimal logging utility used by both service-worker and UI bundles. + * `info` and `debug` calls are stripped in production builds via the + * esbuild `pure` config in build-enhanced.js (line 78). + * @module shared/logger + */ + +export const log = { + debug: (...args) => console.debug(...args), + info: (...args) => console.info(...args), + warn: (...args) => console.warn(...args), + error: (...args) => console.error(...args), +}; diff --git a/js/sw/debugger-manager.js b/js/sw/debugger-manager.js index d7a0568..d18ac95 100644 --- a/js/sw/debugger-manager.js +++ b/js/sw/debugger-manager.js @@ -34,10 +34,6 @@ export function validateUrlPattern(pattern) { throw new Error('Invalid URL pattern: too long (max 1000 characters)'); } - if (trimmed.includes('>} */ +const observedEndpoints = new Map(); + +/** @type {Promise|null} */ +let loadPromise = null; + +// storage.session survives service worker restarts and is cleared when the +// browser closes (the right lifetime for per-tab observations); storage.local +// is the fallback for Chrome < 102. +function storageArea() { + return chrome.storage.session || chrome.storage.local; +} + +/** + * Load persisted observations into memory (first call only). + * Endpoints recorded before the load resolves are kept; stored entries are + * merged in behind them, so a fresh service worker never loses live traffic. + * @returns {Promise} + */ +export function ensureEndpointsLoaded() { + if (!loadPromise) { + loadPromise = new Promise((resolve) => { + try { + storageArea().get([OBSERVED_ENDPOINTS_STORAGE_KEY], (result) => { + if (chrome.runtime.lastError) { + log.error('Failed to load observed endpoints:', chrome.runtime.lastError); + } else { + mergeStored(result[OBSERVED_ENDPOINTS_STORAGE_KEY]); + } + resolve(); + }); + } catch (error) { + log.error('Failed to load observed endpoints:', error); + resolve(); + } + }); + } + return loadPromise; +} + +function mergeStored(stored) { + if (!stored || typeof stored !== 'object') return; + + for (const [key, entries] of Object.entries(stored)) { + const tabId = Number(key); + if (!Number.isInteger(tabId) || !Array.isArray(entries)) continue; + + let endpoints = observedEndpoints.get(tabId); + if (!endpoints) { + endpoints = new Map(); + observedEndpoints.set(tabId, endpoints); + } + for (const entry of entries) { + if (endpoints.size >= MAX_PER_TAB) break; + // Entries persisted before headers were tracked are plain URL strings + const url = typeof entry === 'string' ? entry : entry && entry.url; + if (!url || typeof url !== 'string' || endpoints.has(url)) continue; + const headers = Array.isArray(entry && entry.headers) ? entry.headers : []; + endpoints.set(url, { headers }); + } + } +} + +function persistEndpoints() { + const obj = {}; + for (const [tabId, endpoints] of observedEndpoints) { + obj[tabId] = Array.from(endpoints, ([url, data]) => ({ url, headers: data.headers })); + } + try { + storageArea().set({ [OBSERVED_ENDPOINTS_STORAGE_KEY]: obj }, () => { + if (chrome.runtime.lastError) { + log.error('Failed to persist observed endpoints:', chrome.runtime.lastError); + } + }); + } catch (error) { + log.error('Failed to persist observed endpoints:', error); + } +} + +/** + * Record a GraphQL endpoint URL observed on a tab. + * Strips query string/fragment so probes hit the bare endpoint. + * The first captured header set is kept; later captures only fill in + * headers for endpoints that were recorded without any. + * @param {number} tabId + * @param {string} url + * @param {{name: string, value: string}[]} [headers] - Captured request headers. + */ +export function recordEndpoint(tabId, url, headers = []) { + if (typeof tabId !== 'number' || !url || typeof url !== 'string') return; + + let normalized; + try { + const parsed = new URL(url); + normalized = parsed.origin + parsed.pathname; + } catch (_) { + return; + } + + let endpoints = observedEndpoints.get(tabId); + if (!endpoints) { + endpoints = new Map(); + observedEndpoints.set(tabId, endpoints); + } + + const headerList = Array.isArray(headers) ? headers : []; + const existing = endpoints.get(normalized); + if (existing) { + // Persist again only when this capture adds headers we didn't have + if (existing.headers.length > 0 || headerList.length === 0) return; + existing.headers = headerList; + } else { + if (endpoints.size >= MAX_PER_TAB) return; + endpoints.set(normalized, { headers: headerList }); + } + + // Persist only after the stored snapshot has been merged in, so a write + // from a fresh service worker can't clobber earlier observations. + ensureEndpointsLoaded().then(persistEndpoints); +} + +/** + * Get the endpoint URLs observed on a tab, most useful first (insertion order). + * @param {number} tabId + * @returns {string[]} + */ +export function getObservedEndpoints(tabId) { + const endpoints = observedEndpoints.get(tabId); + return endpoints ? Array.from(endpoints.keys()) : []; +} + +/** + * Get the endpoints observed on a tab with their captured headers. + * Callers that must see observations from before a service worker restart + * should await {@link ensureEndpointsLoaded} first. + * @param {number} tabId + * @returns {{url: string, headers: {name: string, value: string}[]}[]} + */ +export function getObservedEndpointEntries(tabId) { + const endpoints = observedEndpoints.get(tabId); + if (!endpoints) return []; + return Array.from(endpoints, ([url, data]) => ({ url, headers: data.headers })); +} + +/** + * Forget endpoints recorded for a tab (e.g. when the tab closes). + * @param {number} tabId + */ +export function clearObservedEndpoints(tabId) { + observedEndpoints.delete(tabId); + ensureEndpointsLoaded().then(() => { + // The merge may have brought the tab's stored entries back — drop them too + observedEndpoints.delete(tabId); + persistEndpoints(); + }); +} diff --git a/js/sw/hash-registry.js b/js/sw/hash-registry.js new file mode 100644 index 0000000..78cd2bc --- /dev/null +++ b/js/sw/hash-registry.js @@ -0,0 +1,132 @@ +/** + * Hash registry: maps APQ hashes to their resolved queries. + * In passive mode, the registry resolves queries without contamination. + * @module sw/hash-registry + */ + +import { storageGet, storageSet } from './chrome-api.js'; +import { + HASH_REGISTRY_STORAGE_KEY, + HASH_REGISTRY_MAX_ENTRIES, + PASSIVE_MODE_STORAGE_KEY, +} from '../shared/constants.js'; +import { log } from '../shared/logger.js'; + +const registry = new Map(); +let passiveMode = false; + +export async function initRegistry() { + try { + const result = await storageGet([HASH_REGISTRY_STORAGE_KEY, PASSIVE_MODE_STORAGE_KEY]); + + const stored = result[HASH_REGISTRY_STORAGE_KEY]; + if (stored && typeof stored === 'object') { + for (const [hash, entry] of Object.entries(stored)) { + registry.set(hash, entry); + } + // Registries persisted before the size cap existed may exceed it + if (evictOldest()) { + persistRegistry(); + } + log.info(`Loaded ${registry.size} entries from hash registry`); + } + + passiveMode = !!result[PASSIVE_MODE_STORAGE_KEY]; + } catch (error) { + log.error('Failed to load hash registry:', error); + } +} + +export function registerHash(hash, data) { + if (!hash || typeof hash !== 'string') return; + const wasNew = !registry.has(hash); + // Delete before set: Map.set on an existing key keeps its original position, + // so re-registering must move the hash to the end to keep insertion order + // usable as recency order for eviction. + registry.delete(hash); + registry.set(hash, { + query: data.query || '', + operationName: data.operationName || '', + variables: data.variables || null, + registeredAt: Date.now(), + }); + evictOldest(); + persistRegistry(); + if (wasNew) broadcastRegistryUpdate(); +} + +export function lookupHash(hash) { + return registry.get(hash) || null; +} + +export function getRegistrySize() { + return registry.size; +} + +export function clearRegistry() { + registry.clear(); + persistRegistry(); + broadcastRegistryUpdate(); +} + +export function getRegistryEntries() { + const entries = {}; + for (const [hash, data] of registry) { + entries[hash] = data; + } + return entries; +} + +export function isPassiveMode() { + return passiveMode; +} + +export async function setPassiveMode(enabled) { + passiveMode = !!enabled; + try { + await storageSet({ [PASSIVE_MODE_STORAGE_KEY]: passiveMode }); + } catch (error) { + log.error('Failed to persist passive mode:', error); + } + broadcastRegistryUpdate(); +} + +/** + * Drop the least recently registered hashes until the registry fits the cap. + * @returns {boolean} True if any entries were evicted. + */ +function evictOldest() { + let evicted = false; + while (registry.size > HASH_REGISTRY_MAX_ENTRIES) { + registry.delete(registry.keys().next().value); + evicted = true; + } + return evicted; +} + +async function persistRegistry() { + try { + const obj = {}; + for (const [hash, data] of registry) { + obj[hash] = data; + } + await storageSet({ [HASH_REGISTRY_STORAGE_KEY]: obj }); + } catch (error) { + log.error('Failed to persist hash registry:', error); + } +} + +function broadcastRegistryUpdate() { + try { + chrome.runtime.sendMessage( + { status: 'REGISTRY_UPDATED', size: registry.size, passiveMode }, + () => { + if (chrome.runtime.lastError) { + // No listener — DevTools panel not open + } + } + ); + } catch (_) { + /* runtime not available */ + } +} diff --git a/js/sw/index.js b/js/sw/index.js index 1dc1924..fcf8ccb 100644 --- a/js/sw/index.js +++ b/js/sw/index.js @@ -8,9 +8,15 @@ import { attachedTabs, restoreDebuggerState } from './debugger-manager.js'; import { removeTabFromStorage, getStoredTabs } from './storage.js'; import { handleFetchRequestPaused } from './interceptor.js'; import { handleMessage, toggleDebuggerFromAction } from './messaging.js'; +import { initRegistry } from './hash-registry.js'; +import { clearObservedEndpoints } from './endpoint-tracker.js'; const ACTION_MENU_ID = 'apq-toggle-debugger'; +// Load the persisted hash registry and passive-mode flag on every SW start +// (MV3 service workers are terminated and restarted frequently). +initRegistry(); + // ── Global error handling ───────────────────────────────────────── self.addEventListener('unhandledrejection', (event) => { @@ -45,6 +51,7 @@ chrome.debugger.onDetach.addListener((source, reason) => { chrome.action.setBadgeBackgroundColor({ color: '#d9534f', tabId: source.tabId }); if (reason === 'target_closed') { + clearObservedEndpoints(source.tabId); console.log('The target tab was closed.'); } else if (reason === 'canceled_by_user') { console.log('The debugging session was manually detached by the user.'); diff --git a/js/sw/interceptor.js b/js/sw/interceptor.js index 2f3a803..03954a6 100644 --- a/js/sw/interceptor.js +++ b/js/sw/interceptor.js @@ -4,8 +4,51 @@ */ import { digestMessage } from './hash.js'; +import { isPassiveMode, lookupHash, registerHash } from './hash-registry.js'; +import { recordEndpoint } from './endpoint-tracker.js'; +import { BOGUS_HASH_SEED, BASE64_CHUNK_SIZE, DEFAULT_OPERATION_NAME } from '../shared/constants.js'; -/** Cached bogus hash to avoid recomputing SHA-256('1234567890') on every APQ request. */ +/** + * Check whether a parsed request body looks like a GraphQL operation. + * @param {*} body + * @returns {boolean} + */ +function looksLikeGraphQL(body) { + if (Array.isArray(body)) return body.some(looksLikeGraphQL); + if (!body || typeof body !== 'object') return false; + return body.query !== undefined || !!(body.extensions && body.extensions.persistedQuery); +} + +/** Headers set explicitly by the curl generators or derived from the URL. */ +const CURL_SKIP_HEADERS = new Set([ + 'content-length', + 'host', + 'connection', + 'accept-encoding', + 'content-type', + 'accept', +]); + +/** + * Normalize CDP Fetch request headers for replay in curl/PowerShell. + * @param {object|undefined} headers - CDP headers dictionary (name -> value). + * @returns {{name: string, value: string}[]} + */ +export function normalizeRequestHeaders(headers) { + if (!headers || typeof headers !== 'object') return []; + + const result = []; + for (const [name, value] of Object.entries(headers)) { + if (!name || value === undefined || value === null) continue; + if (CURL_SKIP_HEADERS.has(name.toLowerCase())) continue; + result.push({ name, value: String(value) }); + } + + result.sort((a, b) => a.name.localeCompare(b.name)); + return result; +} + +/** Cached bogus hash to avoid recomputing SHA-256 on every APQ request. */ let cachedBogusHash = null; /** @@ -14,63 +57,132 @@ let cachedBogusHash = null; */ async function getBogusHash() { if (!cachedBogusHash) { - cachedBogusHash = await digestMessage('1234567890'); + cachedBogusHash = await digestMessage(BOGUS_HASH_SEED); } return cachedBogusHash; } +/** + * Send an INTERCEPTED message to the DevTools panel. + * Silently ignores the (common) case where no panel is listening. + * @param {object} fields - Message fields merged over the defaults. + */ +function sendIntercepted(fields) { + chrome.runtime.sendMessage( + { + status: 'INTERCEPTED', + operationName: DEFAULT_OPERATION_NAME, + url: '', + query: '', + variables: null, + hash: '', + isAPQ: false, + ...fields, + }, + () => { + if (chrome.runtime.lastError) { + // DevTools panel for this tab might not be open — that's fine + } + } + ); +} + /** * Contaminate an APQ payload by replacing the persisted-query hash, * or capture a full-query payload and forward it to the DevTools panel. + * In passive mode, APQ hashes are resolved from the registry instead of + * being contaminated, leaving the request untouched. * @param {object} payload - Parsed request body (single GraphQL operation). * @param {string} requestUrl - The original request URL. * @param {number} tabId - The source tab ID. + * @param {{name: string, value: string}[]} [requestHeaders] - Captured HTTP headers. + * @returns {Promise} True if the payload was modified. */ -export async function contaminatePayload(payload, requestUrl, tabId) { +export async function contaminatePayload(payload, requestUrl, tabId, requestHeaders = []) { try { if (!payload || typeof payload !== 'object') { console.log('Invalid payload format:', payload); - return; + return false; } if (payload.query === undefined && payload.extensions && payload.extensions.persistedQuery) { - // APQ request without query — contaminate the hash + const originalHash = payload.extensions.persistedQuery.sha256Hash || ''; + + if (isPassiveMode()) { + // Passive mode — resolve the hash from the registry, leave the request untouched + const entry = lookupHash(originalHash); + console.info( + entry ? 'Resolved APQ hash from registry' : 'APQ hash not in registry (passive mode)' + ); + + sendIntercepted({ + operationName: payload.operationName || entry?.operationName || DEFAULT_OPERATION_NAME, + url: requestUrl || '', + query: entry?.query || '', + variables: payload.variables || entry?.variables || null, + hash: originalHash, + isAPQ: true, + tabId, + headers: requestHeaders, + }); + return false; + } + + // Active mode — contaminate the hash to force the full-query retry const hash = await getBogusHash(); if (hash) { payload.extensions.persistedQuery.sha256Hash = hash; console.info('Contaminated APQ request hash'); - } else { - console.error('Failed to generate hash for payload'); + + sendIntercepted({ + operationName: payload.operationName || DEFAULT_OPERATION_NAME, + url: requestUrl || '', + variables: payload.variables || null, + hash: originalHash, + isAPQ: true, + tabId, + headers: requestHeaders, + }); + return true; } + + console.error('Failed to generate hash for payload'); + return false; } else if (payload.query !== undefined) { - // Full query request — capture and send to DevTools + // Full query request — capture, register its hash, and send to DevTools console.log('Captured full query request:', payload.operationName); - chrome.runtime.sendMessage( - { - status: 'INTERCEPTED', - operationName: payload.operationName || 'Anonymous Query', - url: requestUrl || '', + const requestHash = payload.extensions?.persistedQuery?.sha256Hash || ''; + if (requestHash) { + registerHash(requestHash, { query: payload.query || '', + operationName: payload.operationName || '', variables: payload.variables || null, - isAPQ: false, - tabId: tabId, - }, - () => { - if (chrome.runtime.lastError) { - // DevTools panel for this tab might not be open — that's fine - } - } - ); + }); + } + + sendIntercepted({ + operationName: payload.operationName || DEFAULT_OPERATION_NAME, + url: requestUrl || '', + query: payload.query || '', + variables: payload.variables || null, + hash: requestHash, + isAPQ: false, + tabId, + headers: requestHeaders, + }); + return false; } else { try { console.log('Payload does not contain query or APQ extensions:', JSON.stringify(payload)); } catch (stringifyError) { console.log('Payload does not contain query or APQ extensions (could not stringify)'); } + return false; } } catch (error) { console.error('Error in contaminatePayload:', error); + return false; } } @@ -82,9 +194,8 @@ export async function contaminatePayload(payload, requestUrl, tabId) { */ function uint8ArrayToBase64(bytes) { let binary = ''; - const chunkSize = 8192; - for (let i = 0; i < bytes.length; i += chunkSize) { - binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)); + for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + BASE64_CHUNK_SIZE)); } return btoa(binary); } @@ -108,6 +219,7 @@ function continueRequest(tabId, requestId, postData) { /** * Handle a Fetch.requestPaused debugger event. * Parses the request body, contaminates APQ payloads, and continues the request. + * The request body is only rewritten when a payload was actually modified. * @param {{tabId: number}} source * @param {object} params - Fetch.requestPaused event params. */ @@ -131,15 +243,30 @@ export function handleFetchRequestPaused(source, params) { try { const requestUrl = params.request.url || ''; + const requestHeaders = normalizeRequestHeaders(params.request.headers); + let modified = false; + + // Remember GraphQL endpoints (with their headers) for schema loading + if (looksLikeGraphQL(reqBody)) { + recordEndpoint(source.tabId, requestUrl, requestHeaders); + } if (Array.isArray(reqBody)) { console.log('Request payload is an array'); for (const req of reqBody) { - await contaminatePayload(req, requestUrl, source.tabId); + modified = + (await contaminatePayload(req, requestUrl, source.tabId, requestHeaders)) || modified; } } else { console.log('Request payload is a JSON element'); - await contaminatePayload(reqBody, requestUrl, source.tabId); + modified = await contaminatePayload(reqBody, requestUrl, source.tabId, requestHeaders); + } + + if (!modified) { + // Nothing was changed (full query, passive mode, or unrecognized payload) + // — continue with the original body untouched + continueRequest(source.tabId, params.requestId); + return; } // Encode the modified request as base64 (chunked to avoid overflow) diff --git a/js/sw/messaging.js b/js/sw/messaging.js index aeb4c27..3715f03 100644 --- a/js/sw/messaging.js +++ b/js/sw/messaging.js @@ -12,6 +12,8 @@ import { attachDebuggerToTab, detachDebuggerFromTab, } from './debugger-manager.js'; +import { getRegistrySize, clearRegistry, isPassiveMode, setPassiveMode } from './hash-registry.js'; +import { loadSchema } from './schema-loader.js'; /** * Broadcast a status update to all listeners (DevTools panel, popup, etc.). @@ -132,6 +134,25 @@ function validateMessage(message) { return '"getStatus" must be a boolean'; } + // Validate registry message shapes + if (message.getRegistry !== undefined && typeof message.getRegistry !== 'boolean') { + return '"getRegistry" must be a boolean'; + } + if (message.setPassiveMode !== undefined && typeof message.setPassiveMode !== 'boolean') { + return '"setPassiveMode" must be a boolean'; + } + if (message.clearRegistry !== undefined && typeof message.clearRegistry !== 'boolean') { + return '"clearRegistry" must be a boolean'; + } + + // Validate loadSchema message shape + if (message.loadSchema !== undefined && typeof message.loadSchema !== 'boolean') { + return '"loadSchema" must be a boolean'; + } + if (message.url !== undefined && typeof message.url !== 'string') { + return '"url" must be a string'; + } + // Validate tabId when present if (message.tabId !== undefined && typeof message.tabId !== 'number') { return '"tabId" must be a number'; @@ -146,7 +167,13 @@ function validateMessage(message) { */ export function handleMessage(message, sender, sendResponse) { // Ignore internal broadcast messages - if (message && (message.status === 'INTERCEPTED' || message.status === 'ACTION_TOGGLE')) { + if ( + message && + (message.status === 'INTERCEPTED' || + message.status === 'ACTION_TOGGLE' || + message.status === 'REGISTRY_UPDATED' || + message.status === 'SCHEMA_PROGRESS') + ) { return false; } @@ -172,6 +199,28 @@ export function handleMessage(message, sender, sendResponse) { await handleDisconnectMessage(message, sendResponse); } else if (message.getStatus === true) { await handleStatusMessage(message, sendResponse); + } else if (message.getRegistry === true) { + sendResponse({ + status: 'SUCCESS', + size: getRegistrySize(), + passiveMode: isPassiveMode(), + }); + } else if (message.setPassiveMode !== undefined) { + await setPassiveMode(message.setPassiveMode); + sendResponse({ + status: 'SUCCESS', + size: getRegistrySize(), + passiveMode: isPassiveMode(), + }); + } else if (message.clearRegistry === true) { + clearRegistry(); + sendResponse({ + status: 'SUCCESS', + size: 0, + passiveMode: isPassiveMode(), + }); + } else if (message.loadSchema === true) { + await handleLoadSchemaMessage(message, sendResponse); } else { sendResponse({ status: 'ERROR', error: 'Invalid message format or empty patterns' }); } @@ -258,6 +307,49 @@ async function handleDisconnectMessage(message, sendResponse) { } } +async function handleLoadSchemaMessage(message, sendResponse) { + const tabId = message.tabId; + if (tabId === undefined) { + sendResponse({ status: 'ERROR', error: 'No tabId provided for schema load' }); + return; + } + + let tabUrl = ''; + try { + const tab = await getTab(tabId); + tabUrl = tab.url || ''; + } catch (_) { + // Tab lookup failed — detection falls back to observed endpoints only + } + + const onProgress = (progressMessage) => { + chrome.runtime.sendMessage( + { status: 'SCHEMA_PROGRESS', message: progressMessage, tabId }, + () => { + if (chrome.runtime.lastError) { + // Panel may have closed mid-load + } + } + ); + }; + + try { + const result = await loadSchema({ + tabId, + tabUrl, + url: message.url ? message.url.trim() : undefined, + onProgress, + }); + sendResponse({ + status: 'SUCCESS', + endpoint: result.endpoint, + introspection: result.introspection, + }); + } catch (error) { + sendResponse({ status: 'ERROR', error: error.message || 'Schema load failed' }); + } +} + async function handleStatusMessage(message, sendResponse) { const queryTabId = message.tabId; if (queryTabId === undefined) { diff --git a/js/sw/schema-loader.js b/js/sw/schema-loader.js new file mode 100644 index 0000000..7dc1af5 --- /dev/null +++ b/js/sw/schema-loader.js @@ -0,0 +1,204 @@ +/** + * GraphQL schema loading with endpoint auto-detection. + * Endpoints observed by the interceptor are known to speak GraphQL, so they + * are introspected directly with their captured request headers (production + * gateways often reject bare requests). Only origin-based guesses at common + * GraphQL paths are probed first. + * Runs in the service worker, which has host permissions, + * so cross-origin fetches are allowed. + * @module sw/schema-loader + */ + +import { ensureEndpointsLoaded, getObservedEndpointEntries } from './endpoint-tracker.js'; +import { + COMMON_GRAPHQL_PATHS, + INTROSPECTION_QUERY, + SCHEMA_PROBE_TIMEOUT_MS, + SCHEMA_MAX_CANDIDATES, +} from '../shared/constants.js'; + +/** + * Build candidate endpoints from common GraphQL paths on the page's origin. + * @param {string} tabUrl - URL of the inspected page. + * @param {string[]} [excludeUrls] - URLs already tried (observed endpoints). + * @returns {string[]} + */ +export function buildOriginCandidates(tabUrl, excludeUrls = []) { + const candidates = []; + const seen = new Set(excludeUrls); + + try { + const parsed = new URL(tabUrl); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + for (const path of COMMON_GRAPHQL_PATHS) { + const url = parsed.origin + path; + if (!seen.has(url) && candidates.length < SCHEMA_MAX_CANDIDATES) { + seen.add(url); + candidates.push(url); + } + } + } + } catch (_) { + // Invalid tab URL (about:blank, empty, ...) — skip origin candidates + } + + return candidates; +} + +async function postJson(url, body, timeoutMs, headers = []) { + const requestHeaders = { 'Content-Type': 'application/json', Accept: 'application/json' }; + for (const header of headers) { + // Cookies ride along via credentials: 'include'; fetch forbids setting them + if (header && header.name && header.name.toLowerCase() !== 'cookie') { + requestHeaders[header.name] = header.value; + } + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: requestHeaders, + body: JSON.stringify(body), + signal: controller.signal, + }); + return await response.json(); + } finally { + clearTimeout(timer); + } +} + +/** + * Probe a URL to see whether it responds like a GraphQL endpoint. + * Qualifies on `data.__typename` or a GraphQL-shaped `errors` array + * (an endpoint that rejects the query but speaks GraphQL still counts). + * @param {string} url + * @returns {Promise} + */ +export async function probeEndpoint(url) { + try { + const json = await postJson(url, { query: '{__typename}' }, SCHEMA_PROBE_TIMEOUT_MS); + if (!json || typeof json !== 'object') return false; + + if (json.data && typeof json.data.__typename === 'string') return true; + if (Array.isArray(json.errors) && json.errors.length > 0 && json.errors[0].message) { + return true; + } + return false; + } catch (_) { + // Network error, timeout, non-JSON response — not a GraphQL endpoint + return false; + } +} + +/** + * Fetch the introspection result from a GraphQL endpoint. + * @param {string} url + * @param {{name: string, value: string}[]} [headers] - Captured request headers + * replayed so gateways that require client headers accept the request. + * @returns {Promise} The introspection data (`{ __schema: ... }`). + * @throws {Error} If the request fails or introspection is disabled. + */ +export async function fetchIntrospection(url, headers = []) { + let json; + try { + json = await postJson( + url, + { query: INTROSPECTION_QUERY }, + SCHEMA_PROBE_TIMEOUT_MS * 3, + headers + ); + } catch (error) { + throw new Error(`Failed to reach ${url}: ${error.message || 'network error'}`); + } + + if (json && json.data && json.data.__schema) { + return json.data; + } + + if (json && Array.isArray(json.errors) && json.errors.length > 0) { + const message = json.errors[0].message || ''; + if (/introspection/i.test(message)) { + throw new Error('Introspection is disabled on this endpoint'); + } + throw new Error(`Introspection failed: ${message || 'unknown GraphQL error'}`); + } + + throw new Error('Endpoint did not return a valid introspection result'); +} + +/** + * Detect a GraphQL endpoint for a tab and load its schema. + * Observed endpoints are introspected directly (they are known GraphQL + * endpoints); origin-based guesses are probed in parallel as a fallback. + * @param {object} options + * @param {number} options.tabId + * @param {string} options.tabUrl - URL of the inspected page. + * @param {string} [options.url] - Explicit endpoint (skips detection). + * @param {(message: string) => void} [options.onProgress] + * @returns {Promise<{endpoint: string, introspection: object}>} + * @throws {Error} If no endpoint is found or introspection fails. + */ +export async function loadSchema({ tabId, tabUrl, url, onProgress = () => {} }) { + if (url) { + onProgress(`Loading schema from ${url}...`); + const introspection = await fetchIntrospection(url); + return { endpoint: url, introspection }; + } + + // Observations usually live only in storage at this point: the loadSchema + // message tends to wake a fresh service worker with an empty in-memory map. + await ensureEndpointsLoaded(); + + const observed = getObservedEndpointEntries(tabId); + const observedErrors = []; + + for (const { url: endpoint, headers } of observed) { + onProgress(`Loading schema from captured endpoint ${endpoint}...`); + try { + const introspection = await fetchIntrospection(endpoint, headers); + return { endpoint, introspection }; + } catch (error) { + observedErrors.push(`${endpoint} — ${error.message}`); + } + } + + const candidates = buildOriginCandidates( + tabUrl, + observed.map((entry) => entry.url) + ); + + if (candidates.length === 0 && observedErrors.length === 0) { + throw new Error('No candidate endpoints. Enter the GraphQL endpoint URL manually.'); + } + + let endpoint = null; + if (candidates.length > 0) { + onProgress( + `Probing ${candidates.length} candidate endpoint${candidates.length > 1 ? 's' : ''}...` + ); + const results = await Promise.all(candidates.map((candidate) => probeEndpoint(candidate))); + endpoint = candidates.find((_, i) => results[i]); + } + + if (!endpoint) { + if (observedErrors.length > 0) { + // The captured endpoints are the real ones — report why they failed + // instead of pretending no endpoint exists. + throw new Error( + `Could not introspect the captured endpoint${observedErrors.length > 1 ? 's' : ''}: ` + + observedErrors.join('; ') + ); + } + throw new Error( + 'No GraphQL endpoint detected. Capture some traffic first or enter the URL manually.' + ); + } + + onProgress(`Endpoint found: ${endpoint}. Loading schema...`); + const introspection = await fetchIntrospection(endpoint); + return { endpoint, introspection }; +} diff --git a/js/ui/curl-copy.js b/js/ui/curl-copy.js new file mode 100644 index 0000000..2b6df3e --- /dev/null +++ b/js/ui/curl-copy.js @@ -0,0 +1,217 @@ +/** + * Generate cURL commands from intercepted GraphQL requests and show + * a modal to pick Bash vs PowerShell formatting. + * @module ui/curl-copy + */ + +import { getElement } from './dom-helpers.js'; + +/** @type {object|null} Request currently shown in the modal. */ +let activeRequest = null; + +/** + * Build the GraphQL POST body for a captured request. + * @param {object} request + * @returns {object} + */ +export function buildGraphQLBody(request) { + const body = {}; + + if (request.operationName) { + body.operationName = request.operationName; + } + + if (request.query) { + body.query = request.query; + } + + if (request.variables && Object.keys(request.variables).length > 0) { + body.variables = request.variables; + } + + if (request.hash) { + body.extensions = { + persistedQuery: { + version: 1, + sha256Hash: request.hash, + }, + }; + } + + return body; +} + +/** + * @param {object} request + * @returns {{name: string, value: string}[]} + */ +export function getReplayHeaders(request) { + if (!request || !Array.isArray(request.headers)) return []; + return request.headers.filter((h) => h && h.name && h.value); +} + +/** + * Escape a string for use inside single-quoted bash strings. + * @param {string} value + * @returns {string} + */ +function bashSingleQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +/** + * Escape a string for use inside single-quoted PowerShell strings. + * @param {string} value + * @returns {string} + */ +function psSingleQuote(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + +/** + * @param {string} url + * @param {object} body + * @param {{name: string, value: string}[]} [headers] + * @returns {string} + */ +export function toBashCurl(url, body, headers = []) { + const json = JSON.stringify(body); + const lines = [ + `curl ${bashSingleQuote(url)} \\`, + ` -X POST \\`, + ` -H ${bashSingleQuote('Content-Type: application/json')} \\`, + ` -H ${bashSingleQuote('Accept: application/json')} \\`, + ]; + + for (const header of headers) { + lines.push(` -H ${bashSingleQuote(`${header.name}: ${header.value}`)} \\`); + } + + lines.push(` --data-raw ${bashSingleQuote(json)}`); + return lines.join('\n'); +} + +/** + * @param {string} url + * @param {object} body + * @param {{name: string, value: string}[]} [headers] + * @returns {string} + */ +export function toPowerShellCurl(url, body, headers = []) { + const json = JSON.stringify(body); + const escapedUrl = String(url).replace(/"/g, '`"'); + const lines = [`$body = @'`, json, `'@`, ``]; + + if (headers.length > 0) { + lines.push(`$headers = @{`); + for (const header of headers) { + lines.push(` ${psSingleQuote(header.name)} = ${psSingleQuote(header.value)}`); + } + lines.push(`}`, ``); + } + + lines.push(`Invoke-RestMethod \``); + lines.push(` -Uri "${escapedUrl}" \``); + lines.push(` -Method Post \``); + if (headers.length > 0) { + lines.push(` -Headers $headers \``); + } + lines.push(` -ContentType "application/json" \``); + lines.push(` -Body $body`); + return lines.join('\n'); +} + +function getCurlText(shell, request) { + const url = request.url; + if (!url) return '# No URL available for this request'; + + const body = buildGraphQLBody(request); + const headers = getReplayHeaders(request); + if (shell === 'powershell') { + return toPowerShellCurl(url, body, headers); + } + return toBashCurl(url, body, headers); +} + +function closeModal() { + const modal = getElement('curl-modal'); + if (modal) modal.classList.add('hidden'); + activeRequest = null; +} + +function showPreview(shell) { + if (!activeRequest) return; + + const preview = getElement('curl-preview'); + const copyBtn = getElement('curl-copy-btn'); + if (!preview) return; + + document.querySelectorAll('.curl-shell-option').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.shell === shell); + btn.setAttribute('aria-pressed', String(btn.dataset.shell === shell)); + }); + + preview.textContent = getCurlText(shell, activeRequest); + if (copyBtn) copyBtn.textContent = 'Copy'; +} + +/** + * Open the cURL modal for a request. + * @param {object} request + */ +export function openCurlModal(request) { + if (!request || !request.url) return; + + activeRequest = request; + const modal = getElement('curl-modal'); + if (!modal) return; + + modal.classList.remove('hidden'); + showPreview('bash'); + + const firstOption = modal.querySelector('.curl-shell-option'); + if (firstOption) firstOption.focus(); +} + +/** + * Wire up modal controls (shell picker, copy, close). + */ +export function initCurlCopy() { + const modal = getElement('curl-modal'); + if (!modal) return; + + modal.querySelectorAll('.curl-shell-option').forEach((btn) => { + btn.addEventListener('click', () => showPreview(btn.dataset.shell)); + }); + + const copyBtn = getElement('curl-copy-btn'); + if (copyBtn) { + copyBtn.addEventListener('click', () => { + const preview = getElement('curl-preview'); + if (!preview) return; + + navigator.clipboard + .writeText(preview.textContent) + .then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = 'Copy'; + }, 1500); + }) + .catch((err) => console.error('Failed to copy cURL:', err)); + }); + } + + const closeBtn = getElement('curl-modal-close'); + if (closeBtn) closeBtn.addEventListener('click', closeModal); + + const backdrop = modal.querySelector('.modal-backdrop'); + if (backdrop) backdrop.addEventListener('click', closeModal); + + modal.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + closeModal(); + } + }); +} diff --git a/js/ui/index.js b/js/ui/index.js index e571baf..361a140 100644 --- a/js/ui/index.js +++ b/js/ui/index.js @@ -15,6 +15,15 @@ import { handleStartError, handleStopSuccess, } from './debugger-controls.js'; +import { initRegistryControls, updateRegistryUI } from './registry-controls.js'; +import { initSettings } from './settings.js'; +import { initResize } from './resize.js'; +import { initToolbar } from './toolbar.js'; +import { initLayout } from './layout.js'; +import { initKeyboard } from './keyboard.js'; +import { initSchemaControls, updateSchemaProgress } from './schema-controls.js'; +import { initSchemaViewer } from './schema-viewer.js'; +import { initCurlCopy } from './curl-copy.js'; // ── Panel registration ──────────────────────────────────────────── @@ -35,6 +44,15 @@ document.addEventListener('DOMContentLoaded', function () { initRequestList(); initRequestDetail(); initDebuggerControls(); + initRegistryControls(); + initSettings(); + initResize(); + initToolbar(); + initLayout(); + initKeyboard(); + initSchemaViewer(); + initSchemaControls(); + initCurlCopy(); // ── Message listeners ───────────────────────────────────────── @@ -59,6 +77,7 @@ document.addEventListener('DOMContentLoaded', function () { query: message.query || '', variables: message.variables || null, responseTime: message.responseTime || null, + headers: message.headers || [], }); try { @@ -66,6 +85,10 @@ document.addEventListener('DOMContentLoaded', function () { } catch (_) { /* port may be closed */ } + } else if (message.status === 'REGISTRY_UPDATED') { + updateRegistryUI(message); + } else if (message.status === 'SCHEMA_PROGRESS') { + updateSchemaProgress(message.message || ''); } else if (message.status === 'ACTION_TOGGLE') { if (message.error) { handleStartError(message.error); diff --git a/js/ui/keyboard.js b/js/ui/keyboard.js new file mode 100644 index 0000000..4a02d93 --- /dev/null +++ b/js/ui/keyboard.js @@ -0,0 +1,180 @@ +/** + * Keyboard navigation: + * - Escape closes the detail panel + * - Arrow/Home/End navigation within the request list (roving focus) + * - Arrow/Home/End navigation within the schema type list (roving focus) + * - Arrow navigation within the filter chip radio group + * @module ui/keyboard + */ + +import { getElement } from './dom-helpers.js'; +import { state } from './state.js'; + +function isEditableTarget(target) { + if (!target) return false; + const tag = target.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable; +} + +function getRequestItems() { + return Array.from(document.querySelectorAll('#request-list .request-item')); +} + +function getSchemaTypeItems() { + return Array.from(document.querySelectorAll('#schema-type-list .schema-type-item')); +} + +/** + * @param {HTMLElement[]} items + * @param {number} index + */ +function focusItem(items, index) { + if (index < 0 || index >= items.length) return; + items.forEach((item, i) => item.setAttribute('tabindex', i === index ? '0' : '-1')); + items[index].focus(); +} + +function handleListKeydown(e) { + const items = getRequestItems(); + if (items.length === 0) return; + + const currentIndex = items.indexOf(document.activeElement); + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + focusItem(items, currentIndex < 0 ? 0 : Math.min(currentIndex + 1, items.length - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + focusItem(items, currentIndex < 0 ? 0 : Math.max(currentIndex - 1, 0)); + break; + case 'Home': + e.preventDefault(); + focusItem(items, 0); + break; + case 'End': + e.preventDefault(); + focusItem(items, items.length - 1); + break; + case 'Enter': + case ' ': + if (currentIndex >= 0) { + e.preventDefault(); + items[currentIndex].click(); + } + break; + } +} + +function handleSchemaListKeydown(e) { + const items = getSchemaTypeItems(); + if (items.length === 0) return; + + const currentIndex = items.indexOf(document.activeElement); + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + focusSchemaItem(items, currentIndex < 0 ? 0 : Math.min(currentIndex + 1, items.length - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + focusSchemaItem(items, currentIndex < 0 ? 0 : Math.max(currentIndex - 1, 0)); + break; + case 'Home': + e.preventDefault(); + focusSchemaItem(items, 0); + break; + case 'End': + e.preventDefault(); + focusSchemaItem(items, items.length - 1); + break; + case 'Enter': + case ' ': + if (currentIndex >= 0) { + e.preventDefault(); + items[currentIndex].click(); + } + break; + } +} + +/** + * Focus a schema type option and update the SDL panel (single-select listbox). + * @param {HTMLElement[]} items + * @param {number} index + */ +function focusSchemaItem(items, index) { + if (index < 0 || index >= items.length) return; + focusItem(items, index); + items[index].click(); + items[index].scrollIntoView?.({ block: 'nearest' }); +} + +function handleChipKeydown(e) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + + const chips = Array.from(document.querySelectorAll('.filter-chips .chip')); + const currentIndex = chips.indexOf(document.activeElement); + if (currentIndex < 0) return; + + e.preventDefault(); + const delta = e.key === 'ArrowRight' ? 1 : -1; + const next = (currentIndex + delta + chips.length) % chips.length; + chips[next].focus(); + chips[next].click(); +} + +/** + * Wire up global and per-region keyboard handling. + */ +export function initKeyboard() { + const requestList = getElement('request-list'); + if (requestList) { + requestList.addEventListener('keydown', handleListKeydown); + + // Tabbing into the list focuses the selected (or first) item + requestList.addEventListener('focus', () => { + const items = getRequestItems(); + if (items.length === 0) return; + const selectedIndex = items.findIndex( + (item) => parseInt(item.dataset.requestId) === state.selectedRequestId + ); + focusItem(items, selectedIndex >= 0 ? selectedIndex : 0); + }); + } + + const schemaTypeList = getElement('schema-type-list'); + if (schemaTypeList) { + schemaTypeList.addEventListener('keydown', handleSchemaListKeydown); + + schemaTypeList.addEventListener('focus', () => { + const items = getSchemaTypeItems(); + if (items.length === 0) return; + const selectedIndex = items.findIndex( + (item) => item.getAttribute('aria-selected') === 'true' + ); + focusItem(items, selectedIndex >= 0 ? selectedIndex : 0); + }); + } + + const chipGroup = document.querySelector('.filter-chips'); + if (chipGroup) { + chipGroup.addEventListener('keydown', handleChipKeydown); + } + + document.addEventListener('keydown', (e) => { + // Escape: close the detail panel (unless typing in a field) + if (e.key === 'Escape' && !isEditableTarget(e.target)) { + const detailPanel = getElement('detail-panel'); + if (detailPanel && !detailPanel.classList.contains('hidden')) { + const closeBtn = getElement('close-detail'); + if (closeBtn) closeBtn.click(); + + const list = getElement('request-list'); + if (list) list.focus(); + } + } + }); +} diff --git a/js/ui/layout.js b/js/ui/layout.js new file mode 100644 index 0000000..a026b03 --- /dev/null +++ b/js/ui/layout.js @@ -0,0 +1,68 @@ +/** + * Responsive layout management: width-driven panel collapse and the + * manual sidebar toggle. Class changes are picked up by devtools.css + * (.app.narrow, .app.very-narrow, .app.sidebar-collapsed, .sidebar-open). + * @module ui/layout + */ + +import { getElement } from './dom-helpers.js'; + +const NARROW_PX = 560; +const VERY_NARROW_PX = 400; + +function applyBreakpoints(app, width) { + app.classList.toggle('narrow', width < NARROW_PX); + const veryNarrow = width < VERY_NARROW_PX; + const wasVeryNarrow = app.classList.contains('very-narrow'); + app.classList.toggle('very-narrow', veryNarrow); + + // Close the drawer when entering very-narrow so it doesn't cover content + if (veryNarrow && !wasVeryNarrow) { + app.classList.remove('sidebar-open'); + syncToggleButton(app); + } +} + +function syncToggleButton(app) { + const button = document.getElementById('btn-toggle-sidebar'); + if (!button) return; + + const veryNarrow = app.classList.contains('very-narrow'); + const visible = veryNarrow + ? app.classList.contains('sidebar-open') + : !app.classList.contains('sidebar-collapsed'); + button.setAttribute('aria-expanded', String(visible)); +} + +/** + * Initialise responsive breakpoints and the sidebar toggle button. + */ +export function initLayout() { + const app = document.querySelector('.app'); + if (!app) return; + + const toggleButton = getElement('btn-toggle-sidebar'); + if (toggleButton) { + toggleButton.addEventListener('click', () => { + if (app.classList.contains('very-narrow')) { + app.classList.toggle('sidebar-open'); + } else { + app.classList.toggle('sidebar-collapsed'); + } + syncToggleButton(app); + }); + } + + applyBreakpoints(app, window.innerWidth); + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + applyBreakpoints(app, entry.contentRect.width); + } + }); + observer.observe(app); + } else { + window.addEventListener('resize', () => applyBreakpoints(app, window.innerWidth)); + } +} diff --git a/js/ui/registry-controls.js b/js/ui/registry-controls.js new file mode 100644 index 0000000..bf31daf --- /dev/null +++ b/js/ui/registry-controls.js @@ -0,0 +1,81 @@ +/** + * Passive mode toggle and hash registry controls in the sidebar. + * @module ui/registry-controls + */ + +import { getElement } from './dom-helpers.js'; +import { showStatusBanner } from './status.js'; + +/** + * Update the registry UI (size counter, clear button, passive-mode toggle) + * from a service-worker payload. + * @param {{size?: number, passiveMode?: boolean}} info + */ +export function updateRegistryUI(info) { + if (!info || typeof info !== 'object') return; + + if (info.size !== undefined) { + const sizeEl = getElement('registry-size'); + if (sizeEl) sizeEl.textContent = info.size; + + const clearBtn = getElement('btn-clear-registry'); + if (clearBtn) clearBtn.disabled = info.size === 0; + } + + if (info.passiveMode !== undefined) { + const toggle = getElement('passive-mode-toggle'); + if (toggle) toggle.checked = info.passiveMode; + } +} + +/** + * Wire up the passive-mode toggle and clear-registry button, + * and fetch the initial registry state from the service worker. + */ +export function initRegistryControls() { + const toggle = getElement('passive-mode-toggle'); + if (toggle) { + toggle.addEventListener('change', () => { + const enabled = toggle.checked; + chrome.runtime.sendMessage({ setPassiveMode: enabled }, (response) => { + if (chrome.runtime.lastError || !response || response.status !== 'SUCCESS') { + toggle.checked = !enabled; + showStatusBanner('Failed to update passive mode', 'error'); + return; + } + updateRegistryUI(response); + showStatusBanner( + enabled + ? 'Passive mode on — requests will not be modified' + : 'Passive mode off — APQ hashes will be contaminated', + 'success' + ); + }); + }); + } + + const clearBtn = getElement('btn-clear-registry'); + if (clearBtn) { + clearBtn.addEventListener('click', () => { + chrome.runtime.sendMessage({ clearRegistry: true }, (response) => { + if (chrome.runtime.lastError || !response || response.status !== 'SUCCESS') { + showStatusBanner('Failed to clear hash registry', 'error'); + return; + } + updateRegistryUI(response); + showStatusBanner('Hash registry cleared', 'success'); + }); + }); + } + + // Fetch initial registry state + chrome.runtime.sendMessage({ getRegistry: true }, (response) => { + if (chrome.runtime.lastError) { + console.warn('Failed to get registry state:', chrome.runtime.lastError); + return; + } + if (response && response.status === 'SUCCESS') { + updateRegistryUI(response); + } + }); +} diff --git a/js/ui/request-detail.js b/js/ui/request-detail.js index 28bf952..9590908 100644 --- a/js/ui/request-detail.js +++ b/js/ui/request-detail.js @@ -5,6 +5,7 @@ import { getElement, escapeHtml } from './dom-helpers.js'; import { state } from './state.js'; +import { openCurlModal } from './curl-copy.js'; /** * Mark a request as selected and show the detail panel. @@ -13,9 +14,11 @@ import { state } from './state.js'; export function selectRequest(requestId) { state.selectedRequestId = requestId; - // Update visual selection in the list + // Update visual + ARIA selection in the list document.querySelectorAll('.request-item').forEach((item) => { - item.classList.toggle('selected', parseInt(item.dataset.requestId) === requestId); + const isSelected = parseInt(item.dataset.requestId) === requestId; + item.classList.toggle('selected', isSelected); + item.setAttribute('aria-selected', String(isSelected)); }); const detailPanel = getElement('detail-panel'); @@ -70,6 +73,18 @@ export function renderRequestDetail(requestId) {
URL
${escapeHtml(request.url)}
+ + ${ + request.url + ? ` +
+ +
+ ` + : '' + }
@@ -110,6 +125,11 @@ export function renderRequestDetail(requestId) { } }); }); + + const curlBtn = detailContent.querySelector('#btn-copy-curl'); + if (curlBtn) { + curlBtn.addEventListener('click', () => openCurlModal(request)); + } } /** @@ -154,9 +174,10 @@ export function initRequestDetail() { const detailPanel = getElement('detail-panel'); if (detailPanel) detailPanel.classList.add('hidden'); state.selectedRequestId = null; - document - .querySelectorAll('.request-item.selected') - .forEach((el) => el.classList.remove('selected')); + document.querySelectorAll('.request-item.selected').forEach((el) => { + el.classList.remove('selected'); + el.setAttribute('aria-selected', 'false'); + }); }); } } diff --git a/js/ui/request-list.js b/js/ui/request-list.js index b7162e1..844fc7e 100644 --- a/js/ui/request-list.js +++ b/js/ui/request-list.js @@ -6,6 +6,14 @@ import { getElement, escapeHtml, truncateUrl } from './dom-helpers.js'; import { state, MAX_HISTORY } from './state.js'; import { selectRequest } from './request-detail.js'; +import { updateToolbarState } from './toolbar.js'; + +function prefersReducedMotion() { + return ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} /** * Animate the request counter badge with the current history size. @@ -14,10 +22,12 @@ export function updateRequestCount() { const counter = getElement('interception-counter'); if (counter) { counter.textContent = state.requestHistory.length; - counter.style.transform = 'scale(1.2)'; - setTimeout(() => { - counter.style.transform = 'scale(1)'; - }, 200); + if (!prefersReducedMotion()) { + counter.style.transform = 'scale(1.2)'; + setTimeout(() => { + counter.style.transform = 'scale(1)'; + }, 200); + } } } @@ -38,6 +48,7 @@ export function addRequest(requestData) { query: requestData.query || '', variables: requestData.variables || null, responseTime: requestData.responseTime || null, + headers: requestData.headers || [], }; state.requestHistory.unshift(request); @@ -45,6 +56,7 @@ export function addRequest(requestData) { state.requestHistory.length = MAX_HISTORY; } updateRequestCount(); + updateToolbarState(); // Incremental update: prepend the new item if it passes the current filter, // instead of clearing and re-rendering the entire list. @@ -108,19 +120,7 @@ function prependRequestItem(request) { * @returns {object[]} */ export function getFilteredRequests() { - return state.requestHistory.filter((req) => { - if (state.activeFilter !== 'all' && req.type !== state.activeFilter) { - return false; - } - if (state.filterText) { - const searchText = state.filterText.toLowerCase(); - return ( - req.operationName.toLowerCase().includes(searchText) || - req.url.toLowerCase().includes(searchText) - ); - } - return true; - }); + return state.requestHistory.filter(requestPassesFilter); } /** @@ -157,9 +157,13 @@ export function renderRequestList() { function createRequestItem(request) { const item = document.createElement('div'); item.className = 'request-item'; - if (request.id === state.selectedRequestId) { + item.setAttribute('role', 'option'); + item.setAttribute('tabindex', '-1'); + const isSelected = request.id === state.selectedRequestId; + if (isSelected) { item.classList.add('selected'); } + item.setAttribute('aria-selected', String(isSelected)); item.dataset.requestId = request.id; const timeStr = request.timestamp.toLocaleTimeString('en-US', { @@ -199,8 +203,12 @@ export function initRequestList() { document.querySelectorAll('.chip').forEach((chip) => { chip.addEventListener('click', () => { - document.querySelectorAll('.chip').forEach((c) => c.classList.remove('active')); + document.querySelectorAll('.chip').forEach((c) => { + c.classList.remove('active'); + c.setAttribute('aria-checked', 'false'); + }); chip.classList.add('active'); + chip.setAttribute('aria-checked', 'true'); state.activeFilter = chip.dataset.filter; renderRequestList(); }); diff --git a/js/ui/resize.js b/js/ui/resize.js new file mode 100644 index 0000000..358285d --- /dev/null +++ b/js/ui/resize.js @@ -0,0 +1,137 @@ +/** + * Draggable + keyboard-resizable panel widths with persistence. + * Wires the separator handles between sidebar / request panel, + * request panel / detail panel, and schema type list / SDL view. + * @module ui/resize + */ + +import { getElement } from './dom-helpers.js'; +import { PANEL_WIDTHS_STORAGE_KEY } from '../shared/constants.js'; + +const LIMITS = { + sidebar: { min: 150, max: 420 }, + detail: { min: 240, max: 560 }, + schemaList: { min: 140, max: 480 }, +}; + +const KEYBOARD_STEP = 16; + +let widths = { sidebar: null, detail: null }; +let saveTimer = null; + +function persistWidths() { + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + chrome.storage.local.set({ [PANEL_WIDTHS_STORAGE_KEY]: widths }, () => { + if (chrome.runtime.lastError) { + console.warn('Failed to save panel widths:', chrome.runtime.lastError); + } + }); + }, 300); +} + +function clamp(value, { min, max }) { + return Math.min(max, Math.max(min, value)); +} + +function applyWidth(panel, key, value) { + const clamped = clamp(value, LIMITS[key]); + panel.style.width = `${clamped}px`; + panel.style.minWidth = `${clamped}px`; + widths[key] = clamped; + return clamped; +} + +/** + * Wire one resize handle. + * @param {HTMLElement} handle - The separator element. + * @param {HTMLElement} panel - The panel whose width changes. + * @param {'sidebar'|'detail'|'schemaList'} key - Storage key / limits entry. + * @param {1|-1} direction - +1 if dragging right grows the panel. + */ +function wireHandle(handle, panel, key, direction) { + handle.setAttribute('aria-valuemin', LIMITS[key].min); + handle.setAttribute('aria-valuemax', LIMITS[key].max); + + const updateAria = (w) => handle.setAttribute('aria-valuenow', Math.round(w)); + + handle.addEventListener('pointerdown', (e) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = panel.getBoundingClientRect().width; + + handle.setPointerCapture(e.pointerId); + handle.classList.add('dragging'); + document.body.classList.add('resizing'); + + const onMove = (moveEvent) => { + const dx = moveEvent.clientX - startX; + const applied = applyWidth(panel, key, startWidth + direction * dx); + updateAria(applied); + }; + + const onUp = () => { + handle.removeEventListener('pointermove', onMove); + handle.removeEventListener('pointerup', onUp); + handle.removeEventListener('pointercancel', onUp); + handle.classList.remove('dragging'); + document.body.classList.remove('resizing'); + persistWidths(); + }; + + handle.addEventListener('pointermove', onMove); + handle.addEventListener('pointerup', onUp); + handle.addEventListener('pointercancel', onUp); + }); + + handle.addEventListener('keydown', (e) => { + let delta = 0; + if (e.key === 'ArrowLeft') delta = -KEYBOARD_STEP; + else if (e.key === 'ArrowRight') delta = KEYBOARD_STEP; + else return; + + e.preventDefault(); + const current = panel.getBoundingClientRect().width; + const applied = applyWidth(panel, key, current + direction * delta); + updateAria(applied); + persistWidths(); + }); +} + +/** + * Initialise resize handles and restore persisted widths. + */ +export function initResize() { + const sidebar = getElement('patterns-sidebar'); + const detailPanel = getElement('detail-panel'); + const leftHandle = getElement('resize-handle-left'); + const rightHandle = getElement('resize-handle-right'); + const schemaList = getElement('schema-type-list'); + const schemaHandle = getElement('resize-handle-schema'); + + if (sidebar && leftHandle) { + wireHandle(leftHandle, sidebar, 'sidebar', 1); + } + if (detailPanel && rightHandle) { + wireHandle(rightHandle, detailPanel, 'detail', -1); + } + if (schemaList && schemaHandle) { + wireHandle(schemaHandle, schemaList, 'schemaList', 1); + } + + chrome.storage.local.get([PANEL_WIDTHS_STORAGE_KEY], (result) => { + if (chrome.runtime.lastError) return; + const stored = result[PANEL_WIDTHS_STORAGE_KEY]; + if (!stored || typeof stored !== 'object') return; + + if (typeof stored.sidebar === 'number' && sidebar) { + applyWidth(sidebar, 'sidebar', stored.sidebar); + } + if (typeof stored.detail === 'number' && detailPanel) { + applyWidth(detailPanel, 'detail', stored.detail); + } + if (typeof stored.schemaList === 'number' && schemaList) { + applyWidth(schemaList, 'schemaList', stored.schemaList); + } + }); +} diff --git a/js/ui/schema-controls.js b/js/ui/schema-controls.js new file mode 100644 index 0000000..c66ec61 --- /dev/null +++ b/js/ui/schema-controls.js @@ -0,0 +1,181 @@ +/** + * Schema sidebar section: Load Schema button with endpoint auto-detection, + * progress display, and per-endpoint caching in chrome.storage.local. + * The service worker does the network work; this module converts the + * introspection result to SDL and feeds the viewer. + * @module ui/schema-controls + */ + +import { introspectionToSDL, countIntrospectionTypes } from '../shared/introspection-to-sdl.js'; +import { getElement } from './dom-helpers.js'; +import { inspectedTabId } from './state.js'; +import { setSchema, showMainTab, isSchemaLoaded } from './schema-viewer.js'; +import { + SCHEMA_STORAGE_KEY, + SCHEMA_CACHE_STORAGE_KEY, + SCHEMA_CACHE_MAX_BYTES, +} from '../shared/constants.js'; + +let isLoading = false; + +function setStatus(text, isError = false) { + const statusEl = getElement('schema-status'); + if (!statusEl) return; + statusEl.textContent = text; + statusEl.style.color = isError ? 'var(--color-error)' : ''; +} + +/** + * Show live progress broadcast by the service worker during detection. + * @param {string} message + */ +export function updateSchemaProgress(message) { + if (isLoading && message) { + setStatus(message); + } +} + +function updateClearButton() { + const clearBtn = getElement('btn-clear-schema'); + if (clearBtn) clearBtn.disabled = !isSchemaLoaded() || isLoading; +} + +function applySchema(sdl, meta) { + setSchema(sdl, meta); + const when = meta.fetchedAt ? new Date(meta.fetchedAt).toLocaleString() : ''; + setStatus(`${meta.typeCount} types loaded${when ? ` (${when})` : ''}`); + updateClearButton(); +} + +function cacheSchema(endpoint, sdl, typeCount, fetchedAt) { + if (sdl.length > SCHEMA_CACHE_MAX_BYTES) { + console.warn('Schema too large to cache, skipping persistence'); + return; + } + chrome.storage.local.set( + { + [SCHEMA_STORAGE_KEY]: endpoint, + [SCHEMA_CACHE_STORAGE_KEY]: { endpoint, sdl, typeCount, fetchedAt }, + }, + () => { + if (chrome.runtime.lastError) { + console.warn('Failed to cache schema:', chrome.runtime.lastError); + } + } + ); +} + +function handleLoadClick() { + if (isLoading) return; + + const input = getElement('schema-url-input'); + const button = getElement('btn-load-schema'); + const url = input ? input.value.trim() : ''; + + isLoading = true; + updateClearButton(); + if (button) { + button.disabled = true; + button.textContent = 'Loading...'; + } + setStatus(url ? `Loading schema from ${url}...` : 'Detecting GraphQL endpoint...'); + + const message = { loadSchema: true, tabId: inspectedTabId }; + if (url) message.url = url; + + chrome.runtime.sendMessage(message, (response) => { + isLoading = false; + updateClearButton(); + if (button) { + button.disabled = false; + button.textContent = 'Load Schema'; + } + + if (chrome.runtime.lastError) { + setStatus(`Failed: ${chrome.runtime.lastError.message}`, true); + return; + } + + if (!response || response.status !== 'SUCCESS') { + setStatus(response?.error || 'Schema load failed', true); + return; + } + + try { + const sdl = introspectionToSDL(response.introspection); + const typeCount = countIntrospectionTypes(response.introspection); + const fetchedAt = Date.now(); + + if (input) input.value = response.endpoint; + cacheSchema(response.endpoint, sdl, typeCount, fetchedAt); + applySchema(sdl, { endpoint: response.endpoint, typeCount, fetchedAt }); + showMainTab('schema'); + } catch (error) { + console.error('Failed to build schema:', error); + setStatus(`Invalid introspection result: ${error.message}`, true); + } + }); +} + +function handleClearClick() { + if (isLoading || !isSchemaLoaded()) return; + + setSchema(null); + setStatus('No schema loaded'); + updateClearButton(); + + chrome.storage.local.remove([SCHEMA_STORAGE_KEY, SCHEMA_CACHE_STORAGE_KEY], () => { + if (chrome.runtime.lastError) { + console.warn('Failed to clear cached schema:', chrome.runtime.lastError); + } + }); +} + +function restoreFromCache() { + chrome.storage.local.get([SCHEMA_STORAGE_KEY, SCHEMA_CACHE_STORAGE_KEY], (result) => { + if (chrome.runtime.lastError) return; + + const savedUrl = result[SCHEMA_STORAGE_KEY]; + const input = getElement('schema-url-input'); + if (input && typeof savedUrl === 'string' && savedUrl) { + input.value = savedUrl; + } + + const cache = result[SCHEMA_CACHE_STORAGE_KEY]; + if (cache && typeof cache === 'object' && cache.sdl && cache.endpoint) { + applySchema(cache.sdl, { + endpoint: cache.endpoint, + typeCount: cache.typeCount || 0, + fetchedAt: cache.fetchedAt, + }); + } + }); +} + +/** + * Wire up the Load Schema button and restore any cached schema. + */ +export function initSchemaControls() { + const button = getElement('btn-load-schema'); + if (button) { + button.addEventListener('click', handleLoadClick); + } + + const clearBtn = getElement('btn-clear-schema'); + if (clearBtn) { + clearBtn.addEventListener('click', handleClearClick); + } + + const input = getElement('schema-url-input'); + if (input) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleLoadClick(); + } + }); + } + + restoreFromCache(); + updateClearButton(); +} diff --git a/js/ui/schema-viewer.js b/js/ui/schema-viewer.js new file mode 100644 index 0000000..48b30bf --- /dev/null +++ b/js/ui/schema-viewer.js @@ -0,0 +1,364 @@ +/** + * Schema viewer: main-panel Schema tab with a searchable type list and + * lazy per-definition SDL rendering for large schemas. + * @module ui/schema-viewer + */ + +import { getElement, escapeHtml } from './dom-helpers.js'; +import { highlightSDL } from './sdl-highlighter.js'; + +/** @typedef {{ name: string, kind: string, text: string }} SchemaEntry */ + +/** @type {SchemaEntry[]} */ +let schemaEntries = []; + +/** @type {string | null} */ +let selectedName = null; + +/** @type {number} */ +let parseGeneration = 0; + +/** @type {ReturnType | null} */ +let searchDebounceTimer = null; + +const DEFINITION_KEYWORD = /^(type|input|enum|interface|union|scalar|schema|extend|directive)\s/; +const DEFINITION_NAME = + /^(?:(extend)\s+)?(type|input|enum|interface|union|scalar|schema|directive)\s+(\w+)/; + +function isBlankLine(line) { + return line.trim() === ''; +} + +function isDefinitionLine(line) { + return DEFINITION_KEYWORD.test(line.trim()) && !/^\s/.test(line); +} + +/** + * @param {string} line + * @param {boolean} inBlockString + * @returns {boolean} + */ +function advanceBlockString(line, inBlockString) { + const trimmed = line.trim(); + if (!inBlockString) { + if (!trimmed.startsWith('"""')) return false; + const closedOnSameLine = trimmed.endsWith('"""') && trimmed.length > 3; + return !closedOnSameLine; + } + if (trimmed.endsWith('"""')) return false; + return true; +} + +/** + * @param {string[]} lines + * @param {number} endIdx + * @returns {number} + */ +function descriptionStartIndex(lines, endIdx) { + let i = endIdx; + while (i >= 0 && isBlankLine(lines[i])) i--; + if (i < 0) return -1; + + const endLine = lines[i].trim(); + if (!endLine.endsWith('"""')) return -1; + + if (endLine.startsWith('"""') && endLine.length > 3) return i; + + // Multi-line block string: walk back past the closing delimiter. + i--; + while (i >= 0) { + if (lines[i].trim().startsWith('"""')) return i; + i--; + } + return -1; +} + +/** + * @param {string[]} lines + * @returns {number} + */ +function findSplitIndex(lines) { + let end = lines.length; + while (end > 0 && isBlankLine(lines[end - 1])) end--; + + const descStart = end > 0 ? descriptionStartIndex(lines, end - 1) : -1; + return descStart >= 0 ? descStart : end; +} + +/** + * @param {string} block + * @returns {{ name: string, kind: string }} + */ +function extractDefinitionName(block) { + for (const line of block.split('\n')) { + if (/^\s/.test(line)) continue; + const trimmed = line.trim(); + const directiveMatch = trimmed.match(/^(?:extend\s+)?directive\s+@(\w+)/); + if (directiveMatch) return { name: directiveMatch[1], kind: 'directive' }; + const match = trimmed.match(DEFINITION_NAME); + if (!match) continue; + if (match[2] === 'schema') return { name: 'schema', kind: 'schema' }; + return { name: match[3], kind: match[2] }; + } + return { name: 'Unknown', kind: 'unknown' }; +} + +/** + * Split printed SDL into top-level definition blocks. + * @param {string} sdl + * @returns {string[]} + */ +function parseSchemaBlocks(sdl) { + if (!sdl) return []; + + const lines = sdl.split('\n'); + const blocks = []; + let current = []; + let inBlockString = false; + + for (const line of lines) { + inBlockString = advanceBlockString(line, inBlockString); + + if (!inBlockString && isDefinitionLine(line) && current.length > 0) { + const splitAt = findSplitIndex(current); + const prefix = current.slice(0, splitAt); + const suffix = current.slice(splitAt); + + if (prefix.length > 0) { + blocks.push(prefix.join('\n').trim()); + } + current = suffix; + } + + current.push(line); + } + + if (current.length > 0) { + blocks.push(current.join('\n').trim()); + } + + return blocks.filter((block) => block.length > 0); +} + +/** + * @param {() => void} fn + */ +function scheduleIdle(fn) { + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(fn, { timeout: 500 }); + } else { + setTimeout(fn, 0); + } +} + +/** @type {boolean} */ +let schemaLoaded = false; + +/** + * Whether a schema is currently loaded in the viewer. + * @returns {boolean} + */ +export function isSchemaLoaded() { + return schemaLoaded; +} + +/** + * Switch the visible main-panel tab. + * @param {'requests'|'schema'} tab + */ +export function showMainTab(tab) { + const requestsTab = getElement('tab-requests'); + const schemaTab = getElement('tab-schema'); + const requestsContent = getElement('requests-content'); + const schemaContent = getElement('schema-content'); + const requestsToolbar = document.getElementById('requests-toolbar'); + if (!requestsTab || !schemaTab || !requestsContent || !schemaContent) return; + + const showSchema = tab === 'schema'; + requestsTab.setAttribute('aria-selected', String(!showSchema)); + schemaTab.setAttribute('aria-selected', String(showSchema)); + requestsContent.classList.toggle('hidden', showSchema); + schemaContent.classList.toggle('hidden', !showSchema); + if (requestsToolbar) { + requestsToolbar.classList.toggle('hidden', showSchema); + } +} + +/** @deprecated Use showMainTab('schema') */ +export function showDetailTab(tab) { + showMainTab(tab === 'schema' ? 'schema' : 'requests'); +} + +function setExplorerVisible(visible) { + const explorer = getElement('schema-explorer'); + if (explorer) explorer.classList.toggle('hidden', !visible); +} + +function getSearchValue() { + const searchInput = getElement('schema-search'); + return searchInput ? searchInput.value : ''; +} + +/** + * @param {string | null} name + */ +function selectType(name) { + selectedName = name; + const list = getElement('schema-type-list'); + const view = getElement('schema-sdl-view'); + if (!list || !view) return; + + list.querySelectorAll('.schema-type-item').forEach((item) => { + const selected = item.dataset.typeName === name; + item.classList.toggle('selected', selected); + item.setAttribute('aria-selected', String(selected)); + item.setAttribute('tabindex', selected ? '0' : '-1'); + }); + + const entry = schemaEntries.find((item) => item.name === name); + if (!entry) { + view.textContent = ''; + return; + } + + view.innerHTML = highlightSDL(entry.text); +} + +/** + * @param {string} filterText + */ +function renderTypeList(filterText) { + const list = getElement('schema-type-list'); + if (!list) return; + + const search = (filterText || '').trim().toLowerCase(); + const visible = search + ? schemaEntries.filter((entry) => entry.name.toLowerCase().includes(search)) + : schemaEntries; + + list.replaceChildren(); + + if (visible.length === 0) { + const empty = document.createElement('div'); + empty.className = 'schema-type-list-empty'; + empty.textContent = + schemaEntries.length === 0 ? 'Indexing types…' : 'No types match your filter'; + list.appendChild(empty); + const view = getElement('schema-sdl-view'); + if (view) view.textContent = ''; + selectedName = null; + return; + } + + const fragment = document.createDocumentFragment(); + for (const entry of visible) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'schema-type-item'; + button.dataset.typeName = entry.name; + button.setAttribute('role', 'option'); + button.setAttribute('tabindex', '-1'); + button.innerHTML = `${escapeHtml(entry.kind)}${escapeHtml(entry.name)}`; + if (entry.name === selectedName) { + button.classList.add('selected'); + button.setAttribute('aria-selected', 'true'); + } + fragment.appendChild(button); + } + list.appendChild(fragment); + + if (!selectedName || !visible.some((entry) => entry.name === selectedName)) { + selectType(visible[0].name); + } else { + selectType(selectedName); + } +} + +function indexSchema(sdl) { + parseGeneration += 1; + const generation = parseGeneration; + schemaEntries = []; + selectedName = null; + setExplorerVisible(true); + renderTypeList(''); + + scheduleIdle(() => { + if (generation !== parseGeneration) return; + + schemaEntries = parseSchemaBlocks(sdl) + .map((text) => ({ text, ...extractDefinitionName(text) })) + .sort((a, b) => a.name.localeCompare(b.name)); + + renderTypeList(getSearchValue()); + }); +} + +/** + * Render a loaded schema in the viewer. + * @param {string | null | undefined} sdl - Printed schema SDL; falsy clears the viewer. + * @param {{endpoint: string, typeCount: number, fetchedAt: number}} [meta] + */ +export function setSchema(sdl, meta) { + const metaEl = getElement('schema-meta'); + if (metaEl) { + if (meta) { + const when = meta.fetchedAt ? new Date(meta.fetchedAt).toLocaleString() : ''; + metaEl.textContent = `${meta.typeCount} types — ${meta.endpoint}${when ? ` — ${when}` : ''}`; + } else if (!sdl) { + metaEl.textContent = ''; + } + } + + if (!sdl) { + parseGeneration += 1; + schemaEntries = []; + selectedName = null; + schemaLoaded = false; + setExplorerVisible(false); + + const list = getElement('schema-type-list'); + if (list) list.replaceChildren(); + + const view = getElement('schema-sdl-view'); + if (view) view.textContent = ''; + + const searchInput = getElement('schema-search'); + if (searchInput) searchInput.value = ''; + return; + } + + schemaLoaded = true; + indexSchema(sdl); +} + +/** + * Wire up the main-panel tabs, type list, and schema search box. + */ +export function initSchemaViewer() { + const requestsTab = getElement('tab-requests'); + const schemaTab = getElement('tab-schema'); + + if (requestsTab) { + requestsTab.addEventListener('click', () => showMainTab('requests')); + } + if (schemaTab) { + schemaTab.addEventListener('click', () => showMainTab('schema')); + } + + const typeList = getElement('schema-type-list'); + if (typeList) { + typeList.addEventListener('click', (event) => { + const item = event.target.closest('.schema-type-item'); + if (!item) return; + selectType(item.dataset.typeName); + }); + } + + const searchInput = getElement('schema-search'); + if (searchInput) { + searchInput.addEventListener('input', (event) => { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => renderTypeList(event.target.value), 150); + }); + } +} diff --git a/js/ui/sdl-highlighter.js b/js/ui/sdl-highlighter.js new file mode 100644 index 0000000..d846413 --- /dev/null +++ b/js/ui/sdl-highlighter.js @@ -0,0 +1,94 @@ +/** + * Regex-based GraphQL SDL syntax highlighting for the schema viewer. + * Uses placeholder tokens so nested replacements stay well-formed. + * @module ui/sdl-highlighter + */ + +import { escapeHtml } from './dom-helpers.js'; + +const KEYWORDS = new Set([ + 'type', + 'input', + 'enum', + 'interface', + 'union', + 'scalar', + 'schema', + 'directive', + 'implements', + 'extend', + 'on', + 'repeatable', +]); + +const BUILTINS = new Set(['String', 'Int', 'Float', 'Boolean', 'ID']); + +/** + * @param {string} text + * @param {string} className + * @param {{ key: string, html: string }[]} tokens + * @param {{ value: number }} id + * @returns {string} + */ +function tokenize(text, className, tokens, id) { + const key = `\uE000${id.value++}\uE001`; + tokens.push({ key, html: `${text}` }); + return key; +} + +/** + * Highlight a single SDL definition for display in the schema viewer. + * @param {string} text + * @returns {string} + */ +export function highlightSDL(text) { + if (!text) return ''; + + /** @type {{ key: string, html: string }[]} */ + const tokens = []; + const id = { value: 0 }; + + /** @param {string} match @param {string} className */ + const stash = (match, className) => tokenize(match, className, tokens, id); + + let s = escapeHtml(text); + + s = s.replace(/"""[\s\S]*?"""/g, (match) => stash(match, 'string')); + s = s.replace(/"(?:\\.|[^"\\])*"/g, (match) => stash(match, 'string')); + s = s.replace(/^([ \t]*#.*)$/gm, (match) => stash(match, 'comment')); + s = s.replace(/@[A-Za-z_]\w*/g, (match) => stash(match, 'directive')); + + s = s.replace(/^(\s+)([_A-Za-z]\w*)(\s*:)/gm, (line, indent, name, colon) => { + if (KEYWORDS.has(name)) return line; + return `${indent}${tokenize(name, 'field', tokens, id)}${colon}`; + }); + + s = s.replace(/^(\s+)([A-Z][A-Z0-9_]*)(\s*,?\s*)$/gm, (line, indent, name, tail) => { + return `${indent}${tokenize(name, 'enum-value', tokens, id)}${tail}`; + }); + + s = s.replace(/\b(String|Int|Float|Boolean|ID)\b/g, (match) => stash(match, 'builtin')); + + s = s.replace( + /\b(type|input|enum|interface|union|scalar|schema|directive|implements|extend|on|repeatable)\b/g, + (match) => stash(match, 'keyword') + ); + + s = s.replace(/\b([A-Z][a-z][A-Za-z0-9_]*)\b/g, (match) => stash(match, 'type-name')); + + s = s.replace(/\b([A-Z][A-Z0-9_]+)\b/g, (match) => { + if (BUILTINS.has(match)) return match; + return stash(match, 'enum-value'); + }); + + s = s.replace(/!/g, () => tokenize('!', 'punct', tokens, id)); + s = s.replace(/(\s)\|(\s)/g, (_, before, after) => { + return `${before}${tokenize('|', 'punct', tokens, id)}${after}`; + }); + + for (const { key, html } of tokens) { + s = s.split(key).join(html); + } + + return s; +} diff --git a/js/ui/settings.js b/js/ui/settings.js new file mode 100644 index 0000000..4c133c0 --- /dev/null +++ b/js/ui/settings.js @@ -0,0 +1,132 @@ +/** + * User-customizable panel settings: theme override and density. + * Persisted to chrome.storage.local and applied as data attributes + * on the root element so CSS can react. + * @module ui/settings + */ + +import { getElement } from './dom-helpers.js'; +import { UI_SETTINGS_STORAGE_KEY } from '../shared/constants.js'; + +const DEFAULT_SETTINGS = { theme: 'system', density: 'comfortable' }; + +let currentSettings = { ...DEFAULT_SETTINGS }; + +/** + * Apply settings to the document root as data attributes. + * `theme: system` removes the attribute so the prefers-color-scheme + * media query takes over. + * @param {{theme: string, density: string}} settings + */ +export function applySettings(settings) { + const root = document.documentElement; + + if (settings.theme === 'light' || settings.theme === 'dark') { + root.setAttribute('data-theme', settings.theme); + } else { + root.removeAttribute('data-theme'); + } + + if (settings.density === 'compact') { + root.setAttribute('data-density', 'compact'); + } else { + root.removeAttribute('data-density'); + } +} + +/** + * Get the currently active settings. + * @returns {{theme: string, density: string}} + */ +export function getSettings() { + return { ...currentSettings }; +} + +function saveSettings() { + chrome.storage.local.set({ [UI_SETTINGS_STORAGE_KEY]: currentSettings }, () => { + if (chrome.runtime.lastError) { + console.warn('Failed to save UI settings:', chrome.runtime.lastError); + } + }); +} + +function syncRadios(popover) { + popover.querySelectorAll('input[name="theme"]').forEach((input) => { + input.checked = input.value === currentSettings.theme; + }); + popover.querySelectorAll('input[name="density"]').forEach((input) => { + input.checked = input.value === currentSettings.density; + }); +} + +/** + * Wire up the settings gear button and popover, and restore + * persisted settings from storage. + */ +export function initSettings() { + const button = getElement('btn-settings'); + const popover = getElement('settings-popover'); + if (!button || !popover) return; + + // Restore persisted settings + chrome.storage.local.get([UI_SETTINGS_STORAGE_KEY], (result) => { + if (chrome.runtime.lastError) { + console.warn('Failed to load UI settings:', chrome.runtime.lastError); + return; + } + const stored = result[UI_SETTINGS_STORAGE_KEY]; + if (stored && typeof stored === 'object') { + currentSettings = { ...DEFAULT_SETTINGS, ...stored }; + } + applySettings(currentSettings); + syncRadios(popover); + }); + + const closePopover = () => { + popover.classList.add('hidden'); + button.setAttribute('aria-expanded', 'false'); + }; + + button.addEventListener('click', () => { + const isOpen = !popover.classList.contains('hidden'); + if (isOpen) { + closePopover(); + } else { + popover.classList.remove('hidden'); + button.setAttribute('aria-expanded', 'true'); + const firstChecked = popover.querySelector('input:checked'); + if (firstChecked) firstChecked.focus(); + } + }); + + // Close on outside click or Escape + document.addEventListener('click', (e) => { + if (popover.classList.contains('hidden')) return; + if (!popover.contains(e.target) && e.target !== button && !button.contains(e.target)) { + closePopover(); + } + }); + + popover.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + closePopover(); + button.focus(); + } + }); + + // Radio changes + popover.addEventListener('change', (e) => { + const input = e.target; + if (!input || !input.name) return; + + if (input.name === 'theme') { + currentSettings.theme = input.value; + } else if (input.name === 'density') { + currentSettings.density = input.value; + } + + applySettings(currentSettings); + saveSettings(); + }); +} diff --git a/js/ui/toolbar.js b/js/ui/toolbar.js new file mode 100644 index 0000000..d5d0795 --- /dev/null +++ b/js/ui/toolbar.js @@ -0,0 +1,83 @@ +/** + * Request panel toolbar: clear history and export history as JSON. + * @module ui/toolbar + */ + +import { getElement } from './dom-helpers.js'; +import { state } from './state.js'; +import { renderRequestList, updateRequestCount } from './request-list.js'; +import { showStatusBanner } from './status.js'; + +/** + * Enable/disable the toolbar buttons based on history length. + * Called from request-list whenever the history changes. + */ +export function updateToolbarState() { + const hasHistory = state.requestHistory.length > 0; + const clearBtn = document.getElementById('btn-clear-history'); + const exportBtn = document.getElementById('btn-export-history'); + if (clearBtn) clearBtn.disabled = !hasHistory; + if (exportBtn) exportBtn.disabled = !hasHistory; +} + +function clearHistory() { + state.requestHistory = []; + state.selectedRequestId = null; + renderRequestList(); + updateRequestCount(); + updateToolbarState(); + + const detailContent = getElement('detail-content'); + if (detailContent) { + detailContent.innerHTML = + '

Select a request to view details

'; + } + + showStatusBanner('Request history cleared', 'success'); +} + +function exportHistory() { + if (state.requestHistory.length === 0) return; + + try { + const data = state.requestHistory.map((req) => ({ + timestamp: req.timestamp instanceof Date ? req.timestamp.toISOString() : req.timestamp, + operationName: req.operationName, + url: req.url, + type: req.type, + hash: req.hash, + query: req.query, + variables: req.variables, + })); + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `apq-requests-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Failed to export history:', error); + showStatusBanner('Failed to export request history', 'error'); + } +} + +/** + * Wire up the Clear and Export toolbar buttons. + */ +export function initToolbar() { + const clearBtn = getElement('btn-clear-history'); + if (clearBtn) { + clearBtn.addEventListener('click', clearHistory); + } + + const exportBtn = getElement('btn-export-history'); + if (exportBtn) { + exportBtn.addEventListener('click', exportHistory); + } + + updateToolbarState(); +} diff --git a/manifest.json b/manifest.json index 466d192..6280786 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Apollo GraphQL Persisted Queries (APQ) Debugger", "description": "Reveal full GraphQL queries behind Apollo APQ hashes. Inspect fallback flow and debug Automatic Persisted Queries in DevTools.", - "version": "2.0.0", + "version": "3.0.0", "permissions": ["contextMenus", "debugger", "storage", "tabs", "notifications"], "host_permissions": [""], "background": { diff --git a/package-lock.json b/package-lock.json index c71b6cc..ca057f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "apq-debugger", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "apq-debugger", - "version": "2.0.0", - "license": "MIT", + "version": "2.0.1", + "license": "Apache-2.0", "devDependencies": { "@playwright/test": "^1.58.1", "chokidar": "^3.5.3", diff --git a/package.json b/package.json index e9b0f8d..a1a09a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apq-debugger", - "version": "2.0.0", + "version": "3.0.0", "description": "Chrome DevTools extension for intercepting Apollo Persisted Queries", "main": "build-enhanced.js", "scripts": { @@ -16,7 +16,8 @@ "format:check": "prettier --check \"js/**/*.js\" \"tests/**/*.js\" \"*.js\" \"*.json\"", "version": "node scripts/sync-version.js && git add manifest.json", "package": "node scripts/package-extension.js", - "release": "npm run build && npm run package" + "release": "npm run build && npm run package", + "check:bundle": "node scripts/check-bundle-size.js" }, "devDependencies": { "@playwright/test": "^1.58.1", diff --git a/scripts/check-bundle-size.js b/scripts/check-bundle-size.js new file mode 100644 index 0000000..5baa8d5 --- /dev/null +++ b/scripts/check-bundle-size.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +/** + * Verify production bundle sizes stay within declared budgets. + * Run after `npm run build`. + */ + +const fs = require('fs'); +const path = require('path'); + +/** Max bytes per artifact in extension_build/ */ +const BUDGETS = { + 'devtools.min.js': 45000, + 'service-worker.min.js': 25000, +}; + +const extDir = path.join(__dirname, '..', 'extension_build'); + +let failed = false; + +for (const [file, maxBytes] of Object.entries(BUDGETS)) { + const filePath = path.join(extDir, file); + + if (!fs.existsSync(filePath)) { + console.error(`Missing build artifact: ${file} (run npm run build first)`); + failed = true; + continue; + } + + const size = fs.statSync(filePath).size; + const status = size <= maxBytes ? 'OK' : 'FAIL'; + + console.log(`${status} ${file}: ${size} bytes (budget ${maxBytes})`); + + if (size > maxBytes) { + failed = true; + } +} + +if (failed) { + process.exit(1); +} diff --git a/test-results/.last-run.json b/test-results/.last-run.json index 4b04a06..cbcc1fb 100644 --- a/test-results/.last-run.json +++ b/test-results/.last-run.json @@ -1,13 +1,4 @@ { - "status": "interrupted", - "failedTests": [ - "1ca5085205ef592b69b9-c0816e98add0f314994d", - "1ca5085205ef592b69b9-889342f977a6515da0af", - "1ca5085205ef592b69b9-fc2bc441b5a797801063", - "1ca5085205ef592b69b9-7320313f755ace2b5c83", - "1ca5085205ef592b69b9-8da16f3903a9df6e1a26", - "1ca5085205ef592b69b9-915ff6e0f278246ffa28", - "1ca5085205ef592b69b9-2961f6a18df42e86db0c", - "1ca5085205ef592b69b9-7ede29fd34f944167c03" - ] + "status": "passed", + "failedTests": [] } \ No newline at end of file diff --git a/tests/e2e/extension.spec.js b/tests/e2e/extension.spec.js index 7ae8dc7..40533c6 100644 --- a/tests/e2e/extension.spec.js +++ b/tests/e2e/extension.spec.js @@ -1,12 +1,13 @@ const { test, expect, chromium } = require('@playwright/test'); const path = require('path'); +// NOTE: run `npm run build` first — the extension is loaded from extension_build/ test.describe('APQ Debugger Extension', () => { let context; let extensionId; test.beforeEach(async () => { - const pathToExtension = path.join(__dirname, '../../'); + const pathToExtension = path.join(__dirname, '../../extension_build'); context = await chromium.launchPersistentContext('', { headless: false, // Extensions only work in headless=false or new headless with flags args: [ @@ -36,7 +37,7 @@ test.describe('APQ Debugger Extension', () => { panels: { create: () => {} }, }; }); - await page.goto(`chrome-extension://${extensionId}/frontend/devtools.html`); + await page.goto(`chrome-extension://${extensionId}/devtools.html`); await page.waitForLoadState('domcontentloaded'); // Allow time for JS initialization (DOMContentLoaded handler) await page.waitForTimeout(500); @@ -220,4 +221,63 @@ test.describe('APQ Debugger Extension', () => { await filterInput.fill('GetUsers'); await expect(filterInput).toHaveValue('GetUsers'); }); + + // ── Settings popover ─────────────────────────────────────────── + + test('should open settings popover and switch theme', async () => { + const page = await openDevToolsPage(); + + const settingsBtn = page.locator('#btn-settings'); + await expect(settingsBtn).toBeVisible(); + + await settingsBtn.click(); + await expect(page.locator('#settings-popover')).toBeVisible(); + await expect(settingsBtn).toHaveAttribute('aria-expanded', 'true'); + + await page.locator('.settings-option:has(input[name="theme"][value="dark"])').click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + await page.locator('.settings-option:has(input[name="density"][value="compact"])').click(); + await expect(page.locator('html')).toHaveAttribute('data-density', 'compact'); + + await page.waitForTimeout(300); + await page.reload(); + await page.waitForTimeout(500); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + }); + + test('should toggle sidebar via header button', async () => { + const page = await openDevToolsPage(); + + const toggleBtn = page.locator('#btn-toggle-sidebar'); + await expect(toggleBtn).toBeVisible(); + + await toggleBtn.click(); + await expect(page.locator('.app')).toHaveClass(/sidebar-collapsed/); + + await toggleBtn.click(); + await expect(page.locator('.app')).not.toHaveClass(/sidebar-collapsed/); + }); + + // ── Schema tab ───────────────────────────────────────────────── + + test('should show schema tab with load controls', async () => { + const page = await openDevToolsPage(); + + await page.locator('#tab-schema').click(); + await expect(page.locator('#schema-content')).toBeVisible(); + await expect(page.locator('#btn-load-schema')).toContainText('Load Schema'); + await expect(page.locator('#schema-status')).toContainText('No schema loaded'); + await expect(page.locator('#schema-url-input')).toBeVisible(); + }); + + // ── Main panel tabs ──────────────────────────────────────────── + + test('should show requests tab selected by default', async () => { + const page = await openDevToolsPage(); + + await expect(page.locator('#tab-requests')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#tab-schema')).toHaveAttribute('aria-selected', 'false'); + await expect(page.locator('#requests-content')).toBeVisible(); + }); }); diff --git a/tests/unit/curl-copy.test.js b/tests/unit/curl-copy.test.js new file mode 100644 index 0000000..4386e0b --- /dev/null +++ b/tests/unit/curl-copy.test.js @@ -0,0 +1,208 @@ +/** + * @jest-environment jsdom + */ + +import { + buildGraphQLBody, + getReplayHeaders, + toBashCurl, + toPowerShellCurl, + openCurlModal, + initCurlCopy, +} from '../../js/ui/curl-copy.js'; +import { normalizeRequestHeaders } from '../../js/sw/interceptor.js'; + +describe('curl-copy', () => { + const sampleRequest = { + url: 'https://api.example.com/graphql', + operationName: 'GetUsers', + query: 'query GetUsers { users { id name } }', + variables: { limit: 10 }, + hash: 'abc123hash', + }; + + describe('buildGraphQLBody', () => { + test('should include query, variables, operationName, and APQ hash', () => { + const body = buildGraphQLBody(sampleRequest); + expect(body).toEqual({ + operationName: 'GetUsers', + query: 'query GetUsers { users { id name } }', + variables: { limit: 10 }, + extensions: { + persistedQuery: { version: 1, sha256Hash: 'abc123hash' }, + }, + }); + }); + + test('should build APQ-only body from hash', () => { + const body = buildGraphQLBody({ + url: 'https://x.com/gql', + hash: 'only-hash', + type: 'apq', + }); + expect(body.extensions.persistedQuery.sha256Hash).toBe('only-hash'); + expect(body.query).toBeUndefined(); + }); + }); + + describe('toBashCurl', () => { + test('should produce a multi-line curl with POST and JSON body', () => { + const body = buildGraphQLBody(sampleRequest); + const curl = toBashCurl(sampleRequest.url, body); + + expect(curl).toContain("curl 'https://api.example.com/graphql'"); + expect(curl).toContain('-X POST'); + expect(curl).toContain('--data-raw'); + expect(curl).toContain('GetUsers'); + }); + + test('should escape single quotes in bash strings', () => { + const curl = toBashCurl('https://x.com/gql', { query: "query { field(value: 'a') }" }); + expect(curl).toContain("'\\''"); + }); + + test('should include captured request headers', () => { + const headers = [ + { name: 'Cookie', value: 'session=abc' }, + { name: 'client-info', value: 'web' }, + ]; + const curl = toBashCurl('https://x.com/gql', { query: '{}' }, headers); + + expect(curl).toContain("-H 'Cookie: session=abc'"); + expect(curl).toContain("-H 'client-info: web'"); + }); + }); + + describe('toPowerShellCurl', () => { + test('should produce Invoke-RestMethod with here-string body', () => { + const body = buildGraphQLBody(sampleRequest); + const ps = toPowerShellCurl(sampleRequest.url, body); + + expect(ps).toContain("$body = @'"); + expect(ps).toContain('Invoke-RestMethod'); + expect(ps).toContain('-Uri "https://api.example.com/graphql"'); + expect(ps).toContain('-Body $body'); + }); + + test('should emit a headers hashtable when headers were captured', () => { + const headers = [{ name: 'Cookie', value: 'a=b' }]; + const ps = toPowerShellCurl('https://x.com/gql', { query: '{}' }, headers); + + expect(ps).toContain('$headers = @{'); + expect(ps).toContain("'Cookie' = 'a=b'"); + expect(ps).toContain('-Headers $headers'); + }); + }); + + describe('getReplayHeaders', () => { + test('should return stored header pairs from a request', () => { + expect( + getReplayHeaders({ + headers: [{ name: 'User-Agent', value: 'Mozilla/5.0' }], + }) + ).toEqual([{ name: 'User-Agent', value: 'Mozilla/5.0' }]); + }); + }); + + describe('modal interactions', () => { + const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + + beforeEach(() => { + document.body.innerHTML = ` + + `; + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: jest.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + initCurlCopy(); + }); + + test('openCurlModal should show the modal with a bash preview and focus the first option', () => { + openCurlModal(sampleRequest); + + const modal = document.getElementById('curl-modal'); + expect(modal.classList.contains('hidden')).toBe(false); + expect(document.getElementById('curl-preview').textContent).toContain("curl '"); + expect(document.activeElement).toBe(document.querySelector('.curl-shell-option')); + }); + + test('openCurlModal should ignore requests without a URL', () => { + openCurlModal({ operationName: 'NoUrl' }); + expect(document.getElementById('curl-modal').classList.contains('hidden')).toBe(true); + }); + + test('clicking a shell option should switch the preview and toggle active state', () => { + openCurlModal(sampleRequest); + + const psOption = document.querySelector('[data-shell="powershell"]'); + psOption.click(); + + expect(document.getElementById('curl-preview').textContent).toContain('Invoke-RestMethod'); + expect(psOption.classList.contains('active')).toBe(true); + expect(psOption.getAttribute('aria-pressed')).toBe('true'); + + const bashOption = document.querySelector('[data-shell="bash"]'); + expect(bashOption.classList.contains('active')).toBe(false); + expect(bashOption.getAttribute('aria-pressed')).toBe('false'); + }); + + test('copy button should write the preview to the clipboard and confirm', async () => { + openCurlModal(sampleRequest); + + const copyBtn = document.getElementById('curl-copy-btn'); + copyBtn.click(); + await flushMicrotasks(); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + document.getElementById('curl-preview').textContent + ); + expect(copyBtn.textContent).toBe('Copied!'); + }); + + test.each([ + ['close button', () => document.getElementById('curl-modal-close').click()], + ['backdrop click', () => document.querySelector('.modal-backdrop').click()], + [ + 'Escape key', + () => + document + .getElementById('curl-modal') + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), + ], + ])('%s should close the modal', (_label, close) => { + openCurlModal(sampleRequest); + close(); + expect(document.getElementById('curl-modal').classList.contains('hidden')).toBe(true); + }); + }); + + describe('normalizeRequestHeaders', () => { + test('should skip hop-by-hop and duplicate content headers', () => { + expect( + normalizeRequestHeaders({ + Cookie: 'a=b', + Host: 'example.com', + 'Content-Length': '123', + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'Mozilla/5.0', + }) + ).toEqual([ + { name: 'Cookie', value: 'a=b' }, + { name: 'User-Agent', value: 'Mozilla/5.0' }, + ]); + }); + }); +}); diff --git a/tests/unit/devtools.test.js b/tests/unit/devtools.test.js index 06c7a7c..eabf4fe 100644 --- a/tests/unit/devtools.test.js +++ b/tests/unit/devtools.test.js @@ -27,6 +27,18 @@ import { handleStopSuccess, handleStopWarning, } from '../../js/ui/debugger-controls.js'; +import { applySettings, initSettings } from '../../js/ui/settings.js'; +import { initToolbar, updateToolbarState } from '../../js/ui/toolbar.js'; +import { initKeyboard } from '../../js/ui/keyboard.js'; +import { + setSchema, + showMainTab, + initSchemaViewer, + isSchemaLoaded, +} from '../../js/ui/schema-viewer.js'; +import { initSchemaControls } from '../../js/ui/schema-controls.js'; +import { SCHEMA_STORAGE_KEY, SCHEMA_CACHE_STORAGE_KEY } from '../../js/shared/constants.js'; +import { initRequestList } from '../../js/ui/request-list.js'; function setupDOM() { document.body.innerHTML = ` @@ -450,4 +462,436 @@ describe('DevTools Panel Logic', () => { expect(badge.querySelector('.status-text').textContent).toBe('Warning'); }); }); + + // ── Settings ─────────────────────────────────────────────────── + + describe('Settings', () => { + afterEach(() => { + document.documentElement.removeAttribute('data-theme'); + document.documentElement.removeAttribute('data-density'); + }); + + test('applySettings should set data attributes for explicit theme/density', () => { + applySettings({ theme: 'dark', density: 'compact' }); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + expect(document.documentElement.getAttribute('data-density')).toBe('compact'); + }); + + test('applySettings should remove attributes for system/comfortable', () => { + applySettings({ theme: 'dark', density: 'compact' }); + applySettings({ theme: 'system', density: 'comfortable' }); + expect(document.documentElement.hasAttribute('data-theme')).toBe(false); + expect(document.documentElement.hasAttribute('data-density')).toBe(false); + }); + + test('initSettings should restore persisted settings from storage', () => { + document.body.innerHTML += ` + + + `; + + chrome.storage.local.get.mockImplementationOnce((_keys, cb) => { + chrome.runtime.lastError = null; + cb({ apqUiSettings: { theme: 'dark', density: 'compact' } }); + }); + + initSettings(); + + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + expect(document.documentElement.getAttribute('data-density')).toBe('compact'); + expect(document.querySelector('input[value="dark"]').checked).toBe(true); + expect(document.querySelector('input[value="compact"]').checked).toBe(true); + }); + + test('changing a setting should apply and persist it', () => { + document.body.innerHTML += ` + + + `; + + initSettings(); + + const lightRadio = document.querySelector('input[value="light"]'); + lightRadio.checked = true; + lightRadio.dispatchEvent(new Event('change', { bubbles: true })); + + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + expect(chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ + apqUiSettings: expect.objectContaining({ theme: 'light' }), + }), + expect.any(Function) + ); + }); + }); + + // ── Toolbar (Clear / Export) ─────────────────────────────────── + + describe('Toolbar', () => { + beforeEach(() => { + document.body.innerHTML += ` + + + `; + }); + + test('updateToolbarState should enable buttons when history exists', () => { + updateToolbarState(); + expect(document.getElementById('btn-clear-history').disabled).toBe(true); + + addRequest({ operationName: 'Test' }); + expect(document.getElementById('btn-clear-history').disabled).toBe(false); + expect(document.getElementById('btn-export-history').disabled).toBe(false); + }); + + test('clear button should empty history and reset UI', () => { + addRequest({ operationName: 'Test' }); + initToolbar(); + + document.getElementById('btn-clear-history').click(); + + expect(state.requestHistory).toHaveLength(0); + expect(state.selectedRequestId).toBeNull(); + expect(document.getElementById('interception-counter').textContent).toBe('0'); + expect(document.getElementById('btn-clear-history').disabled).toBe(true); + expect(document.getElementById('detail-content').innerHTML).toContain('Select a request'); + }); + + test('export button should download history as JSON', () => { + addRequest({ operationName: 'GetUsers', url: 'https://x.com/graphql' }); + initToolbar(); + + const objectUrlSpy = jest.fn().mockReturnValue('blob:mock'); + const revokeSpy = jest.fn(); + global.URL.createObjectURL = objectUrlSpy; + global.URL.revokeObjectURL = revokeSpy; + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}); + + document.getElementById('btn-export-history').click(); + + expect(objectUrlSpy).toHaveBeenCalledWith(expect.any(Blob)); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeSpy).toHaveBeenCalledWith('blob:mock'); + + clickSpy.mockRestore(); + delete global.URL.createObjectURL; + delete global.URL.revokeObjectURL; + }); + }); + + // ── Keyboard Navigation & ARIA ───────────────────────────────── + + describe('Keyboard & ARIA', () => { + test('request items should carry option role and aria-selected', () => { + addRequest({ operationName: 'A' }); + renderRequestList(); + + const item = document.querySelector('.request-item'); + expect(item.getAttribute('role')).toBe('option'); + expect(item.getAttribute('aria-selected')).toBe('false'); + expect(item.getAttribute('tabindex')).toBe('-1'); + }); + + test('clicking a chip should update aria-checked', () => { + initRequestList(); + + const apqChip = document.querySelector('.chip[data-filter="apq"]'); + apqChip.click(); + + expect(apqChip.getAttribute('aria-checked')).toBe('true'); + expect(apqChip.classList.contains('active')).toBe(true); + const allChip = document.querySelector('.chip[data-filter="all"]'); + expect(allChip.getAttribute('aria-checked')).toBe('false'); + }); + + test('ArrowDown should move focus through request items', () => { + addRequest({ operationName: 'A' }); + addRequest({ operationName: 'B' }); + renderRequestList(); + initKeyboard(); + + const list = document.getElementById('request-list'); + const items = document.querySelectorAll('.request-item'); + + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(document.activeElement).toBe(items[0]); + expect(items[0].getAttribute('tabindex')).toBe('0'); + + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(document.activeElement).toBe(items[1]); + }); + + test('End and Home should jump to last/first item', () => { + addRequest({ operationName: 'A' }); + addRequest({ operationName: 'B' }); + addRequest({ operationName: 'C' }); + renderRequestList(); + initKeyboard(); + + const list = document.getElementById('request-list'); + const items = document.querySelectorAll('.request-item'); + + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); + expect(document.activeElement).toBe(items[2]); + + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); + expect(document.activeElement).toBe(items[0]); + }); + + test('Escape should close the detail panel', () => { + initKeyboard(); + const detailPanel = document.getElementById('detail-panel'); + detailPanel.classList.remove('hidden'); + const closeBtn = document.getElementById('close-detail'); + const clickSpy = jest.spyOn(closeBtn, 'click').mockImplementation(() => {}); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(clickSpy).toHaveBeenCalled(); + clickSpy.mockRestore(); + }); + }); + + // ── Schema Viewer ────────────────────────────────────────────── + + describe('Schema Viewer', () => { + const SAMPLE_SDL = + 'type Query {\n users: [User]\n}\n\ntype User {\n id: ID\n name: String\n}'; + + /** @returns {Promise} */ + async function flushSchemaIndex() { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + afterEach(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + document.body.innerHTML += ` + + +
+
+ + + + +
+ `; + }); + + test('setSchema should index types and show the explorer', async () => { + setSchema(SAMPLE_SDL, { + endpoint: 'https://x.com/graphql', + typeCount: 2, + fetchedAt: Date.now(), + }); + + await flushSchemaIndex(); + + expect(document.getElementById('schema-explorer').classList.contains('hidden')).toBe(false); + expect(document.querySelectorAll('.schema-type-item')).toHaveLength(2); + expect(document.getElementById('schema-sdl-view').textContent).toContain('type Query'); + expect(document.getElementById('schema-meta').textContent).toContain('2 types'); + expect(document.getElementById('schema-meta').textContent).toContain('https://x.com/graphql'); + }); + + test('setSchema should escape and highlight the selected type', async () => { + setSchema('type Query {\n name: String\n}', { + endpoint: 'https://x.com/graphql', + typeCount: 1, + fetchedAt: Date.now(), + }); + + await flushSchemaIndex(); + + const html = document.getElementById('schema-sdl-view').innerHTML; + expect(html).toContain('type'); + expect(html).toContain('name'); + expect(html).toContain('String'); + }); + + test('search should filter the type list', () => { + jest.useFakeTimers(); + initSchemaViewer(); + setSchema(SAMPLE_SDL, { + endpoint: 'https://x.com/graphql', + typeCount: 2, + fetchedAt: Date.now(), + }); + jest.advanceTimersByTime(0); + + const search = document.getElementById('schema-search'); + search.value = 'User'; + search.dispatchEvent(new Event('input', { bubbles: true })); + jest.advanceTimersByTime(150); + + expect(document.querySelectorAll('.schema-type-item')).toHaveLength(1); + expect(document.getElementById('schema-sdl-view').textContent).toContain('type User'); + + search.value = 'no-such-type'; + search.dispatchEvent(new Event('input', { bubbles: true })); + jest.advanceTimersByTime(150); + expect(document.querySelector('.schema-type-list-empty').textContent).toContain( + 'No types match' + ); + jest.useRealTimers(); + }); + + test('setSchema should preserve types with blank lines in descriptions', async () => { + const sdl = `""" +An organization in Apollo Studio. + +Can have multiple members. +""" +type Account { + """Used by Studio to show the Change Plan button""" + canChangePlan: Boolean! +} + +type User { + id: ID +}`; + + setSchema(sdl, { + endpoint: 'https://x.com/graphql', + typeCount: 2, + fetchedAt: Date.now(), + }); + + await flushSchemaIndex(); + + const accountItem = [...document.querySelectorAll('.schema-type-item')].find( + (item) => item.dataset.typeName === 'Account' + ); + expect(accountItem).toBeTruthy(); + accountItem.click(); + + const rendered = document.getElementById('schema-sdl-view').textContent; + expect(rendered).toContain('Can have multiple members.'); + expect(rendered).toContain('canChangePlan: Boolean!'); + expect(rendered.indexOf('type Account')).toBeLessThan(rendered.indexOf('canChangePlan')); + }); + + test('setSchema should label custom directives by name', async () => { + const sdl = `enum CacheControlScope { + PUBLIC + PRIVATE +} + +directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT + +type Query { + characters: [Character] +}`; + + setSchema(sdl, { + endpoint: 'https://rickandmortyapi.com/graphql', + typeCount: 3, + fetchedAt: Date.now(), + }); + + await flushSchemaIndex(); + + const names = [...document.querySelectorAll('.schema-type-item')].map( + (item) => item.dataset.typeName + ); + expect(names).toContain('cacheControl'); + expect(names).not.toContain('Unknown'); + + const directiveItem = document.querySelector( + '.schema-type-item[data-type-name="cacheControl"]' + ); + expect(directiveItem?.querySelector('.kind')?.textContent).toBe('directive'); + }); + + test('showMainTab should toggle panels and aria-selected', () => { + showMainTab('schema'); + expect(document.getElementById('tab-schema').getAttribute('aria-selected')).toBe('true'); + expect(document.getElementById('tab-requests').getAttribute('aria-selected')).toBe('false'); + expect(document.getElementById('schema-content').classList.contains('hidden')).toBe(false); + expect(document.getElementById('requests-content').classList.contains('hidden')).toBe(true); + + showMainTab('requests'); + expect(document.getElementById('tab-requests').getAttribute('aria-selected')).toBe('true'); + expect(document.getElementById('requests-content').classList.contains('hidden')).toBe(false); + }); + + test('ArrowDown should move focus and selection through schema type items', async () => { + initSchemaViewer(); + initKeyboard(); + setSchema(SAMPLE_SDL, { + endpoint: 'https://x.com/graphql', + typeCount: 2, + fetchedAt: Date.now(), + }); + + await flushSchemaIndex(); + + const list = document.getElementById('schema-type-list'); + const items = document.querySelectorAll('.schema-type-item'); + expect(items).toHaveLength(2); + + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(document.activeElement).toBe(items[0]); + expect(items[0].getAttribute('aria-selected')).toBe('true'); + expect(document.getElementById('schema-sdl-view').textContent).toContain( + items[0].dataset.typeName + ); + + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(document.activeElement).toBe(items[1]); + expect(items[1].getAttribute('aria-selected')).toBe('true'); + expect(document.getElementById('schema-sdl-view').textContent).toContain( + items[1].dataset.typeName + ); + }); + + test('clear should reset the viewer and remove cached schema', async () => { + initSchemaControls(); + setSchema(SAMPLE_SDL, { + endpoint: 'https://x.com/graphql', + typeCount: 2, + fetchedAt: Date.now(), + }); + + await flushSchemaIndex(); + + const clearBtn = document.getElementById('btn-clear-schema'); + expect(clearBtn.disabled).toBe(false); + expect(isSchemaLoaded()).toBe(true); + + clearBtn.click(); + + expect(document.getElementById('schema-explorer').classList.contains('hidden')).toBe(true); + expect(document.querySelectorAll('.schema-type-item')).toHaveLength(0); + expect(document.getElementById('schema-sdl-view').textContent).toBe(''); + expect(document.getElementById('schema-meta').textContent).toBe(''); + expect(document.getElementById('schema-search').value).toBe(''); + expect(document.getElementById('schema-status').textContent).toBe('No schema loaded'); + expect(clearBtn.disabled).toBe(true); + expect(chrome.storage.local.remove).toHaveBeenCalledWith( + [SCHEMA_STORAGE_KEY, SCHEMA_CACHE_STORAGE_KEY], + expect.any(Function) + ); + }); + }); }); diff --git a/tests/unit/endpoint-tracker.test.js b/tests/unit/endpoint-tracker.test.js new file mode 100644 index 0000000..d0125c4 --- /dev/null +++ b/tests/unit/endpoint-tracker.test.js @@ -0,0 +1,184 @@ +/** + * Persistence tests for the observed-endpoint tracker. + * A "service worker restart" is simulated with jest.resetModules(): the fresh + * module starts with empty memory while the mocked storage keeps its data. + */ + +const STORAGE_KEY = 'apqObservedEndpoints'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function freshServiceWorker() { + jest.resetModules(); + return { + tracker: require('../../js/sw/endpoint-tracker.js'), + loader: require('../../js/sw/schema-loader.js'), + }; +} + +function lastPersistedSnapshot() { + const calls = chrome.storage.local.set.mock.calls; + return calls[calls.length - 1][0][STORAGE_KEY]; +} + +describe('Endpoint Tracker persistence', () => { + beforeEach(() => { + jest.clearAllMocks(); + chrome.runtime.lastError = null; + chrome.storage.local.get.mockImplementation((_keys, cb) => cb({})); + chrome.storage.local.set.mockImplementation((_items, cb) => cb && cb()); + }); + + test('should persist recorded endpoints with their captured headers', async () => { + const { tracker } = freshServiceWorker(); + + tracker.recordEndpoint(1, 'https://api.other.com/v2/gql?op=GetUsers#frag', [ + { name: 'client-info', value: 'web' }, + ]); + await flush(); + + expect(lastPersistedSnapshot()).toEqual({ + 1: [ + { url: 'https://api.other.com/v2/gql', headers: [{ name: 'client-info', value: 'web' }] }, + ], + }); + }); + + test('should restore observations from storage after a service worker restart', async () => { + chrome.storage.local.get.mockImplementation((_keys, cb) => + cb({ + [STORAGE_KEY]: { + 1: [{ url: 'https://api.other.com/v2/gql', headers: [{ name: 'x-csrf', value: 'tok' }] }], + }, + }) + ); + const { tracker } = freshServiceWorker(); + + expect(tracker.getObservedEndpoints(1)).toEqual([]); + await tracker.ensureEndpointsLoaded(); + expect(tracker.getObservedEndpoints(1)).toEqual(['https://api.other.com/v2/gql']); + expect(tracker.getObservedEndpointEntries(1)).toEqual([ + { url: 'https://api.other.com/v2/gql', headers: [{ name: 'x-csrf', value: 'tok' }] }, + ]); + }); + + test('should migrate entries persisted as plain URL strings', async () => { + chrome.storage.local.get.mockImplementation((_keys, cb) => + cb({ [STORAGE_KEY]: { 1: ['https://legacy.com/graphql'] } }) + ); + const { tracker } = freshServiceWorker(); + + await tracker.ensureEndpointsLoaded(); + + expect(tracker.getObservedEndpointEntries(1)).toEqual([ + { url: 'https://legacy.com/graphql', headers: [] }, + ]); + }); + + test('should keep endpoints recorded before the stored snapshot finishes loading', async () => { + let resolveGet; + chrome.storage.local.get.mockImplementation((_keys, cb) => { + resolveGet = cb; + }); + const { tracker } = freshServiceWorker(); + + tracker.recordEndpoint(1, 'https://live.com/graphql'); + resolveGet({ [STORAGE_KEY]: { 1: [{ url: 'https://stored.com/graphql', headers: [] }] } }); + await tracker.ensureEndpointsLoaded(); + await flush(); + + const endpoints = tracker.getObservedEndpoints(1); + expect(endpoints).toContain('https://live.com/graphql'); + expect(endpoints).toContain('https://stored.com/graphql'); + expect(lastPersistedSnapshot()[1].map((entry) => entry.url)).toEqual( + expect.arrayContaining(['https://live.com/graphql', 'https://stored.com/graphql']) + ); + }); + + test('should fill in headers from a later capture when the first had none', async () => { + const { tracker } = freshServiceWorker(); + + tracker.recordEndpoint(1, 'https://a.com/graphql'); + tracker.recordEndpoint(1, 'https://a.com/graphql', [{ name: 'client-info', value: 'web' }]); + await flush(); + + expect(tracker.getObservedEndpointEntries(1)).toEqual([ + { url: 'https://a.com/graphql', headers: [{ name: 'client-info', value: 'web' }] }, + ]); + }); + + test('should clear stored entries for a tab even on a fresh service worker', async () => { + chrome.storage.local.get.mockImplementation((_keys, cb) => + cb({ + [STORAGE_KEY]: { + 1: [{ url: 'https://a.com/graphql', headers: [] }], + 2: [{ url: 'https://b.com/graphql', headers: [] }], + }, + }) + ); + const { tracker } = freshServiceWorker(); + + tracker.clearObservedEndpoints(1); + await flush(); + + expect(tracker.getObservedEndpoints(1)).toEqual([]); + const persisted = lastPersistedSnapshot(); + expect(persisted[1]).toBeUndefined(); + expect(persisted[2]).toEqual([{ url: 'https://b.com/graphql', headers: [] }]); + }); + + test('should prefer chrome.storage.session when available', async () => { + chrome.storage.session = { + get: jest.fn((_keys, cb) => cb({})), + set: jest.fn((_items, cb) => cb && cb()), + }; + try { + const { tracker } = freshServiceWorker(); + + tracker.recordEndpoint(1, 'https://a.com/graphql'); + await flush(); + + expect(chrome.storage.session.set).toHaveBeenCalled(); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + } finally { + delete chrome.storage.session; + } + }); + + test('loadSchema should introspect endpoints persisted before a service worker restart', async () => { + chrome.storage.local.get.mockImplementation((_keys, cb) => + cb({ + [STORAGE_KEY]: { + 7: [ + { + url: 'https://api.cross-origin.com/gql', + headers: [{ name: 'client-info', value: 'web' }], + }, + ], + }, + }) + ); + const { loader } = freshServiceWorker(); + + global.fetch = jest.fn((url) => { + if (url !== 'https://api.cross-origin.com/gql') { + return Promise.reject(new Error('refused')); + } + return Promise.resolve({ + json: () => Promise.resolve({ data: { __schema: { types: [] } } }), + }); + }); + + try { + const result = await loader.loadSchema({ tabId: 7, tabUrl: 'https://app.test.com/' }); + + expect(result.endpoint).toBe('https://api.cross-origin.com/gql'); + expect(result.introspection).toEqual({ __schema: { types: [] } }); + // Introspected directly with the captured headers — no probe round trip + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch.mock.calls[0][1].headers['client-info']).toBe('web'); + } finally { + delete global.fetch; + } + }); +}); diff --git a/tests/unit/hash-registry.test.js b/tests/unit/hash-registry.test.js new file mode 100644 index 0000000..a821753 --- /dev/null +++ b/tests/unit/hash-registry.test.js @@ -0,0 +1,211 @@ +import { + initRegistry, + registerHash, + lookupHash, + getRegistrySize, + getRegistryEntries, + clearRegistry, + isPassiveMode, + setPassiveMode, +} from '../../js/sw/hash-registry.js'; +import { HASH_REGISTRY_MAX_ENTRIES } from '../../js/shared/constants.js'; + +describe('Hash Registry', () => { + beforeEach(async () => { + jest.clearAllMocks(); + chrome.runtime.lastError = null; + + chrome.storage.local.get.mockImplementation((_keys, cb) => { + chrome.runtime.lastError = null; + cb({}); + }); + chrome.storage.local.set.mockImplementation((_items, cb) => { + chrome.runtime.lastError = null; + cb && cb(); + }); + chrome.runtime.sendMessage.mockImplementation((_msg, cb) => cb && cb(null)); + + // Reset module state between tests + clearRegistry(); + await setPassiveMode(false); + jest.clearAllMocks(); + }); + + describe('registerHash / lookupHash', () => { + test('should register and resolve a hash', () => { + registerHash('abc123', { + query: '{ users { id } }', + operationName: 'GetUsers', + variables: { limit: 10 }, + }); + + const entry = lookupHash('abc123'); + expect(entry).not.toBeNull(); + expect(entry.query).toBe('{ users { id } }'); + expect(entry.operationName).toBe('GetUsers'); + expect(entry.variables).toEqual({ limit: 10 }); + expect(getRegistrySize()).toBe(1); + }); + + test('should return null for unknown hash', () => { + expect(lookupHash('does-not-exist')).toBeNull(); + }); + + test('should ignore invalid hashes', () => { + registerHash(null, { query: 'x' }); + registerHash(42, { query: 'x' }); + registerHash('', { query: 'x' }); + expect(getRegistrySize()).toBe(0); + }); + + test('should persist registry to storage on register', () => { + registerHash('abc123', { query: '{ me }' }); + + expect(chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ + apqHashRegistry: expect.objectContaining({ + abc123: expect.objectContaining({ query: '{ me }' }), + }), + }), + expect.any(Function) + ); + }); + + test('should broadcast REGISTRY_UPDATED for new hashes only', () => { + registerHash('abc123', { query: '{ me }' }); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ status: 'REGISTRY_UPDATED', size: 1 }), + expect.any(Function) + ); + + jest.clearAllMocks(); + registerHash('abc123', { query: '{ me }' }); // re-register same hash + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); + }); + }); + + describe('size cap', () => { + const fillRegistry = () => { + for (let i = 0; i < HASH_REGISTRY_MAX_ENTRIES; i++) { + registerHash(`hash-${i}`, { query: `{ q${i} }` }); + } + }; + + test('should evict the oldest hash once the cap is exceeded', () => { + fillRegistry(); + expect(getRegistrySize()).toBe(HASH_REGISTRY_MAX_ENTRIES); + + registerHash('one-more', { query: '{ extra }' }); + + expect(getRegistrySize()).toBe(HASH_REGISTRY_MAX_ENTRIES); + expect(lookupHash('hash-0')).toBeNull(); + expect(lookupHash('one-more')).not.toBeNull(); + }); + + test('should refresh recency when a hash is re-registered', () => { + fillRegistry(); + registerHash('hash-0', { query: '{ q0 }' }); // refresh the oldest entry + + registerHash('one-more', { query: '{ extra }' }); + + expect(lookupHash('hash-0')).not.toBeNull(); + expect(lookupHash('hash-1')).toBeNull(); + }); + + test('initRegistry should trim oversized persisted registries', async () => { + const stored = {}; + for (let i = 0; i < HASH_REGISTRY_MAX_ENTRIES + 5; i++) { + stored[`hash-${i}`] = { + query: `{ q${i} }`, + operationName: '', + variables: null, + registeredAt: i, + }; + } + chrome.storage.local.get.mockImplementation((_keys, cb) => { + chrome.runtime.lastError = null; + cb({ apqHashRegistry: stored }); + }); + + await initRegistry(); + + expect(getRegistrySize()).toBe(HASH_REGISTRY_MAX_ENTRIES); + expect(lookupHash('hash-0')).toBeNull(); + expect(lookupHash(`hash-${HASH_REGISTRY_MAX_ENTRIES + 4}`)).not.toBeNull(); + }); + }); + + describe('clearRegistry', () => { + test('should empty the registry and broadcast', () => { + registerHash('abc123', { query: '{ me }' }); + jest.clearAllMocks(); + + clearRegistry(); + + expect(getRegistrySize()).toBe(0); + expect(lookupHash('abc123')).toBeNull(); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ status: 'REGISTRY_UPDATED', size: 0 }), + expect.any(Function) + ); + }); + }); + + describe('passive mode', () => { + test('should default to false', () => { + expect(isPassiveMode()).toBe(false); + }); + + test('should persist passive mode flag', async () => { + await setPassiveMode(true); + + expect(isPassiveMode()).toBe(true); + expect(chrome.storage.local.set).toHaveBeenCalledWith( + { apqPassiveMode: true }, + expect.any(Function) + ); + }); + }); + + describe('initRegistry', () => { + test('should load entries and passive mode from storage', async () => { + chrome.storage.local.get.mockImplementation((_keys, cb) => { + chrome.runtime.lastError = null; + cb({ + apqHashRegistry: { + stored1: { query: '{ a }', operationName: 'A', variables: null, registeredAt: 1 }, + }, + apqPassiveMode: true, + }); + }); + + await initRegistry(); + + expect(lookupHash('stored1')).toEqual( + expect.objectContaining({ query: '{ a }', operationName: 'A' }) + ); + expect(isPassiveMode()).toBe(true); + }); + + test('should handle storage errors gracefully', async () => { + chrome.storage.local.get.mockImplementation((_keys, cb) => { + chrome.runtime.lastError = { message: 'Storage error' }; + cb({}); + chrome.runtime.lastError = null; + }); + + await expect(initRegistry()).resolves.toBeUndefined(); + }); + }); + + describe('getRegistryEntries', () => { + test('should return a plain object snapshot', () => { + registerHash('h1', { query: '{ a }' }); + registerHash('h2', { query: '{ b }' }); + + const entries = getRegistryEntries(); + expect(Object.keys(entries).sort()).toEqual(['h1', 'h2']); + expect(entries.h1.query).toBe('{ a }'); + }); + }); +}); diff --git a/tests/unit/introspection-to-sdl.test.js b/tests/unit/introspection-to-sdl.test.js new file mode 100644 index 0000000..9ff4888 --- /dev/null +++ b/tests/unit/introspection-to-sdl.test.js @@ -0,0 +1,237 @@ +import { + introspectionToSDL, + countIntrospectionTypes, + printTypeRef, +} from '../../js/shared/introspection-to-sdl.js'; + +const MINIMAL_INTROSPECTION = { + __schema: { + queryType: { name: 'Query' }, + mutationType: null, + subscriptionType: null, + types: [ + { + kind: 'OBJECT', + name: 'Query', + fields: [ + { + name: 'users', + args: [], + type: { + kind: 'LIST', + ofType: { kind: 'NON_NULL', ofType: { kind: 'OBJECT', name: 'User' } }, + }, + }, + ], + }, + { + kind: 'OBJECT', + name: 'User', + fields: [ + { + name: 'id', + args: [], + type: { kind: 'NON_NULL', ofType: { kind: 'SCALAR', name: 'ID' } }, + }, + { + name: 'name', + args: [], + type: { kind: 'SCALAR', name: 'String' }, + }, + ], + }, + { kind: 'SCALAR', name: 'String' }, + { kind: 'SCALAR', name: 'Int' }, + { kind: 'SCALAR', name: 'Float' }, + { kind: 'SCALAR', name: 'Boolean' }, + { kind: 'SCALAR', name: 'ID' }, + { kind: 'OBJECT', name: '__Schema', fields: [] }, + ], + directives: [ + { + name: 'include', + locations: ['FIELD', 'FRAGMENT_SPREAD', 'INLINE_FRAGMENT'], + args: [ + { name: 'if', type: { kind: 'NON_NULL', ofType: { kind: 'SCALAR', name: 'Boolean' } } }, + ], + }, + { + name: 'cacheControl', + locations: ['FIELD_DEFINITION', 'OBJECT', 'INTERFACE'], + args: [{ name: 'maxAge', type: { kind: 'SCALAR', name: 'Int' } }], + }, + ], + }, +}; + +describe('introspection-to-sdl', () => { + test('printTypeRef should render nested types', () => { + expect(printTypeRef({ kind: 'SCALAR', name: 'String' })).toBe('String'); + expect(printTypeRef({ kind: 'NON_NULL', ofType: { kind: 'SCALAR', name: 'ID' } })).toBe('ID!'); + expect( + printTypeRef({ + kind: 'LIST', + ofType: { kind: 'NON_NULL', ofType: { kind: 'OBJECT', name: 'User' } }, + }) + ).toBe('[User!]'); + }); + + test('countIntrospectionTypes should exclude built-ins and introspection types', () => { + expect(countIntrospectionTypes(MINIMAL_INTROSPECTION)).toBe(2); + }); + + test('introspectionToSDL should print object types and omit built-in scalars', () => { + const sdl = introspectionToSDL(MINIMAL_INTROSPECTION); + + expect(sdl).toContain('type Query {'); + expect(sdl).toContain('users: [User!]'); + expect(sdl).toContain('type User {'); + expect(sdl).toContain('id: ID!'); + expect(sdl).toContain('name: String'); + expect(sdl).not.toContain('scalar String'); + expect(sdl).not.toContain('type __Schema'); + expect(sdl).not.toContain('directive @include'); + expect(sdl).toContain('directive @cacheControl'); + }); + + test('introspectionToSDL should print unions, enums, interfaces, and inputs', () => { + const introspection = { + __schema: { + queryType: { name: 'Query' }, + types: [ + { + kind: 'OBJECT', + name: 'Query', + fields: [{ name: 'node', args: [], type: { kind: 'INTERFACE', name: 'Node' } }], + }, + { + kind: 'INTERFACE', + name: 'Node', + interfaces: [], + fields: [ + { + name: 'id', + args: [], + type: { kind: 'NON_NULL', ofType: { kind: 'SCALAR', name: 'ID' } }, + }, + ], + }, + { + kind: 'OBJECT', + name: 'User', + interfaces: [{ name: 'Node' }], + fields: [ + { + name: 'id', + args: [], + type: { kind: 'NON_NULL', ofType: { kind: 'SCALAR', name: 'ID' } }, + }, + ], + }, + { + kind: 'UNION', + name: 'SearchResult', + possibleTypes: [{ name: 'User' }], + }, + { + kind: 'ENUM', + name: 'Role', + enumValues: [ + { name: 'ADMIN' }, + { name: 'USER', deprecationReason: 'No longer supported' }, + ], + }, + { + kind: 'INPUT_OBJECT', + name: 'UserInput', + inputFields: [ + { + name: 'name', + type: { kind: 'NON_NULL', ofType: { kind: 'SCALAR', name: 'String' } }, + }, + ], + }, + { kind: 'SCALAR', name: 'String' }, + { kind: 'SCALAR', name: 'ID' }, + ], + directives: [], + }, + }; + + const sdl = introspectionToSDL(introspection); + + expect(sdl).toContain('interface Node {'); + expect(sdl).toContain('type User implements Node {'); + expect(sdl).toContain('union SearchResult = User'); + expect(sdl).toContain('enum Role {'); + expect(sdl).toContain('USER @deprecated'); + expect(sdl).toContain('input UserInput {'); + expect(sdl).toContain('name: String!'); + }); + + test('introspectionToSDL should preserve block descriptions', () => { + const introspection = { + __schema: { + queryType: { name: 'Query' }, + types: [ + { + kind: 'OBJECT', + name: 'Account', + description: 'An organization in Apollo Studio.\n\nCan have multiple members.', + fields: [ + { + name: 'canChangePlan', + description: 'Used by Studio to show the Change Plan button', + args: [], + type: { kind: 'NON_NULL', ofType: { kind: 'SCALAR', name: 'Boolean' } }, + }, + ], + }, + { + kind: 'OBJECT', + name: 'Query', + fields: [{ name: 'account', args: [], type: { kind: 'OBJECT', name: 'Account' } }], + }, + { kind: 'SCALAR', name: 'Boolean' }, + ], + directives: [], + }, + }; + + const sdl = introspectionToSDL(introspection); + + expect(sdl).toContain('Can have multiple members.'); + expect(sdl).toContain('Used by Studio to show the Change Plan button'); + expect(sdl).toContain('canChangePlan: Boolean!'); + }); + + test('introspectionToSDL should emit schema block for non-standard root type names', () => { + const introspection = { + __schema: { + queryType: { name: 'RootQuery' }, + mutationType: { name: 'RootMutation' }, + types: [ + { + kind: 'OBJECT', + name: 'RootQuery', + fields: [{ name: 'ping', args: [], type: { kind: 'SCALAR', name: 'String' } }], + }, + { kind: 'OBJECT', name: 'RootMutation', fields: [] }, + { kind: 'SCALAR', name: 'String' }, + ], + directives: [], + }, + }; + + const sdl = introspectionToSDL(introspection); + + expect(sdl).toContain('schema {'); + expect(sdl).toContain('query: RootQuery'); + expect(sdl).toContain('mutation: RootMutation'); + }); + + test('introspectionToSDL should throw on invalid input', () => { + expect(() => introspectionToSDL(null)).toThrow('missing __schema'); + expect(() => introspectionToSDL({})).toThrow('missing __schema'); + }); +}); diff --git a/tests/unit/schema-loader.test.js b/tests/unit/schema-loader.test.js new file mode 100644 index 0000000..232cd4d --- /dev/null +++ b/tests/unit/schema-loader.test.js @@ -0,0 +1,244 @@ +import { + buildOriginCandidates, + probeEndpoint, + fetchIntrospection, + loadSchema, +} from '../../js/sw/schema-loader.js'; +import { + recordEndpoint, + getObservedEndpoints, + clearObservedEndpoints, +} from '../../js/sw/endpoint-tracker.js'; + +function jsonResponse(body) { + return Promise.resolve({ json: () => Promise.resolve(body) }); +} + +describe('Endpoint Tracker', () => { + afterEach(() => { + clearObservedEndpoints(1); + clearObservedEndpoints(2); + }); + + test('should record and normalize endpoint URLs', () => { + recordEndpoint(1, 'https://api.test.com/graphql?op=GetUsers#frag'); + expect(getObservedEndpoints(1)).toEqual(['https://api.test.com/graphql']); + }); + + test('should deduplicate endpoints', () => { + recordEndpoint(1, 'https://api.test.com/graphql'); + recordEndpoint(1, 'https://api.test.com/graphql?x=1'); + expect(getObservedEndpoints(1)).toHaveLength(1); + }); + + test('should track endpoints per tab', () => { + recordEndpoint(1, 'https://a.com/graphql'); + recordEndpoint(2, 'https://b.com/graphql'); + expect(getObservedEndpoints(1)).toEqual(['https://a.com/graphql']); + expect(getObservedEndpoints(2)).toEqual(['https://b.com/graphql']); + }); + + test('should ignore invalid input', () => { + recordEndpoint(1, 'not-a-url'); + recordEndpoint(1, null); + recordEndpoint('x', 'https://a.com/graphql'); + expect(getObservedEndpoints(1)).toEqual([]); + }); + + test('should clear endpoints for a tab', () => { + recordEndpoint(1, 'https://a.com/graphql'); + clearObservedEndpoints(1); + expect(getObservedEndpoints(1)).toEqual([]); + }); +}); + +describe('Schema Loader', () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + clearObservedEndpoints(1); + delete global.fetch; + }); + + describe('buildOriginCandidates', () => { + test('should guess common GraphQL paths on the page origin', () => { + const candidates = buildOriginCandidates('https://app.test.com/dashboard'); + expect(candidates).toContain('https://app.test.com/graphql'); + expect(candidates).toContain('https://app.test.com/api/graphql'); + }); + + test('should skip origin guesses for invalid tab URLs', () => { + expect(buildOriginCandidates('chrome://extensions')).toEqual([]); + }); + + test('should exclude endpoints that were already tried', () => { + const candidates = buildOriginCandidates('https://app.test.com/', [ + 'https://app.test.com/graphql', + ]); + expect(candidates).not.toContain('https://app.test.com/graphql'); + expect(candidates).toContain('https://app.test.com/api/graphql'); + }); + }); + + describe('probeEndpoint', () => { + test('should qualify on data.__typename', async () => { + global.fetch.mockReturnValue(jsonResponse({ data: { __typename: 'Query' } })); + await expect(probeEndpoint('https://a.com/graphql')).resolves.toBe(true); + }); + + test('should qualify on GraphQL-shaped errors', async () => { + global.fetch.mockReturnValue( + jsonResponse({ errors: [{ message: 'PersistedQueryNotFound' }] }) + ); + await expect(probeEndpoint('https://a.com/graphql')).resolves.toBe(true); + }); + + test('should reject non-GraphQL JSON', async () => { + global.fetch.mockReturnValue(jsonResponse({ hello: 'world' })); + await expect(probeEndpoint('https://a.com/api')).resolves.toBe(false); + }); + + test('should reject on network errors', async () => { + global.fetch.mockRejectedValue(new Error('ECONNREFUSED')); + await expect(probeEndpoint('https://a.com/graphql')).resolves.toBe(false); + }); + + test('should reject on non-JSON responses', async () => { + global.fetch.mockResolvedValue({ json: () => Promise.reject(new Error('not json')) }); + await expect(probeEndpoint('https://a.com/page')).resolves.toBe(false); + }); + }); + + describe('fetchIntrospection', () => { + test('should return introspection data on success', async () => { + const data = { __schema: { types: [] } }; + global.fetch.mockReturnValue(jsonResponse({ data })); + await expect(fetchIntrospection('https://a.com/graphql')).resolves.toEqual(data); + }); + + test('should report disabled introspection clearly', async () => { + global.fetch.mockReturnValue( + jsonResponse({ errors: [{ message: 'GraphQL introspection is not allowed' }] }) + ); + await expect(fetchIntrospection('https://a.com/graphql')).rejects.toThrow( + 'Introspection is disabled' + ); + }); + + test('should surface other GraphQL errors', async () => { + global.fetch.mockReturnValue(jsonResponse({ errors: [{ message: 'Unauthorized' }] })); + await expect(fetchIntrospection('https://a.com/graphql')).rejects.toThrow('Unauthorized'); + }); + + test('should fail on network errors', async () => { + global.fetch.mockRejectedValue(new Error('timeout')); + await expect(fetchIntrospection('https://a.com/graphql')).rejects.toThrow('Failed to reach'); + }); + + test('should fail on invalid result shape', async () => { + global.fetch.mockReturnValue(jsonResponse({ data: {} })); + await expect(fetchIntrospection('https://a.com/graphql')).rejects.toThrow( + 'valid introspection result' + ); + }); + }); + + describe('loadSchema', () => { + test('should skip detection when an explicit URL is given', async () => { + const data = { __schema: { types: [] } }; + global.fetch.mockReturnValue(jsonResponse({ data })); + + const result = await loadSchema({ + tabId: 1, + tabUrl: 'https://app.test.com/', + url: 'https://custom.com/graphql', + }); + + expect(result.endpoint).toBe('https://custom.com/graphql'); + expect(result.introspection).toEqual(data); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + test('should introspect observed endpoints directly with their captured headers', async () => { + recordEndpoint(1, 'https://api.test.com/gql-observed', [ + { name: 'client-info', value: 'web' }, + ]); + const data = { __schema: { types: [] } }; + global.fetch.mockReturnValue(jsonResponse({ data })); + + const progress = []; + const result = await loadSchema({ + tabId: 1, + tabUrl: 'https://app.test.com/', + onProgress: (m) => progress.push(m), + }); + + expect(result.endpoint).toBe('https://api.test.com/gql-observed'); + expect(result.introspection).toEqual(data); + // Observed endpoints are known GraphQL endpoints — no probing round trip + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.test.com/gql-observed', + expect.objectContaining({ + headers: expect.objectContaining({ 'client-info': 'web' }), + }) + ); + expect(progress.some((m) => m.includes('captured endpoint'))).toBe(true); + }); + + test('should fall back to probing origin guesses when captured endpoints fail', async () => { + recordEndpoint(1, 'https://api.test.com/gql-observed'); + const data = { __schema: { types: [] } }; + + global.fetch.mockImplementation((url, options) => { + if (url === 'https://api.test.com/gql-observed') { + return Promise.reject(new Error('403 Forbidden')); + } + const body = JSON.parse(options.body); + if (body.query === '{__typename}') { + return url === 'https://app.test.com/graphql' + ? jsonResponse({ data: { __typename: 'Query' } }) + : jsonResponse({ message: 'not found' }); + } + return jsonResponse({ data }); + }); + + const result = await loadSchema({ tabId: 1, tabUrl: 'https://app.test.com/' }); + + expect(result.endpoint).toBe('https://app.test.com/graphql'); + expect(result.introspection).toEqual(data); + }); + + test('should report why captured endpoints failed instead of a generic error', async () => { + recordEndpoint(1, 'https://api.test.com/graphql'); + + global.fetch.mockImplementation((url, options) => { + const body = JSON.parse(options.body); + if (body.query === '{__typename}') { + return jsonResponse({ message: 'not found' }); + } + return jsonResponse({ errors: [{ message: 'GraphQL introspection is not allowed' }] }); + }); + + await expect(loadSchema({ tabId: 1, tabUrl: 'https://app.test.com/' })).rejects.toThrow( + /captured endpoint.*Introspection is disabled/ + ); + }); + + test('should fail when no endpoint responds', async () => { + global.fetch.mockRejectedValue(new Error('refused')); + + await expect(loadSchema({ tabId: 99, tabUrl: 'https://app.test.com/' })).rejects.toThrow( + 'No GraphQL endpoint detected' + ); + }); + + test('should fail with guidance when there are no candidates', async () => { + await expect(loadSchema({ tabId: 99, tabUrl: 'chrome://extensions' })).rejects.toThrow( + 'Enter the GraphQL endpoint URL manually' + ); + }); + }); +}); diff --git a/tests/unit/sdl-highlighter.test.js b/tests/unit/sdl-highlighter.test.js new file mode 100644 index 0000000..2054e72 --- /dev/null +++ b/tests/unit/sdl-highlighter.test.js @@ -0,0 +1,43 @@ +/** + * @jest-environment jsdom + */ + +import { highlightSDL } from '../../js/ui/sdl-highlighter.js'; + +describe('highlightSDL', () => { + test('should highlight keywords, fields, builtins, and custom types', () => { + const html = highlightSDL('type Query {\n users: [User!]!\n}'); + + expect(html).toContain('type'); + expect(html).toContain('Query'); + expect(html).toContain('users'); + expect(html).toContain('User'); + expect(html).toContain('!'); + }); + + test('should highlight enum values and directives', () => { + const html = highlightSDL( + 'enum Status {\n ACTIVE\n INACTIVE\n}\n\ndirective @deprecated(reason: String = "old") on FIELD_DEFINITION' + ); + + expect(html).toContain('ACTIVE'); + expect(html).toContain('@deprecated'); + expect(html).toContain('"old"'); + expect(html).toContain('on'); + }); + + test('should highlight block string descriptions', () => { + const html = highlightSDL('"""\nAccount info.\n"""\ntype Account {\n id: ID\n}'); + + expect(html).toContain(''); + expect(html).toContain('Account info.'); + expect(html).toContain('ID'); + }); + + test('should escape HTML in SDL text', () => { + const html = highlightSDL('type Query {\n note: String\n # \n}'); + + expect(html).not.toContain('