@featurevisor/core 2.10.0 → 2.12.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/coverage/clover.xml +684 -3
  3. package/coverage/coverage-final.json +4 -0
  4. package/coverage/lcov-report/builder/allocator.ts.html +1 -1
  5. package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
  6. package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
  7. package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
  8. package/coverage/lcov-report/builder/index.html +1 -1
  9. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  10. package/coverage/lcov-report/builder/traffic.ts.html +1 -1
  11. package/coverage/lcov-report/index.html +25 -10
  12. package/coverage/lcov-report/linter/conditionSchema.ts.html +775 -0
  13. package/coverage/lcov-report/linter/featureSchema.ts.html +4924 -0
  14. package/coverage/lcov-report/linter/index.html +161 -0
  15. package/coverage/lcov-report/linter/schema.ts.html +1471 -0
  16. package/coverage/lcov-report/linter/segmentSchema.ts.html +130 -0
  17. package/coverage/lcov-report/list/index.html +1 -1
  18. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  19. package/coverage/lcov-report/parsers/index.html +1 -1
  20. package/coverage/lcov-report/parsers/json.ts.html +1 -1
  21. package/coverage/lcov-report/parsers/yml.ts.html +1 -1
  22. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  23. package/coverage/lcov-report/tester/index.html +1 -1
  24. package/coverage/lcov.info +1471 -0
  25. package/lib/builder/buildDatafile.js +15 -1
  26. package/lib/builder/buildDatafile.js.map +1 -1
  27. package/lib/config/projectConfig.d.ts +2 -0
  28. package/lib/config/projectConfig.js +3 -1
  29. package/lib/config/projectConfig.js.map +1 -1
  30. package/lib/datasource/datasource.d.ts +6 -1
  31. package/lib/datasource/datasource.js +16 -0
  32. package/lib/datasource/datasource.js.map +1 -1
  33. package/lib/datasource/filesystemAdapter.js +10 -0
  34. package/lib/datasource/filesystemAdapter.js.map +1 -1
  35. package/lib/generate-code/typescript.js +283 -49
  36. package/lib/generate-code/typescript.js.map +1 -1
  37. package/lib/linter/conditionSchema.spec.d.ts +1 -0
  38. package/lib/linter/conditionSchema.spec.js +331 -0
  39. package/lib/linter/conditionSchema.spec.js.map +1 -0
  40. package/lib/linter/featureSchema.d.ts +153 -17
  41. package/lib/linter/featureSchema.js +536 -49
  42. package/lib/linter/featureSchema.js.map +1 -1
  43. package/lib/linter/featureSchema.spec.d.ts +1 -0
  44. package/lib/linter/featureSchema.spec.js +978 -0
  45. package/lib/linter/featureSchema.spec.js.map +1 -0
  46. package/lib/linter/lintProject.js +67 -1
  47. package/lib/linter/lintProject.js.map +1 -1
  48. package/lib/linter/schema.d.ts +42 -0
  49. package/lib/linter/schema.js +417 -0
  50. package/lib/linter/schema.js.map +1 -0
  51. package/lib/linter/schema.spec.d.ts +1 -0
  52. package/lib/linter/schema.spec.js +483 -0
  53. package/lib/linter/schema.spec.js.map +1 -0
  54. package/lib/linter/segmentSchema.spec.d.ts +1 -0
  55. package/lib/linter/segmentSchema.spec.js +231 -0
  56. package/lib/linter/segmentSchema.spec.js.map +1 -0
  57. package/lib/tester/testFeature.js +5 -3
  58. package/lib/tester/testFeature.js.map +1 -1
  59. package/lib/utils/git.js +3 -0
  60. package/lib/utils/git.js.map +1 -1
  61. package/package.json +5 -5
  62. package/src/builder/buildDatafile.ts +17 -1
  63. package/src/config/projectConfig.ts +3 -0
  64. package/src/datasource/datasource.ts +23 -0
  65. package/src/datasource/filesystemAdapter.ts +7 -0
  66. package/src/generate-code/typescript.ts +333 -52
  67. package/src/linter/conditionSchema.spec.ts +446 -0
  68. package/src/linter/featureSchema.spec.ts +1218 -0
  69. package/src/linter/featureSchema.ts +747 -70
  70. package/src/linter/lintProject.ts +84 -0
  71. package/src/linter/schema.spec.ts +617 -0
  72. package/src/linter/schema.ts +462 -0
  73. package/src/linter/segmentSchema.spec.ts +273 -0
  74. package/src/tester/testFeature.ts +5 -3
  75. package/src/utils/git.ts +2 -0
  76. package/lib/linter/propertySchema.d.ts +0 -5
  77. package/lib/linter/propertySchema.js +0 -43
  78. package/lib/linter/propertySchema.js.map +0 -1
  79. package/src/linter/propertySchema.ts +0 -47
