@reventlessdev/reventless-gwt 1.0.0-alpha.74

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 (172) hide show
  1. package/CHANGELOG.md +698 -0
  2. package/LICENSE +202 -0
  3. package/bin/reventless-dev.mjs +21 -0
  4. package/package.json +54 -0
  5. package/rescript.json +36 -0
  6. package/src/.gwtignore +10 -0
  7. package/src/Automation_GWT.res +207 -0
  8. package/src/Automation_GWT.res.mjs +160 -0
  9. package/src/Behavior_GWT.res +603 -0
  10. package/src/Behavior_GWT.res.mjs +426 -0
  11. package/src/Bind.res +29 -0
  12. package/src/Bind.res.mjs +22 -0
  13. package/src/BuildClassifier.res +137 -0
  14. package/src/BuildClassifier.res.mjs +136 -0
  15. package/src/Cancellation.res +31 -0
  16. package/src/Cancellation.res.mjs +45 -0
  17. package/src/ChildProcess.res +84 -0
  18. package/src/ChildProcess.res.mjs +92 -0
  19. package/src/Cli.res +671 -0
  20. package/src/Cli.res.mjs +754 -0
  21. package/src/Collector.res +201 -0
  22. package/src/Collector.res.mjs +215 -0
  23. package/src/ComponentMeta.res +104 -0
  24. package/src/ComponentMeta.res.mjs +120 -0
  25. package/src/ComponentScan.res +103 -0
  26. package/src/ComponentScan.res.mjs +105 -0
  27. package/src/Delegate_GWT.res +303 -0
  28. package/src/Delegate_GWT.res.mjs +372 -0
  29. package/src/Diff.res +91 -0
  30. package/src/Diff.res.mjs +98 -0
  31. package/src/Discovery.res +105 -0
  32. package/src/Discovery.res.mjs +103 -0
  33. package/src/DomainDeadCode.res +93 -0
  34. package/src/DomainDeadCode.res.mjs +69 -0
  35. package/src/DomainGraph.res +0 -0
  36. package/src/DomainGraph.res.mjs +0 -0
  37. package/src/EventMapping_GWT.res +29 -0
  38. package/src/EventMapping_GWT.res.mjs +44 -0
  39. package/src/Filter.res +36 -0
  40. package/src/Filter.res.mjs +40 -0
  41. package/src/Flow_GWT.res +664 -0
  42. package/src/Flow_GWT.res.mjs +604 -0
  43. package/src/FormatterHuman.res +138 -0
  44. package/src/FormatterHuman.res.mjs +122 -0
  45. package/src/FormatterJson.res +257 -0
  46. package/src/FormatterJson.res.mjs +320 -0
  47. package/src/FormatterJunit.res +93 -0
  48. package/src/FormatterJunit.res.mjs +90 -0
  49. package/src/FormatterTap.res +139 -0
  50. package/src/FormatterTap.res.mjs +149 -0
  51. package/src/FormatterVsCode.res +362 -0
  52. package/src/FormatterVsCode.res.mjs +564 -0
  53. package/src/Hint.res +119 -0
  54. package/src/Hint.res.mjs +124 -0
  55. package/src/InboundTranslation_GWT.res +83 -0
  56. package/src/InboundTranslation_GWT.res.mjs +94 -0
  57. package/src/JestBind.res +70 -0
  58. package/src/JestBind.res.mjs +63 -0
  59. package/src/Loader.res +24 -0
  60. package/src/Loader.res.mjs +23 -0
  61. package/src/LocalHost.res +135 -0
  62. package/src/LocalHost.res.mjs +127 -0
  63. package/src/Mapping_GWT.res +474 -0
  64. package/src/Mapping_GWT.res.mjs +322 -0
  65. package/src/MultiSourceProjection_GWT.res +316 -0
  66. package/src/MultiSourceProjection_GWT.res.mjs +354 -0
  67. package/src/OutboundTranslation_GWT.res +238 -0
  68. package/src/OutboundTranslation_GWT.res.mjs +186 -0
  69. package/src/Outcome.res +161 -0
  70. package/src/Outcome.res.mjs +140 -0
  71. package/src/PackageScan.res +80 -0
  72. package/src/PackageScan.res.mjs +81 -0
  73. package/src/PlatformRunner.res +160 -0
  74. package/src/PlatformRunner.res.mjs +155 -0
  75. package/src/PlatformScan.res +138 -0
  76. package/src/PlatformScan.res.mjs +131 -0
  77. package/src/ProcessManager.res +91 -0
  78. package/src/ProcessManager.res.mjs +80 -0
  79. package/src/Projection_GWT.res +312 -0
  80. package/src/Projection_GWT.res.mjs +332 -0
  81. package/src/Query_GWT.res +379 -0
  82. package/src/Query_GWT.res.mjs +367 -0
  83. package/src/RenderRescript.res +125 -0
  84. package/src/RenderRescript.res.mjs +141 -0
  85. package/src/RunnerTypes.res +68 -0
  86. package/src/RunnerTypes.res.mjs +60 -0
  87. package/src/SideEffect_GWT.res +157 -0
  88. package/src/SideEffect_GWT.res.mjs +113 -0
  89. package/src/StateChange/ExternalAddCategorySlice.res +25 -0
  90. package/src/StateChange/ExternalAddCategorySlice.res.mjs +34 -0
  91. package/src/StateChange/ExternalAddCategorySlice_Behavior.res +19 -0
  92. package/src/StateChange/ExternalAddCategorySlice_Behavior.res.mjs +46 -0
  93. package/src/StateChange/WithFixtures.res +22 -0
  94. package/src/StateChange/WithFixtures.res.mjs +31 -0
  95. package/src/StateChange/WithFixtures_Behavior.res +17 -0
  96. package/src/StateChange/WithFixtures_Behavior.res.mjs +37 -0
  97. package/src/StateChange/WithManualOpen.res +23 -0
  98. package/src/StateChange/WithManualOpen.res.mjs +31 -0
  99. package/src/StateChange/WithManualOpen_Behavior.res +17 -0
  100. package/src/StateChange/WithManualOpen_Behavior.res.mjs +37 -0
  101. package/src/StubRuntime.res +53 -0
  102. package/src/StubRuntime.res.mjs +54 -0
  103. package/src/TestFixtures.res +16 -0
  104. package/src/TestFixtures.res.mjs +34 -0
  105. package/src/Watch.res +92 -0
  106. package/src/Watch.res.mjs +83 -0
  107. package/src/WatcherProbe.res +48 -0
  108. package/src/WatcherProbe.res.mjs +46 -0
  109. package/tests/AutomationGwtTest.res +71 -0
  110. package/tests/AutomationGwtTest.res.mjs +189 -0
  111. package/tests/BuildClassifierTest.res +94 -0
  112. package/tests/BuildClassifierTest.res.mjs +132 -0
  113. package/tests/ComponentMetaTest.res +52 -0
  114. package/tests/ComponentMetaTest.res.mjs +57 -0
  115. package/tests/DelegateGwtTest.res +487 -0
  116. package/tests/DelegateGwtTest.res.mjs +891 -0
  117. package/tests/DiscoveryTest.res +42 -0
  118. package/tests/DiscoveryTest.res.mjs +43 -0
  119. package/tests/DomainDeadCodeTest.res +116 -0
  120. package/tests/DomainDeadCodeTest.res.mjs +155 -0
  121. package/tests/DomainGraphTest.res +365 -0
  122. package/tests/DomainGraphTest.res.mjs +413 -0
  123. package/tests/FlowAggregateGwtTest.res +169 -0
  124. package/tests/FlowAggregateGwtTest.res.mjs +269 -0
  125. package/tests/FlowCrossPluginGwtTest.res +340 -0
  126. package/tests/FlowCrossPluginGwtTest.res.mjs +798 -0
  127. package/tests/FlowGwtTest.res +222 -0
  128. package/tests/FlowGwtTest.res.mjs +432 -0
  129. package/tests/FormatterVsCodeMessageTest.res +41 -0
  130. package/tests/FormatterVsCodeMessageTest.res.mjs +42 -0
  131. package/tests/InboundTranslationGwtTest.res +34 -0
  132. package/tests/InboundTranslationGwtTest.res.mjs +98 -0
  133. package/tests/LocalHostIntegration.res +70 -0
  134. package/tests/LocalHostIntegration.res.mjs +64 -0
  135. package/tests/LocalHostTest.res +71 -0
  136. package/tests/LocalHostTest.res.mjs +63 -0
  137. package/tests/MappingGwtTest.res +348 -0
  138. package/tests/MappingGwtTest.res.mjs +592 -0
  139. package/tests/OutboundTranslationGwtTest.res +59 -0
  140. package/tests/OutboundTranslationGwtTest.res.mjs +138 -0
  141. package/tests/PlatformRunnerTest.res +66 -0
  142. package/tests/PlatformRunnerTest.res.mjs +85 -0
  143. package/tests/PlatformScanTest.res +61 -0
  144. package/tests/PlatformScanTest.res.mjs +56 -0
  145. package/tests/QueryGwtTest.res +161 -0
  146. package/tests/QueryGwtTest.res.mjs +314 -0
  147. package/tests/QueryResolverGwtTest.res +71 -0
  148. package/tests/QueryResolverGwtTest.res.mjs +136 -0
  149. package/tests/RunnerUnitTest.res +219 -0
  150. package/tests/RunnerUnitTest.res.mjs +244 -0
  151. package/tests/SideEffect/FakeOrderNotification.res +23 -0
  152. package/tests/SideEffect/FakeOrderNotification.res.mjs +36 -0
  153. package/tests/SideEffect/FakeOrderNotificationGwtTest.res +35 -0
  154. package/tests/SideEffect/FakeOrderNotificationGwtTest.res.mjs +73 -0
  155. package/tests/SideEffect/MockEmail.res +18 -0
  156. package/tests/SideEffect/MockEmail.res.mjs +47 -0
  157. package/tests/StateChange/ExternalAddCategorySlice_GWT.res +27 -0
  158. package/tests/StateChange/ExternalAddCategorySlice_GWT.res.mjs +70 -0
  159. package/tests/StateChange/WithFixtures_Fixtures.res +9 -0
  160. package/tests/StateChange/WithFixtures_Fixtures.res.mjs +20 -0
  161. package/tests/StateChange/WithFixtures_GWT.res +23 -0
  162. package/tests/StateChange/WithFixtures_GWT.res.mjs +56 -0
  163. package/tests/StateChange/WithManualOpen_Fixtures.res +7 -0
  164. package/tests/StateChange/WithManualOpen_Fixtures.res.mjs +20 -0
  165. package/tests/StateChange/WithManualOpen_GWT.res +17 -0
  166. package/tests/StateChange/WithManualOpen_GWT.res.mjs +56 -0
  167. package/tests/StateChangeSliceGwtTest.res +160 -0
  168. package/tests/StateChangeSliceGwtTest.res.mjs +280 -0
  169. package/tests/StateViewSliceGwtTest.res +64 -0
  170. package/tests/StateViewSliceGwtTest.res.mjs +161 -0
  171. package/tests/WatchDebounceTest.res +78 -0
  172. package/tests/WatchDebounceTest.res.mjs +97 -0
