@quenty/nevermore-cli 4.43.4 → 4.45.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 (147) 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/deploy-init.d.ts.map +1 -1
  13. package/dist/commands/deploy-command/deploy-init.js +24 -7
  14. package/dist/commands/deploy-command/deploy-init.js.map +1 -1
  15. package/dist/commands/deploy-command/index.js +1 -1
  16. package/dist/commands/deploy-command/index.js.map +1 -1
  17. package/dist/utils/deploy/deploy-github-columns.d.ts +12 -2
  18. package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
  19. package/dist/utils/deploy/deploy-github-columns.js +11 -4
  20. package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
  21. package/dist/utils/deploy/deploy-metadata.js +1 -1
  22. package/dist/utils/deploy/deploy-metadata.js.map +1 -1
  23. package/dist/utils/job-context/base-job-context.d.ts +2 -2
  24. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  25. package/dist/utils/job-context/base-job-context.js +1 -1
  26. package/dist/utils/job-context/base-job-context.js.map +1 -1
  27. package/dist/utils/job-context/batch-script-job-context.d.ts +37 -2
  28. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  29. package/dist/utils/job-context/batch-script-job-context.js +59 -22
  30. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  31. package/dist/utils/job-context/cloud-job-context.d.ts +2 -2
  32. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  33. package/dist/utils/job-context/cloud-job-context.js +10 -5
  34. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  35. package/dist/utils/job-context/cloud-job-context.test.d.ts +7 -0
  36. package/dist/utils/job-context/cloud-job-context.test.d.ts.map +1 -0
  37. package/dist/utils/job-context/cloud-job-context.test.js +91 -0
  38. package/dist/utils/job-context/cloud-job-context.test.js.map +1 -0
  39. package/dist/utils/job-context/job-context.d.ts +70 -2
  40. package/dist/utils/job-context/job-context.d.ts.map +1 -1
  41. package/dist/utils/job-context/local-job-context.d.ts +2 -2
  42. package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
  43. package/dist/utils/job-context/local-job-context.js +4 -2
  44. package/dist/utils/job-context/local-job-context.js.map +1 -1
  45. package/dist/utils/job-context/local-job-context.test.d.ts +7 -0
  46. package/dist/utils/job-context/local-job-context.test.d.ts.map +1 -0
  47. package/dist/utils/job-context/local-job-context.test.js +68 -0
  48. package/dist/utils/job-context/local-job-context.test.js.map +1 -0
  49. package/dist/utils/open-cloud/open-cloud-client.d.ts +50 -7
  50. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  51. package/dist/utils/open-cloud/open-cloud-client.js +49 -13
  52. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  53. package/dist/utils/open-cloud/open-cloud-client.test.js +125 -1
  54. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  55. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts +22 -0
  56. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts.map +1 -0
  57. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js +202 -0
  58. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js.map +1 -0
  59. package/dist/utils/package-manager-utils.d.ts.map +1 -1
  60. package/dist/utils/package-manager-utils.js +32 -1
  61. package/dist/utils/package-manager-utils.js.map +1 -1
  62. package/dist/utils/package-manager-utils.test.js +25 -0
  63. package/dist/utils/package-manager-utils.test.js.map +1 -1
  64. package/dist/utils/testing/batch-run.e2e.test.d.ts +17 -0
  65. package/dist/utils/testing/batch-run.e2e.test.d.ts.map +1 -0
  66. package/dist/utils/testing/batch-run.e2e.test.js +343 -0
  67. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -0
  68. package/dist/utils/testing/batch-test-runner-template.test.d.ts +11 -0
  69. package/dist/utils/testing/batch-test-runner-template.test.d.ts.map +1 -0
  70. package/dist/utils/testing/batch-test-runner-template.test.js +38 -0
  71. package/dist/utils/testing/batch-test-runner-template.test.js.map +1 -0
  72. package/dist/utils/testing/log-fetch-stats.d.ts +40 -0
  73. package/dist/utils/testing/log-fetch-stats.d.ts.map +1 -0
  74. package/dist/utils/testing/log-fetch-stats.js +18 -0
  75. package/dist/utils/testing/log-fetch-stats.js.map +1 -0
  76. package/dist/utils/testing/log-fetch-stats.test.d.ts +2 -0
  77. package/dist/utils/testing/log-fetch-stats.test.d.ts.map +1 -0
  78. package/dist/utils/testing/log-fetch-stats.test.js +41 -0
  79. package/dist/utils/testing/log-fetch-stats.test.js.map +1 -0
  80. package/dist/utils/testing/parsers/batch-log-parser.d.ts +126 -1
  81. package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
  82. package/dist/utils/testing/parsers/batch-log-parser.js +440 -103
  83. package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
  84. package/dist/utils/testing/parsers/batch-log-parser.test.js +494 -1
  85. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
  86. package/dist/utils/testing/parsers/batch-log-renderer.d.ts +79 -0
  87. package/dist/utils/testing/parsers/batch-log-renderer.d.ts.map +1 -0
  88. package/dist/utils/testing/parsers/batch-log-renderer.js +241 -0
  89. package/dist/utils/testing/parsers/batch-log-renderer.js.map +1 -0
  90. package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts +2 -0
  91. package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts.map +1 -0
  92. package/dist/utils/testing/parsers/batch-log-renderer.test.js +202 -0
  93. package/dist/utils/testing/parsers/batch-log-renderer.test.js.map +1 -0
  94. package/dist/utils/testing/runner/combined-project-generator.js +1 -1
  95. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  96. package/dist/utils/testing/runner/test-runner.d.ts +24 -0
  97. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  98. package/dist/utils/testing/runner/test-runner.js +166 -6
  99. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  100. package/dist/utils/testing/runner/test-runner.test.js +228 -2
  101. package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
  102. package/dist/utils/testing/structured-test-results.d.ts +77 -0
  103. package/dist/utils/testing/structured-test-results.d.ts.map +1 -0
  104. package/dist/utils/testing/structured-test-results.js +140 -0
  105. package/dist/utils/testing/structured-test-results.js.map +1 -0
  106. package/dist/utils/testing/structured-test-results.test.d.ts +7 -0
  107. package/dist/utils/testing/structured-test-results.test.d.ts.map +1 -0
  108. package/dist/utils/testing/structured-test-results.test.js +179 -0
  109. package/dist/utils/testing/structured-test-results.test.js.map +1 -0
  110. package/package.json +6 -6
  111. package/src/commands/batch-command/batch-deploy-command.ts +4 -4
  112. package/src/commands/batch-command/batch-test-command.ts +94 -19
  113. package/src/commands/deploy-command/deploy-init-utils.ts +7 -1
  114. package/src/commands/deploy-command/deploy-init.ts +41 -11
  115. package/src/commands/deploy-command/index.ts +1 -1
  116. package/src/utils/deploy/deploy-github-columns.ts +14 -4
  117. package/src/utils/deploy/deploy-metadata.ts +1 -1
  118. package/src/utils/job-context/base-job-context.ts +3 -2
  119. package/src/utils/job-context/batch-script-job-context.ts +98 -17
  120. package/src/utils/job-context/cloud-job-context.test.ts +112 -0
  121. package/src/utils/job-context/cloud-job-context.ts +12 -6
  122. package/src/utils/job-context/job-context.ts +71 -2
  123. package/src/utils/job-context/local-job-context.test.ts +89 -0
  124. package/src/utils/job-context/local-job-context.ts +7 -3
  125. package/src/utils/open-cloud/open-cloud-client.test.ts +155 -1
  126. package/src/utils/open-cloud/open-cloud-client.ts +94 -18
  127. package/src/utils/open-cloud/open-cloud-logs.characterization.test.ts +277 -0
  128. package/src/utils/package-manager-utils.test.ts +27 -0
  129. package/src/utils/package-manager-utils.ts +34 -1
  130. package/src/utils/testing/batch-run.e2e.test.ts +425 -0
  131. package/src/utils/testing/batch-test-runner-template.test.ts +62 -0
  132. package/src/utils/testing/log-fetch-stats.test.ts +48 -0
  133. package/src/utils/testing/log-fetch-stats.ts +55 -0
  134. package/src/utils/testing/parsers/batch-log-parser.test.ts +660 -1
  135. package/src/utils/testing/parsers/batch-log-parser.ts +721 -116
  136. package/src/utils/testing/parsers/batch-log-renderer.test.ts +330 -0
  137. package/src/utils/testing/parsers/batch-log-renderer.ts +329 -0
  138. package/src/utils/testing/runner/combined-project-generator.ts +1 -1
  139. package/src/utils/testing/runner/test-runner.test.ts +332 -2
  140. package/src/utils/testing/runner/test-runner.ts +231 -9
  141. package/src/utils/testing/structured-test-results.test.ts +267 -0
  142. package/src/utils/testing/structured-test-results.ts +199 -0
  143. package/templates/batch-test-runner.lua +242 -0
  144. package/tsconfig.tsbuildinfo +1 -1
  145. package/templates/batch-test-runner.luau +0 -137
  146. /package/build-scripts/{smoke-test-server.luau → smoke-test-server.lua} +0 -0
  147. /package/build-scripts/{transform-rojo-merge-place.luau → transform-rojo-merge-place.lua} +0 -0
