diff --git a/Gemfile.lock b/Gemfile.lock index 9d1fe2f..cdf5b22 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,9 @@ PATH specs: ruby_wasm_ui (0.8.3) js (~> 2.7) + listen (~> 3.8) + puma (~> 6.0) + rack (~> 3.0) ruby_wasm (~> 2.7) GEM @@ -11,20 +14,37 @@ GEM date (3.5.0) diff-lcs (1.6.2) erb (6.0.0) + ffi (1.17.2) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) io-console (0.8.1) irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) js (2.7.2) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) logger (1.7.0) + nio4r (2.7.5) pp (0.6.3) prettyprint prettyprint (0.2.0) psych (5.2.6) date stringio + puma (6.6.1) + nio4r (~> 2.0) + rack (3.2.4) rake (13.3.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) rdoc (6.15.1) erb psych (>= 4.0.0) diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..64fd3c0 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,3 @@ +*.wasm +/rubies +/build diff --git a/examples/Gemfile.lock b/examples/Gemfile.lock index 96a3b2b..c3cac2c 100644 --- a/examples/Gemfile.lock +++ b/examples/Gemfile.lock @@ -3,36 +3,34 @@ PATH specs: ruby_wasm_ui (0.8.3) js (~> 2.7) + listen (~> 3.8) + puma (~> 6.0) + rack (~> 3.0) ruby_wasm (~> 2.7) GEM remote: https://rubygems.org/ specs: + ffi (1.17.2-arm64-darwin) js (2.7.2) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) logger (1.7.0) + nio4r (2.7.5) + puma (6.6.1) + nio4r (~> 2.0) + rack (3.2.4) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) ruby_wasm (2.7.2) logger - ruby_wasm (2.7.2-aarch64-linux) - logger - ruby_wasm (2.7.2-aarch64-linux-musl) - logger ruby_wasm (2.7.2-arm64-darwin) logger - ruby_wasm (2.7.2-x86_64-darwin) - logger - ruby_wasm (2.7.2-x86_64-linux) - logger - ruby_wasm (2.7.2-x86_64-linux-musl) - logger PLATFORMS - aarch64-linux - aarch64-linux-musl arm64-darwin - ruby - x86_64-darwin - x86_64-linux - x86_64-linux-musl DEPENDENCIES ruby_wasm_ui! diff --git a/examples/src/index.html b/examples/src/index.html new file mode 100644 index 0000000..d671269 --- /dev/null +++ b/examples/src/index.html @@ -0,0 +1,21 @@ + + + + + My App + + + +

My App

+
+ + diff --git a/examples/src/index.rb b/examples/src/index.rb new file mode 100644 index 0000000..4e540bb --- /dev/null +++ b/examples/src/index.rb @@ -0,0 +1,26 @@ +# Simple Hello World component +HelloComponent = RubyWasmUi.define_component( + state: ->(props) { + { message: props[:message] || "Hello, Ruby WASM UI!" } + }, + template: ->() { + RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding) +
+

{state[:message]}

