expect-pty 0.5.2 → 0.5.3

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.
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "minitest/mock"
5
+
6
+ class RelayReentrancyTest < ExpectTest
7
+ def test_recursive_write_and_flush_cannot_duplicate_delivery_or_change_cursors
8
+ %i[write flush].each do |entry|
9
+ source, = pipe_session
10
+ output = StringIO.new
11
+ errors = []
12
+ snapshots = []
13
+ entered = false
14
+ original = output.method(entry)
15
+ output.define_singleton_method(entry) do |*arguments|
16
+ result = original.call(*arguments)
17
+ unless entered
18
+ entered = true
19
+ cursors = source.__send__(:relay_outputs)
20
+ snapshot = -> { [source.buffer, cursors.map { |c| c.instance_variable_get(:@offset) }] }
21
+ snapshots << snapshot.call
22
+ begin
23
+ Expect.interconnect(source, timeout: 0)
24
+ rescue StandardError => error
25
+ errors << error
26
+ end
27
+ snapshots << snapshot.call
28
+ end
29
+ result
30
+ end
31
+ source.listeners = [output]
32
+ source.buffer = "abc"
33
+ bounded { Expect.interconnect(source, timeout: 0) }
34
+ assert_equal "abc", output.string, "recursive #{entry} replayed bytes"
35
+ assert_equal(["Expect::ReentrancyError"], errors.map { |error| error.class.name })
36
+ assert_equal snapshots.first, snapshots.last
37
+ refute source.pending_output?
38
+ end
39
+ end
40
+
41
+ def test_escape_callback_cannot_enter_the_same_source_but_can_use_a_matcher
42
+ source, = pipe_session
43
+ output = StringIO.new
44
+ source.listeners = [output]
45
+ calls = 0
46
+ source.on_sequence("!") do
47
+ calls += 1
48
+ error = assert_raises(StandardError) { Expect.interconnect(source, timeout: 0) }
49
+ assert_equal "Expect::ReentrancyError", error.class.name
50
+ assert_equal "inside", source.expect_result("inside", timeout: 0).match
51
+ false
52
+ end
53
+ source.buffer = "prefix!insidetail"
54
+ assert_same source, Expect.interconnect(source, timeout: 1)
55
+ assert_equal 1, calls
56
+ assert_equal "prefix", output.string
57
+ assert_equal "tail", source.buffer
58
+ Expect.interconnect(source, timeout: 0)
59
+ assert_equal "prefixtail", output.string
60
+ end
61
+
62
+ def test_partial_overlap_does_not_steal_buffers_or_release_the_outer_owner
63
+ outer, = pipe_session
64
+ other, = pipe_session
65
+ other.buffer = "other"
66
+ output = StringIO.new
67
+ other.listeners = [output]
68
+ outer.on_sequence("!") do
69
+ 2.times do
70
+ error = assert_raises(StandardError) { Expect.interconnect(other, outer, timeout: 0) }
71
+ assert_equal "Expect::ReentrancyError", error.class.name
72
+ assert_equal "other", other.buffer
73
+ end
74
+ Expect.interconnect(other, timeout: 0)
75
+ false
76
+ end
77
+ outer.buffer = "!tail"
78
+ assert_same outer, Expect.interconnect(outer, timeout: 1)
79
+ assert_equal "tail", outer.buffer
80
+ assert_equal "other", output.string
81
+ end
82
+
83
+ def test_disjoint_sources_and_duplicate_arguments_are_supported
84
+ outer, = pipe_session
85
+ inner, = pipe_session
86
+ sink = StringIO.new
87
+ inner.listeners = [sink]
88
+ inner.buffer = "independent"
89
+ outer.buffer = "!tail"
90
+ outer.on_sequence("!") do
91
+ Expect.interconnect(inner, inner, timeout: 0)
92
+ false
93
+ end
94
+ assert_same outer, Expect.interconnect(outer, outer, timeout: 1)
95
+ assert_equal "independent", sink.string
96
+ assert_equal "tail", outer.buffer
97
+ end
98
+
99
+ def test_uncaught_reentry_propagates_and_sequential_retry_releases_the_owner
100
+ source, = pipe_session
101
+ sink = StringIO.new
102
+ entered = false
103
+ original = sink.method(:write)
104
+ sink.define_singleton_method(:write) do |data|
105
+ unless entered
106
+ entered = true
107
+ Expect.interconnect(source, timeout: 0)
108
+ end
109
+ original.call(data)
110
+ end
111
+ source.listeners = [sink]
112
+ source.buffer = "once"
113
+ error = assert_raises(StandardError) { Expect.interconnect(source, timeout: 0) }
114
+ assert_equal "Expect::ReentrancyError", error.class.name
115
+ assert source.pending_output?
116
+ Expect.interconnect(source, timeout: 0)
117
+ assert_equal "once", sink.string
118
+ refute source.pending_output?
119
+ end
120
+
121
+ def test_preparation_failure_restores_all_moved_buffers_and_releases_ownership
122
+ first, = pipe_session
123
+ second, = pipe_session
124
+ first.buffer = "first"
125
+ second.buffer = "second"
126
+ error = IOError.new("injected preparation failure")
127
+ second.stub(:clear_buffer, -> { raise error }) do
128
+ assert_same error, assert_raises(IOError) { Expect.interconnect(first, second, timeout: 0) }
129
+ end
130
+ assert_equal "first", first.buffer
131
+ assert_equal "second", second.buffer
132
+ sink = StringIO.new
133
+ first.listeners = second.listeners = [sink]
134
+ Expect.interconnect(first, second, timeout: 0)
135
+ assert_equal "firstsecond", sink.string
136
+ end
137
+
138
+ def test_invalid_arguments_leave_buffers_unchanged
139
+ source, = pipe_session
140
+ source.buffer = "retained"
141
+ assert_raises(ArgumentError) { Expect.interconnect(source, timeout: -1) }
142
+ assert_raises(ArgumentError) { Expect.interconnect(source, Object.new, timeout: 0) }
143
+ assert_equal "retained", source.buffer
144
+ refute source.pending_output?
145
+ end
146
+
147
+ def test_eof_and_callback_errors_release_ownership
148
+ source, producer = pipe_session
149
+ failure = Interrupt.new("callback interrupted")
150
+ source.on_sequence("!") { raise failure }
151
+ source.buffer = "!tail"
152
+ assert_same failure, assert_raises(Interrupt) { Expect.interconnect(source, timeout: 1) }
153
+ producer.close
154
+ sink = StringIO.new
155
+ source.listeners = [sink]
156
+ 2.times { assert_same source, Expect.interconnect(source, timeout: 1) }
157
+ assert_equal "tail", sink.string
158
+ end
159
+ end
data/test/release_test.rb CHANGED
@@ -10,6 +10,40 @@ class ReleaseTest < Minitest::Test
10
10
  assert_equal "- New API.", Release.release_notes(changelog, "0.2.0")
