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.
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 pkg/ci
9
+ mkdir -p tmp/ci
10
10
  gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
11
- gem_file="pkg/ci/expect-pty-${gem_version}.gem"
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 dialogue and process cleanup passed"
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 || "pkg/ci/expect-pty-#{@version}.gem")
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("pkg", "release", @version)
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
- verify_remote_source
53
- verify_registry_checksum(registry_version)
54
- publish_github
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
- unless capture("git", "rev-parse", "HEAD") == @commit && capture("git", "status", "--porcelain").empty?
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
@@ -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
@@ -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
@@ -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 test_terminal_modes_restore_after_callback_exception
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
- session.on_sequence("!") { raise "stop" }
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