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