@midscene/test 1.16.0 → 1.16.2

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