expect-pty 0.5.3 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +28 -19
- data/docs/API.md +88 -0
- data/docs/MIGRATION.md +15 -0
- data/lib/expect/cleanup.rb +33 -0
- data/lib/expect/configuration.rb +87 -66
- data/lib/expect/interaction.rb +226 -209
- data/lib/expect/logging.rb +157 -156
- data/lib/expect/matcher.rb +30 -29
- data/lib/expect/pattern.rb +8 -2
- data/lib/expect/pattern_list.rb +35 -5
- data/lib/expect/redactor.rb +11 -4
- data/lib/expect/relay.rb +97 -73
- data/lib/expect/relay_writer.rb +2 -1
- data/lib/expect/result.rb +32 -8
- data/lib/expect/session.rb +508 -0
- data/lib/expect/session_resources.rb +10 -2
- data/lib/expect/terminal.rb +78 -78
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +333 -520
- data/sig/expect.rbs +355 -0
- metadata +13 -106
- data/.rubocop.yml +0 -66
- data/CONTRIBUTING.md +0 -29
- data/Gemfile +0 -28
- data/Rakefile +0 -38
- data/benchmark/matching.rb +0 -98
- data/benchmark/redactor.rb +0 -64
- data/benchmark/relay.rb +0 -48
- data/benchmark/scaling.rb +0 -70
- data/benchmark/send_slow.rb +0 -41
- data/benchmark/support.rb +0 -114
- data/docs/COMPATIBILITY.md +0 -98
- data/docs/INTERNAL_CONTRACTS.md +0 -154
- data/docs/PERFORMANCE.md +0 -165
- data/docs/RELEASING.md +0 -84
- data/docs/VERIFICATION.md +0 -456
- data/examples/dialogue.rb +0 -30
- data/examples/kibitz/README.md +0 -81
- data/examples/kibitz/kibitz.rb +0 -142
- data/examples/kibitz/test_kibitz.rb +0 -37
- data/examples/ssh_auto.rb +0 -94
- data/examples/ssh_interact.rb +0 -159
- data/examples/ssh_login.rb +0 -64
- data/expect-pty.gemspec +0 -33
- data/script/ci +0 -121
- data/script/release.rb +0 -319
- data/test/buffer_accounting_test.rb +0 -83
- data/test/cleanup_test.rb +0 -251
- data/test/compare_upstream.rb +0 -157
- data/test/configuration_test.rb +0 -133
- data/test/deadline_test.rb +0 -237
- data/test/diagnostics_test.rb +0 -353
- data/test/edge_case_test.rb +0 -262
- data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
- data/test/fixtures/ssh_scripts/02_output.sh +0 -5
- data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
- data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
- data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
- data/test/initialization_failure_test.rb +0 -92
- data/test/integration/README.md +0 -109
- data/test/integration/ssh_scripts.rb +0 -94
- data/test/interact_test.rb +0 -253
- data/test/interconnect_test.rb +0 -425
- data/test/io_test.rb +0 -321
- data/test/kibitz_test.rb +0 -45
- data/test/lifecycle_contract_test.rb +0 -71
- data/test/literal_scan_test.rb +0 -97
- data/test/matching_test.rb +0 -211
- data/test/multi_session_test.rb +0 -66
- data/test/ownership_sequence_test.rb +0 -208
- data/test/pattern_offset_test.rb +0 -43
- data/test/process_interruption_test.rb +0 -296
- data/test/process_test.rb +0 -291
- data/test/redactor_test.rb +0 -183
- data/test/relay_recovery_test.rb +0 -451
- data/test/relay_reentrancy_test.rb +0 -159
- data/test/release_test.rb +0 -375
- data/test/ruby_api_test.rb +0 -515
- data/test/scan_reuse_test.rb +0 -109
- data/test/script_logging_test.rb +0 -124
- data/test/support/interact_probe.rb +0 -125
- data/test/support/kibitz_probe.rb +0 -177
- data/test/support/script_probe.rb +0 -158
- data/test/terminal_cleanup_test.rb +0 -349
- data/test/test_helper.rb +0 -58
- data/test/timeout_test.rb +0 -289
- data/test/write_contract_test.rb +0 -105
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
|
data/test/cleanup_test.rb
DELETED
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "test_helper"
|
|
4
|
-
|
|
5
|
-
class CleanupTest < ExpectTest
|
|
6
|
-
def test_failed_open_initialization_attempts_all_owned_handles_and_preserves_validation_error
|
|
7
|
-
reader, writer = IO.pipe
|
|
8
|
-
@ios.push(reader, writer)
|
|
9
|
-
reader.stub(:close, -> { raise IOError, "reader close failed" }) do
|
|
10
|
-
error = assert_raises(ArgumentError) { Expect.open(reader, writer: writer, own: true, unknown: true) }
|
|
11
|
-
assert_match(/unknown/, error.message)
|
|
12
|
-
assert writer.closed?
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def test_failed_new_initialization_attempts_all_pty_handles_and_preserves_validation_error
|
|
17
|
-
master, slave = PTY.open
|
|
18
|
-
@ios.push(master, slave)
|
|
19
|
-
PTY.stub(:open, [master, slave]) do
|
|
20
|
-
master.stub(:close, -> { raise IOError, "master close failed" }) do
|
|
21
|
-
error = assert_raises(ArgumentError) { Expect.new(unknown: true) }
|
|
22
|
-
assert_match(/unknown/, error.message)
|
|
23
|
-
assert slave.closed?
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def test_failed_open_closes_real_owned_handles_when_an_argument_is_not_io
|
|
29
|
-
[true, false].each do |invalid_reader|
|
|
30
|
-
reader, writer = IO.pipe
|
|
31
|
-
@ios.push(reader, writer)
|
|
32
|
-
invalid = Object.new
|
|
33
|
-
invalid.define_singleton_method(:close) { raise "invalid IO must not be closed" }
|
|
34
|
-
incoming = invalid_reader ? invalid : reader
|
|
35
|
-
outgoing = invalid_reader ? writer : invalid
|
|
36
|
-
assert_raises(ArgumentError) { Expect.open(incoming, writer: outgoing, own: true) }
|
|
37
|
-
assert(invalid_reader ? writer.closed? : reader.closed?)
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def test_open_block_error_is_preserved_when_owned_cleanup_also_fails
|
|
42
|
-
[ArgumentError.new("block failed"), Interrupt.new("interrupted"), SystemExit.new(17)].each do |failure|
|
|
43
|
-
reader, writer = IO.pipe
|
|
44
|
-
@ios.push(reader, writer)
|
|
45
|
-
reader.stub(:close, -> { raise IOError, "reader close failed" }) do
|
|
46
|
-
error = assert_raises(failure.class) do
|
|
47
|
-
Expect.open(reader, writer: writer, own: true) { raise failure }
|
|
48
|
-
end
|
|
49
|
-
assert_same failure, error
|
|
50
|
-
assert writer.closed?
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def test_open_block_break_still_reports_cleanup_failure
|
|
56
|
-
reader, writer = IO.pipe
|
|
57
|
-
@ios.push(reader, writer)
|
|
58
|
-
failure = IOError.new("reader close failed")
|
|
59
|
-
reader.stub(:close, -> { raise failure }) do
|
|
60
|
-
error = assert_raises(IOError) { Expect.open(reader, writer: writer, own: true) { break :done } }
|
|
61
|
-
assert_same failure, error
|
|
62
|
-
assert writer.closed?
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def test_spawn_block_error_is_preserved_and_child_reaped_when_cleanup_also_fails
|
|
67
|
-
session = stubborn_child
|
|
68
|
-
failure = ArgumentError.new("block failed")
|
|
69
|
-
Expect.stub(:new, session) do
|
|
70
|
-
session.stub(:spawn, session) do
|
|
71
|
-
session.to_io.stub(:close, -> { raise IOError, "reader close failed" }) do
|
|
72
|
-
error = assert_raises(ArgumentError) { Expect.spawn("already started") { raise failure } }
|
|
73
|
-
assert_same failure, error
|
|
74
|
-
assert_nil session.pid
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def test_graceful_close_preserves_logging_error_when_handle_cleanup_also_fails
|
|
81
|
-
session = stubborn_child
|
|
82
|
-
failure = ArgumentError.new("log failed")
|
|
83
|
-
session.log_output = ->(_) { raise failure }
|
|
84
|
-
session.to_io.stub(:wait_readable, true) do
|
|
85
|
-
session.to_io.stub(:read_nonblock, "last output") do
|
|
86
|
-
session.to_io.stub(:close, -> { raise IOError, "reader close failed" }) do
|
|
87
|
-
assert_same failure, assert_raises(ArgumentError) { session.close(graceful: true) }
|
|
88
|
-
assert_nil session.pid
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def test_close_handles_attempts_every_owned_handle_and_preserves_first_error
|
|
95
|
-
reader, writer = IO.pipe
|
|
96
|
-
extra, peer = IO.pipe
|
|
97
|
-
@ios.push(reader, writer, extra, peer)
|
|
98
|
-
resources = Expect::SessionResources.new(reader, writer: writer, slave: extra, own: true)
|
|
99
|
-
failure = IOError.new("reader close failed")
|
|
100
|
-
reader.stub(:close, -> { raise failure }) do
|
|
101
|
-
assert_same failure, assert_raises(IOError) { resources.close_handles }
|
|
102
|
-
assert writer.closed?
|
|
103
|
-
assert extra.closed?
|
|
104
|
-
end
|
|
105
|
-
resources.close_handles
|
|
106
|
-
assert reader.closed?
|
|
107
|
-
resources.close_handles
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def test_hard_close_finishes_wrappers_log_and_child_after_handle_failure
|
|
111
|
-
session = stubborn_child
|
|
112
|
-
pid = session.pid
|
|
113
|
-
first, = pipe_session
|
|
114
|
-
second, = pipe_session
|
|
115
|
-
session.instance_variable_set(:@interact_inputs, { first.to_io => first, second.to_io => second })
|
|
116
|
-
Dir.mktmpdir do |dir|
|
|
117
|
-
log = session.log_to(File.join(dir, "owned.log"))
|
|
118
|
-
failure = IOError.new("handle close failed")
|
|
119
|
-
session.to_io.stub(:close, -> { raise failure }) do
|
|
120
|
-
first.stub(:close, ->(**) { raise IOError, "wrapper close failed" }) do
|
|
121
|
-
error = assert_raises(IOError) { bounded { session.hard_close(timeout: 0.01) } }
|
|
122
|
-
assert_same failure, error
|
|
123
|
-
assert second.closed?
|
|
124
|
-
refute second.to_io.closed?, "borrowed wrapper IO must remain open"
|
|
125
|
-
assert log.closed?
|
|
126
|
-
assert_nil session.log_output
|
|
127
|
-
assert_nil session.pid
|
|
128
|
-
assert_equal Signal.list.fetch("KILL"), session.process_status.termsig
|
|
129
|
-
assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
ensure
|
|
134
|
-
session&.hard_close(timeout: 0)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def test_owned_log_close_failure_is_visible_after_child_reaping_and_retryable
|
|
138
|
-
session = stubborn_child
|
|
139
|
-
Dir.mktmpdir do |dir|
|
|
140
|
-
log = session.log_to(File.join(dir, "owned.log"))
|
|
141
|
-
log.stub(:close, -> { raise IOError, "log close failed" }) do
|
|
142
|
-
assert_raises(IOError) { bounded { session.hard_close(timeout: 0.01) } }
|
|
143
|
-
assert_nil session.pid
|
|
144
|
-
assert_same log, session.log_output
|
|
145
|
-
end
|
|
146
|
-
status = session.hard_close(timeout: 0)
|
|
147
|
-
assert_same session.process_status, status
|
|
148
|
-
assert log.closed?
|
|
149
|
-
assert_nil session.log_output
|
|
150
|
-
end
|
|
151
|
-
ensure
|
|
152
|
-
session&.hard_close(timeout: 0)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def test_explicit_close_inside_rescue_does_not_hide_a_new_cleanup_error
|
|
156
|
-
session, = pipe_session(own: true)
|
|
157
|
-
failure = IOError.new("close failed inside caller rescue")
|
|
158
|
-
begin
|
|
159
|
-
raise ArgumentError, "already handled"
|
|
160
|
-
rescue ArgumentError
|
|
161
|
-
session.to_io.stub(:close, -> { raise failure }) do
|
|
162
|
-
assert_same failure, assert_raises(IOError) { session.hard_close(timeout: 0) }
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def test_unexpected_process_error_is_not_replaced_by_cleanup_failure
|
|
168
|
-
session = stubborn_child
|
|
169
|
-
failure = RuntimeError.new("process wait failed")
|
|
170
|
-
session.to_io.stub(:close, -> { raise IOError, "close failed" }) do
|
|
171
|
-
session.stub(:wait, ->(**) { raise failure }) do
|
|
172
|
-
assert_same failure, assert_raises(RuntimeError) { session.hard_close(timeout: 0) }
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
ensure
|
|
176
|
-
session&.hard_close(timeout: 0)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def test_soft_close_handle_failure_still_sends_term_but_never_kill
|
|
180
|
-
session = stubborn_child
|
|
181
|
-
signals = []
|
|
182
|
-
original = Process.method(:kill)
|
|
183
|
-
Process.stub(:kill, lambda { |signal, pid|
|
|
184
|
-
signals << signal
|
|
185
|
-
original.call(signal, pid)
|
|
186
|
-
}) do
|
|
187
|
-
session.to_io.stub(:close, -> { raise Errno::EIO, "close failed" }) do
|
|
188
|
-
assert_raises(Errno::EIO) { bounded { session.soft_close(timeout: 0, term_timeout: 0) } }
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
assert_equal ["TERM"], signals
|
|
192
|
-
assert session.alive?
|
|
193
|
-
assert session.closed?
|
|
194
|
-
ensure
|
|
195
|
-
session&.hard_close(timeout: 0)
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def test_finalizer_reaps_after_both_handle_and_log_close_fail
|
|
199
|
-
session = stubborn_child
|
|
200
|
-
pid = session.pid
|
|
201
|
-
resources = session.instance_variable_get(:@resources)
|
|
202
|
-
log_attempted = false
|
|
203
|
-
Dir.mktmpdir do |dir|
|
|
204
|
-
log = session.log_to(File.join(dir, "owned.log"))
|
|
205
|
-
session.to_io.stub(:close, -> { raise IOError, "handle failed" }) do
|
|
206
|
-
log.stub(:close, lambda {
|
|
207
|
-
log_attempted = true
|
|
208
|
-
raise Errno::EIO, "log failed"
|
|
209
|
-
}) do
|
|
210
|
-
bounded { resources.finalize }
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
assert log_attempted
|
|
214
|
-
assert_nil resources.pid
|
|
215
|
-
bounded do
|
|
216
|
-
loop do
|
|
217
|
-
Process.kill(0, pid)
|
|
218
|
-
sleep 0.005
|
|
219
|
-
rescue Errno::ESRCH
|
|
220
|
-
break
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
ensure
|
|
225
|
-
session&.hard_close(timeout: 0)
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def test_non_owner_cleanup_never_signals_and_finalizer_leaves_handles_alone
|
|
229
|
-
session = stubborn_child
|
|
230
|
-
resources = session.instance_variable_get(:@resources)
|
|
231
|
-
Process.stub(:pid, -1) do
|
|
232
|
-
Process.stub(:kill, ->(*) { flunk "non-owner sent a signal" }) do
|
|
233
|
-
resources.finalize
|
|
234
|
-
refute session.to_io.closed?
|
|
235
|
-
assert_nil session.hard_close(timeout: 0)
|
|
236
|
-
end
|
|
237
|
-
end
|
|
238
|
-
assert session.alive?
|
|
239
|
-
ensure
|
|
240
|
-
session&.hard_close(timeout: 0)
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
private
|
|
244
|
-
|
|
245
|
-
def stubborn_child
|
|
246
|
-
session = child('Signal.trap("HUP", "IGNORE"); Signal.trap("TERM", "IGNORE"); puts "ready"; sleep 60',
|
|
247
|
-
raw_pty: true)
|
|
248
|
-
assert_equal 1, session.expect("ready", timeout: 2)
|
|
249
|
-
session
|
|
250
|
-
end
|
|
251
|
-
end
|