Skip to content

Commit d1654fa

Browse files
authored
test(plugin): guard playground↔storybook recipe registration drift (#325)
The playground renders Storybook's Vue components via @storybook-components, so each tooling/plugin/playground/recipes/<name>.styleframe.ts must register the same recipe set as apps/storybook/stories/components/<name>.styleframe.ts. Two hand-maintained lists drift — SF-17 added callout title/description sub-recipes to the shim but not the playground, and only CI's MISSING_EXPORT caught it at Build Packages. Add a unit test that, for every playground recipe file, compares the set of registered use<Name>Recipe hooks against its Storybook shim and fails fast naming the missing recipe and both files — before the build stage.
1 parent a03a29b commit d1654fa

1 file changed

Lines changed: 140 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { existsSync, readdirSync, readFileSync } from "node:fs";
2+
import { dirname, join, relative, resolve } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { describe, expect, it } from "vitest";
5+
6+
/**
7+
* Guards the seam between two hand-maintained parallel recipe registrations:
8+
*
9+
* - Storybook shim: apps/storybook/stories/components/<name>.styleframe.ts
10+
* - Playground recipe: tooling/plugin/playground/recipes/<name>.styleframe.ts
11+
*
12+
* The playground renders Storybook's own Vue components (via the
13+
* `@storybook-components` alias). Those components import recipe functions
14+
* (e.g. `calloutTitle`) from `virtual:styleframe`, so the playground's recipe
15+
* file must register the *same set* of recipes as the Storybook shim — or the
16+
* playground build fails late, at the Build Packages stage, with a
17+
* `MISSING_EXPORT` (this is what SF-17 hit for the callout title/description
18+
* sub-recipes).
19+
*
20+
* Two hand-maintained lists drift. This test fails fast, at unit-test speed,
21+
* naming the missing recipe and both files, so a new sub-recipe added to one
22+
* side but not the other is caught before it ever reaches the build.
23+
*/
24+
25+
const testDir = dirname(fileURLToPath(import.meta.url));
26+
const repoRoot = resolve(testDir, "../../..");
27+
28+
const STORYBOOK_SHIM_DIR = join(repoRoot, "apps/storybook/stories/components");
29+
const PLAYGROUND_RECIPE_DIR = join(
30+
repoRoot,
31+
"tooling/plugin/playground/recipes",
32+
);
33+
34+
const SHIM_SUFFIX = ".styleframe.ts";
35+
36+
/**
37+
* Extract the set of recipes a `*.styleframe.ts` file *registers*, keyed by the
38+
* `use<Name>Recipe` hook that is actually invoked. The trailing `(` requirement
39+
* excludes the import statement (which lists the same identifiers without a
40+
* call) and matches only real registration calls like
41+
* `useCalloutTitleRecipe(s)`. Each registration in these files is paired 1:1
42+
* with the named export a consuming Vue component imports, so this set is
43+
* exactly the surface a `MISSING_EXPORT` would complain about.
44+
*/
45+
function extractRegisteredRecipes(source: string): Set<string> {
46+
const recipes = new Set<string>();
47+
const pattern = /\b(use[A-Z][A-Za-z0-9]*Recipe)\s*\(/g;
48+
for (const match of source.matchAll(pattern)) {
49+
recipes.add(match[1]);
50+
}
51+
return recipes;
52+
}
53+
54+
function componentName(fileName: string): string {
55+
return fileName.slice(0, -SHIM_SUFFIX.length);
56+
}
57+
58+
function playgroundRecipeFiles(): string[] {
59+
if (!existsSync(PLAYGROUND_RECIPE_DIR)) return [];
60+
return readdirSync(PLAYGROUND_RECIPE_DIR)
61+
.filter((file) => file.endsWith(SHIM_SUFFIX))
62+
.sort();
63+
}
64+
65+
function rel(path: string): string {
66+
return relative(repoRoot, path);
67+
}
68+
69+
const recipeFiles = playgroundRecipeFiles();
70+
71+
describe("playground ↔ storybook recipe registration parity", () => {
72+
it("has playground recipe files to check", () => {
73+
// A sanity guard: if the directory layout moves, the drift check below
74+
// would silently pass with zero cases. Fail loudly instead.
75+
expect(recipeFiles.length).toBeGreaterThan(0);
76+
});
77+
78+
it.each(recipeFiles)(
79+
"%s registers the same recipes in both the playground and the Storybook shim",
80+
(fileName) => {
81+
const name = componentName(fileName);
82+
const playgroundPath = join(PLAYGROUND_RECIPE_DIR, fileName);
83+
const shimPath = join(STORYBOOK_SHIM_DIR, fileName);
84+
85+
expect(
86+
existsSync(shimPath),
87+
`Playground registers recipes for "${name}" but there is no matching ` +
88+
`Storybook shim.\n` +
89+
` Playground recipe: ${rel(playgroundPath)}\n` +
90+
` Expected shim: ${rel(shimPath)}\n` +
91+
`The playground renders Storybook's "${name}" component, so a shim ` +
92+
`must exist and register the same recipes.`,
93+
).toBe(true);
94+
95+
const playgroundRecipes = extractRegisteredRecipes(
96+
readFileSync(playgroundPath, "utf8"),
97+
);
98+
const shimRecipes = extractRegisteredRecipes(
99+
readFileSync(shimPath, "utf8"),
100+
);
101+
102+
const missingFromPlayground = [...shimRecipes]
103+
.filter((recipe) => !playgroundRecipes.has(recipe))
104+
.sort();
105+
const missingFromShim = [...playgroundRecipes]
106+
.filter((recipe) => !shimRecipes.has(recipe))
107+
.sort();
108+
109+
if (missingFromPlayground.length > 0 || missingFromShim.length > 0) {
110+
const lines = [
111+
`Recipe registration drift for "${name}".`,
112+
` Storybook shim: ${rel(shimPath)}`,
113+
` Playground recipe: ${rel(playgroundPath)}`,
114+
];
115+
if (missingFromPlayground.length > 0) {
116+
lines.push(
117+
` Registered in the Storybook shim but missing from the ` +
118+
`playground recipe: ${missingFromPlayground.join(", ")}`,
119+
);
120+
}
121+
if (missingFromShim.length > 0) {
122+
lines.push(
123+
` Registered in the playground recipe but missing from the ` +
124+
`Storybook shim: ${missingFromShim.join(", ")}`,
125+
);
126+
}
127+
lines.push(
128+
`Register the missing recipe(s) in both files so they stay in ` +
129+
`sync — otherwise the playground build fails with MISSING_EXPORT ` +
130+
`at the Build Packages stage.`,
131+
);
132+
throw new Error(lines.join("\n"));
133+
}
134+
135+
// Also assert equality directly so the matcher output is meaningful
136+
// when the message above is not the failure that surfaces first.
137+
expect([...playgroundRecipes].sort()).toEqual([...shimRecipes].sort());
138+
},
139+
);
140+
});

0 commit comments

Comments
 (0)