@featurevisor/catalog 0.0.1 → 3.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/dist/assets/index-DB7fEbI6.js +25 -0
  3. package/dist/assets/index-DgA0Oqrg.css +2 -0
  4. package/dist/favicon.png +0 -0
  5. package/dist/index.html +14 -0
  6. package/dist/logo-text.png +0 -0
  7. package/lib/entityTypes.d.ts +16 -0
  8. package/lib/entityTypes.js +96 -0
  9. package/lib/entityTypes.js.map +1 -0
  10. package/lib/index.d.ts +1 -0
  11. package/lib/index.js +18 -0
  12. package/lib/index.js.map +1 -0
  13. package/lib/node/index.d.ts +74 -0
  14. package/lib/node/index.js +1368 -0
  15. package/lib/node/index.js.map +1 -0
  16. package/lib/types.d.ts +95 -0
  17. package/lib/types.js +3 -0
  18. package/lib/types.js.map +1 -0
  19. package/package.json +66 -13
  20. package/public/favicon.png +0 -0
  21. package/public/logo-text.png +0 -0
  22. package/src/App.tsx +62 -0
  23. package/src/api.spec.ts +14 -0
  24. package/src/api.ts +74 -0
  25. package/src/components/tests.spec.ts +134 -0
  26. package/src/components/tests.tsx +301 -0
  27. package/src/components/trees.tsx +202 -0
  28. package/src/components/ui.tsx +660 -0
  29. package/src/components/variables.tsx +700 -0
  30. package/src/components/variations.tsx +450 -0
  31. package/src/context/CatalogContext.tsx +30 -0
  32. package/src/entityTypes.spec.ts +13 -0
  33. package/src/entityTypes.ts +102 -0
  34. package/src/index.ts +1 -0
  35. package/src/main.tsx +26 -0
  36. package/src/node/index.spec.ts +380 -0
  37. package/src/node/index.ts +1906 -0
  38. package/src/pages/EntityDetailPage.tsx +1144 -0
  39. package/src/pages/HistoryPage.tsx +144 -0
  40. package/src/pages/HomePage.tsx +21 -0
  41. package/src/pages/ListPage.tsx +737 -0
  42. package/src/styles.css +59 -0
  43. package/src/testModel.ts +123 -0
  44. package/src/types.ts +112 -0
  45. package/src/vite-env.d.ts +1 -0
  46. package/index.js +0 -1
