@featurevisor/core 0.0.3

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/src/linter.ts ADDED
@@ -0,0 +1,328 @@
1
+ // for use in node only
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+
5
+ import * as Joi from "joi";
6
+
7
+ import { ProjectConfig } from "./config";
8
+ import { getYAMLFiles, parseYaml } from "./utils";
9
+
10
+ export function getAttributeJoiSchema(projectConfig: ProjectConfig) {
11
+ const attributeJoiSchema = Joi.object({
12
+ archived: Joi.boolean(),
13
+ type: Joi.string().allow("boolean", "string", "integer", "double"),
14
+ description: Joi.string(),
15
+ capture: Joi.boolean().optional(),
16
+ });
17
+
18
+ return attributeJoiSchema;
19
+ }
20
+
21
+ export function getConditionsJoiSchema(projectConfig: ProjectConfig) {
22
+ const plainConditionJoiSchema = Joi.object({
23
+ attribute: Joi.string(),
24
+ operator: Joi.string().valid(
25
+ "equals",
26
+ "notEquals",
27
+
28
+ // numeric
29
+ "greaterThan",
30
+ "greaterThanOrEquals",
31
+ "lessThan",
32
+ "lessThanOrEquals",
33
+
34
+ // string
35
+ "contains",
36
+ "notContains",
37
+ "startsWith",
38
+ "endsWith",
39
+
40
+ // array of strings
41
+ "in",
42
+ "notIn",
43
+ ),
44
+ value: Joi.alternatives().try(
45
+ // @TODO: make them more specific
46
+ Joi.string(),
47
+ Joi.number(),
48
+ Joi.boolean(),
49
+ Joi.array().items(Joi.string()),
50
+ ),
51
+ });
52
+
53
+ const andOrConditionJoiSchema = Joi.alternatives()
54
+ .try(
55
+ Joi.object({
56
+ and: Joi.array().items(Joi.link("#andOrCondition"), plainConditionJoiSchema),
57
+ }),
58
+ Joi.object({
59
+ or: Joi.array().items(Joi.link("#andOrCondition"), plainConditionJoiSchema),
60
+ }),
61
+ )
62
+ .id("andOrCondition");
63
+
64
+ const conditionJoiSchema = Joi.alternatives().try(
65
+ andOrConditionJoiSchema,
66
+ plainConditionJoiSchema,
67
+ );
68
+
69
+ const conditionsJoiSchema = Joi.alternatives().try(
70
+ conditionJoiSchema,
71
+ Joi.array().items(conditionJoiSchema),
72
+ );
73
+
74
+ return conditionsJoiSchema;
75
+ }
76
+
77
+ export function getSegmentJoiSchema(projectConfig: ProjectConfig, conditionsJoiSchema) {
78
+ const segmentJoiSchema = Joi.object({
79
+ archived: Joi.boolean().optional(),
80
+ description: Joi.string(),
81
+ conditions: conditionsJoiSchema,
82
+ });
83
+
84
+ return segmentJoiSchema;
85
+ }
86
+
87
+ export function getFeatureJoiSchema(projectConfig: ProjectConfig, conditionsJoiSchema) {
88
+ const variationValueJoiSchema = Joi.alternatives().try(Joi.string(), Joi.number(), Joi.boolean());
89
+ const variableValueJoiSchema = Joi.alternatives()
90
+ .try(Joi.string(), Joi.number(), Joi.boolean(), Joi.array().items(Joi.string()))
91
+ .allow("");
92
+
93
+ const plainGroupSegment = Joi.string();
94
+
95
+ const andOrGroupSegment = Joi.alternatives()
96
+ .try(
97
+ Joi.object({
98
+ and: Joi.array().items(Joi.link("#andOrGroupSegment"), plainGroupSegment),
99
+ }),
100
+ Joi.object({
101
+ or: Joi.array().items(Joi.link("#andOrGroupSegment"), plainGroupSegment),
102
+ }),
103
+ )
104
+ .id("andOrGroupSegment");
105
+
106
+ const groupSegment = Joi.alternatives().try(andOrGroupSegment, plainGroupSegment);
107
+
108
+ const groupSegmentsJoiSchema = Joi.alternatives().try(
109
+ Joi.array().items(groupSegment),
110
+ groupSegment,
111
+ );
112
+
113
+ const environmentJoiSchema = Joi.object({
114
+ expose: Joi.boolean(),
115
+ rules: Joi.array().items(
116
+ Joi.object({
117
+ key: Joi.string(), // @TODO: make it unique among siblings
118
+ segments: groupSegmentsJoiSchema,
119
+ percentage: Joi.number().min(0).max(100), // @TODO: allow maximum 3 decimal places
120
+ }),
121
+ ),
122
+ force: Joi.array().items(
123
+ Joi.object({
124
+ // @TODO: either of the two below
125
+ segments: groupSegmentsJoiSchema,
126
+ conditions: conditionsJoiSchema,
127
+
128
+ variation: variationValueJoiSchema,
129
+ variables: Joi.object(), // @TODO: make it stricter
130
+ }),
131
+ ),
132
+ });
133
+
134
+ const allEnvironomentsSchema = {};
135
+ projectConfig.environments.forEach((environmentKey) => {
136
+ allEnvironomentsSchema[environmentKey] = environmentJoiSchema;
137
+ });
138
+ const allEnvironomentsJoiSchema = Joi.object(allEnvironomentsSchema);
139
+
140
+ const featureJoiSchema = Joi.object({
141
+ archived: Joi.boolean().optional(),
142
+ description: Joi.string(),
143
+ tags: Joi.array().items(Joi.string()),
144
+
145
+ defaultVariation: variationValueJoiSchema,
146
+
147
+ bucketBy: Joi.alternatives().try(Joi.string(), Joi.array().items(Joi.string())),
148
+
149
+ variablesSchema: Joi.array().items(
150
+ Joi.object({
151
+ key: Joi.string(), // @TODO: make it unique among siblings
152
+ type: Joi.string().valid("string", "integer", "boolean", "double", "array"),
153
+ defaultValue: variableValueJoiSchema, // @TODO: make it stricter based on `type`
154
+ }),
155
+ ),
156
+
157
+ variations: Joi.array().items(
158
+ Joi.object({
159
+ type: Joi.string().valid("string", "integer", "boolean", "double"),
160
+ value: variationValueJoiSchema, // @TODO: make it unique among siblings
161
+ weight: Joi.number().integer().min(0).max(100), // @TODO: total sum among siblings should be exactly 100, allow maximum 3 decimal places
162
+ variables: Joi.array().items(
163
+ Joi.object({
164
+ key: Joi.string(), // @TODO: make it unique among siblings
165
+ value: variableValueJoiSchema,
166
+ overrides: Joi.array().items(
167
+ Joi.object({
168
+ // @TODO: either segments or conditions prsent at a time
169
+ segments: groupSegmentsJoiSchema,
170
+ conditions: conditionsJoiSchema,
171
+
172
+ // @TODO: make it stricter based on `type`
173
+ value: variableValueJoiSchema,
174
+ }),
175
+ ),
176
+ }),
177
+ ),
178
+ }),
179
+ ),
180
+
181
+ environments: allEnvironomentsJoiSchema,
182
+ });
183
+
184
+ return featureJoiSchema;
185
+ }
186
+
187
+ export function getTestsJoiSchema(projectConfig: ProjectConfig) {
188
+ const testsJoiSchema = Joi.object({
189
+ tests: Joi.array().items(
190
+ Joi.object({
191
+ description: Joi.string().optional(),
192
+ tag: Joi.string(), // @TODO: make it specific
193
+ environment: Joi.string().valid("production", "staging", "testing", "development"), // TODO: make it specific
194
+ features: Joi.array().items(
195
+ Joi.object({
196
+ key: Joi.string(), // @TODO: make it specific
197
+ assertions: Joi.array().items(
198
+ Joi.object({
199
+ description: Joi.string().optional(),
200
+ at: Joi.number().integer().min(0).max(100),
201
+ attributes: Joi.object(), // @TODO: make it specific
202
+
203
+ // @TODO: one or both below
204
+ expectedVariation: Joi.alternatives().try(
205
+ Joi.string(),
206
+ Joi.number(),
207
+ Joi.boolean(),
208
+ ), // @TODO: make it specific
209
+ expectedVariables: Joi.object(), // @TODO: make it specific
210
+ }),
211
+ ),
212
+ }),
213
+ ),
214
+ }),
215
+ ),
216
+ });
217
+
218
+ return testsJoiSchema;
219
+ }
220
+
221
+ export function printJoiError(e: Joi.ValidationError) {
222
+ const { details } = e;
223
+
224
+ details.forEach((detail) => {
225
+ console.error(" => Error:", detail.message);
226
+ console.error(" => Path:", detail.path.join("."));
227
+ console.error(" => Value:", detail.context?.value);
228
+ });
229
+ }
230
+
231
+ export async function lintProject(projectConfig: ProjectConfig): Promise<boolean> {
232
+ let hasError = false;
233
+
234
+ // lint attributes
235
+ console.log("Linting attributes...\n");
236
+ const attributeFilePaths = getYAMLFiles(path.join(projectConfig.attributesDirectoryPath));
237
+ const attributeJoiSchema = getAttributeJoiSchema(projectConfig);
238
+
239
+ for (const filePath of attributeFilePaths) {
240
+ const key = path.basename(filePath, ".yml");
241
+ const parsed = parseYaml(fs.readFileSync(filePath, "utf8")) as any;
242
+ console.log(" =>", key);
243
+
244
+ try {
245
+ await attributeJoiSchema.validateAsync(parsed);
246
+ } catch (e) {
247
+ if (e instanceof Joi.ValidationError) {
248
+ printJoiError(e);
249
+ } else {
250
+ console.log(e);
251
+ }
252
+
253
+ hasError = true;
254
+ }
255
+ }
256
+
257
+ // lint segments
258
+ console.log("\nLinting segments...\n");
259
+ const segmentFilePaths = getYAMLFiles(path.join(projectConfig.segmentsDirectoryPath));
260
+ const conditionsJoiSchema = getConditionsJoiSchema(projectConfig);
261
+ const segmentJoiSchema = getSegmentJoiSchema(projectConfig, conditionsJoiSchema);
262
+
263
+ for (const filePath of segmentFilePaths) {
264
+ const key = path.basename(filePath, ".yml");
265
+ const parsed = parseYaml(fs.readFileSync(filePath, "utf8")) as any;
266
+ console.log(" =>", key);
267
+
268
+ try {
269
+ await segmentJoiSchema.validateAsync(parsed);
270
+ } catch (e) {
271
+ if (e instanceof Joi.ValidationError) {
272
+ printJoiError(e);
273
+ } else {
274
+ console.log(e);
275
+ }
276
+
277
+ hasError = true;
278
+ }
279
+ }
280
+
281
+ // lint features
282
+ console.log("\nLinting features...\n");
283
+ const featureFilePaths = getYAMLFiles(path.join(projectConfig.featuresDirectoryPath));
284
+ const featureJoiSchema = getFeatureJoiSchema(projectConfig, conditionsJoiSchema);
285
+
286
+ for (const filePath of featureFilePaths) {
287
+ const key = path.basename(filePath, ".yml");
288
+ const parsed = parseYaml(fs.readFileSync(filePath, "utf8")) as any;
289
+ console.log(" =>", key);
290
+
291
+ try {
292
+ await featureJoiSchema.validateAsync(parsed);
293
+ } catch (e) {
294
+ if (e instanceof Joi.ValidationError) {
295
+ printJoiError(e);
296
+ } else {
297
+ console.log(e);
298
+ }
299
+
300
+ hasError = true;
301
+ }
302
+ }
303
+
304
+ // lint tests
305
+ console.log("\nLinting tests...\n");
306
+ const testFilePaths = getYAMLFiles(path.join(projectConfig.testsDirectoryPath));
307
+ const testsJoiSchema = getTestsJoiSchema(projectConfig);
308
+
309
+ for (const filePath of testFilePaths) {
310
+ const key = path.basename(filePath, ".yml");
311
+ const parsed = parseYaml(fs.readFileSync(filePath, "utf8")) as any;
312
+ console.log(" =>", key);
313
+
314
+ try {
315
+ await testsJoiSchema.validateAsync(parsed);
316
+ } catch (e) {
317
+ if (e instanceof Joi.ValidationError) {
318
+ printJoiError(e);
319
+ } else {
320
+ console.log(e);
321
+ }
322
+
323
+ hasError = true;
324
+ }
325
+ }
326
+
327
+ return hasError;
328
+ }
package/src/tester.ts ADDED
@@ -0,0 +1,163 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+
4
+ import {
5
+ Attributes,
6
+ DatafileContent,
7
+ VariableKey,
8
+ VariableValue,
9
+ VariationValue,
10
+ } from "@featurevisor/types";
11
+ import { FeaturevisorSDK, MAX_BUCKETED_NUMBER } from "@featurevisor/sdk";
12
+
13
+ import { ProjectConfig } from "./config";
14
+ import { parseYaml } from "./utils";
15
+ import { getDatafilePath } from "./builder";
16
+
17
+ export interface Assertion {
18
+ description?: string;
19
+ at: number; // bucket weight: 0 to 100
20
+ attributes: Attributes;
21
+ expectedVariation?: VariationValue;
22
+ expectedVariables?: {
23
+ [key: VariableKey]: VariableValue;
24
+ };
25
+ }
26
+
27
+ export interface TestFeature {
28
+ key: string;
29
+ assertions: Assertion[];
30
+ }
31
+
32
+ export interface Test {
33
+ description?: string;
34
+ tag: string;
35
+ environment: string;
36
+ features: TestFeature[];
37
+ }
38
+
39
+ export interface Spec {
40
+ tests: Test[];
41
+ }
42
+
43
+ const errorColor = "\x1b[31m";
44
+
45
+ // @TODO: make it better
46
+ export function checkIfArraysAreEqual(a, b) {
47
+ if (!Array.isArray(a) || !Array.isArray(b)) {
48
+ return false;
49
+ }
50
+
51
+ if (a.length !== b.length) return false;
52
+
53
+ for (let i = 0; i < a.length; ++i) {
54
+ if (a[i] !== b[i]) {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ return true;
60
+ }
61
+
62
+ export function testProject(rootDirectoryPath: string, projectConfig: ProjectConfig): boolean {
63
+ let hasError = false;
64
+
65
+ if (!fs.existsSync(projectConfig.testsDirectoryPath)) {
66
+ console.error(`Tests directory does not exist: ${projectConfig.testsDirectoryPath}`);
67
+ hasError = true;
68
+
69
+ return hasError;
70
+ }
71
+
72
+ const testFiles = fs
73
+ .readdirSync(projectConfig.testsDirectoryPath)
74
+ .filter((f) => f.endsWith(".yml"));
75
+
76
+ if (testFiles.length === 0) {
77
+ console.error(`No tests found in: ${projectConfig.testsDirectoryPath}`);
78
+ hasError = true;
79
+
80
+ return hasError;
81
+ }
82
+
83
+ for (const testFile of testFiles) {
84
+ const testFilePath = path.join(projectConfig.testsDirectoryPath, testFile);
85
+
86
+ console.log(` => Testing: ${testFilePath.replace(rootDirectoryPath, "")}`);
87
+
88
+ const parsed = parseYaml(fs.readFileSync(testFilePath, "utf8")) as Spec;
89
+
90
+ parsed.tests.forEach(function (test, tIndex) {
91
+ const datafilePath = getDatafilePath(projectConfig, test.environment, test.tag);
92
+
93
+ if (!fs.existsSync(datafilePath)) {
94
+ console.error(` => Datafile does not exist: ${datafilePath}`);
95
+ hasError = true;
96
+
97
+ return;
98
+ }
99
+
100
+ const datafileContent = JSON.parse(fs.readFileSync(datafilePath, "utf8")) as DatafileContent;
101
+
102
+ let currentAt = 0;
103
+
104
+ let sdk = new FeaturevisorSDK({
105
+ datafile: datafileContent,
106
+ configureBucketValue: (feature, attributes, bucketValue) => {
107
+ return currentAt;
108
+ },
109
+ });
110
+
111
+ test.features.forEach(function (feature, fIndex) {
112
+ const featureKey = feature.key;
113
+
114
+ console.log(` => Feature "${featureKey}" in environment "${test.environment}":`);
115
+
116
+ feature.assertions.forEach(function (assertion, aIndex) {
117
+ console.log(` => Assertion #${aIndex + 1}: ${assertion.description || ""}`);
118
+
119
+ let assertionHasError = false;
120
+ currentAt = assertion.at * (MAX_BUCKETED_NUMBER / 100);
121
+
122
+ // variation
123
+ if ("expectedVariation" in assertion) {
124
+ const variation = sdk.getVariation(featureKey, assertion.attributes);
125
+
126
+ if (variation !== assertion.expectedVariation) {
127
+ hasError = true;
128
+ assertionHasError = true;
129
+
130
+ console.error(
131
+ ` Variation failed: expected "${assertion.expectedVariation}", got "${variation}"`,
132
+ );
133
+ }
134
+ }
135
+
136
+ // variables
137
+ if (typeof assertion.expectedVariables === "object") {
138
+ Object.keys(assertion.expectedVariables).forEach(function (variableKey) {
139
+ const expectedValue =
140
+ assertion.expectedVariables && assertion.expectedVariables[variableKey];
141
+ const actualValue = sdk.getVariable(featureKey, variableKey, assertion.attributes);
142
+
143
+ const passed = Array.isArray(expectedValue)
144
+ ? checkIfArraysAreEqual(expectedValue, actualValue)
145
+ : expectedValue === actualValue;
146
+
147
+ if (!passed) {
148
+ hasError = true;
149
+ assertionHasError = true;
150
+
151
+ console.error(
152
+ ` Variable "${variableKey}" failed: expected "${expectedValue}", got "${actualValue}"`,
153
+ );
154
+ }
155
+ });
156
+ }
157
+ });
158
+ });
159
+ });
160
+ }
161
+
162
+ return hasError;
163
+ }