diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d7174e5..b7a2d626 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,81 +26,57 @@ jobs: - name: Setup Environment uses: ./.github/actions/setup - - name: Install Compact compiler - id: setup + - name: Install Compact developer tools shell: bash run: | - set -euo pipefail - # Create directory for compiler - COMPACT_HOME="$HOME/compactc" - mkdir -p "$COMPACT_HOME" - - # Create URL - ZIP_FILE="compactc_v${COMPILER_VERSION}_x86_64-unknown-linux-musl.zip" - DOWNLOAD_URL="https://d3fazakqrumx6p.cloudfront.net/artifacts/compiler/compactc_${COMPILER_VERSION}/${ZIP_FILE}" - - echo "⬇️ Downloading Compact compiler..." - curl -Ls "$DOWNLOAD_URL" -o "$COMPACT_HOME/compactc.zip" - - echo "📦 Extracting..." - unzip -q "$COMPACT_HOME/compactc.zip" -d "$COMPACT_HOME" - chmod +x "$COMPACT_HOME"/{compactc,compactc.bin,zkir} - - echo "📁 Setting environment variables..." - echo "COMPACT_HOME=$COMPACT_HOME" >> "$GITHUB_ENV" - echo "$COMPACT_HOME" >> "$GITHUB_PATH" - - echo "✅ Verifying installation..." - if [ ! -f "$COMPACT_HOME/compactc" ]; then - echo "::error::❌ compactc not found in $COMPACT_HOME" - exit 1 - fi + curl --proto '=https' --tlsv1.2 -LsSf https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh + echo "$HOME/.compact/bin" >> "$GITHUB_PATH" + - name: Install and verify toolchain + run: | + compact update ${{ env.COMPILER_VERSION }} echo "🤖 Testing installation..." - "$COMPACT_HOME/compactc" --version + compact compile --version + compact compile --language-version - name: Check compiler and language version run: | - COMPILER_OUTPUT=$(compactc --version) - COMPUTED_COMPILER_VERSION=$(echo "$COMPILER_OUTPUT" | grep -oP '\b0\.[0-9]+\.[0-9]+\b' | head -n 1) - if [ "$COMPUTED_COMPILER_VERSION" != "$COMPILER_VERSION" ]; then - errMsg="❌ Compiler version mismatch!%0AExpected: $COMPILER_VERSION%0AGot: $COMPUTED_COMPILER_VERSION" + # Check toolchain version + COMPILER_OUTPUT=$(compact compile --version) + COMPUTED_COMPILER_VERSION=$(echo "$COMPILER_OUTPUT" | grep -oP '\b[0-9]+\.[0-9]+\.[0-9]+\b' | head -n 1) + if [ "$COMPUTED_COMPILER_VERSION" != "$COMPILER_VERSION" ]; then + errMsg="❌ Compiler version mismatch!%0AExpected: $COMPILER_VERSION%0AGot: $COMPUTED_COMPILER_VERSION" + echo "::error::$errMsg" + exit 1 + fi + echo "✅ Compiler version matches: $COMPUTED_COMPILER_VERSION" + + # Check language version using new command + LANGUAGE_OUTPUT=$(compact compile --language-version) + COMPUTED_LANGUAGE_VERSION=$(echo "$LANGUAGE_OUTPUT" | grep -oP '\b[0-9]+\.[0-9]+\.[0-9]+\b' | tail -n 1) + if [ "$COMPUTED_LANGUAGE_VERSION" != "$LANGUAGE_VERSION" ]; then + errMsg="❌ Language version mismatch!%0AExpected: $LANGUAGE_VERSION%0AGot: $COMPUTED_LANGUAGE_VERSION" echo "::error::$errMsg" exit 1 - fi - echo "✅ Compiler version matches: $COMPUTED_COMPILER_VERSION" - - LANGUAGE_OUTPUT=$(compactc --language-version) - COMPUTED_LANGUAGE_VERSION=$(echo "$LANGUAGE_OUTPUT" | grep -oP '\b0\.[0-9]+\.[0-9]+\b' | tail -n 1) - if [ "$COMPUTED_LANGUAGE_VERSION" != "$LANGUAGE_VERSION" ]; then - errMsg="❌ Language version mismatch!%0AExpected: $LANGUAGE_VERSION%0AGot: $COMPUTED_LANGUAGE_VERSION" - echo "::error::$errMsg" - exit 1 - fi - - echo "✅ Language version matches: $COMPUTED_LANGUAGE_VERSION" + fi + echo "✅ Language version matches: $COMPUTED_LANGUAGE_VERSION" - name: Compile contracts (with retry on hash mismatch) shell: bash run: | - set -euo pipefail - compile() { - echo "⚙️ Running Compact compilation..." if ! output=$(turbo compact --concurrency=1 2>&1); then - echo "❌ Compilation failed." if echo "$output" | grep -q "Hash mismatch" && [ -d "$HOME/.cache/midnight/zk-params" ]; then - echo "⚠️ Hash mismatch detected *and* zk-params exists. Removing cache..." + echo "Hash mismatch detected. Clearing zk-params cache..." rm -rf "$HOME/.cache/midnight/zk-params" - echo "::notice::♻️ Retrying compilation after clearing zk-params..." - turbo compact --concurrency=1 || { echo "::error::❌ Retry also failed."; exit 1; } + echo "Retrying compilation..." + turbo compact --concurrency=1 else - echo "🚫 Compilation failed for another reason or zk-params missing. No retry." + echo "$output" exit 1 fi fi } - compile - name: Run type checks diff --git a/README.md b/README.md index 6b688a27..f411b50c 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,13 @@ Make sure you have [nvm](https://github.com/nvm-sh/nvm), [yarn](https://yarnpkg.com/getting-started/install), and [turbo](https://turborepo.com/docs/getting-started/installation) installed on your machine. -Follow Midnight's [compact installation guide](https://docs.midnight.network/develop/tutorial/building/#midnight-compact-compiler) and confirm that `compactc` is in the `PATH` env variable. +Follow Midnight's [Compact Developer Tools installation guide](https://docs.midnight.network/develop/tutorial/building/#midnight-compact-compiler) and confirm that `compact` is in the `PATH` env variable. ```bash -$ compactc +$ compact compile --version Compactc version: 0.24.0 -Usage: compactc.bin ... - --help displays detailed usage information +0.24.0 ``` ## Set up the project @@ -32,7 +31,7 @@ Usage: compactc.bin ... > - [node](https://nodejs.org/) > - [yarn](https://yarnpkg.com/getting-started/install) > - [turbo](https://turborepo.com/docs/getting-started/installation) -> - [compact](https://docs.midnight.network/develop/tutorial/building/#midnight-compact-compiler) +> - [compact](https://docs.midnight.network/blog/compact-developer-tools) Clone the repository: diff --git a/compact/package.json b/compact/package.json index 898b30e2..bba8cb66 100644 --- a/compact/package.json +++ b/compact/package.json @@ -22,12 +22,14 @@ "scripts": { "build": "tsc -p .", "types": "tsc -p tsconfig.json --noEmit", + "test": "yarn vitest run", "clean": "git clean -fXd" }, "devDependencies": { "@types/node": "22.14.0", "fast-check": "^3.15.0", - "typescript": "^5.8.2" + "typescript": "^5.8.2", + "vitest": "^3.1.3" }, "dependencies": { "chalk": "^5.4.1", diff --git a/compact/src/Compiler.ts b/compact/src/Compiler.ts index af426286..eaee0178 100755 --- a/compact/src/Compiler.ts +++ b/compact/src/Compiler.ts @@ -3,146 +3,177 @@ import { exec as execCallback } from 'node:child_process'; import { existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; -import { basename, dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { basename, join, relative } from 'node:path'; import { promisify } from 'node:util'; import chalk from 'chalk'; -import ora, { type Ora } from 'ora'; -import { isPromisifiedChildProcessError } from './types/errors.ts'; +import ora from 'ora'; +import { + CompactCliNotFoundError, + CompilationError, + DirectoryNotFoundError, + isPromisifiedChildProcessError, +} from './types/errors.ts'; -const DIRNAME: string = dirname(fileURLToPath(import.meta.url)); +/** Source directory containing .compact files */ const SRC_DIR: string = 'src'; +/** Output directory for compiled artifacts */ const ARTIFACTS_DIR: string = 'artifacts'; -const COMPACT_HOME: string = - process.env.COMPACT_HOME ?? resolve(DIRNAME, '../compactc'); -const COMPACTC_PATH: string = join(COMPACT_HOME, 'compactc'); /** - * A class to handle compilation of `.compact` files using the `compactc` compiler. - * Provides progress feedback and colored output for success and error states. + * Function type for executing shell commands. + * Allows dependency injection for testing and customization. * - * @example - * ```typescript - * const compiler = new CompactCompiler('--skip-zk'); - * compiler.compile().catch(err => console.error(err)); - * ``` + * @param command - The shell command to execute + * @returns Promise resolving to command output + */ +export type ExecFunction = ( + command: string, +) => Promise<{ stdout: string; stderr: string }>; + +/** + * Service responsible for validating the Compact CLI environment. + * Checks CLI availability, retrieves version information, and ensures + * the toolchain is properly configured before compilation. * - * @example Compile specific directory + * @class EnvironmentValidator + * @example * ```typescript - * const compiler = new CompactCompiler('--skip-zk', 'security'); - * compiler.compile().catch(err => console.error(err)); - * ``` - * - * @example Successful Compilation Output - * ``` - * ℹ [COMPILE] Found 2 .compact file(s) to compile - * ✔ [COMPILE] [1/2] Compiled AccessControl.compact - * Compactc version: 0.24.0 - * ✔ [COMPILE] [2/2] Compiled MockAccessControl.compact - * Compactc version: 0.24.0 - * Compiling circuit "src/artifacts/MockAccessControl/zkir/grantRole.zkir"... (skipped proving keys) - * ``` - * - * @example Failed Compilation Output - * ``` - * ℹ [COMPILE] Found 2 .compact file(s) to compile - * ✖ [COMPILE] [1/2] Failed AccessControl.compact - * Compactc version: 0.24.0 - * Error: Expected ';' at line 5 in AccessControl.compact + * const validator = new EnvironmentValidator(); + * await validator.validate('0.24.0'); + * const version = await validator.getDevToolsVersion(); * ``` */ -export class CompactCompiler { - /** Stores the compiler flags passed via command-line arguments */ - private readonly flags: string; - /** Optional target directory to limit compilation scope */ - private readonly targetDir?: string; +export class EnvironmentValidator { + private execFn: ExecFunction; /** - * Constructs a new CompactCompiler instance, validating the `compactc` binary path. + * Creates a new EnvironmentValidator instance. * - * @param flags - Space-separated string of `compactc` flags (e.g., "--skip-zk --no-communications-commitment") - * @param targetDir - Optional subdirectory within src/ to limit compilation (e.g., "security", "utils") - * @throws {Error} If the `compactc` binary is not found at the resolved path + * @param execFn - Function to execute shell commands (defaults to promisified child_process.exec) */ - constructor(flags: string, targetDir?: string) { - this.flags = flags.trim(); - this.targetDir = targetDir; - const spinner = ora(); - - spinner.info(chalk.blue(`[COMPILE] COMPACT_HOME: ${COMPACT_HOME}`)); - spinner.info(chalk.blue(`[COMPILE] COMPACTC_PATH: ${COMPACTC_PATH}`)); - if (this.targetDir) { - spinner.info(chalk.blue(`[COMPILE] TARGET_DIR: ${this.targetDir}`)); - } + constructor(execFn: ExecFunction = promisify(execCallback)) { + this.execFn = execFn; + } - if (!existsSync(COMPACTC_PATH)) { - spinner.fail( - chalk.red( - `[COMPILE] Error: compactc not found at ${COMPACTC_PATH}. Set COMPACT_HOME to the compactc binary path.`, - ), - ); - throw new Error(`compactc not found at ${COMPACTC_PATH}`); + /** + * Checks if the Compact CLI is available in the system PATH. + * + * @returns Promise resolving to true if CLI is available, false otherwise + * @example + * ```typescript + * const isAvailable = await validator.checkCompactAvailable(); + * if (!isAvailable) { + * throw new Error('Compact CLI not found'); + * } + * ``` + */ + async checkCompactAvailable(): Promise { + try { + await this.execFn('compact --version'); + return true; + } catch { + return false; } } /** - * Compiles all `.compact` files in the source directory (or target subdirectory) and its subdirectories. - * Scans the `src` directory (or `src/{targetDir}`) recursively for `.compact` files, - * compiles each one using `compactc`, and displays progress with a spinner and colored output. + * Retrieves the version of the Compact developer tools. * - * @returns A promise that resolves when all files are compiled successfully - * @throws {Error} If compilation fails for any file + * @returns Promise resolving to the version string + * @throws {Error} If the CLI is not available or command fails + * @example + * ```typescript + * const version = await validator.getDevToolsVersion(); + * console.log(`Using Compact ${version}`); + * ``` */ - public async compile(): Promise { - const searchDir = this.targetDir ? join(SRC_DIR, this.targetDir) : SRC_DIR; - - // Validate target directory exists - if (this.targetDir && !existsSync(searchDir)) { - const spinner = ora(); - spinner.fail( - chalk.red( - `[COMPILE] Error: Target directory ${searchDir} does not exist.`, - ), - ); - throw new Error(`Target directory ${searchDir} does not exist`); - } + async getDevToolsVersion(): Promise { + const { stdout } = await this.execFn('compact --version'); + return stdout.trim(); + } - const compactFiles: string[] = await this.getCompactFiles(searchDir); + /** + * Retrieves the version of the Compact toolchain/compiler. + * + * @param version - Optional specific toolchain version to query + * @returns Promise resolving to the toolchain version string + * @throws {Error} If the CLI is not available or command fails + * @example + * ```typescript + * const toolchainVersion = await validator.getToolchainVersion('0.24.0'); + * console.log(`Toolchain: ${toolchainVersion}`); + * ``` + */ + async getToolchainVersion(version?: string): Promise { + const versionFlag = version ? `+${version}` : ''; + const { stdout } = await this.execFn( + `compact compile ${versionFlag} --version`, + ); + return stdout.trim(); + } - const spinner = ora(); - if (compactFiles.length === 0) { - const searchLocation = this.targetDir ? `${this.targetDir}/` : ''; - spinner.warn( - chalk.yellow(`[COMPILE] No .compact files found in ${searchLocation}.`), + /** + * Validates the entire Compact environment and ensures it's ready for compilation. + * Checks CLI availability and retrieves version information. + * + * @param version - Optional specific toolchain version to validate + * @throws {CompactCliNotFoundError} If the Compact CLI is not available + * @throws {Error} If version commands fail + * @example + * ```typescript + * try { + * await validator.validate('0.24.0'); + * console.log('Environment validated successfully'); + * } catch (error) { + * if (error instanceof CompactCliNotFoundError) { + * console.error('Please install Compact CLI'); + * } + * } + * ``` + */ + async validate( + version?: string, + ): Promise<{ devToolsVersion: string; toolchainVersion: string }> { + const isAvailable = await this.checkCompactAvailable(); + if (!isAvailable) { + throw new CompactCliNotFoundError( + "'compact' CLI not found in PATH. Please install the Compact developer tools.", ); - return; } - const searchLocation = this.targetDir ? ` in ${this.targetDir}/` : ''; - spinner.info( - chalk.blue( - `[COMPILE] Found ${compactFiles.length} .compact file(s) to compile${searchLocation}`, - ), - ); + const devToolsVersion = await this.getDevToolsVersion(); + const toolchainVersion = await this.getToolchainVersion(version); - for (const [index, file] of compactFiles.entries()) { - await this.compileFile(file, index, compactFiles.length); - } + return { devToolsVersion, toolchainVersion }; } +} +/** + * Service responsible for discovering .compact files in the source directory. + * Recursively scans directories and filters for .compact file extensions. + * + * @class FileDiscovery + * @example + * ```typescript + * const discovery = new FileDiscovery(); + * const files = await discovery.getCompactFiles('src/security'); + * console.log(`Found ${files.length} .compact files`); + * ``` + */ +export class FileDiscovery { /** - * Recursively scans directory and returns an array of relative paths to `.compact` - * files found within it. - * - * @param dir - The absolute or relative path to the directory to scan. - * @returns A promise that resolves to an array of relative paths from `SRC_DIR` - * to each `.compact` file. + * Recursively discovers all .compact files in a directory. + * Returns relative paths from the SRC_DIR for consistent processing. * - * @throws Will log an error if a dir cannot be read or if a file or subdir - * fails to be accessed. It will not reject the promise. Errors are handled - * internally and skipped. + * @param dir - Directory path to search (relative or absolute) + * @returns Promise resolving to array of relative file paths + * @example + * ```typescript + * const files = await discovery.getCompactFiles('src'); + * // Returns: ['contracts/Token.compact', 'security/AccessControl.compact'] + * ``` */ - private async getCompactFiles(dir: string): Promise { + async getCompactFiles(dir: string): Promise { try { const dirents = await readdir(dir, { withFileTypes: true }); const filePromises = dirents.map(async (entry) => { @@ -153,12 +184,11 @@ export class CompactCompiler { } if (entry.isFile() && fullPath.endsWith('.compact')) { - // Always return relative path from SRC_DIR, regardless of search directory return [relative(SRC_DIR, fullPath)]; } return []; } catch (err) { - // biome-ignore lint/suspicious/noConsole: Displays file path that failed to parse + // biome-ignore lint/suspicious/noConsole: Needed to display error and file path console.warn(`Error accessing ${fullPath}:`, err); return []; } @@ -167,65 +197,503 @@ export class CompactCompiler { const results = await Promise.all(filePromises); return results.flat(); } catch (err) { - // biome-ignore lint/suspicious/noConsole: Displays which directory failed to be read + // biome-ignore lint/suspicious/noConsole: Needed to display error and dir path console.error(`Failed to read dir: ${dir}`, err); return []; } } +} + +/** + * Service responsible for compiling individual .compact files. + * Handles command construction, execution, and error processing. + * + * @class CompilerService + * @example + * ```typescript + * const compiler = new CompilerService(); + * const result = await compiler.compileFile( + * 'contracts/Token.compact', + * '--skip-zk --verbose', + * '0.24.0' + * ); + * console.log('Compilation output:', result.stdout); + * ``` + */ +export class CompilerService { + private execFn: ExecFunction; /** - * Compiles a single `.compact` file. - * Executes the `compactc` compiler with the provided flags, input file, and output directory. + * Creates a new CompilerService instance. * - * @param file - Relative path of the `.compact` file to compile (e.g., "test/mock/MockFile.compact") - * @param index - Current file index (0-based) for progress display - * @param total - Total number of files to compile for progress display - * @returns A promise that resolves when the file is compiled successfully - * @throws {Error} If compilation fails + * @param execFn - Function to execute shell commands (defaults to promisified child_process.exec) + */ + constructor(execFn: ExecFunction = promisify(execCallback)) { + this.execFn = execFn; + } + + /** + * Compiles a single .compact file using the Compact CLI. + * Constructs the appropriate command with flags and version, then executes it. + * + * @param file - Relative path to the .compact file from SRC_DIR + * @param flags - Space-separated compiler flags (e.g., '--skip-zk --verbose') + * @param version - Optional specific toolchain version to use + * @returns Promise resolving to compilation output (stdout/stderr) + * @throws {CompilationError} If compilation fails for any reason + * @example + * ```typescript + * try { + * const result = await compiler.compileFile( + * 'security/AccessControl.compact', + * '--skip-zk', + * '0.24.0' + * ); + * console.log('Success:', result.stdout); + * } catch (error) { + * if (error instanceof CompilationError) { + * console.error('Compilation failed for', error.file); + * } + * } + * ``` + */ + async compileFile( + file: string, + flags: string, + version?: string, + ): Promise<{ stdout: string; stderr: string }> { + const inputPath = join(SRC_DIR, file); + const outputDir = join(ARTIFACTS_DIR, basename(file, '.compact')); + + const versionFlag = version ? `+${version}` : ''; + const flagsStr = flags ? ` ${flags}` : ''; + const command = `compact compile${versionFlag ? ` ${versionFlag}` : ''}${flagsStr} "${inputPath}" "${outputDir}"`; + + try { + return await this.execFn(command); + } catch (error: unknown) { + let message: string; + + if (error instanceof Error) { + message = error.message; + } else { + message = String(error); // fallback for strings, objects, numbers, etc. + } + + throw new CompilationError(`Failed to compile ${file}: ${message}`, file); + } + } +} + +/** + * Utility service for handling user interface output and formatting. + * Provides consistent styling and formatting for compiler messages and output. + * + * @class UIService + * @example + * ```typescript + * UIService.displayEnvInfo('compact 0.1.0', 'Compactc 0.24.0', 'security'); + * UIService.printOutput('Compilation successful', chalk.green); + * ``` + */ +export const UIService = { + /** + * Prints formatted output with consistent indentation and coloring. + * Filters empty lines and adds consistent indentation for readability. + * + * @param output - Raw output text to format + * @param colorFn - Chalk color function for styling + * @example + * ```typescript + * UIService.printOutput(stdout, chalk.cyan); + * UIService.printOutput(stderr, chalk.red); + * ``` + */ + printOutput(output: string, colorFn: (text: string) => string): void { + const lines = output + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => ` ${line}`); + console.log(colorFn(lines.join('\n'))); + }, + + /** + * Displays environment information including tool versions and configuration. + * Shows developer tools version, toolchain version, and optional settings. + * + * @param devToolsVersion - Version string of the Compact developer tools + * @param toolchainVersion - Version string of the Compact toolchain/compiler + * @param targetDir - Optional target directory being compiled + * @param version - Optional specific version being used + * @example + * ```typescript + * UIService.displayEnvInfo( + * 'compact 0.1.0', + * 'Compactc version: 0.24.0', + * 'security', + * '0.24.0' + * ); + * ``` + */ + displayEnvInfo( + devToolsVersion: string, + toolchainVersion: string, + targetDir?: string, + version?: string, + ): void { + const spinner = ora(); + + if (targetDir) { + spinner.info(chalk.blue(`[COMPILE] TARGET_DIR: ${targetDir}`)); + } + + spinner.info( + chalk.blue(`[COMPILE] Compact developer tools: ${devToolsVersion}`), + ); + spinner.info( + chalk.blue(`[COMPILE] Compact toolchain: ${toolchainVersion}`), + ); + + if (version) { + spinner.info(chalk.blue(`[COMPILE] Using toolchain version: ${version}`)); + } + }, + + /** + * Displays compilation start message with file count and optional location. + * + * @param fileCount - Number of files to be compiled + * @param targetDir - Optional target directory being compiled + * @example + * ```typescript + * UIService.showCompilationStart(5, 'security'); + * // Output: "Found 5 .compact file(s) to compile in security/" + * ``` + */ + showCompilationStart(fileCount: number, targetDir?: string): void { + const searchLocation = targetDir ? ` in ${targetDir}/` : ''; + const spinner = ora(); + spinner.info( + chalk.blue( + `[COMPILE] Found ${fileCount} .compact file(s) to compile${searchLocation}`, + ), + ); + }, + + /** + * Displays a warning message when no .compact files are found. + * + * @param targetDir - Optional target directory that was searched + * @example + * ```typescript + * UIService.showNoFiles('security'); + * // Output: "No .compact files found in security/." + * ``` + */ + showNoFiles(targetDir?: string): void { + const searchLocation = targetDir ? `${targetDir}/` : ''; + const spinner = ora(); + spinner.warn( + chalk.yellow(`[COMPILE] No .compact files found in ${searchLocation}.`), + ); + }, +}; + +/** + * Main compiler class that orchestrates the compilation process. + * Coordinates environment validation, file discovery, and compilation services + * to provide a complete .compact file compilation solution. + * + * Features: + * - Dependency injection for testability + * - Structured error propagation with custom error types + * - Progress reporting and user feedback + * - Support for compiler flags and toolchain versions + * - Environment variable integration + * + * @class CompactCompiler + * @example + * ```typescript + * // Basic usage + * const compiler = new CompactCompiler('--skip-zk', 'security', '0.24.0'); + * await compiler.compile(); + * + * // Factory method usage + * const compiler = CompactCompiler.fromArgs(['--dir', 'security', '--skip-zk']); + * await compiler.compile(); + * + * // With environment variables + * process.env.SKIP_ZK = 'true'; + * const compiler = CompactCompiler.fromArgs(['--dir', 'token']); + * await compiler.compile(); + * ``` + */ +export class CompactCompiler { + /** Environment validation service */ + private readonly environmentValidator: EnvironmentValidator; + /** File discovery service */ + private readonly fileDiscovery: FileDiscovery; + /** Compilation execution service */ + private readonly compilerService: CompilerService; + + /** Compiler flags to pass to the Compact CLI */ + private readonly flags: string; + /** Optional target directory to limit compilation scope */ + private readonly targetDir?: string; + /** Optional specific toolchain version to use */ + private readonly version?: string; + + /** + * Creates a new CompactCompiler instance with specified configuration. + * + * @param flags - Space-separated compiler flags (e.g., '--skip-zk --verbose') + * @param targetDir - Optional subdirectory within src/ to compile (e.g., 'security', 'token') + * @param version - Optional toolchain version to use (e.g., '0.24.0') + * @param execFn - Optional custom exec function for dependency injection + * @example + * ```typescript + * // Compile all files with flags + * const compiler = new CompactCompiler('--skip-zk --verbose'); + * + * // Compile specific directory + * const compiler = new CompactCompiler('', 'security'); + * + * // Compile with specific version + * const compiler = new CompactCompiler('--skip-zk', undefined, '0.24.0'); + * + * // For testing with custom exec function + * const mockExec = vi.fn(); + * const compiler = new CompactCompiler('', undefined, undefined, mockExec); + * ``` + */ + constructor( + flags = '', + targetDir?: string, + version?: string, + execFn?: ExecFunction, + ) { + this.flags = flags.trim(); + this.targetDir = targetDir; + this.version = version; + this.environmentValidator = new EnvironmentValidator(execFn); + this.fileDiscovery = new FileDiscovery(); + this.compilerService = new CompilerService(execFn); + } + + /** + * Factory method to create a CompactCompiler from command-line arguments. + * Parses various argument formats including flags, directories, versions, and environment variables. + * + * Supported argument patterns: + * - `--dir ` - Target specific directory + * - `+` - Use specific toolchain version + * - Other arguments - Treated as compiler flags + * - `SKIP_ZK=true` environment variable - Adds --skip-zk flag + * + * @param args - Array of command-line arguments + * @param env - Environment variables (defaults to process.env) + * @returns New CompactCompiler instance configured from arguments + * @throws {Error} If --dir flag is provided without a directory name + * @example + * ```typescript + * // Parse command line: compact-compiler --dir security --skip-zk +0.24.0 + * const compiler = CompactCompiler.fromArgs([ + * '--dir', 'security', + * '--skip-zk', + * '+0.24.0' + * ]); + * + * // With environment variable + * const compiler = CompactCompiler.fromArgs( + * ['--dir', 'token'], + * { SKIP_ZK: 'true' } + * ); + * + * // Empty args with environment + * const compiler = CompactCompiler.fromArgs([], { SKIP_ZK: 'true' }); + * ``` + */ + static fromArgs( + args: string[], + env: NodeJS.ProcessEnv = process.env, + ): CompactCompiler { + let targetDir: string | undefined; + const flags: string[] = []; + let version: string | undefined; + + if (env.SKIP_ZK === 'true') { + flags.push('--skip-zk'); + } + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--dir') { + const dirNameExists = + i + 1 < args.length && !args[i + 1].startsWith('--'); + if (dirNameExists) { + targetDir = args[i + 1]; + i++; + } else { + throw new Error('--dir flag requires a directory name'); + } + } else if (args[i].startsWith('+')) { + version = args[i].slice(1); + } else { + // Only add flag if it's not already present + if (!flags.includes(args[i])) { + flags.push(args[i]); + } + } + } + + return new CompactCompiler(flags.join(' '), targetDir, version); + } + + /** + * Validates the compilation environment and displays version information. + * Performs environment validation, retrieves toolchain versions, and shows configuration details. + * + * Process: + * + * 1. Validates CLI availability and toolchain compatibility + * 2. Retrieves developer tools and compiler versions + * 3. Displays environment configuration information + * + * @throws {CompactCliNotFoundError} If Compact CLI is not available in PATH + * @throws {Error} If version retrieval or other validation steps fail + * @example + * ```typescript + * try { + * await compiler.validateEnvironment(); + * console.log('Environment ready for compilation'); + * } catch (error) { + * if (error instanceof CompactCliNotFoundError) { + * console.error('Please install Compact CLI'); + * } + * } + * ``` + */ + async validateEnvironment(): Promise { + const { devToolsVersion, toolchainVersion } = + await this.environmentValidator.validate(this.version); + UIService.displayEnvInfo( + devToolsVersion, + toolchainVersion, + this.targetDir, + this.version, + ); + } + + /** + * Main compilation method that orchestrates the entire compilation process. + * + * Process flow: + * 1. Validates environment and shows configuration + * 2. Discovers .compact files in target directory + * 3. Compiles each file with progress reporting + * 4. Handles errors and provides user feedback + * + * @throws {CompactCliNotFoundError} If Compact CLI is not available + * @throws {DirectoryNotFoundError} If target directory doesn't exist + * @throws {CompilationError} If any file compilation fails + * @example + * ```typescript + * const compiler = new CompactCompiler('--skip-zk', 'security'); + * + * try { + * await compiler.compile(); + * console.log('All files compiled successfully'); + * } catch (error) { + * if (error instanceof DirectoryNotFoundError) { + * console.error(`Directory not found: ${error.directory}`); + * } else if (error instanceof CompilationError) { + * console.error(`Failed to compile: ${error.file}`); + * } + * } + * ``` + */ + async compile(): Promise { + await this.validateEnvironment(); + + const searchDir = this.targetDir ? join(SRC_DIR, this.targetDir) : SRC_DIR; + + // Validate target directory exists + if (this.targetDir && !existsSync(searchDir)) { + throw new DirectoryNotFoundError( + `Target directory ${searchDir} does not exist`, + searchDir, + ); + } + + const compactFiles = await this.fileDiscovery.getCompactFiles(searchDir); + + if (compactFiles.length === 0) { + UIService.showNoFiles(this.targetDir); + return; + } + + UIService.showCompilationStart(compactFiles.length, this.targetDir); + + for (const [index, file] of compactFiles.entries()) { + await this.compileFile(file, index, compactFiles.length); + } + } + + /** + * Compiles a single file with progress reporting and error handling. + * Private method used internally by the main compile() method. + * + * @param file - Relative path to the .compact file + * @param index - Current file index (0-based) for progress tracking + * @param total - Total number of files being compiled + * @throws {CompilationError} If compilation fails + * @private */ private async compileFile( file: string, index: number, total: number, ): Promise { - const execAsync = promisify(execCallback); - const inputPath: string = join(SRC_DIR, file); - const outputDir: string = join(ARTIFACTS_DIR, basename(file, '.compact')); - const step: string = `[${index + 1}/${total}]`; - const spinner: Ora = ora( + const step = `[${index + 1}/${total}]`; + const spinner = ora( chalk.blue(`[COMPILE] ${step} Compiling ${file}`), ).start(); try { - const command: string = - `${COMPACTC_PATH} ${this.flags} "${inputPath}" "${outputDir}"`.trim(); - spinner.text = chalk.blue(`[COMPILE] ${step} Running: ${command}`); - const { stdout, stderr }: { stdout: string; stderr: string } = - await execAsync(command); + const result = await this.compilerService.compileFile( + file, + this.flags, + this.version, + ); + spinner.succeed(chalk.green(`[COMPILE] ${step} Compiled ${file}`)); - this.printOutput(stdout, chalk.cyan); - this.printOutput(stderr, chalk.yellow); - } catch (error: unknown) { + UIService.printOutput(result.stdout, chalk.cyan); + UIService.printOutput(result.stderr, chalk.yellow); + } catch (error) { spinner.fail(chalk.red(`[COMPILE] ${step} Failed ${file}`)); - if (isPromisifiedChildProcessError(error)) { - this.printOutput(error.stdout, chalk.cyan); - this.printOutput(error.stderr, chalk.red); + + if ( + error instanceof CompilationError && + isPromisifiedChildProcessError(error.cause) + ) { + const execError = error.cause; + UIService.printOutput(execError.stdout, chalk.cyan); + UIService.printOutput(execError.stderr, chalk.red); } + throw error; } } /** - * Prints compiler output with indentation and specified color. - * - * @param output - The compiler output string to print (stdout or stderr) - * @param colorFn - Chalk color function to style the output (e.g., `chalk.cyan` for success, `chalk.red` for errors) + * For testing */ - private printOutput(output: string, colorFn: (text: string) => string): void { - const lines: string[] = output - .split('\n') - .filter((line: string): boolean => line.trim() !== '') - .map((line: string): string => ` ${line}`); - console.log(colorFn(lines.join('\n'))); + get testFlags(): string { + return this.flags; + } + get testTargetDir(): string | undefined { + return this.targetDir; + } + get testVersion(): string | undefined { + return this.version; } } diff --git a/compact/src/runCompiler.ts b/compact/src/runCompiler.ts index 8d5b98aa..ce3cdc6c 100644 --- a/compact/src/runCompiler.ts +++ b/compact/src/runCompiler.ts @@ -1,109 +1,227 @@ #!/usr/bin/env node import chalk from 'chalk'; -import ora from 'ora'; +import ora, { type Ora } from 'ora'; import { CompactCompiler } from './Compiler.js'; +import { + type CompilationError, + isPromisifiedChildProcessError, +} from './types/errors.js'; /** - * Executes the Compact compiler CLI. - * Compiles `.compact` files using the `CompactCompiler` class with provided flags. + * Executes the Compact compiler CLI with improved error handling and user feedback. * - * For individual module compilation, CLI flags work directly. - * For full compilation with dependencies, use environment variables due to Turbo task orchestration. + * Error Handling Architecture: * - * @example Individual module compilation (CLI flags work directly) + * This CLI follows a layered error handling approach: + * + * - Business logic (Compiler.ts) throws structured errors with context. + * - CLI layer (runCompiler.ts) handles all user-facing error presentation. + * - Custom error types (types/errors.ts) provide semantic meaning and context. + * + * Benefits: Better testability, consistent UI, separation of concerns. + * + * Note: This compiler uses fail-fast error handling. + * Compilation stops on the first error encountered. + * This provides immediate feedback but doesn't attempt to compile remaining files after a failure. + * + * @example Individual module compilation * ```bash * npx compact-compiler --dir security --skip-zk * turbo compact:access -- --skip-zk * turbo compact:security -- --skip-zk --other-flag * ``` * - * @example Full compilation (environment variables required) + * @example Full compilation with environment variables * ```bash - * # Use environment variables for full builds due to task dependencies * SKIP_ZK=true turbo compact - * - * # Normal full build * turbo compact * ``` * - * @example Direct CLI usage + * @example Version specification * ```bash - * npx compact-compiler --skip-zk - * npx compact-compiler --dir security --skip-zk - * ``` - * - * Environment Variables (only needed for full builds): - * - `SKIP_ZK=true`: Adds --skip-zk flag when running full compilation via `turbo compact` - * - * Expected output: - * ``` - * ℹ [COMPILE] Compact compiler started - * ℹ [COMPILE] COMPACT_HOME: /path/to/compactc - * ℹ [COMPILE] COMPACTC_PATH: /path/to/compactc/compactc - * ℹ [COMPILE] TARGET_DIR: access:compact:access: - * ℹ [COMPILE] Found 4 .compact file(s) to compile in access/ - * ✔ [COMPILE] [1/4] Compiled access/AccessControl.compact - * ✔ [COMPILE] [2/4] Compiled access/Ownable.compact - * ✔ [COMPILE] [3/4] Compiled access/test/mocks/MockAccessControl.compact - * ✔ [COMPILE] [4/4] Compiled access/test/mocks/MockOwnable.compact - * Compactc version: 0.24.0 + * npx compact-compiler --dir security --skip-zk +0.24.0 * ``` */ async function runCompiler(): Promise { - const spinner = ora(chalk.blue('[COMPILE] Compact Compiler started')).info(); + const spinner = ora(chalk.blue('[COMPILE] Compact compiler started')).info(); try { const args = process.argv.slice(2); + const compiler = CompactCompiler.fromArgs(args); + await compiler.compile(); + } catch (error) { + handleError(error, spinner); + process.exit(1); + } +} - // Parse arguments more robustly - let targetDir: string | undefined; - const compilerFlags: string[] = []; +/** + * Centralized error handling with specific error types and user-friendly messages. + * + * Handles different error types with appropriate user feedback: + * + * - `CompactCliNotFoundError`: Shows installation instructions. + * - `DirectoryNotFoundError`: Shows available directories. + * - `CompilationError`: Shows file-specific error details with context. + * - Environment validation errors: Shows troubleshooting tips. + * - Argument parsing errors: Shows usage help. + * - Generic errors: Shows general troubleshooting guidance. + * + * @param error - The error that occurred during compilation + * @param spinner - Ora spinner instance for consistent UI messaging + */ +function handleError(error: unknown, spinner: Ora): void { + // CompactCliNotFoundError + if (error instanceof Error && error.name === 'CompactCliNotFoundError') { + spinner.fail(chalk.red(`[COMPILE] Error: ${error.message}`)); + spinner.info( + chalk.blue( + `[COMPILE] Install with: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh`, + ), + ); + return; + } - // Handle common development flags via environment variables - // This is especially useful when using with Turbo monorepo tasks - if (process.env.SKIP_ZK === 'true') { - compilerFlags.push('--skip-zk'); - } + // DirectoryNotFoundError + if (error instanceof Error && error.name === 'DirectoryNotFoundError') { + spinner.fail(chalk.red(`[COMPILE] Error: ${error.message}`)); + showAvailableDirectories(); + return; + } + + // CompilationError + if (error instanceof Error && error.name === 'CompilationError') { + // The compilation error details (file name, stdout/stderr) are already displayed + // by `compileFile`; therefore, this just handles the final err state + const compilationError = error as CompilationError; + spinner.fail( + chalk.red( + `[COMPILE] Compilation failed for file: ${compilationError.file || 'unknown'}`, + ), + ); - for (let i = 0; i < args.length; i++) { - if (args[i] === '--dir') { - const dirNameExists = - i + 1 < args.length && !args[i + 1].startsWith('--'); - if (dirNameExists) { - targetDir = args[i + 1]; - i++; // Skip the next argument (directory name) - } else { - spinner.fail( - chalk.red('[COMPILE] Error: --dir flag requires a directory name'), - ); - console.log( - chalk.yellow( - 'Usage: compact-compiler --dir [other-flags]', - ), - ); - console.log( - chalk.yellow('Example: compact-compiler --dir access --skip-zk'), - ); - console.log( - chalk.yellow('Example: SKIP_ZK=true compact-compiler --dir access'), - ); - process.exit(1); - } - } else { - // All other arguments are compiler flags - compilerFlags.push(args[i]); + if (isPromisifiedChildProcessError(compilationError.cause)) { + const execError = compilationError.cause; + if ( + execError.stderr && + !execError.stderr.includes('stdout') && + !execError.stderr.includes('stderr') + ) { + console.log( + chalk.red(` Additional error details: ${execError.stderr}`), + ); } } + return; + } - const compiler = new CompactCompiler(compilerFlags.join(' '), targetDir); - await compiler.compile(); - } catch (err) { + // Env validation errors (non-CLI errors) + if (isPromisifiedChildProcessError(error)) { spinner.fail( - chalk.red('[COMPILE] Unexpected error:', (err as Error).message), + chalk.red(`[COMPILE] Environment validation failed: ${error.message}`), ); - process.exit(1); + console.log(chalk.gray('\nTroubleshooting:')); + console.log( + chalk.gray(' • Check that Compact CLI is installed and in PATH'), + ); + console.log(chalk.gray(' • Verify the specified Compact version exists')); + console.log(chalk.gray(' • Ensure you have proper permissions')); + return; } + + // Arg parsing + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('--dir flag requires a directory name')) { + spinner.fail( + chalk.red('[COMPILE] Error: --dir flag requires a directory name'), + ); + showUsageHelp(); + return; + } + + // Unexpected errors + spinner.fail(chalk.red(`[COMPILE] Unexpected error: ${errorMessage}`)); + console.log(chalk.gray('\nIf this error persists, please check:')); + console.log(chalk.gray(' • Compact CLI is installed and in PATH')); + console.log(chalk.gray(' • Source files exist and are readable')); + console.log(chalk.gray(' • Specified Compact version exists')); + console.log(chalk.gray(' • File system permissions are correct')); +} + +/** + * Shows available directories when `DirectoryNotFoundError` occurs. + */ +function showAvailableDirectories(): void { + console.log(chalk.yellow('\nAvailable directories:')); + console.log( + chalk.yellow(' --dir access # Compile access control contracts'), + ); + console.log(chalk.yellow(' --dir archive # Compile archive contracts')); + console.log(chalk.yellow(' --dir security # Compile security contracts')); + console.log(chalk.yellow(' --dir token # Compile token contracts')); + console.log(chalk.yellow(' --dir utils # Compile utility contracts')); +} + +/** + * Shows usage help with examples for different scenarios. + */ +function showUsageHelp(): void { + console.log(chalk.yellow('\nUsage: compact-compiler [options]')); + console.log(chalk.yellow('\nOptions:')); + console.log( + chalk.yellow( + ' --dir Compile specific directory (access, archive, security, token, utils)', + ), + ); + console.log( + chalk.yellow(' --skip-zk Skip zero-knowledge proof generation'), + ); + console.log( + chalk.yellow( + ' + Use specific toolchain version (e.g., +0.24.0)', + ), + ); + console.log(chalk.yellow('\nExamples:')); + console.log( + chalk.yellow( + ' compact-compiler # Compile all files', + ), + ); + console.log( + chalk.yellow( + ' compact-compiler --dir security # Compile security directory', + ), + ); + console.log( + chalk.yellow( + ' compact-compiler --dir access --skip-zk # Compile access with flags', + ), + ); + console.log( + chalk.yellow( + ' SKIP_ZK=true compact-compiler --dir token # Use environment variable', + ), + ); + console.log( + chalk.yellow( + ' compact-compiler --skip-zk +0.24.0 # Use specific version', + ), + ); + console.log(chalk.yellow('\nTurbo integration:')); + console.log( + chalk.yellow(' turbo compact # Full build'), + ); + console.log( + chalk.yellow( + ' turbo compact:security -- --skip-zk # Directory with flags', + ), + ); + console.log( + chalk.yellow( + ' SKIP_ZK=true turbo compact # Environment variables', + ), + ); } runCompiler(); diff --git a/compact/src/types/errors.ts b/compact/src/types/errors.ts index fd651ddb..8fcf6da3 100644 --- a/compact/src/types/errors.ts +++ b/compact/src/types/errors.ts @@ -26,3 +26,71 @@ export function isPromisifiedChildProcessError( ): error is PromisifiedChildProcessError { return error instanceof Error && 'stdout' in error && 'stderr' in error; } + +/** + * Custom error thrown when the Compact CLI is not found in the system PATH. + * This error indicates that the Compact developer tools are not installed + * or not properly configured in the environment. + * + * @class CompactCliNotFoundError + * @extends Error + */ +export class CompactCliNotFoundError extends Error { + /** + * Creates a new CompactCliNotFoundError instance. + * + * @param message - Error message describing the CLI availability issue + */ + constructor(message: string) { + super(message); + this.name = 'CompactCliNotFoundError'; + } +} + +/** + * Custom error thrown when compilation of a .compact file fails. + * Contains additional context about which file failed to compile, + * making it easier to identify and debug compilation issues. + * + * @class CompilationError + * @extends Error + */ +export class CompilationError extends Error { + public readonly file?: string; + + /** + * Creates a new CompilationError instance. + * + * @param message - Error message describing the compilation failure + * @param file - Optional relative path to the file that failed to compile + */ + constructor(message: string, file?: string) { + super(message); + this.file = file; + this.name = 'CompilationError'; + } +} + +/** + * Custom error thrown when a specified target directory does not exist. + * Provides specific information about which directory was not found, + * helping users correct path-related issues. + * + * @class DirectoryNotFoundError + * @extends Error + */ +export class DirectoryNotFoundError extends Error { + public readonly directory: string; + + /** + * Creates a new DirectoryNotFoundError instance. + * + * @param message - Error message describing the directory issue + * @param directory - The directory path that was not found + */ + constructor(message: string, directory: string) { + super(message); + this.directory = directory; + this.name = 'DirectoryNotFoundError'; + } +} diff --git a/compact/test/Compiler.test.ts b/compact/test/Compiler.test.ts new file mode 100644 index 00000000..514a82ea --- /dev/null +++ b/compact/test/Compiler.test.ts @@ -0,0 +1,868 @@ +import { existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { + beforeEach, + describe, + expect, + it, + type MockedFunction, + vi, +} from 'vitest'; +import { + CompactCompiler, + CompilerService, + EnvironmentValidator, + type ExecFunction, + FileDiscovery, + UIService, +} from '../src/Compiler.js'; +import { + CompactCliNotFoundError, + CompilationError, + DirectoryNotFoundError, +} from '../src/types/errors.js'; + +// Mock Node.js modules +vi.mock('node:fs'); +vi.mock('node:fs/promises'); +vi.mock('chalk', () => ({ + default: { + blue: (text: string) => text, + green: (text: string) => text, + red: (text: string) => text, + yellow: (text: string) => text, + cyan: (text: string) => text, + gray: (text: string) => text, + }, +})); + +// Mock spinner +const mockSpinner = { + start: () => ({ succeed: vi.fn(), fail: vi.fn(), text: '' }), + info: vi.fn(), + warn: vi.fn(), + fail: vi.fn(), + succeed: vi.fn(), +}; + +vi.mock('ora', () => ({ + default: () => mockSpinner, +})); + +const mockExistsSync = vi.mocked(existsSync); +const mockReaddir = vi.mocked(readdir); + +describe('EnvironmentValidator', () => { + let mockExec: MockedFunction; + let validator: EnvironmentValidator; + + beforeEach(() => { + vi.clearAllMocks(); + mockExec = vi.fn(); + validator = new EnvironmentValidator(mockExec); + }); + + describe('checkCompactAvailable', () => { + it('should return true when compact CLI is available', async () => { + mockExec.mockResolvedValue({ stdout: 'compact 0.1.0', stderr: '' }); + + const result = await validator.checkCompactAvailable(); + + expect(result).toBe(true); + expect(mockExec).toHaveBeenCalledWith('compact --version'); + }); + + it('should return false when compact CLI is not available', async () => { + mockExec.mockRejectedValue(new Error('Command not found')); + + const result = await validator.checkCompactAvailable(); + + expect(result).toBe(false); + expect(mockExec).toHaveBeenCalledWith('compact --version'); + }); + }); + + describe('getDevToolsVersion', () => { + it('should return trimmed version string', async () => { + mockExec.mockResolvedValue({ stdout: ' compact 0.1.0 \n', stderr: '' }); + + const version = await validator.getDevToolsVersion(); + + expect(version).toBe('compact 0.1.0'); + expect(mockExec).toHaveBeenCalledWith('compact --version'); + }); + + it('should throw error when command fails', async () => { + mockExec.mockRejectedValue(new Error('Command failed')); + + await expect(validator.getDevToolsVersion()).rejects.toThrow( + 'Command failed', + ); + }); + }); + + describe('getToolchainVersion', () => { + it('should get version without specific version flag', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compactc version: 0.24.0', + stderr: '', + }); + + const version = await validator.getToolchainVersion(); + + expect(version).toBe('Compactc version: 0.24.0'); + expect(mockExec).toHaveBeenCalledWith('compact compile --version'); + }); + + it('should get version with specific version flag', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compactc version: 0.24.0', + stderr: '', + }); + + const version = await validator.getToolchainVersion('0.24.0'); + + expect(version).toBe('Compactc version: 0.24.0'); + expect(mockExec).toHaveBeenCalledWith( + 'compact compile +0.24.0 --version', + ); + }); + }); + + describe('validate', () => { + it('should validate successfully when CLI is available', async () => { + mockExec.mockResolvedValue({ stdout: 'compact 0.1.0', stderr: '' }); + + await expect(validator.validate()).resolves.not.toThrow(); + }); + + it('should throw CompactCliNotFoundError when CLI is not available', async () => { + mockExec.mockRejectedValue(new Error('Command not found')); + + await expect(validator.validate()).rejects.toThrow( + CompactCliNotFoundError, + ); + }); + }); +}); + +describe('FileDiscovery', () => { + let discovery: FileDiscovery; + + beforeEach(() => { + vi.clearAllMocks(); + discovery = new FileDiscovery(); + }); + + describe('getCompactFiles', () => { + it('should find .compact files in directory', async () => { + const mockDirents = [ + { + name: 'MyToken.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'Ownable.compact', + isFile: () => true, + isDirectory: () => false, + }, + { name: 'README.md', isFile: () => true, isDirectory: () => false }, + { name: 'utils', isFile: () => false, isDirectory: () => true }, + ]; + + mockReaddir + .mockResolvedValueOnce(mockDirents as any) + .mockResolvedValueOnce([ + { + name: 'Utils.compact', + isFile: () => true, + isDirectory: () => false, + }, + ] as any); + + const files = await discovery.getCompactFiles('src'); + + expect(files).toEqual([ + 'MyToken.compact', + 'Ownable.compact', + 'utils/Utils.compact', + ]); + }); + + it('should handle empty directories', async () => { + mockReaddir.mockResolvedValue([]); + + const files = await discovery.getCompactFiles('src'); + + expect(files).toEqual([]); + }); + + it('should handle directory read errors gracefully', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockReaddir.mockRejectedValueOnce(new Error('Permission denied')); + + const files = await discovery.getCompactFiles('src'); + + expect(files).toEqual([]); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to read dir: src', + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + + it('should handle file access errors gracefully', async () => { + const mockDirents = [ + { + name: 'MyToken.compact', + isFile: () => { + throw new Error('Access denied'); + }, + isDirectory: () => false, + }, + { + name: 'Ownable.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + + mockReaddir.mockResolvedValue(mockDirents as any); + + const files = await discovery.getCompactFiles('src'); + + expect(files).toEqual(['Ownable.compact']); + }); + }); +}); + +describe('CompilerService', () => { + let mockExec: MockedFunction; + let service: CompilerService; + + beforeEach(() => { + vi.clearAllMocks(); + mockExec = vi.fn(); + service = new CompilerService(mockExec); + }); + + describe('compileFile', () => { + it('should compile file successfully with basic flags', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compilation successful', + stderr: '', + }); + + const result = await service.compileFile('MyToken.compact', '--skip-zk'); + + expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); + expect(mockExec).toHaveBeenCalledWith( + 'compact compile --skip-zk "src/MyToken.compact" "artifacts/MyToken"', + ); + }); + + it('should compile file with version flag', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compilation successful', + stderr: '', + }); + + const result = await service.compileFile( + 'MyToken.compact', + '--skip-zk', + '0.24.0', + ); + + expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); + expect(mockExec).toHaveBeenCalledWith( + 'compact compile +0.24.0 --skip-zk "src/MyToken.compact" "artifacts/MyToken"', + ); + }); + + it('should handle empty flags', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compilation successful', + stderr: '', + }); + + const result = await service.compileFile('MyToken.compact', ''); + + expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); + expect(mockExec).toHaveBeenCalledWith( + 'compact compile "src/MyToken.compact" "artifacts/MyToken"', + ); + }); + + it('should throw CompilationError when compilation fails', async () => { + mockExec.mockRejectedValue(new Error('Syntax error on line 10')); + + await expect( + service.compileFile('MyToken.compact', '--skip-zk'), + ).rejects.toThrow(CompilationError); + }); + + it('should include file path in CompilationError', async () => { + mockExec.mockRejectedValue(new Error('Syntax error')); + + try { + await service.compileFile('MyToken.compact', '--skip-zk'); + } catch (error) { + expect(error).toBeInstanceOf(CompilationError); + expect((error as CompilationError).file).toBe('MyToken.compact'); + } + }); + }); +}); + +describe('UIService', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + describe('printOutput', () => { + it('should format output with indentation', () => { + const mockColorFn = vi.fn((text: string) => `colored(${text})`); + + UIService.printOutput('line 1\nline 2\n\nline 3', mockColorFn); + + expect(mockColorFn).toHaveBeenCalledWith( + ' line 1\n line 2\n line 3', + ); + expect(console.log).toHaveBeenCalledWith( + 'colored( line 1\n line 2\n line 3)', + ); + }); + + it('should handle empty output', () => { + const mockColorFn = vi.fn((text: string) => `colored(${text})`); + + UIService.printOutput('', mockColorFn); + + expect(mockColorFn).toHaveBeenCalledWith(''); + expect(console.log).toHaveBeenCalledWith('colored()'); + }); + }); + + describe('displayEnvInfo', () => { + it('should display environment information with all parameters', () => { + UIService.displayEnvInfo( + 'compact 0.1.0', + 'Compactc 0.24.0', + 'security', + '0.24.0', + ); + + expect(mockSpinner.info).toHaveBeenCalledWith( + '[COMPILE] TARGET_DIR: security', + ); + expect(mockSpinner.info).toHaveBeenCalledWith( + '[COMPILE] Compact developer tools: compact 0.1.0', + ); + expect(mockSpinner.info).toHaveBeenCalledWith( + '[COMPILE] Compact toolchain: Compactc 0.24.0', + ); + expect(mockSpinner.info).toHaveBeenCalledWith( + '[COMPILE] Using toolchain version: 0.24.0', + ); + }); + + it('should display environment information without optional parameters', () => { + UIService.displayEnvInfo('compact 0.1.0', 'Compactc 0.24.0'); + + expect(mockSpinner.info).toHaveBeenCalledWith( + '[COMPILE] Compact developer tools: compact 0.1.0', + ); + expect(mockSpinner.info).toHaveBeenCalledWith( + '[COMPILE] Compact toolchain: Compactc 0.24.0', + ); + expect(mockSpinner.info).not.toHaveBeenCalledWith( + expect.stringContaining('TARGET_DIR'), + ); + expect(mockSpinner.info).not.toHaveBeenCalledWith( + expect.stringContaining('Using toolchain version'), + ); + }); + }); + + describe('showCompilationStart', () => { + it('should show file count without target directory', () => { + UIService.showCompilationStart(5); + + expect(mockSpinner.info).toHaveBeenCalledWith( + '[COMPILE] Found 5 .compact file(s) to compile', + ); + }); + + it('should show file count with target directory', () => { + UIService.showCompilationStart(3, 'security'); + + expect(mockSpinner.info).toHaveBeenCalledWith( + '[COMPILE] Found 3 .compact file(s) to compile in security/', + ); + }); + }); + + describe('showNoFiles', () => { + it('should show no files message with target directory', () => { + UIService.showNoFiles('security'); + + expect(mockSpinner.warn).toHaveBeenCalledWith( + '[COMPILE] No .compact files found in security/.', + ); + }); + + it('should show no files message without target directory', () => { + UIService.showNoFiles(); + + expect(mockSpinner.warn).toHaveBeenCalledWith( + '[COMPILE] No .compact files found in .', + ); + }); + }); +}); + +describe('CompactCompiler', () => { + let mockExec: MockedFunction; + let compiler: CompactCompiler; + + beforeEach(() => { + vi.clearAllMocks(); + mockExec = vi.fn().mockResolvedValue({ stdout: 'success', stderr: '' }); + mockExistsSync.mockReturnValue(true); + mockReaddir.mockResolvedValue([]); + }); + + describe('constructor', () => { + it('should create instance with default parameters', () => { + compiler = new CompactCompiler(); + + expect(compiler).toBeInstanceOf(CompactCompiler); + }); + + it('should create instance with all parameters', () => { + compiler = new CompactCompiler( + '--skip-zk', + 'security', + '0.24.0', + mockExec, + ); + + expect(compiler).toBeInstanceOf(CompactCompiler); + }); + + it('should trim flags', () => { + compiler = new CompactCompiler(' --skip-zk --verbose '); + expect(compiler.testFlags).toBe('--skip-zk --verbose'); + }); + }); + + describe('fromArgs', () => { + it('should parse empty arguments', () => { + compiler = CompactCompiler.fromArgs([]); + + expect(compiler.testFlags).toBe(''); + expect(compiler.testTargetDir).toBeUndefined(); + expect(compiler.testVersion).toBeUndefined(); + }); + + it('should handle SKIP_ZK environment variable', () => { + compiler = CompactCompiler.fromArgs([], { SKIP_ZK: 'true' }); + + expect(compiler.testFlags).toBe('--skip-zk'); + }); + + it('should ignore SKIP_ZK when not "true"', () => { + compiler = CompactCompiler.fromArgs([], { SKIP_ZK: 'false' }); + + expect(compiler.testFlags).toBe(''); + }); + + it('should parse --dir flag', () => { + compiler = CompactCompiler.fromArgs(['--dir', 'security']); + + expect(compiler.testTargetDir).toBe('security'); + expect(compiler.testFlags).toBe(''); + }); + + it('should parse --dir flag with additional flags', () => { + compiler = CompactCompiler.fromArgs([ + '--dir', + 'security', + '--skip-zk', + '--verbose', + ]); + + expect(compiler.testTargetDir).toBe('security'); + expect(compiler.testFlags).toBe('--skip-zk --verbose'); + }); + + it('should parse version flag', () => { + compiler = CompactCompiler.fromArgs(['+0.24.0']); + + expect(compiler.testVersion).toBe('0.24.0'); + expect(compiler.testFlags).toBe(''); + }); + + it('should parse complex arguments', () => { + compiler = CompactCompiler.fromArgs([ + '--dir', + 'security', + '--skip-zk', + '--verbose', + '+0.24.0', + ]); + + expect(compiler.testTargetDir).toBe('security'); + expect(compiler.testFlags).toBe('--skip-zk --verbose'); + expect(compiler.testVersion).toBe('0.24.0'); + }); + + it('should combine environment variables with CLI flags', () => { + compiler = CompactCompiler.fromArgs(['--dir', 'access', '--verbose'], { + SKIP_ZK: 'true', + }); + + expect(compiler.testTargetDir).toBe('access'); + expect(compiler.testFlags).toBe('--skip-zk --verbose'); + }); + + it('should deduplicate flags when both env var and CLI flag are present', () => { + compiler = CompactCompiler.fromArgs(['--skip-zk', '--verbose'], { + SKIP_ZK: 'true', + }); + + expect(compiler.testFlags).toBe('--skip-zk --verbose'); + }); + + it('should throw error for --dir without argument', () => { + expect(() => CompactCompiler.fromArgs(['--dir'])).toThrow( + '--dir flag requires a directory name', + ); + }); + + it('should throw error for --dir followed by another flag', () => { + expect(() => CompactCompiler.fromArgs(['--dir', '--skip-zk'])).toThrow( + '--dir flag requires a directory name', + ); + }); + }); + + describe('validateEnvironment', () => { + it('should validate successfully and display environment info', async () => { + mockExec + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) // checkCompactAvailable + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) // getDevToolsVersion + .mockResolvedValueOnce({ + stdout: 'Compactc version: 0.24.0', + stderr: '', + }); // getToolchainVersion + + compiler = new CompactCompiler( + '--skip-zk', + 'security', + '0.24.0', + mockExec, + ); + const displaySpy = vi + .spyOn(UIService, 'displayEnvInfo') + .mockImplementation(() => {}); + + await expect(compiler.validateEnvironment()).resolves.not.toThrow(); + + // Check steps + expect(mockExec).toHaveBeenCalledTimes(3); + expect(mockExec).toHaveBeenNthCalledWith(1, 'compact --version'); // validate() calls + expect(mockExec).toHaveBeenNthCalledWith(2, 'compact --version'); // getDevToolsVersion() + expect(mockExec).toHaveBeenNthCalledWith( + 3, + 'compact compile +0.24.0 --version', + ); // getToolchainVersion() + + // Verify passed args + expect(displaySpy).toHaveBeenCalledWith( + 'compact 0.1.0', + 'Compactc version: 0.24.0', + 'security', + '0.24.0', + ); + + displaySpy.mockRestore(); + }); + + it('should handle CompactCliNotFoundError with installation instructions', async () => { + mockExec.mockRejectedValue(new Error('Command not found')); + compiler = new CompactCompiler('', undefined, undefined, mockExec); + + await expect(compiler.validateEnvironment()).rejects.toThrow( + CompactCliNotFoundError, + ); + }); + + it('should handle version retrieval failures after successful CLI check', async () => { + mockExec + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) // validate() succeeds + .mockRejectedValueOnce(new Error('Version command failed')); // getDevToolsVersion() fails + + compiler = new CompactCompiler('', undefined, undefined, mockExec); + + await expect(compiler.validateEnvironment()).rejects.toThrow( + 'Version command failed', + ); + }); + + it('should handle PromisifiedChildProcessError specifically', async () => { + const childProcessError = new Error('Command execution failed') as any; + childProcessError.stdout = 'some output'; + childProcessError.stderr = 'some error'; + + mockExec.mockRejectedValue(childProcessError); + compiler = new CompactCompiler('', undefined, undefined, mockExec); + + await expect(compiler.validateEnvironment()).rejects.toThrow( + "'compact' CLI not found in PATH. Please install the Compact developer tools.", + ); + }); + + it('should handle non-Error exceptions gracefully', async () => { + mockExec.mockRejectedValue('String error message'); + compiler = new CompactCompiler('', undefined, undefined, mockExec); + + await expect(compiler.validateEnvironment()).rejects.toThrow( + CompactCliNotFoundError, + ); + }); + + it('should validate with specific version flag', async () => { + mockExec + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) + .mockResolvedValueOnce({ + stdout: 'Compactc version: 0.25.0', + stderr: '', + }); + + compiler = new CompactCompiler('', undefined, '0.25.0', mockExec); + const displaySpy = vi + .spyOn(UIService, 'displayEnvInfo') + .mockImplementation(() => {}); + + await compiler.validateEnvironment(); + + // Verify version-specific toolchain call + expect(mockExec).toHaveBeenNthCalledWith( + 3, + 'compact compile +0.25.0 --version', + ); + expect(displaySpy).toHaveBeenCalledWith( + 'compact 0.1.0', + 'Compactc version: 0.25.0', + undefined, // no targetDir + '0.25.0', + ); + + displaySpy.mockRestore(); + }); + + it('should validate without target directory or version', async () => { + mockExec + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) + .mockResolvedValueOnce({ + stdout: 'Compactc version: 0.24.0', + stderr: '', + }); + + compiler = new CompactCompiler('', undefined, undefined, mockExec); + const displaySpy = vi + .spyOn(UIService, 'displayEnvInfo') + .mockImplementation(() => {}); + + await compiler.validateEnvironment(); + + // Verify default toolchain call (no version flag) + expect(mockExec).toHaveBeenNthCalledWith(3, 'compact compile --version'); + expect(displaySpy).toHaveBeenCalledWith( + 'compact 0.1.0', + 'Compactc version: 0.24.0', + undefined, + undefined, + ); + + displaySpy.mockRestore(); + }); + }); + + describe('compile', () => { + it('should handle empty source directory', async () => { + mockReaddir.mockResolvedValue([]); + compiler = new CompactCompiler('', undefined, undefined, mockExec); + + await expect(compiler.compile()).resolves.not.toThrow(); + }); + + it('should throw error if target directory does not exist', async () => { + mockExistsSync.mockReturnValue(false); + compiler = new CompactCompiler('', 'nonexistent', undefined, mockExec); + + await expect(compiler.compile()).rejects.toThrow(DirectoryNotFoundError); + }); + + it('should compile files successfully', async () => { + const mockDirents = [ + { + name: 'MyToken.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'Ownable.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + mockReaddir.mockResolvedValue(mockDirents as any); + compiler = new CompactCompiler( + '--skip-zk', + undefined, + undefined, + mockExec, + ); + + await compiler.compile(); + + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('compact compile --skip-zk'), + ); + }); + + it('should handle compilation errors gracefully', async () => { + const brokenDirent = { + name: 'Broken.compact', + isFile: () => true, + isDirectory: () => false, + }; + + const mockDirents = [brokenDirent]; + mockReaddir.mockResolvedValue(mockDirents as any); + mockExistsSync.mockReturnValue(true); + + const testMockExec = vi + .fn() + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) // checkCompactAvailable + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) // getDevToolsVersion + .mockResolvedValueOnce({ stdout: 'Compactc 0.24.0', stderr: '' }) // getToolchainVersion + .mockRejectedValueOnce(new Error('Compilation failed')); // compileFile execution + + compiler = new CompactCompiler('', undefined, undefined, testMockExec); + + // Test that compilation errors are properly propagated + let thrownError: unknown; + try { + await compiler.compile(); + expect.fail('Expected compilation to throw an error'); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe( + `Failed to compile ${brokenDirent.name}: Compilation failed`, + ); + expect(testMockExec).toHaveBeenCalledTimes(4); + }); + }); + + describe('Real-world scenarios', () => { + beforeEach(() => { + const mockDirents = [ + { + name: 'AccessControl.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + mockReaddir.mockResolvedValue(mockDirents as any); + }); + + it('should handle turbo compact command', () => { + compiler = CompactCompiler.fromArgs([]); + + expect(compiler.testFlags).toBe(''); + expect(compiler.testTargetDir).toBeUndefined(); + }); + + it('should handle SKIP_ZK=true turbo compact command', () => { + compiler = CompactCompiler.fromArgs([], { SKIP_ZK: 'true' }); + + expect(compiler.testFlags).toBe('--skip-zk'); + }); + + it('should handle turbo compact:access command', () => { + compiler = CompactCompiler.fromArgs(['--dir', 'access']); + + expect(compiler.testFlags).toBe(''); + expect(compiler.testTargetDir).toBe('access'); + }); + + it('should handle turbo compact:security -- --skip-zk command', () => { + compiler = CompactCompiler.fromArgs(['--dir', 'security', '--skip-zk']); + + expect(compiler.testFlags).toBe('--skip-zk'); + expect(compiler.testTargetDir).toBe('security'); + }); + + it('should handle version specification', () => { + compiler = CompactCompiler.fromArgs(['+0.24.0']); + + expect(compiler.testVersion).toBe('0.24.0'); + }); + + it.each([ + { + name: 'with skip zk env var only', + args: [ + '--dir', + 'security', + '--no-communications-commitment', + '+0.24.0', + ], + env: { SKIP_ZK: 'true' }, + }, + { + name: 'with skip-zk flag only', + args: [ + '--dir', + 'security', + '--skip-zk', + '--no-communications-commitment', + '+0.24.0', + ], + env: { SKIP_ZK: 'false' }, + }, + { + name: 'with both skip-zk flag and env var', + args: [ + '--dir', + 'security', + '--skip-zk', + '--no-communications-commitment', + '+0.24.0', + ], + env: { SKIP_ZK: 'true' }, + }, + ])('should handle complex command $name', ({ args, env }) => { + compiler = CompactCompiler.fromArgs(args, env); + + expect(compiler.testFlags).toBe( + '--skip-zk --no-communications-commitment', + ); + expect(compiler.testTargetDir).toBe('security'); + expect(compiler.testVersion).toBe('0.24.0'); + }); + }); +}); diff --git a/compact/test/runCompiler.test.ts b/compact/test/runCompiler.test.ts new file mode 100644 index 00000000..11244f2d --- /dev/null +++ b/compact/test/runCompiler.test.ts @@ -0,0 +1,458 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CompactCompiler } from '../src/Compiler.js'; +import { + CompactCliNotFoundError, + CompilationError, + DirectoryNotFoundError, + isPromisifiedChildProcessError, +} from '../src/types/errors.js'; + +// Mock CompactCompiler +vi.mock('../src/Compiler.js', () => ({ + CompactCompiler: { + fromArgs: vi.fn(), + }, +})); + +// Mock error utilities +vi.mock('../src/types/errors.js', async () => { + const actual = await vi.importActual('../src/types/errors.js'); + return { + ...actual, + isPromisifiedChildProcessError: vi.fn(), + }; +}); + +// Mock chalk +vi.mock('chalk', () => ({ + default: { + blue: (text: string) => text, + red: (text: string) => text, + yellow: (text: string) => text, + gray: (text: string) => text, + }, +})); + +// Mock ora +const mockSpinner = { + info: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), +}; +vi.mock('ora', () => ({ + default: vi.fn(() => mockSpinner), +})); + +// Mock process.exit +const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + +// Mock console methods +const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); + +describe('runCompiler CLI', () => { + let mockCompile: ReturnType; + let mockFromArgs: ReturnType; + let originalArgv: string[]; + + beforeEach(() => { + // Store original argv + originalArgv = [...process.argv]; + + vi.clearAllMocks(); + vi.resetModules(); + + mockCompile = vi.fn(); + mockFromArgs = vi.mocked(CompactCompiler.fromArgs); + + // Mock CompactCompiler instance + mockFromArgs.mockReturnValue({ + compile: mockCompile, + } as any); + + // Clear all mock calls + mockSpinner.info.mockClear(); + mockSpinner.fail.mockClear(); + mockSpinner.succeed.mockClear(); + mockConsoleLog.mockClear(); + mockExit.mockClear(); + }); + + afterEach(() => { + // Restore original argv + process.argv = originalArgv; + }); + + describe('successful compilation', () => { + it('should compile successfully with no arguments', async () => { + mockCompile.mockResolvedValue(undefined); + + // Import and run the CLI + await import('../src/runCompiler.js'); + + expect(mockFromArgs).toHaveBeenCalledWith([]); + expect(mockCompile).toHaveBeenCalled(); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('should compile successfully with arguments', async () => { + process.argv = [ + 'node', + 'runCompiler.js', + '--dir', + 'security', + '--skip-zk', + ]; + mockCompile.mockResolvedValue(undefined); + + await import('../src/runCompiler.js'); + + expect(mockFromArgs).toHaveBeenCalledWith([ + '--dir', + 'security', + '--skip-zk', + ]); + expect(mockCompile).toHaveBeenCalled(); + expect(mockExit).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should handle CompactCliNotFoundError with installation instructions', async () => { + const error = new CompactCliNotFoundError('CLI not found'); + mockCompile.mockRejectedValue(error); + + await import('../src/runCompiler.js'); + + expect(mockSpinner.fail).toHaveBeenCalledWith( + '[COMPILE] Error: CLI not found', + ); + expect(mockSpinner.info).toHaveBeenCalledWith( + "[COMPILE] Install with: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh", + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle DirectoryNotFoundError with helpful message', async () => { + const error = new DirectoryNotFoundError( + 'Directory not found', + 'src/nonexistent', + ); + mockCompile.mockRejectedValue(error); + + await import('../src/runCompiler.js'); + + expect(mockSpinner.fail).toHaveBeenCalledWith( + '[COMPILE] Error: Directory not found', + ); + expect(mockConsoleLog).toHaveBeenCalledWith('\nAvailable directories:'); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir access # Compile access control contracts', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir archive # Compile archive contracts', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir security # Compile security contracts', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir token # Compile token contracts', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir utils # Compile utility contracts', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle CompilationError with file context', async () => { + const error = new CompilationError( + 'Compilation failed', + 'MyToken.compact', + ); + mockCompile.mockRejectedValue(error); + + await import('../src/runCompiler.js'); + + expect(mockSpinner.fail).toHaveBeenCalledWith( + '[COMPILE] Compilation failed for file: MyToken.compact', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle CompilationError with unknown file', async () => { + const error = new CompilationError('Compilation failed'); + mockCompile.mockRejectedValue(error); + + await import('../src/runCompiler.js'); + + expect(mockSpinner.fail).toHaveBeenCalledWith( + '[COMPILE] Compilation failed for file: unknown', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle argument parsing errors', async () => { + const error = new Error('--dir flag requires a directory name'); + mockFromArgs.mockImplementation(() => { + throw error; + }); + + await import('../src/runCompiler.js'); + + expect(mockSpinner.fail).toHaveBeenCalledWith( + '[COMPILE] Error: --dir flag requires a directory name', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + '\nUsage: compact-compiler [options]', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle unexpected errors', async () => { + const msg = 'Something unexpected happened'; + const error = new Error(msg); + mockCompile.mockRejectedValue(error); + + await import('../src/runCompiler.js'); + + expect(mockSpinner.fail).toHaveBeenCalledWith( + `[COMPILE] Unexpected error: ${msg}`, + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + '\nIf this error persists, please check:', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' • Compact CLI is installed and in PATH', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' • Source files exist and are readable', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' • Specified Compact version exists', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' • File system permissions are correct', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle non-Error exceptions', async () => { + const msg = 'String error'; + mockCompile.mockRejectedValue(msg); + + await import('../src/runCompiler.js'); + + expect(mockSpinner.fail).toHaveBeenCalledWith( + `[COMPILE] Unexpected error: ${msg}`, + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); + + describe('environment validation errors', () => { + it('should handle promisified child process errors', async () => { + const mockIsPromisifiedChildProcessError = vi.mocked( + isPromisifiedChildProcessError, + ); + + const error = { + message: 'Command failed', + stdout: 'some output', + stderr: 'error details', + }; + + // Return true for this specific error + mockIsPromisifiedChildProcessError.mockImplementation( + (err) => err === error, + ); + mockCompile.mockRejectedValue(error); + + await import('../src/runCompiler.js'); + + expect(mockIsPromisifiedChildProcessError).toHaveBeenCalledWith(error); + expect(mockSpinner.fail).toHaveBeenCalledWith( + '[COMPILE] Environment validation failed: Command failed', + ); + expect(mockConsoleLog).toHaveBeenCalledWith('\nTroubleshooting:'); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' • Check that Compact CLI is installed and in PATH', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' • Verify the specified Compact version exists', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' • Ensure you have proper permissions', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); + + describe('usage help', () => { + it('should show complete usage help for argument parsing errors', async () => { + const error = new Error('--dir flag requires a directory name'); + mockFromArgs.mockImplementation(() => { + throw error; + }); + + await import('../src/runCompiler.js'); + + // Verify all sections of help are shown + expect(mockConsoleLog).toHaveBeenCalledWith( + '\nUsage: compact-compiler [options]', + ); + expect(mockConsoleLog).toHaveBeenCalledWith('\nOptions:'); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir Compile specific directory (access, archive, security, token, utils)', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --skip-zk Skip zero-knowledge proof generation', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' + Use specific toolchain version (e.g., +0.24.0)', + ); + expect(mockConsoleLog).toHaveBeenCalledWith('\nExamples:'); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' compact-compiler # Compile all files', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' compact-compiler --dir security # Compile security directory', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' compact-compiler --dir access --skip-zk # Compile access with flags', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' SKIP_ZK=true compact-compiler --dir token # Use environment variable', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' compact-compiler --skip-zk +0.24.0 # Use specific version', + ); + expect(mockConsoleLog).toHaveBeenCalledWith('\nTurbo integration:'); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' turbo compact # Full build', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' turbo compact:security -- --skip-zk # Directory with flags', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' SKIP_ZK=true turbo compact # Environment variables', + ); + }); + }); + + describe('directory error help', () => { + it('should show all available directories', async () => { + const error = new DirectoryNotFoundError( + 'Directory not found', + 'src/invalid', + ); + mockCompile.mockRejectedValue(error); + + await import('../src/runCompiler.js'); + + expect(mockConsoleLog).toHaveBeenCalledWith('\nAvailable directories:'); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir access # Compile access control contracts', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir archive # Compile archive contracts', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir security # Compile security contracts', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir token # Compile token contracts', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --dir utils # Compile utility contracts', + ); + }); + }); + + describe('real-world command scenarios', () => { + beforeEach(() => { + mockCompile.mockResolvedValue(undefined); + }); + + it('should handle turbo compact', async () => { + process.argv = ['node', 'runCompiler.js']; + + await import('../src/runCompiler.js'); + + expect(mockFromArgs).toHaveBeenCalledWith([]); + }); + + it('should handle turbo compact:security', async () => { + process.argv = ['node', 'runCompiler.js', '--dir', 'security']; + + await import('../src/runCompiler.js'); + + expect(mockFromArgs).toHaveBeenCalledWith(['--dir', 'security']); + }); + + it('should handle turbo compact:access -- --skip-zk', async () => { + process.argv = ['node', 'runCompiler.js', '--dir', 'access', '--skip-zk']; + + await import('../src/runCompiler.js'); + + expect(mockFromArgs).toHaveBeenCalledWith([ + '--dir', + 'access', + '--skip-zk', + ]); + }); + + it('should handle version specification', async () => { + process.argv = ['node', 'runCompiler.js', '+0.24.0', '--skip-zk']; + + await import('../src/runCompiler.js'); + + expect(mockFromArgs).toHaveBeenCalledWith(['+0.24.0', '--skip-zk']); + }); + + it('should handle complex command', async () => { + process.argv = [ + 'node', + 'runCompiler.js', + '--dir', + 'security', + '--skip-zk', + '--verbose', + '+0.24.0', + ]; + + await import('../src/runCompiler.js'); + + expect(mockFromArgs).toHaveBeenCalledWith([ + '--dir', + 'security', + '--skip-zk', + '--verbose', + '+0.24.0', + ]); + }); + }); + + describe('integration with CompactCompiler', () => { + it('should pass arguments correctly to CompactCompiler.fromArgs', async () => { + const args = ['--dir', 'token', '--skip-zk', '+0.24.0']; + process.argv = ['node', 'runCompiler.js', ...args]; + mockCompile.mockResolvedValue(undefined); + + await import('../src/runCompiler.js'); + + expect(mockFromArgs).toHaveBeenCalledWith(args); + expect(mockFromArgs).toHaveBeenCalledTimes(1); + expect(mockCompile).toHaveBeenCalledTimes(1); + }); + + it('should handle empty arguments', async () => { + process.argv = ['node', 'runCompiler.js']; + mockCompile.mockResolvedValue(undefined); + + await import('../src/runCompiler.js'); + + expect(mockFromArgs).toHaveBeenCalledWith([]); + }); + }); +}); diff --git a/compact/vitest.config.ts b/compact/vitest.config.ts new file mode 100644 index 00000000..d57e53a7 --- /dev/null +++ b/compact/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + reporters: 'verbose', + }, +}); diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc index 55289646..05a2a246 100644 --- a/docs/modules/ROOT/pages/index.adoc +++ b/docs/modules/ROOT/pages/index.adoc @@ -1,7 +1,8 @@ :midnight: https://midnight.network/[Midnight] :nvm: https://github.com/nvm-sh/nvm[nvm] :yarn: https://yarnpkg.com/getting-started/install[yarn] -:compact-installation: https://docs.midnight.network/develop/tutorial/building/#midnight-compact-compiler[compact installation] +:turbo: https://turborepo.com/docs/getting-started/installation[turbo] +:compact-dev-tools: https://docs.midnight.network/blog/compact-developer-tools[Compact Developer Tools] = Contracts for Compact @@ -12,19 +13,18 @@ WARNING: This repo contains highly experimental code. Expect rapid iteration. *U == Installation -Make sure you have {nvm} and {yarn} installed on your machine. +Make sure you have {nvm}, {yarn}, and {turbo} installed on your machine. -Follow Midnight's {compact-installation} guide and confirm that `compactc` is in the `PATH` env variable. +Follow Midnight's {compact-dev-tools} installation guide and confirm that `compact` is in the `PATH` env variable. ```bash -$ compactc +$ compact compile --version Compactc version: 0.24.0 -Usage: compactc.bin ... - --help displays detailed usage information +0.24.0 ``` -=== Project setup +=== Set up the project Clone the repository: @@ -37,7 +37,7 @@ git clone git@github.com:OpenZeppelin/midnight-contracts.git ```bash nvm install && \ yarn && \ -yarn prepare +turbo compact ``` == Usage @@ -45,7 +45,7 @@ yarn prepare Compile the contracts: ```bash -$ npx turbo compact +$ turbo compact (...) ✔ [COMPILE] [1/2] Compiled FungibleToken.compact @@ -81,10 +81,10 @@ Cached: 0 cached, 2 total ``` NOTE: Speed up the development process by skipping the prover and verifier key file generation: + -`npx turbo compact -- --skip-zk` +`SKIP_ZK=true turbo compact` Run tests: ```bash -npx turbo test +turbo test ``` diff --git a/turbo.json b/turbo.json index f6041193..b52c9c45 100644 --- a/turbo.json +++ b/turbo.json @@ -59,7 +59,7 @@ "package.json" ], "outputs": [], - "cache": true + "cache": false }, "build": { "dependsOn": ["^build"], diff --git a/yarn.lock b/yarn.lock index 12014759..b628db43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -405,6 +405,7 @@ __metadata: log-symbols: "npm:^7.0.0" ora: "npm:^8.2.0" typescript: "npm:^5.8.2" + vitest: "npm:^3.1.3" bin: compact-builder: dist/runBuilder.js compact-compiler: dist/runCompiler.js