@@ -0,0 +1,380 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import * as childProcess from "child_process";
5
+
6
+ import { exportCatalog, type CatalogRuntime } from ".";
7
+
8
+ function createProjectConfig(root: string, sets = false) {
9
+ return {
10
+ sets,
11
+ tags: ["all", "web", "mobile", "premium"],
12
+ environments: ["staging", "production"],
13
+ namespaceCharacter: ".",
14
+ catalogDirectoryPath: path.join(root, "catalog"),
15
+ featuresDirectoryPath: path.join(root, "features"),
16
+ segmentsDirectoryPath: path.join(root, "segments"),
17
+ attributesDirectoryPath: path.join(root, "attributes"),
18
+ targetsDirectoryPath: path.join(root, "targets"),
19
+ groupsDirectoryPath: path.join(root, "groups"),
20
+ schemasDirectoryPath: path.join(root, "schemas"),
21
+ testsDirectoryPath: path.join(root, "tests"),
22
+ setsDirectoryPath: path.join(root, "sets"),
23
+ };
24
+ }
25
+
26
+ function createDatasource(set = "") {
27
+ const featureKey = set ? `checkout.${set}` : "checkout";
28
+ const segmentKey = "premiumUsers";
29
+
30
+ return {
31
+ getExtension: () => "yml",
32
+ listSets: async () => ["dev", "production"],
33
+ forSet: (nextSet: string) => createDatasource(nextSet),
34
+ listHistoryEntries: async () => [
35
+ {
36
+ commit: `${set || "root"}123456789`,
37
+ author: "Test",
38
+ timestamp: "2026-01-01T00:00:00.000Z",
39
+ entities: [{ type: "feature", key: featureKey }],
40
+ },
41
+ ],
42
+ listFeatures: async () => [featureKey],
43
+ readFeature: async () => ({
44
+ key: featureKey,
45
+ description: "Checkout feature",
46
+ tags: ["web", "premium"],
47
+ bucketBy: "userId",
48
+ rules: {
49
+ staging: [
50
+ {
51
+ key: "rule",
52
+ segments: segmentKey,
53
+ conditions: [{ attribute: "country", operator: "equals", value: "nl" }],
54
+ percentage: 100,
55
+ enabled: true,
56
+ },
57
+ ],
58
+ production: [],
59
+ },
60
+ }),
61
+ listSegments: async () => [segmentKey],
62
+ readSegment: async () => ({
63
+ key: segmentKey,
64
+ description: "Premium users",
65
+ conditions: [{ attribute: "plan", operator: "equals", value: "premium" }],
66
+ }),
67
+ listAttributes: async () => ["plan", "country"],
68
+ readAttribute: async (key: string) => ({
69
+ key,
70
+ type: "string",
71
+ description: key === "plan" ? "Plan" : "Country",
72
+ }),
73
+ listTargets: async () => ["premiumWeb", "mobile"],
74
+ readTarget: async (key: string) =>
75
+ key === "premiumWeb"
76
+ ? {
77
+ key,
78
+ description: "Premium web",
79
+ tags: { and: ["web", "premium"] },
80
+ includeFeatures: ["checkout*"],
81
+ excludeFeatures: ["checkout.internal*"],
82
+ }
83
+ : { key, description: "Mobile", tag: "mobile", includeFeatures: "*" },
84
+ listGroups: async () => [],
85
+ readGroup: async () => undefined,
86
+ listSchemas: async () => [],
87
+ readSchema: async () => undefined,
88
+ listTests: async () => ["checkout-primary", "checkout-matrix", "premium-users"],
89
+ readTest: async (key: string) => {
90
+ if (key === "premium-users") {
91
+ return {
92
+ key,
93
+ segment: segmentKey,
94
+ assertions: [
95
+ {
96
+ context: { plan: "premium" },
97
+ expectedToMatch: true,
98
+ },
99
+ ],
100
+ };
101
+ }
102
+
103
+ if (key === "checkout-matrix") {
104
+ return {
105
+ key,
106
+ feature: featureKey,
107
+ assertions: [
108
+ {
109
+ matrix: { country: ["nl", "de"], at: [10, 90] },
110
+ description: "${{ country }} at ${{ at }}%",
111
+ environment: "production",
112
+ at: "${{ at }}",
113
+ context: { country: "${{ country }}" },
114
+ expectedToBeEnabled: true,
115
+ },
116
+ ],
117
+ };
118
+ }
119
+
120
+ return {
121
+ key,
122
+ feature: featureKey,
123
+ assertions: [
124
+ {
125
+ description: "Enabled in staging",
126
+ environment: "staging",
127
+ context: { country: "nl" },
128
+ expectedToBeEnabled: true,
129
+ },
130
+ ],
131
+ };
132
+ },
133
+ };
134
+ }
135
+
136
+ function createRuntime(): CatalogRuntime {
137
+ return {
138
+ getProjectSetExecutions: async (projectConfig, datasource) => {
139
+ if (!projectConfig.sets) {
140
+ return [{ set: "", projectConfig, datasource }];
141
+ }
142
+
143
+ return (await datasource.listSets()).map((set: string) => ({
144
+ set,
145
+ projectConfig: {
146
+ ...projectConfig,
147
+ featuresDirectoryPath: path.join(projectConfig.setsDirectoryPath, set, "features"),
148
+ segmentsDirectoryPath: path.join(projectConfig.setsDirectoryPath, set, "segments"),
149
+ attributesDirectoryPath: path.join(projectConfig.setsDirectoryPath, set, "attributes"),
150
+ targetsDirectoryPath: path.join(projectConfig.setsDirectoryPath, set, "targets"),
151
+ groupsDirectoryPath: path.join(projectConfig.setsDirectoryPath, set, "groups"),
152
+ schemasDirectoryPath: path.join(projectConfig.setsDirectoryPath, set, "schemas"),
153
+ testsDirectoryPath: path.join(projectConfig.setsDirectoryPath, set, "tests"),
154
+ },
155
+ datasource: datasource.forSet(set),
156
+ }));
157
+ },
158
+ };
159
+ }
160
+
161
+ function git(root: string, args: string[]) {
162
+ childProcess.execFileSync("git", ["-C", root, ...args], {
163
+ encoding: "utf8",
164
+ stdio: ["ignore", "pipe", "ignore"],
165
+ });
166
+ }
167
+
168
+ describe("catalog export", () => {
169
+ it("writes manifest, index, and feature detail for a root project", async () => {
170
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-catalog-"));
171
+
172
+ await exportCatalog(createRuntime(), root, createProjectConfig(root), createDatasource(), {
173
+ copyAssets: false,
174
+ });
175
+
176
+ const manifest = JSON.parse(
177
+ fs.readFileSync(path.join(root, "catalog", "data", "manifest.json"), "utf8"),
178
+ );
179
+ const index = JSON.parse(
180
+ fs.readFileSync(path.join(root, "catalog", "data", "root", "index.json"), "utf8"),
181
+ );
182
+ const detail = JSON.parse(
183
+ fs.readFileSync(
184
+ path.join(root, "catalog", "data", "root", "entities", "feature", "checkout.json"),
185
+ "utf8",
186
+ ),
187
+ );
188
+
189
+ expect(manifest.sets).toBe(false);
190
+ expect(index.counts.feature).toBe(1);
191
+ expect(index.entities.feature[0].targets).toEqual(["premiumWeb"]);
192
+ expect(index.entities.segment[0].targets).toEqual(["premiumWeb"]);
193
+ expect(index.entities.segment[0].usedInFeatureCount).toBe(1);
194
+ expect(
195
+ index.entities.attribute.find((entity: any) => entity.key === "country").targets,
196
+ ).toEqual(["premiumWeb"]);
197
+ expect(index.entities.attribute.find((entity: any) => entity.key === "plan").targets).toEqual([
198
+ "premiumWeb",
199
+ ]);
200
+ expect(
201
+ index.entities.attribute.find((entity: any) => entity.key === "plan").usedInSegmentCount,
202
+ ).toBe(1);
203
+ expect(detail.relationships).toMatchObject({
204
+ segments: ["premiumUsers"],
205
+ attributes: ["country"],
206
+ targets: ["premiumWeb"],
207
+ tests: ["checkout-matrix", "checkout-primary"],
208
+ });
209
+ expect(detail.tests).toHaveLength(2);
210
+ expect(detail.tests.map((test: any) => test.key)).toEqual([
211
+ "checkout-matrix",
212
+ "checkout-primary",
213
+ ]);
214
+ expect(detail.tests[0].assertions[0].matrix).toEqual({
215
+ country: ["nl", "de"],
216
+ at: [10, 90],
217
+ });
218
+
219
+ const segmentDetail = JSON.parse(
220
+ fs.readFileSync(
221
+ path.join(root, "catalog", "data", "root", "entities", "segment", "premiumUsers.json"),
222
+ "utf8",
223
+ ),
224
+ );
225
+ expect(segmentDetail.tests).toEqual([
226
+ expect.objectContaining({ key: "premium-users", segment: "premiumUsers" }),
227
+ ]);
228
+ });
229
+
230
+ it("writes per-set catalog files for sets-enabled projects", async () => {
231
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-catalog-"));
232
+
233
+ await exportCatalog(
234
+ createRuntime(),
235
+ root,
236
+ createProjectConfig(root, true),
237
+ createDatasource(),
238
+ {
239
+ copyAssets: false,
240
+ },
241
+ );
242
+
243
+ const manifest = JSON.parse(
244
+ fs.readFileSync(path.join(root, "catalog", "data", "manifest.json"), "utf8"),
245
+ );
246
+
247
+ expect(manifest.sets).toBe(true);
248
+ expect(manifest.setKeys).toEqual(["dev", "production"]);
249
+ expect(
250
+ fs.existsSync(
251
+ path.join(
252
+ root,
253
+ "catalog",
254
+ "data",
255
+ "sets",
256
+ "dev",
257
+ "entities",
258
+ "feature",
259
+ "checkout.dev.json",
260
+ ),
261
+ ),
262
+ ).toBe(true);
263
+ });
264
+
265
+ it("writes slash-namespaced entity details as nested data paths", async () => {
266
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-catalog-"));
267
+ const projectConfig = {
268
+ ...createProjectConfig(root),
269
+ namespaceCharacter: "/",
270
+ };
271
+ const baseDatasource = createDatasource();
272
+ const featureKey = "checkout/redesign";
273
+ const datasource = {
274
+ ...baseDatasource,
275
+ listHistoryEntries: async () => [
276
+ {
277
+ commit: "root123456789",
278
+ author: "Test",
279
+ timestamp: "2026-01-01T00:00:00.000Z",
280
+ entities: [{ type: "feature", key: featureKey }],
281
+ },
282
+ ],
283
+ listFeatures: async () => [featureKey],
284
+ readFeature: async () => ({
285
+ ...(await baseDatasource.readFeature()),
286
+ key: featureKey,
287
+ }),
288
+ listTests: async () => ["features/checkout/redesign.spec"],
289
+ readTest: async () => ({
290
+ key: "features/checkout/redesign.spec",
291
+ feature: featureKey,
292
+ assertions: [],
293
+ }),
294
+ };
295
+
296
+ await exportCatalog(createRuntime(), root, projectConfig, datasource, {
297
+ copyAssets: false,
298
+ });
299
+
300
+ const nestedDetailPath = path.join(
301
+ root,
302
+ "catalog",
303
+ "data",
304
+ "root",
305
+ "entities",
306
+ "feature",
307
+ "checkout",
308
+ "redesign.json",
309
+ );
310
+ const legacyFlatDetailPath = path.join(
311
+ root,
312
+ "catalog",
313
+ "data",
314
+ "root",
315
+ "entities",
316
+ "feature",
317
+ "checkout%2Fredesign.json",
318
+ );
319
+ const detail = JSON.parse(fs.readFileSync(nestedDetailPath, "utf8"));
320
+ const index = JSON.parse(
321
+ fs.readFileSync(path.join(root, "catalog", "data", "root", "index.json"), "utf8"),
322
+ );
323
+
324
+ expect(fs.existsSync(legacyFlatDetailPath)).toBe(false);
325
+ expect(
326
+ fs.existsSync(
327
+ path.join(
328
+ root,
329
+ "catalog",
330
+ "data",
331
+ "root",
332
+ "entities",
333
+ "feature",
334
+ "checkout",
335
+ "redesign",
336
+ "history",
337
+ "page-1.json",
338
+ ),
339
+ ),
340
+ ).toBe(true);
341
+ expect(index.entities.feature[0].key).toBe(featureKey);
342
+ expect(index.entities.feature[0].href).toBe("entities/feature/checkout/redesign.json");
343
+ expect(detail.sourcePath).toBe("features/checkout/redesign.yml");
344
+ expect(detail.historyPath).toBe("data/root/entities/feature/checkout/redesign/history");
345
+ });
346
+
347
+ it("exports repository source links and dev editor links", async () => {
348
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-catalog-"));
349
+
350
+ git(root, ["init"]);
351
+ git(root, ["checkout", "-b", "catalog-test"]);
352
+ git(root, ["remote", "add", "origin", "git@github.com:featurevisor/featurevisor.git"]);
353
+
354
+ await exportCatalog(createRuntime(), root, createProjectConfig(root), createDatasource(), {
355
+ copyAssets: false,
356
+ dev: true,
357
+ devEditors: [{ id: "cursor", label: "Cursor", icon: "cursor" }],
358
+ });
359
+
360
+ const manifest = JSON.parse(
361
+ fs.readFileSync(path.join(root, "catalog", "data", "manifest.json"), "utf8"),
362
+ );
363
+ const detail = JSON.parse(
364
+ fs.readFileSync(
365
+ path.join(root, "catalog", "data", "root", "entities", "feature", "checkout.json"),
366
+ "utf8",
367
+ ),
368
+ );
369
+
370
+ expect(manifest.links).toEqual({
371
+ provider: "github",
372
+ repository: "https://github.com/featurevisor/featurevisor",
373
+ source: "https://github.com/featurevisor/featurevisor/blob/catalog-test/{{path}}",
374
+ commit: "https://github.com/featurevisor/featurevisor/commit/{{hash}}",
375
+ });
376
+ expect(manifest.dev.editors).toEqual([{ id: "cursor", label: "Cursor", icon: "cursor" }]);
377
+ expect(detail.sourcePath).toBe("features/checkout.yml");
378
+ expect(detail.editLinks.cursor).toMatch(/^cursor:\/\/file\/.+features\/checkout\.yml$/);
379
+ });
380
+ });