diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index cbaf94b..4293ff7 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -25,3 +25,8 @@ runs: - name: Run tests shell: bash run: bundle _2.2.10_ exec rspec spec + + - name: Run contract tests + if: ${{ !startsWith(inputs.ruby-version, 'jruby') }} + shell: bash + run: make contract-tests diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..34a27c6 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log + +build-contract-tests: + @cd contract-tests && bundle _2.2.10_ install + +start-contract-test-service: + @cd contract-tests && bundle _2.2.10_ exec ruby service.rb + +start-contract-test-service-bg: + @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" + @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & + +run-contract-tests: + @curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/v1.0.0/downloader/run.sh \ + | VERSION=v1 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end" sh + +contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests + +.PHONY: build-contract-tests start-contract-test-service run-contract-tests contract-tests diff --git a/contract-tests/Gemfile b/contract-tests/Gemfile new file mode 100644 index 0000000..84bdd56 --- /dev/null +++ b/contract-tests/Gemfile @@ -0,0 +1,10 @@ +source 'https://rubygems.org' + +gem 'ld-eventsource', path: '..' + +gem 'sinatra', '~> 2.1' +# Sinatra can work with several server frameworks. In JRuby, we have to use glassfish (which +# is only available in JRuby). Otherwise we use thin (which is not available in JRuby). +gem 'glassfish', :platforms => :jruby +gem 'thin', :platforms => :ruby +gem 'json' diff --git a/contract-tests/README.md b/contract-tests/README.md new file mode 100644 index 0000000..37cc97c --- /dev/null +++ b/contract-tests/README.md @@ -0,0 +1,5 @@ +# SSE client contract test service + +This directory contains an implementation of the cross-platform SSE testing protocol defined by https://github.com/launchdarkly/sse-contract-tests. See that project's `README` for details of this protocol, and the kinds of SSE client capabilities that are relevant to the contract tests. This code should not need to be updated unless the SSE client has added or removed such capabilities. + +To run these tests locally, run `make contract-tests` from the project root directory. This downloads the correct version of the test harness tool automatically. diff --git a/contract-tests/service.rb b/contract-tests/service.rb new file mode 100644 index 0000000..839822c --- /dev/null +++ b/contract-tests/service.rb @@ -0,0 +1,77 @@ +require 'ld-eventsource' +require 'json' +require 'logger' +require 'net/http' +require 'sinatra' + +require './stream_entity.rb' + +$log = Logger.new(STDOUT) +$log.formatter = proc {|severity, datetime, progname, msg| + "#{datetime.strftime('%Y-%m-%d %H:%M:%S.%3N')} #{severity} #{progname} #{msg}\n" +} + +set :port, 8000 +set :logging, false + +streams = {} +streamCounter = 0 + +get '/' do + { + capabilities: [ + 'headers', + 'last-event-id', + 'read-timeout' + ] + }.to_json +end + +delete '/' do + $log.info("Test service has told us to exit") + Thread.new { sleep 1; exit } + return 204 +end + +post '/' do + opts = JSON.parse(request.body.read, :symbolize_names => true) + streamUrl = opts[:streamUrl] + callbackUrl = opts[:callbackUrl] + tag = "[#{opts[:tag]}]:" + + if !streamUrl || !callbackUrl + $log.error("#{tag} Received request with incomplete parameters: #{opts}") + return 400 + end + + streamCounter += 1 + streamId = streamCounter.to_s + streamResourceUrl = "/streams/#{streamId}" + + $log.info("#{tag} Starting stream from #{streamUrl}") + $log.debug("#{tag} Parameters: #{opts}") + + entity = nil + sse = SSE::Client.new( + streamUrl, + headers: opts[:headers] || {}, + last_event_id: opts[:lastEventId], + read_timeout: opts[:readTimeoutMs].nil? ? nil : (opts[:readTimeoutMs].to_f / 1000), + reconnect_time: opts[:initialDelayMs].nil? ? nil : (opts[:initialDelayMs].to_f / 1000) + ) do |sse| + entity = StreamEntity.new(sse, tag, callbackUrl) + end + + streams[streamId] = entity + + return [201, {"Location" => streamResourceUrl}, nil] +end + +delete '/streams/:id' do |streamId| + entity = streams[streamId] + return 404 if entity.nil? + streams.delete(streamId) + entity.close + + return 204 +end diff --git a/contract-tests/stream_entity.rb b/contract-tests/stream_entity.rb new file mode 100644 index 0000000..0c62543 --- /dev/null +++ b/contract-tests/stream_entity.rb @@ -0,0 +1,58 @@ +require 'ld-eventsource' +require 'json' +require 'net/http' + +set :port, 8000 +set :logging, false + +class StreamEntity + def initialize(sse, tag, callbackUrl) + @sse = sse + @tag = tag + @callbackUrl = callbackUrl + @callbackCounter = 0 + + sse.on_event { |event| self.on_event(event) } + sse.on_error { |error| self.on_error(error) } + end + + def on_event(event) + $log.info("#{@tag} Received event from stream (#{event.type})") + message = { + kind: 'event', + event: { + type: event.type, + data: event.data, + id: event.last_event_id + } + } + self.send_message(message) + end + + def on_error(error) + $log.info("#{@tag} Received error from stream: #{error}") + message = { + kind: 'error', + error: error + } + self.send_message(message) + end + + def send_message(message) + @callbackCounter += 1 + uri = "#{@callbackUrl}/#{@callbackCounter}" + begin + resp = Net::HTTP.post(URI(uri), JSON.generate(message)) + if resp.code.to_i >= 300 + $log.error("#{@tag} Callback to #{url} returned status #{resp.code}") + end + rescue => e + $log.error("#{@tag} Callback to #{url} failed: #{e}") + end + end + + def close + @sse.close + $log.info("#{@tag} Test ended") + end +end