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.
- checksums.yaml +7 -0
- data/.rubocop.yml +29 -0
- data/CHANGELOG.md +25 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +264 -0
- data/Rakefile +38 -0
- data/docs/COMPATIBILITY.md +65 -0
- data/docs/RELEASING.md +55 -0
- data/docs/VERIFICATION.md +146 -0
- data/examples/dialogue.rb +30 -0
- data/examples/kibitz/README.md +73 -0
- data/examples/kibitz/kibitz.rb +139 -0
- data/examples/kibitz/test_kibitz.rb +37 -0
- data/examples/ssh_auto.rb +94 -0
- data/examples/ssh_interact.rb +159 -0
- data/examples/ssh_login.rb +64 -0
- data/expect-pty.gemspec +25 -0
- data/lib/expect/configuration.rb +113 -0
- data/lib/expect/engine.rb +141 -0
- data/lib/expect/interconnect.rb +170 -0
- data/lib/expect/pattern.rb +62 -0
- data/lib/expect/pattern_list.rb +90 -0
- data/lib/expect/pty.rb +4 -0
- data/lib/expect/resources.rb +63 -0
- data/lib/expect/result.rb +14 -0
- data/lib/expect/version.rb +6 -0
- data/lib/expect.rb +591 -0
- data/script/ci +44 -0
- data/script/release.rb +267 -0
- data/test/compare_upstream.rb +157 -0
- data/test/configuration_test.rb +90 -0
- data/test/edge_case_test.rb +183 -0
- data/test/fixtures/ssh_scripts/01_identity.sh +4 -0
- data/test/fixtures/ssh_scripts/02_output.sh +5 -0
- data/test/fixtures/ssh_scripts/03_delayed.sh +6 -0
- data/test/fixtures/ssh_scripts/04_failure.sh +2 -0
- data/test/fixtures/ssh_scripts/05_recovery.sh +3 -0
- data/test/integration/README.md +90 -0
- data/test/integration/ssh_scripts.rb +94 -0
- data/test/interact_test.rb +151 -0
- data/test/interconnect_test.rb +226 -0
- data/test/io_test.rb +184 -0
- data/test/kibitz_test.rb +45 -0
- data/test/matching_test.rb +178 -0
- data/test/multi_session_test.rb +66 -0
- data/test/process_test.rb +244 -0
- data/test/release_test.rb +153 -0
- data/test/ruby_api_test.rb +515 -0
- data/test/script_logging_test.rb +124 -0
- data/test/support/interact_probe.rb +125 -0
- data/test/support/kibitz_probe.rb +177 -0
- data/test/support/script_probe.rb +157 -0
- data/test/test_helper.rb +58 -0
- data/test/timeout_test.rb +170 -0
- metadata +97 -0
data/script/release.rb
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
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)
|
|
20
|
+
@version = Expect::VERSION
|
|
21
|
+
@tag = "v#{@version}"
|
|
22
|
+
@artifact = File.expand_path(artifact || "pkg/ci/expect-pty-#{@version}.gem")
|
|
23
|
+
@dry_run = dry_run
|
|
24
|
+
@build = artifact.nil?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
notes = self.class.release_notes(File.read("CHANGELOG.md"), @version)
|
|
29
|
+
unless @dry_run || capture("git", "status", "--porcelain").empty?
|
|
30
|
+
raise "Commit all source changes before publishing"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@commit = capture("git", "rev-parse", "HEAD") unless @dry_run
|
|
34
|
+
|
|
35
|
+
command("bash", "script/ci") if @build
|
|
36
|
+
# 独占目录中的副本贯穿校验和上传,其他构建不会改变本次发布的字节。
|
|
37
|
+
directory = File.join("pkg", "release", @version)
|
|
38
|
+
FileUtils.mkdir_p(directory)
|
|
39
|
+
directory = Dir.mktmpdir("candidate-", directory)
|
|
40
|
+
candidate = File.join(directory, "expect-pty-#{@version}.gem")
|
|
41
|
+
FileUtils.cp(@artifact, candidate)
|
|
42
|
+
@artifact = File.expand_path(candidate)
|
|
43
|
+
verify_package
|
|
44
|
+
@sha256 = Digest::SHA256.file(@artifact).hexdigest
|
|
45
|
+
@checksum_file = File.join(directory, "SHA256SUMS")
|
|
46
|
+
@notes_file = File.join(directory, "release-notes.md")
|
|
47
|
+
File.write(@checksum_file, "#{@sha256} #{File.basename(@artifact)}\n")
|
|
48
|
+
File.write(@notes_file, "#{notes}\n")
|
|
49
|
+
puts "Verified #{@tag}: #{@sha256}\nArtifact: #{@artifact}"
|
|
50
|
+
return puts "Dry run complete: #{@artifact}" if @dry_run
|
|
51
|
+
|
|
52
|
+
verify_remote_source
|
|
53
|
+
verify_registry_checksum(registry_version)
|
|
54
|
+
publish_github
|
|
55
|
+
publish_rubygems
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# 有未归档的变更时拒绝发布,避免把新接口放进旧版本或遗漏发布说明。
|
|
59
|
+
def self.release_notes(changelog, version)
|
|
60
|
+
raise "Use a stable X.Y.Z version" unless /\A\d+\.\d+\.\d+\z/.match?(version)
|
|
61
|
+
|
|
62
|
+
sections = changelog.split(/^## /).drop(1).map do |section|
|
|
63
|
+
heading, body = section.split("\n", 2)
|
|
64
|
+
[heading.strip, body]
|
|
65
|
+
end
|
|
66
|
+
unreleased = sections.find { |heading, _body| heading == "Unreleased" }
|
|
67
|
+
if unreleased && !unreleased[1].to_s.strip.empty?
|
|
68
|
+
raise "Move Unreleased changes into the versioned changelog before releasing"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
section = sections.find do |heading, _body|
|
|
72
|
+
/\A#{Regexp.escape(version)}(?: - \d{4}-\d{2}-\d{2})?\z/.match?(heading)
|
|
73
|
+
end
|
|
74
|
+
raise "Missing release notes for #{version}" unless section && !section[1].to_s.strip.empty?
|
|
75
|
+
|
|
76
|
+
section[1].strip
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def capture(*arguments)
|
|
82
|
+
output, error, status = Open3.capture3(*arguments)
|
|
83
|
+
raise "#{arguments.first} failed: #{error.strip}" unless status.success?
|
|
84
|
+
|
|
85
|
+
output.strip
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def command(*arguments)
|
|
89
|
+
raise "#{arguments.first} failed" unless system(*arguments)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def github(path, missing: false)
|
|
93
|
+
output, error, status = Open3.capture3("gh", "api", "repos/#{REPOSITORY}/#{path}")
|
|
94
|
+
return nil if missing && !status.success? && error.include?("HTTP 404")
|
|
95
|
+
raise "GitHub API failed: #{error.strip}" unless status.success?
|
|
96
|
+
|
|
97
|
+
JSON.parse(output)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def verify_remote_source
|
|
101
|
+
unless capture("git", "rev-parse", "HEAD") == @commit && capture("git", "status", "--porcelain").empty?
|
|
102
|
+
raise "Source changed during verification; commit the changes and start again"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
comparison = github("compare/#{@commit}...main")
|
|
106
|
+
raise "Push this commit to #{REPOSITORY}/main first" unless %w[ahead identical].include?(comparison.fetch("status"))
|
|
107
|
+
|
|
108
|
+
return unless github("git/ref/tags/#{@tag}", missing: true)
|
|
109
|
+
return if github("compare/#{@tag}...#{@commit}").fetch("status") == "identical"
|
|
110
|
+
|
|
111
|
+
raise "Remote tag #{@tag} points to another commit"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 检查包内每个文件,防止拿旧包或其他提交的产物发布到当前标签。
|
|
115
|
+
def verify_package
|
|
116
|
+
package = Gem::Package.new(@artifact)
|
|
117
|
+
expected = Gem::Specification.load(File.expand_path("expect-pty.gemspec"))
|
|
118
|
+
fields = %i[name version platform summary description authors licenses required_ruby_version
|
|
119
|
+
required_rubygems_version require_paths metadata dependencies extensions executables bindir
|
|
120
|
+
post_install_message]
|
|
121
|
+
unless fields.all? { |field| package.spec.public_send(field) == expected.public_send(field) }
|
|
122
|
+
raise "Artifact metadata does not match the gemspec"
|
|
123
|
+
end
|
|
124
|
+
raise "Artifact file list differs from the source" unless package.contents.sort == expected.files.sort
|
|
125
|
+
|
|
126
|
+
# 直接检查归档中的权限,避免解包时本机 umask 改写执行位。
|
|
127
|
+
File.open(@artifact, "rb") do |io|
|
|
128
|
+
Gem::Package::TarReader.new(io) do |archive|
|
|
129
|
+
data = archive.find { |entry| entry.full_name == "data.tar.gz" }
|
|
130
|
+
package.open_tar_gz(data) do |tar|
|
|
131
|
+
tar.each do |entry|
|
|
132
|
+
file = entry.full_name
|
|
133
|
+
raise "Artifact differs from source: #{file}" unless entry.file? && entry.read == File.binread(file)
|
|
134
|
+
next if entry.header.mode & 0o111 == File.stat(file).mode & 0o111
|
|
135
|
+
|
|
136
|
+
raise "Artifact executable permissions differ: #{file}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def get(path)
|
|
144
|
+
uri = URI("#{GEM_HOST}#{path}")
|
|
145
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
|
|
146
|
+
http.get(uri.request_uri)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def registry_version
|
|
151
|
+
response = get("/api/v2/rubygems/expect-pty/versions/#{@version}.json")
|
|
152
|
+
return nil if response.code == "404"
|
|
153
|
+
raise "RubyGems lookup failed: HTTP #{response.code}" unless response.code == "200"
|
|
154
|
+
|
|
155
|
+
JSON.parse(response.body)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def verify_registry_checksum(version)
|
|
159
|
+
return unless version
|
|
160
|
+
return if !version.fetch("yanked") && version.fetch("sha") == @sha256
|
|
161
|
+
|
|
162
|
+
raise "RubyGems already has different or yanked bytes for #{@version}; use the original artifact or a new version"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# gh 同时查找已发布版本和遗留草稿,REST 按标签查询只能找到已发布版本。
|
|
166
|
+
def github_release
|
|
167
|
+
output, error, status = Open3.capture3("gh", "release", "view", @tag, "--repo", REPOSITORY,
|
|
168
|
+
"--json", "isDraft,assets")
|
|
169
|
+
return nil if !status.success? && error.strip == "release not found"
|
|
170
|
+
raise "GitHub Release lookup failed: #{error.strip}" unless status.success?
|
|
171
|
+
|
|
172
|
+
JSON.parse(output)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# 先补齐 GitHub Release;RubyGems 认证失败时,已上传的原包仍可用于重试。
|
|
176
|
+
def publish_github
|
|
177
|
+
release = github_release
|
|
178
|
+
assets = [@artifact, @checksum_file]
|
|
179
|
+
if release
|
|
180
|
+
existing, missing = assets.partition do |asset|
|
|
181
|
+
entry = release.fetch("assets").find { |item| item.fetch("name") == File.basename(asset) }
|
|
182
|
+
if entry && entry.fetch("state") != "uploaded"
|
|
183
|
+
raise "Incomplete GitHub asset: #{entry.fetch("name")}; stop any active upload, remove the incomplete " \
|
|
184
|
+
"asset in GitHub Release, then retry with the same artifact"
|
|
185
|
+
end
|
|
186
|
+
entry
|
|
187
|
+
end
|
|
188
|
+
verify_github_assets(existing)
|
|
189
|
+
missing.each { |asset| command("gh", "release", "upload", @tag, asset, "--repo", REPOSITORY) }
|
|
190
|
+
verify_github_assets(missing)
|
|
191
|
+
if release.fetch("isDraft")
|
|
192
|
+
command("gh", "release", "edit", @tag, "--repo", REPOSITORY, "--target", @commit, "--draft=false")
|
|
193
|
+
end
|
|
194
|
+
else
|
|
195
|
+
command("gh", "release", "create", @tag, *assets, "--repo", REPOSITORY, "--target", @commit,
|
|
196
|
+
"--title", @tag, "--notes-file", @notes_file)
|
|
197
|
+
verify_github_assets(assets)
|
|
198
|
+
end
|
|
199
|
+
unless github("compare/#{@tag}...#{@commit}").fetch("status") == "identical"
|
|
200
|
+
raise "Published tag points to another commit"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
puts "GitHub Release: https://github.com/#{REPOSITORY}/releases/tag/#{@tag}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def verify_github_assets(assets)
|
|
207
|
+
# 新上传的附件也读回核验,工作流成功不代替远端产物校验。
|
|
208
|
+
Dir.mktmpdir("expect-release-readback-") do |directory|
|
|
209
|
+
assets.each do |asset|
|
|
210
|
+
name = File.basename(asset)
|
|
211
|
+
command("gh", "release", "download", @tag, "--repo", REPOSITORY, "--pattern", name, "--dir", directory)
|
|
212
|
+
unless Digest::SHA256.file(File.join(directory, name)).hexdigest == Digest::SHA256.file(asset).hexdigest
|
|
213
|
+
raise "GitHub Release asset verification failed: #{name}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def publish_rubygems
|
|
220
|
+
version = registry_version
|
|
221
|
+
verify_registry_checksum(version)
|
|
222
|
+
unless version
|
|
223
|
+
if ENV["GITHUB_ACTIONS"] == "true" && ENV.fetch("GEM_HOST_API_KEY", "").empty?
|
|
224
|
+
raise "Set the repository Actions secret RUBYGEMS_API_KEY, or publish locally with the existing gem login"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
pushed = system("gem", "push", @artifact, "--host", GEM_HOST)
|
|
228
|
+
# 推送返回失败也先读取远端,避免连接中断后盲目重复上传。
|
|
229
|
+
version = registry_version
|
|
230
|
+
verify_registry_checksum(version)
|
|
231
|
+
raise "gem push failed; check RubyGems authentication and retry with the same artifact" unless pushed || version
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
6.times do |attempt|
|
|
235
|
+
response = get("/downloads/expect-pty-#{@version}.gem")
|
|
236
|
+
if response.code == "200"
|
|
237
|
+
raise "Downloaded RubyGems artifact checksum differs" unless Digest::SHA256.hexdigest(response.body) == @sha256
|
|
238
|
+
|
|
239
|
+
return puts "RubyGems: #{GEM_HOST}/gems/expect-pty/versions/#{@version} (SHA256 verified)"
|
|
240
|
+
end
|
|
241
|
+
raise "RubyGems download failed: HTTP #{response.code}" unless response.code == "404"
|
|
242
|
+
|
|
243
|
+
sleep 2 unless attempt == 5
|
|
244
|
+
end
|
|
245
|
+
raise "RubyGems download is not available yet; retry with the same artifact"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if $PROGRAM_NAME == __FILE__
|
|
250
|
+
options = {}
|
|
251
|
+
parser = OptionParser.new do |arguments|
|
|
252
|
+
arguments.banner = "Usage: ruby script/release.rb [--dry-run] [--artifact PATH]"
|
|
253
|
+
arguments.on("--artifact PATH", "Publish an already verified gem without rebuilding") do |path|
|
|
254
|
+
options[:artifact] = File.expand_path(path)
|
|
255
|
+
end
|
|
256
|
+
arguments.on("--dry-run", "Build and verify locally without publishing") { options[:dry_run] = true }
|
|
257
|
+
end
|
|
258
|
+
begin
|
|
259
|
+
parser.parse!
|
|
260
|
+
raise OptionParser::InvalidArgument, ARGV.join(" ") unless ARGV.empty?
|
|
261
|
+
|
|
262
|
+
Dir.chdir(File.expand_path("..", __dir__)) { Release.new(**options).run }
|
|
263
|
+
rescue StandardError => error
|
|
264
|
+
warn "Release aborted: #{error.message}"
|
|
265
|
+
exit 1
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Optional differential check. The ordinary test suite has no Perl dependency.
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require_relative "../lib/expect"
|
|
8
|
+
|
|
9
|
+
upstream = ARGV.fetch(0) { abort "usage: ruby test/compare_upstream.rb /path/to/expect.pm" }
|
|
10
|
+
perl = <<~'PERL'
|
|
11
|
+
use strict;
|
|
12
|
+
use warnings;
|
|
13
|
+
use Expect;
|
|
14
|
+
use JSON::PP;
|
|
15
|
+
my %results;
|
|
16
|
+
sub fresh {
|
|
17
|
+
my ($buffer) = @_;
|
|
18
|
+
my $e = Expect->new;
|
|
19
|
+
$e->log_stdout(0);
|
|
20
|
+
$e->set_accum($buffer);
|
|
21
|
+
return $e;
|
|
22
|
+
}
|
|
23
|
+
sub snapshot {
|
|
24
|
+
my ($e) = @_;
|
|
25
|
+
my $captures = $e->exp_matchlist;
|
|
26
|
+
return { number => $e->match_number, before => $e->before,
|
|
27
|
+
match => $e->match, after => $e->after, captures => $captures || [],
|
|
28
|
+
accum => $e->get_accum };
|
|
29
|
+
}
|
|
30
|
+
my $e = fresh('before a.c after');
|
|
31
|
+
$e->expect(0, 'absent', 'a.c');
|
|
32
|
+
$results{literal} = snapshot($e);
|
|
33
|
+
$e = fresh('prefix value=42 tail');
|
|
34
|
+
$e->expect(0, ['value=(\d+)']);
|
|
35
|
+
$results{regexp_array} = snapshot($e);
|
|
36
|
+
$e = fresh("first\nsecond\nthird");
|
|
37
|
+
$e->expect(0, '-re', '^second$');
|
|
38
|
+
$results{multiline} = snapshot($e);
|
|
39
|
+
$e = fresh('second first');
|
|
40
|
+
$e->expect(0, 'first', 'second');
|
|
41
|
+
$results{priority} = snapshot($e);
|
|
42
|
+
$e = fresh('before token tail');
|
|
43
|
+
$e->notransfer(1);
|
|
44
|
+
$e->expect(0, 'token');
|
|
45
|
+
$results{notransfer} = snapshot($e);
|
|
46
|
+
$e = fresh('discard tail');
|
|
47
|
+
$e->max_accum(4);
|
|
48
|
+
$e->expect(0, 'tail');
|
|
49
|
+
$results{max_accum} = snapshot($e);
|
|
50
|
+
$e = fresh('keep this');
|
|
51
|
+
$e->expect(0, 'missing');
|
|
52
|
+
$results{timeout} = { error => $e->exp_error, before => $e->before, accum => $e->get_accum };
|
|
53
|
+
$e = fresh('A B C End');
|
|
54
|
+
my @states;
|
|
55
|
+
$e->expect(1, ['[ABC]', sub { push @states, $_[0]->match; exp_continue }], 'End');
|
|
56
|
+
$results{continue} = { states => \@states, final => $e->match, number => $e->match_number };
|
|
57
|
+
$e = fresh('');
|
|
58
|
+
$e->raw_pty(1);
|
|
59
|
+
$e->spawn($^X, '-e', '$|=1; while (<STDIN>) { chomp; print scalar(reverse($_)), "\n" }');
|
|
60
|
+
$e->send("crate\n");
|
|
61
|
+
$e->expect(3, 'etarc');
|
|
62
|
+
$results{pty_dialogue} = { before => $e->before, match => $e->match, number => $e->match_number };
|
|
63
|
+
$e->hard_close;
|
|
64
|
+
pipe(my $reader, my $writer) or die $!;
|
|
65
|
+
$writer->autoflush(1);
|
|
66
|
+
my $input = Expect->exp_init($reader);
|
|
67
|
+
my $idle = fresh('');
|
|
68
|
+
print $writer 'beforeSTOP42;after';
|
|
69
|
+
$results{readiness} = [Expect::test_handles(1, $idle, $input)];
|
|
70
|
+
my @escapes;
|
|
71
|
+
$input->set_seq('STOP\d+;', sub { push @escapes, 'stopped'; return 0; });
|
|
72
|
+
Expect::interconnect($input);
|
|
73
|
+
$results{regexp_escape} = \@escapes;
|
|
74
|
+
close $writer;
|
|
75
|
+
print JSON::PP->new->canonical->encode(\%results);
|
|
76
|
+
PERL
|
|
77
|
+
|
|
78
|
+
output, errors, status = Open3.capture3("perl", "-I#{File.join(upstream, "lib")}", "-e", perl)
|
|
79
|
+
abort "Perl fixture failed: #{errors}" unless status.success?
|
|
80
|
+
expected = JSON.parse(output)
|
|
81
|
+
actual = {}
|
|
82
|
+
sessions = []
|
|
83
|
+
fresh = lambda do |buffer|
|
|
84
|
+
session = Expect.new(log_stdout: false)
|
|
85
|
+
sessions << session
|
|
86
|
+
session.buffer = buffer
|
|
87
|
+
session
|
|
88
|
+
end
|
|
89
|
+
snapshot = lambda do |s|
|
|
90
|
+
{ "number" => s.match_number, "before" => s.before, "match" => s.match,
|
|
91
|
+
"after" => s.after, "captures" => s.captures, "accum" => s.buffer }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
begin
|
|
95
|
+
s = fresh.call("before a.c after")
|
|
96
|
+
s.expect("absent", "a.c", timeout: 0)
|
|
97
|
+
actual["literal"] = snapshot.call(s)
|
|
98
|
+
s = fresh.call("prefix value=42 tail")
|
|
99
|
+
s.expect(/value=(\d+)/, timeout: 0)
|
|
100
|
+
actual["regexp_array"] = snapshot.call(s)
|
|
101
|
+
s = fresh.call("first\nsecond\nthird")
|
|
102
|
+
s.expect(/^second$/, timeout: 0)
|
|
103
|
+
actual["multiline"] = snapshot.call(s)
|
|
104
|
+
s = fresh.call("second first")
|
|
105
|
+
s.expect("first", "second", timeout: 0)
|
|
106
|
+
actual["priority"] = snapshot.call(s)
|
|
107
|
+
s = fresh.call("before token tail")
|
|
108
|
+
s.preserve_buffer = true
|
|
109
|
+
s.expect("token", timeout: 0)
|
|
110
|
+
actual["notransfer"] = snapshot.call(s)
|
|
111
|
+
s = fresh.call("discard tail")
|
|
112
|
+
s.buffer_limit = 4
|
|
113
|
+
s.expect("tail", timeout: 0)
|
|
114
|
+
actual["max_accum"] = snapshot.call(s)
|
|
115
|
+
s = fresh.call("keep this")
|
|
116
|
+
s.expect("missing", timeout: 0)
|
|
117
|
+
actual["timeout"] =
|
|
118
|
+
{ "error" => (s.error == :timeout ? "1:TIMEOUT" : s.error), "before" => s.before, "accum" => s.buffer }
|
|
119
|
+
s = fresh.call("A B C End")
|
|
120
|
+
states = []
|
|
121
|
+
s.expect(timeout: 1) do
|
|
122
|
+
on(/[ABC]/) do |object|
|
|
123
|
+
states << object.match
|
|
124
|
+
Expect.continue
|
|
125
|
+
end
|
|
126
|
+
on("End")
|
|
127
|
+
end
|
|
128
|
+
actual["continue"] = { "states" => states, "final" => s.match, "number" => s.match_number }
|
|
129
|
+
s = fresh.call("")
|
|
130
|
+
s.raw_pty = true
|
|
131
|
+
s.spawn(RbConfig.ruby, "--disable-gems", "-e",
|
|
132
|
+
"STDOUT.sync = true; while line = STDIN.gets; puts line.chomp.reverse; end")
|
|
133
|
+
s.write("crate\n")
|
|
134
|
+
s.expect("etarc", timeout: 3)
|
|
135
|
+
actual["pty_dialogue"] = { "before" => s.before, "match" => s.match, "number" => s.match_number }
|
|
136
|
+
reader, writer = IO.pipe
|
|
137
|
+
input = Expect.open(reader, own: true)
|
|
138
|
+
sessions << input
|
|
139
|
+
idle = fresh.call("")
|
|
140
|
+
writer.write("beforeSTOP42;after")
|
|
141
|
+
actual["readiness"] = Expect.readable_sessions(idle, input, timeout: 1).map { |session| [idle, input].index(session) }
|
|
142
|
+
escapes = []
|
|
143
|
+
input.on_sequence(/STOP\d+;/) do
|
|
144
|
+
escapes << "stopped"
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
Expect.interconnect(input, timeout: 1)
|
|
148
|
+
actual["regexp_escape"] = escapes
|
|
149
|
+
writer.close
|
|
150
|
+
|
|
151
|
+
failures = expected.keys.reject { |key| actual.fetch(key) == expected.fetch(key) }
|
|
152
|
+
failures.each { |key| warn "#{key}: Perl=#{expected[key].inspect} Ruby=#{actual[key].inspect}" }
|
|
153
|
+
abort "#{failures.length} upstream comparisons failed" unless failures.empty?
|
|
154
|
+
puts "#{expected.length} upstream comparisons passed (Expect.pm #{File.basename(upstream)})"
|
|
155
|
+
ensure
|
|
156
|
+
sessions.reverse_each { |session| session.hard_close(timeout: 0.03) }
|
|
157
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class ConfigurationTest < ExpectTest
|
|
6
|
+
def test_configuration_is_frozen_and_sessions_have_independent_settings
|
|
7
|
+
Expect.configure(timeout: 0.1) do |config|
|
|
8
|
+
config.buffer_limit = 4
|
|
9
|
+
config.log_stdout = false
|
|
10
|
+
end
|
|
11
|
+
first, = pipe_session
|
|
12
|
+
second, = pipe_session
|
|
13
|
+
first.timeout = 1
|
|
14
|
+
first.buffer_limit = nil
|
|
15
|
+
Expect.configure(timeout: 2)
|
|
16
|
+
third, = pipe_session
|
|
17
|
+
|
|
18
|
+
assert_equal 1, first.timeout
|
|
19
|
+
assert_equal 0.1, second.timeout
|
|
20
|
+
assert_equal 2, third.timeout
|
|
21
|
+
assert_equal 4, second.buffer_limit
|
|
22
|
+
assert_nil first.buffer_limit
|
|
23
|
+
assert_equal 2, Expect.configuration.timeout
|
|
24
|
+
assert_raises(FrozenError) { Expect.configuration.timeout = 10 }
|
|
25
|
+
Expect.configuration.to_h.clear
|
|
26
|
+
assert_equal 2, Expect.configuration.timeout
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_configuration_errors_do_not_publish_partial_changes
|
|
30
|
+
previous = Expect.configuration
|
|
31
|
+
assert_raises(ArgumentError) { Expect.configure(unknown: true) }
|
|
32
|
+
assert_same previous, Expect.configuration
|
|
33
|
+
assert_raises(ArgumentError) do
|
|
34
|
+
Expect.configure do |config|
|
|
35
|
+
config.timeout = 5
|
|
36
|
+
config.debug_level = 4
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
assert_same previous, Expect.configuration
|
|
40
|
+
assert_raises(RuntimeError) { Expect.configure(timeout: 3) { raise "cancelled" } }
|
|
41
|
+
assert_same previous, Expect.configuration
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_subclasses_inherit_configuration_and_can_override_it_independently
|
|
45
|
+
subclass = Class.new(Expect)
|
|
46
|
+
Expect.configure(timeout: 0.5)
|
|
47
|
+
assert_equal 0.5, subclass.configuration.timeout
|
|
48
|
+
subclass.configure(timeout: 1)
|
|
49
|
+
Expect.configure(timeout: 2)
|
|
50
|
+
reader, writer = IO.pipe
|
|
51
|
+
@ios.push(reader, writer)
|
|
52
|
+
subclass.open(reader) do |session|
|
|
53
|
+
assert_equal 1, session.timeout
|
|
54
|
+
assert_instance_of subclass, session
|
|
55
|
+
end
|
|
56
|
+
assert_equal 2, Expect.configuration.timeout
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_attribute_validation_preserves_the_current_value_and_buffer
|
|
60
|
+
session, = pipe_session(buffer_limit: 8, timeout: 1, write_timeout: 2, debug_level: 1)
|
|
61
|
+
session.buffer = "contents"
|
|
62
|
+
[0, -1, 1.5, "4"].each do |limit|
|
|
63
|
+
assert_raises(ArgumentError) { session.buffer_limit = limit }
|
|
64
|
+
end
|
|
65
|
+
assert_equal 8, session.buffer_limit
|
|
66
|
+
assert_equal "contents", session.buffer
|
|
67
|
+
assert_raises(ArgumentError) { session.timeout = Float::INFINITY }
|
|
68
|
+
assert_raises(ArgumentError) { session.write_timeout = -1 }
|
|
69
|
+
assert_raises(ArgumentError) { session.debug_level = 1.5 }
|
|
70
|
+
assert_equal [1, 2, 1], [session.timeout, session.write_timeout, session.debug_level]
|
|
71
|
+
session.buffer_limit = 4
|
|
72
|
+
assert_equal "ents", session.buffer
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_predicates_use_ruby_truthiness_and_normalize_boolean_attributes
|
|
76
|
+
Expect.configure(raw_pty: 0, log_stdout: nil)
|
|
77
|
+
config = Expect.configuration
|
|
78
|
+
assert_equal true, config.raw_pty
|
|
79
|
+
assert_equal false, config.log_stdout
|
|
80
|
+
session, = pipe_session
|
|
81
|
+
assert session.raw_pty?
|
|
82
|
+
refute session.log_stdout?
|
|
83
|
+
session.preserve_buffer = 0
|
|
84
|
+
assert session.preserve_buffer?
|
|
85
|
+
session.preserve_buffer = nil
|
|
86
|
+
refute session.preserve_buffer?
|
|
87
|
+
assert_raises(ArgumentError) { session.timeout(1) }
|
|
88
|
+
assert_raises(ArgumentError) { session.log_stdout(true) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
class EdgeCaseTest < ExpectTest
|
|
7
|
+
def test_zero_width_continuation_observes_original_deadline
|
|
8
|
+
session, = pipe_session
|
|
9
|
+
session.buffer = "ready"
|
|
10
|
+
result = bounded(1) do
|
|
11
|
+
session.expect_result(timeout: 0.01) { on(/(?=ready)/) { Expect.continue(reset_timeout: false) } }
|
|
12
|
+
end
|
|
13
|
+
assert result.timeout?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_stdout_works_with_utf8_banner_and_ascii_regexp
|
|
17
|
+
session, writer = pipe_session
|
|
18
|
+
writer.write("欢迎登录\nprompt>")
|
|
19
|
+
assert_equal 1, session.expect(/prompt>/, timeout: 1)
|
|
20
|
+
assert_equal "欢迎登录\n".b, session.before
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_invalid_fixed_encoding_regexp_data_raises
|
|
24
|
+
session, = pipe_session
|
|
25
|
+
session.buffer = "\xffinvalid".b
|
|
26
|
+
assert_raises(EncodingError) { session.expect(/中文/, timeout: 0) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_replacing_log_with_invalid_target_preserves_current_log
|
|
30
|
+
session, = pipe_session
|
|
31
|
+
log = StringIO.new
|
|
32
|
+
session.log_to(log)
|
|
33
|
+
assert_raises(ArgumentError) { session.log_to(42) }
|
|
34
|
+
assert_same log, session.log_output
|
|
35
|
+
session.write_log("still open")
|
|
36
|
+
assert_equal "still open", log.string
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_new_session_predicates_use_ruby_truthiness
|
|
40
|
+
Expect.configure(raw_pty: 0, log_stdout: nil)
|
|
41
|
+
session = Expect.new
|
|
42
|
+
@sessions << session
|
|
43
|
+
assert session.raw_pty?
|
|
44
|
+
refute session.log_stdout?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_read_only_regular_file
|
|
48
|
+
Tempfile.create("expect-input") do |file|
|
|
49
|
+
file.write("first\nsecond\n")
|
|
50
|
+
file.rewind
|
|
51
|
+
Expect.open(file) do |session|
|
|
52
|
+
assert_equal 1, session.expect(/^second$/, timeout: 0)
|
|
53
|
+
assert_equal "first\n", session.before
|
|
54
|
+
assert session.expect_result(:eof, timeout: 1).eof?
|
|
55
|
+
end
|
|
56
|
+
refute file.closed?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_existing_pty_eio_is_eof
|
|
61
|
+
master, slave = PTY.open
|
|
62
|
+
@ios.push(master, slave)
|
|
63
|
+
session = Expect.open(master)
|
|
64
|
+
@sessions << session
|
|
65
|
+
slave.write("end")
|
|
66
|
+
slave.flush
|
|
67
|
+
assert_equal 1, session.expect("end", timeout: 1)
|
|
68
|
+
slave.close
|
|
69
|
+
assert bounded { session.expect_result(:eof, timeout: 1) }.eof?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_distinct_reader_writer_io
|
|
73
|
+
incoming, producer = IO.pipe
|
|
74
|
+
consumer, outgoing = IO.pipe
|
|
75
|
+
@ios.push(incoming, producer, consumer, outgoing)
|
|
76
|
+
session = Expect.open(incoming, writer: outgoing)
|
|
77
|
+
@sessions << session
|
|
78
|
+
producer.write("ready")
|
|
79
|
+
assert_equal 1, session.expect("ready", timeout: 1)
|
|
80
|
+
assert_equal 3, session.write("abc")
|
|
81
|
+
assert_equal "abc", consumer.read(3)
|
|
82
|
+
session.close
|
|
83
|
+
refute incoming.closed?
|
|
84
|
+
refute outgoing.closed?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def test_control_character_delivers_signal_to_foreground_child
|
|
88
|
+
session = child('Signal.trap("INT") { puts "interrupted"; exit 0 }; puts "ready"; sleep 30')
|
|
89
|
+
session.expect("ready", timeout: 2)
|
|
90
|
+
session.write("\x03")
|
|
91
|
+
assert_equal 1, session.expect("interrupted", timeout: 2)
|
|
92
|
+
assert_equal 0, session.wait(timeout: 1).exitstatus
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def test_multiple_real_pty_processes
|
|
96
|
+
first = child('puts "one"')
|
|
97
|
+
second = child('STDIN.gets; puts "two"', raw_pty: true)
|
|
98
|
+
seen = []
|
|
99
|
+
record_session = lambda do |session|
|
|
100
|
+
seen << session
|
|
101
|
+
Expect.continue(reset_timeout: false)
|
|
102
|
+
end
|
|
103
|
+
result = Expect.expect_result(timeout: 2) do
|
|
104
|
+
on(/one/, from: first, &record_session)
|
|
105
|
+
eof(from: first) do
|
|
106
|
+
# 明确建立两个进程的顺序,不能通过 sleep 推断启动和输出的先后。
|
|
107
|
+
second.puts("continue")
|
|
108
|
+
Expect.continue(reset_timeout: false)
|
|
109
|
+
end
|
|
110
|
+
on("two", from: second)
|
|
111
|
+
end
|
|
112
|
+
assert_equal [first], seen
|
|
113
|
+
assert_same second, result.session
|
|
114
|
+
assert_equal 3, result.number
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_signal_interruption_preserves_deadline
|
|
118
|
+
session, writer = pipe_session
|
|
119
|
+
old_handler = Signal.trap("USR1") { nil }
|
|
120
|
+
background do
|
|
121
|
+
4.times do
|
|
122
|
+
sleep 0.01
|
|
123
|
+
Process.kill("USR1", Process.pid)
|
|
124
|
+
end
|
|
125
|
+
writer.write("ready")
|
|
126
|
+
end
|
|
127
|
+
assert_equal(1, bounded { session.expect("ready", timeout: 1) })
|
|
128
|
+
ensure
|
|
129
|
+
Signal.trap("USR1", old_handler) if old_handler
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def test_gc_reclaims_abandoned_child
|
|
133
|
+
script = <<~RUBY
|
|
134
|
+
require "expect"
|
|
135
|
+
require "rbconfig"
|
|
136
|
+
def abandoned
|
|
137
|
+
session = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", "sleep 60", log_stdout: false)
|
|
138
|
+
session.pid
|
|
139
|
+
end
|
|
140
|
+
# Ruby 的保守 GC 可能扫描到创建线程栈上残留的引用;先结束该线程,确保会话确实不可达。
|
|
141
|
+
pid = Thread.new { abandoned }.value
|
|
142
|
+
20.times do
|
|
143
|
+
GC.start
|
|
144
|
+
sleep 0.02
|
|
145
|
+
begin
|
|
146
|
+
Process.kill(0, pid)
|
|
147
|
+
rescue Errno::ESRCH
|
|
148
|
+
puts "reaped"
|
|
149
|
+
exit 0
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
Process.kill("KILL", pid) rescue nil
|
|
153
|
+
Process.waitpid(pid) rescue nil
|
|
154
|
+
abort "abandoned child survived GC"
|
|
155
|
+
RUBY
|
|
156
|
+
output, status = Open3.capture2e(RbConfig.ruby, "--disable-gems", "-I", File.expand_path("../lib", __dir__), "-e",
|
|
157
|
+
script)
|
|
158
|
+
assert status.success?, output
|
|
159
|
+
assert_equal "reaped\n", output
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def test_invalid_options_raise_before_spawning
|
|
163
|
+
assert_raises(ArgumentError) { Expect.new(typo: true) }
|
|
164
|
+
session, = pipe_session
|
|
165
|
+
assert_raises(ArgumentError) { session.expect(0, "x") }
|
|
166
|
+
assert_raises(ArgumentError) { session.expect(["-unknown", "x"], timeout: 0) }
|
|
167
|
+
assert_raises(ArgumentError) { session.on_sequence("") }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_entrypoint_can_coexist_with_standard_library_expect
|
|
171
|
+
entrypoint = File.expand_path("../lib/expect/pty.rb", __dir__)
|
|
172
|
+
script = <<~RUBY
|
|
173
|
+
require "rbconfig"
|
|
174
|
+
require File.join(RbConfig::CONFIG.fetch("rubylibdir"), "expect.rb")
|
|
175
|
+
require ARGV.fetch(0)
|
|
176
|
+
puts [IO.method_defined?(:expect), defined?(Expect), Expect::VERSION].join(":")
|
|
177
|
+
RUBY
|
|
178
|
+
environment = { "RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil }
|
|
179
|
+
output, status = Open3.capture2e(environment, RbConfig.ruby, "-e", script, entrypoint)
|
|
180
|
+
assert status.success?, output
|
|
181
|
+
assert_equal "true:constant:#{Expect::VERSION}\n", output
|
|
182
|
+
end
|
|
183
|
+
end
|