@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.
@@ -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, 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,19 @@ 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 validateConcurrency = (value, label)=>{
135
+ if (null === value) return null;
136
+ return validatePositiveInteger(value, 1, label);
137
+ };
138
+ const validateFileConcurrency = (maxConcurrentFiles, fileSetup, label)=>{
139
+ if (maxConcurrentFiles > 1 && !fileSetup) throw new TypeError(`Midscene config ${label}.fileSetup is required when the effective maxConcurrentFiles is greater than 1.`);
140
+ };
141
+ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, globalNodes, test)=>{
134
142
  if (void 0 === value) {
135
143
  const setup = validateSetup(defaultSetup, 'setup');
144
+ const fileSetup = validateSetup(defaultFileSetup, 'fileSetup');
145
+ const maxConcurrentFiles = test.maxConcurrentFiles ?? 1 / 0;
146
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, 'default');
136
147
  return {
137
148
  hasExplicitProjects: false,
138
149
  projects: Object.freeze([
@@ -142,6 +153,10 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
142
153
  ...setup ? {
143
154
  setup
144
155
  } : {},
156
+ ...fileSetup ? {
157
+ fileSetup
158
+ } : {},
159
+ maxConcurrentFiles,
145
160
  tags: Object.freeze({
146
161
  include: [],
147
162
  exclude: []
@@ -154,6 +169,7 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
154
169
  };
155
170
  }
156
171
  if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
172
+ if (void 0 !== defaultFileSetup) throw new TypeError('Midscene config fileSetup cannot be used together with projects. Move fileSetup to projects[].fileSetup.');
157
173
  if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
158
174
  const names = new Set();
159
175
  const projects = value.map((candidate, index)=>{
@@ -162,6 +178,8 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
162
178
  rejectUnknownKeys(candidate, [
163
179
  'name',
164
180
  'setup',
181
+ 'fileSetup',
182
+ 'maxConcurrentFiles',
165
183
  'nodes',
166
184
  'files',
167
185
  'tags',
@@ -178,10 +196,18 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
178
196
  ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
179
197
  ...localNodes.definitions()
180
198
  ]);
199
+ const fileSetup = validateSetup(candidate.fileSetup, `${label}.fileSetup`);
200
+ const globalFileLimit = test.maxConcurrentFiles ?? 1 / 0;
201
+ const maxConcurrentFiles = Math.min(globalFileLimit, validatePositiveInteger(candidate.maxConcurrentFiles, globalFileLimit, `${label}.maxConcurrentFiles`));
202
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, label);
181
203
  return Object.freeze({
182
204
  projectId: projectIdFromIndex(index),
183
205
  name: candidate.name,
184
206
  nodes,
207
+ ...fileSetup ? {
208
+ fileSetup
209
+ } : {},
210
+ maxConcurrentFiles,
185
211
  ...files ? {
186
212
  files
187
213
  } : {},
@@ -198,11 +224,25 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
198
224
  hasExplicitProjects: true
199
225
  };
200
226
  };
201
- const validateTestOptions = (value)=>{
227
+ const validateTestOptions = (value, hasExplicitProjects)=>{
202
228
  if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
203
229
  const candidate = value ?? {};
230
+ if (void 0 !== candidate.maxConcurrency) {
231
+ const limit = 'number' == typeof candidate.maxConcurrency && Number.isInteger(candidate.maxConcurrency) && candidate.maxConcurrency > 0 ? candidate.maxConcurrency : 1;
232
+ throw new TypeError([
233
+ 'Midscene config test.maxConcurrency is no longer supported.',
234
+ 'It controlled project concurrency, not file concurrency. Remove it and use:',
235
+ ` test: { maxConcurrentProjects: ${limit}, maxConcurrentFiles: ${hasExplicitProjects ? limit : 1} }`,
236
+ hasExplicitProjects ? ' Set projects[].maxConcurrentFiles to 1 to keep files sequential within each project.' : ' The implicit default project keeps running files sequentially.',
237
+ hasExplicitProjects ? 'For parallel files, configure fileSetup and raise both test.maxConcurrentFiles and projects[].maxConcurrentFiles.' : 'For parallel files, configure fileSetup and raise test.maxConcurrentFiles.',
238
+ 'Migration guide: https://midscenejs.com/midscene-test/configuration.html#existing-configurations'
239
+ ].join('\n'));
240
+ }
204
241
  return Object.freeze({
205
- maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
242
+ maxConcurrentFiles: validateConcurrency(candidate.maxConcurrentFiles, 'test.maxConcurrentFiles'),
243
+ maxConcurrentProjects: validateConcurrency(candidate.maxConcurrentProjects, 'test.maxConcurrentProjects'),
244
+ setupTimeout: validatePositiveInteger(candidate.setupTimeout, 120000, 'test.setupTimeout'),
245
+ teardownTimeout: validatePositiveInteger(candidate.teardownTimeout, 30000, 'test.teardownTimeout'),
206
246
  bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
207
247
  testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
208
248
  });
@@ -232,16 +272,18 @@ const validateTestProjectDefinition = (definition)=>{
232
272
  if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
233
273
  rejectUnknownKeys(definition, [
234
274
  'setup',
275
+ 'fileSetup',
235
276
  'projects',
236
277
  'test',
237
278
  'output',
238
279
  'nodes'
239
280
  ], 'root');
240
281
  const nodes = new registry_NodeRegistry(definition.nodes);
241
- const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
282
+ const test = validateTestOptions(definition.test, void 0 !== definition.projects);
283
+ const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, definition.fileSetup, nodes, test);
242
284
  return {
243
285
  ...resolvedProjects,
244
- test: validateTestOptions(definition.test),
286
+ test,
245
287
  output: validateOutput(definition.output),
246
288
  nodes,
247
289
  resolveNode: (name)=>nodes.get(name)
@@ -263,7 +305,7 @@ async function loadTestProject(configPath) {
263
305
  if (!configPath) return validateTestProjectDefinition({
264
306
  nodes: []
265
307
  });
266
- const absolutePath = resolve(configPath);
308
+ const absolutePath = external_node_path_resolve(configPath);
267
309
  const extension = assertSupportedConfig(absolutePath);
268
310
  let loaded;
269
311
  try {
@@ -759,7 +801,7 @@ const DEFAULT_TEST_FILE_SELECTION = {
759
801
  };
760
802
  const project_preparation_toPosix = (value)=>value.split(sep).join('/');
761
803
  const discoverResolvedTestFiles = (projectRoot, selection)=>{
762
- const root = resolve(projectRoot);
804
+ const root = external_node_path_resolve(projectRoot);
763
805
  const match = (patterns)=>globSync(patterns, {
764
806
  absolute: true,
765
807
  caseSensitiveMatch: false,
@@ -775,7 +817,7 @@ const discoverResolvedTestFiles = (projectRoot, selection)=>{
775
817
  }).filter((file)=>/\.ya?ml$/i.test(file));
776
818
  const files = match(selection.include);
777
819
  return [
778
- ...new Set(files.map((file)=>resolve(file)))
820
+ ...new Set(files.map((file)=>external_node_path_resolve(file)))
779
821
  ].sort((a, b)=>{
780
822
  const relativeA = project_preparation_toPosix(relative(root, a));
781
823
  const relativeB = project_preparation_toPosix(relative(root, b));
@@ -788,7 +830,7 @@ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)
788
830
  return discoverResolvedTestFiles(projectRoot, normalized);
789
831
  };
790
832
  const discoverTestConfig = (projectRoot)=>{
791
- const root = resolve(projectRoot);
833
+ const root = external_node_path_resolve(projectRoot);
792
834
  const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
793
835
  const supported = candidates.filter((name)=>CONFIG_NAMES.includes(name));
794
836
  const unsupported = candidates.filter((name)=>!CONFIG_NAMES.includes(name));
@@ -842,7 +884,7 @@ const selectProjects = (projects, names)=>{
842
884
  async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
843
885
  const platform = options.platform?.trim();
844
886
  const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
845
- const prerequisiteFile = options.prerequisiteFile ? resolve(options.prerequisiteFile) : void 0;
887
+ const prerequisiteFile = options.prerequisiteFile ? external_node_path_resolve(options.prerequisiteFile) : void 0;
846
888
  const mainFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
847
889
  const files = [
848
890
  ...prerequisiteFile ? [
@@ -920,7 +962,24 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
920
962
  defaultTimeoutMs,
921
963
  reportEnabled: true,
922
964
  bindings: {
923
- resolveNode: project.nodes.require.bind(project.nodes)
965
+ resolveNode: project.nodes.require.bind(project.nodes),
966
+ ...project.fileSetup ? {
967
+ documentSetup: {
968
+ name: project.fileSetup.name,
969
+ setup ({ projectContext, document, signal, onTeardown }) {
970
+ return project.fileSetup.setup({
971
+ project,
972
+ projectContext,
973
+ file: document,
974
+ env: Object.freeze({
975
+ ...process.env
976
+ }),
977
+ signal,
978
+ onTeardown
979
+ });
980
+ }
981
+ }
982
+ } : {}
924
983
  }
925
984
  });
926
985
  } catch (error) {
@@ -966,9 +1025,9 @@ const assertDirectory = (path, label)=>{
966
1025
  if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
967
1026
  };
968
1027
  const resolveRunInput = (options)=>{
969
- const cwd = resolve(options.cwd ?? process.cwd());
1028
+ const cwd = external_node_path_resolve(options.cwd ?? process.cwd());
970
1029
  assertDirectory(cwd, 'Test working directory');
971
- const inputPath = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
1030
+ const inputPath = options.projectRoot ? external_node_path_resolve(cwd, options.projectRoot) : void 0;
972
1031
  const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
973
1032
  if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
974
1033
  const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
@@ -987,14 +1046,14 @@ async function prepareTestRun(options) {
987
1046
  const startedAt = new Date();
988
1047
  const runId = createTestRunId(startedAt);
989
1048
  const input = resolveRunInput(options);
990
- const configPath = options.configPath ? resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
1049
+ const configPath = options.configPath ? external_node_path_resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
991
1050
  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
1051
  if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
993
1052
  const definition = await loadTestProject(configPath);
994
- const resultDir = options.resultDir ? resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
1053
+ const resultDir = options.resultDir ? external_node_path_resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
995
1054
  const runDir = join(resultDir, runId);
996
1055
  const summaryPath = join(runDir, 'summary.json');
997
- const reportDir = resolve(input.projectRoot, definition.output.reportDir);
1056
+ const reportDir = external_node_path_resolve(input.projectRoot, definition.output.reportDir);
998
1057
  mkdirSync(resultDir, {
999
1058
  recursive: true
1000
1059
  });
@@ -1123,11 +1182,40 @@ const documentHasFatalError = (result)=>[
1123
1182
  ...result.beforeAll,
1124
1183
  ...result.afterAll
1125
1184
  ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError) || !!result.executionErrors?.some(isResourceRecoveryError);
1185
+ const isSharedResourceFailure = (error)=>{
1186
+ const seen = new Set();
1187
+ const pending = [
1188
+ error
1189
+ ];
1190
+ while(pending.length && seen.size < 50){
1191
+ const item = pending.pop();
1192
+ if ('object' != typeof item || null === item || seen.has(item)) continue;
1193
+ seen.add(item);
1194
+ const failure = item;
1195
+ if ('PROJECT_RESOURCE_ERROR' === failure.code) return true;
1196
+ if (failure.cause) pending.push(failure.cause);
1197
+ if (Array.isArray(failure.errors)) pending.push(...failure.errors.slice(0, 50));
1198
+ }
1199
+ return false;
1200
+ };
1126
1201
  async function executeDocumentInvocation(options) {
1127
1202
  const { invocation, project, sinks } = options;
1203
+ const isProjectResourceFailure = options.isProjectResourceFailure ?? (()=>false);
1128
1204
  const { document } = invocation;
1129
1205
  const documentRetry = 'document' === invocation.retry.scope;
1130
1206
  const attempts = [];
1207
+ const fileSetup = invocation.bindings.documentSetup;
1208
+ const documentSetup = options.isProjectResourceFailure && fileSetup ? {
1209
+ ...fileSetup,
1210
+ async setup (ctx) {
1211
+ try {
1212
+ return await fileSetup.setup(ctx);
1213
+ } catch (error) {
1214
+ if (isProjectResourceFailure(error)) options.markProjectFatal();
1215
+ throw error;
1216
+ }
1217
+ }
1218
+ } : fileSetup;
1131
1219
  await runDocumentAttempts({
1132
1220
  retry: documentRetry ? invocation.retry.count : 0,
1133
1221
  signal: options.signal,
@@ -1138,6 +1226,9 @@ async function executeDocumentInvocation(options) {
1138
1226
  plan: invocation.plan,
1139
1227
  documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1140
1228
  ...invocation.bindings,
1229
+ documentSetup,
1230
+ setupTimeoutMs: options.setupTimeoutMs,
1231
+ teardownTimeoutMs: options.teardownTimeoutMs,
1141
1232
  project,
1142
1233
  projectContext: options.projectContext,
1143
1234
  retry: documentRetry ? 0 : invocation.retry.count,
@@ -1149,7 +1240,7 @@ async function executeDocumentInvocation(options) {
1149
1240
  ...run.beforeEach,
1150
1241
  ...run.steps,
1151
1242
  ...run.afterEach
1152
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
1243
+ ].some((step)=>step.error && (isFatalDeviceError(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>isFatalDeviceError(error) || isProjectResourceFailure(error)),
1153
1244
  onCaseStart: (collectedCase)=>{
1154
1245
  options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1155
1246
  },
@@ -1158,6 +1249,7 @@ async function executeDocumentInvocation(options) {
1158
1249
  options.onProgress(`${indent}→ ${formatStep(info)}`);
1159
1250
  },
1160
1251
  onStepResult: async (info, result)=>{
1252
+ if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1161
1253
  await invocation.bindings.onStepResult?.(info, result);
1162
1254
  options.onProgress(formatStepResult(info, result));
1163
1255
  },
@@ -1166,7 +1258,12 @@ async function executeDocumentInvocation(options) {
1166
1258
  options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1167
1259
  },
1168
1260
  onCaseOutcome: (outcome)=>{
1169
- if (caseHasFatalError(outcome)) options.markProjectFatal();
1261
+ if (outcome.attempts?.some((attempt)=>[
1262
+ ...attempt.beforeEach,
1263
+ ...attempt.steps,
1264
+ ...attempt.afterEach
1265
+ ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1266
+ if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1170
1267
  if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1171
1268
  },
1172
1269
  onDocumentResult: options.onDocumentResult
@@ -1175,7 +1272,7 @@ async function executeDocumentInvocation(options) {
1175
1272
  const partial = error.result;
1176
1273
  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
1274
  if (resourceOnly) {
1178
- if (error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
1275
+ if (!options.isolatedContext && error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
1179
1276
  return partial;
1180
1277
  }
1181
1278
  if (documentRetry) partial.document.retryDecision = decideRetry({
@@ -1204,7 +1301,17 @@ async function executeDocumentInvocation(options) {
1204
1301
  }
1205
1302
  throw error;
1206
1303
  });
1207
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1304
+ if (!options.isolatedContext && documentHasFatalError(execution.document)) options.markProjectFatal();
1305
+ if ([
1306
+ ...execution.document.beforeAll,
1307
+ ...execution.document.afterAll,
1308
+ ...(execution.document.hostErrors ?? []).map(({ error })=>({
1309
+ error
1310
+ })),
1311
+ ...(execution.document.teardownErrors ?? []).map((error)=>({
1312
+ error
1313
+ }))
1314
+ ].some(({ error })=>isProjectResourceFailure(error))) options.markProjectFatal();
1208
1315
  const owners = [
1209
1316
  execution.document,
1210
1317
  ...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
@@ -1212,7 +1319,10 @@ async function executeDocumentInvocation(options) {
1212
1319
  const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
1213
1320
  const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
1214
1321
  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();
1322
+ if (cleanupPending || operationPending) {
1323
+ options.markProjectFatal();
1324
+ options.onUnresolvedResources?.();
1325
+ } else if (cleanupFailed && !options.isolatedContext) options.markProjectFatal();
1216
1326
  const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1217
1327
  const decision = decideRetry({
1218
1328
  scope: 'document',
@@ -1265,6 +1375,231 @@ async function executeDocumentInvocation(options) {
1265
1375
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1266
1376
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1267
1377
  }
1378
+ async function runPreparedFiles(options) {
1379
+ const states = options.projects.map((prepared)=>({
1380
+ prepared,
1381
+ cases: [],
1382
+ documents: [],
1383
+ cursor: 0,
1384
+ active: 0,
1385
+ initializing: false,
1386
+ finished: false,
1387
+ fatal: false,
1388
+ setupFailed: false
1389
+ }));
1390
+ let nextProject = 0;
1391
+ let resourcesUnresolved = false;
1392
+ let occupied = 0;
1393
+ let occupiedProjects = 0;
1394
+ const cleanupQueue = [];
1395
+ const release = ()=>{
1396
+ occupied--;
1397
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1398
+ occupied++;
1399
+ cleanupQueue.shift()();
1400
+ }
1401
+ wake?.();
1402
+ };
1403
+ let wake;
1404
+ const running = new Set();
1405
+ const stopped = (state)=>options.signal.aborted || options.shouldBail() || state.fatal;
1406
+ const invalid = (state)=>options.preflightFailed || state.prepared.collectionErrors.length > 0;
1407
+ const unresolved = ()=>{
1408
+ if (resourcesUnresolved) return;
1409
+ resourcesUnresolved = true;
1410
+ options.onInfrastructureError(new WorkflowError('Resources are still running or cleanup is pending. No new test files will start.', {
1411
+ code: 'UNRESOLVED_FILE_RESOURCES'
1412
+ }));
1413
+ };
1414
+ const finishProject = async (state)=>{
1415
+ if (!state.runtime || state.finished) return;
1416
+ state.finished = true;
1417
+ const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1418
+ state.lifecycle = await state.runtime.finish(failed ? 'failed' : 'success');
1419
+ const releaseProject = ()=>{
1420
+ occupiedProjects--;
1421
+ wake?.();
1422
+ };
1423
+ if (state.lifecycle.cleanup?.status === 'pending' || state.lifecycle.pendingOperations?.length) {
1424
+ unresolved();
1425
+ Promise.all([
1426
+ waitForScopeOperations(state.runtime.signal),
1427
+ getResourceCleanupCompletion(state.runtime.signal)
1428
+ ]).then(releaseProject, (error)=>{
1429
+ options.onInfrastructureError(error);
1430
+ releaseProject();
1431
+ });
1432
+ } else releaseProject();
1433
+ };
1434
+ const admit = ()=>{
1435
+ for(let offset = 0; offset < states.length; offset++){
1436
+ const index = (nextProject + offset) % states.length;
1437
+ const state = states[index];
1438
+ if (invalid(state) || state.finished || state.initializing) continue;
1439
+ if (!state.runtime && occupiedProjects >= options.maxConcurrentProjects) continue;
1440
+ const cleanupOnly = !!state.runtime && 0 === state.active && (stopped(state) || state.cursor === state.prepared.invocations.length);
1441
+ if (!cleanupOnly && (stopped(state) || state.cursor >= state.prepared.invocations.length || state.active >= (state.prepared.project.maxConcurrentFiles ?? options.maxConcurrentFiles))) continue;
1442
+ nextProject = (index + 1) % states.length;
1443
+ occupied++;
1444
+ state.active++;
1445
+ if (!state.runtime && !cleanupOnly) {
1446
+ occupiedProjects++;
1447
+ state.initializing = true;
1448
+ }
1449
+ const invocationIndex = cleanupOnly ? -1 : state.cursor++;
1450
+ return {
1451
+ state,
1452
+ index,
1453
+ invocationIndex
1454
+ };
1455
+ }
1456
+ };
1457
+ const run = async ({ state, index, invocationIndex })=>{
1458
+ const { prepared } = state;
1459
+ const { project } = prepared;
1460
+ let fileResourcesPending = false;
1461
+ try {
1462
+ if (invocationIndex < 0) return void await finishProject(state);
1463
+ if (!state.runtime) {
1464
+ state.runtime = createProjectRuntime({
1465
+ project,
1466
+ setup: project.setup,
1467
+ signal: options.signal,
1468
+ setupTimeoutMs: options.setupTimeoutMs,
1469
+ teardownTimeoutMs: options.teardownTimeoutMs
1470
+ });
1471
+ setMaxListeners(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1472
+ options.announceProject(prepared, index);
1473
+ await state.runtime.start();
1474
+ state.initializing = false;
1475
+ if (!state.runtime.canRun) {
1476
+ state.fatal = true;
1477
+ state.setupFailed = true;
1478
+ }
1479
+ wake?.();
1480
+ }
1481
+ if (stopped(state)) return;
1482
+ const invocation = prepared.invocations[invocationIndex];
1483
+ options.onProgress(prepared, ` [document ${invocationIndex + 1}/${prepared.invocations.length}] ${invocation.document.sourcePath}`);
1484
+ await executeDocumentInvocation({
1485
+ invocation,
1486
+ project,
1487
+ projectContext: state.runtime.context,
1488
+ runDir: options.runDir,
1489
+ signal: state.runtime.signal,
1490
+ rootSignal: options.signal,
1491
+ shouldBail: options.shouldBail,
1492
+ isProjectFatal: ()=>state.fatal,
1493
+ markProjectFatal: ()=>{
1494
+ if (state.fatal) return;
1495
+ state.fatal = true;
1496
+ state.runtime?.abort(new WorkflowError(`Project "${project.name}" shared execution environment is unavailable.`, {
1497
+ code: 'PROJECT_RESOURCE_ERROR'
1498
+ }));
1499
+ },
1500
+ addFailedCases: options.addFailedCases,
1501
+ onProgress: (message)=>options.onProgress(prepared, message),
1502
+ sinks: state,
1503
+ isolatedContext: void 0 !== project.fileSetup,
1504
+ isProjectResourceFailure: isSharedResourceFailure,
1505
+ setupTimeoutMs: options.setupTimeoutMs,
1506
+ teardownTimeoutMs: options.teardownTimeoutMs,
1507
+ onUnresolvedResources: ()=>{
1508
+ fileResourcesPending = true;
1509
+ unresolved();
1510
+ }
1511
+ });
1512
+ } catch (error) {
1513
+ state.fatal = true;
1514
+ fileResourcesPending = !!state.runtime && getPendingScopeOperations(state.runtime.signal).length > 0;
1515
+ options.onInfrastructureError(error);
1516
+ } finally{
1517
+ state.initializing = false;
1518
+ try {
1519
+ if (1 === state.active && (stopped(state) || state.cursor === prepared.invocations.length)) await finishProject(state);
1520
+ } catch (error) {
1521
+ options.onInfrastructureError(error);
1522
+ }
1523
+ state.active--;
1524
+ if (state.runtime && (fileResourcesPending || state.lifecycle?.cleanup?.status === 'pending')) waitForScopeOperations(state.runtime.signal).then(release, (error)=>{
1525
+ options.onInfrastructureError(error);
1526
+ release();
1527
+ });
1528
+ else release();
1529
+ wake?.();
1530
+ }
1531
+ };
1532
+ while(true){
1533
+ const changed = new Promise((resolve)=>{
1534
+ wake = resolve;
1535
+ });
1536
+ while(occupied < options.maxConcurrentFiles){
1537
+ const job = admit();
1538
+ if (!job) break;
1539
+ const task = run(job);
1540
+ running.add(task);
1541
+ const settled = ()=>{
1542
+ running.delete(task);
1543
+ wake?.();
1544
+ };
1545
+ task.then(settled, (error)=>{
1546
+ options.onInfrastructureError(error);
1547
+ settled();
1548
+ });
1549
+ }
1550
+ if (!running.size) break;
1551
+ await changed;
1552
+ }
1553
+ wake = void 0;
1554
+ await Promise.all(states.map(async (state)=>{
1555
+ if (!state.runtime || state.finished) return;
1556
+ const admission = new Promise((resolve)=>{
1557
+ cleanupQueue.push(resolve);
1558
+ });
1559
+ trackResourceOperation({}, admission, state.runtime.signal);
1560
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1561
+ occupied++;
1562
+ cleanupQueue.shift()();
1563
+ }
1564
+ try {
1565
+ await finishProject(state);
1566
+ } catch (error) {
1567
+ options.onInfrastructureError(error);
1568
+ } finally{
1569
+ Promise.all([
1570
+ admission,
1571
+ getResourceCleanupCompletion(state.runtime.signal)
1572
+ ]).then(release, (error)=>{
1573
+ options.onInfrastructureError(error);
1574
+ release();
1575
+ });
1576
+ }
1577
+ }));
1578
+ for (const state of states){
1579
+ const { prepared } = state;
1580
+ if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1581
+ const completed = new Set(state.cases.map((item)=>item.caseId));
1582
+ const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1583
+ for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1584
+ documentId: document.documentId,
1585
+ caseId: item.caseId,
1586
+ projectName: prepared.project.name,
1587
+ name: item.definition.name,
1588
+ sourcePath: item.sourcePath,
1589
+ caseIndex: item.caseIndex,
1590
+ status: 'not-run',
1591
+ notRunReason: reason
1592
+ });
1593
+ const order = new Map(prepared.documents.map((document, i)=>[
1594
+ document.documentId,
1595
+ i
1596
+ ]));
1597
+ const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1598
+ state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1599
+ state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1600
+ }
1601
+ return states;
1602
+ }
1268
1603
  function createOperationJournal(runDir) {
1269
1604
  const path = join(runDir, 'operations.jsonl');
1270
1605
  writeFileSync(path, '', {
@@ -1334,7 +1669,7 @@ async function runPreparedTestProject(plan) {
1334
1669
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1335
1670
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1336
1671
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1337
- const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1672
+ const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
1338
1673
  await writeTestRunPlan(runDir, preparedProjects);
1339
1674
  const journal = createOperationJournal(runDir);
1340
1675
  const rootController = new AbortController();
@@ -1348,6 +1683,18 @@ async function runPreparedTestProject(plan) {
1348
1683
  process.on('SIGTERM', sigterm);
1349
1684
  let failedCaseCount = 0;
1350
1685
  const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
1686
+ let shutdownDeadline;
1687
+ const beginShutdown = ()=>{
1688
+ shutdownDeadline ??= Date.now() + definition.test.teardownTimeout;
1689
+ };
1690
+ rootController.signal.addEventListener('abort', beginShutdown, {
1691
+ once: true
1692
+ });
1693
+ const addFailedCases = (count)=>{
1694
+ failedCaseCount += count;
1695
+ if (bailReached()) beginShutdown();
1696
+ };
1697
+ const teardownTimeoutMs = ()=>Math.max(0, Math.min(definition.test.teardownTimeout, void 0 === shutdownDeadline ? 1 / 0 : shutdownDeadline - Date.now()));
1351
1698
  let hasInfrastructureError = false;
1352
1699
  const infrastructureErrors = [];
1353
1700
  const recordInfrastructureError = (error)=>{
@@ -1409,112 +1756,25 @@ async function runPreparedTestProject(plan) {
1409
1756
  announceProject(prepared, projectIndex);
1410
1757
  return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
1411
1758
  };
1412
- const runPreparedProject = async (prepared, projectIndex)=>{
1413
- const { project } = prepared;
1414
- announceProject(prepared, projectIndex);
1415
- const projectProgress = (message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${project.name}]${message}` : message));
1416
- const cases = [];
1417
- const documents = [];
1418
- let lifecycle;
1419
- let projectFatal = false;
1420
- if (prepared.collectionErrors.length > 0 || 'run' === plan.preflightScope && totalErrors > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
1421
- else if (rootController.signal.aborted) cases.push(...notRunSuite(prepared, 'interrupted'));
1422
- else if (bailReached()) cases.push(...notRunSuite(prepared, 'bail'));
1423
- else {
1424
- const runtime = createProjectRuntime({
1425
- project,
1426
- setup: project.setup,
1427
- signal: rootController.signal
1428
- });
1429
- let hasProjectExecutionError = false;
1430
- try {
1431
- await runtime.start();
1432
- if (runtime.canRun) {
1433
- const runDocument = async (invocation, documentIndex)=>{
1434
- const { document } = invocation;
1435
- if (rootController.signal.aborted || bailReached() || projectFatal) {
1436
- const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1437
- cases.push(...document.cases.map((item)=>asNotRun(document.documentId, item, project.name, reason)));
1438
- return;
1439
- }
1440
- projectProgress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
1441
- await executeDocumentInvocation({
1442
- invocation,
1443
- project,
1444
- projectContext: runtime.context,
1445
- runDir,
1446
- signal: runtime.signal,
1447
- rootSignal: rootController.signal,
1448
- shouldBail: bailReached,
1449
- isProjectFatal: ()=>projectFatal,
1450
- markProjectFatal: ()=>{
1451
- projectFatal = true;
1452
- },
1453
- addFailedCases: (count)=>{
1454
- failedCaseCount += count;
1455
- },
1456
- onProgress: projectProgress,
1457
- sinks: {
1458
- cases,
1459
- documents
1460
- }
1461
- });
1462
- };
1463
- let prerequisiteFailed = false;
1464
- const prerequisite = prepared.invocations.find((invocation)=>invocation.document.documentId === prepared.prerequisiteDocumentId);
1465
- const prerequisiteCount = prerequisite ? 1 : 0;
1466
- if (prerequisite) {
1467
- await runDocument(prerequisite, 0);
1468
- prerequisiteFailed = documents.at(-1)?.status === 'failed' || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status);
1469
- }
1470
- if (!prerequisiteFailed) await runConcurrentJobs(prepared.invocations.filter((invocation)=>invocation !== prerequisite), {
1471
- concurrency: prepared.documentConcurrency,
1472
- shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal
1473
- }, async (invocation, index)=>{
1474
- try {
1475
- await runDocument(invocation, index + prerequisiteCount);
1476
- } catch (error) {
1477
- recordInfrastructureError(error);
1478
- throw error;
1479
- }
1480
- });
1481
- const completed = new Set(cases.map((item)=>item.caseId));
1482
- const reason = prerequisiteFailed ? 'project-setup-failed' : rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1483
- cases.push(...notRunSuite(prepared, reason).filter((item)=>!completed.has(item.caseId)));
1484
- } else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
1485
- } catch (error) {
1486
- hasProjectExecutionError = true;
1487
- recordInfrastructureError(error);
1488
- } finally{
1489
- 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;
1490
- lifecycle = await runtime.finish(hasFailure ? 'failed' : 'success');
1491
- }
1492
- if (hasProjectExecutionError) {
1493
- const completed = new Set(cases.map((item)=>item.caseId));
1494
- cases.push(...notRunSuite(prepared, 'interrupted').filter((item)=>!completed.has(item.caseId)));
1495
- }
1496
- }
1497
- const order = new Map(prepared.documents.map((document, index)=>[
1498
- document.documentId,
1499
- index
1500
- ]));
1501
- const documentOrder = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1502
- cases.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || a.caseIndex - b.caseIndex);
1503
- documents.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || (a.attemptIndex ?? 0) - (b.attemptIndex ?? 0));
1504
- return buildProjectResult(prepared, cases, documents, lifecycle);
1505
- };
1506
1759
  const projectResults = new Array(preparedProjects.length);
1507
1760
  try {
1508
- await runConcurrentJobs(preparedProjects, {
1509
- concurrency: effectiveConcurrency,
1510
- shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
1511
- }, async (prepared, index)=>{
1512
- try {
1513
- projectResults[index] = await runPreparedProject(prepared, index);
1514
- } catch (error) {
1515
- recordInfrastructureError(error);
1516
- throw error;
1517
- }
1761
+ const states = await runPreparedFiles({
1762
+ projects: preparedProjects,
1763
+ maxConcurrentFiles: effectiveConcurrency,
1764
+ maxConcurrentProjects: definition.test.maxConcurrentProjects ?? 1 / 0,
1765
+ setupTimeoutMs: definition.test.setupTimeout,
1766
+ teardownTimeoutMs,
1767
+ signal: rootController.signal,
1768
+ runDir,
1769
+ preflightFailed: 'run' === plan.preflightScope && totalErrors > 0,
1770
+ shouldBail: bailReached,
1771
+ addFailedCases,
1772
+ announceProject,
1773
+ onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
1774
+ onInfrastructureError: recordInfrastructureError
1775
+ });
1776
+ states.forEach((state, index)=>{
1777
+ projectResults[index] = buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle);
1518
1778
  });
1519
1779
  } catch (error) {
1520
1780
  recordInfrastructureError(error);
@@ -1621,6 +1881,6 @@ async function runPreparedTestProject(plan) {
1621
1881
  async function runTestProject(options = {}) {
1622
1882
  return runPreparedTestProject(await prepareTestRun(options));
1623
1883
  }
1624
- export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
1884
+ export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineFileSetup, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
1625
1885
 
1626
1886
  //# sourceMappingURL=index.mjs.map