@glubean/cli 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +267 -60
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/redact.d.ts.map +1 -1
- package/dist/commands/redact.js +32 -8
- package/dist/commands/redact.js.map +1 -1
- package/dist/commands/run.d.ts +110 -2
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +483 -40
- package/dist/commands/run.js.map +1 -1
- package/dist/lib/config.d.ts +267 -43
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +744 -149
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/env.d.ts +29 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +59 -0
- package/dist/lib/env.js.map +1 -0
- package/dist/lib/print-plan.d.ts +21 -0
- package/dist/lib/print-plan.d.ts.map +1 -0
- package/dist/lib/print-plan.js +108 -0
- package/dist/lib/print-plan.js.map +1 -0
- package/dist/lib/upload.d.ts +36 -1
- package/dist/lib/upload.d.ts.map +1 -1
- package/dist/lib/upload.js +126 -19
- package/dist/lib/upload.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +405 -27
- package/dist/main.js.map +1 -1
- package/package.json +5 -5
- package/templates/README.md +7 -13
- package/templates/demo/.env.example +7 -0
- package/templates/demo/.env.secrets.example +11 -0
- package/templates/demo/README.md +60 -0
- package/templates/demo/config/api.ts +24 -0
- package/templates/demo/gitignore.tpl +13 -0
- package/templates/demo/glubean.yaml +48 -0
- package/templates/demo/tests/api-flaky/search-flaky.test.ts +28 -0
- package/templates/demo/tests/api-stable/get-users.test.ts +30 -0
- package/templates/demo/tests/canary/synthetic-50pct-flaky.test.ts +23 -0
- package/templates/demo/tests/contracts/stable/users-contract.contract.ts +70 -0
- package/templates/demo/tsconfig.json +15 -0
- package/templates/AI-INSTRUCTIONS.md +0 -160
- package/templates/ci-config/ci.yaml +0 -13
- package/templates/ci-config/default.yaml +0 -9
- package/templates/ci-config/explore.yaml +0 -5
- package/templates/ci-config/staging.yaml +0 -9
package/dist/lib/config.js
CHANGED
|
@@ -12,11 +12,733 @@
|
|
|
12
12
|
* Files named "package.json" are special-cased: only the "glubean" field
|
|
13
13
|
* is extracted. All other files are treated as plain glubean config JSON.
|
|
14
14
|
*/
|
|
15
|
-
import { resolve
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
16
|
import { readFile } from "node:fs/promises";
|
|
17
17
|
import { parse as parseYaml } from "yaml";
|
|
18
18
|
import { DEFAULT_CONFIG } from "@glubean/redaction";
|
|
19
19
|
import { LOCAL_RUN_DEFAULTS } from "@glubean/runner";
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
// V1 LOADER (Phase 1 sub-task C — loadProjectConfigV1 with hard-error
|
|
22
|
+
// validation). Resolve + runtime wiring arrive in sub-tasks D and E.
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
/** Thrown by loadProjectConfigV1 — all hard validation failures use this. */
|
|
25
|
+
export class GlubeanConfigError extends Error {
|
|
26
|
+
path;
|
|
27
|
+
constructor(message, path) {
|
|
28
|
+
super(path ? `${message}\n in: ${path}` : message);
|
|
29
|
+
this.path = path;
|
|
30
|
+
this.name = "GlubeanConfigError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const V1_TOP_KEYS = new Set(["version", "defaults", "suites", "profiles", "mcp"]);
|
|
34
|
+
const V1_MCP_KEYS = new Set(["trace"]);
|
|
35
|
+
const V1_MCP_TRACE_KEYS = new Set(["keepRequestHeaders", "keepResponseHeaders"]);
|
|
36
|
+
const V1_SUITE_KEYS = new Set(["target", "kinds", "data"]);
|
|
37
|
+
const V1_SUITE_KINDS = new Set(["test", "contract", "flow"]);
|
|
38
|
+
const V1_SELECTION_KEYS = new Set([
|
|
39
|
+
"tags",
|
|
40
|
+
"excludeTags",
|
|
41
|
+
"filter",
|
|
42
|
+
"pick",
|
|
43
|
+
"tagMode",
|
|
44
|
+
]);
|
|
45
|
+
const V1_EXECUTION_KEYS = new Set([
|
|
46
|
+
"failFast",
|
|
47
|
+
"failAfter",
|
|
48
|
+
"timeoutMs",
|
|
49
|
+
"concurrency",
|
|
50
|
+
"noSession",
|
|
51
|
+
]);
|
|
52
|
+
const V1_CAPABILITIES_KEYS = new Set(["browser", "outOfBand", "optIn"]);
|
|
53
|
+
const V1_REPORTERS_KEYS = new Set([
|
|
54
|
+
"console",
|
|
55
|
+
"junit",
|
|
56
|
+
"resultJson",
|
|
57
|
+
"emitFullTrace",
|
|
58
|
+
"inferSchema",
|
|
59
|
+
"truncateArrays",
|
|
60
|
+
]);
|
|
61
|
+
const V1_UPLOAD_KEYS = new Set(["enabled", "projectAlias"]);
|
|
62
|
+
const V1_DEFAULTS_KEYS = new Set([
|
|
63
|
+
"envFile",
|
|
64
|
+
"selection",
|
|
65
|
+
"execution",
|
|
66
|
+
"capabilities",
|
|
67
|
+
"reporters",
|
|
68
|
+
"redaction",
|
|
69
|
+
"thresholds",
|
|
70
|
+
]);
|
|
71
|
+
const V1_PROFILE_KEYS = new Set([
|
|
72
|
+
"suites",
|
|
73
|
+
"selection",
|
|
74
|
+
"execution",
|
|
75
|
+
"capabilities",
|
|
76
|
+
"reporters",
|
|
77
|
+
"upload",
|
|
78
|
+
"thresholds",
|
|
79
|
+
]);
|
|
80
|
+
const V1_THRESHOLD_AGGREGATIONS = new Set([
|
|
81
|
+
"avg",
|
|
82
|
+
"min",
|
|
83
|
+
"max",
|
|
84
|
+
"p50",
|
|
85
|
+
"p90",
|
|
86
|
+
"p95",
|
|
87
|
+
"p99",
|
|
88
|
+
"count",
|
|
89
|
+
]);
|
|
90
|
+
const V1_REDACTION_KEYS = new Set([
|
|
91
|
+
"sensitiveKeys",
|
|
92
|
+
"customPatterns",
|
|
93
|
+
"replacementFormat",
|
|
94
|
+
]);
|
|
95
|
+
function assertOnlyKnownKeys(obj, known, context, configPath) {
|
|
96
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj))
|
|
97
|
+
return;
|
|
98
|
+
const unknown = Object.keys(obj).filter((k) => !known.has(k));
|
|
99
|
+
if (unknown.length > 0) {
|
|
100
|
+
throw new GlubeanConfigError(`Unknown key(s) at \`${context}\`: ${unknown.map((k) => `"${k}"`).join(", ")}. ` +
|
|
101
|
+
`Allowed keys: ${[...known].join(", ")}.`, configPath);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function assertType(value, expected, context, configPath) {
|
|
105
|
+
let ok = false;
|
|
106
|
+
if (expected === "array")
|
|
107
|
+
ok = Array.isArray(value);
|
|
108
|
+
else if (expected === "object")
|
|
109
|
+
ok = value !== null && typeof value === "object" && !Array.isArray(value);
|
|
110
|
+
else if (expected === "number") {
|
|
111
|
+
// YAML `.nan` / `.inf` / `-.inf` parse as JS numbers but are not usable
|
|
112
|
+
// for execution settings (NaN concurrency → no workers, etc). Reject.
|
|
113
|
+
ok = typeof value === "number" && Number.isFinite(value);
|
|
114
|
+
}
|
|
115
|
+
else
|
|
116
|
+
ok = typeof value === expected;
|
|
117
|
+
if (!ok) {
|
|
118
|
+
let got;
|
|
119
|
+
if (value === null)
|
|
120
|
+
got = "null";
|
|
121
|
+
else if (Array.isArray(value))
|
|
122
|
+
got = "array";
|
|
123
|
+
else if (typeof value === "number" && !Number.isFinite(value)) {
|
|
124
|
+
got = Number.isNaN(value) ? "NaN" : value > 0 ? "Infinity" : "-Infinity";
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
got = typeof value;
|
|
128
|
+
throw new GlubeanConfigError(`Expected \`${context}\` to be ${expected}, got ${got}.`, configPath);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function validateSuite(name, raw, configPath) {
|
|
132
|
+
assertType(raw, "object", `suites.${name}`, configPath);
|
|
133
|
+
assertOnlyKnownKeys(raw, V1_SUITE_KEYS, `suites.${name}`, configPath);
|
|
134
|
+
const s = raw;
|
|
135
|
+
if (s.target === undefined) {
|
|
136
|
+
throw new GlubeanConfigError(`Missing required field \`suites.${name}.target\`.`, configPath);
|
|
137
|
+
}
|
|
138
|
+
assertType(s.target, "string", `suites.${name}.target`, configPath);
|
|
139
|
+
if (s.kinds === undefined) {
|
|
140
|
+
throw new GlubeanConfigError(`Missing required field \`suites.${name}.kinds\` ` +
|
|
141
|
+
`(any of: ${[...V1_SUITE_KINDS].join(", ")}).`, configPath);
|
|
142
|
+
}
|
|
143
|
+
assertType(s.kinds, "array", `suites.${name}.kinds`, configPath);
|
|
144
|
+
const kinds = s.kinds;
|
|
145
|
+
if (kinds.length === 0) {
|
|
146
|
+
throw new GlubeanConfigError(`\`suites.${name}.kinds\` cannot be empty.`, configPath);
|
|
147
|
+
}
|
|
148
|
+
for (const k of kinds) {
|
|
149
|
+
if (typeof k !== "string" || !V1_SUITE_KINDS.has(k)) {
|
|
150
|
+
throw new GlubeanConfigError(`Invalid kind ${JSON.stringify(k)} in \`suites.${name}.kinds\`. ` +
|
|
151
|
+
`Allowed: ${[...V1_SUITE_KINDS].join(", ")}.`, configPath);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (s.data !== undefined) {
|
|
155
|
+
assertType(s.data, "string", `suites.${name}.data`, configPath);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
target: s.target,
|
|
159
|
+
kinds: kinds,
|
|
160
|
+
...(s.data !== undefined && { data: s.data }),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function validateSelection(raw, context, configPath) {
|
|
164
|
+
if (raw === undefined)
|
|
165
|
+
return {};
|
|
166
|
+
assertType(raw, "object", context, configPath);
|
|
167
|
+
assertOnlyKnownKeys(raw, V1_SELECTION_KEYS, context, configPath);
|
|
168
|
+
const s = raw;
|
|
169
|
+
const out = {};
|
|
170
|
+
if (s.tags !== undefined) {
|
|
171
|
+
assertType(s.tags, "array", `${context}.tags`, configPath);
|
|
172
|
+
out.tags = s.tags.map((t) => {
|
|
173
|
+
if (typeof t !== "string") {
|
|
174
|
+
throw new GlubeanConfigError(`\`${context}.tags\` must be an array of strings.`, configPath);
|
|
175
|
+
}
|
|
176
|
+
return t;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (s.excludeTags !== undefined) {
|
|
180
|
+
assertType(s.excludeTags, "array", `${context}.excludeTags`, configPath);
|
|
181
|
+
out.excludeTags = s.excludeTags.map((t) => {
|
|
182
|
+
if (typeof t !== "string") {
|
|
183
|
+
throw new GlubeanConfigError(`\`${context}.excludeTags\` must be an array of strings.`, configPath);
|
|
184
|
+
}
|
|
185
|
+
return t;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (s.filter !== undefined) {
|
|
189
|
+
assertType(s.filter, "string", `${context}.filter`, configPath);
|
|
190
|
+
out.filter = s.filter;
|
|
191
|
+
}
|
|
192
|
+
if (s.pick !== undefined) {
|
|
193
|
+
assertType(s.pick, "string", `${context}.pick`, configPath);
|
|
194
|
+
out.pick = s.pick;
|
|
195
|
+
}
|
|
196
|
+
if (s.tagMode !== undefined) {
|
|
197
|
+
if (s.tagMode !== "or" && s.tagMode !== "and") {
|
|
198
|
+
throw new GlubeanConfigError(`\`${context}.tagMode\` must be "or" or "and", got ${JSON.stringify(s.tagMode)}.`, configPath);
|
|
199
|
+
}
|
|
200
|
+
out.tagMode = s.tagMode;
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
function assertPositiveInt(value, context, configPath) {
|
|
205
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
206
|
+
throw new GlubeanConfigError(`\`${context}\` must be a positive integer (≥ 1), got ${value}.`, configPath);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function validateExecution(raw, context, configPath) {
|
|
210
|
+
if (raw === undefined)
|
|
211
|
+
return {};
|
|
212
|
+
assertType(raw, "object", context, configPath);
|
|
213
|
+
assertOnlyKnownKeys(raw, V1_EXECUTION_KEYS, context, configPath);
|
|
214
|
+
const s = raw;
|
|
215
|
+
const out = {};
|
|
216
|
+
if (s.failFast !== undefined) {
|
|
217
|
+
assertType(s.failFast, "boolean", `${context}.failFast`, configPath);
|
|
218
|
+
out.failFast = s.failFast;
|
|
219
|
+
}
|
|
220
|
+
if (s.failAfter !== undefined && s.failAfter !== null) {
|
|
221
|
+
assertType(s.failAfter, "number", `${context}.failAfter`, configPath);
|
|
222
|
+
// Runner stops at `failedCount >= failureLimit`. 0/negative would
|
|
223
|
+
// make the profile run zero tests. Use `null` to disable the limit.
|
|
224
|
+
assertPositiveInt(s.failAfter, `${context}.failAfter`, configPath);
|
|
225
|
+
out.failAfter = s.failAfter;
|
|
226
|
+
}
|
|
227
|
+
else if (s.failAfter === null) {
|
|
228
|
+
out.failAfter = null;
|
|
229
|
+
}
|
|
230
|
+
if (s.timeoutMs !== undefined) {
|
|
231
|
+
assertType(s.timeoutMs, "number", `${context}.timeoutMs`, configPath);
|
|
232
|
+
assertPositiveInt(s.timeoutMs, `${context}.timeoutMs`, configPath);
|
|
233
|
+
out.timeoutMs = s.timeoutMs;
|
|
234
|
+
}
|
|
235
|
+
if (s.concurrency !== undefined) {
|
|
236
|
+
assertType(s.concurrency, "number", `${context}.concurrency`, configPath);
|
|
237
|
+
assertPositiveInt(s.concurrency, `${context}.concurrency`, configPath);
|
|
238
|
+
out.concurrency = s.concurrency;
|
|
239
|
+
}
|
|
240
|
+
if (s.noSession !== undefined) {
|
|
241
|
+
assertType(s.noSession, "boolean", `${context}.noSession`, configPath);
|
|
242
|
+
out.noSession = s.noSession;
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
function validateBoolMap(raw, known, context, configPath) {
|
|
247
|
+
if (raw === undefined)
|
|
248
|
+
return {};
|
|
249
|
+
assertType(raw, "object", context, configPath);
|
|
250
|
+
assertOnlyKnownKeys(raw, known, context, configPath);
|
|
251
|
+
const s = raw;
|
|
252
|
+
const out = {};
|
|
253
|
+
for (const k of Object.keys(s)) {
|
|
254
|
+
assertType(s[k], "boolean", `${context}.${k}`, configPath);
|
|
255
|
+
out[k] = s[k];
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
function validateReporters(raw, context, configPath) {
|
|
260
|
+
if (raw === undefined)
|
|
261
|
+
return {};
|
|
262
|
+
assertType(raw, "object", context, configPath);
|
|
263
|
+
assertOnlyKnownKeys(raw, V1_REPORTERS_KEYS, context, configPath);
|
|
264
|
+
const s = raw;
|
|
265
|
+
const out = {};
|
|
266
|
+
if (s.console !== undefined) {
|
|
267
|
+
if (s.console !== "detailed" && s.console !== "summary") {
|
|
268
|
+
throw new GlubeanConfigError(`\`${context}.console\` must be "detailed" or "summary", got ${JSON.stringify(s.console)}.`, configPath);
|
|
269
|
+
}
|
|
270
|
+
out.console = s.console;
|
|
271
|
+
}
|
|
272
|
+
if (s.junit !== undefined) {
|
|
273
|
+
assertType(s.junit, "string", `${context}.junit`, configPath);
|
|
274
|
+
out.junit = s.junit;
|
|
275
|
+
}
|
|
276
|
+
if (s.resultJson !== undefined) {
|
|
277
|
+
assertType(s.resultJson, "string", `${context}.resultJson`, configPath);
|
|
278
|
+
out.resultJson = s.resultJson;
|
|
279
|
+
}
|
|
280
|
+
if (s.emitFullTrace !== undefined) {
|
|
281
|
+
assertType(s.emitFullTrace, "boolean", `${context}.emitFullTrace`, configPath);
|
|
282
|
+
out.emitFullTrace = s.emitFullTrace;
|
|
283
|
+
}
|
|
284
|
+
if (s.inferSchema !== undefined) {
|
|
285
|
+
assertType(s.inferSchema, "boolean", `${context}.inferSchema`, configPath);
|
|
286
|
+
out.inferSchema = s.inferSchema;
|
|
287
|
+
}
|
|
288
|
+
if (s.truncateArrays !== undefined) {
|
|
289
|
+
assertType(s.truncateArrays, "boolean", `${context}.truncateArrays`, configPath);
|
|
290
|
+
out.truncateArrays = s.truncateArrays;
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
function validateUpload(raw, context, configPath) {
|
|
295
|
+
assertType(raw, "object", context, configPath);
|
|
296
|
+
assertOnlyKnownKeys(raw, V1_UPLOAD_KEYS, context, configPath);
|
|
297
|
+
const s = raw;
|
|
298
|
+
const out = {};
|
|
299
|
+
if (s.enabled !== undefined) {
|
|
300
|
+
assertType(s.enabled, "boolean", `${context}.enabled`, configPath);
|
|
301
|
+
out.enabled = s.enabled;
|
|
302
|
+
}
|
|
303
|
+
if (s.projectAlias !== undefined) {
|
|
304
|
+
assertType(s.projectAlias, "string", `${context}.projectAlias`, configPath);
|
|
305
|
+
out.projectAlias = s.projectAlias;
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
function validateRedaction(raw, context, configPath) {
|
|
310
|
+
if (raw === undefined)
|
|
311
|
+
return {};
|
|
312
|
+
assertType(raw, "object", context, configPath);
|
|
313
|
+
assertOnlyKnownKeys(raw, V1_REDACTION_KEYS, context, configPath);
|
|
314
|
+
const r = raw;
|
|
315
|
+
const out = {};
|
|
316
|
+
if (r.sensitiveKeys !== undefined) {
|
|
317
|
+
assertType(r.sensitiveKeys, "array", `${context}.sensitiveKeys`, configPath);
|
|
318
|
+
out.sensitiveKeys = r.sensitiveKeys.map((k, i) => {
|
|
319
|
+
if (typeof k !== "string") {
|
|
320
|
+
throw new GlubeanConfigError(`\`${context}.sensitiveKeys[${i}]\` must be a string, got ${typeof k}.`, configPath);
|
|
321
|
+
}
|
|
322
|
+
return k;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (r.customPatterns !== undefined) {
|
|
326
|
+
assertType(r.customPatterns, "array", `${context}.customPatterns`, configPath);
|
|
327
|
+
out.customPatterns = r.customPatterns.map((p, i) => {
|
|
328
|
+
const ctx = `${context}.customPatterns[${i}]`;
|
|
329
|
+
assertType(p, "object", ctx, configPath);
|
|
330
|
+
assertOnlyKnownKeys(p, new Set(["name", "regex"]), ctx, configPath);
|
|
331
|
+
const obj = p;
|
|
332
|
+
if (typeof obj.name !== "string") {
|
|
333
|
+
throw new GlubeanConfigError(`\`${ctx}.name\` is required and must be a string.`, configPath);
|
|
334
|
+
}
|
|
335
|
+
if (typeof obj.regex !== "string") {
|
|
336
|
+
throw new GlubeanConfigError(`\`${ctx}.regex\` is required and must be a string.`, configPath);
|
|
337
|
+
}
|
|
338
|
+
// Compile regex eagerly so malformed patterns fail at load time.
|
|
339
|
+
try {
|
|
340
|
+
new RegExp(obj.regex);
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
throw new GlubeanConfigError(`\`${ctx}.regex\` is not a valid regular expression: ${err.message}`, configPath);
|
|
344
|
+
}
|
|
345
|
+
return { name: obj.name, regex: obj.regex };
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
if (r.replacementFormat !== undefined) {
|
|
349
|
+
if (r.replacementFormat !== "simple" &&
|
|
350
|
+
r.replacementFormat !== "labeled" &&
|
|
351
|
+
r.replacementFormat !== "partial") {
|
|
352
|
+
throw new GlubeanConfigError(`\`${context}.replacementFormat\` must be "simple", "labeled", or "partial", got ${JSON.stringify(r.replacementFormat)}.`, configPath);
|
|
353
|
+
}
|
|
354
|
+
out.replacementFormat = r.replacementFormat;
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
function validateThresholds(raw, context, configPath) {
|
|
359
|
+
if (raw === undefined)
|
|
360
|
+
return {};
|
|
361
|
+
assertType(raw, "object", context, configPath);
|
|
362
|
+
const t = raw;
|
|
363
|
+
const out = {};
|
|
364
|
+
for (const metric of Object.keys(t)) {
|
|
365
|
+
const value = t[metric];
|
|
366
|
+
const ctx = `${context}.${metric}`;
|
|
367
|
+
// Shorthand: a bare string is the expression for the `avg` aggregation.
|
|
368
|
+
if (typeof value === "string") {
|
|
369
|
+
out[metric] = value;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
// Otherwise: a map of aggregation → expression string.
|
|
373
|
+
assertType(value, "object", ctx, configPath);
|
|
374
|
+
assertOnlyKnownKeys(value, V1_THRESHOLD_AGGREGATIONS, ctx, configPath);
|
|
375
|
+
const rules = value;
|
|
376
|
+
const metricRules = {};
|
|
377
|
+
for (const agg of Object.keys(rules)) {
|
|
378
|
+
if (typeof rules[agg] !== "string") {
|
|
379
|
+
throw new GlubeanConfigError(`\`${ctx}.${agg}\` must be a threshold expression string (e.g. "<200"), got ${typeof rules[agg]}.`, configPath);
|
|
380
|
+
}
|
|
381
|
+
metricRules[agg] = rules[agg];
|
|
382
|
+
}
|
|
383
|
+
out[metric] = metricRules;
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
function validateProfile(name, raw, suiteNames, configPath) {
|
|
388
|
+
assertType(raw, "object", `profiles.${name}`, configPath);
|
|
389
|
+
assertOnlyKnownKeys(raw, V1_PROFILE_KEYS, `profiles.${name}`, configPath);
|
|
390
|
+
const p = raw;
|
|
391
|
+
if (p.suites === undefined) {
|
|
392
|
+
throw new GlubeanConfigError(`Missing required field \`profiles.${name}.suites\` (array of suite names).`, configPath);
|
|
393
|
+
}
|
|
394
|
+
assertType(p.suites, "array", `profiles.${name}.suites`, configPath);
|
|
395
|
+
const suites = p.suites.map((s) => {
|
|
396
|
+
if (typeof s !== "string") {
|
|
397
|
+
throw new GlubeanConfigError(`\`profiles.${name}.suites\` must be an array of suite-name strings.`, configPath);
|
|
398
|
+
}
|
|
399
|
+
if (!suiteNames.has(s)) {
|
|
400
|
+
throw new GlubeanConfigError(`\`profiles.${name}.suites\` references undefined suite "${s}". ` +
|
|
401
|
+
`Defined suites: ${[...suiteNames].join(", ") || "(none)"}.`, configPath);
|
|
402
|
+
}
|
|
403
|
+
return s;
|
|
404
|
+
});
|
|
405
|
+
return {
|
|
406
|
+
suites,
|
|
407
|
+
selection: validateSelection(p.selection, `profiles.${name}.selection`, configPath),
|
|
408
|
+
execution: validateExecution(p.execution, `profiles.${name}.execution`, configPath),
|
|
409
|
+
capabilities: validateBoolMap(p.capabilities, V1_CAPABILITIES_KEYS, `profiles.${name}.capabilities`, configPath),
|
|
410
|
+
reporters: validateReporters(p.reporters, `profiles.${name}.reporters`, configPath),
|
|
411
|
+
...(p.upload !== undefined && {
|
|
412
|
+
upload: validateUpload(p.upload, `profiles.${name}.upload`, configPath),
|
|
413
|
+
}),
|
|
414
|
+
...(p.thresholds !== undefined && {
|
|
415
|
+
thresholds: validateThresholds(p.thresholds, `profiles.${name}.thresholds`, configPath),
|
|
416
|
+
}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
function validateDefaults(raw, configPath) {
|
|
420
|
+
if (raw === undefined)
|
|
421
|
+
return {};
|
|
422
|
+
assertType(raw, "object", "defaults", configPath);
|
|
423
|
+
assertOnlyKnownKeys(raw, V1_DEFAULTS_KEYS, "defaults", configPath);
|
|
424
|
+
const d = raw;
|
|
425
|
+
const out = {};
|
|
426
|
+
if (d.envFile !== undefined) {
|
|
427
|
+
assertType(d.envFile, "string", "defaults.envFile", configPath);
|
|
428
|
+
out.envFile = d.envFile;
|
|
429
|
+
}
|
|
430
|
+
out.selection = validateSelection(d.selection, "defaults.selection", configPath);
|
|
431
|
+
out.execution = validateExecution(d.execution, "defaults.execution", configPath);
|
|
432
|
+
out.capabilities = validateBoolMap(d.capabilities, V1_CAPABILITIES_KEYS, "defaults.capabilities", configPath);
|
|
433
|
+
out.reporters = validateReporters(d.reporters, "defaults.reporters", configPath);
|
|
434
|
+
out.redaction = validateRedaction(d.redaction, "defaults.redaction", configPath);
|
|
435
|
+
if (d.thresholds !== undefined) {
|
|
436
|
+
out.thresholds = validateThresholds(d.thresholds, "defaults.thresholds", configPath);
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
function validateMcp(raw, configPath) {
|
|
441
|
+
if (raw === undefined)
|
|
442
|
+
return {};
|
|
443
|
+
assertType(raw, "object", "mcp", configPath);
|
|
444
|
+
assertOnlyKnownKeys(raw, V1_MCP_KEYS, "mcp", configPath);
|
|
445
|
+
const m = raw;
|
|
446
|
+
const out = {};
|
|
447
|
+
if (m.trace !== undefined) {
|
|
448
|
+
assertType(m.trace, "object", "mcp.trace", configPath);
|
|
449
|
+
assertOnlyKnownKeys(m.trace, V1_MCP_TRACE_KEYS, "mcp.trace", configPath);
|
|
450
|
+
const t = m.trace;
|
|
451
|
+
const trace = {};
|
|
452
|
+
for (const key of ["keepRequestHeaders", "keepResponseHeaders"]) {
|
|
453
|
+
if (t[key] !== undefined) {
|
|
454
|
+
assertType(t[key], "array", `mcp.trace.${key}`, configPath);
|
|
455
|
+
trace[key] = t[key].map((h, i) => {
|
|
456
|
+
if (typeof h !== "string") {
|
|
457
|
+
throw new GlubeanConfigError(`\`mcp.trace.${key}[${i}]\` must be a string, got ${typeof h}.`, configPath);
|
|
458
|
+
}
|
|
459
|
+
return h;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
out.trace = trace;
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Load + validate v1 project config.
|
|
469
|
+
*
|
|
470
|
+
* - Default path: `rootDir/glubean.yaml`
|
|
471
|
+
* - Override: `options.configPath` (CLI `--config` plumbed here) — resolved
|
|
472
|
+
* against rootDir if relative. Lets users keep multiple variants on disk
|
|
473
|
+
* while still using v1 schema.
|
|
474
|
+
*
|
|
475
|
+
* Hard-errors on:
|
|
476
|
+
* - File missing → `GlubeanConfigError("glubean.yaml not found ...")`
|
|
477
|
+
* - YAML parse failure → wraps underlying error in `GlubeanConfigError`
|
|
478
|
+
* - Missing/wrong `version` (must be `1`)
|
|
479
|
+
* - Any unknown key at any nesting level (drops the warning behavior of the
|
|
480
|
+
* legacy loader)
|
|
481
|
+
* - Missing required fields (`suites`, `profiles`, `suite.target`, `suite.kinds`,
|
|
482
|
+
* `profile.suites`)
|
|
483
|
+
* - Wrong type on any field
|
|
484
|
+
* - Profile that references an undefined suite name
|
|
485
|
+
*
|
|
486
|
+
* Returns the parsed + validated config plus the absolute path it loaded from
|
|
487
|
+
* (used downstream so `ResolvedRunPlan.configPath` can be populated in sub-task D).
|
|
488
|
+
*/
|
|
489
|
+
export async function loadProjectConfigV1(rootDir, options = {}) {
|
|
490
|
+
const configPath = options.configPath
|
|
491
|
+
? resolve(rootDir, options.configPath)
|
|
492
|
+
: resolve(rootDir, "glubean.yaml");
|
|
493
|
+
let content;
|
|
494
|
+
try {
|
|
495
|
+
content = await readFile(configPath, "utf-8");
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
const code = err.code;
|
|
499
|
+
if (code === "ENOENT") {
|
|
500
|
+
throw new GlubeanConfigError(`glubean.yaml not found at ${configPath}. ` +
|
|
501
|
+
`Run \`glubean init\` to create one, or pass \`--config <path>\` ` +
|
|
502
|
+
`to load from a different location.`);
|
|
503
|
+
}
|
|
504
|
+
throw new GlubeanConfigError(`Failed to read glubean.yaml: ${err.message}`, configPath);
|
|
505
|
+
}
|
|
506
|
+
let parsed;
|
|
507
|
+
try {
|
|
508
|
+
parsed = parseYaml(content);
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
throw new GlubeanConfigError(`Failed to parse glubean.yaml: ${err.message}`, configPath);
|
|
512
|
+
}
|
|
513
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
514
|
+
throw new GlubeanConfigError(`glubean.yaml must contain a top-level mapping (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}).`, configPath);
|
|
515
|
+
}
|
|
516
|
+
const root = parsed;
|
|
517
|
+
assertOnlyKnownKeys(root, V1_TOP_KEYS, "glubean.yaml", configPath);
|
|
518
|
+
if (root.version !== 1) {
|
|
519
|
+
throw new GlubeanConfigError(`\`version\` must be the integer \`1\` (got ${JSON.stringify(root.version)}). ` +
|
|
520
|
+
`Add \`version: 1\` to the top of glubean.yaml.`, configPath);
|
|
521
|
+
}
|
|
522
|
+
if (root.suites === undefined) {
|
|
523
|
+
throw new GlubeanConfigError(`Missing required top-level field \`suites\` (map of suite name → suite config).`, configPath);
|
|
524
|
+
}
|
|
525
|
+
assertType(root.suites, "object", "suites", configPath);
|
|
526
|
+
const suitesIn = root.suites;
|
|
527
|
+
const suites = {};
|
|
528
|
+
for (const name of Object.keys(suitesIn)) {
|
|
529
|
+
suites[name] = validateSuite(name, suitesIn[name], configPath);
|
|
530
|
+
}
|
|
531
|
+
if (root.profiles === undefined) {
|
|
532
|
+
throw new GlubeanConfigError(`Missing required top-level field \`profiles\` (map of profile name → profile config).`, configPath);
|
|
533
|
+
}
|
|
534
|
+
assertType(root.profiles, "object", "profiles", configPath);
|
|
535
|
+
const profilesIn = root.profiles;
|
|
536
|
+
const suiteNames = new Set(Object.keys(suites));
|
|
537
|
+
const profiles = {};
|
|
538
|
+
for (const name of Object.keys(profilesIn)) {
|
|
539
|
+
profiles[name] = validateProfile(name, profilesIn[name], suiteNames, configPath);
|
|
540
|
+
}
|
|
541
|
+
const config = {
|
|
542
|
+
version: 1,
|
|
543
|
+
defaults: validateDefaults(root.defaults, configPath),
|
|
544
|
+
suites,
|
|
545
|
+
profiles,
|
|
546
|
+
...(root.mcp !== undefined && { mcp: validateMcp(root.mcp, configPath) }),
|
|
547
|
+
};
|
|
548
|
+
return { config, configPath };
|
|
549
|
+
}
|
|
550
|
+
/** Built-in defaults applied when neither config.defaults nor profile sets a value. */
|
|
551
|
+
export const RESOLVED_PLAN_BUILTIN_DEFAULTS = {
|
|
552
|
+
envFile: ".env",
|
|
553
|
+
selection: {
|
|
554
|
+
tags: [],
|
|
555
|
+
excludeTags: [],
|
|
556
|
+
tagMode: "or",
|
|
557
|
+
},
|
|
558
|
+
execution: {
|
|
559
|
+
failFast: false,
|
|
560
|
+
failAfter: null,
|
|
561
|
+
timeoutMs: 30000,
|
|
562
|
+
concurrency: 4,
|
|
563
|
+
noSession: false,
|
|
564
|
+
},
|
|
565
|
+
capabilities: {
|
|
566
|
+
browser: false,
|
|
567
|
+
outOfBand: false,
|
|
568
|
+
optIn: false,
|
|
569
|
+
},
|
|
570
|
+
reporters: {
|
|
571
|
+
console: "detailed",
|
|
572
|
+
emitFullTrace: false,
|
|
573
|
+
inferSchema: false,
|
|
574
|
+
truncateArrays: false,
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
/**
|
|
578
|
+
* Resolve a profile against a loaded config + optional CLI overrides.
|
|
579
|
+
*
|
|
580
|
+
* Throws `GlubeanConfigError` when `profileName` is not in `config.profiles`
|
|
581
|
+
* (with a list of available profiles, per plan §Phase 1 acceptance).
|
|
582
|
+
*
|
|
583
|
+
* Merge order (later layer wins per-field; arrays REPLACE, never concat):
|
|
584
|
+
* 1. Built-in defaults (RESOLVED_PLAN_BUILTIN_DEFAULTS)
|
|
585
|
+
* 2. config.defaults (user's `defaults:` block)
|
|
586
|
+
* 3. config.profiles[profileName] (profile-specific)
|
|
587
|
+
* 4. cliOverrides (CLI flags)
|
|
588
|
+
*
|
|
589
|
+
* `suites` field on the profile is expanded: each name is looked up in
|
|
590
|
+
* `config.suites` (loader already validated cross-refs) and returned as a
|
|
591
|
+
* `{name, ...SuiteConfig}` array preserving the order from the profile.
|
|
592
|
+
*/
|
|
593
|
+
export function resolveRunPlan(config, configPath, profileName, cliOverrides = {}) {
|
|
594
|
+
const profile = config.profiles[profileName];
|
|
595
|
+
if (!profile) {
|
|
596
|
+
const available = Object.keys(config.profiles).sort();
|
|
597
|
+
throw new GlubeanConfigError(`Profile "${profileName}" not found. Available profiles: ${available.length > 0 ? available.join(", ") : "(none defined)"}.`, configPath);
|
|
598
|
+
}
|
|
599
|
+
const defaults = config.defaults ?? {};
|
|
600
|
+
const builtin = RESOLVED_PLAN_BUILTIN_DEFAULTS;
|
|
601
|
+
// Suites: expand profile.suites name list to full SuiteConfig+name objects.
|
|
602
|
+
// CLI `--suite <name>` (cliOverrides.suites) filters the profile's list
|
|
603
|
+
// to a subset. Each override name MUST appear in profile.suites — running
|
|
604
|
+
// a suite the profile didn't include would change the run semantics
|
|
605
|
+
// beyond a temporary override; the user is asked to either edit the
|
|
606
|
+
// profile or pick a different profile.
|
|
607
|
+
let effectiveSuiteNames = profile.suites;
|
|
608
|
+
if (cliOverrides.suites && cliOverrides.suites.length > 0) {
|
|
609
|
+
const profileSuiteSet = new Set(profile.suites);
|
|
610
|
+
const unknown = cliOverrides.suites.filter((n) => !profileSuiteSet.has(n));
|
|
611
|
+
if (unknown.length > 0) {
|
|
612
|
+
throw new GlubeanConfigError(`--suite ${unknown.join(", ")} not declared in profile "${profileName}". ` +
|
|
613
|
+
`Profile suites: ${profile.suites.join(", ") || "(none)"}.`, configPath);
|
|
614
|
+
}
|
|
615
|
+
// Preserve override order so user-given order controls execution sequence.
|
|
616
|
+
effectiveSuiteNames = cliOverrides.suites;
|
|
617
|
+
}
|
|
618
|
+
const suites = effectiveSuiteNames.map((name) => ({
|
|
619
|
+
name,
|
|
620
|
+
...config.suites[name],
|
|
621
|
+
}));
|
|
622
|
+
// ── Selection ──────────────────────────────────────────────────────────
|
|
623
|
+
// Arrays (tags / excludeTags) REPLACE per layer, not concat.
|
|
624
|
+
const selection = {
|
|
625
|
+
tags: cliOverrides.tags ??
|
|
626
|
+
profile.selection?.tags ??
|
|
627
|
+
defaults.selection?.tags ??
|
|
628
|
+
builtin.selection.tags,
|
|
629
|
+
excludeTags: cliOverrides.excludeTags ??
|
|
630
|
+
profile.selection?.excludeTags ??
|
|
631
|
+
defaults.selection?.excludeTags ??
|
|
632
|
+
builtin.selection.excludeTags,
|
|
633
|
+
tagMode: cliOverrides.tagMode ??
|
|
634
|
+
profile.selection?.tagMode ??
|
|
635
|
+
defaults.selection?.tagMode ??
|
|
636
|
+
builtin.selection.tagMode,
|
|
637
|
+
filter: cliOverrides.filter ?? profile.selection?.filter ?? defaults.selection?.filter,
|
|
638
|
+
pick: cliOverrides.pick ?? profile.selection?.pick ?? defaults.selection?.pick,
|
|
639
|
+
};
|
|
640
|
+
// ── Execution ──────────────────────────────────────────────────────────
|
|
641
|
+
// failAfter: explicit `null` from any layer means "no limit" (preserved).
|
|
642
|
+
const execution = {
|
|
643
|
+
failFast: cliOverrides.failFast ??
|
|
644
|
+
profile.execution?.failFast ??
|
|
645
|
+
defaults.execution?.failFast ??
|
|
646
|
+
builtin.execution.failFast,
|
|
647
|
+
failAfter: cliOverrides.failAfter !== undefined
|
|
648
|
+
? cliOverrides.failAfter
|
|
649
|
+
: profile.execution?.failAfter !== undefined
|
|
650
|
+
? profile.execution.failAfter
|
|
651
|
+
: defaults.execution?.failAfter !== undefined
|
|
652
|
+
? defaults.execution.failAfter
|
|
653
|
+
: builtin.execution.failAfter,
|
|
654
|
+
timeoutMs: cliOverrides.timeoutMs ??
|
|
655
|
+
profile.execution?.timeoutMs ??
|
|
656
|
+
defaults.execution?.timeoutMs ??
|
|
657
|
+
builtin.execution.timeoutMs,
|
|
658
|
+
concurrency: cliOverrides.concurrency ??
|
|
659
|
+
profile.execution?.concurrency ??
|
|
660
|
+
defaults.execution?.concurrency ??
|
|
661
|
+
builtin.execution.concurrency,
|
|
662
|
+
noSession: cliOverrides.noSession ??
|
|
663
|
+
profile.execution?.noSession ??
|
|
664
|
+
defaults.execution?.noSession ??
|
|
665
|
+
builtin.execution.noSession,
|
|
666
|
+
};
|
|
667
|
+
// ── Capabilities ───────────────────────────────────────────────────────
|
|
668
|
+
// CLI overrides use include* naming (--include-browser etc.) which maps
|
|
669
|
+
// 1:1 to the capability gates here. CLI flag presence means "include"
|
|
670
|
+
// (true); absence falls through to profile/defaults.
|
|
671
|
+
const capabilities = {
|
|
672
|
+
browser: cliOverrides.includeBrowser ??
|
|
673
|
+
profile.capabilities?.browser ??
|
|
674
|
+
defaults.capabilities?.browser ??
|
|
675
|
+
builtin.capabilities.browser,
|
|
676
|
+
outOfBand: cliOverrides.includeOutOfBand ??
|
|
677
|
+
profile.capabilities?.outOfBand ??
|
|
678
|
+
defaults.capabilities?.outOfBand ??
|
|
679
|
+
builtin.capabilities.outOfBand,
|
|
680
|
+
optIn: cliOverrides.includeOptIn ??
|
|
681
|
+
profile.capabilities?.optIn ??
|
|
682
|
+
defaults.capabilities?.optIn ??
|
|
683
|
+
builtin.capabilities.optIn,
|
|
684
|
+
};
|
|
685
|
+
// ── Reporters ──────────────────────────────────────────────────────────
|
|
686
|
+
// CLI overrides individual channels, not the whole reporters dict
|
|
687
|
+
// (codex catch from plan: --reporter detailed shouldn't drop a profile's
|
|
688
|
+
// junit/resultJson outputs).
|
|
689
|
+
const reporters = {
|
|
690
|
+
console: cliOverrides.consoleReporter ??
|
|
691
|
+
profile.reporters?.console ??
|
|
692
|
+
defaults.reporters?.console ??
|
|
693
|
+
builtin.reporters.console,
|
|
694
|
+
junit: cliOverrides.junit ?? profile.reporters?.junit ?? defaults.reporters?.junit,
|
|
695
|
+
resultJson: cliOverrides.resultJson ??
|
|
696
|
+
profile.reporters?.resultJson ??
|
|
697
|
+
defaults.reporters?.resultJson,
|
|
698
|
+
emitFullTrace: cliOverrides.emitFullTrace ??
|
|
699
|
+
profile.reporters?.emitFullTrace ??
|
|
700
|
+
defaults.reporters?.emitFullTrace ??
|
|
701
|
+
builtin.reporters.emitFullTrace,
|
|
702
|
+
inferSchema: cliOverrides.inferSchema ??
|
|
703
|
+
profile.reporters?.inferSchema ??
|
|
704
|
+
defaults.reporters?.inferSchema ??
|
|
705
|
+
builtin.reporters.inferSchema,
|
|
706
|
+
truncateArrays: cliOverrides.truncateArrays ??
|
|
707
|
+
profile.reporters?.truncateArrays ??
|
|
708
|
+
defaults.reporters?.truncateArrays ??
|
|
709
|
+
builtin.reporters.truncateArrays,
|
|
710
|
+
};
|
|
711
|
+
const envFile = cliOverrides.envFile ?? defaults.envFile ?? builtin.envFile;
|
|
712
|
+
const redaction = resolveRedactionConfig(defaults.redaction);
|
|
713
|
+
// ── Thresholds ─────────────────────────────────────────────────────────
|
|
714
|
+
// Shallow per-metric merge: profile overrides defaults on metric-key
|
|
715
|
+
// collision (a profile can tighten or replace a baseline metric's rules,
|
|
716
|
+
// but doesn't deep-merge individual aggregations).
|
|
717
|
+
const thresholds = {
|
|
718
|
+
...(defaults.thresholds ?? {}),
|
|
719
|
+
...(profile.thresholds ?? {}),
|
|
720
|
+
};
|
|
721
|
+
// ── Upload ─────────────────────────────────────────────────────────────
|
|
722
|
+
// CLI `--upload` flag forces enable regardless of profile. Profile-defined
|
|
723
|
+
// upload (with projectAlias) takes effect unless CLI overrides enabled.
|
|
724
|
+
let upload = profile.upload;
|
|
725
|
+
if (cliOverrides.uploadEnabled !== undefined) {
|
|
726
|
+
upload = { ...(upload ?? {}), enabled: cliOverrides.uploadEnabled };
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
profile: profileName,
|
|
730
|
+
configPath,
|
|
731
|
+
suites,
|
|
732
|
+
selection,
|
|
733
|
+
execution,
|
|
734
|
+
capabilities,
|
|
735
|
+
reporters,
|
|
736
|
+
...(upload !== undefined && { upload }),
|
|
737
|
+
envFile,
|
|
738
|
+
redaction,
|
|
739
|
+
thresholds,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
20
742
|
// ── Defaults ─────────────────────────────────────────────────────────────────
|
|
21
743
|
export const RUN_DEFAULTS = {
|
|
22
744
|
verbose: false,
|
|
@@ -37,81 +759,12 @@ export const CONFIG_DEFAULTS = {
|
|
|
37
759
|
run: { ...RUN_DEFAULTS },
|
|
38
760
|
redaction: structuredClone(DEFAULT_CONFIG),
|
|
39
761
|
};
|
|
40
|
-
// ── Internal helpers ─────────────────────────────────────────────────────────
|
|
41
|
-
/** Check if a filename should be treated as a package config file. */
|
|
42
|
-
function isPackageConfig(filePath) {
|
|
43
|
-
const name = filePath.split("/").pop() ?? "";
|
|
44
|
-
return name === "package.json";
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Read a single config source from disk.
|
|
48
|
-
*
|
|
49
|
-
* If the file is a package.json, extract the "glubean" field.
|
|
50
|
-
* Otherwise treat the entire file as a glubean config object.
|
|
51
|
-
*/
|
|
52
|
-
export async function readSingleConfig(filePath) {
|
|
53
|
-
const content = await readFile(filePath, "utf-8");
|
|
54
|
-
const ext = extname(filePath).toLowerCase();
|
|
55
|
-
const parsed = (ext === ".yaml" || ext === ".yml")
|
|
56
|
-
? parseYaml(content)
|
|
57
|
-
: JSON.parse(content);
|
|
58
|
-
if (isPackageConfig(filePath)) {
|
|
59
|
-
return parsed.glubean ?? {};
|
|
60
|
-
}
|
|
61
|
-
return parsed;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Merge two config inputs. Later (overlay) values take precedence.
|
|
65
|
-
*
|
|
66
|
-
* - Scalar fields: right wins.
|
|
67
|
-
* - Array fields (sensitiveKeys.additional, sensitiveKeys.excluded,
|
|
68
|
-
* patterns.custom): concatenated (additive by nature).
|
|
69
|
-
*/
|
|
70
|
-
export function mergeConfigInputs(base, overlay) {
|
|
71
|
-
const merged = {};
|
|
72
|
-
// ── Run section (shallow merge, scalars override) ──────────────────────
|
|
73
|
-
if (base.run || overlay.run) {
|
|
74
|
-
merged.run = { ...base.run, ...overlay.run };
|
|
75
|
-
}
|
|
76
|
-
// ── Redaction section ──────────────────────────────────────────────────
|
|
77
|
-
if (base.redaction || overlay.redaction) {
|
|
78
|
-
const br = base.redaction ?? {};
|
|
79
|
-
const or = overlay.redaction ?? {};
|
|
80
|
-
merged.redaction = {};
|
|
81
|
-
if (or.replacementFormat !== undefined) {
|
|
82
|
-
merged.redaction.replacementFormat = or.replacementFormat;
|
|
83
|
-
}
|
|
84
|
-
else if (br.replacementFormat !== undefined) {
|
|
85
|
-
merged.redaction.replacementFormat = br.replacementFormat;
|
|
86
|
-
}
|
|
87
|
-
if (br.sensitiveKeys || or.sensitiveKeys) {
|
|
88
|
-
merged.redaction.sensitiveKeys = [
|
|
89
|
-
...(br.sensitiveKeys ?? []),
|
|
90
|
-
...(or.sensitiveKeys ?? []),
|
|
91
|
-
];
|
|
92
|
-
}
|
|
93
|
-
if (br.customPatterns || or.customPatterns) {
|
|
94
|
-
merged.redaction.customPatterns = [
|
|
95
|
-
...(br.customPatterns ?? []),
|
|
96
|
-
...(or.customPatterns ?? []),
|
|
97
|
-
];
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// ── Cloud section (shallow merge, scalars override) ─────────────────────
|
|
101
|
-
if (base.cloud || overlay.cloud) {
|
|
102
|
-
merged.cloud = { ...base.cloud, ...overlay.cloud };
|
|
103
|
-
}
|
|
104
|
-
// ── Thresholds section (shallow merge, later rules win per metric key) ──
|
|
105
|
-
if (base.thresholds || overlay.thresholds) {
|
|
106
|
-
merged.thresholds = { ...base.thresholds, ...overlay.thresholds };
|
|
107
|
-
}
|
|
108
|
-
return merged;
|
|
109
|
-
}
|
|
110
762
|
/**
|
|
111
|
-
* Apply a
|
|
112
|
-
* to produce a fully
|
|
763
|
+
* Apply a `GlubeanRedactionConfigInput` (glubean.yaml `defaults.redaction`)
|
|
764
|
+
* on top of the mandatory DEFAULT_CONFIG baseline to produce a fully
|
|
765
|
+
* resolved RedactionConfig.
|
|
113
766
|
*/
|
|
114
|
-
function resolveRedactionConfig(input) {
|
|
767
|
+
export function resolveRedactionConfig(input) {
|
|
115
768
|
const merged = structuredClone(DEFAULT_CONFIG);
|
|
116
769
|
if (!input)
|
|
117
770
|
return merged;
|
|
@@ -135,86 +788,13 @@ function resolveRedactionConfig(input) {
|
|
|
135
788
|
}
|
|
136
789
|
}
|
|
137
790
|
}
|
|
138
|
-
if (input.replacementFormat === "
|
|
791
|
+
if (input.replacementFormat === "simple" ||
|
|
792
|
+
input.replacementFormat === "labeled" ||
|
|
139
793
|
input.replacementFormat === "partial") {
|
|
140
794
|
merged.replacementFormat = input.replacementFormat;
|
|
141
795
|
}
|
|
142
796
|
return merged;
|
|
143
797
|
}
|
|
144
|
-
// ── Validation ───────────────────────────────────────────────────────────────
|
|
145
|
-
const KNOWN_TOP_KEYS = new Set(["run", "redaction", "cloud", "thresholds"]);
|
|
146
|
-
const KNOWN_RUN_KEYS = new Set(Object.keys(RUN_DEFAULTS));
|
|
147
|
-
const KNOWN_REDACTION_KEYS = new Set([
|
|
148
|
-
"sensitiveKeys",
|
|
149
|
-
"customPatterns",
|
|
150
|
-
"replacementFormat",
|
|
151
|
-
]);
|
|
152
|
-
const KNOWN_CLOUD_KEYS = new Set(["projectId", "apiUrl", "token"]);
|
|
153
|
-
function warnUnknownKeys(obj, known, path) {
|
|
154
|
-
for (const key of Object.keys(obj)) {
|
|
155
|
-
if (!known.has(key)) {
|
|
156
|
-
console.error(`\x1b[33mWarning: unknown config key "${path}.${key}" — typo?\x1b[0m`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
function validateConfigInput(input) {
|
|
161
|
-
warnUnknownKeys(input, KNOWN_TOP_KEYS, "glubean");
|
|
162
|
-
if (input.run) {
|
|
163
|
-
warnUnknownKeys(input.run, KNOWN_RUN_KEYS, "glubean.run");
|
|
164
|
-
}
|
|
165
|
-
if (input.redaction) {
|
|
166
|
-
warnUnknownKeys(input.redaction, KNOWN_REDACTION_KEYS, "glubean.redaction");
|
|
167
|
-
}
|
|
168
|
-
if (input.cloud) {
|
|
169
|
-
warnUnknownKeys(input.cloud, KNOWN_CLOUD_KEYS, "glubean.cloud");
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
// ── Public API ───────────────────────────────────────────────────────────────
|
|
173
|
-
/**
|
|
174
|
-
* Load the resolved GlubeanConfig.
|
|
175
|
-
*
|
|
176
|
-
* - If `configPaths` is undefined or empty: auto-read package.json in `rootDir`.
|
|
177
|
-
* - If `configPaths` has entries: merge left-to-right, skip auto-read.
|
|
178
|
-
*/
|
|
179
|
-
export async function loadConfig(rootDir, configPaths) {
|
|
180
|
-
let accumulated = {};
|
|
181
|
-
if (configPaths && configPaths.length > 0) {
|
|
182
|
-
for (const configPath of configPaths) {
|
|
183
|
-
const absPath = resolve(rootDir, configPath);
|
|
184
|
-
try {
|
|
185
|
-
const single = await readSingleConfig(absPath);
|
|
186
|
-
validateConfigInput(single);
|
|
187
|
-
accumulated = mergeConfigInputs(accumulated, single);
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
console.error(`Warning: Could not read config file: ${absPath}`);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
// No --config: auto-read package.json in rootDir
|
|
196
|
-
const pkgPath = resolve(rootDir, "package.json");
|
|
197
|
-
try {
|
|
198
|
-
const single = await readSingleConfig(pkgPath);
|
|
199
|
-
validateConfigInput(single);
|
|
200
|
-
accumulated = mergeConfigInputs(accumulated, single);
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
// Not found, use defaults
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
const resolvedRun = {
|
|
207
|
-
...RUN_DEFAULTS,
|
|
208
|
-
...accumulated.run,
|
|
209
|
-
};
|
|
210
|
-
const resolvedRedaction = resolveRedactionConfig(accumulated.redaction);
|
|
211
|
-
return {
|
|
212
|
-
run: resolvedRun,
|
|
213
|
-
redaction: resolvedRedaction,
|
|
214
|
-
cloud: accumulated.cloud,
|
|
215
|
-
thresholds: accumulated.thresholds,
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
798
|
/**
|
|
219
799
|
* Merge resolved run config with CLI flags.
|
|
220
800
|
*/
|
|
@@ -244,6 +824,21 @@ export function mergeRunOptions(config, cliFlags) {
|
|
|
244
824
|
if (cliFlags.timeout !== undefined) {
|
|
245
825
|
result.perTestTimeoutMs = Number(cliFlags.timeout);
|
|
246
826
|
}
|
|
827
|
+
// Phase 1 sub-task E: profile-driven trace + execution fields. Without
|
|
828
|
+
// these, profile reporters.inferSchema / truncateArrays + execution.timeoutMs
|
|
829
|
+
// / concurrency would be silently ignored by the runner.
|
|
830
|
+
if (cliFlags.inferSchema !== undefined) {
|
|
831
|
+
result.inferSchema = !!cliFlags.inferSchema;
|
|
832
|
+
}
|
|
833
|
+
if (cliFlags.truncateArrays !== undefined) {
|
|
834
|
+
result.truncateArrays = !!cliFlags.truncateArrays;
|
|
835
|
+
}
|
|
836
|
+
if (cliFlags.timeoutMs !== undefined) {
|
|
837
|
+
result.perTestTimeoutMs = Number(cliFlags.timeoutMs);
|
|
838
|
+
}
|
|
839
|
+
if (cliFlags.concurrency !== undefined) {
|
|
840
|
+
result.concurrency = Number(cliFlags.concurrency);
|
|
841
|
+
}
|
|
247
842
|
return result;
|
|
248
843
|
}
|
|
249
844
|
/**
|