expect-pty 0.3.0 → 0.3.2

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/test/io_test.rb CHANGED
@@ -133,6 +133,41 @@ class IOTest < ExpectTest
133
133
  assert_equal 1, session.expect("ABC", timeout: 1)
134
134
  end
135
135
 
136
+ def test_send_slow_zero_delay_only_polls_and_later_reply_remains_readable
137
+ client, peer = Socket.pair(:UNIX, :STREAM, 0)
138
+ @ios.push(client, peer)
139
+ session = Expect.open(client, log_stdout: false)
140
+ @sessions << session
141
+ waits = []
142
+ original = client.method(:wait_readable)
143
+ client.stub(:wait_readable, lambda { |timeout|
144
+ waits << timeout
145
+ original.call(0)
146
+ }) do
147
+ assert_equal 8, session.send_slow("a中😀", delay: 0)
148
+ end
149
+ assert_equal [0, 0, 0], waits
150
+ assert_equal("a中😀".b, bounded { peer.read(8) })
151
+ peer.write("late reply")
152
+ assert_equal 1, session.expect("late reply", timeout: 1)
153
+ assert_empty session.buffer
154
+ end
155
+
156
+ def test_send_slow_preserves_character_delay_and_write_timeout
157
+ session, = pipe_session
158
+ calls = []
159
+ failure = Expect::WriteTimeout.new(bytes_written: 0)
160
+ session.stub(:sleep, ->(duration) { calls << [:sleep, duration] }) do
161
+ session.stub(:write, lambda { |data|
162
+ calls << [:write, data]
163
+ raise failure
164
+ }) do
165
+ assert_same failure, assert_raises(Expect::WriteTimeout) { session.send_slow("中b", delay: 0.25) }
166
+ end
167
+ end
168
+ assert_equal [[:sleep, 0.25], [:write, "中"]], calls
169
+ end
170
+
136
171
  def test_large_bidirectional_write_does_not_deadlock
137
172
  session = child("STDIN.binmode; STDOUT.binmode; loop { print STDIN.readpartial(4096) }", raw_pty: true,
138
173
  write_timeout: 3)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class PatternOffsetTest < ExpectTest
6
+ def test_byte_offsets_captures_and_buffer_consumption
7
+ cases = [
8
+ ["abENDtail", /END/, 2, 3, []],
9
+ ["中文终点尾", /(终)(点)/, 6, 6, ["终".b, "点".b]],
10
+ ["😀中😀尾", /(😀)(x)?/, 0, 4, ["😀".b, nil]],
11
+ ["😀中END尾", /END/, 7, 3, []],
12
+ ["a\0b", /(\0)/n, 1, 1, ["\0".b]],
13
+ ["\xFF\x00\xFE".b, /(\xFE)/n, 2, 1, ["\xFE".b]],
14
+ ["中文尾", /(?=尾)/, 6, 0, []],
15
+ ["😀", /$/u, 4, 0, []]
16
+ ]
17
+ session, = pipe_session
18
+ cases.each do |text, regexp, offset, length, captures|
19
+ bytes = text.b
20
+ assert_equal [offset, length, captures], Expect::Pattern.new(value: regexp).locate(bytes)
21
+ session.buffer = bytes
22
+ result = session.expect_result(regexp, timeout: 0)
23
+ assert_equal bytes.byteslice(0, offset), result.before
24
+ assert_equal bytes.byteslice(offset, length), result.match
25
+ assert_equal bytes.byteslice((offset + length)..), result.after
26
+ assert_equal captures, result.captures
27
+ assert_equal result.after, session.buffer
28
+ end
29
+ end
30
+
31
+ def test_incomplete_utf8_defers_matching_and_eof_still_rejects_it
32
+ pattern = Expect::Pattern.new(value: /中(?=😀)/)
33
+ bytes = "前中😀".b
34
+ (1..3).each do |missing|
35
+ partial = bytes.byteslice(0, bytes.bytesize - missing)
36
+ assert_nil pattern.locate(partial)
37
+ assert_raises(EncodingError) { pattern.locate(partial, final: true) }
38
+ end
39
+ assert_equal [3, 3, []], pattern.locate(bytes)
40
+ end
41
+ end
data/test/process_test.rb CHANGED
@@ -36,9 +36,10 @@ class ProcessTest < ExpectTest
36
36
  assert session.slave.tty?
