switest 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d902a23fc1a0ad34adf1cf55feacc64a3f4b25bfed50e14b32249cb07cd68b8f
4
- data.tar.gz: 30dba87635d06da98bfb19468efe35442a415de21b5ee6acf1e741b670f6b17d
3
+ metadata.gz: 694639e976172b9f71b539b6bfc9d1decb8c2ae1735647c394a732213a419db7
4
+ data.tar.gz: bf525eb26fb1fbe8ed28cf7dbf760e08b8a3c9d4831b01e01355e69dceda42df
5
5
  SHA512:
6
- metadata.gz: aa1102910bc74897527ef14f4a4370f6bf019d09064d49bc5c5a47356f36ccee709a5924002f9dae9c2071d1abf82c5b90dc31dfb8e78eca562e94c39341dc5c
7
- data.tar.gz: a013d6e72d21fafd3461b0d5c5ad1d8be27d48afc97f9526032c91f8b2df2d3ec78904a8e8229279ed0d655d76343893783d562a1c227a5695fe23da121554ed
6
+ metadata.gz: edb3db5bd51e39e16707578d5a07c298edb10bac0188c5eebcbb34829677507203499837b3a58a7b1924003c3c925c9b61368ae45e5d373b21e6ea14f11c4a41
7
+ data.tar.gz: a2115e1b1429a1a7c7558dcf75fd4d54b53c64f9992723b5af742343d44c5fe2ad99ba5844f4712417d6f32711fa0d42a84d72bad67183bd0c0f94c3b7336c40
data/README.md CHANGED
@@ -1,11 +1,17 @@
1
+ <p align="center">
2
+ <img src=".github/banner.svg" alt="switest" width="800"/>
3
+ </p>
4
+
1
5
  # Switest
2
6
 
3
- Functional testing for voice applications via FreeSWITCH ESL.
7
+ Functional testing for voice applications via FreeSWITCH.
4
8
 
