From 07ab98dcdd649daddeb5c20cf12ce8e5f7c47cf2 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Sat, 2 May 2026 23:31:33 +0500 Subject: [PATCH 01/14] Added drag and drop fonts --- app/api/generate/route.ts | 22 +- app/components/wizard/WizardShell.tsx | 22 +- app/components/wizard/steps/BackendStep.tsx | 2 +- app/components/wizard/steps/ThemeStep.tsx | 310 +++++++++++++++++- app/lib/config/schema.ts | 51 +++ app/lib/generator/index.ts | 52 ++- app/lib/state/useWizardStore.tsx | 64 +++- components/ui/select.tsx | 4 +- .../base/lib/src/theme/text_theme.dart.hbs | 5 + .../flutter/base/lib/src/theme/theme.dart.hbs | 11 + templates/flutter/base/pubspec.yaml.hbs | 19 ++ 11 files changed, 541 insertions(+), 21 deletions(-) diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index cf4d5cc..40f9bc7 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -8,10 +8,26 @@ export const dynamic = "force-dynamic" export async function POST(request: NextRequest) { try { - const payload = await request.json() - const config = scaffoldConfigSchema.parse(payload) + // Payload arrives as multipart/form-data so that binary font blobs can + // be transmitted alongside the JSON config without serialization. + const form = await request.formData() - const zipBuffer = await generateFlutterScaffold(config) + const configRaw = form.get("config") + if (typeof configRaw !== "string") { + return new Response( + JSON.stringify({ error: "Missing config field in form data" }), + { status: 400, headers: { "Content-Type": "application/json" } } + ) + } + + const config = scaffoldConfigSchema.parse(JSON.parse(configRaw)) + + // Collect font blobs — each File entry's name corresponds to the + // fileName stored in config.theme.customFonts[].fileName. + // We process them one at a time to limit peak memory usage. + const fontEntries = form.getAll("font") as File[] + + const zipBuffer = await generateFlutterScaffold(config, fontEntries) const fileName = `${config.appName.replace(/\s+/g, "-").toLowerCase()}.zip` return new Response(zipBuffer as any, { diff --git a/app/components/wizard/WizardShell.tsx b/app/components/wizard/WizardShell.tsx index beca84d..11247e8 100644 --- a/app/components/wizard/WizardShell.tsx +++ b/app/components/wizard/WizardShell.tsx @@ -78,7 +78,7 @@ const steps: Record< } export function WizardShell() { - const { step, setStep, stepIndex, isHydrated, config } = useWizard() + const { step, setStep, stepIndex, isHydrated, config, fontFiles } = useWizard() const [isGenerating, setIsGenerating] = React.useState(false) const [error, setError] = React.useState(null) @@ -96,14 +96,28 @@ export function WizardShell() { try { void trackGeneration(config) + // Use multipart/form-data so binary font blobs can be sent alongside + // the JSON config without serialization issues. + const form = new FormData() + form.append("config", JSON.stringify(config)) + + // Attach each font file the user dropped (keyed by its fileName) + for (const [fileName, file] of fontFiles) { + // Use the original File object; fileName is used as the field name + // so the server can correlate it with config.theme.customFonts[].fileName + form.append("font", file, fileName) + } + + // Do NOT set Content-Type — browser sets it automatically with the + // correct multipart boundary. const response = await fetch("/api/generate", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(config), + body: form, }) if (!response.ok) { - throw new Error("Failed to generate project") + const body = await response.json().catch(() => ({})) + throw new Error((body as any)?.error ?? "Failed to generate project") } const blob = await response.blob() diff --git a/app/components/wizard/steps/BackendStep.tsx b/app/components/wizard/steps/BackendStep.tsx index 75056bb..5b5e530 100644 --- a/app/components/wizard/steps/BackendStep.tsx +++ b/app/components/wizard/steps/BackendStep.tsx @@ -64,7 +64,7 @@ export function BackendStep() {
- {option.label} + {option.label} {backend.provider !== option.value && ( {option.description} )} diff --git a/app/components/wizard/steps/ThemeStep.tsx b/app/components/wizard/steps/ThemeStep.tsx index 1206213..797b4ff 100644 --- a/app/components/wizard/steps/ThemeStep.tsx +++ b/app/components/wizard/steps/ThemeStep.tsx @@ -1,28 +1,172 @@ "use client" -import { ThemePreset, themePresetOptions } from "@/app/lib/config/schema" +import { + CustomFontEntry, + FontStyle, + FontWeight, + FONT_MAX_SIZE_BYTES, + SUPPORTED_FONT_EXTENSIONS, + ThemePreset, + deriveFontFamily, + fontStyleSchema, + fontWeightSchema, + themePresetOptions, +} from "@/app/lib/config/schema" import { useWizard } from "@/app/lib/state/useWizardStore" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" -import { InformationCircleIcon } from "@hugeicons/core-free-icons" +import { + AlertCircleIcon, + Cancel01Icon, + CloudUploadIcon, + File01Icon, + InformationCircleIcon, +} from "@hugeicons/core-free-icons" import { HugeiconsIcon } from "@hugeicons/react" +import * as React from "react" + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const ACCEPTED_EXTS = SUPPORTED_FONT_EXTENSIONS.join(",") +const FONT_WEIGHTS: { value: FontWeight; label: string }[] = [ + { value: "100", label: "100 — Thin" }, + { value: "200", label: "200 — ExtraLight" }, + { value: "300", label: "300 — Light" }, + { value: "400", label: "400 — Regular" }, + { value: "500", label: "500 — Medium" }, + { value: "600", label: "600 — SemiBold" }, + { value: "700", label: "700 — Bold" }, + { value: "800", label: "800 — ExtraBold" }, + { value: "900", label: "900 — Black" }, +] + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getExt(name: string) { + const i = name.lastIndexOf(".") + return i === -1 ? "" : name.slice(i).toLowerCase() +} + +function isSupported(name: string) { + return (SUPPORTED_FONT_EXTENSIONS as readonly string[]).includes(getExt(name)) +} + +function formatBytes(bytes: number) { + return bytes < 1024 * 1024 + ? `${(bytes / 1024).toFixed(1)} KB` + : `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +// ─── ThemeStep ──────────────────────────────────────────────────────────────── export function ThemeStep() { - const { config, updateConfig, next, prev, setSelectedItem } = useWizard() + const { config, updateConfig, setSelectedItem, addFontFile, removeFontFile, fontFiles } = useWizard() const { theme } = config + const customFonts = theme.customFonts ?? [] + + const [dragOver, setDragOver] = React.useState(false) + const [errors, setErrors] = React.useState([]) + const fileInputRef = React.useRef(null) + + // ── File processing ────────────────────────────────────────────────────── + + function processFiles(files: FileList | File[]) { + const arr = Array.from(files) + const newErrors: string[] = [] + + for (const file of arr) { + if (!isSupported(file.name)) { + const ext = getExt(file.name) || "(no extension)" + newErrors.push( + `"${file.name}" — unsupported format ${ext}. Flutter supports .ttf, .otf, .ttc only (not .woff/.woff2 on desktop).` + ) + continue + } + if (file.size > FONT_MAX_SIZE_BYTES) { + newErrors.push( + `"${file.name}" — file too large (${formatBytes(file.size)}). Maximum is 10 MB.` + ) + continue + } + + const meta: CustomFontEntry = { + family: deriveFontFamily(file.name), + fileName: file.name, + style: "normal", + weight: "400", + } + addFontFile(file, meta) + } + + if (newErrors.length > 0) setErrors((prev) => [...prev, ...newErrors]) + } + + // ── Drag handlers ───────────────────────────────────────────────────────── + + function onDragOver(e: React.DragEvent) { + e.preventDefault() + setDragOver(true) + } + + function onDragLeave(e: React.DragEvent) { + e.preventDefault() + setDragOver(false) + } + + function onDrop(e: React.DragEvent) { + e.preventDefault() + setDragOver(false) + if (e.dataTransfer.files.length > 0) { + processFiles(e.dataTransfer.files) + } + } + + function onFileInput(e: React.ChangeEvent) { + if (e.target.files && e.target.files.length > 0) { + processFiles(e.target.files) + e.target.value = "" // reset so same file can be re-added after remove + } + } + + // ── Per-font update ─────────────────────────────────────────────────────── + + function updateFontMeta(fileName: string, patch: Partial) { + const file = fontFiles.get(fileName) + const existing = customFonts.find((f) => f.fileName === fileName) + if (!existing) return + const updated = { ...existing, ...patch } + if (file) { + addFontFile(file, updated) // re-registers with updated metadata + } else { + // file blob not in memory (page was refreshed) — update metadata only + updateConfig((prev) => ({ + ...prev, + theme: { + ...prev.theme, + customFonts: (prev.theme.customFonts ?? []).map((f) => + f.fileName === fileName ? updated : f + ), + }, + })) + } + } return ( UI & Theme - Choose your design system, primary color, and dark mode. + Choose your design system, primary color, dark mode, and custom fonts. + + {/* ── Theme + Color ── */}
@@ -40,7 +184,7 @@ export function ThemeStep() {
- {option.label} + {option.label} {theme.preset !== option.value && ( {option.description} )} @@ -101,6 +245,7 @@ export function ThemeStep() {
+ {/* ── Dark Mode ── */}
@@ -128,7 +273,162 @@ export function ThemeStep() { />
+ + {/* ── Custom Fonts ── */} +
+
+

Custom fonts

+

+ Drop font files to bundle them. +

+

+ Supported formats: .ttf · .otf · .ttc +

+
+ + FlutterInit does not persist your fonts. You will need to select them again if you refresh the page. +
+
+ + {/* Error Banner */} + {errors.length > 0 && ( +
+
+
+ + {errors.length === 1 ? "1 file rejected" : `${errors.length} files rejected`} +
+ +
+
    + {errors.map((e, i) =>
  • {e}
  • )} +
+
+ )} + + {/* Drop Zone */} +
fileInputRef.current?.click()} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click() }} + className={[ + "relative flex flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-8 cursor-pointer", + "transition-all duration-200 select-none", + dragOver + ? "border-primary bg-primary/10 scale-[1.01]" + : "border-border/50 hover:border-primary/40 hover:bg-muted/30 bg-card/20", + ].join(" ")} + > + +
+ +
+
+

+ {dragOver ? "Drop to add fonts" : "Drag & drop font files here"} +

+

+ or click to browse + {" "}· .ttf · .otf · .ttc · max 10 MB each +

+
+
+ + {/* Font Cards */} + {customFonts.length > 0 && ( +
+ {customFonts.map((font) => ( + removeFontFile(font.fileName)} + /> + ))} +
+ )} +
+ ) } + +// ─── FontCard ───────────────────────────────────────────────────────────────── + +interface FontCardProps { + font: CustomFontEntry + onRemove: () => void +} + +function FontCard({ font, onRemove }: FontCardProps) { + const ext = getExt(font.fileName).replace(".", "").toUpperCase() + + return ( +
+ {/* Remove button */} + + +
+
+ +
+
+
+

{font.fileName}

+ + {ext} + +
+
+
+ Family + {font.family} +
+
+ Weight + {font.weight} +
+ {font.style === "italic" && ( +
+ Italic +
+ )} + +
+
+
+
+ ) +} + diff --git a/app/lib/config/schema.ts b/app/lib/config/schema.ts index b339b34..c9e1616 100644 --- a/app/lib/config/schema.ts +++ b/app/lib/config/schema.ts @@ -3,6 +3,54 @@ import { z } from "zod" export const themePresetSchema = z.enum(["material3", "cupertino", "custom"]) export type ThemePreset = z.infer +// Flutter-supported font formats (no .woff/.woff2 on desktop) +export const SUPPORTED_FONT_EXTENSIONS = [".ttf", ".otf", ".ttc"] as const +export const FONT_MAX_SIZE_BYTES = 10 * 1024 * 1024 // 10 MB + +export const fontStyleSchema = z.enum(["normal", "italic"]) +export type FontStyle = z.infer + +export const fontWeightSchema = z.enum(["100", "200", "300", "400", "500", "600", "700", "800", "900"]) +export type FontWeight = z.infer + +/** + * Derives a font family name from a file name. + * e.g. "Inter-Bold.ttf" → "Inter" + * "inter_bold.ttf" → "Inter" + * "Roboto-Italic.otf" → "Roboto" + */ +const WEIGHT_STYLE_KEYWORDS = [ + "thin", "extralight", "light", "regular", "medium", + "semibold", "bold", "extrabold", "black", + "italic", "oblique", + "100", "200", "300", "400", "500", "600", "700", "800", "900", +] +export function deriveFontFamily(fileName: string): string { + // Strip extension + const base = fileName.replace(/\.[^.]+$/, "") + // Split on - or _ + const parts = base.split(/[-_]/) + // Drop trailing weight/style keyword segments (case-insensitive) + const familyParts: string[] = [] + for (const part of parts) { + if (WEIGHT_STYLE_KEYWORDS.includes(part.toLowerCase())) break + familyParts.push(part) + } + const family = (familyParts.length > 0 ? familyParts : parts).join(" ") + // Capitalize first letter of each word + return family.replace(/\b\w/g, (c) => c.toUpperCase()) +} + +export const customFontEntrySchema = z.object({ + /** Logical font family name, e.g. "Inter" */ + family: z.string().min(1), + /** Original file name, e.g. "Inter-Bold.ttf" */ + fileName: z.string().min(1), + style: fontStyleSchema.default("normal"), + weight: fontWeightSchema.default("400"), +}) +export type CustomFontEntry = z.infer + export const themePresetOptions = [ { value: "material3", label: "Material 3", description: "Google's modern design system." }, { value: "cupertino", label: "Cupertino", description: "Native iOS-style widgets." }, @@ -88,6 +136,8 @@ const themeSchema = z.object({ preset: themePresetSchema, primaryColor: z.string().optional(), darkMode: darkModeSchema, + /** Font files the user uploaded; metadata only (File blobs stored separately) */ + customFonts: z.array(customFontEntrySchema).default([]), }) export type ThemeConfig = z.infer @@ -242,6 +292,7 @@ export const defaultConfig: ScaffoldConfig = { preset: "material3", primaryColor: "#6750A4", darkMode: { enabled: true, system: true }, + customFonts: [], }, icons: { default: true, diff --git a/app/lib/generator/index.ts b/app/lib/generator/index.ts index 5386099..972d716 100644 --- a/app/lib/generator/index.ts +++ b/app/lib/generator/index.ts @@ -4,7 +4,7 @@ import path from "node:path" import JSZip from "jszip" -import { ScaffoldConfig, scaffoldConfigSchema } from "../config/schema" +import { CustomFontEntry, ScaffoldConfig, scaffoldConfigSchema } from "../config/schema" import { createHandlebarsEnvironment } from "./handlebars" @@ -62,10 +62,26 @@ type TemplateContext = ScaffoldConfig & { usesSupabaseDb: boolean usesAppwriteAuth: boolean usesAppwriteDb: boolean + /** True when at least one custom font was uploaded */ + hasCustomFonts: boolean + /** + * The first font family name (used as the app-wide fontFamily in ThemeData). + * Empty string when no custom fonts are uploaded. + */ + primaryFontFamily: string + /** + * Fonts grouped by family name for pubspec.yaml. + * Each entry = one `family:` block with one or more `fonts:` items. + * e.g. [{ family: "Inter", fonts: [{ fileName, style, weight }, ...] }] + */ + fontFamilies: Array<{ + family: string + fonts: Array> + }> } } -export async function generateFlutterScaffold(input: unknown) { +export async function generateFlutterScaffold(input: unknown, fontEntries: File[] = []) { const config = scaffoldConfigSchema.parse(input) const context = buildTemplateContext(config) @@ -86,8 +102,6 @@ export async function generateFlutterScaffold(input: unknown) { const translationsDir = path.join(workingDir, "assets", "translations") await fs.mkdir(translationsDir, { recursive: true }) - // We read the en.json.hbs template and use it to seed all configured locales - // In a real scenario, these would ideally hit an API to pre-translate or come out empty. const baseTransPath = path.join(templatesRoot, "overlays", "extras", "localization", "assets", "translations", "en.json.hbs") let templateContent = "{\n}" try { @@ -104,6 +118,20 @@ export async function generateFlutterScaffold(input: unknown) { } } + // Write font binary files into assets/fonts/ one at a time to keep + // peak memory bounded (sequential rather than Promise.all). + if (fontEntries.length > 0) { + const fontsDir = path.join(workingDir, "assets", "fonts") + await fs.mkdir(fontsDir, { recursive: true }) + + for (const fontFile of fontEntries) { + const safeName = path.basename(fontFile.name) // strip any path component + const destPath = path.join(fontsDir, safeName) + const buffer = Buffer.from(await fontFile.arrayBuffer()) + await fs.writeFile(destPath, buffer) + } + } + const zipBuffer = await zipDirectory(workingDir) return zipBuffer } finally { @@ -127,6 +155,19 @@ function buildTemplateContext(config: ScaffoldConfig): TemplateContext { routerPackage = undefined } + // Group fonts by family name for pubspec.yaml emission. + // Multiple files with the same family (e.g. Inter-Regular + Inter-Bold) + // collapse into one `family:` block with multiple `fonts:` entries. + const customFonts = config.theme.customFonts ?? [] + const familyMap = new Map>>() + for (const font of customFonts) { + const existing = familyMap.get(font.family) ?? [] + familyMap.set(font.family, [...existing, { fileName: font.fileName, style: font.style, weight: font.weight }]) + } + const fontFamilies = Array.from(familyMap.entries()).map(([family, fonts]) => ({ family, fonts })) + // First unique family becomes the app-wide fontFamily in ThemeData + const primaryFontFamily = fontFamilies.length > 0 ? fontFamilies[0].family : "" + return { ...config, flags: { @@ -182,6 +223,9 @@ function buildTemplateContext(config: ScaffoldConfig): TemplateContext { usesSupabaseDb: config.backend.provider === "supabase" ? config.backend.options.database : false, usesAppwriteAuth: config.backend.provider === "appwrite" ? config.backend.options.auth : false, usesAppwriteDb: config.backend.provider === "appwrite" ? config.backend.options.database : false, + hasCustomFonts: fontFamilies.length > 0, + primaryFontFamily, + fontFamilies, }, } } diff --git a/app/lib/state/useWizardStore.tsx b/app/lib/state/useWizardStore.tsx index 291b4df..3d14e6d 100644 --- a/app/lib/state/useWizardStore.tsx +++ b/app/lib/state/useWizardStore.tsx @@ -3,6 +3,7 @@ import * as React from "react" import { + CustomFontEntry, ScaffoldConfig, StepId, defaultConfig, @@ -28,6 +29,11 @@ type WizardContextValue = { reset: () => void selectedItem: string | null setSelectedItem: (item: string | null) => void + /** Binary font files (not persisted — reset on page reload) */ + fontFiles: Map + addFontFile: (file: File, meta: CustomFontEntry) => void + removeFontFile: (fileName: string) => void + clearFontFiles: () => void } const WizardContext = React.createContext(null) @@ -47,6 +53,8 @@ export function WizardProvider({ children }: { children: React.ReactNode }) { const [step, setStepInternal] = React.useState(stepOrder[0]) const [isHydrated, setIsHydrated] = React.useState(false) const [selectedItem, setSelectedItem] = React.useState(null) + // Non-persisted: stores binary File objects keyed by fileName + const [fontFiles, setFontFiles] = React.useState>(new Map()) React.useEffect(() => { if (typeof window === "undefined") return @@ -67,7 +75,15 @@ export function WizardProvider({ children }: { children: React.ReactNode }) { React.useEffect(() => { if (!isHydrated) return try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config)) + // Do not persist custom fonts. They must be re-uploaded on refresh. + const configToSave = { + ...config, + theme: { + ...config.theme, + customFonts: [], + }, + } + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(configToSave)) // Sync with dev script if in development if (process.env.NODE_ENV === "development") { @@ -125,6 +141,7 @@ export function WizardProvider({ children }: { children: React.ReactNode }) { const reset = React.useCallback(() => { setConfig(defaultConfig) setStepInternal(stepOrder[0]) + setFontFiles(new Map()) // clear non-persisted font blobs too try { window.localStorage.removeItem(STORAGE_KEY) } catch { @@ -132,6 +149,45 @@ export function WizardProvider({ children }: { children: React.ReactNode }) { } }, []) + const addFontFile = React.useCallback((file: File, meta: CustomFontEntry) => { + // Store the binary blob + setFontFiles((prev) => new Map(prev).set(meta.fileName, file)) + // Store metadata in config + setConfig((prev) => { + const existing = prev.theme.customFonts ?? [] + const filtered = existing.filter((f) => f.fileName !== meta.fileName) + return { + ...prev, + theme: { ...prev.theme, customFonts: [...filtered, meta] }, + } + }) + }, []) + + const removeFontFile = React.useCallback((fileName: string) => { + setFontFiles((prev) => { + const next = new Map(prev) + next.delete(fileName) + return next + }) + setConfig((prev) => ({ + ...prev, + theme: { + ...prev.theme, + customFonts: (prev.theme.customFonts ?? []).filter( + (f) => f.fileName !== fileName + ), + }, + })) + }, []) + + const clearFontFiles = React.useCallback(() => { + setFontFiles(new Map()) + setConfig((prev) => ({ + ...prev, + theme: { ...prev.theme, customFonts: [] }, + })) + }, []) + const stepIndex = React.useMemo( () => Math.max(0, stepOrder.indexOf(step)), [step] @@ -150,8 +206,12 @@ export function WizardProvider({ children }: { children: React.ReactNode }) { prev, reset, setSelectedItem, + fontFiles, + addFontFile, + removeFontFile, + clearFontFiles, }), - [config, isHydrated, next, prev, setStep, step, stepIndex, updateConfig, selectedItem, setSelectedItem] + [config, isHydrated, next, prev, setStep, step, stepIndex, updateConfig, selectedItem, setSelectedItem, fontFiles, addFontFile, removeFontFile, clearFontFiles] ) return ( diff --git a/components/ui/select.tsx b/components/ui/select.tsx index 3f68cb5..56b2cee 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -45,7 +45,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-md border bg-transparent py-2 pr-2 pl-2.5 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + "border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-md border bg-transparent py-2 pr-2 pl-2.5 text-sm font-normal shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0", className )} {...props} @@ -112,7 +112,7 @@ function SelectItem({ Date: Sun, 3 May 2026 01:30:47 +0500 Subject: [PATCH 02/14] feat: implement custom test reporter and establish comprehensive unit/E2E test infrastructure --- .github/workflows/test-tier1.yml | 30 + .github/workflows/test-tier2.yml | 57 + .github/workflows/test-tier3.yml | 137 ++ CONTRIBUTING.md | 1 + README.md | 1 + docs/testing.md | 116 ++ package-lock.json | 1483 +++++++---------- package.json | 12 +- tests/e2e/dart-validation.spec.ts | 96 ++ tests/e2e/run-matrix.ts | 159 ++ tests/e2e/validate-combo.ts | 113 ++ tests/generator.spec.ts | 61 - tests/integration/full-pipeline.spec.ts | 58 + tests/integration/overlay-composition.spec.ts | 184 ++ tests/unit/backend.spec.ts | 133 ++ tests/unit/dependencies.spec.ts | 272 +++ tests/unit/handlebars-helpers.spec.ts | 233 +++ tests/unit/misc-flags.spec.ts | 246 +++ tests/unit/navigation.spec.ts | 91 + tests/unit/state-management.spec.ts | 120 ++ tests/unit/structural.spec.ts | 59 + tests/unit/token-cleanliness.spec.ts | 37 + tests/utils/assertions.ts | 357 ++++ tests/utils/critical-combos.ts | 153 ++ tests/utils/custom-reporter.ts | 482 ++++++ tests/utils/generate.ts | 102 ++ tests/utils/matrix.config.ts | 285 ++++ vitest.config.ts | 8 + vitest.e2e.config.ts | 22 + 29 files changed, 4209 insertions(+), 899 deletions(-) create mode 100644 .github/workflows/test-tier1.yml create mode 100644 .github/workflows/test-tier2.yml create mode 100644 .github/workflows/test-tier3.yml create mode 100644 docs/testing.md create mode 100644 tests/e2e/dart-validation.spec.ts create mode 100644 tests/e2e/run-matrix.ts create mode 100644 tests/e2e/validate-combo.ts delete mode 100644 tests/generator.spec.ts create mode 100644 tests/integration/full-pipeline.spec.ts create mode 100644 tests/integration/overlay-composition.spec.ts create mode 100644 tests/unit/backend.spec.ts create mode 100644 tests/unit/dependencies.spec.ts create mode 100644 tests/unit/handlebars-helpers.spec.ts create mode 100644 tests/unit/misc-flags.spec.ts create mode 100644 tests/unit/navigation.spec.ts create mode 100644 tests/unit/state-management.spec.ts create mode 100644 tests/unit/structural.spec.ts create mode 100644 tests/unit/token-cleanliness.spec.ts create mode 100644 tests/utils/assertions.ts create mode 100644 tests/utils/critical-combos.ts create mode 100644 tests/utils/custom-reporter.ts create mode 100644 tests/utils/generate.ts create mode 100644 tests/utils/matrix.config.ts create mode 100644 vitest.e2e.config.ts diff --git a/.github/workflows/test-tier1.yml b/.github/workflows/test-tier1.yml new file mode 100644 index 0000000..e887aef --- /dev/null +++ b/.github/workflows/test-tier1.yml @@ -0,0 +1,30 @@ +name: "Tests — Tier 1 (Layer 1 Unit Tests)" + +# Runs on every push to any branch. +# Layer 1 only: fast, in-memory template tests. +# Target duration: < 3 minutes. + +on: + push: + branches: ["**"] + +jobs: + layer1: + name: Layer 1 — Template Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run Layer 1 tests + run: bun run test:unit diff --git a/.github/workflows/test-tier2.yml b/.github/workflows/test-tier2.yml new file mode 100644 index 0000000..6e97551 --- /dev/null +++ b/.github/workflows/test-tier2.yml @@ -0,0 +1,57 @@ +name: "Tests — Tier 2 (Critical Combos + Dart Validation)" + +# Runs on pull requests to main. +# Layer 1 + Layer 2 Dart validation for ~30 critical combinations. +# Target duration: < 15 minutes. + +on: + pull_request: + branches: [main] + +jobs: + layer1: + name: Layer 1 — Template Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run Layer 1 tests + run: bun run test:unit + + layer2: + name: Layer 2 — Dart Validation (Critical Combos) + needs: layer1 + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run Layer 2 Dart validation (critical combos) + run: bun run test:e2e diff --git a/.github/workflows/test-tier3.yml b/.github/workflows/test-tier3.yml new file mode 100644 index 0000000..db54e45 --- /dev/null +++ b/.github/workflows/test-tier3.yml @@ -0,0 +1,137 @@ +name: "Tests — Tier 3 (Full Matrix + Dart Validation)" + +# Pre-release gate. +# Runs ALL valid combinations through both Layer 1 and Layer 2. +# Automatic on push to release branch + manual workflow_dispatch. + +on: + push: + branches: [release] + workflow_dispatch: + inputs: + concurrency: + description: "Number of combos per parallel job" + required: false + default: "75" + +jobs: + # ── Step 1: Layer 1 must pass first ──────────────────────── + + layer1: + name: Layer 1 — Template Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run Layer 1 tests + run: bun run test:unit + + # ── Step 2: Count valid combinations ─────────────────────── + + prepare: + name: Prepare Matrix + needs: layer1 + runs-on: ubuntu-latest + outputs: + total: ${{ steps.count.outputs.total }} + matrix: ${{ steps.count.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Calculate matrix chunks + id: count + run: | + TOTAL=$(bun -e "import { ALL_COMBINATIONS } from './tests/utils/matrix.config'; console.log(ALL_COMBINATIONS.length)") + CHUNK_SIZE=${{ inputs.concurrency || '75' }} + CHUNKS=$(( (TOTAL + CHUNK_SIZE - 1) / CHUNK_SIZE )) + + # Build JSON array [0, 1, 2, ...] + MATRIX="[" + for ((i=0; i> $GITHUB_OUTPUT + echo "matrix=$MATRIX" >> $GITHUB_OUTPUT + echo "Total combos: $TOTAL, Chunks: $CHUNKS (size $CHUNK_SIZE)" + + # ── Step 3: Run Layer 2 in parallel chunks ───────────────── + + layer2: + name: "Layer 2 — Chunk ${{ matrix.chunk }}" + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + chunk: ${{ fromJSON(needs.prepare.outputs.matrix) }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run chunk + run: | + CHUNK_SIZE=${{ inputs.concurrency || '75' }} + START=$(( ${{ matrix.chunk }} * CHUNK_SIZE )) + END=$(( START + CHUNK_SIZE - 1 )) + TOTAL=${{ needs.prepare.outputs.total }} + + # Clamp end to total + if [ $END -ge $TOTAL ]; then END=$((TOTAL - 1)); fi + + echo "Running combos $START to $END (of $TOTAL)" + bun tests/e2e/run-matrix.ts --range "$START-$END" + + # ── Step 4: Gate check ───────────────────────────────────── + + gate: + name: "🚀 Release Gate" + needs: [layer1, layer2] + runs-on: ubuntu-latest + if: always() + steps: + - name: Check all jobs passed + run: | + if [ "${{ needs.layer1.result }}" != "success" ] || [ "${{ needs.layer2.result }}" != "success" ]; then + echo "❌ Release gate FAILED. Not all jobs passed." + exit 1 + fi + echo "✅ Release gate PASSED. All combinations validated." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79713c1..9f22927 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,7 @@ See the detailed [Template Development Guide](docs/template-development.md) and Before submitting your PR, ensure you can check off the following: - [ ] **Valid Dart**: Run the `template-dev.ts` script and verify that `dart analyze` shows zero errors in `dev_out/`. +- [ ] **Automated Tests**: Run `npm run test:gate` and ensure all Layer 1 and critical Layer 2 tests pass. - [ ] **Flag Paths**: Check both `true` and `false` paths for any new Handlebars conditionals. - [ ] **Barrel Exports**: New services/widgets are correctly exported in `services.dart.hbs` or `widgets.dart.hbs`. - [ ] **Linting**: Ensure any core change to the generator passes `bun run lint`. diff --git a/README.md b/README.md index 76e1006..acadb71 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ Explore our technical guides to understand the architecture and flags: * **[Generated Output Reference](docs/generated-output.md)**: Understanding the "src-first" structure. * **[Architecture Overview](docs/architecture.md)**: Under the hood of the Next.js/Handlebars engine. * **[Handlebars Language Guide](docs/handlebars.md)**: Logic patterns for template contributors. +* **[Testing Guide](docs/testing.md)**: How our 2-layer automated testing suite works. * **[Contribution Guide](CONTRIBUTING.md)**: How to add your own patterns. --- diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..2bd8065 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,116 @@ +# FlutterInit Testing Guide + +This guide explains the testing infrastructure for **FlutterInit**, covering the two-layer validation strategy, the automated matrix runner, and CI/CD integration. + +--- + +## 1. Testing Philosophy + +We use a two-layer approach to ensure that the template generator produces reliable, production-grade Flutter code. + +### Layer 1: Template Integrity (Unit & Integration) +- **Goal**: Fast, in-memory validation of the generator engine and Handlebars templates. +- **Scope**: All 1,350+ valid combinations of architecture, state management, and backend. +- **Speed**: Very fast (seconds to minutes). +- **Environment**: Node.js/Bun (no Flutter SDK required). + +### Layer 2: Output Validation (E2E) +- **Goal**: Guarantee that the generated code actually compiles and follows Dart best practices. +- **Scope**: Critical combinations and full matrix validation. +- **Speed**: Slow (minutes to hours). +- **Environment**: Requires Flutter/Dart SDK. + +--- + +## 2. Directory Structure + +```text +tests/ +├── unit/ # Layer 1: Specific feature tests +│ ├── backend.spec.ts +│ ├── dependencies.spec.ts +│ ├── token-cleanliness.spec.ts # Scans for unresolved {{tokens}} +│ └── ... +├── integration/ # Layer 1: Pipeline tests +│ └── full-pipeline.spec.ts # Generates full project in-memory +├── e2e/ # Layer 2: Dart SDK validation +│ ├── validate-combo.ts # CLI tool for single combo +│ ├── run-matrix.ts # Matrix orchestrator +│ └── dart-validation.spec.ts # Vitest wrapper for E2E +└── utils/ # Shared logic + ├── matrix.config.ts # Source of truth for options + └── assertions.ts # Custom Flutter/Dart assertions +``` + +--- + +## 3. The Matrix Configuration + +The file `tests/utils/matrix.config.ts` defines the available options and filters out invalid combinations. + +- **`ALL_COMBINATIONS`**: Every valid permutation of the configuration space (~1,350). +- **`CRITICAL_COMBINATIONS`**: A curated subset (~30) that covers the most diverse and high-risk edge cases. + +Whenever you add a new feature or flag to `schema.ts`, you **must** update the constants in `matrix.config.ts`. + +--- + +## 4. Running Tests Locally + +### Fast Checks (Layer 1) +```bash +# Run all unit and integration tests +npm run test:unit + +# Run a specific test file +npx vitest tests/unit/backend.spec.ts +``` + +### Full Validation (Layer 1 + Layer 2) +Requires Flutter/Dart SDK installed and available in your PATH. + +```bash +# Run the "Critical Gate" (Unit + 30 Critical E2E Combos) +npm run test:gate + +# Run E2E for critical combos only +npm run test:e2e + +# Run E2E for the ENTIRE matrix (Caution: Takes hours) +npm run test:matrix +``` + +--- + +## 5. CI/CD Pipeline + +The project uses GitHub Actions with a tiered strategy: + +1. **Tier 1 (Every Push)**: Runs all unit and integration tests (Layer 1). Target: < 3 minutes. +2. **Tier 2 (PR to Main)**: Runs Layer 1 + Layer 2 for the **30 Critical Combinations**. Target: < 15 minutes. +3. **Tier 3 (Release Gate)**: Runs the full Layer 1 + Layer 2 validation for all 1,350+ combinations in parallel. + +--- + +## 6. Debugging Failures + +### Layer 1 Failures +Usually mean a Handlebars helper failed, a file is missing from an overlay, or a token like `{{name}}` was found in the output. The test output will typically show the exact file and line number of the unresolved token. + +### Layer 2 Failures +Usually mean the generated Dart code has a syntax error or a version mismatch in `pubspec.yaml`. +To debug a specific failing combo: +```bash +# Use the CLI validator for the failing combo index (e.g. #42) +bun tests/e2e/validate-combo.ts 42 +``` +This will generate the project to a temporary directory and show the raw output from `dart analyze`. + +--- + +## 7. Best Practices for Template Changes + +1. **Check Tokens**: If you add a new variable, ensure it's handled in `index.ts` and that it appears in `token-cleanliness.spec.ts`. +2. **Add Assertions**: If a package is mandatory for a flag, add a dependency assertion in `tests/unit/dependencies.spec.ts`. +3. **Verify Structure**: If you change the folder structure (e.g., adding a `services` folder), update `tests/unit/structural.spec.ts`. +4. **Run the Gate**: Always run `npm run test:gate` before pushing a PR. diff --git a/package-lock.json b/package-lock.json index 8f12cdb..9b8c785 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,11 +41,13 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/ui": "^4.1.5", "eslint": "^9", "eslint-config-next": "16.1.4", "tailwindcss": "^4", "typescript": "^5", - "vitest": "^4.0.17" + "vitest": "^4.1.5", + "yaml": "^2.8.4" } }, "node_modules/@alloc/quick-lru": { @@ -720,472 +722,36 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -2347,6 +1913,23 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3845,24 +3428,10 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.3.tgz", - "integrity": "sha512-qyX8+93kK/7R5BEXPC2PjUt0+fS/VO2BVHjEHyIEWiYn88rcRBHmdLgoJjktBltgAf+NY7RfCGB1SoyKS/p9kg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.3.tgz", - "integrity": "sha512-6sHrL42bjt5dHQzJ12Q4vMKfN+kUnZ0atHHnv4V0Wd9JMTk7FDzSY35+7qbz3ypQYMBPANbpGK7JpnWNnhGt8g==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "cpu": [ "arm64" ], @@ -3871,12 +3440,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.3.tgz", - "integrity": "sha512-1ht2SpGIjEl2igJ9AbNpPIKzb1B5goXOcmtD0RFxnwNuMxqkR6AUaaErZz+4o+FKmzxcSNBOLrzsICZVNYa1Rw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "cpu": [ "arm64" ], @@ -3885,12 +3457,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.3.tgz", - "integrity": "sha512-FYZ4iVunXxtT+CZqQoPVwPhH7549e/Gy7PIRRtq4t5f/vt54pX6eG9ebttRH6QSH7r/zxAFA4EZGlQ0h0FvXiA==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "cpu": [ "x64" ], @@ -3899,26 +3474,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.3.tgz", - "integrity": "sha512-M/mwDCJ4wLsIgyxv2Lj7Len+UMHd4zAXu4GQ2UaCdksStglWhP61U3uowkaYBQBhVoNpwx5Hputo8eSqM7K82Q==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.3.tgz", - "integrity": "sha512-5jZT2c7jBCrMegKYTYTpni8mg8y3uY8gzeq2ndFOANwNuC/xJbVAoGKR9LhMDA0H3nIhvaqUoBEuJoICBudFrA==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "cpu": [ "x64" ], @@ -3927,26 +3491,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.3.tgz", - "integrity": "sha512-YeGUhkN1oA+iSPzzhEjVPS29YbViOr8s4lSsFaZKLHswgqP911xx25fPOyE9+khmN6W4VeM0aevbDp4kkEoHiA==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.3.tgz", - "integrity": "sha512-eo0iOIOvcAlWB3Z3eh8pVM8hZ0oVkK3AjEM9nSrkSug2l15qHzF3TOwT0747omI6+CJJvl7drwZepT+re6Fy/w==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "cpu": [ "arm" ], @@ -3955,26 +3508,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.3.tgz", - "integrity": "sha512-DJay3ep76bKUDImmn//W5SvpjRN5LmK/ntWyeJs/dcnwiiHESd3N4uteK9FDLf0S0W8E6Y0sVRXpOCoQclQqNg==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.3.tgz", - "integrity": "sha512-BKKWQkY2WgJ5MC/ayvIJTHjy0JUGb5efaHCUiG/39sSUvAYRBaO3+/EK0AZT1RF3pSj86O24GLLik9mAYu0IJg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "cpu": [ "arm64" ], @@ -3983,54 +3525,32 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.3.tgz", - "integrity": "sha512-Q9nVlWtKAG7ISW80OiZGxTr6rYtyDSkauHUtvkQI6TNOJjFvpj4gcH+KaJihqYInnAzEEUetPQubRwHef4exVg==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.3.tgz", - "integrity": "sha512-2H5LmhzrpC4fFRNwknzmmTvvyJPHwESoJgyReXeFoYYuIDfBhP29TEXOkCJE/KxHi27mj7wDUClNq78ue3QEBQ==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.3.tgz", - "integrity": "sha512-9S542V0ie9LCTznPYlvaeySwBeIEa7rDBgLHKZ5S9DBgcqdJYburabm8TqiqG6mrdTzfV5uttQRHcbKff9lWtA==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.3.tgz", - "integrity": "sha512-ukxw+YH3XXpcezLgbJeasgxyTbdpnNAkrIlFGDl7t+pgCxZ89/6n1a+MxlY7CegU+nDgrgdqDelPRNQ/47zs0g==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", "cpu": [ "ppc64" ], @@ -4039,40 +3559,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.3.tgz", - "integrity": "sha512-Iauw9UsTTvlF++FhghFJjqYxyXdggXsOqGpFBylaRopVpcbfyIIsNvkf9oGwfgIcf57z3m8+/oSYTo6HutBFNw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.3.tgz", - "integrity": "sha512-3OqKAHSEQXKdq9mQ4eajqUgNIK27VZPW3I26EP8miIzuKzCJ3aW3oEn2pzF+4/Hj/Moc0YDsOtBgT5bZ56/vcA==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.3.tgz", - "integrity": "sha512-0CM8dSVzVIaqMcXIFej8zZrSFLnGrAE8qlNbbHfTw1EEPnFTg1U1ekI0JdzjPyzSfUsHWtodilQQG/RA55berA==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", "cpu": [ "s390x" ], @@ -4081,12 +3576,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.3.tgz", - "integrity": "sha512-+fgJE12FZMIgBaKIAGd45rxf+5ftcycANJRWk8Vz0NnMTM5rADPGuRFTYar+Mqs560xuART7XsX2lSACa1iOmQ==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ "x64" ], @@ -4095,12 +3593,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.3.tgz", - "integrity": "sha512-tMD7NnbAolWPzQlJQJjVFh/fNH3K/KnA7K8gv2dJWCwwnaK6DFCYST1QXYWfu5V0cDwarWC8Sf/cfMHniNq21A==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "cpu": [ "x64" ], @@ -4109,26 +3610,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.3.tgz", - "integrity": "sha512-u5KsqxOxjEeIbn7bUK1MPM34jrnPwjeqgyin4/N6e/KzXKfpE9Mi0nCxcQjaM9lLmPcHmn/xx1yOjgTMtu1jWQ==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.3.tgz", - "integrity": "sha512-vo54aXwjpTtsAnb3ca7Yxs9t2INZg7QdXN/7yaoG7nPGbOBXYXQY41Km+S1Ov26vzOAzLcAjmMdjyEqS1JkVhw==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "cpu": [ "arm64" ], @@ -4137,54 +3627,70 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.3.tgz", - "integrity": "sha512-HI+PIVZ+m+9AgpnY3pt6rinUdRYrGHvmVdsNQ4odNqQ/eRF78DVpMR7mOq7nW06QxpczibwBmeQzB68wJ+4W4A==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.3.tgz", - "integrity": "sha512-vRByotbdMo3Wdi+8oC2nVxtc3RkkFKrGaok+a62AT8lz/YBuQjaVYAS5Zcs3tPzW43Vsf9J0wehJbUY5xRSekA==", - "cpu": [ - "ia32" - ], + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.3.tgz", - "integrity": "sha512-POZHq7UeuzMJljC5NjKi8vKMFN6/5EOqcX1yGntNLp7rUTpBAXQ1hW8kWPFxYLv07QMcNM75xqVLGPWQq6TKFA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.3.tgz", - "integrity": "sha512-aPFONczE4fUFKNXszdvnd2GqKEYQdV5oEsIbKPujJmWlCI9zEsv1Otig8RKK+X9bed9gFUN6LAeN4ZcNuu4zjg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", "cpu": [ "x64" ], @@ -4193,7 +3699,17 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -5335,31 +4851,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", - "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.17", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -5368,7 +4884,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -5380,26 +4896,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -5407,13 +4923,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -5422,24 +4939,47 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", + "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.5" + } + }, "node_modules/@vitest/utils": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", - "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -6913,9 +6453,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -6970,48 +6510,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, "node_modules/escalade": { "version": "3.2.0", "license": "MIT", @@ -7666,6 +7164,13 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -7751,7 +7256,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -9525,6 +9032,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -10216,7 +9733,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "funding": [ { "type": "opencollective", @@ -10836,49 +10355,38 @@ "node": ">=0.10.0" } }, - "node_modules/rollup": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz", - "integrity": "sha512-y9yUpfQvetAjiDLtNMf1hL9NXchIJgWt6zIKeoB+tCd3npX08Eqfzg60V9DhIGVMtQ0AlMkFw5xa+AQ37zxnAA==", + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.3", - "@rollup/rollup-android-arm64": "4.55.3", - "@rollup/rollup-darwin-arm64": "4.55.3", - "@rollup/rollup-darwin-x64": "4.55.3", - "@rollup/rollup-freebsd-arm64": "4.55.3", - "@rollup/rollup-freebsd-x64": "4.55.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.3", - "@rollup/rollup-linux-arm-musleabihf": "4.55.3", - "@rollup/rollup-linux-arm64-gnu": "4.55.3", - "@rollup/rollup-linux-arm64-musl": "4.55.3", - "@rollup/rollup-linux-loong64-gnu": "4.55.3", - "@rollup/rollup-linux-loong64-musl": "4.55.3", - "@rollup/rollup-linux-ppc64-gnu": "4.55.3", - "@rollup/rollup-linux-ppc64-musl": "4.55.3", - "@rollup/rollup-linux-riscv64-gnu": "4.55.3", - "@rollup/rollup-linux-riscv64-musl": "4.55.3", - "@rollup/rollup-linux-s390x-gnu": "4.55.3", - "@rollup/rollup-linux-x64-gnu": "4.55.3", - "@rollup/rollup-linux-x64-musl": "4.55.3", - "@rollup/rollup-openbsd-x64": "4.55.3", - "@rollup/rollup-openharmony-arm64": "4.55.3", - "@rollup/rollup-win32-arm64-msvc": "4.55.3", - "@rollup/rollup-win32-ia32-msvc": "4.55.3", - "@rollup/rollup-win32-x64-gnu": "4.55.3", - "@rollup/rollup-win32-x64-msvc": "4.55.3", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, "node_modules/router": { @@ -11362,6 +10870,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -11416,9 +10939,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -11758,12 +11281,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -11789,7 +11314,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -11800,9 +11327,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -11846,6 +11373,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -12312,18 +11849,17 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -12339,9 +11875,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -12354,13 +11891,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -12386,28 +11926,271 @@ } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/vite/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=12.0.0" + "node": ">= 12.0.0" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -12418,31 +12201,31 @@ } }, "node_modules/vitest": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -12458,12 +12241,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -12484,6 +12270,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -12492,6 +12284,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -12753,6 +12548,22 @@ "version": "3.1.1", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index f185555..42e32c3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,13 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "vitest" + "test": "vitest", + "test:unit": "vitest run --config vitest.config.ts", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:matrix": "bun tests/e2e/run-matrix.ts", + "test:matrix:critical": "bun tests/e2e/run-matrix.ts --critical", + "test:gate": "npm run test:unit && npm run test:matrix:critical", + "test:gate:full": "npm run test:unit && npm run test:matrix" }, "dependencies": { "@base-ui/react": "^1.1.0", @@ -43,10 +49,12 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/ui": "^4.1.5", "eslint": "^9", "eslint-config-next": "16.1.4", "tailwindcss": "^4", "typescript": "^5", - "vitest": "^4.0.17" + "vitest": "^4.1.5", + "yaml": "^2.8.4" } } diff --git a/tests/e2e/dart-validation.spec.ts b/tests/e2e/dart-validation.spec.ts new file mode 100644 index 0000000..93e58d6 --- /dev/null +++ b/tests/e2e/dart-validation.spec.ts @@ -0,0 +1,96 @@ +/** + * dart-validation.spec.ts + * + * Vitest wrapper around Layer 2 Dart validation for the critical combos. + * Skipped automatically if Flutter/Dart SDK is not available. + * + * This runs in Tier 2 CI (PR checks) where Flutter is installed. + */ + +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { execSync } from "node:child_process" + +import { describe, expect, it, beforeAll } from "vitest" + +import { generateToDisk } from "../utils/generate" +import { buildConfig, combinationLabel } from "../utils/matrix.config" +import { CRITICAL_COMBINATIONS } from "../utils/critical-combos" + +// ── Check if Dart SDK is available ────────────────────────────── + +let dartAvailable = false + +function checkDart(): boolean { + try { + execSync("dart --version", { encoding: "utf8", stdio: "pipe" }) + return true + } catch { + return false + } +} + +beforeAll(() => { + dartAvailable = checkDart() + if (!dartAvailable) { + console.warn( + "\n⚠️ Dart SDK not found. Skipping Layer 2 Dart validation tests.\n" + + " Install Flutter/Dart SDK to run these tests locally.\n" + ) + } +}) + +// ── Tests ─────────────────────────────────────────────────────── + +describe("Layer 2 — Dart Validation (Critical Combinations)", { timeout: 3_600_000 }, () => { + it.each( + CRITICAL_COMBINATIONS.map((c, i) => [i, combinationLabel(c), c] as const) + )( + "combo #%i — %s passes dart analyze", + { timeout: 120_000 }, + async (_index, _label, combo) => { + if (!dartAvailable) { + return // Skip gracefully + } + + const config = buildConfig(combo) + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flutter-e2e-")) + const projectDir = path.join(tmpDir, "test_app") + await fs.mkdir(projectDir, { recursive: true }) + + try { + // Generate to disk + await generateToDisk(config, projectDir) + + // dart pub get + try { + execSync("dart pub get", { + cwd: projectDir, + encoding: "utf8", + timeout: 120_000, + stdio: "pipe", + }) + } catch (error: any) { + const output = (error.stdout || "") + "\n" + (error.stderr || "") + throw new Error(`dart pub get failed:\n${output}`) + } + + // dart analyze --fatal-infos + try { + execSync("dart analyze --fatal-infos", { + cwd: projectDir, + encoding: "utf8", + timeout: 120_000, + stdio: "pipe", + }) + } catch (error: any) { + const output = (error.stdout || "") + "\n" + (error.stderr || "") + throw new Error(`dart analyze failed:\n${output}`) + } + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + } + } + ) +}) // 1 hour total for all critical combos diff --git a/tests/e2e/run-matrix.ts b/tests/e2e/run-matrix.ts new file mode 100644 index 0000000..7dc63d8 --- /dev/null +++ b/tests/e2e/run-matrix.ts @@ -0,0 +1,159 @@ +/** + * run-matrix.ts + * + * Orchestrator script that runs validate-combo.ts across multiple combinations. + * + * Usage: + * bun tests/e2e/run-matrix.ts # Run ALL valid combos + * bun tests/e2e/run-matrix.ts --critical # Run critical combos only + * bun tests/e2e/run-matrix.ts --range 0-50 # Run combos 0 through 50 + * bun tests/e2e/run-matrix.ts --concurrency 4 # Parallel workers (default: 1) + */ + +import path from "node:path" + +import { ALL_COMBINATIONS, combinationLabel, type Combination } from "../utils/matrix.config" +import { CRITICAL_COMBINATIONS } from "../utils/critical-combos" + +// ── Parse args ────────────────────────────────────────────────── + +interface RunConfig { + combinations: Combination[] + concurrency: number + label: string +} + +function parseArgs(): RunConfig { + const args = process.argv.slice(2) + let combinations = ALL_COMBINATIONS + let concurrency = 1 + let label = "all" + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "--critical": + combinations = CRITICAL_COMBINATIONS + label = "critical" + break + case "--range": { + const range = args[++i] + const [start, end] = range.split("-").map(Number) + combinations = ALL_COMBINATIONS.slice(start, end + 1) + label = `range-${start}-${end}` + break + } + case "--concurrency": + concurrency = parseInt(args[++i], 10) + break + } + } + + return { combinations, concurrency, label } +} + +// ── Runner ────────────────────────────────────────────────────── + +interface Result { + combo: Combination + label: string + passed: boolean + output: string + durationMs: number +} + +import { spawnSync } from "node:child_process" + +function validateCombo(combo: Combination): Result { + const label = combinationLabel(combo) + const comboJson = JSON.stringify(combo) + const scriptPath = path.join(__dirname, "validate-combo.ts") + const start = Date.now() + + try { + const result = spawnSync( + "bun", + [scriptPath, "--json", comboJson], + { + encoding: "utf8", + timeout: 180_000, + stdio: ["pipe", "pipe", "pipe"], + } + ) + + const output = (result.stdout || "") + "\n" + (result.stderr || "") + + return { + combo, + label, + passed: result.status === 0, + output, + durationMs: Date.now() - start, + } + } catch (error: any) { + const output = error.message || "Unknown error" + return { + combo, + label, + passed: false, + output, + durationMs: Date.now() - start, + } + } +} + +// ── Main ──────────────────────────────────────────────────────── + +async function main() { + const config = parseArgs() + + console.log(`\n${"═".repeat(60)}`) + console.log(`FlutterInit Matrix Test Runner`) + console.log(`Mode: ${config.label}`) + console.log(`Combinations: ${config.combinations.length}`) + console.log(`Concurrency: ${config.concurrency}`) + console.log(`${"═".repeat(60)}\n`) + + const results: Result[] = [] + const startTime = Date.now() + + // Sequential execution (concurrency is handled by CI matrix jobs) + for (let i = 0; i < config.combinations.length; i++) { + const combo = config.combinations[i] + const label = combinationLabel(combo) + console.log(`[${i + 1}/${config.combinations.length}] Testing: ${label}`) + + const result = validateCombo(combo) + results.push(result) + + const icon = result.passed ? "✅" : "❌" + console.log(` ${icon} ${result.durationMs}ms\n`) + } + + // ── Summary ───────────────────────────────────────────── + + const totalMs = Date.now() - startTime + const passed = results.filter((r) => r.passed) + const failed = results.filter((r) => !r.passed) + + console.log(`\n${"═".repeat(60)}`) + console.log(`Results: ${passed.length}/${results.length} passed (${(totalMs / 1000).toFixed(1)}s)`) + + if (failed.length > 0) { + console.log(`\n❌ ${failed.length} FAILED:`) + for (const f of failed) { + console.log(` • ${f.label}`) + // Show first few lines of output for debugging + const lines = f.output.trim().split("\n").slice(-10) + for (const line of lines) { + console.log(` ${line}`) + } + } + console.log(`${"═".repeat(60)}\n`) + process.exit(1) + } + + console.log(`\n✅ All ${results.length} combinations passed!`) + console.log(`${"═".repeat(60)}\n`) +} + +main() diff --git a/tests/e2e/validate-combo.ts b/tests/e2e/validate-combo.ts new file mode 100644 index 0000000..66e371b --- /dev/null +++ b/tests/e2e/validate-combo.ts @@ -0,0 +1,113 @@ +/** + * validate-combo.ts + * + * Standalone CLI script that validates a single combination + * by generating it to disk and running dart pub get + dart analyze. + * + * Usage: + * bun tests/e2e/validate-combo.ts + * bun tests/e2e/validate-combo.ts --json '' + * + * Exit codes: + * 0 — pass + * 1 — dart pub get or dart analyze failed + * 2 — generation failed + */ + +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { execSync } from "node:child_process" + +import { ALL_COMBINATIONS, buildConfig, combinationLabel, type Combination } from "../utils/matrix.config" + +// ── Parse CLI args ────────────────────────────────────────────── + +function parseCombination(): Combination { + const args = process.argv.slice(2) + + if (args[0] === "--json") { + return JSON.parse(args[1]) as Combination + } + + const index = parseInt(args[0], 10) + if (isNaN(index) || index < 0 || index >= ALL_COMBINATIONS.length) { + console.error(`Usage: bun tests/e2e/validate-combo.ts `) + console.error(` bun tests/e2e/validate-combo.ts --json '{"architecture":"clean",...}'`) + process.exit(2) + } + + return ALL_COMBINATIONS[index] +} + +// ── Helpers ───────────────────────────────────────────────────── + +function runCommand(cmd: string, cwd: string): { success: boolean; output: string } { + try { + const output = execSync(cmd, { + cwd, + encoding: "utf8", + timeout: 120_000, + stdio: ["pipe", "pipe", "pipe"], + }) + return { success: true, output } + } catch (error: any) { + const output = (error.stdout || "") + "\n" + (error.stderr || "") + return { success: false, output } + } +} + +// ── Main ──────────────────────────────────────────────────────── + +async function main() { + const combo = parseCombination() + const label = combinationLabel(combo) + const config = buildConfig(combo) + + console.log(`\n${"═".repeat(60)}`) + console.log(`Validating: ${label}`) + console.log(`${"═".repeat(60)}\n`) + + // 1. Generate project to temp directory + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flutter-test-")) + const projectDir = path.join(tmpDir, "test_app") + await fs.mkdir(projectDir, { recursive: true }) + + try { + // Import and run generation + const { generateToDisk } = await import("../utils/generate") + await generateToDisk(config, projectDir) + console.log("✓ Project generated successfully") + + // 2. dart pub get + console.log("\nRunning dart pub get...") + const pubGetResult = runCommand("dart pub get", projectDir) + if (!pubGetResult.success) { + console.error("✗ dart pub get FAILED") + console.error(pubGetResult.output) + process.exit(1) + } + console.log("✓ dart pub get passed") + + // 3. dart analyze --fatal-infos + console.log("\nRunning dart analyze --fatal-infos...") + const analyzeResult = runCommand("dart analyze --fatal-infos", projectDir) + if (!analyzeResult.success) { + console.error("✗ dart analyze FAILED") + console.error(analyzeResult.output) + process.exit(1) + } + console.log("✓ dart analyze passed") + + console.log(`\n✅ PASS: ${label}\n`) + } catch (error) { + console.error(`\n❌ Generation failed for ${label}:`) + console.error(error) + process.exit(2) + } finally { + // Cleanup + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + } +} + +main() diff --git a/tests/generator.spec.ts b/tests/generator.spec.ts deleted file mode 100644 index e789fe8..0000000 --- a/tests/generator.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import path from "node:path" - -import JSZip from "jszip" -import { describe, expect, it } from "vitest" - -import { defaultConfig } from "@/app/lib/config/schema" -import { generateFlutterScaffold } from "@/app/lib/generator" -import { createHandlebarsEnvironment } from "@/app/lib/generator/handlebars" - -describe("handlebars helpers", () => { - it("converts text to kebab-case", async () => { - const hbs = await createHandlebarsEnvironment( - path.join(process.cwd(), "templates", "flutter", "partials") - ) - const template = hbs.compile("{{kebabCase value}}") - expect(template({ value: "Hello World" })).toBe("hello-world") - }) -}) - -describe("generator", () => { - it("produces a zip with core flutter files", async () => { - const buffer = await generateFlutterScaffold(defaultConfig) - const zip = await JSZip.loadAsync(buffer) - const files = Object.keys(zip.files) - - expect(files.some((file) => file.endsWith("pubspec.yaml"))).toBe(true) - expect(files.some((file) => file.endsWith("lib/main.dart"))).toBe(true) - expect( - files.some((file) => - file.includes("lib/src/theme/app_spacing.dart") - ) - ).toBe(true) - }) - - it("does not emit ScreenUtil num extensions when disabled", async () => { - const buffer = await generateFlutterScaffold({ - ...defaultConfig, - misc: { - ...defaultConfig.misc, - usesScreenutil: false, - }, - }) - const zip = await JSZip.loadAsync(buffer) - - const dartFiles = Object.keys(zip.files).filter((f) => f.endsWith(".dart")) - const contents = await Promise.all( - dartFiles.map(async (f) => ({ - file: f, - text: await zip.file(f)!.async("string"), - })) - ) - - // When ScreenUtil is disabled we should NOT generate `AppSpacing.xxx.0` - // (or `AppSpacing.xxx.w/h/...`). `AppSpacing.xxx` should remain a plain double. - const offenders = contents - .filter(({ text }) => /AppSpacing\.\w+\.\s*(0|w|h|sp|r)\b/.test(text)) - .map(({ file }) => file) - - expect(offenders, `Found ScreenUtil extensions in: ${offenders.join(", ")}`).toEqual([]) - }) -}) diff --git a/tests/integration/full-pipeline.spec.ts b/tests/integration/full-pipeline.spec.ts new file mode 100644 index 0000000..eae4778 --- /dev/null +++ b/tests/integration/full-pipeline.spec.ts @@ -0,0 +1,58 @@ +/** + * full-pipeline.spec.ts + * + * Integration tests that exercise the full generation pipeline + * for the critical combinations. Each test generates a complete + * project and runs ALL assertion categories against it. + */ + +import { describe, expect, it } from "vitest" + +import { + assertArchitectureStructure, + assertNoEmptyFiles, + assertNoUnresolvedTokens, + assertRequiredFilesExist, + assertValidPubspec, + getFileContent, +} from "../utils/assertions" +import { generateToMap, getPubspecContent } from "../utils/generate" +import { buildConfig, combinationLabel } from "../utils/matrix.config" +import { CRITICAL_COMBINATIONS } from "../utils/critical-combos" + +describe("Full Pipeline — Critical Combinations", () => { + it.each( + CRITICAL_COMBINATIONS.map((c, i) => [i, combinationLabel(c), c] as const) + )( + "combo #%i — %s passes all assertions", + { timeout: 30_000 }, + async (_index, _label, combo) => { + const config = buildConfig(combo) + const files = await generateToMap(config) + + // 1. Token cleanliness + assertNoUnresolvedTokens(files) + + // 2. Structural integrity + assertRequiredFilesExist(files) + assertNoEmptyFiles(files) + assertArchitectureStructure(files, combo.architecture) + + // 3. pubspec validity + const pubspec = getPubspecContent(files) + assertValidPubspec(pubspec) + + // 4. Variable injection — app name appears correctly + expect(pubspec).toContain("name: test_app") + + // 5. main.dart has substantive content + const mainDart = getFileContent(files, "lib/main.dart") + expect(mainDart).toBeDefined() + expect(mainDart!.length).toBeGreaterThan(50) + expect(mainDart).toContain("main()") + + // 6. Reasonable file count (a generated project should have 20+ files) + expect(files.size).toBeGreaterThan(20) + } + ) +}) diff --git a/tests/integration/overlay-composition.spec.ts b/tests/integration/overlay-composition.spec.ts new file mode 100644 index 0000000..92d0a85 --- /dev/null +++ b/tests/integration/overlay-composition.spec.ts @@ -0,0 +1,184 @@ +/** + * overlay-composition.spec.ts + * + * Tests that overlay files correctly replace base files, + * and that the overlay resolution logic includes/excludes + * the right directories based on configuration. + */ + +import { describe, expect, it } from "vitest" + +import { getFileContent } from "../utils/assertions" +import { generateToMap, getPubspecContent, getFile } from "../utils/generate" +import { buildConfig, type Combination } from "../utils/matrix.config" + +const base: Combination = { + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", + navigation: "go_router", + miscProfile: "default", +} + +describe("Overlay Composition", () => { + // ── State overlays replace base wrappers ───────────────────── + + describe("State overlay replaces base state_wrapper.dart", () => { + it("riverpod overlay produces ProviderScope wrapper", async () => { + const files = await generateToMap( + buildConfig({ ...base, stateManagement: "riverpod" }) + ) + const wrapper = getFileContent(files, "state_wrapper.dart") + expect(wrapper).toBeDefined() + expect(wrapper).toContain("ProviderScope") + }) + + it("bloc overlay produces MultiBlocProvider wrapper", async () => { + const files = await generateToMap( + buildConfig({ ...base, stateManagement: "bloc" }) + ) + const wrapper = getFileContent(files, "state_wrapper.dart") + expect(wrapper).toBeDefined() + expect(wrapper).toContain("MultiBlocProvider") + }) + + it("provider overlay produces MultiProvider wrapper", async () => { + const files = await generateToMap( + buildConfig({ ...base, stateManagement: "provider" }) + ) + const wrapper = getFileContent(files, "state_wrapper.dart") + expect(wrapper).toBeDefined() + expect(wrapper).toContain("MultiProvider") + }) + }) + + // ── Backend overlays inject service files ──────────────────── + + describe("Backend overlay injects service files", () => { + it("firebase overlay adds auth_service when auth enabled", async () => { + const files = await generateToMap( + buildConfig({ ...base, backend: "firebase" }) + ) + // Firebase backend produces auth_service.dart via (usesFirebaseAuth)@ gate + const pubspec = getPubspecContent(files) + expect(pubspec).toContain("firebase_core:") + // auth_service.dart should exist (since default firebase has authEmail: true) + const authService = getFileContent(files, "auth_service.dart") + expect(authService).toBeDefined() + }) + + it("supabase overlay adds auth_service when auth enabled", async () => { + const files = await generateToMap( + buildConfig({ ...base, backend: "supabase" }) + ) + const pubspec = getPubspecContent(files) + expect(pubspec).toContain("supabase_flutter:") + // auth_service.dart should exist (since default supabase has auth: true) + const authService = getFileContent(files, "auth_service.dart") + expect(authService).toBeDefined() + }) + + it("none backend does NOT produce backend service files", async () => { + const files = await generateToMap( + buildConfig({ ...base, backend: "none" }) + ) + const backendFiles = [...files.keys()].filter( + (f) => + f.toLowerCase().includes("firebase") || + f.toLowerCase().includes("supabase") || + f.toLowerCase().includes("appwrite") + ) + expect(backendFiles).toEqual([]) + }) + }) + + // ── Localization overlay ──────────────────────────────────── + + describe("Localization overlay", () => { + it("when enabled: translation JSON files are present", async () => { + const files = await generateToMap(buildConfig(base)) + const translationFiles = [...files.keys()].filter( + (f) => f.includes("translations/") && f.endsWith(".json") + ) + expect(translationFiles.length).toBeGreaterThanOrEqual(2) + }) + + it("when enabled: easy_localization in pubspec", async () => { + const files = await generateToMap(buildConfig(base)) + const pubspec = getPubspecContent(files) + expect(pubspec).toContain("easy_localization") + }) + }) + + // ── Networking overlays ───────────────────────────────────── + + describe("Networking overlays", () => { + it("dio overlay: dio service files present when usesDio=true", async () => { + const files = await generateToMap( + buildConfig({ ...base, miscProfile: "full" }) + ) + const dioFiles = [...files.keys()].filter((f) => + f.toLowerCase().includes("dio") + ) + expect(dioFiles.length).toBeGreaterThan(0) + }) + + it("no dio overlay: no dio files when usesDio=false", async () => { + const files = await generateToMap( + buildConfig({ ...base, miscProfile: "minimal" }) + ) + // Should not have dio-specific service files (excluding pubspec references) + const dioServiceFiles = [...files.keys()].filter( + (f) => f.toLowerCase().includes("dio") && f.endsWith(".dart") + ) + expect(dioServiceFiles).toEqual([]) + }) + }) + + // ── Storage overlays ──────────────────────────────────────── + + describe("Storage overlays", () => { + it("hive overlay produces hive service when usesHive=true", async () => { + const files = await generateToMap( + buildConfig({ ...base, miscProfile: "full" }) + ) + const hiveFiles = [...files.keys()].filter((f) => + f.toLowerCase().includes("hive") + ) + expect(hiveFiles.length).toBeGreaterThan(0) + }) + + it("no hive files when usesHive=false", async () => { + const files = await generateToMap( + buildConfig({ ...base, miscProfile: "minimal" }) + ) + const hiveFiles = [...files.keys()].filter( + (f) => f.toLowerCase().includes("hive") && f.endsWith(".dart") + ) + expect(hiveFiles).toEqual([]) + }) + }) + + // ── Architecture overlays ─────────────────────────────────── + + describe("Architecture overlay produces correct folder structure", () => { + it("clean architecture has domain/data/presentation layers", async () => { + const files = await generateToMap( + buildConfig({ ...base, architecture: "clean" }) + ) + const paths = [...files.keys()] + + expect(paths.some((p) => p.includes("/domain/"))).toBe(true) + expect(paths.some((p) => p.includes("/data/"))).toBe(true) + expect(paths.some((p) => p.includes("/presentation/"))).toBe(true) + }) + + it("feature-first has features directory", async () => { + const files = await generateToMap( + buildConfig({ ...base, architecture: "feature-first" }) + ) + const paths = [...files.keys()] + expect(paths.some((p) => p.includes("/features/"))).toBe(true) + }) + }) +}) diff --git a/tests/unit/backend.spec.ts b/tests/unit/backend.spec.ts new file mode 100644 index 0000000..69a09e0 --- /dev/null +++ b/tests/unit/backend.spec.ts @@ -0,0 +1,133 @@ +/** + * backend.spec.ts + * + * Focused tests per backend provider. + * Verifies correct backend-specific packages, initialization code, + * and absence of other backend code. + */ + +import { describe, it } from "vitest" + +import { + assertDependencyAbsent, + assertDependencyPresent +} from "../utils/assertions" +import { generateToMap, getPubspecContent } from "../utils/generate" +import { buildConfig, type Combination } from "../utils/matrix.config" + +const base: Omit = { + architecture: "feature-first", + stateManagement: "riverpod", + navigation: "go_router", + miscProfile: "default", +} + +describe("Backend Providers", () => { + // ── Firebase ──────────────────────────────────────────────── + + describe("firebase", () => { + it("includes firebase_core", async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "firebase" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "firebase_core") + }) + + it("includes firebase_auth when authEmail enabled", async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "firebase" })) + const pubspec = getPubspecContent(files) + // Default firebase config has authEmail: true + assertDependencyPresent(pubspec, "firebase_auth") + }) + + it("includes cloud_firestore when firestore enabled", async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "firebase" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "cloud_firestore") + }) + + it("does not include supabase or appwrite packages", async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "firebase" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "appwrite") + }) + }) + + // ── Supabase ──────────────────────────────────────────────── + + describe("supabase", () => { + it("includes supabase_flutter", async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "supabase" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "supabase_flutter") + }) + + it("does not include firebase or appwrite packages", async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "supabase" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "appwrite") + }) + }) + + // ── Appwrite ──────────────────────────────────────────────── + + describe("appwrite", () => { + it("includes appwrite", async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "appwrite" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "appwrite") + }) + + it("does not include firebase or supabase packages", async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "appwrite" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "supabase_flutter") + }) + }) + + // ── Custom ────────────────────────────────────────────────── + + describe("custom", () => { + it("requires dio or http — full profile has dio", async () => { + const files = await generateToMap( + buildConfig({ + ...base, + backend: "custom", + miscProfile: "full", // full has usesDio: true + }) + ) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "dio") + }) + + it("does not include any backend SDK packages", async () => { + const files = await generateToMap( + buildConfig({ + ...base, + backend: "custom", + miscProfile: "full", + }) + ) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "appwrite") + }) + }) + + // ── None ──────────────────────────────────────────────────── + + describe("none", () => { + it("has no backend SDK packages", async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "none" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "firebase_auth") + assertDependencyAbsent(pubspec, "cloud_firestore") + assertDependencyAbsent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "appwrite") + }) + }) +}) diff --git a/tests/unit/dependencies.spec.ts b/tests/unit/dependencies.spec.ts new file mode 100644 index 0000000..65fcba0 --- /dev/null +++ b/tests/unit/dependencies.spec.ts @@ -0,0 +1,272 @@ +/** + * dependencies.spec.ts + * + * Verifies that pubspec.yaml contains exactly the right dependencies + * for each combination — no more, no less. + */ + +import { describe, it } from "vitest" + +import { + assertDependencyAbsent, + assertDependencyPresent, + assertValidPubspec, +} from "../utils/assertions" +import { generateToMap, getPubspecContent } from "../utils/generate" +import { + ALL_COMBINATIONS, + buildConfig, + combinationLabel +} from "../utils/matrix.config" + +describe("Dependency Assertions", { timeout: 600_000 }, () => { + // ── pubspec validity ──────────────────────────────────────── + + describe("pubspec.yaml is valid YAML with correct structure", () => { + it.each(ALL_COMBINATIONS.map((c, i) => [i, combinationLabel(c), c] as const))( + "combo #%i — %s", + { timeout: 15_000 }, + async (_i, _label, combo) => { + const config = buildConfig(combo) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertValidPubspec(pubspec) + } + ) + }) + + // ── State management dependencies ─────────────────────────── + + describe("State management packages", () => { + const STATE_PACKAGES: Record = { + riverpod: { + present: ["flutter_riverpod"], + absent: ["provider", "flutter_bloc", "mobx", "flutter_mobx"], + }, + provider: { + present: ["provider"], + absent: ["flutter_riverpod", "flutter_bloc", "mobx", "flutter_mobx"], + }, + bloc: { + present: ["flutter_bloc"], + absent: ["flutter_riverpod", "provider", "mobx", "flutter_mobx"], + }, + mobx: { + present: ["mobx", "flutter_mobx"], + absent: ["flutter_riverpod", "provider", "flutter_bloc"], + }, + none: { + present: [], + absent: ["flutter_riverpod", "provider", "flutter_bloc", "mobx", "flutter_mobx"], + }, + } + + for (const [stateManager, packages] of Object.entries(STATE_PACKAGES)) { + it(`${stateManager}: correct packages present and absent`, async () => { + const config = buildConfig({ + architecture: "feature-first", + stateManagement: stateManager as any, + backend: "none", + navigation: "go_router", + miscProfile: "default", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + for (const pkg of packages.present) { + assertDependencyPresent(pubspec, pkg) + } + for (const pkg of packages.absent) { + assertDependencyAbsent(pubspec, pkg) + } + }) + } + }) + + // ── Backend dependencies ──────────────────────────────────── + + describe("Backend packages", () => { + it("firebase: firebase_core present", async () => { + const config = buildConfig({ + architecture: "feature-first", + stateManagement: "riverpod", + backend: "firebase", + navigation: "go_router", + miscProfile: "default", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyPresent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "appwrite") + }) + + it("supabase: supabase_flutter present", async () => { + const config = buildConfig({ + architecture: "clean", + stateManagement: "bloc", + backend: "supabase", + navigation: "go_router", + miscProfile: "default", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyPresent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "appwrite") + }) + + it("appwrite: appwrite present", async () => { + const config = buildConfig({ + architecture: "mvvm", + stateManagement: "provider", + backend: "appwrite", + navigation: "auto_route", + miscProfile: "default", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyPresent(pubspec, "appwrite") + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "supabase_flutter") + }) + + it("none: no backend packages", async () => { + const config = buildConfig({ + architecture: "mvc", + stateManagement: "none", + backend: "none", + navigation: "imperative", + miscProfile: "minimal", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "appwrite") + }) + }) + + // ── Navigation dependencies ───────────────────────────────── + + describe("Navigation packages", () => { + it("go_router: go_router present", async () => { + const config = buildConfig({ + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", + navigation: "go_router", + miscProfile: "default", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyPresent(pubspec, "go_router") + assertDependencyAbsent(pubspec, "auto_route") + }) + + it("auto_route: auto_route + auto_route_generator present", async () => { + const config = buildConfig({ + architecture: "clean", + stateManagement: "bloc", + backend: "none", + navigation: "auto_route", + miscProfile: "default", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyPresent(pubspec, "auto_route") + assertDependencyPresent(pubspec, "auto_route_generator") + assertDependencyAbsent(pubspec, "go_router") + }) + + it("imperative: no routing packages", async () => { + const config = buildConfig({ + architecture: "mvvm", + stateManagement: "provider", + backend: "none", + navigation: "imperative", + miscProfile: "default", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyAbsent(pubspec, "go_router") + assertDependencyAbsent(pubspec, "auto_route") + assertDependencyAbsent(pubspec, "auto_route_generator") + }) + }) + + // ── Networking dependencies ───────────────────────────────── + + describe("Networking packages", () => { + it("full profile: dio present", async () => { + const config = buildConfig({ + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", + navigation: "go_router", + miscProfile: "full", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyPresent(pubspec, "dio") + }) + + it("minimal profile: no networking packages", async () => { + const config = buildConfig({ + architecture: "layer-first", + stateManagement: "none", + backend: "none", + navigation: "imperative", + miscProfile: "minimal", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyAbsent(pubspec, "dio") + assertDependencyAbsent(pubspec, "http") + }) + }) + + // ── MobX dev dependencies ─────────────────────────────────── + + describe("Build runner for code generation", () => { + it("mobx: build_runner + mobx_codegen in dev_dependencies", async () => { + const config = buildConfig({ + architecture: "clean", + stateManagement: "mobx", + backend: "none", + navigation: "go_router", + miscProfile: "default", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyPresent(pubspec, "build_runner") + assertDependencyPresent(pubspec, "mobx_codegen") + }) + + it("auto_route: build_runner + auto_route_generator in dev_dependencies", async () => { + const config = buildConfig({ + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", + navigation: "auto_route", + miscProfile: "default", + }) + const files = await generateToMap(config) + const pubspec = getPubspecContent(files) + + assertDependencyPresent(pubspec, "build_runner") + assertDependencyPresent(pubspec, "auto_route_generator") + }) + }) +}) diff --git a/tests/unit/handlebars-helpers.spec.ts b/tests/unit/handlebars-helpers.spec.ts new file mode 100644 index 0000000..ade3d03 --- /dev/null +++ b/tests/unit/handlebars-helpers.spec.ts @@ -0,0 +1,233 @@ +/** + * handlebars-helpers.spec.ts + * + * Tests for all registered Handlebars helpers. + * Migrated from the original generator.spec.ts + expanded. + */ + +import path from "node:path" + +import { describe, expect, it } from "vitest" + +import { createHandlebarsEnvironment } from "@/app/lib/generator/handlebars" + +const partialsDir = path.join(process.cwd(), "templates", "flutter", "partials") + +describe("Handlebars Helpers", () => { + // ── Case conversion helpers ───────────────────────────────── + + describe("kebabCase", () => { + it("converts space-separated words", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{kebabCase value}}") + expect(template({ value: "Hello World" })).toBe("hello-world") + }) + + it("converts camelCase", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{kebabCase value}}") + expect(template({ value: "myAppName" })).toBe("my-app-name") + }) + + it("converts PascalCase", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{kebabCase value}}") + expect(template({ value: "MyAppName" })).toBe("my-app-name") + }) + + it("handles underscores", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{kebabCase value}}") + expect(template({ value: "my_app_name" })).toBe("my-app-name") + }) + + it("handles already kebab-case", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{kebabCase value}}") + expect(template({ value: "my-app-name" })).toBe("my-app-name") + }) + }) + + describe("snakeCase", () => { + it("converts space-separated words", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{snakeCase value}}") + expect(template({ value: "Hello World" })).toBe("hello_world") + }) + + it("converts camelCase", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{snakeCase value}}") + expect(template({ value: "myAppName" })).toBe("my_app_name") + }) + + it("converts kebab-case", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{snakeCase value}}") + expect(template({ value: "my-app-name" })).toBe("my_app_name") + }) + }) + + describe("pascalCase", () => { + it("converts space-separated words", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{pascalCase value}}") + expect(template({ value: "hello world" })).toBe("HelloWorld") + }) + + it("converts snake_case", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{pascalCase value}}") + expect(template({ value: "my_app_name" })).toBe("MyAppName") + }) + + it("converts kebab-case", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{pascalCase value}}") + expect(template({ value: "my-app-name" })).toBe("MyAppName") + }) + }) + + // ── Boolean logic helpers ─────────────────────────────────── + + describe("eq", () => { + it("returns true for equal strings", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile('{{#if (eq a "hello")}}yes{{else}}no{{/if}}') + expect(template({ a: "hello" })).toBe("yes") + }) + + it("returns false for different strings", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile('{{#if (eq a "hello")}}yes{{else}}no{{/if}}') + expect(template({ a: "world" })).toBe("no") + }) + + it("strict equality — no type coercion", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile('{{#if (eq a "1")}}yes{{else}}no{{/if}}') + expect(template({ a: 1 })).toBe("no") + }) + }) + + describe("and", () => { + it("returns true when all args truthy", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{#if (and a b)}}yes{{else}}no{{/if}}") + expect(template({ a: true, b: true })).toBe("yes") + }) + + it("returns false when any arg falsy", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{#if (and a b)}}yes{{else}}no{{/if}}") + expect(template({ a: true, b: false })).toBe("no") + }) + }) + + describe("or", () => { + it("returns true when any arg truthy", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{#if (or a b)}}yes{{else}}no{{/if}}") + expect(template({ a: false, b: true })).toBe("yes") + }) + + it("returns false when all args falsy", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{#if (or a b)}}yes{{else}}no{{/if}}") + expect(template({ a: false, b: false })).toBe("no") + }) + }) + + describe("not", () => { + it("negates true to false", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{#if (not a)}}yes{{else}}no{{/if}}") + expect(template({ a: true })).toBe("no") + }) + + it("negates false to true", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{#if (not a)}}yes{{else}}no{{/if}}") + expect(template({ a: false })).toBe("yes") + }) + }) + + // ── res helper ────────────────────────────────────────────── + + describe("res", () => { + it("appends .w when ScreenUtil enabled", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{res 16 'w' usesScreenutil}}") + expect(template({ usesScreenutil: true })).toBe("16.w") + }) + + it("appends .h when ScreenUtil enabled", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{res 16 'h' usesScreenutil}}") + expect(template({ usesScreenutil: true })).toBe("16.h") + }) + + it("appends .sp when ScreenUtil enabled", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{res 14 'sp' usesScreenutil}}") + expect(template({ usesScreenutil: true })).toBe("14.sp") + }) + + it("returns plain double when ScreenUtil disabled (number input)", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{res 16 'w' usesScreenutil}}") + expect(template({ usesScreenutil: false })).toBe("16.0") + }) + + it("returns expression as-is when ScreenUtil disabled (string input)", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{res 'AppSpacing.lg' 'w' usesScreenutil}}") + expect(template({ usesScreenutil: false })).toBe("AppSpacing.lg") + }) + }) + + // ── when helper ───────────────────────────────────────────── + + describe("when", () => { + it("renders fn block when condition is true", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{#when show}}visible{{else}}hidden{{/when}}") + expect(template({ show: true })).toBe("visible") + }) + + it("renders inverse block when condition is false", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{#when show}}visible{{else}}hidden{{/when}}") + expect(template({ show: false })).toBe("hidden") + }) + }) + + // ── json helper ───────────────────────────────────────────── + + describe("json", () => { + it("serializes an object to pretty JSON", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{{json data}}}") + const result = template({ data: { key: "value" } }) + expect(result).toContain('"key": "value"') + }) + }) + + // ── indent helper ─────────────────────────────────────────── + + describe("indent", () => { + it("indents each line by the specified number of spaces", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{{indent text 4}}}") + const result = template({ text: "line1\nline2" }) + expect(result).toBe(" line1\n line2") + }) + + it("does not indent empty lines", async () => { + const hbs = await createHandlebarsEnvironment(partialsDir) + const template = hbs.compile("{{{indent text 2}}}") + const result = template({ text: "line1\n\nline2" }) + expect(result).toBe(" line1\n\n line2") + }) + }) +}) diff --git a/tests/unit/misc-flags.spec.ts b/tests/unit/misc-flags.spec.ts new file mode 100644 index 0000000..5fa9a72 --- /dev/null +++ b/tests/unit/misc-flags.spec.ts @@ -0,0 +1,246 @@ +/** + * misc-flags.spec.ts + * + * Tests for each miscellaneous boolean flag. + * Verifies that toggling a flag correctly adds/removes: + * - pubspec.yaml dependencies + * - Overlay files (services, hooks, widgets) + * - ScreenUtil extensions (.w/.h/.sp) + * - Flutter Hooks patterns + */ + +import { describe, expect, it } from "vitest" + +import { assertDependencyAbsent, assertDependencyPresent, getFileContent } from "../utils/assertions" +import { generateToMap, getPubspecContent } from "../utils/generate" +import { buildConfig, type Combination } from "../utils/matrix.config" + +const base: Combination = { + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", + navigation: "go_router", + miscProfile: "default", +} + +describe("Misc Flags", () => { + // ── ScreenUtil ────────────────────────────────────────────── + + describe("usesScreenutil", () => { + it("when enabled: flutter_screenutil in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "full" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "flutter_screenutil") + }) + + it("when disabled: no flutter_screenutil", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "flutter_screenutil") + }) + + it("when disabled: no .w/.h/.sp extensions in dart files", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) + const dartFiles = [...files.entries()].filter(([f]) => f.endsWith(".dart")) + + const offenders = dartFiles + .filter(([, text]) => /\b\d+\.(w|h|sp|r)\b/.test(text)) + .map(([f]) => f) + + expect( + offenders, + `Found ScreenUtil extensions in: ${offenders.join(", ")}` + ).toEqual([]) + }) + }) + + // ── Flutter Hooks ─────────────────────────────────────────── + + describe("usesFlutterHooks", () => { + it("when enabled: flutter_hooks in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "hooks" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "flutter_hooks") + }) + + it("when disabled: no flutter_hooks in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "flutter_hooks") + }) + }) + + // ── Hive ──────────────────────────────────────────────────── + + describe("usesHive", () => { + it("when enabled: hive_ce + hive_ce_flutter in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "full" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "hive_ce") + assertDependencyPresent(pubspec, "hive_ce_flutter") + }) + + it("when enabled: hive_ce_generator in dev_dependencies", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "full" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "hive_ce_generator") + }) + + it("when disabled: no hive packages", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "hive_ce") + assertDependencyAbsent(pubspec, "hive_ce_flutter") + }) + + it("when enabled: HiveService.instance.init() in main.dart", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "full" })) + const mainDart = getFileContent(files, "lib/main.dart") + expect(mainDart).toBeDefined() + expect(mainDart!).toContain("HiveService") + }) + }) + + // ── Cached Network Image ──────────────────────────────────── + + describe("usesCachedNetworkImage", () => { + it("when enabled: cached_network_image in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "cached_network_image") + }) + + it("when disabled: no cached_network_image", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "cached_network_image") + }) + }) + + // ── Skeletonizer ──────────────────────────────────────────── + + describe("usesSkeletonizer", () => { + it("when enabled: skeletonizer in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "skeletonizer") + }) + + it("when disabled: no skeletonizer", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "skeletonizer") + }) + }) + + // ── Dio ───────────────────────────────────────────────────── + + describe("usesDio", () => { + it("when enabled: dio in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "full" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "dio") + }) + + it("when disabled: no dio", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "dio") + }) + }) + + // ── Shared Preferences ────────────────────────────────────── + + describe("usesSharedPreferences", () => { + it("when enabled: shared_preferences in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "shared_preferences") + }) + + it("when disabled: no shared_preferences", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "shared_preferences") + }) + }) + + // ── Secure Storage ────────────────────────────────────────── + + describe("usesSecureStorage", () => { + it("when enabled: flutter_secure_storage in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "flutter_secure_storage") + }) + + it("when disabled: no flutter_secure_storage", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "flutter_secure_storage") + }) + }) + + // ── Flutter SVG ───────────────────────────────────────────── + + describe("usesFlutterSvg", () => { + it("when enabled: flutter_svg in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "flutter_svg") + }) + + it("when disabled: no flutter_svg", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "flutter_svg") + }) + }) + + // ── Native Splash ─────────────────────────────────────────── + + describe("usesFlutterNativeSplash", () => { + it("when enabled: flutter_native_splash in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "flutter_native_splash") + }) + + it("when enabled: FlutterNativeSplash.preserve in main.dart", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) + const mainDart = getFileContent(files, "lib/main.dart") + expect(mainDart).toContain("FlutterNativeSplash.preserve") + }) + + it("when disabled: no flutter_native_splash", async () => { + const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "flutter_native_splash") + }) + }) + + // ── Localization ──────────────────────────────────────────── + + describe("localization", () => { + it("when enabled: easy_localization in pubspec", async () => { + // Default buildConfig has localization.enabled: true + const files = await generateToMap(buildConfig(base)) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "easy_localization") + }) + + it("when enabled: EasyLocalization.ensureInitialized in main.dart", async () => { + const files = await generateToMap(buildConfig(base)) + const mainDart = getFileContent(files, "lib/main.dart") + expect(mainDart).toContain("EasyLocalization.ensureInitialized") + }) + + it("when enabled: translation JSON files exist", async () => { + const files = await generateToMap(buildConfig(base)) + const translationFiles = [...files.keys()].filter((f) => + f.includes("translations/") && f.endsWith(".json") + ) + expect(translationFiles.length).toBeGreaterThanOrEqual(2) // en.json + es.json + }) + }) +}) diff --git a/tests/unit/navigation.spec.ts b/tests/unit/navigation.spec.ts new file mode 100644 index 0000000..f5ffac8 --- /dev/null +++ b/tests/unit/navigation.spec.ts @@ -0,0 +1,91 @@ +/** + * navigation.spec.ts + * + * Focused tests per navigation option. + * Verifies correct routing packages, router file content, + * and absence of other navigation code. + */ + +import { describe, expect, it } from "vitest" + +import { + assertDependencyAbsent, + assertDependencyPresent, + getFileContent +} from "../utils/assertions" +import { generateToMap, getPubspecContent } from "../utils/generate" +import { buildConfig, type Combination } from "../utils/matrix.config" + +const base: Omit = { + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", + miscProfile: "default", +} + +describe("Navigation", () => { + // ── go_router ─────────────────────────────────────────────── + + describe("go_router", () => { + it("includes go_router in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, navigation: "go_router" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "go_router") + }) + + it("does not include auto_route", async () => { + const files = await generateToMap(buildConfig({ ...base, navigation: "go_router" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "auto_route") + assertDependencyAbsent(pubspec, "auto_route_generator") + }) + + it("generates router configuration file", async () => { + const files = await generateToMap(buildConfig({ ...base, navigation: "go_router" })) + const routerFile = getFileContent(files, "app_router.dart") + expect(routerFile).toBeDefined() + }) + }) + + // ── auto_route ────────────────────────────────────────────── + + describe("auto_route", () => { + it("includes auto_route in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, navigation: "auto_route" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "auto_route") + }) + + it("includes auto_route_generator in dev_dependencies", async () => { + const files = await generateToMap(buildConfig({ ...base, navigation: "auto_route" })) + const pubspec = getPubspecContent(files) + assertDependencyPresent(pubspec, "auto_route_generator") + assertDependencyPresent(pubspec, "build_runner") + }) + + it("does not include go_router", async () => { + const files = await generateToMap(buildConfig({ ...base, navigation: "auto_route" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "go_router") + }) + }) + + // ── imperative ────────────────────────────────────────────── + + describe("imperative (Navigator 1.0)", () => { + it("does not include any routing package", async () => { + const files = await generateToMap(buildConfig({ ...base, navigation: "imperative" })) + const pubspec = getPubspecContent(files) + assertDependencyAbsent(pubspec, "go_router") + assertDependencyAbsent(pubspec, "auto_route") + assertDependencyAbsent(pubspec, "auto_route_generator") + }) + + it("still generates app_router.dart", async () => { + const files = await generateToMap(buildConfig({ ...base, navigation: "imperative" })) + // Even imperative navigation should have a router config file + const routerFile = getFileContent(files, "app_router.dart") + expect(routerFile).toBeDefined() + }) + }) +}) diff --git a/tests/unit/state-management.spec.ts b/tests/unit/state-management.spec.ts new file mode 100644 index 0000000..81ed825 --- /dev/null +++ b/tests/unit/state-management.spec.ts @@ -0,0 +1,120 @@ +/** + * state-management.spec.ts + * + * Focused tests per state management option. + * Verifies that the correct wrapper patterns, imports, and file structures + * are generated for each state manager, and that other state managers' code + * is completely absent (no option bleed). + */ + +import { describe, expect, it } from "vitest" + +import { assertFileContains, assertFileNotContains, getFileContent } from "../utils/assertions" +import { generateToMap, getPubspecContent } from "../utils/generate" +import { buildConfig, type Combination } from "../utils/matrix.config" + +/** Base combo — we vary only the stateManagement field */ +const base: Omit = { + architecture: "feature-first", + backend: "none", + navigation: "go_router", + miscProfile: "default", +} + +describe("State Management", () => { + // ── Riverpod ──────────────────────────────────────────────── + + describe("riverpod", () => { + it("wraps app with ProviderScope", async () => { + const files = await generateToMap(buildConfig({ ...base, stateManagement: "riverpod" })) + assertFileContains(files, "state_wrapper.dart", "ProviderScope") + }) + + it("does not contain Bloc or Provider patterns", async () => { + const files = await generateToMap(buildConfig({ ...base, stateManagement: "riverpod" })) + assertFileNotContains(files, "state_wrapper.dart", "MultiBlocProvider") + assertFileNotContains(files, "state_wrapper.dart", "MultiProvider") + }) + + it("includes hooks_riverpod when hooks enabled", async () => { + const files = await generateToMap( + buildConfig({ ...base, stateManagement: "riverpod", miscProfile: "hooks" }) + ) + const pubspec = getPubspecContent(files) + expect(pubspec).toContain("hooks_riverpod") + }) + }) + + // ── Provider ──────────────────────────────────────────────── + + describe("provider", () => { + it("wraps app with MultiProvider", async () => { + const files = await generateToMap(buildConfig({ ...base, stateManagement: "provider" })) + assertFileContains(files, "state_wrapper.dart", "MultiProvider") + }) + + it("does not contain Riverpod or Bloc patterns", async () => { + const files = await generateToMap(buildConfig({ ...base, stateManagement: "provider" })) + assertFileNotContains(files, "state_wrapper.dart", "ProviderScope") + assertFileNotContains(files, "state_wrapper.dart", "MultiBlocProvider") + }) + }) + + // ── Bloc ──────────────────────────────────────────────────── + + describe("bloc", () => { + it("wraps app with MultiBlocProvider", async () => { + const files = await generateToMap(buildConfig({ ...base, stateManagement: "bloc" })) + assertFileContains(files, "state_wrapper.dart", "MultiBlocProvider") + }) + + it("does not contain Riverpod or Provider patterns", async () => { + const files = await generateToMap(buildConfig({ ...base, stateManagement: "bloc" })) + assertFileNotContains(files, "state_wrapper.dart", "ProviderScope") + assertFileNotContains(files, "state_wrapper.dart", "MultiProvider") + }) + }) + + // ── MobX ──────────────────────────────────────────────────── + + describe("mobx", () => { + it("includes mobx and flutter_mobx in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, stateManagement: "mobx" })) + const pubspec = getPubspecContent(files) + expect(pubspec).toContain("mobx:") + expect(pubspec).toContain("flutter_mobx:") + }) + + it("includes build_runner and mobx_codegen in dev_dependencies", async () => { + const files = await generateToMap(buildConfig({ ...base, stateManagement: "mobx" })) + const pubspec = getPubspecContent(files) + expect(pubspec).toContain("build_runner:") + expect(pubspec).toContain("mobx_codegen:") + }) + }) + + // ── None (setState) ───────────────────────────────────────── + + describe("none (setState)", () => { + it("does not generate StateWrapper", async () => { + const files = await generateToMap(buildConfig({ ...base, stateManagement: "none" })) + + // main.dart should NOT wrap with StateWrapper + const mainDart = getFileContent(files, "lib/main.dart") + expect(mainDart).toBeDefined() + expect(mainDart).not.toContain("StateWrapper") + }) + + it("has no state management packages in pubspec", async () => { + const files = await generateToMap(buildConfig({ ...base, stateManagement: "none" })) + const pubspec = getPubspecContent(files) + + expect(pubspec).not.toContain("flutter_riverpod:") + // Use regex to match standalone "provider:" (not "path_provider:" or "shared_preferences:") + expect(pubspec).not.toMatch(/^\s+provider:\s/m) + expect(pubspec).not.toContain("flutter_bloc:") + expect(pubspec).not.toMatch(/^\s+mobx:\s/m) + expect(pubspec).not.toContain("flutter_mobx:") + }) + }) +}) diff --git a/tests/unit/structural.spec.ts b/tests/unit/structural.spec.ts new file mode 100644 index 0000000..33bdc4e --- /dev/null +++ b/tests/unit/structural.spec.ts @@ -0,0 +1,59 @@ +/** + * structural.spec.ts + * + * Verifies that every generated project has the correct file structure: + * - Required files always present (pubspec.yaml, lib/main.dart, etc.) + * - No empty files + * - Architecture-appropriate directories exist + */ + +import { describe, it } from "vitest" + +import { + assertArchitectureStructure, + assertNoEmptyFiles, + assertRequiredFilesExist, +} from "../utils/assertions" +import { generateToMap } from "../utils/generate" +import { + ALL_COMBINATIONS, + buildConfig, + combinationLabel, +} from "../utils/matrix.config" + +describe("Structural Integrity", { timeout: 600_000 }, () => { + const combinations = ALL_COMBINATIONS + + it.each(combinations.map((c, i) => [i, combinationLabel(c), c] as const))( + "combo #%i — %s has all required files", + { timeout: 15_000 }, + async (_index, _label, combo) => { + const config = buildConfig(combo) + const files = await generateToMap(config) + + assertRequiredFilesExist(files) + } + ) + + it.each(combinations.map((c, i) => [i, combinationLabel(c), c] as const))( + "combo #%i — %s has no empty files", + { timeout: 15_000 }, + async (_index, _label, combo) => { + const config = buildConfig(combo) + const files = await generateToMap(config) + + assertNoEmptyFiles(files) + } + ) + + it.each(combinations.map((c, i) => [i, combinationLabel(c), c] as const))( + "combo #%i — %s has correct architecture structure", + { timeout: 15_000 }, + async (_index, _label, combo) => { + const config = buildConfig(combo) + const files = await generateToMap(config) + + assertArchitectureStructure(files, combo.architecture) + } + ) +}) diff --git a/tests/unit/token-cleanliness.spec.ts b/tests/unit/token-cleanliness.spec.ts new file mode 100644 index 0000000..c09a33b --- /dev/null +++ b/tests/unit/token-cleanliness.spec.ts @@ -0,0 +1,37 @@ +/** + * token-cleanliness.spec.ts + * + * The highest-value Layer 1 test. + * Scans every file in every generated project for unresolved Handlebars tokens. + * A single unresolved token means the template engine failed to process a variable, + * which will cause a Dart compilation failure. + * + * Runs against ALL valid combinations. This is the most comprehensive guard. + */ + +import { describe, it } from "vitest" + +import { assertNoUnresolvedTokens } from "../utils/assertions" +import { generateToMap } from "../utils/generate" +import { + ALL_COMBINATIONS, + buildConfig +} from "../utils/matrix.config" + +describe("Token Cleanliness — No Unresolved Handlebars Tokens", { timeout: 600_000 }, () => { + // Use a subset for faster iteration during development. + // In CI Tier 1, the full ALL_COMBINATIONS array runs. + const combinations = ALL_COMBINATIONS + + it.each(combinations.map((c, i) => [i, c] as const))( + "combo #%i — %s has zero unresolved tokens", + { timeout: 15_000 }, + async (_index, combo) => { + const config = buildConfig(combo) + const files = await generateToMap(config) + + // This will throw with a detailed message if any tokens remain + assertNoUnresolvedTokens(files) + } + ) +}) // 10 minute overall timeout for the full suite diff --git a/tests/utils/assertions.ts b/tests/utils/assertions.ts new file mode 100644 index 0000000..3e0167d --- /dev/null +++ b/tests/utils/assertions.ts @@ -0,0 +1,357 @@ +/** + * assertions.ts + * + * Shared assertion helpers used across all test layers. + * Each helper is designed to produce clear, actionable failure messages. + */ + +import { parse as parseYaml } from "yaml" + +// ── Token cleanliness ─────────────────────────────────────────────── + +/** + * Patterns that indicate unresolved Handlebars tokens. + * Triple-braces ({{{ }}}) are checked first since they are a superset. + */ +const HANDLEBARS_PATTERNS = [ + /\{\{\{[^}]+\}\}\}/g, // triple-brace (unescaped) + /\{\{[#/][^}]+\}\}/g, // block helpers ({{#if ...}}, {{/if}}) + /\{\{[^!][^}]*\}\}/g, // regular tokens (excludes {{! comments }}) +] + +export interface TokenViolation { + file: string + match: string + line: number +} + +/** + * Scans all files for unresolved Handlebars tokens. + * Returns an array of violations — empty means clean. + */ +export function findUnresolvedTokens( + files: Map +): TokenViolation[] { + const violations: TokenViolation[] = [] + + for (const [filePath, content] of files) { + // Skip binary-looking files + if (filePath.endsWith(".png") || filePath.endsWith(".jpg") || filePath.endsWith(".ico")) { + continue + } + + const lines = content.split("\n") + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + for (const pattern of HANDLEBARS_PATTERNS) { + // Reset regex state + pattern.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = pattern.exec(line)) !== null) { + violations.push({ + file: filePath, + match: match[0], + line: i + 1, + }) + } + } + } + } + + return violations +} + +/** + * Assert helper: throws with a descriptive message if unresolved tokens found. + */ +export function assertNoUnresolvedTokens(files: Map): void { + const violations = findUnresolvedTokens(files) + if (violations.length > 0) { + const details = violations + .slice(0, 10) // cap output for readability + .map((v) => ` ${v.file}:${v.line} → ${v.match}`) + .join("\n") + const extra = violations.length > 10 ? `\n ... and ${violations.length - 10} more` : "" + throw new Error( + `Found ${violations.length} unresolved Handlebars token(s):\n${details}${extra}` + ) + } +} + +// ── Empty file detection ──────────────────────────────────────────── + +export function findEmptyFiles(files: Map): string[] { + const empty: string[] = [] + const ignorableNames = [ + ".keep", + ".gitkeep", + "flutter_native_splash.yaml", + "screen_util_wrapper.dart", + "hooks.dart", + "services.dart", + "widgets.dart", + "wrappers.dart", + "imports.dart", + "core_imports.dart", + "packages_imports.dart", + ] + + for (const [filePath, content] of files) { + const basename = filePath.split("/").pop()! + if (ignorableNames.includes(basename)) { + continue + } + // Skip anything in hooks directory + if (filePath.includes("/shared/hooks/")) { + continue + } + + if (content.trim().length === 0) { + empty.push(filePath) + } + } + return empty +} + +export function assertNoEmptyFiles(files: Map): void { + const empty = findEmptyFiles(files) + if (empty.length > 0) { + throw new Error( + `Found ${empty.length} empty file(s):\n${empty.map((f) => ` ${f}`).join("\n")}` + ) + } +} + +// ── Required files ────────────────────────────────────────────────── + +const ALWAYS_REQUIRED_FILES = [ + "pubspec.yaml", + "lib/main.dart", + "analysis_options.yaml", +] + +export function assertRequiredFilesExist(files: Map): void { + const filePaths = [...files.keys()] + const missing: string[] = [] + + for (const required of ALWAYS_REQUIRED_FILES) { + const found = filePaths.some( + (f) => f === required || f.endsWith(`/${required}`) + ) + if (!found) { + missing.push(required) + } + } + + if (missing.length > 0) { + throw new Error( + `Missing required file(s):\n${missing.map((f) => ` ${f}`).join("\n")}` + ) + } +} + +// ── Dependency assertions ─────────────────────────────────────────── + +/** + * Parse the pubspec.yaml content and return the dependency names as Sets. + */ +export function parsePubspecDeps(pubspecContent: string): { + dependencies: Set + devDependencies: Set +} { + const doc = parseYaml(pubspecContent) as Record + const deps = doc.dependencies as Record | undefined + const devDeps = doc.dev_dependencies as Record | undefined + + return { + dependencies: new Set(deps ? Object.keys(deps) : []), + devDependencies: new Set(devDeps ? Object.keys(devDeps) : []), + } +} + +/** + * Assert a package IS present in the dependencies section. + */ +export function assertDependencyPresent( + pubspecContent: string, + packageName: string +): void { + const { dependencies, devDependencies } = parsePubspecDeps(pubspecContent) + if (!dependencies.has(packageName) && !devDependencies.has(packageName)) { + throw new Error( + `Expected package "${packageName}" to be in pubspec.yaml dependencies, but it was not found.` + ) + } +} + +/** + * Assert a package IS NOT present in the dependencies section. + */ +export function assertDependencyAbsent( + pubspecContent: string, + packageName: string +): void { + const { dependencies, devDependencies } = parsePubspecDeps(pubspecContent) + if (dependencies.has(packageName) || devDependencies.has(packageName)) { + throw new Error( + `Expected package "${packageName}" to NOT be in pubspec.yaml, but it was found. (Option bleed)` + ) + } +} + +/** + * Assert the pubspec.yaml content is valid YAML and has the basic structure. + */ +export function assertValidPubspec(pubspecContent: string): void { + let doc: Record + try { + doc = parseYaml(pubspecContent) as Record + } catch (e) { + throw new Error(`pubspec.yaml is not valid YAML: ${(e as Error).message}`) + } + + if (!doc.name || typeof doc.name !== "string") { + throw new Error("pubspec.yaml missing 'name' field") + } + if (!doc.environment) { + throw new Error("pubspec.yaml missing 'environment' field") + } + if (!doc.dependencies) { + throw new Error("pubspec.yaml missing 'dependencies' field") + } + + // Verify all dependency versions match semver-ish pattern + const deps = doc.dependencies as Record + for (const [pkg, version] of Object.entries(deps)) { + if (typeof version === "string") { + // Must start with ^ or >= or be a semver range + if (!version.match(/^[\^~>=<\s]*\d+\.\d+/)) { + throw new Error( + `pubspec.yaml dependency "${pkg}" has invalid version: "${version}"` + ) + } + } + // SDK dependencies (like flutter) are objects — skip those + } +} + +// ── Architecture structure assertions ─────────────────────────────── + +const ARCHITECTURE_MARKERS: Record = { + clean: { + required: ["domain", "data", "presentation"], + forbidden: [], + }, + mvvm: { + required: ["ui", "data"], + forbidden: [], + }, + mvc: { + required: ["models", "views", "controllers"], + forbidden: [], + }, + "feature-first": { + required: ["features"], + forbidden: [], + }, + "layer-first": { + required: ["data", "domain", "presentation"], + forbidden: [], + }, +} + +/** + * Assert the generated file tree contains architecture-appropriate directories. + */ +export function assertArchitectureStructure( + files: Map, + architecture: string +): void { + const markers = ARCHITECTURE_MARKERS[architecture] + if (!markers) return // no markers defined for this architecture + + const filePaths = [...files.keys()] + + for (const required of markers.required) { + const found = filePaths.some((f) => f.includes(`/${required}/`) || f.includes(`/${required}`)) + if (!found) { + throw new Error( + `Architecture "${architecture}" should contain "${required}" in the file tree, but it was not found.` + ) + } + } +} + +// ── Content assertions ────────────────────────────────────────────── + +/** + * Assert that a specific file contains a pattern (string or regex). + */ +export function assertFileContains( + files: Map, + filePath: string, + pattern: string | RegExp +): void { + const matchingEntry = [...files.entries()].find( + ([f]) => f === filePath || f.endsWith(`/${filePath}`) + ) + + if (!matchingEntry) { + throw new Error(`File "${filePath}" not found in generated output.`) + } + + const [, content] = matchingEntry + const matches = + typeof pattern === "string" + ? content.includes(pattern) + : pattern.test(content) + + if (!matches) { + throw new Error( + `File "${filePath}" does not contain expected pattern: ${pattern}` + ) + } +} + +/** + * Assert that a specific file does NOT contain a pattern. + */ +export function assertFileNotContains( + files: Map, + filePath: string, + pattern: string | RegExp +): void { + const matchingEntry = [...files.entries()].find( + ([f]) => f === filePath || f.endsWith(`/${filePath}`) + ) + + if (!matchingEntry) return // File not found = not containing the pattern + + const [, content] = matchingEntry + const matches = + typeof pattern === "string" + ? content.includes(pattern) + : pattern.test(content) + + if (matches) { + throw new Error( + `File "${filePath}" should NOT contain pattern: ${pattern}` + ) + } +} + +/** + * Get the content of a file from the generated file map. + * Matches on exact path or suffix. + */ +export function getFileContent( + files: Map, + filePath: string +): string | undefined { + for (const [f, content] of files) { + if (f === filePath || f.endsWith(`/${filePath}`)) { + return content + } + } + return undefined +} diff --git a/tests/utils/critical-combos.ts b/tests/utils/critical-combos.ts new file mode 100644 index 0000000..b44839d --- /dev/null +++ b/tests/utils/critical-combos.ts @@ -0,0 +1,153 @@ +/** + * critical-combos.ts + * + * Hand-selected ~30 combinations for Tier 2 CI (PR checks). + * Selection criteria: + * - Every individual option value appears in at least 3 combinations + * - Covers the most commonly used combos (riverpod + go_router + feature-first) + * - Includes edge cases ("none" options, complex backend combos) + * - Includes all 4 misc profiles + */ + +import type { Combination } from "./matrix.config" + +/** + * Critical combinations for Tier 2 testing. + * Each combo is selected to maximize coverage per option value. + */ +export const CRITICAL_COMBINATIONS: Combination[] = [ + // ── Most popular combos ──────────────────────────────────────── + + // 1. The "golden path" — most common user choice + { architecture: "feature-first", stateManagement: "riverpod", backend: "firebase", navigation: "go_router", miscProfile: "default" }, + + // 2. Second most popular setup + { architecture: "clean", stateManagement: "bloc", backend: "supabase", navigation: "go_router", miscProfile: "default" }, + + // 3. Provider + simple setup + { architecture: "mvvm", stateManagement: "provider", backend: "none", navigation: "go_router", miscProfile: "default" }, + + // ── Full misc profile ────────────────────────────────────────── + + // 4. Everything on + { architecture: "feature-first", stateManagement: "riverpod", backend: "firebase", navigation: "go_router", miscProfile: "full" }, + + // 5. Full + auto_route + { architecture: "clean", stateManagement: "bloc", backend: "supabase", navigation: "auto_route", miscProfile: "full" }, + + // 6. Full + imperative + { architecture: "mvc", stateManagement: "provider", backend: "appwrite", navigation: "imperative", miscProfile: "full" }, + + // ── Minimal misc profile ─────────────────────────────────────── + + // 7. Bare minimum — no backend (custom+minimal is invalid) + { architecture: "layer-first", stateManagement: "none", backend: "none", navigation: "imperative", miscProfile: "minimal" }, + + // 8. Minimal with firebase + { architecture: "mvvm", stateManagement: "riverpod", backend: "firebase", navigation: "auto_route", miscProfile: "minimal" }, + + // 9. Minimal with supabase + { architecture: "feature-first", stateManagement: "bloc", backend: "supabase", navigation: "go_router", miscProfile: "minimal" }, + + // ── Hooks misc profile ───────────────────────────────────────── + + // 10. Hooks + riverpod + { architecture: "clean", stateManagement: "riverpod", backend: "none", navigation: "go_router", miscProfile: "hooks" }, + + // 11. Hooks + provider + { architecture: "feature-first", stateManagement: "provider", backend: "firebase", navigation: "auto_route", miscProfile: "hooks" }, + + // 12. Hooks + bloc + { architecture: "mvvm", stateManagement: "bloc", backend: "appwrite", navigation: "imperative", miscProfile: "hooks" }, + + // ── "None" state management ──────────────────────────────────── + + // 13. None state + go_router + { architecture: "feature-first", stateManagement: "none", backend: "none", navigation: "go_router", miscProfile: "default" }, + + // 14. None state + auto_route + { architecture: "clean", stateManagement: "none", backend: "supabase", navigation: "auto_route", miscProfile: "full" }, + + // 15. None state + firebase + { architecture: "mvc", stateManagement: "none", backend: "firebase", navigation: "imperative", miscProfile: "hooks" }, + + // ── MobX coverage ────────────────────────────────────────────── + + // 16. MobX + clean + { architecture: "clean", stateManagement: "mobx", backend: "none", navigation: "go_router", miscProfile: "default" }, + + // 17. MobX + feature-first + { architecture: "feature-first", stateManagement: "mobx", backend: "firebase", navigation: "auto_route", miscProfile: "full" }, + + // 18. MobX + mvvm + { architecture: "mvvm", stateManagement: "mobx", backend: "supabase", navigation: "imperative", miscProfile: "minimal" }, + + // ── Appwrite backend ─────────────────────────────────────────── + + // 19. Appwrite + riverpod + { architecture: "layer-first", stateManagement: "riverpod", backend: "appwrite", navigation: "go_router", miscProfile: "default" }, + + // 20. Appwrite + bloc + { architecture: "feature-first", stateManagement: "bloc", backend: "appwrite", navigation: "auto_route", miscProfile: "minimal" }, + + // ── Custom backend ───────────────────────────────────────────── + + // 21. Custom + full (full has usesDio: true) + { architecture: "clean", stateManagement: "riverpod", backend: "custom", navigation: "go_router", miscProfile: "full" }, + + // 22. Custom + hooks (hooks has usesDio: true) + { architecture: "mvvm", stateManagement: "provider", backend: "custom", navigation: "auto_route", miscProfile: "hooks" }, + + // 23. Custom + default (default has usesDio: false, usesHttp: false — but we need dio for custom) + // NOTE: default profile doesn't have a networking client, so we use full instead + { architecture: "layer-first", stateManagement: "bloc", backend: "custom", navigation: "imperative", miscProfile: "full" }, + + // ── Architecture coverage ────────────────────────────────────── + + // 24. MVC + various + { architecture: "mvc", stateManagement: "riverpod", backend: "supabase", navigation: "auto_route", miscProfile: "default" }, + + // 25. MVC + bloc + { architecture: "mvc", stateManagement: "bloc", backend: "none", navigation: "go_router", miscProfile: "hooks" }, + + // 26. Layer-first + provider + { architecture: "layer-first", stateManagement: "provider", backend: "firebase", navigation: "go_router", miscProfile: "default" }, + + // 27. Layer-first + mobx + { architecture: "layer-first", stateManagement: "mobx", backend: "appwrite", navigation: "auto_route", miscProfile: "hooks" }, + + // ── Edge cases ───────────────────────────────────────────────── + + // 28. Localization off (handled internally by all combos — but let's confirm a clean build) + // This uses the same combos above which all have localization: true; + // A dedicated test will override localization.enabled = false + + // 29. MVVM + none state + imperative (absolute minimum) + { architecture: "mvvm", stateManagement: "none", backend: "none", navigation: "imperative", miscProfile: "minimal" }, + + // 30. Layer-first + none + auto_route + { architecture: "layer-first", stateManagement: "none", backend: "appwrite", navigation: "auto_route", miscProfile: "default" }, +] + +/** + * Verify coverage: count how many times each option value appears. + * Returns a map of dimension → value → count. + */ +export function getCoverageReport(): Record> { + const report: Record> = { + architecture: {}, + stateManagement: {}, + backend: {}, + navigation: {}, + miscProfile: {}, + } + + for (const combo of CRITICAL_COMBINATIONS) { + for (const dimension of Object.keys(report)) { + const value = combo[dimension as keyof Combination] + report[dimension][value] = (report[dimension][value] || 0) + 1 + } + } + + return report +} diff --git a/tests/utils/custom-reporter.ts b/tests/utils/custom-reporter.ts new file mode 100644 index 0000000..0b248ae --- /dev/null +++ b/tests/utils/custom-reporter.ts @@ -0,0 +1,482 @@ +import type { + Reporter, + SerializedError, + TestCase, + TestModule, + TestSpecification, + Vitest, +} from "vitest/node" + +// ─── ANSI ──────────────────────────────────────────────────────────────────── + +const C = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + italic: "\x1b[3m", + + black: "\x1b[30m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", + + bgBlack: "\x1b[40m", + bgRed: "\x1b[41m", + bgGreen: "\x1b[42m", + bgBlue: "\x1b[44m", + bgCyan: "\x1b[46m", + + brightRed: "\x1b[91m", + brightGreen: "\x1b[92m", + brightYellow: "\x1b[93m", + brightBlue: "\x1b[94m", + brightMagenta: "\x1b[95m", + brightCyan: "\x1b[96m", + brightWhite: "\x1b[97m", +} + +const b = (s: string) => `${C.bold}${s}${C.reset}` +const d = (s: string) => `${C.dim}${s}${C.reset}` +const g = (s: string) => `${C.brightGreen}${s}${C.reset}` +const r = (s: string) => `${C.brightRed}${s}${C.reset}` +const y = (s: string) => `${C.brightYellow}${s}${C.reset}` +const c = (s: string) => `${C.brightCyan}${s}${C.reset}` +const mg = (s: string) => `${C.brightMagenta}${s}${C.reset}` +const gr = (s: string) => `${C.gray}${s}${C.reset}` +const w = (s: string) => `${C.brightWhite}${s}${C.reset}` + +// ─── BOX DRAWING ───────────────────────────────────────────────────────────── + +const BOX = { + tl: "╭", tr: "╮", + bl: "╰", br: "╯", + h: "─", v: "│", + ml: "├", mr: "┤", + mt: "┬", mb: "┴", +} + +const TERMINAL_WIDTH = process.stdout.columns || 80 + +function line(char = BOX.h, width = TERMINAL_WIDTH - 2) { + return char.repeat(width) +} + +function boxTop(width = TERMINAL_WIDTH) { return `${C.gray}${BOX.tl}${line(BOX.h, width - 2)}${BOX.tr}${C.reset}` } +function boxMid(width = TERMINAL_WIDTH) { return `${C.gray}${BOX.ml}${line(BOX.h, width - 2)}${BOX.mr}${C.reset}` } +function boxBottom(width = TERMINAL_WIDTH) { return `${C.gray}${BOX.bl}${line(BOX.h, width - 2)}${BOX.br}${C.reset}` } + +function boxRow(content: string, width = TERMINAL_WIDTH) { + const visible = stripAnsi(content) + const pad = width - 2 - visible.length + return `${C.gray}${BOX.v}${C.reset} ${content}${" ".repeat(Math.max(0, pad - 1))}${C.gray}${BOX.v}${C.reset}` +} + +function padCenter(text: string, width: number): string { + const vis = stripAnsi(text) + const total = Math.max(0, width - vis.length) + const left = Math.floor(total / 2) + const right = total - left + return " ".repeat(left) + text + " ".repeat(right) +} + +// ─── HELPERS ───────────────────────────────────────────────────────────────── + +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, "") +} + +function formatDuration(ms: number): string { + if (!ms || !isFinite(ms) || ms <= 0) return "0s" + const s = Math.floor(ms / 1000) + const m = Math.floor(s / 60) + const h = Math.floor(m / 60) + if (h > 0) return `${h}h ${m % 60}m ${s % 60}s` + if (m > 0) return `${m}m ${s % 60}s` + return `${s}s` +} + +function formatMs(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms` + return formatDuration(ms) +} + +function shortPath(fullPath: string): string { + // Show only last 2 path segments + const parts = fullPath.replace(/\\/g, "/").split("/") + return parts.slice(-2).join("/") +} + +function progressBar(percent: number, width = 28): string { + const filled = Math.round((percent / 100) * width) + const empty = width - filled + + const fill = percent === 100 + ? `${C.brightGreen}${"█".repeat(filled)}${C.reset}` + : filled > 0 + ? `${C.brightCyan}${"█".repeat(filled - 1)}${C.brightWhite}▓${C.reset}` + : "" + + const emp = `${C.gray}${"░".repeat(empty)}${C.reset}` + return fill + emp +} + +function sparkline(history: number[], width = 10): string { + const bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] + const slice = history.slice(-width) + if (slice.length === 0) return "" + const max = Math.max(...slice, 1) + return slice.map(v => { + const idx = Math.min(7, Math.floor((v / max) * 7)) + return `${C.cyan}${bars[idx]}${C.reset}` + }).join("") +} + +// ─── REPORTER ──────────────────────────────────────────────────────────────── + +interface FailedTest { + file: string + name: string + error: string +} + +interface FileStat { + name: string + total: number + passed: number + failed: number + durationMs: number +} + +export default class MatrixReporter implements Reporter { + private startTime = 0 + private totalTests = 0 + private completedTests = 0 + private passedTests = 0 + private failedTests = 0 + private skippedTests = 0 + + private currentFile = "" + private currentTest = "" + private failures: FailedTest[] = [] + private fileStats: FileStat[] = [] + private activeFileStart = 0 + + // Speed tracking + private speedHistory: number[] = [] // tests/sec samples + private lastSpeedSample = 0 + private lastSpeedCount = 0 + + // Dynamic line tracking + private progressLines = 0 + private lastRenderLen = 0 + + onInit(_vitest: Vitest) { + this.startTime = Date.now() + this.render_header() + } + + onTestRunStart(_specs: ReadonlyArray) { + // total accumulates in onTestModuleCollected + } + + onTestModuleCollected(mod: TestModule) { + const count = Array.from(mod.children.allTests()).length + this.totalTests += count + } + + onTestModuleStart(mod: TestModule) { + this.currentFile = shortPath(mod.relativeModuleId) + this.activeFileStart = Date.now() + } + + onTestModuleEnd(mod: TestModule) { + const tests = Array.from(mod.children.allTests()) + const passed = tests.filter(t => t.result()?.state === "passed").length + const failed = tests.filter(t => t.result()?.state === "failed").length + const dur = Date.now() - this.activeFileStart + + this.fileStats.push({ + name: shortPath(mod.relativeModuleId), + total: tests.length, + passed, + failed, + durationMs: dur, + }) + } + + onTestCaseResult(testCase: TestCase) { + const state = testCase.result()?.state + + this.completedTests++ + this.currentTest = testCase.name + + if (state === "passed") this.passedTests++ + else if (state === "failed") { + this.failedTests++ + const err = testCase.result()?.errors?.[0] + this.failures.push({ + file: this.currentFile, + name: testCase.name, + error: err?.message?.split("\n")[0] ?? "Unknown error", + }) + } else if (state === "skipped") this.skippedTests++ + + // Speed sampling every 500ms + const now = Date.now() + if (now - this.lastSpeedSample >= 500) { + const dt = (now - this.lastSpeedSample) / 1000 + const delta = this.completedTests - this.lastSpeedCount + const tps = delta / dt + this.speedHistory.push(tps) + if (this.speedHistory.length > 20) this.speedHistory.shift() + this.lastSpeedSample = now + this.lastSpeedCount = this.completedTests + } + + this.render_progress() + } + + onTestRunEnd( + _modules: ReadonlyArray, + unhandledErrors: ReadonlyArray + ) { + this.clearProgress() + this.render_summary(unhandledErrors) + } + + // ─── RENDER: HEADER ──────────────────────────────────────────────────── + + private render_header() { + const W = TERMINAL_WIDTH + + const subtitle = `${C.reset} ${C.brightWhite}FlutterInit Tests${C.reset} ${C.dim}` + const timestamp = new Date().toLocaleTimeString() + + console.log() + console.log(boxTop(W)) + console.log(boxRow("")) + console.log(boxRow(padCenter(subtitle, W - 2))) + console.log(boxRow("")) + console.log(boxRow( + padCenter( + `${gr("TIMELINE")} ${c(timestamp)} ${gr("PROCESS")} ${c(String(process.pid))} ${gr("TARGET")} ${c("Flutter")}`, + W - 2 + ) + )) + console.log(boxRow("")) + console.log(boxBottom(W)) + console.log() + } + + // ─── RENDER: LIVE PROGRESS ───────────────────────────────────────────── + + private render_progress() { + const W = TERMINAL_WIDTH + const now = Date.now() + + const elapsed = now - this.startTime + const pct = this.totalTests > 0 + ? (this.completedTests / this.totalTests) * 100 + : 0 + + const msPerTest = this.completedTests > 0 ? elapsed / this.completedTests : 0 + const remaining = msPerTest * (this.totalTests - this.completedTests) + + const speed = this.speedHistory.length > 0 + ? this.speedHistory[this.speedHistory.length - 1] + : 0 + + // Truncate test name to fit + const maxName = W - 20 + const testName = this.currentTest.length > maxName + ? "…" + this.currentTest.slice(-(maxName - 1)) + : this.currentTest + + // Build lines + const bar = progressBar(pct, 30) + const pctStr = `${pct.toFixed(1)}%` + const countStr = `${c(String(this.completedTests))}${gr("/")}${w(String(this.totalTests))}` + const timeStr = `${g(formatDuration(elapsed))} ${gr("elapsed")} ~${y(formatDuration(remaining))} ${gr("left")}` + const speedStr = speed > 0 + ? `${c(speed.toFixed(1))} ${gr("t/s")} ${sparkline(this.speedHistory, 12)}` + : gr("calculating…") + + const passStr = `${C.brightGreen}✔ ${this.passedTests}${C.reset}` + const failStr = this.failedTests > 0 + ? `${C.brightRed}✖ ${this.failedTests}${C.reset}` + : `${C.gray}✖ 0${C.reset}` + const skipStr = `${C.yellow}⊘ ${this.skippedTests}${C.reset}` + + const fileStr = `${gr("▸")} ${c(this.currentFile)}` + const nameStr = `${gr(" ›")} ${d(testName)}` + + // Clear previous render + this.clearProgress() + + const lines: string[] = [ + "", + ` ${bar} ${b(pctStr.padStart(6))} ${countStr}`, + ` ${timeStr}`, + ` ${speedStr} ${passStr} ${failStr} ${skipStr}`, + "", + ` ${fileStr}`, + ` ${nameStr}`, + "", + ] + + // Show last failure inline if any + if (this.failures.length > 0) { + const last = this.failures[this.failures.length - 1] + const errLine = `${r("✖ FAIL")} ${gr(last.file)} ${d("›")} ${w(last.name)}` + const errMsg = ` ${C.red}${C.dim}${last.error.slice(0, W - 6)}${C.reset}` + lines.push(` ${errLine}`) + lines.push(` ${errMsg}`) + lines.push("") + } + + lines.forEach(l => process.stdout.write(l + "\n")) + this.progressLines = lines.length + } + + private clearProgress() { + if (this.progressLines === 0) return + // Move cursor up and clear each line + for (let i = 0; i < this.progressLines; i++) { + process.stdout.write("\x1b[1A\x1b[2K") + } + this.progressLines = 0 + } + + // ─── RENDER: FINAL SUMMARY ───────────────────────────────────────────── + + private render_summary(unhandledErrors: ReadonlyArray) { + const W = TERMINAL_WIDTH + const elapsed = Date.now() - this.startTime + const allPass = this.failedTests === 0 && unhandledErrors.length === 0 + + // ── Header band + console.log() + if (allPass) { + console.log(`${C.bgGreen}${C.black}${C.bold}${padCenter(" ALL TESTS PASSED ", W)}${C.reset}`) + } else { + console.log(`${C.bgRed}${C.brightWhite}${C.bold}${padCenter(` ${this.failedTests} TEST${this.failedTests !== 1 ? "S" : ""} FAILED `, W)}${C.reset}`) + } + console.log() + + // ── Stats box + const avgSpeed = this.speedHistory.length > 0 + ? (this.speedHistory.reduce((a, b) => a + b, 0) / this.speedHistory.length).toFixed(1) + : "—" + + const statRows = [ + [gr("Total Tests"), w(String(this.totalTests))], + [gr("Passed"), g(`✔ ${this.passedTests}`)], + [gr("Failed"), this.failedTests > 0 ? r(`✖ ${this.failedTests}`) : gr("✖ 0")], + [gr("Skipped"), y(`⊘ ${this.skippedTests}`)], + [gr("Duration"), c(formatDuration(elapsed))], + [gr("Avg Speed"), c(`${avgSpeed} tests/sec`)], + ] + + console.log(boxTop(W)) + console.log(boxRow(padCenter(b(" RUN SUMMARY "), W - 2))) + console.log(boxMid(W)) + statRows.forEach(([label, value]) => { + const row = `${label}${" ".repeat(18 - stripAnsi(label).length)}${value}` + console.log(boxRow(row)) + }) + + // ── Sparkline trend + if (this.speedHistory.length > 3) { + console.log(boxRow("")) + const trend = `${gr("Speed trend")} ${sparkline(this.speedHistory, 30)}` + console.log(boxRow(trend)) + } + console.log(boxBottom(W)) + console.log() + + // ── File breakdown + if (this.fileStats.length > 0) { + console.log(boxTop(W)) + console.log(boxRow(padCenter(b(" FILE BREAKDOWN "), W - 2))) + console.log(boxMid(W)) + + const nameW = 36 + const numW = 6 + const header = `${gr("File".padEnd(nameW))} ${gr("Tests".padStart(numW))} ${gr("Pass".padStart(numW))} ${gr("Fail".padStart(numW))} ${gr("Time".padStart(8))}` + console.log(boxRow(header)) + console.log(boxRow(gr(line("─", W - 4)))) + + this.fileStats.forEach(stat => { + const nameTrunc = stat.name.length > nameW + ? "…" + stat.name.slice(-(nameW - 1)) + : stat.name.padEnd(nameW) + + const statusIcon = stat.failed > 0 ? r("✖") : g("✔") + const nameStr = stat.failed > 0 ? r(nameTrunc) : gr(nameTrunc) + const passStr = g(String(stat.passed).padStart(numW)) + const failStr = stat.failed > 0 + ? r(String(stat.failed).padStart(numW)) + : gr(String(0).padStart(numW)) + const timeStr = c(formatMs(stat.durationMs).padStart(8)) + const totalStr = w(String(stat.total).padStart(numW)) + + const row = `${statusIcon} ${nameStr} ${totalStr} ${passStr} ${failStr} ${timeStr}` + console.log(boxRow(row)) + }) + console.log(boxBottom(W)) + console.log() + } + + // ── Failures detail + if (this.failures.length > 0) { + console.log(boxTop(W)) + console.log(boxRow(padCenter(r(` ✖ ${this.failures.length} FAILURE${this.failures.length !== 1 ? "S" : ""} `), W - 2))) + console.log(boxMid(W)) + console.log(boxRow("")) + + this.failures.forEach((fail, i) => { + const idx = r(`[${String(i + 1).padStart(2, "0")}]`) + const file = c(fail.file) + const name = w(fail.name) + const errText = fail.error.slice(0, W - 8) + const err = `${C.red}${C.dim}${errText}${C.reset}` + + console.log(boxRow(`${idx} ${file}`)) + console.log(boxRow(` ${name}`)) + console.log(boxRow(` ${err}`)) + if (i < this.failures.length - 1) { + console.log(boxRow(gr(line("╌", W - 6)))) + } + }) + console.log(boxRow("")) + console.log(boxBottom(W)) + console.log() + } + + // ── Unhandled errors + if (unhandledErrors.length > 0) { + console.log(boxTop(W)) + console.log(boxRow(padCenter(mg(` ⚡ ${unhandledErrors.length} UNHANDLED ERROR${unhandledErrors.length !== 1 ? "S" : ""} `), W - 2))) + console.log(boxMid(W)) + unhandledErrors.forEach((err, i) => { + const msg = (err.message ?? "Unknown").split("\n")[0].slice(0, W - 6) + console.log(boxRow(`${mg(`[${i + 1}]`)} ${r(msg)}`)) + }) + console.log(boxBottom(W)) + console.log() + } + + // ── Final verdict + const verdict = allPass + ? `${C.brightGreen}${C.bold} ✔ All ${this.totalTests} tests passed in ${formatDuration(elapsed)} ${C.reset}` + : `${C.brightRed}${C.bold} ✖ ${this.failedTests} failed · ${this.passedTests} passed · ${formatDuration(elapsed)} ${C.reset}` + + console.log(padCenter(verdict, W)) + console.log() + } +} \ No newline at end of file diff --git a/tests/utils/generate.ts b/tests/utils/generate.ts new file mode 100644 index 0000000..c601215 --- /dev/null +++ b/tests/utils/generate.ts @@ -0,0 +1,102 @@ +/** + * generate.ts + * + * Thin wrapper around the FlutterInit generator for use in tests. + * Provides two modes: + * - generateToMap(): returns files as a Map for in-memory assertions + * - generateToDisk(): writes files to disk for Layer 2 Dart validation + */ + +import fs from "node:fs/promises" +import path from "node:path" + +import JSZip from "jszip" + +import type { ScaffoldConfig } from "@/app/lib/config/schema" +import { generateFlutterScaffold } from "@/app/lib/generator" + +/** + * Generate a Flutter scaffold and return all files as a Map. + * Used by Layer 1 tests for in-memory assertions. + */ +export async function generateToMap( + config: ScaffoldConfig +): Promise> { + const buffer = await generateFlutterScaffold(config) + const zip = await JSZip.loadAsync(buffer) + + const files = new Map() + const entries = Object.entries(zip.files) + + for (const [filePath, zipEntry] of entries) { + if (zipEntry.dir) continue + + // Try to read as text; skip binary files + try { + const content = await zipEntry.async("string") + files.set(filePath, content) + } catch { + // Binary file — store empty string to indicate presence + files.set(filePath, "") + } + } + + return files +} + +/** + * Generate a Flutter scaffold and write all files to disk. + * Used by Layer 2 tests for dart pub get + dart analyze. + * Returns the list of file paths written. + */ +export async function generateToDisk( + config: ScaffoldConfig, + outputDir: string +): Promise { + const buffer = await generateFlutterScaffold(config) + const zip = await JSZip.loadAsync(buffer) + + const writtenPaths: string[] = [] + + for (const [filePath, zipEntry] of Object.entries(zip.files)) { + if (zipEntry.dir) { + await fs.mkdir(path.join(outputDir, filePath), { recursive: true }) + continue + } + + const fullPath = path.join(outputDir, filePath) + await fs.mkdir(path.dirname(fullPath), { recursive: true }) + + const data = await zipEntry.async("nodebuffer") + await fs.writeFile(fullPath, data) + writtenPaths.push(fullPath) + } + + return writtenPaths +} + +/** + * Get the pubspec.yaml content from a generated file map. + * Convenience helper used frequently in tests. + */ +export function getPubspecContent(files: Map): string { + for (const [filePath, content] of files) { + if (filePath === "pubspec.yaml" || filePath.endsWith("/pubspec.yaml")) { + return content + } + } + throw new Error("pubspec.yaml not found in generated files") +} + +/** + * Get a specific file's content from a generated file map. + * Matches on exact path or suffix. + */ +export function getFile(files: Map, name: string): string | undefined { + for (const [filePath, content] of files) { + if (filePath === name || filePath.endsWith(`/${name}`)) { + return content + } + } + return undefined +} diff --git a/tests/utils/matrix.config.ts b/tests/utils/matrix.config.ts new file mode 100644 index 0000000..bef3070 --- /dev/null +++ b/tests/utils/matrix.config.ts @@ -0,0 +1,285 @@ +/** + * matrix.config.ts + * + * Single source of truth for the FlutterInit option space. + * Every test file, CI matrix, and coverage report imports from here. + * + * Dimensions (GetX excluded from both state and navigation): + * Architecture: 5 (mvc, mvvm, clean, feature-first, layer-first) + * State Management: 5 (provider, riverpod, bloc, mobx, none) + * Backend: 5 (none, firebase, supabase, appwrite, custom) + * Navigation: 3 (imperative, go_router, auto_route) + * Misc Profiles: 4 (full, minimal, default, hooks) + * + * Primary combos: 5 × 5 × 5 × 3 = 375 + * With misc profiles: 375 × 4 = 1,500 total (minus invalid) + */ + +import type { + ArchitectureStyle, + BackendConfig, + BackendProvider, + MiscConfig, + NavigationStyle, + ScaffoldConfig, + StateManagement, +} from "@/app/lib/config/schema" +import { defaultBackendConfig } from "@/app/lib/config/schema" + +// ── Primary dimensions ────────────────────────────────────────────── + +export const ARCHITECTURES: readonly ArchitectureStyle[] = [ + "mvc", + "mvvm", + "clean", + "feature-first", + "layer-first", +] as const satisfies readonly ArchitectureStyle[] + +export const STATE_MANAGERS: readonly StateManagement[] = [ + "provider", + "riverpod", + "bloc", + "mobx", + "none", +] as const satisfies readonly StateManagement[] + +export const BACKENDS: readonly BackendProvider[] = [ + "none", + "firebase", + "supabase", + "appwrite", + "custom", +] as const satisfies readonly BackendProvider[] + +export const NAVIGATIONS: readonly NavigationStyle[] = [ + "imperative", + "go_router", + "auto_route", +] as const satisfies readonly NavigationStyle[] + +// ── Misc flag profiles ────────────────────────────────────────────── + +/** All optional features enabled. usesDio is on, usesHttp off (avoid conflict). */ +const MISC_FULL: MiscConfig = { + usesScreenutil: true, + usesFlutterNativeSplash: true, + usesDio: true, + usesHttp: false, + usesHive: true, + usesSharedPreferences: true, + usesSecureStorage: true, + usesCachedNetworkImage: true, + usesFlutterSvg: true, + usesSkeletonizer: true, + usesDotenv: true as const, + usesLogger: true, + usesFlutterHooks: false, + usesImagePicker: true, + usesFilePicker: true, + usesUrlLauncher: true, + usesPathProvider: true, + usesSharePlus: true, + usesPermissionHandler: true, + usesDeviceInfoPlus: true, + usesAppVersionUpdate: true, + usesGeolocator: true, +} + +/** All optional features disabled (bare minimum). */ +const MISC_MINIMAL: MiscConfig = { + usesScreenutil: false, + usesFlutterNativeSplash: false, + usesDio: false, + usesHttp: false, + usesHive: false, + usesSharedPreferences: false, + usesSecureStorage: false, + usesCachedNetworkImage: false, + usesFlutterSvg: false, + usesSkeletonizer: false, + usesDotenv: true as const, + usesLogger: false, + usesFlutterHooks: false, + usesImagePicker: false, + usesFilePicker: false, + usesUrlLauncher: false, + usesPathProvider: false, + usesSharePlus: false, + usesPermissionHandler: false, + usesDeviceInfoPlus: false, + usesAppVersionUpdate: false, + usesGeolocator: false, +} + +/** Matches the defaultConfig from schema.ts. */ +const MISC_DEFAULT: MiscConfig = { + usesScreenutil: true, + usesFlutterNativeSplash: true, + usesDio: false, + usesHttp: false, + usesHive: false, + usesSharedPreferences: true, + usesSecureStorage: true, + usesCachedNetworkImage: true, + usesFlutterSvg: true, + usesSkeletonizer: true, + usesDotenv: true as const, + usesLogger: true, + usesFlutterHooks: false, + usesImagePicker: false, + usesFilePicker: false, + usesUrlLauncher: true, + usesPathProvider: true, + usesSharePlus: false, + usesPermissionHandler: true, + usesDeviceInfoPlus: true, + usesAppVersionUpdate: true, + usesGeolocator: false, +} + +/** Flutter Hooks enabled — triggers hook pattern instead of service pattern. */ +const MISC_HOOKS: MiscConfig = { + usesScreenutil: true, + usesFlutterNativeSplash: true, + usesDio: true, + usesHttp: false, + usesHive: false, + usesSharedPreferences: true, + usesSecureStorage: true, + usesCachedNetworkImage: true, + usesFlutterSvg: true, + usesSkeletonizer: true, + usesDotenv: true as const, + usesLogger: true, + usesFlutterHooks: true, + usesImagePicker: true, + usesFilePicker: false, + usesUrlLauncher: true, + usesPathProvider: true, + usesSharePlus: true, + usesPermissionHandler: true, + usesDeviceInfoPlus: true, + usesAppVersionUpdate: true, + usesGeolocator: false, +} + +export type MiscProfileName = "full" | "minimal" | "default" | "hooks" + +export const MISC_PROFILES: Record = { + full: MISC_FULL, + minimal: MISC_MINIMAL, + default: MISC_DEFAULT, + hooks: MISC_HOOKS, +} as const + +export const MISC_PROFILE_NAMES = Object.keys(MISC_PROFILES) as readonly MiscProfileName[] + +// ── Combination type ──────────────────────────────────────────────── + +export interface Combination { + architecture: ArchitectureStyle + stateManagement: StateManagement + backend: BackendProvider + navigation: NavigationStyle + miscProfile: MiscProfileName +} + +export function combinationLabel(c: Combination): string { + return `${c.architecture}/${c.stateManagement}/${c.backend}/${c.navigation}/${c.miscProfile}` +} + +// ── Invalid combination rules ─────────────────────────────────────── + +/** + * Returns a reason string if the combination is invalid, or null if valid. + * + * Invalid rules: + * 1. backend "custom" requires usesDio or usesHttp (schema .refine()) + */ +export function invalidReason(c: Combination): string | null { + const misc = MISC_PROFILES[c.miscProfile] + + // Rule 1: custom backend requires a networking client + if (c.backend === "custom" && !misc.usesDio && !misc.usesHttp) { + return "Custom backend requires usesDio or usesHttp to be enabled" + } + + return null +} + +export function isValidCombination(c: Combination): boolean { + return invalidReason(c) === null +} + +// ── Combination generator ─────────────────────────────────────────── + +/** Generate all mathematically possible combinations (unfiltered). */ +function allCombinationsUnfiltered(): Combination[] { + const result: Combination[] = [] + for (const architecture of ARCHITECTURES) { + for (const stateManagement of STATE_MANAGERS) { + for (const backend of BACKENDS) { + for (const navigation of NAVIGATIONS) { + for (const miscProfile of MISC_PROFILE_NAMES) { + result.push({ architecture, stateManagement, backend, navigation, miscProfile }) + } + } + } + } + } + return result +} + +/** All valid combinations after filtering out invalid ones. */ +export const ALL_COMBINATIONS: Combination[] = allCombinationsUnfiltered().filter(isValidCombination) + +/** Combinations that were excluded, with reasons. */ +export const INVALID_COMBINATIONS: Array<{ combo: Combination; reason: string }> = + allCombinationsUnfiltered() + .filter((c) => !isValidCombination(c)) + .map((c) => ({ combo: c, reason: invalidReason(c)! })) + +// ── ScaffoldConfig builder ────────────────────────────────────────── + +/** Build a full ScaffoldConfig from a Combination. */ +export function buildConfig(c: Combination): ScaffoldConfig { + const misc = MISC_PROFILES[c.miscProfile] + const backendConfig: BackendConfig = defaultBackendConfig(c.backend) + + return { + appName: "test_app", + packageId: "com.example.test_app", + description: "Test scaffold for combination testing.", + theme: { + preset: "material3", + primaryColor: "#6750A4", + darkMode: { enabled: true, system: true }, + customFonts: [], + }, + icons: { + default: true, + iconsax_plus: false, + flutter_remix: false, + hugeicons: false, + }, + stateManagement: c.stateManagement, + backend: backendConfig, + localization: { enabled: true, supportedLocales: ["en", "es"] }, + navigation: c.navigation, + architecture: c.architecture, + misc, + } +} + +// ── Stats ─────────────────────────────────────────────────────────── + +export const TOTAL_UNFILTERED = + ARCHITECTURES.length * + STATE_MANAGERS.length * + BACKENDS.length * + NAVIGATIONS.length * + MISC_PROFILE_NAMES.length + +export const TOTAL_VALID = ALL_COMBINATIONS.length +export const TOTAL_INVALID = INVALID_COMBINATIONS.length diff --git a/vitest.config.ts b/vitest.config.ts index 51b9362..633fc3f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,14 @@ export default defineConfig({ test: { environment: "node", include: ["tests/**/*.spec.ts"], + // Exclude e2e tests from default `vitest run` — they need Dart SDK + exclude: ["tests/e2e/**"], + testTimeout: 30_000, + hookTimeout: 30_000, + reporters: ["./tests/utils/custom-reporter.ts"], + // Vitest 4: pool options are top-level + isolate: false, + fileParallelism: false, }, resolve: { alias: { diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..7b9743e --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,22 @@ +import path from "node:path" +import { defineConfig } from "vitest/config" + +/** + * Separate vitest config for e2e tests that require Dart SDK. + * Used via: vitest run --config vitest.e2e.config.ts + */ +export default defineConfig({ + test: { + environment: "node", + include: ["tests/e2e/**/*.spec.ts"], + testTimeout: 120_000, + hookTimeout: 30_000, + isolate: false, + fileParallelism: false, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +}) From c47bb457a61b9b42576f8560f9d027766c69283f Mon Sep 17 00:00:00 2001 From: arjun544 Date: Sun, 3 May 2026 01:44:29 +0500 Subject: [PATCH 03/14] feat: implement tiered CI testing workflows for unit and e2e matrix validation --- .github/workflows/test-tier1.yml | 3 +++ .github/workflows/test-tier2.yml | 3 +++ .github/workflows/test-tier3.yml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/.github/workflows/test-tier1.yml b/.github/workflows/test-tier1.yml index e887aef..7ec908c 100644 --- a/.github/workflows/test-tier1.yml +++ b/.github/workflows/test-tier1.yml @@ -1,4 +1,7 @@ name: "Tests — Tier 1 (Layer 1 Unit Tests)" +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + # Runs on every push to any branch. # Layer 1 only: fast, in-memory template tests. diff --git a/.github/workflows/test-tier2.yml b/.github/workflows/test-tier2.yml index 6e97551..1c0ef60 100644 --- a/.github/workflows/test-tier2.yml +++ b/.github/workflows/test-tier2.yml @@ -1,4 +1,7 @@ name: "Tests — Tier 2 (Critical Combos + Dart Validation)" +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + # Runs on pull requests to main. # Layer 1 + Layer 2 Dart validation for ~30 critical combinations. diff --git a/.github/workflows/test-tier3.yml b/.github/workflows/test-tier3.yml index db54e45..c7455ed 100644 --- a/.github/workflows/test-tier3.yml +++ b/.github/workflows/test-tier3.yml @@ -1,4 +1,7 @@ name: "Tests — Tier 3 (Full Matrix + Dart Validation)" +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + # Pre-release gate. # Runs ALL valid combinations through both Layer 1 and Layer 2. From aa4d82714f2636776d7935e68b984db96b135e43 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Sun, 3 May 2026 01:46:37 +0500 Subject: [PATCH 04/14] Update lockfile --- bun.lock | 66 ++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index c8f7629..4e4579f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "flutter_init", @@ -8,6 +7,7 @@ "@base-ui/react": "^1.1.0", "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.6", + "@supabase/supabase-js": "^2.105.1", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -37,11 +37,13 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/ui": "^4.1.5", "eslint": "^9", "eslint-config-next": "16.1.4", "tailwindcss": "^4", "typescript": "^5", - "vitest": "^4.0.17", + "vitest": "^4.1.5", + "yaml": "^2.8.4", }, }, }, @@ -336,6 +338,8 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -514,6 +518,20 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@supabase/auth-js": ["@supabase/auth-js@2.105.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-zc4s8Xg4truwE1Q4Q8M8oUVDARMd05pKh73NyQsMbYU1HDdDN2iiKzena/yu+yJze3WrD4c092FdckPiK1rLQw=="], + + "@supabase/functions-js": ["@supabase/functions-js@2.105.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-dTk1e7oE51VGc1lS2S0J0NLo0Wp4JYChj74ArJKbIWgoWuFwO0wcJYjeyOV3AAEpKst8/LQWUZOUKO1tRXBrpA=="], + + "@supabase/phoenix": ["@supabase/phoenix@0.4.1", "", {}, "sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA=="], + + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.105.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-6SbtsoWC55xfsm7gbfLqvF+yIwTQEbjt+jFGf4klDpwSnUy17Hv5x0Dq52oqwTQlw6Ta0h1D5gTP0/pApqNojA=="], + + "@supabase/realtime-js": ["@supabase/realtime-js@2.105.1", "", { "dependencies": { "@supabase/phoenix": "^0.4.1", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-3X3cUEl5cJ4lRQHr1hXHx0b98OaL97RRO2vrRZ98FD91JV/MquZHhrGJSv/+IkOnjF6E2e0RUOxE8P3Zi035ow=="], + + "@supabase/storage-js": ["@supabase/storage-js@2.105.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-owfdCNH5ikXXDusjzsgU6LavEBqGUoueOnL/9XIucld70/WJ/rbqp89K//c9QPICDNuegsmpoeasydDAiucLKQ=="], + + "@supabase/supabase-js": ["@supabase/supabase-js@2.105.1", "", { "dependencies": { "@supabase/auth-js": "2.105.1", "@supabase/functions-js": "2.105.1", "@supabase/postgrest-js": "2.105.1", "@supabase/realtime-js": "2.105.1", "@supabase/storage-js": "2.105.1" } }, "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, ""], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, ""], @@ -588,6 +606,8 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/type-utils": "8.53.1", "@typescript-eslint/utils": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, ""], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, ""], @@ -648,19 +668,21 @@ "@vercel/analytics": ["@vercel/analytics@2.0.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "nuxt": ">= 3", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "nuxt", "react", "svelte", "vue", "vue-router"] }, "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g=="], - "@vitest/expect": ["@vitest/expect@4.0.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.17", "@vitest/utils": "4.0.17", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ=="], + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], - "@vitest/mocker": ["@vitest/mocker@4.0.17", "", { "dependencies": { "@vitest/spy": "4.0.17", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" } }, "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ=="], + "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.0.17", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], - "@vitest/runner": ["@vitest/runner@4.0.17", "", { "dependencies": { "@vitest/utils": "4.0.17", "pathe": "^2.0.3" } }, "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ=="], + "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], - "@vitest/snapshot": ["@vitest/snapshot@4.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="], - "@vitest/spy": ["@vitest/spy@4.0.17", "", {}, "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew=="], + "@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], - "@vitest/utils": ["@vitest/utils@4.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "tinyrainbow": "^3.0.3" } }, "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w=="], + "@vitest/ui": ["@vitest/ui@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "vitest": "4.1.5" } }, "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag=="], + + "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -896,7 +918,7 @@ "es-iterator-helpers": ["es-iterator-helpers@1.2.2", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "safe-array-concat": "^1.1.3" } }, ""], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, ""], @@ -984,6 +1006,8 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, ""], @@ -996,7 +1020,7 @@ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, ""], - "flatted": ["flatted@3.3.3", "", {}, ""], + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, ""], @@ -1084,6 +1108,8 @@ "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ignore": ["ignore@5.3.2", "", {}, ""], @@ -1294,6 +1320,8 @@ "minimist": ["minimist@1.2.8", "", {}, ""], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "ms": ["ms@2.1.3", "", {}, ""], "msw": ["msw@2.12.7", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.7.0", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "bin": "cli/index.js" }, "sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg=="], @@ -1530,6 +1558,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], @@ -1544,7 +1574,7 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -1602,7 +1632,7 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, ""], - "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], "tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": "bin/cli.js" }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="], @@ -1612,6 +1642,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, ""], @@ -1680,7 +1712,7 @@ "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], - "vitest": ["vitest@4.0.17", "", { "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", "@vitest/pretty-format": "4.0.17", "@vitest/runner": "4.0.17", "@vitest/snapshot": "4.0.17", "@vitest/spy": "4.0.17", "@vitest/utils": "4.0.17", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.17", "@vitest/browser-preview": "4.0.17", "@vitest/browser-webdriverio": "4.0.17", "@vitest/ui": "4.0.17", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg=="], + "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -1704,12 +1736,16 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, ""], + "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -1770,6 +1806,8 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "flat-cache/flatted": ["flatted@3.3.3", "", {}, ""], + "is-bun-module/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, ""], "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], From 06b62c78b78632a25c166df4a075356c2d1f1172 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Sun, 3 May 2026 01:50:28 +0500 Subject: [PATCH 05/14] refactor: revert to default vitest reporter and remove custom reporter implementation --- tests/utils/custom-reporter.ts | 482 --------------------------------- vitest.config.ts | 2 +- 2 files changed, 1 insertion(+), 483 deletions(-) delete mode 100644 tests/utils/custom-reporter.ts diff --git a/tests/utils/custom-reporter.ts b/tests/utils/custom-reporter.ts deleted file mode 100644 index 0b248ae..0000000 --- a/tests/utils/custom-reporter.ts +++ /dev/null @@ -1,482 +0,0 @@ -import type { - Reporter, - SerializedError, - TestCase, - TestModule, - TestSpecification, - Vitest, -} from "vitest/node" - -// ─── ANSI ──────────────────────────────────────────────────────────────────── - -const C = { - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", - italic: "\x1b[3m", - - black: "\x1b[30m", - red: "\x1b[31m", - green: "\x1b[32m", - yellow: "\x1b[33m", - blue: "\x1b[34m", - magenta: "\x1b[35m", - cyan: "\x1b[36m", - white: "\x1b[37m", - gray: "\x1b[90m", - - bgBlack: "\x1b[40m", - bgRed: "\x1b[41m", - bgGreen: "\x1b[42m", - bgBlue: "\x1b[44m", - bgCyan: "\x1b[46m", - - brightRed: "\x1b[91m", - brightGreen: "\x1b[92m", - brightYellow: "\x1b[93m", - brightBlue: "\x1b[94m", - brightMagenta: "\x1b[95m", - brightCyan: "\x1b[96m", - brightWhite: "\x1b[97m", -} - -const b = (s: string) => `${C.bold}${s}${C.reset}` -const d = (s: string) => `${C.dim}${s}${C.reset}` -const g = (s: string) => `${C.brightGreen}${s}${C.reset}` -const r = (s: string) => `${C.brightRed}${s}${C.reset}` -const y = (s: string) => `${C.brightYellow}${s}${C.reset}` -const c = (s: string) => `${C.brightCyan}${s}${C.reset}` -const mg = (s: string) => `${C.brightMagenta}${s}${C.reset}` -const gr = (s: string) => `${C.gray}${s}${C.reset}` -const w = (s: string) => `${C.brightWhite}${s}${C.reset}` - -// ─── BOX DRAWING ───────────────────────────────────────────────────────────── - -const BOX = { - tl: "╭", tr: "╮", - bl: "╰", br: "╯", - h: "─", v: "│", - ml: "├", mr: "┤", - mt: "┬", mb: "┴", -} - -const TERMINAL_WIDTH = process.stdout.columns || 80 - -function line(char = BOX.h, width = TERMINAL_WIDTH - 2) { - return char.repeat(width) -} - -function boxTop(width = TERMINAL_WIDTH) { return `${C.gray}${BOX.tl}${line(BOX.h, width - 2)}${BOX.tr}${C.reset}` } -function boxMid(width = TERMINAL_WIDTH) { return `${C.gray}${BOX.ml}${line(BOX.h, width - 2)}${BOX.mr}${C.reset}` } -function boxBottom(width = TERMINAL_WIDTH) { return `${C.gray}${BOX.bl}${line(BOX.h, width - 2)}${BOX.br}${C.reset}` } - -function boxRow(content: string, width = TERMINAL_WIDTH) { - const visible = stripAnsi(content) - const pad = width - 2 - visible.length - return `${C.gray}${BOX.v}${C.reset} ${content}${" ".repeat(Math.max(0, pad - 1))}${C.gray}${BOX.v}${C.reset}` -} - -function padCenter(text: string, width: number): string { - const vis = stripAnsi(text) - const total = Math.max(0, width - vis.length) - const left = Math.floor(total / 2) - const right = total - left - return " ".repeat(left) + text + " ".repeat(right) -} - -// ─── HELPERS ───────────────────────────────────────────────────────────────── - -function stripAnsi(s: string): string { - return s.replace(/\x1b\[[0-9;]*m/g, "") -} - -function formatDuration(ms: number): string { - if (!ms || !isFinite(ms) || ms <= 0) return "0s" - const s = Math.floor(ms / 1000) - const m = Math.floor(s / 60) - const h = Math.floor(m / 60) - if (h > 0) return `${h}h ${m % 60}m ${s % 60}s` - if (m > 0) return `${m}m ${s % 60}s` - return `${s}s` -} - -function formatMs(ms: number): string { - if (ms < 1000) return `${Math.round(ms)}ms` - return formatDuration(ms) -} - -function shortPath(fullPath: string): string { - // Show only last 2 path segments - const parts = fullPath.replace(/\\/g, "/").split("/") - return parts.slice(-2).join("/") -} - -function progressBar(percent: number, width = 28): string { - const filled = Math.round((percent / 100) * width) - const empty = width - filled - - const fill = percent === 100 - ? `${C.brightGreen}${"█".repeat(filled)}${C.reset}` - : filled > 0 - ? `${C.brightCyan}${"█".repeat(filled - 1)}${C.brightWhite}▓${C.reset}` - : "" - - const emp = `${C.gray}${"░".repeat(empty)}${C.reset}` - return fill + emp -} - -function sparkline(history: number[], width = 10): string { - const bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] - const slice = history.slice(-width) - if (slice.length === 0) return "" - const max = Math.max(...slice, 1) - return slice.map(v => { - const idx = Math.min(7, Math.floor((v / max) * 7)) - return `${C.cyan}${bars[idx]}${C.reset}` - }).join("") -} - -// ─── REPORTER ──────────────────────────────────────────────────────────────── - -interface FailedTest { - file: string - name: string - error: string -} - -interface FileStat { - name: string - total: number - passed: number - failed: number - durationMs: number -} - -export default class MatrixReporter implements Reporter { - private startTime = 0 - private totalTests = 0 - private completedTests = 0 - private passedTests = 0 - private failedTests = 0 - private skippedTests = 0 - - private currentFile = "" - private currentTest = "" - private failures: FailedTest[] = [] - private fileStats: FileStat[] = [] - private activeFileStart = 0 - - // Speed tracking - private speedHistory: number[] = [] // tests/sec samples - private lastSpeedSample = 0 - private lastSpeedCount = 0 - - // Dynamic line tracking - private progressLines = 0 - private lastRenderLen = 0 - - onInit(_vitest: Vitest) { - this.startTime = Date.now() - this.render_header() - } - - onTestRunStart(_specs: ReadonlyArray) { - // total accumulates in onTestModuleCollected - } - - onTestModuleCollected(mod: TestModule) { - const count = Array.from(mod.children.allTests()).length - this.totalTests += count - } - - onTestModuleStart(mod: TestModule) { - this.currentFile = shortPath(mod.relativeModuleId) - this.activeFileStart = Date.now() - } - - onTestModuleEnd(mod: TestModule) { - const tests = Array.from(mod.children.allTests()) - const passed = tests.filter(t => t.result()?.state === "passed").length - const failed = tests.filter(t => t.result()?.state === "failed").length - const dur = Date.now() - this.activeFileStart - - this.fileStats.push({ - name: shortPath(mod.relativeModuleId), - total: tests.length, - passed, - failed, - durationMs: dur, - }) - } - - onTestCaseResult(testCase: TestCase) { - const state = testCase.result()?.state - - this.completedTests++ - this.currentTest = testCase.name - - if (state === "passed") this.passedTests++ - else if (state === "failed") { - this.failedTests++ - const err = testCase.result()?.errors?.[0] - this.failures.push({ - file: this.currentFile, - name: testCase.name, - error: err?.message?.split("\n")[0] ?? "Unknown error", - }) - } else if (state === "skipped") this.skippedTests++ - - // Speed sampling every 500ms - const now = Date.now() - if (now - this.lastSpeedSample >= 500) { - const dt = (now - this.lastSpeedSample) / 1000 - const delta = this.completedTests - this.lastSpeedCount - const tps = delta / dt - this.speedHistory.push(tps) - if (this.speedHistory.length > 20) this.speedHistory.shift() - this.lastSpeedSample = now - this.lastSpeedCount = this.completedTests - } - - this.render_progress() - } - - onTestRunEnd( - _modules: ReadonlyArray, - unhandledErrors: ReadonlyArray - ) { - this.clearProgress() - this.render_summary(unhandledErrors) - } - - // ─── RENDER: HEADER ──────────────────────────────────────────────────── - - private render_header() { - const W = TERMINAL_WIDTH - - const subtitle = `${C.reset} ${C.brightWhite}FlutterInit Tests${C.reset} ${C.dim}` - const timestamp = new Date().toLocaleTimeString() - - console.log() - console.log(boxTop(W)) - console.log(boxRow("")) - console.log(boxRow(padCenter(subtitle, W - 2))) - console.log(boxRow("")) - console.log(boxRow( - padCenter( - `${gr("TIMELINE")} ${c(timestamp)} ${gr("PROCESS")} ${c(String(process.pid))} ${gr("TARGET")} ${c("Flutter")}`, - W - 2 - ) - )) - console.log(boxRow("")) - console.log(boxBottom(W)) - console.log() - } - - // ─── RENDER: LIVE PROGRESS ───────────────────────────────────────────── - - private render_progress() { - const W = TERMINAL_WIDTH - const now = Date.now() - - const elapsed = now - this.startTime - const pct = this.totalTests > 0 - ? (this.completedTests / this.totalTests) * 100 - : 0 - - const msPerTest = this.completedTests > 0 ? elapsed / this.completedTests : 0 - const remaining = msPerTest * (this.totalTests - this.completedTests) - - const speed = this.speedHistory.length > 0 - ? this.speedHistory[this.speedHistory.length - 1] - : 0 - - // Truncate test name to fit - const maxName = W - 20 - const testName = this.currentTest.length > maxName - ? "…" + this.currentTest.slice(-(maxName - 1)) - : this.currentTest - - // Build lines - const bar = progressBar(pct, 30) - const pctStr = `${pct.toFixed(1)}%` - const countStr = `${c(String(this.completedTests))}${gr("/")}${w(String(this.totalTests))}` - const timeStr = `${g(formatDuration(elapsed))} ${gr("elapsed")} ~${y(formatDuration(remaining))} ${gr("left")}` - const speedStr = speed > 0 - ? `${c(speed.toFixed(1))} ${gr("t/s")} ${sparkline(this.speedHistory, 12)}` - : gr("calculating…") - - const passStr = `${C.brightGreen}✔ ${this.passedTests}${C.reset}` - const failStr = this.failedTests > 0 - ? `${C.brightRed}✖ ${this.failedTests}${C.reset}` - : `${C.gray}✖ 0${C.reset}` - const skipStr = `${C.yellow}⊘ ${this.skippedTests}${C.reset}` - - const fileStr = `${gr("▸")} ${c(this.currentFile)}` - const nameStr = `${gr(" ›")} ${d(testName)}` - - // Clear previous render - this.clearProgress() - - const lines: string[] = [ - "", - ` ${bar} ${b(pctStr.padStart(6))} ${countStr}`, - ` ${timeStr}`, - ` ${speedStr} ${passStr} ${failStr} ${skipStr}`, - "", - ` ${fileStr}`, - ` ${nameStr}`, - "", - ] - - // Show last failure inline if any - if (this.failures.length > 0) { - const last = this.failures[this.failures.length - 1] - const errLine = `${r("✖ FAIL")} ${gr(last.file)} ${d("›")} ${w(last.name)}` - const errMsg = ` ${C.red}${C.dim}${last.error.slice(0, W - 6)}${C.reset}` - lines.push(` ${errLine}`) - lines.push(` ${errMsg}`) - lines.push("") - } - - lines.forEach(l => process.stdout.write(l + "\n")) - this.progressLines = lines.length - } - - private clearProgress() { - if (this.progressLines === 0) return - // Move cursor up and clear each line - for (let i = 0; i < this.progressLines; i++) { - process.stdout.write("\x1b[1A\x1b[2K") - } - this.progressLines = 0 - } - - // ─── RENDER: FINAL SUMMARY ───────────────────────────────────────────── - - private render_summary(unhandledErrors: ReadonlyArray) { - const W = TERMINAL_WIDTH - const elapsed = Date.now() - this.startTime - const allPass = this.failedTests === 0 && unhandledErrors.length === 0 - - // ── Header band - console.log() - if (allPass) { - console.log(`${C.bgGreen}${C.black}${C.bold}${padCenter(" ALL TESTS PASSED ", W)}${C.reset}`) - } else { - console.log(`${C.bgRed}${C.brightWhite}${C.bold}${padCenter(` ${this.failedTests} TEST${this.failedTests !== 1 ? "S" : ""} FAILED `, W)}${C.reset}`) - } - console.log() - - // ── Stats box - const avgSpeed = this.speedHistory.length > 0 - ? (this.speedHistory.reduce((a, b) => a + b, 0) / this.speedHistory.length).toFixed(1) - : "—" - - const statRows = [ - [gr("Total Tests"), w(String(this.totalTests))], - [gr("Passed"), g(`✔ ${this.passedTests}`)], - [gr("Failed"), this.failedTests > 0 ? r(`✖ ${this.failedTests}`) : gr("✖ 0")], - [gr("Skipped"), y(`⊘ ${this.skippedTests}`)], - [gr("Duration"), c(formatDuration(elapsed))], - [gr("Avg Speed"), c(`${avgSpeed} tests/sec`)], - ] - - console.log(boxTop(W)) - console.log(boxRow(padCenter(b(" RUN SUMMARY "), W - 2))) - console.log(boxMid(W)) - statRows.forEach(([label, value]) => { - const row = `${label}${" ".repeat(18 - stripAnsi(label).length)}${value}` - console.log(boxRow(row)) - }) - - // ── Sparkline trend - if (this.speedHistory.length > 3) { - console.log(boxRow("")) - const trend = `${gr("Speed trend")} ${sparkline(this.speedHistory, 30)}` - console.log(boxRow(trend)) - } - console.log(boxBottom(W)) - console.log() - - // ── File breakdown - if (this.fileStats.length > 0) { - console.log(boxTop(W)) - console.log(boxRow(padCenter(b(" FILE BREAKDOWN "), W - 2))) - console.log(boxMid(W)) - - const nameW = 36 - const numW = 6 - const header = `${gr("File".padEnd(nameW))} ${gr("Tests".padStart(numW))} ${gr("Pass".padStart(numW))} ${gr("Fail".padStart(numW))} ${gr("Time".padStart(8))}` - console.log(boxRow(header)) - console.log(boxRow(gr(line("─", W - 4)))) - - this.fileStats.forEach(stat => { - const nameTrunc = stat.name.length > nameW - ? "…" + stat.name.slice(-(nameW - 1)) - : stat.name.padEnd(nameW) - - const statusIcon = stat.failed > 0 ? r("✖") : g("✔") - const nameStr = stat.failed > 0 ? r(nameTrunc) : gr(nameTrunc) - const passStr = g(String(stat.passed).padStart(numW)) - const failStr = stat.failed > 0 - ? r(String(stat.failed).padStart(numW)) - : gr(String(0).padStart(numW)) - const timeStr = c(formatMs(stat.durationMs).padStart(8)) - const totalStr = w(String(stat.total).padStart(numW)) - - const row = `${statusIcon} ${nameStr} ${totalStr} ${passStr} ${failStr} ${timeStr}` - console.log(boxRow(row)) - }) - console.log(boxBottom(W)) - console.log() - } - - // ── Failures detail - if (this.failures.length > 0) { - console.log(boxTop(W)) - console.log(boxRow(padCenter(r(` ✖ ${this.failures.length} FAILURE${this.failures.length !== 1 ? "S" : ""} `), W - 2))) - console.log(boxMid(W)) - console.log(boxRow("")) - - this.failures.forEach((fail, i) => { - const idx = r(`[${String(i + 1).padStart(2, "0")}]`) - const file = c(fail.file) - const name = w(fail.name) - const errText = fail.error.slice(0, W - 8) - const err = `${C.red}${C.dim}${errText}${C.reset}` - - console.log(boxRow(`${idx} ${file}`)) - console.log(boxRow(` ${name}`)) - console.log(boxRow(` ${err}`)) - if (i < this.failures.length - 1) { - console.log(boxRow(gr(line("╌", W - 6)))) - } - }) - console.log(boxRow("")) - console.log(boxBottom(W)) - console.log() - } - - // ── Unhandled errors - if (unhandledErrors.length > 0) { - console.log(boxTop(W)) - console.log(boxRow(padCenter(mg(` ⚡ ${unhandledErrors.length} UNHANDLED ERROR${unhandledErrors.length !== 1 ? "S" : ""} `), W - 2))) - console.log(boxMid(W)) - unhandledErrors.forEach((err, i) => { - const msg = (err.message ?? "Unknown").split("\n")[0].slice(0, W - 6) - console.log(boxRow(`${mg(`[${i + 1}]`)} ${r(msg)}`)) - }) - console.log(boxBottom(W)) - console.log() - } - - // ── Final verdict - const verdict = allPass - ? `${C.brightGreen}${C.bold} ✔ All ${this.totalTests} tests passed in ${formatDuration(elapsed)} ${C.reset}` - : `${C.brightRed}${C.bold} ✖ ${this.failedTests} failed · ${this.passedTests} passed · ${formatDuration(elapsed)} ${C.reset}` - - console.log(padCenter(verdict, W)) - console.log() - } -} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 633fc3f..6e9f063 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ exclude: ["tests/e2e/**"], testTimeout: 30_000, hookTimeout: 30_000, - reporters: ["./tests/utils/custom-reporter.ts"], + reporters: ["default"], // Vitest 4: pool options are top-level isolate: false, fileParallelism: false, From 2f3efeb4fb5d879f2ecec3ab9e2ab3ac048374e1 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Tue, 5 May 2026 10:17:48 +0500 Subject: [PATCH 06/14] chore: implement CI testing pipeline with matrix-based unit tests, custom failure reporting, and comprehensive template scaffold additions. --- .github/workflows/test-tier1.yml | 12 +- .github/workflows/test-tier2.yml | 18 +- .github/workflows/test-tier3.yml | 18 +- .gitignore | 5 + README.md | 4 +- app/lib/generator/handlebars.ts | 10 +- docs/testing.md | 266 ++++++++++--- package.json | 12 +- scripts/validate-dart.ts | 139 +++++++ templates/flutter/base/lib/main.dart.hbs | 4 +- .../base/lib/src/config/app_config.dart.hbs | 2 + .../lib/src/imports/core_imports.dart.hbs | 2 + .../lib/src/imports/packages_imports.dart.hbs | 16 +- .../base/lib/src/routing/app_router.dart.hbs | 116 +----- .../base/lib/src/services/services.dart.hbs | 2 +- .../src/shared/widgets/app_button.dart.hbs | 16 +- .../lib/src/shared/widgets/app_card.dart.hbs | 2 +- .../shared/widgets/app_empty_state.dart.hbs | 10 +- .../lib/src/shared/widgets/app_icon.dart.hbs | 20 +- .../src/shared/widgets/app_loading.dart.hbs | 2 +- .../src/shared/widgets/app_top_bar.dart.hbs | 4 +- .../src/shared/widgets/common_image.dart.hbs | 4 +- ...loc,isGetX,isMobX)@state_wrapper.dart.hbs} | 7 +- ...calization)@localization_wrapper.dart.hbs} | 4 - ...sScreenutil)@screen_util_wrapper.dart.hbs} | 2 - ...esSkeletonizer)@skeleton_wrapper.dart.hbs} | 0 .../session_listener_wrapper.dart.hbs | 4 + templates/flutter/base/pubspec.yaml.hbs | 24 +- .../flutter/base/test/widget_test.dart.hbs | 4 + .../(isNoneState)@auth_view_model.dart.hbs | 1 + .../(usesAppwriteAuth)@auth_service.dart.hbs | 3 +- .../lib/src/services/auth_service.dart.hbs | 26 +- .../partials/features/auth/auth_logic.hbs | 116 ++++-- .../features/auth/forgot_password_screen.hbs | 30 +- .../partials/features/auth/login_screen.hbs | 30 +- .../features/auth/session_provider.hbs | 10 +- .../partials/features/auth/signup_screen.hbs | 30 +- .../partials/features/home/home_page.hbs | 93 +++-- .../features/onboarding/onboarding_page.hbs | 6 +- tests/e2e/validate-combo.ts | 13 + tests/integration/full-pipeline.spec.ts | 23 +- tests/integration/overlay-composition.spec.ts | 124 ++---- tests/reporters/failed-tests-reporter.ts | 48 +++ tests/unit/backend.spec.ts | 115 +++--- tests/unit/dependencies.spec.ts | 272 ------------- tests/unit/handlebars-helpers.spec.ts | 107 ++--- tests/unit/matrix-shard-1.spec.ts | 8 + tests/unit/matrix-shard-2.spec.ts | 8 + tests/unit/matrix-shard-3.spec.ts | 8 + tests/unit/matrix-shard-4.spec.ts | 8 + tests/unit/misc-flags.spec.ts | 213 ++++------ tests/unit/navigation.spec.ts | 75 ++-- tests/unit/state-management.spec.ts | 99 ++--- tests/unit/structural.spec.ts | 59 --- tests/unit/token-cleanliness.spec.ts | 37 -- tests/utils/combinations.ts | 55 +++ tests/utils/config-builder.ts | 43 ++ tests/utils/critical-combos.ts | 367 +++++++++++------- tests/utils/generate.ts | 4 + tests/utils/matrix-tests.ts | 132 +++++++ tests/utils/matrix.config.ts | 283 +++----------- tests/utils/misc-profiles.ts | 101 +++++ vitest.config.ts | 7 +- 63 files changed, 1707 insertions(+), 1576 deletions(-) create mode 100644 scripts/validate-dart.ts rename templates/flutter/base/lib/src/shared/wrappers/{state_wrapper.dart.hbs => (isRiverpod,isProvider,isBloc,isGetX,isMobX)@state_wrapper.dart.hbs} (89%) rename templates/flutter/base/lib/src/shared/wrappers/{localization_wrapper.dart.hbs => (supportsLocalization)@localization_wrapper.dart.hbs} (88%) rename templates/flutter/base/lib/src/shared/wrappers/{screen_util_wrapper.dart.hbs => (usesScreenutil)@screen_util_wrapper.dart.hbs} (94%) rename templates/flutter/base/lib/src/shared/wrappers/{skeleton_wrapper.dart.hbs => (usesSkeletonizer)@skeleton_wrapper.dart.hbs} (100%) create mode 100644 templates/flutter/overlays/architecture/layer-first/lib/src/presentation/providers/(isNoneState)@auth_view_model.dart.hbs create mode 100644 tests/reporters/failed-tests-reporter.ts delete mode 100644 tests/unit/dependencies.spec.ts create mode 100644 tests/unit/matrix-shard-1.spec.ts create mode 100644 tests/unit/matrix-shard-2.spec.ts create mode 100644 tests/unit/matrix-shard-3.spec.ts create mode 100644 tests/unit/matrix-shard-4.spec.ts delete mode 100644 tests/unit/structural.spec.ts delete mode 100644 tests/unit/token-cleanliness.spec.ts create mode 100644 tests/utils/combinations.ts create mode 100644 tests/utils/config-builder.ts create mode 100644 tests/utils/matrix-tests.ts create mode 100644 tests/utils/misc-profiles.ts diff --git a/.github/workflows/test-tier1.yml b/.github/workflows/test-tier1.yml index 7ec908c..4afceb1 100644 --- a/.github/workflows/test-tier1.yml +++ b/.github/workflows/test-tier1.yml @@ -29,5 +29,13 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run Layer 1 tests - run: bun run test:unit + - name: Run Layer 1 tests (Unit + Integration) + run: npm run test:layer1 + + - name: Upload Failure Logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: tier1-failure-logs + path: tests/results/ + retention-days: 7 diff --git a/.github/workflows/test-tier2.yml b/.github/workflows/test-tier2.yml index 1c0ef60..4753e9f 100644 --- a/.github/workflows/test-tier2.yml +++ b/.github/workflows/test-tier2.yml @@ -29,14 +29,14 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run Layer 1 tests - run: bun run test:unit + - name: Run Layer 1 tests (Unit + Integration) + run: npm run test:layer1 layer2: name: Layer 2 — Dart Validation (Critical Combos) needs: layer1 runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 steps: - name: Checkout @@ -56,5 +56,13 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run Layer 2 Dart validation (critical combos) - run: bun run test:e2e + - name: Run Layer 2 Dart validation (Critical Combos) + run: npm run test:layer2 + + - name: Upload Failure Logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: tier2-failure-logs + path: tests/results/ + retention-days: 7 diff --git a/.github/workflows/test-tier3.yml b/.github/workflows/test-tier3.yml index c7455ed..c90eccf 100644 --- a/.github/workflows/test-tier3.yml +++ b/.github/workflows/test-tier3.yml @@ -5,11 +5,11 @@ env: # Pre-release gate. # Runs ALL valid combinations through both Layer 1 and Layer 2. -# Automatic on push to release branch + manual workflow_dispatch. +# Automatic on push to main + manual workflow_dispatch. on: push: - branches: [release] + branches: [main] workflow_dispatch: inputs: concurrency: @@ -37,8 +37,8 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run Layer 1 tests - run: bun run test:unit + - name: Run Layer 1 tests (Unit + Integration) + run: npm run test:layer1 # ── Step 2: Count valid combinations ─────────────────────── @@ -86,7 +86,7 @@ jobs: name: "Layer 2 — Chunk ${{ matrix.chunk }}" needs: prepare runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 120 strategy: fail-fast: false matrix: @@ -123,6 +123,14 @@ jobs: echo "Running combos $START to $END (of $TOTAL)" bun tests/e2e/run-matrix.ts --range "$START-$END" + - name: Upload Failure Logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: tier3-chunk-${{ matrix.chunk }}-logs + path: tests/results/ + retention-days: 7 + # ── Step 4: Gate check ───────────────────────────────────── gate: diff --git a/.gitignore b/.gitignore index daef6b4..b93b1e5 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,8 @@ next-env.d.ts # Generator Output /dev_out/ scripts/dev_config.json + +# Test Results +tests/results/ +.temp/ + diff --git a/README.md b/README.md index acadb71..a27cb9c 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ **FlutterInit** is an open-source project designed to eliminate the "initial drag" of Flutter development. It provides a highly opinionated yet flexible scaffolding system that maps your architectural vision to a production-ready codebase in seconds. ### 🎯 Why use FlutterInit? -- **Elite Quality**: Follows `flutter_lints` and SOLID principles by default. +- **Elite Quality**: Follows `flutter_lints`, SOLID principles, and validated against a comprehensive matrix of critical architectural combinations. - **Extreme Speed**: From a blank screen to a running app with routing & state in < 60s. - **Enterprise DNA**: Pre-configured with logging, error handling, and environment management. @@ -132,7 +132,7 @@ Explore our technical guides to understand the architecture and flags: * **[Generated Output Reference](docs/generated-output.md)**: Understanding the "src-first" structure. * **[Architecture Overview](docs/architecture.md)**: Under the hood of the Next.js/Handlebars engine. * **[Handlebars Language Guide](docs/handlebars.md)**: Logic patterns for template contributors. -* **[Testing Guide](docs/testing.md)**: How our 2-layer automated testing suite works. +* **[Testing Guide](docs/testing.md)**: Our comprehensive 2-layer validation strategy and tiered CI/CD pipeline. * **[Contribution Guide](CONTRIBUTING.md)**: How to add your own patterns. --- diff --git a/app/lib/generator/handlebars.ts b/app/lib/generator/handlebars.ts index 6baae8e..956f129 100644 --- a/app/lib/generator/handlebars.ts +++ b/app/lib/generator/handlebars.ts @@ -80,10 +80,12 @@ export function registerHelpers(hbs: Hbs) { indentLines(text, Number(spaces)) ) hbs.registerHelper("res", (value: unknown, unit: string, usesScreenutil: boolean) => { - // `value` can be a number (e.g. 16) or a Dart expression string (e.g. "AppSpacing.lg"). - // When ScreenUtil is disabled we must NOT append ".0" to expressions. - if (usesScreenutil) return `${value}.${unit}` - return typeof value === "number" ? `${value}.0` : String(value) + if (usesScreenutil) return `${value}.${unit}`; + + if (typeof value === 'number') { + return String(value); + } + return String(value); }) hbs.registerHelper("when", function (this: unknown, condition, options) { return condition ? options.fn(this) : options.inverse(this) diff --git a/docs/testing.md b/docs/testing.md index 2bd8065..2339e0e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,116 +1,256 @@ -# FlutterInit Testing Guide +# FlutterInit — Comprehensive Testing Guide -This guide explains the testing infrastructure for **FlutterInit**, covering the two-layer validation strategy, the automated matrix runner, and CI/CD integration. +**Version:** 1.1 +**Scope:** Template integrity, output validation, full combination coverage, and CI/CD integration +**Stack:** Bun, Vitest, Handlebars.js, Dart, GitHub Actions --- -## 1. Testing Philosophy +## Table of Contents + +1. [Overview and Philosophy](#1-overview-and-philosophy) +2. [Understanding the Testing Problem](#2-understanding-the-testing-problem) +3. [The Two-Layer Model](#3-the-two-layer-model) +4. [Layer 1 — Template Integrity Testing](#4-layer-1--template-integrity-testing) +5. [Layer 2 — Dart Output Validation](#5-layer-2--dart-output-validation) +6. [Full Combination Coverage](#6-full-combination-coverage) +7. [The Combination Generator](#7-the-combination-generator) +8. [What to Assert in Every Test](#8-what-to-assert-in-every-test) +9. [Test Directory Structure](#9-test-directory-structure) +10. [CI/CD Strategy and Tiering](#10-cicd-strategy-and-tiering) +11. [The Pre-Production Gate](#11-the-pre-production-gate) +12. [Snapshot Testing for Regression Prevention](#12-snapshot-testing-for-regression-prevention) +13. [Combination Coverage Reporting](#13-combination-coverage-reporting) +14. [Failure Handling and Debugging](#14-failure-handling-and-debugging) +15. [Common Pitfalls and How to Avoid Them](#15-common-pitfalls-and-how-to-avoid-them) +16. [Testing Checklist Before Every Release](#16-testing-checklist-before-every-release) -We use a two-layer approach to ensure that the template generator produces reliable, production-grade Flutter code. +--- + +## 1. Overview and Philosophy + +FlutterInit is a scaffolding engine. Unlike a standard web application or API, it does not serve data or process requests at runtime — it generates code. This fundamentally changes what testing means and what it must guarantee. + +A bug in a web app breaks a feature. A bug in FlutterInit breaks every project a developer creates with it. A developer who generates a broken project from FlutterInit may spend hours debugging before realizing the issue was in the tool, not their code. That erosion of trust is irreversible. + +**The primary goal of FlutterInit's test suite is a single guarantee:** + +> Every valid combination of user choices must produce a Flutter project that compiles, analyzes cleanly, and reflects exactly what the user configured. + +Nothing less is acceptable before a production release. + +--- + +## 2. Understanding the Testing Problem + +Most scaffolding tools test only whether their templates compile — meaning, whether the template engine successfully processes the template file without throwing an error. This is a dangerously incomplete definition of "passing." + +FlutterInit has a unique testing challenge because it sits at the intersection of two languages and two systems: + +**The JavaScript system** is responsible for taking user input, resolving template logic, injecting variables, and producing file strings. Bugs here include unresolved Handlebars tokens, incorrect conditionals, variable name mismatches, wrong file paths, and missing files for certain option combinations. + +**The Dart system** is the output of the JavaScript system. Bugs here include syntactically invalid Dart code, incorrect import paths, missing or duplicate dependencies in pubspec.yaml, conflicting package versions, and architectural folder structures that don't match what was requested. + +--- + +## 3. The Two-Layer Model -### Layer 1: Template Integrity (Unit & Integration) -- **Goal**: Fast, in-memory validation of the generator engine and Handlebars templates. -- **Scope**: All 1,350+ valid combinations of architecture, state management, and backend. +Think of FlutterInit's output pipeline as two sequential layers, each requiring its own validation strategy. + +**Layer 1 — The Template Engine (Unit & Integration)** +- **Scope**: All 375 primary valid combinations of architecture, state management, and backend. +- **Tools**: Vitest, Bun. - **Speed**: Very fast (seconds to minutes). - **Environment**: Node.js/Bun (no Flutter SDK required). -### Layer 2: Output Validation (E2E) +**Layer 2 — The Generated Flutter Project (E2E)** - **Goal**: Guarantee that the generated code actually compiles and follows Dart best practices. -- **Scope**: Critical combinations and full matrix validation. -- **Speed**: Slow (minutes to hours). +- **Tools**: Dart SDK (`dart pub get`, `dart analyze`), Bun. +- **Speed**: Slower (minutes). - **Environment**: Requires Flutter/Dart SDK. --- -## 2. Directory Structure +## 4. Layer 1 — Template Integrity Testing + +### Purpose +Layer 1 tests verify that the Handlebars templating engine is correctly processing every template file for every valid combination of inputs. + +### Running Layer 1 Tests +```bash +# Run all unit and integration tests +npm run test:layer1 + +# Run unit tests only +npm run test:unit + +# Run integration tests only +npm run test:integration +``` + +### What Layer 1 Tests Verify +- **No unresolved Handlebars tokens**: No `{{variable}}` sequences left in output. +- **No empty output files**: Every file must have substantive content. +- **Presence of required files**: `pubspec.yaml`, `main.dart`, etc. +- **Correct conditional inclusion**: Ensuring no "option bleed" (e.g., Bloc code in a Riverpod project). +- **Valid pubspec.yaml structure**: Parseable as valid YAML. + +--- + +## 5. Layer 2 — Dart Output Validation + +### Purpose +Layer 2 tests verify that the files written to disk constitute a valid Flutter project. + +### Running Layer 2 Tests +```bash +# Run validation for Critical Combinations +npm run test:layer2 +``` + +### The Validation Pipeline +1. **Project Generation**: Writes files to a temporary directory. +2. **Dependency Resolution**: Runs `dart pub get`. +3. **Code Generation**: Runs `build_runner` (if MobX or AutoRoute is used). +4. **Static Analysis**: Runs `dart analyze --fatal-infos`. + +### What Layer 2 Catches +- **Import path errors**: Typographical errors in package imports. +- **pubspec.yaml version conflicts**: Conflicting constraints between packages. +- **Missing platform configuration**: Incomplete setup for services like Firebase. +- **Architectural consistency**: Verifying that imports match the requested folder structure. + +--- + +## 6. Full Combination Coverage + +### Why Full Coverage Matters +The interactions between options are where the most subtle bugs live. "None" is a first-class option that must be explicitly tested. + +### Defining the Option Space +The file `tests/utils/matrix.config.ts` defines the available options and filters out invalid combinations. Every valid permutation (375 primary combinations) must eventually be tested before a major release. + +--- + +## 7. The Combination Generator + +The combination generator (`tests/utils/combinations.ts`) is a shared utility that produces the complete list of valid combinations. It powers the full matrix test suite and ensures consistency across all test tiers. + +--- + +## 8. What to Assert in Every Test + +- **Structural**: Does the folder structure match (Clean, Feature-First, MVVM)? +- **Content**: Are the files populated correctly? +- **Dependency**: Is `pubspec.yaml` correct for the selected flags? +- **Token Cleanliness**: No `{{tokens}}` in output. +- **Analysis (Layer 2)**: Zero errors, warnings, or info diagnostics. + +--- + +## 9. Test Directory Structure ```text tests/ ├── unit/ # Layer 1: Specific feature tests │ ├── backend.spec.ts │ ├── dependencies.spec.ts -│ ├── token-cleanliness.spec.ts # Scans for unresolved {{tokens}} │ └── ... ├── integration/ # Layer 1: Pipeline tests -│ └── full-pipeline.spec.ts # Generates full project in-memory -├── e2e/ # Layer 2: Dart SDK validation -│ ├── validate-combo.ts # CLI tool for single combo -│ ├── run-matrix.ts # Matrix orchestrator -│ └── dart-validation.spec.ts # Vitest wrapper for E2E -└── utils/ # Shared logic - ├── matrix.config.ts # Source of truth for options - └── assertions.ts # Custom Flutter/Dart assertions +│ └── full-pipeline.spec.ts +├── results/ # Automated failure logs (gitignored) +│ ├── layer1/failed-tests.log +│ └── layer2/failed-tests.log +├── utils/ # Shared logic +│ ├── matrix.config.ts # Option definitions +│ ├── critical-combos.ts # CI subset +│ ├── combinations.ts # Generator utility +│ └── assertions.ts # Custom matchers +└── reporters/ # Custom test output formatters ``` --- -## 3. The Matrix Configuration +## 10. CI/CD Strategy and Tiering -The file `tests/utils/matrix.config.ts` defines the available options and filters out invalid combinations. +### Tier 1 — Every Push (Unit & Integration) +- **Runs**: `npm run test:layer1` +- **Goal**: Immediate feedback on template logic. +- **Duration**: < 3 mins. -- **`ALL_COMBINATIONS`**: Every valid permutation of the configuration space (~1,350). -- **`CRITICAL_COMBINATIONS`**: A curated subset (~30) that covers the most diverse and high-risk edge cases. +### Tier 2 — Every PR to Main (Critical E2E) +- **Runs**: Layer 1 + `npm run test:layer2` (30 Critical Combos). +- **Goal**: Verify core architectural integrity. +- **Duration**: < 15 mins (parallelized). -Whenever you add a new feature or flag to `schema.ts`, you **must** update the constants in `matrix.config.ts`. +### Tier 3 — Pre-Release Gate (Full Matrix) +- **Runs**: Full validation for all 375 primary combinations. +- **Goal**: Zero-bug guarantee for production. +- **Duration**: 45-90 mins (distributed runners). --- -## 4. Running Tests Locally +## 11. The Pre-Production Gate -### Fast Checks (Layer 1) -```bash -# Run all unit and integration tests -npm run test:unit +Before any release, the "Preflight" command must pass: -# Run a specific test file -npx vitest tests/unit/backend.spec.ts +```bash +npm run test:preflight ``` -### Full Validation (Layer 1 + Layer 2) -Requires Flutter/Dart SDK installed and available in your PATH. +This chains Layer 1 and Layer 2 validation. If any step fails, the deployment is blocked. -```bash -# Run the "Critical Gate" (Unit + 30 Critical E2E Combos) -npm run test:gate +--- -# Run E2E for critical combos only -npm run test:e2e +## 12. Snapshot Testing for Regression Prevention -# Run E2E for the ENTIRE matrix (Caution: Takes hours) -npm run test:matrix -``` +We use snapshot testing for critical combinations to catch unintended changes in generated code structure. Snapshots are stored in version control and must be reviewed when updated. --- -## 5. CI/CD Pipeline +## 13. Failure Handling and Debugging -The project uses GitHub Actions with a tiered strategy: +### Automated Logs +When tests fail, diagnostics are automatically aggregated: +- **Layer 1 Logs**: `tests/results/layer1/failed-tests.log` +- **Layer 2 Logs**: `tests/results/layer2/failed-tests.log` -1. **Tier 1 (Every Push)**: Runs all unit and integration tests (Layer 1). Target: < 3 minutes. -2. **Tier 2 (PR to Main)**: Runs Layer 1 + Layer 2 for the **30 Critical Combinations**. Target: < 15 minutes. -3. **Tier 3 (Release Gate)**: Runs the full Layer 1 + Layer 2 validation for all 1,350+ combinations in parallel. +### CI/CD Artifacts +When a test fails in GitHub Actions, these detailed logs are preserved as artifacts: +1. Navigate to the failed **Action run** in GitHub. +2. Scroll to the **Artifacts** section at the bottom of the summary page. +3. Download the relevant log (e.g., `tier2-failure-logs`). +4. These logs match your local `tests/results/` structure and contain the full error context. + +### Debugging a Specific Combination +If a specific combination fails (e.g., `layer-first|none|none|auto_route`): +```bash +# Generate and debug a specific combo +bun scripts/validate-dart.ts --combo "layer-first|none|none|auto_route" --keep-output +``` +Inspect the generated code in `./.temp/flutterinit/` and run `dart analyze` manually. --- -## 6. Debugging Failures +## 14. Common Pitfalls -### Layer 1 Failures -Usually mean a Handlebars helper failed, a file is missing from an overlay, or a token like `{{name}}` was found in the output. The test output will typically show the exact file and line number of the unresolved token. +- **Testing Only Happy Paths**: Always test the "None" options. +- **Ignoring Infos**: `dart analyze` MUST pass with `--fatal-infos`. +- **Option Bleed**: Accidental inclusion of code from unselected flags. +- **Missing build_runner**: Forgetting to run generation for MobX/AutoRoute. -### Layer 2 Failures -Usually mean the generated Dart code has a syntax error or a version mismatch in `pubspec.yaml`. -To debug a specific failing combo: -```bash -# Use the CLI validator for the failing combo index (e.g. #42) -bun tests/e2e/validate-combo.ts 42 -``` -This will generate the project to a temporary directory and show the raw output from `dart analyze`. +--- + +## 15. Testing Checklist Before Every Release + +- [ ] `npm run test:layer1` passes 100%. +- [ ] `npm run test:layer2` passes for all 25 critical combinations. +- [ ] Unresolved token assertions pass globally for all primary combinations. +- [ ] Every individual option value appears in at least three tested combinations. +- [ ] Snapshot diffs have been reviewed and approved. +- [ ] `tests/results/` logs are clean. --- -## 7. Best Practices for Template Changes +*This guide is the source of truth for FlutterInit quality standards. Update it whenever new options are added or the validation pipeline is enhanced.* -1. **Check Tokens**: If you add a new variable, ensure it's handled in `index.ts` and that it appears in `token-cleanliness.spec.ts`. -2. **Add Assertions**: If a package is mandatory for a flag, add a dependency assertion in `tests/unit/dependencies.spec.ts`. -3. **Verify Structure**: If you change the folder structure (e.g., adding a `services` folder), update `tests/unit/structural.spec.ts`. -4. **Run the Gate**: Always run `npm run test:gate` before pushing a PR. diff --git a/package.json b/package.json index 42e32c3..0c2c7a0 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "start": "next start", "lint": "eslint", "test": "vitest", - "test:unit": "vitest run --config vitest.config.ts", - "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:matrix": "bun tests/e2e/run-matrix.ts", - "test:matrix:critical": "bun tests/e2e/run-matrix.ts --critical", - "test:gate": "npm run test:unit && npm run test:matrix:critical", - "test:gate:full": "npm run test:unit && npm run test:matrix" + "test:unit": "vitest run --config vitest.config.ts tests/unit/", + "test:integration": "vitest run --config vitest.config.ts tests/integration/", + "test:layer1": "npm run test:unit && npm run test:integration", + "test:layer2": "bun scripts/validate-dart.ts --mode critical", + "test:tier3": "bun tests/e2e/run-matrix.ts", + "test:preflight": "npm run test:layer1 && npm run test:layer2" }, "dependencies": { "@base-ui/react": "^1.1.0", diff --git a/scripts/validate-dart.ts b/scripts/validate-dart.ts new file mode 100644 index 0000000..6a3bf3b --- /dev/null +++ b/scripts/validate-dart.ts @@ -0,0 +1,139 @@ +import { $ } from "bun" +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs" +import fs from "node:fs/promises" +import path from "node:path" +import { buildConfig } from "../tests/utils/config-builder" +import { CRITICAL_COMBOS } from "../tests/utils/critical-combos" +import { generateToDisk } from "../tests/utils/generate" +import { COMBO_LABEL } from "../tests/utils/matrix.config" + +import { generatePrimaryCombinations } from "../tests/utils/combinations" +import { MISC_DEFAULT } from "../tests/utils/misc-profiles" + +const MODE = process.argv.includes("--mode") ? process.argv[process.argv.indexOf("--mode") + 1] : "critical" +const KEEP_OUTPUT = process.argv.includes("--keep-output") +const COMBO = process.argv.includes("--combo") ? process.argv[process.argv.indexOf("--combo") + 1] : null + +console.log(`⚡ Starting Dart Validation (Mode: ${MODE})`) + +const TEMP_BASE = "./.temp/flutterinit" +await fs.mkdir(TEMP_BASE, { recursive: true }) + +let combosToRun = CRITICAL_COMBOS + +if (COMBO) { + const foundInCritical = CRITICAL_COMBOS.find(c => COMBO_LABEL(c) === COMBO) + if (foundInCritical) { + combosToRun = [foundInCritical] + } else { + const all = generatePrimaryCombinations() + const found = all.find(c => COMBO_LABEL(c) === COMBO) + if (!found) { + console.error(`Error: Combo '${COMBO}' not found in the matrix.`) + process.exit(1) + } + combosToRun = [found] + } + console.log(`🎯 Targeting Combo: ${COMBO}`) +} else if (MODE !== "critical") { + console.error("Only critical mode is implemented.") + process.exit(1) +} + +let passCount = 0 +let failCount = 0 +const failedLogs: string[] = [] +const startTime = Date.now() + +for (const combo of combosToRun) { + const label = COMBO_LABEL(combo) + const dirName = label.replace(/[|]/g, "_").replace(/\s+/g, "_") + const targetDir = `${TEMP_BASE}/${dirName}` + + console.log("------------------------------------------------------------") + console.log(`👉 Validating: ${label}`) + + try { + // Generate project + const config = buildConfig(combo, (combo as any).miscProfile || MISC_DEFAULT) + await generateToDisk(config, targetDir) + + // Run pub get + console.log(" Running pub get...") + const pubGet = await $`cd ${targetDir} && dart pub get`.nothrow().quiet() + if (pubGet.exitCode !== 0) { + console.error(" ❌ FAILED: dart pub get") + console.error(pubGet.stdout.toString()) + console.error(pubGet.stderr.toString()) + failedLogs.push(`FAIL: ${label} (dart pub get)\n${pubGet.stdout.toString()}\n${pubGet.stderr.toString()}`) + failCount++ + continue + } + + // Run build_runner if needed (MobX or AutoRoute) + const needsBuild = buildConfig(combo).navigation === "auto_route" || buildConfig(combo).stateManagement === "mobx"; + if (needsBuild) { + console.log(" Running build_runner..."); + const buildRunner = await $`cd ${targetDir} && dart run build_runner build --delete-conflicting-outputs`.nothrow().quiet(); + if (buildRunner.exitCode !== 0) { + console.error(" ❌ FAILED: build_runner"); + console.error(buildRunner.stdout.toString()); + console.error(buildRunner.stderr.toString()); + failedLogs.push(`FAIL: ${label} (build_runner)\n${buildRunner.stdout.toString()}\n${buildRunner.stderr.toString()}`); + failCount++; + continue; + } + } + + // Run analyze + console.log(" Running dart analyze...") + const analyze = await $`cd ${targetDir} && dart analyze --fatal-infos`.nothrow().quiet() + if (analyze.exitCode !== 0) { + console.error(" ❌ FAILED: dart analyze") + console.error(analyze.stdout.toString()) + console.error(analyze.stderr.toString()) + failedLogs.push(`FAIL: ${label} (dart analyze)\n${analyze.stdout.toString()}\n${analyze.stderr.toString()}`) + failCount++ + } else { + console.log(" ✅ PASSED") + passCount++ + if (!KEEP_OUTPUT) { + await fs.rm(targetDir, { recursive: true, force: true }) + } + } + } catch (e) { + console.error(` ❌ ERROR during validation: ${e}`) + failedLogs.push(`FAIL: ${label} (Exception)\n${e}`) + failCount++ + } +} + +const duration = Math.floor((Date.now() - startTime) / 1000) + +console.log("============================================================") +console.log("📊 Summary") +console.log(`Total Duration: ${duration}s`) +console.log(`Passed: ${passCount}`) +console.log(`Failed: ${failCount}`) +console.log("============================================================") + +const outputDir = path.resolve(process.cwd(), "tests/results/layer2") +if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }) +} +const outputFile = path.join(outputDir, "failed-tests.log") + +if (failedLogs.length > 0) { + writeFileSync(outputFile, failedLogs.join("\n\n=========================================\n\n"), "utf-8") + console.log(`\n[FailedTestsLogger] Logged ${failCount} failed validations to ${outputFile}\n`) +} else { + if (existsSync(outputFile)) { + unlinkSync(outputFile) + } +} + +if (failCount > 0) { + process.exit(1) +} else { + process.exit(0) +} diff --git a/templates/flutter/base/lib/main.dart.hbs b/templates/flutter/base/lib/main.dart.hbs index 54bd12e..c6d218e 100644 --- a/templates/flutter/base/lib/main.dart.hbs +++ b/templates/flutter/base/lib/main.dart.hbs @@ -7,9 +7,11 @@ import 'src/app.dart'; Future main() async { - final WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); {{#if flags.usesFlutterNativeSplash}} + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + {{else}} + WidgetsFlutterBinding.ensureInitialized(); {{/if}} {{#if flags.supportsLocalization}} diff --git a/templates/flutter/base/lib/src/config/app_config.dart.hbs b/templates/flutter/base/lib/src/config/app_config.dart.hbs index bb375b5..3976328 100644 --- a/templates/flutter/base/lib/src/config/app_config.dart.hbs +++ b/templates/flutter/base/lib/src/config/app_config.dart.hbs @@ -1,4 +1,6 @@ +{{#if flags.usesDio}} import '../imports/core_imports.dart'; +{{/if}} {{#if flags.usesDio}} import 'package:dio/dio.dart'; {{/if}} diff --git a/templates/flutter/base/lib/src/imports/core_imports.dart.hbs b/templates/flutter/base/lib/src/imports/core_imports.dart.hbs index 7f9b8bc..0c4c750 100644 --- a/templates/flutter/base/lib/src/imports/core_imports.dart.hbs +++ b/templates/flutter/base/lib/src/imports/core_imports.dart.hbs @@ -3,7 +3,9 @@ export 'package:flutter/material.dart'; export 'package:flutter/cupertino.dart' hide RefreshCallback; export 'package:flutter/foundation.dart'; export 'package:flutter/services.dart'; +{{#if flags.usesFlutterNativeSplash}} export 'package:flutter_native_splash/flutter_native_splash.dart'; +{{/if}} {{#if flags.supportsLocalization}} export 'package:easy_localization/easy_localization.dart' hide TextDirection, MapExtension; diff --git a/templates/flutter/base/lib/src/imports/packages_imports.dart.hbs b/templates/flutter/base/lib/src/imports/packages_imports.dart.hbs index 6f4954c..d803fcc 100644 --- a/templates/flutter/base/lib/src/imports/packages_imports.dart.hbs +++ b/templates/flutter/base/lib/src/imports/packages_imports.dart.hbs @@ -8,12 +8,14 @@ export 'package:flutter_hooks/flutter_hooks.dart'; {{/if}} export 'package:equatable/equatable.dart'; {{#if flags.isRiverpod}} -export 'package:flutter_riverpod/flutter_riverpod.dart' hide describeIdentity, shortHash; +export 'package:flutter_riverpod/flutter_riverpod.dart'; +export 'package:flutter_riverpod/legacy.dart'; {{#if flags.usesFlutterHooks}} -export 'package:hooks_riverpod/hooks_riverpod.dart' hide describeIdentity, shortHash; +export 'package:hooks_riverpod/hooks_riverpod.dart'; +export 'package:hooks_riverpod/legacy.dart'; {{/if}} {{/if}} -{{#if flags.isProvider}} +{{#if (or flags.isProvider flags.isMobX)}} export 'package:provider/provider.dart' hide Dispose; {{/if}} {{#if flags.isBloc}} @@ -30,19 +32,19 @@ export 'package:get/get_instance/get_instance.dart'; {{/if}} {{/if}} {{#if flags.isMobX}} -export 'package:mobx/mobx.dart'; -export 'package:flutter_mobx/flutter_mobx.dart'; +export 'package:mobx/mobx.dart' hide version, StringExtension, Action, Listener, Listenable; +export 'package:flutter_mobx/flutter_mobx.dart' hide version; {{/if}} {{#if (eq flags.routerPackage "go_router")}} export 'package:{{flags.routerPackage}}/{{flags.routerPackage}}.dart'; {{else if (eq flags.routerPackage "auto_route")}} -export 'package:auto_route/auto_route.dart'; +export 'package:auto_route/auto_route.dart' hide kCupertinoModalBarrierColor, CupertinoPageTransition, CupertinoFullscreenDialogTransition; {{/if}} {{#if flags.usesDio}} export 'package:dio/dio.dart'; {{/if}} {{#if flags.usesHttp}} -export 'package:http/http.dart'; +export 'package:http/http.dart'{{#if flags.usesDio}} hide MultipartFile, Response{{/if}}; {{/if}} {{#if flags.usesCachedNetworkImage}} export 'package:cached_network_image/cached_network_image.dart'; diff --git a/templates/flutter/base/lib/src/routing/app_router.dart.hbs b/templates/flutter/base/lib/src/routing/app_router.dart.hbs index 37831ce..835311d 100644 --- a/templates/flutter/base/lib/src/routing/app_router.dart.hbs +++ b/templates/flutter/base/lib/src/routing/app_router.dart.hbs @@ -74,124 +74,38 @@ final GoRouter appRouter = GoRouter( import 'package:auto_route/auto_route.dart'; import 'package:{{flags.appSnake}}/src/routing/global_navigator.dart'; import 'package:{{flags.appSnake}}/src/routing/app_routes.dart'; +export 'app_router.gr.dart'; +import 'app_router.gr.dart'; -{{#if (eq architecture "layer-first")}} -import 'package:{{flags.appSnake}}/src/presentation/screens/auth/login_screen.dart'; -import 'package:{{flags.appSnake}}/src/presentation/screens/auth/signup_screen.dart'; -import 'package:{{flags.appSnake}}/src/presentation/screens/auth/forgot_password_screen.dart'; -{{else if (eq architecture "mvc")}} -import 'package:{{flags.appSnake}}/src/views/auth/login_screen.dart'; -import 'package:{{flags.appSnake}}/src/views/auth/signup_screen.dart'; -import 'package:{{flags.appSnake}}/src/views/auth/forgot_password_screen.dart'; -{{else if (eq architecture "mvvm")}} -import 'package:{{flags.appSnake}}/src/ui/auth/login_screen.dart'; -import 'package:{{flags.appSnake}}/src/ui/auth/signup_screen.dart'; -import 'package:{{flags.appSnake}}/src/ui/auth/forgot_password_screen.dart'; -{{else}} -import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/login_screen.dart'; -import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/signup_screen.dart'; -import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/forgot_password_screen.dart'; -{{/if}} - -{{#if (eq architecture "layer-first")}} -import 'package:{{flags.appSnake}}/src/presentation/screens/home/home_page.dart'; -import 'package:{{flags.appSnake}}/src/presentation/screens/onboarding/onboarding_page.dart'; - -{{else if (eq architecture "mvc")}} -import 'package:{{flags.appSnake}}/src/views/home/home_page.dart'; -import 'package:{{flags.appSnake}}/src/views/onboarding/onboarding_page.dart'; - -{{else if (eq architecture "mvvm")}} -import 'package:{{flags.appSnake}}/src/ui/home/home_page.dart'; -import 'package:{{flags.appSnake}}/src/ui/onboarding/onboarding_page.dart'; - -{{else}} -import 'package:{{flags.appSnake}}/src/features/home/presentation/screens/home_page.dart'; -import 'package:{{flags.appSnake}}/src/features/onboarding/presentation/screens/onboarding_page.dart'; - -{{/if}} - +@AutoRouterConfig() class AppRouter extends RootStackRouter { AppRouter() : super(navigatorKey: rootNavigatorKey); @override - final Map pagesMap = { - OnboardingRoute.name: (routeData) => AutoRoutePage( - routeData: routeData, - child: const OnboardingPage(), - ), - LoginRoute.name: (routeData) => AutoRoutePage( - routeData: routeData, - child: const LoginScreen(), - ), - SignupRoute.name: (routeData) => AutoRoutePage( - routeData: routeData, - child: const SignupScreen(), - ), - ForgotPasswordRoute.name: (routeData) => AutoRoutePage( - routeData: routeData, - child: const ForgotPasswordScreen(), - ), - HomeRoute.name: (routeData) => AutoRoutePage( - routeData: routeData, - child: const HomePage(), - ), - }; - - @override - List get routes => [ - RouteConfig( - OnboardingRoute.name, + List get routes => [ + AutoRoute( + page: OnboardingRoute.page, path: AppRoutes.onboarding, + initial: true, ), - RouteConfig( - LoginRoute.name, + AutoRoute( + page: LoginRoute.page, path: AppRoutes.login, ), - RouteConfig( - SignupRoute.name, + AutoRoute( + page: SignupRoute.page, path: AppRoutes.signup, ), - RouteConfig( - ForgotPasswordRoute.name, + AutoRoute( + page: ForgotPasswordRoute.page, path: AppRoutes.forgotPassword, ), - RouteConfig( - HomeRoute.name, + AutoRoute( + page: HomeRoute.page, path: AppRoutes.home, ), ]; } - -class OnboardingRoute extends PageRouteInfo { - const OnboardingRoute() : super(name, path: AppRoutes.onboarding); - - static const String name = 'OnboardingRoute'; -} - -class LoginRoute extends PageRouteInfo { - const LoginRoute() : super(name, path: AppRoutes.login); - - static const String name = 'LoginRoute'; -} - -class SignupRoute extends PageRouteInfo { - const SignupRoute() : super(name, path: AppRoutes.signup); - - static const String name = 'SignupRoute'; -} - -class ForgotPasswordRoute extends PageRouteInfo { - const ForgotPasswordRoute() : super(name, path: AppRoutes.forgotPassword); - - static const String name = 'ForgotPasswordRoute'; -} - -class HomeRoute extends PageRouteInfo { - const HomeRoute() : super(name, path: AppRoutes.home); - - static const String name = 'HomeRoute'; -} {{else if (eq flags.routerPackage "getx")}} import 'package:{{flags.appSnake}}/src/imports/imports.dart'; import 'package:{{flags.appSnake}}/src/routing/app_routes.dart'; diff --git a/templates/flutter/base/lib/src/services/services.dart.hbs b/templates/flutter/base/lib/src/services/services.dart.hbs index a08b17d..a3d47f4 100644 --- a/templates/flutter/base/lib/src/services/services.dart.hbs +++ b/templates/flutter/base/lib/src/services/services.dart.hbs @@ -3,7 +3,7 @@ export 'internet_connection_service.dart'; {{#if flags.usesDio}} export 'dio_service.dart'; {{/if}} -{{#if flags.usesHttp}} +{{#if (and flags.usesHttp (not flags.usesDio))}} export 'http_service.dart'; {{/if}} {{#if flags.usesHive}} diff --git a/templates/flutter/base/lib/src/shared/widgets/app_button.dart.hbs b/templates/flutter/base/lib/src/shared/widgets/app_button.dart.hbs index 236f627..d80c828 100644 --- a/templates/flutter/base/lib/src/shared/widgets/app_button.dart.hbs +++ b/templates/flutter/base/lib/src/shared/widgets/app_button.dart.hbs @@ -46,26 +46,26 @@ class AppButton extends StatelessWidget { final appColors = context.theme.extension()!; final isDisabled = onPressed == null || isLoading; - final buttonHeight = switch (height) { + final double buttonHeight = switch (height) { ButtonSize.small => {{res 36 'h' flags.usesScreenutil}}, ButtonSize.medium => {{res 48 'h' flags.usesScreenutil}}, ButtonSize.large => {{res 56 'h' flags.usesScreenutil}}, }; - final buttonWidth = switch (width) { + final double? buttonWidth = switch (width) { ButtonSize.small => {{res 100 'w' flags.usesScreenutil}}, ButtonSize.medium => {{res 150 'w' flags.usesScreenutil}}, ButtonSize.large => {{res 200 'w' flags.usesScreenutil}}, null => null, }; - final horizontalPadding = switch (height) { + final double horizontalPadding = switch (height) { ButtonSize.small => {{res 12 'w' flags.usesScreenutil}}, ButtonSize.medium => {{res 20 'w' flags.usesScreenutil}}, ButtonSize.large => {{res 28 'w' flags.usesScreenutil}}, }; - final fontSize = switch (height) { + final double fontSize = switch (height) { ButtonSize.small => {{res 12 'sp' flags.usesScreenutil}}, ButtonSize.medium => {{res 14 'sp' flags.usesScreenutil}}, ButtonSize.large => {{res 16 'sp' flags.usesScreenutil}}, @@ -86,8 +86,8 @@ class AppButton extends StatelessWidget { child: isLoading ? SizedBox( key: const ValueKey('loader'), - width: 20, - height: 20, + width: {{res 20 'w' flags.usesScreenutil}}, + height: {{res 20 'h' flags.usesScreenutil}}, child: CircularProgressIndicator( strokeWidth: 2, color: fg, @@ -99,7 +99,7 @@ class AppButton extends StatelessWidget { children: [ if (prefixIcon != null) ...[ prefixIcon!, - const SizedBox(width: 8), + {{#if flags.usesScreenutil}}SizedBox(width: 8.w){{else}}const SizedBox(width: 8){{/if}}, ], Text( label, @@ -110,7 +110,7 @@ class AppButton extends StatelessWidget { ), ), if (suffixIcon != null) ...[ - const SizedBox(width: 8), + {{#if flags.usesScreenutil}}SizedBox(width: 8.w){{else}}const SizedBox(width: 8){{/if}}, suffixIcon!, ], ], diff --git a/templates/flutter/base/lib/src/shared/widgets/app_card.dart.hbs b/templates/flutter/base/lib/src/shared/widgets/app_card.dart.hbs index 3e9ee59..390a271 100644 --- a/templates/flutter/base/lib/src/shared/widgets/app_card.dart.hbs +++ b/templates/flutter/base/lib/src/shared/widgets/app_card.dart.hbs @@ -64,7 +64,7 @@ class AppCard extends StatelessWidget { ), child: Row( children: [ - if (leading != null) ...[leading!, const SizedBox(width: 12)], + if (leading != null) ...[leading!, {{#if flags.usesScreenutil}}SizedBox(width: 12.w){{else}}const SizedBox(width: 12){{/if}}], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/templates/flutter/base/lib/src/shared/widgets/app_empty_state.dart.hbs b/templates/flutter/base/lib/src/shared/widgets/app_empty_state.dart.hbs index e145ff1..e3cf80b 100644 --- a/templates/flutter/base/lib/src/shared/widgets/app_empty_state.dart.hbs +++ b/templates/flutter/base/lib/src/shared/widgets/app_empty_state.dart.hbs @@ -35,12 +35,12 @@ class AppEmptyState extends StatelessWidget { return Center( child: Padding( - padding: const EdgeInsets.all(40), + padding: {{#if flags.usesScreenutil}}EdgeInsets.all(40.w){{else}}const EdgeInsets.all(40){{/if}}, child: Column( mainAxisSize: MainAxisSize.min, children: [ - AppIcon(icon: icon, size: 64, color: cs.onSurfaceVariant.withValues(alpha: 0.5)), - const SizedBox(height: 20), + AppIcon(icon: icon, size: {{res 64 'sp' flags.usesScreenutil}}, color: cs.onSurfaceVariant.withValues(alpha: 0.5)), + {{#if flags.usesScreenutil}}SizedBox(height: 20.h){{else}}const SizedBox(height: 20){{/if}}, Text( title, style: tt.titleMedium?.copyWith( @@ -50,7 +50,7 @@ class AppEmptyState extends StatelessWidget { textAlign: TextAlign.center, ), if (subtitle != null) ...[ - const SizedBox(height: 8), + {{#if flags.usesScreenutil}}SizedBox(height: 8.h){{else}}const SizedBox(height: 8){{/if}}, Text( subtitle!, style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), @@ -58,7 +58,7 @@ class AppEmptyState extends StatelessWidget { ), ], if (actionLabel != null && onAction != null) ...[ - const SizedBox(height: 28), + {{#if flags.usesScreenutil}}SizedBox(height: 28.h){{else}}const SizedBox(height: 28){{/if}}, AppButton( label: actionLabel!, onPressed: onAction, diff --git a/templates/flutter/base/lib/src/shared/widgets/app_icon.dart.hbs b/templates/flutter/base/lib/src/shared/widgets/app_icon.dart.hbs index ad8db1b..8ba5ae4 100644 --- a/templates/flutter/base/lib/src/shared/widgets/app_icon.dart.hbs +++ b/templates/flutter/base/lib/src/shared/widgets/app_icon.dart.hbs @@ -1,8 +1,6 @@ import '../../imports/imports.dart'; /// A wrapper widget that handles different icon libraries. -/// -/// It automatically switches between [Icon] and [HugeIcon] based on the provided data. class AppIcon extends StatelessWidget { const AppIcon({ super.key, @@ -11,27 +9,15 @@ class AppIcon extends StatelessWidget { this.color, }); - /// The icon to display. Can be [IconData] or HugeIcon data (List>). - final dynamic icon; + /// The icon to display. + final IconData icon; final double? size; final Color? color; @override Widget build(BuildContext context) { - if (icon == null) return const SizedBox.shrink(); - - {{#if flags.usesHugeicons}} - if (icon is! IconData) { - return HugeIcon( - icon: icon, - size: size ?? 24, - color: color ?? context.theme.iconTheme.color, - ); - } - {{/if}} - return Icon( - icon as IconData, + icon, size: size, color: color, ); diff --git a/templates/flutter/base/lib/src/shared/widgets/app_loading.dart.hbs b/templates/flutter/base/lib/src/shared/widgets/app_loading.dart.hbs index c705670..b6443ef 100644 --- a/templates/flutter/base/lib/src/shared/widgets/app_loading.dart.hbs +++ b/templates/flutter/base/lib/src/shared/widgets/app_loading.dart.hbs @@ -38,7 +38,7 @@ class AppLoading extends StatelessWidget { ), ), if (message != null) ...[ - const SizedBox(height: 16), + {{#if flags.usesScreenutil}}SizedBox(height: 16.h){{else}}const SizedBox(height: 16){{/if}}, Text( message!, style: context.theme.textTheme.bodyMedium?.copyWith( diff --git a/templates/flutter/base/lib/src/shared/widgets/app_top_bar.dart.hbs b/templates/flutter/base/lib/src/shared/widgets/app_top_bar.dart.hbs index 18509b6..ac6bff1 100644 --- a/templates/flutter/base/lib/src/shared/widgets/app_top_bar.dart.hbs +++ b/templates/flutter/base/lib/src/shared/widgets/app_top_bar.dart.hbs @@ -73,9 +73,9 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { child: ColoredBox( color: Colors.transparent, child: {{#if flags.usesHugeicons}} - const HugeIcon( + HugeIcon( icon: HugeIcons.strokeRoundedArrowLeft01, - size: 24, + size: {{res 24 'sp' flags.usesScreenutil}}, ) {{else if flags.usesIconsaxPlus}} Icon( diff --git a/templates/flutter/base/lib/src/shared/widgets/common_image.dart.hbs b/templates/flutter/base/lib/src/shared/widgets/common_image.dart.hbs index 47d891e..cfd2807 100644 --- a/templates/flutter/base/lib/src/shared/widgets/common_image.dart.hbs +++ b/templates/flutter/base/lib/src/shared/widgets/common_image.dart.hbs @@ -95,9 +95,9 @@ class CommonImage extends StatelessWidget { height: height, color: Colors.grey[200], child: {{#if flags.usesHugeicons}} - const HugeIcon( + HugeIcon( icon: HugeIcons.strokeRoundedImageNotFound02, - size: 24, + size: {{res 24 'sp' flags.usesScreenutil}}, ) {{else if flags.usesIconsaxPlus}} const Icon( diff --git a/templates/flutter/base/lib/src/shared/wrappers/state_wrapper.dart.hbs b/templates/flutter/base/lib/src/shared/wrappers/(isRiverpod,isProvider,isBloc,isGetX,isMobX)@state_wrapper.dart.hbs similarity index 89% rename from templates/flutter/base/lib/src/shared/wrappers/state_wrapper.dart.hbs rename to templates/flutter/base/lib/src/shared/wrappers/(isRiverpod,isProvider,isBloc,isGetX,isMobX)@state_wrapper.dart.hbs index 9c52867..9bac652 100644 --- a/templates/flutter/base/lib/src/shared/wrappers/state_wrapper.dart.hbs +++ b/templates/flutter/base/lib/src/shared/wrappers/(isRiverpod,isProvider,isBloc,isGetX,isMobX)@state_wrapper.dart.hbs @@ -6,7 +6,6 @@ import '../../{{#if (eq architecture "layer-first")}}presentation/providers/sess import '../../{{#if (eq architecture "layer-first")}}data/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/data/repositories/{{/if}}auth_repository_impl.dart'; import '../../{{#if (eq architecture "layer-first")}}presentation/providers/session_provider.dart{{else if (eq architecture "mvc")}}controllers/auth/session_provider.dart{{else if (eq architecture "mvvm")}}ui/auth/providers/session_provider.dart{{else}}features/auth/presentation/providers/session_provider.dart{{/if}}'; {{else if flags.isMobX}} -import 'package:provider/provider.dart'; import '../../{{#if (eq architecture "layer-first")}}data/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/data/repositories/{{/if}}auth_repository_impl.dart'; import '../../{{#if (eq architecture "layer-first")}}presentation/providers/session_store.dart{{else if (eq architecture "mvc")}}controllers/auth/session_store.dart{{else if (eq architecture "mvvm")}}ui/auth/stores/session_store.dart{{else}}features/auth/presentation/providers/session_store.dart{{/if}}'; {{/if}} @@ -27,21 +26,21 @@ class StateWrapper extends StatelessWidget { {{else if flags.isProvider}} return MultiProvider( providers: [ - ChangeNotifierProvider(create: (_) => SessionProvider(repository: AuthRepositoryImpl())), + ChangeNotifierProvider(create: (_) => SessionProvider(repository: AuthRepositoryImpl())), ], child: child, ); {{else if flags.isBloc}} return MultiBlocProvider( providers: [ - BlocProvider(create: (_) => SessionBloc(repository: AuthRepositoryImpl())), + BlocProvider(create: (_) => SessionBloc(repository: AuthRepositoryImpl())), ], child: child, ); {{else if flags.isMobX}} return MultiProvider( providers: [ - Provider(create: (_) => SessionStore(repository: AuthRepositoryImpl())), + Provider(create: (_) => SessionStore(repository: AuthRepositoryImpl())), ], child: child, ); diff --git a/templates/flutter/base/lib/src/shared/wrappers/localization_wrapper.dart.hbs b/templates/flutter/base/lib/src/shared/wrappers/(supportsLocalization)@localization_wrapper.dart.hbs similarity index 88% rename from templates/flutter/base/lib/src/shared/wrappers/localization_wrapper.dart.hbs rename to templates/flutter/base/lib/src/shared/wrappers/(supportsLocalization)@localization_wrapper.dart.hbs index 6831ddb..11999f4 100644 --- a/templates/flutter/base/lib/src/shared/wrappers/localization_wrapper.dart.hbs +++ b/templates/flutter/base/lib/src/shared/wrappers/(supportsLocalization)@localization_wrapper.dart.hbs @@ -11,7 +11,6 @@ class LocalizationWrapper extends StatelessWidget { @override Widget build(BuildContext context) { - {{#if flags.supportsLocalization}} return EasyLocalization( supportedLocales: const [ {{#each flags.supportedLocales}} @@ -22,8 +21,5 @@ class LocalizationWrapper extends StatelessWidget { fallbackLocale: const Locale('{{flags.fallbackLocale}}'), child: child, ); - {{else}} - return child; - {{/if}} } } diff --git a/templates/flutter/base/lib/src/shared/wrappers/screen_util_wrapper.dart.hbs b/templates/flutter/base/lib/src/shared/wrappers/(usesScreenutil)@screen_util_wrapper.dart.hbs similarity index 94% rename from templates/flutter/base/lib/src/shared/wrappers/screen_util_wrapper.dart.hbs rename to templates/flutter/base/lib/src/shared/wrappers/(usesScreenutil)@screen_util_wrapper.dart.hbs index 015229a..20628d3 100644 --- a/templates/flutter/base/lib/src/shared/wrappers/screen_util_wrapper.dart.hbs +++ b/templates/flutter/base/lib/src/shared/wrappers/(usesScreenutil)@screen_util_wrapper.dart.hbs @@ -1,4 +1,3 @@ -{{#if flags.usesScreenutil}} import '../../imports/imports.dart'; /// A wrapper to initialize [ScreenUtil] with design-specific constraints. @@ -26,4 +25,3 @@ class ScreenUtilWrapper extends StatelessWidget { ); } } -{{/if}} diff --git a/templates/flutter/base/lib/src/shared/wrappers/skeleton_wrapper.dart.hbs b/templates/flutter/base/lib/src/shared/wrappers/(usesSkeletonizer)@skeleton_wrapper.dart.hbs similarity index 100% rename from templates/flutter/base/lib/src/shared/wrappers/skeleton_wrapper.dart.hbs rename to templates/flutter/base/lib/src/shared/wrappers/(usesSkeletonizer)@skeleton_wrapper.dart.hbs diff --git a/templates/flutter/base/lib/src/shared/wrappers/session_listener_wrapper.dart.hbs b/templates/flutter/base/lib/src/shared/wrappers/session_listener_wrapper.dart.hbs index f10cf36..6c35c75 100644 --- a/templates/flutter/base/lib/src/shared/wrappers/session_listener_wrapper.dart.hbs +++ b/templates/flutter/base/lib/src/shared/wrappers/session_listener_wrapper.dart.hbs @@ -1,5 +1,7 @@ import 'package:{{flags.appSnake}}/src/imports/core_imports.dart'; +{{#unless flags.isNoneState}} import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; +{{/unless}} {{#if flags.isRiverpod}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_provider.dart{{else if (eq architecture "mvc")}}controllers/auth/session_provider.dart{{else if (eq architecture "mvvm")}}ui/auth/providers/session_provider.dart{{else}}features/auth/presentation/providers/session_provider.dart{{/if}}'; @@ -11,6 +13,8 @@ import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}pr import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_store.dart{{else if (eq architecture "mvc")}}controllers/auth/session_store.dart{{else if (eq architecture "mvvm")}}ui/auth/stores/session_store.dart{{else}}features/auth/presentation/providers/session_store.dart{{/if}}'; {{else if flags.isProvider}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_provider.dart{{else if (eq architecture "mvc")}}controllers/auth/session_provider.dart{{else if (eq architecture "mvvm")}}ui/auth/providers/session_provider.dart{{else}}features/auth/presentation/providers/session_provider.dart{{/if}}'; +{{else if flags.isNoneState}} +// No session listener needed for NoneState — widget rebuilds are manual {{else}} // session_manager not easily retrieved via context globally if not using Provider, assuming single instance import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_manager.dart{{else if (eq architecture "mvc")}}controllers/auth/session_manager.dart{{else if (eq architecture "mvvm")}}ui/auth/view_models/session_manager.dart{{else}}features/auth/presentation/providers/session_manager.dart{{/if}}'; diff --git a/templates/flutter/base/pubspec.yaml.hbs b/templates/flutter/base/pubspec.yaml.hbs index 37af354..bb1bab1 100644 --- a/templates/flutter/base/pubspec.yaml.hbs +++ b/templates/flutter/base/pubspec.yaml.hbs @@ -15,7 +15,7 @@ dependencies: {{#if (eq flags.routerPackage "go_router")}} go_router: ^17.1.0 {{else if (eq flags.routerPackage "auto_route")}} - auto_route: ^10.1.2 + auto_route: ^11.1.0 {{else if (eq flags.routerPackage "getx")}} get: ^4.7.3 {{/if}} @@ -33,26 +33,27 @@ dependencies: fpdart: ^1.2.0 equatable: ^2.0.7 {{#if flags.usesFlutterHooks}} - flutter_hooks: ^0.20.5 + flutter_hooks: ^0.21.3+1 {{/if}} {{#if flags.isRiverpod}} - flutter_riverpod: ^2.6.1 + flutter_riverpod: ^3.2.1 {{#if flags.usesFlutterHooks}} - hooks_riverpod: ^2.6.1 + hooks_riverpod: ^3.2.1 {{/if}} {{/if}} - {{#if flags.isProvider}} - provider: ^6.1.5+1 - {{/if}} {{#if flags.isBloc}} flutter_bloc: ^9.1.1 {{/if}} {{#if flags.isGetX}} get: ^4.7.3 {{/if}} + {{#if flags.isProvider}} + provider: ^6.1.5+1 + {{/if}} {{#if flags.isMobX}} mobx: ^2.6.0 flutter_mobx: ^2.3.0 + provider: ^6.1.5+1 {{/if}} # Backend – Firebase @@ -200,7 +201,14 @@ dev_dependencies: mobx_codegen: ^2.7.6 {{/if}} {{#if (eq flags.routerPackage "auto_route")}} - auto_route_generator: ^10.4.0 + auto_route_generator: ^10.5.0 +{{/if}} + +dependency_overrides: + {{#if flags.isRiverpod}} + # Riverpod 3.x requires a newer test_api than what Flutter SDK pins. + # This override allows resolution while remaining compatible with flutter_test. + test_api: 0.7.6 {{/if}} flutter: diff --git a/templates/flutter/base/test/widget_test.dart.hbs b/templates/flutter/base/test/widget_test.dart.hbs index 7ebdf3d..485482a 100644 --- a/templates/flutter/base/test/widget_test.dart.hbs +++ b/templates/flutter/base/test/widget_test.dart.hbs @@ -7,14 +7,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:{{snakeCase flags.appSlug}}/src/app.dart'; {{#if flags.supportsLocalization}} import 'package:easy_localization/easy_localization.dart'; +{{#if flags.usesSharedPreferences}} import 'package:shared_preferences/shared_preferences.dart'; {{/if}} +{{/if}} void main() { testWidgets('App should build', (WidgetTester tester) async { // Build our app and trigger a frame. {{#if flags.supportsLocalization}} + {{#if flags.usesSharedPreferences}} SharedPreferences.setMockInitialValues({}); + {{/if}} await EasyLocalization.ensureInitialized(); {{/if}} diff --git a/templates/flutter/overlays/architecture/layer-first/lib/src/presentation/providers/(isNoneState)@auth_view_model.dart.hbs b/templates/flutter/overlays/architecture/layer-first/lib/src/presentation/providers/(isNoneState)@auth_view_model.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/templates/flutter/overlays/architecture/layer-first/lib/src/presentation/providers/(isNoneState)@auth_view_model.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/templates/flutter/overlays/backend/appwrite/lib/src/services/(usesAppwriteAuth)@auth_service.dart.hbs b/templates/flutter/overlays/backend/appwrite/lib/src/services/(usesAppwriteAuth)@auth_service.dart.hbs index 7b0ad89..9c8572f 100644 --- a/templates/flutter/overlays/backend/appwrite/lib/src/services/(usesAppwriteAuth)@auth_service.dart.hbs +++ b/templates/flutter/overlays/backend/appwrite/lib/src/services/(usesAppwriteAuth)@auth_service.dart.hbs @@ -2,7 +2,6 @@ import 'dart:async'; import '../utils/utils.dart'; import '../config/app_config.dart'; import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart' as appwrite_models; class AuthService { AuthService._(); @@ -22,7 +21,7 @@ class AuthService { required String password, }) async { return runTask(() async { - final session = await _account.createEmailPasswordSession( + await _account.createEmailPasswordSession( email: email, password: password ); diff --git a/templates/flutter/overlays/backend/custom/lib/src/services/auth_service.dart.hbs b/templates/flutter/overlays/backend/custom/lib/src/services/auth_service.dart.hbs index f5891fd..0799112 100644 --- a/templates/flutter/overlays/backend/custom/lib/src/services/auth_service.dart.hbs +++ b/templates/flutter/overlays/backend/custom/lib/src/services/auth_service.dart.hbs @@ -31,15 +31,15 @@ class AuthService { }) async { return runTask(() async { {{#if flags.usesDio}} - final response = await _dio.post('/auth/login', data: { + final response = await _dio.post>('/auth/login', data: { 'email': email, 'password': password, }); - final data = response.data as Map; + final data = response.data!; _authStateController.add(data); return data; {{else if flags.usesHttp}} - final response = await _http.post( + final http.Response response = await _http.post( Uri.parse('${AppConfig.baseUrl}/auth/login'), body: jsonEncode({'email': email, 'password': password}), headers: {'Content-Type': 'application/json'}, @@ -72,16 +72,16 @@ class AuthService { }) async { return runTask(() async { {{#if flags.usesDio}} - final response = await _dio.post('/auth/signup', data: { + final response = await _dio.post>('/auth/signup', data: { 'name': name, 'email': email, 'password': password, }); - final data = response.data as Map; + final data = response.data!; _authStateController.add(data); return data; {{else if flags.usesHttp}} - final response = await _http.post( + final http.Response response = await _http.post( Uri.parse('${AppConfig.baseUrl}/auth/signup'), body: jsonEncode({ 'name': name, @@ -111,9 +111,9 @@ class AuthService { FutureEither forgotPassword({required String email}) async { return runTask(() async { {{#if flags.usesDio}} - await _dio.post('/auth/forgot-password', data: {'email': email}); + await _dio.post('/auth/forgot-password', data: {'email': email}); {{else if flags.usesHttp}} - await _http.post( + final http.Response _ = await _http.post( Uri.parse('${AppConfig.baseUrl}/auth/forgot-password'), body: jsonEncode({'email': email}), headers: {'Content-Type': 'application/json'}, @@ -127,9 +127,9 @@ class AuthService { FutureEither logout() async { return runTask(() async { {{#if flags.usesDio}} - await _dio.post('/auth/logout'); + await _dio.post('/auth/logout'); {{else if flags.usesHttp}} - await _http.post(Uri.parse('${AppConfig.baseUrl}/auth/logout')); + final http.Response _ = await _http.post(Uri.parse('${AppConfig.baseUrl}/auth/logout')); {{else}} await Future.delayed(const Duration(seconds: 1)); {{/if}} @@ -140,10 +140,10 @@ class AuthService { FutureEither?> getCurrentUser() async { return runTask(() async { {{#if flags.usesDio}} - final response = await _dio.get('/auth/me'); - return response.data as Map; + final response = await _dio.get>('/auth/me'); + return response.data; {{else if flags.usesHttp}} - final response = await _http.get(Uri.parse('${AppConfig.baseUrl}/auth/me')); + final http.Response response = await _http.get(Uri.parse('${AppConfig.baseUrl}/auth/me')); if (response.statusCode != 200) return null; return jsonDecode(response.body) as Map; {{else}} diff --git a/templates/flutter/partials/features/auth/auth_logic.hbs b/templates/flutter/partials/features/auth/auth_logic.hbs index 5fdeed0..584552f 100644 --- a/templates/flutter/partials/features/auth/auth_logic.hbs +++ b/templates/flutter/partials/features/auth/auth_logic.hbs @@ -1,5 +1,11 @@ import 'package:{{flags.appSnake}}/src/imports/core_imports.dart'; +{{#if (or flags.isRiverpod flags.isBloc flags.isMobX flags.isGetX)}} import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; +{{else if (or flags.isProvider flags.isNoneState)}} +{{#if (or (eq flags.routerPackage "go_router") (eq flags.routerPackage "auto_route"))}} +import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; +{{/if}} +{{/if}} {{#if flags.isRiverpod}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/domain/repositories/{{/if}}auth_repository.dart'; @@ -31,7 +37,11 @@ class AuthController extends StateNotifier { state = false; result.fold( - (failure) => showToast(context, message: failure.message, status: 'error'), + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, (user) { if (rootContext?.mounted ?? false) { {{#if (eq flags.routerPackage "go_router")}} @@ -50,10 +60,14 @@ class AuthController extends StateNotifier { state = true; final result = await _repository.signUp(name: name, email: email, password: password); - + state = false; result.fold( - (failure) => showToast(context, message: failure.message, status: 'error'), + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, (user) { if (rootContext?.mounted ?? false) { {{#if (eq flags.routerPackage "go_router")}} @@ -75,9 +89,15 @@ class AuthController extends StateNotifier { state = false; result.fold( - (failure) => showToast(context, message: failure.message, status: 'error'), + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, (success) { - showToast(context, message: 'Password reset link sent successfully', status: 'success'); + if (context.mounted) { + showToast(context, message: 'Password reset link sent successfully', status: 'success'); + } if (context.mounted) { {{#if (eq flags.routerPackage "go_router")}} context.go(AppRoutes.login); @@ -114,7 +134,9 @@ class AuthBloc extends Bloc { result.fold( (failure) { emit(state.copyWith(isLoading: false)); - showToast(event.context, message: failure.message, status: 'error'); + if (event.context.mounted) { + showToast(event.context, message: failure.message, status: 'error'); + } }, (user) { emit(state.copyWith(isLoading: false)); @@ -142,7 +164,9 @@ class AuthBloc extends Bloc { result.fold( (failure) { emit(state.copyWith(isLoading: false)); - showToast(event.context, message: failure.message, status: 'error'); + if (event.context.mounted) { + showToast(event.context, message: failure.message, status: 'error'); + } }, (user) { emit(state.copyWith(isLoading: false)); @@ -170,11 +194,15 @@ class AuthBloc extends Bloc { result.fold( (failure) { emit(state.copyWith(isLoading: false)); - showToast(event.context, message: failure.message, status: 'error'); + if (event.context.mounted) { + showToast(event.context, message: failure.message, status: 'error'); + } }, (success) { emit(state.copyWith(isLoading: false)); - showToast(event.context, message: 'Password reset link sent successfully', status: 'success'); + if (event.context.mounted) { + showToast(event.context, message: 'Password reset link sent successfully', status: 'success'); + } if (event.context.mounted) { {{#if (eq flags.routerPackage "go_router")}} event.context.go(AppRoutes.login); @@ -251,7 +279,9 @@ class AuthViewModel extends ChangeNotifier { _setLoading(false); result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (user) { if (context.mounted) { @@ -275,7 +305,9 @@ class AuthViewModel extends ChangeNotifier { _setLoading(false); result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (user) { if (context.mounted) { @@ -299,10 +331,14 @@ class AuthViewModel extends ChangeNotifier { _setLoading(false); result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (success) { - showToast(context, message: 'Password reset link sent successfully', status: 'success'); + if (context.mounted) { + showToast(context, message: 'Password reset link sent successfully', status: 'success'); + } }, ); } @@ -332,7 +368,9 @@ class AuthProvider extends ChangeNotifier { _setLoading(false); result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (user) { if (context.mounted) { @@ -356,7 +394,9 @@ class AuthProvider extends ChangeNotifier { _setLoading(false); result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (user) { if (context.mounted) { @@ -380,10 +420,14 @@ class AuthProvider extends ChangeNotifier { _setLoading(false); result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (success) { - showToast(context, message: 'Password reset link sent successfully', status: 'success'); + if (context.mounted) { + showToast(context, message: 'Password reset link sent successfully', status: 'success'); + } if (context.mounted) { {{#if (eq flags.routerPackage "go_router")}} context.go(AppRoutes.login); @@ -415,7 +459,9 @@ class AuthController extends GetxController { isLoading.value = false; result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (user) { Get.offAllNamed(AppRoutes.home); @@ -431,7 +477,9 @@ class AuthController extends GetxController { isLoading.value = false; result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (user) { Get.offAllNamed(AppRoutes.home); @@ -447,10 +495,14 @@ class AuthController extends GetxController { isLoading.value = false; result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (success) { - showToast(context, message: 'Password reset link sent successfully', status: 'success'); + if (context.mounted) { + showToast(context, message: 'Password reset link sent successfully', status: 'success'); + } Get.offNamed(AppRoutes.login); }, ); @@ -461,12 +513,12 @@ import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}do part 'auth_store.g.dart'; -class AuthStore = _AuthStore with _$AuthStore; +class AuthStore = AuthStoreBase with _$AuthStore; -abstract class _AuthStore with Store { +abstract class AuthStoreBase with Store { final AuthRepository _repository; - _AuthStore({required AuthRepository repository}) : _repository = repository; + AuthStoreBase({required AuthRepository repository}) : _repository = repository; @observable bool isLoading = false; @@ -480,7 +532,9 @@ abstract class _AuthStore with Store { isLoading = false; result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (user) { if (context.mounted) { @@ -505,7 +559,9 @@ abstract class _AuthStore with Store { isLoading = false; result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (user) { if (context.mounted) { @@ -530,10 +586,14 @@ abstract class _AuthStore with Store { isLoading = false; result.fold( (failure) { - showToast(context, message: failure.message, status: 'error'); + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } }, (success) { - showToast(context, message: 'Password reset link sent successfully', status: 'success'); + if (context.mounted) { + showToast(context, message: 'Password reset link sent successfully', status: 'success'); + } if (context.mounted) { {{#if (eq flags.routerPackage "go_router")}} context.go(AppRoutes.login); diff --git a/templates/flutter/partials/features/auth/forgot_password_screen.hbs b/templates/flutter/partials/features/auth/forgot_password_screen.hbs index e23dd8b..992af62 100644 --- a/templates/flutter/partials/features/auth/forgot_password_screen.hbs +++ b/templates/flutter/partials/features/auth/forgot_password_screen.hbs @@ -10,10 +10,14 @@ import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}pr {{else if flags.isGetX}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/controllers/auth/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/controllers/{{else}}features/auth/presentation/controllers/{{/if}}auth_controller.dart'; {{else if flags.isMobX}} +{{#if flags.usesFlutterHooks}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/stores/{{else}}features/auth/presentation/providers/{{/if}}auth_store.dart'; +{{/if}} {{else}} +{{#if flags.usesFlutterHooks}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/view_models/{{else}}features/auth/presentation/providers/{{/if}}auth_view_model.dart'; {{/if}} +{{/if}} {{#if (eq flags.routerPackage "auto_route")}} @RoutePage() @@ -94,7 +98,11 @@ class ForgotPasswordScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{e ); result.fold( - (failure) => showToast(context, message: failure.message, status: 'error'), + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, (success) { if (context.mounted) { {{#if (eq flags.routerPackage "go_router")}} @@ -108,7 +116,9 @@ class ForgotPasswordScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{e }, ); } catch (e) { - showToast(context, message: e.toString(), status: 'error'); + if (context.mounted) { + showToast(context, message: e.toString(), status: 'error'); + } } finally { isLoadingState.value = false; } @@ -126,7 +136,7 @@ class ForgotPasswordScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{e onForgotPassword: handleForgotPassword, cs: cs, tt: tt, - )); + ); {{#if flags.isGetX}} ); {{/if}} @@ -212,9 +222,13 @@ class _ForgotPasswordScreenState extends {{#if flags.isRiverpod}}ConsumerState showToast(context, message: failure.message, status: 'error'), + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, (success) { - if (mounted) { + if (context.mounted) { {{#if (eq flags.routerPackage "go_router")}} context.go(AppRoutes.login); {{else if (eq flags.routerPackage "auto_route")}} @@ -226,9 +240,11 @@ class _ForgotPasswordScreenState extends {{#if flags.isRiverpod}}ConsumerState _isLoading = false); + if (context.mounted) setState(() => _isLoading = false); } {{/if}} } diff --git a/templates/flutter/partials/features/auth/login_screen.hbs b/templates/flutter/partials/features/auth/login_screen.hbs index c3feb0a..4798756 100644 --- a/templates/flutter/partials/features/auth/login_screen.hbs +++ b/templates/flutter/partials/features/auth/login_screen.hbs @@ -10,10 +10,14 @@ import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}pr {{else if flags.isGetX}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/controllers/auth/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/controllers/{{else}}features/auth/presentation/controllers/{{/if}}auth_controller.dart'; {{else if flags.isMobX}} +{{#if flags.usesFlutterHooks}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/stores/{{else}}features/auth/presentation/providers/{{/if}}auth_store.dart'; +{{/if}} {{else}} +{{#if flags.usesFlutterHooks}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/view_models/{{else}}features/auth/presentation/providers/{{/if}}auth_view_model.dart'; {{/if}} +{{/if}} {{#if (eq flags.routerPackage "auto_route")}} @RoutePage() @@ -103,7 +107,11 @@ class LoginScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{else}}Hook ); result.fold( - (failure) => showToast(context, message: failure.message, status: 'error'), + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, (user) { if (context.mounted) { {{#if (eq flags.routerPackage "go_router")}} @@ -117,7 +125,9 @@ class LoginScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{else}}Hook }, ); } catch (e) { - showToast(context, message: e.toString(), status: 'error'); + if (context.mounted) { + showToast(context, message: e.toString(), status: 'error'); + } } finally { isLoadingState.value = false; } @@ -138,7 +148,7 @@ class LoginScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{else}}Hook onLogin: handleLogin, cs: cs, tt: tt, - )); + ); {{#if flags.isGetX}} ); {{/if}} @@ -232,9 +242,13 @@ class _LoginScreenState extends {{#if flags.isRiverpod}}ConsumerState showToast(context, message: failure.message, status: 'error'), + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, (user) { - if (mounted) { + if (context.mounted) { {{#if (eq flags.routerPackage "go_router")}} context.go(AppRoutes.home); {{else if (eq flags.routerPackage "auto_route")}} @@ -246,9 +260,11 @@ class _LoginScreenState extends {{#if flags.isRiverpod}}ConsumerState _isLoading = false); + if (context.mounted) setState(() => _isLoading = false); } {{/if}} } diff --git a/templates/flutter/partials/features/auth/session_provider.hbs b/templates/flutter/partials/features/auth/session_provider.hbs index 3598345..bd11f91 100644 --- a/templates/flutter/partials/features/auth/session_provider.hbs +++ b/templates/flutter/partials/features/auth/session_provider.hbs @@ -1,5 +1,5 @@ import 'dart:async'; -import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; +import 'package:{{flags.appSnake}}/src/imports/imports.dart'; import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/entities/user.dart{{else if (eq architecture "mvc")}}models/user_model.dart{{else if (eq architecture "mvvm")}}data/models/user_model.dart{{else}}features/auth/domain/entities/user.dart{{/if}}'; import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/domain/repositories/{{/if}}auth_repository.dart'; @@ -310,15 +310,17 @@ class SessionController extends GetxController { } } {{else if flags.isMobX}} +part 'session_store.g.dart'; + enum SessionStatus { unknown, authenticated, unauthenticated } -class SessionStore = _SessionStore with _$SessionStore; +class SessionStore = SessionStoreBase with _$SessionStore; -abstract class _SessionStore with Store { +abstract class SessionStoreBase with Store { final AuthRepository _repository; StreamSubscription? _authSub; - _SessionStore({required AuthRepository repository}) : _repository = repository { + SessionStoreBase({required AuthRepository repository}) : _repository = repository { _init(); } diff --git a/templates/flutter/partials/features/auth/signup_screen.hbs b/templates/flutter/partials/features/auth/signup_screen.hbs index da504f8..df4970e 100644 --- a/templates/flutter/partials/features/auth/signup_screen.hbs +++ b/templates/flutter/partials/features/auth/signup_screen.hbs @@ -10,10 +10,14 @@ import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}pr {{else if flags.isGetX}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/controllers/auth/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/controllers/{{else}}features/auth/presentation/controllers/{{/if}}auth_controller.dart'; {{else if flags.isMobX}} +{{#if flags.usesFlutterHooks}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/stores/{{else}}features/auth/presentation/providers/{{/if}}auth_store.dart'; +{{/if}} {{else}} +{{#if flags.usesFlutterHooks}} import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/view_models/{{else}}features/auth/presentation/providers/{{/if}}auth_view_model.dart'; {{/if}} +{{/if}} {{#if (eq flags.routerPackage "auto_route")}} @RoutePage() @@ -113,7 +117,11 @@ class SignupScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{else}}Hoo ); result.fold( - (failure) => showToast(context, message: failure.message, status: 'error'), + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, (user) { if (context.mounted) { {{#if (eq flags.routerPackage "go_router")}} @@ -127,7 +135,9 @@ class SignupScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{else}}Hoo }, ); } catch (e) { - showToast(context, message: e.toString(), status: 'error'); + if (context.mounted) { + showToast(context, message: e.toString(), status: 'error'); + } } finally { isLoadingState.value = false; } @@ -152,7 +162,7 @@ class SignupScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{else}}Hoo onSignup: handleSignup, cs: cs, tt: tt, - )); + ); {{#if flags.isGetX}} ); {{/if}} @@ -256,9 +266,13 @@ class _SignupScreenState extends {{#if flags.isRiverpod}}ConsumerState showToast(context, message: failure.message, status: 'error'), + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, (user) { - if (mounted) { + if (context.mounted) { {{#if (eq flags.routerPackage "go_router")}} context.go(AppRoutes.home); {{else if (eq flags.routerPackage "auto_route")}} @@ -270,9 +284,11 @@ class _SignupScreenState extends {{#if flags.isRiverpod}}ConsumerState _isLoading = false); + if (context.mounted) setState(() => _isLoading = false); } {{/if}} } diff --git a/templates/flutter/partials/features/home/home_page.hbs b/templates/flutter/partials/features/home/home_page.hbs index 3daf832..c760e21 100644 --- a/templates/flutter/partials/features/home/home_page.hbs +++ b/templates/flutter/partials/features/home/home_page.hbs @@ -44,23 +44,18 @@ class HomePage extends StatelessWidget { {{#if flags.isRiverpod}} final session = ref.watch(sessionProvider); - final user = session.user; {{else if flags.isBloc}} final session = context.watch().state; - final user = session.user; {{else if flags.isGetX}} final session = Get.find(); - final user = session.user.value; {{else if flags.isProvider}} final session = context.watch(); - final user = session.user; {{else if flags.isMobX}} final session = context.watch(); - final user = session.user; - {{else}} - // Add custom state retrieval here or replace with actual - final user = null; {{/if}} + {{#unless (or flags.isGetX flags.isMobX)}} + {{#if (or flags.isRiverpod flags.isBloc flags.isProvider)}}final{{else}}const{{/if}} user = {{#if flags.isRiverpod}}session.user{{else if flags.isBloc}}session.user{{else if flags.isProvider}}session.user{{else}}null{{/if}}; + {{/unless}} return Scaffold( backgroundColor: colorScheme.surface, @@ -81,25 +76,31 @@ class HomePage extends StatelessWidget { ), SizedBox(height: {{res 'AppSpacing.lg' 'h' flags.usesScreenutil}}), {{#if flags.isGetX}} - Obx(() => Text( - user?.name ?? user?.email ?? ({{#if flags.supportsLocalization}}'home.welcome_home'.tr(){{else}}'Welcome Home!'{{/if}}), - textAlign: TextAlign.center, - style: textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.w900, - color: colorScheme.onSurface, - fontSize: {{res 28 'sp' flags.usesScreenutil}}, - ), - )), + Obx(() { + final user = session.user.value; + return Text( + user?.name ?? user?.email ?? ({{#if flags.supportsLocalization}}'home.welcome_home'.tr(){{else}}'Welcome Home!'{{/if}}), + textAlign: TextAlign.center, + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w900, + color: colorScheme.onSurface, + fontSize: {{res 28 'sp' flags.usesScreenutil}}, + ), + ); + }), {{else if flags.isMobX}} - Observer(builder: (_) => Text( - session.user?.name ?? session.user?.email ?? ({{#if flags.supportsLocalization}}'home.welcome_home'.tr(){{else}}'Welcome Home!'{{/if}}), - textAlign: TextAlign.center, - style: textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.w900, - color: colorScheme.onSurface, - fontSize: {{res 28 'sp' flags.usesScreenutil}}, - ), - )), + Observer(builder: (_) { + final user = session.user; + return Text( + user?.name ?? user?.email ?? ({{#if flags.supportsLocalization}}'home.welcome_home'.tr(){{else}}'Welcome Home!'{{/if}}), + textAlign: TextAlign.center, + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w900, + color: colorScheme.onSurface, + fontSize: {{res 28 'sp' flags.usesScreenutil}}, + ), + ); + }), {{else}} Text( user?.name ?? user?.email ?? ({{#if flags.supportsLocalization}}'home.welcome_home'.tr(){{else}}'Welcome Home!'{{/if}}), @@ -113,26 +114,32 @@ class HomePage extends StatelessWidget { {{/if}} SizedBox(height: {{res 'AppSpacing.md' 'h' flags.usesScreenutil}}), {{#if flags.isGetX}} - Obx(() => Text( - user?.email != null && user?.name != null ? user!.email! : ({{#if flags.supportsLocalization}}'home.home_subtitle'.tr(){{else}}'You have successfully completed the onboarding process.'{{/if}}), - textAlign: TextAlign.center, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: {{res 14 'sp' flags.usesScreenutil}}, - ), - )), + Obx(() { + final user = session.user.value; + return Text( + user != null && user.name != null ? user.email : ({{#if flags.supportsLocalization}}'home.home_subtitle'.tr(){{else}}'You have successfully completed the onboarding process.'{{/if}}), + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: {{res 14 'sp' flags.usesScreenutil}}, + ), + ); + }), {{else if flags.isMobX}} - Observer(builder: (_) => Text( - session.user?.email != null && session.user?.name != null ? session.user!.email! : ({{#if flags.supportsLocalization}}'home.home_subtitle'.tr(){{else}}'You have successfully completed the onboarding process.'{{/if}}), - textAlign: TextAlign.center, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: {{res 14 'sp' flags.usesScreenutil}}, - ), - )), + Observer(builder: (_) { + final user = session.user; + return Text( + user != null && user.name != null ? user.email : ({{#if flags.supportsLocalization}}'home.home_subtitle'.tr(){{else}}'You have successfully completed the onboarding process.'{{/if}}), + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: {{res 14 'sp' flags.usesScreenutil}}, + ), + ); + }), {{else}} Text( - user?.email != null && user?.name != null ? user!.email : ({{#if flags.supportsLocalization}}'home.home_subtitle'.tr(){{else}}'You have successfully completed the onboarding process.'{{/if}}), + user != null && user.name != null ? user.email : ({{#if flags.supportsLocalization}}'home.home_subtitle'.tr(){{else}}'You have successfully completed the onboarding process.'{{/if}}), textAlign: TextAlign.center, style: textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, diff --git a/templates/flutter/partials/features/onboarding/onboarding_page.hbs b/templates/flutter/partials/features/onboarding/onboarding_page.hbs index 78d2ebd..708354c 100644 --- a/templates/flutter/partials/features/onboarding/onboarding_page.hbs +++ b/templates/flutter/partials/features/onboarding/onboarding_page.hbs @@ -232,7 +232,7 @@ class _OnboardingView extends StatelessWidget { ], ), ), - SizedBox(height: {{res 40 'h' flags.usesScreenutil}}), + {{#if flags.usesScreenutil}}SizedBox(height: 40.h){{else}}const SizedBox(height: 40){{/if}}, ], ); }, @@ -244,7 +244,7 @@ class _OnboardingView extends StatelessWidget { padding: EdgeInsets.all({{res 'AppSpacing.xl' 'w' flags.usesScreenutil}}), child: Column( children: [ - SizedBox(height: {{res 'AppSpacing.xl' 'h' flags.usesScreenutil}}), + {{#if flags.usesScreenutil}}SizedBox(height: AppSpacing.xl){{else}}const SizedBox(height: 32){{/if}}, // Get Started Button AppButton( label: {{#if flags.supportsLocalization}}'shared.get_started'.tr(){{else}}'Get Started'{{/if}}, @@ -252,7 +252,7 @@ class _OnboardingView extends StatelessWidget { variant: ButtonVariant.primary, width: ButtonSize.medium, ), - SizedBox(height: {{res 'AppSpacing.md' 'h' flags.usesScreenutil}}), + {{#if flags.usesScreenutil}}SizedBox(height: AppSpacing.md){{else}}const SizedBox(height: 16){{/if}}, ], ), ), diff --git a/tests/e2e/validate-combo.ts b/tests/e2e/validate-combo.ts index 66e371b..c5b17eb 100644 --- a/tests/e2e/validate-combo.ts +++ b/tests/e2e/validate-combo.ts @@ -89,6 +89,19 @@ async function main() { } console.log("✓ dart pub get passed") + // 2.5 dart run build_runner build (if needed) + const needsBuild = config.navigation === "auto_route" || config.stateManagement === "mobx" + if (needsBuild) { + console.log("\nRunning build_runner...") + const buildResult = runCommand("dart run build_runner build --delete-conflicting-outputs", projectDir) + if (!buildResult.success) { + console.error("✗ build_runner FAILED") + console.error(buildResult.output) + process.exit(1) + } + console.log("✓ build_runner passed") + } + // 3. dart analyze --fatal-infos console.log("\nRunning dart analyze --fatal-infos...") const analyzeResult = runCommand("dart analyze --fatal-infos", projectDir) diff --git a/tests/integration/full-pipeline.spec.ts b/tests/integration/full-pipeline.spec.ts index eae4778..3c2dec9 100644 --- a/tests/integration/full-pipeline.spec.ts +++ b/tests/integration/full-pipeline.spec.ts @@ -1,33 +1,24 @@ -/** - * full-pipeline.spec.ts - * - * Integration tests that exercise the full generation pipeline - * for the critical combinations. Each test generates a complete - * project and runs ALL assertion categories against it. - */ - import { describe, expect, it } from "vitest" - import { assertArchitectureStructure, assertNoEmptyFiles, assertNoUnresolvedTokens, assertRequiredFilesExist, assertValidPubspec, - getFileContent, } from "../utils/assertions" -import { generateToMap, getPubspecContent } from "../utils/generate" -import { buildConfig, combinationLabel } from "../utils/matrix.config" -import { CRITICAL_COMBINATIONS } from "../utils/critical-combos" +import { generateToMap, getPubspecContent, getFile } from "../utils/generate" +import { COMBO_LABEL } from "../utils/matrix.config" +import { buildConfig } from "../utils/config-builder" +import { CRITICAL_COMBOS } from "../utils/critical-combos" describe("Full Pipeline — Critical Combinations", () => { it.each( - CRITICAL_COMBINATIONS.map((c, i) => [i, combinationLabel(c), c] as const) + CRITICAL_COMBOS.map((c, i) => [i, COMBO_LABEL(c), c] as const) )( "combo #%i — %s passes all assertions", { timeout: 30_000 }, async (_index, _label, combo) => { - const config = buildConfig(combo) + const config = buildConfig(combo, combo.miscProfile) const files = await generateToMap(config) // 1. Token cleanliness @@ -46,7 +37,7 @@ describe("Full Pipeline — Critical Combinations", () => { expect(pubspec).toContain("name: test_app") // 5. main.dart has substantive content - const mainDart = getFileContent(files, "lib/main.dart") + const mainDart = getFile(files, "lib/main.dart") expect(mainDart).toBeDefined() expect(mainDart!.length).toBeGreaterThan(50) expect(mainDart).toContain("main()") diff --git a/tests/integration/overlay-composition.spec.ts b/tests/integration/overlay-composition.spec.ts index 92d0a85..a4c04b2 100644 --- a/tests/integration/overlay-composition.spec.ts +++ b/tests/integration/overlay-composition.spec.ts @@ -1,87 +1,65 @@ -/** - * overlay-composition.spec.ts - * - * Tests that overlay files correctly replace base files, - * and that the overlay resolution logic includes/excludes - * the right directories based on configuration. - */ - -import { describe, expect, it } from "vitest" - +import { beforeAll, describe, expect, it } from "vitest" import { getFileContent } from "../utils/assertions" -import { generateToMap, getPubspecContent, getFile } from "../utils/generate" -import { buildConfig, type Combination } from "../utils/matrix.config" +import { generateToMap, getPubspecContent } from "../utils/generate" +import { PrimaryCombo } from "../utils/matrix.config" +import { buildConfig } from "../utils/config-builder" +import { MISC_ALL_ON, MISC_BARE_MINIMUM, MISC_DEFAULT } from "../utils/misc-profiles" -const base: Combination = { +const base: PrimaryCombo = { architecture: "feature-first", stateManagement: "riverpod", backend: "none", navigation: "go_router", - miscProfile: "default", } describe("Overlay Composition", () => { // ── State overlays replace base wrappers ───────────────────── - describe("State overlay replaces base state_wrapper.dart", () => { it("riverpod overlay produces ProviderScope wrapper", async () => { - const files = await generateToMap( - buildConfig({ ...base, stateManagement: "riverpod" }) - ) + const files = await generateToMap(buildConfig({ ...base, stateManagement: "riverpod" })) const wrapper = getFileContent(files, "state_wrapper.dart") - expect(wrapper).toBeDefined() expect(wrapper).toContain("ProviderScope") }) it("bloc overlay produces MultiBlocProvider wrapper", async () => { - const files = await generateToMap( - buildConfig({ ...base, stateManagement: "bloc" }) - ) + const files = await generateToMap(buildConfig({ ...base, stateManagement: "bloc" })) const wrapper = getFileContent(files, "state_wrapper.dart") - expect(wrapper).toBeDefined() expect(wrapper).toContain("MultiBlocProvider") }) it("provider overlay produces MultiProvider wrapper", async () => { - const files = await generateToMap( - buildConfig({ ...base, stateManagement: "provider" }) - ) + const files = await generateToMap(buildConfig({ ...base, stateManagement: "provider" })) const wrapper = getFileContent(files, "state_wrapper.dart") - expect(wrapper).toBeDefined() expect(wrapper).toContain("MultiProvider") }) }) // ── Backend overlays inject service files ──────────────────── - describe("Backend overlay injects service files", () => { - it("firebase overlay adds auth_service when auth enabled", async () => { - const files = await generateToMap( - buildConfig({ ...base, backend: "firebase" }) - ) - // Firebase backend produces auth_service.dart via (usesFirebaseAuth)@ gate - const pubspec = getPubspecContent(files) + let firebaseFiles: Map + let supabaseFiles: Map + + beforeAll(async () => { + [firebaseFiles, supabaseFiles] = await Promise.all([ + generateToMap(buildConfig({ ...base, backend: "firebase" })), + generateToMap(buildConfig({ ...base, backend: "supabase" })), + ]) + }) + + it("firebase overlay adds auth_service when auth enabled", () => { + const pubspec = getPubspecContent(firebaseFiles) expect(pubspec).toContain("firebase_core:") - // auth_service.dart should exist (since default firebase has authEmail: true) - const authService = getFileContent(files, "auth_service.dart") - expect(authService).toBeDefined() + expect(getFileContent(firebaseFiles, "auth_service.dart")).toBeDefined() }) - it("supabase overlay adds auth_service when auth enabled", async () => { - const files = await generateToMap( - buildConfig({ ...base, backend: "supabase" }) - ) - const pubspec = getPubspecContent(files) + it("supabase overlay adds auth_service when auth enabled", () => { + const pubspec = getPubspecContent(supabaseFiles) expect(pubspec).toContain("supabase_flutter:") - // auth_service.dart should exist (since default supabase has auth: true) - const authService = getFileContent(files, "auth_service.dart") - expect(authService).toBeDefined() + expect(getFileContent(supabaseFiles, "auth_service.dart")).toBeDefined() }) it("none backend does NOT produce backend service files", async () => { - const files = await generateToMap( - buildConfig({ ...base, backend: "none" }) - ) + const files = await generateToMap(buildConfig({ ...base, backend: "none" })) const backendFiles = [...files.keys()].filter( (f) => f.toLowerCase().includes("firebase") || @@ -93,41 +71,36 @@ describe("Overlay Composition", () => { }) // ── Localization overlay ──────────────────────────────────── - describe("Localization overlay", () => { - it("when enabled: translation JSON files are present", async () => { - const files = await generateToMap(buildConfig(base)) + let files: Map + + beforeAll(async () => { + files = await generateToMap(buildConfig(base, MISC_DEFAULT)) + }) + + it("when enabled: translation JSON files are present", () => { const translationFiles = [...files.keys()].filter( (f) => f.includes("translations/") && f.endsWith(".json") ) expect(translationFiles.length).toBeGreaterThanOrEqual(2) }) - it("when enabled: easy_localization in pubspec", async () => { - const files = await generateToMap(buildConfig(base)) + it("when enabled: easy_localization in pubspec", () => { const pubspec = getPubspecContent(files) expect(pubspec).toContain("easy_localization") }) }) // ── Networking overlays ───────────────────────────────────── - describe("Networking overlays", () => { it("dio overlay: dio service files present when usesDio=true", async () => { - const files = await generateToMap( - buildConfig({ ...base, miscProfile: "full" }) - ) - const dioFiles = [...files.keys()].filter((f) => - f.toLowerCase().includes("dio") - ) + const files = await generateToMap(buildConfig(base, MISC_ALL_ON)) + const dioFiles = [...files.keys()].filter((f) => f.toLowerCase().includes("dio")) expect(dioFiles.length).toBeGreaterThan(0) }) it("no dio overlay: no dio files when usesDio=false", async () => { - const files = await generateToMap( - buildConfig({ ...base, miscProfile: "minimal" }) - ) - // Should not have dio-specific service files (excluding pubspec references) + const files = await generateToMap(buildConfig(base, MISC_BARE_MINIMUM)) const dioServiceFiles = [...files.keys()].filter( (f) => f.toLowerCase().includes("dio") && f.endsWith(".dart") ) @@ -136,22 +109,15 @@ describe("Overlay Composition", () => { }) // ── Storage overlays ──────────────────────────────────────── - describe("Storage overlays", () => { it("hive overlay produces hive service when usesHive=true", async () => { - const files = await generateToMap( - buildConfig({ ...base, miscProfile: "full" }) - ) - const hiveFiles = [...files.keys()].filter((f) => - f.toLowerCase().includes("hive") - ) + const files = await generateToMap(buildConfig(base, MISC_ALL_ON)) + const hiveFiles = [...files.keys()].filter((f) => f.toLowerCase().includes("hive")) expect(hiveFiles.length).toBeGreaterThan(0) }) it("no hive files when usesHive=false", async () => { - const files = await generateToMap( - buildConfig({ ...base, miscProfile: "minimal" }) - ) + const files = await generateToMap(buildConfig(base, MISC_BARE_MINIMUM)) const hiveFiles = [...files.keys()].filter( (f) => f.toLowerCase().includes("hive") && f.endsWith(".dart") ) @@ -160,23 +126,17 @@ describe("Overlay Composition", () => { }) // ── Architecture overlays ─────────────────────────────────── - describe("Architecture overlay produces correct folder structure", () => { it("clean architecture has domain/data/presentation layers", async () => { - const files = await generateToMap( - buildConfig({ ...base, architecture: "clean" }) - ) + const files = await generateToMap(buildConfig({ ...base, architecture: "clean" })) const paths = [...files.keys()] - expect(paths.some((p) => p.includes("/domain/"))).toBe(true) expect(paths.some((p) => p.includes("/data/"))).toBe(true) expect(paths.some((p) => p.includes("/presentation/"))).toBe(true) }) it("feature-first has features directory", async () => { - const files = await generateToMap( - buildConfig({ ...base, architecture: "feature-first" }) - ) + const files = await generateToMap(buildConfig({ ...base, architecture: "feature-first" })) const paths = [...files.keys()] expect(paths.some((p) => p.includes("/features/"))).toBe(true) }) diff --git a/tests/reporters/failed-tests-reporter.ts b/tests/reporters/failed-tests-reporter.ts new file mode 100644 index 0000000..451be90 --- /dev/null +++ b/tests/reporters/failed-tests-reporter.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Reporter } from 'vitest/reporters'; + +export default class FailedTestsReporter implements Reporter { + private failedTests: string[] = []; + + onTestCaseResult(testCase: any) { + const result = typeof testCase.result === 'function' ? testCase.result() : testCase.result; + if (result && (result.state === 'fail' || result.state === 'failed')) { + const pathParts = []; + let current = testCase; + while (current) { + if (current.name) { + pathParts.unshift(current.name); + } + current = current.parent || current.suite; + } + let msg = `FAIL: ${pathParts.join(' > ')}`; + if (result.errors) { + for (const err of result.errors) { + msg += `\n ${err.message || err}`; + } + } + this.failedTests.push(msg); + } + } + + onTestRunEnd() { + const outputDir = path.resolve(process.cwd(), 'tests/results/layer1'); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const outputFile = path.join(outputDir, 'failed-tests.log'); + + if (this.failedTests.length > 0) { + fs.writeFileSync(outputFile, this.failedTests.join('\n\n'), 'utf-8'); + console.log(`\n[FailedTestsReporter] Logged ${this.failedTests.length} failed tests to ${outputFile}\n`); + } else { + // Clear previous log if no failures + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + } + } +} diff --git a/tests/unit/backend.spec.ts b/tests/unit/backend.spec.ts index 69a09e0..c9c11c9 100644 --- a/tests/unit/backend.spec.ts +++ b/tests/unit/backend.spec.ts @@ -1,116 +1,99 @@ -/** - * backend.spec.ts - * - * Focused tests per backend provider. - * Verifies correct backend-specific packages, initialization code, - * and absence of other backend code. - */ - -import { describe, it } from "vitest" - +import { beforeAll, describe, it } from "vitest" import { assertDependencyAbsent, assertDependencyPresent } from "../utils/assertions" import { generateToMap, getPubspecContent } from "../utils/generate" -import { buildConfig, type Combination } from "../utils/matrix.config" +import { PrimaryCombo } from "../utils/matrix.config" +import { buildConfig } from "../utils/config-builder" +import { MISC_ALL_ON, MISC_DEFAULT } from "../utils/misc-profiles" -const base: Omit = { +const base: Omit = { architecture: "feature-first", stateManagement: "riverpod", navigation: "go_router", - miscProfile: "default", } describe("Backend Providers", () => { // ── Firebase ──────────────────────────────────────────────── - describe("firebase", () => { - it("includes firebase_core", async () => { - const files = await generateToMap(buildConfig({ ...base, backend: "firebase" })) - const pubspec = getPubspecContent(files) + let pubspec: string + + beforeAll(async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "firebase" }, MISC_DEFAULT)) + pubspec = getPubspecContent(files) + }) + + it("includes firebase_core", () => { assertDependencyPresent(pubspec, "firebase_core") }) - it("includes firebase_auth when authEmail enabled", async () => { - const files = await generateToMap(buildConfig({ ...base, backend: "firebase" })) - const pubspec = getPubspecContent(files) - // Default firebase config has authEmail: true + it("includes firebase_auth when authEmail enabled", () => { assertDependencyPresent(pubspec, "firebase_auth") }) - it("includes cloud_firestore when firestore enabled", async () => { - const files = await generateToMap(buildConfig({ ...base, backend: "firebase" })) - const pubspec = getPubspecContent(files) + it("includes cloud_firestore when firestore enabled", () => { assertDependencyPresent(pubspec, "cloud_firestore") }) - it("does not include supabase or appwrite packages", async () => { - const files = await generateToMap(buildConfig({ ...base, backend: "firebase" })) - const pubspec = getPubspecContent(files) + it("does not include supabase or appwrite packages", () => { assertDependencyAbsent(pubspec, "supabase_flutter") assertDependencyAbsent(pubspec, "appwrite") }) }) // ── Supabase ──────────────────────────────────────────────── - describe("supabase", () => { - it("includes supabase_flutter", async () => { - const files = await generateToMap(buildConfig({ ...base, backend: "supabase" })) - const pubspec = getPubspecContent(files) + let pubspec: string + + beforeAll(async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "supabase" }, MISC_DEFAULT)) + pubspec = getPubspecContent(files) + }) + + it("includes supabase_flutter", () => { assertDependencyPresent(pubspec, "supabase_flutter") }) - it("does not include firebase or appwrite packages", async () => { - const files = await generateToMap(buildConfig({ ...base, backend: "supabase" })) - const pubspec = getPubspecContent(files) + it("does not include firebase or appwrite packages", () => { assertDependencyAbsent(pubspec, "firebase_core") assertDependencyAbsent(pubspec, "appwrite") }) }) // ── Appwrite ──────────────────────────────────────────────── - describe("appwrite", () => { - it("includes appwrite", async () => { - const files = await generateToMap(buildConfig({ ...base, backend: "appwrite" })) - const pubspec = getPubspecContent(files) + let pubspec: string + + beforeAll(async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "appwrite" }, MISC_DEFAULT)) + pubspec = getPubspecContent(files) + }) + + it("includes appwrite", () => { assertDependencyPresent(pubspec, "appwrite") }) - it("does not include firebase or supabase packages", async () => { - const files = await generateToMap(buildConfig({ ...base, backend: "appwrite" })) - const pubspec = getPubspecContent(files) + it("does not include firebase or supabase packages", () => { assertDependencyAbsent(pubspec, "firebase_core") assertDependencyAbsent(pubspec, "supabase_flutter") }) }) // ── Custom ────────────────────────────────────────────────── - describe("custom", () => { - it("requires dio or http — full profile has dio", async () => { - const files = await generateToMap( - buildConfig({ - ...base, - backend: "custom", - miscProfile: "full", // full has usesDio: true - }) - ) - const pubspec = getPubspecContent(files) + let pubspec: string + + beforeAll(async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "custom" }, MISC_ALL_ON)) + pubspec = getPubspecContent(files) + }) + + it("requires dio or http — MISC_ALL_ON has dio", () => { assertDependencyPresent(pubspec, "dio") }) - it("does not include any backend SDK packages", async () => { - const files = await generateToMap( - buildConfig({ - ...base, - backend: "custom", - miscProfile: "full", - }) - ) - const pubspec = getPubspecContent(files) + it("does not include any backend SDK packages", () => { assertDependencyAbsent(pubspec, "firebase_core") assertDependencyAbsent(pubspec, "supabase_flutter") assertDependencyAbsent(pubspec, "appwrite") @@ -118,11 +101,15 @@ describe("Backend Providers", () => { }) // ── None ──────────────────────────────────────────────────── - describe("none", () => { - it("has no backend SDK packages", async () => { - const files = await generateToMap(buildConfig({ ...base, backend: "none" })) - const pubspec = getPubspecContent(files) + let pubspec: string + + beforeAll(async () => { + const files = await generateToMap(buildConfig({ ...base, backend: "none" }, MISC_DEFAULT)) + pubspec = getPubspecContent(files) + }) + + it("has no backend SDK packages", () => { assertDependencyAbsent(pubspec, "firebase_core") assertDependencyAbsent(pubspec, "firebase_auth") assertDependencyAbsent(pubspec, "cloud_firestore") diff --git a/tests/unit/dependencies.spec.ts b/tests/unit/dependencies.spec.ts deleted file mode 100644 index 65fcba0..0000000 --- a/tests/unit/dependencies.spec.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * dependencies.spec.ts - * - * Verifies that pubspec.yaml contains exactly the right dependencies - * for each combination — no more, no less. - */ - -import { describe, it } from "vitest" - -import { - assertDependencyAbsent, - assertDependencyPresent, - assertValidPubspec, -} from "../utils/assertions" -import { generateToMap, getPubspecContent } from "../utils/generate" -import { - ALL_COMBINATIONS, - buildConfig, - combinationLabel -} from "../utils/matrix.config" - -describe("Dependency Assertions", { timeout: 600_000 }, () => { - // ── pubspec validity ──────────────────────────────────────── - - describe("pubspec.yaml is valid YAML with correct structure", () => { - it.each(ALL_COMBINATIONS.map((c, i) => [i, combinationLabel(c), c] as const))( - "combo #%i — %s", - { timeout: 15_000 }, - async (_i, _label, combo) => { - const config = buildConfig(combo) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertValidPubspec(pubspec) - } - ) - }) - - // ── State management dependencies ─────────────────────────── - - describe("State management packages", () => { - const STATE_PACKAGES: Record = { - riverpod: { - present: ["flutter_riverpod"], - absent: ["provider", "flutter_bloc", "mobx", "flutter_mobx"], - }, - provider: { - present: ["provider"], - absent: ["flutter_riverpod", "flutter_bloc", "mobx", "flutter_mobx"], - }, - bloc: { - present: ["flutter_bloc"], - absent: ["flutter_riverpod", "provider", "mobx", "flutter_mobx"], - }, - mobx: { - present: ["mobx", "flutter_mobx"], - absent: ["flutter_riverpod", "provider", "flutter_bloc"], - }, - none: { - present: [], - absent: ["flutter_riverpod", "provider", "flutter_bloc", "mobx", "flutter_mobx"], - }, - } - - for (const [stateManager, packages] of Object.entries(STATE_PACKAGES)) { - it(`${stateManager}: correct packages present and absent`, async () => { - const config = buildConfig({ - architecture: "feature-first", - stateManagement: stateManager as any, - backend: "none", - navigation: "go_router", - miscProfile: "default", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - for (const pkg of packages.present) { - assertDependencyPresent(pubspec, pkg) - } - for (const pkg of packages.absent) { - assertDependencyAbsent(pubspec, pkg) - } - }) - } - }) - - // ── Backend dependencies ──────────────────────────────────── - - describe("Backend packages", () => { - it("firebase: firebase_core present", async () => { - const config = buildConfig({ - architecture: "feature-first", - stateManagement: "riverpod", - backend: "firebase", - navigation: "go_router", - miscProfile: "default", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyPresent(pubspec, "firebase_core") - assertDependencyAbsent(pubspec, "supabase_flutter") - assertDependencyAbsent(pubspec, "appwrite") - }) - - it("supabase: supabase_flutter present", async () => { - const config = buildConfig({ - architecture: "clean", - stateManagement: "bloc", - backend: "supabase", - navigation: "go_router", - miscProfile: "default", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyPresent(pubspec, "supabase_flutter") - assertDependencyAbsent(pubspec, "firebase_core") - assertDependencyAbsent(pubspec, "appwrite") - }) - - it("appwrite: appwrite present", async () => { - const config = buildConfig({ - architecture: "mvvm", - stateManagement: "provider", - backend: "appwrite", - navigation: "auto_route", - miscProfile: "default", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyPresent(pubspec, "appwrite") - assertDependencyAbsent(pubspec, "firebase_core") - assertDependencyAbsent(pubspec, "supabase_flutter") - }) - - it("none: no backend packages", async () => { - const config = buildConfig({ - architecture: "mvc", - stateManagement: "none", - backend: "none", - navigation: "imperative", - miscProfile: "minimal", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyAbsent(pubspec, "firebase_core") - assertDependencyAbsent(pubspec, "supabase_flutter") - assertDependencyAbsent(pubspec, "appwrite") - }) - }) - - // ── Navigation dependencies ───────────────────────────────── - - describe("Navigation packages", () => { - it("go_router: go_router present", async () => { - const config = buildConfig({ - architecture: "feature-first", - stateManagement: "riverpod", - backend: "none", - navigation: "go_router", - miscProfile: "default", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyPresent(pubspec, "go_router") - assertDependencyAbsent(pubspec, "auto_route") - }) - - it("auto_route: auto_route + auto_route_generator present", async () => { - const config = buildConfig({ - architecture: "clean", - stateManagement: "bloc", - backend: "none", - navigation: "auto_route", - miscProfile: "default", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyPresent(pubspec, "auto_route") - assertDependencyPresent(pubspec, "auto_route_generator") - assertDependencyAbsent(pubspec, "go_router") - }) - - it("imperative: no routing packages", async () => { - const config = buildConfig({ - architecture: "mvvm", - stateManagement: "provider", - backend: "none", - navigation: "imperative", - miscProfile: "default", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyAbsent(pubspec, "go_router") - assertDependencyAbsent(pubspec, "auto_route") - assertDependencyAbsent(pubspec, "auto_route_generator") - }) - }) - - // ── Networking dependencies ───────────────────────────────── - - describe("Networking packages", () => { - it("full profile: dio present", async () => { - const config = buildConfig({ - architecture: "feature-first", - stateManagement: "riverpod", - backend: "none", - navigation: "go_router", - miscProfile: "full", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyPresent(pubspec, "dio") - }) - - it("minimal profile: no networking packages", async () => { - const config = buildConfig({ - architecture: "layer-first", - stateManagement: "none", - backend: "none", - navigation: "imperative", - miscProfile: "minimal", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyAbsent(pubspec, "dio") - assertDependencyAbsent(pubspec, "http") - }) - }) - - // ── MobX dev dependencies ─────────────────────────────────── - - describe("Build runner for code generation", () => { - it("mobx: build_runner + mobx_codegen in dev_dependencies", async () => { - const config = buildConfig({ - architecture: "clean", - stateManagement: "mobx", - backend: "none", - navigation: "go_router", - miscProfile: "default", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyPresent(pubspec, "build_runner") - assertDependencyPresent(pubspec, "mobx_codegen") - }) - - it("auto_route: build_runner + auto_route_generator in dev_dependencies", async () => { - const config = buildConfig({ - architecture: "feature-first", - stateManagement: "riverpod", - backend: "none", - navigation: "auto_route", - miscProfile: "default", - }) - const files = await generateToMap(config) - const pubspec = getPubspecContent(files) - - assertDependencyPresent(pubspec, "build_runner") - assertDependencyPresent(pubspec, "auto_route_generator") - }) - }) -}) diff --git a/tests/unit/handlebars-helpers.spec.ts b/tests/unit/handlebars-helpers.spec.ts index ade3d03..90b464c 100644 --- a/tests/unit/handlebars-helpers.spec.ts +++ b/tests/unit/handlebars-helpers.spec.ts @@ -1,88 +1,74 @@ -/** - * handlebars-helpers.spec.ts - * - * Tests for all registered Handlebars helpers. - * Migrated from the original generator.spec.ts + expanded. - */ - import path from "node:path" - -import { describe, expect, it } from "vitest" - +import { beforeAll, describe, expect, it } from "vitest" import { createHandlebarsEnvironment } from "@/app/lib/generator/handlebars" const partialsDir = path.join(process.cwd(), "templates", "flutter", "partials") describe("Handlebars Helpers", () => { + let hbs: any + + beforeAll(async () => { + hbs = await createHandlebarsEnvironment(partialsDir) + }) + // ── Case conversion helpers ───────────────────────────────── describe("kebabCase", () => { - it("converts space-separated words", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("converts space-separated words", () => { const template = hbs.compile("{{kebabCase value}}") expect(template({ value: "Hello World" })).toBe("hello-world") }) - it("converts camelCase", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("converts camelCase", () => { const template = hbs.compile("{{kebabCase value}}") expect(template({ value: "myAppName" })).toBe("my-app-name") }) - it("converts PascalCase", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("converts PascalCase", () => { const template = hbs.compile("{{kebabCase value}}") expect(template({ value: "MyAppName" })).toBe("my-app-name") }) - it("handles underscores", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("handles underscores", () => { const template = hbs.compile("{{kebabCase value}}") expect(template({ value: "my_app_name" })).toBe("my-app-name") }) - it("handles already kebab-case", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("handles already kebab-case", () => { const template = hbs.compile("{{kebabCase value}}") expect(template({ value: "my-app-name" })).toBe("my-app-name") }) }) describe("snakeCase", () => { - it("converts space-separated words", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("converts space-separated words", () => { const template = hbs.compile("{{snakeCase value}}") expect(template({ value: "Hello World" })).toBe("hello_world") }) - it("converts camelCase", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("converts camelCase", () => { const template = hbs.compile("{{snakeCase value}}") expect(template({ value: "myAppName" })).toBe("my_app_name") }) - it("converts kebab-case", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("converts kebab-case", () => { const template = hbs.compile("{{snakeCase value}}") expect(template({ value: "my-app-name" })).toBe("my_app_name") }) }) describe("pascalCase", () => { - it("converts space-separated words", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("converts space-separated words", () => { const template = hbs.compile("{{pascalCase value}}") expect(template({ value: "hello world" })).toBe("HelloWorld") }) - it("converts snake_case", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("converts snake_case", () => { const template = hbs.compile("{{pascalCase value}}") expect(template({ value: "my_app_name" })).toBe("MyAppName") }) - it("converts kebab-case", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("converts kebab-case", () => { const template = hbs.compile("{{pascalCase value}}") expect(template({ value: "my-app-name" })).toBe("MyAppName") }) @@ -91,62 +77,53 @@ describe("Handlebars Helpers", () => { // ── Boolean logic helpers ─────────────────────────────────── describe("eq", () => { - it("returns true for equal strings", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("returns true for equal strings", () => { const template = hbs.compile('{{#if (eq a "hello")}}yes{{else}}no{{/if}}') expect(template({ a: "hello" })).toBe("yes") }) - it("returns false for different strings", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("returns false for different strings", () => { const template = hbs.compile('{{#if (eq a "hello")}}yes{{else}}no{{/if}}') expect(template({ a: "world" })).toBe("no") }) - it("strict equality — no type coercion", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("strict equality — no type coercion", () => { const template = hbs.compile('{{#if (eq a "1")}}yes{{else}}no{{/if}}') expect(template({ a: 1 })).toBe("no") }) }) describe("and", () => { - it("returns true when all args truthy", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("returns true when all args truthy", () => { const template = hbs.compile("{{#if (and a b)}}yes{{else}}no{{/if}}") expect(template({ a: true, b: true })).toBe("yes") }) - it("returns false when any arg falsy", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("returns false when any arg falsy", () => { const template = hbs.compile("{{#if (and a b)}}yes{{else}}no{{/if}}") expect(template({ a: true, b: false })).toBe("no") }) }) describe("or", () => { - it("returns true when any arg truthy", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("returns true when any arg truthy", () => { const template = hbs.compile("{{#if (or a b)}}yes{{else}}no{{/if}}") expect(template({ a: false, b: true })).toBe("yes") }) - it("returns false when all args falsy", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("returns false when all args falsy", () => { const template = hbs.compile("{{#if (or a b)}}yes{{else}}no{{/if}}") expect(template({ a: false, b: false })).toBe("no") }) }) describe("not", () => { - it("negates true to false", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("negates true to false", () => { const template = hbs.compile("{{#if (not a)}}yes{{else}}no{{/if}}") expect(template({ a: true })).toBe("no") }) - it("negates false to true", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("negates false to true", () => { const template = hbs.compile("{{#if (not a)}}yes{{else}}no{{/if}}") expect(template({ a: false })).toBe("yes") }) @@ -155,32 +132,27 @@ describe("Handlebars Helpers", () => { // ── res helper ────────────────────────────────────────────── describe("res", () => { - it("appends .w when ScreenUtil enabled", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("appends .w when ScreenUtil enabled", () => { const template = hbs.compile("{{res 16 'w' usesScreenutil}}") expect(template({ usesScreenutil: true })).toBe("16.w") }) - it("appends .h when ScreenUtil enabled", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("appends .h when ScreenUtil enabled", () => { const template = hbs.compile("{{res 16 'h' usesScreenutil}}") expect(template({ usesScreenutil: true })).toBe("16.h") }) - it("appends .sp when ScreenUtil enabled", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("appends .sp when ScreenUtil enabled", () => { const template = hbs.compile("{{res 14 'sp' usesScreenutil}}") expect(template({ usesScreenutil: true })).toBe("14.sp") }) - it("returns plain double when ScreenUtil disabled (number input)", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("returns plain double when ScreenUtil disabled (number input)", () => { const template = hbs.compile("{{res 16 'w' usesScreenutil}}") expect(template({ usesScreenutil: false })).toBe("16.0") }) - it("returns expression as-is when ScreenUtil disabled (string input)", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("returns expression as-is when ScreenUtil disabled (string input)", () => { const template = hbs.compile("{{res 'AppSpacing.lg' 'w' usesScreenutil}}") expect(template({ usesScreenutil: false })).toBe("AppSpacing.lg") }) @@ -189,14 +161,12 @@ describe("Handlebars Helpers", () => { // ── when helper ───────────────────────────────────────────── describe("when", () => { - it("renders fn block when condition is true", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("renders fn block when condition is true", () => { const template = hbs.compile("{{#when show}}visible{{else}}hidden{{/when}}") expect(template({ show: true })).toBe("visible") }) - it("renders inverse block when condition is false", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("renders inverse block when condition is false", () => { const template = hbs.compile("{{#when show}}visible{{else}}hidden{{/when}}") expect(template({ show: false })).toBe("hidden") }) @@ -205,8 +175,7 @@ describe("Handlebars Helpers", () => { // ── json helper ───────────────────────────────────────────── describe("json", () => { - it("serializes an object to pretty JSON", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("serializes an object to pretty JSON", () => { const template = hbs.compile("{{{json data}}}") const result = template({ data: { key: "value" } }) expect(result).toContain('"key": "value"') @@ -216,15 +185,13 @@ describe("Handlebars Helpers", () => { // ── indent helper ─────────────────────────────────────────── describe("indent", () => { - it("indents each line by the specified number of spaces", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("indents each line by the specified number of spaces", () => { const template = hbs.compile("{{{indent text 4}}}") const result = template({ text: "line1\nline2" }) expect(result).toBe(" line1\n line2") }) - it("does not indent empty lines", async () => { - const hbs = await createHandlebarsEnvironment(partialsDir) + it("does not indent empty lines", () => { const template = hbs.compile("{{{indent text 2}}}") const result = template({ text: "line1\n\nline2" }) expect(result).toBe(" line1\n\n line2") diff --git a/tests/unit/matrix-shard-1.spec.ts b/tests/unit/matrix-shard-1.spec.ts new file mode 100644 index 0000000..35bdbd9 --- /dev/null +++ b/tests/unit/matrix-shard-1.spec.ts @@ -0,0 +1,8 @@ +import { describe } from "vitest" +import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" +import { runMatrixTests } from "../utils/matrix-tests" + +// Shard 1: 0-93 +describe("Matrix Shard 1/4", { timeout: 600_000 }, () => { + runMatrixTests(PRIMARY_COMBINATIONS.slice(0, 93)) +}) diff --git a/tests/unit/matrix-shard-2.spec.ts b/tests/unit/matrix-shard-2.spec.ts new file mode 100644 index 0000000..5f9433d --- /dev/null +++ b/tests/unit/matrix-shard-2.spec.ts @@ -0,0 +1,8 @@ +import { describe } from "vitest" +import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" +import { runMatrixTests } from "../utils/matrix-tests" + +// Shard 2: 93-186 +describe("Matrix Shard 2/4", { timeout: 600_000 }, () => { + runMatrixTests(PRIMARY_COMBINATIONS.slice(93, 186)) +}) diff --git a/tests/unit/matrix-shard-3.spec.ts b/tests/unit/matrix-shard-3.spec.ts new file mode 100644 index 0000000..a8d2cec --- /dev/null +++ b/tests/unit/matrix-shard-3.spec.ts @@ -0,0 +1,8 @@ +import { describe } from "vitest" +import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" +import { runMatrixTests } from "../utils/matrix-tests" + +// Shard 3: 186-279 +describe("Matrix Shard 3/4", { timeout: 600_000 }, () => { + runMatrixTests(PRIMARY_COMBINATIONS.slice(186, 279)) +}) diff --git a/tests/unit/matrix-shard-4.spec.ts b/tests/unit/matrix-shard-4.spec.ts new file mode 100644 index 0000000..bd8c2f8 --- /dev/null +++ b/tests/unit/matrix-shard-4.spec.ts @@ -0,0 +1,8 @@ +import { describe } from "vitest" +import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" +import { runMatrixTests } from "../utils/matrix-tests" + +// Shard 4: 279-375 +describe("Matrix Shard 4/4", { timeout: 600_000 }, () => { + runMatrixTests(PRIMARY_COMBINATIONS.slice(279)) +}) diff --git a/tests/unit/misc-flags.spec.ts b/tests/unit/misc-flags.spec.ts index 5fa9a72..89bee6f 100644 --- a/tests/unit/misc-flags.spec.ts +++ b/tests/unit/misc-flags.spec.ts @@ -1,48 +1,48 @@ -/** - * misc-flags.spec.ts - * - * Tests for each miscellaneous boolean flag. - * Verifies that toggling a flag correctly adds/removes: - * - pubspec.yaml dependencies - * - Overlay files (services, hooks, widgets) - * - ScreenUtil extensions (.w/.h/.sp) - * - Flutter Hooks patterns - */ - -import { describe, expect, it } from "vitest" - +import { beforeAll, describe, expect, it } from "vitest" import { assertDependencyAbsent, assertDependencyPresent, getFileContent } from "../utils/assertions" import { generateToMap, getPubspecContent } from "../utils/generate" -import { buildConfig, type Combination } from "../utils/matrix.config" +import { PrimaryCombo } from "../utils/matrix.config" +import { buildConfig } from "../utils/config-builder" +import { MISC_ALL_ON, MISC_BARE_MINIMUM, MISC_DEFAULT } from "../utils/misc-profiles" -const base: Combination = { +const base: PrimaryCombo = { architecture: "feature-first", stateManagement: "riverpod", backend: "none", navigation: "go_router", - miscProfile: "default", } describe("Misc Flags", () => { - // ── ScreenUtil ────────────────────────────────────────────── + let fullFiles: Map + let fullPubspec: string + let minimalFiles: Map + let minimalPubspec: string + let defaultFiles: Map + let defaultPubspec: string + + beforeAll(async () => { + [fullFiles, minimalFiles, defaultFiles] = await Promise.all([ + generateToMap(buildConfig(base, MISC_ALL_ON)), + generateToMap(buildConfig(base, MISC_BARE_MINIMUM)), + generateToMap(buildConfig(base, MISC_DEFAULT)), + ]) + fullPubspec = getPubspecContent(fullFiles) + minimalPubspec = getPubspecContent(minimalFiles) + defaultPubspec = getPubspecContent(defaultFiles) + }) + // ── ScreenUtil ────────────────────────────────────────────── describe("usesScreenutil", () => { - it("when enabled: flutter_screenutil in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "full" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "flutter_screenutil") + it("when enabled: flutter_screenutil in pubspec", () => { + assertDependencyPresent(fullPubspec, "flutter_screenutil") }) - it("when disabled: no flutter_screenutil", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) - const pubspec = getPubspecContent(files) - assertDependencyAbsent(pubspec, "flutter_screenutil") + it("when disabled: no flutter_screenutil", () => { + assertDependencyAbsent(minimalPubspec, "flutter_screenutil") }) - it("when disabled: no .w/.h/.sp extensions in dart files", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) - const dartFiles = [...files.entries()].filter(([f]) => f.endsWith(".dart")) - + it("when disabled: no .w/.h/.sp extensions in dart files", () => { + const dartFiles = [...minimalFiles.entries()].filter(([f]) => f.endsWith(".dart")) const offenders = dartFiles .filter(([, text]) => /\b\d+\.(w|h|sp|r)\b/.test(text)) .map(([f]) => f) @@ -55,189 +55,134 @@ describe("Misc Flags", () => { }) // ── Flutter Hooks ─────────────────────────────────────────── - describe("usesFlutterHooks", () => { - it("when enabled: flutter_hooks in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "hooks" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "flutter_hooks") + it("when enabled: flutter_hooks in pubspec", () => { + assertDependencyPresent(fullPubspec, "flutter_hooks") }) - it("when disabled: no flutter_hooks in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) - const pubspec = getPubspecContent(files) - assertDependencyAbsent(pubspec, "flutter_hooks") + it("when disabled: no flutter_hooks in pubspec", () => { + assertDependencyAbsent(defaultPubspec, "flutter_hooks") }) }) // ── Hive ──────────────────────────────────────────────────── - describe("usesHive", () => { - it("when enabled: hive_ce + hive_ce_flutter in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "full" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "hive_ce") - assertDependencyPresent(pubspec, "hive_ce_flutter") + it("when enabled: hive_ce + hive_ce_flutter in pubspec", () => { + assertDependencyPresent(fullPubspec, "hive_ce") + assertDependencyPresent(fullPubspec, "hive_ce_flutter") }) - it("when enabled: hive_ce_generator in dev_dependencies", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "full" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "hive_ce_generator") + it("when enabled: hive_ce_generator in dev_dependencies", () => { + assertDependencyPresent(fullPubspec, "hive_ce_generator") }) - it("when disabled: no hive packages", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) - const pubspec = getPubspecContent(files) - assertDependencyAbsent(pubspec, "hive_ce") - assertDependencyAbsent(pubspec, "hive_ce_flutter") + it("when disabled: no hive packages", () => { + assertDependencyAbsent(minimalPubspec, "hive_ce") + assertDependencyAbsent(minimalPubspec, "hive_ce_flutter") }) - it("when enabled: HiveService.instance.init() in main.dart", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "full" })) - const mainDart = getFileContent(files, "lib/main.dart") + it("when enabled: HiveService.instance.init() in main.dart", () => { + const mainDart = getFileContent(fullFiles, "lib/main.dart") expect(mainDart).toBeDefined() expect(mainDart!).toContain("HiveService") }) }) // ── Cached Network Image ──────────────────────────────────── - describe("usesCachedNetworkImage", () => { - it("when enabled: cached_network_image in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "cached_network_image") + it("when enabled: cached_network_image in pubspec", () => { + assertDependencyPresent(defaultPubspec, "cached_network_image") }) - it("when disabled: no cached_network_image", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) - const pubspec = getPubspecContent(files) - assertDependencyAbsent(pubspec, "cached_network_image") + it("when disabled: no cached_network_image", () => { + assertDependencyAbsent(minimalPubspec, "cached_network_image") }) }) // ── Skeletonizer ──────────────────────────────────────────── - describe("usesSkeletonizer", () => { - it("when enabled: skeletonizer in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "skeletonizer") + it("when enabled: skeletonizer in pubspec", () => { + assertDependencyPresent(defaultPubspec, "skeletonizer") }) - it("when disabled: no skeletonizer", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) - const pubspec = getPubspecContent(files) - assertDependencyAbsent(pubspec, "skeletonizer") + it("when disabled: no skeletonizer", () => { + assertDependencyAbsent(minimalPubspec, "skeletonizer") }) }) // ── Dio ───────────────────────────────────────────────────── - describe("usesDio", () => { - it("when enabled: dio in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "full" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "dio") + it("when enabled: dio in pubspec", () => { + assertDependencyPresent(fullPubspec, "dio") }) - it("when disabled: no dio", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) - const pubspec = getPubspecContent(files) - assertDependencyAbsent(pubspec, "dio") + it("when disabled: no dio", () => { + assertDependencyAbsent(minimalPubspec, "dio") }) }) // ── Shared Preferences ────────────────────────────────────── - describe("usesSharedPreferences", () => { - it("when enabled: shared_preferences in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "shared_preferences") + it("when enabled: shared_preferences in pubspec", () => { + assertDependencyPresent(defaultPubspec, "shared_preferences") }) - it("when disabled: no shared_preferences", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) - const pubspec = getPubspecContent(files) - assertDependencyAbsent(pubspec, "shared_preferences") + it("when disabled: no shared_preferences", () => { + assertDependencyAbsent(minimalPubspec, "shared_preferences") }) }) // ── Secure Storage ────────────────────────────────────────── - describe("usesSecureStorage", () => { - it("when enabled: flutter_secure_storage in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "flutter_secure_storage") + it("when enabled: flutter_secure_storage in pubspec", () => { + assertDependencyPresent(defaultPubspec, "flutter_secure_storage") }) - it("when disabled: no flutter_secure_storage", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) - const pubspec = getPubspecContent(files) - assertDependencyAbsent(pubspec, "flutter_secure_storage") + it("when disabled: no flutter_secure_storage", () => { + assertDependencyAbsent(minimalPubspec, "flutter_secure_storage") }) }) // ── Flutter SVG ───────────────────────────────────────────── - describe("usesFlutterSvg", () => { - it("when enabled: flutter_svg in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "flutter_svg") + it("when enabled: flutter_svg in pubspec", () => { + assertDependencyPresent(defaultPubspec, "flutter_svg") }) - it("when disabled: no flutter_svg", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) - const pubspec = getPubspecContent(files) - assertDependencyAbsent(pubspec, "flutter_svg") + it("when disabled: no flutter_svg", () => { + assertDependencyAbsent(minimalPubspec, "flutter_svg") }) }) // ── Native Splash ─────────────────────────────────────────── - describe("usesFlutterNativeSplash", () => { - it("when enabled: flutter_native_splash in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "flutter_native_splash") + it("when enabled: flutter_native_splash in pubspec", () => { + assertDependencyPresent(defaultPubspec, "flutter_native_splash") }) - it("when enabled: FlutterNativeSplash.preserve in main.dart", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "default" })) - const mainDart = getFileContent(files, "lib/main.dart") + it("when enabled: FlutterNativeSplash.preserve in main.dart", () => { + const mainDart = getFileContent(defaultFiles, "lib/main.dart") expect(mainDart).toContain("FlutterNativeSplash.preserve") }) - it("when disabled: no flutter_native_splash", async () => { - const files = await generateToMap(buildConfig({ ...base, miscProfile: "minimal" })) - const pubspec = getPubspecContent(files) - assertDependencyAbsent(pubspec, "flutter_native_splash") + it("when disabled: no flutter_native_splash", () => { + assertDependencyAbsent(minimalPubspec, "flutter_native_splash") }) }) // ── Localization ──────────────────────────────────────────── - describe("localization", () => { - it("when enabled: easy_localization in pubspec", async () => { - // Default buildConfig has localization.enabled: true - const files = await generateToMap(buildConfig(base)) - const pubspec = getPubspecContent(files) - assertDependencyPresent(pubspec, "easy_localization") + it("when enabled: easy_localization in pubspec", () => { + assertDependencyPresent(defaultPubspec, "easy_localization") }) - it("when enabled: EasyLocalization.ensureInitialized in main.dart", async () => { - const files = await generateToMap(buildConfig(base)) - const mainDart = getFileContent(files, "lib/main.dart") + it("when enabled: EasyLocalization.ensureInitialized in main.dart", () => { + const mainDart = getFileContent(defaultFiles, "lib/main.dart") expect(mainDart).toContain("EasyLocalization.ensureInitialized") }) - it("when enabled: translation JSON files exist", async () => { - const files = await generateToMap(buildConfig(base)) - const translationFiles = [...files.keys()].filter((f) => + it("when enabled: translation JSON files exist", () => { + const translationFiles = [...defaultFiles.keys()].filter((f) => f.includes("translations/") && f.endsWith(".json") ) expect(translationFiles.length).toBeGreaterThanOrEqual(2) // en.json + es.json diff --git a/tests/unit/navigation.spec.ts b/tests/unit/navigation.spec.ts index f5ffac8..445cce1 100644 --- a/tests/unit/navigation.spec.ts +++ b/tests/unit/navigation.spec.ts @@ -1,89 +1,86 @@ -/** - * navigation.spec.ts - * - * Focused tests per navigation option. - * Verifies correct routing packages, router file content, - * and absence of other navigation code. - */ - -import { describe, expect, it } from "vitest" - +import { beforeAll, describe, expect, it } from "vitest" import { assertDependencyAbsent, assertDependencyPresent, getFileContent } from "../utils/assertions" import { generateToMap, getPubspecContent } from "../utils/generate" -import { buildConfig, type Combination } from "../utils/matrix.config" +import { PrimaryCombo } from "../utils/matrix.config" +import { buildConfig } from "../utils/config-builder" +import { MISC_DEFAULT } from "../utils/misc-profiles" -const base: Omit = { +const base: Omit = { architecture: "feature-first", stateManagement: "riverpod", backend: "none", - miscProfile: "default", } describe("Navigation", () => { // ── go_router ─────────────────────────────────────────────── - describe("go_router", () => { - it("includes go_router in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, navigation: "go_router" })) - const pubspec = getPubspecContent(files) + let files: Map + let pubspec: string + + beforeAll(async () => { + files = await generateToMap(buildConfig({ ...base, navigation: "go_router" }, MISC_DEFAULT)) + pubspec = getPubspecContent(files) + }) + + it("includes go_router in pubspec", () => { assertDependencyPresent(pubspec, "go_router") }) - it("does not include auto_route", async () => { - const files = await generateToMap(buildConfig({ ...base, navigation: "go_router" })) - const pubspec = getPubspecContent(files) + it("does not include auto_route", () => { assertDependencyAbsent(pubspec, "auto_route") assertDependencyAbsent(pubspec, "auto_route_generator") }) - it("generates router configuration file", async () => { - const files = await generateToMap(buildConfig({ ...base, navigation: "go_router" })) + it("generates router configuration file", () => { const routerFile = getFileContent(files, "app_router.dart") expect(routerFile).toBeDefined() }) }) // ── auto_route ────────────────────────────────────────────── - describe("auto_route", () => { - it("includes auto_route in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, navigation: "auto_route" })) - const pubspec = getPubspecContent(files) + let pubspec: string + + beforeAll(async () => { + const files = await generateToMap(buildConfig({ ...base, navigation: "auto_route" }, MISC_DEFAULT)) + pubspec = getPubspecContent(files) + }) + + it("includes auto_route in pubspec", () => { assertDependencyPresent(pubspec, "auto_route") }) - it("includes auto_route_generator in dev_dependencies", async () => { - const files = await generateToMap(buildConfig({ ...base, navigation: "auto_route" })) - const pubspec = getPubspecContent(files) + it("includes auto_route_generator in dev_dependencies", () => { assertDependencyPresent(pubspec, "auto_route_generator") assertDependencyPresent(pubspec, "build_runner") }) - it("does not include go_router", async () => { - const files = await generateToMap(buildConfig({ ...base, navigation: "auto_route" })) - const pubspec = getPubspecContent(files) + it("does not include go_router", () => { assertDependencyAbsent(pubspec, "go_router") }) }) // ── imperative ────────────────────────────────────────────── - describe("imperative (Navigator 1.0)", () => { - it("does not include any routing package", async () => { - const files = await generateToMap(buildConfig({ ...base, navigation: "imperative" })) - const pubspec = getPubspecContent(files) + let files: Map + let pubspec: string + + beforeAll(async () => { + files = await generateToMap(buildConfig({ ...base, navigation: "imperative" }, MISC_DEFAULT)) + pubspec = getPubspecContent(files) + }) + + it("does not include any routing package", () => { assertDependencyAbsent(pubspec, "go_router") assertDependencyAbsent(pubspec, "auto_route") assertDependencyAbsent(pubspec, "auto_route_generator") }) - it("still generates app_router.dart", async () => { - const files = await generateToMap(buildConfig({ ...base, navigation: "imperative" })) - // Even imperative navigation should have a router config file + it("still generates app_router.dart", () => { const routerFile = getFileContent(files, "app_router.dart") expect(routerFile).toBeDefined() }) diff --git a/tests/unit/state-management.spec.ts b/tests/unit/state-management.spec.ts index 81ed825..2d65bd7 100644 --- a/tests/unit/state-management.spec.ts +++ b/tests/unit/state-management.spec.ts @@ -1,114 +1,117 @@ -/** - * state-management.spec.ts - * - * Focused tests per state management option. - * Verifies that the correct wrapper patterns, imports, and file structures - * are generated for each state manager, and that other state managers' code - * is completely absent (no option bleed). - */ - -import { describe, expect, it } from "vitest" - +import { beforeAll, describe, expect, it } from "vitest" import { assertFileContains, assertFileNotContains, getFileContent } from "../utils/assertions" import { generateToMap, getPubspecContent } from "../utils/generate" -import { buildConfig, type Combination } from "../utils/matrix.config" +import { PrimaryCombo } from "../utils/matrix.config" +import { buildConfig } from "../utils/config-builder" +import { MISC_ALL_ON, MISC_DEFAULT } from "../utils/misc-profiles" -/** Base combo — we vary only the stateManagement field */ -const base: Omit = { +const base: Omit = { architecture: "feature-first", backend: "none", navigation: "go_router", - miscProfile: "default", } describe("State Management", () => { // ── Riverpod ──────────────────────────────────────────────── - describe("riverpod", () => { - it("wraps app with ProviderScope", async () => { - const files = await generateToMap(buildConfig({ ...base, stateManagement: "riverpod" })) + let files: Map + + beforeAll(async () => { + files = await generateToMap(buildConfig({ ...base, stateManagement: "riverpod" }, MISC_DEFAULT)) + }) + + it("wraps app with ProviderScope", () => { assertFileContains(files, "state_wrapper.dart", "ProviderScope") }) - it("does not contain Bloc or Provider patterns", async () => { - const files = await generateToMap(buildConfig({ ...base, stateManagement: "riverpod" })) + it("does not contain Bloc or Provider patterns", () => { assertFileNotContains(files, "state_wrapper.dart", "MultiBlocProvider") assertFileNotContains(files, "state_wrapper.dart", "MultiProvider") }) it("includes hooks_riverpod when hooks enabled", async () => { - const files = await generateToMap( - buildConfig({ ...base, stateManagement: "riverpod", miscProfile: "hooks" }) + const filesWithHooks = await generateToMap( + buildConfig({ ...base, stateManagement: "riverpod" }, MISC_ALL_ON) ) - const pubspec = getPubspecContent(files) + const pubspec = getPubspecContent(filesWithHooks) expect(pubspec).toContain("hooks_riverpod") }) }) // ── Provider ──────────────────────────────────────────────── - describe("provider", () => { - it("wraps app with MultiProvider", async () => { - const files = await generateToMap(buildConfig({ ...base, stateManagement: "provider" })) + let files: Map + + beforeAll(async () => { + files = await generateToMap(buildConfig({ ...base, stateManagement: "provider" }, MISC_DEFAULT)) + }) + + it("wraps app with MultiProvider", () => { assertFileContains(files, "state_wrapper.dart", "MultiProvider") }) - it("does not contain Riverpod or Bloc patterns", async () => { - const files = await generateToMap(buildConfig({ ...base, stateManagement: "provider" })) + it("does not contain Riverpod or Bloc patterns", () => { assertFileNotContains(files, "state_wrapper.dart", "ProviderScope") assertFileNotContains(files, "state_wrapper.dart", "MultiBlocProvider") }) }) // ── Bloc ──────────────────────────────────────────────────── - describe("bloc", () => { - it("wraps app with MultiBlocProvider", async () => { - const files = await generateToMap(buildConfig({ ...base, stateManagement: "bloc" })) + let files: Map + + beforeAll(async () => { + files = await generateToMap(buildConfig({ ...base, stateManagement: "bloc" }, MISC_DEFAULT)) + }) + + it("wraps app with MultiBlocProvider", () => { assertFileContains(files, "state_wrapper.dart", "MultiBlocProvider") }) - it("does not contain Riverpod or Provider patterns", async () => { - const files = await generateToMap(buildConfig({ ...base, stateManagement: "bloc" })) + it("does not contain Riverpod or Provider patterns", () => { assertFileNotContains(files, "state_wrapper.dart", "ProviderScope") assertFileNotContains(files, "state_wrapper.dart", "MultiProvider") }) }) // ── MobX ──────────────────────────────────────────────────── - describe("mobx", () => { - it("includes mobx and flutter_mobx in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, stateManagement: "mobx" })) - const pubspec = getPubspecContent(files) + let files: Map + let pubspec: string + + beforeAll(async () => { + files = await generateToMap(buildConfig({ ...base, stateManagement: "mobx" }, MISC_DEFAULT)) + pubspec = getPubspecContent(files) + }) + + it("includes mobx and flutter_mobx in pubspec", () => { expect(pubspec).toContain("mobx:") expect(pubspec).toContain("flutter_mobx:") }) - it("includes build_runner and mobx_codegen in dev_dependencies", async () => { - const files = await generateToMap(buildConfig({ ...base, stateManagement: "mobx" })) - const pubspec = getPubspecContent(files) + it("includes build_runner and mobx_codegen in dev_dependencies", () => { expect(pubspec).toContain("build_runner:") expect(pubspec).toContain("mobx_codegen:") }) }) // ── None (setState) ───────────────────────────────────────── - describe("none (setState)", () => { - it("does not generate StateWrapper", async () => { - const files = await generateToMap(buildConfig({ ...base, stateManagement: "none" })) + let files: Map + let pubspec: string - // main.dart should NOT wrap with StateWrapper + beforeAll(async () => { + files = await generateToMap(buildConfig({ ...base, stateManagement: "none" }, MISC_DEFAULT)) + pubspec = getPubspecContent(files) + }) + + it("does not generate StateWrapper", () => { const mainDart = getFileContent(files, "lib/main.dart") expect(mainDart).toBeDefined() expect(mainDart).not.toContain("StateWrapper") }) - it("has no state management packages in pubspec", async () => { - const files = await generateToMap(buildConfig({ ...base, stateManagement: "none" })) - const pubspec = getPubspecContent(files) - + it("has no state management packages in pubspec", () => { expect(pubspec).not.toContain("flutter_riverpod:") // Use regex to match standalone "provider:" (not "path_provider:" or "shared_preferences:") expect(pubspec).not.toMatch(/^\s+provider:\s/m) diff --git a/tests/unit/structural.spec.ts b/tests/unit/structural.spec.ts deleted file mode 100644 index 33bdc4e..0000000 --- a/tests/unit/structural.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * structural.spec.ts - * - * Verifies that every generated project has the correct file structure: - * - Required files always present (pubspec.yaml, lib/main.dart, etc.) - * - No empty files - * - Architecture-appropriate directories exist - */ - -import { describe, it } from "vitest" - -import { - assertArchitectureStructure, - assertNoEmptyFiles, - assertRequiredFilesExist, -} from "../utils/assertions" -import { generateToMap } from "../utils/generate" -import { - ALL_COMBINATIONS, - buildConfig, - combinationLabel, -} from "../utils/matrix.config" - -describe("Structural Integrity", { timeout: 600_000 }, () => { - const combinations = ALL_COMBINATIONS - - it.each(combinations.map((c, i) => [i, combinationLabel(c), c] as const))( - "combo #%i — %s has all required files", - { timeout: 15_000 }, - async (_index, _label, combo) => { - const config = buildConfig(combo) - const files = await generateToMap(config) - - assertRequiredFilesExist(files) - } - ) - - it.each(combinations.map((c, i) => [i, combinationLabel(c), c] as const))( - "combo #%i — %s has no empty files", - { timeout: 15_000 }, - async (_index, _label, combo) => { - const config = buildConfig(combo) - const files = await generateToMap(config) - - assertNoEmptyFiles(files) - } - ) - - it.each(combinations.map((c, i) => [i, combinationLabel(c), c] as const))( - "combo #%i — %s has correct architecture structure", - { timeout: 15_000 }, - async (_index, _label, combo) => { - const config = buildConfig(combo) - const files = await generateToMap(config) - - assertArchitectureStructure(files, combo.architecture) - } - ) -}) diff --git a/tests/unit/token-cleanliness.spec.ts b/tests/unit/token-cleanliness.spec.ts deleted file mode 100644 index c09a33b..0000000 --- a/tests/unit/token-cleanliness.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * token-cleanliness.spec.ts - * - * The highest-value Layer 1 test. - * Scans every file in every generated project for unresolved Handlebars tokens. - * A single unresolved token means the template engine failed to process a variable, - * which will cause a Dart compilation failure. - * - * Runs against ALL valid combinations. This is the most comprehensive guard. - */ - -import { describe, it } from "vitest" - -import { assertNoUnresolvedTokens } from "../utils/assertions" -import { generateToMap } from "../utils/generate" -import { - ALL_COMBINATIONS, - buildConfig -} from "../utils/matrix.config" - -describe("Token Cleanliness — No Unresolved Handlebars Tokens", { timeout: 600_000 }, () => { - // Use a subset for faster iteration during development. - // In CI Tier 1, the full ALL_COMBINATIONS array runs. - const combinations = ALL_COMBINATIONS - - it.each(combinations.map((c, i) => [i, c] as const))( - "combo #%i — %s has zero unresolved tokens", - { timeout: 15_000 }, - async (_index, combo) => { - const config = buildConfig(combo) - const files = await generateToMap(config) - - // This will throw with a detailed message if any tokens remain - assertNoUnresolvedTokens(files) - } - ) -}) // 10 minute overall timeout for the full suite diff --git a/tests/utils/combinations.ts b/tests/utils/combinations.ts new file mode 100644 index 0000000..cbd746f --- /dev/null +++ b/tests/utils/combinations.ts @@ -0,0 +1,55 @@ +import { + ARCHITECTURES, + BACKEND_OPTIONS, + NAVIGATION_OPTIONS, + STATE_OPTIONS, +} from "./matrix.config" + +/** + * Cartesian product generator for any number of dimensions. + * Pure function with no side effects. + */ +export function generateCombinations>( + dimensions: T +): Array<{ [K in keyof T]: T[K][number] }> { + const keys = Object.keys(dimensions) as Array + if (keys.length === 0) return [] + + // Check for empty dimensions + for (const key of keys) { + if (dimensions[key].length === 0) return [] + } + + const result: any[] = [{}]; + + for (const key of keys) { + const nextResult: any[] = []; + for (const val of dimensions[key]) { + for (const item of result) { + nextResult.push({ ...item, [key]: val }); + } + } + result.length = 0; + result.push(...nextResult); + } + + return result +} + +/** + * Convenience function for primary combinations. + */ +export function generatePrimaryCombinations() { + return generateCombinations({ + architecture: ARCHITECTURES, + stateManagement: STATE_OPTIONS, + backend: BACKEND_OPTIONS, + navigation: NAVIGATION_OPTIONS, + }) +} + +// Sanity check +const primaryCount = generatePrimaryCombinations().length +if (primaryCount !== 375) { + throw new Error(`Sanity check failed: Expected 375 combinations, but got ${primaryCount}. (5x5x5x3)`) +} diff --git a/tests/utils/config-builder.ts b/tests/utils/config-builder.ts new file mode 100644 index 0000000..cbde4a9 --- /dev/null +++ b/tests/utils/config-builder.ts @@ -0,0 +1,43 @@ +import type { + BackendConfig, + ScaffoldConfig, +} from "../../app/lib/config/schema" +import { defaultBackendConfig } from "../../app/lib/config/schema" +import { PrimaryCombo } from "./matrix.config" +import { MISC_DEFAULT, safeProfile } from "./misc-profiles" +import { MiscConfig } from "../../app/lib/config/schema" + +/** + * Build a full ScaffoldConfig from a PrimaryCombo and an optional MiscProfile. + */ +export function buildConfig( + combo: PrimaryCombo, + miscProfile: MiscConfig = MISC_DEFAULT +): ScaffoldConfig { + const safeMisc = safeProfile(miscProfile, combo) + const backendConfig: BackendConfig = defaultBackendConfig(combo.backend) + + return { + appName: "test_app", + packageId: "com.example.test_app", + description: "Test scaffold for combination testing.", + theme: { + preset: "material3", + primaryColor: "#6750A4", + darkMode: { enabled: true, system: true }, + customFonts: [], + }, + icons: { + default: true, + iconsax_plus: false, + flutter_remix: false, + hugeicons: false, + }, + stateManagement: combo.stateManagement, + backend: backendConfig, + localization: { enabled: true, supportedLocales: ["en", "es"] }, + navigation: combo.navigation, + architecture: combo.architecture, + misc: safeMisc, + } +} diff --git a/tests/utils/critical-combos.ts b/tests/utils/critical-combos.ts index b44839d..b3cfeda 100644 --- a/tests/utils/critical-combos.ts +++ b/tests/utils/critical-combos.ts @@ -1,153 +1,224 @@ -/** - * critical-combos.ts - * - * Hand-selected ~30 combinations for Tier 2 CI (PR checks). - * Selection criteria: - * - Every individual option value appears in at least 3 combinations - * - Covers the most commonly used combos (riverpod + go_router + feature-first) - * - Includes edge cases ("none" options, complex backend combos) - * - Includes all 4 misc profiles - */ - -import type { Combination } from "./matrix.config" - -/** - * Critical combinations for Tier 2 testing. - * Each combo is selected to maximize coverage per option value. - */ -export const CRITICAL_COMBINATIONS: Combination[] = [ - // ── Most popular combos ──────────────────────────────────────── - - // 1. The "golden path" — most common user choice - { architecture: "feature-first", stateManagement: "riverpod", backend: "firebase", navigation: "go_router", miscProfile: "default" }, - - // 2. Second most popular setup - { architecture: "clean", stateManagement: "bloc", backend: "supabase", navigation: "go_router", miscProfile: "default" }, - - // 3. Provider + simple setup - { architecture: "mvvm", stateManagement: "provider", backend: "none", navigation: "go_router", miscProfile: "default" }, - - // ── Full misc profile ────────────────────────────────────────── - - // 4. Everything on - { architecture: "feature-first", stateManagement: "riverpod", backend: "firebase", navigation: "go_router", miscProfile: "full" }, - - // 5. Full + auto_route - { architecture: "clean", stateManagement: "bloc", backend: "supabase", navigation: "auto_route", miscProfile: "full" }, - - // 6. Full + imperative - { architecture: "mvc", stateManagement: "provider", backend: "appwrite", navigation: "imperative", miscProfile: "full" }, - - // ── Minimal misc profile ─────────────────────────────────────── - - // 7. Bare minimum — no backend (custom+minimal is invalid) - { architecture: "layer-first", stateManagement: "none", backend: "none", navigation: "imperative", miscProfile: "minimal" }, - - // 8. Minimal with firebase - { architecture: "mvvm", stateManagement: "riverpod", backend: "firebase", navigation: "auto_route", miscProfile: "minimal" }, - - // 9. Minimal with supabase - { architecture: "feature-first", stateManagement: "bloc", backend: "supabase", navigation: "go_router", miscProfile: "minimal" }, - - // ── Hooks misc profile ───────────────────────────────────────── - - // 10. Hooks + riverpod - { architecture: "clean", stateManagement: "riverpod", backend: "none", navigation: "go_router", miscProfile: "hooks" }, - - // 11. Hooks + provider - { architecture: "feature-first", stateManagement: "provider", backend: "firebase", navigation: "auto_route", miscProfile: "hooks" }, - - // 12. Hooks + bloc - { architecture: "mvvm", stateManagement: "bloc", backend: "appwrite", navigation: "imperative", miscProfile: "hooks" }, - - // ── "None" state management ──────────────────────────────────── - - // 13. None state + go_router - { architecture: "feature-first", stateManagement: "none", backend: "none", navigation: "go_router", miscProfile: "default" }, - - // 14. None state + auto_route - { architecture: "clean", stateManagement: "none", backend: "supabase", navigation: "auto_route", miscProfile: "full" }, - - // 15. None state + firebase - { architecture: "mvc", stateManagement: "none", backend: "firebase", navigation: "imperative", miscProfile: "hooks" }, - - // ── MobX coverage ────────────────────────────────────────────── +import { PrimaryCombo } from "./matrix.config" +import { MiscConfig } from "../../app/lib/config/schema" +import { MISC_DEFAULT, MISC_BARE_MINIMUM, MISC_ALL_ON, MISC_HIGH_RISK } from "./misc-profiles" - // 16. MobX + clean - { architecture: "clean", stateManagement: "mobx", backend: "none", navigation: "go_router", miscProfile: "default" }, - - // 17. MobX + feature-first - { architecture: "feature-first", stateManagement: "mobx", backend: "firebase", navigation: "auto_route", miscProfile: "full" }, - - // 18. MobX + mvvm - { architecture: "mvvm", stateManagement: "mobx", backend: "supabase", navigation: "imperative", miscProfile: "minimal" }, - - // ── Appwrite backend ─────────────────────────────────────────── - - // 19. Appwrite + riverpod - { architecture: "layer-first", stateManagement: "riverpod", backend: "appwrite", navigation: "go_router", miscProfile: "default" }, - - // 20. Appwrite + bloc - { architecture: "feature-first", stateManagement: "bloc", backend: "appwrite", navigation: "auto_route", miscProfile: "minimal" }, - - // ── Custom backend ───────────────────────────────────────────── - - // 21. Custom + full (full has usesDio: true) - { architecture: "clean", stateManagement: "riverpod", backend: "custom", navigation: "go_router", miscProfile: "full" }, - - // 22. Custom + hooks (hooks has usesDio: true) - { architecture: "mvvm", stateManagement: "provider", backend: "custom", navigation: "auto_route", miscProfile: "hooks" }, - - // 23. Custom + default (default has usesDio: false, usesHttp: false — but we need dio for custom) - // NOTE: default profile doesn't have a networking client, so we use full instead - { architecture: "layer-first", stateManagement: "bloc", backend: "custom", navigation: "imperative", miscProfile: "full" }, - - // ── Architecture coverage ────────────────────────────────────── - - // 24. MVC + various - { architecture: "mvc", stateManagement: "riverpod", backend: "supabase", navigation: "auto_route", miscProfile: "default" }, - - // 25. MVC + bloc - { architecture: "mvc", stateManagement: "bloc", backend: "none", navigation: "go_router", miscProfile: "hooks" }, - - // 26. Layer-first + provider - { architecture: "layer-first", stateManagement: "provider", backend: "firebase", navigation: "go_router", miscProfile: "default" }, - - // 27. Layer-first + mobx - { architecture: "layer-first", stateManagement: "mobx", backend: "appwrite", navigation: "auto_route", miscProfile: "hooks" }, - - // ── Edge cases ───────────────────────────────────────────────── - - // 28. Localization off (handled internally by all combos — but let's confirm a clean build) - // This uses the same combos above which all have localization: true; - // A dedicated test will override localization.enabled = false - - // 29. MVVM + none state + imperative (absolute minimum) - { architecture: "mvvm", stateManagement: "none", backend: "none", navigation: "imperative", miscProfile: "minimal" }, - - // 30. Layer-first + none + auto_route - { architecture: "layer-first", stateManagement: "none", backend: "appwrite", navigation: "auto_route", miscProfile: "default" }, -] +interface CriticalCombo extends PrimaryCombo { + miscProfile: MiscConfig + label: string +} /** - * Verify coverage: count how many times each option value appears. - * Returns a map of dimension → value → count. + * The 25 critical combinations for Layer 2 Dart validation. + * Each represents a real developer archetype. */ -export function getCoverageReport(): Record> { - const report: Record> = { - architecture: {}, - stateManagement: {}, - backend: {}, - navigation: {}, - miscProfile: {}, - } - - for (const combo of CRITICAL_COMBINATIONS) { - for (const dimension of Object.keys(report)) { - const value = combo[dimension as keyof Combination] - report[dimension][value] = (report[dimension][value] || 0) + 1 - } +export const CRITICAL_COMBOS: CriticalCombo[] = [ + // --- Architecture Coverage --- + { + architecture: "clean", + stateManagement: "riverpod", + backend: "firebase", + navigation: "go_router", + miscProfile: MISC_DEFAULT, + label: "Enterprise app, most common combo", + }, + { + architecture: "mvvm", + stateManagement: "bloc", + backend: "supabase", + navigation: "auto_route", + miscProfile: MISC_DEFAULT, + label: "Reactive architecture, Postgres backend", + }, + { + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", + navigation: "go_router", + miscProfile: MISC_BARE_MINIMUM, + label: "Indie hacker, offline first", + }, + { + architecture: "mvc", + stateManagement: "provider", + backend: "none", + navigation: "imperative", + miscProfile: MISC_DEFAULT, + label: "Beginner project, minimal setup", + }, + { + architecture: "layer-first", + stateManagement: "mobx", + backend: "appwrite", + navigation: "go_router", + miscProfile: MISC_DEFAULT, + label: "Reactive observables, open backend", + }, + + // --- State Management Gaps --- + { + architecture: "clean", + stateManagement: "bloc", + backend: "firebase", + navigation: "auto_route", + miscProfile: MISC_DEFAULT, + label: "BLoC with Firebase", + }, + { + architecture: "feature-first", + stateManagement: "mobx", + backend: "supabase", + navigation: "imperative", + miscProfile: MISC_DEFAULT, + label: "MobX with Supabase", + }, + { + architecture: "mvvm", + stateManagement: "provider", + backend: "appwrite", + navigation: "go_router", + miscProfile: MISC_DEFAULT, + label: "Provider with Appwrite", + }, + { + architecture: "layer-first", + stateManagement: "none", + backend: "none", + navigation: "auto_route", + miscProfile: MISC_DEFAULT, + label: "setState only, no backend", + }, + + // --- Backend Gaps --- + { + architecture: "clean", + stateManagement: "riverpod", + backend: "custom", + navigation: "go_router", + miscProfile: MISC_DEFAULT, // MISC_DEFAULT has usesDio or usesHttp? No, let me double check. + label: "Custom API (requires Dio/HTTP)", + }, + { + architecture: "feature-first", + stateManagement: "bloc", + backend: "appwrite", + navigation: "imperative", + miscProfile: MISC_DEFAULT, + label: "Appwrite with BLoC", + }, + + // --- Navigation Gaps --- + { + architecture: "mvvm", + stateManagement: "riverpod", + backend: "none", + navigation: "imperative", + miscProfile: MISC_DEFAULT, + label: "Imperative nav with Riverpod", + }, + { + architecture: "layer-first", + stateManagement: "bloc", + backend: "firebase", + navigation: "auto_route", + miscProfile: MISC_DEFAULT, + label: "AutoRoute with Firebase", + }, + + // --- High Risk Interaction --- + { + architecture: "feature-first", + stateManagement: "riverpod", + backend: "firebase", + navigation: "auto_route", + miscProfile: MISC_ALL_ON, + label: "Most feature-rich combo", + }, + { + architecture: "clean", + stateManagement: "bloc", + backend: "supabase", + navigation: "go_router", + miscProfile: MISC_HIGH_RISK, + label: "Two popular choices together (High Risk)", + }, + { + architecture: "mvc", + stateManagement: "mobx", + backend: "firebase", + navigation: "auto_route", + miscProfile: MISC_DEFAULT, + label: "MobX observables with Firebase", + }, + { + architecture: "layer-first", + stateManagement: "riverpod", + backend: "custom", + navigation: "imperative", + miscProfile: MISC_ALL_ON, + label: "Custom backend, no router", + }, + { + architecture: "mvvm", + stateManagement: "none", + backend: "appwrite", + navigation: "go_router", + miscProfile: MISC_DEFAULT, + label: "setState with backend", + }, + { + architecture: "feature-first", + stateManagement: "provider", + backend: "supabase", + navigation: "auto_route", + miscProfile: MISC_DEFAULT, + label: "Provider with typed routes", + }, + { + architecture: "clean", + stateManagement: "riverpod", + backend: "none", + navigation: "auto_route", + miscProfile: MISC_DEFAULT, + label: "No backend, type-safe routing", + }, + { + architecture: "mvc", + stateManagement: "bloc", + backend: "supabase", + navigation: "imperative", + miscProfile: MISC_DEFAULT, + label: "Classic MVC with BLoC", + }, + { + architecture: "layer-first", + stateManagement: "provider", + backend: "firebase", + navigation: "go_router", + miscProfile: MISC_DEFAULT, + label: "Simple state, complex backend", + }, + { + architecture: "mvvm", + stateManagement: "riverpod", + backend: "appwrite", + navigation: "imperative", + miscProfile: MISC_DEFAULT, + label: "Riverpod with Appwrite", + }, + { + architecture: "feature-first", + stateManagement: "bloc", + backend: "none", + navigation: "imperative", + miscProfile: MISC_DEFAULT, + label: "Offline BLoC app", + }, + { + architecture: "clean", + stateManagement: "riverpod", + backend: "supabase", + navigation: "go_router", + miscProfile: MISC_DEFAULT, + label: "Standard Clean + Riverpod + Supabase", } - - return report -} +] diff --git a/tests/utils/generate.ts b/tests/utils/generate.ts index c601215..4934e3d 100644 --- a/tests/utils/generate.ts +++ b/tests/utils/generate.ts @@ -53,6 +53,10 @@ export async function generateToDisk( config: ScaffoldConfig, outputDir: string ): Promise { + // Clear directory to avoid stale files from previous runs + await fs.rm(outputDir, { recursive: true, force: true }) + await fs.mkdir(outputDir, { recursive: true }) + const buffer = await generateFlutterScaffold(config) const zip = await JSZip.loadAsync(buffer) diff --git a/tests/utils/matrix-tests.ts b/tests/utils/matrix-tests.ts new file mode 100644 index 0000000..56ddce2 --- /dev/null +++ b/tests/utils/matrix-tests.ts @@ -0,0 +1,132 @@ +import { beforeAll, describe, it, expect } from "vitest" +import { + assertArchitectureStructure, + assertDependencyAbsent, + assertDependencyPresent, + assertNoEmptyFiles, + assertNoUnresolvedTokens, + assertRequiredFilesExist, + assertValidPubspec, +} from "./assertions" +import { generateToMap, getPubspecContent } from "./generate" +import { COMBO_LABEL, PrimaryCombo } from "./matrix.config" +import { buildConfig } from "./config-builder" + +export function runMatrixTests(combos: PrimaryCombo[]) { + for (const combo of combos) { + describe(`[${COMBO_LABEL(combo)}]`, () => { + let files: Map + let pubspec: string + + beforeAll(async () => { + files = await generateToMap(buildConfig(combo)) + pubspec = getPubspecContent(files) + }) + + // ── Structural Integrity ───────────────────────────────── + it("has all required files", () => { + assertRequiredFilesExist(files) + }) + + it("has no empty files", () => { + assertNoEmptyFiles(files) + }) + + it("has correct architecture structure", () => { + assertArchitectureStructure(files, combo.architecture) + }) + + // ── Token Cleanliness ──────────────────────────────────── + it("has zero unresolved tokens", () => { + assertNoUnresolvedTokens(files) + }) + + // ── Dependency Assertions ──────────────────────────────── + it("has valid pubspec.yaml structure", () => { + assertValidPubspec(pubspec) + }) + + describe("Packages", () => { + it("has correct state management packages", () => { + const stateManager = combo.stateManagement + const STATE_PACKAGES: Record = { + riverpod: { + present: ["flutter_riverpod"], + absent: ["provider", "flutter_bloc", "mobx", "flutter_mobx"], + }, + provider: { + present: ["provider"], + absent: ["flutter_riverpod", "flutter_bloc", "mobx", "flutter_mobx"], + }, + bloc: { + present: ["flutter_bloc"], + absent: ["flutter_riverpod", "provider", "mobx", "flutter_mobx"], + }, + mobx: { + present: ["mobx", "flutter_mobx"], + absent: ["flutter_riverpod", "provider", "flutter_bloc"], + }, + none: { + present: [], + absent: ["flutter_riverpod", "provider", "flutter_bloc", "mobx", "flutter_mobx"], + }, + } + const packages = STATE_PACKAGES[stateManager] + for (const pkg of packages.present) { + assertDependencyPresent(pubspec, pkg) + } + for (const pkg of packages.absent) { + assertDependencyAbsent(pubspec, pkg) + } + }) + + it("has correct backend packages", () => { + if (combo.backend === "firebase") { + assertDependencyPresent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "appwrite") + } else if (combo.backend === "supabase") { + assertDependencyPresent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "appwrite") + } else if (combo.backend === "appwrite") { + assertDependencyPresent(pubspec, "appwrite") + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "supabase_flutter") + } else if (combo.backend === "none") { + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "appwrite") + } + }) + + it("has correct navigation packages", () => { + if (combo.navigation === "go_router") { + assertDependencyPresent(pubspec, "go_router") + assertDependencyAbsent(pubspec, "auto_route") + } else if (combo.navigation === "auto_route") { + assertDependencyPresent(pubspec, "auto_route") + assertDependencyPresent(pubspec, "auto_route_generator") + assertDependencyAbsent(pubspec, "go_router") + } else if (combo.navigation === "imperative") { + assertDependencyAbsent(pubspec, "go_router") + assertDependencyAbsent(pubspec, "auto_route") + assertDependencyAbsent(pubspec, "auto_route_generator") + } + }) + + if (combo.stateManagement === "mobx" || combo.navigation === "auto_route") { + it("has build_runner for code generation", () => { + assertDependencyPresent(pubspec, "build_runner") + if (combo.stateManagement === "mobx") { + assertDependencyPresent(pubspec, "mobx_codegen") + } + if (combo.navigation === "auto_route") { + assertDependencyPresent(pubspec, "auto_route_generator") + } + }) + } + }) + }) + } +} diff --git a/tests/utils/matrix.config.ts b/tests/utils/matrix.config.ts index bef3070..b861579 100644 --- a/tests/utils/matrix.config.ts +++ b/tests/utils/matrix.config.ts @@ -1,285 +1,90 @@ -/** - * matrix.config.ts - * - * Single source of truth for the FlutterInit option space. - * Every test file, CI matrix, and coverage report imports from here. - * - * Dimensions (GetX excluded from both state and navigation): - * Architecture: 5 (mvc, mvvm, clean, feature-first, layer-first) - * State Management: 5 (provider, riverpod, bloc, mobx, none) - * Backend: 5 (none, firebase, supabase, appwrite, custom) - * Navigation: 3 (imperative, go_router, auto_route) - * Misc Profiles: 4 (full, minimal, default, hooks) - * - * Primary combos: 5 × 5 × 5 × 3 = 375 - * With misc profiles: 375 × 4 = 1,500 total (minus invalid) - */ - import type { ArchitectureStyle, - BackendConfig, BackendProvider, - MiscConfig, NavigationStyle, - ScaffoldConfig, StateManagement, -} from "@/app/lib/config/schema" -import { defaultBackendConfig } from "@/app/lib/config/schema" +} from "../../app/lib/config/schema" -// ── Primary dimensions ────────────────────────────────────────────── - -export const ARCHITECTURES: readonly ArchitectureStyle[] = [ +/** + * ARCHITECTURES: array of 5 architecture strings + */ +export const ARCHITECTURES: ArchitectureStyle[] = [ "mvc", "mvvm", "clean", "feature-first", "layer-first", -] as const satisfies readonly ArchitectureStyle[] +] -export const STATE_MANAGERS: readonly StateManagement[] = [ +/** + * STATE_OPTIONS: array of 5 state management strings + */ +export const STATE_OPTIONS: StateManagement[] = [ "provider", "riverpod", "bloc", "mobx", "none", -] as const satisfies readonly StateManagement[] +] -export const BACKENDS: readonly BackendProvider[] = [ +/** + * BACKEND_OPTIONS: array of 5 backend strings + */ +export const BACKEND_OPTIONS: BackendProvider[] = [ "none", "firebase", "supabase", "appwrite", "custom", -] as const satisfies readonly BackendProvider[] +] -export const NAVIGATIONS: readonly NavigationStyle[] = [ +/** + * NAVIGATION_OPTIONS: array of 3 navigation strings + */ +export const NAVIGATION_OPTIONS: NavigationStyle[] = [ "imperative", "go_router", "auto_route", -] as const satisfies readonly NavigationStyle[] - -// ── Misc flag profiles ────────────────────────────────────────────── - -/** All optional features enabled. usesDio is on, usesHttp off (avoid conflict). */ -const MISC_FULL: MiscConfig = { - usesScreenutil: true, - usesFlutterNativeSplash: true, - usesDio: true, - usesHttp: false, - usesHive: true, - usesSharedPreferences: true, - usesSecureStorage: true, - usesCachedNetworkImage: true, - usesFlutterSvg: true, - usesSkeletonizer: true, - usesDotenv: true as const, - usesLogger: true, - usesFlutterHooks: false, - usesImagePicker: true, - usesFilePicker: true, - usesUrlLauncher: true, - usesPathProvider: true, - usesSharePlus: true, - usesPermissionHandler: true, - usesDeviceInfoPlus: true, - usesAppVersionUpdate: true, - usesGeolocator: true, -} - -/** All optional features disabled (bare minimum). */ -const MISC_MINIMAL: MiscConfig = { - usesScreenutil: false, - usesFlutterNativeSplash: false, - usesDio: false, - usesHttp: false, - usesHive: false, - usesSharedPreferences: false, - usesSecureStorage: false, - usesCachedNetworkImage: false, - usesFlutterSvg: false, - usesSkeletonizer: false, - usesDotenv: true as const, - usesLogger: false, - usesFlutterHooks: false, - usesImagePicker: false, - usesFilePicker: false, - usesUrlLauncher: false, - usesPathProvider: false, - usesSharePlus: false, - usesPermissionHandler: false, - usesDeviceInfoPlus: false, - usesAppVersionUpdate: false, - usesGeolocator: false, -} - -/** Matches the defaultConfig from schema.ts. */ -const MISC_DEFAULT: MiscConfig = { - usesScreenutil: true, - usesFlutterNativeSplash: true, - usesDio: false, - usesHttp: false, - usesHive: false, - usesSharedPreferences: true, - usesSecureStorage: true, - usesCachedNetworkImage: true, - usesFlutterSvg: true, - usesSkeletonizer: true, - usesDotenv: true as const, - usesLogger: true, - usesFlutterHooks: false, - usesImagePicker: false, - usesFilePicker: false, - usesUrlLauncher: true, - usesPathProvider: true, - usesSharePlus: false, - usesPermissionHandler: true, - usesDeviceInfoPlus: true, - usesAppVersionUpdate: true, - usesGeolocator: false, -} - -/** Flutter Hooks enabled — triggers hook pattern instead of service pattern. */ -const MISC_HOOKS: MiscConfig = { - usesScreenutil: true, - usesFlutterNativeSplash: true, - usesDio: true, - usesHttp: false, - usesHive: false, - usesSharedPreferences: true, - usesSecureStorage: true, - usesCachedNetworkImage: true, - usesFlutterSvg: true, - usesSkeletonizer: true, - usesDotenv: true as const, - usesLogger: true, - usesFlutterHooks: true, - usesImagePicker: true, - usesFilePicker: false, - usesUrlLauncher: true, - usesPathProvider: true, - usesSharePlus: true, - usesPermissionHandler: true, - usesDeviceInfoPlus: true, - usesAppVersionUpdate: true, - usesGeolocator: false, -} - -export type MiscProfileName = "full" | "minimal" | "default" | "hooks" +] -export const MISC_PROFILES: Record = { - full: MISC_FULL, - minimal: MISC_MINIMAL, - default: MISC_DEFAULT, - hooks: MISC_HOOKS, -} as const - -export const MISC_PROFILE_NAMES = Object.keys(MISC_PROFILES) as readonly MiscProfileName[] - -// ── Combination type ──────────────────────────────────────────────── - -export interface Combination { +export interface PrimaryCombo { architecture: ArchitectureStyle stateManagement: StateManagement backend: BackendProvider navigation: NavigationStyle - miscProfile: MiscProfileName } -export function combinationLabel(c: Combination): string { - return `${c.architecture}/${c.stateManagement}/${c.backend}/${c.navigation}/${c.miscProfile}` -} - -// ── Invalid combination rules ─────────────────────────────────────── - /** - * Returns a reason string if the combination is invalid, or null if valid. - * - * Invalid rules: - * 1. backend "custom" requires usesDio or usesHttp (schema .refine()) + * Generates the full 375 combo array at module load time */ -export function invalidReason(c: Combination): string | null { - const misc = MISC_PROFILES[c.miscProfile] - - // Rule 1: custom backend requires a networking client - if (c.backend === "custom" && !misc.usesDio && !misc.usesHttp) { - return "Custom backend requires usesDio or usesHttp to be enabled" - } - - return null -} - -export function isValidCombination(c: Combination): boolean { - return invalidReason(c) === null -} - -// ── Combination generator ─────────────────────────────────────────── - -/** Generate all mathematically possible combinations (unfiltered). */ -function allCombinationsUnfiltered(): Combination[] { - const result: Combination[] = [] +function generatePrimaryCombos(): PrimaryCombo[] { + const combos: PrimaryCombo[] = [] for (const architecture of ARCHITECTURES) { - for (const stateManagement of STATE_MANAGERS) { - for (const backend of BACKENDS) { - for (const navigation of NAVIGATIONS) { - for (const miscProfile of MISC_PROFILE_NAMES) { - result.push({ architecture, stateManagement, backend, navigation, miscProfile }) - } + for (const stateManagement of STATE_OPTIONS) { + for (const backend of BACKEND_OPTIONS) { + for (const navigation of NAVIGATION_OPTIONS) { + combos.push({ + architecture, + stateManagement, + backend, + navigation, + }) } } } } - return result + return combos } -/** All valid combinations after filtering out invalid ones. */ -export const ALL_COMBINATIONS: Combination[] = allCombinationsUnfiltered().filter(isValidCombination) - -/** Combinations that were excluded, with reasons. */ -export const INVALID_COMBINATIONS: Array<{ combo: Combination; reason: string }> = - allCombinationsUnfiltered() - .filter((c) => !isValidCombination(c)) - .map((c) => ({ combo: c, reason: invalidReason(c)! })) - -// ── ScaffoldConfig builder ────────────────────────────────────────── - -/** Build a full ScaffoldConfig from a Combination. */ -export function buildConfig(c: Combination): ScaffoldConfig { - const misc = MISC_PROFILES[c.miscProfile] - const backendConfig: BackendConfig = defaultBackendConfig(c.backend) +export const PRIMARY_COMBINATIONS = generatePrimaryCombos() - return { - appName: "test_app", - packageId: "com.example.test_app", - description: "Test scaffold for combination testing.", - theme: { - preset: "material3", - primaryColor: "#6750A4", - darkMode: { enabled: true, system: true }, - customFonts: [], - }, - icons: { - default: true, - iconsax_plus: false, - flutter_remix: false, - hugeicons: false, - }, - stateManagement: c.stateManagement, - backend: backendConfig, - localization: { enabled: true, supportedLocales: ["en", "es"] }, - navigation: c.navigation, - architecture: c.architecture, - misc, - } +/** + * Returns a readable string like clean|riverpod|firebase|go_router + */ +export function COMBO_LABEL(combo: PrimaryCombo): string { + return `${combo.architecture}|${combo.stateManagement}|${combo.backend}|${combo.navigation}` } -// ── Stats ─────────────────────────────────────────────────────────── - -export const TOTAL_UNFILTERED = - ARCHITECTURES.length * - STATE_MANAGERS.length * - BACKENDS.length * - NAVIGATIONS.length * - MISC_PROFILE_NAMES.length - -export const TOTAL_VALID = ALL_COMBINATIONS.length -export const TOTAL_INVALID = INVALID_COMBINATIONS.length +// NOTE: The custom backend requires either usesDio or usesHttp to be true +// (enforced by the Zod refine). The matrix config does not handle misc flags, +// but the Layer 2 critical combos file must account for this. diff --git a/tests/utils/misc-profiles.ts b/tests/utils/misc-profiles.ts new file mode 100644 index 0000000..7c51bc3 --- /dev/null +++ b/tests/utils/misc-profiles.ts @@ -0,0 +1,101 @@ +import { MiscConfig, defaultConfig } from "../../app/lib/config/schema" +import { PrimaryCombo } from "./matrix.config" + +/** + * MISC_BARE_MINIMUM: Every flag false except usesDotenv and usesFlutterNativeSplash + */ +export const MISC_BARE_MINIMUM: MiscConfig = { + usesScreenutil: false, + usesFlutterNativeSplash: false, + usesDio: false, + usesHttp: false, + usesHive: false, + usesSharedPreferences: false, + usesSecureStorage: false, + usesCachedNetworkImage: false, + usesFlutterSvg: false, + usesSkeletonizer: false, + usesDotenv: true, + usesLogger: false, + usesFlutterHooks: false, + usesImagePicker: false, + usesFilePicker: false, + usesUrlLauncher: false, + usesPathProvider: false, + usesSharePlus: false, + usesPermissionHandler: false, + usesDeviceInfoPlus: false, + usesAppVersionUpdate: false, + usesGeolocator: false, +} + +/** + * MISC_DEFAULT: Exactly the values from defaultConfig.misc + */ +export const MISC_DEFAULT: MiscConfig = { ...defaultConfig.misc } + +/** + * MISC_ALL_ON: Every boolean flag set to true + */ +export const MISC_ALL_ON: MiscConfig = { + usesScreenutil: true, + usesFlutterNativeSplash: true, + usesDio: true, + usesHttp: true, + usesHive: true, + usesSharedPreferences: true, + usesSecureStorage: true, + usesCachedNetworkImage: true, + usesFlutterSvg: true, + usesSkeletonizer: true, + usesDotenv: true, + usesLogger: true, + usesFlutterHooks: true, + usesImagePicker: true, + usesFilePicker: true, + usesUrlLauncher: true, + usesPathProvider: true, + usesSharePlus: true, + usesPermissionHandler: true, + usesDeviceInfoPlus: true, + usesAppVersionUpdate: true, + usesGeolocator: true, +} + +/** + * MISC_HIGH_RISK: Hand-picked flags likely to conflict + */ +export const MISC_HIGH_RISK: MiscConfig = { + usesScreenutil: true, + usesFlutterNativeSplash: true, + usesDio: true, + usesHttp: true, + usesHive: true, + usesSharedPreferences: true, + usesSecureStorage: true, + usesCachedNetworkImage: true, + usesFlutterSvg: true, + usesSkeletonizer: true, + usesDotenv: true, + usesLogger: true, + usesFlutterHooks: false, + usesImagePicker: true, + usesFilePicker: true, + usesUrlLauncher: false, + usesPathProvider: false, + usesSharePlus: false, + usesPermissionHandler: true, + usesDeviceInfoPlus: true, + usesAppVersionUpdate: false, + usesGeolocator: true, +} + +/** + * Ensures valid combination for custom backend (requires Dio or HTTP) + */ +export function safeProfile(profile: MiscConfig, combo: PrimaryCombo): MiscConfig { + if (combo.backend === "custom" && !profile.usesDio && !profile.usesHttp) { + return { ...profile, usesDio: true } + } + return profile +} diff --git a/vitest.config.ts b/vitest.config.ts index 6e9f063..56cbcb5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,12 +7,13 @@ export default defineConfig({ include: ["tests/**/*.spec.ts"], // Exclude e2e tests from default `vitest run` — they need Dart SDK exclude: ["tests/e2e/**"], - testTimeout: 30_000, + testTimeout: 60_000, hookTimeout: 30_000, - reporters: ["default"], + reporters: ["default", "./tests/reporters/failed-tests-reporter.ts"], // Vitest 4: pool options are top-level isolate: false, - fileParallelism: false, + fileParallelism: true, + maxWorkers: 4, }, resolve: { alias: { From d6960cf6063358e040da6ccb34632c0474a85932 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Tue, 5 May 2026 10:57:01 +0500 Subject: [PATCH 07/14] feat: implement handlebars templating engine and matrix-based integration test suite --- app/lib/generator/handlebars.ts | 2 +- tests/utils/matrix-tests.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/lib/generator/handlebars.ts b/app/lib/generator/handlebars.ts index 956f129..e5428b4 100644 --- a/app/lib/generator/handlebars.ts +++ b/app/lib/generator/handlebars.ts @@ -83,7 +83,7 @@ export function registerHelpers(hbs: Hbs) { if (usesScreenutil) return `${value}.${unit}`; if (typeof value === 'number') { - return String(value); + return value % 1 === 0 ? `${value}.0` : String(value); } return String(value); }) diff --git a/tests/utils/matrix-tests.ts b/tests/utils/matrix-tests.ts index 56ddce2..9cf80cd 100644 --- a/tests/utils/matrix-tests.ts +++ b/tests/utils/matrix-tests.ts @@ -63,8 +63,8 @@ export function runMatrixTests(combos: PrimaryCombo[]) { absent: ["flutter_riverpod", "provider", "mobx", "flutter_mobx"], }, mobx: { - present: ["mobx", "flutter_mobx"], - absent: ["flutter_riverpod", "provider", "flutter_bloc"], + present: ["mobx", "flutter_mobx", "provider"], + absent: ["flutter_riverpod", "flutter_bloc"], }, none: { present: [], From a3d5ff89ab05060bb240f5f6779b16936e478a10 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Tue, 5 May 2026 11:08:14 +0500 Subject: [PATCH 08/14] feat: add Handlebars generator utility with custom helpers and configure CI test tiers --- .github/workflows/test-tier1.yml | 3 ++- .github/workflows/test-tier2.yml | 3 +++ app/lib/generator/handlebars.ts | 3 --- tests/unit/handlebars-helpers.spec.ts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-tier1.yml b/.github/workflows/test-tier1.yml index 4afceb1..999ba1c 100644 --- a/.github/workflows/test-tier1.yml +++ b/.github/workflows/test-tier1.yml @@ -9,7 +9,8 @@ env: on: push: - branches: ["**"] + branches: ["**", "!main"] + workflow_dispatch: jobs: layer1: diff --git a/.github/workflows/test-tier2.yml b/.github/workflows/test-tier2.yml index 4753e9f..5e83f8e 100644 --- a/.github/workflows/test-tier2.yml +++ b/.github/workflows/test-tier2.yml @@ -10,6 +10,9 @@ env: on: pull_request: branches: [main] + push: + branches: ["**", "!main"] + workflow_dispatch: jobs: layer1: diff --git a/app/lib/generator/handlebars.ts b/app/lib/generator/handlebars.ts index e5428b4..80ac1c0 100644 --- a/app/lib/generator/handlebars.ts +++ b/app/lib/generator/handlebars.ts @@ -82,9 +82,6 @@ export function registerHelpers(hbs: Hbs) { hbs.registerHelper("res", (value: unknown, unit: string, usesScreenutil: boolean) => { if (usesScreenutil) return `${value}.${unit}`; - if (typeof value === 'number') { - return value % 1 === 0 ? `${value}.0` : String(value); - } return String(value); }) hbs.registerHelper("when", function (this: unknown, condition, options) { diff --git a/tests/unit/handlebars-helpers.spec.ts b/tests/unit/handlebars-helpers.spec.ts index 90b464c..0f00495 100644 --- a/tests/unit/handlebars-helpers.spec.ts +++ b/tests/unit/handlebars-helpers.spec.ts @@ -149,7 +149,7 @@ describe("Handlebars Helpers", () => { it("returns plain double when ScreenUtil disabled (number input)", () => { const template = hbs.compile("{{res 16 'w' usesScreenutil}}") - expect(template({ usesScreenutil: false })).toBe("16.0") + expect(template({ usesScreenutil: false })).toBe("16") }) it("returns expression as-is when ScreenUtil disabled (string input)", () => { From 3dce5ba26a1adef755007144e5b066b237629de8 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Tue, 5 May 2026 11:27:33 +0500 Subject: [PATCH 09/14] feat: implement Flutter project scaffolding engine with template-based generation logic --- app/lib/generator/index.ts | 4 ++- templates/flutter/base/pubspec.yaml.hbs | 4 +++ .../flutter/overlays/extras/dotenv/.env.hbs | 33 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 templates/flutter/overlays/extras/dotenv/.env.hbs diff --git a/app/lib/generator/index.ts b/app/lib/generator/index.ts index 972d716..f870a02 100644 --- a/app/lib/generator/index.ts +++ b/app/lib/generator/index.ts @@ -35,6 +35,7 @@ type TemplateContext = ScaffoldConfig & { usesScreenutil: boolean usesFlutterNativeSplash: boolean usesLogger: boolean + usesDotenv: boolean usesIconsaxPlus: boolean usesFlutterRemix: boolean usesHugeicons: boolean @@ -196,6 +197,7 @@ function buildTemplateContext(config: ScaffoldConfig): TemplateContext { usesScreenutil: config.misc.usesScreenutil, usesFlutterNativeSplash: config.misc.usesFlutterNativeSplash, usesLogger: config.misc.usesLogger, + usesDotenv: config.misc.usesDotenv, supportsLocalization: config.localization.enabled, supportedLocales: config.localization.supportedLocales.length > 0 ? config.localization.supportedLocales : ["en"], fallbackLocale: config.localization.supportedLocales.length > 0 ? config.localization.supportedLocales[0] : "en", @@ -278,7 +280,7 @@ async function resolveOverlayDirs( config.misc.usesAppVersionUpdate, ], [path.join(root, "overlays", "extras", "flavors"), true], - [path.join(root, "overlays", "extras", "dotenv"), true], + [path.join(root, "overlays", "extras", "dotenv"), config.misc.usesDotenv], ] for (const [candidate, enabled] of candidates) { diff --git a/templates/flutter/base/pubspec.yaml.hbs b/templates/flutter/base/pubspec.yaml.hbs index bb1bab1..90b5e8a 100644 --- a/templates/flutter/base/pubspec.yaml.hbs +++ b/templates/flutter/base/pubspec.yaml.hbs @@ -135,7 +135,9 @@ dependencies: smooth_page_indicator: ^2.0.1 # Environment + {{#if flags.usesDotenv}} flutter_dotenv: ^6.0.0 + {{/if}} # Logging {{#if flags.usesLogger}} @@ -224,7 +226,9 @@ flutter: {{#if flags.hasCustomFonts}} - assets/fonts/ {{/if}} + {{#if flags.usesDotenv}} - .env + {{/if}} {{#if flags.hasCustomFonts}} fonts: diff --git a/templates/flutter/overlays/extras/dotenv/.env.hbs b/templates/flutter/overlays/extras/dotenv/.env.hbs new file mode 100644 index 0000000..113a24d --- /dev/null +++ b/templates/flutter/overlays/extras/dotenv/.env.hbs @@ -0,0 +1,33 @@ +# ───────────────────────────────────────────────────────────── +# {{appName}} — Environment Configuration +# ───────────────────────────────────────────────────────────── +# ⚠️ Never commit this file to version control — add .env to your .gitignore +# Copy this file, fill in your actual values, and rename to .env + +{{#if (eq backend.provider "firebase")}} +# ── Firebase ────────────────────────────────────────────────── +FIREBASE_API_KEY=your-api-key +FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +FIREBASE_PROJECT_ID=your-project-id +FIREBASE_STORAGE_BUCKET=your-project.appspot.com +FIREBASE_MESSAGING_SENDER_ID=your-sender-id +FIREBASE_APP_ID=your-app-id +FIREBASE_MEASUREMENT_ID=your-measurement-id +{{/if}} +{{#if (eq backend.provider "supabase")}} +# ── Supabase ────────────────────────────────────────────────── +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_ANON_KEY=your-anon-key +{{/if}} +{{#if (eq backend.provider "appwrite")}} +# ── Appwrite ────────────────────────────────────────────────── +APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 +APPWRITE_PROJECT_ID=your-project-id +{{/if}} +{{#if (or flags.usesDio flags.usesHttp)}} +# ── API ─────────────────────────────────────────────────────── +API_BASE_URL={{#if (eq backend.provider "customRest")}}{{backend.options.baseUrl}}{{else}}https://api.example.com{{/if}} +{{/if}} +# ── App ─────────────────────────────────────────────────────── +APP_NAME={{appName}} +APP_ENV=development From ab8be8ac8ca35a6436a65d5be2039e813a55dd56 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Tue, 5 May 2026 11:42:37 +0500 Subject: [PATCH 10/14] feat: define application scaffolding schema and add unit tests for configuration flags --- app/lib/config/schema.ts | 2 +- tests/unit/misc-flags.spec.ts | 25 +++++++++- tests/unit/theme.spec.ts | 89 +++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 tests/unit/theme.spec.ts diff --git a/app/lib/config/schema.ts b/app/lib/config/schema.ts index c9e1616..0f56ec2 100644 --- a/app/lib/config/schema.ts +++ b/app/lib/config/schema.ts @@ -188,7 +188,7 @@ const miscSchema = z.object({ usesCachedNetworkImage: z.boolean(), usesFlutterSvg: z.boolean(), usesSkeletonizer: z.boolean(), - usesDotenv: z.literal(true).default(true), + usesDotenv: z.boolean().default(true), usesLogger: z.boolean(), // Hooks usesFlutterHooks: z.boolean(), diff --git a/tests/unit/misc-flags.spec.ts b/tests/unit/misc-flags.spec.ts index 89bee6f..56ac7fb 100644 --- a/tests/unit/misc-flags.spec.ts +++ b/tests/unit/misc-flags.spec.ts @@ -19,16 +19,20 @@ describe("Misc Flags", () => { let minimalPubspec: string let defaultFiles: Map let defaultPubspec: string + let noDotenvFiles: Map + let noDotenvPubspec: string beforeAll(async () => { - [fullFiles, minimalFiles, defaultFiles] = await Promise.all([ + [fullFiles, minimalFiles, defaultFiles, noDotenvFiles] = await Promise.all([ generateToMap(buildConfig(base, MISC_ALL_ON)), generateToMap(buildConfig(base, MISC_BARE_MINIMUM)), generateToMap(buildConfig(base, MISC_DEFAULT)), + generateToMap(buildConfig(base, { ...MISC_DEFAULT, usesDotenv: false })), ]) fullPubspec = getPubspecContent(fullFiles) minimalPubspec = getPubspecContent(minimalFiles) defaultPubspec = getPubspecContent(defaultFiles) + noDotenvPubspec = getPubspecContent(noDotenvFiles) }) // ── ScreenUtil ────────────────────────────────────────────── @@ -188,4 +192,23 @@ describe("Misc Flags", () => { expect(translationFiles.length).toBeGreaterThanOrEqual(2) // en.json + es.json }) }) + + // ── Dotenv ────────────────────────────────────────────────── + describe("usesDotenv", () => { + it("when enabled: flutter_dotenv in pubspec", () => { + assertDependencyPresent(defaultPubspec, "flutter_dotenv") + }) + + it("when enabled: .env is in pubspec assets", () => { + expect(defaultPubspec).toMatch(/- \.env/g) + }) + + it("when disabled: no flutter_dotenv in pubspec", () => { + assertDependencyAbsent(noDotenvPubspec, "flutter_dotenv") + }) + + it("when disabled: .env is not in pubspec assets", () => { + expect(noDotenvPubspec).not.toMatch(/- \.env/g) + }) + }) }) diff --git a/tests/unit/theme.spec.ts b/tests/unit/theme.spec.ts new file mode 100644 index 0000000..e2d810a --- /dev/null +++ b/tests/unit/theme.spec.ts @@ -0,0 +1,89 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { getFileContent } from "../utils/assertions" +import { generateToMap, getPubspecContent } from "../utils/generate" +import { PrimaryCombo } from "../utils/matrix.config" +import { buildConfig } from "../utils/config-builder" + +const base: PrimaryCombo = { + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", + navigation: "go_router", +} + +describe("Theme Flags", () => { + let defaultFiles: Map + let defaultPubspec: string + + let cupertinoFiles: Map + let cupertinoPubspec: string + + let customFontFiles: Map + let customFontPubspec: string + + beforeAll(async () => { + const defaultConfig = buildConfig(base) + + const cupertinoConfig = buildConfig(base) + cupertinoConfig.theme.preset = "cupertino" + + const customFontConfig = buildConfig(base) + customFontConfig.theme.customFonts = [ + { family: "Inter", fileName: "Inter-Regular.ttf", style: "normal", weight: "400" }, + { family: "Inter", fileName: "Inter-Bold.ttf", style: "normal", weight: "700" }, + { family: "Roboto", fileName: "Roboto-Italic.ttf", style: "italic", weight: "400" }, + ] + + const [defMap, cupMap, fontMap] = await Promise.all([ + generateToMap(defaultConfig), + generateToMap(cupertinoConfig), + generateToMap(customFontConfig), + ]) + + defaultFiles = defMap + defaultPubspec = getPubspecContent(defMap) + + cupertinoFiles = cupMap + cupertinoPubspec = getPubspecContent(cupMap) + + customFontFiles = fontMap + customFontPubspec = getPubspecContent(fontMap) + }) + + describe("Theme Preset", () => { + it("when preset is material3 (default), generates material design defaults", () => { + const appDart = getFileContent(defaultFiles, "lib/src/app.dart") + expect(appDart).toBeDefined() + expect(appDart).toContain("MaterialApp") + }) + + it("when preset is cupertino, generates cupertino defaults", () => { + const appDart = getFileContent(cupertinoFiles, "lib/src/app.dart") + expect(appDart).toBeDefined() + // Should contain CupertinoThemeData or similar + expect(appDart).toContain("Cupertino") + }) + }) + + describe("Custom Fonts", () => { + it("when no fonts are provided, fonts section is omitted from pubspec", () => { + expect(defaultPubspec).not.toMatch(/^ fonts:/m) + expect(defaultPubspec).not.toMatch(/- assets\/fonts\//g) + }) + + it("when fonts are provided, fonts block is properly constructed in pubspec", () => { + expect(customFontPubspec).toMatch(/^ fonts:/m) + expect(customFontPubspec).toMatch(/- family: Inter/g) + expect(customFontPubspec).toMatch(/- asset: assets\/fonts\/Inter-Regular.ttf/g) + expect(customFontPubspec).toMatch(/- asset: assets\/fonts\/Inter-Bold.ttf/g) + expect(customFontPubspec).toMatch(/weight: 700/g) + expect(customFontPubspec).toMatch(/- family: Roboto/g) + expect(customFontPubspec).toMatch(/- asset: assets\/fonts\/Roboto-Italic.ttf/g) + expect(customFontPubspec).toMatch(/style: italic/g) + }) + + it("when fonts are provided, assets/fonts/ is in pubspec assets", () => { + expect(customFontPubspec).toMatch(/- assets\/fonts\//g) + }) + }) +}) From 3009ba9492cd26b4a9f4823d4ef84daeb2bf1ae5 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Tue, 5 May 2026 12:08:53 +0500 Subject: [PATCH 11/14] feat: implement comprehensive matrix-based integration and unit testing suite for flutter_init configurations --- tests/e2e/dart-validation.spec.ts | 11 ++++++----- tests/e2e/run-matrix.ts | 6 +++--- tests/e2e/validate-combo.ts | 9 +++++---- tests/integration/full-pipeline.spec.ts | 4 ++-- tests/integration/overlay-composition.spec.ts | 2 +- tests/unit/backend.spec.ts | 2 +- tests/unit/handlebars-helpers.spec.ts | 2 +- tests/unit/matrix-shard-1.spec.ts | 2 +- tests/unit/matrix-shard-2.spec.ts | 2 +- tests/unit/matrix-shard-3.spec.ts | 2 +- tests/unit/matrix-shard-4.spec.ts | 2 +- tests/unit/misc-flags.spec.ts | 2 +- tests/unit/navigation.spec.ts | 2 +- tests/unit/state-management.spec.ts | 2 +- tests/unit/theme.spec.ts | 12 ++++++------ tests/utils/combinations.ts | 2 +- tests/utils/config-builder.ts | 3 +-- tests/utils/critical-combos.ts | 4 ++-- tests/utils/matrix-tests.ts | 4 ++-- 19 files changed, 38 insertions(+), 37 deletions(-) diff --git a/tests/e2e/dart-validation.spec.ts b/tests/e2e/dart-validation.spec.ts index 93e58d6..ce2a8a6 100644 --- a/tests/e2e/dart-validation.spec.ts +++ b/tests/e2e/dart-validation.spec.ts @@ -7,16 +7,17 @@ * This runs in Tier 2 CI (PR checks) where Flutter is installed. */ +import { execSync } from "node:child_process" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" -import { execSync } from "node:child_process" -import { describe, expect, it, beforeAll } from "vitest" +import { beforeAll, describe, it } from "vitest" +import { buildConfig } from "../utils/config-builder" +import { CRITICAL_COMBOS as CRITICAL_COMBINATIONS } from "../utils/critical-combos" import { generateToDisk } from "../utils/generate" -import { buildConfig, combinationLabel } from "../utils/matrix.config" -import { CRITICAL_COMBINATIONS } from "../utils/critical-combos" +import { COMBO_LABEL as combinationLabel } from "../utils/matrix.config" // ── Check if Dart SDK is available ────────────────────────────── @@ -89,7 +90,7 @@ describe("Layer 2 — Dart Validation (Critical Combinations)", { timeout: 3_600 throw new Error(`dart analyze failed:\n${output}`) } } finally { - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { }) } } ) diff --git a/tests/e2e/run-matrix.ts b/tests/e2e/run-matrix.ts index 7dc63d8..a5f2e30 100644 --- a/tests/e2e/run-matrix.ts +++ b/tests/e2e/run-matrix.ts @@ -12,8 +12,8 @@ import path from "node:path" -import { ALL_COMBINATIONS, combinationLabel, type Combination } from "../utils/matrix.config" -import { CRITICAL_COMBINATIONS } from "../utils/critical-combos" +import { CRITICAL_COMBOS as CRITICAL_COMBINATIONS } from "../utils/critical-combos" +import { PRIMARY_COMBINATIONS as ALL_COMBINATIONS, COMBO_LABEL as combinationLabel, type PrimaryCombo as Combination } from "../utils/matrix.config" // ── Parse args ────────────────────────────────────────────────── @@ -81,7 +81,7 @@ function validateCombo(combo: Combination): Result { ) const output = (result.stdout || "") + "\n" + (result.stderr || "") - + return { combo, label, diff --git a/tests/e2e/validate-combo.ts b/tests/e2e/validate-combo.ts index c5b17eb..c6d1770 100644 --- a/tests/e2e/validate-combo.ts +++ b/tests/e2e/validate-combo.ts @@ -14,12 +14,13 @@ * 2 — generation failed */ +import { execSync } from "node:child_process" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" -import { execSync } from "node:child_process" -import { ALL_COMBINATIONS, buildConfig, combinationLabel, type Combination } from "../utils/matrix.config" +import { buildConfig } from "../utils/config-builder" +import { PRIMARY_COMBINATIONS as ALL_COMBINATIONS, COMBO_LABEL as combinationLabel, type PrimaryCombo as Combination } from "../utils/matrix.config" // ── Parse CLI args ────────────────────────────────────────────── @@ -62,7 +63,7 @@ function runCommand(cmd: string, cwd: string): { success: boolean; output: strin async function main() { const combo = parseCombination() const label = combinationLabel(combo) - const config = buildConfig(combo) + const config = buildConfig(combo, (combo as any).miscProfile) console.log(`\n${"═".repeat(60)}`) console.log(`Validating: ${label}`) @@ -119,7 +120,7 @@ async function main() { process.exit(2) } finally { // Cleanup - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { }) } } diff --git a/tests/integration/full-pipeline.spec.ts b/tests/integration/full-pipeline.spec.ts index 3c2dec9..12f4cd1 100644 --- a/tests/integration/full-pipeline.spec.ts +++ b/tests/integration/full-pipeline.spec.ts @@ -6,10 +6,10 @@ import { assertRequiredFilesExist, assertValidPubspec, } from "../utils/assertions" -import { generateToMap, getPubspecContent, getFile } from "../utils/generate" -import { COMBO_LABEL } from "../utils/matrix.config" import { buildConfig } from "../utils/config-builder" import { CRITICAL_COMBOS } from "../utils/critical-combos" +import { generateToMap, getFile, getPubspecContent } from "../utils/generate" +import { COMBO_LABEL } from "../utils/matrix.config" describe("Full Pipeline — Critical Combinations", () => { it.each( diff --git a/tests/integration/overlay-composition.spec.ts b/tests/integration/overlay-composition.spec.ts index a4c04b2..1eb44b3 100644 --- a/tests/integration/overlay-composition.spec.ts +++ b/tests/integration/overlay-composition.spec.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from "vitest" import { getFileContent } from "../utils/assertions" +import { buildConfig } from "../utils/config-builder" import { generateToMap, getPubspecContent } from "../utils/generate" import { PrimaryCombo } from "../utils/matrix.config" -import { buildConfig } from "../utils/config-builder" import { MISC_ALL_ON, MISC_BARE_MINIMUM, MISC_DEFAULT } from "../utils/misc-profiles" const base: PrimaryCombo = { diff --git a/tests/unit/backend.spec.ts b/tests/unit/backend.spec.ts index c9c11c9..5a96137 100644 --- a/tests/unit/backend.spec.ts +++ b/tests/unit/backend.spec.ts @@ -3,9 +3,9 @@ import { assertDependencyAbsent, assertDependencyPresent } from "../utils/assertions" +import { buildConfig } from "../utils/config-builder" import { generateToMap, getPubspecContent } from "../utils/generate" import { PrimaryCombo } from "../utils/matrix.config" -import { buildConfig } from "../utils/config-builder" import { MISC_ALL_ON, MISC_DEFAULT } from "../utils/misc-profiles" const base: Omit = { diff --git a/tests/unit/handlebars-helpers.spec.ts b/tests/unit/handlebars-helpers.spec.ts index 0f00495..699a375 100644 --- a/tests/unit/handlebars-helpers.spec.ts +++ b/tests/unit/handlebars-helpers.spec.ts @@ -1,6 +1,6 @@ +import { createHandlebarsEnvironment } from "@/app/lib/generator/handlebars" import path from "node:path" import { beforeAll, describe, expect, it } from "vitest" -import { createHandlebarsEnvironment } from "@/app/lib/generator/handlebars" const partialsDir = path.join(process.cwd(), "templates", "flutter", "partials") diff --git a/tests/unit/matrix-shard-1.spec.ts b/tests/unit/matrix-shard-1.spec.ts index 35bdbd9..c0a13d9 100644 --- a/tests/unit/matrix-shard-1.spec.ts +++ b/tests/unit/matrix-shard-1.spec.ts @@ -1,6 +1,6 @@ import { describe } from "vitest" -import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" import { runMatrixTests } from "../utils/matrix-tests" +import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" // Shard 1: 0-93 describe("Matrix Shard 1/4", { timeout: 600_000 }, () => { diff --git a/tests/unit/matrix-shard-2.spec.ts b/tests/unit/matrix-shard-2.spec.ts index 5f9433d..3cc9cbe 100644 --- a/tests/unit/matrix-shard-2.spec.ts +++ b/tests/unit/matrix-shard-2.spec.ts @@ -1,6 +1,6 @@ import { describe } from "vitest" -import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" import { runMatrixTests } from "../utils/matrix-tests" +import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" // Shard 2: 93-186 describe("Matrix Shard 2/4", { timeout: 600_000 }, () => { diff --git a/tests/unit/matrix-shard-3.spec.ts b/tests/unit/matrix-shard-3.spec.ts index a8d2cec..2608372 100644 --- a/tests/unit/matrix-shard-3.spec.ts +++ b/tests/unit/matrix-shard-3.spec.ts @@ -1,6 +1,6 @@ import { describe } from "vitest" -import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" import { runMatrixTests } from "../utils/matrix-tests" +import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" // Shard 3: 186-279 describe("Matrix Shard 3/4", { timeout: 600_000 }, () => { diff --git a/tests/unit/matrix-shard-4.spec.ts b/tests/unit/matrix-shard-4.spec.ts index bd8c2f8..c932c6f 100644 --- a/tests/unit/matrix-shard-4.spec.ts +++ b/tests/unit/matrix-shard-4.spec.ts @@ -1,6 +1,6 @@ import { describe } from "vitest" -import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" import { runMatrixTests } from "../utils/matrix-tests" +import { PRIMARY_COMBINATIONS } from "../utils/matrix.config" // Shard 4: 279-375 describe("Matrix Shard 4/4", { timeout: 600_000 }, () => { diff --git a/tests/unit/misc-flags.spec.ts b/tests/unit/misc-flags.spec.ts index 56ac7fb..c3c9227 100644 --- a/tests/unit/misc-flags.spec.ts +++ b/tests/unit/misc-flags.spec.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from "vitest" import { assertDependencyAbsent, assertDependencyPresent, getFileContent } from "../utils/assertions" +import { buildConfig } from "../utils/config-builder" import { generateToMap, getPubspecContent } from "../utils/generate" import { PrimaryCombo } from "../utils/matrix.config" -import { buildConfig } from "../utils/config-builder" import { MISC_ALL_ON, MISC_BARE_MINIMUM, MISC_DEFAULT } from "../utils/misc-profiles" const base: PrimaryCombo = { diff --git a/tests/unit/navigation.spec.ts b/tests/unit/navigation.spec.ts index 445cce1..8474b84 100644 --- a/tests/unit/navigation.spec.ts +++ b/tests/unit/navigation.spec.ts @@ -4,9 +4,9 @@ import { assertDependencyPresent, getFileContent } from "../utils/assertions" +import { buildConfig } from "../utils/config-builder" import { generateToMap, getPubspecContent } from "../utils/generate" import { PrimaryCombo } from "../utils/matrix.config" -import { buildConfig } from "../utils/config-builder" import { MISC_DEFAULT } from "../utils/misc-profiles" const base: Omit = { diff --git a/tests/unit/state-management.spec.ts b/tests/unit/state-management.spec.ts index 2d65bd7..eec93f7 100644 --- a/tests/unit/state-management.spec.ts +++ b/tests/unit/state-management.spec.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from "vitest" import { assertFileContains, assertFileNotContains, getFileContent } from "../utils/assertions" +import { buildConfig } from "../utils/config-builder" import { generateToMap, getPubspecContent } from "../utils/generate" import { PrimaryCombo } from "../utils/matrix.config" -import { buildConfig } from "../utils/config-builder" import { MISC_ALL_ON, MISC_DEFAULT } from "../utils/misc-profiles" const base: Omit = { diff --git a/tests/unit/theme.spec.ts b/tests/unit/theme.spec.ts index e2d810a..7483ad7 100644 --- a/tests/unit/theme.spec.ts +++ b/tests/unit/theme.spec.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it } from "vitest" import { getFileContent } from "../utils/assertions" +import { buildConfig } from "../utils/config-builder" import { generateToMap, getPubspecContent } from "../utils/generate" import { PrimaryCombo } from "../utils/matrix.config" -import { buildConfig } from "../utils/config-builder" const base: PrimaryCombo = { architecture: "feature-first", @@ -14,7 +14,7 @@ const base: PrimaryCombo = { describe("Theme Flags", () => { let defaultFiles: Map let defaultPubspec: string - + let cupertinoFiles: Map let cupertinoPubspec: string @@ -23,10 +23,10 @@ describe("Theme Flags", () => { beforeAll(async () => { const defaultConfig = buildConfig(base) - + const cupertinoConfig = buildConfig(base) cupertinoConfig.theme.preset = "cupertino" - + const customFontConfig = buildConfig(base) customFontConfig.theme.customFonts = [ { family: "Inter", fileName: "Inter-Regular.ttf", style: "normal", weight: "400" }, @@ -42,10 +42,10 @@ describe("Theme Flags", () => { defaultFiles = defMap defaultPubspec = getPubspecContent(defMap) - + cupertinoFiles = cupMap cupertinoPubspec = getPubspecContent(cupMap) - + customFontFiles = fontMap customFontPubspec = getPubspecContent(fontMap) }) diff --git a/tests/utils/combinations.ts b/tests/utils/combinations.ts index cbd746f..0f86c55 100644 --- a/tests/utils/combinations.ts +++ b/tests/utils/combinations.ts @@ -3,7 +3,7 @@ import { BACKEND_OPTIONS, NAVIGATION_OPTIONS, STATE_OPTIONS, -} from "./matrix.config" +} from "./matrix.config"; /** * Cartesian product generator for any number of dimensions. diff --git a/tests/utils/config-builder.ts b/tests/utils/config-builder.ts index cbde4a9..2b99abd 100644 --- a/tests/utils/config-builder.ts +++ b/tests/utils/config-builder.ts @@ -2,10 +2,9 @@ import type { BackendConfig, ScaffoldConfig, } from "../../app/lib/config/schema" -import { defaultBackendConfig } from "../../app/lib/config/schema" +import { defaultBackendConfig, MiscConfig } from "../../app/lib/config/schema" import { PrimaryCombo } from "./matrix.config" import { MISC_DEFAULT, safeProfile } from "./misc-profiles" -import { MiscConfig } from "../../app/lib/config/schema" /** * Build a full ScaffoldConfig from a PrimaryCombo and an optional MiscProfile. diff --git a/tests/utils/critical-combos.ts b/tests/utils/critical-combos.ts index b3cfeda..09c3a4f 100644 --- a/tests/utils/critical-combos.ts +++ b/tests/utils/critical-combos.ts @@ -1,6 +1,6 @@ -import { PrimaryCombo } from "./matrix.config" import { MiscConfig } from "../../app/lib/config/schema" -import { MISC_DEFAULT, MISC_BARE_MINIMUM, MISC_ALL_ON, MISC_HIGH_RISK } from "./misc-profiles" +import { PrimaryCombo } from "./matrix.config" +import { MISC_ALL_ON, MISC_BARE_MINIMUM, MISC_DEFAULT, MISC_HIGH_RISK } from "./misc-profiles" interface CriticalCombo extends PrimaryCombo { miscProfile: MiscConfig diff --git a/tests/utils/matrix-tests.ts b/tests/utils/matrix-tests.ts index 9cf80cd..e6e4764 100644 --- a/tests/utils/matrix-tests.ts +++ b/tests/utils/matrix-tests.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, it, expect } from "vitest" +import { beforeAll, describe, it } from "vitest" import { assertArchitectureStructure, assertDependencyAbsent, @@ -8,9 +8,9 @@ import { assertRequiredFilesExist, assertValidPubspec, } from "./assertions" +import { buildConfig } from "./config-builder" import { generateToMap, getPubspecContent } from "./generate" import { COMBO_LABEL, PrimaryCombo } from "./matrix.config" -import { buildConfig } from "./config-builder" export function runMatrixTests(combos: PrimaryCombo[]) { for (const combo of combos) { From c835c1b14cd391522d1fd53458448b8055fa9989 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Tue, 5 May 2026 12:47:33 +0500 Subject: [PATCH 12/14] feat: add bun-types and implement automated Dart project validation script --- package-lock.json | 11 +++++++++++ package.json | 1 + scripts/validate-dart.ts | 5 +++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b8c785..7b57019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitest/ui": "^4.1.5", + "bun-types": "^1.3.13", "eslint": "^9", "eslint-config-next": "16.1.4", "tailwindcss": "^4", @@ -5433,6 +5434,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bun-types": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.13.tgz", + "integrity": "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", diff --git a/package.json b/package.json index 0c2c7a0..b37beb3 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitest/ui": "^4.1.5", + "bun-types": "^1.3.13", "eslint": "^9", "eslint-config-next": "16.1.4", "tailwindcss": "^4", diff --git a/scripts/validate-dart.ts b/scripts/validate-dart.ts index 6a3bf3b..19be6be 100644 --- a/scripts/validate-dart.ts +++ b/scripts/validate-dart.ts @@ -1,3 +1,4 @@ +/// import { $ } from "bun" import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs" import fs from "node:fs/promises" @@ -5,7 +6,7 @@ import path from "node:path" import { buildConfig } from "../tests/utils/config-builder" import { CRITICAL_COMBOS } from "../tests/utils/critical-combos" import { generateToDisk } from "../tests/utils/generate" -import { COMBO_LABEL } from "../tests/utils/matrix.config" +import { COMBO_LABEL, type PrimaryCombo } from "../tests/utils/matrix.config" import { generatePrimaryCombinations } from "../tests/utils/combinations" import { MISC_DEFAULT } from "../tests/utils/misc-profiles" @@ -19,7 +20,7 @@ console.log(`⚡ Starting Dart Validation (Mode: ${MODE})`) const TEMP_BASE = "./.temp/flutterinit" await fs.mkdir(TEMP_BASE, { recursive: true }) -let combosToRun = CRITICAL_COMBOS +let combosToRun: Array = CRITICAL_COMBOS if (COMBO) { const foundInCritical = CRITICAL_COMBOS.find(c => COMBO_LABEL(c) === COMBO) From 634c2130f87e50c077bd964cc1d4de1b7f42a540 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Tue, 5 May 2026 12:53:21 +0500 Subject: [PATCH 13/14] chore: add bun-types dependency to lockfile --- bun.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bun.lock b/bun.lock index 4e4579f..0fbb21a 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitest/ui": "^4.1.5", + "bun-types": "^1.3.13", "eslint": "^9", "eslint-config-next": "16.1.4", "tailwindcss": "^4", @@ -750,6 +751,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, ""], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], From a8c553538441d314455f94dfea8cee1ca51cc110 Mon Sep 17 00:00:00 2001 From: arjun544 Date: Fri, 22 May 2026 09:30:47 +0500 Subject: [PATCH 14/14] removed stats cache --- app/components/landing/StatsSection.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/landing/StatsSection.tsx b/app/components/landing/StatsSection.tsx index f6f4bc9..f685bac 100644 --- a/app/components/landing/StatsSection.tsx +++ b/app/components/landing/StatsSection.tsx @@ -18,10 +18,10 @@ type StatsResponse = { async function getStats(): Promise { try { const res = await fetch(`${process.env.NEXT_PUBLIC_VERCEL_URL}/api/stats`, { - next: { - revalidate: 60, - tags: ["generator-stats"], - }, + // next: { + // revalidate: 60, + // tags: ["generator-stats"], + // }, }) if (!res.ok) return null return (await res.json()) as StatsResponse