@featurevisor/core 2.11.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 +11 -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 +280 -46
  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 +129 -20
  41. package/lib/linter/featureSchema.js +489 -48
  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 +330 -49
  67. package/src/linter/conditionSchema.spec.ts +446 -0
  68. package/src/linter/featureSchema.spec.ts +1218 -0
  69. package/src/linter/featureSchema.ts +671 -69
  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,446 @@
1
+ /**
2
+ * Unit tests for condition schema validation (segment conditions, feature rules, etc.).
3
+ * Covers getConditionsZodSchema: attribute ref, operators, value type per operator,
4
+ * regexFlags, and/or/not nesting, and "*" (everyone).
5
+ */
6
+ import { z } from "zod";
7
+
8
+ import type { ProjectConfig } from "../config";
9
+ import { getConditionsZodSchema } from "./conditionSchema";
10
+
11
+ function minimalProjectConfig(): ProjectConfig {
12
+ return {
13
+ featuresDirectoryPath: "",
14
+ segmentsDirectoryPath: "",
15
+ attributesDirectoryPath: "",
16
+ groupsDirectoryPath: "",
17
+ schemasDirectoryPath: "",
18
+ testsDirectoryPath: "",
19
+ stateDirectoryPath: "",
20
+ datafilesDirectoryPath: "",
21
+ datafileNamePattern: "",
22
+ revisionFileName: "",
23
+ siteExportDirectoryPath: "",
24
+ environments: ["staging", "production"],
25
+ tags: ["all"],
26
+ adapter: {},
27
+ plugins: [],
28
+ defaultBucketBy: "userId",
29
+ parser: "yml",
30
+ prettyState: true,
31
+ prettyDatafile: false,
32
+ stringify: true,
33
+ };
34
+ }
35
+
36
+ const TEST_ATTRIBUTES: [string, ...string[]] = ["userId", "country", "device", "email"];
37
+
38
+ function getConditionsSchema() {
39
+ return getConditionsZodSchema(minimalProjectConfig(), TEST_ATTRIBUTES);
40
+ }
41
+
42
+ function parseConditions(input: unknown): z.SafeParseReturnType<unknown, unknown> {
43
+ return getConditionsSchema().safeParse(input);
44
+ }
45
+
46
+ function expectConditionsSuccess(input: unknown): void {
47
+ const result = parseConditions(input);
48
+ expect(result.success).toBe(true);
49
+ if (!result.success) {
50
+ const err = (result as z.SafeParseError<unknown>).error;
51
+ const msg = err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
52
+ throw new Error(`Expected conditions to pass: ${msg}`);
53
+ }
54
+ }
55
+
56
+ function expectConditionsFailure(input: unknown, messageSubstring?: string): z.ZodError {
57
+ const result = parseConditions(input);
58
+ expect(result.success).toBe(false);
59
+ if (result.success) throw new Error("Expected conditions to fail");
60
+ const err = (result as z.SafeParseError<unknown>).error;
61
+ if (messageSubstring) {
62
+ const messages = err.issues
63
+ .map((i) => (typeof i.message === "string" ? i.message : ""))
64
+ .join(" ");
65
+ expect(messages).toContain(messageSubstring);
66
+ }
67
+ return err;
68
+ }
69
+
70
+ /** Assert that an intentional mistake produces an error at the expected path with expected message. */
71
+ function expectConditionErrorSurfaces(
72
+ input: unknown,
73
+ opts: { pathContains: string[]; messageContains: string },
74
+ ): void {
75
+ const err = expectConditionsFailure(input, opts.messageContains);
76
+ const pathStrings = err.issues.map((i) => i.path.join("."));
77
+ const hasMatchingPath = pathStrings.some((p) =>
78
+ opts.pathContains.every((seg) => p.includes(seg)),
79
+ );
80
+ expect(hasMatchingPath).toBe(true);
81
+ }
82
+
83
+ describe("conditionSchema.ts :: getConditionsZodSchema", () => {
84
+ describe("attribute", () => {
85
+ it("accepts condition when attribute is in available list", () => {
86
+ expectConditionsSuccess({
87
+ attribute: "userId",
88
+ operator: "equals",
89
+ value: "u1",
90
+ });
91
+ });
92
+
93
+ it("rejects condition when attribute is unknown", () => {
94
+ expectConditionsFailure(
95
+ {
96
+ attribute: "unknownAttr",
97
+ operator: "equals",
98
+ value: "x",
99
+ },
100
+ "Unknown attribute",
101
+ );
102
+ });
103
+ });
104
+
105
+ describe("operator and value: common (equals, notEquals)", () => {
106
+ it("accepts string value with equals", () => {
107
+ expectConditionsSuccess({ attribute: "userId", operator: "equals", value: "u1" });
108
+ });
109
+
110
+ it("accepts number value with equals", () => {
111
+ expectConditionsSuccess({ attribute: "userId", operator: "equals", value: 42 });
112
+ });
113
+
114
+ it("accepts boolean value with equals", () => {
115
+ expectConditionsSuccess({ attribute: "device", operator: "equals", value: true });
116
+ });
117
+
118
+ it("accepts null value with equals", () => {
119
+ expectConditionsSuccess({ attribute: "userId", operator: "equals", value: null });
120
+ });
121
+
122
+ it("rejects array value with equals", () => {
123
+ expectConditionsFailure(
124
+ { attribute: "userId", operator: "equals", value: ["a", "b"] },
125
+ "value has to be",
126
+ );
127
+ });
128
+ });
129
+
130
+ describe("operator and value: numeric", () => {
131
+ it("accepts number value with greaterThan", () => {
132
+ expectConditionsSuccess({
133
+ attribute: "userId",
134
+ operator: "greaterThan",
135
+ value: 10,
136
+ });
137
+ });
138
+
139
+ it("rejects string value with greaterThan", () => {
140
+ expectConditionsFailure(
141
+ { attribute: "userId", operator: "greaterThan", value: "10" },
142
+ "value must be a number",
143
+ );
144
+ });
145
+
146
+ it("rejects missing value with lessThan", () => {
147
+ const result = parseConditions({
148
+ attribute: "userId",
149
+ operator: "lessThan",
150
+ });
151
+ expect(result.success).toBe(false);
152
+ });
153
+ });
154
+
155
+ describe("operator and value: string", () => {
156
+ it("accepts string value with contains", () => {
157
+ expectConditionsSuccess({
158
+ attribute: "email",
159
+ operator: "contains",
160
+ value: "@",
161
+ });
162
+ });
163
+
164
+ it("rejects number value with startsWith", () => {
165
+ expectConditionsFailure(
166
+ { attribute: "country", operator: "startsWith", value: 123 },
167
+ "value must be a string",
168
+ );
169
+ });
170
+ });
171
+
172
+ describe("operator and value: date", () => {
173
+ it("accepts ISO 8601 string with before", () => {
174
+ expectConditionsSuccess({
175
+ attribute: "userId",
176
+ operator: "before",
177
+ value: "2025-12-31T23:59:59Z",
178
+ });
179
+ });
180
+
181
+ it("rejects non-ISO string with after", () => {
182
+ expectConditionsFailure(
183
+ { attribute: "userId", operator: "after", value: "not-a-date" },
184
+ "ISO 8601",
185
+ );
186
+ });
187
+
188
+ it("rejects missing value with before", () => {
189
+ expectConditionsFailure(
190
+ { attribute: "userId", operator: "before" },
191
+ "value must be provided",
192
+ );
193
+ });
194
+ });
195
+
196
+ describe("operator and value: array (in, notIn)", () => {
197
+ it("accepts array of strings with in", () => {
198
+ expectConditionsSuccess({
199
+ attribute: "country",
200
+ operator: "in",
201
+ value: ["de", "fr", "nl"],
202
+ });
203
+ });
204
+
205
+ it("rejects non-array value with notIn", () => {
206
+ expectConditionsFailure(
207
+ { attribute: "country", operator: "notIn", value: "de" },
208
+ "value must be an array",
209
+ );
210
+ });
211
+ });
212
+
213
+ describe("operator and value: regex (matches, notMatches)", () => {
214
+ it("accepts string value with matches", () => {
215
+ expectConditionsSuccess({
216
+ attribute: "email",
217
+ operator: "matches",
218
+ value: "^[a-z]+@",
219
+ });
220
+ });
221
+
222
+ it("accepts regexFlags with matches", () => {
223
+ expectConditionsSuccess({
224
+ attribute: "email",
225
+ operator: "matches",
226
+ value: "hello",
227
+ regexFlags: "i",
228
+ });
229
+ });
230
+
231
+ it("rejects invalid regexFlags", () => {
232
+ expectConditionsFailure(
233
+ {
234
+ attribute: "email",
235
+ operator: "matches",
236
+ value: "x",
237
+ regexFlags: "invalid",
238
+ },
239
+ "regexFlags",
240
+ );
241
+ });
242
+
243
+ it("rejects regexFlags when operator is not matches/notMatches", () => {
244
+ expectConditionsFailure(
245
+ {
246
+ attribute: "userId",
247
+ operator: "equals",
248
+ value: "u1",
249
+ regexFlags: "i",
250
+ },
251
+ "not needed",
252
+ );
253
+ });
254
+ });
255
+
256
+ describe("operator: exists, notExists (no value)", () => {
257
+ it("accepts condition without value for exists", () => {
258
+ expectConditionsSuccess({
259
+ attribute: "userId",
260
+ operator: "exists",
261
+ });
262
+ });
263
+
264
+ it("rejects value when operator is exists", () => {
265
+ expectConditionsFailure(
266
+ { attribute: "userId", operator: "exists", value: "x" },
267
+ "value is not needed",
268
+ );
269
+ });
270
+ });
271
+
272
+ describe("structure: and / or / not", () => {
273
+ it("accepts and array of conditions", () => {
274
+ expectConditionsSuccess({
275
+ and: [
276
+ { attribute: "userId", operator: "equals", value: "u1" },
277
+ { attribute: "country", operator: "equals", value: "de" },
278
+ ],
279
+ });
280
+ });
281
+
282
+ it("accepts or array of conditions", () => {
283
+ expectConditionsSuccess({
284
+ or: [
285
+ { attribute: "country", operator: "equals", value: "de" },
286
+ { attribute: "country", operator: "equals", value: "fr" },
287
+ ],
288
+ });
289
+ });
290
+
291
+ it("accepts not array of conditions", () => {
292
+ expectConditionsSuccess({
293
+ not: [{ attribute: "country", operator: "equals", value: "us" }],
294
+ });
295
+ });
296
+
297
+ it("accepts nested and inside or", () => {
298
+ expectConditionsSuccess({
299
+ or: [
300
+ {
301
+ and: [
302
+ { attribute: "userId", operator: "equals", value: "u1" },
303
+ { attribute: "device", operator: "equals", value: "mobile" },
304
+ ],
305
+ },
306
+ { attribute: "country", operator: "equals", value: "de" },
307
+ ],
308
+ });
309
+ });
310
+
311
+ it("rejects unknown attribute inside nested condition", () => {
312
+ expectConditionsFailure(
313
+ {
314
+ and: [
315
+ { attribute: "userId", operator: "equals", value: "u1" },
316
+ { attribute: "badAttr", operator: "equals", value: "x" },
317
+ ],
318
+ },
319
+ "Unknown attribute",
320
+ );
321
+ });
322
+
323
+ it("rejects invalid value type inside nested condition", () => {
324
+ expectConditionsFailure(
325
+ {
326
+ or: [{ attribute: "country", operator: "greaterThan", value: "string" }],
327
+ },
328
+ "value must be a number",
329
+ );
330
+ });
331
+ });
332
+
333
+ describe("everyone (*)", () => {
334
+ it("accepts literal * as conditions", () => {
335
+ expectConditionsSuccess("*");
336
+ });
337
+ });
338
+
339
+ describe("conditions as array", () => {
340
+ it("accepts array of plain conditions", () => {
341
+ expectConditionsSuccess([
342
+ { attribute: "userId", operator: "equals", value: "u1" },
343
+ { attribute: "country", operator: "equals", value: "de" },
344
+ ]);
345
+ });
346
+
347
+ it("rejects array containing invalid condition", () => {
348
+ expectConditionsFailure(
349
+ [
350
+ { attribute: "userId", operator: "equals", value: "u1" },
351
+ { attribute: "unknown", operator: "equals", value: "x" },
352
+ ],
353
+ "Unknown attribute",
354
+ );
355
+ });
356
+ });
357
+
358
+ describe("strict: no extra keys on and/or/not", () => {
359
+ it("rejects and/or/not with extra key", () => {
360
+ const result = parseConditions({
361
+ and: [{ attribute: "userId", operator: "equals", value: "u1" }],
362
+ extraKey: true,
363
+ });
364
+ expect(result.success).toBe(false);
365
+ });
366
+ });
367
+
368
+ describe("operator enum", () => {
369
+ it("rejects unknown operator", () => {
370
+ const result = parseConditions({
371
+ attribute: "userId",
372
+ operator: "unknownOp",
373
+ value: "x",
374
+ });
375
+ expect(result.success).toBe(false);
376
+ });
377
+
378
+ it("accepts all common and numeric operators", () => {
379
+ expectConditionsSuccess({ attribute: "userId", operator: "notEquals", value: "x" });
380
+ expectConditionsSuccess({ attribute: "userId", operator: "greaterThanOrEquals", value: 0 });
381
+ expectConditionsSuccess({ attribute: "userId", operator: "lessThanOrEquals", value: 100 });
382
+ });
383
+
384
+ it("accepts semver operators with string value", () => {
385
+ expectConditionsSuccess({
386
+ attribute: "userId",
387
+ operator: "semverEquals",
388
+ value: "1.0.0",
389
+ });
390
+ });
391
+ });
392
+
393
+ describe("errors surface properly: intentional mistakes produce correct path and message", () => {
394
+ it("unknown attribute: error path includes attribute, message says Unknown attribute", () => {
395
+ expectConditionErrorSurfaces(
396
+ { attribute: "typoAttr", operator: "equals", value: "x" },
397
+ { pathContains: ["attribute"], messageContains: "Unknown attribute" },
398
+ );
399
+ });
400
+
401
+ it("numeric operator with string value: error path points to value, message says number", () => {
402
+ expectConditionErrorSurfaces(
403
+ { attribute: "userId", operator: "greaterThan", value: "10" },
404
+ { pathContains: ["value"], messageContains: "number" },
405
+ );
406
+ });
407
+
408
+ it("date operator with invalid string: error path points to value, message mentions ISO", () => {
409
+ expectConditionErrorSurfaces(
410
+ { attribute: "userId", operator: "before", value: "not-a-date" },
411
+ { pathContains: ["value"], messageContains: "ISO" },
412
+ );
413
+ });
414
+
415
+ it("exists operator with value set: error path points to value, message says not needed", () => {
416
+ expectConditionErrorSurfaces(
417
+ { attribute: "userId", operator: "exists", value: "x" },
418
+ { pathContains: ["value"], messageContains: "not needed" },
419
+ );
420
+ });
421
+
422
+ it("regexFlags when operator is not matches: error path points to regexFlags", () => {
423
+ expectConditionErrorSurfaces(
424
+ {
425
+ attribute: "userId",
426
+ operator: "equals",
427
+ value: "u1",
428
+ regexFlags: "i",
429
+ },
430
+ { pathContains: ["regexFlags"], messageContains: "not needed" },
431
+ );
432
+ });
433
+
434
+ it("nested condition with unknown attribute: error path goes into and.*.attribute", () => {
435
+ expectConditionErrorSurfaces(
436
+ {
437
+ and: [
438
+ { attribute: "userId", operator: "equals", value: "u1" },
439
+ { attribute: "badAttr", operator: "equals", value: "x" },
440
+ ],
441
+ },
442
+ { pathContains: ["attribute"], messageContains: "Unknown attribute" },
443
+ );
444
+ });
445
+ });
446
+ });