@midscene/test 1.15.4-beta-20261013083552.0 → 1.15.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/es/{1~214.mjs → 1~4.mjs} +96 -174
- package/dist/es/1~4.mjs.map +1 -0
- package/dist/es/cli/index.mjs +741 -1899
- package/dist/es/cli/index.mjs.map +1 -1
- package/dist/es/index.mjs +1 -20
- 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 +70 -1254
- package/dist/es/test-cli.mjs.map +1 -1
- package/dist/lib/{0~160.js → 0~593.js} +95 -173
- package/dist/lib/0~593.js.map +1 -0
- package/dist/lib/cli/index.js +740 -1913
- package/dist/lib/cli/index.js.map +1 -1
- package/dist/lib/index.js +1 -20
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/midscene/index.js +0 -3
- package/dist/lib/midscene/index.js.map +1 -1
- package/dist/lib/test-cli.js +70 -1254
- package/dist/lib/test-cli.js.map +1 -1
- package/dist/templates/manifest.json +60 -0
- package/dist/templates/test/android/.env.example +15 -0
- package/dist/templates/test/android/README.md +19 -0
- package/dist/templates/test/android/README.zh.md +11 -0
- package/dist/templates/test/android/cases/todo.yaml +26 -0
- package/dist/templates/test/android/gitignore +4 -0
- package/dist/templates/test/android/midscene.config.ts +69 -0
- package/dist/templates/test/android/package.json +20 -0
- package/dist/templates/test/android/tsconfig.json +12 -0
- package/dist/templates/test/computer/.env.example +17 -0
- package/dist/templates/test/computer/README.md +19 -0
- package/dist/templates/test/computer/README.zh.md +11 -0
- package/dist/templates/test/computer/cases/todo.yaml +31 -0
- package/dist/templates/test/computer/gitignore +4 -0
- package/dist/templates/test/computer/midscene.config.ts +74 -0
- package/dist/templates/test/computer/package.json +20 -0
- package/dist/templates/test/computer/tsconfig.json +12 -0
- package/dist/templates/test/harmony/.env.example +15 -0
- package/dist/templates/test/harmony/README.md +19 -0
- package/dist/templates/test/harmony/README.zh.md +11 -0
- package/dist/templates/test/harmony/cases/todo.yaml +26 -0
- package/dist/templates/test/harmony/gitignore +4 -0
- package/dist/templates/test/harmony/midscene.config.ts +69 -0
- package/dist/templates/test/harmony/package.json +20 -0
- package/dist/templates/test/harmony/tsconfig.json +12 -0
- package/dist/templates/test/ios/.env.example +18 -0
- package/dist/templates/test/ios/README.md +19 -0
- package/dist/templates/test/ios/README.zh.md +11 -0
- package/dist/templates/test/ios/cases/todo.yaml +26 -0
- package/dist/templates/test/ios/gitignore +4 -0
- package/dist/templates/test/ios/midscene.config.ts +95 -0
- package/dist/templates/test/ios/package.json +20 -0
- package/dist/templates/test/ios/tsconfig.json +12 -0
- package/dist/templates/test/web/.env.example +14 -0
- package/dist/templates/test/web/README.md +19 -0
- package/dist/templates/test/web/README.zh.md +13 -0
- package/dist/templates/test/web/cases/todo.yaml +62 -0
- package/dist/templates/test/web/gitignore +4 -0
- package/dist/templates/test/web/midscene.config.ts +56 -0
- package/dist/templates/test/web/nodes/todo.ts +215 -0
- package/dist/templates/test/web/package.json +21 -0
- package/dist/templates/test/web/tsconfig.json +12 -0
- package/dist/types/cli/index.d.ts +0 -550
- package/dist/types/index.d.ts +0 -20
- package/dist/types/midscene/index.d.ts +2 -6
- package/package.json +16 -15
- package/dist/es/1~214.mjs.map +0 -1
- package/dist/lib/0~160.js.map +0 -1
package/dist/es/cli/index.mjs
CHANGED
|
@@ -1,1267 +1,742 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { require as api_require } from "tsx/cjs/api";
|
|
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";
|
|
5
6
|
import { globSync } from "tinyglobby";
|
|
6
7
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
8
|
import { JSON_SCHEMA, load } from "js-yaml";
|
|
8
9
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
9
|
-
import { pathToFileURL } from "node:url";
|
|
10
|
-
import { require as api_require } from "tsx/cjs/api";
|
|
11
10
|
import { setMaxListeners } from "node:events";
|
|
12
11
|
import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
|
|
13
12
|
import { getDebug } from "@midscene/shared/logger";
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
redactedPaths: z.array(z.string()).optional()
|
|
29
|
-
});
|
|
30
|
-
const reportErrorSchema = z.lazy(()=>z.strictObject({
|
|
31
|
-
format: z.literal('midscene-error').optional(),
|
|
32
|
-
name: z.string(),
|
|
33
|
-
message: z.string(),
|
|
34
|
-
code: z.string().optional(),
|
|
35
|
-
details: executor_runtime_schema_value.optional(),
|
|
36
|
-
cause: reportErrorSchema.optional(),
|
|
37
|
-
errors: z.array(reportErrorSchema).optional(),
|
|
38
|
-
truncated: z.boolean().optional()
|
|
39
|
-
}));
|
|
40
|
-
const executor_runtime_schema_operation = z.strictObject({
|
|
41
|
-
operationId: z.string().min(1),
|
|
42
|
-
scopeId: z.string().optional(),
|
|
43
|
-
stepId: z.string().optional(),
|
|
44
|
-
node: z.string().optional()
|
|
45
|
-
});
|
|
46
|
-
const cleanupSchema = z.strictObject({
|
|
47
|
-
status: z["enum"]([
|
|
48
|
-
'not-required',
|
|
49
|
-
'completed',
|
|
50
|
-
'pending',
|
|
51
|
-
'failed'
|
|
52
|
-
]),
|
|
53
|
-
capturedAt: time,
|
|
54
|
-
operationId: z.string().optional(),
|
|
55
|
-
blockingOperations: z.array(executor_runtime_schema_operation).optional(),
|
|
56
|
-
error: reportErrorSchema.optional()
|
|
57
|
-
});
|
|
58
|
-
const pendingOperationsSchema = z.array(executor_runtime_schema_operation);
|
|
59
|
-
const collectionStatus = z["enum"]([
|
|
60
|
-
'complete',
|
|
61
|
-
'partial',
|
|
62
|
-
'unknown'
|
|
63
|
-
]);
|
|
64
|
-
const metricsSchema = z.strictObject({
|
|
65
|
-
modelCallCount: executor_runtime_schema_count,
|
|
66
|
-
modelTimeMs: z.number().finite().nonnegative(),
|
|
67
|
-
promptTokens: executor_runtime_schema_count,
|
|
68
|
-
cachedInputTokens: executor_runtime_schema_count,
|
|
69
|
-
completionTokens: executor_runtime_schema_count,
|
|
70
|
-
totalTokens: executor_runtime_schema_count,
|
|
71
|
-
collection: z.strictObject({
|
|
72
|
-
status: collectionStatus,
|
|
73
|
-
expectedExecutionCount: executor_runtime_schema_count,
|
|
74
|
-
capturedExecutionCount: executor_runtime_schema_count,
|
|
75
|
-
reasons: z.array(z.string()),
|
|
76
|
-
fields: z.strictObject({
|
|
77
|
-
modelCallCount: collectionStatus,
|
|
78
|
-
modelTimeMs: collectionStatus,
|
|
79
|
-
totalTokens: collectionStatus
|
|
80
|
-
}).optional()
|
|
81
|
-
}).optional()
|
|
82
|
-
});
|
|
83
|
-
const operationsSchema = z.strictObject({
|
|
84
|
-
capturedAt: time,
|
|
85
|
-
events: z.array(z.strictObject({
|
|
86
|
-
operationId: z.string().min(1),
|
|
87
|
-
operationKind: z["enum"]([
|
|
88
|
-
'node',
|
|
89
|
-
'resource',
|
|
90
|
-
'acquisition',
|
|
91
|
-
'cleanup',
|
|
92
|
-
'readiness'
|
|
93
|
-
]),
|
|
94
|
-
kind: z["enum"]([
|
|
95
|
-
'started',
|
|
96
|
-
'cancel-requested',
|
|
97
|
-
'settled',
|
|
98
|
-
'runner-returned',
|
|
99
|
-
'cleanup-deferred'
|
|
100
|
-
]),
|
|
101
|
-
at: time,
|
|
102
|
-
stepId: z.string().optional(),
|
|
103
|
-
scopeId: z.string().optional(),
|
|
104
|
-
node: z.string().optional(),
|
|
105
|
-
status: z["enum"]([
|
|
106
|
-
'success',
|
|
107
|
-
'failed',
|
|
108
|
-
'not-run'
|
|
109
|
-
]).optional(),
|
|
110
|
-
pending: z.boolean().optional(),
|
|
111
|
-
error: reportErrorSchema.optional(),
|
|
112
|
-
blockingOperations: z.array(executor_runtime_schema_operation).optional()
|
|
113
|
-
}))
|
|
114
|
-
});
|
|
115
|
-
const runtimeResultFields = {
|
|
116
|
-
executionStatus: z["enum"]([
|
|
117
|
-
'success',
|
|
118
|
-
'failed',
|
|
119
|
-
'not-run'
|
|
120
|
-
]).optional(),
|
|
121
|
-
phases: z.partialRecord(z["enum"]([
|
|
122
|
-
'beforeAll',
|
|
123
|
-
'beforeEach',
|
|
124
|
-
'steps',
|
|
125
|
-
'afterEach',
|
|
126
|
-
'afterAll'
|
|
127
|
-
]), z.strictObject({
|
|
128
|
-
status: z["enum"]([
|
|
129
|
-
'success',
|
|
130
|
-
'failed',
|
|
131
|
-
'not-run',
|
|
132
|
-
'not-required'
|
|
133
|
-
]),
|
|
134
|
-
plannedStepCount: executor_runtime_schema_count,
|
|
135
|
-
executedStepCount: executor_runtime_schema_count
|
|
136
|
-
})).optional(),
|
|
137
|
-
cleanup: cleanupSchema.optional(),
|
|
138
|
-
pendingOperations: pendingOperationsSchema.optional(),
|
|
139
|
-
retryDecision: z.strictObject({
|
|
140
|
-
scope: z["enum"]([
|
|
141
|
-
'case',
|
|
142
|
-
'document'
|
|
143
|
-
]),
|
|
144
|
-
retryCount: executor_runtime_schema_count,
|
|
145
|
-
attemptIndex: executor_runtime_schema_count,
|
|
146
|
-
decision: z["enum"]([
|
|
147
|
-
'retry',
|
|
148
|
-
'stop'
|
|
149
|
-
]),
|
|
150
|
-
reason: z["enum"]([
|
|
151
|
-
'passed',
|
|
152
|
-
'attempt-failed',
|
|
153
|
-
'budget-exhausted',
|
|
154
|
-
'cleanup-pending',
|
|
155
|
-
'cleanup-failed',
|
|
156
|
-
'operation-pending',
|
|
157
|
-
'fatal-error',
|
|
158
|
-
'interrupted',
|
|
159
|
-
'bail',
|
|
160
|
-
'infrastructure-error'
|
|
161
|
-
])
|
|
162
|
-
}).optional()
|
|
13
|
+
const registry_NodeRegistry = NodeRegistry;
|
|
14
|
+
const projectIdFromIndex = (index)=>`project-${index}`;
|
|
15
|
+
const defineTestProject = (definition)=>definition;
|
|
16
|
+
const defineProjectSetup = (definition)=>definition;
|
|
17
|
+
const getDefaultExport = (loaded, absolutePath)=>{
|
|
18
|
+
if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
|
|
19
|
+
const defaultExport = loaded.default;
|
|
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.`);
|
|
163
27
|
};
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
'
|
|
179
|
-
'
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const documentPlanSchema = z.strictObject({
|
|
185
|
-
documentId: z.string(),
|
|
186
|
-
sourcePath: z.string(),
|
|
187
|
-
sourceYaml: executor_runtime_schema_value.optional(),
|
|
188
|
-
retry: z.strictObject({
|
|
189
|
-
scope: z["enum"]([
|
|
190
|
-
'case',
|
|
191
|
-
'document'
|
|
192
|
-
]),
|
|
193
|
-
count: executor_runtime_schema_count
|
|
194
|
-
}),
|
|
195
|
-
beforeAll: z.array(plannedStep),
|
|
196
|
-
afterAll: z.array(plannedStep),
|
|
197
|
-
cases: z.array(z.strictObject({
|
|
198
|
-
caseId: z.string(),
|
|
199
|
-
name: z.string(),
|
|
200
|
-
caseIndex: executor_runtime_schema_count,
|
|
201
|
-
selected: z.boolean(),
|
|
202
|
-
tags: z.array(z.string()),
|
|
203
|
-
onFailure: z["enum"]([
|
|
204
|
-
'continue',
|
|
205
|
-
'stop-document'
|
|
206
|
-
]).optional(),
|
|
207
|
-
beforeEach: z.array(plannedStep),
|
|
208
|
-
steps: z.array(plannedStep),
|
|
209
|
-
afterEach: z.array(plannedStep)
|
|
210
|
-
}))
|
|
211
|
-
});
|
|
212
|
-
function _define_property(obj, key, value) {
|
|
213
|
-
if (key in obj) Object.defineProperty(obj, key, {
|
|
214
|
-
value: value,
|
|
215
|
-
enumerable: true,
|
|
216
|
-
configurable: true,
|
|
217
|
-
writable: true
|
|
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;
|
|
218
48
|
});
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
name: 'local',
|
|
235
|
-
execute: (_task, context)=>context.runLocal()
|
|
236
|
-
});
|
|
237
|
-
const jsonValueSchema = z.lazy(()=>z.union([
|
|
238
|
-
z["null"](),
|
|
239
|
-
z.string(),
|
|
240
|
-
z.number().finite(),
|
|
241
|
-
z.boolean(),
|
|
242
|
-
z.array(jsonValueSchema),
|
|
243
|
-
z.record(z.string(), jsonValueSchema)
|
|
244
|
-
]));
|
|
245
|
-
const timestampSchema = z.string().refine((value)=>Number.isFinite(Date.parse(value)), {
|
|
246
|
-
message: 'Expected an ISO-compatible timestamp.'
|
|
247
|
-
});
|
|
248
|
-
const errorSchema = z.strictObject({
|
|
249
|
-
format: z.literal('midscene-error').optional(),
|
|
250
|
-
cause: reportErrorSchema.optional(),
|
|
251
|
-
name: z.string().min(1),
|
|
252
|
-
message: z.string(),
|
|
253
|
-
code: z.string().min(1),
|
|
254
|
-
details: jsonValueSchema.optional()
|
|
255
|
-
});
|
|
256
|
-
const stepBaseSchema = z.strictObject({
|
|
257
|
-
phase: z["enum"]([
|
|
258
|
-
'beforeAll',
|
|
259
|
-
'beforeEach',
|
|
260
|
-
'steps',
|
|
261
|
-
'afterEach',
|
|
262
|
-
'afterAll'
|
|
263
|
-
]),
|
|
264
|
-
stepIndex: z.number().int().nonnegative(),
|
|
265
|
-
node: z.string().min(1),
|
|
266
|
-
input: jsonValueSchema,
|
|
267
|
-
meta: z.strictObject({
|
|
268
|
-
timeoutMs: z.number().positive().finite().optional(),
|
|
269
|
-
continueOnError: z.boolean()
|
|
270
|
-
}),
|
|
271
|
-
continuedAfterError: z.boolean(),
|
|
272
|
-
waitForPreviousOperationMs: z.number().nonnegative().finite().optional(),
|
|
273
|
-
operations: operationsSchema.optional(),
|
|
274
|
-
output: z.strictObject({
|
|
275
|
-
summary: z.string().optional(),
|
|
276
|
-
data: jsonValueSchema.optional()
|
|
277
|
-
}).optional(),
|
|
278
|
-
error: errorSchema.optional(),
|
|
279
|
-
report: z.strictObject({
|
|
280
|
-
traces: z.array(z.strictObject({
|
|
281
|
-
type: z.literal('midscene-execution'),
|
|
282
|
-
executionId: z.string().min(1),
|
|
283
|
-
metrics: metricsSchema.optional()
|
|
284
|
-
})),
|
|
285
|
-
collectionErrors: z.array(errorSchema).optional()
|
|
286
|
-
}).optional()
|
|
287
|
-
});
|
|
288
|
-
const stepSchema = z.discriminatedUnion('status', [
|
|
289
|
-
stepBaseSchema.extend({
|
|
290
|
-
status: z["enum"]([
|
|
291
|
-
'success',
|
|
292
|
-
'failed'
|
|
293
|
-
]),
|
|
294
|
-
startedAt: timestampSchema,
|
|
295
|
-
endedAt: timestampSchema,
|
|
296
|
-
durationMs: z.number().nonnegative().finite()
|
|
297
|
-
}),
|
|
298
|
-
stepBaseSchema.extend({
|
|
299
|
-
status: z.literal('not-run'),
|
|
300
|
-
notRunReason: z.string().min(1),
|
|
301
|
-
blockedBy: z.string().optional()
|
|
302
|
-
})
|
|
303
|
-
]);
|
|
304
|
-
const validatedStepSchema = stepSchema.superRefine((step, context)=>{
|
|
305
|
-
if ('success' === step.status && (step.error || step.continuedAfterError)) context.addIssue({
|
|
306
|
-
code: 'custom',
|
|
307
|
-
message: 'Successful steps cannot contain failure evidence.'
|
|
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
|
+
} : {}
|
|
308
64
|
});
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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;
|
|
312
72
|
});
|
|
313
|
-
}
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
projectName: z.string().min(1),
|
|
319
|
-
attemptIndex: z.number().int().nonnegative(),
|
|
320
|
-
name: z.string(),
|
|
321
|
-
sourcePath: z.string().min(1),
|
|
322
|
-
caseIndex: z.number().int().nonnegative(),
|
|
323
|
-
status: z["enum"]([
|
|
324
|
-
'success',
|
|
325
|
-
'failed'
|
|
326
|
-
]),
|
|
327
|
-
beforeEach: z.array(validatedStepSchema),
|
|
328
|
-
steps: z.array(validatedStepSchema),
|
|
329
|
-
afterEach: z.array(validatedStepSchema),
|
|
330
|
-
executionErrors: z.array(errorSchema).optional(),
|
|
331
|
-
teardownErrors: z.array(errorSchema).optional(),
|
|
332
|
-
reportRefs: z.array(z.string().min(1)).optional(),
|
|
333
|
-
startedAt: timestampSchema,
|
|
334
|
-
endedAt: timestampSchema,
|
|
335
|
-
durationMs: z.number().nonnegative().finite()
|
|
336
|
-
});
|
|
337
|
-
const notRunReasonSchema = z["enum"]([
|
|
338
|
-
'document-start-failed',
|
|
339
|
-
'project-preflight-failed',
|
|
340
|
-
'project-setup-failed',
|
|
341
|
-
'interrupted',
|
|
342
|
-
'bail',
|
|
343
|
-
'fatal-error',
|
|
344
|
-
'executor-failed',
|
|
345
|
-
'cleanup-pending',
|
|
346
|
-
'cleanup-failed',
|
|
347
|
-
'operation-pending'
|
|
348
|
-
]);
|
|
349
|
-
const resultSchema = z.strictObject({
|
|
350
|
-
case: z.strictObject({
|
|
351
|
-
documentId: z.string().min(1),
|
|
352
|
-
caseId: z.string().min(1),
|
|
353
|
-
projectName: z.string().min(1),
|
|
354
|
-
name: z.string(),
|
|
355
|
-
sourcePath: z.string().min(1),
|
|
356
|
-
caseIndex: z.number().int().nonnegative(),
|
|
357
|
-
status: z["enum"]([
|
|
358
|
-
'success',
|
|
359
|
-
'failed',
|
|
360
|
-
'not-run'
|
|
361
|
-
]),
|
|
362
|
-
run: attemptSchema.optional(),
|
|
363
|
-
attempts: z.array(attemptSchema).optional(),
|
|
364
|
-
notRunReason: notRunReasonSchema.optional()
|
|
365
|
-
}),
|
|
366
|
-
document: z.strictObject({
|
|
367
|
-
...runtimeResultFields,
|
|
368
|
-
plan: documentPlanSchema.optional(),
|
|
369
|
-
attemptIndex: z.number().int().nonnegative().optional(),
|
|
370
|
-
hostErrors: z.array(z.strictObject({
|
|
371
|
-
phase: z["enum"]([
|
|
372
|
-
'setup',
|
|
373
|
-
'execution',
|
|
374
|
-
'cleanup',
|
|
375
|
-
'report',
|
|
376
|
-
'publication',
|
|
377
|
-
'observer'
|
|
378
|
-
]),
|
|
379
|
-
error: reportErrorSchema
|
|
380
|
-
})).optional(),
|
|
381
|
-
documentId: z.string().min(1),
|
|
382
|
-
documentRunId: z.string().min(1),
|
|
383
|
-
projectId: z.string().min(1),
|
|
384
|
-
projectName: z.string().min(1),
|
|
385
|
-
sourcePath: z.string().min(1),
|
|
386
|
-
status: z["enum"]([
|
|
387
|
-
'success',
|
|
388
|
-
'failed'
|
|
389
|
-
]),
|
|
390
|
-
startedAt: timestampSchema,
|
|
391
|
-
endedAt: timestampSchema,
|
|
392
|
-
durationMs: z.number().nonnegative().finite(),
|
|
393
|
-
beforeAll: z.array(validatedStepSchema),
|
|
394
|
-
afterAll: z.array(validatedStepSchema),
|
|
395
|
-
executionErrors: z.array(errorSchema).optional(),
|
|
396
|
-
teardownErrors: z.array(errorSchema).optional(),
|
|
397
|
-
reportRefs: z.array(z.string().min(1)).optional()
|
|
398
|
-
}).optional(),
|
|
399
|
-
lifecycle: z.strictObject({
|
|
400
|
-
cleanup: cleanupSchema.optional(),
|
|
401
|
-
pendingOperations: pendingOperationsSchema.optional(),
|
|
402
|
-
projectName: z.string().min(1),
|
|
403
|
-
status: z["enum"]([
|
|
404
|
-
'success',
|
|
405
|
-
'failed'
|
|
406
|
-
]),
|
|
407
|
-
setupError: errorSchema.optional(),
|
|
408
|
-
teardownErrors: z.array(errorSchema).optional(),
|
|
409
|
-
startedAt: timestampSchema,
|
|
410
|
-
endedAt: timestampSchema,
|
|
411
|
-
durationMs: z.number().nonnegative().finite()
|
|
412
|
-
}).optional(),
|
|
413
|
-
artifacts: z.array(z.strictObject({
|
|
414
|
-
name: z.string().min(1),
|
|
415
|
-
uri: z.string().min(1),
|
|
416
|
-
mediaType: z.string().min(1).optional()
|
|
417
|
-
})).optional(),
|
|
418
|
-
metadata: z.record(z.string(), z.union([
|
|
419
|
-
z.string(),
|
|
420
|
-
z.number().finite(),
|
|
421
|
-
z.boolean(),
|
|
422
|
-
z["null"]()
|
|
423
|
-
])).optional()
|
|
424
|
-
});
|
|
425
|
-
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);
|
|
426
|
-
const hasUnfinishedWork = (result)=>void 0 !== result.executionStatus && 'success' !== result.executionStatus || void 0 !== result.cleanup && ![
|
|
427
|
-
'completed',
|
|
428
|
-
'not-required'
|
|
429
|
-
].includes(result.cleanup.status) || (result.pendingOperations?.length ?? 0) > 0;
|
|
430
|
-
function assertTestCaseTaskRunResult(task, value) {
|
|
431
|
-
const parsed = resultSchema.safeParse(value);
|
|
432
|
-
if (!parsed.success) throw new TypeError(`Executor returned invalid transport data for ${task.caseId}: ${z.prettifyError(parsed.error)}`);
|
|
433
|
-
const outcome = parsed.data.case;
|
|
434
|
-
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}.`);
|
|
435
|
-
const attempts = outcome.attempts ?? [];
|
|
436
|
-
for (const attempt of attempts){
|
|
437
|
-
const steps = [
|
|
438
|
-
...attempt.beforeEach,
|
|
439
|
-
...attempt.steps,
|
|
440
|
-
...attempt.afterEach
|
|
441
|
-
];
|
|
442
|
-
if ('success' === attempt.status && (0 === attempt.steps.length || (attempt.executionErrors?.length ?? 0) > 0 || steps.some((step)=>'success' !== step.status) || hasUnfinishedWork(attempt) || (attempt.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful attempt for ${task.caseId}.`);
|
|
443
|
-
}
|
|
444
|
-
if (new Set(attempts.map((attempt)=>attempt.runId)).size !== attempts.length) throw new TypeError(`Executor returned duplicate attempt IDs for ${task.caseId}.`);
|
|
445
|
-
if (attempts.some((attempt)=>!attemptMatchesTask(task, attempt))) throw new TypeError(`Executor returned invalid attempts for ${task.caseId}.`);
|
|
446
|
-
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}.`);
|
|
447
|
-
const finalAttempt = attempts.at(-1);
|
|
448
|
-
if ('not-run' === outcome.status) {
|
|
449
|
-
if (outcome.run || attempts.length || !outcome.notRunReason) throw new TypeError(`Executor returned an inconsistent not-run result for ${task.caseId}.`);
|
|
450
|
-
} 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}.`);
|
|
451
|
-
const reportRefs = [
|
|
452
|
-
...attempts.flatMap((attempt)=>attempt.reportRefs ?? []),
|
|
453
|
-
...parsed.data.document?.reportRefs ?? []
|
|
454
|
-
];
|
|
455
|
-
if (reportRefs.some((reference)=>!isSafeResultPathSegment(reference))) throw new TypeError(`Executor returned an invalid report reference for ${task.caseId}.`);
|
|
456
|
-
const document = parsed.data.document;
|
|
457
|
-
if (document?.status === 'success' && (hasUnfinishedWork(document) || (document.hostErrors?.length ?? 0) > 0 || (document.executionErrors?.length ?? 0) > 0 || [
|
|
458
|
-
...document.beforeAll,
|
|
459
|
-
...document.afterAll
|
|
460
|
-
].some((step)=>'success' !== step.status) || (document.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful document for ${task.caseId}.`);
|
|
461
|
-
const lifecycle = parsed.data.lifecycle;
|
|
462
|
-
if (lifecycle?.status === 'success' && (hasUnfinishedWork(lifecycle) || lifecycle.setupError || (lifecycle.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful lifecycle for ${task.caseId}.`);
|
|
463
|
-
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}.`);
|
|
464
|
-
if (parsed.data.lifecycle && parsed.data.lifecycle.projectName !== task.projectName) throw new Error(`Executor returned a lifecycle that does not match task ${task.caseId}.`);
|
|
465
|
-
}
|
|
466
|
-
const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
|
|
467
|
-
projectId,
|
|
468
|
-
sourcePath,
|
|
469
|
-
caseIndex,
|
|
470
|
-
...invocationIndex ? [
|
|
471
|
-
invocationIndex
|
|
472
|
-
] : []
|
|
473
|
-
])).digest('hex');
|
|
474
|
-
const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
|
|
475
|
-
projectId,
|
|
476
|
-
sourcePath,
|
|
477
|
-
...invocationIndex ? [
|
|
478
|
-
invocationIndex
|
|
479
|
-
] : []
|
|
480
|
-
])).digest('hex');
|
|
481
|
-
const normalize_normalizeSteps = normalizeSteps;
|
|
482
|
-
function parseWorkflowSource(source) {
|
|
483
|
-
const stack = [];
|
|
484
|
-
const nodes = [];
|
|
485
|
-
const sensitiveNodes = new Set();
|
|
486
|
-
const sensitiveValues = new Set();
|
|
487
|
-
const redactedPaths = new Set();
|
|
488
|
-
const sourceOffset = 0xfeff === source.charCodeAt(0) ? 1 : 0;
|
|
489
|
-
const parsed = load(source, {
|
|
490
|
-
schema: JSON_SCHEMA,
|
|
491
|
-
listener (event, state) {
|
|
492
|
-
if ('open' === event) return void stack.push({
|
|
493
|
-
start: state.position + sourceOffset,
|
|
494
|
-
end: 0,
|
|
495
|
-
value: null,
|
|
496
|
-
children: []
|
|
497
|
-
});
|
|
498
|
-
const node = stack.pop();
|
|
499
|
-
node.end = state.position + sourceOffset;
|
|
500
|
-
node.value = state.result;
|
|
501
|
-
stack.at(-1)?.children.push(node);
|
|
502
|
-
nodes.push(node);
|
|
503
|
-
if ('mapping' !== state.kind) return;
|
|
504
|
-
for(let index = 0; index < node.children.length; index += 2){
|
|
505
|
-
const key = node.children[index].value;
|
|
506
|
-
const value = node.children[index + 1];
|
|
507
|
-
if ('string' == typeof key && isReportSecretKey(key) && value) {
|
|
508
|
-
sensitiveNodes.add(value);
|
|
509
|
-
if (null !== value.value) sensitiveValues.add(value.value);
|
|
510
|
-
redactedPaths.add(`$..[${JSON.stringify(key)}]`);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
73
|
+
};
|
|
74
|
+
const validateTagSelection = (value, label)=>{
|
|
75
|
+
if (void 0 === value) return Object.freeze({
|
|
76
|
+
include: [],
|
|
77
|
+
exclude: []
|
|
514
78
|
});
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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))
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
function assertJsonValue(value, path, seen) {
|
|
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.`);
|
|
525
90
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
redactedPaths: [
|
|
534
|
-
...redactedPaths
|
|
535
|
-
]
|
|
536
|
-
} : {},
|
|
537
|
-
...value !== redacted ? {
|
|
538
|
-
truncatedPaths: [
|
|
539
|
-
'$'
|
|
540
|
-
]
|
|
541
|
-
} : {}
|
|
542
|
-
}
|
|
543
|
-
};
|
|
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);
|
|
544
98
|
}
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
variable: name,
|
|
552
|
-
variableKind: kind
|
|
553
|
-
});
|
|
99
|
+
const deepFreezeJson = (value)=>{
|
|
100
|
+
if ('object' == typeof value && null !== value) {
|
|
101
|
+
for (const child of Object.values(value))deepFreezeJson(child);
|
|
102
|
+
Object.freeze(value);
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
554
105
|
};
|
|
555
|
-
const
|
|
556
|
-
if (
|
|
557
|
-
|
|
106
|
+
const validateVariables = (value, label)=>{
|
|
107
|
+
if (void 0 === value) return Object.freeze({});
|
|
108
|
+
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
109
|
+
assertJsonValue(value, label, new Set());
|
|
110
|
+
return deepFreezeJson(value);
|
|
558
111
|
};
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
if (
|
|
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.`);
|
|
562
121
|
return value;
|
|
563
122
|
};
|
|
564
|
-
const
|
|
565
|
-
if (
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
variableKind: 'project'
|
|
569
|
-
});
|
|
570
|
-
return String(value);
|
|
571
|
-
};
|
|
572
|
-
const resolveString = (value, options)=>{
|
|
573
|
-
const exactEnv = exactEnvExpression.exec(value);
|
|
574
|
-
if (exactEnv) return environmentVariable(exactEnv[1], options);
|
|
575
|
-
const exactProject = exactProjectExpression.exec(value);
|
|
576
|
-
if (exactProject) return projectVariable(exactProject[1], options);
|
|
577
|
-
return value.replace(interpolationExpression, (_match, environmentName, projectName)=>environmentName ? environmentVariable(environmentName, options) : interpolationValue(projectName, projectVariable(projectName, options), options));
|
|
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;
|
|
578
127
|
};
|
|
579
|
-
const
|
|
580
|
-
if (
|
|
581
|
-
if (
|
|
582
|
-
if ('object' == typeof value && null !== value) return Object.fromEntries(Object.entries(value).map(([key, child])=>[
|
|
583
|
-
key,
|
|
584
|
-
resolveWorkflowVariables(child, options)
|
|
585
|
-
]));
|
|
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.`);
|
|
586
131
|
return value;
|
|
587
132
|
};
|
|
588
|
-
|
|
589
|
-
if (
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
const allowedSet = new Set(allowed);
|
|
611
|
-
const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
|
|
612
|
-
if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
|
|
613
|
-
field: unknown[0]
|
|
614
|
-
});
|
|
615
|
-
};
|
|
616
|
-
const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
|
|
617
|
-
const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
|
|
618
|
-
function collectWorkflowDocument(source, options) {
|
|
619
|
-
let snapshot;
|
|
620
|
-
try {
|
|
621
|
-
snapshot = parseWorkflowSource(readFileSync(source.absolutePath, 'utf8'));
|
|
622
|
-
} catch (error) {
|
|
623
|
-
throw new WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
|
|
624
|
-
sourcePath: source.sourcePath
|
|
625
|
-
}, error);
|
|
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
|
+
};
|
|
626
155
|
}
|
|
627
|
-
|
|
628
|
-
if (!
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
'cases',
|
|
635
|
-
'afterEach',
|
|
636
|
-
'afterAll'
|
|
637
|
-
], 'Workflow document');
|
|
638
|
-
if (!Array.isArray(parsed.cases) || 0 === parsed.cases.length) throw new WorkflowParseError('Workflow document must contain a non-empty cases array.');
|
|
639
|
-
const sourcePath = source.sourcePath.split(sep).join('/');
|
|
640
|
-
const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
|
|
641
|
-
...step,
|
|
642
|
-
templateInput: step.input,
|
|
643
|
-
input: resolveWorkflowVariables(step.input, {
|
|
644
|
-
variables: options.variables,
|
|
645
|
-
env: options.env ?? process.env,
|
|
646
|
-
location: {
|
|
647
|
-
projectName: source.projectName ?? source.projectId,
|
|
648
|
-
sourcePath,
|
|
649
|
-
phase,
|
|
650
|
-
stepIndex,
|
|
651
|
-
...void 0 === caseIndex ? {} : {
|
|
652
|
-
caseIndex
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
})
|
|
656
|
-
});
|
|
657
|
-
const normalizeLifecycle = (phase)=>{
|
|
658
|
-
const value = parsed[phase];
|
|
659
|
-
if (void 0 === value) return [];
|
|
660
|
-
if (!Array.isArray(value)) throw new WorkflowParseError(`Workflow document ${phase} must be an array.`, {
|
|
661
|
-
phase
|
|
662
|
-
});
|
|
663
|
-
return normalize_normalizeSteps(value, options.resolveNode).map((normalized, stepIndex)=>{
|
|
664
|
-
const resolved = options.resolveNode(normalized.node);
|
|
665
|
-
if (!resolved) throw new WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
|
|
666
|
-
phase,
|
|
667
|
-
stepIndex,
|
|
668
|
-
node: normalized.node
|
|
669
|
-
});
|
|
670
|
-
return resolveStepVariables(normalized, phase, stepIndex);
|
|
671
|
-
});
|
|
672
|
-
};
|
|
673
|
-
const lifecycle = {
|
|
674
|
-
beforeAll: normalizeLifecycle('beforeAll'),
|
|
675
|
-
beforeEach: normalizeLifecycle('beforeEach'),
|
|
676
|
-
afterEach: normalizeLifecycle('afterEach'),
|
|
677
|
-
afterAll: normalizeLifecycle('afterAll')
|
|
678
|
-
};
|
|
679
|
-
const ids = new Set();
|
|
680
|
-
const cases = parsed.cases.map((definition, caseIndex)=>{
|
|
681
|
-
if (!isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
|
|
682
|
-
caseIndex
|
|
683
|
-
});
|
|
684
|
-
rejectUnknownKeys(definition, [
|
|
685
|
-
'id',
|
|
156
|
+
if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
|
|
157
|
+
if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
|
|
158
|
+
const names = new Set();
|
|
159
|
+
const projects = value.map((candidate, index)=>{
|
|
160
|
+
const label = `projects[${index}]`;
|
|
161
|
+
if (!isRecord(candidate)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
162
|
+
rejectUnknownKeys(candidate, [
|
|
686
163
|
'name',
|
|
164
|
+
'setup',
|
|
165
|
+
'nodes',
|
|
166
|
+
'files',
|
|
687
167
|
'tags',
|
|
688
|
-
'
|
|
689
|
-
'
|
|
690
|
-
],
|
|
691
|
-
if (
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
caseIndex,
|
|
714
|
-
stepIndex,
|
|
715
|
-
node: normalized.node
|
|
716
|
-
});
|
|
717
|
-
return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
|
|
718
|
-
});
|
|
719
|
-
const caseId = 'string' == typeof definition.id ? definition.id : createCaseId(source.projectId, sourcePath, caseIndex);
|
|
720
|
-
if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
|
|
721
|
-
caseId
|
|
722
|
-
});
|
|
723
|
-
ids.add(caseId);
|
|
724
|
-
return {
|
|
725
|
-
caseId,
|
|
726
|
-
projectId: source.projectId,
|
|
727
|
-
sourcePath,
|
|
728
|
-
caseIndex,
|
|
729
|
-
definition: {
|
|
730
|
-
...'string' == typeof definition.id ? {
|
|
731
|
-
id: definition.id
|
|
732
|
-
} : {},
|
|
733
|
-
name: definition.name,
|
|
734
|
-
tags: tags,
|
|
735
|
-
...resources.length > 0 ? {
|
|
736
|
-
resources: resources
|
|
737
|
-
} : {},
|
|
738
|
-
steps
|
|
168
|
+
'retry',
|
|
169
|
+
'variables'
|
|
170
|
+
], label);
|
|
171
|
+
if ('string' != typeof candidate.name || 0 === candidate.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
|
|
172
|
+
if (names.has(candidate.name)) throw new TypeError(`Midscene config project name "${candidate.name}" must be unique.`);
|
|
173
|
+
names.add(candidate.name);
|
|
174
|
+
const files = validateTestFileSelection(candidate.files, `${label}.files`);
|
|
175
|
+
if (void 0 !== candidate.nodes && !Array.isArray(candidate.nodes)) throw new TypeError(`Midscene config ${label}.nodes must be an array.`);
|
|
176
|
+
const localNodes = new registry_NodeRegistry(candidate.nodes);
|
|
177
|
+
const nodes = new registry_NodeRegistry([
|
|
178
|
+
...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
|
|
179
|
+
...localNodes.definitions()
|
|
180
|
+
]);
|
|
181
|
+
return Object.freeze({
|
|
182
|
+
projectId: projectIdFromIndex(index),
|
|
183
|
+
name: candidate.name,
|
|
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`)
|
|
739
193
|
}
|
|
740
|
-
};
|
|
194
|
+
});
|
|
741
195
|
});
|
|
742
196
|
return {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
sourcePath,
|
|
746
|
-
sourceYaml,
|
|
747
|
-
lifecycle,
|
|
748
|
-
cases
|
|
197
|
+
projects: Object.freeze(projects),
|
|
198
|
+
hasExplicitProjects: true
|
|
749
199
|
};
|
|
750
|
-
}
|
|
751
|
-
const writeJson = async (path, value)=>{
|
|
752
|
-
try {
|
|
753
|
-
await mkdir(dirname(path), {
|
|
754
|
-
recursive: true
|
|
755
|
-
});
|
|
756
|
-
await writeFile(path, JSON.stringify(value, null, 2));
|
|
757
|
-
} catch (error) {
|
|
758
|
-
throw new WorkflowPublicationError('write-result', path, error);
|
|
759
|
-
}
|
|
760
200
|
};
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
}))
|
|
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')
|
|
769
208
|
});
|
|
770
|
-
const toPosix = (value)=>value.split(sep).join('/');
|
|
771
|
-
const caseAttemptResultPath = (projectId, documentId, result)=>toPosix(join(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
|
|
772
|
-
const workflowDocumentResultPath = (result)=>toPosix(join(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
|
|
773
|
-
const collectionErrorPath = (projectId, sourcePath)=>{
|
|
774
|
-
const id = createHash('sha256').update(JSON.stringify([
|
|
775
|
-
projectId,
|
|
776
|
-
sourcePath
|
|
777
|
-
])).digest('hex');
|
|
778
|
-
return toPosix(join(projectId, 'collection-errors', `${id}.json`));
|
|
779
|
-
};
|
|
780
|
-
const writeWorkflowDocumentResult = (runDir, result)=>{
|
|
781
|
-
const { documentRunId: _documentRunId, ...persistedResult } = result;
|
|
782
|
-
return writeJson(join(runDir, workflowDocumentResultPath(result)), persistedResult);
|
|
783
209
|
};
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
attemptId
|
|
789
|
-
});
|
|
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;
|
|
790
214
|
};
|
|
791
|
-
const
|
|
792
|
-
|
|
793
|
-
|
|
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')
|
|
794
223
|
});
|
|
795
|
-
const relativeToSummary = (summaryPath, target)=>{
|
|
796
|
-
const value = relative(dirname(summaryPath), target);
|
|
797
|
-
return toPosix(value || '.');
|
|
798
224
|
};
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
path: relativeToSummary(result.summaryPath, result.operationLog.path)
|
|
824
|
-
}
|
|
825
|
-
} : {},
|
|
826
|
-
reportDir: relativeToSummary(result.summaryPath, result.reportDir),
|
|
827
|
-
...result.reportPath ? {
|
|
828
|
-
report: relativeToSummary(result.summaryPath, result.reportPath)
|
|
829
|
-
} : {},
|
|
830
|
-
...result.markdownPath ? {
|
|
831
|
-
markdown: relativeToSummary(result.summaryPath, result.markdownPath)
|
|
832
|
-
} : {},
|
|
833
|
-
summary: result.summary,
|
|
834
|
-
projects: result.projects.map((project)=>({
|
|
835
|
-
projectId: project.projectId,
|
|
836
|
-
name: project.name,
|
|
837
|
-
status: project.status,
|
|
838
|
-
retry: project.retry,
|
|
839
|
-
fileSelection: project.fileSelection,
|
|
840
|
-
tagSelection: project.tagSelection,
|
|
841
|
-
sourceCount: project.sourceCount,
|
|
842
|
-
selectedCaseCount: project.selectedCaseCount,
|
|
843
|
-
filteredCaseCount: project.filteredCaseCount,
|
|
844
|
-
...project.lifecycle ? {
|
|
845
|
-
lifecycle: {
|
|
846
|
-
status: project.lifecycle.status,
|
|
847
|
-
startedAt: project.lifecycle.startedAt,
|
|
848
|
-
endedAt: project.lifecycle.endedAt,
|
|
849
|
-
durationMs: project.lifecycle.durationMs,
|
|
850
|
-
...project.lifecycle.cleanup ? {
|
|
851
|
-
cleanup: project.lifecycle.cleanup
|
|
852
|
-
} : {},
|
|
853
|
-
...project.lifecycle.pendingOperations ? {
|
|
854
|
-
pendingOperations: project.lifecycle.pendingOperations
|
|
855
|
-
} : {},
|
|
856
|
-
...project.lifecycle.setupError ? {
|
|
857
|
-
setupError: errorJson(project.lifecycle.setupError)
|
|
858
|
-
} : {},
|
|
859
|
-
...project.lifecycle.teardownErrors ? {
|
|
860
|
-
teardownErrors: project.lifecycle.teardownErrors.map(errorJson)
|
|
861
|
-
} : {}
|
|
862
|
-
}
|
|
863
|
-
} : {},
|
|
864
|
-
cases: project.cases.map((outcome)=>({
|
|
865
|
-
...outcome.documentRunId ? {
|
|
866
|
-
documentRunId: outcome.documentRunId
|
|
867
|
-
} : {},
|
|
868
|
-
documentId: outcome.documentId,
|
|
869
|
-
caseId: outcome.caseId,
|
|
870
|
-
sourcePath: outcome.sourcePath,
|
|
871
|
-
caseIndex: outcome.caseIndex,
|
|
872
|
-
name: outcome.name,
|
|
873
|
-
status: outcome.status,
|
|
874
|
-
...outcome.notRunReason ? {
|
|
875
|
-
notRunReason: outcome.notRunReason
|
|
876
|
-
} : {},
|
|
877
|
-
...outcome.execution ? {
|
|
878
|
-
execution: {
|
|
879
|
-
executor: outcome.execution.executor,
|
|
880
|
-
resources: outcome.execution.resources,
|
|
881
|
-
...void 0 === outcome.execution.attempts ? {} : {
|
|
882
|
-
attempts: outcome.execution.attempts
|
|
883
|
-
},
|
|
884
|
-
...outcome.execution.failure ? {
|
|
885
|
-
failure: outcome.execution.failure
|
|
886
|
-
} : {},
|
|
887
|
-
...outcome.execution.artifacts ? {
|
|
888
|
-
artifacts: outcome.execution.artifacts
|
|
889
|
-
} : {},
|
|
890
|
-
...outcome.execution.metadata ? {
|
|
891
|
-
metadata: outcome.execution.metadata
|
|
892
|
-
} : {},
|
|
893
|
-
...outcome.execution.lifecycle ? {
|
|
894
|
-
lifecycle: {
|
|
895
|
-
status: outcome.execution.lifecycle.status,
|
|
896
|
-
startedAt: outcome.execution.lifecycle.startedAt,
|
|
897
|
-
endedAt: outcome.execution.lifecycle.endedAt,
|
|
898
|
-
durationMs: outcome.execution.lifecycle.durationMs,
|
|
899
|
-
...outcome.execution.lifecycle.setupError ? {
|
|
900
|
-
setupError: errorJson(outcome.execution.lifecycle.setupError)
|
|
901
|
-
} : {},
|
|
902
|
-
...outcome.execution.lifecycle.teardownErrors ? {
|
|
903
|
-
teardownErrors: outcome.execution.lifecycle.teardownErrors.map(errorJson)
|
|
904
|
-
} : {}
|
|
905
|
-
}
|
|
906
|
-
} : {}
|
|
907
|
-
}
|
|
908
|
-
} : {},
|
|
909
|
-
...outcome.attempts ? {
|
|
910
|
-
attempts: outcome.attempts.map((attempt)=>({
|
|
911
|
-
attemptId: attempt.runId,
|
|
912
|
-
attemptIndex: attempt.attemptIndex,
|
|
913
|
-
status: attempt.status,
|
|
914
|
-
...attempt.cleanup ? {
|
|
915
|
-
cleanup: attempt.cleanup
|
|
916
|
-
} : {},
|
|
917
|
-
...attempt.pendingOperations ? {
|
|
918
|
-
pendingOperations: attempt.pendingOperations
|
|
919
|
-
} : {},
|
|
920
|
-
...attempt.executionStatus ? {
|
|
921
|
-
executionStatus: attempt.executionStatus
|
|
922
|
-
} : {},
|
|
923
|
-
...attempt.retryDecision ? {
|
|
924
|
-
retryDecision: attempt.retryDecision
|
|
925
|
-
} : {},
|
|
926
|
-
resultFile: fact(caseAttemptResultPath(project.projectId, outcome.documentId, attempt)),
|
|
927
|
-
...attempt.reportPaths?.length ? {
|
|
928
|
-
reports: attempt.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
|
|
929
|
-
} : {}
|
|
930
|
-
}))
|
|
931
|
-
} : {}
|
|
932
|
-
})),
|
|
933
|
-
documents: project.documents.map((document)=>({
|
|
934
|
-
documentId: document.documentId,
|
|
935
|
-
...void 0 === document.attemptIndex ? {} : {
|
|
936
|
-
attemptIndex: document.attemptIndex,
|
|
937
|
-
documentRunId: document.documentRunId
|
|
938
|
-
},
|
|
939
|
-
sourcePath: document.sourcePath,
|
|
940
|
-
status: document.status,
|
|
941
|
-
...document.cleanup ? {
|
|
942
|
-
cleanup: document.cleanup
|
|
943
|
-
} : {},
|
|
944
|
-
...document.pendingOperations ? {
|
|
945
|
-
pendingOperations: document.pendingOperations
|
|
946
|
-
} : {},
|
|
947
|
-
...document.executionStatus ? {
|
|
948
|
-
executionStatus: document.executionStatus
|
|
949
|
-
} : {},
|
|
950
|
-
...document.retryDecision ? {
|
|
951
|
-
retryDecision: document.retryDecision
|
|
952
|
-
} : {},
|
|
953
|
-
resultFile: fact(workflowDocumentResultPath(document)),
|
|
954
|
-
...document.reportPaths?.length ? {
|
|
955
|
-
reports: document.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
|
|
956
|
-
} : {}
|
|
957
|
-
})),
|
|
958
|
-
collectionErrors: project.collectionErrors.map((error)=>({
|
|
959
|
-
sourcePath: error.sourcePath,
|
|
960
|
-
errorFile: fact(collectionErrorPath(error.projectId, error.sourcePath))
|
|
961
|
-
}))
|
|
962
|
-
}))
|
|
963
|
-
});
|
|
225
|
+
const validateTestProjectDefinition = (definition)=>{
|
|
226
|
+
if (!isRecord(definition)) throw new TypeError('Midscene config must default export an object.');
|
|
227
|
+
if (void 0 !== definition.nodes && !Array.isArray(definition.nodes)) throw new TypeError('Midscene config nodes must be an array.');
|
|
228
|
+
if ('setupWorkflow' in definition) throw new TypeError('Midscene config setupWorkflow is no longer supported. Use setup or projects[].setup instead.');
|
|
229
|
+
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.');
|
|
230
|
+
if ('root' in definition) throw new TypeError('Midscene config root is not supported. Pass the Test Project directory to midscene-test instead.');
|
|
231
|
+
if ('files' in definition) throw new TypeError('Midscene config files is not supported at the root. Move it to projects[].files.');
|
|
232
|
+
if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
|
|
233
|
+
rejectUnknownKeys(definition, [
|
|
234
|
+
'setup',
|
|
235
|
+
'projects',
|
|
236
|
+
'test',
|
|
237
|
+
'output',
|
|
238
|
+
'nodes'
|
|
239
|
+
], 'root');
|
|
240
|
+
const nodes = new registry_NodeRegistry(definition.nodes);
|
|
241
|
+
const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
|
|
242
|
+
return {
|
|
243
|
+
...resolvedProjects,
|
|
244
|
+
test: validateTestOptions(definition.test),
|
|
245
|
+
output: validateOutput(definition.output),
|
|
246
|
+
nodes,
|
|
247
|
+
resolveNode: (name)=>nodes.get(name)
|
|
248
|
+
};
|
|
964
249
|
};
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
const getDefaultExport = (loaded, absolutePath)=>{
|
|
970
|
-
if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
|
|
971
|
-
const defaultExport = loaded.default;
|
|
972
|
-
if ('object' == typeof defaultExport && null !== defaultExport && '__esModule' in defaultExport) {
|
|
973
|
-
if ('default' in defaultExport) return defaultExport.default;
|
|
974
|
-
throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
|
|
975
|
-
}
|
|
976
|
-
return defaultExport;
|
|
250
|
+
const assertSupportedConfig = (absolutePath)=>{
|
|
251
|
+
if (!absolutePath.endsWith('.ts') && !absolutePath.endsWith('.mjs')) {
|
|
252
|
+
const extension = absolutePath.match(/(\.[^./\\]+)$/)?.[1] ?? '(none)';
|
|
253
|
+
throw new TypeError(`Unsupported Midscene config extension: ${extension}. Supported extensions: .ts, .mjs.`);
|
|
977
254
|
}
|
|
978
|
-
|
|
979
|
-
};
|
|
980
|
-
const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
981
|
-
const test_project_rejectUnknownKeys = (value, allowedKeys, label)=>{
|
|
982
|
-
const allowed = new Set(allowedKeys);
|
|
983
|
-
const unknown = Object.keys(value).find((key)=>!allowed.has(key));
|
|
984
|
-
if (unknown) throw new TypeError(`Midscene config ${label}.${unknown} is not supported.`);
|
|
985
|
-
};
|
|
986
|
-
const isAbsolutePattern = (pattern)=>isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern) || pattern.startsWith('\\\\');
|
|
987
|
-
const validatePatterns = (value, field, label = 'files', allowEmptyInclude = false)=>{
|
|
988
|
-
if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
|
|
989
|
-
if ('include' === field && 0 === value.length && !allowEmptyInclude) throw new TypeError(`Midscene config ${label}.include must be a non-empty array.`);
|
|
990
|
-
return value.map((pattern, index)=>{
|
|
991
|
-
if ('string' != typeof pattern || 0 === pattern.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
|
|
992
|
-
if (isAbsolutePattern(pattern)) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be relative to the project root.`);
|
|
993
|
-
if (pattern.split(/[\\/]/).includes('..')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must not contain a ".." path segment.`);
|
|
994
|
-
if (pattern.includes('\\')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must use POSIX path separators (/).`);
|
|
995
|
-
if (pattern.startsWith('!')) {
|
|
996
|
-
if ('include' === field) throw new TypeError(`Midscene config ${label}.include[${index}] must not be a negated pattern. Use files.exclude instead.`);
|
|
997
|
-
throw new TypeError(`Midscene config ${label}.exclude[${index}] must not be a negated pattern.`);
|
|
998
|
-
}
|
|
999
|
-
return pattern;
|
|
1000
|
-
});
|
|
1001
|
-
};
|
|
1002
|
-
const validateTestFileSelection = (value, label = 'files')=>{
|
|
1003
|
-
if (void 0 === value) return;
|
|
1004
|
-
if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
|
|
1005
|
-
test_project_rejectUnknownKeys(value, [
|
|
1006
|
-
'include',
|
|
1007
|
-
'exclude'
|
|
1008
|
-
], label);
|
|
1009
|
-
const include = validatePatterns(value.include, 'include', label);
|
|
1010
|
-
const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
|
|
1011
|
-
return Object.freeze({
|
|
1012
|
-
include: Object.freeze(include),
|
|
1013
|
-
...exclude ? {
|
|
1014
|
-
exclude: Object.freeze(exclude)
|
|
1015
|
-
} : {}
|
|
1016
|
-
});
|
|
255
|
+
return absolutePath.endsWith('.mjs') ? '.mjs' : '.ts';
|
|
1017
256
|
};
|
|
1018
|
-
const
|
|
1019
|
-
if (
|
|
1020
|
-
|
|
1021
|
-
return
|
|
1022
|
-
if ('string' != typeof tag || 0 === tag.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
|
|
1023
|
-
return tag;
|
|
1024
|
-
});
|
|
257
|
+
const canRetryWithCjsLoader = (error)=>{
|
|
258
|
+
if (!(error instanceof Error)) return false;
|
|
259
|
+
const errorCode = error.code;
|
|
260
|
+
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);
|
|
1025
261
|
};
|
|
1026
|
-
|
|
1027
|
-
if (
|
|
1028
|
-
|
|
1029
|
-
exclude: []
|
|
262
|
+
async function loadTestProject(configPath) {
|
|
263
|
+
if (!configPath) return validateTestProjectDefinition({
|
|
264
|
+
nodes: []
|
|
1030
265
|
});
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
266
|
+
const absolutePath = resolve(configPath);
|
|
267
|
+
const extension = assertSupportedConfig(absolutePath);
|
|
268
|
+
let loaded;
|
|
269
|
+
try {
|
|
270
|
+
if ('.mjs' === extension) loaded = await import(pathToFileURL(absolutePath).href);
|
|
271
|
+
else {
|
|
272
|
+
const { tsImport } = await import("tsx/esm/api");
|
|
273
|
+
loaded = await tsImport(pathToFileURL(absolutePath).href, {
|
|
274
|
+
parentURL: pathToFileURL(`${dirname(absolutePath)}${sep}`).href,
|
|
275
|
+
tsconfig: false
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
try {
|
|
280
|
+
if ('.mjs' === extension || !canRetryWithCjsLoader(error)) throw error;
|
|
281
|
+
loaded = api_require(absolutePath, pathToFileURL(absolutePath));
|
|
282
|
+
} catch (fallbackError) {
|
|
283
|
+
const message = fallbackError instanceof Error ? `: ${fallbackError.message}` : '';
|
|
284
|
+
throw new Error(`Failed to load Midscene config "${absolutePath}"${message}`, {
|
|
285
|
+
cause: fallbackError
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
|
|
290
|
+
}
|
|
291
|
+
const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
|
|
292
|
+
projectId,
|
|
293
|
+
sourcePath,
|
|
294
|
+
caseIndex,
|
|
295
|
+
...invocationIndex ? [
|
|
296
|
+
invocationIndex
|
|
297
|
+
] : []
|
|
298
|
+
])).digest('hex');
|
|
299
|
+
const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
|
|
300
|
+
projectId,
|
|
301
|
+
sourcePath,
|
|
302
|
+
...invocationIndex ? [
|
|
303
|
+
invocationIndex
|
|
304
|
+
] : []
|
|
305
|
+
])).digest('hex');
|
|
306
|
+
const normalize_normalizeSteps = normalizeSteps;
|
|
307
|
+
function parseWorkflowSource(source) {
|
|
308
|
+
const stack = [];
|
|
309
|
+
const nodes = [];
|
|
310
|
+
const sensitiveNodes = new Set();
|
|
311
|
+
const sensitiveValues = new Set();
|
|
312
|
+
const redactedPaths = new Set();
|
|
313
|
+
const sourceOffset = 0xfeff === source.charCodeAt(0) ? 1 : 0;
|
|
314
|
+
const parsed = load(source, {
|
|
315
|
+
schema: JSON_SCHEMA,
|
|
316
|
+
listener (event, state) {
|
|
317
|
+
if ('open' === event) return void stack.push({
|
|
318
|
+
start: state.position + sourceOffset,
|
|
319
|
+
end: 0,
|
|
320
|
+
value: null,
|
|
321
|
+
children: []
|
|
322
|
+
});
|
|
323
|
+
const node = stack.pop();
|
|
324
|
+
node.end = state.position + sourceOffset;
|
|
325
|
+
node.value = state.result;
|
|
326
|
+
stack.at(-1)?.children.push(node);
|
|
327
|
+
nodes.push(node);
|
|
328
|
+
if ('mapping' !== state.kind) return;
|
|
329
|
+
for(let index = 0; index < node.children.length; index += 2){
|
|
330
|
+
const key = node.children[index].value;
|
|
331
|
+
const value = node.children[index + 1];
|
|
332
|
+
if ('string' == typeof key && isReportSecretKey(key) && value) {
|
|
333
|
+
sensitiveNodes.add(value);
|
|
334
|
+
if (null !== value.value) sensitiveValues.add(value.value);
|
|
335
|
+
redactedPaths.add(`$..[${JSON.stringify(key)}]`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
1035
339
|
});
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (
|
|
1041
|
-
|
|
340
|
+
const ranges = nodes.filter((node)=>sensitiveNodes.has(node) || sensitiveValues.has(node.value)).sort((left, right)=>left.start - right.start || right.end - left.end);
|
|
341
|
+
let offset = 0;
|
|
342
|
+
let redacted = '';
|
|
343
|
+
for (const range of ranges){
|
|
344
|
+
if (range.start < offset) continue;
|
|
345
|
+
const text = source.slice(range.start, range.end);
|
|
346
|
+
const leading = text.match(/^\s*/)?.[0] ?? '';
|
|
347
|
+
const trailing = text.slice(leading.length).match(/\s*$/)?.[0] ?? '';
|
|
348
|
+
redacted += `${source.slice(offset, range.start)}${leading}"[REDACTED]"${trailing}`;
|
|
349
|
+
offset = range.end;
|
|
1042
350
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
351
|
+
redacted += source.slice(offset);
|
|
352
|
+
const value = sanitizeReportText(redacted, 65536);
|
|
353
|
+
return {
|
|
354
|
+
parsed,
|
|
355
|
+
sourceYaml: {
|
|
356
|
+
value,
|
|
357
|
+
...redactedPaths.size ? {
|
|
358
|
+
redactedPaths: [
|
|
359
|
+
...redactedPaths
|
|
360
|
+
]
|
|
361
|
+
} : {},
|
|
362
|
+
...value !== redacted ? {
|
|
363
|
+
truncatedPaths: [
|
|
364
|
+
'$'
|
|
365
|
+
]
|
|
366
|
+
} : {}
|
|
367
|
+
}
|
|
368
|
+
};
|
|
1050
369
|
}
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
}
|
|
1056
|
-
|
|
370
|
+
const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
|
|
371
|
+
const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
|
|
372
|
+
const exactProjectExpression = /^\$\{([^{}]+)\}$/;
|
|
373
|
+
const missingVariable = (kind, name, options)=>{
|
|
374
|
+
throw new WorkflowParseError(`Undefined ${kind} variable "${name}" in ${options.location.sourcePath}.`, {
|
|
375
|
+
...options.location,
|
|
376
|
+
variable: name,
|
|
377
|
+
variableKind: kind
|
|
378
|
+
});
|
|
1057
379
|
};
|
|
1058
|
-
const
|
|
1059
|
-
if (
|
|
1060
|
-
|
|
1061
|
-
assertJsonValue(value, label, new Set());
|
|
1062
|
-
return deepFreezeJson(value);
|
|
380
|
+
const projectVariable = (name, options)=>{
|
|
381
|
+
if (!Object.hasOwn(options.variables ?? {}, name)) return missingVariable('project', name, options);
|
|
382
|
+
return options.variables[name];
|
|
1063
383
|
};
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
if (
|
|
1067
|
-
test_project_rejectUnknownKeys(value, [
|
|
1068
|
-
'name',
|
|
1069
|
-
'setup'
|
|
1070
|
-
], label);
|
|
1071
|
-
if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
|
|
1072
|
-
if ('function' != typeof value.setup) throw new TypeError(`Midscene config ${label}.setup must be a function.`);
|
|
384
|
+
const environmentVariable = (name, options)=>{
|
|
385
|
+
const value = options.env?.[name];
|
|
386
|
+
if (void 0 === value) return missingVariable('environment', name, options);
|
|
1073
387
|
return value;
|
|
1074
388
|
};
|
|
1075
|
-
const
|
|
1076
|
-
if (
|
|
1077
|
-
|
|
1078
|
-
|
|
389
|
+
const interpolationValue = (name, value, options)=>{
|
|
390
|
+
if ('object' == typeof value && null !== value) throw new WorkflowParseError(`Project variable "${name}" must be a primitive when embedded in a string.`, {
|
|
391
|
+
...options.location,
|
|
392
|
+
variable: name,
|
|
393
|
+
variableKind: 'project'
|
|
394
|
+
});
|
|
395
|
+
return String(value);
|
|
1079
396
|
};
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1082
|
-
if (
|
|
397
|
+
const resolveString = (value, options)=>{
|
|
398
|
+
const exactEnv = exactEnvExpression.exec(value);
|
|
399
|
+
if (exactEnv) return environmentVariable(exactEnv[1], options);
|
|
400
|
+
const exactProject = exactProjectExpression.exec(value);
|
|
401
|
+
if (exactProject) return projectVariable(exactProject[1], options);
|
|
402
|
+
return value.replace(interpolationExpression, (_match, environmentName, projectName)=>environmentName ? environmentVariable(environmentName, options) : interpolationValue(projectName, projectVariable(projectName, options), options));
|
|
403
|
+
};
|
|
404
|
+
const resolveWorkflowVariables = (value, options)=>{
|
|
405
|
+
if ('string' == typeof value) return resolveString(value, options);
|
|
406
|
+
if (Array.isArray(value)) return value.map((child)=>resolveWorkflowVariables(child, options));
|
|
407
|
+
if ('object' == typeof value && null !== value) return Object.fromEntries(Object.entries(value).map(([key, child])=>[
|
|
408
|
+
key,
|
|
409
|
+
resolveWorkflowVariables(child, options)
|
|
410
|
+
]));
|
|
1083
411
|
return value;
|
|
1084
412
|
};
|
|
1085
|
-
|
|
1086
|
-
if (
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
})
|
|
1105
|
-
])
|
|
1106
|
-
};
|
|
413
|
+
function _define_property(obj, key, value) {
|
|
414
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
415
|
+
value: value,
|
|
416
|
+
enumerable: true,
|
|
417
|
+
configurable: true,
|
|
418
|
+
writable: true
|
|
419
|
+
});
|
|
420
|
+
else obj[key] = value;
|
|
421
|
+
return obj;
|
|
422
|
+
}
|
|
423
|
+
const isMapping = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
424
|
+
class NativeWorkflowFormatError extends WorkflowParseError {
|
|
425
|
+
constructor(actualFormat, sourcePath){
|
|
426
|
+
super('legacy' === actualFormat ? `Legacy tasks/flow YAML is not supported by midscene-test: ${sourcePath}. Run it with \`midscene ${sourcePath}\`, or migrate the file to cases/steps.` : 'legacy-batch' === actualFormat ? `Legacy YAML batch configuration is not a midscene-test workflow document: ${sourcePath}. Run it with \`midscene --config ${sourcePath}\`, or migrate it to a native project config.` : `A workflow file cannot mix legacy tasks/flow with native cases/steps or lifecycle hooks: ${sourcePath}. Keep the formats in separate files and run each file with its matching command.`, {
|
|
427
|
+
sourcePath,
|
|
428
|
+
actualFormat,
|
|
429
|
+
expectedFormat: 'native'
|
|
430
|
+
}), _define_property(this, "actualFormat", void 0);
|
|
431
|
+
this.actualFormat = actualFormat;
|
|
1107
432
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
const
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
433
|
+
}
|
|
434
|
+
const collect_rejectUnknownKeys = (value, allowed, label)=>{
|
|
435
|
+
const allowedSet = new Set(allowed);
|
|
436
|
+
const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
|
|
437
|
+
if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
|
|
438
|
+
field: unknown[0]
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
|
|
442
|
+
const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
|
|
443
|
+
function collectWorkflowDocument(source, options) {
|
|
444
|
+
let snapshot;
|
|
445
|
+
try {
|
|
446
|
+
snapshot = parseWorkflowSource(readFileSync(source.absolutePath, 'utf8'));
|
|
447
|
+
} catch (error) {
|
|
448
|
+
throw new WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
|
|
449
|
+
sourcePath: source.sourcePath
|
|
450
|
+
}, error);
|
|
451
|
+
}
|
|
452
|
+
const { parsed, sourceYaml } = snapshot;
|
|
453
|
+
if (!isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
|
|
454
|
+
const format = classifyWorkflowDocumentFormat(parsed);
|
|
455
|
+
if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
|
|
456
|
+
collect_rejectUnknownKeys(parsed, [
|
|
457
|
+
'beforeAll',
|
|
458
|
+
'beforeEach',
|
|
459
|
+
'cases',
|
|
460
|
+
'afterEach',
|
|
461
|
+
'afterAll'
|
|
462
|
+
], 'Workflow document');
|
|
463
|
+
if (!Array.isArray(parsed.cases) || 0 === parsed.cases.length) throw new WorkflowParseError('Workflow document must contain a non-empty cases array.');
|
|
464
|
+
const sourcePath = source.sourcePath.split(sep).join('/');
|
|
465
|
+
const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
|
|
466
|
+
...step,
|
|
467
|
+
templateInput: step.input,
|
|
468
|
+
input: resolveWorkflowVariables(step.input, {
|
|
469
|
+
variables: options.variables,
|
|
470
|
+
env: options.env ?? process.env,
|
|
471
|
+
location: {
|
|
472
|
+
projectName: source.projectName ?? source.projectId,
|
|
473
|
+
sourcePath,
|
|
474
|
+
phase,
|
|
475
|
+
stepIndex,
|
|
476
|
+
...void 0 === caseIndex ? {} : {
|
|
477
|
+
caseIndex
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
});
|
|
482
|
+
const normalizeLifecycle = (phase)=>{
|
|
483
|
+
const value = parsed[phase];
|
|
484
|
+
if (void 0 === value) return [];
|
|
485
|
+
if (!Array.isArray(value)) throw new WorkflowParseError(`Workflow document ${phase} must be an array.`, {
|
|
486
|
+
phase
|
|
487
|
+
});
|
|
488
|
+
return normalize_normalizeSteps(value, options.resolveNode).map((normalized, stepIndex)=>{
|
|
489
|
+
const resolved = options.resolveNode(normalized.node);
|
|
490
|
+
if (!resolved) throw new WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
|
|
491
|
+
phase,
|
|
492
|
+
stepIndex,
|
|
493
|
+
node: normalized.node
|
|
494
|
+
});
|
|
495
|
+
return resolveStepVariables(normalized, phase, stepIndex);
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
const lifecycle = {
|
|
499
|
+
beforeAll: normalizeLifecycle('beforeAll'),
|
|
500
|
+
beforeEach: normalizeLifecycle('beforeEach'),
|
|
501
|
+
afterEach: normalizeLifecycle('afterEach'),
|
|
502
|
+
afterAll: normalizeLifecycle('afterAll')
|
|
503
|
+
};
|
|
504
|
+
const ids = new Set();
|
|
505
|
+
const cases = parsed.cases.map((definition, caseIndex)=>{
|
|
506
|
+
if (!isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
|
|
507
|
+
caseIndex
|
|
508
|
+
});
|
|
509
|
+
collect_rejectUnknownKeys(definition, [
|
|
1115
510
|
'name',
|
|
1116
|
-
'setup',
|
|
1117
|
-
'nodes',
|
|
1118
|
-
'files',
|
|
1119
511
|
'tags',
|
|
1120
|
-
'
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
} : {},
|
|
1140
|
-
tags: validateTagSelection(candidate.tags, `${label}.tags`),
|
|
1141
|
-
retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
|
|
1142
|
-
variables: validateVariables(candidate.variables, `${label}.variables`),
|
|
1143
|
-
...void 0 === candidate.setup ? {} : {
|
|
1144
|
-
setup: validateSetup(candidate.setup, `${label}.setup`)
|
|
1145
|
-
}
|
|
512
|
+
'steps'
|
|
513
|
+
], `Case ${caseIndex + 1}`);
|
|
514
|
+
if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
|
|
515
|
+
caseIndex
|
|
516
|
+
});
|
|
517
|
+
if (!Array.isArray(definition.steps) || 0 === definition.steps.length) throw new WorkflowParseError(`Case ${caseIndex + 1} steps must be a non-empty array.`, {
|
|
518
|
+
caseIndex
|
|
519
|
+
});
|
|
520
|
+
const tags = definition.tags ?? [];
|
|
521
|
+
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
|
+
caseIndex
|
|
523
|
+
});
|
|
524
|
+
const steps = normalize_normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
|
|
525
|
+
if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
|
|
526
|
+
caseIndex,
|
|
527
|
+
stepIndex,
|
|
528
|
+
node: normalized.node
|
|
529
|
+
});
|
|
530
|
+
return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
|
|
1146
531
|
});
|
|
532
|
+
const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
|
|
533
|
+
if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
|
|
534
|
+
caseId
|
|
535
|
+
});
|
|
536
|
+
ids.add(caseId);
|
|
537
|
+
return {
|
|
538
|
+
caseId,
|
|
539
|
+
projectId: source.projectId,
|
|
540
|
+
sourcePath,
|
|
541
|
+
caseIndex,
|
|
542
|
+
definition: {
|
|
543
|
+
name: definition.name,
|
|
544
|
+
tags: tags,
|
|
545
|
+
steps
|
|
546
|
+
}
|
|
547
|
+
};
|
|
1147
548
|
});
|
|
1148
549
|
return {
|
|
1149
|
-
|
|
1150
|
-
|
|
550
|
+
documentId: createWorkflowDocumentId(source.projectId, sourcePath),
|
|
551
|
+
projectId: source.projectId,
|
|
552
|
+
sourcePath,
|
|
553
|
+
sourceYaml,
|
|
554
|
+
lifecycle,
|
|
555
|
+
cases
|
|
1151
556
|
};
|
|
557
|
+
}
|
|
558
|
+
const writeJson = async (path, value)=>{
|
|
559
|
+
try {
|
|
560
|
+
await mkdir(dirname(path), {
|
|
561
|
+
recursive: true
|
|
562
|
+
});
|
|
563
|
+
await writeFile(path, JSON.stringify(value, null, 2));
|
|
564
|
+
} catch (error) {
|
|
565
|
+
throw new WorkflowPublicationError('write-result', path, error);
|
|
566
|
+
}
|
|
1152
567
|
};
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout'),
|
|
1162
|
-
executionUnit,
|
|
1163
|
-
executorRetry: validateNonNegativeInteger(candidate.executorRetry, 0, 'test.executorRetry')
|
|
568
|
+
const writeTestRunPlan = (runDir, projects)=>writeJson(join(runDir, 'plan.json'), {
|
|
569
|
+
schemaVersion: 1,
|
|
570
|
+
createdAt: new Date().toISOString(),
|
|
571
|
+
projects: projects.map((item)=>({
|
|
572
|
+
projectId: item.project.projectId,
|
|
573
|
+
name: item.project.name,
|
|
574
|
+
documents: item.plans ?? []
|
|
575
|
+
}))
|
|
1164
576
|
});
|
|
577
|
+
const toPosix = (value)=>value.split(sep).join('/');
|
|
578
|
+
const caseAttemptResultPath = (projectId, documentId, result)=>toPosix(join(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
|
|
579
|
+
const workflowDocumentResultPath = (result)=>toPosix(join(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
|
|
580
|
+
const collectionErrorPath = (projectId, sourcePath)=>{
|
|
581
|
+
const id = createHash('sha256').update(JSON.stringify([
|
|
582
|
+
projectId,
|
|
583
|
+
sourcePath
|
|
584
|
+
])).digest('hex');
|
|
585
|
+
return toPosix(join(projectId, 'collection-errors', `${id}.json`));
|
|
1165
586
|
};
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
test_project_rejectUnknownKeys(value, [
|
|
1170
|
-
'name',
|
|
1171
|
-
'execute'
|
|
1172
|
-
], 'executor');
|
|
1173
|
-
if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError('Midscene config executor.name must be non-empty.');
|
|
1174
|
-
if ('function' != typeof value.execute) throw new TypeError('Midscene config executor.execute must be a function.');
|
|
1175
|
-
return Object.freeze(value);
|
|
587
|
+
const writeWorkflowDocumentResult = (runDir, result)=>{
|
|
588
|
+
const { documentRunId: _documentRunId, ...persistedResult } = result;
|
|
589
|
+
return writeJson(join(runDir, workflowDocumentResultPath(result)), persistedResult);
|
|
1176
590
|
};
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
591
|
+
const writeCaseAttemptResult = (runDir, projectId, documentId, result)=>{
|
|
592
|
+
const { runId: attemptId, ...persistedResult } = result;
|
|
593
|
+
return writeJson(join(runDir, caseAttemptResultPath(projectId, documentId, result)), {
|
|
594
|
+
...persistedResult,
|
|
595
|
+
attemptId
|
|
596
|
+
});
|
|
1181
597
|
};
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
test_project_rejectUnknownKeys(candidate, [
|
|
1186
|
-
'reportDir'
|
|
1187
|
-
], 'output');
|
|
1188
|
-
return Object.freeze({
|
|
1189
|
-
reportDir: validateOutputPath(candidate.reportDir, './midscene_run/report', 'output.reportDir')
|
|
598
|
+
const writeCollectionError = (runDir, collectionError)=>writeJson(join(runDir, collectionErrorPath(collectionError.projectId, collectionError.sourcePath)), {
|
|
599
|
+
kind: 'collection-error',
|
|
600
|
+
...collectionError
|
|
1190
601
|
});
|
|
602
|
+
const relativeToSummary = (summaryPath, target)=>{
|
|
603
|
+
const value = relative(dirname(summaryPath), target);
|
|
604
|
+
return toPosix(value || '.');
|
|
1191
605
|
};
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
'executor'
|
|
1207
|
-
], 'root');
|
|
1208
|
-
const nodes = new registry_NodeRegistry(definition.nodes);
|
|
1209
|
-
const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
|
|
1210
|
-
const test = validateTestOptions(definition.test);
|
|
1211
|
-
const executor = validateTestExecutor(definition.executor);
|
|
1212
|
-
if (executor && 'case' !== test.executionUnit) throw new TypeError('Midscene config executor requires test.executionUnit to be "case".');
|
|
1213
|
-
return {
|
|
1214
|
-
...resolvedProjects,
|
|
1215
|
-
test,
|
|
1216
|
-
output: validateOutput(definition.output),
|
|
1217
|
-
nodes,
|
|
1218
|
-
...executor ? {
|
|
1219
|
-
executor
|
|
606
|
+
const errorJson = sanitizeReportError;
|
|
607
|
+
const writeTestProjectRunResult = (options)=>{
|
|
608
|
+
const { result } = options;
|
|
609
|
+
const fact = (path)=>toPosix(path);
|
|
610
|
+
return writeJson(result.summaryPath, {
|
|
611
|
+
schemaVersion: result.schemaVersion,
|
|
612
|
+
runId: result.runId,
|
|
613
|
+
startedAt: result.startedAt,
|
|
614
|
+
endedAt: result.endedAt,
|
|
615
|
+
durationMs: result.durationMs,
|
|
616
|
+
status: result.status,
|
|
617
|
+
exitCode: result.exitCode,
|
|
618
|
+
...result.errors?.length ? {
|
|
619
|
+
errors: result.errors.map(errorJson)
|
|
1220
620
|
} : {},
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
621
|
+
projectRoot: options.projectRoot,
|
|
622
|
+
...options.configPath ? {
|
|
623
|
+
configPath: options.configPath
|
|
624
|
+
} : {},
|
|
625
|
+
factsRoot: '.',
|
|
626
|
+
plan: 'plan.json',
|
|
627
|
+
...result.operationLog ? {
|
|
628
|
+
operationLog: {
|
|
629
|
+
...result.operationLog,
|
|
630
|
+
path: relativeToSummary(result.summaryPath, result.operationLog.path)
|
|
631
|
+
}
|
|
632
|
+
} : {},
|
|
633
|
+
reportDir: relativeToSummary(result.summaryPath, result.reportDir),
|
|
634
|
+
...result.reportPath ? {
|
|
635
|
+
report: relativeToSummary(result.summaryPath, result.reportPath)
|
|
636
|
+
} : {},
|
|
637
|
+
...result.markdownPath ? {
|
|
638
|
+
markdown: relativeToSummary(result.summaryPath, result.markdownPath)
|
|
639
|
+
} : {},
|
|
640
|
+
summary: result.summary,
|
|
641
|
+
projects: result.projects.map((project)=>({
|
|
642
|
+
projectId: project.projectId,
|
|
643
|
+
name: project.name,
|
|
644
|
+
status: project.status,
|
|
645
|
+
retry: project.retry,
|
|
646
|
+
fileSelection: project.fileSelection,
|
|
647
|
+
tagSelection: project.tagSelection,
|
|
648
|
+
sourceCount: project.sourceCount,
|
|
649
|
+
selectedCaseCount: project.selectedCaseCount,
|
|
650
|
+
filteredCaseCount: project.filteredCaseCount,
|
|
651
|
+
...project.lifecycle ? {
|
|
652
|
+
lifecycle: {
|
|
653
|
+
status: project.lifecycle.status,
|
|
654
|
+
startedAt: project.lifecycle.startedAt,
|
|
655
|
+
endedAt: project.lifecycle.endedAt,
|
|
656
|
+
durationMs: project.lifecycle.durationMs,
|
|
657
|
+
...project.lifecycle.cleanup ? {
|
|
658
|
+
cleanup: project.lifecycle.cleanup
|
|
659
|
+
} : {},
|
|
660
|
+
...project.lifecycle.pendingOperations ? {
|
|
661
|
+
pendingOperations: project.lifecycle.pendingOperations
|
|
662
|
+
} : {},
|
|
663
|
+
...project.lifecycle.setupError ? {
|
|
664
|
+
setupError: errorJson(project.lifecycle.setupError)
|
|
665
|
+
} : {},
|
|
666
|
+
...project.lifecycle.teardownErrors ? {
|
|
667
|
+
teardownErrors: project.lifecycle.teardownErrors.map(errorJson)
|
|
668
|
+
} : {}
|
|
669
|
+
}
|
|
670
|
+
} : {},
|
|
671
|
+
cases: project.cases.map((outcome)=>({
|
|
672
|
+
...outcome.documentRunId ? {
|
|
673
|
+
documentRunId: outcome.documentRunId
|
|
674
|
+
} : {},
|
|
675
|
+
documentId: outcome.documentId,
|
|
676
|
+
caseId: outcome.caseId,
|
|
677
|
+
sourcePath: outcome.sourcePath,
|
|
678
|
+
caseIndex: outcome.caseIndex,
|
|
679
|
+
name: outcome.name,
|
|
680
|
+
status: outcome.status,
|
|
681
|
+
...outcome.notRunReason ? {
|
|
682
|
+
notRunReason: outcome.notRunReason
|
|
683
|
+
} : {},
|
|
684
|
+
...outcome.attempts ? {
|
|
685
|
+
attempts: outcome.attempts.map((attempt)=>({
|
|
686
|
+
attemptId: attempt.runId,
|
|
687
|
+
attemptIndex: attempt.attemptIndex,
|
|
688
|
+
status: attempt.status,
|
|
689
|
+
...attempt.cleanup ? {
|
|
690
|
+
cleanup: attempt.cleanup
|
|
691
|
+
} : {},
|
|
692
|
+
...attempt.pendingOperations ? {
|
|
693
|
+
pendingOperations: attempt.pendingOperations
|
|
694
|
+
} : {},
|
|
695
|
+
...attempt.executionStatus ? {
|
|
696
|
+
executionStatus: attempt.executionStatus
|
|
697
|
+
} : {},
|
|
698
|
+
...attempt.retryDecision ? {
|
|
699
|
+
retryDecision: attempt.retryDecision
|
|
700
|
+
} : {},
|
|
701
|
+
resultFile: fact(caseAttemptResultPath(project.projectId, outcome.documentId, attempt)),
|
|
702
|
+
...attempt.reportPaths?.length ? {
|
|
703
|
+
reports: attempt.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
|
|
704
|
+
} : {}
|
|
705
|
+
}))
|
|
706
|
+
} : {}
|
|
707
|
+
})),
|
|
708
|
+
documents: project.documents.map((document)=>({
|
|
709
|
+
documentId: document.documentId,
|
|
710
|
+
...void 0 === document.attemptIndex ? {} : {
|
|
711
|
+
attemptIndex: document.attemptIndex,
|
|
712
|
+
documentRunId: document.documentRunId
|
|
713
|
+
},
|
|
714
|
+
sourcePath: document.sourcePath,
|
|
715
|
+
status: document.status,
|
|
716
|
+
...document.cleanup ? {
|
|
717
|
+
cleanup: document.cleanup
|
|
718
|
+
} : {},
|
|
719
|
+
...document.pendingOperations ? {
|
|
720
|
+
pendingOperations: document.pendingOperations
|
|
721
|
+
} : {},
|
|
722
|
+
...document.executionStatus ? {
|
|
723
|
+
executionStatus: document.executionStatus
|
|
724
|
+
} : {},
|
|
725
|
+
...document.retryDecision ? {
|
|
726
|
+
retryDecision: document.retryDecision
|
|
727
|
+
} : {},
|
|
728
|
+
resultFile: fact(workflowDocumentResultPath(document)),
|
|
729
|
+
...document.reportPaths?.length ? {
|
|
730
|
+
reports: document.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
|
|
731
|
+
} : {}
|
|
732
|
+
})),
|
|
733
|
+
collectionErrors: project.collectionErrors.map((error)=>({
|
|
734
|
+
sourcePath: error.sourcePath,
|
|
735
|
+
errorFile: fact(collectionErrorPath(error.projectId, error.sourcePath))
|
|
736
|
+
}))
|
|
737
|
+
}))
|
|
1239
738
|
});
|
|
1240
|
-
|
|
1241
|
-
const extension = assertSupportedConfig(absolutePath);
|
|
1242
|
-
let loaded;
|
|
1243
|
-
try {
|
|
1244
|
-
if ('.mjs' === extension) loaded = await import(pathToFileURL(absolutePath).href);
|
|
1245
|
-
else {
|
|
1246
|
-
const { tsImport } = await import("tsx/esm/api");
|
|
1247
|
-
loaded = await tsImport(pathToFileURL(absolutePath).href, {
|
|
1248
|
-
parentURL: pathToFileURL(`${dirname(absolutePath)}${sep}`).href,
|
|
1249
|
-
tsconfig: false
|
|
1250
|
-
});
|
|
1251
|
-
}
|
|
1252
|
-
} catch (error) {
|
|
1253
|
-
try {
|
|
1254
|
-
if ('.mjs' === extension || !canRetryWithCjsLoader(error)) throw error;
|
|
1255
|
-
loaded = api_require(absolutePath, pathToFileURL(absolutePath));
|
|
1256
|
-
} catch (fallbackError) {
|
|
1257
|
-
const message = fallbackError instanceof Error ? `: ${fallbackError.message}` : '';
|
|
1258
|
-
throw new Error(`Failed to load Midscene config "${absolutePath}"${message}`, {
|
|
1259
|
-
cause: fallbackError
|
|
1260
|
-
});
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
|
|
1264
|
-
}
|
|
739
|
+
};
|
|
1265
740
|
const CONFIG_NAMES = [
|
|
1266
741
|
'midscene.config.ts',
|
|
1267
742
|
'midscene.config.mjs'
|
|
@@ -1283,29 +758,8 @@ const DEFAULT_TEST_FILE_SELECTION = {
|
|
|
1283
758
|
]
|
|
1284
759
|
};
|
|
1285
760
|
const project_preparation_toPosix = (value)=>value.split(sep).join('/');
|
|
1286
|
-
const discoverSelectedTestFiles = (projectRoot, paths)=>{
|
|
1287
|
-
const root = external_node_path_resolve(projectRoot);
|
|
1288
|
-
const files = new Set();
|
|
1289
|
-
for (const requestedPath of paths){
|
|
1290
|
-
if ('string' != typeof requestedPath || 0 === requestedPath.trim().length) throw new TypeError('Test selection paths must be non-empty strings.');
|
|
1291
|
-
const absolutePath = external_node_path_resolve(root, requestedPath);
|
|
1292
|
-
const relativePath = relative(root, absolutePath);
|
|
1293
|
-
if ('..' === relativePath || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) throw new Error(`Test selection path is outside the project: ${requestedPath}`);
|
|
1294
|
-
if (!existsSync(absolutePath)) throw new Error(`Test selection path does not exist: ${requestedPath}`);
|
|
1295
|
-
if (statSync(absolutePath).isDirectory()) for (const file of discoverTestFiles(absolutePath))files.add(file);
|
|
1296
|
-
else if (/\.ya?ml$/i.test(absolutePath)) files.add(absolutePath);
|
|
1297
|
-
else throw new Error(`Test selection file must be YAML: ${requestedPath}`);
|
|
1298
|
-
}
|
|
1299
|
-
return [
|
|
1300
|
-
...files
|
|
1301
|
-
].sort((a, b)=>{
|
|
1302
|
-
const relativeA = project_preparation_toPosix(relative(root, a));
|
|
1303
|
-
const relativeB = project_preparation_toPosix(relative(root, b));
|
|
1304
|
-
return relativeA < relativeB ? -1 : relativeA > relativeB ? 1 : 0;
|
|
1305
|
-
});
|
|
1306
|
-
};
|
|
1307
761
|
const discoverResolvedTestFiles = (projectRoot, selection)=>{
|
|
1308
|
-
const root =
|
|
762
|
+
const root = resolve(projectRoot);
|
|
1309
763
|
const match = (patterns)=>globSync(patterns, {
|
|
1310
764
|
absolute: true,
|
|
1311
765
|
caseSensitiveMatch: false,
|
|
@@ -1321,7 +775,7 @@ const discoverResolvedTestFiles = (projectRoot, selection)=>{
|
|
|
1321
775
|
}).filter((file)=>/\.ya?ml$/i.test(file));
|
|
1322
776
|
const files = match(selection.include);
|
|
1323
777
|
return [
|
|
1324
|
-
...new Set(files.map((file)=>
|
|
778
|
+
...new Set(files.map((file)=>resolve(file)))
|
|
1325
779
|
].sort((a, b)=>{
|
|
1326
780
|
const relativeA = project_preparation_toPosix(relative(root, a));
|
|
1327
781
|
const relativeB = project_preparation_toPosix(relative(root, b));
|
|
@@ -1334,7 +788,7 @@ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)
|
|
|
1334
788
|
return discoverResolvedTestFiles(projectRoot, normalized);
|
|
1335
789
|
};
|
|
1336
790
|
const discoverTestConfig = (projectRoot)=>{
|
|
1337
|
-
const root =
|
|
791
|
+
const root = resolve(projectRoot);
|
|
1338
792
|
const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
|
|
1339
793
|
const supported = candidates.filter((name)=>CONFIG_NAMES.includes(name));
|
|
1340
794
|
const unsupported = candidates.filter((name)=>!CONFIG_NAMES.includes(name));
|
|
@@ -1363,14 +817,8 @@ const matchesTags = (tags, selection)=>{
|
|
|
1363
817
|
if (selection.exclude.some((tag)=>tags.includes(tag))) return false;
|
|
1364
818
|
return 0 === selection.include.length || selection.include.some((tag)=>tags.includes(tag));
|
|
1365
819
|
};
|
|
1366
|
-
const filterDocumentCases = (document, project
|
|
1367
|
-
const cases = document.cases.filter((item)=>
|
|
1368
|
-
const tags = item.definition.tags ?? [];
|
|
1369
|
-
return matchesTags(tags, project.tags) && matchesTags(tags, {
|
|
1370
|
-
include: selection?.tags?.include ?? [],
|
|
1371
|
-
exclude: selection?.tags?.exclude ?? []
|
|
1372
|
-
}) && (!selection?.caseIds?.length || selection.caseIds.includes(item.caseId));
|
|
1373
|
-
});
|
|
820
|
+
const filterDocumentCases = (document, project)=>{
|
|
821
|
+
const cases = document.cases.filter((item)=>matchesTags(item.definition.tags ?? [], project.tags));
|
|
1374
822
|
const filtered = document.cases.length - cases.length;
|
|
1375
823
|
return 0 === cases.length ? {
|
|
1376
824
|
filtered
|
|
@@ -1394,10 +842,8 @@ const selectProjects = (projects, names)=>{
|
|
|
1394
842
|
async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
|
|
1395
843
|
const platform = options.platform?.trim();
|
|
1396
844
|
const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
|
|
1397
|
-
const prerequisiteFile = options.prerequisiteFile ?
|
|
1398
|
-
const
|
|
1399
|
-
const selectedFiles = options.selection?.paths?.length ? new Set(discoverSelectedTestFiles(projectRoot, options.selection.paths)) : void 0;
|
|
1400
|
-
const mainFiles = selectedFiles ? projectFiles.filter((file)=>selectedFiles.has(file)) : projectFiles;
|
|
845
|
+
const prerequisiteFile = options.prerequisiteFile ? resolve(options.prerequisiteFile) : void 0;
|
|
846
|
+
const mainFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
|
|
1401
847
|
const files = [
|
|
1402
848
|
...prerequisiteFile ? [
|
|
1403
849
|
prerequisiteFile
|
|
@@ -1420,7 +866,6 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
1420
866
|
const invocations = [];
|
|
1421
867
|
const plans = [];
|
|
1422
868
|
let filteredCaseCount = 0;
|
|
1423
|
-
const availableCaseIds = new Set();
|
|
1424
869
|
if (0 === sources.length) {
|
|
1425
870
|
const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
|
|
1426
871
|
projectName: project.name,
|
|
@@ -1451,16 +896,10 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
1451
896
|
variables: project.variables,
|
|
1452
897
|
env: process.env
|
|
1453
898
|
});
|
|
1454
|
-
const duplicate = document.cases.find((item)=>availableCaseIds.has(item.caseId));
|
|
1455
|
-
if (duplicate) throw new WorkflowParseError(`Case id collision in project "${project.name}": ${duplicate.caseId}.`, {
|
|
1456
|
-
caseId: duplicate.caseId,
|
|
1457
|
-
projectName: project.name
|
|
1458
|
-
});
|
|
1459
|
-
for (const item of document.cases)availableCaseIds.add(item.caseId);
|
|
1460
899
|
const filtered = source.absolutePath === prerequisiteFile ? {
|
|
1461
900
|
document,
|
|
1462
901
|
filtered: 0
|
|
1463
|
-
} : filterDocumentCases(document, project
|
|
902
|
+
} : filterDocumentCases(document, project);
|
|
1464
903
|
const plan = buildTestRunReportPlan(document, {
|
|
1465
904
|
selectedCaseIds: filtered.document?.cases.map((item)=>item.caseId) ?? [],
|
|
1466
905
|
defaultTimeoutMs,
|
|
@@ -1508,42 +947,9 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
|
|
|
1508
947
|
plans,
|
|
1509
948
|
collectionErrors,
|
|
1510
949
|
selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
|
|
1511
|
-
filteredCaseCount
|
|
1512
|
-
availableCaseIds: [
|
|
1513
|
-
...availableCaseIds
|
|
1514
|
-
]
|
|
950
|
+
filteredCaseCount
|
|
1515
951
|
};
|
|
1516
952
|
}
|
|
1517
|
-
const validateUniqueStrings = (values, label)=>{
|
|
1518
|
-
if (void 0 === values) return;
|
|
1519
|
-
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.`);
|
|
1520
|
-
if (new Set(values).size !== values.length) throw new TypeError(`${label} must not contain duplicates.`);
|
|
1521
|
-
return values;
|
|
1522
|
-
};
|
|
1523
|
-
const validateRunSelection = (options)=>{
|
|
1524
|
-
const paths = validateUniqueStrings(options.paths, 'Test selection paths');
|
|
1525
|
-
const caseIds = validateUniqueStrings(options.caseIds, 'Test selection caseIds');
|
|
1526
|
-
const include = validateUniqueStrings(options.tags?.include, 'Test selection tags.include');
|
|
1527
|
-
const exclude = validateUniqueStrings(options.tags?.exclude, 'Test selection tags.exclude');
|
|
1528
|
-
return {
|
|
1529
|
-
...paths ? {
|
|
1530
|
-
paths
|
|
1531
|
-
} : {},
|
|
1532
|
-
...caseIds ? {
|
|
1533
|
-
caseIds
|
|
1534
|
-
} : {},
|
|
1535
|
-
...include || exclude ? {
|
|
1536
|
-
tags: {
|
|
1537
|
-
...include ? {
|
|
1538
|
-
include
|
|
1539
|
-
} : {},
|
|
1540
|
-
...exclude ? {
|
|
1541
|
-
exclude
|
|
1542
|
-
} : {}
|
|
1543
|
-
}
|
|
1544
|
-
} : {}
|
|
1545
|
-
};
|
|
1546
|
-
};
|
|
1547
953
|
const padDatePart = (value)=>String(value).padStart(2, '0');
|
|
1548
954
|
const createTestRunId = (date = new Date(), uuid = randomUUID())=>{
|
|
1549
955
|
const timestamp = [
|
|
@@ -1560,9 +966,9 @@ const assertDirectory = (path, label)=>{
|
|
|
1560
966
|
if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
|
|
1561
967
|
};
|
|
1562
968
|
const resolveRunInput = (options)=>{
|
|
1563
|
-
const cwd =
|
|
969
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
1564
970
|
assertDirectory(cwd, 'Test working directory');
|
|
1565
|
-
const inputPath = options.projectRoot ?
|
|
971
|
+
const inputPath = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
|
|
1566
972
|
const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
|
|
1567
973
|
if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
|
|
1568
974
|
const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
|
|
@@ -1581,34 +987,24 @@ async function prepareTestRun(options) {
|
|
|
1581
987
|
const startedAt = new Date();
|
|
1582
988
|
const runId = createTestRunId(startedAt);
|
|
1583
989
|
const input = resolveRunInput(options);
|
|
1584
|
-
const
|
|
1585
|
-
const configPath = options.configPath ? external_node_path_resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
|
|
990
|
+
const configPath = options.configPath ? resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
|
|
1586
991
|
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}\`.`);
|
|
1587
992
|
if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
|
|
1588
993
|
const definition = await loadTestProject(configPath);
|
|
1589
|
-
const resultDir = options.resultDir ?
|
|
994
|
+
const resultDir = options.resultDir ? resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
|
|
1590
995
|
const runDir = join(resultDir, runId);
|
|
1591
996
|
const summaryPath = join(runDir, 'summary.json');
|
|
1592
|
-
const reportDir =
|
|
997
|
+
const reportDir = resolve(input.projectRoot, definition.output.reportDir);
|
|
1593
998
|
mkdirSync(resultDir, {
|
|
1594
999
|
recursive: true
|
|
1595
1000
|
});
|
|
1596
1001
|
mkdirSync(runDir);
|
|
1597
1002
|
const projects = [];
|
|
1598
|
-
for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, {
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
]
|
|
1604
|
-
} : {}
|
|
1605
|
-
}));
|
|
1606
|
-
if (selection.caseIds?.length) {
|
|
1607
|
-
const availableCaseIds = new Set(projects.flatMap((project)=>project.availableCaseIds ?? []));
|
|
1608
|
-
const unknown = selection.caseIds.find((id)=>!availableCaseIds.has(id));
|
|
1609
|
-
if (unknown) throw new Error(`Unknown Midscene case id: ${unknown}`);
|
|
1610
|
-
}
|
|
1611
|
-
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.');
|
|
1003
|
+
for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, input.singleFile ? {
|
|
1004
|
+
files: [
|
|
1005
|
+
input.singleFile
|
|
1006
|
+
]
|
|
1007
|
+
} : void 0));
|
|
1612
1008
|
const hasFormatMismatch = projects.some((project)=>project.collectionErrors.some(({ error })=>error instanceof NativeWorkflowFormatError));
|
|
1613
1009
|
return {
|
|
1614
1010
|
startedAt,
|
|
@@ -1704,601 +1100,20 @@ async function writeTestResultMarkdown(result, index = emptyIndex()) {
|
|
|
1704
1100
|
}));
|
|
1705
1101
|
return output;
|
|
1706
1102
|
}
|
|
1707
|
-
const
|
|
1708
|
-
documentId,
|
|
1709
|
-
caseId: collectedCase.caseId,
|
|
1710
|
-
projectName,
|
|
1711
|
-
name: collectedCase.definition.name,
|
|
1712
|
-
sourcePath: collectedCase.sourcePath,
|
|
1713
|
-
caseIndex: collectedCase.caseIndex,
|
|
1714
|
-
status: 'not-run',
|
|
1715
|
-
notRunReason: reason
|
|
1716
|
-
});
|
|
1103
|
+
const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
|
|
1717
1104
|
const formatStep = (info)=>{
|
|
1718
|
-
const position =
|
|
1105
|
+
const position = stepPosition(info);
|
|
1719
1106
|
const phase = 'steps' === position.phase ? 'step' : position.phase;
|
|
1720
1107
|
return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
|
|
1721
1108
|
};
|
|
1722
|
-
const formatStepResult = (info, result
|
|
1109
|
+
const formatStepResult = (info, result)=>{
|
|
1110
|
+
const indent = 'case' === info.scope ? ' ' : ' ';
|
|
1723
1111
|
if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
|
|
1724
1112
|
const symbol = 'success' === result.status ? '✓' : '✗';
|
|
1725
1113
|
const error = result.error ? ` — ${result.error.message}` : '';
|
|
1726
1114
|
const continuation = result.continuedAfterError ? '; continuing' : '';
|
|
1727
1115
|
return `${indent}${symbol} ${formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
|
|
1728
1116
|
};
|
|
1729
|
-
const latestById = (items, id)=>[
|
|
1730
|
-
...new Map(items.map((item)=>[
|
|
1731
|
-
id(item),
|
|
1732
|
-
item
|
|
1733
|
-
])).values()
|
|
1734
|
-
];
|
|
1735
|
-
const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
|
|
1736
|
-
const { project } = prepared;
|
|
1737
|
-
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';
|
|
1738
|
-
return {
|
|
1739
|
-
projectId: project.projectId,
|
|
1740
|
-
name: project.name,
|
|
1741
|
-
...prepared.platform ? {
|
|
1742
|
-
platform: prepared.platform
|
|
1743
|
-
} : {},
|
|
1744
|
-
status: projectFailed ? 'failed' : 'success',
|
|
1745
|
-
retry: project.retry,
|
|
1746
|
-
fileSelection: prepared.fileSelection,
|
|
1747
|
-
tagSelection: project.tags,
|
|
1748
|
-
sourceCount: prepared.sources.length,
|
|
1749
|
-
selectedCaseCount: prepared.selectedCaseCount,
|
|
1750
|
-
filteredCaseCount: prepared.filteredCaseCount,
|
|
1751
|
-
...lifecycle ? {
|
|
1752
|
-
lifecycle
|
|
1753
|
-
} : {},
|
|
1754
|
-
cases,
|
|
1755
|
-
plans: prepared.plans,
|
|
1756
|
-
documents,
|
|
1757
|
-
collectionErrors: prepared.collectionErrors
|
|
1758
|
-
};
|
|
1759
|
-
};
|
|
1760
|
-
function createExecutorReports(outputDir) {
|
|
1761
|
-
const paths = new Map();
|
|
1762
|
-
const sources = new Map();
|
|
1763
|
-
return {
|
|
1764
|
-
materializeSource (source) {
|
|
1765
|
-
if (!source.dumpPath) {
|
|
1766
|
-
const reference = this.materialize(source.sourcePath);
|
|
1767
|
-
sources.set(reference, {
|
|
1768
|
-
sourcePath: this.resolve(reference),
|
|
1769
|
-
collection: source.collection
|
|
1770
|
-
});
|
|
1771
|
-
return reference;
|
|
1772
|
-
}
|
|
1773
|
-
const dump = realpathSync(source.dumpPath);
|
|
1774
|
-
const reference = randomUUID();
|
|
1775
|
-
const destination = join(outputDir, 'reports', reference);
|
|
1776
|
-
mkdirSync(destination, {
|
|
1777
|
-
recursive: true
|
|
1778
|
-
});
|
|
1779
|
-
const dumpPath = join(destination, 'snapshot.json');
|
|
1780
|
-
cpSync(dump, dumpPath);
|
|
1781
|
-
const screenshots = join(dirname(dump), 'screenshots');
|
|
1782
|
-
if (existsSync(screenshots)) cpSync(screenshots, join(destination, 'screenshots'), {
|
|
1783
|
-
recursive: true,
|
|
1784
|
-
dereference: true
|
|
1785
|
-
});
|
|
1786
|
-
const sourcePath = join(destination, 'index.html');
|
|
1787
|
-
sources.set(reference, {
|
|
1788
|
-
sourcePath,
|
|
1789
|
-
dumpPath,
|
|
1790
|
-
collection: source.collection
|
|
1791
|
-
});
|
|
1792
|
-
paths.set(reference, sourcePath);
|
|
1793
|
-
return reference;
|
|
1794
|
-
},
|
|
1795
|
-
resolveSource (reference) {
|
|
1796
|
-
return sources.get(reference);
|
|
1797
|
-
},
|
|
1798
|
-
materialize (sourcePath) {
|
|
1799
|
-
const source = realpathSync(sourcePath);
|
|
1800
|
-
const entry = statSync(source).isDirectory() ? join(source, 'index.html') : source;
|
|
1801
|
-
if (!statSync(entry).isFile()) throw new TestExecutorError(`Executor report must have a regular HTML entry: ${sourcePath}`, {
|
|
1802
|
-
kind: 'report'
|
|
1803
|
-
});
|
|
1804
|
-
const reference = randomUUID();
|
|
1805
|
-
const destination = join(outputDir, 'reports', reference);
|
|
1806
|
-
mkdirSync(destination, {
|
|
1807
|
-
recursive: true
|
|
1808
|
-
});
|
|
1809
|
-
const isDirectoryReport = 'index.html' === basename(entry);
|
|
1810
|
-
const reportPath = join(destination, isDirectoryReport ? 'index.html' : 'report.html');
|
|
1811
|
-
cpSync(isDirectoryReport ? dirname(entry) : entry, isDirectoryReport ? destination : reportPath, {
|
|
1812
|
-
recursive: isDirectoryReport,
|
|
1813
|
-
dereference: true
|
|
1814
|
-
});
|
|
1815
|
-
if (!isDirectoryReport) {
|
|
1816
|
-
const screenshots = join(dirname(entry), 'screenshots');
|
|
1817
|
-
if (existsSync(screenshots)) cpSync(screenshots, join(destination, 'screenshots'), {
|
|
1818
|
-
recursive: true,
|
|
1819
|
-
dereference: true
|
|
1820
|
-
});
|
|
1821
|
-
}
|
|
1822
|
-
paths.set(reference, reportPath);
|
|
1823
|
-
return reference;
|
|
1824
|
-
},
|
|
1825
|
-
resolve (reference) {
|
|
1826
|
-
const reportPath = paths.get(reference);
|
|
1827
|
-
if (!reportPath) throw new TestExecutorError(`Executor returned unknown report reference ${reference}.`, {
|
|
1828
|
-
kind: 'report'
|
|
1829
|
-
});
|
|
1830
|
-
return reportPath;
|
|
1831
|
-
}
|
|
1832
|
-
};
|
|
1833
|
-
}
|
|
1834
|
-
async function runTaskPool(options) {
|
|
1835
|
-
if (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency <= 0) throw new TypeError('Task scheduler maxConcurrency must be a positive integer.');
|
|
1836
|
-
const pending = options.tasks.map((_, index)=>index);
|
|
1837
|
-
const results = new Array(options.tasks.length);
|
|
1838
|
-
const activeResources = new Set();
|
|
1839
|
-
let active = 0;
|
|
1840
|
-
let firstError;
|
|
1841
|
-
let hasError = false;
|
|
1842
|
-
return await new Promise((resolve, reject)=>{
|
|
1843
|
-
let settled = false;
|
|
1844
|
-
const stopRequested = ()=>hasError || options.signal?.aborted === true || options.shouldStop?.() === true;
|
|
1845
|
-
const finish = ()=>{
|
|
1846
|
-
if (settled) return;
|
|
1847
|
-
settled = true;
|
|
1848
|
-
options.signal?.removeEventListener('abort', pump);
|
|
1849
|
-
if (hasError) reject(firstError);
|
|
1850
|
-
else resolve(results);
|
|
1851
|
-
};
|
|
1852
|
-
const release = (resources)=>{
|
|
1853
|
-
for (const resource of resources)activeResources.delete(resource);
|
|
1854
|
-
};
|
|
1855
|
-
const claimRunnable = ()=>{
|
|
1856
|
-
const pendingIndex = pending.findIndex((taskIndex)=>options.tasks[taskIndex].resources.every((resource)=>!activeResources.has(resource)));
|
|
1857
|
-
if (pendingIndex < 0) return;
|
|
1858
|
-
const [taskIndex] = pending.splice(pendingIndex, 1);
|
|
1859
|
-
return taskIndex;
|
|
1860
|
-
};
|
|
1861
|
-
function pump() {
|
|
1862
|
-
if (settled) return;
|
|
1863
|
-
while(active < options.maxConcurrency && !stopRequested()){
|
|
1864
|
-
const taskIndex = claimRunnable();
|
|
1865
|
-
if (void 0 === taskIndex) break;
|
|
1866
|
-
const task = options.tasks[taskIndex];
|
|
1867
|
-
for (const resource of task.resources)activeResources.add(resource);
|
|
1868
|
-
active += 1;
|
|
1869
|
-
Promise.resolve().then(()=>options.run(task, taskIndex)).then((result)=>{
|
|
1870
|
-
results[taskIndex] = result;
|
|
1871
|
-
}).catch((error)=>{
|
|
1872
|
-
if (!hasError) {
|
|
1873
|
-
hasError = true;
|
|
1874
|
-
firstError = error;
|
|
1875
|
-
}
|
|
1876
|
-
}).finally(()=>{
|
|
1877
|
-
release(task.resources);
|
|
1878
|
-
active -= 1;
|
|
1879
|
-
pump();
|
|
1880
|
-
});
|
|
1881
|
-
}
|
|
1882
|
-
if (0 === active && (0 === pending.length || stopRequested())) finish();
|
|
1883
|
-
}
|
|
1884
|
-
options.signal?.addEventListener('abort', pump, {
|
|
1885
|
-
once: true
|
|
1886
|
-
});
|
|
1887
|
-
pump();
|
|
1888
|
-
});
|
|
1889
|
-
}
|
|
1890
|
-
const isolatedDocumentId = (document, collectedCase)=>`${document.documentId}-case-${collectedCase.caseIndex}`;
|
|
1891
|
-
const prepareCaseTasks = (projects)=>projects.flatMap((prepared, projectIndex)=>prepared.documents.flatMap((document)=>document.cases.map((collectedCase)=>{
|
|
1892
|
-
const documentId = isolatedDocumentId(document, collectedCase);
|
|
1893
|
-
return {
|
|
1894
|
-
projectIndex,
|
|
1895
|
-
prepared,
|
|
1896
|
-
document,
|
|
1897
|
-
collectedCase,
|
|
1898
|
-
task: Object.freeze({
|
|
1899
|
-
taskId: `${prepared.project.projectId}:${collectedCase.caseId}`,
|
|
1900
|
-
projectId: prepared.project.projectId,
|
|
1901
|
-
projectName: prepared.project.name,
|
|
1902
|
-
documentId,
|
|
1903
|
-
sourcePath: collectedCase.sourcePath,
|
|
1904
|
-
caseId: collectedCase.caseId,
|
|
1905
|
-
caseName: collectedCase.definition.name,
|
|
1906
|
-
caseIndex: collectedCase.caseIndex,
|
|
1907
|
-
tags: Object.freeze([
|
|
1908
|
-
...collectedCase.definition.tags ?? []
|
|
1909
|
-
]),
|
|
1910
|
-
resources: Object.freeze([
|
|
1911
|
-
...collectedCase.definition.resources ?? []
|
|
1912
|
-
]),
|
|
1913
|
-
retry: prepared.project.retry
|
|
1914
|
-
})
|
|
1915
|
-
};
|
|
1916
|
-
})));
|
|
1917
|
-
const reviveError = (error)=>{
|
|
1918
|
-
const revived = new WorkflowError(error.message, {
|
|
1919
|
-
code: error.code,
|
|
1920
|
-
details: error.details,
|
|
1921
|
-
cause: error.cause
|
|
1922
|
-
});
|
|
1923
|
-
revived.name = error.name;
|
|
1924
|
-
return revived;
|
|
1925
|
-
};
|
|
1926
|
-
const reviveStep = (step)=>{
|
|
1927
|
-
const { error, output, report, ...base } = step;
|
|
1928
|
-
return {
|
|
1929
|
-
...base,
|
|
1930
|
-
input: step.input,
|
|
1931
|
-
meta: {
|
|
1932
|
-
...step.meta
|
|
1933
|
-
},
|
|
1934
|
-
...output ? {
|
|
1935
|
-
output: {
|
|
1936
|
-
...output
|
|
1937
|
-
}
|
|
1938
|
-
} : {},
|
|
1939
|
-
...error ? {
|
|
1940
|
-
error: reviveError(error)
|
|
1941
|
-
} : {},
|
|
1942
|
-
...report ? {
|
|
1943
|
-
report: {
|
|
1944
|
-
traces: report.traces.map((trace)=>({
|
|
1945
|
-
...trace
|
|
1946
|
-
})),
|
|
1947
|
-
...report.collectionErrors ? {
|
|
1948
|
-
collectionErrors: report.collectionErrors.map(reviveError)
|
|
1949
|
-
} : {}
|
|
1950
|
-
}
|
|
1951
|
-
} : {}
|
|
1952
|
-
};
|
|
1953
|
-
};
|
|
1954
|
-
const reviveAttempt = (attempt)=>{
|
|
1955
|
-
const { executionErrors, teardownErrors, reportRefs, beforeEach, steps, afterEach, ...base } = attempt;
|
|
1956
|
-
return {
|
|
1957
|
-
...base,
|
|
1958
|
-
beforeEach: beforeEach.map(reviveStep),
|
|
1959
|
-
steps: steps.map(reviveStep),
|
|
1960
|
-
afterEach: afterEach.map(reviveStep),
|
|
1961
|
-
...executionErrors ? {
|
|
1962
|
-
executionErrors: executionErrors.map(reviveError)
|
|
1963
|
-
} : {},
|
|
1964
|
-
...teardownErrors ? {
|
|
1965
|
-
teardownErrors: teardownErrors.map(reviveError)
|
|
1966
|
-
} : {}
|
|
1967
|
-
};
|
|
1968
|
-
};
|
|
1969
|
-
const reviveDocument = (document)=>{
|
|
1970
|
-
const { executionErrors, teardownErrors, reportRefs, beforeAll, afterAll, hostErrors, ...base } = document;
|
|
1971
|
-
return {
|
|
1972
|
-
...base,
|
|
1973
|
-
...hostErrors ? {
|
|
1974
|
-
hostErrors: hostErrors.map((error)=>({
|
|
1975
|
-
...error
|
|
1976
|
-
}))
|
|
1977
|
-
} : {},
|
|
1978
|
-
beforeAll: beforeAll.map(reviveStep),
|
|
1979
|
-
afterAll: afterAll.map(reviveStep),
|
|
1980
|
-
...executionErrors ? {
|
|
1981
|
-
executionErrors: executionErrors.map(reviveError)
|
|
1982
|
-
} : {},
|
|
1983
|
-
...teardownErrors ? {
|
|
1984
|
-
teardownErrors: teardownErrors.map(reviveError)
|
|
1985
|
-
} : {}
|
|
1986
|
-
};
|
|
1987
|
-
};
|
|
1988
|
-
const reviveLifecycle = (lifecycle)=>{
|
|
1989
|
-
const { setupError, teardownErrors, ...base } = lifecycle;
|
|
1990
|
-
return {
|
|
1991
|
-
...base,
|
|
1992
|
-
...setupError ? {
|
|
1993
|
-
setupError: reviveError(setupError)
|
|
1994
|
-
} : {},
|
|
1995
|
-
...teardownErrors ? {
|
|
1996
|
-
teardownErrors: teardownErrors.map(reviveError)
|
|
1997
|
-
} : {}
|
|
1998
|
-
};
|
|
1999
|
-
};
|
|
2000
|
-
const toTransportResult = (result, materializeReport, materializeSource)=>{
|
|
2001
|
-
const transportReports = (scope)=>[
|
|
2002
|
-
...(scope.reportSources ?? []).map(materializeSource),
|
|
2003
|
-
...(scope.reportPaths ?? []).filter((path)=>!scope.reportSources?.some((source)=>source.sourcePath === path)).map(materializeReport)
|
|
2004
|
-
];
|
|
2005
|
-
const mapStep = (step)=>{
|
|
2006
|
-
if (!step.report) return step;
|
|
2007
|
-
const { sources: _sources, ...report } = step.report;
|
|
2008
|
-
return {
|
|
2009
|
-
...step,
|
|
2010
|
-
report
|
|
2011
|
-
};
|
|
2012
|
-
};
|
|
2013
|
-
const mapAttempt = (attempt)=>{
|
|
2014
|
-
const { reportPaths, reportSources, reportScopeId, ...transportAttempt } = attempt;
|
|
2015
|
-
const reportRefs = transportReports(attempt);
|
|
2016
|
-
return {
|
|
2017
|
-
...transportAttempt,
|
|
2018
|
-
beforeEach: attempt.beforeEach.map(mapStep),
|
|
2019
|
-
steps: attempt.steps.map(mapStep),
|
|
2020
|
-
afterEach: attempt.afterEach.map(mapStep),
|
|
2021
|
-
...reportRefs.length ? {
|
|
2022
|
-
reportRefs
|
|
2023
|
-
} : {}
|
|
2024
|
-
};
|
|
2025
|
-
};
|
|
2026
|
-
const attempts = result.case.attempts?.map(mapAttempt);
|
|
2027
|
-
const run = attempts?.at(-1);
|
|
2028
|
-
const { run: _run, attempts: _attempts, ...transportCase } = result.case;
|
|
2029
|
-
const document = result.document ? (()=>{
|
|
2030
|
-
const { reportPaths, reportSources, reportScopeId, ...transportDocument } = result.document;
|
|
2031
|
-
const reportRefs = transportReports(result.document);
|
|
2032
|
-
return {
|
|
2033
|
-
...transportDocument,
|
|
2034
|
-
...result.document.hostErrors ? {
|
|
2035
|
-
hostErrors: result.document.hostErrors.map(({ phase, error })=>({
|
|
2036
|
-
phase,
|
|
2037
|
-
error: sanitizeReportError(error)
|
|
2038
|
-
}))
|
|
2039
|
-
} : {},
|
|
2040
|
-
beforeAll: result.document.beforeAll.map(mapStep),
|
|
2041
|
-
afterAll: result.document.afterAll.map(mapStep),
|
|
2042
|
-
...reportRefs.length ? {
|
|
2043
|
-
reportRefs
|
|
2044
|
-
} : {}
|
|
2045
|
-
};
|
|
2046
|
-
})() : void 0;
|
|
2047
|
-
return JSON.parse(JSON.stringify({
|
|
2048
|
-
case: {
|
|
2049
|
-
...transportCase,
|
|
2050
|
-
...run ? {
|
|
2051
|
-
run
|
|
2052
|
-
} : {},
|
|
2053
|
-
...attempts ? {
|
|
2054
|
-
attempts
|
|
2055
|
-
} : {}
|
|
2056
|
-
},
|
|
2057
|
-
...document ? {
|
|
2058
|
-
document
|
|
2059
|
-
} : {},
|
|
2060
|
-
...result.lifecycle ? {
|
|
2061
|
-
lifecycle: result.lifecycle
|
|
2062
|
-
} : {}
|
|
2063
|
-
}));
|
|
2064
|
-
};
|
|
2065
|
-
const fromTransportResult = (result, resolveReportReference, resolveSource)=>{
|
|
2066
|
-
const resolveReports = (refs)=>{
|
|
2067
|
-
const reportPaths = [];
|
|
2068
|
-
const reportSources = [];
|
|
2069
|
-
for (const ref of refs ?? []){
|
|
2070
|
-
const path = resolveReportReference(ref);
|
|
2071
|
-
const source = resolveSource(ref);
|
|
2072
|
-
if (source) reportSources.push(source);
|
|
2073
|
-
else reportPaths.push(path);
|
|
2074
|
-
}
|
|
2075
|
-
return {
|
|
2076
|
-
...reportPaths.length ? {
|
|
2077
|
-
reportPaths
|
|
2078
|
-
} : {},
|
|
2079
|
-
...reportSources.length ? {
|
|
2080
|
-
reportSources
|
|
2081
|
-
} : {}
|
|
2082
|
-
};
|
|
2083
|
-
};
|
|
2084
|
-
const attempts = result.case.attempts?.map((attempt)=>({
|
|
2085
|
-
...reviveAttempt(attempt),
|
|
2086
|
-
...resolveReports(attempt.reportRefs)
|
|
2087
|
-
}));
|
|
2088
|
-
const run = attempts?.at(-1);
|
|
2089
|
-
const { run: _run, attempts: _attempts, ...caseBase } = result.case;
|
|
2090
|
-
return {
|
|
2091
|
-
case: {
|
|
2092
|
-
...caseBase,
|
|
2093
|
-
...run ? {
|
|
2094
|
-
run
|
|
2095
|
-
} : {},
|
|
2096
|
-
...attempts ? {
|
|
2097
|
-
attempts
|
|
2098
|
-
} : {}
|
|
2099
|
-
},
|
|
2100
|
-
...result.document ? {
|
|
2101
|
-
document: {
|
|
2102
|
-
...reviveDocument(result.document),
|
|
2103
|
-
...resolveReports(result.document.reportRefs)
|
|
2104
|
-
}
|
|
2105
|
-
} : {},
|
|
2106
|
-
...result.lifecycle ? {
|
|
2107
|
-
lifecycle: reviveLifecycle(result.lifecycle)
|
|
2108
|
-
} : {}
|
|
2109
|
-
};
|
|
2110
|
-
};
|
|
2111
|
-
const runLocalCase = async (preparedTask, options, taskProgress, materializeReport, materializeSource)=>{
|
|
2112
|
-
const { prepared, document, collectedCase, task } = preparedTask;
|
|
2113
|
-
const { project } = prepared;
|
|
2114
|
-
const isolatedDocument = {
|
|
2115
|
-
...document,
|
|
2116
|
-
documentId: task.documentId,
|
|
2117
|
-
cases: [
|
|
2118
|
-
collectedCase
|
|
2119
|
-
]
|
|
2120
|
-
};
|
|
2121
|
-
const runtime = createProjectRuntime({
|
|
2122
|
-
project,
|
|
2123
|
-
setup: project.setup,
|
|
2124
|
-
signal: options.signal
|
|
2125
|
-
});
|
|
2126
|
-
let lifecycle;
|
|
2127
|
-
let execution;
|
|
2128
|
-
let executionError;
|
|
2129
|
-
try {
|
|
2130
|
-
await runtime.start();
|
|
2131
|
-
if (runtime.canRun) execution = await runWorkflowDocument(isolatedDocument, {
|
|
2132
|
-
resolveNode: project.nodes.require.bind(project.nodes),
|
|
2133
|
-
project,
|
|
2134
|
-
projectContext: runtime.context,
|
|
2135
|
-
retry: project.retry,
|
|
2136
|
-
signal: runtime.signal,
|
|
2137
|
-
defaultTimeoutMs: options.test.testTimeout,
|
|
2138
|
-
shouldStop: ()=>options.signal.aborted,
|
|
2139
|
-
stopReason: ()=>'interrupted',
|
|
2140
|
-
isFatalError: (run)=>[
|
|
2141
|
-
...run.beforeEach,
|
|
2142
|
-
...run.steps,
|
|
2143
|
-
...run.afterEach
|
|
2144
|
-
].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
|
|
2145
|
-
onStepStart: (info)=>taskProgress(`→ ${formatStep(info)}`),
|
|
2146
|
-
onStepResult: (info, result)=>taskProgress(formatStepResult(info, result)),
|
|
2147
|
-
onCaseResult: (attempt)=>taskProgress(`${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${project.retry + 1}: ${attempt.name} (${attempt.durationMs} ms)`)
|
|
2148
|
-
});
|
|
2149
|
-
} catch (error) {
|
|
2150
|
-
executionError = error;
|
|
2151
|
-
if (error instanceof WorkflowExecutionFailure) execution = error.result;
|
|
2152
|
-
} finally{
|
|
2153
|
-
const failed = void 0 !== executionError || !runtime.canRun || execution?.cases[0]?.status !== 'success' || execution?.document.status === 'failed';
|
|
2154
|
-
lifecycle = await runtime.finish(failed ? 'failed' : 'success');
|
|
2155
|
-
}
|
|
2156
|
-
if (executionError) {
|
|
2157
|
-
if (!execution) throw executionError;
|
|
2158
|
-
options.onInfrastructureError(executionError);
|
|
2159
|
-
}
|
|
2160
|
-
if (!execution) return toTransportResult({
|
|
2161
|
-
case: asNotRun(task.documentId, collectedCase, project.name, options.signal.aborted ? 'interrupted' : 'project-setup-failed'),
|
|
2162
|
-
lifecycle
|
|
2163
|
-
}, materializeReport, materializeSource);
|
|
2164
|
-
const outcome = execution.cases[0];
|
|
2165
|
-
if (!outcome) throw new Error(`Executor did not produce case result for ${task.caseId}.`);
|
|
2166
|
-
return toTransportResult({
|
|
2167
|
-
case: {
|
|
2168
|
-
...outcome,
|
|
2169
|
-
documentId: task.documentId
|
|
2170
|
-
},
|
|
2171
|
-
document: execution.document,
|
|
2172
|
-
lifecycle
|
|
2173
|
-
}, materializeReport, materializeSource);
|
|
2174
|
-
};
|
|
2175
|
-
const runCaseTask = async (preparedTask, taskIndex, taskCount, options, onCompleted)=>{
|
|
2176
|
-
const { prepared, task } = preparedTask;
|
|
2177
|
-
const taskProgress = (message)=>options.progress(`[case ${taskIndex + 1}/${taskCount}] ${task.projectName} / ${task.sourcePath} / ${task.caseName}: ${message}`);
|
|
2178
|
-
taskProgress('started');
|
|
2179
|
-
const outputDir = join(options.runDir, task.projectId, 'executor-output', task.documentId, task.caseId);
|
|
2180
|
-
mkdirSync(outputDir, {
|
|
2181
|
-
recursive: true
|
|
2182
|
-
});
|
|
2183
|
-
const { materialize: materializeReport, materializeSource, resolve: resolveReportReference, resolveSource } = createExecutorReports(outputDir);
|
|
2184
|
-
if (prepared.collectionErrors.length > 0) return {
|
|
2185
|
-
case: asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'project-preflight-failed')
|
|
2186
|
-
};
|
|
2187
|
-
const executor = options.executor ?? localTestExecutor;
|
|
2188
|
-
let result;
|
|
2189
|
-
let executorFailure;
|
|
2190
|
-
let executorAttempts = 0;
|
|
2191
|
-
for(let attemptIndex = 0; attemptIndex <= options.test.executorRetry; attemptIndex += 1){
|
|
2192
|
-
executorAttempts = attemptIndex + 1;
|
|
2193
|
-
try {
|
|
2194
|
-
result = await executor.execute(task, {
|
|
2195
|
-
signal: options.signal,
|
|
2196
|
-
onProgress: taskProgress,
|
|
2197
|
-
outputDir,
|
|
2198
|
-
materializeReport,
|
|
2199
|
-
runLocal: ()=>runLocalCase(preparedTask, options, taskProgress, materializeReport, materializeSource)
|
|
2200
|
-
});
|
|
2201
|
-
executorFailure = void 0;
|
|
2202
|
-
break;
|
|
2203
|
-
} catch (error) {
|
|
2204
|
-
if (!(error instanceof TestExecutorError)) throw error;
|
|
2205
|
-
executorFailure = error;
|
|
2206
|
-
if (!error.retryable || attemptIndex >= options.test.executorRetry || options.signal.aborted) break;
|
|
2207
|
-
taskProgress(`executor retry ${attemptIndex + 1}/${options.test.executorRetry}: ${error.message}`);
|
|
2208
|
-
}
|
|
2209
|
-
}
|
|
2210
|
-
if (!result) {
|
|
2211
|
-
if (!executorFailure) throw new Error(`Executor did not return a result for ${task.caseId}.`);
|
|
2212
|
-
taskProgress(`executor-failed: ${executorFailure.message}`);
|
|
2213
|
-
return {
|
|
2214
|
-
case: {
|
|
2215
|
-
...asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'executor-failed'),
|
|
2216
|
-
execution: {
|
|
2217
|
-
executor: executor.name,
|
|
2218
|
-
resources: task.resources,
|
|
2219
|
-
attempts: executorAttempts,
|
|
2220
|
-
failure: {
|
|
2221
|
-
kind: executorFailure.kind,
|
|
2222
|
-
message: executorFailure.message,
|
|
2223
|
-
retryable: executorFailure.retryable
|
|
2224
|
-
}
|
|
2225
|
-
}
|
|
2226
|
-
}
|
|
2227
|
-
};
|
|
2228
|
-
}
|
|
2229
|
-
assertTestCaseTaskRunResult(task, result);
|
|
2230
|
-
const internalResult = fromTransportResult(result, resolveReportReference, resolveSource);
|
|
2231
|
-
const completed = {
|
|
2232
|
-
...internalResult,
|
|
2233
|
-
case: {
|
|
2234
|
-
...internalResult.case,
|
|
2235
|
-
execution: {
|
|
2236
|
-
executor: executor.name,
|
|
2237
|
-
resources: task.resources,
|
|
2238
|
-
attempts: executorAttempts,
|
|
2239
|
-
...internalResult.lifecycle ? {
|
|
2240
|
-
lifecycle: internalResult.lifecycle
|
|
2241
|
-
} : {},
|
|
2242
|
-
...result.artifacts ? {
|
|
2243
|
-
artifacts: result.artifacts
|
|
2244
|
-
} : {},
|
|
2245
|
-
...result.metadata ? {
|
|
2246
|
-
metadata: result.metadata
|
|
2247
|
-
} : {}
|
|
2248
|
-
}
|
|
2249
|
-
}
|
|
2250
|
-
};
|
|
2251
|
-
onCompleted(completed);
|
|
2252
|
-
for (const attempt of internalResult.case.attempts ?? [])await writeCaseAttemptResult(options.runDir, task.projectId, task.documentId, attempt);
|
|
2253
|
-
if (internalResult.document) await writeWorkflowDocumentResult(options.runDir, internalResult.document);
|
|
2254
|
-
taskProgress(internalResult.case.status);
|
|
2255
|
-
return completed;
|
|
2256
|
-
};
|
|
2257
|
-
const runCaseExecution = async (options)=>{
|
|
2258
|
-
const tasks = prepareCaseTasks(options.projects);
|
|
2259
|
-
let failedCaseCount = 0;
|
|
2260
|
-
const bailReached = ()=>options.test.bail > 0 && failedCaseCount >= options.test.bail;
|
|
2261
|
-
const results = new Array(tasks.length);
|
|
2262
|
-
if (tasks.length > 0) try {
|
|
2263
|
-
await runTaskPool({
|
|
2264
|
-
tasks: tasks.map((preparedTask)=>preparedTask.task),
|
|
2265
|
-
maxConcurrency: Math.min(options.test.maxConcurrency, tasks.length),
|
|
2266
|
-
signal: options.signal,
|
|
2267
|
-
shouldStop: bailReached,
|
|
2268
|
-
run: async (_task, taskIndex)=>{
|
|
2269
|
-
try {
|
|
2270
|
-
const result = await runCaseTask(tasks[taskIndex], taskIndex, tasks.length, options, (completed)=>{
|
|
2271
|
-
results[taskIndex] = completed;
|
|
2272
|
-
});
|
|
2273
|
-
results[taskIndex] = result;
|
|
2274
|
-
if ('failed' === result.case.status) failedCaseCount += 1;
|
|
2275
|
-
return result;
|
|
2276
|
-
} catch (error) {
|
|
2277
|
-
options.onInfrastructureError(error);
|
|
2278
|
-
throw error;
|
|
2279
|
-
}
|
|
2280
|
-
}
|
|
2281
|
-
});
|
|
2282
|
-
} catch (error) {
|
|
2283
|
-
options.onInfrastructureError(error);
|
|
2284
|
-
}
|
|
2285
|
-
return options.projects.map((prepared, projectIndex)=>{
|
|
2286
|
-
const projectTasks = tasks.map((task, taskIndex)=>({
|
|
2287
|
-
task,
|
|
2288
|
-
result: results[taskIndex]
|
|
2289
|
-
})).filter(({ task })=>task.projectIndex === projectIndex);
|
|
2290
|
-
const reason = prepared.collectionErrors.length ? 'project-preflight-failed' : options.signal.aborted ? 'interrupted' : bailReached() ? 'bail' : void 0;
|
|
2291
|
-
const cases = projectTasks.map(({ task, result })=>{
|
|
2292
|
-
if (result) return result.case;
|
|
2293
|
-
if (!reason) throw new Error(`Case scheduler did not produce a result for "${task.task.caseId}".`);
|
|
2294
|
-
return asNotRun(task.task.documentId, task.collectedCase, prepared.project.name, reason);
|
|
2295
|
-
});
|
|
2296
|
-
const documents = projectTasks.flatMap(({ result })=>result?.document ? [
|
|
2297
|
-
result.document
|
|
2298
|
-
] : []);
|
|
2299
|
-
return buildProjectResult(prepared, cases, documents);
|
|
2300
|
-
});
|
|
2301
|
-
};
|
|
2302
1117
|
const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
|
|
2303
1118
|
...attempt.beforeEach,
|
|
2304
1119
|
...attempt.steps,
|
|
@@ -2344,7 +1159,7 @@ async function executeDocumentInvocation(options) {
|
|
|
2344
1159
|
},
|
|
2345
1160
|
onStepResult: async (info, result)=>{
|
|
2346
1161
|
await invocation.bindings.onStepResult?.(info, result);
|
|
2347
|
-
options.onProgress(formatStepResult(info, result
|
|
1162
|
+
options.onProgress(formatStepResult(info, result));
|
|
2348
1163
|
},
|
|
2349
1164
|
onCaseResult: async (attempt)=>{
|
|
2350
1165
|
await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
|
|
@@ -2462,6 +1277,16 @@ function createOperationJournal(runDir) {
|
|
|
2462
1277
|
}
|
|
2463
1278
|
};
|
|
2464
1279
|
}
|
|
1280
|
+
const asNotRun = (documentId, collectedCase, projectName, reason)=>({
|
|
1281
|
+
documentId,
|
|
1282
|
+
caseId: collectedCase.caseId,
|
|
1283
|
+
projectName,
|
|
1284
|
+
name: collectedCase.definition.name,
|
|
1285
|
+
sourcePath: collectedCase.sourcePath,
|
|
1286
|
+
caseIndex: collectedCase.caseIndex,
|
|
1287
|
+
status: 'not-run',
|
|
1288
|
+
notRunReason: reason
|
|
1289
|
+
});
|
|
2465
1290
|
const summarize = (projects)=>{
|
|
2466
1291
|
const healthCases = projects.flatMap((project)=>{
|
|
2467
1292
|
const documentAttemptByRunId = new Map(project.documents.map((document)=>[
|
|
@@ -2495,6 +1320,12 @@ const summarize = (projects)=>{
|
|
|
2495
1320
|
projectFailures: projects.filter((project)=>'failed' === project.status).length
|
|
2496
1321
|
};
|
|
2497
1322
|
};
|
|
1323
|
+
const latestById = (items, id)=>[
|
|
1324
|
+
...new Map(items.map((item)=>[
|
|
1325
|
+
id(item),
|
|
1326
|
+
item
|
|
1327
|
+
])).values()
|
|
1328
|
+
];
|
|
2498
1329
|
const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>asNotRun(document.documentId, item, prepared.project.name, reason)));
|
|
2499
1330
|
async function runPreparedTestProject(plan) {
|
|
2500
1331
|
const { startedAt, runId, projectRoot, configPath, resultDir, runDir, summaryPath, reportDir, definition, projects: preparedProjects } = plan;
|
|
@@ -2507,7 +1338,7 @@ async function runPreparedTestProject(plan) {
|
|
|
2507
1338
|
await writeTestRunPlan(runDir, preparedProjects);
|
|
2508
1339
|
const journal = createOperationJournal(runDir);
|
|
2509
1340
|
const rootController = new AbortController();
|
|
2510
|
-
setMaxListeners(Math.max(10,
|
|
1341
|
+
setMaxListeners(Math.max(10, effectiveConcurrency + 1), rootController.signal);
|
|
2511
1342
|
const handleSignal = (signal)=>{
|
|
2512
1343
|
rootController.abort(new Error(`Workflow interrupted by ${signal}.`));
|
|
2513
1344
|
};
|
|
@@ -2549,6 +1380,31 @@ async function runPreparedTestProject(plan) {
|
|
|
2549
1380
|
const { project } = prepared;
|
|
2550
1381
|
runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
|
|
2551
1382
|
};
|
|
1383
|
+
const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
|
|
1384
|
+
const { project } = prepared;
|
|
1385
|
+
const projectFailed = prepared.collectionErrors.length > 0 || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status) || latestById(documents, (item)=>item.documentId).some((item)=>'failed' === item.status) || lifecycle?.status === 'failed';
|
|
1386
|
+
return {
|
|
1387
|
+
projectId: project.projectId,
|
|
1388
|
+
name: project.name,
|
|
1389
|
+
...prepared.platform ? {
|
|
1390
|
+
platform: prepared.platform
|
|
1391
|
+
} : {},
|
|
1392
|
+
status: projectFailed ? 'failed' : 'success',
|
|
1393
|
+
retry: project.retry,
|
|
1394
|
+
fileSelection: prepared.fileSelection,
|
|
1395
|
+
tagSelection: project.tags,
|
|
1396
|
+
sourceCount: prepared.sources.length,
|
|
1397
|
+
selectedCaseCount: prepared.selectedCaseCount,
|
|
1398
|
+
filteredCaseCount: prepared.filteredCaseCount,
|
|
1399
|
+
...lifecycle ? {
|
|
1400
|
+
lifecycle
|
|
1401
|
+
} : {},
|
|
1402
|
+
cases,
|
|
1403
|
+
plans: prepared.plans,
|
|
1404
|
+
documents,
|
|
1405
|
+
collectionErrors: prepared.collectionErrors
|
|
1406
|
+
};
|
|
1407
|
+
};
|
|
2552
1408
|
const buildSkippedProjectResult = (prepared, projectIndex, reason)=>{
|
|
2553
1409
|
announceProject(prepared, projectIndex);
|
|
2554
1410
|
return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
|
|
@@ -2649,7 +1505,7 @@ async function runPreparedTestProject(plan) {
|
|
|
2649
1505
|
};
|
|
2650
1506
|
const projectResults = new Array(preparedProjects.length);
|
|
2651
1507
|
try {
|
|
2652
|
-
|
|
1508
|
+
await runConcurrentJobs(preparedProjects, {
|
|
2653
1509
|
concurrency: effectiveConcurrency,
|
|
2654
1510
|
shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
|
|
2655
1511
|
}, async (prepared, index)=>{
|
|
@@ -2660,20 +1516,6 @@ async function runPreparedTestProject(plan) {
|
|
|
2660
1516
|
throw error;
|
|
2661
1517
|
}
|
|
2662
1518
|
});
|
|
2663
|
-
else {
|
|
2664
|
-
const results = await runCaseExecution({
|
|
2665
|
-
projects: preparedProjects,
|
|
2666
|
-
test: definition.test,
|
|
2667
|
-
executor: definition.executor,
|
|
2668
|
-
runDir,
|
|
2669
|
-
signal: rootController.signal,
|
|
2670
|
-
progress,
|
|
2671
|
-
onInfrastructureError: recordInfrastructureError
|
|
2672
|
-
});
|
|
2673
|
-
results.forEach((result, index)=>{
|
|
2674
|
-
projectResults[index] = result;
|
|
2675
|
-
});
|
|
2676
|
-
}
|
|
2677
1519
|
} catch (error) {
|
|
2678
1520
|
recordInfrastructureError(error);
|
|
2679
1521
|
} finally{
|
|
@@ -2779,6 +1621,6 @@ async function runPreparedTestProject(plan) {
|
|
|
2779
1621
|
async function runTestProject(options = {}) {
|
|
2780
1622
|
return runPreparedTestProject(await prepareTestRun(options));
|
|
2781
1623
|
}
|
|
2782
|
-
export { DEFAULT_TEST_FILE_SELECTION,
|
|
1624
|
+
export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
|
|
2783
1625
|
|
|
2784
1626
|
//# sourceMappingURL=index.mjs.map
|