cj-tauri 2.0.0 → 2.1.1

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 (157) hide show
  1. package/README.md +206 -139
  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 +643 -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 +638 -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 +3745 -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/src/api_system.cj +67 -67
  77. package/src/app.cj +335 -969
  78. package/src/app_events.cj +166 -0
  79. package/src/app_host_ops.cj +287 -0
  80. package/src/app_run.cj +283 -0
  81. package/src/app_spec.cj +76 -76
  82. package/src/arkweb_menu.cj +216 -0
  83. package/src/arkweb_script.cj +142 -142
  84. package/src/capability.cj +409 -409
  85. package/src/clipboard_bridge.cj +178 -0
  86. package/src/dialog_spec.cj +79 -0
  87. package/src/dragdrop.cj +26 -26
  88. package/src/embedded_assets.cj +98 -98
  89. package/src/host.cj +752 -607
  90. package/src/host_arkweb.cj +924 -778
  91. package/src/host_webkit.cj +663 -570
  92. package/src/host_webview2.cj +663 -570
  93. package/src/hotkey.cj +66 -66
  94. package/src/ipc_hub.cj +401 -337
  95. package/src/ipc_message.cj +286 -286
  96. package/src/launch.cj +62 -62
  97. package/src/logging.cj +127 -127
  98. package/src/menu.cj +197 -197
  99. package/src/platform/platform_app_context.cj +77 -77
  100. package/src/platform/platform_assets.cj +33 -33
  101. package/src/platform/platform_discover.cj +176 -176
  102. package/src/platform/platform_env.cj +29 -29
  103. package/src/platform/platform_feature.cj +31 -31
  104. package/src/platform/platform_page_source.cj +87 -87
  105. package/src/platform/platform_target_info.cj +32 -32
  106. package/src/platform_host_provider.cj +87 -87
  107. package/src/plugin.cj +333 -333
  108. package/src/plugins/clipboard/plugin_clipboard.cj +152 -152
  109. package/src/plugins/dialog/plugin_dialog.cj +209 -209
  110. package/src/plugins/fs/plugin_fs.cj +105 -1
  111. package/src/plugins/http/http_backend.cj +131 -0
  112. package/src/plugins/http/http_desktop.cj +195 -0
  113. package/src/plugins/http/http_ohos.cj +204 -0
  114. package/src/plugins/http/plugin_http.cj +310 -459
  115. package/src/plugins/log/plugin_log.cj +105 -105
  116. package/src/plugins/menu/plugin_menu.cj +158 -158
  117. package/src/plugins/notification/plugin_notification.cj +154 -154
  118. package/src/plugins/serial/plugin_serial.cj +458 -454
  119. package/src/plugins/serial/serial_bridge.cj +194 -194
  120. package/src/plugins/serial/serial_manager_ohos.cj +92 -0
  121. package/src/plugins/serial/serial_ohos.cj +529 -0
  122. package/src/plugins/shell/plugin_shell.cj +258 -258
  123. package/src/plugins/sqlite/plugin_sqlite.cj +705 -710
  124. package/src/plugins/sqlite/sqlite_ffi.cj +206 -211
  125. package/src/plugins/ws/plugin_ws.cj +488 -470
  126. package/src/process_hidden.cj +174 -174
  127. package/src/scope.cj +391 -391
  128. package/src/shell_event.cj +69 -69
  129. package/src/tests/app_spec_test.cj +130 -127
  130. package/src/tests/capability_test.cj +243 -243
  131. package/src/tests/dialog_spec_test.cj +73 -0
  132. package/src/tests/fake_host.cj +201 -201
  133. package/src/tests/ipc_hub_test.cj +563 -487
  134. package/src/tests/logging_test.cj +62 -62
  135. package/src/tests/platform_contract_test.cj +264 -251
  136. package/src/tests/plugin_clipboard_test.cj +222 -222
  137. package/src/tests/plugin_dialog_test.cj +237 -237
  138. package/src/tests/plugin_fs_test.cj +207 -0
  139. package/src/tests/plugin_http_test.cj +124 -124
  140. package/src/tests/plugin_notification_test.cj +205 -205
  141. package/src/tests/plugin_serial_test.cj +48 -0
  142. package/src/tests/plugin_test.cj +605 -585
  143. package/src/tests/plugin_ws_test.cj +40 -40
  144. package/src/tests/process_hidden_test.cj +129 -129
  145. package/src/tests/scope_test.cj +350 -350
  146. package/src/tests/system_host_test.cj +99 -81
  147. package/src/tests/urlutil_test.cj +53 -53
  148. package/src/tests/version_test.cj +75 -75
  149. package/src/tests/window_registry_test.cj +123 -123
  150. package/src/tray.cj +90 -90
  151. package/src/urlutil.cj +159 -159
  152. package/src/version.cj +44 -44
  153. package/src/window.cj +51 -51
  154. package/src/window_registry.cj +116 -116
  155. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  156. package/src/plugins/serial/serial_ohos_stub.cj +0 -61
  157. package/src/plugins/sqlite/sqlite_ohos_stub.cj +0 -91
