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,146 @@
1
+ # 验证记录
2
+
3
+ ## 当前源码:Ruby 原生接口与关闭策略(2026-09-12)
4
+
5
+ 此次重构以 144 项测试 / 526 个断言为基线。原有交互场景全部迁移到新接口,新增 18 项回归,三个环境均通过 `bundle exec rake`(RuboCop 和完整测试)及 `ruby examples/dialogue.rb`:
6
+
7
+ | 环境 | 测试结果 | RuboCop |
8
+ | --- | --- | --- |
9
+ | macOS arm64,Ruby 4.0.6 | 162 项 / 618 断言,无失败、错误、跳过 | 37 个文件,无违规 |
10
+ | Linux aarch64,Ruby 3.2.11 | 162 项 / 618 断言,无失败、错误、跳过 | 37 个文件,无违规 |
11
+ | Linux aarch64,Ruby 4.0.6 | 162 项 / 618 断言,无失败、错误、跳过 | 37 个文件,无违规 |
12
+
13
+ 新增检查覆盖配置发布的原子性、冻结与会话隔离、子类覆盖、无效赋值保留状态、Ruby 真值、字面模式标志、全部活跃会话的超时回调、重复超时注册、日志所有权。真实子进程验证软关闭收集尾部、响应 TERM、绝不发送 KILL、保留存活 PID、后续硬关闭和重复回收,以及自动清理、日志异常和无效关闭期限。模拟 EINTR 检查重复 select 中断不能延长期限、读取中断不丢输入。
14
+
15
+ 最终会话输出接口统一为 `puts`、`write` 和 `send_slow(..., delay:)`,移除会话 `print` 和 `write_slow`。`lib/expect/` 的 9 个模块文件及主入口均补充中文注释,说明方法用途、匹配状态机、编码分片、转接缓冲、日志所有权和进程关闭;词法检查确认注释增补没有改变可执行代码。
16
+
17
+ 原有 PTY、控制终端、信号、中文/二进制、背压、捕获组、匹配优先级、多会话、日志、interact 和 GC 测试继续全量运行。模块设置已收敛到配置对象;旧方法、位置超时、数组回调和 Perl 正则模式解析已移除,测试使用新 API。10 个文档 Ruby 示例块均通过语法检查。
18
+
19
+ macOS 的独立 `bundle exec rake test:kibitz` 也通过全部 6 个场景,报告位于 `tmp/kibitz/20260911T232907Z-20260912-9935-te8xfa/report.json`。本轮在 Linux Ruby 3.2 容器中重新运行 `test/compare_upstream.rb`,**11 组共同交互行为对照通过**;适配脚本使用新 Ruby API,显式转换错误标记及可读会话索引,原版 Perl fixture 未修改。
20
+
21
+ 关闭策略另核对上游 `lib/Expect.pm` 的 `soft_close`、`hard_close` 和 `DESTROY`:软关闭最多 TERM,硬关闭可 KILL,销毁可先软后硬。Ruby 以 `graceful_close` / `close(graceful:)` 表达默认策略,以关键字配置等待期限;显式关闭返回 `Process::Status`,通用 `close` 返回 `nil`。
22
+
23
+ Linux 源码以只读方式挂载,使用 `BUNDLE_FROZEN=true` 和独立容器依赖目录;镜像摘要仍为下文记录的 `ruby:3.2` 与 `ruby:4.0`。Gemfile 和锁文件未改动,锁文件 SHA-256 为 `b1186a77057fba44bb79c440ffb6921042c16c1b88d213fabaa6548befb38d57`。macOS 复用了 `/tmp/expect-pty-gems` 的隔离依赖环境。
24
+
25
+ 本轮没有重新连接真实 SSH 或运行远端 CI。`pkg/` 的 0.1.0 / 0.1.1 构建与记录仍代表历史版本;当前源码的接口见 README 和 COMPATIBILITY。以下各节保留其原验证时间、接口和范围,不作为当前接口的使用说明。
26
+
27
+ ## 历史验证记录
28
+
29
+ 验证日期:2026-09-11。上游参考版本 Expect.pm 1.38,提交 `2ea0e4ce20a896c95cb4c94e781f1b1f3145150d`。
30
+
31
+ ## 自动测试
32
+
33
+ 完整测试集包含 76 项测试,全部通过,无跳过:
34
+
35
+ | 环境 | 结果 |
36
+ | --- | --- |
37
+ | macOS arm64,Ruby 4.0.6 | 76 项通过 |
38
+ | Linux aarch64,Ruby 3.2.11 | 76 项通过 |
39
+ | Linux aarch64,Ruby 4.0.6 | 76 项通过 |
40
+
41
+ Linux 使用官方 Docker 镜像:
42
+
43
+ - `ruby:3.2`:`sha256:d3bcbd845d26ae1efafcc987f641aa9ac796267b9b857e0f196a2b05070c8330`
44
+ - `ruby:4.0`:`sha256:8dc3950712ad2078bdd275b890419ba2fd3aab5a0653b291a7325f0d8a24ca05`
45
+
46
+ 测试范围包含真实 PTY 的控制终端、stdin/stdout/stderr、raw/noecho、终端尺寸、Ctrl-C 前台信号、退出码、TERM/KILL 回收、GC 兜底;模式优先级、捕获组、中文分片、二进制、缓冲上限;回调、事件、绝对/接收重置超时;多会话、日志、管道和 socket、大块双向 IO、写入背压;人工交互、跨读取转义序列、异常和超时后终端恢复;与 Ruby 标准库 IO#expect 共存。
47
+
48
+ macOS 改变终端设置后内核可能设置 PENDIN 临时状态位。终端恢复测试屏蔽这一状态位,比较其余完整配置;没有忽略真实的 echo、canonical、输入/输出、信号或控制字符设置变化。
49
+
50
+ 复现:
51
+
52
+ ```sh
53
+ bundle install
54
+ bundle exec rake test
55
+ ```
56
+
57
+ 本机全局 RubyGems/Bundler 安装存在版本混用,验证使用了临时 GEM_HOME 中的独立 Bundler 4.0.16、Rake 和 Minitest;未修改项目对 Bundler 的通常用法或用户的全局 gem。
58
+
59
+ ## 上游差分验证
60
+
61
+ `test/compare_upstream.rb` 对同一输入分别运行原始 Perl 模块与本 Ruby 实现:字面模式、数组正则、跨行锚点、模式优先级、notransfer、缓冲上限、超时保留缓冲、连续回调、真实 PTY 对话。**9 组对照通过**。
62
+
63
+ 对照在 Linux Ruby 3.2 容器内执行,Perl 依赖 IO::Pty / IO::Stty 仅安装到该临时容器。正则捕获及错误访问的 Perl 对象模型差异见兼容性说明;没有将它们伪装为一致行为。
64
+
65
+ ```sh
66
+ # 此项是可选验证,需要 Perl 及 IO::Pty / IO::Stty。
67
+ ruby test/compare_upstream.rb /path/to/expect.pm
68
+ ```
69
+
70
+ 没有把这 9 项差分测试等同于运行完整 Perl 上游测试套件;功能覆盖同时依赖 Ruby 的 76 项测试和源码/API 对照。
71
+
72
+ ## 真实 SSH
73
+
74
+ 使用用户授权的 `crate@127.0.0.1` 完成真实 OpenSSH 密码认证,进入远端交互 shell 和 PTY。校验随机输出标记、`id -un` 返回 `crate`、`tty` 返回 `/dev/...`,随后 `exit`,SSH 退出码为 0。
75
+
76
+ 测试随机标记由远端拼接,排除了仅匹配命令回显的误判。密码由隐藏输入读取或从进程环境取出,只保留在验证进程内,未写入源码、测试、文档和日志。临时 known_hosts 在退出后删除。
77
+
78
+ ```sh
79
+ SSH_USER=crate SSH_HOST=127.0.0.1 ruby examples/ssh_login.rb
80
+ ```
81
+
82
+ ## 打包和范围
83
+
84
+ 提供 `expect-pty.gemspec`、MIT LICENSE、中文 README、API 兼容性表、对话和 SSH 示例,以及 macOS/Linux 的 Ruby 3.2/3.3/3.4/4.0 CI 配置。CI 文件已进行 YAML 解析检查,远端 GitHub Actions 尚未运行。
85
+
86
+ 初始构建物为 `expect-pty-0.1.0.gem`,补充后的交付版本为 `expect-pty-0.1.1.gem`,包含可运行的测试和 fixtures。安装验证在独立 GEM_HOME 中、从项目目录外运行 `require "expect/pty"` 和真实 PTY 对话,避免依赖源码目录的加载路径。
87
+
88
+ 原生 Windows、其他 Unix、真实外部设备/外部 SSH 服务未实测。没有发布 gem、推送仓库或创建远端 Release;本次交付为本地源码和安装包。
89
+
90
+ ## 补充验证:登录后多脚本与日志(2026-09-11)
91
+
92
+ 新增 `test/script_logging_test.rb` 的 8 项真实 PTY 测试,及可单独运行的 `test/integration/ssh_scripts.rb`。自动测试总计从 76 项增至 84 项。新增测试共享 5 个 `.sh` fixture,覆盖多脚本日志文件、回调和借用 File、日志出错、异常退出码、输出不符、引号和中文传输,以及超时后的日志连续性。
93
+
94
+ 最终全量回归:macOS Ruby 4.0.6 为 84 项 / 283 断言,Linux Ruby 3.2.11 为 84 项 / 283 断言,Linux Ruby 4.0.6 为 84 项 / 289 断言,均无失败、错误或跳过。Rake 的 `test:ssh` 入口已注册,Ruby 脚本和 5 个 shell fixture 的语法检查通过。
95
+
96
+ 真实 SSH `crate@127.0.0.1` 已通过同一会话内的 7 项执行(5 个文件脚本,加日志关闭/恢复用例)及 11 项日志检查。第 4 个 fixture 故意返回 7,第 5 个仍执行成功;最终 SSH 退出码为 0。日志读取验证包含执行期间即时 flush、逐条顺序、无重复、覆盖/暂停/追加,以及 `soft_close` 收集最终延迟输出。
97
+
98
+ 实际通过的日志和 JSON 报告保存在 `tmp/ssh-logs/20260911T124918Z-20260911-97383-lka948/`。目录被 Git 忽略,每次重跑生成新目录,失败时保留失败报告。
99
+
100
+ 该成功会话日志共 1633 字节,SHA-256 为 `6b8abf4df93e1c40dec21a2b89c359bf4e2a9f9636791a83713150af5796672c`,与 JSON 报告的摘要一致;日志和报告均为 0600 权限。`pkg/` 中保留 0.1.0 作为初始构建记录,后续修正和测试脚本随 0.1.1 打包。
101
+
102
+ ## 完整性复核与 0.1.1
103
+
104
+ 复核上游实际代码后,补充 `test_handles(timeout, *sessions)` 等待语义,以及 `set_seq` 正则序列和跨读取回调,差分用例从 9 组扩展到 11 组。新增回归也覆盖从 `expect` 切换到 `interconnect` 再返回时的日志唯一性、转义后的原始日志,以及日志目标抛 IO 错误时保留输入并正确报告错误。
105
+
106
+ 0.1.1 包含库源码、中文文档、完整测试与 5 个 `.sh` fixtures、Gemfile、gemspec 和 Rakefile。具体构建摘要和安装后的验证结果记录在构建物旁的 `pkg/VERIFICATION-0.1.1.txt`,避免文档与包自身摘要循环依赖。
107
+
108
+ SSH 首轮测试曾准确报告中文脚本退出 127;根据日志定位到测试 helper 的逐字节转义与远端 shell 行编辑相互影响。已改为完整脚本单引号传输,并禁用交互 shell 的 emacs/vi 编辑;重跑后中文、标准输出和错误输出逐字节匹配通过。
109
+
110
+ ## 补充验证:interact 与本地 Kibitz(2026-09-11)
111
+
112
+ 当前源码全量测试为 **109 项**,无失败、错误或跳过:macOS arm64 Ruby 4.0.6 为 380 断言,Linux aarch64 Ruby 3.2.11 为 380 断言,Linux aarch64 Ruby 4.0.6 为 386 断言。Linux 仍使用上文记录的两个镜像。这些新增内容记在 Unreleased,已有 0.1.1 构建记录保持为该包的历史验证结果。
113
+
114
+ 新增 7 项 `test/interact_test.rb` 测试覆盖真实 PTY 接管、输入即时回显、远端 Ctrl-C、两次 Ctrl-] 返回、恢复自动化、超时/EOF/回调和输出异常后的终端恢复。另补 1 项 CRLF 输出分帧回归,检查空输出、无末尾换行和多个末尾换行。人工交互启用远端 echo,回到自动化时关闭 echo;自动脚本的无回显设置保留。TTY 身份探测的换行匹配不参与输出截取,不存在同一分帧问题。
115
+
116
+ 实际 SSH `crate@127.0.0.1` 通过的三条路径,均记录 SSH 退出码 0,日志与报告权限 0600:
117
+
118
+ | 路径 | 结果 | 报告所在目录 |
119
+ | --- | --- | --- |
120
+ | 自动 PTY 交互 | 4 项命令、13 项交互/日志检查 | `tmp/ssh-interact/20260911T131311Z-20260911-367-hkxq5z/` |
121
+ | 人工输入后 Ctrl-] | 回车前可见 `printf` 输入,执行结果正确,返回后自动命令成功 | `tmp/ssh-interact/20260911T131343Z-20260911-423-7pgq5m/` |
122
+ | 人工直接 exit | 远端 EOF、终端恢复、正常退出,不执行自动化恢复命令 | `tmp/ssh-interact/20260911T132040Z-20260911-1777-qi6yry/` |
123
+
124
+ 参考上游 `examples/kibitz` 源码的连接图,新增 `examples/kibitz/kibitz.rb` 和 `test_kibitz.rb`。本地示例通过 Unix socket 连接两份 CLI,各自运行于独立真实 PTY;共享模式启动真实 shell。`test/kibitz_test.rb` 新增 7 项测试,覆盖共享状态、输入回显、双向广播、中文/stderr、Ctrl-C、无子进程互传、自定义分片转义、禁用转义、非零退出、连接与转接超时、日志唯一性以及终端恢复和 socket 清理。
125
+
126
+ 测试捕获并修正了就绪提示先于本地 raw/noecho 设置的启动时序问题;现在两端的就绪提示均在终端设置完成后发送。分片转义检查先确认普通前缀已到达对端,再发送转义的剩余部分,确保确实覆盖跨读取路径。
127
+
128
+ `bundle exec rake test:kibitz` 独立执行 6 个场景并全部通过,报告保存于 `tmp/kibitz/20260911T132223Z-20260911-2081-1poaup/report.json`,每个场景有独立 `session.log`。此验证参考并重现上游的本地交互行为,不代表移植或运行了上游的用户邀请、跨主机 rlogin 和转义菜单。
129
+
130
+ ## Ruby API 与编码规范重构(2026-09-12)
131
+
132
+ 本轮基线为 109 项测试 / 380 断言。新增 `test/ruby_api_test.rb` 的 35 项测试 / 146 断言,最终三种环境均通过完整的 `bundle exec rake` 和 `ruby examples/dialogue.rb`:
133
+
134
+ | 环境 | 完整测试 | RuboCop |
135
+ | --- | --- | --- |
136
+ | macOS arm64,Ruby 4.0.6 | 144 项 / 526 断言,无失败、错误、跳过 | 35 个文件,无违规 |
137
+ | Linux aarch64,Ruby 3.2.11 | 144 项 / 526 断言,无失败、错误、跳过 | 35 个文件,无违规 |
138
+ | Linux aarch64,Ruby 4.0.6 | 144 项 / 526 断言,无失败、错误、跳过 | 35 个文件,无违规 |
139
+
140
+ 新增验证覆盖简洁 `expect(10) { on(...) ... }`、显式块参数保留调用方 `self`、闭包、可选块参数、注册时异常和 `break` 不消费输入、关键字与位置超时校验、EOF / 超时 / 多会话回调、继续等待及原期限;也覆盖借用和接管 IO 的块清理、初始化失败、缓冲和监听列表副本、日志块、原生 `puts` 的空参数/嵌套/递归数组、对象写入与链式追加。
141
+
142
+ `Result` 转换验证直接对照七个 Struct 字段,包括 `to_a`、`Array(result)`、`to_h`、位置/键模式解构及显式多重赋值;确认不再响应 `to_ary`。另验证 `session.send(:buffer)` 等 Ruby 反射调用和实际字节写入分离。原有 PTY、二进制、超时、进程回收、日志、interact、Kibitz 测试继续全量运行。
143
+
144
+ 开发依赖固定在 Ruby 3.2 可运行的范围:RuboCop 1.89.0、parallel 1.28.0、Minitest 5.27.0、Rake 13.4.2,锁文件已补齐校验和。Linux 验证以只读方式挂载源码,使用 `BUNDLE_FROZEN=true` 安装同一锁文件;镜像仍为本文记录的 `ruby:3.2` 和 `ruby:4.0`。macOS 使用 `/tmp/expect-pty-gems` 中的独立 Bundler 4.0.16。
145
+
146
+ 调用与迁移说明已同步至 README、兼容性表和示例;`send(data)` 需改为 `write(data)`,多重赋值需显式使用 `result.to_a`,`puts` 返回 `nil`。不冲突的 Expect.pm 入口继续作为兼容入口保留。本轮未重新连接真实 SSH、运行 Perl 上游差分或触发远端 CI;历史验证记录不作为这些路径本轮实测的证明。
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/expect/pty"
4
+ require "rbconfig"
5
+
6
+ program = <<~'RUBY'
7
+ STDOUT.sync = true
8
+ print "Name: "
9
+ name = STDIN.gets.strip
10
+ print "Code: "
11
+ code = STDIN.gets.strip
12
+ puts "Hello #{name}, code=#{code}"
13
+ RUBY
14
+
15
+ Expect.spawn(RbConfig.ruby, "-e", program, raw_pty: true, log_stdout: false) do |session|
16
+ matched = session.expect(timeout: 3) do
17
+ on("Name: ") do |connection|
18
+ connection.puts("Ruby")
19
+ connection.continue
20
+ end
21
+ on("Code: ") do |connection|
22
+ connection.puts("1234")
23
+ connection.continue
24
+ end
25
+ on(/Hello (\w+), code=(\d+)/)
26
+ end
27
+ abort(session.error) unless matched
28
+ puts session.match
29
+ session.soft_close(timeout: 1)
30
+ end
@@ -0,0 +1,73 @@
1
+ # 本地 Kibitz 示例与测试
2
+
3
+ 参考 [Expect.pm 的 kibitz](https://github.com/jacoby/expect.pm/tree/master/examples/kibitz),使用本项目的 `Expect.open` 块、`listeners=`、`on_sequence` 和 `Expect.interconnect` 连接两端输入与同一个交互进程。参考源码为上游提交 `2ea0e4ce20a896c95cb4c94e781f1b1f3145150d`。
4
+
5
+ 需要 Ruby 3.2+ 和 macOS/Linux。示例和自动脚本只依赖标准库,不需要 SSH 服务或密码。
6
+
7
+ ## 两个终端手工运行
8
+
9
+ 在第一个终端运行:
10
+
11
+ ```sh
12
+ ruby examples/kibitz/kibitz.rb -- /bin/sh -i
13
+ ```
14
+
15
+ 程序打印一条完整的 `--join /tmp/expect-kibitz-.../peer.sock` 命令。在本机、同一账户的第二个终端执行这条命令,两端即可操作同一个 shell。输入和输出在两端都可见,例如:
16
+
17
+ ```sh
18
+ # 第一个终端设置 shell 变量
19
+ shared_value=42
20
+ # 第二个终端读取同一个变量
21
+ printf '%s\n' "$shared_value"
22
+ ```
23
+
24
+ 任一端输入 `exit` 结束共享 shell;Ctrl-C 发给共享进程的前台任务。任一端按 Ctrl-] 结束自己的转接并断开连接,另一端随 EOF 结束。终端配置在退出时恢复。可以通过 `-- program args...` 运行其他交互程序;不传程序时使用 `$SHELL`,缺省 `/bin/sh`。
25
+
26
+ ## 无子进程互传和可选参数
27
+
28
+ ```sh
29
+ # 输入只传给另一端,没有本地回显
30
+ ruby examples/kibitz/kibitz.rb --noproc
31
+
32
+ # 自定义本端转义键(第二端可在自己的加入命令中独立设置)
33
+ ruby examples/kibitz/kibitz.rb --escape '^X' -- /bin/sh -i
34
+
35
+ # 禁用本端转义,Ctrl-] 也作为普通数据转发
36
+ ruby examples/kibitz/kibitz.rb --noescape -- /bin/sh -i
37
+
38
+ # 等待加入最多 30 秒,加入后转接也最多 30 秒;日志文件必须尚不存在
39
+ ruby examples/kibitz/kibitz.rb --timeout 30 --log /tmp/my-kibitz-session.log -- /bin/sh -i
40
+ ```
41
+
42
+ 共享进程模式的日志记录该进程实际返回的字节,包括终端回显;无子进程模式记录对端返回的字节。日志使用 0600 权限。进程正常结束时,发起端返回该进程的退出码;转义或对端 EOF 返回 0,超时或连接失败返回非零。加入端只能观察连接结束,不取得共享进程的退出码。
43
+
44
+ 这是同账户本地示例:连接使用 0700 临时目录内的 0600 Unix socket,接受一位加入者后移除 socket,退出时清理目录。原例的用户邀请、跨主机 rlogin、FIFO 以及转义菜单没有移植;双方终端初始尺寸由发起端决定,未实现运行期间的窗口尺寸同步。
45
+
46
+ ## 自动运行同样的两个终端流程
47
+
48
+ ```sh
49
+ ruby examples/kibitz/test_kibitz.rb
50
+ # 或
51
+ bundle exec rake test:kibitz
52
+ ```
53
+
54
+ 脚本真正启动两份 `kibitz.rb`,通过两个独立 PTY 模拟两位用户,并执行打印出的 Unix socket 加入流程。共享模式运行真实 `/bin/sh -i`,没有 mock 转发逻辑。
55
+
56
+ | 场景 | 检查 |
57
+ | --- | --- |
58
+ | 共享 shell | 输入回显、双方命令、共享变量、两端输出、中文、stderr、失败后继续执行 |
59
+ | Ctrl-C 与进程 EOF | 第二端中断第一端启动的前台任务,最终输出到达两端,进程退出 |
60
+ | `--noproc` | 双向字节互传,发送端无本地回显 |
61
+ | 自定义转义 | `STOP` 分两次发送,前缀不泄漏,转义和尾部不进入对端 |
62
+ | 加入端转义 | Ctrl-] 关闭加入端,发起端响应 EOF |
63
+ | `--noescape` | Ctrl-] 字节被完整转发 |
64
+ | 非零退出 | 共享 shell 返回 7,发起程序也返回 7 |
65
+ | 超时 | 转接到期返回失败,另一端结束 |
66
+ | 每个场景 | 就绪时已关闭本地回显,退出后两端终端配置恢复,socket 清理、日志权限正确 |
67
+
68
+ 每轮结果写入 `tmp/kibitz/` 的独立目录,可用 `EXPECT_LOG_DIR` 指定其他目录。`report.json` 包含各场景检查项与整体 `passed`,各场景子目录保留 `session.log`。只有全部检查通过才返回 0。默认测试集还覆盖没有第二端加入时的超时清理:
69
+
70
+ ```sh
71
+ bundle exec ruby -Itest test/kibitz_test.rb
72
+ bundle exec rake test
73
+ ```
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "tmpdir"
5
+ require "optparse"
6
+ require "shellwords"
7
+ require_relative "../../lib/expect/pty"
8
+
9
+ # Local counterpart of jacoby/expect.pm examples/kibitz: both keyboards feed
10
+ # one process, whose output is broadcast to both terminals using listeners.
11
+ module Kibitz
12
+ ESCAPE = "\x1d".b.freeze
13
+
14
+ def self.relay(input:, output:, peer:, shell: nil, escape: ESCAPE, timeout: nil, log: nil)
15
+ Expect.open(input, log_stdout: false, write_timeout: 5) do |keyboard|
16
+ Expect.open(peer, log_stdout: false, write_timeout: 5) do |partner|
17
+ keyboard.on_sequence(escape) if escape
18
+ if shell
19
+ keyboard.listeners = [shell]
20
+ partner.listeners = [shell]
21
+ shell.listeners = [output, partner]
22
+ shell.log_stdout = false
23
+ shell.log_output = log if log
24
+ # Keep canonical input, echo and ISIG on the process's own terminal.
25
+ shell.raw_terminal = false
26
+ else
27
+ keyboard.listeners = [partner]
28
+ partner.listeners = [output]
29
+ partner.log_output = log if log
30
+ end
31
+ stopped = Expect.interconnect(*[keyboard, partner, shell].compact, timeout: timeout)
32
+ reason = if stopped.nil?
33
+ :timeout
34
+ elsif stopped.equal?(keyboard)
35
+ :local
36
+ elsif stopped.equal?(partner)
37
+ :peer
38
+ else
39
+ :process
40
+ end
41
+ { reason: reason, tail: keyboard.buffer }
42
+ end
43
+ end
44
+ end
45
+
46
+ def self.escape(value)
47
+ return (value.getbyte(1) & 31).chr if value.match?(/\A\^[@A-Z\[\\\]\^_]\z/)
48
+
49
+ raise OptionParser::InvalidArgument, "escape must not be empty" if value.empty?
50
+
51
+ value
52
+ end
53
+
54
+ def self.run(argv, input: $stdin, output: $stdout)
55
+ options = { escape: ESCAPE }
56
+ parser = OptionParser.new do |opts|
57
+ opts.banner = "Usage: ruby examples/kibitz/kibitz.rb [options] [-- program args...]"
58
+ opts.on("--join SOCKET", "Join the local session printed by its host") { |value| options[:join] = value }
59
+ opts.on("--noproc", "Exchange input directly, with no shared process or local echo") { options[:noproc] = true }
60
+ opts.on("--escape TEXT", "Local exit sequence (default ^]; caret notation accepted)") do |value|
61
+ options[:escape] = escape(value)
62
+ end
63
+ opts.on("--noescape", "Forward all keys; end by exiting the shared process") { options[:escape] = nil }
64
+ opts.on("--timeout SECONDS", Float, "Limit connection wait and then relay duration") do |value|
65
+ options[:timeout] = value
66
+ end
67
+ opts.on("--log FILE", "Create a new received-output log (0600)") { |value| options[:log] = value }
68
+ opts.on("-h", "--help") do
69
+ output.puts(opts)
70
+ return 0
71
+ end
72
+ end
73
+ parser.order!(argv)
74
+ raise OptionParser::InvalidArgument, "a terminal is required" unless input.tty?
75
+ if options[:timeout] && (!options[:timeout].finite? || options[:timeout] <= 0)
76
+ raise OptionParser::InvalidArgument,
77
+ "timeout must be positive and finite"
78
+ end
79
+ if options[:join] && (options[:noproc] || !argv.empty?)
80
+ raise OptionParser::InvalidArgument,
81
+ "--join cannot start a program or use --noproc"
82
+ end
83
+ raise OptionParser::InvalidArgument, "--noproc cannot start a program" if options[:noproc] && !argv.empty?
84
+
85
+ output.sync = true
86
+ # The ensure below closes the log after both terminal connections end.
87
+ log = File.open(options[:log], File::WRONLY | File::CREAT | File::EXCL, 0o600) if options[:log] # rubocop:disable Style/FileOpen
88
+
89
+ if options[:join]
90
+ peer = UNIXSocket.new(options[:join])
91
+ else
92
+ # A short, private socket path works with macOS's sockaddr_un limit.
93
+ directory = Dir.mktmpdir("expect-kibitz-", "/tmp")
94
+ socket_path = File.join(directory, "peer.sock")
95
+ server = UNIXServer.new(socket_path)
96
+ File.chmod(0o600, socket_path)
97
+ output.puts("Join from another terminal on this machine:")
98
+ output.puts(Shellwords.join([RbConfig.ruby, File.expand_path(__FILE__), "--join", socket_path]))
99
+ raise IOError, "timed out waiting for partner" unless server.wait_readable(options[:timeout])
100
+
101
+ peer = server.accept
102
+ server.close
103
+ File.unlink(socket_path)
104
+ unless options[:noproc]
105
+ command = argv.empty? ? [ENV.fetch("SHELL", "/bin/sh")] : argv
106
+ shell = Expect.spawn(*command, log_stdout: false, raw_terminal: false, write_timeout: 5)
107
+ shell.winsize = input.winsize
108
+ end
109
+ end
110
+ # Announce readiness only after local echo/canonical input are disabled.
111
+ # A fast typist (or PTY driver) may send bytes immediately after the banner.
112
+ result = input.raw do
113
+ hint = options[:escape] ? "Escape #{options[:escape].inspect} ends this session." : "Escape disabled."
114
+ output.write("Kibitz connected. #{hint}\r\n")
115
+ relay(input: input, output: output, peer: peer, shell: shell,
116
+ escape: options[:escape], timeout: options[:timeout], log: log)
117
+ end
118
+ if result[:reason] == :process
119
+ shell.soft_close(timeout: 2)
120
+ status = shell.exit_code || 1
121
+ else
122
+ status = result[:reason] == :timeout ? 1 : 0
123
+ end
124
+ output.puts("\nKibitz ended (#{result[:reason]}).")
125
+ status
126
+ rescue OptionParser::ParseError, IOError, SystemCallError, Expect::SpawnError => error
127
+ warn "kibitz: #{error.message}"
128
+ 1
129
+ ensure
130
+ shell&.close
131
+ peer&.close
132
+ server&.close unless server&.closed?
133
+ log&.close
134
+ File.unlink(socket_path) if socket_path && File.socket?(socket_path)
135
+ Dir.rmdir(directory) if directory && Dir.exist?(directory)
136
+ end
137
+ end
138
+
139
+ exit Kibitz.run(ARGV) if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require "time"
7
+ require_relative "../../test/support/kibitz_probe"
8
+
9
+ base = File.expand_path(ENV.fetch("EXPECT_LOG_DIR", "../../tmp/kibitz"), __dir__)
10
+ FileUtils.mkdir_p(base)
11
+ directory = Dir.mktmpdir("#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-", base)
12
+ report = { passed: false, cases: [], started_at: Time.now.utc.iso8601 }
13
+ begin
14
+ {
15
+ shared_shell: ->(dir) { KibitzProbe.shared_shell(dir) },
16
+ host_escape: ->(dir) { KibitzProbe.direct(dir, ending: :host) },
17
+ guest_escape: ->(dir) { KibitzProbe.direct(dir, ending: :guest) },
18
+ noescape: ->(dir) { KibitzProbe.noescape(dir) },
19
+ process_failure: ->(dir) { KibitzProbe.process_failure(dir) },
20
+ relay_timeout: ->(dir) { KibitzProbe.relay_timeout(dir) }
21
+ }.each do |name, probe|
22
+ path = File.join(directory, name.to_s)
23
+ Dir.mkdir(path, 0o700)
24
+ result = probe.call(path)
25
+ report[:cases] << result.merge(log_path: File.join(path, "session.log"))
26
+ puts "PASS #{name}: #{result[:checks].join(", ")}"
27
+ end
28
+ report[:passed] = true
29
+ rescue StandardError => error
30
+ report[:error] = "#{error.class}: #{error.message}"
31
+ ensure
32
+ report[:finished_at] = Time.now.utc.iso8601
33
+ path = File.join(directory, "report.json")
34
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.pretty_generate(report)) }
35
+ puts "Report: #{path}"
36
+ end
37
+ abort(report[:error]) unless report[:passed]
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+ require "tempfile"
6
+ require "tmpdir"
7
+ require_relative "../lib/expect/pty"
8
+
9
+ # 在这里按顺序填写要执行的 macOS / POSIX shell 命令。
10
+ COMMANDS = ["id", "uname -srm", "sw_vers", "df -h /", "uptime"].freeze
11
+ PROMPT = "MACOS> "
12
+
13
+ if ARGV.delete("--help")
14
+ puts "用法:ruby examples/ssh_auto.rb [--no-interact]"
15
+ puts "自动 SSH 登录,执行 COMMANDS 后进入 interact;--no-interact 执行后退出。"
16
+ puts "环境变量:SSH_HOST、SSH_USER、SSH_PORT、SSH_KNOWN_HOSTS、EXPECT_PASSWORD、EXPECT_LOG_DIR。"
17
+ exit
18
+ end
19
+ interactive = !ARGV.delete("--no-interact")
20
+ abort "未知参数:#{ARGV.join(" ")}" unless ARGV.empty?
21
+ abort "interact 需要终端;批量执行请加 --no-interact" if interactive && !$stdin.tty?
22
+
23
+ host = ENV.fetch("SSH_HOST", "127.0.0.1")
24
+ user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
25
+ port = Integer(ENV.fetch("SSH_PORT", "22"), 10)
26
+ abort "SSH_PORT 应为 1..65535" unless (1..65_535).cover?(port)
27
+ abort "SSH_HOST / SSH_USER 格式无效" if [host, user].any? do |value|
28
+ value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
29
+ end
30
+ known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
31
+ abort "连接其他主机时请设置 SSH_KNOWN_HOSTS" unless known_hosts || %w[127.0.0.1 ::1 localhost].include?(host)
32
+
33
+ password = ENV.delete("EXPECT_PASSWORD")&.dup
34
+ log = nil
35
+ begin
36
+ unless password
37
+ $stderr.print("SSH password: ")
38
+ password = ($stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets)&.chomp
39
+ $stderr.puts
40
+ end
41
+ abort "请输入单行密码" if password.nil? || password.empty? || password.match?(/[\r\n\x00]/)
42
+
43
+ Dir.mktmpdir("expect-ssh-") do |dir|
44
+ ssh_args = ["ssh", "-F", "/dev/null", "-tt", "-p", port.to_s,
45
+ "-o", "ConnectTimeout=5", "-o", "NumberOfPasswordPrompts=1",
46
+ "-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
47
+ "-o", "StrictHostKeyChecking=#{known_hosts ? "yes" : "accept-new"}",
48
+ "-o", "UserKnownHostsFile=#{known_hosts || File.join(dir, "known_hosts")}",
49
+ "-l", user, host, "env ENV= PS1=#{Shellwords.escape(PROMPT)} /bin/sh -i"]
50
+
51
+ Expect.spawn(*ssh_args, raw_pty: true, log_stdout: false, write_timeout: 5) do |ssh|
52
+ # 登录成功后才开启日志,密码不写入文件。
53
+ ssh.expect(/password:\s*\z/i, timeout: 10) or abort "未收到密码提示:#{ssh.error}"
54
+ ssh.write(password, "\n")
55
+ ssh.expect(PROMPT, timeout: 10) or abort "SSH 登录失败:#{ssh.error}"
56
+ password.replace("\0" * password.bytesize)
57
+ ssh.write("stty sane -echo; set +o emacs; set +o vi\n")
58
+ ssh.expect(PROMPT, timeout: 5) or abort "终端初始化失败:#{ssh.error}"
59
+
60
+ log_dir = ENV.fetch("EXPECT_LOG_DIR", File.expand_path("../tmp/ssh-auto", __dir__))
61
+ FileUtils.mkdir_p(log_dir)
62
+ log = Tempfile.create(["session-", ".log"], log_dir) # 0600,退出后保留
63
+ ssh.log_to(log)
64
+ puts "已登录 #{user}@#{host};日志:#{log.path}"
65
+
66
+ COMMANDS.each do |command|
67
+ puts "\n$ #{command}"
68
+ ssh.write_log("\n$ #{command}\n")
69
+ token = SecureRandom.hex(8)
70
+ # 远端拼接结束标记并带回退出码,避免把命令回显当成执行结果。
71
+ ssh.write("#{command}; printf '\\n__DONE_%s:%s\\n' '#{token}' \"$?\"\n")
72
+ ssh.expect(/\r?\n__DONE_#{token}:(\d+)\r?\n#{Regexp.escape(PROMPT)}/, timeout: 30) or abort "命令未完成:#{ssh.error}"
73
+ print ssh.before.gsub("\r\n", "\n")
74
+ abort "命令失败(退出码 #{ssh.captures.first}):#{command}" unless ssh.captures.first == "0"
75
+ end
76
+
77
+ if interactive
78
+ ssh.write("stty echo\n")
79
+ ssh.expect(PROMPT, timeout: 5) or abort "无法进入交互:#{ssh.error}"
80
+ puts "\n检查完成,进入 interact。输入 exit 或按 Ctrl-] 结束。"
81
+ $stdout.print(PROMPT)
82
+ $stdout.flush
83
+ next unless ssh.interact(input: $stdin, escape: "\x1d", output: $stdout).equal?(ssh)
84
+ else
85
+ ssh.write("exit\n")
86
+ end
87
+ ssh.soft_close(timeout: 3)
88
+ abort "SSH 退出异常:#{ssh.exit_code.inspect}" unless ssh.exit_code&.zero?
89
+ end
90
+ end
91
+ ensure
92
+ log&.close
93
+ password&.replace("\0" * password.bytesize)
94
+ end