diff --git a/.github/workflows/test-tier1.yml b/.github/workflows/test-tier1.yml new file mode 100644 index 0000000..999ba1c --- /dev/null +++ b/.github/workflows/test-tier1.yml @@ -0,0 +1,42 @@ +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. +# Target duration: < 3 minutes. + +on: + push: + branches: ["**", "!main"] + workflow_dispatch: + +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 (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 new file mode 100644 index 0000000..5e83f8e --- /dev/null +++ b/.github/workflows/test-tier2.yml @@ -0,0 +1,71 @@ +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. +# Target duration: < 15 minutes. + +on: + pull_request: + branches: [main] + push: + branches: ["**", "!main"] + workflow_dispatch: + +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 (Unit + Integration) + run: npm run test:layer1 + + layer2: + name: Layer 2 — Dart Validation (Critical Combos) + needs: layer1 + runs-on: ubuntu-latest + timeout-minutes: 60 + + 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: 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 new file mode 100644 index 0000000..c90eccf --- /dev/null +++ b/.github/workflows/test-tier3.yml @@ -0,0 +1,148 @@ +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. +# Automatic on push to main + manual workflow_dispatch. + +on: + push: + branches: [main] + 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 (Unit + Integration) + run: npm run test:layer1 + + # ── 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: 120 + 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" + + - 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: + 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/.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/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..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,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)**: 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/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/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 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..0f56ec2 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 @@ -138,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(), @@ -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/handlebars.ts b/app/lib/generator/handlebars.ts index 6baae8e..80ac1c0 100644 --- a/app/lib/generator/handlebars.ts +++ b/app/lib/generator/handlebars.ts @@ -80,10 +80,9 @@ 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}`; + + return String(value); }) hbs.registerHelper("when", function (this: unknown, condition, options) { return condition ? options.fn(this) : options.inverse(this) diff --git a/app/lib/generator/index.ts b/app/lib/generator/index.ts index 5386099..f870a02 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" @@ -35,6 +35,7 @@ type TemplateContext = ScaffoldConfig & { usesScreenutil: boolean usesFlutterNativeSplash: boolean usesLogger: boolean + usesDotenv: boolean usesIconsaxPlus: boolean usesFlutterRemix: boolean usesHugeicons: boolean @@ -62,10 +63,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 +103,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 +119,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 +156,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: { @@ -155,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", @@ -182,6 +225,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, }, } } @@ -234,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/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/bun.lock b/bun.lock index c8f7629..0fbb21a 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,14 @@ "@types/node": "^20", "@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", "typescript": "^5", - "vitest": "^4.0.17", + "vitest": "^4.1.5", + "yaml": "^2.8.4", }, }, }, @@ -336,6 +339,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 +519,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 +607,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 +669,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.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/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/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.0.17", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw=="], + "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], - "@vitest/runner": ["@vitest/runner@4.0.17", "", { "dependencies": { "@vitest/utils": "4.0.17", "pathe": "^2.0.3" } }, "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ=="], + "@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/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/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], - "@vitest/spy": ["@vitest/spy@4.0.17", "", {}, "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew=="], + "@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.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "tinyrainbow": "^3.0.3" } }, "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w=="], + "@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=="], @@ -728,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=="], @@ -896,7 +921,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 +1009,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 +1023,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 +1111,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 +1323,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 +1561,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 +1577,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 +1635,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 +1645,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 +1715,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 +1739,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 +1809,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=="], 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({ 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 + +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 — The Generated Flutter Project (E2E)** +- **Goal**: Guarantee that the generated code actually compiles and follows Dart best practices. +- **Tools**: Dart SDK (`dart pub get`, `dart analyze`), Bun. +- **Speed**: Slower (minutes). +- **Environment**: Requires Flutter/Dart SDK. + +--- + +## 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 +│ └── ... +├── integration/ # Layer 1: Pipeline tests +│ └── 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 +``` + +--- + +## 10. CI/CD Strategy and Tiering + +### Tier 1 — Every Push (Unit & Integration) +- **Runs**: `npm run test:layer1` +- **Goal**: Immediate feedback on template logic. +- **Duration**: < 3 mins. + +### 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). + +### 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). + +--- + +## 11. The Pre-Production Gate + +Before any release, the "Preflight" command must pass: + +```bash +npm run test:preflight +``` + +This chains Layer 1 and Layer 2 validation. If any step fails, the deployment is blocked. + +--- + +## 12. Snapshot Testing for Regression Prevention + +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. + +--- + +## 13. Failure Handling and Debugging + +### 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` + +### 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. + +--- + +## 14. Common Pitfalls + +- **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. + +--- + +## 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. + +--- + +*This guide is the source of truth for FlutterInit quality standards. Update it whenever new options are added or the validation pipeline is enhanced.* + diff --git a/package-lock.json b/package-lock.json index 8f12cdb..7b57019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,11 +41,14 @@ "@types/node": "^20", "@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", "typescript": "^5", - "vitest": "^4.0.17" + "vitest": "^4.1.5", + "yaml": "^2.8.4" } }, "node_modules/@alloc/quick-lru": { @@ -720,472 +723,36 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", + "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, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "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" - ], - "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" - } - }, - "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 +1914,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 +3429,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 +3441,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 +3458,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 +3475,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 +3492,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 +3509,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 +3526,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 +3560,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 +3577,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 +3594,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 +3611,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 +3628,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 +3700,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 +4852,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 +4885,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 +4897,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 +4924,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 +4940,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" @@ -5893,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", @@ -6913,9 +6464,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 +6521,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 +7175,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 +7267,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 +9043,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 +9744,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 +10366,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 +10881,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 +10950,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 +11292,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 +11325,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 +11338,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 +11384,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 +11860,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 +11886,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 +11902,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -12386,28 +11937,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 +12212,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 +12252,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 +12281,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -12492,6 +12295,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -12753,6 +12559,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..b37beb3 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 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", @@ -43,10 +49,13 @@ "@types/node": "^20", "@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", "typescript": "^5", - "vitest": "^4.0.17" + "vitest": "^4.1.5", + "yaml": "^2.8.4" } } diff --git a/scripts/validate-dart.ts b/scripts/validate-dart.ts new file mode 100644 index 0000000..19be6be --- /dev/null +++ b/scripts/validate-dart.ts @@ -0,0 +1,140 @@ +/// +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, type PrimaryCombo } 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: Array = 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/lib/src/theme/text_theme.dart.hbs b/templates/flutter/base/lib/src/theme/text_theme.dart.hbs index 68c25c1..bc25619 100644 --- a/templates/flutter/base/lib/src/theme/text_theme.dart.hbs +++ b/templates/flutter/base/lib/src/theme/text_theme.dart.hbs @@ -158,5 +158,10 @@ TextTheme buildTextTheme() { ), ); + {{#if flags.hasCustomFonts}} + // Apply the primary custom font family to every text style in the scale. + return baseTextTheme.apply(fontFamily: '{{flags.primaryFontFamily}}'); + {{else}} return baseTextTheme; + {{/if}} } \ No newline at end of file diff --git a/templates/flutter/base/lib/src/theme/theme.dart.hbs b/templates/flutter/base/lib/src/theme/theme.dart.hbs index adaff93..a8810b3 100644 --- a/templates/flutter/base/lib/src/theme/theme.dart.hbs +++ b/templates/flutter/base/lib/src/theme/theme.dart.hbs @@ -93,6 +93,9 @@ ThemeData _buildTheme(ColorScheme colorScheme, AppColorsExtension customColors) primaryColor: colorScheme.primary, colorScheme: colorScheme, textTheme: textTheme, + {{#if flags.hasCustomFonts}} + fontFamily: '{{flags.primaryFontFamily}}', + {{/if}} extensions: [ customColors, AppDesignTokens.fallback, @@ -384,37 +387,45 @@ CupertinoThemeData buildCupertinoTheme({required String primaryColorHex}) { textTheme: CupertinoTextThemeData( primaryColor: seed, textStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} fontSize: 17, letterSpacing: -0.41, ), actionTextStyle: TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} color: seed, fontSize: 17, fontWeight: FontWeight.w400, ), navTitleTextStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} fontWeight: FontWeight.w600, fontSize: 17, letterSpacing: -0.41, ), navLargeTitleTextStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} fontWeight: FontWeight.bold, fontSize: 34, letterSpacing: 0.41, ), tabLabelTextStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} fontSize: 10, fontWeight: FontWeight.w500, letterSpacing: -0.24, ), pickerTextStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} fontSize: 21, letterSpacing: -0.41, ), dateTimePickerTextStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} fontSize: 21, letterSpacing: -0.41, ), ), ); } + diff --git a/templates/flutter/base/pubspec.yaml.hbs b/templates/flutter/base/pubspec.yaml.hbs index 8ef2792..90b5e8a 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 @@ -134,7 +135,9 @@ dependencies: smooth_page_indicator: ^2.0.1 # Environment + {{#if flags.usesDotenv}} flutter_dotenv: ^6.0.0 + {{/if}} # Logging {{#if flags.usesLogger}} @@ -200,7 +203,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: @@ -213,5 +223,26 @@ flutter: {{#if flags.supportsLocalization}} - assets/translations/ {{/if}} + {{#if flags.hasCustomFonts}} + - assets/fonts/ + {{/if}} + {{#if flags.usesDotenv}} - .env + {{/if}} + {{#if flags.hasCustomFonts}} + fonts: + {{#each flags.fontFamilies}} + - family: {{this.family}} + fonts: + {{#each this.fonts}} + - asset: assets/fonts/{{this.fileName}} + {{#if (eq this.style "italic")}} + style: italic + {{/if}} + {{#unless (eq this.weight "400")}} + weight: {{this.weight}} + {{/unless}} + {{/each}} + {{/each}} + {{/if}} 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/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 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/dart-validation.spec.ts b/tests/e2e/dart-validation.spec.ts new file mode 100644 index 0000000..ce2a8a6 --- /dev/null +++ b/tests/e2e/dart-validation.spec.ts @@ -0,0 +1,97 @@ +/** + * 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 { execSync } from "node:child_process" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +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 { COMBO_LABEL as combinationLabel } from "../utils/matrix.config" + +// ── 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..a5f2e30 --- /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 { 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 ────────────────────────────────────────────────── + +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..c6d1770 --- /dev/null +++ b/tests/e2e/validate-combo.ts @@ -0,0 +1,127 @@ +/** + * 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 { execSync } from "node:child_process" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +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 ────────────────────────────────────────────── + +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, (combo as any).miscProfile) + + 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") + + // 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) + 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..12f4cd1 --- /dev/null +++ b/tests/integration/full-pipeline.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest" +import { + assertArchitectureStructure, + assertNoEmptyFiles, + assertNoUnresolvedTokens, + assertRequiredFilesExist, + assertValidPubspec, +} from "../utils/assertions" +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( + 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, combo.miscProfile) + 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 = getFile(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..1eb44b3 --- /dev/null +++ b/tests/integration/overlay-composition.spec.ts @@ -0,0 +1,144 @@ +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 { MISC_ALL_ON, MISC_BARE_MINIMUM, MISC_DEFAULT } from "../utils/misc-profiles" + +const base: PrimaryCombo = { + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", + navigation: "go_router", +} + +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).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).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).toContain("MultiProvider") + }) + }) + + // ── Backend overlays inject service files ──────────────────── + describe("Backend overlay injects service 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:") + expect(getFileContent(firebaseFiles, "auth_service.dart")).toBeDefined() + }) + + it("supabase overlay adds auth_service when auth enabled", () => { + const pubspec = getPubspecContent(supabaseFiles) + expect(pubspec).toContain("supabase_flutter:") + 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 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", () => { + 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", () => { + 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, 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, MISC_BARE_MINIMUM)) + 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, 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, MISC_BARE_MINIMUM)) + 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/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 new file mode 100644 index 0000000..5a96137 --- /dev/null +++ b/tests/unit/backend.spec.ts @@ -0,0 +1,120 @@ +import { beforeAll, describe, it } from "vitest" +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 { MISC_ALL_ON, MISC_DEFAULT } from "../utils/misc-profiles" + +const base: Omit = { + architecture: "feature-first", + stateManagement: "riverpod", + navigation: "go_router", +} + +describe("Backend Providers", () => { + // ── Firebase ──────────────────────────────────────────────── + describe("firebase", () => { + 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", () => { + assertDependencyPresent(pubspec, "firebase_auth") + }) + + it("includes cloud_firestore when firestore enabled", () => { + assertDependencyPresent(pubspec, "cloud_firestore") + }) + + it("does not include supabase or appwrite packages", () => { + assertDependencyAbsent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "appwrite") + }) + }) + + // ── Supabase ──────────────────────────────────────────────── + describe("supabase", () => { + 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", () => { + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "appwrite") + }) + }) + + // ── Appwrite ──────────────────────────────────────────────── + describe("appwrite", () => { + 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", () => { + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "supabase_flutter") + }) + }) + + // ── Custom ────────────────────────────────────────────────── + describe("custom", () => { + 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", () => { + assertDependencyAbsent(pubspec, "firebase_core") + assertDependencyAbsent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "appwrite") + }) + }) + + // ── None ──────────────────────────────────────────────────── + describe("none", () => { + 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") + assertDependencyAbsent(pubspec, "supabase_flutter") + assertDependencyAbsent(pubspec, "appwrite") + }) + }) +}) diff --git a/tests/unit/handlebars-helpers.spec.ts b/tests/unit/handlebars-helpers.spec.ts new file mode 100644 index 0000000..699a375 --- /dev/null +++ b/tests/unit/handlebars-helpers.spec.ts @@ -0,0 +1,200 @@ +import { createHandlebarsEnvironment } from "@/app/lib/generator/handlebars" +import path from "node:path" +import { beforeAll, describe, expect, it } from "vitest" + +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", () => { + const template = hbs.compile("{{kebabCase value}}") + expect(template({ value: "Hello World" })).toBe("hello-world") + }) + + it("converts camelCase", () => { + const template = hbs.compile("{{kebabCase value}}") + expect(template({ value: "myAppName" })).toBe("my-app-name") + }) + + it("converts PascalCase", () => { + const template = hbs.compile("{{kebabCase value}}") + expect(template({ value: "MyAppName" })).toBe("my-app-name") + }) + + it("handles underscores", () => { + const template = hbs.compile("{{kebabCase value}}") + expect(template({ value: "my_app_name" })).toBe("my-app-name") + }) + + 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", () => { + const template = hbs.compile("{{snakeCase value}}") + expect(template({ value: "Hello World" })).toBe("hello_world") + }) + + it("converts camelCase", () => { + const template = hbs.compile("{{snakeCase value}}") + expect(template({ value: "myAppName" })).toBe("my_app_name") + }) + + 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", () => { + const template = hbs.compile("{{pascalCase value}}") + expect(template({ value: "hello world" })).toBe("HelloWorld") + }) + + it("converts snake_case", () => { + const template = hbs.compile("{{pascalCase value}}") + expect(template({ value: "my_app_name" })).toBe("MyAppName") + }) + + it("converts kebab-case", () => { + 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", () => { + const template = hbs.compile('{{#if (eq a "hello")}}yes{{else}}no{{/if}}') + expect(template({ a: "hello" })).toBe("yes") + }) + + 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", () => { + 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", () => { + 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", () => { + 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", () => { + 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", () => { + 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", () => { + const template = hbs.compile("{{#if (not a)}}yes{{else}}no{{/if}}") + expect(template({ a: true })).toBe("no") + }) + + it("negates false to true", () => { + 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", () => { + const template = hbs.compile("{{res 16 'w' usesScreenutil}}") + expect(template({ usesScreenutil: true })).toBe("16.w") + }) + + 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", () => { + const template = hbs.compile("{{res 14 'sp' usesScreenutil}}") + expect(template({ usesScreenutil: true })).toBe("14.sp") + }) + + it("returns plain double when ScreenUtil disabled (number input)", () => { + const template = hbs.compile("{{res 16 'w' usesScreenutil}}") + expect(template({ usesScreenutil: false })).toBe("16") + }) + + 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") + }) + }) + + // ── when helper ───────────────────────────────────────────── + + describe("when", () => { + 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", () => { + 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", () => { + 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", () => { + 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", () => { + 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..c0a13d9 --- /dev/null +++ b/tests/unit/matrix-shard-1.spec.ts @@ -0,0 +1,8 @@ +import { describe } from "vitest" +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 }, () => { + 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..3cc9cbe --- /dev/null +++ b/tests/unit/matrix-shard-2.spec.ts @@ -0,0 +1,8 @@ +import { describe } from "vitest" +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 }, () => { + 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..2608372 --- /dev/null +++ b/tests/unit/matrix-shard-3.spec.ts @@ -0,0 +1,8 @@ +import { describe } from "vitest" +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 }, () => { + 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..c932c6f --- /dev/null +++ b/tests/unit/matrix-shard-4.spec.ts @@ -0,0 +1,8 @@ +import { describe } from "vitest" +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 }, () => { + runMatrixTests(PRIMARY_COMBINATIONS.slice(279)) +}) diff --git a/tests/unit/misc-flags.spec.ts b/tests/unit/misc-flags.spec.ts new file mode 100644 index 0000000..c3c9227 --- /dev/null +++ b/tests/unit/misc-flags.spec.ts @@ -0,0 +1,214 @@ +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 { MISC_ALL_ON, MISC_BARE_MINIMUM, MISC_DEFAULT } from "../utils/misc-profiles" + +const base: PrimaryCombo = { + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", + navigation: "go_router", +} + +describe("Misc Flags", () => { + let fullFiles: Map + let fullPubspec: string + let minimalFiles: Map + let minimalPubspec: string + let defaultFiles: Map + let defaultPubspec: string + let noDotenvFiles: Map + let noDotenvPubspec: string + + beforeAll(async () => { + [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 ────────────────────────────────────────────── + describe("usesScreenutil", () => { + it("when enabled: flutter_screenutil in pubspec", () => { + assertDependencyPresent(fullPubspec, "flutter_screenutil") + }) + + it("when disabled: no flutter_screenutil", () => { + assertDependencyAbsent(minimalPubspec, "flutter_screenutil") + }) + + 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) + + expect( + offenders, + `Found ScreenUtil extensions in: ${offenders.join(", ")}` + ).toEqual([]) + }) + }) + + // ── Flutter Hooks ─────────────────────────────────────────── + describe("usesFlutterHooks", () => { + it("when enabled: flutter_hooks in pubspec", () => { + assertDependencyPresent(fullPubspec, "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", () => { + assertDependencyPresent(fullPubspec, "hive_ce") + assertDependencyPresent(fullPubspec, "hive_ce_flutter") + }) + + it("when enabled: hive_ce_generator in dev_dependencies", () => { + assertDependencyPresent(fullPubspec, "hive_ce_generator") + }) + + it("when disabled: no hive packages", () => { + assertDependencyAbsent(minimalPubspec, "hive_ce") + assertDependencyAbsent(minimalPubspec, "hive_ce_flutter") + }) + + 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", () => { + assertDependencyPresent(defaultPubspec, "cached_network_image") + }) + + it("when disabled: no cached_network_image", () => { + assertDependencyAbsent(minimalPubspec, "cached_network_image") + }) + }) + + // ── Skeletonizer ──────────────────────────────────────────── + describe("usesSkeletonizer", () => { + it("when enabled: skeletonizer in pubspec", () => { + assertDependencyPresent(defaultPubspec, "skeletonizer") + }) + + it("when disabled: no skeletonizer", () => { + assertDependencyAbsent(minimalPubspec, "skeletonizer") + }) + }) + + // ── Dio ───────────────────────────────────────────────────── + describe("usesDio", () => { + it("when enabled: dio in pubspec", () => { + assertDependencyPresent(fullPubspec, "dio") + }) + + it("when disabled: no dio", () => { + assertDependencyAbsent(minimalPubspec, "dio") + }) + }) + + // ── Shared Preferences ────────────────────────────────────── + describe("usesSharedPreferences", () => { + it("when enabled: shared_preferences in pubspec", () => { + assertDependencyPresent(defaultPubspec, "shared_preferences") + }) + + it("when disabled: no shared_preferences", () => { + assertDependencyAbsent(minimalPubspec, "shared_preferences") + }) + }) + + // ── Secure Storage ────────────────────────────────────────── + describe("usesSecureStorage", () => { + it("when enabled: flutter_secure_storage in pubspec", () => { + assertDependencyPresent(defaultPubspec, "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", () => { + assertDependencyPresent(defaultPubspec, "flutter_svg") + }) + + it("when disabled: no flutter_svg", () => { + assertDependencyAbsent(minimalPubspec, "flutter_svg") + }) + }) + + // ── Native Splash ─────────────────────────────────────────── + describe("usesFlutterNativeSplash", () => { + it("when enabled: flutter_native_splash in pubspec", () => { + assertDependencyPresent(defaultPubspec, "flutter_native_splash") + }) + + 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", () => { + assertDependencyAbsent(minimalPubspec, "flutter_native_splash") + }) + }) + + // ── Localization ──────────────────────────────────────────── + describe("localization", () => { + it("when enabled: easy_localization in pubspec", () => { + assertDependencyPresent(defaultPubspec, "easy_localization") + }) + + 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", () => { + const translationFiles = [...defaultFiles.keys()].filter((f) => + f.includes("translations/") && f.endsWith(".json") + ) + 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/navigation.spec.ts b/tests/unit/navigation.spec.ts new file mode 100644 index 0000000..8474b84 --- /dev/null +++ b/tests/unit/navigation.spec.ts @@ -0,0 +1,88 @@ +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 { MISC_DEFAULT } from "../utils/misc-profiles" + +const base: Omit = { + architecture: "feature-first", + stateManagement: "riverpod", + backend: "none", +} + +describe("Navigation", () => { + // ── go_router ─────────────────────────────────────────────── + describe("go_router", () => { + 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", () => { + assertDependencyAbsent(pubspec, "auto_route") + assertDependencyAbsent(pubspec, "auto_route_generator") + }) + + it("generates router configuration file", () => { + const routerFile = getFileContent(files, "app_router.dart") + expect(routerFile).toBeDefined() + }) + }) + + // ── auto_route ────────────────────────────────────────────── + describe("auto_route", () => { + 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", () => { + assertDependencyPresent(pubspec, "auto_route_generator") + assertDependencyPresent(pubspec, "build_runner") + }) + + it("does not include go_router", () => { + assertDependencyAbsent(pubspec, "go_router") + }) + }) + + // ── imperative ────────────────────────────────────────────── + describe("imperative (Navigator 1.0)", () => { + 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", () => { + 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..eec93f7 --- /dev/null +++ b/tests/unit/state-management.spec.ts @@ -0,0 +1,123 @@ +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 { MISC_ALL_ON, MISC_DEFAULT } from "../utils/misc-profiles" + +const base: Omit = { + architecture: "feature-first", + backend: "none", + navigation: "go_router", +} + +describe("State Management", () => { + // ── Riverpod ──────────────────────────────────────────────── + describe("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", () => { + assertFileNotContains(files, "state_wrapper.dart", "MultiBlocProvider") + assertFileNotContains(files, "state_wrapper.dart", "MultiProvider") + }) + + it("includes hooks_riverpod when hooks enabled", async () => { + const filesWithHooks = await generateToMap( + buildConfig({ ...base, stateManagement: "riverpod" }, MISC_ALL_ON) + ) + const pubspec = getPubspecContent(filesWithHooks) + expect(pubspec).toContain("hooks_riverpod") + }) + }) + + // ── Provider ──────────────────────────────────────────────── + describe("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", () => { + assertFileNotContains(files, "state_wrapper.dart", "ProviderScope") + assertFileNotContains(files, "state_wrapper.dart", "MultiBlocProvider") + }) + }) + + // ── Bloc ──────────────────────────────────────────────────── + describe("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", () => { + assertFileNotContains(files, "state_wrapper.dart", "ProviderScope") + assertFileNotContains(files, "state_wrapper.dart", "MultiProvider") + }) + }) + + // ── MobX ──────────────────────────────────────────────────── + describe("mobx", () => { + 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", () => { + expect(pubspec).toContain("build_runner:") + expect(pubspec).toContain("mobx_codegen:") + }) + }) + + // ── None (setState) ───────────────────────────────────────── + describe("none (setState)", () => { + let files: Map + let pubspec: string + + 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", () => { + 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/theme.spec.ts b/tests/unit/theme.spec.ts new file mode 100644 index 0000000..7483ad7 --- /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 { buildConfig } from "../utils/config-builder" +import { generateToMap, getPubspecContent } from "../utils/generate" +import { PrimaryCombo } from "../utils/matrix.config" + +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) + }) + }) +}) 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/combinations.ts b/tests/utils/combinations.ts new file mode 100644 index 0000000..0f86c55 --- /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..2b99abd --- /dev/null +++ b/tests/utils/config-builder.ts @@ -0,0 +1,42 @@ +import type { + BackendConfig, + ScaffoldConfig, +} 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" + +/** + * 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 new file mode 100644 index 0000000..09c3a4f --- /dev/null +++ b/tests/utils/critical-combos.ts @@ -0,0 +1,224 @@ +import { MiscConfig } from "../../app/lib/config/schema" +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 + label: string +} + +/** + * The 25 critical combinations for Layer 2 Dart validation. + * Each represents a real developer archetype. + */ +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", + } +] diff --git a/tests/utils/generate.ts b/tests/utils/generate.ts new file mode 100644 index 0000000..4934e3d --- /dev/null +++ b/tests/utils/generate.ts @@ -0,0 +1,106 @@ +/** + * 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 { + // 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) + + 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-tests.ts b/tests/utils/matrix-tests.ts new file mode 100644 index 0000000..e6e4764 --- /dev/null +++ b/tests/utils/matrix-tests.ts @@ -0,0 +1,132 @@ +import { beforeAll, describe, it } from "vitest" +import { + assertArchitectureStructure, + assertDependencyAbsent, + assertDependencyPresent, + assertNoEmptyFiles, + assertNoUnresolvedTokens, + assertRequiredFilesExist, + assertValidPubspec, +} from "./assertions" +import { buildConfig } from "./config-builder" +import { generateToMap, getPubspecContent } from "./generate" +import { COMBO_LABEL, PrimaryCombo } from "./matrix.config" + +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", "provider"], + absent: ["flutter_riverpod", "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 new file mode 100644 index 0000000..b861579 --- /dev/null +++ b/tests/utils/matrix.config.ts @@ -0,0 +1,90 @@ +import type { + ArchitectureStyle, + BackendProvider, + NavigationStyle, + StateManagement, +} from "../../app/lib/config/schema" + +/** + * ARCHITECTURES: array of 5 architecture strings + */ +export const ARCHITECTURES: ArchitectureStyle[] = [ + "mvc", + "mvvm", + "clean", + "feature-first", + "layer-first", +] + +/** + * STATE_OPTIONS: array of 5 state management strings + */ +export const STATE_OPTIONS: StateManagement[] = [ + "provider", + "riverpod", + "bloc", + "mobx", + "none", +] + +/** + * BACKEND_OPTIONS: array of 5 backend strings + */ +export const BACKEND_OPTIONS: BackendProvider[] = [ + "none", + "firebase", + "supabase", + "appwrite", + "custom", +] + +/** + * NAVIGATION_OPTIONS: array of 3 navigation strings + */ +export const NAVIGATION_OPTIONS: NavigationStyle[] = [ + "imperative", + "go_router", + "auto_route", +] + +export interface PrimaryCombo { + architecture: ArchitectureStyle + stateManagement: StateManagement + backend: BackendProvider + navigation: NavigationStyle +} + +/** + * Generates the full 375 combo array at module load time + */ +function generatePrimaryCombos(): PrimaryCombo[] { + const combos: PrimaryCombo[] = [] + for (const architecture of ARCHITECTURES) { + 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 combos +} + +export const PRIMARY_COMBINATIONS = generatePrimaryCombos() + +/** + * 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}` +} + +// 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 51b9362..56cbcb5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,15 @@ 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: 60_000, + hookTimeout: 30_000, + reporters: ["default", "./tests/reporters/failed-tests-reporter.ts"], + // Vitest 4: pool options are top-level + isolate: false, + fileParallelism: true, + maxWorkers: 4, }, 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, "."), + }, + }, +})