Skip to content

Commit f4e28d1

Browse files
authored
Merge pull request #470 from Marve10s/fix/builder-cli-agreement
Offer only stacks the CLI accepts: one rule set for APIs and UI libraries
2 parents 9c96456 + 8084559 commit f4e28d1

3 files changed

Lines changed: 353 additions & 89 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { EMBEDDED_TEMPLATES, generateVirtualProject } from "@better-fullstack/template-generator";
2+
import {
3+
analyzeStackCompatibility,
4+
DEFAULT_STACK_SELECTION,
5+
getCategoryOptionIds,
6+
getDisabledReason,
7+
OPTION_CATEGORY_METADATA,
8+
type OptionCategory,
9+
STACK_SELECTION_OPTION_CATEGORY_BY_KEY,
10+
stackSelectionToProjectConfig,
11+
type StackSelectionInput,
12+
TYPESCRIPT_CATEGORY_ORDER,
13+
} from "@better-fullstack/types";
14+
import { describe, expect, it } from "bun:test";
15+
16+
import { validateConfigForProgrammaticUse } from "@/config/config-validation";
17+
import { runWithContext } from "@/presentation/context";
18+
19+
const SELF_BACKENDS: Partial<Record<string, string>> = {
20+
next: "self-next",
21+
vinext: "self-vinext",
22+
"tanstack-start": "self-tanstack-start",
23+
nuxt: "self-nuxt",
24+
svelte: "self-svelte",
25+
"solid-start": "self-solid-start",
26+
astro: "self-astro",
27+
};
28+
29+
const STACK_DIMENSIONS = new Set([
30+
"webFrontend",
31+
"backend",
32+
"runtime",
33+
"packageManager",
34+
"versionChannel",
35+
"git",
36+
"install",
37+
"workspaceShape",
38+
"examples",
39+
]);
40+
41+
const OTHER_ECOSYSTEM_OPTIONS = new Set(["auth.go-better-auth", "search.bleve"]);
42+
43+
function normalize(selection: StackSelectionInput): StackSelectionInput {
44+
return analyzeStackCompatibility(selection).adjustedStack ?? selection;
45+
}
46+
47+
function baseStacks(): { name: string; selection: StackSelectionInput }[] {
48+
const overrides: { name: string; selection: Partial<StackSelectionInput> }[] = [];
49+
for (const frontend of getCategoryOptionIds("webFrontend").filter((id) => id !== "none")) {
50+
overrides.push({
51+
name: `${frontend} + hono`,
52+
selection: { webFrontend: [frontend], backend: "hono", runtime: "bun" },
53+
});
54+
const selfBackend = SELF_BACKENDS[frontend];
55+
if (selfBackend) {
56+
overrides.push({
57+
name: `${frontend} fullstack`,
58+
selection: { webFrontend: [frontend], backend: selfBackend, runtime: "none" },
59+
});
60+
}
61+
}
62+
overrides.push(
63+
{
64+
name: "astro + react fullstack",
65+
selection: {
66+
webFrontend: ["astro"],
67+
astroIntegration: "react",
68+
backend: "self-astro",
69+
runtime: "none",
70+
},
71+
},
72+
{
73+
name: "tanstack-router without ui library",
74+
selection: { webFrontend: ["tanstack-router"], backend: "hono", uiLibrary: "none" },
75+
},
76+
{
77+
name: "expo + hono",
78+
selection: { webFrontend: ["none"], nativeFrontend: ["native-bare"], backend: "hono" },
79+
},
80+
{
81+
name: "express + node",
82+
selection: { webFrontend: ["tanstack-router"], backend: "express", runtime: "node" },
83+
},
84+
{
85+
name: "hono + workers",
86+
selection: { webFrontend: ["tanstack-router"], backend: "hono", runtime: "workers" },
87+
},
88+
{
89+
name: "hono + node + postgres",
90+
selection: {
91+
webFrontend: ["tanstack-router"],
92+
backend: "hono",
93+
runtime: "node",
94+
database: "postgres",
95+
orm: "drizzle",
96+
},
97+
},
98+
{
99+
name: "hono + mongodb",
100+
selection: {
101+
webFrontend: ["tanstack-router"],
102+
backend: "hono",
103+
database: "mongodb",
104+
orm: "mongoose",
105+
},
106+
},
107+
{
108+
name: "next fullstack + postgres + better-auth",
109+
selection: {
110+
webFrontend: ["next"],
111+
backend: "self-next",
112+
runtime: "none",
113+
database: "postgres",
114+
orm: "drizzle",
115+
auth: "better-auth",
116+
},
117+
},
118+
);
119+
return overrides.map(({ name, selection }) => ({
120+
name,
121+
selection: normalize({ ...DEFAULT_STACK_SELECTION, auth: "none", ...selection }),
122+
}));
123+
}
124+
125+
const SHARED_RULE_CATEGORIES = new Set<OptionCategory>(["api", "uiLibrary"]);
126+
const STACKS = baseStacks();
127+
const KEY_BY_CATEGORY = Object.fromEntries(
128+
Object.entries(STACK_SELECTION_OPTION_CATEGORY_BY_KEY).map(([key, category]) => [category, key]),
129+
) as Partial<Record<OptionCategory, keyof StackSelectionInput>>;
130+
const CATEGORIES = TYPESCRIPT_CATEGORY_ORDER.filter(
131+
(category) => !STACK_DIMENSIONS.has(category) && KEY_BY_CATEGORY[category],
132+
);
133+
134+
describe("builder and CLI agreement", () => {
135+
it.each(CATEGORIES)(
136+
"every %s option the builder offers passes CLI validation and generates",
137+
async (category) => {
138+
const key = KEY_BY_CATEGORY[category] as keyof StackSelectionInput;
139+
const isList = Array.isArray(DEFAULT_STACK_SELECTION[key]);
140+
const failures: string[] = [];
141+
const uncovered: string[] = [];
142+
143+
for (const option of OPTION_CATEGORY_METADATA[category].options) {
144+
if (option.id === "none" || OTHER_ECOSYSTEM_OPTIONS.has(`${category}.${option.id}`)) {
145+
continue;
146+
}
147+
let offered = false;
148+
for (const stack of STACKS) {
149+
const selection = {
150+
...stack.selection,
151+
[key]: isList ? [option.id] : option.id,
152+
} as StackSelectionInput;
153+
if (getDisabledReason(selection, category, option.id)) continue;
154+
const label = `${option.id} on ${stack.name}`;
155+
const adjusted = normalize(selection);
156+
const value = adjusted[key];
157+
const kept = Array.isArray(value) ? value.includes(option.id) : value === option.id;
158+
if (!kept) {
159+
if (SHARED_RULE_CATEGORIES.has(category)) {
160+
failures.push(`${label}: offered, but the analyzer replaces it`);
161+
}
162+
continue;
163+
}
164+
offered = true;
165+
166+
const config = stackSelectionToProjectConfig(adjusted, {
167+
projectDir: "/virtual/agreement",
168+
relativePath: "agreement",
169+
});
170+
try {
171+
runWithContext({ silent: true }, () => validateConfigForProgrammaticUse(config));
172+
} catch (error) {
173+
failures.push(`${label}: CLI rejects it (${(error as Error).message.split("\n")[0]})`);
174+
continue;
175+
}
176+
const result = await generateVirtualProject({ config, templates: EMBEDDED_TEMPLATES });
177+
if (!result.success) failures.push(`${label}: generation fails (${result.error})`);
178+
}
179+
if (!offered) uncovered.push(option.id);
180+
}
181+
182+
expect(failures).toEqual([]);
183+
expect(uncovered).toEqual([]);
184+
},
185+
);
186+
});

0 commit comments

Comments
 (0)