@midscene/test 1.15.3 → 1.15.4-beta-20261013090705.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/es/cli/index.mjs +1519 -350
- package/dist/es/cli/index.mjs.map +1 -1
- package/dist/es/index.mjs +20 -1
- package/dist/es/index.mjs.map +1 -1
- package/dist/es/midscene/index.mjs +2 -2
- package/dist/es/midscene/index.mjs.map +1 -1
- package/dist/es/test-cli.mjs +1264 -69
- package/dist/es/test-cli.mjs.map +1 -1
- package/dist/lib/cli/index.js +1530 -343
- package/dist/lib/cli/index.js.map +1 -1
- package/dist/lib/index.js +20 -1
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/midscene/index.js +3 -0
- package/dist/lib/midscene/index.js.map +1 -1
- package/dist/lib/test-cli.js +1264 -69
- package/dist/lib/test-cli.js.map +1 -1
- package/dist/types/cli/index.d.ts +556 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/midscene/index.d.ts +6 -2
- package/package.json +13 -13
package/dist/es/cli/index.mjs
CHANGED
|
@@ -1,292 +1,478 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve as external_node_path_resolve, sep } from "node:path";
|
|
4
4
|
import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runConcurrentJobs, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText } from "@midscene/core/internal/test-runner";
|
|
5
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
6
5
|
import { globSync } from "tinyglobby";
|
|
7
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
8
7
|
import { JSON_SCHEMA, load } from "js-yaml";
|
|
9
8
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import { require as api_require } from "tsx/cjs/api";
|
|
10
11
|
import { setMaxListeners } from "node:events";
|
|
11
12
|
import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
|
|
12
13
|
import { getDebug } from "@midscene/shared/logger";
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
'
|
|
117
|
-
'
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
include: [],
|
|
147
|
-
exclude: []
|
|
148
|
-
}),
|
|
149
|
-
retry: 0,
|
|
150
|
-
variables: Object.freeze({}),
|
|
151
|
-
nodes: new registry_NodeRegistry(globalNodes.definitions())
|
|
152
|
-
})
|
|
153
|
-
])
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
|
|
157
|
-
if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
|
|
158
|
-
const names = new Set();
|
|
159
|
-
const projects = value.map((candidate, index)=>{
|
|
160
|
-
const label = `projects[${index}]`;
|
|
161
|
-
if (!isRecord(candidate)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
162
|
-
rejectUnknownKeys(candidate, [
|
|
163
|
-
'name',
|
|
164
|
-
'setup',
|
|
165
|
-
'nodes',
|
|
166
|
-
'files',
|
|
167
|
-
'tags',
|
|
14
|
+
const isSafeResultPathSegment = (value)=>'string' == typeof value && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
|
|
15
|
+
const json = z.lazy(()=>z.union([
|
|
16
|
+
z["null"](),
|
|
17
|
+
z.string(),
|
|
18
|
+
z.number().finite(),
|
|
19
|
+
z.boolean(),
|
|
20
|
+
z.array(json),
|
|
21
|
+
z.record(z.string(), json)
|
|
22
|
+
]));
|
|
23
|
+
const time = z.string().refine((value)=>Number.isFinite(Date.parse(value)));
|
|
24
|
+
const executor_runtime_schema_count = z.number().int().nonnegative();
|
|
25
|
+
const executor_runtime_schema_value = z.strictObject({
|
|
26
|
+
value: json,
|
|
27
|
+
truncatedPaths: z.array(z.string()).optional(),
|
|
28
|
+
redactedPaths: z.array(z.string()).optional()
|
|
29
|
+
});
|
|
30
|
+
const reportErrorSchema = z.lazy(()=>z.strictObject({
|
|
31
|
+
format: z.literal('midscene-error').optional(),
|
|
32
|
+
name: z.string(),
|
|
33
|
+
message: z.string(),
|
|
34
|
+
code: z.string().optional(),
|
|
35
|
+
details: executor_runtime_schema_value.optional(),
|
|
36
|
+
cause: reportErrorSchema.optional(),
|
|
37
|
+
errors: z.array(reportErrorSchema).optional(),
|
|
38
|
+
truncated: z.boolean().optional()
|
|
39
|
+
}));
|
|
40
|
+
const executor_runtime_schema_operation = z.strictObject({
|
|
41
|
+
operationId: z.string().min(1),
|
|
42
|
+
scopeId: z.string().optional(),
|
|
43
|
+
stepId: z.string().optional(),
|
|
44
|
+
node: z.string().optional()
|
|
45
|
+
});
|
|
46
|
+
const cleanupSchema = z.strictObject({
|
|
47
|
+
status: z["enum"]([
|
|
48
|
+
'not-required',
|
|
49
|
+
'completed',
|
|
50
|
+
'pending',
|
|
51
|
+
'failed'
|
|
52
|
+
]),
|
|
53
|
+
capturedAt: time,
|
|
54
|
+
operationId: z.string().optional(),
|
|
55
|
+
blockingOperations: z.array(executor_runtime_schema_operation).optional(),
|
|
56
|
+
error: reportErrorSchema.optional()
|
|
57
|
+
});
|
|
58
|
+
const pendingOperationsSchema = z.array(executor_runtime_schema_operation);
|
|
59
|
+
const collectionStatus = z["enum"]([
|
|
60
|
+
'complete',
|
|
61
|
+
'partial',
|
|
62
|
+
'unknown'
|
|
63
|
+
]);
|
|
64
|
+
const metricsSchema = z.strictObject({
|
|
65
|
+
modelCallCount: executor_runtime_schema_count,
|
|
66
|
+
modelTimeMs: z.number().finite().nonnegative(),
|
|
67
|
+
promptTokens: executor_runtime_schema_count,
|
|
68
|
+
cachedInputTokens: executor_runtime_schema_count,
|
|
69
|
+
completionTokens: executor_runtime_schema_count,
|
|
70
|
+
totalTokens: executor_runtime_schema_count,
|
|
71
|
+
collection: z.strictObject({
|
|
72
|
+
status: collectionStatus,
|
|
73
|
+
expectedExecutionCount: executor_runtime_schema_count,
|
|
74
|
+
capturedExecutionCount: executor_runtime_schema_count,
|
|
75
|
+
reasons: z.array(z.string()),
|
|
76
|
+
fields: z.strictObject({
|
|
77
|
+
modelCallCount: collectionStatus,
|
|
78
|
+
modelTimeMs: collectionStatus,
|
|
79
|
+
totalTokens: collectionStatus
|
|
80
|
+
}).optional()
|
|
81
|
+
}).optional()
|
|
82
|
+
});
|
|
83
|
+
const operationsSchema = z.strictObject({
|
|
84
|
+
capturedAt: time,
|
|
85
|
+
events: z.array(z.strictObject({
|
|
86
|
+
operationId: z.string().min(1),
|
|
87
|
+
operationKind: z["enum"]([
|
|
88
|
+
'node',
|
|
89
|
+
'resource',
|
|
90
|
+
'acquisition',
|
|
91
|
+
'cleanup',
|
|
92
|
+
'readiness'
|
|
93
|
+
]),
|
|
94
|
+
kind: z["enum"]([
|
|
95
|
+
'started',
|
|
96
|
+
'cancel-requested',
|
|
97
|
+
'settled',
|
|
98
|
+
'runner-returned',
|
|
99
|
+
'cleanup-deferred'
|
|
100
|
+
]),
|
|
101
|
+
at: time,
|
|
102
|
+
stepId: z.string().optional(),
|
|
103
|
+
scopeId: z.string().optional(),
|
|
104
|
+
node: z.string().optional(),
|
|
105
|
+
status: z["enum"]([
|
|
106
|
+
'success',
|
|
107
|
+
'failed',
|
|
108
|
+
'not-run'
|
|
109
|
+
]).optional(),
|
|
110
|
+
pending: z.boolean().optional(),
|
|
111
|
+
error: reportErrorSchema.optional(),
|
|
112
|
+
blockingOperations: z.array(executor_runtime_schema_operation).optional()
|
|
113
|
+
}))
|
|
114
|
+
});
|
|
115
|
+
const runtimeResultFields = {
|
|
116
|
+
executionStatus: z["enum"]([
|
|
117
|
+
'success',
|
|
118
|
+
'failed',
|
|
119
|
+
'not-run'
|
|
120
|
+
]).optional(),
|
|
121
|
+
phases: z.partialRecord(z["enum"]([
|
|
122
|
+
'beforeAll',
|
|
123
|
+
'beforeEach',
|
|
124
|
+
'steps',
|
|
125
|
+
'afterEach',
|
|
126
|
+
'afterAll'
|
|
127
|
+
]), z.strictObject({
|
|
128
|
+
status: z["enum"]([
|
|
129
|
+
'success',
|
|
130
|
+
'failed',
|
|
131
|
+
'not-run',
|
|
132
|
+
'not-required'
|
|
133
|
+
]),
|
|
134
|
+
plannedStepCount: executor_runtime_schema_count,
|
|
135
|
+
executedStepCount: executor_runtime_schema_count
|
|
136
|
+
})).optional(),
|
|
137
|
+
cleanup: cleanupSchema.optional(),
|
|
138
|
+
pendingOperations: pendingOperationsSchema.optional(),
|
|
139
|
+
retryDecision: z.strictObject({
|
|
140
|
+
scope: z["enum"]([
|
|
141
|
+
'case',
|
|
142
|
+
'document'
|
|
143
|
+
]),
|
|
144
|
+
retryCount: executor_runtime_schema_count,
|
|
145
|
+
attemptIndex: executor_runtime_schema_count,
|
|
146
|
+
decision: z["enum"]([
|
|
168
147
|
'retry',
|
|
169
|
-
'
|
|
170
|
-
],
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
nodes,
|
|
185
|
-
...files ? {
|
|
186
|
-
files
|
|
187
|
-
} : {},
|
|
188
|
-
tags: validateTagSelection(candidate.tags, `${label}.tags`),
|
|
189
|
-
retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
|
|
190
|
-
variables: validateVariables(candidate.variables, `${label}.variables`),
|
|
191
|
-
...void 0 === candidate.setup ? {} : {
|
|
192
|
-
setup: validateSetup(candidate.setup, `${label}.setup`)
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
return {
|
|
197
|
-
projects: Object.freeze(projects),
|
|
198
|
-
hasExplicitProjects: true
|
|
199
|
-
};
|
|
200
|
-
};
|
|
201
|
-
const validateTestOptions = (value)=>{
|
|
202
|
-
if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
|
|
203
|
-
const candidate = value ?? {};
|
|
204
|
-
return Object.freeze({
|
|
205
|
-
maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
|
|
206
|
-
bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
|
|
207
|
-
testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
|
|
208
|
-
});
|
|
148
|
+
'stop'
|
|
149
|
+
]),
|
|
150
|
+
reason: z["enum"]([
|
|
151
|
+
'passed',
|
|
152
|
+
'attempt-failed',
|
|
153
|
+
'budget-exhausted',
|
|
154
|
+
'cleanup-pending',
|
|
155
|
+
'cleanup-failed',
|
|
156
|
+
'operation-pending',
|
|
157
|
+
'fatal-error',
|
|
158
|
+
'interrupted',
|
|
159
|
+
'bail',
|
|
160
|
+
'infrastructure-error'
|
|
161
|
+
])
|
|
162
|
+
}).optional()
|
|
209
163
|
};
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
164
|
+
const plannedStep = z.strictObject({
|
|
165
|
+
id: z.string(),
|
|
166
|
+
phase: z["enum"]([
|
|
167
|
+
'beforeAll',
|
|
168
|
+
'beforeEach',
|
|
169
|
+
'steps',
|
|
170
|
+
'afterEach',
|
|
171
|
+
'afterAll'
|
|
172
|
+
]),
|
|
173
|
+
stepIndex: executor_runtime_schema_count,
|
|
174
|
+
node: z.string(),
|
|
175
|
+
title: z.string().optional(),
|
|
176
|
+
input: executor_runtime_schema_value,
|
|
177
|
+
inputMode: z["enum"]([
|
|
178
|
+
'template',
|
|
179
|
+
'resolved'
|
|
180
|
+
]),
|
|
181
|
+
timeoutMs: z.number().positive().optional(),
|
|
182
|
+
continueOnError: z.boolean()
|
|
183
|
+
});
|
|
184
|
+
const documentPlanSchema = z.strictObject({
|
|
185
|
+
documentId: z.string(),
|
|
186
|
+
sourcePath: z.string(),
|
|
187
|
+
sourceYaml: executor_runtime_schema_value.optional(),
|
|
188
|
+
retry: z.strictObject({
|
|
189
|
+
scope: z["enum"]([
|
|
190
|
+
'case',
|
|
191
|
+
'document'
|
|
192
|
+
]),
|
|
193
|
+
count: executor_runtime_schema_count
|
|
194
|
+
}),
|
|
195
|
+
beforeAll: z.array(plannedStep),
|
|
196
|
+
afterAll: z.array(plannedStep),
|
|
197
|
+
cases: z.array(z.strictObject({
|
|
198
|
+
caseId: z.string(),
|
|
199
|
+
name: z.string(),
|
|
200
|
+
caseIndex: executor_runtime_schema_count,
|
|
201
|
+
selected: z.boolean(),
|
|
202
|
+
tags: z.array(z.string()),
|
|
203
|
+
onFailure: z["enum"]([
|
|
204
|
+
'continue',
|
|
205
|
+
'stop-document'
|
|
206
|
+
]).optional(),
|
|
207
|
+
beforeEach: z.array(plannedStep),
|
|
208
|
+
steps: z.array(plannedStep),
|
|
209
|
+
afterEach: z.array(plannedStep)
|
|
210
|
+
}))
|
|
211
|
+
});
|
|
212
|
+
function _define_property(obj, key, value) {
|
|
213
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
214
|
+
value: value,
|
|
215
|
+
enumerable: true,
|
|
216
|
+
configurable: true,
|
|
217
|
+
writable: true
|
|
223
218
|
});
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
'
|
|
235
|
-
'
|
|
236
|
-
|
|
237
|
-
'output',
|
|
238
|
-
'nodes'
|
|
239
|
-
], 'root');
|
|
240
|
-
const nodes = new registry_NodeRegistry(definition.nodes);
|
|
241
|
-
const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
|
|
242
|
-
return {
|
|
243
|
-
...resolvedProjects,
|
|
244
|
-
test: validateTestOptions(definition.test),
|
|
245
|
-
output: validateOutput(definition.output),
|
|
246
|
-
nodes,
|
|
247
|
-
resolveNode: (name)=>nodes.get(name)
|
|
248
|
-
};
|
|
249
|
-
};
|
|
250
|
-
const assertSupportedConfig = (absolutePath)=>{
|
|
251
|
-
if (!absolutePath.endsWith('.ts') && !absolutePath.endsWith('.mjs')) {
|
|
252
|
-
const extension = absolutePath.match(/(\.[^./\\]+)$/)?.[1] ?? '(none)';
|
|
253
|
-
throw new TypeError(`Unsupported Midscene config extension: ${extension}. Supported extensions: .ts, .mjs.`);
|
|
219
|
+
else obj[key] = value;
|
|
220
|
+
return obj;
|
|
221
|
+
}
|
|
222
|
+
const executorErrorKey = Symbol.for('@midscene/test/executor-error');
|
|
223
|
+
let _executorErrorKey = executorErrorKey;
|
|
224
|
+
class TestExecutorError extends Error {
|
|
225
|
+
constructor(message, options = {}){
|
|
226
|
+
super(message, {
|
|
227
|
+
cause: options.cause
|
|
228
|
+
}), _define_property(this, _executorErrorKey, true), _define_property(this, "kind", void 0), _define_property(this, "retryable", void 0);
|
|
229
|
+
this.name = 'TestExecutorError';
|
|
230
|
+
this.kind = options.kind ?? 'unknown';
|
|
231
|
+
this.retryable = options.retryable ?? false;
|
|
254
232
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
233
|
+
}
|
|
234
|
+
function isTestExecutorError(value) {
|
|
235
|
+
return value instanceof Error && true === Reflect.get(value, executorErrorKey) && [
|
|
236
|
+
'provision',
|
|
237
|
+
'transport',
|
|
238
|
+
'cleanup',
|
|
239
|
+
'report',
|
|
240
|
+
'unknown'
|
|
241
|
+
].includes(Reflect.get(value, 'kind')) && 'boolean' == typeof Reflect.get(value, 'retryable');
|
|
242
|
+
}
|
|
243
|
+
const defineTestExecutor = (executor)=>executor;
|
|
244
|
+
const localTestExecutor = Object.freeze({
|
|
245
|
+
name: 'local',
|
|
246
|
+
execute: (_task, context)=>context.runLocal()
|
|
247
|
+
});
|
|
248
|
+
const jsonValueSchema = z.lazy(()=>z.union([
|
|
249
|
+
z["null"](),
|
|
250
|
+
z.string(),
|
|
251
|
+
z.number().finite(),
|
|
252
|
+
z.boolean(),
|
|
253
|
+
z.array(jsonValueSchema),
|
|
254
|
+
z.record(z.string(), jsonValueSchema)
|
|
255
|
+
]));
|
|
256
|
+
const timestampSchema = z.string().refine((value)=>Number.isFinite(Date.parse(value)), {
|
|
257
|
+
message: 'Expected an ISO-compatible timestamp.'
|
|
258
|
+
});
|
|
259
|
+
const errorSchema = z.strictObject({
|
|
260
|
+
format: z.literal('midscene-error').optional(),
|
|
261
|
+
cause: reportErrorSchema.optional(),
|
|
262
|
+
name: z.string().min(1),
|
|
263
|
+
message: z.string(),
|
|
264
|
+
code: z.string().min(1),
|
|
265
|
+
details: jsonValueSchema.optional()
|
|
266
|
+
});
|
|
267
|
+
const stepBaseSchema = z.strictObject({
|
|
268
|
+
phase: z["enum"]([
|
|
269
|
+
'beforeAll',
|
|
270
|
+
'beforeEach',
|
|
271
|
+
'steps',
|
|
272
|
+
'afterEach',
|
|
273
|
+
'afterAll'
|
|
274
|
+
]),
|
|
275
|
+
stepIndex: z.number().int().nonnegative(),
|
|
276
|
+
node: z.string().min(1),
|
|
277
|
+
input: jsonValueSchema,
|
|
278
|
+
meta: z.strictObject({
|
|
279
|
+
timeoutMs: z.number().positive().finite().optional(),
|
|
280
|
+
continueOnError: z.boolean()
|
|
281
|
+
}),
|
|
282
|
+
continuedAfterError: z.boolean(),
|
|
283
|
+
waitForPreviousOperationMs: z.number().nonnegative().finite().optional(),
|
|
284
|
+
operations: operationsSchema.optional(),
|
|
285
|
+
output: z.strictObject({
|
|
286
|
+
summary: z.string().optional(),
|
|
287
|
+
data: jsonValueSchema.optional()
|
|
288
|
+
}).optional(),
|
|
289
|
+
error: errorSchema.optional(),
|
|
290
|
+
report: z.strictObject({
|
|
291
|
+
traces: z.array(z.strictObject({
|
|
292
|
+
type: z.literal('midscene-execution'),
|
|
293
|
+
executionId: z.string().min(1),
|
|
294
|
+
metrics: metricsSchema.optional()
|
|
295
|
+
})),
|
|
296
|
+
collectionErrors: z.array(errorSchema).optional()
|
|
297
|
+
}).optional()
|
|
298
|
+
});
|
|
299
|
+
const stepSchema = z.discriminatedUnion('status', [
|
|
300
|
+
stepBaseSchema.extend({
|
|
301
|
+
status: z["enum"]([
|
|
302
|
+
'success',
|
|
303
|
+
'failed'
|
|
304
|
+
]),
|
|
305
|
+
startedAt: timestampSchema,
|
|
306
|
+
endedAt: timestampSchema,
|
|
307
|
+
durationMs: z.number().nonnegative().finite()
|
|
308
|
+
}),
|
|
309
|
+
stepBaseSchema.extend({
|
|
310
|
+
status: z.literal('not-run'),
|
|
311
|
+
notRunReason: z.string().min(1),
|
|
312
|
+
blockedBy: z.string().optional()
|
|
313
|
+
})
|
|
314
|
+
]);
|
|
315
|
+
const validatedStepSchema = stepSchema.superRefine((step, context)=>{
|
|
316
|
+
if ('success' === step.status && (step.error || step.continuedAfterError)) context.addIssue({
|
|
317
|
+
code: 'custom',
|
|
318
|
+
message: 'Successful steps cannot contain failure evidence.'
|
|
319
|
+
});
|
|
320
|
+
if (step.continuedAfterError && !step.meta.continueOnError) context.addIssue({
|
|
321
|
+
code: 'custom',
|
|
322
|
+
message: 'Continuation requires continueOnError.'
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
const attemptSchema = z.strictObject({
|
|
326
|
+
...runtimeResultFields,
|
|
327
|
+
caseId: z.string().min(1),
|
|
328
|
+
runId: z.string().min(1),
|
|
329
|
+
projectName: z.string().min(1),
|
|
330
|
+
attemptIndex: z.number().int().nonnegative(),
|
|
331
|
+
name: z.string(),
|
|
332
|
+
sourcePath: z.string().min(1),
|
|
333
|
+
caseIndex: z.number().int().nonnegative(),
|
|
334
|
+
status: z["enum"]([
|
|
335
|
+
'success',
|
|
336
|
+
'failed'
|
|
337
|
+
]),
|
|
338
|
+
beforeEach: z.array(validatedStepSchema),
|
|
339
|
+
steps: z.array(validatedStepSchema),
|
|
340
|
+
afterEach: z.array(validatedStepSchema),
|
|
341
|
+
executionErrors: z.array(errorSchema).optional(),
|
|
342
|
+
teardownErrors: z.array(errorSchema).optional(),
|
|
343
|
+
reportRefs: z.array(z.string().min(1)).optional(),
|
|
344
|
+
startedAt: timestampSchema,
|
|
345
|
+
endedAt: timestampSchema,
|
|
346
|
+
durationMs: z.number().nonnegative().finite()
|
|
347
|
+
});
|
|
348
|
+
const notRunReasonSchema = z["enum"]([
|
|
349
|
+
'document-start-failed',
|
|
350
|
+
'project-preflight-failed',
|
|
351
|
+
'project-setup-failed',
|
|
352
|
+
'interrupted',
|
|
353
|
+
'bail',
|
|
354
|
+
'fatal-error',
|
|
355
|
+
'executor-failed',
|
|
356
|
+
'cleanup-pending',
|
|
357
|
+
'cleanup-failed',
|
|
358
|
+
'operation-pending'
|
|
359
|
+
]);
|
|
360
|
+
const resultSchema = z.strictObject({
|
|
361
|
+
case: z.strictObject({
|
|
362
|
+
documentId: z.string().min(1),
|
|
363
|
+
caseId: z.string().min(1),
|
|
364
|
+
projectName: z.string().min(1),
|
|
365
|
+
name: z.string(),
|
|
366
|
+
sourcePath: z.string().min(1),
|
|
367
|
+
caseIndex: z.number().int().nonnegative(),
|
|
368
|
+
status: z["enum"]([
|
|
369
|
+
'success',
|
|
370
|
+
'failed',
|
|
371
|
+
'not-run'
|
|
372
|
+
]),
|
|
373
|
+
run: attemptSchema.optional(),
|
|
374
|
+
attempts: z.array(attemptSchema).optional(),
|
|
375
|
+
notRunReason: notRunReasonSchema.optional()
|
|
376
|
+
}),
|
|
377
|
+
document: z.strictObject({
|
|
378
|
+
...runtimeResultFields,
|
|
379
|
+
plan: documentPlanSchema.optional(),
|
|
380
|
+
attemptIndex: z.number().int().nonnegative().optional(),
|
|
381
|
+
hostErrors: z.array(z.strictObject({
|
|
382
|
+
phase: z["enum"]([
|
|
383
|
+
'setup',
|
|
384
|
+
'execution',
|
|
385
|
+
'cleanup',
|
|
386
|
+
'report',
|
|
387
|
+
'publication',
|
|
388
|
+
'observer'
|
|
389
|
+
]),
|
|
390
|
+
error: reportErrorSchema
|
|
391
|
+
})).optional(),
|
|
392
|
+
documentId: z.string().min(1),
|
|
393
|
+
documentRunId: z.string().min(1),
|
|
394
|
+
projectId: z.string().min(1),
|
|
395
|
+
projectName: z.string().min(1),
|
|
396
|
+
sourcePath: z.string().min(1),
|
|
397
|
+
status: z["enum"]([
|
|
398
|
+
'success',
|
|
399
|
+
'failed'
|
|
400
|
+
]),
|
|
401
|
+
startedAt: timestampSchema,
|
|
402
|
+
endedAt: timestampSchema,
|
|
403
|
+
durationMs: z.number().nonnegative().finite(),
|
|
404
|
+
beforeAll: z.array(validatedStepSchema),
|
|
405
|
+
afterAll: z.array(validatedStepSchema),
|
|
406
|
+
executionErrors: z.array(errorSchema).optional(),
|
|
407
|
+
teardownErrors: z.array(errorSchema).optional(),
|
|
408
|
+
reportRefs: z.array(z.string().min(1)).optional()
|
|
409
|
+
}).optional(),
|
|
410
|
+
lifecycle: z.strictObject({
|
|
411
|
+
cleanup: cleanupSchema.optional(),
|
|
412
|
+
pendingOperations: pendingOperationsSchema.optional(),
|
|
413
|
+
projectName: z.string().min(1),
|
|
414
|
+
status: z["enum"]([
|
|
415
|
+
'success',
|
|
416
|
+
'failed'
|
|
417
|
+
]),
|
|
418
|
+
setupError: errorSchema.optional(),
|
|
419
|
+
teardownErrors: z.array(errorSchema).optional(),
|
|
420
|
+
startedAt: timestampSchema,
|
|
421
|
+
endedAt: timestampSchema,
|
|
422
|
+
durationMs: z.number().nonnegative().finite()
|
|
423
|
+
}).optional(),
|
|
424
|
+
artifacts: z.array(z.strictObject({
|
|
425
|
+
name: z.string().min(1),
|
|
426
|
+
uri: z.string().min(1),
|
|
427
|
+
mediaType: z.string().min(1).optional()
|
|
428
|
+
})).optional(),
|
|
429
|
+
metadata: z.record(z.string(), z.union([
|
|
430
|
+
z.string(),
|
|
431
|
+
z.number().finite(),
|
|
432
|
+
z.boolean(),
|
|
433
|
+
z["null"]()
|
|
434
|
+
])).optional()
|
|
435
|
+
});
|
|
436
|
+
const attemptMatchesTask = (task, attempt)=>attempt.caseId === task.caseId && attempt.projectName === task.projectName && attempt.name === task.caseName && attempt.sourcePath === task.sourcePath && attempt.caseIndex === task.caseIndex && isSafeResultPathSegment(attempt.runId);
|
|
437
|
+
const hasUnfinishedWork = (result)=>void 0 !== result.executionStatus && 'success' !== result.executionStatus || void 0 !== result.cleanup && ![
|
|
438
|
+
'completed',
|
|
439
|
+
'not-required'
|
|
440
|
+
].includes(result.cleanup.status) || (result.pendingOperations?.length ?? 0) > 0;
|
|
441
|
+
function assertTestCaseTaskRunResult(task, value) {
|
|
442
|
+
const parsed = resultSchema.safeParse(value);
|
|
443
|
+
if (!parsed.success) throw new TypeError(`Executor returned invalid transport data for ${task.caseId}: ${z.prettifyError(parsed.error)}`);
|
|
444
|
+
const outcome = parsed.data.case;
|
|
445
|
+
if (outcome.caseId !== task.caseId || outcome.documentId !== task.documentId || outcome.projectName !== task.projectName || outcome.name !== task.caseName || outcome.sourcePath !== task.sourcePath || outcome.caseIndex !== task.caseIndex) throw new Error(`Executor returned a result that does not match task ${task.caseId}.`);
|
|
446
|
+
const attempts = outcome.attempts ?? [];
|
|
447
|
+
for (const attempt of attempts){
|
|
448
|
+
const steps = [
|
|
449
|
+
...attempt.beforeEach,
|
|
450
|
+
...attempt.steps,
|
|
451
|
+
...attempt.afterEach
|
|
452
|
+
];
|
|
453
|
+
if ('success' === attempt.status && (0 === attempt.steps.length || (attempt.executionErrors?.length ?? 0) > 0 || steps.some((step)=>'success' !== step.status) || hasUnfinishedWork(attempt) || (attempt.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful attempt for ${task.caseId}.`);
|
|
288
454
|
}
|
|
289
|
-
|
|
455
|
+
if (new Set(attempts.map((attempt)=>attempt.runId)).size !== attempts.length) throw new TypeError(`Executor returned duplicate attempt IDs for ${task.caseId}.`);
|
|
456
|
+
if (attempts.some((attempt)=>!attemptMatchesTask(task, attempt))) throw new TypeError(`Executor returned invalid attempts for ${task.caseId}.`);
|
|
457
|
+
if (attempts.some((attempt, index)=>attempt.attemptIndex !== index) || attempts.length > task.retry + 1) throw new TypeError(`Executor returned a non-sequential attempt history for ${task.caseId}.`);
|
|
458
|
+
const finalAttempt = attempts.at(-1);
|
|
459
|
+
if ('not-run' === outcome.status) {
|
|
460
|
+
if (outcome.run || attempts.length || !outcome.notRunReason) throw new TypeError(`Executor returned an inconsistent not-run result for ${task.caseId}.`);
|
|
461
|
+
} else if (void 0 !== outcome.notRunReason || !outcome.run || !finalAttempt || finalAttempt.status !== outcome.status || outcome.run.runId !== finalAttempt.runId || JSON.stringify(outcome.run) !== JSON.stringify(finalAttempt) || attempts.slice(0, -1).some((attempt)=>'failed' !== attempt.status)) throw new TypeError(`Executor returned an inconsistent ${outcome.status} result for ${task.caseId}.`);
|
|
462
|
+
const reportRefs = [
|
|
463
|
+
...attempts.flatMap((attempt)=>attempt.reportRefs ?? []),
|
|
464
|
+
...parsed.data.document?.reportRefs ?? []
|
|
465
|
+
];
|
|
466
|
+
if (reportRefs.some((reference)=>!isSafeResultPathSegment(reference))) throw new TypeError(`Executor returned an invalid report reference for ${task.caseId}.`);
|
|
467
|
+
const document = parsed.data.document;
|
|
468
|
+
if (document?.status === 'success' && (hasUnfinishedWork(document) || (document.hostErrors?.length ?? 0) > 0 || (document.executionErrors?.length ?? 0) > 0 || [
|
|
469
|
+
...document.beforeAll,
|
|
470
|
+
...document.afterAll
|
|
471
|
+
].some((step)=>'success' !== step.status) || (document.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful document for ${task.caseId}.`);
|
|
472
|
+
const lifecycle = parsed.data.lifecycle;
|
|
473
|
+
if (lifecycle?.status === 'success' && (hasUnfinishedWork(lifecycle) || lifecycle.setupError || (lifecycle.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful lifecycle for ${task.caseId}.`);
|
|
474
|
+
if (document && (document.documentId !== task.documentId || document.projectId !== task.projectId || document.projectName !== task.projectName || document.sourcePath !== task.sourcePath || !isSafeResultPathSegment(document.documentRunId))) throw new Error(`Executor returned a document that does not match task ${task.caseId}.`);
|
|
475
|
+
if (parsed.data.lifecycle && parsed.data.lifecycle.projectName !== task.projectName) throw new Error(`Executor returned a lifecycle that does not match task ${task.caseId}.`);
|
|
290
476
|
}
|
|
291
477
|
const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
|
|
292
478
|
projectId,
|
|
@@ -410,7 +596,7 @@ const resolveWorkflowVariables = (value, options)=>{
|
|
|
410
596
|
]));
|
|
411
597
|
return value;
|
|
412
598
|
};
|
|
413
|
-
function
|
|
599
|
+
function collect_define_property(obj, key, value) {
|
|
414
600
|
if (key in obj) Object.defineProperty(obj, key, {
|
|
415
601
|
value: value,
|
|
416
602
|
enumerable: true,
|
|
@@ -427,11 +613,11 @@ class NativeWorkflowFormatError extends WorkflowParseError {
|
|
|
427
613
|
sourcePath,
|
|
428
614
|
actualFormat,
|
|
429
615
|
expectedFormat: 'native'
|
|
430
|
-
}),
|
|
616
|
+
}), collect_define_property(this, "actualFormat", void 0);
|
|
431
617
|
this.actualFormat = actualFormat;
|
|
432
618
|
}
|
|
433
619
|
}
|
|
434
|
-
const
|
|
620
|
+
const rejectUnknownKeys = (value, allowed, label)=>{
|
|
435
621
|
const allowedSet = new Set(allowed);
|
|
436
622
|
const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
|
|
437
623
|
if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
|
|
@@ -453,7 +639,7 @@ function collectWorkflowDocument(source, options) {
|
|
|
453
639
|
if (!isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
|
|
454
640
|
const format = classifyWorkflowDocumentFormat(parsed);
|
|
455
641
|
if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
|
|
456
|
-
|
|
642
|
+
rejectUnknownKeys(parsed, [
|
|
457
643
|
'beforeAll',
|
|
458
644
|
'beforeEach',
|
|
459
645
|
'cases',
|
|
@@ -506,11 +692,16 @@ function collectWorkflowDocument(source, options) {
|
|
|
506
692
|
if (!isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
|
|
507
693
|
caseIndex
|
|
508
694
|
});
|
|
509
|
-
|
|
695
|
+
rejectUnknownKeys(definition, [
|
|
696
|
+
'id',
|
|
510
697
|
'name',
|
|
511
698
|
'tags',
|
|
699
|
+
'resources',
|
|
512
700
|
'steps'
|
|
513
701
|
], `Case ${caseIndex + 1}`);
|
|
702
|
+
if (void 0 !== definition.id && !isSafeResultPathSegment(definition.id)) throw new WorkflowParseError(`Case ${caseIndex + 1} id must start with an ASCII letter or number, contain only letters, numbers, dots, underscores, or hyphens, and be at most 128 characters.`, {
|
|
703
|
+
caseIndex
|
|
704
|
+
});
|
|
514
705
|
if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
|
|
515
706
|
caseIndex
|
|
516
707
|
});
|
|
@@ -521,6 +712,13 @@ function collectWorkflowDocument(source, options) {
|
|
|
521
712
|
if (!Array.isArray(tags) || tags.some((tag)=>'string' != typeof tag || 0 === tag.trim().length)) throw new WorkflowParseError(`Case ${caseIndex + 1} tags must be an array of non-empty strings.`, {
|
|
522
713
|
caseIndex
|
|
523
714
|
});
|
|
715
|
+
const resources = definition.resources ?? [];
|
|
716
|
+
if (!Array.isArray(resources) || resources.some((resource)=>'string' != typeof resource || 0 === resource.trim().length)) throw new WorkflowParseError(`Case ${caseIndex + 1} resources must be an array of non-empty strings.`, {
|
|
717
|
+
caseIndex
|
|
718
|
+
});
|
|
719
|
+
if (new Set(resources).size !== resources.length) throw new WorkflowParseError(`Case ${caseIndex + 1} resources must not contain duplicates.`, {
|
|
720
|
+
caseIndex
|
|
721
|
+
});
|
|
524
722
|
const steps = normalize_normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
|
|
525
723
|
if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
|
|
526
724
|
caseIndex,
|
|
@@ -529,7 +727,7 @@ function collectWorkflowDocument(source, options) {
|
|
|
529
727
|
});
|
|
530
728
|
return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
|
|
531
729
|
});
|
|
532
|
-
const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
|
|
730
|
+
const caseId = 'string' == typeof definition.id ? definition.id : createCaseId(source.projectId, sourcePath, caseIndex);
|
|
533
731
|
if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
|
|
534
732
|
caseId
|
|
535
733
|
});
|
|
@@ -540,8 +738,14 @@ function collectWorkflowDocument(source, options) {
|
|
|
540
738
|
sourcePath,
|
|
541
739
|
caseIndex,
|
|
542
740
|
definition: {
|
|
741
|
+
...'string' == typeof definition.id ? {
|
|
742
|
+
id: definition.id
|
|
743
|
+
} : {},
|
|
543
744
|
name: definition.name,
|
|
544
745
|
tags: tags,
|
|
746
|
+
...resources.length > 0 ? {
|
|
747
|
+
resources: resources
|
|
748
|
+
} : {},
|
|
545
749
|
steps
|
|
546
750
|
}
|
|
547
751
|
};
|
|
@@ -681,6 +885,38 @@ const writeTestProjectRunResult = (options)=>{
|
|
|
681
885
|
...outcome.notRunReason ? {
|
|
682
886
|
notRunReason: outcome.notRunReason
|
|
683
887
|
} : {},
|
|
888
|
+
...outcome.execution ? {
|
|
889
|
+
execution: {
|
|
890
|
+
executor: outcome.execution.executor,
|
|
891
|
+
resources: outcome.execution.resources,
|
|
892
|
+
...void 0 === outcome.execution.attempts ? {} : {
|
|
893
|
+
attempts: outcome.execution.attempts
|
|
894
|
+
},
|
|
895
|
+
...outcome.execution.failure ? {
|
|
896
|
+
failure: outcome.execution.failure
|
|
897
|
+
} : {},
|
|
898
|
+
...outcome.execution.artifacts ? {
|
|
899
|
+
artifacts: outcome.execution.artifacts
|
|
900
|
+
} : {},
|
|
901
|
+
...outcome.execution.metadata ? {
|
|
902
|
+
metadata: outcome.execution.metadata
|
|
903
|
+
} : {},
|
|
904
|
+
...outcome.execution.lifecycle ? {
|
|
905
|
+
lifecycle: {
|
|
906
|
+
status: outcome.execution.lifecycle.status,
|
|
907
|
+
startedAt: outcome.execution.lifecycle.startedAt,
|
|
908
|
+
endedAt: outcome.execution.lifecycle.endedAt,
|
|
909
|
+
durationMs: outcome.execution.lifecycle.durationMs,
|
|
910
|
+
...outcome.execution.lifecycle.setupError ? {
|
|
911
|
+
setupError: errorJson(outcome.execution.lifecycle.setupError)
|
|
912
|
+
} : {},
|
|
913
|
+
...outcome.execution.lifecycle.teardownErrors ? {
|
|
914
|
+
teardownErrors: outcome.execution.lifecycle.teardownErrors.map(errorJson)
|
|
915
|
+
} : {}
|
|
916
|
+
}
|
|
917
|
+
} : {}
|
|
918
|
+
}
|
|
919
|
+
} : {},
|
|
684
920
|
...outcome.attempts ? {
|
|
685
921
|
attempts: outcome.attempts.map((attempt)=>({
|
|
686
922
|
attemptId: attempt.runId,
|
|
@@ -737,6 +973,306 @@ const writeTestProjectRunResult = (options)=>{
|
|
|
737
973
|
}))
|
|
738
974
|
});
|
|
739
975
|
};
|
|
976
|
+
const registry_NodeRegistry = NodeRegistry;
|
|
977
|
+
const projectIdFromIndex = (index)=>`project-${index}`;
|
|
978
|
+
const defineTestProject = (definition)=>definition;
|
|
979
|
+
const defineProjectSetup = (definition)=>definition;
|
|
980
|
+
const getDefaultExport = (loaded, absolutePath)=>{
|
|
981
|
+
if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
|
|
982
|
+
const defaultExport = loaded.default;
|
|
983
|
+
if ('object' == typeof defaultExport && null !== defaultExport && '__esModule' in defaultExport) {
|
|
984
|
+
if ('default' in defaultExport) return defaultExport.default;
|
|
985
|
+
throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
|
|
986
|
+
}
|
|
987
|
+
return defaultExport;
|
|
988
|
+
}
|
|
989
|
+
throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
|
|
990
|
+
};
|
|
991
|
+
const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
992
|
+
const test_project_rejectUnknownKeys = (value, allowedKeys, label)=>{
|
|
993
|
+
const allowed = new Set(allowedKeys);
|
|
994
|
+
const unknown = Object.keys(value).find((key)=>!allowed.has(key));
|
|
995
|
+
if (unknown) throw new TypeError(`Midscene config ${label}.${unknown} is not supported.`);
|
|
996
|
+
};
|
|
997
|
+
const isAbsolutePattern = (pattern)=>isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern) || pattern.startsWith('\\\\');
|
|
998
|
+
const validatePatterns = (value, field, label = 'files', allowEmptyInclude = false)=>{
|
|
999
|
+
if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
|
|
1000
|
+
if ('include' === field && 0 === value.length && !allowEmptyInclude) throw new TypeError(`Midscene config ${label}.include must be a non-empty array.`);
|
|
1001
|
+
return value.map((pattern, index)=>{
|
|
1002
|
+
if ('string' != typeof pattern || 0 === pattern.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
|
|
1003
|
+
if (isAbsolutePattern(pattern)) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be relative to the project root.`);
|
|
1004
|
+
if (pattern.split(/[\\/]/).includes('..')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must not contain a ".." path segment.`);
|
|
1005
|
+
if (pattern.includes('\\')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must use POSIX path separators (/).`);
|
|
1006
|
+
if (pattern.startsWith('!')) {
|
|
1007
|
+
if ('include' === field) throw new TypeError(`Midscene config ${label}.include[${index}] must not be a negated pattern. Use files.exclude instead.`);
|
|
1008
|
+
throw new TypeError(`Midscene config ${label}.exclude[${index}] must not be a negated pattern.`);
|
|
1009
|
+
}
|
|
1010
|
+
return pattern;
|
|
1011
|
+
});
|
|
1012
|
+
};
|
|
1013
|
+
const validateTestFileSelection = (value, label = 'files')=>{
|
|
1014
|
+
if (void 0 === value) return;
|
|
1015
|
+
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
1016
|
+
test_project_rejectUnknownKeys(value, [
|
|
1017
|
+
'include',
|
|
1018
|
+
'exclude'
|
|
1019
|
+
], label);
|
|
1020
|
+
const include = validatePatterns(value.include, 'include', label);
|
|
1021
|
+
const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
|
|
1022
|
+
return Object.freeze({
|
|
1023
|
+
include: Object.freeze(include),
|
|
1024
|
+
...exclude ? {
|
|
1025
|
+
exclude: Object.freeze(exclude)
|
|
1026
|
+
} : {}
|
|
1027
|
+
});
|
|
1028
|
+
};
|
|
1029
|
+
const validateTagList = (value, field, label)=>{
|
|
1030
|
+
if (void 0 === value) return [];
|
|
1031
|
+
if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
|
|
1032
|
+
return value.map((tag, index)=>{
|
|
1033
|
+
if ('string' != typeof tag || 0 === tag.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
|
|
1034
|
+
return tag;
|
|
1035
|
+
});
|
|
1036
|
+
};
|
|
1037
|
+
const validateTagSelection = (value, label)=>{
|
|
1038
|
+
if (void 0 === value) return Object.freeze({
|
|
1039
|
+
include: [],
|
|
1040
|
+
exclude: []
|
|
1041
|
+
});
|
|
1042
|
+
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
1043
|
+
return Object.freeze({
|
|
1044
|
+
include: Object.freeze(validateTagList(value.include, 'include', label)),
|
|
1045
|
+
exclude: Object.freeze(validateTagList(value.exclude, 'exclude', label))
|
|
1046
|
+
});
|
|
1047
|
+
};
|
|
1048
|
+
function assertJsonValue(value, path, seen) {
|
|
1049
|
+
if (null === value || 'string' == typeof value || 'boolean' == typeof value) return;
|
|
1050
|
+
if ('number' == typeof value) {
|
|
1051
|
+
if (Number.isFinite(value)) return;
|
|
1052
|
+
throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
|
|
1053
|
+
}
|
|
1054
|
+
if ('object' != typeof value || void 0 === value) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
|
|
1055
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && null !== Object.getPrototypeOf(value)) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
|
|
1056
|
+
if (seen.has(value)) throw new TypeError(`Midscene config ${path} must not contain cycles.`);
|
|
1057
|
+
seen.add(value);
|
|
1058
|
+
if (Array.isArray(value)) value.forEach((item, index)=>assertJsonValue(item, `${path}[${index}]`, seen));
|
|
1059
|
+
else for (const [key, child] of Object.entries(value))assertJsonValue(child, `${path}.${key}`, seen);
|
|
1060
|
+
seen.delete(value);
|
|
1061
|
+
}
|
|
1062
|
+
const deepFreezeJson = (value)=>{
|
|
1063
|
+
if ('object' == typeof value && null !== value) {
|
|
1064
|
+
for (const child of Object.values(value))deepFreezeJson(child);
|
|
1065
|
+
Object.freeze(value);
|
|
1066
|
+
}
|
|
1067
|
+
return value;
|
|
1068
|
+
};
|
|
1069
|
+
const validateVariables = (value, label)=>{
|
|
1070
|
+
if (void 0 === value) return Object.freeze({});
|
|
1071
|
+
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
1072
|
+
assertJsonValue(value, label, new Set());
|
|
1073
|
+
return deepFreezeJson(value);
|
|
1074
|
+
};
|
|
1075
|
+
const validateSetup = (value, label)=>{
|
|
1076
|
+
if (void 0 === value) return;
|
|
1077
|
+
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
1078
|
+
test_project_rejectUnknownKeys(value, [
|
|
1079
|
+
'name',
|
|
1080
|
+
'setup'
|
|
1081
|
+
], label);
|
|
1082
|
+
if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
|
|
1083
|
+
if ('function' != typeof value.setup) throw new TypeError(`Midscene config ${label}.setup must be a function.`);
|
|
1084
|
+
return value;
|
|
1085
|
+
};
|
|
1086
|
+
const validatePositiveInteger = (value, fallback, label)=>{
|
|
1087
|
+
if (void 0 === value) return fallback;
|
|
1088
|
+
if (!Number.isInteger(value) || value <= 0) throw new TypeError(`Midscene config ${label} must be a positive integer.`);
|
|
1089
|
+
return value;
|
|
1090
|
+
};
|
|
1091
|
+
const validateNonNegativeInteger = (value, fallback, label)=>{
|
|
1092
|
+
if (void 0 === value) return fallback;
|
|
1093
|
+
if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
|
|
1094
|
+
return value;
|
|
1095
|
+
};
|
|
1096
|
+
const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
1097
|
+
if (void 0 === value) {
|
|
1098
|
+
const setup = validateSetup(defaultSetup, 'setup');
|
|
1099
|
+
return {
|
|
1100
|
+
hasExplicitProjects: false,
|
|
1101
|
+
projects: Object.freeze([
|
|
1102
|
+
Object.freeze({
|
|
1103
|
+
projectId: projectIdFromIndex(0),
|
|
1104
|
+
name: 'default',
|
|
1105
|
+
...setup ? {
|
|
1106
|
+
setup
|
|
1107
|
+
} : {},
|
|
1108
|
+
tags: Object.freeze({
|
|
1109
|
+
include: [],
|
|
1110
|
+
exclude: []
|
|
1111
|
+
}),
|
|
1112
|
+
retry: 0,
|
|
1113
|
+
variables: Object.freeze({}),
|
|
1114
|
+
nodes: new registry_NodeRegistry(globalNodes.definitions())
|
|
1115
|
+
})
|
|
1116
|
+
])
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
|
|
1120
|
+
if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
|
|
1121
|
+
const names = new Set();
|
|
1122
|
+
const projects = value.map((candidate, index)=>{
|
|
1123
|
+
const label = `projects[${index}]`;
|
|
1124
|
+
if (!isRecord(candidate)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
1125
|
+
test_project_rejectUnknownKeys(candidate, [
|
|
1126
|
+
'name',
|
|
1127
|
+
'setup',
|
|
1128
|
+
'nodes',
|
|
1129
|
+
'files',
|
|
1130
|
+
'tags',
|
|
1131
|
+
'retry',
|
|
1132
|
+
'variables'
|
|
1133
|
+
], label);
|
|
1134
|
+
if ('string' != typeof candidate.name || 0 === candidate.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
|
|
1135
|
+
if (names.has(candidate.name)) throw new TypeError(`Midscene config project name "${candidate.name}" must be unique.`);
|
|
1136
|
+
names.add(candidate.name);
|
|
1137
|
+
const files = validateTestFileSelection(candidate.files, `${label}.files`);
|
|
1138
|
+
if (void 0 !== candidate.nodes && !Array.isArray(candidate.nodes)) throw new TypeError(`Midscene config ${label}.nodes must be an array.`);
|
|
1139
|
+
const localNodes = new registry_NodeRegistry(candidate.nodes);
|
|
1140
|
+
const nodes = new registry_NodeRegistry([
|
|
1141
|
+
...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
|
|
1142
|
+
...localNodes.definitions()
|
|
1143
|
+
]);
|
|
1144
|
+
return Object.freeze({
|
|
1145
|
+
projectId: projectIdFromIndex(index),
|
|
1146
|
+
name: candidate.name,
|
|
1147
|
+
nodes,
|
|
1148
|
+
...files ? {
|
|
1149
|
+
files
|
|
1150
|
+
} : {},
|
|
1151
|
+
tags: validateTagSelection(candidate.tags, `${label}.tags`),
|
|
1152
|
+
retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
|
|
1153
|
+
variables: validateVariables(candidate.variables, `${label}.variables`),
|
|
1154
|
+
...void 0 === candidate.setup ? {} : {
|
|
1155
|
+
setup: validateSetup(candidate.setup, `${label}.setup`)
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
});
|
|
1159
|
+
return {
|
|
1160
|
+
projects: Object.freeze(projects),
|
|
1161
|
+
hasExplicitProjects: true
|
|
1162
|
+
};
|
|
1163
|
+
};
|
|
1164
|
+
const validateTestOptions = (value)=>{
|
|
1165
|
+
if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
|
|
1166
|
+
const candidate = value ?? {};
|
|
1167
|
+
const executionUnit = candidate.executionUnit ?? 'project';
|
|
1168
|
+
if ('project' !== executionUnit && 'case' !== executionUnit) throw new TypeError('Midscene config test.executionUnit must be "project" or "case".');
|
|
1169
|
+
return Object.freeze({
|
|
1170
|
+
maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
|
|
1171
|
+
bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
|
|
1172
|
+
testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout'),
|
|
1173
|
+
executionUnit,
|
|
1174
|
+
executorRetry: validateNonNegativeInteger(candidate.executorRetry, 0, 'test.executorRetry')
|
|
1175
|
+
});
|
|
1176
|
+
};
|
|
1177
|
+
const validateTestExecutor = (value)=>{
|
|
1178
|
+
if (void 0 === value) return;
|
|
1179
|
+
if (!isRecord(value)) throw new TypeError('Midscene config executor must be an object.');
|
|
1180
|
+
test_project_rejectUnknownKeys(value, [
|
|
1181
|
+
'name',
|
|
1182
|
+
'execute'
|
|
1183
|
+
], 'executor');
|
|
1184
|
+
if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError('Midscene config executor.name must be non-empty.');
|
|
1185
|
+
if ('function' != typeof value.execute) throw new TypeError('Midscene config executor.execute must be a function.');
|
|
1186
|
+
return Object.freeze(value);
|
|
1187
|
+
};
|
|
1188
|
+
const validateOutputPath = (value, fallback, label)=>{
|
|
1189
|
+
if (void 0 === value) return fallback;
|
|
1190
|
+
if ('string' != typeof value || 0 === value.trim().length) throw new TypeError(`Midscene config ${label} must be non-empty.`);
|
|
1191
|
+
return value;
|
|
1192
|
+
};
|
|
1193
|
+
const validateOutput = (value)=>{
|
|
1194
|
+
if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config output must be an object.');
|
|
1195
|
+
const candidate = value ?? {};
|
|
1196
|
+
test_project_rejectUnknownKeys(candidate, [
|
|
1197
|
+
'reportDir'
|
|
1198
|
+
], 'output');
|
|
1199
|
+
return Object.freeze({
|
|
1200
|
+
reportDir: validateOutputPath(candidate.reportDir, './midscene_run/report', 'output.reportDir')
|
|
1201
|
+
});
|
|
1202
|
+
};
|
|
1203
|
+
const validateTestProjectDefinition = (definition)=>{
|
|
1204
|
+
if (!isRecord(definition)) throw new TypeError('Midscene config must default export an object.');
|
|
1205
|
+
if (void 0 !== definition.nodes && !Array.isArray(definition.nodes)) throw new TypeError('Midscene config nodes must be an array.');
|
|
1206
|
+
if ('setupWorkflow' in definition) throw new TypeError('Midscene config setupWorkflow is no longer supported. Use setup or projects[].setup instead.');
|
|
1207
|
+
if ('setupDocument' in definition) throw new TypeError('Midscene config setupDocument is not supported. Use setup or projects[].setup for resources, and beforeAll/afterAll for document lifecycle steps.');
|
|
1208
|
+
if ('root' in definition) throw new TypeError('Midscene config root is not supported. Pass the Test Project directory to midscene-test instead.');
|
|
1209
|
+
if ('files' in definition) throw new TypeError('Midscene config files is not supported at the root. Move it to projects[].files.');
|
|
1210
|
+
if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
|
|
1211
|
+
test_project_rejectUnknownKeys(definition, [
|
|
1212
|
+
'setup',
|
|
1213
|
+
'projects',
|
|
1214
|
+
'test',
|
|
1215
|
+
'output',
|
|
1216
|
+
'nodes',
|
|
1217
|
+
'executor'
|
|
1218
|
+
], 'root');
|
|
1219
|
+
const nodes = new registry_NodeRegistry(definition.nodes);
|
|
1220
|
+
const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
|
|
1221
|
+
const test = validateTestOptions(definition.test);
|
|
1222
|
+
const executor = validateTestExecutor(definition.executor);
|
|
1223
|
+
if (executor && 'case' !== test.executionUnit) throw new TypeError('Midscene config executor requires test.executionUnit to be "case".');
|
|
1224
|
+
return {
|
|
1225
|
+
...resolvedProjects,
|
|
1226
|
+
test,
|
|
1227
|
+
output: validateOutput(definition.output),
|
|
1228
|
+
nodes,
|
|
1229
|
+
...executor ? {
|
|
1230
|
+
executor
|
|
1231
|
+
} : {},
|
|
1232
|
+
resolveNode: (name)=>nodes.get(name)
|
|
1233
|
+
};
|
|
1234
|
+
};
|
|
1235
|
+
const assertSupportedConfig = (absolutePath)=>{
|
|
1236
|
+
if (!absolutePath.endsWith('.ts') && !absolutePath.endsWith('.mjs')) {
|
|
1237
|
+
const extension = absolutePath.match(/(\.[^./\\]+)$/)?.[1] ?? '(none)';
|
|
1238
|
+
throw new TypeError(`Unsupported Midscene config extension: ${extension}. Supported extensions: .ts, .mjs.`);
|
|
1239
|
+
}
|
|
1240
|
+
return absolutePath.endsWith('.mjs') ? '.mjs' : '.ts';
|
|
1241
|
+
};
|
|
1242
|
+
const canRetryWithCjsLoader = (error)=>{
|
|
1243
|
+
if (!(error instanceof Error)) return false;
|
|
1244
|
+
const errorCode = error.code;
|
|
1245
|
+
return error instanceof SyntaxError && /^Unexpected (?:identifier|reserved word|token)/.test(error.message) || 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' === errorCode || 'ERR_UNKNOWN_FILE_EXTENSION' === errorCode && /^Unknown file extension\b/.test(error.message);
|
|
1246
|
+
};
|
|
1247
|
+
async function loadTestProject(configPath) {
|
|
1248
|
+
if (!configPath) return validateTestProjectDefinition({
|
|
1249
|
+
nodes: []
|
|
1250
|
+
});
|
|
1251
|
+
const absolutePath = external_node_path_resolve(configPath);
|
|
1252
|
+
const extension = assertSupportedConfig(absolutePath);
|
|
1253
|
+
let loaded;
|
|
1254
|
+
try {
|
|
1255
|
+
if ('.mjs' === extension) loaded = await import(pathToFileURL(absolutePath).href);
|
|
1256
|
+
else {
|
|
1257
|
+
const { tsImport } = await import("tsx/esm/api");
|
|
1258
|
+
loaded = await tsImport(pathToFileURL(absolutePath).href, {
|
|
1259
|
+
parentURL: pathToFileURL(`${dirname(absolutePath)}${sep}`).href,
|
|
1260
|
+
tsconfig: false
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
try {
|
|
1265
|
+
if ('.mjs' === extension || !canRetryWithCjsLoader(error)) throw error;
|
|
1266
|
+
loaded = api_require(absolutePath, pathToFileURL(absolutePath));
|
|
1267
|
+
} catch (fallbackError) {
|
|
1268
|
+
const message = fallbackError instanceof Error ? `: ${fallbackError.message}` : '';
|
|
1269
|
+
throw new Error(`Failed to load Midscene config "${absolutePath}"${message}`, {
|
|
1270
|
+
cause: fallbackError
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
|
|
1275
|
+
}
|
|
740
1276
|
const CONFIG_NAMES = [
|
|
741
1277
|
'midscene.config.ts',
|
|
742
1278
|
'midscene.config.mjs'
|
|
@@ -758,8 +1294,29 @@ const DEFAULT_TEST_FILE_SELECTION = {
|
|
|
758
1294
|
]
|
|
759
1295
|
};
|
|
760
1296
|
const project_preparation_toPosix = (value)=>value.split(sep).join('/');
|
|
1297
|
+
const discoverSelectedTestFiles = (projectRoot, paths)=>{
|
|
1298
|
+
const root = external_node_path_resolve(projectRoot);
|
|
1299
|
+
const files = new Set();
|
|
1300
|
+
for (const requestedPath of paths){
|
|
1301
|
+
if ('string' != typeof requestedPath || 0 === requestedPath.trim().length) throw new TypeError('Test selection paths must be non-empty strings.');
|
|
1302
|
+
const absolutePath = external_node_path_resolve(root, requestedPath);
|
|
1303
|
+
const relativePath = relative(root, absolutePath);
|
|
1304
|
+
if ('..' === relativePath || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) throw new Error(`Test selection path is outside the project: ${requestedPath}`);
|
|
1305
|
+
if (!existsSync(absolutePath)) throw new Error(`Test selection path does not exist: ${requestedPath}`);
|
|
1306
|
+
if (statSync(absolutePath).isDirectory()) for (const file of discoverTestFiles(absolutePath))files.add(file);
|
|
1307
|
+
else if (/\.ya?ml$/i.test(absolutePath)) files.add(absolutePath);
|
|
1308
|
+
else throw new Error(`Test selection file must be YAML: ${requestedPath}`);
|
|
1309
|
+
}
|
|
1310
|
+
return [
|
|
1311
|
+
...files
|
|
1312
|
+
].sort((a, b)=>{
|
|
1313
|
+
const relativeA = project_preparation_toPosix(relative(root, a));
|
|
1314
|
+
const relativeB = project_preparation_toPosix(relative(root, b));
|
|
1315
|
+
return relativeA < relativeB ? -1 : relativeA > relativeB ? 1 : 0;
|
|
1316
|
+
});
|
|
1317
|
+
};
|
|
761
1318
|
const discoverResolvedTestFiles = (projectRoot, selection)=>{
|
|
762
|
-
const root =
|
|
1319
|
+
const root = external_node_path_resolve(projectRoot);
|
|
763
1320
|
const match = (patterns)=>globSync(patterns, {
|
|
764
1321
|
absolute: true,
|
|
765
1322
|
caseSensitiveMatch: false,
|
|
@@ -775,7 +1332,7 @@ const discoverResolvedTestFiles = (projectRoot, selection)=>{
|
|
|
775
1332
|
}).filter((file)=>/\.ya?ml$/i.test(file));
|
|
776
1333
|
const files = match(selection.include);
|
|
777
1334
|
return [
|
|
778
|
-
...new Set(files.map((file)=>
|
|
1335
|
+
...new Set(files.map((file)=>external_node_path_resolve(file)))
|
|
779
1336
|
].sort((a, b)=>{
|
|
780
1337
|
const relativeA = project_preparation_toPosix(relative(root, a));
|
|
781
1338
|
const relativeB = project_preparation_toPosix(relative(root, b));
|
|
@@ -788,7 +1345,7 @@ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)
|
|
|
788
1345
|
return discoverResolvedTestFiles(projectRoot, normalized);
|
|
789
1346
|
};
|
|
790
1347
|
const discoverTestConfig = (projectRoot)=>{
|
|
791
|
-
const root =
|
|
1348
|
+
const root = external_node_path_resolve(projectRoot);
|
|
792
1349
|
const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
|
|
793
1350
|
const supported = candidates.filter((name)=>CONFIG_NAMES.includes(name));
|
|
794
1351
|
const unsupported = candidates.filter((name)=>!CONFIG_NAMES.includes(name));
|
|
@@ -817,8 +1374,14 @@ const matchesTags = (tags, selection)=>{
|
|
|
817
1374
|
if (selection.exclude.some((tag)=>tags.includes(tag))) return false;
|
|
818
1375
|
return 0 === selection.include.length || selection.include.some((tag)=>tags.includes(tag));
|
|
819
1376
|
};
|
|
820
|
-
const filterDocumentCases = (document, project)=>{
|
|
821
|
-
const cases = document.cases.filter((item)=>
|
|
1377
|
+
const filterDocumentCases = (document, project, selection)=>{
|
|
1378
|
+
const cases = document.cases.filter((item)=>{
|
|
1379
|
+
const tags = item.definition.tags ?? [];
|
|
1380
|
+
return matchesTags(tags, project.tags) && matchesTags(tags, {
|
|
1381
|
+
include: selection?.tags?.include ?? [],
|
|
1382
|
+
exclude: selection?.tags?.exclude ?? []
|
|
1383
|
+
}) && (!selection?.caseIds?.length || selection.caseIds.includes(item.caseId));
|
|
1384
|
+
});
|
|
822
1385
|
const filtered = document.cases.length - cases.length;
|
|
823
1386
|
return 0 === cases.length ? {
|
|
824
1387
|
filtered
|
|
@@ -842,8 +1405,10 @@ const selectProjects = (projects, names)=>{
|
|
|
842
1405
|
async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
|
|
843
1406
|
const platform = options.platform?.trim();
|
|
844
1407
|
const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
|
|
845
|
-
const prerequisiteFile = options.prerequisiteFile ?
|
|
846
|
-
const
|
|
1408
|
+
const prerequisiteFile = options.prerequisiteFile ? external_node_path_resolve(options.prerequisiteFile) : void 0;
|
|
1409
|
+
const projectFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
|
|
1410
|
+
const selectedFiles = options.selection?.paths?.length ? new Set(discoverSelectedTestFiles(projectRoot, options.selection.paths)) : void 0;
|
|
1411
|
+
const mainFiles = selectedFiles ? projectFiles.filter((file)=>selectedFiles.has(file)) : projectFiles;
|
|
847
1412
|
const files = [
|
|
848
1413
|
...prerequisiteFile ? [
|
|
849
1414
|
prerequisiteFile
|
|
@@ -866,6 +1431,7 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
866
1431
|
const invocations = [];
|
|
867
1432
|
const plans = [];
|
|
868
1433
|
let filteredCaseCount = 0;
|
|
1434
|
+
const availableCaseIds = new Set();
|
|
869
1435
|
if (0 === sources.length) {
|
|
870
1436
|
const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
|
|
871
1437
|
projectName: project.name,
|
|
@@ -896,10 +1462,16 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
896
1462
|
variables: project.variables,
|
|
897
1463
|
env: process.env
|
|
898
1464
|
});
|
|
1465
|
+
const duplicate = document.cases.find((item)=>availableCaseIds.has(item.caseId));
|
|
1466
|
+
if (duplicate) throw new WorkflowParseError(`Case id collision in project "${project.name}": ${duplicate.caseId}.`, {
|
|
1467
|
+
caseId: duplicate.caseId,
|
|
1468
|
+
projectName: project.name
|
|
1469
|
+
});
|
|
1470
|
+
for (const item of document.cases)availableCaseIds.add(item.caseId);
|
|
899
1471
|
const filtered = source.absolutePath === prerequisiteFile ? {
|
|
900
1472
|
document,
|
|
901
1473
|
filtered: 0
|
|
902
|
-
} : filterDocumentCases(document, project);
|
|
1474
|
+
} : filterDocumentCases(document, project, options.selection);
|
|
903
1475
|
const plan = buildTestRunReportPlan(document, {
|
|
904
1476
|
selectedCaseIds: filtered.document?.cases.map((item)=>item.caseId) ?? [],
|
|
905
1477
|
defaultTimeoutMs,
|
|
@@ -947,9 +1519,42 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
947
1519
|
plans,
|
|
948
1520
|
collectionErrors,
|
|
949
1521
|
selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
|
|
950
|
-
filteredCaseCount
|
|
1522
|
+
filteredCaseCount,
|
|
1523
|
+
availableCaseIds: [
|
|
1524
|
+
...availableCaseIds
|
|
1525
|
+
]
|
|
951
1526
|
};
|
|
952
1527
|
}
|
|
1528
|
+
const validateUniqueStrings = (values, label)=>{
|
|
1529
|
+
if (void 0 === values) return;
|
|
1530
|
+
if (!Array.isArray(values) || values.some((value)=>'string' != typeof value || 0 === value.trim().length)) throw new TypeError(`${label} must be an array of non-empty strings.`);
|
|
1531
|
+
if (new Set(values).size !== values.length) throw new TypeError(`${label} must not contain duplicates.`);
|
|
1532
|
+
return values;
|
|
1533
|
+
};
|
|
1534
|
+
const validateRunSelection = (options)=>{
|
|
1535
|
+
const paths = validateUniqueStrings(options.paths, 'Test selection paths');
|
|
1536
|
+
const caseIds = validateUniqueStrings(options.caseIds, 'Test selection caseIds');
|
|
1537
|
+
const include = validateUniqueStrings(options.tags?.include, 'Test selection tags.include');
|
|
1538
|
+
const exclude = validateUniqueStrings(options.tags?.exclude, 'Test selection tags.exclude');
|
|
1539
|
+
return {
|
|
1540
|
+
...paths ? {
|
|
1541
|
+
paths
|
|
1542
|
+
} : {},
|
|
1543
|
+
...caseIds ? {
|
|
1544
|
+
caseIds
|
|
1545
|
+
} : {},
|
|
1546
|
+
...include || exclude ? {
|
|
1547
|
+
tags: {
|
|
1548
|
+
...include ? {
|
|
1549
|
+
include
|
|
1550
|
+
} : {},
|
|
1551
|
+
...exclude ? {
|
|
1552
|
+
exclude
|
|
1553
|
+
} : {}
|
|
1554
|
+
}
|
|
1555
|
+
} : {}
|
|
1556
|
+
};
|
|
1557
|
+
};
|
|
953
1558
|
const padDatePart = (value)=>String(value).padStart(2, '0');
|
|
954
1559
|
const createTestRunId = (date = new Date(), uuid = randomUUID())=>{
|
|
955
1560
|
const timestamp = [
|
|
@@ -966,9 +1571,9 @@ const assertDirectory = (path, label)=>{
|
|
|
966
1571
|
if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
|
|
967
1572
|
};
|
|
968
1573
|
const resolveRunInput = (options)=>{
|
|
969
|
-
const cwd =
|
|
1574
|
+
const cwd = external_node_path_resolve(options.cwd ?? process.cwd());
|
|
970
1575
|
assertDirectory(cwd, 'Test working directory');
|
|
971
|
-
const inputPath = options.projectRoot ?
|
|
1576
|
+
const inputPath = options.projectRoot ? external_node_path_resolve(cwd, options.projectRoot) : void 0;
|
|
972
1577
|
const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
|
|
973
1578
|
if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
|
|
974
1579
|
const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
|
|
@@ -987,24 +1592,34 @@ async function prepareTestRun(options) {
|
|
|
987
1592
|
const startedAt = new Date();
|
|
988
1593
|
const runId = createTestRunId(startedAt);
|
|
989
1594
|
const input = resolveRunInput(options);
|
|
990
|
-
const
|
|
1595
|
+
const selection = validateRunSelection(options);
|
|
1596
|
+
const configPath = options.configPath ? external_node_path_resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
|
|
991
1597
|
if (options.configPath && /\.ya?ml$/i.test(configPath)) throw new Error(`midscene-test --config only accepts a native TypeScript or JavaScript project config: ${configPath}. Run legacy batch YAML with \`midscene --config ${options.configPath}\`.`);
|
|
992
1598
|
if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
|
|
993
1599
|
const definition = await loadTestProject(configPath);
|
|
994
|
-
const resultDir = options.resultDir ?
|
|
1600
|
+
const resultDir = options.resultDir ? external_node_path_resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
|
|
995
1601
|
const runDir = join(resultDir, runId);
|
|
996
1602
|
const summaryPath = join(runDir, 'summary.json');
|
|
997
|
-
const reportDir =
|
|
1603
|
+
const reportDir = external_node_path_resolve(input.projectRoot, definition.output.reportDir);
|
|
998
1604
|
mkdirSync(resultDir, {
|
|
999
1605
|
recursive: true
|
|
1000
1606
|
});
|
|
1001
1607
|
mkdirSync(runDir);
|
|
1002
1608
|
const projects = [];
|
|
1003
|
-
for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout,
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1609
|
+
for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, {
|
|
1610
|
+
selection,
|
|
1611
|
+
...input.singleFile ? {
|
|
1612
|
+
files: [
|
|
1613
|
+
input.singleFile
|
|
1614
|
+
]
|
|
1615
|
+
} : {}
|
|
1616
|
+
}));
|
|
1617
|
+
if (selection.caseIds?.length) {
|
|
1618
|
+
const availableCaseIds = new Set(projects.flatMap((project)=>project.availableCaseIds ?? []));
|
|
1619
|
+
const unknown = selection.caseIds.find((id)=>!availableCaseIds.has(id));
|
|
1620
|
+
if (unknown) throw new Error(`Unknown Midscene case id: ${unknown}`);
|
|
1621
|
+
}
|
|
1622
|
+
if ((selection.paths?.length || selection.caseIds?.length || selection.tags?.include?.length || selection.tags?.exclude?.length) && projects.every((project)=>0 === project.selectedCaseCount && 0 === project.collectionErrors.length)) throw new Error('No Midscene cases matched the requested selection.');
|
|
1008
1623
|
const hasFormatMismatch = projects.some((project)=>project.collectionErrors.some(({ error })=>error instanceof NativeWorkflowFormatError));
|
|
1009
1624
|
return {
|
|
1010
1625
|
startedAt,
|
|
@@ -1100,20 +1715,601 @@ async function writeTestResultMarkdown(result, index = emptyIndex()) {
|
|
|
1100
1715
|
}));
|
|
1101
1716
|
return output;
|
|
1102
1717
|
}
|
|
1103
|
-
const
|
|
1718
|
+
const asNotRun = (documentId, collectedCase, projectName, reason)=>({
|
|
1719
|
+
documentId,
|
|
1720
|
+
caseId: collectedCase.caseId,
|
|
1721
|
+
projectName,
|
|
1722
|
+
name: collectedCase.definition.name,
|
|
1723
|
+
sourcePath: collectedCase.sourcePath,
|
|
1724
|
+
caseIndex: collectedCase.caseIndex,
|
|
1725
|
+
status: 'not-run',
|
|
1726
|
+
notRunReason: reason
|
|
1727
|
+
});
|
|
1104
1728
|
const formatStep = (info)=>{
|
|
1105
|
-
const position =
|
|
1729
|
+
const position = 'case' === info.scope ? info.case : info.document;
|
|
1106
1730
|
const phase = 'steps' === position.phase ? 'step' : position.phase;
|
|
1107
1731
|
return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
|
|
1108
1732
|
};
|
|
1109
|
-
const formatStepResult = (info, result)=>{
|
|
1110
|
-
const indent = 'case' === info.scope ? ' ' : ' ';
|
|
1733
|
+
const formatStepResult = (info, result, indent = '')=>{
|
|
1111
1734
|
if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
|
|
1112
1735
|
const symbol = 'success' === result.status ? '✓' : '✗';
|
|
1113
1736
|
const error = result.error ? ` — ${result.error.message}` : '';
|
|
1114
1737
|
const continuation = result.continuedAfterError ? '; continuing' : '';
|
|
1115
1738
|
return `${indent}${symbol} ${formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
|
|
1116
1739
|
};
|
|
1740
|
+
const latestById = (items, id)=>[
|
|
1741
|
+
...new Map(items.map((item)=>[
|
|
1742
|
+
id(item),
|
|
1743
|
+
item
|
|
1744
|
+
])).values()
|
|
1745
|
+
];
|
|
1746
|
+
const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
|
|
1747
|
+
const { project } = prepared;
|
|
1748
|
+
const projectFailed = prepared.collectionErrors.length > 0 || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status || item.execution?.lifecycle?.status === 'failed') || latestById(documents, (item)=>item.documentId).some((item)=>'failed' === item.status) || lifecycle?.status === 'failed';
|
|
1749
|
+
return {
|
|
1750
|
+
projectId: project.projectId,
|
|
1751
|
+
name: project.name,
|
|
1752
|
+
...prepared.platform ? {
|
|
1753
|
+
platform: prepared.platform
|
|
1754
|
+
} : {},
|
|
1755
|
+
status: projectFailed ? 'failed' : 'success',
|
|
1756
|
+
retry: project.retry,
|
|
1757
|
+
fileSelection: prepared.fileSelection,
|
|
1758
|
+
tagSelection: project.tags,
|
|
1759
|
+
sourceCount: prepared.sources.length,
|
|
1760
|
+
selectedCaseCount: prepared.selectedCaseCount,
|
|
1761
|
+
filteredCaseCount: prepared.filteredCaseCount,
|
|
1762
|
+
...lifecycle ? {
|
|
1763
|
+
lifecycle
|
|
1764
|
+
} : {},
|
|
1765
|
+
cases,
|
|
1766
|
+
plans: prepared.plans,
|
|
1767
|
+
documents,
|
|
1768
|
+
collectionErrors: prepared.collectionErrors
|
|
1769
|
+
};
|
|
1770
|
+
};
|
|
1771
|
+
function createExecutorReports(outputDir) {
|
|
1772
|
+
const paths = new Map();
|
|
1773
|
+
const sources = new Map();
|
|
1774
|
+
return {
|
|
1775
|
+
materializeSource (source) {
|
|
1776
|
+
if (!source.dumpPath) {
|
|
1777
|
+
const reference = this.materialize(source.sourcePath);
|
|
1778
|
+
sources.set(reference, {
|
|
1779
|
+
sourcePath: this.resolve(reference),
|
|
1780
|
+
collection: source.collection
|
|
1781
|
+
});
|
|
1782
|
+
return reference;
|
|
1783
|
+
}
|
|
1784
|
+
const dump = realpathSync(source.dumpPath);
|
|
1785
|
+
const reference = randomUUID();
|
|
1786
|
+
const destination = join(outputDir, 'reports', reference);
|
|
1787
|
+
mkdirSync(destination, {
|
|
1788
|
+
recursive: true
|
|
1789
|
+
});
|
|
1790
|
+
const dumpPath = join(destination, 'snapshot.json');
|
|
1791
|
+
cpSync(dump, dumpPath);
|
|
1792
|
+
const screenshots = join(dirname(dump), 'screenshots');
|
|
1793
|
+
if (existsSync(screenshots)) cpSync(screenshots, join(destination, 'screenshots'), {
|
|
1794
|
+
recursive: true,
|
|
1795
|
+
dereference: true
|
|
1796
|
+
});
|
|
1797
|
+
const sourcePath = join(destination, 'index.html');
|
|
1798
|
+
sources.set(reference, {
|
|
1799
|
+
sourcePath,
|
|
1800
|
+
dumpPath,
|
|
1801
|
+
collection: source.collection
|
|
1802
|
+
});
|
|
1803
|
+
paths.set(reference, sourcePath);
|
|
1804
|
+
return reference;
|
|
1805
|
+
},
|
|
1806
|
+
resolveSource (reference) {
|
|
1807
|
+
return sources.get(reference);
|
|
1808
|
+
},
|
|
1809
|
+
materialize (sourcePath) {
|
|
1810
|
+
const source = realpathSync(sourcePath);
|
|
1811
|
+
const entry = statSync(source).isDirectory() ? join(source, 'index.html') : source;
|
|
1812
|
+
if (!statSync(entry).isFile()) throw new TestExecutorError(`Executor report must have a regular HTML entry: ${sourcePath}`, {
|
|
1813
|
+
kind: 'report'
|
|
1814
|
+
});
|
|
1815
|
+
const reference = randomUUID();
|
|
1816
|
+
const destination = join(outputDir, 'reports', reference);
|
|
1817
|
+
mkdirSync(destination, {
|
|
1818
|
+
recursive: true
|
|
1819
|
+
});
|
|
1820
|
+
const isDirectoryReport = 'index.html' === basename(entry);
|
|
1821
|
+
const reportPath = join(destination, isDirectoryReport ? 'index.html' : 'report.html');
|
|
1822
|
+
cpSync(isDirectoryReport ? dirname(entry) : entry, isDirectoryReport ? destination : reportPath, {
|
|
1823
|
+
recursive: isDirectoryReport,
|
|
1824
|
+
dereference: true
|
|
1825
|
+
});
|
|
1826
|
+
if (!isDirectoryReport) {
|
|
1827
|
+
const screenshots = join(dirname(entry), 'screenshots');
|
|
1828
|
+
if (existsSync(screenshots)) cpSync(screenshots, join(destination, 'screenshots'), {
|
|
1829
|
+
recursive: true,
|
|
1830
|
+
dereference: true
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
paths.set(reference, reportPath);
|
|
1834
|
+
return reference;
|
|
1835
|
+
},
|
|
1836
|
+
resolve (reference) {
|
|
1837
|
+
const reportPath = paths.get(reference);
|
|
1838
|
+
if (!reportPath) throw new TestExecutorError(`Executor returned unknown report reference ${reference}.`, {
|
|
1839
|
+
kind: 'report'
|
|
1840
|
+
});
|
|
1841
|
+
return reportPath;
|
|
1842
|
+
}
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
async function runTaskPool(options) {
|
|
1846
|
+
if (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency <= 0) throw new TypeError('Task scheduler maxConcurrency must be a positive integer.');
|
|
1847
|
+
const pending = options.tasks.map((_, index)=>index);
|
|
1848
|
+
const results = new Array(options.tasks.length);
|
|
1849
|
+
const activeResources = new Set();
|
|
1850
|
+
let active = 0;
|
|
1851
|
+
let firstError;
|
|
1852
|
+
let hasError = false;
|
|
1853
|
+
return await new Promise((resolve, reject)=>{
|
|
1854
|
+
let settled = false;
|
|
1855
|
+
const stopRequested = ()=>hasError || options.signal?.aborted === true || options.shouldStop?.() === true;
|
|
1856
|
+
const finish = ()=>{
|
|
1857
|
+
if (settled) return;
|
|
1858
|
+
settled = true;
|
|
1859
|
+
options.signal?.removeEventListener('abort', pump);
|
|
1860
|
+
if (hasError) reject(firstError);
|
|
1861
|
+
else resolve(results);
|
|
1862
|
+
};
|
|
1863
|
+
const release = (resources)=>{
|
|
1864
|
+
for (const resource of resources)activeResources.delete(resource);
|
|
1865
|
+
};
|
|
1866
|
+
const claimRunnable = ()=>{
|
|
1867
|
+
const pendingIndex = pending.findIndex((taskIndex)=>options.tasks[taskIndex].resources.every((resource)=>!activeResources.has(resource)));
|
|
1868
|
+
if (pendingIndex < 0) return;
|
|
1869
|
+
const [taskIndex] = pending.splice(pendingIndex, 1);
|
|
1870
|
+
return taskIndex;
|
|
1871
|
+
};
|
|
1872
|
+
function pump() {
|
|
1873
|
+
if (settled) return;
|
|
1874
|
+
while(active < options.maxConcurrency && !stopRequested()){
|
|
1875
|
+
const taskIndex = claimRunnable();
|
|
1876
|
+
if (void 0 === taskIndex) break;
|
|
1877
|
+
const task = options.tasks[taskIndex];
|
|
1878
|
+
for (const resource of task.resources)activeResources.add(resource);
|
|
1879
|
+
active += 1;
|
|
1880
|
+
Promise.resolve().then(()=>options.run(task, taskIndex)).then((result)=>{
|
|
1881
|
+
results[taskIndex] = result;
|
|
1882
|
+
}).catch((error)=>{
|
|
1883
|
+
if (!hasError) {
|
|
1884
|
+
hasError = true;
|
|
1885
|
+
firstError = error;
|
|
1886
|
+
}
|
|
1887
|
+
}).finally(()=>{
|
|
1888
|
+
release(task.resources);
|
|
1889
|
+
active -= 1;
|
|
1890
|
+
pump();
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
if (0 === active && (0 === pending.length || stopRequested())) finish();
|
|
1894
|
+
}
|
|
1895
|
+
options.signal?.addEventListener('abort', pump, {
|
|
1896
|
+
once: true
|
|
1897
|
+
});
|
|
1898
|
+
pump();
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
const isolatedDocumentId = (document, collectedCase)=>`${document.documentId}-case-${collectedCase.caseIndex}`;
|
|
1902
|
+
const prepareCaseTasks = (projects)=>projects.flatMap((prepared, projectIndex)=>prepared.documents.flatMap((document)=>document.cases.map((collectedCase)=>{
|
|
1903
|
+
const documentId = isolatedDocumentId(document, collectedCase);
|
|
1904
|
+
return {
|
|
1905
|
+
projectIndex,
|
|
1906
|
+
prepared,
|
|
1907
|
+
document,
|
|
1908
|
+
collectedCase,
|
|
1909
|
+
task: Object.freeze({
|
|
1910
|
+
taskId: `${prepared.project.projectId}:${collectedCase.caseId}`,
|
|
1911
|
+
projectId: prepared.project.projectId,
|
|
1912
|
+
projectName: prepared.project.name,
|
|
1913
|
+
documentId,
|
|
1914
|
+
sourcePath: collectedCase.sourcePath,
|
|
1915
|
+
caseId: collectedCase.caseId,
|
|
1916
|
+
caseName: collectedCase.definition.name,
|
|
1917
|
+
caseIndex: collectedCase.caseIndex,
|
|
1918
|
+
tags: Object.freeze([
|
|
1919
|
+
...collectedCase.definition.tags ?? []
|
|
1920
|
+
]),
|
|
1921
|
+
resources: Object.freeze([
|
|
1922
|
+
...collectedCase.definition.resources ?? []
|
|
1923
|
+
]),
|
|
1924
|
+
retry: prepared.project.retry
|
|
1925
|
+
})
|
|
1926
|
+
};
|
|
1927
|
+
})));
|
|
1928
|
+
const reviveError = (error)=>{
|
|
1929
|
+
const revived = new WorkflowError(error.message, {
|
|
1930
|
+
code: error.code,
|
|
1931
|
+
details: error.details,
|
|
1932
|
+
cause: error.cause
|
|
1933
|
+
});
|
|
1934
|
+
revived.name = error.name;
|
|
1935
|
+
return revived;
|
|
1936
|
+
};
|
|
1937
|
+
const reviveStep = (step)=>{
|
|
1938
|
+
const { error, output, report, ...base } = step;
|
|
1939
|
+
return {
|
|
1940
|
+
...base,
|
|
1941
|
+
input: step.input,
|
|
1942
|
+
meta: {
|
|
1943
|
+
...step.meta
|
|
1944
|
+
},
|
|
1945
|
+
...output ? {
|
|
1946
|
+
output: {
|
|
1947
|
+
...output
|
|
1948
|
+
}
|
|
1949
|
+
} : {},
|
|
1950
|
+
...error ? {
|
|
1951
|
+
error: reviveError(error)
|
|
1952
|
+
} : {},
|
|
1953
|
+
...report ? {
|
|
1954
|
+
report: {
|
|
1955
|
+
traces: report.traces.map((trace)=>({
|
|
1956
|
+
...trace
|
|
1957
|
+
})),
|
|
1958
|
+
...report.collectionErrors ? {
|
|
1959
|
+
collectionErrors: report.collectionErrors.map(reviveError)
|
|
1960
|
+
} : {}
|
|
1961
|
+
}
|
|
1962
|
+
} : {}
|
|
1963
|
+
};
|
|
1964
|
+
};
|
|
1965
|
+
const reviveAttempt = (attempt)=>{
|
|
1966
|
+
const { executionErrors, teardownErrors, reportRefs, beforeEach, steps, afterEach, ...base } = attempt;
|
|
1967
|
+
return {
|
|
1968
|
+
...base,
|
|
1969
|
+
beforeEach: beforeEach.map(reviveStep),
|
|
1970
|
+
steps: steps.map(reviveStep),
|
|
1971
|
+
afterEach: afterEach.map(reviveStep),
|
|
1972
|
+
...executionErrors ? {
|
|
1973
|
+
executionErrors: executionErrors.map(reviveError)
|
|
1974
|
+
} : {},
|
|
1975
|
+
...teardownErrors ? {
|
|
1976
|
+
teardownErrors: teardownErrors.map(reviveError)
|
|
1977
|
+
} : {}
|
|
1978
|
+
};
|
|
1979
|
+
};
|
|
1980
|
+
const reviveDocument = (document)=>{
|
|
1981
|
+
const { executionErrors, teardownErrors, reportRefs, beforeAll, afterAll, hostErrors, ...base } = document;
|
|
1982
|
+
return {
|
|
1983
|
+
...base,
|
|
1984
|
+
...hostErrors ? {
|
|
1985
|
+
hostErrors: hostErrors.map((error)=>({
|
|
1986
|
+
...error
|
|
1987
|
+
}))
|
|
1988
|
+
} : {},
|
|
1989
|
+
beforeAll: beforeAll.map(reviveStep),
|
|
1990
|
+
afterAll: afterAll.map(reviveStep),
|
|
1991
|
+
...executionErrors ? {
|
|
1992
|
+
executionErrors: executionErrors.map(reviveError)
|
|
1993
|
+
} : {},
|
|
1994
|
+
...teardownErrors ? {
|
|
1995
|
+
teardownErrors: teardownErrors.map(reviveError)
|
|
1996
|
+
} : {}
|
|
1997
|
+
};
|
|
1998
|
+
};
|
|
1999
|
+
const reviveLifecycle = (lifecycle)=>{
|
|
2000
|
+
const { setupError, teardownErrors, ...base } = lifecycle;
|
|
2001
|
+
return {
|
|
2002
|
+
...base,
|
|
2003
|
+
...setupError ? {
|
|
2004
|
+
setupError: reviveError(setupError)
|
|
2005
|
+
} : {},
|
|
2006
|
+
...teardownErrors ? {
|
|
2007
|
+
teardownErrors: teardownErrors.map(reviveError)
|
|
2008
|
+
} : {}
|
|
2009
|
+
};
|
|
2010
|
+
};
|
|
2011
|
+
const toTransportResult = (result, materializeReport, materializeSource)=>{
|
|
2012
|
+
const transportReports = (scope)=>[
|
|
2013
|
+
...(scope.reportSources ?? []).map(materializeSource),
|
|
2014
|
+
...(scope.reportPaths ?? []).filter((path)=>!scope.reportSources?.some((source)=>source.sourcePath === path)).map(materializeReport)
|
|
2015
|
+
];
|
|
2016
|
+
const mapStep = (step)=>{
|
|
2017
|
+
if (!step.report) return step;
|
|
2018
|
+
const { sources: _sources, ...report } = step.report;
|
|
2019
|
+
return {
|
|
2020
|
+
...step,
|
|
2021
|
+
report
|
|
2022
|
+
};
|
|
2023
|
+
};
|
|
2024
|
+
const mapAttempt = (attempt)=>{
|
|
2025
|
+
const { reportPaths, reportSources, reportScopeId, ...transportAttempt } = attempt;
|
|
2026
|
+
const reportRefs = transportReports(attempt);
|
|
2027
|
+
return {
|
|
2028
|
+
...transportAttempt,
|
|
2029
|
+
beforeEach: attempt.beforeEach.map(mapStep),
|
|
2030
|
+
steps: attempt.steps.map(mapStep),
|
|
2031
|
+
afterEach: attempt.afterEach.map(mapStep),
|
|
2032
|
+
...reportRefs.length ? {
|
|
2033
|
+
reportRefs
|
|
2034
|
+
} : {}
|
|
2035
|
+
};
|
|
2036
|
+
};
|
|
2037
|
+
const attempts = result.case.attempts?.map(mapAttempt);
|
|
2038
|
+
const run = attempts?.at(-1);
|
|
2039
|
+
const { run: _run, attempts: _attempts, ...transportCase } = result.case;
|
|
2040
|
+
const document = result.document ? (()=>{
|
|
2041
|
+
const { reportPaths, reportSources, reportScopeId, ...transportDocument } = result.document;
|
|
2042
|
+
const reportRefs = transportReports(result.document);
|
|
2043
|
+
return {
|
|
2044
|
+
...transportDocument,
|
|
2045
|
+
...result.document.hostErrors ? {
|
|
2046
|
+
hostErrors: result.document.hostErrors.map(({ phase, error })=>({
|
|
2047
|
+
phase,
|
|
2048
|
+
error: sanitizeReportError(error)
|
|
2049
|
+
}))
|
|
2050
|
+
} : {},
|
|
2051
|
+
beforeAll: result.document.beforeAll.map(mapStep),
|
|
2052
|
+
afterAll: result.document.afterAll.map(mapStep),
|
|
2053
|
+
...reportRefs.length ? {
|
|
2054
|
+
reportRefs
|
|
2055
|
+
} : {}
|
|
2056
|
+
};
|
|
2057
|
+
})() : void 0;
|
|
2058
|
+
return JSON.parse(JSON.stringify({
|
|
2059
|
+
case: {
|
|
2060
|
+
...transportCase,
|
|
2061
|
+
...run ? {
|
|
2062
|
+
run
|
|
2063
|
+
} : {},
|
|
2064
|
+
...attempts ? {
|
|
2065
|
+
attempts
|
|
2066
|
+
} : {}
|
|
2067
|
+
},
|
|
2068
|
+
...document ? {
|
|
2069
|
+
document
|
|
2070
|
+
} : {},
|
|
2071
|
+
...result.lifecycle ? {
|
|
2072
|
+
lifecycle: result.lifecycle
|
|
2073
|
+
} : {}
|
|
2074
|
+
}));
|
|
2075
|
+
};
|
|
2076
|
+
const fromTransportResult = (result, resolveReportReference, resolveSource)=>{
|
|
2077
|
+
const resolveReports = (refs)=>{
|
|
2078
|
+
const reportPaths = [];
|
|
2079
|
+
const reportSources = [];
|
|
2080
|
+
for (const ref of refs ?? []){
|
|
2081
|
+
const path = resolveReportReference(ref);
|
|
2082
|
+
const source = resolveSource(ref);
|
|
2083
|
+
if (source) reportSources.push(source);
|
|
2084
|
+
else reportPaths.push(path);
|
|
2085
|
+
}
|
|
2086
|
+
return {
|
|
2087
|
+
...reportPaths.length ? {
|
|
2088
|
+
reportPaths
|
|
2089
|
+
} : {},
|
|
2090
|
+
...reportSources.length ? {
|
|
2091
|
+
reportSources
|
|
2092
|
+
} : {}
|
|
2093
|
+
};
|
|
2094
|
+
};
|
|
2095
|
+
const attempts = result.case.attempts?.map((attempt)=>({
|
|
2096
|
+
...reviveAttempt(attempt),
|
|
2097
|
+
...resolveReports(attempt.reportRefs)
|
|
2098
|
+
}));
|
|
2099
|
+
const run = attempts?.at(-1);
|
|
2100
|
+
const { run: _run, attempts: _attempts, ...caseBase } = result.case;
|
|
2101
|
+
return {
|
|
2102
|
+
case: {
|
|
2103
|
+
...caseBase,
|
|
2104
|
+
...run ? {
|
|
2105
|
+
run
|
|
2106
|
+
} : {},
|
|
2107
|
+
...attempts ? {
|
|
2108
|
+
attempts
|
|
2109
|
+
} : {}
|
|
2110
|
+
},
|
|
2111
|
+
...result.document ? {
|
|
2112
|
+
document: {
|
|
2113
|
+
...reviveDocument(result.document),
|
|
2114
|
+
...resolveReports(result.document.reportRefs)
|
|
2115
|
+
}
|
|
2116
|
+
} : {},
|
|
2117
|
+
...result.lifecycle ? {
|
|
2118
|
+
lifecycle: reviveLifecycle(result.lifecycle)
|
|
2119
|
+
} : {}
|
|
2120
|
+
};
|
|
2121
|
+
};
|
|
2122
|
+
const runLocalCase = async (preparedTask, options, taskProgress, materializeReport, materializeSource)=>{
|
|
2123
|
+
const { prepared, document, collectedCase, task } = preparedTask;
|
|
2124
|
+
const { project } = prepared;
|
|
2125
|
+
const isolatedDocument = {
|
|
2126
|
+
...document,
|
|
2127
|
+
documentId: task.documentId,
|
|
2128
|
+
cases: [
|
|
2129
|
+
collectedCase
|
|
2130
|
+
]
|
|
2131
|
+
};
|
|
2132
|
+
const runtime = createProjectRuntime({
|
|
2133
|
+
project,
|
|
2134
|
+
setup: project.setup,
|
|
2135
|
+
signal: options.signal
|
|
2136
|
+
});
|
|
2137
|
+
let lifecycle;
|
|
2138
|
+
let execution;
|
|
2139
|
+
let executionError;
|
|
2140
|
+
try {
|
|
2141
|
+
await runtime.start();
|
|
2142
|
+
if (runtime.canRun) execution = await runWorkflowDocument(isolatedDocument, {
|
|
2143
|
+
resolveNode: project.nodes.require.bind(project.nodes),
|
|
2144
|
+
project,
|
|
2145
|
+
projectContext: runtime.context,
|
|
2146
|
+
retry: project.retry,
|
|
2147
|
+
signal: runtime.signal,
|
|
2148
|
+
defaultTimeoutMs: options.test.testTimeout,
|
|
2149
|
+
shouldStop: ()=>options.signal.aborted,
|
|
2150
|
+
stopReason: ()=>'interrupted',
|
|
2151
|
+
isFatalError: (run)=>[
|
|
2152
|
+
...run.beforeEach,
|
|
2153
|
+
...run.steps,
|
|
2154
|
+
...run.afterEach
|
|
2155
|
+
].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
|
|
2156
|
+
onStepStart: (info)=>taskProgress(`→ ${formatStep(info)}`),
|
|
2157
|
+
onStepResult: (info, result)=>taskProgress(formatStepResult(info, result)),
|
|
2158
|
+
onCaseResult: (attempt)=>taskProgress(`${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${project.retry + 1}: ${attempt.name} (${attempt.durationMs} ms)`)
|
|
2159
|
+
});
|
|
2160
|
+
} catch (error) {
|
|
2161
|
+
executionError = error;
|
|
2162
|
+
if (error instanceof WorkflowExecutionFailure) execution = error.result;
|
|
2163
|
+
} finally{
|
|
2164
|
+
const failed = void 0 !== executionError || !runtime.canRun || execution?.cases[0]?.status !== 'success' || execution?.document.status === 'failed';
|
|
2165
|
+
lifecycle = await runtime.finish(failed ? 'failed' : 'success');
|
|
2166
|
+
}
|
|
2167
|
+
if (executionError) {
|
|
2168
|
+
if (!execution) throw executionError;
|
|
2169
|
+
options.onInfrastructureError(executionError);
|
|
2170
|
+
}
|
|
2171
|
+
if (!execution) return toTransportResult({
|
|
2172
|
+
case: asNotRun(task.documentId, collectedCase, project.name, options.signal.aborted ? 'interrupted' : 'project-setup-failed'),
|
|
2173
|
+
lifecycle
|
|
2174
|
+
}, materializeReport, materializeSource);
|
|
2175
|
+
const outcome = execution.cases[0];
|
|
2176
|
+
if (!outcome) throw new Error(`Executor did not produce case result for ${task.caseId}.`);
|
|
2177
|
+
return toTransportResult({
|
|
2178
|
+
case: {
|
|
2179
|
+
...outcome,
|
|
2180
|
+
documentId: task.documentId
|
|
2181
|
+
},
|
|
2182
|
+
document: execution.document,
|
|
2183
|
+
lifecycle
|
|
2184
|
+
}, materializeReport, materializeSource);
|
|
2185
|
+
};
|
|
2186
|
+
const runCaseTask = async (preparedTask, taskIndex, taskCount, options, onCompleted)=>{
|
|
2187
|
+
const { prepared, task } = preparedTask;
|
|
2188
|
+
const taskProgress = (message)=>options.progress(`[case ${taskIndex + 1}/${taskCount}] ${task.projectName} / ${task.sourcePath} / ${task.caseName}: ${message}`);
|
|
2189
|
+
taskProgress('started');
|
|
2190
|
+
const outputDir = join(options.runDir, task.projectId, 'executor-output', task.documentId, task.caseId);
|
|
2191
|
+
mkdirSync(outputDir, {
|
|
2192
|
+
recursive: true
|
|
2193
|
+
});
|
|
2194
|
+
const { materialize: materializeReport, materializeSource, resolve: resolveReportReference, resolveSource } = createExecutorReports(outputDir);
|
|
2195
|
+
if (prepared.collectionErrors.length > 0) return {
|
|
2196
|
+
case: asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'project-preflight-failed')
|
|
2197
|
+
};
|
|
2198
|
+
const executor = options.executor ?? localTestExecutor;
|
|
2199
|
+
let result;
|
|
2200
|
+
let executorFailure;
|
|
2201
|
+
let executorAttempts = 0;
|
|
2202
|
+
for(let attemptIndex = 0; attemptIndex <= options.test.executorRetry; attemptIndex += 1){
|
|
2203
|
+
executorAttempts = attemptIndex + 1;
|
|
2204
|
+
try {
|
|
2205
|
+
result = await executor.execute(task, {
|
|
2206
|
+
signal: options.signal,
|
|
2207
|
+
onProgress: taskProgress,
|
|
2208
|
+
outputDir,
|
|
2209
|
+
materializeReport,
|
|
2210
|
+
runLocal: ()=>runLocalCase(preparedTask, options, taskProgress, materializeReport, materializeSource)
|
|
2211
|
+
});
|
|
2212
|
+
executorFailure = void 0;
|
|
2213
|
+
break;
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
if (!isTestExecutorError(error)) throw error;
|
|
2216
|
+
executorFailure = error;
|
|
2217
|
+
if (!error.retryable || attemptIndex >= options.test.executorRetry || options.signal.aborted) break;
|
|
2218
|
+
taskProgress(`executor retry ${attemptIndex + 1}/${options.test.executorRetry}: ${error.message}`);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
if (!result) {
|
|
2222
|
+
if (!executorFailure) throw new Error(`Executor did not return a result for ${task.caseId}.`);
|
|
2223
|
+
taskProgress(`executor-failed: ${executorFailure.message}`);
|
|
2224
|
+
return {
|
|
2225
|
+
case: {
|
|
2226
|
+
...asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'executor-failed'),
|
|
2227
|
+
execution: {
|
|
2228
|
+
executor: executor.name,
|
|
2229
|
+
resources: task.resources,
|
|
2230
|
+
attempts: executorAttempts,
|
|
2231
|
+
failure: {
|
|
2232
|
+
kind: executorFailure.kind,
|
|
2233
|
+
message: executorFailure.message,
|
|
2234
|
+
retryable: executorFailure.retryable
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
assertTestCaseTaskRunResult(task, result);
|
|
2241
|
+
const internalResult = fromTransportResult(result, resolveReportReference, resolveSource);
|
|
2242
|
+
const completed = {
|
|
2243
|
+
...internalResult,
|
|
2244
|
+
case: {
|
|
2245
|
+
...internalResult.case,
|
|
2246
|
+
execution: {
|
|
2247
|
+
executor: executor.name,
|
|
2248
|
+
resources: task.resources,
|
|
2249
|
+
attempts: executorAttempts,
|
|
2250
|
+
...internalResult.lifecycle ? {
|
|
2251
|
+
lifecycle: internalResult.lifecycle
|
|
2252
|
+
} : {},
|
|
2253
|
+
...result.artifacts ? {
|
|
2254
|
+
artifacts: result.artifacts
|
|
2255
|
+
} : {},
|
|
2256
|
+
...result.metadata ? {
|
|
2257
|
+
metadata: result.metadata
|
|
2258
|
+
} : {}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
onCompleted(completed);
|
|
2263
|
+
for (const attempt of internalResult.case.attempts ?? [])await writeCaseAttemptResult(options.runDir, task.projectId, task.documentId, attempt);
|
|
2264
|
+
if (internalResult.document) await writeWorkflowDocumentResult(options.runDir, internalResult.document);
|
|
2265
|
+
taskProgress(internalResult.case.status);
|
|
2266
|
+
return completed;
|
|
2267
|
+
};
|
|
2268
|
+
const runCaseExecution = async (options)=>{
|
|
2269
|
+
const tasks = prepareCaseTasks(options.projects);
|
|
2270
|
+
let failedCaseCount = 0;
|
|
2271
|
+
const bailReached = ()=>options.test.bail > 0 && failedCaseCount >= options.test.bail;
|
|
2272
|
+
const results = new Array(tasks.length);
|
|
2273
|
+
if (tasks.length > 0) try {
|
|
2274
|
+
await runTaskPool({
|
|
2275
|
+
tasks: tasks.map((preparedTask)=>preparedTask.task),
|
|
2276
|
+
maxConcurrency: Math.min(options.test.maxConcurrency, tasks.length),
|
|
2277
|
+
signal: options.signal,
|
|
2278
|
+
shouldStop: bailReached,
|
|
2279
|
+
run: async (_task, taskIndex)=>{
|
|
2280
|
+
try {
|
|
2281
|
+
const result = await runCaseTask(tasks[taskIndex], taskIndex, tasks.length, options, (completed)=>{
|
|
2282
|
+
results[taskIndex] = completed;
|
|
2283
|
+
});
|
|
2284
|
+
results[taskIndex] = result;
|
|
2285
|
+
if ('failed' === result.case.status) failedCaseCount += 1;
|
|
2286
|
+
return result;
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
options.onInfrastructureError(error);
|
|
2289
|
+
throw error;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
options.onInfrastructureError(error);
|
|
2295
|
+
}
|
|
2296
|
+
return options.projects.map((prepared, projectIndex)=>{
|
|
2297
|
+
const projectTasks = tasks.map((task, taskIndex)=>({
|
|
2298
|
+
task,
|
|
2299
|
+
result: results[taskIndex]
|
|
2300
|
+
})).filter(({ task })=>task.projectIndex === projectIndex);
|
|
2301
|
+
const reason = prepared.collectionErrors.length ? 'project-preflight-failed' : options.signal.aborted ? 'interrupted' : bailReached() ? 'bail' : void 0;
|
|
2302
|
+
const cases = projectTasks.map(({ task, result })=>{
|
|
2303
|
+
if (result) return result.case;
|
|
2304
|
+
if (!reason) throw new Error(`Case scheduler did not produce a result for "${task.task.caseId}".`);
|
|
2305
|
+
return asNotRun(task.task.documentId, task.collectedCase, prepared.project.name, reason);
|
|
2306
|
+
});
|
|
2307
|
+
const documents = projectTasks.flatMap(({ result })=>result?.document ? [
|
|
2308
|
+
result.document
|
|
2309
|
+
] : []);
|
|
2310
|
+
return buildProjectResult(prepared, cases, documents);
|
|
2311
|
+
});
|
|
2312
|
+
};
|
|
1117
2313
|
const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
|
|
1118
2314
|
...attempt.beforeEach,
|
|
1119
2315
|
...attempt.steps,
|
|
@@ -1159,7 +2355,7 @@ async function executeDocumentInvocation(options) {
|
|
|
1159
2355
|
},
|
|
1160
2356
|
onStepResult: async (info, result)=>{
|
|
1161
2357
|
await invocation.bindings.onStepResult?.(info, result);
|
|
1162
|
-
options.onProgress(formatStepResult(info, result));
|
|
2358
|
+
options.onProgress(formatStepResult(info, result, 'case' === info.scope ? ' ' : ' '));
|
|
1163
2359
|
},
|
|
1164
2360
|
onCaseResult: async (attempt)=>{
|
|
1165
2361
|
await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
|
|
@@ -1277,16 +2473,6 @@ function createOperationJournal(runDir) {
|
|
|
1277
2473
|
}
|
|
1278
2474
|
};
|
|
1279
2475
|
}
|
|
1280
|
-
const asNotRun = (documentId, collectedCase, projectName, reason)=>({
|
|
1281
|
-
documentId,
|
|
1282
|
-
caseId: collectedCase.caseId,
|
|
1283
|
-
projectName,
|
|
1284
|
-
name: collectedCase.definition.name,
|
|
1285
|
-
sourcePath: collectedCase.sourcePath,
|
|
1286
|
-
caseIndex: collectedCase.caseIndex,
|
|
1287
|
-
status: 'not-run',
|
|
1288
|
-
notRunReason: reason
|
|
1289
|
-
});
|
|
1290
2476
|
const summarize = (projects)=>{
|
|
1291
2477
|
const healthCases = projects.flatMap((project)=>{
|
|
1292
2478
|
const documentAttemptByRunId = new Map(project.documents.map((document)=>[
|
|
@@ -1320,12 +2506,6 @@ const summarize = (projects)=>{
|
|
|
1320
2506
|
projectFailures: projects.filter((project)=>'failed' === project.status).length
|
|
1321
2507
|
};
|
|
1322
2508
|
};
|
|
1323
|
-
const latestById = (items, id)=>[
|
|
1324
|
-
...new Map(items.map((item)=>[
|
|
1325
|
-
id(item),
|
|
1326
|
-
item
|
|
1327
|
-
])).values()
|
|
1328
|
-
];
|
|
1329
2509
|
const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>asNotRun(document.documentId, item, prepared.project.name, reason)));
|
|
1330
2510
|
async function runPreparedTestProject(plan) {
|
|
1331
2511
|
const { startedAt, runId, projectRoot, configPath, resultDir, runDir, summaryPath, reportDir, definition, projects: preparedProjects } = plan;
|
|
@@ -1338,7 +2518,7 @@ async function runPreparedTestProject(plan) {
|
|
|
1338
2518
|
await writeTestRunPlan(runDir, preparedProjects);
|
|
1339
2519
|
const journal = createOperationJournal(runDir);
|
|
1340
2520
|
const rootController = new AbortController();
|
|
1341
|
-
setMaxListeners(Math.max(10, effectiveConcurrency + 1), rootController.signal);
|
|
2521
|
+
setMaxListeners(Math.max(10, ('case' === definition.test.executionUnit ? Math.min(definition.test.maxConcurrency, totalCases) : effectiveConcurrency) + 1), rootController.signal);
|
|
1342
2522
|
const handleSignal = (signal)=>{
|
|
1343
2523
|
rootController.abort(new Error(`Workflow interrupted by ${signal}.`));
|
|
1344
2524
|
};
|
|
@@ -1380,31 +2560,6 @@ async function runPreparedTestProject(plan) {
|
|
|
1380
2560
|
const { project } = prepared;
|
|
1381
2561
|
runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
|
|
1382
2562
|
};
|
|
1383
|
-
const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
|
|
1384
|
-
const { project } = prepared;
|
|
1385
|
-
const projectFailed = prepared.collectionErrors.length > 0 || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status) || latestById(documents, (item)=>item.documentId).some((item)=>'failed' === item.status) || lifecycle?.status === 'failed';
|
|
1386
|
-
return {
|
|
1387
|
-
projectId: project.projectId,
|
|
1388
|
-
name: project.name,
|
|
1389
|
-
...prepared.platform ? {
|
|
1390
|
-
platform: prepared.platform
|
|
1391
|
-
} : {},
|
|
1392
|
-
status: projectFailed ? 'failed' : 'success',
|
|
1393
|
-
retry: project.retry,
|
|
1394
|
-
fileSelection: prepared.fileSelection,
|
|
1395
|
-
tagSelection: project.tags,
|
|
1396
|
-
sourceCount: prepared.sources.length,
|
|
1397
|
-
selectedCaseCount: prepared.selectedCaseCount,
|
|
1398
|
-
filteredCaseCount: prepared.filteredCaseCount,
|
|
1399
|
-
...lifecycle ? {
|
|
1400
|
-
lifecycle
|
|
1401
|
-
} : {},
|
|
1402
|
-
cases,
|
|
1403
|
-
plans: prepared.plans,
|
|
1404
|
-
documents,
|
|
1405
|
-
collectionErrors: prepared.collectionErrors
|
|
1406
|
-
};
|
|
1407
|
-
};
|
|
1408
2563
|
const buildSkippedProjectResult = (prepared, projectIndex, reason)=>{
|
|
1409
2564
|
announceProject(prepared, projectIndex);
|
|
1410
2565
|
return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
|
|
@@ -1505,7 +2660,7 @@ async function runPreparedTestProject(plan) {
|
|
|
1505
2660
|
};
|
|
1506
2661
|
const projectResults = new Array(preparedProjects.length);
|
|
1507
2662
|
try {
|
|
1508
|
-
await runConcurrentJobs(preparedProjects, {
|
|
2663
|
+
if ('case' !== definition.test.executionUnit || 'run' === plan.preflightScope && totalErrors > 0) await runConcurrentJobs(preparedProjects, {
|
|
1509
2664
|
concurrency: effectiveConcurrency,
|
|
1510
2665
|
shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
|
|
1511
2666
|
}, async (prepared, index)=>{
|
|
@@ -1516,6 +2671,20 @@ async function runPreparedTestProject(plan) {
|
|
|
1516
2671
|
throw error;
|
|
1517
2672
|
}
|
|
1518
2673
|
});
|
|
2674
|
+
else {
|
|
2675
|
+
const results = await runCaseExecution({
|
|
2676
|
+
projects: preparedProjects,
|
|
2677
|
+
test: definition.test,
|
|
2678
|
+
executor: definition.executor,
|
|
2679
|
+
runDir,
|
|
2680
|
+
signal: rootController.signal,
|
|
2681
|
+
progress,
|
|
2682
|
+
onInfrastructureError: recordInfrastructureError
|
|
2683
|
+
});
|
|
2684
|
+
results.forEach((result, index)=>{
|
|
2685
|
+
projectResults[index] = result;
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
1519
2688
|
} catch (error) {
|
|
1520
2689
|
recordInfrastructureError(error);
|
|
1521
2690
|
} finally{
|
|
@@ -1621,6 +2790,6 @@ async function runPreparedTestProject(plan) {
|
|
|
1621
2790
|
async function runTestProject(options = {}) {
|
|
1622
2791
|
return runPreparedTestProject(await prepareTestRun(options));
|
|
1623
2792
|
}
|
|
1624
|
-
export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
|
|
2793
|
+
export { DEFAULT_TEST_FILE_SELECTION, TestExecutorError, assertTestCaseTaskRunResult, createTestRunId, defineProjectSetup, defineTestExecutor, defineTestProject, discoverSelectedTestFiles, discoverTestConfig, discoverTestFiles, isTestExecutorError, loadTestProject, localTestExecutor, runTestProject, validateTestFileSelection };
|
|
1625
2794
|
|
|
1626
2795
|
//# sourceMappingURL=index.mjs.map
|