@quenty/nevermore-cli 4.40.0 → 4.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  3. package/dist/commands/batch-command/batch-deploy-command.js +2 -1
  4. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  5. package/dist/commands/batch-command/batch-test-command.js +2 -0
  6. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  7. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  8. package/dist/commands/deploy-command/index.js +3 -1
  9. package/dist/commands/deploy-command/index.js.map +1 -1
  10. package/dist/utils/deploy/deploy-metadata.d.ts +2 -0
  11. package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
  12. package/dist/utils/deploy/deploy-metadata.js +2 -0
  13. package/dist/utils/deploy/deploy-metadata.js.map +1 -1
  14. package/dist/utils/deploy/deploy-metadata.test.js +8 -0
  15. package/dist/utils/deploy/deploy-metadata.test.js.map +1 -1
  16. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  17. package/dist/utils/job-context/batch-script-job-context.js +5 -1
  18. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  19. package/dist/utils/nevermore-cli-utils.d.ts +1 -0
  20. package/dist/utils/nevermore-cli-utils.d.ts.map +1 -1
  21. package/dist/utils/nevermore-cli-utils.js +10 -0
  22. package/dist/utils/nevermore-cli-utils.js.map +1 -1
  23. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  24. package/dist/utils/open-cloud/open-cloud-client.js +20 -2
  25. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  26. package/dist/utils/testing/parsers/batch-log-parser.d.ts +16 -0
  27. package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
  28. package/dist/utils/testing/parsers/batch-log-parser.js +80 -7
  29. package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
  30. package/dist/utils/testing/parsers/batch-log-parser.test.d.ts +2 -0
  31. package/dist/utils/testing/parsers/batch-log-parser.test.d.ts.map +1 -0
  32. package/dist/utils/testing/parsers/batch-log-parser.test.js +65 -0
  33. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -0
  34. package/dist/utils/testing/reporting/test-github-columns.d.ts.map +1 -1
  35. package/dist/utils/testing/reporting/test-github-columns.js +1 -0
  36. package/dist/utils/testing/reporting/test-github-columns.js.map +1 -1
  37. package/dist/utils/testing/runner/test-runner.d.ts +2 -0
  38. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  39. package/dist/utils/testing/runner/test-runner.js +3 -0
  40. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  41. package/package.json +6 -6
  42. package/src/commands/batch-command/batch-deploy-command.ts +5 -1
  43. package/src/commands/batch-command/batch-test-command.ts +2 -0
  44. package/src/commands/deploy-command/index.ts +7 -1
  45. package/src/utils/deploy/deploy-metadata.test.ts +13 -0
  46. package/src/utils/deploy/deploy-metadata.ts +3 -0
  47. package/src/utils/job-context/batch-script-job-context.ts +5 -1
  48. package/src/utils/nevermore-cli-utils.ts +15 -0
  49. package/src/utils/open-cloud/open-cloud-client.ts +25 -2
  50. package/src/utils/testing/parsers/batch-log-parser.test.ts +85 -0
  51. package/src/utils/testing/parsers/batch-log-parser.ts +98 -7
  52. package/src/utils/testing/reporting/test-github-columns.ts +1 -0
  53. package/src/utils/testing/runner/test-runner.ts +5 -0
  54. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { countTracebacks, parseBatchTestLogs } from './batch-log-parser.js';
