Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/test-tier1.yml
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions .github/workflows/test-tier2.yml
Original file line number Diff line number Diff line change
@@ -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
148 changes: 148 additions & 0 deletions .github/workflows/test-tier3.yml
Original file line number Diff line number Diff line change
@@ -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<CHUNKS; i++)); do
if [ $i -gt 0 ]; then MATRIX+=","; fi
MATRIX+="$i"
done
MATRIX+="]"

echo "total=$TOTAL" >> $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."
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ next-env.d.ts
# Generator Output
/dev_out/
scripts/dev_config.json

# Test Results
tests/results/
.temp/

1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

---
Expand Down
22 changes: 19 additions & 3 deletions app/api/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
8 changes: 4 additions & 4 deletions app/components/landing/StatsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ type StatsResponse = {
async function getStats(): Promise<StatsResponse | null> {
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
Expand Down
22 changes: 18 additions & 4 deletions app/components/wizard/WizardShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null)

Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion app/components/wizard/steps/BackendStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function BackendStep() {
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center justify-between w-full pr-6">
<div className="flex flex-col py-0.5">
<span className="font-semibold">{option.label}</span>
<span className="font-medium">{option.label}</span>
{backend.provider !== option.value && (
<span className="text-[10px] text-muted-foreground font-normal line-clamp-1">{option.description}</span>
)}
Expand Down
Loading
Loading