@reventlessdev/reventless-gwt 1.0.0-alpha.74 → 1.0.0-alpha.76
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 +15 -0
- package/package.json +8 -8
- package/src/Bind.res.mjs +2 -2
- 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 +115 -9
- package/src/Cli.res.mjs +101 -17
- package/src/Collector.res +11 -0
- package/src/Collector.res.mjs +6 -3
- 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/FormatterVsCode.res +1 -1
- package/src/FormatterVsCode.res.mjs +2 -1
- package/src/JestBind.res +1 -1
- package/src/JestBind.res.mjs +2 -2
- package/src/PlatformRunner.res +27 -6
- package/src/PlatformRunner.res.mjs +27 -6
- 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/CliRunEntryTest.res +57 -0
- package/tests/CliRunEntryTest.res.mjs +65 -0
- package/tests/DomainGraphTest.res +79 -2
- package/tests/DomainGraphTest.res.mjs +87 -6
- package/tests/FlowGwtTest.res.mjs +2 -1
- package/tests/InboundTranslationGwtTest.res.mjs +2 -1
- package/tests/OutboundTranslationGwtTest.res.mjs +2 -1
- package/tests/WatchDebounceTest.res +35 -7
- package/tests/WatchDebounceTest.res.mjs +37 -6
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 1.0.0-alpha.76 (2026-07-02)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **gwt:** harden watch runner process/lifecycle/fidelity (plan A2–A4) ([9d5d566](https://github.com/ReventlessDev/reventless-core/commit/9d5d566f2de572b6de19c18e5a1d3dd53982f1e8))
|
|
11
|
+
* **gwt:** run each watch re-run in a fresh worker (plan A1/B2) ([af71131](https://github.com/ReventlessDev/reventless-core/commit/af711312b8d6f7482ee22faaa3265e72ca3ed68b))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 1.0.0-alpha.75 (2026-06-29)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* external-system boxes for translation slices (Event Graph data) ([3f8ad39](https://github.com/ReventlessDev/reventless-core/commit/3f8ad39b78a3cb1182d59a0e1fb203b7dcb7379b))
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 1.0.0-alpha.74 (2026-06-27)
|
|
7
22
|
|
|
8
23
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-gwt",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.76",
|
|
4
4
|
"description": "Given-When-Then DSLs and CLI runner for Reventless slice testing",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -20,17 +20,17 @@
|
|
|
20
20
|
"chokidar": "^3.6.0",
|
|
21
21
|
"picocolors": "^1.1.1",
|
|
22
22
|
"sury": "11.0.0-alpha.4",
|
|
23
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.
|
|
24
|
-
"@reventlessdev/
|
|
25
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
26
|
-
"@reventlessdev/reventless-
|
|
27
|
-
"@reventlessdev/
|
|
28
|
-
"@reventlessdev/reventless-vscode-protocol": "1.0.0-alpha.
|
|
23
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.24",
|
|
24
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.5",
|
|
25
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.83",
|
|
26
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.136",
|
|
27
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.63",
|
|
28
|
+
"@reventlessdev/reventless-vscode-protocol": "1.0.0-alpha.9"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"rescript": "^12.3.0",
|
|
32
32
|
"sury-ppx": "11.0.0-alpha.2",
|
|
33
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
33
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.48"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"rescript": "^12.3.0"
|
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/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
|
@@ -222,6 +222,42 @@ let exnMessage: exn => string = %raw(`function(e) {
|
|
|
222
222
|
return "unknown error"
|
|
223
223
|
}`)
|
|
224
224
|
|
|
225
|
+
// Default per-test deadline (ms) when a test body doesn't set `~timeout`.
|
|
226
|
+
// Mirrors Jest's default so behaviour is consistent across both runners.
|
|
227
|
+
let defaultTimeoutMs = 5000
|
|
228
|
+
|
|
229
|
+
// Resolve to a timeout `Outcome` after `ms`. Raced against the test body so a
|
|
230
|
+
// hung await reports a failure instead of wedging the entire run.
|
|
231
|
+
type timeoutHandle
|
|
232
|
+
@val external setTimeout: (unit => unit, int) => timeoutHandle = "setTimeout"
|
|
233
|
+
@val external clearTimeout: timeoutHandle => unit = "clearTimeout"
|
|
234
|
+
let raceWithTimeout = (body: unit => promise<Outcome.outcome>, ms: int): promise<
|
|
235
|
+
Outcome.outcome,
|
|
236
|
+
> => {
|
|
237
|
+
let handleRef: ref<option<timeoutHandle>> = ref(None)
|
|
238
|
+
let timeout = Promise.make((resolve, _reject) => {
|
|
239
|
+
handleRef :=
|
|
240
|
+
Some(
|
|
241
|
+
setTimeout(
|
|
242
|
+
() =>
|
|
243
|
+
resolve(
|
|
244
|
+
Outcome.fail(
|
|
245
|
+
Throw({error: `timed out after ${Int.toString(ms)}ms`, stack: ""}),
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
ms,
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
})
|
|
252
|
+
Promise.race([body(), timeout])->Promise.then(o => {
|
|
253
|
+
switch handleRef.contents {
|
|
254
|
+
| Some(h) => clearTimeout(h)
|
|
255
|
+
| None => ()
|
|
256
|
+
}
|
|
257
|
+
Promise.resolve(o)
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
225
261
|
// Execute a single entry, catching exceptions and thrown values so a
|
|
226
262
|
// misbehaved slice doesn't abort the rest of the suite.
|
|
227
263
|
let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
|
|
@@ -232,7 +268,8 @@ let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
|
|
|
232
268
|
| Collector.Skipped => status := Skip
|
|
233
269
|
| _ =>
|
|
234
270
|
try {
|
|
235
|
-
let
|
|
271
|
+
let deadline = entry.timeout->Option.getOr(defaultTimeoutMs)
|
|
272
|
+
let outcome = await raceWithTimeout(entry.body, deadline)
|
|
236
273
|
switch outcome {
|
|
237
274
|
| Ok() => status := Pass
|
|
238
275
|
| Error(m) => {
|
|
@@ -531,6 +568,37 @@ let runWatch = async (opts: options): int => {
|
|
|
531
568
|
await emitDiscovery(paths)
|
|
532
569
|
startBuildWatchers(paths)
|
|
533
570
|
}
|
|
571
|
+
// Each re-run pass executes in a short-lived Worker so it gets a fresh ESM
|
|
572
|
+
// module registry (recompiled implementation modules are re-imported instead
|
|
573
|
+
// of served stale from Node's never-evicting cache) and reclaims the imported
|
|
574
|
+
// graph on exit. The worker's stdout is piped to ours, so its NDJSON reaches
|
|
575
|
+
// the client unchanged. One worker per pass; the parent keeps ownership of the
|
|
576
|
+
// build watchers and chokidar.
|
|
577
|
+
let currentWorker: ref<option<Worker.t>> = ref(None)
|
|
578
|
+
Cancellation.onCancel(() =>
|
|
579
|
+
switch currentWorker.contents {
|
|
580
|
+
| Some(w) =>
|
|
581
|
+
let _ = Worker.terminate(w)
|
|
582
|
+
| None => ()
|
|
583
|
+
}
|
|
584
|
+
)
|
|
585
|
+
let runInWorker = (): promise<unit> =>
|
|
586
|
+
Promise.make((resolve, _reject) => {
|
|
587
|
+
let w = Worker.make(Worker.runWorkerUrl, {workerData: opts})
|
|
588
|
+
currentWorker := Some(w)
|
|
589
|
+
let settled = ref(false)
|
|
590
|
+
let settle = () =>
|
|
591
|
+
if !settled.contents {
|
|
592
|
+
settled := true
|
|
593
|
+
currentWorker := None
|
|
594
|
+
resolve()
|
|
595
|
+
}
|
|
596
|
+
w->Worker.on("exit", (_code: int) => settle())
|
|
597
|
+
w->Worker.on("error", e => {
|
|
598
|
+
Console.error2("gwt run worker error:", e)
|
|
599
|
+
settle()
|
|
600
|
+
})
|
|
601
|
+
})
|
|
534
602
|
let runInProgress = ref(false)
|
|
535
603
|
let rerunPending = ref(false)
|
|
536
604
|
let run = async () => {
|
|
@@ -538,13 +606,17 @@ let runWatch = async (opts: options): int => {
|
|
|
538
606
|
rerunPending := true
|
|
539
607
|
} else {
|
|
540
608
|
runInProgress := true
|
|
541
|
-
let _ = await
|
|
542
|
-
|
|
543
|
-
|
|
609
|
+
let _ = await runInWorker()
|
|
610
|
+
// Drain every re-run request that arrived while a pass was in flight.
|
|
611
|
+
// Clearing `runInProgress` before awaiting the pending pass (the previous
|
|
612
|
+
// shape) let a third concurrent `run` start mid-drain, which corrupts the
|
|
613
|
+
// module-global `Collector` and interleaves NDJSON events. Hold the flag
|
|
614
|
+
// across the whole drain loop so only one pass ever runs at a time.
|
|
615
|
+
while rerunPending.contents {
|
|
544
616
|
rerunPending := false
|
|
545
|
-
let _ = await
|
|
546
|
-
()
|
|
617
|
+
let _ = await runInWorker()
|
|
547
618
|
}
|
|
619
|
+
runInProgress := false
|
|
548
620
|
}
|
|
549
621
|
}
|
|
550
622
|
let _ = await run()
|
|
@@ -560,33 +632,67 @@ let runWatch = async (opts: options): int => {
|
|
|
560
632
|
// completion. Edits and adds stay on the lean incremental re-run. VS Code
|
|
561
633
|
// only: a human terminal `watch` keeps the re-run-only loop (it doesn't own
|
|
562
634
|
// the builds, so there's nothing to clean-rebuild).
|
|
635
|
+
// Guards against two structural paths from the *same* burst (a multi-file move
|
|
636
|
+
// within one package) each launching a concurrent clean-rebuild of that
|
|
637
|
+
// package. The first sets the flag; siblings coalesce into its completion
|
|
638
|
+
// re-run. Watch now emits one callback per distinct structural path, so this
|
|
639
|
+
// is what makes "rebuild every distinct owning package" (not per-file) true.
|
|
640
|
+
let rebuilding: Dict.t<bool> = Dict.make()
|
|
563
641
|
let structuralRebuild = (path: string): unit =>
|
|
564
642
|
switch PackageScan.findOwning(path) {
|
|
565
643
|
| None => ignore(run())
|
|
644
|
+
| Some(pkg) if rebuilding->Dict.get(pkg.dir)->Option.getOr(false) => ()
|
|
566
645
|
| Some(pkg) =>
|
|
646
|
+
rebuilding->Dict.set(pkg.dir, true)
|
|
567
647
|
let t0 = Date.now()
|
|
568
648
|
FormatterVsCode.buildStart(pkg.dir)
|
|
569
649
|
// Surface a compile error during the rebuild (the clean/build stream is
|
|
570
650
|
// classified for the error marker); the re-run is gated on completion
|
|
571
651
|
// below, not on the classifier, since a one-shot `rescript build` has no
|
|
572
652
|
// "Finished … compilation" terminator.
|
|
653
|
+
let sawError = ref(false)
|
|
573
654
|
let feed = BuildClassifier.make({
|
|
574
655
|
onStart: () => (),
|
|
575
656
|
onOk: _ms => (),
|
|
576
|
-
onFail: msg =>
|
|
657
|
+
onFail: msg => {
|
|
658
|
+
sawError := true
|
|
659
|
+
FormatterVsCode.buildFail(pkg.dir, msg)
|
|
660
|
+
},
|
|
577
661
|
})
|
|
578
662
|
ProcessManager.cleanRebuild(
|
|
579
663
|
pkg,
|
|
580
664
|
~onStdout=(_dir, line) => feed(line),
|
|
581
|
-
~onDone=
|
|
582
|
-
|
|
665
|
+
~onDone=ok => {
|
|
666
|
+
rebuilding->Dict.set(pkg.dir, false)
|
|
667
|
+
// Only report success when the build actually succeeded. On failure
|
|
668
|
+
// the classifier's `onFail` has usually already emitted the compiler
|
|
669
|
+
// error; emit a fallback `buildFail` only if it didn't (e.g. a crash
|
|
670
|
+
// with no recognisable marker), so we never claim `buildOk` over a
|
|
671
|
+
// broken build and re-run against stale `.res.mjs`.
|
|
672
|
+
if ok {
|
|
673
|
+
FormatterVsCode.buildOk(pkg.dir, Date.now() -. t0)
|
|
674
|
+
} else if !sawError.contents {
|
|
675
|
+
FormatterVsCode.buildFail(pkg.dir, "clean rebuild failed")
|
|
676
|
+
}
|
|
583
677
|
ignore(run())
|
|
584
678
|
},
|
|
585
679
|
)
|
|
586
680
|
}
|
|
681
|
+
// A test file added mid-session runs (discover re-scans each pass) but never
|
|
682
|
+
// appears in the client's tree, which was emitted once at start. On an `Add`
|
|
683
|
+
// of a *source* `.res` re-emit the discovery stream (idempotent snapshot) and
|
|
684
|
+
// (re)claim build watchers for any new package before re-running.
|
|
685
|
+
let refreshDiscovery = async () => {
|
|
686
|
+
let paths = await Discovery.discover(roots)
|
|
687
|
+
await emitDiscovery(paths)
|
|
688
|
+
startBuildWatchers(paths)
|
|
689
|
+
ignore(run())
|
|
690
|
+
}
|
|
587
691
|
let _watcher = Watch.start(roots, (event, path) =>
|
|
588
692
|
if opts.format == VsCode && Watch.isStructuralSource(event, path) {
|
|
589
693
|
structuralRebuild(path)
|
|
694
|
+
} else if opts.format == VsCode && event == Watch.Add && path->String.endsWith(".res") {
|
|
695
|
+
ignore(refreshDiscovery())
|
|
590
696
|
} else {
|
|
591
697
|
ignore(run())
|
|
592
698
|
}
|
package/src/Cli.res.mjs
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
4
|
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
5
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
6
7
|
import * as Watch$ReventlessGwt from "./Watch.res.mjs";
|
|
8
|
+
import * as Nodeworker_threads from "node:worker_threads";
|
|
7
9
|
import * as Loader$ReventlessGwt from "./Loader.res.mjs";
|
|
8
10
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
11
|
+
import * as Worker$ReventlessGwt from "./Worker.res.mjs";
|
|
9
12
|
import * as Outcome$ReventlessGwt from "./Outcome.res.mjs";
|
|
10
13
|
import * as Collector$ReventlessGwt from "./Collector.res.mjs";
|
|
11
14
|
import * as Discovery$ReventlessGwt from "./Discovery.res.mjs";
|
|
@@ -317,6 +320,29 @@ let exnMessage = (function(e) {
|
|
|
317
320
|
return "unknown error"
|
|
318
321
|
});
|
|
319
322
|
|
|
323
|
+
function raceWithTimeout(body, ms) {
|
|
324
|
+
let handleRef = {
|
|
325
|
+
contents: undefined
|
|
326
|
+
};
|
|
327
|
+
let timeout = new Promise((resolve, _reject) => {
|
|
328
|
+
handleRef.contents = Primitive_option.some(setTimeout(() => resolve(Outcome$ReventlessGwt.fail({
|
|
329
|
+
TAG: "Throw",
|
|
330
|
+
error: `timed out after ` + ms.toString() + `ms`,
|
|
331
|
+
stack: ""
|
|
332
|
+
})), ms));
|
|
333
|
+
});
|
|
334
|
+
return Promise.race([
|
|
335
|
+
body(),
|
|
336
|
+
timeout
|
|
337
|
+
]).then(o => {
|
|
338
|
+
let h = handleRef.contents;
|
|
339
|
+
if (h !== undefined) {
|
|
340
|
+
clearTimeout(Primitive_option.valFromOption(h));
|
|
341
|
+
}
|
|
342
|
+
return Promise.resolve(o);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
320
346
|
async function runEntry(entry) {
|
|
321
347
|
let start = performance.now();
|
|
322
348
|
let status = "Pass";
|
|
@@ -334,7 +360,8 @@ async function runEntry(entry) {
|
|
|
334
360
|
}
|
|
335
361
|
if (exit === 1) {
|
|
336
362
|
try {
|
|
337
|
-
let
|
|
363
|
+
let deadline = Stdlib_Option.getOr(entry.timeout, 5000);
|
|
364
|
+
let outcome = await raceWithTimeout(entry.body, deadline);
|
|
338
365
|
if (outcome.TAG === "Ok") {
|
|
339
366
|
status = "Pass";
|
|
340
367
|
} else {
|
|
@@ -375,7 +402,7 @@ async function loadAndCollect(path) {
|
|
|
375
402
|
} catch (raw_exn) {
|
|
376
403
|
let exn = Primitive_exceptions.internalToException(raw_exn);
|
|
377
404
|
let msg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "import failed");
|
|
378
|
-
Collector$ReventlessGwt.push(undefined, undefined, `<file-load-error>`, () => Promise.resolve(Outcome$ReventlessGwt.fail({
|
|
405
|
+
Collector$ReventlessGwt.push(undefined, undefined, undefined, `<file-load-error>`, () => Promise.resolve(Outcome$ReventlessGwt.fail({
|
|
379
406
|
TAG: "Throw",
|
|
380
407
|
error: "Failed to load " + path + ": " + msg,
|
|
381
408
|
stack: ""
|
|
@@ -598,6 +625,37 @@ async function runWatch(opts) {
|
|
|
598
625
|
await emitDiscovery(paths);
|
|
599
626
|
startBuildWatchers(paths);
|
|
600
627
|
}
|
|
628
|
+
let currentWorker = {
|
|
629
|
+
contents: undefined
|
|
630
|
+
};
|
|
631
|
+
Cancellation$ReventlessGwt.onCancel(() => {
|
|
632
|
+
let w = currentWorker.contents;
|
|
633
|
+
if (w !== undefined) {
|
|
634
|
+
Primitive_option.valFromOption(w).terminate();
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
let runInWorker = () => new Promise((resolve, _reject) => {
|
|
639
|
+
let w = new Nodeworker_threads.Worker(Worker$ReventlessGwt.runWorkerUrl, {
|
|
640
|
+
workerData: opts
|
|
641
|
+
});
|
|
642
|
+
currentWorker.contents = Primitive_option.some(w);
|
|
643
|
+
let settled = {
|
|
644
|
+
contents: false
|
|
645
|
+
};
|
|
646
|
+
let settle = () => {
|
|
647
|
+
if (!settled.contents) {
|
|
648
|
+
settled.contents = true;
|
|
649
|
+
currentWorker.contents = undefined;
|
|
650
|
+
return resolve();
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
w.on("exit", _code => settle());
|
|
654
|
+
w.on("error", e => {
|
|
655
|
+
console.error("gwt run worker error:", e);
|
|
656
|
+
settle();
|
|
657
|
+
});
|
|
658
|
+
});
|
|
601
659
|
let runInProgress = {
|
|
602
660
|
contents: false
|
|
603
661
|
};
|
|
@@ -608,40 +666,62 @@ async function runWatch(opts) {
|
|
|
608
666
|
if (runInProgress.contents) {
|
|
609
667
|
rerunPending.contents = true;
|
|
610
668
|
return;
|
|
611
|
-
} else {
|
|
612
|
-
runInProgress.contents = true;
|
|
613
|
-
await runOnce(opts);
|
|
614
|
-
runInProgress.contents = false;
|
|
615
|
-
if (rerunPending.contents) {
|
|
616
|
-
rerunPending.contents = false;
|
|
617
|
-
await runOnce(opts);
|
|
618
|
-
return;
|
|
619
|
-
} else {
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
669
|
}
|
|
670
|
+
runInProgress.contents = true;
|
|
671
|
+
await runInWorker();
|
|
672
|
+
while (rerunPending.contents) {
|
|
673
|
+
rerunPending.contents = false;
|
|
674
|
+
await runInWorker();
|
|
675
|
+
};
|
|
676
|
+
runInProgress.contents = false;
|
|
623
677
|
};
|
|
624
678
|
await run();
|
|
679
|
+
let rebuilding = {};
|
|
680
|
+
let refreshDiscovery = async () => {
|
|
681
|
+
let paths = await Discovery$ReventlessGwt.discover(roots);
|
|
682
|
+
await emitDiscovery(paths);
|
|
683
|
+
startBuildWatchers(paths);
|
|
684
|
+
run();
|
|
685
|
+
};
|
|
625
686
|
Watch$ReventlessGwt.start(roots, (event, path) => {
|
|
626
687
|
if (opts.format === "VsCode" && Watch$ReventlessGwt.isStructuralSource(event, path)) {
|
|
627
688
|
let pkg = PackageScan$ReventlessGwt.findOwning(path);
|
|
628
689
|
if (pkg !== undefined) {
|
|
690
|
+
if (Stdlib_Option.getOr(rebuilding[pkg.dir], false)) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
rebuilding[pkg.dir] = true;
|
|
629
694
|
let t0 = Date.now();
|
|
630
695
|
FormatterVsCode$ReventlessGwt.buildStart(pkg.dir);
|
|
696
|
+
let sawError = {
|
|
697
|
+
contents: false
|
|
698
|
+
};
|
|
631
699
|
let feed = BuildClassifier$ReventlessGwt.make(undefined, {
|
|
632
700
|
onStart: () => {},
|
|
633
701
|
onOk: _ms => {},
|
|
634
|
-
onFail: msg =>
|
|
702
|
+
onFail: msg => {
|
|
703
|
+
sawError.contents = true;
|
|
704
|
+
FormatterVsCode$ReventlessGwt.buildFail(pkg.dir, msg);
|
|
705
|
+
}
|
|
635
706
|
});
|
|
636
|
-
return ProcessManager$ReventlessGwt.cleanRebuild(pkg, (_dir, line) => feed(line),
|
|
637
|
-
|
|
707
|
+
return ProcessManager$ReventlessGwt.cleanRebuild(pkg, (_dir, line) => feed(line), ok => {
|
|
708
|
+
rebuilding[pkg.dir] = false;
|
|
709
|
+
if (ok) {
|
|
710
|
+
FormatterVsCode$ReventlessGwt.buildOk(pkg.dir, Date.now() - t0);
|
|
711
|
+
} else if (!sawError.contents) {
|
|
712
|
+
FormatterVsCode$ReventlessGwt.buildFail(pkg.dir, "clean rebuild failed");
|
|
713
|
+
}
|
|
638
714
|
run();
|
|
639
715
|
});
|
|
640
716
|
}
|
|
641
717
|
run();
|
|
642
718
|
return;
|
|
643
719
|
} else {
|
|
644
|
-
|
|
720
|
+
if (opts.format === "VsCode" && event === "Add" && path.endsWith(".res")) {
|
|
721
|
+
refreshDiscovery();
|
|
722
|
+
} else {
|
|
723
|
+
run();
|
|
724
|
+
}
|
|
645
725
|
return;
|
|
646
726
|
}
|
|
647
727
|
});
|
|
@@ -728,6 +808,8 @@ async function main() {
|
|
|
728
808
|
}
|
|
729
809
|
}
|
|
730
810
|
|
|
811
|
+
let defaultTimeoutMs = 5000;
|
|
812
|
+
|
|
731
813
|
export {
|
|
732
814
|
toolVersion,
|
|
733
815
|
dateNowIso,
|
|
@@ -737,6 +819,8 @@ export {
|
|
|
737
819
|
parseArgv,
|
|
738
820
|
passesFilter,
|
|
739
821
|
exnMessage,
|
|
822
|
+
defaultTimeoutMs,
|
|
823
|
+
raceWithTimeout,
|
|
740
824
|
runEntry,
|
|
741
825
|
loadAndCollect,
|
|
742
826
|
runFiles,
|
package/src/Collector.res
CHANGED
|
@@ -25,6 +25,10 @@ type entry = {
|
|
|
25
25
|
body: unit => promise<Outcome.outcome>,
|
|
26
26
|
status: status,
|
|
27
27
|
location: option<location>,
|
|
28
|
+
// Per-test deadline in ms (from `~timeout` on `testPromise`). `None` = use the
|
|
29
|
+
// runner default. The CLI runner races the body against this; the Jest arm
|
|
30
|
+
// passes it to Jest's own timeout instead.
|
|
31
|
+
timeout: option<int>,
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
let active = ref(false)
|
|
@@ -43,6 +47,10 @@ let activate = () => {
|
|
|
43
47
|
hasOnly := false
|
|
44
48
|
skipNext := false
|
|
45
49
|
onlyNext := false
|
|
50
|
+
// Reset the skip nesting depth too: a throwing `xdescribe` body in a prior
|
|
51
|
+
// file could otherwise leave it > 0, silently skipping every test in every
|
|
52
|
+
// subsequently loaded file.
|
|
53
|
+
skipDepth := 0
|
|
46
54
|
currentFile := None
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -73,6 +81,7 @@ let buildId = (describePath, name) => {
|
|
|
73
81
|
let push = (
|
|
74
82
|
~slice=?,
|
|
75
83
|
~location=?,
|
|
84
|
+
~timeout=?,
|
|
76
85
|
name: string,
|
|
77
86
|
body: unit => promise<Outcome.outcome>,
|
|
78
87
|
) => {
|
|
@@ -97,6 +106,7 @@ let push = (
|
|
|
97
106
|
body,
|
|
98
107
|
status,
|
|
99
108
|
location,
|
|
109
|
+
timeout,
|
|
100
110
|
}
|
|
101
111
|
entries := Array.concat(entries.contents, [entry])
|
|
102
112
|
}
|
|
@@ -115,6 +125,7 @@ let pushTodo = (name: string) => {
|
|
|
115
125
|
body: () => Promise.resolve(Outcome.pass),
|
|
116
126
|
status: Skipped,
|
|
117
127
|
location: None,
|
|
128
|
+
timeout: None,
|
|
118
129
|
}
|
|
119
130
|
entries := Array.concat(entries.contents, [entry])
|
|
120
131
|
}
|
package/src/Collector.res.mjs
CHANGED
|
@@ -42,6 +42,7 @@ function activate() {
|
|
|
42
42
|
hasOnly.contents = false;
|
|
43
43
|
skipNext.contents = false;
|
|
44
44
|
onlyNext.contents = false;
|
|
45
|
+
skipDepth.contents = 0;
|
|
45
46
|
currentFile.contents = undefined;
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -86,7 +87,7 @@ function buildId(describePath, name) {
|
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
function push(slice, location, name, body) {
|
|
90
|
+
function push(slice, location, timeout, name, body) {
|
|
90
91
|
let status = skipDepth.contents > 0 ? "Skipped" : (
|
|
91
92
|
skipNext.contents ? (skipNext.contents = false, "Skipped") : (
|
|
92
93
|
onlyNext.contents ? (onlyNext.contents = false, "Only") : "Runnable"
|
|
@@ -101,7 +102,8 @@ function push(slice, location, name, body) {
|
|
|
101
102
|
slice: slice,
|
|
102
103
|
body: body,
|
|
103
104
|
status: status,
|
|
104
|
-
location: location
|
|
105
|
+
location: location,
|
|
106
|
+
timeout: timeout
|
|
105
107
|
};
|
|
106
108
|
entries.contents = entries.contents.concat([entry]);
|
|
107
109
|
}
|
|
@@ -117,7 +119,8 @@ function pushTodo(name) {
|
|
|
117
119
|
slice: undefined,
|
|
118
120
|
body: entry_body,
|
|
119
121
|
status: "Skipped",
|
|
120
|
-
location: undefined
|
|
122
|
+
location: undefined,
|
|
123
|
+
timeout: undefined
|
|
121
124
|
};
|
|
122
125
|
entries.contents = entries.contents.concat([entry]);
|
|
123
126
|
}
|
package/src/DomainGraph.res
CHANGED
|
Binary file
|
package/src/DomainGraph.res.mjs
CHANGED
|
Binary file
|
package/src/Filter.res
CHANGED
|
@@ -29,8 +29,17 @@ let xtest = (~slice as _: option<string>=?, name: string, _body: unit => Outcome
|
|
|
29
29
|
let xdescribe = (label: string, body: unit => unit) =>
|
|
30
30
|
if Collector.isActive() {
|
|
31
31
|
Collector.skipDepth := Collector.skipDepth.contents + 1
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Decrement on both the normal and the throwing path (ReScript has no
|
|
33
|
+
// `finally`): a body that raises must not leak the incremented depth and
|
|
34
|
+
// skip every following sibling/file.
|
|
35
|
+
try {
|
|
36
|
+
Collector.pushDescribe(label, body)
|
|
37
|
+
Collector.skipDepth := Collector.skipDepth.contents - 1
|
|
38
|
+
} catch {
|
|
39
|
+
| e =>
|
|
40
|
+
Collector.skipDepth := Collector.skipDepth.contents - 1
|
|
41
|
+
throw(e)
|
|
42
|
+
}
|
|
34
43
|
} else {
|
|
35
44
|
jestXDescribe(label, body)
|
|
36
45
|
}
|