+ +
+ HTML + }, + methods: { + update_message: ->() { + update_state(message: "You clicked the button!") + } + } +) + +# Create and mount the app +app = RubyWasmUi::App.create(HelloComponent, message: "Hello, Ruby WASM UI!") +app_element = JS.global[:document].getElementById("app") +app.mount(app_element) diff --git a/exe/ruby-wasm-ui b/exe/ruby-wasm-ui new file mode 100755 index 0000000..f4ff316 --- /dev/null +++ b/exe/ruby-wasm-ui @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/ruby_wasm_ui/cli/command" + +RubyWasmUi::Cli::Command.run(ARGV) diff --git a/lib/ruby_wasm_ui/cli/command.rb b/lib/ruby_wasm_ui/cli/command.rb new file mode 100644 index 0000000..4b0a2a8 --- /dev/null +++ b/lib/ruby_wasm_ui/cli/command.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "command/setup" +require_relative "command/dev" +require_relative "command/pack" +require_relative "command/rebuild" + +module RubyWasmUi + module Cli + class Command + COMMANDS = { + "setup" => Command::Setup, + "dev" => Command::Dev, + "pack" => Command::Pack, + "rebuild" => Command::Rebuild + }.freeze + + def self.run(argv) + command_name = argv[0] + + if command_name.nil? + show_usage + raise SystemExit.new(1) + end + + command_class = COMMANDS[command_name] + if command_class.nil? + puts "Unknown command: #{command_name}" + puts "" + show_usage + raise SystemExit.new(1) + end + + command_class.new.run(argv[1..-1]) + end + + def self.show_usage + puts "Usage: ruby-wasm-ui " + puts "" + puts "Commands:" + COMMANDS.each do |name, klass| + description = klass.description || "" + puts " #{name.ljust(12)}#{description}" + end + end + end + end +end diff --git a/lib/ruby_wasm_ui/cli/command/base.rb b/lib/ruby_wasm_ui/cli/command/base.rb new file mode 100644 index 0000000..7113e19 --- /dev/null +++ b/lib/ruby_wasm_ui/cli/command/base.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "open3" +require "bundler/setup" +require "ruby_wasm" +require "ruby_wasm/cli" + +module RubyWasmUi + module Cli + class Command + class Base + def self.description + nil + end + + def run(_argv) + raise NotImplementedError, "Subclasses must implement #run" + end + + protected + + def run_command(command, exit_on_error: true) + log_debug("Executing command: #{command}") + + Open3.popen3(command) do |stdin, stdout, stderr, wait_thr| + stdin.close + + # Read stdout and stderr in separate threads for real-time output + stdout_thread = Thread.new do + stdout.each_line do |line| + print line + $stdout.flush + end + end + + stderr_thread = Thread.new do + stderr.each_line do |line| + $stderr.print line + $stderr.flush + end + end + + # Wait for both threads to finish reading + stdout_thread.join + stderr_thread.join + + # Wait for the process to finish + exit_status = wait_thr.value + + unless exit_status.success? + log_error("Command failed: #{command}") + if exit_on_error + raise SystemExit.new(exit_status.exitstatus) + else + return false + end + end + + true + end + end + + def ensure_src_directory + unless Dir.exist?("src") + log_error("src directory not found. Please run 'ruby-wasm-ui setup' first.") + raise SystemExit.new(1) + end + end + + def ensure_ruby_wasm + unless File.exist?("ruby.wasm") + log_error("ruby.wasm not found. Please run 'ruby-wasm-ui setup' first.") + raise SystemExit.new(1) + end + end + + def update_gitignore(entries_to_add) + gitignore_path = ".gitignore" + + # Read existing .gitignore or create new content + if File.exist?(gitignore_path) + content = File.read(gitignore_path) + lines = content.lines.map(&:chomp) + else + lines = [] + end + + # Add entries that don't already exist + added_entries = [] + entries_to_add.each do |entry| + unless lines.include?(entry) + lines << entry + added_entries << entry + end + end + + # Write back to .gitignore + File.write(gitignore_path, lines.join("\n") + "\n") + if added_entries.any? + log_info("Added to .gitignore: #{added_entries.join(', ')}") + else + log_info("No new entries added to .gitignore (all entries already exist)") + end + end + + protected + + def log_info(message) + puts "[INFO] #{message}" + end + + def log_success(message) + puts "[SUCCESS] #{message}" + end + + def log_error(message) + puts "[ERROR] #{message}" + end + + def log_debug(message) + puts "[DEBUG] #{message}" + end + + def check_ruby_version + ruby_version = RUBY_VERSION.split('.').map(&:to_i) + major, minor = ruby_version[0], ruby_version[1] + + if major < 3 || (major == 3 && minor < 2) + log_error("Ruby WASM requires Ruby 3.2 or higher. Current version: #{RUBY_VERSION}") + raise SystemExit.new(1) + end + + "#{major}.#{minor}" + end + + def configure_excluded_gems + # Get all gems from Bundler definition including all dependencies + definition = Bundler.definition + resolved = definition.resolve + + # Get all resolved specs (including transitive dependencies) + all_specs = resolved.materialize(definition.requested_dependencies) + gem_names = all_specs.map(&:name).uniq + + # Always exclude gems that cause WASM build errors + # These gems have native extensions that don't work in WASM environment + always_excluded = %w[nio4r puma rack listen ffi] + + # Exclude gems that cause build errors or are unnecessary for WASM + # Keep essential gems like 'js' for WASM + excluded_gems = gem_names.select do |gem_name| + # Exclude gems that cause WASM build errors + always_excluded.include?(gem_name) || + # Exclude development/test gems + gem_name.start_with?("rspec", "rubocop", "rake") + end + + # Always add always_excluded gems to ensure they're excluded even if not in dependencies + excluded_gems.concat(always_excluded) + excluded_gems.uniq! + + # Add to EXCLUDED_GEMS + RubyWasm::Packager::EXCLUDED_GEMS.concat(excluded_gems) + end + + def build_ruby_wasm(ruby_version_str) + command = ["build", "--ruby-version", ruby_version_str, "-o", "ruby.wasm"] + cli = RubyWasm::CLI.new(stdout: $stdout, stderr: $stderr) + cli.run(command) + end + end + end + end +end diff --git a/lib/ruby_wasm_ui/cli/command/dev.rb b/lib/ruby_wasm_ui/cli/command/dev.rb new file mode 100644 index 0000000..3b0be3e --- /dev/null +++ b/lib/ruby_wasm_ui/cli/command/dev.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require_relative "base" +require "listen" +require "rack" +require "thread" +require "rbconfig" + +module RubyWasmUi + module Cli + class Command + class Dev < Base + def self.description + "Start development server with file watching and auto-build" + end + + def run(_argv) + log_info("Starting development server...") + puts "" + + ensure_src_directory + + # Initial build + log_info("Performing initial build...") + build + log_success("✓ Initial build completed") + puts "" + + # Start file watcher in a separate thread + @build_lock = Mutex.new + @build_queue = Queue.new + @listener = nil + @watcher_thread = nil + + @watcher_thread = Thread.new do + start_file_watcher + end + + # Register cleanup hook to ensure cleanup runs on exit + @cleanup_done = false + at_exit do + cleanup unless @cleanup_done + end + + # Start development server + begin + start_server + ensure + cleanup + end + end + + private + + def open_browser(url) + case RbConfig::CONFIG["host_os"] + when /darwin/ + system("open", url) + when /linux/ + system("xdg-open", url) + when /mswin|mingw|cygwin/ + system("start", url) + else + log_info("Please open #{url} in your browser") + end + rescue => e + log_info("Could not open browser automatically: #{e.message}") + log_info("Please open #{url} in your browser") + end + + def cleanup + return if @cleanup_done + @cleanup_done = true + @listener&.stop + @watcher_thread&.kill + end + + def build + command = "bundle exec rbwasm pack ruby.wasm --dir ./src::./src -o src.wasm" + log_info("Building: #{command}") + + success = run_command(command, exit_on_error: false) + if success + log_success("✓ Build completed") + else + log_error("Build failed") + end + success + end + + def start_file_watcher + @listener = Listen.to("src") do |modified, added, removed| + files_changed = (modified + added + removed).reject do |file| + # Ignore temporary files and common build artifacts + file.end_with?(".swp", ".tmp", "~", ".wasm") + end + + if files_changed.any? + log_info("Files changed: #{files_changed.join(', ')}") + + # Debounce: add to queue + @build_queue << :build + + # Process queue with debouncing + Thread.new do + sleep 0.5 # Debounce delay + begin + if @build_queue.pop(true) + @build_queue.clear + @build_lock.synchronize do + build + end + end + rescue ThreadError + # Queue is empty, ignore + end + end + end + end + + @listener.start + rescue => e + log_error("File watcher error: #{e.message}") + end + + def start_server + port = 8080 + log_info("Starting development server on http://localhost:#{port}") + log_info("Press Ctrl+C to stop") + puts "" + + # Static file server application + static_app = lambda do |env| + path = env["PATH_INFO"] + if path == "/" + file_path = File.join(Dir.pwd, "src", "index.html") + else + file_path = File.join(Dir.pwd, path) + end + + if File.exist?(file_path) && File.file?(file_path) + content_type = Rack::Mime.mime_type(File.extname(file_path), "text/html") + [200, { "Content-Type" => content_type }, [File.read(file_path)]] + else + [404, { "Content-Type" => "text/plain" }, ["File not found: #{path}"]] + end + end + + # CORS middleware + cors_middleware = lambda do |env| + if env["REQUEST_METHOD"] == "OPTIONS" + [ + 200, + { + "Access-Control-Allow-Origin" => "*", + "Access-Control-Allow-Methods" => "GET, POST, OPTIONS", + "Access-Control-Allow-Headers" => "Content-Type" + }, + [] + ] + else + status, headers, body = static_app.call(env) + headers["Access-Control-Allow-Origin"] = "*" + headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + headers["Access-Control-Allow-Headers"] = "Content-Type" + [status, headers, body] + end + end + + # Create Rack application + app = Rack::Builder.new do + run cors_middleware + end + + # Handle shutdown + trap("INT") do + log_info("\nShutting down server...") + cleanup + raise SystemExit.new(0) + end + + # Use Puma handler (Rack 3.0 compatible) + begin + require "rack/handler/puma" + log_info("Using handler: puma") + + # Open browser after a short delay to ensure server is ready + Thread.new do + sleep 1 + open_browser("http://localhost:#{port}/src/index.html") + end + + Rack::Handler::Puma.run(app, Port: port, Host: "0.0.0.0") + rescue LoadError => e + log_error("Puma handler not available: #{e.message}") + log_error("Please ensure puma gem is installed: gem install puma") + raise SystemExit.new(1) + end + rescue => e + log_error("Server error: #{e.message}") + raise SystemExit.new(1) + end + end + end + end +end diff --git a/lib/ruby_wasm_ui/cli/command/pack.rb b/lib/ruby_wasm_ui/cli/command/pack.rb new file mode 100644 index 0000000..b2c313a --- /dev/null +++ b/lib/ruby_wasm_ui/cli/command/pack.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "base" + +module RubyWasmUi + module Cli + class Command + class Pack < Base + def self.description + "Pack WASM file by packing Ruby source files" + end + + def run(_argv) + log_info("Packing WASM file...") + puts "" + + ensure_src_directory + ensure_ruby_wasm + + # Pack WASM file + pack + end + + private + + def pack + command = "bundle exec rbwasm pack ruby.wasm --dir ./src::./src -o src.wasm" + log_info("Packing: #{command}") + + run_command(command) + log_success("✓ Pack completed") + end + end + end + end +end diff --git a/lib/ruby_wasm_ui/cli/command/rebuild.rb b/lib/ruby_wasm_ui/cli/command/rebuild.rb new file mode 100644 index 0000000..6128853 --- /dev/null +++ b/lib/ruby_wasm_ui/cli/command/rebuild.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative "base" + +module RubyWasmUi + module Cli + class Command + class Rebuild < Base + def self.description + "Rebuild Ruby WASM file (useful when gems are added)" + end + + def run(_argv) + log_info("Rebuilding Ruby WASM...") + puts "" + + # Check Ruby version + ruby_version_str = check_ruby_version + + # Configure excluded gems for WASM build + log_info("Step 1/2: Configuring excluded gems...") + configure_excluded_gems + log_success("✓ Excluded gems configured") + + # Build Ruby WASM + puts "" + log_info("Step 2/2: Building Ruby WASM...") + log_info("Running: rbwasm build --ruby-version #{ruby_version_str} -o ruby.wasm") + build_ruby_wasm(ruby_version_str) + log_success("✓ Ruby WASM build completed") + + puts "" + log_success("Rebuild completed successfully!") + end + end + end + end +end diff --git a/lib/ruby_wasm_ui/cli/command/setup.rb b/lib/ruby_wasm_ui/cli/command/setup.rb new file mode 100644 index 0000000..d750df4 --- /dev/null +++ b/lib/ruby_wasm_ui/cli/command/setup.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require_relative "base" + +module RubyWasmUi + module Cli + class Command + class Setup < Base + def self.description + "Set up the project for ruby-wasm-ui" + end + + def run(_argv) + log_info("Setting up ruby-wasm-ui project...") + puts "" + + # Check Ruby version + ruby_version_str = check_ruby_version + + # Configure excluded gems for WASM build + log_info("Step 1/3: Configuring excluded gems...") + configure_excluded_gems + log_success("✓ Excluded gems configured") + + # Build Ruby WASM + puts "" + log_info("Step 2/3: Building Ruby WASM...") + log_info("Running: rbwasm build --ruby-version #{ruby_version_str} -o ruby.wasm") + build_ruby_wasm(ruby_version_str) + log_success("✓ Ruby WASM build completed") + + # Update .gitignore + puts "" + log_info("Step 3/4: Updating .gitignore...") + update_gitignore(["*.wasm", "/rubies", "/build"]) + log_success("✓ .gitignore updated") + + # Create initial files + puts "" + log_info("Step 4/4: Creating initial files...") + create_initial_files + + puts "" + log_success("Setup completed successfully!") + end + + private + + def create_initial_files + # Skip if src directory exists + if Dir.exist?("src") + log_info("src directory already exists, skipping initial file creation") + return + end + + # Skip if files already exist + if File.exist?("src/index.html") + log_info("src/index.html already exists, skipping initial file creation") + return + end + + if File.exist?("src/index.rb") + log_info("src/index.rb already exists, skipping initial file creation") + return + end + + # Create src directory + Dir.mkdir("src") + + # Create index.html + File.write("src/index.html", <<~HTML) + + + + + My App + + + +