37
37
  session.slave.echo = false
38
38
  session.slave.winsize = [37, 111]
39
- session.spawn(RbConfig.ruby, "--disable-gems", "-rio/console", "-e",
39
+ # 子进程需要 io-console gem,保留 RubyGems 以使用 Bundler 选定的版本。
40
+ session.spawn(RbConfig.ruby, "-rio/console", "-e",
40
41
  'STDOUT.sync = true; puts STDIN.winsize.join(":"); puts STDIN.gets')
41
- assert_equal 1, session.expect("37:111", timeout: 2)
42
+ assert_equal 1, session.expect("37:111", timeout: 2), session.before.inspect
42
43
  assert_equal [37, 111], session.winsize
43
44
  session.winsize = [24, 80]
44
45
  assert_equal [24, 80], session.winsize
@@ -234,6 +234,43 @@ class RelayRecoveryTest < ExpectTest
234
234
  end
235
235
  end
236
236
 
237
+ def test_split_literal_and_short_write_failures_preserve_delivery_and_callback_order
238
+ [1, 2, 3].product([1, 2, 3], [0, 2]).each do |split, step, failure_offset|
239
+ source, writer = pipe_session
240
+ fast = StringIO.new
241
+ received = "".b
242
+ failed = false
243
+ slow = Object.new
244
+ slow.define_singleton_method(:write) do |data|
245
+ if !failed && received.bytesize == failure_offset
246
+ failed = true
247
+ raise IOError, "injected partial delivery"
248
+ end
249
+ count = [step, data.bytesize].min
250
+ count = [count, failure_offset - received.bytesize].min unless failed
251
+ received << data.byteslice(0, count)
252
+ count
253
+ end
254
+ callbacks = []
255
+ source.listeners = [fast, slow]
256
+ source.on_sequence("STOP") do
257
+ callbacks << [fast.string.dup, received.dup]
258
+ false
259
+ end
260
+ source.buffer = "head#{"STOP".byteslice(0, split)}"
261
+ writer.write("#{"STOP".byteslice(split..)}tail")
262
+ assert_raises(IOError) { bounded { Expect.interconnect(source, timeout: 1) } }
263
+ assert_empty callbacks
264
+ assert source.pending_output?
265
+ assert_same(source, bounded { Expect.interconnect(source, timeout: 1) })
266
+ assert_equal [%w[head head]], callbacks
267
+ assert_equal "tail", source.buffer
268
+ assert_equal "head", fast.string
269
+ assert_equal "head", received
270
+ refute source.pending_output?
271
+ end
272
+ end
273
+
237
274
  def test_flush_failure_retries_flush_without_replaying_data
238
275
  source, = pipe_session
239
276
  output = StringIO.new
data/test/release_test.rb CHANGED
@@ -63,14 +63,34 @@ class ReleaseTest < Minitest::Test
63
63
  release.stub(:system, push) do
64
64
  release.stub(:github, ->(*) { flunk "RubyGems-only release contacted GitHub" }) do
65
65
  release.stub(:publish_github, -> { flunk "RubyGems-only release published to GitHub" }) do
66
- output, = capture_io { release.run }
67
- assert_includes output, "SHA256 verified"
66
+ [nil, "true"].each do |github_actions|
67
+ with_environment("GITHUB_ACTIONS" => github_actions,
68
+ "GEM_HOST_API_KEY" => (github_actions ? "test-only-api-key" : nil)) do
69
+ published = false
70
+ output, = capture_io { release.run }
71
+ assert_includes output, "SHA256 verified"
72
+ assert published
73
+ end
74
+ end
68
75
  end
69
76
  end
70
77
  end
71
78
  end
72
79
  end
73
- assert published
80
+ end
81
+ end
82
+
83
+ def test_ci_publish_requires_an_api_key_before_pushing
84
+ release = Release.new(rubygems_only: true)
85
+ release.stub(:registry_version, nil) do
86
+ release.stub(:system, ->(*) { flunk "pushed without a CI API key" }) do
87
+ [nil, ""].each do |api_key|
88
+ with_environment("GITHUB_ACTIONS" => "true", "GEM_HOST_API_KEY" => api_key) do
89
+ error = assert_raises(RuntimeError) { release.send(:publish_rubygems) }
90
+ assert_includes error.message, "RUBYGEMS_API_KEY"
91
+ end
92
+ end
93
+ end
74
94
  end
75
95
  end
76
96
 
@@ -194,6 +214,17 @@ class ReleaseTest < Minitest::Test
194
214
 
195
215
  private
196
216
 
217
+ # 发布测试显式控制凭据环境,避免本机登录状态或 CI 标记影响用例结果。
218
+ def with_environment(values)
219
+ previous = values.to_h { |name, _value| [name, ENV.fetch(name, nil)] }
220
+ begin
221
+ values.each { |name, value| ENV[name] = value }
222
+ yield
223
+ ensure
224
+ previous.each { |name, value| ENV[name] = value }
225
+ end
226
+ end
227
+
197
228
  def with_package
198
229
  Dir.mktmpdir("expect-release-test-") do |directory|
199
230
  Dir.chdir(directory) do
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class ScanReuseTest < ExpectTest
6
+ def test_each_scan_snapshots_a_session_once_across_non_adjacent_groups
7
+ first, = pipe_session
8
+ second, = pipe_session
9
+ first.buffer = "ready"
10
+ list = Expect::PatternList.new
11
+ list.on("missing", from: first).on("other", from: second).on("ready", from: first)
12
+ matcher = Expect::Matcher.new(list, 0)
13
+ snapshots = 0
14
+ original = first.method(:buffer)
15
+ first.stub(:buffer, lambda {
16
+ snapshots += 1
17
+ original.call
18
+ }) do
19
+ 2.times do
20
+ result = matcher.__send__(:find_match)
21
+ assert_same first, result[0]
22
+ assert_equal 3, result[1].number
23
+ assert_equal [0, 5, []], result[2]
24
+ end
25
+ end
26
+ assert_equal 2, snapshots
27
+ end
28
+
29
+ def test_callback_replacement_and_nested_match_are_visible_on_the_next_scan
30
+ first, = pipe_session
31
+ second, = pipe_session
32
+ first.buffer = "start"
33
+ events = []
34
+ result = Expect.expect_result(timeout: 0) do
35
+ on("start", from: first) do |session|
36
+ events << session.match
37
+ session.buffer = "nested finish"
38
+ session.expect("nested ", timeout: 0)
39
+ events << session.match
40
+ session.continue
41
+ end
42
+ on("missing", from: second)
43
+ on("finish", from: first) { |session| events << session.match }
44
+ end
45
+ assert_equal 3, result.number
46
+ assert_equal ["start", "nested ", "finish"], events
47
+ assert_empty first.buffer
48
+ end
49
+
50
+ def test_shared_io_keeps_first_session_and_identity_despite_equal_hashes
51
+ first, writer = pipe_session
52
+ second = Expect.open(first.to_io)
53
+ @sessions << second
54
+ other, other_writer = pipe_session
55
+ # IO 的业务相等性不能替代 IO.select 返回对象的身份。
56
+ [first.to_io, other.to_io].each do |io|
57
+ io.define_singleton_method(:hash) { 0 }
58
+ io.define_singleton_method(:eql?) { |_other| true }
59
+ end
60
+ writer.write("first")
61
+ other_writer.write("other")
62
+ result = Expect.expect_result(timeout: 0) do
63
+ on("missing", from: [first, second, other])
64
+ on("other", from: other)
65
+ end
66
+ assert_same other, result.session
67
+ assert_equal "first", first.buffer
68
+ assert_empty second.buffer
69
+ end
70
+
71
+ def test_regexps_share_one_combined_text_and_literal_only_does_not_build_it
72
+ session, = pipe_session
73
+ session.listeners = [StringIO.new]
74
+ [0, 4].each do |count|
75
+ session.__send__(:sequences=, {})
76
+ count.times { |index| session.on_sequence(/missing#{index}/) }
77
+ session.on_sequence("STOP")
78
+ history = session.__send__(:relay_history)
79
+ concatenations = 0
80
+ original = history.method(:+)
81
+ history.define_singleton_method(:+) do |other|
82
+ concatenations += 1
83
+ original.call(other)
84
+ end
85
+ assert Expect.__send__(:relay_buffer, session, { session => "payload".b })
86
+ assert_equal count.zero? ? 0 : 1, concatenations
87
+ end
88
+ end
89
+
90
+ def test_escape_callback_can_change_rules_before_rescanning
91
+ session, = pipe_session
92
+ output = StringIO.new
93
+ session.listeners = [output]
94
+ events = []
95
+ session.on_sequence(/ONE/) do
96
+ events << :one
97
+ session.on_sequence(/TWO/) do
98
+ events << :two
99
+ false
100
+ end
101
+ true
102
+ end
103
+ buffers = { session => "aONEbTWOtail".b }
104
+ refute Expect.__send__(:relay_buffer, session, buffers)
105
+ assert_equal %i[one two], events
106
+ assert_equal "ab", output.string
107
+ assert_equal "tail", buffers.fetch(session)
108
+ end
109
+ end
metadata CHANGED
@@ -1,14 +1,102 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: expect-pty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 2026-09-26 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: forwardable
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: io-console
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.6'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '1.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0.6'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: io-wait
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0.3'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '1.0'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0.3'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '1.0'
66
+ - !ruby/object:Gem::Dependency
67
+ name: shellwords
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0.1'
73
+ - - "<"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0.1'
83
+ - - "<"
84
+ - !ruby/object:Gem::Version
85
+ version: '1.0'
86
+ - !ruby/object:Gem::Dependency
87
+ name: stringio
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - "~>"
91
+ - !ruby/object:Gem::Version
92
+ version: '3.0'
93
+ type: :runtime
94
+ prerelease: false
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: '3.0'
12
100
  description: Automate interactive programs with exact and regexp matching, Ruby blocks,
13
101
  multi-session waits, logging and terminal interaction.
14
102
  executables: []
@@ -17,11 +105,18 @@ extra_rdoc_files: []
17
105
  files:
18
106
  - ".rubocop.yml"
19
107
  - CHANGELOG.md
108
+ - CONTRIBUTING.md
20
109
  - Gemfile
21
110
  - LICENSE
22
111
  - README.md
23
112
  - Rakefile
113
+ - benchmark/matching.rb
114
+ - benchmark/relay.rb
115
+ - benchmark/send_slow.rb
116
+ - benchmark/support.rb
24
117
  - docs/COMPATIBILITY.md
118
+ - docs/INTERNAL_CONTRACTS.md
119
+ - docs/PERFORMANCE.md
25
120
  - docs/RELEASING.md
26
121
  - docs/VERIFICATION.md
27
122
  - examples/dialogue.rb
@@ -46,6 +141,7 @@ files:
46
141
  - lib/expect/version.rb
47
142
  - script/ci
48
143
  - script/release.rb
144
+ - test/cleanup_test.rb
49
145
  - test/compare_upstream.rb
50
146
  - test/configuration_test.rb
51
147
  - test/edge_case_test.rb
@@ -62,10 +158,12 @@ files:
62
158
  - test/kibitz_test.rb
63
159
  - test/matching_test.rb
64
160
  - test/multi_session_test.rb
161
+ - test/pattern_offset_test.rb
65
162
  - test/process_test.rb
66
163
  - test/relay_recovery_test.rb
67
164
  - test/release_test.rb
68
165
  - test/ruby_api_test.rb
166
+ - test/scan_reuse_test.rb
69
167
  - test/script_logging_test.rb
70
168
  - test/support/interact_probe.rb
71
169
  - test/support/kibitz_probe.rb
@@ -94,7 +192,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
192
  - !ruby/object:Gem::Version
95
193
  version: '0'
96
194
  requirements: []
97
- rubygems_version: 4.0.12
195
+ rubygems_version: 4.0.16
98
196
  specification_version: 4
99
197
  summary: Ruby PTY automation with the Expect.pm interaction model
100
198
  test_files: []