@@ -1,6 +1,290 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import {
5
+ afterAll,
6
+ afterEach,
7
+ beforeAll,
8
+ describe,
9
+ expect,
10
+ it,
11
+ vi,
12
+ } from 'vitest';
13
+ import { type DeployTarget } from '@quenty/nevermore-deploy';
14
+ import { OutputHelper } from '@quenty/cli-output-helpers';
2
15
 
3
- import { mergeFailureReasons } from './test-runner.js';
16
+ import { mergeFailureReasons, runSingleTestAsync } from './test-runner.js';
17
+ import { TEST_RESULTS_FORMAT } from '../structured-test-results.js';
18
+ import {
19
+ type Deployment,
20
+ type JobContext,
21
+ type ScriptRunResult,
22
+ } from '../../job-context/job-context.js';
23
+
24
+ const PASSING_LOGS = [
25
+ 'Test Suites: 3 passed, 3 total',
26
+ 'Tests: 25 passed, 25 total',
27
+ ].join('\n');
28
+
29
+ function structuredResults(overrides: Record<string, unknown> = {}) {
30
+ return {
31
+ format: TEST_RESULTS_FORMAT,
32
+ success: true,
33
+ ranJest: true,
34
+ passed: 25,
35
+ failed: 0,
36
+ skipped: 0,
37
+ total: 25,
38
+ suitesPassed: 3,
39
+ suitesFailed: 0,
40
+ suitesTotal: 3,
41
+ failures: [],
42
+ omittedFailures: 0,
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ /** Collect what the runner said out loud, so silence can be asserted on. */
48
+ function captureWarnings(): () => string[] {
49
+ const warnings: string[] = [];
50
+ vi.spyOn(OutputHelper, 'warn').mockImplementation((message: string) => {
51
+ warnings.push(message);
52
+ });
53
+ vi.spyOn(OutputHelper, 'info').mockImplementation(() => {});
54
+ return () => warnings;
55
+ }
56
+
57
+ afterEach(() => {
58
+ vi.restoreAllMocks();
59
+ });
60
+
61
+ /**
62
+ * A context that runs nothing: it hands back the run result and logs the test
63
+ * wants to reason about. Everything else is the minimum the runner touches.
64
+ */
65
+ function createContext(run: ScriptRunResult, logs: string): JobContext {
66
+ return {
67
+ buildPlaceAsync: async (options) => ({
68
+ rbxlPath: 'unused.rbxl',
69
+ target: options.target,
70
+ }),
71
+ deployBuiltPlaceAsync: async () => ({} as Deployment),
72
+ runScriptAsync: async () => run,
73
+ getLogsAsync: async () => ({ text: logs, messages: [] }),
74
+ releaseAsync: async () => {},
75
+ releaseBuiltPlaceAsync: async () => {},
76
+ disposeAsync: async () => {},
77
+ };
78
+ }
79
+
80
+ describe('runSingleTestAsync', () => {
81
+ let packagePath: string;
82
+
83
+ beforeAll(async () => {
84
+ packagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'nevermore-test-'));
85
+ await fs.writeFile(
86
+ path.join(packagePath, 'ServerMain.server.lua'),
87
+ 'return nil\n'
88
+ );
89
+ });
90
+
91
+ afterAll(async () => {
92
+ await fs.rm(packagePath, { recursive: true, force: true });
93
+ });
94
+
95
+ async function runAsync(run: ScriptRunResult, logs: string) {
96
+ return runSingleTestAsync(createContext(run, logs), {
97
+ packagePath,
98
+ packageName: 'maid',
99
+ target: {
100
+ scriptTemplate: 'ServerMain.server.lua',
101
+ } as unknown as DeployTarget,
102
+ });
103
+ }
104
+
105
+ it('fails a run whose returned results say it failed', async () => {
106
+ // The runner used to announce this by throwing, which failed the task. Now
107
+ // it returns the verdict, and these logs are what a truncated window leaves
108
+ // behind: a jest report from a suite that passed and no sign of the one
109
+ // that did not.
110
+ const result = await runAsync(
111
+ {
112
+ success: true,
113
+ returnValues: [structuredResults({ success: false, failed: 2 })],
114
+ },
115
+ PASSING_LOGS
116
+ );
117
+
118
+ expect(result.success).toBe(false);
119
+ expect(result.error).toContain('2 test(s) failed');
120
+ });
121
+
122
+ it('reports the counts the run returned, not the ones in the log', async () => {
123
+ const result = await runAsync(
124
+ {
125
+ success: true,
126
+ returnValues: [structuredResults({ passed: 400, total: 400 })],
127
+ },
128
+ PASSING_LOGS
129
+ );
130
+
131
+ expect(result.success).toBe(true);
132
+ expect(result.testCounts).toEqual({ passed: 400, failed: 0, total: 400 });
133
+ });
134
+
135
+ it('accepts returned results as proof the runner ran', async () => {
136
+ // Demanding a jest report in the logs is a stand-in for that proof, and it
137
+ // is the first thing a truncated log window costs.
138
+ const result = await runAsync(
139
+ { success: true, returnValues: [structuredResults()] },
140
+ '(logs truncated)'
141
+ );
142
+
143
+ expect(result.success).toBe(true);
144
+ });
145
+
146
+ it('still fails a run with a traceback in its logs', async () => {
147
+ // Jest cannot count a deferred-callback crash, so passing results are no
148
+ // reason to stop reading the logs.
149
+ const result = await runAsync(
150
+ { success: true, returnValues: [structuredResults()] },
151
+ `${PASSING_LOGS}\nStack Begin\nScript 'maid.spec', Line 4\nStack End`
152
+ );
153
+
154
+ expect(result.success).toBe(false);
155
+ });
156
+
157
+ it('falls back to the logs for a script that returned nothing', async () => {
158
+ // A test script written before results were returned. It must keep working,
159
+ // and it must not start passing when its logs say otherwise.
160
+ const failing = await runAsync(
161
+ { success: true, returnValues: [] },
162
+ 'Tests: 2 failed, 23 passed, 25 total'
163
+ );
164
+ expect(failing.success).toBe(false);
165
+ expect(failing.error).toContain('2 test(s) failed');
166
+
167
+ const passing = await runAsync(
168
+ { success: true, returnValues: [] },
169
+ PASSING_LOGS
170
+ );
171
+ expect(passing.success).toBe(true);
172
+ expect(passing.testCounts).toEqual({ passed: 25, failed: 0, total: 25 });
173
+ });
174
+
175
+ it('fails a script that returned nothing and logged nothing', async () => {
176
+ const result = await runAsync(
177
+ { success: true, returnValues: undefined },
178
+ ''
179
+ );
180
+
181
+ expect(result.success).toBe(false);
182
+ expect(result.error).toContain('nothing proves any test ran');
183
+ });
184
+
185
+ it('records where the counts came from', async () => {
186
+ const returned = await runAsync(
187
+ { success: true, returnValues: [structuredResults()] },
188
+ PASSING_LOGS
189
+ );
190
+ expect(returned.countsSource).toBe('returned');
191
+
192
+ const scraped = await runAsync(
193
+ { success: true, returnValues: [] },
194
+ PASSING_LOGS
195
+ );
196
+ expect(scraped.countsSource).toBe('scraped');
197
+ });
198
+
199
+ it('warns out loud when the run returned no results', async () => {
200
+ // Silence here is what let the structured channel ship inert: a run that
201
+ // fell back to scraping produced output identical to one that did not.
202
+ const warnings = captureWarnings();
203
+
204
+ await runAsync({ success: true, returnValues: [] }, PASSING_LOGS);
205
+
206
+ expect(warnings().some((w) => w.includes('returned no test results'))).toBe(
207
+ true
208
+ );
209
+ });
210
+
211
+ it('fails a package with specs whose run never reached jest', async () => {
212
+ // A Rojo regression that stops shipping jest.config turns a real suite into
213
+ // a smoke test. Before results were returned, the required jest report caught
214
+ // that; a smoke-test result retires the report and would otherwise pass with
215
+ // zero tests.
216
+ await fs.mkdir(path.join(packagePath, 'src'), { recursive: true });
217
+ await fs.writeFile(
218
+ path.join(packagePath, 'src', 'jest.config.lua'),
219
+ 'return {}\n'
220
+ );
221
+
222
+ const result = await runAsync(
223
+ {
224
+ success: true,
225
+ returnValues: [
226
+ structuredResults({ ranJest: false, passed: 0, total: 0 }),
227
+ ],
228
+ },
229
+ '[NevermoreTestRunner] No jest.config found — smoke test passed (boot success)'
230
+ );
231
+
232
+ expect(result.success).toBe(false);
233
+ expect(result.error).toContain('none of its specs ran');
234
+
235
+ await fs.rm(path.join(packagePath, 'src'), {
236
+ recursive: true,
237
+ force: true,
238
+ });
239
+ });
240
+
241
+ it('passes a package with no specs that smoke tested', async () => {
242
+ // No jest.config on disk, so a smoke test is the whole contract.
243
+ const result = await runAsync(
244
+ {
245
+ success: true,
246
+ returnValues: [
247
+ structuredResults({ ranJest: false, passed: 0, total: 0 }),
248
+ ],
249
+ },
250
+ '[NevermoreTestRunner] No jest.config found — smoke test passed (boot success)'
251
+ );
252
+
253
+ expect(result.success).toBe(true);
254
+ });
255
+
256
+ it('takes results a context resolved for it, without re-reporting', async () => {
257
+ // Aggregated batch mode: one execution covers every package, so the batch
258
+ // log parser splits the per-package results out and has already reported
259
+ // provenance for the whole batch.
260
+ const warnings = captureWarnings();
261
+
262
+ const result = await runAsync(
263
+ {
264
+ success: true,
265
+ testResults: {
266
+ success: false,
267
+ ranJest: true,
268
+ passed: 273,
269
+ failed: 2,
270
+ skipped: 0,
271
+ total: 275,
272
+ suitesPassed: 8,
273
+ suitesFailed: 1,
274
+ suitesTotal: 9,
275
+ failures: [],
276
+ omittedFailures: 0,
277
+ },
278
+ },
279
+ '(this package’s section was truncated away)'
280
+ );
281
+
282
+ expect(result.success).toBe(false);
283
+ expect(result.countsSource).toBe('returned');
284
+ expect(result.testCounts).toEqual({ passed: 273, failed: 2, total: 275 });
285
+ expect(warnings()).toHaveLength(0);
286
+ });
287
+ });
4
288
 
