expect-pty 0.2.0 → 0.3.1
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/CHANGELOG.md +15 -0
- data/Gemfile +21 -5
- data/README.md +40 -15
- data/docs/COMPATIBILITY.md +23 -1
- data/docs/RELEASING.md +26 -13
- data/docs/VERIFICATION.md +65 -1
- data/expect-pty.gemspec +8 -1
- data/lib/expect/configuration.rb +7 -0
- data/lib/expect/interaction.rb +249 -0
- data/lib/expect/{engine.rb → matcher.rb} +40 -5
- data/lib/expect/pattern.rb +12 -20
- data/lib/expect/relay.rb +133 -0
- data/lib/expect/relay_writer.rb +66 -0
- data/lib/expect/result.rb +2 -0
- data/lib/expect/{resources.rb → session_resources.rb} +3 -3
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +105 -30
- data/script/ci +44 -7
- data/script/release.rb +22 -10
- data/test/edge_case_test.rb +101 -22
- data/test/interact_test.rb +102 -0
- data/test/interconnect_test.rb +203 -4
- data/test/io_test.rb +102 -0
- data/test/matching_test.rb +33 -0
- data/test/process_test.rb +3 -2
- data/test/relay_recovery_test.rb +414 -0
- data/test/release_test.rb +98 -1
- data/test/support/script_probe.rb +1 -0
- metadata +98 -7
- data/lib/expect/interconnect.rb +0 -170
data/lib/expect.rb
CHANGED
|
@@ -9,10 +9,10 @@ require "forwardable"
|
|
|
9
9
|
require_relative "expect/version"
|
|
10
10
|
require_relative "expect/configuration"
|
|
11
11
|
require_relative "expect/result"
|
|
12
|
-
require_relative "expect/
|
|
12
|
+
require_relative "expect/session_resources"
|
|
13
13
|
require_relative "expect/pattern"
|
|
14
14
|
require_relative "expect/pattern_list"
|
|
15
|
-
require_relative "expect/
|
|
15
|
+
require_relative "expect/matcher"
|
|
16
16
|
|
|
17
17
|
# 自动化交互会话:可以拥有一个 PTY 子进程,也可以适配已有可 select 的 IO。
|
|
18
18
|
# 缓冲和匹配统一保留原始字节,配置、匹配结果与资源生命周期分别管理。
|
|
@@ -23,7 +23,16 @@ class Expect
|
|
|
23
23
|
READ_SIZE = 16_384
|
|
24
24
|
|
|
25
25
|
class SpawnError < StandardError; end
|
|
26
|
-
|
|
26
|
+
|
|
27
|
+
# 已被底层接受的字节不可撤回;调用方可据此只处理尚未写出的后缀。
|
|
28
|
+
class WriteTimeout < IOError
|
|
29
|
+
attr_reader :bytes_written
|
|
30
|
+
|
|
31
|
+
def initialize(message = "write timed out", bytes_written: 0)
|
|
32
|
+
@bytes_written = bytes_written
|
|
33
|
+
super(message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
27
36
|
|
|
28
37
|
class << self
|
|
29
38
|
# 读取冻结的默认配置;子类未单独配置时继承父类快照。
|
|
@@ -78,6 +87,7 @@ class Expect
|
|
|
78
87
|
|
|
79
88
|
# 返回继续等待的控制符,reset_timeout 决定是否重新计算匹配期限。
|
|
80
89
|
def continue(reset_timeout: true) = reset_timeout ? CONTINUE : CONTINUE_WITHOUT_RESET
|
|
90
|
+
|
|
81
91
|
# 读取不受系统时间调整影响的单调时钟,所有相对超时共用此计时基准。
|
|
82
92
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
93
|
|
|
@@ -99,7 +109,20 @@ class Expect
|
|
|
99
109
|
active = sessions.uniq.reject(&:closed?)
|
|
100
110
|
return [] if active.empty?
|
|
101
111
|
|
|
102
|
-
|
|
112
|
+
deadline = timeout && (monotonic + timeout)
|
|
113
|
+
polled = false
|
|
114
|
+
ready = nil
|
|
115
|
+
loop do
|
|
116
|
+
remaining = deadline && [deadline - monotonic, 0].max
|
|
117
|
+
return [] if polled && remaining&.zero?
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
ready = IO.select(active.map(&:to_io), nil, nil, remaining)
|
|
121
|
+
break
|
|
122
|
+
rescue Errno::EINTR
|
|
123
|
+
polled = true
|
|
124
|
+
end
|
|
125
|
+
end
|
|
103
126
|
return [] unless ready
|
|
104
127
|
|
|
105
128
|
active.select { |session| ready.first.include?(session.to_io) }
|
|
@@ -116,7 +139,7 @@ class Expect
|
|
|
116
139
|
if block
|
|
117
140
|
block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
|
|
118
141
|
end
|
|
119
|
-
|
|
142
|
+
Matcher.new(pattern_list.validate!, timeout).run
|
|
120
143
|
end
|
|
121
144
|
end
|
|
122
145
|
|
|
@@ -214,14 +237,21 @@ class Expect
|
|
|
214
237
|
|
|
215
238
|
# 暴露底层读写 IO 与终端属性,供 select、终端设置及 IO 适配使用。
|
|
216
239
|
def to_io = @resources.reader
|
|
240
|
+
|
|
217
241
|
def writer = @resources.writer
|
|
242
|
+
|
|
218
243
|
def fileno = closed? ? nil : to_io.fileno
|
|
244
|
+
|
|
219
245
|
def tty? = !closed? && to_io.tty?
|
|
246
|
+
|
|
220
247
|
# 诊断时仅显示进程和描述符状态,避免默认对象展开泄露缓冲或日志内容。
|
|
221
248
|
def inspect = "#<#{self.class} pid=#{pid.inspect} fd=#{fileno.inspect} closed=#{closed?}>"
|
|
249
|
+
|
|
222
250
|
def pid = @resources.pid
|
|
251
|
+
|
|
223
252
|
# 非阻塞回收并缓存子进程状态;未退出或仅适配 IO 时返回 nil。
|
|
224
253
|
def process_status = @resources.reap
|
|
254
|
+
|
|
225
255
|
def exit_code = process_status&.exitstatus
|
|
226
256
|
|
|
227
257
|
# 先刷新回收状态,再判断是否仍有未回收的子进程;不以 IO 是否关闭代替进程状态。
|
|
@@ -232,14 +262,22 @@ class Expect
|
|
|
232
262
|
|
|
233
263
|
# 区分会话关闭和输入结束,已关闭会话也不能继续读取。
|
|
234
264
|
def closed? = @closed || to_io.closed?
|
|
265
|
+
|
|
235
266
|
def eof? = @eof || closed?
|
|
267
|
+
|
|
236
268
|
# 以下访问器读取最近一次等待结果;未发生匹配时捕获组返回空数组。
|
|
237
269
|
def before = @last_result&.before
|
|
270
|
+
|
|
238
271
|
def after = @last_result&.after
|
|
272
|
+
|
|
239
273
|
def match = @last_result&.match
|
|
274
|
+
|
|
240
275
|
def match_number = @last_result&.number
|
|
276
|
+
|
|
241
277
|
def captures = @last_result&.captures || []
|
|
278
|
+
|
|
242
279
|
def error = @last_result&.error
|
|
280
|
+
|
|
243
281
|
# 返回缓冲副本,防止调用方原地修改绕过裁剪规则。
|
|
244
282
|
def buffer = @buffer.dup
|
|
245
283
|
|
|
@@ -263,21 +301,38 @@ class Expect
|
|
|
263
301
|
raise IOError, "closed Expect session" if closed? || writer.closed?
|
|
264
302
|
|
|
265
303
|
data = objects.map { |object| object.to_s.b }.join
|
|
266
|
-
trace("sending #{data.inspect}", level: 2)
|
|
304
|
+
trace("sending #{data.inspect}", level: 2) if debug_level >= 2
|
|
267
305
|
deadline = write_timeout && (Expect.monotonic + write_timeout)
|
|
268
306
|
offset = 0
|
|
269
307
|
while offset < data.bytesize
|
|
270
|
-
|
|
308
|
+
begin
|
|
309
|
+
count = writer.write_nonblock(data.byteslice(offset, READ_SIZE), exception: false)
|
|
310
|
+
rescue Errno::EINTR
|
|
311
|
+
raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline
|
|
312
|
+
|
|
313
|
+
next
|
|
314
|
+
end
|
|
271
315
|
if count == :wait_writable
|
|
272
|
-
raise WriteTimeout
|
|
316
|
+
raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline
|
|
273
317
|
|
|
274
318
|
remaining = deadline && [deadline - Expect.monotonic, 0].max
|
|
275
319
|
# 子进程也可能因输出管道填满而停止读取;等可写时同时排空它的输出,避免双向死锁。
|
|
276
320
|
readers = eof? ? [] : [to_io]
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
321
|
+
begin
|
|
322
|
+
ready = IO.select(readers, [writer], nil, remaining)
|
|
323
|
+
raise WriteTimeout.new(bytes_written: offset) unless ready
|
|
324
|
+
|
|
325
|
+
if ready[0].include?(to_io)
|
|
326
|
+
begin
|
|
327
|
+
read_available
|
|
328
|
+
rescue WriteTimeout
|
|
329
|
+
# 日志或监听器可嵌套写入;对外报告本次写入进度,原异常通过 cause 保留。
|
|
330
|
+
raise WriteTimeout.new("write interrupted by an output timeout", bytes_written: offset)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
rescue Errno::EINTR
|
|
334
|
+
next
|
|
335
|
+
end
|
|
281
336
|
else
|
|
282
337
|
offset += count
|
|
283
338
|
end
|
|
@@ -316,7 +371,7 @@ class Expect
|
|
|
316
371
|
end
|
|
317
372
|
|
|
318
373
|
# 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
|
|
319
|
-
|
|
374
|
+
attr_reader :log_output
|
|
320
375
|
|
|
321
376
|
# 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
|
|
322
377
|
def log_output=(target)
|
|
@@ -335,8 +390,8 @@ class Expect
|
|
|
335
390
|
if target.respond_to?(:to_path) || target.is_a?(String)
|
|
336
391
|
raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
|
|
337
392
|
|
|
338
|
-
# 库打开的文件由
|
|
339
|
-
replace_log(File.open(target, "#{mode}b"), owned: true)
|
|
393
|
+
# 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
|
|
394
|
+
replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
|
|
340
395
|
else
|
|
341
396
|
raise ArgumentError, "provide a log target or a block" unless target
|
|
342
397
|
|
|
@@ -445,6 +500,12 @@ class Expect
|
|
|
445
500
|
# IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
|
|
446
501
|
@resources.close_handles
|
|
447
502
|
@closed = true
|
|
503
|
+
@interact_inputs&.each_value { |input| input.close(graceful: false) }
|
|
504
|
+
@interact_inputs&.clear
|
|
505
|
+
@interact_output = nil
|
|
506
|
+
@relay_outputs&.clear
|
|
507
|
+
@relay_history&.clear
|
|
508
|
+
@relay_callback = nil
|
|
448
509
|
return process_status unless @resources.owner == Process.pid && pid
|
|
449
510
|
return process_status if wait(timeout: timeout)
|
|
450
511
|
|
|
@@ -464,15 +525,16 @@ class Expect
|
|
|
464
525
|
raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
|
|
465
526
|
raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
|
|
466
527
|
|
|
467
|
-
@resources =
|
|
528
|
+
@resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
|
|
468
529
|
@pty = reader.tty?
|
|
469
530
|
@slave = slave
|
|
470
531
|
@configuration = Configuration.new(**self.class.configuration.to_h, **)
|
|
471
532
|
@buffer = "".b
|
|
472
533
|
@listeners = []
|
|
473
534
|
@sequences = {}
|
|
535
|
+
@relay_outputs = []
|
|
474
536
|
@closed = @eof = false
|
|
475
|
-
ObjectSpace.define_finalizer(self,
|
|
537
|
+
ObjectSpace.define_finalizer(self, SessionResources.finalizer(@resources))
|
|
476
538
|
end
|
|
477
539
|
|
|
478
540
|
# 开始新一轮等待时清除旧结果并应用缓冲上限,尚未消费的输入继续保留。
|
|
@@ -505,10 +567,16 @@ class Expect
|
|
|
505
567
|
@last_result
|
|
506
568
|
end
|
|
507
569
|
|
|
508
|
-
#
|
|
509
|
-
def read_available(propagate: true,
|
|
570
|
+
# 先将读取字节交给匹配或转接缓冲,再记录日志;日志失败也能恢复输入。
|
|
571
|
+
def read_available(propagate: true, buffer: @buffer)
|
|
510
572
|
return nil if eof?
|
|
511
573
|
|
|
574
|
+
# 写入背压也会读取;转接期间统一交给转义处理器,不能直接转发或另存匹配缓冲。
|
|
575
|
+
if @interaction_buffer
|
|
576
|
+
buffer = @interaction_buffer
|
|
577
|
+
propagate = false
|
|
578
|
+
end
|
|
579
|
+
|
|
512
580
|
begin
|
|
513
581
|
data = to_io.read_nonblock(READ_SIZE, exception: false)
|
|
514
582
|
rescue Errno::EIO
|
|
@@ -528,12 +596,10 @@ class Expect
|
|
|
528
596
|
return nil
|
|
529
597
|
end
|
|
530
598
|
data = data.b
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
trace("received #{data.inspect}", level: 2)
|
|
536
|
-
trace("buffer #{@buffer.inspect}", level: 3)
|
|
599
|
+
buffer << data
|
|
600
|
+
trim_buffer if buffer.equal?(@buffer)
|
|
601
|
+
trace("received #{data.inspect}", level: 2) if debug_level >= 2
|
|
602
|
+
trace("buffer #{@buffer.inspect}", level: 3) if debug_level >= 3
|
|
537
603
|
# 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
|
|
538
604
|
write_log(data)
|
|
539
605
|
propagate(data) if propagate
|
|
@@ -554,7 +620,15 @@ class Expect
|
|
|
554
620
|
|
|
555
621
|
# 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
|
|
556
622
|
def emit(target, data)
|
|
557
|
-
|
|
623
|
+
offset = 0
|
|
624
|
+
while offset < data.bytesize
|
|
625
|
+
count = target.write(data.byteslice(offset..))
|
|
626
|
+
unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
|
|
627
|
+
raise IOError, "write must return the number of accepted bytes"
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
offset += count
|
|
631
|
+
end
|
|
558
632
|
target.flush if target.respond_to?(:flush)
|
|
559
633
|
end
|
|
560
634
|
|
|
@@ -569,9 +643,10 @@ class Expect
|
|
|
569
643
|
# 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
|
|
570
644
|
return target if previous.equal?(target)
|
|
571
645
|
|
|
572
|
-
previous.close if @resources.
|
|
573
|
-
|
|
574
|
-
@resources.
|
|
646
|
+
previous.close if @resources.owned_log && previous && !previous.closed?
|
|
647
|
+
# 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
|
|
648
|
+
@resources.owned_log = owned ? target : nil
|
|
649
|
+
@log_output = target
|
|
575
650
|
target
|
|
576
651
|
rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
|
|
577
652
|
target.close if owned && target && !target.closed?
|
|
@@ -588,4 +663,4 @@ class Expect
|
|
|
588
663
|
end
|
|
589
664
|
end
|
|
590
665
|
|
|
591
|
-
require_relative "expect/
|
|
666
|
+
require_relative "expect/interaction"
|
data/script/ci
CHANGED
|
@@ -6,9 +6,9 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
|
|
6
6
|
bundle exec rake
|
|
7
7
|
bundle exec ruby examples/dialogue.rb
|
|
8
8
|
|
|
9
|
-
mkdir -p
|
|
9
|
+
mkdir -p tmp/ci
|
|
10
10
|
gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
|
|
11
|
-
gem_file="
|
|
11
|
+
gem_file="tmp/ci/expect-pty-${gem_version}.gem"
|
|
12
12
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
13
13
|
export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
|
|
14
14
|
fi
|
|
@@ -17,20 +17,44 @@ gem build expect-pty.gemspec --output "$gem_file"
|
|
|
17
17
|
# 在独立目录安装刚构建的包,避免源码路径或 Bundler 掩盖打包遗漏。
|
|
18
18
|
smoke_dir="$(mktemp -d)"
|
|
19
19
|
trap 'rm -rf "$smoke_dir"' EXIT
|
|
20
|
-
|
|
20
|
+
gem_file="$(pwd)/$gem_file"
|
|
21
|
+
bundler_version="$(bundle exec ruby -rbundler -e 'print Bundler::VERSION')"
|
|
22
|
+
bundler_gem="$(bundle exec ruby -e 'print Gem.loaded_specs.fetch("bundler").cache_file')"
|
|
21
23
|
|
|
22
24
|
(
|
|
23
25
|
cd "$smoke_dir"
|
|
24
|
-
|
|
26
|
+
# 清除 Bundler 的自动加载与路径设置;Ruby 4 还会通过 BUNDLER_SETUP 自动启用 Bundler。
|
|
27
|
+
for bundle_variable in ${!BUNDLE_@} ${!BUNDLER_@}; do
|
|
28
|
+
unset "$bundle_variable"
|
|
29
|
+
done
|
|
30
|
+
unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS
|
|
25
31
|
export GEM_HOME="$smoke_dir/gems" GEM_PATH="$smoke_dir/gems"
|
|
26
|
-
|
|
32
|
+
gem install --local --no-document "$gem_file"
|
|
33
|
+
|
|
34
|
+
cat > smoke.rb <<'RUBY'
|
|
35
|
+
require "expect/pty"
|
|
36
|
+
require "rbconfig"
|
|
37
|
+
|
|
27
38
|
spec = Gem.loaded_specs.fetch("expect-pty")
|
|
28
39
|
# 临时目录可能经由符号链接访问,先统一为实际路径再检查。
|
|
29
40
|
install_root = File.realpath(ENV.fetch("GEM_HOME"))
|
|
30
41
|
abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
|
|
42
|
+
abort "Bundler leaked into the plain gem smoke" if ARGV.first == "plain" && defined?(Bundler)
|
|
43
|
+
unexpected = %w[minitest rake rubocop parallel].select { |name| Gem.loaded_specs.key?(name) }
|
|
44
|
+
abort "development dependencies leaked into the application: #{unexpected.join(', ')}" unless unexpected.empty?
|
|
45
|
+
spec.runtime_dependencies.each do |dependency|
|
|
46
|
+
loaded = Gem.loaded_specs.fetch(dependency.name)
|
|
47
|
+
abort "incompatible dependency: #{dependency}" unless dependency.matches_spec?(loaded)
|
|
48
|
+
end
|
|
31
49
|
|
|
50
|
+
# 检查实际使用的标准库已在包元数据中声明,默认 gem 可加载不能代替依赖声明。
|
|
51
|
+
%w[forwardable io-console io-wait shellwords stringio].each do |name|
|
|
52
|
+
abort "undeclared runtime dependency: #{name}" unless spec.runtime_dependencies.any? { |dependency| dependency.name == name }
|
|
53
|
+
end
|
|
32
54
|
child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
|
|
33
|
-
Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do |session|
|
|
55
|
+
Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_stdout: false) do |session|
|
|
56
|
+
session.winsize = [30, 100]
|
|
57
|
+
abort "io-console terminal size failed" unless session.winsize == [30, 100]
|
|
34
58
|
abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
|
|
35
59
|
|
|
36
60
|
session.puts("expect-ruby")
|
|
@@ -39,6 +63,19 @@ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do
|
|
|
39
63
|
abort "installed gem child did not exit successfully" unless status&.success?
|
|
40
64
|
end
|
|
41
65
|
|
|
42
|
-
puts "Installed #{spec.full_name}: PTY
|
|
66
|
+
puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY and runtime dependencies passed"
|
|
43
67
|
RUBY
|
|
68
|
+
ruby smoke.rb plain
|
|
69
|
+
|
|
70
|
+
# 消费方仅声明当前 Gem,不能借用项目 Gemfile 中的测试和发布依赖。
|
|
71
|
+
# 单独安装本次开发检查使用的 Bundler,避免依赖宿主 Ruby 的默认 Bundler 副本。
|
|
72
|
+
if [ -f "$bundler_gem" ]; then
|
|
73
|
+
gem install --local --no-document "$bundler_gem"
|
|
74
|
+
else
|
|
75
|
+
gem install --no-document bundler --version "$bundler_version"
|
|
76
|
+
fi
|
|
77
|
+
export BUNDLE_IGNORE_CONFIG=true
|
|
78
|
+
printf 'source "https://rubygems.org"\ngem "expect-pty", "= %s", require: "expect/pty"\n' "$gem_version" > Gemfile
|
|
79
|
+
bundle lock --local
|
|
80
|
+
bundle exec ruby -rbundler -e 'Bundler.require; load "smoke.rb"' bundler
|
|
44
81
|
)
|
data/script/release.rb
CHANGED
|
@@ -16,11 +16,12 @@ class Release
|
|
|
16
16
|
REPOSITORY = "gatework/expect-ruby"
|
|
17
17
|
GEM_HOST = "https://rubygems.org"
|
|
18
18
|
|
|
19
|
-
def initialize(artifact: nil, dry_run: false)
|
|
19
|
+
def initialize(artifact: nil, dry_run: false, rubygems_only: false)
|
|
20
20
|
@version = Expect::VERSION
|
|
21
21
|
@tag = "v#{@version}"
|
|
22
|
-
@artifact = File.expand_path(artifact || "
|
|
22
|
+
@artifact = File.expand_path(artifact || "tmp/ci/expect-pty-#{@version}.gem")
|
|
23
23
|
@dry_run = dry_run
|
|
24
|
+
@rubygems_only = rubygems_only
|
|
24
25
|
@build = artifact.nil?
|
|
25
26
|
end
|
|
26
27
|
|
|
@@ -34,7 +35,7 @@ class Release
|
|
|
34
35
|
|
|
35
36
|
command("bash", "script/ci") if @build
|
|
36
37
|
# 独占目录中的副本贯穿校验和上传,其他构建不会改变本次发布的字节。
|
|
37
|
-
directory = File.join("
|
|
38
|
+
directory = File.join("tmp", "release", @version)
|
|
38
39
|
FileUtils.mkdir_p(directory)
|
|
39
40
|
directory = Dir.mktmpdir("candidate-", directory)
|
|
40
41
|
candidate = File.join(directory, "expect-pty-#{@version}.gem")
|
|
@@ -49,9 +50,13 @@ class Release
|
|
|
49
50
|
puts "Verified #{@tag}: #{@sha256}\nArtifact: #{@artifact}"
|
|
50
51
|
return puts "Dry run complete: #{@artifact}" if @dry_run
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
if @rubygems_only
|
|
54
|
+
verify_local_source
|
|
55
|
+
else
|
|
56
|
+
verify_remote_source
|
|
57
|
+
verify_registry_checksum(registry_version)
|
|
58
|
+
publish_github
|
|
59
|
+
end
|
|
55
60
|
publish_rubygems
|
|
56
61
|
end
|
|
57
62
|
|
|
@@ -97,10 +102,14 @@ class Release
|
|
|
97
102
|
JSON.parse(output)
|
|
98
103
|
end
|
|
99
104
|
|
|
105
|
+
def verify_local_source
|
|
106
|
+
return if capture("git", "rev-parse", "HEAD") == @commit && capture("git", "status", "--porcelain").empty?
|
|
107
|
+
|
|
108
|
+
raise "Source changed during verification; commit the changes and start again"
|
|
109
|
+
end
|
|
110
|
+
|
|
100
111
|
def verify_remote_source
|
|
101
|
-
|
|
102
|
-
raise "Source changed during verification; commit the changes and start again"
|
|
103
|
-
end
|
|
112
|
+
verify_local_source
|
|
104
113
|
|
|
105
114
|
comparison = github("compare/#{@commit}...main")
|
|
106
115
|
raise "Push this commit to #{REPOSITORY}/main first" unless %w[ahead identical].include?(comparison.fetch("status"))
|
|
@@ -249,7 +258,10 @@ end
|
|
|
249
258
|
if $PROGRAM_NAME == __FILE__
|
|
250
259
|
options = {}
|
|
251
260
|
parser = OptionParser.new do |arguments|
|
|
252
|
-
arguments.banner = "Usage: ruby script/release.rb [--dry-run] [--artifact PATH]"
|
|
261
|
+
arguments.banner = "Usage: ruby script/release.rb [--rubygems-only] [--dry-run] [--artifact PATH]"
|
|
262
|
+
arguments.on("--rubygems-only", "Publish only to RubyGems using the existing gem login; no GitHub access") do
|
|
263
|
+
options[:rubygems_only] = true
|
|
264
|
+
end
|
|
253
265
|
arguments.on("--artifact PATH", "Publish an already verified gem without rebuilding") do |path|
|
|
254
266
|
options[:artifact] = File.expand_path(path)
|
|
255
267
|
end
|
data/test/edge_case_test.rb
CHANGED
|
@@ -13,6 +13,74 @@ class EdgeCaseTest < ExpectTest
|
|
|
13
13
|
assert result.timeout?
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def test_zero_width_continuation_waits_for_new_input_before_matching_again
|
|
17
|
+
session, = pipe_session
|
|
18
|
+
session.buffer = "ready"
|
|
19
|
+
calls = 0
|
|
20
|
+
|
|
21
|
+
result = bounded(1) do
|
|
22
|
+
session.expect_result(timeout: 0.01) do
|
|
23
|
+
on(/(?=ready)/) do
|
|
24
|
+
calls += 1
|
|
25
|
+
Expect.continue
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
assert result.timeout?
|
|
31
|
+
assert_equal 1, calls
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_stalled_pattern_can_match_again_after_new_input
|
|
35
|
+
session, writer = pipe_session
|
|
36
|
+
session.buffer = "ready"
|
|
37
|
+
first_match = Queue.new
|
|
38
|
+
background do
|
|
39
|
+
first_match.pop
|
|
40
|
+
writer.write("!")
|
|
41
|
+
end
|
|
42
|
+
calls = 0
|
|
43
|
+
|
|
44
|
+
result = bounded(1) do
|
|
45
|
+
session.expect_result(timeout: 0.5) do
|
|
46
|
+
on(/(?=ready)/) do
|
|
47
|
+
calls += 1
|
|
48
|
+
first_match << true if calls == 1
|
|
49
|
+
calls == 1 ? Expect.continue : nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
assert result.matched?
|
|
55
|
+
assert_equal 2, calls
|
|
56
|
+
assert_equal "ready!", session.buffer
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_stalled_pattern_can_match_again_after_timeout_callback_changes_buffer
|
|
60
|
+
session, = pipe_session
|
|
61
|
+
session.buffer = "ready"
|
|
62
|
+
calls = 0
|
|
63
|
+
timeouts = 0
|
|
64
|
+
|
|
65
|
+
result = bounded(1) do
|
|
66
|
+
session.expect_result(timeout: 0.01) do
|
|
67
|
+
on(/(?=ready)/) do
|
|
68
|
+
calls += 1
|
|
69
|
+
calls == 1 ? Expect.continue : nil
|
|
70
|
+
end
|
|
71
|
+
timeout do
|
|
72
|
+
timeouts += 1
|
|
73
|
+
session.buffer = "ready!"
|
|
74
|
+
timeouts == 1 ? Expect.continue : nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
assert result.matched?
|
|
80
|
+
assert_equal 2, calls
|
|
81
|
+
assert_equal 1, timeouts
|
|
82
|
+
end
|
|
83
|
+
|
|
16
84
|
def test_stdout_works_with_utf8_banner_and_ascii_regexp
|
|
17
85
|
session, writer = pipe_session
|
|
18
86
|
writer.write("欢迎登录\nprompt>")
|
|
@@ -130,11 +198,44 @@ class EdgeCaseTest < ExpectTest
|
|
|
130
198
|
end
|
|
131
199
|
|
|
132
200
|
def test_gc_reclaims_abandoned_child
|
|
201
|
+
assert_gc_reclaims_abandoned_child
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def test_gc_reclaims_abandoned_child_with_log_callback_capturing_session
|
|
205
|
+
assert_gc_reclaims_abandoned_child(log_callback: true)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def test_invalid_options_raise_before_spawning
|
|
209
|
+
assert_raises(ArgumentError) { Expect.new(typo: true) }
|
|
210
|
+
session, = pipe_session
|
|
211
|
+
assert_raises(ArgumentError) { session.expect(0, "x") }
|
|
212
|
+
assert_raises(ArgumentError) { session.expect(["-unknown", "x"], timeout: 0) }
|
|
213
|
+
assert_raises(ArgumentError) { session.on_sequence("") }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def test_entrypoint_can_coexist_with_standard_library_expect
|
|
217
|
+
entrypoint = File.expand_path("../lib/expect/pty.rb", __dir__)
|
|
218
|
+
script = <<~RUBY
|
|
219
|
+
require "rbconfig"
|
|
220
|
+
require File.join(RbConfig::CONFIG.fetch("rubylibdir"), "expect.rb")
|
|
221
|
+
require ARGV.fetch(0)
|
|
222
|
+
puts [IO.method_defined?(:expect), defined?(Expect), Expect::VERSION].join(":")
|
|
223
|
+
RUBY
|
|
224
|
+
environment = { "RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil }
|
|
225
|
+
output, status = Open3.capture2e(environment, RbConfig.ruby, "-e", script, entrypoint)
|
|
226
|
+
assert status.success?, output
|
|
227
|
+
assert_equal "true:constant:#{Expect::VERSION}\n", output
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
private
|
|
231
|
+
|
|
232
|
+
def assert_gc_reclaims_abandoned_child(log_callback: false)
|
|
133
233
|
script = <<~RUBY
|
|
134
234
|
require "expect"
|
|
135
235
|
require "rbconfig"
|
|
136
236
|
def abandoned
|
|
137
237
|
session = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", "sleep 60", log_stdout: false)
|
|
238
|
+
session.log_to { |bytes| [session.pid, bytes] } if #{log_callback}
|
|
138
239
|
session.pid
|
|
139
240
|
end
|
|
140
241
|
# Ruby 的保守 GC 可能扫描到创建线程栈上残留的引用;先结束该线程,确保会话确实不可达。
|
|
@@ -158,26 +259,4 @@ class EdgeCaseTest < ExpectTest
|
|
|
158
259
|
assert status.success?, output
|
|
159
260
|
assert_equal "reaped\n", output
|
|
160
261
|
end
|
|
161
|
-
|
|
162
|
-
def test_invalid_options_raise_before_spawning
|
|
163
|
-
assert_raises(ArgumentError) { Expect.new(typo: true) }
|
|
164
|
-
session, = pipe_session
|
|
165
|
-
assert_raises(ArgumentError) { session.expect(0, "x") }
|
|
166
|
-
assert_raises(ArgumentError) { session.expect(["-unknown", "x"], timeout: 0) }
|
|
167
|
-
assert_raises(ArgumentError) { session.on_sequence("") }
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def test_entrypoint_can_coexist_with_standard_library_expect
|
|
171
|
-
entrypoint = File.expand_path("../lib/expect/pty.rb", __dir__)
|
|
172
|
-
script = <<~RUBY
|
|
173
|
-
require "rbconfig"
|
|
174
|
-
require File.join(RbConfig::CONFIG.fetch("rubylibdir"), "expect.rb")
|
|
175
|
-
require ARGV.fetch(0)
|
|
176
|
-
puts [IO.method_defined?(:expect), defined?(Expect), Expect::VERSION].join(":")
|
|
177
|
-
RUBY
|
|
178
|
-
environment = { "RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil }
|
|
179
|
-
output, status = Open3.capture2e(environment, RbConfig.ruby, "-e", script, entrypoint)
|
|
180
|
-
assert status.success?, output
|
|
181
|
-
assert_equal "true:constant:#{Expect::VERSION}\n", output
|
|
182
|
-
end
|
|
183
262
|
end
|