11
11
  end
12
12
 
13
+ def test_dry_run_writes_identical_utf8_notes_in_c_and_utf8_locales
14
+ notes = "- 修复初始化中断,保留原异常。\n"
15
+ with_package do |_release, artifact|
16
+ script = locale_script
17
+ File.write("CHANGELOG.md", "## #{Expect::VERSION}\n\n#{notes}", encoding: "UTF-8")
18
+ rebuild_package
19
+ %w[US-ASCII UTF-8].each do |encoding|
20
+ env = release_environment.merge("LC_ALL" => (encoding == "US-ASCII" ? "C" : "en_US.UTF-8"), "LANG" => "C")
21
+ output, error, status = Open3.capture3(env, RbConfig.ruby, "-E#{encoding}", script,
22
+ "--dry-run", "--artifact", artifact, binmode: true)
23
+ assert status.success?, "#{encoding}: #{output} #{error}"
24
+ assert_includes output, "Dry run complete"
25
+ files = Dir.glob("tmp/release/#{Expect::VERSION}/candidate-*/release-notes.md")
26
+ refute_empty files
27
+ files.each { |file| assert_equal notes.b, File.binread(file) }
28
+ end
29
+ end
30
+ end
31
+
32
+ def test_dry_run_rejects_invalid_utf8_without_rewriting_the_changelog
33
+ with_package do |_release, artifact|
34
+ script = locale_script
35
+ bytes = "## #{Expect::VERSION}\n- invalid \xff\n".b
36
+ File.binwrite("CHANGELOG.md", bytes)
37
+ rebuild_package
38
+ _output, error, status = Open3.capture3(release_environment.merge("LC_ALL" => "C", "LANG" => "C"),
39
+ RbConfig.ruby, "-EUS-ASCII", script, "--dry-run", "--artifact", artifact)
40
+ refute status.success?
41
+ assert_includes error, "Invalid UTF-8 in CHANGELOG.md"
42
+ assert_equal bytes, File.binread("CHANGELOG.md")
43
+ assert_empty Dir.glob("tmp/release/#{Expect::VERSION}/candidate-*")
44
+ end
45
+ end
46
+
13
47
  def test_rejects_unreleased_missing_empty_or_invalid_versions
