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.
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
@@ -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("pkg", "release", Expect::VERSION, "candidate-*")).fetch(0)
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")
@@ -10,6 +10,7 @@ require_relative "../../lib/expect/pty"
10
10
  module ScriptProbe
11
11
  PROMPT = "EXPECT_SCRIPT_PROMPT> "
12
12
  FIXTURES = File.expand_path("../fixtures/ssh_scripts", __dir__)
13
+
13
14
  class Failure < StandardError; end
14
15
 
15
16
  def self.check(condition, message)