@midscene/test 1.15.5-beta-20261015030228.0 → 1.15.5
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 +27 -365
- package/dist/es/cli/index.mjs.map +1 -1
- package/dist/es/midscene/index.mjs +2 -2
- package/dist/es/midscene/index.mjs.map +1 -1
- package/dist/es/test-cli.mjs +18 -353
- package/dist/es/test-cli.mjs.map +1 -1
- package/dist/lib/cli/index.js +16 -357
- package/dist/lib/cli/index.js.map +1 -1
- package/dist/lib/midscene/index.js +3 -0
- package/dist/lib/midscene/index.js.map +1 -1
- package/dist/lib/test-cli.js +17 -352
- package/dist/lib/test-cli.js.map +1 -1
- package/dist/templates/test/android/README.md +0 -2
- package/dist/templates/test/android/README.zh.md +0 -2
- package/dist/templates/test/android/midscene.config.ts +31 -28
- package/dist/templates/test/computer/README.md +0 -2
- package/dist/templates/test/computer/README.zh.md +0 -2
- package/dist/templates/test/computer/midscene.config.ts +34 -31
- package/dist/templates/test/harmony/README.md +0 -2
- package/dist/templates/test/harmony/README.zh.md +0 -2
- package/dist/templates/test/harmony/midscene.config.ts +31 -28
- package/dist/templates/test/ios/README.md +0 -2
- package/dist/templates/test/ios/README.zh.md +0 -2
- package/dist/templates/test/ios/midscene.config.ts +31 -28
- package/dist/templates/test/web/README.md +0 -2
- package/dist/templates/test/web/README.zh.md +0 -2
- package/dist/templates/test/web/cases/todo.yaml +6 -3
- package/dist/templates/test/web/midscene.config.ts +12 -31
- package/dist/types/cli/index.d.ts +9 -30
- package/dist/types/index.d.ts +3 -3
- package/dist/types/midscene/index.d.ts +6 -2
- package/package.json +13 -13
package/dist/es/cli/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { dirname, isAbsolute, join, relative, resolve
|
|
1
|
+
import { dirname, isAbsolute, join, relative, 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,
|
|
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";
|
|
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,7 +14,6 @@ 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;
|
|
18
17
|
const getDefaultExport = (loaded, absolutePath)=>{
|
|
19
18
|
if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
|
|
20
19
|
const defaultExport = loaded.default;
|
|
@@ -131,14 +130,9 @@ const validateNonNegativeInteger = (value, fallback, label)=>{
|
|
|
131
130
|
if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
|
|
132
131
|
return value;
|
|
133
132
|
};
|
|
134
|
-
const
|
|
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)=>{
|
|
133
|
+
const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
138
134
|
if (void 0 === value) {
|
|
139
135
|
const setup = validateSetup(defaultSetup, 'setup');
|
|
140
|
-
const fileSetup = validateSetup(defaultFileSetup, 'fileSetup');
|
|
141
|
-
validateFileConcurrency(test.maxConcurrentFiles, fileSetup, 'default');
|
|
142
136
|
return {
|
|
143
137
|
hasExplicitProjects: false,
|
|
144
138
|
projects: Object.freeze([
|
|
@@ -148,12 +142,6 @@ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, global
|
|
|
148
142
|
...setup ? {
|
|
149
143
|
setup
|
|
150
144
|
} : {},
|
|
151
|
-
...fileSetup ? {
|
|
152
|
-
fileSetup
|
|
153
|
-
} : {},
|
|
154
|
-
...void 0 === test.maxConcurrentFiles ? {} : {
|
|
155
|
-
maxConcurrentFiles: test.maxConcurrentFiles
|
|
156
|
-
},
|
|
157
145
|
tags: Object.freeze({
|
|
158
146
|
include: [],
|
|
159
147
|
exclude: []
|
|
@@ -166,7 +154,6 @@ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, global
|
|
|
166
154
|
};
|
|
167
155
|
}
|
|
168
156
|
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.');
|
|
170
157
|
if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
|
|
171
158
|
const names = new Set();
|
|
172
159
|
const projects = value.map((candidate, index)=>{
|
|
@@ -175,8 +162,6 @@ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, global
|
|
|
175
162
|
rejectUnknownKeys(candidate, [
|
|
176
163
|
'name',
|
|
177
164
|
'setup',
|
|
178
|
-
'fileSetup',
|
|
179
|
-
'maxConcurrentFiles',
|
|
180
165
|
'nodes',
|
|
181
166
|
'files',
|
|
182
167
|
'tags',
|
|
@@ -193,20 +178,10 @@ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, global
|
|
|
193
178
|
...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
|
|
194
179
|
...localNodes.definitions()
|
|
195
180
|
]);
|
|
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);
|
|
200
181
|
return Object.freeze({
|
|
201
182
|
projectId: projectIdFromIndex(index),
|
|
202
183
|
name: candidate.name,
|
|
203
184
|
nodes,
|
|
204
|
-
...fileSetup ? {
|
|
205
|
-
fileSetup
|
|
206
|
-
} : {},
|
|
207
|
-
...void 0 === maxConcurrentFiles ? {} : {
|
|
208
|
-
maxConcurrentFiles
|
|
209
|
-
},
|
|
210
185
|
...files ? {
|
|
211
186
|
files
|
|
212
187
|
} : {},
|
|
@@ -226,15 +201,7 @@ const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, global
|
|
|
226
201
|
const validateTestOptions = (value)=>{
|
|
227
202
|
if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
|
|
228
203
|
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.');
|
|
232
204
|
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
|
-
} : {},
|
|
238
205
|
maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
|
|
239
206
|
bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
|
|
240
207
|
testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
|
|
@@ -265,19 +232,16 @@ const validateTestProjectDefinition = (definition)=>{
|
|
|
265
232
|
if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
|
|
266
233
|
rejectUnknownKeys(definition, [
|
|
267
234
|
'setup',
|
|
268
|
-
'fileSetup',
|
|
269
235
|
'projects',
|
|
270
236
|
'test',
|
|
271
237
|
'output',
|
|
272
238
|
'nodes'
|
|
273
239
|
], 'root');
|
|
274
240
|
const nodes = new registry_NodeRegistry(definition.nodes);
|
|
275
|
-
const
|
|
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);
|
|
241
|
+
const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
|
|
278
242
|
return {
|
|
279
243
|
...resolvedProjects,
|
|
280
|
-
test,
|
|
244
|
+
test: validateTestOptions(definition.test),
|
|
281
245
|
output: validateOutput(definition.output),
|
|
282
246
|
nodes,
|
|
283
247
|
resolveNode: (name)=>nodes.get(name)
|
|
@@ -299,7 +263,7 @@ async function loadTestProject(configPath) {
|
|
|
299
263
|
if (!configPath) return validateTestProjectDefinition({
|
|
300
264
|
nodes: []
|
|
301
265
|
});
|
|
302
|
-
const absolutePath =
|
|
266
|
+
const absolutePath = resolve(configPath);
|
|
303
267
|
const extension = assertSupportedConfig(absolutePath);
|
|
304
268
|
let loaded;
|
|
305
269
|
try {
|
|
@@ -795,7 +759,7 @@ const DEFAULT_TEST_FILE_SELECTION = {
|
|
|
795
759
|
};
|
|
796
760
|
const project_preparation_toPosix = (value)=>value.split(sep).join('/');
|
|
797
761
|
const discoverResolvedTestFiles = (projectRoot, selection)=>{
|
|
798
|
-
const root =
|
|
762
|
+
const root = resolve(projectRoot);
|
|
799
763
|
const match = (patterns)=>globSync(patterns, {
|
|
800
764
|
absolute: true,
|
|
801
765
|
caseSensitiveMatch: false,
|
|
@@ -811,7 +775,7 @@ const discoverResolvedTestFiles = (projectRoot, selection)=>{
|
|
|
811
775
|
}).filter((file)=>/\.ya?ml$/i.test(file));
|
|
812
776
|
const files = match(selection.include);
|
|
813
777
|
return [
|
|
814
|
-
...new Set(files.map((file)=>
|
|
778
|
+
...new Set(files.map((file)=>resolve(file)))
|
|
815
779
|
].sort((a, b)=>{
|
|
816
780
|
const relativeA = project_preparation_toPosix(relative(root, a));
|
|
817
781
|
const relativeB = project_preparation_toPosix(relative(root, b));
|
|
@@ -824,7 +788,7 @@ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)
|
|
|
824
788
|
return discoverResolvedTestFiles(projectRoot, normalized);
|
|
825
789
|
};
|
|
826
790
|
const discoverTestConfig = (projectRoot)=>{
|
|
827
|
-
const root =
|
|
791
|
+
const root = resolve(projectRoot);
|
|
828
792
|
const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
|
|
829
793
|
const supported = candidates.filter((name)=>CONFIG_NAMES.includes(name));
|
|
830
794
|
const unsupported = candidates.filter((name)=>!CONFIG_NAMES.includes(name));
|
|
@@ -878,7 +842,7 @@ const selectProjects = (projects, names)=>{
|
|
|
878
842
|
async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
|
|
879
843
|
const platform = options.platform?.trim();
|
|
880
844
|
const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
|
|
881
|
-
const prerequisiteFile = options.prerequisiteFile ?
|
|
845
|
+
const prerequisiteFile = options.prerequisiteFile ? resolve(options.prerequisiteFile) : void 0;
|
|
882
846
|
const mainFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
|
|
883
847
|
const files = [
|
|
884
848
|
...prerequisiteFile ? [
|
|
@@ -956,24 +920,7 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
956
920
|
defaultTimeoutMs,
|
|
957
921
|
reportEnabled: true,
|
|
958
922
|
bindings: {
|
|
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
|
-
} : {}
|
|
923
|
+
resolveNode: project.nodes.require.bind(project.nodes)
|
|
977
924
|
}
|
|
978
925
|
});
|
|
979
926
|
} catch (error) {
|
|
@@ -1019,9 +966,9 @@ const assertDirectory = (path, label)=>{
|
|
|
1019
966
|
if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
|
|
1020
967
|
};
|
|
1021
968
|
const resolveRunInput = (options)=>{
|
|
1022
|
-
const cwd =
|
|
969
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
1023
970
|
assertDirectory(cwd, 'Test working directory');
|
|
1024
|
-
const inputPath = options.projectRoot ?
|
|
971
|
+
const inputPath = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
|
|
1025
972
|
const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
|
|
1026
973
|
if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
|
|
1027
974
|
const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
|
|
@@ -1040,14 +987,14 @@ async function prepareTestRun(options) {
|
|
|
1040
987
|
const startedAt = new Date();
|
|
1041
988
|
const runId = createTestRunId(startedAt);
|
|
1042
989
|
const input = resolveRunInput(options);
|
|
1043
|
-
const configPath = options.configPath ?
|
|
990
|
+
const configPath = options.configPath ? resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
|
|
1044
991
|
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}\`.`);
|
|
1045
992
|
if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
|
|
1046
993
|
const definition = await loadTestProject(configPath);
|
|
1047
|
-
const resultDir = options.resultDir ?
|
|
994
|
+
const resultDir = options.resultDir ? resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
|
|
1048
995
|
const runDir = join(resultDir, runId);
|
|
1049
996
|
const summaryPath = join(runDir, 'summary.json');
|
|
1050
|
-
const reportDir =
|
|
997
|
+
const reportDir = resolve(input.projectRoot, definition.output.reportDir);
|
|
1051
998
|
mkdirSync(resultDir, {
|
|
1052
999
|
recursive: true
|
|
1053
1000
|
});
|
|
@@ -1176,40 +1123,11 @@ const documentHasFatalError = (result)=>[
|
|
|
1176
1123
|
...result.beforeAll,
|
|
1177
1124
|
...result.afterAll
|
|
1178
1125
|
].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
|
-
};
|
|
1196
1126
|
async function executeDocumentInvocation(options) {
|
|
1197
1127
|
const { invocation, project, sinks } = options;
|
|
1198
1128
|
const { document } = invocation;
|
|
1199
1129
|
const documentRetry = 'document' === invocation.retry.scope;
|
|
1200
1130
|
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;
|
|
1213
1131
|
await runDocumentAttempts({
|
|
1214
1132
|
retry: documentRetry ? invocation.retry.count : 0,
|
|
1215
1133
|
signal: options.signal,
|
|
@@ -1220,9 +1138,6 @@ async function executeDocumentInvocation(options) {
|
|
|
1220
1138
|
plan: invocation.plan,
|
|
1221
1139
|
documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
|
|
1222
1140
|
...invocation.bindings,
|
|
1223
|
-
documentSetup,
|
|
1224
|
-
setupTimeoutMs: options.setupTimeoutMs,
|
|
1225
|
-
teardownTimeoutMs: options.teardownTimeoutMs,
|
|
1226
1141
|
project,
|
|
1227
1142
|
projectContext: options.projectContext,
|
|
1228
1143
|
retry: documentRetry ? 0 : invocation.retry.count,
|
|
@@ -1234,7 +1149,7 @@ async function executeDocumentInvocation(options) {
|
|
|
1234
1149
|
...run.beforeEach,
|
|
1235
1150
|
...run.steps,
|
|
1236
1151
|
...run.afterEach
|
|
1237
|
-
].some((step)=>step.error &&
|
|
1152
|
+
].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
|
|
1238
1153
|
onCaseStart: (collectedCase)=>{
|
|
1239
1154
|
options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
|
|
1240
1155
|
},
|
|
@@ -1243,7 +1158,6 @@ async function executeDocumentInvocation(options) {
|
|
|
1243
1158
|
options.onProgress(`${indent}→ ${formatStep(info)}`);
|
|
1244
1159
|
},
|
|
1245
1160
|
onStepResult: async (info, result)=>{
|
|
1246
|
-
if (options.onUnresolvedResources && isSharedResourceFailure(result.error)) options.markProjectFatal();
|
|
1247
1161
|
await invocation.bindings.onStepResult?.(info, result);
|
|
1248
1162
|
options.onProgress(formatStepResult(info, result));
|
|
1249
1163
|
},
|
|
@@ -1252,12 +1166,7 @@ async function executeDocumentInvocation(options) {
|
|
|
1252
1166
|
options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
|
|
1253
1167
|
},
|
|
1254
1168
|
onCaseOutcome: (outcome)=>{
|
|
1255
|
-
if (options.
|
|
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();
|
|
1169
|
+
if (caseHasFatalError(outcome)) options.markProjectFatal();
|
|
1261
1170
|
if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
|
|
1262
1171
|
},
|
|
1263
1172
|
onDocumentResult: options.onDocumentResult
|
|
@@ -1266,7 +1175,7 @@ async function executeDocumentInvocation(options) {
|
|
|
1266
1175
|
const partial = error.result;
|
|
1267
1176
|
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)));
|
|
1268
1177
|
if (resourceOnly) {
|
|
1269
|
-
if (
|
|
1178
|
+
if (error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
|
|
1270
1179
|
return partial;
|
|
1271
1180
|
}
|
|
1272
1181
|
if (documentRetry) partial.document.retryDecision = decideRetry({
|
|
@@ -1295,17 +1204,7 @@ async function executeDocumentInvocation(options) {
|
|
|
1295
1204
|
}
|
|
1296
1205
|
throw error;
|
|
1297
1206
|
});
|
|
1298
|
-
if (
|
|
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();
|
|
1207
|
+
if (documentHasFatalError(execution.document)) options.markProjectFatal();
|
|
1309
1208
|
const owners = [
|
|
1310
1209
|
execution.document,
|
|
1311
1210
|
...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
|
|
@@ -1313,10 +1212,7 @@ async function executeDocumentInvocation(options) {
|
|
|
1313
1212
|
const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
|
|
1314
1213
|
const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
|
|
1315
1214
|
const cleanupFailed = owners.some((owner)=>owner.cleanup?.status === 'failed' || !!owner.teardownErrors?.length || 'hostErrors' in owner && owner.hostErrors?.some(({ phase })=>'cleanup' === phase));
|
|
1316
|
-
if (cleanupPending || operationPending)
|
|
1317
|
-
options.markProjectFatal();
|
|
1318
|
-
options.onUnresolvedResources?.();
|
|
1319
|
-
} else if (cleanupFailed && !options.isolatedContext) options.markProjectFatal();
|
|
1215
|
+
if (cleanupPending || cleanupFailed || operationPending) options.markProjectFatal();
|
|
1320
1216
|
const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
|
|
1321
1217
|
const decision = decideRetry({
|
|
1322
1218
|
scope: 'document',
|
|
@@ -1369,212 +1265,6 @@ async function executeDocumentInvocation(options) {
|
|
|
1369
1265
|
if (documentRetry && failedCases) options.addFailedCases(failedCases);
|
|
1370
1266
|
else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
|
|
1371
1267
|
}
|
|
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
|
-
}
|
|
1578
1268
|
function createOperationJournal(runDir) {
|
|
1579
1269
|
const path = join(runDir, 'operations.jsonl');
|
|
1580
1270
|
writeFileSync(path, '', {
|
|
@@ -1644,7 +1334,7 @@ async function runPreparedTestProject(plan) {
|
|
|
1644
1334
|
const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
|
|
1645
1335
|
const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
|
|
1646
1336
|
progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
|
|
1647
|
-
const effectiveConcurrency = Math.min(definition.test.
|
|
1337
|
+
const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
|
|
1648
1338
|
await writeTestRunPlan(runDir, preparedProjects);
|
|
1649
1339
|
const journal = createOperationJournal(runDir);
|
|
1650
1340
|
const rootController = new AbortController();
|
|
@@ -1658,18 +1348,6 @@ async function runPreparedTestProject(plan) {
|
|
|
1658
1348
|
process.on('SIGTERM', sigterm);
|
|
1659
1349
|
let failedCaseCount = 0;
|
|
1660
1350
|
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()));
|
|
1673
1351
|
let hasInfrastructureError = false;
|
|
1674
1352
|
const infrastructureErrors = [];
|
|
1675
1353
|
const recordInfrastructureError = (error)=>{
|
|
@@ -1772,7 +1450,9 @@ async function runPreparedTestProject(plan) {
|
|
|
1772
1450
|
markProjectFatal: ()=>{
|
|
1773
1451
|
projectFatal = true;
|
|
1774
1452
|
},
|
|
1775
|
-
addFailedCases
|
|
1453
|
+
addFailedCases: (count)=>{
|
|
1454
|
+
failedCaseCount += count;
|
|
1455
|
+
},
|
|
1776
1456
|
onProgress: projectProgress,
|
|
1777
1457
|
sinks: {
|
|
1778
1458
|
cases,
|
|
@@ -1825,25 +1505,7 @@ async function runPreparedTestProject(plan) {
|
|
|
1825
1505
|
};
|
|
1826
1506
|
const projectResults = new Array(preparedProjects.length);
|
|
1827
1507
|
try {
|
|
1828
|
-
|
|
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, {
|
|
1508
|
+
await runConcurrentJobs(preparedProjects, {
|
|
1847
1509
|
concurrency: effectiveConcurrency,
|
|
1848
1510
|
shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
|
|
1849
1511
|
}, async (prepared, index)=>{
|
|
@@ -1959,6 +1621,6 @@ async function runPreparedTestProject(plan) {
|
|
|
1959
1621
|
async function runTestProject(options = {}) {
|
|
1960
1622
|
return runPreparedTestProject(await prepareTestRun(options));
|
|
1961
1623
|
}
|
|
1962
|
-
export { DEFAULT_TEST_FILE_SELECTION, createTestRunId,
|
|
1624
|
+
export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
|
|
1963
1625
|
|
|
1964
1626
|
//# sourceMappingURL=index.mjs.map
|