@reventlessdev/reventless-gwt 1.0.0-alpha.75 → 1.0.0-alpha.77
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.
- package/CHANGELOG.md +16 -0
- package/package.json +8 -8
- package/src/Behavior_GWT.res +125 -159
- package/src/Behavior_GWT.res.mjs +156 -59
- package/src/Bind.res.mjs +2 -2
- package/src/BuildClassifier.res +15 -5
- package/src/BuildClassifier.res.mjs +3 -3
- package/src/Cancellation.res +6 -0
- package/src/Cancellation.res.mjs +3 -2
- package/src/ChildProcess.res +19 -1
- package/src/ChildProcess.res.mjs +16 -1
- package/src/Cli.res +260 -26
- package/src/Cli.res.mjs +227 -29
- package/src/Collector.res +13 -2
- package/src/Collector.res.mjs +8 -5
- package/src/ComponentMeta.res +16 -43
- package/src/ComponentMeta.res.mjs +12 -41
- package/src/Discovery.res +19 -19
- package/src/Discovery.res.mjs +5 -9
- package/src/DomainGraph.res +0 -0
- package/src/DomainGraph.res.mjs +0 -0
- package/src/Filter.res +11 -2
- package/src/Filter.res.mjs +10 -3
- package/src/FormatterHuman.res +14 -43
- package/src/FormatterHuman.res.mjs +21 -28
- package/src/FormatterJson.res +12 -5
- package/src/FormatterJson.res.mjs +8 -6
- package/src/FormatterTap.res +13 -52
- package/src/FormatterTap.res.mjs +25 -43
- package/src/FormatterVsCode.res +4 -19
- package/src/FormatterVsCode.res.mjs +4 -23
- package/src/JestBind.res +1 -1
- package/src/JestBind.res.mjs +2 -2
- package/src/LocalHost.res +11 -27
- package/src/LocalHost.res.mjs +6 -34
- package/src/MismatchRender.res +72 -0
- package/src/MismatchRender.res.mjs +127 -0
- package/src/Outcome.res +0 -64
- package/src/Outcome.res.mjs +0 -47
- package/src/PlatformRunner.res +27 -6
- package/src/PlatformRunner.res.mjs +27 -6
- package/src/PlatformScan.res +14 -10
- package/src/PlatformScan.res.mjs +14 -13
- package/src/ProcessManager.res +51 -7
- package/src/ProcessManager.res.mjs +43 -4
- package/src/RunWorker.res +24 -0
- package/src/RunWorker.res.mjs +16 -0
- package/src/Watch.res +46 -30
- package/src/Watch.res.mjs +34 -32
- package/src/WatcherProbe.res +11 -3
- package/src/WatcherProbe.res.mjs +15 -2
- package/src/Worker.res +24 -0
- package/src/Worker.res.mjs +9 -0
- package/tests/BuildClassifierTest.res +39 -0
- package/tests/BuildClassifierTest.res.mjs +44 -0
- package/tests/CliRunEntryTest.res +57 -0
- package/tests/CliRunEntryTest.res.mjs +65 -0
- package/tests/CliWatchScopeTest.res +62 -0
- package/tests/CliWatchScopeTest.res.mjs +95 -0
- package/tests/ComponentMetaTest.res +15 -0
- package/tests/ComponentMetaTest.res.mjs +14 -0
- package/tests/DomainGraphTest.res +2 -2
- package/tests/FlowAggregateGwtTest.res +2 -0
- package/tests/FlowAggregateGwtTest.res.mjs +4 -0
- package/tests/FormatterGoldenTest.res +125 -0
- package/tests/FormatterGoldenTest.res.mjs +162 -0
- package/tests/MappingGwtTest.res +2 -0
- package/tests/MappingGwtTest.res.mjs +4 -0
- package/tests/WatchDebounceTest.res +35 -7
- package/tests/WatchDebounceTest.res.mjs +37 -6
package/src/Bind.res.mjs
CHANGED
|
@@ -6,12 +6,12 @@ let describe = Collector$ReventlessGwt.pushDescribe;
|
|
|
6
6
|
|
|
7
7
|
function test(slice, name, body) {
|
|
8
8
|
let location = Collector$ReventlessGwt.captureLocation(1);
|
|
9
|
-
Collector$ReventlessGwt.push(slice, location, name, () => Promise.resolve(body()));
|
|
9
|
+
Collector$ReventlessGwt.push(slice, location, undefined, name, () => Promise.resolve(body()));
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function testPromise(slice, name, body) {
|
|
13
13
|
let location = Collector$ReventlessGwt.captureLocation(1);
|
|
14
|
-
Collector$ReventlessGwt.push(slice, location, name, body);
|
|
14
|
+
Collector$ReventlessGwt.push(slice, location, undefined, name, body);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export {
|
package/src/BuildClassifier.res
CHANGED
|
@@ -74,11 +74,21 @@ let make = (~watchdogMs: int=120_000, cb: callbacks): (string => unit) => {
|
|
|
74
74
|
buffer := []
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
77
|
+
// Cap the failure message so a pathological build can't emit an unbounded
|
|
78
|
+
// line. 20 was too tight — a multi-error build has several "We've found a bug"
|
|
79
|
+
// blocks and the tail (later errors) was silently dropped; keep enough to
|
|
80
|
+
// carry a handful of blocks.
|
|
81
|
+
let maxMessageLines = 200
|
|
82
|
+
|
|
83
|
+
// After output settles, a build that saw an error (and thus no "Finished"
|
|
84
|
+
// terminator) is reported failed, capturing a concise message. The condition
|
|
85
|
+
// is `hasError` alone — not `compiling && hasError` — so an error emitted
|
|
86
|
+
// *before* the first "Parsed …" line (a crashed toolchain, a pnpm/resolution
|
|
87
|
+
// failure) still settles into an `onFail` instead of leaving the consumer
|
|
88
|
+
// pinned at its previous state forever.
|
|
79
89
|
let settle = () =>
|
|
80
|
-
if
|
|
81
|
-
let msg = buffer.contents->Array.slice(~start=0, ~end=
|
|
90
|
+
if hasError.contents {
|
|
91
|
+
let msg = buffer.contents->Array.slice(~start=0, ~end=maxMessageLines)->Array.join("\n")
|
|
82
92
|
clearWatchdog()
|
|
83
93
|
cb.onFail(msg)
|
|
84
94
|
reset()
|
|
@@ -126,7 +136,7 @@ let make = (~watchdogMs: int=120_000, cb: callbacks): (string => unit) => {
|
|
|
126
136
|
}
|
|
127
137
|
} else {
|
|
128
138
|
if String.length(l) > 0 {
|
|
129
|
-
buffer
|
|
139
|
+
buffer.contents->Array.push(l)
|
|
130
140
|
}
|
|
131
141
|
if isErrorMarker(l) {
|
|
132
142
|
hasError := true
|
|
@@ -71,10 +71,10 @@ function make(watchdogMsOpt, cb) {
|
|
|
71
71
|
buffer.contents = [];
|
|
72
72
|
};
|
|
73
73
|
let settle = () => {
|
|
74
|
-
if (!
|
|
74
|
+
if (!hasError.contents) {
|
|
75
75
|
return;
|
|
76
76
|
}
|
|
77
|
-
let msg = buffer.contents.slice(0,
|
|
77
|
+
let msg = buffer.contents.slice(0, 200).join("\n");
|
|
78
78
|
clearWatchdog();
|
|
79
79
|
cb.onFail(msg);
|
|
80
80
|
reset();
|
|
@@ -116,7 +116,7 @@ function make(watchdogMsOpt, cb) {
|
|
|
116
116
|
}
|
|
117
117
|
} else {
|
|
118
118
|
if (l.length > 0) {
|
|
119
|
-
buffer.contents
|
|
119
|
+
buffer.contents.push(l);
|
|
120
120
|
}
|
|
121
121
|
if (isErrorMarker(l)) {
|
|
122
122
|
hasError.contents = true;
|
package/src/Cancellation.res
CHANGED
|
@@ -24,6 +24,12 @@ let install = () => {
|
|
|
24
24
|
| _ => ()
|
|
25
25
|
}
|
|
26
26
|
)
|
|
27
|
+
} else {
|
|
28
|
+
// Second signal: the first requested a graceful drain, but a hung await
|
|
29
|
+
// can keep the process alive indefinitely. Honour the operator's repeat
|
|
30
|
+
// SIGINT/SIGTERM as a hard exit (130 = terminated by SIGINT) so only the
|
|
31
|
+
// one signal — not SIGKILL — is needed to break out.
|
|
32
|
+
process->exit(130)
|
|
27
33
|
}
|
|
28
34
|
}
|
|
29
35
|
process->onSignal("SIGINT", handler)
|
package/src/Cancellation.res.mjs
CHANGED
|
@@ -19,7 +19,9 @@ function onCancel(handler) {
|
|
|
19
19
|
|
|
20
20
|
function install() {
|
|
21
21
|
let handler = () => {
|
|
22
|
-
if (
|
|
22
|
+
if (cancelled.contents) {
|
|
23
|
+
process.exit(130);
|
|
24
|
+
} else {
|
|
23
25
|
cancelled.contents = true;
|
|
24
26
|
handlers.contents.forEach(h => {
|
|
25
27
|
try {
|
|
@@ -28,7 +30,6 @@ function install() {
|
|
|
28
30
|
return;
|
|
29
31
|
}
|
|
30
32
|
});
|
|
31
|
-
return;
|
|
32
33
|
}
|
|
33
34
|
};
|
|
34
35
|
process.on("SIGINT", handler);
|
package/src/ChildProcess.res
CHANGED
|
@@ -35,7 +35,9 @@ let mergeEnv = (overrides: option<Dict.t<string>>): Dict.t<string> => {
|
|
|
35
35
|
@get external _stderr: t => stream = "stderr"
|
|
36
36
|
@send external _streamSetEncoding: (stream, string) => unit = "setEncoding"
|
|
37
37
|
@send external _streamOn: (stream, string, string => unit) => unit = "on"
|
|
38
|
+
@send external _streamOnEnd: (stream, string, unit => unit) => unit = "on"
|
|
38
39
|
@send external _procOnClose: (t, string, Nullable.t<int> => unit) => unit = "on"
|
|
40
|
+
@send external _procOnError: (t, string, JsExn.t => unit) => unit = "on"
|
|
39
41
|
@send external _kill: (t, string) => bool = "kill"
|
|
40
42
|
@val external _processKill: (int, string) => unit = "process.kill"
|
|
41
43
|
|
|
@@ -45,7 +47,10 @@ let spawn = (cmd: string, args: array<string>, ~cwd: string, ~env: option<Dict.t
|
|
|
45
47
|
let pid = (proc: t): option<int> => proc->_pid->Nullable.toOption
|
|
46
48
|
|
|
47
49
|
// Subscribe to a stream line-by-line (stdout or stderr), buffering partial
|
|
48
|
-
// chunks. Used to classify ReScript compiler markers in Phase 5.
|
|
50
|
+
// chunks. Used to classify ReScript compiler markers in Phase 5. On stream
|
|
51
|
+
// `end` the final unterminated line is flushed — crash/panic messages
|
|
52
|
+
// typically lack a trailing newline, so without this the last (often most
|
|
53
|
+
// diagnostic) line was silently dropped.
|
|
49
54
|
let onLines = (s: stream, cb: string => unit): unit => {
|
|
50
55
|
_streamSetEncoding(s, "utf8")
|
|
51
56
|
let buf = ref("")
|
|
@@ -63,6 +68,13 @@ let onLines = (s: stream, cb: string => unit): unit => {
|
|
|
63
68
|
}
|
|
64
69
|
drain()
|
|
65
70
|
})
|
|
71
|
+
_streamOnEnd(s, "end", () =>
|
|
72
|
+
if buf.contents != "" {
|
|
73
|
+
let last = buf.contents
|
|
74
|
+
buf := ""
|
|
75
|
+
cb(last)
|
|
76
|
+
}
|
|
77
|
+
)
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
let onStdoutLine = (proc: t, cb: string => unit): unit => onLines(proc->_stdout, cb)
|
|
@@ -70,6 +82,12 @@ let onStderrLine = (proc: t, cb: string => unit): unit => onLines(proc->_stderr,
|
|
|
70
82
|
let onClose = (proc: t, cb: option<int> => unit): unit =>
|
|
71
83
|
_procOnClose(proc, "close", code => cb(code->Nullable.toOption))
|
|
72
84
|
|
|
85
|
+
// A spawn failure (ENOENT for a missing binary, EACCES, …) emits `"error"` on
|
|
86
|
+
// the child, not `"close"`. Without a listener Node re-throws it as an uncaught
|
|
87
|
+
// exception and crashes the whole CLI. Route the message to the caller instead.
|
|
88
|
+
let onError = (proc: t, cb: string => unit): unit =>
|
|
89
|
+
_procOnError(proc, "error", e => cb(e->JsExn.message->Option.getOr("spawn failed")))
|
|
90
|
+
|
|
73
91
|
// Kill the child's whole process group (negative pid) so the rescript watcher
|
|
74
92
|
// pnpm spawned dies too. Falls back to a direct kill if the pid is gone.
|
|
75
93
|
let killTree = (proc: t, signal: string): unit =>
|
package/src/ChildProcess.res.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
3
5
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
4
6
|
import * as Nodechild_process from "node:child_process";
|
|
5
7
|
|
|
@@ -49,6 +51,14 @@ function onLines(s, cb) {
|
|
|
49
51
|
};
|
|
50
52
|
drain();
|
|
51
53
|
});
|
|
54
|
+
s.on("end", () => {
|
|
55
|
+
if (buf.contents === "") {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let last = buf.contents;
|
|
59
|
+
buf.contents = "";
|
|
60
|
+
cb(last);
|
|
61
|
+
});
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
function onStdoutLine(proc, cb) {
|
|
@@ -63,6 +73,10 @@ function onClose(proc, cb) {
|
|
|
63
73
|
proc.on("close", code => cb((code == null) ? undefined : Primitive_option.some(code)));
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
function onError(proc, cb) {
|
|
77
|
+
proc.on("error", e => cb(Stdlib_Option.getOr(Stdlib_JsExn.message(e), "spawn failed")));
|
|
78
|
+
}
|
|
79
|
+
|
|
66
80
|
function killTree(proc, signal) {
|
|
67
81
|
let p = Primitive_option.fromNullable(proc.pid);
|
|
68
82
|
if (p !== undefined) {
|
|
@@ -87,6 +101,7 @@ export {
|
|
|
87
101
|
onStdoutLine,
|
|
88
102
|
onStderrLine,
|
|
89
103
|
onClose,
|
|
104
|
+
onError,
|
|
90
105
|
killTree,
|
|
91
106
|
}
|
|
92
|
-
/*
|
|
107
|
+
/* Stdlib_JsExn Not a pure module */
|
package/src/Cli.res
CHANGED
|
@@ -31,12 +31,35 @@ type options = {
|
|
|
31
31
|
// the VS Code extension to populate its active-app picker.
|
|
32
32
|
listPlatforms: bool,
|
|
33
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>>,
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
@val external argv: array<string> = "process.argv"
|
|
37
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
|
+
|
|
38
49
|
@val external now: unit => float = "performance.now"
|
|
39
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
|
+
|
|
40
63
|
let dateNowIso: unit => string = %raw(`() => new Date().toISOString()`)
|
|
41
64
|
|
|
42
65
|
let parseFormat = (s: string) =>
|
|
@@ -195,6 +218,7 @@ let parseArgv = (argv: array<string>): result<options, string> => {
|
|
|
195
218
|
uiPorts: uiPorts.contents,
|
|
196
219
|
listPlatforms: listPlatforms.contents,
|
|
197
220
|
toolVersion,
|
|
221
|
+
paths: None,
|
|
198
222
|
})
|
|
199
223
|
}
|
|
200
224
|
}
|
|
@@ -222,6 +246,42 @@ let exnMessage: exn => string = %raw(`function(e) {
|
|
|
222
246
|
return "unknown error"
|
|
223
247
|
}`)
|
|
224
248
|
|
|
249
|
+
// Default per-test deadline (ms) when a test body doesn't set `~timeout`.
|
|
250
|
+
// Mirrors Jest's default so behaviour is consistent across both runners.
|
|
251
|
+
let defaultTimeoutMs = 5000
|
|
252
|
+
|
|
253
|
+
// Resolve to a timeout `Outcome` after `ms`. Raced against the test body so a
|
|
254
|
+
// hung await reports a failure instead of wedging the entire run.
|
|
255
|
+
type timeoutHandle
|
|
256
|
+
@val external setTimeout: (unit => unit, int) => timeoutHandle = "setTimeout"
|
|
257
|
+
@val external clearTimeout: timeoutHandle => unit = "clearTimeout"
|
|
258
|
+
let raceWithTimeout = (body: unit => promise<Outcome.outcome>, ms: int): promise<
|
|
259
|
+
Outcome.outcome,
|
|
260
|
+
> => {
|
|
261
|
+
let handleRef: ref<option<timeoutHandle>> = ref(None)
|
|
262
|
+
let timeout = Promise.make((resolve, _reject) => {
|
|
263
|
+
handleRef :=
|
|
264
|
+
Some(
|
|
265
|
+
setTimeout(
|
|
266
|
+
() =>
|
|
267
|
+
resolve(
|
|
268
|
+
Outcome.fail(
|
|
269
|
+
Throw({error: `timed out after ${Int.toString(ms)}ms`, stack: ""}),
|
|
270
|
+
),
|
|
271
|
+
),
|
|
272
|
+
ms,
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
Promise.race([body(), timeout])->Promise.then(o => {
|
|
277
|
+
switch handleRef.contents {
|
|
278
|
+
| Some(h) => clearTimeout(h)
|
|
279
|
+
| None => ()
|
|
280
|
+
}
|
|
281
|
+
Promise.resolve(o)
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
225
285
|
// Execute a single entry, catching exceptions and thrown values so a
|
|
226
286
|
// misbehaved slice doesn't abort the rest of the suite.
|
|
227
287
|
let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
|
|
@@ -232,7 +292,8 @@ let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
|
|
|
232
292
|
| Collector.Skipped => status := Skip
|
|
233
293
|
| _ =>
|
|
234
294
|
try {
|
|
235
|
-
let
|
|
295
|
+
let deadline = entry.timeout->Option.getOr(defaultTimeoutMs)
|
|
296
|
+
let outcome = await raceWithTimeout(entry.body, deadline)
|
|
236
297
|
switch outcome {
|
|
237
298
|
| Ok() => status := Pass
|
|
238
299
|
| Error(m) => {
|
|
@@ -373,17 +434,63 @@ let emitResult = (opts: options, r: RunnerTypes.runResult) =>
|
|
|
373
434
|
if opts.stream {
|
|
374
435
|
FormatterJson.streamRunEnd(r)
|
|
375
436
|
} else {
|
|
376
|
-
FormatterJson.emit(r, ~toolVersion=opts.toolVersion)
|
|
437
|
+
FormatterJson.emit(r, ~toolVersion=opts.toolVersion, ~schemaVersion=?opts.schemaVersion)
|
|
377
438
|
}
|
|
378
439
|
| Tap => FormatterTap.emit(r)
|
|
379
440
|
| Junit => FormatterJunit.emit(r)
|
|
380
441
|
| VsCode => FormatterVsCode.runEnd(r.summary)
|
|
381
442
|
}
|
|
382
443
|
|
|
444
|
+
// Watch re-run scope. A plain edit under a test-owning package narrows the pass
|
|
445
|
+
// to that package's test files (`RunPackages`); structural rebuilds, new-file
|
|
446
|
+
// adds, and any non-VsCode watch use `RunAll`. Scopes accumulate while a pass is
|
|
447
|
+
// in flight — `RunAll` is absorbing (an all-run unioned with anything is an
|
|
448
|
+
// all-run), and `RunPackages` unions its dirs.
|
|
449
|
+
type runScope = RunAll | RunPackages(array<string>)
|
|
450
|
+
|
|
451
|
+
let mergeScope = (a: runScope, b: runScope): runScope =>
|
|
452
|
+
switch (a, b) {
|
|
453
|
+
| (RunAll, _) | (_, RunAll) => RunAll
|
|
454
|
+
| (RunPackages(x), RunPackages(y)) =>
|
|
455
|
+
let seen = Dict.make()
|
|
456
|
+
let union = []
|
|
457
|
+
Array.concat(x, y)->Array.forEach(d =>
|
|
458
|
+
switch seen->Dict.get(d) {
|
|
459
|
+
| Some(_) => ()
|
|
460
|
+
| None => {
|
|
461
|
+
seen->Dict.set(d, true)
|
|
462
|
+
union->Array.push(d)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
)
|
|
466
|
+
RunPackages(union)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// True when `path` sits inside directory `dir` (dir is an ancestor). Lets a
|
|
470
|
+
// package dir be mapped to its test files by prefix, with no per-path fs walk.
|
|
471
|
+
let pathUnderDir = (path: string, dir: string): bool =>
|
|
472
|
+
String.startsWith(path, String.endsWith(dir, "/") ? dir : dir ++ "/")
|
|
473
|
+
|
|
474
|
+
// The subset of `allPaths` a scope re-runs.
|
|
475
|
+
let scopeSubset = (scope: runScope, allPaths: array<string>): array<string> =>
|
|
476
|
+
switch scope {
|
|
477
|
+
| RunAll => allPaths
|
|
478
|
+
| RunPackages(dirs) => allPaths->Array.filter(p => dirs->Array.some(d => pathUnderDir(p, d)))
|
|
479
|
+
}
|
|
480
|
+
|
|
383
481
|
let runOnce = async (opts: options): int => {
|
|
384
|
-
|
|
482
|
+
// A watch worker pass supplies the exact path set (parent-owned discovery,
|
|
483
|
+
// narrowed to the affected package); the one-shot commands discover the tree.
|
|
484
|
+
let paths = switch opts.paths {
|
|
485
|
+
| Some(p) => p
|
|
486
|
+
| None => await Discovery.discover(opts.roots)
|
|
487
|
+
}
|
|
385
488
|
if opts.format == Json && opts.stream {
|
|
386
|
-
FormatterJson.streamRunStart(
|
|
489
|
+
FormatterJson.streamRunStart(
|
|
490
|
+
~toolVersion=opts.toolVersion,
|
|
491
|
+
~startedAt=dateNowIso(),
|
|
492
|
+
~schemaVersion=?opts.schemaVersion,
|
|
493
|
+
)
|
|
387
494
|
}
|
|
388
495
|
let onFileFinished: option<RunnerTypes.fileResult => unit> = switch (
|
|
389
496
|
opts.format,
|
|
@@ -445,15 +552,22 @@ let emitDomainAnalysis = async (pkgs: array<PackageScan.pkg>): unit =>
|
|
|
445
552
|
| Some(platformModulePath) =>
|
|
446
553
|
try {
|
|
447
554
|
let loaded = await LocalHost.loadGraph(~platformModulePath, ~plugins)
|
|
448
|
-
FormatterVsCode.deadCode(DomainDeadCode.analyze(~structures=loaded.structures))
|
|
449
|
-
FormatterVsCode.graph(DomainGraph.build(~structures=loaded.structures))
|
|
450
555
|
// Field schemas for command/event/read-side state (Phase 6.3): the same
|
|
451
556
|
// per-plugin entry the Platform_ComponentDefinitions GraphQL query returns.
|
|
452
|
-
|
|
557
|
+
let definitions =
|
|
453
558
|
loaded.structures->Array.map(((pluginId, s)) =>
|
|
454
559
|
ReventlessCore.Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId, s)
|
|
455
|
-
)
|
|
456
|
-
|
|
560
|
+
)
|
|
561
|
+
// deadCode + graph + definitions are all pure functions of `structures`,
|
|
562
|
+
// so the definitions JSON is a faithful fingerprint of all three. Skip
|
|
563
|
+
// the whole emit when it hasn't moved since the last discovery.
|
|
564
|
+
let fingerprint = sha256(JSON.stringify(JSON.Array(definitions)))
|
|
565
|
+
if lastDomainHash.contents != Some(fingerprint) {
|
|
566
|
+
lastDomainHash := Some(fingerprint)
|
|
567
|
+
FormatterVsCode.deadCode(DomainDeadCode.analyze(~structures=loaded.structures))
|
|
568
|
+
FormatterVsCode.graph(DomainGraph.build(~structures=loaded.structures))
|
|
569
|
+
FormatterVsCode.definitions(definitions)
|
|
570
|
+
}
|
|
457
571
|
} catch {
|
|
458
572
|
| _ => ()
|
|
459
573
|
}
|
|
@@ -524,30 +638,99 @@ let startBuildWatchers = (paths: array<string>): unit => {
|
|
|
524
638
|
|
|
525
639
|
let runWatch = async (opts: options): int => {
|
|
526
640
|
let roots = opts.roots
|
|
641
|
+
// Parent-owned discovery set for the VS Code engine: walked once here and
|
|
642
|
+
// refreshed only on structural rebuilds / new-file adds. A plain edit's re-run
|
|
643
|
+
// pass reuses it (no per-pass tree re-walk) and narrows to the edited file's
|
|
644
|
+
// owning test package (`affectedScope`), so an unrelated plugin's tests aren't
|
|
645
|
+
// re-executed. The per-test NDJSON protocol keeps the client's other results
|
|
646
|
+
// untouched across a narrowed pass.
|
|
647
|
+
let discoveredPaths: ref<array<string>> = ref([])
|
|
527
648
|
// For the VS Code client, watch mode is the whole engine: emit the test tree
|
|
528
649
|
// and package set, take over the ReScript builds, then run + re-run on change.
|
|
529
650
|
if opts.format == VsCode {
|
|
530
651
|
let paths = await Discovery.discover(roots)
|
|
652
|
+
discoveredPaths := paths
|
|
531
653
|
await emitDiscovery(paths)
|
|
532
654
|
startBuildWatchers(paths)
|
|
533
655
|
}
|
|
656
|
+
// Each re-run pass executes in a short-lived Worker so it gets a fresh ESM
|
|
657
|
+
// module registry (recompiled implementation modules are re-imported instead
|
|
658
|
+
// of served stale from Node's never-evicting cache) and reclaims the imported
|
|
659
|
+
// graph on exit. The worker's stdout is piped to ours, so its NDJSON reaches
|
|
660
|
+
// the client unchanged. One worker per pass; the parent keeps ownership of the
|
|
661
|
+
// build watchers and chokidar.
|
|
662
|
+
let currentWorker: ref<option<Worker.t>> = ref(None)
|
|
663
|
+
Cancellation.onCancel(() =>
|
|
664
|
+
switch currentWorker.contents {
|
|
665
|
+
| Some(w) =>
|
|
666
|
+
let _ = Worker.terminate(w)
|
|
667
|
+
| None => ()
|
|
668
|
+
}
|
|
669
|
+
)
|
|
670
|
+
// Run one pass in a fresh worker over `paths`. `None` lets the worker discover
|
|
671
|
+
// the full tree itself — kept for a human terminal `watch`, whose full-summary
|
|
672
|
+
// output must always reflect every file.
|
|
673
|
+
let runInWorker = (paths: option<array<string>>): promise<unit> =>
|
|
674
|
+
Promise.make((resolve, _reject) => {
|
|
675
|
+
let w = Worker.make(Worker.runWorkerUrl, {workerData: {...opts, paths}})
|
|
676
|
+
currentWorker := Some(w)
|
|
677
|
+
let settled = ref(false)
|
|
678
|
+
let settle = () =>
|
|
679
|
+
if !settled.contents {
|
|
680
|
+
settled := true
|
|
681
|
+
currentWorker := None
|
|
682
|
+
resolve()
|
|
683
|
+
}
|
|
684
|
+
w->Worker.on("exit", (_code: int) => settle())
|
|
685
|
+
w->Worker.on("error", e => {
|
|
686
|
+
Console.error2("gwt run worker error:", e)
|
|
687
|
+
settle()
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
// Resolve a scope to the concrete path subset for a worker pass. The VS Code
|
|
691
|
+
// engine passes the parent-owned subset; a human terminal `watch` passes None
|
|
692
|
+
// (worker discovers), preserving its full-summary semantics.
|
|
693
|
+
let pathsForScope = (scope: runScope): option<array<string>> =>
|
|
694
|
+
opts.format == VsCode ? Some(scopeSubset(scope, discoveredPaths.contents)) : None
|
|
695
|
+
// Single-flight run state machine. A re-run requested while a pass is in flight
|
|
696
|
+
// coalesces into `pendingScope` (scopes merge; `RunAll` absorbing) and drains
|
|
697
|
+
// after the current pass. Holding `runInProgress` across the whole drain loop
|
|
698
|
+
// means only one worker ever runs at a time — a third concurrent request can't
|
|
699
|
+
// corrupt the run or interleave NDJSON.
|
|
534
700
|
let runInProgress = ref(false)
|
|
535
|
-
let
|
|
536
|
-
let
|
|
701
|
+
let pendingScope: ref<option<runScope>> = ref(None)
|
|
702
|
+
let requestRun = async (scope: runScope) =>
|
|
537
703
|
if runInProgress.contents {
|
|
538
|
-
|
|
704
|
+
pendingScope :=
|
|
705
|
+
Some(
|
|
706
|
+
switch pendingScope.contents {
|
|
707
|
+
| Some(s) => mergeScope(s, scope)
|
|
708
|
+
| None => scope
|
|
709
|
+
},
|
|
710
|
+
)
|
|
539
711
|
} else {
|
|
540
712
|
runInProgress := true
|
|
541
|
-
let _ = await
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
713
|
+
let _ = await runInWorker(pathsForScope(scope))
|
|
714
|
+
let rec drain = async () =>
|
|
715
|
+
switch pendingScope.contents {
|
|
716
|
+
| Some(next) => {
|
|
717
|
+
pendingScope := None
|
|
718
|
+
let _ = await runInWorker(pathsForScope(next))
|
|
719
|
+
await drain()
|
|
720
|
+
}
|
|
721
|
+
| None => runInProgress := false
|
|
722
|
+
}
|
|
723
|
+
await drain()
|
|
548
724
|
}
|
|
725
|
+
let _ = await requestRun(RunAll)
|
|
726
|
+
// Re-walk discovery (VS Code) then re-run everything. Used after a structural
|
|
727
|
+
// clean-rebuild, whose relocation may have changed the discovered test set.
|
|
728
|
+
let rediscoverAndRun = async () => {
|
|
729
|
+
if opts.format == VsCode {
|
|
730
|
+
discoveredPaths := (await Discovery.discover(roots))
|
|
731
|
+
}
|
|
732
|
+
let _ = await requestRun(RunAll)
|
|
549
733
|
}
|
|
550
|
-
let _ = await run()
|
|
551
734
|
// A *relocation* (a module moved to another directory — a chapter rename /
|
|
552
735
|
// ungroup / move-to-chapter in the extension, or a terminal `mv`) leaves
|
|
553
736
|
// dependents' emitted `.res.mjs` pointing at the old path: incremental
|
|
@@ -560,35 +743,86 @@ let runWatch = async (opts: options): int => {
|
|
|
560
743
|
// completion. Edits and adds stay on the lean incremental re-run. VS Code
|
|
561
744
|
// only: a human terminal `watch` keeps the re-run-only loop (it doesn't own
|
|
562
745
|
// the builds, so there's nothing to clean-rebuild).
|
|
746
|
+
// Guards against two structural paths from the *same* burst (a multi-file move
|
|
747
|
+
// within one package) each launching a concurrent clean-rebuild of that
|
|
748
|
+
// package. The first sets the flag; siblings coalesce into its completion
|
|
749
|
+
// re-run. Watch now emits one callback per distinct structural path, so this
|
|
750
|
+
// is what makes "rebuild every distinct owning package" (not per-file) true.
|
|
751
|
+
let rebuilding: Dict.t<bool> = Dict.make()
|
|
563
752
|
let structuralRebuild = (path: string): unit =>
|
|
564
753
|
switch PackageScan.findOwning(path) {
|
|
565
|
-
| None => ignore(
|
|
754
|
+
| None => ignore(requestRun(RunAll))
|
|
755
|
+
| Some(pkg) if rebuilding->Dict.get(pkg.dir)->Option.getOr(false) => ()
|
|
566
756
|
| Some(pkg) =>
|
|
757
|
+
rebuilding->Dict.set(pkg.dir, true)
|
|
567
758
|
let t0 = Date.now()
|
|
568
759
|
FormatterVsCode.buildStart(pkg.dir)
|
|
569
760
|
// Surface a compile error during the rebuild (the clean/build stream is
|
|
570
761
|
// classified for the error marker); the re-run is gated on completion
|
|
571
762
|
// below, not on the classifier, since a one-shot `rescript build` has no
|
|
572
763
|
// "Finished … compilation" terminator.
|
|
764
|
+
let sawError = ref(false)
|
|
573
765
|
let feed = BuildClassifier.make({
|
|
574
766
|
onStart: () => (),
|
|
575
767
|
onOk: _ms => (),
|
|
576
|
-
onFail: msg =>
|
|
768
|
+
onFail: msg => {
|
|
769
|
+
sawError := true
|
|
770
|
+
FormatterVsCode.buildFail(pkg.dir, msg)
|
|
771
|
+
},
|
|
577
772
|
})
|
|
578
773
|
ProcessManager.cleanRebuild(
|
|
579
774
|
pkg,
|
|
580
775
|
~onStdout=(_dir, line) => feed(line),
|
|
581
|
-
~onDone=
|
|
582
|
-
|
|
583
|
-
|
|
776
|
+
~onDone=ok => {
|
|
777
|
+
rebuilding->Dict.set(pkg.dir, false)
|
|
778
|
+
// Only report success when the build actually succeeded. On failure
|
|
779
|
+
// the classifier's `onFail` has usually already emitted the compiler
|
|
780
|
+
// error; emit a fallback `buildFail` only if it didn't (e.g. a crash
|
|
781
|
+
// with no recognisable marker), so we never claim `buildOk` over a
|
|
782
|
+
// broken build and re-run against stale `.res.mjs`.
|
|
783
|
+
if ok {
|
|
784
|
+
FormatterVsCode.buildOk(pkg.dir, Date.now() -. t0)
|
|
785
|
+
} else if !sawError.contents {
|
|
786
|
+
FormatterVsCode.buildFail(pkg.dir, "clean rebuild failed")
|
|
787
|
+
}
|
|
788
|
+
ignore(rediscoverAndRun())
|
|
584
789
|
},
|
|
585
790
|
)
|
|
586
791
|
}
|
|
792
|
+
// A test file added mid-session runs (discover re-scans each pass) but never
|
|
793
|
+
// appears in the client's tree, which was emitted once at start. On an `Add`
|
|
794
|
+
// of a *source* `.res` re-emit the discovery stream (idempotent snapshot) and
|
|
795
|
+
// (re)claim build watchers for any new package before re-running.
|
|
796
|
+
let refreshDiscovery = async () => {
|
|
797
|
+
let paths = await Discovery.discover(roots)
|
|
798
|
+
discoveredPaths := paths
|
|
799
|
+
await emitDiscovery(paths)
|
|
800
|
+
startBuildWatchers(paths)
|
|
801
|
+
let _ = await requestRun(RunAll)
|
|
802
|
+
}
|
|
803
|
+
// Narrow a plain edit's re-run to the edited file's owning test package (VS
|
|
804
|
+
// Code only). `RunAll` when the file has no owning package, or the owner isn't
|
|
805
|
+
// one of the discovered test packages (e.g. a framework edit that ripples into
|
|
806
|
+
// several plugins — full re-run is the safe choice there). A human terminal
|
|
807
|
+
// `watch` always re-runs everything (full-summary output).
|
|
808
|
+
let affectedScope = (path: string): runScope =>
|
|
809
|
+
if opts.format == VsCode {
|
|
810
|
+
let abs = resolvePath(path)
|
|
811
|
+
switch PackageScan.findOwning(PackageScan.dirname(abs)) {
|
|
812
|
+
| Some(pkg) if discoveredPaths.contents->Array.some(p => pathUnderDir(p, pkg.dir)) =>
|
|
813
|
+
RunPackages([pkg.dir])
|
|
814
|
+
| _ => RunAll
|
|
815
|
+
}
|
|
816
|
+
} else {
|
|
817
|
+
RunAll
|
|
818
|
+
}
|
|
587
819
|
let _watcher = Watch.start(roots, (event, path) =>
|
|
588
820
|
if opts.format == VsCode && Watch.isStructuralSource(event, path) {
|
|
589
821
|
structuralRebuild(path)
|
|
822
|
+
} else if opts.format == VsCode && event == Watch.Add && path->String.endsWith(".res") {
|
|
823
|
+
ignore(refreshDiscovery())
|
|
590
824
|
} else {
|
|
591
|
-
ignore(
|
|
825
|
+
ignore(requestRun(affectedScope(path)))
|
|
592
826
|
}
|
|
593
827
|
)
|
|
594
828
|
// Keep the process alive until cancelled. Awaiting here (rather than letting
|