package/src/Cli.res ADDED
@@ -0,0 +1,671 @@
1
+ // Entry point for the `reventless-gwt` CLI. Orchestrates discovery, the
2
+ // module-level Collector, each formatter, and the watch / cancellation
3
+ // plumbing. Parses a minimal argv layer — no optparse library, no colour
4
+ // auto-detection — kept deliberately thin so the formatters own all
5
+ // presentational logic.
6
+
7
+ let toolVersion = "0.1.0"
8
+
9
+ type format = Human | Json | Tap | Junit | VsCode
10
+
11
+ type subcommand = Run | Discover | Watch | Platform
12
+
13
+ type options = {
14
+ subcommand: subcommand,
15
+ format: format,
16
+ stream: bool,
17
+ watch: bool,
18
+ filters: array<string>,
19
+ schemaVersion: option<string>,
20
+ roots: array<string>,
21
+ // `platform` only: the REVENTLESS_LOCAL_BACKEND value passed to the spawned
22
+ // platform child ("memory" default, or "sqlite:<path>[?reset]").
23
+ backend: string,
24
+ // `platform` only: pin the spawned platform to the fixed default ports
25
+ // (4000/4001/3001/3002) instead of allocating ephemeral ones. The VS Code
26
+ // extension passes this when it is also launching the host-shell UI, whose
27
+ // Vite proxy hardcodes localhost:4000/4001.
28
+ uiPorts: bool,
29
+ // `platform --list` only: enumerate the launchable platform packages (one
30
+ // NDJSON `{name, dir}` line each) and exit, rather than spawning one. Used by
31
+ // the VS Code extension to populate its active-app picker.
32
+ listPlatforms: bool,
33
+ toolVersion: string,
34
+ }
35
+
36
+ @val external argv: array<string> = "process.argv"
37
+
38
+ @val external now: unit => float = "performance.now"
39
+
40
+ let dateNowIso: unit => string = %raw(`() => new Date().toISOString()`)
41
+
42
+ let parseFormat = (s: string) =>
43
+ switch s {
44
+ | "human" => Ok(Human)
45
+ | "json" => Ok(Json)
46
+ | "tap" => Ok(Tap)
47
+ | "junit" => Ok(Junit)
48
+ | "vscode" => Ok(VsCode)
49
+ | other => Error("Unknown --format value: " ++ other)
50
+ }
51
+
52
+ // With no explicit path argument, scan the whole working-directory subtree.
53
+ // `Discovery.walk` already prunes `node_modules`/`lib`/`.git`/`dist`/`.history`,
54
+ // so pointing at the cwd auto-discovers every plugin's GWT tests in a
55
+ // multi-package workspace (e.g. an example root) with no roots configured.
56
+ let defaultRoots = () => ["."]
57
+
58
+ let help = () => `reventless-dev — Reventless dev CLI: GWT runner, domain graph, components, platform runner
59
+
60
+ USAGE:
61
+ reventless-dev run [--format=<fmt>] [--filter=<id>] [--stream] [--watch] [path...]
62
+ reventless-dev discover [--format=vscode] [path...]
63
+ reventless-dev watch [--format=<fmt>] [--filter=<id>] [path...]
64
+ reventless-dev platform [--format=vscode] [--backend=<b>] [--ui-ports] [path...]
65
+ reventless-dev platform --list [path...]
66
+
67
+ FORMATS:
68
+ human ANSI-coloured terminal output (default)
69
+ json Structured JSON envelope (--stream for NDJSON)
70
+ tap TAP 14 with YAML diagnostics
71
+ junit JUnit XML (single batch)
72
+ vscode NDJSON event stream for the VS Code Testing API
73
+
74
+ FLAGS:
75
+ --filter <id> Restrict execution to tests whose id contains <id>.
76
+ Repeatable.
77
+ --stream NDJSON streaming (json/vscode)
78
+ --watch Re-run on file change
79
+ --schema-version <v> Pin a JSON schema version for stable AI prompts
80
+ --backend <b> platform: storage backend for the spawned local platform
81
+ ("memory" default, or "sqlite:<path>[?reset]")
82
+ --ui-ports platform: pin the platform to the fixed default ports
83
+ (4000/4001/3001/3002) instead of ephemeral ones, so the
84
+ host-shell UI's Vite proxy can reach it
85
+ --list platform: list launchable platform packages as NDJSON
86
+ ({name, dir} per line) and exit
87
+ --help Show this help and exit
88
+
89
+ Exit code is 1 if any test failed, 0 otherwise.
90
+ `
91
+
92
+ let parseArgv = (argv: array<string>): result<options, string> => {
93
+ let slice = argv->Array.slice(~start=2, ~end=argv->Array.length)
94
+ let subcommand = ref(Run)
95
+ let format = ref(Human)
96
+ let stream = ref(false)
97
+ let watch = ref(false)
98
+ let filters = ref([])
99
+ let schemaVersion: ref<option<string>> = ref(None)
100
+ let roots = ref([])
101
+ let backend = ref("memory")
102
+ let uiPorts = ref(false)
103
+ let listPlatforms = ref(false)
104
+ let error = ref(None)
105
+ let showHelp = ref(false)
106
+ let i = ref(0)
107
+ let len = slice->Array.length
108
+ if len > 0 {
109
+ let first = slice->Array.getUnsafe(0)
110
+ switch first {
111
+ | "run" => {
112
+ subcommand := Run
113
+ i := 1
114
+ }
115
+ | "discover" => {
116
+ subcommand := Discover
117
+ format := VsCode
118
+ i := 1
119
+ }
120
+ | "watch" => {
121
+ subcommand := Watch
122
+ watch := true
123
+ i := 1
124
+ }
125
+ | "platform" => {
126
+ subcommand := Platform
127
+ i := 1
128
+ }
129
+ | "--help" | "-h" => showHelp := true
130
+ | _ => ()
131
+ }
132
+ }
133
+ while i.contents < len && error.contents == None && !showHelp.contents {
134
+ let arg = slice->Array.getUnsafe(i.contents)
135
+ if arg == "--help" || arg == "-h" {
136
+ showHelp := true
137
+ } else if String.startsWith(arg, "--format=") {
138
+ let v = String.slice(arg, ~start=9, ~end=String.length(arg))
139
+ switch parseFormat(v) {
140
+ | Ok(f) => format := f
141
+ | Error(e) => error := Some(e)
142
+ }
143
+ } else if arg == "--format" && i.contents + 1 < len {
144
+ let v = slice->Array.getUnsafe(i.contents + 1)
145
+ switch parseFormat(v) {
146
+ | Ok(f) => format := f
147
+ | Error(e) => error := Some(e)
148
+ }
149
+ i := i.contents + 1
150
+ } else if String.startsWith(arg, "--filter=") {
151
+ let v = String.slice(arg, ~start=9, ~end=String.length(arg))
152
+ filters := Array.concat(filters.contents, [v])
153
+ } else if arg == "--filter" && i.contents + 1 < len {
154
+ let v = slice->Array.getUnsafe(i.contents + 1)
155
+ filters := Array.concat(filters.contents, [v])
156
+ i := i.contents + 1
157
+ } else if arg == "--stream" {
158
+ stream := true
159
+ } else if arg == "--watch" {
160
+ watch := true
161
+ } else if String.startsWith(arg, "--schema-version=") {
162
+ let v = String.slice(arg, ~start=17, ~end=String.length(arg))
163
+ schemaVersion := Some(v)
164
+ } else if String.startsWith(arg, "--backend=") {
165
+ backend := String.slice(arg, ~start=10, ~end=String.length(arg))
166
+ } else if arg == "--backend" && i.contents + 1 < len {
167
+ backend := slice->Array.getUnsafe(i.contents + 1)
168
+ i := i.contents + 1
169
+ } else if arg == "--ui-ports" {
170
+ uiPorts := true
171
+ } else if arg == "--list" {
172
+ listPlatforms := true
173
+ } else if String.startsWith(arg, "--") {
174
+ error := Some("Unknown flag: " ++ arg)
175
+ } else {
176
+ roots := Array.concat(roots.contents, [arg])
177
+ }
178
+ i := i.contents + 1
179
+ }
180
+ if showHelp.contents {
181
+ Error(help())
182
+ } else {
183
+ switch error.contents {
184
+ | Some(e) => Error(e ++ "\n\n" ++ help())
185
+ | None =>
186
+ Ok({
187
+ subcommand: subcommand.contents,
188
+ format: format.contents,
189
+ stream: stream.contents,
190
+ watch: watch.contents,
191
+ filters: filters.contents,
192
+ schemaVersion: schemaVersion.contents,
193
+ roots: roots.contents->Array.length == 0 ? defaultRoots() : roots.contents,
194
+ backend: backend.contents,
195
+ uiPorts: uiPorts.contents,
196
+ listPlatforms: listPlatforms.contents,
197
+ toolVersion,
198
+ })
199
+ }
200
+ }
201
+ }
202
+
203
+ // Apply --filter: pass if id contains every filter substring, OR if no filters.
204
+ let passesFilter = (id: string, filters: array<string>) =>
205
+ filters->Array.length == 0 ||
206
+ filters->Array.some(f => id->String.includes(f))
207
+
208
+ // Extract a human-readable message from any thrown value. JS Errors carry a
209
+ // `.message`; ReScript exceptions are tagged objects (`RE_EXN_ID` + an optional
210
+ // string payload), e.g. `failwith("…")` raises `Failure("…")`. Without this the
211
+ // catch block below collapsed every ReScript exception to "unknown error",
212
+ // hiding messages like a slice's `failwith("not implemented: …")`.
213
+ let exnMessage: exn => string = %raw(`function(e) {
214
+ if (e == null) return "unknown error"
215
+ if (typeof e === "string") return e
216
+ if (typeof e.message === "string" && e.message.length) return e.message
217
+ if (typeof e.RE_EXN_ID === "string") {
218
+ var id = e.RE_EXN_ID
219
+ var tag = id.lastIndexOf(".") >= 0 ? id.slice(id.lastIndexOf(".") + 1) : id
220
+ return (typeof e._1 === "string" && e._1.length) ? e._1 : tag
221
+ }
222
+ return "unknown error"
223
+ }`)
224
+
225
+ // Execute a single entry, catching exceptions and thrown values so a
226
+ // misbehaved slice doesn't abort the rest of the suite.
227
+ let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
228
+ let start = now()
229
+ let status = ref(RunnerTypes.Pass)
230
+ let mismatch: ref<option<Outcome.mismatch>> = ref(None)
231
+ switch entry.status {
232
+ | Collector.Skipped => status := Skip
233
+ | _ =>
234
+ try {
235
+ let outcome = await entry.body()
236
+ switch outcome {
237
+ | Ok() => status := Pass
238
+ | Error(m) => {
239
+ status := Fail
240
+ mismatch := Some(m)
241
+ }
242
+ }
243
+ } catch {
244
+ | exn => {
245
+ status := Fail
246
+ let err = exnMessage(exn)
247
+ let stack: string = %raw(`(e => (e && e.stack) || "")`)(exn)
248
+ mismatch := Some(Outcome.Throw({error: err, stack: stack}))
249
+ }
250
+ }
251
+ }
252
+ let durationMs = now() -. start
253
+ {
254
+ id: entry.id,
255
+ name: entry.name,
256
+ describePath: entry.describePath,
257
+ status: status.contents,
258
+ durationMs,
259
+ location: entry.location,
260
+ slice: entry.slice,
261
+ mismatch: mismatch.contents,
262
+ skipReason: entry.status == Collector.Skipped ? Some("skipped at source") : None,
263
+ }
264
+ }
265
+
266
+ let loadAndCollect = async (path: string): array<Collector.entry> => {
267
+ Collector.activate()
268
+ Collector.setCurrentFile(path)
269
+ try {
270
+ await Loader.loadFile(path)
271
+ } catch {
272
+ | exn =>
273
+ let msg =
274
+ exn
275
+ ->JsExn.fromException
276
+ ->Option.flatMap(JsExn.message)
277
+ ->Option.getOr("import failed")
278
+ Collector.push(
279
+ `<file-load-error>`,
280
+ () =>
281
+ Promise.resolve(
282
+ Outcome.fail(
283
+ Throw({error: "Failed to load " ++ path ++ ": " ++ msg, stack: ""}),
284
+ ),
285
+ ),
286
+ )
287
+ }
288
+ let entries = Collector.drain()
289
+ Collector.deactivate()
290
+ entries
291
+ }
292
+
293
+ let runFiles = async (
294
+ opts: options,
295
+ paths: array<string>,
296
+ ~onFileFinished: option<RunnerTypes.fileResult => unit>=?,
297
+ ~onTestStart: option<(string, Collector.entry) => unit>=?,
298
+ ~onTestFinished: option<(string, RunnerTypes.testResult) => unit>=?,
299
+ ): RunnerTypes.runResult => {
300
+ let startedAt = dateNowIso()
301
+ let startTime = now()
302
+ let files: array<RunnerTypes.fileResult> = []
303
+ for i in 0 to paths->Array.length - 1 {
304
+ let path = paths->Array.getUnsafe(i)
305
+ if Cancellation.isCancelled() {
306
+ ()
307
+ } else {
308
+ let entries = await loadAndCollect(path)
309
+ let onlyActive = Collector.hasOnly.contents
310
+ let selected = entries->Array.filter(e => {
311
+ let keepByOnly = onlyActive ? e.status == Only : true
312
+ // Match filters against both the bare entry id (`describe::name`) and
313
+ // the fully-qualified id the VS Code extension uses
314
+ // (`<absFile>::<describe>::<name>`). Without the qualified form the
315
+ // extension's `--filter=<testId>` never matches — see continuous-run
316
+ // stale-failure fix.
317
+ let qualifiedId = path ++ "::" ++ e.id
318
+ let keepByFilter =
319
+ passesFilter(e.id, opts.filters) || passesFilter(qualifiedId, opts.filters)
320
+ keepByOnly && keepByFilter
321
+ })
322
+ let tests: array<RunnerTypes.testResult> = []
323
+ for j in 0 to selected->Array.length - 1 {
324
+ if Cancellation.isCancelled() {
325
+ let e = selected->Array.getUnsafe(j)
326
+ let r: RunnerTypes.testResult = {
327
+ id: e.id,
328
+ name: e.name,
329
+ describePath: e.describePath,
330
+ status: Skip,
331
+ durationMs: 0.0,
332
+ location: e.location,
333
+ slice: e.slice,
334
+ mismatch: None,
335
+ skipReason: Some("cancelled"),
336
+ }
337
+ tests->Array.push(r)
338
+ switch onTestFinished {
339
+ | Some(cb) => cb(path, r)
340
+ | None => ()
341
+ }
342
+ } else {
343
+ let entry = selected->Array.getUnsafe(j)
344
+ switch onTestStart {
345
+ | Some(cb) => cb(path, entry)
346
+ | None => ()
347
+ }
348
+ let r = await runEntry(entry)
349
+ tests->Array.push(r)
350
+ switch onTestFinished {
351
+ | Some(cb) => cb(path, r)
352
+ | None => ()
353
+ }
354
+ }
355
+ }
356
+ let fr: RunnerTypes.fileResult = {path, tests}
357
+ files->Array.push(fr)
358
+ switch onFileFinished {
359
+ | Some(cb) => cb(fr)
360
+ | None => ()
361
+ }
362
+ }
363
+ }
364
+ let durationMs = now() -. startTime
365
+ let summary = RunnerTypes.summaryOf(files, durationMs, startedAt)
366
+ {summary, files}
367
+ }
368
+
369
+ let emitResult = (opts: options, r: RunnerTypes.runResult) =>
370
+ switch opts.format {
371
+ | Human => FormatterHuman.emit(r)
372
+ | Json =>
373
+ if opts.stream {
374
+ FormatterJson.streamRunEnd(r)
375
+ } else {
376
+ FormatterJson.emit(r, ~toolVersion=opts.toolVersion)
377
+ }
378
+ | Tap => FormatterTap.emit(r)
379
+ | Junit => FormatterJunit.emit(r)
380
+ | VsCode => FormatterVsCode.runEnd(r.summary)
381
+ }
382
+
383
+ let runOnce = async (opts: options): int => {
384
+ let paths = await Discovery.discover(opts.roots)
385
+ if opts.format == Json && opts.stream {
386
+ FormatterJson.streamRunStart(~toolVersion=opts.toolVersion, ~startedAt=dateNowIso())
387
+ }
388
+ let onFileFinished: option<RunnerTypes.fileResult => unit> = switch (
389
+ opts.format,
390
+ opts.stream,
391
+ ) {
392
+ | (Json, true) =>
393
+ Some(
394
+ (f: RunnerTypes.fileResult) => {
395
+ f.tests->Array.forEach(FormatterJson.streamTest)
396
+ FormatterJson.streamFileFinished(f)
397
+ },
398
+ )
399
+ | _ => None
400
+ }
401
+ // VS Code emits per-test events (testStart / testPass / testFail / testSkip)
402
+ // so the extension can update the UI in real time. IDs are prefixed with the
403
+ // absolute file path to match the ids emitted by `discover`.
404
+ let onTestStart: option<(string, Collector.entry) => unit> =
405
+ opts.format == VsCode
406
+ ? Some((path, e) => FormatterVsCode.testStart(path ++ "::" ++ e.id))
407
+ : None
408
+ let onTestFinished: option<(string, RunnerTypes.testResult) => unit> =
409
+ opts.format == VsCode
410
+ ? Some(
411
+ (path, t) => {
412
+ let id = path ++ "::" ++ t.id
413
+ switch t.status {
414
+ | Pass => FormatterVsCode.testPass(id, t.durationMs)
415
+ | Fail => FormatterVsCode.testFail(t, id)
416
+ | Skip =>
417
+ FormatterVsCode.testSkip(id, t.skipReason->Option.getOr("skipped"))
418
+ }
419
+ },
420
+ )
421
+ : None
422
+ // For streaming formatters, emit per-file events; emitResult only emits
423
+ // the terminal `runEnd`.
424
+ if opts.format == Json && opts.stream {
425
+ paths->Array.forEach(p => FormatterJson.streamFileStart(p))
426
+ } else if opts.format == VsCode {
427
+ FormatterVsCode.runStart(~total=0, ~filter=opts.filters)
428
+ }
429
+ let result = await runFiles(opts, paths, ~onFileFinished?, ~onTestStart?, ~onTestFinished?)
430
+ emitResult(opts, result)
431
+ result.summary.failed > 0 ? 1 : 0
432
+ }
433
+
434
+ // Reflects each plugin's pluginStructure via the local host (a single cold load) and
435
+ // emits the domain-analysis events: `deadCode` (orphan events) and `graph` (the
436
+ // Event-Modeling node/edge model). Skips silently when there are no plugin packages
437
+ // or the local platform isn't resolvable, and swallows any load failure so the
438
+ // discovery stream is never compromised.
439
+ let emitDomainAnalysis = async (pkgs: array<PackageScan.pkg>): unit =>
440
+ switch LocalHost.discover(~packageDirs=pkgs->Array.map(p => p.dir)) {
441
+ | [] => ()
442
+ | plugins =>
443
+ switch LocalHost.resolveLocalPlatform(~fromPackageDir=(plugins->Array.getUnsafe(0)).packageDir) {
444
+ | None => ()
445
+ | Some(platformModulePath) =>
446
+ try {
447
+ let loaded = await LocalHost.loadGraph(~platformModulePath, ~plugins)
448
+ FormatterVsCode.deadCode(DomainDeadCode.analyze(~structures=loaded.structures))
449
+ FormatterVsCode.graph(DomainGraph.build(~structures=loaded.structures))
450
+ // Field schemas for command/event/read-side state (Phase 6.3): the same
451
+ // per-plugin entry the Platform_ComponentDefinitions GraphQL query returns.
452
+ FormatterVsCode.definitions(
453
+ loaded.structures->Array.map(((pluginId, s)) =>
454
+ ReventlessCore.Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId, s)
455
+ ),
456
+ )
457
+ } catch {
458
+ | _ => ()
459
+ }
460
+ }
461
+ }
462
+
463
+ // Emits the full VS Code discovery stream (tree items + `packages`) for a set
464
+ // of already-discovered test files. Shared by `discover` and `watch`.
465
+ let emitDiscovery = async (paths: array<string>): unit => {
466
+ FormatterVsCode.discoverStart()
467
+ let fileTests: array<(string, array<RunnerTypes.testResult>)> = []
468
+ for i in 0 to paths->Array.length - 1 {
469
+ let path = paths->Array.getUnsafe(i)
470
+ let entries = await loadAndCollect(path)
471
+ let tests: array<RunnerTypes.testResult> =
472
+ entries->Array.map(e => {
473
+ RunnerTypes.id: e.id,
474
+ name: e.name,
475
+ describePath: e.describePath,
476
+ status: Skip,
477
+ durationMs: 0.0,
478
+ location: e.location,
479
+ slice: e.slice,
480
+ mismatch: None,
481
+ skipReason: None,
482
+ })
483
+ fileTests->Array.push((path, tests))
484
+ }
485
+ FormatterVsCode.emitDiscoveryItems(fileTests)
486
+ let total = fileTests->Array.reduce(0, (a, (_, ts)) => a + ts->Array.length)
487
+ let pkgs = PackageScan.scan(paths)
488
+ FormatterVsCode.packages(pkgs)
489
+ // Full component inventory (incl. untested components) from each package's src/.
490
+ let comps = await ComponentScan.scan(pkgs->Array.map(p => p.dir))
491
+ FormatterVsCode.components(comps)
492
+ FormatterVsCode.discoverEnd(total)
493
+ // Domain analysis (dead-code + graph): emitted after the core stream so the tree
494
+ // shows first. One cold load of the local platform reflects pluginStructure —
495
+ // best-effort; never breaks discovery.
496
+ await emitDomainAnalysis(pkgs)
497
+ }
498
+
499
+ let runDiscover = async (opts: options): int => {
500
+ let paths = await Discovery.discover(opts.roots)
501
+ await emitDiscovery(paths)
502
+ 0
503
+ }
504
+
505
+ // Spawns (or adopts) a `rescript build -w` per package owning tests, wiring its
506
+ // output to build-status events. Only for the VS Code client — a human `watch`
507
+ // in a terminal keeps the lean re-run-only loop.
508
+ let startBuildWatchers = (paths: array<string>): unit => {
509
+ Cancellation.onCancel(ProcessManager.killAll)
510
+ PackageScan.scan(paths)->Array.forEach(pkg =>
511
+ if WatcherProbe.hasLiveWatcher(pkg.dir) {
512
+ // A developer's own watcher already covers this package — defer to it.
513
+ FormatterVsCode.buildExternal(pkg.dir)
514
+ } else {
515
+ let feed = BuildClassifier.make({
516
+ onStart: () => FormatterVsCode.buildStart(pkg.dir),
517
+ onOk: ms => FormatterVsCode.buildOk(pkg.dir, ms),
518
+ onFail: msg => FormatterVsCode.buildFail(pkg.dir, msg),
519
+ })
520
+ ProcessManager.spawnWatcher(pkg, ~onStdout=(_dir, line) => feed(line))
521
+ }
522
+ )
523
+ }
524
+
525
+ let runWatch = async (opts: options): int => {
526
+ let roots = opts.roots
527
+ // For the VS Code client, watch mode is the whole engine: emit the test tree
528
+ // and package set, take over the ReScript builds, then run + re-run on change.
529
+ if opts.format == VsCode {
530
+ let paths = await Discovery.discover(roots)
531
+ await emitDiscovery(paths)
532
+ startBuildWatchers(paths)
533
+ }
534
+ let runInProgress = ref(false)
535
+ let rerunPending = ref(false)
536
+ let run = async () => {
537
+ if runInProgress.contents {
538
+ rerunPending := true
539
+ } else {
540
+ runInProgress := true
541
+ let _ = await runOnce(opts)
542
+ runInProgress := false
543
+ if rerunPending.contents {
544
+ rerunPending := false
545
+ let _ = await runOnce(opts)
546
+ ()
547
+ }
548
+ }
549
+ }
550
+ let _ = await run()
551
+ // A *relocation* (a module moved to another directory — a chapter rename /
552
+ // ungroup / move-to-chapter in the extension, or a terminal `mv`) leaves
553
+ // dependents' emitted `.res.mjs` pointing at the old path: incremental
554
+ // compilation never marks them dirty (their source + the moved module's
555
+ // interface hash are unchanged), so the live watcher and a restart both skip
556
+ // them and the stale import surfaces as a `<file-load-error>`. The only
557
+ // reliable repair is a `rescript clean` of the owning package. Detect the
558
+ // structural signal — an `unlink` of a source `.res` (a module left a
559
+ // directory) — and clean-rebuild that package, gating the re-run on build
560
+ // completion. Edits and adds stay on the lean incremental re-run. VS Code
561
+ // only: a human terminal `watch` keeps the re-run-only loop (it doesn't own
562
+ // the builds, so there's nothing to clean-rebuild).
563
+ let structuralRebuild = (path: string): unit =>
564
+ switch PackageScan.findOwning(path) {
565
+ | None => ignore(run())
566
+ | Some(pkg) =>
567
+ let t0 = Date.now()
568
+ FormatterVsCode.buildStart(pkg.dir)
569
+ // Surface a compile error during the rebuild (the clean/build stream is
570
+ // classified for the error marker); the re-run is gated on completion
571
+ // below, not on the classifier, since a one-shot `rescript build` has no
572
+ // "Finished … compilation" terminator.
573
+ let feed = BuildClassifier.make({
574
+ onStart: () => (),
575
+ onOk: _ms => (),
576
+ onFail: msg => FormatterVsCode.buildFail(pkg.dir, msg),
577
+ })
578
+ ProcessManager.cleanRebuild(
579
+ pkg,
580
+ ~onStdout=(_dir, line) => feed(line),
581
+ ~onDone=() => {
582
+ FormatterVsCode.buildOk(pkg.dir, Date.now() -. t0)
583
+ ignore(run())
584
+ },
585
+ )
586
+ }
587
+ let _watcher = Watch.start(roots, (event, path) =>
588
+ if opts.format == VsCode && Watch.isStructuralSource(event, path) {
589
+ structuralRebuild(path)
590
+ } else {
591
+ ignore(run())
592
+ }
593
+ )
594
+ // Keep the process alive until cancelled. Awaiting here (rather than letting
595
+ // runWatch return) is essential: the bin wrapper calls `process.exit` on the
596
+ // resolved code, so a returning runWatch would tear down chokidar and the
597
+ // spawned build watchers immediately. Cancellation handlers run in
598
+ // registration order — ProcessManager.killAll first, then this resolve — so
599
+ // the watchers are killed before the process exits.
600
+ await Promise.make((resolve, _reject) => Cancellation.onCancel(() => resolve()))
601
+ 0
602
+ }
603
+
604
+ // Launches an app's reventless-local platform as a managed child with the domain
605
+ // event tap on, streaming lifecycle + events. For the VS Code client the callbacks
606
+ // emit NDJSON; a human `platform` prints readable lines and passes the child's own
607
+ // logs through.
608
+ let runPlatform = async (opts: options): int => {
609
+ let callbacks: PlatformRunner.callbacks = if opts.format == VsCode {
610
+ {
611
+ onStart: (~package, ~dir, ~domainPort, ~platformPort) =>
612
+ FormatterVsCode.platformStart(~package, ~dir, ~domainPort, ~platformPort),
613
+ onReady: (~domainEndpoint) => FormatterVsCode.platformReady(~domainEndpoint),
614
+ onDomainEvent: json => FormatterVsCode.domainEvent(json),
615
+ onLog: line => FormatterVsCode.platformLog(~line),
616
+ onStop: code => FormatterVsCode.platformStop(~code),
617
+ }
618
+ } else {
619
+ {
620
+ onStart: (~package, ~dir as _, ~domainPort, ~platformPort as _) =>
621
+ Console.log(`▶ platform ${package} starting (domain http://localhost:${Int.toString(domainPort)})`),
622
+ onReady: (~domainEndpoint) => Console.log(`✓ platform ready — ${domainEndpoint}`),
623
+ onDomainEvent: json => Console.log("· event " ++ JSON.stringify(json)),
624
+ onLog: line => Console.log(line),
625
+ onStop: code =>
626
+ Console.log(`■ platform stopped (code ${code->Option.mapOr("?", c => Int.toString(c))})`),
627
+ }
628
+ }
629
+ await PlatformRunner.run(
630
+ ~roots=opts.roots,
631
+ ~backend=opts.backend,
632
+ ~fixedPorts=opts.uiPorts,
633
+ ~callbacks,
634
+ )
635
+ }
636
+
637
+ // `platform --list`: enumerate the launchable platform packages under the roots
638
+ // and print one NDJSON `{name, dir}` object per line. The VS Code extension reads
639
+ // these to populate its active-app picker (app root = the package's parent dir).
640
+ // Plain stdout, no protocol handshake — it's a one-shot query, not a stream.
641
+ let listPlatforms = async (opts: options): int => {
642
+ let pkgs = await PlatformScan.scan(opts.roots)
643
+ pkgs->Array.forEach(pkg => {
644
+ let obj = Dict.fromArray([("name", JSON.String(pkg.name)), ("dir", JSON.String(pkg.dir))])
645
+ Console.log(JSON.stringify(JSON.Object(obj)))
646
+ })
647
+ 0
648
+ }
649
+
650
+ let main = async (): int => {
651
+ Cancellation.install()
652
+ let args = argv
653
+ switch parseArgv(args) {
654
+ | Error(msg) => {
655
+ Console.error(msg)
656
+ args->Array.some(a => a == "--help" || a == "-h") ? 0 : 2
657
+ }
658
+ | Ok(opts) =>
659
+ // Protocol handshake first, so a version-skewed extension can detect the
660
+ // CLI before interpreting the stream.
661
+ if opts.format == VsCode {
662
+ FormatterVsCode.hello()
663
+ }
664
+ switch opts.subcommand {
665
+ | Run => await runOnce(opts)
666
+ | Discover => await runDiscover(opts)
667
+ | Watch => await runWatch(opts)
668
+ | Platform => opts.listPlatforms ? await listPlatforms(opts) : await runPlatform(opts)
669
+ }
670
+ }
671
+ }