expect-pty 0.5.3 → 0.7.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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/README.md +122 -130
  4. data/docs/API.md +113 -0
  5. data/docs/MIGRATION.md +68 -0
  6. data/lib/expect/cleanup.rb +34 -0
  7. data/lib/expect/interaction.rb +228 -210
  8. data/lib/expect/logging.rb +117 -162
  9. data/lib/expect/matcher.rb +42 -38
  10. data/lib/expect/pattern.rb +10 -3
  11. data/lib/expect/pattern_list.rb +39 -9
  12. data/lib/expect/redactor.rb +12 -5
  13. data/lib/expect/relay.rb +106 -81
  14. data/lib/expect/relay_writer.rb +7 -5
  15. data/lib/expect/result.rb +33 -9
  16. data/lib/expect/session.rb +554 -0
  17. data/lib/expect/session_resources.rb +19 -18
  18. data/lib/expect/version.rb +2 -2
  19. data/lib/expect.rb +68 -578
  20. data/sig/expect.rbs +279 -0
  21. metadata +14 -123
  22. data/.rubocop.yml +0 -66
  23. data/CONTRIBUTING.md +0 -29
  24. data/Gemfile +0 -28
  25. data/Rakefile +0 -38
  26. data/benchmark/matching.rb +0 -98
  27. data/benchmark/redactor.rb +0 -64
  28. data/benchmark/relay.rb +0 -48
  29. data/benchmark/scaling.rb +0 -70
  30. data/benchmark/send_slow.rb +0 -41
  31. data/benchmark/support.rb +0 -114
  32. data/docs/COMPATIBILITY.md +0 -98
  33. data/docs/INTERNAL_CONTRACTS.md +0 -154
  34. data/docs/PERFORMANCE.md +0 -165
  35. data/docs/RELEASING.md +0 -84
  36. data/docs/VERIFICATION.md +0 -456
  37. data/examples/dialogue.rb +0 -30
  38. data/examples/kibitz/README.md +0 -81
  39. data/examples/kibitz/kibitz.rb +0 -142
  40. data/examples/kibitz/test_kibitz.rb +0 -37
  41. data/examples/ssh_auto.rb +0 -94
  42. data/examples/ssh_interact.rb +0 -159
  43. data/examples/ssh_login.rb +0 -64
  44. data/expect-pty.gemspec +0 -33
  45. data/lib/expect/configuration.rb +0 -122
  46. data/lib/expect/terminal.rb +0 -102
  47. data/script/ci +0 -121
  48. data/script/release.rb +0 -319
  49. data/test/buffer_accounting_test.rb +0 -83
  50. data/test/cleanup_test.rb +0 -251
  51. data/test/compare_upstream.rb +0 -157
  52. data/test/configuration_test.rb +0 -133
  53. data/test/deadline_test.rb +0 -237
  54. data/test/diagnostics_test.rb +0 -353
  55. data/test/edge_case_test.rb +0 -262
  56. data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
  57. data/test/fixtures/ssh_scripts/02_output.sh +0 -5
  58. data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
  59. data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
  60. data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
  61. data/test/initialization_failure_test.rb +0 -92
  62. data/test/integration/README.md +0 -109
  63. data/test/integration/ssh_scripts.rb +0 -94
  64. data/test/interact_test.rb +0 -253
  65. data/test/interconnect_test.rb +0 -425
  66. data/test/io_test.rb +0 -321
  67. data/test/kibitz_test.rb +0 -45
  68. data/test/lifecycle_contract_test.rb +0 -71
  69. data/test/literal_scan_test.rb +0 -97
  70. data/test/matching_test.rb +0 -211
  71. data/test/multi_session_test.rb +0 -66
  72. data/test/ownership_sequence_test.rb +0 -208
  73. data/test/pattern_offset_test.rb +0 -43
  74. data/test/process_interruption_test.rb +0 -296
  75. data/test/process_test.rb +0 -291
  76. data/test/redactor_test.rb +0 -183
  77. data/test/relay_recovery_test.rb +0 -451
  78. data/test/relay_reentrancy_test.rb +0 -159
  79. data/test/release_test.rb +0 -375
  80. data/test/ruby_api_test.rb +0 -515
  81. data/test/scan_reuse_test.rb +0 -109
  82. data/test/script_logging_test.rb +0 -124
  83. data/test/support/interact_probe.rb +0 -125
  84. data/test/support/kibitz_probe.rb +0 -177
  85. data/test/support/script_probe.rb +0 -158
  86. data/test/terminal_cleanup_test.rb +0 -349
  87. data/test/test_helper.rb +0 -58
  88. data/test/timeout_test.rb +0 -289
  89. data/test/write_contract_test.rb +0 -105
