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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5ed8a06887893ae6dff3edc0e378cfea6ae3b2c05b5800f7f00096b3d2389ecd
4
+ data.tar.gz: b8d38b25c886080b2883d6c823087bb3962185b2a2cf414222eff8ffbf8b4d2a
5
+ SHA512:
6
+ metadata.gz: 7ed8785fd0f71ffbccaad9057248eb4f8aff91b9bab1b7b562eb63f9e6210149303e86c516921f4ba452239407220a048e4f8c2fefdcf9d4efb05de21011f23f
7
+ data.tar.gz: 938c119f3c43bee28e94f16f72029e34c506882f2ce50b118cb739fbefcac454689d8f9cf6552c68572b27c05661e2d0df7954ab8720a3876797910209806e73
data/.rubocop.yml ADDED
@@ -0,0 +1,29 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.2
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - "pkg/**/*"
7
+ - "tmp/**/*"
8
+ - "vendor/**/*"
9
+
10
+ # Protocol state machines and integration scenarios are reviewed by behavior,
11
+ # rather than split merely to meet a line or branch count.
12
+ Metrics:
13
+ Enabled: false
14
+
15
+ Style/StringLiterals:
16
+ EnforcedStyle: double_quotes
17
+
18
+ Style/StringLiteralsInInterpolation:
19
+ EnforcedStyle: double_quotes
20
+
21
+ Style/Documentation:
22
+ Exclude:
23
+ - "test/**/*"
24
+
25
+ Naming/RescuedExceptionsVariableName:
26
+ PreferredName: error
27
+
28
+ Layout/LineLength:
29
+ Max: 120
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.2.0 - 2026-09-13
6
+
7
+ - **不兼容变更**:接口统一采用 Ruby 属性、谓词、关键字参数、原生正则和块回调,移除 `exp_*`、`get/set_accum`、位置超时和数组回调等旧入口,不提供兼容别名,迁移方式见 `docs/COMPATIBILITY.md`。
8
+ - **配置与日志**:通过 `Expect.configure` 设置默认值,每个会话独立管理缓冲、日志、终端模式和超时策略,默认关闭 stdout 输出,日志入口统一为 `log_output`、`log_to` 和 `write_log`。
9
+ - **匹配与多会话**:`expect` 返回模式序号,`expect_result` 返回七字段 Struct,错误保留为 `:timeout`、`:eof` 或原始 IO 异常,`from:` 可选择会话,超时块接收全部活跃会话。
10
+ - **输入与转接**:`write`、`puts` 和 `<<` 遵循 Ruby IO 语义,`send` 保留反射用途,`send_slow(delay:)` 支持逐字符发送,`on_sequence` 使用闭包和 Ruby 真值控制转接。
11
+ - **进程清理**:`soft_close` 收集尾部输出并最多发送 TERM,`hard_close` 必要时发送 KILL,两者返回进程状态,块和 `close(graceful:)` 在异常路径也完成资源回收。
12
+ - **SSH 与双终端示例**:提供自动执行后切换人工交互的 SSH 示例和 Kibitz 双终端共享示例,支持转义退出、终端恢复和会话日志。
13
+ - **安装与运行**:通过 `gem install expect-pty` 安装,使用 `require "expect/pty"` 加载,支持 Linux/macOS 与 Ruby 3.2 及以上版本,运行时仅依赖标准库。
14
+
15
+ ## 0.1.1
16
+
17
+ - 补齐 `test_handles` 等待期限和 `set_seq` 的 Ruby 正则序列,覆盖跨读取匹配和回调继续执行。
18
+ - 日志在实际读取时记录,避免切换 `expect` / `interconnect` 重复写入;日志目标的 IO 错误不再被误判为子进程 EOF。
19
+ - 新增真实 SSH 多脚本日志验证、5 个 shell fixtures 和自动回归测试;安装包包含测试代码、说明和 Rake 入口。
20
+
21
+ ## 0.1.0
22
+
23
+ - 实现 Expect.pm 风格的 Ruby PTY 自动交互:模式、回调、超时、EOF、多会话和缓冲管理。
24
+ - 支持真实 IO 适配、日志、慢速发送、双向写入背压、人工交互转接及终端恢复。
25
+ - 提供受控进程关闭与回收、中文/二进制匹配、Ruby gem 打包和可重复执行的 SSH 验证示例。
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+ gemspec
5
+
6
+ gem "minitest", "~> 5.0"
7
+ # RuboCop's worker dependency must also support the minimum Ruby version.
8
+ gem "parallel", "~> 1.27", require: false
9
+ gem "rake", "~> 13.0"
10
+ gem "rubocop", "~> 1.89", require: false
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 expect-pty contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # expect-ruby
2
+
3
+ [![CI](https://github.com/gatework/expect-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/gatework/expect-ruby/actions/workflows/ci.yml)
4
+
5
+ 用 Ruby 自动操作交互式程序:启动拥有控制终端的子进程,等待文本或正则,发送输入,处理超时、EOF 和回调,也能接管已有 IO、同时监听多个会话和转接人工交互。交互能力参考 [Expect.pm](https://github.com/jacoby/expect.pm),接口采用 Ruby 的属性、关键字参数和代码块。
6
+
7
+ 要求 **Ruby 3.2+、POSIX 系统(Linux/macOS)**。运行时仅使用 Ruby 标准库。推荐入口 **`require "expect/pty"`**;本项目提供独立的 `Expect` 类,不修改标准库的 `IO#expect`。
8
+
9
+ ## 安装和运行
10
+
11
+ 项目和仓库名为 `expect-ruby`,Gem 名为 `expect-pty`。在应用的 Gemfile 中添加以下内容,然后运行 `bundle install`:
12
+
13
+ ```ruby
14
+ gem "expect-pty", "~> 0.2.0"
15
+ ```
16
+
17
+ 也可直接执行 `gem install expect-pty`。需要跟随开发分支时,可从 GitHub 安装:
18
+
19
+ ```ruby
20
+ gem "expect-pty", git: "https://github.com/gatework/expect-ruby.git", branch: "main"
21
+ ```
22
+
23
+ 本地开发可改用 `gem "expect-pty", path: "/path/to/expect-ruby"`,也可在源码目录构建安装:
24
+
25
+ ```sh
26
+ gem build expect-pty.gemspec
27
+ gem install ./expect-pty-0.2.0.gem
28
+ ```
29
+
30
+ ```ruby
31
+ require "expect/pty"
32
+
33
+ Expect.spawn("/bin/sh", "-i") do |shell|
34
+ shell.puts("printf 'hello ruby\\n'")
35
+ if shell.expect(/^hello ruby\r?$/, timeout: 3)
36
+ puts shell.match
37
+ else
38
+ warn shell.error
39
+ end
40
+ shell.puts("exit")
41
+ shell.soft_close(timeout: 2)
42
+ end
43
+ ```
44
+
45
+ 块返回其执行结果,退出时关闭会话并回收子进程,异常和 `break` 也执行清理。无块形式需用 `ensure` 显式调用 `close`。`Expect.new` 可以先创建 PTY、设置 `slave.echo` / `slave.winsize`,然后调用实例的 `spawn`。
46
+
47
+ 多个命令参数原样传给 Ruby `exec`;单个命令字符串使用 Ruby 的 shell 语义。不可信参数应使用独立参数形式。支持 `env: { "NAME" => "value" }` 和 `chdir: "/path"`。同一会话只能启动一次,启动失败抛出 `Expect::SpawnError`。
48
+
49
+ ## 配置与属性
50
+
51
+ ```ruby
52
+ Expect.configure do |config|
53
+ config.timeout = 10
54
+ config.buffer_limit = 65_536
55
+ config.graceful_close = true
56
+ end
57
+
58
+ Expect.configure(debug_level: 0) # 也可用关键字修改默认值
59
+ Expect.configuration.timeout # 默认值快照,只读
60
+
61
+ Expect.spawn("/bin/sh", "-i", timeout: 3) do |session|
62
+ session.timeout = 5
63
+ session.log_stdout = true
64
+ session.raw_pty? # 布尔属性用问号方法查询
65
+ session.puts("exit")
66
+ end
67
+ ```
68
+
69
+ `configure` 校验后发布冻结的配置对象;块异常不会发布部分修改。每个会话独立持有配置,优先使用构造参数;修改默认值不会改变已有会话,修改一个会话也不会影响其他会话。子类继承父类默认配置,可独立覆盖。
70
+
71
+ | 属性 | 默认值 | 行为 |
72
+ | --- | --- | --- |
73
+ | `timeout` | `nil` | 等待匹配的默认超时,秒;`nil` 无限,`0` 非阻塞轮询 |
74
+ | `write_timeout` | `nil` | 写入遇到背压时的超时,秒 |
75
+ | `buffer_limit` | `nil` | 接收缓冲最多保留的字节数;正整数或 `nil`(无限) |
76
+ | `debug_level` | `0` | `1` 生命周期和匹配,`2` 加收发内容,`3` 加缓冲内容 |
77
+ | `raw_pty` | `false` | spawn 前将 slave 设为 raw,禁用回显和换行转换 |
78
+ | `preserve_buffer` | `false` | 匹配后保留完整缓冲 |
79
+ | `log_stdout` | `false` | 将接收内容输出到 `$stdout` |
80
+ | `log_listeners` | `true` | 将接收内容转发给 `listeners` |
81
+ | `raw_terminal` | `true` | 转接期间自动设置并恢复终端 raw 模式 |
82
+ | `reset_timeout_on_read` | `false` | 每次收到数据时重置匹配期限 |
83
+ | `graceful_close` | `false` | `close` 先尝试软关闭,再完成强制清理 |
84
+
85
+ 布尔属性均提供 `name`、`name?` 和 `name=`,按 Ruby 真值规则转换:仅 `nil` / `false` 为假,`0` 为真。超时必须有限且非负,`nil` 表示无限;无效赋值不改变原值。`debug_level` 仅接受整数 `0..3`。
86
+
87
+ ## 等待和匹配
88
+
89
+ ```ruby
90
+ session.expect("literal text", /value=(\d+)/, timeout: 5)
91
+ session.timeout = 5
92
+ session.expect("ready") # 使用会话超时
93
+ session.expect("ready", timeout: nil) # 无限等待
94
+ session.expect("ready", timeout: 0) # 匹配现有缓冲,并最多轮询读取一次
95
+ session.expect(timeout: 1) # 仅收集输出,直到超时或 EOF
96
+ ```
97
+
98
+ 字符串始终按字面匹配,包括 `"-i"`、`"-re"`、`"timeout"` 和 `"eof"`;正则直接使用 Ruby `Regexp`。按声明顺序选择第一个能匹配的模式,不按它们在文本中的位置排序。返回模式的 **1 起始序号**,超时、EOF 或 IO 错误返回 `nil`。超时使用单调时钟。
99
+
100
+ ```ruby
101
+ result = session.expect_result(/value=(\d+)/, timeout: 3)
102
+ result.matched?
103
+ result.timeout?
104
+ result.eof?
105
+ result.number
106
+ result.captures
107
+ result.session
108
+ result.error # nil、:timeout、:eof 或原始 IOError / SystemCallError 对象
109
+
110
+ number, error, match, before, after, session, captures = result.to_a
111
+ ```
112
+
113
+ `Result` 使用原生 Ruby `Struct`,支持 `to_a`、`to_h`、模式解构;没有隐式 `to_ary`。会话提供 `last_result`,以及 `match`、`before`、`after`、`match_number`、`captures`、`error` 快捷读取方法。
114
+
115
+ 成功匹配后删除匹配内容及其之前的内容,尾部留给下次匹配;超时保留缓冲,EOF 将未匹配内容放入 `before` 并清空缓冲。EOF 与子进程退出是不同事件,使用 `wait` / `process_status` 判断进程结果。底层 IO 错误保留原始异常,回调中的普通异常直接抛出。
116
+
117
+ 接收缓冲、匹配和捕获值为 `ASCII-8BIT` 字节串,保留控制字符、NUL 和无效 UTF-8。UTF-8 正则支持跨读取拆开的字符;显示捕获内容时可 `.force_encoding("UTF-8")`。任意二进制流请用字面字符串或二进制正则 `/.../n`。固定 UTF-8 正则遇到无效数据抛出 `EncodingError`。缓冲上限按字节截断,应为文本设置足够的上限。
118
+
119
+ 正则完全遵循 Ruby:`^` / `$` 是行锚点,`\A` / `\z` 是整个缓冲的锚点,`/m` 让点号匹配换行;不再提供全局正则模式开关。
120
+
121
+ ## 回调、事件与多会话
122
+
123
+ ```ruby
124
+ session.expect(timeout: 10) do
125
+ on(/username:\s*/i) do |connection|
126
+ connection.puts("demo")
127
+ connection.continue
128
+ end
129
+ on(/password:\s*/i) do |connection|
130
+ connection.puts(password)
131
+ connection.continue(reset_timeout: false)
132
+ end
133
+ on("ready>")
134
+ eof { |connection| warn "EOF: #{connection.before}" }
135
+ timeout { |sessions| warn "timeout: #{sessions.length} session(s)" }
136
+ end
137
+ ```
138
+
139
+ 回调通过闭包访问局部变量。无参数声明块在模式构建器中执行;希望保留调用方 `self` 时使用 `do |patterns|`,调用 `patterns.on(...)`。所有模式注册完成后才读取 IO;注册异常或 `break` 不消费输入。块和位置模式不能混用。`expect_result` 支持同样的声明方式。
140
+
141
+ `continue` 继续等待并重新计时;`continue(reset_timeout: false)` 保留原期限,类和实例均可调用。无回调或返回其他值时结束本次匹配。超时回调只有返回重置计时的 `continue` 才再次等待。EOF 回调继续时移除该源并等待其余会话,全部 EOF 时立即返回。
142
+
143
+ `eof` / `timeout` 声明会占用模式序号,但事件返回的 `number` 为 `nil`。一个等待只能注册一个超时回调;它接收**所有仍在监听的会话**。不需要回调时,可将 `:eof` / `:timeout` 作为位置事件参数。
144
+
145
+ ```ruby
146
+ Expect.expect(timeout: 5) do
147
+ on(/ready/, from: [first, second]) { |connection| puts connection.inspect }
148
+ on("done", from: third)
149
+ end
150
+
151
+ Expect.expect("ready", from: [first, second], timeout: 5)
152
+ ```
153
+
154
+ `from:` 指定一个或多个会话;实例块默认当前会话,类方法需提供来源。相邻且来源列表相同的模式组成一组,按组、会话、模式顺序匹配。类方法省略超时使用 `Expect.configuration.timeout`。
155
+
156
+ `preserve_buffer = true` 时,继续回调应自行消费匹配,例如 `connection.buffer = connection.after`,避免重复匹配同一内容。无限超时与不消费缓冲的继续回调可以无限循环。被信号中断的匹配 select/read 会自动重试,保留原期限。
157
+
158
+ ## 已有 IO、写入和终端
159
+
160
+ ```ruby
161
+ Expect.open(socket) do |connection|
162
+ connection.expect("prompt>", timeout: 5)
163
+ connection.puts("command")
164
+ end
165
+
166
+ ready = Expect.readable_sessions(first, second, timeout: 5)
167
+ ```
168
+
169
+ `Expect.open` 支持可 `select` 的 File、管道、Socket 和 PTY,`writer:` 可指定独立写端。默认借用 IO,关闭会话不关闭原始 IO;`own: true` 转移关闭责任,初始化失败也会释放接管的 IO。`StringIO` 可以用作日志和监听器,不能用作读取会话。
170
+
171
+ `readable_sessions` 返回可读的会话对象数组,不消费数据、不包含已关闭会话、同一会话只返回一次。默认 `timeout: 0`;`nil` 无限等待。同一会话应由一个读取者驱动,多会话共同监听使用 `Expect.expect`。
172
+
173
+ | API | 行为 |
174
+ | --- | --- |
175
+ | `write(*objects)` | 通过 `to_s` 转换并写入所有字节,返回字节数 |
176
+ | `puts(*objects)` | 原生 IO 风格的换行、数组递归和 `nil` 返回值 |
177
+ | `session << object` | 写入并返回会话,可链式追加 |
178
+ | `send_slow(*objects, delay:)` | 每个字符之前等待指定秒数,同时收集返回数据 |
179
+ | `buffer` / `buffer=` | 获取副本 / 复制字节并应用上限 |
180
+ | `clear_buffer` | 清空缓冲并返回旧内容 |
181
+ | `stty("raw -echo")` / `stty` | 修改终端模式 / 获取可恢复的模式字符串 |
182
+ | `winsize` / `winsize=` | 读取/修改 `[rows, cols]`,由内核通知前台进程 |
183
+ | `slave` / `tty_name` / `to_io` / `writer` / `fileno` / `tty?` | 底层 IO 和终端信息 |
184
+
185
+ 大块写入遇到背压时同时读取输出,避免双向传输互相阻塞。超过 `write_timeout` 抛出 `Expect::WriteTimeout`,已写入字节不回滚。控制字符可直接发送,例如 `session.write("\x03")`,其信号作用取决于终端设置。`send`、`public_send`、`__send__` 保留 Ruby 反射语义。
186
+
187
+ ## 日志与人工交互
188
+
189
+ ```ruby
190
+ session.log_to("session.log") # 文件追加
191
+ session.log_to("session.log", mode: "w") # 文件覆盖
192
+ session.log_to { |bytes| custom_logger.call(bytes) }
193
+ session.log_output = output_io # 借用 IO 或 callable
194
+ session.write_log("annotation\n")
195
+ session.log_output = nil # 关闭本库打开的文件,借用的 IO 保留
196
+ session.listeners = [output_io, another_session]
197
+ session.log_listeners = false
198
+ ```
199
+
200
+ 日志读取用 `log_output`,设置用 `log_output=`,打开路径或注册日志块用 `log_to`。不能同时提供日志目标与块。`listeners` 返回列表副本,`listeners = []` 清空;替换无效目标不会丢失原目标。
201
+
202
+ 所有会话默认不输出到 stdout。日志仅记录实际读取的接收字节;写入不重复记录,终端回显可能作为接收内容返回。密码交互应关闭日志、调试,并确保被控程序不回显密码。
203
+
204
+ ```ruby
205
+ session.interact(input: $stdin, escape: "\x1d", output: $stdout) # Ctrl-]
206
+
207
+ Expect.open($stdin) do |input|
208
+ input.listeners = [session]
209
+ session.listeners = [$stdout]
210
+ input.on_sequence("\x1d") { false }
211
+ Expect.interconnect(input, session, timeout: 60)
212
+ end
213
+ ```
214
+
215
+ `on_sequence(sequence) { ... }` 注册字符串、原生正则或 `:eof`,通过闭包传递参数。无回调、返回 `nil` / `false` 停止,其他 Ruby 真值(包括 `0`)继续;字符串 `"EOF"` 按字面匹配。转接返回导致停止的会话,超时或所有 EOF 回调均继续时返回 `nil`。
216
+
217
+ 字面转义可以跨读取完整过滤,尾部留给下次调用。正则转义使用受 `buffer_limit` 限制的历史记录,已实时转发的前缀无法撤回;零长度正则匹配抛出 `ArgumentError`。日志始终记录原始接收字节,包括被过滤的转义,在 `expect` / `interconnect` 之间切换也不会重复记录。
218
+
219
+ 转接会自动设置并恢复终端 raw 模式;`raw_terminal = false` 将设置交给调用方。`interact` 还会恢复临时监听组、日志开关和转义设置,包括超时和异常路径。
220
+
221
+ ## 软关闭、硬关闭与进程状态
222
+
223
+ ```ruby
224
+ status = session.soft_close(timeout: 3, term_timeout: 1)
225
+ status ||= session.hard_close(timeout: 0.2)
226
+
227
+ session.close(graceful: true) # 先软关闭,必要时继续硬关闭
228
+ ```
229
+
230
+ - `soft_close`:等待自然 EOF 并收集尾部输出,然后关闭所属 IO、等待进程退出;超时后最多发送 TERM,**不发送 KILL**。`timeout:` 是自然退出阶段的期限(默认 15 秒),`term_timeout:` 是发 TERM 后的等待时间(默认 1 秒)。未退出返回 `nil`,保留 PID,可继续 `wait` 或 `hard_close`。
231
+ - `hard_close`:立即关闭所属 IO,不收集尾部输出;等待 `timeout:`,必要时发送 TERM 再等待同样时长,仍未退出则 KILL 并最多等待 1 秒。默认 `timeout: 0.2`,必须有限。
232
+ - 两者返回已回收的 `Process::Status`,没有子进程或尚未回收时返回 `nil`;重复调用保留已获得的状态。借用 IO 不关闭。
233
+ - `close(graceful: graceful_close?)`:可选先软关闭,`ensure` 中硬关闭,返回 `nil`。块生命周期使用它完成清理;软关闭发生日志异常时也会回收子进程。
234
+ - `wait(timeout: nil)`:等待并回收,返回 `Process::Status`;超时返回 `nil`。`process_status` 非阻塞查询,`exit_code` 读取普通退出码,信号退出看 `process_status.termsig`。
235
+ - `closed?` 表示会话 IO 已关闭;`alive?` / `pid` 表示子进程状态。软关闭后可能同时 `closed? == true`、`alive? == true`。成功回收后 PID 为 `nil`。
236
+
237
+ 关闭只负责会话直接启动的子进程;垃圾回收提供非阻塞的强制清理兜底,不执行软关闭等待。优先使用块或 `ensure` 管理资源。
238
+
239
+ ## 示例和验证
240
+
241
+ ```sh
242
+ bundle install
243
+ bundle exec rake # RuboCop + 完整测试
244
+ script/ci # 与 CI 相同:检查、测试、构建和隔离安装验证
245
+ ruby examples/dialogue.rb
246
+ ruby examples/kibitz/test_kibitz.rb
247
+ ruby examples/ssh_auto.rb # 登录后执行命令,再交给人工输入
248
+ ruby examples/ssh_auto.rb --no-interact
249
+ ruby examples/ssh_interact.rb --auto
250
+ ```
251
+
252
+ 普通测试使用真实 PTY、管道和 socket,无需 SSH 服务或账户。Kibitz 的双终端示例与验证见 [examples/kibitz/](examples/kibitz/README.md)。
253
+
254
+ [GitHub Actions](https://github.com/gatework/expect-ruby/actions/workflows/ci.yml) 在推送 `main`、推送 `v*` 标签、提交到 `main` 的 Pull Request 或手动触发时运行。流水线覆盖 Ubuntu 24.04 / macOS 15 与 Ruby 3.2、3.3、3.4、4.0 的 8 种组合;每个环境执行 `script/ci`,包括真实 PTY 测试和构建包的隔离安装验证。Ubuntu / Ruby 4.0 作业保留已验证的 Gem 构建产物 14 天,可从该次工作流的 Artifacts 下载。
255
+
256
+ 运行前先执行 `bundle install`。Gem 库的开发锁文件 `Gemfile.lock` 保留在本地,各 Ruby 环境按 `Gemfile` 解析兼容依赖。生成文件写入已忽略的 `pkg/ci/` 和 `tmp/`。更新工作流中的 Action 时,应同步更新固定的提交 SHA 和版本注释。
257
+
258
+ 版本发布使用 `ruby script/release.rb`,直接复用本机已有的 Gem 和 GitHub 登录状态;脚本会完成验证、创建 GitHub Release 并推送同一个 Gem 到 RubyGems。也可以在 GitHub Actions 手动运行 Release 工作流。版本准备、Actions 凭据和失败重试见 [发布说明](docs/RELEASING.md)。
259
+
260
+ SSH 示例用 `SSH_USER`、`SSH_HOST`、`SSH_KNOWN_HOSTS` 配置,密码隐藏输入或从 `EXPECT_PASSWORD` 读取;非本地主机要求受信任的 known_hosts 文件。`ssh_auto.rb` 顶部 `COMMANDS` 可直接修改,日志写入 `tmp/ssh-auto/`,权限 0600。
261
+
262
+ 多脚本验证入口为 `test/integration/ssh_scripts.rb`,人工/自动接管入口为 `examples/ssh_interact.rb`,详细配置及日志检查见 [SSH 测试说明](test/integration/README.md)。
263
+
264
+ 当前接口迁移表见 [接口说明](docs/COMPATIBILITY.md),本次与历史验证分列在 [验证记录](docs/VERIFICATION.md)。此次重构直接移除了旧入口,不提供兼容别名。
data/Rakefile ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new do |task|
6
+ task.libs << "test"
7
+ task.pattern = "test/**/*_test.rb"
8
+ task.warning = true
9
+ end
10
+
11
+ desc "Check Ruby style and common mistakes"
12
+ task :lint do
13
+ ruby "-S", "rubocop"
14
+ end
15
+
16
+ task default: %i[lint test]
17
+
18
+ namespace :test do
19
+ desc "Log in over SSH and verify multiple scripts and persisted logs (opt-in)"
20
+ task :ssh do
21
+ ruby "test/integration/ssh_scripts.rb"
22
+ end
23
+
24
+ desc "Log in over SSH and verify real PTY interact handoff (opt-in)"
25
+ task :ssh_interact do
26
+ ruby "examples/ssh_interact.rb", "--auto"
27
+ end
28
+
29
+ desc "Automatically log into local SSH and run read-only macOS checks"
30
+ task :ssh_auto do
31
+ ruby "examples/ssh_auto.rb", "--no-interact"
32
+ end
33
+
34
+ desc "Run the local two-terminal kibitz scenarios and save logs (no SSH required)"
35
+ task :kibitz do
36
+ ruby "examples/kibitz/test_kibitz.rb"
37
+ end
38
+ end
@@ -0,0 +1,65 @@
1
+ # Ruby 接口与 Expect.pm 行为对照
2
+
3
+ 交互能力参考 [jacoby/expect.pm](https://github.com/jacoby/expect.pm),源码基准为版本 1.38、提交 `2ea0e4ce20a896c95cb4c94e781f1b1f3145150d`。此项目独立实现,使用 MIT 许可,没有复制 Perl 实现代码;原项目作者及维护者为 Austin Schutz、Roland Giersig、Dave Jacoby,采用与 Perl 相同的许可。
4
+
5
+ 当前未发布版本采用 Ruby 原生接口。下表列出迁移关系,**旧方法、别名和参数语法已移除**;历史 0.1.1 安装包的接口见对应版本记录。
6
+
7
+ | 原接口或状态 | 当前 Ruby 接口 |
8
+ | --- | --- |
9
+ | 包级默认值、`Expect.defaults` | `Expect.configure { |config| ... }`、冻结的 `Expect.configuration` |
10
+ | `timeout(10)` 等读写合一方法 | `timeout`、`timeout = 10`;布尔查询使用 `name?` |
11
+ | `exp_init`、`init` | `Expect.open(io, writer:, own:)`,支持块生命周期 |
12
+ | `expect(seconds, ...)` | `expect(..., timeout: seconds)`;完整结果使用 `expect_result` |
13
+ | `-ex`、`-re`、数组字符串正则 | 字符串字面匹配,正则使用原生 `Regexp` |
14
+ | `-i` 分组 | `from: session` / `from: [sessions]`,或块内 `on(..., from:)` |
15
+ | `[pattern, callback, *args]` | `on(pattern) { |session| ... }`,附加参数使用闭包 |
16
+ | `exp_continue`、`exp_continue_timeout` | `continue`、`continue(reset_timeout: false)` |
17
+ | 数字前缀错误字符串 | `Result#error` 为 `:timeout`、`:eof` 或原始 IO 异常对象 |
18
+ | `matchlist`、`exp_matchlist` | `captures` |
19
+ | `exp_pid`、`exp_match` 等别名 | `pid`、`match` 等属性 |
20
+ | `get_accum`、`set_accum`、`clear_accum` | `buffer`、`buffer=`、`clear_buffer` |
21
+ | `max_accum`、`match_max` | `buffer_limit`,正整数;`nil` 表示无限 |
22
+ | `notransfer` | `preserve_buffer` |
23
+ | `restart_timeout_upon_receive` | `reset_timeout_on_read` |
24
+ | `log_user`、`log_group` | `log_stdout`、`log_listeners` |
25
+ | `log_file`、`logfile`、`exp_logfile` | `log_output` / `log_output=`;路径和日志块使用 `log_to(..., mode:)` |
26
+ | `print_log_file` | `write_log` |
27
+ | `set_group` | `listeners` / `listeners=` |
28
+ | `set_seq(sequence, callback, args)` | `on_sequence(sequence) { ... }` |
29
+ | `send`、`print` | `write` 原样写入、`puts` 按行输出;`send` 保留 Ruby 反射语义 |
30
+ | `send_slow(delay, *strings)`、`write_slow` | `send_slow(*objects, delay:)` |
31
+ | `manual_stty = true` | `raw_terminal = false`;`stty` 保留,`exp_stty` 移除 |
32
+ | `interact(input, escape)` | `interact(input:, escape:, output:, timeout:)` |
33
+ | `test_handles` 的索引数组 | `readable_sessions(*sessions, timeout:)` 返回会话对象数组 |
34
+ | `wait(seconds)` | `wait(timeout: seconds)`,返回 `Process::Status` |
35
+ | `exitstatus`、`exp_exitstatus` 原始整数 | `process_status.to_i`;普通退出码用 `exit_code` |
36
+ | `do_soft_close` | `graceful_close`,可通过 `close(graceful:)` 单次覆盖 |
37
+ | `debug`、`exp_internal` | `debug_level`,整数 `0..3` |
38
+ | `ttyname`、`pty_handle` | `tty_name`、标准 `inspect` |
39
+ | `version` | 常量 `Expect::VERSION` |
40
+ | `multiline_matching` | 原生正则的 `^` / `$`、`\A` / `\z` 和 `/m` |
41
+ | `ignore_eintr` | 匹配等待自动重试 EINTR,并保留原期限 |
42
+
43
+ ## 保留的交互能力
44
+
45
+ 仍支持真实控制终端、精确/正则匹配、模式优先级、捕获组、二进制与分片 UTF-8、EOF/超时回调、绝对期限与接收重置、多会话、缓冲上限、慢速写入和背压、路径/IO/回调日志、监听组、人工接管、跨读取转义及终端恢复。
46
+
47
+ `expect` 返回模式序号或 `nil`;`expect_result` / `last_result` 返回原生七字段 `Struct`。`to_a`、`to_h`、模式解构使用 Ruby 自带行为,多重赋值需要显式 `result.to_a`。日志按实际读取的原始字节记录,转接与匹配切换不重复写日志。
48
+
49
+ ## 软关闭与硬关闭
50
+
51
+ 参考原版的策略边界:`soft_close` 先收集剩余输出,关闭所属句柄,等待退出并最多发送 TERM;**不发送 KILL**。未退出返回 `nil`,保留 PID。`hard_close` 不等待输出,必要时 TERM、KILL 并回收子进程。关闭会话 IO 不代表子进程已经退出。
52
+
53
+ Ruby 使用关键字指定各阶段期限,关闭方法返回 `Process::Status` 或 `nil`。`close(graceful: true)` 和 `graceful_close = true` 先尝试软关闭,再在 `ensure` 中硬关闭;这对应原版销毁时可选软关闭、随后硬关闭的清理策略。`close` 返回 `nil`;GC 兜底直接强制清理,不阻塞等待。
54
+
55
+ ## 有意采用的 Ruby 语义
56
+
57
+ - 默认关闭 stdout 输出,显式启用 `log_stdout`。配置按会话隔离,构造参数覆盖全局默认;配置异常不发布部分状态。
58
+ - 仅 `nil` 和 `false` 为假,`0` 为真。转接回调也采用此规则。
59
+ - 字符串始终是字面文本;只有 `:eof` / `:timeout` 是事件,`"EOF"` 是普通转义文本。
60
+ - 超时回调接收所有仍在监听的会话;重复注册超时回调抛出 `ArgumentError`。
61
+ - `write` 返回字节数,`puts` 返回 `nil`,`<<` 返回会话。
62
+ - 启动失败抛出 `Expect::SpawnError` 并回收;EOF 不主动终止仍存活的进程。借用 IO 不关闭,接管 IO 在初始化失败或会话关闭时释放。
63
+ - Perl 特有的正则、IO::Pty 继承 API、全局信号 handler 和 Solaris 字节删除补丁不移植。Ruby 使用 `to_io`、`slave`、`io/console` 和原生正则。
64
+
65
+ `test/compare_upstream.rb` 仅对共同的交互行为作可选差分验证,通过 Ruby 新接口表达同样场景,并显式转换错误标记和可读会话索引;它不要求或证明旧 API 兼容,也不等于运行完整上游测试套件。
data/docs/RELEASING.md ADDED
@@ -0,0 +1,55 @@
1
+ # 发布版本
2
+
3
+ 项目名为 `expect-ruby`,RubyGems 名为 `expect-pty`。发布脚本会创建 GitHub Release,附带 Gem 和 `SHA256SUMS`,并把同一个 Gem 推送到 RubyGems。
4
+
5
+ ## 准备版本
6
+
7
+ 1. 更新 `lib/expect/version.rb` 的 `Expect::VERSION`,例如 `0.2.0`。
8
+ 2. 把 `CHANGELOG.md` 的 `Unreleased` 内容移到对应版本标题下,例如 `## 0.2.0 - 2026-09-13`;可以保留空的 `Unreleased` 标题。
9
+ 3. 提交并推送到 `main`。发布时工作区必须干净,远端 `main` 必须包含该提交,已有同名标签必须指向该提交。
10
+
11
+ 发布脚本只接受正式版 `X.Y.Z`;未归档的变更会阻止发布。
12
+
13
+ ## 本地发布
14
+
15
+ 本地已登录 `gem` 时,脚本直接使用已有凭据;GitHub 使用 `gh auth login` 的登录状态。如果 RubyGems 要求一次性验证码,`gem push` 会提示输入。
16
+
17
+ ```sh
18
+ bundle install
19
+ ruby script/release.rb --dry-run
20
+ ruby script/release.rb
21
+ ```
22
+
23
+ 默认执行 `script/ci` 的检查、完整测试、构建和隔离安装验证,再把 Gem 复制到 `pkg/release/版本号/candidate-*/` 的独占目录,核对包内文件并生成发布说明和校验文件。该副本贯穿后续发布,目录会保留供失败重试。正式发布先创建 GitHub Release,再上传 RubyGems,最后下载 RubyGems 上的包核对 SHA256。GitHub 上还没有标签时,会为当前提交创建 `v版本号` 标签。
24
+
25
+ `--dry-run` 只做本地验证,可在提交前使用。它仍要求版本号和发布说明完整。
26
+
27
+ ## GitHub Actions 发布
28
+
29
+ GitHub Runner 不会继承本机的 Gem 登录状态。要在 Actions 发布 RubyGems,需在仓库的 Settings → Secrets and variables → Actions 中配置 `RUBYGEMS_API_KEY`,使用具有 `Push rubygem` 权限的发布 Key。
30
+
31
+ ```sh
32
+ git tag -a v0.2.0 -m 'Release v0.2.0'
33
+ git push origin v0.2.0
34
+ gh workflow run release.yml --ref v0.2.0 --repo gatework/expect-ruby
35
+ ```
36
+
37
+ 也可以在 Actions → Release → Run workflow 选择对应版本标签。工作流仅支持手动触发,避免本地发布时出现第二次并发上传。
38
+
39
+ 发布作业先验证标签与版本号一致,再复用 CI 的 Linux/macOS、Ruby 3.2/3.3/3.4/4.0 共 8 个环境。全部通过后,下载 Ubuntu / Ruby 4.0 作业验证过的 Gem,交给同一个发布脚本;发布阶段不重新构建。
40
+
41
+ 未配置 `RUBYGEMS_API_KEY` 时,GitHub Release 仍会创建,RubyGems 步骤会明确失败;此时可以下载 Release 中的原包,在本地使用已有登录状态完成上传。
42
+
43
+ ## 失败后继续
44
+
45
+ 保留脚本输出的 `Artifact` 路径,用该候选 Gem 重试发布;更换 RubyGems 工具版本或重新构建可能得到不同字节,同一个版本不得覆盖已有内容。也可以直接指定从 CI 或 Release 下载的原包:
46
+
47
+ ```sh
48
+ ruby script/release.rb --artifact pkg/ci/expect-pty-0.2.0.gem
49
+ ```
50
+
51
+ 工作流失败时优先使用 Re-run failed jobs,继续使用本次 CI 保存的产物。需要在本地恢复时,检出发布标签对应的干净源码,下载该 Release 的 Gem,再通过 `--artifact` 指定它。
52
+
53
+ 脚本会校验现有 RubyGems 版本和 GitHub Release 附件的 SHA256;一致时复用,不一致时中止。已有 GitHub Release 缺少附件时会补传,已有版本和附件不会被覆盖。
54
+
55
+ 上传中断若留下 `starter` 状态的空附件,脚本会明确指出附件名称。先确认没有其他发布或上传在运行,再在 GitHub Release 中删除该失败附件,使用原包重试;脚本不会自动删除可能仍在上传的附件。