@quenty/nevermore-cli 4.43.3 → 4.44.0

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 (154) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/build-scripts/{combine-test-places.luau → combine-test-places.lua} +3 -3
  3. package/build-scripts/{transform-inject-deploy-metadata.luau → transform-inject-deploy-metadata.lua} +3 -3
  4. package/dist/commands/batch-command/batch-deploy-command.js +4 -4
  5. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  6. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  7. package/dist/commands/batch-command/batch-test-command.js +76 -19
  8. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  9. package/dist/commands/deploy-command/deploy-init-utils.d.ts.map +1 -1
  10. package/dist/commands/deploy-command/deploy-init-utils.js +7 -1
  11. package/dist/commands/deploy-command/deploy-init-utils.js.map +1 -1
  12. package/dist/commands/deploy-command/index.js +1 -1
  13. package/dist/commands/deploy-command/index.js.map +1 -1
  14. package/dist/commands/init-command/init-game-command.js +2 -2
  15. package/dist/commands/init-command/init-game-command.js.map +1 -1
  16. package/dist/commands/install-package-command.d.ts +14 -0
  17. package/dist/commands/install-package-command.d.ts.map +1 -1
  18. package/dist/commands/install-package-command.js +65 -12
  19. package/dist/commands/install-package-command.js.map +1 -1
  20. package/dist/utils/deploy/deploy-github-columns.d.ts +12 -2
  21. package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
  22. package/dist/utils/deploy/deploy-github-columns.js +11 -4
  23. package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
  24. package/dist/utils/deploy/deploy-metadata.js +1 -1
  25. package/dist/utils/deploy/deploy-metadata.js.map +1 -1
  26. package/dist/utils/job-context/base-job-context.d.ts +2 -2
  27. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  28. package/dist/utils/job-context/base-job-context.js +1 -1
  29. package/dist/utils/job-context/base-job-context.js.map +1 -1
  30. package/dist/utils/job-context/batch-script-job-context.d.ts +37 -2
  31. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  32. package/dist/utils/job-context/batch-script-job-context.js +59 -22
  33. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  34. package/dist/utils/job-context/cloud-job-context.d.ts +2 -2
  35. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  36. package/dist/utils/job-context/cloud-job-context.js +10 -5
  37. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  38. package/dist/utils/job-context/cloud-job-context.test.d.ts +7 -0
  39. package/dist/utils/job-context/cloud-job-context.test.d.ts.map +1 -0
  40. package/dist/utils/job-context/cloud-job-context.test.js +91 -0
  41. package/dist/utils/job-context/cloud-job-context.test.js.map +1 -0
  42. package/dist/utils/job-context/job-context.d.ts +70 -2
  43. package/dist/utils/job-context/job-context.d.ts.map +1 -1
  44. package/dist/utils/job-context/local-job-context.d.ts +2 -2
  45. package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
  46. package/dist/utils/job-context/local-job-context.js +4 -2
  47. package/dist/utils/job-context/local-job-context.js.map +1 -1
  48. package/dist/utils/job-context/local-job-context.test.d.ts +7 -0
  49. package/dist/utils/job-context/local-job-context.test.d.ts.map +1 -0
  50. package/dist/utils/job-context/local-job-context.test.js +68 -0
  51. package/dist/utils/job-context/local-job-context.test.js.map +1 -0
  52. package/dist/utils/open-cloud/open-cloud-client.d.ts +50 -7
  53. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  54. package/dist/utils/open-cloud/open-cloud-client.js +49 -13
  55. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  56. package/dist/utils/open-cloud/open-cloud-client.test.js +125 -1
  57. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  58. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts +22 -0
  59. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts.map +1 -0
  60. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js +202 -0
  61. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js.map +1 -0
  62. package/dist/utils/package-manager-utils.d.ts +29 -0
  63. package/dist/utils/package-manager-utils.d.ts.map +1 -0
  64. package/dist/utils/package-manager-utils.js +120 -0
  65. package/dist/utils/package-manager-utils.js.map +1 -0
  66. package/dist/utils/package-manager-utils.test.d.ts +2 -0
  67. package/dist/utils/package-manager-utils.test.d.ts.map +1 -0
  68. package/dist/utils/package-manager-utils.test.js +121 -0
  69. package/dist/utils/package-manager-utils.test.js.map +1 -0
  70. package/dist/utils/testing/batch-run.e2e.test.d.ts +17 -0
  71. package/dist/utils/testing/batch-run.e2e.test.d.ts.map +1 -0
  72. package/dist/utils/testing/batch-run.e2e.test.js +343 -0
  73. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -0
  74. package/dist/utils/testing/batch-test-runner-template.test.d.ts +11 -0
  75. package/dist/utils/testing/batch-test-runner-template.test.d.ts.map +1 -0
  76. package/dist/utils/testing/batch-test-runner-template.test.js +38 -0
  77. package/dist/utils/testing/batch-test-runner-template.test.js.map +1 -0
  78. package/dist/utils/testing/log-fetch-stats.d.ts +40 -0
  79. package/dist/utils/testing/log-fetch-stats.d.ts.map +1 -0
  80. package/dist/utils/testing/log-fetch-stats.js +18 -0
  81. package/dist/utils/testing/log-fetch-stats.js.map +1 -0
  82. package/dist/utils/testing/log-fetch-stats.test.d.ts +2 -0
  83. package/dist/utils/testing/log-fetch-stats.test.d.ts.map +1 -0
  84. package/dist/utils/testing/log-fetch-stats.test.js +41 -0
  85. package/dist/utils/testing/log-fetch-stats.test.js.map +1 -0
  86. package/dist/utils/testing/parsers/batch-log-parser.d.ts +126 -1
  87. package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
  88. package/dist/utils/testing/parsers/batch-log-parser.js +440 -103
  89. package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
  90. package/dist/utils/testing/parsers/batch-log-parser.test.js +494 -1
  91. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
  92. package/dist/utils/testing/parsers/batch-log-renderer.d.ts +79 -0
  93. package/dist/utils/testing/parsers/batch-log-renderer.d.ts.map +1 -0
  94. package/dist/utils/testing/parsers/batch-log-renderer.js +241 -0
  95. package/dist/utils/testing/parsers/batch-log-renderer.js.map +1 -0
  96. package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts +2 -0
  97. package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts.map +1 -0
  98. package/dist/utils/testing/parsers/batch-log-renderer.test.js +202 -0
  99. package/dist/utils/testing/parsers/batch-log-renderer.test.js.map +1 -0
  100. package/dist/utils/testing/runner/combined-project-generator.js +1 -1
  101. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  102. package/dist/utils/testing/runner/test-runner.d.ts +24 -0
  103. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  104. package/dist/utils/testing/runner/test-runner.js +166 -6
  105. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  106. package/dist/utils/testing/runner/test-runner.test.js +228 -2
  107. package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
  108. package/dist/utils/testing/structured-test-results.d.ts +77 -0
  109. package/dist/utils/testing/structured-test-results.d.ts.map +1 -0
  110. package/dist/utils/testing/structured-test-results.js +140 -0
  111. package/dist/utils/testing/structured-test-results.js.map +1 -0
  112. package/dist/utils/testing/structured-test-results.test.d.ts +7 -0
  113. package/dist/utils/testing/structured-test-results.test.d.ts.map +1 -0
  114. package/dist/utils/testing/structured-test-results.test.js +179 -0
  115. package/dist/utils/testing/structured-test-results.test.js.map +1 -0
  116. package/package.json +6 -6
  117. package/src/commands/batch-command/batch-deploy-command.ts +4 -4
  118. package/src/commands/batch-command/batch-test-command.ts +94 -19
  119. package/src/commands/deploy-command/deploy-init-utils.ts +7 -1
  120. package/src/commands/deploy-command/index.ts +1 -1
  121. package/src/commands/init-command/init-game-command.ts +2 -2
  122. package/src/commands/install-package-command.ts +107 -14
  123. package/src/utils/deploy/deploy-github-columns.ts +14 -4
  124. package/src/utils/deploy/deploy-metadata.ts +1 -1
  125. package/src/utils/job-context/base-job-context.ts +3 -2
  126. package/src/utils/job-context/batch-script-job-context.ts +98 -17
  127. package/src/utils/job-context/cloud-job-context.test.ts +112 -0
  128. package/src/utils/job-context/cloud-job-context.ts +12 -6
  129. package/src/utils/job-context/job-context.ts +71 -2
  130. package/src/utils/job-context/local-job-context.test.ts +89 -0
  131. package/src/utils/job-context/local-job-context.ts +7 -3
  132. package/src/utils/open-cloud/open-cloud-client.test.ts +155 -1
  133. package/src/utils/open-cloud/open-cloud-client.ts +94 -18
  134. package/src/utils/open-cloud/open-cloud-logs.characterization.test.ts +277 -0
  135. package/src/utils/package-manager-utils.test.ts +174 -0
  136. package/src/utils/package-manager-utils.ts +148 -0
  137. package/src/utils/testing/batch-run.e2e.test.ts +425 -0
  138. package/src/utils/testing/batch-test-runner-template.test.ts +62 -0
  139. package/src/utils/testing/log-fetch-stats.test.ts +48 -0
  140. package/src/utils/testing/log-fetch-stats.ts +55 -0
  141. package/src/utils/testing/parsers/batch-log-parser.test.ts +660 -1
  142. package/src/utils/testing/parsers/batch-log-parser.ts +721 -116
  143. package/src/utils/testing/parsers/batch-log-renderer.test.ts +330 -0
  144. package/src/utils/testing/parsers/batch-log-renderer.ts +329 -0
  145. package/src/utils/testing/runner/combined-project-generator.ts +1 -1
  146. package/src/utils/testing/runner/test-runner.test.ts +332 -2
  147. package/src/utils/testing/runner/test-runner.ts +231 -9
  148. package/src/utils/testing/structured-test-results.test.ts +267 -0
  149. package/src/utils/testing/structured-test-results.ts +199 -0
  150. package/templates/batch-test-runner.lua +242 -0
  151. package/tsconfig.tsbuildinfo +1 -1
  152. package/templates/batch-test-runner.luau +0 -137
  153. /package/build-scripts/{smoke-test-server.luau → smoke-test-server.lua} +0 -0
  154. /package/build-scripts/{transform-rojo-merge-place.luau → transform-rojo-merge-place.lua} +0 -0