@@ -1,487 +1,563 @@
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
+ }
488
+
489
+ /* ---- 串行组(issue #6-④):同组命令严格按到达序执行 ---- */
490
+
491
+ /** 慢处理器:先睡 delayMs 再回 "slow"——用它把「到达序」与「完成序」拉开 */
492
+ class SleepyHandler <: CommandHandler {
493
+ let delayMs: Int64
494
+ public init(delayMs: Int64) {
495
+ this.delayMs = delayMs
496
+ }
497
+ public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
498
+ let _ = cmd
499
+ let _ = args
500
+ let _ = ipc
501
+ sleep(Duration.millisecond * this.delayMs)
502
+ return JsonString("slow")
503
+ }
504
+ }
505
+
506
+ class InstantHandler <: CommandHandler {
507
+ public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
508
+ let _ = cmd
509
+ let _ = args
510
+ let _ = ipc
511
+ return JsonString("instant")
512
+ }
513
+ }
514
+
515
+ /** sink 里 data="slow" / data="instant" 两条 resolve 的下标(找不到回 -1) */
516
+ func dataPos(sink: ArrayList<String>, value: String): Int64 {
517
+ var pos = -1
518
+ synchronized(TEST_SINK_LOCK) {
519
+ for (i in 0..sink.size) {
520
+ if (strField(sink.get(i).getOrThrow(), "data") == value) {
521
+ pos = i
522
+ break
523
+ }
524
+ }
525
+ }
526
+ return pos
527
+ }
528
+
529
+ @Test
530
+ func testSerialGroupPreservesArrivalOrder(): Unit {
531
+ let sink = ArrayList<String>()
532
+ let hub = hubWith(["slowA", "fastA"], [], sink)
533
+ // 同组:慢的先到、快的后到。串行组必须让完成序 == 到达序——
534
+ // 若实现退回并发分发,fast(立即完成)几乎必然排在 slow(睡 200ms)前面
535
+ hub.registerSerial("slowA", SleepyHandler(200), "grp")
536
+ hub.registerSerial("fastA", InstantHandler(), "grp")
537
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":1,\"cmd\":\"slowA\"}")
538
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":2,\"cmd\":\"fastA\"}")
539
+ waitSink(sink, 2)
540
+ let slowPos = dataPos(sink, "slow")
541
+ let fastPos = dataPos(sink, "instant")
542
+ @Expect(slowPos >= 0, true)
543
+ @Expect(fastPos >= 0, true)
544
+ @Expect(slowPos < fastPos, true)
545
+ }
546
+
547
+ @Test
548
+ func testNonSerialCommandsFinishOutOfOrder(): Unit {
549
+ let sink = ArrayList<String>()
550
+ let hub = hubWith(["slowB", "fastB"], [], sink)
551
+ // 反证(锚定「串行组确实改变了语义」):同样的两条,普通 register 并发分发,
552
+ // 快的先完成。sleep 的 200ms 余量在 CI 上足够宽,不怕调度抖动造成假红。
553
+ hub.register("slowB", SleepyHandler(200))
554
+ hub.register("fastB", InstantHandler())
555
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":1,\"cmd\":\"slowB\"}")
556
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":2,\"cmd\":\"fastB\"}")
557
+ waitSink(sink, 2)
558
+ let slowPos = dataPos(sink, "slow")
559
+ let fastPos = dataPos(sink, "instant")
560
+ @Expect(slowPos >= 0, true)
561
+ @Expect(fastPos >= 0, true)
562
+ @Expect(fastPos < slowPos, true)
563
+ }