switest 0.5.1 → 0.7.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 +4 -4
- data/README.md +29 -25
- data/lib/switest/agent.rb +34 -9
- data/lib/switest/assertions.rb +51 -0
- data/lib/switest/call.rb +196 -0
- data/lib/switest/client.rb +137 -0
- data/lib/switest/{esl/escaper.rb → escaper.rb} +1 -3
- data/lib/switest/events.rb +16 -65
- data/lib/switest/{esl/from_parser.rb → from_parser.rb} +1 -3
- data/lib/switest/scenario.rb +11 -52
- data/lib/switest/session.rb +49 -0
- data/lib/switest/version.rb +1 -1
- data/lib/switest.rb +6 -6
- metadata +10 -11
- data/lib/switest/case_insensitive_hash.rb +0 -17
- data/lib/switest/esl/call.rb +0 -209
- data/lib/switest/esl/client.rb +0 -213
- data/lib/switest/esl/connection.rb +0 -226
- data/lib/switest/esl/event.rb +0 -69
data/lib/switest/esl/client.rb
DELETED
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "securerandom"
|
|
4
|
-
require_relative "from_parser"
|
|
5
|
-
|
|
6
|
-
module Switest
|
|
7
|
-
module ESL
|
|
8
|
-
class Client
|
|
9
|
-
attr_reader :connection, :calls
|
|
10
|
-
|
|
11
|
-
def initialize(connection = nil)
|
|
12
|
-
@connection = connection
|
|
13
|
-
@calls = Concurrent::Map.new
|
|
14
|
-
@offer_handlers = []
|
|
15
|
-
@mutex = Mutex.new
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def start
|
|
19
|
-
@connection ||= Connection.new(
|
|
20
|
-
host: Switest.configuration.host,
|
|
21
|
-
port: Switest.configuration.port,
|
|
22
|
-
password: Switest.configuration.password
|
|
23
|
-
)
|
|
24
|
-
@connection.connect
|
|
25
|
-
|
|
26
|
-
# Register event handler with connection
|
|
27
|
-
@connection.on_event { |response| handle_response(response) }
|
|
28
|
-
|
|
29
|
-
self
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def stop
|
|
33
|
-
hangup_all
|
|
34
|
-
@connection&.disconnect
|
|
35
|
-
|
|
36
|
-
# Any remaining calls are orphaned - just clear them
|
|
37
|
-
@calls.clear
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Hangup all active calls individually and wait for them to end.
|
|
41
|
-
# This ensures proper hangup_cause is set for each call (important for CDRs).
|
|
42
|
-
# Sends all hangups first, then waits with a shared deadline to avoid
|
|
43
|
-
# O(n * timeout) delays with many calls.
|
|
44
|
-
def hangup_all(cause: "NORMAL_CLEARING", timeout: 5)
|
|
45
|
-
return unless @connection&.connected?
|
|
46
|
-
|
|
47
|
-
active = @calls.values.reject(&:ended?)
|
|
48
|
-
|
|
49
|
-
# Send all hangups without waiting
|
|
50
|
-
active.each do |call|
|
|
51
|
-
call.hangup(cause, wait: false) rescue nil
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Wait for all to end with a single shared deadline
|
|
55
|
-
deadline = Time.now + timeout
|
|
56
|
-
active.each do |call|
|
|
57
|
-
remaining = deadline - Time.now
|
|
58
|
-
break if remaining <= 0
|
|
59
|
-
call.wait_for_end(timeout: remaining) unless call.ended?
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def dial(to:, from: nil, timeout: nil, headers: {})
|
|
64
|
-
uuid = SecureRandom.uuid
|
|
65
|
-
|
|
66
|
-
# Build channel variables
|
|
67
|
-
vars = {
|
|
68
|
-
origination_uuid: uuid,
|
|
69
|
-
return_ring_ready: true
|
|
70
|
-
}
|
|
71
|
-
vars.merge!(FromParser.parse(from)) if from
|
|
72
|
-
vars[:originate_timeout] = timeout if timeout
|
|
73
|
-
|
|
74
|
-
var_string = Escaper.build_var_string(vars, headers)
|
|
75
|
-
|
|
76
|
-
# Create call object before originate
|
|
77
|
-
call = Call.new(
|
|
78
|
-
id: uuid,
|
|
79
|
-
connection: @connection,
|
|
80
|
-
direction: :outbound,
|
|
81
|
-
to: to,
|
|
82
|
-
from: from,
|
|
83
|
-
headers: headers
|
|
84
|
-
)
|
|
85
|
-
@calls[uuid] = call
|
|
86
|
-
|
|
87
|
-
# Originate call (park it so we control it)
|
|
88
|
-
begin
|
|
89
|
-
@connection.bgapi("originate #{var_string}#{to} &park")
|
|
90
|
-
rescue
|
|
91
|
-
@calls.delete(uuid)
|
|
92
|
-
raise
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
call
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def on_offer(&block)
|
|
99
|
-
@mutex.synchronize { @offer_handlers << block }
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def active_calls
|
|
103
|
-
result = {}
|
|
104
|
-
@calls.each_pair { |k, v| result[k] = v if v.alive? }
|
|
105
|
-
result
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
private
|
|
109
|
-
|
|
110
|
-
def handle_response(response)
|
|
111
|
-
return unless response && response[:body]
|
|
112
|
-
|
|
113
|
-
event = Event.parse(response[:body])
|
|
114
|
-
return unless event
|
|
115
|
-
|
|
116
|
-
handle_event(event)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def handle_event(event)
|
|
120
|
-
case event.name
|
|
121
|
-
when "CHANNEL_CREATE"
|
|
122
|
-
handle_channel_create(event)
|
|
123
|
-
when "CHANNEL_ANSWER"
|
|
124
|
-
handle_channel_answer(event)
|
|
125
|
-
when "CHANNEL_BRIDGE"
|
|
126
|
-
handle_channel_bridge(event)
|
|
127
|
-
when "CHANNEL_HANGUP_COMPLETE"
|
|
128
|
-
handle_channel_hangup(event)
|
|
129
|
-
when "DTMF"
|
|
130
|
-
handle_dtmf(event)
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def handle_channel_create(event)
|
|
135
|
-
uuid = event.uuid
|
|
136
|
-
return unless uuid
|
|
137
|
-
|
|
138
|
-
# Skip if we already have this call (outbound call we created)
|
|
139
|
-
return if @calls[uuid]
|
|
140
|
-
|
|
141
|
-
# Only handle inbound calls
|
|
142
|
-
direction = event.call_direction
|
|
143
|
-
return unless direction == "inbound"
|
|
144
|
-
|
|
145
|
-
call = Call.new(
|
|
146
|
-
id: uuid,
|
|
147
|
-
connection: @connection,
|
|
148
|
-
direction: :inbound,
|
|
149
|
-
to: event.destination,
|
|
150
|
-
from: event.caller_id,
|
|
151
|
-
headers: event.headers.dup
|
|
152
|
-
)
|
|
153
|
-
@calls[uuid] = call
|
|
154
|
-
|
|
155
|
-
# Notify offer handlers
|
|
156
|
-
fire_offer(call)
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def handle_channel_answer(event)
|
|
160
|
-
uuid = event.uuid
|
|
161
|
-
return unless uuid
|
|
162
|
-
|
|
163
|
-
call = @calls[uuid]
|
|
164
|
-
other_uuid = event["Other-Leg-Unique-ID"]
|
|
165
|
-
# For loopback calls, also check the other leg's UUID
|
|
166
|
-
call ||= @calls[other_uuid] if other_uuid
|
|
167
|
-
call&.handle_answer
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def handle_channel_bridge(event)
|
|
171
|
-
uuid = event.uuid
|
|
172
|
-
return unless uuid
|
|
173
|
-
|
|
174
|
-
call = @calls[uuid]
|
|
175
|
-
other_uuid = event["Other-Leg-Unique-ID"]
|
|
176
|
-
call ||= @calls[other_uuid] if other_uuid
|
|
177
|
-
call&.handle_bridge
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def handle_channel_hangup(event)
|
|
181
|
-
uuid = event.uuid
|
|
182
|
-
return unless uuid
|
|
183
|
-
|
|
184
|
-
call = @calls[uuid]
|
|
185
|
-
other_uuid = event["Other-Leg-Unique-ID"]
|
|
186
|
-
# For loopback calls, also check the other leg's UUID
|
|
187
|
-
call ||= @calls[other_uuid] if other_uuid
|
|
188
|
-
return unless call
|
|
189
|
-
|
|
190
|
-
call.handle_hangup(event.hangup_cause, event.headers)
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def handle_dtmf(event)
|
|
194
|
-
uuid = event.uuid
|
|
195
|
-
return unless uuid
|
|
196
|
-
|
|
197
|
-
call = @calls[uuid]
|
|
198
|
-
other_uuid = event["Other-Leg-Unique-ID"]
|
|
199
|
-
# For loopback calls, also check the other leg's UUID
|
|
200
|
-
call ||= @calls[other_uuid] if other_uuid
|
|
201
|
-
return unless call
|
|
202
|
-
|
|
203
|
-
digit = event["DTMF-Digit"]
|
|
204
|
-
call.handle_dtmf(digit) if digit
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def fire_offer(call)
|
|
208
|
-
handlers = @mutex.synchronize { @offer_handlers.dup }
|
|
209
|
-
handlers.each { |h| h.call(call) }
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
end
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "socket"
|
|
4
|
-
require "concurrent"
|
|
5
|
-
|
|
6
|
-
module Switest
|
|
7
|
-
module ESL
|
|
8
|
-
# Thread-safe ESL connection.
|
|
9
|
-
#
|
|
10
|
-
# All socket I/O is handled by a single reader thread to avoid race conditions.
|
|
11
|
-
# Commands are sent via a queue and responses are returned via promises.
|
|
12
|
-
class Connection
|
|
13
|
-
attr_reader :host, :port
|
|
14
|
-
|
|
15
|
-
def initialize(host:, port:, password:)
|
|
16
|
-
@host = host
|
|
17
|
-
@port = port
|
|
18
|
-
@password = password
|
|
19
|
-
@socket = nil
|
|
20
|
-
@running = false
|
|
21
|
-
@reader_thread = nil
|
|
22
|
-
@command_queue = Queue.new
|
|
23
|
-
@event_handlers = []
|
|
24
|
-
@mutex = Mutex.new
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def connect
|
|
28
|
-
@socket = TCPSocket.new(@host, @port)
|
|
29
|
-
authenticate
|
|
30
|
-
subscribe_events
|
|
31
|
-
@running = true
|
|
32
|
-
@reader_thread = Thread.new { reader_loop }
|
|
33
|
-
self
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def disconnect
|
|
37
|
-
@running = false
|
|
38
|
-
# Close socket to unblock any pending reads
|
|
39
|
-
@mutex.synchronize do
|
|
40
|
-
if @socket
|
|
41
|
-
@socket.close rescue nil
|
|
42
|
-
@socket = nil
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
# Fail any pending commands
|
|
46
|
-
until @command_queue.empty?
|
|
47
|
-
item = @command_queue.pop(true) rescue nil
|
|
48
|
-
item[:promise].fail(ConnectionError.new("Disconnected")) if item&.dig(:promise)
|
|
49
|
-
end
|
|
50
|
-
@reader_thread&.join(2)
|
|
51
|
-
@reader_thread = nil
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def connected?
|
|
55
|
-
@running && @socket && !@socket.closed?
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Send a command and wait for response (thread-safe)
|
|
59
|
-
def send_command(cmd, timeout: 5)
|
|
60
|
-
raise ConnectionError, "Not connected" unless connected?
|
|
61
|
-
|
|
62
|
-
promise = Concurrent::IVar.new
|
|
63
|
-
@command_queue.push({ cmd: cmd, promise: promise })
|
|
64
|
-
|
|
65
|
-
# Wait for response with timeout
|
|
66
|
-
result = promise.value(timeout)
|
|
67
|
-
if promise.pending?
|
|
68
|
-
raise Switest::Error, "Command timed out: #{cmd}"
|
|
69
|
-
elsif promise.rejected?
|
|
70
|
-
raise promise.reason
|
|
71
|
-
end
|
|
72
|
-
result
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Register a handler for incoming events
|
|
76
|
-
def on_event(&block)
|
|
77
|
-
@mutex.synchronize { @event_handlers << block }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def api(cmd)
|
|
81
|
-
response = send_command("api #{cmd}")
|
|
82
|
-
body = response[:body] || ""
|
|
83
|
-
raise Switest::Error, body if body.start_with?("-ERR")
|
|
84
|
-
body
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def bgapi(cmd)
|
|
88
|
-
send_command("bgapi #{cmd}")
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
private
|
|
92
|
-
|
|
93
|
-
def authenticate
|
|
94
|
-
# Read auth request (before reader thread starts)
|
|
95
|
-
response = read_response
|
|
96
|
-
unless response[:headers]["Content-Type"] == "auth/request"
|
|
97
|
-
raise AuthenticationError, "Expected auth/request, got: #{response[:headers]["Content-Type"]}"
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# Send password
|
|
101
|
-
@socket.write("auth #{@password}\n\n")
|
|
102
|
-
|
|
103
|
-
# Read reply
|
|
104
|
-
response = read_response
|
|
105
|
-
reply = response[:headers]["Reply-Text"] || ""
|
|
106
|
-
unless reply.start_with?("+OK")
|
|
107
|
-
raise AuthenticationError, "Authentication failed: #{reply}"
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def subscribe_events
|
|
112
|
-
events = %w[
|
|
113
|
-
CHANNEL_CREATE # Detect new inbound calls
|
|
114
|
-
CHANNEL_ANSWER # Track when calls are answered
|
|
115
|
-
CHANNEL_BRIDGE # Track when calls are bridged
|
|
116
|
-
CHANNEL_HANGUP_COMPLETE # Track call end with final headers
|
|
117
|
-
DTMF # Receive DTMF digits per call
|
|
118
|
-
].join(" ")
|
|
119
|
-
|
|
120
|
-
@socket.write("event plain #{events}\n\n")
|
|
121
|
-
response = read_response
|
|
122
|
-
reply = response[:headers]["Reply-Text"] || ""
|
|
123
|
-
unless reply.start_with?("+OK")
|
|
124
|
-
raise Switest::Error, "Failed to subscribe to events: #{reply}"
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# Main reader loop - handles both commands and events
|
|
129
|
-
def reader_loop
|
|
130
|
-
while @running
|
|
131
|
-
begin
|
|
132
|
-
# Process any pending commands first
|
|
133
|
-
process_pending_commands
|
|
134
|
-
|
|
135
|
-
# Check for incoming data with short timeout (allows checking queue regularly)
|
|
136
|
-
ready = IO.select([@socket], nil, nil, 0.1) rescue nil
|
|
137
|
-
break unless @running && @socket && !@socket.closed?
|
|
138
|
-
next unless ready
|
|
139
|
-
|
|
140
|
-
# Read and dispatch event (skip orphaned command replies)
|
|
141
|
-
response = read_response
|
|
142
|
-
content_type = response[:headers]["Content-Type"]
|
|
143
|
-
next if content_type == "command/reply" || content_type == "api/response"
|
|
144
|
-
|
|
145
|
-
if content_type == "text/disconnect-notice"
|
|
146
|
-
break
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
dispatch_event(response)
|
|
150
|
-
rescue IOError, Errno::EBADF, Errno::ECONNRESET
|
|
151
|
-
# Socket closed
|
|
152
|
-
break
|
|
153
|
-
rescue
|
|
154
|
-
# Log but continue on other errors
|
|
155
|
-
break unless @running
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def process_pending_commands
|
|
161
|
-
while @running && !@command_queue.empty?
|
|
162
|
-
item = @command_queue.pop(true) rescue nil
|
|
163
|
-
break unless item
|
|
164
|
-
|
|
165
|
-
begin
|
|
166
|
-
@socket.write("#{item[:cmd]}\n\n")
|
|
167
|
-
response = read_command_response
|
|
168
|
-
item[:promise].set(response)
|
|
169
|
-
rescue => e
|
|
170
|
-
item[:promise].fail(e)
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def dispatch_event(response)
|
|
176
|
-
handlers = @mutex.synchronize { @event_handlers.dup }
|
|
177
|
-
handlers.each { |h| h.call(response) }
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# Read responses until we get a command reply, dispatching any
|
|
181
|
-
# interleaved events that arrive before the reply.
|
|
182
|
-
def read_command_response
|
|
183
|
-
loop do
|
|
184
|
-
response = read_response
|
|
185
|
-
content_type = response[:headers]["Content-Type"]
|
|
186
|
-
|
|
187
|
-
case content_type
|
|
188
|
-
when "command/reply", "api/response"
|
|
189
|
-
return response
|
|
190
|
-
when "text/event-plain"
|
|
191
|
-
dispatch_event(response)
|
|
192
|
-
when "text/disconnect-notice"
|
|
193
|
-
raise ConnectionError, "Disconnected"
|
|
194
|
-
else
|
|
195
|
-
return response
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def read_response
|
|
201
|
-
headers = read_headers
|
|
202
|
-
body = nil
|
|
203
|
-
|
|
204
|
-
if (content_length = headers["Content-Length"]&.to_i) && content_length > 0
|
|
205
|
-
body = @socket.read(content_length)
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
{ headers: headers, body: body }
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def read_headers
|
|
212
|
-
headers = {}
|
|
213
|
-
loop do
|
|
214
|
-
line = @socket.gets
|
|
215
|
-
raise ConnectionError, "Connection closed" if line.nil?
|
|
216
|
-
line = line.chomp
|
|
217
|
-
break if line.empty?
|
|
218
|
-
|
|
219
|
-
key, value = line.split(": ", 2)
|
|
220
|
-
headers[key] = value if key && value
|
|
221
|
-
end
|
|
222
|
-
headers
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
end
|
data/lib/switest/esl/event.rb
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
|
-
module Switest
|
|
6
|
-
module ESL
|
|
7
|
-
class Event
|
|
8
|
-
attr_reader :headers
|
|
9
|
-
|
|
10
|
-
def self.parse(raw_data)
|
|
11
|
-
return nil if raw_data.nil? || raw_data.empty?
|
|
12
|
-
new(raw_data)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def initialize(raw_data)
|
|
16
|
-
@headers = Switest::CaseInsensitiveHash.new
|
|
17
|
-
parse_headers(raw_data)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Common accessors
|
|
21
|
-
def name
|
|
22
|
-
@headers["Event-Name"]
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def uuid
|
|
26
|
-
@headers["Unique-ID"] || @headers["Channel-Call-UUID"]
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def caller_id
|
|
30
|
-
@headers["Caller-Caller-ID-Number"]
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def destination
|
|
34
|
-
@headers["Caller-Destination-Number"]
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def call_direction
|
|
38
|
-
@headers["Call-Direction"]
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def hangup_cause
|
|
42
|
-
@headers["Hangup-Cause"]
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def [](key)
|
|
46
|
-
@headers[key]
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def variable(name)
|
|
50
|
-
@headers["variable_#{name}"]
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
private
|
|
54
|
-
|
|
55
|
-
def parse_headers(raw_data)
|
|
56
|
-
raw_data.each_line do |line|
|
|
57
|
-
line = line.strip
|
|
58
|
-
next if line.empty?
|
|
59
|
-
|
|
60
|
-
key, value = line.split(": ", 2)
|
|
61
|
-
next unless key && value
|
|
62
|
-
|
|
63
|
-
# ESL uses percent-encoding (%XX) but NOT + for spaces
|
|
64
|
-
@headers[key] = value.gsub(/%([0-9A-Fa-f]{2})/) { [$1.to_i(16)].pack("C") }
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|