@quenty/nevermore-cli 4.41.0 → 4.42.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 (63) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  3. package/dist/commands/batch-command/batch-test-command.js +20 -2
  4. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  5. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  6. package/dist/commands/test-command/test-command.js +6 -4
  7. package/dist/commands/test-command/test-command.js.map +1 -1
  8. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  9. package/dist/utils/job-context/batch-script-job-context.js +5 -1
  10. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  11. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  12. package/dist/utils/open-cloud/open-cloud-client.js +23 -2
  13. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  14. package/dist/utils/testing/parsers/batch-log-parser.d.ts +24 -0
  15. package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
  16. package/dist/utils/testing/parsers/batch-log-parser.js +159 -40
  17. package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
  18. package/dist/utils/testing/parsers/batch-log-parser.test.d.ts +2 -0
  19. package/dist/utils/testing/parsers/batch-log-parser.test.d.ts.map +1 -0
  20. package/dist/utils/testing/parsers/batch-log-parser.test.js +117 -0
  21. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -0
  22. package/dist/utils/testing/parsers/traceback-parser.d.ts +31 -0
  23. package/dist/utils/testing/parsers/traceback-parser.d.ts.map +1 -0
  24. package/dist/utils/testing/parsers/traceback-parser.js +91 -0
  25. package/dist/utils/testing/parsers/traceback-parser.js.map +1 -0
  26. package/dist/utils/testing/parsers/traceback-parser.test.d.ts +2 -0
  27. package/dist/utils/testing/parsers/traceback-parser.test.d.ts.map +1 -0
  28. package/dist/utils/testing/parsers/traceback-parser.test.js +45 -0
  29. package/dist/utils/testing/parsers/traceback-parser.test.js.map +1 -0
  30. package/dist/utils/testing/reporting/test-github-columns.d.ts.map +1 -1
  31. package/dist/utils/testing/reporting/test-github-columns.js +1 -0
  32. package/dist/utils/testing/reporting/test-github-columns.js.map +1 -1
  33. package/dist/utils/testing/runner/test-runner.d.ts +10 -0
  34. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  35. package/dist/utils/testing/runner/test-runner.js +28 -2
  36. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  37. package/dist/utils/testing/runner/test-runner.test.d.ts +2 -0
  38. package/dist/utils/testing/runner/test-runner.test.d.ts.map +1 -0
  39. package/dist/utils/testing/runner/test-runner.test.js +18 -0
  40. package/dist/utils/testing/runner/test-runner.test.js.map +1 -0
  41. package/dist/utils/testing/test-log-parser.d.ts +32 -1
  42. package/dist/utils/testing/test-log-parser.d.ts.map +1 -1
  43. package/dist/utils/testing/test-log-parser.js +53 -9
  44. package/dist/utils/testing/test-log-parser.js.map +1 -1
  45. package/dist/utils/testing/test-log-parser.test.d.ts +2 -0
  46. package/dist/utils/testing/test-log-parser.test.d.ts.map +1 -0
  47. package/dist/utils/testing/test-log-parser.test.js +56 -0
  48. package/dist/utils/testing/test-log-parser.test.js.map +1 -0
  49. package/package.json +6 -6
  50. package/src/commands/batch-command/batch-test-command.ts +80 -58
  51. package/src/commands/test-command/test-command.ts +7 -4
  52. package/src/utils/job-context/batch-script-job-context.ts +5 -1
  53. package/src/utils/open-cloud/open-cloud-client.ts +28 -2
  54. package/src/utils/testing/parsers/batch-log-parser.test.ts +150 -0
  55. package/src/utils/testing/parsers/batch-log-parser.ts +201 -49
  56. package/src/utils/testing/parsers/traceback-parser.test.ts +64 -0
  57. package/src/utils/testing/parsers/traceback-parser.ts +122 -0
  58. package/src/utils/testing/reporting/test-github-columns.ts +1 -0
  59. package/src/utils/testing/runner/test-runner.test.ts +29 -0
  60. package/src/utils/testing/runner/test-runner.ts +37 -2
  61. package/src/utils/testing/test-log-parser.test.ts +70 -0
  62. package/src/utils/testing/test-log-parser.ts +80 -10
  63. package/tsconfig.tsbuildinfo +1 -1
@@ -56,9 +56,9 @@ export class TestProjectCommand<T>
56
56
  type: 'string',
