cj-tauri 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +113 -99
  2. package/bin/cj-tauri.js +287 -287
  3. package/cjpm.toml +63 -52
  4. package/cli/cjpm.toml +22 -22
  5. package/cli/src/doctor.cj +308 -306
  6. package/cli/src/main.cj +129 -129
  7. package/cli/src/manifest.cj +632 -558
  8. package/cli/src/manifest_test.cj +87 -0
  9. package/cli/src/project.cj +770 -770
  10. package/cli/src/resolve.cj +348 -348
  11. package/cli/src/scaffold.cj +369 -369
  12. package/cli/src/sync.cj +526 -526
  13. package/cli/src/util.cj +168 -168
  14. package/cli/templates/app/README.md +51 -48
  15. package/cli/templates/app/assets/capabilities/default.json +7 -7
  16. package/cli/templates/app/assets/ui/index.html +120 -118
  17. package/cli/templates/app/cj-tauri.toml +32 -22
  18. package/cli/templates/app/cjpm.toml +34 -34
  19. package/cli/templates/app/src/main.cj +101 -90
  20. package/cli/templates/app-react/README.md +56 -56
  21. package/cli/templates/app-react/assets/capabilities/default.json +7 -7
  22. package/cli/templates/app-react/assets/ui/index.html +25 -25
  23. package/cli/templates/app-react/assets/ui/package.json +19 -19
  24. package/cli/templates/app-react/assets/ui/src/App.css +67 -67
  25. package/cli/templates/app-react/assets/ui/src/App.jsx +124 -124
  26. package/cli/templates/app-react/assets/ui/src/main.jsx +6 -6
  27. package/cli/templates/app-react/assets/ui/vite.config.js +24 -24
  28. package/cli/templates/app-react/cj-tauri.toml +33 -23
  29. package/cli/templates/app-react/cjpm.toml +34 -34
  30. package/cli/templates/app-react/src/main.cj +91 -91
  31. package/cli/templates/app-vue/README.md +56 -56
  32. package/cli/templates/app-vue/assets/capabilities/default.json +7 -7
  33. package/cli/templates/app-vue/assets/ui/index.html +25 -25
  34. package/cli/templates/app-vue/assets/ui/package.json +18 -18
  35. package/cli/templates/app-vue/assets/ui/src/App.vue +176 -176
  36. package/cli/templates/app-vue/assets/ui/src/main.js +4 -4
  37. package/cli/templates/app-vue/assets/ui/vite.config.js +24 -24
  38. package/cli/templates/app-vue/cj-tauri.toml +33 -23
  39. package/cli/templates/app-vue/cjpm.toml +34 -34
  40. package/cli/templates/app-vue/src/main.cj +91 -91
  41. package/cli/templates/ohos-skeleton/AppScope/app.json5 +13 -13
  42. package/cli/templates/ohos-skeleton/AppScope/resources/base/element/string.json +8 -8
  43. package/cli/templates/ohos-skeleton/AppScope/resources/base/media/layered_image.json +6 -6
  44. package/cli/templates/ohos-skeleton/README.md +84 -79
  45. package/cli/templates/ohos-skeleton/build-hap.sh +15 -0
  46. package/cli/templates/ohos-skeleton/build-profile.json5 +46 -46
  47. package/cli/templates/ohos-skeleton/entry/build-profile.json5 +26 -26
  48. package/cli/templates/ohos-skeleton/entry/cjpm.toml +51 -43
  49. package/cli/templates/ohos-skeleton/entry/hvigorfile.ts +6 -6
  50. package/cli/templates/ohos-skeleton/entry/oh-package.json5 +9 -9
  51. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/ability_stage.cj +17 -17
  52. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/app_host.cj +94 -94
  53. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/dialog_host.cj +217 -0
  54. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/index.cj +527 -167
  55. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/main_ability.cj +139 -139
  56. package/cli/templates/ohos-skeleton/entry/src/main/module.json5 +57 -49
  57. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/element/color.json +8 -8
  58. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/element/string.json +16 -16
  59. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/media/layered_image.json +6 -6
  60. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/profile/main_pages.json +2 -2
  61. package/cli/templates/ohos-skeleton/entry/src/main/resources/rawfile/capabilities.json +6 -6
  62. package/cli/templates/ohos-skeleton/entry/src/main/resources/rawfile/index.html +92 -92
  63. package/cli/templates/ohos-skeleton/hvigor/hvigor-config.json5 +5 -5
  64. package/cli/templates/ohos-skeleton/hvigorfile.ts +6 -6
  65. package/cli/templates/ohos-skeleton/oh-package.json5 +10 -10
  66. package/native/build_win.bat +62 -62
  67. package/native/core/bridge_core.c +1681 -1626
  68. package/native/core/bridge_core.h +675 -618
  69. package/native/core/bridge_js.h +108 -108
  70. package/native/platforms/harmonyos/README.md +115 -6
  71. package/native/platforms/linux/bridge_linux.c +2787 -2485
  72. package/native/platforms/windows/bridge_win.c +3740 -3248
  73. package/native/tests/gtk_threading_probe.c +143 -143
  74. package/native/tests/test_bridge_core.c +1339 -1228
  75. package/package.json +53 -53
  76. package/prebuilt/linux-x64/cj-tauri +0 -0
  77. package/src/api_system.cj +67 -67
  78. package/src/app.cj +325 -969
  79. package/src/app_events.cj +166 -0
  80. package/src/app_host_ops.cj +287 -0
  81. package/src/app_run.cj +283 -0
  82. package/src/app_spec.cj +76 -76
  83. package/src/arkweb_menu.cj +216 -0
  84. package/src/arkweb_script.cj +142 -142
  85. package/src/capability.cj +409 -409
  86. package/src/clipboard_bridge.cj +178 -0
  87. package/src/dialog_spec.cj +79 -0
  88. package/src/dragdrop.cj +26 -26
  89. package/src/embedded_assets.cj +98 -98
  90. package/src/host.cj +752 -607
  91. package/src/host_arkweb.cj +924 -778
  92. package/src/host_webkit.cj +663 -570
  93. package/src/host_webview2.cj +663 -570
  94. package/src/hotkey.cj +66 -66
  95. package/src/ipc_hub.cj +337 -337
  96. package/src/ipc_message.cj +286 -286
  97. package/src/launch.cj +62 -62
  98. package/src/logging.cj +127 -127
  99. package/src/menu.cj +197 -197
  100. package/src/platform/platform_app_context.cj +77 -77
  101. package/src/platform/platform_assets.cj +33 -33
  102. package/src/platform/platform_discover.cj +176 -176
  103. package/src/platform/platform_env.cj +29 -29
  104. package/src/platform/platform_feature.cj +31 -31
  105. package/src/platform/platform_page_source.cj +87 -87
  106. package/src/platform/platform_target_info.cj +32 -32
  107. package/src/platform_host_provider.cj +87 -87
  108. package/src/plugin.cj +333 -333
  109. package/src/plugins/clipboard/plugin_clipboard.cj +152 -152
  110. package/src/plugins/dialog/plugin_dialog.cj +209 -209
  111. package/src/plugins/fs/plugin_fs.cj +105 -1
  112. package/src/plugins/http/http_backend.cj +131 -0
  113. package/src/plugins/http/http_desktop.cj +195 -0
  114. package/src/plugins/http/http_ohos.cj +204 -0
  115. package/src/plugins/http/plugin_http.cj +310 -459
  116. package/src/plugins/log/plugin_log.cj +105 -105
  117. package/src/plugins/menu/plugin_menu.cj +158 -158
  118. package/src/plugins/notification/plugin_notification.cj +154 -154
  119. package/src/plugins/serial/plugin_serial.cj +458 -454
  120. package/src/plugins/serial/serial_bridge.cj +194 -194
  121. package/src/plugins/serial/serial_manager_ohos.cj +92 -0
  122. package/src/plugins/serial/serial_ohos.cj +529 -0
  123. package/src/plugins/shell/plugin_shell.cj +258 -258
  124. package/src/plugins/sqlite/plugin_sqlite.cj +705 -710
  125. package/src/plugins/sqlite/sqlite_ffi.cj +206 -211
  126. package/src/plugins/ws/plugin_ws.cj +488 -470
  127. package/src/process_hidden.cj +174 -174
  128. package/src/scope.cj +391 -391
  129. package/src/shell_event.cj +69 -69
  130. package/src/tests/app_spec_test.cj +126 -127
  131. package/src/tests/capability_test.cj +243 -243
  132. package/src/tests/dialog_spec_test.cj +73 -0
  133. package/src/tests/fake_host.cj +201 -201
  134. package/src/tests/ipc_hub_test.cj +487 -487
  135. package/src/tests/logging_test.cj +62 -62
  136. package/src/tests/platform_contract_test.cj +251 -251
  137. package/src/tests/plugin_clipboard_test.cj +222 -222
  138. package/src/tests/plugin_dialog_test.cj +237 -237
  139. package/src/tests/plugin_fs_test.cj +207 -0
  140. package/src/tests/plugin_http_test.cj +124 -124
  141. package/src/tests/plugin_notification_test.cj +205 -205
  142. package/src/tests/plugin_serial_test.cj +48 -0
  143. package/src/tests/plugin_test.cj +605 -585
  144. package/src/tests/plugin_ws_test.cj +40 -40
  145. package/src/tests/process_hidden_test.cj +129 -129
  146. package/src/tests/scope_test.cj +350 -350
  147. package/src/tests/system_host_test.cj +99 -81
  148. package/src/tests/urlutil_test.cj +53 -53
  149. package/src/tests/version_test.cj +75 -75
  150. package/src/tests/window_registry_test.cj +123 -123
  151. package/src/tray.cj +90 -90
  152. package/src/urlutil.cj +159 -159
  153. package/src/version.cj +44 -44
  154. package/src/window.cj +51 -51
  155. package/src/window_registry.cj +116 -116
  156. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  157. package/src/plugins/serial/serial_ohos_stub.cj +0 -61
  158. package/src/plugins/sqlite/sqlite_ohos_stub.cj +0 -91
