@midscene/test 1.16.1 → 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.
@@ -393,11 +393,13 @@ function parseWorkflowSource(source) {
393
393
  start: state.position + sourceOffset,
394
394
  end: 0,
395
395
  value: null,
396
- children: []
396
+ children: [],
397
+ kind: null
397
398
  });
398
399
  const node = stack.pop();
399
400
  node.end = state.position + sourceOffset;
400
401
  node.value = state.result;
402
+ node.kind = state.kind;
401
403
  stack.at(-1)?.children.push(node);
402
404
  nodes.push(node);
403
405
  if ('mapping' !== state.kind) return;
@@ -427,6 +429,36 @@ function parseWorkflowSource(source) {
427
429
  const value = (0, test_runner_namespaceObject.sanitizeReportText)(redacted, 65536);
428
430
  return {
429
431
  parsed,
432
+ locate (path) {
433
+ let node = nodes.at(-1);
434
+ for (const part of path){
435
+ if (!node) return;
436
+ while(1 === node.children.length && node.children[0].value === node.value)node = node.children[0];
437
+ if ('number' == typeof part && 'sequence' === node.kind) node = node.children[part];
438
+ else {
439
+ if ('string' != typeof part || 'mapping' !== node.kind) return;
440
+ const children = node.children;
441
+ node = void 0;
442
+ for(let index = 0; index < children.length; index += 2)if (children[index].value === part) {
443
+ node = children[index + 1];
444
+ break;
445
+ }
446
+ }
447
+ }
448
+ if (!node) return;
449
+ let start = node.start;
450
+ while(start < node.end)if (/\s/.test(source[start])) start++;
451
+ else if ('#' === source[start]) {
452
+ const end = source.indexOf('\n', start);
453
+ if (end < 0) return;
454
+ start = end + 1;
455
+ } else break;
456
+ const prefix = source.slice(0, start).split(/\r\n|\r|\n/);
457
+ return {
458
+ line: prefix.length,
459
+ column: prefix.at(-1).length + 1
460
+ };
461
+ },
430
462
  sourceYaml: {
431
463
  value,
432
464
  ...redactedPaths.size ? {
@@ -539,6 +571,18 @@ function collectWorkflowDocument(source, options) {
539
571
  const sourcePath = source.sourcePath.split(external_node_path_namespaceObject.sep).join('/');
540
572
  const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
541
573
  ...step,
574
+ meta: {
575
+ ...step.meta,
576
+ sourceLocation: snapshot.locate('steps' === phase ? [
577
+ 'cases',
578
+ caseIndex,
579
+ 'steps',
580
+ stepIndex
581
+ ] : [
582
+ phase,
583
+ stepIndex
584
+ ])
585
+ },
542
586
  templateInput: step.input,
543
587
  input: resolveWorkflowVariables(step.input, {
544
588
  variables: options.variables,
@@ -714,6 +758,9 @@ const writeTestProjectRunResult = (options)=>{
714
758
  markdown: relativeToSummary(result.summaryPath, result.markdownPath)
715
759
  } : {},
716
760
  summary: result.summary,
761
+ ...result.metrics ? {
762
+ metrics: result.metrics
763
+ } : {},
717
764
  projects: result.projects.map((project)=>({
718
765
  projectId: project.projectId,
719
766
  name: project.name,
@@ -1115,7 +1162,8 @@ async function prepareTestRun(options) {
1115
1162
  preflightScope: hasFormatMismatch ? 'run' : 'project',
1116
1163
  reportEnabled: true,
1117
1164
  publications: [],
1118
- onProgress: options.onProgress ?? (()=>{})
1165
+ onProgress: options.onProgress ?? (()=>{}),
1166
+ onEvent: options.onEvent
1119
1167
  };
1120
1168
  }
1121
1169
  const external_node_events_namespaceObject = require("node:events");
@@ -1196,6 +1244,38 @@ async function writeTestResultMarkdown(result, index = emptyIndex()) {
1196
1244
  }));
1197
1245
  return output;
1198
1246
  }
1247
+ function scopeNodeConsole(node) {
1248
+ return {
1249
+ ...node,
1250
+ execute (ctx) {
1251
+ const position = 'case' === ctx.scope ? ctx.case : ctx.document;
1252
+ const scope = {
1253
+ projectName: position.projectName,
1254
+ sourcePath: position.sourcePath,
1255
+ ...'case' === ctx.scope ? {
1256
+ caseId: ctx.case.caseId,
1257
+ caseName: ctx.case.name,
1258
+ runId: ctx.case.runId
1259
+ } : {
1260
+ runId: ctx.document.documentRunId
1261
+ },
1262
+ phase: position.phase,
1263
+ stepIndex: position.stepIndex,
1264
+ node: node.name
1265
+ };
1266
+ return (0, logger_namespaceObject.runWithConsoleLogScope)(scope, ()=>node.execute({
1267
+ ...ctx,
1268
+ onTeardown (teardown) {
1269
+ ctx.onTeardown(()=>(0, logger_namespaceObject.runWithConsoleLogScope)({
1270
+ ...scope,
1271
+ phase: 'teardown',
1272
+ stepIndex: void 0
1273
+ }, teardown));
1274
+ }
1275
+ }));
1276
+ }
1277
+ };
1278
+ }
1199
1279
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1200
1280
  const formatStep = (info)=>{
1201
1281
  const position = stepPosition(info);
@@ -1242,69 +1322,121 @@ async function executeDocumentInvocation(options) {
1242
1322
  const documentRetry = 'document' === invocation.retry.scope;
1243
1323
  const attempts = [];
1244
1324
  const fileSetup = invocation.bindings.documentSetup;
1245
- const documentSetup = options.isProjectResourceFailure && fileSetup ? {
1325
+ const documentSetup = fileSetup ? {
1246
1326
  ...fileSetup,
1247
- async setup (ctx) {
1248
- try {
1249
- return await fileSetup.setup(ctx);
1250
- } catch (error) {
1251
- if (isProjectResourceFailure(error)) options.markProjectFatal();
1252
- throw error;
1253
- }
1327
+ setup (ctx) {
1328
+ const scope = {
1329
+ projectName: project.name,
1330
+ sourcePath: document.sourcePath,
1331
+ runId: ctx.document.documentRunId,
1332
+ phase: 'fileSetup'
1333
+ };
1334
+ return (0, logger_namespaceObject.runWithConsoleLogScope)(scope, async ()=>{
1335
+ try {
1336
+ return await fileSetup.setup({
1337
+ ...ctx,
1338
+ onTeardown (teardown) {
1339
+ ctx.onTeardown(()=>(0, logger_namespaceObject.runWithConsoleLogScope)({
1340
+ ...scope,
1341
+ phase: 'teardown'
1342
+ }, teardown));
1343
+ }
1344
+ });
1345
+ } catch (error) {
1346
+ if (isProjectResourceFailure(error)) options.markProjectFatal();
1347
+ throw error;
1348
+ }
1349
+ });
1254
1350
  }
1255
- } : fileSetup;
1351
+ } : void 0;
1352
+ options.onEvent?.({
1353
+ type: 'document-start',
1354
+ documentId: document.documentId,
1355
+ projectName: project.name,
1356
+ sourcePath: document.sourcePath,
1357
+ cases: document.cases.length
1358
+ });
1256
1359
  await (0, test_runner_namespaceObject.runDocumentAttempts)({
1257
1360
  retry: documentRetry ? invocation.retry.count : 0,
1258
1361
  signal: options.signal,
1259
1362
  shouldStop: ()=>options.shouldBail() || options.isProjectFatal()
1260
1363
  }, async (documentAttemptIndex)=>{
1261
1364
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1262
- const execution = await (0, test_runner_namespaceObject.runWorkflowDocument)(document, {
1263
- plan: invocation.plan,
1264
- documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1265
- ...invocation.bindings,
1266
- documentSetup,
1267
- setupTimeoutMs: options.setupTimeoutMs,
1268
- teardownTimeoutMs: options.teardownTimeoutMs,
1269
- project,
1270
- projectContext: options.projectContext,
1271
- retry: documentRetry ? 0 : invocation.retry.count,
1272
- signal: options.signal,
1273
- defaultTimeoutMs: invocation.defaultTimeoutMs,
1274
- shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1275
- stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1276
- isFatalError: (run)=>[
1277
- ...run.beforeEach,
1278
- ...run.steps,
1279
- ...run.afterEach
1280
- ].some((step)=>step.error && ((0, test_runner_namespaceObject.isFatalDeviceError)(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>(0, test_runner_namespaceObject.isFatalDeviceError)(error) || isProjectResourceFailure(error)),
1281
- onCaseStart: (collectedCase)=>{
1282
- options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1283
- },
1284
- onStepStart: (info)=>{
1285
- const indent = 'case' === info.scope ? ' ' : ' ';
1286
- options.onProgress(`${indent}→ ${formatStep(info)}`);
1287
- },
1288
- onStepResult: async (info, result)=>{
1289
- if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1290
- await invocation.bindings.onStepResult?.(info, result);
1291
- options.onProgress(formatStepResult(info, result));
1292
- },
1293
- onCaseResult: async (attempt)=>{
1294
- await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1295
- options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1296
- },
1297
- onCaseOutcome: (outcome)=>{
1298
- if (outcome.attempts?.some((attempt)=>[
1299
- ...attempt.beforeEach,
1300
- ...attempt.steps,
1301
- ...attempt.afterEach
1302
- ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1303
- if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1304
- if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1305
- },
1306
- onDocumentResult: options.onDocumentResult
1307
- }).catch(async (error)=>{
1365
+ const execution = await (0, logger_namespaceObject.runWithConsoleLogScope)({
1366
+ projectName: project.name,
1367
+ sourcePath: document.sourcePath
1368
+ }, ()=>(0, test_runner_namespaceObject.runWorkflowDocument)(document, {
1369
+ plan: invocation.plan,
1370
+ documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1371
+ ...invocation.bindings,
1372
+ resolveNode: (name)=>scopeNodeConsole(invocation.bindings.resolveNode(name)),
1373
+ documentSetup,
1374
+ setupTimeoutMs: options.setupTimeoutMs,
1375
+ teardownTimeoutMs: options.teardownTimeoutMs,
1376
+ project,
1377
+ projectContext: options.projectContext,
1378
+ retry: documentRetry ? 0 : invocation.retry.count,
1379
+ signal: options.signal,
1380
+ defaultTimeoutMs: invocation.defaultTimeoutMs,
1381
+ shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1382
+ stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1383
+ isFatalError: (run)=>[
1384
+ ...run.beforeEach,
1385
+ ...run.steps,
1386
+ ...run.afterEach
1387
+ ].some((step)=>step.error && ((0, test_runner_namespaceObject.isFatalDeviceError)(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>(0, test_runner_namespaceObject.isFatalDeviceError)(error) || isProjectResourceFailure(error)),
1388
+ onCaseStart: (collectedCase)=>{
1389
+ options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1390
+ },
1391
+ onStepStart: (info)=>{
1392
+ options.onEvent?.({
1393
+ type: 'step-start',
1394
+ projectName: project.name,
1395
+ info
1396
+ });
1397
+ const indent = 'case' === info.scope ? ' ' : ' ';
1398
+ options.onProgress(`${indent}→ ${formatStep(info)}`);
1399
+ },
1400
+ onStepResult: async (info, result)=>{
1401
+ if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1402
+ await invocation.bindings.onStepResult?.(info, result);
1403
+ options.onProgress(formatStepResult(info, result));
1404
+ options.onEvent?.({
1405
+ type: 'step-end',
1406
+ projectName: project.name,
1407
+ info,
1408
+ result
1409
+ });
1410
+ },
1411
+ onCaseResult: async (attempt)=>{
1412
+ await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1413
+ options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} case attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1414
+ if (!documentRetry) options.onEvent?.({
1415
+ type: 'case-attempt-end',
1416
+ attempt,
1417
+ maxAttempts: invocation.retry.count + 1
1418
+ });
1419
+ },
1420
+ onCaseOutcome: (outcome)=>{
1421
+ if (documentRetry) options.onEvent?.({
1422
+ type: 'case-progress',
1423
+ outcome,
1424
+ documentAttemptIndex
1425
+ });
1426
+ else options.onEvent?.({
1427
+ type: 'case-end',
1428
+ outcome
1429
+ });
1430
+ if (outcome.attempts?.some((attempt)=>[
1431
+ ...attempt.beforeEach,
1432
+ ...attempt.steps,
1433
+ ...attempt.afterEach
1434
+ ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1435
+ if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1436
+ if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1437
+ },
1438
+ onDocumentResult: options.onDocumentResult
1439
+ })).catch(async (error)=>{
1308
1440
  if (error instanceof test_runner_namespaceObject.WorkflowExecutionFailure) {
1309
1441
  const partial = error.result;
1310
1442
  const resourceOnly = error.errors.length > 0 && error.errors.every((failure)=>(0, test_runner_namespaceObject.isResourceRecoveryError)(failure) || partial.document.hostErrors?.some((host)=>'cleanup' === host.phase && (host.error === failure || host.error instanceof Error && host.error.cause === failure)));
@@ -1374,6 +1506,11 @@ async function executeDocumentInvocation(options) {
1374
1506
  shouldStop: options.shouldBail()
1375
1507
  });
1376
1508
  if (documentRetry) execution.document.retryDecision = decision;
1509
+ if (documentRetry && 'retry' === decision.decision) options.onEvent?.({
1510
+ type: 'document-retry',
1511
+ document: execution.document,
1512
+ maxAttempts: invocation.retry.count + 1
1513
+ });
1377
1514
  attempts.push(execution);
1378
1515
  sinks.cases.push(...execution.cases.map((outcome)=>({
1379
1516
  ...outcome,
@@ -1408,7 +1545,18 @@ async function executeDocumentInvocation(options) {
1408
1545
  });
1409
1546
  const final = attempts.at(-1);
1410
1547
  if (!final) return;
1548
+ if (documentRetry) for (const outcome of final.cases)options.onEvent?.({
1549
+ type: 'case-end',
1550
+ outcome,
1551
+ documentRetries: attempts.length - 1
1552
+ });
1411
1553
  const failedCases = final.cases.filter((item)=>'failed' === item.status).length;
1554
+ options.onEvent?.({
1555
+ type: 'document-end',
1556
+ document: final.document,
1557
+ cases: final.cases,
1558
+ durationMs: attempts.reduce((total, attempt)=>total + attempt.document.durationMs, 0)
1559
+ });
1412
1560
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1413
1561
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1414
1562
  }
@@ -1448,11 +1596,45 @@ async function runPreparedFiles(options) {
1448
1596
  code: 'UNRESOLVED_FILE_RESOURCES'
1449
1597
  }));
1450
1598
  };
1599
+ const finalizeProject = (state)=>{
1600
+ const { prepared } = state;
1601
+ const completed = new Set(state.cases.map((item)=>item.caseId));
1602
+ const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1603
+ for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1604
+ documentId: document.documentId,
1605
+ caseId: item.caseId,
1606
+ projectName: prepared.project.name,
1607
+ name: item.definition.name,
1608
+ sourcePath: item.sourcePath,
1609
+ caseIndex: item.caseIndex,
1610
+ status: 'not-run',
1611
+ notRunReason: reason
1612
+ });
1613
+ const order = new Map(prepared.documents.map((document, i)=>[
1614
+ document.documentId,
1615
+ i
1616
+ ]));
1617
+ const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1618
+ state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1619
+ state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1620
+ };
1451
1621
  const finishProject = async (state)=>{
1452
1622
  if (!state.runtime || state.finished) return;
1453
1623
  state.finished = true;
1624
+ try {
1625
+ options.onEvent?.({
1626
+ type: 'project-phase',
1627
+ projectName: state.prepared.project.name,
1628
+ phase: 'teardown'
1629
+ });
1630
+ } catch (error) {
1631
+ options.onInfrastructureError(error);
1632
+ }
1454
1633
  const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1455
- state.lifecycle = await state.runtime.finish(failed ? 'failed' : 'success');
1634
+ state.lifecycle = await (0, logger_namespaceObject.runWithConsoleLogScope)({
1635
+ projectName: state.prepared.project.name,
1636
+ phase: 'teardown'
1637
+ }, ()=>state.runtime.finish(failed ? 'failed' : 'success'));
1456
1638
  const releaseProject = ()=>{
1457
1639
  occupiedProjects--;
1458
1640
  wake?.();
@@ -1466,7 +1648,11 @@ async function runPreparedFiles(options) {
1466
1648
  options.onInfrastructureError(error);
1467
1649
  releaseProject();
1468
1650
  });
1469
- } else releaseProject();
1651
+ } else {
1652
+ releaseProject();
1653
+ finalizeProject(state);
1654
+ options.onProjectEnd?.(state);
1655
+ }
1470
1656
  };
1471
1657
  const admit = ()=>{
1472
1658
  for(let offset = 0; offset < states.length; offset++){
@@ -1507,7 +1693,20 @@ async function runPreparedFiles(options) {
1507
1693
  });
1508
1694
  (0, external_node_events_namespaceObject.setMaxListeners)(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1509
1695
  options.announceProject(prepared, index);
1510
- await state.runtime.start();
1696
+ options.onEvent?.({
1697
+ type: 'project-phase',
1698
+ projectName: project.name,
1699
+ phase: 'setup'
1700
+ });
1701
+ await (0, logger_namespaceObject.runWithConsoleLogScope)({
1702
+ projectName: project.name,
1703
+ phase: 'setup'
1704
+ }, ()=>state.runtime.start());
1705
+ options.onEvent?.({
1706
+ type: 'project-phase',
1707
+ projectName: project.name,
1708
+ phase: 'running'
1709
+ });
1511
1710
  state.initializing = false;
1512
1711
  if (!state.runtime.canRun) {
1513
1712
  state.fatal = true;
@@ -1535,8 +1734,9 @@ async function runPreparedFiles(options) {
1535
1734
  }));
1536
1735
  },
1537
1736
  addFailedCases: options.addFailedCases,
1538
- onProgress: (message)=>options.onProgress(prepared, message),
1737
+ onProgress: (message)=>options.onProgress(prepared, options.maxConcurrentFiles > 1 ? ` [${invocation.document.sourcePath}]${message}` : message),
1539
1738
  sinks: state,
1739
+ onEvent: options.onEvent,
1540
1740
  isolatedContext: void 0 !== project.fileSetup,
1541
1741
  isProjectResourceFailure: isSharedResourceFailure,
1542
1742
  setupTimeoutMs: options.setupTimeoutMs,
@@ -1615,28 +1815,42 @@ async function runPreparedFiles(options) {
1615
1815
  for (const state of states){
1616
1816
  const { prepared } = state;
1617
1817
  if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1618
- const completed = new Set(state.cases.map((item)=>item.caseId));
1619
- const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1620
- for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1621
- documentId: document.documentId,
1622
- caseId: item.caseId,
1623
- projectName: prepared.project.name,
1624
- name: item.definition.name,
1625
- sourcePath: item.sourcePath,
1626
- caseIndex: item.caseIndex,
1627
- status: 'not-run',
1628
- notRunReason: reason
1629
- });
1630
- const order = new Map(prepared.documents.map((document, i)=>[
1631
- document.documentId,
1632
- i
1633
- ]));
1634
- const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1635
- state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1636
- state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1818
+ finalizeProject(state);
1637
1819
  }
1638
1820
  return states;
1639
1821
  }
1822
+ require("node:util");
1823
+ const env_namespaceObject = require("@midscene/shared/env");
1824
+ function configuredModelNames() {
1825
+ const env = env_namespaceObject.globalConfigManager.getAllEnvConfig();
1826
+ const defaultName = env.MIDSCENE_MODEL_NAME;
1827
+ const models = new Map();
1828
+ for (const [intent, name] of [
1829
+ [
1830
+ 'default',
1831
+ defaultName
1832
+ ],
1833
+ [
1834
+ 'planning',
1835
+ env.MIDSCENE_PLANNING_MODEL_NAME || defaultName
1836
+ ],
1837
+ [
1838
+ 'insight',
1839
+ env.MIDSCENE_INSIGHT_MODEL_NAME || defaultName
1840
+ ]
1841
+ ]){
1842
+ if (!name) continue;
1843
+ const intents = models.get(name) ?? [];
1844
+ intents.push(intent);
1845
+ models.set(name, intents);
1846
+ }
1847
+ return [
1848
+ ...models
1849
+ ].map(([name, intents])=>({
1850
+ name,
1851
+ intents
1852
+ }));
1853
+ }
1640
1854
  function createOperationJournal(runDir) {
1641
1855
  const path = (0, external_node_path_namespaceObject.join)(runDir, 'operations.jsonl');
1642
1856
  (0, external_node_fs_namespaceObject.writeFileSync)(path, '', {
@@ -1705,6 +1919,32 @@ async function runPreparedTestProject(plan) {
1705
1919
  const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
1706
1920
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1707
1921
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1922
+ plan.onEvent?.({
1923
+ type: 'run-start',
1924
+ configuredModels: configuredModelNames(),
1925
+ projects: preparedProjects.length,
1926
+ documents: totalDocuments + totalErrors,
1927
+ cases: totalCases,
1928
+ projectRoot,
1929
+ configPath,
1930
+ startedAt: startedAt.toISOString(),
1931
+ maxConcurrentProjects: definition.test.maxConcurrentProjects,
1932
+ maxConcurrentFiles: definition.test.maxConcurrentFiles,
1933
+ files: preparedProjects.flatMap((prepared)=>[
1934
+ ...prepared.documents.map((document)=>({
1935
+ documentId: document.documentId,
1936
+ projectName: prepared.project.name,
1937
+ sourcePath: document.sourcePath,
1938
+ cases: document.cases.length
1939
+ })),
1940
+ ...prepared.collectionErrors.map((error)=>({
1941
+ documentId: error.sourcePath,
1942
+ projectName: prepared.project.name,
1943
+ sourcePath: error.sourcePath,
1944
+ cases: 0
1945
+ }))
1946
+ ])
1947
+ });
1708
1948
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1709
1949
  const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
1710
1950
  await writeTestRunPlan(runDir, preparedProjects);
@@ -1807,7 +2047,12 @@ async function runPreparedTestProject(plan) {
1807
2047
  shouldBail: bailReached,
1808
2048
  addFailedCases,
1809
2049
  announceProject,
1810
- onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
2050
+ onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(preparedProjects.length > 1 || effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
2051
+ onEvent: (event)=>runInfrastructureCallback(()=>plan.onEvent?.(event)),
2052
+ onProjectEnd: (state)=>runInfrastructureCallback(()=>plan.onEvent?.({
2053
+ type: 'project-end',
2054
+ project: buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle)
2055
+ })),
1811
2056
  onInfrastructureError: recordInfrastructureError
1812
2057
  });
1813
2058
  states.forEach((state, index)=>{
@@ -1888,6 +2133,10 @@ async function runPreparedTestProject(plan) {
1888
2133
  buildRunnerDump: (index)=>{
1889
2134
  reportIndex = index;
1890
2135
  const dump = (0, test_runner_namespaceObject.buildTestRunReportDump)(completedResult, index);
2136
+ completedResult = {
2137
+ ...completedResult,
2138
+ metrics: dump.metrics
2139
+ };
1891
2140
  const warn = (0, logger_namespaceObject.getDebug)('test-runner:report-assembler', {
1892
2141
  console: true
1893
2142
  });
@@ -1905,6 +2154,16 @@ async function runPreparedTestProject(plan) {
1905
2154
  await publication.publish(completedResult);
1906
2155
  });
1907
2156
  await publish('write-result', (0, external_node_path_namespaceObject.join)((0, external_node_path_namespaceObject.dirname)(summaryPath), 'result.md'), async ()=>{
2157
+ if (!completedResult.metrics) {
2158
+ const dump = (0, test_runner_namespaceObject.buildTestRunReportDump)(completedResult, reportIndex ?? {
2159
+ sources: [],
2160
+ metrics: (0, test_runner_namespaceObject.sumTestRunReportMetrics)([])
2161
+ });
2162
+ completedResult = {
2163
+ ...completedResult,
2164
+ metrics: dump.metrics
2165
+ };
2166
+ }
1908
2167
  const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
1909
2168
  completedResult = {
1910
2169
  ...completedResult,