@midscene/test 1.16.0 → 1.16.2

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.
Files changed (39) hide show
  1. package/dist/es/{1~77.mjs → 1~731.mjs} +9 -9
  2. package/dist/es/1~731.mjs.map +1 -0
  3. package/dist/es/cli/index.mjs +686 -167
  4. package/dist/es/cli/index.mjs.map +1 -1
  5. package/dist/es/index.mjs +45 -1
  6. package/dist/es/index.mjs.map +1 -1
  7. package/dist/es/test-cli.mjs +1863 -206
  8. package/dist/es/test-cli.mjs.map +1 -1
  9. package/dist/lib/cli/index.js +677 -155
  10. package/dist/lib/cli/index.js.map +1 -1
  11. package/dist/lib/index.js +45 -1
  12. package/dist/lib/index.js.map +1 -1
  13. package/dist/lib/test-cli.js +1854 -203
  14. package/dist/lib/test-cli.js.map +1 -1
  15. package/dist/templates/test/android/.env.example +1 -1
  16. package/dist/templates/test/android/README.md +3 -1
  17. package/dist/templates/test/android/README.zh.md +3 -1
  18. package/dist/templates/test/android/midscene.config.ts +28 -31
  19. package/dist/templates/test/computer/.env.example +1 -1
  20. package/dist/templates/test/computer/README.md +3 -1
  21. package/dist/templates/test/computer/README.zh.md +3 -1
  22. package/dist/templates/test/computer/midscene.config.ts +31 -34
  23. package/dist/templates/test/harmony/.env.example +1 -1
  24. package/dist/templates/test/harmony/README.md +3 -1
  25. package/dist/templates/test/harmony/README.zh.md +3 -1
  26. package/dist/templates/test/harmony/midscene.config.ts +28 -31
  27. package/dist/templates/test/ios/.env.example +1 -1
  28. package/dist/templates/test/ios/README.md +3 -1
  29. package/dist/templates/test/ios/README.zh.md +3 -1
  30. package/dist/templates/test/ios/midscene.config.ts +28 -31
  31. package/dist/templates/test/web/.env.example +1 -1
  32. package/dist/templates/test/web/README.md +17 -1
  33. package/dist/templates/test/web/README.zh.md +17 -1
  34. package/dist/templates/test/web/cases/todo.yaml +2 -0
  35. package/dist/templates/test/web/midscene.config.ts +34 -12
  36. package/dist/types/cli/index.d.ts +107 -11
  37. package/dist/types/index.d.ts +6 -3
  38. package/package.json +14 -13
  39. package/dist/es/1~77.mjs.map +0 -1
@@ -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)
@@ -350,11 +393,13 @@ function parseWorkflowSource(source) {
350
393
  start: state.position + sourceOffset,
351
394
  end: 0,
352
395
  value: null,
353
- children: []
396
+ children: [],
397
+ kind: null
354
398
  });
355
399
  const node = stack.pop();
356
400
  node.end = state.position + sourceOffset;
357
401
  node.value = state.result;
402
+ node.kind = state.kind;
358
403
  stack.at(-1)?.children.push(node);
359
404
  nodes.push(node);
360
405
  if ('mapping' !== state.kind) return;
