@midscene/test 1.15.2 → 1.15.3-beta-20261013025343.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/es/cli/index.mjs +1409 -349
- package/dist/es/cli/index.mjs.map +1 -1
- package/dist/es/index.mjs +20 -1
- package/dist/es/index.mjs.map +1 -1
- package/dist/es/midscene/index.mjs +2 -2
- package/dist/es/midscene/index.mjs.map +1 -1
- package/dist/es/test-cli.mjs +1155 -69
- package/dist/es/test-cli.mjs.map +1 -1
- package/dist/lib/cli/index.js +1422 -344
- package/dist/lib/cli/index.js.map +1 -1
- package/dist/lib/index.js +20 -1
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/midscene/index.js +3 -0
- package/dist/lib/midscene/index.js.map +1 -1
- package/dist/lib/test-cli.js +1155 -69
- package/dist/lib/test-cli.js.map +1 -1
- package/dist/types/cli/index.d.ts +230 -0
- package/dist/types/midscene/index.d.ts +6 -2
- package/package.json +13 -13
package/dist/es/cli/index.mjs
CHANGED
|
@@ -1,292 +1,358 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve as external_node_path_resolve, sep } from "node:path";
|
|
4
4
|
import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runConcurrentJobs, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText } from "@midscene/core/internal/test-runner";
|
|
5
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
6
5
|
import { globSync } from "tinyglobby";
|
|
7
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
8
7
|
import { JSON_SCHEMA, load } from "js-yaml";
|
|
9
8
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import { require as api_require } from "tsx/cjs/api";
|
|
10
11
|
import { setMaxListeners } from "node:events";
|
|
11
12
|
import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
|
|
12
13
|
import { getDebug } from "@midscene/shared/logger";
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if ('object' == typeof defaultExport && null !== defaultExport && '__esModule' in defaultExport) {
|
|
21
|
-
if ('default' in defaultExport) return defaultExport.default;
|
|
22
|
-
throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
|
|
23
|
-
}
|
|
24
|
-
return defaultExport;
|
|
25
|
-
}
|
|
26
|
-
throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
|
|
27
|
-
};
|
|
28
|
-
const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
29
|
-
const rejectUnknownKeys = (value, allowedKeys, label)=>{
|
|
30
|
-
const allowed = new Set(allowedKeys);
|
|
31
|
-
const unknown = Object.keys(value).find((key)=>!allowed.has(key));
|
|
32
|
-
if (unknown) throw new TypeError(`Midscene config ${label}.${unknown} is not supported.`);
|
|
33
|
-
};
|
|
34
|
-
const isAbsolutePattern = (pattern)=>isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern) || pattern.startsWith('\\\\');
|
|
35
|
-
const validatePatterns = (value, field, label = 'files', allowEmptyInclude = false)=>{
|
|
36
|
-
if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
|
|
37
|
-
if ('include' === field && 0 === value.length && !allowEmptyInclude) throw new TypeError(`Midscene config ${label}.include must be a non-empty array.`);
|
|
38
|
-
return value.map((pattern, index)=>{
|
|
39
|
-
if ('string' != typeof pattern || 0 === pattern.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
|
|
40
|
-
if (isAbsolutePattern(pattern)) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be relative to the project root.`);
|
|
41
|
-
if (pattern.split(/[\\/]/).includes('..')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must not contain a ".." path segment.`);
|
|
42
|
-
if (pattern.includes('\\')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must use POSIX path separators (/).`);
|
|
43
|
-
if (pattern.startsWith('!')) {
|
|
44
|
-
if ('include' === field) throw new TypeError(`Midscene config ${label}.include[${index}] must not be a negated pattern. Use files.exclude instead.`);
|
|
45
|
-
throw new TypeError(`Midscene config ${label}.exclude[${index}] must not be a negated pattern.`);
|
|
46
|
-
}
|
|
47
|
-
return pattern;
|
|
48
|
-
});
|
|
49
|
-
};
|
|
50
|
-
const validateTestFileSelection = (value, label = 'files')=>{
|
|
51
|
-
if (void 0 === value) return;
|
|
52
|
-
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
53
|
-
rejectUnknownKeys(value, [
|
|
54
|
-
'include',
|
|
55
|
-
'exclude'
|
|
56
|
-
], label);
|
|
57
|
-
const include = validatePatterns(value.include, 'include', label);
|
|
58
|
-
const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
|
|
59
|
-
return Object.freeze({
|
|
60
|
-
include: Object.freeze(include),
|
|
61
|
-
...exclude ? {
|
|
62
|
-
exclude: Object.freeze(exclude)
|
|
63
|
-
} : {}
|
|
64
|
-
});
|
|
65
|
-
};
|
|
66
|
-
const validateTagList = (value, field, label)=>{
|
|
67
|
-
if (void 0 === value) return [];
|
|
68
|
-
if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
|
|
69
|
-
return value.map((tag, index)=>{
|
|
70
|
-
if ('string' != typeof tag || 0 === tag.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
|
|
71
|
-
return tag;
|
|
72
|
-
});
|
|
73
|
-
};
|
|
74
|
-
const validateTagSelection = (value, label)=>{
|
|
75
|
-
if (void 0 === value) return Object.freeze({
|
|
76
|
-
include: [],
|
|
77
|
-
exclude: []
|
|
78
|
-
});
|
|
79
|
-
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
80
|
-
return Object.freeze({
|
|
81
|
-
include: Object.freeze(validateTagList(value.include, 'include', label)),
|
|
82
|
-
exclude: Object.freeze(validateTagList(value.exclude, 'exclude', label))
|
|
14
|
+
const isSafeResultPathSegment = (value)=>'string' == typeof value && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
|
|
15
|
+
function _define_property(obj, key, value) {
|
|
16
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
17
|
+
value: value,
|
|
18
|
+
enumerable: true,
|
|
19
|
+
configurable: true,
|
|
20
|
+
writable: true
|
|
83
21
|
});
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (null === value || 'string' == typeof value || 'boolean' == typeof value) return;
|
|
87
|
-
if ('number' == typeof value) {
|
|
88
|
-
if (Number.isFinite(value)) return;
|
|
89
|
-
throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
|
|
90
|
-
}
|
|
91
|
-
if ('object' != typeof value || void 0 === value) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
|
|
92
|
-
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && null !== Object.getPrototypeOf(value)) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
|
|
93
|
-
if (seen.has(value)) throw new TypeError(`Midscene config ${path} must not contain cycles.`);
|
|
94
|
-
seen.add(value);
|
|
95
|
-
if (Array.isArray(value)) value.forEach((item, index)=>assertJsonValue(item, `${path}[${index}]`, seen));
|
|
96
|
-
else for (const [key, child] of Object.entries(value))assertJsonValue(child, `${path}.${key}`, seen);
|
|
97
|
-
seen.delete(value);
|
|
22
|
+
else obj[key] = value;
|
|
23
|
+
return obj;
|
|
98
24
|
}
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
assertJsonValue(value, label, new Set());
|
|
110
|
-
return deepFreezeJson(value);
|
|
111
|
-
};
|
|
112
|
-
const validateSetup = (value, label)=>{
|
|
113
|
-
if (void 0 === value) return;
|
|
114
|
-
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
115
|
-
rejectUnknownKeys(value, [
|
|
116
|
-
'name',
|
|
117
|
-
'setup'
|
|
118
|
-
], label);
|
|
119
|
-
if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
|
|
120
|
-
if ('function' != typeof value.setup) throw new TypeError(`Midscene config ${label}.setup must be a function.`);
|
|
121
|
-
return value;
|
|
122
|
-
};
|
|
123
|
-
const validatePositiveInteger = (value, fallback, label)=>{
|
|
124
|
-
if (void 0 === value) return fallback;
|
|
125
|
-
if (!Number.isInteger(value) || value <= 0) throw new TypeError(`Midscene config ${label} must be a positive integer.`);
|
|
126
|
-
return value;
|
|
127
|
-
};
|
|
128
|
-
const validateNonNegativeInteger = (value, fallback, label)=>{
|
|
129
|
-
if (void 0 === value) return fallback;
|
|
130
|
-
if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
|
|
131
|
-
return value;
|
|
132
|
-
};
|
|
133
|
-
const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
134
|
-
if (void 0 === value) {
|
|
135
|
-
const setup = validateSetup(defaultSetup, 'setup');
|
|
136
|
-
return {
|
|
137
|
-
hasExplicitProjects: false,
|
|
138
|
-
projects: Object.freeze([
|
|
139
|
-
Object.freeze({
|
|
140
|
-
projectId: projectIdFromIndex(0),
|
|
141
|
-
name: 'default',
|
|
142
|
-
...setup ? {
|
|
143
|
-
setup
|
|
144
|
-
} : {},
|
|
145
|
-
tags: Object.freeze({
|
|
146
|
-
include: [],
|
|
147
|
-
exclude: []
|
|
148
|
-
}),
|
|
149
|
-
retry: 0,
|
|
150
|
-
variables: Object.freeze({}),
|
|
151
|
-
nodes: new registry_NodeRegistry(globalNodes.definitions())
|
|
152
|
-
})
|
|
153
|
-
])
|
|
154
|
-
};
|
|
25
|
+
const executorErrorBrand = Symbol.for('@midscene/test/executor-error');
|
|
26
|
+
let _executorErrorBrand = executorErrorBrand;
|
|
27
|
+
class TestExecutorError extends Error {
|
|
28
|
+
constructor(message, options = {}){
|
|
29
|
+
super(message, {
|
|
30
|
+
cause: options.cause
|
|
31
|
+
}), _define_property(this, _executorErrorBrand, true), _define_property(this, "kind", void 0), _define_property(this, "retryable", void 0);
|
|
32
|
+
this.name = 'TestExecutorError';
|
|
33
|
+
this.kind = options.kind ?? 'unknown';
|
|
34
|
+
this.retryable = options.retryable ?? false;
|
|
155
35
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
36
|
+
}
|
|
37
|
+
function isTestExecutorError(error) {
|
|
38
|
+
return error instanceof Error && (error instanceof TestExecutorError || Object.getOwnPropertyDescriptor(error, Symbol.for('@midscene/test/executor-error'))?.value === true);
|
|
39
|
+
}
|
|
40
|
+
const defineTestExecutor = (executor)=>executor;
|
|
41
|
+
const localTestExecutor = Object.freeze({
|
|
42
|
+
name: 'local',
|
|
43
|
+
execute: (_task, context)=>context.runLocal()
|
|
44
|
+
});
|
|
45
|
+
const jsonValueSchema = z.lazy(()=>z.union([
|
|
46
|
+
z["null"](),
|
|
47
|
+
z.string(),
|
|
48
|
+
z.number().finite(),
|
|
49
|
+
z.boolean(),
|
|
50
|
+
z.array(jsonValueSchema),
|
|
51
|
+
z.record(z.string(), jsonValueSchema)
|
|
52
|
+
]));
|
|
53
|
+
const timestampSchema = z.string().refine((value)=>Number.isFinite(Date.parse(value)), {
|
|
54
|
+
message: 'Expected an ISO-compatible timestamp.'
|
|
55
|
+
});
|
|
56
|
+
const errorSchema = z.strictObject({
|
|
57
|
+
name: z.string().min(1),
|
|
58
|
+
message: z.string(),
|
|
59
|
+
code: z.string().min(1),
|
|
60
|
+
details: jsonValueSchema.optional()
|
|
61
|
+
});
|
|
62
|
+
const operationReferenceSchema = z.strictObject({
|
|
63
|
+
operationId: z.string().min(1),
|
|
64
|
+
scopeId: z.string().optional(),
|
|
65
|
+
stepId: z.string().optional(),
|
|
66
|
+
node: z.string().optional()
|
|
67
|
+
});
|
|
68
|
+
const reportErrorSchema = z.lazy(()=>z.strictObject({
|
|
69
|
+
name: z.string(),
|
|
70
|
+
message: z.string(),
|
|
71
|
+
code: z.string().optional(),
|
|
72
|
+
details: z.strictObject({
|
|
73
|
+
value: jsonValueSchema,
|
|
74
|
+
truncatedPaths: z.array(z.string()).optional(),
|
|
75
|
+
redactedPaths: z.array(z.string()).optional()
|
|
76
|
+
}).optional(),
|
|
77
|
+
format: z.literal('midscene-error').optional(),
|
|
78
|
+
cause: reportErrorSchema.optional(),
|
|
79
|
+
errors: z.array(reportErrorSchema).optional(),
|
|
80
|
+
truncated: z.boolean().optional()
|
|
81
|
+
}));
|
|
82
|
+
const cleanupSchema = z.strictObject({
|
|
83
|
+
status: z["enum"]([
|
|
84
|
+
'not-required',
|
|
85
|
+
'completed',
|
|
86
|
+
'pending',
|
|
87
|
+
'failed'
|
|
88
|
+
]),
|
|
89
|
+
capturedAt: timestampSchema,
|
|
90
|
+
operationId: z.string().optional(),
|
|
91
|
+
blockingOperations: z.array(operationReferenceSchema).optional(),
|
|
92
|
+
error: reportErrorSchema.optional()
|
|
93
|
+
});
|
|
94
|
+
const phaseSchema = z.strictObject({
|
|
95
|
+
status: z["enum"]([
|
|
96
|
+
'success',
|
|
97
|
+
'failed',
|
|
98
|
+
'not-run',
|
|
99
|
+
'not-required'
|
|
100
|
+
]),
|
|
101
|
+
plannedStepCount: z.number().int().nonnegative(),
|
|
102
|
+
executedStepCount: z.number().int().nonnegative()
|
|
103
|
+
});
|
|
104
|
+
const diagnosticsSchema = {
|
|
105
|
+
executionStatus: z["enum"]([
|
|
106
|
+
'success',
|
|
107
|
+
'failed',
|
|
108
|
+
'not-run'
|
|
109
|
+
]).optional(),
|
|
110
|
+
phases: z.strictObject({
|
|
111
|
+
beforeAll: phaseSchema.optional(),
|
|
112
|
+
beforeEach: phaseSchema.optional(),
|
|
113
|
+
steps: phaseSchema.optional(),
|
|
114
|
+
afterEach: phaseSchema.optional(),
|
|
115
|
+
afterAll: phaseSchema.optional()
|
|
116
|
+
}).optional(),
|
|
117
|
+
cleanup: cleanupSchema.optional(),
|
|
118
|
+
pendingOperations: z.array(operationReferenceSchema).optional(),
|
|
119
|
+
retryDecision: z.strictObject({
|
|
120
|
+
scope: z["enum"]([
|
|
121
|
+
'case',
|
|
122
|
+
'document'
|
|
123
|
+
]),
|
|
124
|
+
retryCount: z.number().int().nonnegative(),
|
|
125
|
+
attemptIndex: z.number().int().nonnegative(),
|
|
126
|
+
decision: z["enum"]([
|
|
168
127
|
'retry',
|
|
169
|
-
'
|
|
170
|
-
],
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
nodes,
|
|
185
|
-
...files ? {
|
|
186
|
-
files
|
|
187
|
-
} : {},
|
|
188
|
-
tags: validateTagSelection(candidate.tags, `${label}.tags`),
|
|
189
|
-
retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
|
|
190
|
-
variables: validateVariables(candidate.variables, `${label}.variables`),
|
|
191
|
-
...void 0 === candidate.setup ? {} : {
|
|
192
|
-
setup: validateSetup(candidate.setup, `${label}.setup`)
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
return {
|
|
197
|
-
projects: Object.freeze(projects),
|
|
198
|
-
hasExplicitProjects: true
|
|
199
|
-
};
|
|
200
|
-
};
|
|
201
|
-
const validateTestOptions = (value)=>{
|
|
202
|
-
if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
|
|
203
|
-
const candidate = value ?? {};
|
|
204
|
-
return Object.freeze({
|
|
205
|
-
maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
|
|
206
|
-
bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
|
|
207
|
-
testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
|
|
208
|
-
});
|
|
209
|
-
};
|
|
210
|
-
const validateOutputPath = (value, fallback, label)=>{
|
|
211
|
-
if (void 0 === value) return fallback;
|
|
212
|
-
if ('string' != typeof value || 0 === value.trim().length) throw new TypeError(`Midscene config ${label} must be non-empty.`);
|
|
213
|
-
return value;
|
|
214
|
-
};
|
|
215
|
-
const validateOutput = (value)=>{
|
|
216
|
-
if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config output must be an object.');
|
|
217
|
-
const candidate = value ?? {};
|
|
218
|
-
rejectUnknownKeys(candidate, [
|
|
219
|
-
'reportDir'
|
|
220
|
-
], 'output');
|
|
221
|
-
return Object.freeze({
|
|
222
|
-
reportDir: validateOutputPath(candidate.reportDir, './midscene_run/report', 'output.reportDir')
|
|
223
|
-
});
|
|
128
|
+
'stop'
|
|
129
|
+
]),
|
|
130
|
+
reason: z["enum"]([
|
|
131
|
+
'passed',
|
|
132
|
+
'attempt-failed',
|
|
133
|
+
'budget-exhausted',
|
|
134
|
+
'cleanup-pending',
|
|
135
|
+
'cleanup-failed',
|
|
136
|
+
'operation-pending',
|
|
137
|
+
'fatal-error',
|
|
138
|
+
'interrupted',
|
|
139
|
+
'bail',
|
|
140
|
+
'infrastructure-error'
|
|
141
|
+
])
|
|
142
|
+
}).optional()
|
|
224
143
|
};
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
144
|
+
const stepBaseSchema = z.strictObject({
|
|
145
|
+
waitForPreviousOperationMs: z.number().nonnegative().finite().optional(),
|
|
146
|
+
operations: z.strictObject({
|
|
147
|
+
capturedAt: timestampSchema,
|
|
148
|
+
events: z.array(z.strictObject({
|
|
149
|
+
operationId: z.string().min(1),
|
|
150
|
+
operationKind: z["enum"]([
|
|
151
|
+
'node',
|
|
152
|
+
'resource',
|
|
153
|
+
'acquisition',
|
|
154
|
+
'cleanup',
|
|
155
|
+
'readiness'
|
|
156
|
+
]),
|
|
157
|
+
kind: z["enum"]([
|
|
158
|
+
'started',
|
|
159
|
+
'cancel-requested',
|
|
160
|
+
'settled',
|
|
161
|
+
'runner-returned',
|
|
162
|
+
'cleanup-deferred'
|
|
163
|
+
]),
|
|
164
|
+
at: timestampSchema,
|
|
165
|
+
stepId: z.string().optional(),
|
|
166
|
+
scopeId: z.string().optional(),
|
|
167
|
+
node: z.string().optional(),
|
|
168
|
+
status: z["enum"]([
|
|
169
|
+
'success',
|
|
170
|
+
'failed',
|
|
171
|
+
'not-run'
|
|
172
|
+
]).optional(),
|
|
173
|
+
pending: z.boolean().optional(),
|
|
174
|
+
error: reportErrorSchema.optional(),
|
|
175
|
+
blockingOperations: z.array(operationReferenceSchema).optional()
|
|
176
|
+
}))
|
|
177
|
+
}).optional(),
|
|
178
|
+
phase: z["enum"]([
|
|
179
|
+
'beforeAll',
|
|
180
|
+
'beforeEach',
|
|
181
|
+
'steps',
|
|
182
|
+
'afterEach',
|
|
183
|
+
'afterAll'
|
|
184
|
+
]),
|
|
185
|
+
stepIndex: z.number().int().nonnegative(),
|
|
186
|
+
node: z.string().min(1),
|
|
187
|
+
input: jsonValueSchema,
|
|
188
|
+
meta: z.strictObject({
|
|
189
|
+
timeoutMs: z.number().positive().finite().optional(),
|
|
190
|
+
continueOnError: z.boolean()
|
|
191
|
+
}),
|
|
192
|
+
continuedAfterError: z.boolean(),
|
|
193
|
+
output: z.strictObject({
|
|
194
|
+
summary: z.string().optional(),
|
|
195
|
+
data: jsonValueSchema.optional()
|
|
196
|
+
}).optional(),
|
|
197
|
+
error: errorSchema.optional(),
|
|
198
|
+
report: z.strictObject({
|
|
199
|
+
traces: z.array(z.strictObject({
|
|
200
|
+
type: z.literal('midscene-execution'),
|
|
201
|
+
executionId: z.string().min(1)
|
|
202
|
+
}))
|
|
203
|
+
}).optional()
|
|
204
|
+
});
|
|
205
|
+
const stepSchema = z.discriminatedUnion('status', [
|
|
206
|
+
stepBaseSchema.extend({
|
|
207
|
+
status: z["enum"]([
|
|
208
|
+
'success',
|
|
209
|
+
'failed'
|
|
210
|
+
]),
|
|
211
|
+
startedAt: timestampSchema,
|
|
212
|
+
endedAt: timestampSchema,
|
|
213
|
+
durationMs: z.number().nonnegative().finite()
|
|
214
|
+
}),
|
|
215
|
+
stepBaseSchema.extend({
|
|
216
|
+
status: z.literal('not-run'),
|
|
217
|
+
notRunReason: z.string().min(1),
|
|
218
|
+
blockedBy: z.string().optional()
|
|
219
|
+
})
|
|
220
|
+
]);
|
|
221
|
+
const attemptSchema = z.strictObject({
|
|
222
|
+
...diagnosticsSchema,
|
|
223
|
+
caseId: z.string().min(1),
|
|
224
|
+
runId: z.string().min(1),
|
|
225
|
+
projectName: z.string().min(1),
|
|
226
|
+
attemptIndex: z.number().int().nonnegative(),
|
|
227
|
+
name: z.string(),
|
|
228
|
+
sourcePath: z.string().min(1),
|
|
229
|
+
caseIndex: z.number().int().nonnegative(),
|
|
230
|
+
status: z["enum"]([
|
|
231
|
+
'success',
|
|
232
|
+
'failed'
|
|
233
|
+
]),
|
|
234
|
+
beforeEach: z.array(stepSchema),
|
|
235
|
+
steps: z.array(stepSchema),
|
|
236
|
+
afterEach: z.array(stepSchema),
|
|
237
|
+
executionErrors: z.array(errorSchema).optional(),
|
|
238
|
+
teardownErrors: z.array(errorSchema).optional(),
|
|
239
|
+
reportRefs: z.array(z.string().min(1)).optional(),
|
|
240
|
+
startedAt: timestampSchema,
|
|
241
|
+
endedAt: timestampSchema,
|
|
242
|
+
durationMs: z.number().nonnegative().finite()
|
|
243
|
+
});
|
|
244
|
+
const notRunReasonSchema = z["enum"]([
|
|
245
|
+
'document-start-failed',
|
|
246
|
+
'project-preflight-failed',
|
|
247
|
+
'project-setup-failed',
|
|
248
|
+
'interrupted',
|
|
249
|
+
'bail',
|
|
250
|
+
'fatal-error',
|
|
251
|
+
'executor-failed',
|
|
252
|
+
'cleanup-pending',
|
|
253
|
+
'cleanup-failed',
|
|
254
|
+
'operation-pending'
|
|
255
|
+
]);
|
|
256
|
+
const resultSchema = z.strictObject({
|
|
257
|
+
case: z.strictObject({
|
|
258
|
+
documentId: z.string().min(1),
|
|
259
|
+
caseId: z.string().min(1),
|
|
260
|
+
projectName: z.string().min(1),
|
|
261
|
+
name: z.string(),
|
|
262
|
+
sourcePath: z.string().min(1),
|
|
263
|
+
caseIndex: z.number().int().nonnegative(),
|
|
264
|
+
status: z["enum"]([
|
|
265
|
+
'success',
|
|
266
|
+
'failed',
|
|
267
|
+
'not-run'
|
|
268
|
+
]),
|
|
269
|
+
run: attemptSchema.optional(),
|
|
270
|
+
attempts: z.array(attemptSchema).optional(),
|
|
271
|
+
notRunReason: notRunReasonSchema.optional()
|
|
272
|
+
}),
|
|
273
|
+
document: z.strictObject({
|
|
274
|
+
...diagnosticsSchema,
|
|
275
|
+
attemptIndex: z.number().int().nonnegative().optional(),
|
|
276
|
+
documentId: z.string().min(1),
|
|
277
|
+
documentRunId: z.string().min(1),
|
|
278
|
+
projectId: z.string().min(1),
|
|
279
|
+
projectName: z.string().min(1),
|
|
280
|
+
sourcePath: z.string().min(1),
|
|
281
|
+
status: z["enum"]([
|
|
282
|
+
'success',
|
|
283
|
+
'failed'
|
|
284
|
+
]),
|
|
285
|
+
startedAt: timestampSchema,
|
|
286
|
+
endedAt: timestampSchema,
|
|
287
|
+
durationMs: z.number().nonnegative().finite(),
|
|
288
|
+
beforeAll: z.array(stepSchema),
|
|
289
|
+
afterAll: z.array(stepSchema),
|
|
290
|
+
executionErrors: z.array(errorSchema).optional(),
|
|
291
|
+
teardownErrors: z.array(errorSchema).optional(),
|
|
292
|
+
reportRefs: z.array(z.string().min(1)).optional()
|
|
293
|
+
}).optional(),
|
|
294
|
+
lifecycle: z.strictObject({
|
|
295
|
+
cleanup: cleanupSchema.optional(),
|
|
296
|
+
pendingOperations: z.array(operationReferenceSchema).optional(),
|
|
297
|
+
projectName: z.string().min(1),
|
|
298
|
+
status: z["enum"]([
|
|
299
|
+
'success',
|
|
300
|
+
'failed'
|
|
301
|
+
]),
|
|
302
|
+
setupError: errorSchema.optional(),
|
|
303
|
+
teardownErrors: z.array(errorSchema).optional(),
|
|
304
|
+
startedAt: timestampSchema,
|
|
305
|
+
endedAt: timestampSchema,
|
|
306
|
+
durationMs: z.number().nonnegative().finite()
|
|
307
|
+
}).optional(),
|
|
308
|
+
artifacts: z.array(z.strictObject({
|
|
309
|
+
name: z.string().min(1),
|
|
310
|
+
uri: z.string().min(1),
|
|
311
|
+
mediaType: z.string().min(1).optional()
|
|
312
|
+
})).optional(),
|
|
313
|
+
metadata: z.record(z.string(), z.union([
|
|
314
|
+
z.string(),
|
|
315
|
+
z.number().finite(),
|
|
316
|
+
z.boolean(),
|
|
317
|
+
z["null"]()
|
|
318
|
+
])).optional()
|
|
319
|
+
});
|
|
320
|
+
const attemptMatchesTask = (task, attempt)=>attempt.caseId === task.caseId && attempt.projectName === task.projectName && attempt.name === task.caseName && attempt.sourcePath === task.sourcePath && attempt.caseIndex === task.caseIndex && isSafeResultPathSegment(attempt.runId);
|
|
321
|
+
function assertTestCaseTaskRunResult(task, value) {
|
|
322
|
+
const parsed = resultSchema.safeParse(value);
|
|
323
|
+
if (!parsed.success) throw new TypeError(`Executor returned invalid transport data for ${task.caseId}: ${z.prettifyError(parsed.error)}`);
|
|
324
|
+
const outcome = parsed.data.case;
|
|
325
|
+
if (outcome.caseId !== task.caseId || outcome.documentId !== task.documentId || outcome.projectName !== task.projectName || outcome.name !== task.caseName || outcome.sourcePath !== task.sourcePath || outcome.caseIndex !== task.caseIndex) throw new Error(`Executor returned a result that does not match task ${task.caseId}.`);
|
|
326
|
+
const attempts = outcome.attempts ?? [];
|
|
327
|
+
for (const attempt of attempts){
|
|
328
|
+
const steps = [
|
|
329
|
+
...attempt.beforeEach,
|
|
330
|
+
...attempt.steps,
|
|
331
|
+
...attempt.afterEach
|
|
332
|
+
];
|
|
333
|
+
if ('success' === attempt.status && (0 === attempt.steps.length || (attempt.executionErrors?.length ?? 0) > 0 || steps.some((step)=>'success' !== step.status) || (attempt.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful attempt for ${task.caseId}.`);
|
|
288
334
|
}
|
|
289
|
-
|
|
335
|
+
if (new Set(attempts.map((attempt)=>attempt.runId)).size !== attempts.length) throw new TypeError(`Executor returned duplicate attempt IDs for ${task.caseId}.`);
|
|
336
|
+
if (attempts.some((attempt)=>!attemptMatchesTask(task, attempt))) throw new TypeError(`Executor returned invalid attempts for ${task.caseId}.`);
|
|
337
|
+
if (attempts.some((attempt, index)=>attempt.attemptIndex !== index) || attempts.length > task.retry + 1) throw new TypeError(`Executor returned a non-sequential attempt history for ${task.caseId}.`);
|
|
338
|
+
const finalAttempt = attempts.at(-1);
|
|
339
|
+
if ('not-run' === outcome.status) {
|
|
340
|
+
if (outcome.run || attempts.length || !outcome.notRunReason) throw new TypeError(`Executor returned an inconsistent not-run result for ${task.caseId}.`);
|
|
341
|
+
} else if (void 0 !== outcome.notRunReason || !outcome.run || !finalAttempt || finalAttempt.status !== outcome.status || outcome.run.runId !== finalAttempt.runId || JSON.stringify(outcome.run) !== JSON.stringify(finalAttempt) || attempts.slice(0, -1).some((attempt)=>'failed' !== attempt.status)) throw new TypeError(`Executor returned an inconsistent ${outcome.status} result for ${task.caseId}.`);
|
|
342
|
+
const reportRefs = [
|
|
343
|
+
...attempts.flatMap((attempt)=>attempt.reportRefs ?? []),
|
|
344
|
+
...parsed.data.document?.reportRefs ?? []
|
|
345
|
+
];
|
|
346
|
+
if (reportRefs.some((reference)=>!isSafeResultPathSegment(reference))) throw new TypeError(`Executor returned an invalid report reference for ${task.caseId}.`);
|
|
347
|
+
const document = parsed.data.document;
|
|
348
|
+
if (document?.status === 'success' && ((document.executionErrors?.length ?? 0) > 0 || [
|
|
349
|
+
...document.beforeAll,
|
|
350
|
+
...document.afterAll
|
|
351
|
+
].some((step)=>'success' !== step.status) || (document.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful document for ${task.caseId}.`);
|
|
352
|
+
const lifecycle = parsed.data.lifecycle;
|
|
353
|
+
if (lifecycle?.status === 'success' && (lifecycle.setupError || (lifecycle.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful lifecycle for ${task.caseId}.`);
|
|
354
|
+
if (document && (document.documentId !== task.documentId || document.projectId !== task.projectId || document.projectName !== task.projectName || document.sourcePath !== task.sourcePath || !isSafeResultPathSegment(document.documentRunId))) throw new Error(`Executor returned a document that does not match task ${task.caseId}.`);
|
|
355
|
+
if (parsed.data.lifecycle && parsed.data.lifecycle.projectName !== task.projectName) throw new Error(`Executor returned a lifecycle that does not match task ${task.caseId}.`);
|
|
290
356
|
}
|
|
291
357
|
const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
|
|
292
358
|
projectId,
|
|
@@ -410,7 +476,7 @@ const resolveWorkflowVariables = (value, options)=>{
|
|
|
410
476
|
]));
|
|
411
477
|
return value;
|
|
412
478
|
};
|
|
413
|
-
function
|
|
479
|
+
function collect_define_property(obj, key, value) {
|
|
414
480
|
if (key in obj) Object.defineProperty(obj, key, {
|
|
415
481
|
value: value,
|
|
416
482
|
enumerable: true,
|
|
@@ -427,11 +493,11 @@ class NativeWorkflowFormatError extends WorkflowParseError {
|
|
|
427
493
|
sourcePath,
|
|
428
494
|
actualFormat,
|
|
429
495
|
expectedFormat: 'native'
|
|
430
|
-
}),
|
|
496
|
+
}), collect_define_property(this, "actualFormat", void 0);
|
|
431
497
|
this.actualFormat = actualFormat;
|
|
432
498
|
}
|
|
433
499
|
}
|
|
434
|
-
const
|
|
500
|
+
const rejectUnknownKeys = (value, allowed, label)=>{
|
|
435
501
|
const allowedSet = new Set(allowed);
|
|
436
502
|
const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
|
|
437
503
|
if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
|
|
@@ -453,7 +519,7 @@ function collectWorkflowDocument(source, options) {
|
|
|
453
519
|
if (!isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
|
|
454
520
|
const format = classifyWorkflowDocumentFormat(parsed);
|
|
455
521
|
if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
|
|
456
|
-
|
|
522
|
+
rejectUnknownKeys(parsed, [
|
|
457
523
|
'beforeAll',
|
|
458
524
|
'beforeEach',
|
|
459
525
|
'cases',
|
|
@@ -506,11 +572,16 @@ function collectWorkflowDocument(source, options) {
|
|
|
506
572
|
if (!isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
|
|
507
573
|
caseIndex
|
|
508
574
|
});
|
|
509
|
-
|
|
575
|
+
rejectUnknownKeys(definition, [
|
|
576
|
+
'id',
|
|
510
577
|
'name',
|
|
511
578
|
'tags',
|
|
579
|
+
'resources',
|
|
512
580
|
'steps'
|
|
513
581
|
], `Case ${caseIndex + 1}`);
|
|
582
|
+
if (void 0 !== definition.id && !isSafeResultPathSegment(definition.id)) throw new WorkflowParseError(`Case ${caseIndex + 1} id must start with an ASCII letter or number, contain only letters, numbers, dots, underscores, or hyphens, and be at most 128 characters.`, {
|
|
583
|
+
caseIndex
|
|
584
|
+
});
|
|
514
585
|
if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
|
|
515
586
|
caseIndex
|
|
516
587
|
});
|
|
@@ -521,6 +592,13 @@ function collectWorkflowDocument(source, options) {
|
|
|
521
592
|
if (!Array.isArray(tags) || tags.some((tag)=>'string' != typeof tag || 0 === tag.trim().length)) throw new WorkflowParseError(`Case ${caseIndex + 1} tags must be an array of non-empty strings.`, {
|
|
522
593
|
caseIndex
|
|
523
594
|
});
|
|
595
|
+
const resources = definition.resources ?? [];
|
|
596
|
+
if (!Array.isArray(resources) || resources.some((resource)=>'string' != typeof resource || 0 === resource.trim().length)) throw new WorkflowParseError(`Case ${caseIndex + 1} resources must be an array of non-empty strings.`, {
|
|
597
|
+
caseIndex
|
|
598
|
+
});
|
|
599
|
+
if (new Set(resources).size !== resources.length) throw new WorkflowParseError(`Case ${caseIndex + 1} resources must not contain duplicates.`, {
|
|
600
|
+
caseIndex
|
|
601
|
+
});
|
|
524
602
|
const steps = normalize_normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
|
|
525
603
|
if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
|
|
526
604
|
caseIndex,
|
|
@@ -529,7 +607,7 @@ function collectWorkflowDocument(source, options) {
|
|
|
529
607
|
});
|
|
530
608
|
return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
|
|
531
609
|
});
|
|
532
|
-
const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
|
|
610
|
+
const caseId = 'string' == typeof definition.id ? definition.id : createCaseId(source.projectId, sourcePath, caseIndex);
|
|
533
611
|
if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
|
|
534
612
|
caseId
|
|
535
613
|
});
|
|
@@ -540,8 +618,14 @@ function collectWorkflowDocument(source, options) {
|
|
|
540
618
|
sourcePath,
|
|
541
619
|
caseIndex,
|
|
542
620
|
definition: {
|
|
621
|
+
...'string' == typeof definition.id ? {
|
|
622
|
+
id: definition.id
|
|
623
|
+
} : {},
|
|
543
624
|
name: definition.name,
|
|
544
625
|
tags: tags,
|
|
626
|
+
...resources.length > 0 ? {
|
|
627
|
+
resources: resources
|
|
628
|
+
} : {},
|
|
545
629
|
steps
|
|
546
630
|
}
|
|
547
631
|
};
|
|
@@ -678,6 +762,38 @@ const writeTestProjectRunResult = (options)=>{
|
|
|
678
762
|
...outcome.notRunReason ? {
|
|
679
763
|
notRunReason: outcome.notRunReason
|
|
680
764
|
} : {},
|
|
765
|
+
...outcome.execution ? {
|
|
766
|
+
execution: {
|
|
767
|
+
executor: outcome.execution.executor,
|
|
768
|
+
resources: outcome.execution.resources,
|
|
769
|
+
...void 0 === outcome.execution.attempts ? {} : {
|
|
770
|
+
attempts: outcome.execution.attempts
|
|
771
|
+
},
|
|
772
|
+
...outcome.execution.failure ? {
|
|
773
|
+
failure: outcome.execution.failure
|
|
774
|
+
} : {},
|
|
775
|
+
...outcome.execution.artifacts ? {
|
|
776
|
+
artifacts: outcome.execution.artifacts
|
|
777
|
+
} : {},
|
|
778
|
+
...outcome.execution.metadata ? {
|
|
779
|
+
metadata: outcome.execution.metadata
|
|
780
|
+
} : {},
|
|
781
|
+
...outcome.execution.lifecycle ? {
|
|
782
|
+
lifecycle: {
|
|
783
|
+
status: outcome.execution.lifecycle.status,
|
|
784
|
+
startedAt: outcome.execution.lifecycle.startedAt,
|
|
785
|
+
endedAt: outcome.execution.lifecycle.endedAt,
|
|
786
|
+
durationMs: outcome.execution.lifecycle.durationMs,
|
|
787
|
+
...outcome.execution.lifecycle.setupError ? {
|
|
788
|
+
setupError: errorJson(outcome.execution.lifecycle.setupError)
|
|
789
|
+
} : {},
|
|
790
|
+
...outcome.execution.lifecycle.teardownErrors ? {
|
|
791
|
+
teardownErrors: outcome.execution.lifecycle.teardownErrors.map(errorJson)
|
|
792
|
+
} : {}
|
|
793
|
+
}
|
|
794
|
+
} : {}
|
|
795
|
+
}
|
|
796
|
+
} : {},
|
|
681
797
|
...outcome.attempts ? {
|
|
682
798
|
attempts: outcome.attempts.map((attempt)=>({
|
|
683
799
|
attemptId: attempt.runId,
|
|
@@ -734,6 +850,306 @@ const writeTestProjectRunResult = (options)=>{
|
|
|
734
850
|
}))
|
|
735
851
|
});
|
|
736
852
|
};
|
|
853
|
+
const registry_NodeRegistry = NodeRegistry;
|
|
854
|
+
const projectIdFromIndex = (index)=>`project-${index}`;
|
|
855
|
+
const defineTestProject = (definition)=>definition;
|
|
856
|
+
const defineProjectSetup = (definition)=>definition;
|
|
857
|
+
const getDefaultExport = (loaded, absolutePath)=>{
|
|
858
|
+
if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
|
|
859
|
+
const defaultExport = loaded.default;
|
|
860
|
+
if ('object' == typeof defaultExport && null !== defaultExport && '__esModule' in defaultExport) {
|
|
861
|
+
if ('default' in defaultExport) return defaultExport.default;
|
|
862
|
+
throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
|
|
863
|
+
}
|
|
864
|
+
return defaultExport;
|
|
865
|
+
}
|
|
866
|
+
throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
|
|
867
|
+
};
|
|
868
|
+
const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
869
|
+
const test_project_rejectUnknownKeys = (value, allowedKeys, label)=>{
|
|
870
|
+
const allowed = new Set(allowedKeys);
|
|
871
|
+
const unknown = Object.keys(value).find((key)=>!allowed.has(key));
|
|
872
|
+
if (unknown) throw new TypeError(`Midscene config ${label}.${unknown} is not supported.`);
|
|
873
|
+
};
|
|
874
|
+
const isAbsolutePattern = (pattern)=>isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern) || pattern.startsWith('\\\\');
|
|
875
|
+
const validatePatterns = (value, field, label = 'files', allowEmptyInclude = false)=>{
|
|
876
|
+
if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
|
|
877
|
+
if ('include' === field && 0 === value.length && !allowEmptyInclude) throw new TypeError(`Midscene config ${label}.include must be a non-empty array.`);
|
|
878
|
+
return value.map((pattern, index)=>{
|
|
879
|
+
if ('string' != typeof pattern || 0 === pattern.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
|
|
880
|
+
if (isAbsolutePattern(pattern)) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be relative to the project root.`);
|
|
881
|
+
if (pattern.split(/[\\/]/).includes('..')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must not contain a ".." path segment.`);
|
|
882
|
+
if (pattern.includes('\\')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must use POSIX path separators (/).`);
|
|
883
|
+
if (pattern.startsWith('!')) {
|
|
884
|
+
if ('include' === field) throw new TypeError(`Midscene config ${label}.include[${index}] must not be a negated pattern. Use files.exclude instead.`);
|
|
885
|
+
throw new TypeError(`Midscene config ${label}.exclude[${index}] must not be a negated pattern.`);
|
|
886
|
+
}
|
|
887
|
+
return pattern;
|
|
888
|
+
});
|
|
889
|
+
};
|
|
890
|
+
const validateTestFileSelection = (value, label = 'files')=>{
|
|
891
|
+
if (void 0 === value) return;
|
|
892
|
+
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
893
|
+
test_project_rejectUnknownKeys(value, [
|
|
894
|
+
'include',
|
|
895
|
+
'exclude'
|
|
896
|
+
], label);
|
|
897
|
+
const include = validatePatterns(value.include, 'include', label);
|
|
898
|
+
const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
|
|
899
|
+
return Object.freeze({
|
|
900
|
+
include: Object.freeze(include),
|
|
901
|
+
...exclude ? {
|
|
902
|
+
exclude: Object.freeze(exclude)
|
|
903
|
+
} : {}
|
|
904
|
+
});
|
|
905
|
+
};
|
|
906
|
+
const validateTagList = (value, field, label)=>{
|
|
907
|
+
if (void 0 === value) return [];
|
|
908
|
+
if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
|
|
909
|
+
return value.map((tag, index)=>{
|
|
910
|
+
if ('string' != typeof tag || 0 === tag.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
|
|
911
|
+
return tag;
|
|
912
|
+
});
|
|
913
|
+
};
|
|
914
|
+
const validateTagSelection = (value, label)=>{
|
|
915
|
+
if (void 0 === value) return Object.freeze({
|
|
916
|
+
include: [],
|
|
917
|
+
exclude: []
|
|
918
|
+
});
|
|
919
|
+
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
920
|
+
return Object.freeze({
|
|
921
|
+
include: Object.freeze(validateTagList(value.include, 'include', label)),
|
|
922
|
+
exclude: Object.freeze(validateTagList(value.exclude, 'exclude', label))
|
|
923
|
+
});
|
|
924
|
+
};
|
|
925
|
+
function assertJsonValue(value, path, seen) {
|
|
926
|
+
if (null === value || 'string' == typeof value || 'boolean' == typeof value) return;
|
|
927
|
+
if ('number' == typeof value) {
|
|
928
|
+
if (Number.isFinite(value)) return;
|
|
929
|
+
throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
|
|
930
|
+
}
|
|
931
|
+
if ('object' != typeof value || void 0 === value) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
|
|
932
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && null !== Object.getPrototypeOf(value)) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
|
|
933
|
+
if (seen.has(value)) throw new TypeError(`Midscene config ${path} must not contain cycles.`);
|
|
934
|
+
seen.add(value);
|
|
935
|
+
if (Array.isArray(value)) value.forEach((item, index)=>assertJsonValue(item, `${path}[${index}]`, seen));
|
|
936
|
+
else for (const [key, child] of Object.entries(value))assertJsonValue(child, `${path}.${key}`, seen);
|
|
937
|
+
seen.delete(value);
|
|
938
|
+
}
|
|
939
|
+
const deepFreezeJson = (value)=>{
|
|
940
|
+
if ('object' == typeof value && null !== value) {
|
|
941
|
+
for (const child of Object.values(value))deepFreezeJson(child);
|
|
942
|
+
Object.freeze(value);
|
|
943
|
+
}
|
|
944
|
+
return value;
|
|
945
|
+
};
|
|
946
|
+
const validateVariables = (value, label)=>{
|
|
947
|
+
if (void 0 === value) return Object.freeze({});
|
|
948
|
+
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
949
|
+
assertJsonValue(value, label, new Set());
|
|
950
|
+
return deepFreezeJson(value);
|
|
951
|
+
};
|
|
952
|
+
const validateSetup = (value, label)=>{
|
|
953
|
+
if (void 0 === value) return;
|
|
954
|
+
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
955
|
+
test_project_rejectUnknownKeys(value, [
|
|
956
|
+
'name',
|
|
957
|
+
'setup'
|
|
958
|
+
], label);
|
|
959
|
+
if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
|
|
960
|
+
if ('function' != typeof value.setup) throw new TypeError(`Midscene config ${label}.setup must be a function.`);
|
|
961
|
+
return value;
|
|
962
|
+
};
|
|
963
|
+
const validatePositiveInteger = (value, fallback, label)=>{
|
|
964
|
+
if (void 0 === value) return fallback;
|
|
965
|
+
if (!Number.isInteger(value) || value <= 0) throw new TypeError(`Midscene config ${label} must be a positive integer.`);
|
|
966
|
+
return value;
|
|
967
|
+
};
|
|
968
|
+
const validateNonNegativeInteger = (value, fallback, label)=>{
|
|
969
|
+
if (void 0 === value) return fallback;
|
|
970
|
+
if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
|
|
971
|
+
return value;
|
|
972
|
+
};
|
|
973
|
+
const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
|
|
974
|
+
if (void 0 === value) {
|
|
975
|
+
const setup = validateSetup(defaultSetup, 'setup');
|
|
976
|
+
return {
|
|
977
|
+
hasExplicitProjects: false,
|
|
978
|
+
projects: Object.freeze([
|
|
979
|
+
Object.freeze({
|
|
980
|
+
projectId: projectIdFromIndex(0),
|
|
981
|
+
name: 'default',
|
|
982
|
+
...setup ? {
|
|
983
|
+
setup
|
|
984
|
+
} : {},
|
|
985
|
+
tags: Object.freeze({
|
|
986
|
+
include: [],
|
|
987
|
+
exclude: []
|
|
988
|
+
}),
|
|
989
|
+
retry: 0,
|
|
990
|
+
variables: Object.freeze({}),
|
|
991
|
+
nodes: new registry_NodeRegistry(globalNodes.definitions())
|
|
992
|
+
})
|
|
993
|
+
])
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
|
|
997
|
+
if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
|
|
998
|
+
const names = new Set();
|
|
999
|
+
const projects = value.map((candidate, index)=>{
|
|
1000
|
+
const label = `projects[${index}]`;
|
|
1001
|
+
if (!isRecord(candidate)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
1002
|
+
test_project_rejectUnknownKeys(candidate, [
|
|
1003
|
+
'name',
|
|
1004
|
+
'setup',
|
|
1005
|
+
'nodes',
|
|
1006
|
+
'files',
|
|
1007
|
+
'tags',
|
|
1008
|
+
'retry',
|
|
1009
|
+
'variables'
|
|
1010
|
+
], label);
|
|
1011
|
+
if ('string' != typeof candidate.name || 0 === candidate.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
|
|
1012
|
+
if (names.has(candidate.name)) throw new TypeError(`Midscene config project name "${candidate.name}" must be unique.`);
|
|
1013
|
+
names.add(candidate.name);
|
|
1014
|
+
const files = validateTestFileSelection(candidate.files, `${label}.files`);
|
|
1015
|
+
if (void 0 !== candidate.nodes && !Array.isArray(candidate.nodes)) throw new TypeError(`Midscene config ${label}.nodes must be an array.`);
|
|
1016
|
+
const localNodes = new registry_NodeRegistry(candidate.nodes);
|
|
1017
|
+
const nodes = new registry_NodeRegistry([
|
|
1018
|
+
...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
|
|
1019
|
+
...localNodes.definitions()
|
|
1020
|
+
]);
|
|
1021
|
+
return Object.freeze({
|
|
1022
|
+
projectId: projectIdFromIndex(index),
|
|
1023
|
+
name: candidate.name,
|
|
1024
|
+
nodes,
|
|
1025
|
+
...files ? {
|
|
1026
|
+
files
|
|
1027
|
+
} : {},
|
|
1028
|
+
tags: validateTagSelection(candidate.tags, `${label}.tags`),
|
|
1029
|
+
retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
|
|
1030
|
+
variables: validateVariables(candidate.variables, `${label}.variables`),
|
|
1031
|
+
...void 0 === candidate.setup ? {} : {
|
|
1032
|
+
setup: validateSetup(candidate.setup, `${label}.setup`)
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
return {
|
|
1037
|
+
projects: Object.freeze(projects),
|
|
1038
|
+
hasExplicitProjects: true
|
|
1039
|
+
};
|
|
1040
|
+
};
|
|
1041
|
+
const validateTestOptions = (value)=>{
|
|
1042
|
+
if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
|
|
1043
|
+
const candidate = value ?? {};
|
|
1044
|
+
const executionUnit = candidate.executionUnit ?? 'project';
|
|
1045
|
+
if ('project' !== executionUnit && 'case' !== executionUnit) throw new TypeError('Midscene config test.executionUnit must be "project" or "case".');
|
|
1046
|
+
return Object.freeze({
|
|
1047
|
+
maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
|
|
1048
|
+
bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
|
|
1049
|
+
testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout'),
|
|
1050
|
+
executionUnit,
|
|
1051
|
+
executorRetry: validateNonNegativeInteger(candidate.executorRetry, 0, 'test.executorRetry')
|
|
1052
|
+
});
|
|
1053
|
+
};
|
|
1054
|
+
const validateTestExecutor = (value)=>{
|
|
1055
|
+
if (void 0 === value) return;
|
|
1056
|
+
if (!isRecord(value)) throw new TypeError('Midscene config executor must be an object.');
|
|
1057
|
+
test_project_rejectUnknownKeys(value, [
|
|
1058
|
+
'name',
|
|
1059
|
+
'execute'
|
|
1060
|
+
], 'executor');
|
|
1061
|
+
if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError('Midscene config executor.name must be non-empty.');
|
|
1062
|
+
if ('function' != typeof value.execute) throw new TypeError('Midscene config executor.execute must be a function.');
|
|
1063
|
+
return Object.freeze(value);
|
|
1064
|
+
};
|
|
1065
|
+
const validateOutputPath = (value, fallback, label)=>{
|
|
1066
|
+
if (void 0 === value) return fallback;
|
|
1067
|
+
if ('string' != typeof value || 0 === value.trim().length) throw new TypeError(`Midscene config ${label} must be non-empty.`);
|
|
1068
|
+
return value;
|
|
1069
|
+
};
|
|
1070
|
+
const validateOutput = (value)=>{
|
|
1071
|
+
if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config output must be an object.');
|
|
1072
|
+
const candidate = value ?? {};
|
|
1073
|
+
test_project_rejectUnknownKeys(candidate, [
|
|
1074
|
+
'reportDir'
|
|
1075
|
+
], 'output');
|
|
1076
|
+
return Object.freeze({
|
|
1077
|
+
reportDir: validateOutputPath(candidate.reportDir, './midscene_run/report', 'output.reportDir')
|
|
1078
|
+
});
|
|
1079
|
+
};
|
|
1080
|
+
const validateTestProjectDefinition = (definition)=>{
|
|
1081
|
+
if (!isRecord(definition)) throw new TypeError('Midscene config must default export an object.');
|
|
1082
|
+
if (void 0 !== definition.nodes && !Array.isArray(definition.nodes)) throw new TypeError('Midscene config nodes must be an array.');
|
|
1083
|
+
if ('setupWorkflow' in definition) throw new TypeError('Midscene config setupWorkflow is no longer supported. Use setup or projects[].setup instead.');
|
|
1084
|
+
if ('setupDocument' in definition) throw new TypeError('Midscene config setupDocument is not supported. Use setup or projects[].setup for resources, and beforeAll/afterAll for document lifecycle steps.');
|
|
1085
|
+
if ('root' in definition) throw new TypeError('Midscene config root is not supported. Pass the Test Project directory to midscene-test instead.');
|
|
1086
|
+
if ('files' in definition) throw new TypeError('Midscene config files is not supported at the root. Move it to projects[].files.');
|
|
1087
|
+
if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
|
|
1088
|
+
test_project_rejectUnknownKeys(definition, [
|
|
1089
|
+
'setup',
|
|
1090
|
+
'projects',
|
|
1091
|
+
'test',
|
|
1092
|
+
'output',
|
|
1093
|
+
'nodes',
|
|
1094
|
+
'executor'
|
|
1095
|
+
], 'root');
|
|
1096
|
+
const nodes = new registry_NodeRegistry(definition.nodes);
|
|
1097
|
+
const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
|
|
1098
|
+
const test = validateTestOptions(definition.test);
|
|
1099
|
+
const executor = validateTestExecutor(definition.executor);
|
|
1100
|
+
if (executor && 'case' !== test.executionUnit) throw new TypeError('Midscene config executor requires test.executionUnit to be "case".');
|
|
1101
|
+
return {
|
|
1102
|
+
...resolvedProjects,
|
|
1103
|
+
test,
|
|
1104
|
+
output: validateOutput(definition.output),
|
|
1105
|
+
nodes,
|
|
1106
|
+
...executor ? {
|
|
1107
|
+
executor
|
|
1108
|
+
} : {},
|
|
1109
|
+
resolveNode: (name)=>nodes.get(name)
|
|
1110
|
+
};
|
|
1111
|
+
};
|
|
1112
|
+
const assertSupportedConfig = (absolutePath)=>{
|
|
1113
|
+
if (!absolutePath.endsWith('.ts') && !absolutePath.endsWith('.mjs')) {
|
|
1114
|
+
const extension = absolutePath.match(/(\.[^./\\]+)$/)?.[1] ?? '(none)';
|
|
1115
|
+
throw new TypeError(`Unsupported Midscene config extension: ${extension}. Supported extensions: .ts, .mjs.`);
|
|
1116
|
+
}
|
|
1117
|
+
return absolutePath.endsWith('.mjs') ? '.mjs' : '.ts';
|
|
1118
|
+
};
|
|
1119
|
+
const canRetryWithCjsLoader = (error)=>{
|
|
1120
|
+
if (!(error instanceof Error)) return false;
|
|
1121
|
+
const errorCode = error.code;
|
|
1122
|
+
return error instanceof SyntaxError && /^Unexpected (?:identifier|reserved word|token)/.test(error.message) || 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' === errorCode || 'ERR_UNKNOWN_FILE_EXTENSION' === errorCode && /^Unknown file extension\b/.test(error.message);
|
|
1123
|
+
};
|
|
1124
|
+
async function loadTestProject(configPath) {
|
|
1125
|
+
if (!configPath) return validateTestProjectDefinition({
|
|
1126
|
+
nodes: []
|
|
1127
|
+
});
|
|
1128
|
+
const absolutePath = external_node_path_resolve(configPath);
|
|
1129
|
+
const extension = assertSupportedConfig(absolutePath);
|
|
1130
|
+
let loaded;
|
|
1131
|
+
try {
|
|
1132
|
+
if ('.mjs' === extension) loaded = await import(pathToFileURL(absolutePath).href);
|
|
1133
|
+
else {
|
|
1134
|
+
const { tsImport } = await import("tsx/esm/api");
|
|
1135
|
+
loaded = await tsImport(pathToFileURL(absolutePath).href, {
|
|
1136
|
+
parentURL: pathToFileURL(`${dirname(absolutePath)}${sep}`).href,
|
|
1137
|
+
tsconfig: false
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
try {
|
|
1142
|
+
if ('.mjs' === extension || !canRetryWithCjsLoader(error)) throw error;
|
|
1143
|
+
loaded = api_require(absolutePath, pathToFileURL(absolutePath));
|
|
1144
|
+
} catch (fallbackError) {
|
|
1145
|
+
const message = fallbackError instanceof Error ? `: ${fallbackError.message}` : '';
|
|
1146
|
+
throw new Error(`Failed to load Midscene config "${absolutePath}"${message}`, {
|
|
1147
|
+
cause: fallbackError
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
|
|
1152
|
+
}
|
|
737
1153
|
const CONFIG_NAMES = [
|
|
738
1154
|
'midscene.config.ts',
|
|
739
1155
|
'midscene.config.mjs'
|
|
@@ -755,8 +1171,29 @@ const DEFAULT_TEST_FILE_SELECTION = {
|
|
|
755
1171
|
]
|
|
756
1172
|
};
|
|
757
1173
|
const project_preparation_toPosix = (value)=>value.split(sep).join('/');
|
|
1174
|
+
const discoverSelectedTestFiles = (projectRoot, paths)=>{
|
|
1175
|
+
const root = external_node_path_resolve(projectRoot);
|
|
1176
|
+
const files = new Set();
|
|
1177
|
+
for (const requestedPath of paths){
|
|
1178
|
+
if ('string' != typeof requestedPath || 0 === requestedPath.trim().length) throw new TypeError('Test selection paths must be non-empty strings.');
|
|
1179
|
+
const absolutePath = external_node_path_resolve(root, requestedPath);
|
|
1180
|
+
const relativePath = relative(root, absolutePath);
|
|
1181
|
+
if ('..' === relativePath || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) throw new Error(`Test selection path is outside the project: ${requestedPath}`);
|
|
1182
|
+
if (!existsSync(absolutePath)) throw new Error(`Test selection path does not exist: ${requestedPath}`);
|
|
1183
|
+
if (statSync(absolutePath).isDirectory()) for (const file of discoverTestFiles(absolutePath))files.add(file);
|
|
1184
|
+
else if (/\.ya?ml$/i.test(absolutePath)) files.add(absolutePath);
|
|
1185
|
+
else throw new Error(`Test selection file must be YAML: ${requestedPath}`);
|
|
1186
|
+
}
|
|
1187
|
+
return [
|
|
1188
|
+
...files
|
|
1189
|
+
].sort((a, b)=>{
|
|
1190
|
+
const relativeA = project_preparation_toPosix(relative(root, a));
|
|
1191
|
+
const relativeB = project_preparation_toPosix(relative(root, b));
|
|
1192
|
+
return relativeA < relativeB ? -1 : relativeA > relativeB ? 1 : 0;
|
|
1193
|
+
});
|
|
1194
|
+
};
|
|
758
1195
|
const discoverResolvedTestFiles = (projectRoot, selection)=>{
|
|
759
|
-
const root =
|
|
1196
|
+
const root = external_node_path_resolve(projectRoot);
|
|
760
1197
|
const match = (patterns)=>globSync(patterns, {
|
|
761
1198
|
absolute: true,
|
|
762
1199
|
caseSensitiveMatch: false,
|
|
@@ -772,7 +1209,7 @@ const discoverResolvedTestFiles = (projectRoot, selection)=>{
|
|
|
772
1209
|
}).filter((file)=>/\.ya?ml$/i.test(file));
|
|
773
1210
|
const files = match(selection.include);
|
|
774
1211
|
return [
|
|
775
|
-
...new Set(files.map((file)=>
|
|
1212
|
+
...new Set(files.map((file)=>external_node_path_resolve(file)))
|
|
776
1213
|
].sort((a, b)=>{
|
|
777
1214
|
const relativeA = project_preparation_toPosix(relative(root, a));
|
|
778
1215
|
const relativeB = project_preparation_toPosix(relative(root, b));
|
|
@@ -785,7 +1222,7 @@ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)
|
|
|
785
1222
|
return discoverResolvedTestFiles(projectRoot, normalized);
|
|
786
1223
|
};
|
|
787
1224
|
const discoverTestConfig = (projectRoot)=>{
|
|
788
|
-
const root =
|
|
1225
|
+
const root = external_node_path_resolve(projectRoot);
|
|
789
1226
|
const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
|
|
790
1227
|
const supported = candidates.filter((name)=>CONFIG_NAMES.includes(name));
|
|
791
1228
|
const unsupported = candidates.filter((name)=>!CONFIG_NAMES.includes(name));
|
|
@@ -814,8 +1251,14 @@ const matchesTags = (tags, selection)=>{
|
|
|
814
1251
|
if (selection.exclude.some((tag)=>tags.includes(tag))) return false;
|
|
815
1252
|
return 0 === selection.include.length || selection.include.some((tag)=>tags.includes(tag));
|
|
816
1253
|
};
|
|
817
|
-
const filterDocumentCases = (document, project)=>{
|
|
818
|
-
const cases = document.cases.filter((item)=>
|
|
1254
|
+
const filterDocumentCases = (document, project, selection)=>{
|
|
1255
|
+
const cases = document.cases.filter((item)=>{
|
|
1256
|
+
const tags = item.definition.tags ?? [];
|
|
1257
|
+
return matchesTags(tags, project.tags) && matchesTags(tags, {
|
|
1258
|
+
include: selection?.tags?.include ?? [],
|
|
1259
|
+
exclude: selection?.tags?.exclude ?? []
|
|
1260
|
+
}) && (!selection?.caseIds?.length || selection.caseIds.includes(item.caseId));
|
|
1261
|
+
});
|
|
819
1262
|
const filtered = document.cases.length - cases.length;
|
|
820
1263
|
return 0 === cases.length ? {
|
|
821
1264
|
filtered
|
|
@@ -839,8 +1282,10 @@ const selectProjects = (projects, names)=>{
|
|
|
839
1282
|
async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
|
|
840
1283
|
const platform = options.platform?.trim();
|
|
841
1284
|
const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
|
|
842
|
-
const prerequisiteFile = options.prerequisiteFile ?
|
|
843
|
-
const
|
|
1285
|
+
const prerequisiteFile = options.prerequisiteFile ? external_node_path_resolve(options.prerequisiteFile) : void 0;
|
|
1286
|
+
const projectFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
|
|
1287
|
+
const selectedFiles = options.selection?.paths?.length ? new Set(discoverSelectedTestFiles(projectRoot, options.selection.paths)) : void 0;
|
|
1288
|
+
const mainFiles = selectedFiles ? projectFiles.filter((file)=>selectedFiles.has(file)) : projectFiles;
|
|
844
1289
|
const files = [
|
|
845
1290
|
...prerequisiteFile ? [
|
|
846
1291
|
prerequisiteFile
|
|
@@ -863,6 +1308,7 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
863
1308
|
const invocations = [];
|
|
864
1309
|
const plans = [];
|
|
865
1310
|
let filteredCaseCount = 0;
|
|
1311
|
+
const availableCaseIds = new Set();
|
|
866
1312
|
if (0 === sources.length) {
|
|
867
1313
|
const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
|
|
868
1314
|
projectName: project.name,
|
|
@@ -893,10 +1339,16 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
893
1339
|
variables: project.variables,
|
|
894
1340
|
env: process.env
|
|
895
1341
|
});
|
|
1342
|
+
const duplicate = document.cases.find((item)=>availableCaseIds.has(item.caseId));
|
|
1343
|
+
if (duplicate) throw new WorkflowParseError(`Case id collision in project "${project.name}": ${duplicate.caseId}.`, {
|
|
1344
|
+
caseId: duplicate.caseId,
|
|
1345
|
+
projectName: project.name
|
|
1346
|
+
});
|
|
1347
|
+
for (const item of document.cases)availableCaseIds.add(item.caseId);
|
|
896
1348
|
const filtered = source.absolutePath === prerequisiteFile ? {
|
|
897
1349
|
document,
|
|
898
1350
|
filtered: 0
|
|
899
|
-
} : filterDocumentCases(document, project);
|
|
1351
|
+
} : filterDocumentCases(document, project, options.selection);
|
|
900
1352
|
const plan = buildTestRunReportPlan(document, {
|
|
901
1353
|
selectedCaseIds: filtered.document?.cases.map((item)=>item.caseId) ?? [],
|
|
902
1354
|
defaultTimeoutMs,
|
|
@@ -944,9 +1396,42 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
944
1396
|
plans,
|
|
945
1397
|
collectionErrors,
|
|
946
1398
|
selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
|
|
947
|
-
filteredCaseCount
|
|
1399
|
+
filteredCaseCount,
|
|
1400
|
+
availableCaseIds: [
|
|
1401
|
+
...availableCaseIds
|
|
1402
|
+
]
|
|
948
1403
|
};
|
|
949
1404
|
}
|
|
1405
|
+
const validateUniqueStrings = (values, label)=>{
|
|
1406
|
+
if (void 0 === values) return;
|
|
1407
|
+
if (!Array.isArray(values) || values.some((value)=>'string' != typeof value || 0 === value.trim().length)) throw new TypeError(`${label} must be an array of non-empty strings.`);
|
|
1408
|
+
if (new Set(values).size !== values.length) throw new TypeError(`${label} must not contain duplicates.`);
|
|
1409
|
+
return values;
|
|
1410
|
+
};
|
|
1411
|
+
const validateRunSelection = (options)=>{
|
|
1412
|
+
const paths = validateUniqueStrings(options.paths, 'Test selection paths');
|
|
1413
|
+
const caseIds = validateUniqueStrings(options.caseIds, 'Test selection caseIds');
|
|
1414
|
+
const include = validateUniqueStrings(options.tags?.include, 'Test selection tags.include');
|
|
1415
|
+
const exclude = validateUniqueStrings(options.tags?.exclude, 'Test selection tags.exclude');
|
|
1416
|
+
return {
|
|
1417
|
+
...paths ? {
|
|
1418
|
+
paths
|
|
1419
|
+
} : {},
|
|
1420
|
+
...caseIds ? {
|
|
1421
|
+
caseIds
|
|
1422
|
+
} : {},
|
|
1423
|
+
...include || exclude ? {
|
|
1424
|
+
tags: {
|
|
1425
|
+
...include ? {
|
|
1426
|
+
include
|
|
1427
|
+
} : {},
|
|
1428
|
+
...exclude ? {
|
|
1429
|
+
exclude
|
|
1430
|
+
} : {}
|
|
1431
|
+
}
|
|
1432
|
+
} : {}
|
|
1433
|
+
};
|
|
1434
|
+
};
|
|
950
1435
|
const padDatePart = (value)=>String(value).padStart(2, '0');
|
|
951
1436
|
const createTestRunId = (date = new Date(), uuid = randomUUID())=>{
|
|
952
1437
|
const timestamp = [
|
|
@@ -963,9 +1448,9 @@ const assertDirectory = (path, label)=>{
|
|
|
963
1448
|
if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
|
|
964
1449
|
};
|
|
965
1450
|
const resolveRunInput = (options)=>{
|
|
966
|
-
const cwd =
|
|
1451
|
+
const cwd = external_node_path_resolve(options.cwd ?? process.cwd());
|
|
967
1452
|
assertDirectory(cwd, 'Test working directory');
|
|
968
|
-
const inputPath = options.projectRoot ?
|
|
1453
|
+
const inputPath = options.projectRoot ? external_node_path_resolve(cwd, options.projectRoot) : void 0;
|
|
969
1454
|
const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
|
|
970
1455
|
if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
|
|
971
1456
|
const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
|
|
@@ -984,24 +1469,34 @@ async function prepareTestRun(options) {
|
|
|
984
1469
|
const startedAt = new Date();
|
|
985
1470
|
const runId = createTestRunId(startedAt);
|
|
986
1471
|
const input = resolveRunInput(options);
|
|
987
|
-
const
|
|
1472
|
+
const selection = validateRunSelection(options);
|
|
1473
|
+
const configPath = options.configPath ? external_node_path_resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
|
|
988
1474
|
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}\`.`);
|
|
989
1475
|
if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
|
|
990
1476
|
const definition = await loadTestProject(configPath);
|
|
991
|
-
const resultDir = options.resultDir ?
|
|
1477
|
+
const resultDir = options.resultDir ? external_node_path_resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
|
|
992
1478
|
const runDir = join(resultDir, runId);
|
|
993
1479
|
const summaryPath = join(runDir, 'summary.json');
|
|
994
|
-
const reportDir =
|
|
1480
|
+
const reportDir = external_node_path_resolve(input.projectRoot, definition.output.reportDir);
|
|
995
1481
|
mkdirSync(resultDir, {
|
|
996
1482
|
recursive: true
|
|
997
1483
|
});
|
|
998
1484
|
mkdirSync(runDir);
|
|
999
1485
|
const projects = [];
|
|
1000
|
-
for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout,
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1486
|
+
for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, {
|
|
1487
|
+
selection,
|
|
1488
|
+
...input.singleFile ? {
|
|
1489
|
+
files: [
|
|
1490
|
+
input.singleFile
|
|
1491
|
+
]
|
|
1492
|
+
} : {}
|
|
1493
|
+
}));
|
|
1494
|
+
if (selection.caseIds?.length) {
|
|
1495
|
+
const availableCaseIds = new Set(projects.flatMap((project)=>project.availableCaseIds ?? []));
|
|
1496
|
+
const unknown = selection.caseIds.find((id)=>!availableCaseIds.has(id));
|
|
1497
|
+
if (unknown) throw new Error(`Unknown Midscene case id: ${unknown}`);
|
|
1498
|
+
}
|
|
1499
|
+
if ((selection.paths?.length || selection.caseIds?.length || selection.tags?.include?.length || selection.tags?.exclude?.length) && projects.every((project)=>0 === project.selectedCaseCount && 0 === project.collectionErrors.length)) throw new Error('No Midscene cases matched the requested selection.');
|
|
1005
1500
|
const hasFormatMismatch = projects.some((project)=>project.collectionErrors.some(({ error })=>error instanceof NativeWorkflowFormatError));
|
|
1006
1501
|
return {
|
|
1007
1502
|
startedAt,
|
|
@@ -1022,20 +1517,612 @@ async function prepareTestRun(options) {
|
|
|
1022
1517
|
onProgress: options.onProgress ?? (()=>{})
|
|
1023
1518
|
};
|
|
1024
1519
|
}
|
|
1025
|
-
const
|
|
1520
|
+
const asNotRun = (documentId, collectedCase, projectName, reason)=>({
|
|
1521
|
+
documentId,
|
|
1522
|
+
caseId: collectedCase.caseId,
|
|
1523
|
+
projectName,
|
|
1524
|
+
name: collectedCase.definition.name,
|
|
1525
|
+
sourcePath: collectedCase.sourcePath,
|
|
1526
|
+
caseIndex: collectedCase.caseIndex,
|
|
1527
|
+
status: 'not-run',
|
|
1528
|
+
notRunReason: reason
|
|
1529
|
+
});
|
|
1026
1530
|
const formatStep = (info)=>{
|
|
1027
|
-
const position =
|
|
1531
|
+
const position = 'case' === info.scope ? info.case : info.document;
|
|
1028
1532
|
const phase = 'steps' === position.phase ? 'step' : position.phase;
|
|
1029
1533
|
return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
|
|
1030
1534
|
};
|
|
1031
|
-
const formatStepResult = (info, result)=>{
|
|
1032
|
-
const indent = 'case' === info.scope ? ' ' : ' ';
|
|
1535
|
+
const formatStepResult = (info, result, indent = '')=>{
|
|
1033
1536
|
if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
|
|
1034
1537
|
const symbol = 'success' === result.status ? '✓' : '✗';
|
|
1035
1538
|
const error = result.error ? ` — ${result.error.message}` : '';
|
|
1036
1539
|
const continuation = result.continuedAfterError ? '; continuing' : '';
|
|
1037
1540
|
return `${indent}${symbol} ${formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
|
|
1038
1541
|
};
|
|
1542
|
+
const latestById = (items, id)=>[
|
|
1543
|
+
...new Map(items.map((item)=>[
|
|
1544
|
+
id(item),
|
|
1545
|
+
item
|
|
1546
|
+
])).values()
|
|
1547
|
+
];
|
|
1548
|
+
const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
|
|
1549
|
+
const { project } = prepared;
|
|
1550
|
+
const projectFailed = prepared.collectionErrors.length > 0 || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status || item.execution?.lifecycle?.status === 'failed') || latestById(documents, (item)=>item.documentId).some((item)=>'failed' === item.status) || lifecycle?.status === 'failed';
|
|
1551
|
+
return {
|
|
1552
|
+
projectId: project.projectId,
|
|
1553
|
+
name: project.name,
|
|
1554
|
+
...prepared.platform ? {
|
|
1555
|
+
platform: prepared.platform
|
|
1556
|
+
} : {},
|
|
1557
|
+
status: projectFailed ? 'failed' : 'success',
|
|
1558
|
+
retry: project.retry,
|
|
1559
|
+
fileSelection: prepared.fileSelection,
|
|
1560
|
+
tagSelection: project.tags,
|
|
1561
|
+
sourceCount: prepared.sources.length,
|
|
1562
|
+
selectedCaseCount: prepared.selectedCaseCount,
|
|
1563
|
+
filteredCaseCount: prepared.filteredCaseCount,
|
|
1564
|
+
...lifecycle ? {
|
|
1565
|
+
lifecycle
|
|
1566
|
+
} : {},
|
|
1567
|
+
cases,
|
|
1568
|
+
...prepared.plans ? {
|
|
1569
|
+
plans: prepared.plans
|
|
1570
|
+
} : {},
|
|
1571
|
+
documents,
|
|
1572
|
+
collectionErrors: prepared.collectionErrors
|
|
1573
|
+
};
|
|
1574
|
+
};
|
|
1575
|
+
function createExecutorReports(outputDir) {
|
|
1576
|
+
const paths = new Map();
|
|
1577
|
+
const sources = new Map();
|
|
1578
|
+
return {
|
|
1579
|
+
materializeSource (source) {
|
|
1580
|
+
if (!source.dumpPath) {
|
|
1581
|
+
const reference = this.materialize(source.sourcePath);
|
|
1582
|
+
sources.set(reference, {
|
|
1583
|
+
sourcePath: this.resolve(reference),
|
|
1584
|
+
collection: source.collection
|
|
1585
|
+
});
|
|
1586
|
+
return reference;
|
|
1587
|
+
}
|
|
1588
|
+
const dump = realpathSync(source.dumpPath);
|
|
1589
|
+
const reference = randomUUID();
|
|
1590
|
+
const destination = join(outputDir, 'reports', reference);
|
|
1591
|
+
const sourcePath = join(destination, 'index.html');
|
|
1592
|
+
mkdirSync(destination, {
|
|
1593
|
+
recursive: true
|
|
1594
|
+
});
|
|
1595
|
+
const dumpPath = join(destination, 'snapshot.json');
|
|
1596
|
+
cpSync(dump, dumpPath);
|
|
1597
|
+
const screenshots = join(dirname(dump), 'screenshots');
|
|
1598
|
+
if (existsSync(screenshots)) cpSync(screenshots, join(destination, 'screenshots'), {
|
|
1599
|
+
recursive: true,
|
|
1600
|
+
dereference: true
|
|
1601
|
+
});
|
|
1602
|
+
sources.set(reference, {
|
|
1603
|
+
sourcePath,
|
|
1604
|
+
dumpPath,
|
|
1605
|
+
collection: source.collection
|
|
1606
|
+
});
|
|
1607
|
+
paths.set(reference, sourcePath);
|
|
1608
|
+
return reference;
|
|
1609
|
+
},
|
|
1610
|
+
resolveSource (reference) {
|
|
1611
|
+
return sources.get(reference);
|
|
1612
|
+
},
|
|
1613
|
+
materialize (sourcePath) {
|
|
1614
|
+
const source = realpathSync(sourcePath);
|
|
1615
|
+
const entry = statSync(source).isDirectory() ? join(source, 'index.html') : source;
|
|
1616
|
+
if (!statSync(entry).isFile()) throw new TestExecutorError(`Executor report must have a regular HTML entry: ${sourcePath}`, {
|
|
1617
|
+
kind: 'report'
|
|
1618
|
+
});
|
|
1619
|
+
const reference = randomUUID();
|
|
1620
|
+
const destination = join(outputDir, 'reports', reference);
|
|
1621
|
+
mkdirSync(destination, {
|
|
1622
|
+
recursive: true
|
|
1623
|
+
});
|
|
1624
|
+
const isDirectoryReport = 'index.html' === basename(entry);
|
|
1625
|
+
const reportPath = join(destination, isDirectoryReport ? 'index.html' : 'report.html');
|
|
1626
|
+
cpSync(isDirectoryReport ? dirname(entry) : entry, isDirectoryReport ? destination : reportPath, {
|
|
1627
|
+
recursive: isDirectoryReport,
|
|
1628
|
+
dereference: true
|
|
1629
|
+
});
|
|
1630
|
+
paths.set(reference, reportPath);
|
|
1631
|
+
return reference;
|
|
1632
|
+
},
|
|
1633
|
+
resolve (reference) {
|
|
1634
|
+
const reportPath = paths.get(reference);
|
|
1635
|
+
if (!reportPath) throw new TestExecutorError(`Executor returned unknown report reference ${reference}.`, {
|
|
1636
|
+
kind: 'report'
|
|
1637
|
+
});
|
|
1638
|
+
return reportPath;
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
async function runTaskPool(options) {
|
|
1643
|
+
if (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency <= 0) throw new TypeError('Task scheduler maxConcurrency must be a positive integer.');
|
|
1644
|
+
const pending = options.tasks.map((_, index)=>index);
|
|
1645
|
+
const results = new Array(options.tasks.length);
|
|
1646
|
+
const activeResources = new Set();
|
|
1647
|
+
let active = 0;
|
|
1648
|
+
let firstError;
|
|
1649
|
+
let hasError = false;
|
|
1650
|
+
return await new Promise((resolve, reject)=>{
|
|
1651
|
+
let settled = false;
|
|
1652
|
+
const stopRequested = ()=>hasError || options.signal?.aborted === true || options.shouldStop?.() === true;
|
|
1653
|
+
const finish = ()=>{
|
|
1654
|
+
if (settled) return;
|
|
1655
|
+
settled = true;
|
|
1656
|
+
options.signal?.removeEventListener('abort', pump);
|
|
1657
|
+
if (hasError) reject(firstError);
|
|
1658
|
+
else resolve(results);
|
|
1659
|
+
};
|
|
1660
|
+
const release = (resources)=>{
|
|
1661
|
+
for (const resource of resources)activeResources.delete(resource);
|
|
1662
|
+
};
|
|
1663
|
+
const claimRunnable = ()=>{
|
|
1664
|
+
const pendingIndex = pending.findIndex((taskIndex)=>options.tasks[taskIndex].resources.every((resource)=>!activeResources.has(resource)));
|
|
1665
|
+
if (pendingIndex < 0) return;
|
|
1666
|
+
const [taskIndex] = pending.splice(pendingIndex, 1);
|
|
1667
|
+
return taskIndex;
|
|
1668
|
+
};
|
|
1669
|
+
function pump() {
|
|
1670
|
+
if (settled) return;
|
|
1671
|
+
while(active < options.maxConcurrency && !stopRequested()){
|
|
1672
|
+
const taskIndex = claimRunnable();
|
|
1673
|
+
if (void 0 === taskIndex) break;
|
|
1674
|
+
const task = options.tasks[taskIndex];
|
|
1675
|
+
for (const resource of task.resources)activeResources.add(resource);
|
|
1676
|
+
active += 1;
|
|
1677
|
+
Promise.resolve().then(()=>options.run(task, taskIndex)).then((result)=>{
|
|
1678
|
+
results[taskIndex] = result;
|
|
1679
|
+
}).catch((error)=>{
|
|
1680
|
+
if (!hasError) {
|
|
1681
|
+
hasError = true;
|
|
1682
|
+
firstError = error;
|
|
1683
|
+
}
|
|
1684
|
+
}).finally(()=>{
|
|
1685
|
+
release(task.resources);
|
|
1686
|
+
active -= 1;
|
|
1687
|
+
pump();
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
if (0 === active && (0 === pending.length || stopRequested())) finish();
|
|
1691
|
+
}
|
|
1692
|
+
options.signal?.addEventListener('abort', pump, {
|
|
1693
|
+
once: true
|
|
1694
|
+
});
|
|
1695
|
+
pump();
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
const isolatedDocumentId = (document, collectedCase)=>`${document.documentId}-case-${collectedCase.caseIndex}`;
|
|
1699
|
+
const prepareCaseTasks = (projects)=>projects.flatMap((prepared, projectIndex)=>prepared.documents.flatMap((document)=>document.cases.map((collectedCase)=>{
|
|
1700
|
+
const documentId = isolatedDocumentId(document, collectedCase);
|
|
1701
|
+
return {
|
|
1702
|
+
projectIndex,
|
|
1703
|
+
prepared,
|
|
1704
|
+
document,
|
|
1705
|
+
collectedCase,
|
|
1706
|
+
task: Object.freeze({
|
|
1707
|
+
taskId: `${prepared.project.projectId}:${collectedCase.caseId}`,
|
|
1708
|
+
projectId: prepared.project.projectId,
|
|
1709
|
+
projectName: prepared.project.name,
|
|
1710
|
+
documentId,
|
|
1711
|
+
sourcePath: collectedCase.sourcePath,
|
|
1712
|
+
caseId: collectedCase.caseId,
|
|
1713
|
+
caseName: collectedCase.definition.name,
|
|
1714
|
+
caseIndex: collectedCase.caseIndex,
|
|
1715
|
+
tags: Object.freeze([
|
|
1716
|
+
...collectedCase.definition.tags ?? []
|
|
1717
|
+
]),
|
|
1718
|
+
resources: Object.freeze([
|
|
1719
|
+
...collectedCase.definition.resources ?? []
|
|
1720
|
+
]),
|
|
1721
|
+
retry: prepared.project.retry
|
|
1722
|
+
})
|
|
1723
|
+
};
|
|
1724
|
+
})));
|
|
1725
|
+
const reviveError = (error)=>{
|
|
1726
|
+
const revived = new WorkflowError(error.message, {
|
|
1727
|
+
code: error.code,
|
|
1728
|
+
details: error.details
|
|
1729
|
+
});
|
|
1730
|
+
revived.name = error.name;
|
|
1731
|
+
return revived;
|
|
1732
|
+
};
|
|
1733
|
+
const reviveStep = (step)=>{
|
|
1734
|
+
const { error, output, report, ...base } = step;
|
|
1735
|
+
return {
|
|
1736
|
+
...base,
|
|
1737
|
+
input: step.input,
|
|
1738
|
+
meta: {
|
|
1739
|
+
...step.meta
|
|
1740
|
+
},
|
|
1741
|
+
...output ? {
|
|
1742
|
+
output: {
|
|
1743
|
+
...output
|
|
1744
|
+
}
|
|
1745
|
+
} : {},
|
|
1746
|
+
...error ? {
|
|
1747
|
+
error: reviveError(error)
|
|
1748
|
+
} : {},
|
|
1749
|
+
...report ? {
|
|
1750
|
+
report: {
|
|
1751
|
+
traces: report.traces.map((trace)=>({
|
|
1752
|
+
...trace
|
|
1753
|
+
}))
|
|
1754
|
+
}
|
|
1755
|
+
} : {}
|
|
1756
|
+
};
|
|
1757
|
+
};
|
|
1758
|
+
const reviveAttempt = (attempt)=>{
|
|
1759
|
+
const { executionErrors, teardownErrors, reportRefs, beforeEach, steps, afterEach, ...base } = attempt;
|
|
1760
|
+
return {
|
|
1761
|
+
...base,
|
|
1762
|
+
beforeEach: beforeEach.map(reviveStep),
|
|
1763
|
+
steps: steps.map(reviveStep),
|
|
1764
|
+
afterEach: afterEach.map(reviveStep),
|
|
1765
|
+
...executionErrors ? {
|
|
1766
|
+
executionErrors: executionErrors.map(reviveError)
|
|
1767
|
+
} : {},
|
|
1768
|
+
...teardownErrors ? {
|
|
1769
|
+
teardownErrors: teardownErrors.map(reviveError)
|
|
1770
|
+
} : {}
|
|
1771
|
+
};
|
|
1772
|
+
};
|
|
1773
|
+
const reviveDocument = (document)=>{
|
|
1774
|
+
const { executionErrors, teardownErrors, reportRefs, beforeAll, afterAll, ...base } = document;
|
|
1775
|
+
return {
|
|
1776
|
+
...base,
|
|
1777
|
+
beforeAll: beforeAll.map(reviveStep),
|
|
1778
|
+
afterAll: afterAll.map(reviveStep),
|
|
1779
|
+
...executionErrors ? {
|
|
1780
|
+
executionErrors: executionErrors.map(reviveError)
|
|
1781
|
+
} : {},
|
|
1782
|
+
...teardownErrors ? {
|
|
1783
|
+
teardownErrors: teardownErrors.map(reviveError)
|
|
1784
|
+
} : {}
|
|
1785
|
+
};
|
|
1786
|
+
};
|
|
1787
|
+
const reviveLifecycle = (lifecycle)=>{
|
|
1788
|
+
const { setupError, teardownErrors, ...base } = lifecycle;
|
|
1789
|
+
return {
|
|
1790
|
+
...base,
|
|
1791
|
+
...setupError ? {
|
|
1792
|
+
setupError: reviveError(setupError)
|
|
1793
|
+
} : {},
|
|
1794
|
+
...teardownErrors ? {
|
|
1795
|
+
teardownErrors: teardownErrors.map(reviveError)
|
|
1796
|
+
} : {}
|
|
1797
|
+
};
|
|
1798
|
+
};
|
|
1799
|
+
const mapError = (error)=>({
|
|
1800
|
+
name: error.name,
|
|
1801
|
+
message: error.message,
|
|
1802
|
+
code: error.code,
|
|
1803
|
+
...void 0 === error.details ? {} : {
|
|
1804
|
+
details: error.details
|
|
1805
|
+
}
|
|
1806
|
+
});
|
|
1807
|
+
const mapStep = (step)=>({
|
|
1808
|
+
...step,
|
|
1809
|
+
...step.error ? {
|
|
1810
|
+
error: mapError(step.error)
|
|
1811
|
+
} : {},
|
|
1812
|
+
...step.report ? {
|
|
1813
|
+
report: {
|
|
1814
|
+
traces: step.report.traces
|
|
1815
|
+
}
|
|
1816
|
+
} : {}
|
|
1817
|
+
});
|
|
1818
|
+
const toTransportResult = (result, materializeReport, materializeSource)=>{
|
|
1819
|
+
const transportReports = (scope)=>[
|
|
1820
|
+
...(scope.reportSources ?? []).map(materializeSource),
|
|
1821
|
+
...(scope.reportPaths ?? []).filter((path)=>!scope.reportSources?.some((source)=>source.sourcePath === path)).map(materializeReport)
|
|
1822
|
+
];
|
|
1823
|
+
const mapAttempt = (attempt)=>{
|
|
1824
|
+
const { reportPaths, reportSources, reportScopeId, ...transportAttempt } = attempt;
|
|
1825
|
+
const reportRefs = transportReports(attempt);
|
|
1826
|
+
return {
|
|
1827
|
+
...transportAttempt,
|
|
1828
|
+
beforeEach: attempt.beforeEach.map(mapStep),
|
|
1829
|
+
steps: attempt.steps.map(mapStep),
|
|
1830
|
+
afterEach: attempt.afterEach.map(mapStep),
|
|
1831
|
+
...attempt.executionErrors ? {
|
|
1832
|
+
executionErrors: attempt.executionErrors.map(mapError)
|
|
1833
|
+
} : {},
|
|
1834
|
+
...attempt.teardownErrors ? {
|
|
1835
|
+
teardownErrors: attempt.teardownErrors.map(mapError)
|
|
1836
|
+
} : {},
|
|
1837
|
+
...reportRefs.length ? {
|
|
1838
|
+
reportRefs
|
|
1839
|
+
} : {}
|
|
1840
|
+
};
|
|
1841
|
+
};
|
|
1842
|
+
const attempts = result.case.attempts?.map(mapAttempt);
|
|
1843
|
+
const run = attempts?.at(-1);
|
|
1844
|
+
const { run: _run, attempts: _attempts, ...transportCase } = result.case;
|
|
1845
|
+
const document = result.document ? (()=>{
|
|
1846
|
+
const { reportPaths, reportSources, reportScopeId, plan: _plan, ...transportDocument } = result.document;
|
|
1847
|
+
const reportRefs = transportReports(result.document);
|
|
1848
|
+
return {
|
|
1849
|
+
...transportDocument,
|
|
1850
|
+
beforeAll: result.document.beforeAll.map(mapStep),
|
|
1851
|
+
afterAll: result.document.afterAll.map(mapStep),
|
|
1852
|
+
...result.document.executionErrors ? {
|
|
1853
|
+
executionErrors: result.document.executionErrors.map(mapError)
|
|
1854
|
+
} : {},
|
|
1855
|
+
...result.document.teardownErrors ? {
|
|
1856
|
+
teardownErrors: result.document.teardownErrors.map(mapError)
|
|
1857
|
+
} : {},
|
|
1858
|
+
...reportRefs.length ? {
|
|
1859
|
+
reportRefs
|
|
1860
|
+
} : {}
|
|
1861
|
+
};
|
|
1862
|
+
})() : void 0;
|
|
1863
|
+
return JSON.parse(JSON.stringify({
|
|
1864
|
+
case: {
|
|
1865
|
+
...transportCase,
|
|
1866
|
+
...run ? {
|
|
1867
|
+
run
|
|
1868
|
+
} : {},
|
|
1869
|
+
...attempts ? {
|
|
1870
|
+
attempts
|
|
1871
|
+
} : {}
|
|
1872
|
+
},
|
|
1873
|
+
...document ? {
|
|
1874
|
+
document
|
|
1875
|
+
} : {},
|
|
1876
|
+
...result.lifecycle ? {
|
|
1877
|
+
lifecycle: {
|
|
1878
|
+
...result.lifecycle,
|
|
1879
|
+
...result.lifecycle.setupError ? {
|
|
1880
|
+
setupError: mapError(result.lifecycle.setupError)
|
|
1881
|
+
} : {},
|
|
1882
|
+
...result.lifecycle.teardownErrors ? {
|
|
1883
|
+
teardownErrors: result.lifecycle.teardownErrors.map(mapError)
|
|
1884
|
+
} : {}
|
|
1885
|
+
}
|
|
1886
|
+
} : {}
|
|
1887
|
+
}));
|
|
1888
|
+
};
|
|
1889
|
+
const fromTransportResult = (result, resolveReportReference, resolveSource)=>{
|
|
1890
|
+
const resolveReports = (refs)=>{
|
|
1891
|
+
const reportPaths = [];
|
|
1892
|
+
const reportSources = [];
|
|
1893
|
+
for (const ref of refs ?? []){
|
|
1894
|
+
const path = resolveReportReference(ref);
|
|
1895
|
+
const source = resolveSource(ref);
|
|
1896
|
+
if (source) reportSources.push(source);
|
|
1897
|
+
else reportPaths.push(path);
|
|
1898
|
+
}
|
|
1899
|
+
return {
|
|
1900
|
+
...reportPaths.length ? {
|
|
1901
|
+
reportPaths
|
|
1902
|
+
} : {},
|
|
1903
|
+
...reportSources.length ? {
|
|
1904
|
+
reportSources
|
|
1905
|
+
} : {}
|
|
1906
|
+
};
|
|
1907
|
+
};
|
|
1908
|
+
const attempts = result.case.attempts?.map((attempt)=>({
|
|
1909
|
+
...reviveAttempt(attempt),
|
|
1910
|
+
...resolveReports(attempt.reportRefs)
|
|
1911
|
+
}));
|
|
1912
|
+
const run = attempts?.at(-1);
|
|
1913
|
+
const { run: _run, attempts: _attempts, ...caseBase } = result.case;
|
|
1914
|
+
return {
|
|
1915
|
+
case: {
|
|
1916
|
+
...caseBase,
|
|
1917
|
+
...run ? {
|
|
1918
|
+
run
|
|
1919
|
+
} : {},
|
|
1920
|
+
...attempts ? {
|
|
1921
|
+
attempts
|
|
1922
|
+
} : {}
|
|
1923
|
+
},
|
|
1924
|
+
...result.document ? {
|
|
1925
|
+
document: {
|
|
1926
|
+
...reviveDocument(result.document),
|
|
1927
|
+
...resolveReports(result.document.reportRefs)
|
|
1928
|
+
}
|
|
1929
|
+
} : {},
|
|
1930
|
+
...result.lifecycle ? {
|
|
1931
|
+
lifecycle: reviveLifecycle(result.lifecycle)
|
|
1932
|
+
} : {}
|
|
1933
|
+
};
|
|
1934
|
+
};
|
|
1935
|
+
const runLocalCase = async (preparedTask, options, taskProgress, materializeReport, materializeSource)=>{
|
|
1936
|
+
const { prepared, document, collectedCase, task } = preparedTask;
|
|
1937
|
+
const { project } = prepared;
|
|
1938
|
+
const isolatedDocument = {
|
|
1939
|
+
...document,
|
|
1940
|
+
documentId: task.documentId,
|
|
1941
|
+
cases: [
|
|
1942
|
+
collectedCase
|
|
1943
|
+
]
|
|
1944
|
+
};
|
|
1945
|
+
const runtime = createProjectRuntime({
|
|
1946
|
+
project,
|
|
1947
|
+
setup: project.setup,
|
|
1948
|
+
signal: options.signal
|
|
1949
|
+
});
|
|
1950
|
+
let lifecycle;
|
|
1951
|
+
let execution;
|
|
1952
|
+
let executionError;
|
|
1953
|
+
try {
|
|
1954
|
+
await runtime.start();
|
|
1955
|
+
if (runtime.canRun) execution = await runWorkflowDocument(isolatedDocument, {
|
|
1956
|
+
resolveNode: project.nodes.require.bind(project.nodes),
|
|
1957
|
+
project,
|
|
1958
|
+
projectContext: runtime.context,
|
|
1959
|
+
retry: project.retry,
|
|
1960
|
+
signal: runtime.signal,
|
|
1961
|
+
defaultTimeoutMs: options.test.testTimeout,
|
|
1962
|
+
shouldStop: ()=>options.signal.aborted,
|
|
1963
|
+
stopReason: ()=>'interrupted',
|
|
1964
|
+
isFatalError: (run)=>[
|
|
1965
|
+
...run.beforeEach,
|
|
1966
|
+
...run.steps,
|
|
1967
|
+
...run.afterEach
|
|
1968
|
+
].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
|
|
1969
|
+
onStepStart: (info)=>taskProgress(`→ ${formatStep(info)}`),
|
|
1970
|
+
onStepResult: (info, result)=>taskProgress(formatStepResult(info, result)),
|
|
1971
|
+
onCaseResult: (attempt)=>taskProgress(`${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${project.retry + 1}: ${attempt.name} (${attempt.durationMs} ms)`)
|
|
1972
|
+
});
|
|
1973
|
+
} catch (error) {
|
|
1974
|
+
executionError = error;
|
|
1975
|
+
if (error instanceof WorkflowExecutionFailure) execution = error.result;
|
|
1976
|
+
} finally{
|
|
1977
|
+
const failed = void 0 !== executionError || !runtime.canRun || execution?.cases[0]?.status !== 'success' || execution?.document.status === 'failed';
|
|
1978
|
+
lifecycle = await runtime.finish(failed ? 'failed' : 'success');
|
|
1979
|
+
}
|
|
1980
|
+
if (executionError) {
|
|
1981
|
+
if (!execution) throw executionError;
|
|
1982
|
+
options.onInfrastructureError(executionError);
|
|
1983
|
+
}
|
|
1984
|
+
if (!execution) return toTransportResult({
|
|
1985
|
+
case: asNotRun(task.documentId, collectedCase, project.name, options.signal.aborted ? 'interrupted' : 'project-setup-failed'),
|
|
1986
|
+
lifecycle
|
|
1987
|
+
}, materializeReport, materializeSource);
|
|
1988
|
+
const outcome = execution.cases[0];
|
|
1989
|
+
if (!outcome) throw new Error(`Executor did not produce case result for ${task.caseId}.`);
|
|
1990
|
+
return toTransportResult({
|
|
1991
|
+
case: {
|
|
1992
|
+
...outcome,
|
|
1993
|
+
documentId: task.documentId
|
|
1994
|
+
},
|
|
1995
|
+
document: execution.document,
|
|
1996
|
+
lifecycle
|
|
1997
|
+
}, materializeReport, materializeSource);
|
|
1998
|
+
};
|
|
1999
|
+
const runCaseTask = async (preparedTask, taskIndex, taskCount, options, onCompleted)=>{
|
|
2000
|
+
const { prepared, task } = preparedTask;
|
|
2001
|
+
const taskProgress = (message)=>options.progress(`[case ${taskIndex + 1}/${taskCount}] ${task.projectName} / ${task.sourcePath} / ${task.caseName}: ${message}`);
|
|
2002
|
+
taskProgress('started');
|
|
2003
|
+
const outputDir = join(options.runDir, task.projectId, 'executor-output', task.documentId, task.caseId);
|
|
2004
|
+
mkdirSync(outputDir, {
|
|
2005
|
+
recursive: true
|
|
2006
|
+
});
|
|
2007
|
+
const { materialize: materializeReport, materializeSource, resolve: resolveReportReference, resolveSource } = createExecutorReports(outputDir);
|
|
2008
|
+
if (prepared.collectionErrors.length > 0) return {
|
|
2009
|
+
case: asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'project-preflight-failed')
|
|
2010
|
+
};
|
|
2011
|
+
const executor = options.executor ?? localTestExecutor;
|
|
2012
|
+
let result;
|
|
2013
|
+
let executorFailure;
|
|
2014
|
+
let executorAttempts = 0;
|
|
2015
|
+
for(let attemptIndex = 0; attemptIndex <= options.test.executorRetry; attemptIndex += 1){
|
|
2016
|
+
executorAttempts = attemptIndex + 1;
|
|
2017
|
+
try {
|
|
2018
|
+
result = await executor.execute(task, {
|
|
2019
|
+
signal: options.signal,
|
|
2020
|
+
onProgress: taskProgress,
|
|
2021
|
+
outputDir,
|
|
2022
|
+
materializeReport,
|
|
2023
|
+
runLocal: ()=>runLocalCase(preparedTask, options, taskProgress, materializeReport, materializeSource)
|
|
2024
|
+
});
|
|
2025
|
+
executorFailure = void 0;
|
|
2026
|
+
break;
|
|
2027
|
+
} catch (error) {
|
|
2028
|
+
if (!isTestExecutorError(error)) throw error;
|
|
2029
|
+
executorFailure = error;
|
|
2030
|
+
if (!error.retryable || attemptIndex >= options.test.executorRetry || options.signal.aborted) break;
|
|
2031
|
+
taskProgress(`executor retry ${attemptIndex + 1}/${options.test.executorRetry}: ${error.message}`);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
if (!result) {
|
|
2035
|
+
if (!executorFailure) throw new Error(`Executor did not return a result for ${task.caseId}.`);
|
|
2036
|
+
taskProgress(`executor-failed: ${executorFailure.message}`);
|
|
2037
|
+
return {
|
|
2038
|
+
case: {
|
|
2039
|
+
...asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'executor-failed'),
|
|
2040
|
+
execution: {
|
|
2041
|
+
executor: executor.name,
|
|
2042
|
+
resources: task.resources,
|
|
2043
|
+
attempts: executorAttempts,
|
|
2044
|
+
failure: {
|
|
2045
|
+
kind: executorFailure.kind,
|
|
2046
|
+
message: executorFailure.message,
|
|
2047
|
+
retryable: executorFailure.retryable
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
assertTestCaseTaskRunResult(task, result);
|
|
2054
|
+
const internalResult = fromTransportResult(result, resolveReportReference, resolveSource);
|
|
2055
|
+
const completed = {
|
|
2056
|
+
...internalResult,
|
|
2057
|
+
case: {
|
|
2058
|
+
...internalResult.case,
|
|
2059
|
+
execution: {
|
|
2060
|
+
executor: executor.name,
|
|
2061
|
+
resources: task.resources,
|
|
2062
|
+
attempts: executorAttempts,
|
|
2063
|
+
...internalResult.lifecycle ? {
|
|
2064
|
+
lifecycle: internalResult.lifecycle
|
|
2065
|
+
} : {},
|
|
2066
|
+
...result.artifacts ? {
|
|
2067
|
+
artifacts: result.artifacts
|
|
2068
|
+
} : {},
|
|
2069
|
+
...result.metadata ? {
|
|
2070
|
+
metadata: result.metadata
|
|
2071
|
+
} : {}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
onCompleted(completed);
|
|
2076
|
+
for (const attempt of internalResult.case.attempts ?? [])await writeCaseAttemptResult(options.runDir, task.projectId, task.documentId, attempt);
|
|
2077
|
+
if (internalResult.document) await writeWorkflowDocumentResult(options.runDir, internalResult.document);
|
|
2078
|
+
taskProgress(internalResult.case.status);
|
|
2079
|
+
return completed;
|
|
2080
|
+
};
|
|
2081
|
+
const runCaseExecution = async (options)=>{
|
|
2082
|
+
const tasks = prepareCaseTasks(options.projects);
|
|
2083
|
+
let failedCaseCount = 0;
|
|
2084
|
+
const bailReached = ()=>options.test.bail > 0 && failedCaseCount >= options.test.bail;
|
|
2085
|
+
const results = new Array(tasks.length);
|
|
2086
|
+
if (tasks.length > 0) try {
|
|
2087
|
+
await runTaskPool({
|
|
2088
|
+
tasks: tasks.map((preparedTask)=>preparedTask.task),
|
|
2089
|
+
maxConcurrency: Math.min(options.test.maxConcurrency, tasks.length),
|
|
2090
|
+
signal: options.signal,
|
|
2091
|
+
shouldStop: bailReached,
|
|
2092
|
+
run: async (_task, taskIndex)=>{
|
|
2093
|
+
try {
|
|
2094
|
+
const result = await runCaseTask(tasks[taskIndex], taskIndex, tasks.length, options, (completed)=>{
|
|
2095
|
+
results[taskIndex] = completed;
|
|
2096
|
+
});
|
|
2097
|
+
results[taskIndex] = result;
|
|
2098
|
+
if ('failed' === result.case.status) failedCaseCount += 1;
|
|
2099
|
+
return result;
|
|
2100
|
+
} catch (error) {
|
|
2101
|
+
options.onInfrastructureError(error);
|
|
2102
|
+
throw error;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
});
|
|
2106
|
+
} catch (error) {
|
|
2107
|
+
options.onInfrastructureError(error);
|
|
2108
|
+
}
|
|
2109
|
+
return options.projects.map((prepared, projectIndex)=>{
|
|
2110
|
+
const projectTasks = tasks.map((task, taskIndex)=>({
|
|
2111
|
+
task,
|
|
2112
|
+
result: results[taskIndex]
|
|
2113
|
+
})).filter(({ task })=>task.projectIndex === projectIndex);
|
|
2114
|
+
const reason = prepared.collectionErrors.length ? 'project-preflight-failed' : options.signal.aborted ? 'interrupted' : bailReached() ? 'bail' : void 0;
|
|
2115
|
+
const cases = projectTasks.map(({ task, result })=>{
|
|
2116
|
+
if (result) return result.case;
|
|
2117
|
+
if (!reason) throw new Error(`Case scheduler did not produce a result for "${task.task.caseId}".`);
|
|
2118
|
+
return asNotRun(task.task.documentId, task.collectedCase, prepared.project.name, reason);
|
|
2119
|
+
});
|
|
2120
|
+
const documents = projectTasks.flatMap(({ result })=>result?.document ? [
|
|
2121
|
+
result.document
|
|
2122
|
+
] : []);
|
|
2123
|
+
return buildProjectResult(prepared, cases, documents);
|
|
2124
|
+
});
|
|
2125
|
+
};
|
|
1039
2126
|
const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
|
|
1040
2127
|
...attempt.beforeEach,
|
|
1041
2128
|
...attempt.steps,
|
|
@@ -1081,7 +2168,7 @@ async function executeDocumentInvocation(options) {
|
|
|
1081
2168
|
},
|
|
1082
2169
|
onStepResult: async (info, result)=>{
|
|
1083
2170
|
await invocation.bindings.onStepResult?.(info, result);
|
|
1084
|
-
options.onProgress(formatStepResult(info, result));
|
|
2171
|
+
options.onProgress(formatStepResult(info, result, 'case' === info.scope ? ' ' : ' '));
|
|
1085
2172
|
},
|
|
1086
2173
|
onCaseResult: async (attempt)=>{
|
|
1087
2174
|
await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
|
|
@@ -1199,16 +2286,6 @@ function createOperationJournal(runDir) {
|
|
|
1199
2286
|
}
|
|
1200
2287
|
};
|
|
1201
2288
|
}
|
|
1202
|
-
const asNotRun = (documentId, collectedCase, projectName, reason)=>({
|
|
1203
|
-
documentId,
|
|
1204
|
-
caseId: collectedCase.caseId,
|
|
1205
|
-
projectName,
|
|
1206
|
-
name: collectedCase.definition.name,
|
|
1207
|
-
sourcePath: collectedCase.sourcePath,
|
|
1208
|
-
caseIndex: collectedCase.caseIndex,
|
|
1209
|
-
status: 'not-run',
|
|
1210
|
-
notRunReason: reason
|
|
1211
|
-
});
|
|
1212
2289
|
const summarize = (projects)=>{
|
|
1213
2290
|
const healthCases = projects.flatMap((project)=>{
|
|
1214
2291
|
const documentAttemptByRunId = new Map(project.documents.map((document)=>[
|
|
@@ -1242,12 +2319,6 @@ const summarize = (projects)=>{
|
|
|
1242
2319
|
projectFailures: projects.filter((project)=>'failed' === project.status).length
|
|
1243
2320
|
};
|
|
1244
2321
|
};
|
|
1245
|
-
const latestById = (items, id)=>[
|
|
1246
|
-
...new Map(items.map((item)=>[
|
|
1247
|
-
id(item),
|
|
1248
|
-
item
|
|
1249
|
-
])).values()
|
|
1250
|
-
];
|
|
1251
2322
|
const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>asNotRun(document.documentId, item, prepared.project.name, reason)));
|
|
1252
2323
|
async function runPreparedTestProject(plan) {
|
|
1253
2324
|
const { startedAt, runId, projectRoot, configPath, resultDir, runDir, summaryPath, reportDir, definition, projects: preparedProjects } = plan;
|
|
@@ -1260,7 +2331,7 @@ async function runPreparedTestProject(plan) {
|
|
|
1260
2331
|
await writeTestRunPlan(runDir, preparedProjects);
|
|
1261
2332
|
const journal = createOperationJournal(runDir);
|
|
1262
2333
|
const rootController = new AbortController();
|
|
1263
|
-
setMaxListeners(Math.max(10, effectiveConcurrency + 1), rootController.signal);
|
|
2334
|
+
setMaxListeners(Math.max(10, ('case' === definition.test.executionUnit ? Math.min(definition.test.maxConcurrency, totalCases) : effectiveConcurrency) + 1), rootController.signal);
|
|
1264
2335
|
const handleSignal = (signal)=>{
|
|
1265
2336
|
rootController.abort(new Error(`Workflow interrupted by ${signal}.`));
|
|
1266
2337
|
};
|
|
@@ -1302,31 +2373,6 @@ async function runPreparedTestProject(plan) {
|
|
|
1302
2373
|
const { project } = prepared;
|
|
1303
2374
|
runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
|
|
1304
2375
|
};
|
|
1305
|
-
const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
|
|
1306
|
-
const { project } = prepared;
|
|
1307
|
-
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';
|
|
1308
|
-
return {
|
|
1309
|
-
projectId: project.projectId,
|
|
1310
|
-
name: project.name,
|
|
1311
|
-
...prepared.platform ? {
|
|
1312
|
-
platform: prepared.platform
|
|
1313
|
-
} : {},
|
|
1314
|
-
status: projectFailed ? 'failed' : 'success',
|
|
1315
|
-
retry: project.retry,
|
|
1316
|
-
fileSelection: prepared.fileSelection,
|
|
1317
|
-
tagSelection: project.tags,
|
|
1318
|
-
sourceCount: prepared.sources.length,
|
|
1319
|
-
selectedCaseCount: prepared.selectedCaseCount,
|
|
1320
|
-
filteredCaseCount: prepared.filteredCaseCount,
|
|
1321
|
-
...lifecycle ? {
|
|
1322
|
-
lifecycle
|
|
1323
|
-
} : {},
|
|
1324
|
-
cases,
|
|
1325
|
-
plans: prepared.plans,
|
|
1326
|
-
documents,
|
|
1327
|
-
collectionErrors: prepared.collectionErrors
|
|
1328
|
-
};
|
|
1329
|
-
};
|
|
1330
2376
|
const buildSkippedProjectResult = (prepared, projectIndex, reason)=>{
|
|
1331
2377
|
announceProject(prepared, projectIndex);
|
|
1332
2378
|
return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
|
|
@@ -1427,7 +2473,7 @@ async function runPreparedTestProject(plan) {
|
|
|
1427
2473
|
};
|
|
1428
2474
|
const projectResults = new Array(preparedProjects.length);
|
|
1429
2475
|
try {
|
|
1430
|
-
await runConcurrentJobs(preparedProjects, {
|
|
2476
|
+
if ('case' !== definition.test.executionUnit || 'run' === plan.preflightScope && totalErrors > 0) await runConcurrentJobs(preparedProjects, {
|
|
1431
2477
|
concurrency: effectiveConcurrency,
|
|
1432
2478
|
shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
|
|
1433
2479
|
}, async (prepared, index)=>{
|
|
@@ -1438,6 +2484,20 @@ async function runPreparedTestProject(plan) {
|
|
|
1438
2484
|
throw error;
|
|
1439
2485
|
}
|
|
1440
2486
|
});
|
|
2487
|
+
else {
|
|
2488
|
+
const results = await runCaseExecution({
|
|
2489
|
+
projects: preparedProjects,
|
|
2490
|
+
test: definition.test,
|
|
2491
|
+
executor: definition.executor,
|
|
2492
|
+
runDir,
|
|
2493
|
+
signal: rootController.signal,
|
|
2494
|
+
progress,
|
|
2495
|
+
onInfrastructureError: recordInfrastructureError
|
|
2496
|
+
});
|
|
2497
|
+
results.forEach((result, index)=>{
|
|
2498
|
+
projectResults[index] = result;
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
1441
2501
|
} catch (error) {
|
|
1442
2502
|
recordInfrastructureError(error);
|
|
1443
2503
|
} finally{
|
|
@@ -1534,6 +2594,6 @@ async function runPreparedTestProject(plan) {
|
|
|
1534
2594
|
async function runTestProject(options = {}) {
|
|
1535
2595
|
return runPreparedTestProject(await prepareTestRun(options));
|
|
1536
2596
|
}
|
|
1537
|
-
export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
|
|
2597
|
+
export { DEFAULT_TEST_FILE_SELECTION, TestExecutorError, assertTestCaseTaskRunResult, createTestRunId, defineProjectSetup, defineTestExecutor, defineTestProject, discoverSelectedTestFiles, discoverTestConfig, discoverTestFiles, isTestExecutorError, loadTestProject, localTestExecutor, runTestProject, validateTestFileSelection };
|
|
1538
2598
|
|
|
1539
2599
|
//# sourceMappingURL=index.mjs.map
|