@midscene/test 1.12.10-beta-20260920031616.0 → 1.13.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/{1~891.mjs → 1~214.mjs} +6 -6
- package/dist/es/{1~891.mjs.map → 1~214.mjs.map} +1 -1
- package/dist/es/cli/index.mjs +534 -1700
- package/dist/es/cli/index.mjs.map +1 -1
- package/dist/es/index.mjs +57 -1525
- package/dist/es/index.mjs.map +1 -1
- package/dist/es/midscene/index.mjs +93 -168
- package/dist/es/midscene/index.mjs.map +1 -1
- package/dist/es/runtime/index.mjs +317 -0
- package/dist/es/runtime/index.mjs.map +1 -0
- package/dist/es/test-cli.mjs +666 -1780
- package/dist/es/test-cli.mjs.map +1 -1
- package/dist/lib/{0~458.js → 0~160.js} +5 -5
- package/dist/lib/{0~458.js.map → 0~160.js.map} +1 -1
- package/dist/lib/cli/index.js +545 -1711
- package/dist/lib/cli/index.js.map +1 -1
- package/dist/lib/index.js +92 -1557
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/midscene/index.js +130 -172
- package/dist/lib/midscene/index.js.map +1 -1
- package/dist/lib/runtime/index.js +372 -0
- package/dist/lib/runtime/index.js.map +1 -0
- package/dist/lib/test-cli.js +670 -1784
- package/dist/lib/test-cli.js.map +1 -1
- package/dist/types/cli/index.d.ts +62 -233
- package/dist/types/index.d.ts +186 -492
- package/dist/types/midscene/index.d.ts +82 -96
- package/dist/types/runtime/index.d.ts +52 -0
- package/package.json +51 -8
package/dist/lib/cli/index.js
CHANGED
|
@@ -38,276 +38,8 @@ const external_node_path_namespaceObject = require("node:path");
|
|
|
38
38
|
const external_node_url_namespaceObject = require("node:url");
|
|
39
39
|
const api_namespaceObject = require("tsx/cjs/api");
|
|
40
40
|
const esm_api_namespaceObject = require("tsx/esm/api");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
value: value,
|
|
44
|
-
enumerable: true,
|
|
45
|
-
configurable: true,
|
|
46
|
-
writable: true
|
|
47
|
-
});
|
|
48
|
-
else obj[key] = value;
|
|
49
|
-
return obj;
|
|
50
|
-
}
|
|
51
|
-
class WorkflowError extends Error {
|
|
52
|
-
toJSON() {
|
|
53
|
-
return {
|
|
54
|
-
name: this.name,
|
|
55
|
-
message: this.message,
|
|
56
|
-
code: this.code,
|
|
57
|
-
...void 0 === this.details ? {} : {
|
|
58
|
-
details: this.details
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
constructor(message, options = {}){
|
|
63
|
-
super(message, {
|
|
64
|
-
cause: options.cause
|
|
65
|
-
}), _define_property(this, "code", void 0), _define_property(this, "details", void 0);
|
|
66
|
-
this.name = new.target.name;
|
|
67
|
-
this.code = options.code ?? 'WORKFLOW_ERROR';
|
|
68
|
-
this.details = options.details;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
class WorkflowParseError extends WorkflowError {
|
|
72
|
-
constructor(message, details, cause){
|
|
73
|
-
super(message, {
|
|
74
|
-
code: 'WORKFLOW_PARSE_ERROR',
|
|
75
|
-
details,
|
|
76
|
-
cause
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
class NodeDefinitionError extends WorkflowError {
|
|
81
|
-
constructor(message, details){
|
|
82
|
-
super(message, {
|
|
83
|
-
code: 'NODE_DEFINITION_ERROR',
|
|
84
|
-
details
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
class DuplicateNodeError extends WorkflowError {
|
|
89
|
-
constructor(node){
|
|
90
|
-
super(`Node "${node}" is already registered.`, {
|
|
91
|
-
code: 'DUPLICATE_NODE',
|
|
92
|
-
details: {
|
|
93
|
-
node
|
|
94
|
-
}
|
|
95
|
-
}), _define_property(this, "node", void 0);
|
|
96
|
-
this.node = node;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
class NodeNotFoundError extends WorkflowError {
|
|
100
|
-
constructor(node){
|
|
101
|
-
super(`Node "${node}" is not registered.`, {
|
|
102
|
-
code: 'NODE_NOT_FOUND',
|
|
103
|
-
details: {
|
|
104
|
-
node
|
|
105
|
-
}
|
|
106
|
-
}), _define_property(this, "node", void 0);
|
|
107
|
-
this.node = node;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
class NodeInputValidationError extends WorkflowError {
|
|
111
|
-
static fromZod(node, error) {
|
|
112
|
-
const issues = error.issues.map((issue)=>({
|
|
113
|
-
code: issue.code,
|
|
114
|
-
path: issue.path.map(String).join('.'),
|
|
115
|
-
message: issue.message
|
|
116
|
-
}));
|
|
117
|
-
const firstIssue = issues[0];
|
|
118
|
-
const path = firstIssue?.path || '<root>';
|
|
119
|
-
const message = firstIssue?.message ?? 'invalid input';
|
|
120
|
-
return new NodeInputValidationError(`Node "${node}" input validation failed at "${path}": ${message}`, {
|
|
121
|
-
node,
|
|
122
|
-
issues
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
constructor(message, details){
|
|
126
|
-
super(message, {
|
|
127
|
-
code: 'NODE_INPUT_VALIDATION_ERROR',
|
|
128
|
-
details
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
class StepTimeoutError extends WorkflowError {
|
|
133
|
-
constructor(timeoutMs, node){
|
|
134
|
-
super(node ? `Node "${node}" timed out after ${timeoutMs}ms.` : `Step timed out after ${timeoutMs}ms.`, {
|
|
135
|
-
code: 'STEP_TIMEOUT',
|
|
136
|
-
details: {
|
|
137
|
-
timeoutMs,
|
|
138
|
-
...void 0 === node ? {} : {
|
|
139
|
-
node
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}), _define_property(this, "timeoutMs", void 0), _define_property(this, "node", void 0);
|
|
143
|
-
this.timeoutMs = timeoutMs;
|
|
144
|
-
this.node = node;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
class NodeExecutionError extends WorkflowError {
|
|
148
|
-
constructor(node, cause){
|
|
149
|
-
const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
|
|
150
|
-
super(`Node "${node}" failed: ${causeMessage}`, {
|
|
151
|
-
code: 'NODE_EXECUTION_ERROR',
|
|
152
|
-
details: {
|
|
153
|
-
node
|
|
154
|
-
},
|
|
155
|
-
cause
|
|
156
|
-
}), _define_property(this, "node", void 0);
|
|
157
|
-
this.node = node;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
class WorkflowLifecycleError extends WorkflowError {
|
|
161
|
-
constructor(message, details){
|
|
162
|
-
super(message, {
|
|
163
|
-
code: 'WORKFLOW_LIFECYCLE_ERROR',
|
|
164
|
-
details
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
class ProjectSetupError extends WorkflowError {
|
|
169
|
-
constructor(cause, details){
|
|
170
|
-
const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
|
|
171
|
-
super(`Project "${details.projectName}" setup failed: ${causeMessage}`, {
|
|
172
|
-
code: 'PROJECT_SETUP_ERROR',
|
|
173
|
-
details,
|
|
174
|
-
cause
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
class ProjectTeardownError extends WorkflowError {
|
|
179
|
-
constructor(cause, details){
|
|
180
|
-
const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
|
|
181
|
-
super(`Project "${details.projectName}" teardown failed: ${causeMessage}`, {
|
|
182
|
-
code: 'PROJECT_TEARDOWN_ERROR',
|
|
183
|
-
details,
|
|
184
|
-
cause
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
class NodeScopeTeardownError extends WorkflowError {
|
|
189
|
-
constructor(cause, details){
|
|
190
|
-
const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
|
|
191
|
-
super(`${'case' === details.scope ? 'Case attempt' : 'Workflow document'} node teardown failed for "${details.node}": ${causeMessage}`, {
|
|
192
|
-
code: 'NODE_SCOPE_TEARDOWN_ERROR',
|
|
193
|
-
details,
|
|
194
|
-
cause
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
class FatalDeviceError extends WorkflowError {
|
|
199
|
-
constructor(message, cause){
|
|
200
|
-
super(message, {
|
|
201
|
-
code: 'FATAL_DEVICE_ERROR',
|
|
202
|
-
cause
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
const isFatalDeviceError = (error)=>{
|
|
207
|
-
if (error instanceof FatalDeviceError) return true;
|
|
208
|
-
if (error instanceof WorkflowError && 'FATAL_DEVICE_ERROR' === error.code) return true;
|
|
209
|
-
if (error instanceof Error && /device offline|device not found|(?:adb|bdc|device) connection (?:was )?closed/i.test(error.message)) return true;
|
|
210
|
-
return error instanceof Error && void 0 !== error.cause ? isFatalDeviceError(error.cause) : false;
|
|
211
|
-
};
|
|
212
|
-
function normalizeNodeExecutionError(error, node) {
|
|
213
|
-
return error instanceof WorkflowError ? error : new NodeExecutionError(node, error);
|
|
214
|
-
}
|
|
215
|
-
const v4_namespaceObject = require("zod/v4");
|
|
216
|
-
const validateOptionalText = (value, field, node)=>{
|
|
217
|
-
if (void 0 !== value && ('string' != typeof value || 0 === value.trim().length)) throw new NodeDefinitionError(`Node "${node}" ${field} must be a non-empty string.`, {
|
|
218
|
-
node,
|
|
219
|
-
field
|
|
220
|
-
});
|
|
221
|
-
};
|
|
222
|
-
const validateInputSchema = (schema, node)=>{
|
|
223
|
-
if (void 0 === schema) return;
|
|
224
|
-
if (!(schema instanceof v4_namespaceObject.z.ZodObject)) throw new NodeDefinitionError(`Node "${node}" inputSchema must be a Zod object schema.`, {
|
|
225
|
-
node,
|
|
226
|
-
field: 'inputSchema'
|
|
227
|
-
});
|
|
228
|
-
if ('$' in schema.shape) throw new NodeDefinitionError(`Node "${node}" inputSchema must not declare "$" as an input property.`, {
|
|
229
|
-
node,
|
|
230
|
-
field: 'inputSchema.$'
|
|
231
|
-
});
|
|
232
|
-
};
|
|
233
|
-
const validateDefinition = (options)=>{
|
|
234
|
-
if (!options || 'object' != typeof options) throw new NodeDefinitionError('Node definition must be an object.');
|
|
235
|
-
if ('string' != typeof options.name || 0 === options.name.trim().length) throw new NodeDefinitionError('Node name must be a non-empty string.');
|
|
236
|
-
validateOptionalText(options.title, 'title', options.name);
|
|
237
|
-
validateOptionalText(options.description, "description", options.name);
|
|
238
|
-
if (void 0 !== options.stringInputKey && false !== options.stringInputKey && ('string' != typeof options.stringInputKey || 0 === options.stringInputKey.trim().length)) throw new NodeDefinitionError(`Node "${options.name}" stringInputKey must be a non-empty string or false.`, {
|
|
239
|
-
node: options.name,
|
|
240
|
-
field: 'stringInputKey'
|
|
241
|
-
});
|
|
242
|
-
validateInputSchema(options.inputSchema, options.name);
|
|
243
|
-
if ('string' == typeof options.stringInputKey && options.inputSchema instanceof v4_namespaceObject.z.ZodObject && !(options.stringInputKey in options.inputSchema.shape)) throw new NodeDefinitionError(`Node "${options.name}" stringInputKey must reference a field in inputSchema.`, {
|
|
244
|
-
node: options.name,
|
|
245
|
-
field: 'stringInputKey'
|
|
246
|
-
});
|
|
247
|
-
if ('function' != typeof options.execute) throw new NodeDefinitionError(`Node "${options.name}" must provide an execute function.`, {
|
|
248
|
-
node: options.name
|
|
249
|
-
});
|
|
250
|
-
};
|
|
251
|
-
function defineNode(options) {
|
|
252
|
-
validateDefinition(options);
|
|
253
|
-
return options;
|
|
254
|
-
}
|
|
255
|
-
function _check_private_redeclaration(obj, privateCollection) {
|
|
256
|
-
if (privateCollection.has(obj)) throw new TypeError("Cannot initialize the same private elements twice on an object");
|
|
257
|
-
}
|
|
258
|
-
function _class_apply_descriptor_get(receiver, descriptor) {
|
|
259
|
-
if (descriptor.get) return descriptor.get.call(receiver);
|
|
260
|
-
return descriptor.value;
|
|
261
|
-
}
|
|
262
|
-
function _class_extract_field_descriptor(receiver, privateMap, action) {
|
|
263
|
-
if (!privateMap.has(receiver)) throw new TypeError("attempted to " + action + " private field on non-instance");
|
|
264
|
-
return privateMap.get(receiver);
|
|
265
|
-
}
|
|
266
|
-
function _class_private_field_get(receiver, privateMap) {
|
|
267
|
-
var descriptor = _class_extract_field_descriptor(receiver, privateMap, "get");
|
|
268
|
-
return _class_apply_descriptor_get(receiver, descriptor);
|
|
269
|
-
}
|
|
270
|
-
function _class_private_field_init(obj, privateMap, value) {
|
|
271
|
-
_check_private_redeclaration(obj, privateMap);
|
|
272
|
-
privateMap.set(obj, value);
|
|
273
|
-
}
|
|
274
|
-
var _nodes = /*#__PURE__*/ new WeakMap();
|
|
275
|
-
class NodeRegistry {
|
|
276
|
-
register(node) {
|
|
277
|
-
defineNode(node);
|
|
278
|
-
if (_class_private_field_get(this, _nodes).has(node.name)) throw new DuplicateNodeError(node.name);
|
|
279
|
-
_class_private_field_get(this, _nodes).set(node.name, node);
|
|
280
|
-
return this;
|
|
281
|
-
}
|
|
282
|
-
get(name) {
|
|
283
|
-
return _class_private_field_get(this, _nodes).get(name);
|
|
284
|
-
}
|
|
285
|
-
require(name) {
|
|
286
|
-
const node = this.get(name);
|
|
287
|
-
if (!node) throw new NodeNotFoundError(name);
|
|
288
|
-
return node;
|
|
289
|
-
}
|
|
290
|
-
has(name) {
|
|
291
|
-
return _class_private_field_get(this, _nodes).has(name);
|
|
292
|
-
}
|
|
293
|
-
names() {
|
|
294
|
-
return [
|
|
295
|
-
..._class_private_field_get(this, _nodes).keys()
|
|
296
|
-
];
|
|
297
|
-
}
|
|
298
|
-
definitions() {
|
|
299
|
-
return [
|
|
300
|
-
..._class_private_field_get(this, _nodes).values()
|
|
301
|
-
];
|
|
302
|
-
}
|
|
303
|
-
constructor(nodes = []){
|
|
304
|
-
_class_private_field_init(this, _nodes, {
|
|
305
|
-
writable: true,
|
|
306
|
-
value: new Map()
|
|
307
|
-
});
|
|
308
|
-
for (const node of nodes)this.register(node);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
41
|
+
const test_runner_namespaceObject = require("@midscene/core/internal/test-runner");
|
|
42
|
+
const NodeRegistry = test_runner_namespaceObject.NodeRegistry;
|
|
311
43
|
const projectIdFromIndex = (index)=>`project-${index}`;
|
|
312
44
|
const defineTestProject = (definition)=>definition;
|
|
313
45
|
const defineProjectSetup = (definition)=>definition;
|
|
@@ -347,6 +79,10 @@ const validatePatterns = (value, field, label = 'files', allowEmptyInclude = fal
|
|
|
347
79
|
const validateTestFileSelection = (value, label = 'files')=>{
|
|
348
80
|
if (void 0 === value) return;
|
|
349
81
|
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
82
|
+
rejectUnknownKeys(value, [
|
|
83
|
+
'include',
|
|
84
|
+
'exclude'
|
|
85
|
+
], label);
|
|
350
86
|
const include = validatePatterns(value.include, 'include', label);
|
|
351
87
|
const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
|
|
352
88
|
return Object.freeze({
|
|
@@ -402,7 +138,7 @@ const validateVariables = (value, label)=>{
|
|
|
402
138
|
assertJsonValue(value, label, new Set());
|
|
403
139
|
return deepFreezeJson(value);
|
|
404
140
|
};
|
|
405
|
-
const
|
|
141
|
+
const validateSetup = (value, label)=>{
|
|
406
142
|
if (void 0 === value) return;
|
|
407
143
|
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
408
144
|
rejectUnknownKeys(value, [
|
|
@@ -425,7 +161,7 @@ const validateNonNegativeInteger = (value, fallback, label)=>{
|
|
|
425
161
|
};
|
|
426
162
|
const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
427
163
|
if (void 0 === value) {
|
|
428
|
-
const setup =
|
|
164
|
+
const setup = validateSetup(defaultSetup, 'setup');
|
|
429
165
|
return {
|
|
430
166
|
hasExplicitProjects: false,
|
|
431
167
|
projects: Object.freeze([
|
|
@@ -482,7 +218,7 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
|
482
218
|
retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
|
|
483
219
|
variables: validateVariables(candidate.variables, `${label}.variables`),
|
|
484
220
|
...void 0 === candidate.setup ? {} : {
|
|
485
|
-
setup:
|
|
221
|
+
setup: validateSetup(candidate.setup, `${label}.setup`)
|
|
486
222
|
}
|
|
487
223
|
});
|
|
488
224
|
});
|
|
@@ -577,772 +313,31 @@ async function loadTestProject(configPath) {
|
|
|
577
313
|
}
|
|
578
314
|
return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
|
|
579
315
|
}
|
|
580
|
-
const external_node_crypto_namespaceObject = require("node:crypto");
|
|
581
|
-
const external_node_events_namespaceObject = require("node:events");
|
|
582
316
|
const external_node_fs_namespaceObject = require("node:fs");
|
|
583
|
-
const report_namespaceObject = require("@midscene/core/report");
|
|
584
317
|
const external_tinyglobby_namespaceObject = require("tinyglobby");
|
|
585
|
-
const createProjectRuntime = (options)=>{
|
|
586
|
-
const startedAt = new Date();
|
|
587
|
-
const controller = new AbortController();
|
|
588
|
-
const parentSignal = options.signal;
|
|
589
|
-
const abortFromParent = ()=>controller.abort(parentSignal?.reason);
|
|
590
|
-
if (parentSignal?.aborted) abortFromParent();
|
|
591
|
-
else parentSignal?.addEventListener('abort', abortFromParent, {
|
|
592
|
-
once: true
|
|
593
|
-
});
|
|
594
|
-
const teardownStack = [];
|
|
595
|
-
let context;
|
|
596
|
-
let setupError;
|
|
597
|
-
let started = false;
|
|
598
|
-
let acceptingTeardowns = void 0 !== options.setup;
|
|
599
|
-
let finishedResult;
|
|
600
|
-
const createResult = (requestedStatus, teardownErrors = [])=>{
|
|
601
|
-
const endedAt = new Date();
|
|
602
|
-
return {
|
|
603
|
-
projectName: options.project.name,
|
|
604
|
-
status: 'failed' === requestedStatus || setupError || teardownErrors.length > 0 ? 'failed' : 'success',
|
|
605
|
-
...setupError ? {
|
|
606
|
-
setupError
|
|
607
|
-
} : {},
|
|
608
|
-
...teardownErrors.length > 0 ? {
|
|
609
|
-
teardownErrors
|
|
610
|
-
} : {},
|
|
611
|
-
startedAt: startedAt.toISOString(),
|
|
612
|
-
endedAt: endedAt.toISOString(),
|
|
613
|
-
durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
|
|
614
|
-
};
|
|
615
|
-
};
|
|
616
|
-
const onTeardown = (teardown)=>{
|
|
617
|
-
if (!acceptingTeardowns) throw new WorkflowLifecycleError('onTeardown() can only be called while Project setup is running.', {
|
|
618
|
-
projectName: options.project.name
|
|
619
|
-
});
|
|
620
|
-
if ('function' != typeof teardown) throw new WorkflowLifecycleError('Project onTeardown() requires a teardown function.', {
|
|
621
|
-
projectName: options.project.name
|
|
622
|
-
});
|
|
623
|
-
teardownStack.push({
|
|
624
|
-
registrationIndex: teardownStack.length,
|
|
625
|
-
teardown
|
|
626
|
-
});
|
|
627
|
-
};
|
|
628
|
-
return {
|
|
629
|
-
get context () {
|
|
630
|
-
return context;
|
|
631
|
-
},
|
|
632
|
-
get signal () {
|
|
633
|
-
return controller.signal;
|
|
634
|
-
},
|
|
635
|
-
get canRun () {
|
|
636
|
-
return started && !setupError && !controller.signal.aborted;
|
|
637
|
-
},
|
|
638
|
-
async start () {
|
|
639
|
-
if (started) throw new WorkflowLifecycleError('Project runtime has already started.', {
|
|
640
|
-
projectName: options.project.name
|
|
641
|
-
});
|
|
642
|
-
started = true;
|
|
643
|
-
if (controller.signal.aborted) {
|
|
644
|
-
acceptingTeardowns = false;
|
|
645
|
-
return createResult('failed');
|
|
646
|
-
}
|
|
647
|
-
if (options.setup) try {
|
|
648
|
-
context = await options.setup.setup({
|
|
649
|
-
project: options.project,
|
|
650
|
-
env: Object.freeze({
|
|
651
|
-
...process.env
|
|
652
|
-
}),
|
|
653
|
-
signal: controller.signal,
|
|
654
|
-
onTeardown
|
|
655
|
-
});
|
|
656
|
-
} catch (error) {
|
|
657
|
-
setupError = new ProjectSetupError(error, {
|
|
658
|
-
projectName: options.project.name
|
|
659
|
-
});
|
|
660
|
-
} finally{
|
|
661
|
-
acceptingTeardowns = false;
|
|
662
|
-
}
|
|
663
|
-
return createResult(setupError ? 'failed' : 'success');
|
|
664
|
-
},
|
|
665
|
-
async finish (requestedStatus = 'success') {
|
|
666
|
-
if (finishedResult) return finishedResult;
|
|
667
|
-
if (!started) throw new WorkflowLifecycleError('Project runtime must start before it can finish.', {
|
|
668
|
-
projectName: options.project.name
|
|
669
|
-
});
|
|
670
|
-
const statusBeforeTeardown = createResult(requestedStatus).status;
|
|
671
|
-
const teardownErrors = [];
|
|
672
|
-
for (const { registrationIndex, teardown } of teardownStack.reverse())try {
|
|
673
|
-
await teardown({
|
|
674
|
-
project: options.project,
|
|
675
|
-
context,
|
|
676
|
-
status: statusBeforeTeardown,
|
|
677
|
-
...setupError ? {
|
|
678
|
-
setupError
|
|
679
|
-
} : {}
|
|
680
|
-
});
|
|
681
|
-
} catch (error) {
|
|
682
|
-
teardownErrors.push(new ProjectTeardownError(error, {
|
|
683
|
-
projectName: options.project.name,
|
|
684
|
-
registrationIndex
|
|
685
|
-
}));
|
|
686
|
-
}
|
|
687
|
-
parentSignal?.removeEventListener('abort', abortFromParent);
|
|
688
|
-
finishedResult = createResult(requestedStatus, teardownErrors);
|
|
689
|
-
return finishedResult;
|
|
690
|
-
},
|
|
691
|
-
abort (reason) {
|
|
692
|
-
controller.abort(reason);
|
|
693
|
-
}
|
|
694
|
-
};
|
|
695
|
-
};
|
|
696
|
-
const assertJsonSerializable = (value, label, node)=>{
|
|
697
|
-
const visit = (candidate, path, seen)=>{
|
|
698
|
-
if (null === candidate || 'string' == typeof candidate || 'boolean' == typeof candidate) return;
|
|
699
|
-
if ('number' == typeof candidate) {
|
|
700
|
-
if (Number.isFinite(candidate)) return;
|
|
701
|
-
throw new TypeError(`${path} contains a non-finite number.`);
|
|
702
|
-
}
|
|
703
|
-
if (void 0 === candidate) throw new TypeError(`${path} contains undefined.`);
|
|
704
|
-
if ('object' != typeof candidate) throw new TypeError(`${path} is not JSON-serializable.`);
|
|
705
|
-
if (!Array.isArray(candidate) && Object.getPrototypeOf(candidate) !== Object.prototype && null !== Object.getPrototypeOf(candidate)) throw new TypeError(`${path} must contain only plain objects.`);
|
|
706
|
-
if (seen.has(candidate)) throw new TypeError(`${path} contains a cycle.`);
|
|
707
|
-
seen.add(candidate);
|
|
708
|
-
if (Array.isArray(candidate)) candidate.forEach((child, index)=>visit(child, `${path}[${index}]`, seen));
|
|
709
|
-
else for (const [key, child] of Object.entries(candidate))visit(child, `${path}.${key}`, seen);
|
|
710
|
-
seen.delete(candidate);
|
|
711
|
-
};
|
|
712
|
-
try {
|
|
713
|
-
visit(value, label, new Set());
|
|
714
|
-
} catch (error) {
|
|
715
|
-
throw new NodeExecutionError(node, error);
|
|
716
|
-
}
|
|
717
|
-
};
|
|
718
|
-
function validateNodeOutput(output, node) {
|
|
719
|
-
if (void 0 === output) return;
|
|
720
|
-
if ('object' != typeof output || null === output || Array.isArray(output)) throw new NodeExecutionError(node, new TypeError('Node output must be an object or undefined.'));
|
|
721
|
-
if ('summary' in output && void 0 !== output.summary && 'string' != typeof output.summary) throw new NodeExecutionError(node, new TypeError('Node output summary must be a string.'));
|
|
722
|
-
if ('data' in output && void 0 !== output.data) assertJsonSerializable(output.data, 'output.data', node);
|
|
723
|
-
return output;
|
|
724
|
-
}
|
|
725
|
-
function createStepResultBase(step, startedAt, phase, stepIndex) {
|
|
726
|
-
const endedAt = new Date();
|
|
727
|
-
return {
|
|
728
|
-
node: step.node,
|
|
729
|
-
phase,
|
|
730
|
-
stepIndex,
|
|
731
|
-
input: step.input,
|
|
732
|
-
meta: step.meta,
|
|
733
|
-
startedAt: startedAt.toISOString(),
|
|
734
|
-
endedAt: endedAt.toISOString(),
|
|
735
|
-
durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
|
|
736
|
-
};
|
|
737
|
-
}
|
|
738
|
-
async function executeNode(step, execute, phase, stepIndex, options) {
|
|
739
|
-
const startedAt = new Date();
|
|
740
|
-
const abortController = new AbortController();
|
|
741
|
-
const parentSignal = options.parentSignal;
|
|
742
|
-
const abortFromParent = ()=>abortController.abort(parentSignal?.reason);
|
|
743
|
-
if (parentSignal?.aborted) abortFromParent();
|
|
744
|
-
else parentSignal?.addEventListener('abort', abortFromParent, {
|
|
745
|
-
once: true
|
|
746
|
-
});
|
|
747
|
-
const timeoutMs = step.meta.timeoutMs ?? options.defaultTimeoutMs;
|
|
748
|
-
let timeout;
|
|
749
|
-
let abortGraceTimeout;
|
|
750
|
-
const execution = Promise.resolve().then(()=>{
|
|
751
|
-
abortController.signal.throwIfAborted();
|
|
752
|
-
return execute(abortController.signal);
|
|
753
|
-
});
|
|
754
|
-
let settleOnAbort;
|
|
755
|
-
const abortedExecution = new Promise((_, reject)=>{
|
|
756
|
-
settleOnAbort = ()=>{
|
|
757
|
-
abortGraceTimeout = setTimeout(()=>{
|
|
758
|
-
reject(abortController.signal.reason);
|
|
759
|
-
}, 0);
|
|
760
|
-
};
|
|
761
|
-
if (abortController.signal.aborted) settleOnAbort();
|
|
762
|
-
else abortController.signal.addEventListener('abort', settleOnAbort, {
|
|
763
|
-
once: true
|
|
764
|
-
});
|
|
765
|
-
});
|
|
766
|
-
const timeoutExecution = void 0 === timeoutMs ? void 0 : new Promise((_, reject)=>{
|
|
767
|
-
timeout = setTimeout(()=>{
|
|
768
|
-
if (abortController.signal.aborted) return void reject(abortController.signal.reason);
|
|
769
|
-
const timeoutError = new StepTimeoutError(timeoutMs, step.node);
|
|
770
|
-
abortController.abort(timeoutError);
|
|
771
|
-
reject(timeoutError);
|
|
772
|
-
}, timeoutMs);
|
|
773
|
-
});
|
|
774
|
-
const settledExecution = Promise.race([
|
|
775
|
-
execution,
|
|
776
|
-
abortedExecution,
|
|
777
|
-
...void 0 === timeoutExecution ? [] : [
|
|
778
|
-
timeoutExecution
|
|
779
|
-
]
|
|
780
|
-
]);
|
|
781
|
-
try {
|
|
782
|
-
const output = validateNodeOutput(await settledExecution, step.node);
|
|
783
|
-
return {
|
|
784
|
-
...createStepResultBase(step, startedAt, phase, stepIndex),
|
|
785
|
-
status: 'success',
|
|
786
|
-
continuedAfterError: false,
|
|
787
|
-
...void 0 === output ? {} : {
|
|
788
|
-
output
|
|
789
|
-
}
|
|
790
|
-
};
|
|
791
|
-
} catch (error) {
|
|
792
|
-
return {
|
|
793
|
-
...createStepResultBase(step, startedAt, phase, stepIndex),
|
|
794
|
-
status: 'failed',
|
|
795
|
-
continuedAfterError: step.meta.continueOnError,
|
|
796
|
-
error: normalizeNodeExecutionError(error, step.node)
|
|
797
|
-
};
|
|
798
|
-
} finally{
|
|
799
|
-
if (void 0 !== timeout) clearTimeout(timeout);
|
|
800
|
-
if (void 0 !== abortGraceTimeout) clearTimeout(abortGraceTimeout);
|
|
801
|
-
if (void 0 !== settleOnAbort) abortController.signal.removeEventListener('abort', settleOnAbort);
|
|
802
|
-
parentSignal?.removeEventListener('abort', abortFromParent);
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
function createNodeReportCollector(node) {
|
|
806
|
-
const traces = new Map();
|
|
807
|
-
let accepting = true;
|
|
808
|
-
return {
|
|
809
|
-
collector: Object.freeze({
|
|
810
|
-
addTrace (trace) {
|
|
811
|
-
if (!accepting) throw new NodeExecutionError(node, new Error('Report traces can only be added while the current Step is running.'));
|
|
812
|
-
if (!trace || 'object' != typeof trace || 'midscene-execution' !== trace.type) throw new NodeExecutionError(node, new TypeError('Report trace type must be "midscene-execution".'));
|
|
813
|
-
if ('string' != typeof trace.executionId || 0 === trace.executionId.trim().length) throw new NodeExecutionError(node, new TypeError('Report trace executionId must be non-empty.'));
|
|
814
|
-
const normalized = Object.freeze({
|
|
815
|
-
type: 'midscene-execution',
|
|
816
|
-
executionId: trace.executionId
|
|
817
|
-
});
|
|
818
|
-
traces.set(`${normalized.type}:${normalized.executionId}`, normalized);
|
|
819
|
-
}
|
|
820
|
-
}),
|
|
821
|
-
close () {
|
|
822
|
-
accepting = false;
|
|
823
|
-
return Object.freeze([
|
|
824
|
-
...traces.values()
|
|
825
|
-
]);
|
|
826
|
-
}
|
|
827
|
-
};
|
|
828
|
-
}
|
|
829
|
-
async function parseNodeInput(node, input) {
|
|
830
|
-
if (!node.inputSchema) return input;
|
|
831
|
-
const parsed = await node.inputSchema.safeParseAsync(input);
|
|
832
|
-
if (!parsed.success) throw NodeInputValidationError.fromZod(node.name, parsed.error);
|
|
833
|
-
return parsed.data;
|
|
834
|
-
}
|
|
835
|
-
async function executeStep(step, node, target, context, execution = {}) {
|
|
836
|
-
const phase = 'case' === target.scope ? target.case.phase : target.document.phase;
|
|
837
|
-
const stepIndex = 'case' === target.scope ? target.case.stepIndex : target.document.stepIndex;
|
|
838
|
-
const reportCollector = createNodeReportCollector(step.node);
|
|
839
|
-
let result;
|
|
840
|
-
try {
|
|
841
|
-
result = await executeNode(step, async (signal)=>{
|
|
842
|
-
const input = await parseNodeInput(node, step.input);
|
|
843
|
-
signal.throwIfAborted();
|
|
844
|
-
const common = {
|
|
845
|
-
input,
|
|
846
|
-
$: step.meta,
|
|
847
|
-
signal,
|
|
848
|
-
context,
|
|
849
|
-
report: reportCollector.collector,
|
|
850
|
-
onTeardown: (teardown)=>{
|
|
851
|
-
if (!execution.onTeardown) throw new NodeExecutionError(step.node, new Error('The current execution scope cannot register teardown.'));
|
|
852
|
-
execution.onTeardown(step.node, teardown);
|
|
853
|
-
}
|
|
854
|
-
};
|
|
855
|
-
return 'case' === target.scope ? node.execute({
|
|
856
|
-
...common,
|
|
857
|
-
scope: 'case',
|
|
858
|
-
case: target.case
|
|
859
|
-
}) : node.execute({
|
|
860
|
-
...common,
|
|
861
|
-
scope: 'document',
|
|
862
|
-
document: target.document
|
|
863
|
-
});
|
|
864
|
-
}, phase, stepIndex, {
|
|
865
|
-
parentSignal: execution.signal,
|
|
866
|
-
defaultTimeoutMs: execution.defaultTimeoutMs
|
|
867
|
-
});
|
|
868
|
-
} finally{
|
|
869
|
-
const traces = reportCollector.close();
|
|
870
|
-
if (traces.length > 0 && result) result = {
|
|
871
|
-
...result,
|
|
872
|
-
report: {
|
|
873
|
-
traces
|
|
874
|
-
}
|
|
875
|
-
};
|
|
876
|
-
}
|
|
877
|
-
if (!result) throw new NodeExecutionError(step.node, new Error('Step execution completed without a result.'));
|
|
878
|
-
return result;
|
|
879
|
-
}
|
|
880
|
-
const reportPathsFromTeardown = (result)=>{
|
|
881
|
-
if (void 0 === result) return [];
|
|
882
|
-
if ('object' != typeof result || null === result || Array.isArray(result)) throw new TypeError('Node teardown result must be an object or undefined.');
|
|
883
|
-
const paths = result.reportPaths;
|
|
884
|
-
if (void 0 === paths) return [];
|
|
885
|
-
if (!Array.isArray(paths)) throw new TypeError('Node teardown reportPaths must be an array.');
|
|
886
|
-
return paths.map((path, index)=>{
|
|
887
|
-
if ('string' != typeof path || 0 === path.trim().length) throw new TypeError(`Node teardown reportPaths[${index}] must be a non-empty string.`);
|
|
888
|
-
if (!(0, external_node_path_namespaceObject.isAbsolute)(path)) throw new TypeError(`Node teardown reportPaths[${index}] must be an absolute path.`);
|
|
889
|
-
return path;
|
|
890
|
-
});
|
|
891
|
-
};
|
|
892
|
-
function createDocumentRuntime(document, options) {
|
|
893
|
-
const documentRunId = options.createDocumentRunId?.() ?? (0, external_node_crypto_namespaceObject.randomUUID)();
|
|
894
|
-
const startedAt = new Date();
|
|
895
|
-
const beforeAll = [];
|
|
896
|
-
const afterAll = [];
|
|
897
|
-
const nodes = {
|
|
898
|
-
beforeAll: document.lifecycle.beforeAll.map((step)=>options.resolveNode(step.node)),
|
|
899
|
-
afterAll: document.lifecycle.afterAll.map((step)=>options.resolveNode(step.node))
|
|
900
|
-
};
|
|
901
|
-
const project = options.project ?? Object.freeze({
|
|
902
|
-
projectId: document.projectId,
|
|
903
|
-
name: document.projectId,
|
|
904
|
-
tags: Object.freeze({
|
|
905
|
-
include: [],
|
|
906
|
-
exclude: []
|
|
907
|
-
}),
|
|
908
|
-
retry: 0,
|
|
909
|
-
variables: Object.freeze({})
|
|
910
|
-
});
|
|
911
|
-
const context = options.projectContext;
|
|
912
|
-
const signal = options.signal ?? new AbortController().signal;
|
|
913
|
-
const nodeTeardownStack = [];
|
|
914
|
-
let acceptingNodeTeardowns = true;
|
|
915
|
-
let started = false;
|
|
916
|
-
let finishedResult;
|
|
917
|
-
const reportPaths = new Set();
|
|
918
|
-
const createResult = (teardownErrors = [])=>{
|
|
919
|
-
const endedAt = new Date();
|
|
920
|
-
return {
|
|
921
|
-
documentId: document.documentId,
|
|
922
|
-
documentRunId,
|
|
923
|
-
projectId: document.projectId,
|
|
924
|
-
projectName: project.name,
|
|
925
|
-
sourcePath: document.sourcePath,
|
|
926
|
-
status: beforeAll.some((step)=>'failed' === step.status) || afterAll.some((step)=>'failed' === step.status) || teardownErrors.length > 0 ? 'failed' : 'success',
|
|
927
|
-
startedAt: startedAt.toISOString(),
|
|
928
|
-
endedAt: endedAt.toISOString(),
|
|
929
|
-
durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime()),
|
|
930
|
-
beforeAll: [
|
|
931
|
-
...beforeAll
|
|
932
|
-
],
|
|
933
|
-
afterAll: [
|
|
934
|
-
...afterAll
|
|
935
|
-
],
|
|
936
|
-
...teardownErrors.length > 0 ? {
|
|
937
|
-
teardownErrors
|
|
938
|
-
} : {},
|
|
939
|
-
...reportPaths.size > 0 ? {
|
|
940
|
-
reportPaths: [
|
|
941
|
-
...reportPaths
|
|
942
|
-
]
|
|
943
|
-
} : {}
|
|
944
|
-
};
|
|
945
|
-
};
|
|
946
|
-
const runPhase = async (phase, results, executionSignal = signal)=>{
|
|
947
|
-
for (const [stepIndex, step] of document.lifecycle[phase].entries()){
|
|
948
|
-
const documentContext = {
|
|
949
|
-
documentId: document.documentId,
|
|
950
|
-
documentRunId,
|
|
951
|
-
projectId: document.projectId,
|
|
952
|
-
projectName: project.name,
|
|
953
|
-
sourcePath: document.sourcePath,
|
|
954
|
-
phase,
|
|
955
|
-
stepIndex
|
|
956
|
-
};
|
|
957
|
-
const stepInfo = {
|
|
958
|
-
scope: 'document',
|
|
959
|
-
node: step.node,
|
|
960
|
-
stepCount: document.lifecycle[phase].length,
|
|
961
|
-
document: documentContext
|
|
962
|
-
};
|
|
963
|
-
await options.onStepStart?.(stepInfo);
|
|
964
|
-
const result = await executeStep(step, nodes[phase][stepIndex], {
|
|
965
|
-
scope: 'document',
|
|
966
|
-
document: documentContext
|
|
967
|
-
}, context, {
|
|
968
|
-
signal: executionSignal,
|
|
969
|
-
defaultTimeoutMs: options.defaultTimeoutMs,
|
|
970
|
-
onTeardown (node, teardown) {
|
|
971
|
-
if (!acceptingNodeTeardowns) throw new WorkflowLifecycleError('Node teardown can only be registered while a workflow document is running.', {
|
|
972
|
-
documentId: document.documentId,
|
|
973
|
-
documentRunId,
|
|
974
|
-
node
|
|
975
|
-
});
|
|
976
|
-
if ('function' != typeof teardown) throw new WorkflowLifecycleError('Node onTeardown() requires a teardown function.', {
|
|
977
|
-
documentId: document.documentId,
|
|
978
|
-
documentRunId,
|
|
979
|
-
node
|
|
980
|
-
});
|
|
981
|
-
nodeTeardownStack.push({
|
|
982
|
-
registrationIndex: nodeTeardownStack.length,
|
|
983
|
-
node,
|
|
984
|
-
teardown
|
|
985
|
-
});
|
|
986
|
-
}
|
|
987
|
-
});
|
|
988
|
-
await options.onStepResult?.(stepInfo, result);
|
|
989
|
-
results.push(result);
|
|
990
|
-
if ('failed' === result.status && !result.continuedAfterError) break;
|
|
991
|
-
}
|
|
992
|
-
};
|
|
993
|
-
return {
|
|
994
|
-
get context () {
|
|
995
|
-
return context;
|
|
996
|
-
},
|
|
997
|
-
get canRunCases () {
|
|
998
|
-
return started && !signal.aborted && !beforeAll.some((step)=>'failed' === step.status);
|
|
999
|
-
},
|
|
1000
|
-
async start () {
|
|
1001
|
-
if (started) throw new WorkflowLifecycleError('Workflow document runtime has already started.', {
|
|
1002
|
-
documentId: document.documentId,
|
|
1003
|
-
documentRunId
|
|
1004
|
-
});
|
|
1005
|
-
started = true;
|
|
1006
|
-
if (!signal.aborted) await runPhase('beforeAll', beforeAll);
|
|
1007
|
-
return createResult();
|
|
1008
|
-
},
|
|
1009
|
-
async finish () {
|
|
1010
|
-
if (finishedResult) return finishedResult;
|
|
1011
|
-
if (!started) throw new WorkflowLifecycleError('Workflow document runtime must start before it can finish.', {
|
|
1012
|
-
documentId: document.documentId,
|
|
1013
|
-
documentRunId
|
|
1014
|
-
});
|
|
1015
|
-
let finishError;
|
|
1016
|
-
try {
|
|
1017
|
-
await runPhase('afterAll', afterAll, signal.aborted ? new AbortController().signal : signal);
|
|
1018
|
-
} catch (error) {
|
|
1019
|
-
finishError = error;
|
|
1020
|
-
}
|
|
1021
|
-
acceptingNodeTeardowns = false;
|
|
1022
|
-
const teardownErrors = [];
|
|
1023
|
-
for (const { registrationIndex, node, teardown } of nodeTeardownStack.reverse())try {
|
|
1024
|
-
for (const path of reportPathsFromTeardown(await teardown()))reportPaths.add(path);
|
|
1025
|
-
} catch (error) {
|
|
1026
|
-
teardownErrors.push(new NodeScopeTeardownError(error, {
|
|
1027
|
-
scope: 'document',
|
|
1028
|
-
scopeId: documentRunId,
|
|
1029
|
-
node,
|
|
1030
|
-
registrationIndex
|
|
1031
|
-
}));
|
|
1032
|
-
}
|
|
1033
|
-
finishedResult = createResult(teardownErrors);
|
|
1034
|
-
if (finishError) throw finishError;
|
|
1035
|
-
await options.onResult?.(finishedResult);
|
|
1036
|
-
return finishedResult;
|
|
1037
|
-
}
|
|
1038
|
-
};
|
|
1039
|
-
}
|
|
1040
|
-
async function runCollectedCase(collectedCase, options) {
|
|
1041
|
-
const phases = {
|
|
1042
|
-
beforeEach: options.beforeEach ?? [],
|
|
1043
|
-
steps: collectedCase.definition.steps,
|
|
1044
|
-
afterEach: options.afterEach ?? []
|
|
1045
|
-
};
|
|
1046
|
-
const nodes = {
|
|
1047
|
-
beforeEach: phases.beforeEach.map((step)=>options.resolveNode(step.node)),
|
|
1048
|
-
steps: phases.steps.map((step)=>options.resolveNode(step.node)),
|
|
1049
|
-
afterEach: phases.afterEach.map((step)=>options.resolveNode(step.node))
|
|
1050
|
-
};
|
|
1051
|
-
const runId = options.createRunId?.() ?? (0, external_node_crypto_namespaceObject.randomUUID)();
|
|
1052
|
-
const startedAt = new Date();
|
|
1053
|
-
const beforeEach = [];
|
|
1054
|
-
const steps = [];
|
|
1055
|
-
const afterEach = [];
|
|
1056
|
-
const teardownStack = [];
|
|
1057
|
-
let acceptingTeardowns = true;
|
|
1058
|
-
const onTeardown = (node, teardown)=>{
|
|
1059
|
-
if (!acceptingTeardowns) throw new WorkflowLifecycleError('Node teardown can only be registered while a case attempt is running.', {
|
|
1060
|
-
runId,
|
|
1061
|
-
node
|
|
1062
|
-
});
|
|
1063
|
-
if ('function' != typeof teardown) throw new WorkflowLifecycleError('Node onTeardown() requires a teardown function.', {
|
|
1064
|
-
runId,
|
|
1065
|
-
node
|
|
1066
|
-
});
|
|
1067
|
-
teardownStack.push({
|
|
1068
|
-
registrationIndex: teardownStack.length,
|
|
1069
|
-
node,
|
|
1070
|
-
teardown
|
|
1071
|
-
});
|
|
1072
|
-
};
|
|
1073
|
-
const runPhase = async (phase, results, signal = options.signal)=>{
|
|
1074
|
-
for (const [stepIndex, step] of phases[phase].entries()){
|
|
1075
|
-
const caseContext = {
|
|
1076
|
-
caseId: collectedCase.caseId,
|
|
1077
|
-
runId,
|
|
1078
|
-
projectName: options.projectName ?? collectedCase.projectId,
|
|
1079
|
-
attemptIndex: options.attemptIndex ?? 0,
|
|
1080
|
-
name: collectedCase.definition.name,
|
|
1081
|
-
sourcePath: collectedCase.sourcePath,
|
|
1082
|
-
caseIndex: collectedCase.caseIndex,
|
|
1083
|
-
phase,
|
|
1084
|
-
stepIndex
|
|
1085
|
-
};
|
|
1086
|
-
const stepInfo = {
|
|
1087
|
-
scope: 'case',
|
|
1088
|
-
node: step.node,
|
|
1089
|
-
stepCount: phases[phase].length,
|
|
1090
|
-
case: caseContext
|
|
1091
|
-
};
|
|
1092
|
-
await options.onStepStart?.(stepInfo);
|
|
1093
|
-
const result = await executeStep(step, nodes[phase][stepIndex], {
|
|
1094
|
-
scope: 'case',
|
|
1095
|
-
case: caseContext
|
|
1096
|
-
}, options.context, {
|
|
1097
|
-
signal,
|
|
1098
|
-
defaultTimeoutMs: options.defaultTimeoutMs,
|
|
1099
|
-
onTeardown
|
|
1100
|
-
});
|
|
1101
|
-
await options.onStepResult?.(stepInfo, result);
|
|
1102
|
-
results.push(result);
|
|
1103
|
-
if ('failed' === result.status && !result.continuedAfterError) break;
|
|
1104
|
-
}
|
|
1105
|
-
};
|
|
1106
|
-
let executionError;
|
|
1107
|
-
try {
|
|
1108
|
-
await runPhase('beforeEach', beforeEach);
|
|
1109
|
-
if (!beforeEach.some((step)=>'failed' === step.status)) await runPhase('steps', steps);
|
|
1110
|
-
} catch (error) {
|
|
1111
|
-
executionError = error;
|
|
1112
|
-
}
|
|
1113
|
-
try {
|
|
1114
|
-
const cleanupSignal = options.signal?.aborted ? new AbortController().signal : options.signal;
|
|
1115
|
-
await runPhase('afterEach', afterEach, cleanupSignal);
|
|
1116
|
-
} catch (error) {
|
|
1117
|
-
executionError ??= error;
|
|
1118
|
-
}
|
|
1119
|
-
acceptingTeardowns = false;
|
|
1120
|
-
const teardownErrors = [];
|
|
1121
|
-
const reportPaths = new Set();
|
|
1122
|
-
for (const { registrationIndex, node, teardown } of teardownStack.reverse())try {
|
|
1123
|
-
for (const path of reportPathsFromTeardown(await teardown()))reportPaths.add(path);
|
|
1124
|
-
} catch (error) {
|
|
1125
|
-
teardownErrors.push(new NodeScopeTeardownError(error, {
|
|
1126
|
-
scope: 'case',
|
|
1127
|
-
scopeId: runId,
|
|
1128
|
-
node,
|
|
1129
|
-
registrationIndex
|
|
1130
|
-
}));
|
|
1131
|
-
}
|
|
1132
|
-
if (executionError) throw executionError;
|
|
1133
|
-
const endedAt = new Date();
|
|
1134
|
-
const result = {
|
|
1135
|
-
caseId: collectedCase.caseId,
|
|
1136
|
-
runId,
|
|
1137
|
-
projectName: options.projectName ?? collectedCase.projectId,
|
|
1138
|
-
attemptIndex: options.attemptIndex ?? 0,
|
|
1139
|
-
name: collectedCase.definition.name,
|
|
1140
|
-
sourcePath: collectedCase.sourcePath,
|
|
1141
|
-
caseIndex: collectedCase.caseIndex,
|
|
1142
|
-
status: beforeEach.some((step)=>'failed' === step.status) || steps.some((step)=>'failed' === step.status) || afterEach.some((step)=>'failed' === step.status) || teardownErrors.length > 0 ? 'failed' : 'success',
|
|
1143
|
-
beforeEach,
|
|
1144
|
-
steps,
|
|
1145
|
-
afterEach,
|
|
1146
|
-
...teardownErrors.length > 0 ? {
|
|
1147
|
-
teardownErrors
|
|
1148
|
-
} : {},
|
|
1149
|
-
...reportPaths.size > 0 ? {
|
|
1150
|
-
reportPaths: [
|
|
1151
|
-
...reportPaths
|
|
1152
|
-
]
|
|
1153
|
-
} : {},
|
|
1154
|
-
startedAt: startedAt.toISOString(),
|
|
1155
|
-
endedAt: endedAt.toISOString(),
|
|
1156
|
-
durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
|
|
1157
|
-
};
|
|
1158
|
-
await options.onResult?.(result);
|
|
1159
|
-
return result;
|
|
1160
|
-
}
|
|
1161
|
-
const asNotRun = (collectedCase, projectName, reason)=>({
|
|
1162
|
-
caseId: collectedCase.caseId,
|
|
1163
|
-
projectName,
|
|
1164
|
-
name: collectedCase.definition.name,
|
|
1165
|
-
sourcePath: collectedCase.sourcePath,
|
|
1166
|
-
caseIndex: collectedCase.caseIndex,
|
|
1167
|
-
status: 'not-run',
|
|
1168
|
-
notRunReason: reason
|
|
1169
|
-
});
|
|
1170
|
-
async function runWorkflowDocument(document, options) {
|
|
1171
|
-
const createDocumentRunId = options.createDocumentRunId;
|
|
1172
|
-
const createCaseRunId = options.createCaseRunId;
|
|
1173
|
-
const projectName = options.project?.name ?? document.projectId;
|
|
1174
|
-
const retry = options.retry ?? options.project?.retry ?? 0;
|
|
1175
|
-
if (!Number.isInteger(retry) || retry < 0) throw new TypeError('Workflow document retry must be a non-negative integer.');
|
|
1176
|
-
const runtime = createDocumentRuntime(document, {
|
|
1177
|
-
resolveNode: options.resolveNode,
|
|
1178
|
-
project: options.project,
|
|
1179
|
-
projectContext: options.projectContext,
|
|
1180
|
-
signal: options.signal,
|
|
1181
|
-
defaultTimeoutMs: options.defaultTimeoutMs,
|
|
1182
|
-
onStepStart: options.onStepStart,
|
|
1183
|
-
onStepResult: options.onStepResult,
|
|
1184
|
-
onResult: options.onDocumentResult,
|
|
1185
|
-
createDocumentRunId: createDocumentRunId ? ()=>createDocumentRunId(document) : void 0
|
|
1186
|
-
});
|
|
1187
|
-
const cases = [];
|
|
1188
|
-
let runtimeStarted = false;
|
|
1189
|
-
let documentResult;
|
|
1190
|
-
let executionError;
|
|
1191
|
-
let fatalError = false;
|
|
1192
|
-
try {
|
|
1193
|
-
runtimeStarted = true;
|
|
1194
|
-
await runtime.start();
|
|
1195
|
-
if (runtime.canRunCases) for (const collectedCase of document.cases){
|
|
1196
|
-
if (fatalError || options.shouldStop?.()) {
|
|
1197
|
-
const outcome = asNotRun(collectedCase, projectName, fatalError ? 'fatal-error' : options.stopReason?.() ?? 'interrupted');
|
|
1198
|
-
cases.push(outcome);
|
|
1199
|
-
await options.onCaseOutcome?.(outcome);
|
|
1200
|
-
continue;
|
|
1201
|
-
}
|
|
1202
|
-
await options.onCaseStart?.(collectedCase);
|
|
1203
|
-
const attempts = [];
|
|
1204
|
-
for(let attemptIndex = 0; attemptIndex <= retry; attemptIndex += 1){
|
|
1205
|
-
const run = await runCollectedCase(collectedCase, {
|
|
1206
|
-
resolveNode: options.resolveNode,
|
|
1207
|
-
beforeEach: document.lifecycle.beforeEach,
|
|
1208
|
-
afterEach: document.lifecycle.afterEach,
|
|
1209
|
-
context: runtime.context,
|
|
1210
|
-
projectName,
|
|
1211
|
-
attemptIndex,
|
|
1212
|
-
signal: options.signal,
|
|
1213
|
-
defaultTimeoutMs: options.defaultTimeoutMs,
|
|
1214
|
-
onStepStart: options.onStepStart,
|
|
1215
|
-
onStepResult: options.onStepResult,
|
|
1216
|
-
onResult: options.onCaseResult,
|
|
1217
|
-
createRunId: createCaseRunId ? ()=>createCaseRunId(collectedCase, attemptIndex) : void 0
|
|
1218
|
-
});
|
|
1219
|
-
attempts.push(run);
|
|
1220
|
-
fatalError = options.isFatalError?.(run) ?? false;
|
|
1221
|
-
if ('success' === run.status || fatalError) break;
|
|
1222
|
-
}
|
|
1223
|
-
const run = attempts.at(-1);
|
|
1224
|
-
const outcome = {
|
|
1225
|
-
caseId: collectedCase.caseId,
|
|
1226
|
-
projectName,
|
|
1227
|
-
name: collectedCase.definition.name,
|
|
1228
|
-
sourcePath: collectedCase.sourcePath,
|
|
1229
|
-
caseIndex: collectedCase.caseIndex,
|
|
1230
|
-
status: run.status,
|
|
1231
|
-
run,
|
|
1232
|
-
attempts
|
|
1233
|
-
};
|
|
1234
|
-
cases.push(outcome);
|
|
1235
|
-
await options.onCaseOutcome?.(outcome);
|
|
1236
|
-
}
|
|
1237
|
-
else {
|
|
1238
|
-
const reason = options.signal?.aborted ? options.stopReason?.() ?? 'interrupted' : 'document-start-failed';
|
|
1239
|
-
cases.push(...document.cases.map((collectedCase)=>asNotRun(collectedCase, projectName, reason)));
|
|
1240
|
-
}
|
|
1241
|
-
} catch (error) {
|
|
1242
|
-
executionError = error;
|
|
1243
|
-
} finally{
|
|
1244
|
-
if (runtimeStarted) try {
|
|
1245
|
-
documentResult = await runtime.finish();
|
|
1246
|
-
} catch (error) {
|
|
1247
|
-
executionError ??= error;
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
if (executionError) throw executionError;
|
|
1251
|
-
if (!documentResult) throw new Error(`Workflow document "${document.sourcePath}" did not produce a result.`);
|
|
1252
|
-
return {
|
|
1253
|
-
document: documentResult,
|
|
1254
|
-
cases
|
|
1255
|
-
};
|
|
1256
|
-
}
|
|
1257
318
|
const external_js_yaml_namespaceObject = require("js-yaml");
|
|
1258
|
-
const
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
for (const key of Object.keys(value))if (!supportedMetaKeys.has(key)) throw new WorkflowParseError(`${formatStep(index)} has unsupported engine metadata "${key}".`, {
|
|
1276
|
-
index,
|
|
1277
|
-
key
|
|
1278
|
-
});
|
|
1279
|
-
const timeout = value.timeout;
|
|
1280
|
-
if (void 0 !== timeout && ('number' != typeof timeout || !Number.isFinite(timeout) || timeout <= 0)) throw new WorkflowParseError(`${formatStep(index)} "$.timeout" must be a positive number of milliseconds.`, {
|
|
1281
|
-
index,
|
|
1282
|
-
timeout
|
|
1283
|
-
});
|
|
1284
|
-
const continueOnError = value['continue-on-error'];
|
|
1285
|
-
if (void 0 !== continueOnError && 'boolean' != typeof continueOnError) throw new WorkflowParseError(`${formatStep(index)} "$.continue-on-error" must be a boolean.`, {
|
|
1286
|
-
index,
|
|
1287
|
-
continueOnError
|
|
1288
|
-
});
|
|
1289
|
-
return {
|
|
1290
|
-
...void 0 === timeout ? {} : {
|
|
1291
|
-
timeoutMs: timeout
|
|
1292
|
-
},
|
|
1293
|
-
continueOnError: continueOnError ?? false
|
|
1294
|
-
};
|
|
1295
|
-
}
|
|
1296
|
-
function normalizeStep(value, index = 0, resolveNode) {
|
|
1297
|
-
if (!isMapping(value)) throw new WorkflowParseError(`${formatStep(index)} must be a mapping.`, {
|
|
1298
|
-
index
|
|
1299
|
-
});
|
|
1300
|
-
const entries = Object.entries(value);
|
|
1301
|
-
if (1 !== entries.length) throw new WorkflowParseError(`${formatStep(index)} must contain exactly one node.`, {
|
|
1302
|
-
index,
|
|
1303
|
-
nodes: Object.keys(value)
|
|
1304
|
-
});
|
|
1305
|
-
const [node, rawValue] = entries[0];
|
|
1306
|
-
if (0 === node.trim().length) throw new WorkflowParseError(`${formatStep(index)} node name must not be empty.`, {
|
|
1307
|
-
index
|
|
1308
|
-
});
|
|
1309
|
-
if ('string' == typeof rawValue) {
|
|
1310
|
-
const resolvedNode = resolveNode?.(node);
|
|
1311
|
-
const stringInputKey = resolvedNode ? resolvedNode.stringInputKey : 'prompt';
|
|
1312
|
-
if (false === stringInputKey || void 0 === stringInputKey) throw new WorkflowParseError(`${formatStep(index)} node "${node}" does not accept string shorthand.`, {
|
|
1313
|
-
index,
|
|
1314
|
-
node
|
|
1315
|
-
});
|
|
1316
|
-
return {
|
|
1317
|
-
node,
|
|
1318
|
-
input: {
|
|
1319
|
-
[stringInputKey]: rawValue
|
|
1320
|
-
},
|
|
1321
|
-
meta: {
|
|
1322
|
-
continueOnError: false
|
|
1323
|
-
}
|
|
1324
|
-
};
|
|
1325
|
-
}
|
|
1326
|
-
if (!isMapping(rawValue)) throw new WorkflowParseError(`${formatStep(index)} value must be a string shorthand or a mapping.`, {
|
|
1327
|
-
index,
|
|
1328
|
-
node
|
|
1329
|
-
});
|
|
1330
|
-
const { $: rawMeta, ...input } = rawValue;
|
|
1331
|
-
return {
|
|
1332
|
-
node,
|
|
1333
|
-
input,
|
|
1334
|
-
meta: normalizeMeta(rawMeta, index)
|
|
1335
|
-
};
|
|
1336
|
-
}
|
|
1337
|
-
function normalizeSteps(steps, resolveNode) {
|
|
1338
|
-
if (!Array.isArray(steps)) throw new WorkflowParseError('Steps must be an array.');
|
|
1339
|
-
return steps.map((step, index)=>normalizeStep(step, index, resolveNode));
|
|
1340
|
-
}
|
|
319
|
+
const external_node_crypto_namespaceObject = require("node:crypto");
|
|
320
|
+
const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>(0, external_node_crypto_namespaceObject.createHash)('sha256').update(JSON.stringify([
|
|
321
|
+
projectId,
|
|
322
|
+
sourcePath,
|
|
323
|
+
caseIndex,
|
|
324
|
+
...invocationIndex ? [
|
|
325
|
+
invocationIndex
|
|
326
|
+
] : []
|
|
327
|
+
])).digest('hex');
|
|
328
|
+
const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=>(0, external_node_crypto_namespaceObject.createHash)('sha256').update(JSON.stringify([
|
|
329
|
+
projectId,
|
|
330
|
+
sourcePath,
|
|
331
|
+
...invocationIndex ? [
|
|
332
|
+
invocationIndex
|
|
333
|
+
] : []
|
|
334
|
+
])).digest('hex');
|
|
335
|
+
const normalizeSteps = test_runner_namespaceObject.normalizeSteps;
|
|
1341
336
|
const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
|
|
1342
337
|
const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
|
|
1343
338
|
const exactProjectExpression = /^\$\{([^{}]+)\}$/;
|
|
1344
339
|
const missingVariable = (kind, name, options)=>{
|
|
1345
|
-
throw new WorkflowParseError(`Undefined ${kind} variable "${name}" in ${options.location.sourcePath}.`, {
|
|
340
|
+
throw new test_runner_namespaceObject.WorkflowParseError(`Undefined ${kind} variable "${name}" in ${options.location.sourcePath}.`, {
|
|
1346
341
|
...options.location,
|
|
1347
342
|
variable: name,
|
|
1348
343
|
variableKind: kind
|
|
@@ -1358,7 +353,7 @@ const environmentVariable = (name, options)=>{
|
|
|
1358
353
|
return value;
|
|
1359
354
|
};
|
|
1360
355
|
const interpolationValue = (name, value, options)=>{
|
|
1361
|
-
if ('object' == typeof value && null !== value) throw new WorkflowParseError(`Project variable "${name}" must be a primitive when embedded in a string.`, {
|
|
356
|
+
if ('object' == typeof value && null !== value) throw new test_runner_namespaceObject.WorkflowParseError(`Project variable "${name}" must be a primitive when embedded in a string.`, {
|
|
1362
357
|
...options.location,
|
|
1363
358
|
variable: name,
|
|
1364
359
|
variableKind: 'project'
|
|
@@ -1381,23 +376,36 @@ const resolveWorkflowVariables = (value, options)=>{
|
|
|
1381
376
|
]));
|
|
1382
377
|
return value;
|
|
1383
378
|
};
|
|
1384
|
-
|
|
379
|
+
function _define_property(obj, key, value) {
|
|
380
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
381
|
+
value: value,
|
|
382
|
+
enumerable: true,
|
|
383
|
+
configurable: true,
|
|
384
|
+
writable: true
|
|
385
|
+
});
|
|
386
|
+
else obj[key] = value;
|
|
387
|
+
return obj;
|
|
388
|
+
}
|
|
389
|
+
const isMapping = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
390
|
+
class NativeWorkflowFormatError extends test_runner_namespaceObject.WorkflowParseError {
|
|
391
|
+
constructor(actualFormat, sourcePath){
|
|
392
|
+
super('legacy' === actualFormat ? `Legacy tasks/flow YAML is not supported by midscene-test: ${sourcePath}. Run it with \`midscene ${sourcePath}\`, or migrate the file to cases/steps.` : 'legacy-batch' === actualFormat ? `Legacy YAML batch configuration is not a midscene-test workflow document: ${sourcePath}. Run it with \`midscene --config ${sourcePath}\`, or migrate it to a native project config.` : `A workflow file cannot mix legacy tasks/flow with native cases/steps or lifecycle hooks: ${sourcePath}. Keep the formats in separate files and run each file with its matching command.`, {
|
|
393
|
+
sourcePath,
|
|
394
|
+
actualFormat,
|
|
395
|
+
expectedFormat: 'native'
|
|
396
|
+
}), _define_property(this, "actualFormat", void 0);
|
|
397
|
+
this.actualFormat = actualFormat;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
1385
400
|
const collect_rejectUnknownKeys = (value, allowed, label)=>{
|
|
1386
401
|
const allowedSet = new Set(allowed);
|
|
1387
402
|
const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
|
|
1388
|
-
if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
|
|
403
|
+
if (unknown.length) throw new test_runner_namespaceObject.WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
|
|
1389
404
|
field: unknown[0]
|
|
1390
405
|
});
|
|
1391
406
|
};
|
|
1392
|
-
const createCaseId = (projectId, sourcePath, caseIndex)=>(
|
|
1393
|
-
|
|
1394
|
-
sourcePath,
|
|
1395
|
-
caseIndex
|
|
1396
|
-
])).digest('hex');
|
|
1397
|
-
const createWorkflowDocumentId = (projectId, sourcePath)=>(0, external_node_crypto_namespaceObject.createHash)('sha256').update(JSON.stringify([
|
|
1398
|
-
projectId,
|
|
1399
|
-
sourcePath
|
|
1400
|
-
])).digest('hex');
|
|
407
|
+
const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
|
|
408
|
+
const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
|
|
1401
409
|
function collectWorkflowDocument(source, options) {
|
|
1402
410
|
let parsed;
|
|
1403
411
|
try {
|
|
@@ -1405,11 +413,13 @@ function collectWorkflowDocument(source, options) {
|
|
|
1405
413
|
schema: external_js_yaml_namespaceObject.JSON_SCHEMA
|
|
1406
414
|
});
|
|
1407
415
|
} catch (error) {
|
|
1408
|
-
throw new WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
|
|
416
|
+
throw new test_runner_namespaceObject.WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
|
|
1409
417
|
sourcePath: source.sourcePath
|
|
1410
418
|
}, error);
|
|
1411
419
|
}
|
|
1412
|
-
if (!
|
|
420
|
+
if (!isMapping(parsed)) throw new test_runner_namespaceObject.WorkflowParseError('Workflow document must be a mapping.');
|
|
421
|
+
const format = (0, test_runner_namespaceObject.classifyWorkflowDocumentFormat)(parsed);
|
|
422
|
+
if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
|
|
1413
423
|
collect_rejectUnknownKeys(parsed, [
|
|
1414
424
|
'beforeAll',
|
|
1415
425
|
'beforeEach',
|
|
@@ -1417,7 +427,7 @@ function collectWorkflowDocument(source, options) {
|
|
|
1417
427
|
'afterEach',
|
|
1418
428
|
'afterAll'
|
|
1419
429
|
], 'Workflow document');
|
|
1420
|
-
if (!Array.isArray(parsed.cases) || 0 === parsed.cases.length) throw new WorkflowParseError('Workflow document must contain a non-empty cases array.');
|
|
430
|
+
if (!Array.isArray(parsed.cases) || 0 === parsed.cases.length) throw new test_runner_namespaceObject.WorkflowParseError('Workflow document must contain a non-empty cases array.');
|
|
1421
431
|
const sourcePath = source.sourcePath.split(external_node_path_namespaceObject.sep).join('/');
|
|
1422
432
|
const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
|
|
1423
433
|
...step,
|
|
@@ -1438,12 +448,12 @@ function collectWorkflowDocument(source, options) {
|
|
|
1438
448
|
const normalizeLifecycle = (phase)=>{
|
|
1439
449
|
const value = parsed[phase];
|
|
1440
450
|
if (void 0 === value) return [];
|
|
1441
|
-
if (!Array.isArray(value)) throw new WorkflowParseError(`Workflow document ${phase} must be an array.`, {
|
|
451
|
+
if (!Array.isArray(value)) throw new test_runner_namespaceObject.WorkflowParseError(`Workflow document ${phase} must be an array.`, {
|
|
1442
452
|
phase
|
|
1443
453
|
});
|
|
1444
454
|
return normalizeSteps(value, options.resolveNode).map((normalized, stepIndex)=>{
|
|
1445
455
|
const resolved = options.resolveNode(normalized.node);
|
|
1446
|
-
if (!resolved) throw new WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
|
|
456
|
+
if (!resolved) throw new test_runner_namespaceObject.WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
|
|
1447
457
|
phase,
|
|
1448
458
|
stepIndex,
|
|
1449
459
|
node: normalized.node
|
|
@@ -1459,7 +469,7 @@ function collectWorkflowDocument(source, options) {
|
|
|
1459
469
|
};
|
|
1460
470
|
const ids = new Set();
|
|
1461
471
|
const cases = parsed.cases.map((definition, caseIndex)=>{
|
|
1462
|
-
if (!
|
|
472
|
+
if (!isMapping(definition)) throw new test_runner_namespaceObject.WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
|
|
1463
473
|
caseIndex
|
|
1464
474
|
});
|
|
1465
475
|
collect_rejectUnknownKeys(definition, [
|
|
@@ -1467,471 +477,63 @@ function collectWorkflowDocument(source, options) {
|
|
|
1467
477
|
'tags',
|
|
1468
478
|
'steps'
|
|
1469
479
|
], `Case ${caseIndex + 1}`);
|
|
1470
|
-
if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
|
|
480
|
+
if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new test_runner_namespaceObject.WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
|
|
1471
481
|
caseIndex
|
|
1472
482
|
});
|
|
1473
|
-
if (!Array.isArray(definition.steps) || 0 === definition.steps.length) throw new WorkflowParseError(`Case ${caseIndex + 1} steps must be a non-empty array.`, {
|
|
483
|
+
if (!Array.isArray(definition.steps) || 0 === definition.steps.length) throw new test_runner_namespaceObject.WorkflowParseError(`Case ${caseIndex + 1} steps must be a non-empty array.`, {
|
|
1474
484
|
caseIndex
|
|
1475
485
|
});
|
|
1476
486
|
const tags = definition.tags ?? [];
|
|
1477
|
-
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.`, {
|
|
487
|
+
if (!Array.isArray(tags) || tags.some((tag)=>'string' != typeof tag || 0 === tag.trim().length)) throw new test_runner_namespaceObject.WorkflowParseError(`Case ${caseIndex + 1} tags must be an array of non-empty strings.`, {
|
|
1478
488
|
caseIndex
|
|
1479
489
|
});
|
|
1480
490
|
const steps = normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
|
|
1481
|
-
if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
|
|
491
|
+
if (!options.resolveNode(normalized.node)) throw new test_runner_namespaceObject.WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
|
|
1482
492
|
caseIndex,
|
|
1483
493
|
stepIndex,
|
|
1484
494
|
node: normalized.node
|
|
1485
|
-
});
|
|
1486
|
-
return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
|
|
1487
|
-
});
|
|
1488
|
-
const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
|
|
1489
|
-
if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
|
|
1490
|
-
caseId
|
|
1491
|
-
});
|
|
1492
|
-
ids.add(caseId);
|
|
1493
|
-
return {
|
|
1494
|
-
caseId,
|
|
1495
|
-
projectId: source.projectId,
|
|
1496
|
-
sourcePath,
|
|
1497
|
-
caseIndex,
|
|
1498
|
-
definition: {
|
|
1499
|
-
name: definition.name,
|
|
1500
|
-
tags: tags,
|
|
1501
|
-
steps
|
|
1502
|
-
}
|
|
1503
|
-
};
|
|
1504
|
-
});
|
|
1505
|
-
return {
|
|
1506
|
-
documentId: createWorkflowDocumentId(source.projectId, sourcePath),
|
|
1507
|
-
projectId: source.projectId,
|
|
1508
|
-
sourcePath,
|
|
1509
|
-
lifecycle,
|
|
1510
|
-
cases
|
|
1511
|
-
};
|
|
1512
|
-
}
|
|
1513
|
-
const logger_namespaceObject = require("@midscene/shared/logger");
|
|
1514
|
-
const MAX_DEPTH = 6;
|
|
1515
|
-
const MAX_STRING_LENGTH = 2000;
|
|
1516
|
-
const MAX_ARRAY_ITEMS = 50;
|
|
1517
|
-
const MAX_OBJECT_KEYS = 50;
|
|
1518
|
-
const MAX_SERIALIZED_BYTES = 65536;
|
|
1519
|
-
const TRUNCATED_VALUE = '[Truncated: display value exceeded 64 KiB]';
|
|
1520
|
-
const normalizedSecretKeys = [
|
|
1521
|
-
'password',
|
|
1522
|
-
'passwd',
|
|
1523
|
-
'secret',
|
|
1524
|
-
'token',
|
|
1525
|
-
'authorization',
|
|
1526
|
-
'cookie',
|
|
1527
|
-
'apikey'
|
|
1528
|
-
];
|
|
1529
|
-
const normalizedKey = (key)=>key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
1530
|
-
const isSecretKey = (key)=>{
|
|
1531
|
-
const normalized = normalizedKey(key);
|
|
1532
|
-
return normalizedSecretKeys.some((secret)=>normalized.includes(secret));
|
|
1533
|
-
};
|
|
1534
|
-
const objectPath = (parent, key)=>/^[A-Za-z_$][\w$]*$/.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
|
|
1535
|
-
const stringByteLength = (value)=>Buffer.byteLength(value, 'utf8');
|
|
1536
|
-
function sanitizeReportText(value, maximumLength = MAX_STRING_LENGTH) {
|
|
1537
|
-
return value.length <= maximumLength ? value : `${value.slice(0, maximumLength)}… [truncated]`;
|
|
1538
|
-
}
|
|
1539
|
-
function sanitizeReportValue(value) {
|
|
1540
|
-
const truncatedPaths = new Set();
|
|
1541
|
-
const redactedPaths = new Set();
|
|
1542
|
-
const ancestors = new WeakSet();
|
|
1543
|
-
let remainingBytes = MAX_SERIALIZED_BYTES;
|
|
1544
|
-
const consume = (path, text)=>{
|
|
1545
|
-
remainingBytes -= stringByteLength(text);
|
|
1546
|
-
if (remainingBytes >= 0) return true;
|
|
1547
|
-
truncatedPaths.add(path);
|
|
1548
|
-
return false;
|
|
1549
|
-
};
|
|
1550
|
-
const visit = (current, path, depth)=>{
|
|
1551
|
-
if (remainingBytes <= 0) {
|
|
1552
|
-
truncatedPaths.add(path);
|
|
1553
|
-
return '[Truncated: size limit]';
|
|
1554
|
-
}
|
|
1555
|
-
if (null === current || 'boolean' == typeof current) {
|
|
1556
|
-
consume(path, String(current));
|
|
1557
|
-
return current;
|
|
1558
|
-
}
|
|
1559
|
-
if ('string' == typeof current) {
|
|
1560
|
-
if (current.length > MAX_STRING_LENGTH) {
|
|
1561
|
-
truncatedPaths.add(path);
|
|
1562
|
-
const shortened = `${current.slice(0, MAX_STRING_LENGTH)}… [truncated]`;
|
|
1563
|
-
consume(path, shortened);
|
|
1564
|
-
return shortened;
|
|
1565
|
-
}
|
|
1566
|
-
consume(path, current);
|
|
1567
|
-
return current;
|
|
1568
|
-
}
|
|
1569
|
-
if ('number' == typeof current) {
|
|
1570
|
-
if (Number.isFinite(current)) {
|
|
1571
|
-
consume(path, String(current));
|
|
1572
|
-
return current;
|
|
1573
|
-
}
|
|
1574
|
-
truncatedPaths.add(path);
|
|
1575
|
-
return `[Unsupported number: ${String(current)}]`;
|
|
1576
|
-
}
|
|
1577
|
-
if ('bigint' == typeof current) {
|
|
1578
|
-
truncatedPaths.add(path);
|
|
1579
|
-
return `[Unsupported bigint: ${String(current)}]`;
|
|
1580
|
-
}
|
|
1581
|
-
if (void 0 === current || 'function' == typeof current || 'symbol' == typeof current) {
|
|
1582
|
-
truncatedPaths.add(path);
|
|
1583
|
-
return `[Unsupported ${typeof current}]`;
|
|
1584
|
-
}
|
|
1585
|
-
if (depth >= MAX_DEPTH) {
|
|
1586
|
-
truncatedPaths.add(path);
|
|
1587
|
-
return '[Truncated: maximum depth]';
|
|
1588
|
-
}
|
|
1589
|
-
if (current instanceof Date) {
|
|
1590
|
-
const date = Number.isNaN(current.getTime()) ? '[Invalid Date]' : current.toISOString();
|
|
1591
|
-
consume(path, date);
|
|
1592
|
-
return date;
|
|
1593
|
-
}
|
|
1594
|
-
if (ancestors.has(current)) {
|
|
1595
|
-
truncatedPaths.add(path);
|
|
1596
|
-
return '[Unsupported circular value]';
|
|
1597
|
-
}
|
|
1598
|
-
if (Array.isArray(current)) {
|
|
1599
|
-
ancestors.add(current);
|
|
1600
|
-
const output = [];
|
|
1601
|
-
const itemCount = Math.min(current.length, MAX_ARRAY_ITEMS);
|
|
1602
|
-
for(let index = 0; index < itemCount; index += 1){
|
|
1603
|
-
if (remainingBytes <= 0) break;
|
|
1604
|
-
output.push(visit(current[index], `${path}[${index}]`, depth + 1));
|
|
1605
|
-
}
|
|
1606
|
-
if (current.length > output.length) truncatedPaths.add(path);
|
|
1607
|
-
ancestors.delete(current);
|
|
1608
|
-
return output;
|
|
1609
|
-
}
|
|
1610
|
-
const prototype = Object.getPrototypeOf(current);
|
|
1611
|
-
if (prototype !== Object.prototype && null !== prototype) {
|
|
1612
|
-
truncatedPaths.add(path);
|
|
1613
|
-
const constructorName = current.constructor?.name ?? 'object';
|
|
1614
|
-
return `[Unsupported object: ${constructorName}]`;
|
|
1615
|
-
}
|
|
1616
|
-
ancestors.add(current);
|
|
1617
|
-
const output = {};
|
|
1618
|
-
const keys = Object.keys(current);
|
|
1619
|
-
for (const key of keys.slice(0, MAX_OBJECT_KEYS)){
|
|
1620
|
-
const childPath = objectPath(path, key);
|
|
1621
|
-
if (!consume(childPath, key)) break;
|
|
1622
|
-
if (isSecretKey(key)) {
|
|
1623
|
-
output[key] = '[REDACTED]';
|
|
1624
|
-
redactedPaths.add(childPath);
|
|
1625
|
-
} else output[key] = visit(current[key], childPath, depth + 1);
|
|
1626
|
-
if (remainingBytes <= 0) break;
|
|
1627
|
-
}
|
|
1628
|
-
if (keys.length > Object.keys(output).length) truncatedPaths.add(path);
|
|
1629
|
-
ancestors.delete(current);
|
|
1630
|
-
return output;
|
|
1631
|
-
};
|
|
1632
|
-
const sanitized = visit(value, '$', 0);
|
|
1633
|
-
const serialized = JSON.stringify(sanitized);
|
|
1634
|
-
if (void 0 === serialized || stringByteLength(serialized) > MAX_SERIALIZED_BYTES) return {
|
|
1635
|
-
value: TRUNCATED_VALUE,
|
|
1636
|
-
truncatedPaths: [
|
|
1637
|
-
'$'
|
|
1638
|
-
]
|
|
1639
|
-
};
|
|
1640
|
-
return {
|
|
1641
|
-
value: sanitized,
|
|
1642
|
-
...truncatedPaths.size > 0 ? {
|
|
1643
|
-
truncatedPaths: [
|
|
1644
|
-
...truncatedPaths
|
|
1645
|
-
]
|
|
1646
|
-
} : {},
|
|
1647
|
-
...redactedPaths.size > 0 ? {
|
|
1648
|
-
redactedPaths: [
|
|
1649
|
-
...redactedPaths
|
|
1650
|
-
]
|
|
1651
|
-
} : {}
|
|
1652
|
-
};
|
|
1653
|
-
}
|
|
1654
|
-
function sanitizeReportError(error) {
|
|
1655
|
-
if (error instanceof Error) {
|
|
1656
|
-
const workflowLike = error;
|
|
1657
|
-
return {
|
|
1658
|
-
name: sanitizeReportText(error.name || 'Error', 200),
|
|
1659
|
-
message: sanitizeReportText(error.message || String(error)),
|
|
1660
|
-
...'string' == typeof workflowLike.code ? {
|
|
1661
|
-
code: sanitizeReportText(workflowLike.code, 200)
|
|
1662
|
-
} : {},
|
|
1663
|
-
...void 0 === workflowLike.details ? {} : {
|
|
1664
|
-
details: sanitizeReportValue(workflowLike.details)
|
|
1665
|
-
}
|
|
1666
|
-
};
|
|
1667
|
-
}
|
|
1668
|
-
return {
|
|
1669
|
-
name: 'Error',
|
|
1670
|
-
message: sanitizeReportText(String(error))
|
|
1671
|
-
};
|
|
1672
|
-
}
|
|
1673
|
-
const warnReport = (0, logger_namespaceObject.getDebug)('test-runner:report-assembler', {
|
|
1674
|
-
console: true
|
|
1675
|
-
});
|
|
1676
|
-
const exactMidsceneDetailNodes = new Set([
|
|
1677
|
-
'aiAct',
|
|
1678
|
-
'aiAssert',
|
|
1679
|
-
'recordToReport'
|
|
1680
|
-
]);
|
|
1681
|
-
const scopedExecutionKey = (scopeId, executionId)=>`${scopeId}\u0000${executionId}`;
|
|
1682
|
-
const reportSourcesForScope = (context, scopeId)=>context.sourcesByScope.get(scopeId) ?? [];
|
|
1683
|
-
const addDiagnostic = (context, diagnostic)=>{
|
|
1684
|
-
context.diagnostics.push(diagnostic);
|
|
1685
|
-
const key = JSON.stringify([
|
|
1686
|
-
diagnostic.code,
|
|
1687
|
-
diagnostic.scopeId,
|
|
1688
|
-
diagnostic.executionId
|
|
1689
|
-
]);
|
|
1690
|
-
if (!context.warnedDiagnostics.has(key)) {
|
|
1691
|
-
context.warnedDiagnostics.add(key);
|
|
1692
|
-
warnReport(diagnostic.message);
|
|
1693
|
-
}
|
|
1694
|
-
};
|
|
1695
|
-
const buildStep = (step, scopeId, context)=>{
|
|
1696
|
-
const details = [];
|
|
1697
|
-
const unresolvedExecutionIds = [];
|
|
1698
|
-
for (const trace of step.report?.traces ?? []){
|
|
1699
|
-
const previousScope = context.tracedScopeByExecution.get(trace.executionId);
|
|
1700
|
-
if (previousScope && previousScope !== scopeId) throw new Error(`Agent execution ${trace.executionId} was traced by multiple test scopes (${previousScope}, ${scopeId}).`);
|
|
1701
|
-
context.tracedScopeByExecution.set(trace.executionId, scopeId);
|
|
1702
|
-
const detail = context.detailsByScopeAndExecution.get(scopedExecutionKey(scopeId, trace.executionId));
|
|
1703
|
-
if (detail) details.push(detail);
|
|
1704
|
-
else unresolvedExecutionIds.push(trace.executionId);
|
|
1705
|
-
}
|
|
1706
|
-
let agentDetailDiagnostic;
|
|
1707
|
-
if (unresolvedExecutionIds.length > 0) {
|
|
1708
|
-
agentDetailDiagnostic = `Agent detail could not be resolved for ${unresolvedExecutionIds.join(', ')}.`;
|
|
1709
|
-
const sources = reportSourcesForScope(context, scopeId);
|
|
1710
|
-
for (const executionId of unresolvedExecutionIds)addDiagnostic(context, {
|
|
1711
|
-
level: 'warning',
|
|
1712
|
-
code: 'agent-detail-unresolved',
|
|
1713
|
-
message: `Agent detail could not be resolved for execution ${executionId} in test scope ${scopeId}.`,
|
|
1714
|
-
scopeId,
|
|
1715
|
-
executionId,
|
|
1716
|
-
...sources.length > 0 ? {
|
|
1717
|
-
sourcePaths: sources.map((source)=>source.sourcePath)
|
|
1718
|
-
} : {}
|
|
1719
|
-
});
|
|
1720
|
-
} else if (0 === details.length && exactMidsceneDetailNodes.has(step.node)) {
|
|
1721
|
-
agentDetailDiagnostic = 'This Agent did not expose a stable execution reference for the Step.';
|
|
1722
|
-
addDiagnostic(context, {
|
|
1723
|
-
level: 'warning',
|
|
1724
|
-
code: 'agent-detail-unavailable',
|
|
1725
|
-
message: `No stable Agent execution reference was captured for ${step.node} in test scope ${scopeId}.`,
|
|
1726
|
-
scopeId,
|
|
1727
|
-
...reportSourcesForScope(context, scopeId).length > 0 ? {
|
|
1728
|
-
sourcePaths: reportSourcesForScope(context, scopeId).map((source)=>source.sourcePath)
|
|
1729
|
-
} : {}
|
|
1730
|
-
});
|
|
1731
|
-
}
|
|
1732
|
-
const prompt = 'string' == typeof step.input?.prompt ? step.input.prompt : void 0;
|
|
1733
|
-
return {
|
|
1734
|
-
id: `${scopeId}:${step.phase}:${step.stepIndex}`,
|
|
1735
|
-
phase: step.phase,
|
|
1736
|
-
stepIndex: step.stepIndex,
|
|
1737
|
-
node: step.node,
|
|
1738
|
-
...prompt ? {
|
|
1739
|
-
title: sanitizeReportText(prompt, 240)
|
|
1740
|
-
} : {},
|
|
1741
|
-
status: step.status,
|
|
1742
|
-
continuedAfterError: step.continuedAfterError,
|
|
1743
|
-
startedAt: step.startedAt,
|
|
1744
|
-
endedAt: step.endedAt,
|
|
1745
|
-
durationMs: step.durationMs,
|
|
1746
|
-
input: sanitizeReportValue(step.input),
|
|
1747
|
-
...step.output ? {
|
|
1748
|
-
output: {
|
|
1749
|
-
...void 0 === step.output.summary ? {} : {
|
|
1750
|
-
summary: sanitizeReportText(step.output.summary)
|
|
1751
|
-
},
|
|
1752
|
-
...void 0 === step.output.data ? {} : {
|
|
1753
|
-
data: sanitizeReportValue(step.output.data)
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
} : {},
|
|
1757
|
-
...step.error ? {
|
|
1758
|
-
error: sanitizeReportError(step.error)
|
|
1759
|
-
} : {},
|
|
1760
|
-
...details.length > 0 ? {
|
|
1761
|
-
agentDetails: details
|
|
1762
|
-
} : {},
|
|
1763
|
-
...agentDetailDiagnostic ? {
|
|
1764
|
-
agentDetailDiagnostic
|
|
1765
|
-
} : {}
|
|
1766
|
-
};
|
|
1767
|
-
};
|
|
1768
|
-
const buildAttempt = (attempt, context)=>({
|
|
1769
|
-
attemptId: attempt.runId,
|
|
1770
|
-
attemptIndex: attempt.attemptIndex,
|
|
1771
|
-
status: attempt.status,
|
|
1772
|
-
startedAt: attempt.startedAt,
|
|
1773
|
-
endedAt: attempt.endedAt,
|
|
1774
|
-
durationMs: attempt.durationMs,
|
|
1775
|
-
beforeEach: attempt.beforeEach.map((step)=>buildStep(step, attempt.runId, context)),
|
|
1776
|
-
steps: attempt.steps.map((step)=>buildStep(step, attempt.runId, context)),
|
|
1777
|
-
afterEach: attempt.afterEach.map((step)=>buildStep(step, attempt.runId, context)),
|
|
1778
|
-
...attempt.teardownErrors?.length ? {
|
|
1779
|
-
teardownErrors: attempt.teardownErrors.map(sanitizeReportError)
|
|
1780
|
-
} : {},
|
|
1781
|
-
...reportSourcesForScope(context, attempt.runId).length > 0 ? {
|
|
1782
|
-
scopeReportIds: reportSourcesForScope(context, attempt.runId).map((source)=>source.reportId)
|
|
1783
|
-
} : {}
|
|
1784
|
-
});
|
|
1785
|
-
const buildCase = (outcome, context)=>({
|
|
1786
|
-
caseId: outcome.caseId,
|
|
1787
|
-
name: outcome.name,
|
|
1788
|
-
caseIndex: outcome.caseIndex,
|
|
1789
|
-
status: outcome.status,
|
|
1790
|
-
...outcome.notRunReason ? {
|
|
1791
|
-
notRunReason: outcome.notRunReason
|
|
1792
|
-
} : {},
|
|
1793
|
-
attempts: (outcome.attempts ?? (outcome.run ? [
|
|
1794
|
-
outcome.run
|
|
1795
|
-
] : [])).map((attempt)=>buildAttempt(attempt, context))
|
|
1796
|
-
});
|
|
1797
|
-
const buildDocument = (documentId, document, cases, context)=>{
|
|
1798
|
-
const sourcePath = document?.sourcePath ?? cases[0]?.sourcePath ?? '<unknown>';
|
|
1799
|
-
const status = document?.status ?? (cases.some((item)=>'success' !== item.status) ? 'failed' : 'success');
|
|
1800
|
-
return {
|
|
1801
|
-
documentId,
|
|
1802
|
-
sourcePath,
|
|
1803
|
-
status,
|
|
1804
|
-
...document ? {
|
|
1805
|
-
startedAt: document.startedAt,
|
|
1806
|
-
endedAt: document.endedAt,
|
|
1807
|
-
durationMs: document.durationMs
|
|
1808
|
-
} : {},
|
|
1809
|
-
beforeAll: (document?.beforeAll ?? []).map((step)=>buildStep(step, document.documentRunId, context)),
|
|
1810
|
-
cases: cases.map((outcome)=>buildCase(outcome, context)),
|
|
1811
|
-
afterAll: (document?.afterAll ?? []).map((step)=>buildStep(step, document.documentRunId, context)),
|
|
1812
|
-
...document?.teardownErrors?.length ? {
|
|
1813
|
-
teardownErrors: document.teardownErrors.map(sanitizeReportError)
|
|
1814
|
-
} : {},
|
|
1815
|
-
...document && reportSourcesForScope(context, document.documentRunId).length > 0 ? {
|
|
1816
|
-
scopeReportIds: reportSourcesForScope(context, document.documentRunId).map((source)=>source.reportId)
|
|
1817
|
-
} : {}
|
|
1818
|
-
};
|
|
1819
|
-
};
|
|
1820
|
-
const buildProject = (project, context)=>{
|
|
1821
|
-
const documentById = new Map(project.documents.map((document)=>[
|
|
1822
|
-
document.documentId,
|
|
1823
|
-
document
|
|
1824
|
-
]));
|
|
1825
|
-
const casesByDocumentId = new Map();
|
|
1826
|
-
for (const outcome of project.cases){
|
|
1827
|
-
const current = casesByDocumentId.get(outcome.documentId) ?? [];
|
|
1828
|
-
current.push(outcome);
|
|
1829
|
-
casesByDocumentId.set(outcome.documentId, current);
|
|
1830
|
-
}
|
|
1831
|
-
const documentIds = new Set([
|
|
1832
|
-
...project.documents.map((document)=>document.documentId),
|
|
1833
|
-
...project.cases.map((outcome)=>outcome.documentId)
|
|
1834
|
-
]);
|
|
1835
|
-
return {
|
|
1836
|
-
projectId: project.projectId,
|
|
1837
|
-
name: project.name,
|
|
1838
|
-
status: project.status,
|
|
1839
|
-
retry: project.retry,
|
|
1840
|
-
...project.lifecycle ? {
|
|
1841
|
-
lifecycle: {
|
|
1842
|
-
status: project.lifecycle.status,
|
|
1843
|
-
startedAt: project.lifecycle.startedAt,
|
|
1844
|
-
endedAt: project.lifecycle.endedAt,
|
|
1845
|
-
durationMs: project.lifecycle.durationMs,
|
|
1846
|
-
...project.lifecycle.setupError ? {
|
|
1847
|
-
setupError: sanitizeReportError(project.lifecycle.setupError)
|
|
1848
|
-
} : {},
|
|
1849
|
-
...project.lifecycle.teardownErrors?.length ? {
|
|
1850
|
-
teardownErrors: project.lifecycle.teardownErrors.map(sanitizeReportError)
|
|
1851
|
-
} : {}
|
|
1852
|
-
}
|
|
1853
|
-
} : {},
|
|
1854
|
-
documents: [
|
|
1855
|
-
...documentIds
|
|
1856
|
-
].map((documentId)=>buildDocument(documentId, documentById.get(documentId), casesByDocumentId.get(documentId) ?? [], context)),
|
|
1857
|
-
collectionErrors: project.collectionErrors.map((collectionError)=>({
|
|
1858
|
-
sourcePath: collectionError.sourcePath,
|
|
1859
|
-
error: sanitizeReportError(collectionError.error)
|
|
1860
|
-
}))
|
|
1861
|
-
};
|
|
1862
|
-
};
|
|
1863
|
-
function collectTestRunReportSources(result) {
|
|
1864
|
-
const sources = [];
|
|
1865
|
-
for (const project of result.projects){
|
|
1866
|
-
for (const document of project.documents)for (const sourcePath of document.reportPaths ?? [])sources.push({
|
|
1867
|
-
scopeId: document.documentRunId,
|
|
1868
|
-
sourcePath
|
|
1869
|
-
});
|
|
1870
|
-
for (const outcome of project.cases)for (const attempt of outcome.attempts ?? (outcome.run ? [
|
|
1871
|
-
outcome.run
|
|
1872
|
-
] : []))for (const sourcePath of attempt.reportPaths ?? [])sources.push({
|
|
1873
|
-
scopeId: attempt.runId,
|
|
1874
|
-
sourcePath
|
|
495
|
+
});
|
|
496
|
+
return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
|
|
1875
497
|
});
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
function buildTestRunReportDump(result, index) {
|
|
1880
|
-
const sourcesByScope = new Map();
|
|
1881
|
-
const detailsByScopeAndExecution = new Map();
|
|
1882
|
-
for (const source of index.sources){
|
|
1883
|
-
const scopeSources = sourcesByScope.get(source.scopeId) ?? [];
|
|
1884
|
-
scopeSources.push(source);
|
|
1885
|
-
sourcesByScope.set(source.scopeId, scopeSources);
|
|
1886
|
-
for (const executionId of source.executionIds)detailsByScopeAndExecution.set(scopedExecutionKey(source.scopeId, executionId), {
|
|
1887
|
-
reportId: source.reportId,
|
|
1888
|
-
executionId
|
|
498
|
+
const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
|
|
499
|
+
if (ids.has(caseId)) throw new test_runner_namespaceObject.WorkflowParseError(`Case id collision: ${caseId}.`, {
|
|
500
|
+
caseId
|
|
1889
501
|
});
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
scopeId: source.scopeId,
|
|
1903
|
-
sourcePaths: [
|
|
1904
|
-
source.sourcePath
|
|
1905
|
-
]
|
|
502
|
+
ids.add(caseId);
|
|
503
|
+
return {
|
|
504
|
+
caseId,
|
|
505
|
+
projectId: source.projectId,
|
|
506
|
+
sourcePath,
|
|
507
|
+
caseIndex,
|
|
508
|
+
definition: {
|
|
509
|
+
name: definition.name,
|
|
510
|
+
tags: tags,
|
|
511
|
+
steps
|
|
512
|
+
}
|
|
513
|
+
};
|
|
1906
514
|
});
|
|
1907
|
-
const projects = result.projects.map((project)=>buildProject(project, context));
|
|
1908
515
|
return {
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
endedAt: result.endedAt,
|
|
1915
|
-
durationMs: result.durationMs,
|
|
1916
|
-
summary: {
|
|
1917
|
-
...result.summary
|
|
1918
|
-
},
|
|
1919
|
-
metrics: index.metrics,
|
|
1920
|
-
projects,
|
|
1921
|
-
...context.diagnostics.length > 0 ? {
|
|
1922
|
-
diagnostics: context.diagnostics
|
|
1923
|
-
} : {}
|
|
516
|
+
documentId: createWorkflowDocumentId(source.projectId, sourcePath),
|
|
517
|
+
projectId: source.projectId,
|
|
518
|
+
sourcePath,
|
|
519
|
+
lifecycle,
|
|
520
|
+
cases
|
|
1924
521
|
};
|
|
1925
522
|
}
|
|
1926
|
-
const
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
523
|
+
const promises_namespaceObject = require("node:fs/promises");
|
|
524
|
+
const writeJson = async (path, value)=>{
|
|
525
|
+
try {
|
|
526
|
+
await (0, promises_namespaceObject.mkdir)((0, external_node_path_namespaceObject.dirname)(path), {
|
|
527
|
+
recursive: true
|
|
528
|
+
});
|
|
529
|
+
await (0, promises_namespaceObject.writeFile)(path, JSON.stringify(value, null, 2));
|
|
530
|
+
} catch (error) {
|
|
531
|
+
throw new test_runner_namespaceObject.WorkflowPublicationError('write-result', path, error);
|
|
532
|
+
}
|
|
1931
533
|
};
|
|
1932
534
|
const toPosix = (value)=>value.split(external_node_path_namespaceObject.sep).join('/');
|
|
1933
535
|
const caseAttemptResultPath = (projectId, documentId, result)=>toPosix((0, external_node_path_namespaceObject.join)(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
|
|
1934
|
-
const workflowDocumentResultPath = (result)=>toPosix((0, external_node_path_namespaceObject.join)(result.projectId, 'documents', result.documentId, 'document.json'));
|
|
536
|
+
const workflowDocumentResultPath = (result)=>toPosix((0, external_node_path_namespaceObject.join)(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
|
|
1935
537
|
const collectionErrorPath = (projectId, sourcePath)=>{
|
|
1936
538
|
const id = (0, external_node_crypto_namespaceObject.createHash)('sha256').update(JSON.stringify([
|
|
1937
539
|
projectId,
|
|
@@ -1941,21 +543,19 @@ const collectionErrorPath = (projectId, sourcePath)=>{
|
|
|
1941
543
|
};
|
|
1942
544
|
const writeWorkflowDocumentResult = (runDir, result)=>{
|
|
1943
545
|
const { documentRunId: _documentRunId, ...persistedResult } = result;
|
|
1944
|
-
writeJson((0, external_node_path_namespaceObject.join)(runDir, workflowDocumentResultPath(result)), persistedResult);
|
|
546
|
+
return writeJson((0, external_node_path_namespaceObject.join)(runDir, workflowDocumentResultPath(result)), persistedResult);
|
|
1945
547
|
};
|
|
1946
548
|
const writeCaseAttemptResult = (runDir, projectId, documentId, result)=>{
|
|
1947
549
|
const { runId: attemptId, ...persistedResult } = result;
|
|
1948
|
-
writeJson((0, external_node_path_namespaceObject.join)(runDir, caseAttemptResultPath(projectId, documentId, result)), {
|
|
550
|
+
return writeJson((0, external_node_path_namespaceObject.join)(runDir, caseAttemptResultPath(projectId, documentId, result)), {
|
|
1949
551
|
...persistedResult,
|
|
1950
552
|
attemptId
|
|
1951
553
|
});
|
|
1952
554
|
};
|
|
1953
|
-
const writeCollectionError = (runDir, collectionError)=>{
|
|
1954
|
-
writeJson((0, external_node_path_namespaceObject.join)(runDir, collectionErrorPath(collectionError.projectId, collectionError.sourcePath)), {
|
|
555
|
+
const writeCollectionError = (runDir, collectionError)=>writeJson((0, external_node_path_namespaceObject.join)(runDir, collectionErrorPath(collectionError.projectId, collectionError.sourcePath)), {
|
|
1955
556
|
kind: 'collection-error',
|
|
1956
557
|
...collectionError
|
|
1957
558
|
});
|
|
1958
|
-
};
|
|
1959
559
|
const relativeToSummary = (summaryPath, target)=>{
|
|
1960
560
|
const value = (0, external_node_path_namespaceObject.relative)((0, external_node_path_namespaceObject.dirname)(summaryPath), target);
|
|
1961
561
|
return toPosix(value || '.');
|
|
@@ -1964,7 +564,7 @@ const errorJson = (error)=>error instanceof Error && 'toJSON' in error ? error.t
|
|
|
1964
564
|
const writeTestProjectRunResult = (options)=>{
|
|
1965
565
|
const { result } = options;
|
|
1966
566
|
const fact = (path)=>toPosix(path);
|
|
1967
|
-
writeJson(result.summaryPath, {
|
|
567
|
+
return writeJson(result.summaryPath, {
|
|
1968
568
|
schemaVersion: result.schemaVersion,
|
|
1969
569
|
runId: result.runId,
|
|
1970
570
|
startedAt: result.startedAt,
|
|
@@ -1972,6 +572,9 @@ const writeTestProjectRunResult = (options)=>{
|
|
|
1972
572
|
durationMs: result.durationMs,
|
|
1973
573
|
status: result.status,
|
|
1974
574
|
exitCode: result.exitCode,
|
|
575
|
+
...result.errors?.length ? {
|
|
576
|
+
errors: result.errors.map(errorJson)
|
|
577
|
+
} : {},
|
|
1975
578
|
projectRoot: options.projectRoot,
|
|
1976
579
|
...options.configPath ? {
|
|
1977
580
|
configPath: options.configPath
|
|
@@ -2007,6 +610,9 @@ const writeTestProjectRunResult = (options)=>{
|
|
|
2007
610
|
}
|
|
2008
611
|
} : {},
|
|
2009
612
|
cases: project.cases.map((outcome)=>({
|
|
613
|
+
...outcome.documentRunId ? {
|
|
614
|
+
documentRunId: outcome.documentRunId
|
|
615
|
+
} : {},
|
|
2010
616
|
documentId: outcome.documentId,
|
|
2011
617
|
caseId: outcome.caseId,
|
|
2012
618
|
sourcePath: outcome.sourcePath,
|
|
@@ -2030,6 +636,10 @@ const writeTestProjectRunResult = (options)=>{
|
|
|
2030
636
|
})),
|
|
2031
637
|
documents: project.documents.map((document)=>({
|
|
2032
638
|
documentId: document.documentId,
|
|
639
|
+
...void 0 === document.attemptIndex ? {} : {
|
|
640
|
+
attemptIndex: document.attemptIndex,
|
|
641
|
+
documentRunId: document.documentRunId
|
|
642
|
+
},
|
|
2033
643
|
sourcePath: document.sourcePath,
|
|
2034
644
|
status: document.status,
|
|
2035
645
|
resultFile: fact(workflowDocumentResultPath(document)),
|
|
@@ -2064,32 +674,36 @@ const DEFAULT_TEST_FILE_SELECTION = {
|
|
|
2064
674
|
'**/*.{yaml,yml}'
|
|
2065
675
|
]
|
|
2066
676
|
};
|
|
2067
|
-
const
|
|
2068
|
-
const
|
|
677
|
+
const project_preparation_toPosix = (value)=>value.split(external_node_path_namespaceObject.sep).join('/');
|
|
678
|
+
const discoverResolvedTestFiles = (projectRoot, selection)=>{
|
|
2069
679
|
const root = (0, external_node_path_namespaceObject.resolve)(projectRoot);
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
}).filter((file)=>/\.ya?ml$/i.test(file));
|
|
680
|
+
const match = (patterns)=>(0, external_tinyglobby_namespaceObject.globSync)(patterns, {
|
|
681
|
+
absolute: true,
|
|
682
|
+
caseSensitiveMatch: false,
|
|
683
|
+
cwd: root,
|
|
684
|
+
dot: true,
|
|
685
|
+
expandDirectories: false,
|
|
686
|
+
followSymbolicLinks: false,
|
|
687
|
+
ignore: [
|
|
688
|
+
...ALWAYS_IGNORED_PATTERNS,
|
|
689
|
+
...selection.exclude ?? []
|
|
690
|
+
],
|
|
691
|
+
onlyFiles: true
|
|
692
|
+
}).filter((file)=>/\.ya?ml$/i.test(file));
|
|
693
|
+
const files = match(selection.include);
|
|
2085
694
|
return [
|
|
2086
695
|
...new Set(files.map((file)=>(0, external_node_path_namespaceObject.resolve)(file)))
|
|
2087
696
|
].sort((a, b)=>{
|
|
2088
|
-
const relativeA =
|
|
2089
|
-
const relativeB =
|
|
697
|
+
const relativeA = project_preparation_toPosix((0, external_node_path_namespaceObject.relative)(root, a));
|
|
698
|
+
const relativeB = project_preparation_toPosix((0, external_node_path_namespaceObject.relative)(root, b));
|
|
2090
699
|
return relativeA < relativeB ? -1 : relativeA > relativeB ? 1 : 0;
|
|
2091
700
|
});
|
|
2092
701
|
};
|
|
702
|
+
const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)=>{
|
|
703
|
+
const normalized = validateTestFileSelection(selection);
|
|
704
|
+
if (!normalized) throw new TypeError('Test file selection is required.');
|
|
705
|
+
return discoverResolvedTestFiles(projectRoot, normalized);
|
|
706
|
+
};
|
|
2093
707
|
const discoverTestConfig = (projectRoot)=>{
|
|
2094
708
|
const root = (0, external_node_path_namespaceObject.resolve)(projectRoot);
|
|
2095
709
|
const candidates = (0, external_node_fs_namespaceObject.readdirSync)(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
|
|
@@ -2107,27 +721,11 @@ const discoverTestConfig = (projectRoot)=>{
|
|
|
2107
721
|
].join('\n'));
|
|
2108
722
|
return supported[0] ? (0, external_node_path_namespaceObject.join)(root, supported[0]) : void 0;
|
|
2109
723
|
};
|
|
2110
|
-
const defaultResultDir = (projectRoot)=>(0, external_node_path_namespaceObject.join)(projectRoot, '.midscene', 'test-results');
|
|
2111
|
-
const padDatePart = (value)=>String(value).padStart(2, '0');
|
|
2112
|
-
const createTestRunId = (date = new Date(), uuid = (0, external_node_crypto_namespaceObject.randomUUID)())=>{
|
|
2113
|
-
const timestamp = [
|
|
2114
|
-
date.getFullYear(),
|
|
2115
|
-
padDatePart(date.getMonth() + 1),
|
|
2116
|
-
padDatePart(date.getDate()),
|
|
2117
|
-
padDatePart(date.getHours()),
|
|
2118
|
-
padDatePart(date.getMinutes()),
|
|
2119
|
-
padDatePart(date.getSeconds())
|
|
2120
|
-
].join('');
|
|
2121
|
-
return `${timestamp}-${uuid.slice(0, 8)}`;
|
|
2122
|
-
};
|
|
2123
|
-
const assertDirectory = (path, label)=>{
|
|
2124
|
-
if (!(0, external_node_fs_namespaceObject.existsSync)(path) || !(0, external_node_fs_namespaceObject.statSync)(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
|
|
2125
|
-
};
|
|
2126
724
|
const asCollectionError = (projectId, projectName, sourcePath, error)=>({
|
|
2127
725
|
projectId,
|
|
2128
726
|
projectName,
|
|
2129
727
|
sourcePath,
|
|
2130
|
-
error: error instanceof WorkflowError ? error : new WorkflowParseError(`Failed to collect workflow document "${sourcePath}": ${error instanceof Error ? error.message : String(error)}`, {
|
|
728
|
+
error: error instanceof test_runner_namespaceObject.WorkflowError ? error : new test_runner_namespaceObject.WorkflowParseError(`Failed to collect workflow document "${sourcePath}": ${error instanceof Error ? error.message : String(error)}`, {
|
|
2131
729
|
projectName,
|
|
2132
730
|
sourcePath
|
|
2133
731
|
}, error)
|
|
@@ -2149,52 +747,6 @@ const filterDocumentCases = (document, project)=>{
|
|
|
2149
747
|
filtered
|
|
2150
748
|
};
|
|
2151
749
|
};
|
|
2152
|
-
const test_project_runner_asNotRun = (documentId, collectedCase, projectName, reason)=>({
|
|
2153
|
-
documentId,
|
|
2154
|
-
caseId: collectedCase.caseId,
|
|
2155
|
-
projectName,
|
|
2156
|
-
name: collectedCase.definition.name,
|
|
2157
|
-
sourcePath: collectedCase.sourcePath,
|
|
2158
|
-
caseIndex: collectedCase.caseIndex,
|
|
2159
|
-
status: 'not-run',
|
|
2160
|
-
notRunReason: reason
|
|
2161
|
-
});
|
|
2162
|
-
const summarize = (projects)=>{
|
|
2163
|
-
const cases = projects.flatMap((project)=>project.cases);
|
|
2164
|
-
const documents = projects.flatMap((project)=>project.documents);
|
|
2165
|
-
return {
|
|
2166
|
-
total: cases.length,
|
|
2167
|
-
passed: cases.filter((item)=>'success' === item.status).length,
|
|
2168
|
-
failed: cases.filter((item)=>'failed' === item.status).length,
|
|
2169
|
-
notRun: cases.filter((item)=>'not-run' === item.status).length,
|
|
2170
|
-
filtered: projects.reduce((total, project)=>total + project.filteredCaseCount, 0),
|
|
2171
|
-
collectionErrors: projects.reduce((total, project)=>total + project.collectionErrors.length, 0),
|
|
2172
|
-
documentFailures: documents.filter((document)=>'failed' === document.status).length,
|
|
2173
|
-
projectFailures: projects.filter((project)=>'failed' === project.status).length
|
|
2174
|
-
};
|
|
2175
|
-
};
|
|
2176
|
-
const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
|
|
2177
|
-
const test_project_runner_formatStep = (info)=>{
|
|
2178
|
-
const position = stepPosition(info);
|
|
2179
|
-
const phase = 'steps' === position.phase ? 'step' : position.phase;
|
|
2180
|
-
return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
|
|
2181
|
-
};
|
|
2182
|
-
const formatStepResult = (info, result)=>{
|
|
2183
|
-
const indent = 'case' === info.scope ? ' ' : ' ';
|
|
2184
|
-
const symbol = 'success' === result.status ? '✓' : '✗';
|
|
2185
|
-
const error = result.error ? ` — ${result.error.message}` : '';
|
|
2186
|
-
const continuation = result.continuedAfterError ? '; continuing' : '';
|
|
2187
|
-
return `${indent}${symbol} ${test_project_runner_formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
|
|
2188
|
-
};
|
|
2189
|
-
const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
|
|
2190
|
-
...attempt.beforeEach,
|
|
2191
|
-
...attempt.steps,
|
|
2192
|
-
...attempt.afterEach
|
|
2193
|
-
].some((step)=>step.error && isFatalDeviceError(step.error)) || (attempt.teardownErrors ?? []).some(isFatalDeviceError));
|
|
2194
|
-
const documentHasFatalError = (result)=>[
|
|
2195
|
-
...result.beforeAll,
|
|
2196
|
-
...result.afterAll
|
|
2197
|
-
].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError);
|
|
2198
750
|
const selectProjects = (projects, names)=>{
|
|
2199
751
|
if (!names || 0 === names.length) return projects;
|
|
2200
752
|
const requested = new Set(names);
|
|
@@ -2204,74 +756,316 @@ const selectProjects = (projects, names)=>{
|
|
|
2204
756
|
if (unknown) throw new Error(`Unknown Midscene project: ${unknown}`);
|
|
2205
757
|
return projects.filter((project)=>requested.has(project.name));
|
|
2206
758
|
};
|
|
2207
|
-
|
|
759
|
+
async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
|
|
2208
760
|
const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
|
|
2209
|
-
const
|
|
2210
|
-
const
|
|
761
|
+
const prerequisiteFile = options.prerequisiteFile ? (0, external_node_path_namespaceObject.resolve)(options.prerequisiteFile) : void 0;
|
|
762
|
+
const mainFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
|
|
763
|
+
const files = [
|
|
764
|
+
...prerequisiteFile ? [
|
|
765
|
+
prerequisiteFile
|
|
766
|
+
] : [],
|
|
767
|
+
...mainFiles.filter((file)=>file !== prerequisiteFile)
|
|
768
|
+
];
|
|
769
|
+
const occurrences = new Map();
|
|
770
|
+
const sources = files.map((absolutePath)=>{
|
|
771
|
+
const invocationIndex = occurrences.get(absolutePath) ?? 0;
|
|
772
|
+
occurrences.set(absolutePath, invocationIndex + 1);
|
|
773
|
+
return {
|
|
2211
774
|
projectId: project.projectId,
|
|
2212
775
|
projectName: project.name,
|
|
2213
|
-
sourcePath:
|
|
2214
|
-
absolutePath
|
|
2215
|
-
|
|
776
|
+
sourcePath: project_preparation_toPosix((0, external_node_path_namespaceObject.relative)(projectRoot, absolutePath)),
|
|
777
|
+
absolutePath,
|
|
778
|
+
invocationIndex
|
|
779
|
+
};
|
|
780
|
+
});
|
|
2216
781
|
const collectionErrors = [];
|
|
2217
|
-
const
|
|
782
|
+
const invocations = [];
|
|
2218
783
|
let filteredCaseCount = 0;
|
|
2219
784
|
if (0 === sources.length) {
|
|
2220
|
-
const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
|
|
785
|
+
const error = asCollectionError(project.projectId, project.name, '<project>', new test_runner_namespaceObject.WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
|
|
2221
786
|
projectName: project.name,
|
|
2222
787
|
projectRoot
|
|
2223
788
|
}));
|
|
2224
789
|
collectionErrors.push(error);
|
|
2225
|
-
writeCollectionError(runDir, error);
|
|
790
|
+
await writeCollectionError(runDir, error);
|
|
2226
791
|
}
|
|
2227
792
|
for (const source of sources)try {
|
|
2228
|
-
const collected =
|
|
793
|
+
const collected = await options.collectDocument?.(source, project);
|
|
794
|
+
if (collected) {
|
|
795
|
+
filteredCaseCount += collected.filteredCaseCount;
|
|
796
|
+
if (collected.invocation) invocations.push(collected.invocation);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const document = collectWorkflowDocument(source, {
|
|
2229
800
|
resolveNode: project.nodes.get.bind(project.nodes),
|
|
2230
801
|
variables: project.variables,
|
|
2231
802
|
env: process.env
|
|
2232
803
|
});
|
|
2233
|
-
const filtered =
|
|
804
|
+
const filtered = source.absolutePath === prerequisiteFile ? {
|
|
805
|
+
document,
|
|
806
|
+
filtered: 0
|
|
807
|
+
} : filterDocumentCases(document, project);
|
|
2234
808
|
filteredCaseCount += filtered.filtered;
|
|
2235
|
-
if (filtered.document)
|
|
809
|
+
if (filtered.document) invocations.push({
|
|
810
|
+
document: filtered.document,
|
|
811
|
+
retry: {
|
|
812
|
+
scope: 'case',
|
|
813
|
+
count: project.retry
|
|
814
|
+
},
|
|
815
|
+
defaultTimeoutMs,
|
|
816
|
+
reportEnabled: true,
|
|
817
|
+
bindings: {
|
|
818
|
+
resolveNode: project.nodes.require.bind(project.nodes)
|
|
819
|
+
}
|
|
820
|
+
});
|
|
2236
821
|
} catch (error) {
|
|
2237
822
|
const collectionError = asCollectionError(project.projectId, project.name, source.sourcePath, error);
|
|
2238
823
|
collectionErrors.push(collectionError);
|
|
2239
|
-
writeCollectionError(runDir, collectionError);
|
|
824
|
+
await writeCollectionError(runDir, collectionError);
|
|
2240
825
|
}
|
|
826
|
+
const documents = invocations.map((invocation)=>invocation.document);
|
|
827
|
+
const prerequisiteSource = sources.find((source)=>source.absolutePath === prerequisiteFile);
|
|
828
|
+
const prerequisiteDocument = documents.find((document)=>document.sourcePath === prerequisiteSource?.sourcePath);
|
|
2241
829
|
return {
|
|
2242
830
|
project,
|
|
831
|
+
platform: options.platform ?? 'test',
|
|
832
|
+
documentConcurrency: options.documentConcurrency ?? 1,
|
|
833
|
+
...prerequisiteDocument ? {
|
|
834
|
+
prerequisiteDocumentId: prerequisiteDocument.documentId
|
|
835
|
+
} : {},
|
|
2243
836
|
fileSelection,
|
|
2244
837
|
sources,
|
|
2245
838
|
documents,
|
|
839
|
+
invocations,
|
|
2246
840
|
collectionErrors,
|
|
2247
841
|
selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
|
|
2248
842
|
filteredCaseCount
|
|
2249
843
|
};
|
|
844
|
+
}
|
|
845
|
+
const padDatePart = (value)=>String(value).padStart(2, '0');
|
|
846
|
+
const createTestRunId = (date = new Date(), uuid = (0, external_node_crypto_namespaceObject.randomUUID)())=>{
|
|
847
|
+
const timestamp = [
|
|
848
|
+
date.getFullYear(),
|
|
849
|
+
padDatePart(date.getMonth() + 1),
|
|
850
|
+
padDatePart(date.getDate()),
|
|
851
|
+
padDatePart(date.getHours()),
|
|
852
|
+
padDatePart(date.getMinutes()),
|
|
853
|
+
padDatePart(date.getSeconds())
|
|
854
|
+
].join('');
|
|
855
|
+
return `${timestamp}-${uuid.slice(0, 8)}`;
|
|
2250
856
|
};
|
|
2251
|
-
const
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
857
|
+
const assertDirectory = (path, label)=>{
|
|
858
|
+
if (!(0, external_node_fs_namespaceObject.existsSync)(path) || !(0, external_node_fs_namespaceObject.statSync)(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
|
|
859
|
+
};
|
|
860
|
+
const resolveRunInput = (options)=>{
|
|
2255
861
|
const cwd = (0, external_node_path_namespaceObject.resolve)(options.cwd ?? process.cwd());
|
|
2256
862
|
assertDirectory(cwd, 'Test working directory');
|
|
2257
|
-
const
|
|
863
|
+
const inputPath = options.projectRoot ? (0, external_node_path_namespaceObject.resolve)(cwd, options.projectRoot) : void 0;
|
|
864
|
+
const singleFile = inputPath && (0, external_node_fs_namespaceObject.existsSync)(inputPath) && (0, external_node_fs_namespaceObject.statSync)(inputPath).isFile() ? inputPath : void 0;
|
|
865
|
+
if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
|
|
866
|
+
const cliProjectRoot = singleFile ? (0, external_node_path_namespaceObject.dirname)(singleFile) : inputPath;
|
|
2258
867
|
if (cliProjectRoot) assertDirectory(cliProjectRoot, 'Test project directory');
|
|
2259
|
-
const configSearchRoot = cliProjectRoot ?? cwd;
|
|
2260
|
-
const configPath = options.configPath ? (0, external_node_path_namespaceObject.resolve)(configSearchRoot, options.configPath) : discoverTestConfig(configSearchRoot);
|
|
2261
|
-
if (options.configPath && (!configPath || !(0, external_node_fs_namespaceObject.existsSync)(configPath))) throw new Error(`Midscene config does not exist: ${configPath}`);
|
|
2262
|
-
const definition = await loadTestProject(configPath);
|
|
2263
868
|
const projectRoot = cliProjectRoot ?? cwd;
|
|
2264
|
-
|
|
869
|
+
return {
|
|
870
|
+
cwd,
|
|
871
|
+
projectRoot,
|
|
872
|
+
configSearchRoot: projectRoot,
|
|
873
|
+
...singleFile ? {
|
|
874
|
+
singleFile
|
|
875
|
+
} : {}
|
|
876
|
+
};
|
|
877
|
+
};
|
|
878
|
+
async function prepareTestRun(options) {
|
|
879
|
+
const startedAt = new Date();
|
|
880
|
+
const runId = createTestRunId(startedAt);
|
|
881
|
+
const input = resolveRunInput(options);
|
|
882
|
+
const configPath = options.configPath ? (0, external_node_path_namespaceObject.resolve)(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
|
|
883
|
+
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}\`.`);
|
|
884
|
+
if (options.configPath && !(0, external_node_fs_namespaceObject.existsSync)(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
|
|
885
|
+
const definition = await loadTestProject(configPath);
|
|
886
|
+
const resultDir = options.resultDir ? (0, external_node_path_namespaceObject.resolve)(input.cwd, options.resultDir) : (0, external_node_path_namespaceObject.join)(input.projectRoot, '.midscene', 'test-results');
|
|
2265
887
|
const runDir = (0, external_node_path_namespaceObject.join)(resultDir, runId);
|
|
2266
888
|
const summaryPath = (0, external_node_path_namespaceObject.join)(runDir, 'summary.json');
|
|
2267
|
-
const reportDir = (0, external_node_path_namespaceObject.resolve)(projectRoot, definition.output.reportDir);
|
|
889
|
+
const reportDir = (0, external_node_path_namespaceObject.resolve)(input.projectRoot, definition.output.reportDir);
|
|
2268
890
|
(0, external_node_fs_namespaceObject.mkdirSync)(resultDir, {
|
|
2269
891
|
recursive: true
|
|
2270
892
|
});
|
|
2271
893
|
(0, external_node_fs_namespaceObject.mkdirSync)(runDir);
|
|
2272
|
-
const
|
|
2273
|
-
const
|
|
2274
|
-
|
|
894
|
+
const projects = [];
|
|
895
|
+
for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, input.singleFile ? {
|
|
896
|
+
files: [
|
|
897
|
+
input.singleFile
|
|
898
|
+
]
|
|
899
|
+
} : void 0));
|
|
900
|
+
const hasFormatMismatch = projects.some((project)=>project.collectionErrors.some(({ error })=>error instanceof NativeWorkflowFormatError));
|
|
901
|
+
return {
|
|
902
|
+
startedAt,
|
|
903
|
+
runId,
|
|
904
|
+
projectRoot: input.projectRoot,
|
|
905
|
+
...configPath ? {
|
|
906
|
+
configPath
|
|
907
|
+
} : {},
|
|
908
|
+
resultDir,
|
|
909
|
+
runDir,
|
|
910
|
+
summaryPath,
|
|
911
|
+
reportDir,
|
|
912
|
+
definition,
|
|
913
|
+
projects,
|
|
914
|
+
preflightScope: hasFormatMismatch ? 'run' : 'project',
|
|
915
|
+
reportEnabled: true,
|
|
916
|
+
publications: [],
|
|
917
|
+
onProgress: options.onProgress ?? (()=>{})
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
const external_node_events_namespaceObject = require("node:events");
|
|
921
|
+
const report_namespaceObject = require("@midscene/core/report");
|
|
922
|
+
const logger_namespaceObject = require("@midscene/shared/logger");
|
|
923
|
+
const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
|
|
924
|
+
const formatStep = (info)=>{
|
|
925
|
+
const position = stepPosition(info);
|
|
926
|
+
const phase = 'steps' === position.phase ? 'step' : position.phase;
|
|
927
|
+
return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
|
|
928
|
+
};
|
|
929
|
+
const formatStepResult = (info, result)=>{
|
|
930
|
+
const indent = 'case' === info.scope ? ' ' : ' ';
|
|
931
|
+
const symbol = 'success' === result.status ? '✓' : '✗';
|
|
932
|
+
const error = result.error ? ` — ${result.error.message}` : '';
|
|
933
|
+
const continuation = result.continuedAfterError ? '; continuing' : '';
|
|
934
|
+
return `${indent}${symbol} ${formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
|
|
935
|
+
};
|
|
936
|
+
const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
|
|
937
|
+
...attempt.beforeEach,
|
|
938
|
+
...attempt.steps,
|
|
939
|
+
...attempt.afterEach
|
|
940
|
+
].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (attempt.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError));
|
|
941
|
+
const documentHasFatalError = (result)=>[
|
|
942
|
+
...result.beforeAll,
|
|
943
|
+
...result.afterAll
|
|
944
|
+
].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (result.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError);
|
|
945
|
+
async function executeDocumentInvocation(options) {
|
|
946
|
+
const { invocation, project, sinks } = options;
|
|
947
|
+
const { document } = invocation;
|
|
948
|
+
const documentRetry = 'document' === invocation.retry.scope;
|
|
949
|
+
const attempts = [];
|
|
950
|
+
await (0, test_runner_namespaceObject.runDocumentAttempts)({
|
|
951
|
+
retry: documentRetry ? invocation.retry.count : 0,
|
|
952
|
+
signal: options.signal,
|
|
953
|
+
shouldStop: ()=>options.shouldBail() || options.isProjectFatal()
|
|
954
|
+
}, async (documentAttemptIndex)=>{
|
|
955
|
+
if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
|
|
956
|
+
const execution = await (0, test_runner_namespaceObject.runWorkflowDocument)(document, {
|
|
957
|
+
documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
|
|
958
|
+
...invocation.bindings,
|
|
959
|
+
project,
|
|
960
|
+
projectContext: options.projectContext,
|
|
961
|
+
retry: documentRetry ? 0 : invocation.retry.count,
|
|
962
|
+
signal: options.signal,
|
|
963
|
+
defaultTimeoutMs: invocation.defaultTimeoutMs,
|
|
964
|
+
shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
|
|
965
|
+
stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
|
|
966
|
+
isFatalError: (run)=>[
|
|
967
|
+
...run.beforeEach,
|
|
968
|
+
...run.steps,
|
|
969
|
+
...run.afterEach
|
|
970
|
+
].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (run.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError),
|
|
971
|
+
onCaseStart: (collectedCase)=>{
|
|
972
|
+
options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
|
|
973
|
+
},
|
|
974
|
+
onStepStart: (info)=>{
|
|
975
|
+
const indent = 'case' === info.scope ? ' ' : ' ';
|
|
976
|
+
options.onProgress(`${indent}→ ${formatStep(info)}`);
|
|
977
|
+
},
|
|
978
|
+
onStepResult: async (info, result)=>{
|
|
979
|
+
await invocation.bindings.onStepResult?.(info, result);
|
|
980
|
+
options.onProgress(formatStepResult(info, result));
|
|
981
|
+
},
|
|
982
|
+
onCaseResult: async (attempt)=>{
|
|
983
|
+
await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
|
|
984
|
+
options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
|
|
985
|
+
},
|
|
986
|
+
onCaseOutcome: (outcome)=>{
|
|
987
|
+
if (caseHasFatalError(outcome)) options.markProjectFatal();
|
|
988
|
+
if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
|
|
989
|
+
},
|
|
990
|
+
onDocumentResult: options.onDocumentResult
|
|
991
|
+
}).catch(async (error)=>{
|
|
992
|
+
if (error instanceof test_runner_namespaceObject.WorkflowExecutionFailure) {
|
|
993
|
+
const partial = error.result;
|
|
994
|
+
attempts.push(partial);
|
|
995
|
+
sinks.documents.push(partial.document);
|
|
996
|
+
sinks.cases.push(...partial.cases.map((outcome)=>({
|
|
997
|
+
...outcome,
|
|
998
|
+
documentId: document.documentId,
|
|
999
|
+
documentRunId: partial.document.documentRunId
|
|
1000
|
+
})));
|
|
1001
|
+
try {
|
|
1002
|
+
await writeWorkflowDocumentResult(options.runDir, partial.document);
|
|
1003
|
+
} catch (publicationError) {
|
|
1004
|
+
throw new test_runner_namespaceObject.WorkflowExecutionFailure(partial, [
|
|
1005
|
+
...error.errors,
|
|
1006
|
+
publicationError
|
|
1007
|
+
]);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
throw error;
|
|
1011
|
+
});
|
|
1012
|
+
attempts.push(execution);
|
|
1013
|
+
sinks.cases.push(...execution.cases.map((outcome)=>({
|
|
1014
|
+
...outcome,
|
|
1015
|
+
documentId: document.documentId,
|
|
1016
|
+
documentRunId: execution.document.documentRunId
|
|
1017
|
+
})));
|
|
1018
|
+
sinks.documents.push(execution.document);
|
|
1019
|
+
await writeWorkflowDocumentResult(options.runDir, execution.document);
|
|
1020
|
+
if (documentHasFatalError(execution.document)) options.markProjectFatal();
|
|
1021
|
+
const cleanupErrors = [
|
|
1022
|
+
...execution.document.teardownErrors ?? [],
|
|
1023
|
+
...execution.cases.flatMap((outcome)=>(outcome.attempts ?? []).flatMap((attempt)=>attempt.teardownErrors ?? []))
|
|
1024
|
+
];
|
|
1025
|
+
if (cleanupErrors.length) throw new test_runner_namespaceObject.WorkflowExecutionFailure(execution, cleanupErrors);
|
|
1026
|
+
const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
|
|
1027
|
+
return failed ? 'failed' : 'success';
|
|
1028
|
+
});
|
|
1029
|
+
const final = attempts.at(-1);
|
|
1030
|
+
if (!final) return;
|
|
1031
|
+
const failedCases = final.cases.filter((item)=>'failed' === item.status).length;
|
|
1032
|
+
if (documentRetry && failedCases) options.addFailedCases(failedCases);
|
|
1033
|
+
else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
|
|
1034
|
+
}
|
|
1035
|
+
const asNotRun = (documentId, collectedCase, projectName, reason)=>({
|
|
1036
|
+
documentId,
|
|
1037
|
+
caseId: collectedCase.caseId,
|
|
1038
|
+
projectName,
|
|
1039
|
+
name: collectedCase.definition.name,
|
|
1040
|
+
sourcePath: collectedCase.sourcePath,
|
|
1041
|
+
caseIndex: collectedCase.caseIndex,
|
|
1042
|
+
status: 'not-run',
|
|
1043
|
+
notRunReason: reason
|
|
1044
|
+
});
|
|
1045
|
+
const summarize = (projects)=>{
|
|
1046
|
+
const cases = projects.flatMap((project)=>latestById(project.cases, (item)=>item.caseId));
|
|
1047
|
+
const documents = projects.flatMap((project)=>latestById(project.documents, (item)=>item.documentId));
|
|
1048
|
+
return {
|
|
1049
|
+
total: cases.length,
|
|
1050
|
+
passed: cases.filter((item)=>'success' === item.status).length,
|
|
1051
|
+
failed: cases.filter((item)=>'failed' === item.status).length,
|
|
1052
|
+
notRun: cases.filter((item)=>'not-run' === item.status).length,
|
|
1053
|
+
filtered: projects.reduce((total, project)=>total + project.filteredCaseCount, 0),
|
|
1054
|
+
collectionErrors: projects.reduce((total, project)=>total + project.collectionErrors.length, 0),
|
|
1055
|
+
documentFailures: documents.filter((document)=>'failed' === document.status).length,
|
|
1056
|
+
projectFailures: projects.filter((project)=>'failed' === project.status).length
|
|
1057
|
+
};
|
|
1058
|
+
};
|
|
1059
|
+
const latestById = (items, id)=>[
|
|
1060
|
+
...new Map(items.map((item)=>[
|
|
1061
|
+
id(item),
|
|
1062
|
+
item
|
|
1063
|
+
])).values()
|
|
1064
|
+
];
|
|
1065
|
+
const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>asNotRun(document.documentId, item, prepared.project.name, reason)));
|
|
1066
|
+
async function runPreparedTestProject(plan) {
|
|
1067
|
+
const { startedAt, runId, projectRoot, configPath, resultDir, runDir, summaryPath, reportDir, definition, projects: preparedProjects } = plan;
|
|
1068
|
+
const progress = plan.onProgress;
|
|
2275
1069
|
const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
|
|
2276
1070
|
const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
|
|
2277
1071
|
const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
|
|
@@ -2289,11 +1083,13 @@ async function runTestProject(options = {}) {
|
|
|
2289
1083
|
let failedCaseCount = 0;
|
|
2290
1084
|
const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
|
|
2291
1085
|
let hasInfrastructureError = false;
|
|
2292
|
-
|
|
1086
|
+
const infrastructureErrors = [];
|
|
2293
1087
|
const recordInfrastructureError = (error)=>{
|
|
2294
|
-
|
|
1088
|
+
const errors = error instanceof test_runner_namespaceObject.WorkflowExecutionFailure ? error.errors : [
|
|
1089
|
+
error
|
|
1090
|
+
];
|
|
1091
|
+
for (const item of errors)if (!infrastructureErrors.includes(item)) infrastructureErrors.push(item);
|
|
2295
1092
|
hasInfrastructureError = true;
|
|
2296
|
-
firstInfrastructureError = error;
|
|
2297
1093
|
rootController.abort(error);
|
|
2298
1094
|
};
|
|
2299
1095
|
const runInfrastructureCallback = (callback)=>{
|
|
@@ -2310,10 +1106,11 @@ async function runTestProject(options = {}) {
|
|
|
2310
1106
|
};
|
|
2311
1107
|
const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
|
|
2312
1108
|
const { project } = prepared;
|
|
2313
|
-
const projectFailed = prepared.collectionErrors.length > 0 || cases.some((item)=>'success' !== item.status) || documents.some((item)=>'failed' === item.status) || lifecycle?.status === 'failed';
|
|
1109
|
+
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';
|
|
2314
1110
|
return {
|
|
2315
1111
|
projectId: project.projectId,
|
|
2316
1112
|
name: project.name,
|
|
1113
|
+
platform: prepared.platform,
|
|
2317
1114
|
status: projectFailed ? 'failed' : 'success',
|
|
2318
1115
|
retry: project.retry,
|
|
2319
1116
|
fileSelection: prepared.fileSelection,
|
|
@@ -2341,106 +1138,111 @@ async function runTestProject(options = {}) {
|
|
|
2341
1138
|
const documents = [];
|
|
2342
1139
|
let lifecycle;
|
|
2343
1140
|
let projectFatal = false;
|
|
2344
|
-
if (prepared.collectionErrors.length > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
|
|
1141
|
+
if (prepared.collectionErrors.length > 0 || 'run' === plan.preflightScope && totalErrors > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
|
|
2345
1142
|
else if (rootController.signal.aborted) cases.push(...notRunSuite(prepared, 'interrupted'));
|
|
2346
1143
|
else if (bailReached()) cases.push(...notRunSuite(prepared, 'bail'));
|
|
2347
1144
|
else {
|
|
2348
|
-
const runtime = createProjectRuntime({
|
|
1145
|
+
const runtime = (0, test_runner_namespaceObject.createProjectRuntime)({
|
|
2349
1146
|
project,
|
|
2350
1147
|
setup: project.setup,
|
|
2351
1148
|
signal: rootController.signal
|
|
2352
1149
|
});
|
|
2353
1150
|
let hasProjectExecutionError = false;
|
|
2354
|
-
let projectExecutionError;
|
|
2355
1151
|
try {
|
|
2356
1152
|
await runtime.start();
|
|
2357
|
-
if (runtime.canRun)
|
|
2358
|
-
|
|
2359
|
-
const
|
|
2360
|
-
|
|
2361
|
-
|
|
1153
|
+
if (runtime.canRun) {
|
|
1154
|
+
const runDocument = async (invocation, documentIndex)=>{
|
|
1155
|
+
const { document } = invocation;
|
|
1156
|
+
if (rootController.signal.aborted || bailReached() || projectFatal) {
|
|
1157
|
+
const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
|
|
1158
|
+
cases.push(...document.cases.map((item)=>asNotRun(document.documentId, item, project.name, reason)));
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
projectProgress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
|
|
1162
|
+
await executeDocumentInvocation({
|
|
1163
|
+
invocation,
|
|
1164
|
+
project,
|
|
1165
|
+
projectContext: runtime.context,
|
|
1166
|
+
runDir,
|
|
1167
|
+
signal: runtime.signal,
|
|
1168
|
+
rootSignal: rootController.signal,
|
|
1169
|
+
shouldBail: bailReached,
|
|
1170
|
+
isProjectFatal: ()=>projectFatal,
|
|
1171
|
+
markProjectFatal: ()=>{
|
|
1172
|
+
projectFatal = true;
|
|
1173
|
+
},
|
|
1174
|
+
addFailedCases: (count)=>{
|
|
1175
|
+
failedCaseCount += count;
|
|
1176
|
+
},
|
|
1177
|
+
onProgress: projectProgress,
|
|
1178
|
+
sinks: {
|
|
1179
|
+
cases,
|
|
1180
|
+
documents
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
};
|
|
1184
|
+
let prerequisiteFailed = false;
|
|
1185
|
+
const prerequisite = prepared.invocations.find((invocation)=>invocation.document.documentId === prepared.prerequisiteDocumentId);
|
|
1186
|
+
const prerequisiteCount = prerequisite ? 1 : 0;
|
|
1187
|
+
if (prerequisite) {
|
|
1188
|
+
await runDocument(prerequisite, 0);
|
|
1189
|
+
prerequisiteFailed = documents.at(-1)?.status === 'failed' || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status);
|
|
2362
1190
|
}
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
isFatalError: (run)=>[
|
|
2374
|
-
...run.beforeEach,
|
|
2375
|
-
...run.steps,
|
|
2376
|
-
...run.afterEach
|
|
2377
|
-
].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
|
|
2378
|
-
onCaseStart: (collectedCase)=>{
|
|
2379
|
-
projectProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
|
|
2380
|
-
},
|
|
2381
|
-
onStepStart: (info)=>{
|
|
2382
|
-
const indent = 'case' === info.scope ? ' ' : ' ';
|
|
2383
|
-
projectProgress(`${indent}→ ${test_project_runner_formatStep(info)}`);
|
|
2384
|
-
},
|
|
2385
|
-
onStepResult: (info, result)=>projectProgress(formatStepResult(info, result)),
|
|
2386
|
-
onCaseResult: (attempt)=>{
|
|
2387
|
-
runInfrastructureCallback(()=>writeCaseAttemptResult(runDir, project.projectId, document.documentId, attempt));
|
|
2388
|
-
projectProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${project.retry + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
|
|
2389
|
-
},
|
|
2390
|
-
onCaseOutcome: (outcome)=>{
|
|
2391
|
-
if ('failed' === outcome.status) failedCaseCount += 1;
|
|
2392
|
-
if (caseHasFatalError(outcome)) projectFatal = true;
|
|
2393
|
-
},
|
|
2394
|
-
onDocumentResult: (documentResult)=>runInfrastructureCallback(()=>writeWorkflowDocumentResult(runDir, documentResult))
|
|
1191
|
+
if (!prerequisiteFailed) await (0, test_runner_namespaceObject.runConcurrentJobs)(prepared.invocations.filter((invocation)=>invocation !== prerequisite), {
|
|
1192
|
+
concurrency: prepared.documentConcurrency,
|
|
1193
|
+
shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal
|
|
1194
|
+
}, async (invocation, index)=>{
|
|
1195
|
+
try {
|
|
1196
|
+
await runDocument(invocation, index + prerequisiteCount);
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
recordInfrastructureError(error);
|
|
1199
|
+
throw error;
|
|
1200
|
+
}
|
|
2395
1201
|
});
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
if (documentHasFatalError(execution.document)) projectFatal = true;
|
|
2401
|
-
documents.push(execution.document);
|
|
2402
|
-
}
|
|
2403
|
-
else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
|
|
1202
|
+
const completed = new Set(cases.map((item)=>item.caseId));
|
|
1203
|
+
const reason = prerequisiteFailed ? 'project-setup-failed' : rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
|
|
1204
|
+
cases.push(...notRunSuite(prepared, reason).filter((item)=>!completed.has(item.caseId)));
|
|
1205
|
+
} else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
|
|
2404
1206
|
} catch (error) {
|
|
2405
1207
|
hasProjectExecutionError = true;
|
|
2406
|
-
projectExecutionError = error;
|
|
2407
1208
|
recordInfrastructureError(error);
|
|
2408
1209
|
} finally{
|
|
2409
|
-
const hasFailure = hasProjectExecutionError || cases.some((item)=>'success' !== item.status) || documents.some((item)=>'failed' === item.status) || projectFatal || rootController.signal.aborted;
|
|
1210
|
+
const hasFailure = hasProjectExecutionError || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status) || latestById(documents, (item)=>item.documentId).some((item)=>'failed' === item.status) || projectFatal || rootController.signal.aborted;
|
|
2410
1211
|
lifecycle = await runtime.finish(hasFailure ? 'failed' : 'success');
|
|
2411
1212
|
}
|
|
2412
|
-
if (hasProjectExecutionError)
|
|
1213
|
+
if (hasProjectExecutionError) {
|
|
1214
|
+
const completed = new Set(cases.map((item)=>item.caseId));
|
|
1215
|
+
cases.push(...notRunSuite(prepared, 'interrupted').filter((item)=>!completed.has(item.caseId)));
|
|
1216
|
+
}
|
|
2413
1217
|
}
|
|
1218
|
+
const order = new Map(prepared.documents.map((document, index)=>[
|
|
1219
|
+
document.documentId,
|
|
1220
|
+
index
|
|
1221
|
+
]));
|
|
1222
|
+
const documentOrder = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
|
|
1223
|
+
cases.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || a.caseIndex - b.caseIndex);
|
|
1224
|
+
documents.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || (a.attemptIndex ?? 0) - (b.attemptIndex ?? 0));
|
|
2414
1225
|
return buildProjectResult(prepared, cases, documents, lifecycle);
|
|
2415
1226
|
};
|
|
2416
1227
|
const projectResults = new Array(preparedProjects.length);
|
|
2417
|
-
let nextProjectIndex = 0;
|
|
2418
|
-
const claimNextProject = ()=>{
|
|
2419
|
-
if (hasInfrastructureError || rootController.signal.aborted || bailReached() || nextProjectIndex >= preparedProjects.length) return;
|
|
2420
|
-
const projectIndex = nextProjectIndex;
|
|
2421
|
-
nextProjectIndex += 1;
|
|
2422
|
-
return projectIndex;
|
|
2423
|
-
};
|
|
2424
|
-
const worker = async ()=>{
|
|
2425
|
-
try {
|
|
2426
|
-
while(true){
|
|
2427
|
-
const projectIndex = claimNextProject();
|
|
2428
|
-
if (void 0 === projectIndex) return;
|
|
2429
|
-
projectResults[projectIndex] = await runPreparedProject(preparedProjects[projectIndex], projectIndex);
|
|
2430
|
-
}
|
|
2431
|
-
} catch (error) {
|
|
2432
|
-
recordInfrastructureError(error);
|
|
2433
|
-
}
|
|
2434
|
-
};
|
|
2435
1228
|
try {
|
|
2436
|
-
await
|
|
2437
|
-
|
|
2438
|
-
|
|
1229
|
+
await (0, test_runner_namespaceObject.runConcurrentJobs)(preparedProjects, {
|
|
1230
|
+
concurrency: effectiveConcurrency,
|
|
1231
|
+
shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
|
|
1232
|
+
}, async (prepared, index)=>{
|
|
1233
|
+
try {
|
|
1234
|
+
projectResults[index] = await runPreparedProject(prepared, index);
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
recordInfrastructureError(error);
|
|
1237
|
+
throw error;
|
|
1238
|
+
}
|
|
1239
|
+
});
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
recordInfrastructureError(error);
|
|
2439
1242
|
} finally{
|
|
2440
1243
|
process.off('SIGINT', sigint);
|
|
2441
1244
|
process.off('SIGTERM', sigterm);
|
|
2442
1245
|
}
|
|
2443
|
-
if (hasInfrastructureError) throw firstInfrastructureError;
|
|
2444
1246
|
const completedProjectResults = preparedProjects.map((prepared, projectIndex)=>{
|
|
2445
1247
|
const result = projectResults[projectIndex];
|
|
2446
1248
|
if (result) return result;
|
|
@@ -2449,7 +1251,7 @@ async function runTestProject(options = {}) {
|
|
|
2449
1251
|
return buildSkippedProjectResult(prepared, projectIndex, reason);
|
|
2450
1252
|
});
|
|
2451
1253
|
const summary = summarize(completedProjectResults);
|
|
2452
|
-
const failed = rootController.signal.aborted || summary.failed > 0 || summary.notRun > 0 || summary.collectionErrors > 0 || summary.documentFailures > 0 || summary.projectFailures > 0;
|
|
1254
|
+
const failed = hasInfrastructureError || rootController.signal.aborted || summary.failed > 0 || summary.notRun > 0 || summary.collectionErrors > 0 || summary.documentFailures > 0 || summary.projectFailures > 0;
|
|
2453
1255
|
const endedAt = new Date();
|
|
2454
1256
|
const result = {
|
|
2455
1257
|
schemaVersion: 3,
|
|
@@ -2466,34 +1268,66 @@ async function runTestProject(options = {}) {
|
|
|
2466
1268
|
projects: completedProjectResults,
|
|
2467
1269
|
cases: completedProjectResults.flatMap((project)=>project.cases),
|
|
2468
1270
|
documents: completedProjectResults.flatMap((project)=>project.documents),
|
|
2469
|
-
collectionErrors: completedProjectResults.flatMap((project)=>project.collectionErrors)
|
|
1271
|
+
collectionErrors: completedProjectResults.flatMap((project)=>project.collectionErrors),
|
|
1272
|
+
...hasInfrastructureError ? {
|
|
1273
|
+
errors: infrastructureErrors.map(test_runner_namespaceObject.asExecutionError)
|
|
1274
|
+
} : {}
|
|
2470
1275
|
};
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
reportPath
|
|
1276
|
+
let completedResult = result;
|
|
1277
|
+
const publish = async (operation, path, callback)=>{
|
|
1278
|
+
try {
|
|
1279
|
+
await callback();
|
|
1280
|
+
return true;
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
recordInfrastructureError(error instanceof test_runner_namespaceObject.WorkflowPublicationError || error instanceof test_runner_namespaceObject.WorkflowExecutionFailure ? error : new test_runner_namespaceObject.WorkflowPublicationError(operation, path, error));
|
|
1283
|
+
completedResult = {
|
|
1284
|
+
...completedResult,
|
|
1285
|
+
status: 'failed',
|
|
1286
|
+
exitCode: 1,
|
|
1287
|
+
errors: infrastructureErrors.map(test_runner_namespaceObject.asExecutionError)
|
|
1288
|
+
};
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
2487
1291
|
};
|
|
2488
|
-
writeTestProjectRunResult({
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
1292
|
+
const writeSummary = ()=>publish('write-result', summaryPath, ()=>writeTestProjectRunResult({
|
|
1293
|
+
projectRoot,
|
|
1294
|
+
...configPath ? {
|
|
1295
|
+
configPath
|
|
1296
|
+
} : {},
|
|
1297
|
+
result: completedResult
|
|
1298
|
+
}));
|
|
1299
|
+
const writeReport = ()=>publish('write-report', reportDir, async ()=>{
|
|
1300
|
+
if (!plan.reportEnabled) return;
|
|
1301
|
+
const reportPath = await new report_namespaceObject.TestRunReportAssembler().assembleAsync({
|
|
1302
|
+
outputDir: reportDir,
|
|
1303
|
+
reportFileName: `midscene-e2e-${runId}`,
|
|
1304
|
+
overwrite: false,
|
|
1305
|
+
sources: (0, test_runner_namespaceObject.collectTestRunReportSources)(completedResult),
|
|
1306
|
+
buildRunnerDump: (index)=>{
|
|
1307
|
+
const dump = (0, test_runner_namespaceObject.buildTestRunReportDump)(completedResult, index);
|
|
1308
|
+
const warn = (0, logger_namespaceObject.getDebug)('test-runner:report-assembler', {
|
|
1309
|
+
console: true
|
|
1310
|
+
});
|
|
1311
|
+
for (const diagnostic of dump.diagnostics ?? [])warn(diagnostic.message);
|
|
1312
|
+
return dump;
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
completedResult = {
|
|
1316
|
+
...completedResult,
|
|
1317
|
+
reportPath
|
|
1318
|
+
};
|
|
1319
|
+
});
|
|
1320
|
+
await writeReport();
|
|
1321
|
+
for (const publication of plan.publications)await publish(publication.operation, publication.path, async ()=>{
|
|
1322
|
+
await publication.publish(completedResult);
|
|
2494
1323
|
});
|
|
1324
|
+
await writeSummary();
|
|
1325
|
+
if (hasInfrastructureError) throw new test_runner_namespaceObject.WorkflowExecutionFailure(completedResult, infrastructureErrors);
|
|
2495
1326
|
return completedResult;
|
|
2496
1327
|
}
|
|
1328
|
+
async function runTestProject(options = {}) {
|
|
1329
|
+
return runPreparedTestProject(await prepareTestRun(options));
|
|
1330
|
+
}
|
|
2497
1331
|
exports.DEFAULT_TEST_FILE_SELECTION = __webpack_exports__.DEFAULT_TEST_FILE_SELECTION;
|
|
2498
1332
|
exports.createTestRunId = __webpack_exports__.createTestRunId;
|
|
2499
1333
|
exports.defineProjectSetup = __webpack_exports__.defineProjectSetup;
|