@reventlessdev/reventless-gwt 1.0.0-alpha.115 → 1.0.0-alpha.116

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 (91) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +5 -12
  3. package/rescript.json +0 -1
  4. package/bin/reventless-dev.mjs +0 -21
  5. package/src/Bind.res +0 -29
  6. package/src/Bind.res.mjs +0 -22
  7. package/src/BuildClassifier.res +0 -147
  8. package/src/BuildClassifier.res.mjs +0 -136
  9. package/src/Cancellation.res +0 -37
  10. package/src/Cancellation.res.mjs +0 -46
  11. package/src/ChildProcess.res +0 -102
  12. package/src/ChildProcess.res.mjs +0 -107
  13. package/src/Cli.res +0 -889
  14. package/src/Cli.res.mjs +0 -944
  15. package/src/Collector.res +0 -222
  16. package/src/Collector.res.mjs +0 -228
  17. package/src/ComponentMeta.res +0 -77
  18. package/src/ComponentMeta.res.mjs +0 -91
  19. package/src/ComponentScan.res +0 -101
  20. package/src/ComponentScan.res.mjs +0 -92
  21. package/src/Discovery.res +0 -102
  22. package/src/Discovery.res.mjs +0 -86
  23. package/src/DomainDeadCode.res +0 -98
  24. package/src/DomainDeadCode.res.mjs +0 -72
  25. package/src/DomainGraph.res +0 -0
  26. package/src/DomainGraph.res.mjs +0 -0
  27. package/src/ExnMessage.res +0 -14
  28. package/src/ExnMessage.res.mjs +0 -17
  29. package/src/ExnReflect.mjs +0 -24
  30. package/src/Filter.res +0 -45
  31. package/src/Filter.res.mjs +0 -47
  32. package/src/FormatterHuman.res +0 -109
  33. package/src/FormatterHuman.res.mjs +0 -115
  34. package/src/FormatterJson.res +0 -264
  35. package/src/FormatterJson.res.mjs +0 -322
  36. package/src/FormatterJunit.res +0 -93
  37. package/src/FormatterJunit.res.mjs +0 -90
  38. package/src/FormatterTap.res +0 -100
  39. package/src/FormatterTap.res.mjs +0 -131
  40. package/src/FormatterVsCode.res +0 -352
  41. package/src/FormatterVsCode.res.mjs +0 -546
  42. package/src/Loader.res +0 -24
  43. package/src/Loader.res.mjs +0 -23
  44. package/src/PackageScan.res +0 -80
  45. package/src/PackageScan.res.mjs +0 -81
  46. package/src/PlatformRunner.res +0 -191
  47. package/src/PlatformRunner.res.mjs +0 -178
  48. package/src/PlatformScan.res +0 -141
  49. package/src/PlatformScan.res.mjs +0 -124
  50. package/src/ProcessManager.res +0 -135
  51. package/src/ProcessManager.res.mjs +0 -119
  52. package/src/RunWorker.res +0 -24
  53. package/src/RunWorker.res.mjs +0 -16
  54. package/src/RunnerTypes.res +0 -68
  55. package/src/RunnerTypes.res.mjs +0 -60
  56. package/src/ScanIgnore.res +0 -11
  57. package/src/ScanIgnore.res.mjs +0 -23
  58. package/src/Watch.res +0 -102
  59. package/src/Watch.res.mjs +0 -80
  60. package/src/WatcherProbe.res +0 -56
  61. package/src/WatcherProbe.res.mjs +0 -59
  62. package/src/Worker.res +0 -24
  63. package/src/Worker.res.mjs +0 -9
  64. package/tests/BuildClassifierTest.res +0 -133
  65. package/tests/BuildClassifierTest.res.mjs +0 -176
  66. package/tests/CliRunEntryTest.res +0 -57
  67. package/tests/CliRunEntryTest.res.mjs +0 -65
  68. package/tests/CliWatchScopeTest.res +0 -62
  69. package/tests/CliWatchScopeTest.res.mjs +0 -95
  70. package/tests/ComponentMetaTest.res +0 -67
  71. package/tests/ComponentMetaTest.res.mjs +0 -71
  72. package/tests/DiscoveryTest.res +0 -42
  73. package/tests/DiscoveryTest.res.mjs +0 -43
  74. package/tests/DomainDeadCodeTest.res +0 -119
  75. package/tests/DomainDeadCodeTest.res.mjs +0 -158
  76. package/tests/DomainGraphTest.res +0 -448
  77. package/tests/DomainGraphTest.res.mjs +0 -505
  78. package/tests/ExnMessageTest.res +0 -42
  79. package/tests/ExnMessageTest.res.mjs +0 -64
  80. package/tests/FormatterGoldenTest.res +0 -125
  81. package/tests/FormatterGoldenTest.res.mjs +0 -162
  82. package/tests/FormatterVsCodeMessageTest.res +0 -41
  83. package/tests/FormatterVsCodeMessageTest.res.mjs +0 -42
  84. package/tests/PlatformRunnerTest.res +0 -66
  85. package/tests/PlatformRunnerTest.res.mjs +0 -85
  86. package/tests/PlatformScanTest.res +0 -61
  87. package/tests/PlatformScanTest.res.mjs +0 -56
  88. package/tests/RunnerUnitTest.res +0 -219
  89. package/tests/RunnerUnitTest.res.mjs +0 -244
  90. package/tests/WatchDebounceTest.res +0 -106
  91. package/tests/WatchDebounceTest.res.mjs +0 -128
