@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
@@ -0,0 +1,122 @@
1
+ import { OutputHelper } from '@quenty/cli-output-helpers';
2
+
3
+ export interface AttributedTraceback {
4
+ /** First line of the error, before the stack. */
5
+ message: string;
6
+ /** Roblox instance path of the topmost frame, e.g. "ServerScriptService.Foo.Bar". */
7
+ source?: string;
8
+ /** Root of that path, which names the package the frame belongs to. */
9
+ owner?: string;
10
+ /** Frames kept for display, topmost first. */
11
+ frames: string[];
12
+ /** How many times this same traceback appeared. */
13
+ count: number;
14
+ }
15
+
16
+ const STACK_BEGIN = /^\s*Stack Begin\s*$/;
17
+ const STACK_END = /^\s*Stack End\s*$/;
18
+ // Script 'ServerScriptService.EggHunt2026.game.Server.Foo', Line 365 - function bar
19
+ const FRAME = /Script\s+'(?:\[string\s+")?([^'"\]]+)/;
20
+
21
+ const MAX_FRAMES = 4;
22
+
23
+ /**
24
+ * Pull tracebacks out of log output and attribute each to its owning package.
25
+ *
26
+ * Attribution reads the script path in the frames rather than the position of
27
+ * the traceback in the log. Position is unreliable: a deferred callback can
28
+ * fire during a later package's section, which is why batch mode previously
29
+ * gave up on tracebacks entirely.
30
+ */
31
+ export function parseTracebacks(rawOutput: string): AttributedTraceback[] {
32
+ if (!rawOutput) {
33
+ return [];
34
+ }
35
+
36
+ const lines = OutputHelper.stripAnsi(rawOutput).split('\n');
37
+ const bySignature = new Map<string, AttributedTraceback>();
38
+
39
+ for (let i = 0; i < lines.length; i++) {
40
+ if (!STACK_BEGIN.test(lines[i])) {
41
+ continue;
42
+ }
43
+
44
+ // The error text sits on the line above "Stack Begin".
45
+ const message = (lines[i - 1] ?? '').trim();
46
+
47
+ const frames: string[] = [];
48
+ let source: string | undefined;
49
+ for (let j = i + 1; j < lines.length && !STACK_END.test(lines[j]); j++) {
50
+ const frame = lines[j].trim();
51
+ if (!frame) {
52
+ continue;
53
+ }
54
+ if (frames.length < MAX_FRAMES) {
55
+ frames.push(frame);
56
+ }
57
+ if (!source) {
58
+ source = FRAME.exec(frame)?.[1];
59
+ }
60
+ }
61
+
62
+ // One bug fires per player and per frame, so the same traceback can appear
63
+ // hundreds of times. Collapse by signature and count instead of printing
64
+ // every copy.
65
+ const signature = `${message}::${frames[0] ?? ''}`;
66
+ const existing = bySignature.get(signature);
67
+ if (existing) {
68
+ existing.count++;
69
+ continue;
70
+ }
71
+
72
+ bySignature.set(signature, {
73
+ message,
74
+ source,
75
+ owner: source ? ownerOf(source) : undefined,
76
+ frames,
77
+ count: 1,
78
+ });
79
+ }
80
+
81
+ return [...bySignature.values()];
82
+ }
83
+
84
+ /**
85
+ * Name the package a script path belongs to.
86
+ *
87
+ * Batch places hold each package under its own root, so the segment below the
88
+ * service is the package.
89
+ */
90
+ export function ownerOf(scriptPath: string): string | undefined {
91
+ const parts = scriptPath.split('.');
92
+ return parts.length >= 2 ? parts[1] : undefined;
93
+ }
94
+
95
+ /** Render tracebacks for a human, newest information first and bounded. */
96
+ export function formatTracebacks(
97
+ tracebacks: AttributedTraceback[],
98
+ maxShown = 5
99
+ ): string {
100
+ if (tracebacks.length === 0) {
101
+ return '';
102
+ }
103
+
104
+ const lines: string[] = [];
105
+ const shown = tracebacks.slice(0, maxShown);
106
+
107
+ for (const traceback of shown) {
108
+ const where = traceback.source ? ` (${traceback.source})` : '';
109
+ const times = traceback.count > 1 ? ` ×${traceback.count}` : '';
110
+ lines.push(` ${traceback.message}${where}${times}`);
111
+ for (const frame of traceback.frames) {
112
+ lines.push(` ${frame}`);
113
+ }
114
+ }
115
+
116
+ const hidden = tracebacks.length - shown.length;
117
+ if (hidden > 0) {
118
+ lines.push(` ...and ${hidden} more distinct traceback(s)`);
119
+ }
120
+
121
+ return lines.join('\n');
122
+ }
@@ -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
 
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { mergeFailureReasons } from './test-runner.js';
4
+
5
+ describe('mergeFailureReasons', () => {
6
+ it('does not repeat reasons the context already reported', () => {
7
+ // Batch mode evaluates the same logs upstream, so both sources describe the
8
+ // same failures. Concatenating them read as double the failures.
9
+ const merged = mergeFailureReasons(
10
+ '3 test suite(s) failed; 19 test(s) failed',
11
+ ['3 test suite(s) failed', '19 test(s) failed']
12
+ );
13
+
14
+ expect(merged).toEqual(['3 test suite(s) failed', '19 test(s) failed']);
15
+ });
16
+
17
+ it('keeps reasons only one side knows about', () => {
18
+ const merged = mergeFailureReasons(
19
+ 'the batch runner reported a Luau error',
20
+ ['19 test(s) failed']
21
+ );
22
+
23
+ expect(merged).toHaveLength(2);
24
+ });
25
+
26
+ it('is empty when nothing failed', () => {
27
+ expect(mergeFailureReasons(undefined, [])).toEqual([]);
28
+ });
29
+ });
@@ -26,6 +26,27 @@ 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;
31
+ }
32
+
33
+ /**
34
+ * Combine failure reasons from the job context with those read from the logs.
35
+ *
36
+ * In batch mode the context has already evaluated these same logs, so the two
37
+ * sources overlap. Merging on the individual reason keeps the overlap from
38
+ * reading as double the failures.
39
+ */
40
+ export function mergeFailureReasons(
41
+ contextError: string | undefined,
42
+ logReasons: string[]
43
+ ): string[] {
44
+ return [
45
+ ...new Set([
46
+ ...(contextError ? contextError.split('; ') : []),
47
+ ...logReasons,
48
+ ]),
49
+ ];
29
50
  }
