From 6fb93d554b29611e5fe75904726c24bc5ce28713 Mon Sep 17 00:00:00 2001 From: CarboxyDev Date: Wed, 11 Feb 2026 23:59:01 +0530 Subject: [PATCH] Add scaffold smoke matrix test and update template output --- .github/workflows/ci.yml | 25 +- create-blitzpack/README.md | 14 + create-blitzpack/package.json | 2 +- .../scripts/scaffold-matrix-smoke.ts | 354 ++++++++++++++++++ create-blitzpack/src/template.ts | 33 ++ create-blitzpack/src/transform.ts | 4 +- package.json | 3 + 7 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 create-blitzpack/scripts/scaffold-matrix-smoke.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d64b162..afea46e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,10 +55,31 @@ jobs: env: DATABASE_URL: postgresql://user:pass@localhost:5432/db + scaffold-smoke: + runs-on: ubuntu-latest + needs: [lint, typecheck] + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run scaffold smoke matrix + run: pnpm run smoke:create-blitzpack:ci + # Tests and build run after lint/typecheck pass test: runs-on: ubuntu-latest - needs: [lint, typecheck] + needs: [lint, typecheck, scaffold-smoke] # PostgreSQL service for integration tests services: @@ -111,7 +132,7 @@ jobs: build: runs-on: ubuntu-latest - needs: [lint, typecheck] + needs: [lint, typecheck, scaffold-smoke] steps: - uses: actions/checkout@v4 diff --git a/create-blitzpack/README.md b/create-blitzpack/README.md index 5bd00b5..fc64c25 100644 --- a/create-blitzpack/README.md +++ b/create-blitzpack/README.md @@ -58,6 +58,20 @@ pnpm dev # Start development Full documentation: [github.com/CarboxyDev/blitzpack](https://github.com/CarboxyDev/blitzpack) +## Scaffold Smoke Checks (Repository Maintenance) + +Run from repository root: + +```bash +pnpm smoke:create-blitzpack +``` + +For a wider matrix: + +```bash +pnpm smoke:create-blitzpack:full +``` + ## License MIT diff --git a/create-blitzpack/package.json b/create-blitzpack/package.json index bd22c1e..2ddbb2d 100644 --- a/create-blitzpack/package.json +++ b/create-blitzpack/package.json @@ -1,6 +1,6 @@ { "name": "create-blitzpack", - "version": "0.1.20", + "version": "0.1.21", "description": "Create a new Blitzpack project - full-stack TypeScript monorepo with Next.js and Fastify", "type": "module", "bin": { diff --git a/create-blitzpack/scripts/scaffold-matrix-smoke.ts b/create-blitzpack/scripts/scaffold-matrix-smoke.ts new file mode 100644 index 0000000..4bdf9d6 --- /dev/null +++ b/create-blitzpack/scripts/scaffold-matrix-smoke.ts @@ -0,0 +1,354 @@ +import { spawnSync } from 'child_process'; +import { createHash } from 'crypto'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { + FEATURE_EXCLUSIONS, + type FeatureKey, + type FeatureOptions, + PROJECT_PROFILES, +} from '../src/constants.js'; +import { prepareTemplateFromLocalSource } from '../src/template.js'; +import { transformFiles } from '../src/transform.js'; + +interface MatrixCase { + id: string; + features: FeatureOptions; + verifyTypecheck: boolean; +} + +const ENV_FILES = [ + { from: 'apps/web/.env.example', to: 'apps/web/.env.local' }, + { from: 'apps/api/.env.example', to: 'apps/api/.env.local' }, +]; + +const ALWAYS_EXCLUDED_PATHS = [ + 'create-blitzpack', + 'apps/marketing', + 'CONTRIBUTING.md', + 'docs/create-blitzpack-scaffolding-maintenance-plan.md', +]; + +const FEATURE_SENTINEL_PATHS: Record = { + testing: 'vitest.workspace.ts', + admin: 'apps/web/src/app/(admin)', + uploads: 'apps/api/src/routes/uploads.ts', + dockerDeploy: 'deploy/docker/docker-compose.prod.yml', + ciCd: '.github/workflows/cd.yml', +}; + +function profileFeatures( + key: 'recommended' | 'platformAgnostic' +): FeatureOptions { + const profile = PROJECT_PROFILES.find((item) => item.key === key); + if (!profile) { + throw new Error(`Unknown profile: ${key}`); + } + return profile.defaultFeatures; +} + +const QUICK_MATRIX: MatrixCase[] = [ + { + id: 'recommended', + features: profileFeatures('recommended'), + verifyTypecheck: true, + }, + { + id: 'platform-agnostic', + features: profileFeatures('platformAgnostic'), + verifyTypecheck: false, + }, + { + id: 'modular-minimal', + features: { + testing: false, + admin: false, + uploads: false, + dockerDeploy: false, + ciCd: false, + }, + verifyTypecheck: true, + }, +]; + +const FULL_MATRIX: MatrixCase[] = [ + ...QUICK_MATRIX, + { + id: 'modular-admin-uploads', + features: { + testing: true, + admin: true, + uploads: true, + dockerDeploy: false, + ciCd: false, + }, + verifyTypecheck: false, + }, + { + id: 'modular-deploy-ci', + features: { + testing: false, + admin: false, + uploads: false, + dockerDeploy: true, + ciCd: true, + }, + verifyTypecheck: false, + }, +]; + +function isWindows(): boolean { + return process.platform === 'win32'; +} + +function runCommand(command: string, args: string[], cwd: string): void { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf-8', + stdio: 'pipe', + env: { + ...process.env, + CI: '1', + }, + }); + + if (result.status === 0) { + return; + } + + const renderedCommand = `${command} ${args.join(' ')}`.trim(); + const output = [result.stdout, result.stderr].filter(Boolean).join('\n'); + throw new Error( + `Command failed (${renderedCommand}) in ${cwd}\n${output || 'No output'}` + ); +} + +async function copyEnvFiles(targetDir: string): Promise { + for (const { from, to } of ENV_FILES) { + const source = path.join(targetDir, from); + const destination = path.join(targetDir, to); + if (await fs.pathExists(source)) { + await fs.copy(source, destination); + } + } +} + +function assertCondition(condition: unknown, message: string): void { + if (!condition) { + throw new Error(message); + } +} + +async function fileSha256(filePath: string): Promise { + const data = await fs.readFile(filePath); + return createHash('sha256').update(data).digest('hex'); +} + +async function validateStructure( + targetDir: string, + matrixCase: MatrixCase +): Promise { + for (const relativePath of ALWAYS_EXCLUDED_PATHS) { + assertCondition( + !(await fs.pathExists(path.join(targetDir, relativePath))), + `Unexpected path present: ${relativePath}` + ); + } + + const lockPath = path.join(targetDir, 'pnpm-lock.yaml'); + assertCondition( + !(await fs.pathExists(lockPath)), + 'Scaffold should not copy source pnpm-lock.yaml before install' + ); + + const readmePath = path.join(targetDir, 'README.md'); + const readme = await fs.readFile(readmePath, 'utf-8'); + assertCondition( + !readme.includes('pnpm init:project'), + 'Generated README still references removed init:project command' + ); + + const packageJsonPath = path.join(targetDir, 'package.json'); + const rootPkg = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) as { + scripts?: Record; + }; + assertCondition( + !rootPkg.scripts || !('init:project' in rootPkg.scripts), + 'Generated package.json contains removed init:project script' + ); + assertCondition( + !rootPkg.scripts || !('smoke:create-blitzpack' in rootPkg.scripts), + 'Generated package.json leaked internal smoke:create-blitzpack script' + ); + assertCondition( + !rootPkg.scripts || !('smoke:create-blitzpack:ci' in rootPkg.scripts), + 'Generated package.json leaked internal smoke:create-blitzpack:ci script' + ); + assertCondition( + !rootPkg.scripts || !('smoke:create-blitzpack:full' in rootPkg.scripts), + 'Generated package.json leaked internal smoke:create-blitzpack:full script' + ); + + const featureEntries = Object.entries(matrixCase.features) as [ + FeatureKey, + boolean, + ][]; + for (const [featureKey, enabled] of featureEntries) { + const sentinelPath = FEATURE_SENTINEL_PATHS[featureKey]; + const sentinelExists = await fs.pathExists( + path.join(targetDir, sentinelPath) + ); + + if (enabled) { + assertCondition( + sentinelExists, + `Expected feature sentinel missing for ${featureKey}: ${sentinelPath}` + ); + continue; + } + + assertCondition( + !sentinelExists, + `Disabled feature sentinel still present for ${featureKey}: ${sentinelPath}` + ); + + for (const excludedPath of FEATURE_EXCLUSIONS[featureKey]) { + assertCondition( + !(await fs.pathExists(path.join(targetDir, excludedPath))), + `Disabled feature path still present for ${featureKey}: ${excludedPath}` + ); + } + } +} + +async function validateInstallStability(targetDir: string): Promise { + const pnpmCommand = isWindows() ? 'pnpm.cmd' : 'pnpm'; + runCommand(pnpmCommand, ['install', '--no-frozen-lockfile'], targetDir); + + const lockPath = path.join(targetDir, 'pnpm-lock.yaml'); + assertCondition( + await fs.pathExists(lockPath), + 'Install did not generate pnpm-lock.yaml' + ); + const firstHash = await fileSha256(lockPath); + + runCommand(pnpmCommand, ['install', '--no-frozen-lockfile'], targetDir); + const secondHash = await fileSha256(lockPath); + + assertCondition( + firstHash === secondHash, + 'pnpm-lock.yaml changed between consecutive installs' + ); +} + +async function runTypecheck(targetDir: string): Promise { + const pnpmCommand = isWindows() ? 'pnpm.cmd' : 'pnpm'; + runCommand(pnpmCommand, ['typecheck'], targetDir); +} + +function parseArgs(argv: string[]): { + full: boolean; + keepTemp: boolean; + skipInstall: boolean; + skipTypecheck: boolean; +} { + return { + full: argv.includes('--full'), + keepTemp: argv.includes('--keep-temp'), + skipInstall: argv.includes('--skip-install'), + skipTypecheck: argv.includes('--skip-typecheck'), + }; +} + +async function runMatrixCase( + repoRoot: string, + workspaceDir: string, + matrixCase: MatrixCase, + options: { skipInstall: boolean; skipTypecheck: boolean } +): Promise { + const targetDir = path.join(workspaceDir, matrixCase.id); + const projectName = `smoke-${matrixCase.id}`; + + await prepareTemplateFromLocalSource( + repoRoot, + targetDir, + matrixCase.features + ); + await transformFiles( + targetDir, + { + projectName, + projectSlug: projectName, + projectDescription: `Smoke test scaffold for ${matrixCase.id}`, + }, + matrixCase.features + ); + await copyEnvFiles(targetDir); + await validateStructure(targetDir, matrixCase); + + if (!options.skipInstall) { + await validateInstallStability(targetDir); + } + + if (!options.skipTypecheck && matrixCase.verifyTypecheck) { + await runTypecheck(targetDir); + } +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const matrix = args.full ? FULL_MATRIX : QUICK_MATRIX; + + const scriptPath = fileURLToPath(import.meta.url); + const scriptDir = path.dirname(scriptPath); + const repoRoot = path.resolve(scriptDir, '..', '..'); + const workspaceDir = path.join( + os.tmpdir(), + `blitzpack-scaffold-smoke-${Date.now()}` + ); + + await fs.ensureDir(workspaceDir); + + const failures: string[] = []; + console.log(`Running scaffold smoke matrix (${matrix.length} cases)`); + console.log(`Workspace: ${workspaceDir}`); + + for (const matrixCase of matrix) { + const start = Date.now(); + process.stdout.write(`\n[${matrixCase.id}] start\n`); + try { + await runMatrixCase(repoRoot, workspaceDir, matrixCase, { + skipInstall: args.skipInstall, + skipTypecheck: args.skipTypecheck, + }); + const duration = Date.now() - start; + process.stdout.write(`[${matrixCase.id}] pass (${duration}ms)\n`); + } catch (error) { + const duration = Date.now() - start; + const reason = error instanceof Error ? error.message : String(error); + failures.push(`${matrixCase.id}: ${reason}`); + process.stdout.write(`[${matrixCase.id}] fail (${duration}ms)\n`); + } + } + + if (!args.keepTemp) { + await fs.remove(workspaceDir); + } else { + console.log(`\nKept workspace: ${workspaceDir}`); + } + + if (failures.length > 0) { + console.error('\nScaffold matrix smoke checks failed:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + + console.log('\nScaffold matrix smoke checks passed'); +} + +await main(); diff --git a/create-blitzpack/src/template.ts b/create-blitzpack/src/template.ts index bddc735..c6cb95e 100644 --- a/create-blitzpack/src/template.ts +++ b/create-blitzpack/src/template.ts @@ -18,6 +18,13 @@ const POST_DOWNLOAD_EXCLUDES = [ 'docs/create-blitzpack-scaffolding-maintenance-plan.md', 'pnpm-lock.yaml', ]; +const LOCAL_COPY_EXCLUDES = new Set([ + '.git', + 'node_modules', + '.pnpm-store', + '.turbo', + '.temp', +]); function getFeatureExclusions(features: FeatureOptions): string[] { const exclusions: string[] = []; @@ -42,6 +49,19 @@ async function cleanupExcludes( } } +function shouldCopyFromLocalSource( + sourceDir: string, + currentPath: string +): boolean { + const relativePath = path.relative(sourceDir, currentPath); + if (!relativePath || relativePath === '.') { + return true; + } + + const segments = relativePath.split(path.sep); + return !segments.some((segment) => LOCAL_COPY_EXCLUDES.has(segment)); +} + export async function downloadAndPrepareTemplate( targetDir: string, spinner: Ora, @@ -64,6 +84,19 @@ export async function downloadAndPrepareTemplate( spinner.succeed(`Downloaded template (${files} files)`); } +export async function prepareTemplateFromLocalSource( + sourceDir: string, + targetDir: string, + features: FeatureOptions +): Promise { + await fs.copy(sourceDir, targetDir, { + filter: (sourcePath) => shouldCopyFromLocalSource(sourceDir, sourcePath), + }); + + const featureExclusions = getFeatureExclusions(features); + await cleanupExcludes(targetDir, featureExclusions); +} + async function countFiles(dir: string): Promise { try { let count = 0; diff --git a/create-blitzpack/src/transform.ts b/create-blitzpack/src/transform.ts index 7fc0c77..9652c7f 100644 --- a/create-blitzpack/src/transform.ts +++ b/create-blitzpack/src/transform.ts @@ -107,8 +107,10 @@ function transformPackageJson( pkg.description = vars.projectDescription; delete pkg.repository; delete pkg.homepage; - delete pkg.scripts?.['init:project']; pkg.version = '0.1.0'; + delete pkg.scripts?.['smoke:create-blitzpack']; + delete pkg.scripts?.['smoke:create-blitzpack:ci']; + delete pkg.scripts?.['smoke:create-blitzpack:full']; if (!features.testing) { for (const script of TESTING_SCRIPTS) { diff --git a/package.json b/package.json index cda4271..5b5267a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "email": "turbo run email:dev", "build": "turbo run build", "typecheck": "turbo run typecheck", + "smoke:create-blitzpack": "pnpm tsx create-blitzpack/scripts/scaffold-matrix-smoke.ts", + "smoke:create-blitzpack:ci": "pnpm tsx create-blitzpack/scripts/scaffold-matrix-smoke.ts --skip-typecheck", + "smoke:create-blitzpack:full": "pnpm tsx create-blitzpack/scripts/scaffold-matrix-smoke.ts --full", "lint": "turbo run lint", "lint:fix": "turbo run lint:fix", "test": "turbo run test",