+ 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