expect-pty 0.2.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.
- checksums.yaml +7 -0
- data/.rubocop.yml +29 -0
- data/CHANGELOG.md +25 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +264 -0
- data/Rakefile +38 -0
- data/docs/COMPATIBILITY.md +65 -0
- data/docs/RELEASING.md +55 -0
- data/docs/VERIFICATION.md +146 -0
- data/examples/dialogue.rb +30 -0
- data/examples/kibitz/README.md +73 -0
- data/examples/kibitz/kibitz.rb +139 -0
- data/examples/kibitz/test_kibitz.rb +37 -0
- data/examples/ssh_auto.rb +94 -0
- data/examples/ssh_interact.rb +159 -0
- data/examples/ssh_login.rb +64 -0
- data/expect-pty.gemspec +25 -0
- data/lib/expect/configuration.rb +113 -0
- data/lib/expect/engine.rb +141 -0
- data/lib/expect/interconnect.rb +170 -0
- data/lib/expect/pattern.rb +62 -0
- data/lib/expect/pattern_list.rb +90 -0
- data/lib/expect/pty.rb +4 -0
- data/lib/expect/resources.rb +63 -0
- data/lib/expect/result.rb +14 -0
- data/lib/expect/version.rb +6 -0
- data/lib/expect.rb +591 -0
- data/script/ci +44 -0
- data/script/release.rb +267 -0
- data/test/compare_upstream.rb +157 -0
- data/test/configuration_test.rb +90 -0
- data/test/edge_case_test.rb +183 -0
- data/test/fixtures/ssh_scripts/01_identity.sh +4 -0
- data/test/fixtures/ssh_scripts/02_output.sh +5 -0
- data/test/fixtures/ssh_scripts/03_delayed.sh +6 -0
- data/test/fixtures/ssh_scripts/04_failure.sh +2 -0
- data/test/fixtures/ssh_scripts/05_recovery.sh +3 -0
- data/test/integration/README.md +90 -0
- data/test/integration/ssh_scripts.rb +94 -0
- data/test/interact_test.rb +151 -0
- data/test/interconnect_test.rb +226 -0
- data/test/io_test.rb +184 -0
- data/test/kibitz_test.rb +45 -0
- data/test/matching_test.rb +178 -0
- data/test/multi_session_test.rb +66 -0
- data/test/process_test.rb +244 -0
- data/test/release_test.rb +153 -0
- data/test/ruby_api_test.rb +515 -0
- data/test/script_logging_test.rb +124 -0
- data/test/support/interact_probe.rb +125 -0
- data/test/support/kibitz_probe.rb +177 -0
- data/test/support/script_probe.rb +157 -0
- data/test/test_helper.rb +58 -0
- data/test/timeout_test.rb +170 -0
- metadata +97 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Expect
|
|
4
|
+
# 一个已编号的文本模式或事件及其回调;匹配结果统一使用字节偏移,便于精确消费 IO 缓冲。
|
|
5
|
+
Pattern = Struct.new(:number, :value, :callback, keyword_init: true) do
|
|
6
|
+
# 将匹配会话或超时会话数组交给回调;额外上下文由调用方闭包保存。
|
|
7
|
+
def call(subject)
|
|
8
|
+
callback&.call(subject)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# 识别 EOF 事件,供引擎在源结束时单独派发。
|
|
12
|
+
def eof? = value == :eof
|
|
13
|
+
|
|
14
|
+
# 在缓冲中定位字符串或正则,返回 [字节偏移, 字节长度, 捕获组];事件或未匹配返回 nil。
|
|
15
|
+
def locate(buffer)
|
|
16
|
+
case value
|
|
17
|
+
when String
|
|
18
|
+
offset = buffer.index(value)
|
|
19
|
+
return [offset, value.bytesize, []] if offset
|
|
20
|
+
when Regexp
|
|
21
|
+
text = buffer.dup
|
|
22
|
+
text.force_encoding(value.encoding) if value.fixed_encoding?
|
|
23
|
+
unless text.valid_encoding?
|
|
24
|
+
# 一次读取可能截断 UTF-8 字符。仅对完整前缀做本轮匹配,原缓冲保留残片等待后续字节。
|
|
25
|
+
text = complete_prefix(text)
|
|
26
|
+
end
|
|
27
|
+
found = value.match(text)
|
|
28
|
+
return unless found
|
|
29
|
+
|
|
30
|
+
# Ruby 正则偏移按字符计算,缓冲切片按字节计算,必须转换;捕获组也统一返回字节串。
|
|
31
|
+
offset = text[0...found.begin(0)].bytesize
|
|
32
|
+
return [offset, found[0].bytesize, found.captures.map { |capture| capture&.b }]
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# 仅容忍末尾尚未收全的 UTF-8 字符,其他非法编码直接报错,不静默替换接收字节。
|
|
40
|
+
def complete_prefix(text)
|
|
41
|
+
if text.encoding == Encoding::UTF_8
|
|
42
|
+
# UTF-8 字符最多四字节,不完整后缀最多三字节。逐一验证头字节、续字节及剩余前缀。
|
|
43
|
+
1.upto([3, text.bytesize].min) do |length|
|
|
44
|
+
prefix = text.byteslice(0, text.bytesize - length)
|
|
45
|
+
suffix = text.byteslice(text.bytesize - length, length).b
|
|
46
|
+
lead = suffix.getbyte(0)
|
|
47
|
+
expected = case lead
|
|
48
|
+
when 0xC2..0xDF then 2
|
|
49
|
+
when 0xE0..0xEF then 3
|
|
50
|
+
when 0xF0..0xF4 then 4
|
|
51
|
+
end
|
|
52
|
+
if expected && length < expected && suffix.bytes.drop(1).all? do |byte|
|
|
53
|
+
(0x80..0xBF).cover?(byte)
|
|
54
|
+
end && prefix.valid_encoding?
|
|
55
|
+
return prefix
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
raise EncodingError, "received invalid #{text.encoding} data; use a binary regexp (/.../n) for binary streams"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Expect
|
|
4
|
+
# 将 Ruby 模式和块回调整理为有序会话组;注册阶段不读取 IO、不执行匹配回调。
|
|
5
|
+
class PatternList
|
|
6
|
+
attr_reader :groups, :timeout_pattern
|
|
7
|
+
|
|
8
|
+
# 建立默认来源,并将位置参数中的文本、正则、:eof、:timeout 转成统一模式。
|
|
9
|
+
def initialize(sessions = [], patterns = [])
|
|
10
|
+
@default_sessions = Array(sessions).dup
|
|
11
|
+
validate_sessions!(@default_sessions) unless @default_sessions.empty?
|
|
12
|
+
# 保留空模式的默认会话组,使 expect(timeout:) 也能只收集输出而不匹配文本。
|
|
13
|
+
@groups = @default_sessions.empty? ? [] : [[@default_sessions, []]]
|
|
14
|
+
@number = 0
|
|
15
|
+
patterns.each do |value|
|
|
16
|
+
case value
|
|
17
|
+
when :eof then eof
|
|
18
|
+
when :timeout then timeout
|
|
19
|
+
else on(value)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 注册文本模式;字符串始终字面匹配并复制冻结,避免注册后被外部修改。
|
|
25
|
+
def on(value, from: @default_sessions, &block)
|
|
26
|
+
raise ArgumentError, "pattern must be a String or Regexp" unless value.is_a?(String) || value.is_a?(Regexp)
|
|
27
|
+
|
|
28
|
+
add(value.is_a?(String) ? value.b.freeze : value, from, block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 为指定来源注册 EOF 回调;实例 DSL 默认使用当前会话。
|
|
32
|
+
def eof(from: @default_sessions, &block)
|
|
33
|
+
add(:eof, from, block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 注册一次等待的唯一超时回调,重复定义直接报错,避免悄悄覆盖业务处理。
|
|
37
|
+
def timeout(&block)
|
|
38
|
+
raise ArgumentError, "timeout callback already registered" if @timeout_pattern
|
|
39
|
+
|
|
40
|
+
@timeout_pattern = build(:timeout, block)
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 汇总并去重读取源,同一会话出现在多个模式组时仍只读取一次。
|
|
45
|
+
def sessions = groups.flat_map(&:first).uniq
|
|
46
|
+
|
|
47
|
+
# 收集指定会话的所有 EOF 处理器,保留原注册顺序。
|
|
48
|
+
def eof_patterns_for(session)
|
|
49
|
+
groups.flat_map do |sessions, patterns|
|
|
50
|
+
sessions.include?(session) ? patterns.select(&:eof?) : []
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 启动引擎前确保存在读取源;类级等待必须通过 from: 明确来源。
|
|
55
|
+
def validate!
|
|
56
|
+
raise ArgumentError, "at least one session is required" if groups.empty?
|
|
57
|
+
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# 校验并复制来源列表,将模式加入相邻的相同来源组或新建组。
|
|
64
|
+
def add(value, from, callback)
|
|
65
|
+
sessions = Array(from).dup
|
|
66
|
+
validate_sessions!(sessions)
|
|
67
|
+
pattern = build(value, callback)
|
|
68
|
+
# 仅合并相邻的相同来源,保持声明顺序;跨组复用会话由引擎去重读取。
|
|
69
|
+
if groups.last&.first == sessions
|
|
70
|
+
groups.last.last << pattern
|
|
71
|
+
else
|
|
72
|
+
groups << [sessions, [pattern]]
|
|
73
|
+
end
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# 按注册顺序分配从 1 开始的序号,文本模式与事件共用编号。
|
|
78
|
+
def build(value, callback)
|
|
79
|
+
@number += 1
|
|
80
|
+
Pattern.new(number: @number, value: value, callback: callback)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# 拒绝空来源和非 Expect 对象,在任何 IO 读取之前暴露调用错误。
|
|
84
|
+
def validate_sessions!(sessions)
|
|
85
|
+
return if !sessions.empty? && sessions.all?(Expect)
|
|
86
|
+
|
|
87
|
+
raise ArgumentError, "patterns require Expect sessions; specify from: for class-level waits"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/expect/pty.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Expect
|
|
4
|
+
# 独立保存句柄、PID 和日志所有权,让终结器无需直接捕获会话即可清理遗弃资源。
|
|
5
|
+
class Resources
|
|
6
|
+
attr_accessor :pid, :status, :log, :own_log
|
|
7
|
+
attr_reader :reader, :writer, :slave, :owner, :own
|
|
8
|
+
|
|
9
|
+
# 记录创建资源的进程;fork 后的副本不能向父进程拥有的子进程发信号。
|
|
10
|
+
def initialize(reader, writer:, slave: nil, own: false)
|
|
11
|
+
@reader = reader
|
|
12
|
+
@writer = writer
|
|
13
|
+
@slave = slave
|
|
14
|
+
@own = own
|
|
15
|
+
@owner = Process.pid
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# 只关闭由本库拥有的 IO;借用的 reader、writer 由调用方管理。
|
|
19
|
+
def close_handles
|
|
20
|
+
return unless own
|
|
21
|
+
|
|
22
|
+
# PTY 的读写端可能是同一个对象,先去重,重复关闭也保持安全。
|
|
23
|
+
[reader, writer, slave].compact.uniq.each { |io| io.close unless io.closed? }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 非阻塞回收直属子进程,缓存 Process::Status,成功后清空 PID 以支持重复查询。
|
|
27
|
+
def reap
|
|
28
|
+
return status unless pid && owner == Process.pid
|
|
29
|
+
|
|
30
|
+
if (reaped = Process.waitpid2(pid, Process::WNOHANG))
|
|
31
|
+
@status = reaped.last
|
|
32
|
+
@pid = nil
|
|
33
|
+
end
|
|
34
|
+
status
|
|
35
|
+
rescue Errno::ECHILD
|
|
36
|
+
# 子进程可能已被调用方或其他线程回收,不再保留可能被系统复用的 PID。
|
|
37
|
+
@pid = nil
|
|
38
|
+
status
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# GC 兜底关闭所属句柄和日志,并强制终止尚存活的子进程;不执行软关闭等待。
|
|
42
|
+
def finalize
|
|
43
|
+
return unless owner == Process.pid
|
|
44
|
+
|
|
45
|
+
close_handles
|
|
46
|
+
log.close if own_log && log && !log.closed?
|
|
47
|
+
reap
|
|
48
|
+
return unless pid
|
|
49
|
+
|
|
50
|
+
Process.kill("KILL", pid)
|
|
51
|
+
# 将最终 wait 交给后台回收线程,避免在 GC 终结器中阻塞等待。
|
|
52
|
+
Process.detach(pid)
|
|
53
|
+
@pid = nil
|
|
54
|
+
rescue IOError, SystemCallError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# 构造只持有资源对象的终结回调,避免闭包中的 self 绑定到会话而妨碍回收。
|
|
59
|
+
def self.finalizer(resources)
|
|
60
|
+
proc { resources.finalize }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Expect
|
|
4
|
+
# 保存一次等待的结果,保留 Struct 原生数组、Hash 转换和模式解构能力。
|
|
5
|
+
# number 仅在文本匹配时存在;error 为 :timeout、:eof 或原始 IO 异常,文本字段均为字节串。
|
|
6
|
+
Result = Struct.new(:number, :error, :match, :before, :after, :session, :captures, keyword_init: true) do
|
|
7
|
+
# 是否命中文本模式;事件不会返回模式序号。
|
|
8
|
+
def matched? = !number.nil?
|
|
9
|
+
# 是否因本次等待期限到达而返回。
|
|
10
|
+
def timeout? = error == :timeout
|
|
11
|
+
# 是否读取源已经结束;子进程是否退出仍应查询会话的 process_status。
|
|
12
|
+
def eof? = error == :eof
|
|
13
|
+
end
|
|
14
|
+
end
|