@midscene/test 1.15.6 → 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.
@@ -28,11 +28,12 @@ __webpack_require__.d(__webpack_exports__, {
28
28
  createTestRunId: ()=>createTestRunId,
29
29
  runTestProject: ()=>runTestProject,
30
30
  discoverTestConfig: ()=>discoverTestConfig,
31
+ defineFileSetup: ()=>defineFileSetup,
31
32
  validateTestFileSelection: ()=>validateTestFileSelection,
32
33
  defineProjectSetup: ()=>defineProjectSetup,
33
34
  discoverTestFiles: ()=>discoverTestFiles,
34
- loadTestProject: ()=>loadTestProject,
35
- defineTestProject: ()=>defineTestProject
35
+ defineTestProject: ()=>defineTestProject,
36
+ loadTestProject: ()=>loadTestProject
36
37
  });
37
38
  const external_node_path_namespaceObject = require("node:path");
38
39
  const external_node_url_namespaceObject = require("node:url");
@@ -42,6 +43,7 @@ const NodeRegistry = test_runner_namespaceObject.NodeRegistry;
42
43
  const projectIdFromIndex = (index)=>`project-${index}`;
43
44
  const defineTestProject = (definition)=>definition;
44
45
  const defineProjectSetup = (definition)=>definition;
46
+ const defineFileSetup = (definition)=>definition;
45
47
  const getDefaultExport = (loaded, absolutePath)=>{
46
48
  if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
47
49
  const defaultExport = loaded.default;
@@ -158,9 +160,19 @@ const validateNonNegativeInteger = (value, fallback, label)=>{
158
160
  if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
159
161
  return value;
160
162
  };
161
- const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
163
+ const validateConcurrency = (value, label)=>{
164
+ if (null === value) return null;
165
+ return validatePositiveInteger(value, 1, label);
166
+ };
167
+ const validateFileConcurrency = (maxConcurrentFiles, fileSetup, label)=>{
168
+ if (maxConcurrentFiles > 1 && !fileSetup) throw new TypeError(`Midscene config ${label}.fileSetup is required when the effective maxConcurrentFiles is greater than 1.`);
169
+ };
170
+ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, globalNodes, test)=>{
162
171
  if (void 0 === value) {
163
172
  const setup = validateSetup(defaultSetup, 'setup');
173
+ const fileSetup = validateSetup(defaultFileSetup, 'fileSetup');
174
+ const maxConcurrentFiles = test.maxConcurrentFiles ?? 1 / 0;
175
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, 'default');
164
176
  return {
165
177
  hasExplicitProjects: false,
166
178
  projects: Object.freeze([
@@ -170,6 +182,10 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
170
182
  ...setup ? {
171
183
  setup
172
184
  } : {},
185
+ ...fileSetup ? {
186
+ fileSetup
187
+ } : {},
188
+ maxConcurrentFiles,
173
189
  tags: Object.freeze({
174
190
  include: [],
175
191
  exclude: []
@@ -182,6 +198,7 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
182
198
  };
183
199
  }
184
200
  if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
201
+ if (void 0 !== defaultFileSetup) throw new TypeError('Midscene config fileSetup cannot be used together with projects. Move fileSetup to projects[].fileSetup.');
185
202
  if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
186
203
  const names = new Set();
187
204
  const projects = value.map((candidate, index)=>{
@@ -190,6 +207,8 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
190
207
  rejectUnknownKeys(candidate, [
191
208
  'name',
192
209
  'setup',
210
+ 'fileSetup',
211
+ 'maxConcurrentFiles',
193
212
  'nodes',
194
213
  'files',
195
214
  'tags',
@@ -206,10 +225,18 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
206
225
  ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
207
226
  ...localNodes.definitions()
208
227
  ]);
228
+ const fileSetup = validateSetup(candidate.fileSetup, `${label}.fileSetup`);
229
+ const globalFileLimit = test.maxConcurrentFiles ?? 1 / 0;
230
+ const maxConcurrentFiles = Math.min(globalFileLimit, validatePositiveInteger(candidate.maxConcurrentFiles, globalFileLimit, `${label}.maxConcurrentFiles`));
231
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, label);
209
232
  return Object.freeze({
210
233
  projectId: projectIdFromIndex(index),
211
234
  name: candidate.name,
212
235
  nodes,
236
+ ...fileSetup ? {
237
+ fileSetup
238
+ } : {},
239
+ maxConcurrentFiles,
213
240
  ...files ? {
214
241
  files
215
242
  } : {},
@@ -226,11 +253,25 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
226
253
  hasExplicitProjects: true
227
254
  };
228
255
  };
229
- const validateTestOptions = (value)=>{
256
+ const validateTestOptions = (value, hasExplicitProjects)=>{
230
257
  if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
231
258
  const candidate = value ?? {};
259
+ if (void 0 !== candidate.maxConcurrency) {
260
+ const limit = 'number' == typeof candidate.maxConcurrency && Number.isInteger(candidate.maxConcurrency) && candidate.maxConcurrency > 0 ? candidate.maxConcurrency : 1;
261
+ throw new TypeError([
262
+ 'Midscene config test.maxConcurrency is no longer supported.',
263
+ 'It controlled project concurrency, not file concurrency. Remove it and use:',
264
+ ` test: { maxConcurrentProjects: ${limit}, maxConcurrentFiles: ${hasExplicitProjects ? limit : 1} }`,
265
+ hasExplicitProjects ? ' Set projects[].maxConcurrentFiles to 1 to keep files sequential within each project.' : ' The implicit default project keeps running files sequentially.',
266
+ hasExplicitProjects ? 'For parallel files, configure fileSetup and raise both test.maxConcurrentFiles and projects[].maxConcurrentFiles.' : 'For parallel files, configure fileSetup and raise test.maxConcurrentFiles.',
267
+ 'Migration guide: https://midscenejs.com/midscene-test/configuration.html#existing-configurations'
268
+ ].join('\n'));
269
+ }
232
270
  return Object.freeze({
233
- maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
271
+ maxConcurrentFiles: validateConcurrency(candidate.maxConcurrentFiles, 'test.maxConcurrentFiles'),
272
+ maxConcurrentProjects: validateConcurrency(candidate.maxConcurrentProjects, 'test.maxConcurrentProjects'),
273
+ setupTimeout: validatePositiveInteger(candidate.setupTimeout, 120000, 'test.setupTimeout'),
274
+ teardownTimeout: validatePositiveInteger(candidate.teardownTimeout, 30000, 'test.teardownTimeout'),
234
275
  bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
235
276
  testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
236
277
  });
@@ -260,16 +301,18 @@ const validateTestProjectDefinition = (definition)=>{
260
301
  if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
261
302
  rejectUnknownKeys(definition, [
262
303
  'setup',
304
+ 'fileSetup',
263
305
  'projects',
264
306
  'test',
265
307
  'output',
266
308
  'nodes'
267
309
  ], 'root');
268
310
  const nodes = new NodeRegistry(definition.nodes);
269
- const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
311
+ const test = validateTestOptions(definition.test, void 0 !== definition.projects);
312
+ const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, definition.fileSetup, nodes, test);
270
313
  return {
271
314
  ...resolvedProjects,
272
- test: validateTestOptions(definition.test),
315
+ test,
273
316
  output: validateOutput(definition.output),
274
317
  nodes,
275
318
  resolveNode: (name)=>nodes.get(name)
@@ -953,7 +996,24 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
953
996
  defaultTimeoutMs,
954
997
  reportEnabled: true,
955
998
  bindings: {
956
- resolveNode: project.nodes.require.bind(project.nodes)
999
+ resolveNode: project.nodes.require.bind(project.nodes),
1000
+ ...project.fileSetup ? {
1001
+ documentSetup: {
1002
+ name: project.fileSetup.name,
1003
+ setup ({ projectContext, document, signal, onTeardown }) {
1004
+ return project.fileSetup.setup({
1005
+ project,
1006
+ projectContext,
1007
+ file: document,
1008
+ env: Object.freeze({
1009
+ ...process.env
1010
+ }),
1011
+ signal,
1012
+ onTeardown
1013
+ });
1014
+ }
1015
+ }
1016
+ } : {}
957
1017
  }
958
1018
  });
959
1019
  } catch (error) {
@@ -1159,11 +1219,40 @@ const documentHasFatalError = (result)=>[
1159
1219
  ...result.beforeAll,
1160
1220
  ...result.afterAll
1161
1221
  ].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);
1222
+ const isSharedResourceFailure = (error)=>{
1223
+ const seen = new Set();
1224
+ const pending = [
1225
+ error
1226
+ ];
1227
+ while(pending.length && seen.size < 50){
1228
+ const item = pending.pop();
1229
+ if ('object' != typeof item || null === item || seen.has(item)) continue;
1230
+ seen.add(item);
1231
+ const failure = item;
1232
+ if ('PROJECT_RESOURCE_ERROR' === failure.code) return true;
1233
+ if (failure.cause) pending.push(failure.cause);
1234
+ if (Array.isArray(failure.errors)) pending.push(...failure.errors.slice(0, 50));
1235
+ }
1236
+ return false;
1237
+ };
1162
1238
  async function executeDocumentInvocation(options) {
1163
1239
  const { invocation, project, sinks } = options;
1240
+ const isProjectResourceFailure = options.isProjectResourceFailure ?? (()=>false);
1164
1241
  const { document } = invocation;
1165
1242
  const documentRetry = 'document' === invocation.retry.scope;
1166
1243
  const attempts = [];
1244
+ const fileSetup = invocation.bindings.documentSetup;
1245
+ const documentSetup = options.isProjectResourceFailure && fileSetup ? {
1246
+ ...fileSetup,
1247
+ async setup (ctx) {
1248
+ try {
1249
+ return await fileSetup.setup(ctx);
1250
+ } catch (error) {
1251
+ if (isProjectResourceFailure(error)) options.markProjectFatal();
1252
+ throw error;
1253
+ }
1254
+ }
1255
+ } : fileSetup;
1167
1256
  await (0, test_runner_namespaceObject.runDocumentAttempts)({
1168
1257
  retry: documentRetry ? invocation.retry.count : 0,
1169
1258
  signal: options.signal,
@@ -1174,6 +1263,9 @@ async function executeDocumentInvocation(options) {
1174
1263
  plan: invocation.plan,
1175
1264
  documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1176
1265
  ...invocation.bindings,
1266
+ documentSetup,
1267
+ setupTimeoutMs: options.setupTimeoutMs,
1268
+ teardownTimeoutMs: options.teardownTimeoutMs,
1177
1269
  project,
1178
1270
  projectContext: options.projectContext,
1179
1271
  retry: documentRetry ? 0 : invocation.retry.count,
@@ -1185,7 +1277,7 @@ async function executeDocumentInvocation(options) {
1185
1277
  ...run.beforeEach,
1186
1278
  ...run.steps,
1187
1279
  ...run.afterEach
1188
- ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (run.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError),
1280
+ ].some((step)=>step.error && ((0, test_runner_namespaceObject.isFatalDeviceError)(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>(0, test_runner_namespaceObject.isFatalDeviceError)(error) || isProjectResourceFailure(error)),
1189
1281
  onCaseStart: (collectedCase)=>{
1190
1282
  options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1191
1283
  },
@@ -1194,6 +1286,7 @@ async function executeDocumentInvocation(options) {
1194
1286
  options.onProgress(`${indent}→ ${formatStep(info)}`);
1195
1287
  },
1196
1288
  onStepResult: async (info, result)=>{
1289
+ if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1197
1290
  await invocation.bindings.onStepResult?.(info, result);
1198
1291
  options.onProgress(formatStepResult(info, result));
1199
1292
  },
@@ -1202,7 +1295,12 @@ async function executeDocumentInvocation(options) {
1202
1295
  options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1203
1296
  },
1204
1297
  onCaseOutcome: (outcome)=>{
1205
- if (caseHasFatalError(outcome)) options.markProjectFatal();
1298
+ if (outcome.attempts?.some((attempt)=>[
1299
+ ...attempt.beforeEach,
1300
+ ...attempt.steps,
1301
+ ...attempt.afterEach
1302
+ ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1303
+ if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1206
1304
  if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1207
1305
  },
1208
1306
  onDocumentResult: options.onDocumentResult
@@ -1211,7 +1309,7 @@ async function executeDocumentInvocation(options) {
1211
1309
  const partial = error.result;
1212
1310
  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)));
1213
1311
  if (resourceOnly) {
1214
- if (error.errors.some(test_runner_namespaceObject.isResourceRecoveryError)) options.markProjectFatal();
1312
+ if (!options.isolatedContext && error.errors.some(test_runner_namespaceObject.isResourceRecoveryError)) options.markProjectFatal();
1215
1313
  return partial;
1216
1314
  }
1217
1315
  if (documentRetry) partial.document.retryDecision = (0, test_runner_namespaceObject.decideRetry)({
@@ -1240,7 +1338,17 @@ async function executeDocumentInvocation(options) {
1240
1338
  }
1241
1339
  throw error;
1242
1340
  });
1243
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1341
+ if (!options.isolatedContext && documentHasFatalError(execution.document)) options.markProjectFatal();
1342
+ if ([
1343
+ ...execution.document.beforeAll,
1344
+ ...execution.document.afterAll,
1345
+ ...(execution.document.hostErrors ?? []).map(({ error })=>({
1346
+ error
1347
+ })),
1348
+ ...(execution.document.teardownErrors ?? []).map((error)=>({
1349
+ error
1350
+ }))
1351
+ ].some(({ error })=>isProjectResourceFailure(error))) options.markProjectFatal();
1244
1352
  const owners = [
1245
1353
  execution.document,
1246
1354
  ...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
@@ -1248,7 +1356,10 @@ async function executeDocumentInvocation(options) {
1248
1356
  const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
1249
1357
  const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
1250
1358
  const cleanupFailed = owners.some((owner)=>owner.cleanup?.status === 'failed' || !!owner.teardownErrors?.length || 'hostErrors' in owner && owner.hostErrors?.some(({ phase })=>'cleanup' === phase));
1251
- if (cleanupPending || cleanupFailed || operationPending) options.markProjectFatal();
1359
+ if (cleanupPending || operationPending) {
1360
+ options.markProjectFatal();
1361
+ options.onUnresolvedResources?.();
1362
+ } else if (cleanupFailed && !options.isolatedContext) options.markProjectFatal();
1252
1363
  const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1253
1364
  const decision = (0, test_runner_namespaceObject.decideRetry)({
1254
1365
  scope: 'document',
@@ -1301,6 +1412,231 @@ async function executeDocumentInvocation(options) {
1301
1412
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1302
1413
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1303
1414
  }
1415
+ async function runPreparedFiles(options) {
1416
+ const states = options.projects.map((prepared)=>({
1417
+ prepared,
1418
+ cases: [],
1419
+ documents: [],
1420
+ cursor: 0,
1421
+ active: 0,
1422
+ initializing: false,
1423
+ finished: false,
1424
+ fatal: false,
1425
+ setupFailed: false
1426
+ }));
1427
+ let nextProject = 0;
1428
+ let resourcesUnresolved = false;
1429
+ let occupied = 0;
1430
+ let occupiedProjects = 0;
1431
+ const cleanupQueue = [];
1432
+ const release = ()=>{
1433
+ occupied--;
1434
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1435
+ occupied++;
1436
+ cleanupQueue.shift()();
1437
+ }
1438
+ wake?.();
1439
+ };
1440
+ let wake;
1441
+ const running = new Set();
1442
+ const stopped = (state)=>options.signal.aborted || options.shouldBail() || state.fatal;
1443
+ const invalid = (state)=>options.preflightFailed || state.prepared.collectionErrors.length > 0;
1444
+ const unresolved = ()=>{
1445
+ if (resourcesUnresolved) return;
1446
+ resourcesUnresolved = true;
1447
+ options.onInfrastructureError(new test_runner_namespaceObject.WorkflowError('Resources are still running or cleanup is pending. No new test files will start.', {
1448
+ code: 'UNRESOLVED_FILE_RESOURCES'
1449
+ }));
1450
+ };
1451
+ const finishProject = async (state)=>{
1452
+ if (!state.runtime || state.finished) return;
1453
+ state.finished = true;
1454
+ const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1455
+ state.lifecycle = await state.runtime.finish(failed ? 'failed' : 'success');
1456
+ const releaseProject = ()=>{
1457
+ occupiedProjects--;
1458
+ wake?.();
1459
+ };
1460
+ if (state.lifecycle.cleanup?.status === 'pending' || state.lifecycle.pendingOperations?.length) {
1461
+ unresolved();
1462
+ Promise.all([
1463
+ (0, test_runner_namespaceObject.waitForScopeOperations)(state.runtime.signal),
1464
+ (0, test_runner_namespaceObject.getResourceCleanupCompletion)(state.runtime.signal)
1465
+ ]).then(releaseProject, (error)=>{
1466
+ options.onInfrastructureError(error);
1467
+ releaseProject();
1468
+ });
1469
+ } else releaseProject();
1470
+ };
1471
+ const admit = ()=>{
1472
+ for(let offset = 0; offset < states.length; offset++){
1473
+ const index = (nextProject + offset) % states.length;
1474
+ const state = states[index];
1475
+ if (invalid(state) || state.finished || state.initializing) continue;
1476
+ if (!state.runtime && occupiedProjects >= options.maxConcurrentProjects) continue;
1477
+ const cleanupOnly = !!state.runtime && 0 === state.active && (stopped(state) || state.cursor === state.prepared.invocations.length);
1478
+ if (!cleanupOnly && (stopped(state) || state.cursor >= state.prepared.invocations.length || state.active >= (state.prepared.project.maxConcurrentFiles ?? options.maxConcurrentFiles))) continue;
1479
+ nextProject = (index + 1) % states.length;
1480
+ occupied++;
1481
+ state.active++;
1482
+ if (!state.runtime && !cleanupOnly) {
1483
+ occupiedProjects++;
1484
+ state.initializing = true;
1485
+ }
1486
+ const invocationIndex = cleanupOnly ? -1 : state.cursor++;
1487
+ return {
1488
+ state,
1489
+ index,
1490
+ invocationIndex
1491
+ };
1492
+ }
1493
+ };
1494
+ const run = async ({ state, index, invocationIndex })=>{
1495
+ const { prepared } = state;
1496
+ const { project } = prepared;
1497
+ let fileResourcesPending = false;
1498
+ try {
1499
+ if (invocationIndex < 0) return void await finishProject(state);
1500
+ if (!state.runtime) {
1501
+ state.runtime = (0, test_runner_namespaceObject.createProjectRuntime)({
1502
+ project,
1503
+ setup: project.setup,
1504
+ signal: options.signal,
1505
+ setupTimeoutMs: options.setupTimeoutMs,
1506
+ teardownTimeoutMs: options.teardownTimeoutMs
1507
+ });
1508
+ (0, external_node_events_namespaceObject.setMaxListeners)(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1509
+ options.announceProject(prepared, index);
1510
+ await state.runtime.start();
1511
+ state.initializing = false;
1512
+ if (!state.runtime.canRun) {
1513
+ state.fatal = true;
1514
+ state.setupFailed = true;
1515
+ }
1516
+ wake?.();
1517
+ }
1518
+ if (stopped(state)) return;
1519
+ const invocation = prepared.invocations[invocationIndex];
1520
+ options.onProgress(prepared, ` [document ${invocationIndex + 1}/${prepared.invocations.length}] ${invocation.document.sourcePath}`);
1521
+ await executeDocumentInvocation({
1522
+ invocation,
1523
+ project,
1524
+ projectContext: state.runtime.context,
1525
+ runDir: options.runDir,
1526
+ signal: state.runtime.signal,
1527
+ rootSignal: options.signal,
1528
+ shouldBail: options.shouldBail,
1529
+ isProjectFatal: ()=>state.fatal,
1530
+ markProjectFatal: ()=>{
1531
+ if (state.fatal) return;
1532
+ state.fatal = true;
1533
+ state.runtime?.abort(new test_runner_namespaceObject.WorkflowError(`Project "${project.name}" shared execution environment is unavailable.`, {
1534
+ code: 'PROJECT_RESOURCE_ERROR'
1535
+ }));
1536
+ },
1537
+ addFailedCases: options.addFailedCases,
1538
+ onProgress: (message)=>options.onProgress(prepared, message),
1539
+ sinks: state,
1540
+ isolatedContext: void 0 !== project.fileSetup,
1541
+ isProjectResourceFailure: isSharedResourceFailure,
1542
+ setupTimeoutMs: options.setupTimeoutMs,
1543
+ teardownTimeoutMs: options.teardownTimeoutMs,
1544
+ onUnresolvedResources: ()=>{
1545
+ fileResourcesPending = true;
1546
+ unresolved();
1547
+ }
1548
+ });
1549
+ } catch (error) {
1550
+ state.fatal = true;
1551
+ fileResourcesPending = !!state.runtime && (0, test_runner_namespaceObject.getPendingScopeOperations)(state.runtime.signal).length > 0;
1552
+ options.onInfrastructureError(error);
1553
+ } finally{
1554
+ state.initializing = false;
1555
+ try {
1556
+ if (1 === state.active && (stopped(state) || state.cursor === prepared.invocations.length)) await finishProject(state);
1557
+ } catch (error) {
1558
+ options.onInfrastructureError(error);
1559
+ }
1560
+ state.active--;
1561
+ if (state.runtime && (fileResourcesPending || state.lifecycle?.cleanup?.status === 'pending')) (0, test_runner_namespaceObject.waitForScopeOperations)(state.runtime.signal).then(release, (error)=>{
1562
+ options.onInfrastructureError(error);
1563
+ release();
1564
+ });
1565
+ else release();
1566
+ wake?.();
1567
+ }
1568
+ };
1569
+ while(true){
1570
+ const changed = new Promise((resolve)=>{
1571
+ wake = resolve;
1572
+ });
1573
+ while(occupied < options.maxConcurrentFiles){
1574
+ const job = admit();
1575
+ if (!job) break;
1576
+ const task = run(job);
1577
+ running.add(task);
1578
+ const settled = ()=>{
1579
+ running.delete(task);
1580
+ wake?.();
1581
+ };
1582
+ task.then(settled, (error)=>{
1583
+ options.onInfrastructureError(error);
1584
+ settled();
1585
+ });
1586
+ }
1587
+ if (!running.size) break;
1588
+ await changed;
1589
+ }
1590
+ wake = void 0;
1591
+ await Promise.all(states.map(async (state)=>{
1592
+ if (!state.runtime || state.finished) return;
1593
+ const admission = new Promise((resolve)=>{
1594
+ cleanupQueue.push(resolve);
1595
+ });
1596
+ (0, test_runner_namespaceObject.trackResourceOperation)({}, admission, state.runtime.signal);
1597
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1598
+ occupied++;
1599
+ cleanupQueue.shift()();
1600
+ }
1601
+ try {
1602
+ await finishProject(state);
1603
+ } catch (error) {
1604
+ options.onInfrastructureError(error);
1605
+ } finally{
1606
+ Promise.all([
1607
+ admission,
1608
+ (0, test_runner_namespaceObject.getResourceCleanupCompletion)(state.runtime.signal)
1609
+ ]).then(release, (error)=>{
1610
+ options.onInfrastructureError(error);
1611
+ release();
1612
+ });
1613
+ }
1614
+ }));
1615
+ for (const state of states){
1616
+ const { prepared } = state;
1617
+ if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1618
+ const completed = new Set(state.cases.map((item)=>item.caseId));
1619
+ const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1620
+ for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1621
+ documentId: document.documentId,
1622
+ caseId: item.caseId,
1623
+ projectName: prepared.project.name,
1624
+ name: item.definition.name,
1625
+ sourcePath: item.sourcePath,
1626
+ caseIndex: item.caseIndex,
1627
+ status: 'not-run',
1628
+ notRunReason: reason
1629
+ });
1630
+ const order = new Map(prepared.documents.map((document, i)=>[
1631
+ document.documentId,
1632
+ i
1633
+ ]));
1634
+ const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1635
+ state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1636
+ state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1637
+ }
1638
+ return states;
1639
+ }
1304
1640
  function createOperationJournal(runDir) {
1305
1641
  const path = (0, external_node_path_namespaceObject.join)(runDir, 'operations.jsonl');
1306
1642
  (0, external_node_fs_namespaceObject.writeFileSync)(path, '', {
@@ -1370,7 +1706,7 @@ async function runPreparedTestProject(plan) {
1370
1706
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1371
1707
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1372
1708
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1373
- const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1709
+ const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
1374
1710
  await writeTestRunPlan(runDir, preparedProjects);
1375
1711
  const journal = createOperationJournal(runDir);
1376
1712
  const rootController = new AbortController();
@@ -1384,6 +1720,18 @@ async function runPreparedTestProject(plan) {
1384
1720
  process.on('SIGTERM', sigterm);
1385
1721
  let failedCaseCount = 0;
1386
1722
  const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
1723
+ let shutdownDeadline;
1724
+ const beginShutdown = ()=>{
1725
+ shutdownDeadline ??= Date.now() + definition.test.teardownTimeout;
1726
+ };
1727
+ rootController.signal.addEventListener('abort', beginShutdown, {
1728
+ once: true
1729
+ });
1730
+ const addFailedCases = (count)=>{
1731
+ failedCaseCount += count;
1732
+ if (bailReached()) beginShutdown();
1733
+ };
1734
+ const teardownTimeoutMs = ()=>Math.max(0, Math.min(definition.test.teardownTimeout, void 0 === shutdownDeadline ? 1 / 0 : shutdownDeadline - Date.now()));
1387
1735
  let hasInfrastructureError = false;
1388
1736
  const infrastructureErrors = [];
1389
1737
  const recordInfrastructureError = (error)=>{
@@ -1445,112 +1793,25 @@ async function runPreparedTestProject(plan) {
1445
1793
  announceProject(prepared, projectIndex);
1446
1794
  return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
1447
1795
  };
1448
- const runPreparedProject = async (prepared, projectIndex)=>{
1449
- const { project } = prepared;
1450
- announceProject(prepared, projectIndex);
1451
- const projectProgress = (message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${project.name}]${message}` : message));
1452
- const cases = [];
1453
- const documents = [];
1454
- let lifecycle;
1455
- let projectFatal = false;
1456
- if (prepared.collectionErrors.length > 0 || 'run' === plan.preflightScope && totalErrors > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
1457
- else if (rootController.signal.aborted) cases.push(...notRunSuite(prepared, 'interrupted'));
1458
- else if (bailReached()) cases.push(...notRunSuite(prepared, 'bail'));
1459
- else {
1460
- const runtime = (0, test_runner_namespaceObject.createProjectRuntime)({
1461
- project,
1462
- setup: project.setup,
1463
- signal: rootController.signal
1464
- });
1465
- let hasProjectExecutionError = false;
1466
- try {
1467
- await runtime.start();
1468
- if (runtime.canRun) {
1469
- const runDocument = async (invocation, documentIndex)=>{
1470
- const { document } = invocation;
1471
- if (rootController.signal.aborted || bailReached() || projectFatal) {
1472
- const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1473
- cases.push(...document.cases.map((item)=>asNotRun(document.documentId, item, project.name, reason)));
1474
- return;
1475
- }
1476
- projectProgress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
1477
- await executeDocumentInvocation({
1478
- invocation,
1479
- project,
1480
- projectContext: runtime.context,
1481
- runDir,
1482
- signal: runtime.signal,
1483
- rootSignal: rootController.signal,
1484
- shouldBail: bailReached,
1485
- isProjectFatal: ()=>projectFatal,
1486
- markProjectFatal: ()=>{
1487
- projectFatal = true;
1488
- },
1489
- addFailedCases: (count)=>{
1490
- failedCaseCount += count;
1491
- },
1492
- onProgress: projectProgress,
1493
- sinks: {
1494
- cases,
1495
- documents
1496
- }
1497
- });
1498
- };
1499
- let prerequisiteFailed = false;
1500
- const prerequisite = prepared.invocations.find((invocation)=>invocation.document.documentId === prepared.prerequisiteDocumentId);
1501
- const prerequisiteCount = prerequisite ? 1 : 0;
1502
- if (prerequisite) {
1503
- await runDocument(prerequisite, 0);
1504
- prerequisiteFailed = documents.at(-1)?.status === 'failed' || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status);
1505
- }
1506
- if (!prerequisiteFailed) await (0, test_runner_namespaceObject.runConcurrentJobs)(prepared.invocations.filter((invocation)=>invocation !== prerequisite), {
1507
- concurrency: prepared.documentConcurrency,
1508
- shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal
1509
- }, async (invocation, index)=>{
1510
- try {
1511
- await runDocument(invocation, index + prerequisiteCount);
1512
- } catch (error) {
1513
- recordInfrastructureError(error);
1514
- throw error;
1515
- }
1516
- });
1517
- const completed = new Set(cases.map((item)=>item.caseId));
1518
- const reason = prerequisiteFailed ? 'project-setup-failed' : rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1519
- cases.push(...notRunSuite(prepared, reason).filter((item)=>!completed.has(item.caseId)));
1520
- } else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
1521
- } catch (error) {
1522
- hasProjectExecutionError = true;
1523
- recordInfrastructureError(error);
1524
- } finally{
1525
- 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;
1526
- lifecycle = await runtime.finish(hasFailure ? 'failed' : 'success');
1527
- }
1528
- if (hasProjectExecutionError) {
1529
- const completed = new Set(cases.map((item)=>item.caseId));
1530
- cases.push(...notRunSuite(prepared, 'interrupted').filter((item)=>!completed.has(item.caseId)));
1531
- }
1532
- }
1533
- const order = new Map(prepared.documents.map((document, index)=>[
1534
- document.documentId,
1535
- index
1536
- ]));
1537
- const documentOrder = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1538
- cases.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || a.caseIndex - b.caseIndex);
1539
- documents.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || (a.attemptIndex ?? 0) - (b.attemptIndex ?? 0));
1540
- return buildProjectResult(prepared, cases, documents, lifecycle);
1541
- };
1542
1796
  const projectResults = new Array(preparedProjects.length);
1543
1797
  try {
1544
- await (0, test_runner_namespaceObject.runConcurrentJobs)(preparedProjects, {
1545
- concurrency: effectiveConcurrency,
1546
- shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
1547
- }, async (prepared, index)=>{
1548
- try {
1549
- projectResults[index] = await runPreparedProject(prepared, index);
1550
- } catch (error) {
1551
- recordInfrastructureError(error);
1552
- throw error;
1553
- }
1798
+ const states = await runPreparedFiles({
1799
+ projects: preparedProjects,
1800
+ maxConcurrentFiles: effectiveConcurrency,
1801
+ maxConcurrentProjects: definition.test.maxConcurrentProjects ?? 1 / 0,
1802
+ setupTimeoutMs: definition.test.setupTimeout,
1803
+ teardownTimeoutMs,
1804
+ signal: rootController.signal,
1805
+ runDir,
1806
+ preflightFailed: 'run' === plan.preflightScope && totalErrors > 0,
1807
+ shouldBail: bailReached,
1808
+ addFailedCases,
1809
+ announceProject,
1810
+ onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
1811
+ onInfrastructureError: recordInfrastructureError
1812
+ });
1813
+ states.forEach((state, index)=>{
1814
+ projectResults[index] = buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle);
1554
1815
  });
1555
1816
  } catch (error) {
1556
1817
  recordInfrastructureError(error);
@@ -1659,6 +1920,7 @@ async function runTestProject(options = {}) {
1659
1920
  }
1660
1921
  exports.DEFAULT_TEST_FILE_SELECTION = __webpack_exports__.DEFAULT_TEST_FILE_SELECTION;
1661
1922
  exports.createTestRunId = __webpack_exports__.createTestRunId;
1923
+ exports.defineFileSetup = __webpack_exports__.defineFileSetup;
1662
1924
  exports.defineProjectSetup = __webpack_exports__.defineProjectSetup;
1663
1925
  exports.defineTestProject = __webpack_exports__.defineTestProject;
1664
1926
  exports.discoverTestConfig = __webpack_exports__.discoverTestConfig;
@@ -1669,6 +1931,7 @@ exports.validateTestFileSelection = __webpack_exports__.validateTestFileSelectio
1669
1931
  for(var __rspack_i in __webpack_exports__)if (-1 === [
1670
1932
  "DEFAULT_TEST_FILE_SELECTION",
1671
1933
  "createTestRunId",
1934
+ "defineFileSetup",
1672
1935
  "defineProjectSetup",
1673
1936
  "defineTestProject",
1674
1937
  "discoverTestConfig",