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