diff --git a/README.md b/README.md index 821cd5c..f566257 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,11 @@ A Chrome/Edge DevTools extension that intercepts network responses and replaces | ------------------------------------------------------------ | --------------------------------------------------------------------- | | ![Override rules](store-assets/v2.2.0/03-override-rules.png) | ![Save and retry feedback](store-assets/v2.2.0/04-save-and-retry.png) | -## Features - +- **Real-time API Type & Tab Counters**: Live request counters next to each resource filter (`XHR (5)`, `Fetch (12)`, `Doc (1)`, `JS (3)`, `CSS (0)`) and in tab headers (`Captured APIs (15)`, `Overridden (3)`, `Rules (2)`). +- **High-Performance LRU Regex & Batch DOM Rendering**: LRU-cached `RegExp` pattern matching (`regexCache`) and `DocumentFragment` batch DOM rendering to handle heavy network traffic without UI lag. +- **JS Bundling & Minimal Dist Packaging**: Build pipeline automatically concatenates scripts into 5 clean JS bundles (`background.bundle.js`, `ui.bundle.js`, `panel.js`, `popup.js`, `devtools.js`) and cleans unbundled source files for optimal store uploads. +- **Modern Minimalist Vector Branding**: Sleek vector icons with 3 customizable design variants stored in `icons/concepts/`. +- **Persistent MV3 Worker Rehydration & Port Retries**: Automatic tab state rehydration from `chrome.storage.session` and automatic 150ms message port retries to handle Chrome background worker sleep/wake cycles gracefully. - **Enable/disable** overrides per active tab via a toggle switch. - **Per-rule enable/disable toggle**: disable an individual rule without deleting it; it stays visible (dimmed) and is skipped by the background worker until re-enabled. - **HTTP method matching**: scope a rule to `GET`/`POST`/`PUT`/`PATCH`/`DELETE`, or leave it at `Any` to match every method (default, pre-filled from the captured request when available). @@ -24,7 +27,7 @@ A Chrome/Edge DevTools extension that intercepts network responses and replaces - **Duplicate Rules**: 1-click clone any override rule directly in the rules list. - **Request Headers & Response Headers Overriding**: inject or modify request headers (e.g. `Authorization: Bearer token`) during the request stage or extra response headers during the response stage. - **Response Image & Visual Preview**: instant image preview (Base64 PNG/JPG, SVG) directly inside the editor modal. -- **Dynamic Captured Resource Filters**: toggle body capture for XHR, Fetch, Document, Script, or Stylesheet resources. +- **Dynamic Captured Resource Filters**: toggle body capture for XHR, Fetch, Document, Script, or Stylesheet resources with real-time counters. - **Three pattern matching modes** for override rules: - URL substring match (e.g. `/api/users`) - Wildcard `*` glob (e.g. `https://old.com/api/*/users` → `*` captures matching segments) @@ -38,6 +41,12 @@ A Chrome/Edge DevTools extension that intercepts network responses and replaces - **View captured APIs**, grouped by resource type (XHR, Fetch, JS, CSS, Img, Doc, WS, etc.), with real-time updates from the background service worker. - **Search APIs** by URL substring. - **One-click override creation**: Click any API in the list to open the modal and create/edit an override rule. +- **Dynamic Response Templating**: Insert dynamic placeholders into mock responses (`{{$uuid}}`, `{{$isoDate}}`, `{{$epoch}}`, `{{$randomEmail}}`, `{{$randomName}}`, `{{$randomInt(min, max)}}`, `{{$query(paramName)}}`). +- **Global Cross-Domain Rules**: Scope rules globally across all domains (`isGlobal`), highlighted with a `GLOBAL` badge in the rules list. +- **Request Payload Interception & Modification**: Modify outgoing request payloads (`postData`) during the CDP request stage. +- **HAR File Import**: Drag & drop or import `.har` files (HTTP Archive) to generate mock rules in bulk. +- **Traffic Analytics**: Track total overridden and failed request statistics per active tab. +- **Editor Keyboard Shortcuts**: Modal hotkeys `Ctrl+Enter` / `Cmd+Enter` to save and `Ctrl+Shift+F` / `Cmd+Shift+F` to format JSON. - **Auto-fill response body**: When creating a new override, the current response body is automatically fetched from the background worker and pre-filled into the editor. - **JSON formatting**: Auto-detect and format JSON bodies with a single button. - **Copy cURL**: Copy any API request as a cURL command. @@ -48,24 +57,56 @@ A Chrome/Edge DevTools extension that intercepts network responses and replaces ``` src/ -├── background.ts # Service-worker bootstrap -├── background/ # Debugger lifecycle, interception, encoding, capture, message routing -├── ui.ts # Shared UI state and controller orchestration -├── ui/ # Reusable modal, rules, headers, dialogs, notifications, profiles, import/export -├── shared.ts # Shared OverrideRule, ApiEntry, and header types -├── tab-state.ts # Per-tab state, session persistence, and worker rehydration -├── panel.ts # DevTools panel initialization and HAR streaming -├── popup.ts # Action popup initialization -└── utils.ts # Pattern matching, wildcard, and origin helpers - -styles.css # CSS entrypoint -styles/ # Base, feature, modal/rules, primitive, and guide styles -scripts/ # Smoke, Store screenshot, and packaging automation -store-assets/screenshots/ # Chrome Web Store-ready screenshots -dist/ # Compiled JavaScript (generated by TypeScript) -panel.html # DevTools panel shell -popup.html # Action popup shell -manifest.json # Manifest V3 configuration +├── background.ts # Service Worker entrypoint bootstrap +├── devtools.ts # Chrome DevTools extension tab registration +├── panel.ts # DevTools panel entrypoint & HAR streaming +├── popup.ts # Action popup entrypoint +├── ui.ts # Shared UI state & controller orchestration +├── shared.ts # Shared OverrideRule, ApiEntry, and header types +├── tab-state.ts # Per-tab state store, session persistence, & worker rehydration +├── utils.ts # Pattern matching, wildcard, dynamic templates, LRU regex cache, & origin helpers +├── background/ +│ ├── api-capture.ts # CDP Network event listener & API tracking +│ ├── debugger-controller.ts # chrome.debugger attach/detach & domain setup +│ ├── encoding.ts # Base64 response body encoding helpers +│ ├── interceptor.ts # Fetch.requestPaused request/response/fail interceptor +│ └── message-router.ts # Background message listener & route handler +└── ui/ + ├── api-list.ts # Render captured APIs & resource type counters + ├── attach-status.ts # Status badge renderer (green/red) + ├── curl.ts # cURL command generator & parser + ├── dialogs.ts # Prompt & confirmation modal dialogs + ├── har.ts # HAR (HTTP Archive) spec parser + ├── headers-editor.ts # Request & Response headers editor table/textarea + ├── modal-controller.ts # Override editor modal event handlers + ├── modal.ts # Override modal UI state & visibility + ├── notifications.ts # Toast notifications (success/warning/error) + ├── persistence.ts # Storage persistence queue & error handler + ├── primitives.ts # UI element creation primitives + ├── profiles.ts # Per-domain rule profiles & presets + ├── rules-io-controller.ts # Import/export JSON rules & HAR/Swagger drag-and-drop + ├── rules-list.ts # Render saved rules list with action buttons & badges + ├── swagger.ts # Swagger / OpenAPI spec parser + ├── toolbar-controller.ts# Enable toggle, search, refresh, tabs, & action buttons + ├── types.ts # UI state & elements interfaces + └── view-utils.ts # Highlighting & label formatting helpers + +styles.css # CSS stylesheet entrypoint +styles/ # Modular CSS stylesheets (base, feature, modal/rules, primitive, guide) +scripts/ # Automation scripts: +├── bundle.mjs # Bundles TypeScript outputs into 5 clean JS files & cleans dist/ +├── package-store.mjs # Store zip packager +├── smoke.mjs # Real Chromium Playwright integration smoke test +└── capture-store-screenshots.mjs # Store assets screenshot generator + +icons/ # Active extension icons (16x16, 48x48, 128x128) +icons/concepts/ # 3 concept icon design variants (concept-1, concept-2, concept-3) +dist/ # Bundled JavaScript outputs (background.bundle.js, ui.bundle.js, panel.js, popup.js, devtools.js) +devtools.html # DevTools tab registrar page +panel.html # DevTools panel page +popup.html # Extension action popup page +guide.html # Bundled offline user guide +manifest.json # Chrome Manifest V3 configuration ``` ### Key flows diff --git a/USE-EN.md b/USE-EN.md index 92bdc0d..a890164 100644 --- a/USE-EN.md +++ b/USE-EN.md @@ -368,21 +368,52 @@ Click the **Refresh** button (↻) in the top right. The extension retries up to APIs are grouped by resource type: -| Type | Label | -| ----------- | ----------- | -| XHR | XHR | -| Fetch | Fetch | -| JS | JS | -| CSS | CSS | -| Image | Img | -| Media | Media | -| Font | Font | -| Document | Doc | -| WebSocket | WS | -| Manifest | Manifest | -| EventSource | EventSource | -| TextTrack | TextTrack | -| Other | Other | +| Type | Label | +| -------- | ----- | +| XHR | XHR | +| Fetch | Fetch | +| JS | JS | +| CSS | CSS | +| Image | Img | +| Media | Media | +| Font | Font | +| Document | Doc | + +### 9.5. Dynamic Response Templating + +Insert dynamic placeholders into mock response bodies: + +- `{{$uuid}}`: Generates a random UUID v4 (e.g. `c9bf9e57-1685-4c89-bafb-ff5af830be8a`). +- `{{$isoDate}}`: Current ISO 8601 timestamp (`2026-08-09T10:30:00.000Z`). +- `{{$epoch}}`: Current Unix epoch timestamp in milliseconds. +- `{{$randomEmail}}`: Generates a random test email (`user_x82a9@example.com`). +- `{{$randomName}}`: Generates a random full name (`Alex Rivers`). +- `{{$randomInt(min, max)}}`: Generates a random integer between `min` and `max`. +- `{{$query(paramName)}}`: Extracts the query parameter `paramName` directly from the request URL. + +### 9.6. Global Rules + +Check **Global Rule** when creating or editing a rule to apply it across **all domains**. Global rules display a prominent `GLOBAL` badge in the rules list. + +### 9.7. Request Payload Modification + +Override outgoing POST, PUT, or PATCH request payloads before they reach the server by filling in the **Request Payload** field in the editor modal. + +### 9.8. HAR File Import + +Import `.har` files (HTTP Archive exported from DevTools Network tab) to automatically convert recorded network requests into mock rules. + +### 9.9. Editor Keyboard Shortcuts + +In the Override Modal editor: + +- **`Ctrl + Enter`** (or **`Cmd + Enter`** on macOS): Save override rule. +- **`Ctrl + Shift + F`** (or **`Cmd + Shift + F`** on macOS): Format JSON response body. + | WebSocket | WS | + | Manifest | Manifest | + | EventSource | EventSource | + | TextTrack | TextTrack | + | Other | Other | Click a group header (e.g. "XHR ▼") to collapse/expand. Collapse state is persisted in storage. diff --git a/USE.md b/USE.md index b64f480..46b7ff0 100644 --- a/USE.md +++ b/USE.md @@ -368,6 +368,44 @@ Click nút **Refresh** (↻) ở góc trên bên phải. Extension sẽ thử 5 API được nhóm theo resource type: +- **Fetch / XHR**: API calls +- **JS**: Script files +- **CSS**: Style files +- **Img**: Images +- **Doc**: HTML documents +- **WS**: WebSockets + +### 9.5. Templating Động (Dynamic Response Templates) + +Cho phép chèn các biến sinh tự động vào nội dung Response Mock Body: + +- `{{$uuid}}`: Tạo ngẫu nhiên UUID v4 (ví dụ `c9bf9e57-1685-4c89-bafb-ff5af830be8a`). +- `{{$isoDate}}`: Ngày giờ hiện tại chuẩn ISO 8601 (`2026-08-09T10:30:00.000Z`). +- `{{$epoch}}`: Unix timestamp (tính bằng ms). +- `{{$randomEmail}}`: Tạo email thử nghiệm ngẫu nhiên (`user_x82a9@example.com`). +- `{{$randomName}}`: Tạo tên ngẫu nhiên (`Alex Rivers`). +- `{{$randomInt(1, 100)}}`: Sinh số nguyên ngẫu nhiên trong khoảng `min` tới `max`. +- `{{$query(id)}}`: Trích xuất trực tiếp giá trị của Query Parameter `id` từ Request URL. + +### 9.6. Quy tắc Toàn cục (Global Rules) + +Tích chọn tùy chọn **Global Rule** khi tạo/chỉnh sửa quy tắc để áp dụng rule này trên **tất cả các domain**. Các Global Rule sẽ có nhãn badge **GLOBAL** nổi bật trong danh sách Rules. + +### 9.7. Can thiệp & Ghi đè Request Payload + +Cho phép sửa đổi dữ liệu Request Payload (body của các request `POST`, `PUT`, `PATCH`) trước khi gửi lên Server bằng cách nhập nội dung mới vào ô **Request Payload** trong editor modal. + +### 9.8. Import HAR File (HTTP Archive) + +Hỗ trợ Import trực tiếp file `.har` (được export từ DevTools Network tab): Hệ thống sẽ tự động phân tích và chuyển đổi lịch sử traffic mạng ghi trong file HAR thành danh sách các Mock Rules sẵn sàng sử dụng. + +### 9.9. Phím tắt Thao tác Nhanh (Keyboard Shortcuts) + +Tại Editor Modal: + +- **`Ctrl + Enter`** (hoặc **`Cmd + Enter`** trên macOS): Lưu quy tắc nhanh (Save Rule). +- **`Ctrl + Shift + F`** (hoặc **`Cmd + Shift + F`** trên macOS): Định dạng JSON tự động (Format JSON). + | Type | Hiển thị | Màu/Icon | | ----------- | ----------- | -------- | | XHR | XHR | -- | diff --git a/guide.html b/guide.html index b09ccb0..f9563b4 100644 --- a/guide.html +++ b/guide.html @@ -80,11 +80,14 @@