57
57
  });
58
58
  args.option('logs', {
59
- describe: 'Show execution logs',
59
+ describe:
60
+ 'Show execution logs. Defaults on with --script-text, whose output is the whole point of the run',
60
61
  type: 'boolean',
61
- default: false,
62
62
  });
63
63
  args.option('universe-id', {
64
64
  describe:
@@ -84,7 +84,7 @@ export class TestProjectCommand<T>
84
84
  });
85
85
  args.option('timeout', {
86
86
  describe:
87
- 'Max script execution time in seconds. Sent to the Open Cloud API so Roblox cancels server-side on overrun (default: 120)',
87
+ 'Max script execution time in seconds. Sent to the Open Cloud API so Roblox cancels server-side on overrun. Max 300s (API limit). (default: 300)',
88
88
  type: 'number',
89
89
  });
90
90
 
@@ -94,7 +94,10 @@ export class TestProjectCommand<T>
94
94
  public handler = async (args: TestProjectArgs) => {
95
95
  const cwd = process.cwd();
96
96
  const packageName = (await readPackageNameAsync(cwd)) ?? path.basename(cwd);
97
- const showLogs = args.logs ?? false;
97
+ // A probe run exists to show its output. Hiding it behind an opt-in flag
98
+ // made --script-text look broken enough that a workaround was written into
99
+ // the docs instead of a bug report.
100
+ const showLogs = args.logs ?? args.scriptText !== undefined;
98
101
  const useSpinner = process.stdout.isTTY && !args.verbose;
99
102
 
100
103
  const reporter = new CompositeReporter(
@@ -113,7 +113,11 @@ export class BatchScriptJobContext implements JobContext {
113
113
  return { success: false };
114
114
  }
115
115
 
116
- return { success: result.success, durationMs: result.durationMs };
116
+ return {
117
+ success: result.success,
118
+ durationMs: result.durationMs,
119
+ errorMessage: result.error,
120
+ };
117
121
  }
118
122
 
119
123
  async getLogsAsync(deployment: Deployment): Promise<string> {
@@ -336,7 +336,10 @@ export class OpenCloudClient {
336
336
  private async _fetchRawLogsAsync(taskPath: string): Promise<string> {
337
337
  const apiKey = await this._resolveApiKeyAsync();
338
338
  const messages: string[] = [];
339
+ const structured: { message: string; createTime: string }[] = [];
339
340
  let pageToken: string | undefined;
341
+ let pagesFetched = 0;
342
+ let entriesFetched = 0;
340
343
 
341
344
  do {
342
345
  const url = new URL(`https://apis.roblox.com/cloud/v2/${taskPath}/logs`);
@@ -371,18 +374,41 @@ export class OpenCloudClient {
371
374
 
372
375
  for (const entry of data.luauExecutionSessionTaskLogs ?? []) {
373
376
  if (entry.structuredMessages?.length) {
377
+ // Keep createTime alongside the text: the API does not guarantee
378
+ // chronological order, and readers downstream assume it.
374
379
  for (const msg of entry.structuredMessages) {
375
- messages.push(msg.message);
380
+ structured.push({
381
+ message: msg.message,
382
+ createTime: msg.createTime,
383
+ });
376
384
  }
377
385
  } else if (entry.messages?.length) {
378
386
  messages.push(...entry.messages);
379
387
  }
380
388
  }
381
389
 
390
+ entriesFetched += (data.luauExecutionSessionTaskLogs ?? []).length;
391
+ pagesFetched++;
382
392
  pageToken = data.nextPageToken || undefined;
383
393
  } while (pageToken);
384
394
 
385
- return messages.join('\n');
395
+ // Out-of-order messages silently destroy marker-delimited parsing: a
396
+ // trailing summary that arrives early ends the scan before any of the
397
+ // output it summarizes has been read.
398
+ structured.sort((a, b) => a.createTime.localeCompare(b.createTime));
399
+ messages.push(...structured.map((m) => m.message));
400
+
401
+ const text = messages.join('\n');
402
+
403
+ // Log volume is the prime suspect when engine output goes missing between
404
+ // the task and the parser, and the parser cannot tell a short run from a
405
+ // truncated fetch. Record what arrived so a real run can settle it.
406
+ OutputHelper.verbose(
407
+ `[open-cloud] Task logs: ${pagesFetched} page(s), ${entriesFetched} entries, ` +
408
+ `${messages.length} messages, ${text.length} chars`
409
+ );
410
+
411
+ return text;
386
412
  }
387
413
 
388
414
  /**
@@ -0,0 +1,150 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ countTracebacks,
5
+ findSummaryEntries,
6
+ parseBatchTestLogs,
7
+ } from './batch-log-parser.js';
8
+
9
+ const SLUG_MAP = new Map([['egghunt2026', 'egghunt2026']]);
10
+
11
+ function buildLogs(section: string): string {
12
+ return [
13
+ '===BATCH_TEST_BEGIN egghunt2026===',
14
+ section,
15
+ '===BATCH_TEST_END egghunt2026 PASS 1000===',
16
+ '===BATCH_TEST_SUMMARY===',
17
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000}]',
18
+ ].join('\n');
19
+ }
20
+
21
+ describe('parseBatchTestLogs', () => {
22
+ it('reads test counts out of a package section', () => {
23
+ const logs = buildLogs('Tests: 275 passed, 275 total');
24
+
25
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
26
+
27
+ expect(result?.testCounts).toEqual({ passed: 275, failed: 0, total: 275 });
28
+ });
29
+
30
+ it('attributes a section closed by END when its BEGIN was dropped', () => {
31
+ // Open Cloud keeps only the tail of a long run's log, so BEGIN — printed
32
+ // first — is the first casualty while END and the summary survive.
33
+ const logs = [
34
+ ' ✓ some test that survived (3 ms)',
35
+ 'Tests: 275 passed, 275 total',
36
+ '===BATCH_TEST_END egghunt2026 PASS 1000===',
37
+ '===BATCH_TEST_SUMMARY===',
38
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000}]',
39
+ ].join('\n');
40
+
41
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
42
+
43
+ expect(result?.success).toBe(true);
44
+ expect(result?.testCounts).toEqual({ passed: 275, failed: 0, total: 275 });
45
+ });
46
+
47
+ it('does not let an out-of-order END steal another package’s output', () => {
48
+ // The API does not deliver messages in order, so a stray END can arrive
49
+ // mid-section. Only the first section can have lost its head; past that the
50
+ // log is well-formed and a BEGIN-less END must not claim anything.
51
+ const twoPackages = new Map([
52
+ ['alpha', 'alpha'],
53
+ ['beta', 'beta'],
54
+ ]);
55
+ const logs = [
56
+ '===BATCH_TEST_BEGIN alpha===',
57
+ 'Tests: 10 passed, 10 total',
58
+ '===BATCH_TEST_END beta PASS 5===',
59
+ '===BATCH_TEST_END alpha PASS 10===',
60
+ '===BATCH_TEST_SUMMARY===',
61
+ '[{"slug":"alpha","success":true},{"slug":"beta","success":true}]',
62
+ ].join('\n');
63
+
64
+ const results = parseBatchTestLogs(logs, twoPackages);
65
+
66
+ expect(results.get('alpha')?.testCounts?.total).toBe(10);
67
+ // beta never had a section of its own; it must not inherit alpha's.
68
+ expect(results.get('beta')?.testCounts).toBeUndefined();
69
+ expect(results.get('beta')?.success).toBe(false);
70
+ });
71
+
72
+ it('fails a pass it cannot read, rather than trusting the pcall', () => {
73
+ // No END marker either, so there is no boundary to attribute output with.
74
+ // The pcall said success, but nothing here saw a test run.
75
+ const logs = [
76
+ 'Tests: 275 passed, 275 total',
77
+ '===BATCH_TEST_SUMMARY===',
78
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000}]',
79
+ ].join('\n');
80
+
81
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
82
+
83
+ expect(result?.success).toBe(false);
84
+ expect(result?.error).toContain('no output could be attributed');
85
+ expect(result?.testCounts).toBeUndefined();
86
+ });
87
+
88
+ it('still shows output it could not attribute', () => {
89
+ // 330KB of real test output was being reported as "(no output)" because no
90
+ // BEGIN marker survived to delimit it.
91
+ const logs = [
92
+ 'PlayerBadgeHelper: attempt to index nil',
93
+ '===BATCH_TEST_SUMMARY===',
94
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000}]',
95
+ ].join('\n');
96
+
97
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
98
+
99
+ expect(result?.logs).toContain('PlayerBadgeHelper');
100
+ });
101
+
102
+ it('counts tracebacks that jest reports as a clean pass', () => {
103
+ const logs = buildLogs(
104
+ [
105
+ 'Tests: 155 passed, 155 total',
106
+ 'PlayerBadgeHelper: attempt to index nil',
107
+ 'Stack Begin',
108
+ "Script 'PlayerBadgeHelper', Line 365",
109
+ 'Stack End',
110
+ ].join('\n')
111
+ );
112
+
113
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
114
+
115
+ expect(result?.testCounts?.failed).toBe(0);
116
+ expect(result?.tracebackCount).toBe(1);
117
+ });
118
+ });
119
+
120
+ describe('findSummaryEntries', () => {
121
+ it('finds the summary even when output landed after it', () => {
122
+ // Messages are not delivered in order, so a stray line can follow the
123
+ // summary. Treating the whole remainder as one JSON blob made the same
124
+ // batch parse on one run and fail on the next.
125
+ const lines = [
126
+ '[{"slug":"alpha","success":true}]',
127
+ ' ✓ a stray line that arrived late',
128
+ ];
129
+
130
+ expect(findSummaryEntries(lines, 0)).toEqual([
131
+ { slug: 'alpha', success: true },
132
+ ]);
133
+ });
134
+
135
+ it('returns undefined when there is no array to find', () => {
136
+ expect(findSummaryEntries(['not json at all'], 0)).toBeUndefined();
137
+ });
138
+ });
139
+
140
+ describe('countTracebacks', () => {
141
+ it('counts each traceback separately', () => {
142
+ expect(countTracebacks('Stack Begin\nfoo\nStack End\nStack Begin\n')).toBe(
143
+ 2
144
+ );
145
+ });
146
+
147
+ it('is zero for empty output', () => {
148
+ expect(countTracebacks('')).toBe(0);
149
+ });
150
+ });
@@ -1,5 +1,10 @@
1
1
  import { OutputHelper } from '@quenty/cli-output-helpers';
2
- import { type ParsedTestCounts, parseTestCounts } from '../test-log-parser.js';
2
+ import {
3
+ type ParsedTestCounts,
4
+ evaluateTestOutcome,
5
+ parseTestCounts,
6
+ } from '../test-log-parser.js';
7
+ import { formatTracebacks, parseTracebacks } from './traceback-parser.js';
3
8
 
4
9
  export interface BatchPackageResult {
5
10
  slug: string;
@@ -8,6 +13,14 @@ export interface BatchPackageResult {
8
13
  /** Inner pcall execution time reported by the Luau batch runner. */
9
14
  durationMs?: number;
10
15
  testCounts?: ParsedTestCounts;
16
+ /** Why this package failed, when the failure is the parser's own verdict. */
17
+ error?: string;
18
+ /**
19
+ * Luau tracebacks found in this package's section. Reported, not gated on:
20
+ * jest cannot count these, since a deferred-callback crash fires outside any
21
+ * test — often after the test that scheduled it already passed.
22
+ */
23
+ tracebackCount: number;
11
24
  }
12
25
 
13
26
  const BEGIN_MARKER = '===BATCH_TEST_BEGIN ';
@@ -51,10 +64,17 @@ export function parseBatchTestLogs(
51
64
  // ── Pass 1: Extract per-package log sections from markers ──
52
65
 
53
66
  const logSections = new Map<string, string>();
67
+ /** Sections closed by an END whose BEGIN never arrived — start of output lost. */
68
+ const partialSections = new Set<string>();
54
69
  const markerDurations = new Map<string, number>();
55
70
  let currentSlug: string | null = null;
56
71
  let currentLines: string[] = [];
57
72
  let summaryLineIndex = -1;
73
+ let beginMarkersSeen = 0;
74
+ let endMarkersSeen = 0;
75
+ let strayEndMarkers = 0;
76
+ /** Only one section can have lost its head: the one the log was cut inside. */
77
+ let headSectionClaimed = false;
58
78
 
59
79
  // Matches "<slug> PASS|FAIL [<durationMs>]" — slugs have no whitespace.
60
80
  const endInnerPattern = /^(\S+)\s+(?:PASS|FAIL)(?:\s+(\d+))?$/;
@@ -65,25 +85,47 @@ export function parseBatchTestLogs(
65
85
  if (trimmed.startsWith(BEGIN_MARKER) && trimmed.endsWith('===')) {
66
86
  currentSlug = trimmed.slice(BEGIN_MARKER.length, -3);
67
87
  currentLines = [];
88
+ beginMarkersSeen++;
68
89
  continue;
69
90
  }
70
91
 
71
92
  if (trimmed.startsWith(END_MARKER) && trimmed.endsWith('===')) {
72
93
  const inner = trimmed.slice(END_MARKER.length, -3);
94
+ endMarkersSeen++;
73
95
  const match = endInnerPattern.exec(inner);
74
96
  const endSlug = match ? match[1] : inner;
75
97
  const durationStr = match?.[2];
76
98
 
77
- if (currentSlug && endSlug === currentSlug) {
78
- logSections.set(currentSlug, currentLines.join('\n'));
99
+ // A section normally closes on the END matching its own BEGIN.
100
+ const closesOwnSection = endSlug === currentSlug;
101
+
102
+ // Open Cloud keeps only the tail of a long run's log, so BEGIN — printed
103
+ // first — can be lost while END and the summary survive. Recovering that
104
+ // means letting an END with no open section claim what precedes it, but
105
+ // only where the head can actually have been dropped: before any BEGIN
106
+ // has survived, and only once. Past that point the log is well-formed,
107
+ // and a BEGIN-less END is a message delivered out of order (the API does
108
+ // not order them), which must not be allowed to claim another package's
109
+ // output.
110
+ const closesDroppedHead =
111
+ currentSlug === null && beginMarkersSeen === 0 && !headSectionClaimed;
112
+
113
+ if (closesOwnSection || closesDroppedHead) {
114
+ logSections.set(endSlug, currentLines.join('\n'));
115
+ if (closesDroppedHead) {
116
+ partialSections.add(endSlug);
117
+ headSectionClaimed = true;
118
+ }
79
119
  if (durationStr !== undefined) {
80
- markerDurations.set(currentSlug, parseInt(durationStr, 10));
120
+ markerDurations.set(endSlug, parseInt(durationStr, 10));
81
121
  }
82
122
  currentSlug = null;
83
123
  currentLines = [];
124
+ } else {
125
+ // Reordered marker from another package. Ignore it without resetting
126
+ // state, so the section it interrupted still closes on its own END.
127
+ strayEndMarkers++;
84
128
  }
85
- // If endSlug doesn't match currentSlug, this is a reordered marker
86
- // from another package — ignore it without resetting state.
87
129
  continue;
88
130
  }
89
131
 
@@ -92,9 +134,9 @@ export function parseBatchTestLogs(
92
134
  break;
93
135
  }
94
136
 
95
- if (currentSlug) {
96
- currentLines.push(lines[i]);
97
- }
137
+ // Accumulate unconditionally: output that precedes the first surviving
138
+ // BEGIN still belongs to whichever package's END closes it.
139
+ currentLines.push(lines[i]);
98
140
  }
99
141
 
100
142
  // ── Pass 2: Parse the JSON summary for authoritative pcall results ──
@@ -102,12 +144,16 @@ export function parseBatchTestLogs(
102
144
  const summaryResults = new Map<string, boolean>();
103
145
  const summaryDurations = new Map<string, number>();
104
146
  if (summaryLineIndex >= 0 && summaryLineIndex + 1 < lines.length) {
105
- const jsonLine = lines
106
- .slice(summaryLineIndex + 1)
107
- .join('\n')
108
- .trim();
109
- try {
110
- const entries = JSON.parse(jsonLine) as SummaryEntry[];
147
+ const entries = findSummaryEntries(lines, summaryLineIndex + 1);
148
+ if (entries === undefined) {
149
+ OutputHelper.verbose(
150
+ `[batch-log-parser] Failed to parse JSON summary after line ${summaryLineIndex}: ` +
151
+ lines
152
+ .slice(summaryLineIndex + 1, summaryLineIndex + 3)
153
+ .join('\n')
154
+ .slice(0, 200)
155
+ );
156
+ } else {
111
157
  for (const entry of entries) {
112
158
  summaryResults.set(entry.slug, entry.success);
113
159
  if (typeof entry.durationMs === 'number') {
@@ -122,20 +168,20 @@ export function parseBatchTestLogs(
122
168
  );
123
169
  }
124
170
  console.error(
125
- `[batch-log-parser] Parsed ${entries.length} summary entries, ${failures.length} failures`
126
- );
127
- } catch {
128
- OutputHelper.verbose(
129
- `[batch-log-parser] Failed to parse JSON summary: ${jsonLine.slice(
130
- 0,
131
- 200
132
- )}`
171
+ `[batch-log-parser] Parsed ${entries.length} summary entries, ${failures.length} pcall failures`
133
172
  );
134
173
  }
135
174
  }
136
175
 
137
176
  // ── Warn when the batch produced no recognizable output ──
138
177
 
178
+ if (strayEndMarkers > 0) {
179
+ OutputHelper.verbose(
180
+ `[batch-log-parser] Ignored ${strayEndMarkers} out-of-order END marker(s); ` +
181
+ 'their sections closed on their own boundaries.'
182
+ );
183
+ }
184
+
139
185
  const noOutputAtAll = logSections.size === 0 && summaryResults.size === 0;
140
186
  if (noOutputAtAll) {
141
187
  OutputHelper.warn(
@@ -144,35 +190,111 @@ export function parseBatchTestLogs(
144
190
  );
145
191
  }
146
192
 
193
+ // The summary is printed last, so it survives a log fetch that dropped the
194
+ // output above it. Summary present with no sections is exactly that case, and
195
+ // it is invisible to the check above — the results below would then be built
196
+ // from pcall exit status alone, knowing nothing about what actually ran.
197
+ const lostSectionOutput = logSections.size === 0 && summaryResults.size > 0;
198
+ if (lostSectionOutput) {
199
+ OutputHelper.warn(
200
+ `[batch-log-parser] Received ${rawLogs.length} chars of output but could not ` +
201
+ `attribute any of it to a package section ` +
202
+ `(${beginMarkersSeen} BEGIN, ${endMarkersSeen} END markers; summary at line ` +
203
+ `${summaryLineIndex} of ${lines.length}). ` +
204
+ 'Test counts are not derived from unattributed output.'
205
+ );
206
+ // BEGIN is printed first and the summary last, so their positions separate
207
+ // the two ways this happens: a log window that dropped the head keeps the
208
+ // summary at the end, while reordered messages put it early.
209
+ OutputHelper.warn(
210
+ summaryLineIndex >= lines.length - 2
211
+ ? '[batch-log-parser] Summary sits at the end of the log — consistent with the head being dropped by a log size/retention limit.'
212
+ : '[batch-log-parser] Summary sits early in the log — consistent with out-of-order messages ending the scan prematurely.'
213
+ );
214
+ }
215
+
147
216
  // ── Pass 3: Combine log sections with summary results ──
148
217
 
149
- // When the batch produced zero output, surface the raw error in every
150
- // package's logs so reporters show the actual failure instead of "(no output)".
151
- const fallbackLogs = noOutputAtAll ? rawLogs.trim() : '';
218
+ // Output that arrived but could not be split into sections is still output.
219
+ // Reporting "(no output)" over it hides the very thing needed to diagnose the
220
+ // parse failure, so it is shown verbatim instead. Only safe to hand to a
221
+ // single package: with several, there is no way to say whose output this is.
222
+ const fallbackLogs = noOutputAtAll || lostSectionOutput ? rawLogs.trim() : '';
223
+
224
+ // The unattributed stream is the same text for every package, so it is
225
+ // attached once rather than repeated per package across a large log.
226
+ let unattributedClaimed = false;
152
227
 
153
228
  for (const [packageName, slug] of slugMap) {
154
- const sectionLogs = logSections.get(slug) ?? fallbackLogs;
229
+ const attributedLogs = logSections.get(slug);
230
+ let sectionLogs = attributedLogs ?? '';
231
+ if (!attributedLogs && fallbackLogs && !unattributedClaimed) {
232
+ sectionLogs = fallbackLogs;
233
+ unattributedClaimed = true;
234
+ }
155
235
  const summarySuccess = summaryResults.get(slug);
236
+ const reasons: string[] = [];
156
237
 
157
- // The JSON summary (pcall result) is the primary success signal.
158
- // Jest failure detection provides a secondary override.
238
+ // The pcall result only proves the script did not throw. It is a floor, not
239
+ // a verdict — everything below can fail a run it called successful.
159
240
  let success = summarySuccess ?? false;
160
- if (success && sectionLogs) {
161
- const hasJestFailures = _hasJestFailuresInLogs(sectionLogs);
162
- if (hasJestFailures) {
241
+ if (summarySuccess === false) {
242
+ reasons.push('the batch runner reported a Luau error');
243
+ } else if (summarySuccess === undefined) {
244
+ // Absent from the summary is a different fault from failing in it, and
245
+ // conflating them sends you hunting for a Luau error that never happened.
246
+ reasons.push('this package is missing from the batch summary');
247
+ }
248
+
249
+ if (attributedLogs !== undefined) {
250
+ const outcome = evaluateTestOutcome(attributedLogs, {
251
+ requireTestReport: true,
252
+ });
253
+ if (!outcome.success) {
163
254
  success = false;
255
+ reasons.push(...outcome.failureReasons);
164
256
  }
257
+ } else {
258
+ // Judged unreadable rather than judged by content: whatever broke
259
+ // attribution is reason enough not to trust which package a line is from.
260
+ success = false;
261
+ reasons.push(
262
+ `no output could be attributed to this package ` +
263
+ `(${rawLogs.length} chars received, ${beginMarkersSeen} BEGIN markers found)`
264
+ );
165
265
  }
166
266
 
167
- if (!success && !noOutputAtAll) {
168
- console.error(
169
- `[batch-log-parser] ${slug}: summarySuccess=${summarySuccess} hasLogs=${
170
- sectionLogs.length > 0
171
- } logsLen=${sectionLogs.length}`
267
+ if (partialSections.has(slug)) {
268
+ OutputHelper.warn(
269
+ `[batch-log-parser] ${slug}: section closed by its END marker with no BEGIN — ` +
270
+ 'the start of this output was dropped by the log window, so the section is partial.'
172
271
  );
173
272
  }
174
273
 
175
- const testCounts = sectionLogs ? parseTestCounts(sectionLogs) : undefined;
274
+ // Attribute by script path, not by log position: a deferred callback can
275
+ // fire during another package's section.
276
+ const tracebacks = parseTracebacks(sectionLogs);
277
+ const tracebackCount = tracebacks.reduce((sum, t) => sum + t.count, 0);
278
+ const owned = tracebacks.filter((t) => !t.owner || t.owner === slug);
279
+ if (tracebacks.length > owned.length) {
280
+ OutputHelper.verbose(
281
+ `[batch-log-parser] ${slug}: ignored ${
282
+ tracebacks.length - owned.length
283
+ } traceback(s) belonging to other packages`
284
+ );
285
+ }
286
+ if (owned.length > 0) {
287
+ OutputHelper.warn(
288
+ `[batch-log-parser] ${slug}: ${owned.length} distinct traceback(s):\n` +
289
+ formatTracebacks(owned)
290
+ );
291
+ }
292
+
293
+ const error = reasons.length > 0 ? reasons.join('; ') : undefined;
294
+
295
+ const testCounts = attributedLogs
296
+ ? parseTestCounts(attributedLogs)
297
+ : undefined;
176
298
  // Prefer the JSON summary (authoritative, immune to log reordering);
177
299
  // fall back to the END-marker value if the summary was truncated.
178
300
  const durationMs = summaryDurations.get(slug) ?? markerDurations.get(slug);
@@ -182,6 +304,8 @@ export function parseBatchTestLogs(
182
304
  logs: sectionLogs,
183
305
  durationMs,
184
306
  testCounts,
307
+ tracebackCount,
308
+ error,
185
309
  });
186
310
  }
187
311
 
@@ -189,16 +313,44 @@ export function parseBatchTestLogs(
189
313
  }
190
314
 
191
315
  /**
192
- * Check specifically for Jest test suite/test failures in logs.
193
- * Unlike parseTestLogs, this ignores "Stack Begin" runtime errors since
194
- * those can come from deferred callbacks of other packages in batch mode.
316
+ * Find the summary array in the lines following the summary marker.
317
+ *
318
+ * The marker is printed last, but the API does not deliver messages in order,
319
+ * so unrelated output can land after it. Treating everything past the marker as
320
+ * one JSON blob therefore fails intermittently — the same batch parses on one
321
+ * run and not the next. Scan for the array instead of assuming it is alone.
195
322
  */
196
- function _hasJestFailuresInLogs(rawOutput: string): boolean {
197
- const cleanLogs = OutputHelper.stripAnsi(rawOutput);
198
- const failedSuites = cleanLogs.match(/Test Suites:\s*(\d+)\s+failed/);
199
- const failedTests = cleanLogs.match(/Tests:\s*(\d+)\s+failed/);
200
- return (
201
- (failedSuites != null && parseInt(failedSuites[1], 10) > 0) ||
202
- (failedTests != null && parseInt(failedTests[1], 10) > 0)
203
- );
323
+ export function findSummaryEntries(
324
+ lines: string[],
325
+ startIndex: number
326
+ ): SummaryEntry[] | undefined {
327
+ const MAX_SPAN = 50;
328
+
329
+ for (let i = startIndex; i < lines.length; i++) {
330
+ if (!lines[i].trim().startsWith('[')) {
331
+ continue;
332
+ }
333
+
334
+ // Normally one line, but allow the array to span a few in case the runner
335
+ // or the transport breaks it up.
336
+ for (let end = i; end < Math.min(lines.length, i + MAX_SPAN); end++) {
337
+ try {
338
+ const parsed = JSON.parse(
339
+ lines
340
+ .slice(i, end + 1)
341
+ .join('\n')
342
+ .trim()
343
+ );
344
+ if (Array.isArray(parsed)) {
345
+ return parsed as SummaryEntry[];
346
+ }
347
+ } catch {
348
+ // Incomplete so far — grow the window and try again.
349
+ }
350
+ }
351
+ }
352
+
353
+ return undefined;
204
354
  }
355
+
356
+ export { countTracebacks } from '../test-log-parser.js';
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ formatTracebacks,
5
+ ownerOf,
6
+ parseTracebacks,
7
+ } from './traceback-parser.js';
8
+
9
+ const TRACEBACK = [
10
+ 'PlayerBadgeHelper: attempt to index nil with UserId',
11
+ 'Stack Begin',
12
+ 'Script \'[string "ServerScriptService.egghunt2026.game.Server.PlayerBadgeHelper"]\', Line 365 - function _awardBadge',
13
+ 'Script \'[string "ServerScriptService.egghunt2026.game.Server.PlayerBadgeHelper"]\', Line 138',
14
+ 'Stack End',
15
+ ].join('\n');
16
+
17
+ describe('parseTracebacks', () => {
18
+ it('attributes a traceback to the package that owns the failing script', () => {
19
+ const [traceback] = parseTracebacks(TRACEBACK);
20
+
21
+ expect(traceback.owner).toBe('egghunt2026');
22
+ expect(traceback.source).toContain('PlayerBadgeHelper');
23
+ expect(traceback.message).toContain('attempt to index nil');
24
+ });
25
+
26
+ it('collapses repeats of the same crash into one entry with a count', () => {
27
+ // One bug fires per player per frame, so raw output can hold hundreds of
28
+ // identical copies. Printing them all is what makes logs unreadable.
29
+ const repeated = [TRACEBACK, TRACEBACK, TRACEBACK].join('\n');
30
+
31
+ const tracebacks = parseTracebacks(repeated);
32
+
33
+ expect(tracebacks).toHaveLength(1);
34
+ expect(tracebacks[0].count).toBe(3);
35
+ });
36
+
37
+ it('keeps distinct crashes apart', () => {
38
+ const other = TRACEBACK.replace('PlayerBadgeHelper', 'TrainHelper');
39
+
40
+ expect(parseTracebacks([TRACEBACK, other].join('\n'))).toHaveLength(2);
41
+ });
42
+
43
+ it('finds nothing in clean output', () => {
44
+ expect(parseTracebacks('Tests: 10 passed, 10 total')).toEqual([]);
45
+ });
46
+ });
47
+
48
+ describe('ownerOf', () => {
49
+ it('names the package below the service', () => {
50
+ expect(ownerOf('ServerScriptService.brio.Foo.Bar')).toBe('brio');
51
+ });
52
+ });
53
+
54
+ describe('formatTracebacks', () => {
55
+ it('bounds how many it prints and says how many it withheld', () => {
56
+ const many = Array.from({ length: 8 }, (_, i) =>
57
+ TRACEBACK.replace('PlayerBadgeHelper:', `Helper${i}:`)
58
+ ).join('\n');
59
+
60
+ const formatted = formatTracebacks(parseTracebacks(many), 5);
61
+
62
+ expect(formatted).toContain('and 3 more distinct traceback(s)');
63
+ });
64
+ });