@midscene/test 1.16.1 → 1.16.5

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.
@@ -29,7 +29,7 @@ var __webpack_modules__ = {
29
29
  },
30
30
  "./package.json" (module) {
31
31
  module.exports = {
32
- rE: "1.16.1"
32
+ rE: "1.16.5"
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}`;
@@ -585,11 +1626,13 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
585
1626
  start: state.position + sourceOffset,
586
1627
  end: 0,
587
1628
  value: null,
588
- children: []
1629
+ children: [],
1630
+ kind: null
589
1631
  });
590
1632
  const node = stack.pop();
591
1633
  node.end = state.position + sourceOffset;
592
1634
  node.value = state.result;
1635
+ node.kind = state.kind;
593
1636
  stack.at(-1)?.children.push(node);
594
1637
  nodes.push(node);
595
1638
  if ('mapping' !== state.kind) return;
@@ -619,6 +1662,36 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
619
1662
  const value = (0, test_runner_namespaceObject.sanitizeReportText)(redacted, 65536);
620
1663
  return {
621
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
+ },
622
1695
  sourceYaml: {
623
1696
  value,
624
1697
  ...redactedPaths.size ? {
@@ -677,7 +1750,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
677
1750
  ]));
678
1751
  return value;
679
1752
  };
680
- function _define_property(obj, key, value) {
1753
+ function collect_define_property(obj, key, value) {
681
1754
  if (key in obj) Object.defineProperty(obj, key, {
682
1755
  value: value,
683
1756
  enumerable: true,
@@ -694,7 +1767,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
694
1767
  sourcePath,
695
1768
  actualFormat,
696
1769
  expectedFormat: 'native'
697
- }), _define_property(this, "actualFormat", void 0);
1770
+ }), collect_define_property(this, "actualFormat", void 0);
698
1771
  this.actualFormat = actualFormat;
699
1772
  }
700
1773
  }
@@ -731,6 +1804,18 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
731
1804
  const sourcePath = source.sourcePath.split(external_node_path_.sep).join('/');
732
1805
  const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
733
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
+ },
734
1819
  templateInput: step.input,
735
1820
  input: resolveWorkflowVariables(step.input, {
736
1821
  variables: options.variables,
@@ -906,6 +1991,9 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
906
1991
  markdown: relativeToSummary(result.summaryPath, result.markdownPath)
907
1992
  } : {},
908
1993
  summary: result.summary,
1994
+ ...result.metrics ? {
1995
+ metrics: result.metrics
1996
+ } : {},
909
1997
  projects: result.projects.map((project)=>({
910
1998
  projectId: project.projectId,
911
1999
  name: project.name,
@@ -1302,14 +2390,14 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1302
2390
  preflightScope: hasFormatMismatch ? 'run' : 'project',
1303
2391
  reportEnabled: true,
1304
2392
  publications: [],
1305
- onProgress: options.onProgress ?? (()=>{})
2393
+ onProgress: options.onProgress ?? (()=>{}),
2394
+ onEvent: options.onEvent
1306
2395
  };
1307
2396
  }
1308
2397
  const external_node_events_namespaceObject = require("node:events");
1309
2398
  const report_namespaceObject = require("@midscene/core/report");
1310
- const logger_namespaceObject = require("@midscene/shared/logger");
1311
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, ' ');
1312
- const duration = (ms)=>{
2400
+ const result_markdown_duration = (ms)=>{
1313
2401
  const seconds = Math.round(ms / 1000);
1314
2402
  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
1315
2403
  };
@@ -1320,7 +2408,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1320
2408
  const lines = [
1321
2409
  '## Midscene Test',
1322
2410
  '',
1323
- `${outcome} · ${duration(dump.durationMs)}`,
2411
+ `${outcome} · ${result_markdown_duration(dump.durationMs)}`,
1324
2412
  ''
1325
2413
  ];
1326
2414
  if (summary.filtered) lines.push(`**Filtered:** ${summary.filtered}`, '');
@@ -1383,6 +2471,38 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1383
2471
  }));
1384
2472
  return output;
1385
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
+ }
1386
2506
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1387
2507
  const formatStep = (info)=>{
1388
2508
  const position = stepPosition(info);
@@ -1429,69 +2549,121 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1429
2549
  const documentRetry = 'document' === invocation.retry.scope;
1430
2550
  const attempts = [];
1431
2551
  const fileSetup = invocation.bindings.documentSetup;
1432
- const documentSetup = options.isProjectResourceFailure && fileSetup ? {
2552
+ const documentSetup = fileSetup ? {
1433
2553
  ...fileSetup,
1434
- async setup (ctx) {
1435
- try {
1436
- return await fileSetup.setup(ctx);
1437
- } catch (error) {
1438
- if (isProjectResourceFailure(error)) options.markProjectFatal();
1439
- throw error;
1440
- }
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
+ });
1441
2577
  }
1442
- } : fileSetup;
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
+ });
1443
2586
  await (0, test_runner_namespaceObject.runDocumentAttempts)({
1444
2587
  retry: documentRetry ? invocation.retry.count : 0,
1445
2588
  signal: options.signal,
1446
2589
  shouldStop: ()=>options.shouldBail() || options.isProjectFatal()
1447
2590
  }, async (documentAttemptIndex)=>{
1448
2591
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1449
- const execution = await (0, test_runner_namespaceObject.runWorkflowDocument)(document, {
1450
- plan: invocation.plan,
1451
- documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1452
- ...invocation.bindings,
1453
- documentSetup,
1454
- setupTimeoutMs: options.setupTimeoutMs,
1455
- teardownTimeoutMs: options.teardownTimeoutMs,
1456
- project,
1457
- projectContext: options.projectContext,
1458
- retry: documentRetry ? 0 : invocation.retry.count,
1459
- signal: options.signal,
1460
- defaultTimeoutMs: invocation.defaultTimeoutMs,
1461
- shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1462
- stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1463
- isFatalError: (run)=>[
1464
- ...run.beforeEach,
1465
- ...run.steps,
1466
- ...run.afterEach
1467
- ].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)),
1468
- onCaseStart: (collectedCase)=>{
1469
- options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1470
- },
1471
- onStepStart: (info)=>{
1472
- const indent = 'case' === info.scope ? ' ' : ' ';
1473
- options.onProgress(`${indent}→ ${formatStep(info)}`);
1474
- },
1475
- onStepResult: async (info, result)=>{
1476
- if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1477
- await invocation.bindings.onStepResult?.(info, result);
1478
- options.onProgress(formatStepResult(info, result));
1479
- },
1480
- onCaseResult: async (attempt)=>{
1481
- await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1482
- options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1483
- },
1484
- onCaseOutcome: (outcome)=>{
1485
- if (outcome.attempts?.some((attempt)=>[
1486
- ...attempt.beforeEach,
1487
- ...attempt.steps,
1488
- ...attempt.afterEach
1489
- ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1490
- if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1491
- if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1492
- },
1493
- onDocumentResult: options.onDocumentResult
1494
- }).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)=>{
1495
2667
  if (error instanceof test_runner_namespaceObject.WorkflowExecutionFailure) {
1496
2668
  const partial = error.result;
1497
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)));
@@ -1561,6 +2733,11 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1561
2733
  shouldStop: options.shouldBail()
1562
2734
  });
1563
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
+ });
1564
2741
  attempts.push(execution);
1565
2742
  sinks.cases.push(...execution.cases.map((outcome)=>({
1566
2743
  ...outcome,
@@ -1595,7 +2772,18 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1595
2772
  });
1596
2773
  const final = attempts.at(-1);
1597
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
+ });
1598
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
+ });
1599
2787
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1600
2788
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1601
2789
  }
@@ -1635,11 +2823,45 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1635
2823
  code: 'UNRESOLVED_FILE_RESOURCES'
1636
2824
  }));
1637
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
+ };
1638
2848
  const finishProject = async (state)=>{
1639
2849
  if (!state.runtime || state.finished) return;
1640
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
+ }
1641
2860
  const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1642
- state.lifecycle = await state.runtime.finish(failed ? 'failed' : 'success');
2861
+ state.lifecycle = await (0, logger_namespaceObject.runWithConsoleLogScope)({
2862
+ projectName: state.prepared.project.name,
2863
+ phase: 'teardown'
2864
+ }, ()=>state.runtime.finish(failed ? 'failed' : 'success'));
1643
2865
  const releaseProject = ()=>{
1644
2866
  occupiedProjects--;
1645
2867
  wake?.();
@@ -1653,7 +2875,11 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1653
2875
  options.onInfrastructureError(error);
1654
2876
  releaseProject();
1655
2877
  });
1656
- } else releaseProject();
2878
+ } else {
2879
+ releaseProject();
2880
+ finalizeProject(state);
2881
+ options.onProjectEnd?.(state);
2882
+ }
1657
2883
  };
1658
2884
  const admit = ()=>{
1659
2885
  for(let offset = 0; offset < states.length; offset++){
@@ -1694,7 +2920,20 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1694
2920
  });
1695
2921
  (0, external_node_events_namespaceObject.setMaxListeners)(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1696
2922
  options.announceProject(prepared, index);
1697
- await state.runtime.start();
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
+ });
1698
2937
  state.initializing = false;
1699
2938
  if (!state.runtime.canRun) {
1700
2939
  state.fatal = true;
@@ -1722,8 +2961,9 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1722
2961
  }));
1723
2962
  },
1724
2963
  addFailedCases: options.addFailedCases,
1725
- onProgress: (message)=>options.onProgress(prepared, message),
2964
+ onProgress: (message)=>options.onProgress(prepared, options.maxConcurrentFiles > 1 ? ` [${invocation.document.sourcePath}]${message}` : message),
1726
2965
  sinks: state,
2966
+ onEvent: options.onEvent,
1727
2967
  isolatedContext: void 0 !== project.fileSetup,
1728
2968
  isProjectResourceFailure: isSharedResourceFailure,
1729
2969
  setupTimeoutMs: options.setupTimeoutMs,
@@ -1802,25 +3042,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1802
3042
  for (const state of states){
1803
3043
  const { prepared } = state;
1804
3044
  if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1805
- const completed = new Set(state.cases.map((item)=>item.caseId));
1806
- const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1807
- for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1808
- documentId: document.documentId,
1809
- caseId: item.caseId,
1810
- projectName: prepared.project.name,
1811
- name: item.definition.name,
1812
- sourcePath: item.sourcePath,
1813
- caseIndex: item.caseIndex,
1814
- status: 'not-run',
1815
- notRunReason: reason
1816
- });
1817
- const order = new Map(prepared.documents.map((document, i)=>[
1818
- document.documentId,
1819
- i
1820
- ]));
1821
- const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1822
- state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1823
- state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
3045
+ finalizeProject(state);
1824
3046
  }
1825
3047
  return states;
1826
3048
  }
@@ -1892,6 +3114,32 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1892
3114
  const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
1893
3115
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1894
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
+ });
1895
3143
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1896
3144
  const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
1897
3145
  await writeTestRunPlan(runDir, preparedProjects);
@@ -1994,7 +3242,12 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1994
3242
  shouldBail: bailReached,
1995
3243
  addFailedCases,
1996
3244
  announceProject,
1997
- onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
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
+ })),
1998
3251
  onInfrastructureError: recordInfrastructureError
1999
3252
  });
2000
3253
  states.forEach((state, index)=>{
@@ -2075,6 +3328,10 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
2075
3328
  buildRunnerDump: (index)=>{
2076
3329
  reportIndex = index;
2077
3330
  const dump = (0, test_runner_namespaceObject.buildTestRunReportDump)(completedResult, index);
3331
+ completedResult = {
3332
+ ...completedResult,
3333
+ metrics: dump.metrics
3334
+ };
2078
3335
  const warn = (0, logger_namespaceObject.getDebug)('test-runner:report-assembler', {
2079
3336
  console: true
2080
3337
  });
@@ -2092,6 +3349,16 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
2092
3349
  await publication.publish(completedResult);
2093
3350
  });
2094
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
+ }
2095
3362
  const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
2096
3363
  completedResult = {
2097
3364
  ...completedResult,
@@ -2112,6 +3379,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
2112
3379
  let configPath;
2113
3380
  let resultDir;
2114
3381
  const projectNames = [];
3382
+ let reporter;
2115
3383
  for(let index = commandOffset; index < args.length; index += 1){
2116
3384
  const token = args[index];
2117
3385
  const equal = token.startsWith('-') ? token.indexOf('=') : -1;
@@ -2122,16 +3390,21 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
2122
3390
  projectRoot = (0, external_node_path_.resolve)(cwd, arg);
2123
3391
  continue;
2124
3392
  }
2125
- if ('--config' === arg || '--result-dir' === arg || '--project' === arg) {
3393
+ if ('--config' === arg || '--result-dir' === arg || '--project' === arg || '--reporter' === arg) {
2126
3394
  const value = inlineValue ?? args[index + 1];
2127
3395
  if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value.`);
