@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.
@@ -1,9 +1,13 @@
1
+ import * as __rspack_external__midscene_shared_env_0c37bb76 from "@midscene/shared/env";
2
+ import * as __rspack_external_chalk from "chalk";
1
3
  import * as __rspack_external_node_fs_5ea92f0c from "node:fs";
2
4
  import * as __rspack_external_node_path_c5b9b54f from "node:path";
3
- import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, getPendingScopeOperations, getResourceCleanupCompletion, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText, trackResourceOperation, waitForScopeOperations } from "@midscene/core/internal/test-runner";
4
- import { inspect } from "node:util";
5
+ import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, getPendingScopeOperations, getResourceCleanupCompletion, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText, sanitizeReportValue, sumTestRunReportMetrics, trackResourceOperation, waitForScopeOperations } from "@midscene/core/internal/test-runner";
6
+ import { getDebug, runWithConsoleLogHandler, runWithConsoleLogScope } from "@midscene/shared/logger";
7
+ import { inspect, stripVTControlCharacters } from "node:util";
8
+ import string_width from "string-width";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
10
  import { z } from "zod/v4";
6
- import { pathToFileURL } from "node:url";
7
11
  import { require as api_require } from "tsx/cjs/api";
8
12
  import { globSync } from "tinyglobby";
9
13
  import { createHash, randomUUID } from "node:crypto";
@@ -11,8 +15,13 @@ import { JSON_SCHEMA, load } from "js-yaml";
11
15
  import { mkdir, writeFile } from "node:fs/promises";
12
16
  import { setMaxListeners } from "node:events";
13
17
  import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
14
- import { getDebug } from "@midscene/shared/logger";
15
18
  var __webpack_modules__ = {
19
+ "@midscene/shared/env" (module) {
20
+ module.exports = __rspack_external__midscene_shared_env_0c37bb76;
21
+ },
22
+ chalk (module) {
23
+ module.exports = __rspack_external_chalk;
24
+ },
16
25
  "node:fs" (module) {
17
26
  module.exports = __rspack_external_node_fs_5ea92f0c;
18
27
  },
@@ -21,7 +30,7 @@ var __webpack_modules__ = {
21
30
  },
22
31
  "./package.json" (module) {
23
32
  module.exports = {
24
- rE: "1.16.1"
33
+ rE: "1.16.5"
25
34
  };
26
35
  }
27
36
  };
@@ -102,6 +111,1017 @@ const formatCliError = (error)=>'string' == typeof error ? error : inspect(error
102
111
  depth: null,
103
112
  customInspect: false
104
113
  });
