@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.
- package/CHANGELOG.md +12 -0
- package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-test-command.js +20 -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/job-context/batch-script-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.js +5 -1
- package/dist/utils/job-context/batch-script-job-context.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 +23 -2
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.d.ts +24 -0
- package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.js +159 -40
- package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.d.ts +2 -0
- package/dist/utils/testing/parsers/batch-log-parser.test.d.ts.map +1 -0
- package/dist/utils/testing/parsers/batch-log-parser.test.js +117 -0
- package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -0
- 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/reporting/test-github-columns.d.ts.map +1 -1
- package/dist/utils/testing/reporting/test-github-columns.js +1 -0
- package/dist/utils/testing/reporting/test-github-columns.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.d.ts +10 -0
- package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
- package/dist/utils/testing/runner/test-runner.js +28 -2
- 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 +6 -6
- package/src/commands/batch-command/batch-test-command.ts +80 -58
- package/src/commands/test-command/test-command.ts +7 -4
- package/src/utils/job-context/batch-script-job-context.ts +5 -1
- package/src/utils/open-cloud/open-cloud-client.ts +28 -2
- package/src/utils/testing/parsers/batch-log-parser.test.ts +150 -0
- package/src/utils/testing/parsers/batch-log-parser.ts +201 -49
- 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/reporting/test-github-columns.ts +1 -0
- package/src/utils/testing/runner/test-runner.test.ts +29 -0
- package/src/utils/testing/runner/test-runner.ts +37 -2
- 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
|
@@ -56,9 +56,9 @@ export class TestProjectCommand<T>
|
|
|
56
56
|
type: 'string',
|
|
57
57
|
});
|
|
58
58
|
args.option('logs', {
|
|
59
|
-
describe:
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
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
|
|
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
|
|
158
|
-
//
|
|
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 (
|
|
161
|
-
|
|
162
|
-
|
|
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 (
|
|
168
|
-
|
|
169
|
-
`[batch-log-parser] ${slug}:
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
+
});
|