From 4eb607f2156a6155e2bd3082c009dcea17df25fc Mon Sep 17 00:00:00 2001 From: nijeesh-stream Date: Tue, 12 May 2026 18:06:23 +0200 Subject: [PATCH 1/2] chore(spec): clean fellowship sediment + eventually matcher Two flakes in client_spec.rb, both shared-CI app state, neither an SDK bug: 1. queries-users: before(:all) seeds Frodo / Sam / Gandalf / Legolas and hard-deletes them in after(:all). When a CI run dies before the after(:all) fires, leftovers stay on the shared app and the next run's queries-users test (race=Hobbit, length==2) picks up sediment. Fix: - Extract cleanup into a cleanup_fellowship_leftovers!(client) helper, called from a dedicated before(:all) block so the concern is separate from the fellowship setup. - Keep the per-run unique race tag as defensive isolation for concurrent runs racing the cleanup -> upsert window. 2. custom commands create -> get / update / delete: backend write is eventually consistent; the read-side regularly hits 'the command "" does not exist'. Fix: add an RSpec block matcher expect { ... }.to eventually(matches?(value)) with chainable .with_retries(n) / .every(seconds). Replace the custom-commands loop_times calls with this matcher so the read-side reads as an eventual-consistency contract instead of a retry loop. The existing loop_times helper stays as a backward-compat alias delegating to a plain block helper, so other tests that use it keep working untouched. --- spec/client_spec.rb | 58 +++++++++++++++++++++++------ spec/spec_helper.rb | 4 ++ spec/support/matchers/eventually.rb | 47 +++++++++++++++++++++++ 3 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 spec/support/matchers/eventually.rb diff --git a/spec/client_spec.rb b/spec/client_spec.rb index b120f1e..6190e6a 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -6,6 +6,10 @@ require 'faraday' describe StreamChat::Client do + # Block-form retry helper. Kept under the original `loop_times` name to + # preserve backward compatibility with existing call sites. New tests + # should prefer the `eventually` RSpec matcher defined above, which reads + # as an eventual-consistency contract rather than a retry loop. def loop_times(times) loop do begin @@ -20,16 +24,44 @@ def loop_times(times) end end + # Hard-delete fellowship leftovers from prior CI runs whose after(:all) + # didn't fire. `delete_users` is idempotent so this is safe to re-run. + # Without it the shared test app accumulates Frodos / Sams / Gandalfs / + # Legolases forever and other tests that query users by name match sediment. + def cleanup_fellowship_leftovers!(client) + names = ['Frodo Baggins', 'Samwise Gamgee', 'Gandalf the Grey', 'Legolas'] + leftover = client.query_users({ 'name' => { '$in' => names } }) + leftover_ids = leftover['users'].map { |u| u['id'] } + return if leftover_ids.empty? + + client.delete_users(leftover_ids, + user: StreamChat::HARD_DELETE, + messages: StreamChat::HARD_DELETE) + rescue StandardError + # Best-effort: a failed cleanup must not block tests; the per-run race + # tag below isolates concurrent runs that race past this point. + end + before(:all) do @client = StreamChat::Client.from_env + cleanup_fellowship_leftovers!(@client) + end + before(:all) do @created_users = [] + # Per-run race markers so concurrent CI runs (and the seconds-window between + # the active cleanup above and the upserts below) don't collide on + # race=Hobbit / Elf / Istari lookups. + @hobbit_race = "Hobbit-#{SecureRandom.alphanumeric(8)}" + @elf_race = "Elf-#{SecureRandom.alphanumeric(8)}" + @istari_race = "Istari-#{SecureRandom.alphanumeric(8)}" + @fellowship_of_the_ring = [ - { id: SecureRandom.uuid, name: 'Frodo Baggins', race: 'Hobbit', age: 50 }, - { id: SecureRandom.uuid, name: 'Samwise Gamgee', race: 'Hobbit', age: 38 }, - { id: SecureRandom.uuid, name: 'Gandalf the Grey', race: 'Istari' }, - { id: SecureRandom.uuid, name: 'Legolas', race: 'Elf', age: 500 } + { id: SecureRandom.uuid, name: 'Frodo Baggins', race: @hobbit_race, age: 50 }, + { id: SecureRandom.uuid, name: 'Samwise Gamgee', race: @hobbit_race, age: 38 }, + { id: SecureRandom.uuid, name: 'Gandalf the Grey', race: @istari_race }, + { id: SecureRandom.uuid, name: 'Legolas', race: @elf_race, age: 500 } ] @legolas = @fellowship_of_the_ring[3][:id] @@ -588,7 +620,7 @@ def loop_times(times) end it 'queries users' do - response = @client.query_users({ 'race' => { '$eq' => 'Hobbit' } }, sort: { 'age' => -1 }) + response = @client.query_users({ 'race' => { '$eq' => @hobbit_race } }, sort: { 'age' => -1 }) expect(response['users'].length).to eq 2 expect([50, 38]).to eq(response['users'].map { |u| u['age'] }) end @@ -1025,20 +1057,22 @@ def loop_times(times) expect(cmd['description']).to eq 'I am testing' end + # Each follow-up step uses the `eventually` matcher to absorb the + # backend's eventual-consistency lag between Create / Get / Update / + # Delete — on a cold shared CI app the next call regularly hits + # "the command ... does not exist" until the write propagates. it 'get that command' do - cmd = @client.get_command(@cmd) - expect(cmd['name']).to eq @cmd - expect(cmd['description']).to eq 'I am testing' + expect { @client.get_command(@cmd) } + .to eventually(include('name' => @cmd, 'description' => 'I am testing')) end it 'update that command' do - cmd = @client.update_command(@cmd, { description: 'I tested' })['command'] - expect(cmd['name']).to eq @cmd - expect(cmd['description']).to eq 'I tested' + expect { @client.update_command(@cmd, { description: 'I tested' })['command'] } + .to eventually(include('name' => @cmd, 'description' => 'I tested')) end it 'delete that command' do - @client.delete_command(@cmd) + expect { @client.delete_command(@cmd) }.to eventually(be_truthy) end it 'list commands' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 86ec841..5842bd5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,3 +5,7 @@ formatter SimpleCov::Formatter::Console add_filter '/spec/' end + +# Autoload everything under spec/support — custom RSpec matchers, shared +# contexts, fixtures, etc. +Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } diff --git a/spec/support/matchers/eventually.rb b/spec/support/matchers/eventually.rb new file mode 100644 index 0000000..0f024d9 --- /dev/null +++ b/spec/support/matchers/eventually.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Block matcher that retries `expect { ... }` against an inner matcher until +# it passes or the budget runs out. Designed for the eventually-consistent +# bits of the shared-CI backend (e.g. create_command -> get_command races). +# +# expect { client.get_command(name) }.to eventually(include('name' => name)) +# +# Tweak the budget with chainable `.with_retries(n)` / `.every(seconds)`. +RSpec::Matchers.define :eventually do |inner_matcher| + supports_block_expectations + + match do |block| + @retries = (defined?(@retries_value) && @retries_value) || 3 + @interval = (defined?(@interval_value) && @interval_value) || 1 + @last_actual = nil + @last_error = nil + attempts = 0 + loop do + begin + @last_actual = block.call + break true if inner_matcher.matches?(@last_actual) + rescue StandardError, RSpec::Expectations::ExpectationNotMetError => e + @last_error = e + end + attempts += 1 + break false if attempts >= @retries + + sleep(@interval) + end + end + + chain :with_retries do |n| + @retries_value = n + end + + chain :every do |seconds| + @interval_value = seconds + end + + failure_message do |_block| + msg = "expected block to eventually #{inner_matcher.description}" + msg += "; last value: #{@last_actual.inspect}" unless @last_actual.nil? + msg += "; last error: #{@last_error.message}" if @last_error + msg + end +end From 3f982c7dc6bed5669bd8bff3de0042417894adb4 Mon Sep 17 00:00:00 2001 From: nijeesh-stream Date: Wed, 13 May 2026 13:24:32 +0200 Subject: [PATCH 2/2] chore(spec): per-test wall-clock timing + documentation reporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default rspec progress (dots) hides which test is in flight when a runner stalls. Last week's CI on Ruby 3.4 spent 41 minutes silently between two examples — the log only shows DEPRECATION warnings before and after, so the culprit was unidentifiable. Adds .rspec config (--format documentation, --profile 25) and a support/timing.rb hook that warns: [timing] start [timing] N.NN s on each example. Two effects: * A hung runner now leaves a 'start ...' breadcrumb so the next person knows exactly which spec was mid-flight. * End-of-run --profile output lists the 25 slowest, which surfaces the flake culprits without having to scan the whole transcript. --- .rspec | 3 +++ spec/support/timing.rb | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .rspec create mode 100644 spec/support/timing.rb diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..578a67d --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--profile 25 diff --git a/spec/support/timing.rb b/spec/support/timing.rb new file mode 100644 index 0000000..59573a6 --- /dev/null +++ b/spec/support/timing.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Per-test wall-clock timing emitted to STDOUT so CI logs surface which +# example is in flight when a runner stalls. The default `--format +# documentation` reporter prints test names but not timing; `--profile` +# only summarises at the end (no help when the run hangs before reaching +# the summary). This hook fills the gap: every example logs +# `[timing] start ...` on enter and `[timing] N.NN s ...` on exit with +# its full description path, so an unattended runner stuck mid-test +# leaves a clear breadcrumb. +RSpec.configure do |config| + config.before(:each) do |example| + example.metadata[:timing_started_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + warn "[timing] start #{example.full_description}" + end + + config.after(:each) do |example| + started_at = example.metadata[:timing_started_at] + next unless started_at + + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + status = example.exception ? 'fail' : 'pass' + warn format( + '[timing] %.2f s %s %s', + elapsed: elapsed, + status: status, + description: example.full_description + ) + end +end