@quenty/nevermore-cli 4.47.0 → 4.47.1

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 (42) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  3. package/dist/commands/batch-command/batch-deploy-command.js +4 -3
  4. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  5. package/dist/utils/build/upload.d.ts.map +1 -1
  6. package/dist/utils/build/upload.js +4 -1
  7. package/dist/utils/build/upload.js.map +1 -1
  8. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  9. package/dist/utils/job-context/cloud-job-context.js +5 -3
  10. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  11. package/dist/utils/open-cloud/open-cloud-client.d.ts +4 -4
  12. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  13. package/dist/utils/open-cloud/open-cloud-client.js +6 -5
  14. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  15. package/dist/utils/open-cloud/rate-limiter.d.ts +29 -2
  16. package/dist/utils/open-cloud/rate-limiter.d.ts.map +1 -1
  17. package/dist/utils/open-cloud/rate-limiter.js +98 -20
  18. package/dist/utils/open-cloud/rate-limiter.js.map +1 -1
  19. package/dist/utils/open-cloud/rate-limiter.test.js +64 -4
  20. package/dist/utils/open-cloud/rate-limiter.test.js.map +1 -1
  21. package/dist/utils/testing/parsers/batch-log-parser.test.js +1 -1
  22. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
  23. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  24. package/dist/utils/testing/runner/test-runner.js +8 -7
  25. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  26. package/dist/utils/testing/runner/test-runner.test.js +1 -1
  27. package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
  28. package/package.json +2 -2
  29. package/src/commands/batch-command/batch-deploy-command.ts +11 -3
  30. package/src/utils/build/upload.ts +5 -1
  31. package/src/utils/job-context/cloud-job-context.ts +24 -15
  32. package/src/utils/open-cloud/open-cloud-client.ts +25 -12
  33. package/src/utils/open-cloud/rate-limiter.test.ts +106 -4
  34. package/src/utils/open-cloud/rate-limiter.ts +150 -21
  35. package/src/utils/testing/parsers/batch-log-parser.test.ts +3 -3
  36. package/src/utils/testing/runner/test-runner.test.ts +1 -1
  37. package/src/utils/testing/runner/test-runner.ts +8 -9
  38. package/templates/batch-test-runner.lua +176 -172
  39. package/templates/game-template/src/scripts/Server/ServerMain.server.lua +3 -2
  40. package/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
  41. package/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
  42. package/tsconfig.tsbuildinfo +1 -1
@@ -1,6 +1,10 @@
1
- -- batch-test-runner.lua
2
- -- Runs all test scripts sequentially in a single execution task.
3
- -- PACKAGE_SLUGS_JSON placeholder is replaced with a JSON array of slug strings at runtime.
1
+ --[[
2
+ Runs every package's test script sequentially in one execution task, isolating
3
+ each package under ServerScriptService and cleaning up after it.
4
+
5
+ PACKAGE_SLUGS_JSON is replaced with a JSON array of slugs at runtime. Test
6
+ scripts are found by the `_BatchTest_<slug>` tag set by combine-test-places.lua.
7
+ ]]
4
8
 
5
9
  local CollectionService = game:GetService("CollectionService")
6
10
  local HttpService = game:GetService("HttpService")
@@ -8,56 +12,28 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
8
12
  local RunService = game:GetService("RunService")
9
13
  local ServerScriptService = game:GetService("ServerScriptService")
10
14
 
11
- -- stylua: ignore
12
- local packageSlugs: { string } = HttpService:JSONDecode([==[{{ PACKAGE_SLUGS_JSON }}]==])
13
-
14
- -- Discover test scripts via tags (set by combine-test-places.lua)
15
- local scriptSources = {}
16
- for _, slug in packageSlugs do
17
- local tagged = CollectionService:GetTagged(`_BatchTest_{slug}`)
18
- local inst = tagged[1]
19
- if inst then
20
- scriptSources[slug] = inst.Source
21
- else
22
- warn(`[BatchTest] Failed to find script source for {slug}`)
23
- end
24
- end
25
-
26
- -- Snapshot all package roots (keep references even when unparented)
27
- local allPackages = {}
28
- for _, child in ServerScriptService:GetChildren() do
29
- allPackages[child.Name] = child
30
- end
15
+ local BatchTestRunnerUtils = {}
31
16
 
