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

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 CHANGED
@@ -3,6 +3,24 @@
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.115 (2026-07-15)
7
+
8
+ **Note:** Version bump only for package @reventlessdev/reventless-gwt
9
+
10
+
11
+
12
+
13
+
14
+ # 1.0.0-alpha.114 (2026-07-14)
15
+
16
+ ### Bug Fixes
17
+
18
+ * show debug logs on the local platform (both launch paths) ([81837d7](https://github.com/ReventlessDev/reventless-core/commit/81837d7e02abe1065a722b43b195adef2c836794))
19
+ ### Features
20
+
21
+ * **core:** thread ~comp through Projection.handleAction for attributed action logging ([fa32d93](https://github.com/ReventlessDev/reventless-core/commit/fa32d93dedabea1b1f655f01a4ea769a5908b143))
22
+
23
+
6
24
  # 1.0.0-alpha.113 (2026-07-14)
7
25
 
8
26
  **Note:** Version bump only for package @reventlessdev/reventless-gwt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-gwt",
3
- "version": "1.0.0-alpha.113",
3
+ "version": "1.0.0-alpha.115",
4
4
  "description": "Given-When-Then DSLs and CLI runner for Reventless slice testing",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -21,11 +21,11 @@
21
21
  "picocolors": "^1.1.1",
22
22
  "sury": "11.0.0-alpha.4",
23
23
  "@reventlessdev/rescript-jest": "1.0.0-alpha.7",
24
- "@reventlessdev/reventless-core": "3.0.0-alpha.167",
25
- "@reventlessdev/reventless-domain-protocol": "1.0.0-alpha.20",
26
24
  "@reventlessdev/rescript-effect": "0.1.0-alpha.27",
27
- "@reventlessdev/reventless-infra": "3.0.0-alpha.99",
28
- "@reventlessdev/reventless-spec": "3.0.0-alpha.76"
25
+ "@reventlessdev/reventless-core": "3.0.0-alpha.169",
26
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.100",
27
+ "@reventlessdev/reventless-domain-protocol": "1.0.0-alpha.20",
28
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.77"
29
29
  },
30
30
  "devDependencies": {
31
31
  "rescript": "^12.3.0",
package/src/Cli.res CHANGED
@@ -60,7 +60,7 @@ let sha256 = (s: string): string => createHash("sha256")->hashUpdate(s)->hashDig
60
60
  // re-emit forces the client to re-render the domain view for nothing.
61
61
  let lastDomainHash: ref<option<string>> = ref(None)
62
62
 
63
- let dateNowIso: unit => string = %raw(`() => new Date().toISOString()`)
63
+ let dateNowIso = (): string => Date.make()->Date.toISOString
64
64
 
65
65
  let parseFormat = (s: string) =>
66
66
  switch s {
@@ -229,23 +229,6 @@ let passesFilter = (id: string, filters: array<string>) =>
229
229
  filters->Array.length == 0 ||
230
230
  filters->Array.some(f => id->String.includes(f))
231
231
 
232
- // Extract a human-readable message from any thrown value. JS Errors carry a
233
- // `.message`; ReScript exceptions are tagged objects (`RE_EXN_ID` + an optional
234
- // string payload), e.g. `failwith("…")` raises `Failure("…")`. Without this the
235
- // catch block below collapsed every ReScript exception to "unknown error",
236
- // hiding messages like a slice's `failwith("not implemented: …")`.
237
- let exnMessage: exn => string = %raw(`function(e) {
238
- if (e == null) return "unknown error"
239
- if (typeof e === "string") return e
240
- if (typeof e.message === "string" && e.message.length) return e.message
241
- if (typeof e.RE_EXN_ID === "string") {
242
- var id = e.RE_EXN_ID
243
- var tag = id.lastIndexOf(".") >= 0 ? id.slice(id.lastIndexOf(".") + 1) : id
244
- return (typeof e._1 === "string" && e._1.length) ? e._1 : tag
245
- }
246
- return "unknown error"
247
- }`)
248
-
249
232
  // Default per-test deadline (ms) when a test body doesn't set `~timeout`.
250
233
  // Mirrors Jest's default so behaviour is consistent across both runners.
251
234
  let defaultTimeoutMs = 5000
@@ -304,8 +287,8 @@ let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
304
287
  } catch {
305
288
  | exn => {
306
289
  status := Fail
307
- let err = exnMessage(exn)
308
- let stack: string = %raw(`(e => (e && e.stack) || "")`)(exn)
290
+ let err = ExnMessage.extract(exn)
291
+ let stack = ExnMessage.stack(exn)
309
292
  mismatch := Some(Outcome.Throw({error: err, stack: stack}))
310
293
  }
311
294
  }
@@ -326,6 +309,7 @@ let runEntry = async (entry: Collector.entry): RunnerTypes.testResult => {
326
309
 
327
310
  let loadAndCollect = async (path: string): array<Collector.entry> => {
328
311
  Collector.activate()
312
+ RunnerHook.register(Collector.asSink)
329
313
  Collector.setCurrentFile(path)
330
314
  try {
331
315
  await Loader.loadFile(path)
package/src/Cli.res.mjs CHANGED
@@ -16,6 +16,8 @@ import * as Outcome$ReventlessGwt from "./Outcome.res.mjs";
16
16
  import * as Collector$ReventlessGwt from "./Collector.res.mjs";
17
17
  import * as Discovery$ReventlessGwt from "./Discovery.res.mjs";
18
18
  import * as LocalHost$ReventlessGwt from "./LocalHost.res.mjs";
19
+ import * as ExnMessage$ReventlessGwt from "./ExnMessage.res.mjs";
20
+ import * as RunnerHook$ReventlessGwt from "./RunnerHook.res.mjs";
19
21
  import * as DomainGraph$ReventlessGwt from "./DomainGraph.res.mjs";
20
22
  import * as PackageScan$ReventlessGwt from "./PackageScan.res.mjs";
21
23
  import * as RunnerTypes$ReventlessGwt from "./RunnerTypes.res.mjs";
@@ -44,7 +46,9 @@ let lastDomainHash = {
44
46
  contents: undefined
45
47
  };
46
48
 
47
- let dateNowIso = (() => new Date().toISOString());
49
+ function dateNowIso() {
50
+ return new Date().toISOString();
51
+ }
48
52
 
49
53
  function parseFormat(s) {
50
54
  switch (s) {
@@ -320,18 +324,6 @@ function passesFilter(id, filters) {
320
324
  }
321
325
  }
322
326
 
323
- let exnMessage = (function(e) {
324
- if (e == null) return "unknown error"
325
- if (typeof e === "string") return e
326
- if (typeof e.message === "string" && e.message.length) return e.message
327
- if (typeof e.RE_EXN_ID === "string") {
328
- var id = e.RE_EXN_ID
329
- var tag = id.lastIndexOf(".") >= 0 ? id.slice(id.lastIndexOf(".") + 1) : id
330
- return (typeof e._1 === "string" && e._1.length) ? e._1 : tag
331
- }
332
- return "unknown error"
333
- });
334
-
335
327
  function raceWithTimeout(body, ms) {
336
328
  let handleRef = {
337
329
  contents: undefined
@@ -383,8 +375,8 @@ async function runEntry(entry) {
383
375
  } catch (raw_exn) {
384
376
  let exn = Primitive_exceptions.internalToException(raw_exn);
385
377
  status = "Fail";
386
- let err = exnMessage(exn);
387
- let stack = ((e => (e && e.stack) || ""))(exn);
378
+ let err = ExnMessage$ReventlessGwt.extract(exn);
379
+ let stack = ExnMessage$ReventlessGwt.stack(exn);
388
380
  mismatch = {
389
381
  TAG: "Throw",
390
382
  error: err,
@@ -408,6 +400,7 @@ async function runEntry(entry) {
408
400
 
409
401
  async function loadAndCollect(path) {
410
402
  Collector$ReventlessGwt.activate();
403
+ RunnerHook$ReventlessGwt.register(Collector$ReventlessGwt.asSink);
411
404
  Collector$ReventlessGwt.setCurrentFile(path);
412
405
  try {
413
406
  await Loader$ReventlessGwt.loadFile(path);
@@ -426,7 +419,7 @@ async function loadAndCollect(path) {
426
419
  }
427
420
 
428
421
  async function runFiles(opts, paths, onFileFinished, onTestStart, onTestFinished) {
429
- let startedAt = dateNowIso();
422
+ let startedAt = new Date().toISOString();
430
423
  let startTime = performance.now();
431
424
  let files = [];
432
425
  for (let i = 0, i_finish = paths.length; i < i_finish; ++i) {
@@ -560,7 +553,7 @@ async function runOnce(opts) {
560
553
  let p = opts.paths;
561
554
  let paths = p !== undefined ? p : await Discovery$ReventlessGwt.discover(opts.roots);
562
555
  if (opts.format === "Json" && opts.stream) {
563
- FormatterJson$ReventlessGwt.streamRunStart(opts.toolVersion, dateNowIso(), opts.schemaVersion);
556
+ FormatterJson$ReventlessGwt.streamRunStart(opts.toolVersion, new Date().toISOString(), opts.schemaVersion);
564
557
  }
565
558
  let match = opts.format;
566
559
  let match$1 = opts.stream;
@@ -929,7 +922,6 @@ export {
929
922
  help,
930
923
  parseArgv,
931
924
  passesFilter,
932
- exnMessage,
933
925
  defaultTimeoutMs,
934
926
  raceWithTimeout,
935
927
  runEntry,
package/src/Collector.res CHANGED
@@ -11,11 +11,9 @@
11
11
 
12
12
  type status = Skipped | Runnable | Only
13
13
 
14
- type location = {
15
- file: string,
16
- line: int,
17
- column: int,
18
- }
14
+ // Source location shape is shared with `RunnerHook` so this collector can be
15
+ // exposed as a `RunnerHook.sink` without a conversion layer.
16
+ type location = RunnerHook.location
19
17
 
20
18
  type entry = {
21
19
  id: string,
@@ -210,3 +208,15 @@ let captureLocation = (skip: int): option<location> => {
210
208
  find(skip + 1)
211
209
  }
212
210
  }
211
+
212
+ // Expose this collector as a `RunnerHook.sink` so a driver can route `JestBind`
213
+ // registrations here. `captureLocation` is bound directly (no wrapper closure)
214
+ // so the stack depth `JestBind` skips when capturing the caller's location is
215
+ // unchanged from calling `captureLocation` inline.
216
+ let asSink: RunnerHook.sink = {
217
+ describe: pushDescribe,
218
+ todo: pushTodo,
219
+ captureLocation,
220
+ test: (~slice=?, ~location=?, ~timeout=?, name, body) =>
221
+ push(~slice?, ~location?, ~timeout?, name, body),
222
+ }
@@ -191,6 +191,15 @@ function captureLocation(skip) {
191
191
  };
192
192
  }
193
193
 
194
+ let asSink_test = push;
195
+
196
+ let asSink = {
197
+ describe: pushDescribe,
198
+ todo: pushTodo,
199
+ captureLocation: captureLocation,
200
+ test: asSink_test
201
+ };
202
+
194
203
  export {
195
204
  active,
196
205
  describeStack,
@@ -214,5 +223,6 @@ export {
214
223
  drain,
215
224
  parseFrame,
216
225
  captureLocation,
226
+ asSink,
217
227
  }
218
228
  /* No side effect */
@@ -26,8 +26,6 @@ external _readdir: (string, readdirOpts) => promise<array<dirent>> = "readdir"
26
26
  @module("node:fs") external _existsSync: string => bool = "existsSync"
27
27
  @module("node:path") external join: (string, string) => string = "join"
28
28
 
29
- let ignoreNames = ["node_modules", ".git", "dist", "lib", ".history"]
30
- let shouldIgnore = (name: string) => Array.includes(ignoreNames, name)
31
29
 
32
30
  // A directory carrying this sentinel file — and its whole subtree — is pruned
33
31
  // from the component scan, matching `Discovery`'s `.gwtignore` convention so
@@ -51,7 +49,7 @@ let rec walk = async (dir: string, acc: array<string>): array<string> => {
51
49
  let found = ref(acc)
52
50
  for i in 0 to entries->Array.length - 1 {
53
51
  let entry = entries->Array.getUnsafe(i)
54
- if !shouldIgnore(entry.name) {
52
+ if !ScanIgnore.shouldIgnore(entry.name) {
55
53
  let full = join(dir, entry.name)
56
54
  if entry._isDirectory() {
57
55
  let nested = await walk(full, [])
@@ -4,20 +4,9 @@ import * as Nodefs from "node:fs";
4
4
  import * as Nodepath from "node:path";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Promises from "node:fs/promises";
7
+ import * as ScanIgnore$ReventlessGwt from "./ScanIgnore.res.mjs";
7
8
  import * as ComponentMeta$ReventlessGwt from "./ComponentMeta.res.mjs";
8
9
 
9
- let ignoreNames = [
10
- "node_modules",
11
- ".git",
12
- "dist",
13
- "lib",
14
- ".history"
15
- ];
16
-
17
- function shouldIgnore(name) {
18
- return ignoreNames.includes(name);
19
- }
20
-
21
10
  let gwtIgnoreFile = ".gwtignore";
22
11
 
23
12
  function isPruned(entries) {
@@ -43,7 +32,7 @@ async function walk(dir, acc) {
43
32
  let found = acc;
44
33
  for (let i = 0, i_finish = entries.length; i < i_finish; ++i) {
45
34
  let entry = entries[i];
46
- if (!ignoreNames.includes(entry.name)) {
35
+ if (!ScanIgnore$ReventlessGwt.shouldIgnore(entry.name)) {
47
36
  let full = Nodepath.join(dir, entry.name);
48
37
  if (entry.isDirectory()) {
49
38
  let nested = await walk(full, []);
@@ -94,8 +83,6 @@ async function scan(pkgDirs) {
94
83
  }
95
84
 
96
85
  export {
97
- ignoreNames,
98
- shouldIgnore,
99
86
  gwtIgnoreFile,
100
87
  isPruned,
101
88
  isSrcResFile,
package/src/Discovery.res CHANGED
@@ -28,11 +28,8 @@ type stats = {
28
28
  @module("node:path") external isAbsolute: string => bool = "isAbsolute"
29
29
  @module("node:path") external resolve: string => string = "resolve"
30
30
 
31
- let ignoreNames = ["node_modules", ".git", "dist", "lib", ".history"]
32
- let shouldIgnore = (name: string) => Array.includes(ignoreNames, name)
33
-
34
31
  // A directory carrying this sentinel file — and its whole subtree — is pruned
35
- // from discovery. Unlike the `ignoreNames` dir-name list, this is filesystem
32
+ // from discovery. Unlike the `ScanIgnore.names` dir-name list, this is filesystem
36
33
  // state, so it excludes generated/vendored trees (e.g. codegen golden fixtures)
37
34
  // even when compiled `*_GWT.res.mjs` are present on disk.
38
35
  let gwtIgnoreFile = ".gwtignore"
@@ -58,7 +55,7 @@ let rec walk = async (dir: string, acc: array<string>): unit => {
58
55
  } else {
59
56
  for i in 0 to entries->Array.length - 1 {
60
57
  let entry = entries->Array.getUnsafe(i)
61
- if !shouldIgnore(entry.name) {
58
+ if !ScanIgnore.shouldIgnore(entry.name) {
62
59
  let full = join(dir, entry.name)
63
60
  if entry._isDirectory() {
64
61
  await walk(full, acc)
@@ -2,18 +2,7 @@
2
2
 
3
3
  import * as Nodepath from "node:path";
4
4
  import * as Promises from "node:fs/promises";
5
-
6
- let ignoreNames = [
7
- "node_modules",
8
- ".git",
9
- "dist",
10
- "lib",
11
- ".history"
12
- ];
13
-
14
- function shouldIgnore(name) {
15
- return ignoreNames.includes(name);
16
- }
5
+ import * as ScanIgnore$ReventlessGwt from "./ScanIgnore.res.mjs";
17
6
 
18
7
  let gwtIgnoreFile = ".gwtignore";
19
8
 
@@ -43,7 +32,7 @@ async function walk(dir, acc) {
43
32
  }
44
33
  for (let i = 0, i_finish = entries.length; i < i_finish; ++i) {
45
34
  let entry = entries[i];
46
- if (!ignoreNames.includes(entry.name)) {
35
+ if (!ScanIgnore$ReventlessGwt.shouldIgnore(entry.name)) {
47
36
  let full = Nodepath.join(dir, entry.name);
48
37
  if (entry.isDirectory()) {
49
38
  await walk(full, acc);
@@ -88,8 +77,6 @@ async function discover(roots) {
88
77
  }
89
78
 
90
79
  export {
91
- ignoreNames,
92
- shouldIgnore,
93
80
  gwtIgnoreFile,
94
81
  isPruned,
95
82
  isGwtTestFile,
@@ -0,0 +1,14 @@
1
+ // Typed bindings for the exception reflection in the companion `ExnReflect.mjs`.
2
+ // Inspecting an arbitrary thrown value's shape (`.message`, `RE_EXN_ID`/`_1`,
3
+ // `.stack`, a bare string, `throw null`) is untyped work ReScript can't
4
+ // pattern-match, so it lives whole in that JS module — no `%raw`, no `Obj.magic`
5
+ // here. Used by the runner's catch block to surface a test body's real error
6
+ // (e.g. a slice's `failwith("not implemented: …")`) instead of "unknown error".
7
+
8
+ // A human-readable message from any thrown value.
9
+ @module("./ExnReflect.mjs")
10
+ external extract: exn => string = "extractMessage"
11
+
12
+ // The `.stack` of a thrown JS `Error`, or "" when absent.
13
+ @module("./ExnReflect.mjs")
14
+ external stack: exn => string = "stackOf"
@@ -0,0 +1,17 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as ExnReflectMjs from "./ExnReflect.mjs";
4
+
5
+ function extract(prim) {
6
+ return ExnReflectMjs.extractMessage(prim);
7
+ }
8
+
9
+ function stack(prim) {
10
+ return ExnReflectMjs.stackOf(prim);
11
+ }
12
+
13
+ export {
14
+ extract,
15
+ stack,
16
+ }
17
+ /* ./ExnReflect.mjs Not a pure module */
@@ -0,0 +1,24 @@
1
+ // Untyped reflection over an arbitrary thrown value — the shapes ReScript can't
2
+ // pattern-match. Kept whole in JS so the ReScript side (ExnMessage.res) is a
3
+ // clean typed binding with no `%raw` or `Obj.magic`.
4
+
5
+ // A human-readable message: a `throw null`, a bare thrown string, a JS `Error`
6
+ // (`.message`), or a ReScript exception's constructor payload (`RE_EXN_ID` +
7
+ // `_1`, e.g. `failwith("…")` raises `Failure`).
8
+ export function extractMessage(e) {
9
+ if (e == null) return "unknown error";
10
+ if (typeof e === "string") return e;
11
+ if (typeof e.message === "string" && e.message.length) return e.message;
12
+ if (typeof e.RE_EXN_ID === "string") {
13
+ const id = e.RE_EXN_ID;
14
+ const dot = id.lastIndexOf(".");
15
+ const tag = dot >= 0 ? id.slice(dot + 1) : id;
16
+ return typeof e._1 === "string" && e._1.length ? e._1 : tag;
17
+ }
18
+ return "unknown error";
19
+ }
20
+
21
+ // The `.stack` of a thrown JS `Error`, or "" for anything without one.
22
+ export function stackOf(e) {
23
+ return (e && e.stack) || "";
24
+ }
@@ -100,7 +100,7 @@ let locateInSource = (mjsPath: string, label: string): option<Collector.location
100
100
  if col < 0 {
101
101
  find(i + 1)
102
102
  } else {
103
- Some({Collector.file: resPath, line: i + 1, column: col})
103
+ Some({RunnerHook.file: resPath, line: i + 1, column: col})
104
104
  }
105
105
  }
106
106
  find(0)
package/src/JestBind.res CHANGED
@@ -2,10 +2,10 @@
2
2
  //
3
3
  // GWT DSLs emit `Outcome.outcome` from every `then*` combinator. Test files
4
4
  // registered via `JestBind.test` / `JestBind.testPromise` have their body's
5
- // outcome translated into a Jest pass/fail, unless the standalone CLI runner
6
- // has activated its in-process `Collector` — in which case the call pushes
7
- // straight into the collector and skips Jest. This lets the same test file
8
- // run under either `pnpm jest` or `reventless-gwt run` without modification.
5
+ // outcome translated into a Jest pass/fail, unless an external runner has
6
+ // registered an in-process sink (`RunnerHook`) — in which case the call is
7
+ // routed to that sink and skips Jest. This lets the same test file run under
8
+ // either plain `jest` or an external GWT runner without modification.
9
9
  //
10
10
  // `~slice` is the slice/component name — each DSL threads `Spec.name` through
11
11
  // so failure hints read `Look at AddCategory.decide` rather than the generic
@@ -29,27 +29,25 @@ let assertOutcome = (~slice=?, outcome: Outcome.outcome): unit =>
29
29
  }
30
30
 
31
31
  let describe = (label: string, body: unit => unit) =>
32
- if Collector.isActive() {
33
- Collector.pushDescribe(label, body)
34
- } else {
35
- JestGlobals.describe(label, body)
32
+ switch RunnerHook.get() {
33
+ | Some(sink) => sink.describe(label, body)
34
+ | None => JestGlobals.describe(label, body)
36
35
  }
37
36
 
38
37
  // Pending-spec placeholder emitted by the codegen for slices with no upstream
39
- // specification. Registers a non-running, non-failing entry under both runners.
38
+ // specification. Registers a non-running, non-failing entry under both drivers.
40
39
  let todo = (label: string) =>
41
- if Collector.isActive() {
42
- Collector.pushTodo(label)
43
- } else {
44
- JestGlobals.todo(label)
40
+ switch RunnerHook.get() {
41
+ | Some(sink) => sink.todo(label)
42
+ | None => JestGlobals.todo(label)
45
43
  }
46
44
 
47
45
  let test = (~slice=?, name: string, body: unit => Outcome.outcome) =>
48
- if Collector.isActive() {
49
- let location = Collector.captureLocation(1)
50
- Collector.push(~slice?, ~location?, name, () => Promise.resolve(body()))
51
- } else {
52
- JestGlobals.testSync(name, () => assertOutcome(~slice?, body()))
46
+ switch RunnerHook.get() {
47
+ | Some(sink) =>
48
+ let location = sink.captureLocation(1)
49
+ sink.test(~slice?, ~location?, name, () => Promise.resolve(body()))
50
+ | None => JestGlobals.testSync(name, () => assertOutcome(~slice?, body()))
53
51
  }
54
52
 
55
53
  let testPromise = (
@@ -58,10 +56,11 @@ let testPromise = (
58
56
  ~timeout: option<int>=?,
59
57
  body: unit => promise<Outcome.outcome>,
60
58
  ) =>
61
- if Collector.isActive() {
62
- let location = Collector.captureLocation(1)
63
- Collector.push(~slice?, ~location?, ~timeout?, name, body)
64
- } else {
59
+ switch RunnerHook.get() {
60
+ | Some(sink) =>
61
+ let location = sink.captureLocation(1)
62
+ sink.test(~slice?, ~location?, ~timeout?, name, body)
63
+ | None =>
65
64
  switch timeout {
66
65
  | Some(t) =>
67
66
  JestGlobals.testWithTimeout(name, async () => assertOutcome(~slice?, await body()), t)
@@ -3,7 +3,7 @@
3
3
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
4
4
  import * as Hint$ReventlessGwt from "./Hint.res.mjs";
5
5
  import * as Outcome$ReventlessGwt from "./Outcome.res.mjs";
6
- import * as Collector$ReventlessGwt from "./Collector.res.mjs";
6
+ import * as RunnerHook$ReventlessGwt from "./RunnerHook.res.mjs";
7
7
 
8
8
  function assertOutcome(slice, outcome) {
9
9
  if (outcome.TAG === "Ok") {
@@ -15,8 +15,9 @@ function assertOutcome(slice, outcome) {
15
15
  }
16
16
 
17
17
  function describe(label, body) {
18
- if (Collector$ReventlessGwt.isActive()) {
19
- return Collector$ReventlessGwt.pushDescribe(label, body);
18
+ let sink = RunnerHook$ReventlessGwt.get();
19
+ if (sink !== undefined) {
20
+ return sink.describe(label, body);
20
21
  } else {
21
22
  globalThis.describe(label, body);
22
23
  return;
@@ -24,8 +25,9 @@ function describe(label, body) {
24
25
  }
25
26
 
26
27
  function todo(label) {
27
- if (Collector$ReventlessGwt.isActive()) {
28
- return Collector$ReventlessGwt.pushTodo(label);
28
+ let sink = RunnerHook$ReventlessGwt.get();
29
+ if (sink !== undefined) {
30
+ return sink.todo(label);
29
31
  } else {
30
32
  globalThis.test.todo(label);
31
33
  return;
@@ -33,15 +35,17 @@ function todo(label) {
33
35
  }
34
36
 
35
37
  function test(slice, name, body) {
36
- if (Collector$ReventlessGwt.isActive()) {
37
- let location = Collector$ReventlessGwt.captureLocation(1);
38
- return Collector$ReventlessGwt.push(slice, location, undefined, name, () => Promise.resolve(body()));
38
+ let sink = RunnerHook$ReventlessGwt.get();
39
+ if (sink !== undefined) {
40
+ let location = sink.captureLocation(1);
41
+ return sink.test(slice, location, undefined, name, () => Promise.resolve(body()));
39
42
  }
40
43
  globalThis.test(name, () => assertOutcome(slice, body()));
41
44
  }
42
45
 
43
46
  function testPromise(slice, name, timeout, body) {
44
- if (!Collector$ReventlessGwt.isActive()) {
47
+ let sink = RunnerHook$ReventlessGwt.get();
48
+ if (sink === undefined) {
45
49
  if (timeout !== undefined) {
46
50
  globalThis.test(name, async () => assertOutcome(slice, await body()), timeout);
47
51
  } else {
@@ -49,8 +53,8 @@ function testPromise(slice, name, timeout, body) {
49
53
  }
50
54
  return;
51
55
  }
52
- let location = Collector$ReventlessGwt.captureLocation(1);
53
- Collector$ReventlessGwt.push(slice, location, timeout, name, body);
56
+ let location = sink.captureLocation(1);
57
+ sink.test(slice, location, timeout, name, body);
54
58
  }
55
59
 
56
60
  export {
@@ -45,9 +45,9 @@ function Make(Projection) {
45
45
  let deleteSubState = (store, id, subId, getSubId) => {
46
46
  store[id] = Stdlib_Option.getOr(Stdlib_Option.map(store[id], states => states.filter(state => Primitive_object.notequal(getSubId(state), subId))), []);
47
47
  };
48
+ let handleActions = (actions, operations) => Projection$ReventlessCore.handleActions(undefined, actions, operations, Projection.subIdConfig);
48
49
  let update = async (store, events$p) => {
49
- let actions = events$p.map(event$p => Projection.project(event$p));
50
- await Projection$ReventlessCore.handleActions(actions, {
50
+ await handleActions(events$p.map(event$p => Projection.project(event$p)), {
51
51
  load: extra => Promise.resolve({
52
52
  TAG: "Ok",
53
53
  _0: states(store, extra)
@@ -163,7 +163,7 @@ function Make(Projection) {
163
163
  _0: undefined
164
164
  });
165
165
  }
166
- }, Projection.subIdConfig);
166
+ });
167
167
  if (Projection.subIdConfig !== undefined) {
168
168
  return Stdlib_Dict.mapValues(store, states => states.toSorted((state1, state2) => Primitive_string.compare(getSubId(state1), getSubId(state2))));
169
169
  } else {
@@ -11,6 +11,8 @@
11
11
 
12
12
  let tapSentinel = "@@RVLESS_EVT@@ "
13
13
 
14
+ @val external _envLogLevel: option<string> = "process.env.LOG_LEVEL"
15
+
14
16
  type lineClass =
15
17
  | Domain(JSON.t) // a tap line; the parsed `{event:"domainEvent",…}` payload
16
18
  | Ready // the Domain GraphQL server's "listening on …" line
@@ -110,6 +112,18 @@ let run = async (
110
112
  let pPort = ports->Array.getUnsafe(1)
111
113
  let dMcp = ports->Array.getUnsafe(2)
112
114
  let pMcp = ports->Array.getUnsafe(3)
115
+ // The gwt bin defaults LOG_LEVEL=silent so its OWN stdout stays pure NDJSON;
116
+ // the platform child is a separate process whose stdout we parse, so it needs
117
+ // real logs — the "listening on" line drives readiness and the rest becomes
118
+ // platformLog. Default it to the local platform's own verbosity (debug — same
119
+ // as `pnpm run serve`, which relies on reventless-local's Debug default), so
120
+ // projection/action debug lines surface in the runner log. An explicit
121
+ // LOG_LEVEL the developer set (anything but the bin's silent default) is
122
+ // honored, so `LOG_LEVEL=info` still quietens it.
123
+ let childLogLevel = switch _envLogLevel {
124
+ | Some(l) if l != "silent" && l != "" => l
125
+ | _ => "debug"
126
+ }
113
127
  let env = Dict.fromArray([
114
128
  ("REVENTLESS_EVENT_TAP", "ndjson"),
115
129
  ("REVENTLESS_LOCAL_BACKEND", backend),
@@ -118,11 +132,7 @@ let run = async (
118
132
  ("REVENTLESS_DOMAIN_MCP_PORT", Int.toString(dMcp)),
119
133
  ("REVENTLESS_PLATFORM_MCP_PORT", Int.toString(pMcp)),
120
134
  ("NODE_OPTIONS", "--disable-warning=ExperimentalWarning"),
121
- // The gwt bin defaults LOG_LEVEL=silent so its OWN stdout stays pure NDJSON;
122
- // the platform child is a separate process whose stdout we parse, so it needs
123
- // real logs — the "listening on" line drives readiness and the rest becomes
124
- // platformLog. Override the inherited silent.
125
- ("LOG_LEVEL", "info"),
135
+ ("LOG_LEVEL", childLogLevel),
126
136
  // The child's stdout is a pipe (we line-parse it), so the framework's sink
127
137
  // detection would auto-pick JSON (non-TTY). But every consumer of `onLog`
128
138
  // renders ANSI — the CLI forwards to the developer's terminal, the extension
@@ -80,6 +80,8 @@ async function run(roots, backend, fixedPortsOpt, callbacks) {
80
80
  let pPort = ports[1];
81
81
  let dMcp = ports[2];
82
82
  let pMcp = ports[3];
83
+ let l = process.env.LOG_LEVEL;
84
+ let childLogLevel = l !== undefined && l !== "silent" && l !== "" ? l : "debug";
83
85
  let env = Object.fromEntries([
84
86
  [
85
87
  "REVENTLESS_EVENT_TAP",
@@ -111,7 +113,7 @@ async function run(roots, backend, fixedPortsOpt, callbacks) {
111
113
  ],
112
114
  [
113
115
  "LOG_LEVEL",
114
- "info"
116
+ childLogLevel
115
117
  ],
116
118
  [
117
119
  "REVENTLESS_LOG_FORMAT",
@@ -31,7 +31,6 @@ external _readdir: (string, readdirOpts) => promise<array<dirent>> = "readdir"
31
31
  @module("node:path") external join: (string, string) => string = "join"
32
32
 
33
33
  let localPlatformDep = "@reventlessdev/reventless-local"
34
- let ignoreNames = ["node_modules", ".git", "dist", "lib", ".history"]
35
34
 
36
35
  // Pure predicate: given a package.json's text and whether src/Main.res.mjs exists,
37
36
  // decide whether this is a launchable platform package and surface its name +
@@ -116,7 +115,7 @@ let rec walk = async (dir: string, acc: array<platformPkg>): array<platformPkg>
116
115
  }
117
116
  for i in 0 to entries->Array.length - 1 {
118
117
  let entry = entries->Array.getUnsafe(i)
119
- if entry._isDirectory() && !Array.includes(ignoreNames, entry.name) {
118
+ if entry._isDirectory() && !ScanIgnore.shouldIgnore(entry.name) {
120
119
  let _ = await walk(join(dir, entry.name), acc)
121
120
  }
122
121
  }
@@ -5,17 +5,10 @@ import * as Nodepath from "node:path";
5
5
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Promises from "node:fs/promises";
8
+ import * as ScanIgnore$ReventlessGwt from "./ScanIgnore.res.mjs";
8
9
 
9
10
  let localPlatformDep = "@reventlessdev/reventless-local";
10
11
 
11
- let ignoreNames = [
12
- "node_modules",
13
- ".git",
14
- "dist",
15
- "lib",
16
- ".history"
17
- ];
18
-
19
12
  function matchPlatform(pkgJsonText, mainExists) {
20
13
  if (!mainExists) {
21
14
  return;
@@ -94,7 +87,7 @@ async function walk(dir, acc) {
94
87
  }
95
88
  for (let i = 0, i_finish = entries.length; i < i_finish; ++i) {
96
89
  let entry = entries[i];
97
- if (entry.isDirectory() && !ignoreNames.includes(entry.name)) {
90
+ if (entry.isDirectory() && !ScanIgnore$ReventlessGwt.shouldIgnore(entry.name)) {
98
91
  await walk(Nodepath.join(dir, entry.name), acc);
99
92
  }
100
93
  }
@@ -123,7 +116,6 @@ async function scan(roots) {
123
116
 
124
117
  export {
125
118
  localPlatformDep,
126
- ignoreNames,
127
119
  matchPlatform,
128
120
  inspectDir,
129
121
  walk,
@@ -59,9 +59,10 @@ function Make(Spec) {
59
59
  let deleteSubState = (store, id, subId, getSubId) => {
60
60
  store[id] = Stdlib_Option.getOr(Stdlib_Option.map(store[id], states => states.filter(state => Primitive_object.notequal(getSubId(state), subId))), []);
61
61
  };
62
+ let runActions = (actions, operations) => Projection$ReventlessCore.handleActions(undefined, actions, operations, Spec.subIdConfig);
62
63
  let update = async (store, events) => {
63
64
  let actions = events.map(ev => Projection.project(ev)).flat();
64
- await Projection$ReventlessCore.handleActions(actions, {
65
+ await runActions(actions, {
65
66
  load: extra => Promise.resolve({
66
67
  TAG: "Ok",
67
68
  _0: states(store, extra)
@@ -177,7 +178,7 @@ function Make(Spec) {
177
178
  _0: undefined
178
179
  });
179
180
  }
180
- }, Spec.subIdConfig);
181
+ });
181
182
  if (Spec.subIdConfig !== undefined) {
182
183
  return Stdlib_Dict.mapValues(store, states => states.toSorted((s1, s2) => Primitive_string.compare(getSubId(s1), getSubId(s2))));
183
184
  } else {
@@ -0,0 +1,38 @@
1
+ // Registration seam that lets an external runner intercept GWT test
2
+ // registration in-process instead of forwarding to Jest.
3
+ //
4
+ // By default no runner is registered, so `JestBind` forwards every
5
+ // `describe` / `test` / `testPromise` to Jest's globals — the workflow the
6
+ // example apps use (`npx jest`), unchanged. An external runner may register an
7
+ // in-process sink at startup; `JestBind` then routes each registration to the
8
+ // sink instead, so the same `*_GWT.res` file runs unmodified under either
9
+ // driver.
10
+
11
+ type location = {
12
+ file: string,
13
+ line: int,
14
+ column: int,
15
+ }
16
+
17
+ type sink = {
18
+ describe: (string, unit => unit) => unit,
19
+ todo: string => unit,
20
+ // Capture the caller's source location by walking the current stack, skipping
21
+ // `n` internal frames. Invoked directly from `JestBind` so the resolved frame
22
+ // is the user's test file (the skip count is tuned for that call site).
23
+ captureLocation: int => option<location>,
24
+ test: (
25
+ ~slice: string=?,
26
+ ~location: location=?,
27
+ ~timeout: int=?,
28
+ string,
29
+ unit => promise<Outcome.outcome>,
30
+ ) => unit,
31
+ }
32
+
33
+ // The currently-registered sink, if any. `None` → plain Jest behaviour.
34
+ let current: ref<option<sink>> = ref(None)
35
+
36
+ let register = (s: sink) => current := Some(s)
37
+ let reset = () => current := None
38
+ let get = () => current.contents
@@ -0,0 +1,26 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ let current = {
5
+ contents: undefined
6
+ };
7
+
8
+ function register(s) {
9
+ current.contents = s;
10
+ }
11
+
12
+ function reset() {
13
+ current.contents = undefined;
14
+ }
15
+
16
+ function get() {
17
+ return current.contents;
18
+ }
19
+
20
+ export {
21
+ current,
22
+ register,
23
+ reset,
24
+ get,
25
+ }
26
+ /* No side effect */
@@ -0,0 +1,11 @@
1
+ // Single source for the directory names pruned from every monorepo scan — test
2
+ // discovery, component scan, platform scan — and the equivalent chokidar globs
3
+ // for the watch. Previously duplicated verbatim across the three walkers and,
4
+ // divergently, as inline globs in `Watch`; that drift once let `dist/` writes
5
+ // drive a re-run loop.
6
+ let names = ["node_modules", ".git", "dist", "lib", ".history"]
7
+
8
+ let shouldIgnore = (name: string): bool => names->Array.includes(name)
9
+
10
+ // chokidar-style globs derived from the same list (the watch's ignore set).
11
+ let globs = names->Array.map(n => "**/" ++ n ++ "/**")
@@ -0,0 +1,23 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ let names = [
5
+ "node_modules",
6
+ ".git",
7
+ "dist",
8
+ "lib",
9
+ ".history"
10
+ ];
11
+
12
+ function shouldIgnore(name) {
13
+ return names.includes(name);
14
+ }
15
+
16
+ let globs = names.map(n => "**/" + n + "/**");
17
+
18
+ export {
19
+ names,
20
+ shouldIgnore,
21
+ globs,
22
+ }
23
+ /* globs Not a pure module */
package/src/Watch.res CHANGED
@@ -88,16 +88,10 @@ let start = (roots: array<string>, onChange: (event, string) => unit): watcher =
88
88
  roots,
89
89
  {
90
90
  ignoreInitial: true,
91
- // Keep in sync with Discovery's pruned dirs — writes under `dist/` or
91
+ // Shared with the scanners via `ScanIgnore` — writes under `dist/` or
92
92
  // `.history/` are build/editor output, not source, and a `dist/` write
93
93
  // could otherwise drive a re-run loop.
94
- ignored: [
95
- "**/node_modules/**",
96
- "**/lib/**",
97
- "**/.git/**",
98
- "**/dist/**",
99
- "**/.history/**",
100
- ],
94
+ ignored: ScanIgnore.globs,
101
95
  },
102
96
  )
103
97
  let debounced = debounce(120, onChange)
package/src/Watch.res.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Chokidar from "chokidar";
4
+ import * as ScanIgnore$ReventlessGwt from "./ScanIgnore.res.mjs";
4
5
 
5
6
  function isStructuralSource(event, path) {
6
7
  if (event === "Unlink") {
@@ -65,13 +66,7 @@ function debounce(wait, fn) {
65
66
  function start(roots, onChange) {
66
67
  let w = Chokidar.watch(roots, {
67
68
  ignoreInitial: true,
68
- ignored: [
69
- "**/node_modules/**",
70
- "**/lib/**",
71
- "**/.git/**",
72
- "**/dist/**",
73
- "**/.history/**"
74
- ]
69
+ ignored: ScanIgnore$ReventlessGwt.globs
75
70
  });
76
71
  let debounced = debounce(120, onChange);
77
72
  return w.on("add", p => debounced("Add", p)).on("change", p => debounced("Change", p)).on("unlink", p => debounced("Unlink", p));
@@ -0,0 +1,42 @@
1
+ // Pins `ExnMessage.extract` across the value shapes it inspects: ReScript
2
+ // exceptions (matched or reflected via `RE_EXN_ID`/`_1`), thrown JS `Error`s,
3
+ // bare thrown strings, and non-message values. The reflective cases are the
4
+ // reason the extractor exists, so they are asserted explicitly.
5
+
6
+ open JestGlobals
7
+
8
+ exception Boom(string)
9
+ exception NoPayload
10
+
11
+ // Fixtures the type system can't produce as a well-typed `exn`.
12
+ let jsError: exn = %raw(`new Error("kaboom")`)
13
+ let rawString: exn = %raw(`"just a string"`)
14
+ let nullish: exn = %raw(`null`)
15
+ let bareObject: exn = %raw(`({ some: "thing" })`)
16
+
17
+ describe("ExnMessage.extract", () => {
18
+ testSync("Failure carries its message (the failwith path)", () =>
19
+ expect(ExnMessage.extract(Failure("not implemented: X")))->toEqual("not implemented: X")
20
+ )
21
+ testSync("empty Failure falls back to the constructor tag", () =>
22
+ expect(ExnMessage.extract(Failure("")))->toEqual("Failure")
23
+ )
24
+ testSync("a custom exception with a string payload yields the payload", () =>
25
+ expect(ExnMessage.extract(Boom("custom boom")))->toEqual("custom boom")
26
+ )
27
+ testSync("a payload-less custom exception yields its constructor tag", () =>
28
+ expect(ExnMessage.extract(NoPayload))->toEqual("NoPayload")
29
+ )
30
+ testSync("a thrown JS Error yields its .message", () =>
31
+ expect(ExnMessage.extract(jsError))->toEqual("kaboom")
32
+ )
33
+ testSync("a bare thrown string is returned as-is", () =>
34
+ expect(ExnMessage.extract(rawString))->toEqual("just a string")
35
+ )
36
+ testSync("a value with no message/RE_EXN_ID falls back", () =>
37
+ expect(ExnMessage.extract(bareObject))->toEqual("unknown error")
38
+ )
39
+ testSync("null-ish falls back", () =>
40
+ expect(ExnMessage.extract(nullish))->toEqual("unknown error")
41
+ )
42
+ })
@@ -0,0 +1,64 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
4
+ import * as ExnMessage$ReventlessGwt from "../src/ExnMessage.res.mjs";
5
+
6
+ let Boom = /* @__PURE__ */Primitive_exceptions.create("ExnMessageTest-ReventlessGwt.Boom");
7
+
8
+ let NoPayload = /* @__PURE__ */Primitive_exceptions.create("ExnMessageTest-ReventlessGwt.NoPayload");
9
+
10
+ let jsError = (new Error("kaboom"));
11
+
12
+ let rawString = "just a string";
13
+
14
+ let nullish = null;
15
+
16
+ let bareObject = ({ some: "thing" });
17
+
18
+ globalThis.describe("ExnMessage.extract", () => {
19
+ globalThis.test("Failure carries its message (the failwith path)", () => {
20
+ globalThis.expect(ExnMessage$ReventlessGwt.extract({
21
+ RE_EXN_ID: "Failure",
22
+ _1: "not implemented: X"
23
+ })).toEqual("not implemented: X");
24
+ });
25
+ globalThis.test("empty Failure falls back to the constructor tag", () => {
26
+ globalThis.expect(ExnMessage$ReventlessGwt.extract({
27
+ RE_EXN_ID: "Failure",
28
+ _1: ""
29
+ })).toEqual("Failure");
30
+ });
31
+ globalThis.test("a custom exception with a string payload yields the payload", () => {
32
+ globalThis.expect(ExnMessage$ReventlessGwt.extract({
33
+ RE_EXN_ID: Boom,
34
+ _1: "custom boom"
35
+ })).toEqual("custom boom");
36
+ });
37
+ globalThis.test("a payload-less custom exception yields its constructor tag", () => {
38
+ globalThis.expect(ExnMessage$ReventlessGwt.extract({
39
+ RE_EXN_ID: NoPayload
40
+ })).toEqual("NoPayload");
41
+ });
42
+ globalThis.test("a thrown JS Error yields its .message", () => {
43
+ globalThis.expect(ExnMessage$ReventlessGwt.extract(jsError)).toEqual("kaboom");
44
+ });
45
+ globalThis.test("a bare thrown string is returned as-is", () => {
46
+ globalThis.expect(ExnMessage$ReventlessGwt.extract(rawString)).toEqual("just a string");
47
+ });
48
+ globalThis.test("a value with no message/RE_EXN_ID falls back", () => {
49
+ globalThis.expect(ExnMessage$ReventlessGwt.extract(bareObject)).toEqual("unknown error");
50
+ });
51
+ globalThis.test("null-ish falls back", () => {
52
+ globalThis.expect(ExnMessage$ReventlessGwt.extract(nullish)).toEqual("unknown error");
53
+ });
54
+ });
55
+
56
+ export {
57
+ Boom,
58
+ NoPayload,
59
+ jsError,
60
+ rawString,
61
+ nullish,
62
+ bareObject,
63
+ }
64
+ /* jsError Not a pure module */