@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.
@@ -1,6 +1,6 @@
1
1
  import * as __rspack_external_node_fs_5ea92f0c from "node:fs";
2
2
  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";
3
+ import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, getPendingScopeOperations, getResourceCleanupCompletion, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText, trackResourceOperation, waitForScopeOperations } from "@midscene/core/internal/test-runner";
4
4
  import { inspect } from "node:util";
5
5
  import { z } from "zod/v4";
6
6
  import { pathToFileURL } from "node:url";
@@ -21,7 +21,7 @@ var __webpack_modules__ = {
21
21
  },
22
22
  "./package.json" (module) {
23
23
  module.exports = {
24
- rE: "1.16.0"
24
+ rE: "1.16.1"
25
25
  };
26
26
  }
27
27
  };
@@ -352,9 +352,19 @@ const validateNonNegativeInteger = (value, fallback, label)=>{
352
352
  if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
353
353
  return value;
354
354
  };
355
- const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
355
+ const validateConcurrency = (value, label)=>{
356
+ if (null === value) return null;
357
+ return validatePositiveInteger(value, 1, label);
358
+ };
359
+ const validateFileConcurrency = (maxConcurrentFiles, fileSetup, label)=>{
360
+ if (maxConcurrentFiles > 1 && !fileSetup) throw new TypeError(`Midscene config ${label}.fileSetup is required when the effective maxConcurrentFiles is greater than 1.`);
361
+ };
362
+ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, globalNodes, test)=>{
356
363
  if (void 0 === value) {
357
364
  const setup = validateSetup(defaultSetup, 'setup');
365
+ const fileSetup = validateSetup(defaultFileSetup, 'fileSetup');
366
+ const maxConcurrentFiles = test.maxConcurrentFiles ?? 1 / 0;
367
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, 'default');
358
368
  return {
359
369
  hasExplicitProjects: false,
360
370
  projects: Object.freeze([
@@ -364,6 +374,10 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
364
374
  ...setup ? {
365
375
  setup
366
376
  } : {},
377
+ ...fileSetup ? {
378
+ fileSetup
379
+ } : {},
380
+ maxConcurrentFiles,
367
381
  tags: Object.freeze({
368
382
  include: [],
369
383
  exclude: []
@@ -376,6 +390,7 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
376
390
  };
377
391
  }
378
392
  if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
393
+ if (void 0 !== defaultFileSetup) throw new TypeError('Midscene config fileSetup cannot be used together with projects. Move fileSetup to projects[].fileSetup.');
379
394
  if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
380
395
  const names = new Set();
381
396
  const projects = value.map((candidate, index)=>{
@@ -384,6 +399,8 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
384
399
  rejectUnknownKeys(candidate, [
385
400
  'name',
386
401
  'setup',
402
+ 'fileSetup',
403
+ 'maxConcurrentFiles',
387
404
  'nodes',
388
405
  'files',
389
406
  'tags',
@@ -400,10 +417,18 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
400
417
  ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
401
418
  ...localNodes.definitions()
402
419
  ]);
420
+ const fileSetup = validateSetup(candidate.fileSetup, `${label}.fileSetup`);
421
+ const globalFileLimit = test.maxConcurrentFiles ?? 1 / 0;
422
+ const maxConcurrentFiles = Math.min(globalFileLimit, validatePositiveInteger(candidate.maxConcurrentFiles, globalFileLimit, `${label}.maxConcurrentFiles`));
423
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, label);
403
424
  return Object.freeze({
404
425
  projectId: projectIdFromIndex(index),
405
426
  name: candidate.name,
406
427
  nodes,
428
+ ...fileSetup ? {
429
+ fileSetup
430
+ } : {},
431
+ maxConcurrentFiles,
407
432
  ...files ? {
408
433
  files
409
434
  } : {},
@@ -420,11 +445,25 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
420
445
  hasExplicitProjects: true
421
446
  };
422
447
  };
423
- const validateTestOptions = (value)=>{
448
+ const validateTestOptions = (value, hasExplicitProjects)=>{
424
449
  if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
425
450
  const candidate = value ?? {};
451
+ if (void 0 !== candidate.maxConcurrency) {
452
+ const limit = 'number' == typeof candidate.maxConcurrency && Number.isInteger(candidate.maxConcurrency) && candidate.maxConcurrency > 0 ? candidate.maxConcurrency : 1;
453
+ throw new TypeError([
454
+ 'Midscene config test.maxConcurrency is no longer supported.',
455
+ 'It controlled project concurrency, not file concurrency. Remove it and use:',
456
+ ` test: { maxConcurrentProjects: ${limit}, maxConcurrentFiles: ${hasExplicitProjects ? limit : 1} }`,
457
+ hasExplicitProjects ? ' Set projects[].maxConcurrentFiles to 1 to keep files sequential within each project.' : ' The implicit default project keeps running files sequentially.',
458
+ hasExplicitProjects ? 'For parallel files, configure fileSetup and raise both test.maxConcurrentFiles and projects[].maxConcurrentFiles.' : 'For parallel files, configure fileSetup and raise test.maxConcurrentFiles.',
459
+ 'Migration guide: https://midscenejs.com/midscene-test/configuration.html#existing-configurations'
460
+ ].join('\n'));
461
+ }
426
462
  return Object.freeze({
427
- maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
463
+ maxConcurrentFiles: validateConcurrency(candidate.maxConcurrentFiles, 'test.maxConcurrentFiles'),
464
+ maxConcurrentProjects: validateConcurrency(candidate.maxConcurrentProjects, 'test.maxConcurrentProjects'),
465
+ setupTimeout: validatePositiveInteger(candidate.setupTimeout, 120000, 'test.setupTimeout'),
466
+ teardownTimeout: validatePositiveInteger(candidate.teardownTimeout, 30000, 'test.teardownTimeout'),
428
467
  bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
429
468
  testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
430
469
  });
@@ -454,16 +493,18 @@ const validateTestProjectDefinition = (definition)=>{
454
493
  if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
455
494
  rejectUnknownKeys(definition, [
456
495
  'setup',
496
+ 'fileSetup',
457
497
  'projects',
458
498
  'test',
459
499
  'output',
460
500
  'nodes'
461
501
  ], 'root');
462
502
  const nodes = new registry_NodeRegistry(definition.nodes);
463
- const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
503
+ const test = validateTestOptions(definition.test, void 0 !== definition.projects);
504
+ const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, definition.fileSetup, nodes, test);
464
505
  return {
465
506
  ...resolvedProjects,
466
- test: validateTestOptions(definition.test),
507
+ test,
467
508
  output: validateOutput(definition.output),
468
509
  nodes,
469
510
  resolveNode: (name)=>nodes.get(name)
@@ -1137,7 +1178,24 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
1137
1178
  defaultTimeoutMs,
1138
1179
  reportEnabled: true,
1139
1180
  bindings: {
1140
- resolveNode: project.nodes.require.bind(project.nodes)
1181
+ resolveNode: project.nodes.require.bind(project.nodes),
1182
+ ...project.fileSetup ? {
1183
+ documentSetup: {
1184
+ name: project.fileSetup.name,
1185
+ setup ({ projectContext, document, signal, onTeardown }) {
1186
+ return project.fileSetup.setup({
1187
+ project,
1188
+ projectContext,
1189
+ file: document,
1190
+ env: Object.freeze({
1191
+ ...process.env
1192
+ }),
1193
+ signal,
1194
+ onTeardown
1195
+ });
1196
+ }
1197
+ }
1198
+ } : {}
1141
1199
  }
1142
1200
  });
1143
1201
  } catch (error) {
@@ -1340,11 +1398,40 @@ const documentHasFatalError = (result)=>[
1340
1398
  ...result.beforeAll,
1341
1399
  ...result.afterAll
1342
1400
  ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError) || !!result.executionErrors?.some(isResourceRecoveryError);
1401
+ const isSharedResourceFailure = (error)=>{
1402
+ const seen = new Set();
1403
+ const pending = [
1404
+ error
1405
+ ];
1406
+ while(pending.length && seen.size < 50){
1407
+ const item = pending.pop();
1408
+ if ('object' != typeof item || null === item || seen.has(item)) continue;
1409
+ seen.add(item);
1410
+ const failure = item;
1411
+ if ('PROJECT_RESOURCE_ERROR' === failure.code) return true;
1412
+ if (failure.cause) pending.push(failure.cause);
1413
+ if (Array.isArray(failure.errors)) pending.push(...failure.errors.slice(0, 50));
1414
+ }
1415
+ return false;
1416
+ };
1343
1417
  async function executeDocumentInvocation(options) {
1344
1418
  const { invocation, project, sinks } = options;
1419
+ const isProjectResourceFailure = options.isProjectResourceFailure ?? (()=>false);
1345
1420
  const { document } = invocation;
1346
1421
  const documentRetry = 'document' === invocation.retry.scope;
1347
1422
  const attempts = [];
1423
+ const fileSetup = invocation.bindings.documentSetup;
1424
+ const documentSetup = options.isProjectResourceFailure && fileSetup ? {
1425
+ ...fileSetup,
1426
+ async setup (ctx) {
1427
+ try {
1428
+ return await fileSetup.setup(ctx);
1429
+ } catch (error) {
1430
+ if (isProjectResourceFailure(error)) options.markProjectFatal();
1431
+ throw error;
1432
+ }
1433
+ }
1434
+ } : fileSetup;
1348
1435
  await runDocumentAttempts({
1349
1436
  retry: documentRetry ? invocation.retry.count : 0,
1350
1437
  signal: options.signal,
@@ -1355,6 +1442,9 @@ async function executeDocumentInvocation(options) {
1355
1442
  plan: invocation.plan,
1356
1443
  documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1357
1444
  ...invocation.bindings,
1445
+ documentSetup,
1446
+ setupTimeoutMs: options.setupTimeoutMs,
1447
+ teardownTimeoutMs: options.teardownTimeoutMs,
1358
1448
  project,
1359
1449
  projectContext: options.projectContext,
1360
1450
  retry: documentRetry ? 0 : invocation.retry.count,
@@ -1366,7 +1456,7 @@ async function executeDocumentInvocation(options) {
1366
1456
  ...run.beforeEach,
1367
1457
  ...run.steps,
1368
1458
  ...run.afterEach
1369
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
1459
+ ].some((step)=>step.error && (isFatalDeviceError(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>isFatalDeviceError(error) || isProjectResourceFailure(error)),
1370
1460
  onCaseStart: (collectedCase)=>{
1371
1461
  options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1372
1462
  },
@@ -1375,6 +1465,7 @@ async function executeDocumentInvocation(options) {
1375
1465
  options.onProgress(`${indent}→ ${formatStep(info)}`);
1376
1466
  },
1377
1467
  onStepResult: async (info, result)=>{
1468
+ if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1378
1469
  await invocation.bindings.onStepResult?.(info, result);
1379
1470
  options.onProgress(formatStepResult(info, result));
1380
1471
  },
@@ -1383,7 +1474,12 @@ async function executeDocumentInvocation(options) {
1383
1474
  options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1384
1475
  },
1385
1476
  onCaseOutcome: (outcome)=>{
1386
- if (caseHasFatalError(outcome)) options.markProjectFatal();
1477
+ if (outcome.attempts?.some((attempt)=>[
1478
+ ...attempt.beforeEach,
1479
+ ...attempt.steps,
1480
+ ...attempt.afterEach
1481
+ ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1482
+ if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1387
1483
  if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1388
1484
  },
1389
1485
  onDocumentResult: options.onDocumentResult
@@ -1392,7 +1488,7 @@ async function executeDocumentInvocation(options) {
1392
1488
  const partial = error.result;
1393
1489
  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
1490
  if (resourceOnly) {
1395
- if (error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
1491
+ if (!options.isolatedContext && error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
1396
1492
  return partial;
1397
1493
  }
1398
1494
  if (documentRetry) partial.document.retryDecision = decideRetry({
@@ -1421,7 +1517,17 @@ async function executeDocumentInvocation(options) {
1421
1517
  }
1422
1518
  throw error;
1423
1519
  });
1424
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1520
+ if (!options.isolatedContext && documentHasFatalError(execution.document)) options.markProjectFatal();
1521
+ if ([
1522
+ ...execution.document.beforeAll,
1523
+ ...execution.document.afterAll,
1524
+ ...(execution.document.hostErrors ?? []).map(({ error })=>({
1525
+ error
1526
+ })),
1527
+ ...(execution.document.teardownErrors ?? []).map((error)=>({
1528
+ error
1529
+ }))
1530
+ ].some(({ error })=>isProjectResourceFailure(error))) options.markProjectFatal();
1425
1531
  const owners = [
1426
1532
  execution.document,
1427
1533
  ...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
@@ -1429,7 +1535,10 @@ async function executeDocumentInvocation(options) {
1429
1535
  const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
1430
1536
  const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
1431
1537
  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();
1538
+ if (cleanupPending || operationPending) {
1539
+ options.markProjectFatal();
1540
+ options.onUnresolvedResources?.();
1541
+ } else if (cleanupFailed && !options.isolatedContext) options.markProjectFatal();
1433
1542
  const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1434
1543
  const decision = decideRetry({
1435
1544
  scope: 'document',
@@ -1482,6 +1591,231 @@ async function executeDocumentInvocation(options) {
1482
1591
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1483
1592
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1484
1593
  }
1594
+ async function runPreparedFiles(options) {
1595
+ const states = options.projects.map((prepared)=>({
1596
+ prepared,
1597
+ cases: [],
1598
+ documents: [],
1599
+ cursor: 0,
1600
+ active: 0,
1601
+ initializing: false,
1602
+ finished: false,
1603
+ fatal: false,
1604
+ setupFailed: false
1605
+ }));
1606
+ let nextProject = 0;
1607
+ let resourcesUnresolved = false;
1608
+ let occupied = 0;
1609
+ let occupiedProjects = 0;
1610
+ const cleanupQueue = [];
1611
+ const release = ()=>{
1612
+ occupied--;
1613
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1614
+ occupied++;
1615
+ cleanupQueue.shift()();
1616
+ }
1617
+ wake?.();
1618
+ };
1619
+ let wake;
1620
+ const running = new Set();
1621
+ const stopped = (state)=>options.signal.aborted || options.shouldBail() || state.fatal;
1622
+ const invalid = (state)=>options.preflightFailed || state.prepared.collectionErrors.length > 0;
1623
+ const unresolved = ()=>{
1624
+ if (resourcesUnresolved) return;
1625
+ resourcesUnresolved = true;
1626
+ options.onInfrastructureError(new WorkflowError('Resources are still running or cleanup is pending. No new test files will start.', {
1627
+ code: 'UNRESOLVED_FILE_RESOURCES'
1628
+ }));
1629
+ };
1630
+ const finishProject = async (state)=>{
1631
+ if (!state.runtime || state.finished) return;
1632
+ state.finished = true;
1633
+ const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1634
+ state.lifecycle = await state.runtime.finish(failed ? 'failed' : 'success');
1635
+ const releaseProject = ()=>{
1636
+ occupiedProjects--;
1637
+ wake?.();
1638
+ };
1639
+ if (state.lifecycle.cleanup?.status === 'pending' || state.lifecycle.pendingOperations?.length) {
1640
+ unresolved();
1641
+ Promise.all([
1642
+ waitForScopeOperations(state.runtime.signal),
1643
+ getResourceCleanupCompletion(state.runtime.signal)
1644
+ ]).then(releaseProject, (error)=>{
1645
+ options.onInfrastructureError(error);
1646
+ releaseProject();
1647
+ });
1648
+ } else releaseProject();
1649
+ };
1650
+ const admit = ()=>{
1651
+ for(let offset = 0; offset < states.length; offset++){
1652
+ const index = (nextProject + offset) % states.length;
1653
+ const state = states[index];
1654
+ if (invalid(state) || state.finished || state.initializing) continue;
1655
+ if (!state.runtime && occupiedProjects >= options.maxConcurrentProjects) continue;
1656
+ const cleanupOnly = !!state.runtime && 0 === state.active && (stopped(state) || state.cursor === state.prepared.invocations.length);
1657
+ if (!cleanupOnly && (stopped(state) || state.cursor >= state.prepared.invocations.length || state.active >= (state.prepared.project.maxConcurrentFiles ?? options.maxConcurrentFiles))) continue;
1658
+ nextProject = (index + 1) % states.length;
1659
+ occupied++;
1660
+ state.active++;
1661
+ if (!state.runtime && !cleanupOnly) {
1662
+ occupiedProjects++;
1663
+ state.initializing = true;
1664
+ }
1665
+ const invocationIndex = cleanupOnly ? -1 : state.cursor++;
1666
+ return {
1667
+ state,
1668
+ index,
1669
+ invocationIndex
1670
+ };
1671
+ }
1672
+ };
1673
+ const run = async ({ state, index, invocationIndex })=>{
1674
+ const { prepared } = state;
1675
+ const { project } = prepared;
1676
+ let fileResourcesPending = false;
1677
+ try {
1678
+ if (invocationIndex < 0) return void await finishProject(state);
1679
+ if (!state.runtime) {
1680
+ state.runtime = createProjectRuntime({
1681
+ project,
1682
+ setup: project.setup,
1683
+ signal: options.signal,
1684
+ setupTimeoutMs: options.setupTimeoutMs,
1685
+ teardownTimeoutMs: options.teardownTimeoutMs
1686
+ });
1687
+ setMaxListeners(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1688
+ options.announceProject(prepared, index);
1689
+ await state.runtime.start();
1690
+ state.initializing = false;
1691
+ if (!state.runtime.canRun) {
1692
+ state.fatal = true;
1693
+ state.setupFailed = true;
1694
+ }
1695
+ wake?.();
1696
+ }
1697
+ if (stopped(state)) return;
1698
+ const invocation = prepared.invocations[invocationIndex];
1699
+ options.onProgress(prepared, ` [document ${invocationIndex + 1}/${prepared.invocations.length}] ${invocation.document.sourcePath}`);
1700
+ await executeDocumentInvocation({
1701
+ invocation,
1702
+ project,
1703
+ projectContext: state.runtime.context,
1704
+ runDir: options.runDir,
1705
+ signal: state.runtime.signal,
1706
+ rootSignal: options.signal,
1707
+ shouldBail: options.shouldBail,
1708
+ isProjectFatal: ()=>state.fatal,
1709
+ markProjectFatal: ()=>{
1710
+ if (state.fatal) return;
1711
+ state.fatal = true;
1712
+ state.runtime?.abort(new WorkflowError(`Project "${project.name}" shared execution environment is unavailable.`, {
1713
+ code: 'PROJECT_RESOURCE_ERROR'
1714
+ }));
1715
+ },
1716
+ addFailedCases: options.addFailedCases,
1717
+ onProgress: (message)=>options.onProgress(prepared, message),
1718
+ sinks: state,
1719
+ isolatedContext: void 0 !== project.fileSetup,
1720
+ isProjectResourceFailure: isSharedResourceFailure,
1721
+ setupTimeoutMs: options.setupTimeoutMs,
1722
+ teardownTimeoutMs: options.teardownTimeoutMs,
1723
+ onUnresolvedResources: ()=>{
1724
+ fileResourcesPending = true;
1725
+ unresolved();
1726
+ }
1727
+ });
1728
+ } catch (error) {
1729
+ state.fatal = true;
1730
+ fileResourcesPending = !!state.runtime && getPendingScopeOperations(state.runtime.signal).length > 0;
1731
+ options.onInfrastructureError(error);
1732
+ } finally{
1733
+ state.initializing = false;
1734
+ try {
1735
+ if (1 === state.active && (stopped(state) || state.cursor === prepared.invocations.length)) await finishProject(state);
1736
+ } catch (error) {
1737
+ options.onInfrastructureError(error);
1738
+ }
1739
+ state.active--;
1740
+ if (state.runtime && (fileResourcesPending || state.lifecycle?.cleanup?.status === 'pending')) waitForScopeOperations(state.runtime.signal).then(release, (error)=>{
1741
+ options.onInfrastructureError(error);
1742
+ release();
1743
+ });
1744
+ else release();
1745
+ wake?.();
1746
+ }
1747
+ };
1748
+ while(true){
1749
+ const changed = new Promise((resolve)=>{
1750
+ wake = resolve;
1751
+ });
1752
+ while(occupied < options.maxConcurrentFiles){
1753
+ const job = admit();
1754
+ if (!job) break;
1755
+ const task = run(job);
1756
+ running.add(task);
1757
+ const settled = ()=>{
1758
+ running.delete(task);
1759
+ wake?.();
1760
+ };
1761
+ task.then(settled, (error)=>{
1762
+ options.onInfrastructureError(error);
1763
+ settled();
1764
+ });
1765
+ }
1766
+ if (!running.size) break;
1767
+ await changed;
1768
+ }
1769
+ wake = void 0;
1770
+ await Promise.all(states.map(async (state)=>{
1771
+ if (!state.runtime || state.finished) return;
1772
+ const admission = new Promise((resolve)=>{
1773
+ cleanupQueue.push(resolve);
1774
+ });
1775
+ trackResourceOperation({}, admission, state.runtime.signal);
1776
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1777
+ occupied++;
1778
+ cleanupQueue.shift()();
1779
+ }
1780
+ try {
1781
+ await finishProject(state);
1782
+ } catch (error) {
1783
+ options.onInfrastructureError(error);
1784
+ } finally{
1785
+ Promise.all([
1786
+ admission,
1787
+ getResourceCleanupCompletion(state.runtime.signal)
1788
+ ]).then(release, (error)=>{
1789
+ options.onInfrastructureError(error);
1790
+ release();
1791
+ });
1792
+ }
1793
+ }));
1794
+ for (const state of states){
1795
+ const { prepared } = state;
1796
+ if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1797
+ const completed = new Set(state.cases.map((item)=>item.caseId));
1798
+ const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1799
+ for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1800
+ documentId: document.documentId,
1801
+ caseId: item.caseId,
1802
+ projectName: prepared.project.name,
1803
+ name: item.definition.name,
1804
+ sourcePath: item.sourcePath,
1805
+ caseIndex: item.caseIndex,
1806
+ status: 'not-run',
1807
+ notRunReason: reason
1808
+ });
1809
+ const order = new Map(prepared.documents.map((document, i)=>[
1810
+ document.documentId,
1811
+ i
1812
+ ]));
1813
+ const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1814
+ state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1815
+ state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1816
+ }
1817
+ return states;
1818
+ }
1485
1819
  function createOperationJournal(runDir) {
1486
1820
  const path = (0, external_node_path_.join)(runDir, 'operations.jsonl');
1487
1821
  (0, external_node_fs_.writeFileSync)(path, '', {
@@ -1551,7 +1885,7 @@ async function runPreparedTestProject(plan) {
1551
1885
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1552
1886
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1553
1887
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1554
- const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1888
+ const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
1555
1889
  await writeTestRunPlan(runDir, preparedProjects);
1556
1890
  const journal = createOperationJournal(runDir);
1557
1891
  const rootController = new AbortController();
@@ -1565,6 +1899,18 @@ async function runPreparedTestProject(plan) {
1565
1899
  process.on('SIGTERM', sigterm);
1566
1900
  let failedCaseCount = 0;
1567
1901
  const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
1902
+ let shutdownDeadline;
1903
+ const beginShutdown = ()=>{
1904
+ shutdownDeadline ??= Date.now() + definition.test.teardownTimeout;
1905
+ };
1906
+ rootController.signal.addEventListener('abort', beginShutdown, {
1907
+ once: true
1908
+ });
1909
+ const addFailedCases = (count)=>{
1910
+ failedCaseCount += count;
1911
+ if (bailReached()) beginShutdown();
1912
+ };
1913
+ const teardownTimeoutMs = ()=>Math.max(0, Math.min(definition.test.teardownTimeout, void 0 === shutdownDeadline ? 1 / 0 : shutdownDeadline - Date.now()));
1568
1914
  let hasInfrastructureError = false;
1569
1915
  const infrastructureErrors = [];
1570
1916
  const recordInfrastructureError = (error)=>{
@@ -1626,112 +1972,25 @@ async function runPreparedTestProject(plan) {
1626
1972
  announceProject(prepared, projectIndex);
1627
1973
  return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
1628
1974
  };
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
1975
  const projectResults = new Array(preparedProjects.length);
1724
1976
  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
- }
1977
+ const states = await runPreparedFiles({
1978
+ projects: preparedProjects,
1979
+ maxConcurrentFiles: effectiveConcurrency,
1980
+ maxConcurrentProjects: definition.test.maxConcurrentProjects ?? 1 / 0,
1981
+ setupTimeoutMs: definition.test.setupTimeout,
1982
+ teardownTimeoutMs,
1983
+ signal: rootController.signal,
1984
+ runDir,
1985
+ preflightFailed: 'run' === plan.preflightScope && totalErrors > 0,
1986
+ shouldBail: bailReached,
1987
+ addFailedCases,
1988
+ announceProject,
1989
+ onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
1990
+ onInfrastructureError: recordInfrastructureError
1991
+ });
1992
+ states.forEach((state, index)=>{
1993
+ projectResults[index] = buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle);
1735
1994
  });
1736
1995
  } catch (error) {
1737
1996
  recordInfrastructureError(error);