14
48
  ["## Unreleased\n- Pending.\n## 0.2.0\n- Ready.", "## 0.1.1\n- Old.", "## 0.2.0\n"].each do |changelog|
15
49
  assert_raises(RuntimeError) { Release.release_notes(changelog, "0.2.0") }
@@ -261,6 +295,19 @@ class ReleaseTest < Minitest::Test
261
295
 
262
296
  private
263
297
 
298
+ # CLI 按自身路径定位项目根目录;复制入口和版本文件,避免误读开发仓库的 CHANGELOG。
299
+ def locale_script
300
+ FileUtils.mkdir_p(["script", "lib/expect"])
301
+ FileUtils.cp(File.expand_path("../script/release.rb", __dir__), "script/release.rb")
302
+ FileUtils.cp(File.expand_path("../lib/expect/version.rb", __dir__), "lib/expect/version.rb")
303
+ File.expand_path("script/release.rb")
304
+ end
305
+
306
+ def release_environment
307
+ names = ENV.keys.grep(/\ABUNDLE/) + %w[RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS]
308
+ names.to_h { |name| [name, nil] }
309
+ end
310
+
264
311
  def git(*)
265
312
  output, error, status = Open3.capture3("git", *)
266
313
  assert status.success?, error
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "minitest/mock"
5
+
6
+ class WriteContractTest < ExpectTest
7
+ def test_invalid_write_counts_fail_on_the_first_attempt
8
+ [0, -1, nil, "3", 4].each do |invalid|
9
+ session, = writable_session
10
+ attempts = 0
11
+ session.writer.stub(:write_nonblock, lambda { |*, **|
12
+ attempts += 1
13
+ invalid
14
+ }) do
15
+ error = assert_raises(IOError) { bounded(0.2) { session.write("abc") } }
16
+ refute_kind_of Expect::WriteTimeout, error
17
+ assert_match(/accepted bytes/, error.message)
18
+ assert_equal 1, attempts
19
+ end
20
+ end
21
+ end
22
+
23
+ def test_count_is_checked_against_the_attempted_chunk_not_the_total_input
24
+ session, = writable_session
25
+ session.writer.stub(:write_nonblock, lambda { |chunk, **|
26
+ assert_equal Expect::READ_SIZE, chunk.bytesize
27
+ chunk.bytesize + 1
28
+ }) do
29
+ assert_raises(IOError) { session.write("x" * (Expect::READ_SIZE + 1)) }
30
+ end
31
+ end
32
+
33
+ def test_successful_short_writes_can_outlive_the_backpressure_deadline
34
+ session, sink = writable_session(write_timeout: 0.01)
35
+ now = 0.0
36
+ original = session.writer.method(:write_nonblock)
37
+ Expect.stub(:monotonic, -> { now }) do
38
+ session.writer.stub(:write_nonblock, lambda { |chunk, **options|
39
+ count = original.call(chunk.byteslice(0, 1), **options)
40
+ now += 0.02
41
+ count
42
+ }) do
43
+ assert_equal 3, session.write("abc")
44
+ end
45
+ end
46
+ assert_in_delta 0.06, now
47
+ assert_equal("abc", bounded { sink.read(3) })
48
+ end
49
+
50
+ def test_empty_input_never_attempts_a_write
51
+ session, = writable_session(write_timeout: 0)
52
+ session.writer.stub(:write_nonblock, ->(*, **) { flunk "empty input reached writer" }) do
53
+ assert_equal 0, session.write
54
+ assert_equal 0, session.write("")
55
+ end
56
+ end
57
+
58
+ def test_valid_short_writes_and_eintr_preserve_every_byte
59
+ session, sink = writable_session
60
+ original = session.writer.method(:write_nonblock)
61
+ attempts = 0
62
+ session.writer.stub(:write_nonblock, lambda { |chunk, **options|
63
+ attempts += 1
64
+ raise Errno::EINTR if attempts == 2
65
+
66
+ original.call(chunk.byteslice(0, 1), **options)
67
+ }) do
68
+ assert_equal(4, bounded { session.write("a\0\xffb".b) })
69
+ end
70
+ assert_equal 5, attempts
71
+ assert_equal("a\0\xffb".b, bounded { sink.read(4) })
72
+ end
73
+
74
+ def test_backpressure_reports_only_confirmed_progress
75
+ session, sink = writable_session(write_timeout: 0.01)
76
+ original = session.writer.method(:write_nonblock)
77
+ now = 0.0
78
+ attempts = 0
79
+ Expect.stub(:monotonic, -> { now }) do
80
+ session.writer.stub(:write_nonblock, lambda { |chunk, **options|
81
+ attempts += 1
82
+ next :wait_writable if attempts > 1
83
+
84
+ now = 0.02
85
+ original.call(chunk.byteslice(0, 1), **options)
86
+ }) do
87
+ error = assert_raises(Expect::WriteTimeout) { bounded { session.write("abc") } }
88
+ assert_equal 1, error.bytes_written
89
+ end
90
+ end
91
+ assert_equal 2, attempts
92
+ assert_equal("a", bounded { sink.read(1) })
93
+ end
94
+
95
+ private
96
+
97
+ def writable_session(**)
98
+ reader, producer = IO.pipe
99
+ sink, writer = IO.pipe
100
+ @ios.push(reader, producer, sink, writer)
101
+ session = Expect.open(reader, writer: writer, **)
102
+ @sessions << session
103
+ [session, sink]
104
+ end
105
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: expect-pty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
@@ -158,6 +158,7 @@ files:
158
158
  - test/fixtures/ssh_scripts/03_delayed.sh