32
- -- Tag on a NevermoreTestRunnerUtils results table. A test script returns one;
33
- -- one written before the convention returns nil, and a probe returns whatever
34
- -- it likes, so only a tagged table is read as results.
17
+ -- stylua: ignore
18
+ local PACKAGE_SLUGS: { PackageSlug } = HttpService:JSONDecode([==[{{ PACKAGE_SLUGS_JSON }}]==])
35
19
  local RESULTS_FORMAT = "nevermore-test-results@1"
36
-
37
- -- Module NevermoreTestRunnerUtils leaves a run's results in, regardless of what
38
- -- the script did with them. Most test scripts discard the return value
39
- -- (`if runTestsIfNeededAsync(root) then return end`), and without this their
40
- -- counts exist only in Jest's printed report — the first thing a long batch's
41
- -- log window drops. Read as a fallback, never in preference to what the script
42
- -- actually returned.
43
- --
44
- -- Named rather than required by path: this script is raw source with no loader,
45
- -- so it finds the module under the package it just ran and requires the
46
- -- instance. Requiring the same instance twice returns the same table, which is
47
- -- what makes this a mailbox and not a copy.
48
20
  local STATE_MODULE_NAME = "NevermoreTestRunnerState.global"
21
+ local COUNT_FIELDS = { "passed", "failed", "skipped", "total", "suitesPassed", "suitesFailed", "suitesTotal" }
49
22
 
50
- --[[
51
- The state module inside one package, or nil for a package that has no test
52
- runner in it at all.
53
- ]]
54
- local function findStateModule(package: Instance): ModuleScript?
55
- local found = package:FindFirstChild(STATE_MODULE_NAME, true)
23
+ type PackageSlug = string
24
+ type PackageLookupMap = { [PackageSlug]: Instance }
25
+ type ScriptSourceLookupMap = { [PackageSlug]: string }
56
26
 
57
- return if found and found:IsA("ModuleScript") then found else nil
58
- end
27
+ type TestResult = {
28
+ success: boolean,
29
+ slug: PackageSlug,
30
+ durationMs: number,
31
+ error: string?,
32
+ counts: TestCountsResult?,
33
+ ranJest: boolean?,
34
+ }
59
35
 