@@ -0,0 +1,978 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const conditionSchema_1 = require("./conditionSchema");
4
+ const featureSchema_1 = require("./featureSchema");
5
+ /** Minimal project config for feature schema tests (no file paths used for variable validation). */
6
+ function minimalProjectConfig(overrides = {}) {
7
+ return {
8
+ featuresDirectoryPath: "",
9
+ segmentsDirectoryPath: "",
10
+ attributesDirectoryPath: "",
11
+ groupsDirectoryPath: "",
12
+ schemasDirectoryPath: "",
13
+ testsDirectoryPath: "",
14
+ stateDirectoryPath: "",
15
+ datafilesDirectoryPath: "",
16
+ datafileNamePattern: "",
17
+ revisionFileName: "",
18
+ siteExportDirectoryPath: "",
19
+ environments: ["staging", "production"],
20
+ tags: ["all"],
21
+ adapter: {},
22
+ plugins: [],
23
+ defaultBucketBy: "userId",
24
+ parser: "yml",
25
+ prettyState: true,
26
+ prettyDatafile: false,
27
+ stringify: true,
28
+ enforceCatchAllRule: false,
29
+ ...overrides,
30
+ };
31
+ }
32
+ /** Attributes and segments that appear in conditions; keep small for tests. */
33
+ const TEST_ATTRIBUTES = ["userId", "country", "device"];
34
+ const TEST_SEGMENTS = ["*", "countries/germany", "countries/france"];
35
+ const TEST_FEATURES = ["testFeature"];
36
+ const TEST_SCHEMA_KEYS = ["link", "slugSchema"];
37
+ /** Resolved schema for "slugSchema": string with pattern and length. */
38
+ const slugSchemaResolved = {
39
+ type: "string",
40
+ minLength: 1,
41
+ maxLength: 30,
42
+ pattern: "^[a-z0-9-]+$",
43
+ };
44
+ /** Resolved schema for "link": object with title and url. */
45
+ const linkSchemaResolved = {
46
+ type: "object",
47
+ required: ["title", "url"],
48
+ properties: {
49
+ title: { type: "string" },
50
+ url: { type: "string" },
51
+ },
52
+ };
53
+ const TEST_SCHEMAS_BY_KEY = {
54
+ link: linkSchemaResolved,
55
+ slugSchema: slugSchemaResolved,
56
+ };
57
+ function getFeatureSchema() {
58
+ const projectConfig = minimalProjectConfig();
59
+ const conditionsZodSchema = (0, conditionSchema_1.getConditionsZodSchema)(projectConfig, TEST_ATTRIBUTES);
60
+ return (0, featureSchema_1.getFeatureZodSchema)(projectConfig, conditionsZodSchema, TEST_ATTRIBUTES, TEST_SEGMENTS, TEST_FEATURES, TEST_SCHEMA_KEYS, TEST_SCHEMAS_BY_KEY);
61
+ }
62
+ /** Base feature shape required by getFeatureZodSchema (description, tags, bucketBy, rules). */
63
+ function baseFeature(overrides = {}) {
64
+ return {
65
+ description: "Test feature",
66
+ tags: ["all"],
67
+ bucketBy: "userId",
68
+ rules: {
69
+ staging: [{ key: "r1", segments: "*", percentage: 100 }],
70
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
71
+ },
72
+ ...overrides,
73
+ };
74
+ }
75
+ function parseFeature(feature) {
76
+ return getFeatureSchema().safeParse(feature);
77
+ }
78
+ function expectParseSuccess(feature) {
79
+ const result = parseFeature(feature);
80
+ expect(result.success).toBe(true);
81
+ if (!result.success) {
82
+ const err = result.error;
83
+ const msg = err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
84
+ throw new Error(`Expected success but got: ${msg}`);
85
+ }
86
+ }
87
+ function expectParseFailure(feature, messageSubstring) {
88
+ const result = parseFeature(feature);
89
+ expect(result.success).toBe(false);
90
+ if (result.success)
91
+ throw new Error("Expected parse failure");
92
+ const err = result.error;
93
+ if (messageSubstring) {
94
+ const messages = err.issues
95
+ .map((i) => (typeof i.message === "string" ? i.message : ""))
96
+ .join(" ");
97
+ expect(messages).toContain(messageSubstring);
98
+ }
99
+ return err;
100
+ }
101
+ /** Assert that an intentional mistake produces an error at the expected path with expected message. */
102
+ function expectErrorSurfaces(feature, opts) {
103
+ const err = expectParseFailure(feature, opts.messageContains);
104
+ const pathStrings = err.issues.map((i) => i.path.join("."));
105
+ const hasMatchingPath = pathStrings.some((p) => opts.pathContains.every((seg) => p.includes(seg)));
106
+ expect(hasMatchingPath).toBe(true);
107
+ }
108
+ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable values)", () => {
109
+ describe("variablesSchema: schema reference", () => {
110
+ it("accepts variable with schema reference and valid defaultValue", () => {
111
+ expectParseSuccess(baseFeature({
112
+ variablesSchema: {
113
+ myLink: {
114
+ schema: "link",
115
+ defaultValue: { title: "Home", url: "/" },
116
+ },
117
+ },
118
+ }));
119
+ });
120
+ it("rejects variable with unknown schema reference", () => {
121
+ expectParseFailure(baseFeature({
122
+ variablesSchema: {
123
+ myLink: {
124
+ schema: "nonexistentSchema",
125
+ defaultValue: { title: "Home", url: "/" },
126
+ },
127
+ },
128
+ }), "Unknown schema");
129
+ });
130
+ it("rejects variable with schema reference when defaultValue does not match schema", () => {
131
+ expectParseFailure(baseFeature({
132
+ variablesSchema: {
133
+ myLink: {
134
+ schema: "link",
135
+ defaultValue: { title: "Home" }, // missing required "url"
136
+ },
137
+ },
138
+ }));
139
+ });
140
+ });
141
+ describe("variablesSchema: inline schema and defaultValue", () => {
142
+ it("accepts inline string variable with valid defaultValue", () => {
143
+ expectParseSuccess(baseFeature({
144
+ variablesSchema: {
145
+ label: { type: "string", defaultValue: "hello" },
146
+ },
147
+ }));
148
+ });
149
+ it("accepts inline integer with min/max and defaultValue in range", () => {
150
+ expectParseSuccess(baseFeature({
151
+ variablesSchema: {
152
+ level: {
153
+ type: "integer",
154
+ minimum: 1,
155
+ maximum: 10,
156
+ defaultValue: 5,
157
+ },
158
+ },
159
+ }));
160
+ });
161
+ it("rejects inline integer defaultValue below minimum", () => {
162
+ expectParseFailure(baseFeature({
163
+ variablesSchema: {
164
+ level: {
165
+ type: "integer",
166
+ minimum: 1,
167
+ maximum: 10,
168
+ defaultValue: 0,
169
+ },
170
+ },
171
+ }), "minimum");
172
+ });
173
+ it("rejects inline integer defaultValue above maximum", () => {
174
+ expectParseFailure(baseFeature({
175
+ variablesSchema: {
176
+ level: {
177
+ type: "integer",
178
+ minimum: 1,
179
+ maximum: 10,
180
+ defaultValue: 11,
181
+ },
182
+ },
183
+ }), "maximum");
184
+ });
185
+ it("rejects inline string defaultValue that violates minLength", () => {
186
+ expectParseFailure(baseFeature({
187
+ variablesSchema: {
188
+ slug: {
189
+ type: "string",
190
+ minLength: 3,
191
+ defaultValue: "ab",
192
+ },
193
+ },
194
+ }), "minLength");
195
+ });
196
+ it("rejects inline string defaultValue that violates maxLength", () => {
197
+ expectParseFailure(baseFeature({
198
+ variablesSchema: {
199
+ slug: {
200
+ type: "string",
201
+ maxLength: 5,
202
+ defaultValue: "toolong",
203
+ },
204
+ },
205
+ }), "maxLength");
206
+ });
207
+ it("rejects inline string defaultValue that does not match pattern", () => {
208
+ expectParseFailure(baseFeature({
209
+ variablesSchema: {
210
+ slug: {
211
+ type: "string",
212
+ pattern: "^[a-z0-9-]+$",
213
+ defaultValue: "INVALID",
214
+ },
215
+ },
216
+ }), "pattern");
217
+ });
218
+ it("rejects inline array defaultValue with length below minItems", () => {
219
+ expectParseFailure(baseFeature({
220
+ variablesSchema: {
221
+ tags: {
222
+ type: "array",
223
+ items: { type: "string" },
224
+ minItems: 2,
225
+ defaultValue: ["one"],
226
+ },
227
+ },
228
+ }), "minItems");
229
+ });
230
+ it("rejects inline array defaultValue with length above maxItems", () => {
231
+ expectParseFailure(baseFeature({
232
+ variablesSchema: {
233
+ tags: {
234
+ type: "array",
235
+ items: { type: "string" },
236
+ maxItems: 2,
237
+ defaultValue: ["a", "b", "c"],
238
+ },
239
+ },
240
+ }), "maxItems");
241
+ });
242
+ it("rejects inline array defaultValue with duplicate items when uniqueItems is true", () => {
243
+ expectParseFailure(baseFeature({
244
+ variablesSchema: {
245
+ codes: {
246
+ type: "array",
247
+ items: { type: "string" },
248
+ uniqueItems: true,
249
+ defaultValue: ["x", "x"],
250
+ },
251
+ },
252
+ }), "duplicate");
253
+ });
254
+ it("rejects inline const variable when defaultValue does not equal const", () => {
255
+ expectParseFailure(baseFeature({
256
+ variablesSchema: {
257
+ status: {
258
+ type: "string",
259
+ const: "active",
260
+ defaultValue: "inactive",
261
+ },
262
+ },
263
+ }), "constant");
264
+ });
265
+ it("rejects inline enum variable when defaultValue is not in enum", () => {
266
+ expectParseFailure(baseFeature({
267
+ variablesSchema: {
268
+ theme: {
269
+ type: "string",
270
+ enum: ["light", "dark"],
271
+ defaultValue: "blue",
272
+ },
273
+ },
274
+ }));
275
+ });
276
+ it("accepts inline oneOf variable when defaultValue matches exactly one branch", () => {
277
+ expectParseSuccess(baseFeature({
278
+ variablesSchema: {
279
+ idOrLink: {
280
+ oneOf: [{ type: "string" }, { schema: "link" }],
281
+ defaultValue: "ref-123",
282
+ },
283
+ },
284
+ }));
285
+ });
286
+ it("rejects when variable has both schema reference and inline type", () => {
287
+ expectParseFailure(baseFeature({
288
+ variablesSchema: {
289
+ myLink: {
290
+ schema: "link",
291
+ type: "object",
292
+ defaultValue: { title: "Home", url: "/" },
293
+ },
294
+ },
295
+ }), "schema");
296
+ });
297
+ it("rejects reserved variable key 'variation'", () => {
298
+ expectParseFailure(baseFeature({
299
+ variablesSchema: {
300
+ variation: { type: "string", defaultValue: "control" },
301
+ },
302
+ }), "reserved");
303
+ });
304
+ });
305
+ describe("variablesSchema: disabledValue", () => {
306
+ it("accepts valid disabledValue matching variable schema", () => {
307
+ expectParseSuccess(baseFeature({
308
+ variablesSchema: {
309
+ label: {
310
+ type: "string",
311
+ defaultValue: "on",
312
+ disabledValue: "off",
313
+ },
314
+ },
315
+ }));
316
+ });
317
+ it("rejects disabledValue that does not match variable schema", () => {
318
+ expectParseFailure(baseFeature({
319
+ variablesSchema: {
320
+ level: {
321
+ type: "integer",
322
+ minimum: 1,
323
+ maximum: 10,
324
+ defaultValue: 5,
325
+ disabledValue: 99,
326
+ },
327
+ },
328
+ }), "maximum");
329
+ });
330
+ });
331
+ describe("variations: variables", () => {
332
+ it("accepts variation variables that match variablesSchema", () => {
333
+ expectParseSuccess(baseFeature({
334
+ variablesSchema: {
335
+ label: { type: "string", defaultValue: "default" },
336
+ },
337
+ variations: [
338
+ { value: "control", weight: 50 },
339
+ { value: "treatment", weight: 50, variables: { label: "treatment-label" } },
340
+ ],
341
+ }));
342
+ });
343
+ it("rejects variation variable value that does not match schema", () => {
344
+ expectParseFailure(baseFeature({
345
+ variablesSchema: {
346
+ level: { type: "integer", minimum: 1, maximum: 10, defaultValue: 1 },
347
+ },
348
+ variations: [
349
+ { value: "control", weight: 50 },
350
+ { value: "treatment", weight: 50, variables: { level: 100 } },
351
+ ],
352
+ }), "maximum");
353
+ });
354
+ it("rejects when variation uses a variable key not defined in variablesSchema", () => {
355
+ expectParseFailure(baseFeature({
356
+ variablesSchema: {
357
+ label: { type: "string", defaultValue: "default" },
358
+ },
359
+ variations: [
360
+ { value: "control", weight: 50 },
361
+ { value: "treatment", weight: 50, variables: { unknownVar: "x" } },
362
+ ],
363
+ }), "not defined in");
364
+ });
365
+ });
366
+ describe("variations: variableOverrides", () => {
367
+ it("accepts variableOverrides with values matching variable schema", () => {
368
+ expectParseSuccess(baseFeature({
369
+ variablesSchema: {
370
+ slug: {
371
+ type: "string",
372
+ pattern: "^[a-z0-9-]+$",
373
+ minLength: 1,
374
+ maxLength: 20,
375
+ defaultValue: "home",
376
+ },
377
+ },
378
+ variations: [
379
+ { value: "control", weight: 50 },
380
+ {
381
+ value: "treatment",
382
+ weight: 50,
383
+ variables: { slug: "treatment" },
384
+ variableOverrides: {
385
+ slug: [
386
+ { segments: "countries/germany", value: "de" },
387
+ { segments: "countries/france", value: "fr" },
388
+ ],
389
+ },
390
+ },
391
+ ],
392
+ }));
393
+ });
394
+ it("rejects variableOverride value that does not match schema", () => {
395
+ expectParseFailure(baseFeature({
396
+ variablesSchema: {
397
+ slug: {
398
+ type: "string",
399
+ pattern: "^[a-z]+$",
400
+ defaultValue: "home",
401
+ },
402
+ },
403
+ variations: [
404
+ { value: "control", weight: 50 },
405
+ {
406
+ value: "treatment",
407
+ weight: 50,
408
+ variableOverrides: {
409
+ slug: [{ segments: "*", value: "UPPERCASE" }],
410
+ },
411
+ },
412
+ ],
413
+ }), "pattern");
414
+ });
415
+ it("rejects variableOverride for variable key not in variablesSchema", () => {
416
+ expectParseFailure(baseFeature({
417
+ variablesSchema: {
418
+ label: { type: "string", defaultValue: "default" },
419
+ },
420
+ variations: [
421
+ { value: "control", weight: 50 },
422
+ {
423
+ value: "treatment",
424
+ weight: 50,
425
+ variableOverrides: {
426
+ notDefined: [{ segments: "*", value: "x" }],
427
+ },
428
+ },
429
+ ],
430
+ }), "not defined in");
431
+ });
432
+ it("validates variableOverrides even when variation has no variables", () => {
433
+ expectParseFailure(baseFeature({
434
+ variablesSchema: {
435
+ slug: { type: "string", maxLength: 5, defaultValue: "home" },
436
+ },
437
+ variations: [
438
+ { value: "control", weight: 50 },
439
+ {
440
+ value: "treatment",
441
+ weight: 50,
442
+ variableOverrides: {
443
+ slug: [{ segments: "*", value: "toolongvalue" }],
444
+ },
445
+ },
446
+ ],
447
+ }), "maxLength");
448
+ });
449
+ });
450
+ describe("rules: variables", () => {
451
+ it("accepts rule variables that match variablesSchema", () => {
452
+ expectParseSuccess(baseFeature({
453
+ variablesSchema: {
454
+ title: { type: "string", defaultValue: "Default" },
455
+ },
456
+ rules: {
457
+ staging: [
458
+ { key: "r1", segments: "*", percentage: 100 },
459
+ {
460
+ key: "r2",
461
+ segments: "countries/germany",
462
+ percentage: 100,
463
+ variables: { title: "Germany" },
464
+ },
465
+ ],
466
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
467
+ },
468
+ }));
469
+ });
470
+ it("rejects rule variable value that does not match schema", () => {
471
+ expectParseFailure(baseFeature({
472
+ variablesSchema: {
473
+ count: { type: "integer", minimum: 0, maximum: 10, defaultValue: 0 },
474
+ },
475
+ rules: {
476
+ staging: [
477
+ { key: "r1", segments: "*", percentage: 100 },
478
+ {
479
+ key: "r2",
480
+ segments: "countries/germany",
481
+ percentage: 100,
482
+ variables: { count: 100 },
483
+ },
484
+ ],
485
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
486
+ },
487
+ }), "maximum");
488
+ });
489
+ it("rejects rule variable key not defined in variablesSchema", () => {
490
+ expectParseFailure(baseFeature({
491
+ variablesSchema: {
492
+ title: { type: "string", defaultValue: "Default" },
493
+ },
494
+ rules: {
495
+ staging: [
496
+ { key: "r1", segments: "*", percentage: 100 },
497
+ {
498
+ key: "r2",
499
+ segments: "countries/germany",
500
+ percentage: 100,
501
+ variables: { unknownVar: "x" },
502
+ },
503
+ ],
504
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
505
+ },
506
+ }), "not defined in");
507
+ });
508
+ });
509
+ describe("force: variables", () => {
510
+ it("accepts force variables that match variablesSchema", () => {
511
+ expectParseSuccess(baseFeature({
512
+ variablesSchema: {
513
+ title: { type: "string", defaultValue: "Default" },
514
+ },
515
+ variations: [
516
+ { value: "control", weight: 50 },
517
+ { value: "treatment", weight: 50 },
518
+ ],
519
+ force: {
520
+ staging: [
521
+ {
522
+ conditions: [{ attribute: "userId", operator: "equals", value: "u1" }],
523
+ variation: "treatment",
524
+ variables: { title: "Forced" },
525
+ },
526
+ ],
527
+ production: [],
528
+ },
529
+ }));
530
+ });
531
+ it("rejects force variable value that does not match schema", () => {
532
+ expectParseFailure(baseFeature({
533
+ variablesSchema: {
534
+ level: { type: "integer", minimum: 1, maximum: 5, defaultValue: 1 },
535
+ },
536
+ variations: [
537
+ { value: "control", weight: 50 },
538
+ { value: "treatment", weight: 50 },
539
+ ],
540
+ force: {
541
+ staging: [
542
+ {
543
+ conditions: [{ attribute: "userId", operator: "equals", value: "u1" }],
544
+ variation: "control",
545
+ variables: { level: 10 },
546
+ },
547
+ ],
548
+ production: [],
549
+ },
550
+ }), "maximum");
551
+ });
552
+ it("rejects force variable key not defined in variablesSchema", () => {
553
+ expectParseFailure(baseFeature({
554
+ variablesSchema: {
555
+ title: { type: "string", defaultValue: "Default" },
556
+ },
557
+ variations: [
558
+ { value: "control", weight: 50 },
559
+ { value: "treatment", weight: 50 },
560
+ ],
561
+ force: {
562
+ staging: [
563
+ {
564
+ conditions: [{ attribute: "userId", operator: "equals", value: "u1" }],
565
+ variation: "control",
566
+ variables: { notDefined: "x" },
567
+ },
568
+ ],
569
+ production: [],
570
+ },
571
+ }), "not defined in");
572
+ });
573
+ });
574
+ describe("oneOf: value must match exactly one branch", () => {
575
+ it("rejects defaultValue that matches no oneOf branch", () => {
576
+ expectParseFailure(baseFeature({
577
+ variablesSchema: {
578
+ idOrLink: {
579
+ oneOf: [{ type: "string" }, { schema: "link" }],
580
+ defaultValue: 42, // number matches neither
581
+ },
582
+ },
583
+ }));
584
+ });
585
+ it("rejects defaultValue that matches more than one oneOf branch when one is string", () => {
586
+ // If we had two branches that both accept the same value, we'd get "matched more than one"
587
+ expectParseSuccess(baseFeature({
588
+ variablesSchema: {
589
+ idOrLink: {
590
+ oneOf: [{ type: "string" }, { schema: "link" }],
591
+ defaultValue: "id-1",
592
+ },
593
+ },
594
+ }));
595
+ });
596
+ });
597
+ describe("schema reference: defaultValue resolved and validated", () => {
598
+ it("rejects defaultValue for schema-ref variable when value violates resolved schema", () => {
599
+ expectParseFailure(baseFeature({
600
+ variablesSchema: {
601
+ slug: {
602
+ schema: "slugSchema",
603
+ defaultValue: "INVALID-SLUG", // pattern is ^[a-z0-9-]+$
604
+ },
605
+ },
606
+ }));
607
+ });
608
+ it("accepts defaultValue for schema-ref variable when value satisfies resolved schema", () => {
609
+ expectParseSuccess(baseFeature({
610
+ variablesSchema: {
611
+ slug: {
612
+ schema: "slugSchema",
613
+ defaultValue: "valid-slug",
614
+ },
615
+ },
616
+ }));
617
+ });
618
+ });
619
+ describe("complex cases (mirroring example-1 withSchema / withComplexSchema)", () => {
620
+ it("accepts inline object variable with properties and valid defaultValue (e.g. settings)", () => {
621
+ expectParseSuccess(baseFeature({
622
+ variablesSchema: {
623
+ settings: {
624
+ type: "object",
625
+ properties: {
626
+ theme: { type: "string" },
627
+ compact: { type: "boolean" },
628
+ },
629
+ defaultValue: { theme: "light", compact: true },
630
+ },
631
+ },
632
+ }));
633
+ });
634
+ it("rejects inline object variable when defaultValue is missing required property", () => {
635
+ expectParseFailure(baseFeature({
636
+ variablesSchema: {
637
+ settings: {
638
+ type: "object",
639
+ properties: {
640
+ theme: { type: "string" },
641
+ compact: { type: "boolean" },
642
+ },
643
+ required: ["theme"],
644
+ defaultValue: { compact: true },
645
+ },
646
+ },
647
+ }));
648
+ });
649
+ it("accepts inline array variable with items schema ref (e.g. linkPair)", () => {
650
+ expectParseSuccess(baseFeature({
651
+ variablesSchema: {
652
+ linkPair: {
653
+ type: "array",
654
+ items: { schema: "link" },
655
+ defaultValue: [
656
+ { title: "First", url: "/first" },
657
+ { title: "Second", url: "/second" },
658
+ ],
659
+ },
660
+ },
661
+ }));
662
+ });
663
+ it("rejects inline array with items schema ref when an item does not match schema", () => {
664
+ expectParseFailure(baseFeature({
665
+ variablesSchema: {
666
+ linkPair: {
667
+ type: "array",
668
+ items: { schema: "link" },
669
+ defaultValue: [{ title: "Only title, missing url" }],
670
+ },
671
+ },
672
+ }));
673
+ });
674
+ it("accepts oneOf variable when defaultValue matches object branch (e.g. refOrLink as link)", () => {
675
+ expectParseSuccess(baseFeature({
676
+ variablesSchema: {
677
+ refOrLink: {
678
+ oneOf: [{ type: "string" }, { schema: "link" }],
679
+ defaultValue: { title: "Home", url: "/" },
680
+ },
681
+ },
682
+ }));
683
+ });
684
+ it("accepts object variable with nested const in property (e.g. statusInfo.kind const active)", () => {
685
+ expectParseSuccess(baseFeature({
686
+ variablesSchema: {
687
+ statusInfo: {
688
+ type: "object",
689
+ properties: {
690
+ kind: { type: "string", const: "active" },
691
+ label: { type: "string" },
692
+ },
693
+ required: ["kind", "label"],
694
+ defaultValue: { kind: "active", label: "Default" },
695
+ },
696
+ },
697
+ }));
698
+ });
699
+ it("rejects object variable when nested const property has wrong value", () => {
700
+ expectParseFailure(baseFeature({
701
+ variablesSchema: {
702
+ statusInfo: {
703
+ type: "object",
704
+ properties: {
705
+ kind: { type: "string", const: "active" },
706
+ label: { type: "string" },
707
+ },
708
+ required: ["kind", "label"],
709
+ defaultValue: { kind: "inactive", label: "Default" },
710
+ },
711
+ },
712
+ }));
713
+ });
714
+ it("accepts object variable with nested enum in property (e.g. themeConfig.theme)", () => {
715
+ expectParseSuccess(baseFeature({
716
+ variablesSchema: {
717
+ themeConfig: {
718
+ type: "object",
719
+ properties: {
720
+ theme: { type: "string", enum: ["light", "dark", "system"] },
721
+ label: { type: "string" },
722
+ },
723
+ required: ["theme", "label"],
724
+ defaultValue: { theme: "light", label: "Default" },
725
+ },
726
+ },
727
+ }));
728
+ });
729
+ it("rejects object variable when nested enum property has invalid value", () => {
730
+ expectParseFailure(baseFeature({
731
+ variablesSchema: {
732
+ themeConfig: {
733
+ type: "object",
734
+ properties: {
735
+ theme: { type: "string", enum: ["light", "dark", "system"] },
736
+ label: { type: "string" },
737
+ },
738
+ required: ["theme", "label"],
739
+ defaultValue: { theme: "invalid", label: "Default" },
740
+ },
741
+ },
742
+ }));
743
+ });
744
+ it("accepts rule variables with object value (e.g. singleLink, themeColor in rules)", () => {
745
+ expectParseSuccess(baseFeature({
746
+ variablesSchema: {
747
+ singleLink: {
748
+ schema: "link",
749
+ defaultValue: { title: "Home", url: "/" },
750
+ },
751
+ },
752
+ rules: {
753
+ staging: [
754
+ {
755
+ key: "r1",
756
+ segments: "*",
757
+ percentage: 100,
758
+ variables: {
759
+ singleLink: { title: "DE Link", url: "/de" },
760
+ },
761
+ },
762
+ ],
763
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
764
+ },
765
+ }));
766
+ });
767
+ it("rejects rule variable when object value does not match schema", () => {
768
+ expectParseFailure(baseFeature({
769
+ variablesSchema: {
770
+ singleLink: {
771
+ schema: "link",
772
+ defaultValue: { title: "Home", url: "/" },
773
+ },
774
+ },
775
+ rules: {
776
+ staging: [
777
+ {
778
+ key: "r1",
779
+ segments: "*",
780
+ percentage: 100,
781
+ variables: {
782
+ singleLink: { title: "Missing url" },
783
+ },
784
+ },
785
+ ],
786
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
787
+ },
788
+ }));
789
+ });
790
+ });
791
+ describe("errors surface properly: intentional mistakes produce correct path and message", () => {
792
+ it("unknown schema ref: error path points to variable and message says Unknown schema", () => {
793
+ expectErrorSurfaces(baseFeature({
794
+ variablesSchema: {
795
+ myLink: {
796
+ schema: "nonexistent",
797
+ defaultValue: { title: "Home", url: "/" },
798
+ },
799
+ },
800
+ }), {
801
+ pathContains: ["variablesSchema", "myLink", "schema"],
802
+ messageContains: "Unknown schema",
803
+ });
804
+ });
805
+ it("defaultValue below minimum: error path includes defaultValue, message mentions minimum", () => {
806
+ expectErrorSurfaces(baseFeature({
807
+ variablesSchema: {
808
+ level: {
809
+ type: "integer",
810
+ minimum: 10,
811
+ maximum: 100,
812
+ defaultValue: 5,
813
+ },
814
+ },
815
+ }), { pathContains: ["variablesSchema", "level", "defaultValue"], messageContains: "minimum" });
816
+ });
817
+ it("defaultValue above maximum: error path includes defaultValue, message mentions maximum", () => {
818
+ expectErrorSurfaces(baseFeature({
819
+ variablesSchema: {
820
+ level: {
821
+ type: "integer",
822
+ minimum: 0,
823
+ maximum: 10,
824
+ defaultValue: 99,
825
+ },
826
+ },
827
+ }), { pathContains: ["variablesSchema", "level", "defaultValue"], messageContains: "maximum" });
828
+ });
829
+ it("variation uses undeclared variable: error path points to variations.*.variables, message says not defined", () => {
830
+ expectErrorSurfaces(baseFeature({
831
+ variablesSchema: {
832
+ label: { type: "string", defaultValue: "x" },
833
+ },
834
+ variations: [
835
+ { value: "control", weight: 50 },
836
+ { value: "treatment", weight: 50, variables: { typoVar: "y" } },
837
+ ],
838
+ }), {
839
+ pathContains: ["variations", "variables", "typoVar"],
840
+ messageContains: "not defined in",
841
+ });
842
+ });
843
+ it("variableOverrides value violates pattern: error path points to variableOverrides.*.value", () => {
844
+ expectErrorSurfaces(baseFeature({
845
+ variablesSchema: {
846
+ slug: {
847
+ type: "string",
848
+ pattern: "^[a-z-]+$",
849
+ defaultValue: "home",
850
+ },
851
+ },
852
+ variations: [
853
+ { value: "control", weight: 50 },
854
+ {
855
+ value: "treatment",
856
+ weight: 50,
857
+ variableOverrides: {
858
+ slug: [{ segments: "*", value: "INVALID-UPPERCASE" }],
859
+ },
860
+ },
861
+ ],
862
+ }), {
863
+ pathContains: ["variableOverrides", "slug", "value"],
864
+ messageContains: "pattern",
865
+ });
866
+ });
867
+ it("rule variable wrong type: error path points to rules.*.variables, message surfaces constraint", () => {
868
+ expectErrorSurfaces(baseFeature({
869
+ variablesSchema: {
870
+ count: {
871
+ type: "integer",
872
+ minimum: 0,
873
+ maximum: 10,
874
+ defaultValue: 0,
875
+ },
876
+ },
877
+ rules: {
878
+ staging: [
879
+ {
880
+ key: "r1",
881
+ segments: "*",
882
+ percentage: 100,
883
+ variables: { count: 100 },
884
+ },
885
+ ],
886
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
887
+ },
888
+ }), {
889
+ pathContains: ["rules", "variables", "count"],
890
+ messageContains: "maximum",
891
+ });
892
+ });
893
+ it("disabledValue violates schema: error path includes disabledValue", () => {
894
+ expectErrorSurfaces(baseFeature({
895
+ variablesSchema: {
896
+ level: {
897
+ type: "integer",
898
+ minimum: 1,
899
+ maximum: 5,
900
+ defaultValue: 1,
901
+ disabledValue: 99,
902
+ },
903
+ },
904
+ }), {
905
+ pathContains: ["variablesSchema", "level", "disabledValue"],
906
+ messageContains: "maximum",
907
+ });
908
+ });
909
+ it("reserved key variation: error path points to variablesSchema.variation", () => {
910
+ expectErrorSurfaces(baseFeature({
911
+ variablesSchema: {
912
+ variation: { type: "string", defaultValue: "control" },
913
+ },
914
+ }), {
915
+ pathContains: ["variablesSchema", "variation"],
916
+ messageContains: "reserved",
917
+ });
918
+ });
919
+ it("object defaultValue missing required property: error path includes defaultValue and key, message says Missing required", () => {
920
+ expectErrorSurfaces(baseFeature({
921
+ variablesSchema: {
922
+ settings: {
923
+ type: "object",
924
+ properties: { theme: { type: "string" }, requiredKey: { type: "string" } },
925
+ required: ["requiredKey"],
926
+ defaultValue: { theme: "light" },
927
+ },
928
+ },
929
+ }), {
930
+ pathContains: ["variablesSchema", "settings", "defaultValue"],
931
+ messageContains: "Missing required",
932
+ });
933
+ });
934
+ it("nested const property wrong: error path points into object value", () => {
935
+ expectErrorSurfaces(baseFeature({
936
+ variablesSchema: {
937
+ statusInfo: {
938
+ type: "object",
939
+ properties: {
940
+ kind: { type: "string", const: "active" },
941
+ label: { type: "string" },
942
+ },
943
+ required: ["kind", "label"],
944
+ defaultValue: { kind: "inactive", label: "x" },
945
+ },
946
+ },
947
+ }), {
948
+ pathContains: ["variablesSchema", "statusInfo", "defaultValue"],
949
+ messageContains: "constant",
950
+ });
951
+ });
952
+ it("force variable not in variablesSchema: error path points to force.*.variables", () => {
953
+ expectErrorSurfaces(baseFeature({
954
+ variablesSchema: {
955
+ title: { type: "string", defaultValue: "Default" },
956
+ },
957
+ variations: [
958
+ { value: "control", weight: 50 },
959
+ { value: "treatment", weight: 50 },
960
+ ],
961
+ force: {
962
+ staging: [
963
+ {
964
+ conditions: [{ attribute: "userId", operator: "equals", value: "u1" }],
965
+ variation: "control",
966
+ variables: { typoKey: "x" },
967
+ },
968
+ ],
969
+ production: [],
970
+ },
971
+ }), {
972
+ pathContains: ["force", "variables", "typoKey"],
973
+ messageContains: "not defined in",
974
+ });
975
+ });
976
+ });
977
+ });
978
+ //# sourceMappingURL=featureSchema.spec.js.map