feat(chrome-extension): surface popup configuration errors

This commit is contained in:
YBF
2026-09-07 17:20:32 +08:00
parent 5794192c23
commit 0bf97d65c9
18 changed files with 960 additions and 75 deletions
@@ -7,3 +7,4 @@
{"file":".trellis/spec/chrome-extension/frontend/type-safety.md","reason":"复核 runtime message guard、strict TypeScript 和无 any 边界。"}
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"执行目标测试及 root format/typecheck/build/test 基线。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-diagnostics.md","reason":"复核 snapshot 单一投影、错误 code 脱敏和 production diagnostics 边界。"}
{"file": ".trellis/tasks/09-07-popup-internal-error-presentation/research/current-popup-error-flow-summary.md", "reason": "研究结论摘要:用于独立复核 Popup 边界、server code、重连语义和敏感信息隔离。"}
@@ -9,3 +9,4 @@
{"file":".trellis/spec/chrome-extension/frontend/type-safety.md","reason":"Chrome runtime message 必须在唯一边界做严格运行时校验。"}
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"使用 Node 内置测试、typecheck、build 与全仓质量命令。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-diagnostics.md","reason":"复用 getSnapshot 单一投影和错误诊断脱敏契约,不将 raw error 传给 Popup。"}
{"file": ".trellis/tasks/09-07-popup-internal-error-presentation/research/current-popup-error-flow-summary.md", "reason": "研究结论摘要:闭集错误契约、ws.error 透传、生命周期不变量与单一扩展包范围。"}
@@ -1,5 +1,31 @@
# Popup 内部错误展示实施计划
## Scope and coordination
Mode: Default; risk: structural (the closed error contract crosses the Popup,
Service Worker and Bright client boundaries, while the wire protocol and
connection lifecycle remain unchanged).
| Scope ID | Monorepo package/root | Included functionality | Owns/excludes | Depends on | Acceptance | Research evidence |
| --- | --- | --- | --- | --- | --- | --- |
| POPUP-ERROR | `@trade-message-center/chrome-extension` / `apps/chrome-extension` | Closed Popup error code contract, Bright `ws.error` projection, Service Worker `runtime_error` normalization and snapshot/status propagation, Popup error guidance/layout, and focused regression tests | Owns the source and test paths named below; excludes `apps/onetalk-contract`, `apps/server`, protocol frames, reconnect/authorization/sync/storage behavior, and root configuration | None outside this package; preserve the existing shared `OneTalkErrorCode` input contract | All seven PRD acceptance criteria, focused tests, package static checks, and final integration checks pass | `design.md`, plus `research/current-popup-error-flow.md` to be completed before implementation dispatch |
Write range for POPUP-ERROR:
- `apps/chrome-extension/src/onetalk/config.ts`
- `apps/chrome-extension/src/onetalk/service-worker/bright-client.ts`
- `apps/chrome-extension/src/onetalk/service-worker/sync-controller.ts`
- `apps/chrome-extension/popup/popup.ts`
- `apps/chrome-extension/popup/popup.html`
- `apps/chrome-extension/src/service-worker-entry.ts` only if the status
payload projection requires an entry-level adjustment
- the five focused tests listed in this plan, with no production changes in
other packages
User confirmation: the 2026-09-07 request “批准规划并开始实现” confirms this
unchanged single-package range and the exclusions above. No additional scope
has been derived beyond the reviewed plan.
## 1. Establish the closed Popup error contract
1.`apps/chrome-extension/src/onetalk/config.ts` 定义 `OneTalkPopupErrorCode` 和 runtime guardunion 复用共享 `OneTalkErrorCode`,让 config response、status event 与 Popup 的 response guard 使用适当的 closed subtype,拒绝未知 status error code。
@@ -0,0 +1,80 @@
# Popup error flow research summary
Evidence baseline: `/Users/ybf/work/trade-message-center`, HEAD
`5794192c23c0a17f4221edddadb03d250c85357d`. The only current-task changes
outside source are task metadata, the implementation scope record, and the
full `current-popup-error-flow.md` report. GitNexus is stale by nine commits;
current source and tests are authoritative.
## Canonical owners and current gap
- `src/onetalk/config.ts` owns Popup config message/status types and guards.
The current status guard accepts any non-empty string `error`; the response
type uses `error?: string`, and `popup.ts` owns a loose response guard that
accepts any failure `code` string. There is no Popup closed error union.
- `src/onetalk/service-worker/bright-client.ts` decodes the shared contract's
`OneTalkErrorCode` before `handleFrame`. Valid `ws.error` frames are sent to
`onFrame` and frame listeners, then the existing close/reconnect policy runs;
they currently enter diagnostics only and do not call `onError`.
- `src/onetalk/service-worker/sync-controller.ts` owns the Popup-facing
`lastError` and `getSnapshot()` projection. `OneTalkExtensionConfigError`
and `OneTalkBrightClientError` retain their codes; every other unknown value
becomes `runtime_error`. `onBeforeChange` clears `lastError`; accepted does
not; same-config configure is a no-op; revision gates reject old callbacks.
- `src/service-worker-entry.ts` already copies top-level `snapshot.error` into
`onetalk.config.status` and deduplicates by connection status/error. It
should not become a second error projection owner.
- `popup/popup.ts` has three routes: initial get response, save/clear response,
and status-event listener. `showError` currently falls back to
`配置或连接失败`. `popup.html` currently keeps connection status in hidden
developer details and error at the form bottom.
## Stable code boundary
The shared decoder already validates these 12 server codes from
`apps/onetalk-contract`: `auth_required`, `authorization_rejected`,
`authorization_unavailable`, `authorization_version_changed`, `binding_revoked`,
`database_unavailable`, `invalid_message`, `plugin_offline`,
`profile_observed_at_future`, `onetalk_protocol_upgrade_required`,
`scope_mismatch`, and `unknown_request`.
The Popup union must also cover the seven config validation codes,
`storage_unavailable`, `sender_not_allowed`, the five Bright client codes
(`invalid_message`, `scope_mismatch`, `websocket_unavailable`, `send_failed`,
`invalid_time`), and `runtime_error`. Unknown status/response codes must be
rejected at the shared boundary; unknown runtime values normalize to
`runtime_error`. No message, stack, cause, raw frame, binding, identity,
payload, URL query, or credential may cross into Popup.
## Lifecycle invariants to preserve
- For deterministic server errors (`auth_required`, `authorization_rejected`,
`authorization_version_changed`, `binding_revoked`, `scope_mismatch`, and
`onetalk_protocol_upgrade_required`), preserve reconnect blocking and the
existing browser-valid `close(1000, code)` behavior.
- For transient/non-blocking server errors, preserve close/reconnect behavior.
`profile_observed_at_future` must not close the socket; the profile
coordinator must still discard only matching pending profile requests.
- Keep the order `onFrame`/profile handling before the existing `ws.error`
policy. Do not change heartbeat, ACK, sync completion, page routing, storage,
protocol frames, or manual reconnect/disconnect behavior.
- Keep one in-memory latest controller error. Configuration change/clear
clears it, successful authentication does not, and later errors replace it.
Engine-local `engineStatus.lastError` remains a separate diagnostic field.
## Accepted implementation scope and probes
Single scope `POPUP-ERROR` in `apps/chrome-extension`: config contract/guards;
Bright server-code callback projection; controller code narrowing and snapshot;
Popup labels/layout; and focused tests in `onetalk-config.test.js`,
`onetalk-bright-client.test.js`, `onetalk-sync-runtime.test.js`,
`popup-config-realtime.test.js`, and `service-worker-entry-config.test.js`.
Do not edit `apps/onetalk-contract`, `apps/server`,
`configured-sync-session.ts`, or engine/page modules unless type compilation
proves an adapter is unavoidable.
No service, port, database, container, browser account, or real Bright runtime
is required for the requested local acceptance. Run package focused tests,
then `pnpm format:check`, `pnpm typecheck`, `pnpm build`, `pnpm test`, and
`git diff --check`. Before commit run GitNexus `detect_changes()` and inspect
the final diff for duplicate mappings, raw-error leakage, and lifecycle drift.
@@ -0,0 +1,337 @@
# Research: current-popup-error-flow
- Query: 围绕 Scope POPUP-ERROR`apps/chrome-extension`)核对配置消息契约与 runtime guard、Bright `ws.error` 到 Service Worker/Popup 的完整路径、`sync-controller` 的错误/快照/epoch 语义,以及 Popup/HTML/Service Worker 的错误入口、构建边界和测试覆盖;同时对照当前任务计划,给出最小实现文件范围与不可改变的行为。
- Scope: mixed(仓库内代码、规范、测试为事实来源;Bright/Chrome 真实运行环境边界仅作未执行说明)
- Date: 2026-09-07
- Coordination class: cross-cutting
- Blocks: `POPUP-ERROR` implementation scope;前置条件是固定 Popup 闭集错误码、其 runtime guard、`ws.error` 的上报/连接策略映射、控制器单一快照投影,以及生产/开发 UI 的可见性契约。
- Shared boundary: `apps/chrome-extension/src/onetalk/config.ts` 的 Popup 配置/状态消息类型与 guard;`sync-controller.ts` 的错误归一和快照;`service-worker-entry.ts` 的状态广播;`popup.ts` 的 response/status 消费;共享输入来源为 `apps/onetalk-contract/src/model.ts``decoder.ts``OneTalkErrorCode` 闭集。
- Evidence baseline: resolved Git root `/Users/ybf/work/trade-message-center`; HEAD `5794192c23c0a17f4221edddadb03d250c85357d`; initial status 仅为 `.trellis/tasks/09-07-popup-internal-error-presentation/task.json` 修改,研究期间主会话又修改了同任务的 `implement.md`;两者均为既有/协作变更,未触碰。GitNexus 当前索引对应本 checkout 的旧提交 `29c3c556c56ff79b4526ef271ee3871061f64ebe`,落后 9 commits;本报告以当前文件和行号为准,未运行会可能改写文档的 re-index。
- Reused evidence and gaps: 复用 `.trellis/spec/chrome-extension/frontend/onetalk/runtime-diagnostics.md``runtime-sync.md``durable-sync.md` 的已验证所有权/脱敏/生命周期原则,并重新核对当前源文件;先前关于 Popup 配置存储、generation/revision 隔离的历史记忆只用于定位,当前结论均由本 checkout 重新验证。未读取或使用 `implement.jsonl` / `check.jsonl`,未启动服务、端口、数据库、容器或浏览器账户。
## Findings
### 1. `config.ts`:当前类型、guard owner、调用者和未知 error 行为
#### Canonical owner and current shapes
`apps/chrome-extension/src/onetalk/config.ts` 是配置消息模型的唯一共享类型 owner:
- `OneTalkConfigMessage` 只有 `onetalk.config.get``onetalk.config.save``config: unknown`)和 `onetalk.config.clear`,见 `config.ts:41-44`
- `OneTalkConfigMessageResponse` 的成功分支包含 `config: OneTalkExtensionConfigView | null``deviceId: string``connectionStatus: string`、可选 `authorizationVersion``permissions: string[]`、可选 `error?: string`;失败分支的 `code` 是 7 个配置 validation code 加 `storage_unavailable``sender_not_allowed`,见 `config.ts:46-59`。Service Worker 在本地另加 `engineStatus`,形成 `OneTalkConfigMessageResponseWithEngineStatus`,见 `service-worker-entry.ts:56-60`
- `OneTalkConfigStatusEvent``{ type: "onetalk.config.status"; connectionStatus: string; error?: string }`,见 `config.ts:61-65`
- 配置 validation code 是 `invalid_configuration``invalid_configuration_version``invalid_bright_websocket_url``missing_bright_websocket_url``missing_channel_account_id``missing_device_id``missing_binding`,见 `config.ts:18-29``OneTalkExtensionConfigError` 还可携带 `storage_unavailable`,见 `config.ts:67-75`
当前 `config.ts` 内的 runtime guard 只有两个共享 guard
- `isOneTalkConfigMessage`:先检查 record/type`get`/`clear` 只按 type 放行,`save` 还要求 `config` 是 record,见 `config.ts:344-349`。它不拒绝 get/clear 上的额外 key。
- `isOneTalkConfigStatusEvent`:要求 record、正确 type、只允许 `type`/`connectionStatus`/`error` 三类 key、非空 string `connectionStatus`,但 `error` 只要求是 string,因此空字符串和任意未知 code 都会放行,见 `config.ts:351-361`
没有由 `config.ts` 导出的 response guard。`popup.ts``118-127` 定义了本地 `isConfigResponse`:成功分支只检查 `config` 为 null/record、`deviceId`/`connectionStatus` 为 string、`permissions` 为 array;失败分支只检查 `code` 为任意 string。它没有验证 config view 字段、error code 闭集、response 额外字段或 `permissions` 元素类型,见 `popup/popup.ts:114-127`。因此当前 response guard 的 canonical owner 实际是 Popup 私有实现,而非共享配置模块;这与跨层规范要求“边界一次 decode、消费者不重复定义”的方向不一致。
#### All current callers
| Symbol / shape | Current callers | Evidence |
| --- | --- | --- |
| `OneTalkConfigMessage` | Popup `PopupChromeApi.runtime.sendMessage``sendConfigurationMessage`Service Worker `handleConfigurationMessage` listener | `popup/popup.ts:18-32,203-209`; `service-worker-entry.ts:30-44,203-210` |
| `OneTalkConfigMessageResponse` | Popup `isConfigResponse`/`renderResponse`/`sendConfigurationMessage`Service Worker response type alias、`responseFor``handleConfigurationMessage` | `popup/popup.ts:118-127,185-209`; `service-worker-entry.ts:56-60,187-200,203-264` |
| `OneTalkConfigStatusEvent` | Service Worker `broadcastStatus` 构造并发送;Popup `onMessage` listener 消费;配置测试直接测试 guard | `service-worker-entry.ts:89-108`; `popup/popup.ts:291-294`; `onetalk-config.test.js:19-48` |
| `isOneTalkConfigMessage` | 仅 Service Worker 入口在收到 runtime message 时使用 | `service-worker-entry.ts:203-210` |
| `isOneTalkConfigStatusEvent` | 仅 Popup listener 使用;配置单测验证若干形状 | `popup/popup.ts:292-294`; `onetalk-config.test.js:19-48` |
| Config storage/validation functions | Service Worker `applyStoredConfiguration`、get/save/clear message handler;配置单测 | `service-worker-entry.ts:172-185,213-264`; `onetalk-config.test.js:76-176` |
#### Current unknown/error behavior
- Service Worker sender 不合法时直接返回 `{ ok: false, code: "sender_not_allowed" }`;合法 sender 但消息 guard 失败时返回 `{ ok: false, code: "invalid_configuration" }`,见 `service-worker-entry.ts:203-210`
- `config.ts` 的 URL、字段、存储失败会通过 `OneTalkExtensionConfigError` 保留稳定配置 code;存储读写 catch 统一为 `storage_unavailable`,见 `config.ts:122-165,177-199,240-323`。未知异常在入口配置 message catch 中统一返回 `storage_unavailable`,见 `service-worker-entry.ts:259-264`;启动读取的非配置异常也调用 `setConfigurationError("storage_unavailable")`,见 `service-worker-entry.ts:172-184`
- 配置错误经过 `controller.setConfigurationError` 时,当前 controller 仅认可 8 个配置/storage code;其它字符串(包括直接传入的 `sender_not_allowed`)变成 `runtime_error`,见 `sync-controller.ts:60-69,172-175`
- Popup 的初次 get 失败、save 失败、clear 失败都 catch 后调用 `showError("storage_unavailable")`;初次 get 还把连接状态设为“未知”并清空 Device ID,见 `popup/popup.ts:249-257,259-282`
- Popup 收到 response 的失败分支会把任意 string `response.code` 传给 `showError`;成功分支把任意 string `response.error` 传给它,见 `popup/popup.ts:185-195``showError` 对空值隐藏红字,对未知 code 使用笼统 fallback `配置或连接失败`,见 `popup/popup.ts:129-137`
- Popup 收到 status event 时,当前 status guard 会放行任意 string error,随后同样落入 `showError` 的笼统 fallback;未知 status event 形状则被静默忽略,见 `popup/popup.ts:197-200,291-294`
**结论(对计划的差异)**:计划要求 `OneTalkPopupErrorCode` 闭集及共享 guard,并让 response/status 的错误字段收窄;当前只有 status 的宽松形状 guardresponse guard 仍在 Popup 内部且更宽松。最小根因修复应把 Popup error union、code guard、response guard/形状校验放在 `config.ts`,让 Popup 仅消费共享投影;不能继续扩展 `showError` 的 string fallback。
### 2. Bright `ws.error`:完整路径、onError/close/reconnect/profile 语义和安全 server code
#### Shared server source of truth
共享 contract 在 `apps/onetalk-contract/src/model.ts:69-83` 定义闭集 `OneTalkErrorCode`
`auth_required``authorization_rejected``authorization_unavailable``authorization_version_changed``binding_revoked``database_unavailable``invalid_message``plugin_offline``profile_observed_at_future``onetalk_protocol_upgrade_required``scope_mismatch``unknown_request`
`OneTalkErrorFrame.payload` 只有 `{ code: OneTalkErrorCode }`,见 `model.ts:333-338`。contract decoder 的 `isOneTalkErrorCode` 使用 `Object.values(ONETALK_ERROR_CODES)`,见 `decoder.ts:137-144``ws.error` payload 只接受该闭集 code,见 `decoder.ts:397-399``decodeOneTalkFrame` 对未知/不完整帧返回 `invalid_message`,协议版本不匹配返回 `onetalk_protocol_upgrade_required`,见 `decoder.ts:529-556`。因此上述 12 个 code 是可安全上报到 Popup 的服务端稳定原因;原始 payload、异常 message、binding、scope、账号和 URL query 不应进入 Popup。
#### Inbound path and current behavior
当前 socket listener 安装路径是:
```text
create/connect
-> installSocket
-> message listener
-> handleFrame(sourceSocket, event)
-> parseFrameData(JSON/string/ArrayBuffer/Uint8Array)
-> decodeOneTalkFrame
-> diagnostic(frame, ws.error code only)
-> scope/connectionType check
-> onFrame(frame) and frameListeners(frame)
-> ws.accepted or ws.error policy
```
证据:`installSocket``bright-client.ts:720-727` 绑定 open/message/error/close`handleFrame``646-678` 解析、解码、记录 diagnostic、复核 scope 后先调用 `options.onFrame` 和所有 frame listeners`ws.accepted` 状态处理在 `680-689``ws.error` 策略在 `692-702`
当前 valid `ws.error` 的关键事实是:
1. `onDiagnostic` 会携带 `frame.payload.code`,但只使用安全元数据和稳定 code,不携带原始 frame,见 `bright-client.ts:659-665` 以及 `frameDiagnostic` 的敏感字段白名单 `465-499`
2. `handleFrame` **不会**对 valid `ws.error` 调用 `report`/`options.onError`;它只在解码失败、scope mismatch 等路径调用 `report(new OneTalkBrightClientError(...))`,见 `bright-client.ts:648-657,666-674`。因此当前 server code 不会进入 `sync-controller.lastError` 或 Popup status error。
3. 因为 `onFrame`/listeners 在 close policy 前执行,配置会话首先把 frame 交给 profile coordinator,然后交给 sync lifecycle。`configured-sync-session.ts:173-176` 调 profile`sync-engine/lifecycle.ts:67-72,232-235` 订阅并转发 frame。sync engine 当前只处理 `anchor.snapshot` 和 message ACK`ws.error` 不会被它当作 sync frame 继续处理,见 `sync-engine.ts:224-254`
#### Close/reconnect policy
`shouldBlockReconnect` 只把以下 6 个 code 视为确定性授权/协议阻断:`auth_required``authorization_rejected``authorization_version_changed``binding_revoked``scope_mismatch``onetalk_protocol_upgrade_required`,见 `bright-client.ts:421-430`
| Valid server `ws.error` code class | Current socket action | Current resulting state/reconnect | Popup-safe reporting status |
| --- | --- | --- | --- |
| 6 deterministic codes above | Except `profile_observed_at_future`(不属于这 6 个)之外,open socket 用 browser-valid `close(1000, code)` | close event sees `reconnectBlocked=true``unauthorized`,不 schedule auto reconnect | Code 本身安全;当前未走 `onError` |
| `authorization_unavailable`, `database_unavailable`, `invalid_message`, `plugin_offline`, `unknown_request` | `close(1000, code)` | `reconnectBlocked=false``offline`,若 `autoReconnect` 则按 delay 重连 | Code 本身安全;当前未走 `onError` |
| `profile_observed_at_future` | 不主动 close | 保持当前 authenticated socketprofile coordinator 精确丢弃对应 pending profile,不重试该 request | Code 本身安全;需保留“不关闭连接”语义 |
`handleClose` 会清 heartbeat、置 socket null;主动 disconnect 进入 `closed` 且不重连,否则按 `reconnectBlocked` 选择 `unauthorized`/`offline` 并调用 `scheduleReconnect`,见 `bright-client.ts:611-634``scheduleReconnect` 受 intentionallyClosed、reconnectBlocked、autoReconnect、已有 timer 和 delay 限制,见 `bright-client.ts:598-609`
手动 `reconnect` 会清 timer、解除 block、用 `close(1000, "reconnect")` 关闭旧 socket、先设 idle 再 connect`disconnect` 会标记 intentionallyClosed/reconnectBlocked、清 timer/heartbeat、用 `close(1000, "client_disconnect")` 并设 closed,见 `bright-client.ts:750-781`。这两条行为不是本期 Popup 错误展示的可变项。
`handleError`(浏览器 socket error 事件)只上报 `OneTalkBrightClientError("websocket_unavailable")`,不在自身改变状态;socket create/send/invalid inbound frame/invalid scope 也分别上报 `websocket_unavailable``send_failed``invalid_message``scope_mismatch`,见 `bright-client.ts:512-579,636-644,729-747``OneTalkBrightClientError` 当前 code 类型仅有 `invalid_message``scope_mismatch``websocket_unavailable``send_failed``invalid_time`,见 `bright-client.ts:122-135`
#### Profile semantics
`configured-sync-session` 在创建 Bright client 时把 `onError` 接到 revision-gated `reportCurrentError`,把 `onFrame` 接到 profile/send command,见 `configured-sync-session.ts:155-223`profile status subscription 也以当前 revision 为门禁,见 `configured-sync-session.ts:240-246`。profile coordinator 对 `ws.error` `profile_observed_at_future`:按 requestId 找到当前请求,删除内存 request,逐条 `discardPendingProfile`,再以 diagnostic 记录 rejected;存储失败才走 `onError`,见 `contact-profile-coordinator.ts:330-369`。非 authenticated Bright 状态会清 request map/flush flagauthenticated 会 flush pending profile,见 `contact-profile-coordinator.ts:417-424`
因此若把 valid server code 接入现有 `onError`,应当在现有 `onFrame`/profile 处理顺序之后或保持当前顺序,使 `profile_observed_at_future` 仍先完成精确 pending 处置,且不改变 close/reconnect。最小类型适配点在 `bright-client.ts`:让 `OneTalkBrightClientError`/等价 error projection 可携带共享 `OneTalkErrorCode`,再由既有 `sync-controller.safeErrorCode` 读取;不要新建第二份 server code 常量。
### 3. `sync-controller.ts`lastError、safeErrorCode、snapshot、epoch/config 切换
#### Controller-level projection
controller 对外快照类型是 `OneTalkServiceWorkerSyncSnapshot`:脱敏 config、Bright `connectionStatus`、可选 authorizationVersion、permissions、可选 engineStatus 和顶层 `error?: string`,见 `sync-controller.ts:29-36``snapshotFor` 在无 config/active 时固定返回 `connectionStatus: "unconfigured"`、空 permissions、`engineStatus: null`;有 active 时读取 `bright.getState()``engine.getStatus()` 并用 `redactOneTalkExtensionConfig` 去除 binding,见 `sync-controller.ts:77-102`
当前有两套同名语义相近但所有权不同的错误状态:
| State | Owner | How written | How exposed now |
| --- | --- | --- | --- |
| controller `lastError` | `sync-controller.ts` closure | `reportError` 通过 `safeErrorCode` 写入;`setConfigurationError` 直接写入已知配置 code/`runtime_error` | `snapshot.error`Service Worker status broadcastPopup response/status 可见 |
| engine `lifecycle.lastError` | `sync-engine/lifecycle.ts` 每个 engine 实例 | `reportError``STABLE_ERROR_CODES`/`onetalk_history_*` 投影,否则 `onetalk_sync_error``setLastError` 被 observation pipeline 用于 anomaly | `snapshot.engineStatus.lastError` 和 development state log;当前 broadcast 只复制顶层 `snapshot.error`Popup response guard 也不消费 engineStatus |
controller 的 `safeErrorCode` 当前只识别 `OneTalkExtensionConfigError``OneTalkBrightClientError`,否则统一 `runtime_error`,见 `sync-controller.ts:60-75``reportError` 先写 controller lastError,再调用 `options.onError`(保留 raw unknown 给日志边界),最后 notify status,见 `sync-controller.ts:108-127`。入口 onError 用 config error 的 code,否则 `runtime_error``logOneTalkError`development 才带有限投影,production 只带 prefix/code,见 `service-worker-entry.ts:129-132``error-diagnostics.ts:224-246`
这解释了当前的双重行为:sync engine 可以有 `engineStatus.lastError = "onetalk_history_..."`,而 controller 的 Popup-facing `snapshot.error` 对同一异步 raw error 仍是 `runtime_error`engine `setLastError` 本身只写 engine 状态、不调用 controller `reportError`,见 `sync-engine/lifecycle.ts:151-168``observation-pipeline.ts:300-305,413-420`。本期计划的 `runtime_error` 归一应保持这种边界,不把每个同步内部 stable code 扩大为 Popup 契约。
#### Configuration switch and revision/epoch isolation
`OneTalkConfiguredSyncSession.configure` 的切换语义:
- null→null 且无 active、或 active 配置与新配置完全相同,返回 false/no-op,比较字段是 URL、channelAccountId、deviceId、binding,见 `configured-sync-session.ts:53-64,125-135`
- 真正变化时先 `revision += 1`,执行 `onBeforeChange`,清 active/currentConfigdispose engine/profile、取消 profile status subscription、disconnect old Bright,再通知一次状态;controller 传入的 `onBeforeChange` 清空 controller lastError,见 `configured-sync-session.ts:137-149``sync-controller.ts:143-147`
- 新 Bright、profile、engine 的 error/status callback 都闭包捕获 `currentRevision`;revision 不匹配时旧回调直接 return,见 `configured-sync-session.ts:155-162,240-263`。构造期间若 revision 已变,丢弃新 engine/bright/profile,见 `configured-sync-session.ts:266-280`
- controller `configure` 在 session changed 后用当前 revision 调 `pageHost.replayTo`await 后再次比较 revision,未变化才 notify 并 `connectCurrent`,见 `sync-controller.ts:160-170`。相同配置重复 save 因入口 `sameConfiguration` 以及 session no-op 而不伪造重连/错误恢复,见 `service-worker-entry.ts:153-170`
engine 内还有独立的 lifecycle epoch`currentEpoch()``connectionEpoch:snapshotEpoch:pageEpoch`,见 `sync-engine/lifecycle.ts:107-113`。首次 authenticated 或从非 authenticated 状态变更时会增加 connection epoch;新认证清 snapshot epoch/fingerprint/anchors,非认证也清 anchors`anchor.snapshot` 接受后增加 snapshot epochpage ready/disconnect 增加 page epoch,见 `lifecycle.ts:170-223,237-263`。Page host 再把 configurationEpoch、connectionEpoch、pageEpoch 和 active engine/profile/identity 一起校验,防止旧异步 page/profile callback 写入新配置,见 `page-runtime-host.ts:28-70,81-139,201-224`
配置切换/清除会清 controller lastError,但 authenticated 事件不会清;Bright `ws.accepted` 只清 reconnect timer/block 并更新 Bright state,见 `bright-client.ts:680-689`。因此既定语义是“最近一次错误”,允许“已认证 + 最近错误”并存,直到 config change/clear 或 Service Worker 重启;不应借展示任务加入自动清除。
### 4. Popup / HTML / Service Worker 的三个错误入口、DOM 可见性和测试覆盖
#### Three ingress paths
1. **初次读取:** module load 后 `loadConfiguration` 发送 `onetalk.config.get`response 经过 Popup 私有 `isConfigResponse` 后进入 `renderResponse`。成功显示 config/device/status/error,失败或 guard/transport 异常显示配置/storage 错误,见 `popup/popup.ts:185-209,249-257`
2. **保存/清除:** 表单 submit 和 clear button 分别调用 `saveConfiguration`/`clearSavedConfiguration`,二者都通过 `sendConfigurationMessage` 得到 response 后复用同一个 `renderResponse`catch 仅显示 `storage_unavailable`,见 `popup/popup.ts:259-290`。保存前没有 channel account 时直接显示 `missing_channel_account_id`,见 `popup/popup.ts:259-263`
3. **实时状态广播:** Service Worker `onStatusChange``broadcastStatus`,只构造 `{ type, connectionStatus, error? }`,按 connectionStatus/error 去重后调用 `chrome.runtime.sendMessage`,见 `service-worker-entry.ts:89-108,116-127`Popup listener 用 `isOneTalkConfigStatusEvent` 放行后调用 `renderStatusEvent`,见 `popup/popup.ts:197-200,291-294`。无接收端的固定 Chrome 错误被忽略,其它广播失败仅 development-safe-log 为 `runtime_error`,见 `service-worker-entry.ts:74-87`
第一和第二入口在实现层是同一个 response/render 入口;计划所说的三条通道应理解为“初始化 response、save/clear response、status event”,而不是新增三套 UI 映射。当前 `showError` 是三条通道最终共用的唯一显示函数,但它接受任意 string 并有旧 fallback,见 `popup/popup.ts:129-137`
#### Current DOM and build-mode boundary
当前 `popup.html` 的 identity panel 依次是登录人账号、Device ID、Build Hash、Binding formerror 在 form 底部,见 `popup/popup.html:262-317``connection-status` 和 Bright URL 位于 `developer-details`,该 section 默认带 `hidden`,见 `popup/popup.html:319-327`。因此:
- development 中 `popup.ts:291` 设置 `developerDetails.hidden = false`,连接状态和 URL 可见;production 中整个 section 保持隐藏,连接状态也随之不可见。
- error 当前不依赖 developer-details,所有模式都在 form 内存在,但位于计划要求的顶部身份区之后/Binding form 内部;当前没有“最近错误”标签和稳定 code 展示。
- `IS_ONE_TALK_DEVELOPMENT_MODE` 唯一由 `import.meta.env.MODE === "development"` 判定,见 `build-config.ts:7-12``vite.config.ts:33-75` 只在 development 设置 minify=false/sourcemap=trueproduction 保留默认压缩/source map 边界。Popup 是独立 Vite HTML inputservice worker 是另一入口,见 `vite.config.ts:10-29,64-74`
- manifest 的 `action.default_popup` 仍指向 `popup/popup.html`,权限为 `activeTab`/`scripting`/`storage`service worker 为 module,见 `manifest.template.json:12-15,31-38`。本任务不能通过改变 manifest、权限或入口来解决可见性。
计划要求的 UI 差异是:Build Hash → connection status → recent error → Binding form 全部放在 identity panelproduction/development 都可见;developer-details 仅保留 development-only Bright URL。这是 `popup.html` 的布局/可见性变更,不应改变 `sendMessage`、表单、绑定存储或状态 listener 生命周期。
#### Existing automated coverage and gaps
| Test | Existing assertions | Missing for current plan |
| --- | --- | --- |
| `test/onetalk-config.test.js` | status guard 接受 authenticated/runtime_error、拒绝额外 binding、非 string/空 status;配置 validation、binding storage/redaction、device ID、storage unavailable | 没有 Popup error 闭集;未知/空 error 仍未拒绝;没有 response guard/敏感额外字段矩阵;没有 12 server code |
| `test/popup-config-realtime.test.js` | 静态检查 identity 中 account/device/build/binding、developer details 默认 hidden、URL 在 details、binding 不在 details;检查 development flag、status guard listener、scripting/build hash | 没有执行 DOM/render;没有 errorLabels 穷尽性、稳定 code、旧 fallback、response/status 共用展示规则、生产 connection/error 可见性/顺序断言 |
| `test/onetalk-bright-client.test.js` | FakeSocket 覆盖 scope mismatch、单个 `binding_revoked` ws.error diagnostic 和合法 close code 1000、`authorization_unavailable` 自动重连、6 个 deterministic code 阻断重连、invalid inbound/send close | 未断言 valid ws.error 调 `onError`;未覆盖 12 个 code 进入 error snapshotprofile future code 的不关闭/精确 pending 处置只在 profile 相关测试间接存在 |
| `test/onetalk-sync-runtime.test.js` | 配置替换、旧 Bright error callback 不污染新 session、binding 不进入 snapshot、重复配置 no-op、page hello replay、旧 page callback 不触及替换账号 | 没有“先报错再切换/清除后清空”断言;没有 server code/unknown→runtime_error snapshot;没有 authenticated 不清 lastError 的断言 |
| `test/service-worker-entry-config.test.js` | sender allowlist、status broadcast 形状和去重、known config error statusproduction page send error 只安全记录 runtime_error 且不传 raw object | 没有从真实 Bright valid ws.error 到 status event 的 entry-level path;没有未知 status code guard 边界 |
| `test/onetalk-sync-engine.test.js` | engine lifecycle 的 `lastError` 可为 `onetalk_history_message_request_failed`,见 `:962-964` | 没有验证 engine lastError 与 Popup-facing controller `snapshot.error` 的隔离 |
| `test/onetalk-error-diagnostics.test.js` | unknown/Error projection、cause/accessor/cycle/敏感 assignment、dev/prod logger、原始 error identity | 不属于 Popup code contract;无需把原始 error 详情带到 UI |
现有测试主要是 Node fake socket/store 和静态文件读取,没有真实 Popup DOM、Chrome Service Worker 生命周期或 Bright 服务验证。
### 5. 计划与当前代码的差异、最小实现文件范围和不可改变行为
#### Plan/code delta
| Planned contract/behavior | Current code | Research consequence |
| --- | --- | --- |
| `OneTalkPopupErrorCode` closed union reuses `OneTalkErrorCode` | 没有 Popup unionconfig/status error 是 `string` | 必须在 config owner 处建立单一 union/guard;不要在 Popup、controller、Bright 各复制 code list |
| response/status runtime guard rejects unknown code and sensitive extras | status guard 只做 key/type 粗校验;response guard 是 Popup-local 且宽松 | 新 guard 应覆盖 status、response success/error/failure 的精确安全形状;未知 status error 静默忽略 |
| valid server `ws.error` enters same snapshot | 当前只进入 Bright diagnostic,未调用 `onError` | Bright 增加现有回调的稳定 code 投影;entry broadcast 已会复制 `snapshot.error`,通常不需改业务逻辑 |
| all other runtime unknowns become `runtime_error` | controller `safeErrorCode` 已将非 config/BrightClientError 变成 `runtime_error`engine 另有内部 `lastError` | 保留 engine 内部诊断和 `onetalk_history_*` 行为,不把它们新增为 Popup codecontroller 顶层仍是 Popup owner |
| labels are exhaustive and stable, no fallback | `errorLabels: Record<string,string>``配置或连接失败` fallback | 改为基于闭集的穷尽表;展示“最近错误:文案(错误码:code)”,未知 code 不进入 render |
| connection/error always visible in identity panel | connection status 在 hidden developer-detailserror 在 form bottom,无稳定 code | 只移动/调整 DOM placement and visibilitydeveloper details 保留 Bright URL development-only |
| last error does not auto-clear; same config remains no-op | controller onBeforeChange 清除;accepted 不清除;same config no-op 已存在 | 这些语义应写成 regression;不要引入 auth-success clear、manual reconnect、history list 或 error state machine |
#### Minimal implementation scope
Proposed single implementation scope: `POPUP-ERROR` / `@trade-message-center/chrome-extension` (`apps/chrome-extension`). It owns the following source/test range:
- `apps/chrome-extension/src/onetalk/config.ts`: Popup error union, shared error/code/response/status guards, type narrowing;复用 contract `OneTalkErrorCode`,不修改协议包。
- `apps/chrome-extension/src/onetalk/service-worker/bright-client.ts`: valid decoded `ws.error` 的稳定 error projection/`onError` 回调;保留现有 close/reconnect/profile 顺序与 browser-valid close code。
- `apps/chrome-extension/src/onetalk/service-worker/sync-controller.ts`: `safeErrorCode`/snapshot 顶层 error 类型收窄,unknown → `runtime_error`,保持 revision/config switch semantics。
- `apps/chrome-extension/popup/popup.ts`: import shared response/status guards;穷尽 label map、无 fallback、稳定 code/最近错误展示;三条既有入口继续复用 render path。
- `apps/chrome-extension/popup/popup.html`: identity panel 中 status/error 的固定顺序和 aria/role 保留;developer details 只保留 URL。
- `apps/chrome-extension/src/service-worker-entry.ts`: 当前逻辑已将 `snapshot.error` 复制到 status event;仅在新类型导致编译/安全形状需要时调整,不能新增第二份映射或改变广播去重/发送错误处理。
- Focused tests: `test/onetalk-config.test.js``test/onetalk-bright-client.test.js``test/onetalk-sync-runtime.test.js``test/popup-config-realtime.test.js``test/service-worker-entry-config.test.js``onetalk-sync-engine.test.js`/`onetalk-error-diagnostics.test.js` 只在回归需要时补充,不应扩大 Popup 契约。
No source write is required in `apps/onetalk-contract` or `apps/server`: the existing shared contract already defines/decodes all 12 server codes. `configured-sync-session.ts`, `sync-engine/lifecycle.ts`, and `page-runtime-host.ts` are interface dependencies and should not be edited unless type compilation proves an adapter change is unavoidable; their existing callback/revision/epoch behavior is the boundary to preserve.
Actual parallelism blocker: all proposed source writes converge on one Popup error/status contract and one snapshot path, so splitting the same package by file would create competing owners. There is no independent package scope in this task; `apps/onetalk-contract` and `apps/server` are read-only dependencies.
#### Behaviors that must not change
- Do not modify `OneTalkErrorCode`, `ws.error` wire shape, protocol version, server authorization, binding validation, storage format, IndexedDB, page routing, message ACK, sync completion, or permissions.
- Do not change deterministic close code policy, `1000` browser-valid close reason, `authorization_unavailable` auto-reconnect, six deterministic reconnect blocks, `profile_observed_at_future` no-close behavior, heartbeat, or manual reconnect/disconnect semantics.
- Do not clear controller lastError on `ws.accepted`; do not add automatic timeout/acknowledge/clear/history behavior. Config change/clear remains the only existing clear boundary; same config save remains no-op.
- Do not expose `Error.message`, stack, cause, raw frame, binding, credential, account/device identifiers, message content, scope, URL query/hash/userinfo, or diagnostic payload to Popup. Existing diagnostics remain development-safe projections and production code-only logging.
- Do not add fallback text for unknown Popup error codes. Unknown valid/forged status events are ignored by the shared guard; unknown internal exceptions are normalized to `runtime_error` before crossing Popup boundary.
- Do not make production visibility depend on `developer-details`; only Bright WebSocket URL remains development-only.
## Candidate Scopes And Dependencies
### POPUP-ERROR (`@trade-message-center/chrome-extension`)
- Deliverable: closed Popup-facing error contract; valid server/client/config errors projected into controller snapshot/status; exhaustive Chinese guidance with stable code; top identity-panel status/error presentation; focused tests.
- Owns: the source/test files listed in the minimal scope above, including error contract, runtime guard, Bright callback projection, controller top-level error, Popup rendering and layout.
- Excludes: `apps/onetalk-contract`/`apps/server` changes, wire protocol, server authorization, reconnect/sync/storage/page semantics, new persistent fields, browser permissions and unrelated diagnostics.
- Required interfaces: existing `OneTalkErrorCode`/`decodeOneTalkFrame`; `OneTalkConfiguredSyncSession` revision-gated `onError`; existing `service-worker-entry.broadcastStatus`; current Chrome runtime message API.
- Dependency: this scope must settle the shared union/guard and server-code projection before implementation; no other implementation scope may independently add error labels or status fields.
- Acceptance: all 7 PRD criteria; deterministic 12-code ws.error matrix; configuration/unknown/sensitive shape guard matrix; clear/no-clear/epoch matrix; production/development layout and visibility assertions; focused package tests and repository quality commands.
## Invariants and Acceptance Probes
### Invariant owners and side-effect boundaries
| Invariant | Owner | Source of truth / boundary |
| --- | --- | --- |
| Popup accepts only declared non-sensitive error codes | `config.ts` | `OneTalkPopupErrorCode` derived from 7 config response codes + storage/sender + Bright client codes + shared 12 server codes + `runtime_error`; guard runs at response/status ingress |
| Server code is safe but does not alter connection policy | `bright-client.ts` | shared decoder validates code; Bright maps code to `onError` only; existing `ws.error` block/close/reconnect/profile policy remains the side-effect owner |
| Popup recent error is one latest code | `sync-controller.ts` | controller closure `lastError` and `snapshot.error`; clear only through existing `onBeforeChange`; `engineStatus.lastError` stays diagnostic/internal and is not a second Popup source |
| Configuration replacement cannot be overwritten by old async work | `configured-sync-session.ts` + `page-runtime-host.ts` | session revision plus page/config/connection/page epoch tokens; no new fallback or callback race guard should bypass these checks |
| Popup presentation is stable across build modes | `popup.html`/`popup.ts` | identity panel is normal UI; `developer-details` visibility only controls Bright URL; Vite mode/source-map boundary remains in `vite.config.ts` |
| Diagnostics remain safe | existing `error-diagnostics.ts` / `service-worker-entry.ts` | dev gets finite projection, production gets prefix/code only; Popup gets code/text table only |
Async/irreversible boundaries to test explicitly: WebSocket message event → decode → `onFrame`/profile side effect → `onError`/status projection → close/reconnect; profile `discardPendingProfile` Promise; `configure` dispose/disconnect and revision capture; `pageHost.replayTo` awaits; `chrome.runtime.sendMessage` status broadcast Promise; Popup `sendMessage`/`executeScript` Promise. No code change should move a close, dispose, IndexedDB write, ACK, or reconnect across these boundaries.
### Local no-service/static probes
These probes use fake sockets/stores or read source/config files and do not require a listening port, server, database, container, external account, or real Bright authorization:
1. Focused extension tests:
```bash
pnpm --filter @trade-message-center/chrome-extension test
```
Expected evidence: config guard/validation, fake Bright lifecycle and ws.error matrix, controller replacement/epoch behavior, Popup HTML/source contract, Service Worker status dedupe. Package `pretest` builds `@trade-message-center/onetalk-contract`, so it writes generated contract `dist`; this was not run during research because the user restricted writes to the task research directory.
2. For a no-pretest focused run when the contract `dist` is already available:
```bash
node --experimental-strip-types --test \
apps/chrome-extension/test/onetalk-config.test.js \
apps/chrome-extension/test/onetalk-bright-client.test.js \
apps/chrome-extension/test/onetalk-sync-runtime.test.js \
apps/chrome-extension/test/popup-config-realtime.test.js \
apps/chrome-extension/test/service-worker-entry-config.test.js
```
Expected additions for this task: every allowed config/client/server/runtime code has deterministic guard/label evidence; forged unknown status/response code is rejected or ignored; valid `ws.error` reaches controller snapshot; profile future error keeps socket open; config switch clears recent error; accepted does not; stale revision cannot overwrite.
3. Static/type/build/repo gates required after implementation (not run during research):
```bash
pnpm format:check
pnpm typecheck
pnpm build
pnpm test
git diff --check
```
`pnpm typecheck`/`build`/root `test` invoke version/contract/generated-dist steps per root and package manifests; they are no-service in the protocol sense but may write generated artifacts. Build proof should inspect both development and production Popup outputs: stable status/error visible in both, Bright URL only in development details, and no raw sensitive data in source/compiled payload.
4. Deterministic mutation matrix for implementer/checker:
- Config response: 7 config codes + `storage_unavailable` + `sender_not_allowed`; success with each client/server/runtime code; missing error hides; unknown code and extra `binding`/raw error fields fail guard.
- Bright `ws.error`: all 12 codes; six block reconnect and become unauthorized; five transient/non-blocking codes close with 1000 and offline/auto-reconnect; `profile_observed_at_future` keeps authenticated socket and discards only matching profile pending. Assert callback code, close reason, timer/reconnect count, and profile request map independently.
- Controller: known config/Bright/server code, unknown Error/string/object → expected Popup code or `runtime_error`; config change/clear clears only controller recent error; accepted does not; same config has no new client; old revision callback is ignored; engine internal lastError remains separate.
- Popup: response get/save/clear and status event all reach the same explicit label function; code and “最近错误” are visible in both modes; DOM order is Build Hash → connection → recent error → Binding; unknown event is ignored; developer section contains URL only.
### Integration-only probes and environment
No integration-only service/port/database/container probe is required for the requested contract/UI change, and none was run. A later real-browser smoke test would require a built extension loaded in Chrome, a running Bright WebSocket endpoint with valid authorization/binding and a OneTalk page; it would be needed only to prove actual Chrome event delivery/DOM visibility and real reconnect behavior, not to replace the local contract tests. No external account or endpoint was accessed for this research.
## Files found
- `apps/chrome-extension/src/onetalk/config.ts` — configuration storage model, request/response/status types, config validation/storage, two shared config guards.
- `apps/chrome-extension/src/onetalk/service-worker/bright-client.ts` — Bright socket creation, frame decoder boundary, ws.error policy, state/reconnect/profile frame delivery.
- `apps/chrome-extension/src/onetalk/service-worker/sync-controller.ts` — controller-level lastError, safeErrorCode, redacted snapshot, config/revision callbacks.
- `apps/chrome-extension/src/onetalk/service-worker/configured-sync-session.ts` — active Bright/engine/profile session replacement and revision isolation.
- `apps/chrome-extension/src/onetalk/service-worker/sync-engine.ts` — Bright frame routing to anchor/ACK handlers.
- `apps/chrome-extension/src/onetalk/service-worker/sync-engine/lifecycle.ts` — engine-local lastError, connection/snapshot/page epochs and status projection.
- `apps/chrome-extension/src/onetalk/service-worker/sync-engine/helpers.ts` — engine stable internal error code set.
- `apps/chrome-extension/src/onetalk/service-worker/contact-profile-coordinator.ts` — profile ws.error future-time handling and status flush/clear.
- `apps/chrome-extension/src/onetalk/service-worker/page-runtime-host.ts` — configuration/connection/page token validation for async page/profile callbacks.
- `apps/chrome-extension/src/service-worker-entry.ts` — controller wiring, status event construction/dedupe/broadcast, config message handling and safe logging.
- `apps/chrome-extension/popup/popup.ts` — Popup response guard, error fallback, three response/status rendering routes, build-mode details visibility.
- `apps/chrome-extension/popup/popup.html` — current identity/error/developer-details DOM order and ARIA attributes.
- `apps/chrome-extension/src/onetalk/build-config.ts` — development mode and Bright URL build inputs.
- `apps/chrome-extension/vite.config.ts` — independent Popup/service-worker inputs and dev/prod build boundaries.
- `apps/chrome-extension/manifest.template.json` — Popup, module service worker, scripting/storage permissions.
- `apps/onetalk-contract/src/model.ts` — shared 12-code server error union and ws.error frame shape.
- `apps/onetalk-contract/src/decoder.ts` — runtime closed-set server code/frame decoder.
- `apps/chrome-extension/src/onetalk/service-worker/error-diagnostics.ts` — finite safe development projection and production code-only logging.
- `apps/chrome-extension/test/onetalk-config.test.js` — config guard/storage/validation coverage.
- `apps/chrome-extension/test/popup-config-realtime.test.js` — static Popup layout/build/status listener coverage.
- `apps/chrome-extension/test/onetalk-bright-client.test.js` — fake Bright socket/auth/ws.error/reconnect coverage.
- `apps/chrome-extension/test/onetalk-sync-runtime.test.js` — controller/session replacement and stale callback coverage.
- `apps/chrome-extension/test/service-worker-entry-config.test.js` — Service Worker sender/status/error-log coverage.
- `apps/chrome-extension/test/onetalk-sync-engine.test.js` — engine-local history error coverage.
- `apps/chrome-extension/test/onetalk-error-diagnostics.test.js` — safe error projection/logging coverage.
- `package.json` and `apps/chrome-extension/package.json` — package manager, Node range, test/typecheck/build commands.
- `.trellis/spec/chrome-extension/frontend/onetalk/runtime-diagnostics.md` — snapshot/error diagnostic/build-mode contract.
- `.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md` — cross-layer ownership and diagnostic boundary.
- `.trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md` — connection lifecycle and durable/error boundary.
- `.trellis/spec/chrome-extension/frontend/type-safety.md` — runtime guard and no-local-cast rules.
- `.trellis/tasks/09-07-popup-internal-error-presentation/{prd,design,implement}.md` — current task requirements/design/implementation plan.
## Related specs and external/version references
- Related specs: `.trellis/spec/chrome-extension/frontend/index.md`, `architecture.md`, `type-safety.md`, `onetalk/runtime-sync.md`, `onetalk/runtime-diagnostics.md`, `onetalk/durable-sync.md`, `quality-guidelines.md`; shared thinking guides `cross-layer-thinking-guide.md` and `code-reuse-thinking-guide.md`.
- Version evidence: root `package.json` declares `pnpm@11.7.0` and Node `>=22.22.2 <23`; extension uses TypeScript/Vite package versions from `apps/chrome-extension/package.json`; protocol `ONETALK_PROTOCOL_VERSION = 3` and `OneTalkErrorCode` are current repository values, not external assumptions.
- No web/external references were needed or accessed. Actual Bright server runtime emission, Chrome delivery timing and account authorization remain external/unverified boundaries; the local shared decoder and existing fake-socket tests are the available evidence.
## Caveats / Not Found
- GitNexus index is stale by 9 commits and has a duplicate same-name repo entry; its query/context/process results were used only as navigation hints. Current source line numbers and tests are authoritative.
- No existing `OneTalkPopupErrorCode`, `isOneTalkPopupErrorCode`, shared `isOneTalkConfigMessageResponse`, or exhaustive Popup label map was found.
- No real DOM test harness for `popup.html`/`popup.ts` was found; current Popup coverage reads source/HTML text only.
- No current test sends a valid Bright `ws.error` through the real `createOneTalkServiceWorkerSyncController` and observes `snapshot.error`/status broadcast; this is the central missing regression.
- `service-worker-entry.broadcastStatus` already copies top-level `snapshot.error` and deduplicates by status/error. Changing its mapping there would create a second projection owner; source change should be unnecessary unless type narrowing exposes a concrete compile issue.
- `engineStatus.lastError` and controller `snapshot.error` are intentionally different current fields. The task plan says unknown sync/page/runtime faults should become Popup `runtime_error`; it does not authorize promoting all engine stable/error strings into the Popup union. This distinction needs to remain explicit in implementation/check review.
- Focused tests were not executed because package pretest/build commands can write generated `dist` outside the permitted research directory; no runtime/service claim is made.
@@ -3,16 +3,16 @@
"name": "popup-internal-error-presentation",
"title": "明确 Popup 内部错误展示",
"description": "为 Popup 内收到的内部错误建立明确、可恢复的用户可见提示。",
"status": "planning",
"status": "in_progress",
"dev_type": null,
"scope": null,
"scope": "chrome-extension",
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-07",
"completedAt": null,
"branch": null,
"branch": "09-07-popup-internal-error-presentation",
"base_branch": "main",
"worktree_path": null,
"commit": null,
@@ -0,0 +1,132 @@
version: 1
task_path: /Users/ybf/work/trade-message-center/.trellis/tasks/09-07-popup-internal-error-presentation
coordination_checkout: /Users/ybf/work/trade-message-center
initial_dirty_paths:
- .trellis/tasks/09-07-popup-internal-error-presentation/check.jsonl
- .trellis/tasks/09-07-popup-internal-error-presentation/implement.jsonl
- .trellis/tasks/09-07-popup-internal-error-presentation/implement.md
- .trellis/tasks/09-07-popup-internal-error-presentation/task.json
- .trellis/tasks/09-07-popup-internal-error-presentation/research/current-popup-error-flow.md
- .trellis/tasks/09-07-popup-internal-error-presentation/research/current-popup-error-flow-summary.md
accepted_base: 5794192c23c0a17f4221edddadb03d250c85357d
git_common_dir: /Users/ybf/work/trade-message-center/.git
integration:
path: /private/tmp/trade-message-center-popup-error-integration
creation_base: 5794192c23c0a17f4221edddadb03d250c85357d
integration_ref: 09-07-popup-internal-error-presentation-integration
role: integration
creation_command: git worktree add -b 09-07-popup-internal-error-presentation-integration /private/tmp/trade-message-center-popup-error-integration 5794192c23c0a17f4221edddadb03d250c85357d
creation_result: HEAD is now at 5794192 chore(trellis): add popup error presentation plan
verification: git worktree list --porcelain; git -C /private/tmp/trade-message-center-popup-error-integration rev-parse --show-toplevel; rev-parse --git-common-dir; rev-parse HEAD; status --short --branch
verified_root: /private/tmp/trade-message-center-popup-error-integration
verified_head: 5794192c23c0a17f4221edddadb03d250c85357d
verified_status: clean
lifecycle: removed_after_reclaim
removed_by: main_session_on_user_request
removal_command: git worktree remove /private/tmp/trade-message-center-popup-error-integration
scope_lanes:
POPUP-ERROR:
path: /private/tmp/trade-message-center-popup-error-scope
creation_base: 5794192c23c0a17f4221edddadb03d250c85357d
integration_ref: 09-07-popup-internal-error-presentation-scope
role: scope
owns:
- apps/chrome-extension/src/onetalk/config.ts
- apps/chrome-extension/src/onetalk/service-worker/bright-client.ts
- apps/chrome-extension/src/onetalk/service-worker/sync-controller.ts
- apps/chrome-extension/popup/popup.ts
- apps/chrome-extension/popup/popup.html
- apps/chrome-extension/src/service-worker-entry.ts
- apps/chrome-extension/test/onetalk-config.test.js
- apps/chrome-extension/test/onetalk-bright-client.test.js
- apps/chrome-extension/test/onetalk-sync-runtime.test.js
- apps/chrome-extension/test/popup-config-realtime.test.js
- apps/chrome-extension/test/service-worker-entry-config.test.js
excludes:
- apps/onetalk-contract/**
- apps/server/**
- apps/chrome-extension/src/onetalk/service-worker/configured-sync-session.ts
- apps/chrome-extension/src/onetalk/service-worker/sync-engine/**
- apps/chrome-extension/src/onetalk/service-worker/page-runtime-host.ts
- package.json
- pnpm-lock.yaml
depends_on: []
creation_command: git worktree add -b 09-07-popup-internal-error-presentation-scope /private/tmp/trade-message-center-popup-error-scope 5794192c23c0a17f4221edddadb03d250c85357d
creation_result: HEAD is now at 5794192 chore(trellis): add popup error presentation plan
verification: git worktree list --porcelain; git -C /private/tmp/trade-message-center-popup-error-scope rev-parse --show-toplevel; rev-parse --git-common-dir; rev-parse HEAD; status --short --branch
verified_root: /private/tmp/trade-message-center-popup-error-scope
verified_head: 5794192c23c0a17f4221edddadb03d250c85357d
verified_status: clean
lifecycle: removed_after_reclaim
removed_by: main_session_on_user_request
removal_command: git worktree remove --force /private/tmp/trade-message-center-popup-error-scope
workers:
- id: 01a07ad8-8704-7423-b43b-67c6f7443860
role: trellis-research
scope: POPUP-ERROR
worktree: /Users/ybf/work/trade-message-center
state: closed
report: research/current-popup-error-flow.md and summary created; no source changes
- id: 01a07ae3-d0f2-7a30-af68-0696ac0ff1d1
role: trellis-implement
scope: POPUP-ERROR
worktree: /private/tmp/trade-message-center-popup-error-scope
state: closed
report: implementer terminal report in conversation; no commit, no Git index changes
dependency_setup: pnpm install --offline --frozen-lockfile --ignore-scripts; contract build completed
repair_round_1: POPUP-ERROR-C1 repaired; explicit OneTalkErrorCode narrowing in config.ts, scoped tests/typecheck passed
repair_round_2: POPUP-ERROR-D1 repaired; entry preserves validated Bright/config error codes in safe diagnostics
- id: 01a07af0-4490-7a02-af04-aa2058a61345
role: trellis-check
scope: POPUP-ERROR
worktree: /private/tmp/trade-message-center-popup-error-scope
state: shutdown
check_type: package
decision: reopen:POPUP-ERROR
finding: POPUP-ERROR-C1; config.ts:134 includes argument requires explicit OneTalkErrorCode narrowing
report: checker terminal report in conversation; focused 44, full extension 271, profile/diagnostics 26 passed; strict typecheck failed
revalidation_state: superseded_by_user_requested_rebuild
- id: 01a07b01-3b48-7f51-9dda-cf64601e28b0
role: trellis-check
scope: POPUP-ERROR
worktree: /private/tmp/trade-message-center-popup-error-scope
state: closed
check_type: package
revalidates: POPUP-ERROR-C1
replaces: 01a07af0-4490-7a02-af04-aa2058a61345
decision: reopen:POPUP-ERROR
report: replacement checker terminal report in conversation; C1 fixed, package checks passed, D1 retained for repair
revalidation_state: regressed; POPUP-ERROR-D1 remains open
final_finding: POPUP-ERROR-D1; oneTalkEntryDiagnosticErrorCode returns unknown_code for a malformed BrightClientError instead of runtime_error
rework:
POPUP-ERROR:
rounds:
- round: 1
finding_ids:
- POPUP-ERROR-C1
implementer_context: available; used_percentage: 32.21
reuse_decision: reuse same implementer because usage was below 50 percent and responsibility was unchanged
result: accepted by replacement checker
- round: 2
finding_ids:
- POPUP-ERROR-D1
implementer_context: available; used_percentage: 36.29
reuse_decision: reuse same implementer because usage was below 50 percent and responsibility was unchanged
result: unsuccessful; same-checker revalidation found POPUP-ERROR-D1 regression
forced_stop: true
forced_stop_reason: second repair unsuccessful; Trellis permits no third repair dispatch for this convergence target
integration_status:
state: not_started
reason: POPUP-ERROR-D1 remains open; scope code was reclaimed to the coordination checkout
reclaim:
target: coordination_checkout
source_scope: /private/tmp/trade-message-center-popup-error-scope
source_integration: /private/tmp/trade-message-center-popup-error-integration
accepted_base: 5794192c23c0a17f4221edddadb03d250c85357d
source_diff_sha256: 249e6aff3ee368c85e17b60685bbf06cbfc18ec37bd34363d71a5ff26720b32c
reclaimed_diff_sha256: 249e6aff3ee368c85e17b60685bbf06cbfc18ec37bd34363d71a5ff26720b32c
application: plain git apply after successful git apply --check; no three-way object writes
result: code and tests restored as uncommitted changes in coordination checkout
branches_preserved:
- 09-07-popup-internal-error-presentation-integration
- 09-07-popup-internal-error-presentation-scope
+2 -2
View File
@@ -294,6 +294,8 @@
<span class="identity-label">Build Hash</span>
<output id="build-hash" aria-live="polite">正在读取…</output>
</div>
<p id="connection-status" class="muted" role="status">正在读取连接状态…</p>
<p id="error" class="error" role="alert" hidden></p>
<form id="configuration-form">
<label for="binding">
Binding
@@ -311,7 +313,6 @@
清除配置
</button>
</div>
<p id="error" class="error" role="alert" hidden></p>
</form>
</div>
</section>
@@ -319,7 +320,6 @@
<section id="developer-details" aria-labelledby="onetalk-heading" hidden>
<p class="section-kicker">Transport / 02</p>
<h2 id="onetalk-heading">OneTalk Bright 连接</h2>
<p id="connection-status" class="muted" role="status">正在读取连接状态…</p>
<p class="field-label">Bright WebSocket URL</p>
<output id="bright-websocket-url" class="config-value" aria-live="polite">
正在读取…
+36 -28
View File
@@ -4,6 +4,8 @@ import {
type OneTalkConfigMessage,
type OneTalkConfigMessageResponse,
type OneTalkExtensionConfigView,
type OneTalkPopupErrorCode,
isOneTalkConfigMessageResponse,
isOneTalkConfigStatusEvent,
} from "../src/onetalk/config.ts";
import { BRIGHT_WEBSOCKET_URL, IS_ONE_TALK_DEVELOPMENT_MODE } from "../src/onetalk/build-config.ts";
@@ -97,42 +99,44 @@ const connectionStatusLabels: Record<string, string> = {
idle: "连接状态:未连接",
};
const errorLabels: Record<string, string> = {
invalid_configuration: "配置格式无效",
invalid_configuration_version: "配置版本无效",
invalid_bright_websocket_url: "Bright WebSocket URL 必须使用 ws 或 wss 协议",
missing_bright_websocket_url: "请填写 Bright WebSocket URL",
missing_channel_account_id: "请先打开 OneTalk 登录页面",
missing_device_id: "请填写 device ID",
missing_binding: "请填写 binding",
storage_unavailable: "扩展存储暂不可用",
const errorLabels: Record<OneTalkPopupErrorCode, string> = {
invalid_configuration: "配置格式无效,请重新保存配置。",
invalid_configuration_version: "已保存的配置版本不受支持,请重新保存配置。",
invalid_bright_websocket_url: "Bright 服务地址配置无效,请联系管理员。",
missing_bright_websocket_url: "未注入 Bright 服务地址,请重新安装扩展或联系管理员。",
missing_channel_account_id: "请在已登录的 OneTalk 页面打开 Popup 后保存。",
missing_device_id: "扩展设备标识不可用,请重新加载扩展。",
missing_binding: "请填写 binding 后保存。",
storage_unavailable: "无法访问扩展本地存储,请检查浏览器权限后重试。",
sender_not_allowed: "Popup 与扩展后台通信被拒绝,请关闭后重新打开 Popup。",
websocket_unavailable: "无法连接 Bright 服务,正在尝试重新连接。",
invalid_message: "扩展与 Bright 的通信数据异常,请重新加载扩展;若持续出现请联系技术支持。",
scope_mismatch: "扩展身份与当前连接不一致,请重新打开 OneTalk 后保存配置。",
send_failed: "向 Bright 发送数据失败,请检查网络和服务状态。",
invalid_time: "系统时间异常,请校准系统时间后重试。",
runtime_error: "扩展运行时发生异常,请重新加载扩展;若持续出现请联系技术支持。",
auth_required: "服务端要求重新授权,请检查 binding 后重新保存配置。",
authorization_rejected: "当前 binding 未获所需授权,请联系管理员开通权限或更新 binding。",
authorization_unavailable: "暂时无法读取授权状态,请稍后重试。",
authorization_version_changed: "授权配置已变更,请重新保存配置。",
binding_revoked: "当前 binding 已失效,请更新 binding 后重新保存。",
database_unavailable: "Bright 服务暂时不可用,请稍后重试。",
plugin_offline: "插件当前不在线,请重新打开 OneTalk 页面后重试。",
profile_observed_at_future: "检测到资料时间异常,请校准系统时间后重试。",
onetalk_protocol_upgrade_required: "扩展版本过旧,请更新扩展。",
unknown_request: "当前扩展版本不支持该服务请求,请更新扩展。",
};
let channelAccountIdValue: string | null = null;
let deviceIdValue: string | null = null;
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
const isConfigResponse = (value: unknown): value is OneTalkConfigMessageResponse => {
if (!isRecord(value) || typeof value.ok !== "boolean") return false;
if (!value.ok) return typeof value.code === "string";
return (
(value.config === null || isRecord(value.config)) &&
typeof value.deviceId === "string" &&
typeof value.connectionStatus === "string" &&
Array.isArray(value.permissions)
);
};
const showError = (code: string | undefined): void => {
const showError = (code: OneTalkPopupErrorCode | undefined): void => {
if (!code) {
error.textContent = "";
error.hidden = true;
return;
}
error.textContent = errorLabels[code] ?? "配置或连接失败";
error.textContent = `最近错误:${errorLabels[code]}(错误码:${code}`;
error.hidden = false;
};
@@ -194,7 +198,10 @@ const renderResponse = (response: OneTalkConfigMessageResponse): void => {
showError(response.error);
};
const renderStatusEvent = (event: { connectionStatus: string; error?: string }): void => {
const renderStatusEvent = (event: {
connectionStatus: string;
error?: OneTalkPopupErrorCode;
}): void => {
brightConnectionStatus.textContent =
connectionStatusLabels[event.connectionStatus] ?? "连接状态:未知";
showError(event.error);
@@ -204,7 +211,8 @@ const sendConfigurationMessage = async (
message: OneTalkConfigMessage,
): Promise<OneTalkConfigMessageResponse> => {
const response = await chromeApi.runtime.sendMessage(message);
if (!isConfigResponse(response)) throw new Error("invalid_configuration_response");
if (!isOneTalkConfigMessageResponse(response))
throw new Error("invalid_configuration_response");
return response;
};
+107 -4
View File
@@ -1,5 +1,7 @@
// 定义 OneTalk 扩展配置与存储边界
import { ONETALK_ERROR_CODES, type OneTalkErrorCode } from "@trade-message-center/onetalk-contract";
export const ONE_TALK_EXTENSION_CONFIG_KEY = "trade-message-center.onetalk.config";
export const ONE_TALK_DEVICE_ID_KEY = "trade-message-center.onetalk.device-id";
export const ONE_TALK_EXTENSION_CONFIG_VERSION = 1 as const;
@@ -24,6 +26,21 @@ export type OneTalkExtensionConfigValidationCode =
| "missing_device_id"
| "missing_binding";
export type OneTalkPopupErrorCode =
| OneTalkExtensionConfigValidationCode
| "storage_unavailable"
| "sender_not_allowed"
| "websocket_unavailable"
| "send_failed"
| "invalid_time"
| "runtime_error"
| OneTalkErrorCode;
export type OneTalkConfigMessageErrorCode = Exclude<
OneTalkPopupErrorCode,
OneTalkErrorCode | "websocket_unavailable" | "send_failed" | "invalid_time" | "runtime_error"
>;
export type OneTalkExtensionConfigValidationResult =
| { ok: true; config: OneTalkExtensionConfig }
| { ok: false; code: OneTalkExtensionConfigValidationCode };
@@ -51,17 +68,17 @@ export type OneTalkConfigMessageResponse =
connectionStatus: string;
authorizationVersion?: string;
permissions: string[];
error?: string;
error?: OneTalkPopupErrorCode;
}
| {
ok: false;
code: OneTalkExtensionConfigValidationCode | "storage_unavailable" | "sender_not_allowed";
code: OneTalkConfigMessageErrorCode;
};
export type OneTalkConfigStatusEvent = {
type: "onetalk.config.status";
connectionStatus: string;
error?: string;
error?: OneTalkPopupErrorCode;
};
export class OneTalkExtensionConfigError extends Error {
@@ -82,6 +99,58 @@ const isNonEmptyString = (value: unknown): value is string => {
return typeof value === "string" && value.trim().length > 0;
};
const ONE_TALK_POPUP_ERROR_CODES = new Set<OneTalkPopupErrorCode>([
"invalid_configuration",
"invalid_configuration_version",
"invalid_bright_websocket_url",
"missing_bright_websocket_url",
"missing_channel_account_id",
"missing_device_id",
"missing_binding",
"storage_unavailable",
"sender_not_allowed",
"websocket_unavailable",
"send_failed",
"invalid_time",
"runtime_error",
...Object.values(ONETALK_ERROR_CODES),
]);
export const isOneTalkPopupErrorCode = (value: unknown): value is OneTalkPopupErrorCode => {
return (
typeof value === "string" && ONE_TALK_POPUP_ERROR_CODES.has(value as OneTalkPopupErrorCode)
);
};
const isOneTalkConfigMessageErrorCode = (
value: unknown,
): value is OneTalkConfigMessageErrorCode => {
return (
isOneTalkPopupErrorCode(value) &&
value !== "websocket_unavailable" &&
value !== "send_failed" &&
value !== "invalid_time" &&
value !== "runtime_error" &&
!Object.values(ONETALK_ERROR_CODES).includes(value as OneTalkErrorCode)
);
};
const isOneTalkExtensionConfigView = (value: unknown): value is OneTalkExtensionConfigView => {
return (
isRecord(value) &&
Object.keys(value).every((key) =>
["version", "brightWebSocketUrl", "channelAccountId", "deviceId", "binding"].includes(
key,
),
) &&
value.version === ONE_TALK_EXTENSION_CONFIG_VERSION &&
isNonEmptyString(value.brightWebSocketUrl) &&
isNonEmptyString(value.channelAccountId) &&
isNonEmptyString(value.deviceId) &&
value.binding === ONE_TALK_MASKED_BINDING
);
};
const ONE_TALK_DEVICE_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const ONE_TALK_LEGACY_DEVICE_ID_PATTERN = /^[0-9a-f]{32}$/;
@@ -348,6 +417,40 @@ export const isOneTalkConfigMessage = (value: unknown): value is OneTalkConfigMe
return value.type === "onetalk.config.save" && isRecord(value.config);
};
/** 判断来自 Service Worker 的安全配置响应。 */
export const isOneTalkConfigMessageResponse = (
value: unknown,
): value is OneTalkConfigMessageResponse => {
if (!isRecord(value) || typeof value.ok !== "boolean") return false;
if (!value.ok) {
return (
Object.keys(value).every((key) => key === "ok" || key === "code") &&
isOneTalkConfigMessageErrorCode(value.code)
);
}
return (
Object.keys(value).every((key) =>
[
"ok",
"config",
"deviceId",
"connectionStatus",
"authorizationVersion",
"permissions",
"error",
].includes(key),
) &&
(value.config === null || isOneTalkExtensionConfigView(value.config)) &&
typeof value.deviceId === "string" &&
isNonEmptyString(value.connectionStatus) &&
(value.authorizationVersion === undefined ||
typeof value.authorizationVersion === "string") &&
Array.isArray(value.permissions) &&
value.permissions.every((permission) => typeof permission === "string") &&
(value.error === undefined || isOneTalkPopupErrorCode(value.error))
);
};
/** 判断来自 Service Worker 的安全连接状态事件。 */
export const isOneTalkConfigStatusEvent = (value: unknown): value is OneTalkConfigStatusEvent => {
if (!isRecord(value) || value.type !== "onetalk.config.status") return false;
@@ -356,6 +459,6 @@ export const isOneTalkConfigStatusEvent = (value: unknown): value is OneTalkConf
(key) => key === "type" || key === "connectionStatus" || key === "error",
) &&
isNonEmptyString(value.connectionStatus) &&
(value.error === undefined || typeof value.error === "string")
(value.error === undefined || isOneTalkPopupErrorCode(value.error))
);
};
@@ -13,6 +13,7 @@ import {
type OneTalkFrame,
type OneTalkObservedMessage,
type OneTalkContactProfile,
type OneTalkErrorCode,
type OneTalkPermission,
type OneTalkSyncAnomalyCode,
type OneTalkSyncMode,
@@ -125,7 +126,8 @@ export class OneTalkBrightClientError extends Error {
| "scope_mismatch"
| "websocket_unavailable"
| "send_failed"
| "invalid_time";
| "invalid_time"
| OneTalkErrorCode;
public constructor(code: OneTalkBrightClientError["code"], cause?: unknown) {
super(code, cause === undefined ? undefined : { cause });
@@ -690,6 +692,7 @@ export const createOneTalkBrightClient = (
}
if (frame.type === "ws.error") {
report(new OneTalkBrightClientError(frame.payload.code));
if (shouldBlockReconnect(frame.payload.code)) reconnectBlocked = true;
if (
frame.payload.code !== ONETALK_ERROR_CODES.profileObservedAtFuture &&
@@ -9,9 +9,11 @@ import {
} from "./bright-client.ts";
import {
OneTalkExtensionConfigError,
isOneTalkPopupErrorCode,
redactOneTalkExtensionConfig,
type OneTalkExtensionConfig,
type OneTalkExtensionConfigView,
type OneTalkPopupErrorCode,
} from "../config.ts";
import { OneTalkPageRuntimeHost } from "./page-runtime-host.ts";
import { OneTalkConfiguredSyncSession } from "./configured-sync-session.ts";
@@ -32,7 +34,7 @@ export type OneTalkServiceWorkerSyncSnapshot = {
authorizationVersion?: string;
permissions: string[];
engineStatus: OneTalkSyncEngineStatus | null;
error?: string;
error?: OneTalkPopupErrorCode;
};
export type OneTalkServiceWorkerSyncControllerOptions = {
@@ -53,31 +55,24 @@ export type OneTalkServiceWorkerSyncControllerOptions = {
export type OneTalkServiceWorkerSyncController = {
runtime: OneTalkServiceWorkerRuntime;
configure: (config: OneTalkExtensionConfig | null) => Promise<void>;
setConfigurationError: (code: string) => void;
setConfigurationError: (code: OneTalkPopupErrorCode) => void;
getSnapshot: () => OneTalkServiceWorkerSyncSnapshot;
};
const KNOWN_CONFIGURATION_ERRORS = new Set([
"invalid_configuration",
"invalid_configuration_version",
"invalid_bright_websocket_url",
"missing_bright_websocket_url",
"missing_channel_account_id",
"missing_device_id",
"missing_binding",
"storage_unavailable",
]);
const safeErrorCode = (error: unknown): string => {
if (error instanceof OneTalkExtensionConfigError) return error.code;
if (error instanceof OneTalkBrightClientError) return error.code;
const safeErrorCode = (error: unknown): OneTalkPopupErrorCode => {
if (error instanceof OneTalkExtensionConfigError && isOneTalkPopupErrorCode(error.code)) {
return error.code;
}
if (error instanceof OneTalkBrightClientError && isOneTalkPopupErrorCode(error.code)) {
return error.code;
}
return "runtime_error";
};
const snapshotFor = (
config: OneTalkExtensionConfig | null,
active: { bright: OneTalkBrightClient; engine: OneTalkSyncEngine } | null,
lastError: string | undefined,
lastError: OneTalkPopupErrorCode | undefined,
): OneTalkServiceWorkerSyncSnapshot => {
if (!config || !active) {
return {
@@ -105,7 +100,7 @@ const snapshotFor = (
export const createOneTalkServiceWorkerSyncController = (
options: OneTalkServiceWorkerSyncControllerOptions = {},
): OneTalkServiceWorkerSyncController => {
let lastError: string | undefined;
let lastError: OneTalkPopupErrorCode | undefined;
let session: OneTalkConfiguredSyncSession | null = null;
const reportError = (error: unknown): void => {
lastError = safeErrorCode(error);
@@ -169,8 +164,8 @@ export const createOneTalkServiceWorkerSyncController = (
session!.connectCurrent();
};
const setConfigurationError = (code: string): void => {
lastError = KNOWN_CONFIGURATION_ERRORS.has(code) ? code : "runtime_error";
const setConfigurationError = (code: OneTalkPopupErrorCode): void => {
lastError = isOneTalkPopupErrorCode(code) ? code : "runtime_error";
notifyStatus();
};
@@ -4,6 +4,7 @@ import {
clearOneTalkExtensionConfig,
isMaskedOneTalkBinding,
isOneTalkConfigMessage,
isOneTalkPopupErrorCode,
loadOrCreateOneTalkDeviceId,
loadOneTalkExtensionConfig,
ONE_TALK_EXTENSION_CONFIG_KEY,
@@ -13,6 +14,7 @@ import {
type OneTalkConfigMessageResponse,
type OneTalkExtensionConfig,
type OneTalkExtensionStorageArea,
type OneTalkPopupErrorCode,
} from "./onetalk/config.ts";
import {
createOneTalkServiceWorkerSyncController,
@@ -24,7 +26,10 @@ import type { OneTalkServiceWorkerPort } from "./onetalk/service-worker/runtime.
import { BRIGHT_WEBSOCKET_URL, ENABLE_ONE_TALK_DIAGNOSTICS } from "./onetalk/build-config.ts";
import type { OneTalkPageDiagnostic } from "./onetalk/service-worker/runtime.ts";
import type { OneTalkSyncEngineDiagnostic } from "./onetalk/service-worker/sync-engine.ts";
import type { OneTalkBrightDiagnostic } from "./onetalk/service-worker/bright-client.ts";
import {
OneTalkBrightClientError,
type OneTalkBrightDiagnostic,
} from "./onetalk/service-worker/bright-client.ts";
import type { OneTalkContactProfileDiagnostic } from "./onetalk/service-worker/contact-profile-coordinator.ts";
declare const chrome: {
@@ -38,7 +43,7 @@ declare const chrome: {
listener: (
message: unknown,
sender: unknown,
) => Promise<OneTalkConfigMessageResponseWithEngineStatus>,
) => Promise<OneTalkConfigMessageResponse>,
): void;
};
sendMessage: (message: OneTalkConfigStatusEvent) => Promise<unknown>;
@@ -53,12 +58,6 @@ declare const chrome: {
};
};
type OneTalkConfigMessageResponseWithEngineStatus =
| (Extract<OneTalkConfigMessageResponse, { ok: true }> & {
engineStatus: OneTalkServiceWorkerSyncSnapshot["engineStatus"];
})
| Extract<OneTalkConfigMessageResponse, { ok: false }>;
type OneTalkDiagnostic =
| OneTalkBrightDiagnostic
| OneTalkPageDiagnostic
@@ -86,6 +85,17 @@ const reportStatusBroadcastError = (error: unknown): void => {
logOneTalkError(error, "runtime_error", ENABLE_ONE_TALK_DIAGNOSTICS, console.error);
};
export const oneTalkEntryDiagnosticErrorCode = (error: unknown): OneTalkPopupErrorCode => {
if (
(error instanceof OneTalkExtensionConfigError ||
error instanceof OneTalkBrightClientError) &&
isOneTalkPopupErrorCode(error.code)
) {
return error.code;
}
return "runtime_error";
};
let lastBroadcastStatus: OneTalkConfigStatusEvent | undefined;
const broadcastStatus = (snapshot: OneTalkServiceWorkerSyncSnapshot): void => {
@@ -127,8 +137,12 @@ const controller: OneTalkServiceWorkerSyncController = createOneTalkServiceWorke
});
},
onError: (error: unknown) => {
const code = error instanceof OneTalkExtensionConfigError ? error.code : "runtime_error";
logOneTalkError(error, code, ENABLE_ONE_TALK_DIAGNOSTICS, console.error);
logOneTalkError(
error,
oneTalkEntryDiagnosticErrorCode(error),
ENABLE_ONE_TALK_DIAGNOSTICS,
console.error,
);
},
});
@@ -184,7 +198,7 @@ const applyStoredConfiguration = async (): Promise<void> => {
}
};
const responseFor = (): OneTalkConfigMessageResponseWithEngineStatus => {
const responseFor = (): OneTalkConfigMessageResponse => {
const snapshot = controller.getSnapshot();
return {
ok: true,
@@ -192,7 +206,6 @@ const responseFor = (): OneTalkConfigMessageResponseWithEngineStatus => {
deviceId: deviceId ?? snapshot.config?.deviceId ?? "",
connectionStatus: snapshot.connectionStatus,
permissions: [...snapshot.permissions],
engineStatus: snapshot.engineStatus,
...(snapshot.authorizationVersion === undefined
? {}
: { authorizationVersion: snapshot.authorizationVersion }),
@@ -203,7 +216,7 @@ const responseFor = (): OneTalkConfigMessageResponseWithEngineStatus => {
const handleConfigurationMessage = async (
message: unknown,
sender: unknown,
): Promise<OneTalkConfigMessageResponseWithEngineStatus> => {
): Promise<OneTalkConfigMessageResponse> => {
if (!isAllowedPopupSender(sender)) return { ok: false, code: "sender_not_allowed" };
if (!isOneTalkConfigMessage(message)) {
return { ok: false, code: "invalid_configuration" };
@@ -3,7 +3,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ONETALK_PROTOCOL_VERSION } from "@trade-message-center/onetalk-contract";
import {
ONETALK_ERROR_CODES,
ONETALK_PROTOCOL_VERSION,
} from "@trade-message-center/onetalk-contract";
import { createOneTalkBrightClient } from "../src/onetalk/service-worker/bright-client.ts";
const scope = {
@@ -222,12 +225,14 @@ test("sends the approved profile including avatarUrl through the existing Bright
test("keeps ws.error visible and uses a browser-valid client close code", () => {
const socket = new FakeSocket("wss://bright.example/ws?credential=secret#fragment");
const diagnostics = [];
const errors = [];
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
onError: (error) => errors.push(error),
onDiagnostic: (event) => diagnostics.push(event),
});
client.connect();
@@ -244,6 +249,7 @@ test("keeps ws.error visible and uses a browser-valid client close code", () =>
assert.equal(socket.closed.at(-1).code, 1000);
assert.equal(socket.closed.at(-1).reason, "binding_revoked");
assert.equal(errors.at(-1).code, "binding_revoked");
const hello = diagnostics.find(
(event) => event.event === "frame" && event.frameType === "ws.hello",
);
@@ -277,6 +283,40 @@ test("keeps ws.error visible and uses a browser-valid client close code", () =>
assert.equal(JSON.stringify(diagnostics).includes("binding-1"), false);
});
for (const code of Object.values(ONETALK_ERROR_CODES)) {
test(`reports decoded ws.error ${code} without changing its close policy`, () => {
const socket = new FakeSocket("wss://bright.example/ws");
const errors = [];
const client = createOneTalkBrightClient({
url: socket.url,
scope,
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
onError: (error) => errors.push(error),
});
client.connect();
socket.open();
socket.receive(accepted());
socket.receive({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "ws.error",
requestId: "error-1",
scope,
payload: { code },
});
assert.equal(errors.at(-1).code, code);
assert.equal(
socket.closed.length > 0,
code !== "profile_observed_at_future",
`${code} close policy`,
);
client.disconnect();
});
}
test("reconnects after transient authorization_unavailable", async () => {
const sockets = [];
let resolveReconnected;
@@ -2,13 +2,16 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ONETALK_ERROR_CODES } from "@trade-message-center/onetalk-contract";
import {
ONE_TALK_DEVICE_ID_KEY,
ONE_TALK_EXTENSION_CONFIG_KEY,
ONE_TALK_MASKED_BINDING,
clearOneTalkExtensionConfig,
isOneTalkConfigMessageResponse,
isOneTalkConfigStatusEvent,
isOneTalkPopupErrorCode,
loadOrCreateOneTalkDeviceId,
loadOneTalkExtensionConfig,
redactOneTalkExtensionConfig,
@@ -16,7 +19,25 @@ import {
validateOneTalkExtensionConfig,
} from "../src/onetalk/config.ts";
test("accepts only the safe OneTalk Popup status event shape", () => {
test("accepts only the closed OneTalk Popup error codes and safe status event shape", () => {
const popupErrorCodes = [
"invalid_configuration",
"invalid_configuration_version",
"invalid_bright_websocket_url",
"missing_bright_websocket_url",
"missing_channel_account_id",
"missing_device_id",
"missing_binding",
"storage_unavailable",
"sender_not_allowed",
"websocket_unavailable",
"send_failed",
"invalid_time",
"runtime_error",
...Object.values(ONETALK_ERROR_CODES),
];
for (const code of popupErrorCodes) assert.equal(isOneTalkPopupErrorCode(code), true, code);
assert.equal(isOneTalkPopupErrorCode("unknown_popup_error"), false);
assert.equal(
isOneTalkConfigStatusEvent({
type: "onetalk.config.status",
@@ -33,6 +54,14 @@ test("accepts only the safe OneTalk Popup status event shape", () => {
}),
false,
);
assert.equal(
isOneTalkConfigStatusEvent({
type: "onetalk.config.status",
connectionStatus: "offline",
error: "unknown_popup_error",
}),
false,
);
assert.equal(
isOneTalkConfigStatusEvent({ type: "onetalk.config.status", connectionStatus: 42 }),
false,
@@ -47,6 +76,49 @@ test("accepts only the safe OneTalk Popup status event shape", () => {
);
});
test("accepts only safe closed Popup configuration responses", () => {
const response = {
ok: true,
config: null,
deviceId: "device-1",
connectionStatus: "offline",
permissions: [],
error: "binding_revoked",
};
assert.equal(isOneTalkConfigMessageResponse(response), true);
assert.equal(
isOneTalkConfigMessageResponse({ ...response, error: "unknown_popup_error" }),
false,
);
assert.equal(isOneTalkConfigMessageResponse({ ...response, binding: "secret" }), false);
assert.equal(
isOneTalkConfigMessageResponse({ ...response, engineStatus: { lastError: "raw" } }),
false,
);
assert.equal(
isOneTalkConfigMessageResponse({
...response,
config: {
version: 1,
brightWebSocketUrl: "wss://bright.example/ws",
channelAccountId: "account-1",
deviceId: "device-1",
binding: "secret",
},
}),
false,
);
assert.equal(
isOneTalkConfigMessageResponse({
ok: false,
code: "sender_not_allowed",
message: "secret",
}),
false,
);
assert.equal(isOneTalkConfigMessageResponse({ ok: false, code: "binding_revoked" }), false);
});
const input = {
brightWebSocketUrl: "wss://bright.example/ws",
channelAccountId: "account-1",
@@ -9,6 +9,7 @@ import {
ONE_TALK_PAGE_PORT_NAME,
} from "../src/onetalk/page-bridge/model.ts";
import { OneTalkPageRuntimeHost } from "../src/onetalk/service-worker/page-runtime-host.ts";
import { OneTalkBrightClientError } from "../src/onetalk/service-worker/bright-client.ts";
import { createOneTalkServiceWorkerSyncController } from "../src/onetalk/service-worker/sync-runtime.ts";
const config = (suffix) => ({
@@ -186,13 +187,20 @@ test("replaces the old Bright client and keeps binding out of the snapshot", asy
clients[0].options.onError(new Error("stale-old-client-error"));
assert.equal(controller.getSnapshot().error, undefined);
clients[1].options.onError(new Error("unknown-current-client-error"));
assert.equal(controller.getSnapshot().error, "runtime_error");
clients[1].options.onError(new OneTalkBrightClientError("binding_revoked"));
assert.equal(controller.getSnapshot().error, "binding_revoked");
await controller.configure(config("two"));
assert.equal(clients.length, 2);
assert.equal(controller.getSnapshot().error, "binding_revoked");
await controller.configure(null);
assert.equal(clients[1].disconnectCount, 1);
assert.equal(controller.getSnapshot().connectionStatus, "unconfigured");
assert.equal(controller.getSnapshot().config, null);
assert.equal(controller.getSnapshot().error, undefined);
});
test("replays a page hello that arrived before async configuration finished", async () => {
@@ -10,7 +10,7 @@ const buildConfigUrl = new URL("../src/onetalk/build-config.ts", import.meta.url
const viteConfigUrl = new URL("../vite.config.ts", import.meta.url);
const manifestUrl = new URL("../manifest.template.json", import.meta.url);
test("keeps Binding in the identity section and Bright details development-only", async () => {
test("keeps status, recent error, and Binding in the identity section", async () => {
const html = await readFile(popupHtmlUrl, "utf8");
const identitySection = html.slice(
html.indexOf('<section class="identity-panel"'),
@@ -24,9 +24,19 @@ test("keeps Binding in the identity section and Bright details development-only"
assert.match(identitySection, /id="channel-account-id"/u);
assert.match(identitySection, /id="device-id"/u);
assert.match(identitySection, /id="build-hash"/u);
assert.match(identitySection, /id="connection-status"/u);
assert.match(identitySection, /id="error"/u);
assert.match(identitySection, /id="binding"/u);
assert.ok(
identitySection.indexOf('id="build-hash"') <
identitySection.indexOf('id="connection-status"') &&
identitySection.indexOf('id="connection-status"') <
identitySection.indexOf('id="error"') &&
identitySection.indexOf('id="error"') < identitySection.indexOf('id="binding"'),
);
assert.match(developerDetails, /^<section id="developer-details"[^>]* hidden>/mu);
assert.match(developerDetails, /id="bright-websocket-url"/u);
assert.doesNotMatch(developerDetails, /id="connection-status"|id="error"/u);
assert.doesNotMatch(developerDetails, /id="binding"/u);
});
@@ -48,6 +58,13 @@ test("uses the canonical development flag and subscribes through the shared stat
assert.doesNotMatch(popupSource, /readActiveAccountIdFromUrl/u);
assert.match(manifest, /"scripting"/u);
assert.match(popupSource, /isOneTalkConfigStatusEvent\(message\)/u);
assert.match(popupSource, /isOneTalkConfigMessageResponse\(response\)/u);
assert.match(popupSource, /Record<OneTalkPopupErrorCode, string>/u);
assert.match(popupSource, /最近错误:\$\{errorLabels\[code\]\}(错误码:\$\{code\}/u);
assert.match(popupSource, /showError\(response\.code\)/u);
assert.match(popupSource, /showError\(response\.error\)/u);
assert.match(popupSource, /showError\(event\.error\)/u);
assert.doesNotMatch(popupSource, /配置或连接失败/u);
assert.match(popupSource, /buildHashOutput\.textContent = __TMC_BUILD_HASH__/u);
assert.match(viteConfig, /__TMC_BUILD_HASH__: JSON\.stringify\(buildHash\)/u);
});
@@ -6,6 +6,8 @@ import {
createOneTalkPageHelloMessage,
ONE_TALK_PAGE_PORT_NAME,
} from "../src/onetalk/page-bridge/model.ts";
import { OneTalkExtensionConfigError } from "../src/onetalk/config.ts";
import { OneTalkBrightClientError } from "../src/onetalk/service-worker/bright-client.ts";
test("rejects configuration messages from non-Popup senders", async () => {
const messageListeners = [];
@@ -80,13 +82,21 @@ test("rejects configuration messages from non-Popup senders", async () => {
assert.equal(response.ok, true);
assert.equal(response.config, null);
assert.equal(response.connectionStatus, "unconfigured");
assert.deepEqual(response.engineStatus, null);
assert.equal(Object.hasOwn(response, "engineStatus"), false);
assert.deepEqual(response.permissions, []);
assert.match(
response.deviceId,
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
assert.equal(getCount, startupGetCount);
module.oneTalkServiceWorkerController.setConfigurationError("binding_revoked");
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(statusEvents.at(-1), {
type: "onetalk.config.status",
connectionStatus: "unconfigured",
error: "binding_revoked",
});
});
test("reports synchronous status broadcast failures without creating an unhandled rejection", async () => {
@@ -124,6 +134,45 @@ test("reports synchronous status broadcast failures without creating an unhandle
assert.deepEqual(calls, [["[Trade Message Center][OneTalk Sync]", "runtime_error"]]);
});
test("keeps a decoded Bright error code in the entry diagnostic projection", async () => {
const chromeApi = {
runtime: {
id: "test-extension",
onConnect: { addListener() {} },
onMessage: { addListener() {} },
async sendMessage() {},
},
storage: {
local: {
async get() {
return {};
},
async set() {},
async remove() {},
},
onChanged: { addListener() {} },
},
};
globalThis.chrome = chromeApi;
const module = await import(
`../src/service-worker-entry.ts?bright-diagnostic-test=${Date.now()}`
);
assert.equal(
module.oneTalkEntryDiagnosticErrorCode(new OneTalkBrightClientError("binding_revoked")),
"binding_revoked",
);
assert.equal(
module.oneTalkEntryDiagnosticErrorCode(new OneTalkBrightClientError("unknown_code")),
"runtime_error",
);
assert.equal(
module.oneTalkEntryDiagnosticErrorCode(new OneTalkExtensionConfigError("unknown_code")),
"runtime_error",
);
assert.equal(module.oneTalkEntryDiagnosticErrorCode(new Error("unknown")), "runtime_error");
});
test("routes a page send failure through the real entry onError wiring in production", async () => {
const connectListeners = [];
const chromeApi = {