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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +22 -9
- data/docs/COMPATIBILITY.md +23 -1
- data/docs/RELEASING.md +21 -8
- data/docs/VERIFICATION.md +65 -1
- data/expect-pty.gemspec +1 -1
- data/lib/expect/configuration.rb +7 -0
- data/lib/expect/interaction.rb +249 -0
- data/lib/expect/{engine.rb → matcher.rb} +40 -5
- data/lib/expect/pattern.rb +12 -20
- data/lib/expect/relay.rb +133 -0
- data/lib/expect/relay_writer.rb +66 -0
- data/lib/expect/result.rb +2 -0
- data/lib/expect/{resources.rb → session_resources.rb} +3 -3
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +105 -30
- data/script/ci +3 -4
- data/script/release.rb +22 -10
- data/test/edge_case_test.rb +101 -22
- data/test/interact_test.rb +102 -0
- data/test/interconnect_test.rb +203 -4
- data/test/io_test.rb +102 -0
- data/test/matching_test.rb +33 -0
- data/test/relay_recovery_test.rb +414 -0
- data/test/release_test.rb +67 -1
- data/test/support/script_probe.rb +1 -0
- metadata +9 -6
- data/lib/expect/interconnect.rb +0 -170
data/test/io_test.rb
CHANGED
|
@@ -49,6 +49,26 @@ class IOTest < ExpectTest
|
|
|
49
49
|
assert_equal "\ncba\n", chunks.join
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
def test_new_log_files_are_private_without_changing_existing_permissions
|
|
53
|
+
session, = pipe_session
|
|
54
|
+
Dir.mktmpdir do |directory|
|
|
55
|
+
previous_umask = File.umask(0o022)
|
|
56
|
+
begin
|
|
57
|
+
%w[a w].each do |mode|
|
|
58
|
+
path = File.join(directory, "session-#{mode}.log")
|
|
59
|
+
session.log_to(path, mode: mode)
|
|
60
|
+
assert_equal 0o600, File.stat(path).mode & 0o777
|
|
61
|
+
session.log_output = nil
|
|
62
|
+
File.chmod(0o640, path)
|
|
63
|
+
session.log_to(path, mode: mode)
|
|
64
|
+
assert_equal 0o640, File.stat(path).mode & 0o777
|
|
65
|
+
end
|
|
66
|
+
ensure
|
|
67
|
+
File.umask(previous_umask)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
52
72
|
def test_group_and_stdout_logging_can_be_controlled_separately
|
|
53
73
|
session, writer = pipe_session
|
|
54
74
|
listener = StringIO.new
|
|
@@ -85,6 +105,25 @@ class IOTest < ExpectTest
|
|
|
85
105
|
assert_match(/received "more"/, diagnostics)
|
|
86
106
|
end
|
|
87
107
|
|
|
108
|
+
def test_quiet_mode_does_not_format_byte_content_for_diagnostics
|
|
109
|
+
session, peer = Socket.pair(:UNIX, :STREAM, 0)
|
|
110
|
+
@ios.push(session, peer)
|
|
111
|
+
connection = Expect.open(session, debug_level: 0)
|
|
112
|
+
@sessions << connection
|
|
113
|
+
inspected = 0
|
|
114
|
+
probe = TracePoint.new(:c_call) do |event|
|
|
115
|
+
inspected += 1 if event.defined_class == String && event.method_id == :inspect
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
probe.enable do
|
|
119
|
+
connection.write("hello")
|
|
120
|
+
peer.write("reply")
|
|
121
|
+
connection.expect("reply", timeout: 1)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
assert_equal 0, inspected
|
|
125
|
+
end
|
|
126
|
+
|
|
88
127
|
def test_send_slow_collects_replies_even_when_logging_disabled
|
|
89
128
|
session = child("loop { char = STDIN.read(1); break unless char; print char.upcase }", raw_pty: true)
|
|
90
129
|
session.log_listeners = false
|
|
@@ -121,6 +160,69 @@ class IOTest < ExpectTest
|
|
|
121
160
|
assert_empty Expect.readable_sessions(first, second)
|
|
122
161
|
end
|
|
123
162
|
|
|
163
|
+
def test_readiness_retries_an_interrupted_select
|
|
164
|
+
session, writer = pipe_session
|
|
165
|
+
writer.write("ready")
|
|
166
|
+
original = IO.method(:select)
|
|
167
|
+
interrupted = true
|
|
168
|
+
select = lambda do |*arguments|
|
|
169
|
+
if interrupted
|
|
170
|
+
interrupted = false
|
|
171
|
+
raise Errno::EINTR
|
|
172
|
+
end
|
|
173
|
+
original.call(*arguments)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
IO.stub(:select, select) do
|
|
177
|
+
assert_equal [session], Expect.readable_sessions(session, timeout: 1)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def test_write_retries_an_interrupted_nonblocking_write
|
|
182
|
+
reader, writer = Socket.pair(:UNIX, :STREAM, 0)
|
|
183
|
+
@ios.push(reader, writer)
|
|
184
|
+
session = Expect.open(reader)
|
|
185
|
+
@sessions << session
|
|
186
|
+
original = reader.method(:write_nonblock)
|
|
187
|
+
interrupted = true
|
|
188
|
+
write = lambda do |*arguments, **options|
|
|
189
|
+
if interrupted
|
|
190
|
+
interrupted = false
|
|
191
|
+
raise Errno::EINTR
|
|
192
|
+
end
|
|
193
|
+
original.call(*arguments, **options)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
reader.stub(:write_nonblock, write) do
|
|
197
|
+
assert_equal 5, session.write("hello")
|
|
198
|
+
end
|
|
199
|
+
assert_equal "hello", writer.read(5)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def test_write_retries_an_interrupted_writable_wait
|
|
203
|
+
reader, writer = Socket.pair(:UNIX, :STREAM, 0)
|
|
204
|
+
@ios.push(reader, writer)
|
|
205
|
+
session = Expect.open(reader, write_timeout: 1)
|
|
206
|
+
@sessions << session
|
|
207
|
+
original = reader.method(:write_nonblock)
|
|
208
|
+
waiting = true
|
|
209
|
+
write = lambda do |*arguments, **options|
|
|
210
|
+
if waiting
|
|
211
|
+
waiting = false
|
|
212
|
+
:wait_writable
|
|
213
|
+
else
|
|
214
|
+
original.call(*arguments, **options)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
reader.stub(:write_nonblock, write) do
|
|
219
|
+
IO.stub(:select, ->(*) { raise Errno::EINTR }) do
|
|
220
|
+
assert_equal 5, session.write("hello")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
assert_equal "hello", writer.read(5)
|
|
224
|
+
end
|
|
225
|
+
|
|
124
226
|
def test_readiness_waits_for_data_and_does_not_consume_data
|
|
125
227
|
first, = pipe_session
|
|
126
228
|
second, writer = pipe_session
|
data/test/matching_test.rb
CHANGED
|
@@ -126,6 +126,19 @@ class MatchingTest < ExpectTest
|
|
|
126
126
|
assert_equal 1, session.expect("结束", timeout: 1)
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
+
def test_utf8_regexp_waits_for_incomplete_trailing_character_before_matching
|
|
130
|
+
session, writer = pipe_session
|
|
131
|
+
session.buffer = "ready\xE4".b
|
|
132
|
+
|
|
133
|
+
assert_nil session.expect(/ready\z/u, timeout: 0)
|
|
134
|
+
assert_nil session.expect(/ready/u, timeout: 0)
|
|
135
|
+
assert_equal "ready\xE4".b, session.buffer
|
|
136
|
+
|
|
137
|
+
writer.write("\xB8\xAD".b)
|
|
138
|
+
assert_equal 1, session.expect(/ready中\z/u, timeout: 1)
|
|
139
|
+
assert_equal "ready中".b, session.match
|
|
140
|
+
end
|
|
141
|
+
|
|
129
142
|
def test_binary_nul_and_invalid_utf8
|
|
130
143
|
session, writer = pipe_session
|
|
131
144
|
writer.write("\xff\x00abc\xfe".b)
|
|
@@ -134,6 +147,26 @@ class MatchingTest < ExpectTest
|
|
|
134
147
|
assert_equal ["abc"], session.captures
|
|
135
148
|
end
|
|
136
149
|
|
|
150
|
+
def test_utf8_regexp_rejects_impossible_partial_characters
|
|
151
|
+
session, = pipe_session
|
|
152
|
+
["\xE0\x80", "\xED\xA0", "\xF0\x80", "\xF4\x90"].each do |bytes|
|
|
153
|
+
session.buffer = bytes.b
|
|
154
|
+
assert_raises(EncodingError) { session.expect(/ready/u, timeout: 0) }
|
|
155
|
+
assert_equal bytes.b, session.buffer
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def test_utf8_regexp_accepts_valid_partial_characters
|
|
160
|
+
session, = pipe_session
|
|
161
|
+
["¢", "中", "😀", "\u{10FFFF}"].each do |character|
|
|
162
|
+
1.upto(character.bytesize - 1) do |length|
|
|
163
|
+
session.buffer = character.b.byteslice(0, length)
|
|
164
|
+
assert_nil session.expect(/./u, timeout: 0)
|
|
165
|
+
assert_equal character.b.byteslice(0, length), session.buffer
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
137
170
|
def test_native_regexp_anchors_and_flags
|
|
138
171
|
session, = pipe_session
|
|
139
172
|
session.buffer = "a\nb\nc"
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class RelayRecoveryTest < ExpectTest
|
|
6
|
+
def sink_session(**)
|
|
7
|
+
reader, peer = IO.pipe
|
|
8
|
+
sink, writer = IO.pipe
|
|
9
|
+
@ios.push(reader, peer, sink, writer)
|
|
10
|
+
session = Expect.open(reader, writer: writer, **)
|
|
11
|
+
@sessions << session
|
|
12
|
+
[session, sink, peer]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fill(writer)
|
|
16
|
+
total = 0
|
|
17
|
+
loop do
|
|
18
|
+
count = writer.write_nonblock("x" * 4096, exception: false)
|
|
19
|
+
return total if count == :wait_writable
|
|
20
|
+
|
|
21
|
+
total += count
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def drain(reader)
|
|
26
|
+
data = "".b
|
|
27
|
+
loop do
|
|
28
|
+
chunk = reader.read_nonblock(65_536, exception: false)
|
|
29
|
+
return data if chunk == :wait_readable || chunk.nil?
|
|
30
|
+
|
|
31
|
+
data << chunk
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_outer_deadline_bounds_raw_io_and_expect_listeners
|
|
36
|
+
[false, true].each do |wrapped|
|
|
37
|
+
source, = pipe_session
|
|
38
|
+
target, = sink_session
|
|
39
|
+
fill(target.writer)
|
|
40
|
+
source.listeners = [wrapped ? target : target.writer]
|
|
41
|
+
source.buffer = "request"
|
|
42
|
+
started = Expect.monotonic
|
|
43
|
+
assert_nil bounded(0.5) { Expect.interconnect(source, target, timeout: 0.02) }
|
|
44
|
+
assert_operator Expect.monotonic - started, :<, 0.3
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_partial_timeout_and_retry_deliver_exactly_once_to_each_listener
|
|
49
|
+
source, source_writer = pipe_session
|
|
50
|
+
target, sink = sink_session(write_timeout: 0.02)
|
|
51
|
+
first = StringIO.new
|
|
52
|
+
source.listeners = [first, target]
|
|
53
|
+
payload = (0..255).to_a.pack("C*") * 2048
|
|
54
|
+
source.buffer = payload
|
|
55
|
+
assert_raises(Expect::WriteTimeout) { bounded { Expect.interconnect(source, timeout: 1) } }
|
|
56
|
+
received = drain(sink)
|
|
57
|
+
assert_operator received.bytesize, :>, 0
|
|
58
|
+
assert_operator received.bytesize, :<, payload.bytesize
|
|
59
|
+
target.write_timeout = 2
|
|
60
|
+
receiver = background do
|
|
61
|
+
loop { received << sink.readpartial(65_536) }
|
|
62
|
+
rescue EOFError
|
|
63
|
+
received
|
|
64
|
+
end
|
|
65
|
+
source_writer.close
|
|
66
|
+
assert_same(source, bounded { Expect.interconnect(source, timeout: 2) })
|
|
67
|
+
target.writer.close
|
|
68
|
+
assert receiver.join(2), "receiver did not finish"
|
|
69
|
+
assert_equal payload.bytesize, receiver.value.bytesize
|
|
70
|
+
assert payload == receiver.value, "resumed pipe output differs from the original bytes"
|
|
71
|
+
assert_equal payload.bytesize, first.string.bytesize
|
|
72
|
+
assert payload == first.string.b, "first listener output differs from the original bytes"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_short_custom_writes_preserve_suffix
|
|
76
|
+
source, = pipe_session
|
|
77
|
+
received = "".b
|
|
78
|
+
target = Object.new
|
|
79
|
+
target.define_singleton_method(:write) do |data|
|
|
80
|
+
count = [2, data.bytesize].min
|
|
81
|
+
received << data.byteslice(0, count)
|
|
82
|
+
count
|
|
83
|
+
end
|
|
84
|
+
source.listeners = [target]
|
|
85
|
+
source.buffer = "abcdef"
|
|
86
|
+
Expect.interconnect(source, timeout: 0.02)
|
|
87
|
+
assert_equal "abcdef", received
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_failed_second_listener_does_not_replay_first_listener
|
|
91
|
+
source, = pipe_session
|
|
92
|
+
first = StringIO.new
|
|
93
|
+
second = StringIO.new
|
|
94
|
+
failing = true
|
|
95
|
+
original = second.method(:write)
|
|
96
|
+
second.define_singleton_method(:write) do |data|
|
|
97
|
+
raise IOError, "listener failed" if failing
|
|
98
|
+
|
|
99
|
+
original.call(data)
|
|
100
|
+
end
|
|
101
|
+
source.listeners = [first, second]
|
|
102
|
+
source.buffer = "payload"
|
|
103
|
+
assert_raises(IOError) { Expect.interconnect(source, timeout: 0.02) }
|
|
104
|
+
failing = false
|
|
105
|
+
Expect.interconnect(source, timeout: 0.02)
|
|
106
|
+
assert_equal "payload", first.string
|
|
107
|
+
assert_equal "payload", second.string
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_repeated_interact_with_raw_input_keeps_read_ahead
|
|
111
|
+
session, sink = sink_session
|
|
112
|
+
input, keyboard = IO.pipe
|
|
113
|
+
@ios.push(input, keyboard)
|
|
114
|
+
keyboard.write("one!two!")
|
|
115
|
+
first = session.interact(input: input, output: StringIO.new, escape: "!", timeout: 0.05)
|
|
116
|
+
second = session.interact(input: input, output: StringIO.new, escape: "!", timeout: 0.05)
|
|
117
|
+
assert_same first, second
|
|
118
|
+
assert_equal "onetwo", drain(sink)
|
|
119
|
+
refute input.closed?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_write_retries_read_eintr_during_backpressure
|
|
123
|
+
session, sink, peer = sink_session(write_timeout: 1)
|
|
124
|
+
fill(session.writer)
|
|
125
|
+
peer.write("reply")
|
|
126
|
+
original = session.to_io.method(:read_nonblock)
|
|
127
|
+
interrupted = false
|
|
128
|
+
session.to_io.define_singleton_method(:read_nonblock) do |*args, **kwargs|
|
|
129
|
+
unless interrupted
|
|
130
|
+
interrupted = true
|
|
131
|
+
raise Errno::EINTR
|
|
132
|
+
end
|
|
133
|
+
original.call(*args, **kwargs)
|
|
134
|
+
end
|
|
135
|
+
session.log_to { drain(sink) }
|
|
136
|
+
assert_equal(5, bounded { session.write("hello") })
|
|
137
|
+
assert interrupted
|
|
138
|
+
assert_equal "reply", session.buffer
|
|
139
|
+
assert_equal "hello", drain(sink)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def test_write_timeout_reports_accepted_bytes
|
|
143
|
+
session, sink = sink_session(write_timeout: 0.02)
|
|
144
|
+
error = assert_raises(Expect::WriteTimeout) { session.write("x" * 524_288) }
|
|
145
|
+
received = drain(sink)
|
|
146
|
+
assert_operator received.bytesize, :>, 0
|
|
147
|
+
assert_equal received.bytesize, error.bytes_written
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def test_nested_output_timeout_preserves_the_callers_write_progress
|
|
151
|
+
%i[listeners log_output].each do |destination|
|
|
152
|
+
session, sink, peer = sink_session(write_timeout: 1)
|
|
153
|
+
target, = sink_session(write_timeout: 0)
|
|
154
|
+
fill(target.writer)
|
|
155
|
+
session.public_send(:"#{destination}=", destination == :listeners ? [target] : target)
|
|
156
|
+
peer.write("reply")
|
|
157
|
+
|
|
158
|
+
error = assert_raises(Expect::WriteTimeout) { bounded { session.write("x" * 1_048_576) } }
|
|
159
|
+
received = drain(sink)
|
|
160
|
+
assert_operator received.bytesize, :>, 0
|
|
161
|
+
assert_equal received.bytesize, error.bytes_written
|
|
162
|
+
assert_instance_of Expect::WriteTimeout, error.cause
|
|
163
|
+
assert_equal 0, error.cause.bytes_written
|
|
164
|
+
assert_equal "reply", session.buffer
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def test_truncated_utf8_at_eof_raises_without_losing_bytes
|
|
169
|
+
session, writer = pipe_session
|
|
170
|
+
writer.write("\xe4".b)
|
|
171
|
+
writer.close
|
|
172
|
+
assert_raises(EncodingError) { session.expect(/中/u, timeout: 1) }
|
|
173
|
+
assert_equal "\xe4".b, session.buffer
|
|
174
|
+
assert_equal 1, session.expect("\xe4".b, timeout: 0)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def test_blocked_target_does_not_starve_another_source
|
|
178
|
+
slow, = pipe_session
|
|
179
|
+
fast, writer = pipe_session
|
|
180
|
+
target, = sink_session
|
|
181
|
+
fill(target.writer)
|
|
182
|
+
slow.listeners = [target]
|
|
183
|
+
slow.buffer = "blocked"
|
|
184
|
+
output = StringIO.new
|
|
185
|
+
fast.listeners = [output]
|
|
186
|
+
writer.write("ready")
|
|
187
|
+
assert_nil bounded(0.5) { Expect.interconnect(slow, fast, timeout: 0.02) }
|
|
188
|
+
assert_equal "ready", output.string
|
|
189
|
+
assert slow.pending_output?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def test_outer_timeout_keeps_output_for_original_targets
|
|
193
|
+
source, = pipe_session
|
|
194
|
+
target, sink = sink_session
|
|
195
|
+
fill(target.writer)
|
|
196
|
+
source.listeners = [target.writer]
|
|
197
|
+
source.buffer = "request"
|
|
198
|
+
Expect.interconnect(source, timeout: 0.01)
|
|
199
|
+
assert source.pending_output?
|
|
200
|
+
assert_empty source.buffer
|
|
201
|
+
drain(sink)
|
|
202
|
+
replacement = StringIO.new
|
|
203
|
+
source.listeners = [replacement]
|
|
204
|
+
source.buffer = "new"
|
|
205
|
+
Expect.interconnect(source, timeout: 0.02)
|
|
206
|
+
refute source.pending_output?
|
|
207
|
+
assert_equal "request", drain(sink)
|
|
208
|
+
assert_equal "new", replacement.string
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def test_every_short_write_failure_position_resumes_without_replay
|
|
212
|
+
0.upto(5) do |failure_offset|
|
|
213
|
+
source, = pipe_session
|
|
214
|
+
first = StringIO.new
|
|
215
|
+
received = "".b
|
|
216
|
+
failed = false
|
|
217
|
+
target = Object.new
|
|
218
|
+
target.define_singleton_method(:write) do |data|
|
|
219
|
+
if !failed && received.bytesize == failure_offset
|
|
220
|
+
failed = true
|
|
221
|
+
raise IOError, "interrupted delivery"
|
|
222
|
+
end
|
|
223
|
+
received << data.byteslice(0, 1)
|
|
224
|
+
1
|
|
225
|
+
end
|
|
226
|
+
source.listeners = [first, target]
|
|
227
|
+
source.buffer = "abcdef"
|
|
228
|
+
assert_raises(IOError) { Expect.interconnect(source, timeout: 1) }
|
|
229
|
+
assert source.pending_output?
|
|
230
|
+
Expect.interconnect(source, timeout: 0.01)
|
|
231
|
+
assert_equal "abcdef", received
|
|
232
|
+
assert_equal "abcdef", first.string
|
|
233
|
+
refute source.pending_output?
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def test_flush_failure_retries_flush_without_replaying_data
|
|
238
|
+
source, = pipe_session
|
|
239
|
+
output = StringIO.new
|
|
240
|
+
failed = false
|
|
241
|
+
output.define_singleton_method(:flush) do
|
|
242
|
+
return if failed
|
|
243
|
+
|
|
244
|
+
failed = true
|
|
245
|
+
raise IOError, "flush failed"
|
|
246
|
+
end
|
|
247
|
+
source.listeners = [output]
|
|
248
|
+
source.buffer = "once"
|
|
249
|
+
assert_raises(IOError) { Expect.interconnect(source, timeout: 1) }
|
|
250
|
+
Expect.interconnect(source, timeout: 0.01)
|
|
251
|
+
assert_equal "once", output.string
|
|
252
|
+
refute source.pending_output?
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def test_invalid_write_count_raises_and_can_recover
|
|
256
|
+
[0, nil, -1, 7, "6"].each do |invalid|
|
|
257
|
+
source, = pipe_session
|
|
258
|
+
output = StringIO.new
|
|
259
|
+
source.listeners = [output]
|
|
260
|
+
source.buffer = "abcdef"
|
|
261
|
+
output.stub(:write, invalid) do
|
|
262
|
+
assert_raises(IOError) { bounded { Expect.interconnect(source, timeout: 1) } }
|
|
263
|
+
end
|
|
264
|
+
Expect.interconnect(source, timeout: 0.01)
|
|
265
|
+
assert_equal "abcdef", output.string
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def test_escape_callback_waits_for_prefix_delivery_across_timeout
|
|
270
|
+
source, = pipe_session
|
|
271
|
+
target, sink = sink_session
|
|
272
|
+
fill(target.writer)
|
|
273
|
+
source.listeners = [target]
|
|
274
|
+
called = 0
|
|
275
|
+
source.on_sequence(/END\z/) do
|
|
276
|
+
called += 1
|
|
277
|
+
false
|
|
278
|
+
end
|
|
279
|
+
source.buffer = "prefixEND"
|
|
280
|
+
assert_nil Expect.interconnect(source, timeout: 0.01)
|
|
281
|
+
assert_equal 0, called
|
|
282
|
+
source.buffer = "tail"
|
|
283
|
+
drain(sink)
|
|
284
|
+
assert_same source, Expect.interconnect(source, timeout: 0.1)
|
|
285
|
+
assert_equal 1, called
|
|
286
|
+
assert_equal "prefix", drain(sink)
|
|
287
|
+
assert_equal "tail", source.buffer
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def test_matching_window_does_not_truncate_undelivered_relay_input
|
|
291
|
+
source, writer = pipe_session(buffer_limit: 2)
|
|
292
|
+
target, sink = sink_session
|
|
293
|
+
fill(target.writer)
|
|
294
|
+
source.listeners = [target]
|
|
295
|
+
source.on_sequence("!")
|
|
296
|
+
writer.write("prefix!tail")
|
|
297
|
+
Expect.interconnect(source, timeout: 0.01)
|
|
298
|
+
assert_equal "tail", source.buffer
|
|
299
|
+
drain(sink)
|
|
300
|
+
assert_same source, Expect.interconnect(source, timeout: 0.1)
|
|
301
|
+
assert_equal "prefix", drain(sink)
|
|
302
|
+
assert_equal "tail", source.buffer
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def test_unlisted_target_output_is_drained_without_losing_bytes
|
|
306
|
+
source, writer = pipe_session
|
|
307
|
+
target = child('puts "ready"; data = STDIN.read(131072); print "reply" if data.bytesize == 131072',
|
|
308
|
+
raw_pty: true, write_timeout: 1)
|
|
309
|
+
source.listeners = [target]
|
|
310
|
+
source.buffer = "x" * 131_072
|
|
311
|
+
writer.close
|
|
312
|
+
assert_same(source, bounded { Expect.interconnect(source, timeout: 2) })
|
|
313
|
+
refute source.pending_output?
|
|
314
|
+
assert_empty source.buffer
|
|
315
|
+
assert_equal 1, target.expect("ready", timeout: 1)
|
|
316
|
+
assert_equal 1, target.expect("reply", timeout: 1)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def test_eof_validates_truncated_utf8_in_escape_regex
|
|
320
|
+
source, writer = pipe_session
|
|
321
|
+
source.on_sequence(/中/u)
|
|
322
|
+
writer.write("\xe4".b)
|
|
323
|
+
writer.close
|
|
324
|
+
assert_raises(EncodingError) { Expect.interconnect(source, timeout: 1) }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def test_instance_regexp_timeout_propagates_and_keeps_buffer
|
|
328
|
+
source, = pipe_session
|
|
329
|
+
source.buffer = "#{"a" * 30}!"
|
|
330
|
+
regexp = Regexp.new('\\A(a+)+\\1\\z', timeout: 0.01)
|
|
331
|
+
global_timeout = Regexp.timeout
|
|
332
|
+
assert_raises(Regexp::TimeoutError) { bounded { source.expect(regexp, timeout: 0) } }
|
|
333
|
+
assert_equal "#{"a" * 30}!", source.buffer
|
|
334
|
+
assert Regexp.timeout == global_timeout, "matching must not change the process regexp timeout"
|
|
335
|
+
assert_equal 0.01, regexp.timeout
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def test_closed_cached_input_wrapper_can_be_reopened_without_closing_raw_io
|
|
339
|
+
session, sink = sink_session
|
|
340
|
+
input, keyboard = IO.pipe
|
|
341
|
+
@ios.push(input, keyboard)
|
|
342
|
+
keyboard.write("one!")
|
|
343
|
+
source = session.interact(input: input, output: StringIO.new, escape: "!", timeout: 0.1)
|
|
344
|
+
source.close
|
|
345
|
+
keyboard.write("two!")
|
|
346
|
+
resumed = session.interact(input: input, output: StringIO.new, escape: "!", timeout: 0.1)
|
|
347
|
+
refute_same source, resumed
|
|
348
|
+
assert_equal "onetwo", drain(sink)
|
|
349
|
+
resumed.graceful_close = true
|
|
350
|
+
bounded(0.5) { session.close }
|
|
351
|
+
assert resumed.closed?
|
|
352
|
+
refute input.closed?
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def test_repeated_read_interruptions_do_not_extend_write_timeout
|
|
356
|
+
session, = sink_session(write_timeout: 0.02)
|
|
357
|
+
fill(session.writer)
|
|
358
|
+
reads = 0
|
|
359
|
+
session.to_io.stub(:read_nonblock, lambda { |*|
|
|
360
|
+
reads += 1
|
|
361
|
+
raise Errno::EINTR
|
|
362
|
+
}) do
|
|
363
|
+
IO.stub(:select, ->(*) { [[session.to_io], [], []] }) do
|
|
364
|
+
error = assert_raises(Expect::WriteTimeout) { bounded(0.5) { session.write("request") } }
|
|
365
|
+
assert_equal 0, error.bytes_written
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
assert_operator reads, :>, 1
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def test_zero_timeout_retains_a_blocked_escape_prefix_for_retry
|
|
372
|
+
source, = pipe_session
|
|
373
|
+
target, sink = sink_session
|
|
374
|
+
fill(target.writer)
|
|
375
|
+
source.on_sequence("STOP")
|
|
376
|
+
source.listeners = [target]
|
|
377
|
+
source.buffer = "ST"
|
|
378
|
+
assert_nil bounded(0.5) { Expect.interconnect(source, timeout: 0) }
|
|
379
|
+
assert source.pending_output?
|
|
380
|
+
drain(sink)
|
|
381
|
+
Expect.interconnect(source, timeout: 0.01)
|
|
382
|
+
assert_equal "ST", drain(sink)
|
|
383
|
+
refute source.pending_output?
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def test_short_log_writes_keep_all_bytes
|
|
387
|
+
source, = pipe_session
|
|
388
|
+
output = StringIO.new
|
|
389
|
+
original = output.method(:write)
|
|
390
|
+
output.define_singleton_method(:write) { |data| original.call(data.byteslice(0, 1)) }
|
|
391
|
+
source.log_to(output)
|
|
392
|
+
source.write_log("complete")
|
|
393
|
+
assert_equal "complete", output.string
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def test_interrupted_custom_output_is_retried_without_waiting_for_new_input
|
|
397
|
+
source, = pipe_session
|
|
398
|
+
output = StringIO.new
|
|
399
|
+
interrupted = false
|
|
400
|
+
original = output.method(:write)
|
|
401
|
+
output.define_singleton_method(:write) do |data|
|
|
402
|
+
unless interrupted
|
|
403
|
+
interrupted = true
|
|
404
|
+
raise Errno::EINTR
|
|
405
|
+
end
|
|
406
|
+
original.call(data)
|
|
407
|
+
end
|
|
408
|
+
source.listeners = [output]
|
|
409
|
+
source.on_sequence("!")
|
|
410
|
+
source.buffer = "ready!"
|
|
411
|
+
assert_same source, bounded(0.5) { Expect.interconnect(source, timeout: 0.05) }
|
|
412
|
+
assert_equal "ready", output.string
|
|
413
|
+
end
|
|
414
|
+
end
|
data/test/release_test.rb
CHANGED
|
@@ -25,7 +25,7 @@ class ReleaseTest < Minitest::Test
|
|
|
25
25
|
capture_io { release.run }
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
|
-
directory = Dir.glob(File.join("
|
|
28
|
+
directory = Dir.glob(File.join("tmp", "release", Expect::VERSION, "candidate-*")).fetch(0)
|
|
29
29
|
checksum = File.read(File.join(directory, "SHA256SUMS"))
|
|
30
30
|
assert_equal "#{Digest::SHA256.file(artifact).hexdigest} #{File.basename(artifact)}\n", checksum
|
|
31
31
|
assert_equal "- Release fixture.\n", File.read(File.join(directory, "release-notes.md"))
|
|
@@ -34,6 +34,72 @@ class ReleaseTest < Minitest::Test
|
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
def test_rubygems_only_publishes_the_verified_copy_without_github
|
|
38
|
+
with_package do |_release, artifact|
|
|
39
|
+
release = Release.new(artifact: artifact, rubygems_only: true)
|
|
40
|
+
bytes = File.binread(artifact)
|
|
41
|
+
checksum = Digest::SHA256.hexdigest(bytes)
|
|
42
|
+
published = false
|
|
43
|
+
capture = ->(*arguments) { arguments.include?("rev-parse") ? "verified" : "" }
|
|
44
|
+
get = lambda do |path|
|
|
45
|
+
if path.start_with?("/downloads/")
|
|
46
|
+
Struct.new(:code, :body).new("200", bytes)
|
|
47
|
+
elsif published
|
|
48
|
+
Struct.new(:code, :body).new("200", JSON.generate("sha" => checksum, "yanked" => false))
|
|
49
|
+
else
|
|
50
|
+
Struct.new(:code, :body).new("404", "")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
push = lambda do |*arguments|
|
|
54
|
+
candidate = arguments.fetch(2)
|
|
55
|
+
assert_equal ["gem", "push", candidate, "--host", "https://rubygems.org"], arguments
|
|
56
|
+
assert_match %r{/tmp/release/#{Regexp.escape(Expect::VERSION)}/candidate-[^/]+/}, candidate
|
|
57
|
+
assert_equal bytes, File.binread(candidate)
|
|
58
|
+
refute_equal File.expand_path(artifact), candidate
|
|
59
|
+
published = true
|
|
60
|
+
end
|
|
61
|
+
release.stub(:capture, capture) do
|
|
62
|
+
release.stub(:get, get) do
|
|
63
|
+
release.stub(:system, push) do
|
|
64
|
+
release.stub(:github, ->(*) { flunk "RubyGems-only release contacted GitHub" }) do
|
|
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"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
assert published
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_rubygems_only_rejects_uncommitted_source_before_publishing
|
|
78
|
+
with_package do |_release, artifact|
|
|
79
|
+
release = Release.new(artifact: artifact, rubygems_only: true)
|
|
80
|
+
release.stub(:capture, " M payload.rb") do
|
|
81
|
+
release.stub(:get, ->(*) { flunk "uncommitted source contacted RubyGems" }) do
|
|
82
|
+
assert_match "Commit all source changes", assert_raises(RuntimeError) { release.run }.message
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_rubygems_only_rechecks_source_after_verification
|
|
89
|
+
with_package do |_release, artifact|
|
|
90
|
+
release = Release.new(artifact: artifact, rubygems_only: true)
|
|
91
|
+
statuses = ["", " M payload.rb"]
|
|
92
|
+
capture = ->(*arguments) { arguments.include?("rev-parse") ? "verified" : statuses.shift }
|
|
93
|
+
release.stub(:capture, capture) do
|
|
94
|
+
release.stub(:get, ->(*) { flunk "changed source contacted RubyGems" }) do
|
|
95
|
+
capture_io do
|
|
96
|
+
assert_match "Source changed", assert_raises(RuntimeError) { release.run }.message
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
37
103
|
def test_rejects_a_package_from_different_source
|
|
38
104
|
with_package do |release, _artifact|
|
|
39
105
|
File.write("payload.rb", "puts :changed\n")
|