mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
一次三方(CLI/GUI/鸿蒙)与内核的协议审计,发现四处**全部静默**的不一致
(无任何测试或日志会报),本次一并修复并把它们变成可执行判据。
## 四处不一致
1. `agent_error` 是**幽灵类型**:SSE 白名单订阅了它、鸿蒙还写了处理分支
(显示「本轮处理失败」),但全仓**无任何发布者** —— events 包与 SDK 都
没有这个常量。于是那段分支永远不可达,而真正的失败帧叫 `error`
(webui/cli 都发 `{type:"error"}`),鸿蒙从未接住。
2. `system` **发了没人收**:internal/plugin/dynamic_proc.go 一直在发插件崩溃
(注释还写着「webui/诊断插件可订阅」),却不在 SSE 白名单里 ⇒ 事件发出来
就掉地上,插件崩溃对用户完全不可见。
3. CLI 缺 `stage`/`system`:CLI 是唯一看不到阶段进展的客户端,agent 跑长任务
时终端长时间静默,用户分不清「在干活」还是「卡住了」。
4. 鸿蒙设备桥**静默丢弃**:只处理 bind_ack 与 cmd,服务端下行的
push / cmd_speech_start / cmd_speech_end / hello_ack 直接 return ——
「收到了但不支持」与「根本没收到」在日志上完全一样,无从排查。
## 修法(注意:三方客户端的通信模型不同,清单不可一刀切)
CLI 走 InjectTextSync **同步拿最终回复**(response 帧)+ 事件订阅做过程输出,
**不做 SSE 重连** ⇒ 它需要 stage/tool_call/reasoning/delta,
但**不需要** agent_output(正文来自同步回执)与 sync_required(那是 SSE
断线重连专用的补拉信号)。GUI/鸿蒙是真 SSE 客户端,两者都需要全量对话事件。
第一版判据按同一份清单要求三者,误报了 CLI —— 那是判据的错,不是代码的错。
- handler_chat.go:subTypes 删 `agent_error`、补 `system`
- dashboard.js / ChatSse.ets:补 `system` 消费(WebUI 用 toast,鸿蒙用 setStage)
- ChatSse.ets:幽灵分支 `agent_error` 改为真实类型 `error`
- cli/plugin.go:补 `stage`(过滤 `_consolidation_`)与 `system` 订阅
- cmd/waiter:补 Phase/Event/Plugin 字段与 stage/system 渲染
- DeviceBridge.ets:静默丢弃改为带 hilog 日志(区分「不支持」与「没收到」)
## 判据(internal/plugins/webui/handler_sse_protocol_test.go,7 条)
TestSSESubTypesAreRealEventTypes(幽灵类型)/
TestEveryPublishedEventHasAnOutwardPath(发了没人收,例外须显式声明理由)/
TestClientsRecognizeServerEventTypes(按客户端分档)/
TestNoPhantomFrameTypes / TestStageFrameFieldsMatchServer /
TestSseWhitelistParsesToExpectedSet(**解析器反向自检** —— 解析器失效会让整组
判据静默变成空集并全绿,是最危险的假绿)/
TestDeviceBridgeOpCoverage。已接入 `make check-client-protocol`(进 make test)。
变异 M1–M8 全部验证。其中 M5 首轮未报红:判据只查前缀 `json:"phase`,
把 tag 改成 `phaseX` 仍匹配 ⇒ 改为匹配**完整 tag** 后才咬住。
## 端到端实测(不只看测试绿)
- 真实 SSE 流收到 stage×15 / content_delta×111 / memory_access×3 /
agent_llm_chain / tool_call×2,**agent_error 0 次**(幽灵已清)
- kill 一个 plugin.bin → SSE 收到 `event: system`
`{"event":"plugin_crashed","plugin":"a2a"}`,日志确认「自动重启 a2a」
- WebUI 真弹出 toast「插件 acp 异常退出,内核正在重启它」(MutationObserver 捕获)
457 lines
19 KiB
Go
457 lines
19 KiB
Go
package webui
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// ===== 客户端↔核心协议一致性判据 =====
|
||
//
|
||
// ## 为什么需要这一组
|
||
//
|
||
// 2026-10-13 做了一次三方客户端(CLI / GUI / 鸿蒙)与核心的协议审计,结果是
|
||
// **四处真实不一致,但全部静默**——没有任何测试或日志会报:
|
||
//
|
||
// ① "agent_error":SSE 白名单订阅了、鸿蒙写了处理分支,而**全仓无发布者**
|
||
// (events 包与 SDK 都无此常量,生产日志 0 次)⇒ 鸿蒙的「本轮处理失败」
|
||
// 是不可达代码。真正的失败帧叫 "error",鸿蒙没接。
|
||
// ② "system":dynamic_proc.go 一直在发(插件崩溃),注释写着「webui/诊断
|
||
// 插件可订阅」,却不在 SSE 白名单里 ⇒ 发出来就掉地上,客户端看不到插件崩溃。
|
||
// ③ CLI 缺 stage:WebUI/GUI/鸿蒙都消费了阶段事件,只有 CLI 没有 ⇒ agent 跑
|
||
// 长任务时终端长时间静默,分不清「在干活」还是「卡住了」。
|
||
// ④ 鸿蒙设备桥对 push / cmd_speech_* 静默丢弃(只实现 bind_ack + cmd)。
|
||
//
|
||
// 共同形态是**「写了、发了、不报错,只是对方收不到」**——正是本仓反复记过的那类
|
||
// 静默失效。靠人眼审阅抓不住(本轮就是审出来的),必须变成可执行判据。
|
||
//
|
||
// ## 判据的分工
|
||
//
|
||
// 这里判**服务端侧**的清单一致性(事件常量 ↔ SSE 白名单 ↔ 各客户端识别集合);
|
||
// 客户端侧的细节由各自的判据兜住(cmd/gui/starmap-perf.test.mjs 等)。
|
||
|
||
// eventsBusPath 是事件常量的唯一事实源。
|
||
const eventsBusPath = "../../../internal/events/bus.go"
|
||
|
||
// sseSubTypesPath 是 SSE 转发白名单所在处。
|
||
const sseSubTypesPath = "handler_chat.go"
|
||
|
||
// eventLiterals 从 internal/events/bus.go 提取全部事件字面量。
|
||
//
|
||
// 为何以它为准:事件类型是跨进程/跨客户端的**契约**,而 bus.go 是唯一定义处。
|
||
// 任何客户端只应消费这里有的类型;反过来,核心发布的事件(除明确内销的)
|
||
// 都应有对外通路,否则就是「发了没人收」。
|
||
func eventLiterals(t *testing.T) map[string]bool {
|
||
t.Helper()
|
||
b, err := os.ReadFile(filepath.Join("..", "..", "..", "internal", "events", "bus.go"))
|
||
if err != nil {
|
||
t.Fatalf("读 events/bus.go: %v", err)
|
||
}
|
||
re := regexp.MustCompile(`EventType\s*=\s*"([a-z_]+)"`)
|
||
out := map[string]bool{}
|
||
for _, m := range re.FindAllStringSubmatch(string(b), -1) {
|
||
out[m[1]] = true
|
||
}
|
||
if len(out) < 10 {
|
||
t.Fatalf("只解出 %d 个事件常量,疑似正则失效(预期 >10)", len(out))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// sseSubscribedTypes 解析 handler_chat.go 里**实际**被 SSE 转发的类型集合。
|
||
//
|
||
// 三个来源,缺一不可:
|
||
// - subTypes 字面量清单(含 memory_access 等)
|
||
// - appendDeltaSub(sdk.EventXxx) 的 delta 增量
|
||
// - 单独的 Subscribe(sdk.EventRawInput)(转成 channel_input)
|
||
func sseSubscribedTypes(t *testing.T) map[string]bool {
|
||
t.Helper()
|
||
b, err := os.ReadFile(sseSubTypesPath)
|
||
if err != nil {
|
||
t.Fatalf("读 %s: %v", sseSubTypesPath, err)
|
||
}
|
||
src := string(b)
|
||
// 先剥注释:本文件的注释里出现过被删掉的 "agent_error",
|
||
// 不剥的话判据会被注释喂饱(这是本项目踩过的假绿形态)。
|
||
code := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(src, "")
|
||
code = regexp.MustCompile(`(?m)^\s*//.*$`).ReplaceAllString(code, "")
|
||
|
||
out := map[string]bool{}
|
||
|
||
// ① subTypes 清单
|
||
if m := regexp.MustCompile(`(?s)subTypes\s*:?=\s*\[\]string\{(.*?)\}`).FindStringSubmatch(code); m != nil {
|
||
for _, lit := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(m[1], -1) {
|
||
out[lit[1]] = true
|
||
}
|
||
}
|
||
|
||
// ② delta 订阅:appendDeltaSub(sdk.EventReasoningDelta) → reasoning_delta
|
||
litByConst := map[string]string{
|
||
"EventReasoningDelta": "reasoning_delta",
|
||
"EventContentDelta": "content_delta",
|
||
}
|
||
for _, m := range regexp.MustCompile(`appendDeltaSub\(sdk\.(Event\w+)\)`).FindAllStringSubmatch(code, -1) {
|
||
if lit, ok := litByConst[m[1]]; ok {
|
||
out[lit] = true
|
||
}
|
||
}
|
||
|
||
// ③ raw_input 单独订阅(SSE 上以 channel_input 名义发出)
|
||
if strings.Contains(code, "Subscribe(sdk.EventRawInput") {
|
||
out["raw_input"] = true
|
||
}
|
||
|
||
if len(out) < 8 {
|
||
t.Fatalf("只解出 %d 个 SSE 订阅类型,疑似解析失效(预期 >8):%v", len(out), out)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestSSESubTypesAreRealEventTypes 钉住:SSE 白名单里的每个名字都必须是真事件。
|
||
//
|
||
// 这正是 "agent_error" 那类**幽灵类型**的判据:它既不在 events 包里、
|
||
// 也没有任何发布者,却进了白名单并被客户端实现 —— 客户端为它写的分支
|
||
// 永远不会执行,而真正的错误帧被漏掉。
|
||
func TestSSESubTypesAreRealEventTypes(t *testing.T) {
|
||
real := eventLiterals(t)
|
||
sub := sseSubscribedTypes(t)
|
||
|
||
var ghosts []string
|
||
for name := range sub {
|
||
if !real[name] {
|
||
ghosts = append(ghosts, name)
|
||
}
|
||
}
|
||
sort.Strings(ghosts)
|
||
if len(ghosts) > 0 {
|
||
t.Errorf("SSE 白名单里有 %d 个**不是事件类型**的名字:%v\n"+
|
||
" 它们是幽灵:没有任何发布者,客户端为它们写的分支永远不执行。\n"+
|
||
" 修法:从 handler_chat.go 的 subTypes 里删掉,并同步删掉各客户端的处理分支。",
|
||
len(ghosts), ghosts)
|
||
}
|
||
}
|
||
|
||
// TestEveryPublishedEventHasAnOutwardPath 钉住:核心发布的事件都要有对外通路。
|
||
//
|
||
// 这是 "system" 那类的判据:dynamic_proc.go 一直在发插件崩溃事件,注释还写着
|
||
// 「webui/诊断插件可订阅」,但 SSE 白名单没订阅 ⇒ 事件发出来就掉地上。
|
||
//
|
||
// ★ 例外清单必须显式写下来并给理由 —— 「内销」「给插件总线用」都必须是**声明过的**
|
||
// 决定,而不是漏了。放行清单越长,这份判据的价值越低,所以每加一条都要有理由。
|
||
func TestEveryPublishedEventHasAnOutwardPath(t *testing.T) {
|
||
sub := sseSubscribedTypes(t)
|
||
|
||
// 允许不进 SSE 的事件:事件名 → 理由。
|
||
//
|
||
// ★ 加这一条前先问:前端真的不需要知道这件事吗?
|
||
// 「system」曾经可以放在这里(当年就是漏了),而插件崩溃恰恰是用户
|
||
// 最该被告诉的事 —— 所以它现在**在**白名单里,不在这里。
|
||
allowed := map[string]string{
|
||
// 已是 SSE 通路(raw_input 以 channel_input 名义发出,见测试 ①)
|
||
"raw_input": "经 channel_input 轻量转发(不带正文,避免每次说话都多背一份用户消息)",
|
||
// 插件间握手事件:clawhubadapter → skillmgr 移交 SKILL 条目,
|
||
// 属内核内部的职责链,前端无需感知。
|
||
"skill_detected": "插件间握手(clawhubadapter → skillmgr),非对话流程",
|
||
// delta 已由 appendDeltaSub 覆盖(见 sseSubscribedTypes ②)
|
||
"reasoning_delta": "由 appendDeltaSub 订阅",
|
||
"content_delta": "由 appendDeltaSub 订阅",
|
||
// 事件总线的通配订阅用,不是可发布的具体类型
|
||
"": "EventAll 通配",
|
||
}
|
||
|
||
real := eventLiterals(t)
|
||
var missing []string
|
||
for name := range real {
|
||
if sub[name] {
|
||
continue
|
||
}
|
||
if _, ok := allowed[name]; ok {
|
||
continue
|
||
}
|
||
missing = append(missing, name)
|
||
}
|
||
sort.Strings(missing)
|
||
if len(missing) > 0 {
|
||
t.Errorf("这些事件有发布者但**没有任何对外通路**:%v\n"+
|
||
" 症状是「发了、没报错、客户端收不到」,只能翻 journal 才发现。\n"+
|
||
" 修法:加进 handler_chat.go 的 subTypes(并在各客户端加处理分支),\n"+
|
||
" 或在本判据的 allowed 里显式声明理由(声明 = 这是个决定,不是漏了)。",
|
||
missing)
|
||
}
|
||
}
|
||
|
||
// TestClientsRecognizeServerEventTypes 钉住:三方客户端都认得「它应当认得的」事件。
|
||
//
|
||
// ★ 不能一刀切(第一版就是这么写错的):三方客户端的**通信模型不同**。
|
||
//
|
||
// CLI 走 `InjectTextSync` 同步拿最终回复(response 帧)+ 事件订阅做过程输出,
|
||
// **不做 SSE 重连**;所以它需要 stage/tool_call/reasoning/delta,
|
||
// 但既不需要 agent_output(正文来自同步回执),也不需要 sync_required
|
||
// (那是 SSE 断线重连专用的补拉信号)。
|
||
//
|
||
// 而 GUI / 鸿蒙 是真正的 SSE 客户端,两者都需要全量对话事件。
|
||
func TestClientsRecognizeServerEventTypes(t *testing.T) {
|
||
// SSE 客户端(GUI / 鸿蒙)应认得的核心对话事件。
|
||
// 不含 skill_detected(内核内部握手)。含 system(插件崩溃要到达用户)、
|
||
// 含 sync_required(两者都用 Last-Event-ID 重连,需要这个补拉信号)。
|
||
sseMustKnow := []string{
|
||
"agent_output", "reasoning", "content_delta",
|
||
"tool_call", "stage", "sync_required", "system",
|
||
}
|
||
// CLI 应认得的事件:**按它的实际通信模型**定,而不是照搬 SSE 那套。
|
||
//
|
||
// ★ 这份清单本身就是要审的东西:每加一项都要回答「CLI 真的会收到它吗」。
|
||
cliMustKnow := []string{
|
||
"reasoning", "reasoning_delta", "content_delta",
|
||
"tool_call", "stage", "system", "response", "error",
|
||
}
|
||
|
||
clients := []struct {
|
||
name string
|
||
path string
|
||
root bool
|
||
mustKnow []string
|
||
}{
|
||
{"GUI", "../../../cmd/gui/renderer/app.js", false, sseMustKnow},
|
||
{"鸿蒙", "../../../cmd/ohos/HomeAgent/entry/src/main/ets", true, sseMustKnow},
|
||
{"CLI", "../../../cmd/waiter", true, cliMustKnow},
|
||
}
|
||
|
||
for _, c := range clients {
|
||
p := c.path
|
||
var blob string
|
||
if c.root {
|
||
var sb strings.Builder
|
||
_ = filepath.Walk(p, func(fp string, fi os.FileInfo, err error) error {
|
||
if err != nil || fi.IsDir() {
|
||
return nil
|
||
}
|
||
ext := filepath.Ext(fp)
|
||
if ext != ".ets" && ext != ".ts" && ext != ".go" {
|
||
return nil
|
||
}
|
||
b, rerr := os.ReadFile(fp)
|
||
if rerr == nil {
|
||
sb.Write(b)
|
||
}
|
||
return nil
|
||
})
|
||
blob = sb.String()
|
||
} else {
|
||
b, err := os.ReadFile(p)
|
||
if err != nil {
|
||
t.Fatalf("读 %s: %v", p, err)
|
||
}
|
||
blob = string(b)
|
||
}
|
||
if len(blob) < 1000 {
|
||
t.Errorf("[%s] 源码只有 %d 字节,疑似路径写错(测了等于没测)", c.name, len(blob))
|
||
continue
|
||
}
|
||
|
||
var missed []string
|
||
for _, e := range c.mustKnow {
|
||
// 接受单/双引号两种字面量风格
|
||
if !strings.Contains(blob, `"`+e+`"`) && !strings.Contains(blob, `'`+e+`'`) {
|
||
missed = append(missed, e)
|
||
}
|
||
}
|
||
if len(missed) > 0 {
|
||
t.Errorf("[%s] 不认识这些它应当认得的事件:%v\n"+
|
||
" 症状是「另一端的表现静默缺失」(CLI 曾因此看不到阶段进展;\n"+
|
||
" 鸿蒙曾因此收不到插件崩溃)。", c.name, missed)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNoPhantomFrameTypes 钉住:客户端不得实现服务端**从不发送**的帧类型。
|
||
//
|
||
// 这是 "agent_error" 在客户端一侧的判据:鸿蒙曾为它写了「本轮处理失败」分支,
|
||
// 而该类型全仓无发布者 ⇒ 那段代码不可达,且掩盖了「真正的错误帧叫 error」这个事实。
|
||
func TestNoPhantomFrameTypes(t *testing.T) {
|
||
// 已确认的幽灵:类型名 → 为什么它不存在。
|
||
phantoms := map[string]string{
|
||
"agent_error": `events 包无此常量、SDK 无此常量、全仓无发布者;真正的失败帧叫 "error"`,
|
||
}
|
||
|
||
// 客户端源码里搜幽灵字面量(排除注释行,避免判据被说明文字喂饱)。
|
||
scan := []string{
|
||
"../../../cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets",
|
||
}
|
||
|
||
for _, p := range scan {
|
||
b, err := os.ReadFile(p)
|
||
if err != nil {
|
||
t.Fatalf("读 %s: %v", p, err)
|
||
}
|
||
src := string(b)
|
||
var lines []string
|
||
for _, ln := range strings.Split(src, "\n") {
|
||
trimmed := strings.TrimSpace(ln)
|
||
// 跳过纯注释行:注释里提到幽灵名是**解释**,不是实现
|
||
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") {
|
||
continue
|
||
}
|
||
lines = append(lines, ln)
|
||
}
|
||
code := strings.Join(lines, "\n")
|
||
for name, why := range phantoms {
|
||
if strings.Contains(code, `'`+name+`'`) || strings.Contains(code, `"`+name+`"`) {
|
||
t.Errorf("%s 仍实现了幽灵帧类型 %q(%s)\n"+
|
||
" 那段分支永远不会执行,还掩盖了真实类型名。", p, name, why)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestStageFrameFieldsMatchServer 钉住 CLI 的 stage 帧字段与服务端 payload 一致。
|
||
//
|
||
// 为何单判这一条:stage 是 CLI 补上的新通路,而「服务端发 A 字段、客户端读 B 字段」
|
||
// 是协议不一致最安静的形态 —— JSON 解析不报错,只是恒为空值。
|
||
func TestStageFrameFieldsMatchServer(t *testing.T) {
|
||
// 服务端(internal/agent/core/stage.go)发的是 phase + tool
|
||
stageGo, err := os.ReadFile("../../../internal/agent/core/stage.go")
|
||
if err != nil {
|
||
t.Fatalf("读 stage.go: %v", err)
|
||
}
|
||
srv := string(stageGo)
|
||
for _, f := range []string{`"phase"`, `"tool"`} {
|
||
if !strings.Contains(srv, f) {
|
||
t.Errorf("服务端 stage 事件 payload 里找不到字段 %s —— 判据的前提变了,请同步更新", f)
|
||
}
|
||
}
|
||
|
||
// CLI 插件发出的帧必须带同样的字段
|
||
cli, err := os.ReadFile("../../../internal/plugins/cli/plugin.go")
|
||
if err != nil {
|
||
t.Fatalf("读 cli/plugin.go: %v", err)
|
||
}
|
||
cb := string(cli)
|
||
if !strings.Contains(cb, `"type": "stage"`) {
|
||
t.Error("CLI 插件未发送 stage 帧 —— CLI 用户看不到阶段进展")
|
||
}
|
||
for _, f := range []string{`"phase"`, `"tool"`} {
|
||
if !strings.Contains(cb, f) {
|
||
t.Errorf("CLI 的 stage 帧缺字段 %s(服务端发的是它),客户端会恒读到空值", f)
|
||
}
|
||
}
|
||
|
||
// CLI 客户端必须有承载 phase 的字段,否则解析后丢弃。
|
||
//
|
||
// ★ 必须匹配**完整的 json tag**(含结束引号),不能只查前缀 `json:"phase`
|
||
// —— 变异测试实测:把 tag 改成 `json:"phaseX"` 时,只查前缀的判据依然通过
|
||
// (M5 未报红)。这是「判据太松」的典型形态。
|
||
st, err := os.ReadFile("../../../cmd/waiter/state.go")
|
||
if err != nil {
|
||
t.Fatalf("读 waiter/state.go: %v", err)
|
||
}
|
||
stc := string(st)
|
||
if !strings.Contains(stc, `json:"phase,omitempty"`) {
|
||
t.Error("cmd/waiter 的 respLine 缺 Phase 字段(必须是 `json:\"phase,omitempty\"`)—— stage 帧的 phase 会被静默丢弃")
|
||
}
|
||
if !strings.Contains(stc, `json:"event,omitempty"`) || !strings.Contains(stc, `json:"plugin,omitempty"`) {
|
||
t.Error("cmd/waiter 的 respLine 缺 Event/Plugin 字段 —— system 帧(插件崩溃)会被静默丢弃")
|
||
}
|
||
}
|
||
|
||
// TestSseWhitelistParsesToExpectedSet 反向自检:确认判据的解析器真的能工作。
|
||
//
|
||
// 为何要自检:本组判据全靠从源码里解出「类型集合」。解析器一旦失效
|
||
// (正则不匹配、文件改名),判据会**静默变成空集**并全绿 ——
|
||
// 那是最危险的假绿。这里钉一个下限与几个必含项。
|
||
func TestSseWhitelistParsesToExpectedSet(t *testing.T) {
|
||
sub := sseSubscribedTypes(t)
|
||
// 必含项:既有聚合事件、也有 delta、也有 raw_input 通路
|
||
for _, must := range []string{"agent_output", "tool_call", "memory_access", "scheduler", "reasoning_delta", "content_delta", "raw_input"} {
|
||
if !sub[must] {
|
||
t.Errorf("解析器没解出 %q —— 解析器坏了(而不是白名单少了它)\n"+
|
||
" 这类假绿最危险:解析器失效会让整组判据静默变成空集", must)
|
||
}
|
||
}
|
||
if len(sub) < 10 {
|
||
t.Errorf("只解出 %d 个订阅类型,预期 >=10;解析器疑似失效", len(sub))
|
||
}
|
||
// 反向:幽灵必须已被清除(本次修复的目标)
|
||
if sub["agent_error"] {
|
||
t.Error("subTypes 里仍有 agent_error —— 幽灵类型未清除")
|
||
}
|
||
// 反向:system 必须已在(本次修复的目标)
|
||
if !sub["system"] {
|
||
t.Error("subTypes 里缺 system —— 插件崩溃事件仍会掉地上")
|
||
}
|
||
}
|
||
|
||
// 确保 json 包被使用(保留给后续判据扩展 payload 形状检查)。
|
||
var _ = json.Marshal
|
||
|
||
// TestDeviceBridgeOpCoverage 钉住:设备桥的下行 op 要么被实现、要么被显式声明忽略。
|
||
//
|
||
// ## 为什么需要
|
||
//
|
||
// 2026-10-13 协议审计发现:鸿蒙端 `DeviceBridge.ets` 的帧分发是
|
||
//
|
||
// if (op !== 'cmd' || !this.bound) { return; }
|
||
//
|
||
// —— 即除了 bind_ack 与 cmd,**其余全部静默丢弃**。而服务端会下行
|
||
// hello_ack / push / cmd_speech_start / cmd_speech_end 四个 op。
|
||
//
|
||
// 静默丢弃是最难排查的形态:服务端以为发出去了、设备端毫无痕迹,
|
||
// 「收到了但不支持」与「根本没收到」在日志上完全一样。
|
||
//
|
||
// ## 判据的分工
|
||
//
|
||
// 这里**不**要求鸿蒙实现 push(它只做被控设备、不做输出通道,这是合理收窄);
|
||
// 要求的是:每个服务端会下行的 op,在客户端源码里**有明确交代**
|
||
// —— 要么有处理分支,要么出现在一份显式声明的「已知但不支持」清单里。
|
||
// 这样「新增一个下行 op 却没人管」会立刻暴露。
|
||
func TestDeviceBridgeOpCoverage(t *testing.T) {
|
||
// 服务端会下行到设备的 op(事实源:remotedevice 的 Push* 帧构造)。
|
||
downOps := []string{"bind_ack", "cmd", "hello_ack", "push", "cmd_speech_start", "cmd_speech_end"}
|
||
|
||
// 客户端源码:鸿蒙端设备桥。
|
||
const p = "../../../cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets"
|
||
b, err := os.ReadFile(p)
|
||
if err != nil {
|
||
t.Fatalf("读 %s: %v", p, err)
|
||
}
|
||
code := string(b)
|
||
|
||
// 已知但不支持的 op:必须**显式列在这里**并给理由。
|
||
// 空清单意味着「全部下行 op 都已实现」。
|
||
knownUnsupported := map[string]string{
|
||
"hello_ack": "鸿蒙端只做被控设备:hello 由本端发出,ack 仅确认注册,无需额外处理",
|
||
"push": "鸿蒙端不注册输出通道(不做“被推送消息”的一方),push 属预期忽略",
|
||
"cmd_speech_start": "大负载分块的前半帧;鸿蒙端不接收下发的大负载",
|
||
"cmd_speech_end": "同上,分块结束帧",
|
||
}
|
||
|
||
var missing []string
|
||
for _, op := range downOps {
|
||
if strings.Contains(code, "'"+op+"'") {
|
||
continue // 有处理分支
|
||
}
|
||
if _, ok := knownUnsupported[op]; ok {
|
||
continue // 显式声明不支持
|
||
}
|
||
missing = append(missing, op)
|
||
}
|
||
if len(missing) > 0 {
|
||
t.Errorf("设备桥下行 op 无人处理也未声明:%v\n"+
|
||
" 后果:服务端发出去、设备端静默丢弃,「收到但不支持」与「没收到」\n"+
|
||
" 无法区分。修法:加处理分支,或在 knownUnsupported 里显式声明理由。", missing)
|
||
}
|
||
|
||
// 反向自检:确认真的有能力丢弃日志(而不是又回到静默 return)。
|
||
// 这是本次修复的落脚点 —— 没有它,上面那份清单只是纸面声明。
|
||
if !strings.Contains(code, "hilog") {
|
||
t.Error("DeviceBridge.ets 没有任何日志调用 —— 「忽略不支持的 op」会是静默的,排查时无从下手")
|
||
}
|
||
if !strings.Contains(code, "忽略不支持的 op") {
|
||
t.Error("DeviceBridge.ets 未在丢弃不支持 op 时记录日志:静默丢弃是最难排查的形态")
|
||
}
|
||
}
|