@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.
@@ -1,7 +1,7 @@
1
1
  import { dirname, isAbsolute, join, relative, resolve as external_node_path_resolve, sep } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { require as api_require } from "tsx/cjs/api";
4
- import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, getPendingScopeOperations, getResourceCleanupCompletion, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText, trackResourceOperation, waitForScopeOperations } from "@midscene/core/internal/test-runner";
4
+ import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, getPendingScopeOperations, getResourceCleanupCompletion, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText, sumTestRunReportMetrics as test_runner_sumTestRunReportMetrics, trackResourceOperation, waitForScopeOperations } from "@midscene/core/internal/test-runner";
5
5
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
6
  import { globSync } from "tinyglobby";
7
7
  import { createHash, randomUUID } from "node:crypto";
@@ -9,7 +9,9 @@ import { JSON_SCHEMA, load } from "js-yaml";
9
9
  import { mkdir, writeFile } from "node:fs/promises";
10
10
  import { setMaxListeners } from "node:events";
11
11
  import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
12
- import { getDebug } from "@midscene/shared/logger";
12
+ import { getDebug, runWithConsoleLogScope } from "@midscene/shared/logger";
13
+ import "node:util";
14
+ import { globalConfigManager } from "@midscene/shared/env";
13
15
  const registry_NodeRegistry = NodeRegistry;
14
16
  const projectIdFromIndex = (index)=>`project-${index}`;
15
17
  const defineTestProject = (definition)=>definition;
@@ -360,11 +362,13 @@ function parseWorkflowSource(source) {
360
362
  start: state.position + sourceOffset,
361
363
  end: 0,
362
364
  value: null,
363
- children: []
365
+ children: [],
366
+ kind: null
364
367
  });
365
368
  const node = stack.pop();
366
369
  node.end = state.position + sourceOffset;
367
370
  node.value = state.result;
371
+ node.kind = state.kind;
368
372
  stack.at(-1)?.children.push(node);
369
373
  nodes.push(node);
370
374
  if ('mapping' !== state.kind) return;