159
159
  - test/fixtures/ssh_scripts/04_failure.sh
160
160
  - test/fixtures/ssh_scripts/05_recovery.sh
161
+ - test/initialization_failure_test.rb
161
162
  - test/integration/README.md
162
163
  - test/integration/ssh_scripts.rb
163
164
  - test/interact_test.rb
@@ -168,10 +169,13 @@ files:
168
169
  - test/literal_scan_test.rb
169
170
  - test/matching_test.rb
170
171
  - test/multi_session_test.rb
172
+ - test/ownership_sequence_test.rb
171
173
  - test/pattern_offset_test.rb
174
+ - test/process_interruption_test.rb
172
175
  - test/process_test.rb
173
176
  - test/redactor_test.rb
174
177
  - test/relay_recovery_test.rb
178
+ - test/relay_reentrancy_test.rb
175
179
  - test/release_test.rb
176
180
  - test/ruby_api_test.rb
177
181
  - test/scan_reuse_test.rb
@@ -182,6 +186,7 @@ files:
182
186
  - test/terminal_cleanup_test.rb
183
187
  - test/test_helper.rb
184
188
  - test/timeout_test.rb
189
+ - test/write_contract_test.rb
185
190
  homepage: https://github.com/gatework/expect-ruby
186
191
  licenses:
187
192
  - MIT
@@ -204,7 +209,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
204
209
  - !ruby/object:Gem::Version
205
210
  version: '0'
206
211
  requirements: []
207
- rubygems_version: 4.0.16
212
+ rubygems_version: 4.0.12
208
213
  specification_version: 4
209
214
  summary: Ruby PTY automation with the Expect.pm interaction model
210
215
  test_files: []