@midscene/test 1.16.0 → 1.16.2
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/dist/es/{1~77.mjs → 1~731.mjs} +9 -9
- package/dist/es/1~731.mjs.map +1 -0
- package/dist/es/cli/index.mjs +686 -167
- package/dist/es/cli/index.mjs.map +1 -1
- package/dist/es/index.mjs +45 -1
- package/dist/es/index.mjs.map +1 -1
- package/dist/es/test-cli.mjs +1863 -206
- package/dist/es/test-cli.mjs.map +1 -1
- package/dist/lib/cli/index.js +677 -155
- package/dist/lib/cli/index.js.map +1 -1
- package/dist/lib/index.js +45 -1
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/test-cli.js +1854 -203
- package/dist/lib/test-cli.js.map +1 -1
- package/dist/templates/test/android/.env.example +1 -1
- package/dist/templates/test/android/README.md +3 -1
- package/dist/templates/test/android/README.zh.md +3 -1
- package/dist/templates/test/android/midscene.config.ts +28 -31
- package/dist/templates/test/computer/.env.example +1 -1
- package/dist/templates/test/computer/README.md +3 -1
- package/dist/templates/test/computer/README.zh.md +3 -1
- package/dist/templates/test/computer/midscene.config.ts +31 -34
- package/dist/templates/test/harmony/.env.example +1 -1
- package/dist/templates/test/harmony/README.md +3 -1
- package/dist/templates/test/harmony/README.zh.md +3 -1
- package/dist/templates/test/harmony/midscene.config.ts +28 -31
- package/dist/templates/test/ios/.env.example +1 -1
- package/dist/templates/test/ios/README.md +3 -1
- package/dist/templates/test/ios/README.zh.md +3 -1
- package/dist/templates/test/ios/midscene.config.ts +28 -31
- package/dist/templates/test/web/.env.example +1 -1
- package/dist/templates/test/web/README.md +17 -1
- package/dist/templates/test/web/README.zh.md +17 -1
- package/dist/templates/test/web/cases/todo.yaml +2 -0
- package/dist/templates/test/web/midscene.config.ts +34 -12
- package/dist/types/cli/index.d.ts +107 -11
- package/dist/types/index.d.ts +6 -3
- package/package.json +14 -13
- package/dist/es/1~77.mjs.map +0 -1
package/dist/es/test-cli.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import * as __rspack_external__midscene_shared_env_0c37bb76 from "@midscene/shared/env";
|
|
2
|
+
import * as __rspack_external_chalk from "chalk";
|
|
1
3
|
import * as __rspack_external_node_fs_5ea92f0c from "node:fs";
|
|
2
4
|
import * as __rspack_external_node_path_c5b9b54f from "node:path";
|
|
3
|
-
import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations,
|
|
4
|
-
import {
|
|
5
|
+
import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, getPendingScopeOperations, getResourceCleanupCompletion, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText, sanitizeReportValue, sumTestRunReportMetrics, trackResourceOperation, waitForScopeOperations } from "@midscene/core/internal/test-runner";
|
|
6
|
+
import { getDebug, runWithConsoleLogHandler, runWithConsoleLogScope } from "@midscene/shared/logger";
|
|
7
|
+
import { inspect, stripVTControlCharacters } from "node:util";
|
|
8
|
+
import string_width from "string-width";
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
10
|
import { z } from "zod/v4";
|
|
6
|
-
import { pathToFileURL } from "node:url";
|
|
7
11
|
import { require as api_require } from "tsx/cjs/api";
|
|
8
12
|
import { globSync } from "tinyglobby";
|
|
9
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -11,8 +15,13 @@ import { JSON_SCHEMA, load } from "js-yaml";
|
|
|
11
15
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
12
16
|
import { setMaxListeners } from "node:events";
|
|
13
17
|
import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
|
|
14
|
-
import { getDebug } from "@midscene/shared/logger";
|
|
15
18
|
var __webpack_modules__ = {
|
|
19
|
+
"@midscene/shared/env" (module) {
|
|
20
|
+
module.exports = __rspack_external__midscene_shared_env_0c37bb76;
|
|
21
|
+
},
|
|
22
|
+
chalk (module) {
|
|
23
|
+
module.exports = __rspack_external_chalk;
|
|
24
|
+
},
|
|
16
25
|
"node:fs" (module) {
|
|
17
26
|
module.exports = __rspack_external_node_fs_5ea92f0c;
|
|
18
27
|
},
|
|
@@ -21,7 +30,7 @@ var __webpack_modules__ = {
|
|
|
21
30
|
},
|
|
22
31
|
"./package.json" (module) {
|
|
23
32
|
module.exports = {
|
|
24
|
-
rE: "1.16.
|
|
33
|
+
rE: "1.16.2"
|
|
25
34
|
};
|
|
26
35
|
}
|
|
27
36
|
};
|
|
@@ -102,6 +111,1017 @@ const formatCliError = (error)=>'string' == typeof error ? error : inspect(error
|
|
|
102
111
|
depth: null,
|
|
103
112
|
customInspect: false
|
|
104
113
|
});
|
|
114
|
+
function formatConciseError(error) {
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
const messages = [];
|
|
117
|
+
let current = error;
|
|
118
|
+
while(current instanceof Error && !seen.has(current)){
|
|
119
|
+
seen.add(current);
|
|
120
|
+
if ('NODE_EXECUTION_ERROR' === current.code && void 0 !== current.cause) {
|
|
121
|
+
current = current.cause;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (current.message && !messages.includes(current.message)) messages.push(current.message);
|
|
125
|
+
if (current instanceof AggregateError) {
|
|
126
|
+
for (const nested of current.errors)if (!(nested === current || seen.has(nested))) {
|
|
127
|
+
seen.add(nested);
|
|
128
|
+
messages.push(nested instanceof Error ? nested.message : String(nested));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
current = current.cause;
|
|
132
|
+
}
|
|
133
|
+
if (!messages.length) return current instanceof Error ? current.message : formatCliError(current);
|
|
134
|
+
return messages.join('\nCaused by: ');
|
|
135
|
+
}
|
|
136
|
+
var external_chalk_ = __webpack_require__("chalk");
|
|
137
|
+
const oneLine = (value)=>stripVTControlCharacters(value).replace(/\s+/g, ' ').trim();
|
|
138
|
+
const shorten = (value, length)=>{
|
|
139
|
+
const text = oneLine(value);
|
|
140
|
+
return text.length <= length ? text : `${text.slice(0, length - 1)}…`;
|
|
141
|
+
};
|
|
142
|
+
function firstBusinessFrame(error, projectRoot) {
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
const frames = [];
|
|
145
|
+
const visit = (value, depth = 0)=>{
|
|
146
|
+
if (!value || 'object' != typeof value || seen.has(value) || depth > 12) return;
|
|
147
|
+
seen.add(value);
|
|
148
|
+
const detail = value;
|
|
149
|
+
visit(detail.cause, depth + 1);
|
|
150
|
+
if (Array.isArray(detail.errors)) for (const child of detail.errors.slice(0, 10))visit(child, depth + 1);
|
|
151
|
+
if ('string' != typeof detail.stack) return;
|
|
152
|
+
for (const line of detail.stack.split('\n')){
|
|
153
|
+
if (!line.trim().startsWith('at ')) continue;
|
|
154
|
+
let location = line.trim().slice(3).replace(/^async /, '');
|
|
155
|
+
if (location.endsWith(')')) location = location.slice(location.lastIndexOf('(') + 1, -1);
|
|
156
|
+
const match = location.match(/^(.*):(\d+):(\d+)$/);
|
|
157
|
+
if (!match) continue;
|
|
158
|
+
let file = match[1];
|
|
159
|
+
if (file.startsWith('file://')) try {
|
|
160
|
+
file = fileURLToPath(file);
|
|
161
|
+
} catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const normalized = file.replace(/\\/g, '/');
|
|
165
|
+
if (!(normalized.startsWith('node:') || normalized.includes('/node_modules/') || /(?:^|\/)packages\/(?:core|test|shared|web-integration)\/(?:dist|src)\//.test(normalized)) && /\.[cm]?[jt]sx?$/.test(normalized)) frames.push(`${file}:${match[2]}:${match[3]}`);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
visit(error);
|
|
169
|
+
const chosen = frames[0];
|
|
170
|
+
if (!chosen || !projectRoot) return chosen;
|
|
171
|
+
const match = chosen.match(/^(.*):(\d+):(\d+)$/);
|
|
172
|
+
const file = match[1];
|
|
173
|
+
const paths = /^[A-Za-z]:[\\/]/.test(file) ? external_node_path_.win32 : {
|
|
174
|
+
isAbsolute: external_node_path_.isAbsolute,
|
|
175
|
+
relative: external_node_path_.relative
|
|
176
|
+
};
|
|
177
|
+
const display = paths.isAbsolute(file) ? paths.relative(projectRoot, file) : file;
|
|
178
|
+
return `${display.replace(/\\/g, '/')}:${match[2]}:${match[3]}`;
|
|
179
|
+
}
|
|
180
|
+
function formatFailureInput(input) {
|
|
181
|
+
if (void 0 === input) return [];
|
|
182
|
+
const value = sanitizeReportValue(input).value;
|
|
183
|
+
const render = (value)=>shorten(sanitizeReportError({
|
|
184
|
+
message: 'string' == typeof value ? value : inspect(value, {
|
|
185
|
+
colors: false,
|
|
186
|
+
depth: 2,
|
|
187
|
+
maxArrayLength: 4,
|
|
188
|
+
maxStringLength: 180,
|
|
189
|
+
breakLength: 1 / 0,
|
|
190
|
+
customInspect: false,
|
|
191
|
+
getters: false
|
|
192
|
+
})
|
|
193
|
+
}).message, 160);
|
|
194
|
+
if (value && 'object' == typeof value && !Array.isArray(value)) {
|
|
195
|
+
const entries = Object.entries(value);
|
|
196
|
+
const rows = entries.slice(0, 4).map(([key, value])=>`${shorten(key, 48)}: ${render(value)}`);
|
|
197
|
+
if (entries.length > 4) rows.push(`… ${entries.length - 4} more fields (see Summary)`);
|
|
198
|
+
return rows;
|
|
199
|
+
}
|
|
200
|
+
return [
|
|
201
|
+
render(value)
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
function formatFailureDetails(source, error, context = {}) {
|
|
205
|
+
const title = context.projectName ? `[${oneLine(context.projectName)}] ${oneLine(context.caseName ?? context.sourcePath ?? context.phase ?? source)}` : oneLine(source);
|
|
206
|
+
const body = [];
|
|
207
|
+
const field = (label, value)=>body.push(` ${label.padEnd(9)}${value}`);
|
|
208
|
+
const step = context.step;
|
|
209
|
+
if (step) {
|
|
210
|
+
const phase = 'steps' === step.phase ? '' : `${step.phase} `;
|
|
211
|
+
const index = step.stepIndex + 1;
|
|
212
|
+
const position = void 0 === context.stepCount ? String(index) : `${index}/${Math.max(index, context.stepCount)}`;
|
|
213
|
+
field('Step', `${phase}${position} · ${oneLine(step.node)}`);
|
|
214
|
+
} else if (context.phase) field('Phase', oneLine(context.phase));
|
|
215
|
+
if (context.sourcePath) {
|
|
216
|
+
const location = step?.meta.sourceLocation;
|
|
217
|
+
field('Location', `${oneLine(context.sourcePath)}${location ? `:${location.line}` : ''}`);
|
|
218
|
+
}
|
|
219
|
+
for (const [label, attempt] of [
|
|
220
|
+
[
|
|
221
|
+
'Attempt',
|
|
222
|
+
context.attempt
|
|
223
|
+
],
|
|
224
|
+
[
|
|
225
|
+
'File try',
|
|
226
|
+
context.fileAttempt
|
|
227
|
+
]
|
|
228
|
+
])if (attempt && (attempt.total > 1 || attempt.index > 0)) field(label, `${attempt.index + 1}/${attempt.total}`);
|
|
229
|
+
if (body.length) body.push('');
|
|
230
|
+
const safeMessage = sanitizeReportError({
|
|
231
|
+
message: formatConciseError(error)
|
|
232
|
+
}).message;
|
|
233
|
+
const lines = stripVTControlCharacters(safeMessage).split(/\r?\n/);
|
|
234
|
+
body.push(` Error: ${shorten(lines[0], 320)}`, ...lines.slice(1, 6).map((line)=>` ${shorten(line, 320)}`));
|
|
235
|
+
if (lines.length > 6) body.push(' … (use --reporter verbose for details)');
|
|
236
|
+
const input = step ? formatFailureInput(step.input) : [];
|
|
237
|
+
if (input.length) {
|
|
238
|
+
body.push('');
|
|
239
|
+
input.forEach((line, index)=>field(0 === index ? 'Input' : '', line));
|
|
240
|
+
}
|
|
241
|
+
const frame = firstBusinessFrame(error, context.projectRoot);
|
|
242
|
+
if (frame) body.push('', ` at ${oneLine(frame)}`);
|
|
243
|
+
return {
|
|
244
|
+
title,
|
|
245
|
+
body
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
var env_ = __webpack_require__("@midscene/shared/env");
|
|
249
|
+
function _define_property(obj, key, value) {
|
|
250
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
251
|
+
value: value,
|
|
252
|
+
enumerable: true,
|
|
253
|
+
configurable: true,
|
|
254
|
+
writable: true
|
|
255
|
+
});
|
|
256
|
+
else obj[key] = value;
|
|
257
|
+
return obj;
|
|
258
|
+
}
|
|
259
|
+
function configuredModelNames() {
|
|
260
|
+
const env = env_.globalConfigManager.getAllEnvConfig();
|
|
261
|
+
const defaultName = env.MIDSCENE_MODEL_NAME;
|
|
262
|
+
const models = new Map();
|
|
263
|
+
for (const [intent, name] of [
|
|
264
|
+
[
|
|
265
|
+
'default',
|
|
266
|
+
defaultName
|
|
267
|
+
],
|
|
268
|
+
[
|
|
269
|
+
'planning',
|
|
270
|
+
env.MIDSCENE_PLANNING_MODEL_NAME || defaultName
|
|
271
|
+
],
|
|
272
|
+
[
|
|
273
|
+
'insight',
|
|
274
|
+
env.MIDSCENE_INSIGHT_MODEL_NAME || defaultName
|
|
275
|
+
]
|
|
276
|
+
]){
|
|
277
|
+
if (!name) continue;
|
|
278
|
+
const intents = models.get(name) ?? [];
|
|
279
|
+
intents.push(intent);
|
|
280
|
+
models.set(name, intents);
|
|
281
|
+
}
|
|
282
|
+
return [
|
|
283
|
+
...models
|
|
284
|
+
].map(([name, intents])=>({
|
|
285
|
+
name,
|
|
286
|
+
intents
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
class CliModelUsage {
|
|
290
|
+
add(step) {
|
|
291
|
+
for (const trace of step.report?.traces ?? [])if (trace.metrics) {
|
|
292
|
+
this.executions.set(trace.executionId, trace.metrics);
|
|
293
|
+
this.missing.delete(trace.executionId);
|
|
294
|
+
} else if (!this.executions.has(trace.executionId)) this.missing.add(trace.executionId);
|
|
295
|
+
}
|
|
296
|
+
snapshot() {
|
|
297
|
+
if (!this.executions.size && !this.missing.size) return;
|
|
298
|
+
return sumTestRunReportMetrics([
|
|
299
|
+
...this.executions.values()
|
|
300
|
+
], this.missing.size ? [
|
|
301
|
+
'Some execution usage has not been recorded.'
|
|
302
|
+
] : []);
|
|
303
|
+
}
|
|
304
|
+
constructor(){
|
|
305
|
+
_define_property(this, "executions", new Map());
|
|
306
|
+
_define_property(this, "missing", new Set());
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function formatTokenCount(value) {
|
|
310
|
+
if (value < 1000) return String(Math.round(value));
|
|
311
|
+
if (value < 1000000) return `${(value / 1000).toFixed(1)}k`;
|
|
312
|
+
return `${(value / 1000000).toFixed(1)}m`;
|
|
313
|
+
}
|
|
314
|
+
function formatModelUsage(metrics) {
|
|
315
|
+
if (!metrics) return [
|
|
316
|
+
'—'
|
|
317
|
+
];
|
|
318
|
+
if (!metrics.models?.length) {
|
|
319
|
+
const name = metrics.modelCallCount || metrics.collection?.status === 'unknown' ? 'unknown' : '—';
|
|
320
|
+
return [
|
|
321
|
+
`${name} · ${formatTokenUsage(metrics)}`
|
|
322
|
+
];
|
|
323
|
+
}
|
|
324
|
+
return metrics.models.map((model)=>{
|
|
325
|
+
const name = stripVTControlCharacters(model.modelName).replace(/\s+/g, ' ').trim() || 'unknown';
|
|
326
|
+
const usage = formatTokenUsage({
|
|
327
|
+
...metrics,
|
|
328
|
+
modelCallCount: model.callCount,
|
|
329
|
+
promptTokens: model.promptTokens,
|
|
330
|
+
cachedInputTokens: model.cachedInputTokens,
|
|
331
|
+
completionTokens: model.completionTokens,
|
|
332
|
+
totalTokens: model.totalTokens
|
|
333
|
+
});
|
|
334
|
+
return `${name} · ${usage}`;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function formatTokenUsage(metrics, includeCached = true) {
|
|
338
|
+
if (!metrics) return '—';
|
|
339
|
+
const state = (field)=>metrics.collection?.fields?.[field] ?? metrics.collection?.status ?? 'complete';
|
|
340
|
+
if ('unknown' === state('totalTokens')) return '— (usage unavailable)';
|
|
341
|
+
if (!metrics.modelCallCount && !metrics.totalTokens && 'complete' === state('totalTokens')) return '0 (no model calls)';
|
|
342
|
+
const count = (field)=>'unknown' === state(field) ? '—' : `${'partial' === state(field) ? '≥' : ''}${formatTokenCount(metrics[field])}`;
|
|
343
|
+
return [
|
|
344
|
+
`${count('totalTokens')} total${'partial' === state('totalTokens') ? ' (partial)' : ''}`,
|
|
345
|
+
`${count('promptTokens')} input${includeCached && metrics.cachedInputTokens ? ` (${formatTokenCount(metrics.cachedInputTokens)} cached)` : ''}`,
|
|
346
|
+
`${count('completionTokens')} output`
|
|
347
|
+
].join(' · ');
|
|
348
|
+
}
|
|
349
|
+
function terminal_progress_define_property(obj, key, value) {
|
|
350
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
351
|
+
value: value,
|
|
352
|
+
enumerable: true,
|
|
353
|
+
configurable: true,
|
|
354
|
+
writable: true
|
|
355
|
+
});
|
|
356
|
+
else obj[key] = value;
|
|
357
|
+
return obj;
|
|
358
|
+
}
|
|
359
|
+
const graphemes = new Intl.Segmenter(void 0, {
|
|
360
|
+
granularity: 'grapheme'
|
|
361
|
+
});
|
|
362
|
+
const SYNC_START = '\u001b[?2026h';
|
|
363
|
+
const SYNC_END = '\u001b[?2026l';
|
|
364
|
+
const truncateTerminalLine = (text, columns)=>{
|
|
365
|
+
if (columns <= 0) return '';
|
|
366
|
+
if (string_width(text) <= columns) return text;
|
|
367
|
+
let output = '';
|
|
368
|
+
let width = 0;
|
|
369
|
+
for (const { segment } of graphemes.segment(stripVTControlCharacters(text))){
|
|
370
|
+
width += string_width(segment);
|
|
371
|
+
if (width > columns - 1) break;
|
|
372
|
+
output += segment;
|
|
373
|
+
}
|
|
374
|
+
return `${output}…`;
|
|
375
|
+
};
|
|
376
|
+
class TerminalProgress {
|
|
377
|
+
erase() {
|
|
378
|
+
if (!this.lines.length) return '';
|
|
379
|
+
const columns = Math.max(1, this.io.columns ?? 100);
|
|
380
|
+
const occupiedRows = this.lines.reduce((sum, line)=>sum + Math.max(1, Math.ceil(string_width(line) / columns)), 0);
|
|
381
|
+
const rows = Math.min(occupiedRows, Math.max(0, (this.io.rows ?? 24) - 1));
|
|
382
|
+
this.lines = [];
|
|
383
|
+
return `\r${rows ? `\u001b[${rows}A` : ''}\u001b[0J`;
|
|
384
|
+
}
|
|
385
|
+
frame(previousHeight = 0) {
|
|
386
|
+
const availableRows = Math.max(0, (this.io.rows ?? 24) - 2);
|
|
387
|
+
const columns = Math.max(0, (this.io.columns ?? 100) - 1);
|
|
388
|
+
if (!availableRows || !columns) return '';
|
|
389
|
+
const footer = this.footer.slice(0, availableRows);
|
|
390
|
+
const budget = availableRows - footer.length;
|
|
391
|
+
const overflow = this.content.length > budget;
|
|
392
|
+
const visible = Math.max(0, budget - (overflow ? 1 : 0));
|
|
393
|
+
const body = this.content.slice(0, visible);
|
|
394
|
+
if (overflow && budget > 0) body.push(` … ${this.content.length - visible} more rows`);
|
|
395
|
+
const lines = [
|
|
396
|
+
...body,
|
|
397
|
+
...footer
|
|
398
|
+
].map((line)=>truncateTerminalLine(line, columns));
|
|
399
|
+
const blanks = Math.max(0, Math.min(previousHeight, availableRows) - lines.length);
|
|
400
|
+
this.lines = [
|
|
401
|
+
...Array.from({
|
|
402
|
+
length: blanks
|
|
403
|
+
}, ()=>''),
|
|
404
|
+
...lines
|
|
405
|
+
];
|
|
406
|
+
return this.lines.length ? `${this.lines.join('\n')}\n` : '';
|
|
407
|
+
}
|
|
408
|
+
render(content, footer) {
|
|
409
|
+
this.update(content, footer);
|
|
410
|
+
const previousHeight = this.lines.length;
|
|
411
|
+
const erase = this.erase();
|
|
412
|
+
this.io.write?.(`${SYNC_START}${erase}${this.frame(previousHeight)}${SYNC_END}`);
|
|
413
|
+
}
|
|
414
|
+
update(content, footer) {
|
|
415
|
+
this.content = content;
|
|
416
|
+
this.footer = footer;
|
|
417
|
+
}
|
|
418
|
+
output(message, write) {
|
|
419
|
+
this.io.write?.(SYNC_START);
|
|
420
|
+
try {
|
|
421
|
+
const erase = this.erase();
|
|
422
|
+
if (erase) this.io.write?.(erase);
|
|
423
|
+
write(message.endsWith('\n') ? message : `${message}\n`);
|
|
424
|
+
const frame = this.frame();
|
|
425
|
+
if (frame) this.io.write?.(frame);
|
|
426
|
+
} finally{
|
|
427
|
+
this.io.write?.(SYNC_END);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
clear() {
|
|
431
|
+
const erase = this.erase();
|
|
432
|
+
this.content = [];
|
|
433
|
+
this.footer = [];
|
|
434
|
+
if (erase) this.io.write?.(`${SYNC_START}${erase}${SYNC_END}`);
|
|
435
|
+
}
|
|
436
|
+
constructor(io){
|
|
437
|
+
terminal_progress_define_property(this, "io", void 0);
|
|
438
|
+
terminal_progress_define_property(this, "lines", void 0);
|
|
439
|
+
terminal_progress_define_property(this, "content", void 0);
|
|
440
|
+
terminal_progress_define_property(this, "footer", void 0);
|
|
441
|
+
this.io = io;
|
|
442
|
+
this.lines = [];
|
|
443
|
+
this.content = [];
|
|
444
|
+
this.footer = [];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const warning_format_oneLine = (text)=>stripVTControlCharacters(text).replace(/\s+/g, ' ').trim();
|
|
448
|
+
function formatWarningScope(scope) {
|
|
449
|
+
if (!scope) return '';
|
|
450
|
+
const parts = [
|
|
451
|
+
`[${warning_format_oneLine(scope.projectName)}]`
|
|
452
|
+
];
|
|
453
|
+
if (scope.sourcePath) parts.push(warning_format_oneLine(scope.sourcePath));
|
|
454
|
+
if (scope.caseName) parts.push(warning_format_oneLine(scope.caseName));
|
|
455
|
+
if (scope.phase) {
|
|
456
|
+
const phase = 'steps' === scope.phase ? 'step' : scope.phase;
|
|
457
|
+
parts.push(`${phase}${void 0 === scope.stepIndex ? '' : `[${scope.stepIndex + 1}]`}${scope.node ? ` ${warning_format_oneLine(scope.node)}` : ''}`);
|
|
458
|
+
}
|
|
459
|
+
return `${parts.join(' › ')} · `;
|
|
460
|
+
}
|
|
461
|
+
function errorMessage(error) {
|
|
462
|
+
const seen = new Set();
|
|
463
|
+
let current = error;
|
|
464
|
+
let message = '';
|
|
465
|
+
let status;
|
|
466
|
+
while(current && 'object' == typeof current && !seen.has(current)){
|
|
467
|
+
seen.add(current);
|
|
468
|
+
const detail = current;
|
|
469
|
+
if ('string' == typeof detail.message) message = detail.message;
|
|
470
|
+
if ('number' == typeof detail.status) status = detail.status;
|
|
471
|
+
current = detail.cause;
|
|
472
|
+
}
|
|
473
|
+
const firstLine = warning_format_oneLine((message || String(error)).split(/\r?\n/)[0]);
|
|
474
|
+
return status && !firstLine.startsWith(String(status)) ? `${status}: ${firstLine}` : firstLine;
|
|
475
|
+
}
|
|
476
|
+
function formatWarning(entry) {
|
|
477
|
+
const details = entry.warning;
|
|
478
|
+
let message;
|
|
479
|
+
if (details?.kind === 'model-request-retry') message = `model request retry ${details.attempt + 1}/${details.maxAttempts} in ${details.delayMs / 1000}s (${warning_format_oneLine(details.modelName)}): ${errorMessage(details.error)}`;
|
|
480
|
+
else {
|
|
481
|
+
const error = entry.args?.find((arg)=>arg instanceof Error);
|
|
482
|
+
const prefix = 'string' == typeof entry.args?.[0] ? warning_format_oneLine(entry.args[0].split(/\r?\n/)[0]).replace(/:\s*$/, '') : '';
|
|
483
|
+
message = error ? `${prefix ? `${prefix}: ` : ''}${errorMessage(error)}` : warning_format_oneLine(entry.message.split(/\r?\n/)[0]);
|
|
484
|
+
message = message.replace(/^\[midscene:warning\]\s*/, '');
|
|
485
|
+
}
|
|
486
|
+
if (message.startsWith('waitForNetworkIdle is skipped for Playwright.')) message = 'Playwright: post-action network-idle wait skipped.';
|
|
487
|
+
return message.length > 240 ? `${message.slice(0, 239)}…` : message;
|
|
488
|
+
}
|
|
489
|
+
function warningKey(entry, message) {
|
|
490
|
+
const details = entry.warning;
|
|
491
|
+
if (details?.kind === 'model-request-retry') return `${entry.topic}\n${JSON.stringify(entry.scope)}\n${details.callId}\n${details.attempt}`;
|
|
492
|
+
const scope = entry.args?.some((arg)=>arg instanceof Error) ? JSON.stringify(entry.scope) : '';
|
|
493
|
+
return `${entry.topic}\n${scope}\n${message}`;
|
|
494
|
+
}
|
|
495
|
+
function terminal_reporter_define_property(obj, key, value) {
|
|
496
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
497
|
+
value: value,
|
|
498
|
+
enumerable: true,
|
|
499
|
+
configurable: true,
|
|
500
|
+
writable: true
|
|
501
|
+
});
|
|
502
|
+
else obj[key] = value;
|
|
503
|
+
return obj;
|
|
504
|
+
}
|
|
505
|
+
const terminal_reporter_oneLine = (value)=>stripVTControlCharacters(value).replace(/\s+/g, ' ').trim();
|
|
506
|
+
const formatDuration = (ms)=>{
|
|
507
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
508
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
509
|
+
return `${Math.floor(ms / 60000)}m ${Math.floor(ms % 60000 / 1000)}s`;
|
|
510
|
+
};
|
|
511
|
+
const identity = (project, path, name)=>`[${terminal_reporter_oneLine(project)}] ${terminal_reporter_oneLine(path)}${name ? ` › ${terminal_reporter_oneLine(name)}` : ''}`;
|
|
512
|
+
const projectPhaseLabel = (phase)=>'setup' === phase ? 'setting up' : 'cleaning up';
|
|
513
|
+
const fileStatus = (status, cases)=>{
|
|
514
|
+
if ('failed' === status || cases.some((item)=>'failed' === item.status)) return 'failed';
|
|
515
|
+
if (cases.length && cases.every((item)=>'not-run' === item.status)) return 'not-run';
|
|
516
|
+
if (cases.some((item)=>'not-run' === item.status)) return 'failed';
|
|
517
|
+
return status ?? (cases.length ? 'success' : 'not-run');
|
|
518
|
+
};
|
|
519
|
+
class TerminalReporter {
|
|
520
|
+
get interactive() {
|
|
521
|
+
return !!this.io.isTTY && !!this.io.write && 'default' === this.mode;
|
|
522
|
+
}
|
|
523
|
+
log(message) {
|
|
524
|
+
if (this.interactive) this.output(`${message}\n`, (text)=>this.io.write(text));
|
|
525
|
+
else this.io.log(message);
|
|
526
|
+
this.lastOutput = Date.now();
|
|
527
|
+
}
|
|
528
|
+
output(message, write) {
|
|
529
|
+
this.renderProgress(false);
|
|
530
|
+
this.progressView.output(message, write);
|
|
531
|
+
this.lastOutput = Date.now();
|
|
532
|
+
}
|
|
533
|
+
progress(message) {
|
|
534
|
+
if ('verbose' === this.mode) this.log(message);
|
|
535
|
+
}
|
|
536
|
+
console(entry) {
|
|
537
|
+
if ('report' === entry.type) {
|
|
538
|
+
if ('verbose' === this.mode) this.log(entry.message);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const scope = formatWarningScope(entry.scope);
|
|
542
|
+
if ('verbose' === this.mode) {
|
|
543
|
+
const kind = entry.warning?.kind === 'model-request-retry' ? 'model request retry · ' : entry.warning?.kind === 'model-request-detail' ? 'model request error · ' : '';
|
|
544
|
+
this.log(`WARN ${scope}${kind}${entry.message}`);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (entry.warning?.kind === 'model-request-detail') return;
|
|
548
|
+
const message = formatWarning(entry);
|
|
549
|
+
const key = warningKey(entry, message);
|
|
550
|
+
if (this.warnings.has(key)) return;
|
|
551
|
+
this.warnings.set(key, {
|
|
552
|
+
scope,
|
|
553
|
+
message
|
|
554
|
+
});
|
|
555
|
+
if (this.interactive) {
|
|
556
|
+
let attached = false;
|
|
557
|
+
if (entry.warning?.kind === 'model-request-retry' && entry.scope) {
|
|
558
|
+
const owner = entry.scope;
|
|
559
|
+
for (const [id, task] of this.active)if (task.file.projectName === owner.projectName && (!owner.sourcePath || task.file.sourcePath === owner.sourcePath) && (!owner.caseId || id === owner.caseId) && (!owner.runId || task.runId === owner.runId) && (!owner.phase || task.phase === owner.phase) && (void 0 === owner.stepIndex || task.stepIndex === owner.stepIndex)) {
|
|
560
|
+
task.warning = message;
|
|
561
|
+
attached = true;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (!attached) this.latestNotice = message.startsWith('Playwright:') ? 'Playwright: network-idle wait skipped.' : `${scope}${message}`;
|
|
565
|
+
this.schedule();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
this.log(`${this.color.yellow('WARN')} ${this.color.dim(scope)}${message}`);
|
|
569
|
+
}
|
|
570
|
+
file(projectName, sourcePath, total = 0) {
|
|
571
|
+
const key = `${projectName}\0${sourcePath}`;
|
|
572
|
+
let file = this.files.get(key);
|
|
573
|
+
if (!file) {
|
|
574
|
+
file = {
|
|
575
|
+
projectName,
|
|
576
|
+
sourcePath,
|
|
577
|
+
total,
|
|
578
|
+
outcomes: new Map(),
|
|
579
|
+
attemptOutcomes: new Map(),
|
|
580
|
+
started: false,
|
|
581
|
+
printed: false,
|
|
582
|
+
durationMs: 0,
|
|
583
|
+
retries: 0,
|
|
584
|
+
completionOrder: 0
|
|
585
|
+
};
|
|
586
|
+
this.files.set(key, file);
|
|
587
|
+
}
|
|
588
|
+
file.total = Math.max(file.total, total);
|
|
589
|
+
return file;
|
|
590
|
+
}
|
|
591
|
+
event(event) {
|
|
592
|
+
if ('run-start' === event.type) {
|
|
593
|
+
this.total = event.cases;
|
|
594
|
+
this.totalFiles = event.documents;
|
|
595
|
+
this.startedAt = event.startedAt ? Date.parse(event.startedAt) : Date.now();
|
|
596
|
+
for (const file of event.files ?? [])this.file(file.projectName, file.sourcePath, file.cases);
|
|
597
|
+
const c = this.color;
|
|
598
|
+
this.log(`\n ${c.bgGreen.black.bold(' RUN ')} ${c.green(`midscene-test v${package_0.rE}`)} ${c.dim(terminal_reporter_oneLine(event.projectRoot ?? process.cwd()))}`);
|
|
599
|
+
this.log(` ${c.dim(`${event.projects} projects · ${event.documents} files · ${event.cases} cases`)}`);
|
|
600
|
+
if (void 0 !== event.maxConcurrentProjects && void 0 !== event.maxConcurrentFiles) this.log(` ${c.dim(`Concurrency ${event.maxConcurrentProjects ?? 'unlimited'} projects · ${event.maxConcurrentFiles ?? 'unlimited'} files (global limits)`)}`);
|
|
601
|
+
if (event.configPath) this.log(` ${c.dim(`Config ${terminal_reporter_oneLine(event.configPath)}`)}`);
|
|
602
|
+
for (const model of event.configuredModels ?? [])this.log(` ${c.dim('Models (env)')} ${terminal_reporter_oneLine(model.name)} ${c.dim(`· ${model.intents.map(terminal_reporter_oneLine).join(', ')}`)}`);
|
|
603
|
+
this.log('');
|
|
604
|
+
if ('default' === this.mode) {
|
|
605
|
+
this.timer = setInterval(()=>this.tick(), this.interactive ? 1000 : 30000);
|
|
606
|
+
this.timer.unref();
|
|
607
|
+
if (this.interactive) this.removeResizeListener = this.io.onResize?.(()=>this.renderProgress());
|
|
608
|
+
this.renderProgress();
|
|
609
|
+
}
|
|
610
|
+
} else if ('project-end' === event.type) {
|
|
611
|
+
this.completeProject(event.project);
|
|
612
|
+
this.schedule();
|
|
613
|
+
} else if ('project-phase' === event.type) {
|
|
614
|
+
if ('running' === event.phase) this.projectPhases.delete(event.projectName);
|
|
615
|
+
else this.projectPhases.set(event.projectName, {
|
|
616
|
+
phase: event.phase,
|
|
617
|
+
since: Date.now()
|
|
618
|
+
});
|
|
619
|
+
this.schedule();
|
|
620
|
+
} else if ('document-start' === event.type) {
|
|
621
|
+
this.file(event.projectName, event.sourcePath, event.cases).started = true;
|
|
622
|
+
this.schedule();
|
|
623
|
+
} else if ('document-end' === event.type) {
|
|
624
|
+
const file = this.file(event.document.projectName, event.document.sourcePath, event.cases.length);
|
|
625
|
+
file.status = fileStatus(event.document.status, event.cases);
|
|
626
|
+
file.started = true;
|
|
627
|
+
file.completionOrder = ++this.completionOrder;
|
|
628
|
+
file.durationMs = event.durationMs;
|
|
629
|
+
file.retries = event.document.attemptIndex ?? 0;
|
|
630
|
+
file.outcomes = new Map(event.cases.map((outcome)=>[
|
|
631
|
+
outcome.caseId,
|
|
632
|
+
outcome
|
|
633
|
+
]));
|
|
634
|
+
file.attemptOutcomes.clear();
|
|
635
|
+
for (const [key, task] of this.active)if (task.file === file) this.active.delete(key);
|
|
636
|
+
if (!this.interactive) this.printFile(file);
|
|
637
|
+
this.schedule();
|
|
638
|
+
} else if ('step-start' === event.type) this.startStep(event.projectName, event.info);
|
|
639
|
+
else if ('step-end' === event.type) {
|
|
640
|
+
if (event.result) this.usage.add(event.result);
|
|
641
|
+
const key = 'case' === event.info.scope ? event.info.case.caseId : event.info.document.documentId;
|
|
642
|
+
const task = this.active.get(key);
|
|
643
|
+
if (task) if ('document' === event.info.scope) this.active.delete(key);
|
|
644
|
+
else {
|
|
645
|
+
task.step = void 0;
|
|
646
|
+
task.warning = void 0;
|
|
647
|
+
}
|
|
648
|
+
this.schedule();
|
|
649
|
+
} else if ('case-attempt-end' === event.type) this.attempts.set(event.attempt.caseId, {
|
|
650
|
+
run: event.attempt,
|
|
651
|
+
max: event.maxAttempts
|
|
652
|
+
});
|
|
653
|
+
else if ('case-end' === event.type || 'case-progress' === event.type) {
|
|
654
|
+
const file = this.file(event.outcome.projectName, event.outcome.sourcePath);
|
|
655
|
+
if ('case-progress' === event.type) {
|
|
656
|
+
file.attemptOutcomes.set(event.outcome.caseId, event.outcome);
|
|
657
|
+
file.retries = Math.max(file.retries, event.documentAttemptIndex);
|
|
658
|
+
} else {
|
|
659
|
+
file.attemptOutcomes.delete(event.outcome.caseId);
|
|
660
|
+
file.outcomes.set(event.outcome.caseId, event.outcome);
|
|
661
|
+
file.retries = Math.max(file.retries, event.documentRetries ?? 0);
|
|
662
|
+
}
|
|
663
|
+
this.active.delete(event.outcome.caseId);
|
|
664
|
+
this.schedule();
|
|
665
|
+
} else if ('document-retry' === event.type) {
|
|
666
|
+
const file = this.file(event.document.projectName, event.document.sourcePath);
|
|
667
|
+
file.outcomes.clear();
|
|
668
|
+
file.attemptOutcomes.clear();
|
|
669
|
+
file.status = void 0;
|
|
670
|
+
file.completionOrder = 0;
|
|
671
|
+
for (const [key, task] of this.active)if (task.file === file) this.active.delete(key);
|
|
672
|
+
this.log(this.color.yellow(` ↻ ${identity(file.projectName, file.sourcePath)} · file retry ${(event.document.attemptIndex ?? 0) + 2}/${event.maxAttempts}`));
|
|
673
|
+
this.schedule();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
startStep(projectName, info) {
|
|
677
|
+
const position = 'case' === info.scope ? info.case : info.document;
|
|
678
|
+
const file = this.file(projectName, position.sourcePath);
|
|
679
|
+
file.started = true;
|
|
680
|
+
const key = 'case' === info.scope ? info.case.caseId : info.document.documentId;
|
|
681
|
+
const attempt = 'case' === info.scope ? info.case.attemptIndex : 0;
|
|
682
|
+
const previous = this.attempts.get(key);
|
|
683
|
+
if (previous && previous.run.attemptIndex < attempt) {
|
|
684
|
+
this.attempts.delete(key);
|
|
685
|
+
if ('default' === this.mode && !this.interactive) this.log(this.color.yellow(` ↻ ${identity(projectName, position.sourcePath, 'case' === info.scope ? info.case.name : void 0)} · case retry · attempt ${attempt + 1}/${previous.max}`));
|
|
686
|
+
}
|
|
687
|
+
const existing = this.active.get(key);
|
|
688
|
+
const now = Date.now();
|
|
689
|
+
this.active.set(key, {
|
|
690
|
+
file,
|
|
691
|
+
name: 'case' === info.scope ? info.case.name : void 0,
|
|
692
|
+
step: `${'steps' === position.phase ? 'step' : position.phase} ${position.stepIndex + 1}/${info.stepCount} · ${terminal_reporter_oneLine(info.node)}`,
|
|
693
|
+
since: existing?.attempt === attempt ? existing.since : now,
|
|
694
|
+
attempt,
|
|
695
|
+
maxAttempts: previous?.max ?? existing?.maxAttempts,
|
|
696
|
+
runId: 'case' === info.scope ? info.case.runId : info.document.documentRunId,
|
|
697
|
+
phase: position.phase,
|
|
698
|
+
stepIndex: position.stepIndex,
|
|
699
|
+
caseIndex: 'case' === info.scope ? info.case.caseIndex : void 0
|
|
700
|
+
});
|
|
701
|
+
this.schedule();
|
|
702
|
+
}
|
|
703
|
+
schedule() {
|
|
704
|
+
if (!this.interactive || !this.timer || this.scheduled) return;
|
|
705
|
+
this.scheduled = setTimeout(()=>{
|
|
706
|
+
this.scheduled = void 0;
|
|
707
|
+
this.renderProgress();
|
|
708
|
+
}, 100);
|
|
709
|
+
this.scheduled.unref();
|
|
710
|
+
}
|
|
711
|
+
counts(items, total) {
|
|
712
|
+
const failed = items.filter((item)=>'failed' === item.status).length;
|
|
713
|
+
const passed = items.filter((item)=>'success' === item.status).length;
|
|
714
|
+
const notRun = items.filter((item)=>'not-run' === item.status).length;
|
|
715
|
+
return this.stateCounts(passed, failed, notRun, total);
|
|
716
|
+
}
|
|
717
|
+
stateCounts(passed, failed, notRun, total) {
|
|
718
|
+
const c = this.color;
|
|
719
|
+
return [
|
|
720
|
+
failed ? c.red.bold(`${failed} failed`) : '',
|
|
721
|
+
c.green.bold(`${passed} passed`),
|
|
722
|
+
notRun ? c.yellow(`${notRun} not run`) : ''
|
|
723
|
+
].filter(Boolean).join(c.dim(' | ')) + c.dim(` (${total})`);
|
|
724
|
+
}
|
|
725
|
+
footer(durationMs = Date.now() - this.startedAt, result) {
|
|
726
|
+
const files = [
|
|
727
|
+
...this.files.values()
|
|
728
|
+
];
|
|
729
|
+
const cases = files.flatMap((file)=>[
|
|
730
|
+
...file.outcomes.values()
|
|
731
|
+
]);
|
|
732
|
+
const c = this.color;
|
|
733
|
+
const metrics = result ? this.finalMetrics : this.usage.snapshot();
|
|
734
|
+
return [
|
|
735
|
+
` ${c.dim('Test Files')} ${this.counts(files, this.totalFiles)}`,
|
|
736
|
+
` ${c.dim(' Cases')} ${result ? this.stateCounts(result.summary.passed, result.summary.failed, result.summary.notRun, result.summary.total) : this.counts(cases, this.total)}`,
|
|
737
|
+
` ${c.dim(' Start at')} ${new Date(this.startedAt).toLocaleTimeString('en-GB', {
|
|
738
|
+
hour12: false
|
|
739
|
+
})}`,
|
|
740
|
+
` ${c.dim(' Duration')} ${formatDuration(durationMs)}`,
|
|
741
|
+
...formatModelUsage(metrics).map((model, index)=>` ${c.dim(index ? ' ' : ' Models:')} ${model}`),
|
|
742
|
+
...!result || this.warnings.size ? [
|
|
743
|
+
` ${c.dim(' Warnings')} ${this.warnings.size ? c.yellow(String(this.warnings.size)) : c.dim('0')}${!result && this.latestNotice ? c.dim(` · ${this.latestNotice}`) : ''}`
|
|
744
|
+
] : []
|
|
745
|
+
];
|
|
746
|
+
}
|
|
747
|
+
renderProgress(render = true) {
|
|
748
|
+
if (!this.interactive || !this.timer) return;
|
|
749
|
+
const projects = new Map();
|
|
750
|
+
const files = [
|
|
751
|
+
...this.files.values()
|
|
752
|
+
].filter((file)=>file.started && !file.printed).sort((a, b)=>a.projectName.localeCompare(b.projectName) || a.sourcePath.localeCompare(b.sourcePath));
|
|
753
|
+
for (const file of files){
|
|
754
|
+
const group = projects.get(file.projectName) ?? [];
|
|
755
|
+
group.push(file);
|
|
756
|
+
projects.set(file.projectName, group);
|
|
757
|
+
}
|
|
758
|
+
for (const projectName of this.projectPhases.keys())if (!projects.has(projectName)) projects.set(projectName, []);
|
|
759
|
+
const footer = this.footer();
|
|
760
|
+
const budget = Math.max(0, (this.io.rows ?? 24) - 2 - footer.length - 1);
|
|
761
|
+
let lines = this.liveTree(projects, 'full');
|
|
762
|
+
if (lines.length > budget) lines = this.liveTree(projects, 'recent');
|
|
763
|
+
if (lines.length > budget) lines = this.liveTree(projects, 'compact');
|
|
764
|
+
if (lines.length > budget) lines = this.liveTree(projects, 'active');
|
|
765
|
+
if (lines.length) lines.push('');
|
|
766
|
+
if (render) this.progressView.render(lines, footer);
|
|
767
|
+
else this.progressView.update(lines, footer);
|
|
768
|
+
}
|
|
769
|
+
liveTree(projects, mode) {
|
|
770
|
+
const c = this.color;
|
|
771
|
+
const lines = [];
|
|
772
|
+
const groups = [
|
|
773
|
+
...projects
|
|
774
|
+
];
|
|
775
|
+
const isActive = ([projectName, files])=>this.projectPhases.has(projectName) || files.some((file)=>!file.status);
|
|
776
|
+
if ('full' !== mode) groups.sort((a, b)=>Number(isActive(b)) - Number(isActive(a)));
|
|
777
|
+
for (const [projectName, files] of groups){
|
|
778
|
+
const phase = this.projectPhases.get(projectName);
|
|
779
|
+
const running = files.filter((file)=>!file.status);
|
|
780
|
+
const completed = files.filter((file)=>file.status);
|
|
781
|
+
const recent = [
|
|
782
|
+
...completed
|
|
783
|
+
].sort((a, b)=>b.completionOrder - a.completionOrder).slice(0, 'recent' === mode ? 1 : 0);
|
|
784
|
+
const visible = 'full' === mode ? files : [
|
|
785
|
+
...running,
|
|
786
|
+
...recent
|
|
787
|
+
];
|
|
788
|
+
const hidden = completed.filter((file)=>!visible.includes(file));
|
|
789
|
+
const hiddenFailures = hidden.filter((file)=>'failed' === file.status).length;
|
|
790
|
+
const details = [
|
|
791
|
+
phase ? c.yellow(`${projectPhaseLabel(phase.phase)} ${formatDuration(Date.now() - phase.since)}`) : '',
|
|
792
|
+
c.yellow(`${running.length} ${1 === running.length ? 'file' : 'files'} running`),
|
|
793
|
+
completed.length ? c.dim(`${completed.length} completed`) : '',
|
|
794
|
+
'active' === mode && hiddenFailures ? c.red(`${hiddenFailures} failed`) : ''
|
|
795
|
+
].filter(Boolean);
|
|
796
|
+
lines.push(this.liveRow(` ${c.yellow('❯')} ${c.bold(terminal_reporter_oneLine(projectName))}`, c.dim(' · ') + details.join(c.dim(' · '))));
|
|
797
|
+
for (const [fileIndex, file] of visible.entries()){
|
|
798
|
+
const lastFile = fileIndex === visible.length - 1 && (!hidden.length || 'active' === mode);
|
|
799
|
+
const stem = lastFile ? ' ' : ` ${c.dim('│')} `;
|
|
800
|
+
const outcomes = [
|
|
801
|
+
...new Map([
|
|
802
|
+
...file.attemptOutcomes,
|
|
803
|
+
...file.outcomes
|
|
804
|
+
]).values()
|
|
805
|
+
].sort((a, b)=>a.caseIndex - b.caseIndex);
|
|
806
|
+
const failedCases = outcomes.filter((outcome)=>'failed' === outcome.status).length;
|
|
807
|
+
lines.push(this.liveRow(` ${c.dim(lastFile ? '└─' : '├─')} ${file.status ? `${this.statusSymbol(file.status)} ` : ''}${terminal_reporter_oneLine(file.sourcePath)}`, ` ${c.dim(`${outcomes.length}/${file.total} cases`)}${file.status ? c.dim(` ${formatDuration(file.durationMs)}`) : ''}${'active' === mode && failedCases ? c.red(` · ${failedCases} failed`) : ''}`));
|
|
808
|
+
const tasks = [
|
|
809
|
+
...this.active.values()
|
|
810
|
+
].filter((task)=>task.file === file);
|
|
811
|
+
const shown = 'full' === mode ? outcomes : 'recent' === mode ? outcomes.slice(-1) : [];
|
|
812
|
+
const children = [
|
|
813
|
+
...tasks.map((task)=>({
|
|
814
|
+
task
|
|
815
|
+
})),
|
|
816
|
+
...shown.map((outcome)=>({
|
|
817
|
+
outcome
|
|
818
|
+
}))
|
|
819
|
+
];
|
|
820
|
+
if ('full' === mode) children.sort((a, b)=>this.liveChildIndex(a) - this.liveChildIndex(b));
|
|
821
|
+
const hiddenCases = outcomes.filter((outcome)=>!shown.includes(outcome));
|
|
822
|
+
if (hiddenCases.length && 'active' !== mode) {
|
|
823
|
+
const failed = hiddenCases.filter((outcome)=>'failed' === outcome.status).length;
|
|
824
|
+
children.push({
|
|
825
|
+
summary: `… ${hiddenCases.length} completed ${1 === hiddenCases.length ? 'case' : 'cases'}${failed ? ` · ${failed} failed` : ''}`,
|
|
826
|
+
failed: failed > 0
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
for (const [index, child] of children.entries()){
|
|
830
|
+
const last = index === children.length - 1;
|
|
831
|
+
const prefix = `${stem}${c.dim(last ? '└─' : '├─')} `;
|
|
832
|
+
if ('outcome' in child) {
|
|
833
|
+
lines.push(this.liveRow(`${prefix}${this.formatCaseResult(child.outcome, file.retries)}`, ''));
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if ('summary' in child) {
|
|
837
|
+
lines.push(this.liveRow(`${prefix}${child.failed ? c.red(child.summary) : c.dim(child.summary)}`, ''));
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
const { task } = child;
|
|
841
|
+
const hint = task.name && task.step ? truncateTerminalLine(` · ${task.step}`, Math.floor(Math.max(0, (this.io.columns ?? 100) - 1) / 2)) : '';
|
|
842
|
+
const retry = task.attempt ? ` · attempt ${task.attempt + 1}${task.maxAttempts ? `/${task.maxAttempts}` : ''}` : '';
|
|
843
|
+
lines.push(this.liveRow(`${prefix}${terminal_reporter_oneLine(task.name ?? task.step ?? 'file lifecycle')}`, `${c.dim(hint)} ${c.yellow(formatDuration(Date.now() - task.since))}${c.yellow(retry)}`));
|
|
844
|
+
if (task.warning) lines.push(this.liveRow(`${stem}${last ? ' ' : `${c.dim('│')} `}${c.dim('└─')} ${c.yellow(`⚠ ${task.warning}`)}`, ''));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (hidden.length && 'active' !== mode) {
|
|
848
|
+
const summary = `… ${hidden.length} completed ${1 === hidden.length ? 'file' : 'files'}${hiddenFailures ? ` · ${hiddenFailures} failed` : ''}`;
|
|
849
|
+
lines.push(this.liveRow(` ${c.dim('└─')} ${hiddenFailures ? c.red(summary) : c.dim(summary)}`, ''));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return lines;
|
|
853
|
+
}
|
|
854
|
+
liveChildIndex(child) {
|
|
855
|
+
if ('outcome' in child) return child.outcome.caseIndex;
|
|
856
|
+
if ('summary' in child) return Number.MAX_SAFE_INTEGER;
|
|
857
|
+
return child.task.caseIndex ?? ('beforeAll' === child.task.phase ? -1 : Number.MAX_SAFE_INTEGER);
|
|
858
|
+
}
|
|
859
|
+
liveRow(label, suffix) {
|
|
860
|
+
const columns = Math.max(0, (this.io.columns ?? 100) - 1);
|
|
861
|
+
const tail = truncateTerminalLine(suffix, columns);
|
|
862
|
+
return truncateTerminalLine(label, columns - string_width(tail)) + tail;
|
|
863
|
+
}
|
|
864
|
+
tick() {
|
|
865
|
+
if (this.interactive) return void this.renderProgress();
|
|
866
|
+
if ((this.active.size || this.projectPhases.size) && Date.now() - this.lastOutput >= 30000) this.log(`… Still running: ${[
|
|
867
|
+
...[
|
|
868
|
+
...this.projectPhases
|
|
869
|
+
].map(([projectName, phase])=>`[${terminal_reporter_oneLine(projectName)}] · ${projectPhaseLabel(phase.phase)} (${formatDuration(Date.now() - phase.since)})`),
|
|
870
|
+
...[
|
|
871
|
+
...this.active.values()
|
|
872
|
+
].map((task)=>`${identity(task.file.projectName, task.file.sourcePath, task.name)} · ${task.step ?? 'between steps'} (${formatDuration(Date.now() - task.since)})`)
|
|
873
|
+
].join(' | ')}`);
|
|
874
|
+
}
|
|
875
|
+
formatCaseResult(outcome, documentRetries) {
|
|
876
|
+
const c = this.color;
|
|
877
|
+
const attempts = outcome.attempts ?? (outcome.run ? [
|
|
878
|
+
outcome.run
|
|
879
|
+
] : []);
|
|
880
|
+
const retries = Math.max(attempts.length - 1, outcome.run?.attemptIndex ?? 0);
|
|
881
|
+
const duration = formatDuration(attempts.reduce((sum, attempt)=>sum + attempt.durationMs, 0));
|
|
882
|
+
const symbol = 'success' === outcome.status ? c.green('✓') : 'failed' === outcome.status ? c.red('×') : c.yellow('–');
|
|
883
|
+
const detail = 'not-run' === outcome.status ? c.dim(` · ${outcome.notRunReason ?? 'not run'}`) : ` ${c.dim(duration)}${retries ? c.yellow(` · ${'success' === outcome.status ? 'passed after retry' : 'failed after retries'} (${retries + 1} attempts)`) : documentRetries ? c.yellow(` · file retried ${documentRetries}x`) : ''}`;
|
|
884
|
+
return `${symbol} ${terminal_reporter_oneLine(outcome.name)}${detail}`;
|
|
885
|
+
}
|
|
886
|
+
statusSymbol(status) {
|
|
887
|
+
return 'failed' === status ? this.color.red('×') : 'not-run' === status ? this.color.yellow('–') : this.color.green('✓');
|
|
888
|
+
}
|
|
889
|
+
resultFileLines(file, lastFile) {
|
|
890
|
+
const c = this.color;
|
|
891
|
+
const stem = lastFile ? ' ' : ` ${c.dim('│')} `;
|
|
892
|
+
const cases = [
|
|
893
|
+
...file.outcomes.values()
|
|
894
|
+
].sort((a, b)=>a.caseIndex - b.caseIndex);
|
|
895
|
+
const failed = cases.filter((item)=>'failed' === item.status).length;
|
|
896
|
+
const notRun = cases.filter((item)=>'not-run' === item.status).length;
|
|
897
|
+
const stats = `(${cases.length} cases${failed ? ` | ${failed} failed` : ''}${notRun ? ` | ${notRun} not run` : ''})`;
|
|
898
|
+
return [
|
|
899
|
+
` ${c.dim(lastFile ? '└─' : '├─')} ${this.statusSymbol(file.status)} ${c.bold(terminal_reporter_oneLine(file.sourcePath))} ${c.dim(stats)} ${c.dim(formatDuration(file.durationMs))}`,
|
|
900
|
+
...cases.map((item, caseIndex)=>`${stem}${c.dim(caseIndex === cases.length - 1 ? '└─' : '├─')} ${this.formatCaseResult(item, file.retries)}`)
|
|
901
|
+
];
|
|
902
|
+
}
|
|
903
|
+
printFile(file) {
|
|
904
|
+
if (file.printed) return;
|
|
905
|
+
file.printed = true;
|
|
906
|
+
this.log([
|
|
907
|
+
'',
|
|
908
|
+
` ${this.color.bold(terminal_reporter_oneLine(file.projectName))}`,
|
|
909
|
+
...this.resultFileLines(file, true)
|
|
910
|
+
].join('\n'));
|
|
911
|
+
}
|
|
912
|
+
printProject(projectName, files, failed) {
|
|
913
|
+
if (!files.length || files.every((file)=>file.printed)) return;
|
|
914
|
+
const c = this.color;
|
|
915
|
+
const status = failed || files.some((file)=>'failed' === file.status) ? 'failed' : files.every((file)=>'not-run' === file.status) ? 'not-run' : files.some((file)=>'not-run' === file.status) ? 'failed' : 'success';
|
|
916
|
+
const lines = [
|
|
917
|
+
'',
|
|
918
|
+
` ${this.statusSymbol(status)} ${c.bold(terminal_reporter_oneLine(projectName))} ${c.dim(`(${files.length} ${1 === files.length ? 'file' : 'files'})`)}`
|
|
919
|
+
];
|
|
920
|
+
for (const [index, file] of files.entries())if (!file.printed) {
|
|
921
|
+
file.printed = true;
|
|
922
|
+
lines.push(...this.resultFileLines(file, index === files.length - 1));
|
|
923
|
+
}
|
|
924
|
+
this.log(lines.join('\n'));
|
|
925
|
+
}
|
|
926
|
+
failure(source, error, context) {
|
|
927
|
+
this.failures.push({
|
|
928
|
+
source,
|
|
929
|
+
error,
|
|
930
|
+
context
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
completeProject(project) {
|
|
934
|
+
this.projectPhases.delete(project.name);
|
|
935
|
+
const projectFiles = new Set();
|
|
936
|
+
const documents = new Map(project.documents.map((document)=>[
|
|
937
|
+
document.documentId,
|
|
938
|
+
document
|
|
939
|
+
]));
|
|
940
|
+
const resultSources = new Set([
|
|
941
|
+
...project.cases.map((outcome)=>outcome.sourcePath),
|
|
942
|
+
...project.documents.map((document)=>document.sourcePath),
|
|
943
|
+
...project.collectionErrors.map((error)=>error.sourcePath)
|
|
944
|
+
]);
|
|
945
|
+
for (const plan of project.plans ?? []){
|
|
946
|
+
const selected = plan.cases.filter((item)=>item.selected).length;
|
|
947
|
+
if (selected || resultSources.has(plan.sourcePath)) projectFiles.add(this.file(project.name, plan.sourcePath, selected));
|
|
948
|
+
}
|
|
949
|
+
for (const outcome of project.cases)this.file(project.name, outcome.sourcePath).outcomes.set(outcome.caseId, outcome);
|
|
950
|
+
for (const document of documents.values()){
|
|
951
|
+
const file = this.file(project.name, document.sourcePath);
|
|
952
|
+
file.status = document.status;
|
|
953
|
+
file.retries = document.attemptIndex ?? 0;
|
|
954
|
+
file.durationMs = project.documents.filter((item)=>item.documentId === document.documentId).reduce((sum, item)=>sum + item.durationMs, 0);
|
|
955
|
+
}
|
|
956
|
+
for (const error of project.collectionErrors)this.file(project.name, error.sourcePath).status = 'failed';
|
|
957
|
+
for (const file of this.files.values())if (file.projectName === project.name) {
|
|
958
|
+
file.attemptOutcomes.clear();
|
|
959
|
+
const cases = [
|
|
960
|
+
...file.outcomes.values()
|
|
961
|
+
];
|
|
962
|
+
file.status = fileStatus(file.status, cases);
|
|
963
|
+
projectFiles.add(file);
|
|
964
|
+
}
|
|
965
|
+
for (const [key, task] of this.active)if (task.file.projectName === project.name) this.active.delete(key);
|
|
966
|
+
if (this.interactive) this.printProject(project.name, [
|
|
967
|
+
...projectFiles
|
|
968
|
+
], 'failed' === project.status);
|
|
969
|
+
else for (const file of projectFiles)this.printFile(file);
|
|
970
|
+
}
|
|
971
|
+
finish(result) {
|
|
972
|
+
this.dispose();
|
|
973
|
+
this.finalMetrics = result.metrics ?? this.usage.snapshot();
|
|
974
|
+
if (result.startedAt) this.startedAt = Date.parse(result.startedAt);
|
|
975
|
+
for (const project of result.projects)this.completeProject(project);
|
|
976
|
+
this.total = result.summary.total;
|
|
977
|
+
this.totalFiles = this.files.size;
|
|
978
|
+
}
|
|
979
|
+
sectionHeading(label, kind) {
|
|
980
|
+
const c = this.color;
|
|
981
|
+
const width = Math.max(0, (this.io.columns ?? 80) - 2);
|
|
982
|
+
const title = truncateTerminalLine(` ${label} `, width);
|
|
983
|
+
const padding = Math.max(0, width - string_width(title));
|
|
984
|
+
const left = Math.floor(padding / 2);
|
|
985
|
+
const line = 'warning' === kind ? c.yellow : c.red;
|
|
986
|
+
const badge = 'warning' === kind ? c.bgYellow.black.bold : c.bgRed.white.bold;
|
|
987
|
+
this.log(`\n ${line('─'.repeat(left))}${badge(title)}${line('─'.repeat(padding - left))}\n`);
|
|
988
|
+
}
|
|
989
|
+
summary(result) {
|
|
990
|
+
const c = this.color;
|
|
991
|
+
if (this.interactive && this.warnings.size) {
|
|
992
|
+
this.sectionHeading(`Warnings ${this.warnings.size}`, 'warning');
|
|
993
|
+
for (const warning of [
|
|
994
|
+
...this.warnings.values()
|
|
995
|
+
].slice(0, 5))this.log(` ${c.yellow('WARN')} ${c.dim(warning.scope)}${warning.message}`);
|
|
996
|
+
if (this.warnings.size > 5) this.log(c.dim(` … ${this.warnings.size - 5} more warnings; see debug logs or --reporter verbose.`));
|
|
997
|
+
}
|
|
998
|
+
if (this.failures.length) {
|
|
999
|
+
this.sectionHeading(`Failures ${this.failures.length}`, 'failure');
|
|
1000
|
+
for (const failure of this.failures){
|
|
1001
|
+
if ('default' === this.mode) {
|
|
1002
|
+
const detail = formatFailureDetails(failure.source, failure.error, failure.context);
|
|
1003
|
+
const body = detail.body.map((line)=>{
|
|
1004
|
+
if (line.startsWith(' Error:')) return c.red(line);
|
|
1005
|
+
if (line.startsWith(' at ')) return c.dim(line);
|
|
1006
|
+
return line.replace(/^ {2}(?:Step|Location|Attempt|File try|Phase|Input)\s+/, (label)=>c.dim(label));
|
|
1007
|
+
}).join('\n');
|
|
1008
|
+
const block = `${c.bgRed.white.bold(' FAIL ')} ${c.bold(detail.title)}\n${body}\n`;
|
|
1009
|
+
if (this.interactive) this.log(block);
|
|
1010
|
+
else this.io.error(`FAIL ${detail.title}\n${detail.body.join('\n')}\n`);
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
const message = formatCliError(failure.error);
|
|
1014
|
+
this.io.error(`FAIL midscene-test: ${failure.source}:\n${message.split('\n').map((line)=>` ${line}`).join('\n')}\n`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
this.log(`\n${this.footer(result.durationMs, result).join('\n')}`);
|
|
1018
|
+
this.log('');
|
|
1019
|
+
this.failures = [];
|
|
1020
|
+
}
|
|
1021
|
+
dispose() {
|
|
1022
|
+
if (this.timer) clearInterval(this.timer);
|
|
1023
|
+
if (this.scheduled) clearTimeout(this.scheduled);
|
|
1024
|
+
this.timer = void 0;
|
|
1025
|
+
this.scheduled = void 0;
|
|
1026
|
+
this.removeResizeListener?.();
|
|
1027
|
+
this.removeResizeListener = void 0;
|
|
1028
|
+
this.progressView.clear();
|
|
1029
|
+
}
|
|
1030
|
+
constructor(io, mode = 'default'){
|
|
1031
|
+
terminal_reporter_define_property(this, "io", void 0);
|
|
1032
|
+
terminal_reporter_define_property(this, "mode", void 0);
|
|
1033
|
+
terminal_reporter_define_property(this, "total", void 0);
|
|
1034
|
+
terminal_reporter_define_property(this, "completionOrder", void 0);
|
|
1035
|
+
terminal_reporter_define_property(this, "totalFiles", void 0);
|
|
1036
|
+
terminal_reporter_define_property(this, "files", void 0);
|
|
1037
|
+
terminal_reporter_define_property(this, "warnings", void 0);
|
|
1038
|
+
terminal_reporter_define_property(this, "latestNotice", void 0);
|
|
1039
|
+
terminal_reporter_define_property(this, "usage", void 0);
|
|
1040
|
+
terminal_reporter_define_property(this, "finalMetrics", void 0);
|
|
1041
|
+
terminal_reporter_define_property(this, "attempts", void 0);
|
|
1042
|
+
terminal_reporter_define_property(this, "active", void 0);
|
|
1043
|
+
terminal_reporter_define_property(this, "projectPhases", void 0);
|
|
1044
|
+
terminal_reporter_define_property(this, "failures", void 0);
|
|
1045
|
+
terminal_reporter_define_property(this, "lastOutput", void 0);
|
|
1046
|
+
terminal_reporter_define_property(this, "startedAt", void 0);
|
|
1047
|
+
terminal_reporter_define_property(this, "timer", void 0);
|
|
1048
|
+
terminal_reporter_define_property(this, "scheduled", void 0);
|
|
1049
|
+
terminal_reporter_define_property(this, "progressView", void 0);
|
|
1050
|
+
terminal_reporter_define_property(this, "removeResizeListener", void 0);
|
|
1051
|
+
terminal_reporter_define_property(this, "color", void 0);
|
|
1052
|
+
this.io = io;
|
|
1053
|
+
this.mode = mode;
|
|
1054
|
+
this.total = 0;
|
|
1055
|
+
this.completionOrder = 0;
|
|
1056
|
+
this.totalFiles = 0;
|
|
1057
|
+
this.files = new Map();
|
|
1058
|
+
this.warnings = new Map();
|
|
1059
|
+
this.usage = new CliModelUsage();
|
|
1060
|
+
this.attempts = new Map();
|
|
1061
|
+
this.active = new Map();
|
|
1062
|
+
this.projectPhases = new Map();
|
|
1063
|
+
this.failures = [];
|
|
1064
|
+
this.lastOutput = Date.now();
|
|
1065
|
+
this.startedAt = Date.now();
|
|
1066
|
+
this.progressView = new TerminalProgress(io);
|
|
1067
|
+
this.color = new external_chalk_["default"].Instance({
|
|
1068
|
+
level: io.isTTY && !process.env.NO_COLOR ? 1 : 0
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
const escapeData = (text)=>text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
1073
|
+
const escapeProperty = (text)=>escapeData(text).replace(/:/g, '%3A').replace(/,/g, '%2C');
|
|
1074
|
+
const markdown = (text)=>text.replace(/[&<>`\r\n]/g, (char)=>({
|
|
1075
|
+
'&': '&',
|
|
1076
|
+
'<': '<',
|
|
1077
|
+
'>': '>',
|
|
1078
|
+
'`': '`',
|
|
1079
|
+
'\r': ' ',
|
|
1080
|
+
'\n': ' '
|
|
1081
|
+
})[char]);
|
|
1082
|
+
function githubFailureAnnotation(source, message, file) {
|
|
1083
|
+
if ('true' !== process.env.GITHUB_ACTIONS) return;
|
|
1084
|
+
const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd();
|
|
1085
|
+
const path = file ? (0, external_node_path_.relative)(workspace, (0, external_node_path_.resolve)(file)).split(external_node_path_.sep).join('/') : void 0;
|
|
1086
|
+
return `::error ${path ? `file=${escapeProperty(path)},` : ''}title=${escapeProperty(source)}::${escapeData(message)}`;
|
|
1087
|
+
}
|
|
1088
|
+
function writeGitHubSummary(result) {
|
|
1089
|
+
const output = process.env.GITHUB_STEP_SUMMARY;
|
|
1090
|
+
if ('true' !== process.env.GITHUB_ACTIONS || !output) return;
|
|
1091
|
+
const failedCases = [
|
|
1092
|
+
...new Map(result.cases.map((item)=>[
|
|
1093
|
+
item.caseId,
|
|
1094
|
+
item
|
|
1095
|
+
])).values()
|
|
1096
|
+
].filter((item)=>'failed' === item.status);
|
|
1097
|
+
const summary = [
|
|
1098
|
+
'## Midscene Test',
|
|
1099
|
+
'',
|
|
1100
|
+
'| Passed | Failed | Not run | Passed after retry | Duration |',
|
|
1101
|
+
'| ---: | ---: | ---: | ---: | ---: |',
|
|
1102
|
+
`| ${result.summary.passed} | ${result.summary.failed} | ${result.summary.notRun} | ${result.summary.passedAfterRetry} | ${formatDuration(result.durationMs)} |`,
|
|
1103
|
+
'',
|
|
1104
|
+
'Passed after retry is included in Passed.',
|
|
1105
|
+
'',
|
|
1106
|
+
...failedCases.map((item)=>`- Failed: **${markdown(item.name)}** — ${markdown(item.projectName)}/\`${markdown(item.sourcePath)}\``),
|
|
1107
|
+
...result.summary.collectionErrors ? [
|
|
1108
|
+
`- Collection errors: ${result.summary.collectionErrors}`
|
|
1109
|
+
] : [],
|
|
1110
|
+
...result.summary.documentFailures ? [
|
|
1111
|
+
`- Failed files: ${result.summary.documentFailures}`
|
|
1112
|
+
] : [],
|
|
1113
|
+
...result.summary.projectFailures ? [
|
|
1114
|
+
`- Failed projects: ${result.summary.projectFailures}`
|
|
1115
|
+
] : [],
|
|
1116
|
+
'',
|
|
1117
|
+
...result.reportPath ? [
|
|
1118
|
+
`Report artifact entry: \`${markdown(result.reportPath)}\``
|
|
1119
|
+
] : [],
|
|
1120
|
+
`Structured results: \`${markdown(result.summaryPath)}\``,
|
|
1121
|
+
''
|
|
1122
|
+
].join('\n');
|
|
1123
|
+
(0, external_node_fs_.appendFileSync)(output, `${summary}\n`);
|
|
1124
|
+
}
|
|
105
1125
|
const GENERATED_COMMENT = '<!-- Generated by `midscene-test nodes`. Do not edit directly. -->';
|
|
106
1126
|
const sortNodesForSpec = (nodes)=>{
|
|
107
1127
|
const priority = (name)=>'aiAct' === name ? 0 : 'aiAssert' === name ? 1 : 2;
|
|
@@ -234,6 +1254,32 @@ const renderNodeSpec = (nodes, location)=>{
|
|
|
234
1254
|
warnings
|
|
235
1255
|
};
|
|
236
1256
|
};
|
|
1257
|
+
function captureTerminalOutput(output, streams = [
|
|
1258
|
+
process.stdout,
|
|
1259
|
+
process.stderr
|
|
1260
|
+
]) {
|
|
1261
|
+
const cleanups = [];
|
|
1262
|
+
for (const stream of streams){
|
|
1263
|
+
const original = stream.write;
|
|
1264
|
+
const forward = original.bind(stream);
|
|
1265
|
+
const intercepted = (chunk, encoding, callback)=>{
|
|
1266
|
+
const done = 'function' == typeof encoding ? encoding : callback;
|
|
1267
|
+
const message = 'string' == typeof chunk ? chunk : Buffer.from(chunk).toString();
|
|
1268
|
+
let accepted = true;
|
|
1269
|
+
output(message, (text)=>{
|
|
1270
|
+
accepted = forward(text, done);
|
|
1271
|
+
});
|
|
1272
|
+
return accepted;
|
|
1273
|
+
};
|
|
1274
|
+
stream.write = intercepted;
|
|
1275
|
+
cleanups.push(()=>{
|
|
1276
|
+
if (stream.write === intercepted) stream.write = original;
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
return ()=>{
|
|
1280
|
+
for (const cleanup of cleanups)cleanup();
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
237
1283
|
const registry_NodeRegistry = NodeRegistry;
|
|
238
1284
|
const projectIdFromIndex = (index)=>`project-${index}`;
|
|
239
1285
|
const getDefaultExport = (loaded, absolutePath)=>{
|
|
@@ -352,9 +1398,19 @@ const validateNonNegativeInteger = (value, fallback, label)=>{
|
|
|
352
1398
|
if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
|
|
353
1399
|
return value;
|
|
354
1400
|
};
|
|
355
|
-
const
|
|
1401
|
+
const validateConcurrency = (value, label)=>{
|
|
1402
|
+
if (null === value) return null;
|
|
1403
|
+
return validatePositiveInteger(value, 1, label);
|
|
1404
|
+
};
|
|
1405
|
+
const validateFileConcurrency = (maxConcurrentFiles, fileSetup, label)=>{
|
|
1406
|
+
if (maxConcurrentFiles > 1 && !fileSetup) throw new TypeError(`Midscene config ${label}.fileSetup is required when the effective maxConcurrentFiles is greater than 1.`);
|
|
1407
|
+
};
|
|
1408
|
+
const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, globalNodes, test)=>{
|
|
356
1409
|
if (void 0 === value) {
|
|
357
1410
|
const setup = validateSetup(defaultSetup, 'setup');
|
|
1411
|
+
const fileSetup = validateSetup(defaultFileSetup, 'fileSetup');
|
|
1412
|
+
const maxConcurrentFiles = test.maxConcurrentFiles ?? 1 / 0;
|
|
1413
|
+
validateFileConcurrency(maxConcurrentFiles, fileSetup, 'default');
|
|
358
1414
|
return {
|
|
359
1415
|
hasExplicitProjects: false,
|
|
360
1416
|
projects: Object.freeze([
|
|
@@ -364,6 +1420,10 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
|
364
1420
|
...setup ? {
|
|
365
1421
|
setup
|
|
366
1422
|
} : {},
|
|
1423
|
+
...fileSetup ? {
|
|
1424
|
+
fileSetup
|
|
1425
|
+
} : {},
|
|
1426
|
+
maxConcurrentFiles,
|
|
367
1427
|
tags: Object.freeze({
|
|
368
1428
|
include: [],
|
|
369
1429
|
exclude: []
|
|
@@ -376,6 +1436,7 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
|
376
1436
|
};
|
|
377
1437
|
}
|
|
378
1438
|
if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
|
|
1439
|
+
if (void 0 !== defaultFileSetup) throw new TypeError('Midscene config fileSetup cannot be used together with projects. Move fileSetup to projects[].fileSetup.');
|
|
379
1440
|
if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
|
|
380
1441
|
const names = new Set();
|
|
381
1442
|
const projects = value.map((candidate, index)=>{
|
|
@@ -384,6 +1445,8 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
|
384
1445
|
rejectUnknownKeys(candidate, [
|
|
385
1446
|
'name',
|
|
386
1447
|
'setup',
|
|
1448
|
+
'fileSetup',
|
|
1449
|
+
'maxConcurrentFiles',
|
|
387
1450
|
'nodes',
|
|
388
1451
|
'files',
|
|
389
1452
|
'tags',
|
|
@@ -400,10 +1463,18 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
|
400
1463
|
...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
|
|
401
1464
|
...localNodes.definitions()
|
|
402
1465
|
]);
|
|
1466
|
+
const fileSetup = validateSetup(candidate.fileSetup, `${label}.fileSetup`);
|
|
1467
|
+
const globalFileLimit = test.maxConcurrentFiles ?? 1 / 0;
|
|
1468
|
+
const maxConcurrentFiles = Math.min(globalFileLimit, validatePositiveInteger(candidate.maxConcurrentFiles, globalFileLimit, `${label}.maxConcurrentFiles`));
|
|
1469
|
+
validateFileConcurrency(maxConcurrentFiles, fileSetup, label);
|
|
403
1470
|
return Object.freeze({
|
|
404
1471
|
projectId: projectIdFromIndex(index),
|
|
405
1472
|
name: candidate.name,
|
|
406
1473
|
nodes,
|
|
1474
|
+
...fileSetup ? {
|
|
1475
|
+
fileSetup
|
|
1476
|
+
} : {},
|
|
1477
|
+
maxConcurrentFiles,
|
|
407
1478
|
...files ? {
|
|
408
1479
|
files
|
|
409
1480
|
} : {},
|
|
@@ -420,11 +1491,25 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
|
420
1491
|
hasExplicitProjects: true
|
|
421
1492
|
};
|
|
422
1493
|
};
|
|
423
|
-
const validateTestOptions = (value)=>{
|
|
1494
|
+
const validateTestOptions = (value, hasExplicitProjects)=>{
|
|
424
1495
|
if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
|
|
425
1496
|
const candidate = value ?? {};
|
|
1497
|
+
if (void 0 !== candidate.maxConcurrency) {
|
|
1498
|
+
const limit = 'number' == typeof candidate.maxConcurrency && Number.isInteger(candidate.maxConcurrency) && candidate.maxConcurrency > 0 ? candidate.maxConcurrency : 1;
|
|
1499
|
+
throw new TypeError([
|
|
1500
|
+
'Midscene config test.maxConcurrency is no longer supported.',
|
|
1501
|
+
'It controlled project concurrency, not file concurrency. Remove it and use:',
|
|
1502
|
+
` test: { maxConcurrentProjects: ${limit}, maxConcurrentFiles: ${hasExplicitProjects ? limit : 1} }`,
|
|
1503
|
+
hasExplicitProjects ? ' Set projects[].maxConcurrentFiles to 1 to keep files sequential within each project.' : ' The implicit default project keeps running files sequentially.',
|
|
1504
|
+
hasExplicitProjects ? 'For parallel files, configure fileSetup and raise both test.maxConcurrentFiles and projects[].maxConcurrentFiles.' : 'For parallel files, configure fileSetup and raise test.maxConcurrentFiles.',
|
|
1505
|
+
'Migration guide: https://midscenejs.com/midscene-test/configuration.html#existing-configurations'
|
|
1506
|
+
].join('\n'));
|
|
1507
|
+
}
|
|
426
1508
|
return Object.freeze({
|
|
427
|
-
|
|
1509
|
+
maxConcurrentFiles: validateConcurrency(candidate.maxConcurrentFiles, 'test.maxConcurrentFiles'),
|
|
1510
|
+
maxConcurrentProjects: validateConcurrency(candidate.maxConcurrentProjects, 'test.maxConcurrentProjects'),
|
|
1511
|
+
setupTimeout: validatePositiveInteger(candidate.setupTimeout, 120000, 'test.setupTimeout'),
|
|
1512
|
+
teardownTimeout: validatePositiveInteger(candidate.teardownTimeout, 30000, 'test.teardownTimeout'),
|
|
428
1513
|
bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
|
|
429
1514
|
testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
|
|
430
1515
|
});
|
|
@@ -454,16 +1539,18 @@ const validateTestProjectDefinition = (definition)=>{
|
|
|
454
1539
|
if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
|
|
455
1540
|
rejectUnknownKeys(definition, [
|
|
456
1541
|
'setup',
|
|
1542
|
+
'fileSetup',
|
|
457
1543
|
'projects',
|
|
458
1544
|
'test',
|
|
459
1545
|
'output',
|
|
460
1546
|
'nodes'
|
|
461
1547
|
], 'root');
|
|
462
1548
|
const nodes = new registry_NodeRegistry(definition.nodes);
|
|
463
|
-
const
|
|
1549
|
+
const test = validateTestOptions(definition.test, void 0 !== definition.projects);
|
|
1550
|
+
const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, definition.fileSetup, nodes, test);
|
|
464
1551
|
return {
|
|
465
1552
|
...resolvedProjects,
|
|
466
|
-
test
|
|
1553
|
+
test,
|
|
467
1554
|
output: validateOutput(definition.output),
|
|
468
1555
|
nodes,
|
|
469
1556
|
resolveNode: (name)=>nodes.get(name)
|
|
@@ -540,11 +1627,13 @@ function parseWorkflowSource(source) {
|
|
|
540
1627
|
start: state.position + sourceOffset,
|
|
541
1628
|
end: 0,
|
|
542
1629
|
value: null,
|
|
543
|
-
children: []
|
|
1630
|
+
children: [],
|
|
1631
|
+
kind: null
|
|
544
1632
|
});
|
|
545
1633
|
const node = stack.pop();
|
|
546
1634
|
node.end = state.position + sourceOffset;
|
|
547
1635
|
node.value = state.result;
|
|
1636
|
+
node.kind = state.kind;
|
|
548
1637
|
stack.at(-1)?.children.push(node);
|
|
549
1638
|
nodes.push(node);
|
|
550
1639
|
if ('mapping' !== state.kind) return;
|
|
@@ -574,6 +1663,36 @@ function parseWorkflowSource(source) {
|
|
|
574
1663
|
const value = sanitizeReportText(redacted, 65536);
|
|
575
1664
|
return {
|
|
576
1665
|
parsed,
|
|
1666
|
+
locate (path) {
|
|
1667
|
+
let node = nodes.at(-1);
|
|
1668
|
+
for (const part of path){
|
|
1669
|
+
if (!node) return;
|
|
1670
|
+
while(1 === node.children.length && node.children[0].value === node.value)node = node.children[0];
|
|
1671
|
+
if ('number' == typeof part && 'sequence' === node.kind) node = node.children[part];
|
|
1672
|
+
else {
|
|
1673
|
+
if ('string' != typeof part || 'mapping' !== node.kind) return;
|
|
1674
|
+
const children = node.children;
|
|
1675
|
+
node = void 0;
|
|
1676
|
+
for(let index = 0; index < children.length; index += 2)if (children[index].value === part) {
|
|
1677
|
+
node = children[index + 1];
|
|
1678
|
+
break;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
if (!node) return;
|
|
1683
|
+
let start = node.start;
|
|
1684
|
+
while(start < node.end)if (/\s/.test(source[start])) start++;
|
|
1685
|
+
else if ('#' === source[start]) {
|
|
1686
|
+
const end = source.indexOf('\n', start);
|
|
1687
|
+
if (end < 0) return;
|
|
1688
|
+
start = end + 1;
|
|
1689
|
+
} else break;
|
|
1690
|
+
const prefix = source.slice(0, start).split(/\r\n|\r|\n/);
|
|
1691
|
+
return {
|
|
1692
|
+
line: prefix.length,
|
|
1693
|
+
column: prefix.at(-1).length + 1
|
|
1694
|
+
};
|
|
1695
|
+
},
|
|
577
1696
|
sourceYaml: {
|
|
578
1697
|
value,
|
|
579
1698
|
...redactedPaths.size ? {
|
|
@@ -632,7 +1751,7 @@ const resolveWorkflowVariables = (value, options)=>{
|
|
|
632
1751
|
]));
|
|
633
1752
|
return value;
|
|
634
1753
|
};
|
|
635
|
-
function
|
|
1754
|
+
function collect_define_property(obj, key, value) {
|
|
636
1755
|
if (key in obj) Object.defineProperty(obj, key, {
|
|
637
1756
|
value: value,
|
|
638
1757
|
enumerable: true,
|
|
@@ -649,7 +1768,7 @@ class NativeWorkflowFormatError extends WorkflowParseError {
|
|
|
649
1768
|
sourcePath,
|
|
650
1769
|
actualFormat,
|
|
651
1770
|
expectedFormat: 'native'
|
|
652
|
-
}),
|
|
1771
|
+
}), collect_define_property(this, "actualFormat", void 0);
|
|
653
1772
|
this.actualFormat = actualFormat;
|
|
654
1773
|
}
|
|
655
1774
|
}
|
|
@@ -686,6 +1805,18 @@ function collectWorkflowDocument(source, options) {
|
|
|
686
1805
|
const sourcePath = source.sourcePath.split(external_node_path_.sep).join('/');
|
|
687
1806
|
const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
|
|
688
1807
|
...step,
|
|
1808
|
+
meta: {
|
|
1809
|
+
...step.meta,
|
|
1810
|
+
sourceLocation: snapshot.locate('steps' === phase ? [
|
|
1811
|
+
'cases',
|
|
1812
|
+
caseIndex,
|
|
1813
|
+
'steps',
|
|
1814
|
+
stepIndex
|
|
1815
|
+
] : [
|
|
1816
|
+
phase,
|
|
1817
|
+
stepIndex
|
|
1818
|
+
])
|
|
1819
|
+
},
|
|
689
1820
|
templateInput: step.input,
|
|
690
1821
|
input: resolveWorkflowVariables(step.input, {
|
|
691
1822
|
variables: options.variables,
|
|
@@ -860,6 +1991,9 @@ const writeTestProjectRunResult = (options)=>{
|
|
|
860
1991
|
markdown: relativeToSummary(result.summaryPath, result.markdownPath)
|
|
861
1992
|
} : {},
|
|
862
1993
|
summary: result.summary,
|
|
1994
|
+
...result.metrics ? {
|
|
1995
|
+
metrics: result.metrics
|
|
1996
|
+
} : {},
|
|
863
1997
|
projects: result.projects.map((project)=>({
|
|
864
1998
|
projectId: project.projectId,
|
|
865
1999
|
name: project.name,
|
|
@@ -1137,7 +2271,24 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
1137
2271
|
defaultTimeoutMs,
|
|
1138
2272
|
reportEnabled: true,
|
|
1139
2273
|
bindings: {
|
|
1140
|
-
resolveNode: project.nodes.require.bind(project.nodes)
|
|
2274
|
+
resolveNode: project.nodes.require.bind(project.nodes),
|
|
2275
|
+
...project.fileSetup ? {
|
|
2276
|
+
documentSetup: {
|
|
2277
|
+
name: project.fileSetup.name,
|
|
2278
|
+
setup ({ projectContext, document, signal, onTeardown }) {
|
|
2279
|
+
return project.fileSetup.setup({
|
|
2280
|
+
project,
|
|
2281
|
+
projectContext,
|
|
2282
|
+
file: document,
|
|
2283
|
+
env: Object.freeze({
|
|
2284
|
+
...process.env
|
|
2285
|
+
}),
|
|
2286
|
+
signal,
|
|
2287
|
+
onTeardown
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
} : {}
|
|
1141
2292
|
}
|
|
1142
2293
|
});
|
|
1143
2294
|
} catch (error) {
|
|
@@ -1239,11 +2390,12 @@ async function prepareTestRun(options) {
|
|
|
1239
2390
|
preflightScope: hasFormatMismatch ? 'run' : 'project',
|
|
1240
2391
|
reportEnabled: true,
|
|
1241
2392
|
publications: [],
|
|
1242
|
-
onProgress: options.onProgress ?? (()=>{})
|
|
2393
|
+
onProgress: options.onProgress ?? (()=>{}),
|
|
2394
|
+
onEvent: options.onEvent
|
|
1243
2395
|
};
|
|
1244
2396
|
}
|
|
1245
2397
|
const result_markdown_text = (value)=>value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll('|', '|').replaceAll('[', '[').replaceAll(']', ']').replaceAll('*', '*').replaceAll('_', '_').replaceAll('`', '`').replaceAll('\\', '\').replace(/[\r\n]+/g, ' ');
|
|
1246
|
-
const
|
|
2398
|
+
const result_markdown_duration = (ms)=>{
|
|
1247
2399
|
const seconds = Math.round(ms / 1000);
|
|
1248
2400
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
1249
2401
|
};
|
|
@@ -1254,7 +2406,7 @@ function renderTestResultMarkdown(dump, options = {}) {
|
|
|
1254
2406
|
const lines = [
|
|
1255
2407
|
'## Midscene Test',
|
|
1256
2408
|
'',
|
|
1257
|
-
`${outcome} · ${
|
|
2409
|
+
`${outcome} · ${result_markdown_duration(dump.durationMs)}`,
|
|
1258
2410
|
''
|
|
1259
2411
|
];
|
|
1260
2412
|
if (summary.filtered) lines.push(`**Filtered:** ${summary.filtered}`, '');
|
|
@@ -1317,6 +2469,38 @@ async function writeTestResultMarkdown(result, index = emptyIndex()) {
|
|
|
1317
2469
|
}));
|
|
1318
2470
|
return output;
|
|
1319
2471
|
}
|
|
2472
|
+
function scopeNodeConsole(node) {
|
|
2473
|
+
return {
|
|
2474
|
+
...node,
|
|
2475
|
+
execute (ctx) {
|
|
2476
|
+
const position = 'case' === ctx.scope ? ctx.case : ctx.document;
|
|
2477
|
+
const scope = {
|
|
2478
|
+
projectName: position.projectName,
|
|
2479
|
+
sourcePath: position.sourcePath,
|
|
2480
|
+
...'case' === ctx.scope ? {
|
|
2481
|
+
caseId: ctx.case.caseId,
|
|
2482
|
+
caseName: ctx.case.name,
|
|
2483
|
+
runId: ctx.case.runId
|
|
2484
|
+
} : {
|
|
2485
|
+
runId: ctx.document.documentRunId
|
|
2486
|
+
},
|
|
2487
|
+
phase: position.phase,
|
|
2488
|
+
stepIndex: position.stepIndex,
|
|
2489
|
+
node: node.name
|
|
2490
|
+
};
|
|
2491
|
+
return runWithConsoleLogScope(scope, ()=>node.execute({
|
|
2492
|
+
...ctx,
|
|
2493
|
+
onTeardown (teardown) {
|
|
2494
|
+
ctx.onTeardown(()=>runWithConsoleLogScope({
|
|
2495
|
+
...scope,
|
|
2496
|
+
phase: 'teardown',
|
|
2497
|
+
stepIndex: void 0
|
|
2498
|
+
}, teardown));
|
|
2499
|
+
}
|
|
2500
|
+
}));
|
|
2501
|
+
}
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
1320
2504
|
const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
|
|
1321
2505
|
const formatStep = (info)=>{
|
|
1322
2506
|
const position = stepPosition(info);
|
|
@@ -1340,59 +2524,149 @@ const documentHasFatalError = (result)=>[
|
|
|
1340
2524
|
...result.beforeAll,
|
|
1341
2525
|
...result.afterAll
|
|
1342
2526
|
].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError) || !!result.executionErrors?.some(isResourceRecoveryError);
|
|
2527
|
+
const isSharedResourceFailure = (error)=>{
|
|
2528
|
+
const seen = new Set();
|
|
2529
|
+
const pending = [
|
|
2530
|
+
error
|
|
2531
|
+
];
|
|
2532
|
+
while(pending.length && seen.size < 50){
|
|
2533
|
+
const item = pending.pop();
|
|
2534
|
+
if ('object' != typeof item || null === item || seen.has(item)) continue;
|
|
2535
|
+
seen.add(item);
|
|
2536
|
+
const failure = item;
|
|
2537
|
+
if ('PROJECT_RESOURCE_ERROR' === failure.code) return true;
|
|
2538
|
+
if (failure.cause) pending.push(failure.cause);
|
|
2539
|
+
if (Array.isArray(failure.errors)) pending.push(...failure.errors.slice(0, 50));
|
|
2540
|
+
}
|
|
2541
|
+
return false;
|
|
2542
|
+
};
|
|
1343
2543
|
async function executeDocumentInvocation(options) {
|
|
1344
2544
|
const { invocation, project, sinks } = options;
|
|
2545
|
+
const isProjectResourceFailure = options.isProjectResourceFailure ?? (()=>false);
|
|
1345
2546
|
const { document } = invocation;
|
|
1346
2547
|
const documentRetry = 'document' === invocation.retry.scope;
|
|
1347
2548
|
const attempts = [];
|
|
2549
|
+
const fileSetup = invocation.bindings.documentSetup;
|
|
2550
|
+
const documentSetup = fileSetup ? {
|
|
2551
|
+
...fileSetup,
|
|
2552
|
+
setup (ctx) {
|
|
2553
|
+
const scope = {
|
|
2554
|
+
projectName: project.name,
|
|
2555
|
+
sourcePath: document.sourcePath,
|
|
2556
|
+
runId: ctx.document.documentRunId,
|
|
2557
|
+
phase: 'fileSetup'
|
|
2558
|
+
};
|
|
2559
|
+
return runWithConsoleLogScope(scope, async ()=>{
|
|
2560
|
+
try {
|
|
2561
|
+
return await fileSetup.setup({
|
|
2562
|
+
...ctx,
|
|
2563
|
+
onTeardown (teardown) {
|
|
2564
|
+
ctx.onTeardown(()=>runWithConsoleLogScope({
|
|
2565
|
+
...scope,
|
|
2566
|
+
phase: 'teardown'
|
|
2567
|
+
}, teardown));
|
|
2568
|
+
}
|
|
2569
|
+
});
|
|
2570
|
+
} catch (error) {
|
|
2571
|
+
if (isProjectResourceFailure(error)) options.markProjectFatal();
|
|
2572
|
+
throw error;
|
|
2573
|
+
}
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
} : void 0;
|
|
2577
|
+
options.onEvent?.({
|
|
2578
|
+
type: 'document-start',
|
|
2579
|
+
documentId: document.documentId,
|
|
2580
|
+
projectName: project.name,
|
|
2581
|
+
sourcePath: document.sourcePath,
|
|
2582
|
+
cases: document.cases.length
|
|
2583
|
+
});
|
|
1348
2584
|
await runDocumentAttempts({
|
|
1349
2585
|
retry: documentRetry ? invocation.retry.count : 0,
|
|
1350
2586
|
signal: options.signal,
|
|
1351
2587
|
shouldStop: ()=>options.shouldBail() || options.isProjectFatal()
|
|
1352
2588
|
}, async (documentAttemptIndex)=>{
|
|
1353
2589
|
if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
|
|
1354
|
-
const execution = await
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
options.
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
2590
|
+
const execution = await runWithConsoleLogScope({
|
|
2591
|
+
projectName: project.name,
|
|
2592
|
+
sourcePath: document.sourcePath
|
|
2593
|
+
}, ()=>runWorkflowDocument(document, {
|
|
2594
|
+
plan: invocation.plan,
|
|
2595
|
+
documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
|
|
2596
|
+
...invocation.bindings,
|
|
2597
|
+
resolveNode: (name)=>scopeNodeConsole(invocation.bindings.resolveNode(name)),
|
|
2598
|
+
documentSetup,
|
|
2599
|
+
setupTimeoutMs: options.setupTimeoutMs,
|
|
2600
|
+
teardownTimeoutMs: options.teardownTimeoutMs,
|
|
2601
|
+
project,
|
|
2602
|
+
projectContext: options.projectContext,
|
|
2603
|
+
retry: documentRetry ? 0 : invocation.retry.count,
|
|
2604
|
+
signal: options.signal,
|
|
2605
|
+
defaultTimeoutMs: invocation.defaultTimeoutMs,
|
|
2606
|
+
shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
|
|
2607
|
+
stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
|
|
2608
|
+
isFatalError: (run)=>[
|
|
2609
|
+
...run.beforeEach,
|
|
2610
|
+
...run.steps,
|
|
2611
|
+
...run.afterEach
|
|
2612
|
+
].some((step)=>step.error && (isFatalDeviceError(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>isFatalDeviceError(error) || isProjectResourceFailure(error)),
|
|
2613
|
+
onCaseStart: (collectedCase)=>{
|
|
2614
|
+
options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
|
|
2615
|
+
},
|
|
2616
|
+
onStepStart: (info)=>{
|
|
2617
|
+
options.onEvent?.({
|
|
2618
|
+
type: 'step-start',
|
|
2619
|
+
projectName: project.name,
|
|
2620
|
+
info
|
|
2621
|
+
});
|
|
2622
|
+
const indent = 'case' === info.scope ? ' ' : ' ';
|
|
2623
|
+
options.onProgress(`${indent}→ ${formatStep(info)}`);
|
|
2624
|
+
},
|
|
2625
|
+
onStepResult: async (info, result)=>{
|
|
2626
|
+
if (isProjectResourceFailure(result.error)) options.markProjectFatal();
|
|
2627
|
+
await invocation.bindings.onStepResult?.(info, result);
|
|
2628
|
+
options.onProgress(formatStepResult(info, result));
|
|
2629
|
+
options.onEvent?.({
|
|
2630
|
+
type: 'step-end',
|
|
2631
|
+
projectName: project.name,
|
|
2632
|
+
info,
|
|
2633
|
+
result
|
|
2634
|
+
});
|
|
2635
|
+
},
|
|
2636
|
+
onCaseResult: async (attempt)=>{
|
|
2637
|
+
await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
|
|
2638
|
+
options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} case attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
|
|
2639
|
+
if (!documentRetry) options.onEvent?.({
|
|
2640
|
+
type: 'case-attempt-end',
|
|
2641
|
+
attempt,
|
|
2642
|
+
maxAttempts: invocation.retry.count + 1
|
|
2643
|
+
});
|
|
2644
|
+
},
|
|
2645
|
+
onCaseOutcome: (outcome)=>{
|
|
2646
|
+
if (documentRetry) options.onEvent?.({
|
|
2647
|
+
type: 'case-progress',
|
|
2648
|
+
outcome,
|
|
2649
|
+
documentAttemptIndex
|
|
2650
|
+
});
|
|
2651
|
+
else options.onEvent?.({
|
|
2652
|
+
type: 'case-end',
|
|
2653
|
+
outcome
|
|
2654
|
+
});
|
|
2655
|
+
if (outcome.attempts?.some((attempt)=>[
|
|
2656
|
+
...attempt.beforeEach,
|
|
2657
|
+
...attempt.steps,
|
|
2658
|
+
...attempt.afterEach
|
|
2659
|
+
].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
|
|
2660
|
+
if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
|
|
2661
|
+
if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
|
|
2662
|
+
},
|
|
2663
|
+
onDocumentResult: options.onDocumentResult
|
|
2664
|
+
})).catch(async (error)=>{
|
|
1391
2665
|
if (error instanceof WorkflowExecutionFailure) {
|
|
1392
2666
|
const partial = error.result;
|
|
1393
2667
|
const resourceOnly = error.errors.length > 0 && error.errors.every((failure)=>isResourceRecoveryError(failure) || partial.document.hostErrors?.some((host)=>'cleanup' === host.phase && (host.error === failure || host.error instanceof Error && host.error.cause === failure)));
|
|
1394
2668
|
if (resourceOnly) {
|
|
1395
|
-
if (error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
|
|
2669
|
+
if (!options.isolatedContext && error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
|
|
1396
2670
|
return partial;
|
|
1397
2671
|
}
|
|
1398
2672
|
if (documentRetry) partial.document.retryDecision = decideRetry({
|
|
@@ -1421,7 +2695,17 @@ async function executeDocumentInvocation(options) {
|
|
|
1421
2695
|
}
|
|
1422
2696
|
throw error;
|
|
1423
2697
|
});
|
|
1424
|
-
if (documentHasFatalError(execution.document)) options.markProjectFatal();
|
|
2698
|
+
if (!options.isolatedContext && documentHasFatalError(execution.document)) options.markProjectFatal();
|
|
2699
|
+
if ([
|
|
2700
|
+
...execution.document.beforeAll,
|
|
2701
|
+
...execution.document.afterAll,
|
|
2702
|
+
...(execution.document.hostErrors ?? []).map(({ error })=>({
|
|
2703
|
+
error
|
|
2704
|
+
})),
|
|
2705
|
+
...(execution.document.teardownErrors ?? []).map((error)=>({
|
|
2706
|
+
error
|
|
2707
|
+
}))
|
|
2708
|
+
].some(({ error })=>isProjectResourceFailure(error))) options.markProjectFatal();
|
|
1425
2709
|
const owners = [
|
|
1426
2710
|
execution.document,
|
|
1427
2711
|
...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
|
|
@@ -1429,7 +2713,10 @@ async function executeDocumentInvocation(options) {
|
|
|
1429
2713
|
const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
|
|
1430
2714
|
const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
|
|
1431
2715
|
const cleanupFailed = owners.some((owner)=>owner.cleanup?.status === 'failed' || !!owner.teardownErrors?.length || 'hostErrors' in owner && owner.hostErrors?.some(({ phase })=>'cleanup' === phase));
|
|
1432
|
-
if (cleanupPending ||
|
|
2716
|
+
if (cleanupPending || operationPending) {
|
|
2717
|
+
options.markProjectFatal();
|
|
2718
|
+
options.onUnresolvedResources?.();
|
|
2719
|
+
} else if (cleanupFailed && !options.isolatedContext) options.markProjectFatal();
|
|
1433
2720
|
const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
|
|
1434
2721
|
const decision = decideRetry({
|
|
1435
2722
|
scope: 'document',
|
|
@@ -1444,6 +2731,11 @@ async function executeDocumentInvocation(options) {
|
|
|
1444
2731
|
shouldStop: options.shouldBail()
|
|
1445
2732
|
});
|
|
1446
2733
|
if (documentRetry) execution.document.retryDecision = decision;
|
|
2734
|
+
if (documentRetry && 'retry' === decision.decision) options.onEvent?.({
|
|
2735
|
+
type: 'document-retry',
|
|
2736
|
+
document: execution.document,
|
|
2737
|
+
maxAttempts: invocation.retry.count + 1
|
|
2738
|
+
});
|
|
1447
2739
|
attempts.push(execution);
|
|
1448
2740
|
sinks.cases.push(...execution.cases.map((outcome)=>({
|
|
1449
2741
|
...outcome,
|
|
@@ -1478,10 +2770,280 @@ async function executeDocumentInvocation(options) {
|
|
|
1478
2770
|
});
|
|
1479
2771
|
const final = attempts.at(-1);
|
|
1480
2772
|
if (!final) return;
|
|
2773
|
+
if (documentRetry) for (const outcome of final.cases)options.onEvent?.({
|
|
2774
|
+
type: 'case-end',
|
|
2775
|
+
outcome,
|
|
2776
|
+
documentRetries: attempts.length - 1
|
|
2777
|
+
});
|
|
1481
2778
|
const failedCases = final.cases.filter((item)=>'failed' === item.status).length;
|
|
2779
|
+
options.onEvent?.({
|
|
2780
|
+
type: 'document-end',
|
|
2781
|
+
document: final.document,
|
|
2782
|
+
cases: final.cases,
|
|
2783
|
+
durationMs: attempts.reduce((total, attempt)=>total + attempt.document.durationMs, 0)
|
|
2784
|
+
});
|
|
1482
2785
|
if (documentRetry && failedCases) options.addFailedCases(failedCases);
|
|
1483
2786
|
else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
|
|
1484
2787
|
}
|
|
2788
|
+
async function runPreparedFiles(options) {
|
|
2789
|
+
const states = options.projects.map((prepared)=>({
|
|
2790
|
+
prepared,
|
|
2791
|
+
cases: [],
|
|
2792
|
+
documents: [],
|
|
2793
|
+
cursor: 0,
|
|
2794
|
+
active: 0,
|
|
2795
|
+
initializing: false,
|
|
2796
|
+
finished: false,
|
|
2797
|
+
fatal: false,
|
|
2798
|
+
setupFailed: false
|
|
2799
|
+
}));
|
|
2800
|
+
let nextProject = 0;
|
|
2801
|
+
let resourcesUnresolved = false;
|
|
2802
|
+
let occupied = 0;
|
|
2803
|
+
let occupiedProjects = 0;
|
|
2804
|
+
const cleanupQueue = [];
|
|
2805
|
+
const release = ()=>{
|
|
2806
|
+
occupied--;
|
|
2807
|
+
while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
|
|
2808
|
+
occupied++;
|
|
2809
|
+
cleanupQueue.shift()();
|
|
2810
|
+
}
|
|
2811
|
+
wake?.();
|
|
2812
|
+
};
|
|
2813
|
+
let wake;
|
|
2814
|
+
const running = new Set();
|
|
2815
|
+
const stopped = (state)=>options.signal.aborted || options.shouldBail() || state.fatal;
|
|
2816
|
+
const invalid = (state)=>options.preflightFailed || state.prepared.collectionErrors.length > 0;
|
|
2817
|
+
const unresolved = ()=>{
|
|
2818
|
+
if (resourcesUnresolved) return;
|
|
2819
|
+
resourcesUnresolved = true;
|
|
2820
|
+
options.onInfrastructureError(new WorkflowError('Resources are still running or cleanup is pending. No new test files will start.', {
|
|
2821
|
+
code: 'UNRESOLVED_FILE_RESOURCES'
|
|
2822
|
+
}));
|
|
2823
|
+
};
|
|
2824
|
+
const finalizeProject = (state)=>{
|
|
2825
|
+
const { prepared } = state;
|
|
2826
|
+
const completed = new Set(state.cases.map((item)=>item.caseId));
|
|
2827
|
+
const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
|
|
2828
|
+
for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
|
|
2829
|
+
documentId: document.documentId,
|
|
2830
|
+
caseId: item.caseId,
|
|
2831
|
+
projectName: prepared.project.name,
|
|
2832
|
+
name: item.definition.name,
|
|
2833
|
+
sourcePath: item.sourcePath,
|
|
2834
|
+
caseIndex: item.caseIndex,
|
|
2835
|
+
status: 'not-run',
|
|
2836
|
+
notRunReason: reason
|
|
2837
|
+
});
|
|
2838
|
+
const order = new Map(prepared.documents.map((document, i)=>[
|
|
2839
|
+
document.documentId,
|
|
2840
|
+
i
|
|
2841
|
+
]));
|
|
2842
|
+
const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
|
|
2843
|
+
state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
|
|
2844
|
+
state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
|
|
2845
|
+
};
|
|
2846
|
+
const finishProject = async (state)=>{
|
|
2847
|
+
if (!state.runtime || state.finished) return;
|
|
2848
|
+
state.finished = true;
|
|
2849
|
+
try {
|
|
2850
|
+
options.onEvent?.({
|
|
2851
|
+
type: 'project-phase',
|
|
2852
|
+
projectName: state.prepared.project.name,
|
|
2853
|
+
phase: 'teardown'
|
|
2854
|
+
});
|
|
2855
|
+
} catch (error) {
|
|
2856
|
+
options.onInfrastructureError(error);
|
|
2857
|
+
}
|
|
2858
|
+
const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
|
|
2859
|
+
state.lifecycle = await runWithConsoleLogScope({
|
|
2860
|
+
projectName: state.prepared.project.name,
|
|
2861
|
+
phase: 'teardown'
|
|
2862
|
+
}, ()=>state.runtime.finish(failed ? 'failed' : 'success'));
|
|
2863
|
+
const releaseProject = ()=>{
|
|
2864
|
+
occupiedProjects--;
|
|
2865
|
+
wake?.();
|
|
2866
|
+
};
|
|
2867
|
+
if (state.lifecycle.cleanup?.status === 'pending' || state.lifecycle.pendingOperations?.length) {
|
|
2868
|
+
unresolved();
|
|
2869
|
+
Promise.all([
|
|
2870
|
+
waitForScopeOperations(state.runtime.signal),
|
|
2871
|
+
getResourceCleanupCompletion(state.runtime.signal)
|
|
2872
|
+
]).then(releaseProject, (error)=>{
|
|
2873
|
+
options.onInfrastructureError(error);
|
|
2874
|
+
releaseProject();
|
|
2875
|
+
});
|
|
2876
|
+
} else {
|
|
2877
|
+
releaseProject();
|
|
2878
|
+
finalizeProject(state);
|
|
2879
|
+
options.onProjectEnd?.(state);
|
|
2880
|
+
}
|
|
2881
|
+
};
|
|
2882
|
+
const admit = ()=>{
|
|
2883
|
+
for(let offset = 0; offset < states.length; offset++){
|
|
2884
|
+
const index = (nextProject + offset) % states.length;
|
|
2885
|
+
const state = states[index];
|
|
2886
|
+
if (invalid(state) || state.finished || state.initializing) continue;
|
|
2887
|
+
if (!state.runtime && occupiedProjects >= options.maxConcurrentProjects) continue;
|
|
2888
|
+
const cleanupOnly = !!state.runtime && 0 === state.active && (stopped(state) || state.cursor === state.prepared.invocations.length);
|
|
2889
|
+
if (!cleanupOnly && (stopped(state) || state.cursor >= state.prepared.invocations.length || state.active >= (state.prepared.project.maxConcurrentFiles ?? options.maxConcurrentFiles))) continue;
|
|
2890
|
+
nextProject = (index + 1) % states.length;
|
|
2891
|
+
occupied++;
|
|
2892
|
+
state.active++;
|
|
2893
|
+
if (!state.runtime && !cleanupOnly) {
|
|
2894
|
+
occupiedProjects++;
|
|
2895
|
+
state.initializing = true;
|
|
2896
|
+
}
|
|
2897
|
+
const invocationIndex = cleanupOnly ? -1 : state.cursor++;
|
|
2898
|
+
return {
|
|
2899
|
+
state,
|
|
2900
|
+
index,
|
|
2901
|
+
invocationIndex
|
|
2902
|
+
};
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2905
|
+
const run = async ({ state, index, invocationIndex })=>{
|
|
2906
|
+
const { prepared } = state;
|
|
2907
|
+
const { project } = prepared;
|
|
2908
|
+
let fileResourcesPending = false;
|
|
2909
|
+
try {
|
|
2910
|
+
if (invocationIndex < 0) return void await finishProject(state);
|
|
2911
|
+
if (!state.runtime) {
|
|
2912
|
+
state.runtime = createProjectRuntime({
|
|
2913
|
+
project,
|
|
2914
|
+
setup: project.setup,
|
|
2915
|
+
signal: options.signal,
|
|
2916
|
+
setupTimeoutMs: options.setupTimeoutMs,
|
|
2917
|
+
teardownTimeoutMs: options.teardownTimeoutMs
|
|
2918
|
+
});
|
|
2919
|
+
setMaxListeners(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
|
|
2920
|
+
options.announceProject(prepared, index);
|
|
2921
|
+
options.onEvent?.({
|
|
2922
|
+
type: 'project-phase',
|
|
2923
|
+
projectName: project.name,
|
|
2924
|
+
phase: 'setup'
|
|
2925
|
+
});
|
|
2926
|
+
await runWithConsoleLogScope({
|
|
2927
|
+
projectName: project.name,
|
|
2928
|
+
phase: 'setup'
|
|
2929
|
+
}, ()=>state.runtime.start());
|
|
2930
|
+
options.onEvent?.({
|
|
2931
|
+
type: 'project-phase',
|
|
2932
|
+
projectName: project.name,
|
|
2933
|
+
phase: 'running'
|
|
2934
|
+
});
|
|
2935
|
+
state.initializing = false;
|
|
2936
|
+
if (!state.runtime.canRun) {
|
|
2937
|
+
state.fatal = true;
|
|
2938
|
+
state.setupFailed = true;
|
|
2939
|
+
}
|
|
2940
|
+
wake?.();
|
|
2941
|
+
}
|
|
2942
|
+
if (stopped(state)) return;
|
|
2943
|
+
const invocation = prepared.invocations[invocationIndex];
|
|
2944
|
+
options.onProgress(prepared, ` [document ${invocationIndex + 1}/${prepared.invocations.length}] ${invocation.document.sourcePath}`);
|
|
2945
|
+
await executeDocumentInvocation({
|
|
2946
|
+
invocation,
|
|
2947
|
+
project,
|
|
2948
|
+
projectContext: state.runtime.context,
|
|
2949
|
+
runDir: options.runDir,
|
|
2950
|
+
signal: state.runtime.signal,
|
|
2951
|
+
rootSignal: options.signal,
|
|
2952
|
+
shouldBail: options.shouldBail,
|
|
2953
|
+
isProjectFatal: ()=>state.fatal,
|
|
2954
|
+
markProjectFatal: ()=>{
|
|
2955
|
+
if (state.fatal) return;
|
|
2956
|
+
state.fatal = true;
|
|
2957
|
+
state.runtime?.abort(new WorkflowError(`Project "${project.name}" shared execution environment is unavailable.`, {
|
|
2958
|
+
code: 'PROJECT_RESOURCE_ERROR'
|
|
2959
|
+
}));
|
|
2960
|
+
},
|
|
2961
|
+
addFailedCases: options.addFailedCases,
|
|
2962
|
+
onProgress: (message)=>options.onProgress(prepared, options.maxConcurrentFiles > 1 ? ` [${invocation.document.sourcePath}]${message}` : message),
|
|
2963
|
+
sinks: state,
|
|
2964
|
+
onEvent: options.onEvent,
|
|
2965
|
+
isolatedContext: void 0 !== project.fileSetup,
|
|
2966
|
+
isProjectResourceFailure: isSharedResourceFailure,
|
|
2967
|
+
setupTimeoutMs: options.setupTimeoutMs,
|
|
2968
|
+
teardownTimeoutMs: options.teardownTimeoutMs,
|
|
2969
|
+
onUnresolvedResources: ()=>{
|
|
2970
|
+
fileResourcesPending = true;
|
|
2971
|
+
unresolved();
|
|
2972
|
+
}
|
|
2973
|
+
});
|
|
2974
|
+
} catch (error) {
|
|
2975
|
+
state.fatal = true;
|
|
2976
|
+
fileResourcesPending = !!state.runtime && getPendingScopeOperations(state.runtime.signal).length > 0;
|
|
2977
|
+
options.onInfrastructureError(error);
|
|
2978
|
+
} finally{
|
|
2979
|
+
state.initializing = false;
|
|
2980
|
+
try {
|
|
2981
|
+
if (1 === state.active && (stopped(state) || state.cursor === prepared.invocations.length)) await finishProject(state);
|
|
2982
|
+
} catch (error) {
|
|
2983
|
+
options.onInfrastructureError(error);
|
|
2984
|
+
}
|
|
2985
|
+
state.active--;
|
|
2986
|
+
if (state.runtime && (fileResourcesPending || state.lifecycle?.cleanup?.status === 'pending')) waitForScopeOperations(state.runtime.signal).then(release, (error)=>{
|
|
2987
|
+
options.onInfrastructureError(error);
|
|
2988
|
+
release();
|
|
2989
|
+
});
|
|
2990
|
+
else release();
|
|
2991
|
+
wake?.();
|
|
2992
|
+
}
|
|
2993
|
+
};
|
|
2994
|
+
while(true){
|
|
2995
|
+
const changed = new Promise((resolve)=>{
|
|
2996
|
+
wake = resolve;
|
|
2997
|
+
});
|
|
2998
|
+
while(occupied < options.maxConcurrentFiles){
|
|
2999
|
+
const job = admit();
|
|
3000
|
+
if (!job) break;
|
|
3001
|
+
const task = run(job);
|
|
3002
|
+
running.add(task);
|
|
3003
|
+
const settled = ()=>{
|
|
3004
|
+
running.delete(task);
|
|
3005
|
+
wake?.();
|
|
3006
|
+
};
|
|
3007
|
+
task.then(settled, (error)=>{
|
|
3008
|
+
options.onInfrastructureError(error);
|
|
3009
|
+
settled();
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
if (!running.size) break;
|
|
3013
|
+
await changed;
|
|
3014
|
+
}
|
|
3015
|
+
wake = void 0;
|
|
3016
|
+
await Promise.all(states.map(async (state)=>{
|
|
3017
|
+
if (!state.runtime || state.finished) return;
|
|
3018
|
+
const admission = new Promise((resolve)=>{
|
|
3019
|
+
cleanupQueue.push(resolve);
|
|
3020
|
+
});
|
|
3021
|
+
trackResourceOperation({}, admission, state.runtime.signal);
|
|
3022
|
+
while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
|
|
3023
|
+
occupied++;
|
|
3024
|
+
cleanupQueue.shift()();
|
|
3025
|
+
}
|
|
3026
|
+
try {
|
|
3027
|
+
await finishProject(state);
|
|
3028
|
+
} catch (error) {
|
|
3029
|
+
options.onInfrastructureError(error);
|
|
3030
|
+
} finally{
|
|
3031
|
+
Promise.all([
|
|
3032
|
+
admission,
|
|
3033
|
+
getResourceCleanupCompletion(state.runtime.signal)
|
|
3034
|
+
]).then(release, (error)=>{
|
|
3035
|
+
options.onInfrastructureError(error);
|
|
3036
|
+
release();
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
3039
|
+
}));
|
|
3040
|
+
for (const state of states){
|
|
3041
|
+
const { prepared } = state;
|
|
3042
|
+
if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
|
|
3043
|
+
finalizeProject(state);
|
|
3044
|
+
}
|
|
3045
|
+
return states;
|
|
3046
|
+
}
|
|
1485
3047
|
function createOperationJournal(runDir) {
|
|
1486
3048
|
const path = (0, external_node_path_.join)(runDir, 'operations.jsonl');
|
|
1487
3049
|
(0, external_node_fs_.writeFileSync)(path, '', {
|
|
@@ -1550,8 +3112,34 @@ async function runPreparedTestProject(plan) {
|
|
|
1550
3112
|
const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
|
|
1551
3113
|
const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
|
|
1552
3114
|
const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
|
|
3115
|
+
plan.onEvent?.({
|
|
3116
|
+
type: 'run-start',
|
|
3117
|
+
configuredModels: configuredModelNames(),
|
|
3118
|
+
projects: preparedProjects.length,
|
|
3119
|
+
documents: totalDocuments + totalErrors,
|
|
3120
|
+
cases: totalCases,
|
|
3121
|
+
projectRoot,
|
|
3122
|
+
configPath,
|
|
3123
|
+
startedAt: startedAt.toISOString(),
|
|
3124
|
+
maxConcurrentProjects: definition.test.maxConcurrentProjects,
|
|
3125
|
+
maxConcurrentFiles: definition.test.maxConcurrentFiles,
|
|
3126
|
+
files: preparedProjects.flatMap((prepared)=>[
|
|
3127
|
+
...prepared.documents.map((document)=>({
|
|
3128
|
+
documentId: document.documentId,
|
|
3129
|
+
projectName: prepared.project.name,
|
|
3130
|
+
sourcePath: document.sourcePath,
|
|
3131
|
+
cases: document.cases.length
|
|
3132
|
+
})),
|
|
3133
|
+
...prepared.collectionErrors.map((error)=>({
|
|
3134
|
+
documentId: error.sourcePath,
|
|
3135
|
+
projectName: prepared.project.name,
|
|
3136
|
+
sourcePath: error.sourcePath,
|
|
3137
|
+
cases: 0
|
|
3138
|
+
}))
|
|
3139
|
+
])
|
|
3140
|
+
});
|
|
1553
3141
|
progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
|
|
1554
|
-
const effectiveConcurrency = Math.min(definition.test.
|
|
3142
|
+
const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
|
|
1555
3143
|
await writeTestRunPlan(runDir, preparedProjects);
|
|
1556
3144
|
const journal = createOperationJournal(runDir);
|
|
1557
3145
|
const rootController = new AbortController();
|
|
@@ -1565,6 +3153,18 @@ async function runPreparedTestProject(plan) {
|
|
|
1565
3153
|
process.on('SIGTERM', sigterm);
|
|
1566
3154
|
let failedCaseCount = 0;
|
|
1567
3155
|
const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
|
|
3156
|
+
let shutdownDeadline;
|
|
3157
|
+
const beginShutdown = ()=>{
|
|
3158
|
+
shutdownDeadline ??= Date.now() + definition.test.teardownTimeout;
|
|
3159
|
+
};
|
|
3160
|
+
rootController.signal.addEventListener('abort', beginShutdown, {
|
|
3161
|
+
once: true
|
|
3162
|
+
});
|
|
3163
|
+
const addFailedCases = (count)=>{
|
|
3164
|
+
failedCaseCount += count;
|
|
3165
|
+
if (bailReached()) beginShutdown();
|
|
3166
|
+
};
|
|
3167
|
+
const teardownTimeoutMs = ()=>Math.max(0, Math.min(definition.test.teardownTimeout, void 0 === shutdownDeadline ? 1 / 0 : shutdownDeadline - Date.now()));
|
|
1568
3168
|
let hasInfrastructureError = false;
|
|
1569
3169
|
const infrastructureErrors = [];
|
|
1570
3170
|
const recordInfrastructureError = (error)=>{
|
|
@@ -1626,112 +3226,30 @@ async function runPreparedTestProject(plan) {
|
|
|
1626
3226
|
announceProject(prepared, projectIndex);
|
|
1627
3227
|
return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
|
|
1628
3228
|
};
|
|
1629
|
-
const runPreparedProject = async (prepared, projectIndex)=>{
|
|
1630
|
-
const { project } = prepared;
|
|
1631
|
-
announceProject(prepared, projectIndex);
|
|
1632
|
-
const projectProgress = (message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${project.name}]${message}` : message));
|
|
1633
|
-
const cases = [];
|
|
1634
|
-
const documents = [];
|
|
1635
|
-
let lifecycle;
|
|
1636
|
-
let projectFatal = false;
|
|
1637
|
-
if (prepared.collectionErrors.length > 0 || 'run' === plan.preflightScope && totalErrors > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
|
|
1638
|
-
else if (rootController.signal.aborted) cases.push(...notRunSuite(prepared, 'interrupted'));
|
|
1639
|
-
else if (bailReached()) cases.push(...notRunSuite(prepared, 'bail'));
|
|
1640
|
-
else {
|
|
1641
|
-
const runtime = createProjectRuntime({
|
|
1642
|
-
project,
|
|
1643
|
-
setup: project.setup,
|
|
1644
|
-
signal: rootController.signal
|
|
1645
|
-
});
|
|
1646
|
-
let hasProjectExecutionError = false;
|
|
1647
|
-
try {
|
|
1648
|
-
await runtime.start();
|
|
1649
|
-
if (runtime.canRun) {
|
|
1650
|
-
const runDocument = async (invocation, documentIndex)=>{
|
|
1651
|
-
const { document } = invocation;
|
|
1652
|
-
if (rootController.signal.aborted || bailReached() || projectFatal) {
|
|
1653
|
-
const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
|
|
1654
|
-
cases.push(...document.cases.map((item)=>asNotRun(document.documentId, item, project.name, reason)));
|
|
1655
|
-
return;
|
|
1656
|
-
}
|
|
1657
|
-
projectProgress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
|
|
1658
|
-
await executeDocumentInvocation({
|
|
1659
|
-
invocation,
|
|
1660
|
-
project,
|
|
1661
|
-
projectContext: runtime.context,
|
|
1662
|
-
runDir,
|
|
1663
|
-
signal: runtime.signal,
|
|
1664
|
-
rootSignal: rootController.signal,
|
|
1665
|
-
shouldBail: bailReached,
|
|
1666
|
-
isProjectFatal: ()=>projectFatal,
|
|
1667
|
-
markProjectFatal: ()=>{
|
|
1668
|
-
projectFatal = true;
|
|
1669
|
-
},
|
|
1670
|
-
addFailedCases: (count)=>{
|
|
1671
|
-
failedCaseCount += count;
|
|
1672
|
-
},
|
|
1673
|
-
onProgress: projectProgress,
|
|
1674
|
-
sinks: {
|
|
1675
|
-
cases,
|
|
1676
|
-
documents
|
|
1677
|
-
}
|
|
1678
|
-
});
|
|
1679
|
-
};
|
|
1680
|
-
let prerequisiteFailed = false;
|
|
1681
|
-
const prerequisite = prepared.invocations.find((invocation)=>invocation.document.documentId === prepared.prerequisiteDocumentId);
|
|
1682
|
-
const prerequisiteCount = prerequisite ? 1 : 0;
|
|
1683
|
-
if (prerequisite) {
|
|
1684
|
-
await runDocument(prerequisite, 0);
|
|
1685
|
-
prerequisiteFailed = documents.at(-1)?.status === 'failed' || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status);
|
|
1686
|
-
}
|
|
1687
|
-
if (!prerequisiteFailed) await runConcurrentJobs(prepared.invocations.filter((invocation)=>invocation !== prerequisite), {
|
|
1688
|
-
concurrency: prepared.documentConcurrency,
|
|
1689
|
-
shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal
|
|
1690
|
-
}, async (invocation, index)=>{
|
|
1691
|
-
try {
|
|
1692
|
-
await runDocument(invocation, index + prerequisiteCount);
|
|
1693
|
-
} catch (error) {
|
|
1694
|
-
recordInfrastructureError(error);
|
|
1695
|
-
throw error;
|
|
1696
|
-
}
|
|
1697
|
-
});
|
|
1698
|
-
const completed = new Set(cases.map((item)=>item.caseId));
|
|
1699
|
-
const reason = prerequisiteFailed ? 'project-setup-failed' : rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
|
|
1700
|
-
cases.push(...notRunSuite(prepared, reason).filter((item)=>!completed.has(item.caseId)));
|
|
1701
|
-
} else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
|
|
1702
|
-
} catch (error) {
|
|
1703
|
-
hasProjectExecutionError = true;
|
|
1704
|
-
recordInfrastructureError(error);
|
|
1705
|
-
} finally{
|
|
1706
|
-
const hasFailure = hasProjectExecutionError || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status) || latestById(documents, (item)=>item.documentId).some((item)=>'failed' === item.status) || projectFatal || rootController.signal.aborted;
|
|
1707
|
-
lifecycle = await runtime.finish(hasFailure ? 'failed' : 'success');
|
|
1708
|
-
}
|
|
1709
|
-
if (hasProjectExecutionError) {
|
|
1710
|
-
const completed = new Set(cases.map((item)=>item.caseId));
|
|
1711
|
-
cases.push(...notRunSuite(prepared, 'interrupted').filter((item)=>!completed.has(item.caseId)));
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
const order = new Map(prepared.documents.map((document, index)=>[
|
|
1715
|
-
document.documentId,
|
|
1716
|
-
index
|
|
1717
|
-
]));
|
|
1718
|
-
const documentOrder = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
|
|
1719
|
-
cases.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || a.caseIndex - b.caseIndex);
|
|
1720
|
-
documents.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || (a.attemptIndex ?? 0) - (b.attemptIndex ?? 0));
|
|
1721
|
-
return buildProjectResult(prepared, cases, documents, lifecycle);
|
|
1722
|
-
};
|
|
1723
3229
|
const projectResults = new Array(preparedProjects.length);
|
|
1724
3230
|
try {
|
|
1725
|
-
await
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
3231
|
+
const states = await runPreparedFiles({
|
|
3232
|
+
projects: preparedProjects,
|
|
3233
|
+
maxConcurrentFiles: effectiveConcurrency,
|
|
3234
|
+
maxConcurrentProjects: definition.test.maxConcurrentProjects ?? 1 / 0,
|
|
3235
|
+
setupTimeoutMs: definition.test.setupTimeout,
|
|
3236
|
+
teardownTimeoutMs,
|
|
3237
|
+
signal: rootController.signal,
|
|
3238
|
+
runDir,
|
|
3239
|
+
preflightFailed: 'run' === plan.preflightScope && totalErrors > 0,
|
|
3240
|
+
shouldBail: bailReached,
|
|
3241
|
+
addFailedCases,
|
|
3242
|
+
announceProject,
|
|
3243
|
+
onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(preparedProjects.length > 1 || effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
|
|
3244
|
+
onEvent: (event)=>runInfrastructureCallback(()=>plan.onEvent?.(event)),
|
|
3245
|
+
onProjectEnd: (state)=>runInfrastructureCallback(()=>plan.onEvent?.({
|
|
3246
|
+
type: 'project-end',
|
|
3247
|
+
project: buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle)
|
|
3248
|
+
})),
|
|
3249
|
+
onInfrastructureError: recordInfrastructureError
|
|
3250
|
+
});
|
|
3251
|
+
states.forEach((state, index)=>{
|
|
3252
|
+
projectResults[index] = buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle);
|
|
1735
3253
|
});
|
|
1736
3254
|
} catch (error) {
|
|
1737
3255
|
recordInfrastructureError(error);
|
|
@@ -1808,6 +3326,10 @@ async function runPreparedTestProject(plan) {
|
|
|
1808
3326
|
buildRunnerDump: (index)=>{
|
|
1809
3327
|
reportIndex = index;
|
|
1810
3328
|
const dump = buildTestRunReportDump(completedResult, index);
|
|
3329
|
+
completedResult = {
|
|
3330
|
+
...completedResult,
|
|
3331
|
+
metrics: dump.metrics
|
|
3332
|
+
};
|
|
1811
3333
|
const warn = getDebug('test-runner:report-assembler', {
|
|
1812
3334
|
console: true
|
|
1813
3335
|
});
|
|
@@ -1825,6 +3347,16 @@ async function runPreparedTestProject(plan) {
|
|
|
1825
3347
|
await publication.publish(completedResult);
|
|
1826
3348
|
});
|
|
1827
3349
|
await publish('write-result', (0, external_node_path_.join)((0, external_node_path_.dirname)(summaryPath), 'result.md'), async ()=>{
|
|
3350
|
+
if (!completedResult.metrics) {
|
|
3351
|
+
const dump = buildTestRunReportDump(completedResult, reportIndex ?? {
|
|
3352
|
+
sources: [],
|
|
3353
|
+
metrics: sumTestRunReportMetrics([])
|
|
3354
|
+
});
|
|
3355
|
+
completedResult = {
|
|
3356
|
+
...completedResult,
|
|
3357
|
+
metrics: dump.metrics
|
|
3358
|
+
};
|
|
3359
|
+
}
|
|
1828
3360
|
const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
|
|
1829
3361
|
completedResult = {
|
|
1830
3362
|
...completedResult,
|
|
@@ -1845,6 +3377,7 @@ const parseTestCliArgs = (args, cwd = process.cwd())=>{
|
|
|
1845
3377
|
let configPath;
|
|
1846
3378
|
let resultDir;
|
|
1847
3379
|
const projectNames = [];
|
|
3380
|
+
let reporter;
|
|
1848
3381
|
for(let index = commandOffset; index < args.length; index += 1){
|
|
1849
3382
|
const token = args[index];
|
|
1850
3383
|
const equal = token.startsWith('-') ? token.indexOf('=') : -1;
|
|
@@ -1855,16 +3388,21 @@ const parseTestCliArgs = (args, cwd = process.cwd())=>{
|
|
|
1855
3388
|
projectRoot = (0, external_node_path_.resolve)(cwd, arg);
|
|
1856
3389
|
continue;
|
|
1857
3390
|
}
|
|
1858
|
-
if ('--config' === arg || '--result-dir' === arg || '--project' === arg) {
|
|
3391
|
+
if ('--config' === arg || '--result-dir' === arg || '--project' === arg || '--reporter' === arg) {
|
|
1859
3392
|
const value = inlineValue ?? args[index + 1];
|
|
1860
3393
|
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value.`);
|
|
1861
3394
|
if ('--config' === arg) configPath = value;
|
|
1862
3395
|
else if ('--result-dir' === arg) resultDir = (0, external_node_path_.resolve)(cwd, value);
|
|
1863
|
-
else projectNames.push(value);
|
|
3396
|
+
else if ('--project' === arg) projectNames.push(value);
|
|
3397
|
+
else {
|
|
3398
|
+
if ('default' !== value && 'verbose' !== value) throw new Error(`Unknown reporter: ${value}. Expected default or verbose.`);
|
|
3399
|
+
reporter = value;
|
|
3400
|
+
}
|
|
1864
3401
|
if (void 0 === inlineValue) index += 1;
|
|
1865
3402
|
} else throw new Error(`Unknown option: ${token}`);
|
|
1866
3403
|
}
|
|
1867
3404
|
if ('nodes' === command && resultDir) throw new Error('--result-dir is not supported by nodes.');
|
|
3405
|
+
if ('nodes' === command && reporter) throw new Error('--reporter is not supported by nodes.');
|
|
1868
3406
|
if ('nodes' === command && projectNames.length > 1) throw new Error('nodes accepts only one --project name.');
|
|
1869
3407
|
return {
|
|
1870
3408
|
...command ? {
|
|
@@ -1876,13 +3414,27 @@ const parseTestCliArgs = (args, cwd = process.cwd())=>{
|
|
|
1876
3414
|
resultDir,
|
|
1877
3415
|
...projectNames.length > 0 ? {
|
|
1878
3416
|
projectNames
|
|
3417
|
+
} : {},
|
|
3418
|
+
...reporter ? {
|
|
3419
|
+
reporter
|
|
1879
3420
|
} : {}
|
|
1880
3421
|
};
|
|
1881
3422
|
};
|
|
1882
3423
|
const defaultCliIO = {
|
|
1883
3424
|
log: console.log,
|
|
1884
3425
|
error: console.error,
|
|
1885
|
-
write:
|
|
3426
|
+
write: process.stdout.write.bind(process.stdout),
|
|
3427
|
+
isTTY: Boolean(process.stdout.isTTY && !process.env.CI),
|
|
3428
|
+
get columns () {
|
|
3429
|
+
return process.stdout.columns;
|
|
3430
|
+
},
|
|
3431
|
+
get rows () {
|
|
3432
|
+
return process.stdout.rows;
|
|
3433
|
+
},
|
|
3434
|
+
onResize (listener) {
|
|
3435
|
+
process.stdout.on('resize', listener);
|
|
3436
|
+
return ()=>process.stdout.off('resize', listener);
|
|
3437
|
+
}
|
|
1886
3438
|
};
|
|
1887
3439
|
const testCliHelp = `Midscene Test: run native cases/steps Test projects.
|
|
1888
3440
|
|
|
@@ -1897,6 +3449,7 @@ Options:
|
|
|
1897
3449
|
--config <path> Native TypeScript or JavaScript Test config
|
|
1898
3450
|
--project <name> Select a native execution Project (repeatable)
|
|
1899
3451
|
--result-dir <path> Store Test result files in this directory
|
|
3452
|
+
--reporter <default|verbose> Concise Case results or full step diagnostics
|
|
1900
3453
|
--help, -h Show this help
|
|
1901
3454
|
--version Show the package version
|
|
1902
3455
|
|
|
@@ -1957,11 +3510,17 @@ const runNodesCommand = async (options, io)=>{
|
|
|
1957
3510
|
io.log(`\nNode Spec generated: ${specPath}`);
|
|
1958
3511
|
}
|
|
1959
3512
|
};
|
|
1960
|
-
const reportTestProjectResult = (result, io, infrastructureErrors = result.errors ?? [])=>{
|
|
3513
|
+
const reportTestProjectResult = (result, io, reporter, projectRoot, infrastructureErrors = result.errors ?? [])=>{
|
|
3514
|
+
reporter.finish(result);
|
|
1961
3515
|
const reportedErrors = new Set();
|
|
1962
|
-
const reportError = (source, error)=>{
|
|
3516
|
+
const reportError = (source, error, sourcePath, context)=>{
|
|
1963
3517
|
if (error instanceof Error && reportedErrors.has(error)) return;
|
|
1964
|
-
|
|
3518
|
+
reporter.failure(source, error, {
|
|
3519
|
+
projectRoot,
|
|
3520
|
+
...context
|
|
3521
|
+
});
|
|
3522
|
+
const annotation = githubFailureAnnotation(source, formatConciseError(error), sourcePath ? (0, external_node_path_.resolve)(projectRoot, sourcePath) : void 0);
|
|
3523
|
+
if (annotation) io.log(annotation);
|
|
1965
3524
|
const pending = [
|
|
1966
3525
|
error
|
|
1967
3526
|
];
|
|
@@ -1973,7 +3532,33 @@ const reportTestProjectResult = (result, io, infrastructureErrors = result.error
|
|
|
1973
3532
|
if (current instanceof AggregateError) pending.push(...current.errors);
|
|
1974
3533
|
}
|
|
1975
3534
|
};
|
|
1976
|
-
for (const failure of result.collectionErrors)reportError(`${failure.projectName}/${failure.sourcePath}`, failure.error
|
|
3535
|
+
for (const failure of result.collectionErrors)reportError(`${failure.projectName}/${failure.sourcePath}`, failure.error, failure.sourcePath, {
|
|
3536
|
+
projectName: failure.projectName,
|
|
3537
|
+
sourcePath: failure.sourcePath,
|
|
3538
|
+
phase: 'collection'
|
|
3539
|
+
});
|
|
3540
|
+
const finalDocuments = new Map(result.documents.map((document)=>[
|
|
3541
|
+
document.documentId,
|
|
3542
|
+
document
|
|
3543
|
+
]));
|
|
3544
|
+
const documentContext = (projectName, documentId, sourcePath)=>{
|
|
3545
|
+
const project = result.projects.find((project)=>project.name === projectName);
|
|
3546
|
+
const document = finalDocuments.get(documentId);
|
|
3547
|
+
const plan = document?.plan ?? project?.plans?.find((plan)=>plan.documentId === documentId);
|
|
3548
|
+
const fileIndex = document?.attemptIndex ?? 0;
|
|
3549
|
+
return {
|
|
3550
|
+
plan,
|
|
3551
|
+
projectName,
|
|
3552
|
+
sourcePath,
|
|
3553
|
+
...plan?.retry.scope === 'document' ? {
|
|
3554
|
+
fileAttempt: {
|
|
3555
|
+
index: fileIndex,
|
|
3556
|
+
total: Math.max(fileIndex + 1, plan.retry.count + 1)
|
|
3557
|
+
}
|
|
3558
|
+
} : {},
|
|
3559
|
+
caseAttempts: plan?.retry.scope === 'document' ? 1 : (plan?.retry.count ?? project?.retry ?? 0) + 1
|
|
3560
|
+
};
|
|
3561
|
+
};
|
|
1977
3562
|
const finalCases = new Map(result.cases.map((outcome)=>[
|
|
1978
3563
|
outcome.caseId,
|
|
1979
3564
|
outcome
|
|
@@ -1982,49 +3567,107 @@ const reportTestProjectResult = (result, io, infrastructureErrors = result.error
|
|
|
1982
3567
|
if ('failed' !== outcome.status || !outcome.run) continue;
|
|
1983
3568
|
const run = outcome.run;
|
|
1984
3569
|
const source = `${run.projectName}/${run.sourcePath} / ${run.name}`;
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
...
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
3570
|
+
const { caseAttempts, plan, ...scope } = documentContext(run.projectName, outcome.documentId, run.sourcePath);
|
|
3571
|
+
const context = {
|
|
3572
|
+
...scope,
|
|
3573
|
+
caseName: run.name,
|
|
3574
|
+
attempt: {
|
|
3575
|
+
index: run.attemptIndex,
|
|
3576
|
+
total: Math.max(caseAttempts, run.attemptIndex + 1)
|
|
3577
|
+
}
|
|
3578
|
+
};
|
|
3579
|
+
const casePlan = plan?.cases.find((item)=>item.caseId === outcome.caseId);
|
|
3580
|
+
for (const [phase, steps] of [
|
|
3581
|
+
[
|
|
3582
|
+
'beforeEach',
|
|
3583
|
+
run.beforeEach
|
|
3584
|
+
],
|
|
3585
|
+
[
|
|
3586
|
+
'steps',
|
|
3587
|
+
run.steps
|
|
3588
|
+
],
|
|
3589
|
+
[
|
|
3590
|
+
'afterEach',
|
|
3591
|
+
run.afterEach
|
|
3592
|
+
]
|
|
3593
|
+
])for (const step of steps)if (step.error) reportError(`${source} / ${step.phase}[${step.stepIndex + 1}] ${step.node}${run.attemptIndex ? ` (failed after ${run.attemptIndex + 1} attempts)` : ''}`, step.error, run.sourcePath, {
|
|
3594
|
+
...context,
|
|
3595
|
+
step,
|
|
3596
|
+
stepCount: casePlan?.[phase].length
|
|
3597
|
+
});
|
|
3598
|
+
for (const [phase, errors] of [
|
|
3599
|
+
[
|
|
3600
|
+
'execution',
|
|
3601
|
+
run.executionErrors
|
|
3602
|
+
],
|
|
3603
|
+
[
|
|
3604
|
+
'teardown',
|
|
3605
|
+
run.teardownErrors
|
|
3606
|
+
]
|
|
3607
|
+
])for (const error of errors ?? [])reportError(source, error, run.sourcePath, {
|
|
3608
|
+
...context,
|
|
3609
|
+
phase
|
|
3610
|
+
});
|
|
1994
3611
|
}
|
|
1995
|
-
const finalDocuments = new Map(result.documents.map((document)=>[
|
|
1996
|
-
document.documentId,
|
|
1997
|
-
document
|
|
1998
|
-
]));
|
|
1999
3612
|
for (const document of finalDocuments.values()){
|
|
2000
3613
|
if ('failed' !== document.status) continue;
|
|
2001
3614
|
const source = `${document.projectName}/${document.sourcePath}`;
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
3615
|
+
const { caseAttempts: _, plan, ...context } = documentContext(document.projectName, document.documentId, document.sourcePath);
|
|
3616
|
+
for (const [phase, steps] of [
|
|
3617
|
+
[
|
|
3618
|
+
'beforeAll',
|
|
3619
|
+
document.beforeAll
|
|
3620
|
+
],
|
|
3621
|
+
[
|
|
3622
|
+
'afterAll',
|
|
3623
|
+
document.afterAll
|
|
3624
|
+
]
|
|
3625
|
+
])for (const step of steps)if (step.error) reportError(`${source} / ${step.phase}[${step.stepIndex + 1}] ${step.node}`, step.error, document.sourcePath, {
|
|
3626
|
+
...context,
|
|
3627
|
+
step,
|
|
3628
|
+
stepCount: plan?.[phase].length
|
|
3629
|
+
});
|
|
3630
|
+
for (const [phase, errors] of [
|
|
3631
|
+
[
|
|
3632
|
+
'execution',
|
|
3633
|
+
document.executionErrors
|
|
3634
|
+
],
|
|
3635
|
+
[
|
|
3636
|
+
'teardown',
|
|
3637
|
+
document.teardownErrors
|
|
3638
|
+
]
|
|
3639
|
+
])for (const error of errors ?? [])reportError(source, error, document.sourcePath, {
|
|
3640
|
+
...context,
|
|
3641
|
+
phase
|
|
3642
|
+
});
|
|
3643
|
+
for (const failure of document.hostErrors ?? [])reportError(`${source} / host ${failure.phase}`, failure.error, document.sourcePath, {
|
|
3644
|
+
...context,
|
|
3645
|
+
phase: `host ${failure.phase}`
|
|
3646
|
+
});
|
|
2011
3647
|
}
|
|
2012
3648
|
for (const project of result.projects){
|
|
2013
|
-
if (project.lifecycle?.setupError) reportError(`${project.name} / setup`, project.lifecycle.setupError
|
|
2014
|
-
|
|
3649
|
+
if (project.lifecycle?.setupError) reportError(`${project.name} / setup`, project.lifecycle.setupError, void 0, {
|
|
3650
|
+
projectName: project.name,
|
|
3651
|
+
phase: 'setup'
|
|
3652
|
+
});
|
|
3653
|
+
for (const error of project.lifecycle?.teardownErrors ?? [])reportError(`${project.name} / teardown`, error, void 0, {
|
|
3654
|
+
projectName: project.name,
|
|
3655
|
+
phase: 'teardown'
|
|
3656
|
+
});
|
|
2015
3657
|
}
|
|
2016
3658
|
for (const error of infrastructureErrors)reportError('infrastructure', error);
|
|
2017
|
-
|
|
2018
|
-
io.log(`Results: ${result.resultDir}`);
|
|
3659
|
+
reporter.summary(result);
|
|
3660
|
+
if ('verbose' === reporter.mode) io.log(`Results: ${result.resultDir}`);
|
|
2019
3661
|
io.log(`Summary: ${result.summaryPath}`);
|
|
2020
|
-
if (result.markdownPath) io.log(`Markdown: ${result.markdownPath}`);
|
|
3662
|
+
if (result.markdownPath && 'verbose' === reporter.mode) io.log(`Markdown: ${result.markdownPath}`);
|
|
2021
3663
|
if (result.reportPath) io.log(`Report: ${result.reportPath}`);
|
|
3664
|
+
writeGitHubSummary(result);
|
|
2022
3665
|
return result.exitCode;
|
|
2023
3666
|
};
|
|
2024
3667
|
async function runTestCli(args, io = defaultCliIO) {
|
|
2025
3668
|
try {
|
|
2026
3669
|
if ('model' === args[0]) {
|
|
2027
|
-
const { runModelCommand } = await __webpack_require__.e("
|
|
3670
|
+
const { runModelCommand } = await __webpack_require__.e("731").then(__webpack_require__.bind(__webpack_require__, "./src/cli/model-command.ts?3cff"));
|
|
2028
3671
|
return runModelCommand(args, void 0, {
|
|
2029
3672
|
stdout: io.log,
|
|
2030
3673
|
stderr: io.error
|
|
@@ -2048,18 +3691,32 @@ async function runTestCli(args, io = defaultCliIO) {
|
|
|
2048
3691
|
await runNodesCommand(options, io);
|
|
2049
3692
|
return 0;
|
|
2050
3693
|
}
|
|
2051
|
-
|
|
3694
|
+
const reporter = new TerminalReporter(io, options.reporter);
|
|
3695
|
+
const restoreOutput = io === defaultCliIO && io.isTTY && 'verbose' !== options.reporter ? captureTerminalOutput((message, write)=>reporter.output(message, write)) : void 0;
|
|
3696
|
+
const projectRoot = (0, external_node_fs_.realpathSync)(options.projectRoot && (0, external_node_fs_.existsSync)(options.projectRoot) && (0, external_node_fs_.statSync)(options.projectRoot).isFile() ? (0, external_node_path_.dirname)(options.projectRoot) : options.projectRoot ?? options.cwd);
|
|
2052
3697
|
try {
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
3698
|
+
return await runWithConsoleLogHandler((entry)=>reporter.console(entry), async ()=>{
|
|
3699
|
+
let result;
|
|
3700
|
+
try {
|
|
3701
|
+
result = await runTestProject({
|
|
3702
|
+
...options,
|
|
3703
|
+
onProgress: (message)=>reporter.progress(message),
|
|
3704
|
+
onEvent: (event)=>reporter.event(event)
|
|
3705
|
+
});
|
|
3706
|
+
} catch (error) {
|
|
3707
|
+
if (!(error instanceof WorkflowExecutionFailure)) throw error;
|
|
3708
|
+
reportTestProjectResult(error.result, io, reporter, projectRoot, error.errors);
|
|
3709
|
+
return 1;
|
|
3710
|
+
}
|
|
3711
|
+
return reportTestProjectResult(result, io, reporter, projectRoot);
|
|
2056
3712
|
});
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
3713
|
+
} finally{
|
|
3714
|
+
try {
|
|
3715
|
+
reporter.dispose();
|
|
3716
|
+
} finally{
|
|
3717
|
+
restoreOutput?.();
|
|
3718
|
+
}
|
|
2061
3719
|
}
|
|
2062
|
-
return reportTestProjectResult(result, io);
|
|
2063
3720
|
} catch (error) {
|
|
2064
3721
|
io.error(formatCliError(error));
|
|
2065
3722
|
return 1;
|