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