expect-pty 0.3.2 → 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.
data/lib/expect.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  require "pty"
4
4
  require "io/console"
5
5
  require "io/wait"
6
- require "shellwords"
7
6
  require "stringio"
8
7
  require "forwardable"
9
8
  require_relative "expect/version"
@@ -62,11 +61,19 @@ class Expect
62
61
  def spawn(*command, env: {}, chdir: nil, **)
63
62
  session = new(**)
64
63
  session.spawn(*command, env: env, chdir: chdir)
64
+ spawned = true
65
65
  return session unless block_given?
66
66
 
67
67
  yield session
68
+ rescue Exception # rubocop:disable Lint/RescueException -- 记录本次作用域的失败,清理后原样传播,包括非 StandardError 异常。
69
+ failed = true
70
+ raise
68
71
  ensure
69
- session&.close if block_given? || (session && !session.pid)
72
+ if session && (block_given? || !spawned)
73
+ session.__send__(:cleanup_preserving_failure, failed) do
74
+ session.close(graceful: spawned && session.graceful_close?)
75
+ end
76
+ end
70
77
  end
71
78
 
72
79
  # 适配已有 IO;own: true 接管关闭责任,初始化失败也释放接管的读写端。
@@ -77,11 +84,14 @@ class Expect
77
84
  return session unless block_given?
78
85
 
79
86
  yield session
87
+ rescue Exception # rubocop:disable Lint/RescueException -- 初始化和块异常均须保留,清理失败不能替换原始原因。
88
+ failed = true
89
+ raise
80
90
  ensure
81
- if initialized
82
- session.close if block_given?
83
- elsif own
84
- [io, writer].uniq.each { |handle| handle.close if handle.is_a?(IO) && !handle.closed? }
91
+ if session && (block_given? || !initialized)
92
+ session.__send__(:cleanup_preserving_failure, failed) do
93
+ session.close(graceful: initialized && session.graceful_close?)
94
+ end
85
95
  end
86
96
  end
87
97
 
@@ -173,11 +183,11 @@ class Expect
173
183
  @tty_name = slave.path
174
184
  spawn(*command, env: env, chdir: chdir) unless command.empty?
175
185
  initialized = true
186
+ rescue Exception # rubocop:disable Lint/RescueException -- 构造异常时也要关闭已创建的资源并回收已启动的子进程。
187
+ failed = true
188
+ raise
176
189
  ensure
177
- unless initialized
178
- master&.close unless master&.closed?
179
- slave&.close unless slave&.closed?
180
- end
190
+ cleanup_preserving_failure(failed) { close(graceful: false) } if @resources && !initialized
181
191
  end
182
192
 
183
193
  # 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
@@ -382,82 +392,6 @@ class Expect
382
392
  count
383
393
  end
384
394
 
385
- # 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
386
- attr_reader :log_output
387
-
388
- # 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
389
- def log_output=(target)
390
- unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
391
- raise ArgumentError, "log output must support write or call, or be nil"
392
- end
393
-
394
- replace_log(target)
395
- end
396
-
397
- # 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
398
- def log_to(target = nil, mode: "a", &block)
399
- raise ArgumentError, "provide a log target or a block, not both" if block && target
400
-
401
- target = block if block
402
- if target.respond_to?(:to_path) || target.is_a?(String)
403
- raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
404
-
405
- # 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
406
- replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
407
- else
408
- raise ArgumentError, "provide a log target or a block" unless target
409
-
410
- self.log_output = target
411
- end
412
- end
413
-
414
- # 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
415
- def write_log(*objects)
416
- target = log_output
417
- return unless target
418
-
419
- data = objects.map { |object| object.to_s.b }.join
420
- target.respond_to?(:call) ? target.call(data) : emit(target, data)
421
- end
422
-
423
- # 返回监听器列表副本,避免外部原地修改转发关系。
424
- def listeners = @listeners.dup
425
-
426
- # 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
427
- def listeners=(outputs)
428
- outputs = Array(outputs)
429
- raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
430
-
431
- @listeners = outputs.dup
432
- end
433
-
434
- # 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
435
- def stty(*modes)
436
- return "" unless tty?
437
-
438
- modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
439
- modes = ["-g"] if modes.empty?
440
- reader, sink = IO.pipe
441
- child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
442
- sink.close
443
- output = reader.read
444
- _, status = Process.waitpid2(child)
445
- raise IOError, "stty failed: #{output.strip}" unless status.success?
446
-
447
- output.strip
448
- ensure
449
- reader&.close unless reader&.closed?
450
- sink&.close unless sink&.closed?
451
- end
452
-
453
- # 读取终端的 [行数, 列数]。
454
- def winsize = to_io.winsize
455
-
456
- # 更新终端尺寸,由内核通知前台进程。
457
- def winsize=(size)
458
- to_io.winsize = size
459
- end
460
-
461
395
  # 轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。