@@ -1,4 +1,5 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OutputHelper } from '@quenty/cli-output-helpers';
2
3
 
3
4
  import {
4
5
  countTracebacks,
@@ -6,6 +7,34 @@ import {
6
7
  parseBatchTestLogs,
7
8
  } from './batch-log-parser.js';
8
9
 
10
+ /** Collect what the parser said out loud, so silence can be asserted on. */
11
+ function captureWarnings(): () => string[] {
12
+ const warnings: string[] = [];
13
+ vi.spyOn(OutputHelper, 'warn').mockImplementation((message: string) => {
14
+ warnings.push(message);
15
+ });
16
+ vi.spyOn(OutputHelper, 'info').mockImplementation(() => {});
17
+ vi.spyOn(OutputHelper, 'startGroup').mockImplementation(() => {});
18
+ vi.spyOn(OutputHelper, 'endGroup').mockImplementation(() => {});
19
+ return () => warnings;
20
+ }
21
+
22
+ /**
23
+ * Everything the parser reported, with its level — for findings that are a
24
+ * group rather than a single warning.
25
+ */
26
+ function captureDiagnostics(): Array<{ level: string; message: string }> {
27
+ return [];
28
+ }
29
+
30
+ afterEach(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ const RETURNED_COUNTS =
35
+ '"counts":{"passed":273,"failed":2,"skipped":1,"total":276,' +
36
+ '"suitesPassed":8,"suitesFailed":1,"suitesTotal":9},"ranJest":true';
37
+
9
38
  const SLUG_MAP = new Map([['egghunt2026', 'egghunt2026']]);
10
39
 
11
40
  function buildLogs(section: string): string {
@@ -151,6 +180,565 @@ describe('parseBatchTestLogs', () => {
151
180
  expect(result?.testCounts?.failed).toBe(0);
152
181
  expect(result?.tracebackCount).toBe(1);
153
182
  });
183
+
184
+ it('prefers the counts the runner returned over the ones in the section', () => {
185
+ // The whole point of returning them: these are the numbers a truncated log
186
+ // window cannot take away.
187
+ const logs = [
188
+ '===BATCH_TEST_BEGIN egghunt2026===',
189
+ '(the section this run printed is gone)',
190
+ '===BATCH_TEST_END egghunt2026 PASS 1000===',
191
+ '===BATCH_TEST_SUMMARY===',
192
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000,"counts":' +
193
+ '{"passed":275,"failed":0,"skipped":2,"total":277,' +
194
+ '"suitesPassed":9,"suitesFailed":0,"suitesTotal":9}}]',
195
+ ].join('\n');
196
+
197
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
198
+
199
+ expect(result?.testCounts).toEqual({ passed: 275, failed: 0, total: 277 });
200
+ });
201
+
202
+ it('ignores malformed counts instead of reporting zeroes', () => {
203
+ const logs = buildLogs('Tests: 275 passed, 275 total').replace(
204
+ '"durationMs":1000',
205
+ '"durationMs":1000,"counts":{"passed":"lots"}'
206
+ );
207
+
208
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
209
+
210
+ expect(result?.testCounts).toEqual({ passed: 275, failed: 0, total: 275 });
211
+ });
212
+
213
+ it('reports why the runner failed a package rather than guessing', () => {
214
+ // A failing suite used to reach here as a Luau error. It arrives as a
215
+ // verdict now, and saying "Luau error" about it sends you hunting for one.
216
+ const logs = [
217
+ '===BATCH_TEST_BEGIN egghunt2026===',
218
+ 'Tests: 2 failed, 273 passed, 275 total',
219
+ '===BATCH_TEST_END egghunt2026 FAIL 1000===',
220
+ '===BATCH_TEST_SUMMARY===',
221
+ '[{"slug":"egghunt2026","success":false,"durationMs":1000,' +
222
+ '"error":"[NevermoreTestRunner] 2 test(s) and 0 test suite(s) failed",' +
223
+ '"counts":{"passed":273,"failed":2,"skipped":0,"total":275,' +
224
+ '"suitesPassed":8,"suitesFailed":0,"suitesTotal":9}}]',
225
+ ].join('\n');
226
+
227
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
228
+
229
+ expect(result?.success).toBe(false);
230
+ expect(result?.error).toContain('[NevermoreTestRunner] 2 test(s)');
231
+ expect(result?.error).not.toContain('Luau error');
232
+ expect(result?.testCounts).toEqual({ passed: 273, failed: 2, total: 275 });
233
+ });
234
+
235
+ it('does not believe a pass reported alongside failed tests', () => {
236
+ const logs = [
237
+ '===BATCH_TEST_BEGIN egghunt2026===',
238
+ 'Tests: 275 passed, 275 total',
239
+ '===BATCH_TEST_END egghunt2026 PASS 1000===',
240
+ '===BATCH_TEST_SUMMARY===',
241
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000,"counts":' +
242
+ '{"passed":273,"failed":2,"skipped":0,"total":275,' +
243
+ '"suitesPassed":8,"suitesFailed":1,"suitesTotal":9}}]',
244
+ ].join('\n');
245
+
246
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
247
+
248
+ expect(result?.success).toBe(false);
249
+ expect(result?.error).toContain('2 failed test(s)');
250
+ });
251
+
252
+ it('still reads a summary from a package that returned no counts', () => {
253
+ // A test script written before results were returned.
254
+ const result = parseBatchTestLogs(
255
+ buildLogs('Tests: 275 passed, 275 total'),
256
+ SLUG_MAP
257
+ ).get('egghunt2026');
258
+
259
+ expect(result?.success).toBe(true);
260
+ expect(result?.testCounts).toEqual({ passed: 275, failed: 0, total: 275 });
261
+ });
262
+
263
+ it('records where each package’s counts came from', () => {
264
+ // The structured channel and log scraping produce identical-looking output,
265
+ // so a channel that quietly stopped flowing is invisible without this.
266
+ const scraped = parseBatchTestLogs(
267
+ buildLogs('Tests: 275 passed, 275 total'),
268
+ SLUG_MAP
269
+ ).get('egghunt2026');
270
+ expect(scraped?.countsSource).toBe('scraped');
271
+ expect(scraped?.testResults).toBeUndefined();
272
+
273
+ const returned = parseBatchTestLogs(
274
+ buildLogs('Tests: 2 failed, 273 passed, 276 total').replace(
275
+ '"durationMs":1000',
276
+ `"durationMs":1000,${RETURNED_COUNTS}`
277
+ ),
278
+ SLUG_MAP
279
+ ).get('egghunt2026');
280
+ expect(returned?.countsSource).toBe('returned');
281
+ expect(returned?.testResults).toMatchObject({
282
+ ranJest: true,
283
+ passed: 273,
284
+ failed: 2,
285
+ skipped: 1,
286
+ total: 276,
287
+ suitesFailed: 1,
288
+ });
289
+ });
290
+
291
+ it('reports a fallback to log scraping as one group naming each package', () => {
292
+ // Per-package lines said the same thing once per package, which in a real
293
+ // batch buried everything else under 78 restatements of one finding.
294
+ const diagnostics = captureDiagnostics();
295
+
296
+ parseBatchTestLogs(buildLogs('Tests: 275 passed, 275 total'), SLUG_MAP, {
297
+ onDiagnostic: (level, message) => diagnostics.push({ level, message }),
298
+ });
299
+
300
+ const group = diagnostics.find((d) => d.level === 'group');
301
+ expect(group?.message).toContain('fell back to log scraping');
302
+ expect(group?.message).toContain('1 of 1');
303
+ expect(diagnostics.some((d) => d.message.includes('egghunt2026'))).toBe(
304
+ true
305
+ );
306
+ expect(diagnostics.filter((d) => d.level === 'endgroup')).toHaveLength(1);
307
+ });
308
+
309
+ it('says nothing about a fallback when every package returned its counts', () => {
310
+ const warnings = captureWarnings();
311
+
312
+ parseBatchTestLogs(
313
+ buildLogs('Tests: 2 failed, 273 passed, 276 total').replace(
314
+ '"durationMs":1000',
315
+ `"durationMs":1000,${RETURNED_COUNTS}`
316
+ ),
317
+ SLUG_MAP
318
+ );
319
+
320
+ expect(warnings().some((w) => w.includes('returned no test results'))).toBe(
321
+ false
322
+ );
323
+ });
324
+
325
+ it('passes every package whose returned counts are clean', () => {
326
+ // The regression this guards: a runner consulted jest-lua's inverted
327
+ // AggregatedResult.success and failed all four packages in a batch, three of
328
+ // which had every test passing.
329
+ const warnings = captureWarnings();
330
+ const fourPackages = new Map([
331
+ ['access', 'access'],
332
+ ['animations', 'animations'],
333
+ ['binder', 'binder'],
334
+ ['blend', 'blend'],
335
+ ]);
336
+
337
+ const clean = (slug: string, passed: number) =>
338
+ [
339
+ `===BATCH_TEST_BEGIN ${slug}===`,
340
+ `Tests: ${passed} passed, ${passed} total`,
341
+ `===BATCH_TEST_END ${slug} PASS 100===`,
342
+ ].join('\n');
343
+
344
+ const entry = (slug: string, passed: number) =>
345
+ `{"slug":"${slug}","success":true,"durationMs":100,"ranJest":true,` +
346
+ `"counts":{"passed":${passed},"failed":0,"skipped":0,"total":${passed},` +
347
+ `"suitesPassed":1,"suitesFailed":0,"suitesTotal":1}}`;
348
+
349
+ const logs = [
350
+ clean('access', 311),
351
+ clean('animations', 8),
352
+ clean('binder', 99),
353
+ clean('blend', 3),
354
+ '===BATCH_TEST_SUMMARY===',
355
+ `[${entry('access', 311)},${entry('animations', 8)},` +
356
+ `${entry('binder', 99)},${entry('blend', 3)}]`,
357
+ ].join('\n');
358
+
359
+ const parsed = parseBatchTestLogs(logs, fourPackages);
360
+
361
+ expect([...parsed.values()].filter((r) => r.success)).toHaveLength(4);
362
+ expect(parsed.get('access')?.testCounts).toEqual({
363
+ passed: 311,
364
+ failed: 0,
365
+ total: 311,
366
+ });
367
+ expect(warnings()).toHaveLength(0);
368
+ });
369
+
370
+ it('warns when a failure’s own counts show nothing failed', () => {
371
+ const warnings = captureWarnings();
372
+
373
+ const logs = [
374
+ '===BATCH_TEST_BEGIN egghunt2026===',
375
+ 'Tests: 311 passed, 311 total',
376
+ '===BATCH_TEST_END egghunt2026 FAIL 100===',
377
+ '===BATCH_TEST_SUMMARY===',
378
+ '[{"slug":"egghunt2026","success":false,"durationMs":100,"ranJest":true,' +
379
+ '"error":"[NevermoreTestRunner] something","counts":{"passed":311,' +
380
+ '"failed":0,"skipped":0,"total":311,"suitesPassed":19,"suitesFailed":0,' +
381
+ '"suitesTotal":19}}]',
382
+ ].join('\n');
383
+
384
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
385
+
386
+ // The verdict stands — it is not the parser's to overturn — but it is loud.
387
+ expect(result?.success).toBe(false);
388
+ expect(
389
+ warnings().some((w) => w.includes('own counts show nothing failed'))
390
+ ).toBe(true);
391
+ });
392
+
393
+ it('accepts returned counts in place of a jest report the log lost', () => {
394
+ // The CI case this whole channel exists for: dozens of packages share one log
395
+ // window, so a section keeps its END marker long after its jest summary was
396
+ // dropped. Requiring the report there failed the package anyway.
397
+ const logs = [
398
+ '===BATCH_TEST_BEGIN egghunt2026===',
399
+ ' ✓ one line of output that survived',
400
+ '===BATCH_TEST_END egghunt2026 PASS 1000===',
401
+ '===BATCH_TEST_SUMMARY===',
402
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000,"ranJest":true,' +
403
+ '"counts":{"passed":311,"failed":0,"skipped":0,"total":311,' +
404
+ '"suitesPassed":19,"suitesFailed":0,"suitesTotal":19}}]',
405
+ ].join('\n');
406
+
407
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
408
+
409
+ expect(result?.success).toBe(true);
410
+ expect(result?.error).toBeUndefined();
411
+ expect(result?.testCounts).toEqual({ passed: 311, failed: 0, total: 311 });
412
+ });
413
+
414
+ it('still demands a jest report from a package that returned no counts', () => {
415
+ // Nothing structural to stand in for it, so the stand-in stays.
416
+ const logs = [
417
+ '===BATCH_TEST_BEGIN egghunt2026===',
418
+ ' ✓ one line of output that survived',
419
+ '===BATCH_TEST_END egghunt2026 PASS 1000===',
420
+ '===BATCH_TEST_SUMMARY===',
421
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000}]',
422
+ ].join('\n');
423
+
424
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
425
+
426
+ expect(result?.success).toBe(false);
427
+ expect(result?.error).toContain('nothing proves any test ran');
428
+ });
429
+
430
+ it('judges a package whose section was lost entirely on its counts', () => {
431
+ const warnings = captureWarnings();
432
+ const twoPackages = new Map([
433
+ ['alpha', 'alpha'],
434
+ ['beta', 'beta'],
435
+ ]);
436
+ const logs = [
437
+ '===BATCH_TEST_BEGIN alpha===',
438
+ 'Tests: 10 passed, 10 total',
439
+ '===BATCH_TEST_END alpha PASS 10===',
440
+ '===BATCH_TEST_SUMMARY===',
441
+ '[{"slug":"alpha","success":true,"durationMs":10,"ranJest":true,' +
442
+ '"counts":{"passed":10,"failed":0,"skipped":0,"total":10,"suitesPassed":1,' +
443
+ '"suitesFailed":0,"suitesTotal":1}},' +
444
+ '{"slug":"beta","success":true,"durationMs":20,"ranJest":true,' +
445
+ '"counts":{"passed":7,"failed":0,"skipped":0,"total":7,"suitesPassed":1,' +
446
+ '"suitesFailed":0,"suitesTotal":1}}]',
447
+ ].join('\n');
448
+
449
+ const results = parseBatchTestLogs(logs, twoPackages);
450
+
451
+ // beta's section never arrived; its counts did.
452
+ expect(results.get('beta')?.success).toBe(true);
453
+ expect(results.get('beta')?.testCounts).toEqual({
454
+ passed: 7,
455
+ failed: 0,
456
+ total: 7,
457
+ });
458
+ // Narrower than a verdict with a section: nothing checked it for tracebacks.
459
+ expect(
460
+ warnings().some((w) => w.includes('not checked for Luau tracebacks'))
461
+ ).toBe(true);
462
+ });
463
+
464
+ it('still fails a lost section when no counts came back either', () => {
465
+ const twoPackages = new Map([
466
+ ['alpha', 'alpha'],
467
+ ['beta', 'beta'],
468
+ ]);
469
+ const logs = [
470
+ '===BATCH_TEST_BEGIN alpha===',
471
+ 'Tests: 10 passed, 10 total',
472
+ '===BATCH_TEST_END alpha PASS 10===',
473
+ '===BATCH_TEST_SUMMARY===',
474
+ '[{"slug":"alpha","success":true},{"slug":"beta","success":true}]',
475
+ ].join('\n');
476
+
477
+ const results = parseBatchTestLogs(logs, twoPackages);
478
+
479
+ expect(results.get('beta')?.success).toBe(false);
480
+ expect(results.get('beta')?.error).toContain(
481
+ 'no output could be attributed'
482
+ );
483
+ });
484
+
485
+ it('warns when the returned counts and the log disagree', () => {
486
+ // The exact failure that shipped once: the runner read the wrong level of
487
+ // jest's result, so every count came back zero and read as a clean run.
488
+ const warnings = captureWarnings();
489
+
490
+ const result = parseBatchTestLogs(
491
+ buildLogs('Tests: 275 passed, 275 total').replace(
492
+ '"durationMs":1000',
493
+ '"durationMs":1000,"counts":{"passed":0,"failed":0,"skipped":0,' +
494
+ '"total":0,"suitesPassed":0,"suitesFailed":0,"suitesTotal":0},"ranJest":true'
495
+ ),
496
+ SLUG_MAP
497
+ );
498
+
499
+ expect(
500
+ warnings().some((w) =>
501
+ w.includes('returned counts disagree with the log')
502
+ )
503
+ ).toBe(true);
504
+ expect(result.get('egghunt2026')?.countsSource).toBe('returned');
505
+ });
506
+
507
+ it('fails a section containing a traceback', () => {
508
+ const logs = buildLogs(
509
+ [
510
+ 'Tests: 155 passed, 155 total',
511
+ 'PlayerBadgeHelper: attempt to index nil',
512
+ 'Stack Begin',
513
+ "Script 'PlayerBadgeHelper', Line 365",
514
+ 'Stack End',
515
+ ].join('\n')
516
+ );
517
+
518
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
519
+
520
+ expect(result?.success).toBe(false);
521
+ expect(result?.error).toContain('Luau traceback(s)');
522
+ });
523
+
524
+ it('takes the whole inner string as the slug when END has no PASS or FAIL', () => {
525
+ const logs = [
526
+ '===BATCH_TEST_BEGIN egghunt2026===',
527
+ 'Tests: 5 passed, 5 total',
528
+ '===BATCH_TEST_END egghunt2026===',
529
+ '===BATCH_TEST_SUMMARY===',
530
+ '[{"slug":"egghunt2026","success":true}]',
531
+ ].join('\n');
532
+
533
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
534
+
535
+ expect(result?.success).toBe(true);
536
+ expect(result?.testCounts).toEqual({ passed: 5, failed: 0, total: 5 });
537
+ });
538
+
539
+ it('records no duration for an END that carries none', () => {
540
+ const logs = [
541
+ '===BATCH_TEST_BEGIN egghunt2026===',
542
+ 'Tests: 5 passed, 5 total',
543
+ '===BATCH_TEST_END egghunt2026 PASS===',
544
+ '===BATCH_TEST_SUMMARY===',
545
+ '[{"slug":"egghunt2026","success":true}]',
546
+ ].join('\n');
547
+
548
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
549
+
550
+ expect(result?.durationMs).toBeUndefined();
551
+ });
552
+
553
+ it('falls back to the END marker duration when the summary entry omits one', () => {
554
+ const logs = [
555
+ '===BATCH_TEST_BEGIN egghunt2026===',
556
+ 'Tests: 5 passed, 5 total',
557
+ '===BATCH_TEST_END egghunt2026 PASS 4242===',
558
+ '===BATCH_TEST_SUMMARY===',
559
+ '[{"slug":"egghunt2026","success":true}]',
560
+ ].join('\n');
561
+
562
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
563
+
564
+ expect(result?.durationMs).toBe(4242);
565
+ });
566
+
567
+ it('lets only the first BEGIN-less END claim the output above it', () => {
568
+ const twoPackages = new Map([
569
+ ['alpha', 'alpha'],
570
+ ['beta', 'beta'],
571
+ ]);
572
+ const logs = [
573
+ 'Tests: 10 passed, 10 total',
574
+ '===BATCH_TEST_END alpha PASS 10===',
575
+ 'Tests: 20 passed, 20 total',
576
+ '===BATCH_TEST_END beta PASS 20===',
577
+ '===BATCH_TEST_SUMMARY===',
578
+ '[{"slug":"alpha","success":true},{"slug":"beta","success":true}]',
579
+ ].join('\n');
580
+
581
+ const results = parseBatchTestLogs(logs, twoPackages);
582
+
583
+ expect(results.get('alpha')?.testCounts?.total).toBe(10);
584
+ expect(results.get('beta')?.testCounts).toBeUndefined();
585
+ expect(results.get('beta')?.logs).toBe('');
586
+ expect(results.get('beta')?.error).toContain(
587
+ 'no output could be attributed'
588
+ );
589
+ });
590
+
591
+ it('distinguishes a reported Luau error from an absent summary entry', () => {
592
+ const twoPackages = new Map([
593
+ ['alpha', 'alpha'],
594
+ ['beta', 'beta'],
595
+ ]);
596
+ const logs = [
597
+ '===BATCH_TEST_BEGIN alpha===',
598
+ 'Tests: 10 passed, 10 total',
599
+ '===BATCH_TEST_END alpha FAIL 10===',
600
+ '===BATCH_TEST_BEGIN beta===',
601
+ 'Tests: 20 passed, 20 total',
602
+ '===BATCH_TEST_END beta PASS 20===',
603
+ '===BATCH_TEST_SUMMARY===',
604
+ '[{"slug":"alpha","success":false,"error":"boom"}]',
605
+ ].join('\n');
606
+
607
+ const results = parseBatchTestLogs(logs, twoPackages);
608
+
609
+ expect(results.get('alpha')?.success).toBe(false);
610
+ // The reason the runner gave, not a guess at one: a failing suite reaches
611
+ // the summary as a verdict now, and calling it a Luau error sends whoever
612
+ // reads it hunting for a traceback that was never thrown.
613
+ expect(results.get('alpha')?.error).toBe('boom');
614
+ expect(results.get('beta')?.success).toBe(false);
615
+ expect(results.get('beta')?.error).toBe(
616
+ 'this package is missing from the batch summary'
617
+ );
618
+ });
619
+
620
+ it('reads every package as missing when the summary payload is not JSON', () => {
621
+ const twoPackages = new Map([
622
+ ['alpha', 'alpha'],
623
+ ['beta', 'beta'],
624
+ ]);
625
+ const logs = [
626
+ '===BATCH_TEST_BEGIN alpha===',
627
+ 'Tests: 10 passed, 10 total',
628
+ '===BATCH_TEST_END alpha PASS 10===',
629
+ '===BATCH_TEST_BEGIN beta===',
630
+ 'Tests: 20 passed, 20 total',
631
+ '===BATCH_TEST_END beta PASS 20===',
632
+ '===BATCH_TEST_SUMMARY===',
633
+ 'not json at all',
634
+ ].join('\n');
635
+
636
+ const results = parseBatchTestLogs(logs, twoPackages);
637
+
638
+ expect(results.get('alpha')?.error).toBe(
639
+ 'this package is missing from the batch summary'
640
+ );
641
+ expect(results.get('beta')?.error).toBe(
642
+ 'this package is missing from the batch summary'
643
+ );
644
+ });
645
+
646
+ it('reads every package as missing when no summary arrived at all', () => {
647
+ const twoPackages = new Map([
648
+ ['alpha', 'alpha'],
649
+ ['beta', 'beta'],
650
+ ]);
651
+ const logs = [
652
+ '===BATCH_TEST_BEGIN alpha===',
653
+ 'Tests: 10 passed, 10 total',
654
+ '===BATCH_TEST_END alpha PASS 10===',
655
+ '===BATCH_TEST_BEGIN beta===',
656
+ 'Tests: 20 passed, 20 total',
657
+ '===BATCH_TEST_END beta PASS 20===',
658
+ ].join('\n');
659
+
660
+ const results = parseBatchTestLogs(logs, twoPackages);
661
+
662
+ expect(results.get('alpha')?.error).toBe(
663
+ 'this package is missing from the batch summary'
664
+ );
665
+ expect(results.get('beta')?.error).toBe(
666
+ 'this package is missing from the batch summary'
667
+ );
668
+ });
669
+
670
+ it('joins several failure reasons with a semicolon', () => {
671
+ const logs = [
672
+ '===BATCH_TEST_BEGIN egghunt2026===',
673
+ 'Tests: 2 failed, 8 passed, 10 total',
674
+ 'Test Suites: 1 failed, 3 total',
675
+ '===BATCH_TEST_END egghunt2026 FAIL 10===',
676
+ '===BATCH_TEST_SUMMARY===',
677
+ '[{"slug":"egghunt2026","success":false}]',
678
+ ].join('\n');
679
+
680
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
681
+
682
+ expect(result?.error).toBe(
683
+ 'the batch runner reported this as failed; ' +
684
+ '1 test suite(s) failed; 2 test(s) failed'
685
+ );
686
+ });
687
+
688
+ it('treats a marker missing its trailing delimiter as ordinary content', () => {
689
+ const logs = [
690
+ '===BATCH_TEST_BEGIN egghunt2026===',
691
+ '===BATCH_TEST_BEGIN egghunt2026',
692
+ 'Tests: 5 passed, 5 total',
693
+ '===BATCH_TEST_END egghunt2026 PASS 5===',
694
+ '===BATCH_TEST_SUMMARY===',
695
+ '[{"slug":"egghunt2026","success":true}]',
696
+ ].join('\n');
697
+
698
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
699
+
700
+ expect(result?.logs).toContain('===BATCH_TEST_BEGIN egghunt2026');
701
+ expect(result?.testCounts).toEqual({ passed: 5, failed: 0, total: 5 });
702
+ });
703
+
704
+ it('hands unattributable output to the first package only', () => {
705
+ const threePackages = new Map([
706
+ ['alpha', 'alpha'],
707
+ ['beta', 'beta'],
708
+ ['gamma', 'gamma'],
709
+ ]);
710
+ const logs = [
711
+ 'PlayerBadgeHelper: attempt to index nil',
712
+ '===BATCH_TEST_SUMMARY===',
713
+ '[{"slug":"alpha","success":true},{"slug":"beta","success":true},' +
714
+ '{"slug":"gamma","success":true}]',
715
+ ].join('\n');
716
+
717
+ const results = parseBatchTestLogs(logs, threePackages);
718
+
719
+ expect(results.get('alpha')?.logs).toContain('PlayerBadgeHelper');
720
+ expect(results.get('beta')?.logs).toBe('');
721
+ expect(results.get('gamma')?.logs).toBe('');
722
+ });
723
+
724
+ it('fails an empty section for having no jest report, with no counts', () => {
725
+ const logs = [
726
+ '===BATCH_TEST_BEGIN egghunt2026===',
727
+ '===BATCH_TEST_END egghunt2026 PASS 0===',
728
+ '===BATCH_TEST_SUMMARY===',
729
+ '[{"slug":"egghunt2026","success":true}]',
730
+ ].join('\n');
731
+
732
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
733
+
734
+ expect(result?.success).toBe(false);
735
+ expect(result?.logs).toBe('');
736
+ expect(result?.error).toBe(
737
+ 'no jest report in output — nothing proves any test ran'
738
+ );
739
+ // No counts were seen, so none are reported — an empty section is not zero.
740
+ expect(result?.testCounts).toBeUndefined();
741
+ });
154
742
  });