3.1. Popup

3 tabs:
  • Search bar: Filter APIs by URL substring.
  • @@ -547,6 +550,42 @@

    9.6. Marker headers

    This lets you easily identify overridden requests in the DevTools Network tab.

    +

    9.7. Dynamic Response Templating

    +

    Insert dynamic placeholders into mock response bodies:

    + + +

    9.8. Global Cross-Domain Rules

    +

    + Mark rules as Global to intercept matching requests across all domains. + Global rules display a GLOBAL badge in the rules list. +

    + +

    9.9. Request Payload Modification

    +

    + Override outgoing POST, PUT, or PATCH request + payloads before they reach the server. +

    + +

    9.10. HAR File Import

    +

    + Drag & drop or import .har files (HTTP Archive) to generate mock rules in + bulk from recorded traffic. +

    + +

    9.11. Editor Keyboard Shortcuts

    + +

    10. Important Notes

    diff --git a/icons/concepts/concept-1/icon-128x128.png b/icons/concepts/concept-1/icon-128x128.png new file mode 100644 index 0000000..0530b26 Binary files /dev/null and b/icons/concepts/concept-1/icon-128x128.png differ diff --git a/icons/concepts/concept-1/icon-16x16.png b/icons/concepts/concept-1/icon-16x16.png new file mode 100644 index 0000000..bb1ede1 Binary files /dev/null and b/icons/concepts/concept-1/icon-16x16.png differ diff --git a/icons/concepts/concept-1/icon-48x48.png b/icons/concepts/concept-1/icon-48x48.png new file mode 100644 index 0000000..e6416ea Binary files /dev/null and b/icons/concepts/concept-1/icon-48x48.png differ diff --git a/icons/concepts/concept-1/storeIcon.png b/icons/concepts/concept-1/storeIcon.png new file mode 100644 index 0000000..0530b26 Binary files /dev/null and b/icons/concepts/concept-1/storeIcon.png differ diff --git a/icons/concepts/concept-2/icon-128x128.png b/icons/concepts/concept-2/icon-128x128.png new file mode 100644 index 0000000..5a2ae15 Binary files /dev/null and b/icons/concepts/concept-2/icon-128x128.png differ diff --git a/icons/concepts/concept-2/icon-16x16.png b/icons/concepts/concept-2/icon-16x16.png new file mode 100644 index 0000000..fd13ced Binary files /dev/null and b/icons/concepts/concept-2/icon-16x16.png differ diff --git a/icons/concepts/concept-2/icon-48x48.png b/icons/concepts/concept-2/icon-48x48.png new file mode 100644 index 0000000..e52728b Binary files /dev/null and b/icons/concepts/concept-2/icon-48x48.png differ diff --git a/icons/concepts/concept-2/storeIcon.png b/icons/concepts/concept-2/storeIcon.png new file mode 100644 index 0000000..5a2ae15 Binary files /dev/null and b/icons/concepts/concept-2/storeIcon.png differ diff --git a/icons/concepts/concept-3/icon-128x128.png b/icons/concepts/concept-3/icon-128x128.png new file mode 100644 index 0000000..675e66b Binary files /dev/null and b/icons/concepts/concept-3/icon-128x128.png differ diff --git a/icons/concepts/concept-3/icon-16x16.png b/icons/concepts/concept-3/icon-16x16.png new file mode 100644 index 0000000..c7bfd1b Binary files /dev/null and b/icons/concepts/concept-3/icon-16x16.png differ diff --git a/icons/concepts/concept-3/icon-48x48.png b/icons/concepts/concept-3/icon-48x48.png new file mode 100644 index 0000000..0dd6e88 Binary files /dev/null and b/icons/concepts/concept-3/icon-48x48.png differ diff --git a/icons/concepts/concept-3/storeIcon.png b/icons/concepts/concept-3/storeIcon.png new file mode 100644 index 0000000..675e66b Binary files /dev/null and b/icons/concepts/concept-3/storeIcon.png differ diff --git a/icons/icon-128x128.png b/icons/icon-128x128.png index 6fe178e..0530b26 100644 Binary files a/icons/icon-128x128.png and b/icons/icon-128x128.png differ diff --git a/icons/icon-16x16.png b/icons/icon-16x16.png index 4617b0b..bb1ede1 100644 Binary files a/icons/icon-16x16.png and b/icons/icon-16x16.png differ diff --git a/icons/icon-48x48.png b/icons/icon-48x48.png index b2dc95e..e6416ea 100644 Binary files a/icons/icon-48x48.png and b/icons/icon-48x48.png differ diff --git a/icons/storeIcon.png b/icons/storeIcon.png index 2da14d9..0530b26 100644 Binary files a/icons/storeIcon.png and b/icons/storeIcon.png differ diff --git a/manifest.json b/manifest.json index 83a91f1..b39c25c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,12 +1,12 @@ { "manifest_version": 3, "name": "Network Overrides API (DevTools)", - "version": "2.2.0", + "version": "2.3.0", "description": "Debugging tool that intercepts and overrides network API responses from DevTools panel or popup. For developers only.", "permissions": ["storage", "debugger"], "host_permissions": [""], "background": { - "service_worker": "dist/background.js" + "service_worker": "dist/background.bundle.js" }, "devtools_page": "devtools.html", "action": { diff --git a/package-lock.json b/package-lock.json index 9f9c1fc..9f02276 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "network-overrides-devtools", - "version": "2.0.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "network-overrides-devtools", - "version": "2.0.1", + "version": "2.3.0", "devDependencies": { "@commitlint/cli": "^19.5.0", "@commitlint/config-conventional": "^19.5.0", diff --git a/package.json b/package.json index 7b4cd9e..4afccc7 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "network-overrides-devtools", - "version": "2.2.0", + "version": "2.3.0", "private": true, "engines": { "node": ">=22" }, "scripts": { - "build": "tsc", + "build": "tsc && node scripts/bundle.mjs", "watch": "tsc --watch", "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "lint": "eslint src", diff --git a/panel.html b/panel.html index 38b4bf5..7b09fbf 100644 --- a/panel.html +++ b/panel.html @@ -302,26 +302,7 @@

    Import Rules from cURL or OpenAPI / Swagger Spec

    - - - - - - - - - - - - - - - - - - - - + diff --git a/popup.html b/popup.html index 67c40a5..18a2316 100644 --- a/popup.html +++ b/popup.html @@ -302,26 +302,7 @@

    Import Rules from cURL or OpenAPI / Swagger Spec

    - - - - - - - - - - - - - - - - - - - - + diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs new file mode 100644 index 0000000..52092fa --- /dev/null +++ b/scripts/bundle.mjs @@ -0,0 +1,89 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const distDir = path.join(repoRoot, 'dist'); + +const backgroundFiles = [ + 'utils.js', + 'shared.js', + 'tab-state.js', + 'background/encoding.js', + 'background/api-capture.js', + 'background/interceptor.js', + 'background/debugger-controller.js', + 'background/message-router.js', + 'background.js', +]; + +const uiFiles = [ + 'utils.js', + 'shared.js', + 'ui/types.js', + 'ui/view-utils.js', + 'ui/primitives.js', + 'ui/notifications.js', + 'ui/dialogs.js', + 'ui/persistence.js', + 'ui/attach-status.js', + 'ui/curl.js', + 'ui/swagger.js', + 'ui/har.js', + 'ui/headers-editor.js', + 'ui/modal.js', + 'ui/rules-list.js', + 'ui/api-list.js', + 'ui/profiles.js', + 'ui/modal-controller.js', + 'ui/rules-io-controller.js', + 'ui/toolbar-controller.js', + 'ui.js', +]; + +async function bundleFiles(fileList, outputFile, filterFn) { + const contents = []; + for (const relFile of fileList) { + const filePath = path.join(distDir, relFile); + let text = await fs.readFile(filePath, 'utf8'); + if (filterFn) { + text = filterFn(text, relFile); + } + contents.push(`/* --- ${relFile} --- */\n${text}`); + } + const bundledContent = contents.join('\n\n'); + await fs.writeFile(path.join(distDir, outputFile), bundledContent, 'utf8'); +} + +async function main() { + await bundleFiles(backgroundFiles, 'background.bundle.js', (text, file) => { + if (file === 'background.js') { + return text.replace(/importScripts\([\s\S]*?\);?/g, '// importScripts bundled'); + } + return text; + }); + + await bundleFiles(uiFiles, 'ui.bundle.js'); + + // Clean up unbundled files and subdirectories so dist only contains essential bundles and entrypoints + const keepFiles = new Set([ + 'background.bundle.js', + 'ui.bundle.js', + 'devtools.js', + 'panel.js', + 'popup.js', + ]); + + const entries = await fs.readdir(distDir, { withFileTypes: true }); + for (const entry of entries) { + if (keepFiles.has(entry.name)) continue; + const entryPath = path.join(distDir, entry.name); + await fs.rm(entryPath, { recursive: true, force: true }); + } + + console.log('Bundled dist/ and cleaned unbundled files successfully.'); +} + +main().catch(err => { + console.error('Bundle error:', err); + process.exit(1); +}); diff --git a/scripts/package-store.mjs b/scripts/package-store.mjs index 0b9cd67..b15a5c5 100644 --- a/scripts/package-store.mjs +++ b/scripts/package-store.mjs @@ -13,6 +13,7 @@ const requiredPaths = [ 'popup.html', 'guide.html', 'styles.css', + 'styles', 'dist', 'icons', 'privacy_policy.md', diff --git a/src/background/interceptor.ts b/src/background/interceptor.ts index 5ad23b8..65457f7 100644 --- a/src/background/interceptor.ts +++ b/src/background/interceptor.ts @@ -63,6 +63,7 @@ namespace NetworkOverridesBackground { const postData = params.request?.postData || state.recentApis.get(url)?.postData; const match = findOverride(url, params.request?.method, state.overrides, postData); if (match && match.override.failReason) { + TabState.recordOverrideStat(tabId, true); const errorReason = match.override.failReason; const fail = () => { chrome.debugger.sendCommand( @@ -105,42 +106,50 @@ namespace NetworkOverridesBackground { ); return; } - if ( - match && - Array.isArray(match.override.requestHeaders) && - match.override.requestHeaders.length > 0 - ) { - const originalHeaders = toFetchHeaders(params.request?.headers); - const reqHeaderMap = new Map(); - for (const h of originalHeaders) { - reqHeaderMap.set(h.name.toLowerCase(), h.value); - } - for (const h of match.override.requestHeaders) { - if (h.name && h.name.trim()) { - reqHeaderMap.set(h.name.trim().toLowerCase(), h.value); + const hasReqHeaders = + Boolean(match) && + Array.isArray(match!.override.requestHeaders) && + match!.override.requestHeaders.length > 0; + const hasReqBody = + Boolean(match) && + typeof match!.override.requestBody === 'string' && + match!.override.requestBody.trim().length > 0; + + if (match && (hasReqHeaders || hasReqBody)) { + const continueParams: any = { requestId: params.requestId }; + if (hasReqHeaders && match.override.requestHeaders) { + const originalHeaders = toFetchHeaders(params.request?.headers); + const reqHeaderMap = new Map(); + for (const h of originalHeaders) { + reqHeaderMap.set(h.name.toLowerCase(), h.value); } - } - const updatedHeaders = Array.from(reqHeaderMap.entries()).map(([name, value]) => ({ - name, - value, - })); - const doContinue = () => { - chrome.debugger.sendCommand( - { tabId }, - 'Fetch.continueRequest', - { requestId: params.requestId, headers: updatedHeaders }, - () => { - if (chrome.runtime.lastError) { - proceed(); - } + for (const h of match.override.requestHeaders) { + if (h.name && h.name.trim()) { + reqHeaderMap.set(h.name.trim().toLowerCase(), h.value); } + } + continueParams.headers = Array.from(reqHeaderMap.entries()).map(([name, value]) => ({ + name, + value, + })); + } + if (hasReqBody && match.override.requestBody) { + continueParams.postData = NetworkOverridesStringToBase64Local( + match.override.requestBody ); + } + const doContinueReq = () => { + chrome.debugger.sendCommand({ tabId }, 'Fetch.continueRequest', continueParams, () => { + if (chrome.runtime.lastError) { + proceed(); + } + }); }; const delayMs = typeof match.override.delayMs === 'number' ? match.override.delayMs : 0; if (delayMs > 0) { - setTimeout(doContinue, delayMs); + setTimeout(doContinueReq, delayMs); } else { - doContinue(); + doContinueReq(); } return; } @@ -196,7 +205,7 @@ namespace NetworkOverridesBackground { const rawBody = ov.body || ''; const processedBody = ov.mode !== 'file' - ? NetworkOverridesUtils.processResponseTemplate(rawBody, captures) + ? NetworkOverridesUtils.processResponseTemplate(rawBody, captures, url) : rawBody; if (ov.mode !== 'file') return NetworkOverridesStringToBase64Local(processedBody); try { @@ -258,6 +267,7 @@ namespace NetworkOverridesBackground { ? ov.statusCode : fallbackCode; + TabState.recordOverrideStat(tabId, false); const doFulfill = () => { chrome.debugger.sendCommand( { tabId }, diff --git a/src/shared.ts b/src/shared.ts index 2a004b4..f5ec893 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -16,6 +16,8 @@ declare namespace NetworkOverridesShared { processTemplates?: boolean; // whether to process dynamic template tokens {{now}}, {{uuid}}, etc. delayMs?: number; // 0–120000 ms; body and fail rules failReason?: string; // presence makes this a fail rule (CDP Network.ErrorReason) + isGlobal?: boolean; // applies across all domains if true + requestBody?: string; // override outgoing request payload } interface RuleProfile { id: string; diff --git a/src/tab-state.ts b/src/tab-state.ts index 4f9c62c..6ce7ce8 100644 --- a/src/tab-state.ts +++ b/src/tab-state.ts @@ -11,6 +11,11 @@ namespace NetworkOverridesTabState { type OverrideRule = NetworkOverridesShared.OverrideRule; type ApiEntry = NetworkOverridesShared.ApiEntry; + export interface TabStateStats { + totalOverridden: number; + totalFailed: number; + } + export interface TabState { enabled: boolean; origin: string; @@ -19,6 +24,8 @@ namespace NetworkOverridesTabState { attachError?: string; recentApis: Map; recentApiBodies: Map; + stats: TabStateStats; + throttlePreset?: 'none' | 'fast3g' | 'slow3g' | 'offline'; } // Runtime-only artifacts: never mirrored to storage. @@ -55,12 +62,24 @@ namespace NetworkOverridesTabState { attached: false, recentApis: new Map(), recentApiBodies: new Map(), + stats: { totalOverridden: 0, totalFailed: 0 }, + throttlePreset: 'none', }; states.set(tabId, state); } return state; } + export function recordOverrideStat(tabId: number, isFail = false): void { + const state = ensure(tabId); + if (isFail) { + state.stats.totalFailed++; + } else { + state.stats.totalOverridden++; + } + schedulePersist(tabId); + } + export function runtime(tabId: number): TabRuntime { let rt = runtimes.get(tabId); if (!rt) { @@ -125,6 +144,8 @@ namespace NetworkOverridesTabState { attachError: state.attachError, recentApis: Object.fromEntries(state.recentApis.entries()), recentApiBodies: Object.fromEntries(state.recentApiBodies.entries()), + stats: state.stats, + throttlePreset: state.throttlePreset, }; } diff --git a/src/ui.ts b/src/ui.ts index 40115bf..be97a31 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -11,6 +11,7 @@ /// /// /// +/// /// /// /// @@ -181,7 +182,7 @@ namespace NetworkOverridesUi { return operation; } - function notifyBackground(overrides = state.overrides): Promise { + function notifyBackground(overrides = state.overrides, isRetry = false): Promise { if (activeTabId === null) return Promise.reject(new Error('No active tab is available')); return new Promise((resolve, reject) => { try { @@ -195,7 +196,18 @@ namespace NetworkOverridesUi { }, (response: { success?: boolean; error?: string } | undefined) => { if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); + const lastErrMsg = chrome.runtime.lastError.message || ''; + if ( + !isRetry && + (lastErrMsg.includes('message port closed') || + lastErrMsg.includes('Could not establish connection')) + ) { + window.setTimeout(() => { + notifyBackground(overrides, true).then(resolve, reject); + }, 150); + return; + } + reject(new Error(lastErrMsg)); return; } if (!response || response.success === false) { @@ -386,10 +398,9 @@ namespace NetworkOverridesUi { : false; elements.enableCheckbox.checked = state.enabled; renderRules(); - void notifyBackground().catch(error => { - if (state.enabled) { - showNotification(`Interception could not start: ${errorMessage(error)}`, 'error'); - } + void notifyBackground().catch(() => { + // Startup sync: if port closes during worker startup, ignore silently; + // getStatus will report the real debugger attachment status. }); if (!port) { diff --git a/src/ui/api-list.ts b/src/ui/api-list.ts index 40c29e3..6eda00e 100644 --- a/src/ui/api-list.ts +++ b/src/ui/api-list.ts @@ -87,6 +87,49 @@ namespace NetworkOverridesUi { return match === null; }); + const typeCounts: Record = {}; + let capturedCount = 0; + let overriddenCount = 0; + + state.capturedApis.forEach(api => { + const t = api.type.toLowerCase(); + typeCounts[t] = (typeCounts[t] || 0) + 1; + + const match = getMatchingRuleStatus(api, state.overrides); + if (match !== null && !match.isDisabledOnly) { + overriddenCount++; + } else { + capturedCount++; + } + }); + + typeInputs.forEach(input => { + const typeVal = input.value.toLowerCase(); + const count = typeCounts[typeVal] || 0; + const baseLabel = TYPE_LABELS[typeVal] || typeVal.toUpperCase(); + const parentLabel = input.parentElement; + if (parentLabel) { + const textNode = Array.from(parentLabel.childNodes).find( + n => n.nodeType === Node.TEXT_NODE + ); + if (textNode) { + textNode.nodeValue = ` ${baseLabel} (${count})`; + } + } + }); + + const tabBtns = document.querySelectorAll('.tab-btn'); + tabBtns.forEach(btn => { + const tabType = btn.dataset.tab; + if (tabType === 'other') { + btn.textContent = `Captured APIs (${capturedCount})`; + } else if (tabType === 'overridden') { + btn.textContent = `Overridden (${overriddenCount})`; + } else if (tabType === 'overrides') { + btn.textContent = `Rules (${state.overrides.length})`; + } + }); + apisToRender.sort((a, b) => { const idxA = TYPE_ORDER.indexOf(a.type); const idxB = TYPE_ORDER.indexOf(b.type); @@ -109,6 +152,8 @@ namespace NetworkOverridesUi { return; } + const fragment = document.createDocumentFragment(); + apisToRender.forEach(api => { const item = document.createElement('li'); item.className = 'api-item'; @@ -180,7 +225,9 @@ namespace NetworkOverridesUi { item.appendChild(actionsDiv); item.addEventListener('click', () => onApiClick(api)); - elements.apisList.appendChild(item); + fragment.appendChild(item); }); + + elements.apisList.appendChild(fragment); } } diff --git a/src/ui/har.ts b/src/ui/har.ts new file mode 100644 index 0000000..dbeb634 --- /dev/null +++ b/src/ui/har.ts @@ -0,0 +1,57 @@ +/// + +namespace NetworkOverridesUi { + export function parseHarToRules(content: string): OverrideRule[] { + const trimmed = content.trim(); + if (!trimmed) return []; + + let parsed: any; + try { + parsed = JSON.parse(trimmed); + } catch { + return []; + } + + if (!parsed || typeof parsed !== 'object') return []; + const entries = parsed.log?.entries; + if (!Array.isArray(entries)) return []; + + const rules: OverrideRule[] = []; + + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + const request = entry.request; + const response = entry.response; + if (!request || typeof request.url !== 'string') continue; + + const url = request.url; + const method = typeof request.method === 'string' ? request.method.toUpperCase() : 'ANY'; + const statusCode = typeof response?.status === 'number' ? response.status : undefined; + const text = response?.content?.text; + const isBase64 = Boolean(response?.content?.encoding === 'base64'); + + let body = ''; + if (typeof text === 'string') { + if (isBase64) { + try { + body = atob(text); + } catch { + body = text; + } + } else { + body = text; + } + } + + rules.push({ + pattern: url, + method: method !== 'ANY' ? method : undefined, + mode: 'text', + body, + statusCode: statusCode && statusCode >= 100 && statusCode <= 599 ? statusCode : undefined, + }); + } + + return rules; + } +} diff --git a/src/ui/modal-controller.ts b/src/ui/modal-controller.ts index 27440e4..b0bf995 100644 --- a/src/ui/modal-controller.ts +++ b/src/ui/modal-controller.ts @@ -52,6 +52,16 @@ namespace NetworkOverridesUi { (event.target as HTMLElement).removeAttribute?.('aria-invalid'); if (elements.modalFeedback.classList.contains('is-error')) clearModalFeedback(elements); }); + elements.modal?.addEventListener('keydown', (event: KeyboardEvent) => { + const isCtrlOrCmd = event.ctrlKey || event.metaKey; + if (isCtrlOrCmd && event.key === 'Enter') { + event.preventDefault(); + elements.saveOverrideBtn?.click(); + } else if (isCtrlOrCmd && event.shiftKey && (event.key === 'F' || event.key === 'f')) { + event.preventDefault(); + elements.formatJsonBtn?.click(); + } + }); elements.modalMode?.addEventListener('change', () => { updateImagePreview(elements, elements.modalBody.value); }); diff --git a/src/ui/rules-io-controller.ts b/src/ui/rules-io-controller.ts index 12b7165..987790e 100644 --- a/src/ui/rules-io-controller.ts +++ b/src/ui/rules-io-controller.ts @@ -202,10 +202,15 @@ namespace NetworkOverridesUi { elements.curlSwaggerImportBtn?.addEventListener('click', async () => { const text = elements.curlSwaggerTextarea?.value || ''; const curlRule = parseCurlToRule(text); - const newRules = curlRule ? [curlRule] : parseSwaggerToRules(text); + const harRules = parseHarToRules(text); + const newRules = curlRule + ? [curlRule] + : harRules.length > 0 + ? harRules + : parseSwaggerToRules(text); if (newRules.length === 0) { showNotification( - 'Could not parse any rules from the cURL command or Swagger/OpenAPI specification.', + 'Could not parse any rules from cURL, HAR file, or Swagger/OpenAPI specification.', 'error' ); return; diff --git a/src/ui/rules-list.ts b/src/ui/rules-list.ts index 0fb46ba..3ab5ac4 100644 --- a/src/ui/rules-list.ts +++ b/src/ui/rules-list.ts @@ -12,6 +12,7 @@ namespace NetworkOverridesUi { onDuplicate: (index: number) => void ): void { elements.listEl.innerHTML = ''; + const fragment = document.createDocumentFragment(); state.overrides.forEach((rule, index) => { const li = document.createElement('li'); li.className = 'override-item'; @@ -34,6 +35,15 @@ namespace NetworkOverridesUi { const span = document.createElement('span'); span.className = 'rule-pattern'; + if (rule.isGlobal) { + const globalBadge = document.createElement('span'); + globalBadge.className = 'global-badge rule-global-badge'; + globalBadge.textContent = 'GLOBAL'; + globalBadge.style.cssText = + 'background:#7c3aed;color:#fff;padding:1px 4px;border-radius:3px;font-size:10px;margin-right:4px;font-weight:bold;'; + span.appendChild(globalBadge); + } + const methodText = rule.method && rule.method !== 'ANY' ? rule.method : null; if (methodText) { const methodBadge = document.createElement('span'); @@ -138,7 +148,8 @@ namespace NetworkOverridesUi { li.appendChild(cb); li.appendChild(span); li.appendChild(actionsDiv); - elements.listEl.appendChild(li); + fragment.appendChild(li); }); + elements.listEl.appendChild(fragment); } } diff --git a/src/utils.ts b/src/utils.ts index 552baaa..ce40d6c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,31 +12,61 @@ namespace NetworkOverridesUtils { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + const regexCache = new Map(); + const MAX_REGEX_CACHE_SIZE = 500; + + function getCompiledRegex(key: string, compile: () => RegExp | null): RegExp | null { + if (regexCache.has(key)) { + return regexCache.get(key)!; + } + if (regexCache.size >= MAX_REGEX_CACHE_SIZE) { + const keysToDelete = Array.from(regexCache.keys()).slice(0, 250); + for (const k of keysToDelete) { + regexCache.delete(k); + } + } + const compiled = compile(); + regexCache.set(key, compiled); + return compiled; + } + + export function clearRegexCache(): void { + regexCache.clear(); + } + export function matchPattern(pattern: string, url: string): string[] | null { const trimmed = pattern.trim(); if (trimmed === '*' || trimmed.toLowerCase() === 'all') { return []; } if (isRegexPattern(trimmed)) { - const lastSlash = trimmed.lastIndexOf('/'); - const source = trimmed.slice(1, lastSlash); - const flags = trimmed.slice(lastSlash + 1); - try { - const regex = new RegExp(source, flags); - return regex.test(url) ? [] : null; - } catch { - return null; - } + const regex = getCompiledRegex('reg:' + trimmed, () => { + const lastSlash = trimmed.lastIndexOf('/'); + const source = trimmed.slice(1, lastSlash); + const flags = trimmed.slice(lastSlash + 1); + try { + return new RegExp(source, flags); + } catch { + return null; + } + }); + if (!regex) return null; + regex.lastIndex = 0; + return regex.test(url) ? [] : null; } if (trimmed.includes('*')) { - const parts = trimmed.split('*').map(escapeRegex); - try { - const regex = new RegExp('^' + parts.join('(.*)') + '$'); - const match = url.match(regex); - return match ? match.slice(1) : null; - } catch { - return null; - } + const regex = getCompiledRegex('glob:' + trimmed, () => { + const parts = trimmed.split('*').map(escapeRegex); + try { + return new RegExp('^' + parts.join('(.*)') + '$'); + } catch { + return null; + } + }); + if (!regex) return null; + regex.lastIndex = 0; + const match = url.match(regex); + return match ? match.slice(1) : null; } return url.includes(trimmed) ? [] : null; } @@ -91,22 +121,50 @@ namespace NetworkOverridesUtils { return postData.toLowerCase().includes(target); } - export function processResponseTemplate(body: string, captures: string[] = []): string { + export function processResponseTemplate( + body: string, + captures: string[] = [], + requestUrl: string = '' + ): string { if (!body || !body.includes('{{')) return body; let result = body; - result = result.replace(/\{\{(timestamp|now)\}\}/gi, () => new Date().toISOString()); - result = result.replace(/\{\{epoch\}\}/gi, () => String(Date.now())); - result = result.replace(/\{\{uuid\}\}/gi, () => { + result = result.replace(/\{\{(timestamp|now|\$timestamp|\$isoDate)\}\}/gi, () => + new Date().toISOString() + ); + result = result.replace(/\{\{(epoch|\$epoch)\}\}/gi, () => String(Date.now())); + result = result.replace(/\{\{(uuid|\$uuid|\$randomUUID)\}\}/gi, () => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); }); - result = result.replace(/\{\{randomInt:(\d+):(\d+)\}\}/gi, (_, minStr, maxStr) => { - const min = parseInt(minStr, 10); - const max = parseInt(maxStr, 10); - return String(Math.floor(Math.random() * (max - min + 1)) + min); + result = result.replace(/\{\{\$(randomEmail)\}\}/gi, () => { + const rand = Math.floor(Math.random() * 100000); + return `user_${rand}@example.com`; + }); + result = result.replace(/\{\{\$(randomName)\}\}/gi, () => { + const names = ['Alice', 'Bob', 'Charlie', 'David', 'Eva', 'Frank', 'Grace', 'Hannah']; + const rand = names[Math.floor(Math.random() * names.length)]; + const num = Math.floor(Math.random() * 1000); + return `${rand}_${num}`; + }); + result = result.replace( + /\{\{(?:randomInt:(\d+):(\d+)|\$randomInt\((\d+),\s*(\d+)\))\}\}/gi, + (_, min1, max1, min2, max2) => { + const min = parseInt(min1 || min2, 10); + const max = parseInt(max1 || max2, 10); + return String(Math.floor(Math.random() * (max - min + 1)) + min); + } + ); + result = result.replace(/\{\{\$(?:query|queryParam)\(([^)]+)\)\}\}/gi, (_, paramName) => { + if (!requestUrl) return ''; + try { + const urlObj = new URL(requestUrl); + return urlObj.searchParams.get(paramName) || ''; + } catch { + return ''; + } }); result = result.replace(/\{\{param:(\d+)\}\}/gi, (_, indexStr) => { const idx = parseInt(indexStr, 10) - 1; diff --git a/tests/background-advanced.test.mjs b/tests/background-advanced.test.mjs index 4a13cce..b1f3b31 100644 --- a/tests/background-advanced.test.mjs +++ b/tests/background-advanced.test.mjs @@ -417,3 +417,31 @@ test('Background enforces an imported disabled rule by not applying it: request assert.ok(continueCmd); assert.equal(continueCmd.params.body, undefined); }); + +test('Background overrides outgoing request payload when requestBody is set', async () => { + const harness = createBackgroundHarness(); + + harness.callMessage({ + type: 'update', + tabId: 7, + tabUrl: `${TEST_DOMAIN}/`, + enabled: true, + overrides: [ + { pattern: '/users$/', body: '', mode: 'text', requestBody: '{"name":"overridden"}' }, + ], + }); + await new Promise(resolve => setTimeout(resolve, 0)); + + harness.emitDebuggerEvent('Fetch.requestPaused', { + requestId: 'req-body-mod', + request: { url: TEST_API_URL, method: 'POST', postData: '{"name":"original"}' }, + resourceType: 'Fetch', + }); + + const continueCmd = harness.commandLog.find( + ({ method, params }) => + method === 'Fetch.continueRequest' && params.requestId === 'req-body-mod' + ); + assert.ok(continueCmd); + assert.ok(continueCmd.params.postData); +}); diff --git a/tests/background-test-harness.mjs b/tests/background-test-harness.mjs index 16449fd..fd775cc 100644 --- a/tests/background-test-harness.mjs +++ b/tests/background-test-harness.mjs @@ -190,15 +190,7 @@ export function createBackgroundHarness({ chrome, }; vm.createContext(context); - runDistFile('utils.js', context); - runDistFile('shared.js', context); - runDistFile('tab-state.js', context); - runDistFile('background/encoding.js', context); - runDistFile('background/api-capture.js', context); - runDistFile('background/interceptor.js', context); - runDistFile('background/debugger-controller.js', context); - runDistFile('background/message-router.js', context); - runDistFile('background.js', context); + runDistFile('background.bundle.js', context); return { context, diff --git a/tests/helpers.test.mjs b/tests/helpers.test.mjs index b07fa2b..b267ffb 100644 --- a/tests/helpers.test.mjs +++ b/tests/helpers.test.mjs @@ -176,3 +176,38 @@ test('glob * matches the empty string', () => { true ); }); + +test('processResponseTemplate processes dynamic placeholders', () => { + const { NetworkOverridesUtils } = createUiContext(); + const template = + '{"id":"{{$uuid}}","email":"{{$randomEmail}}","name":"{{$randomName}}","queryId":"{{$query(id)}}","num":{{$randomInt(10, 50)}}}'; + const processed = NetworkOverridesUtils.processResponseTemplate( + template, + [], + 'https://example.com/test?id=999' + ); + assert.match(processed, /"queryId":"999"/); + assert.match(processed, /"email":"user_\d+@example\.com"/); + assert.match(processed, /"id":"[0-9a-f-]{36}"/); +}); + +test('UI parses HAR JSON specification into OverrideRules', () => { + const { NetworkOverridesUi } = createUiContext(); + const harJson = JSON.stringify({ + log: { + entries: [ + { + request: { method: 'POST', url: 'https://example.com/api/orders' }, + response: { status: 201, content: { mimeType: 'application/json', text: '{"id":101}' } }, + }, + ], + }, + }); + + const rules = NetworkOverridesUi.parseHarToRules(harJson); + assert.equal(rules.length, 1); + assert.equal(rules[0].pattern, 'https://example.com/api/orders'); + assert.equal(rules[0].method, 'POST'); + assert.equal(rules[0].statusCode, 201); + assert.equal(rules[0].body, '{"id":101}'); +}); diff --git a/tests/tab-state.test.mjs b/tests/tab-state.test.mjs index a2f5bce..569805e 100644 --- a/tests/tab-state.test.mjs +++ b/tests/tab-state.test.mjs @@ -73,3 +73,16 @@ test('rehydrate rebuilds live tabs, drops dead tabs, and reports enabled tabs', assert.equal(store.get(999), undefined); assert.equal(harness.sessionState['tabState_999'], undefined); }); + +test('recordOverrideStat increments totalOverridden and totalFailed stats', async () => { + const harness = createBackgroundHarness(); + const store = harness.context.NetworkOverridesTabState; + + store.recordOverrideStat(7, false); + store.recordOverrideStat(7, false); + store.recordOverrideStat(7, true); + + const state = store.get(7); + assert.equal(state.stats.totalOverridden, 2); + assert.equal(state.stats.totalFailed, 1); +}); diff --git a/tests/test-harness.mjs b/tests/test-harness.mjs index 5ae21db..98d77dd 100644 --- a/tests/test-harness.mjs +++ b/tests/test-harness.mjs @@ -37,28 +37,10 @@ export function normalize(value) { export function createUiContext() { const context = { console, + URL, }; vm.createContext(context); - runDistFile('utils.js', context); - runDistFile('shared.js', context); - runDistFile('ui/types.js', context); - runDistFile('ui/view-utils.js', context); - runDistFile('ui/primitives.js', context); - runDistFile('ui/notifications.js', context); - runDistFile('ui/dialogs.js', context); - runDistFile('ui/persistence.js', context); - runDistFile('ui/attach-status.js', context); - runDistFile('ui/curl.js', context); - runDistFile('ui/swagger.js', context); - runDistFile('ui/headers-editor.js', context); - runDistFile('ui/modal.js', context); - runDistFile('ui/rules-list.js', context); - runDistFile('ui/api-list.js', context); - runDistFile('ui/profiles.js', context); - runDistFile('ui/modal-controller.js', context); - runDistFile('ui/rules-io-controller.js', context); - runDistFile('ui/toolbar-controller.js', context); - runDistFile('ui.js', context); + runDistFile('ui.bundle.js', context); return context; } @@ -109,15 +91,7 @@ export function createBackgroundContext() { }, }; vm.createContext(context); - runDistFile('utils.js', context); - runDistFile('shared.js', context); - runDistFile('tab-state.js', context); - runDistFile('background/encoding.js', context); - runDistFile('background/api-capture.js', context); - runDistFile('background/interceptor.js', context); - runDistFile('background/debugger-controller.js', context); - runDistFile('background/message-router.js', context); - runDistFile('background.js', context); + runDistFile('background.bundle.js', context); return context; } @@ -450,26 +424,7 @@ export function createUiHarness({ }; const context = dom.getInternalVMContext(); - runDistFile('utils.js', context); - runDistFile('shared.js', context); - runDistFile('ui/types.js', context); - runDistFile('ui/view-utils.js', context); - runDistFile('ui/primitives.js', context); - runDistFile('ui/notifications.js', context); - runDistFile('ui/dialogs.js', context); - runDistFile('ui/persistence.js', context); - runDistFile('ui/attach-status.js', context); - runDistFile('ui/curl.js', context); - runDistFile('ui/swagger.js', context); - runDistFile('ui/headers-editor.js', context); - runDistFile('ui/modal.js', context); - runDistFile('ui/rules-list.js', context); - runDistFile('ui/api-list.js', context); - runDistFile('ui/profiles.js', context); - runDistFile('ui/modal-controller.js', context); - runDistFile('ui/rules-io-controller.js', context); - runDistFile('ui/toolbar-controller.js', context); - runDistFile('ui.js', context); + runDistFile('ui.bundle.js', context); window.NetworkOverridesUi.init(options); return { diff --git a/tests/ui-modal-primitives.test.mjs b/tests/ui-modal-primitives.test.mjs index ebd5b41..3d603e3 100644 --- a/tests/ui-modal-primitives.test.mjs +++ b/tests/ui-modal-primitives.test.mjs @@ -367,3 +367,31 @@ test('Saving a body override with requestHeaders persists requestHeaders', async { name: 'Authorization', value: 'Bearer my-secret-token' }, ]); }); + +test('Modal triggers save on Ctrl+Enter keyboard shortcut', async () => { + const harness = createUiHarness({ apis: [], tabUrl: `${TEST_DOMAIN}/` }); + await flushUi(harness.window); + + harness.document + .getElementById('add-api-btn') + .dispatchEvent(new harness.window.MouseEvent('click', { bubbles: true })); + await flushUi(harness.window); + + harness.document.getElementById('modal-pattern').value = 'api/shortcut'; + harness.document.getElementById('modal-body').value = '{"ok":true}'; + + const modal = harness.document.getElementById('override-modal'); + modal.dispatchEvent( + new harness.window.KeyboardEvent('keydown', { + key: 'Enter', + ctrlKey: true, + bubbles: true, + }) + ); + await flushUi(harness.window); + + const savedRules = harness.localState[`overrides_${TEST_DOMAIN}`]; + assert.ok(savedRules); + assert.equal(savedRules.length, 1); + assert.equal(savedRules[0].pattern, 'api/shortcut'); +}); diff --git a/tests/ui-rules-io.test.mjs b/tests/ui-rules-io.test.mjs index deac908..999898b 100644 --- a/tests/ui-rules-io.test.mjs +++ b/tests/ui-rules-io.test.mjs @@ -420,3 +420,22 @@ test('Rule profiles report save and delete outcomes through shared notifications true ); }); + +test('Rules list renders GLOBAL badge for rules with isGlobal true', async () => { + const harness = createUiHarness({ + storageState: { + overrides: [{ pattern: 'global-api', body: '{}', mode: 'text', isGlobal: true }], + }, + tabUrl: `${TEST_DOMAIN}/`, + }); + await flushUi(harness.window); + + harness.document + .querySelector('[data-tab="overrides"]') + .dispatchEvent(new harness.window.MouseEvent('click', { bubbles: true })); + await flushUi(harness.window); + + const badge = harness.document.querySelector('.rule-global-badge'); + assert.notEqual(badge, null); + assert.equal(badge.textContent, 'GLOBAL'); +});