@quenty/nevermore-cli 4.41.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 (33) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/commands/batch-command/batch-test-command.js +2 -0
  3. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  4. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  5. package/dist/utils/job-context/batch-script-job-context.js +5 -1
  6. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  7. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  8. package/dist/utils/open-cloud/open-cloud-client.js +20 -2
  9. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  10. package/dist/utils/testing/parsers/batch-log-parser.d.ts +16 -0
  11. package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
  12. package/dist/utils/testing/parsers/batch-log-parser.js +80 -7
  13. package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
  14. package/dist/utils/testing/parsers/batch-log-parser.test.d.ts +2 -0
  15. package/dist/utils/testing/parsers/batch-log-parser.test.d.ts.map +1 -0
  16. package/dist/utils/testing/parsers/batch-log-parser.test.js +65 -0
  17. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -0
  18. package/dist/utils/testing/reporting/test-github-columns.d.ts.map +1 -1
  19. package/dist/utils/testing/reporting/test-github-columns.js +1 -0
  20. package/dist/utils/testing/reporting/test-github-columns.js.map +1 -1
  21. package/dist/utils/testing/runner/test-runner.d.ts +2 -0
  22. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  23. package/dist/utils/testing/runner/test-runner.js +1 -0
  24. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  25. package/package.json +6 -6
  26. package/src/commands/batch-command/batch-test-command.ts +2 -0
  27. package/src/utils/job-context/batch-script-job-context.ts +5 -1
  28. package/src/utils/open-cloud/open-cloud-client.ts +25 -2
  29. package/src/utils/testing/parsers/batch-log-parser.test.ts +85 -0
  30. package/src/utils/testing/parsers/batch-log-parser.ts +98 -7
  31. package/src/utils/testing/reporting/test-github-columns.ts +1 -0
  32. package/src/utils/testing/runner/test-runner.ts +3 -0
  33. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/nevermore-cli",
3
- "version": "4.41.0",
3
+ "version": "4.41.1",
4
4
  "description": "CLI interface for Nevermore",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -25,10 +25,10 @@
25
25
  "Quenty"
26
26
  ],
27
27
  "dependencies": {
28
- "@quenty/cli-output-helpers": "1.17.0",
29
- "@quenty/nevermore-cli-helpers": "1.16.0",
30
- "@quenty/nevermore-template-helpers": "1.18.0",
31
- "@quenty/studio-bridge": "0.17.0",
28
+ "@quenty/cli-output-helpers": "1.17.1",
29
+ "@quenty/nevermore-cli-helpers": "1.16.1",
30
+ "@quenty/nevermore-template-helpers": "1.18.1",
31
+ "@quenty/studio-bridge": "0.17.1",
32
32
  "execa": "^9.6.1",
33
33
  "find-git-root": "^1.0.4",
34
34
  "inquirer": "^13.2.0",
@@ -57,5 +57,5 @@
57
57
  "engines": {
58
58
  "node": ">=16"
59
59
  },
60
- "gitHead": "8bd905a5c7e7f1fd4e178b54beeadef94a615d1d"
60
+ "gitHead": "35993999cfe6fbdfd44721a1835b6ee29602f057"
61
61
  }
@@ -171,6 +171,7 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
171
171
  showLogs: args.logs ?? false,
172
172
  verbose: args.verbose,
173
173
  actionVerb: 'Testing',
174
+ expectsTestCounts: true,
174
175
  })
175
176
  : new SpinnerReporter(state, {
176
177
  showLogs: args.logs ?? false,
@@ -251,6 +252,7 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
251
252
  progressSummary: result.testCounts
252
253
  ? { kind: 'test-counts' as const, ...result.testCounts }
253
254
  : undefined,
255
+ error: result.error,
254
256
  };
255
257
  },
256
258
  });
@@ -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,9 @@ 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;
340
342
 
341
343
  do {
342
344
  const url = new URL(`https://apis.roblox.com/cloud/v2/${taskPath}/logs`);
@@ -371,18 +373,39 @@ export class OpenCloudClient {
371
373
 
372
374
  for (const entry of data.luauExecutionSessionTaskLogs ?? []) {
373
375
  if (entry.structuredMessages?.length) {
376
+ // Keep createTime alongside the text: the API does not guarantee
377
+ // chronological order, and readers downstream assume it.
374
378
  for (const msg of entry.structuredMessages) {
375
- messages.push(msg.message);
379
+ structured.push({
380
+ message: msg.message,
381
+ createTime: msg.createTime,
382
+ });
376
383
  }
377
384
  } else if (entry.messages?.length) {
378
385
  messages.push(...entry.messages);
379
386
  }
380
387
  }
381
388
 
389
+ pagesFetched++;
382
390
  pageToken = data.nextPageToken || undefined;
383
391
  } while (pageToken);
384
392
 
385
- return messages.join('\n');
393
+ // Out-of-order messages silently destroy marker-delimited parsing: a
394
+ // trailing summary that arrives early ends the scan before any of the
395
+ // output it summarizes has been read.
396
+ structured.sort((a, b) => a.createTime.localeCompare(b.createTime));
397
+ messages.push(...structured.map((m) => m.message));
398
+
399
+ const text = messages.join('\n');
400
+
401
+ // Log volume is the prime suspect when engine output goes missing between
402
+ // the task and the parser, and the parser cannot tell a short run from a
403
+ // truncated fetch. Record what arrived so a real run can settle it.
404
+ OutputHelper.verbose(
405
+ `[open-cloud] Task logs: ${pagesFetched} page(s), ${messages.length} messages, ${text.length} chars`
406
+ );
407
+
408
+ return text;
386
409
  }
387
410
 
388
411
  /**
@@ -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
 
@@ -26,6 +26,8 @@ export interface SingleTestResult {
26
26
  * non-aggregated cloud/local runs — callers should fall back to wall-clock.
27
27
  */
28
28
  durationMs?: number;
29
+ /** Why the run failed, when the runner can say more than "it failed". */
30
+ error?: string;
29
31
  }
30
32
 
31
33
  export interface SingleTestOptions {
@@ -109,6 +111,7 @@ export async function runSingleTestAsync(
109
111
  logs: parsed.logs,
110
112
  testCounts: parseTestCounts(parsed.logs),
111
113
  durationMs: result.durationMs,
114
+ error: result.errorMessage,
112
115
  };
113
116
  } finally {
114
117
  await context.releaseAsync(deployment);