5
289
  describe('mergeFailureReasons', () => {
6
290
  it('does not repeat reasons the context already reported', () => {
@@ -27,3 +311,49 @@ describe('mergeFailureReasons', () => {
27
311
  expect(mergeFailureReasons(undefined, [])).toEqual([]);
28
312
  });
29
313
  });
314
+
315
+ describe('runSingleTestAsync log volume reporting', () => {
316
+ let packagePath: string;
317
+
318
+ beforeAll(async () => {
319
+ packagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'volume-'));
320
+ await fs.writeFile(
321
+ path.join(packagePath, 'ServerMain.server.lua'),
322
+ 'return nil\n'
323
+ );
324
+ });
325
+
326
+ afterAll(async () => {
327
+ await fs.rm(packagePath, { recursive: true, force: true });
328
+ });
329
+
330
+ function runAsync(logs: string) {
331
+ return runSingleTestAsync(createContext({ success: true }, logs), {
332
+ packagePath,
333
+ packageName: 'gameconfig',
334
+ target: {
335
+ scriptTemplate: 'ServerMain.server.lua',
336
+ } as unknown as DeployTarget,
337
+ });
338
+ }
339
+
340
+ it('reports how much log text a verdict was read from', async () => {
341
+ const result = await runAsync('some output with no jest report');
342
+
343
+ expect(result.success).toBe(false);
344
+ expect(result.error).toContain('verdict read from log text alone');
345
+ expect(result.error).toContain('31 chars, 1 line(s) received');
346
+ });
347
+
348
+ it('says nothing about log volume when there was no log text', async () => {
349
+ // A batch package whose section was lost has no text of its own. Reporting
350
+ // "0 chars received" here contradicts the fetch stats already printed for
351
+ // the whole batch window, and describes a judgement never made: the verdict
352
+ // came from the output being absent, which its own reason already says.
353
+ const result = await runAsync('');
354
+
355
+ expect(result.success).toBe(false);
356
+ expect(result.error).not.toContain('verdict read from log text alone');
357
+ expect(result.error).not.toContain('0 chars, 0 line(s)');
358
+ });
359
+ });
@@ -8,6 +8,15 @@ import {
8
8
  parseTestLogs,
9
9
  parseTestCounts,
10
10
  } from '../test-log-parser.js';