package/src/Cli.res DELETED
@@ -1,889 +0,0 @@
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
- // When `Some`, `runOnce` runs exactly these compiled test paths instead of
35
- // walking the tree. Set by the watch loop's worker passes (the parent owns
36
- // discovery and narrows to the affected package); `None` for the one-shot
37
- // `run`/`discover` commands, which discover from `roots`.
38
- paths: option<array<string>>,
39
- }
40
-
41
- @val external argv: array<string> = "process.argv"
42
-
43
- // chokidar reports paths in the same form as its watched roots (relative when
44
- // roots are `["."]`), while `Discovery` returns absolute paths. Normalize a
45
- // changed path to absolute before mapping it to an owning package so the
46
- // prefix match against the discovered set holds.
47
- @module("node:path") external resolvePath: string => string = "resolve"
48
-
49
- @val external now: unit => float = "performance.now"
50
-
51
- type hash
52
- @module("node:crypto") external createHash: string => hash = "createHash"
53
- @send external hashUpdate: (hash, string) => hash = "update"
54
- @send external hashDigest: (hash, string) => string = "digest"
55
- let sha256 = (s: string): string => createHash("sha256")->hashUpdate(s)->hashDigest("hex")
56
-
57
- // Last-emitted domain-analysis fingerprint (deadCode + graph + definitions all
58
- // derive from the loaded plugin structures). Persists across a watch session's
59
- // repeated discovery emits so an unchanged graph isn't re-sent — a redundant
60
- // re-emit forces the client to re-render the domain view for nothing.
61
- let lastDomainHash: ref<option<string>> = ref(None)
62
-
63
- let dateNowIso = (): string => Date.make()->Date.toISOString
64
-
65
- let parseFormat = (s: string) =>
66
- switch s {
67
- | "human" => Ok(Human)
68
- | "json" => Ok(Json)
69
- | "tap" => Ok(Tap)
70
- | "junit" => Ok(Junit)
71
- | "vscode" => Ok(VsCode)
72
- | other => Error("Unknown --format value: " ++ other)
73
- }
74
-
75
- // With no explicit path argument, scan the whole working-directory subtree.
76
- // `Discovery.walk` already prunes `node_modules`/`lib`/`.git`/`dist`/`.history`,
77
- // so pointing at the cwd auto-discovers every plugin's GWT tests in a
78
- // multi-package workspace (e.g. an example root) with no roots configured.
79
- let defaultRoots = () => ["."]
80
-
81
- let help = () => `reventless-dev — Reventless dev CLI: GWT runner, domain graph, components, platform runner
82
-
83
- USAGE:
84
- reventless-dev run [--format=<fmt>] [--filter=<id>] [--stream] [--watch] [path...]
85
- reventless-dev discover [--format=vscode] [path...]
86
- reventless-dev watch [--format=<fmt>] [--filter=<id>] [path...]
87
- reventless-dev platform [--format=vscode] [--backend=<b>] [--ui-ports] [path...]
88
- reventless-dev platform --list [path...]
89
-
90
- FORMATS:
91
- human ANSI-coloured terminal output (default)
92
- json Structured JSON envelope (--stream for NDJSON)
93
- tap TAP 14 with YAML diagnostics
94
- junit JUnit XML (single batch)
95
- vscode NDJSON event stream for the VS Code Testing API
96
-
97
- FLAGS:
98
- --filter <id> Restrict execution to tests whose id contains <id>.
99
- Repeatable.
100
- --stream NDJSON streaming (json/vscode)
101
- --watch Re-run on file change
102
- --schema-version <v> Pin a JSON schema version for stable AI prompts
103
- --backend <b> platform: storage backend for the spawned local platform
104
- ("memory" default, or "sqlite:<path>[?reset]")
105
- --ui-ports platform: pin the platform to the fixed default ports
106
- (4000/4001/3001/3002) instead of ephemeral ones, so the
107
- host-shell UI's Vite proxy can reach it
108
- --list platform: list launchable platform packages as NDJSON
109
- ({name, dir} per line) and exit
110
- --help Show this help and exit
111
-
112
- Exit code is 1 if any test failed, 0 otherwise.
113
- `
114
-
115
- let parseArgv = (argv: array<string>): result<options, string> => {
116
- let slice = argv->Array.slice(~start=2, ~end=argv->Array.length)
117
- let subcommand = ref(Run)
118
- let format = ref(Human)
119
- let stream = ref(false)
120
- let watch = ref(false)
121
- let filters = ref([])
122
- let schemaVersion: ref<option<string>> = ref(None)
123
- let roots = ref([])
124
- let backend = ref("memory")
125
- let uiPorts = ref(false)
126
- let listPlatforms = ref(false)
127
- let error = ref(None)
128
- let showHelp = ref(false)
129
- let i = ref(0)
130
- let len = slice->Array.length
131
- if len > 0 {
132
- let first = slice->Array.getUnsafe(0)
133
- switch first {
134
- | "run" => {
135
- subcommand := Run
136
- i := 1
137
- }
138
- | "discover" => {
139
- subcommand := Discover
140
- format := VsCode
141
- i := 1
142
- }
143
- | "watch" => {
144
- subcommand := Watch
145
- watch := true
146
- i := 1
147
- }
148
- | "platform" => {
149
- subcommand := Platform
150
- i := 1
151
- }
152
- | "--help" | "-h" => showHelp := true
153
- | _ => ()
154
- }
155
- }
156
- while i.contents < len && error.contents == None && !showHelp.contents {
157
- let arg = slice->Array.getUnsafe(i.contents)
158
- if arg == "--help" || arg == "-h" {
159
- showHelp := true
160
- } else if String.startsWith(arg, "--format=") {
161
- let v = String.slice(arg, ~start=9, ~end=String.length(arg))
162
- switch parseFormat(v) {
163
- | Ok(f) => format := f
164
- | Error(e) => error := Some(e)
165
- }
166
- } else if arg == "--format" && i.contents + 1 < len {
167
- let v = slice->Array.getUnsafe(i.contents + 1)
168
- switch parseFormat(v) {
169
- | Ok(f) => format := f
170
- | Error(e) => error := Some(e)
171
- }
172
- i := i.contents + 1
173
- } else if String.startsWith(arg, "--filter=") {
174
- let v = String.slice(arg, ~start=9, ~end=String.length(arg))
175
- filters := Array.concat(filters.contents, [v])
176
- } else if arg == "--filter" && i.contents + 1 < len {
177
- let v = slice->Array.getUnsafe(i.contents + 1)
178
- filters := Array.concat(filters.contents, [v])
179
- i := i.contents + 1
180
- } else if arg == "--stream" {
181
- stream := true
182
- } else if arg == "--watch" {
183
- watch := true
184
- } else if String.startsWith(arg, "--schema-version=") {
185
- let v = String.slice(arg, ~start=17, ~end=String.length(arg))
186
- schemaVersion := Some(v)
187
- } else if String.startsWith(arg, "--backend=") {
188
- backend := String.slice(arg, ~start=10, ~end=String.length(arg))
189
- } else if arg == "--backend" && i.contents + 1 < len {
190
- backend := slice->Array.getUnsafe(i.contents + 1)
191
- i := i.contents + 1
192
- } else if arg == "--ui-ports" {
193
- uiPorts := true
194
- } else if arg == "--list" {
195
- listPlatforms := true
196
- } else if String.startsWith(arg, "--") {
197
- error := Some("Unknown flag: " ++ arg)
198
- } else {
199
- roots := Array.concat(roots.contents, [arg])
200
- }
201
- i := i.contents + 1
202
- }
203
- if showHelp.contents {
204
- Error(help())
205
- } else {
206
- switch error.contents {
207
- | Some(e) => Error(e ++ "\n\n" ++ help())
208
- | None =>
209
- Ok({
210
- subcommand: subcommand.contents,
211
- format: format.contents,
212
- stream: stream.contents,
213
- watch: watch.contents,
214
- filters: filters.contents,
215
- schemaVersion: schemaVersion.contents,
216
- roots: roots.contents->Array.length == 0 ? defaultRoots() : roots.contents,
217
- backend: backend.contents,
218
- uiPorts: uiPorts.contents,
219
- listPlatforms: listPlatforms.contents,
220
- toolVersion,
221
- paths: None,
222
- })
223
- }
224
- }
225
- }
226
-
227
- // Apply --filter: pass if id contains every filter substring, OR if no filters.
228
- let passesFilter = (id: string, filters: array<string>) =>
229
- filters->Array.length == 0 ||
230
- filters->Array.some(f => id->String.includes(f))
231
-
232
- // Default per-test deadline (ms) when a test body doesn't set `~timeout`.
233
- // Mirrors Jest's default so behaviour is consistent across both runners.
234
- let defaultTimeoutMs = 5000
235
-
236
- // Resolve to a timeout `Outcome` after `ms`. Raced against the test body so a
237
- // hung await reports a failure instead of wedging the entire run.
238
- type timeoutHandle
239
- @val external setTimeout: (unit => unit, int) => timeoutHandle = "setTimeout"
240
- @val external clearTimeout: timeoutHandle => unit = "clearTimeout"
241
- let raceWithTimeout = (body: unit => promise<Outcome.outcome>, ms: int): promise<
242
- Outcome.outcome,
243
- > => {
244
- let handleRef: ref<option<timeoutHandle>> = ref(None)
245
- let timeout = Promise.make((resolve, _reject) => {
246
- handleRef :=
247
- Some(
248
- setTimeout(
249
- () =>
250
- resolve(
251
- Outcome.fail(
252
- Throw({error: `timed out after ${Int.toString(ms)}ms`, stack: ""}),
253
- ),
254
- ),
255
- ms,
256
- ),
257
- )
258
- })
259
- Promise.race([body(), timeout])->Promise.then(o => {
260
- switch handleRef.contents {
261
- | Some(h) => clearTimeout(h)
262
- | None => ()
263
- }
264
- Promise.resolve(o)
265
- })
266
- }
267
-
268
- // Execute a single entry, catching exceptions and thrown values so a
269
- // misbehaved slice doesn't abort the rest of the suite.
270
- let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
271
- let start = now()
272
- let status = ref(RunnerTypes.Pass)
273
- let mismatch: ref<option<Outcome.mismatch>> = ref(None)
274
- switch entry.status {
275
- | Collector.Skipped => status := Skip
276
- | _ =>
277
- try {
278
- let deadline = entry.timeout->Option.getOr(defaultTimeoutMs)
279
- let outcome = await raceWithTimeout(entry.body, deadline)
280
- switch outcome {
281
- | Ok() => status := Pass
282
- | Error(m) => {
283
- status := Fail
284
- mismatch := Some(m)
285
- }
286
- }
287
- } catch {
288
- | exn => {
289
- status := Fail
290
- let err = ExnMessage.extract(exn)
291
- let stack = ExnMessage.stack(exn)
292
- mismatch := Some(Outcome.Throw({error: err, stack: stack}))
293
- }
294
- }
295
- }
296
- let durationMs = now() -. start
297
- {
298
- id: entry.id,
299
- name: entry.name,
300
- describePath: entry.describePath,
301
- status: status.contents,
302
- durationMs,
303
- location: entry.location,
304
- slice: entry.slice,
305
- mismatch: mismatch.contents,
306
- skipReason: entry.status == Collector.Skipped ? Some("skipped at source") : None,
307
- }
308
- }
309
-
310
- let loadAndCollect = async (path: string): array<Collector.entry> => {
311
- Collector.activate()
312
- RunnerHook.register(Collector.asSink)
313
- Collector.setCurrentFile(path)
314
- try {
315
- await Loader.loadFile(path)
316
- } catch {
317
- | exn =>
318
- let msg =
319
- exn
320
- ->JsExn.fromException
321
- ->Option.flatMap(JsExn.message)
322
- ->Option.getOr("import failed")
323
- Collector.push(
324
- `<file-load-error>`,
325
- () =>
326
- Promise.resolve(
327
- Outcome.fail(
328
- Throw({error: "Failed to load " ++ path ++ ": " ++ msg, stack: ""}),
329
- ),
330
- ),
331
- )
332
- }
333
- let entries = Collector.drain()
334
- Collector.deactivate()
335
- entries
336
- }
337
-
338
- let runFiles = async (
339
- opts: options,
340
- paths: array<string>,
341
- ~onFileFinished: option<RunnerTypes.fileResult => unit>=?,
342
- ~onTestStart: option<(string, Collector.entry) => unit>=?,
343
- ~onTestFinished: option<(string, RunnerTypes.testResult) => unit>=?,
344
- ): RunnerTypes.runResult => {
345
- let startedAt = dateNowIso()
346
- let startTime = now()
347
- let files: array<RunnerTypes.fileResult> = []
348
- for i in 0 to paths->Array.length - 1 {
349
- let path = paths->Array.getUnsafe(i)
350
- if Cancellation.isCancelled() {
351
- ()
352
- } else {
353
- let entries = await loadAndCollect(path)
354
- let onlyActive = Collector.hasOnly.contents
355
- let selected = entries->Array.filter(e => {
356
- let keepByOnly = onlyActive ? e.status == Only : true
357
- // Match filters against both the bare entry id (`describe::name`) and
358
- // the fully-qualified id the VS Code extension uses
359
- // (`<absFile>::<describe>::<name>`). Without the qualified form the
360
- // extension's `--filter=<testId>` never matches — see continuous-run
361
- // stale-failure fix.
362
- let qualifiedId = path ++ "::" ++ e.id
363
- let keepByFilter =
364
- passesFilter(e.id, opts.filters) || passesFilter(qualifiedId, opts.filters)
365
- keepByOnly && keepByFilter
366
- })
367
- let tests: array<RunnerTypes.testResult> = []
368
- for j in 0 to selected->Array.length - 1 {
369
- if Cancellation.isCancelled() {
370
- let e = selected->Array.getUnsafe(j)
371
- let r: RunnerTypes.testResult = {
372
- id: e.id,
373
- name: e.name,
374
- describePath: e.describePath,
375
- status: Skip,
376
- durationMs: 0.0,
377
- location: e.location,
378
- slice: e.slice,
379
- mismatch: None,
380
- skipReason: Some("cancelled"),
381
- }
382
- tests->Array.push(r)
383
- switch onTestFinished {
384
- | Some(cb) => cb(path, r)
385
- | None => ()
386
- }
387
- } else {
388
- let entry = selected->Array.getUnsafe(j)
389
- switch onTestStart {
390
- | Some(cb) => cb(path, entry)
391
- | None => ()
392
- }
393
- let r = await runEntry(entry)
394
- tests->Array.push(r)
395
- switch onTestFinished {
396
- | Some(cb) => cb(path, r)
397
- | None => ()
398
- }
399
- }
400
- }
401
- let fr: RunnerTypes.fileResult = {path, tests}
402
- files->Array.push(fr)
403
- switch onFileFinished {
404
- | Some(cb) => cb(fr)
405
- | None => ()
406
- }
407
- }
408
- }
409
- let durationMs = now() -. startTime
410
- let summary = RunnerTypes.summaryOf(files, durationMs, startedAt)
411
- {summary, files}
412
- }
413
-
414
- let emitResult = (opts: options, r: RunnerTypes.runResult) =>
415
- switch opts.format {
416
- | Human => FormatterHuman.emit(r)
417
- | Json =>
418
- if opts.stream {
419
- FormatterJson.streamRunEnd(r)
420
- } else {
421
- FormatterJson.emit(r, ~toolVersion=opts.toolVersion, ~schemaVersion=?opts.schemaVersion)
422
- }
423
- | Tap => FormatterTap.emit(r)
424
- | Junit => FormatterJunit.emit(r)
425
- | VsCode => FormatterVsCode.runEnd(r.summary)
426
- }
427
-
428
- // Watch re-run scope. A plain edit under a test-owning package narrows the pass
429
- // to that package's test files (`RunPackages`); structural rebuilds, new-file
430
- // adds, and any non-VsCode watch use `RunAll`. Scopes accumulate while a pass is
431
- // in flight — `RunAll` is absorbing (an all-run unioned with anything is an
432
- // all-run), and `RunPackages` unions its dirs.
433
- type runScope = RunAll | RunPackages(array<string>)
434
-
435
- let mergeScope = (a: runScope, b: runScope): runScope =>
436
- switch (a, b) {
437
- | (RunAll, _) | (_, RunAll) => RunAll
438
- | (RunPackages(x), RunPackages(y)) =>
439
- let seen = Dict.make()
440
- let union = []
441
- Array.concat(x, y)->Array.forEach(d =>
442
- switch seen->Dict.get(d) {
443
- | Some(_) => ()
444
- | None => {
445
- seen->Dict.set(d, true)
446
- union->Array.push(d)
447
- }
448
- }
449
- )
450
- RunPackages(union)
451
- }
452
-
453
- // True when `path` sits inside directory `dir` (dir is an ancestor). Lets a
454
- // package dir be mapped to its test files by prefix, with no per-path fs walk.
455
- let pathUnderDir = (path: string, dir: string): bool =>
456
- String.startsWith(path, String.endsWith(dir, "/") ? dir : dir ++ "/")
457
-
458
- // The subset of `allPaths` a scope re-runs.
459
- let scopeSubset = (scope: runScope, allPaths: array<string>): array<string> =>
460
- switch scope {
461
- | RunAll => allPaths
462
- | RunPackages(dirs) => allPaths->Array.filter(p => dirs->Array.some(d => pathUnderDir(p, d)))
463
- }
464
-
465
- let runOnce = async (opts: options): int => {
466
- // A watch worker pass supplies the exact path set (parent-owned discovery,
467
- // narrowed to the affected package); the one-shot commands discover the tree.
468
- let paths = switch opts.paths {
469
- | Some(p) => p
470
- | None => await Discovery.discover(opts.roots)
471
- }
472
- if opts.format == Json && opts.stream {
473
- FormatterJson.streamRunStart(
474
- ~toolVersion=opts.toolVersion,
475
- ~startedAt=dateNowIso(),
476
- ~schemaVersion=?opts.schemaVersion,
477
- )
478
- }
479
- let onFileFinished: option<RunnerTypes.fileResult => unit> = switch (
480
- opts.format,
481
- opts.stream,
482
- ) {
483
- | (Json, true) =>
484
- Some(
485
- (f: RunnerTypes.fileResult) => {
486
- f.tests->Array.forEach(FormatterJson.streamTest)
487
- FormatterJson.streamFileFinished(f)
488
- },
489
- )
490
- | _ => None
491
- }
492
- // VS Code emits per-test events (testStart / testPass / testFail / testSkip)
493
- // so the extension can update the UI in real time. IDs are prefixed with the
494
- // absolute file path to match the ids emitted by `discover`.
495
- let onTestStart: option<(string, Collector.entry) => unit> =
496
- opts.format == VsCode
497
- ? Some((path, e) => FormatterVsCode.testStart(path ++ "::" ++ e.id))
498
- : None
499
- let onTestFinished: option<(string, RunnerTypes.testResult) => unit> =
500
- opts.format == VsCode
501
- ? Some(
502
- (path, t) => {
503
- let id = path ++ "::" ++ t.id
504
- switch t.status {
505
- | Pass => FormatterVsCode.testPass(id, t.durationMs)
506
- | Fail => FormatterVsCode.testFail(t, id)
507
- | Skip =>
508
- FormatterVsCode.testSkip(id, t.skipReason->Option.getOr("skipped"))
509
- }
510
- },
511
- )
512
- : None
513
- // For streaming formatters, emit per-file events; emitResult only emits
514
- // the terminal `runEnd`.
515
- if opts.format == Json && opts.stream {
516
- paths->Array.forEach(p => FormatterJson.streamFileStart(p))
517
- } else if opts.format == VsCode {
518
- FormatterVsCode.runStart(~total=0, ~filter=opts.filters)
519
- }
520
- let result = await runFiles(opts, paths, ~onFileFinished?, ~onTestStart?, ~onTestFinished?)
521
- emitResult(opts, result)
522
- result.summary.failed > 0 ? 1 : 0
523
- }
524
-
525
- // Reflects each plugin's pluginStructure via the local host (a single cold load) and
526
- // emits the domain-analysis events: `deadCode` (orphan events) and `graph` (the
527
- // Event-Modeling node/edge model). Skips silently when there are no plugin packages
528
- // or the local platform isn't resolvable, and swallows any load failure so the
529
- // discovery stream is never compromised.
530
- let emitDomainAnalysis = async (pkgs: array<PackageScan.pkg>): unit =>
531
- switch LocalHost.discover(~packageDirs=pkgs->Array.map(p => p.dir)) {
532
- | [] => ()
533
- | plugins =>
534
- switch LocalHost.resolveLocalPlatform(~fromPackageDir=(plugins->Array.getUnsafe(0)).packageDir) {
535
- | None => ()
536
- | Some(platformModulePath) =>
537
- try {
538
- let loaded = await LocalHost.loadGraph(~platformModulePath, ~plugins)
539
- // Field schemas for command/event/read-side state (Phase 6.3): the same
540
- // per-plugin entry the Platform_ComponentDefinitions GraphQL query returns.
541
- let definitions =
542
- loaded.structures->Array.map(((pluginId, s)) =>
543
- ReventlessCore.Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId, s)
544
- )
545
- // deadCode + graph + definitions are all pure functions of `structures`,
546
- // so the definitions JSON is a faithful fingerprint of all three. Skip
547
- // the whole emit when it hasn't moved since the last discovery.
548
- let fingerprint = sha256(JSON.stringify(JSON.Array(definitions)))
549
- if lastDomainHash.contents != Some(fingerprint) {
550
- lastDomainHash := Some(fingerprint)
551
- FormatterVsCode.deadCode(DomainDeadCode.analyze(~structures=loaded.structures))
552
- FormatterVsCode.graph(DomainGraph.build(~structures=loaded.structures))
553
- FormatterVsCode.definitions(definitions)
554
- }
555
- } catch {
556
- | _ => ()
557
- }
558
- }
559
- }
560
-
561
- // Emits the full VS Code discovery stream (tree items + `packages`) for a set
562
- // of already-discovered test files. Shared by `discover` and `watch`.
563
- let emitDiscovery = async (paths: array<string>): unit => {
564
- FormatterVsCode.discoverStart()
565
- let fileTests: array<(string, array<RunnerTypes.testResult>)> = []
566
- for i in 0 to paths->Array.length - 1 {
567
- let path = paths->Array.getUnsafe(i)
568
- let entries = await loadAndCollect(path)
569
- let tests: array<RunnerTypes.testResult> =
570
- entries->Array.map(e => {
571
- RunnerTypes.id: e.id,
572
- name: e.name,
573
- describePath: e.describePath,
574
- status: Skip,
575
- durationMs: 0.0,
576
- location: e.location,
577
- slice: e.slice,
578
- mismatch: None,
579
- skipReason: None,
580
- })
581
- fileTests->Array.push((path, tests))
582
- }
583
- FormatterVsCode.emitDiscoveryItems(fileTests)
584
- let total = fileTests->Array.reduce(0, (a, (_, ts)) => a + ts->Array.length)
585
- let pkgs = PackageScan.scan(paths)
586
- FormatterVsCode.packages(pkgs)
587
- // Full component inventory (incl. untested components) from each package's src/.
588
- let comps = await ComponentScan.scan(pkgs->Array.map(p => p.dir))
589
- FormatterVsCode.components(comps)
590
- FormatterVsCode.discoverEnd(total)
591
- // Domain analysis (dead-code + graph): emitted after the core stream so the tree
592
- // shows first. One cold load of the local platform reflects pluginStructure —
593
- // best-effort; never breaks discovery.
594
- await emitDomainAnalysis(pkgs)
595
- }
596
-
597
- let runDiscover = async (opts: options): int => {
598
- let paths = await Discovery.discover(opts.roots)
599
- await emitDiscovery(paths)
600
- 0
601
- }
602
-
603
- // Spawns (or adopts) a `rescript build -w` per package owning tests, wiring its
604
- // output to build-status events. Only for the VS Code client — a human `watch`
605
- // in a terminal keeps the lean re-run-only loop.
606
- let startBuildWatchers = (paths: array<string>): unit => {
607
- Cancellation.onCancel(ProcessManager.killAll)
608
- PackageScan.scan(paths)->Array.forEach(pkg =>
609
- if WatcherProbe.hasLiveWatcher(pkg.dir) {
610
- // A developer's own watcher already covers this package — defer to it.
611
- FormatterVsCode.buildExternal(pkg.dir)
612
- } else {
613
- let feed = BuildClassifier.make({
614
- onStart: () => FormatterVsCode.buildStart(pkg.dir),
615
- onOk: ms => FormatterVsCode.buildOk(pkg.dir, ms),
616
- onFail: msg => FormatterVsCode.buildFail(pkg.dir, msg),
617
- })
618
- ProcessManager.spawnWatcher(pkg, ~onStdout=(_dir, line) => feed(line))
619
- }
620
- )
621
- }
622
-
623
- let runWatch = async (opts: options): int => {
624
- let roots = opts.roots
625
- // Parent-owned discovery set for the VS Code engine: walked once here and
626
- // refreshed only on structural rebuilds / new-file adds. A plain edit's re-run
627
- // pass reuses it (no per-pass tree re-walk) and narrows to the edited file's
628
- // owning test package (`affectedScope`), so an unrelated plugin's tests aren't
629
- // re-executed. The per-test NDJSON protocol keeps the client's other results
630
- // untouched across a narrowed pass.
631
- let discoveredPaths: ref<array<string>> = ref([])
632
- // For the VS Code client, watch mode is the whole engine: emit the test tree
633
- // and package set, take over the ReScript builds, then run + re-run on change.
634
- if opts.format == VsCode {
635
- let paths = await Discovery.discover(roots)
636
- discoveredPaths := paths
637
- await emitDiscovery(paths)
638
- startBuildWatchers(paths)
639
- }
640
- // Each re-run pass executes in a short-lived Worker so it gets a fresh ESM
641
- // module registry (recompiled implementation modules are re-imported instead
642
- // of served stale from Node's never-evicting cache) and reclaims the imported
643
- // graph on exit. The worker's stdout is piped to ours, so its NDJSON reaches
644
- // the client unchanged. One worker per pass; the parent keeps ownership of the
645
- // build watchers and chokidar.
646
- let currentWorker: ref<option<Worker.t>> = ref(None)
647
- Cancellation.onCancel(() =>
648
- switch currentWorker.contents {
649
- | Some(w) =>
650
- let _ = Worker.terminate(w)
651
- | None => ()
652
- }
653
- )
654
- // Run one pass in a fresh worker over `paths`. `None` lets the worker discover
655
- // the full tree itself — kept for a human terminal `watch`, whose full-summary
656
- // output must always reflect every file.
657
- let runInWorker = (paths: option<array<string>>): promise<unit> =>
658
- Promise.make((resolve, _reject) => {
659
- let w = Worker.make(Worker.runWorkerUrl, {workerData: {...opts, paths}})
660
- currentWorker := Some(w)
661
- let settled = ref(false)
662
- let settle = () =>
663
- if !settled.contents {
664
- settled := true
665
- currentWorker := None
666
- resolve()
667
- }
668
- w->Worker.on("exit", (_code: int) => settle())
669
- w->Worker.on("error", e => {
670
- Console.error2("gwt run worker error:", e)
671
- settle()
672
- })
673
- })
674
- // Resolve a scope to the concrete path subset for a worker pass. The VS Code
675
- // engine passes the parent-owned subset; a human terminal `watch` passes None
676
- // (worker discovers), preserving its full-summary semantics.
677
- let pathsForScope = (scope: runScope): option<array<string>> =>
678
- opts.format == VsCode ? Some(scopeSubset(scope, discoveredPaths.contents)) : None
679
- // Single-flight run state machine. A re-run requested while a pass is in flight
680
- // coalesces into `pendingScope` (scopes merge; `RunAll` absorbing) and drains
681
- // after the current pass. Holding `runInProgress` across the whole drain loop
682
- // means only one worker ever runs at a time — a third concurrent request can't
683
- // corrupt the run or interleave NDJSON.
684
- let runInProgress = ref(false)
685
- let pendingScope: ref<option<runScope>> = ref(None)
686
- let requestRun = async (scope: runScope) =>
687
- if runInProgress.contents {
688
- pendingScope :=
689
- Some(
690
- switch pendingScope.contents {
691
- | Some(s) => mergeScope(s, scope)
692
- | None => scope
693
- },
694
- )
695
- } else {
696
- runInProgress := true
697
- let _ = await runInWorker(pathsForScope(scope))
698
- let rec drain = async () =>
699
- switch pendingScope.contents {
700
- | Some(next) => {
701
- pendingScope := None
702
- let _ = await runInWorker(pathsForScope(next))
703
- await drain()
704
- }
705
- | None => runInProgress := false
706
- }
707
- await drain()
708
- }
709
- let _ = await requestRun(RunAll)
710
- // Re-walk discovery (VS Code) then re-run everything. Used after a structural
711
- // clean-rebuild, whose relocation may have changed the discovered test set.
712
- let rediscoverAndRun = async () => {
713
- if opts.format == VsCode {
714
- discoveredPaths := (await Discovery.discover(roots))
715
- }
716
- let _ = await requestRun(RunAll)
717
- }
718
- // A *relocation* (a module moved to another directory — a chapter rename /
719
- // ungroup / move-to-chapter in the extension, or a terminal `mv`) leaves
720
- // dependents' emitted `.res.mjs` pointing at the old path: incremental
721
- // compilation never marks them dirty (their source + the moved module's
722
- // interface hash are unchanged), so the live watcher and a restart both skip
723
- // them and the stale import surfaces as a `<file-load-error>`. The only
724
- // reliable repair is a `rescript clean` of the owning package. Detect the
725
- // structural signal — an `unlink` of a source `.res` (a module left a
726
- // directory) — and clean-rebuild that package, gating the re-run on build
727
- // completion. Edits and adds stay on the lean incremental re-run. VS Code
728
- // only: a human terminal `watch` keeps the re-run-only loop (it doesn't own
729
- // the builds, so there's nothing to clean-rebuild).
730
- // Guards against two structural paths from the *same* burst (a multi-file move
731
- // within one package) each launching a concurrent clean-rebuild of that
732
- // package. The first sets the flag; siblings coalesce into its completion
733
- // re-run. Watch now emits one callback per distinct structural path, so this
734
- // is what makes "rebuild every distinct owning package" (not per-file) true.
735
- let rebuilding: Dict.t<bool> = Dict.make()
736
- let structuralRebuild = (path: string): unit =>
737
- switch PackageScan.findOwning(path) {
738
- | None => ignore(requestRun(RunAll))
739
- | Some(pkg) if rebuilding->Dict.get(pkg.dir)->Option.getOr(false) => ()
740
- | Some(pkg) =>
741
- rebuilding->Dict.set(pkg.dir, true)
742
- let t0 = Date.now()
743
- FormatterVsCode.buildStart(pkg.dir)
744
- // Surface a compile error during the rebuild (the clean/build stream is
745
- // classified for the error marker); the re-run is gated on completion
746
- // below, not on the classifier, since a one-shot `rescript build` has no
747
- // "Finished … compilation" terminator.
748
- let sawError = ref(false)
749
- let feed = BuildClassifier.make({
750
- onStart: () => (),
751
- onOk: _ms => (),
752
- onFail: msg => {
753
- sawError := true
754
- FormatterVsCode.buildFail(pkg.dir, msg)
755
- },
756
- })
757
- ProcessManager.cleanRebuild(
758
- pkg,
759
- ~onStdout=(_dir, line) => feed(line),
760
- ~onDone=ok => {
761
- rebuilding->Dict.set(pkg.dir, false)
762
- // Only report success when the build actually succeeded. On failure
763
- // the classifier's `onFail` has usually already emitted the compiler
764
- // error; emit a fallback `buildFail` only if it didn't (e.g. a crash
765
- // with no recognisable marker), so we never claim `buildOk` over a
766
- // broken build and re-run against stale `.res.mjs`.
767
- if ok {
768
- FormatterVsCode.buildOk(pkg.dir, Date.now() -. t0)
769
- } else if !sawError.contents {
770
- FormatterVsCode.buildFail(pkg.dir, "clean rebuild failed")
771
- }
772
- ignore(rediscoverAndRun())
773
- },
774
- )
775
- }
776
- // A test file added mid-session runs (discover re-scans each pass) but never
777
- // appears in the client's tree, which was emitted once at start. On an `Add`
778
- // of a *source* `.res` re-emit the discovery stream (idempotent snapshot) and
779
- // (re)claim build watchers for any new package before re-running.
780
- let refreshDiscovery = async () => {
781
- let paths = await Discovery.discover(roots)
782
- discoveredPaths := paths
783
- await emitDiscovery(paths)
784
- startBuildWatchers(paths)
785
- let _ = await requestRun(RunAll)
786
- }
787
- // Narrow a plain edit's re-run to the edited file's owning test package (VS
788
- // Code only). `RunAll` when the file has no owning package, or the owner isn't
789
- // one of the discovered test packages (e.g. a framework edit that ripples into
790
- // several plugins — full re-run is the safe choice there). A human terminal
791
- // `watch` always re-runs everything (full-summary output).
792
- let affectedScope = (path: string): runScope =>
793
- if opts.format == VsCode {
794
- let abs = resolvePath(path)
795
- switch PackageScan.findOwning(PackageScan.dirname(abs)) {
796
- | Some(pkg) if discoveredPaths.contents->Array.some(p => pathUnderDir(p, pkg.dir)) =>
797
- RunPackages([pkg.dir])
798
- | _ => RunAll
799
- }
800
- } else {
801
- RunAll
802
- }
803
- let _watcher = Watch.start(roots, (event, path) =>
804
- if opts.format == VsCode && Watch.isStructuralSource(event, path) {
805
- structuralRebuild(path)
806
- } else if opts.format == VsCode && event == Watch.Add && path->String.endsWith(".res") {
807
- ignore(refreshDiscovery())
808
- } else {
809
- ignore(requestRun(affectedScope(path)))
810
- }
811
- )
812
- // Keep the process alive until cancelled. Awaiting here (rather than letting
813
- // runWatch return) is essential: the bin wrapper calls `process.exit` on the
814
- // resolved code, so a returning runWatch would tear down chokidar and the
815
- // spawned build watchers immediately. Cancellation handlers run in
816
- // registration order — ProcessManager.killAll first, then this resolve — so
817
- // the watchers are killed before the process exits.
818
- await Promise.make((resolve, _reject) => Cancellation.onCancel(() => resolve()))
819
- 0
820
- }
821
-
822
- // Launches an app's reventless-local platform as a managed child with the domain
823
- // event tap on, streaming lifecycle + events. For the VS Code client the callbacks
824
- // emit NDJSON; a human `platform` prints readable lines and passes the child's own
825
- // logs through.
826
- let runPlatform = async (opts: options): int => {
827
- let callbacks: PlatformRunner.callbacks = if opts.format == VsCode {
828
- {
829
- onStart: (~package, ~dir, ~domainPort, ~platformPort) =>
830
- FormatterVsCode.platformStart(~package, ~dir, ~domainPort, ~platformPort),
831
- onReady: (~domainEndpoint) => FormatterVsCode.platformReady(~domainEndpoint),
832
- onDomainEvent: json => FormatterVsCode.domainEvent(json),
833
- onLog: line => FormatterVsCode.platformLog(~line),
834
- onStop: code => FormatterVsCode.platformStop(~code),
835
- }
836
- } else {
837
- {
838
- onStart: (~package, ~dir as _, ~domainPort, ~platformPort as _) =>
839
- Console.log(`▶ platform ${package} starting (domain http://localhost:${Int.toString(domainPort)})`),
840
- onReady: (~domainEndpoint) => Console.log(`✓ platform ready — ${domainEndpoint}`),
841
- onDomainEvent: json => Console.log("· event " ++ JSON.stringify(json)),
842
- onLog: line => Console.log(line),
843
- onStop: code =>
844
- Console.log(`■ platform stopped (code ${code->Option.mapOr("?", c => Int.toString(c))})`),
845
- }
846
- }
847
- await PlatformRunner.run(
848
- ~roots=opts.roots,
849
- ~backend=opts.backend,
850
- ~fixedPorts=opts.uiPorts,
851
- ~callbacks,
852
- )
853
- }
854
-
855
- // `platform --list`: enumerate the launchable platform packages under the roots
856
- // and print one NDJSON `{name, dir}` object per line. The VS Code extension reads
857
- // these to populate its active-app picker (app root = the package's parent dir).
858
- // Plain stdout, no protocol handshake — it's a one-shot query, not a stream.
859
- let listPlatforms = async (opts: options): int => {
860
- let pkgs = await PlatformScan.scan(opts.roots)
861
- pkgs->Array.forEach(pkg => {
862
- let obj = Dict.fromArray([("name", JSON.String(pkg.name)), ("dir", JSON.String(pkg.dir))])
863
- Console.log(JSON.stringify(JSON.Object(obj)))
864
- })
865
- 0
866
- }
867
-
868
- let main = async (): int => {
869
- Cancellation.install()
870
- let args = argv
871
- switch parseArgv(args) {
872
- | Error(msg) => {
873
- Console.error(msg)
874
- args->Array.some(a => a == "--help" || a == "-h") ? 0 : 2
875
- }
876
- | Ok(opts) =>
877
- // Protocol handshake first, so a version-skewed extension can detect the
878
- // CLI before interpreting the stream.
879
- if opts.format == VsCode {
880
- FormatterVsCode.hello()
881
- }
882
- switch opts.subcommand {
883
- | Run => await runOnce(opts)
884
- | Discover => await runDiscover(opts)
885
- | Watch => await runWatch(opts)
886
- | Platform => opts.listPlatforms ? await listPlatforms(opts) : await runPlatform(opts)
887
- }
888
- }
889
- }