2128
3396
  if ('--config' === arg) configPath = value;
2129
3397
  else if ('--result-dir' === arg) resultDir = (0, external_node_path_.resolve)(cwd, value);
2130
- 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
+ }
2131
3403
  if (void 0 === inlineValue) index += 1;
2132
3404
  } else throw new Error(`Unknown option: ${token}`);
2133
3405
  }
2134
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.');
2135
3408
  if ('nodes' === command && projectNames.length > 1) throw new Error('nodes accepts only one --project name.');
2136
3409
  return {
2137
3410
  ...command ? {
@@ -2143,13 +3416,27 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
2143
3416
  resultDir,
2144
3417
  ...projectNames.length > 0 ? {
2145
3418
  projectNames
3419
+ } : {},
3420
+ ...reporter ? {
3421
+ reporter
2146
3422
  } : {}
2147
3423
  };
2148
3424
  };
2149
3425
  const defaultCliIO = {
2150
3426
  log: console.log,
2151
3427
  error: console.error,
2152
- 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
+ }
2153
3440
  };
2154
3441
  const testCliHelp = `Midscene Test: run native cases/steps Test projects.
2155
3442
 
@@ -2164,6 +3451,7 @@ Options:
2164
3451
  --config <path> Native TypeScript or JavaScript Test config
2165
3452
  --project <name> Select a native execution Project (repeatable)
2166
3453
  --result-dir <path> Store Test result files in this directory
3454
+ --reporter <default|verbose> Concise Case results or full step diagnostics
2167
3455
  --help, -h Show this help
2168
3456
  --version Show the package version
2169
3457
 
@@ -2224,11 +3512,17 @@ Legacy tasks/flow YAML and its CLI options belong to the midscene command.
2224
3512
  io.log(`\nNode Spec generated: ${specPath}`);
2225
3513
  }
2226
3514
  };
2227
- const reportTestProjectResult = (result, io, infrastructureErrors = result.errors ?? [])=>{
3515
+ const reportTestProjectResult = (result, io, reporter, projectRoot, infrastructureErrors = result.errors ?? [])=>{
3516
+ reporter.finish(result);
2228
3517
  const reportedErrors = new Set();
2229
- const reportError = (source, error)=>{
3518
+ const reportError = (source, error, sourcePath, context)=>{
2230
3519
  if (error instanceof Error && reportedErrors.has(error)) return;
2231
- 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);
2232
3526
  const pending = [
2233
3527
  error
2234
3528
  ];
@@ -2240,7 +3534,33 @@ Legacy tasks/flow YAML and its CLI options belong to the midscene command.
2240
3534
  if (current instanceof AggregateError) pending.push(...current.errors);
2241
3535
  }
2242
3536
  };
2243
- 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
+ };
2244
3564
  const finalCases = new Map(result.cases.map((outcome)=>[
2245
3565
  outcome.caseId,
2246
3566
  outcome
@@ -2249,43 +3569,101 @@ Legacy tasks/flow YAML and its CLI options belong to the midscene command.
2249
3569
  if ('failed' !== outcome.status || !outcome.run) continue;
2250
3570
  const run = outcome.run;
2251
3571
  const source = `${run.projectName}/${run.sourcePath} / ${run.name}`;
2252
- for (const step of [
2253
- ...run.beforeEach,
2254
- ...run.steps,
2255
- ...run.afterEach
2256
- ])if (step.error) reportError(`${source} / ${step.phase}[${step.stepIndex + 1}] ${step.node}`, step.error);
2257
- for (const error of [
2258
- ...run.executionErrors ?? [],
2259
- ...run.teardownErrors ?? []
2260
- ])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
+ });
2261
3613
  }
2262
- const finalDocuments = new Map(result.documents.map((document)=>[
2263
- document.documentId,
2264
- document
2265
- ]));
2266
3614
  for (const document of finalDocuments.values()){
2267
3615
  if ('failed' !== document.status) continue;
2268
3616
  const source = `${document.projectName}/${document.sourcePath}`;
2269
- for (const step of [
2270
- ...document.beforeAll,
2271
- ...document.afterAll
2272
- ])if (step.error) reportError(`${source} / ${step.phase}[${step.stepIndex + 1}] ${step.node}`, step.error);
2273
- for (const error of [
2274
- ...document.executionErrors ?? [],
2275
- ...document.teardownErrors ?? []
2276
- ])reportError(source, error);
2277
- 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
+ });
2278
3649
  }
2279
3650
  for (const project of result.projects){
2280
- if (project.lifecycle?.setupError) reportError(`${project.name} / setup`, project.lifecycle.setupError);
2281
- 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
+ });
2282
3659
  }
2283
3660
  for (const error of infrastructureErrors)reportError('infrastructure', error);
2284
- io.log(`midscene-test: ${result.summary.passed}/${result.summary.total} cases passed, ${result.summary.failed} failed, ${result.summary.notRun} not run`);
2285
- io.log(`Results: ${result.resultDir}`);
3661
+ reporter.summary(result);
3662
+ if ('verbose' === reporter.mode) io.log(`Results: ${result.resultDir}`);
2286
3663
  io.log(`Summary: ${result.summaryPath}`);
2287
- if (result.markdownPath) io.log(`Markdown: ${result.markdownPath}`);
3664
+ if (result.markdownPath && 'verbose' === reporter.mode) io.log(`Markdown: ${result.markdownPath}`);
2288
3665
  if (result.reportPath) io.log(`Report: ${result.reportPath}`);
3666
+ writeGitHubSummary(result);
2289
3667
  return result.exitCode;
2290
3668
  };
2291
3669
  async function runTestCli(args, io = defaultCliIO) {
@@ -2315,18 +3693,32 @@ Legacy tasks/flow YAML and its CLI options belong to the midscene command.
2315
3693
  await runNodesCommand(options, io);
2316
3694
  return 0;
2317
3695
  }
2318
- 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);
2319
3699
  try {
2320
- result = await runTestProject({
2321
- ...options,
2322
- 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);
2323
3714
  });
2324
- } catch (error) {
2325
- if (!(error instanceof test_runner_namespaceObject.WorkflowExecutionFailure)) throw error;
2326
- reportTestProjectResult(error.result, io, error.errors);
2327
- return 1;
3715
+ } finally{
3716
+ try {
3717
+ reporter.dispose();
3718
+ } finally{
3719
+ restoreOutput?.();
3720
+ }
2328
3721
  }
2329
- return reportTestProjectResult(result, io);
2330
3722
  } catch (error) {
2331
3723
  io.error(formatCliError(error));
2332
3724
  return 1;