My App

+
+ + + HTML + + # Create index.rb + File.write("src/index.rb", <<~RUBY) + # Simple Hello World component + HelloComponent = RubyWasmUi.define_component( + state: ->(props) { + { message: props[:message] || "Hello, Ruby WASM UI!" } + }, + template: ->() { + RubyWasmUi::Template::Parser.parse_and_eval(<<~HTML, binding) +
+

{state[:message]}

+ +
+ HTML + }, + methods: { + update_message: ->() { + update_state(message: "You clicked the button!") + } + } + ) + + # Create and mount the app + app = RubyWasmUi::App.create(HelloComponent, message: "Hello, Ruby WASM UI!") + app_element = JS.global[:document].getElementById("app") + app.mount(app_element) + RUBY + + log_success("✓ Initial files created: src/index.html, src/index.rb") + end + end + end + end +end diff --git a/packages/npm-packages/runtime/rollup.config.mjs b/packages/npm-packages/runtime/rollup.config.mjs index ba92262..b45b8c9 100644 --- a/packages/npm-packages/runtime/rollup.config.mjs +++ b/packages/npm-packages/runtime/rollup.config.mjs @@ -18,7 +18,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // Find all Ruby files in ruby_wasm_ui directory const rubyFiles = glob - .sync("src/ruby_wasm_ui/**/*.rb") + .sync("src/ruby_wasm_ui/runtime/**/*.rb") .map((file) => file.replace("src/", "")) .sort((a, b) => { // Files in root directory should be loaded first diff --git a/ruby_wasm_ui.gemspec b/ruby_wasm_ui.gemspec index b7982c8..b1f7904 100644 --- a/ruby_wasm_ui.gemspec +++ b/ruby_wasm_ui.gemspec @@ -34,6 +34,9 @@ Gem::Specification.new do |spec| # Uncomment to register a new dependency of your gem spec.add_dependency "ruby_wasm", "~> 2.7" spec.add_dependency "js", "~> 2.7" + spec.add_dependency "listen", "~> 3.8" + spec.add_dependency "rack", "~> 3.0" + spec.add_dependency "puma", "~> 6.0" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/spec/ruby_wasm_ui/cli/command/base_spec.rb b/spec/ruby_wasm_ui/cli/command/base_spec.rb new file mode 100644 index 0000000..cd749f4 --- /dev/null +++ b/spec/ruby_wasm_ui/cli/command/base_spec.rb @@ -0,0 +1,358 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' +require 'tmpdir' + +RSpec.describe RubyWasmUi::Cli::Command::Base do + let(:base_instance) { described_class.new } + + describe '#run' do + it 'raises NotImplementedError' do + expect { base_instance.run([]) }.to raise_error(NotImplementedError, /Subclasses must implement #run/) + end + end + + describe '#update_gitignore' do + let(:temp_dir) { Dir.mktmpdir } + let(:original_dir) { Dir.pwd } + + around do |example| + Dir.chdir(temp_dir) do + example.run + end + ensure + FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir) + end + + context 'when .gitignore does not exist' do + it 'creates .gitignore with new entries' do + base_instance.send(:update_gitignore, ['*.wasm', '/rubies']) + content = File.read('.gitignore') + expect(content).to include('*.wasm') + expect(content).to include('/rubies') + end + end + + context 'when .gitignore exists' do + before do + File.write('.gitignore', "node_modules/\n*.log\n") + end + + it 'adds new entries to existing .gitignore' do + base_instance.send(:update_gitignore, ['*.wasm', '/rubies']) + content = File.read('.gitignore') + expect(content).to include('node_modules/') + expect(content).to include('*.log') + expect(content).to include('*.wasm') + expect(content).to include('/rubies') + end + + it 'does not add duplicate entries' do + File.write('.gitignore', "*.wasm\n") + base_instance.send(:update_gitignore, ['*.wasm', '/rubies']) + content = File.read('.gitignore') + expect(content.scan(/^\*\.wasm$/).count).to eq(1) + end + + it 'outputs message when entries are added' do + expect { base_instance.send(:update_gitignore, ['*.wasm']) }.to output( + /Added to .gitignore: \*\.wasm/ + ).to_stdout + end + + it 'outputs message when no entries are added' do + File.write('.gitignore', "*.wasm\n") + expect { base_instance.send(:update_gitignore, ['*.wasm']) }.to output( + /No new entries added to .gitignore/ + ).to_stdout + end + end + end + + describe '#run_command' do + context 'when command succeeds' do + it 'executes the command and outputs stdout' do + expect { base_instance.send(:run_command, 'echo "test output"') }.to output( + /test output/ + ).to_stdout + end + + it 'does not raise error' do + expect { base_instance.send(:run_command, 'echo "test"') }.not_to raise_error + end + + it 'returns true' do + result = base_instance.send(:run_command, 'echo "test"') + expect(result).to be true + end + end + + context 'when command fails' do + it 'outputs error message and exits by default' do + expect { base_instance.send(:run_command, 'false') }.to output( + /Command failed: false/ + ).to_stdout.and raise_error(SystemExit) + end + + it 'exits with correct status code' do + expect { base_instance.send(:run_command, 'false') }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + + context 'when exit_on_error is false' do + it 'outputs error message but does not exit' do + expect { base_instance.send(:run_command, 'false', exit_on_error: false) }.to output( + /Command failed: false/ + ).to_stdout + expect { base_instance.send(:run_command, 'false', exit_on_error: false) }.not_to raise_error + end + + it 'returns false' do + result = base_instance.send(:run_command, 'false', exit_on_error: false) + expect(result).to be false + end + end + end + end + + describe '#ensure_src_directory' do + let(:temp_dir) { Dir.mktmpdir } + let(:original_dir) { Dir.pwd } + + around do |example| + Dir.chdir(temp_dir) do + example.run + end + ensure + FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir) + end + + context 'when src directory does not exist' do + it 'outputs error message and exits' do + expect { base_instance.send(:ensure_src_directory) }.to output( + /src directory not found. Please run 'ruby-wasm-ui setup' first./ + ).to_stdout.and raise_error(SystemExit) + end + + it 'exits with status 1' do + expect { base_instance.send(:ensure_src_directory) }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + end + + context 'when src directory exists' do + before do + FileUtils.mkdir_p('src') + end + + it 'does not raise error' do + expect { base_instance.send(:ensure_src_directory) }.not_to raise_error + end + + it 'does not output error message' do + expect { base_instance.send(:ensure_src_directory) }.not_to output( + /src directory not found/ + ).to_stdout + end + end + end + + describe '#ensure_ruby_wasm' do + let(:temp_dir) { Dir.mktmpdir } + let(:original_dir) { Dir.pwd } + + around do |example| + Dir.chdir(temp_dir) do + example.run + end + ensure + FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir) + end + + context 'when ruby.wasm does not exist' do + it 'outputs error message and exits' do + expect { base_instance.send(:ensure_ruby_wasm) }.to output( + /ruby.wasm not found. Please run 'ruby-wasm-ui setup' first./ + ).to_stdout.and raise_error(SystemExit) + end + + it 'exits with status 1' do + expect { base_instance.send(:ensure_ruby_wasm) }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + end + + context 'when ruby.wasm exists' do + before do + FileUtils.touch('ruby.wasm') + end + + it 'does not raise error' do + expect { base_instance.send(:ensure_ruby_wasm) }.not_to raise_error + end + + it 'does not output error message' do + expect { base_instance.send(:ensure_ruby_wasm) }.not_to output( + /ruby.wasm not found/ + ).to_stdout + end + end + end + + describe '#check_ruby_version' do + context 'when Ruby version is 3.2 or higher' do + before do + stub_const('RUBY_VERSION', '3.2.0') + end + + it 'returns the version string' do + result = base_instance.send(:check_ruby_version) + expect(result).to eq('3.2') + end + + it 'does not exit' do + expect { base_instance.send(:check_ruby_version) }.not_to raise_error + end + end + + context 'when Ruby version is 3.4' do + before do + stub_const('RUBY_VERSION', '3.4.0') + end + + it 'returns the version string' do + result = base_instance.send(:check_ruby_version) + expect(result).to eq('3.4') + end + end + + context 'when Ruby version is less than 3.2' do + context 'when Ruby version is 3.1' do + before do + stub_const('RUBY_VERSION', '3.1.5') + end + + it 'outputs error message and exits' do + expect { base_instance.send(:check_ruby_version) }.to output( + /\[ERROR\] Ruby WASM requires Ruby 3.2 or higher. Current version: 3.1.5/ + ).to_stdout.and raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + end + + context 'when Ruby version is 2.7' do + before do + stub_const('RUBY_VERSION', '2.7.8') + end + + it 'outputs error message and exits' do + expect { base_instance.send(:check_ruby_version) }.to output( + /\[ERROR\] Ruby WASM requires Ruby 3.2 or higher. Current version: 2.7.8/ + ).to_stdout.and raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + end + end + end + + describe '#configure_excluded_gems' do + before do + # Clear EXCLUDED_GEMS before each test + RubyWasm::Packager::EXCLUDED_GEMS.clear if defined?(RubyWasm::Packager::EXCLUDED_GEMS) + end + + it 'excludes rack, puma, nio4r, listen, and ffi gems' do + allow(Bundler).to receive(:definition).and_return( + double( + resolve: double( + materialize: [ + double(name: 'rack'), + double(name: 'puma'), + double(name: 'nio4r'), + double(name: 'listen'), + double(name: 'ffi'), + double(name: 'js'), + double(name: 'ruby_wasm') + ] + ), + requested_dependencies: [] + ) + ) + + base_instance.send(:configure_excluded_gems) + + expect(RubyWasm::Packager::EXCLUDED_GEMS).to include('rack', 'puma', 'nio4r', 'listen', 'ffi') + expect(RubyWasm::Packager::EXCLUDED_GEMS).not_to include('js', 'ruby_wasm') + end + + it 'always excludes nio4r, puma, rack, listen, and ffi even if not in dependencies' do + allow(Bundler).to receive(:definition).and_return( + double( + resolve: double( + materialize: [ + double(name: 'js'), + double(name: 'ruby_wasm') + ] + ), + requested_dependencies: [] + ) + ) + + base_instance.send(:configure_excluded_gems) + + # Always excluded gems should be in the list even if not in dependencies + expect(RubyWasm::Packager::EXCLUDED_GEMS).to include('nio4r', 'puma', 'rack', 'listen', 'ffi') + expect(RubyWasm::Packager::EXCLUDED_GEMS).not_to include('js', 'ruby_wasm') + end + + it 'excludes development/test gems' do + allow(Bundler).to receive(:definition).and_return( + double( + resolve: double( + materialize: [ + double(name: 'rspec'), + double(name: 'rubocop'), + double(name: 'rake'), + double(name: 'js') + ] + ), + requested_dependencies: [] + ) + ) + + base_instance.send(:configure_excluded_gems) + + expect(RubyWasm::Packager::EXCLUDED_GEMS).to include('rspec', 'rubocop', 'rake') + expect(RubyWasm::Packager::EXCLUDED_GEMS).not_to include('js') + end + end + + describe '#build_ruby_wasm' do + let(:cli_instance) { instance_double(RubyWasm::CLI) } + + before do + allow(RubyWasm::CLI).to receive(:new).and_return(cli_instance) + allow(cli_instance).to receive(:run) + end + + it 'executes rbwasm build command with correct arguments' do + expect(RubyWasm::CLI).to receive(:new).with(stdout: $stdout, stderr: $stderr).and_return(cli_instance) + expect(cli_instance).to receive(:run).with(['build', '--ruby-version', '3.2', '-o', 'ruby.wasm']) + + base_instance.send(:build_ruby_wasm, '3.2') + end + + it 'executes rbwasm build command with different version' do + expect(RubyWasm::CLI).to receive(:new).with(stdout: $stdout, stderr: $stderr).and_return(cli_instance) + expect(cli_instance).to receive(:run).with(['build', '--ruby-version', '3.4', '-o', 'ruby.wasm']) + + base_instance.send(:build_ruby_wasm, '3.4') + end + end +end diff --git a/spec/ruby_wasm_ui/cli/command/dev_spec.rb b/spec/ruby_wasm_ui/cli/command/dev_spec.rb new file mode 100644 index 0000000..edcca7e --- /dev/null +++ b/spec/ruby_wasm_ui/cli/command/dev_spec.rb @@ -0,0 +1,412 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' +require 'tmpdir' + +RSpec.describe RubyWasmUi::Cli::Command::Dev do + let(:dev_instance) { described_class.new } + let(:temp_dir) { Dir.mktmpdir } + let(:original_dir) { Dir.pwd } + + around do |example| + begin + Dir.chdir(temp_dir) do + example.run + end + ensure + # 確実に元のディレクトリに戻る + begin + Dir.chdir(original_dir) if Dir.exist?(original_dir) + rescue => e + # ディレクトリが存在しない場合は無視 + end + FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir) + end + end + + describe '.description' do + it 'returns the description' do + expect(described_class.description).to eq('Start development server with file watching and auto-build') + end + end + + describe '#run' do + let(:watcher_thread) { instance_double(Thread, kill: nil) } + + before do + allow(dev_instance).to receive(:build).and_return(true) + allow(dev_instance).to receive(:start_file_watcher) + allow(dev_instance).to receive(:start_server) + allow(Thread).to receive(:new).and_return(watcher_thread) + end + + context 'when src directory does not exist' do + it 'outputs error message and exits' do + expect { dev_instance.run([]) }.to output( + /src directory not found. Please run 'ruby-wasm-ui setup' first./ + ).to_stdout.and raise_error(SystemExit) + end + + it 'exits with status 1' do + expect { dev_instance.run([]) }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + end + + context 'when src directory exists' do + before do + FileUtils.mkdir_p('src') + end + + it 'outputs starting message' do + expect { dev_instance.run([]) }.to output( + /Starting development server/ + ).to_stdout + end + + it 'performs initial build' do + expect(dev_instance).to receive(:build).and_return(true) + dev_instance.run([]) + end + + it 'outputs initial build completion message' do + expect { dev_instance.run([]) }.to output( + /Initial build completed/ + ).to_stdout + end + + it 'starts file watcher' do + file_watcher_called = false + # Allow Thread.new to actually create threads so start_file_watcher gets called + allow(Thread).to receive(:new).and_call_original + allow(dev_instance).to receive(:start_file_watcher) do + file_watcher_called = true + end + # Mock start_server to block briefly to allow thread to execute + allow(dev_instance).to receive(:start_server) do + sleep 0.1 # Give thread time to execute + end + + dev_instance.run([]) + + expect(file_watcher_called).to be true + end + + it 'starts development server' do + allow(dev_instance).to receive(:start_file_watcher).and_return(nil) + expect(dev_instance).to receive(:start_server) + dev_instance.run([]) + end + + context 'when build fails' do + before do + allow(dev_instance).to receive(:build).and_return(false) + end + + it 'still starts the server' do + expect(dev_instance).to receive(:start_server) + dev_instance.run([]) + end + end + end + end + + describe '#build' do + before do + FileUtils.mkdir_p('src') + end + + context 'when command succeeds' do + before do + allow(dev_instance).to receive(:run_command).and_return(true) + end + + it 'executes rbwasm pack command via run_command' do + expect(dev_instance).to receive(:run_command).with( + 'bundle exec rbwasm pack ruby.wasm --dir ./src::./src -o src.wasm', + exit_on_error: false + ) + dev_instance.send(:build) + end + + it 'outputs build message' do + expect { dev_instance.send(:build) }.to output( + /Building: bundle exec rbwasm pack/ + ).to_stdout + end + + it 'outputs success message' do + expect { dev_instance.send(:build) }.to output( + /Build completed/ + ).to_stdout + end + + it 'returns true' do + result = dev_instance.send(:build) + expect(result).to be true + end + end + + context 'when command fails' do + before do + allow(dev_instance).to receive(:run_command).and_return(false) + end + + it 'outputs error message' do + expect { dev_instance.send(:build) }.to output( + /Build failed/ + ).to_stdout + end + + it 'returns false' do + result = dev_instance.send(:build) + expect(result).to be false + end + end + end + + describe '#start_file_watcher' do + before do + FileUtils.mkdir_p('src') + @listener = instance_double(Listen::Listener, start: nil, stop: nil) + allow(Listen).to receive(:to).and_return(@listener) + end + + it 'starts listening to src directory' do + expect(Listen).to receive(:to).with('src') + dev_instance.send(:start_file_watcher) + end + + it 'starts the listener' do + expect(@listener).to receive(:start) + dev_instance.send(:start_file_watcher) + end + + context 'when file changes occur' do + let(:callback_container) { {} } + + before do + allow(Listen).to receive(:to) do |_dir, &block| + callback_container[:callback] = block + @listener + end + dev_instance.instance_variable_set(:@build_lock, Mutex.new) + dev_instance.instance_variable_set(:@build_queue, Queue.new) + allow(dev_instance).to receive(:build) + allow(dev_instance).to receive(:log_info) + end + + it 'triggers rebuild on file change' do + dev_instance.send(:start_file_watcher) + callback = callback_container[:callback] + expect(callback).not_to be_nil + + # Allow Thread.new to create actual threads for the debounce logic + allow(Thread).to receive(:new).and_call_original + + callback.call(['src/app.rb'], [], []) + sleep 0.6 # Wait for debounce + expect(dev_instance).to have_received(:build) + end + + it 'ignores temporary files' do + dev_instance.send(:start_file_watcher) + callback = callback_container[:callback] + expect(callback).not_to be_nil + + # Allow Thread.new to create actual threads for the debounce logic + allow(Thread).to receive(:new).and_call_original + + callback.call(['src/app.rb.swp'], [], []) + sleep 0.6 + expect(dev_instance).not_to have_received(:build) + end + end + + context 'when listener raises an error' do + before do + allow(Listen).to receive(:to).and_raise(StandardError.new('Listener error')) + end + + it 'outputs error message' do + expect { dev_instance.send(:start_file_watcher) }.to output( + /File watcher error: Listener error/ + ).to_stdout + end + end + end + + describe '#start_server' do + let(:puma_handler_class) do + Class.new do + def self.run(app, options = {}) + # Blocking call - will be mocked + end + end + end + + before do + FileUtils.mkdir_p('src') + end + + context 'when Puma handler is available' do + before do + stub_const('Rack::Handler::Puma', puma_handler_class) + # Mock require to prevent LoadError for rack/handler/puma + allow(dev_instance).to receive(:require).with('rack/handler/puma').and_return(true) + # Prevent browser from opening in all tests by default + allow(dev_instance).to receive(:open_browser) + # Prevent Thread.new from creating actual threads by default + allow(Thread).to receive(:new).and_return(instance_double(Thread)) + end + + it 'outputs server start message' do + allow(Rack::Handler::Puma).to receive(:run) + expect { dev_instance.send(:start_server) }.to output( + a_string_including('Starting development server on http://localhost:8080') + ).to_stdout + end + + it 'outputs instructions message' do + allow(Rack::Handler::Puma).to receive(:run) + expect { dev_instance.send(:start_server) }.to output( + a_string_including('Press Ctrl+C to stop') + ).to_stdout + end + + it 'starts Puma server' do + expect(Rack::Handler::Puma).to receive(:run).with( + anything, + Port: 8080, + Host: '0.0.0.0' + ) + dev_instance.send(:start_server) + end + + it 'outputs handler info message' do + allow(Rack::Handler::Puma).to receive(:run) + expect { dev_instance.send(:start_server) }.to output( + a_string_including('Using handler: puma') + ).to_stdout + end + + it 'opens browser after server starts' do + allow(Rack::Handler::Puma).to receive(:run) + browser_thread_called = false + + allow(Thread).to receive(:new) do |&block| + if block + browser_thread_called = true + # Execute block immediately for testing (without sleep) + allow(Kernel).to receive(:sleep) + block.call + end + instance_double(Thread) + end + + expect(dev_instance).to receive(:open_browser).with('http://localhost:8080/src/index.html').and_return(nil) + + dev_instance.send(:start_server) + + expect(browser_thread_called).to be true + end + end + + context 'when Puma handler is not available' do + before do + allow(dev_instance).to receive(:require).with('rack/handler/puma').and_raise(LoadError.new('cannot load such file')) + end + + it 'outputs error message' do + expect { dev_instance.send(:start_server) }.to output( + /Puma handler not available/ + ).to_stdout.and raise_error(SystemExit) + end + + it 'exits with status 1' do + expect { dev_instance.send(:start_server) }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + end + + context 'when server raises an error' do + before do + stub_const('Rack::Handler::Puma', puma_handler_class) + allow(dev_instance).to receive(:require).with('rack/handler/puma').and_return(true) + allow(Rack::Handler::Puma).to receive(:run).and_raise(StandardError.new('Server error')) + end + + it 'outputs error message and exits' do + expect { dev_instance.send(:start_server) }.to output( + /Server error: Server error/ + ).to_stdout.and raise_error(SystemExit) + end + end + end + + describe '#open_browser' do + before do + FileUtils.mkdir_p('src') + end + + context 'on macOS' do + before do + allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('darwin') + end + + it 'calls open command' do + expect(dev_instance).to receive(:system).with('open', 'http://localhost:8080/src/index.html').and_return(true) + dev_instance.send(:open_browser, 'http://localhost:8080/src/index.html') + end + + context 'when system call fails' do + before do + allow(dev_instance).to receive(:system).and_raise(StandardError.new('Command failed')) + end + + it 'outputs fallback message' do + expect { dev_instance.send(:open_browser, 'http://localhost:8080/src/index.html') }.to output( + /Please open http:\/\/localhost:8080\/src\/index.html in your browser/ + ).to_stdout + end + end + end + + context 'on Linux' do + before do + allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('linux') + end + + it 'calls xdg-open command' do + expect(dev_instance).to receive(:system).with('xdg-open', 'http://localhost:8080/src/index.html').and_return(true) + dev_instance.send(:open_browser, 'http://localhost:8080/src/index.html') + end + end + + context 'on Windows' do + before do + allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('mswin') + end + + it 'calls start command' do + expect(dev_instance).to receive(:system).with('start', 'http://localhost:8080/src/index.html').and_return(true) + dev_instance.send(:open_browser, 'http://localhost:8080/src/index.html') + end + end + + context 'on unknown OS' do + before do + allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('unknown') + end + + it 'outputs manual instruction' do + expect { dev_instance.send(:open_browser, 'http://localhost:8080/src/index.html') }.to output( + /Please open http:\/\/localhost:8080\/src\/index.html in your browser/ + ).to_stdout + end + end + end +end diff --git a/spec/ruby_wasm_ui/cli/command/pack_spec.rb b/spec/ruby_wasm_ui/cli/command/pack_spec.rb new file mode 100644 index 0000000..4937b4e --- /dev/null +++ b/spec/ruby_wasm_ui/cli/command/pack_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' +require 'tmpdir' + +RSpec.describe RubyWasmUi::Cli::Command::Pack do + let(:pack_instance) { described_class.new } + let(:temp_dir) { Dir.mktmpdir } + let(:original_dir) { Dir.pwd } + + around do |example| + Dir.chdir(temp_dir) do + example.run + end + ensure + Dir.chdir(original_dir) + FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir) + end + + describe '.description' do + it 'returns the description' do + expect(described_class.description).to eq('Pack WASM file by packing Ruby source files') + end + end + + describe '#run' do + context 'when src directory does not exist' do + it 'outputs error message and exits' do + expect { pack_instance.run([]) }.to output( + /src directory not found. Please run 'ruby-wasm-ui setup' first./ + ).to_stdout.and raise_error(SystemExit) + end + + it 'exits with status 1' do + expect { pack_instance.run([]) }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + end + + context 'when src directory exists but ruby.wasm does not exist' do + before do + FileUtils.mkdir_p('src') + end + + it 'outputs error message and exits' do + expect { pack_instance.run([]) }.to output( + /ruby.wasm not found. Please run 'ruby-wasm-ui setup' first./ + ).to_stdout.and raise_error(SystemExit) + end + + it 'exits with status 1' do + expect { pack_instance.run([]) }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + end + + context 'when src directory and ruby.wasm exist' do + before do + FileUtils.mkdir_p('src') + FileUtils.touch('ruby.wasm') + allow(pack_instance).to receive(:pack) + end + + it 'outputs packing message' do + expect { pack_instance.run([]) }.to output( + /Packing WASM file/ + ).to_stdout + end + + it 'calls pack method' do + expect(pack_instance).to receive(:pack) + pack_instance.run([]) + end + end + end + + describe '#pack' do + before do + FileUtils.mkdir_p('src') + FileUtils.touch('ruby.wasm') + end + + context 'when command succeeds' do + before do + allow(pack_instance).to receive(:run_command).and_return(true) + end + + it 'executes rbwasm pack command via run_command' do + expect(pack_instance).to receive(:run_command).with( + 'bundle exec rbwasm pack ruby.wasm --dir ./src::./src -o src.wasm' + ) + pack_instance.send(:pack) + end + + it 'outputs pack message' do + expect { pack_instance.send(:pack) }.to output( + /Packing: bundle exec rbwasm pack/ + ).to_stdout + end + + it 'outputs success message' do + expect { pack_instance.send(:pack) }.to output( + /Pack completed/ + ).to_stdout + end + end + + context 'when command fails' do + before do + allow(pack_instance).to receive(:run_command).and_raise(SystemExit.new(1)) + end + + it 'outputs error message and exits' do + expect { pack_instance.send(:pack) }.to raise_error(SystemExit) + end + + it 'exits with status 1' do + expect { pack_instance.send(:pack) }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + end + end +end diff --git a/spec/ruby_wasm_ui/cli/command/rebuild_spec.rb b/spec/ruby_wasm_ui/cli/command/rebuild_spec.rb new file mode 100644 index 0000000..bf646dc --- /dev/null +++ b/spec/ruby_wasm_ui/cli/command/rebuild_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyWasmUi::Cli::Command::Rebuild do + let(:rebuild_instance) { described_class.new } + + describe '.description' do + it 'returns the description' do + expect(described_class.description).to eq('Rebuild Ruby WASM file (useful when gems are added)') + end + end + + describe '#run' do + before do + allow(rebuild_instance).to receive(:check_ruby_version).and_return(RUBY_VERSION.split('.')[0..1].join('.')) + allow(rebuild_instance).to receive(:configure_excluded_gems) + allow(rebuild_instance).to receive(:build_ruby_wasm) + end + + it 'outputs rebuild message' do + expect { rebuild_instance.run([]) }.to output( + /Rebuilding Ruby WASM/ + ).to_stdout + end + + it 'checks Ruby version' do + expect(rebuild_instance).to receive(:check_ruby_version) + rebuild_instance.run([]) + end + + it 'configures excluded gems' do + expect(rebuild_instance).to receive(:configure_excluded_gems) + rebuild_instance.run([]) + end + + it 'executes rbwasm build command' do + ruby_version = RUBY_VERSION.split('.')[0..1].join('.') + expect(rebuild_instance).to receive(:build_ruby_wasm).with(ruby_version) + rebuild_instance.run([]) + end + + it 'outputs completion message' do + expect { rebuild_instance.run([]) }.to output( + /Rebuild completed successfully!/ + ).to_stdout + end + + it 'outputs progress messages' do + expect { rebuild_instance.run([]) }.to output( + /Configuring excluded gems/ + ).to_stdout + expect { rebuild_instance.run([]) }.to output( + /Building Ruby WASM/ + ).to_stdout + end + + it 'does not update .gitignore' do + expect(rebuild_instance).not_to respond_to(:update_gitignore) + rebuild_instance.run([]) + end + + it 'does not create initial files' do + expect(rebuild_instance).not_to respond_to(:create_initial_files) + rebuild_instance.run([]) + end + + context 'when Ruby version check fails' do + before do + allow(rebuild_instance).to receive(:check_ruby_version).and_raise(SystemExit.new(1)) + allow(Kernel).to receive(:exit) + end + + it 'does not execute subsequent steps' do + expect(rebuild_instance).not_to receive(:configure_excluded_gems) + expect(rebuild_instance).not_to receive(:build_ruby_wasm) + expect { rebuild_instance.run([]) }.to raise_error(SystemExit) + end + end + + context 'when Ruby version is 3.2' do + before do + stub_const('RUBY_VERSION', '3.2.0') + allow(rebuild_instance).to receive(:check_ruby_version).and_return('3.2') + allow(rebuild_instance).to receive(:configure_excluded_gems) + allow(rebuild_instance).to receive(:build_ruby_wasm) + end + + it 'executes rbwasm build command with correct version' do + expect(rebuild_instance).to receive(:build_ruby_wasm).with('3.2') + rebuild_instance.run([]) + end + end + end +end diff --git a/spec/ruby_wasm_ui/cli/command/setup_spec.rb b/spec/ruby_wasm_ui/cli/command/setup_spec.rb new file mode 100644 index 0000000..442b757 --- /dev/null +++ b/spec/ruby_wasm_ui/cli/command/setup_spec.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe RubyWasmUi::Cli::Command::Setup do + let(:setup_instance) { described_class.new } + + describe '.description' do + it 'returns the description' do + expect(described_class.description).to eq('Set up the project for ruby-wasm-ui') + end + end + + describe '#run' do + before do + allow(setup_instance).to receive(:check_ruby_version).and_return(RUBY_VERSION.split('.')[0..1].join('.')) + allow(setup_instance).to receive(:configure_excluded_gems) + allow(setup_instance).to receive(:build_ruby_wasm) + allow(setup_instance).to receive(:update_gitignore) + allow(setup_instance).to receive(:create_initial_files) + end + + it 'outputs setup message' do + expect { setup_instance.run([]) }.to output( + /Setting up ruby-wasm-ui project/ + ).to_stdout + end + + it 'checks Ruby version' do + expect(setup_instance).to receive(:check_ruby_version) + setup_instance.run([]) + end + + it 'configures excluded gems' do + expect(setup_instance).to receive(:configure_excluded_gems) + setup_instance.run([]) + end + + it 'executes rbwasm build command' do + ruby_version = RUBY_VERSION.split('.')[0..1].join('.') + expect(setup_instance).to receive(:build_ruby_wasm).with(ruby_version) + setup_instance.run([]) + end + + it 'updates .gitignore with required entries' do + expect(setup_instance).to receive(:update_gitignore).with( + ['*.wasm', '/rubies', '/build'] + ) + setup_instance.run([]) + end + + it 'outputs completion message' do + expect { setup_instance.run([]) }.to output( + /Setup completed successfully!/ + ).to_stdout + end + + it 'outputs progress messages' do + expect { setup_instance.run([]) }.to output( + /Configuring excluded gems/ + ).to_stdout + expect { setup_instance.run([]) }.to output( + /Building Ruby WASM/ + ).to_stdout + expect { setup_instance.run([]) }.to output( + /Updating .gitignore/ + ).to_stdout + expect { setup_instance.run([]) }.to output( + /Creating initial files/ + ).to_stdout + end + + it 'creates initial files' do + expect(setup_instance).to receive(:create_initial_files) + setup_instance.run([]) + end + + context 'when Ruby version check fails' do + before do + allow(setup_instance).to receive(:check_ruby_version).and_raise(SystemExit.new(1)) + allow(Kernel).to receive(:exit) + end + + it 'does not execute subsequent steps' do + expect(setup_instance).not_to receive(:configure_excluded_gems) + expect(setup_instance).not_to receive(:build_ruby_wasm) + expect(setup_instance).not_to receive(:update_gitignore) + expect(setup_instance).not_to receive(:create_initial_files) + expect { setup_instance.run([]) }.to raise_error(SystemExit) + end + end + + context 'when Ruby version is 3.2' do + before do + stub_const('RUBY_VERSION', '3.2.0') + allow(setup_instance).to receive(:check_ruby_version).and_return('3.2') + allow(setup_instance).to receive(:configure_excluded_gems) + allow(setup_instance).to receive(:build_ruby_wasm) + allow(setup_instance).to receive(:update_gitignore) + allow(setup_instance).to receive(:create_initial_files) + end + + it 'executes rbwasm build command with correct version' do + expect(setup_instance).to receive(:build_ruby_wasm).with('3.2') + setup_instance.run([]) + end + end + end + + describe '#create_initial_files' do + let(:temp_dir) { Dir.mktmpdir } + let(:src_dir) { File.join(temp_dir, 'src') } + + before do + allow(Dir).to receive(:pwd).and_return(temp_dir) + allow(Dir).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:write).and_call_original + allow(Dir).to receive(:mkdir).and_call_original + end + + after do + FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir) + end + + context 'when src directory does not exist' do + before do + # Ensure the directory doesn't exist before the test + FileUtils.rm_rf('src') if Dir.exist?('src') + end + + after do + # Clean up after test + FileUtils.rm_rf('src') if Dir.exist?('src') + end + + it 'creates src directory and initial files' do + expect(Dir).to receive(:exist?).with('src').and_return(false) + expect(File).to receive(:exist?).with('src/index.html').and_return(false) + expect(File).to receive(:exist?).with('src/index.rb').and_return(false) + expect(Dir).to receive(:mkdir).with('src').and_call_original + expect(File).to receive(:write).with('src/index.html', anything).and_call_original + expect(File).to receive(:write).with('src/index.rb', anything).and_call_original + + setup_instance.send(:create_initial_files) + end + end + + context 'when src directory already exists' do + it 'skips file creation' do + expect(Dir).to receive(:exist?).with('src').and_return(true) + expect(File).not_to receive(:write) + + expect { setup_instance.send(:create_initial_files) }.to output( + /src directory already exists, skipping initial file creation/ + ).to_stdout + end + end + + context 'when src/index.html already exists' do + it 'skips file creation' do + expect(Dir).to receive(:exist?).with('src').and_return(false) + expect(File).to receive(:exist?).with('src/index.html').and_return(true) + expect(File).not_to receive(:write) + + expect { setup_instance.send(:create_initial_files) }.to output( + /src\/index.html already exists, skipping initial file creation/ + ).to_stdout + end + end + + context 'when src/index.rb already exists' do + it 'skips file creation' do + expect(Dir).to receive(:exist?).with('src').and_return(false) + expect(File).to receive(:exist?).with('src/index.html').and_return(false) + expect(File).to receive(:exist?).with('src/index.rb').and_return(true) + expect(File).not_to receive(:write) + + expect { setup_instance.send(:create_initial_files) }.to output( + /src\/index.rb already exists, skipping initial file creation/ + ).to_stdout + end + end + end +end diff --git a/spec/ruby_wasm_ui/cli/command_spec.rb b/spec/ruby_wasm_ui/cli/command_spec.rb new file mode 100644 index 0000000..b754976 --- /dev/null +++ b/spec/ruby_wasm_ui/cli/command_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyWasmUi::Cli::Command do + describe '.run' do + context 'when command is provided' do + context 'with valid command' do + context 'setup command' do + let(:setup_instance) { instance_double(RubyWasmUi::Cli::Command::Setup) } + + before do + allow(RubyWasmUi::Cli::Command::Setup).to receive(:new).and_return(setup_instance) + allow(setup_instance).to receive(:run) + end + + it 'executes the setup command' do + expect(setup_instance).to receive(:run).with([]) + described_class.run(['setup']) + end + + it 'passes remaining arguments to the command' do + expect(setup_instance).to receive(:run).with(['arg1', 'arg2']) + described_class.run(['setup', 'arg1', 'arg2']) + end + end + + context 'dev command' do + let(:dev_instance) { instance_double(RubyWasmUi::Cli::Command::Dev) } + + before do + allow(RubyWasmUi::Cli::Command::Dev).to receive(:new).and_return(dev_instance) + allow(dev_instance).to receive(:run) + end + + it 'executes the dev command' do + expect(dev_instance).to receive(:run).with([]) + described_class.run(['dev']) + end + + it 'passes remaining arguments to the command' do + expect(dev_instance).to receive(:run).with(['arg1', 'arg2']) + described_class.run(['dev', 'arg1', 'arg2']) + end + end + + context 'rebuild command' do + let(:rebuild_instance) { instance_double(RubyWasmUi::Cli::Command::Rebuild) } + + before do + allow(RubyWasmUi::Cli::Command::Rebuild).to receive(:new).and_return(rebuild_instance) + allow(rebuild_instance).to receive(:run) + end + + it 'executes the rebuild command' do + expect(rebuild_instance).to receive(:run).with([]) + described_class.run(['rebuild']) + end + + it 'passes remaining arguments to the command' do + expect(rebuild_instance).to receive(:run).with(['arg1', 'arg2']) + described_class.run(['rebuild', 'arg1', 'arg2']) + end + end + end + + context 'with invalid command' do + it 'prints error message and shows usage' do + expect { described_class.run(['unknown']) }.to output( + /Unknown command: unknown/ + ).to_stdout.and raise_error(SystemExit) + end + end + end + + context 'when no command is provided' do + it 'shows usage and exits' do + expect { described_class.run([]) }.to output( + /Usage: ruby-wasm-ui / + ).to_stdout.and raise_error(SystemExit) + end + end + end + + describe '.show_usage' do + it 'displays usage information' do + expect { described_class.show_usage }.to output( + /Usage: ruby-wasm-ui / + ).to_stdout + end + + it 'lists available commands' do + expect { described_class.show_usage }.to output( + /setup.*Set up the project for ruby-wasm-ui/ + ).to_stdout + expect { described_class.show_usage }.to output( + /dev.*Start development server with file watching and auto-build/ + ).to_stdout + expect { described_class.show_usage }.to output( + /rebuild.*Rebuild Ruby WASM file/ + ).to_stdout + end + end + + describe 'COMMANDS' do + it 'contains setup command' do + expect(described_class::COMMANDS).to include('setup' => RubyWasmUi::Cli::Command::Setup) + end + + it 'contains dev command' do + expect(described_class::COMMANDS).to include('dev' => RubyWasmUi::Cli::Command::Dev) + end + + it 'contains rebuild command' do + expect(described_class::COMMANDS).to include('rebuild' => RubyWasmUi::Cli::Command::Rebuild) + end + end +end diff --git a/spec/ruby_wasm_ui/component_spec.rb b/spec/ruby_wasm_ui/runtime/component_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/component_spec.rb rename to spec/ruby_wasm_ui/runtime/component_spec.rb diff --git a/spec/ruby_wasm_ui/dom/scheduler_spec.rb b/spec/ruby_wasm_ui/runtime/dom/scheduler_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/dom/scheduler_spec.rb rename to spec/ruby_wasm_ui/runtime/dom/scheduler_spec.rb diff --git a/spec/ruby_wasm_ui/nodes_equal_spec.rb b/spec/ruby_wasm_ui/runtime/nodes_equal_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/nodes_equal_spec.rb rename to spec/ruby_wasm_ui/runtime/nodes_equal_spec.rb diff --git a/spec/ruby_wasm_ui/template/build_conditional_group_spec.rb b/spec/ruby_wasm_ui/runtime/template/build_conditional_group_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/template/build_conditional_group_spec.rb rename to spec/ruby_wasm_ui/runtime/template/build_conditional_group_spec.rb diff --git a/spec/ruby_wasm_ui/template/build_for_group_spec.rb b/spec/ruby_wasm_ui/runtime/template/build_for_group_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/template/build_for_group_spec.rb rename to spec/ruby_wasm_ui/runtime/template/build_for_group_spec.rb diff --git a/spec/ruby_wasm_ui/template/build_vdom_spec.rb b/spec/ruby_wasm_ui/runtime/template/build_vdom_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/template/build_vdom_spec.rb rename to spec/ruby_wasm_ui/runtime/template/build_vdom_spec.rb diff --git a/spec/ruby_wasm_ui/template/parser_spec.rb b/spec/ruby_wasm_ui/runtime/template/parser_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/template/parser_spec.rb rename to spec/ruby_wasm_ui/runtime/template/parser_spec.rb diff --git a/spec/ruby_wasm_ui/utils/arrays_spec.rb b/spec/ruby_wasm_ui/runtime/utils/arrays_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/utils/arrays_spec.rb rename to spec/ruby_wasm_ui/runtime/utils/arrays_spec.rb diff --git a/spec/ruby_wasm_ui/utils/objects_spec.rb b/spec/ruby_wasm_ui/runtime/utils/objects_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/utils/objects_spec.rb rename to spec/ruby_wasm_ui/runtime/utils/objects_spec.rb diff --git a/spec/ruby_wasm_ui/utils/props_spec.rb b/spec/ruby_wasm_ui/runtime/utils/props_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/utils/props_spec.rb rename to spec/ruby_wasm_ui/runtime/utils/props_spec.rb diff --git a/spec/ruby_wasm_ui/utils/strings_spec.rb b/spec/ruby_wasm_ui/runtime/utils/strings_spec.rb similarity index 100% rename from spec/ruby_wasm_ui/utils/strings_spec.rb rename to spec/ruby_wasm_ui/runtime/utils/strings_spec.rb