4
+
5
+ const SLUG_MAP = new Map([['egghunt2026', 'egghunt2026']]);
6
+
7
+ function buildLogs(section: string): string {
8
+ return [
9
+ '===BATCH_TEST_BEGIN egghunt2026===',
10
+ section,
11
+ '===BATCH_TEST_END egghunt2026 PASS 1000===',
12
+ '===BATCH_TEST_SUMMARY===',
13
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000}]',
14
+ ].join('\n');
15
+ }
16
+
17
+ describe('parseBatchTestLogs', () => {
18
+ it('reads test counts out of a package section', () => {
19
+ const logs = buildLogs('Tests: 275 passed, 275 total');
20
+
21
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
22
+
23
+ expect(result?.testCounts).toEqual({ passed: 275, failed: 0, total: 275 });
24
+ });
25
+
26
+ it('fails a pass it cannot read, rather than trusting the pcall', () => {
27
+ // The summary prints last, so it survives a log window that dropped the
28
+ // head. The pcall said success, but nothing here saw a test run.
29
+ const logs = [
30
+ 'Tests: 275 passed, 275 total',
31
+ '===BATCH_TEST_END egghunt2026 PASS 1000===',
32
+ '===BATCH_TEST_SUMMARY===',
33
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000}]',
34
+ ].join('\n');
35
+
36
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
37
+
38
+ expect(result?.success).toBe(false);
39
+ expect(result?.error).toContain('No test output could be attributed');
40
+ expect(result?.testCounts).toBeUndefined();
41
+ });
42
+
43
+ it('still shows output it could not attribute', () => {
44
+ // 330KB of real test output was being reported as "(no output)" because no
45
+ // BEGIN marker survived to delimit it.
46
+ const logs = [
47
+ 'PlayerBadgeHelper: attempt to index nil',
48
+ '===BATCH_TEST_SUMMARY===',
49
+ '[{"slug":"egghunt2026","success":true,"durationMs":1000}]',
50
+ ].join('\n');
51
+
52
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
53
+
54
+ expect(result?.logs).toContain('PlayerBadgeHelper');
55
+ });
56
+
57
+ it('counts tracebacks that jest reports as a clean pass', () => {
58
+ const logs = buildLogs(
59
+ [
60
+ 'Tests: 155 passed, 155 total',
61
+ 'PlayerBadgeHelper: attempt to index nil',
62
+ 'Stack Begin',
63
+ "Script 'PlayerBadgeHelper', Line 365",
64
+ 'Stack End',
65
+ ].join('\n')
66
+ );
67
+
68
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
69
+
70
+ expect(result?.testCounts?.failed).toBe(0);
71
+ expect(result?.tracebackCount).toBe(1);
72
+ });
73
+ });
74
+
75
+ describe('countTracebacks', () => {
76
+ it('counts each traceback separately', () => {
77
+ expect(countTracebacks('Stack Begin\nfoo\nStack End\nStack Begin\n')).toBe(
78
+ 2
79
+ );
80
+ });
81
+
82
+ it('is zero for empty output', () => {
83
+ expect(countTracebacks('')).toBe(0);
84
+ });
85
+ });
@@ -8,6 +8,14 @@ export interface BatchPackageResult {
8
8
  /** Inner pcall execution time reported by the Luau batch runner. */
9
9
  durationMs?: number;
10
10
  testCounts?: ParsedTestCounts;
11
+ /** Why this package failed, when the failure is the parser's own verdict. */
12
+ error?: string;
13
+ /**
14
+ * Luau tracebacks found in this package's section. Reported, not gated on:
15
+ * jest cannot count these, since a deferred-callback crash fires outside any
16
+ * test — often after the test that scheduled it already passed.
17
+ */
18
+ tracebackCount: number;
11
19
  }
12
20
 
13
21
  const BEGIN_MARKER = '===BATCH_TEST_BEGIN ';