@@ -1,102 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "shellwords"
4
-
5
- # 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
6
- class Expect
7
- # 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
8
- # 辅助进程与管道独立记账,不能覆盖主会话 PID;失败时保留原异常并有界回收。
9
- def stty(*modes)
10
- return "" unless tty?
11
-
12
- modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
13
- modes = ["-g"] if modes.empty?
14
- reader, sink = IO.pipe
15
- resources = SessionResources.new(reader, writer: sink, own: true)
16
- resources.pid = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
17
- sink.close
18
- output = reader.read
19
- status = wait_stty(resources.pid)
20
- resources.pid = nil
21
- raise IOError, "stty failed: #{output.strip}" unless status.success?
22
-
23
- output.strip
24
- rescue Errno::ENOENT
25
- failed = true
26
- raise IOError, "stty executable not found in PATH; install the system terminal utilities"
27
- rescue Exception # rubocop:disable Lint/RescueException -- 中断同样先回收辅助进程,再原样传播;不读取调用者的旧 $!。
28
- failed = true
29
- raise
30
- ensure
31
- cleanup(failed:) { cleanup_stty(resources, reader, sink) }
32
- end
33
-
34
- # 读取终端的 [行数, 列数];底层并非终端或句柄已关闭时保留原生 IO 异常。
35
- def winsize = to_io.winsize
36
-
37
- # 更新终端尺寸,由内核通知前台进程。
38
- def winsize=(size)
39
- to_io.winsize = size
40
- end
41
-
42
- private
43
-
44
- # 正常路径阻塞取得真实状态,不额外轮询或固定等待;EINTR 不重新执行 stty。
45
- def wait_stty(pid)
46
- Process.waitpid2(pid).last
47
- rescue Errno::EINTR
48
- retry
49
- end
50
-
51
- # 即使账本初始化被中断,局部变量中的管道仍须关闭;单端失败不跳过其他清理。
52
- # 无主异常时由调用方传播首个常规清理错误。
53
- def cleanup_stty(resources, reader, sink)
54
- failure = nil
55
- [reader, sink].compact.each do |io|
56
- io.close unless io.closed?
57
- rescue IOError, SystemCallError => error
58
- failure ||= error
59
- end
60
- begin
61
- reap_stty(resources) if resources
62
- rescue IOError, SystemCallError => error
63
- failure ||= error
64
- end
65
- raise failure if failure
66
- end
67
-
68
- # 每阶段共享固定的 50ms 单调时钟预算:自然退出、TERM、KILL;中断不续期。
69
- # 仍无法同步回收或系统调用失败时交给 detach 的后台 wait,不伪造退出状态。
70
- def reap_stty(resources)
71
- return unless resources.owner == Process.pid
72
-
73
- [nil, "TERM", "KILL"].each do |signal|
74
- deadline = Expect.monotonic + 0.05
75
- loop do
76
- begin
77
- resources.reap
78
- return resources.status unless resources.pid && resources.owner == Process.pid
79
-
80
- if signal
81
- begin
82
- Process.kill(signal, resources.pid)
83
- rescue Errno::ESRCH
84
- # 退出可发生在非阻塞 wait 与信号之间,仍需继续 wait。
85
- nil
86
- end
87
- signal = nil
88
- end
89
- rescue Errno::EINTR
90
- # 重试沿用本阶段期限,连续 EINTR 也必须交还控制权。
91
- nil
92
- end
93
- remaining = deadline - Expect.monotonic
94
- break unless remaining.positive?
95
-
96
- sleep [remaining, 0.005].min
97
- end
98
- end
99
- ensure
100
- Process.detach(resources.pid) if resources.pid && resources.owner == Process.pid
101
- end
102
- end
data/script/ci DELETED
@@ -1,121 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- cd "$(dirname "${BASH_SOURCE[0]}")/.."
5
-
6
- bundle exec rake
7
- bundle exec ruby examples/dialogue.rb
8
-
9
- # 只检查小规模工作负载的正确性,不为共享 CI 机器设置性能阈值。
10
- for benchmark in matching relay send_slow scaling redactor; do
11
- bundle exec ruby "benchmark/${benchmark}.rb" --smoke
12
- done
13
-
14
- mkdir -p tmp/ci
15
- gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
16
- gem_file="tmp/ci/expect-pty-${gem_version}.gem"
17
- if git rev-parse --git-dir >/dev/null 2>&1; then
18
- export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
19
- fi
20
- gem build expect-pty.gemspec --output "$gem_file"
21
-
22
- # 在独立目录安装刚构建的包,避免源码路径或 Bundler 掩盖打包遗漏。
23
- smoke_dir="$(mktemp -d)"
24
- trap 'rm -rf "$smoke_dir"' EXIT
25
- gem_file="$(pwd)/$gem_file"
26
- bundler_version="$(bundle exec ruby -rbundler -e 'print Bundler::VERSION')"
27
- bundler_gem="$(bundle exec ruby -e 'print Gem.loaded_specs.fetch("bundler").cache_file')"
28
-
29
- (
30
- cd "$smoke_dir"
31
- # 清除 Bundler 的自动加载与路径设置;Ruby 4 还会通过 BUNDLER_SETUP 自动启用 Bundler。
32
- for bundle_variable in ${!BUNDLE_@} ${!BUNDLER_@}; do
33
- unset "$bundle_variable"
34
- done
35
- unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS
36
- export GEM_HOME="$smoke_dir/gems" GEM_PATH="$smoke_dir/gems"
37
- gem install --local --no-document "$gem_file"
38
-
39
- cat > smoke.rb <<'RUBY'
40
- require "expect/pty"
41
- require "rbconfig"
42
-
43
- spec = Gem.loaded_specs.fetch("expect-pty")
44
- # 临时目录可能经由符号链接访问,先统一为实际路径再检查。
45
- install_root = File.realpath(ENV.fetch("GEM_HOME"))
46
- abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
47
- abort "Bundler leaked into the plain gem smoke" if ARGV.first == "plain" && defined?(Bundler)
48
- unexpected = %w[minitest rake rubocop parallel logger].select { |name| Gem.loaded_specs.key?(name) }
49
- abort "development dependencies leaked into the application: #{unexpected.join(', ')}" unless unexpected.empty?
50
- spec.runtime_dependencies.each do |dependency|
51
- loaded = Gem.loaded_specs.fetch(dependency.name)
52
- abort "incompatible dependency: #{dependency}" unless dependency.matches_spec?(loaded)
53
- end
54
-
55
- # 检查实际使用的标准库已在包元数据中声明,默认 gem 可加载不能代替依赖声明。
56
- %w[forwardable io-console io-wait shellwords stringio].each do |name|
57
- abort "undeclared runtime dependency: #{name}" unless spec.runtime_dependencies.any? { |dependency| dependency.name == name }
58
- end
59
- child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
60
- Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_stdout: false) do |session|
61
- session.winsize = [30, 100]
62
- abort "io-console terminal size failed" unless session.winsize == [30, 100]
63
- # BSD stty 设置模式会清空待读输出,先收取提示,使子进程停在等待输入的位置。
64
- abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
65
- mode = session.stty
66
- abort "installed stty query failed" if mode.empty?
67
- session.stty(mode)
68
-
69
- session.puts("expect-ruby")
70
- abort "installed gem PTY dialogue failed" unless session.expect("reply:expect-ruby", timeout: 5)
71
- status = session.soft_close(timeout: 5)
72
- abort "installed gem child did not exit successfully" unless status&.success?
73
- end
74
-
75
- # 使用已安装包验证公共过滤器随包分发,且结构化诊断无需加载可选的 Logger。
76
- filter = Expect::Redactor.new(["secret"], replacement: "[REDACTED]")
77
- filtered = filter.append("sec") + filter.append("ret!") + filter.finish
78
- abort "installed public redactor failed" unless filtered == "[REDACTED]!"
79
- abort "installed complete-text redactor failed" unless Expect::Redactor.redact("sec", ["secret"]) == "sec"
80
- reader, producer = IO.pipe
81
- events = []
82
- transcript = StringIO.new
83
- begin
84
- Expect.open(reader, own: true, buffer_limit: 5, debug_level: 2,
85
- diagnostic_output: ->(event) { events << event }) do |session|
86
- session.redact("secret")
87
- session.log_to(transcript)
88
- session.buffer = "ready"
89
- expired = session.expect_result("ready", deadline: Expect.monotonic)
90
- abort "expired deadline consumed buffered text" unless expired.timeout? && session.buffer == "ready"
91
- abort "zero timeout lost buffered text" unless session.expect("ready", timeout: 0) == 1
92
- producer.write("secret!")
93
- producer.close
94
- result = session.expect_result(:eof, deadline: Expect.monotonic + 5)
95
- abort "installed gem did not preserve capped input" unless result.eof? && result.before == "cret!"
96
- abort "incorrect installed buffer accounting" unless session.buffer_discarded_bytes == 2
97
- abort "installed redaction failed" unless transcript.string == "[FILTERED]!"
98
- abort "diagnostics leaked secret" if events.any? { |event| event[:message].include?("secret") }
99
- abort "filtered diagnostic missing" unless events.any? { |event| event[:message].include?("[FILTERED]") }
100
- end
101
- ensure
102
- reader.close unless reader.closed?
103
- producer.close unless producer.closed?
104
- end
105
-
106
- puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY, core contracts and runtime dependencies passed"
107
- RUBY
108
- ruby smoke.rb plain
109
-
110
- # 消费方仅声明当前 Gem,不能借用项目 Gemfile 中的测试和发布依赖。
111
- # 单独安装本次开发检查使用的 Bundler,避免依赖宿主 Ruby 的默认 Bundler 副本。
112
- if [ -f "$bundler_gem" ]; then
113
- gem install --local --no-document "$bundler_gem"
114
- else
115
- gem install --no-document bundler --version "$bundler_version"
116
- fi
117
- export BUNDLE_IGNORE_CONFIG=true
118
- printf 'source "https://rubygems.org"\ngem "expect-pty", "= %s", require: "expect/pty"\n' "$gem_version" > Gemfile
119
- bundle lock --local
120
- bundle exec ruby -rbundler -e 'Bundler.require; load "smoke.rb"' bundler
121
- )
data/script/release.rb DELETED
@@ -1,319 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require "digest"
5
- require "fileutils"
6
- require "json"
7
- require "net/http"
8
- require "open3"
9
- require "optparse"
10
- require "rubygems/package"
11
- require "tmpdir"
12
- require_relative "../lib/expect/version"
13
-
14
- # 本地使用 gem / gh 已有的登录状态;CI 传入测试过的包,发布阶段不重新构建。
15
- class Release
16
- REPOSITORY = "gatework/expect-ruby"
17
- GEM_HOST = "https://rubygems.org"
18
-
19
- def initialize(artifact: nil, dry_run: false, rubygems_only: false)
20
- @version = Expect::VERSION
21
- @tag = "v#{@version}"
22
- @artifact = File.expand_path(artifact || "tmp/ci/expect-pty-#{@version}.gem")
23
- @dry_run = dry_run
24
- @rubygems_only = rubygems_only
25
- @build = artifact.nil?
26
- end
27
-
28
- def run
29
- notes = self.class.read_notes("CHANGELOG.md", @version)
30
- unless @dry_run || capture("git", "status", "--porcelain").empty?
31
- raise "Commit all source changes before publishing"
32
- end
33
-
34
- @commit = capture("git", "rev-parse", "HEAD") unless @dry_run
35
-
36
- command("bash", "script/ci") if @build
37
- # 独占目录中的副本贯穿校验和上传,其他构建不会改变本次发布的字节。
38
- directory = File.join("tmp", "release", @version)
39
- FileUtils.mkdir_p(directory)
40
- directory = Dir.mktmpdir("candidate-", directory)
41
- candidate = File.join(directory, "expect-pty-#{@version}.gem")
42
- FileUtils.cp(@artifact, candidate)
43
- @artifact = File.expand_path(candidate)
44
- verify_package
45
- @sha256 = Digest::SHA256.file(@artifact).hexdigest
46
- @checksum_file = File.join(directory, "SHA256SUMS")
47
- @notes_file = File.join(directory, "release-notes.md")
48
- File.write(@checksum_file, "#{@sha256} #{File.basename(@artifact)}\n")
49
- File.write(@notes_file, "#{notes}\n", encoding: "UTF-8")
50
- puts "Verified #{@tag}: #{@sha256}\nArtifact: #{@artifact}"
51
- return puts "Dry run complete: #{@artifact}" if @dry_run
52
-
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
60
- publish_rubygems
61
- end
62
-
63
- # 有未归档的变更时拒绝发布,避免把新接口放进旧版本或遗漏发布说明。
64
- def self.read_notes(path, version)
65
- changelog = File.read(path, encoding: "UTF-8")
66
- raise "Invalid UTF-8 in #{path}" unless changelog.valid_encoding?
67
-
68
- release_notes(changelog, version)
69
- end
70
-
71
- # 纯章节解析与文件编码边界分离,本地入口和工作流共用 read_notes。
72
- def self.release_notes(changelog, version)
73
- raise "Use a stable X.Y.Z version" unless /\A\d+\.\d+\.\d+\z/.match?(version)
74
-
75
- sections = changelog.split(/^## /).drop(1).map do |section|
76
- heading, body = section.split("\n", 2)
77
- [heading.strip, body]
78
- end
79
- unreleased = sections.find { |heading, _body| heading == "Unreleased" }
80
- if unreleased && !unreleased[1].to_s.strip.empty?
81
- raise "Move Unreleased changes into the versioned changelog before releasing"
82
- end
83
-
84
- section = sections.find do |heading, _body|
85
- /\A#{Regexp.escape(version)}(?: - \d{4}-\d{2}-\d{2})?\z/.match?(heading)
86
- end
87
- raise "Missing release notes for #{version}" unless section && !section[1].to_s.strip.empty?
88
-
89
- section[1].strip
90
- end
91
-
92
- private
93
-
94
- def capture(*)
95
- capture_bytes(*).strip
96
- end
97
-
98
- # Git blob 是任意字节,不能沿用命令文本的 strip,尾部换行和 NUL 也属于发布内容。
99
- def capture_bytes(*arguments)
100
- output, error, status = Open3.capture3(*arguments, binmode: true)
101
- raise "#{arguments.first} failed: #{error.strip}" unless status.success?
102
-
103
- output
104
- end
105
-
106
- def command(*arguments)
107
- raise "#{arguments.first} failed" unless system(*arguments)
108
- end
109
-
110
- def github(path, missing: false)
111
- output, error, status = Open3.capture3("gh", "api", "repos/#{REPOSITORY}/#{path}")
112
- return nil if missing && !status.success? && error.include?("HTTP 404")
113
- raise "GitHub API failed: #{error.strip}" unless status.success?
114
-
115
- JSON.parse(output)
116
- end
117
-
118
- def verify_local_source
119
- return if capture("git", "rev-parse", "HEAD") == @commit && capture("git", "status", "--porcelain").empty?
120
-
121
- raise "Source changed during verification; commit the changes and start again"
122
- end
123
-
124
- def verify_remote_source
125
- verify_local_source
126
-
127
- comparison = github("compare/#{@commit}...main")
128
- raise "Push this commit to #{REPOSITORY}/main first" unless %w[ahead identical].include?(comparison.fetch("status"))
129
-
130
- return unless github("git/ref/tags/#{@tag}", missing: true)
131
- return if github("compare/#{@tag}...#{@commit}").fetch("status") == "identical"
132
-
133
- raise "Remote tag #{@tag} points to another commit"
134
- end
135
-
136
- # 检查包内每个文件,防止拿旧包或其他提交的产物发布到当前标签。
137
- def verify_package
138
- package = Gem::Package.new(@artifact)
139
- expected = Gem::Specification.load(File.expand_path("expect-pty.gemspec"))
140
- fields = %i[name version platform summary description authors licenses homepage required_ruby_version
141
- required_rubygems_version require_paths metadata dependencies extensions executables bindir
142
- post_install_message]
143
- unless fields.all? { |field| package.spec.public_send(field) == expected.public_send(field) }
144
- raise "Artifact metadata does not match the gemspec"
145
- end
146
- raise "Artifact file list differs from the source" unless package.contents.sort == expected.files.sort
147
-
148
- committed = committed_files if @commit
149
- # 直接检查归档中的权限,避免解包时本机 umask 改写执行位。
150
- File.open(@artifact, "rb") do |io|
151
- Gem::Package::TarReader.new(io) do |archive|
152
- data = archive.find { |entry| entry.full_name == "data.tar.gz" }
153
- package.open_tar_gz(data) do |tar|
154
- tar.each do |entry|
155
- file = entry.full_name
156
- bytes = entry.read
157
- raise "Artifact differs from source: #{file}" unless entry.file? && bytes == File.binread(file)
158
-
159
- verify_committed_file(file, bytes, entry.header.mode, committed) if committed
160
- next if entry.header.mode & 0o111 == File.stat(file).mode & 0o111
161
-
162
- raise "Artifact executable permissions differ: #{file}"
163
- end
164
- end
165
- end
166
- end
167
- end
168
-
169
- # 工作区干净不代表所有打包文件都来自提交:ignore、assume-unchanged 和 filemode 均可隐藏差异。
170
- # 只在正式发布时核对提交树;dry-run 仍允许检验尚未提交的开发源码。
171
- def committed_files
172
- capture_bytes("git", "ls-tree", "-rz", "--full-tree", @commit).split("\0").to_h do |record|
173
- metadata, file = record.split("\t", 2)
174
- mode, type, object = metadata.split
175
- [file, [mode.to_i(8), type, object]]
176
- end
177
- end
178
-
179
- def verify_committed_file(file, bytes, mode, committed)
180
- committed_mode, type, object = committed[file.b]
181
- unless type == "blob" && committed_mode & 0o170000 == 0o100000
182
- raise "Artifact file is not in release commit: #{file}"
183
- end
184
- unless bytes == capture_bytes("git", "cat-file", "blob", object)
185
- raise "Artifact differs from release commit: #{file}"
186
- end
187
- return if mode & 0o111 == committed_mode & 0o111
188
-
189
- raise "Artifact executable permissions differ from release commit: #{file}"
190
- end
191
-
192
- def get(path)
193
- uri = URI("#{GEM_HOST}#{path}")
194
- Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
195
- http.get(uri.request_uri)
196
- end
197
- end
198
-
199
- def registry_version
200
- response = get("/api/v2/rubygems/expect-pty/versions/#{@version}.json")
201
- return nil if response.code == "404"
202
- raise "RubyGems lookup failed: HTTP #{response.code}" unless response.code == "200"
203
-
204
- JSON.parse(response.body)
205
- end
206
-
207
- def verify_registry_checksum(version)
208
- return unless version
209
- return if !version.fetch("yanked") && version.fetch("sha") == @sha256
210
-
211
- raise "RubyGems already has different or yanked bytes for #{@version}; use the original artifact or a new version"
212
- end
213
-
214
- # gh 同时查找已发布版本和遗留草稿,REST 按标签查询只能找到已发布版本。
215
- def github_release
216
- output, error, status = Open3.capture3("gh", "release", "view", @tag, "--repo", REPOSITORY,
217
- "--json", "isDraft,assets")
218
- return nil if !status.success? && error.strip == "release not found"
219
- raise "GitHub Release lookup failed: #{error.strip}" unless status.success?
220
-
221
- JSON.parse(output)
222
- end
223
-
224
- # 先补齐 GitHub Release;RubyGems 认证失败时,已上传的原包仍可用于重试。
225
- def publish_github
226
- release = github_release
227
- assets = [@artifact, @checksum_file]
228
- if release
229
- existing, missing = assets.partition do |asset|
230
- entry = release.fetch("assets").find { |item| item.fetch("name") == File.basename(asset) }
231
- if entry && entry.fetch("state") != "uploaded"
232
- raise "Incomplete GitHub asset: #{entry.fetch("name")}; stop any active upload, remove the incomplete " \
233
- "asset in GitHub Release, then retry with the same artifact"
234
- end
235
- entry
236
- end
237
- verify_github_assets(existing)
238
- missing.each { |asset| command("gh", "release", "upload", @tag, asset, "--repo", REPOSITORY) }
239
- verify_github_assets(missing)
240
- if release.fetch("isDraft")
241
- command("gh", "release", "edit", @tag, "--repo", REPOSITORY, "--target", @commit, "--draft=false")
242
- end
243
- else
244
- command("gh", "release", "create", @tag, *assets, "--repo", REPOSITORY, "--target", @commit,
245
- "--title", @tag, "--notes-file", @notes_file)
246
- verify_github_assets(assets)
247
- end
248
- unless github("compare/#{@tag}...#{@commit}").fetch("status") == "identical"
249
- raise "Published tag points to another commit"
250
- end
251
-
252
- puts "GitHub Release: https://github.com/#{REPOSITORY}/releases/tag/#{@tag}"
253
- end
254
-
255
- def verify_github_assets(assets)
256
- # 新上传的附件也读回核验,工作流成功不代替远端产物校验。
257
- Dir.mktmpdir("expect-release-readback-") do |directory|
258
- assets.each do |asset|
259
- name = File.basename(asset)
260
- command("gh", "release", "download", @tag, "--repo", REPOSITORY, "--pattern", name, "--dir", directory)
261
- unless Digest::SHA256.file(File.join(directory, name)).hexdigest == Digest::SHA256.file(asset).hexdigest
262
- raise "GitHub Release asset verification failed: #{name}"
263
- end
264
- end
265
- end
266
- end
267
-
268
- def publish_rubygems
269
- version = registry_version
270
- verify_registry_checksum(version)
271
- unless version
272
- if ENV["GITHUB_ACTIONS"] == "true" && ENV.fetch("GEM_HOST_API_KEY", "").empty?
273
- raise "Set the repository Actions secret RUBYGEMS_API_KEY, or publish locally with the existing gem login"
274
- end
275
-
276
- pushed = system("gem", "push", @artifact, "--host", GEM_HOST)
277
- # 推送返回失败也先读取远端,避免连接中断后盲目重复上传。
278
- version = registry_version
279
- verify_registry_checksum(version)
280
- raise "gem push failed; check RubyGems authentication and retry with the same artifact" unless pushed || version
281
- end
282
-
283
- 6.times do |attempt|
284
- response = get("/downloads/expect-pty-#{@version}.gem")
285
- if response.code == "200"
286
- raise "Downloaded RubyGems artifact checksum differs" unless Digest::SHA256.hexdigest(response.body) == @sha256
287
-
288
- return puts "RubyGems: #{GEM_HOST}/gems/expect-pty/versions/#{@version} (SHA256 verified)"
289
- end
290
- raise "RubyGems download failed: HTTP #{response.code}" unless response.code == "404"
291
-
292
- sleep 2 unless attempt == 5
293
- end
294
- raise "RubyGems download is not available yet; retry with the same artifact"
295
- end
296
- end
297
-
298
- if $PROGRAM_NAME == __FILE__
299
- options = {}
300
- parser = OptionParser.new do |arguments|
301
- arguments.banner = "Usage: ruby script/release.rb [--rubygems-only] [--dry-run] [--artifact PATH]"
302
- arguments.on("--rubygems-only", "Publish only to RubyGems using the existing gem login; no GitHub access") do
303
- options[:rubygems_only] = true
304
- end
305
- arguments.on("--artifact PATH", "Publish an already verified gem without rebuilding") do |path|
306
- options[:artifact] = File.expand_path(path)
307
- end
308
- arguments.on("--dry-run", "Build and verify locally without publishing") { options[:dry_run] = true }
309
- end
310
- begin
311
- parser.parse!
312
- raise OptionParser::InvalidArgument, ARGV.join(" ") unless ARGV.empty?
313
-
314
- Dir.chdir(File.expand_path("..", __dir__)) { Release.new(**options).run }
315
- rescue StandardError => error
316
- warn "Release aborted: #{error.message}"
317
- exit 1
318
- end
319
- end
@@ -1,83 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "test_helper"
4
-
5
- class BufferAccountingTest < ExpectTest
6
- def test_limit_discards_are_cumulative_and_distinct_from_consumption
7
- session, = pipe_session(buffer_limit: 4)
8
- assert_equal 0, session.buffer_discarded_bytes
9
- session.buffer = "abcdef"
10
- assert_equal "cdef", session.buffer
11
- assert_equal 2, session.buffer_discarded_bytes
12
- session.buffer_limit = 2
13
- assert_equal "ef", session.buffer
14
- assert_equal 4, session.buffer_discarded_bytes
15
- assert_equal 1, session.expect("e", timeout: 0)
16
- assert_equal "f", session.clear_buffer
17
- session.close
18
- assert_equal 4, session.buffer_discarded_bytes
19
- refute_respond_to session, :buffer_discarded_bytes=
20
- end
21
-
22
- def test_invalid_limit_and_buffer_leave_accounting_unchanged
23
- session, = pipe_session(buffer_limit: 4)
24
- session.buffer = "abcdef"
25
- assert_raises(ArgumentError) { session.buffer_limit = 0 }
26
- assert_raises(ArgumentError) { session.buffer = nil }
27
- assert_equal "cdef", session.buffer
28
- assert_equal 2, session.buffer_discarded_bytes
29
- session.buffer_limit = nil
30
- session.buffer = "unlimited"
31
- assert_equal 2, session.buffer_discarded_bytes
32
- end
33
-
34
- def test_continuous_output_keeps_a_bounded_window_and_complete_log
35
- session, writer = pipe_session(buffer_limit: 64)
36
- logged = 0
37
- session.log_to { |data| logged += data.bytesize }
38
- 32.times do
39
- writer.write("x" * 4096)
40
- assert_nil session.expect("missing", timeout: 0)
41
- assert_equal 64, session.buffer.bytesize
42
- end
43
- assert_equal 131_072, logged
44
- assert_equal logged - 64, session.buffer_discarded_bytes
45
- end
46
-
47
- def test_preserved_match_does_not_count_as_discard
48
- session, = pipe_session(buffer_limit: 4, preserve_buffer: true)
49
- session.buffer = "abcdef"
50
- 2.times { assert_equal 1, session.expect("ef", timeout: 0) }
51
- assert_equal "cdef", session.buffer
52
- assert_equal 2, session.buffer_discarded_bytes
53
- end
54
-
55
- def test_relay_tail_is_only_counted_when_matching_applies_the_limit
56
- session, writer = pipe_session(buffer_limit: 2)
57
- output = StringIO.new
58
- session.listeners = [output]
59
- session.on_sequence("STOP")
60
- writer.write("prefixSTOPtail")
61
- assert_same session, Expect.interconnect(session, timeout: 1)
62
- assert_equal "prefix", output.string
63
- assert_equal "tail", session.buffer
64
- assert_equal 0, session.buffer_discarded_bytes
65
- assert_equal 1, session.expect("il", timeout: 0)
66
- assert_equal 2, session.buffer_discarded_bytes
67
- session.expect(timeout: 0)
68
- assert_equal 2, session.buffer_discarded_bytes
69
- end
70
-
71
- def test_logging_failure_preserves_the_retained_input_and_discard_count
72
- session, writer = pipe_session(buffer_limit: 4)
73
- failure = IOError.new("log failed")
74
- session.log_to { raise failure }
75
- writer.write("abcdef")
76
- assert_same failure, session.expect_result("ef", timeout: 1).error
77
- assert_equal "cdef", session.buffer
78
- assert_equal 2, session.buffer_discarded_bytes
79
- session.log_output = nil
80
- assert_equal 1, session.expect("ef", timeout: 0)
81
- assert_equal 2, session.buffer_discarded_bytes
82
- end
83
- end