5
- Switest lets you write tests for your voice applications using direct
6
- ESL (Event Socket Library) communication with FreeSWITCH. Tests run as
7
- plain Minitest cases no Adhearsion, no Rayo, just a TCP socket to
8
- FreeSWITCH.
9
+ Switest lets you write tests for your voice applications using
10
+ [librevox](https://github.com/relatel/librevox) to communicate with
11
+ FreeSWITCH over Event Socket. Tests run as plain Minitest cases no
12
+ Adhearsion, no Rayo, just a TCP socket to FreeSWITCH. Each test runs
13
+ inside an [async](https://github.com/socketry/async) reactor for
14
+ fiber-based concurrency.
9
15
 
10
16
  ## Table of Contents
11
17
 
@@ -74,8 +80,9 @@ bundle exec rake test
74
80
  ### Scenario
75
81
 
76
82
  `Switest::Scenario` is a Minitest::Test subclass that handles FreeSWITCH
77
- connection lifecycle for you. Each test method gets a fresh ESL client that
78
- connects on setup and disconnects on teardown.
83
+ connection lifecycle for you. Each test method gets a fresh connection that
84
+ is established on setup and torn down after the test. The test body runs
85
+ inside an `Async` reactor, so all waits are fiber-based and non-blocking.
79
86
 
80
87
  ```ruby
81
88
  class MyTest < Switest::Scenario
@@ -137,6 +144,7 @@ Agent.listen_for_call(guards) # e.g. to: /pattern/, from: /pattern/
137
144
  agent.answer(wait: 5) # Answer an inbound call
138
145
  agent.hangup(wait: 5) # Hang up
139
146
  agent.reject(reason = :decline) # Reject inbound call (:decline or :busy)
147
+ agent.play_audio(url) # Play an audio file or tone stream
140
148
  agent.send_dtmf(digits) # Send DTMF tones
141
149
  agent.receive_dtmf(count:, timeout:) # Receive DTMF digits
142
150
  ```
@@ -145,8 +153,7 @@ agent.receive_dtmf(count:, timeout:) # Receive DTMF digits
145
153
 
146
154
  ```ruby
147
155
  agent.wait_for_call(timeout: 5) # Wait for inbound call to arrive
148
- agent.wait_for_answer(timeout: 5) # Wait for call to be answered
149
- agent.wait_for_bridge(timeout: 5) # Wait for call to be bridged
156
+ agent.wait_for_answer(timeout: 5) # Wait for call to be fully answered (ACTIVE)
150
157
  agent.wait_for_end(timeout: 5) # Wait for call to end
151
158
  ```
152
159
 
@@ -158,34 +165,30 @@ agent.alive? # Call exists and not ended?
158
165
  agent.active? # Answered and not ended?
159
166
  agent.answered? # Has been answered?
160
167
  agent.ended? # Has ended?
168
+ agent.inbound? # Is an inbound call?
169
+ agent.outbound? # Is an outbound call?
170
+ agent.id # Call UUID
171
+ agent.headers # Call headers hash
161
172
  agent.start_time # When call started
162
173
  agent.answer_time # When answered
174
+ agent.end_time # When ended
163
175
  agent.end_reason # e.g. "NORMAL_CLEARING"
164
176
  ```
165
177
 
166
178
  ### Scenario Assertions
167
179
 
168
- `Switest::Scenario` provides these assertions:
180
+ Available via `Switest::Assertions` (included in `Switest::Scenario`):
169
181
 
170
182
  ```ruby
171
183
  assert_call(agent, timeout: 5) # Agent receives a call
172
184
  assert_no_call(agent, timeout: 2) # Agent does NOT receive a call
173
- assert_answered(agent, timeout: 5) # Call has been answered
174
- assert_bridged(agent, timeout: 5) # Call has been bridged (see note below)
185
+ assert_answered(agent, timeout: 5) # Call is fully established (ACTIVE)
175
186
  assert_hungup(agent, timeout: 5) # Call has ended
176
187
  assert_not_hungup(agent, timeout: 2) # Call is still active
177
188
  assert_dtmf(agent, "123", timeout: 5) # Agent receives expected DTMF digits
178
189
  assert_dtmf(agent, "123") { other.send_dtmf("123") } # With block: flushes stale DTMF first
179
190
  ```
180
191
 
181
- **Note on `assert_bridged`:** This assertion only works when a CHANNEL_BRIDGE
182
- event fires on a channel Switest tracks. It works when the FreeSWITCH dialplan
183
- runs the `bridge` application on an inbound channel picked up via
184
- `listen_for_call`. It does **not** work for agent-to-agent calls routed through
185
- SIP gateways — the bridge happens on internal gateway legs whose UUIDs Switest
186
- doesn't track. For gateway scenarios, use `assert_answered` on both agents
187
- instead to confirm the call is connected.
188
-
189
192
  The `hangup_all` helper ends all active calls (useful before CDR assertions):
190
193
 
191
194
  ```ruby
@@ -272,12 +275,13 @@ docker compose up -d freeswitch # start FreeSWITCH
272
275
  docker compose run --rm test # run integration tests
273
276
  ```
274
277
 
275
- The compose file mounts three config files into FreeSWITCH:
278
+ The compose file mounts config files into FreeSWITCH:
276
279
 
277
280
  | Local file | Container path |
278
281
  |-----------------------------------------------|-----------------------------------------------------------------|
279
282
  | `docker/freeswitch/event_socket.conf.xml` | `/etc/freeswitch/autoload_configs/event_socket.conf.xml` |
280
283
  | `docker/freeswitch/acl.conf.xml` | `/etc/freeswitch/autoload_configs/acl.conf.xml` |
284
+ | `docker/freeswitch/switch.conf.xml` | `/etc/freeswitch/autoload_configs/switch.conf.xml` |
281
285
  | `docker/freeswitch/dialplan.xml` | `/etc/freeswitch/dialplan/public/00_switest.xml` |
282
286
 
283
287
  ### FreeSWITCH Requirements
@@ -312,13 +316,13 @@ The compose file mounts three config files into FreeSWITCH:
312
316
  ```ruby
313
317
  Switest.configure do |config|
314
318
  config.host = "127.0.0.1" # FreeSWITCH host
315
- config.port = 8021 # ESL port
316
- config.password = "ClueCon" # ESL password
319
+ config.port = 8021 # Event Socket port
320
+ config.password = "ClueCon" # Event Socket password
317
321
  config.default_timeout = 5 # Default timeout for waits
318
322
  end
319
323
  ```
320
324
 
321
- Or via environment variables (used by the integration test helper):
325
+ Or via environment variables (used by the scenario helper):
322
326
 
323
327
  ```bash
324
328
  FREESWITCH_HOST=127.0.0.1
@@ -329,7 +333,7 @@ FREESWITCH_PASSWORD=ClueCon
329
333
  ## Dependencies
330
334
 
331
335
  - Ruby >= 3.0
332
- - concurrent-ruby ~> 1.2
336
+ - librevox ~> 1.0
333
337
  - minitest >= 5.5, < 7.0
334
338
 
335
339
  ## License
data/lib/switest/agent.rb CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "async"
4
+ require "async/promise"
5
+
3
6
  module Switest
4
7
  class Agent
5
8
  class << self
@@ -30,8 +33,7 @@ module Switest
30
33
 
31
34
  # Register a one-time handler for matching inbound calls
32
35
  @events.once(:offer, guards) do |data|
33
- agent.instance_variable_set(:@call, data[:call])
34
- agent.instance_variable_get(:@call_event).set
36
+ agent.receive_call(data[:call])
35
37
  end
36
38
 
37
39
  agent
@@ -42,7 +44,7 @@ module Switest
42
44
 
43
45
  def initialize(call)
44
46
  @call = call
45
- @call_event = Concurrent::Event.new
47
+ @call_promise = Async::Promise.new
46
48
  end
47
49
 
48
50
  def call?
@@ -81,7 +83,9 @@ module Switest
81
83
 
82
84
  def wait_for_call(timeout: 5)
83
85
  return true if @call
84
- @call_event.wait(timeout)
86
+ Async::Task.current.with_timeout(timeout) { @call_promise.wait }
87
+ !@call.nil?
88
+ rescue Async::TimeoutError
85
89
  !@call.nil?
86
90
  end
87
91
 
@@ -90,11 +94,6 @@ module Switest
90
94
  @call.wait_for_answer(timeout: timeout)
91
95
  end
92
96
 
93
- def wait_for_bridge(timeout: 5)
94
- raise "No call to wait for" unless @call
95
- @call.wait_for_bridge(timeout: timeout)
96
- end
97
-
98
97
  def wait_for_end(timeout: 5)
99
98
  raise "No call to wait for" unless @call
100
99
  @call.wait_for_end(timeout: timeout)
@@ -117,6 +116,18 @@ module Switest
117
116
  @call&.ended? || false
118
117
  end
119
118
 
119
+ def outbound?
120
+ @call&.outbound? || false
121
+ end
122
+
123
+ def inbound?
124
+ @call&.inbound? || false
125
+ end
126
+
127
+ def id
128
+ @call&.id
129
+ end
130
+
120
131
  def start_time
121
132
  @call&.start_time
122
133
  end
@@ -125,8 +136,22 @@ module Switest
125
136
  @call&.answer_time
126
137
  end
127
138
 
139
+ def end_time
140
+ @call&.end_time
141
+ end
142
+
128
143
  def end_reason
129
144
  @call&.end_reason
130
145
  end
146
+
147
+ def headers
148
+ @call&.headers
149
+ end
150
+
151
+ # @api private — called by listen_for_call handler
152
+ def receive_call(call)
153
+ @call = call
154
+ @call_promise.resolve(true)
155
+ end
131
156
  end
132
157
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Switest
4
+ module Assertions
5
+ def assert_call(agent, timeout: 5)
6
+ success = agent.wait_for_call(timeout: timeout)
7
+ assert success, "Expected agent to receive a call within #{timeout} seconds"
8
+ end
9
+
10
+ def assert_no_call(agent, timeout: 2)
11
+ sleep timeout
12
+ refute agent.call?, "Expected agent to not have received a call"
13
+ end
14
+
15
+ def assert_answered(agent, timeout: 5)
16
+ assert agent.call?, "Agent has no call"
17
+ success = agent.wait_for_answer(timeout: timeout)
18
+ assert success, "Expected call to be answered within #{timeout} seconds"
19
+ end
20
+
21
+ def assert_hungup(agent, timeout: 5)
22
+ assert agent.call?, "Agent has no call"
23
+ success = agent.wait_for_end(timeout: timeout)
24
+ assert success, "Expected call to be hung up within #{timeout} seconds"
25
+ end
26
+
27
+ def assert_not_hungup(agent, timeout: 2)
28
+ assert agent.call?, "Agent has no call"
29
+ sleep timeout
30
+ refute agent.ended?, "Expected call to still be active"
31
+ end
32
+
33
+ def assert_dtmf(agent, expected_dtmf, timeout: 5, after: 1, &block)
34
+ assert agent.call?, "Agent has no call"
35
+
36
+ if block
37
+ agent.flush_dtmf
38
+ Async do
39
+ sleep after
40
+ block.call
41
+ end
42
+ received = agent.receive_dtmf(count: expected_dtmf.length, timeout: timeout + after)
43
+ else
44
+ received = agent.receive_dtmf(count: expected_dtmf.length, timeout: timeout)
45
+ end
46
+
47
+ assert_equal expected_dtmf, received, "Expected DTMF '#{expected_dtmf}' but received '#{received}'"
48
+ end
49
+
50
+ end
51
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/promise"
5
+ require "async/queue"
6
+
7
+ module Switest
8
+ class Call
9
+ CALLSTATE_MAP = { "RINGING" => :ringing, "ACTIVE" => :answered }.freeze
10
+
11
+ attr_reader :id, :to, :from, :headers, :direction
12
+ attr_reader :start_time, :answer_time, :end_time, :end_reason
13
+ attr_reader :state
14
+
15
+ def initialize(id:, direction:, to: nil, from: nil, headers: {}, session: nil)
16
+ @id = id
17
+ @session = session
18
+ @direction = direction # :inbound or :outbound
19
+ @to = to
20
+ @from = from
21
+ @headers = headers.is_a?(Hash) ? headers.dup : {}
22
+
23
+ @state = :new
24
+ @start_time = Time.now
25
+ @answer_time = nil
26
+ @end_time = nil
27
+ @end_reason = nil
28
+
29
+ @dtmf_queue = Async::Queue.new
30
+
31
+ @answered_promise = Async::Promise.new
32
+ @ended_promise = Async::Promise.new
33
+ end
34
+
35
+ # State queries
36
+ def alive?
37
+ @state != :ended
38
+ end
39
+
40
+ def active?
41
+ @state == :answered
42
+ end
43
+
44
+ def answered?
45
+ @state == :answered
46
+ end
47
+
48
+ def ended?
49
+ @state == :ended
50
+ end
51
+
52
+ def inbound?
53
+ @direction == :inbound
54
+ end
55
+
56
+ def outbound?
57
+ @direction == :outbound
58
+ end
59
+
60
+ # Actions
61
+ def answer(wait: 5)
62
+ return unless @state == :ringing && inbound?
63
+ sendmsg("execute", "answer")
64
+ return unless wait
65
+ wait_for_answer(timeout: wait)
66
+ end
67
+
68
+ def hangup(cause = "NORMAL_CLEARING", wait: 5)
69
+ return if ended?
70
+ sendmsg("hangup", hangup_cause: cause)
71
+ return unless wait
72
+ wait_for_end(timeout: wait)
73
+ end
74
+
75
+ def reject(reason = :decline, wait: 5)
76
+ return unless @state == :ringing && inbound?
77
+ cause = case reason
78
+ when :busy then "USER_BUSY"
79
+ when :decline then "CALL_REJECTED"
80
+ else "CALL_REJECTED"
81
+ end
82
+ hangup(cause, wait: wait)
83
+ end
84
+
85
+ def play_audio(url, wait: true)
86
+ sendmsg("execute", "playback", url, event_lock: wait)
87
+ end
88
+
89
+ def send_dtmf(digits, wait: true)
90
+ play_audio("tone_stream://d=200;w=250;#{digits}", wait: wait)
91
+ end
92
+
93
+ def flush_dtmf
94
+ @dtmf_queue.dequeue until @dtmf_queue.empty?
95
+ end
96
+
97
+ def receive_dtmf(count: 1, timeout: 5)
98
+ digits = String.new
99
+ deadline = Time.now + timeout
100
+
101
+ count.times do
102
+ remaining = deadline - Time.now
103
+ break if remaining <= 0
104
+
105
+ Async::Task.current.with_timeout(remaining) do
106
+ digits << @dtmf_queue.dequeue
107
+ end
108
+ rescue Async::TimeoutError
109
+ break
110
+ end
111
+
112
+ digits
113
+ end
114
+
115
+ # Blocking waits
116
+ def wait_for_answer(timeout: 5)
117
+ return true if answered?
118
+ Async::Task.current.with_timeout(timeout) { @answered_promise.wait }
119
+ answered?
120
+ rescue Async::TimeoutError
121
+ answered?
122
+ end
123
+
124
+ def wait_for_end(timeout: 5)
125
+ return true if ended?
126
+ Async::Task.current.with_timeout(timeout) { @ended_promise.wait }
127
+ ended?
128
+ rescue Async::TimeoutError
129
+ ended?
130
+ end
131
+
132
+ # Internal: dispatch a librevox Response event to the appropriate handler.
133
+ def handle_event(response)
134
+ return unless response.event?
135
+
136
+ case response.event
137
+ when "CHANNEL_CALLSTATE"
138
+ handle_callstate(response.content[:channel_call_state])
139
+ when "CHANNEL_HANGUP_COMPLETE"
140
+ cause = response.content[:hangup_cause]
141
+ handle_hangup(cause, response.content)
142
+ when "DTMF"
143
+ digit = response.content[:dtmf_digit]
144
+ handle_dtmf(digit) if digit
145
+ end
146
+ end
147
+
148
+ # Internal state updates
149
+ def handle_callstate(call_state)
150
+ return if @state == :ended
151
+ new_state = CALLSTATE_MAP[call_state]
152
+ return if new_state.nil? || @state == new_state
153
+
154
+ @state = new_state
155
+ if new_state == :answered
156
+ @answer_time = Time.now
157
+ @answered_promise.resolve(true)
158
+ end
159
+ end
160
+
161
+ def handle_hangup(cause, event_content = {})
162
+ return if @state == :ended
163
+ @state = :ended
164
+ @end_time = Time.now
165
+ @end_reason = cause
166
+
167
+ # Merge event data into headers
168
+ if event_content.is_a?(Hash)
169
+ event_content.each do |k, v|
170
+ next if k == :body
171
+ @headers[k] = v.to_s
172
+ end
173
+ end
174
+
175
+ @answered_promise.resolve(true)
176
+ @ended_promise.resolve(true)
177
+ end
178
+
179
+ def handle_dtmf(digit)
180
+ @dtmf_queue.enqueue(digit)
181
+ end
182
+
183
+ private
184
+
185
+ def sendmsg(command, app = nil, arg = nil, event_lock: false, hangup_cause: nil)
186
+ msg = +"sendmsg #{@id}\n"
187
+ msg << "call-command: #{command}\n"
188
+ msg << "execute-app-name: #{app}\n" if app
189
+ msg << "execute-app-arg: #{arg}\n" if arg
190
+ msg << "event-lock: true\n" if event_lock
191
+ msg << "hangup-cause: #{hangup_cause}" if hangup_cause
192
+ @session.command(msg.chomp)
193
+ end
194
+
195
+ end
196
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "async/promise"
5
+ require "io/endpoint/host_endpoint"
6
+ require_relative "from_parser"
7
+
8
+ module Switest
9
+ class Client
10
+ attr_reader :calls
11
+
12
+ def initialize
13
+ @calls = {}
14
+ @offer_handlers = []
15
+ @session = nil
16
+ @client_task = nil
17
+ end
18
+
19
+ def start
20
+ config = Switest.configuration
21
+
22
+ # Configure Session class with client state
23
+ Session.call_registry = @calls
24
+ Session.offer_handler = method(:handle_inbound_offer)
25
+ Session.connection_promise = Async::Promise.new
26
+
27
+ endpoint = IO::Endpoint.tcp(config.host, config.port)
28
+ client = Librevox::Client.new(Session, endpoint, auth: config.password)
29
+ @client_task = Async { client.run }
30
+
31
+ # Wait for the session to be established
32
+ @session = Async::Task.current.with_timeout(config.default_timeout) do
33
+ Session.connection_promise.wait
34
+ end
35
+
36
+ self
37
+ rescue Async::TimeoutError
38
+ @client_task&.stop
39
+ @client_task = nil
40
+ raise Switest::ConnectionError, "Timed out connecting to FreeSWITCH"
41
+ end
42
+
43
+ def stop
44
+ hangup_all
45
+ @client_task&.stop
46
+ @client_task = nil
47
+ @session = nil
48
+ @calls.clear
49
+ Session.call_registry = nil
50
+ Session.offer_handler = nil
51
+ Session.connection_promise = nil
52
+ end
53
+
54
+ def connected?
55
+ !@session.nil?
56
+ end
57
+
58
+ def hangup_all(cause: "NORMAL_CLEARING", timeout: 5)
59
+ active = @calls.values.reject(&:ended?)
60
+
61
+ active.each do |call|
62
+ call.hangup(cause, wait: false) rescue nil
63
+ end
64
+
65
+ deadline = Time.now + timeout
66
+ active.each do |call|
67
+ remaining = deadline - Time.now
68
+ break if remaining <= 0
69
+ call.wait_for_end(timeout: remaining) unless call.ended?
70
+ end
71
+ end
72
+
73
+ def dial(to:, from: nil, timeout: nil, headers: {})
74
+ uuid = SecureRandom.uuid
75
+
76
+ vars = {
77
+ origination_uuid: uuid,
78
+ return_ring_ready: true
79
+ }
80
+ vars.merge!(FromParser.parse(from)) if from
81
+ vars[:originate_timeout] = timeout if timeout
82
+
83
+ var_string = Escaper.build_var_string(vars, headers)
84
+
85
+ call = Call.new(
86
+ id: uuid,
87
+ direction: :outbound,
88
+ to: to,
89
+ from: from,
90
+ headers: headers,
91
+ session: @session
92
+ )
93
+ @calls[uuid] = call
94
+
95
+ begin
96
+ @session.bgapi("originate #{var_string}#{to} &park()")
97
+ rescue
98
+ @calls.delete(uuid)
99
+ raise
100
+ end
101
+
102
+ call
103
+ end
104
+
105
+ def on_offer(&block)
106
+ @offer_handlers << block
107
+ end
108
+
109
+ def active_calls
110
+ @calls.select { |_k, v| v.alive? }
111
+ end
112
+
113
+ private
114
+
115
+ def handle_inbound_offer(event)
116
+ uuid = event.content[:unique_id]
117
+ data = event.content
118
+
119
+ call = Call.new(
120
+ id: uuid,
121
+ direction: :inbound,
122
+ to: data[:caller_destination_number],
123
+ from: data[:caller_caller_id_number],
124
+ headers: data,
125
+ session: @session
126
+ )
127
+ call.handle_callstate("RINGING")
128
+ @calls[uuid] = call
129
+
130
+ fire_offer(call)
131
+ end
132
+
133
+ def fire_offer(call)
134
+ @offer_handlers.each { |h| h.call(call) }
135
+ end
136
+ end
137
+ end
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Switest
4
- module ESL
5
- # Escapes values for use in FreeSWITCH channel variable strings.
4
+ # Escapes values for use in FreeSWITCH channel variable strings.
6
5
  #
7
6
  # FreeSWITCH originate syntax: {var1=value1,var2=value2}endpoint
8
7
  #
@@ -139,6 +138,5 @@ module Switest
139
138
  # Fallback - this should rarely happen
140
139
  ":"
141
140
  end
142
- end
143
141
  end
144
142
  end