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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +10 -1
- data/docs/INTERNAL_CONTRACTS.md +21 -0
- data/docs/VERIFICATION.md +98 -0
- data/lib/expect/interaction.rb +2 -0
- data/lib/expect/relay.rb +37 -7
- data/lib/expect/session_resources.rb +39 -14
- data/lib/expect/terminal.rb +1 -1
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +70 -33
- data/script/release.rb +10 -2
- data/test/initialization_failure_test.rb +92 -0
- data/test/ownership_sequence_test.rb +208 -0
- data/test/process_interruption_test.rb +296 -0
- data/test/relay_reentrancy_test.rb +159 -0
- data/test/release_test.rb +47 -0
- data/test/write_contract_test.rb +105 -0
- metadata +7 -2
|
@@ -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.
|
|
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.
|
|
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: []
|