@featurevisor/catalog 3.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.
@@ -0,0 +1,134 @@
1
+ import type { TestFeature, TestSegment } from "@featurevisor/types";
2
+
3
+ import { expandTestAssertions, getTestAssertionPermalink } from "../testModel";
4
+
5
+ describe("catalog test presentation", () => {
6
+ it("keeps authored assertions without matrices as one numbered assertion", () => {
7
+ const test: TestFeature = {
8
+ key: "checkout-primary",
9
+ feature: "checkout",
10
+ assertions: [
11
+ {
12
+ environment: "staging",
13
+ context: { country: "nl" },
14
+ expectedToBeEnabled: true,
15
+ },
16
+ ],
17
+ };
18
+
19
+ expect(expandTestAssertions(test)).toEqual([
20
+ expect.objectContaining({
21
+ assertionIndex: 0,
22
+ label: "1",
23
+ assertion: test.assertions[0],
24
+ }),
25
+ ]);
26
+ });
27
+
28
+ it("expands feature matrices in deterministic order with dotted case numbers", () => {
29
+ const test: TestFeature = {
30
+ key: "checkout-matrix",
31
+ feature: "checkout",
32
+ assertions: [
33
+ {
34
+ environment: "staging",
35
+ context: {},
36
+ expectedToBeEnabled: true,
37
+ },
38
+ {
39
+ matrix: {
40
+ environment: ["staging", "production"],
41
+ at: [10, 90],
42
+ country: ["nl"],
43
+ },
44
+ description: "${{ country }} in ${{ environment }} at ${{ at }}%",
45
+ environment: "${{ environment }}",
46
+ target: "${{ environment }}-web",
47
+ at: "${{ at }}" as never,
48
+ context: {
49
+ country: "${{ country }}",
50
+ label: "${{ country }}-${{ environment }}",
51
+ },
52
+ expectedToBeEnabled: true,
53
+ },
54
+ ],
55
+ };
56
+
57
+ const expanded = expandTestAssertions(test);
58
+
59
+ expect(expanded.map((item) => item.label)).toEqual(["1", "2.1", "2.2", "2.3", "2.4"]);
60
+ expect(expanded[1]).toMatchObject({
61
+ assertionIndex: 1,
62
+ caseIndex: 0,
63
+ caseCount: 4,
64
+ matrixValues: { environment: "staging", at: 10, country: "nl" },
65
+ assertion: {
66
+ description: "nl in staging at 10%",
67
+ environment: "staging",
68
+ target: "staging-web",
69
+ at: 10,
70
+ context: { country: "nl", label: "nl-staging" },
71
+ },
72
+ });
73
+ expect(expanded[4]).toMatchObject({
74
+ matrixValues: { environment: "production", at: 90, country: "nl" },
75
+ assertion: { environment: "production", at: 90 },
76
+ });
77
+ expect("matrix" in expanded[1].assertion).toBe(false);
78
+ });
79
+
80
+ it("expands segment contexts and descriptions without changing expectations", () => {
81
+ const test: TestSegment = {
82
+ key: "countries-germany",
83
+ segment: "countries.germany",
84
+ assertions: [
85
+ {
86
+ matrix: { country: ["de"], city: ["berlin", "hamburg"] },
87
+ description: "${{ city }}, ${{ country }}",
88
+ context: { country: "${{ country }}", city: "${{ city }}" },
89
+ expectedToMatch: false,
90
+ },
91
+ ],
92
+ };
93
+
94
+ expect(expandTestAssertions(test)).toEqual([
95
+ expect.objectContaining({
96
+ label: "1.1",
97
+ assertion: expect.objectContaining({
98
+ description: "berlin, de",
99
+ context: { country: "de", city: "berlin" },
100
+ expectedToMatch: false,
101
+ }),
102
+ }),
103
+ expect.objectContaining({
104
+ label: "1.2",
105
+ assertion: expect.objectContaining({
106
+ description: "hamburg, de",
107
+ context: { country: "de", city: "hamburg" },
108
+ expectedToMatch: false,
109
+ }),
110
+ }),
111
+ ]);
112
+ });
113
+
114
+ it("uses the test key and dotted case number for stable permalink identities", () => {
115
+ expect(getTestAssertionPermalink("features/checkout/redesign.spec", "2.3")).toBe(
116
+ "features/checkout/redesign.spec:2.3",
117
+ );
118
+ });
119
+
120
+ it("matches the tester by producing no cases for an empty matrix", () => {
121
+ const test: TestSegment = {
122
+ segment: "everyone",
123
+ assertions: [
124
+ {
125
+ matrix: {},
126
+ context: {},
127
+ expectedToMatch: true,
128
+ },
129
+ ],
130
+ };
131
+
132
+ expect(expandTestAssertions(test)).toEqual([]);
133
+ });
134
+ });
@@ -0,0 +1,301 @@
1
+ import * as React from "react";
2
+ import { Link, useLocation, useSearchParams } from "react-router-dom";
3
+ import type {
4
+ FeatureAssertion,
5
+ SegmentAssertion,
6
+ Test,
7
+ TestFeature,
8
+ TestSegment,
9
+ } from "@featurevisor/types";
10
+
11
+ import { Badge, EmptyState, EntityKey, LabelValueBadge, MarkdownContent } from "./ui";
12
+ import { expandTestAssertions, getTestAssertionPermalink } from "../testModel";
13
+
14
+ function getAssertionElementId(permalink: string) {
15
+ return `assertion-${encodeURIComponent(permalink)}`;
16
+ }
17
+
18
+ function hasValue(value: unknown) {
19
+ return value !== undefined && value !== null;
20
+ }
21
+
22
+ function ValueDisplay(props: { value: unknown }) {
23
+ if (props.value === undefined || props.value === null) {
24
+ return <span className="text-faint">not set</span>;
25
+ }
26
+
27
+ if (typeof props.value === "boolean") {
28
+ return (
29
+ <Badge tone={props.value ? "success" : "neutral"}>{props.value ? "true" : "false"}</Badge>
30
+ );
31
+ }
32
+
33
+ if (Array.isArray(props.value)) {
34
+ if (props.value.length === 0) {
35
+ return <span className="text-faint">empty</span>;
36
+ }
37
+
38
+ return (
39
+ <div className="flex flex-wrap gap-1.5">
40
+ {props.value.map((value, index) => (
41
+ <span key={index} className="rounded-md border border-border bg-surface px-2 py-1">
42
+ <ValueDisplay value={value} />
43
+ </span>
44
+ ))}
45
+ </div>
46
+ );
47
+ }
48
+
49
+ if (typeof props.value === "object") {
50
+ const entries = Object.entries(props.value as Record<string, unknown>);
51
+ if (entries.length === 0) {
52
+ return <span className="text-faint">empty</span>;
53
+ }
54
+
55
+ return (
56
+ <dl className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface">
57
+ {entries.map(([key, value]) => (
58
+ <div key={key} className="grid gap-1 px-3 py-2 sm:grid-cols-[minmax(7rem,0.35fr)_1fr]">
59
+ <dt className="font-mono text-xs font-semibold text-muted [overflow-wrap:anywhere]">
60
+ {key}
61
+ </dt>
62
+ <dd className="min-w-0 text-sm [overflow-wrap:anywhere]">
63
+ <ValueDisplay value={value} />
64
+ </dd>
65
+ </div>
66
+ ))}
67
+ </dl>
68
+ );
69
+ }
70
+
71
+ return <span className="font-mono text-xs [overflow-wrap:anywhere]">{String(props.value)}</span>;
72
+ }
73
+
74
+ function TestDataPanel(props: { title: string; value: unknown; className?: string }) {
75
+ if (!hasValue(props.value)) {
76
+ return null;
77
+ }
78
+
79
+ return (
80
+ <section
81
+ className={`min-w-0 rounded-xl border border-border bg-elevated p-4 ${props.className || ""}`}
82
+ >
83
+ <h4 className="mb-3 text-[10px] font-semibold uppercase tracking-wider text-faint">
84
+ {props.title}
85
+ </h4>
86
+ <ValueDisplay value={props.value} />
87
+ </section>
88
+ );
89
+ }
90
+
91
+ function AssertionPermalink(props: { permalink: string; label: string }) {
92
+ const location = useLocation();
93
+ const search = new URLSearchParams(location.search);
94
+ search.set("assertion", props.permalink);
95
+
96
+ return (
97
+ <Link
98
+ to={{ pathname: location.pathname, search: `?${search.toString()}` }}
99
+ aria-label={`Link to assertion ${props.label}`}
100
+ title="Link to this assertion"
101
+ className="inline-flex rounded p-1 text-muted opacity-100 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary md:opacity-0 md:group-hover:opacity-100"
102
+ >
103
+ <svg
104
+ aria-hidden="true"
105
+ viewBox="0 0 24 24"
106
+ className="h-4 w-4"
107
+ fill="none"
108
+ stroke="currentColor"
109
+ strokeLinecap="round"
110
+ strokeLinejoin="round"
111
+ strokeWidth="2"
112
+ >
113
+ <path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11 4" />
114
+ <path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 1 0 7.07 7.07L13 20" />
115
+ </svg>
116
+ </Link>
117
+ );
118
+ }
119
+
120
+ function FeatureAssertionContent(props: { assertion: FeatureAssertion }) {
121
+ const assertion = props.assertion;
122
+ const defaults = {
123
+ ...(hasValue(assertion.defaultVariationValue)
124
+ ? { variation: assertion.defaultVariationValue }
125
+ : {}),
126
+ ...(assertion.defaultVariableValues ? { variables: assertion.defaultVariableValues } : {}),
127
+ };
128
+ const expectations = {
129
+ ...(hasValue(assertion.expectedToBeEnabled) ? { enabled: assertion.expectedToBeEnabled } : {}),
130
+ ...(hasValue(assertion.expectedVariation) ? { variation: assertion.expectedVariation } : {}),
131
+ ...(assertion.expectedVariables ? { variables: assertion.expectedVariables } : {}),
132
+ ...(assertion.expectedEvaluations ? { evaluations: assertion.expectedEvaluations } : {}),
133
+ };
134
+
135
+ return (
136
+ <>
137
+ <div className="flex flex-wrap gap-2">
138
+ <LabelValueBadge label="Environment" value={assertion.environment || "none"} compact />
139
+ {assertion.target && <LabelValueBadge label="Target" value={assertion.target} compact />}
140
+ {hasValue(assertion.at) && (
141
+ <LabelValueBadge label="Bucket" value={`${assertion.at}%`} compact />
142
+ )}
143
+ </div>
144
+
145
+ <div className="space-y-4">
146
+ <TestDataPanel title="Context" value={assertion.context || {}} />
147
+ <TestDataPanel title="Expected" value={expectations} />
148
+ {Object.keys(defaults).length > 0 && <TestDataPanel title="Defaults" value={defaults} />}
149
+ {assertion.sticky && <TestDataPanel title="Sticky features" value={assertion.sticky} />}
150
+ </div>
151
+
152
+ {assertion.children?.length ? (
153
+ <section className="space-y-3 rounded-xl border border-border bg-surface p-4">
154
+ <h4 className="text-[10px] font-semibold uppercase tracking-wider text-faint">
155
+ Child instances
156
+ </h4>
157
+ <div className="space-y-3">
158
+ {assertion.children.map((child, index) => (
159
+ <div key={index} className="rounded-lg border border-border bg-elevated p-3">
160
+ <div className="mb-3 text-xs font-semibold text-muted">Child {index + 1}</div>
161
+ <ValueDisplay value={child} />
162
+ </div>
163
+ ))}
164
+ </div>
165
+ </section>
166
+ ) : null}
167
+ </>
168
+ );
169
+ }
170
+
171
+ function SegmentAssertionContent(props: { assertion: SegmentAssertion }) {
172
+ return (
173
+ <div className="space-y-4">
174
+ <TestDataPanel title="Context" value={props.assertion.context} />
175
+ <section className="rounded-xl border border-border bg-elevated p-4">
176
+ <h4 className="mb-3 text-[10px] font-semibold uppercase tracking-wider text-faint">
177
+ Expected
178
+ </h4>
179
+ <Badge tone={props.assertion.expectedToMatch ? "success" : "neutral"}>
180
+ {props.assertion.expectedToMatch ? "Matches segment" : "Does not match segment"}
181
+ </Badge>
182
+ </section>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function TestSpec(props: { test: Test; index: number; selectedPermalink?: string }) {
188
+ const testKey = props.test.key || `test-${props.index + 1}`;
189
+ const assertions = expandTestAssertions(props.test);
190
+ const authoredCount = props.test.assertions.length;
191
+ const hasMatrix = props.test.assertions.some((assertion) => Boolean(assertion.matrix));
192
+
193
+ return (
194
+ <section className="space-y-5">
195
+ <header className="flex flex-col justify-between gap-3 border-b border-border pb-3 sm:flex-row sm:items-end">
196
+ <div className="min-w-0">
197
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-faint">
198
+ Test spec {props.index + 1}
199
+ </div>
200
+ <h2 className="mt-1 font-mono text-sm font-semibold [overflow-wrap:anywhere]">
201
+ <EntityKey value={testKey} />
202
+ </h2>
203
+ </div>
204
+ <div className="flex flex-wrap gap-2">
205
+ {props.test.promotable === false && <Badge>not promotable</Badge>}
206
+ <Badge>{authoredCount} authored</Badge>
207
+ {hasMatrix && <Badge tone="primary">{assertions.length} applied</Badge>}
208
+ </div>
209
+ </header>
210
+
211
+ {assertions.length === 0 ? (
212
+ <EmptyState title="No assertions found in this test spec" />
213
+ ) : (
214
+ <div className="space-y-5">
215
+ {assertions.map((expanded) => {
216
+ const permalink = getTestAssertionPermalink(testKey, expanded.label);
217
+ const selected = props.selectedPermalink === permalink;
218
+ const assertion = expanded.assertion;
219
+
220
+ return (
221
+ <article
222
+ id={getAssertionElementId(permalink)}
223
+ key={permalink}
224
+ className={[
225
+ "scroll-mt-6 space-y-4 rounded-2xl border bg-surface p-5 transition-shadow",
226
+ selected ? "border-primary ring-2 ring-primary/20" : "border-border",
227
+ ].join(" ")}
228
+ >
229
+ <header className="space-y-2">
230
+ <div className="group flex min-w-0 items-start justify-between gap-3">
231
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
232
+ <h3 className="font-semibold">Assertion {expanded.label}</h3>
233
+ {hasValue(expanded.caseIndex) && (
234
+ <Badge tone="primary">
235
+ Matrix case {(expanded.caseIndex as number) + 1} of {expanded.caseCount}
236
+ </Badge>
237
+ )}
238
+ </div>
239
+ <div className="shrink-0">
240
+ <AssertionPermalink permalink={permalink} label={expanded.label} />
241
+ </div>
242
+ </div>
243
+ {assertion.description && <MarkdownContent value={assertion.description} />}
244
+ </header>
245
+
246
+ {expanded.matrixValues && (
247
+ <div className="flex flex-wrap items-center gap-2">
248
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-faint">
249
+ Matrix values
250
+ </span>
251
+ {Object.entries(expanded.matrixValues).map(([key, value]) => (
252
+ <LabelValueBadge key={key} label={key} value={String(value)} compact />
253
+ ))}
254
+ </div>
255
+ )}
256
+
257
+ {"feature" in props.test ? (
258
+ <FeatureAssertionContent assertion={assertion as FeatureAssertion} />
259
+ ) : (
260
+ <SegmentAssertionContent assertion={assertion as SegmentAssertion} />
261
+ )}
262
+ </article>
263
+ );
264
+ })}
265
+ </div>
266
+ )}
267
+ </section>
268
+ );
269
+ }
270
+
271
+ export function EntityTests(props: { tests: Test[] }) {
272
+ const [searchParams] = useSearchParams();
273
+ const selectedPermalink = searchParams.get("assertion") || undefined;
274
+
275
+ React.useEffect(() => {
276
+ if (!selectedPermalink) {
277
+ return;
278
+ }
279
+
280
+ const frame = window.requestAnimationFrame(() => {
281
+ document
282
+ .getElementById(getAssertionElementId(selectedPermalink))
283
+ ?.scrollIntoView({ block: "start" });
284
+ });
285
+
286
+ return () => window.cancelAnimationFrame(frame);
287
+ }, [selectedPermalink, props.tests]);
288
+
289
+ return (
290
+ <div className="space-y-10">
291
+ {props.tests.map((test, index) => (
292
+ <TestSpec
293
+ key={test.key || index}
294
+ test={test as TestFeature | TestSegment}
295
+ index={index}
296
+ selectedPermalink={selectedPermalink}
297
+ />
298
+ ))}
299
+ </div>
300
+ );
301
+ }
@@ -123,18 +123,14 @@ export function LabelValueBadge(props: {
123
123
  {props.value}
124
124
  </Link>
125
125
  ) : (
126
- <span className={props.compact ? undefined : "font-medium text-text"}>{props.value}</span>
126
+ <span className="font-medium text-text">{props.value}</span>
127
127
  );
128
128
 
129
129
  if (props.compact) {
130
130
  return (
131
- <span className="inline-flex h-6 shrink-0 overflow-hidden rounded-full border border-border text-[11px] leading-none text-faint transition-colors group-hover:text-muted">
132
- <span className="flex items-center bg-slate-100 pl-1.5 pr-1.5 font-medium transition-colors group-hover:bg-slate-200">
133
- {props.label}
134
- </span>
135
- <span className="flex items-center bg-surface pl-1.5 pr-1.5 transition-colors group-hover:bg-slate-100">
136
- {valueContent}
137
- </span>
131
+ <span className="inline-flex h-5 shrink-0 overflow-hidden rounded-md border border-border/70 text-[10px] leading-none">
132
+ <span className="flex items-center bg-elevated px-1.5 text-muted">{props.label}</span>
133
+ <span className="flex items-center bg-surface px-1.5 text-text">{valueContent}</span>
138
134
  </span>
139
135
  );
140
136
  }
@@ -241,15 +237,19 @@ export function EmptyState(props: { title: string; description?: string }) {
241
237
 
242
238
  export function PageHeader(props: {
243
239
  title: React.ReactNode;
240
+ titleAction?: React.ReactNode;
244
241
  description?: React.ReactNode;
245
242
  actions?: React.ReactNode;
246
243
  }) {
247
244
  return (
248
245
  <div className="mb-6 flex flex-col justify-between gap-4 border-b border-border px-6 pb-4 pt-8 md:flex-row md:items-start">
249
246
  <div className="min-w-0 flex-1">
250
- <h1 className="min-w-0 text-3xl font-black text-text [overflow-wrap:anywhere]">
251
- {props.title}
252
- </h1>
247
+ <div className="group flex min-w-0 items-center gap-2">
248
+ <h1 className="min-w-0 text-3xl font-black text-text [overflow-wrap:anywhere]">
249
+ {props.title}
250
+ </h1>
251
+ {props.titleAction ? <div className="shrink-0">{props.titleAction}</div> : null}
252
+ </div>
253
253
  {props.description && (
254
254
  <div className="mt-2 min-w-0 text-sm text-muted [overflow-wrap:anywhere]">
255
255
  {props.description}
@@ -0,0 +1,13 @@
1
+ import { decodeRouteSegment, encodeDataPath, encodeRouteSegment } from "./entityTypes";
2
+
3
+ describe("catalog entity path encoding", () => {
4
+ it("keeps slash-namespaced keys in one browser route segment", () => {
5
+ expect(encodeRouteSegment("checkout/redesign")).toBe("checkout%252Fredesign");
6
+ expect(decodeRouteSegment("checkout%2Fredesign")).toBe("checkout/redesign");
7
+ });
8
+
9
+ it("maps slash-namespaced keys to nested data paths", () => {
10
+ expect(encodeDataPath("checkout/redesign")).toBe("checkout/redesign");
11
+ expect(encodeDataPath("checkout/new design")).toBe("checkout/new%20design");
12
+ });
13
+ });
@@ -55,6 +55,13 @@ export function encodeDataSegment(value: string) {
55
55
  return encodeURIComponent(value);
56
56
  }
57
57
 
58
+ export function encodeDataPath(value: string) {
59
+ return value
60
+ .split("/")
61
+ .map((segment) => encodeURIComponent(segment))
62
+ .join("/");
63
+ }
64
+
58
65
  export function getBasePath(setKey?: string) {
59
66
  return setKey ? `/sets/${encodeRouteSegment(setKey)}` : "";
60
67
  }
@@ -85,8 +85,51 @@ function createDatasource(set = "") {
85
85
  readGroup: async () => undefined,
86
86
  listSchemas: async () => [],
87
87
  readSchema: async () => undefined,
88
- listTests: async () => ["checkout"],
89
- readTest: async () => ({ key: "checkout", feature: featureKey, assertions: [] }),
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
+ },
90
133
  };
91
134
  }
92
135
 
@@ -161,8 +204,27 @@ describe("catalog export", () => {
161
204
  segments: ["premiumUsers"],
162
205
  attributes: ["country"],
163
206
  targets: ["premiumWeb"],
164
- tests: ["checkout"],
207
+ tests: ["checkout-matrix", "checkout-primary"],
165
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
+ ]);
166
228
  });
167
229
 
168
230
  it("writes per-set catalog files for sets-enabled projects", async () => {
@@ -200,6 +262,88 @@ describe("catalog export", () => {
200
262
  ).toBe(true);
201
263
  });
202
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
+
203
347
  it("exports repository source links and dev editor links", async () => {
204
348
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-catalog-"));
205
349