diff --git a/spec/debugserver/spec_debug_helper.rb b/spec/debugserver/spec_debug_helper.rb index acd2a4efb..aec36564a 100644 --- a/spec/debugserver/spec_debug_helper.rb +++ b/spec/debugserver/spec_debug_helper.rb @@ -1,3 +1,4 @@ +ENV['COVERAGE_SUITE'] = 'debugserver' require_relative '../spec_helper.rb' # Emulate the setup from the root 'puppet-debugserver' file # Add the debug server into the load path diff --git a/spec/debugserver/unit/dsp/dsp_protocol_spec.rb b/spec/debugserver/unit/dsp/dsp_protocol_spec.rb new file mode 100644 index 000000000..15817f250 --- /dev/null +++ b/spec/debugserver/unit/dsp/dsp_protocol_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'spec_debug_helper' +# All DSP files are loaded transitively via puppet_debugserver in spec_debug_helper + +describe 'DSP Protocol' do + describe 'DSP module helper' do + describe '.create_range' do + it 'returns a hash describing a document range' do + result = DSP.create_range(0, 1, 2, 3) + expect(result).to eq( + 'start' => { 'line' => 0, 'character' => 1 }, + 'end' => { 'line' => 2, 'character' => 3 } + ) + end + end + end + + describe 'DSP::DSPBase subclasses' do + let(:all_dsp_classes) do + ObjectSpace.each_object(Class) + .select { |c| c < DSP::DSPBase } + .sort_by(&:name) + end + + it 'discovers DSP protocol classes' do + expect(all_dsp_classes).not_to be_empty + end + + it 'every class can be instantiated without arguments' do + all_dsp_classes.each do |klass| + expect { klass.new }.not_to raise_error, + "#{klass.name}.new raised an error" + end + end + + it 'every class returns self from from_h!(nil)' do + all_dsp_classes.each do |klass| + instance = klass.new + expect(instance.from_h!(nil)).to eq(instance), + "#{klass.name}#from_h!(nil) did not return self" + end + end + + it 'every class returns self from from_h!({})' do + all_dsp_classes.each do |klass| + instance = klass.new + expect(instance.from_h!({})).to eq(instance), + "#{klass.name}#from_h!({}) did not return self" + end + end + + it 'every class returns a Hash from to_h' do + all_dsp_classes.each do |klass| + instance = klass.new + instance.from_h!({}) + result = instance.to_h + expect(result).to be_a(Hash), + "#{klass.name}#to_h returned #{result.class}, not Hash" + end + end + + it 'every class returns a String from to_json' do + all_dsp_classes.each do |klass| + instance = klass.new + instance.from_h!({}) + expect(instance.to_json).to be_a(String), + "#{klass.name}#to_json did not return a String" + end + end + + it 'every class can be round-tripped through from_h!/to_h' do + all_dsp_classes.each do |klass| + instance = klass.new + instance.from_h!({}) + h = instance.to_h + instance2 = klass.new + expect { instance2.from_h!(h) }.not_to raise_error, + "#{klass.name}: round-trip from_h!(to_h result) raised an error" + end + end + end + + describe 'nested object deserialization' do + describe 'DSP::CancelRequest' do + let(:request_hash) do + { + 'seq' => 1, + 'type' => 'request', + 'command' => 'cancel', + 'arguments' => { 'requestId' => 5 } + } + end + + it 'creates a nested CancelArguments object' do + req = DSP::CancelRequest.new(request_hash) + expect(req.arguments).to be_a(DSP::CancelArguments) + expect(req.arguments.requestId).to eq(5) + end + + it 'serializes the nested object in to_h' do + req = DSP::CancelRequest.new(request_hash) + result = req.to_h + expect(result['arguments']).to be_a(Hash) + expect(result['arguments']['requestId']).to eq(5) + end + + it 'leaves arguments nil when absent' do + req = DSP::CancelRequest.new({ 'seq' => 1, 'type' => 'request', 'command' => 'cancel' }) + expect(req.arguments).to be_nil + end + end + end + + describe 'typed array deserialization' do + describe 'DSP::SetBreakpointsArguments' do + let(:args_hash) do + { + 'source' => { 'path' => '/example.pp' }, + 'breakpoints' => [ + { 'line' => 5 }, + { 'line' => 10, 'condition' => 'x > 0' } + ] + } + end + + it 'deserializes a typed array of SourceBreakpoint objects' do + args = DSP::SetBreakpointsArguments.new(args_hash) + expect(args.breakpoints).to be_an(Array) + expect(args.breakpoints.length).to eq(2) + expect(args.breakpoints.first).to be_a(DSP::SourceBreakpoint) + expect(args.breakpoints.first.line).to eq(5) + end + + it 'serializes the typed array in to_h' do + args = DSP::SetBreakpointsArguments.new(args_hash) + result = args.to_h + expect(result['breakpoints']).to be_an(Array) + expect(result['breakpoints'].first).to be_a(Hash) + expect(result['breakpoints'].first['line']).to eq(5) + end + + it 'returns nil for an absent typed array field' do + args = DSP::SetBreakpointsArguments.new({}) + expect(args.breakpoints).to be_nil + end + end + end + + describe 'optional fields' do + describe 'DSP::CancelArguments' do + it 'omits optional nil fields from to_h' do + args = DSP::CancelArguments.new({}) + result = args.to_h + expect(result).not_to have_key('requestId') + expect(result).not_to have_key('progressId') + end + + it 'includes optional fields when present' do + args = DSP::CancelArguments.new({ 'requestId' => 42 }) + result = args.to_h + expect(result).to have_key('requestId') + expect(result['requestId']).to eq(42) + end + end + end + + describe 'basic DSP message types' do + describe 'DSP::ProtocolMessage' do + it 'stores seq and type' do + msg = DSP::ProtocolMessage.new({ 'seq' => 7, 'type' => 'request' }) + expect(msg.seq).to eq(7) + expect(msg.type).to eq('request') + end + end + + describe 'DSP::Response' do + it 'stores all response fields' do + resp = DSP::Response.new({ + 'seq' => 2, + 'type' => 'response', + 'request_seq' => 1, + 'success' => true, + 'command' => 'initialize', + 'body' => { 'supportsConfigurationDoneRequest' => true } + }) + expect(resp.success).to be(true) + expect(resp.command).to eq('initialize') + expect(resp.body).to be_a(Hash) + end + + it 'omits optional message and body fields when nil' do + resp = DSP::Response.new({ 'request_seq' => 1, 'success' => true, 'command' => 'test' }) + result = resp.to_h + expect(result).not_to have_key('message') + expect(result).not_to have_key('body') + end + end + end +end diff --git a/spec/languageserver-sidecar/spec_helper.rb b/spec/languageserver-sidecar/spec_helper.rb index 6616596f7..769741a4d 100644 --- a/spec/languageserver-sidecar/spec_helper.rb +++ b/spec/languageserver-sidecar/spec_helper.rb @@ -1,3 +1,4 @@ +ENV['COVERAGE_SUITE'] = 'languageserver-sidecar' require_relative '../spec_helper.rb' # Emulate the setup from the root 'puppet-languageserver' file diff --git a/spec/languageserver-sidecar/unit/puppet-languageserver-sidecar/cache/base_spec.rb b/spec/languageserver-sidecar/unit/puppet-languageserver-sidecar/cache/base_spec.rb new file mode 100644 index 000000000..55154f749 --- /dev/null +++ b/spec/languageserver-sidecar/unit/puppet-languageserver-sidecar/cache/base_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet-languageserver-sidecar/cache/base' + +describe 'PuppetLanguageServerSidecar::Cache::Base' do + subject(:cache) { PuppetLanguageServerSidecar::Cache::Base.new } + + describe '#initialize' do + it 'accepts an options hash' do + cache_with_opts = PuppetLanguageServerSidecar::Cache::Base.new({ timeout: 60 }) + expect(cache_with_opts.cache_options).to eq({ timeout: 60 }) + end + + it 'defaults to an empty options hash' do + expect(cache.cache_options).to eq({}) + end + end + + describe '#active?' do + it 'returns false' do + expect(cache.active?).to be(false) + end + end + + describe '#load' do + it 'raises NotImplementedError' do + expect { cache.load('/some/path', PuppetLanguageServerSidecar::Cache::CLASSES_SECTION) } + .to raise_error(NotImplementedError) + end + end + + describe '#save' do + it 'raises NotImplementedError' do + expect { cache.save('/some/path', PuppetLanguageServerSidecar::Cache::FUNCTIONS_SECTION, 'content') } + .to raise_error(NotImplementedError) + end + end + + describe '#clear!' do + it 'raises NotImplementedError' do + expect { cache.clear! }.to raise_error(NotImplementedError) + end + end + + describe 'cache section constants' do + it 'defines a CLASSES_SECTION constant' do + expect(PuppetLanguageServerSidecar::Cache::CLASSES_SECTION).to be_a(String) + end + + it 'defines a FUNCTIONS_SECTION constant' do + expect(PuppetLanguageServerSidecar::Cache::FUNCTIONS_SECTION).to be_a(String) + end + + it 'defines a TYPES_SECTION constant' do + expect(PuppetLanguageServerSidecar::Cache::TYPES_SECTION).to be_a(String) + end + + it 'defines a PUPPETSTRINGS_SECTION constant' do + expect(PuppetLanguageServerSidecar::Cache::PUPPETSTRINGS_SECTION).to be_a(String) + end + end +end diff --git a/spec/languageserver-sidecar/unit/puppet-languageserver-sidecar/cache/null_spec.rb b/spec/languageserver-sidecar/unit/puppet-languageserver-sidecar/cache/null_spec.rb new file mode 100644 index 000000000..50042cacb --- /dev/null +++ b/spec/languageserver-sidecar/unit/puppet-languageserver-sidecar/cache/null_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet-languageserver-sidecar/cache/base' +require 'puppet-languageserver-sidecar/cache/null' + +describe 'PuppetLanguageServerSidecar::Cache::Null' do + subject(:cache) { PuppetLanguageServerSidecar::Cache::Null.new } + + it 'is a subclass of Cache::Base' do + expect(cache).to be_a(PuppetLanguageServerSidecar::Cache::Base) + end + + describe '#initialize' do + it 'accepts an options hash' do + cache_with_opts = PuppetLanguageServerSidecar::Cache::Null.new({ timeout: 30 }) + expect(cache_with_opts.cache_options).to eq({ timeout: 30 }) + end + + it 'defaults to an empty options hash' do + expect(cache.cache_options).to eq({}) + end + end + + describe '#active?' do + it 'returns false' do + expect(cache.active?).to be(false) + end + end + + describe '#load' do + it 'returns nil for any path and section' do + result = cache.load('/some/path', PuppetLanguageServerSidecar::Cache::CLASSES_SECTION) + expect(result).to be_nil + end + + it 'accepts any number of arguments' do + expect { cache.load('/path', 'section', 'extra') }.not_to raise_error + end + end + + describe '#save' do + it 'returns true for any arguments' do + result = cache.save('/some/path', PuppetLanguageServerSidecar::Cache::FUNCTIONS_SECTION, 'content') + expect(result).to be(true) + end + + it 'accepts any number of arguments' do + expect { cache.save('/path', 'section', 'data', 'extra') }.not_to raise_error + end + end + + describe '#clear!' do + it 'returns nil' do + expect(cache.clear!).to be_nil + end + + it 'does not raise' do + expect { cache.clear! }.not_to raise_error + end + end +end diff --git a/spec/languageserver/spec_helper.rb b/spec/languageserver/spec_helper.rb index 4ceb66b09..81dbcf618 100644 --- a/spec/languageserver/spec_helper.rb +++ b/spec/languageserver/spec_helper.rb @@ -1,3 +1,4 @@ +ENV['COVERAGE_SUITE'] = 'languageserver' require_relative '../spec_helper.rb' # Emulate the setup from the root 'puppet-languageserver' file root = File.join(File.dirname(__FILE__),'..','..') diff --git a/spec/languageserver/unit/lsp/lsp_protocol_spec.rb b/spec/languageserver/unit/lsp/lsp_protocol_spec.rb new file mode 100644 index 000000000..59e3c27e7 --- /dev/null +++ b/spec/languageserver/unit/lsp/lsp_protocol_spec.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require 'spec_helper' +# lsp/lsp is loaded transitively when this spec runs via rake (--default-path spec/languageserver +# causes the languageserver spec_helper to be used, which loads puppet_languageserver). +# When run in isolation the root spec_helper is found instead, so require explicitly. +unless defined?(LSP) + lib_dir = File.expand_path('../../../../lib', __dir__) + $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir) + require 'lsp/lsp' +end + +describe 'LSP Protocol' do + describe 'LSP module helper' do + describe '.create_range' do + it 'returns a hash describing a document range' do + result = LSP.create_range(1, 2, 3, 4) + expect(result).to eq( + 'start' => { 'line' => 1, 'character' => 2 }, + 'end' => { 'line' => 3, 'character' => 4 } + ) + end + end + end + + describe 'LSP::LSPBase subclasses' do + let(:all_lsp_classes) do + ObjectSpace.each_object(Class) + .select { |c| c < LSP::LSPBase } + .sort_by(&:name) + end + + it 'discovers LSP protocol classes' do + expect(all_lsp_classes).not_to be_empty + end + + it 'every class can be instantiated without arguments' do + all_lsp_classes.each do |klass| + expect { klass.new }.not_to raise_error, + "#{klass.name}.new raised an error" + end + end + + it 'every class returns self from from_h!(nil)' do + all_lsp_classes.each do |klass| + instance = klass.new + expect(instance.from_h!(nil)).to eq(instance), + "#{klass.name}#from_h!(nil) did not return self" + end + end + + it 'every class returns self from from_h!({})' do + all_lsp_classes.each do |klass| + instance = klass.new + expect(instance.from_h!({})).to eq(instance), + "#{klass.name}#from_h!({}) did not return self" + end + end + + it 'every class returns a Hash from to_h' do + all_lsp_classes.each do |klass| + instance = klass.new + instance.from_h!({}) + result = instance.to_h + expect(result).to be_a(Hash), + "#{klass.name}#to_h returned #{result.class}, not Hash" + end + end + + it 'every class returns a String from to_json' do + all_lsp_classes.each do |klass| + instance = klass.new + instance.from_h!({}) + expect(instance.to_json).to be_a(String), + "#{klass.name}#to_json did not return a String" + end + end + + it 'every class can be round-tripped through from_h!/to_h' do + all_lsp_classes.each do |klass| + instance = klass.new + instance.from_h!({}) + h = instance.to_h + instance2 = klass.new + expect { instance2.from_h!(h) }.not_to raise_error, + "#{klass.name}: round-trip from_h!(to_h result) raised an error" + end + end + end + + describe 'nested object deserialization' do + describe 'LSP::Range' do + let(:range_hash) do + { + 'start' => { 'line' => 2, 'character' => 4 }, + 'end' => { 'line' => 2, 'character' => 12 } + } + end + + it 'creates nested Position objects on from_h!' do + range = LSP::Range.new(range_hash) + expect(range.start).to be_a(LSP::Position) + expect(range.send(:end)).to be_a(LSP::Position) + expect(range.start.line).to eq(2) + expect(range.start.character).to eq(4) + expect(range.send(:end).character).to eq(12) + end + + it 'serializes nested Position objects in to_h' do + range = LSP::Range.new(range_hash) + result = range.to_h + expect(result['start']).to be_a(Hash) + expect(result['start']['line']).to eq(2) + expect(result['end']).to be_a(Hash) + expect(result['end']['character']).to eq(12) + end + + it 'does not instantiate nested objects when fields are absent' do + range = LSP::Range.new({}) + expect(range.start).to be_nil + expect(range.send(:end)).to be_nil + end + end + + describe 'LSP::Location' do + let(:location_hash) do + { + 'uri' => 'file:///example.pp', + 'range' => { + 'start' => { 'line' => 0, 'character' => 0 }, + 'end' => { 'line' => 0, 'character' => 5 } + } + } + end + + it 'creates a nested Range object' do + location = LSP::Location.new(location_hash) + expect(location.range).to be_a(LSP::Range) + expect(location.range.start).to be_a(LSP::Position) + end + + it 'serializes nested Range in to_h' do + location = LSP::Location.new(location_hash) + result = location.to_h + expect(result['range']).to be_a(Hash) + expect(result['range']['start']).to be_a(Hash) + end + end + end + + describe 'typed array deserialization' do + describe 'LSP::RegistrationParams' do + let(:params_hash) do + { + 'registrations' => [ + { 'id' => 'reg-1', 'method' => 'textDocument/didOpen' }, + { 'id' => 'reg-2', 'method' => 'textDocument/didClose' } + ] + } + end + + it 'deserializes a typed array of Registration objects' do + params = LSP::RegistrationParams.new(params_hash) + expect(params.registrations).to be_an(Array) + expect(params.registrations.length).to eq(2) + expect(params.registrations.first).to be_a(LSP::Registration) + expect(params.registrations.first.id).to eq('reg-1') + expect(params.registrations.last.id).to eq('reg-2') + end + + it 'serializes the typed array in to_h' do + params = LSP::RegistrationParams.new(params_hash) + result = params.to_h + expect(result['registrations']).to be_an(Array) + expect(result['registrations'].first).to be_a(Hash) + expect(result['registrations'].first['id']).to eq('reg-1') + end + + it 'returns nil for an absent typed array field' do + params = LSP::RegistrationParams.new({}) + expect(params.registrations).to be_nil + end + end + end + + describe 'optional fields' do + describe 'LSP::Registration' do + it 'omits optional nil fields from to_h output' do + reg = LSP::Registration.new({ 'id' => 'r1', 'method' => 'test/method' }) + result = reg.to_h + expect(result).not_to have_key('registerOptions') + end + + it 'includes optional fields when they have a value' do + reg = LSP::Registration.new({ + 'id' => 'r1', 'method' => 'test/method', 'registerOptions' => { 'opt' => true } + }) + result = reg.to_h + expect(result).to have_key('registerOptions') + expect(result['registerOptions']).to eq({ 'opt' => true }) + end + end + end + + describe 'field name aliasing (__lsp suffix)' do + describe 'LSP::Registration' do + it 'reads the method field via the method__lsp accessor' do + reg = LSP::Registration.new({ 'id' => 'r1', 'method' => 'test/method' }) + expect(reg.method__lsp).to eq('test/method') + end + + it 'serializes method__lsp back to the method key in to_h' do + reg = LSP::Registration.new({ 'id' => 'r1', 'method' => 'test/method' }) + result = reg.to_h + expect(result).to have_key('method') + expect(result['method']).to eq('test/method') + expect(result).not_to have_key('method__lsp') + end + end + end +end diff --git a/spec/languageserver/unit/puppet-editor-services/logging_spec.rb b/spec/languageserver/unit/puppet-editor-services/logging_spec.rb new file mode 100644 index 000000000..2931bc065 --- /dev/null +++ b/spec/languageserver/unit/puppet-editor-services/logging_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'logger' +require 'tempfile' +require 'puppet_editor_services/logging' + +describe 'PuppetEditorServices logging' do + around do |example| + saved_logger = PuppetEditorServices.instance_variable_get(:@logger) + saved_log_file = PuppetEditorServices.instance_variable_get(:@log_file) + example.run + ensure + # Close any real file opened during the test before restoring state. + # Use is_a?(IO) to avoid touching RSpec doubles, which expire before ensure runs. + current_log_file = PuppetEditorServices.instance_variable_get(:@log_file) + current_log_file.close if current_log_file.is_a?(IO) && !current_log_file.closed? + PuppetEditorServices.instance_variable_set(:@logger, saved_logger) + PuppetEditorServices.instance_variable_set(:@log_file, saved_log_file) + end + + describe '.log_message' do + context 'when no logger is configured' do + before { PuppetEditorServices.instance_variable_set(:@logger, nil) } + + it 'does not raise an error' do + expect { PuppetEditorServices.log_message(:info, 'test') }.not_to raise_error + end + + it 'returns nil' do + expect(PuppetEditorServices.log_message(:debug, 'test')).to be_nil + end + end + + context 'when a logger is configured' do + let(:mock_logger) { instance_double(Logger) } + + before do + PuppetEditorServices.instance_variable_set(:@logger, mock_logger) + PuppetEditorServices.instance_variable_set(:@log_file, nil) + end + + it 'calls debug on the logger for :debug severity' do + expect(mock_logger).to receive(:debug).with('debug message') + PuppetEditorServices.log_message(:debug, 'debug message') + end + + it 'calls info on the logger for :info severity' do + expect(mock_logger).to receive(:info).with('info message') + PuppetEditorServices.log_message(:info, 'info message') + end + + it 'calls warn on the logger for :warn severity' do + expect(mock_logger).to receive(:warn).with('warn message') + PuppetEditorServices.log_message(:warn, 'warn message') + end + + it 'calls error on the logger for :error severity' do + expect(mock_logger).to receive(:error).with('error message') + PuppetEditorServices.log_message(:error, 'error message') + end + + it 'calls fatal on the logger for :fatal severity' do + expect(mock_logger).to receive(:fatal).with('fatal message') + PuppetEditorServices.log_message(:fatal, 'fatal message') + end + + it 'calls unknown on the logger for an unrecognised severity' do + expect(mock_logger).to receive(:unknown).with('other message') + PuppetEditorServices.log_message(:something_else, 'other message') + end + end + + context 'when a log file is also configured' do + let(:mock_logger) { instance_double(Logger) } + let(:mock_log_file) { instance_double(File) } + + before do + PuppetEditorServices.instance_variable_set(:@logger, mock_logger) + PuppetEditorServices.instance_variable_set(:@log_file, mock_log_file) + allow(mock_logger).to receive(:info) + end + + it 'fsyncs the log file after each message' do + expect(mock_log_file).to receive(:fsync) + PuppetEditorServices.log_message(:info, 'test') + end + end + end + + describe '.init_logging' do + context 'when debug option is nil' do + it 'sets the logger to nil' do + PuppetEditorServices.init_logging({ debug: nil }) + expect(PuppetEditorServices.instance_variable_get(:@logger)).to be_nil + end + end + + context 'when debug option is stdout' do + it 'creates a Logger that writes to $stdout' do + PuppetEditorServices.init_logging({ debug: 'stdout' }) + expect(PuppetEditorServices.instance_variable_get(:@logger)).to be_a(Logger) + end + + it 'is case-insensitive (STDOUT)' do + PuppetEditorServices.init_logging({ debug: 'STDOUT' }) + expect(PuppetEditorServices.instance_variable_get(:@logger)).to be_a(Logger) + end + end + + context 'when debug option is a file path' do + let(:log_file) { Tempfile.new(['puppet_ls_test_log', '.log']) } + + after { log_file.unlink } + + it 'creates a Logger backed by the file' do + PuppetEditorServices.init_logging({ debug: log_file.path }) + expect(PuppetEditorServices.instance_variable_get(:@logger)).to be_a(Logger) + expect(PuppetEditorServices.instance_variable_get(:@log_file)).not_to be_nil + end + end + + context 'when debug option is an invalid file path' do + it 'disables logging and does not raise' do + expect { PuppetEditorServices.init_logging({ debug: '/no/such/dir/log.txt' }) }.not_to raise_error + expect(PuppetEditorServices.instance_variable_get(:@logger)).to be_nil + end + end + end +end diff --git a/spec/languageserver/unit/puppet-editor-services/protocol/debug_adapter_messages_spec.rb b/spec/languageserver/unit/puppet-editor-services/protocol/debug_adapter_messages_spec.rb new file mode 100644 index 000000000..9eba77208 --- /dev/null +++ b/spec/languageserver/unit/puppet-editor-services/protocol/debug_adapter_messages_spec.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet_editor_services/protocol/debug_adapter_messages' + +describe 'PuppetEditorServices::Protocol::DebugAdapterMessages' do + let(:msgs) { PuppetEditorServices::Protocol::DebugAdapterMessages } + + describe 'ProtocolMessage' do + subject(:msg) { msgs::ProtocolMessage.new } + + it 'initializes with nil seq and type' do + expect(msg.seq).to be_nil + expect(msg.type).to be_nil + end + + it 'populates fields via from_h!' do + msg.from_h!({ 'seq' => 3, 'type' => 'request' }) + expect(msg.seq).to eq(3) + expect(msg.type).to eq('request') + end + + it 'handles nil hash in from_h!' do + expect { msg.from_h!(nil) }.not_to raise_error + end + + it 'serializes to a hash via to_h' do + msg.from_h!({ 'seq' => 1, 'type' => 'event' }) + result = msg.to_h + expect(result).to eq({ 'seq' => 1, 'type' => 'event' }) + end + + it 'serializes to JSON via to_json' do + msg.from_h!({ 'seq' => 1, 'type' => 'event' }) + expect(msg.to_json).to be_a(String) + expect(JSON.parse(msg.to_json)).to include('seq' => 1) + end + + it 'can be instantiated with an initial hash' do + msg2 = msgs::ProtocolMessage.new({ 'seq' => 5, 'type' => 'response' }) + expect(msg2.seq).to eq(5) + expect(msg2.type).to eq('response') + end + end + + describe 'Request' do + subject(:req) { msgs::Request.new } + + it 'sets type to request on initialization' do + expect(req.type).to eq('request') + end + + it 'populates command and arguments via from_h!' do + req.from_h!({ 'seq' => 1, 'command' => 'initialize', 'arguments' => { 'foo' => 'bar' } }) + expect(req.command).to eq('initialize') + expect(req.arguments).to eq({ 'foo' => 'bar' }) + end + + it 'handles nil arguments in from_h!' do + req.from_h!({ 'seq' => 1, 'command' => 'initialize' }) + expect(req.arguments).to be_nil + end + + it 'includes arguments in to_h when present' do + req.from_h!({ 'seq' => 1, 'command' => 'launch', 'arguments' => { 'program' => '/a.pp' } }) + result = req.to_h + expect(result['command']).to eq('launch') + expect(result['arguments']).to eq({ 'program' => '/a.pp' }) + end + + it 'omits arguments from to_h when nil' do + req.from_h!({ 'seq' => 1, 'command' => 'configurationDone' }) + result = req.to_h + expect(result).not_to have_key('arguments') + end + + it 'can be instantiated with an initial hash' do + req2 = msgs::Request.new({ 'seq' => 2, 'command' => 'threads' }) + expect(req2.command).to eq('threads') + end + end + + describe 'Event' do + subject(:evt) { msgs::Event.new } + + it 'sets type to event on initialization' do + expect(evt.type).to eq('event') + end + + it 'populates event name and body via from_h!' do + evt.from_h!({ 'seq' => 1, 'event' => 'initialized', 'body' => { 'reason' => 'started' } }) + expect(evt.event).to eq('initialized') + expect(evt.body).to eq({ 'reason' => 'started' }) + end + + it 'includes body in to_h when present' do + evt.from_h!({ 'seq' => 1, 'event' => 'stopped', 'body' => { 'reason' => 'breakpoint' } }) + result = evt.to_h + expect(result['event']).to eq('stopped') + expect(result['body']).to eq({ 'reason' => 'breakpoint' }) + end + + it 'omits body from to_h when nil' do + evt.from_h!({ 'seq' => 1, 'event' => 'initialized' }) + result = evt.to_h + expect(result).not_to have_key('body') + end + end + + describe 'Response' do + subject(:resp) { msgs::Response.new } + + it 'sets type to response on initialization' do + expect(resp.type).to eq('response') + end + + it 'populates all fields via from_h!' do + resp.from_h!({ + 'seq' => 2, + 'request_seq' => 1, + 'success' => true, + 'command' => 'initialize', + 'message' => 'ok', + 'body' => { 'result' => true } + }) + expect(resp.request_seq).to eq(1) + expect(resp.success).to be(true) + expect(resp.command).to eq('initialize') + expect(resp.message).to eq('ok') + expect(resp.body).to eq({ 'result' => true }) + end + + it 'includes message in to_h when present' do + resp.from_h!({ 'request_seq' => 1, 'success' => false, 'command' => 'test', 'message' => 'oops' }) + result = resp.to_h + expect(result['message']).to eq('oops') + end + + it 'omits message from to_h when nil' do + resp.from_h!({ 'request_seq' => 1, 'success' => true, 'command' => 'test' }) + result = resp.to_h + expect(result).not_to have_key('message') + end + + it 'omits body from to_h when nil' do + resp.from_h!({ 'request_seq' => 1, 'success' => true, 'command' => 'test' }) + result = resp.to_h + expect(result).not_to have_key('body') + end + + it 'includes body in to_h when present' do + resp.from_h!({ 'request_seq' => 1, 'success' => true, 'command' => 'test', 'body' => { 'x' => 1 } }) + result = resp.to_h + expect(result['body']).to eq({ 'x' => 1 }) + end + + it 'can be instantiated with an initial hash' do + resp2 = msgs::Response.new({ 'request_seq' => 5, 'success' => true, 'command' => 'launch' }) + expect(resp2.command).to eq('launch') + expect(resp2.success).to be(true) + end + end + + describe 'factory methods' do + let(:request) do + msgs::Request.new({ 'seq' => 1, 'command' => 'initialize' }) + end + + describe '.reply_error' do + it 'returns a Response with success false' do + resp = msgs.reply_error(request, 'Something went wrong') + expect(resp).to be_a(msgs::Response) + expect(resp.success).to be(false) + expect(resp.request_seq).to eq(1) + expect(resp.command).to eq('initialize') + expect(resp.message).to eq('Something went wrong') + end + + it 'attaches a message object when provided' do + err_obj = { 'id' => 1, 'format' => 'error' } + resp = msgs.reply_error(request, 'err', err_obj) + expect(resp.body).to eq({ 'error' => err_obj }) + end + + it 'works without optional arguments' do + resp = msgs.reply_error(request) + expect(resp.success).to be(false) + expect(resp.message).to be_nil + end + end + + describe '.reply_success' do + it 'returns a Response with success true' do + resp = msgs.reply_success(request) + expect(resp).to be_a(msgs::Response) + expect(resp.success).to be(true) + expect(resp.request_seq).to eq(1) + expect(resp.command).to eq('initialize') + end + + it 'attaches body content when provided' do + resp = msgs.reply_success(request, { 'result' => 42 }) + expect(resp.body).to eq({ 'result' => 42 }) + end + + it 'leaves body nil when not provided' do + resp = msgs.reply_success(request) + expect(resp.body).to be_nil + end + end + + describe '.new_event' do + it 'returns an Event with the given name' do + evt = msgs.new_event('initialized') + expect(evt).to be_a(msgs::Event) + expect(evt.event).to eq('initialized') + end + + it 'attaches body content when provided' do + evt = msgs.new_event('stopped', { 'reason' => 'breakpoint' }) + expect(evt.body).to eq({ 'reason' => 'breakpoint' }) + end + + it 'leaves body nil when not provided' do + evt = msgs.new_event('initialized') + expect(evt.body).to be_nil + end + end + end +end diff --git a/spec/languageserver/unit/puppet-editor-services/protocol/debug_adapter_spec.rb b/spec/languageserver/unit/puppet-editor-services/protocol/debug_adapter_spec.rb new file mode 100644 index 000000000..094d7bf5b --- /dev/null +++ b/spec/languageserver/unit/puppet-editor-services/protocol/debug_adapter_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet_editor_services/protocol/debug_adapter' +require 'puppet_editor_services/handler/debug_adapter' + +describe 'PuppetEditorServices::Protocol::DebugAdapter' do + let(:server) do + MockServer.new( + {}, + {}, + { class: PuppetEditorServices::Protocol::DebugAdapter }, + { class: PuppetEditorServices::Handler::DebugAdapter } + ) + end + let(:subject) { server.protocol_object } + let(:message_handler) { server.handler_object } + + before do + allow(PuppetEditorServices).to receive(:log_message) + end + + def framed(json_string) + "Content-Length: #{json_string.bytesize}\r\n\r\n#{json_string}" + end + + describe '#extract_headers' do + it 'parses a Content-Length header' do + headers = subject.extract_headers('Content-Length: 123') + expect(headers['Content-Length']).to eq(123) + end + + it 'parses a Content-Type header (stored as Content-Length key per implementation)' do + headers = subject.extract_headers('Content-Type: application/json') + expect(headers).to have_key('Content-Length') + end + + it 'raises on an unknown header' do + expect { subject.extract_headers('X-Unknown: value') }.to raise_error(/Unknown header/) + end + + it 'handles multiple headers' do + headers = subject.extract_headers("Content-Length: 50\r\nContent-Type: application/json") + expect(headers['Content-Length']).to be_a(Integer).or be_a(String) + end + end + + describe '#receive_data' do + context 'with empty data' do + it 'returns without error' do + expect { subject.receive_data('') }.not_to raise_error + end + end + + context 'with a valid framed JSON request' do + let(:json_body) { '{"seq":1,"type":"request","command":"initialize"}' } + let(:data) { framed(json_body) } + + it 'dispatches the request to the message handler' do + expect(message_handler).to receive(:handle).with( + an_instance_of(PuppetEditorServices::Protocol::DebugAdapterMessages::Request) + ) + subject.receive_data(data) + end + end + + context 'with partial data (incomplete message)' do + it 'buffers the data without error' do + expect { subject.receive_data("Content-Length: 100\r\n\r\n{") }.not_to raise_error + end + end + end + + describe '#receive_json_message_as_hash' do + context 'when message type is request' do + let(:json_obj) { { 'seq' => 1, 'type' => 'request', 'command' => 'initialize' } } + + it 'calls handle on the message handler with a Request object' do + expect(message_handler).to receive(:handle).with( + an_instance_of(PuppetEditorServices::Protocol::DebugAdapterMessages::Request) + ) + subject.receive_json_message_as_hash(json_obj) + end + + it 'returns true' do + allow(message_handler).to receive(:handle) + expect(subject.receive_json_message_as_hash(json_obj)).to be(true) + end + end + + context 'when message type is not request' do + let(:json_obj) { { 'seq' => 1, 'type' => 'event', 'event' => 'initialized' } } + + it 'logs an error' do + expect(PuppetEditorServices).to receive(:log_message).with(:error, /event/) + subject.receive_json_message_as_hash(json_obj) + end + + it 'does not call handle on the message handler' do + expect(message_handler).not_to receive(:handle) + subject.receive_json_message_as_hash(json_obj) + end + + it 'returns false' do + expect(subject.receive_json_message_as_hash(json_obj)).to be(false) + end + end + end + + describe '#encode_and_send' do + let(:event) do + PuppetEditorServices::Protocol::DebugAdapterMessages::Event.new({ 'event' => 'initialized' }) + end + + it 'assigns an incrementing sequence id to the message' do + allow(server.connection_object).to receive(:send_data) + subject.encode_and_send(event) + expect(event.seq).to be_a(Integer) + end + + it 'sends framed JSON data via the connection' do + expect(server.connection_object).to receive(:send_data).with(/Content-Length:/) + subject.encode_and_send(event) + end + + it 'raises when passed a non-ProtocolMessage object' do + expect { subject.encode_and_send('not a message') }.to raise_error(/ProtocolMessage/) + end + + it 'increments the sequence id on each call' do + allow(server.connection_object).to receive(:send_data) + event1 = PuppetEditorServices::Protocol::DebugAdapterMessages::Event.new({ 'event' => 'e1' }) + event2 = PuppetEditorServices::Protocol::DebugAdapterMessages::Event.new({ 'event' => 'e2' }) + subject.encode_and_send(event1) + subject.encode_and_send(event2) + expect(event2.seq).to eq(event1.seq + 1) + end + end + + describe '#send_json_string' do + it 'sends data with a Content-Length header' do + payload = '{"type":"event","seq":1}' + expect(server.connection_object).to receive(:send_data).with( + "Content-Length: #{payload.bytesize}\r\n\r\n#{payload}" + ) + subject.send_json_string(payload) + end + end +end diff --git a/spec/languageserver/unit/puppet-editor-services/protocol/json_rpc_messages_spec.rb b/spec/languageserver/unit/puppet-editor-services/protocol/json_rpc_messages_spec.rb new file mode 100644 index 000000000..e8f5cb241 --- /dev/null +++ b/spec/languageserver/unit/puppet-editor-services/protocol/json_rpc_messages_spec.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet_editor_services/protocol/json_rpc' + +describe 'PuppetEditorServices::Protocol::JsonRPCMessages' do + let(:msgs) { PuppetEditorServices::Protocol::JsonRPCMessages } + let(:rpc) { PuppetEditorServices::Protocol::JsonRPC } + + describe 'Message' do + subject(:msg) { msgs::Message.new } + + it 'initializes with the default JSONRPC version' do + expect(msg.jsonrpc).to eq(rpc::JSONRPC_VERSION) + end + + it 'updates jsonrpc from from_h! when provided' do + msg.from_h!({ 'jsonrpc' => '1.0' }) + expect(msg.jsonrpc).to eq('1.0') + end + + it 'does not override jsonrpc when value is nil in from_h!' do + msg.from_h!({ 'jsonrpc' => nil }) + expect(msg.jsonrpc).to eq(rpc::JSONRPC_VERSION) + end + + it 'handles an empty hash in from_h!' do + expect { msg.from_h!({}) }.not_to raise_error + end + + it 'handles nil in from_h!' do + expect { msg.from_h!(nil) }.not_to raise_error + end + + it 'serializes to a hash via to_h' do + result = msg.to_h + expect(result).to have_key('jsonrpc') + expect(result['jsonrpc']).to eq(rpc::JSONRPC_VERSION) + end + + it 'serializes to JSON via to_json' do + expect(msg.to_json).to be_a(String) + expect(JSON.parse(msg.to_json)).to include('jsonrpc' => rpc::JSONRPC_VERSION) + end + end + + describe 'RequestMessage' do + subject(:req) { msgs::RequestMessage.new } + + it 'populates id, method, and params from from_h!' do + req.from_h!({ 'id' => 42, 'method' => 'puppet/getVersion', 'params' => { 'x' => 1 } }) + expect(req.id).to eq(42) + expect(req.rpc_method).to eq('puppet/getVersion') + expect(req.params).to eq({ 'x' => 1 }) + end + + it 'maps the method JSON key to the rpc_method accessor' do + req.from_h!({ 'id' => 1, 'method' => 'initialize' }) + expect(req.rpc_method).to eq('initialize') + end + + it 'includes id, method, and params in to_h' do + req.from_h!({ 'id' => 1, 'method' => 'initialize', 'params' => nil }) + result = req.to_h + expect(result).to include('id' => 1, 'method' => 'initialize', 'params' => nil) + expect(result['jsonrpc']).to eq(rpc::JSONRPC_VERSION) + end + + it 'can be instantiated with an initial hash' do + req2 = msgs::RequestMessage.new({ 'id' => 5, 'method' => 'shutdown' }) + expect(req2.id).to eq(5) + expect(req2.rpc_method).to eq('shutdown') + end + end + + describe 'NotificationMessage' do + subject(:notif) { msgs::NotificationMessage.new } + + it 'populates method and params from from_h!' do + notif.from_h!({ 'method' => 'initialized', 'params' => {} }) + expect(notif.rpc_method).to eq('initialized') + expect(notif.params).to eq({}) + end + + it 'includes params in to_h when present' do + notif.from_h!({ 'method' => 'textDocument/didChange', 'params' => { 'uri' => 'file:///a.pp' } }) + result = notif.to_h + expect(result['method']).to eq('textDocument/didChange') + expect(result['params']).to eq({ 'uri' => 'file:///a.pp' }) + end + + it 'omits params from to_h when nil' do + notif.from_h!({ 'method' => 'initialized' }) + result = notif.to_h + expect(result).not_to have_key('params') + end + + it 'includes the jsonrpc key in to_h' do + notif.from_h!({ 'method' => 'initialized' }) + result = notif.to_h + expect(result['jsonrpc']).to eq(rpc::JSONRPC_VERSION) + end + end + + describe 'ResponseMessage' do + subject(:resp) { msgs::ResponseMessage.new } + + describe 'successful response' do + it 'deserializes a successful response' do + resp.from_h!({ 'id' => 1, 'result' => 'ok' }) + expect(resp.id).to eq(1) + expect(resp.result).to eq('ok') + expect(resp.is_successful).to be(true) + end + + it 'includes result in to_h for successful responses' do + resp.from_h!({ 'id' => 1, 'result' => { 'value' => 42 } }) + result = resp.to_h + expect(result).to have_key('result') + expect(result['result']).to eq({ 'value' => 42 }) + expect(result).not_to have_key('error') + end + + it 'includes result in to_h even when result is null' do + resp.from_h!({ 'id' => 1, 'result' => nil }) + result = resp.to_h + expect(result).to have_key('result') + expect(result['result']).to be_nil + end + end + + describe 'error response' do + it 'deserializes an error response' do + resp.from_h!({ 'id' => 1, 'error' => { 'code' => -32_600, 'message' => 'Invalid Request' } }) + expect(resp.is_successful).to be(false) + expect(resp.error).to eq({ 'code' => -32_600, 'message' => 'Invalid Request' }) + end + + it 'includes error in to_h for error responses' do + resp.from_h!({ 'id' => 1, 'error' => { 'code' => -32_601, 'message' => 'Not Found' } }) + result = resp.to_h + expect(result).to have_key('error') + expect(result).not_to have_key('result') + end + end + + describe 'manually constructed response' do + it 'serializes as success when is_successful is true' do + resp.id = 1 + resp.result = 'value' + resp.is_successful = true + result = resp.to_h + expect(result).to have_key('result') + expect(result).not_to have_key('error') + end + + it 'serializes as error when is_successful is false' do + resp.id = 1 + resp.error = { 'code' => -32_603, 'message' => 'Internal Error' } + resp.is_successful = false + result = resp.to_h + expect(result).to have_key('error') + expect(result).not_to have_key('result') + end + end + end + + describe 'module-level factory methods' do + let(:request) do + msgs::RequestMessage.new({ 'id' => 10, 'method' => 'puppet/getVersion' }) + end + + describe '.reply_result' do + it 'creates a successful ResponseMessage' do + resp = msgs.reply_result(request, 'v1.2.3') + expect(resp).to be_a(msgs::ResponseMessage) + expect(resp.id).to eq(10) + expect(resp.is_successful).to be(true) + expect(resp.result).to eq('v1.2.3') + end + end + + describe '.reply_error' do + it 'creates an error ResponseMessage' do + resp = msgs.reply_error(request, -32_600, 'Bad Request') + expect(resp).to be_a(msgs::ResponseMessage) + expect(resp.id).to eq(10) + expect(resp.is_successful).to be(false) + expect(resp.error).to include('code' => -32_600, 'message' => 'Bad Request') + end + end + + describe '.reply_error_by_id' do + it 'creates an error ResponseMessage by explicit id' do + resp = msgs.reply_error_by_id(99, -32_603, 'Internal Error') + expect(resp).to be_a(msgs::ResponseMessage) + expect(resp.id).to eq(99) + expect(resp.error['code']).to eq(-32_603) + end + end + + describe '.reply_method_not_found' do + it 'creates a method-not-found error response' do + resp = msgs.reply_method_not_found(request) + expect(resp.is_successful).to be(false) + expect(resp.error['code']).to eq(rpc::CODE_METHOD_NOT_FOUND) + end + + it 'uses a custom message when provided' do + resp = msgs.reply_method_not_found(request, 'Custom not found message') + expect(resp.error['message']).to eq('Custom not found message') + end + end + + describe '.new_notification' do + it 'creates a NotificationMessage with the given method and params' do + notif = msgs.new_notification('textDocument/publishDiagnostics', { 'uri' => 'file:///a.pp' }) + expect(notif).to be_a(msgs::NotificationMessage) + expect(notif.rpc_method).to eq('textDocument/publishDiagnostics') + expect(notif.params).to eq({ 'uri' => 'file:///a.pp' }) + end + end + + describe '.new_request' do + it 'creates a RequestMessage with the given id, method, and params' do + req = msgs.new_request(7, 'window/showMessageRequest', { 'message' => 'hello' }) + expect(req).to be_a(msgs::RequestMessage) + expect(req.id).to eq(7) + expect(req.rpc_method).to eq('window/showMessageRequest') + expect(req.params).to eq({ 'message' => 'hello' }) + end + end + end +end diff --git a/spec/languageserver/unit/puppet-languageserver/crash_dump_spec.rb b/spec/languageserver/unit/puppet-languageserver/crash_dump_spec.rb new file mode 100644 index 000000000..5e4d0e59e --- /dev/null +++ b/spec/languageserver/unit/puppet-languageserver/crash_dump_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'tempfile' +require 'puppet-languageserver/crash_dump' + +describe 'PuppetLanguageServer::CrashDump' do + describe '.default_crash_file' do + it 'returns a String' do + expect(PuppetLanguageServer::CrashDump.default_crash_file).to be_a(String) + end + + it 'returns a path in the system temp directory' do + expect(PuppetLanguageServer::CrashDump.default_crash_file).to include(Dir.tmpdir) + end + + it 'includes a descriptive filename' do + result = PuppetLanguageServer::CrashDump.default_crash_file + expect(result).to include('puppet_language_server_crash') + end + end + + describe '.write_crash_file' do + let(:mock_error) do + raise 'test crash error' + rescue StandardError => e + e + end + + # rubocop:disable RSpec/VerifiedDoubles + let(:mock_documents) do + double('documents', + document_uris: [], + document_content: nil) + end + + let(:session_state) do + double('session_state', documents: mock_documents) + end + # rubocop:enable RSpec/VerifiedDoubles + + let(:crash_file) { Tempfile.new(['crash_test', '.txt']) } + + after { crash_file.unlink } + + it 'writes a crash file to the given path' do + PuppetLanguageServer::CrashDump.write_crash_file(mock_error, session_state, crash_file.path) + expect(File.size(crash_file.path)).to be > 0 + end + + it 'includes the error message in the crash file' do + PuppetLanguageServer::CrashDump.write_crash_file(mock_error, session_state, crash_file.path) + content = File.read(crash_file.path) + expect(content).to include('test crash error') + end + + it 'includes a backtrace in the crash file' do + PuppetLanguageServer::CrashDump.write_crash_file(mock_error, session_state, crash_file.path) + content = File.read(crash_file.path) + expect(content).to include('Backtrace') + end + + it 'uses the default crash file path when none is provided' do + default_path = PuppetLanguageServer::CrashDump.default_crash_file + PuppetLanguageServer::CrashDump.write_crash_file(mock_error, session_state) + expect(File.exist?(default_path)).to be(true) + ensure + FileUtils.rm_f(default_path) + end + + context 'with documents in the session state' do + # rubocop:disable RSpec/VerifiedDoubles + let(:mock_documents) do + double('documents', + document_uris: ['file:///example.pp'], + document_content: "class example {}\n") + end + # rubocop:enable RSpec/VerifiedDoubles + + it 'includes the document URI in the crash file' do + PuppetLanguageServer::CrashDump.write_crash_file(mock_error, session_state, crash_file.path) + expect(File.read(crash_file.path)).to include('file:///example.pp') + end + + it 'includes the document content in the crash file' do + PuppetLanguageServer::CrashDump.write_crash_file(mock_error, session_state, crash_file.path) + expect(File.read(crash_file.path)).to include('class example {}') + end + end + + context 'with additional objects' do + let(:extra_objects) { { 'Extra Info' => 'some additional data' } } + + it 'includes the extra key in the crash file' do + PuppetLanguageServer::CrashDump.write_crash_file(mock_error, session_state, crash_file.path, extra_objects) + expect(File.read(crash_file.path)).to include('Extra Info') + end + + it 'includes the extra value in the crash file' do + PuppetLanguageServer::CrashDump.write_crash_file(mock_error, session_state, crash_file.path, extra_objects) + expect(File.read(crash_file.path)).to include('some additional data') + end + end + + context 'when writing fails' do + it 'does not raise an error' do + expect do + PuppetLanguageServer::CrashDump.write_crash_file(mock_error, session_state, '/no/such/dir/crash.txt') + end.not_to raise_error + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 125e1c444..5478a8130 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -9,6 +9,7 @@ ] SimpleCov.start do + command_name "RSpec-#{ENV.fetch('COVERAGE_SUITE', 'default')}" track_files 'lib/**/*.rb' add_filter '/spec' add_filter '/tools'