@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +8 -8
  3. package/src/Bind.res.mjs +2 -2
  4. package/src/Cancellation.res +6 -0
  5. package/src/Cancellation.res.mjs +3 -2
  6. package/src/ChildProcess.res +19 -1
  7. package/src/ChildProcess.res.mjs +16 -1
  8. package/src/Cli.res +115 -9
  9. package/src/Cli.res.mjs +101 -17
  10. package/src/Collector.res +11 -0
  11. package/src/Collector.res.mjs +6 -3
  12. package/src/DomainGraph.res +0 -0
  13. package/src/DomainGraph.res.mjs +0 -0
  14. package/src/Filter.res +11 -2
  15. package/src/Filter.res.mjs +10 -3
  16. package/src/FormatterVsCode.res +1 -1
  17. package/src/FormatterVsCode.res.mjs +2 -1
  18. package/src/JestBind.res +1 -1
  19. package/src/JestBind.res.mjs +2 -2
  20. package/src/PlatformRunner.res +27 -6
  21. package/src/PlatformRunner.res.mjs +27 -6
  22. package/src/ProcessManager.res +51 -7
  23. package/src/ProcessManager.res.mjs +43 -4
  24. package/src/RunWorker.res +24 -0
  25. package/src/RunWorker.res.mjs +16 -0
  26. package/src/Watch.res +46 -30
  27. package/src/Watch.res.mjs +34 -32
  28. package/src/WatcherProbe.res +11 -3
  29. package/src/WatcherProbe.res.mjs +15 -2
  30. package/src/Worker.res +24 -0
  31. package/src/Worker.res.mjs +9 -0
  32. package/tests/CliRunEntryTest.res +57 -0
  33. package/tests/CliRunEntryTest.res.mjs +65 -0
  34. package/tests/DomainGraphTest.res +79 -2
  35. package/tests/DomainGraphTest.res.mjs +87 -6
  36. package/tests/FlowGwtTest.res.mjs +2 -1
  37. package/tests/InboundTranslationGwtTest.res.mjs +2 -1
  38. package/tests/OutboundTranslationGwtTest.res.mjs +2 -1
  39. package/tests/WatchDebounceTest.res +35 -7
  40. 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.74",
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.23",
24
- "@reventlessdev/reventless-core": "3.0.0-alpha.134",
25
- "@reventlessdev/reventless-infra": "3.0.0-alpha.81",
26
- "@reventlessdev/reventless-spec": "3.0.0-alpha.61",
27
- "@reventlessdev/rescript-jest": "1.0.0-alpha.3",
28
- "@reventlessdev/reventless-vscode-protocol": "1.0.0-alpha.8"
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.47"
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 {
@@ -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)
@@ -19,7 +19,9 @@ function onCancel(handler) {
19
19
 
20
20
  function install() {
21
21
  let handler = () => {
22
- if (!cancelled.contents) {
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);
@@ -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 =>
@@ -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
- /* node:child_process Not a pure module */
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 outcome = await entry.body()
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 runOnce(opts)
542
- runInProgress := false
543
- if rerunPending.contents {
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 runOnce(opts)
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 => FormatterVsCode.buildFail(pkg.dir, 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
- FormatterVsCode.buildOk(pkg.dir, Date.now() -. t0)
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 outcome = await entry.body();
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 => FormatterVsCode$ReventlessGwt.buildFail(pkg.dir, 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
- FormatterVsCode$ReventlessGwt.buildOk(pkg.dir, Date.now() - t0);
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
- run();
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
  }
@@ -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
  }
Binary file
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
- Collector.pushDescribe(label, body)
33
- Collector.skipDepth := Collector.skipDepth.contents - 1
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
  }