expect-pty 0.3.1 → 0.3.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/.rubocop.yml +41 -4
- data/CHANGELOG.md +14 -0
- data/CONTRIBUTING.md +23 -0
- data/Gemfile +2 -1
- data/README.md +23 -6
- data/benchmark/matching.rb +72 -0
- data/benchmark/relay.rb +48 -0
- data/benchmark/send_slow.rb +41 -0
- data/benchmark/support.rb +94 -0
- data/docs/COMPATIBILITY.md +1 -1
- data/docs/INTERNAL_CONTRACTS.md +65 -0
- data/docs/PERFORMANCE.md +60 -0
- data/docs/RELEASING.md +8 -6
- data/docs/VERIFICATION.md +24 -0
- data/examples/kibitz/kibitz.rb +2 -0
- data/expect-pty.gemspec +2 -1
- data/lib/expect/interaction.rb +13 -6
- data/lib/expect/logging.rb +96 -0
- data/lib/expect/matcher.rb +26 -5
- data/lib/expect/pattern.rb +9 -5
- data/lib/expect/relay.rb +3 -1
- data/lib/expect/relay_writer.rb +2 -0
- data/lib/expect/session_resources.rb +29 -11
- data/lib/expect/terminal.rb +35 -0
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +85 -145
- data/script/ci +5 -0
- data/script/release.rb +37 -5
- data/test/cleanup_test.rb +251 -0
- data/test/configuration_test.rb +43 -0
- data/test/io_test.rb +35 -0
- data/test/pattern_offset_test.rb +43 -0
- data/test/process_test.rb +46 -0
- data/test/relay_recovery_test.rb +37 -0
- data/test/release_test.rb +94 -16
- data/test/scan_reuse_test.rb +109 -0
- data/test/timeout_test.rb +119 -0
- metadata +15 -3
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class CleanupTest < ExpectTest
|
|
6
|
+
def test_failed_open_initialization_attempts_all_owned_handles_and_preserves_validation_error
|
|
7
|
+
reader, writer = IO.pipe
|
|
8
|
+
@ios.push(reader, writer)
|
|
9
|
+
reader.stub(:close, -> { raise IOError, "reader close failed" }) do
|
|
10
|
+
error = assert_raises(ArgumentError) { Expect.open(reader, writer: writer, own: true, unknown: true) }
|
|
11
|
+
assert_match(/unknown/, error.message)
|
|
12
|
+
assert writer.closed?
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_failed_new_initialization_attempts_all_pty_handles_and_preserves_validation_error
|
|
17
|
+
master, slave = PTY.open
|
|
18
|
+
@ios.push(master, slave)
|
|
19
|
+
PTY.stub(:open, [master, slave]) do
|
|
20
|
+
master.stub(:close, -> { raise IOError, "master close failed" }) do
|
|
21
|
+
error = assert_raises(ArgumentError) { Expect.new(unknown: true) }
|
|
22
|
+
assert_match(/unknown/, error.message)
|
|
23
|
+
assert slave.closed?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_failed_open_closes_real_owned_handles_when_an_argument_is_not_io
|
|
29
|
+
[true, false].each do |invalid_reader|
|
|
30
|
+
reader, writer = IO.pipe
|
|
31
|
+
@ios.push(reader, writer)
|
|
32
|
+
invalid = Object.new
|
|
33
|
+
invalid.define_singleton_method(:close) { raise "invalid IO must not be closed" }
|
|
34
|
+
incoming = invalid_reader ? invalid : reader
|
|
35
|
+
outgoing = invalid_reader ? writer : invalid
|
|
36
|
+
assert_raises(ArgumentError) { Expect.open(incoming, writer: outgoing, own: true) }
|
|
37
|
+
assert(invalid_reader ? writer.closed? : reader.closed?)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_open_block_error_is_preserved_when_owned_cleanup_also_fails
|
|
42
|
+
[ArgumentError.new("block failed"), Interrupt.new("interrupted"), SystemExit.new(17)].each do |failure|
|
|
43
|
+
reader, writer = IO.pipe
|
|
44
|
+
@ios.push(reader, writer)
|
|
45
|
+
reader.stub(:close, -> { raise IOError, "reader close failed" }) do
|
|
46
|
+
error = assert_raises(failure.class) do
|
|
47
|
+
Expect.open(reader, writer: writer, own: true) { raise failure }
|
|
48
|
+
end
|
|
49
|
+
assert_same failure, error
|
|
50
|
+
assert writer.closed?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_open_block_break_still_reports_cleanup_failure
|
|
56
|
+
reader, writer = IO.pipe
|
|
57
|
+
@ios.push(reader, writer)
|
|
58
|
+
failure = IOError.new("reader close failed")
|
|
59
|
+
reader.stub(:close, -> { raise failure }) do
|
|
60
|
+
error = assert_raises(IOError) { Expect.open(reader, writer: writer, own: true) { break :done } }
|
|
61
|
+
assert_same failure, error
|
|
62
|
+
assert writer.closed?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def test_spawn_block_error_is_preserved_and_child_reaped_when_cleanup_also_fails
|
|
67
|
+
session = stubborn_child
|
|
68
|
+
failure = ArgumentError.new("block failed")
|
|
69
|
+
Expect.stub(:new, session) do
|
|
70
|
+
session.stub(:spawn, session) do
|
|
71
|
+
session.to_io.stub(:close, -> { raise IOError, "reader close failed" }) do
|
|
72
|
+
error = assert_raises(ArgumentError) { Expect.spawn("already started") { raise failure } }
|
|
73
|
+
assert_same failure, error
|
|
74
|
+
assert_nil session.pid
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_graceful_close_preserves_logging_error_when_handle_cleanup_also_fails
|
|
81
|
+
session = stubborn_child
|
|
82
|
+
failure = ArgumentError.new("log failed")
|
|
83
|
+
session.log_output = ->(_) { raise failure }
|
|
84
|
+
session.to_io.stub(:wait_readable, true) do
|
|
85
|
+
session.to_io.stub(:read_nonblock, "last output") do
|
|
86
|
+
session.to_io.stub(:close, -> { raise IOError, "reader close failed" }) do
|
|
87
|
+
assert_same failure, assert_raises(ArgumentError) { session.close(graceful: true) }
|
|
88
|
+
assert_nil session.pid
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_close_handles_attempts_every_owned_handle_and_preserves_first_error
|
|
95
|
+
reader, writer = IO.pipe
|
|
96
|
+
extra, peer = IO.pipe
|
|
97
|
+
@ios.push(reader, writer, extra, peer)
|
|
98
|
+
resources = Expect::SessionResources.new(reader, writer: writer, slave: extra, own: true)
|
|
99
|
+
failure = IOError.new("reader close failed")
|
|
100
|
+
reader.stub(:close, -> { raise failure }) do
|
|
101
|
+
assert_same failure, assert_raises(IOError) { resources.close_handles }
|
|
102
|
+
assert writer.closed?
|
|
103
|
+
assert extra.closed?
|
|
104
|
+
end
|
|
105
|
+
resources.close_handles
|
|
106
|
+
assert reader.closed?
|
|
107
|
+
resources.close_handles
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_hard_close_finishes_wrappers_log_and_child_after_handle_failure
|
|
111
|
+
session = stubborn_child
|
|
112
|
+
pid = session.pid
|
|
113
|
+
first, = pipe_session
|
|
114
|
+
second, = pipe_session
|
|
115
|
+
session.instance_variable_set(:@interact_inputs, { first.to_io => first, second.to_io => second })
|
|
116
|
+
Dir.mktmpdir do |dir|
|
|
117
|
+
log = session.log_to(File.join(dir, "owned.log"))
|
|
118
|
+
failure = IOError.new("handle close failed")
|
|
119
|
+
session.to_io.stub(:close, -> { raise failure }) do
|
|
120
|
+
first.stub(:close, ->(**) { raise IOError, "wrapper close failed" }) do
|
|
121
|
+
error = assert_raises(IOError) { bounded { session.hard_close(timeout: 0.01) } }
|
|
122
|
+
assert_same failure, error
|
|
123
|
+
assert second.closed?
|
|
124
|
+
refute second.to_io.closed?, "borrowed wrapper IO must remain open"
|
|
125
|
+
assert log.closed?
|
|
126
|
+
assert_nil session.log_output
|
|
127
|
+
assert_nil session.pid
|
|
128
|
+
assert_equal Signal.list.fetch("KILL"), session.process_status.termsig
|
|
129
|
+
assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
ensure
|
|
134
|
+
session&.hard_close(timeout: 0)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def test_owned_log_close_failure_is_visible_after_child_reaping_and_retryable
|
|
138
|
+
session = stubborn_child
|
|
139
|
+
Dir.mktmpdir do |dir|
|
|
140
|
+
log = session.log_to(File.join(dir, "owned.log"))
|
|
141
|
+
log.stub(:close, -> { raise IOError, "log close failed" }) do
|
|
142
|
+
assert_raises(IOError) { bounded { session.hard_close(timeout: 0.01) } }
|
|
143
|
+
assert_nil session.pid
|
|
144
|
+
assert_same log, session.log_output
|
|
145
|
+
end
|
|
146
|
+
status = session.hard_close(timeout: 0)
|
|
147
|
+
assert_same session.process_status, status
|
|
148
|
+
assert log.closed?
|
|
149
|
+
assert_nil session.log_output
|
|
150
|
+
end
|
|
151
|
+
ensure
|
|
152
|
+
session&.hard_close(timeout: 0)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def test_explicit_close_inside_rescue_does_not_hide_a_new_cleanup_error
|
|
156
|
+
session, = pipe_session(own: true)
|
|
157
|
+
failure = IOError.new("close failed inside caller rescue")
|
|
158
|
+
begin
|
|
159
|
+
raise ArgumentError, "already handled"
|
|
160
|
+
rescue ArgumentError
|
|
161
|
+
session.to_io.stub(:close, -> { raise failure }) do
|
|
162
|
+
assert_same failure, assert_raises(IOError) { session.hard_close(timeout: 0) }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def test_unexpected_process_error_is_not_replaced_by_cleanup_failure
|
|
168
|
+
session = stubborn_child
|
|
169
|
+
failure = RuntimeError.new("process wait failed")
|
|
170
|
+
session.to_io.stub(:close, -> { raise IOError, "close failed" }) do
|
|
171
|
+
session.stub(:wait, ->(**) { raise failure }) do
|
|
172
|
+
assert_same failure, assert_raises(RuntimeError) { session.hard_close(timeout: 0) }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
ensure
|
|
176
|
+
session&.hard_close(timeout: 0)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def test_soft_close_handle_failure_still_sends_term_but_never_kill
|
|
180
|
+
session = stubborn_child
|
|
181
|
+
signals = []
|
|
182
|
+
original = Process.method(:kill)
|
|
183
|
+
Process.stub(:kill, lambda { |signal, pid|
|
|
184
|
+
signals << signal
|
|
185
|
+
original.call(signal, pid)
|
|
186
|
+
}) do
|
|
187
|
+
session.to_io.stub(:close, -> { raise Errno::EIO, "close failed" }) do
|
|
188
|
+
assert_raises(Errno::EIO) { bounded { session.soft_close(timeout: 0, term_timeout: 0) } }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
assert_equal ["TERM"], signals
|
|
192
|
+
assert session.alive?
|
|
193
|
+
assert session.closed?
|
|
194
|
+
ensure
|
|
195
|
+
session&.hard_close(timeout: 0)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def test_finalizer_reaps_after_both_handle_and_log_close_fail
|
|
199
|
+
session = stubborn_child
|
|
200
|
+
pid = session.pid
|
|
201
|
+
resources = session.instance_variable_get(:@resources)
|
|
202
|
+
log_attempted = false
|
|
203
|
+
Dir.mktmpdir do |dir|
|
|
204
|
+
log = session.log_to(File.join(dir, "owned.log"))
|
|
205
|
+
session.to_io.stub(:close, -> { raise IOError, "handle failed" }) do
|
|
206
|
+
log.stub(:close, lambda {
|
|
207
|
+
log_attempted = true
|
|
208
|
+
raise Errno::EIO, "log failed"
|
|
209
|
+
}) do
|
|
210
|
+
bounded { resources.finalize }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
assert log_attempted
|
|
214
|
+
assert_nil resources.pid
|
|
215
|
+
bounded do
|
|
216
|
+
loop do
|
|
217
|
+
Process.kill(0, pid)
|
|
218
|
+
sleep 0.005
|
|
219
|
+
rescue Errno::ESRCH
|
|
220
|
+
break
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
ensure
|
|
225
|
+
session&.hard_close(timeout: 0)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def test_non_owner_cleanup_never_signals_and_finalizer_leaves_handles_alone
|
|
229
|
+
session = stubborn_child
|
|
230
|
+
resources = session.instance_variable_get(:@resources)
|
|
231
|
+
Process.stub(:pid, -1) do
|
|
232
|
+
Process.stub(:kill, ->(*) { flunk "non-owner sent a signal" }) do
|
|
233
|
+
resources.finalize
|
|
234
|
+
refute session.to_io.closed?
|
|
235
|
+
assert_nil session.hard_close(timeout: 0)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
assert session.alive?
|
|
239
|
+
ensure
|
|
240
|
+
session&.hard_close(timeout: 0)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private
|
|
244
|
+
|
|
245
|
+
def stubborn_child
|
|
246
|
+
session = child('Signal.trap("HUP", "IGNORE"); Signal.trap("TERM", "IGNORE"); puts "ready"; sleep 60',
|
|
247
|
+
raw_pty: true)
|
|
248
|
+
assert_equal 1, session.expect("ready", timeout: 2)
|
|
249
|
+
session
|
|
250
|
+
end
|
|
251
|
+
end
|
data/test/configuration_test.rb
CHANGED
|
@@ -56,6 +56,49 @@ class ConfigurationTest < ExpectTest
|
|
|
56
56
|
assert_equal 2, Expect.configuration.timeout
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
def test_concurrent_configure_updates_are_serialized
|
|
60
|
+
subclass = Class.new(Expect)
|
|
61
|
+
entered = Queue.new
|
|
62
|
+
second_started = Queue.new
|
|
63
|
+
release = Queue.new
|
|
64
|
+
first = Thread.new do
|
|
65
|
+
subclass.configure do |config|
|
|
66
|
+
entered << true
|
|
67
|
+
release.pop
|
|
68
|
+
config.timeout = 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
entered.pop
|
|
72
|
+
second = Thread.new do
|
|
73
|
+
second_started << true
|
|
74
|
+
subclass.configure(debug_level: 2)
|
|
75
|
+
end
|
|
76
|
+
second_started.pop
|
|
77
|
+
# 第一轮尚未发布时,第二轮不能从同一旧快照完成更新。
|
|
78
|
+
refute second.join(0.05), "concurrent configure published before the first update finished"
|
|
79
|
+
release << true
|
|
80
|
+
first.value
|
|
81
|
+
second.value
|
|
82
|
+
assert_equal 1, subclass.configuration.timeout
|
|
83
|
+
assert_equal 2, subclass.configuration.debug_level
|
|
84
|
+
ensure
|
|
85
|
+
release&.push(true)
|
|
86
|
+
first&.join
|
|
87
|
+
second&.join
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_nested_configure_fails_without_publishing_partial_changes
|
|
91
|
+
subclass = Class.new(Expect)
|
|
92
|
+
previous = subclass.configuration
|
|
93
|
+
assert_raises(ThreadError) do
|
|
94
|
+
subclass.configure do |config|
|
|
95
|
+
config.timeout = 1
|
|
96
|
+
subclass.configure(debug_level: 2)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
assert_same previous, subclass.configuration
|
|
100
|
+
end
|
|
101
|
+
|
|
59
102
|
def test_attribute_validation_preserves_the_current_value_and_buffer
|
|
60
103
|
session, = pipe_session(buffer_limit: 8, timeout: 1, write_timeout: 2, debug_level: 1)
|
|
61
104
|
session.buffer = "contents"
|
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,43 @@
|
|
|
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
|
+
# 扫描只读调用者输入;省去正则副本后也不能改变编码标记或冻结字符串。
|
|
20
|
+
bytes = text.b.freeze
|
|
21
|
+
assert_equal [offset, length, captures], Expect::Pattern.new(value: regexp).locate(bytes)
|
|
22
|
+
assert_equal Encoding::BINARY, bytes.encoding
|
|
23
|
+
session.buffer = bytes
|
|
24
|
+
result = session.expect_result(regexp, timeout: 0)
|
|
25
|
+
assert_equal bytes.byteslice(0, offset), result.before
|
|
26
|
+
assert_equal bytes.byteslice(offset, length), result.match
|
|
27
|
+
assert_equal bytes.byteslice((offset + length)..), result.after
|
|
28
|
+
assert_equal captures, result.captures
|
|
29
|
+
assert_equal result.after, session.buffer
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_incomplete_utf8_defers_matching_and_eof_still_rejects_it
|
|
34
|
+
pattern = Expect::Pattern.new(value: /中(?=😀)/)
|
|
35
|
+
bytes = "前中😀".b
|
|
36
|
+
(1..3).each do |missing|
|
|
37
|
+
partial = bytes.byteslice(0, bytes.bytesize - missing)
|
|
38
|
+
assert_nil pattern.locate(partial)
|
|
39
|
+
assert_raises(EncodingError) { pattern.locate(partial, final: true) }
|
|
40
|
+
end
|
|
41
|
+
assert_equal [3, 3, []], pattern.locate(bytes)
|
|
42
|
+
end
|
|
43
|
+
end
|
data/test/process_test.rb
CHANGED
|
@@ -77,6 +77,14 @@ class ProcessTest < ExpectTest
|
|
|
77
77
|
assert_raises(Expect::SpawnError) { session.spawn("cat") }
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
def test_spawn_cleans_child_when_post_exec_diagnostics_fail
|
|
81
|
+
assert_failed_construction_cleans_child(:spawn)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_new_cleans_child_when_post_exec_diagnostics_fail
|
|
85
|
+
assert_failed_construction_cleans_child(:new)
|
|
86
|
+
end
|
|
87
|
+
|
|
80
88
|
def test_block_closes_child_on_exception
|
|
81
89
|
pid = nil
|
|
82
90
|
assert_raises(RuntimeError) do
|
|
@@ -228,6 +236,17 @@ class ProcessTest < ExpectTest
|
|
|
228
236
|
assert_equal configuration, terminal_configuration(session)
|
|
229
237
|
end
|
|
230
238
|
|
|
239
|
+
def test_stty_reports_a_missing_system_command_and_preserves_the_cause
|
|
240
|
+
previous_path = ENV.fetch("PATH", nil)
|
|
241
|
+
session = child("sleep 60")
|
|
242
|
+
ENV["PATH"] = ""
|
|
243
|
+
error = assert_raises(IOError) { session.stty }
|
|
244
|
+
assert_match "stty executable not found", error.message
|
|
245
|
+
assert_instance_of Errno::ENOENT, error.cause
|
|
246
|
+
ensure
|
|
247
|
+
ENV["PATH"] = previous_path
|
|
248
|
+
end
|
|
249
|
+
|
|
231
250
|
def test_configuration_and_session_attributes
|
|
232
251
|
Expect.configure(timeout: 0.01, log_stdout: false)
|
|
233
252
|
session = Expect.new
|
|
@@ -242,4 +261,31 @@ class ProcessTest < ExpectTest
|
|
|
242
261
|
assert_equal 30, session.buffer_limit
|
|
243
262
|
assert_match(/\A#<Expect .*closed=false>\z/, session.inspect)
|
|
244
263
|
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
def assert_failed_construction_cleans_child(factory)
|
|
268
|
+
connection = nil
|
|
269
|
+
session_class = Class.new(Expect)
|
|
270
|
+
session_class.define_method(:initialize) do |*command, **options|
|
|
271
|
+
connection = self
|
|
272
|
+
super(*command, **options)
|
|
273
|
+
end
|
|
274
|
+
reader, diagnostic_output = IO.pipe
|
|
275
|
+
@ios.push(reader, diagnostic_output)
|
|
276
|
+
reader.close
|
|
277
|
+
previous_stderr = $stderr
|
|
278
|
+
begin
|
|
279
|
+
$stderr = diagnostic_output
|
|
280
|
+
assert_raises(Errno::EPIPE) do
|
|
281
|
+
session_class.public_send(factory, RbConfig.ruby, "--disable-gems", "-e", "sleep 60", debug_level: 1)
|
|
282
|
+
end
|
|
283
|
+
ensure
|
|
284
|
+
$stderr = previous_stderr
|
|
285
|
+
@sessions << connection if connection
|
|
286
|
+
end
|
|
287
|
+
assert connection.closed?
|
|
288
|
+
assert_nil connection.pid
|
|
289
|
+
assert_instance_of Process::Status, connection.process_status
|
|
290
|
+
end
|
|
245
291
|
end
|
data/test/relay_recovery_test.rb
CHANGED
|
@@ -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
|
@@ -36,11 +36,11 @@ class ReleaseTest < Minitest::Test
|
|
|
36
36
|
|
|
37
37
|
def test_rubygems_only_publishes_the_verified_copy_without_github
|
|
38
38
|
with_package do |_release, artifact|
|
|
39
|
+
commit_package_source
|
|
39
40
|
release = Release.new(artifact: artifact, rubygems_only: true)
|
|
40
41
|
bytes = File.binread(artifact)
|
|
41
42
|
checksum = Digest::SHA256.hexdigest(bytes)
|
|
42
43
|
published = false
|
|
43
|
-
capture = ->(*arguments) { arguments.include?("rev-parse") ? "verified" : "" }
|
|
44
44
|
get = lambda do |path|
|
|
45
45
|
if path.start_with?("/downloads/")
|
|
46
46
|
Struct.new(:code, :body).new("200", bytes)
|
|
@@ -58,19 +58,17 @@ class ReleaseTest < Minitest::Test
|
|
|
58
58
|
refute_equal File.expand_path(artifact), candidate
|
|
59
59
|
published = true
|
|
60
60
|
end
|
|
61
|
-
release.stub(:
|
|
62
|
-
release.stub(:
|
|
63
|
-
release.stub(:
|
|
64
|
-
release.stub(:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
assert published
|
|
73
|
-
end
|
|
61
|
+
release.stub(:get, get) do
|
|
62
|
+
release.stub(:system, push) do
|
|
63
|
+
release.stub(:github, ->(*) { flunk "RubyGems-only release contacted GitHub" }) do
|
|
64
|
+
release.stub(:publish_github, -> { flunk "RubyGems-only release published to GitHub" }) do
|
|
65
|
+
[nil, "true"].each do |github_actions|
|
|
66
|
+
with_environment("GITHUB_ACTIONS" => github_actions,
|
|
67
|
+
"GEM_HOST_API_KEY" => (github_actions ? "test-only-api-key" : nil)) do
|
|
68
|
+
published = false
|
|
69
|
+
output, = capture_io { release.run }
|
|
70
|
+
assert_includes output, "SHA256 verified"
|
|
71
|
+
assert published
|
|
74
72
|
end
|
|
75
73
|
end
|
|
76
74
|
end
|
|
@@ -107,9 +105,11 @@ class ReleaseTest < Minitest::Test
|
|
|
107
105
|
|
|
108
106
|
def test_rubygems_only_rechecks_source_after_verification
|
|
109
107
|
with_package do |_release, artifact|
|
|
108
|
+
commit_package_source
|
|
110
109
|
release = Release.new(artifact: artifact, rubygems_only: true)
|
|
111
110
|
statuses = ["", " M payload.rb"]
|
|
112
|
-
|
|
111
|
+
original = release.method(:capture)
|
|
112
|
+
capture = ->(*arguments) { arguments.include?("rev-parse") ? original.call(*arguments) : statuses.shift }
|
|
113
113
|
release.stub(:capture, capture) do
|
|
114
114
|
release.stub(:get, ->(*) { flunk "changed source contacted RubyGems" }) do
|
|
115
115
|
capture_io do
|
|
@@ -127,6 +127,53 @@ class ReleaseTest < Minitest::Test
|
|
|
127
127
|
end
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
def test_rejects_ignored_source_collected_by_the_package_glob
|
|
131
|
+
with_package do |_release, artifact|
|
|
132
|
+
commit_package_source
|
|
133
|
+
FileUtils.mkdir_p("lib")
|
|
134
|
+
File.write("lib/local_only.rb", "puts :local_only\n")
|
|
135
|
+
File.write(".git/info/exclude", "lib/local_only.rb\n", mode: "a")
|
|
136
|
+
rebuild_package
|
|
137
|
+
assert_empty git("status", "--porcelain")
|
|
138
|
+
|
|
139
|
+
error = publishing_source_error(artifact)
|
|
140
|
+
assert_match "not in release commit: lib/local_only.rb", error.message
|
|
141
|
+
# 提交前的 dry-run 仍可检验候选包,但不能以此证明发布来源。
|
|
142
|
+
capture_io { Release.new(artifact: artifact, dry_run: true).run }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def test_rejects_source_changes_hidden_from_git_status
|
|
147
|
+
with_package do |_release, artifact|
|
|
148
|
+
commit_package_source
|
|
149
|
+
git("update-index", "--assume-unchanged", "payload.rb")
|
|
150
|
+
File.binwrite("payload.rb", "puts :changed\n\n")
|
|
151
|
+
rebuild_package
|
|
152
|
+
assert_empty git("status", "--porcelain")
|
|
153
|
+
assert_match "differs from release commit: payload.rb", publishing_source_error(artifact).message
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def test_rejects_executable_mode_changes_hidden_from_git_status
|
|
158
|
+
with_package do |_release, artifact|
|
|
159
|
+
commit_package_source
|
|
160
|
+
git("config", "core.filemode", "false")
|
|
161
|
+
File.chmod(0o755, "payload.rb")
|
|
162
|
+
rebuild_package
|
|
163
|
+
assert_empty git("status", "--porcelain")
|
|
164
|
+
assert_match "permissions differ from release commit: payload.rb", publishing_source_error(artifact).message
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def test_rejects_altered_homepage_metadata
|
|
169
|
+
with_package do |release, _artifact|
|
|
170
|
+
spec = Gem::Specification.load(File.expand_path("expect-pty.gemspec")).dup
|
|
171
|
+
spec.homepage = "https://example.invalid/altered"
|
|
172
|
+
capture_io { Gem::Package.build(spec) }
|
|
173
|
+
assert_match "metadata", assert_raises(RuntimeError) { release.send(:verify_package) }.message
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
130
177
|
def test_rejects_different_installation_metadata_even_with_identical_source_files
|
|
131
178
|
with_package do |release, _artifact|
|
|
132
179
|
expected = Gem::Specification.load(File.expand_path("expect-pty.gemspec")).dup
|
|
@@ -214,6 +261,37 @@ class ReleaseTest < Minitest::Test
|
|
|
214
261
|
|
|
215
262
|
private
|
|
216
263
|
|
|
264
|
+
def git(*)
|
|
265
|
+
output, error, status = Open3.capture3("git", *)
|
|
266
|
+
assert status.success?, error
|
|
267
|
+
output
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def commit_package_source
|
|
271
|
+
git("init", "--quiet")
|
|
272
|
+
git("config", "core.hooksPath", File::NULL)
|
|
273
|
+
File.write(".git/info/exclude", "*.gem\ntmp/\n")
|
|
274
|
+
git("add", "--", "payload.rb", "CHANGELOG.md", "expect-pty.gemspec")
|
|
275
|
+
git("-c", "user.name=Release Test", "-c", "user.email=release-test@example.invalid",
|
|
276
|
+
"-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "Release fixture")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def rebuild_package
|
|
280
|
+
# Gem::Specification.load 会缓存已加载的 gemspec;重新执行才能看到新的 glob 文件。
|
|
281
|
+
Gem::Specification.reset
|
|
282
|
+
spec = Gem::Specification.load(File.expand_path("expect-pty.gemspec"))
|
|
283
|
+
capture_io { Gem::Package.build(spec) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def publishing_source_error(artifact)
|
|
287
|
+
release = Release.new(artifact: artifact, rubygems_only: true)
|
|
288
|
+
release.stub(:get, ->(*) { flunk "uncommitted package content reached RubyGems" }) do
|
|
289
|
+
error = nil
|
|
290
|
+
capture_io { error = assert_raises(RuntimeError) { release.run } }
|
|
291
|
+
error
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
217
295
|
# 发布测试显式控制凭据环境,避免本机登录状态或 CI 标记影响用例结果。
|
|
218
296
|
def with_environment(values)
|
|
219
297
|
previous = values.to_h { |name, _value| [name, ENV.fetch(name, nil)] }
|
|
@@ -238,7 +316,7 @@ class ReleaseTest < Minitest::Test
|
|
|
238
316
|
spec.authors = ["Test"]
|
|
239
317
|
spec.license = "MIT"
|
|
240
318
|
spec.homepage = "https://github.com/gatework/expect-ruby"
|
|
241
|
-
spec.files = ["payload.rb", "CHANGELOG.md", "expect-pty.gemspec"]
|
|
319
|
+
spec.files = ["payload.rb", "CHANGELOG.md", "expect-pty.gemspec"] + Dir["lib/**/*.rb"]
|
|
242
320
|
end
|
|
243
321
|
RUBY
|
|
244
322
|
artifact = nil
|