@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
@@ -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, sumTestRunReportMetrics as test_runner_sumTestRunReportMetrics, 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";
@@ -9,11 +9,14 @@ import { JSON_SCHEMA, load } from "js-yaml";
9
9
  import { mkdir, writeFile } from "node:fs/promises";
10
10
  import { setMaxListeners } from "node:events";
11
11
  import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
12
- import { getDebug } from "@midscene/shared/logger";
12
+ import { getDebug, runWithConsoleLogScope } from "@midscene/shared/logger";
13
+ import "node:util";
14
+ import { globalConfigManager } from "@midscene/shared/env";
13
15
  const registry_NodeRegistry = NodeRegistry;
14
16
  const projectIdFromIndex = (index)=>`project-${index}`;
15
17
  const defineTestProject = (definition)=>definition;
16
18
  const defineProjectSetup = (definition)=>definition;
19
+ const defineFileSetup = (definition)=>definition;
17
20
  const getDefaultExport = (loaded, absolutePath)=>{
18
21
  if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
19
22
  const defaultExport = loaded.default;
@@ -130,9 +133,19 @@ const validateNonNegativeInteger = (value, fallback, label)=>{
130
133
  if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
131
134
  return value;
132
135
  };
133
- const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
136
+ const validateConcurrency = (value, label)=>{
137
+ if (null === value) return null;
138
+ return validatePositiveInteger(value, 1, label);
139
+ };
140
+ const validateFileConcurrency = (maxConcurrentFiles, fileSetup, label)=>{
141
+ if (maxConcurrentFiles > 1 && !fileSetup) throw new TypeError(`Midscene config ${label}.fileSetup is required when the effective maxConcurrentFiles is greater than 1.`);
142
+ };
143
+ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, globalNodes, test)=>{
134
144
  if (void 0 === value) {
135
145
  const setup = validateSetup(defaultSetup, 'setup');
146
+ const fileSetup = validateSetup(defaultFileSetup, 'fileSetup');
147
+ const maxConcurrentFiles = test.maxConcurrentFiles ?? 1 / 0;
148
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, 'default');
136
149
  return {
137
150
  hasExplicitProjects: false,
138
151
  projects: Object.freeze([
@@ -142,6 +155,10 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
142
155
  ...setup ? {
143
156
  setup
144
157
  } : {},
158
+ ...fileSetup ? {
159
+ fileSetup
160
+ } : {},
161
+ maxConcurrentFiles,
145
162
  tags: Object.freeze({
146
163
  include: [],
147
164
  exclude: []
@@ -154,6 +171,7 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
154
171
  };
155
172
  }
156
173
  if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
174
+ if (void 0 !== defaultFileSetup) throw new TypeError('Midscene config fileSetup cannot be used together with projects. Move fileSetup to projects[].fileSetup.');
157
175
  if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
158
176
  const names = new Set();
159
177
  const projects = value.map((candidate, index)=>{
@@ -162,6 +180,8 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
162
180
  rejectUnknownKeys(candidate, [
163
181
  'name',
164
182
  'setup',
183
+ 'fileSetup',
184
+ 'maxConcurrentFiles',
165
185
  'nodes',
166
186
  'files',
167
187
  'tags',
@@ -178,10 +198,18 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
178
198
  ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
179
199
  ...localNodes.definitions()
180
200
  ]);
201
+ const fileSetup = validateSetup(candidate.fileSetup, `${label}.fileSetup`);
202
+ const globalFileLimit = test.maxConcurrentFiles ?? 1 / 0;
203
+ const maxConcurrentFiles = Math.min(globalFileLimit, validatePositiveInteger(candidate.maxConcurrentFiles, globalFileLimit, `${label}.maxConcurrentFiles`));
204
+ validateFileConcurrency(maxConcurrentFiles, fileSetup, label);
181
205
  return Object.freeze({
182
206
  projectId: projectIdFromIndex(index),
183
207
  name: candidate.name,
184
208
  nodes,
209
+ ...fileSetup ? {
210
+ fileSetup
211
+ } : {},
212
+ maxConcurrentFiles,
185
213
  ...files ? {
186
214
  files
187
215
  } : {},
@@ -198,11 +226,25 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
198
226
  hasExplicitProjects: true
199
227
  };
200
228
  };
201
- const validateTestOptions = (value)=>{
229
+ const validateTestOptions = (value, hasExplicitProjects)=>{
202
230
  if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
203
231
  const candidate = value ?? {};
232
+ if (void 0 !== candidate.maxConcurrency) {
233
+ const limit = 'number' == typeof candidate.maxConcurrency && Number.isInteger(candidate.maxConcurrency) && candidate.maxConcurrency > 0 ? candidate.maxConcurrency : 1;
234
+ throw new TypeError([
235
+ 'Midscene config test.maxConcurrency is no longer supported.',
236
+ 'It controlled project concurrency, not file concurrency. Remove it and use:',
237
+ ` test: { maxConcurrentProjects: ${limit}, maxConcurrentFiles: ${hasExplicitProjects ? limit : 1} }`,
238
+ hasExplicitProjects ? ' Set projects[].maxConcurrentFiles to 1 to keep files sequential within each project.' : ' The implicit default project keeps running files sequentially.',
239
+ hasExplicitProjects ? 'For parallel files, configure fileSetup and raise both test.maxConcurrentFiles and projects[].maxConcurrentFiles.' : 'For parallel files, configure fileSetup and raise test.maxConcurrentFiles.',
240
+ 'Migration guide: https://midscenejs.com/midscene-test/configuration.html#existing-configurations'
241
+ ].join('\n'));
242
+ }
204
243
  return Object.freeze({
205
- maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
244
+ maxConcurrentFiles: validateConcurrency(candidate.maxConcurrentFiles, 'test.maxConcurrentFiles'),
245
+ maxConcurrentProjects: validateConcurrency(candidate.maxConcurrentProjects, 'test.maxConcurrentProjects'),
246
+ setupTimeout: validatePositiveInteger(candidate.setupTimeout, 120000, 'test.setupTimeout'),
247
+ teardownTimeout: validatePositiveInteger(candidate.teardownTimeout, 30000, 'test.teardownTimeout'),
206
248
  bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
207
249
  testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
208
250
  });
@@ -232,16 +274,18 @@ const validateTestProjectDefinition = (definition)=>{
232
274
  if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
233
275
  rejectUnknownKeys(definition, [
234
276
  'setup',
277
+ 'fileSetup',
235
278
  'projects',
236
279
  'test',
237
280
  'output',
238
281
  'nodes'
239
282
  ], 'root');
240
283
  const nodes = new registry_NodeRegistry(definition.nodes);
241
- const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
284
+ const test = validateTestOptions(definition.test, void 0 !== definition.projects);
285
+ const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, definition.fileSetup, nodes, test);
242
286
  return {
243
287
  ...resolvedProjects,
244
- test: validateTestOptions(definition.test),
288
+ test,
245
289
  output: validateOutput(definition.output),
246
290
  nodes,
247
291
  resolveNode: (name)=>nodes.get(name)
@@ -263,7 +307,7 @@ async function loadTestProject(configPath) {
263
307
  if (!configPath) return validateTestProjectDefinition({
264
308
  nodes: []
265
309
  });
266
- const absolutePath = resolve(configPath);
310
+ const absolutePath = external_node_path_resolve(configPath);
267
311
  const extension = assertSupportedConfig(absolutePath);
268
312
  let loaded;
269
313
  try {
@@ -318,11 +362,13 @@ function parseWorkflowSource(source) {
318
362
  start: state.position + sourceOffset,
319
363
  end: 0,
320
364
  value: null,
321
- children: []
365
+ children: [],
366
+ kind: null
322
367
  });
323
368
  const node = stack.pop();
324
369
  node.end = state.position + sourceOffset;
325
370
  node.value = state.result;
371
+ node.kind = state.kind;
326
372
  stack.at(-1)?.children.push(node);
327
373
  nodes.push(node);
328
374
  if ('mapping' !== state.kind) return;
@@ -352,6 +398,36 @@ function parseWorkflowSource(source) {
352
398
  const value = sanitizeReportText(redacted, 65536);
353
399
  return {
354
400
  parsed,
401
+ locate (path) {
402
+ let node = nodes.at(-1);
403
+ for (const part of path){
404
+ if (!node) return;
405
+ while(1 === node.children.length && node.children[0].value === node.value)node = node.children[0];
406
+ if ('number' == typeof part && 'sequence' === node.kind) node = node.children[part];
407
+ else {
408
+ if ('string' != typeof part || 'mapping' !== node.kind) return;
409
+ const children = node.children;
410
+ node = void 0;
411
+ for(let index = 0; index < children.length; index += 2)if (children[index].value === part) {
412
+ node = children[index + 1];
413
+ break;
414
+ }
415
+ }
416
+ }
417
+ if (!node) return;
418
+ let start = node.start;
419
+ while(start < node.end)if (/\s/.test(source[start])) start++;
420
+ else if ('#' === source[start]) {
421
+ const end = source.indexOf('\n', start);
422
+ if (end < 0) return;
423
+ start = end + 1;
424
+ } else break;
425
+ const prefix = source.slice(0, start).split(/\r\n|\r|\n/);
426
+ return {
427
+ line: prefix.length,
428
+ column: prefix.at(-1).length + 1
429
+ };
430
+ },
355
431
  sourceYaml: {
356
432
  value,
357
433
  ...redactedPaths.size ? {
@@ -464,6 +540,18 @@ function collectWorkflowDocument(source, options) {
464
540
  const sourcePath = source.sourcePath.split(sep).join('/');
465
541
  const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
466
542
  ...step,
543
+ meta: {
544
+ ...step.meta,
545
+ sourceLocation: snapshot.locate('steps' === phase ? [
546
+ 'cases',
547
+ caseIndex,
548
+ 'steps',
549
+ stepIndex
550
+ ] : [
551
+ phase,
552
+ stepIndex
553
+ ])
554
+ },
467
555
  templateInput: step.input,
468
556
  input: resolveWorkflowVariables(step.input, {
469
557
  variables: options.variables,
@@ -638,6 +726,9 @@ const writeTestProjectRunResult = (options)=>{
638
726
  markdown: relativeToSummary(result.summaryPath, result.markdownPath)
639
727
  } : {},
640
728
  summary: result.summary,
729
+ ...result.metrics ? {
730
+ metrics: result.metrics
731
+ } : {},
641
732
  projects: result.projects.map((project)=>({
642
733
  projectId: project.projectId,
643
734
  name: project.name,
@@ -759,7 +850,7 @@ const DEFAULT_TEST_FILE_SELECTION = {
759
850
  };
760
851
  const project_preparation_toPosix = (value)=>value.split(sep).join('/');
761
852
  const discoverResolvedTestFiles = (projectRoot, selection)=>{
762
- const root = resolve(projectRoot);
853
+ const root = external_node_path_resolve(projectRoot);
763
854
  const match = (patterns)=>globSync(patterns, {
764
855
  absolute: true,
765
856
  caseSensitiveMatch: false,
@@ -775,7 +866,7 @@ const discoverResolvedTestFiles = (projectRoot, selection)=>{
775
866
  }).filter((file)=>/\.ya?ml$/i.test(file));
776
867
  const files = match(selection.include);
777
868
  return [
778
- ...new Set(files.map((file)=>resolve(file)))
869
+ ...new Set(files.map((file)=>external_node_path_resolve(file)))
779
870
  ].sort((a, b)=>{
780
871
  const relativeA = project_preparation_toPosix(relative(root, a));
781
872
  const relativeB = project_preparation_toPosix(relative(root, b));
@@ -788,7 +879,7 @@ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)
788
879
  return discoverResolvedTestFiles(projectRoot, normalized);
789
880
  };
790
881
  const discoverTestConfig = (projectRoot)=>{
791
- const root = resolve(projectRoot);
882
+ const root = external_node_path_resolve(projectRoot);
792
883
  const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
793
884
  const supported = candidates.filter((name)=>CONFIG_NAMES.includes(name));
794
885
  const unsupported = candidates.filter((name)=>!CONFIG_NAMES.includes(name));
@@ -842,7 +933,7 @@ const selectProjects = (projects, names)=>{
842
933
  async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
843
934
  const platform = options.platform?.trim();
844
935
  const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
845
- const prerequisiteFile = options.prerequisiteFile ? resolve(options.prerequisiteFile) : void 0;
936
+ const prerequisiteFile = options.prerequisiteFile ? external_node_path_resolve(options.prerequisiteFile) : void 0;
846
937
  const mainFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
847
938
  const files = [
848
939
  ...prerequisiteFile ? [
@@ -920,7 +1011,24 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
920
1011
  defaultTimeoutMs,
921
1012
  reportEnabled: true,
922
1013
  bindings: {
923
- resolveNode: project.nodes.require.bind(project.nodes)
1014
+ resolveNode: project.nodes.require.bind(project.nodes),
1015
+ ...project.fileSetup ? {
1016
+ documentSetup: {
1017
+ name: project.fileSetup.name,
1018
+ setup ({ projectContext, document, signal, onTeardown }) {
1019
+ return project.fileSetup.setup({
1020
+ project,
1021
+ projectContext,
1022
+ file: document,
1023
+ env: Object.freeze({
1024
+ ...process.env
1025
+ }),
1026
+ signal,
1027
+ onTeardown
1028
+ });
1029
+ }
1030
+ }
1031
+ } : {}
924
1032
  }
925
1033
  });
926
1034
  } catch (error) {
@@ -966,9 +1074,9 @@ const assertDirectory = (path, label)=>{
966
1074
  if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
967
1075
  };
968
1076
  const resolveRunInput = (options)=>{
969
- const cwd = resolve(options.cwd ?? process.cwd());
1077
+ const cwd = external_node_path_resolve(options.cwd ?? process.cwd());
970
1078
  assertDirectory(cwd, 'Test working directory');
971
- const inputPath = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
1079
+ const inputPath = options.projectRoot ? external_node_path_resolve(cwd, options.projectRoot) : void 0;
972
1080
  const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
973
1081
  if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
974
1082
  const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
@@ -987,14 +1095,14 @@ async function prepareTestRun(options) {
987
1095
  const startedAt = new Date();
988
1096
  const runId = createTestRunId(startedAt);
989
1097
  const input = resolveRunInput(options);
990
- const configPath = options.configPath ? resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
1098
+ const configPath = options.configPath ? external_node_path_resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
991
1099
  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
1100
  if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
993
1101
  const definition = await loadTestProject(configPath);
994
- const resultDir = options.resultDir ? resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
1102
+ const resultDir = options.resultDir ? external_node_path_resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
995
1103
  const runDir = join(resultDir, runId);
996
1104
  const summaryPath = join(runDir, 'summary.json');
997
- const reportDir = resolve(input.projectRoot, definition.output.reportDir);
1105
+ const reportDir = external_node_path_resolve(input.projectRoot, definition.output.reportDir);
998
1106
  mkdirSync(resultDir, {
999
1107
  recursive: true
1000
1108
  });
@@ -1022,7 +1130,8 @@ async function prepareTestRun(options) {
1022
1130
  preflightScope: hasFormatMismatch ? 'run' : 'project',
1023
1131
  reportEnabled: true,
1024
1132
  publications: [],
1025
- onProgress: options.onProgress ?? (()=>{})
1133
+ onProgress: options.onProgress ?? (()=>{}),
1134
+ onEvent: options.onEvent
1026
1135
  };
1027
1136
  }
1028
1137
  const result_markdown_text = (value)=>value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll('|', '&#124;').replaceAll('[', '&#91;').replaceAll(']', '&#93;').replaceAll('*', '&#42;').replaceAll('_', '&#95;').replaceAll('`', '&#96;').replaceAll('\\', '&#92;').replace(/[\r\n]+/g, ' ');
@@ -1100,6 +1209,38 @@ async function writeTestResultMarkdown(result, index = emptyIndex()) {
1100
1209
  }));
1101
1210
  return output;
1102
1211
  }
1212
+ function scopeNodeConsole(node) {
1213
+ return {
1214
+ ...node,
1215
+ execute (ctx) {
1216
+ const position = 'case' === ctx.scope ? ctx.case : ctx.document;
1217
+ const scope = {
1218
+ projectName: position.projectName,
1219
+ sourcePath: position.sourcePath,
1220
+ ...'case' === ctx.scope ? {
1221
+ caseId: ctx.case.caseId,
1222
+ caseName: ctx.case.name,
1223
+ runId: ctx.case.runId
1224
+ } : {
1225
+ runId: ctx.document.documentRunId
1226
+ },
1227
+ phase: position.phase,
1228
+ stepIndex: position.stepIndex,
1229
+ node: node.name
1230
+ };
1231
+ return runWithConsoleLogScope(scope, ()=>node.execute({
1232
+ ...ctx,
1233
+ onTeardown (teardown) {
1234
+ ctx.onTeardown(()=>runWithConsoleLogScope({
1235
+ ...scope,
1236
+ phase: 'teardown',
1237
+ stepIndex: void 0
1238
+ }, teardown));
1239
+ }
1240
+ }));
1241
+ }
1242
+ };
1243
+ }
1103
1244
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1104
1245
  const formatStep = (info)=>{
1105
1246
  const position = stepPosition(info);
@@ -1123,59 +1264,149 @@ const documentHasFatalError = (result)=>[
1123
1264
  ...result.beforeAll,
1124
1265
  ...result.afterAll
1125
1266
  ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError) || !!result.executionErrors?.some(isResourceRecoveryError);
1267
+ const isSharedResourceFailure = (error)=>{
1268
+ const seen = new Set();
1269
+ const pending = [
1270
+ error
1271
+ ];
1272
+ while(pending.length && seen.size < 50){
1273
+ const item = pending.pop();
1274
+ if ('object' != typeof item || null === item || seen.has(item)) continue;
1275
+ seen.add(item);
1276
+ const failure = item;
1277
+ if ('PROJECT_RESOURCE_ERROR' === failure.code) return true;
1278
+ if (failure.cause) pending.push(failure.cause);
1279
+ if (Array.isArray(failure.errors)) pending.push(...failure.errors.slice(0, 50));
1280
+ }
1281
+ return false;
1282
+ };
1126
1283
  async function executeDocumentInvocation(options) {
1127
1284
  const { invocation, project, sinks } = options;
1285
+ const isProjectResourceFailure = options.isProjectResourceFailure ?? (()=>false);
1128
1286
  const { document } = invocation;
1129
1287
  const documentRetry = 'document' === invocation.retry.scope;
1130
1288
  const attempts = [];
1289
+ const fileSetup = invocation.bindings.documentSetup;
1290
+ const documentSetup = fileSetup ? {
1291
+ ...fileSetup,
1292
+ setup (ctx) {
1293
+ const scope = {
1294
+ projectName: project.name,
1295
+ sourcePath: document.sourcePath,
1296
+ runId: ctx.document.documentRunId,
1297
+ phase: 'fileSetup'
1298
+ };
1299
+ return runWithConsoleLogScope(scope, async ()=>{
1300
+ try {
1301
+ return await fileSetup.setup({
1302
+ ...ctx,
1303
+ onTeardown (teardown) {
1304
+ ctx.onTeardown(()=>runWithConsoleLogScope({
1305
+ ...scope,
1306
+ phase: 'teardown'
1307
+ }, teardown));
1308
+ }
1309
+ });
1310
+ } catch (error) {
1311
+ if (isProjectResourceFailure(error)) options.markProjectFatal();
1312
+ throw error;
1313
+ }
1314
+ });
1315
+ }
1316
+ } : void 0;
1317
+ options.onEvent?.({
1318
+ type: 'document-start',
1319
+ documentId: document.documentId,
1320
+ projectName: project.name,
1321
+ sourcePath: document.sourcePath,
1322
+ cases: document.cases.length
1323
+ });
1131
1324
  await runDocumentAttempts({
1132
1325
  retry: documentRetry ? invocation.retry.count : 0,
1133
1326
  signal: options.signal,
1134
1327
  shouldStop: ()=>options.shouldBail() || options.isProjectFatal()
1135
1328
  }, async (documentAttemptIndex)=>{
1136
1329
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1137
- const execution = await runWorkflowDocument(document, {
1138
- plan: invocation.plan,
1139
- documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1140
- ...invocation.bindings,
1141
- project,
1142
- projectContext: options.projectContext,
1143
- retry: documentRetry ? 0 : invocation.retry.count,
1144
- signal: options.signal,
1145
- defaultTimeoutMs: invocation.defaultTimeoutMs,
1146
- shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1147
- stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1148
- isFatalError: (run)=>[
1149
- ...run.beforeEach,
1150
- ...run.steps,
1151
- ...run.afterEach
1152
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
1153
- onCaseStart: (collectedCase)=>{
1154
- options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1155
- },
1156
- onStepStart: (info)=>{
1157
- const indent = 'case' === info.scope ? ' ' : ' ';
1158
- options.onProgress(`${indent}→ ${formatStep(info)}`);
1159
- },
1160
- onStepResult: async (info, result)=>{
1161
- await invocation.bindings.onStepResult?.(info, result);
1162
- options.onProgress(formatStepResult(info, result));
1163
- },
1164
- onCaseResult: async (attempt)=>{
1165
- await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1166
- options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1167
- },
1168
- onCaseOutcome: (outcome)=>{
1169
- if (caseHasFatalError(outcome)) options.markProjectFatal();
1170
- if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1171
- },
1172
- onDocumentResult: options.onDocumentResult
1173
- }).catch(async (error)=>{
1330
+ const execution = await runWithConsoleLogScope({
1331
+ projectName: project.name,
1332
+ sourcePath: document.sourcePath
1333
+ }, ()=>runWorkflowDocument(document, {
1334
+ plan: invocation.plan,
1335
+ documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1336
+ ...invocation.bindings,
1337
+ resolveNode: (name)=>scopeNodeConsole(invocation.bindings.resolveNode(name)),
1338
+ documentSetup,
1339
+ setupTimeoutMs: options.setupTimeoutMs,
1340
+ teardownTimeoutMs: options.teardownTimeoutMs,
1341
+ project,
1342
+ projectContext: options.projectContext,
1343
+ retry: documentRetry ? 0 : invocation.retry.count,
1344
+ signal: options.signal,
1345
+ defaultTimeoutMs: invocation.defaultTimeoutMs,
1346
+ shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1347
+ stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1348
+ isFatalError: (run)=>[
1349
+ ...run.beforeEach,
1350
+ ...run.steps,
1351
+ ...run.afterEach
1352
+ ].some((step)=>step.error && (isFatalDeviceError(step.error) || isProjectResourceFailure(step.error))) || (run.teardownErrors ?? []).some((error)=>isFatalDeviceError(error) || isProjectResourceFailure(error)),
1353
+ onCaseStart: (collectedCase)=>{
1354
+ options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1355
+ },
1356
+ onStepStart: (info)=>{
1357
+ options.onEvent?.({
1358
+ type: 'step-start',
1359
+ projectName: project.name,
1360
+ info
1361
+ });
1362
+ const indent = 'case' === info.scope ? ' ' : ' ';
1363
+ options.onProgress(`${indent}→ ${formatStep(info)}`);
1364
+ },
1365
+ onStepResult: async (info, result)=>{
1366
+ if (isProjectResourceFailure(result.error)) options.markProjectFatal();
1367
+ await invocation.bindings.onStepResult?.(info, result);
1368
+ options.onProgress(formatStepResult(info, result));
1369
+ options.onEvent?.({
1370
+ type: 'step-end',
1371
+ projectName: project.name,
1372
+ info,
1373
+ result
1374
+ });
1375
+ },
1376
+ onCaseResult: async (attempt)=>{
1377
+ await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1378
+ options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} case attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1379
+ if (!documentRetry) options.onEvent?.({
1380
+ type: 'case-attempt-end',
1381
+ attempt,
1382
+ maxAttempts: invocation.retry.count + 1
1383
+ });
1384
+ },
1385
+ onCaseOutcome: (outcome)=>{
1386
+ if (documentRetry) options.onEvent?.({
1387
+ type: 'case-progress',
1388
+ outcome,
1389
+ documentAttemptIndex
1390
+ });
1391
+ else options.onEvent?.({
1392
+ type: 'case-end',
1393
+ outcome
1394
+ });
1395
+ if (outcome.attempts?.some((attempt)=>[
1396
+ ...attempt.beforeEach,
1397
+ ...attempt.steps,
1398
+ ...attempt.afterEach
1399
+ ].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
1400
+ if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
1401
+ if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1402
+ },
1403
+ onDocumentResult: options.onDocumentResult
1404
+ })).catch(async (error)=>{
1174
1405
  if (error instanceof WorkflowExecutionFailure) {
1175
1406
  const partial = error.result;
1176
1407
  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
1408
  if (resourceOnly) {
1178
- if (error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
1409
+ if (!options.isolatedContext && error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
1179
1410
  return partial;
1180
1411
  }
1181
1412
  if (documentRetry) partial.document.retryDecision = decideRetry({
@@ -1204,7 +1435,17 @@ async function executeDocumentInvocation(options) {
1204
1435
  }
1205
1436
  throw error;
1206
1437
  });
1207
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1438
+ if (!options.isolatedContext && documentHasFatalError(execution.document)) options.markProjectFatal();
1439
+ if ([
1440
+ ...execution.document.beforeAll,
1441
+ ...execution.document.afterAll,
1442
+ ...(execution.document.hostErrors ?? []).map(({ error })=>({
1443
+ error
1444
+ })),
1445
+ ...(execution.document.teardownErrors ?? []).map((error)=>({
1446
+ error
1447
+ }))
1448
+ ].some(({ error })=>isProjectResourceFailure(error))) options.markProjectFatal();
1208
1449
  const owners = [
1209
1450
  execution.document,
1210
1451
  ...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
@@ -1212,7 +1453,10 @@ async function executeDocumentInvocation(options) {
1212
1453
  const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
1213
1454
  const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
1214
1455
  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();
1456
+ if (cleanupPending || operationPending) {
1457
+ options.markProjectFatal();
1458
+ options.onUnresolvedResources?.();
1459
+ } else if (cleanupFailed && !options.isolatedContext) options.markProjectFatal();
1216
1460
  const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1217
1461
  const decision = decideRetry({
1218
1462
  scope: 'document',
@@ -1227,6 +1471,11 @@ async function executeDocumentInvocation(options) {
1227
1471
  shouldStop: options.shouldBail()
1228
1472
  });
1229
1473
  if (documentRetry) execution.document.retryDecision = decision;
1474
+ if (documentRetry && 'retry' === decision.decision) options.onEvent?.({
1475
+ type: 'document-retry',
1476
+ document: execution.document,
1477
+ maxAttempts: invocation.retry.count + 1
1478
+ });
1230
1479
  attempts.push(execution);
1231
1480
  sinks.cases.push(...execution.cases.map((outcome)=>({
1232
1481
  ...outcome,
@@ -1261,10 +1510,310 @@ async function executeDocumentInvocation(options) {
1261
1510
  });
1262
1511
  const final = attempts.at(-1);
1263
1512
  if (!final) return;
1513
+ if (documentRetry) for (const outcome of final.cases)options.onEvent?.({
1514
+ type: 'case-end',
1515
+ outcome,
1516
+ documentRetries: attempts.length - 1
1517
+ });
1264
1518
  const failedCases = final.cases.filter((item)=>'failed' === item.status).length;
1519
+ options.onEvent?.({
1520
+ type: 'document-end',
1521
+ document: final.document,
1522
+ cases: final.cases,
1523
+ durationMs: attempts.reduce((total, attempt)=>total + attempt.document.durationMs, 0)
1524
+ });
1265
1525
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1266
1526
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1267
1527
  }
1528
+ async function runPreparedFiles(options) {
1529
+ const states = options.projects.map((prepared)=>({
1530
+ prepared,
1531
+ cases: [],
1532
+ documents: [],
1533
+ cursor: 0,
1534
+ active: 0,
1535
+ initializing: false,
1536
+ finished: false,
1537
+ fatal: false,
1538
+ setupFailed: false
1539
+ }));
1540
+ let nextProject = 0;
1541
+ let resourcesUnresolved = false;
1542
+ let occupied = 0;
1543
+ let occupiedProjects = 0;
1544
+ const cleanupQueue = [];
1545
+ const release = ()=>{
1546
+ occupied--;
1547
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1548
+ occupied++;
1549
+ cleanupQueue.shift()();
1550
+ }
1551
+ wake?.();
1552
+ };
1553
+ let wake;
1554
+ const running = new Set();
1555
+ const stopped = (state)=>options.signal.aborted || options.shouldBail() || state.fatal;
1556
+ const invalid = (state)=>options.preflightFailed || state.prepared.collectionErrors.length > 0;
1557
+ const unresolved = ()=>{
1558
+ if (resourcesUnresolved) return;
1559
+ resourcesUnresolved = true;
1560
+ options.onInfrastructureError(new WorkflowError('Resources are still running or cleanup is pending. No new test files will start.', {
1561
+ code: 'UNRESOLVED_FILE_RESOURCES'
1562
+ }));
1563
+ };
1564
+ const finalizeProject = (state)=>{
1565
+ const { prepared } = state;
1566
+ const completed = new Set(state.cases.map((item)=>item.caseId));
1567
+ const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
1568
+ for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
1569
+ documentId: document.documentId,
1570
+ caseId: item.caseId,
1571
+ projectName: prepared.project.name,
1572
+ name: item.definition.name,
1573
+ sourcePath: item.sourcePath,
1574
+ caseIndex: item.caseIndex,
1575
+ status: 'not-run',
1576
+ notRunReason: reason
1577
+ });
1578
+ const order = new Map(prepared.documents.map((document, i)=>[
1579
+ document.documentId,
1580
+ i
1581
+ ]));
1582
+ const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1583
+ state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
1584
+ state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
1585
+ };
1586
+ const finishProject = async (state)=>{
1587
+ if (!state.runtime || state.finished) return;
1588
+ state.finished = true;
1589
+ try {
1590
+ options.onEvent?.({
1591
+ type: 'project-phase',
1592
+ projectName: state.prepared.project.name,
1593
+ phase: 'teardown'
1594
+ });
1595
+ } catch (error) {
1596
+ options.onInfrastructureError(error);
1597
+ }
1598
+ const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
1599
+ state.lifecycle = await runWithConsoleLogScope({
1600
+ projectName: state.prepared.project.name,
1601
+ phase: 'teardown'
1602
+ }, ()=>state.runtime.finish(failed ? 'failed' : 'success'));
1603
+ const releaseProject = ()=>{
1604
+ occupiedProjects--;
1605
+ wake?.();
1606
+ };
1607
+ if (state.lifecycle.cleanup?.status === 'pending' || state.lifecycle.pendingOperations?.length) {
1608
+ unresolved();
1609
+ Promise.all([
1610
+ waitForScopeOperations(state.runtime.signal),
1611
+ getResourceCleanupCompletion(state.runtime.signal)
1612
+ ]).then(releaseProject, (error)=>{
1613
+ options.onInfrastructureError(error);
1614
+ releaseProject();
1615
+ });
1616
+ } else {
1617
+ releaseProject();
1618
+ finalizeProject(state);
1619
+ options.onProjectEnd?.(state);
1620
+ }
1621
+ };
1622
+ const admit = ()=>{
1623
+ for(let offset = 0; offset < states.length; offset++){
1624
+ const index = (nextProject + offset) % states.length;
1625
+ const state = states[index];
1626
+ if (invalid(state) || state.finished || state.initializing) continue;
1627
+ if (!state.runtime && occupiedProjects >= options.maxConcurrentProjects) continue;
1628
+ const cleanupOnly = !!state.runtime && 0 === state.active && (stopped(state) || state.cursor === state.prepared.invocations.length);
1629
+ if (!cleanupOnly && (stopped(state) || state.cursor >= state.prepared.invocations.length || state.active >= (state.prepared.project.maxConcurrentFiles ?? options.maxConcurrentFiles))) continue;
1630
+ nextProject = (index + 1) % states.length;
1631
+ occupied++;
1632
+ state.active++;
1633
+ if (!state.runtime && !cleanupOnly) {
1634
+ occupiedProjects++;
1635
+ state.initializing = true;
1636
+ }
1637
+ const invocationIndex = cleanupOnly ? -1 : state.cursor++;
1638
+ return {
1639
+ state,
1640
+ index,
1641
+ invocationIndex
1642
+ };
1643
+ }
1644
+ };
1645
+ const run = async ({ state, index, invocationIndex })=>{
1646
+ const { prepared } = state;
1647
+ const { project } = prepared;
1648
+ let fileResourcesPending = false;
1649
+ try {
1650
+ if (invocationIndex < 0) return void await finishProject(state);
1651
+ if (!state.runtime) {
1652
+ state.runtime = createProjectRuntime({
1653
+ project,
1654
+ setup: project.setup,
1655
+ signal: options.signal,
1656
+ setupTimeoutMs: options.setupTimeoutMs,
1657
+ teardownTimeoutMs: options.teardownTimeoutMs
1658
+ });
1659
+ setMaxListeners(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
1660
+ options.announceProject(prepared, index);
1661
+ options.onEvent?.({
1662
+ type: 'project-phase',
1663
+ projectName: project.name,
1664
+ phase: 'setup'
1665
+ });
1666
+ await runWithConsoleLogScope({
1667
+ projectName: project.name,
1668
+ phase: 'setup'
1669
+ }, ()=>state.runtime.start());
1670
+ options.onEvent?.({
1671
+ type: 'project-phase',
1672
+ projectName: project.name,
1673
+ phase: 'running'
1674
+ });
1675
+ state.initializing = false;
1676
+ if (!state.runtime.canRun) {
1677
+ state.fatal = true;
1678
+ state.setupFailed = true;
1679
+ }
1680
+ wake?.();
1681
+ }
1682
+ if (stopped(state)) return;
1683
+ const invocation = prepared.invocations[invocationIndex];
1684
+ options.onProgress(prepared, ` [document ${invocationIndex + 1}/${prepared.invocations.length}] ${invocation.document.sourcePath}`);
1685
+ await executeDocumentInvocation({
1686
+ invocation,
1687
+ project,
1688
+ projectContext: state.runtime.context,
1689
+ runDir: options.runDir,
1690
+ signal: state.runtime.signal,
1691
+ rootSignal: options.signal,
1692
+ shouldBail: options.shouldBail,
1693
+ isProjectFatal: ()=>state.fatal,
1694
+ markProjectFatal: ()=>{
1695
+ if (state.fatal) return;
1696
+ state.fatal = true;
1697
+ state.runtime?.abort(new WorkflowError(`Project "${project.name}" shared execution environment is unavailable.`, {
1698
+ code: 'PROJECT_RESOURCE_ERROR'
1699
+ }));
1700
+ },
1701
+ addFailedCases: options.addFailedCases,
1702
+ onProgress: (message)=>options.onProgress(prepared, options.maxConcurrentFiles > 1 ? ` [${invocation.document.sourcePath}]${message}` : message),
1703
+ sinks: state,
1704
+ onEvent: options.onEvent,
1705
+ isolatedContext: void 0 !== project.fileSetup,
1706
+ isProjectResourceFailure: isSharedResourceFailure,
1707
+ setupTimeoutMs: options.setupTimeoutMs,
1708
+ teardownTimeoutMs: options.teardownTimeoutMs,
1709
+ onUnresolvedResources: ()=>{
1710
+ fileResourcesPending = true;
1711
+ unresolved();
1712
+ }
1713
+ });
1714
+ } catch (error) {
1715
+ state.fatal = true;
1716
+ fileResourcesPending = !!state.runtime && getPendingScopeOperations(state.runtime.signal).length > 0;
1717
+ options.onInfrastructureError(error);
1718
+ } finally{
1719
+ state.initializing = false;
1720
+ try {
1721
+ if (1 === state.active && (stopped(state) || state.cursor === prepared.invocations.length)) await finishProject(state);
1722
+ } catch (error) {
1723
+ options.onInfrastructureError(error);
1724
+ }
1725
+ state.active--;
1726
+ if (state.runtime && (fileResourcesPending || state.lifecycle?.cleanup?.status === 'pending')) waitForScopeOperations(state.runtime.signal).then(release, (error)=>{
1727
+ options.onInfrastructureError(error);
1728
+ release();
1729
+ });
1730
+ else release();
1731
+ wake?.();
1732
+ }
1733
+ };
1734
+ while(true){
1735
+ const changed = new Promise((resolve)=>{
1736
+ wake = resolve;
1737
+ });
1738
+ while(occupied < options.maxConcurrentFiles){
1739
+ const job = admit();
1740
+ if (!job) break;
1741
+ const task = run(job);
1742
+ running.add(task);
1743
+ const settled = ()=>{
1744
+ running.delete(task);
1745
+ wake?.();
1746
+ };
1747
+ task.then(settled, (error)=>{
1748
+ options.onInfrastructureError(error);
1749
+ settled();
1750
+ });
1751
+ }
1752
+ if (!running.size) break;
1753
+ await changed;
1754
+ }
1755
+ wake = void 0;
1756
+ await Promise.all(states.map(async (state)=>{
1757
+ if (!state.runtime || state.finished) return;
1758
+ const admission = new Promise((resolve)=>{
1759
+ cleanupQueue.push(resolve);
1760
+ });
1761
+ trackResourceOperation({}, admission, state.runtime.signal);
1762
+ while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
1763
+ occupied++;
1764
+ cleanupQueue.shift()();
1765
+ }
1766
+ try {
1767
+ await finishProject(state);
1768
+ } catch (error) {
1769
+ options.onInfrastructureError(error);
1770
+ } finally{
1771
+ Promise.all([
1772
+ admission,
1773
+ getResourceCleanupCompletion(state.runtime.signal)
1774
+ ]).then(release, (error)=>{
1775
+ options.onInfrastructureError(error);
1776
+ release();
1777
+ });
1778
+ }
1779
+ }));
1780
+ for (const state of states){
1781
+ const { prepared } = state;
1782
+ if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
1783
+ finalizeProject(state);
1784
+ }
1785
+ return states;
1786
+ }
1787
+ function configuredModelNames() {
1788
+ const env = globalConfigManager.getAllEnvConfig();
1789
+ const defaultName = env.MIDSCENE_MODEL_NAME;
1790
+ const models = new Map();
1791
+ for (const [intent, name] of [
1792
+ [
1793
+ 'default',
1794
+ defaultName
1795
+ ],
1796
+ [
1797
+ 'planning',
1798
+ env.MIDSCENE_PLANNING_MODEL_NAME || defaultName
1799
+ ],
1800
+ [
1801
+ 'insight',
1802
+ env.MIDSCENE_INSIGHT_MODEL_NAME || defaultName
1803
+ ]
1804
+ ]){
1805
+ if (!name) continue;
1806
+ const intents = models.get(name) ?? [];
1807
+ intents.push(intent);
1808
+ models.set(name, intents);
1809
+ }
1810
+ return [
1811
+ ...models
1812
+ ].map(([name, intents])=>({
1813
+ name,
1814
+ intents
1815
+ }));
1816
+ }
1268
1817
  function createOperationJournal(runDir) {
1269
1818
  const path = join(runDir, 'operations.jsonl');
1270
1819
  writeFileSync(path, '', {
@@ -1333,8 +1882,34 @@ async function runPreparedTestProject(plan) {
1333
1882
  const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
1334
1883
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1335
1884
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1885
+ plan.onEvent?.({
1886
+ type: 'run-start',
1887
+ configuredModels: configuredModelNames(),
1888
+ projects: preparedProjects.length,
1889
+ documents: totalDocuments + totalErrors,
1890
+ cases: totalCases,
1891
+ projectRoot,
1892
+ configPath,
1893
+ startedAt: startedAt.toISOString(),
1894
+ maxConcurrentProjects: definition.test.maxConcurrentProjects,
1895
+ maxConcurrentFiles: definition.test.maxConcurrentFiles,
1896
+ files: preparedProjects.flatMap((prepared)=>[
1897
+ ...prepared.documents.map((document)=>({
1898
+ documentId: document.documentId,
1899
+ projectName: prepared.project.name,
1900
+ sourcePath: document.sourcePath,
1901
+ cases: document.cases.length
1902
+ })),
1903
+ ...prepared.collectionErrors.map((error)=>({
1904
+ documentId: error.sourcePath,
1905
+ projectName: prepared.project.name,
1906
+ sourcePath: error.sourcePath,
1907
+ cases: 0
1908
+ }))
1909
+ ])
1910
+ });
1336
1911
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1337
- const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1912
+ const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
1338
1913
  await writeTestRunPlan(runDir, preparedProjects);
1339
1914
  const journal = createOperationJournal(runDir);
1340
1915
  const rootController = new AbortController();
@@ -1348,6 +1923,18 @@ async function runPreparedTestProject(plan) {
1348
1923
  process.on('SIGTERM', sigterm);
1349
1924
  let failedCaseCount = 0;
1350
1925
  const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
1926
+ let shutdownDeadline;
1927
+ const beginShutdown = ()=>{
1928
+ shutdownDeadline ??= Date.now() + definition.test.teardownTimeout;
1929
+ };
1930
+ rootController.signal.addEventListener('abort', beginShutdown, {
1931
+ once: true
1932
+ });
1933
+ const addFailedCases = (count)=>{
1934
+ failedCaseCount += count;
1935
+ if (bailReached()) beginShutdown();
1936
+ };
1937
+ const teardownTimeoutMs = ()=>Math.max(0, Math.min(definition.test.teardownTimeout, void 0 === shutdownDeadline ? 1 / 0 : shutdownDeadline - Date.now()));
1351
1938
  let hasInfrastructureError = false;
1352
1939
  const infrastructureErrors = [];
1353
1940
  const recordInfrastructureError = (error)=>{
@@ -1409,112 +1996,30 @@ async function runPreparedTestProject(plan) {
1409
1996
  announceProject(prepared, projectIndex);
1410
1997
  return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
1411
1998
  };
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
1999
  const projectResults = new Array(preparedProjects.length);
1507
2000
  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
- }
2001
+ const states = await runPreparedFiles({
2002
+ projects: preparedProjects,
2003
+ maxConcurrentFiles: effectiveConcurrency,
2004
+ maxConcurrentProjects: definition.test.maxConcurrentProjects ?? 1 / 0,
2005
+ setupTimeoutMs: definition.test.setupTimeout,
2006
+ teardownTimeoutMs,
2007
+ signal: rootController.signal,
2008
+ runDir,
2009
+ preflightFailed: 'run' === plan.preflightScope && totalErrors > 0,
2010
+ shouldBail: bailReached,
2011
+ addFailedCases,
2012
+ announceProject,
2013
+ onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(preparedProjects.length > 1 || effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
2014
+ onEvent: (event)=>runInfrastructureCallback(()=>plan.onEvent?.(event)),
2015
+ onProjectEnd: (state)=>runInfrastructureCallback(()=>plan.onEvent?.({
2016
+ type: 'project-end',
2017
+ project: buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle)
2018
+ })),
2019
+ onInfrastructureError: recordInfrastructureError
2020
+ });
2021
+ states.forEach((state, index)=>{
2022
+ projectResults[index] = buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle);
1518
2023
  });
1519
2024
  } catch (error) {
1520
2025
  recordInfrastructureError(error);
@@ -1591,6 +2096,10 @@ async function runPreparedTestProject(plan) {
1591
2096
  buildRunnerDump: (index)=>{
1592
2097
  reportIndex = index;
1593
2098
  const dump = buildTestRunReportDump(completedResult, index);
2099
+ completedResult = {
2100
+ ...completedResult,
2101
+ metrics: dump.metrics
2102
+ };
1594
2103
  const warn = getDebug('test-runner:report-assembler', {
1595
2104
  console: true
1596
2105
  });
@@ -1608,6 +2117,16 @@ async function runPreparedTestProject(plan) {
1608
2117
  await publication.publish(completedResult);
1609
2118
  });
1610
2119
  await publish('write-result', join(dirname(summaryPath), 'result.md'), async ()=>{
2120
+ if (!completedResult.metrics) {
2121
+ const dump = buildTestRunReportDump(completedResult, reportIndex ?? {
2122
+ sources: [],
2123
+ metrics: test_runner_sumTestRunReportMetrics([])
2124
+ });
2125
+ completedResult = {
2126
+ ...completedResult,
2127
+ metrics: dump.metrics
2128
+ };
2129
+ }
1611
2130
  const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
1612
2131
  completedResult = {
1613
2132
  ...completedResult,
@@ -1621,6 +2140,6 @@ async function runPreparedTestProject(plan) {
1621
2140
  async function runTestProject(options = {}) {
1622
2141
  return runPreparedTestProject(await prepareTestRun(options));
1623
2142
  }
1624
- export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
2143
+ export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineFileSetup, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
1625
2144
 
1626
2145
  //# sourceMappingURL=index.mjs.map