462
396
  def wait(timeout: nil)
463
397
  period = Expect.duration(timeout)
@@ -501,12 +435,22 @@ class Expect
501
435
  def close(graceful: graceful_close?)
502
436
  soft_close if graceful
503
437
  nil
438
+ rescue Exception # rubocop:disable Lint/RescueException -- 软关闭的原始异常在硬关闭兜底后继续传播。
439
+ failed = true
440
+ raise
504
441
  ensure
505
- hard_close
442
+ cleanup_preserving_failure(failed) { hard_close }
506
443
  end
507
444
 
508
445
  private
509
446
 
447
+ # 仅在本次生命周期已有异常时抑制常规清理错误;调用者 rescue 中的旧异常不算本次失败。
448
+ def cleanup_preserving_failure(failed)
449
+ yield
450
+ rescue IOError, SystemCallError
451
+ raise unless failed
452
+ end
453
+
510
454
  # 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
511
455
  def finish_close(timeout:, term_timeout:, force:)
512
456
  failure = nil
@@ -555,10 +499,11 @@ class Expect
555
499
 
556
500
  # 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
557
501
  def initialize_session(reader, writer:, slave: nil, own: false, **)
502
+ # 先登记所有权,后续校验失败也使用同一个资源对象逐个清理所属 IO。
503
+ @resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
558
504
  raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
559
505
  raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
560
506
 
561
- @resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
562
507
  @pty = reader.tty?
563
508
  @slave = slave
564
509
  @configuration = Configuration.new(**self.class.configuration.to_h, **)
@@ -646,47 +591,6 @@ class Expect
646
591
  @buffer = @buffer.byteslice(-limit, limit) if limit&.positive? && @buffer.bytesize > limit
647
592
  end
648
593
 
649
- # 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
650
- def propagate(data)
651
- emit($stdout, data) if log_stdout?
652
- @listeners.each { |listener| emit(listener, data) } if log_listeners?
653
- end
654
-
655
- # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
656
- def emit(target, data)
657
- offset = 0
658
- while offset < data.bytesize
659
- count = target.write(data.byteslice(offset..))
660
- unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
661
- raise IOError, "write must return the number of accepted bytes"
662
- end
663
-
664
- offset += count
665
- end
666
- target.flush if target.respond_to?(:flush)
667
- end
668
-
669
- # 按诊断级别向 stderr 输出会话标识和消息。
670
- def trace(message, level: 1)
671
- warn("#{inspect}: #{message}") if debug_level >= level
672
- end
673
-
674
- # 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
675
- def replace_log(target, owned: false)
676
- previous = log_output
677
- # 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
678
- return target if previous.equal?(target)
679
-
680
- previous.close if @resources.owned_log && previous && !previous.closed?
681
- # 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
682
- @resources.owned_log = owned ? target : nil
683
- @log_output = target
684
- target
685
- rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
686
- target.close if owned && target && !target.closed?
687
- raise
688
- end
689
-
690
594
  # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
691
595
  def signal_child(signal)
692
596
  return unless alive? && @resources.owner == Process.pid