@@ -1,487 +1,487 @@
1
- package cjTauri.tests
2
-
3
- import cjTauri.*
4
- import std.collection.*
5
- import std.sync.*
6
- import stdx.encoding.json.*
7
- import std.unittest.*
8
- import std.unittest.testmacro.*
9
-
10
- /*
11
- * IPC 链路单元测试:解析 → 能力校验 → 分发 → 结果/错误回写 → 事件投递。
12
- * 这些用例不碰宿主层(不创建窗口),纯逻辑,可在无 GUI 的环境跑。
13
- */
14
-
15
- /** 固定返回 "ok" 的处理器 */
16
- class OkHandler <: CommandHandler {
17
- public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
18
- let _ = cmd
19
- let _ = args
20
- let _ = ipc
21
- return JsonString("ok")
22
- }
23
- }
24
-
25
- /** 抛 CommandException 的处理器(预期内的失败) */
26
- class BoomHandler <: CommandHandler {
27
- public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
28
- let _ = cmd
29
- let _ = args
30
- let _ = ipc
31
- throw CommandException("boom")
32
- }
33
- }
34
-
35
- /** 抛普通异常的处理器(预期外的失败) */
36
- class CrashHandler <: CommandHandler {
37
- public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
38
- let _ = cmd
39
- let _ = args
40
- let _ = ipc
41
- throw Exception("crash")
42
- }
43
- }
44
-
45
- /** 取 JSON 顶层字符串字段(缺失返回空串) */
46
- func strField(json: String, key: String): String {
47
- let o = JsonValue.fromStr(json).asObject()
48
- if (let Some(x) <- o.get(key)) {
49
- return x.asString().getValue()
50
- }
51
- return ""
52
- }
53
-
54
- /** 取 JSON 顶层布尔字段(缺失返回 false) */
55
- func boolField(json: String, key: String): Bool {
56
- let o = JsonValue.fromStr(json).asObject()
57
- if (let Some(x) <- o.get(key)) {
58
- return x.asBool().getValue()
59
- }
60
- return false
61
- }
62
-
63
- /*
64
- * 命令执行自「异步分发」改造起跑在 worker 线程上(见 IpcHub.handleInvoke),于是用例里出现两侧并发:
65
- * worker 线程往 sink 里写,用例线程读。两边都过同一把锁——既避免读写竞态,也借锁的释放/获取建立可见性
66
- * (没有同步点的话,写完的那侧不保证被读到)。
67
- */
68
- let TEST_SINK_LOCK = Mutex()
69
-
70
- /** 往用例 sink 追加一条(加锁;hub 的 jsSink 与断言前的等待都走它建立的顺序) */
71
- func addToTestSink(sink: ArrayList<String>, s: String): Unit {
72
- synchronized(TEST_SINK_LOCK) {
73
- sink.add(s)
74
- }
75
- }
76
-
77
- /**
78
- * 等 sink 至少收到 n 条消息(最多等 timeoutMs 毫秒后放弃,让断言自己报失败而不是挂死)。
79
- * 「已放行且已注册」的调用是异步的,断言前必须先等;「未授权 / 未注册」是同步拒绝,不必等。
80
- */
81
- func waitSink(sink: ArrayList<String>, n: Int64, timeoutMs!: Int64 = 10000): Unit {
82
- var waited: Int64 = 0
83
- while (waited < timeoutMs) {
84
- synchronized(TEST_SINK_LOCK) {
85
- if (sink.size >= n) {
86
- return
87
- }
88
- }
89
- sleep(Duration.millisecond * 5)
90
- waited += 5
91
- }
92
- }
93
-
94
- /** 组装一个「只放行指定命令/事件」的 hub,sink 收集输出 */
95
- func hubWith(commands: Array<String>, events: Array<String>, sink: ArrayList<String>): IpcHub {
96
- let hub = IpcHub()
97
- let cap = Capability("test")
98
- cap.allowWindow("main")
99
- for (c in commands) {
100
- cap.allowCommand(c)
101
- }
102
- for (e in events) {
103
- cap.allowEvent(e)
104
- }
105
- let reg = CapabilityRegistry()
106
- reg.add(cap)
107
- hub.setCapabilities(reg)
108
- // 第二个参数是目标窗口 label:单窗口用例只关心「有没有投出去」,路由由 TauriApp 层的用例覆盖
109
- hub.jsSink = { s, _ => addToTestSink(sink, s) }
110
- return hub
111
- }
112
-
113
- @Test
114
- func testInvokeRequestParseOk(): Unit {
115
- let req = InvokeRequest.parse("{\"type\":\"invoke\",\"id\":7,\"cmd\":\"greet\",\"args\":{\"who\":\"cj\"}}")
116
- @Expect(req.isSome(), true)
117
- let r = req.getOrThrow()
118
- @Expect(r.id, 7)
119
- @Expect(r.cmd, "greet")
120
- @Expect(r.args.get("who").getOrThrow().asString().getValue(), "cj")
121
- }
122
-
123
- @Test
124
- func testInvokeRequestParseToleratesMissingArgs(): Unit {
125
- let req = InvokeRequest.parse("{\"type\":\"invoke\",\"id\":1,\"cmd\":\"system:ping\"}")
126
- @Expect(req.isSome(), true)
127
- @Expect(req.getOrThrow().args.size(), 0)
128
- }
129
-
130
- @Test
131
- func testInvokeRequestParseRejectsMalformed(): Unit {
132
- // 非 JSON
133
- @Expect(InvokeRequest.parse("not json").isNone(), true)
134
- // 顶层不是对象
135
- @Expect(InvokeRequest.parse("[1,2,3]").isNone(), true)
136
- // type 不是 invoke:emit 报文由 EmitRequest.parse 接走,这边照样是 None
137
- @Expect(InvokeRequest.parse("{\"type\":\"emit\",\"id\":1,\"event\":\"x\"}").isNone(), true)
138
- // 缺 cmd
139
- @Expect(InvokeRequest.parse("{\"type\":\"invoke\",\"id\":1}").isNone(), true)
140
- // 缺 id
141
- @Expect(InvokeRequest.parse("{\"type\":\"invoke\",\"cmd\":\"greet\"}").isNone(), true)
142
- // 缺 type
143
- @Expect(InvokeRequest.parse("{\"id\":1,\"cmd\":\"greet\"}").isNone(), true)
144
- }
145
-
146
- @Test
147
- func testHubResolvesAllowedCommand(): Unit {
148
- let out = ArrayList<String>()
149
- let hub = hubWith(["greet"], [], out)
150
- hub.register("greet", OkHandler())
151
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":3,\"cmd\":\"greet\"}")
152
- waitSink(out, 1)
153
- @Expect(out.size, 1)
154
- @Expect(strField(out[0], "type"), "resolve")
155
- @Expect(strField(out[0], "data"), "ok")
156
- }
157
-
158
- @Test
159
- func testHubRejectsCommandNotAllowed(): Unit {
160
- let out = ArrayList<String>()
161
- let hub = hubWith(["system:ping"], [], out)
162
- hub.register("greet", OkHandler())
163
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":4,\"cmd\":\"greet\"}")
164
- // 未授权在**调用线程上同步拒绝**:不必等 worker,调用返回即可见
165
- @Expect(out.size, 1)
166
- @Expect(strField(out[0], "error"), "command not allowed: greet")
167
- }
168
-
169
- @Test
170
- func testHubRejectsUnregisteredCommand(): Unit {
171
- let out = ArrayList<String>()
172
- // 放行了但没注册:与「未放行」是两条不同的报错,容易混淆
173
- let hub = hubWith(["ghost"], [], out)
174
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":5,\"cmd\":\"ghost\"}")
175
- // 未注册同样是同步拒绝
176
- @Expect(out.size, 1)
177
- @Expect(strField(out[0], "error"), "command not registered: ghost")
178
- }
179
-
180
- @Test
181
- func testHubReportsCommandException(): Unit {
182
- let out = ArrayList<String>()
183
- let hub = hubWith(["boom"], [], out)
184
- hub.register("boom", BoomHandler())
185
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":6,\"cmd\":\"boom\"}")
186
- waitSink(out, 1)
187
- @Expect(out.size, 1)
188
- @Expect(strField(out[0], "error"), "boom")
189
- }
190
-
191
- @Test
192
- func testHubReportsUnexpectedException(): Unit {
193
- let out = ArrayList<String>()
194
- let hub = hubWith(["crash"], [], out)
195
- hub.register("crash", CrashHandler())
196
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":8,\"cmd\":\"crash\"}")
197
- waitSink(out, 1)
198
- @Expect(out.size, 1)
199
- // 预期外的异常统一加前缀,前端据此区分「业务拒绝」与「崩了」
200
- @Expect(strField(out[0], "error").startsWith("command failed:"), true)
201
- }
202
-
203
- @Test
204
- func testHubIgnoresMessageWithoutId(): Unit {
205
- let out = ArrayList<String>()
206
- let hub = hubWith(["greet"], ["tick"], out)
207
- hub.register("greet", OkHandler())
208
- // 结构坏掉且**读不出 id**:对应不到任何 promise,只能 stderr(这是设计,不是遗留问题)
209
- hub.handleRawMessage("garbage")
210
- hub.handleRawMessage("{\"type\":\"emit\",\"event\":\"tick\"}")
211
- hub.handleRawMessage("{\"type\":\"invoke\"}")
212
- @Expect(out.size, 0)
213
- }
214
-
215
- @Test
216
- func testEmitRequiresDeclaredEvent(): Unit {
217
- let denied = ArrayList<String>()
218
- let hubDenied = hubWith(["greet"], [], denied)
219
- hubDenied.emitToWindow("tick", JsonObject(), "main")
220
- @Expect(denied.size, 0)
221
-
222
- let allowed = ArrayList<String>()
223
- let hubAllowed = hubWith(["greet"], ["tick"], allowed)
224
- hubAllowed.emitToWindow("tick", JsonObject(), "main")
225
- @Expect(allowed.size, 1)
226
- @Expect(strField(allowed[0], "type"), "event")
227
- @Expect(strField(allowed[0], "event"), "tick")
228
- }
229
-
230
- @Test
231
- func testIpcMessageResolveKeepsPayload(): Unit {
232
- var payload = JsonObject()
233
- payload.put("n", JsonInt(1))
234
- let msg = IpcMessage.resolve(9, payload)
235
- @Expect(strField(msg, "type"), "resolve")
236
- let o = JsonValue.fromStr(msg).asObject()
237
- @Expect(o.get("data").getOrThrow().asObject().get("n").getOrThrow().asInt().getValue(), 1)
238
- }
239
-
240
- /*
241
- * 以下是「异步分发」的行为契约(命令执行跑在 worker 线程上,见 IpcHub.handleInvoke):
242
- * 调用线程只做校验并立刻返回,慢命令不再把宿主 UI 线程钉住。
243
- */
244
-
245
- /** 睡 300ms 再回答:模拟 `shell:exec` 这类慢命令 */
246
- class SlowHandler <: CommandHandler {
247
- public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
248
- let _ = cmd
249
- let _ = args
250
- let _ = ipc
251
- sleep(Duration.millisecond * 300)
252
- return JsonString("slow-ok")
253
- }
254
- }
255
-
256
- @Test
257
- func testDispatchReturnsBeforeSlowCommandFinishes(): Unit {
258
- let out = ArrayList<String>()
259
- let hub = hubWith(["slow"], [], out)
260
- hub.register("slow", SlowHandler())
261
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":41,\"cmd\":\"slow\"}")
262
- // 关键:handler 还在睡,调用就已经返回了 —— 这就是「UI 线程不被命令阻塞」的单元级证据
263
- // (handler 睡 300ms,这两行断言在微秒级完成,不存在轮到 worker 跑完的可能)
264
- @Expect(out.size, 0)
265
- waitSink(out, 1)
266
- @Expect(strField(out[0], "type"), "resolve")
267
- @Expect(strField(out[0], "data"), "slow-ok")
268
- }
269
-
270
- @Test
271
- func testRejectsStaySynchronous(): Unit {
272
- let out = ArrayList<String>()
273
- let hub = hubWith(["allowed"], [], out)
274
- // 未授权:调用线程上同步回写,调用一返回就可见,不需要等
275
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":42,\"cmd\":\"nope\"}")
276
- @Expect(out.size, 1)
277
- @Expect(strField(out[0], "error"), "command not allowed: nope")
278
- // 未注册:同样同步(放行了但没注册,两条报错别混)
279
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":43,\"cmd\":\"allowed\"}")
280
- @Expect(out.size, 2)
281
- @Expect(strField(out[1], "error"), "command not registered: allowed")
282
- }
283
-
284
- @Test
285
- func testConcurrentInvokesEachResolveExactlyOnce(): Unit {
286
- let out = ArrayList<String>()
287
- let hub = hubWith(["slow"], [], out)
288
- hub.register("slow", SlowHandler())
289
- // 同一命令连发 6 次:**不保证执行顺序**(各自独立跑,谁先完谁先回),但每条 id 必须有且只有一条响应
290
- for (i in 50..56) {
291
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":${i},\"cmd\":\"slow\"}")
292
- }
293
- waitSink(out, 6, timeoutMs: 20000)
294
- @Expect(out.size, 6)
295
- let ids = ArrayList<Int64>()
296
- for (m in out) {
297
- @Expect(strField(m, "type"), "resolve")
298
- ids.add(JsonValue.fromStr(m).asObject().get("id").getOrThrow().asInt().getValue())
299
- }
300
- for (i in 50..56) {
301
- var hits = 0
302
- for (id in ids) {
303
- if (id == i) {
304
- hits += 1
305
- }
306
- }
307
- // 既不丢(0)也不重复(2):并发下每条 invoke 恰好被回投一次
308
- @Expect(hits, 1)
309
- }
310
- }
311
-
312
- /*
313
- * 前端 emit(P1-4 缺口):只投后端监听器、不回投页面;未授权与非法报文的失败都要回给前端。
314
- * 与 Tauri v2 的差异是有意选的(见 EmitRequest 注释 / docs/IPC-通信机制.md §7)。
315
- */
316
-
317
- /** 抛异常的监听器:验证「一个听众崩了不牵连别的听众与发送方」 */
318
- func boomListener(p: JsonValue): Unit {
319
- let _ = p
320
- throw Exception("listener boom")
321
- }
322
-
323
- @Test
324
- func testEmitRequestParseOk(): Unit {
325
- let req = EmitRequest.parse("{\"type\":\"emit\",\"id\":9,\"event\":\"tick\",\"payload\":{\"n\":1}}")
326
- @Expect(req.isSome(), true)
327
- let r = req.getOrThrow()
328
- @Expect(r.id, 9)
329
- @Expect(r.event, "tick")
330
- @Expect(r.payload.asObject().get("n").getOrThrow().asInt().getValue(), 1)
331
- }
332
-
333
- @Test
334
- func testEmitRequestParseDefaultsPayloadAndRejectsMalformed(): Unit {
335
- // 前端 emit('tick') 不带第二个参数:payload 缺省成空对象
336
- let bare = EmitRequest.parse("{\"type\":\"emit\",\"id\":1,\"event\":\"tick\"}")
337
- @Expect(bare.isSome(), true)
338
- @Expect(bare.getOrThrow().payload.asObject().size(), 0)
339
- // 缺 id / 缺 event / 类型不对,都不是合法 emit 报文
340
- @Expect(EmitRequest.parse("{\"type\":\"emit\",\"event\":\"tick\"}").isNone(), true)
341
- @Expect(EmitRequest.parse("{\"type\":\"emit\",\"id\":1}").isNone(), true)
342
- @Expect(EmitRequest.parse("{\"type\":\"invoke\",\"id\":1,\"cmd\":\"greet\"}").isNone(), true)
343
- @Expect(EmitRequest.parse("not json").isNone(), true)
344
- }
345
-
346
- @Test
347
- func testFrontendEmitReachesBackendListener(): Unit {
348
- let out = ArrayList<String>()
349
- let hub = hubWith([], ["tick"], out)
350
- let seen = ArrayList<Int64>()
351
- hub.listenEvent("tick", { p =>
352
- synchronized(TEST_SINK_LOCK) {
353
- seen.add(p.asObject().get("n").getOrThrow().asInt().getValue())
354
- }
355
- })
356
- hub.handleRawMessage("{\"type\":\"emit\",\"id\":21,\"event\":\"tick\",\"payload\":{\"n\":7}}")
357
- waitSink(out, 1)
358
- @Expect(seen.size, 1)
359
- @Expect(seen[0], 7)
360
- @Expect(out.size, 1)
361
- @Expect(strField(out[0], "type"), "resolve")
362
- @Expect(boolField(out[0], "ok"), true)
363
- }
364
-
365
- @Test
366
- func testAllListenersOfEventAreCalled(): Unit {
367
- let out = ArrayList<String>()
368
- let hub = hubWith([], ["tick"], out)
369
- let hits = ArrayList<String>()
370
- hub.listenEvent("tick", { _ => synchronized(TEST_SINK_LOCK) { hits.add("a") } })
371
- hub.listenEvent("tick", { _ => synchronized(TEST_SINK_LOCK) { hits.add("b") } })
372
- hub.handleRawMessage("{\"type\":\"emit\",\"id\":23,\"event\":\"tick\",\"payload\":{}}")
373
- waitSink(out, 1)
374
- @Expect(hits.size, 2)
375
- }
376
-
377
- @Test
378
- func testFailingListenerDoesNotBreakOthers(): Unit {
379
- let out = ArrayList<String>()
380
- let hub = hubWith([], ["tick"], out)
381
- hub.listenEvent("tick", boomListener)
382
- let ok = ArrayList<Bool>()
383
- hub.listenEvent("tick", { _ => synchronized(TEST_SINK_LOCK) { ok.add(true) } })
384
- hub.handleRawMessage("{\"type\":\"emit\",\"id\":24,\"event\":\"tick\",\"payload\":{}}")
385
- waitSink(out, 1)
386
- // 事件是广播语义:一个听众抛错不该牵连其它听众,也不该让发送方收到失败
387
- @Expect(ok.size, 1)
388
- @Expect(boolField(out[0], "ok"), true)
389
- }
390
-
391
- @Test
392
- func testFrontendEmitDeniedEventIsRejected(): Unit {
393
- let out = ArrayList<String>()
394
- let hub = hubWith([], ["tick"], out)
395
- // 未授权的事件:与命令一样在调用线程上同步拒绝,不必等 worker
396
- hub.handleRawMessage("{\"type\":\"emit\",\"id\":22,\"event\":\"nope\",\"payload\":{}}")
397
- @Expect(out.size, 1)
398
- @Expect(boolField(out[0], "ok"), false)
399
- @Expect(strField(out[0], "error"), "event not allowed: nope")
400
- }
401
-
402
- @Test
403
- func testFrontendEmitWithoutListenerStillResolves(): Unit {
404
- let out = ArrayList<String>()
405
- let hub = hubWith([], ["tick"], out)
406
- // 授权了但没人听:不是错误(Tauri 同样不报),发送方照常拿到 resolve
407
- hub.handleRawMessage("{\"type\":\"emit\",\"id\":25,\"event\":\"tick\",\"payload\":{}}")
408
- waitSink(out, 1)
409
- @Expect(out.size, 1)
410
- @Expect(boolField(out[0], "ok"), true)
411
- }
412
-
413
- @Test
414
- func testMalformedMessageWithIdIsRejected(): Unit {
415
- let out = ArrayList<String>()
416
- let hub = hubWith([], ["tick"], out)
417
- // 未知报文类型但 id 可读:回一条 reject,前端那条 promise 不再永久 pending
418
- hub.handleRawMessage("{\"type\":\"wat\",\"id\":31}")
419
- @Expect(out.size, 1)
420
- @Expect(boolField(out[0], "ok"), false)
421
- @Expect(strField(out[0], "error"), "invalid IPC message")
422
- }
423
-
424
- @Test
425
- func testEventCarriesWindowLabelOrNothingWhenBroadcast(): Unit {
426
- let out = ArrayList<String>()
427
- let hub = hubWith([], ["tick"], out)
428
- hub.emitToWindow("tick", JsonObject(), "main")
429
- @Expect(strField(out[0], "window"), "main")
430
- hub.emitAll("tick", JsonObject())
431
- // 广播不带 window 字段:页面里由桥放行(多窗口过滤靠它)
432
- @Expect(JsonValue.fromStr(out[1]).asObject().get("window").isNone(), true)
433
- }
434
-
435
- /*
436
- * emitLatest:显式 opt-in 的「同事件同帧只投最新值」。
437
- * 报文侧只该多一个 coalesce:true,页面侧的合并(一帧一次、只投尾值)由桥 JS 做,
438
- * 所以单元测试能钉住的是「报文契约」与「能力白名单」两条;合并行为本身靠实机探针取证
439
- * (见 docs/IPC-通信机制.md §6 第 5 项)。
440
- */
441
-
442
- @Test
443
- func testEmitLatestMarksCoalesceAndKeepsPayload(): Unit {
444
- let out = ArrayList<String>()
445
- let hub = hubWith([], ["progress"], out)
446
- var payload = JsonObject()
447
- payload.put("n", JsonInt(3))
448
- hub.emitLatest("progress", payload, "")
449
- @Expect(out.size, 1)
450
- @Expect(strField(out[0], "type"), "event")
451
- @Expect(strField(out[0], "event"), "progress")
452
- @Expect(boolField(out[0], "coalesce"), true)
453
- let n = JsonValue.fromStr(out[0]).asObject().get("payload").getOrThrow().asObject().get("n").getOrThrow().asInt().getValue()
454
- @Expect(n, 3)
455
- // 广播不带 window 字段(与 emitAll 一致)
456
- @Expect(JsonValue.fromStr(out[0]).asObject().get("window").isNone(), true)
457
- }
458
-
459
- @Test
460
- func testEmitLatestCarriesWindowLabelWhenTargeted(): Unit {
461
- let out = ArrayList<String>()
462
- let hub = hubWith([], ["progress"], out)
463
- hub.emitLatest("progress", JsonObject(), "main")
464
- @Expect(strField(out[0], "window"), "main")
465
- @Expect(boolField(out[0], "coalesce"), true)
466
- }
467
-
468
- @Test
469
- func testEmitLatestRespectsCapabilityDeny(): Unit {
470
- let out = ArrayList<String>()
471
- let hub = hubWith([], ["other"], out)
472
- // 未授权的事件与 emit 同一套白名单:不投递,也不能因为「只是合并语义」就绕过声明
473
- hub.emitLatest("progress", JsonObject(), "")
474
- @Expect(out.size, 0)
475
- }
476
-
477
- @Test
478
- func testPlainEmitHasNoCoalesceField(): Unit {
479
- let out = ArrayList<String>()
480
- let hub = hubWith([], ["tick"], out)
481
- hub.emitAll("tick", JsonObject())
482
- // 默认路径不做合并:报文里不能出现 coalesce 字段(合并必须显式 opt-in)
483
- @Expect(JsonValue.fromStr(out[0]).asObject().get("coalesce").isNone(), true)
484
- hub.emitToWindow("tick", JsonObject(), "main")
485
- @Expect(JsonValue.fromStr(out[1]).asObject().get("coalesce").isNone(), true)
486
- @Expect(strField(out[1], "window"), "main")
487
- }
1
+ package cjTauri.tests
2
+
3
+ import cjTauri.*
4
+ import std.collection.*
5
+ import std.sync.*
6
+ import stdx.encoding.json.*
7
+ import std.unittest.*
8
+ import std.unittest.testmacro.*
9
+
10
+ /*
11
+ * IPC 链路单元测试:解析 → 能力校验 → 分发 → 结果/错误回写 → 事件投递。
12
+ * 这些用例不碰宿主层(不创建窗口),纯逻辑,可在无 GUI 的环境跑。
13
+ */
14
+
15
+ /** 固定返回 "ok" 的处理器 */
16
+ class OkHandler <: CommandHandler {
17
+ public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
18
+ let _ = cmd
19
+ let _ = args
20
+ let _ = ipc
21
+ return JsonString("ok")
22
+ }
23
+ }
24
+
25
+ /** 抛 CommandException 的处理器(预期内的失败) */
26
+ class BoomHandler <: CommandHandler {
27
+ public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
28
+ let _ = cmd
29
+ let _ = args
30
+ let _ = ipc
31
+ throw CommandException("boom")
32
+ }
33
+ }
34
+
35
+ /** 抛普通异常的处理器(预期外的失败) */
36
+ class CrashHandler <: CommandHandler {
37
+ public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
38
+ let _ = cmd
39
+ let _ = args
40
+ let _ = ipc
41
+ throw Exception("crash")
42
+ }
43
+ }
44
+
45
+ /** 取 JSON 顶层字符串字段(缺失返回空串) */
46
+ func strField(json: String, key: String): String {
47
+ let o = JsonValue.fromStr(json).asObject()
48
+ if (let Some(x) <- o.get(key)) {
49
+ return x.asString().getValue()
50
+ }
51
+ return ""
52
+ }
53
+
54
+ /** 取 JSON 顶层布尔字段(缺失返回 false) */
55
+ func boolField(json: String, key: String): Bool {
56
+ let o = JsonValue.fromStr(json).asObject()
57
+ if (let Some(x) <- o.get(key)) {
58
+ return x.asBool().getValue()
59
+ }
60
+ return false
61
+ }
62
+
63
+ /*
64
+ * 命令执行自「异步分发」改造起跑在 worker 线程上(见 IpcHub.handleInvoke),于是用例里出现两侧并发:
65
+ * worker 线程往 sink 里写,用例线程读。两边都过同一把锁——既避免读写竞态,也借锁的释放/获取建立可见性
66
+ * (没有同步点的话,写完的那侧不保证被读到)。
67
+ */
68
+ let TEST_SINK_LOCK = Mutex()
69
+
70
+ /** 往用例 sink 追加一条(加锁;hub 的 jsSink 与断言前的等待都走它建立的顺序) */
71
+ func addToTestSink(sink: ArrayList<String>, s: String): Unit {
72
+ synchronized(TEST_SINK_LOCK) {
73
+ sink.add(s)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 等 sink 至少收到 n 条消息(最多等 timeoutMs 毫秒后放弃,让断言自己报失败而不是挂死)。
79
+ * 「已放行且已注册」的调用是异步的,断言前必须先等;「未授权 / 未注册」是同步拒绝,不必等。
80
+ */
81
+ func waitSink(sink: ArrayList<String>, n: Int64, timeoutMs!: Int64 = 10000): Unit {
82
+ var waited: Int64 = 0
83
+ while (waited < timeoutMs) {
84
+ synchronized(TEST_SINK_LOCK) {
85
+ if (sink.size >= n) {
86
+ return
87
+ }
88
+ }
89
+ sleep(Duration.millisecond * 5)
90
+ waited += 5
91
+ }
92
+ }
93
+
94
+ /** 组装一个「只放行指定命令/事件」的 hub,sink 收集输出 */
95
+ func hubWith(commands: Array<String>, events: Array<String>, sink: ArrayList<String>): IpcHub {
96
+ let hub = IpcHub()
97
+ let cap = Capability("test")
98
+ cap.allowWindow("main")
99
+ for (c in commands) {
100
+ cap.allowCommand(c)
101
+ }
102
+ for (e in events) {
103
+ cap.allowEvent(e)
104
+ }
105
+ let reg = CapabilityRegistry()
106
+ reg.add(cap)
107
+ hub.setCapabilities(reg)
108
+ // 第二个参数是目标窗口 label:单窗口用例只关心「有没有投出去」,路由由 TauriApp 层的用例覆盖
109
+ hub.jsSink = { s, _ => addToTestSink(sink, s) }
110
+ return hub
111
+ }
112
+
113
+ @Test
114
+ func testInvokeRequestParseOk(): Unit {
115
+ let req = InvokeRequest.parse("{\"type\":\"invoke\",\"id\":7,\"cmd\":\"greet\",\"args\":{\"who\":\"cj\"}}")
116
+ @Expect(req.isSome(), true)
117
+ let r = req.getOrThrow()
118
+ @Expect(r.id, 7)
119
+ @Expect(r.cmd, "greet")
120
+ @Expect(r.args.get("who").getOrThrow().asString().getValue(), "cj")
121
+ }
122
+
123
+ @Test
124
+ func testInvokeRequestParseToleratesMissingArgs(): Unit {
125
+ let req = InvokeRequest.parse("{\"type\":\"invoke\",\"id\":1,\"cmd\":\"system:ping\"}")
126
+ @Expect(req.isSome(), true)
127
+ @Expect(req.getOrThrow().args.size(), 0)
128
+ }
129
+
130
+ @Test
131
+ func testInvokeRequestParseRejectsMalformed(): Unit {
132
+ // 非 JSON
133
+ @Expect(InvokeRequest.parse("not json").isNone(), true)
134
+ // 顶层不是对象
135
+ @Expect(InvokeRequest.parse("[1,2,3]").isNone(), true)
136
+ // type 不是 invoke:emit 报文由 EmitRequest.parse 接走,这边照样是 None
137
+ @Expect(InvokeRequest.parse("{\"type\":\"emit\",\"id\":1,\"event\":\"x\"}").isNone(), true)
138
+ // 缺 cmd
139
+ @Expect(InvokeRequest.parse("{\"type\":\"invoke\",\"id\":1}").isNone(), true)
140
+ // 缺 id
141
+ @Expect(InvokeRequest.parse("{\"type\":\"invoke\",\"cmd\":\"greet\"}").isNone(), true)
142
+ // 缺 type
143
+ @Expect(InvokeRequest.parse("{\"id\":1,\"cmd\":\"greet\"}").isNone(), true)
144
+ }
145
+
146
+ @Test
147
+ func testHubResolvesAllowedCommand(): Unit {
148
+ let out = ArrayList<String>()
149
+ let hub = hubWith(["greet"], [], out)
150
+ hub.register("greet", OkHandler())
151
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":3,\"cmd\":\"greet\"}")
152
+ waitSink(out, 1)
153
+ @Expect(out.size, 1)
154
+ @Expect(strField(out[0], "type"), "resolve")
155
+ @Expect(strField(out[0], "data"), "ok")
156
+ }
157
+
158
+ @Test
159
+ func testHubRejectsCommandNotAllowed(): Unit {
160
+ let out = ArrayList<String>()
161
+ let hub = hubWith(["system:ping"], [], out)
162
+ hub.register("greet", OkHandler())
163
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":4,\"cmd\":\"greet\"}")
164
+ // 未授权在**调用线程上同步拒绝**:不必等 worker,调用返回即可见
165
+ @Expect(out.size, 1)
166
+ @Expect(strField(out[0], "error"), "command not allowed: greet")
167
+ }
168
+
169
+ @Test
170
+ func testHubRejectsUnregisteredCommand(): Unit {
171
+ let out = ArrayList<String>()
172
+ // 放行了但没注册:与「未放行」是两条不同的报错,容易混淆
173
+ let hub = hubWith(["ghost"], [], out)
174
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":5,\"cmd\":\"ghost\"}")
175
+ // 未注册同样是同步拒绝
176
+ @Expect(out.size, 1)
177
+ @Expect(strField(out[0], "error"), "command not registered: ghost")
178
+ }
179
+
180
+ @Test
181
+ func testHubReportsCommandException(): Unit {
182
+ let out = ArrayList<String>()
183
+ let hub = hubWith(["boom"], [], out)
184
+ hub.register("boom", BoomHandler())
185
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":6,\"cmd\":\"boom\"}")
186
+ waitSink(out, 1)
187
+ @Expect(out.size, 1)
188
+ @Expect(strField(out[0], "error"), "boom")
189
+ }
190
+
191
+ @Test
192
+ func testHubReportsUnexpectedException(): Unit {
193
+ let out = ArrayList<String>()
194
+ let hub = hubWith(["crash"], [], out)
195
+ hub.register("crash", CrashHandler())
196
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":8,\"cmd\":\"crash\"}")
197
+ waitSink(out, 1)
198
+ @Expect(out.size, 1)
199
+ // 预期外的异常统一加前缀,前端据此区分「业务拒绝」与「崩了」
200
+ @Expect(strField(out[0], "error").startsWith("command failed:"), true)
201
+ }
202
+
203
+ @Test
204
+ func testHubIgnoresMessageWithoutId(): Unit {
205
+ let out = ArrayList<String>()
206
+ let hub = hubWith(["greet"], ["tick"], out)
207
+ hub.register("greet", OkHandler())
208
+ // 结构坏掉且**读不出 id**:对应不到任何 promise,只能 stderr(这是设计,不是遗留问题)
209
+ hub.handleRawMessage("garbage")
210
+ hub.handleRawMessage("{\"type\":\"emit\",\"event\":\"tick\"}")
211
+ hub.handleRawMessage("{\"type\":\"invoke\"}")
212
+ @Expect(out.size, 0)
213
+ }
214
+
215
+ @Test
216
+ func testEmitRequiresDeclaredEvent(): Unit {
217
+ let denied = ArrayList<String>()
218
+ let hubDenied = hubWith(["greet"], [], denied)
219
+ hubDenied.emitToWindow("tick", JsonObject(), "main")
220
+ @Expect(denied.size, 0)
221
+
222
+ let allowed = ArrayList<String>()
223
+ let hubAllowed = hubWith(["greet"], ["tick"], allowed)
224
+ hubAllowed.emitToWindow("tick", JsonObject(), "main")
225
+ @Expect(allowed.size, 1)
226
+ @Expect(strField(allowed[0], "type"), "event")
227
+ @Expect(strField(allowed[0], "event"), "tick")
228
+ }
229
+
230
+ @Test
231
+ func testIpcMessageResolveKeepsPayload(): Unit {
232
+ var payload = JsonObject()
233
+ payload.put("n", JsonInt(1))
234
+ let msg = IpcMessage.resolve(9, payload)
235
+ @Expect(strField(msg, "type"), "resolve")
236
+ let o = JsonValue.fromStr(msg).asObject()
237
+ @Expect(o.get("data").getOrThrow().asObject().get("n").getOrThrow().asInt().getValue(), 1)
238
+ }
239
+
240
+ /*
241
+ * 以下是「异步分发」的行为契约(命令执行跑在 worker 线程上,见 IpcHub.handleInvoke):
242
+ * 调用线程只做校验并立刻返回,慢命令不再把宿主 UI 线程钉住。
243
+ */
244
+
245
+ /** 睡 300ms 再回答:模拟 `shell:exec` 这类慢命令 */
246
+ class SlowHandler <: CommandHandler {
247
+ public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
248
+ let _ = cmd
249
+ let _ = args
250
+ let _ = ipc
251
+ sleep(Duration.millisecond * 300)
252
+ return JsonString("slow-ok")
253
+ }
254
+ }
255
+
256
+ @Test
257
+ func testDispatchReturnsBeforeSlowCommandFinishes(): Unit {
258
+ let out = ArrayList<String>()
259
+ let hub = hubWith(["slow"], [], out)
260
+ hub.register("slow", SlowHandler())
261
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":41,\"cmd\":\"slow\"}")
262
+ // 关键:handler 还在睡,调用就已经返回了 —— 这就是「UI 线程不被命令阻塞」的单元级证据
263
+ // (handler 睡 300ms,这两行断言在微秒级完成,不存在轮到 worker 跑完的可能)
264
+ @Expect(out.size, 0)
265
+ waitSink(out, 1)
266
+ @Expect(strField(out[0], "type"), "resolve")
267
+ @Expect(strField(out[0], "data"), "slow-ok")
268
+ }
269
+
270
+ @Test
271
+ func testRejectsStaySynchronous(): Unit {
272
+ let out = ArrayList<String>()
273
+ let hub = hubWith(["allowed"], [], out)
274
+ // 未授权:调用线程上同步回写,调用一返回就可见,不需要等
275
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":42,\"cmd\":\"nope\"}")
276
+ @Expect(out.size, 1)
277
+ @Expect(strField(out[0], "error"), "command not allowed: nope")
278
+ // 未注册:同样同步(放行了但没注册,两条报错别混)
279
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":43,\"cmd\":\"allowed\"}")
280
+ @Expect(out.size, 2)
281
+ @Expect(strField(out[1], "error"), "command not registered: allowed")
282
+ }
283
+
284
+ @Test
285
+ func testConcurrentInvokesEachResolveExactlyOnce(): Unit {
286
+ let out = ArrayList<String>()
287
+ let hub = hubWith(["slow"], [], out)
288
+ hub.register("slow", SlowHandler())
289
+ // 同一命令连发 6 次:**不保证执行顺序**(各自独立跑,谁先完谁先回),但每条 id 必须有且只有一条响应
290
+ for (i in 50..56) {
291
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":${i},\"cmd\":\"slow\"}")
292
+ }
293
+ waitSink(out, 6, timeoutMs: 20000)
294
+ @Expect(out.size, 6)
295
+ let ids = ArrayList<Int64>()
296
+ for (m in out) {
297
+ @Expect(strField(m, "type"), "resolve")
298
+ ids.add(JsonValue.fromStr(m).asObject().get("id").getOrThrow().asInt().getValue())
299
+ }
300
+ for (i in 50..56) {
301
+ var hits = 0
302
+ for (id in ids) {
303
+ if (id == i) {
304
+ hits += 1
305
+ }
306
+ }
307
+ // 既不丢(0)也不重复(2):并发下每条 invoke 恰好被回投一次
308
+ @Expect(hits, 1)
309
+ }
310
+ }
311
+
312
+ /*
313
+ * 前端 emit(P1-4 缺口):只投后端监听器、不回投页面;未授权与非法报文的失败都要回给前端。
314
+ * 与 Tauri v2 的差异是有意选的(见 EmitRequest 注释 / docs/IPC-通信机制.md §7)。
315
+ */
316
+
317
+ /** 抛异常的监听器:验证「一个听众崩了不牵连别的听众与发送方」 */
318
+ func boomListener(p: JsonValue): Unit {
319
+ let _ = p
320
+ throw Exception("listener boom")
321
+ }
322
+
323
+ @Test
324
+ func testEmitRequestParseOk(): Unit {
325
+ let req = EmitRequest.parse("{\"type\":\"emit\",\"id\":9,\"event\":\"tick\",\"payload\":{\"n\":1}}")
326
+ @Expect(req.isSome(), true)
327
+ let r = req.getOrThrow()
328
+ @Expect(r.id, 9)
329
+ @Expect(r.event, "tick")
330
+ @Expect(r.payload.asObject().get("n").getOrThrow().asInt().getValue(), 1)
331
+ }
332
+
333
+ @Test
334
+ func testEmitRequestParseDefaultsPayloadAndRejectsMalformed(): Unit {
335
+ // 前端 emit('tick') 不带第二个参数:payload 缺省成空对象
336
+ let bare = EmitRequest.parse("{\"type\":\"emit\",\"id\":1,\"event\":\"tick\"}")
337
+ @Expect(bare.isSome(), true)
338
+ @Expect(bare.getOrThrow().payload.asObject().size(), 0)
339
+ // 缺 id / 缺 event / 类型不对,都不是合法 emit 报文
340
+ @Expect(EmitRequest.parse("{\"type\":\"emit\",\"event\":\"tick\"}").isNone(), true)
341
+ @Expect(EmitRequest.parse("{\"type\":\"emit\",\"id\":1}").isNone(), true)
342
+ @Expect(EmitRequest.parse("{\"type\":\"invoke\",\"id\":1,\"cmd\":\"greet\"}").isNone(), true)
343
+ @Expect(EmitRequest.parse("not json").isNone(), true)
344
+ }
345
+
346
+ @Test
347
+ func testFrontendEmitReachesBackendListener(): Unit {
348
+ let out = ArrayList<String>()
349
+ let hub = hubWith([], ["tick"], out)
350
+ let seen = ArrayList<Int64>()
351
+ hub.listenEvent("tick", { p =>
352
+ synchronized(TEST_SINK_LOCK) {
353
+ seen.add(p.asObject().get("n").getOrThrow().asInt().getValue())
354
+ }
355
+ })
356
+ hub.handleRawMessage("{\"type\":\"emit\",\"id\":21,\"event\":\"tick\",\"payload\":{\"n\":7}}")
357
+ waitSink(out, 1)
358
+ @Expect(seen.size, 1)
359
+ @Expect(seen[0], 7)
360
+ @Expect(out.size, 1)
361
+ @Expect(strField(out[0], "type"), "resolve")
362
+ @Expect(boolField(out[0], "ok"), true)
363
+ }
364
+
365
+ @Test
366
+ func testAllListenersOfEventAreCalled(): Unit {
367
+ let out = ArrayList<String>()
368
+ let hub = hubWith([], ["tick"], out)
369
+ let hits = ArrayList<String>()
370
+ hub.listenEvent("tick", { _ => synchronized(TEST_SINK_LOCK) { hits.add("a") } })
371
+ hub.listenEvent("tick", { _ => synchronized(TEST_SINK_LOCK) { hits.add("b") } })
372
+ hub.handleRawMessage("{\"type\":\"emit\",\"id\":23,\"event\":\"tick\",\"payload\":{}}")
373
+ waitSink(out, 1)
374
+ @Expect(hits.size, 2)
375
+ }
376
+
377
+ @Test
378
+ func testFailingListenerDoesNotBreakOthers(): Unit {
379
+ let out = ArrayList<String>()
380
+ let hub = hubWith([], ["tick"], out)
381
+ hub.listenEvent("tick", boomListener)
382
+ let ok = ArrayList<Bool>()
383
+ hub.listenEvent("tick", { _ => synchronized(TEST_SINK_LOCK) { ok.add(true) } })
384
+ hub.handleRawMessage("{\"type\":\"emit\",\"id\":24,\"event\":\"tick\",\"payload\":{}}")
385
+ waitSink(out, 1)
386
+ // 事件是广播语义:一个听众抛错不该牵连其它听众,也不该让发送方收到失败
387
+ @Expect(ok.size, 1)
388
+ @Expect(boolField(out[0], "ok"), true)
389
+ }
390
+
391
+ @Test
392
+ func testFrontendEmitDeniedEventIsRejected(): Unit {
393
+ let out = ArrayList<String>()
394
+ let hub = hubWith([], ["tick"], out)
395
+ // 未授权的事件:与命令一样在调用线程上同步拒绝,不必等 worker
396
+ hub.handleRawMessage("{\"type\":\"emit\",\"id\":22,\"event\":\"nope\",\"payload\":{}}")
397
+ @Expect(out.size, 1)
398
+ @Expect(boolField(out[0], "ok"), false)
399
+ @Expect(strField(out[0], "error"), "event not allowed: nope")
400
+ }
401
+
402
+ @Test
403
+ func testFrontendEmitWithoutListenerStillResolves(): Unit {
404
+ let out = ArrayList<String>()
405
+ let hub = hubWith([], ["tick"], out)
406
+ // 授权了但没人听:不是错误(Tauri 同样不报),发送方照常拿到 resolve
407
+ hub.handleRawMessage("{\"type\":\"emit\",\"id\":25,\"event\":\"tick\",\"payload\":{}}")
408
+ waitSink(out, 1)
409
+ @Expect(out.size, 1)
410
+ @Expect(boolField(out[0], "ok"), true)
411
+ }
412
+
413
+ @Test
414
+ func testMalformedMessageWithIdIsRejected(): Unit {
415
+ let out = ArrayList<String>()
416
+ let hub = hubWith([], ["tick"], out)
417
+ // 未知报文类型但 id 可读:回一条 reject,前端那条 promise 不再永久 pending
418
+ hub.handleRawMessage("{\"type\":\"wat\",\"id\":31}")
419
+ @Expect(out.size, 1)
420
+ @Expect(boolField(out[0], "ok"), false)
421
+ @Expect(strField(out[0], "error"), "invalid IPC message")
422
+ }
423
+
424
+ @Test
425
+ func testEventCarriesWindowLabelOrNothingWhenBroadcast(): Unit {
426
+ let out = ArrayList<String>()
427
+ let hub = hubWith([], ["tick"], out)
428
+ hub.emitToWindow("tick", JsonObject(), "main")
429
+ @Expect(strField(out[0], "window"), "main")
430
+ hub.emitAll("tick", JsonObject())
431
+ // 广播不带 window 字段:页面里由桥放行(多窗口过滤靠它)
432
+ @Expect(JsonValue.fromStr(out[1]).asObject().get("window").isNone(), true)
433
+ }
434
+
435
+ /*
436
+ * emitLatest:显式 opt-in 的「同事件同帧只投最新值」。
437
+ * 报文侧只该多一个 coalesce:true,页面侧的合并(一帧一次、只投尾值)由桥 JS 做,
438
+ * 所以单元测试能钉住的是「报文契约」与「能力白名单」两条;合并行为本身靠实机探针取证
439
+ * (见 docs/IPC-通信机制.md §6 第 5 项)。
440
+ */
441
+
442
+ @Test
443
+ func testEmitLatestMarksCoalesceAndKeepsPayload(): Unit {
444
+ let out = ArrayList<String>()
445
+ let hub = hubWith([], ["progress"], out)
446
+ var payload = JsonObject()
447
+ payload.put("n", JsonInt(3))
448
+ hub.emitLatest("progress", payload, "")
449
+ @Expect(out.size, 1)
450
+ @Expect(strField(out[0], "type"), "event")
451
+ @Expect(strField(out[0], "event"), "progress")
452
+ @Expect(boolField(out[0], "coalesce"), true)
453
+ let n = JsonValue.fromStr(out[0]).asObject().get("payload").getOrThrow().asObject().get("n").getOrThrow().asInt().getValue()
454
+ @Expect(n, 3)
455
+ // 广播不带 window 字段(与 emitAll 一致)
456
+ @Expect(JsonValue.fromStr(out[0]).asObject().get("window").isNone(), true)
457
+ }
458
+
459
+ @Test
460
+ func testEmitLatestCarriesWindowLabelWhenTargeted(): Unit {
461
+ let out = ArrayList<String>()
462
+ let hub = hubWith([], ["progress"], out)
463
+ hub.emitLatest("progress", JsonObject(), "main")
464
+ @Expect(strField(out[0], "window"), "main")
465
+ @Expect(boolField(out[0], "coalesce"), true)
466
+ }
467
+
468
+ @Test
469
+ func testEmitLatestRespectsCapabilityDeny(): Unit {
470
+ let out = ArrayList<String>()
471
+ let hub = hubWith([], ["other"], out)
472
+ // 未授权的事件与 emit 同一套白名单:不投递,也不能因为「只是合并语义」就绕过声明
473
+ hub.emitLatest("progress", JsonObject(), "")
474
+ @Expect(out.size, 0)
475
+ }
476
+
477
+ @Test
478
+ func testPlainEmitHasNoCoalesceField(): Unit {
479
+ let out = ArrayList<String>()
480
+ let hub = hubWith([], ["tick"], out)
481
+ hub.emitAll("tick", JsonObject())
482
+ // 默认路径不做合并:报文里不能出现 coalesce 字段(合并必须显式 opt-in)
483
+ @Expect(JsonValue.fromStr(out[0]).asObject().get("coalesce").isNone(), true)
484
+ hub.emitToWindow("tick", JsonObject(), "main")
485
+ @Expect(JsonValue.fromStr(out[1]).asObject().get("coalesce").isNone(), true)
486
+ @Expect(strField(out[1], "window"), "main")
487
+ }