@quenty/nevermore-cli 4.43.4 → 4.45.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 +10 -0
- package/build-scripts/{combine-test-places.luau → combine-test-places.lua} +3 -3
- package/build-scripts/{transform-inject-deploy-metadata.luau → transform-inject-deploy-metadata.lua} +3 -3
- package/dist/commands/batch-command/batch-deploy-command.js +4 -4
- package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
- package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-test-command.js +76 -19
- package/dist/commands/batch-command/batch-test-command.js.map +1 -1
- package/dist/commands/deploy-command/deploy-init-utils.d.ts.map +1 -1
- package/dist/commands/deploy-command/deploy-init-utils.js +7 -1
- package/dist/commands/deploy-command/deploy-init-utils.js.map +1 -1
- package/dist/commands/deploy-command/deploy-init.d.ts.map +1 -1
- package/dist/commands/deploy-command/deploy-init.js +24 -7
- package/dist/commands/deploy-command/deploy-init.js.map +1 -1
- package/dist/commands/deploy-command/index.js +1 -1
- package/dist/commands/deploy-command/index.js.map +1 -1
- package/dist/utils/deploy/deploy-github-columns.d.ts +12 -2
- package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
- package/dist/utils/deploy/deploy-github-columns.js +11 -4
- package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
- package/dist/utils/deploy/deploy-metadata.js +1 -1
- package/dist/utils/deploy/deploy-metadata.js.map +1 -1
- package/dist/utils/job-context/base-job-context.d.ts +2 -2
- package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/base-job-context.js +1 -1
- package/dist/utils/job-context/base-job-context.js.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.d.ts +37 -2
- package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.js +59 -22
- package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
- package/dist/utils/job-context/cloud-job-context.d.ts +2 -2
- package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/cloud-job-context.js +10 -5
- package/dist/utils/job-context/cloud-job-context.js.map +1 -1
- package/dist/utils/job-context/cloud-job-context.test.d.ts +7 -0
- package/dist/utils/job-context/cloud-job-context.test.d.ts.map +1 -0
- package/dist/utils/job-context/cloud-job-context.test.js +91 -0
- package/dist/utils/job-context/cloud-job-context.test.js.map +1 -0
- package/dist/utils/job-context/job-context.d.ts +70 -2
- package/dist/utils/job-context/job-context.d.ts.map +1 -1
- package/dist/utils/job-context/local-job-context.d.ts +2 -2
- package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/local-job-context.js +4 -2
- package/dist/utils/job-context/local-job-context.js.map +1 -1
- package/dist/utils/job-context/local-job-context.test.d.ts +7 -0
- package/dist/utils/job-context/local-job-context.test.d.ts.map +1 -0
- package/dist/utils/job-context/local-job-context.test.js +68 -0
- package/dist/utils/job-context/local-job-context.test.js.map +1 -0
- package/dist/utils/open-cloud/open-cloud-client.d.ts +50 -7
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +49 -13
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js +125 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts +22 -0
- package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts.map +1 -0
- package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js +202 -0
- package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js.map +1 -0
- package/dist/utils/package-manager-utils.d.ts.map +1 -1
- package/dist/utils/package-manager-utils.js +32 -1
- package/dist/utils/package-manager-utils.js.map +1 -1
- package/dist/utils/package-manager-utils.test.js +25 -0
- package/dist/utils/package-manager-utils.test.js.map +1 -1
- package/dist/utils/testing/batch-run.e2e.test.d.ts +17 -0
- package/dist/utils/testing/batch-run.e2e.test.d.ts.map +1 -0
- package/dist/utils/testing/batch-run.e2e.test.js +343 -0
- package/dist/utils/testing/batch-run.e2e.test.js.map +1 -0
- package/dist/utils/testing/batch-test-runner-template.test.d.ts +11 -0
- package/dist/utils/testing/batch-test-runner-template.test.d.ts.map +1 -0
- package/dist/utils/testing/batch-test-runner-template.test.js +38 -0
- package/dist/utils/testing/batch-test-runner-template.test.js.map +1 -0
- package/dist/utils/testing/log-fetch-stats.d.ts +40 -0
- package/dist/utils/testing/log-fetch-stats.d.ts.map +1 -0
- package/dist/utils/testing/log-fetch-stats.js +18 -0
- package/dist/utils/testing/log-fetch-stats.js.map +1 -0
- package/dist/utils/testing/log-fetch-stats.test.d.ts +2 -0
- package/dist/utils/testing/log-fetch-stats.test.d.ts.map +1 -0
- package/dist/utils/testing/log-fetch-stats.test.js +41 -0
- package/dist/utils/testing/log-fetch-stats.test.js.map +1 -0
- package/dist/utils/testing/parsers/batch-log-parser.d.ts +126 -1
- package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.js +440 -103
- package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.js +494 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-renderer.d.ts +79 -0
- package/dist/utils/testing/parsers/batch-log-renderer.d.ts.map +1 -0
- package/dist/utils/testing/parsers/batch-log-renderer.js +241 -0
- package/dist/utils/testing/parsers/batch-log-renderer.js.map +1 -0
- package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts +2 -0
- package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts.map +1 -0
- package/dist/utils/testing/parsers/batch-log-renderer.test.js +202 -0
- package/dist/utils/testing/parsers/batch-log-renderer.test.js.map +1 -0
- package/dist/utils/testing/runner/combined-project-generator.js +1 -1
- package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.d.ts +24 -0
- package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
- package/dist/utils/testing/runner/test-runner.js +166 -6
- package/dist/utils/testing/runner/test-runner.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.test.js +228 -2
- package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
- package/dist/utils/testing/structured-test-results.d.ts +77 -0
- package/dist/utils/testing/structured-test-results.d.ts.map +1 -0
- package/dist/utils/testing/structured-test-results.js +140 -0
- package/dist/utils/testing/structured-test-results.js.map +1 -0
- package/dist/utils/testing/structured-test-results.test.d.ts +7 -0
- package/dist/utils/testing/structured-test-results.test.d.ts.map +1 -0
- package/dist/utils/testing/structured-test-results.test.js +179 -0
- package/dist/utils/testing/structured-test-results.test.js.map +1 -0
- package/package.json +6 -6
- package/src/commands/batch-command/batch-deploy-command.ts +4 -4
- package/src/commands/batch-command/batch-test-command.ts +94 -19
- package/src/commands/deploy-command/deploy-init-utils.ts +7 -1
- package/src/commands/deploy-command/deploy-init.ts +41 -11
- package/src/commands/deploy-command/index.ts +1 -1
- package/src/utils/deploy/deploy-github-columns.ts +14 -4
- package/src/utils/deploy/deploy-metadata.ts +1 -1
- package/src/utils/job-context/base-job-context.ts +3 -2
- package/src/utils/job-context/batch-script-job-context.ts +98 -17
- package/src/utils/job-context/cloud-job-context.test.ts +112 -0
- package/src/utils/job-context/cloud-job-context.ts +12 -6
- package/src/utils/job-context/job-context.ts +71 -2
- package/src/utils/job-context/local-job-context.test.ts +89 -0
- package/src/utils/job-context/local-job-context.ts +7 -3
- package/src/utils/open-cloud/open-cloud-client.test.ts +155 -1
- package/src/utils/open-cloud/open-cloud-client.ts +94 -18
- package/src/utils/open-cloud/open-cloud-logs.characterization.test.ts +277 -0
- package/src/utils/package-manager-utils.test.ts +27 -0
- package/src/utils/package-manager-utils.ts +34 -1
- package/src/utils/testing/batch-run.e2e.test.ts +425 -0
- package/src/utils/testing/batch-test-runner-template.test.ts +62 -0
- package/src/utils/testing/log-fetch-stats.test.ts +48 -0
- package/src/utils/testing/log-fetch-stats.ts +55 -0
- package/src/utils/testing/parsers/batch-log-parser.test.ts +660 -1
- package/src/utils/testing/parsers/batch-log-parser.ts +721 -116
- package/src/utils/testing/parsers/batch-log-renderer.test.ts +330 -0
- package/src/utils/testing/parsers/batch-log-renderer.ts +329 -0
- package/src/utils/testing/runner/combined-project-generator.ts +1 -1
- package/src/utils/testing/runner/test-runner.test.ts +332 -2
- package/src/utils/testing/runner/test-runner.ts +231 -9
- package/src/utils/testing/structured-test-results.test.ts +267 -0
- package/src/utils/testing/structured-test-results.ts +199 -0
- package/templates/batch-test-runner.lua +242 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/templates/batch-test-runner.luau +0 -137
- /package/build-scripts/{smoke-test-server.luau → smoke-test-server.lua} +0 -0
- /package/build-scripts/{transform-rojo-merge-place.luau → transform-rojo-merge-place.lua} +0 -0
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
2
|
+
import { isCI } from '@quenty/cli-output-helpers/cli-utils';
|
|
2
3
|
import {
|
|
3
4
|
type ParsedTestCounts,
|
|
4
5
|
evaluateTestOutcome,
|
|
5
6
|
parseTestCounts,
|
|
6
7
|
} from '../test-log-parser.js';
|
|
7
8
|
import { formatTracebacks, parseTracebacks } from './traceback-parser.js';
|
|
9
|
+
import { type StructuredTestResults } from '../structured-test-results.js';
|
|
10
|
+
import { describeLogVolume, type LogFetchStats } from '../log-fetch-stats.js';
|
|
8
11
|
|
|
9
12
|
export interface BatchPackageResult {
|
|
10
13
|
slug: string;
|
|
@@ -21,17 +24,245 @@ export interface BatchPackageResult {
|
|
|
21
24
|
* test — often after the test that scheduled it already passed.
|
|
22
25
|
*/
|
|
23
26
|
tracebackCount: number;
|
|
27
|
+
/**
|
|
28
|
+
* Where `testCounts` came from. `returned` means the run handed them over as a
|
|
29
|
+
* value; `scraped` means they were read out of log text, which is the channel
|
|
30
|
+
* truncation destroys. Absent when there are no counts at all.
|
|
31
|
+
*
|
|
32
|
+
* Recorded because the two are otherwise indistinguishable in the output, and
|
|
33
|
+
* a structured channel that quietly stopped flowing looks exactly like one
|
|
34
|
+
* that never existed.
|
|
35
|
+
*/
|
|
36
|
+
countsSource?: 'returned' | 'scraped';
|
|
37
|
+
/**
|
|
38
|
+
* True when this package's counts arrived but its log section did not.
|
|
39
|
+
*
|
|
40
|
+
* A narrower verdict than a package with a section gets: the counts say what
|
|
41
|
+
* happened, but with no text there is nothing to check for tracebacks, and
|
|
42
|
+
* nothing to read when something did go wrong. Reported so a pass that came
|
|
43
|
+
* back half-blind cannot be mistaken for an ordinary one.
|
|
44
|
+
*/
|
|
45
|
+
logsLost?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* What this package's own run returned, recovered from the batch summary.
|
|
48
|
+
*
|
|
49
|
+
* One execution covers every package, so its return value belongs to none of
|
|
50
|
+
* them — the batch runner splits the per-package results into the summary
|
|
51
|
+
* instead, and this puts them back in the shape a single run's results have.
|
|
52
|
+
* Lossy by design: the summary carries counts, not failure text, which is
|
|
53
|
+
* per-package and already in that package's log section.
|
|
54
|
+
*/
|
|
55
|
+
testResults?: StructuredTestResults;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ParseBatchTestLogsOptions {
|
|
59
|
+
/**
|
|
60
|
+
* What the fetch that produced `rawLogs` had to do to collect them, when the
|
|
61
|
+
* caller knows. Not used to judge a package — it only tells a failure that
|
|
62
|
+
* has already happened how much of the run's output ever arrived.
|
|
63
|
+
*/
|
|
64
|
+
logFetchStats?: LogFetchStats;
|
|
65
|
+
/**
|
|
66
|
+
* Where diagnostics go. When set, the parser reports through this instead of
|
|
67
|
+
* printing, so a caller that renders the run's output can hold them until
|
|
68
|
+
* after it: a remark about a log window reads as nonsense above the log it
|
|
69
|
+
* is describing.
|
|
70
|
+
*/
|
|
71
|
+
onDiagnostic?: (level: BatchDiagnosticLevel, message: string) => void;
|
|
72
|
+
/**
|
|
73
|
+
* What the batch script returned, when the transport carries return values.
|
|
74
|
+
*
|
|
75
|
+
* The same summary the script also prints, taken from the one channel the
|
|
76
|
+
* engine's log buffer cannot truncate — an oversize return value fails the
|
|
77
|
+
* task outright rather than arriving short, so a summary that arrives here is
|
|
78
|
+
* whole. Preferred over the printed copy, which is only reached when this is
|
|
79
|
+
* absent (a local bridge run, or a task that ended without an output).
|
|
80
|
+
*/
|
|
81
|
+
returnValues?: unknown[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `group` and `endgroup` bracket a run of diagnostics that belong together, so
|
|
86
|
+
* a finding repeated across many packages can be collapsed into one block
|
|
87
|
+
* instead of one line each.
|
|
88
|
+
*/
|
|
89
|
+
export type BatchDiagnosticLevel =
|
|
90
|
+
| 'info'
|
|
91
|
+
| 'warn'
|
|
92
|
+
| 'verbose'
|
|
93
|
+
| 'group'
|
|
94
|
+
| 'endgroup';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How one package in a listing turned out, phrased as its group title is.
|
|
98
|
+
*
|
|
99
|
+
* Kept short: this is a line in a list of dozens, not a report.
|
|
100
|
+
*/
|
|
101
|
+
function describeSlugState(
|
|
102
|
+
slug: string,
|
|
103
|
+
results: Map<string, BatchPackageResult>,
|
|
104
|
+
slugMap: Map<string, string>
|
|
105
|
+
): string {
|
|
106
|
+
let packageName: string | undefined;
|
|
107
|
+
for (const [name, candidate] of slugMap) {
|
|
108
|
+
if (candidate === slug) {
|
|
109
|
+
packageName = name;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = packageName ? results.get(packageName) : undefined;
|
|
115
|
+
if (!result) {
|
|
116
|
+
return 'no result';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const counts = result.testCounts
|
|
120
|
+
? ` (${result.testCounts.passed}/${result.testCounts.total})`
|
|
121
|
+
: '';
|
|
122
|
+
return `${result.success ? 'Passed' : 'FAILED'}${counts}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Print one diagnostic, honoring the grouping levels.
|
|
127
|
+
*
|
|
128
|
+
* `useGroups` is passed in rather than looked up, so the whole run agrees about
|
|
129
|
+
* whether it is emitting workflow commands. Asking `isCI()` separately here, in
|
|
130
|
+
* the renderer and in the reporter is three answers to one question.
|
|
131
|
+
*/
|
|
132
|
+
export function emitDiagnostic(
|
|
133
|
+
level: BatchDiagnosticLevel,
|
|
134
|
+
message: string,
|
|
135
|
+
useGroups: boolean
|
|
136
|
+
): void {
|
|
137
|
+
if (level === 'group') {
|
|
138
|
+
OutputHelper.startGroup(message, useGroups);
|
|
139
|
+
} else if (level === 'endgroup') {
|
|
140
|
+
OutputHelper.endGroup(useGroups);
|
|
141
|
+
} else if (level === 'warn') {
|
|
142
|
+
OutputHelper.warn(message);
|
|
143
|
+
} else if (level === 'info') {
|
|
144
|
+
OutputHelper.info(message);
|
|
145
|
+
} else {
|
|
146
|
+
OutputHelper.verbose(message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Report through the caller's sink, or straight out when there is none. */
|
|
151
|
+
function makeReporter(
|
|
152
|
+
onDiagnostic?: (level: BatchDiagnosticLevel, message: string) => void
|
|
153
|
+
): (level: BatchDiagnosticLevel, message: string) => void {
|
|
154
|
+
if (onDiagnostic) {
|
|
155
|
+
return onDiagnostic;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (level, message) => emitDiagnostic(level, message, isCI());
|
|
24
159
|
}
|
|
25
160
|
|
|
26
161
|
const BEGIN_MARKER = '===BATCH_TEST_BEGIN ';
|
|
27
162
|
const END_MARKER = '===BATCH_TEST_END ';
|
|
28
163
|
const SUMMARY_MARKER = '===BATCH_TEST_SUMMARY===';
|
|
164
|
+
const MARKER_SUFFIX = '===';
|
|
165
|
+
|
|
166
|
+
// Matches "<slug> PASS|FAIL [<durationMs>]" — slugs have no whitespace.
|
|
167
|
+
const END_INNER_PATTERN = /^(\S+)\s+(?:PASS|FAIL)(?:\s+(\d+))?$/;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Counts the batch runner folds in from what a package's test script returned.
|
|
171
|
+
*
|
|
172
|
+
* Present only for a package whose script returns its results. Carried in the
|
|
173
|
+
* summary rather than left to the logs because the summary prints last and
|
|
174
|
+
* survives a truncated log window, which is where scraped counts are lost.
|
|
175
|
+
*/
|
|
176
|
+
interface SummaryCounts {
|
|
177
|
+
passed: number;
|
|
178
|
+
failed: number;
|
|
179
|
+
skipped: number;
|
|
180
|
+
total: number;
|
|
181
|
+
suitesPassed: number;
|
|
182
|
+
suitesFailed: number;
|
|
183
|
+
suitesTotal: number;
|
|
184
|
+
}
|
|
29
185
|
|
|
30
186
|
interface SummaryEntry {
|
|
31
187
|
slug: string;
|
|
32
188
|
success: boolean;
|
|
33
189
|
durationMs?: number;
|
|
34
190
|
error?: string;
|
|
191
|
+
counts?: SummaryCounts;
|
|
192
|
+
/** False for a smoke test, so zero counts are not read as "no tests found". */
|
|
193
|
+
ranJest?: boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Read counts off a summary entry, ignoring anything malformed. */
|
|
197
|
+
function readSummaryCounts(entry: SummaryEntry): SummaryCounts | undefined {
|
|
198
|
+
const counts = entry.counts;
|
|
199
|
+
if (typeof counts !== 'object' || counts === null) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const fields: (keyof SummaryCounts)[] = [
|
|
204
|
+
'passed',
|
|
205
|
+
'failed',
|
|
206
|
+
'skipped',
|
|
207
|
+
'total',
|
|
208
|
+
'suitesPassed',
|
|
209
|
+
'suitesFailed',
|
|
210
|
+
'suitesTotal',
|
|
211
|
+
];
|
|
212
|
+
for (const field of fields) {
|
|
213
|
+
if (!Number.isFinite(counts[field])) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return counts;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
interface BeginToken {
|
|
221
|
+
kind: 'begin';
|
|
222
|
+
slug: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface EndToken {
|
|
226
|
+
kind: 'end';
|
|
227
|
+
slug: string;
|
|
228
|
+
durationMs?: number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface SummaryToken {
|
|
232
|
+
kind: 'summary';
|
|
233
|
+
/** Where the marker sat, so the JSON payload can be looked up around it. */
|
|
234
|
+
lineIndex: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface SummaryPayloadToken {
|
|
238
|
+
kind: 'summaryPayload';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface ContentToken {
|
|
242
|
+
kind: 'content';
|
|
243
|
+
/** The untrimmed line, since section logs are shown to a human verbatim. */
|
|
244
|
+
line: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** One log line, classified. Every line becomes exactly one token. */
|
|
248
|
+
export type BatchLogToken =
|
|
249
|
+
| BeginToken
|
|
250
|
+
| EndToken
|
|
251
|
+
| SummaryToken
|
|
252
|
+
| SummaryPayloadToken
|
|
253
|
+
| ContentToken;
|
|
254
|
+
|
|
255
|
+
interface FoldedSections {
|
|
256
|
+
/** Log text of each section that closed, by slug. */
|
|
257
|
+
sections: Map<string, string>;
|
|
258
|
+
/** Sections closed by an END whose BEGIN never arrived — start of output lost. */
|
|
259
|
+
partialSections: Set<string>;
|
|
260
|
+
/** Durations read off END markers, by slug. */
|
|
261
|
+
markerDurations: Map<string, number>;
|
|
262
|
+
/** Slugs of ENDs that closed nothing, so claimed no output. */
|
|
263
|
+
strayEndSlugs: string[];
|
|
264
|
+
/** Slugs of sections a second BEGIN reopened, discarding their output. */
|
|
265
|
+
orphanedBeginSlugs: string[];
|
|
35
266
|
}
|
|
36
267
|
|
|
37
268
|
/**
|
|
@@ -50,10 +281,16 @@ interface SummaryEntry {
|
|
|
50
281
|
*/
|
|
51
282
|
export function parseBatchTestLogs(
|
|
52
283
|
rawLogs: string,
|
|
53
|
-
slugMap: Map<string, string
|
|
284
|
+
slugMap: Map<string, string>,
|
|
285
|
+
options: ParseBatchTestLogsOptions = {}
|
|
54
286
|
): Map<string, BatchPackageResult> {
|
|
55
287
|
const results = new Map<string, BatchPackageResult>();
|
|
56
288
|
const lines = rawLogs.split('\n');
|
|
289
|
+
// Reported by every diagnostic below. Each of them is some form of "the
|
|
290
|
+
// output is not what this run should have produced", and none can tell how
|
|
291
|
+
// much of it never arrived without knowing what the fetch collected.
|
|
292
|
+
const logVolume = describeLogVolume(rawLogs, options.logFetchStats);
|
|
293
|
+
const report = makeReporter(options.onDiagnostic);
|
|
57
294
|
|
|
58
295
|
// Build reverse map: slug → packageName
|
|
59
296
|
const slugToPackage = new Map<string, string>();
|
|
@@ -63,103 +300,48 @@ export function parseBatchTestLogs(
|
|
|
63
300
|
|
|
64
301
|
// ── Pass 1: Extract per-package log sections from markers ──
|
|
65
302
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (trimmed.startsWith(BEGIN_MARKER) && trimmed.endsWith('===')) {
|
|
88
|
-
currentSlug = trimmed.slice(BEGIN_MARKER.length, -3);
|
|
89
|
-
currentLines = [];
|
|
90
|
-
beginMarkersSeen++;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (trimmed.startsWith(END_MARKER) && trimmed.endsWith('===')) {
|
|
95
|
-
const inner = trimmed.slice(END_MARKER.length, -3);
|
|
96
|
-
endMarkersSeen++;
|
|
97
|
-
const match = endInnerPattern.exec(inner);
|
|
98
|
-
const endSlug = match ? match[1] : inner;
|
|
99
|
-
const durationStr = match?.[2];
|
|
100
|
-
|
|
101
|
-
// A section normally closes on the END matching its own BEGIN.
|
|
102
|
-
const closesOwnSection = endSlug === currentSlug;
|
|
103
|
-
|
|
104
|
-
// Open Cloud keeps only the tail of a long run's log, so BEGIN — printed
|
|
105
|
-
// first — can be lost while END and the summary survive. Recovering that
|
|
106
|
-
// means letting an END with no open section claim what precedes it, but
|
|
107
|
-
// only where the head can actually have been dropped: before any BEGIN
|
|
108
|
-
// has survived, and only once. Past that point the log is well-formed,
|
|
109
|
-
// and a BEGIN-less END is a message delivered out of order (the API does
|
|
110
|
-
// not order them), which must not be allowed to claim another package's
|
|
111
|
-
// output.
|
|
112
|
-
const closesDroppedHead =
|
|
113
|
-
currentSlug === null && beginMarkersSeen === 0 && !headSectionClaimed;
|
|
114
|
-
|
|
115
|
-
if (closesOwnSection || closesDroppedHead) {
|
|
116
|
-
logSections.set(endSlug, currentLines.join('\n'));
|
|
117
|
-
if (closesDroppedHead) {
|
|
118
|
-
partialSections.add(endSlug);
|
|
119
|
-
headSectionClaimed = true;
|
|
120
|
-
}
|
|
121
|
-
if (durationStr !== undefined) {
|
|
122
|
-
markerDurations.set(endSlug, parseInt(durationStr, 10));
|
|
123
|
-
}
|
|
124
|
-
currentSlug = null;
|
|
125
|
-
currentLines = [];
|
|
126
|
-
} else {
|
|
127
|
-
// Reordered marker from another package. Ignore it without resetting
|
|
128
|
-
// state, so the section it interrupted still closes on its own END.
|
|
129
|
-
strayEndMarkers++;
|
|
130
|
-
}
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (trimmed === SUMMARY_MARKER) {
|
|
135
|
-
// Keep scanning: the summary prints last but is not always delivered last,
|
|
136
|
-
// and a section's END can follow it.
|
|
137
|
-
if (summaryLineIndex < 0) {
|
|
138
|
-
summaryLineIndex = i;
|
|
139
|
-
}
|
|
140
|
-
summaryPayloadPending = true;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// The summary's JSON payload belongs to no section.
|
|
145
|
-
if (summaryPayloadPending && trimmed.trimStart().startsWith('[')) {
|
|
146
|
-
summaryPayloadPending = false;
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Accumulate unconditionally: output that precedes the first surviving
|
|
151
|
-
// BEGIN still belongs to whichever package's END closes it.
|
|
152
|
-
currentLines.push(lines[i]);
|
|
153
|
-
}
|
|
303
|
+
const tokens = tokenizeBatchLog(lines);
|
|
304
|
+
const {
|
|
305
|
+
sections: logSections,
|
|
306
|
+
partialSections,
|
|
307
|
+
markerDurations,
|
|
308
|
+
strayEndSlugs,
|
|
309
|
+
orphanedBeginSlugs,
|
|
310
|
+
} = foldTokensIntoSections(tokens);
|
|
311
|
+
|
|
312
|
+
// Diagnostics are read back off the token list rather than counted alongside
|
|
313
|
+
// the fold, so nothing the warnings report can also steer the parse.
|
|
314
|
+
const beginMarkersSeen = tokensOfKind(tokens, 'begin').length;
|
|
315
|
+
const endMarkersSeen = tokensOfKind(tokens, 'end').length;
|
|
316
|
+
const summaryLineIndex = tokensOfKind(tokens, 'summary')[0]?.lineIndex ?? -1;
|
|
317
|
+
const unknownEndSlugs = [
|
|
318
|
+
...new Set(
|
|
319
|
+
tokensOfKind(tokens, 'end')
|
|
320
|
+
.map((token) => token.slug)
|
|
321
|
+
.filter((slug) => !slugToPackage.has(slug))
|
|
322
|
+
),
|
|
323
|
+
];
|
|
154
324
|
|
|
155
325
|
// ── Pass 2: Parse the JSON summary for authoritative pcall results ──
|
|
156
326
|
|
|
157
327
|
const summaryResults = new Map<string, boolean>();
|
|
158
328
|
const summaryDurations = new Map<string, number>();
|
|
159
|
-
|
|
160
|
-
|
|
329
|
+
const summaryErrors = new Map<string, string>();
|
|
330
|
+
const summaryCounts = new Map<string, SummaryCounts>();
|
|
331
|
+
const summaryRanJest = new Map<string, boolean>();
|
|
332
|
+
|
|
333
|
+
// The returned copy first: it is the same summary, from the channel that
|
|
334
|
+
// cannot arrive truncated. Only when there is none is the printed one read.
|
|
335
|
+
const returnedEntries = findReturnedSummaryEntries(options.returnValues);
|
|
336
|
+
if (
|
|
337
|
+
returnedEntries ||
|
|
338
|
+
(summaryLineIndex >= 0 && summaryLineIndex + 1 < lines.length)
|
|
339
|
+
) {
|
|
340
|
+
const entries =
|
|
341
|
+
returnedEntries ?? findSummaryEntries(lines, summaryLineIndex + 1);
|
|
161
342
|
if (entries === undefined) {
|
|
162
|
-
|
|
343
|
+
report(
|
|
344
|
+
'verbose',
|
|
163
345
|
`[batch-log-parser] Failed to parse JSON summary after line ${summaryLineIndex}: ` +
|
|
164
346
|
lines
|
|
165
347
|
.slice(summaryLineIndex + 1, summaryLineIndex + 3)
|
|
@@ -172,33 +354,64 @@ export function parseBatchTestLogs(
|
|
|
172
354
|
if (typeof entry.durationMs === 'number') {
|
|
173
355
|
summaryDurations.set(entry.slug, entry.durationMs);
|
|
174
356
|
}
|
|
357
|
+
if (typeof entry.error === 'string' && entry.error.length > 0) {
|
|
358
|
+
summaryErrors.set(entry.slug, entry.error);
|
|
359
|
+
}
|
|
360
|
+
const counts = readSummaryCounts(entry);
|
|
361
|
+
if (counts) {
|
|
362
|
+
summaryCounts.set(entry.slug, counts);
|
|
363
|
+
summaryRanJest.set(entry.slug, entry.ranJest === true);
|
|
364
|
+
}
|
|
175
365
|
}
|
|
176
|
-
// Log any
|
|
366
|
+
// Log any failures the Luau template reported
|
|
177
367
|
const failures = entries.filter((e) => !e.success);
|
|
178
368
|
if (failures.length > 0) {
|
|
179
369
|
console.error(
|
|
180
|
-
`[batch-log-parser] Luau
|
|
370
|
+
`[batch-log-parser] Luau runner failures: ${JSON.stringify(failures)}`
|
|
181
371
|
);
|
|
182
372
|
}
|
|
183
373
|
console.error(
|
|
184
|
-
`[batch-log-parser] Parsed ${entries.length} summary entries, ${failures.length}
|
|
374
|
+
`[batch-log-parser] Parsed ${entries.length} summary entries, ${failures.length} reported failures`
|
|
185
375
|
);
|
|
186
376
|
}
|
|
187
377
|
}
|
|
188
378
|
|
|
189
379
|
// ── Warn when the batch produced no recognizable output ──
|
|
190
380
|
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
381
|
+
if (strayEndSlugs.length > 0) {
|
|
382
|
+
report(
|
|
383
|
+
'verbose',
|
|
384
|
+
`[batch-log-parser] Ignored ${strayEndSlugs.length} out-of-order END marker(s); ` +
|
|
194
385
|
'their sections closed on their own boundaries.'
|
|
195
386
|
);
|
|
196
387
|
}
|
|
197
388
|
|
|
389
|
+
// Reported, not acted on: a slug the batch never asked for means the markers
|
|
390
|
+
// and the package list disagree, which no per-package verdict can express.
|
|
391
|
+
if (unknownEndSlugs.length > 0) {
|
|
392
|
+
report(
|
|
393
|
+
'warn',
|
|
394
|
+
`[batch-log-parser] END marker(s) for slug(s) not in this batch: ` +
|
|
395
|
+
`${unknownEndSlugs.join(', ')}.`
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (orphanedBeginSlugs.length > 0) {
|
|
400
|
+
report(
|
|
401
|
+
'warn',
|
|
402
|
+
`[batch-log-parser] ${orphanedBeginSlugs.length} section(s) reopened by a ` +
|
|
403
|
+
`second BEGIN before their own END arrived (${orphanedBeginSlugs.join(
|
|
404
|
+
', '
|
|
405
|
+
)}); ` +
|
|
406
|
+
'the output collected so far was discarded.'
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
198
410
|
const noOutputAtAll = logSections.size === 0 && summaryResults.size === 0;
|
|
199
411
|
if (noOutputAtAll) {
|
|
200
|
-
|
|
201
|
-
|
|
412
|
+
report(
|
|
413
|
+
'warn',
|
|
414
|
+
`[batch-log-parser] No batch markers or summary found in logs (${logVolume}). ` +
|
|
202
415
|
'The batch script may not have started or produced output.'
|
|
203
416
|
);
|
|
204
417
|
}
|
|
@@ -209,17 +422,19 @@ export function parseBatchTestLogs(
|
|
|
209
422
|
// from pcall exit status alone, knowing nothing about what actually ran.
|
|
210
423
|
const lostSectionOutput = logSections.size === 0 && summaryResults.size > 0;
|
|
211
424
|
if (lostSectionOutput) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
`(${
|
|
216
|
-
`${
|
|
425
|
+
report(
|
|
426
|
+
'warn',
|
|
427
|
+
`[batch-log-parser] Received output but could not attribute any of it to ` +
|
|
428
|
+
`a package section (${logVolume}; ${beginMarkersSeen} BEGIN, ` +
|
|
429
|
+
`${endMarkersSeen} END markers; summary at line ${summaryLineIndex} of ` +
|
|
430
|
+
`${lines.length}). ` +
|
|
217
431
|
'Test counts are not derived from unattributed output.'
|
|
218
432
|
);
|
|
219
433
|
// BEGIN is printed first and the summary last, so their positions separate
|
|
220
434
|
// the two ways this happens: a log window that dropped the head keeps the
|
|
221
435
|
// summary at the end, while reordered messages put it early.
|
|
222
|
-
|
|
436
|
+
report(
|
|
437
|
+
'warn',
|
|
223
438
|
summaryLineIndex >= lines.length - 2
|
|
224
439
|
? '[batch-log-parser] Summary sits at the end of the log — consistent with the head being dropped by a log size/retention limit.'
|
|
225
440
|
: '[batch-log-parser] Summary sits early in the log — consistent with messages delivered out of order.'
|
|
@@ -238,6 +453,14 @@ export function parseBatchTestLogs(
|
|
|
238
453
|
// attached once rather than repeated per package across a large log.
|
|
239
454
|
let unattributedClaimed = false;
|
|
240
455
|
|
|
456
|
+
/** Packages whose run returned its counts, and those left to log scraping. */
|
|
457
|
+
const structuredSlugs: string[] = [];
|
|
458
|
+
const scrapedSlugs: string[] = [];
|
|
459
|
+
/** Packages reported as failed by a run whose counts show nothing failed. */
|
|
460
|
+
const unexplainedFailures: string[] = [];
|
|
461
|
+
/** Packages judged on returned counts alone, their log section having been lost. */
|
|
462
|
+
const sectionlessButCounted: string[] = [];
|
|
463
|
+
|
|
241
464
|
for (const [packageName, slug] of slugMap) {
|
|
242
465
|
const attributedLogs = logSections.get(slug);
|
|
243
466
|
let sectionLogs = attributedLogs ?? '';
|
|
@@ -246,39 +469,78 @@ export function parseBatchTestLogs(
|
|
|
246
469
|
unattributedClaimed = true;
|
|
247
470
|
}
|
|
248
471
|
const summarySuccess = summaryResults.get(slug);
|
|
472
|
+
const counts = summaryCounts.get(slug);
|
|
249
473
|
const reasons: string[] = [];
|
|
250
474
|
|
|
251
|
-
// The
|
|
252
|
-
//
|
|
475
|
+
// The summary verdict is a floor, not the whole verdict — everything below
|
|
476
|
+
// can fail a run it called successful. It covers both a script that threw
|
|
477
|
+
// and one whose returned results said it failed, so the reason comes from
|
|
478
|
+
// the runner rather than being guessed at here.
|
|
253
479
|
let success = summarySuccess ?? false;
|
|
254
480
|
if (summarySuccess === false) {
|
|
255
|
-
reasons.push(
|
|
481
|
+
reasons.push(
|
|
482
|
+
summaryErrors.get(slug) ?? 'the batch runner reported this as failed'
|
|
483
|
+
);
|
|
256
484
|
} else if (summarySuccess === undefined) {
|
|
257
485
|
// Absent from the summary is a different fault from failing in it, and
|
|
258
486
|
// conflating them sends you hunting for a Luau error that never happened.
|
|
259
487
|
reasons.push('this package is missing from the batch summary');
|
|
260
488
|
}
|
|
261
489
|
|
|
490
|
+
// Returned counts are structural, so a summary that called this a pass
|
|
491
|
+
// while reporting failed tests is not believed — no log line involved.
|
|
492
|
+
if (success && counts && (counts.failed > 0 || counts.suitesFailed > 0)) {
|
|
493
|
+
success = false;
|
|
494
|
+
reasons.push(
|
|
495
|
+
`the batch summary reported a pass alongside ${counts.failed} failed ` +
|
|
496
|
+
`test(s) and ${counts.suitesFailed} failed test suite(s)`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The reverse contradiction: failure reported over counts where nothing
|
|
501
|
+
// failed. Left as a failure but said out loud — a runner reading the wrong
|
|
502
|
+
// field produces exactly this, for every package at once.
|
|
503
|
+
if (
|
|
504
|
+
!success &&
|
|
505
|
+
counts &&
|
|
506
|
+
counts.failed === 0 &&
|
|
507
|
+
counts.suitesFailed === 0
|
|
508
|
+
) {
|
|
509
|
+
unexplainedFailures.push(slug);
|
|
510
|
+
}
|
|
511
|
+
|
|
262
512
|
if (attributedLogs !== undefined) {
|
|
513
|
+
// Demanding a jest report in the section is a stand-in for proof the
|
|
514
|
+
// runner ran, and it is the first thing a truncated log window costs.
|
|
515
|
+
// Returned counts are that proof directly, so they retire the stand-in —
|
|
516
|
+
// without this the whole structured channel changed nothing in a CI batch,
|
|
517
|
+
// where dozens of packages share one log window and a section keeps its
|
|
518
|
+
// END marker long after its jest summary was dropped.
|
|
263
519
|
const outcome = evaluateTestOutcome(attributedLogs, {
|
|
264
|
-
requireTestReport:
|
|
520
|
+
requireTestReport: counts === undefined,
|
|
265
521
|
});
|
|
266
522
|
if (!outcome.success) {
|
|
267
523
|
success = false;
|
|
268
524
|
reasons.push(...outcome.failureReasons);
|
|
269
525
|
}
|
|
270
|
-
} else {
|
|
526
|
+
} else if (counts === undefined) {
|
|
271
527
|
// Judged unreadable rather than judged by content: whatever broke
|
|
272
528
|
// attribution is reason enough not to trust which package a line is from.
|
|
273
529
|
success = false;
|
|
274
530
|
reasons.push(
|
|
275
531
|
`no output could be attributed to this package ` +
|
|
276
|
-
`(${
|
|
532
|
+
`(${logVolume}; ${beginMarkersSeen} BEGIN markers found)`
|
|
277
533
|
);
|
|
534
|
+
} else {
|
|
535
|
+
// The counts say what happened even though the log does not. Tracebacks
|
|
536
|
+
// cannot be checked without the text, though, so this is a narrower
|
|
537
|
+
// verdict than a package with a section gets — hence the warning.
|
|
538
|
+
sectionlessButCounted.push(slug);
|
|
278
539
|
}
|
|
279
540
|
|
|
280
541
|
if (partialSections.has(slug)) {
|
|
281
|
-
|
|
542
|
+
report(
|
|
543
|
+
'warn',
|
|
282
544
|
`[batch-log-parser] ${slug}: section closed by its END marker with no BEGIN — ` +
|
|
283
545
|
'the start of this output was dropped by the log window, so the section is partial.'
|
|
284
546
|
);
|
|
@@ -290,14 +552,16 @@ export function parseBatchTestLogs(
|
|
|
290
552
|
const tracebackCount = tracebacks.reduce((sum, t) => sum + t.count, 0);
|
|
291
553
|
const owned = tracebacks.filter((t) => !t.owner || t.owner === slug);
|
|
292
554
|
if (tracebacks.length > owned.length) {
|
|
293
|
-
|
|
555
|
+
report(
|
|
556
|
+
'verbose',
|
|
294
557
|
`[batch-log-parser] ${slug}: ignored ${
|
|
295
558
|
tracebacks.length - owned.length
|
|
296
559
|
} traceback(s) belonging to other packages`
|
|
297
560
|
);
|
|
298
561
|
}
|
|
299
562
|
if (owned.length > 0) {
|
|
300
|
-
|
|
563
|
+
report(
|
|
564
|
+
'warn',
|
|
301
565
|
`[batch-log-parser] ${slug}: ${owned.length} distinct traceback(s):\n` +
|
|
302
566
|
formatTracebacks(owned)
|
|
303
567
|
);
|
|
@@ -305,9 +569,41 @@ export function parseBatchTestLogs(
|
|
|
305
569
|
|
|
306
570
|
const error = reasons.length > 0 ? reasons.join('; ') : undefined;
|
|
307
571
|
|
|
308
|
-
|
|
572
|
+
// Counts the runner returned outrank counts scraped from the section: the
|
|
573
|
+
// same numbers when the log survived, real numbers when it did not.
|
|
574
|
+
const scrapedCounts = attributedLogs
|
|
309
575
|
? parseTestCounts(attributedLogs)
|
|
310
576
|
: undefined;
|
|
577
|
+
const testCounts = counts
|
|
578
|
+
? { passed: counts.passed, failed: counts.failed, total: counts.total }
|
|
579
|
+
: scrapedCounts;
|
|
580
|
+
const countsSource = counts
|
|
581
|
+
? ('returned' as const)
|
|
582
|
+
: scrapedCounts
|
|
583
|
+
? ('scraped' as const)
|
|
584
|
+
: undefined;
|
|
585
|
+
|
|
586
|
+
if (counts) {
|
|
587
|
+
structuredSlugs.push(slug);
|
|
588
|
+
} else {
|
|
589
|
+
scrapedSlugs.push(slug);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Two channels reporting the same run must agree. When they do not, one of
|
|
593
|
+
// them is lying about what happened and neither total can be trusted on its
|
|
594
|
+
// own — said out loud because a returned count that is quietly wrong reads
|
|
595
|
+
// like a clean run, which is how a broken structured read stays invisible.
|
|
596
|
+
if (counts && scrapedCounts && counts.total !== scrapedCounts.total) {
|
|
597
|
+
report(
|
|
598
|
+
'warn',
|
|
599
|
+
`[batch-log-parser] ${slug}: returned counts disagree with the log — ` +
|
|
600
|
+
`the run returned ${counts.passed} passed / ${counts.failed} failed / ` +
|
|
601
|
+
`${counts.total} total, its jest report says ${scrapedCounts.passed} / ` +
|
|
602
|
+
`${scrapedCounts.failed} / ${scrapedCounts.total}. Reporting the returned ` +
|
|
603
|
+
`counts; one of the two channels is wrong.`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
311
607
|
// Prefer the JSON summary (authoritative, immune to log reordering);
|
|
312
608
|
// fall back to the END-marker value if the summary was truncated.
|
|
313
609
|
const durationMs = summaryDurations.get(slug) ?? markerDurations.get(slug);
|
|
@@ -318,13 +614,322 @@ export function parseBatchTestLogs(
|
|
|
318
614
|
durationMs,
|
|
319
615
|
testCounts,
|
|
320
616
|
tracebackCount,
|
|
617
|
+
countsSource,
|
|
618
|
+
logsLost: attributedLogs === undefined && counts !== undefined,
|
|
619
|
+
testResults: counts
|
|
620
|
+
? {
|
|
621
|
+
success: summarySuccess === true,
|
|
622
|
+
ranJest: summaryRanJest.get(slug) === true,
|
|
623
|
+
...counts,
|
|
624
|
+
failures: [],
|
|
625
|
+
omittedFailures: 0,
|
|
626
|
+
error: summaryErrors.get(slug),
|
|
627
|
+
}
|
|
628
|
+
: undefined,
|
|
321
629
|
error,
|
|
322
630
|
});
|
|
323
631
|
}
|
|
324
632
|
|
|
633
|
+
reportCountsProvenance(
|
|
634
|
+
structuredSlugs,
|
|
635
|
+
scrapedSlugs,
|
|
636
|
+
unexplainedFailures,
|
|
637
|
+
sectionlessButCounted,
|
|
638
|
+
report,
|
|
639
|
+
results,
|
|
640
|
+
slugMap
|
|
641
|
+
);
|
|
642
|
+
|
|
325
643
|
return results;
|
|
326
644
|
}
|
|
327
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Say where this batch's counts came from, every run.
|
|
648
|
+
*
|
|
649
|
+
* The structured channel exists because Open Cloud truncates a long run's logs.
|
|
650
|
+
* A channel that is plumbed but not flowing produces output identical to one
|
|
651
|
+
* that is working, so silence here is not evidence of anything — the count is
|
|
652
|
+
* stated unconditionally and the fallback is a warning, not a debug line.
|
|
653
|
+
*/
|
|
654
|
+
function reportCountsProvenance(
|
|
655
|
+
structuredSlugs: string[],
|
|
656
|
+
scrapedSlugs: string[],
|
|
657
|
+
unexplainedFailures: string[],
|
|
658
|
+
sectionlessButCounted: string[],
|
|
659
|
+
report: (level: BatchDiagnosticLevel, message: string) => void,
|
|
660
|
+
results: Map<string, BatchPackageResult>,
|
|
661
|
+
slugMap: Map<string, string>
|
|
662
|
+
): void {
|
|
663
|
+
const total = structuredSlugs.length + scrapedSlugs.length;
|
|
664
|
+
if (total === 0) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Not a failure — this is the case the structured channel was built for — but
|
|
669
|
+
// these packages were judged without their log text, so nothing checked them
|
|
670
|
+
// for tracebacks, which jest cannot count and only the log shows.
|
|
671
|
+
if (sectionlessButCounted.length > 0) {
|
|
672
|
+
report(
|
|
673
|
+
'warn',
|
|
674
|
+
`[batch-log-parser] ${sectionlessButCounted.length} package(s) were judged on ` +
|
|
675
|
+
`their returned counts alone, with no log section to read: ` +
|
|
676
|
+
`${sectionlessButCounted.join(
|
|
677
|
+
', '
|
|
678
|
+
)}. Their counts are exact; they were ` +
|
|
679
|
+
`not checked for Luau tracebacks, which only the log shows.`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
report(
|
|
684
|
+
'info',
|
|
685
|
+
`[batch-log-parser] Counts returned by the run for ${structuredSlugs.length} ` +
|
|
686
|
+
`of ${total} package(s); ${scrapedSlugs.length} scraped from logs.`
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
// Failing every package while every package's counts are clean is a signature,
|
|
690
|
+
// not a coincidence: it means the runner's verdict, not the tests, is wrong.
|
|
691
|
+
if (unexplainedFailures.length > 0) {
|
|
692
|
+
report(
|
|
693
|
+
'warn',
|
|
694
|
+
`[batch-log-parser] ${unexplainedFailures.length} of ${total} package(s) were ` +
|
|
695
|
+
`failed by a run whose own counts show nothing failed: ` +
|
|
696
|
+
`${unexplainedFailures.join(
|
|
697
|
+
', '
|
|
698
|
+
)}. The failures stand — a runner may know ` +
|
|
699
|
+
`something its counts cannot express — but a verdict no count supports is ` +
|
|
700
|
+
`far more likely to be the runner reading the wrong field.`
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (scrapedSlugs.length > 0) {
|
|
705
|
+
// One group rather than a line per package: the packages differ but the
|
|
706
|
+
// finding does not, and 78 restatements of it buried everything else. The
|
|
707
|
+
// state goes in the title so the group can be left collapsed.
|
|
708
|
+
report(
|
|
709
|
+
'group',
|
|
710
|
+
`${scrapedSlugs.length} of ${total} package(s) fell back to log scraping ` +
|
|
711
|
+
`(no test results returned)`
|
|
712
|
+
);
|
|
713
|
+
report(
|
|
714
|
+
'warn',
|
|
715
|
+
`Counts for these came from log text, the channel Open Cloud truncates on ` +
|
|
716
|
+
`long runs. A package that runs its tests through ` +
|
|
717
|
+
`NevermoreTestRunnerUtils reports them as a value without its script ` +
|
|
718
|
+
`changing, so these are packages doing something else (see ` +
|
|
719
|
+
`docs/testing/testing.md).`
|
|
720
|
+
);
|
|
721
|
+
// Each with its own state: this list is read to find the packages worth
|
|
722
|
+
// looking at, and a column of bare names says nothing about which those are.
|
|
723
|
+
for (const slug of scrapedSlugs) {
|
|
724
|
+
report(
|
|
725
|
+
'info',
|
|
726
|
+
` ${slug} — ${describeSlugState(slug, results, slugMap)}`
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
report('endgroup', '');
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Classify every log line, so section splitting reads tokens instead of text.
|
|
735
|
+
*/
|
|
736
|
+
export function tokenizeBatchLog(lines: string[]): BatchLogToken[] {
|
|
737
|
+
const tokens: BatchLogToken[] = [];
|
|
738
|
+
/** True between the summary marker and its JSON payload. */
|
|
739
|
+
let summaryPayloadPending = false;
|
|
740
|
+
|
|
741
|
+
for (let i = 0; i < lines.length; i++) {
|
|
742
|
+
const trimmed = lines[i].trimEnd();
|
|
743
|
+
|
|
744
|
+
if (trimmed.startsWith(BEGIN_MARKER) && trimmed.endsWith(MARKER_SUFFIX)) {
|
|
745
|
+
tokens.push({
|
|
746
|
+
kind: 'begin',
|
|
747
|
+
slug: trimmed.slice(BEGIN_MARKER.length, -MARKER_SUFFIX.length),
|
|
748
|
+
});
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (trimmed.startsWith(END_MARKER) && trimmed.endsWith(MARKER_SUFFIX)) {
|
|
753
|
+
tokens.push(
|
|
754
|
+
parseEndToken(trimmed.slice(END_MARKER.length, -MARKER_SUFFIX.length))
|
|
755
|
+
);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (trimmed === SUMMARY_MARKER) {
|
|
760
|
+
// Keep scanning: the summary prints last but is not always delivered last,
|
|
761
|
+
// and a section's END can follow it.
|
|
762
|
+
tokens.push({ kind: 'summary', lineIndex: i });
|
|
763
|
+
summaryPayloadPending = true;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// The summary's JSON payload belongs to no section.
|
|
768
|
+
if (summaryPayloadPending && trimmed.trimStart().startsWith('[')) {
|
|
769
|
+
summaryPayloadPending = false;
|
|
770
|
+
tokens.push({ kind: 'summaryPayload' });
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
tokens.push({ kind: 'content', line: lines[i] });
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return tokens;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Read an END marker's inner text.
|
|
782
|
+
*
|
|
783
|
+
* The PASS|FAIL verdict and the duration are both optional, so inner text that
|
|
784
|
+
* does not parse is taken whole as the slug rather than dropped.
|
|
785
|
+
*/
|
|
786
|
+
function parseEndToken(inner: string): EndToken {
|
|
787
|
+
const match = END_INNER_PATTERN.exec(inner);
|
|
788
|
+
if (!match) {
|
|
789
|
+
return { kind: 'end', slug: inner };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const durationStr = match[2];
|
|
793
|
+
return {
|
|
794
|
+
kind: 'end',
|
|
795
|
+
slug: match[1],
|
|
796
|
+
durationMs:
|
|
797
|
+
durationStr === undefined ? undefined : parseInt(durationStr, 10),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Fold tokens into per-package log sections.
|
|
803
|
+
*
|
|
804
|
+
* Sections close on END, not on the next BEGIN, because END is what survives:
|
|
805
|
+
* see the head-claiming rule below.
|
|
806
|
+
*/
|
|
807
|
+
function foldTokensIntoSections(
|
|
808
|
+
tokens: readonly BatchLogToken[]
|
|
809
|
+
): FoldedSections {
|
|
810
|
+
const sections = new Map<string, string>();
|
|
811
|
+
const partialSections = new Set<string>();
|
|
812
|
+
const markerDurations = new Map<string, number>();
|
|
813
|
+
const strayEndSlugs: string[] = [];
|
|
814
|
+
const orphanedBeginSlugs: string[] = [];
|
|
815
|
+
|
|
816
|
+
let openSlug: string | null = null;
|
|
817
|
+
let openLines: string[] = [];
|
|
818
|
+
/**
|
|
819
|
+
* Whether the log can still be one whose head was dropped.
|
|
820
|
+
*
|
|
821
|
+
* Open Cloud keeps only the tail of a long run's log, so BEGIN — printed
|
|
822
|
+
* first — can be lost while END and the summary survive. Recovering that
|
|
823
|
+
* means letting an END with no open section claim what precedes it, but
|
|
824
|
+
* only where the head can actually have been dropped: before any BEGIN
|
|
825
|
+
* has survived, and only once. Past that point the log is well-formed,
|
|
826
|
+
* and a BEGIN-less END is a message delivered out of order (the API does
|
|
827
|
+
* not order them), which must not be allowed to claim another package's
|
|
828
|
+
* output.
|
|
829
|
+
*/
|
|
830
|
+
let headClaimable = true;
|
|
831
|
+
|
|
832
|
+
for (const token of tokens) {
|
|
833
|
+
switch (token.kind) {
|
|
834
|
+
case 'begin': {
|
|
835
|
+
if (openSlug !== null) {
|
|
836
|
+
orphanedBeginSlugs.push(openSlug);
|
|
837
|
+
}
|
|
838
|
+
openSlug = token.slug;
|
|
839
|
+
openLines = [];
|
|
840
|
+
headClaimable = false;
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
case 'end': {
|
|
845
|
+
// A section normally closes on the END matching its own BEGIN.
|
|
846
|
+
const closesOwnSection = token.slug === openSlug;
|
|
847
|
+
// Mutually exclusive with the above: an unclaimed head means no BEGIN
|
|
848
|
+
// has arrived, so no section is open to match.
|
|
849
|
+
const closesDroppedHead = headClaimable;
|
|
850
|
+
|
|
851
|
+
if (!closesOwnSection && !closesDroppedHead) {
|
|
852
|
+
// Reordered marker from another package. Ignore it without resetting
|
|
853
|
+
// state, so the section it interrupted still closes on its own END.
|
|
854
|
+
strayEndSlugs.push(token.slug);
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
sections.set(token.slug, openLines.join('\n'));
|
|
859
|
+
if (closesDroppedHead) {
|
|
860
|
+
partialSections.add(token.slug);
|
|
861
|
+
}
|
|
862
|
+
if (token.durationMs !== undefined) {
|
|
863
|
+
markerDurations.set(token.slug, token.durationMs);
|
|
864
|
+
}
|
|
865
|
+
openSlug = null;
|
|
866
|
+
openLines = [];
|
|
867
|
+
headClaimable = false;
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
case 'content': {
|
|
872
|
+
// Accumulate unconditionally: output that precedes the first surviving
|
|
873
|
+
// BEGIN still belongs to whichever package's END closes it.
|
|
874
|
+
openLines.push(token.line);
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
case 'summary':
|
|
879
|
+
case 'summaryPayload': {
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
sections,
|
|
887
|
+
partialSections,
|
|
888
|
+
markerDurations,
|
|
889
|
+
strayEndSlugs,
|
|
890
|
+
orphanedBeginSlugs,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Select the tokens of one kind, for counting and reporting. */
|
|
895
|
+
function tokensOfKind<K extends BatchLogToken['kind']>(
|
|
896
|
+
tokens: readonly BatchLogToken[],
|
|
897
|
+
kind: K
|
|
898
|
+
): Extract<BatchLogToken, { kind: K }>[] {
|
|
899
|
+
return tokens.filter(
|
|
900
|
+
(token): token is Extract<BatchLogToken, { kind: K }> => token.kind === kind
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Find the summary array among a task's return values.
|
|
906
|
+
*
|
|
907
|
+
* The batch script returns the same array it prints. Recognized by shape rather
|
|
908
|
+
* than by position: a run is free to return other things, and an array of
|
|
909
|
+
* objects that all carry a string `slug` is the summary and nothing else.
|
|
910
|
+
*/
|
|
911
|
+
export function findReturnedSummaryEntries(
|
|
912
|
+
returnValues: unknown[] | undefined
|
|
913
|
+
): SummaryEntry[] | undefined {
|
|
914
|
+
for (const value of returnValues ?? []) {
|
|
915
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const looksLikeSummary = value.every(
|
|
920
|
+
(entry) =>
|
|
921
|
+
typeof entry === 'object' &&
|
|
922
|
+
entry !== null &&
|
|
923
|
+
typeof (entry as { slug?: unknown }).slug === 'string'
|
|
924
|
+
);
|
|
925
|
+
if (looksLikeSummary) {
|
|
926
|
+
return value as SummaryEntry[];
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return undefined;
|
|
931
|
+
}
|
|
932
|
+
|
|
328
933
|
/**
|
|
329
934
|
* Find the summary array in the lines following the summary marker.
|
|
330
935
|
*
|