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