From 78e498f131dc95ecc610228f571a7f85f11af667 Mon Sep 17 00:00:00 2001 From: Eric <47561374+Luyii@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:37:39 +0800 Subject: [PATCH] feat(v1.5.0): add persistent field-level response mocking Introduce in-place scalar editing, stable row targeting and refresh-persistent mock rules. Add startup Fetch/XHR hooks, scoped session storage, regression coverage and the v1.5.0 project presentation. --- CHANGELOG.md | 28 +++- README.md | 145 +++++++++++++------ docs/CHROME_REPRO.md | 12 ++ docs/DEVELOPMENT.md | 26 +++- docs/INSTALLATION.md | 2 + docs/RELEASE_v1.5.0.md | 56 +++++++ docs/TESTING.md | 27 +++- docs/USAGE.md | 14 ++ field-mock-background.js | 41 ++++++ field-mock-bridge.js | 12 ++ field-mock-page.js | 108 ++++++++++++++ field-mock.js | 129 +++++++++++++++++ field-patch-core.js | 116 +++++++++++++++ manifest.json | 41 +++++- mock-page.js | 162 +++++++++++++++++++++ package.json | 10 +- panel.html | 36 ++++- panel.js | 6 + response-editor.js | 186 ++++++++++++++++++++++++ response-mock.js | 111 ++++++++++++++ scripts/package.py | 4 +- shortcuts.js | 1 + style.css | 42 ++++++ tests/field-mock.cjs | 114 +++++++++++++++ tests/manual/index.html | 1 + tests/manual/mock-debug.html | 8 + tests/manual/mock-debug.js | 25 ++++ tests/manual/server.cjs | 3 +- tests/package-check.cjs | 4 + tests/preview/boot.js | 9 ++ tests/preview/build-preview.py | 64 ++++++++ tests/preview/check.cjs | 54 +++++++ tests/preview/env.js | 13 ++ tests/preview/inline-style.css | 28 ++++ tests/preview/inline-template.html | 61 ++++++++ tests/preview/server.cjs | 36 +++++ tests/regression.cjs | 67 +++++++++ tests/response-mock.cjs | 225 +++++++++++++++++++++++++++++ viewer-expanded.js | 1 + viewer.js | 2 + worker.js | 3 + 41 files changed, 1971 insertions(+), 62 deletions(-) create mode 100644 docs/RELEASE_v1.5.0.md create mode 100644 field-mock-background.js create mode 100644 field-mock-bridge.js create mode 100644 field-mock-page.js create mode 100644 field-mock.js create mode 100644 field-patch-core.js create mode 100644 mock-page.js create mode 100644 response-editor.js create mode 100644 response-mock.js create mode 100644 tests/field-mock.cjs create mode 100644 tests/manual/mock-debug.html create mode 100644 tests/manual/mock-debug.js create mode 100644 tests/preview/boot.js create mode 100644 tests/preview/build-preview.py create mode 100644 tests/preview/check.cjs create mode 100644 tests/preview/env.js create mode 100644 tests/preview/inline-style.css create mode 100644 tests/preview/inline-template.html create mode 100644 tests/preview/server.cjs create mode 100644 tests/response-mock.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9083fd5..4f80c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,32 @@ # 更新记录 -当前版本:1.4.13。以下按版本记录主要变更,当前行为以 README 和使用说明为准。 +当前版本:1.5.0。以下按版本记录主要变更,当前行为以 README 和使用说明为准。 + +## 1.5.0 · Field-level Response Mocking + +从 JSON 数据查看扩展升级为可原位修改响应字段的调试工具。无需修改业务代码或启动额外 Mock 服务,直接在 DevTools 构造边界值,再通过真实页面验证。 + +### 交互 + +- 双击 / F2 编辑表格、数据树和查看器中的标量,Enter / 失焦提交,Esc 取消。 +- 以轻微底色标记修改,顶部显示接口与字段数量,支持前后值清单、定位、单项恢复和统一退出。 +- 刷新、强制刷新、清空捕获列表和重开面板后保留当前标签页规则,省去重复配置。 + +### 实现 + +- 字段级补丁替代默认的整份 JSON 编辑路径:保存路径、类型和值,不在输入时进行全量 diff。 +- 真实 Fetch / 异步 XHR 返回后覆盖指定节点,未修改字段继续使用最新真实响应。 +- 对象数组按唯一标识重新定位,避免后端排序变化后套错行;路径、类型或标识失效时跳过。 +- `document_start` 页面脚本与隔离桥接在启动请求前恢复会话规则;规则按标签页与网站隔离。 +- 保留普通超安全整数精度;支持在超过 1 MiB 的响应中修改小字段,单响应载入上限仍为 20 MiB。 + +### 工程 + +- 新增 13 项字段 Mock 自动检查,覆盖启动请求、会话恢复、跨站隔离、数组定位、取消、退出及大响应小补丁。 +- 提供 `/mock-debug` Fetch / XHR 页面和手工复现步骤;维护既有回归、资源、预览与 ZIP 检查。 +- 增加 `storage` 和 HTTP / HTTPS 网站访问权限,最低 Chrome 114;数据不上传。 + +完整操作、限制和验证范围见 [v1.5.0 发布说明](docs/RELEASE_v1.5.0.md)。 ## 1.4.13:全面检查后的修复版 diff --git a/README.md b/README.md index 81a9098..1f84eda 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,135 @@ # API Table Viewer -Chrome DevTools 中的 JSON 接口调试扩展:把数组转换为表格,用内置 JSON 查看器浏览响应,支持搜索定位、请求详情和数据导出。 +**See the data. Shape the response.** -当前版本 **1.4.13** · Manifest V3 · 无运行时依赖 · 数据在本地处理。 +Chrome DevTools 中的 JSON 接口调试工具。把响应展开成可搜索的表格和数据树,直接双击字段构造测试数据,让真实页面在下一次请求中使用修改后的响应。 -## 安装与开始使用 +[![CI](https://github.com/Luyii/API-Table-Viewer/actions/workflows/ci.yml/badge.svg)](https://github.com/Luyii/API-Table-Viewer/actions/workflows/ci.yml) +![Version](https://img.shields.io/badge/version-1.5.0-2563eb) +![Chrome](https://img.shields.io/badge/Chrome-114%2B-4285f4) +![Manifest](https://img.shields.io/badge/Manifest-V3-16a34a) +![Dependencies](https://img.shields.io/badge/runtime_dependencies-0-0f766e) -1. 下载项目源码或扩展 ZIP,完整解压到固定目录。 -2. 打开 `chrome://extensions`,开启“开发者模式”,点击“加载已解压的扩展程序”。 +[快速开始](#快速开始) · [v1.5.0 更新](#v150--field-level-response-mocking) · [工作原理](#工作原理) · [测试说明](docs/TESTING.md) · [更新记录](CHANGELOG.md) + +## v1.5.0 · Field-level Response Mocking + +从“看清接口数据”,进一步到“控制页面收到的数据”。 + +**双击修改 → 自动进入编辑模式 → 重新查询或刷新网页 → 验证页面表现。** + +无需修改业务代码,无需额外启动 Mock 服务,也不必复制整份 JSON。只记录修改字段的路径、类型和值,在真实接口返回后覆盖对应节点。未修改的字段继续使用最新后端数据。 + +| 能力 | 具体行为 | +| --- | --- | +| 原位编辑 | 表格、数据树和 JSON 查看器中双击标量,或聚焦后按 F2;Enter / 失焦提交,Esc 取消 | +| 修改可追踪 | 轻微底色标记;悬停查看原值;顶部汇总接口数和修改数,支持清单核对、定位和单项恢复 | +| 刷新保留 | 普通刷新、强制刷新、清空捕获列表、重开 DevTools 后继续应用当前标签页的规则 | +| 字段级补丁 | 不在输入时计算整份 JSON diff,不把完整响应作为每次编辑的通信载荷 | +| 稳定数组定位 | 对象数组按唯一 ID / UUID / key 定位,排序变化后仍修改同一条记录;路径或标识失效时跳过 | +| 一键退出 | 清除当前网站的修改规则;后续请求恢复真实响应 | + +例如,把 `data.records[0].amount` 从 `128.5` 改为 `-999`,把 `customerName` 改为超长文本,可以直接观察金额样式、异常状态和布局边界。新请求中的库存、时间等其他字段仍由真实后端返回。 + +> 修改会在网页**下一次请求**时应用。插件不会直接修改 Vue / React 的组件状态;“退出编辑”也不会自动重绘业务页面。请求仍会到达后端,包括 POST 等操作。 + +完整设计和版本说明见 [v1.5.0 发布说明](docs/RELEASE_v1.5.0.md)。 + +## 快速开始 + +1. 下载源码或扩展 ZIP,完整解压到固定目录。 +2. 打开 `chrome://extensions`,开启开发者模式,选择“加载已解压的扩展程序”。 3. 选择**直接包含 `manifest.json` 的文件夹**。 -4. 打开目标页面的 DevTools,选择 **API Table** 标签,刷新页面并选择 JSON 请求。 -5. 从“数据结构”选择数组并打开 Table;点击单元格可查看完整内容。 +4. 打开目标网页 DevTools → **API Table**,刷新网页,选择 JSON 请求。 +5. 查看表格或数据树,双击要修改的字段;回到网页重新查询或刷新,观察结果。 -更新、加载失败排查见 [安装说明](docs/INSTALLATION.md)。 +更新时替换旧目录、重新加载扩展,再刷新目标网页,以安装新的页面启动脚本。加载失败排查见 [安装说明](docs/INSTALLATION.md)。扩展本身不需要 Node.js、Python 或构建工具。 -## 功能 +## 不止于 Mock | 场景 | 支持内容 | | --- | --- | -| 捕获接口 | JSON 响应、已有 Network 记录补录、URL / 方法 / 状态筛选、保留导航记录 | -| 表格分析 | 全数组搜索、匹配高亮与跨页定位、排序、分页、显示列、列宽自适应及拖动调整 | -| 查看长内容 | 单元格单行省略;点击后使用内置 JSON 查看器显示完整数据 | -| JSON 浏览 | 普通数据默认展开全部;大数据确认后完整遍历、虚拟滚动;路径跳转与搜索定位 | -| 请求复现 | 请求详情、复制请求体 JSON、复制 Bash 格式 cURL | -| 导出 | 原始响应、当前 JSON 节点、全部筛选和排序后的 CSV / Markdown / JSON | -| 布局 | 左侧列表独立收起、拖动调宽、记住布局和分页偏好 | -| 数值 | 普通整数字面量超出安全整数范围时仍精确保留,支持搜索、排序与导出 | +| 请求捕获 | JSON 响应、已有 Network 记录补录、URL / 方法 / 状态筛选、保留导航记录 | +| 表格分析 | 全数组搜索、匹配高亮与跨页定位、排序、分页、列显隐、自动列宽与拖动调整 | +| JSON 浏览 | 普通数据默认展开;大数据分批遍历、虚拟滚动;字段路径跳转与搜索定位 | +| 长内容查看 | 单元格摘要、完整值弹框、分段文本与完整复制 | +| 请求复现 | 请求详情、请求体 JSON、Bash / WSL / Git Bash 格式 cURL | +| 数据导出 | 原始响应、当前节点、全部筛选和排序后的 CSV / Markdown / JSON | +| 数值精度 | 普通超安全整数字面量精确保留,贯穿浏览、搜索、排序、复制和 JSON 导出 | +| 面板布局 | 独立收起左侧列表、拖动分栏、统一滚动条、布局与分页偏好记忆 | + +表头字段保持单行,内容自动列宽通常为 88–380 px;手动列宽通常最高 960 px,超长表头可突破上限。长内容省略展示,隐藏列不影响导出。 + +## 工作原理 + +```mermaid +flowchart TD + A[DevTools 原位编辑] --> B[字段补丁:路径、类型与行标识] + B --> C[会话规则:标签页 × 网站] + C --> D[document_start 安装 Fetch / XHR 拦截] + E[真实接口响应] --> D + D --> F[应用字段补丁后交给页面] +``` + +- **浏览与修改分离**:Worker 保存原始响应与解析结果。编辑只生成小范围字段补丁,不改写捕获记录,原始数据仍可核对和导出。 +- **启动阶段恢复规则**:MAIN world 脚本在 `document_start` 接管主页面 Fetch / XHR,隔离脚本从扩展会话中读取规则。启动请求等待初始化;异常超时回退真实响应。 +- **会话隔离**:规则按标签页和页面网站保存,跨站点、跨标签页不混用。关闭标签页、重启浏览器或重新加载扩展后清除。 +- **有界的大数据展示**:Worker 解析、分页与搜索;较大子树分批建立索引,只把当前窗口附近的节点交给 DOM。 + +模块分工、消息流和约束见 [开发与架构](docs/DEVELOPMENT.md)。 -表格自适应指**列宽**:内容自动宽度通常为 88–380 px,手动宽度通常最高 960 px;完整表头字段名单行所需宽度始终作为下限,超长表头可突破上述上限,通过表格横向滚动查看。长内容不会持续撑高单元格。隐藏列仅影响屏幕显示,导出仍包含全部字段。 +## 支持范围与边界 + +- Chrome 114+、Manifest V3。字段 Mock 支持主页面 Fetch 和异步 XHR 的 JSON 文本 / JSON 响应;不覆盖 Worker、iframe、WebSocket 或流式协议。 +- 接口按**方法与完整 URL(含查询参数)**匹配,不按请求体区分。仅替换已有标量,保持原类型;不新增 / 删除字段,不调整数组结构。 +- 对象数组需有唯一 `id`、`uuid`、`key` 或以 `Id` 结尾的标识字段。用于定位的标识本身不允许修改;字段缺失、类型改变或标识失效时跳过,并在网页控制台提示。 +- 单响应载入上限 **20 MiB UTF-8**;大于 1 MiB 的响应也可修改小字段。单个编辑字段上限 **64 KiB**,最多 **30 个接口、每接口 200 个字段、1 MiB 规则配置**。 +- 捕获缓存最多 **150 条或 80 MiB 原文**。超过 **5,000 节点、500,000 展示字符或 32 层嵌套**的子树需确认后浏览。虚拟滚动减少 DOM 数量,不消除解析和序列化开销。 +- 普通超安全整数精确保留;小数、指数形式仍按 JavaScript Number 处理。需要原始写法时使用原始响应复制 / 下载。 ## 快捷键 | 按键 | 操作 | | --- | --- | -| Ctrl / ⌘ + F | 聚焦当前上下文的接口、表格或 JSON 搜索 | +| 双击值 / F2 | 原位编辑字段 | +| 编辑时 Enter / Esc | 提交 / 取消本次输入 | +| Ctrl / ⌘ + F | 聚焦当前接口、表格或 JSON 搜索 | | F3 / Shift + F3 | 下一个 / 上一个匹配 | | Ctrl / ⌘ + G,配合 Shift | 下一个 / 上一个匹配 | | 搜索框中 Enter / Shift + Enter | 下一个 / 上一个匹配 | -| Esc | 关闭弹框 | - -按键需要焦点位于扩展面板内;Chrome 可能优先处理部分系统快捷键。树节点支持整行点击、Enter / 空格展开与方向键收起。 +| Esc | 未编辑字段时关闭弹框 | -## 大数据与精度边界 +焦点需位于扩展面板内;部分系统快捷键可能由 Chrome 优先处理。 -- 单响应最多 **20 MiB UTF-8**;缓存最多 **150 条或 80 MiB 原文**,达到限制时淘汰旧记录。解析对象和界面会占用额外内存。 -- 当前子树超过 **5,000 节点、500,000 展示字符或 32 层嵌套**时,先显示提示。点击“按需浏览此数据(展开全部)”后自动遍历全部节点,仅渲染可见窗口。没有“暂停展开”按钮。 -- 复制与导出完整数据仍需要处理全部对应数据;极深嵌套的格式化 / 导出可能受序列化调用栈限制。 -- 普通超安全整数保持精确;小数和指数形式按 JavaScript Number 处理。需要保留全部原始写法时,使用原始响应复制 / 下载。 +## 工程与验证 -## 开发与检查 - -需要 Node.js 18+;打包另需 Python 3。无需 `npm install`。 +开发检查需要 Node.js 18+ 和 Python 3,**无需安装项目依赖**。 ```sh npm test python3 scripts/package.py +python3 tests/package-archive.py ``` -Windows 可用 `py scripts/package.py`。扩展安装本身不需要 Node.js 或 Python。 +| 检查 | 范围 | +| --- | --- | +| 88 项回归 | JSON 解析、精度、排序、搜索、导出、缓存、异步状态与界面逻辑 | +| 8 个 HTTP / Worker 子案例 | 本地接口、较大记录集、长文本与解析联动 | +| 13 项字段 Mock 检查 | 补丁、稳定行定位、Fetch / XHR、启动请求、会话恢复、隔离、取消与退出 | +| 18 项响应替换回归 | 原替换引擎、规则更新、单次消费、超时释放与控制器 | +| 3 项预览检查 | 示例 HAR、路由、脚本与标识完整性 | +| 资源与压缩包检查 | MV3 清单、资源路径、CSP、脚本作用域、白名单、源文件一致性与解压验证 | -打包产物位于 `dist/API-Table-Viewer-v1.4.13.zip`,解压根目录直接包含清单。源码无需构建即可作为已解压扩展加载。 +GitHub Actions 在 push / pull_request 时以 Node.js 18、22、24 执行检查,再验证 ZIP。实际运行结果见上方 CI 标记;本地自动检查中的 DOM / Chrome API 模拟不替代真实浏览器验收,详见 [测试说明](docs/TESTING.md)。 -自动检查覆盖 78 项回归、扩展资源与脚本检查,以及本地接口联动。命令和覆盖范围见 [测试说明](docs/TESTING.md),版本修复见 [检查记录](CHECK_REPORT.md)。 +`npm run test:manual` 启动本地复现服务,访问 http://127.0.0.1:8765 。`/mock-debug` 提供真实 Fetch / XHR 订单页面,便于验证编辑、刷新保留和退出恢复。步骤见 [Chrome 复现指南](docs/CHROME_REPRO.md)。打包产物为 `dist/API-Table-Viewer-v1.5.0.zip`。 ## 文档 -- [使用说明](docs/USAGE.md) -- [安装与故障排查](docs/INSTALLATION.md) -- [开发与架构](docs/DEVELOPMENT.md) -- [测试与发布检查](docs/TESTING.md) -- [更新记录](CHANGELOG.md) -- [贡献说明](CONTRIBUTING.md) - -## 隐私与许可 - -扩展不上传捕获的数据,不加载远程代码,清单未申请额外权限。复制 cURL、请求详情和导出文件可能包含接口凭证及业务数据,分享前应自行检查内容。 - -项目尚未指定开源许可证;创建 GitHub 仓库不代表授予开源使用许可。 +[使用说明](docs/USAGE.md) · [安装排查](docs/INSTALLATION.md) · [开发架构](docs/DEVELOPMENT.md) · [测试与发布](docs/TESTING.md) · [v1.5.0 发布说明](docs/RELEASE_v1.5.0.md) · [贡献说明](CONTRIBUTING.md) +## 数据与权限 -## 本地 Chrome 复现环境 +扩展不上传捕获的数据,不加载远程代码。HTTP / HTTPS 网站访问用于在页面启动时安装请求拦截;`storage` 用于保存当前浏览器会话的字段规则。真实接口通信仍由目标网页发起。cURL、请求详情和导出文件可能包含凭证及业务数据,分享前请检查内容。 -执行 `npm run test:manual`,打开 http://127.0.0.1:8765 ,在 API Table 面板打开后发送案例。无需安装依赖;数据说明和操作步骤见 [Chrome 复现指南](docs/CHROME_REPRO.md)。`npm run test:fixtures` 检查 HTTP 接口数据与 Worker 联动。 +项目尚未指定开源许可证;公开仓库不代表授予开源使用许可。 diff --git a/docs/CHROME_REPRO.md b/docs/CHROME_REPRO.md index 4bbd32c..3e42674 100644 --- a/docs/CHROME_REPRO.md +++ b/docs/CHROME_REPRO.md @@ -54,3 +54,15 @@ npm run test:manual - 页面和脚本路由、POST 回显、413、422、404、405 及非 JSON 响应。 命令与执行记录汇总见 [测试说明](TESTING.md)。 + +## 原位字段 Mock 验收(1.5.0) + +更新扩展后关闭、重开 DevTools 并刷新目标网页,以安装新的启动脚本。打开本地服务的 `/mock-debug`,自动加载订单。API Table 中选择 `/api/mock-orders`,打开 `data.records` 表格。 + +1. 双击 customerName,输入测试名称,Enter 提交;双击 amount 修改数值。观察轻微底色、顶部“编辑模式”和两处修改。Esc 应取消当前输入,失焦应提交;非法数字不能提交。 +2. 网页点击 Fetch、XHR 查询,两者显示修改值,未改字段继续来自后端。Network 仍有真实请求。手动 / Ctrl+R / 强制刷新后,页面启动请求应继续得到修改值。 +3. 清空插件捕获列表、关闭并重开 DevTools 后再请求,规则仍保留。顶部“查看修改”核对前后值,定位按钮进入对应字段,恢复一项只移除该字段。 +4. 点击“退出编辑”,再请求或刷新,网页恢复真实值。退出本身不重渲染业务页面。另一个标签页 / 其他网站不继承规则。 +5. 对 2 MiB+ 响应只修改小字段,确认没有整份编辑器的 1 MiB 限制。对象数组重新排序后仍修改相同 ID 的行;缺少 / 重复 ID 或字段类型变化时应跳过并在网页控制台提示。 + +自动测试通过不等于上述真实 Chrome 行为已验收,请记录实际浏览器版本和结果。 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 920be85..49bb5fc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -2,7 +2,7 @@ ## 环境与命令 -运行扩展只需支持 Manifest V3 的 Chrome;当前未声明经过验证的最低 Chrome 版本。开发检查需要 Node.js 18+,打包使用 Python 3 标准库,没有 npm 依赖。 +清单声明 Chrome 114+,用于 Manifest V3、MAIN world 内容脚本与扩展会话存储。开发检查需要 Node.js 18+ 和 Python 3,预览生成检查与打包只使用 Python 标准库,没有 npm 依赖。 ```sh npm test @@ -29,11 +29,33 @@ python3 scripts/package.py | `developer.js` | 请求详情、cURL、偏好、列显隐、路径解析及下载 | | `layout.js` | 左侧列表折叠与尺寸调整 | | `shortcuts.js` | 上下文搜索与匹配快捷键 | +| `response-editor.js` | 响应草稿、精确格式化、JSON 校验与有界字段对比 | +| `response-mock.js` | 面板会话内的替换规则、DevTools 通信、命中状态和刷新前注入 | +| `mock-page.js` | 自包含页面拦截器,处理 Fetch / 异步 XHR、单次消费与超时释放 | +| `field-mock.js` | 原位编辑、字段标记、修改清单、定位、单项恢复与退出 | +| `field-patch-core.js` | 精确数值、标量补丁、稳定数组定位与类型验证 | +| `field-mock-background.js` | 标签页 / origin 会话存储与串行更新 | +| `field-mock-bridge.js` | 隔离世界与 MAIN world 的配置桥接 | +| `field-mock-page.js` | 启动阶段 Fetch / XHR 包装与真实响应补丁 | | `tests/` | 回归测试与静态加载检查 | | `scripts/package.py` | 白名单打包、清单与 ZIP 完整性检查 | ## 数据流和状态 +1.5.0 的默认调试路径是原位标量编辑。`field-mock.js` 将表格、数据树与查看器中的值绑定到 Worker 路径;提交时只请求 `patchTarget`,取得原值、类型、字段路径和数组行标识。修改以补丁保存,不调用整份响应编辑器,也不运行整份 JSON diff。Worker 中的捕获记录保持原样。 + +`field-patch-core.js` 在 Worker、面板、后台与 MAIN world 复用路径、类型验证和精确整数逻辑。数组对象通过唯一 ID / UUID / key 定位;后端排序改变时重新找行,标识缺失 / 重复或字段类型改变时跳过。单字段 64 KiB,响应载入仍为 20 MiB;规则配置最多 30 个接口、每接口 200 个字段、合计 1 MiB。 + +`field-mock-background.js` 将规则写入 `chrome.storage.session`,按标签页 ID 和页面 origin 分组,串行处理同一标签页的读写。隔离世界的 `field-mock-bridge.js` 仅向当前 origin 传递配置,网页不能经由桥接写入扩展存储。关闭标签页时删除对应会话,重启浏览器或重新加载扩展也会清除。 + +MAIN world 的 `field-mock-page.js` 在 `document_start` 安装 Fetch / XHR 包装器。启动请求等待规则初始化,3 秒异常超时后回退真实请求。请求正常抵达后端,响应正文解析后应用标量补丁,再交给网页;方法与完整 URL 精确匹配,不按请求体区分。Fetch 保留状态和可用响应属性,调整已失效的长度 / 编码 / 校验头;XHR 支持异步文本与 JSON。退出或更新规则时,尚未交付的旧规则不会继续修改响应。重复初始化配置保持规则引用稳定,避免错误取消启动中的替换。 + +编辑不直接改变 Vue / React 的状态。页面需要再次请求或刷新才能展示变化;退出也只影响后续响应。原始响应的导出和 Network 核对不被补丁覆盖。未修改字段使用最新后端数据,但响应仍需解析 / 序列化,因此字段补丁不意味着大响应处理零开销。 + +`response-editor.js`、`response-mock.js` 和 `mock-page.js` 是此前整份替换实验的保留模块与回归基线,可见入口已隐藏,不属于 1.5.0 默认操作流程。其测试验证独立引擎的旧行为,不代表字段 Mock 绕过真实后端。 + +执行 `npm run preview` 后访问 http://localhost:8766,可用示例数据打开真实面板与 Worker;只有 DevTools 的宿主接口被替代。此预览不验证扩展安装或网页响应替换。`python3 tests/preview/build-preview.py /absolute/path/preview.html` 可生成同一面板的独立预览,不生成扩展 ZIP。 + DevTools 的请求完成事件以及初始 `getHAR` 补录读取响应正文,面板将原文交给 Worker。Worker 持有解析结果与原始响应,用请求 ID 读取当前路径、表格页、搜索结果或导出内容。普通浏览优先传摘要及分页数据,完整复制和导出按操作请求。 面板与 Worker 使用带序号的消息匹配 Promise。选择请求、搜索词变化、弹框导航和字符串分段各有状态版本,异步结果应用前检查当前上下文,避免旧结果覆盖新视图。修改相应逻辑时保留这些检查。 @@ -44,7 +66,7 @@ DevTools 的请求完成事件以及初始 `getHAR` 补录读取响应正文, - 面板多个经典脚本共享顶层作用域,新增变量需避免命名冲突并保持 `panel.html` 加载顺序。 - Worker 使用 `importScripts`,共享 JSON codec 必须先于数据逻辑加载。 -- 用户字段和值必须作为文本渲染,路径只允许自有字段访问;不得引入 `eval`、远程脚本或内联事件属性。 +- 用户字段和值必须作为文本渲染,路径只允许自有字段访问;不使用 `window.eval`、动态函数、远程脚本或内联事件属性。仅响应替换通过 DevTools 官方 `inspectedWindow.eval` 运行固定本地代码与序列化配置。 - 不使用普通 `JSON.stringify` 序列化可能含 BigInt 的数据,应使用 `stringifyJson`。 - 只有普通整数字面量可自动精确保留;小数、指数形式仍为 Number。原始文本另行保留。 - 新功能需考虑缓存淘汰、请求切换、弹框关闭以及无响应读取方法的 HAR 项。 diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 0944bf2..aaa9885 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -1,5 +1,7 @@ # 安装、更新与加载失败排查 +1.5.0 需要 Chrome 114+。原位字段 Mock 使用 HTTP / HTTPS 网站访问权限和扩展会话存储。更新后必须刷新目标网页,才能让页面启动阶段的请求使用保留规则。 + ## 首次安装 1. 将 ZIP **完整解压**。GitHub 的源码 ZIP 通常还包含一层项目目录,请进入该目录确认能看到 `manifest.json`。 diff --git a/docs/RELEASE_v1.5.0.md b/docs/RELEASE_v1.5.0.md new file mode 100644 index 0000000..95ab1cb --- /dev/null +++ b/docs/RELEASE_v1.5.0.md @@ -0,0 +1,56 @@ +# v1.5.0 · Field-level Response Mocking + +**从接口观察,到字段级调试。** + +API Table Viewer 1.5.0 把调试入口放到数据本身:双击字段、输入测试值,然后让页面重新查询。无需修改业务代码、启动独立 Mock 服务或反复粘贴整份 JSON。 + +## 这次更新解决什么 + +真实数据经常不方便触发边界场景:超长用户名、负数金额、异常枚举、布尔开关。为几处字段更改维护整份响应,容易覆盖无关的后端变化,也增加了操作成本。 + +1.5.0 只记录修改字段。请求继续访问真实后端,响应返回后再应用补丁;没有修改的字段继续更新。刷新页面时规则自动恢复,调试可以围绕同一组边界数据反复进行。 + +## 四步完成一次调试 + +1. DevTools → API Table 中选择接口,在表格、数据树或查看器中找到目标值。 +2. 双击或按 F2 编辑,Enter / 失焦提交;Esc 取消当前输入。 +3. 回到网页重新查询或刷新,观察业务页面的展示和逻辑。 +4. 在顶部查看、定位、恢复某项修改,或点击“退出编辑”结束当前网站的 Mock。 + +已改字段使用轻微底色标记,悬停可查看原值。修改清单记录接口、路径、前后值,切换接口后仍可定位。清空捕获列表不清空规则。 + +## 字段级补丁,而非整份响应副本 + +编辑配置保存字段路径、原始类型、原值、新值和必要的数组标识,不将完整响应作为每次编辑的载荷,也不在每次输入时遍历整份 JSON 做 diff。 + +例如,只修改 `data.records[0].amount`,配置还会记录该行的唯一订单 ID。后端返回不同排序时,补丁先重新定位订单,再修改金额。若标识不存在、重复,或字段类型改变,该修改会跳过并在网页控制台提示,不盲目沿用旧行号。 + +超过 1 MiB 的响应仍可修改小字段。响应载入上限为 20 MiB,单字段编辑上限为 64 KiB。字段补丁减少了编辑阶段的通信与对比工作;每次匹配响应仍需解析和序列化,不承诺零开销。 + +## 刷新为什么还能继续用 + +页面 MAIN world 的脚本在 `document_start` 接管 Fetch / XHR。隔离脚本向扩展后台读取当前标签页、当前网站的会话配置,启动请求等待初始化后再发出;初始化异常超时则回退真实响应。 + +配置保存在 `chrome.storage.session`。普通刷新、强制刷新、清空列表和重开 DevTools 都不会主动清除。其他网站、其他标签页不会继承;关闭标签页、重启浏览器或重新加载扩展后清除。 + +## 支持范围 + +- Chrome 114+,Manifest V3,新增 HTTP / HTTPS 网站访问和 `storage` 权限。 +- 主页面 Fetch、异步 XHR 的 JSON 文本 / JSON 响应;不覆盖 iframe、Worker、WebSocket 或流式协议。 +- 按方法和完整 URL(含查询参数)匹配,不区分同 URL 的不同请求体。 +- 修改已有标量并保持原类型;不新增 / 删除字段,不调整数组结构,不修改用于数组定位的标识。 +- 最多 30 个接口、每接口 200 个字段、1 MiB 规则配置。 + +请求仍然执行真实后端操作。页面需要再次请求才能显示修改;退出也不直接改写已渲染的组件状态。原始捕获与导出不被补丁覆盖。数据只在本地处理,不加载远程代码、不上传响应。 + +## 验证 + +本地执行 `npm test`,覆盖 88 项回归、8 个 HTTP / Worker 子案例、13 项字段 Mock 检查、18 项旧替换引擎回归、3 项预览检查,以及资源 / CSP / 脚本检查。打包后运行 `python3 tests/package-archive.py` 检查文件白名单、内容一致性和解压资源。 + +字段 Mock 测试包含精确整数、数组重排、2 MiB 响应的小字段补丁、Fetch / XHR、启动请求等待、后台重启后的会话读取、站点 / 标签页隔离、取消、退出和重复初始化。另用模拟 DOM 执行实际面板与 Worker 检查原位编辑交互。 + +自动测试中的 DOM、XHR 和 Chrome API 有模拟,不代表真实 Chrome 兼容性与业务页面渲染已全部验收。仓库提供 `/mock-debug` 真实 HTTP 订单页面及[手工步骤](CHROME_REPRO.md);GitHub Actions 的运行结果以对应提交为准。 + +## 更新 + +关闭 DevTools,将新文件替换到原扩展目录,在 `chrome://extensions` 重新加载扩展,再刷新目标网页。重新加载扩展会清空旧会话规则,请重新设置需要的字段修改。 diff --git a/docs/TESTING.md b/docs/TESTING.md index 1c1859b..0c3c5a9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -2,7 +2,7 @@ ## 自动检查 -需要 Node.js 18+,无需安装依赖。 +需要 Node.js 18+ 和 Python 3(预览生成检查),无需安装项目依赖。 ```sh npm test @@ -10,9 +10,12 @@ npm test | 命令 | 检查内容 | | --- | --- | -| `npm run test:regression` | 78 项回归:解析、整数精度、路径、排序、搜索定位、导出、缓存、异步状态、列宽和详情复制 | +| `npm run test:regression` | 88 项回归:解析、整数精度、路径、排序、搜索定位、导出、缓存、异步状态、列宽、详情复制和响应编辑 | | `npm run test:package` | MV3 清单、入口资源、HTML ID、CSP、脚本语法及面板共享作用域 | | `npm run test:fixtures` | 本地 HTTP 接口与 Worker 联动,包括节点阈值、50,000 条记录、精确整数和长文本 | +| `npm run test:preview` | 示例 HAR、预览 HTTP 路由,以及生成预览的脚本语法和 HTML 标识 | +| `npm run test:fields` | 13 项原位字段 Mock 检查:精确整数、类型、数组定位、大响应小补丁、Fetch / XHR、启动请求、会话恢复、站点隔离与 Worker 路径 | +| `npm run test:mock` | 18 项:Fetch / XHR 拦截、精确匹配、单次并发、取消、类型、规则更新、租约、面板控制和 HTTP 页面联动 | 回归测试在 Node.js 中运行 Worker 代码,通过模拟 DOM 和 DevTools 对象检查界面逻辑。接口测试使用随机端口,结束后自动关闭服务。2026-10-20 的自动检查结果:78 项回归、静态检查、8 个接口子案例通过。 @@ -20,7 +23,7 @@ npm test `.github/workflows/ci.yml` 在所有分支的 `push`、`pull_request`(创建、重新打开和更新提交)以及手动触发时运行,不设置路径过滤。 -- Node.js 18、22、24 分别执行 `npm test`,覆盖上述三类自动检查;18 用于验证当前声明的最低版本兼容性。 +- Node.js 18、22、24 分别执行 `npm test`,覆盖上述全部自动检查;18 用于验证当前声明的最低版本兼容性。 - 所有测试任务成功后,使用 Node.js 24 和 Python 3.12 执行 `python3 scripts/package.py`,再执行 `python3 tests/package-archive.py`。 - ZIP 检查核对打包白名单、重复条目、完整性及文件内容,并对临时解压目录运行现有资源、CSP 和语法检查。版本号从清单读取,无需在 CI 中同步修改。 - 不安装 npm 或 Python 项目依赖,不使用依赖缓存,不发布产物。工作流仅申请仓库读取权限。 @@ -40,6 +43,14 @@ python3 tests/package-archive.py ## 浏览器验收 +响应编辑第一步的本地检查:88 项回归、资源检查、8 个 HTTP/Worker 子案例及 3 项预览检查通过。新增 10 项回归覆盖精确格式化、语法与大小限制、字段变更、特殊键、有界对比、原始数据隔离、非法草稿、请求间草稿隔离、异步切换和清空。交互预览还通过 DOM 模拟检查,包括初始差异、校验、恢复、保存、关闭和重新打开。本次未运行打包,也未验证真实 Chrome 扩展加载或响应替换。 + +响应替换第二步增加 17 项 Node.js 检查,包括真实 HTTP 服务 + 模拟页面 DOM 的联动:原始订单可正常显示,替换后 Fetch 和 XHR 显示编辑内容,后端请求次数不增加。XHR 使用测试替身,DevTools 调用在隔离执行上下文中验证;这些结果不等于真实 Chrome 扩展加载验收。新增本地验收页 `/mock-debug` 用于补充真实浏览器检查,本次仍不打包。 + +响应替换简化交互:默认持续替换,保留原始状态码;点击“应用到网页”后回到网页重新查询,点击“恢复真实响应”结束替换。单次模式、状态码、复制/保存和“应用并刷新网页”收进高级设置,字段变更默认折叠。“应用并刷新网页”先应用最新有效草稿再刷新,避免使用旧内容。 + +2026-10-22 简化后本地 `npm test` 通过:88 项回归、资源检查、8 个 HTTP/Worker 子案例、17 项替换检查和 3 项预览检查。另用 DOM 模拟实际编辑器,验证高级设置默认折叠、持续替换默认值、连续两次命中、恢复真实响应、刷新前应用最新草稿及非法 JSON 阻止应用。未打包、未推送,未完成真实 Chrome 验收。 + ```sh npm run test:manual ``` @@ -51,6 +62,7 @@ npm run test:manual | 项目 | 检查内容 | | --- | --- | | 安装与捕获 | 加载扩展、新请求捕获、Network 记录补录、非 JSON 请求处理 | +| 响应替换 | `/mock-debug` 中 Fetch / XHR 表格变化、单次与持续、精确 URL、刷新前注入、导航、停用和错误状态码 | | 请求列表 | URL / 方法 / 状态筛选、清空、保留导航记录、侧栏开关与拖动 | | 搜索 | 左侧搜索接口地址,右侧搜索字段和值;匹配高亮、跨页定位、隐藏列定位 | | 表格 | 根数组、嵌套数组、混合值、分页、排序、列显隐 | @@ -73,3 +85,12 @@ npm run test:manual 4. 解压生成的 ZIP,确认根目录包含 manifest.json,并加载该目录。 1.4.13 的修复记录见 [CHECK_REPORT.md](../CHECK_REPORT.md)。 + +响应对比改为延迟执行:编辑和应用时不计算字段差异,网页请求首次命中当前规则后才对比原始响应与已应用正文。同一正文复用对比结果,未应用的草稿不参与;规则内容/状态/模式改变时命中计数重置,防止旧命中被当作新正文已生效。状态由现有 5 秒轮询同步,命中仅表示请求使用了假数据,不保证业务页面成功渲染。2026-10-22 本地 `npm test` 通过:88 项回归、资源检查、8 个 HTTP/Worker 子案例、18 项替换检查、3 项预览检查;尚未完成真实 Chrome 验收。 + + +## 1.5.0 原位字段 Mock 检查 + +2026-10-22 本地检查覆盖:双击 / F2 标量修改,不打开整份响应、不计算整份 diff;字段规则按标签页和网站保存,真实请求返回后应用。自动测试新增 13 项,使用 Node.js 执行 MAIN 脚本、模拟 XHR 与扩展 API;验证刷新后的新页面在规则到达前等待启动请求、后台重启后读回同一会话、其他网站 / 标签页隔离、退出后返回真实响应、数组重排仍按唯一标识定位,以及 2 MiB 响应只传递小字段补丁。共享精确整数代码与原解析器逐字核对。 + +另用临时 DOM 环境执行实际面板及 Worker,检查表格和查看器的双击编辑、Enter 提交、Esc 取消、失焦提交、底色标记、翻页重绘后保留、原值 / 新值清单、单项恢复、清空列表后规则保留与统一退出。DOM、XHR、Chrome API 均有模拟,不代表真实 Chrome 安装 / 刷新 / 业务页面渲染已经验收;手工步骤见 CHROME_REPRO.md。 diff --git a/docs/USAGE.md b/docs/USAGE.md index 4a7f410..8acdecc 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -48,6 +48,20 @@ $.data[0]["field.name"] 顶部“复制 JSON / 下载 JSON”保留原始响应文本。Raw JSON 首屏为受限预览,大数据优先使用查看器或下载原文。 +## 原位字段 Mock(1.5.0) + +双击表格、数据树或 JSON 查看器中的标量,或聚焦后按 F2。Enter / 失焦提交,Esc 取消;提交后自动进入编辑模式,不需要额外点击启用。 + +修改处以轻微底色标记,悬停可看原值。顶部显示修改数量,“查看修改”可核对前后值、定位字段或恢复一项;“退出编辑”清除当前网站的规则。 + +回到网页重新查询或刷新,真实接口响应会在交给网页前应用字段修改。未修改的字段继续使用最新后端数据。刷新、清空捕获列表和重开面板后保留;其他网站或标签页不共用。关闭标签页、重启浏览器或重新加载扩展后清除。 + +保持字段类型,不新增 / 删除字段。对象数组按唯一 ID / UUID / key 定位,缺少可靠标识时不允许编辑,标识失效时跳过。响应超过 1 MiB 也可编辑小字段;当前响应载入上限 20 MiB,单字段上限 64 KiB。 + +请求仍会执行后端操作,修改不直接重渲染业务页面;退出后需要再次查询或刷新才显示真实值。捕获历史和导出保留原始数据。支持主页面 Fetch 与异步 XHR 的 JSON 文本 / JSON 响应;接口按方法和完整 URL 匹配,不按请求体区分。 + +操作与限制见 [v1.5.0 发布说明](RELEASE_v1.5.0.md)。 + ## 搜索范围 - 左侧“搜索接口”只筛选已捕获的接口地址,不搜索响应字段;“清空记录”清除所有捕获记录。 diff --git a/field-mock-background.js b/field-mock-background.js new file mode 100644 index 0000000..175814b --- /dev/null +++ b/field-mock-background.js @@ -0,0 +1,41 @@ +// Session storage survives navigation and service-worker suspension, scoped by tab + page origin. +importScripts('field-patch-core.js'); +const fieldWrites=new Map(); +const originOf=url=>{const u=new URL(url);if(!/^https?:$/.test(u.protocol))throw Error('仅支持 HTTP / HTTPS 页面');return u.origin;}; +chrome.runtime.onMessage.addListener((message,sender,reply)=>{ + if(!message || message.channel!=='atv-fields')return; + const run=async()=>{ + const tabId=sender.tab?.id??message.tabId; + if(!Number.isInteger(tabId))throw Error('标签页不可用'); + if(sender.tab && sender.frameId!==0)throw Error('仅支持主页面'); + const origin=originOf(sender.tab?sender.url:(await chrome.tabs.get(tabId)).url); + if(!sender.tab && message.origin && message.origin!==origin)throw Error('网页已切换,请重新编辑'); + const storageKey='field-mock:'+tabId; + const sessions=(await chrome.storage.session.get(storageKey))[storageKey]||{}; + if(message.action==='set'){ + if(sender.tab)throw Error('网页不能写入编辑规则'); + const rules=message.rules; + if(!Array.isArray(rules) || rules.length>30 || JSON.stringify(rules).length>1024*1024)throw Error('修改规则过多(最多 30 个接口 / 1 MiB 配置)'); + const seen=new Set(); + for(const rule of rules){ + const url=new URL(rule.url);url.hash=''; + if(!/^https?:$/.test(url.protocol) || url.username || url.password || url.href!==rule.url || !/^(GET|POST|PUT|PATCH|DELETE)$/.test(rule.method) || !Array.isArray(rule.patches) || rule.patches.length>200)throw Error('接口规则无效'); + const key=rule.method+' '+rule.url;if(seen.has(key))throw Error('接口规则重复');seen.add(key); + for(const patch of rule.patches)FieldPatchCore.validate(patch); + } + if(rules.length)sessions[origin]=rules;else delete sessions[origin]; + if(JSON.stringify(sessions).length>3*1024*1024)throw Error('当前标签页保存的修改过多'); + await chrome.storage.session.set({[storageKey]:sessions}); + // A page may have navigated between storage and delivery; the bridge checks origin again. + await chrome.tabs.sendMessage(tabId,{channel:'atv-fields-config',origin,rules},{frameId:0}).catch(()=>{}); + } + return {origin,rules:sessions[origin]||[]}; + }; + const tabId=sender.tab?.id??message.tabId; + const previous=fieldWrites.get(tabId)||Promise.resolve(); + const pending=previous.catch(()=>{}).then(run); + fieldWrites.set(tabId,pending); + pending.then(result=>reply({ok:true,...result}),error=>reply({ok:false,error:error.message})).finally(()=>{if(fieldWrites.get(tabId)===pending)fieldWrites.delete(tabId);}); + return true; +}); +chrome.tabs.onRemoved.addListener(tabId=>{void chrome.storage.session.remove('field-mock:'+tabId);}); diff --git a/field-mock-bridge.js b/field-mock-bridge.js new file mode 100644 index 0000000..7084200 --- /dev/null +++ b/field-mock-bridge.js @@ -0,0 +1,12 @@ +(()=>{ + const channel='atv-fields-page-v1';let config=null; + const publish=()=>{if(config)window.postMessage({channel,kind:'config',origin:location.origin,rules:config},location.origin);}; + window.addEventListener('message',event=>{if(event.source===window && event.origin===location.origin && event.data?.channel===channel && event.data.kind==='ready')publish();}); + chrome.runtime.onMessage.addListener(message=>{ + if(message?.channel==='atv-fields-config' && message.origin===location.origin){config=message.rules;publish();} + }); + chrome.runtime.sendMessage({channel:'atv-fields',action:'get'}).then(result=>{ + // An incoming update can beat the initial read; never overwrite it with the old snapshot. + if(config===null)config=result?.ok?result.rules:[];publish(); + }).catch(()=>{if(config===null)config=[];publish();}); +})(); diff --git a/field-mock-page.js b/field-mock-page.js new file mode 100644 index 0000000..ce3b06a --- /dev/null +++ b/field-mock-page.js @@ -0,0 +1,108 @@ +// MAIN-world document_start hook; real requests continue normally, then scalar patches apply. +(()=>{ + const channel='atv-fields-page-v1',nativeFetch=window.fetch,NativeXHR=window.XMLHttpRequest; + let rules=[],serializedRules='[]',initialized=false,release; + const ready=new Promise(resolve=>{release=resolve;}); + const timeout=setTimeout(()=>{initialized=true;release();console.warn('[API Table Viewer] 规则加载超时,启动请求回退到真实响应');},3000); + const normalize=url=>{const u=new URL(url,location.href);u.hash='';return u.href;}; + window.addEventListener('message',event=>{ + if(event.source!==window || event.origin!==location.origin || event.data?.channel!==channel || event.data.kind!=='config' || event.data.origin!==location.origin)return; + try{ + const next=event.data.rules;if(!Array.isArray(next) || next.length>30 || JSON.stringify(next).length>1024*1024)return; + for(const rule of next){if(!Array.isArray(rule.patches) || rule.patches.length>200)return;for(const patch of rule.patches)FieldPatchCore.validate(patch);} + const serialized=JSON.stringify(next); + if(serialized!==serializedRules){rules=next;serializedRules=serialized;} + initialized=true;clearTimeout(timeout);release(); + }catch{} + }); + window.postMessage({channel,kind:'ready'},location.origin); + const match=(method,url)=>rules.find(r=>r.method===method && r.url===normalize(url)); + function patchText(text,rule){ + if(text.length>20*1024*1024){console.warn('[API Table Viewer] 响应超过 20 MiB,未应用字段修改');return null;} + try{ + const result=FieldPatchCore.apply(FieldPatchCore.parse(text),rule.patches); + if(result.warnings.length)console.warn('[API Table Viewer] 部分修改已跳过:',result.warnings); + return result.applied?FieldPatchCore.stringify(result.value):null; + }catch(error){console.warn('[API Table Viewer] 无法修改响应:',error.message);return null;} + } + function decorate(response,original){ + for(const name of ['url','redirected','type'])Object.defineProperty(response,name,{value:original[name]}); + const clone=response.clone.bind(response);Object.defineProperty(response,'clone',{value:()=>decorate(clone(),original)});return response; + } + window.fetch=async function(input,init){ + if(!initialized)await ready; + const method=String(init?.method??(input instanceof Request?input.method:'GET')).toUpperCase(); + const url=input instanceof Request?input.url:String(input),rule=match(method,url); + const response=await Reflect.apply(nativeFetch,this,arguments); + if(!rule || response.type==='opaque' || !response.body || !/json/i.test(response.headers.get('content-type')||''))return response; + let text; + try{text=await response.clone().text();}catch{return response;} + // Exit/edit while a request is in flight must not apply an obsolete rule. + if(match(method,url)!==rule)return response; + const patched=patchText(text,rule);if(patched===null)return response; + const headers=new Headers(response.headers);for(const name of ['content-length','content-encoding','etag','content-md5'])headers.delete(name); + headers.set('x-api-table-viewer-mock','fields'); + void response.body.cancel().catch(()=>{}); + return decorate(new Response(patched,{status:response.status,statusText:response.statusText,headers}),response); + }; + const states=new WeakMap(); + class FieldXHR extends NativeXHR{ + constructor(){super();states.set(this,{generation:0,pending:false,sent:false,rule:null,body:undefined,json:undefined,type:null});} + open(method,url,async=true,...rest){ + const state=states.get(this);state.generation++;state.pending=false;state.sent=false;state.rule=null;state.body=undefined;state.json=undefined;state.type=null; + state.method=String(method).toUpperCase();state.url=normalize(String(url));state.async=Boolean(async); + return super.open(method,url,async,...rest); + } + send(body){ + const state=states.get(this);if(state.sent || this.readyState!==1)throw new DOMException('Invalid XHR state','InvalidStateError');state.sent=true; + const generation=state.generation; + const send=()=>{ + if(generation!==state.generation)return; + state.pending=false;const rule=match(state.method,state.url),type=super.responseType; + if(rule && state.async && (!type || type==='text' || type==='json')){ + state.rule=rule;state.type=type;if(type==='json')super.responseType='text'; + } + return super.send(body); + }; + if(!state.async || initialized)return send(); + state.pending=true;void ready.then(send).catch(error=>{console.warn('[API Table Viewer] XHR 启动失败',error);this.dispatchEvent(new Event('error'));this.dispatchEvent(new Event('loadend'));}); + } + abort(){const state=states.get(this),pending=state.pending;state.generation++;state.pending=false;state.rule=null;state.sent=false;const result=super.abort();if(pending){this.dispatchEvent(new Event('abort'));this.dispatchEvent(new Event('loadend'));}return result;} + setRequestHeader(name,value){if(states.get(this).pending)throw new DOMException('Request already sent','InvalidStateError');return super.setRequestHeader(name,value);} + get responseType(){return states.get(this)?.type??super.responseType;} + set responseType(value){const state=states.get(this);if(state?.pending)throw new DOMException('Request already sent','InvalidStateError');super.responseType=value;if(state)state.type=null;} + patched(){ + const state=states.get(this); + if(!state.rule || this.readyState!==4 || !/json/i.test(super.getResponseHeader('content-type')||''))return undefined; + if(state.body===undefined){if(match(state.method,state.url)!==state.rule)return undefined;state.body=patchText(super.responseText,state.rule)??null;} + return state.body??undefined; + } + getResponseHeader(name){ + const changed=this.patched()!==undefined,key=String(name).toLowerCase(); + if(changed && key==='x-api-table-viewer-mock')return 'fields'; + if(changed && ['content-length','content-encoding','etag','content-md5'].includes(key))return null; + return super.getResponseHeader(name); + } + getAllResponseHeaders(){ + const headers=super.getAllResponseHeaders(); + return this.patched()===undefined?headers:headers.split(/\r?\n/).filter(line=>line && !/^(content-length|content-encoding|etag|content-md5):/i.test(line)).join('\r\n')+'\r\nx-api-table-viewer-mock: fields\r\n'; + } + get responseText(){ + const state=states.get(this); + if(state?.type==='json')throw new DOMException('responseText unavailable for JSON','InvalidStateError'); + if(state?.rule && this.readyState===3)return ''; + return this.patched()??super.responseText; + } + get response(){ + const state=states.get(this); + if(state?.type==='json'){ + if(this.readyState!==4)return null; + if(state.json===undefined){try{state.json=JSON.parse(this.patched()??super.responseText);}catch{state.json=null;}} + return state.json; + } + if(!super.responseType || super.responseType==='text')return this.patched()??super.response; + return super.response; + } + } + window.XMLHttpRequest=FieldXHR; +})(); diff --git a/field-mock.js b/field-mock.js new file mode 100644 index 0000000..79ceeff --- /dev/null +++ b/field-mock.js @@ -0,0 +1,129 @@ +// In-place editor. Only scalar patches cross the extension boundary; captured Worker data stays immutable. +const FieldMock=(()=>{ + let rules=[],origin='',version=0,loading=0,queue=Promise.resolve(); + const bindings=new WeakMap(),locations=new Map(); + const keyFor=item=>String(item.method).toUpperCase()+' '+normalize(item.url); + function normalize(url){const parsed=new URL(url);parsed.hash='';return parsed.href;} + const ruleFor=item=>rules.find(r=>r.method+' '+r.url===keyFor(item)); + async function request(action,changes){ + if(!chrome.runtime?.sendMessage)throw Error('请重新加载扩展并刷新网页后编辑'); + const result=await chrome.runtime.sendMessage({channel:'atv-fields',action,tabId:chrome.devtools.inspectedWindow.tabId,origin:action==='set'?origin:undefined,rules:changes}); + if(!result?.ok)throw Error(result?.error||'无法保存修改规则');return result; + } + async function load(){ + const current=++loading; + try{const result=await request('get');if(current!==loading)return;origin=result.origin;rules=result.rules;version++;locations.clear();render();paintAll();} + catch(error){if(current!==loading)return;origin='';rules=[];version++;locations.clear();render();paintAll();} + } + function mutate(change){ + const requestedOrigin=origin; + const operation=queue.then(async()=>{ + if(origin!==requestedOrigin || !origin)throw Error('网页已切换,请重新编辑'); + const next=structuredClone(rules);change(next); + const result=await request('set',next.filter(r=>r.patches.length)); + if(result.origin!==origin)return; + rules=result.rules;version++;locations.clear();render();paintAll(); + }); + queue=operation.catch(()=>{});return operation; + } + function resolved(item){ + const rule=ruleFor(item);if(!rule)return Promise.resolve([]); + const key=item.rid+':'+version; + if(!locations.has(key))locations.set(key,callWorker('patchLocations',{rid:item.rid,patches:rule.patches}).then(result=>result.paths).catch(()=>[])); + return locations.get(key); + } + async function paint(el){ + const binding=bindings.get(el);if(!binding || binding.editing)return; + const current=version,paths=await resolved(binding.item);if(current!==version || binding.editing || !el.isConnected)return; + const found=paths.find(entry=>JSON.stringify(entry.path)===JSON.stringify(binding.path)); + const patch=found?ruleFor(binding.item)?.patches[found.index]:null; + binding.patch=patch; + binding.mark.classList.toggle('field-mock-edited',!!patch); + if(patch){ + binding.altered=true;const value=FieldPatchCore.parse(patch.value);el.textContent=typeof value==='string'?value:patch.value; + el.title='已修改 · 原值:'+patch.old+' → '+patch.value+'\n双击继续编辑;顶部可恢复此项'; + el.setAttribute('aria-label',pathText(binding.path)+',已修改,原值 '+patch.old+',当前 '+patch.value); + }else{ + if(binding.altered){el.innerHTML=binding.html;binding.altered=false;}el.title=binding.title+'\n双击修改 · F2 编辑';el.removeAttribute('aria-label'); + } + } + function paintAll(){for(const el of document.querySelectorAll('[data-field-mock]'))void paint(el);} + function bind(el,item,path,type,mark=el){ + if(!el || !item || !['string','number','boolean','null'].includes(type))return; + const binding={item,path:[...path],type,mark,text:el.textContent,html:el.innerHTML,title:el.title||'',editing:false,patch:null};bindings.set(el,binding); + el.dataset.fieldMock='1';el.tabIndex=0;el.title=binding.title+'\n双击修改 · F2 编辑'; + const click=el.onclick;let timer; + if(click)el.onclick=e=>{e.stopPropagation();clearTimeout(timer);timer=setTimeout(()=>{if(!binding.editing && selected===binding.item)void click(e);},260);}; + const start=safe(async e=>{e.preventDefault();e.stopPropagation();clearTimeout(timer);await edit(el);}); + el.ondblclick=start; + const keydown=el.onkeydown;el.onkeydown=e=>{if(e.key==='F2'){void start(e);return;}keydown?.(e);}; + void paint(el); + } + async function edit(el){ + const binding=bindings.get(el);if(!binding || binding.editing)return; + if(!origin)await load();if(!origin)throw Error('当前页面不支持编辑,请重新加载扩展并刷新网页'); + const currentOrigin=origin; + const target=await callWorker('patchTarget',{rid:binding.item.rid,path:binding.path}); + if(origin!==currentOrigin || !el.isConnected)return; + const existing=binding.patch; + const patch=existing?structuredClone(existing):target; + const current=FieldPatchCore.parse(patch.value); + const input=document.createElement(typeof current==='string'&¤t.includes('\n')?'textarea':'input'); + if(input.tagName==='INPUT')input.type='text';else input.rows=3; + input.className='field-mock-input';input.value=typeof current==='string'?current:patch.value; + input.setAttribute('aria-label','修改 '+pathText(binding.path)+'('+binding.type+')'); + input.title='Enter 提交 · Esc 取消(保持 '+binding.type+' 类型)'; + binding.editing=true;binding.altered=true;el.textContent='';el.appendChild(input);input.focus();input.select();let finished=false,saving=false; + const cancel=()=>{finished=true;binding.editing=false;void paint(el);}; + async function commit(){ + if(finished || saving)return; + let value; + try{ + value=patch.type==='string'?JSON.stringify(input.value):FieldPatchCore.stringify(FieldPatchCore.parse(input.value)); + FieldPatchCore.validate({...patch,value}); + }catch(error){input.setAttribute('aria-invalid','true');input.title=error.message+' · Esc 取消';toast(error.message);input.focus();return;} + saving=true;input.disabled=true; + try{ + if(origin!==currentOrigin)throw Error('网页已切换,修改未保存'); + await mutate(next=>{ + const id=keyFor(binding.item);let rule=next.find(r=>r.method+' '+r.url===id); + if(!rule){rule={method:binding.item.method.toUpperCase(),url:normalize(binding.item.url),patches:[]};next.push(rule);} + const identity=FieldPatchCore.identity(patch),index=rule.patches.findIndex(p=>FieldPatchCore.identity(p)===identity); + const original=index>=0?rule.patches[index].old:patch.old; + if(value===original){if(index>=0)rule.patches.splice(index,1);} + else{const updated={...patch,old:original,value};if(index>=0)rule.patches[index]=updated;else rule.patches.push(updated);} + }); + finished=true;binding.editing=false;void paint(el);toast('已保存修改,网页下次请求自动应用;刷新后保留'); + }catch(error){input.disabled=false;toast(error.message);input.focus();} + finally{saving=false;} + } + input.onclick=e=>e.stopPropagation();input.ondblclick=e=>e.stopPropagation(); + input.onkeydown=e=>{e.stopPropagation();if(e.key==='Escape'){e.preventDefault();cancel();}else if(e.key==='Enter' && !e.shiftKey && !e.isComposing){e.preventDefault();void commit();}}; + input.onblur=()=>{void commit();}; + } + function restore(rule,patch){return mutate(next=>{const target=next.find(r=>r.method===rule.method && r.url===rule.url);if(target)target.patches=target.patches.filter(p=>FieldPatchCore.identity(p)!==FieldPatchCore.identity(patch));});} + async function locate(rule,patch){ + const item=requests.find(r=>r.valid && keyFor(r)===rule.method+' '+rule.url); + if(!item)throw Error('当前捕获列表没有该接口,请在网页重新请求'); + await selectRequest(item); + const paths=await resolved(item),index=ruleFor(item)?.patches.findIndex(p=>FieldPatchCore.identity(p)===FieldPatchCore.identity(patch)); + const found=paths.find(entry=>entry.index===index);if(!found)throw Error('该字段已不存在或数组标识已改变'); + await openJsonViewer(item.rid,found.path,'已修改字段 · '+pathText(found.path)); + } + function render(){ + const count=rules.reduce((sum,r)=>sum+r.patches.length,0); + $('#fieldMockBar').classList.toggle('hidden',!count); + $('#fieldMockSummary').textContent=`编辑模式 · ${rules.length} 个接口 · ${count} 处修改 · 刷新后保留`; + const list=$('#fieldMockChangeList');list.textContent=''; + for(const rule of rules)for(const patch of rule.patches){ + const row=document.createElement('div');row.className='field-mock-change'; + const text=document.createElement('span');text.textContent=rule.method+' '+rule.url+' · '+pathText(patch.path)+' · '+patch.old+' → '+patch.value;row.appendChild(text); + for(const [label,action] of [['定位',()=>locate(rule,patch)],['恢复此项',()=>restore(rule,patch)]]){const button=document.createElement('button');button.className='btn ghost';button.textContent=label;button.onclick=safe(action);row.appendChild(button);} + list.appendChild(row); + } + } + $('#fieldMockExit').onclick=safe(async()=>{await mutate(next=>{next.length=0;});toast('已退出编辑模式,后续请求返回真实响应;重新查询网页以恢复显示');}); + chrome.devtools.network.onNavigated.addListener(()=>{void load();}); + void load(); + return {bind,load,edit}; +})(); diff --git a/field-patch-core.js b/field-patch-core.js new file mode 100644 index 0000000..685236c --- /dev/null +++ b/field-patch-core.js @@ -0,0 +1,116 @@ +// Shared scalar patch engine. Numeric codec is kept identical to json-codec.js. +const FieldPatchCore=(()=>{ +// Preserve integer literals outside Number's safe range without changing JSON strings. +function parseJsonExact(text){ + text=String(text).replace(/^\uFEFF/,''); + if(!/-?\d{16,}/.test(text))return JSON.parse(text); + let prefix='__API_TABLE_INTEGER__';while(text.includes(prefix))prefix+='_'; + let integers,rewritten,collision; + do{ + integers=[];collision=false; + rewritten=text.replace(/"(?:\\.|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g,token=>{ + if(token[0]==='"'){ + if((token.includes(prefix)||token.includes('\\u')) && JSON.parse(token).startsWith(prefix))collision=true; + return token; + } + if(/^-?\d+$/.test(token) && !Number.isSafeInteger(Number(token))){const index=integers.push(BigInt(token))-1;return JSON.stringify(prefix+index);} + return token; + }); + if(collision)prefix+='_'; + }while(collision); + let root=JSON.parse(rewritten); + const decode=v=>typeof v==='string' && v.startsWith(prefix) && /^\d+$/.test(v.slice(prefix.length))?integers[Number(v.slice(prefix.length))]:v; + root=decode(root);const stack=root&&typeof root==='object'?[root]:[]; + while(stack.length){const value=stack.pop();for(const key of Object.keys(value)){value[key]=decode(value[key]);if(value[key]&&typeof value[key]==='object')stack.push(value[key]);}} + return root; +} +function stringifyJson(value,replacer=null,space){ + // Native raw JSON emits large integers as number literals, not quoted text. + if(typeof JSON.rawJSON==='function')return JSON.stringify(value,(key,v)=>{if(typeof replacer==='function')v=replacer(key,v);return typeof v==='bigint'?JSON.rawJSON(String(v)):v;},space); + let prefix='__API_TABLE_BIGINT__'; + for(;;){ + const values=[];let collision=false; + const text=JSON.stringify(value,(key,v)=>{ + if(typeof replacer==='function')v=replacer(key,v); + if(key.startsWith(prefix) || typeof v==='string' && v.startsWith(prefix))collision=true; + if(typeof v==='bigint')return prefix+(values.push(String(v))-1); + return v; + },space); + if(collision){prefix+='_';continue;} + return text?.replace(new RegExp('"'+prefix+'(\\d+)"','g'),(_,index)=>values[Number(index)]); + } +} + + const scalar=v=>v===null || ['string','number','boolean','bigint'].includes(typeof v); + const kind=v=>typeof v==='bigint'?'number':v===null?'null':typeof v; + const token=v=>stringifyJson(v); + function target(root,path){ + if(!Array.isArray(path) || path.length>64)throw Error('字段路径过深'); + let value=root;const anchors=[]; + for(let depth=0;depth100000)throw Error('数组定位超出支持范围'); + const row=value[key]; + if(row && typeof row==='object' && !Array.isArray(row)){ + const keys=Object.keys(row).filter(k=>/^(id|uuid|key)$/i.test(k) || /id$/i.test(k)); + const identity=keys.find(k=>scalar(row[k]) && row[k]!==null && value.filter(r=>r && Object.hasOwn(r,k) && token(r[k])===token(row[k])).length===1); + if(!identity)throw Error('数组行缺少唯一 id / uuid / key,无法安全定位修改'); + if(depth===path.length-2 && path.at(-1)===identity)throw Error('暂不支持修改用于定位数组行的标识字段'); + anchors.push({depth,key:identity,value:token(row[identity])}); + }else if(scalar(row))anchors.push({depth,key:null,value:token(row)}); + else throw Error('嵌套数组缺少稳定标识,暂不支持编辑'); + } + value=value[key]; + } + if(!scalar(value))throw Error('请双击字符串、数字、布尔值或 null'); + if(token(value).length>65536)throw Error('单个字段超过 64 KiB,暂不支持编辑'); + return {path:[...path],anchors,type:kind(value),old:token(value),value:token(value)}; + } + function validate(patch){ + if(!patch || !Array.isArray(patch.path) || patch.path.length>64 || !patch.path.every(k=>typeof k==='string' || Number.isInteger(k)&&k>=0))throw Error('字段路径无效'); + if(!Array.isArray(patch.anchors) || patch.anchors.length>64)throw Error('数组定位无效'); + for(const a of patch.anchors){ + if(!Number.isInteger(a.depth) || a.depth<0 || a.depth>=patch.path.length || a.key!==null && typeof a.key!=='string' || typeof a.value!=='string' || a.value.length>65536 || !scalar(parseJsonExact(a.value)))throw Error('数组标识无效'); + } + if(typeof patch.value!=='string' || patch.value.length>65536 || typeof patch.old!=='string' || patch.old.length>65536)throw Error('字段内容过大'); + const value=parseJsonExact(patch.value),old=parseJsonExact(patch.old); + if(!scalar(value) || !scalar(old) || kind(old)!==patch.type || kind(value)!==patch.type)throw Error('修改必须保持原字段类型'); + return patch; + } + function resolve(root,patch){ + let value=root;const path=[]; + for(let depth=0;deptha.depth===depth); + if(Array.isArray(value)){ + if(!anchor || value.length>100000)throw Error('数组缺少定位信息'); + if(anchor.key===null){if(token(value[key])!==anchor.value && token(value[key])!==patch.value)throw Error('数组元素已改变');} + else{ + const indices=[]; + for(let i=0;ipatch.anchors.some(a=>a.depth===i && a.key!==null)?'*':k),patch.anchors]);} + return {parse:parseJsonExact,stringify:stringifyJson,target,validate,resolve,apply,identity}; +})(); diff --git a/manifest.json b/manifest.json index 1650567..e861f5a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,42 @@ { "manifest_version": 3, "name": "API Table Viewer", - "version": "1.4.13", + "version": "1.5.0", "description": "High-performance JSON API response viewer for Chrome DevTools", - "devtools_page": "devtools.html" -} \ No newline at end of file + "devtools_page": "devtools.html", + "minimum_chrome_version": "114", + "permissions": [ + "storage" + ], + "host_permissions": [ + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "field-mock-background.js" + }, + "content_scripts": [ + { + "matches": [ + "http://*/*", + "https://*/*" + ], + "js": [ + "field-patch-core.js", + "field-mock-page.js" + ], + "run_at": "document_start", + "world": "MAIN" + }, + { + "matches": [ + "http://*/*", + "https://*/*" + ], + "js": [ + "field-mock-bridge.js" + ], + "run_at": "document_start" + } + ] +} diff --git a/mock-page.js b/mock-page.js new file mode 100644 index 0000000..00cf6ec --- /dev/null +++ b/mock-page.js @@ -0,0 +1,162 @@ +// Serialized into the inspected page through the DevTools API. Keep self-contained. +function installPageResponseMocks(config){ + 'use strict'; + const key='__API_TABLE_VIEWER_RESPONSE_MOCK_V1__',g=globalThis; + if(g.top && g.top!==g)return {active:false,reason:'仅支持主页面',origin:g.location.origin}; + if(!/^https?:$/.test(g.location.protocol))throw Error('响应替换仅支持 HTTP / HTTPS 网页'); + let existing=g[key]; + if(existing && existing.version!==1)throw Error('页面中存在不兼容的响应替换器'); + if(config.action==='status')return existing?.owner===config.owner?existing.status(true):{active:false,origin:g.location.origin,rules:[]}; + if(config.action==='stop'){ + if(existing?.owner===config.owner)existing.stop(); + return {active:false,origin:g.location.origin,rules:[]}; + } + if(config.action!=='set')throw Error('未知的响应替换操作'); + if(config.origin && config.origin!==g.location.origin)throw Error('网页来源已改变,请重新启用替换'); + if(!Array.isArray(config.rules) || config.rules.length>10)throw Error('最多支持 10 条替换规则'); + const encoder=new TextEncoder();let total=0; + const next=config.rules.map(rule=>{ + const url=new URL(rule.url); + if(!/^https?:$/.test(url.protocol) || url.username || url.password)throw Error('接口 URL 无效'); + url.hash=''; + if(!/^(GET|POST|PUT|PATCH|DELETE)$/.test(rule.method))throw Error('仅支持 GET / POST / PUT / PATCH / DELETE'); + if(typeof rule.body!=='string' || encoder.encode(rule.body).byteLength>1024*1024)throw Error('单条响应不能超过 1 MiB'); + total+=encoder.encode(rule.body).byteLength; + if(total>3*1024*1024)throw Error('启用的响应总大小不能超过 3 MiB'); + JSON.parse(rule.body.replace(/^\uFEFF/,'')); + if(!Number.isInteger(rule.status) || rule.status<200 || rule.status>599 || [204,205,304].includes(rule.status))throw Error('状态码须为支持 JSON 正文的 200–599 整数'); + return {id:String(rule.id),method:rule.method,url:url.href,body:rule.body,status:rule.status,once:rule.once===true,enabled:true,hits:0,lastKind:'',lastHit:0}; + }); + if(new Set(next.map(rule=>rule.method+' '+rule.url)).size!==next.length)throw Error('替换规则不能重复'); + if(existing?.owner!==config.owner){existing?.stop();existing=null;} + if(existing)return existing.replace(next); + const originalFetch=g.fetch,OriginalXHR=g.XMLHttpRequest; + if(typeof originalFetch!=='function' || typeof OriginalXHR!=='function')throw Error('页面缺少 Fetch 或 XMLHttpRequest'); + let rules=next,enabled=true,expires=Date.now()+120000,expiryTimer; + const instance=Date.now().toString(36)+'-'+Math.random().toString(36).slice(2); + const xhrStates=new WeakMap(); + const statusTexts={200:'OK',201:'Created',202:'Accepted',400:'Bad Request',401:'Unauthorized',403:'Forbidden',404:'Not Found',422:'Unprocessable Content',500:'Internal Server Error',502:'Bad Gateway',503:'Service Unavailable'}; + function alive(){if(enabled && Date.now()>=expires)stop();return enabled;} + function take(method,url,kind){ + if(!alive())return null; + const parsed=new URL(url,g.location.href);parsed.hash=''; + const rule=rules.find(item=>item.enabled && item.method===method && item.url===parsed.href); + if(!rule)return null; + rule.hits++;rule.lastKind=kind;rule.lastHit=Date.now();if(rule.once)rule.enabled=false; + return rule; + } + function decorate(response,url){ + Object.defineProperty(response,'url',{value:url,configurable:true}); + const clone=response.clone.bind(response); + Object.defineProperty(response,'clone',{value:()=>decorate(clone(),url),configurable:true}); + return response; + } + async function wrappedFetch(input,init){ + const url=input instanceof Request?input.url:new URL(String(input),g.location.href).href; + const method=String(init?.method??(input instanceof Request?input.method:'GET')).toUpperCase(); + // A Request validates inputs without consuming a body on the pass-through path. + if(!alive() || !rules.some(rule=>rule.enabled && rule.method===method && rule.url===url.split('#')[0]))return Reflect.apply(originalFetch,this,arguments); + const request=new Request(input instanceof Request?input:url,init); + if(request.mode==='no-cors')throw new TypeError('响应替换不支持 no-cors 请求,请停用该规则后重试'); + if(request.signal.aborted)throw request.signal.reason??new DOMException('The operation was aborted.','AbortError'); + const rule=take(request.method,request.url,'Fetch'); + if(!rule)return Reflect.apply(originalFetch,this,arguments); + await new Promise((resolve,reject)=>{ + const signal=request.signal; + const abort=()=>{clearTimeout(timer);signal.removeEventListener('abort',abort);reject(signal.reason??new DOMException('The operation was aborted.','AbortError'));}; + const timer=setTimeout(()=>{signal.removeEventListener('abort',abort);resolve();},0); + signal.addEventListener('abort',abort,{once:true}); + if(signal.aborted)abort(); + }); + return decorate(new Response(rule.body,{status:rule.status,statusText:statusTexts[rule.status]||'',headers:{'content-type':'application/json; charset=utf-8','x-api-table-viewer-mock':'1'}}),request.url); + } + class MockXHR extends OriginalXHR{ + constructor(){super();xhrStates.set(this,{method:'',url:'',async:true,sent:false,mock:null,timer:null});} + open(method,url,async=true,...rest){ + const state=xhrStates.get(this);clearTimeout(state.timer); + if(state.mock)state.mock.aborted=true; + state.mock=null;state.sent=false; + state.method=String(method).toUpperCase();state.url=new URL(String(url),g.location.href).href;state.async=Boolean(async); + return super.open(method,url,async,...rest); + } + send(body){ + const state=xhrStates.get(this); + if(this.readyState!==1 || state.sent)throw new DOMException('Invalid XHR state.','InvalidStateError'); + const match=alive() && rules.some(rule=>rule.enabled && rule.method===state.method && rule.url===state.url.split('#')[0]); + if(!match)return super.send(body); + if(!state.async)throw new DOMException('响应替换仅支持异步 XHR。','InvalidAccessError'); + const rule=take(state.method,state.url,'XHR'); + if(!rule)return super.send(body); + state.sent=true; + const mock={rule,readyState:1,aborted:false,json:undefined};state.mock=mock; + this.dispatchEvent(new ProgressEvent('loadstart')); + if(mock.aborted || state.mock!==mock)return; + state.timer=setTimeout(()=>{ + const current=()=>state.mock===mock && !mock.aborted; + for(const readyState of [2,3]){ + if(!current())return;mock.readyState=readyState;this.dispatchEvent(new Event('readystatechange')); + } + if(!current())return; + const size=encoder.encode(rule.body).byteLength; + this.dispatchEvent(new ProgressEvent('progress',{lengthComputable:true,loaded:size,total:size})); + if(!current())return;mock.readyState=4;this.dispatchEvent(new Event('readystatechange')); + if(!current())return;this.dispatchEvent(new ProgressEvent('load',{lengthComputable:true,loaded:size,total:size})); + if(current())this.dispatchEvent(new ProgressEvent('loadend',{lengthComputable:true,loaded:size,total:size})); + },0); + } + abort(){ + const state=xhrStates.get(this),mock=state.mock; + if(!mock)return super.abort(); + clearTimeout(state.timer); + if(mock.readyState===4){mock.aborted=true;mock.readyState=0;state.sent=false;return;} + mock.aborted=true;mock.readyState=4; + this.dispatchEvent(new Event('readystatechange'));this.dispatchEvent(new ProgressEvent('abort'));this.dispatchEvent(new ProgressEvent('loadend')); + mock.readyState=0;state.sent=false; + } + setRequestHeader(name,value){if(xhrStates.get(this).sent)throw new DOMException('Invalid XHR state.','InvalidStateError');return super.setRequestHeader(name,value);} + get readyState(){return xhrStates.get(this)?.mock?.readyState??super.readyState;} + get status(){const mock=xhrStates.get(this)?.mock;return mock?(mock.readyState>=2 && !mock.aborted?mock.rule.status:0):super.status;} + get statusText(){const mock=xhrStates.get(this)?.mock;return mock?(this.status?statusTexts[this.status]||'':''):super.statusText;} + get responseURL(){const state=xhrStates.get(this);return state?.mock?(this.status?state.url.split('#')[0]:''):super.responseURL;} + get responseText(){ + const mock=xhrStates.get(this)?.mock;if(!mock)return super.responseText; + if(this.responseType && this.responseType!=='text')throw new DOMException('responseText is unavailable for this responseType.','InvalidStateError'); + return mock.readyState>=3 && !mock.aborted?mock.rule.body:''; + } + get responseXML(){return xhrStates.get(this)?.mock?null:super.responseXML;} + get response(){ + const mock=xhrStates.get(this)?.mock;if(!mock)return super.response; + const type=this.responseType; + if(!type || type==='text')return this.responseText; + if(mock.readyState!==4 || mock.aborted)return null; + if(type==='json'){if(mock.json===undefined)mock.json=JSON.parse(mock.rule.body.replace(/^\uFEFF/,''));return mock.json;} + if(type==='blob')return mock.blob??(mock.blob=new Blob([mock.rule.body],{type:'application/json'})); + if(type==='arraybuffer')return mock.buffer??(mock.buffer=encoder.encode(mock.rule.body).buffer); + return null; + } + getResponseHeader(name){ + if(!xhrStates.get(this)?.mock)return super.getResponseHeader(name); + if(!this.status)return null; + return {'content-type':'application/json; charset=utf-8','x-api-table-viewer-mock':'1'}[String(name).toLowerCase()]??null; + } + getAllResponseHeaders(){return xhrStates.get(this)?.mock?(this.status?'content-type: application/json; charset=utf-8\r\nx-api-table-viewer-mock: 1\r\n':''):super.getAllResponseHeaders();} + } + function stop(){ + enabled=false;clearTimeout(expiryTimer); + if(g.fetch===wrappedFetch)g.fetch=originalFetch; + if(g.XMLHttpRequest===MockXHR)g.XMLHttpRequest=OriginalXHR; + if(g[key]===api)delete g[key]; + } + function status(renew=false){ + alive(); + if(renew && enabled){expires=Date.now()+120000;clearTimeout(expiryTimer);expiryTimer=setTimeout(stop,120000);} + return {active:enabled,origin:g.location.origin,instance,rules:rules.map(({id,enabled,hits,lastKind,lastHit})=>({id,enabled,hits,lastKind,lastHit}))}; + } + const api={version:1,owner:config.owner,stop,status,replace(nextRules){ + for(const rule of nextRules){const old=rules.find(item=>item.id===rule.id);if(old && old.body===rule.body && old.status===rule.status && old.once===rule.once){rule.hits=old.hits;rule.lastKind=old.lastKind;rule.lastHit=old.lastHit;}} + rules=nextRules;return status(true); + }}; + try{g[key]=api;g.fetch=wrappedFetch;g.XMLHttpRequest=MockXHR;} + catch(error){stop();throw Error('页面的请求 API 不允许替换:'+error.message);} + return status(true); +} diff --git a/package.json b/package.json index 6b8df16..c80c56e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "api-table-viewer", - "version": "1.4.13", + "version": "1.5.0", "private": true, "description": "Chrome DevTools JSON response table and integrated JSON viewer", "license": "UNLICENSED", @@ -8,10 +8,14 @@ "node": ">=18" }, "scripts": { - "test": "node tests/regression.cjs && node tests/package-check.cjs && npm run test:fixtures", + "preview": "node tests/preview/server.cjs", + "test": "node tests/regression.cjs && node tests/package-check.cjs && npm run test:fixtures && npm run test:mock && npm run test:preview && npm run test:fields", "test:regression": "node tests/regression.cjs", "test:package": "node tests/package-check.cjs", "test:manual": "node tests/manual/server.cjs", - "test:fixtures": "node --test tests/manual/check.cjs" + "test:fixtures": "node --test tests/manual/check.cjs", + "test:preview": "node --test tests/preview/check.cjs", + "test:mock": "node --test tests/response-mock.cjs", + "test:fields": "node --test tests/field-mock.cjs" } } diff --git a/panel.html b/panel.html index 5abd2af..53023be 100644 --- a/panel.html +++ b/panel.html @@ -13,6 +13,10 @@ + + + +
-
+
双击字段值修改 · F2 编辑 + @@ -139,6 +144,30 @@
+
@@ -148,5 +177,10 @@ + + + + + diff --git a/panel.js b/panel.js index 35ec838..cff59ad 100644 --- a/panel.js +++ b/panel.js @@ -66,6 +66,7 @@ async function addRequest(req, jsonRaw, generation=captureGeneration) { requests.unshift(item);renderList(); } function resetDetail(){ + if(typeof ResponseEditor!=="undefined")ResponseEditor.reset(); selected=null;selectionVersion++;tableVersion++;clearTimeout(window.st); tableState={path:null,page:1,pageSize:100,query:"",sortKey:null,dir:1,columns:[],rows:[],total:0}; $("#detail").classList.add("hidden");$("#emptyState").classList.remove("hidden"); @@ -123,6 +124,7 @@ function renderNode(meta,parent,root=false,path=[]) { head.innerHTML=`${expandable?"▶":""}${root?"root":esc(path[path.length-1])}${esc(expandable?meta.label:meta.type)}${!expandable?`${esc(meta.label)}`:""}`; if(meta.type==="array") {const b=document.createElement("button");b.className="mini-btn";b.textContent="▦ 表格";b.onclick=safe(e=>{e.stopPropagation();return openTable(path);});head.appendChild(b);} if(root){const b=document.createElement("button");b.className="mini-btn";b.textContent="查看";b.onclick=safe(e=>{e.stopPropagation();return showValue(path,"root");});head.appendChild(b);} + if(!expandable && typeof FieldMock!=='undefined')FieldMock.bind(head.querySelector('.node-preview'),selected,path,meta.type); wrap.appendChild(head); parent.appendChild(wrap); const children=document.createElement("div"); children.className="node-children hidden"; wrap.appendChild(children); if(expandable){ @@ -161,6 +163,7 @@ function renderChildren(list,parent){ } else { const b=document.createElement("button"); b.className="copy-mini"; b.textContent="复制值"; b.onclick=safe(e=>{e.stopPropagation();return callWorker("value",{rid:selected.rid,path:basePath}).then(r=>copyText(c.type==="string"?JSON.parse(r.text):r.text));}); head.appendChild(b); } + if(!expandable && typeof FieldMock!=='undefined')FieldMock.bind(head.querySelector('.node-preview'),selected,basePath,c.type); wrap.appendChild(head); const child=document.createElement("div"); child.className="node-children hidden"; wrap.appendChild(child); parent.appendChild(wrap); @@ -239,6 +242,9 @@ async function renderTable(navigate=false){ const b=document.createElement("button");b.className="expand-cell";b.textContent="查看"; b.onclick=safe(()=>showValue(cell.path,col));content.appendChild(b); }else td.ondblclick=safe(()=>copyText(text)); + if(typeof FieldMock!=='undefined' && cell.path && !complex && cell.type!=='undefined'){ + td.ondblclick=null;FieldMock.bind(span,selected,cell.path,cell.type,td); + } tr.appendChild(td); } tbody.appendChild(tr); diff --git a/response-editor.js b/response-editor.js new file mode 100644 index 0000000..2fbeb6e --- /dev/null +++ b/response-editor.js @@ -0,0 +1,186 @@ +// Response drafts are isolated from captured data. Mock rules are enabled explicitly. +const ResponseEditor=(()=>{ + const LIMIT=1024*1024, drafts=new WeakMap(); + let state=null, generation=0, timer; + const bytes=text=>new TextEncoder().encode(text).byteLength; + function validate(text){ + if(bytes(text)>LIMIT)throw Error('编辑内容超过 1 MiB,请缩小数据后重试'); + return parseJsonExact(text); + } + function format(text){ + validate(text); + const tokens=String(text).replace(/^\uFEFF/,'').match(/"(?:\\.|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null|[{}\[\]:,]/g)||[]; + const out=[];let depth=0; + const indent=()=> ' '.repeat(depth); + for(let i=0;i64)throw Error('嵌套超过 64 层,无法在编辑器中格式化'); + out.push(token);if(tokens[i+1]!=='}'&&tokens[i+1]!==']')out.push('\n',indent()); + }else if(token==='}'||token===']'){ + depth--;if(tokens[i-1]!=='{'&&tokens[i-1]!=='[')out.push('\n',indent());out.push(token); + }else if(token===',')out.push(',\n',indent()); + else out.push(token===':'?': ':token); + } + const result=out.join(''); + if(bytes(result)>LIMIT)throw Error('格式化结果超过 1 MiB,请保留紧凑格式'); + return result; + } + function diff(before,after){ + const changes=[],stack=[{before,after,path:[],hasBefore:true,hasAfter:true}]; + let visited=0; + while(stack.length && changes.length<100 && visited++<20000){ + const entry=stack.pop(),a=entry.before,b=entry.after; + if(!entry.hasBefore){changes.push({...entry,kind:'added'});continue;} + if(!entry.hasAfter){changes.push({...entry,kind:'removed'});continue;} + if(Object.is(a,b))continue; + if(a!==null && b!==null && typeof a==='object' && typeof b==='object' && Array.isArray(a)===Array.isArray(b)){ + if(entry.path.length>=64)return {changes,truncated:true}; + const keys=[...new Set([...Object.keys(a),...Object.keys(b)])]; + // Bound pending work as well as rendered changes on wide responses. + if(keys.length+stack.length>20000)return {changes,truncated:true}; + for(let i=keys.length-1;i>=0;i--){const key=keys[i];stack.push({before:Object.hasOwn(a,key)?a[key]:undefined,after:Object.hasOwn(b,key)?b[key]:undefined,path:entry.path.concat(Array.isArray(a)?Number(key):key),hasBefore:Object.hasOwn(a,key),hasAfter:Object.hasOwn(b,key)});} + }else changes.push({...entry,kind:'changed'}); + } + return {changes,truncated:stack.length>0}; + } + function preview(value,present){ + if(!present)return '(不存在)'; + if(Array.isArray(value))return `Array(${value.length})`; + if(value!==null && typeof value==='object')return `Object(${Object.keys(value).length})`; + if(typeof value==='string' && value.length>160)return stringifyJson(value.slice(0,160))+'…'; + return stringifyJson(value); + } + function remember(){if(state)drafts.set(state.item,{text:$('#responseEditorInput').value,savedText:state.savedText});} + function renderChanges(result){ + const host=$('#responseEditorChanges');host.textContent=''; + $('#responseEditorDiffSummary').textContent=result.truncated?`已显示 ${result.changes.length} 项 · 对比达到上限`:`${result.changes.length} 项变更`; + if(!result.changes.length){ + const empty=document.createElement('div');empty.className='response-diff-empty';empty.textContent=result.truncated?'数据较复杂,仅完成部分对比。':'字段值与原始响应一致。编辑右侧内容后,变更会显示在这里。';host.appendChild(empty);return; + } + const labels={added:'新增',removed:'删除',changed:'修改'}; + for(const change of result.changes){ + const row=document.createElement('div');row.className='response-change '+change.kind; + const kind=document.createElement('span');kind.className='change-kind';kind.textContent=labels[change.kind]; + const path=document.createElement('code');path.className='change-path';path.textContent=pathText(change.path);path.title=path.textContent; + const values=document.createElement('div');values.className='change-values'; + const before=document.createElement('span');before.className='change-before';before.textContent=preview(change.before,change.hasBefore); + const arrow=document.createElement('span');arrow.className='change-arrow';arrow.textContent='→'; + const after=document.createElement('span');after.className='change-after';after.textContent=preview(change.after,change.hasAfter); + values.appendChild(before);values.appendChild(arrow);values.appendChild(after); + row.appendChild(kind);row.appendChild(path);row.appendChild(values);host.appendChild(row); + } + } + function update(){ + clearTimeout(timer);if(!state)return false; + const input=$('#responseEditorInput'),text=input.value; + remember(); + const size=bytes(text);$('#responseEditorSize').textContent=`${(size/1024).toFixed(1)} KiB · ${text.split('\n').length} 行`; + $('#responseEditorSaveState').textContent=text===state.savedText?(text===state.original?'未修改':'已保存草稿'):'未保存更改'; + try{ + validate(text); + input.setAttribute('aria-invalid','false');$('#responseEditorError').classList.add('hidden');$('#responseEditorError').textContent=''; + $('#responseEditorValidity').textContent='✓ JSON 有效';$('#responseEditorValidity').className='editor-validity valid'; + $('#responseEditorSave').disabled=false;$('#responseEditorCopy').disabled=false; + renderMock();return true; + }catch(error){ + input.setAttribute('aria-invalid','true');$('#responseEditorError').textContent='JSON 无效:'+error.message;$('#responseEditorError').classList.remove('hidden'); + $('#responseEditorValidity').textContent='! JSON 无效';$('#responseEditorValidity').className='editor-validity invalid'; + $('#responseEditorSave').disabled=true;$('#responseEditorCopy').disabled=true; + renderMock();return false; + } + } + function reset(restoreFocus=false){ + ++generation;clearTimeout(timer);remember();state=null; + $('#responseEditor').classList.add('hidden'); + $('#responseEditorInput').value='';$('#responseEditorOriginal').textContent='';$('#responseEditorChanges').textContent=''; + $('#responseEditorError').textContent='';$('#responseEditorError').classList.add('hidden'); + $('.app').inert=false; + if(restoreFocus && selected)$('#editResponseBtn').focus(); + } + async function open(){ + const item=selected;if(!item?.valid)return; + const current=++generation; + if(item.meta.bytes>LIMIT)throw Error('响应超过 1 MiB,当前编辑器仅支持较小的 JSON 响应'); + const raw=await callWorker('rawFull',{rid:item.rid}); + if(current!==generation || selected!==item)return; + const originalValue=validate(raw.text); + let original=raw.text;try{original=format(raw.text);}catch{} + const draft=drafts.get(item); + state={item,original,originalValue,savedText:draft?.savedText??original}; + closeModal();$('.app').inert=true; + $('#responseEditorOriginal').textContent=original; + $('#responseEditorInput').value=draft?.text??original; + $('#responseEditorMethod').textContent=item.method;$('#responseEditorMethod').className='method '+item.method.toLowerCase(); + const mock=typeof ResponseMock==='undefined'?null:ResponseMock.get(item).rule; + $('#responseMockMode').value=mock?.once===true?'once':'always';$('#responseMockStatus').value=String(mock?.status??(item.status>=200 && item.status<=599 && ![204,205,304].includes(item.status)?item.status:200)); + $('#responseEditorUrl').textContent=item.url;$('#responseEditorUrl').title=item.url; + $('#responseEditor').classList.remove('hidden');update();$('#responseEditorInput').focus(); + } + function save(){ + if(!update())return false; + state.savedText=$('#responseEditorInput').value;remember();update();toast('草稿已保存;点击“应用到网页”后生效');return true; + } + function renderAppliedChanges(rule){ + // Compare only the response body confirmed as consumed by the page, never a draft. + if(!rule || !rule.hits){ + state.comparedBody=undefined; + $('#responseEditorChanges').textContent=''; + $('#responseEditorDiffSummary').textContent='网页使用假数据后自动对比'; + return; + } + if(state.comparedBody===rule.body)return; + renderChanges(diff(state.originalValue,validate(rule.body))); + state.comparedBody=rule.body; + } + function renderMock(){ + if(!state)return; + const manager=typeof ResponseMock==='undefined'?null:ResponseMock.get(state.item),rule=manager?.rule; + renderAppliedChanges(rule); + const stale=rule && (rule.body!==$('#responseEditorInput').value || rule.once!==($('#responseMockMode').value==='once') || rule.status!==Number($('#responseMockStatus').value)); + const valid=$('#responseEditorInput').getAttribute('aria-invalid')!=='true' && !$('#responseEditorSave').disabled; + $('#responseMockEnable').disabled=!valid || !manager || manager.busy; + $('#responseMockDisable').disabled=!manager?.active || !rule?.enabled || manager.busy; + $('#responseMockReload').disabled=!valid || !manager || manager.busy; + $('#responseMockEnable').textContent='应用到网页'; + $('#responseMockState').textContent=!manager?'网页替换请在 Chrome DevTools 中使用':manager.error?manager.error:manager.reloading?'正在刷新网页…':manager.active && rule?.enabled?`替换中 · 已命中 ${rule.hits} 次${stale?' · 修改尚未应用':' · 回到网页重新查询'}`:'应用后,回到网页重新查询即可看到修改'; + } + $('#responseMockMode').onchange=renderMock;$('#responseMockStatus').oninput=renderMock; + async function applyToPage(){ + if(!update() || typeof ResponseMock==='undefined')return; + const item=state.item,body=$('#responseEditorInput').value,options={once:$('#responseMockMode').value==='once',status:Number($('#responseMockStatus').value)}; + await ResponseMock.enable(item,body,options); + if(state?.item===item){state.savedText=body;remember();update();} + return true; + } + $('#responseMockEnable').onclick=safe(applyToPage); + $('#responseMockDisable').onclick=safe(()=>state && typeof ResponseMock!=='undefined' && ResponseMock.disable(state.item)); + $('#responseMockReload').onclick=safe(async()=>{if(await applyToPage())await ResponseMock.reload();}); + if(typeof ResponseMock!=='undefined')ResponseMock.subscribe(renderMock); + $('#editResponseBtn').onclick=safe(open); + $('#responseEditorClose').onclick=()=>reset(true); + $('#responseEditorValidate').onclick=update;$('#responseEditorCompare').onclick=update; + $('#responseEditorSave').onclick=save; + $('#responseEditorCopy').onclick=safe(()=>{if(update())return copyText($('#responseEditorInput').value);}); + $('#responseEditorFormat').onclick=safe(()=>{if(!state)return;$('#responseEditorInput').value=format($('#responseEditorInput').value);update();}); + $('#responseEditorRestore').onclick=()=>{if(!state)return;$('#responseEditorInput').value=state.original;update();}; + $('#responseEditorInput').oninput=()=>{ + if(!state)return;remember();$('#responseEditorSaveState').textContent='未保存更改'; + $('#responseEditorValidity').textContent='正在校验…';$('#responseEditorSave').disabled=true;$('#responseEditorCopy').disabled=true; + $('#responseMockEnable').disabled=true; + // Keep the last applied comparison while editing the next draft. + clearTimeout(timer);timer=setTimeout(update,220); + }; + document.addEventListener('keydown',e=>{ + if(!state)return; + if(e.key==='Escape'){e.preventDefault();e.stopPropagation();reset(true);return;} + if((e.ctrlKey||e.metaKey) && e.key.toLowerCase()==='s'){e.preventDefault();save();return;} + if(e.key==='Tab'){ + const focusable=[...$('#responseEditor').querySelectorAll('button:not(:disabled):not([hidden]), textarea, select, input, summary, [tabindex="0"]')].filter(el=>!el.closest('details:not([open])') || el===el.closest('details').querySelector('summary')); + const first=focusable[0],last=focusable.at(-1); + if(e.shiftKey && document.activeElement===first){e.preventDefault();last.focus();} + else if(!e.shiftKey && document.activeElement===last){e.preventDefault();first.focus();} + } + },true); + return {open,reset,validate,format,diff,update,save}; +})(); diff --git a/response-mock.js b/response-mock.js new file mode 100644 index 0000000..a4acb38 --- /dev/null +++ b/response-mock.js @@ -0,0 +1,111 @@ +// Panel-side rule manager. The inspected page receives fixed code plus JSON data only. +const ResponseMock=(()=>{ + const owner=crypto.randomUUID(),rules=new Map(),listeners=new Set(); + let active=false,origin='',busy=false,error='',queue=Promise.resolve(),reloading=false,reloadDeadline=0,reloadInstance='',navigation=0; + const idFor=item=>String(item.method).toUpperCase()+' '+normalize(item.url); + function normalize(url){const parsed=new URL(url);if(!/^https?:$/.test(parsed.protocol))throw Error('仅支持 HTTP / HTTPS 接口');parsed.hash='';return parsed.href;} + function emit(){for(const fn of listeners)fn();renderBar();} + function evaluate(config){ + return new Promise((resolve,reject)=>{ + const epoch=navigation; + const inspected=chrome.devtools.inspectedWindow; + if(!inspected?.eval){reject(Error('此环境不支持网页响应替换,请在 Chrome DevTools 中使用'));return;} + const timer=setTimeout(()=>reject(Error('网页连接超时,请重新打开面板后重试')),8000); + // JSON.stringify protects user text from being treated as executable code. + const expression='('+installPageResponseMocks.toString()+')('+JSON.stringify({...config,owner})+')'; + try{inspected.eval(expression,(result,exception)=>{ + clearTimeout(timer); + if(epoch!==navigation){reject(Error('网页已导航,请重新启用替换'));return;} + if(chrome.runtime.lastError){reject(Error(chrome.runtime.lastError.message));return;} + if(exception?.isException || exception?.isError){reject(Error(String(exception.value||exception.description||'无法连接网页')));return;} + if(!result || typeof result.active!=='boolean' || typeof result.origin!=='string'){reject(Error('网页返回了无效的替换状态'));return;} + resolve(result); + });}catch(cause){clearTimeout(timer);reject(cause);} + }); + } + function sync(result){ + active=result.active;origin=result.origin; + for(const entry of result.rules||[]){ + const rule=rules.get(entry.id);if(!rule)continue; + rule.enabled=entry.enabled===true; + if(Number.isSafeInteger(entry.hits) && entry.hits>=0)rule.hits=entry.hits; + rule.lastKind=entry.lastKind==='XHR'?'XHR':entry.lastKind==='Fetch'?'Fetch':''; + } + if(!active)for(const rule of rules.values())rule.enabled=false; + } + function serialize(){return [...rules.values()].filter(rule=>rule.enabled).map(({id,method,url,body,status,once})=>({id,method,url,body,status,once}));} + function transact(operation){ + const result=queue.then(async()=>{busy=true;error='';emit();try{return await operation();}catch(cause){error=cause.message||'响应替换失败';throw cause;}finally{busy=false;emit();}}); + queue=result.catch(()=>{});return result; + } + async function enable(item,body,options={}){ + // Validate before touching the currently active page rules. + ResponseEditor.validate(body); + const id=idFor(item),method=String(item.method).toUpperCase(); + if(!/^(GET|POST|PUT|PATCH|DELETE)$/.test(method))throw Error('当前方法暂不支持响应替换'); + const status=Number(options.status??item.status??200); + if(!Number.isInteger(status) || status<200 || status>599 || [204,205,304].includes(status))throw Error('状态码须为支持 JSON 正文的 200–599 整数(不含 204、205、304)'); + return transact(async()=>{ + if(active)sync(await evaluate({action:'status'})); + const old=rules.get(id); + rules.set(id,{id,method,url:normalize(item.url),body,status,once:options.once!==false,enabled:true,hits:old?.hits||0,lastKind:old?.lastKind||''}); + try{sync(await evaluate({action:'set',origin:active?origin:undefined,rules:serialize()}));} + catch(cause){old?rules.set(id,old):rules.delete(id);throw cause;} + while(rules.size>20){const stale=[...rules.values()].find(rule=>!rule.enabled && rule.id!==id);if(!stale)break;rules.delete(stale.id);} + toast('替换已启用,请在网页重新触发请求');return get(item); + }); + } + function disable(item){return transact(async()=>{ + if(active)sync(await evaluate({action:'status'})); + const rule=rules.get(idFor(item));if(!rule)return; + const wasEnabled=rule.enabled;rule.enabled=false; + try{sync(await evaluate(serialize().length?{action:'set',origin,rules:serialize()}:{action:'stop'}));} + catch(cause){rule.enabled=wasEnabled;throw cause;} + toast('该接口替换已停用'); + });} + function stopAll(){return transact(async()=>{ + sync(await evaluate({action:'stop'}));reloading=false;toast('所有响应替换已停用'); + });} + function reload(){return transact(async()=>{ + // Check the page origin before sending a pre-load injection to a reload. + const status=await evaluate({action:'status'}); + if(status.origin!==origin)throw Error('网页来源已改变,请重新启用替换'); + sync(status);const current=serialize();if(!current.length)throw Error('请先启用至少一条替换规则'); + const script='('+installPageResponseMocks.toString()+')('+JSON.stringify({action:'set',owner,origin,rules:current})+')'; + reloading=true;reloadDeadline=Date.now()+15000;reloadInstance=status.instance; + try{chrome.devtools.inspectedWindow.reload({ignoreCache:true,injectedScript:script});} + catch(cause){reloading=false;throw cause;} + active=false;toast('正在刷新网页并应用替换'); + });} + async function refresh(){ + if(busy || (!active && !reloading))return; + return transact(async()=>{ + const result=await evaluate({action:'status'}); + if(reloading && (!result.active || result.instance===reloadInstance) && Date.now()rule.enabled).length; + const hits=[...rules.values()].reduce((sum,rule)=>sum+rule.hits,0); + $('#responseMockBar').classList.toggle('hidden',!enabled && !hits && !error && !reloading); + $('#responseMockSummary').textContent=error?'替换异常:'+error:reloading?'正在刷新网页并应用替换…':`${active && enabled?'响应替换已启用':'响应替换未启用'} · ${enabled} 条规则 · 命中 ${hits} 次`; + $('#responseMockStopAll').disabled=busy || !enabled; + $('#responseMockRefresh').disabled=busy; + } + $('#responseMockStopAll').onclick=safe(stopAll);$('#responseMockRefresh').onclick=safe(refresh); + chrome.devtools.network.onNavigated.addListener(()=>{ + if(reloading)return; + navigation++; + active=false;origin='';for(const rule of rules.values())rule.enabled=false; + error='';emit();void evaluate({action:'stop'}).catch(()=>{}); + }); + const heartbeat=setInterval(()=>{void refresh().catch(()=>{});},5000); + window.addEventListener('pagehide',()=>{clearInterval(heartbeat);void evaluate({action:'stop'}).catch(()=>{});},{once:true}); + return {enable,disable,stopAll,reload,refresh,get,subscribe(fn){listeners.add(fn);}}; +})(); diff --git a/scripts/package.py b/scripts/package.py index 1588097..839748a 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -9,7 +9,9 @@ RUNTIME = ( "manifest.json", "devtools.html", "devtools.js", "panel.html", "panel.js", "style.css", "json-codec.js", "worker.js", "viewer.js", "viewer-expanded.js", - "developer.js", "table-ui.js", "layout.js", "shortcuts.js", + "developer.js", "table-ui.js", "layout.js", "shortcuts.js", "response-editor.js", + "mock-page.js", "response-mock.js", + "field-patch-core.js", "field-mock.js", "field-mock-page.js", "field-mock-bridge.js", "field-mock-background.js", ) DOCS = ("README.md", "CHANGELOG.md", "CHECK_REPORT.md", "CONTRIBUTING.md") diff --git a/shortcuts.js b/shortcuts.js index b1debe4..ba260b5 100644 --- a/shortcuts.js +++ b/shortcuts.js @@ -1,5 +1,6 @@ // Shortcuts apply while focus is inside the extension panel. function activeSearchScope(){ + if(typeof ResponseEditor!=='undefined' && !$('#responseEditor').classList.contains('hidden'))return null; if(!$('#modal').classList.contains('hidden'))return viewerState?'viewer':null; const active=document.activeElement; if(active===$('#searchInput') || active?.closest?.('#requestSidebar'))return 'requests'; diff --git a/style.css b/style.css index 5d1b76e..3934280 100644 --- a/style.css +++ b/style.css @@ -199,3 +199,45 @@ select{background:#242426;color:#ddd;border:1px solid #444;border-radius:4px;pad #modalContent>.viewer-content-actions{display:flex;flex-direction:row;align-items:center;gap:8px;margin:0 0 8px;padding:6px 0;background:var(--bg);position:sticky;top:0;z-index:1;white-space:normal} .viewer-content-actions>.mini-btn{padding:5px 10px;font-size:12px;border-color:#79a8d8;color:#edf6ff;background:#304e70} .viewer-content-actions>.mini-btn:hover{background:#3b628b} + +/* Response editing is a separate workspace; captured data stays read-only. */ +.edit-response-entry{border-color:#639fe1;background:#294e76;color:#eef7ff} +.response-editor{position:fixed;inset:0;z-index:25;background:#080e18bd;backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;padding:22px} +.response-editor-card{display:flex;flex-direction:column;width:min(1280px,100%);height:min(900px,95vh);min-height:0;border:1px solid #425976;border-radius:12px;background:#172232;box-shadow:0 24px 100px #0009;overflow:hidden} +.response-editor-head{display:flex;align-items:center;justify-content:space-between;padding:19px 24px 15px;background:#1e2c40;border-bottom:1px solid #34465f;flex:none} +.response-editor-head h2{font-size:21px;line-height:1.3;letter-spacing:.3px;font-weight:600;margin:8px 0 0;color:#f1f6ff} +.response-editor-eyebrow{font-size:10px;letter-spacing:1.8px;color:#9bb3d0;display:flex;align-items:center;gap:13px} +.response-editor-eyebrow span{letter-spacing:0;font-size:11px;padding:2px 8px;color:#91d6c7;background:#28463f;border:1px solid #3a665c;border-radius:4px} +.response-editor-head .icon-btn{width:32px;height:32px;border-radius:6px;color:#b4c7df}.response-editor-head .icon-btn:hover{background:#34475e} +.response-editor-request{display:flex;align-items:center;gap:10px;padding:11px 24px;background:#1b293a;border-bottom:1px solid #34465f;min-width:0;flex:none} +#responseEditorUrl{font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#b7cae1} +.draft-state{margin-left:auto;white-space:nowrap;color:#c0ad7c;font-size:11px;padding-left:12px} +.response-editor-toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;padding:12px 24px;flex:none;border-bottom:1px solid #34465f} +.response-editor-tools{display:flex;gap:7px;flex-wrap:wrap} +.response-editor .btn{font-size:12px;padding:6px 12px;border-radius:5px}.response-editor .btn:disabled{opacity:.4;cursor:not-allowed} +.editor-validity{color:#a5b4c8;font-size:11px;white-space:nowrap}.editor-validity.valid{color:#81d5b3}.editor-validity.invalid{color:#ff9696} +.response-editor-panes{display:grid;grid-template-columns:1fr 1fr;min-height:170px;flex:1;overflow:hidden;background:#121c29} +.response-editor-pane{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}.response-editor-pane+.response-editor-pane{border-left:1px solid #34465f} +.response-pane-heading{flex:none;display:flex;align-items:center;justify-content:space-between;padding:10px 18px;border-bottom:1px solid #2e3e55;background:#1b2738;color:#c5d6eb;font-weight:500} +.response-pane-heading>span:last-child:not(:first-child){color:#849ab4;font-size:10px;font-weight:400}.response-pane-heading label{color:#b0d2ff} +.response-original,.response-editor-input{margin:0;padding:16px 20px;flex:1;min-height:0;min-width:0;width:100%;overflow:auto;tab-size:2;white-space:pre;color:#9db1ca;background:#121c29;border:0;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,"Microsoft YaHei",monospace;overscroll-behavior:contain} +.response-editor-input{resize:none;outline:none;color:#dceaff;background:#1b2b3a;border-radius:0;caret-color:#91bcff}.response-editor-input:focus{box-shadow:inset 0 0 0 1px #568ac2}.response-editor-input[aria-invalid="true"]{box-shadow:inset 0 0 0 1px #e47979} +.response-mock-bar{display:flex;align-items:center;gap:8px;flex:none;padding:7px 12px;background:#243e3e;border-bottom:1px solid #49665e;color:#a7dfc8;flex-wrap:wrap}.response-mock-bar>span{flex:1;min-width:150px}.response-mock-bar .btn{font-size:11px;padding:3px 8px} +.response-mock-controls{padding:12px 24px;background:#1b303b;border-bottom:1px solid #3b5361;flex:none}.response-mock-options{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.response-mock-options label{color:#a9bfd5;font-size:11px}.response-mock-options select,.response-mock-options input{font:inherit;background:#132331;border:1px solid #476378;border-radius:4px;color:#dceaff;padding:5px 7px;min-height:29px}.response-mock-options input{width:68px}.response-mock-state{color:#a2c6c4;font-size:11px;margin-top:9px;overflow-wrap:anywhere}.response-editor-card>.response-mock-controls{max-height:150px;overflow:auto} +.response-editor-error{padding:9px 24px;color:#ffb0b0;background:#502b36;max-height:70px;overflow:auto;flex:none;border-top:1px solid #7b4351;overflow-wrap:anywhere} +.response-diff{flex:none;max-height:230px;min-height:104px;display:flex;flex-direction:column;border-top:1px solid #34465f;background:#182537} +.response-diff-heading{display:flex;align-items:center;gap:12px;padding:10px 20px;flex:none;border-bottom:1px solid #2c3d54}.response-diff-heading h3{font-size:12px;margin:0;font-weight:500}.response-diff-heading>span{font-size:11px;color:#9eb3cd}.response-diff-heading>.btn{margin-left:auto;padding:3px 8px;font-size:11px} +.response-diff-list{overflow:auto;min-height:0;padding:0 20px;overscroll-behavior:contain}.response-diff-empty{padding:16px 0;color:#829ab6;font-size:12px} +.response-change{display:grid;grid-template-columns:40px minmax(100px,.75fr) minmax(150px,1.25fr);gap:12px;align-items:center;min-height:35px;padding:7px 0;border-bottom:1px solid #2a3c52} +.change-kind{border-radius:4px;padding:1px 5px;background:#3e3b26;color:#e6ca84;text-align:center;font-size:10px}.added .change-kind{background:#25463f;color:#8dd4ba}.removed .change-kind{background:#4c2e3a;color:#f0a1ac} +.change-path{font:11px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;color:#a8c9f4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.change-values{display:flex;align-items:center;gap:10px;min-width:0;font:11px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace}.change-before,.change-after{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46%}.change-before{color:#d6a0a8}.change-after{color:#8fd5bc}.change-arrow{color:#6e87a5} +.response-editor-footer{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:14px 24px;border-top:1px solid #34465f;background:#1e2c40;flex:none}.response-editor-footer p{margin:0;color:#95abc5;font-size:11px}.response-editor-footer>div{display:flex;gap:8px;flex:none}.editor-notice-dot{display:inline-block;width:5px;height:5px;border-radius:50%;background:#7e9abd;margin-right:7px;vertical-align:middle}.response-editor .editor-primary{background:#4288dc;border-color:#6ca7ec;color:#fff;font-weight:500}.response-editor .editor-primary:hover{background:#5398ed} +.response-editor button:focus-visible,.response-original:focus-visible{outline:2px solid #8bbcff;outline-offset:2px} +@media(max-height:720px){.response-editor{padding:8px}.response-editor-head{padding:10px 18px}.response-editor-head h2{font-size:18px;margin-top:4px}.response-editor-request{padding:7px 18px}.response-mock-controls{padding:8px 18px;max-height:105px}.response-editor-toolbar{padding:7px 18px}.response-editor-panes{min-height:85px}.response-diff{min-height:72px;max-height:125px}.response-editor-footer{padding:9px 18px}.response-diff-heading{padding:6px 16px}} +@media(max-width:760px){.response-editor{padding:8px}.response-editor-card{height:97vh;border-radius:8px}.response-editor-head{padding:12px 15px}.response-editor-request,.response-editor-toolbar{padding:9px 14px}.response-editor-footer{padding:10px 14px;align-items:flex-start;flex-wrap:wrap}.response-editor-panes{grid-template-columns:1fr}.original-pane{display:none}.response-change{grid-template-columns:35px minmax(80px,.8fr) minmax(100px,1fr);gap:8px}.response-diff{max-height:190px}.response-diff-heading{padding:8px 12px}.response-diff-list{padding:0 12px}.response-original,.response-editor-input{padding:12px 14px}.draft-state{font-size:10px}.response-editor-head h2{font-size:18px}} + +/* Keep the everyday response workflow to apply / restore. */ +.response-mock-options>.response-mock-state{margin:0;flex:1;min-width:180px}.response-mock-advanced{margin-top:10px;color:#a9bfd5;font-size:12px}.response-mock-advanced summary{cursor:pointer;width:fit-content}.response-mock-advanced .response-mock-options{margin-top:10px}.response-mock-advanced p{margin-bottom:0}.response-editor-card>.response-mock-controls{max-height:190px}.response-diff:not([open]){display:block;min-height:0}.response-diff[open]{display:block;overflow:auto}.response-diff-heading{cursor:pointer}.response-diff-heading>span:first-child{font-size:12px;color:#dce7f5}.response-diff-list{max-height:160px}.response-editor [hidden]{display:none!important} + +/* Scalar editing keeps modified fields visible without a full-response diff. */ +[hidden]{display:none!important}.field-mock-bar{flex:none;padding:9px 14px;background:#243e3e;border-bottom:1px solid #49665e;color:#b8e0ce;max-height:210px;overflow:auto}.field-mock-heading{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.field-mock-bar summary{cursor:pointer;margin:7px 0}.field-mock-bar .hint{display:block;margin-top:5px}.field-mock-change{display:flex;gap:8px;align-items:center;padding:5px 0}.field-mock-change>span{flex:1;min-width:0;overflow-wrap:anywhere;white-space:pre-wrap;max-height:64px;overflow:auto}.field-mock-edited{background:#233b4d!important;box-shadow:inset 3px 0 #76bca7}.field-mock-edited .cell-text::after,[data-field-mock].field-mock-edited::after{content:' •';color:#8fddbf}[data-field-mock]{cursor:text}.field-mock-input{font:inherit;color:#e4f3ff;background:#152a3b;border:1px solid #78baa7;border-radius:3px;min-width:70px;max-width:100%;width:100%;padding:3px 5px;box-sizing:border-box}.field-mock-input[aria-invalid=true]{border-color:#f08c8c} diff --git a/tests/field-mock.cjs b/tests/field-mock.cjs new file mode 100644 index 0000000..889bdc1 --- /dev/null +++ b/tests/field-mock.cjs @@ -0,0 +1,114 @@ +'use strict'; +const {test}=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'); +const root=path.resolve(__dirname,'..'),source=name=>fs.readFileSync(path.join(root,name),'utf8'); +const coreContext=vm.createContext({});vm.runInContext(source('field-patch-core.js'),coreContext);const core=vm.runInContext('FieldPatchCore',coreContext); +const patch=(obj,path,value)=>({...core.target(obj,path),value}); +test('scalar patches preserve untouched fields, unsafe integers, scalar types and prototype-named keys',()=>{ + const original=core.parse('{"id":9007199254740993,"amount":12,"name":"old","ok":true,"__proto__":{"x":1}}'); + const changes=[patch(original,['amount'],'99'),patch(original,['name'],'"new"'),patch(original,['ok'],'false'),patch(original,['__proto__','x'],'2')]; + const result=core.apply(original,changes);assert.equal(result.applied,4);assert.equal(result.value.id,9007199254740993n);assert.equal(result.value.__proto__.x,2);assert.equal({}.x,undefined); + assert.throws(()=>core.validate({...changes[0],value:'"wrong type"'}),/类型/); +}); +test('stable row identifiers follow sorting, while missing/duplicate IDs and changed types skip safely',()=>{ + const original=core.parse('{"rows":[{"orderId":1,"amount":12},{"orderId":2,"amount":24}]}'); + const change=patch(original,['rows',0,'amount'],'99'); + const next=core.parse('{"rows":[{"orderId":2,"amount":25},{"orderId":1,"amount":13}]}'); + const result=core.apply(next,[change]);assert.equal(result.value.rows[0].amount,25);assert.equal(result.value.rows[1].amount,99); + for(const raw of ['{"rows":[]}','{"rows":[{"orderId":1,"amount":12},{"orderId":1,"amount":13}]}','{"rows":[{"orderId":1,"amount":"13"}]}'])assert.equal(core.apply(core.parse(raw),[change]).applied,0); + assert.throws(()=>core.target(original,['rows',0,'orderId']),/标识字段/); + assert.throws(()=>core.target({rows:[{value:1}]},['rows',0,'value']),/唯一/); +}); +test('a response over 1 MiB can produce a tiny field patch without a full response draft',()=>{ + const data={padding:'x'.repeat(2*1024*1024),amount:1};const change=patch(data,['amount'],'2'); + assert(JSON.stringify(change).length<200);const result=core.apply(data,[change]);assert.equal(result.value.amount,2);assert.equal(result.value.padding.length,2*1024*1024); +}); +function page(initialRules=[],options={}){ + let rules=initialRules,reads=0,backend=options.backend||'{"data":{"name":"real","amount":12},"id":9007199254740993}'; + const listeners={},timers=new Map();let timer=0; + class XHR extends EventTarget{ + constructor(){super();this._ready=0;this._type='';this._text='';this._headers={};this.status=200;} + open(method,url,async=true){this.method=method;this.url=url;this._ready=1;this._type='';} + send(){reads++;this._text=backend;this._ready=4;for(const event of ['readystatechange','load','loadend'])this.dispatchEvent(new Event(event));} + abort(){this._ready=0;} + setRequestHeader(k,v){this._headers[k]=v;} + get readyState(){return this._ready;} + get responseText(){return this._text;} + get response(){return this._type==='json'?JSON.parse(this._text):this._text;} + get responseType(){return this._type;}set responseType(v){this._type=v;} + getResponseHeader(){return 'application/json';} + getAllResponseHeaders(){return 'content-type: application/json\r\ncontent-length: '+this._text.length+'\r\n';} + } + const context=vm.createContext({URL,Request,Response,Headers,TextEncoder,DOMException,Event,console,location:{origin:'https://example.test',href:'https://example.test/app'},XMLHttpRequest:XHR, + fetch:async()=>{reads++;return new Response(backend,{headers:{'content-type':'application/json','content-length':String(backend.length)},status:201});}, + setTimeout(fn){timers.set(++timer,fn);return timer;},clearTimeout:id=>timers.delete(id), + addEventListener:(event,fn)=>{listeners[event]=fn;},postMessage(){}}); + context.window=context; + vm.runInContext(source('field-patch-core.js'),context);vm.runInContext(source('field-mock-page.js'),context); + function configure(next){rules=next;vm.runInContext('0',context);context.configMessage={source:context,origin:'https://example.test',data:{channel:'atv-fields-page-v1',kind:'config',origin:'https://example.test',rules}}; + // Use the context's global proxy for the event source, as a browser would. + context.deliver=listeners.message;vm.runInContext('configMessage.source=window;deliver(configMessage)',context); + } + if(!options.defer)configure(rules); + return {context,configure,reads:()=>reads,run:code=>vm.runInContext(code,context),setBackend:text=>{backend=text;}}; +} +const sample=()=>[{method:'GET',url:'https://example.test/api',patches:[patch({data:{name:'real',amount:12}},['data','name'],'"mock"')]}]; +test('Fetch makes a real backend request, patches the body, preserves status and integers, and exit restores real responses',async()=>{ + const p=page(sample());const response=await p.context.fetch('https://example.test/api');assert.equal(response.status,201);assert.equal(response.headers.get('content-length'),null); + assert((await response.clone().text()).includes('9007199254740993'));assert.equal((await response.json()).data.name,'mock');assert.equal(p.reads(),1); + p.setBackend('{"data":{"name":"latest real","amount":77}}'); + const latest=await (await p.context.fetch('https://example.test/api')).json();assert.equal(latest.data.name,'mock');assert.equal(latest.data.amount,77); + p.configure([]);assert.equal((await (await p.context.fetch('https://example.test/api')).json()).data.name,'latest real');assert.equal(p.reads(),3); +}); +test('fresh page holds startup Fetch until stored rules arrive; a reload uses identical rules',async()=>{ + const p=page([],{defer:true});let done=false;const pending=p.context.fetch('https://example.test/api').then(r=>{done=true;return r;});await Promise.resolve();assert.equal(p.reads(),0);assert.equal(done,false); + p.configure(sample());assert.equal((await (await pending).json()).data.name,'mock'); + const reload=page(sample());assert.equal((await (await reload.context.fetch('https://example.test/api')).json()).data.name,'mock'); +}); +test('XHR text and JSON responses apply patches, including startup requests; synchronous and unmatched requests pass through',async()=>{ + const p=page([],{defer:true});p.run('globalThis.xhr=new XMLHttpRequest();xhr.open("GET","/api");xhr.responseType="json";xhr.send()');assert.equal(p.reads(),0); + p.configure(sample());await Promise.resolve();assert.equal(p.run('xhr.response.data.name'),'mock');assert.equal(p.run('xhr.responseType'),'json');assert.throws(()=>p.run('xhr.responseText'),/JSON/); + p.run('xhr=new XMLHttpRequest();xhr.open("GET","/api");xhr.send()');assert.equal(JSON.parse(p.run('xhr.responseText')).data.name,'mock');assert(p.run('xhr.responseText').includes('9007199254740993'));assert.equal(p.run('xhr.getResponseHeader("x-api-table-viewer-mock")'),'fields');assert(!p.run('xhr.getAllResponseHeaders()').includes('content-length')); + p.run('xhr=new XMLHttpRequest();xhr.open("GET","/api",false);xhr.send()');assert.equal(JSON.parse(p.run('xhr.responseText')).data.name,'real'); + p.run('xhr=new XMLHttpRequest();xhr.open("GET","/other");xhr.send()');assert.equal(JSON.parse(p.run('xhr.responseText')).data.name,'real'); +}); +test('exit while Fetch is in flight prevents obsolete patches',async()=>{ + const p=page(sample());const pending=p.context.fetch('https://example.test/api');p.configure([]);assert.equal((await (await pending).json()).data.name,'real'); +}); +function background(store={}){ + let handler,removed;const deliveries=[]; + const context=vm.createContext({URL,console,chrome:{runtime:{onMessage:{addListener:fn=>{handler=fn;}}},tabs:{get:async id=>({url:background.urls[id]||'https://example.test/app'}),sendMessage:async(...args)=>{deliveries.push(args);},onRemoved:{addListener:fn=>{removed=fn;}}},storage:{session:{get:async key=>({[key]:structuredClone(store[key])}),set:async record=>Object.assign(store,structuredClone(record)),remove:async key=>{delete store[key];}}}},importScripts:file=>vm.runInContext(source(file),context)}); + vm.runInContext(source('field-mock-background.js'),context); + return {store,deliveries,removed,send:(message,sender={})=>new Promise(resolve=>handler({channel:'atv-fields',tabId:1,...message},sender,resolve))}; +} +background.urls={1:'https://example.test/app',2:'https://other.test/app'}; +test('background session survives worker restart, isolates origins and tabs, and explicit exit clears rules',async()=>{ + const first=background(),rules=sample();assert.equal((await first.send({action:'set',origin:'https://example.test',rules})).ok,true); + const restarted=background(first.store); + assert.equal((await restarted.send({action:'get'},{tab:{id:1},frameId:0,url:'https://example.test/reloaded'})).rules.length,1); + assert.equal((await restarted.send({action:'get'},{tab:{id:1},frameId:0,url:'https://other.test/'})).rules.length,0); + assert.equal((await restarted.send({action:'get',tabId:2})).rules.length,0); + assert.equal((await restarted.send({action:'set',rules:[],origin:'https://wrong.test'})).ok,false); + assert.equal((await restarted.send({action:'set',rules,origin:'https://example.test'},{tab:{id:1},frameId:0,url:'https://example.test/'})).ok,false); + assert.equal((await restarted.send({action:'set',rules:[],origin:'https://example.test'})).rules.length,0); +}); +test('background validates patches and serializes concurrent writes to the same tab',async()=>{ + const bg=background();const bad=sample();bad[0].patches[0].value='{"nested":true}';assert.equal((await bg.send({action:'set',origin:'https://example.test',rules:bad})).ok,false); + const results=await Promise.all([bg.send({action:'set',origin:'https://example.test',rules:sample()}),bg.send({action:'set',origin:'https://example.test',rules:[]})]);assert(results.every(r=>r.ok));assert.equal((await bg.send({action:'get'})).rules.length,0); +}); +test('abort during startup XHR initialization does not dispatch a backend request',async()=>{ + const p=page([],{defer:true});p.run('globalThis.xhr=new XMLHttpRequest();xhr.open("GET","/api");xhr.send();xhr.abort()');p.configure(sample());await Promise.resolve();assert.equal(p.reads(),0); +}); +test('Worker returns scalar targets and relocated paths without exporting a large response',()=>{ + let result;const context=vm.createContext({TextEncoder,self:{postMessage:r=>{result=r;}},importScripts:file=>vm.runInContext(source(file),context)});vm.runInContext(source('worker.js'),context); + const send=data=>{context.self.onmessage({data:{id:1,...data}});assert(result.ok,result.error);return result.result;}; + const record=send({cmd:'parse',raw:JSON.stringify({padding:'x'.repeat(2*1024*1024),amount:1})}); + const target=send({cmd:'patchTarget',rid:record.rid,path:['amount']});assert.equal(target.old,'1');assert(JSON.stringify(target).length<200); + assert.equal(send({cmd:'patchLocations',rid:record.rid,patches:[{...target,value:'2'}]}).paths.length,1); +}); + +test('shared field codec retains the same exact-integer implementation as the original Worker codec',()=>{assert(source('field-patch-core.js').includes(source('json-codec.js')));}); + + +test('duplicate startup configuration does not invalidate an in-flight patched Fetch',async()=>{ + const p=page(sample());const pending=p.context.fetch('https://example.test/api');p.configure(sample());assert.equal((await (await pending).json()).data.name,'mock'); +}); diff --git a/tests/manual/index.html b/tests/manual/index.html index c94db40..22b3069 100644 --- a/tests/manual/index.html +++ b/tests/manual/index.html @@ -3,6 +3,7 @@ API Table Viewer 本地验收

API Table Viewer 本地验收

+

响应替换联调:编辑接口后验证网页表格变化 →

先安装仓库根目录扩展,打开 DevTools → API Table,再点击发送。这里仅产生真实 HTTP 请求,不模拟扩展界面。没有自动请求,刷新后可复测捕获顺序。

diff --git a/tests/manual/mock-debug.html b/tests/manual/mock-debug.html new file mode 100644 index 0000000..e5736cd --- /dev/null +++ b/tests/manual/mock-debug.html @@ -0,0 +1,8 @@ + + +响应替换 · 页面联调验收 + +← 本地验收首页

修改接口响应,验证网页展示

此页面使用真实 Fetch / XHR 请求,不内置 Mock。先打开 DevTools → API Table,再发送请求。

+
尚未请求
订单号客户状态金额
点击查询加载订单
+
操作顺序
  1. 点击查询,在插件中选中 /api/mock-orders。
  2. 打开“编辑响应”,修改客户名、状态或金额,点击“启用替换”。
  3. 回到本页,再点击 Fetch 或 XHR 查询,检查表格与下方响应。
  4. 测试初始化请求时,启用规则后点击插件中的“刷新网页并应用”。
  5. 单次规则命中后自动结束;持续规则需手动停用。普通刷新不会自动重新安装规则。
+

页面实际收到的响应

等待请求
diff --git a/tests/manual/mock-debug.js b/tests/manual/mock-debug.js new file mode 100644 index 0000000..a3a7fbc --- /dev/null +++ b/tests/manual/mock-debug.js @@ -0,0 +1,25 @@ +'use strict'; +function showOrders(raw,status,source){ + document.querySelector('#error').textContent=''; + document.querySelector('#source').textContent=`${source} · HTTP ${status}`; + document.querySelector('#response').textContent=raw; + if(status<200 || status>=300)throw Error(`接口返回 HTTP ${status}`); + const result=JSON.parse(raw),rows=result.data?.records; + if(!Array.isArray(rows))throw Error('响应中缺少 data.records 数组'); + const host=document.querySelector('#orders');host.textContent=''; + for(const row of rows){const tr=document.createElement('tr');for(const key of ['orderId','customerName','status','amount']){const td=document.createElement('td');td.textContent=String(row[key]??'—');tr.appendChild(td);}host.appendChild(tr);} + if(!rows.length){const tr=document.createElement('tr'),td=document.createElement('td');td.colSpan=4;td.textContent='暂无订单';tr.appendChild(td);host.appendChild(tr);} +} +async function fetchOrders(){ + try{const response=await fetch('/api/mock-orders');showOrders(await response.text(),response.status,response.headers.get('x-api-table-viewer-mock')?'Fetch · 模拟响应':'Fetch · 真实后端');} + catch(error){document.querySelector('#error').textContent=error.message;} +} +function xhrOrders(){ + const xhr=new XMLHttpRequest();xhr.open('GET','/api/mock-orders'); + xhr.onload=()=>{try{showOrders(xhr.responseText,xhr.status,xhr.getResponseHeader('x-api-table-viewer-mock')?'XHR · 模拟响应':'XHR · 真实后端');}catch(error){document.querySelector('#error').textContent=error.message;}}; + xhr.onerror=()=>{document.querySelector('#error').textContent='XHR 请求失败';};xhr.send();return xhr; +} +document.querySelector('#fetchOrders').addEventListener('click',fetchOrders); +document.querySelector('#xhrOrders').addEventListener('click',xhrOrders); +// Load on startup to verify persistent field patches after ordinary refresh. +void fetchOrders(); diff --git a/tests/manual/server.cjs b/tests/manual/server.cjs index 809aff8..3ec0ad8 100644 --- a/tests/manual/server.cjs +++ b/tests/manual/server.cjs @@ -3,7 +3,7 @@ const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); const {cases, fixture} = require('./fixtures.cjs'); -const assets = {'/': ['index.html', 'text/html'], '/app.js': ['app.js', 'text/javascript']}; +const assets = {'/': ['index.html', 'text/html'], '/app.js': ['app.js', 'text/javascript'], '/mock-debug': ['mock-debug.html', 'text/html'], '/mock-debug.js': ['mock-debug.js', 'text/javascript']}; function createServer() { return http.createServer(async (req, res) => { const url = new URL(req.url, 'http://localhost'); @@ -28,6 +28,7 @@ function createServer() { send(200, fs.readFileSync(path.join(__dirname, file)), type); return; } if (url.pathname === '/api/cases') {send(200, JSON.stringify(cases)); return;} + if (url.pathname === '/api/mock-orders') {send(200, JSON.stringify({code:200,data:{total:2,records:[{orderId:'ORD202610220001',customerName:'林小满',status:'paid',amount:128.5},{orderId:'ORD202610220002',customerName:'陈一诺',status:'pending',amount:299}]}})); return;} if (url.pathname === '/api/plain') {send(200, 'not JSON', 'text/plain'); return;} if (url.pathname === '/api/status') {send(422, '{"error":"fixture validation","code":422}'); return;} const match = /^\/api\/(ordinary|nested|nodes|records|integers|long-text|missing)$/.exec(url.pathname); diff --git a/tests/package-check.cjs b/tests/package-check.cjs index e6b0cf4..ea87ecd 100644 --- a/tests/package-check.cjs +++ b/tests/package-check.cjs @@ -2,6 +2,10 @@ const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),asse const root=path.join(__dirname,'..'); const manifest=JSON.parse(fs.readFileSync(path.join(root,'manifest.json'),'utf8')); assert.equal(manifest.manifest_version,3);assert(fs.existsSync(path.join(root,manifest.devtools_page))); +assert(manifest.permissions.includes('storage')); +assert(fs.existsSync(path.join(root,manifest.background.service_worker))); +for(const entry of manifest.content_scripts){assert.equal(entry.run_at,'document_start');for(const file of entry.js)assert(fs.existsSync(path.join(root,file)),file);} +assert(manifest.content_scripts.some(entry=>entry.world==='MAIN' && entry.js.includes('field-mock-page.js'))); const scripts=[]; for(const file of ['devtools.html','panel.html']){ const html=fs.readFileSync(path.join(root,file),'utf8'); diff --git a/tests/preview/boot.js b/tests/preview/boot.js new file mode 100644 index 0000000..19d29d2 --- /dev/null +++ b/tests/preview/boot.js @@ -0,0 +1,9 @@ +document.addEventListener('DOMContentLoaded',()=>{ + // Wait for real Worker parsing and request capture, not a mock panel renderer. + let attempts=0; + const ready=setInterval(()=>{ + const orders=requests.find(item=>item.url.includes('/api/orders?')); + if(orders){clearInterval(ready);safe(async()=>{await selectRequest(orders);await openTable(['data','records']);})();} + else if(++attempts>100)clearInterval(ready); + },30); +},{once:true}); diff --git a/tests/preview/build-preview.py b/tests/preview/build-preview.py new file mode 100644 index 0000000..c78988d --- /dev/null +++ b/tests/preview/build-preview.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Assemble previews from the actual extension source, without making a ZIP.""" +import argparse +import json +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[2] + + +def inline_script(code): + return '' + + +def build(output, fragment=None): + html = (ROOT / 'panel.html').read_text(encoding='utf-8') + worker = (ROOT / 'worker.js').read_text(encoding='utf-8').replace( + "importScripts('json-codec.js');", (ROOT / 'json-codec.js').read_text(encoding='utf-8')).replace( + "importScripts('field-patch-core.js');", (ROOT / 'field-patch-core.js').read_text(encoding='utf-8')) + html = html.replace('', '') + html = html.replace('API Table Viewer', '响应编辑 · API Table Viewer 交互预览') + html = html.replace('', '' + inline_script((ROOT / 'tests/preview/env.js').read_text(encoding='utf-8'))) + for match in list(re.finditer(r'', html)): + code = (ROOT / match[1]).read_text(encoding='utf-8') + if match[1] == 'panel.js': + code = code.replace('new Worker("worker.js")', 'new Worker(URL.createObjectURL(new Blob([' + json.dumps(worker) + '],{type:"text/javascript"})))') + html = html.replace(match[0], inline_script(code)) + html = html.replace('', inline_script((ROOT / 'tests/preview/boot.js').read_text(encoding='utf-8')) + '') + html = html.replace('API Table Viewer', 'API Table Viewer · 示例数据预览') + output.write_text(html, encoding='utf-8') + print(output) + if fragment: + source = (ROOT / 'panel.html').read_text(encoding='utf-8') + markup = source[source.index('
', markup) + template = template.replace('// CODEC_SCRIPT', (ROOT / 'json-codec.js').read_text(encoding='utf-8')) + template = template.replace('// EDITOR_SCRIPT', (ROOT / 'response-editor.js').read_text(encoding='utf-8')) + template = namespace(template) + overrides = (ROOT / 'tests/preview/inline-style.css').read_text(encoding='utf-8') + template = template.replace('/* EDITOR_STYLES */', styles + '\n' + overrides) + fragment.write_text(template, encoding='utf-8') + print(fragment) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('output', type=Path) + parser.add_argument('--inline', type=Path) + args = parser.parse_args() + build(args.output, args.inline) diff --git a/tests/preview/check.cjs b/tests/preview/check.cjs new file mode 100644 index 0000000..24f31f0 --- /dev/null +++ b/tests/preview/check.cjs @@ -0,0 +1,54 @@ +'use strict'; +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const {execFileSync} = require('node:child_process'); +const {createPreviewServer} = require('./server.cjs'); + +test('preview fixtures provide valid HAR entries and preserve source integer tokens', () => { + const context = vm.createContext({window: {}, TextEncoder}); + vm.runInContext(fs.readFileSync(path.join(__dirname, 'env.js'), 'utf8'), context); + let entries; + context.window.chrome.devtools.network.getHAR(har => {entries = har.entries;}); + assert.equal(entries.length, 3); + let raw; + entries[0].getContent(text => {raw = text;}); + assert(raw.includes('9007199254740993')); + for (const entry of entries) entry.getContent(text => assert.doesNotThrow(() => JSON.parse(text))); + new vm.Script(fs.readFileSync(path.join(__dirname, 'boot.js'), 'utf8')); +}); + +test('preview server serves panel/runtime assets and rejects unrelated files', async () => { + const server = createPreviewServer(); + await new Promise((resolve, reject) => {server.once('error', reject); server.listen(0, '127.0.0.1', resolve);}); + try { + const origin = 'http://127.0.0.1:' + server.address().port; + const response = await fetch(origin + '/'); + assert.equal(response.status, 200); + const html = await response.text(); + assert(html.includes('/preview/env.js')); assert(html.includes('id="responseEditor"')); + for (const route of ['/preview/env.js', '/preview/boot.js', '/worker.js', '/response-editor.js', '/style.css']) assert.equal((await fetch(origin + route)).status, 200); + assert.equal((await fetch(origin + '/package.json')).status, 404); + assert.equal((await fetch(origin + '/', {method: 'POST'})).status, 405); + } finally {await new Promise(resolve => server.close(resolve));} +}); + +test('generated full and inline previews contain compilable scripts and complete markup', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'api-preview-')); + try { + const output = path.join(directory, 'preview.html'), inline = path.join(directory, 'inline.html'); + execFileSync('python3', [path.join(__dirname, 'build-preview.py'), output, '--inline', inline]); + for (const file of [output, inline]) { + const html = fs.readFileSync(file, 'utf8'); + const scripts = [...html.matchAll(/ diff --git a/tests/preview/server.cjs b/tests/preview/server.cjs new file mode 100644 index 0000000..4b4da14 --- /dev/null +++ b/tests/preview/server.cjs @@ -0,0 +1,36 @@ +'use strict'; +// Browser preview of the real panel. Only the DevTools host is simulated. +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = path.resolve(__dirname, '../..'); +const runtime = new Set(['json-codec.js', 'panel.js', 'table-ui.js', 'viewer.js', 'viewer-expanded.js', 'developer.js', 'layout.js', 'shortcuts.js', 'response-editor.js', 'mock-page.js', 'response-mock.js', 'worker.js', 'style.css', 'field-patch-core.js', 'field-mock.js']); +function createPreviewServer() { + return http.createServer((req, res) => { + const pathname = new URL(req.url, 'http://localhost').pathname; + let content, type; + if (req.method !== 'GET') {res.writeHead(405); res.end(); return;} + if (pathname === '/' || pathname === '/panel.html') { + content = fs.readFileSync(path.join(root, 'panel.html'), 'utf8') + .replace('API Table Viewer', '响应编辑 · API Table Viewer 交互预览') + .replace('', '') + .replace('API Table Viewer', 'API Table Viewer交互预览 · 示例数据') + .replace('', ''); + type = 'text/html'; + } else if (pathname === '/preview/env.js' || pathname === '/preview/boot.js') { + content = fs.readFileSync(path.join(__dirname, path.basename(pathname))); type = 'text/javascript'; + } else if (runtime.has(pathname.slice(1))) { + content = fs.readFileSync(path.join(root, pathname.slice(1))); type = pathname.endsWith('.css') ? 'text/css' : 'text/javascript'; + } else {res.writeHead(404); res.end('Not found'); return;} + res.writeHead(200, {'Content-Type': type + '; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}); + res.end(content); + }); +} +if (require.main === module) { + const port = Number(process.env.PORT || 8766); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error('Invalid PORT'); + const server = createPreviewServer(); + server.on('error', error => {console.error(error.message); process.exitCode = 1;}); + server.listen(port, '0.0.0.0', () => console.log(`Response editor preview: http://localhost:${port}`)); +} +module.exports = {createPreviewServer}; diff --git a/tests/regression.cjs b/tests/regression.cjs index 580f173..50f8ae5 100644 --- a/tests/regression.cjs +++ b/tests/regression.cjs @@ -55,6 +55,9 @@ vm.runInContext(fs.readFileSync(path.join(base,'table-ui.js'),'utf8'),ctx); vm.runInContext(fs.readFileSync(path.join(base,'viewer.js'),'utf8'),ctx); vm.runInContext(fs.readFileSync(path.join(base,'viewer-expanded.js'),'utf8'),ctx); vm.runInContext(fs.readFileSync(path.join(base,'developer.js'),'utf8'),ctx); +ctx.TextEncoder=TextEncoder; +get('#responseEditor').classList.add('hidden'); +vm.runInContext(fs.readFileSync(path.join(base,'response-editor.js'),'utf8'),ctx); const run=x=>vm.runInContext(x,ctx),req={request:{url:'https://test/api',method:'GET'},response:{status:200},time:1};ctx.fixture=req; await test('panel root table renders header and primitive values',async()=>{await run('addRequest(fixture,"[1,2,3]")');await run('selectRequest(requests[0])');assert(get('#tree').children[0].children[0].children.some(e=>e.textContent.includes('表格')));await run('openTable([])');assert.equal(get('#dataTable thead').children[0].children.length,2);assert.equal(get('#dataTable tbody').children.length,3);}); await test('switch clears old table; stale results cannot overwrite',async()=>{delay=m=>m.cmd==='table'?30:0;const rendering=run('renderTable()');await run('addRequest(fixture,"{\\"new\\":true}")');await run('selectRequest(requests[0])');await rendering;assert.equal(get('#dataTable tbody').children.length,0);assert.equal(run('tableState.path'),null);delay=()=>0;}); @@ -158,5 +161,69 @@ await test('capture ignores HAR entries without content readers',()=>{ctx.unread await test('expanded viewport resizes and releases its observer on close',async()=>{let observer;ctx.ResizeObserver=class{constructor(fn){this.callback=fn;observer=this;}observe(el){this.target=el;}disconnect(){this.disconnected=true;}};ctx.raw=JSON.stringify(Array.from({length:6000},(_,i)=>i));await run('addRequest(fixture,raw)');await run('selectRequest(requests[0])');await run('showValue([],"JSON")');await run('expandWholeViewer([])');const before=run('viewerState.expansion.root.children.length');get('#modalContent').clientHeight=1200;await observer.callback();assert(run('viewerState.expansion.root.children.length')>before);run('closeModal()');assert(observer.disconnected);delete ctx.ResizeObserver;}); await test('navigation respects the preserve-log setting',async()=>{const count=run('requests.length');await navigated();assert.equal(run('requests.length'),count);get('#preserveLog').checked=false;get('#preserveLog').onchange();await navigated();assert.equal(run('requests.length'),0);assert.equal(run('selected'),null);assert.equal(JSON.parse(preferences.get('api-table-viewer.dev.v1')).preserveLog,false);}); await test('clear releases retained records and resets state',async()=>{await get('#clearBtn').onclick();assert.equal(run('requests.length'),0);assert.equal(run('selected'),null);assert.equal(run('pending.size'),0);}); +await test('response editor formats exact numeric tokens and escaped strings without rounding',()=>{ + const raw='{"id":9007199254740993,"amount":128.50,"exponent":1e+03,"zero":-0,"text":"a \\\"quote\\\" and \\n line","empty":[]}';ctx.editorRaw=raw; + const text=run('ResponseEditor.format(editorRaw)'); + assert(text.includes('9007199254740993'));assert(text.includes('128.50'));assert(text.includes('1e+03'));assert(text.includes('-0'));assert(text.includes('"empty": []')); + assert.deepEqual(JSON.parse(text),JSON.parse(raw)); +}); +await test('response editor validates scalars and rejects malformed or oversized JSON',()=>{ + assert.equal(run('ResponseEditor.validate("null")'),null);assert.equal(run('ResponseEditor.validate("9007199254740993")'),9007199254740993n); + assert.throws(()=>run('ResponseEditor.validate("{bad}")')); + assert.throws(()=>run('ResponseEditor.validate(JSON.stringify("中".repeat(400000)))'),/1 MiB/); +}); +await test('response editor diff identifies nested adds, removals and exact integer changes',()=>{ + const result=run('ResponseEditor.diff(ResponseEditor.validate(\'{"items":[{"id":9007199254740993,"old":true}],"same":null}\'),ResponseEditor.validate(\'{"items":[{"id":9007199254740994,"new":false}],"same":null}\'))'); + assert.equal(result.truncated,false); + assert.deepEqual(Array.from(result.changes,c=>({kind:c.kind,path:Array.from(c.path)})),[{kind:'changed',path:['items',0,'id']},{kind:'removed',path:['items',0,'old']},{kind:'added',path:['items',0,'new']}]); +}); +await test('response editor treats prototype-named keys as data and ignores object key ordering',()=>{ + const result=run('ResponseEditor.diff(ResponseEditor.validate(\'{"__proto__":{"safe":1},"constructor":2}\'),ResponseEditor.validate(\'{"constructor":2,"__proto__":{"safe":3}}\'))'); + assert.equal(result.changes.length,1);assert.deepEqual(Array.from(result.changes[0].path),['__proto__','safe']); + assert.equal(run('ResponseEditor.diff({a:1,b:2},{b:2,a:1}).changes.length'),0); +}); +await test('response editor bounds wide/deep diffs and caps displayed changes',()=>{ + assert(run('ResponseEditor.diff({},Object.fromEntries(Array.from({length:20001},(_,i)=>[i,i]))).truncated')); + const result=run('ResponseEditor.diff(Array(200).fill(0),Array(200).fill(1))');assert.equal(result.changes.length,100);assert(result.truncated); + assert.throws(()=>run('ResponseEditor.format("[".repeat(65)+"0"+"]".repeat(65))'),/64/); +}); +await test('response drafts do not mutate captured responses and copy preserves unsafe integers',async()=>{ + ctx.editorRaw='{"id":9007199254740993,"name":"original","amount":128.50}'; + await run('addRequest(fixture,editorRaw)');await run('selectRequest(requests[0]);');await run('ResponseEditor.open()'); + assert(!get('#responseEditor').classList.contains('hidden'));assert(get('.app').inert);assert.equal(run('activeSearchScope()'),null); + get('#responseEditorInput').value='{"id":9007199254740994,"name":"edited"}';assert(run('ResponseEditor.save()')); + assert.equal(get('#responseEditorSaveState').textContent,'已保存草稿'); + assert.equal((await run('callWorker("rawFull",{rid:selected.rid})')).text,ctx.editorRaw); + await get('#responseEditorCopy').onclick();assert.equal(copied,'{"id":9007199254740994,"name":"edited"}'); + run('ResponseEditor.reset(true)');assert(!get('.app').inert);assert(get('#editResponseBtn').focused); + await run('ResponseEditor.open()');assert.equal(get('#responseEditorInput').value,copied); +}); +await test('invalid editor drafts cannot be saved or copied and never show stale diffs',async()=>{ + const previous=copied;get('#responseEditorInput').value='{"id":';get('#responseEditorInput').oninput(); + assert(get('#responseEditorSave').disabled);assert.equal(get('#responseEditorChanges').textContent,''); + assert.equal(run('ResponseEditor.save()'),false);assert.equal(get('#responseEditorInput')['aria-invalid'],'true'); + assert(get('#responseEditorError').textContent.includes('JSON 无效')); + await get('#responseEditorCopy').onclick();assert.equal(copied,previous); + run('ResponseEditor.reset()');await run('ResponseEditor.open()');assert.equal(get('#responseEditorInput').value,'{"id":'); + get('#responseEditorRestore').onclick();assert(!get('#responseEditorSave').disabled);assert.equal(get('#responseEditorDiffSummary').textContent,'网页使用假数据后自动对比'); +}); +await test('response editor isolates drafts when switching captured requests',async()=>{ + ctx.firstEditorItem=run('selected');get('#responseEditorInput').value='{"first":"draft"}';run('ResponseEditor.save()'); + await run('addRequest(fixture,\'{"second":true}\')');await run('selectRequest(requests[0])'); + assert(get('#responseEditor').classList.contains('hidden'));assert(!get('.app').inert); + await run('ResponseEditor.open()');assert(get('#responseEditorInput').value.includes('"second": true'));assert(!get('#responseEditorInput').value.includes('first')); + await run('selectRequest(firstEditorItem)');await run('ResponseEditor.open()');assert.equal(get('#responseEditorInput').value,'{"first":"draft"}'); +}); +await test('response editor ignores stale response loads after a request switch',async()=>{ + run('ResponseEditor.reset()');delay=m=>m.cmd==='rawFull'?30:0; + const opening=run('ResponseEditor.open()');await run('selectRequest(requests[0])');await opening;delay=()=>0; + assert(get('#responseEditor').classList.contains('hidden'));assert(!get('.app').inert); +}); +await test('response editor rejects large original responses and clear closes the editor',async()=>{ + await run('addRequest(fixture,JSON.stringify("x".repeat(1024*1024)))');await run('selectRequest(requests[0])'); + await assert.rejects(()=>run('ResponseEditor.open()'),/1 MiB/);assert(get('#responseEditor').classList.contains('hidden')); + await run('selectRequest(firstEditorItem)');await run('ResponseEditor.open()');await get('#clearBtn').onclick(); + assert(get('#responseEditor').classList.contains('hidden'));assert(!get('.app').inert);assert.equal(run('selected'),null); +}); console.log(`${passed} regression tests passed`); })().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/tests/response-mock.cjs b/tests/response-mock.cjs new file mode 100644 index 0000000..2921072 --- /dev/null +++ b/tests/response-mock.cjs @@ -0,0 +1,225 @@ +'use strict'; +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const root = path.resolve(__dirname, '..'); +const engineSource = fs.readFileSync(path.join(root, 'mock-page.js'), 'utf8'); +class Progress extends Event {constructor(type, options = {}) {super(type); Object.assign(this, options);}} +function page(t, options = {}) { + let calls = 0, clock = Date.now(); + const nativeFetch = async (...args) => {calls++; return options.fetch ? options.fetch(...args) : new Response('{"source":"backend"}', {status:200, headers:{'content-type':'application/json'}});}; + class NativeXHR extends EventTarget { + constructor() {super(); this._state = 0; this._text = ''; this._status = 0; this.responseType = ''; this.timeout = 0; this.withCredentials = false; this.upload = new EventTarget(); this._headers = new Headers();} + dispatchEvent(event) {const result = super.dispatchEvent(event); this['on' + event.type]?.call(this, event); return result;} + open(method, url, async = true) {this._method = String(method).toUpperCase(); this._url = new URL(String(url), options.origin || 'https://example.test').href; this._state = 1; this.dispatchEvent(new Event('readystatechange'));} + send(body) {if (this._state !== 1) throw new DOMException('', 'InvalidStateError'); void nativeFetch(this._url, {method:this._method, body}).then(async response => {this._text = await response.text(); this._status = response.status; this._headers = response.headers; this._state = 4; this.dispatchEvent(new Event('readystatechange')); this.dispatchEvent(new Progress('load')); this.dispatchEvent(new Progress('loadend'));});} + abort() {this._state = 0; this.dispatchEvent(new Progress('abort'));} + setRequestHeader() {} + get readyState() {return this._state;} + get status() {return this._status;} + get statusText() {return 'OK';} + get responseURL() {return this._url;} + get responseText() {if (this.responseType && this.responseType !== 'text') throw new DOMException('', 'InvalidStateError'); return this._text;} + get response() {return this.responseType === 'json' ? JSON.parse(this._text) : this._text;} + get responseXML() {return null;} + getResponseHeader(name) {return this._headers.get(name);} + getAllResponseHeaders() {return '';} + } + const context = vm.createContext({fetch:nativeFetch, XMLHttpRequest:NativeXHR, location:new URL(options.origin || 'https://example.test/page'), Request, Response, Headers, TextEncoder, Blob, URL, Event, ProgressEvent:Progress, EventTarget, DOMException, AbortController, console, Date:class extends Date {static now() {return clock;}}, setTimeout:(fn, ms) => {const timer = setTimeout(fn, ms); if (ms >= 120000) timer.unref(); return timer;}, clearTimeout}); + vm.runInContext('globalThis.top=globalThis;', context); + vm.runInContext(engineSource, context); + const apply = config => {context.config = {owner:'test-owner', ...config}; return vm.runInContext('installPageResponseMocks(config)', context);}; + t.after(() => apply({action:'stop'})); + return {context, apply, calls:() => calls, advance:ms => {clock += ms;}, nativeFetch, NativeXHR}; +} +const rule = (overrides = {}) => ({id:'orders', method:'GET', url:'https://example.test/api/orders?page=1', body:'{"id":9007199254740993,"name":"edited"}', status:200, once:false, ...overrides}); +function xhrRequest(context, url, type = '', configure) { + const xhr = new context.XMLHttpRequest(); xhr.open('GET', url); xhr.responseType = type; + const done = new Promise((resolve, reject) => {xhr.addEventListener('loadend', () => resolve(xhr), {once:true}); xhr.addEventListener('error', reject, {once:true});}); + configure?.(xhr); xhr.send(); return {xhr, done}; +} + +test('Fetch returns edited JSON without backend calls, preserving bytes, URL and clone', async t => { + const p = page(t); p.apply({action:'set', rules:[rule()]}); + const response = await p.context.fetch('/api/orders?page=1'); + assert.equal(response.status, 200); assert.equal(response.url, rule().url); + assert.equal(response.headers.get('x-api-table-viewer-mock'), '1'); + const clone = response.clone(); assert.equal(clone.url, response.url); + assert.equal(await response.text(), rule().body); assert.equal(await clone.text(), rule().body); + assert.equal(p.calls(), 0); assert.equal(p.apply({action:'status'}).rules[0].hits, 1); +}); +test('method and full query must match; unrelated requests use the original fetch', async t => { + const p = page(t); p.apply({action:'set', rules:[rule()]}); + for (const [url, init] of [['/api/orders?page=2', {}], ['/api/orders?page=1', {method:'POST'}], ['/other', {}]]) assert.equal((await (await p.context.fetch(url, init)).json()).source, 'backend'); + assert.equal(p.calls(), 3); assert.equal(p.apply({action:'status'}).rules[0].hits, 0); +}); +test('a one-shot rule intercepts only one concurrent request', async t => { + const p = page(t); p.apply({action:'set', rules:[rule({once:true})]}); + const responses = await Promise.all([p.context.fetch(rule().url), p.context.fetch(rule().url)]); + assert.equal(responses.filter(response => response.headers.has('x-api-table-viewer-mock')).length, 1); + const status = p.apply({action:'status'}); assert.equal(status.rules[0].hits, 1); assert.equal(status.rules[0].enabled, false); assert.equal(p.calls(), 1); +}); +test('Request inputs, status overrides and abort signals keep expected Fetch behavior', async t => { + const p = page(t); p.apply({action:'set', rules:[rule({status:500})]}); + const response = await p.context.fetch(new Request(rule().url)); assert.equal(response.ok, false); assert.equal(response.status, 500); + const before = new AbortController(); before.abort(); + await assert.rejects(p.context.fetch(rule().url, {signal:before.signal}), error => error.name === 'AbortError'); + const after = new AbortController(), pending = p.context.fetch(rule().url, {signal:after.signal}); after.abort(); + await assert.rejects(pending, error => error.name === 'AbortError'); assert.equal(p.calls(), 0); +}); +test('XHR dispatches lifecycle events and returns text, JSON and mock headers', async t => { + const p = page(t); p.apply({action:'set', rules:[rule()]}); const states = [], events = []; + const {xhr, done} = xhrRequest(p.context, rule().url, '', xhr => {xhr.addEventListener('readystatechange', () => states.push(xhr.readyState)); for (const event of ['loadstart','progress','load','loadend']) xhr.addEventListener(event, () => events.push(event));}); + assert(xhr instanceof p.NativeXHR); await done; + assert.deepEqual(states, [2,3,4]); assert.deepEqual(events, ['loadstart','progress','load','loadend']); + assert.equal(xhr.responseText, rule().body); assert.equal(xhr.status, 200); assert.equal(xhr.responseURL, rule().url); + assert.equal(xhr.getResponseHeader('CONTENT-TYPE'), 'application/json; charset=utf-8'); assert.equal(xhr.getResponseHeader('missing'), null); + const json = await xhrRequest(p.context, rule().url, 'json').done; assert.equal(json.response.name, 'edited'); assert.throws(() => json.responseText, error => error.name === 'InvalidStateError'); + assert.equal(p.calls(), 0); assert.equal(p.apply({action:'status'}).rules[0].lastKind, 'XHR'); +}); +test('XHR abort cancels mock completion and prevents load', async t => { + const p = page(t); p.apply({action:'set', rules:[rule()]}); let loaded = false; + const {xhr, done} = xhrRequest(p.context, rule().url, '', xhr => {xhr.onload = () => {loaded = true;};}); + xhr.abort(); await done; await new Promise(resolve => setTimeout(resolve, 5)); + assert.equal(loaded, false); assert.equal(xhr.status, 0); assert.equal(xhr.readyState, 0); assert.equal(xhr.responseText, ''); assert.equal(p.calls(), 0); +}); +test('XHR abort from headers-received stops subsequent mock events', async t => { + const p = page(t); p.apply({action:'set', rules:[rule()]}); let loaded = false; + const {xhr, done} = xhrRequest(p.context, rule().url, '', xhr => {xhr.onreadystatechange = () => {if (xhr.readyState === 2) xhr.abort();}; xhr.onload = () => {loaded = true;};}); + await done; assert.equal(loaded, false); assert.equal(xhr.readyState, 0); assert.equal(p.calls(), 0); +}); +test('XHR supports reuse, blob/arraybuffer and unmatched native pass-through', async t => { + const p = page(t); p.apply({action:'set', rules:[rule()]}); + const blob = await xhrRequest(p.context, rule().url, 'blob').done; assert.equal(await blob.response.text(), rule().body); + const buffer = await xhrRequest(p.context, rule().url, 'arraybuffer').done; assert.equal(new TextDecoder().decode(buffer.response), rule().body); + const xhr = await xhrRequest(p.context, rule().url).done; + xhr.open('GET', '/unmatched'); const done = new Promise(resolve => xhr.addEventListener('loadend', resolve, {once:true})); xhr.send(); await done; + assert.equal(JSON.parse(xhr.responseText).source, 'backend'); assert.equal(p.calls(), 1); +}); +test('unsupported sync XHR and no-cors Fetch fail before backend access', async t => { + const p = page(t); p.apply({action:'set', rules:[rule()]}); + const xhr = new p.context.XMLHttpRequest(); xhr.open('GET', rule().url, false); assert.throws(() => xhr.send(), error => error.name === 'InvalidAccessError'); + await assert.rejects(p.context.fetch(rule().url, {mode:'no-cors'}), /no-cors/); assert.equal(p.calls(), 0); +}); +test('invalid rules fail atomically and never replace active valid rules', async t => { + const p = page(t); p.apply({action:'set', rules:[rule()]}); + for (const bad of [rule({body:'{bad'}), rule({status:204}), rule({method:'HEAD'}), rule({url:'file:///etc/passwd'}), rule({body:JSON.stringify('x'.repeat(1024*1024))})]) assert.throws(() => p.apply({action:'set', rules:[bad]})); + assert.throws(() => p.apply({action:'set', rules:[rule(), rule()]})); + assert.throws(() => p.apply({action:'set', origin:'https://other.test', rules:[rule()]})); + assert.equal((await p.context.fetch(rule().url)).headers.get('x-api-table-viewer-mock'), '1'); assert.equal(p.calls(), 0); +}); +test('updating rule content resets hit evidence without stacking wrappers; unchanged rules retain counters', async t => { + const p = page(t); p.apply({action:'set', rules:[rule()]}); const wrapped = p.context.fetch; + await p.context.fetch(rule().url); p.apply({action:'set', rules:[rule({body:'{"updated":true}'})]}); assert.equal(p.context.fetch, wrapped); + assert.equal(p.apply({action:'status'}).rules[0].hits, 0); + assert.equal((await (await p.context.fetch(rule().url)).json()).updated, true); assert.equal(p.apply({action:'status'}).rules[0].hits, 1); + p.apply({action:'set', rules:[rule({body:'{"updated":true}'})]}); + assert.equal(p.apply({action:'status'}).rules[0].hits, 1); + p.apply({action:'stop'}); assert.equal(p.context.fetch, p.nativeFetch); assert.equal(p.context.XMLHttpRequest, p.NativeXHR); +}); +test('expired panel lease restores originals; stop does not overwrite later page wrappers', async t => { + const p = page(t); p.apply({action:'set', rules:[rule()]}); p.advance(120001); + assert.equal(p.apply({action:'status'}).active, false); assert.equal(p.context.fetch, p.nativeFetch); + p.apply({action:'set', rules:[rule()]}); const custom = async () => new Response('custom'); p.context.fetch = custom; + p.apply({action:'stop'}); assert.equal(p.context.fetch, custom); +}); + +function panel(t, p) { + const nodes=new Map(),navigated=[],lifecycle={};let reloadOptions; + const $=selector=>{if(!nodes.has(selector))nodes.set(selector,{classList:{toggle(){}},textContent:'',disabled:false});return nodes.get(selector);}; + const chrome={runtime:{},devtools:{network:{onNavigated:{addListener:fn=>navigated.push(fn)}},inspectedWindow:{eval(expression,callback){try{callback(vm.runInContext(expression,p.context),undefined);}catch(error){callback(undefined,{isException:true,value:error.message});}},reload(options){reloadOptions=options;for(const fn of navigated)fn(p.context.location.href);}}}}; + const context=vm.createContext({$,chrome,crypto:require('node:crypto').webcrypto,URL,TextEncoder,setTimeout,clearTimeout,setInterval:()=>1,clearInterval(){},window:{addEventListener:(event,fn)=>{lifecycle[event]=fn;}},toast(){},safe:fn=>(...args)=>Promise.resolve(fn(...args)),ResponseEditor:{validate:text=>JSON.parse(text)}}); + vm.runInContext(engineSource,context);vm.runInContext(fs.readFileSync(path.join(root,'response-mock.js'),'utf8'),context); + const run=code=>vm.runInContext(code,context); + t.after(()=>lifecycle.pagehide?.()); + return {run,context,nodes,reload:()=>reloadOptions,navigate:()=>{for(const fn of navigated)fn(p.context.location.href);}}; +} +const item={method:'GET',url:rule().url,status:200}; +test('panel enables and disables rules through serialized DevTools calls, treating JSON as data',async t=>{ + const p=page(t),ui=panel(t,p);ui.context.item=item; + ui.context.body=JSON.stringify({text:'");globalThis.PWNED=true;// '}); + await ui.run('ResponseMock.enable(item,body,{once:false,status:200})'); + assert.equal(ui.run('ResponseMock.get(item).rule.enabled'),true); + assert.equal(await (await p.context.fetch(item.url)).text(),ui.context.body);assert.equal(p.context.PWNED,undefined); + await ui.run('ResponseMock.refresh()');assert.equal(ui.run('ResponseMock.get(item).rule.hits'),1); + await ui.run('ResponseMock.disable(item)');assert.equal(ui.run('ResponseMock.get(item).active'),false);assert.equal(p.context.fetch,p.nativeFetch); +}); +test('panel syncs consumed one-shot rules before enabling another rule',async t=>{ + const p=page(t),ui=panel(t,p);ui.context.item=item; + await ui.run('ResponseMock.enable(item,\'{"once":true}\',{once:true,status:200})');await p.context.fetch(item.url); + ui.context.other={...item,url:'https://example.test/api/other'}; + await ui.run('ResponseMock.enable(other,\'{"other":true}\',{once:false,status:200})'); + const result=await p.context.fetch(item.url);assert.equal((await result.json()).source,'backend'); + assert.equal(ui.run('ResponseMock.get(item).rule.enabled'),false);assert.equal(p.calls(),1); +}); +test('panel reload embeds rules before page scripts and navigation disables stale session state',async t=>{ + const p=page(t),ui=panel(t,p);ui.context.item=item; + await ui.run('ResponseMock.enable(item,\'{"reload":true}\',{once:false,status:200})'); + await ui.run('ResponseMock.reload()');assert(ui.reload().ignoreCache);assert(ui.reload().injectedScript.includes('installPageResponseMocks')); + await ui.run('ResponseMock.refresh()');assert.equal(ui.run('ResponseMock.get(item).reloading'),true); + vm.runInContext('globalThis.__API_TABLE_VIEWER_RESPONSE_MOCK_V1__.stop()',p.context); + vm.runInContext(ui.reload().injectedScript,p.context);await ui.run('ResponseMock.refresh()'); + const received=await (await p.context.fetch(item.url)).json(); + assert.equal(received.reload,true,JSON.stringify({received,state:ui.run('ResponseMock.get(item)'),source:p.context.fetch.toString().slice(0,90)})); + ui.navigate();assert.equal(ui.run('ResponseMock.get(item).rule.enabled'),false);assert.equal(ui.run('ResponseMock.get(item).active'),false); +}); +test('panel does not reload after one-shot consumption and rolls back failed installs',async t=>{ + const p=page(t),ui=panel(t,p);ui.context.item=item; + await ui.run('ResponseMock.enable(item,\'{"once":true}\',{once:true,status:200})');await p.context.fetch(item.url); + await assert.rejects(ui.run('ResponseMock.reload()'),/启用/);assert.equal(ui.reload(),undefined); + ui.context.bad={method:'GET',url:'https://example.test/api/'+ 'x'.repeat(10),status:200}; + const prior=p.context.installPageResponseMocks; p.context.installPageResponseMocks=()=>{throw Error('install failed');}; + // Reject at the DevTools transport, without modifying the previously stored rule. + ui.context.chrome.devtools.inspectedWindow.eval=(_,callback)=>callback(undefined,{isException:true,value:'install failed'}); + await assert.rejects(ui.run('ResponseMock.enable(bad,\'{"bad":false}\',{once:false,status:200})'),/install failed/); + assert.equal(ui.run('ResponseMock.get(bad).rule'),null);p.context.installPageResponseMocks=prior; +}); + +test('real HTTP demo page renders edited Fetch and XHR data without another backend hit', async t => { + const {createServer} = require('./manual/server.cjs'); const server = createServer(); let backend = 0; + server.on('request', req => {if (req.url === '/api/mock-orders') backend++;}); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); t.after(() => new Promise(resolve => server.close(resolve))); + const origin = 'http://127.0.0.1:' + server.address().port; + const p = page(t, {origin, fetch:(url, init) => fetch(new URL(url, origin), init)}); + class Element {constructor() {this.children=[]; this._text='';} set textContent(text) {this._text=String(text); this.children=[];} get textContent() {return this._text+this.children.map(child=>child.textContent).join('');} appendChild(child) {this.children.push(child);} addEventListener() {}} + const elements=new Map();p.context.document={querySelector(selector){if(!elements.has(selector))elements.set(selector,new Element());return elements.get(selector);},createElement:()=>new Element()}; + vm.runInContext(fs.readFileSync(path.join(root, 'tests/manual/mock-debug.js'), 'utf8'), p.context); + await vm.runInContext('fetchOrders()', p.context); const before = backend; + assert(elements.get('#orders').textContent.includes('林小满')); + p.apply({action:'set', rules:[rule({url:origin+'/api/mock-orders', body:'{"code":200,"data":{"records":[{"orderId":"MOCK-01","customerName":"已替换客户","status":"cancelled","amount":-999.99}]}}'})]}); + await vm.runInContext('fetchOrders()', p.context); assert(elements.get('#orders').textContent.includes('已替换客户')); assert(elements.get('#orders').textContent.includes('-999.99')); + const xhr=vm.runInContext('xhrOrders()',p.context);await new Promise(resolve=>xhr.addEventListener('loadend',resolve,{once:true})); + assert(elements.get('#source').textContent.includes('XHR · 模拟响应')); assert.equal(backend,before); +}); + + +test('editor compares only consumed mock bodies once, never the pending draft', async () => { + class Element { + constructor(){this.value='';this.textContent='';this.children=[];this.classList={add(){},remove(){}};} + setAttribute(k,v){this[k]=v;} getAttribute(k){return this[k];} + appendChild(el){this.children.push(el);} focus(){} + } + const elements=new Map(), $=key=>{if(!elements.has(key))elements.set(key,new Element());return elements.get(key);}; + let rule=null,listener,comparisons=0; + const context=vm.createContext({$,TextEncoder,setTimeout,clearTimeout,selected:{valid:true,method:'GET',url:'https://example.test/api',rid:'1',meta:{bytes:10}}, + callWorker:async()=>({text:'{"value":1}'}),closeModal(){},toast(){},copyText(){},pathText:()=>'', + safe:fn=>fn,document:{addEventListener(){},createElement:()=>new Element()}, + ResponseMock:{get:()=>({active:true,busy:false,rule}),subscribe:fn=>{listener=fn;}}}); + vm.runInContext(fs.readFileSync(path.join(root,'json-codec.js'),'utf8'),context); + // Count traversal executions rather than mirroring the diff implementation. + const source=fs.readFileSync(path.join(root,'response-editor.js'),'utf8').replace('function diff(before,after){','function diff(before,after){ countComparison();'); + context.countComparison=()=>{comparisons++;}; + vm.runInContext(source,context); + await vm.runInContext('ResponseEditor.open()',context); + $('#responseEditorInput').value='{"value":2}';vm.runInContext('ResponseEditor.update()',context); + assert.equal(comparisons,0); + rule={body:'{"value":2}',status:200,once:false,enabled:true,hits:0};listener();assert.equal(comparisons,0); + rule.hits=1;listener();assert.equal(comparisons,1);assert.equal($('#responseEditorDiffSummary').textContent,'1 项变更'); + rule.hits=2;listener();assert.equal(comparisons,1); + $('#responseEditorInput').value='{"value":3}';vm.runInContext('ResponseEditor.update()',context);assert.equal(comparisons,1); + rule={...rule,body:'{"value":3}',hits:0};listener();assert.equal(comparisons,1); + assert.equal($('#responseEditorDiffSummary').textContent,'网页使用假数据后自动对比'); + rule.hits=1;listener();assert.equal(comparisons,2); +}); diff --git a/viewer-expanded.js b/viewer-expanded.js index 279c35f..1062ac3 100644 --- a/viewer-expanded.js +++ b/viewer-expanded.js @@ -46,6 +46,7 @@ async function renderExpandedViewport(s,e){ const arrow=document.createElement('span');arrow.className='flat-caret';arrow.textContent=meta.type==='object'||meta.type==='array'?'▾':'·';row.appendChild(arrow); const key=document.createElement('span');key.className='viewer-key';highlightText(key,meta.path.length?String(meta.path.at(-1)):'root',s.query);key.title=pathText(meta.path);row.appendChild(key); const value=document.createElement('span');value.className='viewer-value type-'+meta.type;highlightText(value,meta.label,s.query);value.title=meta.label;row.appendChild(value); + if(typeof FieldMock!=='undefined')FieldMock.bind(value,selected?.rid===s.rid?selected:null,meta.path,meta.type); row.appendChild(viewerButton('进入',()=>navigateViewer(meta.path))); row.appendChild(viewerButton('路径',()=>copyText(pathText(meta.path)))); row.appendChild(viewerButton('复制',async()=>{const r=await callWorker('value',{rid:s.rid,path:meta.path});return copyText(r.text);}));e.root.appendChild(row); diff --git a/viewer.js b/viewer.js index 61aa22a..2eb29ca 100644 --- a/viewer.js +++ b/viewer.js @@ -54,6 +54,7 @@ async function navigateViewer(path,{targetPath=null,forceLazy=false,collapsed=fa } else if(doc.complete){ appendViewerContentActions(host,s,path); const text=document.createElement('pre');text.className='viewer-scalar';highlightText(text,doc.text,s.query);host.appendChild(text); + if(typeof FieldMock!=='undefined')FieldMock.bind(text,selected?.rid===s.rid?selected:null,path,doc.meta.type); const mark=text.querySelector('mark');if(mark)mark.scrollIntoView({block:'nearest',inline:'nearest'}); } else await viewerScalar(path,-1,s,version); } @@ -92,6 +93,7 @@ async function viewerNode(meta,path,parent,auto=false,targetPath=null){ const toggle=viewerButton(expandable?'▶':'·',()=>load(0));toggle.disabled=!expandable;row.appendChild(toggle); const key=document.createElement('span');key.className='viewer-key';highlightText(key,path.length?String(path.at(-1)):'root',s.query);row.appendChild(key); const value=document.createElement('span');value.className='viewer-value type-'+meta.type;highlightText(value,meta.label,s.query);row.appendChild(value); + if(typeof FieldMock!=='undefined')FieldMock.bind(value,selected?.rid===s.rid?selected:null,path,meta.type); row.appendChild(viewerButton('进入',()=>navigateViewer(path))); row.appendChild(viewerButton('路径',()=>copyText(pathText(path)))); row.appendChild(viewerButton('复制',async()=>{const r=await callWorker('value',{rid:s.rid,path});await copyText(r.text);})); diff --git a/worker.js b/worker.js index 877b6d8..085b9cc 100644 --- a/worker.js +++ b/worker.js @@ -1,4 +1,5 @@ importScripts('json-codec.js'); +importScripts('field-patch-core.js'); const records = new Map(); let seq = 0, retainedBytes = 0; const MAX_BYTES = 20 * 1024 * 1024, CACHE_BYTES = 80 * 1024 * 1024, MAX_RECORDS = 150; @@ -207,6 +208,8 @@ self.onmessage=e=>{ result={text:text.slice(page*size,(page+1)*size),page,pages:Math.max(1,Math.ceil(text.length/size)),length:text.length}; } else if(cmd==='valuePreview')result=valuePreview(getAt(rec.obj,e.data.path)); + else if(cmd==='patchTarget')result=FieldPatchCore.target(rec.obj,e.data.path); + else if(cmd==='patchLocations')result={paths:e.data.patches.flatMap((patch,index)=>{try{return [{index,path:FieldPatchCore.resolve(rec.obj,patch)}];}catch{return [];}})}; else if(cmd==='value')result={text:stringifyJson(getAt(rec.obj,e.data.path),null,2)}; else if(cmd==='valueText'){const value=getAt(rec.obj,e.data.path);result={text:typeof value==='string'?value:stringifyJson(value,null,2)};} else if(cmd==='rawFull')result={text:rec.raw};