expect-pty 0.3.2 → 0.4.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/.rubocop.yml +18 -10
- data/CHANGELOG.md +42 -12
- data/CONTRIBUTING.md +3 -1
- data/Gemfile +1 -0
- data/README.md +194 -80
- data/benchmark/matching.rb +26 -0
- data/benchmark/scaling.rb +70 -0
- data/benchmark/support.rb +23 -3
- data/docs/COMPATIBILITY.md +68 -57
- data/docs/INTERNAL_CONTRACTS.md +95 -24
- data/docs/PERFORMANCE.md +71 -9
- data/docs/RELEASING.md +8 -6
- data/docs/VERIFICATION.md +213 -81
- data/lib/expect/configuration.rb +3 -1
- data/lib/expect/interaction.rb +18 -3
- data/lib/expect/logging.rb +200 -0
- data/lib/expect/matcher.rb +70 -8
- data/lib/expect/pattern.rb +11 -4
- data/lib/expect/pattern_list.rb +3 -0
- data/lib/expect/redactor.rb +81 -0
- data/lib/expect/relay.rb +12 -0
- data/lib/expect/relay_writer.rb +9 -0
- data/lib/expect/result.rb +2 -0
- data/lib/expect/session_resources.rb +6 -2
- data/lib/expect/terminal.rb +36 -0
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +84 -152
- data/script/ci +30 -3
- data/script/release.rb +37 -5
- data/test/buffer_accounting_test.rb +83 -0
- data/test/cleanup_test.rb +88 -0
- data/test/deadline_test.rb +237 -0
- data/test/diagnostics_test.rb +353 -0
- data/test/lifecycle_contract_test.rb +71 -0
- data/test/literal_scan_test.rb +97 -0
- data/test/pattern_offset_test.rb +3 -1
- data/test/process_test.rb +46 -0
- data/test/release_test.rb +94 -16
- data/test/timeout_test.rb +119 -0
- metadata +11 -2
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
class DiagnosticsTest < ExpectTest
|
|
7
|
+
def test_logger_receives_lifecycle_and_payload_at_separate_levels
|
|
8
|
+
session, writer = pipe_session(debug_level: 2)
|
|
9
|
+
output = StringIO.new
|
|
10
|
+
logger = Logger.new(output)
|
|
11
|
+
logger.level = Logger::INFO
|
|
12
|
+
session.diagnostic_output = logger
|
|
13
|
+
writer.write("ready")
|
|
14
|
+
session.expect("ready", timeout: 1)
|
|
15
|
+
assert_includes output.string, "matched pattern 1"
|
|
16
|
+
refute_includes output.string, "received"
|
|
17
|
+
logger.level = Logger::DEBUG
|
|
18
|
+
writer.write("next")
|
|
19
|
+
session.expect("next", timeout: 1)
|
|
20
|
+
assert_includes output.string, 'received "next"'
|
|
21
|
+
session.close
|
|
22
|
+
refute output.closed?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_constructor_accepts_borrowed_diagnostic_io_and_keeps_transcript_separate
|
|
26
|
+
diagnostics = StringIO.new
|
|
27
|
+
session, writer = pipe_session(debug_level: 2, diagnostic_output: diagnostics)
|
|
28
|
+
transcript = StringIO.new
|
|
29
|
+
session.log_to(transcript)
|
|
30
|
+
writer.write("ready")
|
|
31
|
+
session.expect("ready", timeout: 1)
|
|
32
|
+
assert_equal "ready", transcript.string
|
|
33
|
+
assert_includes diagnostics.string, 'received "ready"'
|
|
34
|
+
session.close
|
|
35
|
+
refute diagnostics.closed?
|
|
36
|
+
refute transcript.closed?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_callable_diagnostics_receive_immutable_metadata_without_the_session
|
|
40
|
+
events = []
|
|
41
|
+
session, writer = pipe_session(debug_level: 2, diagnostic_output: ->(event) { events << event })
|
|
42
|
+
writer.write("ready")
|
|
43
|
+
session.expect("ready", timeout: 1)
|
|
44
|
+
event = events.find { |item| item[:event] == :received }
|
|
45
|
+
assert event.frozen?
|
|
46
|
+
assert event[:message].frozen?
|
|
47
|
+
assert_equal :debug, event[:level]
|
|
48
|
+
assert_equal session.fileno, event[:fd]
|
|
49
|
+
assert_equal 'received "ready"', event[:message]
|
|
50
|
+
refute_includes event.values, session
|
|
51
|
+
assert_equal :info, events.last[:level]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_invalid_diagnostic_target_preserves_the_previous_target
|
|
55
|
+
session, = pipe_session
|
|
56
|
+
output = StringIO.new
|
|
57
|
+
session.diagnostic_output = output
|
|
58
|
+
assert_raises(ArgumentError) { session.diagnostic_output = Object.new }
|
|
59
|
+
assert_same output, session.diagnostic_output
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_diagnostic_failure_keeps_original_input_available
|
|
63
|
+
session, writer = pipe_session(debug_level: 2)
|
|
64
|
+
failure = IOError.new("diagnostic failed")
|
|
65
|
+
session.diagnostic_output = ->(_) { raise failure }
|
|
66
|
+
writer.write("ready")
|
|
67
|
+
result = session.expect_result("ready", timeout: 1)
|
|
68
|
+
assert_same failure, result.error
|
|
69
|
+
assert_equal "ready", session.buffer
|
|
70
|
+
session.debug_level = 0
|
|
71
|
+
assert_equal 1, session.expect("ready", timeout: 0)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_nested_diagnostic_wait_preserves_the_outer_match_result
|
|
75
|
+
session, = pipe_session(debug_level: 1)
|
|
76
|
+
session.buffer = "ready"
|
|
77
|
+
nested = nil
|
|
78
|
+
notified = false
|
|
79
|
+
callback_result = nil
|
|
80
|
+
session.diagnostic_output = lambda do |event|
|
|
81
|
+
next unless event[:event] == :matched && !notified
|
|
82
|
+
|
|
83
|
+
notified = true
|
|
84
|
+
nested = session.expect_result("missing", timeout: 0)
|
|
85
|
+
end
|
|
86
|
+
result = session.expect_result(timeout: 0) do |patterns|
|
|
87
|
+
patterns.on("ready") { |source| callback_result = source.last_result }
|
|
88
|
+
end
|
|
89
|
+
assert nested.timeout?
|
|
90
|
+
assert result.matched?
|
|
91
|
+
assert_equal "ready", result.match
|
|
92
|
+
assert_same result, callback_result
|
|
93
|
+
assert_same result, session.last_result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_redaction_handles_every_two_chunk_split_and_overlapping_secrets
|
|
97
|
+
(0..11).each do |split|
|
|
98
|
+
session, = pipe_session
|
|
99
|
+
output = StringIO.new
|
|
100
|
+
session.log_to(output)
|
|
101
|
+
session.redact("abc", "bcd", "secret")
|
|
102
|
+
text = "xabcdsecret!"
|
|
103
|
+
session.write_log(text.byteslice(0, split))
|
|
104
|
+
session.write_log(text.byteslice(split..))
|
|
105
|
+
session.log_output = nil
|
|
106
|
+
assert_equal "x[FILTERED]!", output.string, "split #{split}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_byte_at_a_time_redaction_preserves_matching_and_listeners
|
|
111
|
+
session, writer = pipe_session(debug_level: 3)
|
|
112
|
+
events = []
|
|
113
|
+
log = StringIO.new
|
|
114
|
+
forwarded = StringIO.new
|
|
115
|
+
session.diagnostic_output = ->(event) { events << event }
|
|
116
|
+
session.log_to(log)
|
|
117
|
+
session.listeners = [forwarded]
|
|
118
|
+
session.redact("password")
|
|
119
|
+
"password!".each_char do |byte|
|
|
120
|
+
writer.write(byte)
|
|
121
|
+
session.__send__(:read_available)
|
|
122
|
+
refute_includes log.string, "password"
|
|
123
|
+
end
|
|
124
|
+
assert_equal 1, session.expect("password!", timeout: 0)
|
|
125
|
+
writer.close
|
|
126
|
+
assert session.expect_result(:eof, timeout: 1).eof?
|
|
127
|
+
assert_equal "[FILTERED]!", log.string
|
|
128
|
+
assert_equal "password!", forwarded.string
|
|
129
|
+
received = events.select { |event| event[:event] == :received }.map { |event| event[:message] }.join
|
|
130
|
+
assert_includes received, "[FILTERED]"
|
|
131
|
+
refute_includes events.inspect, "password"
|
|
132
|
+
buffers = events.select { |event| event[:event] == :buffer }
|
|
133
|
+
assert(buffers.all? { |event| event[:message] == "buffer [FILTERED]" })
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_sending_diagnostics_are_filtered_across_separate_writes
|
|
137
|
+
client, peer = Socket.pair(:UNIX, :STREAM, 0)
|
|
138
|
+
@ios.push(client, peer)
|
|
139
|
+
output = StringIO.new
|
|
140
|
+
session = Expect.open(client, debug_level: 2, diagnostic_output: output)
|
|
141
|
+
@sessions << session
|
|
142
|
+
session.redact("password")
|
|
143
|
+
%w[pa ss wo rd].each { |part| session.write(part) }
|
|
144
|
+
assert_equal "password", peer.read(8)
|
|
145
|
+
session.close
|
|
146
|
+
refute_includes output.string, "password"
|
|
147
|
+
refute_includes output.string, '"pa"'
|
|
148
|
+
assert_includes output.string, "[FILTERED]"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def test_registration_copies_secrets_is_additive_and_isolated_between_sessions
|
|
152
|
+
first, = pipe_session
|
|
153
|
+
second, = pipe_session
|
|
154
|
+
first_log = StringIO.new
|
|
155
|
+
second_log = StringIO.new
|
|
156
|
+
first.log_to(first_log)
|
|
157
|
+
second.log_to(second_log)
|
|
158
|
+
secret = +"first"
|
|
159
|
+
assert_same first, first.redact(secret)
|
|
160
|
+
secret.replace("changed")
|
|
161
|
+
first.write_log("fi")
|
|
162
|
+
first.redact("second")
|
|
163
|
+
first.write_log("rst second")
|
|
164
|
+
second.write_log("first second")
|
|
165
|
+
first.close
|
|
166
|
+
assert_equal "[FILTERED] [FILTERED]", first_log.string
|
|
167
|
+
assert_equal "first second", second_log.string
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_binary_secrets_are_matched_before_diagnostic_escaping
|
|
171
|
+
session, = pipe_session(debug_level: 2)
|
|
172
|
+
log = StringIO.new
|
|
173
|
+
diagnostics = StringIO.new
|
|
174
|
+
session.log_to(log)
|
|
175
|
+
session.diagnostic_output = diagnostics
|
|
176
|
+
secret = "\xff\0\n".b
|
|
177
|
+
session.redact(secret)
|
|
178
|
+
session.write_log("before#{secret}after".b)
|
|
179
|
+
session.__send__(:trace_data, :received, secret, level: 2)
|
|
180
|
+
session.close
|
|
181
|
+
assert_equal "before[FILTERED]after", log.string
|
|
182
|
+
refute_includes diagnostics.string, '\\xFF'
|
|
183
|
+
assert_includes diagnostics.string, "[FILTERED]"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def test_eof_and_close_flush_benign_tails_but_hide_incomplete_secret_prefixes
|
|
187
|
+
session, writer = pipe_session
|
|
188
|
+
log = StringIO.new
|
|
189
|
+
session.log_to(log)
|
|
190
|
+
session.redact("password")
|
|
191
|
+
writer.write("hello pass")
|
|
192
|
+
writer.close
|
|
193
|
+
assert session.expect_result(:eof, timeout: 1).eof?
|
|
194
|
+
assert_equal "hello [FILTERED]", log.string
|
|
195
|
+
session.close
|
|
196
|
+
assert_equal "hello [FILTERED]", log.string
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def test_replacing_log_finishes_the_original_target_and_keeps_it_borrowed
|
|
200
|
+
session, = pipe_session
|
|
201
|
+
first = StringIO.new
|
|
202
|
+
second = StringIO.new
|
|
203
|
+
session.redact("secret")
|
|
204
|
+
session.log_to(first)
|
|
205
|
+
session.write_log("hello")
|
|
206
|
+
session.log_to(second)
|
|
207
|
+
session.write_log("secret!")
|
|
208
|
+
session.close
|
|
209
|
+
assert_equal "hello", first.string
|
|
210
|
+
assert_equal "[FILTERED]!", second.string
|
|
211
|
+
refute first.closed?
|
|
212
|
+
refute second.closed?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def test_invalid_registration_does_not_partially_add_secrets
|
|
216
|
+
session, = pipe_session
|
|
217
|
+
output = StringIO.new
|
|
218
|
+
session.log_to(output)
|
|
219
|
+
[[], [""], [nil], ["valid", 42]].each do |values|
|
|
220
|
+
assert_raises(ArgumentError) { session.redact(*values) }
|
|
221
|
+
end
|
|
222
|
+
session.write_log("valid")
|
|
223
|
+
assert_equal "valid", output.string
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def test_reopening_the_same_log_path_finishes_the_old_stream_before_truncating
|
|
227
|
+
session, = pipe_session
|
|
228
|
+
session.redact("password")
|
|
229
|
+
Dir.mktmpdir do |directory|
|
|
230
|
+
path = File.join(directory, "session.log")
|
|
231
|
+
previous = session.log_to(path, mode: "w")
|
|
232
|
+
session.write_log("hello")
|
|
233
|
+
assert_equal "", File.binread(path)
|
|
234
|
+
session.log_to(path, mode: "w")
|
|
235
|
+
assert previous.closed?
|
|
236
|
+
session.write_log("x")
|
|
237
|
+
session.close
|
|
238
|
+
assert_equal "x", File.binread(path)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def test_failed_old_log_flush_does_not_truncate_the_new_path
|
|
243
|
+
session, = pipe_session
|
|
244
|
+
session.redact("password")
|
|
245
|
+
failure = IOError.new("old log failed")
|
|
246
|
+
session.log_to { raise failure }
|
|
247
|
+
session.write_log("tail")
|
|
248
|
+
Dir.mktmpdir do |directory|
|
|
249
|
+
path = File.join(directory, "existing.log")
|
|
250
|
+
File.write(path, "keep")
|
|
251
|
+
assert_same failure, assert_raises(IOError) { session.log_to(path, mode: "w") }
|
|
252
|
+
assert_equal "keep", File.binread(path)
|
|
253
|
+
end
|
|
254
|
+
session.log_output = nil
|
|
255
|
+
assert_nil session.log_output
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def test_flush_failure_does_not_prevent_handle_and_child_cleanup
|
|
259
|
+
session = child('puts "ready"; sleep 60', raw_pty: true)
|
|
260
|
+
session.expect("ready", timeout: 2)
|
|
261
|
+
session.redact("password")
|
|
262
|
+
failure = IOError.new("flush failed")
|
|
263
|
+
session.log_to { raise failure }
|
|
264
|
+
session.write_log("hi") # 小于保留窗口,失败应发生在关闭时的尾部交付。
|
|
265
|
+
assert_same failure, assert_raises(IOError) { session.close }
|
|
266
|
+
assert session.closed?
|
|
267
|
+
refute session.alive?
|
|
268
|
+
session.log_output = nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def test_unexpected_diagnostic_flush_failure_still_closes_owned_log
|
|
272
|
+
session, = pipe_session(debug_level: 2)
|
|
273
|
+
session.redact("password")
|
|
274
|
+
Dir.mktmpdir do |directory|
|
|
275
|
+
log = session.log_to(File.join(directory, "session.log"))
|
|
276
|
+
failure = RuntimeError.new("diagnostic callback failed")
|
|
277
|
+
session.diagnostic_output = ->(_) { raise failure }
|
|
278
|
+
session.__send__(:trace_data, :sending, "hi", level: 2)
|
|
279
|
+
assert_same failure, assert_raises(RuntimeError) { session.close }
|
|
280
|
+
assert log.closed?
|
|
281
|
+
assert session.closed?
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def test_replacing_diagnostics_finishes_the_previous_stream
|
|
286
|
+
session, = pipe_session(debug_level: 2)
|
|
287
|
+
session.redact("password")
|
|
288
|
+
first = StringIO.new
|
|
289
|
+
second = StringIO.new
|
|
290
|
+
session.diagnostic_output = first
|
|
291
|
+
session.__send__(:trace_data, :received, "pass", level: 2)
|
|
292
|
+
session.diagnostic_output = second
|
|
293
|
+
session.__send__(:trace_data, :received, "password!", level: 2)
|
|
294
|
+
session.close
|
|
295
|
+
assert_includes first.string, "[FILTERED]"
|
|
296
|
+
refute_includes first.string, "pass"
|
|
297
|
+
assert_includes second.string, "[FILTERED]"
|
|
298
|
+
refute_includes second.string, "password"
|
|
299
|
+
refute first.closed?
|
|
300
|
+
refute second.closed?
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def test_log_callback_can_rotate_the_target_during_close
|
|
304
|
+
session, = pipe_session
|
|
305
|
+
session.redact("long-secret")
|
|
306
|
+
delivered = []
|
|
307
|
+
rotated = nil
|
|
308
|
+
Dir.mktmpdir do |directory|
|
|
309
|
+
path = File.join(directory, "rotated.log")
|
|
310
|
+
session.log_to do |data|
|
|
311
|
+
delivered << data
|
|
312
|
+
rotated = session.log_to(path, mode: "w")
|
|
313
|
+
session.write_log("body")
|
|
314
|
+
end
|
|
315
|
+
session.write_log("xyz")
|
|
316
|
+
session.close
|
|
317
|
+
assert_equal ["xyz"], delivered
|
|
318
|
+
assert rotated.closed?
|
|
319
|
+
assert_equal "body", File.binread(path)
|
|
320
|
+
assert_nil session.log_output
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def test_diagnostic_flush_callback_can_create_or_revisit_the_other_direction
|
|
325
|
+
[nil, "seed"].each do |initial_send|
|
|
326
|
+
client, peer = Socket.pair(:UNIX, :STREAM, 0)
|
|
327
|
+
@ios.push(client, peer)
|
|
328
|
+
session = Expect.open(client, debug_level: 2)
|
|
329
|
+
@sessions << session
|
|
330
|
+
session.redact("long-secret")
|
|
331
|
+
events = []
|
|
332
|
+
session.diagnostic_output = lambda do |event|
|
|
333
|
+
events << event
|
|
334
|
+
session.write("ack") if event[:event] == :received
|
|
335
|
+
end
|
|
336
|
+
if initial_send
|
|
337
|
+
session.write(initial_send)
|
|
338
|
+
assert_equal initial_send, peer.read(initial_send.bytesize)
|
|
339
|
+
end
|
|
340
|
+
peer.write("xyz")
|
|
341
|
+
assert_equal 1, session.expect("xyz", timeout: 0)
|
|
342
|
+
replacement = StringIO.new
|
|
343
|
+
session.diagnostic_output = replacement
|
|
344
|
+
assert_equal "ack", peer.read(3)
|
|
345
|
+
expected = initial_send ? %i[matched sending received sending] : %i[matched received sending]
|
|
346
|
+
assert_equal(expected, events.map { |event| event[:event] })
|
|
347
|
+
assert_includes events.last[:message], "ack"
|
|
348
|
+
assert_equal "", replacement.string
|
|
349
|
+
session.close
|
|
350
|
+
assert_equal "", replacement.string
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class LifecycleContractTest < ExpectTest
|
|
6
|
+
def test_external_reaping_releases_pid_without_fabricating_process_status
|
|
7
|
+
session = child('puts "ready"; exit 7', raw_pty: true)
|
|
8
|
+
assert_equal 1, session.expect("ready", timeout: 2)
|
|
9
|
+
pid = session.pid
|
|
10
|
+
assert_equal(7, bounded { Process.waitpid2(pid).last.exitstatus })
|
|
11
|
+
refute session.alive?
|
|
12
|
+
assert_nil session.pid
|
|
13
|
+
assert_nil session.process_status
|
|
14
|
+
assert_nil session.wait(timeout: 0)
|
|
15
|
+
Process.stub(:kill, ->(*) { flunk "must not signal an externally reaped PID" }) do
|
|
16
|
+
2.times { session.close }
|
|
17
|
+
end
|
|
18
|
+
assert session.closed?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_external_reader_close_does_not_take_ownership_of_the_writer
|
|
22
|
+
reader, producer = IO.pipe
|
|
23
|
+
consumer, writer = IO.pipe
|
|
24
|
+
@ios.push(reader, producer, consumer, writer)
|
|
25
|
+
session = Expect.open(reader, writer: writer)
|
|
26
|
+
@sessions << session
|
|
27
|
+
session.buffer = "tail"
|
|
28
|
+
reader.close
|
|
29
|
+
assert session.closed?
|
|
30
|
+
assert session.eof?
|
|
31
|
+
refute session.alive?
|
|
32
|
+
assert_raises(IOError) { session.write("blocked") }
|
|
33
|
+
2.times { session.close }
|
|
34
|
+
refute writer.closed?
|
|
35
|
+
assert_equal "tail", session.buffer
|
|
36
|
+
writer.write("borrowed")
|
|
37
|
+
assert_equal "borrowed", consumer.read(8)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_failed_handle_close_can_be_retried_without_losing_other_owned_handles
|
|
41
|
+
reader, writer = IO.pipe
|
|
42
|
+
@ios.push(reader, writer)
|
|
43
|
+
session = Expect.open(reader, writer: writer, own: true)
|
|
44
|
+
@sessions << session
|
|
45
|
+
failure = IOError.new("close failed")
|
|
46
|
+
reader.stub(:close, -> { raise failure }) do
|
|
47
|
+
assert_same failure, assert_raises(IOError) { session.close }
|
|
48
|
+
assert writer.closed?
|
|
49
|
+
refute reader.closed?
|
|
50
|
+
assert session.closed?
|
|
51
|
+
end
|
|
52
|
+
session.close
|
|
53
|
+
assert reader.closed?
|
|
54
|
+
assert_nil session.close
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_timeout_and_eof_remain_events_and_write_errors_remain_io_errors
|
|
58
|
+
session, writer = pipe_session
|
|
59
|
+
timeout = session.expect_result("missing", timeout: 0)
|
|
60
|
+
assert timeout.timeout?
|
|
61
|
+
assert_equal :timeout, timeout.error
|
|
62
|
+
writer.close
|
|
63
|
+
eof = session.expect_result(:eof, timeout: 1)
|
|
64
|
+
assert eof.eof?
|
|
65
|
+
assert_equal :eof, eof.error
|
|
66
|
+
failure = Expect::WriteTimeout.new(bytes_written: 3)
|
|
67
|
+
assert_kind_of IOError, failure
|
|
68
|
+
assert_equal 3, failure.bytes_written
|
|
69
|
+
assert_operator Expect::SpawnError, :<, StandardError
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class LiteralScanTest < ExpectTest
|
|
6
|
+
def test_new_bytes_can_complete_a_previously_missing_literal
|
|
7
|
+
session, writer = pipe_session
|
|
8
|
+
list = Expect::PatternList.new([session], ["password:", "word:"])
|
|
9
|
+
matcher = Expect::Matcher.new(list, nil)
|
|
10
|
+
session.buffer = "prefix pass"
|
|
11
|
+
assert_nil matcher.__send__(:find_match)
|
|
12
|
+
writer.write("word:")
|
|
13
|
+
session.__send__(:read_available)
|
|
14
|
+
result = matcher.__send__(:find_match)
|
|
15
|
+
assert_equal 1, result[1].number
|
|
16
|
+
assert_equal [7, 9, []], result[2]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_later_text_for_an_earlier_pattern_keeps_declaration_priority
|
|
20
|
+
session, writer = pipe_session
|
|
21
|
+
list = Expect::PatternList.new([session], %w[preferred ready])
|
|
22
|
+
matcher = Expect::Matcher.new(list, nil)
|
|
23
|
+
session.buffer = "ready"
|
|
24
|
+
assert_equal 2, matcher.__send__(:find_match)[1].number
|
|
25
|
+
writer.write(" preferred")
|
|
26
|
+
session.__send__(:read_available)
|
|
27
|
+
assert_equal 1, matcher.__send__(:find_match)[1].number
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_destructive_buffer_changes_invalidate_old_misses
|
|
31
|
+
session, writer = pipe_session
|
|
32
|
+
list = Expect::PatternList.new([session], ["start", "token", /END/])
|
|
33
|
+
matcher = Expect::Matcher.new(list, nil)
|
|
34
|
+
session.buffer = "x" * 100
|
|
35
|
+
assert_nil matcher.__send__(:find_match)
|
|
36
|
+
session.buffer = "start"
|
|
37
|
+
assert_equal 1, matcher.__send__(:find_match)[1].number
|
|
38
|
+
session.clear_buffer
|
|
39
|
+
assert_nil matcher.__send__(:find_match)
|
|
40
|
+
writer.write("token")
|
|
41
|
+
session.__send__(:read_available)
|
|
42
|
+
assert_equal 2, matcher.__send__(:find_match)[1].number
|
|
43
|
+
session.expect("token", timeout: 0)
|
|
44
|
+
session.buffer_limit = 8
|
|
45
|
+
writer.write("xxxxstart")
|
|
46
|
+
session.__send__(:read_available)
|
|
47
|
+
assert_equal [3, 5, []], matcher.__send__(:find_match)[2]
|
|
48
|
+
session.clear_buffer
|
|
49
|
+
session.__send__(:restore_relay_buffer, "token")
|
|
50
|
+
assert_equal 2, matcher.__send__(:find_match)[1].number
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_repeated_scans_equal_a_fresh_matcher_across_operation_sequences
|
|
54
|
+
session, writer = pipe_session
|
|
55
|
+
patterns = ["abc", "bc", "\0\xff".b, /END/n]
|
|
56
|
+
list = Expect::PatternList.new([session], patterns)
|
|
57
|
+
matcher = Expect::Matcher.new(list, nil)
|
|
58
|
+
random = Random.new(40_033)
|
|
59
|
+
chunks = ["a", "b", "c", "x", "END", "\0", "\xff".b]
|
|
60
|
+
150.times do
|
|
61
|
+
case random.rand(6)
|
|
62
|
+
when 0
|
|
63
|
+
session.buffer = chunks.sample(random: random) * random.rand(1..4)
|
|
64
|
+
when 1
|
|
65
|
+
session.clear_buffer
|
|
66
|
+
when 2
|
|
67
|
+
session.buffer_limit = [nil, 2, 5, 12].sample(random: random)
|
|
68
|
+
when 3
|
|
69
|
+
session.__send__(:restore_relay_buffer, chunks.sample(random: random).b)
|
|
70
|
+
when 4
|
|
71
|
+
session.expect("x", timeout: 0)
|
|
72
|
+
else
|
|
73
|
+
writer.write(chunks.sample(random: random))
|
|
74
|
+
session.__send__(:read_available)
|
|
75
|
+
end
|
|
76
|
+
expected = Expect::Matcher.new(list, nil).__send__(:find_match)
|
|
77
|
+
actual = matcher.__send__(:find_match)
|
|
78
|
+
assert_equal signature(expected), signature(actual), "buffer=#{session.buffer.inspect}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_a_dynamically_registered_pattern_is_scanned_from_the_start
|
|
83
|
+
session, = pipe_session
|
|
84
|
+
session.buffer = "prefix ready"
|
|
85
|
+
list = Expect::PatternList.new([session], ["missing"])
|
|
86
|
+
matcher = Expect::Matcher.new(list, nil)
|
|
87
|
+
assert_nil matcher.__send__(:find_match)
|
|
88
|
+
list.on("prefix")
|
|
89
|
+
assert_equal [0, 6, []], matcher.__send__(:find_match)[2]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def signature(result)
|
|
95
|
+
result ? [result[0], result[1].number, result[2]] : []
|
|
96
|
+
end
|
|
97
|
+
end
|
data/test/pattern_offset_test.rb
CHANGED
|
@@ -16,8 +16,10 @@ class PatternOffsetTest < ExpectTest
|
|
|
16
16
|
]
|
|
17
17
|
session, = pipe_session
|
|
18
18
|
cases.each do |text, regexp, offset, length, captures|
|
|
19
|
-
|
|
19
|
+
# 扫描只读调用者输入;省去正则副本后也不能改变编码标记或冻结字符串。
|
|
20
|
+
bytes = text.b.freeze
|
|
20
21
|
assert_equal [offset, length, captures], Expect::Pattern.new(value: regexp).locate(bytes)
|
|
22
|
+
assert_equal Encoding::BINARY, bytes.encoding
|
|
21
23
|
session.buffer = bytes
|
|
22
24
|
result = session.expect_result(regexp, timeout: 0)
|
|
23
25
|
assert_equal bytes.byteslice(0, offset), result.before
|
data/test/process_test.rb
CHANGED
|
@@ -77,6 +77,14 @@ class ProcessTest < ExpectTest
|
|
|
77
77
|
assert_raises(Expect::SpawnError) { session.spawn("cat") }
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
def test_spawn_cleans_child_when_post_exec_diagnostics_fail
|
|
81
|
+
assert_failed_construction_cleans_child(:spawn)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_new_cleans_child_when_post_exec_diagnostics_fail
|
|
85
|
+
assert_failed_construction_cleans_child(:new)
|
|
86
|
+
end
|
|
87
|
+
|
|
80
88
|
def test_block_closes_child_on_exception
|
|
81
89
|
pid = nil
|
|
82
90
|
assert_raises(RuntimeError) do
|
|
@@ -228,6 +236,17 @@ class ProcessTest < ExpectTest
|
|
|
228
236
|
assert_equal configuration, terminal_configuration(session)
|
|
229
237
|
end
|
|
230
238
|
|
|
239
|
+
def test_stty_reports_a_missing_system_command_and_preserves_the_cause
|
|
240
|
+
previous_path = ENV.fetch("PATH", nil)
|
|
241
|
+
session = child("sleep 60")
|
|
242
|
+
ENV["PATH"] = ""
|
|
243
|
+
error = assert_raises(IOError) { session.stty }
|
|
244
|
+
assert_match "stty executable not found", error.message
|
|
245
|
+
assert_instance_of Errno::ENOENT, error.cause
|
|
246
|
+
ensure
|
|
247
|
+
ENV["PATH"] = previous_path
|
|
248
|
+
end
|
|
249
|
+
|
|
231
250
|
def test_configuration_and_session_attributes
|
|
232
251
|
Expect.configure(timeout: 0.01, log_stdout: false)
|
|
233
252
|
session = Expect.new
|
|
@@ -242,4 +261,31 @@ class ProcessTest < ExpectTest
|
|
|
242
261
|
assert_equal 30, session.buffer_limit
|
|
243
262
|
assert_match(/\A#<Expect .*closed=false>\z/, session.inspect)
|
|
244
263
|
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
def assert_failed_construction_cleans_child(factory)
|
|
268
|
+
connection = nil
|
|
269
|
+
session_class = Class.new(Expect)
|
|
270
|
+
session_class.define_method(:initialize) do |*command, **options|
|
|
271
|
+
connection = self
|
|
272
|
+
super(*command, **options)
|
|
273
|
+
end
|
|
274
|
+
reader, diagnostic_output = IO.pipe
|
|
275
|
+
@ios.push(reader, diagnostic_output)
|
|
276
|
+
reader.close
|
|
277
|
+
previous_stderr = $stderr
|
|
278
|
+
begin
|
|
279
|
+
$stderr = diagnostic_output
|
|
280
|
+
assert_raises(Errno::EPIPE) do
|
|
281
|
+
session_class.public_send(factory, RbConfig.ruby, "--disable-gems", "-e", "sleep 60", debug_level: 1)
|
|
282
|
+
end
|
|
283
|
+
ensure
|
|
284
|
+
$stderr = previous_stderr
|
|
285
|
+
@sessions << connection if connection
|
|
286
|
+
end
|
|
287
|
+
assert connection.closed?
|
|
288
|
+
assert_nil connection.pid
|
|
289
|
+
assert_instance_of Process::Status, connection.process_status
|
|
290
|
+
end
|
|
245
291
|
end
|