30
51
 
31
52
  export interface SingleTestOptions {
@@ -54,7 +75,10 @@ export async function runSingleTestAsync(
54
75
  const {
55
76
  packagePath,
56
77
  packageName,
57
- timeoutMs = 120_000,
78
+ // 300s is the Open Cloud maximum. The old 120s default failed real suites
79
+ // with no jest summary at all, so "--timeout 300" had become folklore
80
+ // everyone had to know before their first useful run.
81
+ timeoutMs = 300_000,
58
82
  scriptText,
59
83
  target,
60
84
  } = options;
@@ -102,13 +126,24 @@ export async function runSingleTestAsync(
102
126
  });
103
127
 
104
128
  const rawLogs = await context.getLogsAsync(deployment);
105
- const parsed = parseTestLogs(rawLogs);
129
+ // A probe script is arbitrary Luau with no jest in it, so demanding a test
130
+ // report would fail every --script-text run. Everything else must prove a
131
+ // runner spoke before it can pass.
132
+ const parsed = parseTestLogs(rawLogs, {
133
+ requireTestReport: scriptText === undefined,
134
+ });
135
+
136
+ const reasons = mergeFailureReasons(
137
+ result.errorMessage,
138
+ parsed.failureReasons
139
+ );
106
140
 
107
141
  return {
108
142
  success: result.success && parsed.success,
109
143
  logs: parsed.logs,
110
144
  testCounts: parseTestCounts(parsed.logs),
111
145
  durationMs: result.durationMs,
146
+ error: reasons.length > 0 ? reasons.join('; ') : undefined,
112
147
  };
113
148
  } finally {
114
149
  await context.releaseAsync(deployment);
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { evaluateTestOutcome, hasTestReport } from './test-log-parser.js';
4
+
5
+ describe('evaluateTestOutcome', () => {
6
+ it('passes a clean jest report', () => {
7
+ const outcome = evaluateTestOutcome('Tests: 275 passed, 275 total', {
8
+ requireTestReport: true,
9
+ });
10
+
11
+ expect(outcome.success).toBe(true);
12
+ expect(outcome.failureReasons).toEqual([]);
13
+ });
14
+
15
+ it('refuses to pass output with no jest report in it', () => {
16
+ // Absence of failure was previously read as success, so an empty log passed.
17
+ const outcome = evaluateTestOutcome('some unrelated chatter', {
18
+ requireTestReport: true,
19
+ });
20
+
21
+ expect(outcome.success).toBe(false);
22
+ expect(outcome.failureReasons.join()).toContain('no jest report');
23
+ });
24
+
25
+ it('exempts probe runs, which have no jest in them at all', () => {
26
+ const outcome = evaluateTestOutcome('hello from a probe script');
27
+
28
+ expect(outcome.success).toBe(true);
29
+ });
30
+
31
+ it('fails on a traceback even when every test passed', () => {
32
+ // Jest cannot see a deferred-callback crash: it fires outside any test.
33
+ const logs = [
34
+ 'Tests: 155 passed, 155 total',
35
+ 'attempt to index nil',
36
+ 'Stack Begin',
37
+ "Script 'ServerScriptService.egghunt2026.Foo', Line 12",
38
+ 'Stack End',
39
+ ].join('\n');
40
+
41
+ const outcome = evaluateTestOutcome(logs, { requireTestReport: true });
42
+
43
+ expect(outcome.success).toBe(false);
44
+ expect(outcome.failureReasons.join()).toContain('traceback');
45
+ });
46
+
47
+ it('reports every reason a run failed, not just the first', () => {
48
+ const logs = [
49
+ 'Tests: 19 failed, 470 passed, 516 total',
50
+ 'Stack Begin',
51
+ "Script 'ServerScriptService.egghunt2026.Foo', Line 12",
52
+ 'Stack End',
53
+ ].join('\n');
54
+
55
+ const outcome = evaluateTestOutcome(logs, { requireTestReport: true });
56
+
57
+ expect(outcome.failureReasons).toHaveLength(2);
58
+ });
59
+ });
60
+
61
+ describe('hasTestReport', () => {
62
+ it('accepts a runner that ran and found nothing', () => {
63
+ // Packages without specs are legitimate; passWithNoTests depends on this.
64
+ expect(hasTestReport('No tests found, exiting with code 0')).toBe(true);
65
+ });
66
+
67
+ it('rejects silence', () => {
68
+ expect(hasTestReport('')).toBe(false);
69
+ });
70
+ });
@@ -10,27 +10,97 @@ export interface ParsedTestLogs {
10
10
  success: boolean;
11
11
  logs: string;
12
12
  testCounts?: ParsedTestCounts;
13
+ /** Why the run is not a pass. Empty when it is. */
14
+ failureReasons: string[];
15
+ }
16
+
17
+ export interface TestOutcomeOptions {
18
+ /**
19
+ * Require proof the runner ran. Off for probe scripts (--script-text), which
20
+ * legitimately produce no jest report.
21
+ */
22
+ requireTestReport?: boolean;
13
23
  }
14
24
 
15
25
  /**
16
- * Analyze test output for Jest failures and Luau runtime errors.
17
- * Shared by both Open Cloud log fetching and local run-in-roblox output.
26
+ * Decide whether test output represents a pass, and say why when it does not.
27
+ *
28
+ * The single definition of the rule. Both the single-package path and the batch
29
+ * path route through here — they previously carried divergent copies that
30
+ * disagreed about tracebacks, so the same commit could pass one and fail the
31
+ * other.
18
32
  */
19
- export function parseTestLogs(rawOutput: string): ParsedTestLogs {
33
+ export function evaluateTestOutcome(
34
+ rawOutput: string,
35
+ options: TestOutcomeOptions = {}
36
+ ): { success: boolean; failureReasons: string[] } {
20
37
  const cleanLogs = OutputHelper.stripAnsi(rawOutput);
38
+ const failureReasons: string[] = [];
21
39
 
22
- // Check for Jest-style test failures
23
40
  const failedSuites = cleanLogs.match(/Test Suites:\s*(\d+)\s+failed/);
24
41
  const failedTests = cleanLogs.match(/Tests:\s*(\d+)\s+failed/);
25
- const hasJestFailures =
26
- (failedSuites && parseInt(failedSuites[1], 10) > 0) ||
27
- (failedTests && parseInt(failedTests[1], 10) > 0);
42
+ if (failedSuites && parseInt(failedSuites[1], 10) > 0) {
43
+ failureReasons.push(`${failedSuites[1]} test suite(s) failed`);
44
+ }
45
+ if (failedTests && parseInt(failedTests[1], 10) > 0) {
46
+ failureReasons.push(`${failedTests[1]} test(s) failed`);
47
+ }
48
+
49
+ // Tracebacks always fail. They catch use-after-free bugs that jest cannot
50
+ // see, since a deferred-callback crash fires outside any test.
51
+ const tracebacks = countTracebacks(cleanLogs);
52
+ if (tracebacks > 0) {
53
+ failureReasons.push(
54
+ `${tracebacks} Luau traceback(s) — see attributed errors below`
55
+ );
56
+ }
28
57
 
29
- // Check for Luau runtime errors (stack traces)
30
- const hasRuntimeError = /Stack Begin\s/.test(cleanLogs);
58
+ if (options.requireTestReport && !hasTestReport(cleanLogs)) {
59
+ failureReasons.push(
60
+ 'no jest report in output — nothing proves any test ran'
61
+ );
62
+ }
63
+
64
+ return { success: failureReasons.length === 0, failureReasons };
65
+ }
66
+
67
+ /**
68
+ * True when jest reported at all, including reporting that it found nothing.
69
+ *
70
+ * "Ran and found no tests" is a legitimate answer for packages that ship
71
+ * without specs; "we never heard from jest" is not. Keying on the report
72
+ * rather than on a test count keeps `passWithNoTests` working.
73
+ */
74
+ export function hasTestReport(rawOutput: string): boolean {
75
+ const clean = OutputHelper.stripAnsi(rawOutput);
76
+ return (
77
+ /Tests:\s+/.test(clean) ||
78
+ /Test Suites:\s+/.test(clean) ||
79
+ /No tests found/i.test(clean)
80
+ );
81
+ }
82
+
83
+ /** Count Luau tracebacks in output. */
84
+ export function countTracebacks(rawOutput: string): number {
85
+ if (!rawOutput) {
86
+ return 0;
87
+ }
88
+ return OutputHelper.stripAnsi(rawOutput).match(/Stack Begin\s/g)?.length ?? 0;
89
+ }
90
+
91
+ /**
92
+ * Analyze test output for Jest failures and Luau runtime errors.
93
+ * Shared by both Open Cloud log fetching and local run-in-roblox output.
94
+ */
95
+ export function parseTestLogs(
96
+ rawOutput: string,
97
+ options: TestOutcomeOptions = {}
98
+ ): ParsedTestLogs {
99
+ const outcome = evaluateTestOutcome(rawOutput, options);
31
100
 
32
101
  return {
33
- success: !hasJestFailures && !hasRuntimeError,
102
+ success: outcome.success,
103
+ failureReasons: outcome.failureReasons,
34
104
  logs: rawOutput,
35
105
  testCounts: parseTestCounts(rawOutput),
36
106
  };