@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
@@ -0,0 +1,330 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { OutputHelper } from '@quenty/cli-output-helpers';
3
+ import {
4
+ renderBatchLog,
5
+ toRenderableLines,
6
+ type RenderableLogLine,
7
+ } from './batch-log-renderer.js';
8
+ import { type BatchPackageResult } from './batch-log-parser.js';
9
+
10
+ const SLUGS = new Map([
11
+ ['alpha', '@quenty/alpha'],
12
+ ['beta', '@quenty/beta'],
13
+ ]);
14
+
15
+ /** Plain OUTPUT lines, which is what most of a run is. */
16
+ function plain(...lines: string[]): RenderableLogLine[] {
17
+ return lines.map((line) => ({ line, severity: 'output' as const }));
18
+ }
19
+
20
+ function render(
21
+ lines: RenderableLogLine[],
22
+ results?: Map<string, BatchPackageResult>
23
+ ): string[] {
24
+ return renderBatchLog(lines, {
25
+ slugToPackage: SLUGS,
26
+ results,
27
+ useGroups: true,
28
+ color: false,
29
+ });
30
+ }
31
+
32
+ function result(over: Partial<BatchPackageResult>): BatchPackageResult {
33
+ return {
34
+ slug: 'alpha',
35
+ success: true,
36
+ logs: '',
37
+ tracebackCount: 0,
38
+ ...over,
39
+ };
40
+ }
41
+
42
+ describe('renderBatchLog', () => {
43
+ it('wraps each package section in a group of its own', () => {
44
+ const out = render(
45
+ plain(
46
+ '===BATCH_TEST_BEGIN alpha===',
47
+ 'alpha ran',
48
+ '===BATCH_TEST_END alpha PASS 10==='
49
+ )
50
+ );
51
+
52
+ expect(out).toEqual([
53
+ '::group::@quenty/alpha',
54
+ 'alpha ran',
55
+ '::endgroup::',
56
+ ]);
57
+ });
58
+
59
+ it('keeps output that belongs to no package, outside any group', () => {
60
+ // The whole point. Output between two sections used to be discarded by the
61
+ // parser, so a crash landing between packages reached nobody.
62
+ const out = render(
63
+ plain(
64
+ 'harness starting up',
65
+ '===BATCH_TEST_BEGIN alpha===',
66
+ 'alpha ran',
67
+ '===BATCH_TEST_END alpha PASS 10===',
68
+ 'something exploded between packages',
69
+ '===BATCH_TEST_BEGIN beta===',
70
+ 'beta ran',
71
+ '===BATCH_TEST_END beta PASS 20===',
72
+ 'trailing note after the last package'
73
+ )
74
+ );
75
+
76
+ expect(out).toEqual([
77
+ 'harness starting up',
78
+ '::group::@quenty/alpha',
79
+ 'alpha ran',
80
+ '::endgroup::',
81
+ 'something exploded between packages',
82
+ '::group::@quenty/beta',
83
+ 'beta ran',
84
+ '::endgroup::',
85
+ 'trailing note after the last package',
86
+ ]);
87
+ });
88
+
89
+ it('emits one group per package rather than one per start', () => {
90
+ // The bug this replaces: every package was grouped when it started, which
91
+ // in an aggregated batch is all of them at once, before the task existed.
92
+ const out = render(
93
+ plain(
94
+ '===BATCH_TEST_BEGIN alpha===',
95
+ '===BATCH_TEST_END alpha PASS 10===',
96
+ '===BATCH_TEST_BEGIN beta===',
97
+ '===BATCH_TEST_END beta PASS 20==='
98
+ )
99
+ );
100
+
101
+ expect(out.filter((line) => line === '::endgroup::')).toHaveLength(2);
102
+ expect(out.indexOf('::group::@quenty/beta')).toBeGreaterThan(
103
+ out.indexOf('::endgroup::')
104
+ );
105
+ });
106
+
107
+ it('closes a section left open by a missing END', () => {
108
+ // Groups must balance whatever the log did, or every later line renders
109
+ // inside a package it has nothing to do with.
110
+ const out = render(
111
+ plain(
112
+ '===BATCH_TEST_BEGIN alpha===',
113
+ 'alpha ran',
114
+ '===BATCH_TEST_BEGIN beta===',
115
+ 'beta ran',
116
+ '===BATCH_TEST_END beta PASS 20==='
117
+ )
118
+ );
119
+
120
+ expect(out.filter((line) => line.startsWith('::group::'))).toHaveLength(2);
121
+ expect(out.filter((line) => line === '::endgroup::')).toHaveLength(2);
122
+ });
123
+
124
+ it('puts a dropped BEGIN section inside its group, output and all', () => {
125
+ // Truncation eats the head of the window, so the first surviving section
126
+ // announces itself only at its END — after its output has been emitted.
127
+ // Heading it there would leave a group holding nothing but the verdict
128
+ // while hundreds of its lines sat loose above it.
129
+ const out = render(
130
+ plain(
131
+ 'alpha line one',
132
+ 'alpha line two',
133
+ '===BATCH_TEST_END alpha PASS 10==='
134
+ )
135
+ );
136
+
137
+ expect(out).toEqual([
138
+ '::group::@quenty/alpha',
139
+ 'alpha line one',
140
+ 'alpha line two',
141
+ '::endgroup::',
142
+ ]);
143
+ });
144
+
145
+ it('leaves earlier top-level output above a dropped BEGIN section', () => {
146
+ // Only the run since the last section closed belongs to the orphan; output
147
+ // from before that is not retroactively swept into it.
148
+ const out = render(
149
+ plain(
150
+ '===BATCH_TEST_BEGIN alpha===',
151
+ 'alpha ran',
152
+ '===BATCH_TEST_END alpha PASS 10===',
153
+ 'loose line between packages',
154
+ 'beta output with no begin',
155
+ '===BATCH_TEST_END beta PASS 20==='
156
+ )
157
+ );
158
+
159
+ expect(out).toEqual([
160
+ '::group::@quenty/alpha',
161
+ 'alpha ran',
162
+ '::endgroup::',
163
+ '::group::@quenty/beta',
164
+ 'loose line between packages',
165
+ 'beta output with no begin',
166
+ '::endgroup::',
167
+ ]);
168
+ });
169
+
170
+ it('carries the verdict in the group title, and the reason in its body', () => {
171
+ // A group is read collapsed first, so the title has to say which ones are
172
+ // worth opening. The reason is a sentence and stays in the body.
173
+ const results = new Map([
174
+ [
175
+ '@quenty/alpha',
176
+ result({
177
+ success: false,
178
+ error: 'no jest report in output',
179
+ testCounts: { passed: 3, failed: 1, total: 4 },
180
+ durationMs: 1500,
181
+ }),
182
+ ],
183
+ ]);
184
+
185
+ const out = render(
186
+ plain(
187
+ '===BATCH_TEST_BEGIN alpha===',
188
+ 'alpha ran',
189
+ '===BATCH_TEST_END alpha FAIL 10==='
190
+ ),
191
+ results
192
+ );
193
+
194
+ expect(out).toEqual([
195
+ '::group::@quenty/alpha - ❌ FAILED (3/4) (1.5s)',
196
+ 'alpha ran',
197
+ 'no jest report in output',
198
+ '::endgroup::',
199
+ ]);
200
+ });
201
+
202
+ it('titles a passing package with its counts and duration', () => {
203
+ const results = new Map([
204
+ [
205
+ '@quenty/alpha',
206
+ result({
207
+ success: true,
208
+ testCounts: { passed: 68, failed: 0, total: 68 },
209
+ durationMs: 1500,
210
+ }),
211
+ ],
212
+ ]);
213
+
214
+ const out = render(
215
+ plain(
216
+ '===BATCH_TEST_BEGIN alpha===',
217
+ '===BATCH_TEST_END alpha PASS 1==='
218
+ ),
219
+ results
220
+ );
221
+
222
+ expect(out[0]).toBe('::group::@quenty/alpha - ✅ Passed (68/68) (1.5s)');
223
+ });
224
+
225
+ it('titles a package with only its name when nothing is known about it', () => {
226
+ const out = render(
227
+ plain('===BATCH_TEST_BEGIN alpha===', '===BATCH_TEST_END alpha PASS 1===')
228
+ );
229
+
230
+ expect(out[0]).toBe('::group::@quenty/alpha');
231
+ });
232
+
233
+ it('drops the summary marker and its payload', () => {
234
+ // Framing for the parser, not output a person reads.
235
+ const out = render(
236
+ plain(
237
+ 'real output',
238
+ '===BATCH_TEST_SUMMARY===',
239
+ '[{"slug":"alpha","success":true}]'
240
+ )
241
+ );
242
+
243
+ expect(out).toEqual(['real output']);
244
+ });
245
+
246
+ it('leaves plain output untouched so jest keeps its own color', () => {
247
+ const out = renderBatchLog(plain('✓ passed'), {
248
+ slugToPackage: SLUGS,
249
+ useGroups: true,
250
+ color: true,
251
+ });
252
+
253
+ expect(out).toEqual(['✓ passed']);
254
+ });
255
+
256
+ it('colors warnings and errors by the severity the API reported', () => {
257
+ const out = renderBatchLog(
258
+ [
259
+ { line: 'plain', severity: 'output' },
260
+ { line: 'careful', severity: 'warning' },
261
+ { line: 'broken', severity: 'error' },
262
+ ],
263
+ { slugToPackage: SLUGS, useGroups: true, color: true }
264
+ );
265
+
266
+ // Compared against the formatters themselves rather than asserting the text
267
+ // changed: chalk emits no escapes on a non-TTY, so "it differs" would pass
268
+ // or fail on where the suite runs rather than on what the renderer did.
269
+ expect(out[0]).toBe('plain');
270
+ expect(out[1]).toBe(OutputHelper.formatWarning('careful'));
271
+ expect(out[2]).toBe(OutputHelper.formatError('broken'));
272
+ });
273
+
274
+ it('uses a plain header instead of group markers outside CI', () => {
275
+ const out = renderBatchLog(
276
+ plain(
277
+ '===BATCH_TEST_BEGIN alpha===',
278
+ 'ran',
279
+ '===BATCH_TEST_END alpha PASS 1==='
280
+ ),
281
+ { slugToPackage: SLUGS, useGroups: false, color: false }
282
+ );
283
+
284
+ expect(out.some((line) => line.includes('@quenty/alpha'))).toBe(true);
285
+ expect(out).not.toContain('::group::@quenty/alpha');
286
+ expect(out).not.toContain('::endgroup::');
287
+ });
288
+ });
289
+
290
+ describe('toRenderableLines', () => {
291
+ it('gives every line of a message the severity of that message', () => {
292
+ // An error arrives with its traceback attached as one message. Classifying
293
+ // by line would leave the continuation lines looking like ordinary output.
294
+ const lines = toRenderableLines([
295
+ { message: 'fine', messageType: 'OUTPUT' },
296
+ {
297
+ message:
298
+ "TaskScript:1: boom\nStack Begin\nScript 'X', Line 1\nStack End",
299
+ messageType: 'ERROR',
300
+ },
301
+ ]);
302
+
303
+ expect(lines).toHaveLength(5);
304
+ expect(lines[0]).toEqual({ line: 'fine', severity: 'output' });
305
+ expect(lines.slice(1).every((l) => l.severity === 'error')).toBe(true);
306
+ expect(lines[2].line).toBe('Stack Begin');
307
+ });
308
+
309
+ it('treats an untyped message as ordinary output', () => {
310
+ // The flat log view carries no type, and a missing type must not read as
311
+ // a failure.
312
+ expect(toRenderableLines([{ message: 'hi' }])).toEqual([
313
+ { line: 'hi', severity: 'output' },
314
+ ]);
315
+ });
316
+
317
+ it('produces exactly the lines the parser tokenizes', () => {
318
+ // The renderer maps token i onto line i, which only holds if this split is
319
+ // the same one the fetch's joined text would produce.
320
+ const messages = [
321
+ { message: 'one', messageType: 'OUTPUT' },
322
+ { message: 'two\nthree', messageType: 'WARNING' },
323
+ ];
324
+ const joined = messages.map((m) => m.message).join('\n');
325
+
326
+ expect(toRenderableLines(messages).map((l) => l.line)).toEqual(
327
+ joined.split('\n')
328
+ );
329
+ });
330
+ });
@@ -0,0 +1,329 @@
1
+ import { OutputHelper } from '@quenty/cli-output-helpers';
2
+ import { formatDurationMs } from '@quenty/cli-output-helpers/cli-utils';
3
+ import {
4
+ formatStatusText,
5
+ resolveResultStatus,
6
+ statusIcon,
7
+ type ResultStatusInput,
8
+ } from '@quenty/cli-output-helpers/reporting';
9
+ import {
10
+ type BatchPackageResult,
11
+ tokenizeBatchLog,
12
+ } from './batch-log-parser.js';
13
+
14
+ /**
15
+ * One line of a batch run's output, carrying the severity Open Cloud reported
16
+ * for the message it came from.
17
+ *
18
+ * Severity is per *message*, and a message can be several lines — an error
19
+ * arrives with its whole traceback attached — so every line of a message
20
+ * inherits it. That is why this is not derived from the text: guessing at
21
+ * severity from patterns misreads a traceback's continuation lines, while the
22
+ * API already says which of OUTPUT, WARNING and ERROR each message was.
23
+ */
24
+ export interface RenderableLogLine {
25
+ line: string;
26
+ severity: 'output' | 'warning' | 'error';
27
+ }
28
+
29
+ export interface BatchLogRenderOptions {
30
+ /** Section slug to the package name a reader recognizes, for group titles. */
31
+ slugToPackage: Map<string, string>;
32
+ /**
33
+ * Per-package verdicts, printed as each section closes so a package's result
34
+ * sits with the output that produced it rather than in a later block.
35
+ *
36
+ * Keyed by package name, as `parseBatchTestLogs` returns it — sections are
37
+ * named by slug, so a lookup here goes through `slugToPackage` first.
38
+ */
39
+ results?: Map<string, BatchPackageResult>;
40
+ /** Emit GitHub `::group::` markers. Off outside CI, where they are noise. */
41
+ useGroups: boolean;
42
+ /** Colorize by severity. Off when NO_COLOR is set. */
43
+ color: boolean;
44
+ }
45
+
46
+ /**
47
+ * Split typed messages into the lines the parser tokenizes, keeping severity.
48
+ *
49
+ * The join is the same one `getRawTaskLogsAsync` performs, so the resulting
50
+ * lines are positionally identical to the parser's — which is what lets the
51
+ * token at index i describe the line at index i.
52
+ */
53
+ export function toRenderableLines(
54
+ messages: Array<{ message: string; messageType?: string }>
55
+ ): RenderableLogLine[] {
56
+ const lines: RenderableLogLine[] = [];
57
+
58
+ for (const entry of messages) {
59
+ const severity =
60
+ entry.messageType === 'ERROR'
61
+ ? 'error'
62
+ : entry.messageType === 'WARNING'
63
+ ? 'warning'
64
+ : 'output';
65
+
66
+ for (const line of entry.message.split('\n')) {
67
+ lines.push({ line, severity });
68
+ }
69
+ }
70
+
71
+ return lines;
72
+ }
73
+
74
+ /**
75
+ * Render a whole batch run's output in arrival order, with each package's
76
+ * section wrapped in a collapsible group.
77
+ *
78
+ * Every line the run produced is emitted, including the ones that belong to no
79
+ * package: output between two sections, or before the first BEGIN, used to be
80
+ * discarded by the parser and so never reached anyone. Those lines are exactly
81
+ * the ones worth seeing when a batch misbehaves — a crash between packages, or
82
+ * a harness message printed before the first test — so they are rendered at the
83
+ * top level, outside any group, rather than dropped.
84
+ *
85
+ * Groups are injected around the parser's own BEGIN/END tokens rather than
86
+ * opened as each package starts. An aggregated batch starts every package at
87
+ * once, before the cloud task exists, so grouping by start time produced one
88
+ * empty group per package and put every line under the last of them.
89
+ */
90
+ export function renderBatchLog(
91
+ lines: RenderableLogLine[],
92
+ options: BatchLogRenderOptions
93
+ ): string[] {
94
+ const tokens = tokenizeBatchLog(lines.map((entry) => entry.line));
95
+ const out: string[] = [];
96
+ /** Slugs that reached the output, so the rest can be listed after it. */
97
+ const rendered = new Set<string>();
98
+ let openSlug: string | undefined;
99
+ /**
100
+ * Where the run of lines belonging to no open section began.
101
+ *
102
+ * A section whose BEGIN the log window dropped only announces itself at its
103
+ * END, by which point its output has already been emitted. Knowing where that
104
+ * run started lets the header be spliced in above it, so the output lands
105
+ * inside its own group rather than the group holding only a verdict.
106
+ */
107
+ let orphanStart = 0;
108
+
109
+ const closeGroup = (): void => {
110
+ if (openSlug === undefined) {
111
+ return;
112
+ }
113
+ if (options.useGroups) {
114
+ out.push('::endgroup::');
115
+ }
116
+ openSlug = undefined;
117
+ orphanStart = out.length;
118
+ };
119
+
120
+ for (let i = 0; i < tokens.length; i++) {
121
+ const token = tokens[i];
122
+
123
+ if (token.kind === 'begin') {
124
+ // A second BEGIN with a section still open means the first never closed;
125
+ // close it here so the groups stay balanced whatever the log did.
126
+ closeGroup();
127
+ openSlug = token.slug;
128
+ rendered.add(token.slug);
129
+ out.push(...openGroup(token.slug, options));
130
+ continue;
131
+ }
132
+
133
+ if (token.kind === 'end') {
134
+ if (openSlug === undefined) {
135
+ // An END whose BEGIN the log window dropped. Its output is already in
136
+ // `out`, so the header goes in above it rather than here — otherwise
137
+ // the group holds nothing but the verdict while the output it belongs
138
+ // to sits loose above it.
139
+ out.splice(orphanStart, 0, ...openGroup(token.slug, options));
140
+ openSlug = token.slug;
141
+ }
142
+ rendered.add(token.slug);
143
+ out.push(...verdictLines(token.slug, options));
144
+ closeGroup();
145
+ continue;
146
+ }
147
+
148
+ // The summary marker and its JSON payload are framing for the parser, not
149
+ // output anyone reads.
150
+ if (token.kind === 'summary' || token.kind === 'summaryPayload') {
151
+ continue;
152
+ }
153
+
154
+ out.push(colorize(lines[i], options.color));
155
+ }
156
+
157
+ closeGroup();
158
+ out.push(...sectionlessGroups(rendered, options));
159
+
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * Groups for packages that never appeared in the log at all.
165
+ *
166
+ * A dropped log window takes whole sections with it, markers included, and such
167
+ * a package used to render nothing: no group, no verdict, no reason — only a
168
+ * row in the summary table saying FAILED with nothing behind it. The result is
169
+ * known regardless, because it came back in the run's return value, so it is
170
+ * shown in the same shape every other package is.
171
+ */
172
+ function sectionlessGroups(
173
+ rendered: Set<string>,
174
+ options: BatchLogRenderOptions
175
+ ): string[] {
176
+ const out: string[] = [];
177
+
178
+ for (const [slug, name] of options.slugToPackage) {
179
+ if (rendered.has(slug)) {
180
+ continue;
181
+ }
182
+ const result = options.results?.get(name);
183
+ if (!result) {
184
+ continue;
185
+ }
186
+
187
+ out.push(...openGroup(slug, options));
188
+ out.push(...verdictLines(slug, options));
189
+ // The group is otherwise empty, so it says why rather than leaving a reader
190
+ // to work out that a package showing no output is one whose output is gone.
191
+ out.push(
192
+ options.color ? OutputHelper.formatDim(NO_SECTION_NOTE) : NO_SECTION_NOTE
193
+ );
194
+ if (options.useGroups) {
195
+ out.push('::endgroup::');
196
+ }
197
+ }
198
+
199
+ return out;
200
+ }
201
+
202
+ const NO_SECTION_NOTE =
203
+ "This package printed nothing that survived the run's log window. " +
204
+ 'Its verdict and counts came back in the batch summary instead.';
205
+
206
+ /**
207
+ * Render everything one batch execution produced, ready to print.
208
+ *
209
+ * The binding between what a run reports and what the renderer is told about it
210
+ * lives here rather than at the call site, so a field the outcome carries and
211
+ * the render forgets — the results that put a verdict in every group title, say
212
+ * — is a mistake one test can catch.
213
+ *
214
+ * Structural rather than typed against `BatchExecutionOutcome`: the job context
215
+ * that defines it already imports this module.
216
+ */
217
+ export function renderBatchOutcome(
218
+ outcome: {
219
+ lines: RenderableLogLine[];
220
+ results: Map<string, BatchPackageResult>;
221
+ slugToPackage: Map<string, string>;
222
+ },
223
+ presentation: { useGroups: boolean; color: boolean }
224
+ ): string[] {
225
+ return renderBatchLog(outcome.lines, {
226
+ slugToPackage: outcome.slugToPackage,
227
+ results: outcome.results,
228
+ useGroups: presentation.useGroups,
229
+ color: presentation.color,
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Header for one package's section, carrying its verdict.
235
+ *
236
+ * The status is in the title because a group is read collapsed: a reader
237
+ * scanning 78 of them wants to know which to open, and a title that is only a
238
+ * package name makes them all look alike. The counts and duration are formatted
239
+ * with the same helpers the PR comment table uses, so the two cannot drift into
240
+ * describing the same run differently.
241
+ */
242
+ function openGroup(slug: string, options: BatchLogRenderOptions): string[] {
243
+ const name = options.slugToPackage.get(slug) ?? slug;
244
+ const result = options.results?.get(name);
245
+ const title = result ? `${name} - ${describeResult(result)}` : name;
246
+
247
+ if (options.useGroups) {
248
+ return [`::group::${title}`];
249
+ }
250
+
251
+ return [OutputHelper.formatDim(`──────── ${title} ────────`)];
252
+ }
253
+
254
+ /**
255
+ * "⚠️ Passed (35/35) - logs lost (1.2s)", the shape every other reporter uses.
256
+ *
257
+ * Resolved by the shared status resolver rather than worked out here, so a
258
+ * group title, a summary-table row and a PR comment row describing the same run
259
+ * cannot disagree about what state it is in or what that state is called. This
260
+ * only picks the icon style and puts the duration on the end.
261
+ */
262
+ function describeResult(result: BatchPackageResult): string {
263
+ const status = resolveResultStatus(toStatusInput(result));
264
+ const duration =
265
+ result.durationMs === undefined
266
+ ? ''
267
+ : `(${formatDurationMs(result.durationMs)})`;
268
+
269
+ return [
270
+ statusIcon(status.severity, 'emoji'),
271
+ formatStatusText(status),
272
+ duration,
273
+ ]
274
+ .filter(Boolean)
275
+ .join(' ');
276
+ }
277
+
278
+ /**
279
+ * A batch result in the shape the status resolver reads.
280
+ *
281
+ * The parser's result is not a `PackageResult` — it is per-section, and one
282
+ * section is not a package's whole run — so the two fields the resolver needs
283
+ * are handed over rather than the type being widened to fit.
284
+ */
285
+ function toStatusInput(result: BatchPackageResult): ResultStatusInput {
286
+ return {
287
+ success: result.success,
288
+ progressSummary: result.testCounts
289
+ ? {
290
+ kind: 'test-counts',
291
+ passed: result.testCounts.passed,
292
+ failed: result.testCounts.failed,
293
+ total: result.testCounts.total,
294
+ }
295
+ : undefined,
296
+ caveats: result.logsLost ? ['logs-lost'] : undefined,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Why a package failed, printed where its output ends.
302
+ *
303
+ * Only the reason: the verdict itself is in the group's title now, and saying
304
+ * it twice in six lines of output reads as two separate findings. A reason is
305
+ * a sentence and belongs in the body.
306
+ */
307
+ function verdictLines(slug: string, options: BatchLogRenderOptions): string[] {
308
+ const name = options.slugToPackage.get(slug);
309
+ const result = name ? options.results?.get(name) : undefined;
310
+ if (!result?.error) {
311
+ return [];
312
+ }
313
+
314
+ return [
315
+ options.color ? OutputHelper.formatError(result.error) : result.error,
316
+ ];
317
+ }
318
+
319
+ function colorize(entry: RenderableLogLine, color: boolean): string {
320
+ if (!color || entry.severity === 'output') {
321
+ // Left exactly as it arrived: jest-lua colors its own output, and
322
+ // re-wrapping it would fight those escapes.
323
+ return entry.line;
324
+ }
325
+
326
+ return entry.severity === 'error'
327
+ ? OutputHelper.formatError(entry.line)
328
+ : OutputHelper.formatWarning(entry.line);
329
+ }
@@ -125,7 +125,7 @@ export async function generateCombinedProjectAsync(options: {
125
125
  const luneScriptPath = resolvePackagePath(
126
126
  import.meta.url,
127
127
  'build-scripts',
128
- 'combine-test-places.luau'
128
+ 'combine-test-places.lua'
129
129
  );
130
130
 
131
131
  // Build args: <outputPath> <slug1> <rbxl1> <script1> <slug2> <rbxl2> <script2> ...