From bce185094612ba57c5bbdc39ebddb57717e5a49d Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 20 Feb 2026 16:04:29 +0200 Subject: [PATCH] refactor(export): consolidate export testing into export-app cli Remove the unmaintained test-exported-app-local.sh script and add a new --env packed mode to the export-app CLI. The packed mode builds and packs monorepo adapter packages into tarballs, then exports an app, swaps deps with file: tarball paths, and runs pnpm install + build to verify types and bundling behave correctly post-publish. This catches .d.ts resolution issues that file: workspace links miss. --- apps/builder/src/export/cli/export-app.cjs | 177 ++++++++++++- package.json | 2 +- scripts/test-exported-app-local.sh | 284 --------------------- 3 files changed, 176 insertions(+), 287 deletions(-) delete mode 100644 scripts/test-exported-app-local.sh diff --git a/apps/builder/src/export/cli/export-app.cjs b/apps/builder/src/export/cli/export-app.cjs index ddd5fbfb7..e6e79403e 100644 --- a/apps/builder/src/export/cli/export-app.cjs +++ b/apps/builder/src/export/cli/export-app.cjs @@ -81,10 +81,14 @@ ${colors.bold}Options:${colors.reset} --template, -t [name] Template to use (default: typescript-react-vite) --complex, -x Use complex app with multiple fields --verbose, -v Enable verbose output - --env, -e [env] Target environment: 'local' or 'production' (default: local) + --env, -e [env] Target environment: 'local', 'packed', or 'production' (default: local) + local - file: links to workspace packages (fast, for dev) + packed - pnpm pack tarballs (simulates real npm install, catches .d.ts issues) + production - uses published npm versions ${colors.bold}Examples:${colors.reset} export-app export export-app export -c solana -f stake -o stake-app + export-app export --env packed -c polkadot -o polkadot-test export-app export --env production -o prod-app export-app build ./exports/transfer-app export-app serve ./exports/transfer-app @@ -160,6 +164,141 @@ function execInDir(command, dir, stdio = 'inherit') { } } +/** + * Builds and packs all publishable monorepo packages into tarballs. + * Returns { packDir, packedMap } where packedMap maps package names to tarball paths. + */ +function packMonorepoPackages() { + const packDir = path.join(monorepoRoot, '.packed-packages'); + + fs.rmSync(packDir, { recursive: true, force: true }); + fs.mkdirSync(packDir, { recursive: true }); + + console.log(`\n${colors.blue}Building monorepo packages...${colors.reset}`); + execInDir('pnpm build', monorepoRoot); + console.log(`${colors.green}✓ Build complete${colors.reset}\n`); + + const packagesDir = path.join(monorepoRoot, 'packages'); + const packageDirs = fs + .readdirSync(packagesDir) + .map((dir) => path.join(packagesDir, dir)) + .filter((dir) => { + return ( + fs.statSync(dir).isDirectory() && + fs.existsSync(path.join(dir, 'package.json')) && + fs.existsSync(path.join(dir, 'dist')) + ); + }); + + const packedMap = {}; + + console.log(`${colors.blue}Packing ${packageDirs.length} packages...${colors.reset}`); + for (const pkgDir of packageDirs) { + const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); + const pkgName = pkgJson.name; + + try { + const output = execInDir(`pnpm pack --pack-destination "${packDir}"`, pkgDir, 'pipe'); + const tarball = output.toString().trim().split('\n').pop(); + const tarballPath = path.join(packDir, path.basename(tarball)); + + if (fs.existsSync(tarballPath)) { + packedMap[pkgName] = tarballPath; + console.log( + ` ${colors.green}✓${colors.reset} ${pkgName} → ${path.basename(tarballPath)}` + ); + } + } catch (error) { + console.log(` ${colors.yellow}⚠${colors.reset} Skipping ${pkgName} (pack failed)`); + } + } + + console.log( + `\n${colors.green}✓ Packed ${Object.keys(packedMap).length} packages${colors.reset}` + ); + return { packDir, packedMap }; +} + +/** + * Configures an extracted export app to use locally packed tarballs + * instead of published npm versions. Also handles Midnight SDK patches. + */ +function configureForPackedMode(extractDir, packedMap) { + const packageJsonPath = path.join(extractDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + + console.log(`\n${colors.blue}Replacing dependencies with packed tarballs...${colors.reset}`); + for (const depType of ['dependencies', 'devDependencies']) { + if (!packageJson[depType]) continue; + for (const dep of Object.keys(packageJson[depType])) { + if (packedMap[dep]) { + console.log( + ` ${colors.green}✓${colors.reset} ${dep}: ${packageJson[depType][dep]} → file:${packedMap[dep]}` + ); + packageJson[depType][dep] = `file:${packedMap[dep]}`; + } + } + } + + if (!packageJson.pnpm) packageJson.pnpm = {}; + if (!packageJson.pnpm.overrides) packageJson.pnpm.overrides = {}; + + for (const [pkgName, tarballPath] of Object.entries(packedMap)) { + packageJson.pnpm.overrides[pkgName] = `file:${tarballPath}`; + } + + const allDeps = { + ...(packageJson.dependencies || {}), + ...(packageJson.devDependencies || {}), + }; + const hasMidnight = Object.keys(allDeps).some((k) => k.startsWith('@midnight-ntwrk/')); + + if (hasMidnight) { + const adapterPatchesDir = path.join(monorepoRoot, 'packages/adapter-midnight/patches'); + if (fs.existsSync(adapterPatchesDir)) { + console.log(`\n${colors.blue}Configuring Midnight SDK patches...${colors.reset}`); + const targetPatchesDir = path.join(extractDir, 'patches'); + fs.mkdirSync(targetPatchesDir, { recursive: true }); + + const patchFiles = fs.readdirSync(adapterPatchesDir).filter((f) => f.endsWith('.patch')); + for (const patchFile of patchFiles) { + fs.copyFileSync( + path.join(adapterPatchesDir, patchFile), + path.join(targetPatchesDir, patchFile) + ); + } + + if (!packageJson.pnpm.patchedDependencies) packageJson.pnpm.patchedDependencies = {}; + + for (const patchFile of patchFiles) { + // Format: @scope__package@version.patch → @scope/package@version + const match = patchFile.match(/^(@[^_]+)__(.+)@(.+)\.patch$/); + if (match) { + const patchKey = `${match[1]}/${match[2]}@${match[3]}`; + const pkgNameFromPatch = `${match[1]}/${match[2]}`; + + if (allDeps[pkgNameFromPatch]) { + packageJson.pnpm.patchedDependencies[patchKey] = `patches/${patchFile}`; + packageJson.pnpm.overrides[pkgNameFromPatch] = match[3]; + console.log(` ${colors.green}✓${colors.reset} Patching ${patchKey}`); + } + } + } + } + } else { + debug('No Midnight packages detected, skipping patches'); + } + + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + + const lockfilePath = path.join(extractDir, 'pnpm-lock.yaml'); + if (fs.existsSync(lockfilePath)) { + fs.unlinkSync(lockfilePath); + } + + console.log(`${colors.green}✓ package.json configured with packed tarballs${colors.reset}`); +} + function exportAppSimple(options) { try { console.log(`\n${colors.bold}${colors.cyan}Exporting UI Builder App${colors.reset}\n`); @@ -180,6 +319,15 @@ function exportAppSimple(options) { console.log(` Output Directory: ${colors.blue}${outputDir}${colors.reset}`); console.log(` Environment: ${colors.blue}${options.env}${colors.reset}\n`); + let packedResult = null; + if (options.env === 'packed') { + packedResult = packMonorepoPackages(); + } + + // For packed mode, tell the export system to use production versions + // (they'll be overridden with tarball paths after extraction) + const exportEnv = options.env === 'packed' ? 'production' : options.env; + const env = { EXPORT_TEST_CHAIN: options.chain, EXPORT_TEST_FUNCTION: options.func, @@ -188,7 +336,7 @@ function exportAppSimple(options) { EXPORT_TEST_COMPLEX: options.complex.toString(), EXPORT_TEST_OUTPUT_DIR: outputDir, EXPORT_CLI_MODE: 'true', - EXPORT_CLI_ENV: options.env, + EXPORT_CLI_ENV: exportEnv, ...process.env, }; @@ -277,6 +425,31 @@ function exportAppSimple(options) { } } + if (options.env === 'packed' && packedResult) { + configureForPackedMode(extractDir, packedResult.packedMap); + + const tempDir = path.join(os.tmpdir(), `ui-builder-packed-test-${Date.now()}`); + console.log( + `\n${colors.blue}Moving project to isolated test directory...${colors.reset}` + ); + fs.cpSync(extractDir, tempDir, { recursive: true }); + console.log(`${colors.green}✓ Project copied to:${colors.reset} ${tempDir}`); + + console.log(`\n${colors.blue}Installing dependencies...${colors.reset}`); + execInDir('pnpm install --no-frozen-lockfile', tempDir); + console.log(`${colors.green}✓ Dependencies installed${colors.reset}`); + + console.log(`\n${colors.blue}Building exported app (verifying types and bundling)...${colors.reset}`); + execInDir('pnpm build', tempDir); + console.log(`\n${colors.green}${colors.bold}✓ Packed build verification passed!${colors.reset}`); + console.log(` ${colors.dim}Types, bundling, and Tailwind all resolved correctly.${colors.reset}`); + console.log(`\n${colors.cyan}Test directory:${colors.reset} ${tempDir}`); + console.log(`\n${colors.cyan}To clean up:${colors.reset}`); + console.log(` rm -rf ${tempDir}`); + console.log(` rm -rf ${packedResult.packDir}`); + return extractDir; + } + if (options.env === 'local') { const tempDir = path.join(os.homedir(), 'ui-builder-app-test'); console.log( diff --git a/package.json b/package.json index 3a8d75fe4..37f2ecf18 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build": "pnpm clean && pnpm sync-patches && pnpm validate:vite-configs && NODE_OPTIONS='--max-old-space-size=8192' pnpm -r build", "test": "pnpm -r test", "test:watch": "pnpm -r test:watch", - "test:export": "bash scripts/test-exported-app-local.sh", + "test:export": "node apps/builder/src/export/cli/export-app.cjs export --env packed", "coverage": "pnpm -r coverage", "preview": "pnpm --filter=@openzeppelin/ui-builder-app preview", "lint": "pnpm -r lint", diff --git a/scripts/test-exported-app-local.sh b/scripts/test-exported-app-local.sh deleted file mode 100644 index d3d396721..000000000 --- a/scripts/test-exported-app-local.sh +++ /dev/null @@ -1,284 +0,0 @@ -#!/bin/bash -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo -e "${BLUE}=== Testing Exported App with Local Packages ===${NC}" -echo "" - -# Check if exported app path is provided -if [ -z "$1" ]; then - echo -e "${RED}Error: Please provide the exported app directory${NC}" - echo "Usage: ./scripts/test-exported-app-local.sh " - echo "Example: ./scripts/test-exported-app-local.sh exports/increment-form" - exit 1 -fi - -EXPORTED_APP="$1" -TEMP_TEST_DIR="/tmp/ui-builder-test-$(date +%s)" - -# Resolve absolute path to exported app -if [[ "$EXPORTED_APP" = /* ]]; then - EXPORTED_APP_ABS="$EXPORTED_APP" -else - EXPORTED_APP_ABS="$(pwd)/$EXPORTED_APP" -fi - -if [ ! -d "$EXPORTED_APP_ABS" ]; then - echo -e "${RED}Error: Exported app directory not found: $EXPORTED_APP_ABS${NC}" - exit 1 -fi - -echo -e "${YELLOW}Step 1: Building packages...${NC}" -echo -e " ${BLUE}Running 'pnpm build' (this may take a minute)...${NC}" -pnpm build 2>&1 | tee /tmp/build-output.log | grep -E "(built|Building|✓|error|Error)" || true -if [ ${PIPESTATUS[0]} -ne 0 ]; then - echo -e "${RED}Build failed! Check /tmp/build-output.log for details${NC}" - exit 1 -fi -echo -e " ${GREEN}✓${NC} Build complete" - -echo "" -echo -e "${YELLOW}Step 2: Packing packages locally...${NC}" - -# Create a temporary directory for packed packages -PACK_DIR="$(pwd)/.packed-packages" -SCRIPT_DIR="$(pwd)" # Save for later when we're in TEMP_TEST_DIR -rm -rf "$PACK_DIR" -mkdir -p "$PACK_DIR" - -# Pack all internal packages that the exported app depends on -PACKAGES=( - "packages/types" - "packages/utils" - "packages/ui" - "packages/renderer" - "packages/react-core" - "packages/adapter-midnight" - "packages/adapter-stellar" - "packages/adapter-evm" - "packages/adapter-solana" -) - -for pkg in "${PACKAGES[@]}"; do - if [ -d "$pkg" ]; then - echo -e " 📦 Packing $pkg..." - cd "$pkg" - PACK_FILE=$(pnpm pack --pack-destination "$PACK_DIR" 2>&1 | grep -o '[^ ]*\.tgz' | tail -1) - cd - > /dev/null - echo -e " ${GREEN}✓${NC} Created $(basename "$PACK_FILE")" - fi -done - -echo "" -echo -e "${YELLOW}Step 3: Creating test environment...${NC}" - -# Copy exported app to temp directory -mkdir -p "$TEMP_TEST_DIR" -cp -r "$EXPORTED_APP_ABS"/* "$TEMP_TEST_DIR/" -echo -e " ${GREEN}✓${NC} Copied app to $TEMP_TEST_DIR" - -# Update package.json to use local packed versions -cd "$TEMP_TEST_DIR" - -echo -e "\n${YELLOW}Step 4: Installing dependencies with local packages...${NC}" - -# Replace workspace:* and published versions with file: paths to packed tarballs -node -e " -const fs = require('fs'); -const path = require('path'); -const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); -const packDir = '$PACK_DIR'; - -// Map of package names to their packed tarball files -const packedFiles = fs.readdirSync(packDir) - .filter(f => f.endsWith('.tgz')) - .reduce((acc, file) => { - // Extract package name from tarball name - // Format: openzeppelin-ui-builder--.tgz - const match = file.match(/openzeppelin-ui-builder-(.*?)-\\d+/); - if (match) { - const pkgName = '@openzeppelin/ui-builder-' + match[1].replace(/-/g, '-'); - acc[pkgName] = path.join(packDir, file); - console.log(' Mapped:', pkgName, '->', file); - } - return acc; - }, {}); - -console.log('\\nAvailable packed files:', Object.keys(packedFiles).join(', ')); - -// Update dependencies - replace ALL @openzeppelin/ui-builder packages -['dependencies', 'devDependencies'].forEach(depType => { - if (pkg[depType]) { - Object.keys(pkg[depType]).forEach(dep => { - if (dep.startsWith('@openzeppelin/ui-builder-')) { - if (packedFiles[dep]) { - console.log(' ✓ Replacing', dep, ':', pkg[depType][dep], '->', 'file:' + packedFiles[dep]); - pkg[depType][dep] = 'file:' + packedFiles[dep]; - } else { - console.log(' ⚠ Warning: No packed file found for', dep); - } - } - }); - } -}); - -// Add pnpm overrides to force local packages for ALL resolutions (including peer deps) -if (!pkg.pnpm) { - pkg.pnpm = {}; -} -if (!pkg.pnpm.overrides) { - pkg.pnpm.overrides = {}; -} - -Object.keys(packedFiles).forEach(pkgName => { - pkg.pnpm.overrides[pkgName] = 'file:' + packedFiles[pkgName]; - console.log(' ✓ Added pnpm override:', pkgName, '->', 'file:' + packedFiles[pkgName]); -}); - -fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); -console.log('\\n✓ Updated package.json with overrides'); -" - -echo -e "\n ${GREEN}✓${NC} Updated package.json with local package paths" - -echo -e "\n${YELLOW}Step 5: Copying Midnight SDK patches...${NC}" - -# Check if the exported app uses any Midnight packages -if node -e "const pkg = require('./package.json'); const deps = {...(pkg.dependencies || {}), ...(pkg.devDependencies || {})}; const hasMidnight = Object.keys(deps).some(k => k.startsWith('@midnight-ntwrk/')); process.exit(hasMidnight ? 0 : 1);" 2>/dev/null; then - # Copy patches from the adapter to the test directory - ADAPTER_DIR="$SCRIPT_DIR/packages/adapter-midnight" - if [ -d "$ADAPTER_DIR/patches" ]; then - mkdir -p patches - cp -r "$ADAPTER_DIR/patches"/* patches/ 2>/dev/null || true - echo -e " ${GREEN}✓${NC} Copied $(ls -1 patches/ | wc -l | tr -d ' ') patch files" - - # Add patchedDependencies to package.json (only for packages that exist in dependencies) - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - - // Get all dependencies - const allDeps = {...(pkg.dependencies || {}), ...(pkg.devDependencies || {})}; - - // All available patches from the adapter - const availablePatches = { - '@midnight-ntwrk/compact-runtime@0.9.0': 'patches/@midnight-ntwrk__compact-runtime@0.9.0.patch', - '@midnight-ntwrk/midnight-js-indexer-public-data-provider@2.0.2': 'patches/@midnight-ntwrk__midnight-js-indexer-public-data-provider@2.0.2.patch', - '@midnight-ntwrk/midnight-js-network-id@2.0.2': 'patches/@midnight-ntwrk__midnight-js-network-id@2.0.2.patch', - '@midnight-ntwrk/midnight-js-types@2.0.2': 'patches/@midnight-ntwrk__midnight-js-types@2.0.2.patch', - '@midnight-ntwrk/midnight-js-utils@2.0.2': 'patches/@midnight-ntwrk__midnight-js-utils@2.0.2.patch', - '@midnight-ntwrk/midnight-js-contracts@2.0.2': 'patches/@midnight-ntwrk__midnight-js-contracts@2.0.2.patch', - '@midnight-ntwrk/midnight-js-http-client-proof-provider@2.0.2': 'patches/@midnight-ntwrk__midnight-js-http-client-proof-provider@2.0.2.patch' - }; - - // Add pnpm.patchedDependencies and pnpm.overrides only for packages that are actually in dependencies - if (!pkg.pnpm) { - pkg.pnpm = {}; - } - if (!pkg.pnpm.overrides) { - pkg.pnpm.overrides = {}; - } - - pkg.pnpm.patchedDependencies = {}; - - for (const [pkgWithVersion, patchPath] of Object.entries(availablePatches)) { - // Extract package name without version (e.g., '@midnight-ntwrk/compact-runtime') - const pkgName = pkgWithVersion.split('@').slice(0, -1).join('@'); - const version = pkgWithVersion.split('@').pop(); - - // Check if this package is in dependencies - if (allDeps[pkgName]) { - pkg.pnpm.patchedDependencies[pkgWithVersion] = patchPath; - // Force the exact version that has a patch - pkg.pnpm.overrides[pkgName] = version; - } - } - - fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); - const patchCount = Object.keys(pkg.pnpm.patchedDependencies).length; - console.log(' ✓ Added ' + patchCount + ' patchedDependencies to package.json'); - console.log(' ✓ Added ' + patchCount + ' version overrides to ensure patches match'); - " - else - echo -e " ${YELLOW}⚠${NC} No patches directory found in adapter" - fi -else - echo -e " ${BLUE}ℹ${NC} Exported app does not use Midnight adapter, skipping patches" -fi - -# Remove lock file to ensure fresh install -rm -f pnpm-lock.yaml -echo -e " ${GREEN}✓${NC} Removed pnpm-lock.yaml for fresh install" - -echo -e "\n${YELLOW}Step 6: Configuring Tailwind for local testing...${NC}" - -# Update Tailwind import to use source() directive pointing to monorepo root -# This is needed because Tailwind needs to scan source files from unpublished packages -# We use an absolute path to the monorepo root (SCRIPT_DIR) since we're in /tmp/ -if [ -f "src/styles.css" ]; then - # Escape the path for use in sed (replace / with \/) - MONOREPO_ROOT_ESCAPED=$(echo "$SCRIPT_DIR" | sed 's/\//\\\//g') - - # Remove any existing source() directive first - sed -i.bak "s/@import 'tailwindcss' source('[^']*');/@import 'tailwindcss';/g" src/styles.css - sed -i.bak "s/@import \"tailwindcss\" source(\"[^\"]*\");/@import \"tailwindcss\";/g" src/styles.css - - # Add source() directive with absolute path to monorepo root - # Replace @import 'tailwindcss'; with @import 'tailwindcss' source('/path/to/monorepo'); - sed -i.bak "s/@import 'tailwindcss';/@import 'tailwindcss' source('${MONOREPO_ROOT_ESCAPED}');/g" src/styles.css - sed -i.bak "s/@import \"tailwindcss\";/@import \"tailwindcss\" source(\"${MONOREPO_ROOT_ESCAPED}\");/g" src/styles.css - - rm -f src/styles.css.bak - echo -e " ${GREEN}✓${NC} Updated Tailwind import to use source() pointing to monorepo root" - echo -e " ${BLUE}ℹ${NC} Tailwind will scan source files from local packages" -else - echo -e " ${YELLOW}⚠${NC} src/styles.css not found" -fi - -# Install dependencies -echo -e "\n Installing dependencies..." -if pnpm install; then - echo -e " ${GREEN}✓${NC} Dependencies installed successfully" -else - echo -e " ${RED}✗${NC} Failed to install dependencies" - echo -e "${RED}Error: pnpm install failed. Check the output above for details.${NC}" - exit 1 -fi - -# Verify node_modules exists -if [ ! -d "node_modules" ]; then - echo -e "${RED}Error: node_modules directory not found after installation${NC}" - exit 1 -fi - -echo "" -echo -e "${GREEN}=== Setup Complete! ===${NC}" -echo "" -echo -e "${BLUE}Test directory:${NC} $TEMP_TEST_DIR" -echo "" -echo -e "${YELLOW}Next steps:${NC}" -echo -e " 1. cd $TEMP_TEST_DIR" -echo -e " 2. pnpm dev" -echo "" -echo -e "${YELLOW}Or run development server automatically:${NC}" -echo -e " cd $TEMP_TEST_DIR && pnpm dev" -echo "" -echo -e "${YELLOW}To clean up after testing:${NC}" -echo -e " rm -rf $TEMP_TEST_DIR" -echo -e " rm -rf $PACK_DIR" -echo "" - -# Ask if user wants to start dev server -read -p "$(echo -e ${YELLOW}Start development server now? [y/N]:${NC} )" -n 1 -r -echo -if [[ $REPLY =~ ^[Yy]$ ]]; then - cd "$TEMP_TEST_DIR" - pnpm dev -fi -