expect-pty 0.2.0 → 0.3.1
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 +15 -0
- data/Gemfile +21 -5
- data/README.md +40 -15
- data/docs/COMPATIBILITY.md +23 -1
- data/docs/RELEASING.md +26 -13
- data/docs/VERIFICATION.md +65 -1
- data/expect-pty.gemspec +8 -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 +44 -7
- 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/process_test.rb +3 -2
- data/test/relay_recovery_test.rb +414 -0
- data/test/release_test.rb +98 -1
- data/test/support/script_probe.rb +1 -0
- metadata +98 -7
- data/lib/expect/interconnect.rb +0 -170
|
@@ -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,92 @@ 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
|
+
[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
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
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
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_rubygems_only_rejects_uncommitted_source_before_publishing
|
|
98
|
+
with_package do |_release, artifact|
|
|
99
|
+
release = Release.new(artifact: artifact, rubygems_only: true)
|
|
100
|
+
release.stub(:capture, " M payload.rb") do
|
|
101
|
+
release.stub(:get, ->(*) { flunk "uncommitted source contacted RubyGems" }) do
|
|
102
|
+
assert_match "Commit all source changes", assert_raises(RuntimeError) { release.run }.message
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_rubygems_only_rechecks_source_after_verification
|
|
109
|
+
with_package do |_release, artifact|
|
|
110
|
+
release = Release.new(artifact: artifact, rubygems_only: true)
|
|
111
|
+
statuses = ["", " M payload.rb"]
|
|
112
|
+
capture = ->(*arguments) { arguments.include?("rev-parse") ? "verified" : statuses.shift }
|
|
113
|
+
release.stub(:capture, capture) do
|
|
114
|
+
release.stub(:get, ->(*) { flunk "changed source contacted RubyGems" }) do
|
|
115
|
+
capture_io do
|
|
116
|
+
assert_match "Source changed", assert_raises(RuntimeError) { release.run }.message
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
37
123
|
def test_rejects_a_package_from_different_source
|
|
38
124
|
with_package do |release, _artifact|
|
|
39
125
|
File.write("payload.rb", "puts :changed\n")
|
|
@@ -128,6 +214,17 @@ class ReleaseTest < Minitest::Test
|
|
|
128
214
|
|
|
129
215
|
private
|
|
130
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
|
+
|
|
131
228
|
def with_package
|
|
132
229
|
Dir.mktmpdir("expect-release-test-") do |directory|
|
|
133
230
|
Dir.chdir(directory) do
|
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.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- expect-pty contributors
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
11
|
-
dependencies:
|
|
10
|
+
date: 2026-09-26 00:00:00.000000000 Z
|
|
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: []
|
|
@@ -34,13 +122,15 @@ files:
|
|
|
34
122
|
- expect-pty.gemspec
|
|
35
123
|
- lib/expect.rb
|
|
36
124
|
- lib/expect/configuration.rb
|
|
37
|
-
- lib/expect/
|
|
38
|
-
- lib/expect/
|
|
125
|
+
- lib/expect/interaction.rb
|
|
126
|
+
- lib/expect/matcher.rb
|
|
39
127
|
- lib/expect/pattern.rb
|
|
40
128
|
- lib/expect/pattern_list.rb
|
|
41
129
|
- lib/expect/pty.rb
|
|
42
|
-
- lib/expect/
|
|
130
|
+
- lib/expect/relay.rb
|
|
131
|
+
- lib/expect/relay_writer.rb
|
|
43
132
|
- lib/expect/result.rb
|
|
133
|
+
- lib/expect/session_resources.rb
|
|
44
134
|
- lib/expect/version.rb
|
|
45
135
|
- script/ci
|
|
46
136
|
- script/release.rb
|
|
@@ -61,6 +151,7 @@ files:
|
|
|
61
151
|
- test/matching_test.rb
|
|
62
152
|
- test/multi_session_test.rb
|
|
63
153
|
- test/process_test.rb
|
|
154
|
+
- test/relay_recovery_test.rb
|
|
64
155
|
- test/release_test.rb
|
|
65
156
|
- test/ruby_api_test.rb
|
|
66
157
|
- test/script_logging_test.rb
|
|
@@ -91,7 +182,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
91
182
|
- !ruby/object:Gem::Version
|
|
92
183
|
version: '0'
|
|
93
184
|
requirements: []
|
|
94
|
-
rubygems_version: 4.0.
|
|
185
|
+
rubygems_version: 4.0.12
|
|
95
186
|
specification_version: 4
|
|
96
187
|
summary: Ruby PTY automation with the Expect.pm interaction model
|
|
97
188
|
test_files: []
|