@@ -697,4 +601,6 @@ class Expect
697
601
  end
698
602
  end
699
603
 
604
+ require_relative "expect/logging"
605
+ require_relative "expect/terminal"
700
606
  require_relative "expect/interaction"
data/script/release.rb CHANGED
@@ -83,11 +83,16 @@ class Release
83
83
 
84
84
  private
85
85
 
86
- def capture(*arguments)
87
- output, error, status = Open3.capture3(*arguments)
86
+ def capture(*)
87
+ capture_bytes(*).strip
88
+ end
89
+
90
+ # Git blob 是任意字节,不能沿用命令文本的 strip,尾部换行和 NUL 也属于发布内容。
91
+ def capture_bytes(*arguments)
92
+ output, error, status = Open3.capture3(*arguments, binmode: true)
88
93
  raise "#{arguments.first} failed: #{error.strip}" unless status.success?
89
94
 
90
- output.strip
95
+ output
91
96
  end
92
97
 
93
98
  def command(*arguments)
@@ -124,7 +129,7 @@ class Release
124
129
  def verify_package
125
130
  package = Gem::Package.new(@artifact)
126
131
  expected = Gem::Specification.load(File.expand_path("expect-pty.gemspec"))
127
- fields = %i[name version platform summary description authors licenses required_ruby_version
132
+ fields = %i[name version platform summary description authors licenses homepage required_ruby_version
128
133
  required_rubygems_version require_paths metadata dependencies extensions executables bindir
129
134
  post_install_message]
130
135
  unless fields.all? { |field| package.spec.public_send(field) == expected.public_send(field) }
@@ -132,6 +137,7 @@ class Release
132
137
  end
133
138
  raise "Artifact file list differs from the source" unless package.contents.sort == expected.files.sort
134
139
 
140
+ committed = committed_files if @commit
135
141
  # 直接检查归档中的权限,避免解包时本机 umask 改写执行位。
136
142
  File.open(@artifact, "rb") do |io|
137
143
  Gem::Package::TarReader.new(io) do |archive|
@@ -139,7 +145,10 @@ class Release
139
145
  package.open_tar_gz(data) do |tar|
140
146
  tar.each do |entry|
141
147
  file = entry.full_name
142
- raise "Artifact differs from source: #{file}" unless entry.file? && entry.read == File.binread(file)
148
+ bytes = entry.read
149
+ raise "Artifact differs from source: #{file}" unless entry.file? && bytes == File.binread(file)
150
+
151
+ verify_committed_file(file, bytes, entry.header.mode, committed) if committed
143
152
  next if entry.header.mode & 0o111 == File.stat(file).mode & 0o111
144
153
 
145
154
  raise "Artifact executable permissions differ: #{file}"
@@ -149,6 +158,29 @@ class Release
149
158
  end
150
159
  end
151
160
 
161
+ # 工作区干净不代表所有打包文件都来自提交:ignore、assume-unchanged 和 filemode 均可隐藏差异。
162
+ # 只在正式发布时核对提交树;dry-run 仍允许检验尚未提交的开发源码。
163
+ def committed_files
164
+ capture_bytes("git", "ls-tree", "-rz", "--full-tree", @commit).split("\0").to_h do |record|
165
+ metadata, file = record.split("\t", 2)
166
+ mode, type, object = metadata.split
167
+ [file, [mode.to_i(8), type, object]]
168
+ end
169
+ end
170
+
171
+ def verify_committed_file(file, bytes, mode, committed)
172
+ committed_mode, type, object = committed[file.b]
173
+ unless type == "blob" && committed_mode & 0o170000 == 0o100000
174
+ raise "Artifact file is not in release commit: #{file}"
175
+ end
176
+ unless bytes == capture_bytes("git", "cat-file", "blob", object)
177
+ raise "Artifact differs from release commit: #{file}"
178
+ end
179
+ return if mode & 0o111 == committed_mode & 0o111
180
+
181
+ raise "Artifact executable permissions differ from release commit: #{file}"
182
+ end
183
+
152
184
  def get(path)
153
185
  uri = URI("#{GEM_HOST}#{path}")
154
186
  Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
data/test/cleanup_test.rb CHANGED
@@ -3,6 +3,94 @@
3
3
  require_relative "test_helper"
4
4
 
5
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
+
6
94
  def test_close_handles_attempts_every_owned_handle_and_preserves_first_error
7
95
  reader, writer = IO.pipe
8
96
  extra, peer = IO.pipe
@@ -16,8 +16,10 @@ class PatternOffsetTest < ExpectTest
16
16
  ]
