@midscene/test 1.16.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,7 @@ var __webpack_modules__ = {
29
29
  },
30
30
  "./package.json" (module) {
31
31
  module.exports = {
32
- rE: "1.16.0"
32
+ rE: "1.16.1"
33
33
  };
34
34
  }
35
35
  };
@@ -353,9 +353,19 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
353
353
  if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
354
354
  return value;
355
355
  };
356
- const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
356
+ const validateConcurrency = (value, label)=>{
357
+ if (null === value) return null;
358
+ return validatePositiveInteger(value, 1, label);
359
+ };
360
+ const validateFileConcurrency = (maxConcurrentFiles, fileSetup, label)=>{
361
+ if (maxConcurrentFiles > 1 && !fileSetup) throw new TypeError(`Midscene config ${label}.fileSetup is required when the effective maxConcurrentFiles is greater than 1.`);
362
+ };
363
+ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, globalNodes, test)=>{
357
364
  if (void 0 === value) {
358
365
  const setup = validateSetup(defaultSetup, 'setup');
366
+ const fileSetup = validateSetup(defaultFileSetup, 'fileSetup');
367
+ const maxConcurrentFiles = test.maxConcurrentFiles ?? 1 / 0;
368
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, 'default');
359
369
  return {
360
370
  hasExplicitProjects: false,
361
371
  projects: Object.freeze([
@@ -365,6 +375,10 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
365
375
  ...setup ? {
366
376
  setup
367
377
  } : {},
378
+ ...fileSetup ? {
379
+ fileSetup
380
+ } : {},
381
+ maxConcurrentFiles,
368
382
  tags: Object.freeze({
369
383
  include: [],
370
384
  exclude: []
@@ -377,6 +391,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
377
391
  };
378
392
  }
379
393
  if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
394
+ if (void 0 !== defaultFileSetup) throw new TypeError('Midscene config fileSetup cannot be used together with projects. Move fileSetup to projects[].fileSetup.');
380
395
  if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
381
396
  const names = new Set();
382
397
  const projects = value.map((candidate, index)=>{
@@ -385,6 +400,8 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
385
400
  rejectUnknownKeys(candidate, [
386
401
  'name',
387
402
  'setup',
403
+ 'fileSetup',
404
+ 'maxConcurrentFiles',
388
405
  'nodes',
389
406
  'files',
390
407
  'tags',
@@ -401,10 +418,18 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
401
418
  ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
402
419
  ...localNodes.definitions()
403
420
  ]);
421
+ const fileSetup = validateSetup(candidate.fileSetup, `${label}.fileSetup`);
422
+ const globalFileLimit = test.maxConcurrentFiles ?? 1 / 0;
423
+ const maxConcurrentFiles = Math.min(globalFileLimit, validatePositiveInteger(candidate.maxConcurrentFiles, globalFileLimit, `${label}.maxConcurrentFiles`));
424
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, label);
404
425
  return Object.freeze({
405
426
  projectId: projectIdFromIndex(index),
406
427
  name: candidate.name,
407
428
  nodes,
429
+ ...fileSetup ? {
430
+ fileSetup
431
+ } : {},
432
+ maxConcurrentFiles,
408
433
  ...files ? {
409
434
  files
410
435
  } : {},
@@ -421,11 +446,25 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
421
446
  hasExplicitProjects: true
422
447
  };
423
448
  };
424
- const validateTestOptions = (value)=>{
449
+ const validateTestOptions = (value, hasExplicitProjects)=>{
425
450
  if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
426
451
  const candidate = value ?? {};
452
+ if (void 0 !== candidate.maxConcurrency) {
453
+ const limit = 'number' == typeof candidate.maxConcurrency && Number.isInteger(candidate.maxConcurrency) && candidate.maxConcurrency > 0 ? candidate.maxConcurrency : 1;
454
+ throw new TypeError([
455
+ 'Midscene config test.maxConcurrency is no longer supported.',
456
+ 'It controlled project concurrency, not file concurrency. Remove it and use:',
457
+ ` test: { maxConcurrentProjects: ${limit}, maxConcurrentFiles: ${hasExplicitProjects ? limit : 1} }`,
458
+ hasExplicitProjects ? ' Set projects[].maxConcurrentFiles to 1 to keep files sequential within each project.' : ' The implicit default project keeps running files sequentially.',
459
+ hasExplicitProjects ? 'For parallel files, configure fileSetup and raise both test.maxConcurrentFiles and projects[].maxConcurrentFiles.' : 'For parallel files, configure fileSetup and raise test.maxConcurrentFiles.',
460
+ 'Migration guide: https://midscenejs.com/midscene-test/configuration.html#existing-configurations'
461
+ ].join('\n'));
462
+ }
427
463
  return Object.freeze({
428
- maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
464
+ maxConcurrentFiles: validateConcurrency(candidate.maxConcurrentFiles, 'test.maxConcurrentFiles'),
465
+ maxConcurrentProjects: validateConcurrency(candidate.maxConcurrentProjects, 'test.maxConcurrentProjects'),
466
+ setupTimeout: validatePositiveInteger(candidate.setupTimeout, 120000, 'test.setupTimeout'),
467
+ teardownTimeout: validatePositiveInteger(candidate.teardownTimeout, 30000, 'test.teardownTimeout'),
429
468
  bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
430
469
  testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
431
470
  });
@@ -455,16 +494,18 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
455
494
  if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
456
495
  rejectUnknownKeys(definition, [
457
496
  'setup',
497
+ 'fileSetup',
458
498
  'projects',
459
499
  'test',
460
500
  'output',
461
501
  'nodes'
462
502
  ], 'root');
463
503
  const nodes = new NodeRegistry(definition.nodes);
464
- const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
504
+ const test = validateTestOptions(definition.test, void 0 !== definition.projects);
505
+ const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, definition.fileSetup, nodes, test);
465
506
  return {
466
507
  ...resolvedProjects,
467
- test: validateTestOptions(definition.test),
508
+ test,
468
509
  output: validateOutput(definition.output),
469
510
  nodes,
470
511
  resolveNode: (name)=>nodes.get(name)
@@ -1142,7 +1183,24 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1142
1183
  defaultTimeoutMs,
1143
1184
  reportEnabled: true,
1144
1185
  bindings: {
1145
- resolveNode: project.nodes.require.bind(project.nodes)
1186
+ resolveNode: project.nodes.require.bind(project.nodes),
1187
+ ...project.fileSetup ? {
1188
+ documentSetup: {
1189
+ name: project.fileSetup.name,
1190
+ setup ({ projectContext, document, signal, onTeardown }) {
1191
+ return project.fileSetup.setup({
1192
+ project,
1193
+ projectContext,
1194
+ file: document,
1195
+ env: Object.freeze({
1196
+ ...process.env
1197
+ }),
1198
+ signal,
1199
+ onTeardown
1200
+ });
1201
+ }
1202
+ }
1203
+ } : {}
1146
1204
  }
1147
1205
  });
1148
1206
  } catch (error) {
@@ -1348,11 +1406,40 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1348
1406
  ...result.beforeAll,
1349
1407
  ...result.afterAll
1350
1408
  ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (result.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError) || !!result.executionErrors?.some(test_runner_namespaceObject.isResourceRecoveryError);
1409
+ const isSharedResourceFailure = (error)=>{
1410
+ const seen = new Set();
1411
+ const pending = [
1412
+ error
1413
+ ];
1414
+ while(pending.length && seen.size < 50){
1415
+ const item = pending.pop();
1416
+ if ('object' != typeof item || null === item || seen.has(item)) continue;
1417
+ seen.add(item);
1418
+ const failure = item;
1419
+ if ('PROJECT_RESOURCE_ERROR' === failure.code) return true;
1420
+ if (failure.cause) pending.push(failure.cause);
1421
+ if (Array.isArray(failure.errors)) pending.push(...failure.errors.slice(0, 50));
1422
+ }
1423
+ return false;
1424
+ };
1351
1425
  async function executeDocumentInvocation(options) {
1352
1426
  const { invocation, project, sinks } = options;
1427
+ const isProjectResourceFailure = options.isProjectResourceFailure ?? (()=>false);
1353
1428
  const { document } = invocation;
1354
1429
  const documentRetry = 'document' === invocation.retry.scope;
1355
1430
  const attempts = [];
1431
+ const fileSetup = invocation.bindings.documentSetup;
1432
+ const documentSetup = options.isProjectResourceFailure && fileSetup ? {
1433
+ ...fileSetup,
1434
+ async setup (ctx) {
1435
+ try {
1436
+ return await fileSetup.setup(ctx);
1437
+ } catch (error) {
1438
+ if (isProjectResourceFailure(error)) options.markProjectFatal();
1439
+ throw error;
1440
+ }
1441
+ }
1442
+ } : fileSetup;
1356
1443
  await (0, test_runner_namespaceObject.runDocumentAttempts)({
1357
1444
  retry: documentRetry ? invocation.retry.count : 0,
1358
1445
  signal: options.signal,
@@ -1363,6 +1450,9 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1363
1450
  plan: invocation.plan,
1364
1451
  documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1365
1452
  ...invocation.bindings,
1453
+ documentSetup,
1454
+ setupTimeoutMs: options.setupTimeoutMs,
1455
+ teardownTimeoutMs: options.teardownTimeoutMs,
1366
1456
  project,
1367
1457
  projectContext: options.projectContext,
1368
1458
  retry: documentRetry ? 0 : invocation.retry.count,
@@ -1374,7 +1464,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1374
1464
  ...run.beforeEach,
1375
1465
  ...run.steps,
1376
1466
  ...run.afterEach
1377
- ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (run.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError),
1467
+ ].some((step)=>step.error && ((0, test_runner_namespaceObject.isFatalDeviceError)(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>(0, test_runner_namespaceObject.isFatalDeviceError)(error) || isProjectResourceFailure(error)),
1378
1468
  onCaseStart: (collectedCase)=>{
1379
1469
  options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1380
1470
  },
@@ -1383,6 +1473,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1383
1473
  options.onProgress(`${indent}→ ${formatStep(info)}`);
1384
1474
  },
1385
1475
  onStepResult: async (info, result)=>{
1476
+ if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1386
1477
  await invocation.bindings.onStepResult?.(info, result);
1387
1478
  options.onProgress(formatStepResult(info, result));
1388
1479
  },
@@ -1391,7 +1482,12 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1391
1482
  options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1392
1483
  },
1393
1484
  onCaseOutcome: (outcome)=>{
1394
- if (caseHasFatalError(outcome)) options.markProjectFatal();
1485
+ if (outcome.attempts?.some((attempt)=>[
1486
+ ...attempt.beforeEach,
1487
+ ...attempt.steps,
1488
+ ...attempt.afterEach
1489
+ ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1490
+ if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1395
1491
  if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1396
1492
  },
1397
1493
  onDocumentResult: options.onDocumentResult
@@ -1400,7 +1496,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1400
1496
  const partial = error.result;
1401
1497
  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)));
1402
1498
  if (resourceOnly) {
1403
- if (error.errors.some(test_runner_namespaceObject.isResourceRecoveryError)) options.markProjectFatal();
1499
+ if (!options.isolatedContext && error.errors.some(test_runner_namespaceObject.isResourceRecoveryError)) options.markProjectFatal();
1404
1500
  return partial;
1405
1501
  }
1406
1502
  if (documentRetry) partial.document.retryDecision = (0, test_runner_namespaceObject.decideRetry)({
@@ -1429,7 +1525,17 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1429
1525
  }
1430
1526
  throw error;
1431
1527
  });
1432
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1528
+ if (!options.isolatedContext && documentHasFatalError(execution.document)) options.markProjectFatal();
1529
+ if ([
1530
+ ...execution.document.beforeAll,
1531
+ ...execution.document.afterAll,
1532
+ ...(execution.document.hostErrors ?? []).map(({ error })=>({
1533
+ error
1534
+ })),
1535
+ ...(execution.document.teardownErrors ?? []).map((error)=>({
1536
+ error
1537
+ }))
1538
+ ].some(({ error })=>isProjectResourceFailure(error))) options.markProjectFatal();
1433
1539
  const owners = [
1434
1540
  execution.document,
1435
1541
  ...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
@@ -1437,7 +1543,10 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1437
1543
  const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
1438
1544
  const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
1439
1545
  const cleanupFailed = owners.some((owner)=>owner.cleanup?.status === 'failed' || !!owner.teardownErrors?.length || 'hostErrors' in owner && owner.hostErrors?.some(({ phase })=>'cleanup' === phase));
1440
- if (cleanupPending || cleanupFailed || operationPending) options.markProjectFatal();
1546
+ if (cleanupPending || operationPending) {
1547
+ options.markProjectFatal();
1548
+ options.onUnresolvedResources?.();
1549
+ } else if (cleanupFailed && !options.isolatedContext) options.markProjectFatal();
1441
1550
  const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1442
1551
  const decision = (0, test_runner_namespaceObject.decideRetry)({
1443
1552
  scope: 'document',
@@ -1490,6 +1599,231 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1490
1599
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1491
1600
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1492
1601
  }
1602
+ async function runPreparedFiles(options) {
1603
+ const states = options.projects.map((prepared)=>({
1604
+ prepared,
1605
+ cases: [],
1606
+ documents: [],
1607
+ cursor: 0,
1608
+ active: 0,
1609
+ initializing: false,
1610
+ finished: false,
1611
+ fatal: false,
1612
+ setupFailed: false
1613
+ }));
1614
+ let nextProject = 0;
1615
+ let resourcesUnresolved = false;
1616
+ let occupied = 0;
1617
+ let occupiedProjects = 0;
1618
+ const cleanupQueue = [];
1619
+ const release = ()=>{
1620
+ occupied--;
1621
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1622
+ occupied++;
1623
+ cleanupQueue.shift()();
1624
+ }
1625
+ wake?.();
1626
+ };
1627
+ let wake;
1628
+ const running = new Set();
1629
+ const stopped = (state)=>options.signal.aborted || options.shouldBail() || state.fatal;
1630
+ const invalid = (state)=>options.preflightFailed || state.prepared.collectionErrors.length > 0;
1631
+ const unresolved = ()=>{
1632
+ if (resourcesUnresolved) return;
1633
+ resourcesUnresolved = true;
1634
+ options.onInfrastructureError(new test_runner_namespaceObject.WorkflowError('Resources are still running or cleanup is pending. No new test files will start.', {
1635
+ code: 'UNRESOLVED_FILE_RESOURCES'
1636
+ }));
1637
+ };
1638
+ const finishProject = async (state)=>{
1639
+ if (!state.runtime || state.finished) return;
1640
+ state.finished = true;
1641
+ const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1642
+ state.lifecycle = await state.runtime.finish(failed ? 'failed' : 'success');
1643
+ const releaseProject = ()=>{
1644
+ occupiedProjects--;
1645
+ wake?.();
1646
+ };
1647
+ if (state.lifecycle.cleanup?.status === 'pending' || state.lifecycle.pendingOperations?.length) {
1648
+ unresolved();
1649
+ Promise.all([
1650
+ (0, test_runner_namespaceObject.waitForScopeOperations)(state.runtime.signal),
1651
+ (0, test_runner_namespaceObject.getResourceCleanupCompletion)(state.runtime.signal)
1652
+ ]).then(releaseProject, (error)=>{
1653
+ options.onInfrastructureError(error);
1654
+ releaseProject();
1655
+ });
1656
+ } else releaseProject();
1657
+ };
1658
+ const admit = ()=>{
1659
+ for(let offset = 0; offset < states.length; offset++){
1660
+ const index = (nextProject + offset) % states.length;
1661
+ const state = states[index];
1662
+ if (invalid(state) || state.finished || state.initializing) continue;
1663
+ if (!state.runtime && occupiedProjects >= options.maxConcurrentProjects) continue;
1664
+ const cleanupOnly = !!state.runtime && 0 === state.active && (stopped(state) || state.cursor === state.prepared.invocations.length);
1665
+ if (!cleanupOnly && (stopped(state) || state.cursor >= state.prepared.invocations.length || state.active >= (state.prepared.project.maxConcurrentFiles ?? options.maxConcurrentFiles))) continue;
1666
+ nextProject = (index + 1) % states.length;
1667
+ occupied++;
1668
+ state.active++;
1669
+ if (!state.runtime && !cleanupOnly) {
1670
+ occupiedProjects++;
1671
+ state.initializing = true;
1672
+ }
1673
+ const invocationIndex = cleanupOnly ? -1 : state.cursor++;
1674
+ return {
1675
+ state,
1676
+ index,
1677
+ invocationIndex
1678
+ };
1679
+ }
1680
+ };
1681
+ const run = async ({ state, index, invocationIndex })=>{
1682
+ const { prepared } = state;
1683
+ const { project } = prepared;
1684
+ let fileResourcesPending = false;
1685
+ try {
1686
+ if (invocationIndex < 0) return void await finishProject(state);
1687
+ if (!state.runtime) {
1688
+ state.runtime = (0, test_runner_namespaceObject.createProjectRuntime)({
1689
+ project,
1690
+ setup: project.setup,
1691
+ signal: options.signal,
1692
+ setupTimeoutMs: options.setupTimeoutMs,
1693
+ teardownTimeoutMs: options.teardownTimeoutMs
1694
+ });
1695
+ (0, external_node_events_namespaceObject.setMaxListeners)(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1696
+ options.announceProject(prepared, index);
1697
+ await state.runtime.start();
1698
+ state.initializing = false;
1699
+ if (!state.runtime.canRun) {
1700
+ state.fatal = true;
1701
+ state.setupFailed = true;
1702
+ }
1703
+ wake?.();
1704
+ }
1705
+ if (stopped(state)) return;
1706
+ const invocation = prepared.invocations[invocationIndex];
1707
+ options.onProgress(prepared, ` [document ${invocationIndex + 1}/${prepared.invocations.length}] ${invocation.document.sourcePath}`);
1708
+ await executeDocumentInvocation({
1709
+ invocation,
1710
+ project,
1711
+ projectContext: state.runtime.context,
1712
+ runDir: options.runDir,
1713
+ signal: state.runtime.signal,
1714
+ rootSignal: options.signal,
1715
+ shouldBail: options.shouldBail,
1716
+ isProjectFatal: ()=>state.fatal,
1717
+ markProjectFatal: ()=>{
1718
+ if (state.fatal) return;
1719
+ state.fatal = true;
1720
+ state.runtime?.abort(new test_runner_namespaceObject.WorkflowError(`Project "${project.name}" shared execution environment is unavailable.`, {
1721
+ code: 'PROJECT_RESOURCE_ERROR'
1722
+ }));
1723
+ },
1724
+ addFailedCases: options.addFailedCases,
1725
+ onProgress: (message)=>options.onProgress(prepared, message),
1726
+ sinks: state,
1727
+ isolatedContext: void 0 !== project.fileSetup,
1728
+ isProjectResourceFailure: isSharedResourceFailure,
1729
+ setupTimeoutMs: options.setupTimeoutMs,
1730
+ teardownTimeoutMs: options.teardownTimeoutMs,
1731
+ onUnresolvedResources: ()=>{
1732
+ fileResourcesPending = true;
1733
+ unresolved();
1734
+ }
1735
+ });
1736
+ } catch (error) {
1737
+ state.fatal = true;
1738
+ fileResourcesPending = !!state.runtime && (0, test_runner_namespaceObject.getPendingScopeOperations)(state.runtime.signal).length > 0;
1739
+ options.onInfrastructureError(error);
1740
+ } finally{
1741
+ state.initializing = false;
1742
+ try {
1743
+ if (1 === state.active && (stopped(state) || state.cursor === prepared.invocations.length)) await finishProject(state);
1744
+ } catch (error) {
1745
+ options.onInfrastructureError(error);
1746
+ }
1747
+ state.active--;
1748
+ if (state.runtime && (fileResourcesPending || state.lifecycle?.cleanup?.status === 'pending')) (0, test_runner_namespaceObject.waitForScopeOperations)(state.runtime.signal).then(release, (error)=>{
1749
+ options.onInfrastructureError(error);
1750
+ release();
1751
+ });
1752
+ else release();
1753
+ wake?.();
1754
+ }
1755
+ };
1756
+ while(true){
1757
+ const changed = new Promise((resolve)=>{
1758
+ wake = resolve;
1759
+ });
1760
+ while(occupied < options.maxConcurrentFiles){
1761
+ const job = admit();
1762
+ if (!job) break;
1763
+ const task = run(job);
1764
+ running.add(task);
1765
+ const settled = ()=>{
1766
+ running.delete(task);
1767
+ wake?.();
1768
+ };
1769
+ task.then(settled, (error)=>{
1770
+ options.onInfrastructureError(error);
1771
+ settled();
1772
+ });
1773
+ }
1774
+ if (!running.size) break;
1775
+ await changed;
1776
+ }
1777
+ wake = void 0;
1778
+ await Promise.all(states.map(async (state)=>{
1779
+ if (!state.runtime || state.finished) return;
1780
+ const admission = new Promise((resolve)=>{
1781
+ cleanupQueue.push(resolve);
1782
+ });
1783
+ (0, test_runner_namespaceObject.trackResourceOperation)({}, admission, state.runtime.signal);
1784
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1785
+ occupied++;
1786
+ cleanupQueue.shift()();
1787
+ }
1788
+ try {
1789
+ await finishProject(state);
1790
+ } catch (error) {
1791
+ options.onInfrastructureError(error);
1792
+ } finally{
1793
+ Promise.all([
1794
+ admission,
1795
+ (0, test_runner_namespaceObject.getResourceCleanupCompletion)(state.runtime.signal)
1796
+ ]).then(release, (error)=>{
1797
+ options.onInfrastructureError(error);
1798
+ release();
1799
+ });
1800
+ }
1801
+ }));
1802
+ for (const state of states){
1803
+ const { prepared } = state;
1804
+ if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1805
+ const completed = new Set(state.cases.map((item)=>item.caseId));
1806
+ const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1807
+ for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1808
+ documentId: document.documentId,
1809
+ caseId: item.caseId,
1810
+ projectName: prepared.project.name,
1811
+ name: item.definition.name,
1812
+ sourcePath: item.sourcePath,
1813
+ caseIndex: item.caseIndex,
1814
+ status: 'not-run',
1815
+ notRunReason: reason
1816
+ });
1817
+ const order = new Map(prepared.documents.map((document, i)=>[
1818
+ document.documentId,
1819
+ i
1820
+ ]));
1821
+ const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1822
+ state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1823
+ state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1824
+ }
1825
+ return states;
1826
+ }
1493
1827
  function createOperationJournal(runDir) {
1494
1828
  const path = (0, external_node_path_.join)(runDir, 'operations.jsonl');
1495
1829
  (0, external_node_fs_.writeFileSync)(path, '', {
@@ -1559,7 +1893,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1559
1893
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1560
1894
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1561
1895
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1562
- const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1896
+ const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
1563
1897
  await writeTestRunPlan(runDir, preparedProjects);
1564
1898
  const journal = createOperationJournal(runDir);
1565
1899
  const rootController = new AbortController();
@@ -1573,6 +1907,18 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1573
1907
  process.on('SIGTERM', sigterm);
1574
1908
  let failedCaseCount = 0;
1575
1909
  const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
1910
+ let shutdownDeadline;
1911
+ const beginShutdown = ()=>{
1912
+ shutdownDeadline ??= Date.now() + definition.test.teardownTimeout;
1913
+ };
1914
+ rootController.signal.addEventListener('abort', beginShutdown, {
1915
+ once: true
1916
+ });
1917
+ const addFailedCases = (count)=>{
1918
+ failedCaseCount += count;
1919
+ if (bailReached()) beginShutdown();
1920
+ };
1921
+ const teardownTimeoutMs = ()=>Math.max(0, Math.min(definition.test.teardownTimeout, void 0 === shutdownDeadline ? 1 / 0 : shutdownDeadline - Date.now()));
1576
1922
  let hasInfrastructureError = false;
1577
1923
  const infrastructureErrors = [];
1578
1924
  const recordInfrastructureError = (error)=>{
@@ -1634,112 +1980,25 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
1634
1980
  announceProject(prepared, projectIndex);
1635
1981
  return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
1636
1982
  };
1637
- const runPreparedProject = async (prepared, projectIndex)=>{
1638
- const { project } = prepared;
1639
- announceProject(prepared, projectIndex);
1640
- const projectProgress = (message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${project.name}]${message}` : message));
1641
- const cases = [];
1642
- const documents = [];
1643
- let lifecycle;
1644
- let projectFatal = false;
1645
- if (prepared.collectionErrors.length > 0 || 'run' === plan.preflightScope && totalErrors > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
1646
- else if (rootController.signal.aborted) cases.push(...notRunSuite(prepared, 'interrupted'));
1647
- else if (bailReached()) cases.push(...notRunSuite(prepared, 'bail'));
1648
- else {
1649
- const runtime = (0, test_runner_namespaceObject.createProjectRuntime)({
1650
- project,
1651
- setup: project.setup,
1652
- signal: rootController.signal
1653
- });
1654
- let hasProjectExecutionError = false;
1655
- try {
1656
- await runtime.start();
1657
- if (runtime.canRun) {
1658
- const runDocument = async (invocation, documentIndex)=>{
1659
- const { document } = invocation;
1660
- if (rootController.signal.aborted || bailReached() || projectFatal) {
1661
- const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1662
- cases.push(...document.cases.map((item)=>asNotRun(document.documentId, item, project.name, reason)));
1663
- return;
1664
- }
1665
- projectProgress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
1666
- await executeDocumentInvocation({
1667
- invocation,
1668
- project,
1669
- projectContext: runtime.context,
1670
- runDir,
1671
- signal: runtime.signal,
1672
- rootSignal: rootController.signal,
1673
- shouldBail: bailReached,
1674
- isProjectFatal: ()=>projectFatal,
1675
- markProjectFatal: ()=>{
1676
- projectFatal = true;
1677
- },
1678
- addFailedCases: (count)=>{
1679
- failedCaseCount += count;
1680
- },
1681
- onProgress: projectProgress,
1682
- sinks: {
1683
- cases,
1684
- documents
1685
- }
1686
- });
1687
- };
1688
- let prerequisiteFailed = false;
1689
- const prerequisite = prepared.invocations.find((invocation)=>invocation.document.documentId === prepared.prerequisiteDocumentId);
1690
- const prerequisiteCount = prerequisite ? 1 : 0;
1691
- if (prerequisite) {
1692
- await runDocument(prerequisite, 0);
1693
- prerequisiteFailed = documents.at(-1)?.status === 'failed' || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status);
1694
- }
1695
- if (!prerequisiteFailed) await (0, test_runner_namespaceObject.runConcurrentJobs)(prepared.invocations.filter((invocation)=>invocation !== prerequisite), {
1696
- concurrency: prepared.documentConcurrency,
1697
- shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal
1698
- }, async (invocation, index)=>{
1699
- try {
1700
- await runDocument(invocation, index + prerequisiteCount);
1701
- } catch (error) {
1702
- recordInfrastructureError(error);
1703
- throw error;
1704
- }
1705
- });
1706
- const completed = new Set(cases.map((item)=>item.caseId));
1707
- const reason = prerequisiteFailed ? 'project-setup-failed' : rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1708
- cases.push(...notRunSuite(prepared, reason).filter((item)=>!completed.has(item.caseId)));
1709
- } else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
1710
- } catch (error) {
1711
- hasProjectExecutionError = true;
1712
- recordInfrastructureError(error);
1713
- } finally{
1714
- 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;
1715
- lifecycle = await runtime.finish(hasFailure ? 'failed' : 'success');
1716
- }
1717
- if (hasProjectExecutionError) {
1718
- const completed = new Set(cases.map((item)=>item.caseId));
1719
- cases.push(...notRunSuite(prepared, 'interrupted').filter((item)=>!completed.has(item.caseId)));
1720
- }
1721
- }
1722
- const order = new Map(prepared.documents.map((document, index)=>[
1723
- document.documentId,
1724
- index
1725
- ]));
1726
- const documentOrder = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1727
- cases.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || a.caseIndex - b.caseIndex);
1728
- documents.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || (a.attemptIndex ?? 0) - (b.attemptIndex ?? 0));
1729
- return buildProjectResult(prepared, cases, documents, lifecycle);
1730
- };
1731
1983
  const projectResults = new Array(preparedProjects.length);
1732
1984
  try {
1733
- await (0, test_runner_namespaceObject.runConcurrentJobs)(preparedProjects, {
1734
- concurrency: effectiveConcurrency,
1735
- shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
1736
- }, async (prepared, index)=>{
1737
- try {
1738
- projectResults[index] = await runPreparedProject(prepared, index);
1739
- } catch (error) {
1740
- recordInfrastructureError(error);
1741
- throw error;
1742
- }
1985
+ const states = await runPreparedFiles({
1986
+ projects: preparedProjects,
1987
+ maxConcurrentFiles: effectiveConcurrency,
1988
+ maxConcurrentProjects: definition.test.maxConcurrentProjects ?? 1 / 0,
1989
+ setupTimeoutMs: definition.test.setupTimeout,
1990
+ teardownTimeoutMs,
1991
+ signal: rootController.signal,
1992
+ runDir,
1993
+ preflightFailed: 'run' === plan.preflightScope && totalErrors > 0,
1994
+ shouldBail: bailReached,
1995
+ addFailedCases,
1996
+ announceProject,
1997
+ onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
1998
+ onInfrastructureError: recordInfrastructureError
1999
+ });
2000
+ states.forEach((state, index)=>{
2001
+ projectResults[index] = buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle);
1743
2002
  });
1744
2003
  } catch (error) {
1745
2004
  recordInfrastructureError(error);