114
+ function formatConciseError(error) {
115
+ const seen = new Set();
116
+ const messages = [];
117
+ let current = error;
118
+ while(current instanceof Error && !seen.has(current)){
119
+ seen.add(current);
120
+ if ('NODE_EXECUTION_ERROR' === current.code && void 0 !== current.cause) {
121
+ current = current.cause;
122
+ continue;
123
+ }
124
+ if (current.message && !messages.includes(current.message)) messages.push(current.message);
125
+ if (current instanceof AggregateError) {
126
+ for (const nested of current.errors)if (!(nested === current || seen.has(nested))) {
127
+ seen.add(nested);
128
+ messages.push(nested instanceof Error ? nested.message : String(nested));
129
+ }
130
+ }
131
+ current = current.cause;
132
+ }
133
+ if (!messages.length) return current instanceof Error ? current.message : formatCliError(current);
134
+ return messages.join('\nCaused by: ');
135
+ }
136
+ var external_chalk_ = __webpack_require__("chalk");
137
+ const oneLine = (value)=>stripVTControlCharacters(value).replace(/\s+/g, ' ').trim();
138
+ const shorten = (value, length)=>{
139
+ const text = oneLine(value);
140
+ return text.length <= length ? text : `${text.slice(0, length - 1)}…`;
141
+ };
142
+ function firstBusinessFrame(error, projectRoot) {
143
+ const seen = new Set();
144
+ const frames = [];
145
+ const visit = (value, depth = 0)=>{
146
+ if (!value || 'object' != typeof value || seen.has(value) || depth > 12) return;
147
+ seen.add(value);
148
+ const detail = value;
149
+ visit(detail.cause, depth + 1);
150
+ if (Array.isArray(detail.errors)) for (const child of detail.errors.slice(0, 10))visit(child, depth + 1);
151
+ if ('string' != typeof detail.stack) return;
152
+ for (const line of detail.stack.split('\n')){
153
+ if (!line.trim().startsWith('at ')) continue;
154
+ let location = line.trim().slice(3).replace(/^async /, '');
155
+ if (location.endsWith(')')) location = location.slice(location.lastIndexOf('(') + 1, -1);
156
+ const match = location.match(/^(.*):(\d+):(\d+)$/);
157
+ if (!match) continue;
158
+ let file = match[1];
159
+ if (file.startsWith('file://')) try {
160
+ file = fileURLToPath(file);
161
+ } catch {
162
+ continue;
163
+ }
164
+ const normalized = file.replace(/\\/g, '/');
165
+ if (!(normalized.startsWith('node:') || normalized.includes('/node_modules/') || /(?:^|\/)packages\/(?:core|test|shared|web-integration)\/(?:dist|src)\//.test(normalized)) && /\.[cm]?[jt]sx?$/.test(normalized)) frames.push(`${file}:${match[2]}:${match[3]}`);
166
+ }
167
+ };
168
+ visit(error);
169
+ const chosen = frames[0];
170
+ if (!chosen || !projectRoot) return chosen;
171
+ const match = chosen.match(/^(.*):(\d+):(\d+)$/);
172
+ const file = match[1];
173
+ const paths = /^[A-Za-z]:[\\/]/.test(file) ? external_node_path_.win32 : {
174
+ isAbsolute: external_node_path_.isAbsolute,
175
+ relative: external_node_path_.relative
176
+ };
177
+ const display = paths.isAbsolute(file) ? paths.relative(projectRoot, file) : file;
178
+ return `${display.replace(/\\/g, '/')}:${match[2]}:${match[3]}`;
179
+ }
180
+ function formatFailureInput(input) {
181
+ if (void 0 === input) return [];
182
+ const value = sanitizeReportValue(input).value;
183
+ const render = (value)=>shorten(sanitizeReportError({
184
+ message: 'string' == typeof value ? value : inspect(value, {
185
+ colors: false,
186
+ depth: 2,
187
+ maxArrayLength: 4,
188
+ maxStringLength: 180,
189
+ breakLength: 1 / 0,
190
+ customInspect: false,
191
+ getters: false
192
+ })
193
+ }).message, 160);
194
+ if (value && 'object' == typeof value && !Array.isArray(value)) {
195
+ const entries = Object.entries(value);
196
+ const rows = entries.slice(0, 4).map(([key, value])=>`${shorten(key, 48)}: ${render(value)}`);
197
+ if (entries.length > 4) rows.push(`… ${entries.length - 4} more fields (see Summary)`);
198
+ return rows;
199
+ }
200
+ return [
201
+ render(value)
202
+ ];
203
+ }
204
+ function formatFailureDetails(source, error, context = {}) {
205
+ const title = context.projectName ? `[${oneLine(context.projectName)}] ${oneLine(context.caseName ?? context.sourcePath ?? context.phase ?? source)}` : oneLine(source);
206
+ const body = [];
207
+ const field = (label, value)=>body.push(` ${label.padEnd(9)}${value}`);
208
+ const step = context.step;
209
+ if (step) {
210
+ const phase = 'steps' === step.phase ? '' : `${step.phase} `;
211
+ const index = step.stepIndex + 1;
212
+ const position = void 0 === context.stepCount ? String(index) : `${index}/${Math.max(index, context.stepCount)}`;
213
+ field('Step', `${phase}${position} · ${oneLine(step.node)}`);
214
+ } else if (context.phase) field('Phase', oneLine(context.phase));
215
+ if (context.sourcePath) {
216
+ const location = step?.meta.sourceLocation;
217
+ field('Location', `${oneLine(context.sourcePath)}${location ? `:${location.line}` : ''}`);
218
+ }
219
+ for (const [label, attempt] of [
220
+ [
221
+ 'Attempt',
222
+ context.attempt
223
+ ],
224
+ [
225
+ 'File try',
226
+ context.fileAttempt
227
+ ]
228
+ ])if (attempt && (attempt.total > 1 || attempt.index > 0)) field(label, `${attempt.index + 1}/${attempt.total}`);
229
+ if (body.length) body.push('');
230
+ const safeMessage = sanitizeReportError({
231
+ message: formatConciseError(error)
232
+ }).message;
233
+ const lines = stripVTControlCharacters(safeMessage).split(/\r?\n/);
234
+ body.push(` Error: ${shorten(lines[0], 320)}`, ...lines.slice(1, 6).map((line)=>` ${shorten(line, 320)}`));
235
+ if (lines.length > 6) body.push(' … (use --reporter verbose for details)');
236
+ const input = step ? formatFailureInput(step.input) : [];
237
+ if (input.length) {
238
+ body.push('');
239
+ input.forEach((line, index)=>field(0 === index ? 'Input' : '', line));
240
+ }
241
+ const frame = firstBusinessFrame(error, context.projectRoot);
242
+ if (frame) body.push('', ` at ${oneLine(frame)}`);
243
+ return {
244
+ title,
245
+ body
246
+ };
247
+ }
248
+ var env_ = __webpack_require__("@midscene/shared/env");
249
+ function _define_property(obj, key, value) {
250
+ if (key in obj) Object.defineProperty(obj, key, {
251
+ value: value,
252
+ enumerable: true,
253
+ configurable: true,
254
+ writable: true
255
+ });
256
+ else obj[key] = value;
257
+ return obj;
258
+ }
259
+ function configuredModelNames() {
260
+ const env = env_.globalConfigManager.getAllEnvConfig();
261
+ const defaultName = env.MIDSCENE_MODEL_NAME;
262
+ const models = new Map();
263
+ for (const [intent, name] of [
264
+ [
265
+ 'default',
266
+ defaultName
267
+ ],
268
+ [
269
+ 'planning',
270
+ env.MIDSCENE_PLANNING_MODEL_NAME || defaultName
271
+ ],
272
+ [
273
+ 'insight',
274
+ env.MIDSCENE_INSIGHT_MODEL_NAME || defaultName
275
+ ]
276
+ ]){
277
+ if (!name) continue;
278
+ const intents = models.get(name) ?? [];
279
+ intents.push(intent);
280
+ models.set(name, intents);
281
+ }
282
+ return [
283
+ ...models
284
+ ].map(([name, intents])=>({
285
+ name,
286
+ intents
287
+ }));
288
+ }
289
+ class CliModelUsage {
290
+ add(step) {
291
+ for (const trace of step.report?.traces ?? [])if (trace.metrics) {
292
+ this.executions.set(trace.executionId, trace.metrics);
293
+ this.missing.delete(trace.executionId);
294
+ } else if (!this.executions.has(trace.executionId)) this.missing.add(trace.executionId);
295
+ }
296
+ snapshot() {
297
+ if (!this.executions.size && !this.missing.size) return;
298
+ return sumTestRunReportMetrics([
299
+ ...this.executions.values()
300
+ ], this.missing.size ? [
301
+ 'Some execution usage has not been recorded.'
302
+ ] : []);
303
+ }
304
+ constructor(){
305
+ _define_property(this, "executions", new Map());
306
+ _define_property(this, "missing", new Set());
307
+ }
308
+ }
309
+ function formatTokenCount(value) {
310
+ if (value < 1000) return String(Math.round(value));
311
+ if (value < 1000000) return `${(value / 1000).toFixed(1)}k`;
312
+ return `${(value / 1000000).toFixed(1)}m`;
313
+ }
314
+ function formatModelUsage(metrics) {
315
+ if (!metrics) return [
316
+ '—'
317
+ ];
318
+ if (!metrics.models?.length) {
319
+ const name = metrics.modelCallCount || metrics.collection?.status === 'unknown' ? 'unknown' : '—';
320
+ return [
321
+ `${name} · ${formatTokenUsage(metrics)}`
322
+ ];
323
+ }
324
+ return metrics.models.map((model)=>{
325
+ const name = stripVTControlCharacters(model.modelName).replace(/\s+/g, ' ').trim() || 'unknown';
326
+ const usage = formatTokenUsage({
327
+ ...metrics,
328
+ modelCallCount: model.callCount,
329
+ promptTokens: model.promptTokens,
330
+ cachedInputTokens: model.cachedInputTokens,
331
+ completionTokens: model.completionTokens,
332
+ totalTokens: model.totalTokens
333
+ });
334
+ return `${name} · ${usage}`;
335
+ });
336
+ }
337
+ function formatTokenUsage(metrics, includeCached = true) {
338
+ if (!metrics) return '—';
339
+ const state = (field)=>metrics.collection?.fields?.[field] ?? metrics.collection?.status ?? 'complete';
340
+ if ('unknown' === state('totalTokens')) return '— (usage unavailable)';
341
+ if (!metrics.modelCallCount && !metrics.totalTokens && 'complete' === state('totalTokens')) return '0 (no model calls)';
342
+ const count = (field)=>'unknown' === state(field) ? '—' : `${'partial' === state(field) ? '≥' : ''}${formatTokenCount(metrics[field])}`;
343
+ return [
344
+ `${count('totalTokens')} total${'partial' === state('totalTokens') ? ' (partial)' : ''}`,
345
+ `${count('promptTokens')} input${includeCached && metrics.cachedInputTokens ? ` (${formatTokenCount(metrics.cachedInputTokens)} cached)` : ''}`,
346
+ `${count('completionTokens')} output`
347
+ ].join(' · ');
348
+ }
349
+ function terminal_progress_define_property(obj, key, value) {
350
+ if (key in obj) Object.defineProperty(obj, key, {
351
+ value: value,
352
+ enumerable: true,
353
+ configurable: true,
354
+ writable: true
355
+ });
356
+ else obj[key] = value;
357
+ return obj;
358
+ }
359
+ const graphemes = new Intl.Segmenter(void 0, {
360
+ granularity: 'grapheme'
361
+ });
362
+ const SYNC_START = '\u001b[?2026h';
363
+ const SYNC_END = '\u001b[?2026l';
364
+ const truncateTerminalLine = (text, columns)=>{
365
+ if (columns <= 0) return '';
366
+ if (string_width(text) <= columns) return text;
367
+ let output = '';
368
+ let width = 0;
369
+ for (const { segment } of graphemes.segment(stripVTControlCharacters(text))){
370
+ width += string_width(segment);
371
+ if (width > columns - 1) break;
372
+ output += segment;
373
+ }
374
+ return `${output}…`;
375
+ };
376
+ class TerminalProgress {
377
+ erase() {
378
+ if (!this.lines.length) return '';
379
+ const columns = Math.max(1, this.io.columns ?? 100);
380
+ const occupiedRows = this.lines.reduce((sum, line)=>sum + Math.max(1, Math.ceil(string_width(line) / columns)), 0);
381
+ const rows = Math.min(occupiedRows, Math.max(0, (this.io.rows ?? 24) - 1));
382
+ this.lines = [];
383
+ return `\r${rows ? `\u001b[${rows}A` : ''}\u001b[0J`;
384
+ }
385
+ frame(previousHeight = 0) {
386
+ const availableRows = Math.max(0, (this.io.rows ?? 24) - 2);
387
+ const columns = Math.max(0, (this.io.columns ?? 100) - 1);
388
+ if (!availableRows || !columns) return '';
389
+ const footer = this.footer.slice(0, availableRows);
390
+ const budget = availableRows - footer.length;
391
+ const overflow = this.content.length > budget;
392
+ const visible = Math.max(0, budget - (overflow ? 1 : 0));
393
+ const body = this.content.slice(0, visible);
394
+ if (overflow && budget > 0) body.push(` … ${this.content.length - visible} more rows`);
395
+ const lines = [
396
+ ...body,
397
+ ...footer
398
+ ].map((line)=>truncateTerminalLine(line, columns));
399
+ const blanks = Math.max(0, Math.min(previousHeight, availableRows) - lines.length);
400
+ this.lines = [
401
+ ...Array.from({
402
+ length: blanks
403
+ }, ()=>''),
404
+ ...lines
405
+ ];
406
+ return this.lines.length ? `${this.lines.join('\n')}\n` : '';
407
+ }
408
+ render(content, footer) {
409
+ this.update(content, footer);
410
+ const previousHeight = this.lines.length;
411
+ const erase = this.erase();
412
+ this.io.write?.(`${SYNC_START}${erase}${this.frame(previousHeight)}${SYNC_END}`);
413
+ }
414
+ update(content, footer) {
415
+ this.content = content;
416
+ this.footer = footer;
417
+ }
418
+ output(message, write) {
419
+ this.io.write?.(SYNC_START);
420
+ try {
421
+ const erase = this.erase();
422
+ if (erase) this.io.write?.(erase);
423
+ write(message.endsWith('\n') ? message : `${message}\n`);
424
+ const frame = this.frame();
425
+ if (frame) this.io.write?.(frame);
426
+ } finally{
427
+ this.io.write?.(SYNC_END);
428
+ }
429
+ }
430
+ clear() {
431
+ const erase = this.erase();
432
+ this.content = [];
433
+ this.footer = [];
434
+ if (erase) this.io.write?.(`${SYNC_START}${erase}${SYNC_END}`);
435
+ }
436
+ constructor(io){
437
+ terminal_progress_define_property(this, "io", void 0);
438
+ terminal_progress_define_property(this, "lines", void 0);
439
+ terminal_progress_define_property(this, "content", void 0);
440
+ terminal_progress_define_property(this, "footer", void 0);
441
+ this.io = io;
442
+ this.lines = [];
443
+ this.content = [];
444
+ this.footer = [];
445
+ }
446
+ }
447
+ const warning_format_oneLine = (text)=>stripVTControlCharacters(text).replace(/\s+/g, ' ').trim();
448
+ function formatWarningScope(scope) {
449
+ if (!scope) return '';
450
+ const parts = [
451
+ `[${warning_format_oneLine(scope.projectName)}]`
452
+ ];
453
+ if (scope.sourcePath) parts.push(warning_format_oneLine(scope.sourcePath));
454
+ if (scope.caseName) parts.push(warning_format_oneLine(scope.caseName));
455
+ if (scope.phase) {
456
+ const phase = 'steps' === scope.phase ? 'step' : scope.phase;
457
+ parts.push(`${phase}${void 0 === scope.stepIndex ? '' : `[${scope.stepIndex + 1}]`}${scope.node ? ` ${warning_format_oneLine(scope.node)}` : ''}`);
458
+ }
459
+ return `${parts.join(' › ')} · `;
460
+ }
461
+ function errorMessage(error) {
462
+ const seen = new Set();
463
+ let current = error;
464
+ let message = '';
465
+ let status;
466
+ while(current && 'object' == typeof current && !seen.has(current)){
467
+ seen.add(current);
468
+ const detail = current;
469
+ if ('string' == typeof detail.message) message = detail.message;
470
+ if ('number' == typeof detail.status) status = detail.status;
471
+ current = detail.cause;
472
+ }
473
+ const firstLine = warning_format_oneLine((message || String(error)).split(/\r?\n/)[0]);
474
+ return status && !firstLine.startsWith(String(status)) ? `${status}: ${firstLine}` : firstLine;
475
+ }
476
+ function formatWarning(entry) {
477
+ const details = entry.warning;
478
+ let message;
479
+ if (details?.kind === 'model-request-retry') message = `model request retry ${details.attempt + 1}/${details.maxAttempts} in ${details.delayMs / 1000}s (${warning_format_oneLine(details.modelName)}): ${errorMessage(details.error)}`;
480
+ else {
481
+ const error = entry.args?.find((arg)=>arg instanceof Error);
482
+ const prefix = 'string' == typeof entry.args?.[0] ? warning_format_oneLine(entry.args[0].split(/\r?\n/)[0]).replace(/:\s*$/, '') : '';
483
+ message = error ? `${prefix ? `${prefix}: ` : ''}${errorMessage(error)}` : warning_format_oneLine(entry.message.split(/\r?\n/)[0]);
484
+ message = message.replace(/^\[midscene:warning\]\s*/, '');
485
+ }
486
+ if (message.startsWith('waitForNetworkIdle is skipped for Playwright.')) message = 'Playwright: post-action network-idle wait skipped.';
487
+ return message.length > 240 ? `${message.slice(0, 239)}…` : message;
488
+ }
489
+ function warningKey(entry, message) {
490
+ const details = entry.warning;
491
+ if (details?.kind === 'model-request-retry') return `${entry.topic}\n${JSON.stringify(entry.scope)}\n${details.callId}\n${details.attempt}`;
492
+ const scope = entry.args?.some((arg)=>arg instanceof Error) ? JSON.stringify(entry.scope) : '';
493
+ return `${entry.topic}\n${scope}\n${message}`;
494
+ }
495
+ function terminal_reporter_define_property(obj, key, value) {
496
+ if (key in obj) Object.defineProperty(obj, key, {
497
+ value: value,
498
+ enumerable: true,
499
+ configurable: true,
500
+ writable: true
501
+ });
502
+ else obj[key] = value;
503
+ return obj;
504
+ }
505
+ const terminal_reporter_oneLine = (value)=>stripVTControlCharacters(value).replace(/\s+/g, ' ').trim();
506
+ const formatDuration = (ms)=>{
507
+ if (ms < 1000) return `${Math.round(ms)}ms`;
508
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
509
+ return `${Math.floor(ms / 60000)}m ${Math.floor(ms % 60000 / 1000)}s`;
510
+ };
511
+ const identity = (project, path, name)=>`[${terminal_reporter_oneLine(project)}] ${terminal_reporter_oneLine(path)}${name ? ` › ${terminal_reporter_oneLine(name)}` : ''}`;
512
+ const projectPhaseLabel = (phase)=>'setup' === phase ? 'setting up' : 'cleaning up';
513
+ const fileStatus = (status, cases)=>{
514
+ if ('failed' === status || cases.some((item)=>'failed' === item.status)) return 'failed';
515
+ if (cases.length && cases.every((item)=>'not-run' === item.status)) return 'not-run';
516
+ if (cases.some((item)=>'not-run' === item.status)) return 'failed';
517
+ return status ?? (cases.length ? 'success' : 'not-run');
518
+ };
519
+ class TerminalReporter {
520
+ get interactive() {
521
+ return !!this.io.isTTY && !!this.io.write && 'default' === this.mode;
522
+ }
523
+ log(message) {
524
+ if (this.interactive) this.output(`${message}\n`, (text)=>this.io.write(text));
525
+ else this.io.log(message);
526
+ this.lastOutput = Date.now();
527
+ }
528
+ output(message, write) {
529
+ this.renderProgress(false);
530
+ this.progressView.output(message, write);
531
+ this.lastOutput = Date.now();
532
+ }
533
+ progress(message) {
534
+ if ('verbose' === this.mode) this.log(message);
535
+ }
536
+ console(entry) {
537
+ if ('report' === entry.type) {
538
+ if ('verbose' === this.mode) this.log(entry.message);
539
+ return;
540
+ }
541
+ const scope = formatWarningScope(entry.scope);
542
+ if ('verbose' === this.mode) {
543
+ const kind = entry.warning?.kind === 'model-request-retry' ? 'model request retry · ' : entry.warning?.kind === 'model-request-detail' ? 'model request error · ' : '';
544
+ this.log(`WARN ${scope}${kind}${entry.message}`);
545
+ return;
546
+ }
547
+ if (entry.warning?.kind === 'model-request-detail') return;
548
+ const message = formatWarning(entry);
549
+ const key = warningKey(entry, message);
550
+ if (this.warnings.has(key)) return;
551
+ this.warnings.set(key, {
552
+ scope,
553
+ message
554
+ });
555
+ if (this.interactive) {
556
+ let attached = false;
557
+ if (entry.warning?.kind === 'model-request-retry' && entry.scope) {
558
+ const owner = entry.scope;
559
+ for (const [id, task] of this.active)if (task.file.projectName === owner.projectName && (!owner.sourcePath || task.file.sourcePath === owner.sourcePath) && (!owner.caseId || id === owner.caseId) && (!owner.runId || task.runId === owner.runId) && (!owner.phase || task.phase === owner.phase) && (void 0 === owner.stepIndex || task.stepIndex === owner.stepIndex)) {
560
+ task.warning = message;
561
+ attached = true;
562
+ }
563
+ }
564
+ if (!attached) this.latestNotice = message.startsWith('Playwright:') ? 'Playwright: network-idle wait skipped.' : `${scope}${message}`;
565
+ this.schedule();
566
+ return;
567
+ }
568
+ this.log(`${this.color.yellow('WARN')} ${this.color.dim(scope)}${message}`);
569
+ }
570
+ file(projectName, sourcePath, total = 0) {
571
+ const key = `${projectName}\0${sourcePath}`;
572
+ let file = this.files.get(key);
573
+ if (!file) {
574
+ file = {
575
+ projectName,
576
+ sourcePath,
577
+ total,
578
+ outcomes: new Map(),
579
+ attemptOutcomes: new Map(),
580
+ started: false,
581
+ printed: false,
582
+ durationMs: 0,
583
+ retries: 0,
584
+ completionOrder: 0
585
+ };
586
+ this.files.set(key, file);
587
+ }
588
+ file.total = Math.max(file.total, total);
589
+ return file;
590
+ }
591
+ event(event) {
592
+ if ('run-start' === event.type) {
593
+ this.total = event.cases;
594
+ this.totalFiles = event.documents;
595
+ this.startedAt = event.startedAt ? Date.parse(event.startedAt) : Date.now();
596
+ for (const file of event.files ?? [])this.file(file.projectName, file.sourcePath, file.cases);
597
+ const c = this.color;
598
+ this.log(`\n ${c.bgGreen.black.bold(' RUN ')} ${c.green(`midscene-test v${package_0.rE}`)} ${c.dim(terminal_reporter_oneLine(event.projectRoot ?? process.cwd()))}`);
599
+ this.log(` ${c.dim(`${event.projects} projects · ${event.documents} files · ${event.cases} cases`)}`);
600
+ if (void 0 !== event.maxConcurrentProjects && void 0 !== event.maxConcurrentFiles) this.log(` ${c.dim(`Concurrency ${event.maxConcurrentProjects ?? 'unlimited'} projects · ${event.maxConcurrentFiles ?? 'unlimited'} files (global limits)`)}`);
601
+ if (event.configPath) this.log(` ${c.dim(`Config ${terminal_reporter_oneLine(event.configPath)}`)}`);
602
+ for (const model of event.configuredModels ?? [])this.log(` ${c.dim('Models (env)')} ${terminal_reporter_oneLine(model.name)} ${c.dim(`· ${model.intents.map(terminal_reporter_oneLine).join(', ')}`)}`);
603
+ this.log('');
604
+ if ('default' === this.mode) {
605
+ this.timer = setInterval(()=>this.tick(), this.interactive ? 1000 : 30000);
606
+ this.timer.unref();
607
+ if (this.interactive) this.removeResizeListener = this.io.onResize?.(()=>this.renderProgress());
608
+ this.renderProgress();
609
+ }
610
+ } else if ('project-end' === event.type) {
611
+ this.completeProject(event.project);
612
+ this.schedule();
613
+ } else if ('project-phase' === event.type) {
614
+ if ('running' === event.phase) this.projectPhases.delete(event.projectName);
615
+ else this.projectPhases.set(event.projectName, {
616
+ phase: event.phase,
617
+ since: Date.now()
618
+ });
619
+ this.schedule();
620
+ } else if ('document-start' === event.type) {
621
+ this.file(event.projectName, event.sourcePath, event.cases).started = true;
622
+ this.schedule();
623
+ } else if ('document-end' === event.type) {
624
+ const file = this.file(event.document.projectName, event.document.sourcePath, event.cases.length);
625
+ file.status = fileStatus(event.document.status, event.cases);
626
+ file.started = true;
627
+ file.completionOrder = ++this.completionOrder;
628
+ file.durationMs = event.durationMs;
629
+ file.retries = event.document.attemptIndex ?? 0;
630
+ file.outcomes = new Map(event.cases.map((outcome)=>[
631
+ outcome.caseId,
632
+ outcome
633
+ ]));
634
+ file.attemptOutcomes.clear();
635
+ for (const [key, task] of this.active)if (task.file === file) this.active.delete(key);
636
+ if (!this.interactive) this.printFile(file);
637
+ this.schedule();
638
+ } else if ('step-start' === event.type) this.startStep(event.projectName, event.info);
639
+ else if ('step-end' === event.type) {
640
+ if (event.result) this.usage.add(event.result);
641
+ const key = 'case' === event.info.scope ? event.info.case.caseId : event.info.document.documentId;
642
+ const task = this.active.get(key);
643
+ if (task) if ('document' === event.info.scope) this.active.delete(key);
644
+ else {
645
+ task.step = void 0;
646
+ task.warning = void 0;
647
+ }
648
+ this.schedule();
649
+ } else if ('case-attempt-end' === event.type) this.attempts.set(event.attempt.caseId, {
650
+ run: event.attempt,
651
+ max: event.maxAttempts
652
+ });
653
+ else if ('case-end' === event.type || 'case-progress' === event.type) {
654
+ const file = this.file(event.outcome.projectName, event.outcome.sourcePath);
655
+ if ('case-progress' === event.type) {
656
+ file.attemptOutcomes.set(event.outcome.caseId, event.outcome);
657
+ file.retries = Math.max(file.retries, event.documentAttemptIndex);
658
+ } else {
659
+ file.attemptOutcomes.delete(event.outcome.caseId);
660
+ file.outcomes.set(event.outcome.caseId, event.outcome);
661
+ file.retries = Math.max(file.retries, event.documentRetries ?? 0);
662
+ }
663
+ this.active.delete(event.outcome.caseId);
664
+ this.schedule();
665
+ } else if ('document-retry' === event.type) {
666
+ const file = this.file(event.document.projectName, event.document.sourcePath);
667
+ file.outcomes.clear();
668
+ file.attemptOutcomes.clear();
669
+ file.status = void 0;
670
+ file.completionOrder = 0;
671
+ for (const [key, task] of this.active)if (task.file === file) this.active.delete(key);
672
+ this.log(this.color.yellow(` ↻ ${identity(file.projectName, file.sourcePath)} · file retry ${(event.document.attemptIndex ?? 0) + 2}/${event.maxAttempts}`));
673
+ this.schedule();
674
+ }
675
+ }
676
+ startStep(projectName, info) {
677
+ const position = 'case' === info.scope ? info.case : info.document;
678
+ const file = this.file(projectName, position.sourcePath);
679
+ file.started = true;
680
+ const key = 'case' === info.scope ? info.case.caseId : info.document.documentId;
681
+ const attempt = 'case' === info.scope ? info.case.attemptIndex : 0;
682
+ const previous = this.attempts.get(key);
683
+ if (previous && previous.run.attemptIndex < attempt) {
684
+ this.attempts.delete(key);
685
+ if ('default' === this.mode && !this.interactive) this.log(this.color.yellow(` ↻ ${identity(projectName, position.sourcePath, 'case' === info.scope ? info.case.name : void 0)} · case retry · attempt ${attempt + 1}/${previous.max}`));
686
+ }
687
+ const existing = this.active.get(key);
688
+ const now = Date.now();
689
+ this.active.set(key, {
690
+ file,
691
+ name: 'case' === info.scope ? info.case.name : void 0,
692
+ step: `${'steps' === position.phase ? 'step' : position.phase} ${position.stepIndex + 1}/${info.stepCount} · ${terminal_reporter_oneLine(info.node)}`,
693
+ since: existing?.attempt === attempt ? existing.since : now,
694
+ attempt,
695
+ maxAttempts: previous?.max ?? existing?.maxAttempts,
696
+ runId: 'case' === info.scope ? info.case.runId : info.document.documentRunId,
697
+ phase: position.phase,
698
+ stepIndex: position.stepIndex,
699
+ caseIndex: 'case' === info.scope ? info.case.caseIndex : void 0
700
+ });
701
+ this.schedule();
702
+ }
703
+ schedule() {
704
+ if (!this.interactive || !this.timer || this.scheduled) return;
705
+ this.scheduled = setTimeout(()=>{
706
+ this.scheduled = void 0;
707
+ this.renderProgress();
708
+ }, 100);
709
+ this.scheduled.unref();
710
+ }
711
+ counts(items, total) {
712
+ const failed = items.filter((item)=>'failed' === item.status).length;
713
+ const passed = items.filter((item)=>'success' === item.status).length;
714
+ const notRun = items.filter((item)=>'not-run' === item.status).length;
715
+ return this.stateCounts(passed, failed, notRun, total);
716
+ }
717
+ stateCounts(passed, failed, notRun, total) {
718
+ const c = this.color;
719
+ return [
720
+ failed ? c.red.bold(`${failed} failed`) : '',
721
+ c.green.bold(`${passed} passed`),
722
+ notRun ? c.yellow(`${notRun} not run`) : ''
723
+ ].filter(Boolean).join(c.dim(' | ')) + c.dim(` (${total})`);
724
+ }
725
+ footer(durationMs = Date.now() - this.startedAt, result) {
726
+ const files = [
727
+ ...this.files.values()
728
+ ];
729
+ const cases = files.flatMap((file)=>[
730
+ ...file.outcomes.values()
731
+ ]);
732
+ const c = this.color;
733
+ const metrics = result ? this.finalMetrics : this.usage.snapshot();
734
+ return [
735
+ ` ${c.dim('Test Files')} ${this.counts(files, this.totalFiles)}`,
736
+ ` ${c.dim(' Cases')} ${result ? this.stateCounts(result.summary.passed, result.summary.failed, result.summary.notRun, result.summary.total) : this.counts(cases, this.total)}`,
737
+ ` ${c.dim(' Start at')} ${new Date(this.startedAt).toLocaleTimeString('en-GB', {
738
+ hour12: false
739
+ })}`,
740
+ ` ${c.dim(' Duration')} ${formatDuration(durationMs)}`,
741
+ ...formatModelUsage(metrics).map((model, index)=>` ${c.dim(index ? ' ' : ' Models:')} ${model}`),
742
+ ...!result || this.warnings.size ? [
743
+ ` ${c.dim(' Warnings')} ${this.warnings.size ? c.yellow(String(this.warnings.size)) : c.dim('0')}${!result && this.latestNotice ? c.dim(` · ${this.latestNotice}`) : ''}`
744
+ ] : []
745
+ ];
746
+ }
747
+ renderProgress(render = true) {
748
+ if (!this.interactive || !this.timer) return;
749
+ const projects = new Map();
750
+ const files = [
751
+ ...this.files.values()
752
+ ].filter((file)=>file.started && !file.printed).sort((a, b)=>a.projectName.localeCompare(b.projectName) || a.sourcePath.localeCompare(b.sourcePath));
753
+ for (const file of files){
754
+ const group = projects.get(file.projectName) ?? [];
755
+ group.push(file);
756
+ projects.set(file.projectName, group);
757
+ }
758
+ for (const projectName of this.projectPhases.keys())if (!projects.has(projectName)) projects.set(projectName, []);
759
+ const footer = this.footer();
760
+ const budget = Math.max(0, (this.io.rows ?? 24) - 2 - footer.length - 1);
761
+ let lines = this.liveTree(projects, 'full');
762
+ if (lines.length > budget) lines = this.liveTree(projects, 'recent');
763
+ if (lines.length > budget) lines = this.liveTree(projects, 'compact');
764
+ if (lines.length > budget) lines = this.liveTree(projects, 'active');
765
+ if (lines.length) lines.push('');
766
+ if (render) this.progressView.render(lines, footer);
767
+ else this.progressView.update(lines, footer);
768
+ }
769
+ liveTree(projects, mode) {
770
+ const c = this.color;
771
+ const lines = [];
772
+ const groups = [
773
+ ...projects
774
+ ];
775
+ const isActive = ([projectName, files])=>this.projectPhases.has(projectName) || files.some((file)=>!file.status);
776
+ if ('full' !== mode) groups.sort((a, b)=>Number(isActive(b)) - Number(isActive(a)));
777
+ for (const [projectName, files] of groups){
778
+ const phase = this.projectPhases.get(projectName);
779
+ const running = files.filter((file)=>!file.status);
780
+ const completed = files.filter((file)=>file.status);
781
+ const recent = [
782
+ ...completed
783
+ ].sort((a, b)=>b.completionOrder - a.completionOrder).slice(0, 'recent' === mode ? 1 : 0);
784
+ const visible = 'full' === mode ? files : [
785
+ ...running,
786
+ ...recent
787
+ ];
788
+ const hidden = completed.filter((file)=>!visible.includes(file));
789
+ const hiddenFailures = hidden.filter((file)=>'failed' === file.status).length;
790
+ const details = [
791
+ phase ? c.yellow(`${projectPhaseLabel(phase.phase)} ${formatDuration(Date.now() - phase.since)}`) : '',
792
+ c.yellow(`${running.length} ${1 === running.length ? 'file' : 'files'} running`),
793
+ completed.length ? c.dim(`${completed.length} completed`) : '',
794
+ 'active' === mode && hiddenFailures ? c.red(`${hiddenFailures} failed`) : ''
795
+ ].filter(Boolean);
796
+ lines.push(this.liveRow(` ${c.yellow('❯')} ${c.bold(terminal_reporter_oneLine(projectName))}`, c.dim(' · ') + details.join(c.dim(' · '))));
797
+ for (const [fileIndex, file] of visible.entries()){
798
+ const lastFile = fileIndex === visible.length - 1 && (!hidden.length || 'active' === mode);
799
+ const stem = lastFile ? ' ' : ` ${c.dim('│')} `;
800
+ const outcomes = [
801
+ ...new Map([
802
+ ...file.attemptOutcomes,
803
+ ...file.outcomes
804
+ ]).values()
805
+ ].sort((a, b)=>a.caseIndex - b.caseIndex);
806
+ const failedCases = outcomes.filter((outcome)=>'failed' === outcome.status).length;
807
+ lines.push(this.liveRow(` ${c.dim(lastFile ? '└─' : '├─')} ${file.status ? `${this.statusSymbol(file.status)} ` : ''}${terminal_reporter_oneLine(file.sourcePath)}`, ` ${c.dim(`${outcomes.length}/${file.total} cases`)}${file.status ? c.dim(` ${formatDuration(file.durationMs)}`) : ''}${'active' === mode && failedCases ? c.red(` · ${failedCases} failed`) : ''}`));
808
+ const tasks = [
809
+ ...this.active.values()
810
+ ].filter((task)=>task.file === file);
811
+ const shown = 'full' === mode ? outcomes : 'recent' === mode ? outcomes.slice(-1) : [];
812
+ const children = [
813
+ ...tasks.map((task)=>({
814
+ task
815
+ })),
816
+ ...shown.map((outcome)=>({
817
+ outcome
818
+ }))
819
+ ];
820
+ if ('full' === mode) children.sort((a, b)=>this.liveChildIndex(a) - this.liveChildIndex(b));
821
+ const hiddenCases = outcomes.filter((outcome)=>!shown.includes(outcome));
822
+ if (hiddenCases.length && 'active' !== mode) {
823
+ const failed = hiddenCases.filter((outcome)=>'failed' === outcome.status).length;
824
+ children.push({
825
+ summary: `… ${hiddenCases.length} completed ${1 === hiddenCases.length ? 'case' : 'cases'}${failed ? ` · ${failed} failed` : ''}`,
826
+ failed: failed > 0
827
+ });
828
+ }
829
+ for (const [index, child] of children.entries()){
830
+ const last = index === children.length - 1;
831
+ const prefix = `${stem}${c.dim(last ? '└─' : '├─')} `;
832
+ if ('outcome' in child) {
833
+ lines.push(this.liveRow(`${prefix}${this.formatCaseResult(child.outcome, file.retries)}`, ''));
834
+ continue;
835
+ }
836
+ if ('summary' in child) {
837
+ lines.push(this.liveRow(`${prefix}${child.failed ? c.red(child.summary) : c.dim(child.summary)}`, ''));
838
+ continue;
839
+ }
840
+ const { task } = child;
841
+ const hint = task.name && task.step ? truncateTerminalLine(` · ${task.step}`, Math.floor(Math.max(0, (this.io.columns ?? 100) - 1) / 2)) : '';
842
+ const retry = task.attempt ? ` · attempt ${task.attempt + 1}${task.maxAttempts ? `/${task.maxAttempts}` : ''}` : '';
843
+ lines.push(this.liveRow(`${prefix}${terminal_reporter_oneLine(task.name ?? task.step ?? 'file lifecycle')}`, `${c.dim(hint)} ${c.yellow(formatDuration(Date.now() - task.since))}${c.yellow(retry)}`));
844
+ if (task.warning) lines.push(this.liveRow(`${stem}${last ? ' ' : `${c.dim('│')} `}${c.dim('└─')} ${c.yellow(`⚠ ${task.warning}`)}`, ''));
845
+ }
846
+ }
847
+ if (hidden.length && 'active' !== mode) {
848
+ const summary = `… ${hidden.length} completed ${1 === hidden.length ? 'file' : 'files'}${hiddenFailures ? ` · ${hiddenFailures} failed` : ''}`;
849
+ lines.push(this.liveRow(` ${c.dim('└─')} ${hiddenFailures ? c.red(summary) : c.dim(summary)}`, ''));
850
+ }
851
+ }
852
+ return lines;
853
+ }
854
+ liveChildIndex(child) {
855
+ if ('outcome' in child) return child.outcome.caseIndex;
856
+ if ('summary' in child) return Number.MAX_SAFE_INTEGER;
857
+ return child.task.caseIndex ?? ('beforeAll' === child.task.phase ? -1 : Number.MAX_SAFE_INTEGER);
858
+ }
859
+ liveRow(label, suffix) {
860
+ const columns = Math.max(0, (this.io.columns ?? 100) - 1);
861
+ const tail = truncateTerminalLine(suffix, columns);
862
+ return truncateTerminalLine(label, columns - string_width(tail)) + tail;
863
+ }
864
+ tick() {
865
+ if (this.interactive) return void this.renderProgress();
866
+ if ((this.active.size || this.projectPhases.size) && Date.now() - this.lastOutput >= 30000) this.log(`… Still running: ${[
867
+ ...[
868
+ ...this.projectPhases
869
+ ].map(([projectName, phase])=>`[${terminal_reporter_oneLine(projectName)}] · ${projectPhaseLabel(phase.phase)} (${formatDuration(Date.now() - phase.since)})`),
870
+ ...[
871
+ ...this.active.values()
872
+ ].map((task)=>`${identity(task.file.projectName, task.file.sourcePath, task.name)} · ${task.step ?? 'between steps'} (${formatDuration(Date.now() - task.since)})`)
873
+ ].join(' | ')}`);
874
+ }
875
+ formatCaseResult(outcome, documentRetries) {
876
+ const c = this.color;
877
+ const attempts = outcome.attempts ?? (outcome.run ? [
878
+ outcome.run
879
+ ] : []);
880
+ const retries = Math.max(attempts.length - 1, outcome.run?.attemptIndex ?? 0);
881
+ const duration = formatDuration(attempts.reduce((sum, attempt)=>sum + attempt.durationMs, 0));
882
+ const symbol = 'success' === outcome.status ? c.green('✓') : 'failed' === outcome.status ? c.red('×') : c.yellow('–');
883
+ const detail = 'not-run' === outcome.status ? c.dim(` · ${outcome.notRunReason ?? 'not run'}`) : ` ${c.dim(duration)}${retries ? c.yellow(` · ${'success' === outcome.status ? 'passed after retry' : 'failed after retries'} (${retries + 1} attempts)`) : documentRetries ? c.yellow(` · file retried ${documentRetries}x`) : ''}`;
884
+ return `${symbol} ${terminal_reporter_oneLine(outcome.name)}${detail}`;
885
+ }
886
+ statusSymbol(status) {
887
+ return 'failed' === status ? this.color.red('×') : 'not-run' === status ? this.color.yellow('–') : this.color.green('✓');
888
+ }
889
+ resultFileLines(file, lastFile) {
890
+ const c = this.color;
891
+ const stem = lastFile ? ' ' : ` ${c.dim('│')} `;
892
+ const cases = [
893
+ ...file.outcomes.values()
894
+ ].sort((a, b)=>a.caseIndex - b.caseIndex);
895
+ const failed = cases.filter((item)=>'failed' === item.status).length;
896
+ const notRun = cases.filter((item)=>'not-run' === item.status).length;
897
+ const stats = `(${cases.length} cases${failed ? ` | ${failed} failed` : ''}${notRun ? ` | ${notRun} not run` : ''})`;
898
+ return [
899
+ ` ${c.dim(lastFile ? '└─' : '├─')} ${this.statusSymbol(file.status)} ${c.bold(terminal_reporter_oneLine(file.sourcePath))} ${c.dim(stats)} ${c.dim(formatDuration(file.durationMs))}`,
900
+ ...cases.map((item, caseIndex)=>`${stem}${c.dim(caseIndex === cases.length - 1 ? '└─' : '├─')} ${this.formatCaseResult(item, file.retries)}`)
901
+ ];
902
+ }
903
+ printFile(file) {
904
+ if (file.printed) return;
905
+ file.printed = true;
906
+ this.log([
907
+ '',
908
+ ` ${this.color.bold(terminal_reporter_oneLine(file.projectName))}`,
909
+ ...this.resultFileLines(file, true)
910
+ ].join('\n'));
911
+ }
912
+ printProject(projectName, files, failed) {
913
+ if (!files.length || files.every((file)=>file.printed)) return;
914
+ const c = this.color;
915
+ const status = failed || files.some((file)=>'failed' === file.status) ? 'failed' : files.every((file)=>'not-run' === file.status) ? 'not-run' : files.some((file)=>'not-run' === file.status) ? 'failed' : 'success';
916
+ const lines = [
917
+ '',
918
+ ` ${this.statusSymbol(status)} ${c.bold(terminal_reporter_oneLine(projectName))} ${c.dim(`(${files.length} ${1 === files.length ? 'file' : 'files'})`)}`
919
+ ];
920
+ for (const [index, file] of files.entries())if (!file.printed) {
921
+ file.printed = true;
922
+ lines.push(...this.resultFileLines(file, index === files.length - 1));
923
+ }
924
+ this.log(lines.join('\n'));
925
+ }
926
+ failure(source, error, context) {
927
+ this.failures.push({
928
+ source,
929
+ error,
930
+ context
931
+ });
932
+ }
933
+ completeProject(project) {
934
+ this.projectPhases.delete(project.name);
935
+ const projectFiles = new Set();
936
+ const documents = new Map(project.documents.map((document)=>[
937
+ document.documentId,
938
+ document
939
+ ]));
940
+ const resultSources = new Set([
941
+ ...project.cases.map((outcome)=>outcome.sourcePath),
942
+ ...project.documents.map((document)=>document.sourcePath),
943
+ ...project.collectionErrors.map((error)=>error.sourcePath)
944
+ ]);
945
+ for (const plan of project.plans ?? []){
946
+ const selected = plan.cases.filter((item)=>item.selected).length;
947
+ if (selected || resultSources.has(plan.sourcePath)) projectFiles.add(this.file(project.name, plan.sourcePath, selected));
948
+ }
949
+ for (const outcome of project.cases)this.file(project.name, outcome.sourcePath).outcomes.set(outcome.caseId, outcome);
950
+ for (const document of documents.values()){
951
+ const file = this.file(project.name, document.sourcePath);
952
+ file.status = document.status;
953
+ file.retries = document.attemptIndex ?? 0;
954
+ file.durationMs = project.documents.filter((item)=>item.documentId === document.documentId).reduce((sum, item)=>sum + item.durationMs, 0);
955
+ }
956
+ for (const error of project.collectionErrors)this.file(project.name, error.sourcePath).status = 'failed';
957
+ for (const file of this.files.values())if (file.projectName === project.name) {
958
+ file.attemptOutcomes.clear();
959
+ const cases = [
960
+ ...file.outcomes.values()
961
+ ];
962
+ file.status = fileStatus(file.status, cases);
963
+ projectFiles.add(file);
964
+ }
965
+ for (const [key, task] of this.active)if (task.file.projectName === project.name) this.active.delete(key);
966
+ if (this.interactive) this.printProject(project.name, [
967
+ ...projectFiles
968
+ ], 'failed' === project.status);
969
+ else for (const file of projectFiles)this.printFile(file);
970
+ }
971
+ finish(result) {
972
+ this.dispose();
973
+ this.finalMetrics = result.metrics ?? this.usage.snapshot();
974
+ if (result.startedAt) this.startedAt = Date.parse(result.startedAt);
975
+ for (const project of result.projects)this.completeProject(project);
976
+ this.total = result.summary.total;
977
+ this.totalFiles = this.files.size;
978
+ }
979
+ sectionHeading(label, kind) {
980
+ const c = this.color;
981
+ const width = Math.max(0, (this.io.columns ?? 80) - 2);
982
+ const title = truncateTerminalLine(` ${label} `, width);
983
+ const padding = Math.max(0, width - string_width(title));
984
+ const left = Math.floor(padding / 2);
985
+ const line = 'warning' === kind ? c.yellow : c.red;
986
+ const badge = 'warning' === kind ? c.bgYellow.black.bold : c.bgRed.white.bold;
987
+ this.log(`\n ${line('─'.repeat(left))}${badge(title)}${line('─'.repeat(padding - left))}\n`);
988
+ }
989
+ summary(result) {
990
+ const c = this.color;
991
+ if (this.interactive && this.warnings.size) {
992
+ this.sectionHeading(`Warnings ${this.warnings.size}`, 'warning');
993
+ for (const warning of [
994
+ ...this.warnings.values()
995
+ ].slice(0, 5))this.log(` ${c.yellow('WARN')} ${c.dim(warning.scope)}${warning.message}`);
996
+ if (this.warnings.size > 5) this.log(c.dim(` … ${this.warnings.size - 5} more warnings; see debug logs or --reporter verbose.`));
997
+ }
998
+ if (this.failures.length) {
999
+ this.sectionHeading(`Failures ${this.failures.length}`, 'failure');
1000
+ for (const failure of this.failures){
1001
+ if ('default' === this.mode) {
1002
+ const detail = formatFailureDetails(failure.source, failure.error, failure.context);
1003
+ const body = detail.body.map((line)=>{
1004
+ if (line.startsWith(' Error:')) return c.red(line);
1005
+ if (line.startsWith(' at ')) return c.dim(line);
1006
+ return line.replace(/^ {2}(?:Step|Location|Attempt|File try|Phase|Input)\s+/, (label)=>c.dim(label));
1007
+ }).join('\n');
1008
+ const block = `${c.bgRed.white.bold(' FAIL ')} ${c.bold(detail.title)}\n${body}\n`;
1009
+ if (this.interactive) this.log(block);
1010
+ else this.io.error(`FAIL ${detail.title}\n${detail.body.join('\n')}\n`);
1011
+ continue;
1012
+ }
1013
+ const message = formatCliError(failure.error);
1014
+ this.io.error(`FAIL midscene-test: ${failure.source}:\n${message.split('\n').map((line)=>` ${line}`).join('\n')}\n`);
1015
+ }
1016
+ }
1017
+ this.log(`\n${this.footer(result.durationMs, result).join('\n')}`);
1018
+ this.log('');
1019
+ this.failures = [];
1020
+ }
1021
+ dispose() {
1022
+ if (this.timer) clearInterval(this.timer);
1023
+ if (this.scheduled) clearTimeout(this.scheduled);
1024
+ this.timer = void 0;
1025
+ this.scheduled = void 0;
1026
+ this.removeResizeListener?.();
1027
+ this.removeResizeListener = void 0;
1028
+ this.progressView.clear();
1029
+ }
1030
+ constructor(io, mode = 'default'){
1031
+ terminal_reporter_define_property(this, "io", void 0);
1032
+ terminal_reporter_define_property(this, "mode", void 0);
1033
+ terminal_reporter_define_property(this, "total", void 0);
1034
+ terminal_reporter_define_property(this, "completionOrder", void 0);
1035
+ terminal_reporter_define_property(this, "totalFiles", void 0);
1036
+ terminal_reporter_define_property(this, "files", void 0);
1037
+ terminal_reporter_define_property(this, "warnings", void 0);
1038
+ terminal_reporter_define_property(this, "latestNotice", void 0);
1039
+ terminal_reporter_define_property(this, "usage", void 0);
1040
+ terminal_reporter_define_property(this, "finalMetrics", void 0);
1041
+ terminal_reporter_define_property(this, "attempts", void 0);
1042
+ terminal_reporter_define_property(this, "active", void 0);
1043
+ terminal_reporter_define_property(this, "projectPhases", void 0);
1044
+ terminal_reporter_define_property(this, "failures", void 0);
1045
+ terminal_reporter_define_property(this, "lastOutput", void 0);
1046
+ terminal_reporter_define_property(this, "startedAt", void 0);
1047
+ terminal_reporter_define_property(this, "timer", void 0);
1048
+ terminal_reporter_define_property(this, "scheduled", void 0);
1049
+ terminal_reporter_define_property(this, "progressView", void 0);
1050
+ terminal_reporter_define_property(this, "removeResizeListener", void 0);
1051
+ terminal_reporter_define_property(this, "color", void 0);
1052
+ this.io = io;
1053
+ this.mode = mode;
1054
+ this.total = 0;
1055
+ this.completionOrder = 0;
1056
+ this.totalFiles = 0;
1057
+ this.files = new Map();
1058
+ this.warnings = new Map();
1059
+ this.usage = new CliModelUsage();
1060
+ this.attempts = new Map();
1061
+ this.active = new Map();
1062
+ this.projectPhases = new Map();
1063
+ this.failures = [];
1064
+ this.lastOutput = Date.now();
1065
+ this.startedAt = Date.now();
1066
+ this.progressView = new TerminalProgress(io);
1067
+ this.color = new external_chalk_["default"].Instance({
1068
+ level: io.isTTY && !process.env.NO_COLOR ? 1 : 0
1069
+ });
1070
+ }
1071
+ }
1072
+ const escapeData = (text)=>text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
1073
+ const escapeProperty = (text)=>escapeData(text).replace(/:/g, '%3A').replace(/,/g, '%2C');
1074
+ const markdown = (text)=>text.replace(/[&<>`\r\n]/g, (char)=>({
1075
+ '&': '&amp;',
1076
+ '<': '&lt;',
1077
+ '>': '&gt;',
1078
+ '`': '&#96;',
1079
+ '\r': ' ',
1080
+ '\n': ' '
1081
+ })[char]);
1082
+ function githubFailureAnnotation(source, message, file) {
1083
+ if ('true' !== process.env.GITHUB_ACTIONS) return;
1084
+ const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd();
1085
+ const path = file ? (0, external_node_path_.relative)(workspace, (0, external_node_path_.resolve)(file)).split(external_node_path_.sep).join('/') : void 0;
1086
+ return `::error ${path ? `file=${escapeProperty(path)},` : ''}title=${escapeProperty(source)}::${escapeData(message)}`;
1087
+ }
1088
+ function writeGitHubSummary(result) {
1089
+ const output = process.env.GITHUB_STEP_SUMMARY;
1090
+ if ('true' !== process.env.GITHUB_ACTIONS || !output) return;
1091
+ const failedCases = [
1092
+ ...new Map(result.cases.map((item)=>[
1093
+ item.caseId,
1094
+ item
1095
+ ])).values()
1096
+ ].filter((item)=>'failed' === item.status);
1097
+ const summary = [
1098
+ '## Midscene Test',
1099
+ '',
1100
+ '| Passed | Failed | Not run | Passed after retry | Duration |',
1101
+ '| ---: | ---: | ---: | ---: | ---: |',
1102
+ `| ${result.summary.passed} | ${result.summary.failed} | ${result.summary.notRun} | ${result.summary.passedAfterRetry} | ${formatDuration(result.durationMs)} |`,
1103
+ '',
1104
+ 'Passed after retry is included in Passed.',
1105
+ '',
1106
+ ...failedCases.map((item)=>`- Failed: **${markdown(item.name)}** — ${markdown(item.projectName)}/\`${markdown(item.sourcePath)}\``),
1107
+ ...result.summary.collectionErrors ? [
1108
+ `- Collection errors: ${result.summary.collectionErrors}`
1109
+ ] : [],
1110
+ ...result.summary.documentFailures ? [
1111
+ `- Failed files: ${result.summary.documentFailures}`
1112
+ ] : [],
1113
+ ...result.summary.projectFailures ? [
1114
+ `- Failed projects: ${result.summary.projectFailures}`
1115
+ ] : [],
1116
+ '',
1117
+ ...result.reportPath ? [
1118
+ `Report artifact entry: \`${markdown(result.reportPath)}\``
1119
+ ] : [],
1120
+ `Structured results: \`${markdown(result.summaryPath)}\``,
1121
+ ''
1122
+ ].join('\n');
1123
+ (0, external_node_fs_.appendFileSync)(output, `${summary}\n`);
1124
+ }
105
1125
  const GENERATED_COMMENT = '<!-- Generated by `midscene-test nodes`. Do not edit directly. -->';
106
1126
  const sortNodesForSpec = (nodes)=>{
107
1127
  const priority = (name)=>'aiAct' === name ? 0 : 'aiAssert' === name ? 1 : 2;
@@ -234,6 +1254,32 @@ const renderNodeSpec = (nodes, location)=>{
234
1254
  warnings
235
1255
  };
236
1256
  };
1257
+ function captureTerminalOutput(output, streams = [
1258
+ process.stdout,
1259
+ process.stderr
1260
+ ]) {
1261
+ const cleanups = [];
1262
+ for (const stream of streams){
1263
+ const original = stream.write;
1264
+ const forward = original.bind(stream);
1265
+ const intercepted = (chunk, encoding, callback)=>{
1266
+ const done = 'function' == typeof encoding ? encoding : callback;
1267
+ const message = 'string' == typeof chunk ? chunk : Buffer.from(chunk).toString();
1268
+ let accepted = true;
1269
+ output(message, (text)=>{
1270
+ accepted = forward(text, done);
1271
+ });
1272
+ return accepted;
1273
+ };
1274
+ stream.write = intercepted;
1275
+ cleanups.push(()=>{
1276
+ if (stream.write === intercepted) stream.write = original;
1277
+ });
1278
+ }
1279
+ return ()=>{
1280
+ for (const cleanup of cleanups)cleanup();
1281
+ };
1282
+ }
237
1283
  const registry_NodeRegistry = NodeRegistry;
238
1284
  const projectIdFromIndex = (index)=>`project-${index}`;
239
1285
  const getDefaultExport = (loaded, absolutePath)=>{
@@ -581,11 +1627,13 @@ function parseWorkflowSource(source) {
581
1627
  start: state.position + sourceOffset,
582
1628
  end: 0,
583
1629
  value: null,
584
- children: []
1630
+ children: [],
1631
+ kind: null
585
1632
  });
586
1633
  const node = stack.pop();
587
1634
  node.end = state.position + sourceOffset;
588
1635
  node.value = state.result;
1636
+ node.kind = state.kind;
589
1637
  stack.at(-1)?.children.push(node);
590
1638
  nodes.push(node);
591
1639
  if ('mapping' !== state.kind) return;
@@ -615,6 +1663,36 @@ function parseWorkflowSource(source) {
615
1663
  const value = sanitizeReportText(redacted, 65536);
616
1664
  return {
617
1665
  parsed,
1666
+ locate (path) {
1667
+ let node = nodes.at(-1);
1668
+ for (const part of path){
1669
+ if (!node) return;
1670
+ while(1 === node.children.length && node.children[0].value === node.value)node = node.children[0];
1671
+ if ('number' == typeof part && 'sequence' === node.kind) node = node.children[part];
1672
+ else {
1673
+ if ('string' != typeof part || 'mapping' !== node.kind) return;
1674
+ const children = node.children;
1675
+ node = void 0;
1676
+ for(let index = 0; index < children.length; index += 2)if (children[index].value === part) {
1677
+ node = children[index + 1];
1678
+ break;
1679
+ }
1680
+ }
1681
+ }
1682
+ if (!node) return;
1683
+ let start = node.start;
1684
+ while(start < node.end)if (/\s/.test(source[start])) start++;
1685
+ else if ('#' === source[start]) {
1686
+ const end = source.indexOf('\n', start);
1687
+ if (end < 0) return;
1688
+ start = end + 1;
1689
+ } else break;
1690
+ const prefix = source.slice(0, start).split(/\r\n|\r|\n/);
1691
+ return {
1692
+ line: prefix.length,
1693
+ column: prefix.at(-1).length + 1
1694
+ };
1695
+ },
618
1696
  sourceYaml: {
619
1697
  value,
620
1698
  ...redactedPaths.size ? {
@@ -673,7 +1751,7 @@ const resolveWorkflowVariables = (value, options)=>{
673
1751
  ]));
674
1752
  return value;
675
1753
  };
676
- function _define_property(obj, key, value) {
1754
+ function collect_define_property(obj, key, value) {
677
1755
  if (key in obj) Object.defineProperty(obj, key, {
678
1756
  value: value,
679
1757
  enumerable: true,
@@ -690,7 +1768,7 @@ class NativeWorkflowFormatError extends WorkflowParseError {
690
1768
  sourcePath,
691
1769
  actualFormat,
692
1770
  expectedFormat: 'native'
693
- }), _define_property(this, "actualFormat", void 0);
1771
+ }), collect_define_property(this, "actualFormat", void 0);
694
1772
  this.actualFormat = actualFormat;
695
1773
  }
696
1774
  }
@@ -727,6 +1805,18 @@ function collectWorkflowDocument(source, options) {
727
1805
  const sourcePath = source.sourcePath.split(external_node_path_.sep).join('/');
728
1806
  const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
729
1807
  ...step,
1808
+ meta: {
1809
+ ...step.meta,
1810
+ sourceLocation: snapshot.locate('steps' === phase ? [
1811
+ 'cases',
1812
+ caseIndex,
1813
+ 'steps',
1814
+ stepIndex
1815
+ ] : [
1816
+ phase,
1817
+ stepIndex
1818
+ ])
1819
+ },
730
1820
  templateInput: step.input,
731
1821
  input: resolveWorkflowVariables(step.input, {
732
1822
  variables: options.variables,
@@ -901,6 +1991,9 @@ const writeTestProjectRunResult = (options)=>{
901
1991
  markdown: relativeToSummary(result.summaryPath, result.markdownPath)
902
1992
  } : {},
903
1993
  summary: result.summary,
1994
+ ...result.metrics ? {
1995
+ metrics: result.metrics
1996
+ } : {},
904
1997
  projects: result.projects.map((project)=>({
905
1998
  projectId: project.projectId,
906
1999
  name: project.name,
@@ -1297,11 +2390,12 @@ async function prepareTestRun(options) {
1297
2390
  preflightScope: hasFormatMismatch ? 'run' : 'project',
1298
2391
  reportEnabled: true,
1299
2392
  publications: [],
1300
- onProgress: options.onProgress ?? (()=>{})
2393
+ onProgress: options.onProgress ?? (()=>{}),
2394
+ onEvent: options.onEvent
1301
2395
  };
1302
2396
  }
1303
2397
  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, ' ');
1304
- const duration = (ms)=>{
2398
+ const result_markdown_duration = (ms)=>{
1305
2399
  const seconds = Math.round(ms / 1000);
1306
2400
  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
1307
2401
  };
@@ -1312,7 +2406,7 @@ function renderTestResultMarkdown(dump, options = {}) {
1312
2406
  const lines = [
1313
2407
  '## Midscene Test',
1314
2408
  '',
1315
- `${outcome} · ${duration(dump.durationMs)}`,
2409
+ `${outcome} · ${result_markdown_duration(dump.durationMs)}`,
1316
2410
  ''
1317
2411
  ];
1318
2412
  if (summary.filtered) lines.push(`**Filtered:** ${summary.filtered}`, '');
@@ -1375,6 +2469,38 @@ async function writeTestResultMarkdown(result, index = emptyIndex()) {
1375
2469
  }));
1376
2470
  return output;
1377
2471
  }
2472
+ function scopeNodeConsole(node) {
2473
+ return {
2474
+ ...node,
2475
+ execute (ctx) {
2476
+ const position = 'case' === ctx.scope ? ctx.case : ctx.document;
2477
+ const scope = {
2478
+ projectName: position.projectName,
2479
+ sourcePath: position.sourcePath,
2480
+ ...'case' === ctx.scope ? {
2481
+ caseId: ctx.case.caseId,
2482
+ caseName: ctx.case.name,
2483
+ runId: ctx.case.runId
2484
+ } : {
2485
+ runId: ctx.document.documentRunId
2486
+ },
2487
+ phase: position.phase,
2488
+ stepIndex: position.stepIndex,
2489
+ node: node.name
2490
+ };
2491
+ return runWithConsoleLogScope(scope, ()=>node.execute({
2492
+ ...ctx,
2493
+ onTeardown (teardown) {
2494
+ ctx.onTeardown(()=>runWithConsoleLogScope({
2495
+ ...scope,
2496
+ phase: 'teardown',
2497
+ stepIndex: void 0
2498
+ }, teardown));
2499
+ }
2500
+ }));
2501
+ }
2502
+ };
2503
+ }
1378
2504
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1379
2505
  const formatStep = (info)=>{
1380
2506
  const position = stepPosition(info);
@@ -1421,69 +2547,121 @@ async function executeDocumentInvocation(options) {
1421
2547
  const documentRetry = 'document' === invocation.retry.scope;
1422
2548
  const attempts = [];
1423
2549
  const fileSetup = invocation.bindings.documentSetup;
1424
- const documentSetup = options.isProjectResourceFailure && fileSetup ? {
2550
+ const documentSetup = fileSetup ? {
1425
2551
  ...fileSetup,
1426
- async setup (ctx) {
1427
- try {
1428
- return await fileSetup.setup(ctx);
1429
- } catch (error) {
1430
- if (isProjectResourceFailure(error)) options.markProjectFatal();
1431
- throw error;
1432
- }
2552
+ setup (ctx) {
2553
+ const scope = {
2554
+ projectName: project.name,
2555
+ sourcePath: document.sourcePath,
2556
+ runId: ctx.document.documentRunId,
2557
+ phase: 'fileSetup'
2558
+ };
2559
+ return runWithConsoleLogScope(scope, async ()=>{
2560
+ try {
2561
+ return await fileSetup.setup({
2562
+ ...ctx,
2563
+ onTeardown (teardown) {
2564
+ ctx.onTeardown(()=>runWithConsoleLogScope({
2565
+ ...scope,
2566
+ phase: 'teardown'
2567
+ }, teardown));
2568
+ }
2569
+ });
2570
+ } catch (error) {
2571
+ if (isProjectResourceFailure(error)) options.markProjectFatal();
2572
+ throw error;
2573
+ }
2574
+ });
1433
2575
  }
1434
- } : fileSetup;
2576
+ } : void 0;
2577
+ options.onEvent?.({
2578
+ type: 'document-start',
2579
+ documentId: document.documentId,
2580
+ projectName: project.name,
2581
+ sourcePath: document.sourcePath,
2582
+ cases: document.cases.length
2583
+ });
1435
2584
  await runDocumentAttempts({
1436
2585
  retry: documentRetry ? invocation.retry.count : 0,
1437
2586
  signal: options.signal,
1438
2587
  shouldStop: ()=>options.shouldBail() || options.isProjectFatal()
1439
2588
  }, async (documentAttemptIndex)=>{
1440
2589
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1441
- const execution = await runWorkflowDocument(document, {
1442
- plan: invocation.plan,
1443
- documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1444
- ...invocation.bindings,
1445
- documentSetup,
1446
- setupTimeoutMs: options.setupTimeoutMs,
1447
- teardownTimeoutMs: options.teardownTimeoutMs,
1448
- project,
1449
- projectContext: options.projectContext,
1450
- retry: documentRetry ? 0 : invocation.retry.count,
1451
- signal: options.signal,
1452
- defaultTimeoutMs: invocation.defaultTimeoutMs,
1453
- shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1454
- stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1455
- isFatalError: (run)=>[
1456
- ...run.beforeEach,
1457
- ...run.steps,
1458
- ...run.afterEach
1459
- ].some((step)=>step.error && (isFatalDeviceError(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>isFatalDeviceError(error) || isProjectResourceFailure(error)),
1460
- onCaseStart: (collectedCase)=>{
1461
- options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1462
- },
1463
- onStepStart: (info)=>{
1464
- const indent = 'case' === info.scope ? ' ' : ' ';
1465
- options.onProgress(`${indent}→ ${formatStep(info)}`);
1466
- },
1467
- onStepResult: async (info, result)=>{
1468
- if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1469
- await invocation.bindings.onStepResult?.(info, result);
1470
- options.onProgress(formatStepResult(info, result));
1471
- },
1472
- onCaseResult: async (attempt)=>{
1473
- await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1474
- options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1475
- },
1476
- onCaseOutcome: (outcome)=>{
1477
- if (outcome.attempts?.some((attempt)=>[
1478
- ...attempt.beforeEach,
1479
- ...attempt.steps,
1480
- ...attempt.afterEach
1481
- ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1482
- if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1483
- if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1484
- },
1485
- onDocumentResult: options.onDocumentResult
1486
- }).catch(async (error)=>{
2590
+ const execution = await runWithConsoleLogScope({
2591
+ projectName: project.name,
2592
+ sourcePath: document.sourcePath
2593
+ }, ()=>runWorkflowDocument(document, {
2594
+ plan: invocation.plan,
2595
+ documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
2596
+ ...invocation.bindings,
2597
+ resolveNode: (name)=>scopeNodeConsole(invocation.bindings.resolveNode(name)),
2598
+ documentSetup,
2599
+ setupTimeoutMs: options.setupTimeoutMs,
2600
+ teardownTimeoutMs: options.teardownTimeoutMs,
2601
+ project,
2602
+ projectContext: options.projectContext,
2603
+ retry: documentRetry ? 0 : invocation.retry.count,
2604
+ signal: options.signal,
2605
+ defaultTimeoutMs: invocation.defaultTimeoutMs,
2606
+ shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
2607
+ stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
2608
+ isFatalError: (run)=>[
2609
+ ...run.beforeEach,
2610
+ ...run.steps,
2611
+ ...run.afterEach
2612
+ ].some((step)=>step.error && (isFatalDeviceError(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>isFatalDeviceError(error) || isProjectResourceFailure(error)),
2613
+ onCaseStart: (collectedCase)=>{
2614
+ options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
2615
+ },
2616
+ onStepStart: (info)=>{
2617
+ options.onEvent?.({
2618
+ type: 'step-start',
2619
+ projectName: project.name,
2620
+ info
2621
+ });
2622
+ const indent = 'case' === info.scope ? ' ' : ' ';
2623
+ options.onProgress(`${indent}→ ${formatStep(info)}`);
2624
+ },
2625
+ onStepResult: async (info, result)=>{
2626
+ if (isProjectResourceFailure(result.error)) options.markProjectFatal();
2627
+ await invocation.bindings.onStepResult?.(info, result);
2628
+ options.onProgress(formatStepResult(info, result));
2629
+ options.onEvent?.({
2630
+ type: 'step-end',
2631
+ projectName: project.name,
2632
+ info,
2633
+ result
2634
+ });
2635
+ },
2636
+ onCaseResult: async (attempt)=>{
2637
+ await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
2638
+ options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} case attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
2639
+ if (!documentRetry) options.onEvent?.({
2640
+ type: 'case-attempt-end',
2641
+ attempt,
2642
+ maxAttempts: invocation.retry.count + 1
2643
+ });
2644
+ },
2645
+ onCaseOutcome: (outcome)=>{
2646
+ if (documentRetry) options.onEvent?.({
2647
+ type: 'case-progress',
2648
+ outcome,
2649
+ documentAttemptIndex
2650
+ });
2651
+ else options.onEvent?.({
2652
+ type: 'case-end',
2653
+ outcome
2654
+ });
2655
+ if (outcome.attempts?.some((attempt)=>[
2656
+ ...attempt.beforeEach,
2657
+ ...attempt.steps,
2658
+ ...attempt.afterEach
2659
+ ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
2660
+ if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
2661
+ if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
2662
+ },
2663
+ onDocumentResult: options.onDocumentResult
2664
+ })).catch(async (error)=>{
1487
2665
  if (error instanceof WorkflowExecutionFailure) {
1488
2666
  const partial = error.result;
1489
2667
  const resourceOnly = error.errors.length > 0 && error.errors.every((failure)=>isResourceRecoveryError(failure) || partial.document.hostErrors?.some((host)=>'cleanup' === host.phase && (host.error === failure || host.error instanceof Error && host.error.cause === failure)));
@@ -1553,6 +2731,11 @@ async function executeDocumentInvocation(options) {
1553
2731
  shouldStop: options.shouldBail()
1554
2732
  });
1555
2733
  if (documentRetry) execution.document.retryDecision = decision;
2734
+ if (documentRetry && 'retry' === decision.decision) options.onEvent?.({
2735
+ type: 'document-retry',
2736
+ document: execution.document,
2737
+ maxAttempts: invocation.retry.count + 1
2738
+ });
1556
2739
  attempts.push(execution);
1557
2740
  sinks.cases.push(...execution.cases.map((outcome)=>({
1558
2741
  ...outcome,
@@ -1587,7 +2770,18 @@ async function executeDocumentInvocation(options) {
1587
2770
  });
1588
2771
  const final = attempts.at(-1);
1589
2772
  if (!final) return;
2773
+ if (documentRetry) for (const outcome of final.cases)options.onEvent?.({
2774
+ type: 'case-end',
2775
+ outcome,
2776
+ documentRetries: attempts.length - 1
2777
+ });
1590
2778
  const failedCases = final.cases.filter((item)=>'failed' === item.status).length;
2779
+ options.onEvent?.({
2780
+ type: 'document-end',
2781
+ document: final.document,
2782
+ cases: final.cases,
2783
+ durationMs: attempts.reduce((total, attempt)=>total + attempt.document.durationMs, 0)
2784
+ });
1591
2785
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1592
2786
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1593
2787
  }
@@ -1627,11 +2821,45 @@ async function runPreparedFiles(options) {
1627
2821
  code: 'UNRESOLVED_FILE_RESOURCES'
1628
2822
  }));
1629
2823
  };
2824
+ const finalizeProject = (state)=>{
2825
+ const { prepared } = state;
2826
+ const completed = new Set(state.cases.map((item)=>item.caseId));
2827
+ const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
2828
+ for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
2829
+ documentId: document.documentId,
2830
+ caseId: item.caseId,
2831
+ projectName: prepared.project.name,
2832
+ name: item.definition.name,
2833
+ sourcePath: item.sourcePath,
2834
+ caseIndex: item.caseIndex,
2835
+ status: 'not-run',
2836
+ notRunReason: reason
2837
+ });
2838
+ const order = new Map(prepared.documents.map((document, i)=>[
2839
+ document.documentId,
2840
+ i
2841
+ ]));
2842
+ const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
2843
+ state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
2844
+ state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
2845
+ };
1630
2846
  const finishProject = async (state)=>{
1631
2847
  if (!state.runtime || state.finished) return;
1632
2848
  state.finished = true;
2849
+ try {
2850
+ options.onEvent?.({
2851
+ type: 'project-phase',
2852
+ projectName: state.prepared.project.name,
2853
+ phase: 'teardown'
2854
+ });
2855
+ } catch (error) {
2856
+ options.onInfrastructureError(error);
2857
+ }
1633
2858
  const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1634
- state.lifecycle = await state.runtime.finish(failed ? 'failed' : 'success');
2859
+ state.lifecycle = await runWithConsoleLogScope({
2860
+ projectName: state.prepared.project.name,
2861
+ phase: 'teardown'
2862
+ }, ()=>state.runtime.finish(failed ? 'failed' : 'success'));
1635
2863
  const releaseProject = ()=>{
1636
2864
  occupiedProjects--;
1637
2865
  wake?.();
@@ -1645,7 +2873,11 @@ async function runPreparedFiles(options) {
1645
2873
  options.onInfrastructureError(error);
1646
2874
  releaseProject();
1647
2875
  });
1648
- } else releaseProject();
2876
+ } else {
2877
+ releaseProject();
2878
+ finalizeProject(state);
2879
+ options.onProjectEnd?.(state);
2880
+ }
1649
2881
  };
1650
2882
  const admit = ()=>{
1651
2883
  for(let offset = 0; offset < states.length; offset++){
@@ -1686,7 +2918,20 @@ async function runPreparedFiles(options) {
1686
2918
  });
1687
2919
  setMaxListeners(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1688
2920
  options.announceProject(prepared, index);
1689
- await state.runtime.start();
2921
+ options.onEvent?.({
2922
+ type: 'project-phase',
2923
+ projectName: project.name,
2924
+ phase: 'setup'
2925
+ });
2926
+ await runWithConsoleLogScope({
2927
+ projectName: project.name,
2928
+ phase: 'setup'
2929
+ }, ()=>state.runtime.start());
2930
+ options.onEvent?.({
2931
+ type: 'project-phase',
2932
+ projectName: project.name,
2933
+ phase: 'running'
2934
+ });
1690
2935
  state.initializing = false;
1691
2936
  if (!state.runtime.canRun) {
1692
2937
  state.fatal = true;
@@ -1714,8 +2959,9 @@ async function runPreparedFiles(options) {
1714
2959
  }));
1715
2960
  },
1716
2961
  addFailedCases: options.addFailedCases,
1717
- onProgress: (message)=>options.onProgress(prepared, message),
2962
+ onProgress: (message)=>options.onProgress(prepared, options.maxConcurrentFiles > 1 ? ` [${invocation.document.sourcePath}]${message}` : message),
1718
2963
  sinks: state,
2964
+ onEvent: options.onEvent,
1719
2965
  isolatedContext: void 0 !== project.fileSetup,
1720
2966
  isProjectResourceFailure: isSharedResourceFailure,
1721
2967
  setupTimeoutMs: options.setupTimeoutMs,
@@ -1794,25 +3040,7 @@ async function runPreparedFiles(options) {
1794
3040
  for (const state of states){
1795
3041
  const { prepared } = state;
1796
3042
  if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1797
- const completed = new Set(state.cases.map((item)=>item.caseId));
1798
- const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1799
- for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1800
- documentId: document.documentId,
1801
- caseId: item.caseId,
1802
- projectName: prepared.project.name,
1803
- name: item.definition.name,
1804
- sourcePath: item.sourcePath,
1805
- caseIndex: item.caseIndex,
1806
- status: 'not-run',
1807
- notRunReason: reason
1808
- });
1809
- const order = new Map(prepared.documents.map((document, i)=>[
1810
- document.documentId,
1811
- i
1812
- ]));
1813
- const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1814
- state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1815
- state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
3043
+ finalizeProject(state);
1816
3044
  }
1817
3045
  return states;
1818
3046
  }
@@ -1884,6 +3112,32 @@ async function runPreparedTestProject(plan) {
1884
3112
  const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
1885
3113
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1886
3114
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
3115
+ plan.onEvent?.({
3116
+ type: 'run-start',
3117
+ configuredModels: configuredModelNames(),
3118
+ projects: preparedProjects.length,
3119
+ documents: totalDocuments + totalErrors,
3120
+ cases: totalCases,
3121
+ projectRoot,
3122
+ configPath,
3123
+ startedAt: startedAt.toISOString(),
3124
+ maxConcurrentProjects: definition.test.maxConcurrentProjects,
3125
+ maxConcurrentFiles: definition.test.maxConcurrentFiles,
3126
+ files: preparedProjects.flatMap((prepared)=>[
3127
+ ...prepared.documents.map((document)=>({
3128
+ documentId: document.documentId,
3129
+ projectName: prepared.project.name,
3130
+ sourcePath: document.sourcePath,
3131
+ cases: document.cases.length
3132
+ })),
3133
+ ...prepared.collectionErrors.map((error)=>({
3134
+ documentId: error.sourcePath,
3135
+ projectName: prepared.project.name,
3136
+ sourcePath: error.sourcePath,
3137
+ cases: 0
3138
+ }))
3139
+ ])
3140
+ });
1887
3141
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1888
3142
  const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
1889
3143
  await writeTestRunPlan(runDir, preparedProjects);
@@ -1986,7 +3240,12 @@ async function runPreparedTestProject(plan) {
1986
3240
  shouldBail: bailReached,
1987
3241
  addFailedCases,
1988
3242
  announceProject,
1989
- onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
3243
+ onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(preparedProjects.length > 1 || effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
3244
+ onEvent: (event)=>runInfrastructureCallback(()=>plan.onEvent?.(event)),
3245
+ onProjectEnd: (state)=>runInfrastructureCallback(()=>plan.onEvent?.({
3246
+ type: 'project-end',
3247
+ project: buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle)
3248
+ })),
1990
3249
  onInfrastructureError: recordInfrastructureError
1991
3250
  });
1992
3251
  states.forEach((state, index)=>{
@@ -2067,6 +3326,10 @@ async function runPreparedTestProject(plan) {
2067
3326
  buildRunnerDump: (index)=>{
2068
3327
  reportIndex = index;
2069
3328
  const dump = buildTestRunReportDump(completedResult, index);
3329
+ completedResult = {
3330
+ ...completedResult,
3331
+ metrics: dump.metrics
3332
+ };
2070
3333
  const warn = getDebug('test-runner:report-assembler', {
2071
3334
  console: true
2072
3335
  });
@@ -2084,6 +3347,16 @@ async function runPreparedTestProject(plan) {
2084
3347
  await publication.publish(completedResult);
2085
3348
  });
2086
3349
  await publish('write-result', (0, external_node_path_.join)((0, external_node_path_.dirname)(summaryPath), 'result.md'), async ()=>{
3350
+ if (!completedResult.metrics) {
3351
+ const dump = buildTestRunReportDump(completedResult, reportIndex ?? {
3352
+ sources: [],
3353
+ metrics: sumTestRunReportMetrics([])
3354
+ });
3355
+ completedResult = {
3356
+ ...completedResult,
3357
+ metrics: dump.metrics
3358
+ };
3359
+ }
2087
3360
  const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
2088
3361
  completedResult = {
2089
3362
  ...completedResult,
@@ -2104,6 +3377,7 @@ const parseTestCliArgs = (args, cwd = process.cwd())=>{
2104
3377
  let configPath;
2105
3378
  let resultDir;
2106
3379
  const projectNames = [];
3380
+ let reporter;
2107
3381
  for(let index = commandOffset; index < args.length; index += 1){
2108
3382
  const token = args[index];
2109
3383
  const equal = token.startsWith('-') ? token.indexOf('=') : -1;
@@ -2114,16 +3388,21 @@ const parseTestCliArgs = (args, cwd = process.cwd())=>{
2114
3388
  projectRoot = (0, external_node_path_.resolve)(cwd, arg);
2115
3389
  continue;
2116
3390
  }
2117
- if ('--config' === arg || '--result-dir' === arg || '--project' === arg) {
3391
+ if ('--config' === arg || '--result-dir' === arg || '--project' === arg || '--reporter' === arg) {
2118
3392
  const value = inlineValue ?? args[index + 1];
2119
3393
  if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value.`);
2120
3394
  if ('--config' === arg) configPath = value;
2121
3395
  else if ('--result-dir' === arg) resultDir = (0, external_node_path_.resolve)(cwd, value);
2122
- else projectNames.push(value);
3396
+ else if ('--project' === arg) projectNames.push(value);
3397
+ else {
3398
+ if ('default' !== value && 'verbose' !== value) throw new Error(`Unknown reporter: ${value}. Expected default or verbose.`);
3399
+ reporter = value;
3400
+ }
2123
3401
  if (void 0 === inlineValue) index += 1;
2124
3402
  } else throw new Error(`Unknown option: ${token}`);
2125
3403
  }
2126
3404
  if ('nodes' === command && resultDir) throw new Error('--result-dir is not supported by nodes.');
3405
+ if ('nodes' === command && reporter) throw new Error('--reporter is not supported by nodes.');
2127
3406
  if ('nodes' === command && projectNames.length > 1) throw new Error('nodes accepts only one --project name.');
2128
3407
  return {
2129
3408
  ...command ? {
@@ -2135,13 +3414,27 @@ const parseTestCliArgs = (args, cwd = process.cwd())=>{
2135
3414
  resultDir,
2136
3415
  ...projectNames.length > 0 ? {
2137
3416
  projectNames
3417
+ } : {},
3418
+ ...reporter ? {
3419
+ reporter
2138
3420
  } : {}
2139
3421
  };
2140
3422
  };
2141
3423
  const defaultCliIO = {
2142
3424
  log: console.log,
2143
3425
  error: console.error,
2144
- write: (message)=>process.stdout.write(message)
3426
+ write: process.stdout.write.bind(process.stdout),
3427
+ isTTY: Boolean(process.stdout.isTTY && !process.env.CI),
3428
+ get columns () {
3429
+ return process.stdout.columns;
3430
+ },
3431
+ get rows () {
3432
+ return process.stdout.rows;
3433
+ },
3434
+ onResize (listener) {
3435
+ process.stdout.on('resize', listener);
3436
+ return ()=>process.stdout.off('resize', listener);
3437
+ }
2145
3438
  };
2146
3439
  const testCliHelp = `Midscene Test: run native cases/steps Test projects.
2147
3440
 
@@ -2156,6 +3449,7 @@ Options:
2156
3449
  --config <path> Native TypeScript or JavaScript Test config
2157
3450
  --project <name> Select a native execution Project (repeatable)
2158
3451
  --result-dir <path> Store Test result files in this directory
3452
+ --reporter <default|verbose> Concise Case results or full step diagnostics
2159
3453
  --help, -h Show this help
2160
3454
  --version Show the package version
2161
3455
 
@@ -2216,11 +3510,17 @@ const runNodesCommand = async (options, io)=>{
2216
3510
  io.log(`\nNode Spec generated: ${specPath}`);
2217
3511
  }
2218
3512
  };
2219
- const reportTestProjectResult = (result, io, infrastructureErrors = result.errors ?? [])=>{
3513
+ const reportTestProjectResult = (result, io, reporter, projectRoot, infrastructureErrors = result.errors ?? [])=>{
3514
+ reporter.finish(result);
2220
3515
  const reportedErrors = new Set();
2221
- const reportError = (source, error)=>{
3516
+ const reportError = (source, error, sourcePath, context)=>{
2222
3517
  if (error instanceof Error && reportedErrors.has(error)) return;
2223
- io.error(`midscene-test: ${source}: ${formatCliError(error)}`);
3518
+ reporter.failure(source, error, {
3519
+ projectRoot,
3520
+ ...context
3521
+ });
3522
+ const annotation = githubFailureAnnotation(source, formatConciseError(error), sourcePath ? (0, external_node_path_.resolve)(projectRoot, sourcePath) : void 0);
3523
+ if (annotation) io.log(annotation);
2224
3524
  const pending = [
2225
3525
  error
2226
3526
  ];
@@ -2232,7 +3532,33 @@ const reportTestProjectResult = (result, io, infrastructureErrors = result.error
2232
3532
  if (current instanceof AggregateError) pending.push(...current.errors);
2233
3533
  }
2234
3534
  };
2235
- for (const failure of result.collectionErrors)reportError(`${failure.projectName}/${failure.sourcePath}`, failure.error);
3535
+ for (const failure of result.collectionErrors)reportError(`${failure.projectName}/${failure.sourcePath}`, failure.error, failure.sourcePath, {
3536
+ projectName: failure.projectName,
3537
+ sourcePath: failure.sourcePath,
3538
+ phase: 'collection'
3539
+ });
3540
+ const finalDocuments = new Map(result.documents.map((document)=>[
3541
+ document.documentId,
3542
+ document
3543
+ ]));
3544
+ const documentContext = (projectName, documentId, sourcePath)=>{
3545
+ const project = result.projects.find((project)=>project.name === projectName);
3546
+ const document = finalDocuments.get(documentId);
3547
+ const plan = document?.plan ?? project?.plans?.find((plan)=>plan.documentId === documentId);
3548
+ const fileIndex = document?.attemptIndex ?? 0;
3549
+ return {
3550
+ plan,
3551
+ projectName,
3552
+ sourcePath,
3553
+ ...plan?.retry.scope === 'document' ? {
3554
+ fileAttempt: {
3555
+ index: fileIndex,
3556
+ total: Math.max(fileIndex + 1, plan.retry.count + 1)
3557
+ }
3558
+ } : {},
3559
+ caseAttempts: plan?.retry.scope === 'document' ? 1 : (plan?.retry.count ?? project?.retry ?? 0) + 1
3560
+ };
3561
+ };
2236
3562
  const finalCases = new Map(result.cases.map((outcome)=>[
2237
3563
  outcome.caseId,
2238
3564
  outcome
@@ -2241,49 +3567,107 @@ const reportTestProjectResult = (result, io, infrastructureErrors = result.error
2241
3567
  if ('failed' !== outcome.status || !outcome.run) continue;
2242
3568
  const run = outcome.run;
2243
3569
  const source = `${run.projectName}/${run.sourcePath} / ${run.name}`;
2244
- for (const step of [
2245
- ...run.beforeEach,
2246
- ...run.steps,
2247
- ...run.afterEach
2248
- ])if (step.error) reportError(`${source} / ${step.phase}[${step.stepIndex + 1}] ${step.node}`, step.error);
2249
- for (const error of [
2250
- ...run.executionErrors ?? [],
2251
- ...run.teardownErrors ?? []
2252
- ])reportError(source, error);
3570
+ const { caseAttempts, plan, ...scope } = documentContext(run.projectName, outcome.documentId, run.sourcePath);
3571
+ const context = {
3572
+ ...scope,
3573
+ caseName: run.name,
3574
+ attempt: {
3575
+ index: run.attemptIndex,
3576
+ total: Math.max(caseAttempts, run.attemptIndex + 1)
3577
+ }
3578
+ };
3579
+ const casePlan = plan?.cases.find((item)=>item.caseId === outcome.caseId);
3580
+ for (const [phase, steps] of [
3581
+ [
3582
+ 'beforeEach',
3583
+ run.beforeEach
3584
+ ],
3585
+ [
3586
+ 'steps',
3587
+ run.steps
3588
+ ],
3589
+ [
3590
+ 'afterEach',
3591
+ run.afterEach
3592
+ ]
3593
+ ])for (const step of steps)if (step.error) reportError(`${source} / ${step.phase}[${step.stepIndex + 1}] ${step.node}${run.attemptIndex ? ` (failed after ${run.attemptIndex + 1} attempts)` : ''}`, step.error, run.sourcePath, {
3594
+ ...context,
3595
+ step,
3596
+ stepCount: casePlan?.[phase].length
3597
+ });
3598
+ for (const [phase, errors] of [
3599
+ [
3600
+ 'execution',
3601
+ run.executionErrors
3602
+ ],
3603
+ [
3604
+ 'teardown',
3605
+ run.teardownErrors
3606
+ ]
3607
+ ])for (const error of errors ?? [])reportError(source, error, run.sourcePath, {
3608
+ ...context,
3609
+ phase
3610
+ });
2253
3611
  }
2254
- const finalDocuments = new Map(result.documents.map((document)=>[
2255
- document.documentId,
2256
- document
2257
- ]));
2258
3612
  for (const document of finalDocuments.values()){
2259
3613
  if ('failed' !== document.status) continue;
2260
3614
  const source = `${document.projectName}/${document.sourcePath}`;
2261
- for (const step of [
2262
- ...document.beforeAll,
2263
- ...document.afterAll
2264
- ])if (step.error) reportError(`${source} / ${step.phase}[${step.stepIndex + 1}] ${step.node}`, step.error);
2265
- for (const error of [
2266
- ...document.executionErrors ?? [],
2267
- ...document.teardownErrors ?? []
2268
- ])reportError(source, error);
2269
- for (const failure of document.hostErrors ?? [])reportError(`${source} / host ${failure.phase}`, failure.error);
3615
+ const { caseAttempts: _, plan, ...context } = documentContext(document.projectName, document.documentId, document.sourcePath);
3616
+ for (const [phase, steps] of [
3617
+ [
3618
+ 'beforeAll',
3619
+ document.beforeAll
3620
+ ],
3621
+ [
3622
+ 'afterAll',
3623
+ document.afterAll
3624
+ ]
3625
+ ])for (const step of steps)if (step.error) reportError(`${source} / ${step.phase}[${step.stepIndex + 1}] ${step.node}`, step.error, document.sourcePath, {
3626
+ ...context,
3627
+ step,
3628
+ stepCount: plan?.[phase].length
3629
+ });
3630
+ for (const [phase, errors] of [
3631
+ [
3632
+ 'execution',
3633
+ document.executionErrors
3634
+ ],
3635
+ [
3636
+ 'teardown',
3637
+ document.teardownErrors
3638
+ ]
3639
+ ])for (const error of errors ?? [])reportError(source, error, document.sourcePath, {
3640
+ ...context,
3641
+ phase
3642
+ });
3643
+ for (const failure of document.hostErrors ?? [])reportError(`${source} / host ${failure.phase}`, failure.error, document.sourcePath, {
3644
+ ...context,
3645
+ phase: `host ${failure.phase}`
3646
+ });
2270
3647
  }
2271
3648
  for (const project of result.projects){
2272
- if (project.lifecycle?.setupError) reportError(`${project.name} / setup`, project.lifecycle.setupError);
2273
- for (const error of project.lifecycle?.teardownErrors ?? [])reportError(`${project.name} / teardown`, error);
3649
+ if (project.lifecycle?.setupError) reportError(`${project.name} / setup`, project.lifecycle.setupError, void 0, {
3650
+ projectName: project.name,
3651
+ phase: 'setup'
3652
+ });
3653
+ for (const error of project.lifecycle?.teardownErrors ?? [])reportError(`${project.name} / teardown`, error, void 0, {
3654
+ projectName: project.name,
3655
+ phase: 'teardown'
3656
+ });
2274
3657
  }
2275
3658
  for (const error of infrastructureErrors)reportError('infrastructure', error);
2276
- io.log(`midscene-test: ${result.summary.passed}/${result.summary.total} cases passed, ${result.summary.failed} failed, ${result.summary.notRun} not run`);
2277
- io.log(`Results: ${result.resultDir}`);
3659
+ reporter.summary(result);
3660
+ if ('verbose' === reporter.mode) io.log(`Results: ${result.resultDir}`);
2278
3661
  io.log(`Summary: ${result.summaryPath}`);
2279
- if (result.markdownPath) io.log(`Markdown: ${result.markdownPath}`);
3662
+ if (result.markdownPath && 'verbose' === reporter.mode) io.log(`Markdown: ${result.markdownPath}`);
2280
3663
  if (result.reportPath) io.log(`Report: ${result.reportPath}`);
3664
+ writeGitHubSummary(result);
2281
3665
  return result.exitCode;
2282
3666
  };
2283
3667
  async function runTestCli(args, io = defaultCliIO) {
2284
3668
  try {
2285
3669
  if ('model' === args[0]) {
2286
- const { runModelCommand } = await __webpack_require__.e("77").then(__webpack_require__.bind(__webpack_require__, "./src/cli/model-command.ts?bb10"));
3670
+ const { runModelCommand } = await __webpack_require__.e("731").then(__webpack_require__.bind(__webpack_require__, "./src/cli/model-command.ts?3cff"));
2287
3671
  return runModelCommand(args, void 0, {
2288
3672
  stdout: io.log,
2289
3673
  stderr: io.error
@@ -2307,18 +3691,32 @@ async function runTestCli(args, io = defaultCliIO) {
2307
3691
  await runNodesCommand(options, io);
2308
3692
  return 0;
2309
3693
  }
2310
- let result;
3694
+ const reporter = new TerminalReporter(io, options.reporter);
3695
+ const restoreOutput = io === defaultCliIO && io.isTTY && 'verbose' !== options.reporter ? captureTerminalOutput((message, write)=>reporter.output(message, write)) : void 0;
3696
+ const projectRoot = (0, external_node_fs_.realpathSync)(options.projectRoot && (0, external_node_fs_.existsSync)(options.projectRoot) && (0, external_node_fs_.statSync)(options.projectRoot).isFile() ? (0, external_node_path_.dirname)(options.projectRoot) : options.projectRoot ?? options.cwd);
2311
3697
  try {
2312
- result = await runTestProject({
2313
- ...options,
2314
- onProgress: (message)=>io.log(message)
3698
+ return await runWithConsoleLogHandler((entry)=>reporter.console(entry), async ()=>{
3699
+ let result;
3700
+ try {
3701
+ result = await runTestProject({
3702
+ ...options,
3703
+ onProgress: (message)=>reporter.progress(message),
3704
+ onEvent: (event)=>reporter.event(event)
3705
+ });
3706
+ } catch (error) {
3707
+ if (!(error instanceof WorkflowExecutionFailure)) throw error;
3708
+ reportTestProjectResult(error.result, io, reporter, projectRoot, error.errors);
3709
+ return 1;
3710
+ }
3711
+ return reportTestProjectResult(result, io, reporter, projectRoot);
2315
3712
  });
2316
- } catch (error) {
2317
- if (!(error instanceof WorkflowExecutionFailure)) throw error;
2318
- reportTestProjectResult(error.result, io, error.errors);
2319
- return 1;
3713
+ } finally{
3714
+ try {
3715
+ reporter.dispose();
3716
+ } finally{
3717
+ restoreOutput?.();
3718
+ }
2320
3719
  }
2321
- return reportTestProjectResult(result, io);
2322
3720
  } catch (error) {
2323
3721
  io.error(formatCliError(error));
2324
3722
  return 1;