expect-pty 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +29 -0
  3. data/CHANGELOG.md +25 -0
  4. data/Gemfile +10 -0
  5. data/LICENSE +21 -0
  6. data/README.md +264 -0
  7. data/Rakefile +38 -0
  8. data/docs/COMPATIBILITY.md +65 -0
  9. data/docs/RELEASING.md +55 -0
  10. data/docs/VERIFICATION.md +146 -0
  11. data/examples/dialogue.rb +30 -0
  12. data/examples/kibitz/README.md +73 -0
  13. data/examples/kibitz/kibitz.rb +139 -0
  14. data/examples/kibitz/test_kibitz.rb +37 -0
  15. data/examples/ssh_auto.rb +94 -0
  16. data/examples/ssh_interact.rb +159 -0
  17. data/examples/ssh_login.rb +64 -0
  18. data/expect-pty.gemspec +25 -0
  19. data/lib/expect/configuration.rb +113 -0
  20. data/lib/expect/engine.rb +141 -0
  21. data/lib/expect/interconnect.rb +170 -0
  22. data/lib/expect/pattern.rb +62 -0
  23. data/lib/expect/pattern_list.rb +90 -0
  24. data/lib/expect/pty.rb +4 -0
  25. data/lib/expect/resources.rb +63 -0
  26. data/lib/expect/result.rb +14 -0
  27. data/lib/expect/version.rb +6 -0
  28. data/lib/expect.rb +591 -0
  29. data/script/ci +44 -0
  30. data/script/release.rb +267 -0
  31. data/test/compare_upstream.rb +157 -0
  32. data/test/configuration_test.rb +90 -0
  33. data/test/edge_case_test.rb +183 -0
  34. data/test/fixtures/ssh_scripts/01_identity.sh +4 -0
  35. data/test/fixtures/ssh_scripts/02_output.sh +5 -0
  36. data/test/fixtures/ssh_scripts/03_delayed.sh +6 -0
  37. data/test/fixtures/ssh_scripts/04_failure.sh +2 -0
  38. data/test/fixtures/ssh_scripts/05_recovery.sh +3 -0
  39. data/test/integration/README.md +90 -0
  40. data/test/integration/ssh_scripts.rb +94 -0
  41. data/test/interact_test.rb +151 -0
  42. data/test/interconnect_test.rb +226 -0
  43. data/test/io_test.rb +184 -0
  44. data/test/kibitz_test.rb +45 -0
  45. data/test/matching_test.rb +178 -0
  46. data/test/multi_session_test.rb +66 -0
  47. data/test/process_test.rb +244 -0
  48. data/test/release_test.rb +153 -0
  49. data/test/ruby_api_test.rb +515 -0
  50. data/test/script_logging_test.rb +124 -0
  51. data/test/support/interact_probe.rb +125 -0
  52. data/test/support/kibitz_probe.rb +177 -0
  53. data/test/support/script_probe.rb +157 -0
  54. data/test/test_helper.rb +58 -0
  55. data/test/timeout_test.rb +170 -0
  56. metadata +97 -0
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