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