@midscene/test 1.15.6 → 1.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/es/cli/index.mjs +389 -129
- package/dist/es/cli/index.mjs.map +1 -1
- package/dist/es/test-cli.mjs +377 -118
- package/dist/es/test-cli.mjs.map +1 -1
- package/dist/lib/cli/index.js +381 -118
- package/dist/lib/cli/index.js.map +1 -1
- package/dist/lib/test-cli.js +376 -117
- package/dist/lib/test-cli.js.map +1 -1
- package/dist/templates/test/android/README.md +2 -0
- package/dist/templates/test/android/README.zh.md +2 -0
- package/dist/templates/test/android/midscene.config.ts +28 -31
- package/dist/templates/test/computer/README.md +2 -0
- package/dist/templates/test/computer/README.zh.md +2 -0
- package/dist/templates/test/computer/midscene.config.ts +31 -34
- package/dist/templates/test/harmony/README.md +2 -0
- package/dist/templates/test/harmony/README.zh.md +2 -0
- package/dist/templates/test/harmony/midscene.config.ts +28 -31
- package/dist/templates/test/ios/README.md +2 -0
- package/dist/templates/test/ios/README.zh.md +2 -0
- package/dist/templates/test/ios/midscene.config.ts +28 -31
- package/dist/templates/test/web/README.md +16 -0
- package/dist/templates/test/web/README.zh.md +16 -0
- package/dist/templates/test/web/cases/todo.yaml +2 -0
- package/dist/templates/test/web/midscene.config.ts +34 -12
- package/dist/types/cli/index.d.ts +32 -11
- package/dist/types/index.d.ts +3 -3
- package/package.json +13 -13
package/dist/lib/test-cli.js
CHANGED
|
@@ -29,7 +29,7 @@ var __webpack_modules__ = {
|
|
|
29
29
|
},
|
|
30
30
|
"./package.json" (module) {
|
|
31
31
|
module.exports = {
|
|
32
|
-
rE: "1.
|
|
32
|
+
rE: "1.16.1"
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
};
|
|
@@ -353,9 +353,19 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
353
353
|
if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
|
|
354
354
|
return value;
|
|
355
355
|
};
|
|
356
|
-
const
|
|
356
|
+
const validateConcurrency = (value, label)=>{
|
|
357
|
+
if (null === value) return null;
|
|
358
|
+
return validatePositiveInteger(value, 1, label);
|
|
359
|
+
};
|
|
360
|
+
const validateFileConcurrency = (maxConcurrentFiles, fileSetup, label)=>{
|
|
361
|
+
if (maxConcurrentFiles > 1 && !fileSetup) throw new TypeError(`Midscene config ${label}.fileSetup is required when the effective maxConcurrentFiles is greater than 1.`);
|
|
362
|
+
};
|
|
363
|
+
const validateExecutionProjects = (value, defaultSetup, defaultFileSetup, globalNodes, test)=>{
|
|
357
364
|
if (void 0 === value) {
|
|
358
365
|
const setup = validateSetup(defaultSetup, 'setup');
|
|
366
|
+
const fileSetup = validateSetup(defaultFileSetup, 'fileSetup');
|
|
367
|
+
const maxConcurrentFiles = test.maxConcurrentFiles ?? 1 / 0;
|
|
368
|
+
validateFileConcurrency(maxConcurrentFiles, fileSetup, 'default');
|
|
359
369
|
return {
|
|
360
370
|
hasExplicitProjects: false,
|
|
361
371
|
projects: Object.freeze([
|
|
@@ -365,6 +375,10 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
365
375
|
...setup ? {
|
|
366
376
|
setup
|
|
367
377
|
} : {},
|
|
378
|
+
...fileSetup ? {
|
|
379
|
+
fileSetup
|
|
380
|
+
} : {},
|
|
381
|
+
maxConcurrentFiles,
|
|
368
382
|
tags: Object.freeze({
|
|
369
383
|
include: [],
|
|
370
384
|
exclude: []
|
|
@@ -377,6 +391,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
377
391
|
};
|
|
378
392
|
}
|
|
379
393
|
if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
|
|
394
|
+
if (void 0 !== defaultFileSetup) throw new TypeError('Midscene config fileSetup cannot be used together with projects. Move fileSetup to projects[].fileSetup.');
|
|
380
395
|
if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
|
|
381
396
|
const names = new Set();
|
|
382
397
|
const projects = value.map((candidate, index)=>{
|
|
@@ -385,6 +400,8 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
385
400
|
rejectUnknownKeys(candidate, [
|
|
386
401
|
'name',
|
|
387
402
|
'setup',
|
|
403
|
+
'fileSetup',
|
|
404
|
+
'maxConcurrentFiles',
|
|
388
405
|
'nodes',
|
|
389
406
|
'files',
|
|
390
407
|
'tags',
|
|
@@ -401,10 +418,18 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
401
418
|
...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
|
|
402
419
|
...localNodes.definitions()
|
|
403
420
|
]);
|
|
421
|
+
const fileSetup = validateSetup(candidate.fileSetup, `${label}.fileSetup`);
|
|
422
|
+
const globalFileLimit = test.maxConcurrentFiles ?? 1 / 0;
|
|
423
|
+
const maxConcurrentFiles = Math.min(globalFileLimit, validatePositiveInteger(candidate.maxConcurrentFiles, globalFileLimit, `${label}.maxConcurrentFiles`));
|
|
424
|
+
validateFileConcurrency(maxConcurrentFiles, fileSetup, label);
|
|
404
425
|
return Object.freeze({
|
|
405
426
|
projectId: projectIdFromIndex(index),
|
|
406
427
|
name: candidate.name,
|
|
407
428
|
nodes,
|
|
429
|
+
...fileSetup ? {
|
|
430
|
+
fileSetup
|
|
431
|
+
} : {},
|
|
432
|
+
maxConcurrentFiles,
|
|
408
433
|
...files ? {
|
|
409
434
|
files
|
|
410
435
|
} : {},
|
|
@@ -421,11 +446,25 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
421
446
|
hasExplicitProjects: true
|
|
422
447
|
};
|
|
423
448
|
};
|
|
424
|
-
const validateTestOptions = (value)=>{
|
|
449
|
+
const validateTestOptions = (value, hasExplicitProjects)=>{
|
|
425
450
|
if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
|
|
426
451
|
const candidate = value ?? {};
|
|
452
|
+
if (void 0 !== candidate.maxConcurrency) {
|
|
453
|
+
const limit = 'number' == typeof candidate.maxConcurrency && Number.isInteger(candidate.maxConcurrency) && candidate.maxConcurrency > 0 ? candidate.maxConcurrency : 1;
|
|
454
|
+
throw new TypeError([
|
|
455
|
+
'Midscene config test.maxConcurrency is no longer supported.',
|
|
456
|
+
'It controlled project concurrency, not file concurrency. Remove it and use:',
|
|
457
|
+
` test: { maxConcurrentProjects: ${limit}, maxConcurrentFiles: ${hasExplicitProjects ? limit : 1} }`,
|
|
458
|
+
hasExplicitProjects ? ' Set projects[].maxConcurrentFiles to 1 to keep files sequential within each project.' : ' The implicit default project keeps running files sequentially.',
|
|
459
|
+
hasExplicitProjects ? 'For parallel files, configure fileSetup and raise both test.maxConcurrentFiles and projects[].maxConcurrentFiles.' : 'For parallel files, configure fileSetup and raise test.maxConcurrentFiles.',
|
|
460
|
+
'Migration guide: https://midscenejs.com/midscene-test/configuration.html#existing-configurations'
|
|
461
|
+
].join('\n'));
|
|
462
|
+
}
|
|
427
463
|
return Object.freeze({
|
|
428
|
-
|
|
464
|
+
maxConcurrentFiles: validateConcurrency(candidate.maxConcurrentFiles, 'test.maxConcurrentFiles'),
|
|
465
|
+
maxConcurrentProjects: validateConcurrency(candidate.maxConcurrentProjects, 'test.maxConcurrentProjects'),
|
|
466
|
+
setupTimeout: validatePositiveInteger(candidate.setupTimeout, 120000, 'test.setupTimeout'),
|
|
467
|
+
teardownTimeout: validatePositiveInteger(candidate.teardownTimeout, 30000, 'test.teardownTimeout'),
|
|
429
468
|
bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
|
|
430
469
|
testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
|
|
431
470
|
});
|
|
@@ -455,16 +494,18 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
455
494
|
if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
|
|
456
495
|
rejectUnknownKeys(definition, [
|
|
457
496
|
'setup',
|
|
497
|
+
'fileSetup',
|
|
458
498
|
'projects',
|
|
459
499
|
'test',
|
|
460
500
|
'output',
|
|
461
501
|
'nodes'
|
|
462
502
|
], 'root');
|
|
463
503
|
const nodes = new NodeRegistry(definition.nodes);
|
|
464
|
-
const
|
|
504
|
+
const test = validateTestOptions(definition.test, void 0 !== definition.projects);
|
|
505
|
+
const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, definition.fileSetup, nodes, test);
|
|
465
506
|
return {
|
|
466
507
|
...resolvedProjects,
|
|
467
|
-
test
|
|
508
|
+
test,
|
|
468
509
|
output: validateOutput(definition.output),
|
|
469
510
|
nodes,
|
|
470
511
|
resolveNode: (name)=>nodes.get(name)
|
|
@@ -1142,7 +1183,24 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1142
1183
|
defaultTimeoutMs,
|
|
1143
1184
|
reportEnabled: true,
|
|
1144
1185
|
bindings: {
|
|
1145
|
-
resolveNode: project.nodes.require.bind(project.nodes)
|
|
1186
|
+
resolveNode: project.nodes.require.bind(project.nodes),
|
|
1187
|
+
...project.fileSetup ? {
|
|
1188
|
+
documentSetup: {
|
|
1189
|
+
name: project.fileSetup.name,
|
|
1190
|
+
setup ({ projectContext, document, signal, onTeardown }) {
|
|
1191
|
+
return project.fileSetup.setup({
|
|
1192
|
+
project,
|
|
1193
|
+
projectContext,
|
|
1194
|
+
file: document,
|
|
1195
|
+
env: Object.freeze({
|
|
1196
|
+
...process.env
|
|
1197
|
+
}),
|
|
1198
|
+
signal,
|
|
1199
|
+
onTeardown
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
} : {}
|
|
1146
1204
|
}
|
|
1147
1205
|
});
|
|
1148
1206
|
} catch (error) {
|
|
@@ -1348,11 +1406,40 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1348
1406
|
...result.beforeAll,
|
|
1349
1407
|
...result.afterAll
|
|
1350
1408
|
].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);
|
|
1409
|
+
const isSharedResourceFailure = (error)=>{
|
|
1410
|
+
const seen = new Set();
|
|
1411
|
+
const pending = [
|
|
1412
|
+
error
|
|
1413
|
+
];
|
|
1414
|
+
while(pending.length && seen.size < 50){
|
|
1415
|
+
const item = pending.pop();
|
|
1416
|
+
if ('object' != typeof item || null === item || seen.has(item)) continue;
|
|
1417
|
+
seen.add(item);
|
|
1418
|
+
const failure = item;
|
|
1419
|
+
if ('PROJECT_RESOURCE_ERROR' === failure.code) return true;
|
|
1420
|
+
if (failure.cause) pending.push(failure.cause);
|
|
1421
|
+
if (Array.isArray(failure.errors)) pending.push(...failure.errors.slice(0, 50));
|
|
1422
|
+
}
|
|
1423
|
+
return false;
|
|
1424
|
+
};
|
|
1351
1425
|
async function executeDocumentInvocation(options) {
|
|
1352
1426
|
const { invocation, project, sinks } = options;
|
|
1427
|
+
const isProjectResourceFailure = options.isProjectResourceFailure ?? (()=>false);
|
|
1353
1428
|
const { document } = invocation;
|
|
1354
1429
|
const documentRetry = 'document' === invocation.retry.scope;
|
|
1355
1430
|
const attempts = [];
|
|
1431
|
+
const fileSetup = invocation.bindings.documentSetup;
|
|
1432
|
+
const documentSetup = options.isProjectResourceFailure && fileSetup ? {
|
|
1433
|
+
...fileSetup,
|
|
1434
|
+
async setup (ctx) {
|
|
1435
|
+
try {
|
|
1436
|
+
return await fileSetup.setup(ctx);
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
if (isProjectResourceFailure(error)) options.markProjectFatal();
|
|
1439
|
+
throw error;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
} : fileSetup;
|
|
1356
1443
|
await (0, test_runner_namespaceObject.runDocumentAttempts)({
|
|
1357
1444
|
retry: documentRetry ? invocation.retry.count : 0,
|
|
1358
1445
|
signal: options.signal,
|
|
@@ -1363,6 +1450,9 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1363
1450
|
plan: invocation.plan,
|
|
1364
1451
|
documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
|
|
1365
1452
|
...invocation.bindings,
|
|
1453
|
+
documentSetup,
|
|
1454
|
+
setupTimeoutMs: options.setupTimeoutMs,
|
|
1455
|
+
teardownTimeoutMs: options.teardownTimeoutMs,
|
|
1366
1456
|
project,
|
|
1367
1457
|
projectContext: options.projectContext,
|
|
1368
1458
|
retry: documentRetry ? 0 : invocation.retry.count,
|
|
@@ -1374,7 +1464,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1374
1464
|
...run.beforeEach,
|
|
1375
1465
|
...run.steps,
|
|
1376
1466
|
...run.afterEach
|
|
1377
|
-
].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (run.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError),
|
|
1467
|
+
].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)),
|
|
1378
1468
|
onCaseStart: (collectedCase)=>{
|
|
1379
1469
|
options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
|
|
1380
1470
|
},
|
|
@@ -1383,6 +1473,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1383
1473
|
options.onProgress(`${indent}→ ${formatStep(info)}`);
|
|
1384
1474
|
},
|
|
1385
1475
|
onStepResult: async (info, result)=>{
|
|
1476
|
+
if (isProjectResourceFailure(result.error)) options.markProjectFatal();
|
|
1386
1477
|
await invocation.bindings.onStepResult?.(info, result);
|
|
1387
1478
|
options.onProgress(formatStepResult(info, result));
|
|
1388
1479
|
},
|
|
@@ -1391,7 +1482,12 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1391
1482
|
options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
|
|
1392
1483
|
},
|
|
1393
1484
|
onCaseOutcome: (outcome)=>{
|
|
1394
|
-
if (
|
|
1485
|
+
if (outcome.attempts?.some((attempt)=>[
|
|
1486
|
+
...attempt.beforeEach,
|
|
1487
|
+
...attempt.steps,
|
|
1488
|
+
...attempt.afterEach
|
|
1489
|
+
].some((step)=>isProjectResourceFailure(step.error)) || attempt.teardownErrors?.some(isProjectResourceFailure))) options.markProjectFatal();
|
|
1490
|
+
if (!options.isolatedContext && caseHasFatalError(outcome)) options.markProjectFatal();
|
|
1395
1491
|
if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
|
|
1396
1492
|
},
|
|
1397
1493
|
onDocumentResult: options.onDocumentResult
|
|
@@ -1400,7 +1496,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1400
1496
|
const partial = error.result;
|
|
1401
1497
|
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)));
|
|
1402
1498
|
if (resourceOnly) {
|
|
1403
|
-
if (error.errors.some(test_runner_namespaceObject.isResourceRecoveryError)) options.markProjectFatal();
|
|
1499
|
+
if (!options.isolatedContext && error.errors.some(test_runner_namespaceObject.isResourceRecoveryError)) options.markProjectFatal();
|
|
1404
1500
|
return partial;
|
|
1405
1501
|
}
|
|
1406
1502
|
if (documentRetry) partial.document.retryDecision = (0, test_runner_namespaceObject.decideRetry)({
|
|
@@ -1429,7 +1525,17 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1429
1525
|
}
|
|
1430
1526
|
throw error;
|
|
1431
1527
|
});
|
|
1432
|
-
if (documentHasFatalError(execution.document)) options.markProjectFatal();
|
|
1528
|
+
if (!options.isolatedContext && documentHasFatalError(execution.document)) options.markProjectFatal();
|
|
1529
|
+
if ([
|
|
1530
|
+
...execution.document.beforeAll,
|
|
1531
|
+
...execution.document.afterAll,
|
|
1532
|
+
...(execution.document.hostErrors ?? []).map(({ error })=>({
|
|
1533
|
+
error
|
|
1534
|
+
})),
|
|
1535
|
+
...(execution.document.teardownErrors ?? []).map((error)=>({
|
|
1536
|
+
error
|
|
1537
|
+
}))
|
|
1538
|
+
].some(({ error })=>isProjectResourceFailure(error))) options.markProjectFatal();
|
|
1433
1539
|
const owners = [
|
|
1434
1540
|
execution.document,
|
|
1435
1541
|
...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
|
|
@@ -1437,7 +1543,10 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1437
1543
|
const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
|
|
1438
1544
|
const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
|
|
1439
1545
|
const cleanupFailed = owners.some((owner)=>owner.cleanup?.status === 'failed' || !!owner.teardownErrors?.length || 'hostErrors' in owner && owner.hostErrors?.some(({ phase })=>'cleanup' === phase));
|
|
1440
|
-
if (cleanupPending ||
|
|
1546
|
+
if (cleanupPending || operationPending) {
|
|
1547
|
+
options.markProjectFatal();
|
|
1548
|
+
options.onUnresolvedResources?.();
|
|
1549
|
+
} else if (cleanupFailed && !options.isolatedContext) options.markProjectFatal();
|
|
1441
1550
|
const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
|
|
1442
1551
|
const decision = (0, test_runner_namespaceObject.decideRetry)({
|
|
1443
1552
|
scope: 'document',
|
|
@@ -1490,6 +1599,231 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1490
1599
|
if (documentRetry && failedCases) options.addFailedCases(failedCases);
|
|
1491
1600
|
else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
|
|
1492
1601
|
}
|
|
1602
|
+
async function runPreparedFiles(options) {
|
|
1603
|
+
const states = options.projects.map((prepared)=>({
|
|
1604
|
+
prepared,
|
|
1605
|
+
cases: [],
|
|
1606
|
+
documents: [],
|
|
1607
|
+
cursor: 0,
|
|
1608
|
+
active: 0,
|
|
1609
|
+
initializing: false,
|
|
1610
|
+
finished: false,
|
|
1611
|
+
fatal: false,
|
|
1612
|
+
setupFailed: false
|
|
1613
|
+
}));
|
|
1614
|
+
let nextProject = 0;
|
|
1615
|
+
let resourcesUnresolved = false;
|
|
1616
|
+
let occupied = 0;
|
|
1617
|
+
let occupiedProjects = 0;
|
|
1618
|
+
const cleanupQueue = [];
|
|
1619
|
+
const release = ()=>{
|
|
1620
|
+
occupied--;
|
|
1621
|
+
while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
|
|
1622
|
+
occupied++;
|
|
1623
|
+
cleanupQueue.shift()();
|
|
1624
|
+
}
|
|
1625
|
+
wake?.();
|
|
1626
|
+
};
|
|
1627
|
+
let wake;
|
|
1628
|
+
const running = new Set();
|
|
1629
|
+
const stopped = (state)=>options.signal.aborted || options.shouldBail() || state.fatal;
|
|
1630
|
+
const invalid = (state)=>options.preflightFailed || state.prepared.collectionErrors.length > 0;
|
|
1631
|
+
const unresolved = ()=>{
|
|
1632
|
+
if (resourcesUnresolved) return;
|
|
1633
|
+
resourcesUnresolved = true;
|
|
1634
|
+
options.onInfrastructureError(new test_runner_namespaceObject.WorkflowError('Resources are still running or cleanup is pending. No new test files will start.', {
|
|
1635
|
+
code: 'UNRESOLVED_FILE_RESOURCES'
|
|
1636
|
+
}));
|
|
1637
|
+
};
|
|
1638
|
+
const finishProject = async (state)=>{
|
|
1639
|
+
if (!state.runtime || state.finished) return;
|
|
1640
|
+
state.finished = true;
|
|
1641
|
+
const failed = state.fatal || options.signal.aborted || state.cases.some((item)=>'success' !== item.status) || state.documents.some((item)=>'success' !== item.status);
|
|
1642
|
+
state.lifecycle = await state.runtime.finish(failed ? 'failed' : 'success');
|
|
1643
|
+
const releaseProject = ()=>{
|
|
1644
|
+
occupiedProjects--;
|
|
1645
|
+
wake?.();
|
|
1646
|
+
};
|
|
1647
|
+
if (state.lifecycle.cleanup?.status === 'pending' || state.lifecycle.pendingOperations?.length) {
|
|
1648
|
+
unresolved();
|
|
1649
|
+
Promise.all([
|
|
1650
|
+
(0, test_runner_namespaceObject.waitForScopeOperations)(state.runtime.signal),
|
|
1651
|
+
(0, test_runner_namespaceObject.getResourceCleanupCompletion)(state.runtime.signal)
|
|
1652
|
+
]).then(releaseProject, (error)=>{
|
|
1653
|
+
options.onInfrastructureError(error);
|
|
1654
|
+
releaseProject();
|
|
1655
|
+
});
|
|
1656
|
+
} else releaseProject();
|
|
1657
|
+
};
|
|
1658
|
+
const admit = ()=>{
|
|
1659
|
+
for(let offset = 0; offset < states.length; offset++){
|
|
1660
|
+
const index = (nextProject + offset) % states.length;
|
|
1661
|
+
const state = states[index];
|
|
1662
|
+
if (invalid(state) || state.finished || state.initializing) continue;
|
|
1663
|
+
if (!state.runtime && occupiedProjects >= options.maxConcurrentProjects) continue;
|
|
1664
|
+
const cleanupOnly = !!state.runtime && 0 === state.active && (stopped(state) || state.cursor === state.prepared.invocations.length);
|
|
1665
|
+
if (!cleanupOnly && (stopped(state) || state.cursor >= state.prepared.invocations.length || state.active >= (state.prepared.project.maxConcurrentFiles ?? options.maxConcurrentFiles))) continue;
|
|
1666
|
+
nextProject = (index + 1) % states.length;
|
|
1667
|
+
occupied++;
|
|
1668
|
+
state.active++;
|
|
1669
|
+
if (!state.runtime && !cleanupOnly) {
|
|
1670
|
+
occupiedProjects++;
|
|
1671
|
+
state.initializing = true;
|
|
1672
|
+
}
|
|
1673
|
+
const invocationIndex = cleanupOnly ? -1 : state.cursor++;
|
|
1674
|
+
return {
|
|
1675
|
+
state,
|
|
1676
|
+
index,
|
|
1677
|
+
invocationIndex
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
const run = async ({ state, index, invocationIndex })=>{
|
|
1682
|
+
const { prepared } = state;
|
|
1683
|
+
const { project } = prepared;
|
|
1684
|
+
let fileResourcesPending = false;
|
|
1685
|
+
try {
|
|
1686
|
+
if (invocationIndex < 0) return void await finishProject(state);
|
|
1687
|
+
if (!state.runtime) {
|
|
1688
|
+
state.runtime = (0, test_runner_namespaceObject.createProjectRuntime)({
|
|
1689
|
+
project,
|
|
1690
|
+
setup: project.setup,
|
|
1691
|
+
signal: options.signal,
|
|
1692
|
+
setupTimeoutMs: options.setupTimeoutMs,
|
|
1693
|
+
teardownTimeoutMs: options.teardownTimeoutMs
|
|
1694
|
+
});
|
|
1695
|
+
(0, external_node_events_namespaceObject.setMaxListeners)(Math.max(10, Math.min(prepared.invocations.length, project.maxConcurrentFiles ?? options.maxConcurrentFiles) + 1), state.runtime.signal);
|
|
1696
|
+
options.announceProject(prepared, index);
|
|
1697
|
+
await state.runtime.start();
|
|
1698
|
+
state.initializing = false;
|
|
1699
|
+
if (!state.runtime.canRun) {
|
|
1700
|
+
state.fatal = true;
|
|
1701
|
+
state.setupFailed = true;
|
|
1702
|
+
}
|
|
1703
|
+
wake?.();
|
|
1704
|
+
}
|
|
1705
|
+
if (stopped(state)) return;
|
|
1706
|
+
const invocation = prepared.invocations[invocationIndex];
|
|
1707
|
+
options.onProgress(prepared, ` [document ${invocationIndex + 1}/${prepared.invocations.length}] ${invocation.document.sourcePath}`);
|
|
1708
|
+
await executeDocumentInvocation({
|
|
1709
|
+
invocation,
|
|
1710
|
+
project,
|
|
1711
|
+
projectContext: state.runtime.context,
|
|
1712
|
+
runDir: options.runDir,
|
|
1713
|
+
signal: state.runtime.signal,
|
|
1714
|
+
rootSignal: options.signal,
|
|
1715
|
+
shouldBail: options.shouldBail,
|
|
1716
|
+
isProjectFatal: ()=>state.fatal,
|
|
1717
|
+
markProjectFatal: ()=>{
|
|
1718
|
+
if (state.fatal) return;
|
|
1719
|
+
state.fatal = true;
|
|
1720
|
+
state.runtime?.abort(new test_runner_namespaceObject.WorkflowError(`Project "${project.name}" shared execution environment is unavailable.`, {
|
|
1721
|
+
code: 'PROJECT_RESOURCE_ERROR'
|
|
1722
|
+
}));
|
|
1723
|
+
},
|
|
1724
|
+
addFailedCases: options.addFailedCases,
|
|
1725
|
+
onProgress: (message)=>options.onProgress(prepared, message),
|
|
1726
|
+
sinks: state,
|
|
1727
|
+
isolatedContext: void 0 !== project.fileSetup,
|
|
1728
|
+
isProjectResourceFailure: isSharedResourceFailure,
|
|
1729
|
+
setupTimeoutMs: options.setupTimeoutMs,
|
|
1730
|
+
teardownTimeoutMs: options.teardownTimeoutMs,
|
|
1731
|
+
onUnresolvedResources: ()=>{
|
|
1732
|
+
fileResourcesPending = true;
|
|
1733
|
+
unresolved();
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
} catch (error) {
|
|
1737
|
+
state.fatal = true;
|
|
1738
|
+
fileResourcesPending = !!state.runtime && (0, test_runner_namespaceObject.getPendingScopeOperations)(state.runtime.signal).length > 0;
|
|
1739
|
+
options.onInfrastructureError(error);
|
|
1740
|
+
} finally{
|
|
1741
|
+
state.initializing = false;
|
|
1742
|
+
try {
|
|
1743
|
+
if (1 === state.active && (stopped(state) || state.cursor === prepared.invocations.length)) await finishProject(state);
|
|
1744
|
+
} catch (error) {
|
|
1745
|
+
options.onInfrastructureError(error);
|
|
1746
|
+
}
|
|
1747
|
+
state.active--;
|
|
1748
|
+
if (state.runtime && (fileResourcesPending || state.lifecycle?.cleanup?.status === 'pending')) (0, test_runner_namespaceObject.waitForScopeOperations)(state.runtime.signal).then(release, (error)=>{
|
|
1749
|
+
options.onInfrastructureError(error);
|
|
1750
|
+
release();
|
|
1751
|
+
});
|
|
1752
|
+
else release();
|
|
1753
|
+
wake?.();
|
|
1754
|
+
}
|
|
1755
|
+
};
|
|
1756
|
+
while(true){
|
|
1757
|
+
const changed = new Promise((resolve)=>{
|
|
1758
|
+
wake = resolve;
|
|
1759
|
+
});
|
|
1760
|
+
while(occupied < options.maxConcurrentFiles){
|
|
1761
|
+
const job = admit();
|
|
1762
|
+
if (!job) break;
|
|
1763
|
+
const task = run(job);
|
|
1764
|
+
running.add(task);
|
|
1765
|
+
const settled = ()=>{
|
|
1766
|
+
running.delete(task);
|
|
1767
|
+
wake?.();
|
|
1768
|
+
};
|
|
1769
|
+
task.then(settled, (error)=>{
|
|
1770
|
+
options.onInfrastructureError(error);
|
|
1771
|
+
settled();
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
if (!running.size) break;
|
|
1775
|
+
await changed;
|
|
1776
|
+
}
|
|
1777
|
+
wake = void 0;
|
|
1778
|
+
await Promise.all(states.map(async (state)=>{
|
|
1779
|
+
if (!state.runtime || state.finished) return;
|
|
1780
|
+
const admission = new Promise((resolve)=>{
|
|
1781
|
+
cleanupQueue.push(resolve);
|
|
1782
|
+
});
|
|
1783
|
+
(0, test_runner_namespaceObject.trackResourceOperation)({}, admission, state.runtime.signal);
|
|
1784
|
+
while(occupied < options.maxConcurrentFiles && cleanupQueue.length){
|
|
1785
|
+
occupied++;
|
|
1786
|
+
cleanupQueue.shift()();
|
|
1787
|
+
}
|
|
1788
|
+
try {
|
|
1789
|
+
await finishProject(state);
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
options.onInfrastructureError(error);
|
|
1792
|
+
} finally{
|
|
1793
|
+
Promise.all([
|
|
1794
|
+
admission,
|
|
1795
|
+
(0, test_runner_namespaceObject.getResourceCleanupCompletion)(state.runtime.signal)
|
|
1796
|
+
]).then(release, (error)=>{
|
|
1797
|
+
options.onInfrastructureError(error);
|
|
1798
|
+
release();
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
}));
|
|
1802
|
+
for (const state of states){
|
|
1803
|
+
const { prepared } = state;
|
|
1804
|
+
if (!state.runtime) options.announceProject(prepared, states.indexOf(state));
|
|
1805
|
+
const completed = new Set(state.cases.map((item)=>item.caseId));
|
|
1806
|
+
const reason = invalid(state) ? 'project-preflight-failed' : state.setupFailed ? 'project-setup-failed' : options.signal.aborted ? 'interrupted' : state.fatal ? 'fatal-error' : 'bail';
|
|
1807
|
+
for (const document of prepared.documents)for (const item of document.cases)if (!completed.has(item.caseId)) state.cases.push({
|
|
1808
|
+
documentId: document.documentId,
|
|
1809
|
+
caseId: item.caseId,
|
|
1810
|
+
projectName: prepared.project.name,
|
|
1811
|
+
name: item.definition.name,
|
|
1812
|
+
sourcePath: item.sourcePath,
|
|
1813
|
+
caseIndex: item.caseIndex,
|
|
1814
|
+
status: 'not-run',
|
|
1815
|
+
notRunReason: reason
|
|
1816
|
+
});
|
|
1817
|
+
const order = new Map(prepared.documents.map((document, i)=>[
|
|
1818
|
+
document.documentId,
|
|
1819
|
+
i
|
|
1820
|
+
]));
|
|
1821
|
+
const position = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
|
|
1822
|
+
state.cases.sort((a, b)=>position(a.documentId) - position(b.documentId) || a.caseIndex - b.caseIndex);
|
|
1823
|
+
state.documents.sort((a, b)=>position(a.documentId) - position(b.documentId));
|
|
1824
|
+
}
|
|
1825
|
+
return states;
|
|
1826
|
+
}
|
|
1493
1827
|
function createOperationJournal(runDir) {
|
|
1494
1828
|
const path = (0, external_node_path_.join)(runDir, 'operations.jsonl');
|
|
1495
1829
|
(0, external_node_fs_.writeFileSync)(path, '', {
|
|
@@ -1559,7 +1893,7 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1559
1893
|
const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
|
|
1560
1894
|
const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
|
|
1561
1895
|
progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
|
|
1562
|
-
const effectiveConcurrency = Math.min(definition.test.
|
|
1896
|
+
const effectiveConcurrency = Math.min(definition.test.maxConcurrentFiles ?? 1 / 0, Math.max(1, totalDocuments));
|
|
1563
1897
|
await writeTestRunPlan(runDir, preparedProjects);
|
|
1564
1898
|
const journal = createOperationJournal(runDir);
|
|
1565
1899
|
const rootController = new AbortController();
|
|
@@ -1573,6 +1907,18 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1573
1907
|
process.on('SIGTERM', sigterm);
|
|
1574
1908
|
let failedCaseCount = 0;
|
|
1575
1909
|
const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
|
|
1910
|
+
let shutdownDeadline;
|
|
1911
|
+
const beginShutdown = ()=>{
|
|
1912
|
+
shutdownDeadline ??= Date.now() + definition.test.teardownTimeout;
|
|
1913
|
+
};
|
|
1914
|
+
rootController.signal.addEventListener('abort', beginShutdown, {
|
|
1915
|
+
once: true
|
|
1916
|
+
});
|
|
1917
|
+
const addFailedCases = (count)=>{
|
|
1918
|
+
failedCaseCount += count;
|
|
1919
|
+
if (bailReached()) beginShutdown();
|
|
1920
|
+
};
|
|
1921
|
+
const teardownTimeoutMs = ()=>Math.max(0, Math.min(definition.test.teardownTimeout, void 0 === shutdownDeadline ? 1 / 0 : shutdownDeadline - Date.now()));
|
|
1576
1922
|
let hasInfrastructureError = false;
|
|
1577
1923
|
const infrastructureErrors = [];
|
|
1578
1924
|
const recordInfrastructureError = (error)=>{
|
|
@@ -1634,112 +1980,25 @@ Each Node's returned \`data\` is saved on that Step's result. Results are not na
|
|
|
1634
1980
|
announceProject(prepared, projectIndex);
|
|
1635
1981
|
return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
|
|
1636
1982
|
};
|
|
1637
|
-
const runPreparedProject = async (prepared, projectIndex)=>{
|
|
1638
|
-
const { project } = prepared;
|
|
1639
|
-
announceProject(prepared, projectIndex);
|
|
1640
|
-
const projectProgress = (message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${project.name}]${message}` : message));
|
|
1641
|
-
const cases = [];
|
|
1642
|
-
const documents = [];
|
|
1643
|
-
let lifecycle;
|
|
1644
|
-
let projectFatal = false;
|
|
1645
|
-
if (prepared.collectionErrors.length > 0 || 'run' === plan.preflightScope && totalErrors > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
|
|
1646
|
-
else if (rootController.signal.aborted) cases.push(...notRunSuite(prepared, 'interrupted'));
|
|
1647
|
-
else if (bailReached()) cases.push(...notRunSuite(prepared, 'bail'));
|
|
1648
|
-
else {
|
|
1649
|
-
const runtime = (0, test_runner_namespaceObject.createProjectRuntime)({
|
|
1650
|
-
project,
|
|
1651
|
-
setup: project.setup,
|
|
1652
|
-
signal: rootController.signal
|
|
1653
|
-
});
|
|
1654
|
-
let hasProjectExecutionError = false;
|
|
1655
|
-
try {
|
|
1656
|
-
await runtime.start();
|
|
1657
|
-
if (runtime.canRun) {
|
|
1658
|
-
const runDocument = async (invocation, documentIndex)=>{
|
|
1659
|
-
const { document } = invocation;
|
|
1660
|
-
if (rootController.signal.aborted || bailReached() || projectFatal) {
|
|
1661
|
-
const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
|
|
1662
|
-
cases.push(...document.cases.map((item)=>asNotRun(document.documentId, item, project.name, reason)));
|
|
1663
|
-
return;
|
|
1664
|
-
}
|
|
1665
|
-
projectProgress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
|
|
1666
|
-
await executeDocumentInvocation({
|
|
1667
|
-
invocation,
|
|
1668
|
-
project,
|
|
1669
|
-
projectContext: runtime.context,
|
|
1670
|
-
runDir,
|
|
1671
|
-
signal: runtime.signal,
|
|
1672
|
-
rootSignal: rootController.signal,
|
|
1673
|
-
shouldBail: bailReached,
|
|
1674
|
-
isProjectFatal: ()=>projectFatal,
|
|
1675
|
-
markProjectFatal: ()=>{
|
|
1676
|
-
projectFatal = true;
|
|
1677
|
-
},
|
|
1678
|
-
addFailedCases: (count)=>{
|
|
1679
|
-
failedCaseCount += count;
|
|
1680
|
-
},
|
|
1681
|
-
onProgress: projectProgress,
|
|
1682
|
-
sinks: {
|
|
1683
|
-
cases,
|
|
1684
|
-
documents
|
|
1685
|
-
}
|
|
1686
|
-
});
|
|
1687
|
-
};
|
|
1688
|
-
let prerequisiteFailed = false;
|
|
1689
|
-
const prerequisite = prepared.invocations.find((invocation)=>invocation.document.documentId === prepared.prerequisiteDocumentId);
|
|
1690
|
-
const prerequisiteCount = prerequisite ? 1 : 0;
|
|
1691
|
-
if (prerequisite) {
|
|
1692
|
-
await runDocument(prerequisite, 0);
|
|
1693
|
-
prerequisiteFailed = documents.at(-1)?.status === 'failed' || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status);
|
|
1694
|
-
}
|
|
1695
|
-
if (!prerequisiteFailed) await (0, test_runner_namespaceObject.runConcurrentJobs)(prepared.invocations.filter((invocation)=>invocation !== prerequisite), {
|
|
1696
|
-
concurrency: prepared.documentConcurrency,
|
|
1697
|
-
shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal
|
|
1698
|
-
}, async (invocation, index)=>{
|
|
1699
|
-
try {
|
|
1700
|
-
await runDocument(invocation, index + prerequisiteCount);
|
|
1701
|
-
} catch (error) {
|
|
1702
|
-
recordInfrastructureError(error);
|
|
1703
|
-
throw error;
|
|
1704
|
-
}
|
|
1705
|
-
});
|
|
1706
|
-
const completed = new Set(cases.map((item)=>item.caseId));
|
|
1707
|
-
const reason = prerequisiteFailed ? 'project-setup-failed' : rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
|
|
1708
|
-
cases.push(...notRunSuite(prepared, reason).filter((item)=>!completed.has(item.caseId)));
|
|
1709
|
-
} else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
|
|
1710
|
-
} catch (error) {
|
|
1711
|
-
hasProjectExecutionError = true;
|
|
1712
|
-
recordInfrastructureError(error);
|
|
1713
|
-
} finally{
|
|
1714
|
-
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;
|
|
1715
|
-
lifecycle = await runtime.finish(hasFailure ? 'failed' : 'success');
|
|
1716
|
-
}
|
|
1717
|
-
if (hasProjectExecutionError) {
|
|
1718
|
-
const completed = new Set(cases.map((item)=>item.caseId));
|
|
1719
|
-
cases.push(...notRunSuite(prepared, 'interrupted').filter((item)=>!completed.has(item.caseId)));
|
|
1720
|
-
}
|
|
1721
|
-
}
|
|
1722
|
-
const order = new Map(prepared.documents.map((document, index)=>[
|
|
1723
|
-
document.documentId,
|
|
1724
|
-
index
|
|
1725
|
-
]));
|
|
1726
|
-
const documentOrder = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
|
|
1727
|
-
cases.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || a.caseIndex - b.caseIndex);
|
|
1728
|
-
documents.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || (a.attemptIndex ?? 0) - (b.attemptIndex ?? 0));
|
|
1729
|
-
return buildProjectResult(prepared, cases, documents, lifecycle);
|
|
1730
|
-
};
|
|
1731
1983
|
const projectResults = new Array(preparedProjects.length);
|
|
1732
1984
|
try {
|
|
1733
|
-
await (
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1985
|
+
const states = await runPreparedFiles({
|
|
1986
|
+
projects: preparedProjects,
|
|
1987
|
+
maxConcurrentFiles: effectiveConcurrency,
|
|
1988
|
+
maxConcurrentProjects: definition.test.maxConcurrentProjects ?? 1 / 0,
|
|
1989
|
+
setupTimeoutMs: definition.test.setupTimeout,
|
|
1990
|
+
teardownTimeoutMs,
|
|
1991
|
+
signal: rootController.signal,
|
|
1992
|
+
runDir,
|
|
1993
|
+
preflightFailed: 'run' === plan.preflightScope && totalErrors > 0,
|
|
1994
|
+
shouldBail: bailReached,
|
|
1995
|
+
addFailedCases,
|
|
1996
|
+
announceProject,
|
|
1997
|
+
onProgress: (prepared, message)=>runInfrastructureCallback(()=>progress(effectiveConcurrency > 1 ? `[${prepared.project.name}]${message}` : message)),
|
|
1998
|
+
onInfrastructureError: recordInfrastructureError
|
|
1999
|
+
});
|
|
2000
|
+
states.forEach((state, index)=>{
|
|
2001
|
+
projectResults[index] = buildProjectResult(state.prepared, state.cases, state.documents, state.lifecycle);
|
|
1743
2002
|
});
|
|
1744
2003
|
} catch (error) {
|
|
1745
2004
|
recordInfrastructureError(error);
|