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,585 +1,605 @@
1
- package cjTauri.tests
2
-
3
- import cjTauri.*
4
- import cjTauri.plugins.fs.*
5
- import std.collection.*
6
- import std.env.*
7
- import stdx.encoding.json.*
8
- import std.unittest.*
9
- import std.unittest.testmacro.*
10
-
11
- /*
12
- * 插件体系单元测试:命名空间前缀、接口默认实现、JS 汇总与 HTML 注入位置、fs 插件的读写。
13
- * 纯逻辑为主(不创建窗口),可在无 GUI 环境跑;文件用例只碰 /tmp 下的固定路径。
14
- */
15
-
16
- /** 只实现必写成员的最小插件:用来验证接口默认实现(events / jsShim / setup)够用 */
17
- class MiniPlugin <: Plugin {
18
- public init() {}
19
-
20
- public func name(): String {
21
- return "mini"
22
- }
23
-
24
- public func commands(): HashMap<String, CommandHandler> {
25
- let m = HashMap<String, CommandHandler>()
26
- m.add("ping", PluginEchoHandler())
27
- return m
28
- }
29
- }
30
-
31
- /** 带事件与前端 shim 的插件 */
32
- class DemoPlugin <: Plugin {
33
- public init() {}
34
-
35
- public func name(): String {
36
- return "demo"
37
- }
38
-
39
- public func commands(): HashMap<String, CommandHandler> {
40
- let m = HashMap<String, CommandHandler>()
41
- m.add("do", PluginEchoHandler())
42
- return m
43
- }
44
-
45
- public func events(): ArrayList<String> {
46
- let e = ArrayList<String>()
47
- e.add("changed")
48
- return e
49
- }
50
-
51
- public func jsShim(): String {
52
- return "window.__CJ_TAURI__.demo = {};\n"
53
- }
54
- }
55
-
56
- /** 回显命令全名,便于断言「前端调到的到底是哪个名字」 */
57
- class PluginEchoHandler <: CommandHandler {
58
- public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
59
- let _ = args
60
- let _ = ipc
61
- return JsonString("pong:${cmd}")
62
- }
63
- }
64
-
65
- /** 声明命名权限集的插件:验证「短名补前缀」「集声明 ≠ 放行」 */
66
- class ScopedPlugin <: Plugin {
67
- public init() {}
68
-
69
- public func name(): String {
70
- return "scoped"
71
- }
72
-
73
- public func commands(): HashMap<String, CommandHandler> {
74
- let m = HashMap<String, CommandHandler>()
75
- m.add("read", PluginEchoHandler())
76
- m.add("write", PluginEchoHandler())
77
- return m
78
- }
79
-
80
- public func events(): ArrayList<String> {
81
- let e = ArrayList<String>()
82
- e.add("changed")
83
- return e
84
- }
85
-
86
- public func permissions(): HashMap<String, ArrayList<String>> {
87
- let m = HashMap<String, ArrayList<String>>()
88
- let watch = ArrayList<String>()
89
- watch.add("read") // 短名:应补成 scoped:read
90
- watch.add("scoped:changed") // 全名:原样保留
91
- m.add("watch", watch)
92
- return m
93
- }
94
- }
95
-
96
- /** 用给定能力注册中心装配的 hub(命名权限集用例用;不碰宿主层) */
97
- func hubWithRegistry(reg: CapabilityRegistry, sink: ArrayList<String>): IpcHub {
98
- let hub = IpcHub()
99
- hub.setCapabilities(reg)
100
- // 第二个参数是目标窗口 label:本文件只关心「投出去了什么」,路由由 TauriApp 层的用例覆盖
101
- hub.jsSink = { s, _ => addToTestSink(sink, s) }
102
- return hub
103
- }
104
-
105
- /** 造一个只有 path 的对象 */
106
- func pathArgs(path: String): JsonObject {
107
- var o = JsonObject()
108
- o.put("path", JsonString(path))
109
- return o
110
- }
111
-
112
- @Test
113
- func testPluginNameValidation(): Unit {
114
- @Expect(isValidPluginName("fs"), true)
115
- @Expect(isValidPluginName("dialog2"), true)
116
- @Expect(isValidPluginName(""), false)
117
- @Expect(isValidPluginName("a:b"), false)
118
- @Expect(isValidPluginName("a:b"), false) // 全角冒号同样不行
119
- }
120
-
121
- @Test
122
- func testPluginCommandNamesArePrefixed(): Unit {
123
- let names = pluginCommandNames(MiniPlugin())
124
- @Expect(names.size, 1)
125
- @Expect(names[0], "mini:ping")
126
- }
127
-
128
- @Test
129
- func testPluginEventNamesArePrefixed(): Unit {
130
- @Expect(pluginEventNames(MiniPlugin()).size, 0) // 默认实现:没有事件
131
- let names = pluginEventNames(DemoPlugin())
132
- @Expect(names.size, 1)
133
- @Expect(names[0], "demo:changed")
134
- }
135
-
136
- @Test
137
- func testRegisterPluginCommandsUsesNamespace(): Unit {
138
- let out = ArrayList<String>()
139
- let hub = hubWith(["mini:ping"], [], out)
140
- registerPluginCommands(hub, MiniPlugin())
141
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":1,\"cmd\":\"mini:ping\"}")
142
- waitSink(out, 1)
143
- @Expect(out.size, 1)
144
- @Expect(strField(out[0], "type"), "resolve")
145
- @Expect(strField(out[0], "data"), "pong:mini:ping")
146
- }
147
-
148
- @Test
149
- func testPluginDoesNotAutoGrantCapability(): Unit {
150
- let out = ArrayList<String>()
151
- // 插件把命令注册进来了,但能力清单没放行:默认最小权限仍生效(插件不自动放行)
152
- let hub = hubWith([], [], out)
153
- registerPluginCommands(hub, MiniPlugin())
154
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":2,\"cmd\":\"mini:ping\"}")
155
- @Expect(out.size, 1)
156
- @Expect(strField(out[0], "error"), "command not allowed: mini:ping")
157
- }
158
-
159
- @Test
160
- func testPluginInitScriptsSkipEmpty(): Unit {
161
- let none = ArrayList<Plugin>()
162
- @Expect(pluginInitScripts(none).size, 0) // 没有任何 shim 就不注册脚本
163
-
164
- let onlyEmpty = ArrayList<Plugin>()
165
- onlyEmpty.add(MiniPlugin()) // 默认 shim 为空,应被跳过
166
- @Expect(pluginInitScripts(onlyEmpty).size, 0)
167
- }
168
-
169
- @Test
170
- func testPluginInitScriptsPassShimThrough(): Unit {
171
- let plugins = ArrayList<Plugin>()
172
- plugins.add(DemoPlugin())
173
- let scripts = pluginInitScripts(plugins)
174
- @Expect(scripts.size, 1)
175
- @Expect(scripts[0].contains("__CJ_TAURI__.demo"), true)
176
- // 交给宿主的是裸 JS 片段、不是 script 标签块:桥按脚本注入,不再拼 HTML(RFC §5.4 的 v2)
177
- @Expect(scripts[0].contains("<script>"), false)
178
- @Expect(scripts[0].size, DemoPlugin().jsShim().size) // 原样透传,不改写
179
- }
180
-
181
- @Test
182
- func testPluginInitScriptsOnePerPluginInOrder(): Unit {
183
- let plugins = ArrayList<Plugin>()
184
- plugins.add(DemoPlugin())
185
- plugins.add(FsPlugin())
186
- let scripts = pluginInitScripts(plugins)
187
- // 一段一个插件(不拼成一大段):某个插件的 shim 写坏了不会带崩别的插件
188
- @Expect(scripts.size, 2)
189
- @Expect(scripts[0].contains("__CJ_TAURI__.demo"), true)
190
- @Expect(scripts[1].contains("t.fs ="), true)
191
- }
192
-
193
- @Test
194
- func testPluginInitScriptsMixedPluginsKeepOrder(): Unit {
195
- // 空 shim 的插件不占位,剩下的仍按装配顺序排列
196
- let plugins = ArrayList<Plugin>()
197
- plugins.add(MiniPlugin())
198
- plugins.add(DemoPlugin())
199
- plugins.add(FsPlugin())
200
- let scripts = pluginInitScripts(plugins)
201
- @Expect(scripts.size, 2)
202
- @Expect(scripts[0].contains("__CJ_TAURI__.demo"), true)
203
- @Expect(scripts[1].contains("t.fs ="), true)
204
- }
205
-
206
- @Test
207
- func testDescribePluginsListsCommandsAndEvents(): Unit {
208
- let plugins = ArrayList<Plugin>()
209
- plugins.add(DemoPlugin())
210
- let text = describePlugins(plugins)
211
- @Expect(text.contains("demo"), true)
212
- @Expect(text.contains("demo:do"), true)
213
- @Expect(text.contains("events: demo:changed"), true)
214
- }
215
-
216
- @Test
217
- func testFsPluginSurface(): Unit {
218
- let p = FsPlugin()
219
- @Expect(p.name(), "fs")
220
- let names = pluginCommandNames(p)
221
- @Expect(names.size, 5)
222
- var hasRead = false
223
- var hasWrite = false
224
- var hasExists = false
225
- var hasList = false
226
- var hasRemove = false
227
- for (n in names) {
228
- if (n == "fs:readText") {
229
- hasRead = true
230
- }
231
- if (n == "fs:writeText") {
232
- hasWrite = true
233
- }
234
- if (n == "fs:exists") {
235
- hasExists = true
236
- }
237
- if (n == "fs:list") {
238
- hasList = true
239
- }
240
- if (n == "fs:remove") {
241
- hasRemove = true
242
- }
243
- }
244
- @Expect(hasRead, true)
245
- @Expect(hasWrite, true)
246
- @Expect(hasExists, true)
247
- @Expect(hasList, true) // 目录列举:笔记工具的文件树靠它,应用层不必自己拼 std.fs
248
- @Expect(hasRemove, true)
249
- }
250
-
251
- @Test
252
- func testFsPluginShimTargetsItsOwnNamespace(): Unit {
253
- let js = FsPlugin().jsShim()
254
- @Expect(js.contains("t.fs"), true)
255
- @Expect(js.contains("'fs:readText'"), true)
256
- @Expect(js.contains("'fs:writeText'"), true)
257
- @Expect(js.contains("'fs:exists'"), true)
258
- }
259
-
260
- @Test
261
- func testFsPluginDeclaresPermissionSets(): Unit {
262
- let sets = pluginPermissionSets(FsPlugin())
263
- @Expect(sets.size, 2)
264
- let ro = sets.get("fs:readonly").getOrThrow()
265
- @Expect(ro.size, 3)
266
- var roHasWrite = false
267
- var roHasRemove = false
268
- for (m in ro) {
269
- if (m == "fs:writeText") {
270
- roHasWrite = true
271
- }
272
- if (m == "fs:remove") {
273
- roHasRemove = true
274
- }
275
- }
276
- @Expect(roHasWrite, false) // 只读集不含写:最小权限不打折
277
- @Expect(roHasRemove, false) // 删也同理——列目录是「读」,删除不是
278
- @Expect(sets.get("fs:default").getOrThrow().size, 5)
279
- }
280
-
281
- @Test
282
- func testPluginPermissionSetsAreNamespacedAndExpanded(): Unit {
283
- let sets = pluginPermissionSets(ScopedPlugin())
284
- @Expect(sets.size, 1)
285
- @Expect(sets.contains("scoped:watch"), true)
286
- let members = sets.get("scoped:watch").getOrThrow()
287
- @Expect(members.size, 2)
288
- var hasShort = false
289
- var hasFull = false
290
- for (m in members) {
291
- if (m == "scoped:read") {
292
- hasShort = true
293
- }
294
- if (m == "scoped:changed") {
295
- hasFull = true
296
- }
297
- }
298
- @Expect(hasShort, true) // 短名补了插件名前缀
299
- @Expect(hasFull, true) // 全名原样
300
- }
301
-
302
- @Test
303
- func testPluginPermissionSetsDefaultEmpty(): Unit {
304
- // 接口默认实现:没写 permissions() 的插件没有集(MiniPlugin 只写了必写成员)
305
- @Expect(pluginPermissionSets(MiniPlugin()).size, 0)
306
- }
307
-
308
- @Test
309
- func testPluginPermissionSetDoesNotAutoGrant(): Unit {
310
- let out = ArrayList<String>()
311
- // 插件声明了 scoped:watch,但清单没引用它 → 命令仍被拒(集声明不等于放行)
312
- let hub = hubWith([], [], out)
313
- registerPluginCommands(hub, ScopedPlugin())
314
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":11,\"cmd\":\"scoped:read\"}")
315
- @Expect(out.size, 1)
316
- @Expect(strField(out[0], "error"), "command not allowed: scoped:read")
317
- }
318
-
319
- @Test
320
- func testPluginPermissionSetAuthorizesWhenReferenced(): Unit {
321
- let out = ArrayList<String>()
322
- let reg = CapabilityRegistry()
323
- reg.addFromJson(JsonValue.fromStr("{\"identifier\":\"d\",\"permissions\":[\"scoped:watch\"]}"))
324
- let hub = hubWithRegistry(reg, out)
325
- registerPluginCommands(hub, ScopedPlugin())
326
- registerPluginPermissionSets(reg, ScopedPlugin())
327
- // 集内命令放行
328
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":12,\"cmd\":\"scoped:read\"}")
329
- waitSink(out, 1) // 执行是异步的,先等这条 resolve 落地,再发下一条同步拒绝
330
- @Expect(out.size, 1)
331
- @Expect(strField(out[0], "type"), "resolve")
332
- @Expect(strField(out[0], "data"), "pong:scoped:read")
333
- // 集外命令仍拒:放行的是那一组,不是整个插件
334
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":13,\"cmd\":\"scoped:write\"}")
335
- @Expect(out.size, 2)
336
- @Expect(strField(out[1], "error"), "command not allowed: scoped:write")
337
- }
338
-
339
- @Test
340
- func testDescribePermissionSets(): Unit {
341
- let none = ArrayList<Plugin>()
342
- @Expect(describePermissionSets(none), "")
343
- let plugins = ArrayList<Plugin>()
344
- plugins.add(ScopedPlugin())
345
- let text = describePermissionSets(plugins)
346
- @Expect(text.contains("scoped:watch"), true)
347
- @Expect(text.contains("scoped:read"), true)
348
- }
349
-
350
- @Test
351
- func testFsWriteThenReadRoundTrip(): Unit {
352
- let cmds = FsPlugin().commands()
353
- let ctx = IpcContext("main", IpcHub())
354
- let path = "/tmp/cj-tauri-plugin-roundtrip.txt"
355
-
356
- var wArgs = pathArgs(path)
357
- wArgs.put("contents", JsonString("插件写入的内容 CJ"))
358
- let wrote = cmds.get("writeText").getOrThrow().handle("fs:writeText", wArgs, ctx)
359
- @Expect(wrote.asBool().getValue(), true)
360
-
361
- let read = cmds.get("readText").getOrThrow().handle("fs:readText", pathArgs(path), ctx)
362
- @Expect(read.asString().getValue(), "插件写入的内容 CJ")
363
-
364
- // 覆盖写:再写一次短内容,读回来应是新的(不是追加、也不是旧内容)
365
- var w2 = pathArgs(path)
366
- w2.put("contents", JsonString("覆盖"))
367
- cmds.get("writeText").getOrThrow().handle("fs:writeText", w2, ctx)
368
- let read2 = cmds.get("readText").getOrThrow().handle("fs:readText", pathArgs(path), ctx)
369
- @Expect(read2.asString().getValue(), "覆盖")
370
-
371
- let exists = cmds.get("exists").getOrThrow().handle("fs:exists", pathArgs(path), ctx)
372
- @Expect(exists.asBool().getValue(), true)
373
- }
374
-
375
- @Test
376
- func testFsRejectsMissingArgument(): Unit {
377
- let cmds = FsPlugin().commands()
378
- let ctx = IpcContext("main", IpcHub())
379
- var threw = false
380
- try {
381
- cmds.get("readText").getOrThrow().handle("fs:readText", JsonObject(), ctx)
382
- } catch (e: CommandException) {
383
- threw = true
384
- @Expect(e.message.contains("missing argument"), true)
385
- }
386
- @Expect(threw, true)
387
- }
388
-
389
- @Test
390
- func testFsReadMissingFileBecomesBusinessError(): Unit {
391
- let cmds = FsPlugin().commands()
392
- let ctx = IpcContext("main", IpcHub())
393
- var threw = false
394
- try {
395
- cmds.get("readText").getOrThrow().handle("fs:readText", pathArgs("/tmp/cj-tauri-plugin-no-such-file.txt"), ctx)
396
- } catch (e: CommandException) {
397
- // 抛 CommandException 而不是裸异常:前端拿到的是可读原文,不是 "command failed: ..."
398
- threw = true
399
- @Expect(e.message.startsWith("fs:readText failed:"), true)
400
- }
401
- @Expect(threw, true)
402
-
403
- let exists = cmds.get("exists").getOrThrow().handle("fs:exists", pathArgs("/tmp/cj-tauri-plugin-no-such-file.txt"), ctx)
404
- @Expect(exists.asBool().getValue(), false)
405
- }
406
-
407
- /**
408
- * JsonValue → ArrayList<JsonValue>(测试助手)
409
- * JsonArray 本身不支持 for-in,且 arr.get(i) 的元素类型要靠上下文推断——
410
- * 统一在这儿把它转成 ArrayList,后面就能直接下标访问、也能 for-in。
411
- */
412
- func jsonItemsOf(v: JsonValue): ArrayList<JsonValue> {
413
- let out = ArrayList<JsonValue>()
414
- let arr = v.asArray()
415
- for (i in 0..arr.size()) {
416
- out.add(arr.get(i).getOrThrow())
417
- }
418
- return out
419
- }
420
-
421
- /** 取出对象里某个键的字符串数组(describePluginsJson 的测试助手) */
422
- func jsonStringsOf(obj: JsonObject, key: String): ArrayList<String> {
423
- let out = ArrayList<String>()
424
- for (e in jsonItemsOf(obj.get(key).getOrThrow())) {
425
- out.add(e.asString().getValue())
426
- }
427
- return out
428
- }
429
-
430
- /** "a|b" 形式,便于用字符串断言数组的内容与顺序 */
431
- func joinWithPipe(items: ArrayList<String>): String {
432
- var s = ""
433
- for (x in items) {
434
- if (s.size > 0) {
435
- s += "|"
436
- }
437
- s += x
438
- }
439
- return s
440
- }
441
-
442
- @Test
443
- func testDescribePluginsJsonShape(): Unit {
444
- let plugins = ArrayList<Plugin>()
445
- plugins.add(DemoPlugin())
446
- plugins.add(ScopedPlugin())
447
- let arr = jsonItemsOf(describePluginsJson(plugins))
448
- @Expect(arr.size, 2)
449
-
450
- // 数组顺序 = 装配顺序
451
- let demo = arr[0].asObject()
452
- @Expect(demo.get("name").getOrThrow().asString().getValue(), "demo")
453
- @Expect(joinWithPipe(jsonStringsOf(demo, "commands")), "demo:do")
454
- @Expect(joinWithPipe(jsonStringsOf(demo, "events")), "demo:changed")
455
- @Expect(demo.get("permissions").getOrThrow().asObject().size(), 0)
456
- @Expect(demo.get("hasShim").getOrThrow().asBool().getValue(), true)
457
-
458
- let scoped = arr[1].asObject()
459
- // 命令/事件按字典序(read 在 write 前),而不是 HashMap 的迭代顺序
460
- @Expect(joinWithPipe(jsonStringsOf(scoped, "commands")), "scoped:read|scoped:write")
461
- @Expect(scoped.get("hasShim").getOrThrow().asBool().getValue(), false)
462
- // 权限集用「框架展开后」的全名,成员同样排序(scoped:changed < scoped:read)
463
- let perms = scoped.get("permissions").getOrThrow().asObject()
464
- @Expect(perms.size(), 1)
465
- @Expect(joinWithPipe(jsonStringsOf(perms, "scoped:watch")), "scoped:changed|scoped:read")
466
- }
467
-
468
- @Test
469
- func testDescribePluginsJsonStableAcrossRuns(): Unit {
470
- // 连续两次序列化必须逐字节相同:命令/事件/权限键都排过序,不受 HashMap 迭代顺序影响
471
- let plugins = ArrayList<Plugin>()
472
- plugins.add(DemoPlugin())
473
- plugins.add(ScopedPlugin())
474
- plugins.add(MiniPlugin())
475
- @Expect(describePluginsJson(plugins).toJsonString(), describePluginsJson(plugins).toJsonString())
476
- }
477
-
478
- @Test
479
- func testDescribePluginsJsonEmptyList(): Unit {
480
- let none = ArrayList<Plugin>()
481
- @Expect(jsonItemsOf(describePluginsJson(none)).size, 0)
482
- }
483
-
484
- /*
485
- * 清单导出通道(CJ_TAURI_MANIFEST):`cj-tauri info` 靠它从「跑到装配完成、但不建窗口」的应用里取清单。
486
- * 这里只测纯逻辑(环境变量解析 + 两种模式的渲染与可解析性),不创建窗口。
487
- */
488
-
489
- /** 把 manifestDumpMode() 的 Option 归一成字符串,便于用 @Expect 断言 */
490
- func manifestModeOf(): String {
491
- match (manifestDumpMode()) {
492
- case Some(m) => return m
493
- case None => return "<none>"
494
- }
495
- }
496
-
497
- @Test
498
- func testManifestDumpModeFromEnv(): Unit {
499
- removeVariable("CJ_TAURI_MANIFEST")
500
- @Expect(manifestModeOf(), "<none>")
501
-
502
- setVariable("CJ_TAURI_MANIFEST", "")
503
- @Expect(manifestModeOf(), "<none>") // 空串等同未设置
504
-
505
- setVariable("CJ_TAURI_MANIFEST", "json")
506
- @Expect(manifestModeOf(), "json")
507
-
508
- setVariable("CJ_TAURI_MANIFEST", "1")
509
- @Expect(manifestModeOf(), "text") // 只认 "json" 这一个值,其它一律当人类可读形态
510
-
511
- removeVariable("CJ_TAURI_MANIFEST")
512
- @Expect(manifestModeOf(), "<none>") // 收尾:别把环境变量漏给后面的用例
513
- }
514
-
515
- @Test
516
- func testRenderPluginManifestTextMode(): Unit {
517
- let plugins = ArrayList<Plugin>()
518
- plugins.add(DemoPlugin())
519
- // 人读形态就是启动日志里那份表,两种出口不能各写一套
520
- @Expect(renderPluginManifest(plugins, "text"), describePlugins(plugins))
521
- @Expect(renderPluginManifest(plugins, "text").contains("demo:do"), true)
522
- }
523
-
524
- @Test
525
- func testRenderPluginManifestJsonMode(): Unit {
526
- let plugins = ArrayList<Plugin>()
527
- plugins.add(DemoPlugin())
528
- let text = renderPluginManifest(plugins, "json")
529
- // 必须能被标准 JSON 解析器重新解析(CLI / CI 消费的就是它)
530
- let arr = jsonItemsOf(JsonValue.fromStr(text))
531
- @Expect(arr.size, 1)
532
- let demo = arr[0].asObject()
533
- @Expect(demo.get("name").getOrThrow().asString().getValue(), "demo")
534
- @Expect(joinWithPipe(jsonStringsOf(demo, "commands")), "demo:do")
535
- }
536
-
537
- @Test
538
- func testRenderPluginManifestEmptyList(): Unit {
539
- let none = ArrayList<Plugin>()
540
- @Expect(renderPluginManifest(none, "text"), "(未装配插件)\n")
541
- @Expect(JsonValue.fromStr(renderPluginManifest(none, "json")).asArray().size(), 0)
542
- }
543
-
544
- class HostedDemoPlugin <: Plugin {
545
- public init() {}
546
-
547
- public func name(): String {
548
- return "demo2"
549
- }
550
-
551
- public func commands(): HashMap<String, CommandHandler> {
552
- return HashMap<String, CommandHandler>()
553
- }
554
-
555
- public func describe(): PluginManifest {
556
- let hosts = ArrayList<String>()
557
- hosts.add("webkitgtk")
558
- return PluginManifest(hosts, ArrayList<String>())
559
- }
560
- }
561
-
562
- @Test
563
- func testDescribeDefault(): Unit {
564
- let d = DemoPlugin().describe()
565
- // 默认实现:全宿主、无能力依赖(空数组 = 不限)
566
- @Expect(d.supportedHosts.size, 0)
567
- @Expect(d.requiredCapabilities.size, 0)
568
- }
569
-
570
- @Test
571
- func testDescribeOverride(): Unit {
572
- let d = HostedDemoPlugin().describe()
573
- @Expect(d.supportedHosts.size, 1)
574
- @Expect(d.supportedHosts.get(0), "webkitgtk")
575
- }
576
-
577
- @Test
578
- func testManifestJsonCarriesDescribe(): Unit {
579
- let plugins = ArrayList<Plugin>()
580
- plugins.add(HostedDemoPlugin())
581
- let arr = jsonItemsOf(JsonValue.fromStr(renderPluginManifest(plugins, "json")))
582
- let obj = arr[0].asObject()
583
- @Expect(joinWithPipe(jsonStringsOf(obj, "supportedHosts")), "webkitgtk")
584
- @Expect(jsonStringsOf(obj, "requiredCapabilities").size, 0)
585
- }
1
+ package cjTauri.tests
2
+
3
+ import cjTauri.*
4
+ import cjTauri.plugins.fs.*
5
+ import std.collection.*
6
+ import std.env.*
7
+ import stdx.encoding.json.*
8
+ import std.unittest.*
9
+ import std.unittest.testmacro.*
10
+
11
+ /*
12
+ * 插件体系单元测试:命名空间前缀、接口默认实现、JS 汇总与 HTML 注入位置、fs 插件的读写。
13
+ * 纯逻辑为主(不创建窗口),可在无 GUI 环境跑;文件用例只碰 /tmp 下的固定路径。
14
+ */
15
+
16
+ /** 只实现必写成员的最小插件:用来验证接口默认实现(events / jsShim / setup)够用 */
17
+ class MiniPlugin <: Plugin {
18
+ public init() {}
19
+
20
+ public func name(): String {
21
+ return "mini"
22
+ }
23
+
24
+ public func commands(): HashMap<String, CommandHandler> {
25
+ let m = HashMap<String, CommandHandler>()
26
+ m.add("ping", PluginEchoHandler())
27
+ return m
28
+ }
29
+ }
30
+
31
+ /** 带事件与前端 shim 的插件 */
32
+ class DemoPlugin <: Plugin {
33
+ public init() {}
34
+
35
+ public func name(): String {
36
+ return "demo"
37
+ }
38
+
39
+ public func commands(): HashMap<String, CommandHandler> {
40
+ let m = HashMap<String, CommandHandler>()
41
+ m.add("do", PluginEchoHandler())
42
+ return m
43
+ }
44
+
45
+ public func events(): ArrayList<String> {
46
+ let e = ArrayList<String>()
47
+ e.add("changed")
48
+ return e
49
+ }
50
+
51
+ public func jsShim(): String {
52
+ return "window.__CJ_TAURI__.demo = {};\n"
53
+ }
54
+ }
55
+
56
+ /** 回显命令全名,便于断言「前端调到的到底是哪个名字」 */
57
+ class PluginEchoHandler <: CommandHandler {
58
+ public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
59
+ let _ = args
60
+ let _ = ipc
61
+ return JsonString("pong:${cmd}")
62
+ }
63
+ }
64
+
65
+ /** 声明命名权限集的插件:验证「短名补前缀」「集声明 ≠ 放行」 */
66
+ class ScopedPlugin <: Plugin {
67
+ public init() {}
68
+
69
+ public func name(): String {
70
+ return "scoped"
71
+ }
72
+
73
+ public func commands(): HashMap<String, CommandHandler> {
74
+ let m = HashMap<String, CommandHandler>()
75
+ m.add("read", PluginEchoHandler())
76
+ m.add("write", PluginEchoHandler())
77
+ return m
78
+ }
79
+
80
+ public func events(): ArrayList<String> {
81
+ let e = ArrayList<String>()
82
+ e.add("changed")
83
+ return e
84
+ }
85
+
86
+ public func permissions(): HashMap<String, ArrayList<String>> {
87
+ let m = HashMap<String, ArrayList<String>>()
88
+ let watch = ArrayList<String>()
89
+ watch.add("read") // 短名:应补成 scoped:read
90
+ watch.add("scoped:changed") // 全名:原样保留
91
+ m.add("watch", watch)
92
+ return m
93
+ }
94
+ }
95
+
96
+ /** 用给定能力注册中心装配的 hub(命名权限集用例用;不碰宿主层) */
97
+ func hubWithRegistry(reg: CapabilityRegistry, sink: ArrayList<String>): IpcHub {
98
+ let hub = IpcHub()
99
+ hub.setCapabilities(reg)
100
+ // 第二个参数是目标窗口 label:本文件只关心「投出去了什么」,路由由 TauriApp 层的用例覆盖
101
+ hub.jsSink = { s, _ => addToTestSink(sink, s) }
102
+ return hub
103
+ }
104
+
105
+ /** 造一个只有 path 的对象 */
106
+ func pathArgs(path: String): JsonObject {
107
+ var o = JsonObject()
108
+ o.put("path", JsonString(path))
109
+ return o
110
+ }
111
+
112
+ @Test
113
+ func testPluginNameValidation(): Unit {
114
+ @Expect(isValidPluginName("fs"), true)
115
+ @Expect(isValidPluginName("dialog2"), true)
116
+ @Expect(isValidPluginName(""), false)
117
+ @Expect(isValidPluginName("a:b"), false)
118
+ @Expect(isValidPluginName("a:b"), false) // 全角冒号同样不行
119
+ }
120
+
121
+ @Test
122
+ func testPluginCommandNamesArePrefixed(): Unit {
123
+ let names = pluginCommandNames(MiniPlugin())
124
+ @Expect(names.size, 1)
125
+ @Expect(names[0], "mini:ping")
126
+ }
127
+
128
+ @Test
129
+ func testPluginEventNamesArePrefixed(): Unit {
130
+ @Expect(pluginEventNames(MiniPlugin()).size, 0) // 默认实现:没有事件
131
+ let names = pluginEventNames(DemoPlugin())
132
+ @Expect(names.size, 1)
133
+ @Expect(names[0], "demo:changed")
134
+ }
135
+
136
+ @Test
137
+ func testRegisterPluginCommandsUsesNamespace(): Unit {
138
+ let out = ArrayList<String>()
139
+ let hub = hubWith(["mini:ping"], [], out)
140
+ registerPluginCommands(hub, MiniPlugin())
141
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":1,\"cmd\":\"mini:ping\"}")
142
+ waitSink(out, 1)
143
+ @Expect(out.size, 1)
144
+ @Expect(strField(out[0], "type"), "resolve")
145
+ @Expect(strField(out[0], "data"), "pong:mini:ping")
146
+ }
147
+
148
+ @Test
149
+ func testPluginDoesNotAutoGrantCapability(): Unit {
150
+ let out = ArrayList<String>()
151
+ // 插件把命令注册进来了,但能力清单没放行:默认最小权限仍生效(插件不自动放行)
152
+ let hub = hubWith([], [], out)
153
+ registerPluginCommands(hub, MiniPlugin())
154
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":2,\"cmd\":\"mini:ping\"}")
155
+ @Expect(out.size, 1)
156
+ @Expect(strField(out[0], "error"), "command not allowed: mini:ping")
157
+ }
158
+
159
+ @Test
160
+ func testPluginInitScriptsSkipEmpty(): Unit {
161
+ let none = ArrayList<Plugin>()
162
+ @Expect(pluginInitScripts(none).size, 0) // 没有任何 shim 就不注册脚本
163
+
164
+ let onlyEmpty = ArrayList<Plugin>()
165
+ onlyEmpty.add(MiniPlugin()) // 默认 shim 为空,应被跳过
166
+ @Expect(pluginInitScripts(onlyEmpty).size, 0)
167
+ }
168
+
169
+ @Test
170
+ func testPluginInitScriptsPassShimThrough(): Unit {
171
+ let plugins = ArrayList<Plugin>()
172
+ plugins.add(DemoPlugin())
173
+ let scripts = pluginInitScripts(plugins)
174
+ @Expect(scripts.size, 1)
175
+ @Expect(scripts[0].contains("__CJ_TAURI__.demo"), true)
176
+ // 交给宿主的是裸 JS 片段、不是 script 标签块:桥按脚本注入,不再拼 HTML(RFC §5.4 的 v2)
177
+ @Expect(scripts[0].contains("<script>"), false)
178
+ @Expect(scripts[0].size, DemoPlugin().jsShim().size) // 原样透传,不改写
179
+ }
180
+
181
+ @Test
182
+ func testPluginInitScriptsOnePerPluginInOrder(): Unit {
183
+ let plugins = ArrayList<Plugin>()
184
+ plugins.add(DemoPlugin())
185
+ plugins.add(FsPlugin())
186
+ let scripts = pluginInitScripts(plugins)
187
+ // 一段一个插件(不拼成一大段):某个插件的 shim 写坏了不会带崩别的插件
188
+ @Expect(scripts.size, 2)
189
+ @Expect(scripts[0].contains("__CJ_TAURI__.demo"), true)
190
+ @Expect(scripts[1].contains("t.fs ="), true)
191
+ }
192
+
193
+ @Test
194
+ func testPluginInitScriptsMixedPluginsKeepOrder(): Unit {
195
+ // 空 shim 的插件不占位,剩下的仍按装配顺序排列
196
+ let plugins = ArrayList<Plugin>()
197
+ plugins.add(MiniPlugin())
198
+ plugins.add(DemoPlugin())
199
+ plugins.add(FsPlugin())
200
+ let scripts = pluginInitScripts(plugins)
201
+ @Expect(scripts.size, 2)
202
+ @Expect(scripts[0].contains("__CJ_TAURI__.demo"), true)
203
+ @Expect(scripts[1].contains("t.fs ="), true)
204
+ }
205
+
206
+ @Test
207
+ func testDescribePluginsListsCommandsAndEvents(): Unit {
208
+ let plugins = ArrayList<Plugin>()
209
+ plugins.add(DemoPlugin())
210
+ let text = describePlugins(plugins)
211
+ @Expect(text.contains("demo"), true)
212
+ @Expect(text.contains("demo:do"), true)
213
+ @Expect(text.contains("events: demo:changed"), true)
214
+ }
215
+
216
+ @Test
217
+ func testFsPluginSurface(): Unit {
218
+ let p = FsPlugin()
219
+ @Expect(p.name(), "fs")
220
+ let names = pluginCommandNames(p)
221
+ @Expect(names.size, 9)
222
+ var hasRead = false
223
+ var hasWrite = false
224
+ var hasExists = false
225
+ var hasList = false
226
+ var hasRemove = false
227
+ var hasMkdir = false
228
+ var hasRename = false
229
+ var hasCopy = false
230
+ var hasStat = false
231
+ for (n in names) {
232
+ if (n == "fs:readText") {
233
+ hasRead = true
234
+ }
235
+ if (n == "fs:writeText") {
236
+ hasWrite = true
237
+ }
238
+ if (n == "fs:exists") {
239
+ hasExists = true
240
+ }
241
+ if (n == "fs:list") {
242
+ hasList = true
243
+ }
244
+ if (n == "fs:remove") {
245
+ hasRemove = true
246
+ }
247
+ if (n == "fs:mkdir") {
248
+ hasMkdir = true
249
+ }
250
+ if (n == "fs:rename") {
251
+ hasRename = true
252
+ }
253
+ if (n == "fs:copy") {
254
+ hasCopy = true
255
+ }
256
+ if (n == "fs:stat") {
257
+ hasStat = true
258
+ }
259
+ }
260
+ @Expect(hasRead, true)
261
+ @Expect(hasWrite, true)
262
+ @Expect(hasExists, true)
263
+ @Expect(hasList, true) // 目录列举:笔记工具的文件树靠它,应用层不必自己拼 std.fs
264
+ @Expect(hasRemove, true)
265
+ @Expect(hasMkdir, true)
266
+ @Expect(hasRename, true)
267
+ @Expect(hasCopy, true)
268
+ @Expect(hasStat, true)
269
+ }
270
+
271
+ @Test
272
+ func testFsPluginShimTargetsItsOwnNamespace(): Unit {
273
+ let js = FsPlugin().jsShim()
274
+ @Expect(js.contains("t.fs"), true)
275
+ @Expect(js.contains("'fs:readText'"), true)
276
+ @Expect(js.contains("'fs:writeText'"), true)
277
+ @Expect(js.contains("'fs:exists'"), true)
278
+ }
279
+
280
+ @Test
281
+ func testFsPluginDeclaresPermissionSets(): Unit {
282
+ let sets = pluginPermissionSets(FsPlugin())
283
+ @Expect(sets.size, 2)
284
+ let ro = sets.get("fs:readonly").getOrThrow()
285
+ @Expect(ro.size, 4)
286
+ var roHasWrite = false
287
+ var roHasRemove = false
288
+ for (m in ro) {
289
+ if (m == "fs:writeText") {
290
+ roHasWrite = true
291
+ }
292
+ if (m == "fs:remove") {
293
+ roHasRemove = true
294
+ }
295
+ }
296
+ @Expect(roHasWrite, false) // 只读集不含写:最小权限不打折
297
+ @Expect(roHasRemove, false) // 删也同理——列目录是「读」,删除不是
298
+ @Expect(sets.get("fs:default").getOrThrow().size, 9)
299
+ }
300
+
301
+ @Test
302
+ func testPluginPermissionSetsAreNamespacedAndExpanded(): Unit {
303
+ let sets = pluginPermissionSets(ScopedPlugin())
304
+ @Expect(sets.size, 1)
305
+ @Expect(sets.contains("scoped:watch"), true)
306
+ let members = sets.get("scoped:watch").getOrThrow()
307
+ @Expect(members.size, 2)
308
+ var hasShort = false
309
+ var hasFull = false
310
+ for (m in members) {
311
+ if (m == "scoped:read") {
312
+ hasShort = true
313
+ }
314
+ if (m == "scoped:changed") {
315
+ hasFull = true
316
+ }
317
+ }
318
+ @Expect(hasShort, true) // 短名补了插件名前缀
319
+ @Expect(hasFull, true) // 全名原样
320
+ }
321
+
322
+ @Test
323
+ func testPluginPermissionSetsDefaultEmpty(): Unit {
324
+ // 接口默认实现:没写 permissions() 的插件没有集(MiniPlugin 只写了必写成员)
325
+ @Expect(pluginPermissionSets(MiniPlugin()).size, 0)
326
+ }
327
+
328
+ @Test
329
+ func testPluginPermissionSetDoesNotAutoGrant(): Unit {
330
+ let out = ArrayList<String>()
331
+ // 插件声明了 scoped:watch,但清单没引用它 → 命令仍被拒(集声明不等于放行)
332
+ let hub = hubWith([], [], out)
333
+ registerPluginCommands(hub, ScopedPlugin())
334
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":11,\"cmd\":\"scoped:read\"}")
335
+ @Expect(out.size, 1)
336
+ @Expect(strField(out[0], "error"), "command not allowed: scoped:read")
337
+ }
338
+
339
+ @Test
340
+ func testPluginPermissionSetAuthorizesWhenReferenced(): Unit {
341
+ let out = ArrayList<String>()
342
+ let reg = CapabilityRegistry()
343
+ reg.addFromJson(JsonValue.fromStr("{\"identifier\":\"d\",\"permissions\":[\"scoped:watch\"]}"))
344
+ let hub = hubWithRegistry(reg, out)
345
+ registerPluginCommands(hub, ScopedPlugin())
346
+ registerPluginPermissionSets(reg, ScopedPlugin())
347
+ // 集内命令放行
348
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":12,\"cmd\":\"scoped:read\"}")
349
+ waitSink(out, 1) // 执行是异步的,先等这条 resolve 落地,再发下一条同步拒绝
350
+ @Expect(out.size, 1)
351
+ @Expect(strField(out[0], "type"), "resolve")
352
+ @Expect(strField(out[0], "data"), "pong:scoped:read")
353
+ // 集外命令仍拒:放行的是那一组,不是整个插件
354
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":13,\"cmd\":\"scoped:write\"}")
355
+ @Expect(out.size, 2)
356
+ @Expect(strField(out[1], "error"), "command not allowed: scoped:write")
357
+ }
358
+
359
+ @Test
360
+ func testDescribePermissionSets(): Unit {
361
+ let none = ArrayList<Plugin>()
362
+ @Expect(describePermissionSets(none), "")
363
+ let plugins = ArrayList<Plugin>()
364
+ plugins.add(ScopedPlugin())
365
+ let text = describePermissionSets(plugins)
366
+ @Expect(text.contains("scoped:watch"), true)
367
+ @Expect(text.contains("scoped:read"), true)
368
+ }
369
+
370
+ @Test
371
+ func testFsWriteThenReadRoundTrip(): Unit {
372
+ let cmds = FsPlugin().commands()
373
+ let ctx = IpcContext("main", IpcHub())
374
+ let path = "/tmp/cj-tauri-plugin-roundtrip.txt"
375
+
376
+ var wArgs = pathArgs(path)
377
+ wArgs.put("contents", JsonString("插件写入的内容 CJ"))
378
+ let wrote = cmds.get("writeText").getOrThrow().handle("fs:writeText", wArgs, ctx)
379
+ @Expect(wrote.asBool().getValue(), true)
380
+
381
+ let read = cmds.get("readText").getOrThrow().handle("fs:readText", pathArgs(path), ctx)
382
+ @Expect(read.asString().getValue(), "插件写入的内容 CJ")
383
+
384
+ // 覆盖写:再写一次短内容,读回来应是新的(不是追加、也不是旧内容)
385
+ var w2 = pathArgs(path)
386
+ w2.put("contents", JsonString("覆盖"))
387
+ cmds.get("writeText").getOrThrow().handle("fs:writeText", w2, ctx)
388
+ let read2 = cmds.get("readText").getOrThrow().handle("fs:readText", pathArgs(path), ctx)
389
+ @Expect(read2.asString().getValue(), "覆盖")
390
+
391
+ let exists = cmds.get("exists").getOrThrow().handle("fs:exists", pathArgs(path), ctx)
392
+ @Expect(exists.asBool().getValue(), true)
393
+ }
394
+
395
+ @Test
396
+ func testFsRejectsMissingArgument(): Unit {
397
+ let cmds = FsPlugin().commands()
398
+ let ctx = IpcContext("main", IpcHub())
399
+ var threw = false
400
+ try {
401
+ cmds.get("readText").getOrThrow().handle("fs:readText", JsonObject(), ctx)
402
+ } catch (e: CommandException) {
403
+ threw = true
404
+ @Expect(e.message.contains("missing argument"), true)
405
+ }
406
+ @Expect(threw, true)
407
+ }
408
+
409
+ @Test
410
+ func testFsReadMissingFileBecomesBusinessError(): Unit {
411
+ let cmds = FsPlugin().commands()
412
+ let ctx = IpcContext("main", IpcHub())
413
+ var threw = false
414
+ try {
415
+ cmds.get("readText").getOrThrow().handle("fs:readText", pathArgs("/tmp/cj-tauri-plugin-no-such-file.txt"), ctx)
416
+ } catch (e: CommandException) {
417
+ // 抛 CommandException 而不是裸异常:前端拿到的是可读原文,不是 "command failed: ..."
418
+ threw = true
419
+ @Expect(e.message.startsWith("fs:readText failed:"), true)
420
+ }
421
+ @Expect(threw, true)
422
+
423
+ let exists = cmds.get("exists").getOrThrow().handle("fs:exists", pathArgs("/tmp/cj-tauri-plugin-no-such-file.txt"), ctx)
424
+ @Expect(exists.asBool().getValue(), false)
425
+ }
426
+
427
+ /**
428
+ * JsonValue → ArrayList<JsonValue>(测试助手)
429
+ * JsonArray 本身不支持 for-in,且 arr.get(i) 的元素类型要靠上下文推断——
430
+ * 统一在这儿把它转成 ArrayList,后面就能直接下标访问、也能 for-in。
431
+ */
432
+ func jsonItemsOf(v: JsonValue): ArrayList<JsonValue> {
433
+ let out = ArrayList<JsonValue>()
434
+ let arr = v.asArray()
435
+ for (i in 0..arr.size()) {
436
+ out.add(arr.get(i).getOrThrow())
437
+ }
438
+ return out
439
+ }
440
+
441
+ /** 取出对象里某个键的字符串数组(describePluginsJson 的测试助手) */
442
+ func jsonStringsOf(obj: JsonObject, key: String): ArrayList<String> {
443
+ let out = ArrayList<String>()
444
+ for (e in jsonItemsOf(obj.get(key).getOrThrow())) {
445
+ out.add(e.asString().getValue())
446
+ }
447
+ return out
448
+ }
449
+
450
+ /** "a|b" 形式,便于用字符串断言数组的内容与顺序 */
451
+ func joinWithPipe(items: ArrayList<String>): String {
452
+ var s = ""
453
+ for (x in items) {
454
+ if (s.size > 0) {
455
+ s += "|"
456
+ }
457
+ s += x
458
+ }
459
+ return s
460
+ }
461
+
462
+ @Test
463
+ func testDescribePluginsJsonShape(): Unit {
464
+ let plugins = ArrayList<Plugin>()
465
+ plugins.add(DemoPlugin())
466
+ plugins.add(ScopedPlugin())
467
+ let arr = jsonItemsOf(describePluginsJson(plugins))
468
+ @Expect(arr.size, 2)
469
+
470
+ // 数组顺序 = 装配顺序
471
+ let demo = arr[0].asObject()
472
+ @Expect(demo.get("name").getOrThrow().asString().getValue(), "demo")
473
+ @Expect(joinWithPipe(jsonStringsOf(demo, "commands")), "demo:do")
474
+ @Expect(joinWithPipe(jsonStringsOf(demo, "events")), "demo:changed")
475
+ @Expect(demo.get("permissions").getOrThrow().asObject().size(), 0)
476
+ @Expect(demo.get("hasShim").getOrThrow().asBool().getValue(), true)
477
+
478
+ let scoped = arr[1].asObject()
479
+ // 命令/事件按字典序(read 在 write 前),而不是 HashMap 的迭代顺序
480
+ @Expect(joinWithPipe(jsonStringsOf(scoped, "commands")), "scoped:read|scoped:write")
481
+ @Expect(scoped.get("hasShim").getOrThrow().asBool().getValue(), false)
482
+ // 权限集用「框架展开后」的全名,成员同样排序(scoped:changed < scoped:read)
483
+ let perms = scoped.get("permissions").getOrThrow().asObject()
484
+ @Expect(perms.size(), 1)
485
+ @Expect(joinWithPipe(jsonStringsOf(perms, "scoped:watch")), "scoped:changed|scoped:read")
486
+ }
487
+
488
+ @Test
489
+ func testDescribePluginsJsonStableAcrossRuns(): Unit {
490
+ // 连续两次序列化必须逐字节相同:命令/事件/权限键都排过序,不受 HashMap 迭代顺序影响
491
+ let plugins = ArrayList<Plugin>()
492
+ plugins.add(DemoPlugin())
493
+ plugins.add(ScopedPlugin())
494
+ plugins.add(MiniPlugin())
495
+ @Expect(describePluginsJson(plugins).toJsonString(), describePluginsJson(plugins).toJsonString())
496
+ }
497
+
498
+ @Test
499
+ func testDescribePluginsJsonEmptyList(): Unit {
500
+ let none = ArrayList<Plugin>()
501
+ @Expect(jsonItemsOf(describePluginsJson(none)).size, 0)
502
+ }
503
+
504
+ /*
505
+ * 清单导出通道(CJ_TAURI_MANIFEST):`cj-tauri info` 靠它从「跑到装配完成、但不建窗口」的应用里取清单。
506
+ * 这里只测纯逻辑(环境变量解析 + 两种模式的渲染与可解析性),不创建窗口。
507
+ */
508
+
509
+ /** 把 manifestDumpMode() 的 Option 归一成字符串,便于用 @Expect 断言 */
510
+ func manifestModeOf(): String {
511
+ match (manifestDumpMode()) {
512
+ case Some(m) => return m
513
+ case None => return "<none>"
514
+ }
515
+ }
516
+
517
+ @Test
518
+ func testManifestDumpModeFromEnv(): Unit {
519
+ removeVariable("CJ_TAURI_MANIFEST")
520
+ @Expect(manifestModeOf(), "<none>")
521
+
522
+ setVariable("CJ_TAURI_MANIFEST", "")
523
+ @Expect(manifestModeOf(), "<none>") // 空串等同未设置
524
+
525
+ setVariable("CJ_TAURI_MANIFEST", "json")
526
+ @Expect(manifestModeOf(), "json")
527
+
528
+ setVariable("CJ_TAURI_MANIFEST", "1")
529
+ @Expect(manifestModeOf(), "text") // 只认 "json" 这一个值,其它一律当人类可读形态
530
+
531
+ removeVariable("CJ_TAURI_MANIFEST")
532
+ @Expect(manifestModeOf(), "<none>") // 收尾:别把环境变量漏给后面的用例
533
+ }
534
+
535
+ @Test
536
+ func testRenderPluginManifestTextMode(): Unit {
537
+ let plugins = ArrayList<Plugin>()
538
+ plugins.add(DemoPlugin())
539
+ // 人读形态就是启动日志里那份表,两种出口不能各写一套
540
+ @Expect(renderPluginManifest(plugins, "text"), describePlugins(plugins))
541
+ @Expect(renderPluginManifest(plugins, "text").contains("demo:do"), true)
542
+ }
543
+
544
+ @Test
545
+ func testRenderPluginManifestJsonMode(): Unit {
546
+ let plugins = ArrayList<Plugin>()
547
+ plugins.add(DemoPlugin())
548
+ let text = renderPluginManifest(plugins, "json")
549
+ // 必须能被标准 JSON 解析器重新解析(CLI / CI 消费的就是它)
550
+ let arr = jsonItemsOf(JsonValue.fromStr(text))
551
+ @Expect(arr.size, 1)
552
+ let demo = arr[0].asObject()
553
+ @Expect(demo.get("name").getOrThrow().asString().getValue(), "demo")
554
+ @Expect(joinWithPipe(jsonStringsOf(demo, "commands")), "demo:do")
555
+ }
556
+
557
+ @Test
558
+ func testRenderPluginManifestEmptyList(): Unit {
559
+ let none = ArrayList<Plugin>()
560
+ @Expect(renderPluginManifest(none, "text"), "(未装配插件)\n")
561
+ @Expect(JsonValue.fromStr(renderPluginManifest(none, "json")).asArray().size(), 0)
562
+ }
563
+
564
+ class HostedDemoPlugin <: Plugin {
565
+ public init() {}
566
+
567
+ public func name(): String {
568
+ return "demo2"
569
+ }
570
+
571
+ public func commands(): HashMap<String, CommandHandler> {
572
+ return HashMap<String, CommandHandler>()
573
+ }
574
+
575
+ public func describe(): PluginManifest {
576
+ let hosts = ArrayList<String>()
577
+ hosts.add("webkitgtk")
578
+ return PluginManifest(hosts, ArrayList<String>())
579
+ }
580
+ }
581
+
582
+ @Test
583
+ func testDescribeDefault(): Unit {
584
+ let d = DemoPlugin().describe()
585
+ // 默认实现:全宿主、无能力依赖(空数组 = 不限)
586
+ @Expect(d.supportedHosts.size, 0)
587
+ @Expect(d.requiredCapabilities.size, 0)
588
+ }
589
+
590
+ @Test
591
+ func testDescribeOverride(): Unit {
592
+ let d = HostedDemoPlugin().describe()
593
+ @Expect(d.supportedHosts.size, 1)
594
+ @Expect(d.supportedHosts.get(0), "webkitgtk")
595
+ }
596
+
597
+ @Test
598
+ func testManifestJsonCarriesDescribe(): Unit {
599
+ let plugins = ArrayList<Plugin>()
600
+ plugins.add(HostedDemoPlugin())
601
+ let arr = jsonItemsOf(JsonValue.fromStr(renderPluginManifest(plugins, "json")))
602
+ let obj = arr[0].asObject()
603
+ @Expect(joinWithPipe(jsonStringsOf(obj, "supportedHosts")), "webkitgtk")
604
+ @Expect(jsonStringsOf(obj, "requiredCapabilities").size, 0)
605
+ }