mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat(chrome-extension): surface popup configuration errors
This commit is contained in:
@@ -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 guard;union 复用共享 `OneTalkErrorCode`,让 config response、status event 与 Popup 的 response guard 使用适当的 closed subtype,拒绝未知 status error code。
|
||||
|
||||
+80
@@ -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.
|
||||
+337
@@ -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 的宽松形状 guard,response 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 socket;profile 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 flag,authenticated 会 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 broadcast;Popup 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/currentConfig,dispose 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 epoch;page 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 form,error 在 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=true,production 保留默认压缩/source map 边界。Popup 是独立 Vite HTML input,service 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 panel,production/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 snapshot;profile 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 status;production 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 union;config/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 code;controller 顶层仍是 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-details;error 在 form bottom,无稳定 code | 只移动/调整 DOM placement and visibility;developer 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
|
||||
@@ -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">
|
||||
正在读取…
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user