11
+ import { describeLogVolume } from '../log-fetch-stats.js';
12
+ import {
13
+ type StructuredTestResults,
14
+ describeUnexplainedVerdict,
15
+ findStructuredTestResults,
16
+ structuredFailureReasons,
17
+ toParsedTestCounts,
18
+ } from '../structured-test-results.js';
19
+ import { OutputHelper } from '@quenty/cli-output-helpers';
11
20
  import {
12
21
  buildDeployMetadataAttributes,
13
22
  gatherGitDeployInfo,
@@ -28,6 +37,14 @@ export interface SingleTestResult {
28
37
  durationMs?: number;
29
38
  /** Why the run failed, when the runner can say more than "it failed". */
30
39
  error?: string;
40
+ /**
41
+ * Where `testCounts` came from. `returned` means the run handed them over as a
42
+ * value; `scraped` means they were read out of log text, which is the channel
43
+ * truncation destroys. Absent when there are no counts at all.
44
+ */
45
+ countsSource?: 'returned' | 'scraped';
46
+ /** True when the verdict rests on returned results with no log to read. */
47
+ logsLost?: boolean;
31
48
  }
32
49
 
33
50
  /**
@@ -60,6 +77,12 @@ export interface SingleTestOptions {
60
77
  timeoutMs?: number;
61
78
  /** Luau code to execute directly, bypassing the configured scriptTemplate. */
62
79
  scriptText?: string;
80
+ /**
81
+ * Skip this run's counts-provenance line, because the caller reports it for
82
+ * every package at once. Set by an aggregated batch, where one fetch serves
83
+ * the whole run and the per-package line says the same thing each time.
84
+ */
85
+ suppressCountsProvenance?: boolean;
63
86
  }
