expect-pty 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +29 -0
  3. data/CHANGELOG.md +25 -0
  4. data/Gemfile +10 -0
  5. data/LICENSE +21 -0
  6. data/README.md +264 -0
  7. data/Rakefile +38 -0
  8. data/docs/COMPATIBILITY.md +65 -0
  9. data/docs/RELEASING.md +55 -0
  10. data/docs/VERIFICATION.md +146 -0
  11. data/examples/dialogue.rb +30 -0
  12. data/examples/kibitz/README.md +73 -0
  13. data/examples/kibitz/kibitz.rb +139 -0
  14. data/examples/kibitz/test_kibitz.rb +37 -0
  15. data/examples/ssh_auto.rb +94 -0
  16. data/examples/ssh_interact.rb +159 -0
  17. data/examples/ssh_login.rb +64 -0
  18. data/expect-pty.gemspec +25 -0
  19. data/lib/expect/configuration.rb +113 -0
  20. data/lib/expect/engine.rb +141 -0
  21. data/lib/expect/interconnect.rb +170 -0
  22. data/lib/expect/pattern.rb +62 -0
  23. data/lib/expect/pattern_list.rb +90 -0
  24. data/lib/expect/pty.rb +4 -0
  25. data/lib/expect/resources.rb +63 -0
  26. data/lib/expect/result.rb +14 -0
  27. data/lib/expect/version.rb +6 -0
  28. data/lib/expect.rb +591 -0
  29. data/script/ci +44 -0
  30. data/script/release.rb +267 -0
  31. data/test/compare_upstream.rb +157 -0
  32. data/test/configuration_test.rb +90 -0
  33. data/test/edge_case_test.rb +183 -0
  34. data/test/fixtures/ssh_scripts/01_identity.sh +4 -0
  35. data/test/fixtures/ssh_scripts/02_output.sh +5 -0
  36. data/test/fixtures/ssh_scripts/03_delayed.sh +6 -0
  37. data/test/fixtures/ssh_scripts/04_failure.sh +2 -0
  38. data/test/fixtures/ssh_scripts/05_recovery.sh +3 -0
  39. data/test/integration/README.md +90 -0
  40. data/test/integration/ssh_scripts.rb +94 -0
  41. data/test/interact_test.rb +151 -0
  42. data/test/interconnect_test.rb +226 -0
  43. data/test/io_test.rb +184 -0
  44. data/test/kibitz_test.rb +45 -0
  45. data/test/matching_test.rb +178 -0
  46. data/test/multi_session_test.rb +66 -0
  47. data/test/process_test.rb +244 -0
  48. data/test/release_test.rb +153 -0
  49. data/test/ruby_api_test.rb +515 -0
  50. data/test/script_logging_test.rb +124 -0
  51. data/test/support/interact_probe.rb +125 -0
  52. data/test/support/kibitz_probe.rb +177 -0
  53. data/test/support/script_probe.rb +157 -0
  54. data/test/test_helper.rb +58 -0
  55. data/test/timeout_test.rb +170 -0
  56. metadata +97 -0
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class ProcessTest < ExpectTest
6
+ def test_spawn_has_controlling_terminal_and_merged_stderr
7
+ session = child(<<~RUBY)
8
+ terminal = File.open("/dev/tty", &:tty?)
9
+ puts [STDIN.tty?, STDOUT.tty?, STDERR.tty?, terminal].join(":")
10
+ STDERR.puts "error-stream"
11
+ RUBY
12
+ assert_equal 1, session.expect("true:true:true:true", timeout: 2)
13
+ assert_equal 1, session.expect("error-stream", timeout: 2)
14
+ session.soft_close(timeout: 1)
15
+ assert_equal 0, session.exit_code
16
+ assert_equal 0, session.process_status.to_i
17
+ assert session.closed?
18
+ refute session.alive?
19
+ end
20
+
21
+ def test_raw_mode_is_applied_before_child_starts
22
+ session = child(<<~'RUBY', raw_pty: true)
23
+ print "name: "
24
+ value = STDIN.gets
25
+ puts "reply=#{value.strip.reverse}"
26
+ RUBY
27
+ session.expect("name: ", timeout: 2)
28
+ session.write("crate\n")
29
+ assert_equal 1, session.expect("reply=etarc\n", timeout: 2)
30
+ assert_equal "", session.before
31
+ end
32
+
33
+ def test_new_pty_supports_slave_configuration_before_spawn
34
+ session = Expect.new(log_stdout: false)
35
+ @sessions << session
36
+ assert session.slave.tty?
37
+ session.slave.echo = false
38
+ session.slave.winsize = [37, 111]
39
+ session.spawn(RbConfig.ruby, "--disable-gems", "-rio/console", "-e",
40
+ 'STDOUT.sync = true; puts STDIN.winsize.join(":"); puts STDIN.gets')
41
+ assert_equal 1, session.expect("37:111", timeout: 2)
42
+ assert_equal [37, 111], session.winsize
43
+ session.winsize = [24, 80]
44
+ assert_equal [24, 80], session.winsize
45
+ session.clear_buffer
46
+ session.write("line\n")
47
+ session.expect("line", timeout: 2)
48
+ session.expect(timeout: 2)
49
+ assert_equal "\r\n", session.before
50
+ end
51
+
52
+ def test_command_arguments_environment_and_directory
53
+ Dir.mktmpdir do |dir|
54
+ session = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e",
55
+ 'puts ARGV.first; puts ENV.fetch("EXPECT_TEST_VALUE"); puts Dir.pwd', "a;$(exit) b",
56
+ env: { "EXPECT_TEST_VALUE" => "environment" }, chdir: dir,
57
+ log_stdout: false, raw_pty: true)
58
+ @sessions << session
59
+ assert_equal 1, session.expect("a;$(exit) b\nenvironment\n#{File.realpath(dir)}\n", timeout: 2)
60
+ end
61
+ end
62
+
63
+ def test_string_command_supports_shell_semantics
64
+ session = Expect.new("printf 'shell-one'; printf 'shell-two'", log_stdout: false)
65
+ @sessions << session
66
+ assert_equal 1, session.expect("shell-oneshell-two", timeout: 2)
67
+ end
68
+
69
+ def test_exec_failure_is_synchronous_and_reaped
70
+ session = Expect.new(log_stdout: false)
71
+ @sessions << session
72
+ error = assert_raises(Expect::SpawnError) { session.spawn("/no/such/expect-test-command") }
73
+ assert_match(/ENOENT/, error.message)
74
+ assert session.closed?
75
+ assert_nil session.pid
76
+ assert_raises(Expect::SpawnError) { session.spawn("cat") }
77
+ end
78
+
79
+ def test_block_closes_child_on_exception
80
+ pid = nil
81
+ assert_raises(RuntimeError) do
82
+ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", "sleep 60", log_stdout: false) do |session|
83
+ pid = session.pid
84
+ raise "stop"
85
+ end
86
+ end
87
+ assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
88
+ assert_raises(Errno::ESRCH) { Process.kill(0, pid) }
89
+ end
90
+
91
+ def test_soft_close_drains_output_before_exit_and_returns_process_status
92
+ session = child('sleep 0.03; print "last output"; exit 7', raw_pty: true)
93
+ log = StringIO.new
94
+ session.log_to(log)
95
+ assert_equal 7, session.soft_close(timeout: 1).exitstatus
96
+ assert_equal "last output", log.string
97
+ assert_equal "last output", session.buffer
98
+ assert_equal 7, session.exit_code
99
+ refute log.closed?
100
+ end
101
+
102
+ def test_hard_close_escalates_uncooperative_child_and_reaps
103
+ session = child('Signal.trap("HUP", "IGNORE"); Signal.trap("TERM", "IGNORE"); puts "ready"; loop { sleep 1 }')
104
+ session.expect("ready", timeout: 2)
105
+ pid = session.pid
106
+ bounded(2) { session.hard_close(timeout: 0.03) }
107
+ assert_equal Signal.list.fetch("KILL"), session.process_status.termsig
108
+ assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
109
+ assert_equal session.process_status, session.hard_close
110
+ end
111
+
112
+ def test_soft_close_sends_term_and_returns_the_exit_status
113
+ session = child(<<~RUBY, raw_pty: true)
114
+ Signal.trap("HUP", "IGNORE")
115
+ Signal.trap("TERM") { exit 7 }
116
+ puts "ready"
117
+ sleep 60
118
+ RUBY
119
+ session.expect("ready", timeout: 2)
120
+ status = bounded { session.soft_close(timeout: 0, term_timeout: 1) }
121
+ assert_equal 7, status.exitstatus
122
+ assert session.closed?
123
+ refute session.alive?
124
+ assert_same status, session.soft_close(timeout: 0)
125
+ end
126
+
127
+ def test_soft_close_never_sends_kill_and_retains_a_live_pid_for_hard_close
128
+ session = child(<<~RUBY)
129
+ Signal.trap("HUP", "IGNORE")
130
+ Signal.trap("TERM", "IGNORE")
131
+ puts "ready"
132
+ sleep 60
133
+ RUBY
134
+ session.expect("ready", timeout: 2)
135
+ pid = session.pid
136
+ assert_nil(bounded { session.soft_close(timeout: 0, term_timeout: 0.03) })
137
+ assert session.closed?
138
+ assert session.alive?
139
+ assert_equal pid, session.pid
140
+ assert_nil session.wait(timeout: 0)
141
+ status = bounded { session.hard_close(timeout: 0.03) }
142
+ assert_equal Signal.list.fetch("KILL"), status.termsig
143
+ assert_nil session.pid
144
+ assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
145
+ end
146
+
147
+ def test_block_cleanup_uses_the_configured_graceful_close_and_drains_output
148
+ log = StringIO.new
149
+ connection = nil
150
+ value = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", 'sleep 0.03; puts "tail"',
151
+ raw_pty: true, graceful_close: true) do |session|
152
+ connection = session
153
+ session.log_output = log
154
+ :finished
155
+ end
156
+ assert_equal :finished, value
157
+ assert_equal "tail\n", log.string
158
+ assert connection.closed?
159
+ assert_equal 0, connection.exit_code
160
+ refute log.closed?
161
+ end
162
+
163
+ def test_explicit_graceful_close_returns_nil_like_io_close
164
+ session = child('sleep 0.03; print "tail"', raw_pty: true)
165
+ assert_nil session.close(graceful: true)
166
+ assert_equal "tail", session.buffer
167
+ assert_equal 0, session.exit_code
168
+ assert_nil session.close
169
+ end
170
+
171
+ def test_close_still_reaps_the_child_when_graceful_logging_fails
172
+ session = child('puts "ready"; sleep 60', raw_pty: true)
173
+ pid = session.pid
174
+ session.log_to { raise IOError, "log failed" }
175
+ assert_raises(IOError) { bounded { session.close(graceful: true) } }
176
+ assert session.closed?
177
+ refute session.alive?
178
+ assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
179
+ end
180
+
181
+ def test_invalid_close_timeout_does_not_close_or_signal_the_child
182
+ session = child('puts "ready"; sleep 60')
183
+ session.expect("ready", timeout: 2)
184
+ assert_raises(ArgumentError) { session.soft_close(timeout: 0, term_timeout: nil) }
185
+ assert_raises(ArgumentError) { session.soft_close(timeout: 0, term_timeout: -1) }
186
+ assert_raises(ArgumentError) { session.hard_close(timeout: nil) }
187
+ refute session.closed?
188
+ assert session.alive?
189
+ end
190
+
191
+ def test_eof_does_not_prevent_buffered_match
192
+ session = child('print "final"; exit 4', raw_pty: true)
193
+ assert_equal 1, session.expect("final", timeout: 2)
194
+ assert session.expect_result(:eof, timeout: 2).eof?
195
+ assert_equal 4, session.wait(timeout: 1).exitstatus
196
+ end
197
+
198
+ def test_wait_timeout_and_explicit_close
199
+ session = child("sleep 0.15; exit 0")
200
+ assert_nil session.wait(timeout: 0)
201
+ assert session.alive?
202
+ assert_equal 0, session.wait(timeout: 1).exitstatus
203
+ refute session.alive?
204
+ session.close
205
+ assert_raises(IOError) { session.write("x") }
206
+ end
207
+
208
+ def test_borrowed_and_owned_io_lifetimes
209
+ session, writer = pipe_session
210
+ reader = session.to_io
211
+ session.close
212
+ refute reader.closed?
213
+ writer.write("x")
214
+ assert_equal "x", reader.read(1)
215
+ owned = Expect.open(reader, own: true)
216
+ owned.close
217
+ assert reader.closed?
218
+ end
219
+
220
+ def test_stty_roundtrip
221
+ session = child("sleep 30")
222
+ initial = session.stty
223
+ configuration = terminal_configuration(session)
224
+ session.stty("raw -echo")
225
+ refute session.to_io.echo?
226
+ session.stty(initial)
227
+ assert_equal configuration, terminal_configuration(session)
228
+ end
229
+
230
+ def test_configuration_and_session_attributes
231
+ Expect.configure(timeout: 0.01, log_stdout: false)
232
+ session = Expect.new
233
+ @sessions << session
234
+ refute session.log_stdout?
235
+ assert_equal 0.01, session.timeout
236
+ session.log_stdout = true
237
+ assert session.log_stdout
238
+ session.log_stdout = false
239
+ refute session.log_stdout
240
+ session.buffer_limit = 30
241
+ assert_equal 30, session.buffer_limit
242
+ assert_match(/\A#<Expect .*closed=false>\z/, session.inspect)
243
+ end
244
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "minitest/mock"
5
+ require_relative "../script/release"
6
+
7
+ class ReleaseTest < Minitest::Test
8
+ def test_extracts_only_the_selected_version
9
+ changelog = "# Changelog\n\n## Unreleased\n\n## 0.2.0 - 2026-09-12\n\n- New API.\n\n## 0.1.1\n\n- Old API.\n"
10
+ assert_equal "- New API.", Release.release_notes(changelog, "0.2.0")
11
+ end
12
+
13
+ def test_rejects_unreleased_missing_empty_or_invalid_versions
14
+ ["## Unreleased\n- Pending.\n## 0.2.0\n- Ready.", "## 0.1.1\n- Old.", "## 0.2.0\n"].each do |changelog|
15
+ assert_raises(RuntimeError) { Release.release_notes(changelog, "0.2.0") }
16
+ end
17
+ assert_raises(RuntimeError) { Release.release_notes("## 0.2.0.rc1\n- Preview.", "0.2.0.rc1") }
18
+ assert_raises(RuntimeError) { Release.release_notes("## Unreleased \t\n- Pending.\n## 0.2.0\n- Ready.", "0.2.0") }
19
+ end
20
+
21
+ def test_dry_run_verifies_the_artifact_without_remote_calls
22
+ with_package do |release, artifact|
23
+ release.stub(:github, ->(*) { flunk "dry run contacted GitHub" }) do
24
+ release.stub(:get, ->(*) { flunk "dry run contacted RubyGems" }) do
25
+ capture_io { release.run }
26
+ end
27
+ end
28
+ directory = Dir.glob(File.join("pkg", "release", Expect::VERSION, "candidate-*")).fetch(0)
29
+ checksum = File.read(File.join(directory, "SHA256SUMS"))
30
+ assert_equal "#{Digest::SHA256.file(artifact).hexdigest} #{File.basename(artifact)}\n", checksum
31
+ assert_equal "- Release fixture.\n", File.read(File.join(directory, "release-notes.md"))
32
+ File.write(artifact, "another build")
33
+ assert_equal checksum.split.first, Digest::SHA256.file(File.join(directory, File.basename(artifact))).hexdigest
34
+ end
35
+ end
36
+
37
+ def test_rejects_a_package_from_different_source
38
+ with_package do |release, _artifact|
39
+ File.write("payload.rb", "puts :changed\n")
40
+ assert_match "Artifact differs from source", assert_raises(RuntimeError) { release.send(:verify_package) }.message
41
+ end
42
+ end
43
+
44
+ def test_rejects_different_installation_metadata_even_with_identical_source_files
45
+ with_package do |release, _artifact|
46
+ expected = Gem::Specification.load(File.expand_path("expect-pty.gemspec")).dup
47
+ expected.add_runtime_dependency "unexpected-dependency", "= 1.0.0"
48
+ Gem::Specification.stub(:load, expected) do
49
+ assert_match "metadata", assert_raises(RuntimeError) { release.send(:verify_package) }.message
50
+ end
51
+ expected = Gem::Specification.load(File.expand_path("expect-pty.gemspec")).dup
52
+ expected.extensions = ["payload.rb"]
53
+ Gem::Specification.stub(:load, expected) do
54
+ assert_match "metadata", assert_raises(RuntimeError) { release.send(:verify_package) }.message
55
+ end
56
+ end
57
+ end
58
+
59
+ def test_registry_errors_are_not_treated_as_an_unpublished_version
60
+ release = Release.new
61
+ response = Struct.new(:code, :body).new("503", "unavailable")
62
+ release.stub(:get, response) { assert_raises(RuntimeError) { release.send(:registry_version) } }
63
+ end
64
+
65
+ def test_rejects_overwriting_a_published_version_or_reusing_a_yanked_version
66
+ release = Release.new
67
+ release.instance_variable_set(:@sha256, "expected")
68
+ [{ "sha" => "different", "yanked" => false }, { "sha" => "expected", "yanked" => true }].each do |version|
69
+ assert_raises(RuntimeError) { release.send(:verify_registry_checksum, version) }
70
+ end
71
+ end
72
+
73
+ def test_existing_registry_version_is_downloaded_and_verified_without_pushing_again
74
+ release = Release.new
75
+ bytes = "published gem"
76
+ checksum = Digest::SHA256.hexdigest(bytes)
77
+ release.instance_variable_set(:@sha256, checksum)
78
+ response = Struct.new(:code, :body).new("200", bytes)
79
+ release.stub(:registry_version, { "sha" => checksum, "yanked" => false }) do
80
+ release.stub(:system, ->(*) { flunk "repushed an existing gem" }) do
81
+ release.stub(:get, response) { capture_io { release.send(:publish_rubygems) } }
82
+ response.body = "different bytes"
83
+ release.stub(:get, response) { assert_raises(RuntimeError) { release.send(:publish_rubygems) } }
84
+ end
85
+ end
86
+ end
87
+
88
+ def test_remote_tag_must_point_to_the_verified_commit
89
+ release = Release.new
90
+ release.instance_variable_set(:@commit, "verified")
91
+ capture = ->(*arguments) { arguments.include?("rev-parse") ? "verified" : "" }
92
+ github = lambda do |path, **|
93
+ { "status" => (path.end_with?("...main") ? "ahead" : "behind") }
94
+ end
95
+ release.stub(:capture, capture) do
96
+ release.stub(:github, github) do
97
+ assert_match "another commit", assert_raises(RuntimeError) { release.send(:verify_remote_source) }.message
98
+ end
99
+ end
100
+ end
101
+
102
+ def test_package_validation_uses_archive_modes_regardless_of_umask
103
+ with_package do |release, artifact|
104
+ File.chmod(0o755, "payload.rb")
105
+ capture_io { Gem::Package.build(Gem::Specification.load(File.expand_path("expect-pty.gemspec"))) }
106
+ previous_umask = File.umask(0o077)
107
+ begin
108
+ release.send(:verify_package)
109
+ assert File.file?(artifact)
110
+ File.chmod(0o644, "payload.rb")
111
+ assert_match "permissions differ", assert_raises(RuntimeError) { release.send(:verify_package) }.message
112
+ ensure
113
+ File.umask(previous_umask)
114
+ end
115
+ end
116
+ end
117
+
118
+ def test_incomplete_github_asset_has_an_explicit_recovery_message
119
+ release = Release.new
120
+ release.instance_variable_set(:@checksum_file, "SHA256SUMS")
121
+ remote = { "assets" => [{ "name" => "SHA256SUMS", "state" => "starter" }] }
122
+ release.stub(:github_release, remote) do
123
+ error = assert_raises(RuntimeError) { release.send(:publish_github) }
124
+ assert_match "Incomplete GitHub asset: SHA256SUMS", error.message
125
+ assert_match "stop any active upload", error.message
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def with_package
132
+ Dir.mktmpdir("expect-release-test-") do |directory|
133
+ Dir.chdir(directory) do
134
+ File.write("payload.rb", "puts :original\n")
135
+ File.write("CHANGELOG.md", "## #{Expect::VERSION}\n\n- Release fixture.\n")
136
+ File.write("expect-pty.gemspec", <<~RUBY)
137
+ Gem::Specification.new do |spec|
138
+ spec.name = "expect-pty"
139
+ spec.version = "#{Expect::VERSION}"
140
+ spec.summary = "Release test fixture"
141
+ spec.authors = ["Test"]
142
+ spec.license = "MIT"
143
+ spec.homepage = "https://github.com/gatework/expect-ruby"
144
+ spec.files = ["payload.rb", "CHANGELOG.md", "expect-pty.gemspec"]
145
+ end
146
+ RUBY
147
+ artifact = nil
148
+ capture_io { artifact = Gem::Package.build(Gem::Specification.load(File.expand_path("expect-pty.gemspec"))) }
149
+ yield Release.new(artifact: artifact, dry_run: true), artifact
150
+ end
151
+ end
152
+ end
153
+ end