@quenty/nevermore-cli 4.41.1 → 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.
- package/CHANGELOG.md +6 -0
- package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-test-command.js +18 -2
- package/dist/commands/batch-command/batch-test-command.js.map +1 -1
- package/dist/commands/test-command/test-command.d.ts.map +1 -1
- package/dist/commands/test-command/test-command.js +6 -4
- package/dist/commands/test-command/test-command.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +4 -1
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.d.ts +13 -5
- package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.js +114 -68
- package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.js +57 -5
- package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
- package/dist/utils/testing/parsers/traceback-parser.d.ts +31 -0
- package/dist/utils/testing/parsers/traceback-parser.d.ts.map +1 -0
- package/dist/utils/testing/parsers/traceback-parser.js +91 -0
- package/dist/utils/testing/parsers/traceback-parser.js.map +1 -0
- package/dist/utils/testing/parsers/traceback-parser.test.d.ts +2 -0
- package/dist/utils/testing/parsers/traceback-parser.test.d.ts.map +1 -0
- package/dist/utils/testing/parsers/traceback-parser.test.js +45 -0
- package/dist/utils/testing/parsers/traceback-parser.test.js.map +1 -0
- package/dist/utils/testing/runner/test-runner.d.ts +8 -0
- package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
- package/dist/utils/testing/runner/test-runner.js +28 -3
- package/dist/utils/testing/runner/test-runner.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.test.d.ts +2 -0
- package/dist/utils/testing/runner/test-runner.test.d.ts.map +1 -0
- package/dist/utils/testing/runner/test-runner.test.js +18 -0
- package/dist/utils/testing/runner/test-runner.test.js.map +1 -0
- package/dist/utils/testing/test-log-parser.d.ts +32 -1
- package/dist/utils/testing/test-log-parser.d.ts.map +1 -1
- package/dist/utils/testing/test-log-parser.js +53 -9
- package/dist/utils/testing/test-log-parser.js.map +1 -1
- package/dist/utils/testing/test-log-parser.test.d.ts +2 -0
- package/dist/utils/testing/test-log-parser.test.d.ts.map +1 -0
- package/dist/utils/testing/test-log-parser.test.js +56 -0
- package/dist/utils/testing/test-log-parser.test.js.map +1 -0
- package/package.json +2 -2
- package/src/commands/batch-command/batch-test-command.ts +78 -58
- package/src/commands/test-command/test-command.ts +7 -4
- package/src/utils/open-cloud/open-cloud-client.ts +4 -1
- package/src/utils/testing/parsers/batch-log-parser.test.ts +70 -5
- package/src/utils/testing/parsers/batch-log-parser.ts +141 -80
- package/src/utils/testing/parsers/traceback-parser.test.ts +64 -0
- package/src/utils/testing/parsers/traceback-parser.ts +122 -0
- package/src/utils/testing/runner/test-runner.test.ts +29 -0
- package/src/utils/testing/runner/test-runner.ts +35 -3
- package/src/utils/testing/test-log-parser.test.ts +70 -0
- package/src/utils/testing/test-log-parser.ts +80 -10
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
2
|
-
import {
|
|
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;
|
|
@@ -59,12 +64,17 @@ export function parseBatchTestLogs(
|
|
|
59
64
|
// ── Pass 1: Extract per-package log sections from markers ──
|
|
60
65
|
|
|
61
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>();
|
|
62
69
|
const markerDurations = new Map<string, number>();
|
|
63
70
|
let currentSlug: string | null = null;
|
|
64
71
|
let currentLines: string[] = [];
|
|
65
72
|
let summaryLineIndex = -1;
|
|
66
73
|
let beginMarkersSeen = 0;
|
|
67
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;
|
|
68
78
|
|
|
69
79
|
// Matches "<slug> PASS|FAIL [<durationMs>]" — slugs have no whitespace.
|
|
70
80
|
const endInnerPattern = /^(\S+)\s+(?:PASS|FAIL)(?:\s+(\d+))?$/;
|
|
@@ -86,16 +96,36 @@ export function parseBatchTestLogs(
|
|
|
86
96
|
const endSlug = match ? match[1] : inner;
|
|
87
97
|
const durationStr = match?.[2];
|
|
88
98
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|
|
91
119
|
if (durationStr !== undefined) {
|
|
92
|
-
markerDurations.set(
|
|
120
|
+
markerDurations.set(endSlug, parseInt(durationStr, 10));
|
|
93
121
|
}
|
|
94
122
|
currentSlug = null;
|
|
95
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++;
|
|
96
128
|
}
|
|
97
|
-
// If endSlug doesn't match currentSlug, this is a reordered marker
|
|
98
|
-
// from another package — ignore it without resetting state.
|
|
99
129
|
continue;
|
|
100
130
|
}
|
|
101
131
|
|
|
@@ -104,9 +134,9 @@ export function parseBatchTestLogs(
|
|
|
104
134
|
break;
|
|
105
135
|
}
|
|
106
136
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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]);
|
|
110
140
|
}
|
|
111
141
|
|
|
112
142
|
// ── Pass 2: Parse the JSON summary for authoritative pcall results ──
|
|
@@ -114,12 +144,16 @@ export function parseBatchTestLogs(
|
|
|
114
144
|
const summaryResults = new Map<string, boolean>();
|
|
115
145
|
const summaryDurations = new Map<string, number>();
|
|
116
146
|
if (summaryLineIndex >= 0 && summaryLineIndex + 1 < lines.length) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 {
|
|
123
157
|
for (const entry of entries) {
|
|
124
158
|
summaryResults.set(entry.slug, entry.success);
|
|
125
159
|
if (typeof entry.durationMs === 'number') {
|
|
@@ -134,20 +168,20 @@ export function parseBatchTestLogs(
|
|
|
134
168
|
);
|
|
135
169
|
}
|
|
136
170
|
console.error(
|
|
137
|
-
`[batch-log-parser] Parsed ${entries.length} summary entries, ${failures.length} failures`
|
|
138
|
-
);
|
|
139
|
-
} catch {
|
|
140
|
-
OutputHelper.verbose(
|
|
141
|
-
`[batch-log-parser] Failed to parse JSON summary: ${jsonLine.slice(
|
|
142
|
-
0,
|
|
143
|
-
200
|
|
144
|
-
)}`
|
|
171
|
+
`[batch-log-parser] Parsed ${entries.length} summary entries, ${failures.length} pcall failures`
|
|
145
172
|
);
|
|
146
173
|
}
|
|
147
174
|
}
|
|
148
175
|
|
|
149
176
|
// ── Warn when the batch produced no recognizable output ──
|
|
150
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
|
+
|
|
151
185
|
const noOutputAtAll = logSections.size === 0 && summaryResults.size === 0;
|
|
152
186
|
if (noOutputAtAll) {
|
|
153
187
|
OutputHelper.warn(
|
|
@@ -199,54 +233,68 @@ export function parseBatchTestLogs(
|
|
|
199
233
|
unattributedClaimed = true;
|
|
200
234
|
}
|
|
201
235
|
const summarySuccess = summaryResults.get(slug);
|
|
236
|
+
const reasons: string[] = [];
|
|
202
237
|
|
|
203
|
-
// The
|
|
204
|
-
//
|
|
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.
|
|
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.
|
|
209
240
|
let success = summarySuccess ?? false;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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) {
|
|
214
254
|
success = false;
|
|
215
|
-
|
|
255
|
+
reasons.push(...outcome.failureReasons);
|
|
216
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
|
+
);
|
|
217
265
|
}
|
|
218
266
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
`No test output could be attributed to this package ` +
|
|
225
|
-
`(${rawLogs.length} chars received, ${beginMarkersSeen} BEGIN markers found). ` +
|
|
226
|
-
'Unattributed output follows.';
|
|
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.'
|
|
271
|
+
);
|
|
227
272
|
}
|
|
228
273
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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)
|
|
234
290
|
);
|
|
235
291
|
}
|
|
236
292
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
// are still shown, so the jest summary remains readable by eye.
|
|
293
|
+
const error = reasons.length > 0 ? reasons.join('; ') : undefined;
|
|
294
|
+
|
|
240
295
|
const testCounts = attributedLogs
|
|
241
296
|
? parseTestCounts(attributedLogs)
|
|
242
297
|
: 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
|
-
}
|
|
250
298
|
// Prefer the JSON summary (authoritative, immune to log reordering);
|
|
251
299
|
// fall back to the END-marker value if the summary was truncated.
|
|
252
300
|
const durationMs = summaryDurations.get(slug) ?? markerDurations.get(slug);
|
|
@@ -265,31 +313,44 @@ export function parseBatchTestLogs(
|
|
|
265
313
|
}
|
|
266
314
|
|
|
267
315
|
/**
|
|
268
|
-
*
|
|
316
|
+
* Find the summary array in the lines following the summary marker.
|
|
269
317
|
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
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.
|
|
273
322
|
*/
|
|
274
|
-
export function
|
|
275
|
-
|
|
276
|
-
|
|
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
|
+
}
|
|
277
351
|
}
|
|
278
|
-
const cleanLogs = OutputHelper.stripAnsi(rawOutput);
|
|
279
|
-
return cleanLogs.match(/Stack Begin\s/g)?.length ?? 0;
|
|
280
|
-
}
|
|
281
352
|
|
|
282
|
-
|
|
283
|
-
* Check specifically for Jest test suite/test failures in logs.
|
|
284
|
-
* Unlike parseTestLogs, this ignores "Stack Begin" runtime errors since
|
|
285
|
-
* those can come from deferred callbacks of other packages in batch mode.
|
|
286
|
-
*/
|
|
287
|
-
function _hasJestFailuresInLogs(rawOutput: string): boolean {
|
|
288
|
-
const cleanLogs = OutputHelper.stripAnsi(rawOutput);
|
|
289
|
-
const failedSuites = cleanLogs.match(/Test Suites:\s*(\d+)\s+failed/);
|
|
290
|
-
const failedTests = cleanLogs.match(/Tests:\s*(\d+)\s+failed/);
|
|
291
|
-
return (
|
|
292
|
-
(failedSuites != null && parseInt(failedSuites[1], 10) > 0) ||
|
|
293
|
-
(failedTests != null && parseInt(failedTests[1], 10) > 0)
|
|
294
|
-
);
|
|
353
|
+
return undefined;
|
|
295
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
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -30,6 +30,25 @@ export interface SingleTestResult {
|
|
|
30
30
|
error?: string;
|
|
31
31
|
}
|
|
32
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
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
export interface SingleTestOptions {
|
|
34
53
|
packagePath: string;
|
|
35
54
|
packageName: string;
|
|
@@ -56,7 +75,10 @@ export async function runSingleTestAsync(
|
|
|
56
75
|
const {
|
|
57
76
|
packagePath,
|
|
58
77
|
packageName,
|
|
59
|
-
|
|
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,
|
|
60
82
|
scriptText,
|
|
61
83
|
target,
|
|
62
84
|
} = options;
|
|
@@ -104,14 +126,24 @@ export async function runSingleTestAsync(
|
|
|
104
126
|
});
|
|
105
127
|
|
|
106
128
|
const rawLogs = await context.getLogsAsync(deployment);
|
|
107
|
-
|
|
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
|
+
);
|
|
108
140
|
|
|
109
141
|
return {
|
|
110
142
|
success: result.success && parsed.success,
|
|
111
143
|
logs: parsed.logs,
|
|
112
144
|
testCounts: parseTestCounts(parsed.logs),
|
|
113
145
|
durationMs: result.durationMs,
|
|
114
|
-
error:
|
|
146
|
+
error: reasons.length > 0 ? reasons.join('; ') : undefined,
|
|
115
147
|
};
|
|
116
148
|
} finally {
|
|
117
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
|
+
});
|