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