@rettangoli/vt 0.0.14 → 1.0.0-rc12
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/README.md +164 -64
- package/package.json +9 -2
- package/src/capture/capture-scheduler.js +206 -0
- package/src/capture/playwright-runner.js +405 -0
- package/src/capture/result-collector.js +234 -0
- package/src/capture/screenshot-naming.js +13 -0
- package/src/capture/spec-loader.js +119 -0
- package/src/capture/worker-plan.js +66 -0
- package/src/cli/generate-options.js +94 -0
- package/src/cli/generate.js +118 -28
- package/src/cli/index.js +3 -1
- package/src/cli/report-options.js +43 -0
- package/src/cli/report.js +88 -151
- package/src/cli/screenshot.js +8 -0
- package/src/cli/service-runtime.js +116 -0
- package/src/cli/templates/default.html +5 -3
- package/src/cli/templates/index.html +9 -7
- package/src/cli/templates/report.html +8 -6
- package/src/common.js +124 -185
- package/src/createSteps.js +810 -44
- package/src/report/report-model.js +76 -0
- package/src/report/report-render.js +22 -0
- package/src/section-page-key.js +14 -0
- package/src/selector-filter.js +140 -0
- package/src/step-commands.js +37 -0
- package/src/validation.js +636 -0
- package/src/viewport.js +99 -0
- package/docker/Dockerfile +0 -21
- package/docker/build-and-push.sh +0 -16
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import { normalizeViewportField } from "./viewport.js";
|
|
2
|
+
import { deriveSectionPageKey } from "./section-page-key.js";
|
|
3
|
+
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function valueType(value) {
|
|
9
|
+
if (Array.isArray(value)) return "array";
|
|
10
|
+
if (value === null) return "null";
|
|
11
|
+
return typeof value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assert(condition, message) {
|
|
15
|
+
if (!condition) {
|
|
16
|
+
throw new Error(message);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function validateOptionalString(value, path, options = {}) {
|
|
21
|
+
const { allowEmpty = false } = options;
|
|
22
|
+
if (value === undefined || value === null) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
assert(typeof value === "string", `"${path}" must be a string, got ${valueType(value)}.`);
|
|
26
|
+
if (!allowEmpty) {
|
|
27
|
+
assert(value.trim().length > 0, `"${path}" cannot be empty.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateOptionalBoolean(value, path) {
|
|
32
|
+
if (value === undefined || value === null) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
assert(typeof value === "boolean", `"${path}" must be a boolean, got ${valueType(value)}.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validateOptionalNumber(value, path, options = {}) {
|
|
39
|
+
const { integer = false, min, max } = options;
|
|
40
|
+
if (value === undefined || value === null) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
assert(
|
|
45
|
+
typeof value === "number" && Number.isFinite(value),
|
|
46
|
+
`"${path}" must be a finite number, got ${valueType(value)} (${String(value)}).`,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (integer) {
|
|
50
|
+
assert(Number.isInteger(value), `"${path}" must be an integer, got ${value}.`);
|
|
51
|
+
}
|
|
52
|
+
if (min !== undefined) {
|
|
53
|
+
assert(value >= min, `"${path}" must be >= ${min}, got ${value}.`);
|
|
54
|
+
}
|
|
55
|
+
if (max !== undefined) {
|
|
56
|
+
assert(value <= max, `"${path}" must be <= ${max}, got ${value}.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateOptionalEnum(value, path, allowedValues) {
|
|
61
|
+
if (value === undefined || value === null) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
validateOptionalString(value, path);
|
|
65
|
+
assert(
|
|
66
|
+
allowedValues.includes(value),
|
|
67
|
+
`"${path}" must be one of: ${allowedValues.join(", ")}. Got "${value}".`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateCaptureConfig(captureConfig, sourcePath) {
|
|
72
|
+
if (captureConfig === undefined || captureConfig === null) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
assert(
|
|
77
|
+
isPlainObject(captureConfig),
|
|
78
|
+
`Invalid VT config in ${sourcePath}: "vt.capture" must be an object when provided, got ${valueType(captureConfig)}.`,
|
|
79
|
+
);
|
|
80
|
+
assert(
|
|
81
|
+
Object.keys(captureConfig).length === 0,
|
|
82
|
+
`Invalid VT config in ${sourcePath}: "vt.capture" is internal and no longer user-configurable. Remove this section.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validateServiceConfig(serviceConfig, sourcePath) {
|
|
87
|
+
if (serviceConfig === undefined || serviceConfig === null) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
assert(
|
|
92
|
+
isPlainObject(serviceConfig),
|
|
93
|
+
`Invalid VT config in ${sourcePath}: "vt.service" must be an object when provided, got ${valueType(serviceConfig)}.`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const allowedKeys = new Set(["start"]);
|
|
97
|
+
const unknownKeys = Object.keys(serviceConfig).filter((key) => !allowedKeys.has(key));
|
|
98
|
+
assert(
|
|
99
|
+
unknownKeys.length === 0,
|
|
100
|
+
`Invalid VT config in ${sourcePath}: "vt.service" supports only "start". Unknown keys: ${unknownKeys.join(", ")}.`,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
validateOptionalString(serviceConfig.start, "vt.service.start");
|
|
104
|
+
assert(
|
|
105
|
+
typeof serviceConfig.start === "string" && serviceConfig.start.trim().length > 0,
|
|
106
|
+
`Invalid VT config in ${sourcePath}: "vt.service.start" is required when "vt.service" is provided.`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const LEGACY_CAPTURE_FIELDS = {
|
|
111
|
+
screenshotWaitTime: true,
|
|
112
|
+
waitSelector: true,
|
|
113
|
+
waitStrategy: true,
|
|
114
|
+
workerCount: true,
|
|
115
|
+
isolationMode: true,
|
|
116
|
+
navigationTimeout: true,
|
|
117
|
+
readyTimeout: true,
|
|
118
|
+
screenshotTimeout: true,
|
|
119
|
+
maxRetries: true,
|
|
120
|
+
recycleEvery: true,
|
|
121
|
+
metricsPath: true,
|
|
122
|
+
headless: true,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function assertNoLegacyCaptureFields(vtConfig, sourcePath) {
|
|
126
|
+
for (const legacyField of Object.keys(LEGACY_CAPTURE_FIELDS)) {
|
|
127
|
+
if (Object.prototype.hasOwnProperty.call(vtConfig, legacyField)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Invalid VT config in ${sourcePath}: "vt.${legacyField}" was removed and is no longer user-configurable.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function assertDerivableSectionPageKey(sectionLike, path) {
|
|
136
|
+
const pageKey = deriveSectionPageKey(sectionLike);
|
|
137
|
+
assert(
|
|
138
|
+
pageKey.length > 0,
|
|
139
|
+
`"${path}" must contain at least one letter or number.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function validateSection(section, index) {
|
|
144
|
+
const sectionPath = `vt.sections[${index}]`;
|
|
145
|
+
assert(isPlainObject(section), `"${sectionPath}" must be an object, got ${valueType(section)}.`);
|
|
146
|
+
|
|
147
|
+
validateOptionalString(section.title, `${sectionPath}.title`);
|
|
148
|
+
assert(typeof section.title === "string" && section.title.trim().length > 0, `"${sectionPath}.title" is required.`);
|
|
149
|
+
validateOptionalString(section.description, `${sectionPath}.description`, { allowEmpty: true });
|
|
150
|
+
|
|
151
|
+
if (section.type !== undefined) {
|
|
152
|
+
validateOptionalString(section.type, `${sectionPath}.type`);
|
|
153
|
+
assert(
|
|
154
|
+
section.type === "groupLabel",
|
|
155
|
+
`"${sectionPath}.type" must be "groupLabel" when provided, got "${section.type}".`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (section.type === "groupLabel") {
|
|
160
|
+
assert(Array.isArray(section.items), `"${sectionPath}.items" must be an array for groupLabel sections.`);
|
|
161
|
+
assert(section.items.length > 0, `"${sectionPath}.items" cannot be empty.`);
|
|
162
|
+
|
|
163
|
+
section.items.forEach((item, itemIndex) => {
|
|
164
|
+
const itemPath = `${sectionPath}.items[${itemIndex}]`;
|
|
165
|
+
assert(isPlainObject(item), `"${itemPath}" must be an object, got ${valueType(item)}.`);
|
|
166
|
+
validateOptionalString(item.title, `${itemPath}.title`);
|
|
167
|
+
validateOptionalString(item.files, `${itemPath}.files`);
|
|
168
|
+
validateOptionalString(item.description, `${itemPath}.description`, { allowEmpty: true });
|
|
169
|
+
|
|
170
|
+
assert(typeof item.title === "string" && item.title.trim().length > 0, `"${itemPath}.title" is required.`);
|
|
171
|
+
assert(typeof item.files === "string" && item.files.trim().length > 0, `"${itemPath}.files" is required.`);
|
|
172
|
+
assertDerivableSectionPageKey(item, `${itemPath}.title`);
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
validateOptionalString(section.files, `${sectionPath}.files`);
|
|
178
|
+
assert(typeof section.files === "string" && section.files.trim().length > 0, `"${sectionPath}.files" is required.`);
|
|
179
|
+
assertDerivableSectionPageKey(section, `${sectionPath}.title`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function collectSectionPageKeys(vtConfig) {
|
|
183
|
+
const keys = [];
|
|
184
|
+
vtConfig.sections.forEach((section) => {
|
|
185
|
+
if (section.type === "groupLabel" && Array.isArray(section.items)) {
|
|
186
|
+
section.items.forEach((item) => keys.push(deriveSectionPageKey(item)));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (section.files) {
|
|
190
|
+
keys.push(deriveSectionPageKey(section));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
return keys;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function assertUniqueSectionPageKeys(vtConfig, sourcePath) {
|
|
197
|
+
const seen = new Map();
|
|
198
|
+
const pageKeys = collectSectionPageKeys(vtConfig);
|
|
199
|
+
|
|
200
|
+
pageKeys.forEach((pageKey) => {
|
|
201
|
+
const canonicalKey = pageKey.toLowerCase();
|
|
202
|
+
const existing = seen.get(canonicalKey);
|
|
203
|
+
if (existing) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Invalid VT config in ${sourcePath}: section page key "${pageKey}" conflicts with "${existing}". Page keys must be unique (case-insensitive).`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
seen.set(canonicalKey, pageKey);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function assertNoUnknownStepKeys(stepObject, stepPath, allowedKeys) {
|
|
213
|
+
const unknownKeys = Object.keys(stepObject).filter((key) => !allowedKeys.has(key));
|
|
214
|
+
assert(
|
|
215
|
+
unknownKeys.length === 0,
|
|
216
|
+
`"${stepPath}" has unknown keys: ${unknownKeys.join(", ")}.`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function validateStructuredActionStep(step, stepPath) {
|
|
221
|
+
validateOptionalString(step.action, `${stepPath}.action`);
|
|
222
|
+
assert(
|
|
223
|
+
typeof step.action === "string" && step.action.trim().length > 0,
|
|
224
|
+
`"${stepPath}.action" is required.`,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const action = step.action.trim();
|
|
228
|
+
const allowedActions = [
|
|
229
|
+
"assert",
|
|
230
|
+
"blur",
|
|
231
|
+
"check",
|
|
232
|
+
"clear",
|
|
233
|
+
"click",
|
|
234
|
+
"customEvent",
|
|
235
|
+
"dblclick",
|
|
236
|
+
"focus",
|
|
237
|
+
"goto",
|
|
238
|
+
"hover",
|
|
239
|
+
"keypress",
|
|
240
|
+
"mouseDown",
|
|
241
|
+
"mouseUp",
|
|
242
|
+
"move",
|
|
243
|
+
"rclick",
|
|
244
|
+
"rightMouseDown",
|
|
245
|
+
"rightMouseUp",
|
|
246
|
+
"scroll",
|
|
247
|
+
"select",
|
|
248
|
+
"selectOption",
|
|
249
|
+
"setViewport",
|
|
250
|
+
"screenshot",
|
|
251
|
+
"uncheck",
|
|
252
|
+
"upload",
|
|
253
|
+
"wait",
|
|
254
|
+
"waitFor",
|
|
255
|
+
"write",
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
assert(
|
|
259
|
+
allowedActions.includes(action),
|
|
260
|
+
`"${stepPath}.action" must be one of: ${allowedActions.join(", ")}.`,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
if (action === "assert") {
|
|
264
|
+
assertNoUnknownStepKeys(
|
|
265
|
+
step,
|
|
266
|
+
stepPath,
|
|
267
|
+
new Set(["action", "type", "match", "selector", "timeoutMs", "value", "global", "fn", "args"]),
|
|
268
|
+
);
|
|
269
|
+
const assertConfig = { ...step };
|
|
270
|
+
delete assertConfig.action;
|
|
271
|
+
validateAssertObject(assertConfig, `${stepPath}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (action === "select") {
|
|
276
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "testId", "steps"]));
|
|
277
|
+
validateOptionalString(step.testId, `${stepPath}.testId`);
|
|
278
|
+
assert(
|
|
279
|
+
typeof step.testId === "string" && step.testId.trim().length > 0,
|
|
280
|
+
`"${stepPath}.testId" is required for action=select.`,
|
|
281
|
+
);
|
|
282
|
+
assert(Array.isArray(step.steps), `"${stepPath}.steps" must be an array for action=select.`);
|
|
283
|
+
step.steps.forEach((nestedStep, nestedIndex) => {
|
|
284
|
+
const nestedPath = `${stepPath}.steps[${nestedIndex}]`;
|
|
285
|
+
validateStepObject(nestedStep, nestedPath);
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (action === "click" || action === "dblclick" || action === "hover" || action === "rclick") {
|
|
291
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "x", "y"]));
|
|
292
|
+
const hasX = Object.prototype.hasOwnProperty.call(step, "x");
|
|
293
|
+
const hasY = Object.prototype.hasOwnProperty.call(step, "y");
|
|
294
|
+
assert(hasX === hasY, `"${stepPath}" requires both "x" and "y" together when provided.`);
|
|
295
|
+
if (hasX) {
|
|
296
|
+
validateOptionalNumber(step.x, `${stepPath}.x`);
|
|
297
|
+
validateOptionalNumber(step.y, `${stepPath}.y`);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (action === "move") {
|
|
303
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "x", "y"]));
|
|
304
|
+
validateOptionalNumber(step.x, `${stepPath}.x`);
|
|
305
|
+
validateOptionalNumber(step.y, `${stepPath}.y`);
|
|
306
|
+
assert(typeof step.x === "number", `"${stepPath}.x" is required for action=move.`);
|
|
307
|
+
assert(typeof step.y === "number", `"${stepPath}.y" is required for action=move.`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (action === "scroll") {
|
|
312
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "deltaX", "deltaY"]));
|
|
313
|
+
validateOptionalNumber(step.deltaX, `${stepPath}.deltaX`);
|
|
314
|
+
validateOptionalNumber(step.deltaY, `${stepPath}.deltaY`);
|
|
315
|
+
assert(typeof step.deltaX === "number", `"${stepPath}.deltaX" is required for action=scroll.`);
|
|
316
|
+
assert(typeof step.deltaY === "number", `"${stepPath}.deltaY" is required for action=scroll.`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (action === "goto") {
|
|
321
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "url"]));
|
|
322
|
+
validateOptionalString(step.url, `${stepPath}.url`);
|
|
323
|
+
assert(
|
|
324
|
+
typeof step.url === "string" && step.url.trim().length > 0,
|
|
325
|
+
`"${stepPath}.url" is required for action=goto.`,
|
|
326
|
+
);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (action === "keypress") {
|
|
331
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "key"]));
|
|
332
|
+
validateOptionalString(step.key, `${stepPath}.key`);
|
|
333
|
+
assert(
|
|
334
|
+
typeof step.key === "string" && step.key.trim().length > 0,
|
|
335
|
+
`"${stepPath}.key" is required for action=keypress.`,
|
|
336
|
+
);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (action === "wait") {
|
|
341
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "ms"]));
|
|
342
|
+
validateOptionalNumber(step.ms, `${stepPath}.ms`, { min: 0 });
|
|
343
|
+
assert(typeof step.ms === "number", `"${stepPath}.ms" is required for action=wait.`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (action === "setViewport") {
|
|
348
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "width", "height"]));
|
|
349
|
+
validateOptionalNumber(step.width, `${stepPath}.width`, { integer: true, min: 1 });
|
|
350
|
+
validateOptionalNumber(step.height, `${stepPath}.height`, { integer: true, min: 1 });
|
|
351
|
+
assert(typeof step.width === "number", `"${stepPath}.width" is required for action=setViewport.`);
|
|
352
|
+
assert(typeof step.height === "number", `"${stepPath}.height" is required for action=setViewport.`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (action === "write") {
|
|
357
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "value"]));
|
|
358
|
+
validateOptionalString(step.value, `${stepPath}.value`);
|
|
359
|
+
assert(typeof step.value === "string", `"${stepPath}.value" is required for action=write.`);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (action === "upload") {
|
|
364
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "files"]));
|
|
365
|
+
assert(Array.isArray(step.files), `"${stepPath}.files" must be an array for action=upload.`);
|
|
366
|
+
assert(step.files.length > 0, `"${stepPath}.files" cannot be empty for action=upload.`);
|
|
367
|
+
step.files.forEach((filePath, index) => {
|
|
368
|
+
assert(typeof filePath === "string", `"${stepPath}.files[${index}]" must be a string.`);
|
|
369
|
+
assert(filePath.trim().length > 0, `"${stepPath}.files[${index}]" cannot be empty.`);
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (action === "waitFor") {
|
|
375
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "selector", "state", "timeoutMs"]));
|
|
376
|
+
if (step.selector !== undefined) {
|
|
377
|
+
validateOptionalString(step.selector, `${stepPath}.selector`);
|
|
378
|
+
}
|
|
379
|
+
if (step.state !== undefined) {
|
|
380
|
+
validateOptionalEnum(step.state, `${stepPath}.state`, ["attached", "detached", "visible", "hidden"]);
|
|
381
|
+
}
|
|
382
|
+
if (step.timeoutMs !== undefined) {
|
|
383
|
+
validateOptionalNumber(step.timeoutMs, `${stepPath}.timeoutMs`, { integer: true, min: 0 });
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (action === "selectOption") {
|
|
389
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "value", "label", "index"]));
|
|
390
|
+
if (step.value !== undefined) {
|
|
391
|
+
validateOptionalString(step.value, `${stepPath}.value`);
|
|
392
|
+
}
|
|
393
|
+
if (step.label !== undefined) {
|
|
394
|
+
validateOptionalString(step.label, `${stepPath}.label`);
|
|
395
|
+
}
|
|
396
|
+
if (step.index !== undefined) {
|
|
397
|
+
validateOptionalNumber(step.index, `${stepPath}.index`);
|
|
398
|
+
}
|
|
399
|
+
const chosen = [step.value !== undefined, step.label !== undefined, step.index !== undefined]
|
|
400
|
+
.filter(Boolean)
|
|
401
|
+
.length;
|
|
402
|
+
assert(
|
|
403
|
+
chosen === 1,
|
|
404
|
+
`"${stepPath}" for action=selectOption requires exactly one of "value", "label", or "index".`,
|
|
405
|
+
);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (action === "customEvent") {
|
|
410
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action", "name", "detail"]));
|
|
411
|
+
validateOptionalString(step.name, `${stepPath}.name`);
|
|
412
|
+
assert(
|
|
413
|
+
typeof step.name === "string" && step.name.trim().length > 0,
|
|
414
|
+
`"${stepPath}.name" is required for action=customEvent.`,
|
|
415
|
+
);
|
|
416
|
+
if (step.detail !== undefined) {
|
|
417
|
+
assert(
|
|
418
|
+
isPlainObject(step.detail),
|
|
419
|
+
`"${stepPath}.detail" must be an object when provided.`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
assertNoUnknownStepKeys(step, stepPath, new Set(["action"]));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function validateStepObject(step, stepPath) {
|
|
429
|
+
assert(isPlainObject(step), `"${stepPath}" must be an object.`);
|
|
430
|
+
|
|
431
|
+
if (Object.prototype.hasOwnProperty.call(step, "action")) {
|
|
432
|
+
validateStructuredActionStep(step, stepPath);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const keys = Object.keys(step);
|
|
437
|
+
assert(
|
|
438
|
+
keys.length === 1,
|
|
439
|
+
`"${stepPath}" must have exactly one key (e.g. "select my-id"), got ${keys.length}.`,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const [stepKey] = keys;
|
|
443
|
+
validateOptionalString(stepKey, `${stepPath} key`);
|
|
444
|
+
|
|
445
|
+
if (stepKey === "assert") {
|
|
446
|
+
validateAssertObject(step[stepKey], `${stepPath}.assert`);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const nestedSteps = step[stepKey];
|
|
451
|
+
assert(Array.isArray(nestedSteps), `"${stepPath}.${stepKey}" must be an array of step values.`);
|
|
452
|
+
nestedSteps.forEach((nestedStep, nestedIndex) => {
|
|
453
|
+
const nestedPath = `${stepPath}.${stepKey}[${nestedIndex}]`;
|
|
454
|
+
validateStepObject(nestedStep, nestedPath);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function validateAssertObject(assertObject, assertPath) {
|
|
459
|
+
assert(isPlainObject(assertObject), `"${assertPath}" must be an object.`);
|
|
460
|
+
|
|
461
|
+
validateOptionalString(assertObject.type, `${assertPath}.type`);
|
|
462
|
+
assert(
|
|
463
|
+
typeof assertObject.type === "string" && assertObject.type.trim().length > 0,
|
|
464
|
+
`"${assertPath}.type" is required.`,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const type = assertObject.type;
|
|
468
|
+
assert(
|
|
469
|
+
["url", "exists", "visible", "hidden", "text", "js"].includes(type),
|
|
470
|
+
`"${assertPath}.type" must be one of: url, exists, visible, hidden, text, js.`,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
if (assertObject.match !== undefined) {
|
|
474
|
+
validateOptionalEnum(assertObject.match, `${assertPath}.match`, ["includes", "equals"]);
|
|
475
|
+
}
|
|
476
|
+
if (assertObject.timeoutMs !== undefined) {
|
|
477
|
+
validateOptionalNumber(assertObject.timeoutMs, `${assertPath}.timeoutMs`, { integer: true, min: 0 });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (type === "url") {
|
|
481
|
+
validateOptionalString(assertObject.value, `${assertPath}.value`);
|
|
482
|
+
assert(
|
|
483
|
+
typeof assertObject.value === "string" && assertObject.value.length > 0,
|
|
484
|
+
`"${assertPath}.value" is required for type=url and must be a non-empty string.`,
|
|
485
|
+
);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (type === "exists" || type === "visible" || type === "hidden") {
|
|
490
|
+
if (assertObject.selector !== undefined) {
|
|
491
|
+
validateOptionalString(assertObject.selector, `${assertPath}.selector`);
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (type === "text") {
|
|
497
|
+
if (assertObject.selector !== undefined) {
|
|
498
|
+
validateOptionalString(assertObject.selector, `${assertPath}.selector`);
|
|
499
|
+
}
|
|
500
|
+
validateOptionalString(assertObject.value, `${assertPath}.value`);
|
|
501
|
+
assert(
|
|
502
|
+
typeof assertObject.value === "string",
|
|
503
|
+
`"${assertPath}.value" is required for type=text and must be a string.`,
|
|
504
|
+
);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (type === "js") {
|
|
509
|
+
if (assertObject.global !== undefined) {
|
|
510
|
+
validateOptionalString(assertObject.global, `${assertPath}.global`);
|
|
511
|
+
}
|
|
512
|
+
if (assertObject.fn !== undefined) {
|
|
513
|
+
validateOptionalString(assertObject.fn, `${assertPath}.fn`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const hasGlobal = typeof assertObject.global === "string" && assertObject.global.length > 0;
|
|
517
|
+
const hasFn = typeof assertObject.fn === "string" && assertObject.fn.length > 0;
|
|
518
|
+
assert(
|
|
519
|
+
hasGlobal !== hasFn,
|
|
520
|
+
`"${assertPath}" for type=js requires exactly one of "global" or "fn".`,
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
assert(
|
|
524
|
+
Object.prototype.hasOwnProperty.call(assertObject, "value"),
|
|
525
|
+
`"${assertPath}.value" is required for type=js.`,
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
if (assertObject.args !== undefined) {
|
|
529
|
+
assert(
|
|
530
|
+
Array.isArray(assertObject.args),
|
|
531
|
+
`"${assertPath}.args" must be an array when provided.`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function validateVtConfig(vtConfig, sourcePath = "rettangoli.config.yaml") {
|
|
538
|
+
assert(
|
|
539
|
+
isPlainObject(vtConfig),
|
|
540
|
+
`Invalid VT config in ${sourcePath}: "vt" must be an object, got ${valueType(vtConfig)}.`,
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
validateOptionalString(vtConfig.path, "vt.path");
|
|
544
|
+
validateOptionalString(vtConfig.url, "vt.url");
|
|
545
|
+
validateOptionalString(vtConfig.name, "vt.name", { allowEmpty: true });
|
|
546
|
+
validateOptionalString(vtConfig.description, "vt.description", { allowEmpty: true });
|
|
547
|
+
validateOptionalString(vtConfig.compareMethod, "vt.compareMethod");
|
|
548
|
+
validateOptionalBoolean(vtConfig.skipScreenshots, "vt.skipScreenshots");
|
|
549
|
+
validateOptionalNumber(vtConfig.port, "vt.port", { integer: true, min: 1, max: 65535 });
|
|
550
|
+
validateOptionalNumber(vtConfig.concurrency, "vt.concurrency", { integer: true, min: 1 });
|
|
551
|
+
validateOptionalNumber(vtConfig.timeout, "vt.timeout", { integer: true, min: 1 });
|
|
552
|
+
validateOptionalString(vtConfig.waitEvent, "vt.waitEvent");
|
|
553
|
+
normalizeViewportField(vtConfig.viewport, "vt.viewport");
|
|
554
|
+
validateOptionalNumber(vtConfig.colorThreshold, "vt.colorThreshold", { min: 0, max: 1 });
|
|
555
|
+
validateOptionalNumber(vtConfig.diffThreshold, "vt.diffThreshold", { min: 0, max: 100 });
|
|
556
|
+
validateServiceConfig(vtConfig.service, sourcePath);
|
|
557
|
+
if (vtConfig.service) {
|
|
558
|
+
assert(
|
|
559
|
+
typeof vtConfig.url === "string" && vtConfig.url.trim().length > 0,
|
|
560
|
+
`Invalid VT config in ${sourcePath}: "vt.url" is required when "vt.service" is configured.`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
assertNoLegacyCaptureFields(vtConfig, sourcePath);
|
|
564
|
+
validateCaptureConfig(vtConfig.capture, sourcePath);
|
|
565
|
+
|
|
566
|
+
assert(Array.isArray(vtConfig.sections), `Invalid VT config in ${sourcePath}: "vt.sections" is required and must be an array.`);
|
|
567
|
+
assert(vtConfig.sections.length > 0, `Invalid VT config in ${sourcePath}: "vt.sections" cannot be empty.`);
|
|
568
|
+
vtConfig.sections.forEach(validateSection);
|
|
569
|
+
assertUniqueSectionPageKeys(vtConfig, sourcePath);
|
|
570
|
+
|
|
571
|
+
return vtConfig;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function validateFrontMatter(frontMatter, specPath) {
|
|
575
|
+
if (frontMatter === null || frontMatter === undefined) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
assert(
|
|
580
|
+
isPlainObject(frontMatter),
|
|
581
|
+
`Invalid front matter in "${specPath}": expected an object, got ${valueType(frontMatter)}.`,
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
validateOptionalString(frontMatter.title, `${specPath}: frontMatter.title`, { allowEmpty: true });
|
|
585
|
+
validateOptionalString(frontMatter.description, `${specPath}: frontMatter.description`, { allowEmpty: true });
|
|
586
|
+
validateOptionalString(frontMatter.template, `${specPath}: frontMatter.template`);
|
|
587
|
+
validateOptionalString(frontMatter.url, `${specPath}: frontMatter.url`);
|
|
588
|
+
validateOptionalString(frontMatter.waitEvent, `${specPath}: frontMatter.waitEvent`);
|
|
589
|
+
validateOptionalString(frontMatter.waitSelector, `${specPath}: frontMatter.waitSelector`);
|
|
590
|
+
normalizeViewportField(frontMatter.viewport, `${specPath}: frontMatter.viewport`);
|
|
591
|
+
validateOptionalEnum(
|
|
592
|
+
frontMatter.waitStrategy,
|
|
593
|
+
`${specPath}: frontMatter.waitStrategy`,
|
|
594
|
+
["networkidle", "load", "event", "selector"],
|
|
595
|
+
);
|
|
596
|
+
validateOptionalBoolean(frontMatter.skipScreenshot, `${specPath}: frontMatter.skipScreenshot`);
|
|
597
|
+
validateOptionalBoolean(
|
|
598
|
+
frontMatter.skipInitialScreenshot,
|
|
599
|
+
`${specPath}: frontMatter.skipInitialScreenshot`,
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
if (frontMatter.waitStrategy === "event") {
|
|
603
|
+
assert(
|
|
604
|
+
typeof frontMatter.waitEvent === "string" && frontMatter.waitEvent.trim().length > 0,
|
|
605
|
+
`"${specPath}: frontMatter.waitEvent" is required when waitStrategy is "event".`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
if (frontMatter.waitStrategy === "selector") {
|
|
609
|
+
assert(
|
|
610
|
+
typeof frontMatter.waitSelector === "string" && frontMatter.waitSelector.trim().length > 0,
|
|
611
|
+
`"${specPath}: frontMatter.waitSelector" is required when waitStrategy is "selector".`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (frontMatter.specs !== undefined) {
|
|
616
|
+
assert(Array.isArray(frontMatter.specs), `"${specPath}: frontMatter.specs" must be an array of strings.`);
|
|
617
|
+
frontMatter.specs.forEach((spec, index) => {
|
|
618
|
+
const specPathField = `${specPath}: frontMatter.specs[${index}]`;
|
|
619
|
+
assert(typeof spec === "string", `"${specPathField}" must be a string.`);
|
|
620
|
+
assert(spec.trim().length > 0, `"${specPathField}" cannot be empty.`);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (frontMatter.steps !== undefined) {
|
|
625
|
+
assert(Array.isArray(frontMatter.steps), `"${specPath}: frontMatter.steps" must be an array.`);
|
|
626
|
+
frontMatter.steps.forEach((step, index) => {
|
|
627
|
+
const stepPath = `${specPath}: frontMatter.steps[${index}]`;
|
|
628
|
+
validateStepObject(step, stepPath);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function validateFiniteNumber(value, fieldName, options = {}) {
|
|
634
|
+
validateOptionalNumber(value, fieldName, options);
|
|
635
|
+
return value;
|
|
636
|
+
}
|