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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +29 -0
  3. data/CHANGELOG.md +25 -0
  4. data/Gemfile +10 -0
  5. data/LICENSE +21 -0
  6. data/README.md +264 -0
  7. data/Rakefile +38 -0
  8. data/docs/COMPATIBILITY.md +65 -0
  9. data/docs/RELEASING.md +55 -0
  10. data/docs/VERIFICATION.md +146 -0
  11. data/examples/dialogue.rb +30 -0
  12. data/examples/kibitz/README.md +73 -0
  13. data/examples/kibitz/kibitz.rb +139 -0
  14. data/examples/kibitz/test_kibitz.rb +37 -0
  15. data/examples/ssh_auto.rb +94 -0
  16. data/examples/ssh_interact.rb +159 -0
  17. data/examples/ssh_login.rb +64 -0
  18. data/expect-pty.gemspec +25 -0
  19. data/lib/expect/configuration.rb +113 -0
  20. data/lib/expect/engine.rb +141 -0
  21. data/lib/expect/interconnect.rb +170 -0
  22. data/lib/expect/pattern.rb +62 -0
  23. data/lib/expect/pattern_list.rb +90 -0
  24. data/lib/expect/pty.rb +4 -0
  25. data/lib/expect/resources.rb +63 -0
  26. data/lib/expect/result.rb +14 -0
  27. data/lib/expect/version.rb +6 -0
  28. data/lib/expect.rb +591 -0
  29. data/script/ci +44 -0
  30. data/script/release.rb +267 -0
  31. data/test/compare_upstream.rb +157 -0
  32. data/test/configuration_test.rb +90 -0
  33. data/test/edge_case_test.rb +183 -0
  34. data/test/fixtures/ssh_scripts/01_identity.sh +4 -0
  35. data/test/fixtures/ssh_scripts/02_output.sh +5 -0
  36. data/test/fixtures/ssh_scripts/03_delayed.sh +6 -0
  37. data/test/fixtures/ssh_scripts/04_failure.sh +2 -0
  38. data/test/fixtures/ssh_scripts/05_recovery.sh +3 -0
  39. data/test/integration/README.md +90 -0
  40. data/test/integration/ssh_scripts.rb +94 -0
  41. data/test/interact_test.rb +151 -0
  42. data/test/interconnect_test.rb +226 -0
  43. data/test/io_test.rb +184 -0
  44. data/test/kibitz_test.rb +45 -0
  45. data/test/matching_test.rb +178 -0
  46. data/test/multi_session_test.rb +66 -0
  47. data/test/process_test.rb +244 -0
  48. data/test/release_test.rb +153 -0
  49. data/test/ruby_api_test.rb +515 -0
  50. data/test/script_logging_test.rb +124 -0
  51. data/test/support/interact_probe.rb +125 -0
  52. data/test/support/kibitz_probe.rb +177 -0
  53. data/test/support/script_probe.rb +157 -0
  54. data/test/test_helper.rb +58 -0
  55. data/test/timeout_test.rb +170 -0
  56. 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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 提供明确的 Gem 加载入口,避免与 Ruby 标准库 expect.rb(IO#expect 扩展)同名冲突。
4
+ require_relative "../expect"
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Expect
4
+ # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
+ VERSION = "0.2.0"
6
+ end