17
17
  session, = pipe_session
18
18
  cases.each do |text, regexp, offset, length, captures|
19
- bytes = text.b
19
+ # 扫描只读调用者输入;省去正则副本后也不能改变编码标记或冻结字符串。
20
+ bytes = text.b.freeze
20
21
  assert_equal [offset, length, captures], Expect::Pattern.new(value: regexp).locate(bytes)
22
+ assert_equal Encoding::BINARY, bytes.encoding
21
23
  session.buffer = bytes
22
24
  result = session.expect_result(regexp, timeout: 0)
23
25
  assert_equal bytes.byteslice(0, offset), result.before
data/test/process_test.rb CHANGED
@@ -77,6 +77,14 @@ class ProcessTest < ExpectTest
77
77
  assert_raises(Expect::SpawnError) { session.spawn("cat") }
78
78
  end
79
79
 
80
+ def test_spawn_cleans_child_when_post_exec_diagnostics_fail
81
+ assert_failed_construction_cleans_child(:spawn)
82
+ end
83
+
84
+ def test_new_cleans_child_when_post_exec_diagnostics_fail
85
+ assert_failed_construction_cleans_child(:new)
86
+ end
87
+
80
88
  def test_block_closes_child_on_exception
81
89
  pid = nil
82
90
  assert_raises(RuntimeError) do
@@ -228,6 +236,17 @@ class ProcessTest < ExpectTest
228
236
  assert_equal configuration, terminal_configuration(session)
229
237
  end
230
238
 
239
+ def test_stty_reports_a_missing_system_command_and_preserves_the_cause
240
+ previous_path = ENV.fetch("PATH", nil)
241
+ session = child("sleep 60")
242
+ ENV["PATH"] = ""
243
+ error = assert_raises(IOError) { session.stty }
244
+ assert_match "stty executable not found", error.message
245
+ assert_instance_of Errno::ENOENT, error.cause
246
+ ensure
247
+ ENV["PATH"] = previous_path
248
+ end
249
+
231
250
  def test_configuration_and_session_attributes
232
251
  Expect.configure(timeout: 0.01, log_stdout: false)
233
252
  session = Expect.new
@@ -242,4 +261,31 @@ class ProcessTest < ExpectTest
242
261
  assert_equal 30, session.buffer_limit
243
262
  assert_match(/\A#<Expect .*closed=false>\z/, session.inspect)
244
263
  end
264
+
265
+ private
266
+
267
+ def assert_failed_construction_cleans_child(factory)
268
+ connection = nil
269
+ session_class = Class.new(Expect)
270
+ session_class.define_method(:initialize) do |*command, **options|
271
+ connection = self
272
+ super(*command, **options)
273
+ end
274
+ reader, diagnostic_output = IO.pipe
275
+ @ios.push(reader, diagnostic_output)
276
+ reader.close
277
+ previous_stderr = $stderr
278
+ begin
279
+ $stderr = diagnostic_output
280
+ assert_raises(Errno::EPIPE) do
281
+ session_class.public_send(factory, RbConfig.ruby, "--disable-gems", "-e", "sleep 60", debug_level: 1)
282
+ end
283
+ ensure
284
+ $stderr = previous_stderr
285
+ @sessions << connection if connection
286
+ end
287
+ assert connection.closed?
288
+ assert_nil connection.pid
289
+ assert_instance_of Process::Status, connection.process_status
290
+ end
245
291
  end
data/test/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