refactor: extract mind test harness

This commit is contained in:
YBF
2026-09-04 02:37:51 +08:00
parent 59918604f6
commit 66af1ab23b
35 changed files with 332 additions and 679 deletions
+7 -2
View File
@@ -12,12 +12,17 @@ OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY_ID= OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET= OSS_ACCESS_KEY_SECRET=
# Mind authorization upstream. For local `pnpm dev`, point this at the Mind HTTP mock. # Mind authorization upstream. When manually running mind-test-harness, point this at its mock.
MIND_AUTH_BASE_URL=https://mind.example.com MIND_AUTH_BASE_URL=https://mind.example.com
# Bright API 的 CORS 白名单 # Bright API 的 CORS 白名单;本地联调页默认运行在 http://127.0.0.1:8788。
MIND_PAGE_ORIGIN=https://mind.example.com MIND_PAGE_ORIGIN=https://mind.example.com
ONETALK_PLUGIN_ORIGINS=chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf ONETALK_PLUGIN_ORIGINS=chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
MIND_AUTH_TIMEOUT_MS=3000 MIND_AUTH_TIMEOUT_MS=3000
# mind-test-harness 的独立联调页目标。该包只供顺道手工测试,不参与根 dev、质量门禁、build 或生产镜像。
MIND_TEST_HARNESS_HOST=127.0.0.1
MIND_TEST_HARNESS_PORT=8788
MIND_TEST_HARNESS_BRIGHT_BASE_URL=http://127.0.0.1:7878
# 扩展构建时注入的 Bright WebSocket 地址 # 扩展构建时注入的 Bright WebSocket 地址
VITE_BRIGHT_WEBSOCKET_URL=ws://127.0.0.1:7878/ws/plugin VITE_BRIGHT_WEBSOCKET_URL=ws://127.0.0.1:7878/ws/plugin
+4
View File
@@ -161,6 +161,10 @@ channel:
packages: packages:
chrome-extension: chrome-extension:
path: apps/chrome-extension path: apps/chrome-extension
mind-test-harness:
path: apps/mind-test-harness
onetalk-contract:
path: apps/onetalk-contract
server: server:
path: apps/server path: apps/server
default_package: @trade-message-center/chrome-extension default_package: @trade-message-center/chrome-extension
@@ -0,0 +1,62 @@
# Mind 测试支架边界与启动契约
## 1. Scope / Trigger
- Trigger:修改 `apps/mind-test-harness/` 的 Mind 外部依赖模拟、Bright 手工联调页面、启动入口或环境变量。
- Scope:仅开发者手工测试。它可模拟外部 Mind 授权,也可操作/展示 Bright 业务响应;不属于 server、Chrome extension 或生产部署。
## 2. Signatures
```text
pnpm dev:harness
MIND_MOCK_HOST=127.0.0.1
MIND_MOCK_PORT=8787
MIND_TEST_HARNESS_HOST=127.0.0.1
MIND_TEST_HARNESS_PORT=8788
MIND_TEST_HARNESS_BRIGHT_BASE_URL=http://127.0.0.1:7878
```
- `src/entry.ts` 必须启动 Mind 授权模拟监听器和 Bright 联调页监听器。
- `pnpm dev` 不得启动、等待或依赖该包;只有显式 `pnpm dev:harness` 才运行它。
- 联调页使用 `MIND_TEST_HARNESS_BRIGHT_BASE_URL` 请求 Bright HTTP 和 WS;不能再由 `apps/server``/harness` 路由提供。
## 3. Contracts
- 包可消费 `@trade-message-center/onetalk-contract`,但不提供给其它 workspace 包消费。任何 `apps/*/package.json` 都不得声明 `@trade-message-center/mind-test-harness` 为任一 dependency 类字段。
- 包内不得有 `test/` 目录、`*.test.*` 文件、`test` 脚本、`build` 脚本、TypeScript `outDir` 或提交的 `dist/`。根 `pnpm build` 和生产 Dockerfile 均不得 filter/copy 该包。
- 该包是顺道编写的低优先级工具,不得被根 `dev``typecheck``test``build` 调度;实现完成不要求测试。其启动或运行失败只在独立命令中报告,不能停止、等待或改变 server/extension 主流程。
- 浏览器的 Origin 必须与 server 的 `MIND_PAGE_ORIGIN` 精确相等;默认页面地址为 `http://127.0.0.1:8788`。Cookie 快捷入口只写页面所在 loopback host 的开发 Cookie,生产 Cookie 仍由 Mind 登录设置。
- 该包的启动失败、上游 Bright 拒绝、CORS 拒绝和 WS Origin 拒绝必须原样可见,不得加跨域代理、默认成功或静默 fallback。
## 4. Validation & Error Matrix
| 条件 | 结果 |
| --- | --- |
| `MIND_TEST_HARNESS_PORT``MIND_MOCK_PORT` 非 0–65535 整数 | 启动前抛出对应环境变量错误 |
| Bright base URL 不是精确 HTTP/HTTPS origin | 启动前抛出 `Invalid MIND_TEST_HARNESS_BRIGHT_BASE_URL` |
| 监听端口已被占用 | 启动失败并关闭已打开的另一监听器 |
| 非 `GET /` 请求联调页监听器 | 返回 404 |
| `MIND_PAGE_ORIGIN` 与页面 Origin 不相等 | Bright 的 CORS/WS 显式拒绝;不得绕过 |
| 支架启动或运行失败 | 仅向 `pnpm dev:harness` 报告;根主流程继续运行 |
## 5. Good / Base / Bad Cases
- Good:向支架加入可重复的外部依赖 fixture 或 Bright 页面操作,同时保持它只从共享 contract 读取协议版本和 HTTP route 模板。
- Base:需要时运行 `pnpm dev:harness` 进行手工 HTTP/页面 smoke;这不是完成条件,也不是生产发布验证。
- Badserver import 支架以重新开放 `/harness`;其它包声明它为依赖;把支架加入根 dev/质量门禁;为“覆盖率”在包内加入测试文件;或增加 build/dist 来发布它。
## 6. Tests Required
- `mind-test-harness` 本身不创建或保留测试文件,也不声明 `test` 命令。
- 完成支架改动无需新增或执行测试,也不得把它接入根质量门禁。若做了手工启动,结果仅作为诊断报告,不构成主流程阻塞条件。
- 错误必须保留可见的日志/退出信息;不要为了保持支架可用而制造成功 fallback。
## 7. Wrong vs Correct
```ts
// Wrong: 将手工页面重新挂回 server,使 server 依赖测试支架。
installOneTalkHarnessRoute(app);
// Correct: 支架自己监听页面端口,server 只保留业务 HTTP/WS 路由。
const harnessServer = createOneTalkHarnessServer(config);
```
@@ -0,0 +1,23 @@
# Mind 测试支架开发规范
> 本规范定义 `@trade-message-center/mind-test-harness` 的唯一用途:人工联调时模拟外部 Mind 依赖,并提供针对 Bright 业务的本地操作页面。它不是产品运行时模块,也不是可复用的 workspace 库。
## 当前基线
- 包目录为 `apps/mind-test-harness/``src/entry.ts` 在显式执行 `pnpm dev:harness` 时启动 Mind 授权模拟和独立 Bright 联调页。
- 包可以依赖共享协议 `@trade-message-center/onetalk-contract`,但仓库内任何其它包不得依赖它;server 也不得导入它来注册路由或提供页面。
- 包是顺道编写的低优先级手工工具:不声明 `build``test` 脚本,不含测试文件,不产生 `dist/`;根 `pnpm dev``typecheck``test``build`、生产 Docker 镜像和发布流程都不包含它。它的失败只报告,绝不阻断主流程。
## 规范目录
| 文档 | 内容 | 状态 |
| --- | --- | --- |
| [项目级代码架构](../../project/architecture.md) | 所有包共享的职责、依赖方向和源码约定 | 项目级必读 |
| [边界与启动契约](./boundary.md) | 独立性、监听地址、环境变量和验证约束 | 已建立 |
## 开始前检查
1. 先阅读项目级代码架构和 [边界与启动契约](./boundary.md),确认改动只服务人工测试。
2. 搜索 workspace manifests,确认没有包把 `@trade-message-center/mind-test-harness` 列为 dependency、devDependency、peerDependency 或 optionalDependency。
3. 不新增 `test/``*.test.*``build` 脚本、`outDir``dist` 或 package export;完成支架改动不要求测试或将它接入根质量门禁,错误只需如实报告。
4. 修改联调页 Origin 或 Bright 目标地址时,同时核对 server 的 `MIND_PAGE_ORIGIN`,避免以 CORS/WS 绕过掩盖配置错误。
+1 -1
View File
@@ -7,7 +7,7 @@
- 包目录是 `apps/server/`,已有 Fastify 应用工厂、健康路由、Bright 读取/联调路由、WebSocket 注册和 PostgreSQL ORM 连接模块。 - 包目录是 `apps/server/`,已有 Fastify 应用工厂、健康路由、Bright 读取/联调路由、WebSocket 注册和 PostgreSQL ORM 连接模块。
- 运行时依赖为 `fastify@^5.12.1``@fastify/websocket@^11.3.0``drizzle-orm@^0.45.2``postgres@^3.4.9` - 运行时依赖为 `fastify@^5.12.1``@fastify/websocket@^11.3.0``drizzle-orm@^0.45.2``postgres@^3.4.9`
- 根 TypeScript 配置使用严格模式、`ES2022``NodeNext`,服务端代码应复用该配置。 - 根 TypeScript 配置使用严格模式、`ES2022``NodeNext`,服务端代码应复用该配置。
- 当前已建立 Drizzle 迁移配置、OneTalk 事实 schema一次性执行入口本地 Mind HTTP mock生产日志库仍未引入。OneTalk 协议使用共享 contract 包,测试使用 Node.js 内置 `node:test` - 当前已建立 Drizzle 迁移配置、OneTalk 事实 schema一次性执行入口本地 Mind 授权模拟与 Bright 联调页归独立的 `mind-test-harness`,不属于 server。生产日志库仍未引入。OneTalk 协议使用共享 contract 包,测试使用 Node.js 内置 `node:test`
## 规范目录 ## 规范目录
@@ -173,7 +173,7 @@ createApp(config: ServerConfig, dependencies?: AppDependencies): FastifyInstance
### 5. Good / Base / Bad Cases ### 5. Good / Base / Bad Cases
- Good`pnpm dev` 设置开发环境并启动本地 Mind HTTP mock`createApp` 通过固定 HTTP adapter 授权;测试/生产仍按各自 URL/HTTPS 规则运行。 - Good需要时显式运行 `pnpm dev:harness` 启动本地 Mind 授权模拟`createApp` 通过固定 HTTP adapter 授权;`pnpm dev` 与 server/extension 主流程不依赖该低优先级工具,测试/生产仍按各自 URL/HTTPS 规则运行。
- Base:真实 Mind HTTP auth 通过同一个 `createMindAuthorizationReader``AppDependencies.authorization` 接入,环境选择不改变业务授权调用。 - Base:真实 Mind HTTP auth 通过同一个 `createMindAuthorizationReader``AppDependencies.authorization` 接入,环境选择不改变业务授权调用。
- Bad:在 `createApp` 中无条件 allow、把 `NODE_ENV` 缺失当作 development,或在 HTTP header/host 上推断开发模式。 - Bad:在 `createApp` 中无条件 allow、把 `NODE_ENV` 缺失当作 development,或在 HTTP header/host 上推断开发模式。
@@ -2,17 +2,17 @@
## 当前状态 ## 当前状态
服务端包包含一个只用于本地联调的原生 HTML 页面;它不是独立前端应用,也没有前端构建产物: 服务端包包含原生 HTML 联调页,也没有前端构建产物:
```text ```text
apps/ apps/
── server/ ── server/
└── src/ └── src/ # Fastify HTTP/WS 和业务实现
└── http/ └── mind-test-harness/
└── harness.ts # Fastify /harness 路由、HTML、CSS 和浏览器脚本 └── src/ # 独立联调页与 Mind 外部依赖模拟
``` ```
`harness.ts` 将页面作为静态响应返回。页面通过同源 Bright HTTP/WS 读取会话、历史、插件状态和同步事件;不连接数据库、不读取 Mind legacy 消息、不引入 UI 框架或前端依赖。 `mind-test-harness` 自己监听页面端口,并通过显式 Bright base URL 读取会话、历史、插件状态和同步事件;不连接数据库、不读取 Mind legacy 消息、不引入 UI 框架或前端依赖。server 不注册 `/harness` 路由,也不导入该包。
## 边界规则 ## 边界规则
@@ -22,16 +22,17 @@ apps/
## 联调页边界 ## 联调页边界
- 页面脚本只负责输入、请求编排、运行时响应校验、状态展示、历史分页和事实去重;HTTP/WS route、service、repository 保持在 backend 层。 - 页面脚本只负责输入、请求编排、运行时响应校验、状态展示、历史分页和事实去重;HTTP/WS route、service、repository 保持在 server backend 层。
- 页面使用独立 history cursor 恢复;重连时先重新读取历史,再建立 WS,避免把内存中的事件状态当作可靠存储。 - 页面使用独立 history cursor 恢复;重连时先重新读取历史,再建立 WS,避免把内存中的事件状态当作可靠存储。
- 页面展示 raw `conversationId`/`messageId`/sender/login/direction/sentAtMs,文本进入 `innerHTML` 前必须转义。 - 页面展示 raw `conversationId`/`messageId`/sender/login/direction/sentAtMs,文本进入 `innerHTML` 前必须转义。
- 发送按钮在当前未实现发送链路时保持禁用,不得伪造成功或写入客户端 outbox。 - 发送按钮在当前未实现发送链路时保持禁用,不得伪造成功或写入客户端 outbox。
## 命名 ## 命名
包目录为 `server`、包名为 `@trade-message-center/server`;联调页文件命名使用功能名 `harness.ts`。若加入正式 UI,再按其真实入口和功能边界拆分文件。 包目录为 `server`、包名为 `@trade-message-center/server``mind-test-harness` 仅用于开发手工测试,不能被 server 或其它包依赖。若加入正式 UI,再按其真实入口和功能边界拆分文件。
## 参考文件 ## 参考文件
- [`apps/server/package.json`](../../../../apps/server/package.json):当前服务端包的真实内容。 - [`apps/server/package.json`](../../../../apps/server/package.json):当前服务端包的真实内容。
- [`apps/mind-test-harness/package.json`](../../../../apps/mind-test-harness/package.json):独立开发测试工具,不参与构建。
- [`tsconfig.base.json`](../../../../tsconfig.base.json):共享编译配置。 - [`tsconfig.base.json`](../../../../tsconfig.base.json):共享编译配置。
+4 -4
View File
@@ -1,11 +1,11 @@
# 服务端前端开发规范 # 服务端前端开发规范
> `@trade-message-center/server` 是纯服务端包,但已包含一个只用于本地验证的原生 HTML harness。这里不定义正式前端框架或生产 UI。 > `@trade-message-center/server` 是纯服务端包,不包含本地 HTML harness正式前端框架或生产 UI。
## 当前基线 ## 当前基线
- `apps/server/package.json` 只有 Fastify 依赖,没有 React/Vue/Svelte、样式库或独立前端构建工具`src/http/harness.ts` 内联提供本地页面 - `apps/server/package.json` 只有 Fastify 依赖,没有 React/Vue/Svelte、样式库或独立前端构建工具。
- 页面只调用 Bright HTTP/WS,组件、Hook、状态管理和正式 UI 测试约定仍未形成 - 手工 Bright 联调页面归 `apps/mind-test-harness/` 所有;server 只提供其 HTTP/WS 业务边界
- TypeScript 仍必须遵循根 `tsconfig.base.json` 的严格模式和 `NodeNext` 模块解析。 - TypeScript 仍必须遵循根 `tsconfig.base.json` 的严格模式和 `NodeNext` 模块解析。
## 规范目录 ## 规范目录
@@ -29,4 +29,4 @@
## 文档维护 ## 文档维护
当服务端出现正式前端代码后,用真实组件、入口和测试补充本文件;不要把本地 harness 约定扩展成未经任务确认的 UI 框架细节 当服务端出现正式前端代码后,用真实组件、入口和测试补充本文件;不要把 `mind-test-harness` 的手工测试能力重新放回 server
@@ -3,7 +3,7 @@
## 已确认的工具链 ## 已确认的工具链
- 使用 pnpm `11.7.0`、Node.js `>=22.22.2 <23` 和根级严格 TypeScript 配置。 - 使用 pnpm `11.7.0`、Node.js `>=22.22.2 <23` 和根级严格 TypeScript 配置。
- 根级 `dev``build``typecheck``test` 命令只调用实际存在的子包脚本;当前联调页由服务端直接返回,不新增前端构建脚本。 - 根级 `dev``build``typecheck``test` 命令只调用实际存在的子包脚本;手工联调页由 `mind-test-harness` 独立提供,server 不新增前端构建脚本。
- 根级 `format``format:check` 使用 Oxfmtlint-staged 只格式化暂存文件,Husky 在 `pre-commit` 阶段触发该检查。 - 根级 `format``format:check` 使用 Oxfmtlint-staged 只格式化暂存文件,Husky 在 `pre-commit` 阶段触发该检查。
- 当前没有 lint、组件测试、端到端测试或浏览器构建工具,不能把这些能力写成已经启用的检查项。 - 当前没有 lint、组件测试、端到端测试或浏览器构建工具,不能把这些能力写成已经启用的检查项。
@@ -11,10 +11,10 @@
- UI 代码必须通过根 `tsconfig.base.json` 的严格检查,并保持与服务端 API/消息契约的类型边界。 - UI 代码必须通过根 `tsconfig.base.json` 的严格检查,并保持与服务端 API/消息契约的类型边界。
- 用户可见状态至少要考虑加载、空数据、错误和权限失败;实际测试方案确定后补上自动化验证。 - 用户可见状态至少要考虑加载、空数据、错误和权限失败;实际测试方案确定后补上自动化验证。
- 原生联调页必须显式展示加载、空数据、插件 online/offline、授权失败、历史读取失败、WS 断线和同步状态;发送能力未接入时保持禁用。 - 联调页必须显式展示加载、空数据、插件 online/offline、授权失败、历史读取失败、WS 断线和同步状态;发送能力未接入时保持禁用。
- 联调页只能调用 Bright HTTP/WS;不得在浏览器脚本中访问数据库、服务端模块、Mind legacy 接口或保存 credential。 - 联调页只能调用 Bright HTTP/WS;不得在浏览器脚本中访问数据库、server 内部模块、Mind legacy 接口或保存 credential。
- 来自 HTTP/WS 的未知数据必须先运行时校验;消息正文进入 DOM 前必须转义,事实去重键必须包含 scope、会话和原始 messageId。 - 来自 HTTP/WS 的未知数据必须先运行时校验;消息正文进入 DOM 前必须转义,事实去重键必须包含 scope、会话和原始 messageId。
- 前端专属依赖和脚本写入 `apps/server/package.json`,不要无理由污染根依赖。 - 联调页专属依赖和脚本写入 `apps/mind-test-harness/package.json`;该包不应声明 build/test 脚本,也不得成为其它包依赖。
- 引入浏览器端能力时,明确哪些代码只能在客户端执行,避免在服务端环境访问浏览器全局对象。 - 引入浏览器端能力时,明确哪些代码只能在客户端执行,避免在服务端环境访问浏览器全局对象。
## 禁止做法 ## 禁止做法
@@ -35,7 +35,7 @@ pnpm test
首次出现服务端前端后,应把实际入口和测试脚本接入 workspace,并记录浏览器/SSR 场景的额外验证方法。 首次出现服务端前端后,应把实际入口和测试脚本接入 workspace,并记录浏览器/SSR 场景的额外验证方法。
当前 `/harness` 没有独立浏览器构建或 E2E runner;最低验证包括服务端 typecheck/build/test、`GET /harness` 内容 smoke,以及注入 Mock auth/service 的真实本地 HTTP/WS 网络 smoke。 当前联调页没有独立浏览器构建或 E2E runner;最低验证包括服务端 typecheck/build/test、`GET http://127.0.0.1:8788/` 内容 smoke,以及真实本地 HTTP/WS 网络 smoke。
## 评审清单 ## 评审清单
+16 -16
View File
@@ -110,17 +110,17 @@ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/trade_message_center
### OneTalk 本地开发 ### OneTalk 本地开发
`pnpm dev` `NODE_ENV=development` 启动 Bright server,并同时启动本地 Mind HTTP mock(默认 `127.0.0.1:8787`。Bright 通过 `MIND_AUTH_BASE_URL` 访问 mock 的两个授权接口,不再需要 `ONETALK_DEV_*` 身份 fixturemock 只模拟 Mind HTTP 授权结果,不是生产授权实现,也不会连接 Mind 数据库 `pnpm dev` `NODE_ENV=development` 启动 Bright server 与 Chrome 扩展。`mind-test-harness` 是顺道使用的低优先级手工工具,必须另开终端运行 `pnpm dev:harness`;它的失败只报告给该命令,绝不阻断主开发流程。启动后,其 Mind 授权模拟默认监听 `127.0.0.1:8787`,独立 Bright 联调页默认监听 `127.0.0.1:8788`。Bright 通过 `MIND_AUTH_BASE_URL` 访问模拟的两个授权接口,不再需要 `ONETALK_DEV_*` 身份 fixture该工具不连接 Mind 数据库,也不属于生产授权实现
本地 `.env.local` 至少应将 `MIND_AUTH_BASE_URL` 指向 mock,并将 `MIND_PAGE_ORIGIN` 指向 Bright 页面地址: 本地 `.env.local` 至少应将 `MIND_AUTH_BASE_URL` 指向 mock,并将 `MIND_PAGE_ORIGIN` 指向 Bright 页面地址:
```dotenv ```dotenv
MIND_AUTH_BASE_URL=http://127.0.0.1:8787 MIND_AUTH_BASE_URL=http://127.0.0.1:8787
MIND_PAGE_ORIGIN=http://127.0.0.1:7878 MIND_PAGE_ORIGIN=http://127.0.0.1:8788
ONETALK_PLUGIN_ORIGINS=chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf ONETALK_PLUGIN_ORIGINS=chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
``` ```
`apps/chrome-extension/.keys/extension-private-key.pem` 是本地生成的扩展私钥,权限应保持为 `600`,不会提交到 Git;生成的 `dist/manifest.json` 内置从该私钥导出的固定公钥,因此从不同目录加载 `apps/chrome-extension/dist/` 不会再导致扩展 ID 变化。当前固定 ID 是 `ogdbffjakeeidblabkeakakdecfbcmlf``ONETALK_PLUGIN_ORIGINS` 仍必须是这个扩展的精确 Origin,不能改成任意 Origin`MIND_PAGE_ORIGIN` 是浏览器访问 Bright 的页面 Origin,不是 mock 的监听地址。 `apps/chrome-extension/.keys/extension-private-key.pem` 是本地生成的扩展私钥,权限应保持为 `600`,不会提交到 Git;生成的 `dist/manifest.json` 内置从该私钥导出的固定公钥,因此从不同目录加载 `apps/chrome-extension/dist/` 不会再导致扩展 ID 变化。当前固定 ID 是 `ogdbffjakeeidblabkeakakdecfbcmlf``ONETALK_PLUGIN_ORIGINS` 仍必须是这个扩展的精确 Origin,不能改成任意 Origin`MIND_PAGE_ORIGIN` 必须是浏览器打开 `mind-test-harness` 页面时的精确 Origin不是 Mind 授权模拟的监听地址。
开发 server 会输出 `[mind-auth][diagnostic]`,只包含 `binding/session` endpoint、授权 operation、`request_started/allowed/rejected/request_failed`、HTTP status 和稳定 code,可据此判断请求是否发出、Mind 是否返回以及最终拒绝原因;不包含 Cookie、binding、请求体或原始异常。扩展握手的 Origin 拒绝会出现在 `[onetalk][diagnostic]` 中。 开发 server 会输出 `[mind-auth][diagnostic]`,只包含 `binding/session` endpoint、授权 operation、`request_started/allowed/rejected/request_failed`、HTTP status 和稳定 code,可据此判断请求是否发出、Mind 是否返回以及最终拒绝原因;不包含 Cookie、binding、请求体或原始异常。扩展握手的 Origin 拒绝会出现在 `[onetalk][diagnostic]` 中。
@@ -130,33 +130,33 @@ ONETALK_PLUGIN_ORIGINS=chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
Bright 不连接 Mind 数据库、不读取认证视图、不共享 Mind 登录密钥,也不签发 `mind_page` credential。Bright 只把页面请求中的 Cookie 转发给 Mind 的 Session 授权接口,并把插件的 `channelAccountId + binding` 转发给 Mind 的 binding 授权接口;Cookie 不进入 URL、WS payload、日志或持久化数据。 Bright 不连接 Mind 数据库、不读取认证视图、不共享 Mind 登录密钥,也不签发 `mind_page` credential。Bright 只把页面请求中的 Cookie 转发给 Mind 的 Session 授权接口,并把插件的 `channelAccountId + binding` 转发给 Mind 的 binding 授权接口;Cookie 不进入 URL、WS payload、日志或持久化数据。
### Mind HTTP 本地模拟 ### Mind 本地测试支架
仓库提供一个无状态的本地 Mind HTTP mock,只实现上述两个授权接口。它默认监听 `127.0.0.1:8787`,默认 fixture 是账号 `286995452`、binding `binding-1` 和 Cookie `mind_session=mock-valid`;通过 `MIND_MOCK_*` 环境变量覆盖 fixture 后重启即可模拟 binding/version/cookie 切换。两个 endpoint 还可独立设置结果:`MIND_MOCK_BINDING_RESULT=allow|scope_mismatch|binding_revoked|authorization_unavailable``MIND_MOCK_SESSION_RESULT=allow|auth_required|scope_mismatch|authorization_rejected|authorization_unavailable` `mind-test-harness` 是仅供手工测试的独立开发工具:它模拟两个 Mind 授权接口,并提供 Bright HTTP/WS 联调页。Mind 模拟默认监听 `127.0.0.1:8787`,默认 fixture 是账号 `286995452`、binding `binding-123` 和 Cookie `mind_session=mock-valid`;通过 `MIND_MOCK_*` 环境变量覆盖 fixture 后重启即可模拟 binding/version/cookie 切换。两个 endpoint 还可独立设置结果:`MIND_MOCK_BINDING_RESULT=allow|scope_mismatch|binding_revoked|authorization_unavailable``MIND_MOCK_SESSION_RESULT=allow|auth_required|scope_mismatch|authorization_rejected|authorization_unavailable`
根目录执行 `pnpm dev`会先清理各 workspace 包的开发 `dist`,重新构建共享 contract,再自动启动 mock;也可以单独运行 `pnpm dev:mock` 根目录执行 `pnpm dev`不会启动支架;需要手工验证时才另开终端运行 `pnpm dev:harness`。支架不被任何 workspace 包依赖,不含测试文件,不声明 build 脚本,也不进入根 `dev``typecheck``test``build` 或生产 Docker 镜像。支架错误只在其独立命令中报告,不能改变主流程结果
直接验证两个接口: 直接验证两个接口:
```bash ```bash
curl -sS http://127.0.0.1:8787/internal/bright/onetalk/authorize-binding \ curl -sS http://127.0.0.1:8787/internal/bright/onetalk/authorize-binding \
-H 'content-type: application/json' \ -H 'content-type: application/json' \
--data '{"channelAccountId":"286995452","binding":"binding-1"}' --data '{"channelAccountId":"286995452","binding":"binding-123"}'
curl -sS http://127.0.0.1:8787/internal/bright/onetalk/authorize-session \ curl -sS http://127.0.0.1:8787/internal/bright/onetalk/authorize-session \
-H 'content-type: application/json' \ -H 'content-type: application/json' \
-H 'cookie: mind_session=mock-valid' \ -H 'cookie: mind_session=mock-valid' \
--data '{"channelAccountId":"286995452"}' --data '{"channelAccountId":"286995452"}'
``` ```
根目录执行 `pnpm dev` 会自动启动 mock 和 Bright;也可以单独运行: 手工验证时单独运行:
```bash ```bash
pnpm dev:mock pnpm dev:harness
``` ```
`pnpm --filter @trade-message-center/server dev:mind-http` 是仅供显式测试配置使用的 server 启动脚本;普通 `pnpm dev` 已直接使用 development-only 的 loopback Mind HTTP 配置。这个 mock 不模拟真实 Mind 的 Cookie Domain/SameSite/Secure、三级域 CORS/TLS、浏览器页面或 takeover 事务。 `pnpm --filter @trade-message-center/server dev:mind-http` 是仅供显式测试配置使用的 server 启动脚本;普通 `pnpm dev` 已直接使用 development-only 的 loopback Mind HTTP 配置。支架不模拟真实 Mind 的 Cookie Domain/SameSite/Secure、三级域 CORS/TLS 或 takeover 事务;它只在独立本地页面中暴露 Bright 的手工操作入口
`mind-http-mock` 参与本地 `dev`、类型检查和测试,不参与根 `pnpm build`,也不会被 `Dockerfile.server` 复制到生产镜像。 `mind-test-harness` 参与显式的 `pnpm dev:harness`;它不进入根 `pnpm dev``typecheck``test``build`,也不会被 `Dockerfile.server` 复制到生产镜像。完成支架改动无需新增或执行测试;若出现错误,只报告,不阻断主流程。
`VITE_BRIGHT_WEBSOCKET_URL` 用于 Chrome 扩展构建: `VITE_BRIGHT_WEBSOCKET_URL` 用于 Chrome 扩展构建:
@@ -232,7 +232,7 @@ pnpm dev
pnpm dev:server pnpm dev:server
``` ```
该命令直接启动 server watch,不调用 workspace 级 `with-build-hash`,也跳过 server `predev` 的 contract 构建;因此不会重新构建 Chrome 扩展,也不会改变当前扩展产物中的 `BUILD_HASH`。首次 checkout 或修改 `onetalk-contract` 后,请先执行 `pnpm --filter @trade-message-center/onetalk-contract build`。如果 server 需要本地 Mind HTTP mock,请另开终端运行 `pnpm dev:mock` 该命令直接启动 server watch,不调用 workspace 级 `with-build-hash`,也跳过 server `predev` 的 contract 构建;因此不会重新构建 Chrome 扩展,也不会改变当前扩展产物中的 `BUILD_HASH`。首次 checkout 或修改 `onetalk-contract` 后,请先执行 `pnpm --filter @trade-message-center/onetalk-contract build`。如果 server 需要本地 Mind 测试支架,请另开终端运行 `pnpm dev:harness`
检查 server 是否启动: 检查 server 是否启动:
@@ -246,20 +246,20 @@ curl http://127.0.0.1:7878/health
{ "status": "ok" } { "status": "ok" }
``` ```
本地 Bright 联调页位于 本地 Bright 联调页由测试支架独立提供
```text ```text
http://127.0.0.1:7878/harness http://127.0.0.1:8788/
``` ```
本地 harness 中的 OneTalk account 应与 `MIND_MOCK_CHANNEL_ACCOUNT_ID`(默认 `286995452`)一致;生产 Mind 页面不提交可信的 user/workspace header,而由 Cookie Session 授权结果派生。页面 WebSocket 使用 `/ws/mind`,插件使用 `/ws/plugin` 本地 harness 中的 OneTalk account 应与 `MIND_MOCK_CHANNEL_ACCOUNT_ID`(默认 `286995452`)一致;生产 Mind 页面不提交可信的 user/workspace header,而由 Cookie Session 授权结果派生。页面会连接配置的 Bright 地址的 `/ws/mind`,插件使用 Bright 的 `/ws/plugin``MIND_PAGE_ORIGIN` 必须设为该页面的 Origin
## 加载和配置 Chrome 扩展 ## 加载和配置 Chrome 扩展
1. 执行 `pnpm dev`,确认生成 `apps/chrome-extension/dist/` 1. 执行 `pnpm dev`,确认生成 `apps/chrome-extension/dist/`
2. 打开 `chrome://extensions`,启用“开发者模式”,选择“加载已解压的扩展程序”,加载 `apps/chrome-extension/dist/` 2. 打开 `chrome://extensions`,启用“开发者模式”,选择“加载已解压的扩展程序”,加载 `apps/chrome-extension/dist/`
3. 打开 OneTalk 页面 `https://onetalk.alibaba.com/`,点击扩展图标打开 Popup;Popup 会从页面运行时读取登录人账号 ID(`currentUserAccountId`),不要填写 URL 的 `activeAccountId` 3. 打开 OneTalk 页面 `https://onetalk.alibaba.com/`,点击扩展图标打开 Popup;Popup 会从页面运行时读取登录人账号 ID(`currentUserAccountId`),不要填写 URL 的 `activeAccountId`
4. 在 Binding 输入框填入 `MIND_MOCK_BINDING`(默认 `binding-1`)对应的值后保存配置;Popup 首次运行会自动生成 Device ID。URL 中的 `activeAccountId` 仅是当前对话账号。 4. 在 Binding 输入框填入 `MIND_MOCK_BINDING`(默认 `binding-123`)对应的值后保存配置;Popup 首次运行会自动生成 Device ID。URL 中的 `activeAccountId` 仅是当前对话账号。
5. Bright WebSocket URL 仅展示当前构建使用的地址,不在 Popup 中手工填写。配置保存或清除后,Service Worker 会动态建立、重建或关闭 Bright 连接和同步引擎。 5. Bright WebSocket URL 仅展示当前构建使用的地址,不在 Popup 中手工填写。配置保存或清除后,Service Worker 会动态建立、重建或关闭 Bright 连接和同步引擎。
扩展配置保存在 `chrome.storage.local`。Popup 不配置 `mindUserId``workspaceId`;保存 binding 时留空表示保持已有 binding,输入新值才会替换它。清除配置不会重置自动生成的 Device ID。 扩展配置保存在 `chrome.storage.local`。Popup 不配置 `mindUserId``workspaceId`;保存 binding 时留空表示保持已有 binding,输入新值才会替换它。清除配置不会重置自动生成的 Device ID。
-65
View File
@@ -1,65 +0,0 @@
// 启动 Mind HTTP mock 的本地监听入口
import { pathToFileURL } from "node:url";
import { createMindAuthorizationServer, parseMindMockConfig } from "./server.ts";
export {
createMindAuthorizationServer,
parseMindMockConfig,
MIND_BINDING_AUTHORIZATION_PATH,
MIND_SESSION_AUTHORIZATION_PATH,
} from "./server.ts";
export type { MindMockAuthorization, MindMockConfig, MindMockPermission } from "./server.ts";
const entryPath = process.argv[1];
const isMainModule = entryPath !== undefined && import.meta.url === pathToFileURL(entryPath).href;
const listen = async (
server: ReturnType<typeof createMindAuthorizationServer>,
config: ReturnType<typeof parseMindMockConfig>,
): Promise<void> => {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error): void => {
server.off("listening", onListening);
reject(error);
};
const onListening = (): void => {
server.off("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(config.port, config.host);
});
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("Mind HTTP mock did not expose a TCP address");
}
console.info(`[mind-http-mock] listening on http://${address.address}:${address.port}`);
};
const close = async (server: ReturnType<typeof createMindAuthorizationServer>): Promise<void> => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
};
/** 启动本地 Mind mock,并在进程信号到达时关闭监听器。 */
export const startMindMock = async (): Promise<void> => {
const config = parseMindMockConfig();
const server = createMindAuthorizationServer(config);
await listen(server, config);
const shutdown = (): void => {
void close(server).finally(() => process.exit(0));
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
};
if (isMainModule) {
void startMindMock().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : "Mind HTTP mock failed");
process.exitCode = 1;
});
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"types": ["node"],
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*.ts"]
}
@@ -1,5 +1,5 @@
{ {
"name": "@trade-message-center/mind-http-mock", "name": "@trade-message-center/mind-test-harness",
"version": "0.8.13", "version": "0.8.13",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -7,8 +7,6 @@
"dev": "node --watch --experimental-strip-types src/entry.ts", "dev": "node --watch --experimental-strip-types src/entry.ts",
"predev": "pnpm --filter @trade-message-center/onetalk-contract build", "predev": "pnpm --filter @trade-message-center/onetalk-contract build",
"start": "node --experimental-strip-types src/entry.ts", "start": "node --experimental-strip-types src/entry.ts",
"prebuild": "pnpm --filter @trade-message-center/onetalk-contract build",
"build": "tsc -p tsconfig.json",
"pretypecheck": "pnpm --filter @trade-message-center/onetalk-contract build", "pretypecheck": "pnpm --filter @trade-message-center/onetalk-contract build",
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
@@ -18,6 +18,9 @@ const DEFAULT_MIND_USER_ID = "mind-user-1";
const DEFAULT_WORKSPACE_ID = "workspace-1"; const DEFAULT_WORKSPACE_ID = "workspace-1";
const DEFAULT_PERMISSIONS = "read,send"; const DEFAULT_PERMISSIONS = "read,send";
const DEFAULT_COOKIE = "mind_session=mock-valid"; const DEFAULT_COOKIE = "mind_session=mock-valid";
const DEFAULT_HARNESS_HOST = "127.0.0.1";
const DEFAULT_HARNESS_PORT = 8788;
const DEFAULT_BRIGHT_BASE_URL = "http://127.0.0.1:7878";
export type MindMockConfig = { export type MindMockConfig = {
host: string; host: string;
@@ -28,6 +31,12 @@ export type MindMockConfig = {
sessionResult?: MindMockSessionResult; sessionResult?: MindMockSessionResult;
}; };
export type OneTalkHarnessConfig = {
host: string;
port: number;
brightBaseUrl: string;
};
const readEnvironmentValue = ( const readEnvironmentValue = (
environment: Record<string, string | undefined>, environment: Record<string, string | undefined>,
key: string, key: string,
@@ -37,14 +46,26 @@ const readEnvironmentValue = (
return value === undefined || value === "" ? defaultValue : value; return value === undefined || value === "" ? defaultValue : value;
}; };
const parsePort = (value: string): number => { const parsePort = (value: string, key: string): number => {
const port = Number(value); const port = Number(value);
if (!Number.isInteger(port) || port < 0 || port > 65535) { if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error("Invalid MIND_MOCK_PORT: expected integer 0-65535"); throw new Error(`Invalid ${key}: expected integer 0-65535`);
} }
return port; return port;
}; };
const parseHttpOrigin = (value: string, key: string): string => {
const url = new URL(value);
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
url.origin !== value ||
url.pathname !== "/"
) {
throw new Error(`Invalid ${key}: expected exact HTTP or HTTPS origin`);
}
return url.origin;
};
const parsePermissions = (value: string): MindMockPermission[] => { const parsePermissions = (value: string): MindMockPermission[] => {
const permissions = value.split(",").map((permission) => permission.trim()); const permissions = value.split(",").map((permission) => permission.trim());
if ( if (
@@ -115,10 +136,38 @@ export const parseMindMockConfig = (
}; };
return { return {
host: readEnvironmentValue(environment, "MIND_MOCK_HOST", DEFAULT_HOST), host: readEnvironmentValue(environment, "MIND_MOCK_HOST", DEFAULT_HOST),
port: parsePort(readEnvironmentValue(environment, "MIND_MOCK_PORT", String(DEFAULT_PORT))), port: parsePort(
readEnvironmentValue(environment, "MIND_MOCK_PORT", String(DEFAULT_PORT)),
"MIND_MOCK_PORT",
),
cookie: parseFixtureString(environment, "MIND_MOCK_COOKIE", DEFAULT_COOKIE), cookie: parseFixtureString(environment, "MIND_MOCK_COOKIE", DEFAULT_COOKIE),
authorization, authorization,
bindingResult: parseResult(environment, "MIND_MOCK_BINDING_RESULT", BINDING_MOCK_RESULTS), bindingResult: parseResult(environment, "MIND_MOCK_BINDING_RESULT", BINDING_MOCK_RESULTS),
sessionResult: parseResult(environment, "MIND_MOCK_SESSION_RESULT", SESSION_MOCK_RESULTS), sessionResult: parseResult(environment, "MIND_MOCK_SESSION_RESULT", SESSION_MOCK_RESULTS),
}; };
}; };
/** 解析独立 Bright 联调页的监听与目标地址。 */
export const parseOneTalkHarnessConfig = (
environment: Record<string, string | undefined> = process.env,
): OneTalkHarnessConfig => {
return {
host: readEnvironmentValue(environment, "MIND_TEST_HARNESS_HOST", DEFAULT_HARNESS_HOST),
port: parsePort(
readEnvironmentValue(
environment,
"MIND_TEST_HARNESS_PORT",
String(DEFAULT_HARNESS_PORT),
),
"MIND_TEST_HARNESS_PORT",
),
brightBaseUrl: parseHttpOrigin(
readEnvironmentValue(
environment,
"MIND_TEST_HARNESS_BRIGHT_BASE_URL",
DEFAULT_BRIGHT_BASE_URL,
),
"MIND_TEST_HARNESS_BRIGHT_BASE_URL",
),
};
};
+71
View File
@@ -0,0 +1,71 @@
// 启动本地 Mind 授权模拟与 Bright 联调页
import type { Server } from "node:http";
import { pathToFileURL } from "node:url";
import { parseOneTalkHarnessConfig } from "./config.ts";
import { createOneTalkHarnessServer } from "./harness-server.ts";
import { createMindAuthorizationServer, parseMindMockConfig } from "./server.ts";
const entryPath = process.argv[1];
const isMainModule = entryPath !== undefined && import.meta.url === pathToFileURL(entryPath).href;
const listen = async (server: Server, config: { host: string; port: number }): Promise<void> => {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error): void => {
server.off("listening", onListening);
reject(error);
};
const onListening = (): void => {
server.off("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(config.port, config.host);
});
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("Mind test harness did not expose a TCP address");
}
};
const close = async (server: Server): Promise<void> => {
if (!server.listening) return;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
};
/** 启动本地 Mind 授权模拟和 Bright 联调页,并在进程信号到达时关闭监听器。 */
export const startMindTestHarness = async (): Promise<void> => {
const mockConfig = parseMindMockConfig();
const harnessConfig = parseOneTalkHarnessConfig();
const mockServer = createMindAuthorizationServer(mockConfig);
const harnessServer = createOneTalkHarnessServer(harnessConfig);
try {
await listen(mockServer, mockConfig);
await listen(harnessServer, harnessConfig);
} catch (error) {
await Promise.all([close(mockServer), close(harnessServer)]);
throw error;
}
console.info(
`[mind-test-harness] Mind authorization mock listening on http://${mockConfig.host}:${mockConfig.port}`,
);
console.info(
`[mind-test-harness] Bright harness listening on http://${harnessConfig.host}:${harnessConfig.port}`,
);
const shutdown = (): void => {
void Promise.all([close(mockServer), close(harnessServer)]).finally(() => process.exit(0));
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
};
if (isMainModule) {
void startMindTestHarness().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : "Mind test harness failed");
process.exitCode = 1;
});
}
@@ -0,0 +1,32 @@
// 提供独立本地 Bright 联调页的 HTTP 监听器
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { OneTalkHarnessConfig } from "./config.ts";
import { createOneTalkHarnessHtml } from "./harness.ts";
const writeNotFound = (response: ServerResponse): void => {
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
response.end("Not Found");
};
const handleRequest = (
request: IncomingMessage,
response: ServerResponse,
config: OneTalkHarnessConfig,
): void => {
if (request.method !== "GET" || request.url !== "/") {
writeNotFound(response);
return;
}
response.writeHead(200, {
"cache-control": "no-store",
"content-type": "text/html; charset=utf-8",
});
response.end(createOneTalkHarnessHtml(config.brightBaseUrl));
};
/** 创建独立的 Bright 原生联调页监听器。 */
export const createOneTalkHarnessServer = (config: OneTalkHarnessConfig): Server => {
return createServer((request, response) => handleRequest(request, response, config));
};
@@ -1,12 +1,13 @@
// 提供本地 Bright 原生联调页面 // 提供独立本地 Bright 原生联调页面
import type { FastifyInstance } from "fastify"; import {
ONETALK_CONVERSATIONS_ROUTE,
ONETALK_PROTOCOL_VERSION,
} from "@trade-message-center/onetalk-contract";
import { ONETALK_PROTOCOL_VERSION } from "@trade-message-center/onetalk-contract"; export const createOneTalkHarnessHtml = (
brightBaseUrl: string,
import { ONETALK_CONVERSATIONS_ROUTE } from "./onetalk.ts"; ): string => String.raw`<!doctype html>
export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -47,7 +48,7 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
<body> <body>
<main> <main>
<h1>Bright OneTalk </h1> <h1>Bright OneTalk </h1>
<p class="subtitle"> Bright HTTP/WS Mind legacy </p> <p class="subtitle"> Bright HTTP/WS Mind legacy </p>
<section class="panel" aria-labelledby="connection-heading"> <section class="panel" aria-labelledby="connection-heading">
<h2 id="connection-heading"></h2> <h2 id="connection-heading"></h2>
@@ -92,6 +93,7 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
</main> </main>
<script> <script>
(() => { (() => {
const brightBaseUrl = ${JSON.stringify(brightBaseUrl)};
const conversationRoute = ${JSON.stringify(ONETALK_CONVERSATIONS_ROUTE)}; const conversationRoute = ${JSON.stringify(ONETALK_CONVERSATIONS_ROUTE)};
const state = { const state = {
scope: null, scope: null,
@@ -231,7 +233,8 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
const apiUrlFor = (channelAccountId, conversationId) => { const apiUrlFor = (channelAccountId, conversationId) => {
const base = conversationRoute.replace(':channelAccountId', encodeURIComponent(channelAccountId)); const base = conversationRoute.replace(':channelAccountId', encodeURIComponent(channelAccountId));
return base + (conversationId ? '/' + encodeURIComponent(conversationId) : ''); const path = base + (conversationId ? '/' + encodeURIComponent(conversationId) : '');
return new URL(path, brightBaseUrl).toString();
}; };
const conversationsUrlFor = (channelAccountId, cursor, queryValue) => { const conversationsUrlFor = (channelAccountId, cursor, queryValue) => {
@@ -400,8 +403,9 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
}; };
const wsUrl = () => { const wsUrl = () => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = new URL('/ws/mind', brightBaseUrl);
return protocol + '//' + location.host + '/ws/mind'; url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return url.toString();
}; };
const connectSocket = () => { const connectSocket = () => {
@@ -652,13 +656,3 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
</script> </script>
</body> </body>
</html>`; </html>`;
/** 安装原生 HTML harness 页面路由。 */
export const installOneTalkHarnessRoute = (app: FastifyInstance): void => {
app.get("/harness", async (_request, reply) => {
return reply
.type("text/html; charset=utf-8")
.header("cache-control", "no-store")
.send(ONE_TALK_HARNESS_HTML);
});
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+7
View File
@@ -4,6 +4,13 @@ import type { OneTalkCenterMessage, OneTalkMessageContent } from "./content.ts";
export const ONETALK_PROTOCOL_VERSION = 3 as const; export const ONETALK_PROTOCOL_VERSION = 3 as const;
export const ONETALK_CONVERSATIONS_ROUTE =
"/api/bright/onetalk/accounts/:channelAccountId/conversations";
export const ONETALK_CONVERSATION_ROUTE =
"/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId";
export const ONETALK_HISTORY_ROUTE =
"/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages";
export const ONETALK_CONNECTION_TYPES = ["plugin", "mind_page"] as const; export const ONETALK_CONNECTION_TYPES = ["plugin", "mind_page"] as const;
export type OneTalkConnectionType = (typeof ONETALK_CONNECTION_TYPES)[number]; export type OneTalkConnectionType = (typeof ONETALK_CONNECTION_TYPES)[number];
-2
View File
@@ -14,7 +14,6 @@ import {
} from "./mind-authorization.ts"; } from "./mind-authorization.ts";
import { installChromeExtensionDownloadRoute } from "./http/chrome-extension-download.ts"; import { installChromeExtensionDownloadRoute } from "./http/chrome-extension-download.ts";
import { installHealthRoute } from "./http/health.ts"; import { installHealthRoute } from "./http/health.ts";
import { installOneTalkHarnessRoute } from "./http/harness.ts";
import { installOneTalkReadRoutes } from "./http/onetalk.ts"; import { installOneTalkReadRoutes } from "./http/onetalk.ts";
import { import {
createOneTalkReadRepository, createOneTalkReadRepository,
@@ -88,7 +87,6 @@ export const createApp = (
installHealthRoute(app); installHealthRoute(app);
installChromeExtensionDownloadRoute(app, config.chromeExtensionDownload); installChromeExtensionDownloadRoute(app, config.chromeExtensionDownload);
installOneTalkHarnessRoute(app);
const oneTalkRegistry = installWebsocket(app, { const oneTalkRegistry = installWebsocket(app, {
authorization, authorization,
service: oneTalkService, service: oneTalkService,
+6 -8
View File
@@ -1,7 +1,12 @@
// 提供 Bright 受权会话读取 HTTP 边界 // 提供 Bright 受权会话读取 HTTP 边界
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { ONETALK_PROTOCOL_VERSION } from "@trade-message-center/onetalk-contract"; import {
ONETALK_CONVERSATIONS_ROUTE,
ONETALK_CONVERSATION_ROUTE,
ONETALK_HISTORY_ROUTE,
ONETALK_PROTOCOL_VERSION,
} from "@trade-message-center/onetalk-contract";
import type { import type {
OneTalkAuthorizationDecision, OneTalkAuthorizationDecision,
OneTalkAuthorizationReader, OneTalkAuthorizationReader,
@@ -17,13 +22,6 @@ import {
import type { OneTalkConnectionRegistry } from "../websocket/registry.ts"; import type { OneTalkConnectionRegistry } from "../websocket/registry.ts";
import type { OneTalkCutoverPolicy } from "../cutover-policy.ts"; import type { OneTalkCutoverPolicy } from "../cutover-policy.ts";
export const ONETALK_CONVERSATIONS_ROUTE =
"/api/bright/onetalk/accounts/:channelAccountId/conversations";
export const ONETALK_CONVERSATION_ROUTE =
"/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId";
export const ONETALK_HISTORY_ROUTE =
"/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages";
const SUMMARY_PURPOSE = "communication_summary_read"; const SUMMARY_PURPOSE = "communication_summary_read";
const HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS = 30; const HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS = 30;
const MINIMUM_PAGE_LIMIT = 1; const MINIMUM_PAGE_LIMIT = 1;
-448
View File
@@ -1,448 +0,0 @@
// 验证原生 harness 的异步状态边界
import assert from "node:assert/strict";
import vm from "node:vm";
import test from "node:test";
import { ONE_TALK_HARNESS_HTML } from "../src/http/harness.ts";
const HARNESS_SCRIPT = ONE_TALK_HARNESS_HTML.match(/<script>\n([\s\S]*?)\n <\/script>/)?.[1];
const elementIds = [
"account-id",
"conversation-query",
"conversation-id",
"read-purpose",
"from-sent-at-ms",
"to-sent-at-ms",
"mind-cookie",
"load-conversations",
"load-more-conversations",
"write-cookie",
"load-history",
"load-next",
"reconnect",
"send",
"send-content",
"history-status",
"connection-status",
"cookie-status",
"plugin-status",
"sync-status",
"messages",
"message-empty",
] as const;
type HarnessElement = {
value: string;
disabled: boolean;
hidden: boolean;
dataset: Record<string, string>;
innerHTML: string;
options: Array<{ value: string }>;
events: Map<string, () => void | Promise<void>>;
add: (option: { value: string }) => void;
addEventListener: (name: string, handler: () => void | Promise<void>) => void;
querySelector: () => { textContent: string };
};
const createHarnessElement = (): HarnessElement => {
const span = { textContent: "" };
const element: HarnessElement = {
value: "",
disabled: false,
hidden: false,
dataset: {},
innerHTML: "",
options: [],
events: new Map(),
add(option) {
this.options.push(option);
if (this.value === "") this.value = option.value;
},
addEventListener(name, handler) {
this.events.set(name, handler);
},
querySelector: () => span,
};
return element;
};
const createResponse = (body: unknown) => ({
ok: true,
json: async () => body,
headers: { get: () => null },
});
const scopeFor = (channelAccountId: string) => ({
mindUserId: `mind-${channelAccountId}`,
workspaceId: `workspace-${channelAccountId}`,
channelAccountId,
});
const conversationFor = (channelAccountId: string) => ({
channelAccountId,
conversationId: `conversation-${channelAccountId}`,
conversationType: "direct",
name: `Buyer ${channelAccountId}`,
avatarUrl: null,
participantIds: [],
latestMessageId: null,
latestMessageAtMs: null,
unreadCount: 0,
messageCount: 0,
historyComplete: true,
syncPhase: "initial",
syncResult: "succeeded",
});
test("does not let an old account list response reset the current account", async () => {
assert.ok(HARNESS_SCRIPT);
const elements = new Map<string, HarnessElement>();
for (const id of elementIds) elements.set(id, createHarnessElement());
const pending = new Map<string, { resolve: (response: unknown) => void }>();
const calls: string[] = [];
const context = {
document: {
cookie: "",
getElementById: (id: string) => elements.get(id),
},
fetch: (url: string) => {
calls.push(url);
return new Promise((resolve) => pending.set(url, { resolve }));
},
WebSocket: class {
static OPEN = 1;
},
Option: class {
readonly value: string;
constructor(_text: string, value: string) {
this.value = value;
}
},
URLSearchParams,
Map,
String,
JSON,
Number,
Math,
Date,
Error,
setTimeout,
clearTimeout,
location: { protocol: "http:", host: "bright.test" },
};
vm.runInNewContext(HARNESS_SCRIPT, context);
const account = elements.get("account-id");
const query = elements.get("conversation-query");
const load = elements.get("load-conversations");
const conversations = elements.get("conversation-id");
const loadMore = elements.get("load-more-conversations");
assert.ok(account && query && load && conversations && loadMore);
const input = (element: HarnessElement, value: string) => {
element.value = value;
element.events.get("input")?.();
};
const click = async (element: HarnessElement) => {
await element.events.get("click")?.();
};
const listResponse = (channelAccountId: string, cursor: string) =>
createResponse({
scope: scopeFor(channelAccountId),
plugin: { status: "offline" },
conversations: [conversationFor(channelAccountId)],
page: { hasMore: true, nextCursor: cursor },
});
const resolveFor = (channelAccountId: string, response: unknown) => {
const url = [...pending.keys()].find((value) =>
value.includes(`/accounts/${channelAccountId}/`),
);
assert.ok(url);
pending.get(url)?.resolve(response);
pending.delete(url);
};
input(account, "account-a");
input(query, "buyer-a");
const accountARequest = click(load);
input(account, "account-b");
input(query, "buyer-b");
const accountBRequest = click(load);
assert.equal(calls.length, 2);
await resolveFor("account-b", listResponse("account-b", "cursor-b"));
await accountBRequest;
await resolveFor("account-a", listResponse("account-a", "cursor-a"));
await accountARequest;
assert.equal(conversations.options[0]?.value, "conversation-account-b");
assert.equal(loadMore.disabled, false);
});
test("ignores plugin and sync events outside the active page scope", async () => {
assert.ok(HARNESS_SCRIPT);
const elements = new Map<string, HarnessElement>();
for (const id of elementIds) elements.set(id, createHarnessElement());
const sockets: Array<{
readyState: number;
onclose?: () => void;
onmessage?: (event: { data: string }) => void;
close: () => void;
send: () => void;
}> = [];
class HarnessSocket {
static OPEN = 1;
readyState = HarnessSocket.OPEN;
onclose?: () => void;
onmessage?: (event: { data: string }) => void;
constructor() {
sockets.push(this);
}
close = (): void => {
this.readyState = 3;
this.onclose?.();
};
send = (): void => {};
}
const channelAccountId = "account-a";
const context = {
document: {
cookie: "",
getElementById: (id: string) => elements.get(id),
},
fetch: (url: string) => {
if (url.includes("/messages")) {
return Promise.resolve(
createResponse({
scope: scopeFor(channelAccountId),
conversationId: conversationFor(channelAccountId).conversationId,
messages: [],
page: { hasMore: false, nextCursor: null },
}),
);
}
return Promise.resolve(
createResponse({
scope: scopeFor(channelAccountId),
plugin: { status: "offline" },
conversations: [conversationFor(channelAccountId)],
page: { hasMore: false, nextCursor: null },
}),
);
},
WebSocket: HarnessSocket,
Option: class {
readonly value: string;
constructor(_text: string, value: string) {
this.value = value;
}
},
URLSearchParams,
Map,
String,
JSON,
Number,
Math,
Date,
Error,
setTimeout,
clearTimeout,
location: { protocol: "http:", host: "bright.test" },
};
vm.runInNewContext(HARNESS_SCRIPT, context);
const account = elements.get("account-id");
const load = elements.get("load-conversations");
const loadHistory = elements.get("load-history");
const pluginStatus = elements.get("plugin-status");
const syncStatus = elements.get("sync-status");
assert.ok(account && load && loadHistory && pluginStatus && syncStatus);
account.value = channelAccountId;
account.events.get("input")?.();
await load.events.get("click")?.();
await loadHistory.events.get("click")?.();
assert.equal(sockets.length, 1);
const foreignScope = scopeFor("account-b");
sockets[0]?.onmessage?.({
data: JSON.stringify({
type: "plugin.status",
scope: foreignScope,
payload: { status: "online" },
}),
});
sockets[0]?.onmessage?.({
data: JSON.stringify({
type: "sync.status",
scope: foreignScope,
payload: {
conversationId: conversationFor(channelAccountId).conversationId,
mode: "full",
syncPhase: "initial",
syncResult: "succeeded",
latestMessageId: null,
historyComplete: true,
messageCount: 0,
anchorAdvanced: false,
},
}),
});
assert.equal(pluginStatus.dataset.state, "warn");
assert.equal(syncStatus.dataset.state, undefined);
});
test("does not add conversations whose nested account scope differs from the response scope", async () => {
assert.ok(HARNESS_SCRIPT);
const elements = new Map<string, HarnessElement>();
for (const id of elementIds) elements.set(id, createHarnessElement());
const context = {
document: {
cookie: "",
getElementById: (id: string) => elements.get(id),
},
fetch: () =>
Promise.resolve(
createResponse({
scope: scopeFor("account-a"),
plugin: { status: "offline" },
conversations: [conversationFor("account-b")],
page: { hasMore: false, nextCursor: null },
}),
),
WebSocket: class {
static OPEN = 1;
},
Option: class {
readonly value: string;
constructor(_text: string, value: string) {
this.value = value;
}
},
URLSearchParams,
Map,
String,
JSON,
Number,
Math,
Date,
Error,
setTimeout,
clearTimeout,
location: { protocol: "http:", host: "bright.test" },
};
vm.runInNewContext(HARNESS_SCRIPT, context);
const account = elements.get("account-id");
const conversations = elements.get("conversation-id");
const load = elements.get("load-conversations");
assert.ok(account && conversations && load);
account.value = "account-a";
await load.events.get("click")?.();
assert.equal(conversations.options[0]?.value, "");
});
test("does not apply an old history response after an account generation changes", async () => {
assert.ok(HARNESS_SCRIPT);
const elements = new Map<string, HarnessElement>();
for (const id of elementIds) elements.set(id, createHarnessElement());
let resolveHistory: ((response: unknown) => void) | undefined;
const context = {
document: {
cookie: "",
getElementById: (id: string) => elements.get(id),
},
fetch: (url: string) => {
if (url.includes("/messages")) {
return new Promise((resolve) => {
resolveHistory = resolve;
});
}
return Promise.resolve(
createResponse({
scope: scopeFor("account-a"),
plugin: { status: "offline" },
conversations: [conversationFor("account-a")],
page: { hasMore: false, nextCursor: null },
}),
);
},
WebSocket: class {
static OPEN = 1;
},
Option: class {
readonly value: string;
constructor(_text: string, value: string) {
this.value = value;
}
},
URLSearchParams,
Map,
String,
JSON,
Number,
Math,
Date,
Error,
setTimeout,
clearTimeout,
location: { protocol: "http:", host: "bright.test" },
};
vm.runInNewContext(HARNESS_SCRIPT, context);
const account = elements.get("account-id");
const load = elements.get("load-conversations");
const loadHistory = elements.get("load-history");
const messages = elements.get("messages");
assert.ok(account && load && loadHistory && messages);
account.value = "account-a";
account.events.get("input")?.();
await load.events.get("click")?.();
const oldHistoryRequest = loadHistory.events.get("click")?.();
account.value = "account-b";
account.events.get("input")?.();
account.value = "account-a";
account.events.get("input")?.();
await load.events.get("click")?.();
resolveHistory?.(
createResponse({
scope: scopeFor("account-a"),
conversationId: conversationFor("account-a").conversationId,
messages: [
{
messageId: "stale-message",
conversationId: conversationFor("account-a").conversationId,
senderId: "sender-a",
participantIds: ["sender-a", "account-a"],
direction: "received",
sentAtMs: 100,
readStatus: "read",
content: { version: 1, kind: "text", text: "stale" },
},
],
page: { hasMore: false, nextCursor: null },
}),
);
await oldHistoryRequest;
assert.equal(messages.innerHTML, "");
});
-69
View File
@@ -580,72 +580,3 @@ test("fences an in-flight list when Bright v3 pauses during the read await", asy
await closeApp(app); await closeApp(app);
} }
}); });
test("serves a guarded harness that renders normalized media without raw decoding", async () => {
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
readService: createReadService(),
});
try {
const response = await app.inject({ method: "GET", url: "/harness" });
assert.equal(response.statusCode, 200);
assert.match(response.headers["content-type"] ?? "", /text\/html/);
assert.match(response.body, /isCenterMessage/);
assert.match(response.body, /isConversationResponse/);
assert.match(response.body, /isReadErrorResponse/);
assert.match(response.body, /history_incomplete/);
assert.match(response.body, /retryAfter/);
assert.match(
response.body,
/requestHeadersForHistory[\s\S]*'X-Mind-Purpose': 'communication_summary_read'/,
);
assert.match(response.body, /readResponse\(url, requestHeadersForHistory\(\)\)/);
assert.match(
response.body,
/conversationsUrlFor\(channelAccountId, append \? state\.listCursor : null, queryValue\)/,
);
assert.match(response.body, /state\.listCursor = body\.page\.nextCursor/);
assert.match(
response.body,
/fields\.account\.addEventListener\('input', resetForAccountOrQueryChange\)/,
);
assert.match(response.body, /escapeHtml/);
assert.match(response.body, /isNormalizedContent/);
assert.match(response.body, /JSON\.stringify\(message\.content, null, 2\)/);
assert.match(response.body, /message-image/);
assert.match(response.body, /图片预览加载失败/);
assert.match(response.body, /downloadState/);
assert.match(response.body, /message\.created/);
assert.match(response.body, /credentials: 'include'/);
assert.equal(response.body.includes("decodeOneTalk"), false);
assert.equal(response.body.includes("contentType"), false);
assert.equal(response.body.includes("custom"), false);
assert.equal(response.body.includes("deviceId"), false);
assert.equal(response.body.includes("scope: state.scope"), false);
assert.equal(response.body.includes("x-onetalk-mind-user-id"), false);
assert.equal(response.body.includes("postgres://"), false);
} finally {
await closeApp(app);
}
});
test("keeps harness sending disabled until the plugin is online", async () => {
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
readService: createReadService(),
});
try {
const response = await app.inject({ method: "GET", url: "/harness" });
assert.equal(response.statusCode, 200);
assert.doesNotMatch(
response.body,
/socket\.onopen = \(\) => \{[\s\S]*?fields\.send\.disabled = false;/,
);
} finally {
await closeApp(app);
}
});
+1 -1
View File
@@ -697,7 +697,7 @@ CSRF
## 10.1 当前仓库的本地 profile receipt(仅测试) ## 10.1 当前仓库的本地 profile receipt(仅测试)
当前仓库的 `apps/mind-http-mock` 提供一个仅供本地验证的接口: 当前仓库的 `apps/mind-test-harness` 提供一个仅供本地验证的接口:
```text ```text
POST /internal/bright/onetalk/contact-profiles POST /internal/bright/onetalk/contact-profiles
+1 -1
View File
@@ -885,7 +885,7 @@ aliIdEncrypt
| 共享 decoder | `apps/onetalk-contract/src/decoder.ts` | 不理解 text/image/file 语义 | | 共享 decoder | `apps/onetalk-contract/src/decoder.ts` | 不理解 text/image/file 语义 |
| Bright 清洗 | `apps/server/src/onetalk/service.ts` | 按键过滤对象,字符串原样通过 | | Bright 清洗 | `apps/server/src/onetalk/service.ts` | 按键过滤对象,字符串原样通过 |
| Bright 持久化 | `apps/server/src/database/schema/onetalk.ts` | `content jsonb``text nullable` | | Bright 持久化 | `apps/server/src/database/schema/onetalk.ts` | `content jsonb``text nullable` |
| 开发展示 | `apps/server/src/http/harness.ts` | 只展示 raw JSON | | 开发展示 | `apps/mind-test-harness/src/harness.ts` | 只展示 raw JSON |
| 现有媒体覆盖 | `apps/chrome-extension/test/onetalk-websocket-tap.test.js` | 只有简化 `custom.type=10010` fixture | | 现有媒体覆盖 | `apps/chrome-extension/test/onetalk-websocket-tap.test.js` | 只有简化 `custom.type=10010` fixture |
## 16. 最终判断 ## 16. 最终判断
+4 -4
View File
@@ -8,13 +8,13 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"clean:dist": "node scripts/clean-generated-dist.mjs", "clean:dist": "node scripts/clean-generated-dist.mjs",
"dev": "pnpm run clean:dist && pnpm run version:ensure && pnpm --filter @trade-message-center/onetalk-contract build && node scripts/with-build-hash.mjs --parallel --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --filter @trade-message-center/mind-http-mock --if-present run dev", "dev": "pnpm run clean:dist && pnpm run version:ensure && pnpm --filter @trade-message-center/onetalk-contract build && node scripts/with-build-hash.mjs --parallel --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run dev",
"dev:server": "pnpm --filter @trade-message-center/server run dev:watch", "dev:server": "pnpm --filter @trade-message-center/server run dev:watch",
"dev:extension": "pnpm run version:ensure && node scripts/with-build-hash.mjs --filter @trade-message-center/chrome-extension run dev", "dev:extension": "pnpm run version:ensure && node scripts/with-build-hash.mjs --filter @trade-message-center/chrome-extension run dev",
"dev:mock": "pnpm --filter @trade-message-center/mind-http-mock run dev", "dev:harness": "pnpm --filter @trade-message-center/mind-test-harness run dev",
"build": "pnpm run clean:dist && pnpm run version:ensure && node scripts/with-build-hash.mjs --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run build", "build": "pnpm run clean:dist && pnpm run version:ensure && node scripts/with-build-hash.mjs --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run build",
"typecheck": "pnpm run clean:dist && pnpm run version:ensure && pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --filter @trade-message-center/mind-http-mock --if-present run typecheck", "typecheck": "pnpm run clean:dist && pnpm run version:ensure && pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run typecheck",
"test": "pnpm run clean:dist && pnpm run version:ensure && node --test scripts/package-version.test.mjs scripts/dev-startup.test.mjs && pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --filter @trade-message-center/mind-http-mock --if-present run test", "test": "pnpm run clean:dist && pnpm run version:ensure && node --test scripts/package-version.test.mjs scripts/dev-startup.test.mjs && pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run test",
"format": "oxfmt", "format": "oxfmt",
"format:check": "pnpm run version:ensure && oxfmt --check", "format:check": "pnpm run version:ensure && oxfmt --check",
"version:sync": "node scripts/sync-package-versions.mjs", "version:sync": "node scripts/sync-package-versions.mjs",
+1 -1
View File
@@ -37,7 +37,7 @@ importers:
specifier: ^6.0.7 specifier: ^6.0.7
version: 6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0) version: 6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0)
apps/mind-http-mock: apps/mind-test-harness:
dependencies: dependencies:
'@trade-message-center/onetalk-contract': '@trade-message-center/onetalk-contract':
specifier: workspace:* specifier: workspace:*
-1
View File
@@ -9,7 +9,6 @@ export const GENERATED_DIST_RELATIVE_PATHS = [
"apps/onetalk-contract/dist", "apps/onetalk-contract/dist",
"apps/server/dist", "apps/server/dist",
"apps/chrome-extension/dist", "apps/chrome-extension/dist",
"apps/mind-http-mock/dist",
]; ];
export const cleanGeneratedDist = (rootDir = workspaceDir) => { export const cleanGeneratedDist = (rootDir = workspaceDir) => {
+1 -5
View File
@@ -32,11 +32,8 @@ test("cleans only the explicit workspace package dist directories", () => {
} }
}); });
test("builds the shared contract before starting parallel development apps", async () => { test("builds the shared contract before starting core development apps", async () => {
const rootPackage = await import("../package.json", { with: { type: "json" } }); const rootPackage = await import("../package.json", { with: { type: "json" } });
const mockPackage = await import("../apps/mind-http-mock/package.json", {
with: { type: "json" },
});
const rootDev = rootPackage.default.scripts.dev; const rootDev = rootPackage.default.scripts.dev;
assert.match(rootDev, /^pnpm run clean:dist && /u); assert.match(rootDev, /^pnpm run clean:dist && /u);
@@ -45,5 +42,4 @@ test("builds the shared contract before starting parallel development apps", asy
rootDev, rootDev,
new RegExp(`&& ${contractBuildCommand} && node scripts/with-build-hash\\.mjs`), new RegExp(`&& ${contractBuildCommand} && node scripts/with-build-hash\\.mjs`),
); );
assert.equal(mockPackage.default.scripts.predev, contractBuildCommand);
}); });