155
743
 
156
744
  describe('findSummaryEntries', () => {
@@ -184,3 +772,74 @@ describe('countTracebacks', () => {
184
772
  expect(countTracebacks('')).toBe(0);
185
773
  });
186
774
  });
775
+
776
+ describe('parseBatchTestLogs log fetch diagnostics', () => {
777
+ afterEach(() => {
778
+ vi.restoreAllMocks();
779
+ });
780
+
781
+ const SLUG_MAP = new Map([['egghunt2026', 'egghunt2026']]);
782
+
783
+ /** A summary with no section for the package, i.e. output that went missing. */
784
+ const LOST_SECTION_LOGS = [
785
+ 'Tests: 275 passed, 275 total',
786
+ '===BATCH_TEST_SUMMARY===',
787
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000}]',
788
+ ].join('\n');
789
+
790
+ it('reports what the fetch collected on a package with no attributable output', () => {
791
+ // "276989 chars received" alone cannot say whether the run printed little or
792
+ // the engine dropped most of it. The caps that would explain it are counted
793
+ // in entries, pages and requests, so the failure names all of them.
794
+ const result = parseBatchTestLogs(LOST_SECTION_LOGS, SLUG_MAP, {
795
+ logFetchStats: {
796
+ requests: 3,
797
+ pages: 2,
798
+ entries: 4,
799
+ messages: 7627,
800
+ chars: 276989,
801
+ },
802
+ }).get('egghunt2026');
803
+
804
+ expect(result?.success).toBe(false);
805
+ expect(result?.error).toContain('no output could be attributed');
806
+ expect(result?.error).toContain('3 API call(s)');
807
+ expect(result?.error).toContain('2 page(s)');
808
+ expect(result?.error).toContain('4 log entry(ies)');
809
+ expect(result?.error).toContain('7627 message(s)');
810
+ });
811
+
812
+ it('still reports the volume it can measure with no fetch stats', () => {
813
+ // A local run has no request count, which must not cost the line and char
814
+ // counts the parser can always take off the text it was handed.
815
+ const result = parseBatchTestLogs(LOST_SECTION_LOGS, SLUG_MAP).get(
816
+ 'egghunt2026'
817
+ );
818
+
819
+ expect(result?.error).toContain('no output could be attributed');
820
+ expect(result?.error).toContain('3 line(s) received');
821
+ expect(result?.error).toContain('no fetch stats');
822
+ });
823
+
824
+ it('reports the fetch in the unattributed-output warning too', () => {
825
+ const getWarnings = captureWarnings();
826
+
827
+ parseBatchTestLogs(LOST_SECTION_LOGS, SLUG_MAP, {
828
+ logFetchStats: {
829
+ requests: 1,
830
+ pages: 1,
831
+ entries: 1,
832
+ messages: 7627,
833
+ chars: 276989,
834
+ },
835
+ });
836
+
837
+ expect(
838
+ getWarnings().some(
839
+ (warning) =>
840
+ warning.includes('could not attribute') &&
841
+ warning.includes('1 log entry(ies), 7627 message(s)')
842
+ )
843
+ ).toBe(true);
844
+ });
845
+ });