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.
@@ -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
@@ -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
@@ -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(: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
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
- capture = ->(*arguments) { arguments.include?("rev-parse") ? "verified" : statuses.shift }
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