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.
- checksums.yaml +4 -4
- data/.rubocop.yml +18 -10
- data/CHANGELOG.md +8 -0
- data/CONTRIBUTING.md +3 -1
- data/README.md +11 -5
- data/docs/COMPATIBILITY.md +1 -1
- data/docs/INTERNAL_CONTRACTS.md +21 -0
- data/docs/PERFORMANCE.md +16 -0
- data/docs/RELEASING.md +8 -6
- data/docs/VERIFICATION.md +16 -0
- data/lib/expect/interaction.rb +3 -3
- data/lib/expect/logging.rb +96 -0
- data/lib/expect/matcher.rb +11 -2
- data/lib/expect/pattern.rb +6 -2
- data/lib/expect/session_resources.rb +2 -2
- data/lib/expect/terminal.rb +35 -0
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +35 -129
- data/script/release.rb +37 -5
- data/test/cleanup_test.rb +88 -0
- data/test/pattern_offset_test.rb +3 -1
- data/test/process_test.rb +46 -0
- data/test/release_test.rb +94 -16
- data/test/timeout_test.rb +119 -0
- metadata +4 -2
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
|
|
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.
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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(*
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/test/pattern_offset_test.rb
CHANGED
|
@@ -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
|
-
|
|
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(:
|
|
62
|
-
release.stub(:
|
|
63
|
-
release.stub(:
|
|
64
|
-
release.stub(:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|