@@ -384,6 +429,36 @@ function parseWorkflowSource(source) {
384
429
  const value = (0, test_runner_namespaceObject.sanitizeReportText)(redacted, 65536);
385
430
  return {
386
431
  parsed,
432
+ locate (path) {
433
+ let node = nodes.at(-1);
434
+ for (const part of path){
435
+ if (!node) return;
436
+ while(1 === node.children.length && node.children[0].value === node.value)node = node.children[0];
437
+ if ('number' == typeof part && 'sequence' === node.kind) node = node.children[part];
438
+ else {
439
+ if ('string' != typeof part || 'mapping' !== node.kind) return;
440
+ const children = node.children;
441
+ node = void 0;
442
+ for(let index = 0; index < children.length; index += 2)if (children[index].value === part) {
443
+ node = children[index + 1];
444
+ break;
445
+ }
446
+ }
447
+ }
448
+ if (!node) return;
449
+ let start = node.start;
450
+ while(start < node.end)if (/\s/.test(source[start])) start++;
451
+ else if ('#' === source[start]) {
452
+ const end = source.indexOf('\n', start);
453
+ if (end < 0) return;
454
+ start = end + 1;
455
+ } else break;
456
+ const prefix = source.slice(0, start).split(/\r\n|\r|\n/);
457
+ return {
458
+ line: prefix.length,
459
+ column: prefix.at(-1).length + 1
460
+ };
461
+ },
387
462
  sourceYaml: {
388
463
  value,
389
464
  ...redactedPaths.size ? {
@@ -496,6 +571,18 @@ function collectWorkflowDocument(source, options) {
496
571
  const sourcePath = source.sourcePath.split(external_node_path_namespaceObject.sep).join('/');
497
572
  const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
498
573
  ...step,
574
+ meta: {
575
+ ...step.meta,
576
+ sourceLocation: snapshot.locate('steps' === phase ? [
577
+ 'cases',
578
+ caseIndex,
579
+ 'steps',
580
+ stepIndex
581
+ ] : [
582
+ phase,
583
+ stepIndex
584
+ ])
585
+ },
499
586
  templateInput: step.input,
500
587
  input: resolveWorkflowVariables(step.input, {
501
588
  variables: options.variables,
@@ -671,6 +758,9 @@ const writeTestProjectRunResult = (options)=>{
671
758
  markdown: relativeToSummary(result.summaryPath, result.markdownPath)
672
759
  } : {},
673
760
  summary: result.summary,
761
+ ...result.metrics ? {
762
+ metrics: result.metrics
763
+ } : {},
674
764
  projects: result.projects.map((project)=>({
675
765
  projectId: project.projectId,
676
766
  name: project.name,
@@ -953,7 +1043,24 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
953
1043
  defaultTimeoutMs,
954
1044
  reportEnabled: true,
955
1045
  bindings: {
956
- resolveNode: project.nodes.require.bind(project.nodes)
1046
+ resolveNode: project.nodes.require.bind(project.nodes),
1047
+ ...project.fileSetup ? {
1048
+ documentSetup: {
1049
+ name: project.fileSetup.name,
1050
+ setup ({ projectContext, document, signal, onTeardown }) {
1051
+ return project.fileSetup.setup({
1052
+ project,
1053
+ projectContext,
1054
+ file: document,
1055
+ env: Object.freeze({
1056
+ ...process.env
1057
+ }),
1058
+ signal,
1059
+ onTeardown
1060
+ });
1061
+ }
1062
+ }
1063
+ } : {}
957
1064
  }
958
1065
  });
959
1066
  } catch (error) {
@@ -1055,7 +1162,8 @@ async function prepareTestRun(options) {
1055
1162
  preflightScope: hasFormatMismatch ? 'run' : 'project',
1056
1163
  reportEnabled: true,
1057
1164
  publications: [],
1058
- onProgress: options.onProgress ?? (()=>{})
1165
+ onProgress: options.onProgress ?? (()=>{}),
1166
+ onEvent: options.onEvent
1059
1167
  };
1060
1168
  }
1061
1169
  const external_node_events_namespaceObject = require("node:events");
@@ -1136,6 +1244,38 @@ async function writeTestResultMarkdown(result, index = emptyIndex()) {
1136
1244
  }));
1137
1245
  return output;
1138
1246
  }
1247
+ function scopeNodeConsole(node) {
1248
+ return {
1249
+ ...node,
1250
+ execute (ctx) {
1251
+ const position = 'case' === ctx.scope ? ctx.case : ctx.document;
1252
+ const scope = {
1253
+ projectName: position.projectName,
1254
+ sourcePath: position.sourcePath,
1255
+ ...'case' === ctx.scope ? {
1256
+ caseId: ctx.case.caseId,
1257
+ caseName: ctx.case.name,
1258
+ runId: ctx.case.runId
1259
+ } : {
1260
+ runId: ctx.document.documentRunId
1261
+ },
1262
+ phase: position.phase,
1263
+ stepIndex: position.stepIndex,
1264
+ node: node.name
1265
+ };
1266
+ return (0, logger_namespaceObject.runWithConsoleLogScope)(scope, ()=>node.execute({
1267
+ ...ctx,
1268
+ onTeardown (teardown) {
1269
+ ctx.onTeardown(()=>(0, logger_namespaceObject.runWithConsoleLogScope)({
1270
+ ...scope,
1271
+ phase: 'teardown',
1272
+ stepIndex: void 0
1273
+ }, teardown));
1274
+ }
1275
+ }));
1276
+ }
1277
+ };
1278
+ }
1139
1279
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1140
1280
  const formatStep = (info)=>{
1141
1281
  const position = stepPosition(info);
@@ -1159,59 +1299,149 @@ const documentHasFatalError = (result)=>[
1159
1299
  ...result.beforeAll,
1160
1300
  ...result.afterAll
1161
1301
  ].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);
1302
+ const isSharedResourceFailure = (error)=>{
1303
+ const seen = new Set();
1304
+ const pending = [
1305
+ error
1306
+ ];
1307
+ while(pending.length && seen.size < 50){
1308
+ const item = pending.pop();
1309
+ if ('object' != typeof item || null === item || seen.has(item)) continue;
1310
+ seen.add(item);
1311
+ const failure = item;
1312
+ if ('PROJECT_RESOURCE_ERROR' === failure.code) return true;
1313
+ if (failure.cause) pending.push(failure.cause);
1314
+ if (Array.isArray(failure.errors)) pending.push(...failure.errors.slice(0, 50));
1315
+ }
1316
+ return false;
1317
+ };
1162
1318
  async function executeDocumentInvocation(options) {
1163
1319
  const { invocation, project, sinks } = options;
1320
+ const isProjectResourceFailure = options.isProjectResourceFailure ?? (()=>false);
1164
1321
  const { document } = invocation;
1165
1322
  const documentRetry = 'document' === invocation.retry.scope;
1166
1323
  const attempts = [];
1324
+ const fileSetup = invocation.bindings.documentSetup;
1325
+ const documentSetup = fileSetup ? {
1326
+ ...fileSetup,
1327
+ setup (ctx) {
1328
+ const scope = {
1329
+ projectName: project.name,
1330
+ sourcePath: document.sourcePath,
1331
+ runId: ctx.document.documentRunId,
1332
+ phase: 'fileSetup'
1333
+ };
1334
+ return (0, logger_namespaceObject.runWithConsoleLogScope)(scope, async ()=>{
1335
+ try {
1336
+ return await fileSetup.setup({
1337
+ ...ctx,
1338
+ onTeardown (teardown) {
1339
+ ctx.onTeardown(()=>(0, logger_namespaceObject.runWithConsoleLogScope)({
1340
+ ...scope,
1341
+ phase: 'teardown'
1342
+ }, teardown));
1343
+ }
1344
+ });
1345
+ } catch (error) {
1346
+ if (isProjectResourceFailure(error)) options.markProjectFatal();
1347
+ throw error;
1348
+ }
1349
+ });
1350
+ }
1351
+ } : void 0;
1352
+ options.onEvent?.({
1353
+ type: 'document-start',
1354
+ documentId: document.documentId,
1355
+ projectName: project.name,
1356
+ sourcePath: document.sourcePath,
1357
+ cases: document.cases.length
1358
+ });
1167
1359
  await (0, test_runner_namespaceObject.runDocumentAttempts)({
1168
1360
  retry: documentRetry ? invocation.retry.count : 0,
1169
1361
  signal: options.signal,
1170
1362
  shouldStop: ()=>options.shouldBail() || options.isProjectFatal()
1171
1363
  }, async (documentAttemptIndex)=>{
1172
1364
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1173
- const execution = await (0, test_runner_namespaceObject.runWorkflowDocument)(document, {
1174
- plan: invocation.plan,
1175
- documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1176
- ...invocation.bindings,
1177
- project,
1178
- projectContext: options.projectContext,
1179
- retry: documentRetry ? 0 : invocation.retry.count,
1180
- signal: options.signal,
1181
- defaultTimeoutMs: invocation.defaultTimeoutMs,
1182
- shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1183
- stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1184
- isFatalError: (run)=>[
1185
- ...run.beforeEach,
1186
- ...run.steps,
1187
- ...run.afterEach
1188
- ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (run.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError),
1189
- onCaseStart: (collectedCase)=>{
1190
- options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1191
- },
1192
- onStepStart: (info)=>{
1193
- const indent = 'case' === info.scope ? ' ' : ' ';
1194
- options.onProgress(`${indent}→ ${formatStep(info)}`);
1195
- },
1196
- onStepResult: async (info, result)=>{
1197
- await invocation.bindings.onStepResult?.(info, result);
1198
- options.onProgress(formatStepResult(info, result));
1199
- },
1200
- onCaseResult: async (attempt)=>{
1201
- await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1202
- options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1203
- },
1204
- onCaseOutcome: (outcome)=>{
1205
- if (caseHasFatalError(outcome)) options.markProjectFatal();
1206
- if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1207
- },
1208
- onDocumentResult: options.onDocumentResult
1209
- }).catch(async (error)=>{
1365
+ const execution = await (0, logger_namespaceObject.runWithConsoleLogScope)({
1366
+ projectName: project.name,
1367
+ sourcePath: document.sourcePath
1368
+ }, ()=>(0, test_runner_namespaceObject.runWorkflowDocument)(document, {
1369
+ plan: invocation.plan,
1370
+ documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1371
+ ...invocation.bindings,
1372
+ resolveNode: (name)=>scopeNodeConsole(invocation.bindings.resolveNode(name)),
1373
+ documentSetup,
1374
+ setupTimeoutMs: options.setupTimeoutMs,
1375
+ teardownTimeoutMs: options.teardownTimeoutMs,
1376
+ project,
1377
+ projectContext: options.projectContext,
1378
+ retry: documentRetry ? 0 : invocation.retry.count,
1379
+ signal: options.signal,
1380
+ defaultTimeoutMs: invocation.defaultTimeoutMs,
1381
+ shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1382
+ stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1383
+ isFatalError: (run)=>[
1384
+ ...run.beforeEach,
1385
+ ...run.steps,
1386
+ ...run.afterEach
1387
+ ].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)),
1388
+ onCaseStart: (collectedCase)=>{
1389
+ options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1390
+ },
1391
+ onStepStart: (info)=>{
1392
+ options.onEvent?.({
1393
+ type: 'step-start',
1394
+ projectName: project.name,
1395
+ info
1396
+ });
1397
+ const indent = 'case' === info.scope ? ' ' : ' ';
1398
+ options.onProgress(`${indent}→ ${formatStep(info)}`);
1399
+ },
1400
+ onStepResult: async (info, result)=>{
1401
+ if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1402
+ await invocation.bindings.onStepResult?.(info, result);
1403
+ options.onProgress(formatStepResult(info, result));
1404
+ options.onEvent?.({
1405
+ type: 'step-end',
1406
+ projectName: project.name,
1407
+ info,
1408
+ result
1409
+ });
1410
+ },
1411
+ onCaseResult: async (attempt)=>{
1412
+ await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1413
+ options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} case attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1414
+ if (!documentRetry) options.onEvent?.({
1415
+ type: 'case-attempt-end',
1416
+ attempt,
1417
+ maxAttempts: invocation.retry.count + 1
1418
+ });
1419
+ },
1420
+ onCaseOutcome: (outcome)=>{
1421
+ if (documentRetry) options.onEvent?.({
1422
+ type: 'case-progress',
1423
+ outcome,
1424
+ documentAttemptIndex
1425
+ });
1426
+ else options.onEvent?.({
1427
+ type: 'case-end',
1428
+ outcome
1429
+ });
1430
+ if (outcome.attempts?.some((attempt)=>[
1431
+ ...attempt.beforeEach,
1432
+ ...attempt.steps,
1433
+ ...attempt.afterEach
1434
+ ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1435
+ if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1436
+ if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1437
+ },
1438
+ onDocumentResult: options.onDocumentResult
1439
+ })).catch(async (error)=>{
1210
1440
  if (error instanceof test_runner_namespaceObject.WorkflowExecutionFailure) {
1211
1441
  const partial = error.result;
1212
1442
  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
1443
  if (resourceOnly) {
1214
- if (error.errors.some(test_runner_namespaceObject.isResourceRecoveryError)) options.markProjectFatal();
1444
+ if (!options.isolatedContext && error.errors.some(test_runner_namespaceObject.isResourceRecoveryError)) options.markProjectFatal();
1215
1445
  return partial;
1216
1446
  }
1217
1447
  if (documentRetry) partial.document.retryDecision = (0, test_runner_namespaceObject.decideRetry)({
@@ -1240,7 +1470,17 @@ async function executeDocumentInvocation(options) {
1240
1470
  }
1241
1471
  throw error;
1242
1472
  });
1243
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1473
+ if (!options.isolatedContext && documentHasFatalError(execution.document)) options.markProjectFatal();
1474
+ if ([
1475
+ ...execution.document.beforeAll,
1476
+ ...execution.document.afterAll,
1477
+ ...(execution.document.hostErrors ?? []).map(({ error })=>({
1478
+ error
1479
+ })),
1480
+ ...(execution.document.teardownErrors ?? []).map((error)=>({
1481
+ error
1482
+ }))
1483
+ ].some(({ error })=>isProjectResourceFailure(error))) options.markProjectFatal();
1244
1484
  const owners = [
1245
1485
  execution.document,
1246
1486
  ...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
@@ -1248,7 +1488,10 @@ async function executeDocumentInvocation(options) {
1248
1488
  const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
1249
1489
  const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
1250
1490
  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();
1491
+ if (cleanupPending || operationPending) {
1492
+ options.markProjectFatal();
1493
+ options.onUnresolvedResources?.();
1494
+ } else if (cleanupFailed && !options.isolatedContext) options.markProjectFatal();
1252
1495
  const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1253
1496
  const decision = (0, test_runner_namespaceObject.decideRetry)({
1254
1497
  scope: 'document',
@@ -1263,6 +1506,11 @@ async function executeDocumentInvocation(options) {
1263
1506
  shouldStop: options.shouldBail()
1264
1507
  });
1265
1508
  if (documentRetry) execution.document.retryDecision = decision;
1509
+ if (documentRetry && 'retry' === decision.decision) options.onEvent?.({
1510
+ type: 'document-retry',
1511
+ document: execution.document,
1512
+ maxAttempts: invocation.retry.count + 1
1513
+ });
1266
1514
  attempts.push(execution);
1267
1515
  sinks.cases.push(...execution.cases.map((outcome)=>({
1268
1516
  ...outcome,
@@ -1297,10 +1545,312 @@ async function executeDocumentInvocation(options) {
1297
1545
  });
1298
1546
  const final = attempts.at(-1);
1299
1547
  if (!final) return;
1548
+ if (documentRetry) for (const outcome of final.cases)options.onEvent?.({
1549
+ type: 'case-end',
1550
+ outcome,
1551
+ documentRetries: attempts.length - 1
1552
+ });
1300
1553
  const failedCases = final.cases.filter((item)=>'failed' === item.status).length;
1554
+ options.onEvent?.({
1555
+ type: 'document-end',
1556
+ document: final.document,
1557
+ cases: final.cases,
1558
+ durationMs: attempts.reduce((total, attempt)=>total + attempt.document.durationMs, 0)
1559
+ });
1301
1560
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1302
1561
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1303
1562
  }
1563
+ async function runPreparedFiles(options) {
1564
+ const states = options.projects.map((prepared)=>({
1565
+ prepared,
1566
+ cases: [],
1567
+ documents: [],
1568
+ cursor: 0,
1569
+ active: 0,
1570
+ initializing: false,
1571
+ finished: false,
1572
+ fatal: false,
1573
+ setupFailed: false
1574
+ }));
1575
+ let nextProject = 0;
1576
+ let resourcesUnresolved = false;
1577
+ let occupied = 0;
1578
+ let occupiedProjects = 0;
1579
+ const cleanupQueue = [];
1580
+ const release = ()=>{
1581
+ occupied--;
1582
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1583
+ occupied++;
1584
+ cleanupQueue.shift()();
1585
+ }
1586
+ wake?.();
1587
+ };
1588
+ let wake;
1589
+ const running = new Set();
1590
+ const stopped = (state)=>options.signal.aborted || options.shouldBail() || state.fatal;
1591
+ const invalid = (state)=>options.preflightFailed || state.prepared.collectionErrors.length > 0;
1592
+ const unresolved = ()=>{
1593
+ if (resourcesUnresolved) return;
1594
+ resourcesUnresolved = true;
1595
+ options.onInfrastructureError(new test_runner_namespaceObject.WorkflowError('Resources are still running or cleanup is pending. No new test files will start.', {
1596
+ code: 'UNRESOLVED_FILE_RESOURCES'
1597
+ }));
1598
+ };
1599
+ const finalizeProject = (state)=>{
1600
+ const { prepared } = state;
1601
+ const completed = new Set(state.cases.map((item)=>item.caseId));
1602
+ const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1603
+ for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1604
+ documentId: document.documentId,
1605
+ caseId: item.caseId,
1606
+ projectName: prepared.project.name,
1607
+ name: item.definition.name,
1608
+ sourcePath: item.sourcePath,
1609
+ caseIndex: item.caseIndex,
1610
+ status: 'not-run',
1611
+ notRunReason: reason
1612
+ });
1613
+ const order = new Map(prepared.documents.map((document, i)=>[
1614
+ document.documentId,
1615
+ i
1616
+ ]));
1617
+ const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1618
+ state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1619
+ state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1620
+ };
1621
+ const finishProject = async (state)=>{
1622
+ if (!state.runtime || state.finished) return;
1623
+ state.finished = true;
1624
+ try {
1625
+ options.onEvent?.({
1626
+ type: 'project-phase',
1627
+ projectName: state.prepared.project.name,
1628
+ phase: 'teardown'
1629
+ });
1630
+ } catch (error) {
1631
+ options.onInfrastructureError(error);
1632
+ }
1633
+ const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1634
+ state.lifecycle = await (0, logger_namespaceObject.runWithConsoleLogScope)({
1635
+ projectName: state.prepared.project.name,
1636
+ phase: 'teardown'
1637
+ }, ()=>state.runtime.finish(failed ? 'failed' : 'success'));
1638
+ const releaseProject = ()=>{
1639
+ occupiedProjects--;
1640
+ wake?.();
1641
+ };
1642
+ if (state.lifecycle.cleanup?.status === 'pending' || state.lifecycle.pendingOperations?.length) {
1643
+ unresolved();
1644
+ Promise.all([
1645
+ (0, test_runner_namespaceObject.waitForScopeOperations)(state.runtime.signal),
1646
+ (0, test_runner_namespaceObject.getResourceCleanupCompletion)(state.runtime.signal)
1647
+ ]).then(releaseProject, (error)=>{
1648
+ options.onInfrastructureError(error);
1649
+ releaseProject();
1650
+ });
1651
+ } else {
1652
+ releaseProject();
1653
+ finalizeProject(state);
1654
+ options.onProjectEnd?.(state);
1655
+ }
1656
+ };
1657
+ const admit = ()=>{
1658
+ for(let offset = 0; offset < states.length; offset++){
1659
+ const index = (nextProject + offset) % states.length;
1660
+ const state = states[index];
1661
+ if (invalid(state) || state.finished || state.initializing) continue;
1662
+ if (!state.runtime && occupiedProjects >= options.maxConcurrentProjects) continue;
1663
+ const cleanupOnly = !!state.runtime && 0 === state.active && (stopped(state) || state.cursor === state.prepared.invocations.length);
1664
+ if (!cleanupOnly && (stopped(state) || state.cursor >= state.prepared.invocations.length || state.active >= (state.prepared.project.maxConcurrentFiles ?? options.maxConcurrentFiles))) continue;
1665
+ nextProject = (index + 1) % states.length;
1666
+ occupied++;
1667
+ state.active++;
1668
+ if (!state.runtime && !cleanupOnly) {
1669
+ occupiedProjects++;
1670
+ state.initializing = true;
1671
+ }
1672
+ const invocationIndex = cleanupOnly ? -1 : state.cursor++;
1673
+ return {
1674
+ state,
1675
+ index,
1676
+ invocationIndex
1677
+ };
1678
+ }
1679
+ };
1680
+ const run = async ({ state, index, invocationIndex })=>{
1681
+ const { prepared } = state;
1682
+ const { project } = prepared;
1683
+ let fileResourcesPending = false;
1684
+ try {
1685
+ if (invocationIndex < 0) return void await finishProject(state);
1686
+ if (!state.runtime) {
1687
+ state.runtime = (0, test_runner_namespaceObject.createProjectRuntime)({
1688
+ project,
1689
+ setup: project.setup,
1690
+ signal: options.signal,
1691
+ setupTimeoutMs: options.setupTimeoutMs,
1692
+ teardownTimeoutMs: options.teardownTimeoutMs
1693
+ });
1694
+ (0, external_node_events_namespaceObject.setMaxListeners)(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1695
+ options.announceProject(prepared, index);
1696
+ options.onEvent?.({
1697
+ type: 'project-phase',
1698
+ projectName: project.name,
1699
+ phase: 'setup'
1700
+ });
1701
+ await (0, logger_namespaceObject.runWithConsoleLogScope)({
1702
+ projectName: project.name,
1703
+ phase: 'setup'
1704
+ }, ()=>state.runtime.start());
1705
+ options.onEvent?.({
1706
+ type: 'project-phase',
1707
+ projectName: project.name,
1708
+ phase: 'running'
1709
+ });
1710
+ state.initializing = false;
1711
+ if (!state.runtime.canRun) {
1712
+ state.fatal = true;
1713
+ state.setupFailed = true;
1714
+ }
1715
+ wake?.();
1716
+ }
1717
+ if (stopped(state)) return;
1718
+ const invocation = prepared.invocations[invocationIndex];
1719
+ options.onProgress(prepared, ` [document ${invocationIndex + 1}/${prepared.invocations.length}] ${invocation.document.sourcePath}`);
1720
+ await executeDocumentInvocation({
1721
+ invocation,
1722
+ project,
1723
+ projectContext: state.runtime.context,
1724
+ runDir: options.runDir,
1725
+ signal: state.runtime.signal,
1726
+ rootSignal: options.signal,
1727
+ shouldBail: options.shouldBail,
1728
+ isProjectFatal: ()=>state.fatal,
1729
+ markProjectFatal: ()=>{
1730
+ if (state.fatal) return;
1731
+ state.fatal = true;
1732
+ state.runtime?.abort(new test_runner_namespaceObject.WorkflowError(`Project "${project.name}" shared execution environment is unavailable.`, {
1733
+ code: 'PROJECT_RESOURCE_ERROR'
1734
+ }));
1735
+ },
1736
+ addFailedCases: options.addFailedCases,
1737
+ onProgress: (message)=>options.onProgress(prepared, options.maxConcurrentFiles > 1 ? ` [${invocation.document.sourcePath}]${message}` : message),
1738
+ sinks: state,
1739
+ onEvent: options.onEvent,
1740
+ isolatedContext: void 0 !== project.fileSetup,
1741
+ isProjectResourceFailure: isSharedResourceFailure,
1742
+ setupTimeoutMs: options.setupTimeoutMs,
1743
+ teardownTimeoutMs: options.teardownTimeoutMs,
1744
+ onUnresolvedResources: ()=>{
1745
+ fileResourcesPending = true;
1746
+ unresolved();
1747
+ }
1748
+ });
1749
+ } catch (error) {
1750
+ state.fatal = true;
1751
+ fileResourcesPending = !!state.runtime && (0, test_runner_namespaceObject.getPendingScopeOperations)(state.runtime.signal).length > 0;
1752
+ options.onInfrastructureError(error);
1753
+ } finally{
1754
+ state.initializing = false;
1755
+ try {
1756
+ if (1 === state.active && (stopped(state) || state.cursor === prepared.invocations.length)) await finishProject(state);
1757
+ } catch (error) {
1758
+ options.onInfrastructureError(error);
1759
+ }
1760
+ state.active--;
1761
+ if (state.runtime && (fileResourcesPending || state.lifecycle?.cleanup?.status === 'pending')) (0, test_runner_namespaceObject.waitForScopeOperations)(state.runtime.signal).then(release, (error)=>{
1762
+ options.onInfrastructureError(error);
1763
+ release();
1764
+ });
1765
+ else release();
1766
+ wake?.();
1767
+ }
1768
+ };
1769
+ while(true){
1770
+ const changed = new Promise((resolve)=>{
1771
+ wake = resolve;
1772
+ });
1773
+ while(occupied < options.maxConcurrentFiles){
1774
+ const job = admit();
1775
+ if (!job) break;
1776
+ const task = run(job);
1777
+ running.add(task);
1778
+ const settled = ()=>{
1779
+ running.delete(task);
1780
+ wake?.();
1781
+ };
1782
+ task.then(settled, (error)=>{
1783
+ options.onInfrastructureError(error);
1784
+ settled();
1785
+ });
1786
+ }
1787
+ if (!running.size) break;
1788
+ await changed;
1789
+ }
1790
+ wake = void 0;
1791
+ await Promise.all(states.map(async (state)=>{
1792
+ if (!state.runtime || state.finished) return;
1793
+ const admission = new Promise((resolve)=>{
1794
+ cleanupQueue.push(resolve);
1795
+ });
1796
+ (0, test_runner_namespaceObject.trackResourceOperation)({}, admission, state.runtime.signal);
1797
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1798
+ occupied++;
1799
+ cleanupQueue.shift()();
1800
+ }
1801
+ try {
1802
+ await finishProject(state);
1803
+ } catch (error) {
1804
+ options.onInfrastructureError(error);
1805
+ } finally{
1806
+ Promise.all([
1807
+ admission,
1808
+ (0, test_runner_namespaceObject.getResourceCleanupCompletion)(state.runtime.signal)
1809
+ ]).then(release, (error)=>{
1810
+ options.onInfrastructureError(error);
1811
+ release();
1812
+ });
1813
+ }
1814
+ }));
1815
+ for (const state of states){
1816
+ const { prepared } = state;
1817
+ if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1818
+ finalizeProject(state);
1819
+ }
1820
+ return states;
1821
+ }
1822
+ require("node:util");
1823
+ const env_namespaceObject = require("@midscene/shared/env");
1824
+ function configuredModelNames() {
1825
+ const env = env_namespaceObject.globalConfigManager.getAllEnvConfig();
1826
+ const defaultName = env.MIDSCENE_MODEL_NAME;
1827
+ const models = new Map();
1828
+ for (const [intent, name] of [
1829
+ [
1830
+ 'default',
1831
+ defaultName
1832
+ ],
1833
+ [
1834
+ 'planning',
1835
+ env.MIDSCENE_PLANNING_MODEL_NAME || defaultName
1836
+ ],
1837
+ [
1838
+ 'insight',
1839
+ env.MIDSCENE_INSIGHT_MODEL_NAME || defaultName
1840
+ ]
1841
+ ]){
1842
+ if (!name) continue;
1843
+ const intents = models.get(name) ?? [];
1844
+ intents.push(intent);
1845
+ models.set(name, intents);
1846
+ }
1847
+ return [
1848
+ ...models
1849
+ ].map(([name, intents])=>({
1850
+ name,
1851
+ intents
1852
+ }));
1853
+ }
1304
1854
  function createOperationJournal(runDir) {
1305
1855
  const path = (0, external_node_path_namespaceObject.join)(runDir, 'operations.jsonl');
1306
1856
  (0, external_node_fs_namespaceObject.writeFileSync)(path, '', {
@@ -1369,8 +1919,34 @@ async function runPreparedTestProject(plan) {
1369
1919
  const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
1370
1920
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1371
1921
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1922
+ plan.onEvent?.({
1923
+ type: 'run-start',
1924
+ configuredModels: configuredModelNames(),
1925
+ projects: preparedProjects.length,
1926
+ documents: totalDocuments + totalErrors,
1927
+ cases: totalCases,
1928
+ projectRoot,
1929
+ configPath,
1930
+ startedAt: startedAt.toISOString(),
1931
+ maxConcurrentProjects: definition.test.maxConcurrentProjects,
1932
+ maxConcurrentFiles: definition.test.maxConcurrentFiles,
1933
+ files: preparedProjects.flatMap((prepared)=>[
1934
+ ...prepared.documents.map((document)=>({
1935
+ documentId: document.documentId,
1936
+ projectName: prepared.project.name,
1937
+ sourcePath: document.sourcePath,
1938
+ cases: document.cases.length
1939
+ })),
1940
+ ...prepared.collectionErrors.map((error)=>({
1941
+ documentId: error.sourcePath,
1942
+ projectName: prepared.project.name,
1943
+ sourcePath: error.sourcePath,
1944
+ cases: 0
1945
+ }))
1946
+ ])
1947
+ });
1372
1948
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1373
- const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1949
+ const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
1374
1950
  await writeTestRunPlan(runDir, preparedProjects);
1375
1951
  const journal = createOperationJournal(runDir);
1376
1952
  const rootController = new AbortController();
@@ -1384,6 +1960,18 @@ async function runPreparedTestProject(plan) {
1384
1960
  process.on('SIGTERM', sigterm);
1385
1961
  let failedCaseCount = 0;
1386
1962
  const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
1963
+ let shutdownDeadline;
1964
+ const beginShutdown = ()=>{
1965
+ shutdownDeadline ??= Date.now() + definition.test.teardownTimeout;
1966
+ };
1967
+ rootController.signal.addEventListener('abort', beginShutdown, {
1968
+ once: true
1969
+ });
1970
+ const addFailedCases = (count)=>{
1971
+ failedCaseCount += count;
1972
+ if (bailReached()) beginShutdown();
1973
+ };
1974
+ const teardownTimeoutMs = ()=>Math.max(0, Math.min(definition.test.teardownTimeout, void 0 === shutdownDeadline ? 1 / 0 : shutdownDeadline - Date.now()));
1387
1975
  let hasInfrastructureError = false;
1388
1976
  const infrastructureErrors = [];
1389
1977
  const recordInfrastructureError = (error)=>{
@@ -1445,112 +2033,30 @@ async function runPreparedTestProject(plan) {
1445
2033
  announceProject(prepared, projectIndex);
1446
2034
  return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
1447
2035
  };
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
2036
  const projectResults = new Array(preparedProjects.length);
1543
2037
  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
- }
2038
+ const states = await runPreparedFiles({
2039
+ projects: preparedProjects,
2040
+ maxConcurrentFiles: effectiveConcurrency,
2041
+ maxConcurrentProjects: definition.test.maxConcurrentProjects ?? 1 / 0,
2042
+ setupTimeoutMs: definition.test.setupTimeout,
2043
+ teardownTimeoutMs,
2044
+ signal: rootController.signal,
2045
+ runDir,
2046
+ preflightFailed: 'run' === plan.preflightScope && totalErrors > 0,
2047
+ shouldBail: bailReached,
2048
+ addFailedCases,
2049
+ announceProject,
2050
+ onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(preparedProjects.length > 1 || effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
2051
+ onEvent: (event)=>runInfrastructureCallback(()=>plan.onEvent?.(event)),
2052
+ onProjectEnd: (state)=>runInfrastructureCallback(()=>plan.onEvent?.({
2053
+ type: 'project-end',
2054
+ project: buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle)
2055
+ })),
2056
+ onInfrastructureError: recordInfrastructureError
2057
+ });
2058
+ states.forEach((state, index)=>{
2059
+ projectResults[index] = buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle);
1554
2060
  });
1555
2061
  } catch (error) {
1556
2062
  recordInfrastructureError(error);
@@ -1627,6 +2133,10 @@ async function runPreparedTestProject(plan) {
1627
2133
  buildRunnerDump: (index)=>{
1628
2134
  reportIndex = index;
1629
2135
  const dump = (0, test_runner_namespaceObject.buildTestRunReportDump)(completedResult, index);
2136
+ completedResult = {
2137
+ ...completedResult,
2138
+ metrics: dump.metrics
2139
+ };
1630
2140
  const warn = (0, logger_namespaceObject.getDebug)('test-runner:report-assembler', {
1631
2141
  console: true
1632
2142
  });
@@ -1644,6 +2154,16 @@ async function runPreparedTestProject(plan) {
1644
2154
  await publication.publish(completedResult);
1645
2155
  });
1646
2156
  await publish('write-result', (0, external_node_path_namespaceObject.join)((0, external_node_path_namespaceObject.dirname)(summaryPath), 'result.md'), async ()=>{
2157
+ if (!completedResult.metrics) {
2158
+ const dump = (0, test_runner_namespaceObject.buildTestRunReportDump)(completedResult, reportIndex ?? {
2159
+ sources: [],
2160
+ metrics: (0, test_runner_namespaceObject.sumTestRunReportMetrics)([])
2161
+ });
2162
+ completedResult = {
2163
+ ...completedResult,
2164
+ metrics: dump.metrics
2165
+ };
2166
+ }
1647
2167
  const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
1648
2168
  completedResult = {
1649
2169
  ...completedResult,
@@ -1659,6 +2179,7 @@ async function runTestProject(options = {}) {
1659
2179
  }
1660
2180
  exports.DEFAULT_TEST_FILE_SELECTION = __webpack_exports__.DEFAULT_TEST_FILE_SELECTION;
1661
2181
  exports.createTestRunId = __webpack_exports__.createTestRunId;
2182
+ exports.defineFileSetup = __webpack_exports__.defineFileSetup;
1662
2183
  exports.defineProjectSetup = __webpack_exports__.defineProjectSetup;
1663
2184
  exports.defineTestProject = __webpack_exports__.defineTestProject;
1664
2185
  exports.discoverTestConfig = __webpack_exports__.discoverTestConfig;
@@ -1669,6 +2190,7 @@ exports.validateTestFileSelection = __webpack_exports__.validateTestFileSelectio
1669
2190
  for(var __rspack_i in __webpack_exports__)if (-1 === [
1670
2191
  "DEFAULT_TEST_FILE_SELECTION",
1671
2192
  "createTestRunId",
2193
+ "defineFileSetup",
1672
2194
  "defineProjectSetup",
1673
2195
  "defineTestProject",
1674
2196
  "discoverTestConfig",