@midscene/test 1.15.4 → 1.15.5-beta-20261019120409.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
1
+ import { dirname, isAbsolute, join, relative, resolve as external_node_path_resolve, sep } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { require as api_require } from "tsx/cjs/api";
4
- import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runConcurrentJobs, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText } from "@midscene/core/internal/test-runner";
4
+ import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, getPendingScopeOperations, getResourceCleanupCompletion, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runConcurrentJobs, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText, trackResourceOperation, waitForScopeOperations } from "@midscene/core/internal/test-runner";
5
5
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
6
  import { globSync } from "tinyglobby";
7
7
  import { createHash, randomUUID } from "node:crypto";
@@ -14,6 +14,7 @@ const registry_NodeRegistry = NodeRegistry;
14
14
  const projectIdFromIndex = (index)=>`project-${index}`;
15
15
  const defineTestProject = (definition)=>definition;
16
16
  const defineProjectSetup = (definition)=>definition;
17
+ const defineFileSetup = (definition)=>definition;
17
18
  const getDefaultExport = (loaded, absolutePath)=>{
18
19
  if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
19
20
  const defaultExport = loaded.default;
@@ -130,9 +131,14 @@ const validateNonNegativeInteger = (value, fallback, label)=>{
130
131
  if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
131
132
  return value;
132
133
  };
133
- const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
134
+ const validateFileConcurrency = (maxConcurrentFiles, fileSetup, label)=>{
135
+ if (void 0 !== maxConcurrentFiles && maxConcurrentFiles > 1 && !fileSetup) throw new TypeError(`Midscene config ${label}.fileSetup is required when the effective maxConcurrentFiles is greater than 1.`);
136
+ };
137
+ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, globalNodes, test)=>{
134
138
  if (void 0 === value) {
135
139
  const setup = validateSetup(defaultSetup, 'setup');
140
+ const fileSetup = validateSetup(defaultFileSetup, 'fileSetup');
141
+ validateFileConcurrency(test.maxConcurrentFiles, fileSetup, 'default');
136
142
  return {
137
143
  hasExplicitProjects: false,
138
144
  projects: Object.freeze([
@@ -142,6 +148,12 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
142
148
  ...setup ? {
143
149
  setup
144
150
  } : {},
151
+ ...fileSetup ? {
152
+ fileSetup
153
+ } : {},
154
+ ...void 0 === test.maxConcurrentFiles ? {} : {
155
+ maxConcurrentFiles: test.maxConcurrentFiles
156
+ },
145
157
  tags: Object.freeze({
146
158
  include: [],
147
159
  exclude: []
@@ -154,6 +166,7 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
154
166
  };
155
167
  }
156
168
  if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
169
+ if (void 0 !== defaultFileSetup) throw new TypeError('Midscene config fileSetup cannot be used together with projects. Move fileSetup to projects[].fileSetup.');
157
170
  if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
158
171
  const names = new Set();
159
172
  const projects = value.map((candidate, index)=>{
@@ -162,6 +175,8 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
162
175
  rejectUnknownKeys(candidate, [
163
176
  'name',
164
177
  'setup',
178
+ 'fileSetup',
179
+ 'maxConcurrentFiles',
165
180
  'nodes',
166
181
  'files',
167
182
  'tags',
@@ -178,10 +193,20 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
178
193
  ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
179
194
  ...localNodes.definitions()
180
195
  ]);
196
+ const fileSetup = validateSetup(candidate.fileSetup, `${label}.fileSetup`);
197
+ if (void 0 === test.maxConcurrentFiles && (void 0 !== fileSetup || void 0 !== candidate.maxConcurrentFiles)) throw new TypeError(`Midscene config ${label}.fileSetup/maxConcurrentFiles requires test.maxConcurrentFiles.`);
198
+ const maxConcurrentFiles = void 0 === test.maxConcurrentFiles ? void 0 : Math.min(test.maxConcurrentFiles, validatePositiveInteger(candidate.maxConcurrentFiles, test.maxConcurrentFiles, `${label}.maxConcurrentFiles`));
199
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, label);
181
200
  return Object.freeze({
182
201
  projectId: projectIdFromIndex(index),
183
202
  name: candidate.name,
184
203
  nodes,
204
+ ...fileSetup ? {
205
+ fileSetup
206
+ } : {},
207
+ ...void 0 === maxConcurrentFiles ? {} : {
208
+ maxConcurrentFiles
209
+ },
185
210
  ...files ? {
186
211
  files
187
212
  } : {},
@@ -201,7 +226,15 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
201
226
  const validateTestOptions = (value)=>{
202
227
  if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
203
228
  const candidate = value ?? {};
229
+ const fileMode = void 0 !== candidate.maxConcurrentFiles;
230
+ if (fileMode && void 0 !== candidate.maxConcurrency) throw new TypeError('Midscene config test.maxConcurrentFiles cannot be combined with test.maxConcurrency.');
231
+ if (!fileMode && (void 0 !== candidate.setupTimeout || void 0 !== candidate.teardownTimeout)) throw new TypeError('Midscene config test.setupTimeout/teardownTimeout requires test.maxConcurrentFiles.');
204
232
  return Object.freeze({
233
+ ...fileMode ? {
234
+ maxConcurrentFiles: validatePositiveInteger(candidate.maxConcurrentFiles, 1, 'test.maxConcurrentFiles'),
235
+ setupTimeout: validatePositiveInteger(candidate.setupTimeout, 120000, 'test.setupTimeout'),
236
+ teardownTimeout: validatePositiveInteger(candidate.teardownTimeout, 30000, 'test.teardownTimeout')
237
+ } : {},
205
238
  maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
206
239
  bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
207
240
  testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
@@ -232,16 +265,19 @@ const validateTestProjectDefinition = (definition)=>{
232
265
  if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
233
266
  rejectUnknownKeys(definition, [
234
267
  'setup',
268
+ 'fileSetup',
235
269
  'projects',
236
270
  'test',
237
271
  'output',
238
272
  'nodes'
239
273
  ], 'root');
240
274
  const nodes = new registry_NodeRegistry(definition.nodes);
241
- const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
275
+ const test = validateTestOptions(definition.test);
276
+ if (void 0 !== definition.fileSetup && void 0 === test.maxConcurrentFiles) throw new TypeError('Midscene config fileSetup requires test.maxConcurrentFiles.');
277
+ const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, definition.fileSetup, nodes, test);
242
278
  return {
243
279
  ...resolvedProjects,
244
- test: validateTestOptions(definition.test),
280
+ test,
245
281
  output: validateOutput(definition.output),
246
282
  nodes,
247
283
  resolveNode: (name)=>nodes.get(name)
@@ -263,7 +299,7 @@ async function loadTestProject(configPath) {
263
299
  if (!configPath) return validateTestProjectDefinition({
264
300
  nodes: []
265
301
  });
266
- const absolutePath = resolve(configPath);
302
+ const absolutePath = external_node_path_resolve(configPath);
267
303
  const extension = assertSupportedConfig(absolutePath);
268
304
  let loaded;
269
305
  try {
@@ -759,7 +795,7 @@ const DEFAULT_TEST_FILE_SELECTION = {
759
795
  };
760
796
  const project_preparation_toPosix = (value)=>value.split(sep).join('/');
761
797
  const discoverResolvedTestFiles = (projectRoot, selection)=>{
762
- const root = resolve(projectRoot);
798
+ const root = external_node_path_resolve(projectRoot);
763
799
  const match = (patterns)=>globSync(patterns, {
764
800
  absolute: true,
765
801
  caseSensitiveMatch: false,
@@ -775,7 +811,7 @@ const discoverResolvedTestFiles = (projectRoot, selection)=>{
775
811
  }).filter((file)=>/\.ya?ml$/i.test(file));
776
812
  const files = match(selection.include);
777
813
  return [
778
- ...new Set(files.map((file)=>resolve(file)))
814
+ ...new Set(files.map((file)=>external_node_path_resolve(file)))
779
815
  ].sort((a, b)=>{
780
816
  const relativeA = project_preparation_toPosix(relative(root, a));
781
817
  const relativeB = project_preparation_toPosix(relative(root, b));
@@ -788,7 +824,7 @@ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)
788
824
  return discoverResolvedTestFiles(projectRoot, normalized);
789
825
  };
790
826
  const discoverTestConfig = (projectRoot)=>{
791
- const root = resolve(projectRoot);
827
+ const root = external_node_path_resolve(projectRoot);
792
828
  const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
793
829
  const supported = candidates.filter((name)=>CONFIG_NAMES.includes(name));
794
830
  const unsupported = candidates.filter((name)=>!CONFIG_NAMES.includes(name));
@@ -842,7 +878,7 @@ const selectProjects = (projects, names)=>{
842
878
  async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
843
879
  const platform = options.platform?.trim();
844
880
  const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
845
- const prerequisiteFile = options.prerequisiteFile ? resolve(options.prerequisiteFile) : void 0;
881
+ const prerequisiteFile = options.prerequisiteFile ? external_node_path_resolve(options.prerequisiteFile) : void 0;
846
882
  const mainFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
847
883
  const files = [
848
884
  ...prerequisiteFile ? [
@@ -920,7 +956,24 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
920
956
  defaultTimeoutMs,
921
957
  reportEnabled: true,
922
958
  bindings: {
923
- resolveNode: project.nodes.require.bind(project.nodes)
959
+ resolveNode: project.nodes.require.bind(project.nodes),
960
+ ...project.fileSetup ? {
961
+ documentSetup: {
962
+ name: project.fileSetup.name,
963
+ setup ({ projectContext, document, signal, onTeardown }) {
964
+ return project.fileSetup.setup({
965
+ project,
966
+ projectContext,
967
+ file: document,
968
+ env: Object.freeze({
969
+ ...process.env
970
+ }),
971
+ signal,
972
+ onTeardown
973
+ });
974
+ }
975
+ }
976
+ } : {}
924
977
  }
925
978
  });
926
979
  } catch (error) {
@@ -966,9 +1019,9 @@ const assertDirectory = (path, label)=>{
966
1019
  if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
967
1020
  };
968
1021
  const resolveRunInput = (options)=>{
969
- const cwd = resolve(options.cwd ?? process.cwd());
1022
+ const cwd = external_node_path_resolve(options.cwd ?? process.cwd());
970
1023
  assertDirectory(cwd, 'Test working directory');
971
- const inputPath = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
1024
+ const inputPath = options.projectRoot ? external_node_path_resolve(cwd, options.projectRoot) : void 0;
972
1025
  const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
973
1026
  if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
974
1027
  const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
@@ -987,14 +1040,14 @@ async function prepareTestRun(options) {
987
1040
  const startedAt = new Date();
988
1041
  const runId = createTestRunId(startedAt);
989
1042
  const input = resolveRunInput(options);
990
- const configPath = options.configPath ? resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
1043
+ const configPath = options.configPath ? external_node_path_resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
991
1044
  if (options.configPath && /\.ya?ml$/i.test(configPath)) throw new Error(`midscene-test --config only accepts a native TypeScript or JavaScript project config: ${configPath}. Run legacy batch YAML with \`midscene --config ${options.configPath}\`.`);
992
1045
  if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
993
1046
  const definition = await loadTestProject(configPath);
994
- const resultDir = options.resultDir ? resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
1047
+ const resultDir = options.resultDir ? external_node_path_resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
995
1048
  const runDir = join(resultDir, runId);
996
1049
  const summaryPath = join(runDir, 'summary.json');
997
- const reportDir = resolve(input.projectRoot, definition.output.reportDir);
1050
+ const reportDir = external_node_path_resolve(input.projectRoot, definition.output.reportDir);
998
1051
  mkdirSync(resultDir, {
999
1052
  recursive: true
1000
1053
  });
@@ -1123,11 +1176,40 @@ const documentHasFatalError = (result)=>[
1123
1176
  ...result.beforeAll,
1124
1177
  ...result.afterAll
1125
1178
  ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError) || !!result.executionErrors?.some(isResourceRecoveryError);
1179
+ const isSharedResourceFailure = (error)=>{
1180
+ const seen = new Set();
1181
+ const pending = [
1182
+ error
1183
+ ];
1184
+ while(pending.length && seen.size < 50){
1185
+ const item = pending.pop();
1186
+ if ('object' != typeof item || null === item || seen.has(item)) continue;
1187
+ seen.add(item);
1188
+ const failure = item;
1189
+ if ('PROJECT_RESOURCE_ERROR' === failure.code) return true;
1190
+ if (failure.cause) pending.push(failure.cause);
1191
+ if (Array.isArray(failure.errors)) pending.push(...failure.errors.slice(0, 50));
1192
+ }
1193
+ return false;
1194
+ };
1126
1195
  async function executeDocumentInvocation(options) {
1127
1196
  const { invocation, project, sinks } = options;
1197
+ const isProjectResourceFailure = options.isProjectResourceFailure ?? (()=>false);
1128
1198
  const { document } = invocation;
1129
1199
  const documentRetry = 'document' === invocation.retry.scope;
1130
1200
  const attempts = [];
1201
+ const fileSetup = invocation.bindings.documentSetup;
1202
+ const documentSetup = options.isProjectResourceFailure && fileSetup ? {
1203
+ ...fileSetup,
1204
+ async setup (ctx) {
1205
+ try {
1206
+ return await fileSetup.setup(ctx);
1207
+ } catch (error) {
1208
+ if (isProjectResourceFailure(error)) options.markProjectFatal();
1209
+ throw error;
1210
+ }
1211
+ }
1212
+ } : fileSetup;
1131
1213
  await runDocumentAttempts({
1132
1214
  retry: documentRetry ? invocation.retry.count : 0,
1133
1215
  signal: options.signal,
@@ -1138,6 +1220,9 @@ async function executeDocumentInvocation(options) {
1138
1220
  plan: invocation.plan,
1139
1221
  documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1140
1222
  ...invocation.bindings,
1223
+ documentSetup,
1224
+ setupTimeoutMs: options.setupTimeoutMs,
1225
+ teardownTimeoutMs: options.teardownTimeoutMs,
1141
1226
  project,
1142
1227
  projectContext: options.projectContext,
1143
1228
  retry: documentRetry ? 0 : invocation.retry.count,
@@ -1149,7 +1234,7 @@ async function executeDocumentInvocation(options) {
1149
1234
  ...run.beforeEach,
1150
1235
  ...run.steps,
1151
1236
  ...run.afterEach
1152
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
1237
+ ].some((step)=>step.error && (isFatalDeviceError(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>isFatalDeviceError(error) || isProjectResourceFailure(error)),
1153
1238
  onCaseStart: (collectedCase)=>{
1154
1239
  options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1155
1240
  },
@@ -1158,6 +1243,7 @@ async function executeDocumentInvocation(options) {
1158
1243
  options.onProgress(`${indent}→ ${formatStep(info)}`);
1159
1244
  },
1160
1245
  onStepResult: async (info, result)=>{
1246
+ if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1161
1247
  await invocation.bindings.onStepResult?.(info, result);
1162
1248
  options.onProgress(formatStepResult(info, result));
1163
1249
  },
@@ -1166,7 +1252,12 @@ async function executeDocumentInvocation(options) {
1166
1252
  options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1167
1253
  },
1168
1254
  onCaseOutcome: (outcome)=>{
1169
- if (caseHasFatalError(outcome)) options.markProjectFatal();
1255
+ if (outcome.attempts?.some((attempt)=>[
1256
+ ...attempt.beforeEach,
1257
+ ...attempt.steps,
1258
+ ...attempt.afterEach
1259
+ ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1260
+ if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1170
1261
  if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1171
1262
  },
1172
1263
  onDocumentResult: options.onDocumentResult
@@ -1175,7 +1266,7 @@ async function executeDocumentInvocation(options) {
1175
1266
  const partial = error.result;
1176
1267
  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)));
1177
1268
  if (resourceOnly) {
1178
- if (error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
1269
+ if (!options.isolatedContext && error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
1179
1270
  return partial;
1180
1271
  }
1181
1272
  if (documentRetry) partial.document.retryDecision = decideRetry({
@@ -1204,7 +1295,17 @@ async function executeDocumentInvocation(options) {
1204
1295
  }
1205
1296
  throw error;
1206
1297
  });
1207
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1298
+ if (!options.isolatedContext && documentHasFatalError(execution.document)) options.markProjectFatal();
1299
+ if ([
1300
+ ...execution.document.beforeAll,
1301
+ ...execution.document.afterAll,
1302
+ ...(execution.document.hostErrors ?? []).map(({ error })=>({
1303
+ error
1304
+ })),
1305
+ ...(execution.document.teardownErrors ?? []).map((error)=>({
1306
+ error
1307
+ }))
1308
+ ].some(({ error })=>isProjectResourceFailure(error))) options.markProjectFatal();
1208
1309
  const owners = [
1209
1310
  execution.document,
1210
1311
  ...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
@@ -1212,7 +1313,10 @@ async function executeDocumentInvocation(options) {
1212
1313
  const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
1213
1314
  const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
1214
1315
  const cleanupFailed = owners.some((owner)=>owner.cleanup?.status === 'failed' || !!owner.teardownErrors?.length || 'hostErrors' in owner && owner.hostErrors?.some(({ phase })=>'cleanup' === phase));
1215
- if (cleanupPending || cleanupFailed || operationPending) options.markProjectFatal();
1316
+ if (cleanupPending || operationPending) {
1317
+ options.markProjectFatal();
1318
+ options.onUnresolvedResources?.();
1319
+ } else if (cleanupFailed && !options.isolatedContext) options.markProjectFatal();
1216
1320
  const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1217
1321
  const decision = decideRetry({
1218
1322
  scope: 'document',
@@ -1265,6 +1369,213 @@ async function executeDocumentInvocation(options) {
1265
1369
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1266
1370
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1267
1371
  }
1372
+ async function runPreparedFiles(options) {
1373
+ const states = options.projects.map((prepared)=>({
1374
+ prepared,
1375
+ cases: [],
1376
+ documents: [],
1377
+ cursor: 0,
1378
+ active: 0,
1379
+ initializing: false,
1380
+ finished: false,
1381
+ fatal: false,
1382
+ setupFailed: false
1383
+ }));
1384
+ let nextProject = 0;
1385
+ let resourcesUnresolved = false;
1386
+ let occupied = 0;
1387
+ const cleanupQueue = [];
1388
+ const release = ()=>{
1389
+ occupied--;
1390
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1391
+ occupied++;
1392
+ cleanupQueue.shift()();
1393
+ }
1394
+ wake?.();
1395
+ };
1396
+ let wake;
1397
+ const running = new Set();
1398
+ const stopped = (state)=>options.signal.aborted || options.shouldBail() || state.fatal;
1399
+ const invalid = (state)=>options.preflightFailed || state.prepared.collectionErrors.length > 0;
1400
+ const unresolved = ()=>{
1401
+ if (resourcesUnresolved) return;
1402
+ resourcesUnresolved = true;
1403
+ options.onInfrastructureError(new WorkflowError('Resources are still running or cleanup is pending. No new test files will start.', {
1404
+ code: 'UNRESOLVED_FILE_RESOURCES'
1405
+ }));
1406
+ };
1407
+ const finishProject = async (state)=>{
1408
+ if (!state.runtime || state.finished) return;
1409
+ state.finished = true;
1410
+ const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1411
+ state.lifecycle = await state.runtime.finish(failed ? 'failed' : 'success');
1412
+ if (state.lifecycle.cleanup?.status === 'pending' || state.lifecycle.pendingOperations?.length) unresolved();
1413
+ };
1414
+ const admit = ()=>{
1415
+ for(let offset = 0; offset < states.length; offset++){
1416
+ const index = (nextProject + offset) % states.length;
1417
+ const state = states[index];
1418
+ if (invalid(state) || state.finished || state.initializing) continue;
1419
+ const cleanupOnly = !!state.runtime && 0 === state.active && (stopped(state) || state.cursor === state.prepared.invocations.length);
1420
+ if (!cleanupOnly && (stopped(state) || state.cursor >= state.prepared.invocations.length || state.active >= (state.prepared.project.maxConcurrentFiles ?? options.maxConcurrentFiles))) continue;
1421
+ nextProject = (index + 1) % states.length;
1422
+ occupied++;
1423
+ state.active++;
1424
+ if (!state.runtime && !cleanupOnly) state.initializing = true;
1425
+ const invocationIndex = cleanupOnly ? -1 : state.cursor++;
1426
+ return {
1427
+ state,
1428
+ index,
1429
+ invocationIndex
1430
+ };
1431
+ }
1432
+ };
1433
+ const run = async ({ state, index, invocationIndex })=>{
1434
+ const { prepared } = state;
1435
+ const { project } = prepared;
1436
+ let fileResourcesPending = false;
1437
+ try {
1438
+ if (invocationIndex < 0) return void await finishProject(state);
1439
+ if (!state.runtime) {
1440
+ state.runtime = createProjectRuntime({
1441
+ project,
1442
+ setup: project.setup,
1443
+ signal: options.signal,
1444
+ setupTimeoutMs: options.setupTimeoutMs,
1445
+ teardownTimeoutMs: options.teardownTimeoutMs
1446
+ });
1447
+ setMaxListeners(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1448
+ options.announceProject(prepared, index);
1449
+ await state.runtime.start();
1450
+ state.initializing = false;
1451
+ if (!state.runtime.canRun) {
1452
+ state.fatal = true;
1453
+ state.setupFailed = true;
1454
+ }
1455
+ wake?.();
1456
+ }
1457
+ if (stopped(state)) return;
1458
+ const invocation = prepared.invocations[invocationIndex];
1459
+ options.onProgress(prepared, ` [document ${invocationIndex + 1}/${prepared.invocations.length}] ${invocation.document.sourcePath}`);
1460
+ await executeDocumentInvocation({
1461
+ invocation,
1462
+ project,
1463
+ projectContext: state.runtime.context,
1464
+ runDir: options.runDir,
1465
+ signal: state.runtime.signal,
1466
+ rootSignal: options.signal,
1467
+ shouldBail: options.shouldBail,
1468
+ isProjectFatal: ()=>state.fatal,
1469
+ markProjectFatal: ()=>{
1470
+ if (state.fatal) return;
1471
+ state.fatal = true;
1472
+ state.runtime?.abort(new WorkflowError(`Project "${project.name}" shared execution environment is unavailable.`, {
1473
+ code: 'PROJECT_RESOURCE_ERROR'
1474
+ }));
1475
+ },
1476
+ addFailedCases: options.addFailedCases,
1477
+ onProgress: (message)=>options.onProgress(prepared, message),
1478
+ sinks: state,
1479
+ isolatedContext: void 0 !== project.fileSetup,
1480
+ isProjectResourceFailure: isSharedResourceFailure,
1481
+ setupTimeoutMs: options.setupTimeoutMs,
1482
+ teardownTimeoutMs: options.teardownTimeoutMs,
1483
+ onUnresolvedResources: ()=>{
1484
+ fileResourcesPending = true;
1485
+ unresolved();
1486
+ }
1487
+ });
1488
+ } catch (error) {
1489
+ state.fatal = true;
1490
+ fileResourcesPending = !!state.runtime && getPendingScopeOperations(state.runtime.signal).length > 0;
1491
+ options.onInfrastructureError(error);
1492
+ } finally{
1493
+ state.initializing = false;
1494
+ try {
1495
+ if (1 === state.active && (stopped(state) || state.cursor === prepared.invocations.length)) await finishProject(state);
1496
+ } catch (error) {
1497
+ options.onInfrastructureError(error);
1498
+ }
1499
+ state.active--;
1500
+ if (state.runtime && (fileResourcesPending || state.lifecycle?.cleanup?.status === 'pending')) waitForScopeOperations(state.runtime.signal).then(release, (error)=>{
1501
+ options.onInfrastructureError(error);
1502
+ release();
1503
+ });
1504
+ else release();
1505
+ wake?.();
1506
+ }
1507
+ };
1508
+ while(true){
1509
+ const changed = new Promise((resolve)=>{
1510
+ wake = resolve;
1511
+ });
1512
+ while(occupied < options.maxConcurrentFiles){
1513
+ const job = admit();
1514
+ if (!job) break;
1515
+ const task = run(job);
1516
+ running.add(task);
1517
+ const settled = ()=>{
1518
+ running.delete(task);
1519
+ wake?.();
1520
+ };
1521
+ task.then(settled, (error)=>{
1522
+ options.onInfrastructureError(error);
1523
+ settled();
1524
+ });
1525
+ }
1526
+ if (!running.size) break;
1527
+ await changed;
1528
+ }
1529
+ wake = void 0;
1530
+ await Promise.all(states.map(async (state)=>{
1531
+ if (!state.runtime || state.finished) return;
1532
+ const admission = new Promise((resolve)=>{
1533
+ cleanupQueue.push(resolve);
1534
+ });
1535
+ trackResourceOperation({}, admission, state.runtime.signal);
1536
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1537
+ occupied++;
1538
+ cleanupQueue.shift()();
1539
+ }
1540
+ try {
1541
+ await finishProject(state);
1542
+ } catch (error) {
1543
+ options.onInfrastructureError(error);
1544
+ } finally{
1545
+ Promise.all([
1546
+ admission,
1547
+ getResourceCleanupCompletion(state.runtime.signal)
1548
+ ]).then(release, (error)=>{
1549
+ options.onInfrastructureError(error);
1550
+ release();
1551
+ });
1552
+ }
1553
+ }));
1554
+ for (const state of states){
1555
+ const { prepared } = state;
1556
+ if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1557
+ const completed = new Set(state.cases.map((item)=>item.caseId));
1558
+ const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1559
+ for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1560
+ documentId: document.documentId,
1561
+ caseId: item.caseId,
1562
+ projectName: prepared.project.name,
1563
+ name: item.definition.name,
1564
+ sourcePath: item.sourcePath,
1565
+ caseIndex: item.caseIndex,
1566
+ status: 'not-run',
1567
+ notRunReason: reason
1568
+ });
1569
+ const order = new Map(prepared.documents.map((document, i)=>[
1570
+ document.documentId,
1571
+ i
1572
+ ]));
1573
+ const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1574
+ state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1575
+ state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1576
+ }
1577
+ return states;
1578
+ }
1268
1579
  function createOperationJournal(runDir) {
1269
1580
  const path = join(runDir, 'operations.jsonl');
1270
1581
  writeFileSync(path, '', {
@@ -1334,7 +1645,7 @@ async function runPreparedTestProject(plan) {
1334
1645
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1335
1646
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1336
1647
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1337
- const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1648
+ const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? definition.test.maxConcurrency, void 0 === definition.test.maxConcurrentFiles ? preparedProjects.length : Math.max(1, totalDocuments));
1338
1649
  await writeTestRunPlan(runDir, preparedProjects);
1339
1650
  const journal = createOperationJournal(runDir);
1340
1651
  const rootController = new AbortController();
@@ -1348,6 +1659,18 @@ async function runPreparedTestProject(plan) {
1348
1659
  process.on('SIGTERM', sigterm);
1349
1660
  let failedCaseCount = 0;
1350
1661
  const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
1662
+ let shutdownDeadline;
1663
+ const beginShutdown = ()=>{
1664
+ if (void 0 !== definition.test.maxConcurrentFiles) shutdownDeadline ??= Date.now() + definition.test.teardownTimeout;
1665
+ };
1666
+ rootController.signal.addEventListener('abort', beginShutdown, {
1667
+ once: true
1668
+ });
1669
+ const addFailedCases = (count)=>{
1670
+ failedCaseCount += count;
1671
+ if (bailReached()) beginShutdown();
1672
+ };
1673
+ const teardownTimeoutMs = ()=>Math.max(0, Math.min(definition.test.teardownTimeout, void 0 === shutdownDeadline ? 1 / 0 : shutdownDeadline - Date.now()));
1351
1674
  let hasInfrastructureError = false;
1352
1675
  const infrastructureErrors = [];
1353
1676
  const recordInfrastructureError = (error)=>{
@@ -1450,9 +1773,7 @@ async function runPreparedTestProject(plan) {
1450
1773
  markProjectFatal: ()=>{
1451
1774
  projectFatal = true;
1452
1775
  },
1453
- addFailedCases: (count)=>{
1454
- failedCaseCount += count;
1455
- },
1776
+ addFailedCases,
1456
1777
  onProgress: projectProgress,
1457
1778
  sinks: {
1458
1779
  cases,
@@ -1505,7 +1826,25 @@ async function runPreparedTestProject(plan) {
1505
1826
  };
1506
1827
  const projectResults = new Array(preparedProjects.length);
1507
1828
  try {
1508
- await runConcurrentJobs(preparedProjects, {
1829
+ if (void 0 !== definition.test.maxConcurrentFiles) {
1830
+ const states = await runPreparedFiles({
1831
+ projects: preparedProjects,
1832
+ maxConcurrentFiles: definition.test.maxConcurrentFiles,
1833
+ setupTimeoutMs: definition.test.setupTimeout,
1834
+ teardownTimeoutMs,
1835
+ signal: rootController.signal,
1836
+ runDir,
1837
+ preflightFailed: 'run' === plan.preflightScope && totalErrors > 0,
1838
+ shouldBail: bailReached,
1839
+ addFailedCases,
1840
+ announceProject,
1841
+ onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
1842
+ onInfrastructureError: recordInfrastructureError
1843
+ });
1844
+ states.forEach((state, index)=>{
1845
+ projectResults[index] = buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle);
1846
+ });
1847
+ } else await runConcurrentJobs(preparedProjects, {
1509
1848
  concurrency: effectiveConcurrency,
1510
1849
  shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
1511
1850
  }, async (prepared, index)=>{
@@ -1621,6 +1960,6 @@ async function runPreparedTestProject(plan) {
1621
1960
  async function runTestProject(options = {}) {
1622
1961
  return runPreparedTestProject(await prepareTestRun(options));
1623
1962
  }
1624
- export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
1963
+ export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineFileSetup, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
1625
1964
 
1626
1965
  //# sourceMappingURL=index.mjs.map