expect-pty 0.2.0 → 0.3.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/CHANGELOG.md +9 -0
- data/README.md +22 -9
- data/docs/COMPATIBILITY.md +23 -1
- data/docs/RELEASING.md +21 -8
- data/docs/VERIFICATION.md +65 -1
- data/expect-pty.gemspec +1 -1
- data/lib/expect/configuration.rb +7 -0
- data/lib/expect/interaction.rb +249 -0
- data/lib/expect/{engine.rb → matcher.rb} +40 -5
- data/lib/expect/pattern.rb +12 -20
- data/lib/expect/relay.rb +133 -0
- data/lib/expect/relay_writer.rb +66 -0
- data/lib/expect/result.rb +2 -0
- data/lib/expect/{resources.rb → session_resources.rb} +3 -3
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +105 -30
- data/script/ci +3 -4
- data/script/release.rb +22 -10
- data/test/edge_case_test.rb +101 -22
- data/test/interact_test.rb +102 -0
- data/test/interconnect_test.rb +203 -4
- data/test/io_test.rb +102 -0
- data/test/matching_test.rb +33 -0
- data/test/relay_recovery_test.rb +414 -0
- data/test/release_test.rb +67 -1
- data/test/support/script_probe.rb +1 -0
- metadata +9 -6
- data/lib/expect/interconnect.rb +0 -170
data/script/ci
CHANGED
|
@@ -6,9 +6,9 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
|
|
6
6
|
bundle exec rake
|
|
7
7
|
bundle exec ruby examples/dialogue.rb
|
|
8
8
|
|
|
9
|
-
mkdir -p
|
|
9
|
+
mkdir -p tmp/ci
|
|
10
10
|
gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
|
|
11
|
-
gem_file="
|
|
11
|
+
gem_file="tmp/ci/expect-pty-${gem_version}.gem"
|
|
12
12
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
13
13
|
export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
|
|
14
14
|
fi
|
|
@@ -28,7 +28,6 @@ spec = Gem.loaded_specs.fetch("expect-pty")
|
|
|
28
28
|
# 临时目录可能经由符号链接访问,先统一为实际路径再检查。
|
|
29
29
|
install_root = File.realpath(ENV.fetch("GEM_HOME"))
|
|
30
30
|
abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
|
|
31
|
-
|
|
32
31
|
child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
|
|
33
32
|
Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do |session|
|
|
34
33
|
abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
|
|
@@ -39,6 +38,6 @@ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do
|
|
|
39
38
|
abort "installed gem child did not exit successfully" unless status&.success?
|
|
40
39
|
end
|
|
41
40
|
|
|
42
|
-
puts "Installed #{spec.full_name}: PTY
|
|
41
|
+
puts "Installed #{spec.full_name}: PTY and isolated installation passed"
|
|
43
42
|
RUBY
|
|
44
43
|
)
|
data/script/release.rb
CHANGED
|
@@ -16,11 +16,12 @@ class Release
|
|
|
16
16
|
REPOSITORY = "gatework/expect-ruby"
|
|
17
17
|
GEM_HOST = "https://rubygems.org"
|
|
18
18
|
|
|
19
|
-
def initialize(artifact: nil, dry_run: false)
|
|
19
|
+
def initialize(artifact: nil, dry_run: false, rubygems_only: false)
|
|
20
20
|
@version = Expect::VERSION
|
|
21
21
|
@tag = "v#{@version}"
|
|
22
|
-
@artifact = File.expand_path(artifact || "
|
|
22
|
+
@artifact = File.expand_path(artifact || "tmp/ci/expect-pty-#{@version}.gem")
|
|
23
23
|
@dry_run = dry_run
|
|
24
|
+
@rubygems_only = rubygems_only
|
|
24
25
|
@build = artifact.nil?
|
|
25
26
|
end
|
|
26
27
|
|
|
@@ -34,7 +35,7 @@ class Release
|
|
|
34
35
|
|
|
35
36
|
command("bash", "script/ci") if @build
|
|
36
37
|
# 独占目录中的副本贯穿校验和上传,其他构建不会改变本次发布的字节。
|
|
37
|
-
directory = File.join("
|
|
38
|
+
directory = File.join("tmp", "release", @version)
|
|
38
39
|
FileUtils.mkdir_p(directory)
|
|
39
40
|
directory = Dir.mktmpdir("candidate-", directory)
|
|
40
41
|
candidate = File.join(directory, "expect-pty-#{@version}.gem")
|
|
@@ -49,9 +50,13 @@ class Release
|
|
|
49
50
|
puts "Verified #{@tag}: #{@sha256}\nArtifact: #{@artifact}"
|
|
50
51
|
return puts "Dry run complete: #{@artifact}" if @dry_run
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
if @rubygems_only
|
|
54
|
+
verify_local_source
|
|
55
|
+
else
|
|
56
|
+
verify_remote_source
|
|
57
|
+
verify_registry_checksum(registry_version)
|
|
58
|
+
publish_github
|
|
59
|
+
end
|
|
55
60
|
publish_rubygems
|
|
56
61
|
end
|
|
57
62
|
|
|
@@ -97,10 +102,14 @@ class Release
|
|
|
97
102
|
JSON.parse(output)
|
|
98
103
|
end
|
|
99
104
|
|
|
105
|
+
def verify_local_source
|
|
106
|
+
return if capture("git", "rev-parse", "HEAD") == @commit && capture("git", "status", "--porcelain").empty?
|
|
107
|
+
|
|
108
|
+
raise "Source changed during verification; commit the changes and start again"
|
|
109
|
+
end
|
|
110
|
+
|
|
100
111
|
def verify_remote_source
|
|
101
|
-
|
|
102
|
-
raise "Source changed during verification; commit the changes and start again"
|
|
103
|
-
end
|
|
112
|
+
verify_local_source
|
|
104
113
|
|
|
105
114
|
comparison = github("compare/#{@commit}...main")
|
|
106
115
|
raise "Push this commit to #{REPOSITORY}/main first" unless %w[ahead identical].include?(comparison.fetch("status"))
|
|
@@ -249,7 +258,10 @@ end
|
|
|
249
258
|
if $PROGRAM_NAME == __FILE__
|
|
250
259
|
options = {}
|
|
251
260
|
parser = OptionParser.new do |arguments|
|
|
252
|
-
arguments.banner = "Usage: ruby script/release.rb [--dry-run] [--artifact PATH]"
|
|
261
|
+
arguments.banner = "Usage: ruby script/release.rb [--rubygems-only] [--dry-run] [--artifact PATH]"
|
|
262
|
+
arguments.on("--rubygems-only", "Publish only to RubyGems using the existing gem login; no GitHub access") do
|
|
263
|
+
options[:rubygems_only] = true
|
|
264
|
+
end
|
|
253
265
|
arguments.on("--artifact PATH", "Publish an already verified gem without rebuilding") do |path|
|
|
254
266
|
options[:artifact] = File.expand_path(path)
|
|
255
267
|
end
|
data/test/edge_case_test.rb
CHANGED
|
@@ -13,6 +13,74 @@ class EdgeCaseTest < ExpectTest
|
|
|
13
13
|
assert result.timeout?
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def test_zero_width_continuation_waits_for_new_input_before_matching_again
|
|
17
|
+
session, = pipe_session
|
|
18
|
+
session.buffer = "ready"
|
|
19
|
+
calls = 0
|
|
20
|
+
|
|
21
|
+
result = bounded(1) do
|
|
22
|
+
session.expect_result(timeout: 0.01) do
|
|
23
|
+
on(/(?=ready)/) do
|
|
24
|
+
calls += 1
|
|
25
|
+
Expect.continue
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
assert result.timeout?
|
|
31
|
+
assert_equal 1, calls
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_stalled_pattern_can_match_again_after_new_input
|
|
35
|
+
session, writer = pipe_session
|
|
36
|
+
session.buffer = "ready"
|
|
37
|
+
first_match = Queue.new
|
|
38
|
+
background do
|
|
39
|
+
first_match.pop
|
|
40
|
+
writer.write("!")
|
|
41
|
+
end
|
|
42
|
+
calls = 0
|
|
43
|
+
|
|
44
|
+
result = bounded(1) do
|
|
45
|
+
session.expect_result(timeout: 0.5) do
|
|
46
|
+
on(/(?=ready)/) do
|
|
47
|
+
calls += 1
|
|
48
|
+
first_match << true if calls == 1
|
|
49
|
+
calls == 1 ? Expect.continue : nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
assert result.matched?
|
|
55
|
+
assert_equal 2, calls
|
|
56
|
+
assert_equal "ready!", session.buffer
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_stalled_pattern_can_match_again_after_timeout_callback_changes_buffer
|
|
60
|
+
session, = pipe_session
|
|
61
|
+
session.buffer = "ready"
|
|
62
|
+
calls = 0
|
|
63
|
+
timeouts = 0
|
|
64
|
+
|
|
65
|
+
result = bounded(1) do
|
|
66
|
+
session.expect_result(timeout: 0.01) do
|
|
67
|
+
on(/(?=ready)/) do
|
|
68
|
+
calls += 1
|
|
69
|
+
calls == 1 ? Expect.continue : nil
|
|
70
|
+
end
|
|
71
|
+
timeout do
|
|
72
|
+
timeouts += 1
|
|
73
|
+
session.buffer = "ready!"
|
|
74
|
+
timeouts == 1 ? Expect.continue : nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
assert result.matched?
|
|
80
|
+
assert_equal 2, calls
|
|
81
|
+
assert_equal 1, timeouts
|
|
82
|
+
end
|
|
83
|
+
|
|
16
84
|
def test_stdout_works_with_utf8_banner_and_ascii_regexp
|
|
17
85
|
session, writer = pipe_session
|
|
18
86
|
writer.write("欢迎登录\nprompt>")
|
|
@@ -130,11 +198,44 @@ class EdgeCaseTest < ExpectTest
|
|
|
130
198
|
end
|
|
131
199
|
|
|
132
200
|
def test_gc_reclaims_abandoned_child
|
|
201
|
+
assert_gc_reclaims_abandoned_child
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def test_gc_reclaims_abandoned_child_with_log_callback_capturing_session
|
|
205
|
+
assert_gc_reclaims_abandoned_child(log_callback: true)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def test_invalid_options_raise_before_spawning
|
|
209
|
+
assert_raises(ArgumentError) { Expect.new(typo: true) }
|
|
210
|
+
session, = pipe_session
|
|
211
|
+
assert_raises(ArgumentError) { session.expect(0, "x") }
|
|
212
|
+
assert_raises(ArgumentError) { session.expect(["-unknown", "x"], timeout: 0) }
|
|
213
|
+
assert_raises(ArgumentError) { session.on_sequence("") }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def test_entrypoint_can_coexist_with_standard_library_expect
|
|
217
|
+
entrypoint = File.expand_path("../lib/expect/pty.rb", __dir__)
|
|
218
|
+
script = <<~RUBY
|
|
219
|
+
require "rbconfig"
|
|
220
|
+
require File.join(RbConfig::CONFIG.fetch("rubylibdir"), "expect.rb")
|
|
221
|
+
require ARGV.fetch(0)
|
|
222
|
+
puts [IO.method_defined?(:expect), defined?(Expect), Expect::VERSION].join(":")
|
|
223
|
+
RUBY
|
|
224
|
+
environment = { "RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil }
|
|
225
|
+
output, status = Open3.capture2e(environment, RbConfig.ruby, "-e", script, entrypoint)
|
|
226
|
+
assert status.success?, output
|
|
227
|
+
assert_equal "true:constant:#{Expect::VERSION}\n", output
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
private
|
|
231
|
+
|
|
232
|
+
def assert_gc_reclaims_abandoned_child(log_callback: false)
|
|
133
233
|
script = <<~RUBY
|
|
134
234
|
require "expect"
|
|
135
235
|
require "rbconfig"
|
|
136
236
|
def abandoned
|
|
137
237
|
session = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", "sleep 60", log_stdout: false)
|
|
238
|
+
session.log_to { |bytes| [session.pid, bytes] } if #{log_callback}
|
|
138
239
|
session.pid
|
|
139
240
|
end
|
|
140
241
|
# Ruby 的保守 GC 可能扫描到创建线程栈上残留的引用;先结束该线程,确保会话确实不可达。
|
|
@@ -158,26 +259,4 @@ class EdgeCaseTest < ExpectTest
|
|
|
158
259
|
assert status.success?, output
|
|
159
260
|
assert_equal "reaped\n", output
|
|
160
261
|
end
|
|
161
|
-
|
|
162
|
-
def test_invalid_options_raise_before_spawning
|
|
163
|
-
assert_raises(ArgumentError) { Expect.new(typo: true) }
|
|
164
|
-
session, = pipe_session
|
|
165
|
-
assert_raises(ArgumentError) { session.expect(0, "x") }
|
|
166
|
-
assert_raises(ArgumentError) { session.expect(["-unknown", "x"], timeout: 0) }
|
|
167
|
-
assert_raises(ArgumentError) { session.on_sequence("") }
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def test_entrypoint_can_coexist_with_standard_library_expect
|
|
171
|
-
entrypoint = File.expand_path("../lib/expect/pty.rb", __dir__)
|
|
172
|
-
script = <<~RUBY
|
|
173
|
-
require "rbconfig"
|
|
174
|
-
require File.join(RbConfig::CONFIG.fetch("rubylibdir"), "expect.rb")
|
|
175
|
-
require ARGV.fetch(0)
|
|
176
|
-
puts [IO.method_defined?(:expect), defined?(Expect), Expect::VERSION].join(":")
|
|
177
|
-
RUBY
|
|
178
|
-
environment = { "RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil }
|
|
179
|
-
output, status = Open3.capture2e(environment, RbConfig.ruby, "-e", script, entrypoint)
|
|
180
|
-
assert status.success?, output
|
|
181
|
-
assert_equal "true:constant:#{Expect::VERSION}\n", output
|
|
182
|
-
end
|
|
183
262
|
end
|
data/test/interact_test.rb
CHANGED
|
@@ -25,6 +25,71 @@ class InteractTest < ExpectTest
|
|
|
25
25
|
[master, slave, source]
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def socket_session
|
|
29
|
+
local, peer = Socket.pair(:UNIX, :STREAM, 0)
|
|
30
|
+
@ios.push(local, peer)
|
|
31
|
+
session = Expect.open(local)
|
|
32
|
+
@sessions << session
|
|
33
|
+
[session, peer]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_changed_interact_regexp_does_not_match_previously_forwarded_input
|
|
37
|
+
session, peer = socket_session
|
|
38
|
+
input, keyboard = pipe_session
|
|
39
|
+
keyboard.write("old")
|
|
40
|
+
assert_nil session.interact(input: input.to_io, output: StringIO.new, escape: /STOP/, timeout: 0)
|
|
41
|
+
assert_equal "old", peer.read_nonblock(100)
|
|
42
|
+
|
|
43
|
+
assert_nil session.interact(input: input.to_io, output: StringIO.new, escape: /old/, timeout: 0)
|
|
44
|
+
keyboard.write("oldtail")
|
|
45
|
+
stopped = session.interact(input: input.to_io, output: StringIO.new, escape: /old/, timeout: 0.1)
|
|
46
|
+
assert_same input.to_io, stopped.to_io
|
|
47
|
+
assert_equal "tail", stopped.buffer
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def test_same_interact_regexp_keeps_cross_call_history_and_unread_tail
|
|
51
|
+
session, peer = socket_session
|
|
52
|
+
input, keyboard = pipe_session
|
|
53
|
+
keyboard.write("ST")
|
|
54
|
+
assert_nil session.interact(input: input.to_io, output: StringIO.new, escape: /STOP/, timeout: 0)
|
|
55
|
+
assert_equal "ST", peer.read_nonblock(100)
|
|
56
|
+
|
|
57
|
+
keyboard.write("OPtail")
|
|
58
|
+
stopped = session.interact(input: input.to_io, output: StringIO.new, escape: /STOP/, timeout: 0.1)
|
|
59
|
+
assert_same input.to_io, stopped.to_io
|
|
60
|
+
assert_equal "tail", stopped.buffer
|
|
61
|
+
assert_nil session.interact(input: input.to_io, output: StringIO.new, escape: /STOP/, timeout: 0)
|
|
62
|
+
assert_equal "tail", peer.read_nonblock(100)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def test_repeated_interact_keeps_split_crlf_on_the_same_terminal
|
|
66
|
+
session, = socket_session
|
|
67
|
+
master, slave, source = local_terminal
|
|
68
|
+
session.buffer = "first\r"
|
|
69
|
+
assert_nil session.interact(input: source, output: slave, timeout: 0)
|
|
70
|
+
assert_equal "first\r", master.read_nonblock(100)
|
|
71
|
+
|
|
72
|
+
session.buffer = "\nsecond"
|
|
73
|
+
assert_nil session.interact(input: source, output: slave, timeout: 0)
|
|
74
|
+
assert_equal "\nsecond", master.read_nonblock(100)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_changed_interact_regexp_preserves_pending_output_and_tail
|
|
78
|
+
session, peer = socket_session
|
|
79
|
+
input, keyboard = pipe_session
|
|
80
|
+
loop { break if session.writer.write_nonblock("x" * 4096, exception: false) == :wait_writable }
|
|
81
|
+
keyboard.write("old")
|
|
82
|
+
assert_nil session.interact(input: input, output: StringIO.new, escape: /STOP/, timeout: 0)
|
|
83
|
+
assert input.pending_output?
|
|
84
|
+
input.buffer = "tail"
|
|
85
|
+
loop { break if peer.read_nonblock(65_536, exception: false) == :wait_readable }
|
|
86
|
+
|
|
87
|
+
assert_nil session.interact(input: input, output: StringIO.new, escape: /old/, timeout: 0.01)
|
|
88
|
+
refute input.pending_output?
|
|
89
|
+
assert_empty input.buffer
|
|
90
|
+
assert_equal "oldtail", peer.read_nonblock(100)
|
|
91
|
+
end
|
|
92
|
+
|
|
28
93
|
def test_real_terminal_handoff_ctrl_c_escape_and_resume
|
|
29
94
|
session = shell_session
|
|
30
95
|
log = StringIO.new
|
|
@@ -98,6 +163,43 @@ class InteractTest < ExpectTest
|
|
|
98
163
|
keyboard&.kill&.join if keyboard&.alive?
|
|
99
164
|
end
|
|
100
165
|
|
|
166
|
+
def test_interact_preserves_newline_processing_on_the_shared_local_terminal
|
|
167
|
+
session = child(<<~'RUBY', raw_pty: true)
|
|
168
|
+
STDOUT.sync = true
|
|
169
|
+
while STDIN.gets
|
|
170
|
+
STDOUT.write("first\nsecond\r")
|
|
171
|
+
STDIN.gets
|
|
172
|
+
STDOUT.write("\nFW# ")
|
|
173
|
+
end
|
|
174
|
+
RUBY
|
|
175
|
+
master, _, source = local_terminal
|
|
176
|
+
screen = Expect.open(master, write_timeout: 3)
|
|
177
|
+
@sessions << screen
|
|
178
|
+
driver = Thread.new do
|
|
179
|
+
screen.write("show\n")
|
|
180
|
+
ScriptProbe.check(screen.expect("first\r\nsecond\r", timeout: 3),
|
|
181
|
+
"interact disabled output newline processing")
|
|
182
|
+
screen.write("continue\n")
|
|
183
|
+
ScriptProbe.check(screen.expect("\nFW# ", timeout: 3), "interact duplicated a split CRLF sequence")
|
|
184
|
+
screen.write(InteractProbe::ESCAPE)
|
|
185
|
+
true
|
|
186
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Release interact when the assertion fails.
|
|
187
|
+
begin
|
|
188
|
+
screen.write(InteractProbe::ESCAPE)
|
|
189
|
+
rescue StandardError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
raise
|
|
193
|
+
end
|
|
194
|
+
driver.report_on_exception = false
|
|
195
|
+
|
|
196
|
+
assert_same source, session.interact(input: source, escape: InteractProbe::ESCAPE, output: source.to_io, timeout: 5)
|
|
197
|
+
assert driver.join(2), "terminal driver did not finish"
|
|
198
|
+
assert driver.value
|
|
199
|
+
ensure
|
|
200
|
+
driver&.kill&.join if driver&.alive?
|
|
201
|
+
end
|
|
202
|
+
|
|
101
203
|
def test_remote_eof_restores_input_terminal_and_keeps_borrowed_io_open
|
|
102
204
|
session = child('STDIN.gets; print "FINAL_REMOTE_OUTPUT"', raw_pty: true)
|
|
103
205
|
_, slave, source = local_terminal
|
data/test/interconnect_test.rb
CHANGED
|
@@ -3,6 +3,113 @@
|
|
|
3
3
|
require_relative "test_helper"
|
|
4
4
|
|
|
5
5
|
class InterconnectTest < ExpectTest
|
|
6
|
+
def test_escape_callback_can_match_new_input_and_return_its_tail
|
|
7
|
+
session, writer = pipe_session
|
|
8
|
+
output = StringIO.new
|
|
9
|
+
session.listeners = [output]
|
|
10
|
+
result = nil
|
|
11
|
+
session.on_sequence("!") do
|
|
12
|
+
writer.write("ready tail")
|
|
13
|
+
result = session.expect("ready", timeout: 0.05)
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
session.buffer = "!"
|
|
17
|
+
|
|
18
|
+
assert_same(session, bounded { Expect.interconnect(session, timeout: 1) })
|
|
19
|
+
assert_equal 1, result
|
|
20
|
+
assert_equal " tail", session.buffer
|
|
21
|
+
assert_empty output.string
|
|
22
|
+
Expect.interconnect(session, timeout: 0)
|
|
23
|
+
assert_equal " tail", output.string
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_escape_callback_can_match_previously_read_tail
|
|
27
|
+
session, = pipe_session
|
|
28
|
+
result = nil
|
|
29
|
+
session.on_sequence("!") do
|
|
30
|
+
result = session.expect("ready", timeout: 0)
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
session.buffer = "!ready tail"
|
|
34
|
+
|
|
35
|
+
assert_same session, Expect.interconnect(session, timeout: 1)
|
|
36
|
+
assert_equal 1, result
|
|
37
|
+
assert_equal " tail", session.buffer
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_callback_match_failure_returns_unconsumed_bytes_to_relay
|
|
41
|
+
session, writer = pipe_session
|
|
42
|
+
session.on_sequence("!") do
|
|
43
|
+
writer.write("\xff".b)
|
|
44
|
+
session.expect(/ready/u, timeout: 0.05)
|
|
45
|
+
end
|
|
46
|
+
session.buffer = "!prefix"
|
|
47
|
+
|
|
48
|
+
assert_raises(EncodingError) { bounded { Expect.interconnect(session, timeout: 1) } }
|
|
49
|
+
assert_equal "prefix\xff".b, session.buffer
|
|
50
|
+
assert_nil session.__send__(:interaction_buffer)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_timeout_flush_leaves_blocked_output_pending_without_reading_after_deadline
|
|
54
|
+
reader, writer = IO.pipe
|
|
55
|
+
sink, sink_writer = IO.pipe
|
|
56
|
+
@ios.push(reader, writer, sink, sink_writer)
|
|
57
|
+
loop { break if sink_writer.write_nonblock("x" * 4096, exception: false) == :wait_writable }
|
|
58
|
+
session = Expect.open(reader, writer: sink_writer, write_timeout: 1)
|
|
59
|
+
@sessions << session
|
|
60
|
+
# A loopback listener must not wait or read more input after the deadline.
|
|
61
|
+
session.listeners = [session]
|
|
62
|
+
session.on_sequence("STOP")
|
|
63
|
+
session.buffer = "ST"
|
|
64
|
+
writer.write("tail")
|
|
65
|
+
original = IO.method(:select)
|
|
66
|
+
poll = true
|
|
67
|
+
select = lambda do |*arguments|
|
|
68
|
+
if poll
|
|
69
|
+
poll = false
|
|
70
|
+
nil
|
|
71
|
+
else
|
|
72
|
+
original.call(*arguments)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
IO.stub(:select, select) { assert_nil Expect.interconnect(session, timeout: 0) }
|
|
77
|
+
assert session.pending_output?
|
|
78
|
+
assert_empty session.buffer
|
|
79
|
+
session.listeners = []
|
|
80
|
+
assert_equal 1, session.expect("tail", timeout: 0)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_backpressure_reads_still_apply_escape_sequences
|
|
84
|
+
source, = pipe_session
|
|
85
|
+
reader, writer = IO.pipe
|
|
86
|
+
sink, sink_writer = IO.pipe
|
|
87
|
+
@ios.push(reader, writer, sink, sink_writer)
|
|
88
|
+
loop { break if sink_writer.write_nonblock("x" * 4096, exception: false) == :wait_writable }
|
|
89
|
+
target = Expect.open(reader, writer: sink_writer, write_timeout: 1)
|
|
90
|
+
@sessions << target
|
|
91
|
+
output = StringIO.new
|
|
92
|
+
received = Queue.new
|
|
93
|
+
target.log_to { received << true }
|
|
94
|
+
target.listeners = [output]
|
|
95
|
+
target.on_sequence("STOP")
|
|
96
|
+
source.listeners = [target]
|
|
97
|
+
source.buffer = "request"
|
|
98
|
+
writer.write("beforeSTOPtail")
|
|
99
|
+
background do
|
|
100
|
+
received.pop
|
|
101
|
+
sink.readpartial(65_536)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
assert_same(target, bounded { Expect.interconnect(target, source, timeout: 0.2) })
|
|
105
|
+
assert_equal "before", output.string
|
|
106
|
+
assert_equal "tail", target.buffer
|
|
107
|
+
target.log_output = nil
|
|
108
|
+
writer.write("ready")
|
|
109
|
+
assert_equal 1, target.expect("ready", timeout: 1)
|
|
110
|
+
assert_equal "tail", target.before
|
|
111
|
+
end
|
|
112
|
+
|
|
6
113
|
def test_interconnect_forwards_and_strips_split_escape
|
|
7
114
|
session, writer = pipe_session
|
|
8
115
|
listener = StringIO.new
|
|
@@ -102,14 +209,17 @@ class InterconnectTest < ExpectTest
|
|
|
102
209
|
assert_equal ["original"], source.instance_variable_get(:@sequences).keys
|
|
103
210
|
end
|
|
104
211
|
|
|
105
|
-
def
|
|
212
|
+
def test_interconnect_leaves_terminal_modes_to_the_caller
|
|
106
213
|
session = child('print "!"; sleep 30')
|
|
107
|
-
mode = session.to_io.console_mode
|
|
108
214
|
original = terminal_configuration(session)
|
|
109
|
-
|
|
215
|
+
seen = nil
|
|
216
|
+
session.on_sequence("!") do
|
|
217
|
+
seen = terminal_configuration(session)
|
|
218
|
+
raise "stop"
|
|
219
|
+
end
|
|
110
220
|
assert_raises(RuntimeError) { Expect.interconnect(session, timeout: 1) }
|
|
221
|
+
assert_equal original, seen
|
|
111
222
|
assert_equal original, terminal_configuration(session)
|
|
112
|
-
refute_nil mode
|
|
113
223
|
end
|
|
114
224
|
|
|
115
225
|
def test_manual_stty_leaves_terminal_mode_to_caller
|
|
@@ -136,6 +246,50 @@ class InterconnectTest < ExpectTest
|
|
|
136
246
|
assert_empty session.buffer
|
|
137
247
|
end
|
|
138
248
|
|
|
249
|
+
def test_interconnect_retries_an_interrupted_select
|
|
250
|
+
session, writer = pipe_session
|
|
251
|
+
output = StringIO.new
|
|
252
|
+
session.listeners = [output]
|
|
253
|
+
session.on_sequence("!")
|
|
254
|
+
writer.write("ready!")
|
|
255
|
+
original = IO.method(:select)
|
|
256
|
+
interrupted = true
|
|
257
|
+
select = lambda do |*arguments|
|
|
258
|
+
if interrupted
|
|
259
|
+
interrupted = false
|
|
260
|
+
raise Errno::EINTR
|
|
261
|
+
end
|
|
262
|
+
original.call(*arguments)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
IO.stub(:select, select) do
|
|
266
|
+
assert_same session, Expect.interconnect(session, timeout: 1)
|
|
267
|
+
end
|
|
268
|
+
assert_equal "ready", output.string
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def test_interconnect_retries_an_interrupted_read
|
|
272
|
+
session, writer = pipe_session
|
|
273
|
+
output = StringIO.new
|
|
274
|
+
session.listeners = [output]
|
|
275
|
+
session.on_sequence("!")
|
|
276
|
+
writer.write("ready!")
|
|
277
|
+
original = session.to_io.method(:read_nonblock)
|
|
278
|
+
interrupted = true
|
|
279
|
+
read = lambda do |*arguments, **options|
|
|
280
|
+
if interrupted
|
|
281
|
+
interrupted = false
|
|
282
|
+
raise Errno::EINTR
|
|
283
|
+
end
|
|
284
|
+
original.call(*arguments, **options)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
session.to_io.stub(:read_nonblock, read) do
|
|
288
|
+
assert_same session, Expect.interconnect(session, timeout: 1)
|
|
289
|
+
end
|
|
290
|
+
assert_equal "ready", output.string
|
|
291
|
+
end
|
|
292
|
+
|
|
139
293
|
def test_regexp_escape_matches_and_preserves_trailing_input
|
|
140
294
|
session, writer = pipe_session
|
|
141
295
|
output = StringIO.new
|
|
@@ -169,6 +323,36 @@ class InterconnectTest < ExpectTest
|
|
|
169
323
|
assert_equal "after", session.buffer
|
|
170
324
|
end
|
|
171
325
|
|
|
326
|
+
def test_regexp_escape_history_has_a_finite_default_window
|
|
327
|
+
session, = pipe_session
|
|
328
|
+
session.on_sequence(/UNLIKELY_ESCAPE/)
|
|
329
|
+
buffers = { session => "".b }
|
|
330
|
+
|
|
331
|
+
5.times do
|
|
332
|
+
buffers[session] << ("x" * 16_384)
|
|
333
|
+
Expect.send(:relay_buffer, session, buffers)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
assert_operator session.__send__(:relay_history).bytesize, :<=, 65_536
|
|
337
|
+
assert_empty buffers.fetch(session)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def test_utf8_regexp_escape_history_keeps_a_valid_leading_character
|
|
341
|
+
session, = pipe_session
|
|
342
|
+
session.on_sequence(/终止/u)
|
|
343
|
+
buffers = { session => "".b }
|
|
344
|
+
|
|
345
|
+
6.times do
|
|
346
|
+
buffers[session] << ("中" * 5461).b
|
|
347
|
+
assert Expect.send(:relay_buffer, session, buffers)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
assert_operator session.__send__(:relay_history).bytesize, :<=, 65_536
|
|
351
|
+
assert session.__send__(:relay_history).dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
352
|
+
buffers[session] << "终止".b
|
|
353
|
+
refute Expect.send(:relay_buffer, session, buffers)
|
|
354
|
+
end
|
|
355
|
+
|
|
172
356
|
def test_regexp_escape_continuation_does_not_rematch_history
|
|
173
357
|
session, writer = pipe_session
|
|
174
358
|
seen = []
|
|
@@ -194,6 +378,21 @@ class InterconnectTest < ExpectTest
|
|
|
194
378
|
assert_equal "abc", session.buffer
|
|
195
379
|
end
|
|
196
380
|
|
|
381
|
+
def test_log_failure_preserves_received_bytes_for_recovery
|
|
382
|
+
session, writer = pipe_session
|
|
383
|
+
session.log_to { raise IOError, "log failed" }
|
|
384
|
+
writer.write("before!tail")
|
|
385
|
+
|
|
386
|
+
error = assert_raises(IOError) { Expect.interconnect(session, timeout: 1) }
|
|
387
|
+
assert_equal "log failed", error.message
|
|
388
|
+
assert_equal "before!tail", session.buffer
|
|
389
|
+
refute session.eof?
|
|
390
|
+
|
|
391
|
+
session.log_output = nil
|
|
392
|
+
assert_equal 1, session.expect("before!", timeout: 0)
|
|
393
|
+
assert_equal "tail", session.buffer
|
|
394
|
+
end
|
|
395
|
+
|
|
197
396
|
def test_logs_received_bytes_once_across_expect_interconnect_and_expect
|
|
198
397
|
session, writer = pipe_session
|
|
199
398
|
log = StringIO.new
|