@@ -394,6 +398,36 @@ function parseWorkflowSource(source) {
394
398
  const value = sanitizeReportText(redacted, 65536);
395
399
  return {
396
400
  parsed,
401
+ locate (path) {
402
+ let node = nodes.at(-1);
403
+ for (const part of path){
404
+ if (!node) return;
405
+ while(1 === node.children.length && node.children[0].value === node.value)node = node.children[0];
406
+ if ('number' == typeof part && 'sequence' === node.kind) node = node.children[part];
407
+ else {
408
+ if ('string' != typeof part || 'mapping' !== node.kind) return;
409
+ const children = node.children;
410
+ node = void 0;
411
+ for(let index = 0; index < children.length; index += 2)if (children[index].value === part) {
412
+ node = children[index + 1];
413
+ break;
414
+ }
415
+ }
416
+ }
417
+ if (!node) return;
418
+ let start = node.start;
419
+ while(start < node.end)if (/\s/.test(source[start])) start++;
420
+ else if ('#' === source[start]) {
421
+ const end = source.indexOf('\n', start);
422
+ if (end < 0) return;
423
+ start = end + 1;
424
+ } else break;
425
+ const prefix = source.slice(0, start).split(/\r\n|\r|\n/);
426
+ return {
427
+ line: prefix.length,
428
+ column: prefix.at(-1).length + 1
429
+ };
430
+ },
397
431
  sourceYaml: {
398
432
  value,
399
433
  ...redactedPaths.size ? {
@@ -506,6 +540,18 @@ function collectWorkflowDocument(source, options) {
506
540
  const sourcePath = source.sourcePath.split(sep).join('/');
507
541
  const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
508
542
  ...step,
543
+ meta: {
544
+ ...step.meta,
545
+ sourceLocation: snapshot.locate('steps' === phase ? [
546
+ 'cases',
547
+ caseIndex,
548
+ 'steps',
549
+ stepIndex
550
+ ] : [
551
+ phase,
552
+ stepIndex
553
+ ])
554
+ },
509
555
  templateInput: step.input,
510
556
  input: resolveWorkflowVariables(step.input, {
511
557
  variables: options.variables,
@@ -680,6 +726,9 @@ const writeTestProjectRunResult = (options)=>{
680
726
  markdown: relativeToSummary(result.summaryPath, result.markdownPath)
681
727
  } : {},
682
728
  summary: result.summary,
729
+ ...result.metrics ? {
730
+ metrics: result.metrics
731
+ } : {},
683
732
  projects: result.projects.map((project)=>({
684
733
  projectId: project.projectId,
685
734
  name: project.name,
@@ -1081,7 +1130,8 @@ async function prepareTestRun(options) {
1081
1130
  preflightScope: hasFormatMismatch ? 'run' : 'project',
1082
1131
  reportEnabled: true,
1083
1132
  publications: [],
1084
- onProgress: options.onProgress ?? (()=>{})
1133
+ onProgress: options.onProgress ?? (()=>{}),
1134
+ onEvent: options.onEvent
1085
1135
  };
1086
1136
  }
1087
1137
  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, ' ');
@@ -1159,6 +1209,38 @@ async function writeTestResultMarkdown(result, index = emptyIndex()) {
1159
1209
  }));
1160
1210
  return output;
1161
1211
  }
1212
+ function scopeNodeConsole(node) {
1213
+ return {
1214
+ ...node,
1215
+ execute (ctx) {
1216
+ const position = 'case' === ctx.scope ? ctx.case : ctx.document;
1217
+ const scope = {
1218
+ projectName: position.projectName,
1219
+ sourcePath: position.sourcePath,
1220
+ ...'case' === ctx.scope ? {
1221
+ caseId: ctx.case.caseId,
1222
+ caseName: ctx.case.name,
1223
+ runId: ctx.case.runId
1224
+ } : {
1225
+ runId: ctx.document.documentRunId
1226
+ },
1227
+ phase: position.phase,
1228
+ stepIndex: position.stepIndex,
1229
+ node: node.name
1230
+ };
1231
+ return runWithConsoleLogScope(scope, ()=>node.execute({
1232
+ ...ctx,
1233
+ onTeardown (teardown) {
1234
+ ctx.onTeardown(()=>runWithConsoleLogScope({
1235
+ ...scope,
1236
+ phase: 'teardown',
1237
+ stepIndex: void 0
1238
+ }, teardown));
1239
+ }
1240
+ }));
1241
+ }
1242
+ };
1243
+ }
1162
1244
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1163
1245
  const formatStep = (info)=>{
1164
1246
  const position = stepPosition(info);
@@ -1205,69 +1287,121 @@ async function executeDocumentInvocation(options) {
1205
1287
  const documentRetry = 'document' === invocation.retry.scope;
1206
1288
  const attempts = [];
1207
1289
  const fileSetup = invocation.bindings.documentSetup;
1208
- const documentSetup = options.isProjectResourceFailure && fileSetup ? {
1290
+ const documentSetup = fileSetup ? {
1209
1291
  ...fileSetup,
1210
- async setup (ctx) {
1211
- try {
1212
- return await fileSetup.setup(ctx);
1213
- } catch (error) {
1214
- if (isProjectResourceFailure(error)) options.markProjectFatal();
1215
- throw error;
1216
- }
1292
+ setup (ctx) {
1293
+ const scope = {
1294
+ projectName: project.name,
1295
+ sourcePath: document.sourcePath,
1296
+ runId: ctx.document.documentRunId,
1297
+ phase: 'fileSetup'
1298
+ };
1299
+ return runWithConsoleLogScope(scope, async ()=>{
1300
+ try {
1301
+ return await fileSetup.setup({
1302
+ ...ctx,
1303
+ onTeardown (teardown) {
1304
+ ctx.onTeardown(()=>runWithConsoleLogScope({
1305
+ ...scope,
1306
+ phase: 'teardown'
1307
+ }, teardown));
1308
+ }
1309
+ });
1310
+ } catch (error) {
1311
+ if (isProjectResourceFailure(error)) options.markProjectFatal();
1312
+ throw error;
1313
+ }
1314
+ });
1217
1315
  }
1218
- } : fileSetup;
1316
+ } : void 0;
1317
+ options.onEvent?.({
1318
+ type: 'document-start',
1319
+ documentId: document.documentId,
1320
+ projectName: project.name,
1321
+ sourcePath: document.sourcePath,
1322
+ cases: document.cases.length
1323
+ });
1219
1324
  await runDocumentAttempts({
1220
1325
  retry: documentRetry ? invocation.retry.count : 0,
1221
1326
  signal: options.signal,
1222
1327
  shouldStop: ()=>options.shouldBail() || options.isProjectFatal()
1223
1328
  }, async (documentAttemptIndex)=>{
1224
1329
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1225
- const execution = await runWorkflowDocument(document, {
1226
- plan: invocation.plan,
1227
- documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1228
- ...invocation.bindings,
1229
- documentSetup,
1230
- setupTimeoutMs: options.setupTimeoutMs,
1231
- teardownTimeoutMs: options.teardownTimeoutMs,
1232
- project,
1233
- projectContext: options.projectContext,
1234
- retry: documentRetry ? 0 : invocation.retry.count,
1235
- signal: options.signal,
1236
- defaultTimeoutMs: invocation.defaultTimeoutMs,
1237
- shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1238
- stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1239
- isFatalError: (run)=>[
1240
- ...run.beforeEach,
1241
- ...run.steps,
1242
- ...run.afterEach
1243
- ].some((step)=>step.error && (isFatalDeviceError(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>isFatalDeviceError(error) || isProjectResourceFailure(error)),
1244
- onCaseStart: (collectedCase)=>{
1245
- options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1246
- },
1247
- onStepStart: (info)=>{
1248
- const indent = 'case' === info.scope ? ' ' : ' ';
1249
- options.onProgress(`${indent}→ ${formatStep(info)}`);
1250
- },
1251
- onStepResult: async (info, result)=>{
1252
- if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1253
- await invocation.bindings.onStepResult?.(info, result);
1254
- options.onProgress(formatStepResult(info, result));
1255
- },
1256
- onCaseResult: async (attempt)=>{
1257
- await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1258
- options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1259
- },
1260
- onCaseOutcome: (outcome)=>{
1261
- if (outcome.attempts?.some((attempt)=>[
1262
- ...attempt.beforeEach,
1263
- ...attempt.steps,
1264
- ...attempt.afterEach
1265
- ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1266
- if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1267
- if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1268
- },
1269
- onDocumentResult: options.onDocumentResult
1270
- }).catch(async (error)=>{
1330
+ const execution = await runWithConsoleLogScope({
1331
+ projectName: project.name,
1332
+ sourcePath: document.sourcePath
1333
+ }, ()=>runWorkflowDocument(document, {
1334
+ plan: invocation.plan,
1335
+ documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1336
+ ...invocation.bindings,
1337
+ resolveNode: (name)=>scopeNodeConsole(invocation.bindings.resolveNode(name)),
1338
+ documentSetup,
1339
+ setupTimeoutMs: options.setupTimeoutMs,
1340
+ teardownTimeoutMs: options.teardownTimeoutMs,
1341
+ project,
1342
+ projectContext: options.projectContext,
1343
+ retry: documentRetry ? 0 : invocation.retry.count,
1344
+ signal: options.signal,
1345
+ defaultTimeoutMs: invocation.defaultTimeoutMs,
1346
+ shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1347
+ stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1348
+ isFatalError: (run)=>[
1349
+ ...run.beforeEach,
1350
+ ...run.steps,
1351
+ ...run.afterEach
1352
+ ].some((step)=>step.error && (isFatalDeviceError(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>isFatalDeviceError(error) || isProjectResourceFailure(error)),
1353
+ onCaseStart: (collectedCase)=>{
1354
+ options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1355
+ },
1356
+ onStepStart: (info)=>{
1357
+ options.onEvent?.({
1358
+ type: 'step-start',
1359
+ projectName: project.name,
1360
+ info
1361
+ });
1362
+ const indent = 'case' === info.scope ? ' ' : ' ';
1363
+ options.onProgress(`${indent}→ ${formatStep(info)}`);
1364
+ },
1365
+ onStepResult: async (info, result)=>{
1366
+ if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1367
+ await invocation.bindings.onStepResult?.(info, result);
1368
+ options.onProgress(formatStepResult(info, result));
1369
+ options.onEvent?.({
1370
+ type: 'step-end',
1371
+ projectName: project.name,
1372
+ info,
1373
+ result
1374
+ });
1375
+ },
1376
+ onCaseResult: async (attempt)=>{
1377
+ await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1378
+ options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} case attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1379
+ if (!documentRetry) options.onEvent?.({
1380
+ type: 'case-attempt-end',
1381
+ attempt,
1382
+ maxAttempts: invocation.retry.count + 1
1383
+ });
1384
+ },
1385
+ onCaseOutcome: (outcome)=>{
1386
+ if (documentRetry) options.onEvent?.({
1387
+ type: 'case-progress',
1388
+ outcome,
1389
+ documentAttemptIndex
1390
+ });
1391
+ else options.onEvent?.({
1392
+ type: 'case-end',
1393
+ outcome
1394
+ });
1395
+ if (outcome.attempts?.some((attempt)=>[
1396
+ ...attempt.beforeEach,
1397
+ ...attempt.steps,
1398
+ ...attempt.afterEach
1399
+ ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1400
+ if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1401
+ if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1402
+ },
1403
+ onDocumentResult: options.onDocumentResult
1404
+ })).catch(async (error)=>{
1271
1405
  if (error instanceof WorkflowExecutionFailure) {
1272
1406
  const partial = error.result;
1273
1407
  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)));
@@ -1337,6 +1471,11 @@ async function executeDocumentInvocation(options) {
1337
1471
  shouldStop: options.shouldBail()
1338
1472
  });
1339
1473
  if (documentRetry) execution.document.retryDecision = decision;
1474
+ if (documentRetry && 'retry' === decision.decision) options.onEvent?.({
1475
+ type: 'document-retry',
1476
+ document: execution.document,
1477
+ maxAttempts: invocation.retry.count + 1
1478
+ });
1340
1479
  attempts.push(execution);
1341
1480
  sinks.cases.push(...execution.cases.map((outcome)=>({
1342
1481
  ...outcome,
@@ -1371,7 +1510,18 @@ async function executeDocumentInvocation(options) {
1371
1510
  });
1372
1511
  const final = attempts.at(-1);
1373
1512
  if (!final) return;
1513
+ if (documentRetry) for (const outcome of final.cases)options.onEvent?.({
1514
+ type: 'case-end',
1515
+ outcome,
1516
+ documentRetries: attempts.length - 1
1517
+ });
1374
1518
  const failedCases = final.cases.filter((item)=>'failed' === item.status).length;
1519
+ options.onEvent?.({
1520
+ type: 'document-end',
1521
+ document: final.document,
1522
+ cases: final.cases,
1523
+ durationMs: attempts.reduce((total, attempt)=>total + attempt.document.durationMs, 0)
1524
+ });
1375
1525
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1376
1526
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1377
1527
  }
@@ -1411,11 +1561,45 @@ async function runPreparedFiles(options) {
1411
1561
  code: 'UNRESOLVED_FILE_RESOURCES'
1412
1562
  }));
1413
1563
  };
1564
+ const finalizeProject = (state)=>{
1565
+ const { prepared } = state;
1566
+ const completed = new Set(state.cases.map((item)=>item.caseId));
1567
+ const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1568
+ for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1569
+ documentId: document.documentId,
1570
+ caseId: item.caseId,
1571
+ projectName: prepared.project.name,
1572
+ name: item.definition.name,
1573
+ sourcePath: item.sourcePath,
1574
+ caseIndex: item.caseIndex,
1575
+ status: 'not-run',
1576
+ notRunReason: reason
1577
+ });
1578
+ const order = new Map(prepared.documents.map((document, i)=>[
1579
+ document.documentId,
1580
+ i
1581
+ ]));
1582
+ const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1583
+ state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1584
+ state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1585
+ };
1414
1586
  const finishProject = async (state)=>{
1415
1587
  if (!state.runtime || state.finished) return;
1416
1588
  state.finished = true;
1589
+ try {
1590
+ options.onEvent?.({
1591
+ type: 'project-phase',
1592
+ projectName: state.prepared.project.name,
1593
+ phase: 'teardown'
1594
+ });
1595
+ } catch (error) {
1596
+ options.onInfrastructureError(error);
1597
+ }
1417
1598
  const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1418
- state.lifecycle = await state.runtime.finish(failed ? 'failed' : 'success');
1599
+ state.lifecycle = await runWithConsoleLogScope({
1600
+ projectName: state.prepared.project.name,
1601
+ phase: 'teardown'
1602
+ }, ()=>state.runtime.finish(failed ? 'failed' : 'success'));
1419
1603
  const releaseProject = ()=>{
1420
1604
  occupiedProjects--;
1421
1605
  wake?.();
@@ -1429,7 +1613,11 @@ async function runPreparedFiles(options) {
1429
1613
  options.onInfrastructureError(error);
1430
1614
  releaseProject();
1431
1615
  });
1432
- } else releaseProject();
1616
+ } else {
1617
+ releaseProject();
1618
+ finalizeProject(state);
1619
+ options.onProjectEnd?.(state);
1620
+ }
1433
1621
  };
1434
1622
  const admit = ()=>{
1435
1623
  for(let offset = 0; offset < states.length; offset++){
@@ -1470,7 +1658,20 @@ async function runPreparedFiles(options) {
1470
1658
  });
1471
1659
  setMaxListeners(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1472
1660
  options.announceProject(prepared, index);
1473
- await state.runtime.start();
1661
+ options.onEvent?.({
1662
+ type: 'project-phase',
1663
+ projectName: project.name,
1664
+ phase: 'setup'
1665
+ });
1666
+ await runWithConsoleLogScope({
1667
+ projectName: project.name,
1668
+ phase: 'setup'
1669
+ }, ()=>state.runtime.start());
1670
+ options.onEvent?.({
1671
+ type: 'project-phase',
1672
+ projectName: project.name,
1673
+ phase: 'running'
1674
+ });
1474
1675
  state.initializing = false;
1475
1676
  if (!state.runtime.canRun) {
1476
1677
  state.fatal = true;
@@ -1498,8 +1699,9 @@ async function runPreparedFiles(options) {
1498
1699
  }));
1499
1700
  },
1500
1701
  addFailedCases: options.addFailedCases,
1501
- onProgress: (message)=>options.onProgress(prepared, message),
1702
+ onProgress: (message)=>options.onProgress(prepared, options.maxConcurrentFiles > 1 ? ` [${invocation.document.sourcePath}]${message}` : message),
1502
1703
  sinks: state,
1704
+ onEvent: options.onEvent,
1503
1705
  isolatedContext: void 0 !== project.fileSetup,
1504
1706
  isProjectResourceFailure: isSharedResourceFailure,
1505
1707
  setupTimeoutMs: options.setupTimeoutMs,
@@ -1578,28 +1780,40 @@ async function runPreparedFiles(options) {
1578
1780
  for (const state of states){
1579
1781
  const { prepared } = state;
1580
1782
  if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1581
- const completed = new Set(state.cases.map((item)=>item.caseId));
1582
- const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1583
- for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1584
- documentId: document.documentId,
1585
- caseId: item.caseId,
1586
- projectName: prepared.project.name,
1587
- name: item.definition.name,
1588
- sourcePath: item.sourcePath,
1589
- caseIndex: item.caseIndex,
1590
- status: 'not-run',
1591
- notRunReason: reason
1592
- });
1593
- const order = new Map(prepared.documents.map((document, i)=>[
1594
- document.documentId,
1595
- i
1596
- ]));
1597
- const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1598
- state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1599
- state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1783
+ finalizeProject(state);
1600
1784
  }
1601
1785
  return states;
1602
1786
  }
1787
+ function configuredModelNames() {
1788
+ const env = globalConfigManager.getAllEnvConfig();
1789
+ const defaultName = env.MIDSCENE_MODEL_NAME;
1790
+ const models = new Map();
1791
+ for (const [intent, name] of [
1792
+ [
1793
+ 'default',
1794
+ defaultName
1795
+ ],
1796
+ [
1797
+ 'planning',
1798
+ env.MIDSCENE_PLANNING_MODEL_NAME || defaultName
1799
+ ],
1800
+ [
1801
+ 'insight',
1802
+ env.MIDSCENE_INSIGHT_MODEL_NAME || defaultName
1803
+ ]
1804
+ ]){
1805
+ if (!name) continue;
1806
+ const intents = models.get(name) ?? [];
1807
+ intents.push(intent);
1808
+ models.set(name, intents);
1809
+ }
1810
+ return [
1811
+ ...models
1812
+ ].map(([name, intents])=>({
1813
+ name,
1814
+ intents
1815
+ }));
1816
+ }
1603
1817
  function createOperationJournal(runDir) {
1604
1818
  const path = join(runDir, 'operations.jsonl');
1605
1819
  writeFileSync(path, '', {
@@ -1668,6 +1882,32 @@ async function runPreparedTestProject(plan) {
1668
1882
  const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
1669
1883
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1670
1884
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1885
+ plan.onEvent?.({
1886
+ type: 'run-start',
1887
+ configuredModels: configuredModelNames(),
1888
+ projects: preparedProjects.length,
1889
+ documents: totalDocuments + totalErrors,
1890
+ cases: totalCases,
1891
+ projectRoot,
1892
+ configPath,
1893
+ startedAt: startedAt.toISOString(),
1894
+ maxConcurrentProjects: definition.test.maxConcurrentProjects,
1895
+ maxConcurrentFiles: definition.test.maxConcurrentFiles,
1896
+ files: preparedProjects.flatMap((prepared)=>[
1897
+ ...prepared.documents.map((document)=>({
1898
+ documentId: document.documentId,
1899
+ projectName: prepared.project.name,
1900
+ sourcePath: document.sourcePath,
1901
+ cases: document.cases.length
1902
+ })),
1903
+ ...prepared.collectionErrors.map((error)=>({
1904
+ documentId: error.sourcePath,
1905
+ projectName: prepared.project.name,
1906
+ sourcePath: error.sourcePath,
1907
+ cases: 0
1908
+ }))
1909
+ ])
1910
+ });
1671
1911
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1672
1912
  const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
1673
1913
  await writeTestRunPlan(runDir, preparedProjects);
@@ -1770,7 +2010,12 @@ async function runPreparedTestProject(plan) {
1770
2010
  shouldBail: bailReached,
1771
2011
  addFailedCases,
1772
2012
  announceProject,
1773
- onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
2013
+ onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(preparedProjects.length > 1 || effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
2014
+ onEvent: (event)=>runInfrastructureCallback(()=>plan.onEvent?.(event)),
2015
+ onProjectEnd: (state)=>runInfrastructureCallback(()=>plan.onEvent?.({
2016
+ type: 'project-end',
2017
+ project: buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle)
2018
+ })),
1774
2019
  onInfrastructureError: recordInfrastructureError
1775
2020
  });
1776
2021
  states.forEach((state, index)=>{
@@ -1851,6 +2096,10 @@ async function runPreparedTestProject(plan) {
1851
2096
  buildRunnerDump: (index)=>{
1852
2097
  reportIndex = index;
1853
2098
  const dump = buildTestRunReportDump(completedResult, index);
2099
+ completedResult = {
2100
+ ...completedResult,
2101
+ metrics: dump.metrics
2102
+ };
1854
2103
  const warn = getDebug('test-runner:report-assembler', {
1855
2104
  console: true
1856
2105
  });
@@ -1868,6 +2117,16 @@ async function runPreparedTestProject(plan) {
1868
2117
  await publication.publish(completedResult);
1869
2118
  });
1870
2119
  await publish('write-result', join(dirname(summaryPath), 'result.md'), async ()=>{
2120
+ if (!completedResult.metrics) {
2121
+ const dump = buildTestRunReportDump(completedResult, reportIndex ?? {
2122
+ sources: [],
2123
+ metrics: test_runner_sumTestRunReportMetrics([])
2124
+ });
2125
+ completedResult = {
2126
+ ...completedResult,
2127
+ metrics: dump.metrics
2128
+ };
2129
+ }
1871
2130
  const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
1872
2131
  completedResult = {
1873
2132
  ...completedResult,