60
- type TestCounts = {
36
+ type TestCountsResult = {
61
37
  passed: number,
62
38
  failed: number,
63
39
  skipped: number,
@@ -67,92 +43,142 @@ type TestCounts = {
67
43
  suitesTotal: number,
68
44
  }
69
45
 
70
- type Result = {
71
- success: boolean,
72
- slug: string,
73
- durationMs: number,
74
- error: string?,
75
- -- Absent when the package's test script returned nothing structured, which
76
- -- is the only case where a reader still has to fall back to its logs.
77
- counts: TestCounts?,
78
- -- False for a smoke test, whose counts are zero because nothing counted them.
79
- -- Carried so a reader never has to guess whether zero means "no tests".
80
- ranJest: boolean?,
81
- }
46
+ function BatchTestRunnerUtils.getScriptSourceLookupMap(slugs: { PackageSlug }): ScriptSourceLookupMap
47
+ local scriptSourceLookupMap: ScriptSourceLookupMap = {}
48
+ for _, slug in slugs do
49
+ local inst = CollectionService:GetTagged(`_BatchTest_{slug}`)[1]
50
+ if inst then
51
+ scriptSourceLookupMap[slug] = (inst :: Script).Source
52
+ else
53
+ warn(`[BatchTest] Failed to find script source for {slug}`)
54
+ end
55
+ end
56
+ return table.freeze(scriptSourceLookupMap)
57
+ end
82
58
 
83
- -- Counts only. This summary is printed as one log line and read back by the
84
- -- CLI, so it stays short enough to survive whatever the engine does to a long
85
- -- line; the failure text a run also returns is per-package and already in that
86
- -- package's own log section.
87
- local function toCounts(returned: any): TestCounts?
88
- if type(returned) ~= "table" or returned.format ~= RESULTS_FORMAT then
89
- return nil
59
+ function BatchTestRunnerUtils.detachPackages(): (PackageLookupMap, () -> ())
60
+ local packageLookupMap: PackageLookupMap = {}
61
+ for _, child in ServerScriptService:GetChildren() do
62
+ packageLookupMap[child.Name] = child
63
+ child.Parent = nil
90
64
  end
65
+ table.freeze(packageLookupMap)
91
66
 
92
- local counts: { [string]: number } = {}
93
- for _, field in { "passed", "failed", "skipped", "total", "suitesPassed", "suitesFailed", "suitesTotal" } do
94
- if type(returned[field]) ~= "number" then
95
- return nil
67
+ local function restore()
68
+ for _, package in packageLookupMap do
69
+ package.Parent = ServerScriptService
96
70
  end
97
- counts[field] = returned[field]
98
71
  end
99
72
 
100
- return counts :: any
73
+ return packageLookupMap, restore
101
74
  end
102
75
 
103
- local function isStructuredFailure(returned: any): boolean
104
- return type(returned) == "table" and returned.format == RESULTS_FORMAT and returned.success ~= true
76
+ function BatchTestRunnerUtils.toSet<T>(list: { T }): { [T]: true }
77
+ local set: { [T]: true } = {}
78
+ for _, item in list do
79
+ set[item] = true
80
+ end
81
+ return table.freeze(set)
105
82
  end
106
83
 
107
- local results: { Result } = {}
84
+ function BatchTestRunnerUtils.isolatePackage(package: Instance): () -> ()
85
+ package.Parent = ServerScriptService
108
86
 
109
- for _, slug in packageSlugs do
110
- local scriptSource = scriptSources[slug]
111
- if not scriptSource then
112
- print("===BATCH_TEST_BEGIN " .. slug .. "===")
113
- warn(`[BatchTest] {slug}: No test script found for {slug}. Is it tagged with _BatchTest_{slug}?`)
114
- print("===BATCH_TEST_END " .. slug .. " FAIL 0===")
115
- table.insert(results, { slug = slug, success = false, durationMs = 0, error = "No test script found" })
116
- continue
117
- end
87
+ local preWorkspace = BatchTestRunnerUtils.toSet(workspace:GetChildren())
88
+ local preReplicatedStorage = BatchTestRunnerUtils.toSet(ReplicatedStorage:GetChildren())
118
89
 
119
- -- ISOLATE: unparent all, show only this package
120
- for _, package in allPackages do
90
+ return function()
121
91
  package.Parent = nil
92
+
93
+ for _, child in workspace:GetChildren() do
94
+ if not preWorkspace[child] and not child:IsA("Terrain") and not child:IsA("Camera") then
95
+ pcall(child.Destroy, child)
96
+ end
97
+ end
98
+ for _, child in ReplicatedStorage:GetChildren() do
99
+ if not preReplicatedStorage[child] then
100
+ pcall(child.Destroy, child)
101
+ end
102
+ end
103
+ for _, desc in package:GetDescendants() do
104
+ if not desc.Archivable and desc:IsA("ModuleScript") and desc.Name == "loader" then
105
+ pcall(desc.Destroy, desc)
106
+ end
107
+ end
122
108
  end
123
- allPackages[slug].Parent = ServerScriptService
109
+ end
124
110
 
125
- -- Snapshot services for cleanup
126
- local preWorkspace = {}
127
- for _, child in workspace:GetChildren() do
128
- preWorkspace[child] = true
111
+ function BatchTestRunnerUtils.resetTestResultsMailbox(package: Instance): any
112
+ local found = package:FindFirstChild(STATE_MODULE_NAME, true)
113
+ if not (found and found:IsA("ModuleScript")) then
114
+ return nil
129
115
  end
130
- local preRunService = {}
131
- for _, child in ReplicatedStorage:GetChildren() do
132
- preRunService[child] = true
116
+
117
+ local state = (require :: any)(found)
118
+ state.results = nil
119
+ return state
120
+ end
121
+
122
+ function BatchTestRunnerUtils.isFailedTestResult(returned: any): boolean
123
+ return type(returned) == "table" and returned.format == RESULTS_FORMAT and returned.success ~= true
124
+ end
125
+
126
+ function BatchTestRunnerUtils.readTestResultCounts(returned: any): TestCountsResult?
127
+ if type(returned) ~= "table" or returned.format ~= RESULTS_FORMAT then
128
+ return nil
133
129
  end
134
130
 
135
- -- Yield before the BEGIN marker so deferred callbacks triggered by
136
- -- reparenting flush before the marker enters the log stream. This
137
- -- prevents Open Cloud log reordering from placing leaked output
138
- -- inside this package's section.
139
- RunService.Heartbeat:Wait()
131
+ local counts: { [string]: number } = {}
132
+ for _, field in COUNT_FIELDS do
133
+ if type(returned[field]) ~= "number" then
134
+ return nil
135
+ end
136
+ counts[field] = returned[field]
137
+ end
140
138
 
141
- print("===BATCH_TEST_BEGIN " .. slug .. "===")
139
+ return table.freeze(counts) :: any
140
+ end
142
141
 
143
- -- Cleared before the run, so a package that leaves nothing behind cannot be
144
- -- credited with the previous package's counts — worse than having none.
145
- local stateModule = findStateModule(allPackages[slug])
146
- local state: any = if stateModule then (require :: any)(stateModule) else nil
147
- if state then
148
- state.results = nil
142
+ function BatchTestRunnerUtils.createTestResult(
143
+ slug: PackageSlug,
144
+ testOk: boolean,
145
+ returned: any,
146
+ state: any,
147
+ durationMs: number
148
+ ): TestResult
149
+ local mailboxResults = if state then state.results else nil
150
+ local reported = if testOk and BatchTestRunnerUtils.readTestResultCounts(returned) then returned else mailboxResults
151
+
152
+ local counts = if testOk then BatchTestRunnerUtils.readTestResultCounts(reported) else nil
153
+ local success = testOk and not BatchTestRunnerUtils.isFailedTestResult(reported)
154
+ local failureReason: string? = nil
155
+ if not testOk then
156
+ failureReason = tostring(returned)
157
+ elseif not success then
158
+ failureReason = tostring((reported :: any).error or "the test runner reported the run as failed")
149
159
  end
150
160
 
151
- -- Measure pcall execution time only. The surrounding cleanup/yield cost
152
- -- is amortized across all packages and would otherwise dominate fast tests.
161
+ return table.freeze({
162
+ slug = slug,
163
+ success = success,
164
+ durationMs = durationMs,
165
+ error = failureReason,
166
+ counts = counts,
167
+ ranJest = if counts then (reported :: any).ranJest == true else nil,
168
+ })
169
+ end
170
+
171
+ function BatchTestRunnerUtils.reportMissingScriptResult(slug: PackageSlug): TestResult
172
+ print("===BATCH_TEST_BEGIN " .. slug .. "===")
173
+ warn(`[BatchTest] {slug}: No test script found for {slug}. Is it tagged with _BatchTest_{slug}?`)
174
+ print("===BATCH_TEST_END " .. slug .. " FAIL 0===")
175
+ return table.freeze({ slug = slug, success = false, durationMs = 0, error = "No test script found" })
176
+ end
177
+
178
+ function BatchTestRunnerUtils.runScriptTimed(slug: PackageSlug, scriptSource: string): (boolean, any, number)
153
179
  local startClock = os.clock()
154
180
 
155
- local testOk, returned = pcall(function()
181
+ local ok, returned = pcall(function()
156
182
  local fn, compileErr = loadstring(scriptSource, slug)
157
183
  if not fn then
158
184
  error("Compile error: " .. tostring(compileErr))
@@ -161,82 +187,60 @@ for _, slug in packageSlugs do
161
187
  end)
162
188
 
163
189
  local durationMs = math.floor((os.clock() - startClock) * 1000 + 0.5)
190
+ return ok, returned, durationMs
191
+ end
192
+
193
+ function BatchTestRunnerUtils.runPackage(
194
+ packageLookupMap: PackageLookupMap,
195
+ slug: PackageSlug,
196
+ scriptSource: string?
197
+ ): TestResult
198
+ if not scriptSource then
199
+ return BatchTestRunnerUtils.reportMissingScriptResult(slug)
200
+ end
201
+
202
+ local package = packageLookupMap[slug]
203
+ local restore = BatchTestRunnerUtils.isolatePackage(package)
164
204
 
165
- -- Yield before the END marker for the same reason: leaked deferred
166
- -- callbacks may fire during fn() and their output must land before
167
- -- the marker in the log stream.
168
205
  RunService.Heartbeat:Wait()
206
+ print("===BATCH_TEST_BEGIN " .. slug .. "===")
169
207
 
170
- -- CLEANUP: restore service state
171
- allPackages[slug].Parent = nil
172
- for _, child in workspace:GetChildren() do
173
- if not preWorkspace[child] and not child:IsA("Terrain") and not child:IsA("Camera") then
174
- pcall(child.Destroy, child)
175
- end
176
- end
177
- for _, child in ReplicatedStorage:GetChildren() do
178
- if not preRunService[child] then
179
- pcall(child.Destroy, child)
180
- end
181
- end
182
- -- Destroy injected LoaderLinks (non-archivable, created by LoaderLinkCreator)
183
- for _, desc in allPackages[slug]:GetDescendants() do
184
- if not desc.Archivable and desc:IsA("ModuleScript") and desc.Name == "loader" then
185
- pcall(desc.Destroy, desc)
186
- end
187
- end
208
+ local state = BatchTestRunnerUtils.resetTestResultsMailbox(package)
209
+ local testOk, returned, durationMs = BatchTestRunnerUtils.runScriptTimed(slug, scriptSource)
210
+
211
+ RunService.Heartbeat:Wait()
212
+ restore()
188
213
 
189
- -- Flush lingering deferred callbacks. Heavy packages (Binder, ServiceBag)
190
- -- schedule deferred callbacks via CollectionService that survive cleanup.
191
- -- Yielding across multiple heartbeat frames is more reliable than a fixed
192
- -- time wait, since each frame drains its deferred queue.
193
214
  for _ = 1, 3 do
194
215
  RunService.Heartbeat:Wait()
195
216
  end
196
217
 
197
- -- What the script returned, or what the runner left behind when the script
198
- -- returned nothing. Both are the same table whenever both exist, so
199
- -- preferring the returned one costs nothing and keeps a script that does
200
- -- return its results independent of the state module.
201
- local published = if state then state.results else nil
202
- local reported = if testOk and toCounts(returned) then returned else published
203
-
204
- -- A test script that does not throw has still failed if the results say so,
205
- -- which is the whole reason they are read at all: a failing suite used to
206
- -- announce itself by erroring, and an error carries no counts.
207
- local counts = if testOk then toCounts(reported) else nil
208
- local success = testOk and not isStructuredFailure(reported)
209
- local failureReason: string? = nil
210
- if not testOk then
211
- failureReason = tostring(returned)
212
- elseif not success then
213
- failureReason = tostring((reported :: any).error or "the test runner reported the run as failed")
218
+ local result = BatchTestRunnerUtils.createTestResult(slug, testOk, returned, state, durationMs)
219
+ if result.error then
220
+ warn("[BatchTest] " .. slug .. ": " .. result.error)
214
221
  end
222
+ print(
223
+ "===BATCH_TEST_END " .. slug .. (if result.success then " PASS " else " FAIL ") .. tostring(durationMs) .. "==="
224
+ )
215
225
 
216
- if failureReason then
217
- warn("[BatchTest] " .. slug .. ": " .. failureReason)
218
- end
219
- print("===BATCH_TEST_END " .. slug .. (if success then " PASS " else " FAIL ") .. tostring(durationMs) .. "===")
220
- table.insert(results, {
221
- slug = slug,
222
- success = success,
223
- durationMs = durationMs,
224
- error = failureReason,
225
- counts = counts,
226
- ranJest = if counts then (reported :: any).ranJest == true else nil,
227
- })
226
+ return result
228
227
  end
229
228
 
230
- -- Restore all packages
231
- for _, package in allPackages do
232
- package.Parent = ServerScriptService
229
+ function BatchTestRunnerUtils.printResultsSummary(results: { TestResult })
230
+ print("===BATCH_TEST_SUMMARY===")
231
+ print(HttpService:JSONEncode(results))
232
+ end
233
+
234
+ local scriptSourceLookupMap = BatchTestRunnerUtils.getScriptSourceLookupMap(PACKAGE_SLUGS)
235
+ local packageLookupMap, restorePackages = BatchTestRunnerUtils.detachPackages()
236
+
237
+ local results: { TestResult } = {}
238
+ for _, slug in PACKAGE_SLUGS do
239
+ table.insert(results, BatchTestRunnerUtils.runPackage(packageLookupMap, slug, scriptSourceLookupMap[slug]))
233
240
  end
241
+ table.freeze(results)
234
242
 
235
- -- Printed as well as returned. The print is what a person reads in the log and
236
- -- what an older CLI parses; the return value is the copy that cannot be
237
- -- truncated, since an oversize return fails the task outright rather than
238
- -- arriving short. Counts only, so it stays far inside that limit.
239
- print("===BATCH_TEST_SUMMARY===")
240
- print(HttpService:JSONEncode(results))
243
+ restorePackages()
244
+ BatchTestRunnerUtils.printResultsSummary(results)
241
245
 
242
246
  return results
@@ -10,8 +10,9 @@ local require = require(loader).bootstrapGame(root)
10
10
 
11
11
  local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
12
12
 
13
- if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root.game) then
14
- return
13
+ local results = NevermoreTestRunnerUtils.runTestsIfNeededAsync(root.game)
14
+ if results then
15
+ return results
15
16
  end
16
17
 
17
18
  local serviceBag = require("ServiceBag").new()
@@ -8,6 +8,7 @@ local loader = root:FindFirstChild("LoaderUtils", true).Parent
8
8
  local require = require(loader).bootstrapGame(root)
9
9
 
10
10
  local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
11
- if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
12
- return
11
+ local results = NevermoreTestRunnerUtils.runTestsIfNeededAsync(root)
12
+ if results then
13
+ return results
13
14
  end
@@ -8,8 +8,9 @@ local loader = root:FindFirstChild("LoaderUtils", true).Parent
8
8
  local require = require(loader).bootstrapGame(root)
9
9
 
10
10
  local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
11
- if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
12
- return
11
+ local results = NevermoreTestRunnerUtils.runTestsIfNeededAsync(root)
12
+ if results then
13
+ return results
13
14
  end
14
15
 
15
16
  local serviceBag = require("ServiceBag").new()