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