@@ -55,6 +63,8 @@ export function parseBatchTestLogs(
55
63
  let currentSlug: string | null = null;
56
64
  let currentLines: string[] = [];
57
65
  let summaryLineIndex = -1;
66
+ let beginMarkersSeen = 0;
67
+ let endMarkersSeen = 0;
58
68
 
59
69
  // Matches "<slug> PASS|FAIL [<durationMs>]" — slugs have no whitespace.
60
70
  const endInnerPattern = /^(\S+)\s+(?:PASS|FAIL)(?:\s+(\d+))?$/;
@@ -65,11 +75,13 @@ export function parseBatchTestLogs(
65
75
  if (trimmed.startsWith(BEGIN_MARKER) && trimmed.endsWith('===')) {
66
76
  currentSlug = trimmed.slice(BEGIN_MARKER.length, -3);
67
77
  currentLines = [];
78
+ beginMarkersSeen++;
68
79
  continue;
69
80
  }
70
81
 
71
82
  if (trimmed.startsWith(END_MARKER) && trimmed.endsWith('===')) {
72
83
  const inner = trimmed.slice(END_MARKER.length, -3);
84
+ endMarkersSeen++;
73
85
  const match = endInnerPattern.exec(inner);
74
86
  const endSlug = match ? match[1] : inner;
75
87
  const durationStr = match?.[2];
@@ -144,26 +156,76 @@ export function parseBatchTestLogs(
144
156
  );
145
157
  }
146
158
 
159
+ // The summary is printed last, so it survives a log fetch that dropped the
160
+ // output above it. Summary present with no sections is exactly that case, and
161
+ // it is invisible to the check above — the results below would then be built
162
+ // from pcall exit status alone, knowing nothing about what actually ran.
163
+ const lostSectionOutput = logSections.size === 0 && summaryResults.size > 0;
164
+ if (lostSectionOutput) {
165
+ OutputHelper.warn(
166
+ `[batch-log-parser] Received ${rawLogs.length} chars of output but could not ` +
167
+ `attribute any of it to a package section ` +
168
+ `(${beginMarkersSeen} BEGIN, ${endMarkersSeen} END markers; summary at line ` +
169
+ `${summaryLineIndex} of ${lines.length}). ` +
170
+ 'Test counts are not derived from unattributed output.'
171
+ );
172
+ // BEGIN is printed first and the summary last, so their positions separate
173
+ // the two ways this happens: a log window that dropped the head keeps the
174
+ // summary at the end, while reordered messages put it early.
175
+ OutputHelper.warn(
176
+ summaryLineIndex >= lines.length - 2
177
+ ? '[batch-log-parser] Summary sits at the end of the log — consistent with the head being dropped by a log size/retention limit.'
178
+ : '[batch-log-parser] Summary sits early in the log — consistent with out-of-order messages ending the scan prematurely.'
179
+ );
180
+ }
181
+
147
182
  // ── Pass 3: Combine log sections with summary results ──
148
183
 
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() : '';
184
+ // Output that arrived but could not be split into sections is still output.
185
+ // Reporting "(no output)" over it hides the very thing needed to diagnose the
186
+ // parse failure, so it is shown verbatim instead. Only safe to hand to a
187
+ // single package: with several, there is no way to say whose output this is.
188
+ const fallbackLogs = noOutputAtAll || lostSectionOutput ? rawLogs.trim() : '';
189
+
190
+ // The unattributed stream is the same text for every package, so it is
191
+ // attached once rather than repeated per package across a large log.
192
+ let unattributedClaimed = false;
152
193
 
153
194
  for (const [packageName, slug] of slugMap) {
154
- const sectionLogs = logSections.get(slug) ?? fallbackLogs;
195
+ const attributedLogs = logSections.get(slug);
196
+ let sectionLogs = attributedLogs ?? '';
197
+ if (!attributedLogs && fallbackLogs && !unattributedClaimed) {
198
+ sectionLogs = fallbackLogs;
199
+ unattributedClaimed = true;
200
+ }
155
201
  const summarySuccess = summaryResults.get(slug);
156
202
 
157
203
  // The JSON summary (pcall result) is the primary success signal.
158
204
  // Jest failure detection provides a secondary override.
205
+ //
206
+ // Gate on attributed output only. Unattributed output is shown but not
207
+ // judged: whatever broke attribution is reason enough not to trust which
208
+ // package a failure line belongs to.
159
209
  let success = summarySuccess ?? false;
160
- if (success && sectionLogs) {
161
- const hasJestFailures = _hasJestFailuresInLogs(sectionLogs);
210
+ let error: string | undefined;
211
+ if (success && attributedLogs) {
212
+ const hasJestFailures = _hasJestFailuresInLogs(attributedLogs);
162
213
  if (hasJestFailures) {
163
214
  success = false;
215
+ error = 'Jest reported failing tests';
164
216
  }
165
217
  }
166
218
 
219
+ // A pass we cannot read is not a pass. The pcall only proves the script did
220
+ // not throw, which says nothing about whether any test ran or passed.
221
+ if (success && !attributedLogs) {
222
+ success = false;
223
+ error =
224
+ `No test output could be attributed to this package ` +
225
+ `(${rawLogs.length} chars received, ${beginMarkersSeen} BEGIN markers found). ` +
226
+ 'Unattributed output follows.';
227
+ }
228
+
167
229
  if (!success && !noOutputAtAll) {
168
230
  console.error(
169
231
  `[batch-log-parser] ${slug}: summarySuccess=${summarySuccess} hasLogs=${
@@ -172,7 +234,19 @@ export function parseBatchTestLogs(
172
234
  );
173
235
  }
174
236
 
175
- const testCounts = sectionLogs ? parseTestCounts(sectionLogs) : undefined;
237
+ // Deliberately not parsed from unattributed output: attribution is exactly
238
+ // what failed, so a count read from it could belong to anything. The logs
239
+ // are still shown, so the jest summary remains readable by eye.
240
+ const testCounts = attributedLogs
241
+ ? parseTestCounts(attributedLogs)
242
+ : undefined;
243
+ const tracebackCount = countTracebacks(sectionLogs);
244
+ if (tracebackCount > 0) {
245
+ OutputHelper.warn(
246
+ `[batch-log-parser] ${slug}: ${tracebackCount} Luau traceback(s) in output. ` +
247
+ 'Jest does not count these — a deferred-callback crash fires outside any test.'
248
+ );
249
+ }
176
250
  // Prefer the JSON summary (authoritative, immune to log reordering);
177
251
  // fall back to the END-marker value if the summary was truncated.
178
252
  const durationMs = summaryDurations.get(slug) ?? markerDurations.get(slug);
@@ -182,12 +256,29 @@ export function parseBatchTestLogs(
182
256
  logs: sectionLogs,
183
257
  durationMs,
184
258
  testCounts,
259
+ tracebackCount,
260
+ error,
185
261
  });
186
262
  }
187
263
 
188
264
  return results;
189
265
  }
190
266
 
267
+ /**
268
+ * Count Luau tracebacks in log output.
269
+ *
270
+ * Reported separately from test counts because jest never sees these: a crash in
271
+ * a deferred callback fires outside any test, so a run can print
272
+ * "Tests: 155 passed, 0 failed" while something is genuinely broken.
273
+ */
274
+ export function countTracebacks(rawOutput: string): number {
275
+ if (!rawOutput) {
276
+ return 0;
277
+ }
278
+ const cleanLogs = OutputHelper.stripAnsi(rawOutput);
279
+ return cleanLogs.match(/Stack Begin\s/g)?.length ?? 0;
280
+ }
281
+
191
282
  /**
192
283
  * Check specifically for Jest test suite/test failures in logs.
193
284
  * Unlike parseTestLogs, this ignores "Stack Begin" runtime errors since
@@ -19,6 +19,7 @@ export function createTestCommentConfig(): GithubCommentTableConfig {
19
19
  sectionId: 'tests',
20
20
  extraColumns: createTestColumns(),
21
21
  errorHeading: 'Test Results',
22
+ expectsTestCounts: true,
22
23
  };
23
24
  }
24
25
 
@@ -14,6 +14,7 @@ import {
14
14
  injectDeployMetadataInPlaceAsync,
15
15
  packageUsesManifestAsync,
16
16
  } from '../../deploy/deploy-metadata.js';
17
+ import { readPackageVersionAsync } from '../../nevermore-cli-utils.js';
17
18
 
18
19
  export interface SingleTestResult {
19
20
  success: boolean;
@@ -25,6 +26,8 @@ export interface SingleTestResult {
25
26
  * non-aggregated cloud/local runs — callers should fall back to wall-clock.
26
27
  */
27
28
  durationMs?: number;
29
+ /** Why the run failed, when the runner can say more than "it failed". */
30
+ error?: string;
28
31
  }
29
32
 
30
33
  export interface SingleTestOptions {
@@ -78,6 +81,7 @@ export async function runSingleTestAsync(
78
81
  timestamp: new Date().toISOString(),
79
82
  placeId: builtPlace.target.placeId,
80
83
  universeId: builtPlace.target.universeId,
84
+ packageVersion: await readPackageVersionAsync(packagePath),
81
85
  })
82
86
  );
83
87
  }
@@ -107,6 +111,7 @@ export async function runSingleTestAsync(
107
111
  logs: parsed.logs,
108
112
  testCounts: parseTestCounts(parsed.logs),
109
113
  durationMs: result.durationMs,
114
+ error: result.errorMessage,
110
115
  };
111
116
  } finally {
112
117
  await context.releaseAsync(deployment);