64
87
 
65
88
  /**
@@ -125,31 +148,230 @@ export async function runSingleTestAsync(
125
148
  timeoutMs,
126
149
  });
127
150
 
128
- const rawLogs = await context.getLogsAsync(deployment);
151
+ const logs = await context.getLogsAsync(deployment);
152
+ const rawLogs = logs.text;
153
+ const logVolume = describeLogVolume(rawLogs, logs.stats);
154
+
155
+ // The runner used to announce a failing suite by throwing, which failed the
156
+ // task. It returns its verdict now, so the verdict has to be read: without
157
+ // this, a failing suite whose report fell outside the truncated log window
158
+ // would come back a pass.
159
+ //
160
+ // A context that already resolved the results (aggregated batch, where one
161
+ // execution's return value belongs to no single package) hands them over
162
+ // directly; otherwise they are decoded from what the script returned.
163
+ const structured =
164
+ result.testResults ?? findStructuredTestResults(result.returnValues);
165
+
129
166
  // A probe script is arbitrary Luau with no jest in it, so demanding a test
130
167
  // report would fail every --script-text run. Everything else must prove a
131
- // runner spoke before it can pass.
168
+ // runner spoke before it can pass — and returned results are that proof,
169
+ // where a scraped report is only what survived truncation.
132
170
  const parsed = parseTestLogs(rawLogs, {
133
- requireTestReport: scriptText === undefined,
171
+ requireTestReport: scriptText === undefined && structured === undefined,
134
172
  });
135
173
 
136
- const reasons = mergeFailureReasons(
137
- result.errorMessage,
138
- parsed.failureReasons
139
- );
174
+ // A run that reports it never reached jest, for a package that has a
175
+ // jest.config on disk, did not test anything — the built place lost the
176
+ // config. Before results were returned, the required jest report caught this
177
+ // as "nothing proves any test ran"; a smoke-test result would otherwise
178
+ // retire that check and pass with zero tests.
179
+ const smokeTestedWithSpecs =
180
+ structured !== undefined &&
181
+ !structured.ranJest &&
182
+ scriptText === undefined &&
183
+ (await packageHasJestConfigAsync(packagePath));
184
+
185
+ const reasons = mergeFailureReasons(result.errorMessage, [
186
+ ...(smokeTestedWithSpecs
187
+ ? [
188
+ 'this package has a jest.config but the run reported no jest.config ' +
189
+ 'in the built place, so none of its specs ran',
190
+ ]
191
+ : []),
192
+ ...(structured ? structuredFailureReasons(structured) : []),
193
+ ...parsed.failureReasons,
194
+ // Said only when there was log text and it did not hold up: the reasons
195
+ // above are then statements about what arrived, and how much arrived is
196
+ // the first thing to check. With no text at all the verdict came from the
197
+ // absence of it, which the reason for that absence already explains —
198
+ // saying "0 chars received" beside it only contradicts the fetch that
199
+ // reported the whole window.
200
+ ...(structured === undefined &&
201
+ parsed.failureReasons.length > 0 &&
202
+ rawLogs.length > 0
203
+ ? [`verdict read from log text alone (${logVolume})`]
204
+ : []),
205
+ ]);
206
+
207
+ // Returned counts outrank scraped ones: same numbers when the log
208
+ // survived, real numbers when it did not.
209
+ const scrapedCounts = parseTestCounts(parsed.logs);
210
+ const testCounts = structured
211
+ ? toParsedTestCounts(structured)
212
+ : scrapedCounts;
213
+
214
+ reportCountsProvenance({
215
+ packageName,
216
+ structured,
217
+ scrapedCounts,
218
+ logVolume,
219
+ // A probe is not a test run, so it has no results to be missing. Neither
220
+ // is one package of an aggregated batch, whose context reports the whole
221
+ // batch's provenance once, as a group — repeating it per package printed
222
+ // the same fetch stats 78 times over.
223
+ silent:
224
+ scriptText !== undefined ||
225
+ result.testResults !== undefined ||
226
+ (options.suppressCountsProvenance ?? false),
227
+ hadReturnChannel: result.returnValues !== undefined,
228
+ });
140
229
 
141
230
  return {
142
- success: result.success && parsed.success,
231
+ success:
232
+ result.success &&
233
+ parsed.success &&
234
+ (structured?.success ?? true) &&
235
+ !smokeTestedWithSpecs,
143
236
  logs: parsed.logs,
144
- testCounts: parseTestCounts(parsed.logs),
237
+ testCounts,
238
+ countsSource: structured
239
+ ? 'returned'
240
+ : scrapedCounts
241
+ ? 'scraped'
242
+ : undefined,
145
243
  durationMs: result.durationMs,
146
244
  error: reasons.length > 0 ? reasons.join('; ') : undefined,
245
+ logsLost: result.logsLost,
147
246
  };
148
247
  } finally {
149
248
  await context.releaseAsync(deployment);
150
249
  }
151
250
  }
152
251
 
252
+ /**
253
+ * Say where a run's counts came from, and complain when they had to be scraped.
254
+ *
255
+ * The structured channel exists because Open Cloud truncates a long run's logs.
256
+ * A channel that is plumbed but not flowing produces output identical to one
257
+ * that works, so the fallback is a warning rather than silence — the first
258
+ * version of this shipped inert and looked green.
259
+ */
260
+ function reportCountsProvenance(options: {
261
+ packageName: string;
262
+ structured?: StructuredTestResults;
263
+ scrapedCounts?: ParsedTestCounts;
264
+ silent: boolean;
265
+ hadReturnChannel: boolean;
266
+ /** How much log text there was to scrape, for the fallback warning. */
267
+ logVolume: string;
268
+ }): void {
269
+ const {
270
+ packageName,
271
+ structured,
272
+ scrapedCounts,
273
+ silent,
274
+ hadReturnChannel,
275
+ logVolume,
276
+ } = options;
277
+
278
+ if (silent) {
279
+ return;
280
+ }
281
+
282
+ if (structured) {
283
+ OutputHelper.info(
284
+ `${packageName}: counts returned by the run — ` +
285
+ `${structured.passed} passed, ${structured.failed} failed, ` +
286
+ `${structured.total} total.`
287
+ );
288
+
289
+ const unexplained = describeUnexplainedVerdict(structured);
290
+ if (unexplained) {
291
+ OutputHelper.warn(`${packageName}: ${unexplained}`);
292
+ }
293
+
294
+ // A suite that runs and counts nothing is not the same as one that passed.
295
+ if (structured.ranJest && structured.total === 0) {
296
+ OutputHelper.warn(
297
+ `${packageName}: jest ran but found no tests to run. If this package has ` +
298
+ `specs, they are not reaching the test place.`
299
+ );
300
+ }
301
+
302
+ // Two channels reporting the same run must agree. When they do not, one is
303
+ // lying and neither total stands on its own.
304
+ if (scrapedCounts && scrapedCounts.total !== structured.total) {
305
+ OutputHelper.warn(
306
+ `${packageName}: returned counts disagree with the log — the run returned ` +
307
+ `${structured.passed}/${structured.failed}/${structured.total} ` +
308
+ `(passed/failed/total), its jest report says ` +
309
+ `${scrapedCounts.passed}/${scrapedCounts.failed}/${scrapedCounts.total}. ` +
310
+ `Reporting the returned counts; one of the two channels is wrong.`
311
+ );
312
+ }
313
+ return;
314
+ }
315
+
316
+ OutputHelper.warn(
317
+ `${packageName}: the run returned no test results, so its counts were ` +
318
+ (scrapedCounts ? 'scraped from log text' : 'unavailable') +
319
+ ` — the channel Open Cloud truncates on long runs (${logVolume}). ` +
320
+ (hadReturnChannel
321
+ ? 'The run delivered a return channel but nothing recognizable in it: the ' +
322
+ 'test script should end with "return results" (see docs/testing/testing.md).'
323
+ : 'No return channel was delivered at all, so the transport lost it.')
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Whether this package ships a jest.config, i.e. whether it has specs to run.
329
+ *
330
+ * Deliberately a bounded walk, not a recursive glob: every package under `src/`
331
+ * has a symlinked, self-referential `node_modules`, so recursive search here
332
+ * hangs. Two levels covers the layouts in use (`src/jest.config.lua` for a
333
+ * package, `src/modules/jest.config.lua` for a game) without needing a list of
334
+ * them.
335
+ */
336
+ export async function packageHasJestConfigAsync(
337
+ packagePath: string
338
+ ): Promise<boolean> {
339
+ async function scanAsync(dir: string, depth: number): Promise<boolean> {
340
+ let entries;
341
+ try {
342
+ entries = await fs.readdir(dir, { withFileTypes: true });
343
+ } catch {
344
+ return false;
345
+ }
346
+
347
+ for (const entry of entries) {
348
+ if (entry.isFile() && entry.name.startsWith('jest.config')) {
349
+ return true;
350
+ }
351
+ }
352
+
353
+ if (depth === 0) {
354
+ return false;
355
+ }
356
+
357
+ for (const entry of entries) {
358
+ if (!entry.isDirectory()) {
359
+ continue;
360
+ }
361
+ // `test` holds the test place, never the specs the place runs.
362
+ if (entry.name === 'node_modules' || entry.name === 'test') {
363
+ continue;
364
+ }
365
+ if (await scanAsync(path.join(dir, entry.name), depth - 1)) {
366
+ return true;
367
+ }
368
+ }
369
+ return false;
370
+ }
371
+
372
+ return scanAsync(packagePath, 2);
373
+ }
374
+
153
375
  /**
154
376
  * Read a test script from the deploy target's configured script path.
155
377
  */