expect-pty 0.3.2 → 0.4.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/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|
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class BufferAccountingTest < ExpectTest
6
+ def test_limit_discards_are_cumulative_and_distinct_from_consumption
7
+ session, = pipe_session(buffer_limit: 4)
8
+ assert_equal 0, session.buffer_discarded_bytes
9
+ session.buffer = "abcdef"
10
+ assert_equal "cdef", session.buffer
11
+ assert_equal 2, session.buffer_discarded_bytes
12
+ session.buffer_limit = 2
13
+ assert_equal "ef", session.buffer
14
+ assert_equal 4, session.buffer_discarded_bytes
15
+ assert_equal 1, session.expect("e", timeout: 0)
16
+ assert_equal "f", session.clear_buffer
17
+ session.close
18
+ assert_equal 4, session.buffer_discarded_bytes
19
+ refute_respond_to session, :buffer_discarded_bytes=
20
+ end
21
+
22
+ def test_invalid_limit_and_buffer_leave_accounting_unchanged
23
+ session, = pipe_session(buffer_limit: 4)
24
+ session.buffer = "abcdef"
25
+ assert_raises(ArgumentError) { session.buffer_limit = 0 }
26
+ assert_raises(ArgumentError) { session.buffer = nil }
27
+ assert_equal "cdef", session.buffer
28
+ assert_equal 2, session.buffer_discarded_bytes
29
+ session.buffer_limit = nil
30
+ session.buffer = "unlimited"
31
+ assert_equal 2, session.buffer_discarded_bytes
32
+ end
33
+
34
+ def test_continuous_output_keeps_a_bounded_window_and_complete_log
35
+ session, writer = pipe_session(buffer_limit: 64)
36
+ logged = 0
37
+ session.log_to { |data| logged += data.bytesize }
38
+ 32.times do
39
+ writer.write("x" * 4096)
40
+ assert_nil session.expect("missing", timeout: 0)
41
+ assert_equal 64, session.buffer.bytesize
42
+ end
43
+ assert_equal 131_072, logged
44
+ assert_equal logged - 64, session.buffer_discarded_bytes
45
+ end
46
+
47
+ def test_preserved_match_does_not_count_as_discard
48
+ session, = pipe_session(buffer_limit: 4, preserve_buffer: true)
49
+ session.buffer = "abcdef"
50
+ 2.times { assert_equal 1, session.expect("ef", timeout: 0) }
51
+ assert_equal "cdef", session.buffer
52
+ assert_equal 2, session.buffer_discarded_bytes
53
+ end
54
+
55
+ def test_relay_tail_is_only_counted_when_matching_applies_the_limit
56
+ session, writer = pipe_session(buffer_limit: 2)
57
+ output = StringIO.new
58
+ session.listeners = [output]
59
+ session.on_sequence("STOP")
60
+ writer.write("prefixSTOPtail")
61
+ assert_same session, Expect.interconnect(session, timeout: 1)
62
+ assert_equal "prefix", output.string
63
+ assert_equal "tail", session.buffer
64
+ assert_equal 0, session.buffer_discarded_bytes
65
+ assert_equal 1, session.expect("il", timeout: 0)
66
+ assert_equal 2, session.buffer_discarded_bytes
67
+ session.expect(timeout: 0)
68
+ assert_equal 2, session.buffer_discarded_bytes
69
+ end
70
+
71
+ def test_logging_failure_preserves_the_retained_input_and_discard_count
72
+ session, writer = pipe_session(buffer_limit: 4)
73
+ failure = IOError.new("log failed")
74
+ session.log_to { raise failure }
75
+ writer.write("abcdef")
76
+ assert_same failure, session.expect_result("ef", timeout: 1).error
77
+ assert_equal "cdef", session.buffer
78
+ assert_equal 2, session.buffer_discarded_bytes
79
+ session.log_output = nil
80
+ assert_equal 1, session.expect("ef", timeout: 0)
81
+ assert_equal 2, session.buffer_discarded_bytes
82
+ end
83
+ end
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
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class DeadlineTest < ExpectTest
6
+ def test_absolute_deadline_caps_timeout_resets_from_incoming_data
7
+ session, writer = pipe_session(reset_timeout_on_read: true)
8
+ with_clock([[4, writer, "."], [8, writer, "."], [12, writer, "done"]]) do |clock|
9
+ result = session.expect_result("done", timeout: 5, deadline: 10)
10
+ assert result.timeout?
11
+ assert_equal 10, clock[0]
12
+ assert_equal "..", session.buffer
13
+ end
14
+ end
15
+
16
+ def test_class_api_and_nil_timeout_use_the_absolute_deadline
17
+ session, writer = pipe_session
18
+ with_clock([[4, writer, "ready"]]) do |clock|
19
+ assert_equal 1, Expect.expect("ready", from: session, timeout: nil, deadline: 5)
20
+ result = Expect.expect_result("missing", from: session, timeout: nil, deadline: 5)
21
+ assert result.timeout?
22
+ assert_equal 5, clock[0]
23
+ end
24
+ end
25
+
26
+ def test_shorter_relative_timeout_still_wins
27
+ session, = pipe_session
28
+ with_clock do |clock|
29
+ assert session.expect_result("missing", timeout: 2, deadline: 10).timeout?
30
+ assert_equal 2, clock[0]
31
+ end
32
+ end
33
+
34
+ def test_continuing_match_cannot_extend_the_hard_deadline
35
+ session, = pipe_session
36
+ session.buffer = "first second"
37
+ with_clock do |clock|
38
+ result = session.expect_result(timeout: 10, deadline: 5) do
39
+ on("first") do
40
+ clock[0] = 6
41
+ Expect.continue
42
+ end
43
+ on("second") { flunk "must not consume text after the deadline" }
44
+ end
45
+ assert result.timeout?
46
+ assert_equal " second", session.buffer
47
+ end
48
+ end
49
+
50
+ def test_timeout_callback_cannot_restart_an_expired_hard_deadline
51
+ session, = pipe_session
52
+ calls = 0
53
+ with_clock do |clock|
54
+ result = session.expect_result(timeout: 3, deadline: 5) do
55
+ timeout do
56
+ calls += 1
57
+ Expect.continue
58
+ end
59
+ end
60
+ assert result.timeout?
61
+ assert_equal 2, calls
62
+ assert_equal 5, clock[0]
63
+ end
64
+ end
65
+
66
+ def test_expired_deadline_does_not_scan_or_read_and_preserves_input
67
+ session, writer = pipe_session
68
+ session.buffer = "buffered"
69
+ writer.write("unread")
70
+ with_clock do |clock|
71
+ clock[0] = 5
72
+ assert session.expect_result("buffered", timeout: 0, deadline: 5).timeout?
73
+ assert_equal "buffered", session.buffer
74
+ assert_equal "unread", session.to_io.read_nonblock(6)
75
+ end
76
+ end
77
+
78
+ def test_zero_relative_timeout_still_polls_with_a_future_hard_deadline
79
+ session, writer = pipe_session
80
+ writer.write("ready")
81
+ assert_equal 1, session.expect("ready", timeout: 0, deadline: Expect.monotonic + 1)
82
+ end
83
+
84
+ def test_repeated_interruptions_cannot_extend_a_deadline
85
+ session, = pipe_session
86
+ clock = [0]
87
+ Expect.stub(:monotonic, -> { clock[0] }) do
88
+ IO.stub(:select, lambda { |*|
89
+ clock[0] += 1
90
+ raise Errno::EINTR
91
+ }) do
92
+ assert session.expect_result("missing", deadline: 3).timeout?
93
+ end
94
+ assert_equal 3, clock[0]
95
+ end
96
+ end
97
+
98
+ def test_known_eof_events_are_delivered_before_timeout_of_live_sources
99
+ ended, = pipe_session
100
+ live, = pipe_session
101
+ ended.close
102
+ live.buffer = "ready"
103
+ seen = []
104
+ with_clock do
105
+ result = Expect.expect_result(from: [ended, live], deadline: 0) do
106
+ on("ready") { flunk "deadline already expired" }
107
+ eof(from: ended) do
108
+ seen << :eof
109
+ Expect.continue
110
+ end
111
+ timeout { |sessions| seen << sessions }
112
+ end
113
+ assert result.timeout?
114
+ assert_same live, result.session
115
+ assert_equal [:eof, [live]], seen
116
+ assert_equal "ready", live.buffer
117
+ end
118
+ end
119
+
120
+ def test_all_known_eof_returns_eof_even_at_the_hard_deadline
121
+ first, = pipe_session
122
+ second, = pipe_session
123
+ [first, second].each(&:close)
124
+ seen = []
125
+ with_clock do
126
+ result = Expect.expect_result(from: [first, second], deadline: 0) do
127
+ eof do |session|
128
+ seen << session
129
+ Expect.continue
130
+ end
131
+ timeout { flunk "all sources ended" }
132
+ end
133
+ assert result.eof?
134
+ assert_equal [first, second], seen
135
+ end
136
+ end
137
+
138
+ def test_deadline_reached_during_regexp_does_not_consume_the_match
139
+ session, = pipe_session
140
+ session.buffer = "ready"
141
+ pattern = Regexp.new("ready")
142
+ match = pattern.method(:match)
143
+ with_clock do |clock|
144
+ pattern.stub(:match, lambda { |text|
145
+ clock[0] = 5
146
+ match.call(text)
147
+ }) do
148
+ assert session.expect_result(pattern, deadline: 5).timeout?
149
+ end
150
+ assert_equal "ready", session.buffer
151
+ end
152
+ end
153
+
154
+ def test_preserved_timeout_at_hard_deadline_still_dispatches_all_known_eof
155
+ first, = pipe_session
156
+ second, = pipe_session
157
+ [first, second].each(&:close)
158
+ first.buffer = "ready"
159
+ seen = []
160
+ with_clock do |clock|
161
+ result = Expect.expect_result(from: [first, second], deadline: 5) do
162
+ on("ready") do
163
+ clock[0] = 5
164
+ Expect.continue(reset_timeout: false)
165
+ end
166
+ eof do |session|
167
+ seen << session
168
+ Expect.continue(reset_timeout: false)
169
+ end
170
+ timeout { seen << :timeout }
171
+ end
172
+ assert result.eof?
173
+ assert_equal [first, second], seen
174
+ end
175
+ end
176
+
177
+ def test_preserved_timeout_at_hard_deadline_only_times_out_live_sources
178
+ ended, = pipe_session
179
+ live, = pipe_session
180
+ ended.close
181
+ ended.buffer = "ready"
182
+ seen = []
183
+ with_clock do |clock|
184
+ result = Expect.expect_result(from: [ended, live], deadline: 5) do
185
+ on("ready") do
186
+ clock[0] = 5
187
+ Expect.continue(reset_timeout: false)
188
+ end
189
+ eof(from: ended) do
190
+ seen << :eof
191
+ Expect.continue(reset_timeout: false)
192
+ end
193
+ timeout { |sessions| seen << sessions }
194
+ end
195
+ assert result.timeout?
196
+ assert_same live, result.session
197
+ assert_equal [:eof, [live]], seen
198
+ end
199
+ end
200
+
201
+ def test_invalid_deadlines_are_rejected_before_the_definition_or_io
202
+ session, writer = pipe_session
203
+ writer.write("ready")
204
+ [Float::INFINITY, -Float::INFINITY, Float::NAN, "invalid"].each do |deadline|
205
+ assert_raises(ArgumentError) do
206
+ session.expect_result(deadline: deadline) { flunk "invalid deadline must reject before registration" }
207
+ end
208
+ end
209
+ assert_equal "ready", session.to_io.read_nonblock(5)
210
+ end
211
+
212
+ private
213
+
214
+ # 虚拟时间只控制等待,数据仍经真实管道;精确断言上限,不依赖线程调度和 sleep。
215
+ def with_clock(events = [])
216
+ clock = [0.0]
217
+ pending = events.dup
218
+ poll = IO.method(:select)
219
+ select = lambda do |readers, writers, errors, timeout|
220
+ due = timeout && (clock[0] + timeout)
221
+ if pending.any? && (!due || pending.first[0] <= due)
222
+ at, writer, data = pending.shift
223
+ clock[0] = at
224
+ writer.write(data)
225
+ poll.call(readers, writers, errors, 0)
226
+ else
227
+ raise "unbounded wait without input" unless due
228
+
229
+ clock[0] = due
230
+ nil
231
+ end
232
+ end
233
+ Expect.stub(:monotonic, -> { clock[0] }) do
234
+ IO.stub(:select, select) { yield clock }
235
+ end
236
+ end
237
+ end