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/test/io_test.rb ADDED
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class IOTest < ExpectTest
6
+ def test_socket_pair_send_and_expect
7
+ client, server = Socket.pair(:UNIX, :STREAM, 0)
8
+ @ios.push(client, server)
9
+ session = Expect.open(client)
10
+ @sessions << session
11
+ background do
12
+ server.write("prompt>")
13
+ server.write(server.gets.upcase)
14
+ end
15
+ assert_equal 1, session.expect("prompt>", timeout: 1)
16
+ assert_equal 6, session.write("hello\n")
17
+ assert_equal 1, session.expect("HELLO\n", timeout: 1)
18
+ end
19
+
20
+ def test_log_file_append_truncate_disable_and_manual_output
21
+ session, writer = pipe_session
22
+ Dir.mktmpdir do |dir|
23
+ path = File.join(dir, "session.log")
24
+ File.write(path, "old")
25
+ log = session.log_to(path)
26
+ writer.write("reply")
27
+ session.expect("reply", timeout: 1)
28
+ session.write_log(" annotation")
29
+ session.log_output = nil
30
+ assert log.closed?
31
+ assert_equal "oldreply annotation", File.binread(path)
32
+ session.log_to(path, mode: "w")
33
+ writer.write("new")
34
+ session.expect("new", timeout: 1)
35
+ session.log_output = nil
36
+ assert_equal "new", File.binread(path)
37
+ writer.write("unlogged")
38
+ session.expect("unlogged", timeout: 1)
39
+ assert_equal "new", File.binread(path)
40
+ end
41
+ end
42
+
43
+ def test_logging_matches_upstream_received_data_only
44
+ session = child("puts STDIN.gets.reverse", raw_pty: true)
45
+ chunks = []
46
+ session.log_to(->(data) { chunks << data })
47
+ session.write("abc\n")
48
+ session.expect("cba", timeout: 2)
49
+ assert_equal "\ncba\n", chunks.join
50
+ end
51
+
52
+ def test_group_and_stdout_logging_can_be_controlled_separately
53
+ session, writer = pipe_session
54
+ listener = StringIO.new
55
+ session.listeners = [listener]
56
+ writer.write("one")
57
+ session.expect("one", timeout: 1)
58
+ assert_equal "one", listener.string
59
+ session.log_listeners = false
60
+ session.log_stdout = true
61
+ output, = capture_io do
62
+ writer.write("two")
63
+ session.expect("two", timeout: 1)
64
+ end
65
+ assert_equal "two", output
66
+ assert_equal "one", listener.string
67
+ session.listeners = []
68
+ assert_empty session.listeners
69
+ end
70
+
71
+ def test_debug_and_internal_output
72
+ session, writer = pipe_session
73
+ session.debug_level = 1
74
+ _, diagnostics = capture_io do
75
+ writer.write("ready")
76
+ session.expect("ready", timeout: 1)
77
+ end
78
+ assert_match(/matched pattern 1/, diagnostics)
79
+ refute_match(/received/, diagnostics)
80
+ session.debug_level = 2
81
+ _, diagnostics = capture_io do
82
+ writer.write("more")
83
+ session.expect("more", timeout: 1)
84
+ end
85
+ assert_match(/received "more"/, diagnostics)
86
+ end
87
+
88
+ def test_send_slow_collects_replies_even_when_logging_disabled
89
+ session = child("loop { char = STDIN.read(1); break unless char; print char.upcase }", raw_pty: true)
90
+ session.log_listeners = false
91
+ start = Expect.monotonic
92
+ assert_equal 3, session.send_slow("abc", delay: 0.02)
93
+ assert_operator Expect.monotonic - start, :>=, 0.06
94
+ assert_equal 1, session.expect("ABC", timeout: 1)
95
+ end
96
+
97
+ def test_large_bidirectional_write_does_not_deadlock
98
+ session = child("STDIN.binmode; STDOUT.binmode; loop { print STDIN.readpartial(4096) }", raw_pty: true,
99
+ write_timeout: 3)
100
+ payload = (0..255).to_a.pack("C*") * 1024
101
+ bounded do
102
+ assert_equal payload.bytesize, session.write(payload)
103
+ assert_equal 1, session.expect(payload, timeout: 3)
104
+ end
105
+ assert_equal payload, session.match
106
+ end
107
+
108
+ def test_write_timeout_on_backpressure
109
+ session = child('puts "ready"; sleep 30', raw_pty: true, write_timeout: 0.05)
110
+ session.expect("ready", timeout: 2)
111
+ assert_raises(Expect::WriteTimeout) { bounded { session.write("x" * 1_000_000) } }
112
+ end
113
+
114
+ def test_readiness_returns_readable_sessions
115
+ first, = pipe_session
116
+ second, writer = pipe_session
117
+ assert_empty Expect.readable_sessions(first, second)
118
+ writer.write("data")
119
+ assert_equal [second], Expect.readable_sessions(first, second)
120
+ second.expect("data", timeout: 1)
121
+ assert_empty Expect.readable_sessions(first, second)
122
+ end
123
+
124
+ def test_readiness_waits_for_data_and_does_not_consume_data
125
+ first, = pipe_session
126
+ second, writer = pipe_session
127
+ background do
128
+ sleep 0.03
129
+ writer.write("ready")
130
+ end
131
+ assert_equal([second], bounded { Expect.readable_sessions(first, second, timeout: 1) })
132
+ assert_equal 1, second.expect("ready", timeout: 0)
133
+ assert_empty Expect.readable_sessions(first, second, timeout: 0)
134
+ end
135
+
136
+ def test_readiness_accepts_keyword_and_unlimited_timeout
137
+ session, writer = pipe_session
138
+ assert_empty Expect.readable_sessions(session, timeout: 0.01)
139
+ background do
140
+ sleep 0.03
141
+ writer.write("ready")
142
+ end
143
+ assert_equal([session], bounded { Expect.readable_sessions(session, timeout: nil) })
144
+ assert_equal [session], Expect.readable_sessions(session, timeout: 0)
145
+ assert_raises(ArgumentError) { Expect.readable_sessions(session, timeout: -1) }
146
+ assert_raises(ArgumentError) { Expect.readable_sessions(0, session, timeout: 1) }
147
+ assert_raises(ArgumentError) { Expect.readable_sessions("invalid") }
148
+ end
149
+
150
+ def test_readiness_excludes_closed_sessions
151
+ first, = pipe_session
152
+ second, writer = pipe_session
153
+ first.close
154
+ writer.write("ready")
155
+ assert_equal [second], Expect.readable_sessions(first, second, timeout: 0)
156
+ assert_empty Expect.readable_sessions
157
+ end
158
+
159
+ def test_read_io_error_is_reported
160
+ Tempfile.create("expect-write-only") do |file|
161
+ File.open(file.path, "w") do |writer|
162
+ session = Expect.open(writer)
163
+ @sessions << session
164
+ result = session.expect_result("x", timeout: 1)
165
+ assert_instance_of IOError, result.error
166
+ end
167
+ end
168
+ end
169
+
170
+ def test_log_io_failure_does_not_masquerade_as_child_eof
171
+ session = child('puts "ready"; STDIN.gets; puts "response"; sleep 30', raw_pty: true)
172
+ assert_equal 1, session.expect("ready\n", timeout: 2)
173
+ session.log_to(->(_) { raise Errno::EIO, "log failed" })
174
+ session.write("continue\n")
175
+ result = session.expect_result("response", timeout: 2)
176
+ assert_instance_of Errno::EIO, result.error
177
+ refute result.eof?
178
+ refute session.eof?
179
+ assert session.alive?
180
+ assert_includes result.before, "response\n"
181
+ session.log_output = nil
182
+ assert_equal 1, session.expect("response", timeout: 0)
183
+ end
184
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/kibitz_probe"
5
+
6
+ class KibitzTest < ExpectTest
7
+ def test_two_terminals_share_shell_echo_signals_and_logged_output
8
+ Dir.mktmpdir { |directory| assert KibitzProbe.shared_shell(directory)[:passed] }
9
+ end
10
+
11
+ def test_noproc_split_custom_escape_does_not_leak_to_peer
12
+ Dir.mktmpdir { |directory| assert KibitzProbe.direct(directory, ending: :host)[:passed] }
13
+ end
14
+
15
+ def test_guest_escape_ends_both_sides_and_restores_terminals
16
+ Dir.mktmpdir { |directory| assert KibitzProbe.direct(directory, ending: :guest)[:passed] }
17
+ end
18
+
19
+ def test_noescape_forwards_control_character_as_data
20
+ Dir.mktmpdir { |directory| assert KibitzProbe.noescape(directory)[:passed] }
21
+ end
22
+
23
+ def test_shared_process_failure_is_reported
24
+ Dir.mktmpdir { |directory| assert KibitzProbe.process_failure(directory)[:passed] }
25
+ end
26
+
27
+ def test_relay_timeout_restores_terminals_and_closes_peer
28
+ Dir.mktmpdir { |directory| assert KibitzProbe.relay_timeout(directory)[:passed] }
29
+ end
30
+
31
+ def test_missing_partner_times_out_and_removes_socket
32
+ session = Expect.new(log_stdout: false)
33
+ @sessions << session
34
+ saved = InteractProbe.configuration(session)
35
+ session.spawn(RbConfig.ruby, KibitzProbe::EXAMPLE, "--timeout", "0.2")
36
+ result = session.expect_result(%r{--join (/tmp/expect-kibitz-[^\r\n ]+/peer\.sock)}, timeout: 3)
37
+ assert result.matched?
38
+ path = result.captures.first
39
+ assert session.expect_result(:eof, timeout: 3).eof?
40
+ assert_includes session.before, "timed out waiting for partner"
41
+ assert_equal 1, session.wait(timeout: 1).exitstatus
42
+ assert_equal saved, InteractProbe.configuration(session)
43
+ refute File.exist?(File.dirname(path))
44
+ end
45
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class MatchingTest < ExpectTest
6
+ def test_literal_regexp_number_and_result_tuple
7
+ session, = pipe_session
8
+ session.buffer = "before a.c between abc:42 after"
9
+ assert_equal 2, session.expect("absent", "a.c", timeout: 0)
10
+ assert_equal "before ", session.before
11
+ assert_equal "a.c", session.match
12
+ result = session.expect_result(/abc:(\d+)/, timeout: 0)
13
+ assert_equal [1, nil, "abc:42", " between ", " after", session, ["42"]], result.to_a
14
+ assert_equal ["42"], session.captures
15
+ assert result.matched?
16
+ refute result.timeout?
17
+ refute result.eof?
18
+ end
19
+
20
+ def test_regexp_callback_captures_lexical_arguments
21
+ session, = pipe_session
22
+ session.buffer = "prefix xyz42 suffix"
23
+ observed = []
24
+ argument = :value
25
+ number = session.expect(timeout: 0) do
26
+ on(/xyz(\d+)/) { |object| observed << [object.captures, argument] }
27
+ end
28
+ assert_equal 1, number
29
+ assert_equal [[["42"], :value]], observed
30
+ end
31
+
32
+ def test_native_regexp_and_literal_event_names
33
+ session, = pipe_session
34
+ session.buffer = "timeout eof -word a.c end99"
35
+ assert_equal 1, session.expect("timeout", timeout: 0)
36
+ assert_equal 1, session.expect("eof", timeout: 0)
37
+ assert_equal 1, session.expect("-word", timeout: 0)
38
+ assert_equal 1, session.expect("a.c", timeout: 0)
39
+ assert_equal 1, session.expect(/end\d+/, timeout: 0)
40
+ end
41
+
42
+ def test_patterns_have_priority_over_their_position_in_buffer
43
+ session, = pipe_session
44
+ session.buffer = "second first"
45
+ assert_equal 1, session.expect("first", "second", timeout: 0)
46
+ assert_equal "second ", session.before
47
+ end
48
+
49
+ def test_regexp_uses_actual_offset_with_lookbehind
50
+ session, = pipe_session
51
+ session.buffer = "same prefix same end"
52
+ session.expect(/(?<=prefix )same/, timeout: 0)
53
+ assert_equal "same prefix ", session.before
54
+ assert_equal " end", session.after
55
+ end
56
+
57
+ def test_unmatched_capture_and_zero_width_pattern
58
+ session, = pipe_session
59
+ session.buffer = "b"
60
+ assert_equal 1, session.expect(/(a)?(?=b)/, timeout: 0)
61
+ assert_equal [nil], session.captures
62
+ assert_equal "", session.match
63
+ assert_equal "b", session.buffer
64
+ end
65
+
66
+ def test_capture_list_clears_for_literal_and_timeout
67
+ session, = pipe_session
68
+ session.buffer = "a1 b"
69
+ session.expect(/a(\d)/, timeout: 0)
70
+ session.expect("b", timeout: 0)
71
+ assert_empty session.captures
72
+ assert_nil session.expect(/absent/, timeout: 0)
73
+ assert_nil session.match
74
+ assert_nil session.after
75
+ assert_empty session.captures
76
+ end
77
+
78
+ def test_buffer_retained_on_timeout_and_manually_replaceable
79
+ session, writer = pipe_session
80
+ writer.write("some string")
81
+ assert_nil session.expect("other", timeout: 0.03)
82
+ assert_equal :timeout, session.error
83
+ assert_equal "some string", session.before
84
+ assert_equal 1, session.expect("some", timeout: 0)
85
+ assert_equal " string", session.buffer
86
+ session.buffer = "new"
87
+ assert_equal "new", session.clear_buffer
88
+ assert_empty session.buffer
89
+ end
90
+
91
+ def test_preserve_buffer_and_buffer_limit
92
+ session, = pipe_session
93
+ session.preserve_buffer = true
94
+ session.buffer = "prefix token tail"
95
+ 2.times { assert_equal 1, session.expect("token", timeout: 0) }
96
+ assert_equal "prefix token tail", session.buffer
97
+ session.buffer_limit = 4
98
+ assert_equal "tail", session.buffer
99
+ session.buffer_limit = nil
100
+ session.buffer = "123456"
101
+ assert_equal "123456", session.buffer
102
+ end
103
+
104
+ def test_split_patterns_across_reads
105
+ session, writer = pipe_session
106
+ background do
107
+ writer.write("pass")
108
+ sleep 0.03
109
+ writer.write("word: ")
110
+ end
111
+ assert_equal 1, session.expect(/password: /, timeout: 1)
112
+ end
113
+
114
+ def test_utf8_regexp_split_character_and_byte_offsets
115
+ session, writer = pipe_session
116
+ bytes = "前缀密码:结束".b
117
+ background do
118
+ bytes.each_byte do |byte|
119
+ writer.write(byte.chr)
120
+ sleep 0.001
121
+ end
122
+ end
123
+ assert_equal 1, session.expect(/(密码):/, timeout: 1)
124
+ assert_equal "前缀".b, session.before
125
+ assert_equal ["密码".b], session.captures
126
+ assert_equal 1, session.expect("结束", timeout: 1)
127
+ end
128
+
129
+ def test_binary_nul_and_invalid_utf8
130
+ session, writer = pipe_session
131
+ writer.write("\xff\x00abc\xfe".b)
132
+ assert_equal 1, session.expect(/\x00(abc)/n, timeout: 1)
133
+ assert_equal "\xff".b, session.before
134
+ assert_equal ["abc"], session.captures
135
+ end
136
+
137
+ def test_native_regexp_anchors_and_flags
138
+ session, = pipe_session
139
+ session.buffer = "a\nb\nc"
140
+ session.preserve_buffer = true
141
+ assert_nil session.expect("^b$", timeout: 0)
142
+ assert_nil session.expect(/\Ab\z/, timeout: 0)
143
+ assert_equal 1, session.expect(/^b$/, timeout: 0)
144
+ assert_equal 1, session.expect(/a.b/m, timeout: 0)
145
+ assert_nil session.expect(/a.b/, timeout: 0)
146
+ end
147
+
148
+ def test_eof_callback_consumes_remaining_buffer
149
+ session, writer = pipe_session
150
+ writer.write("last bytes")
151
+ writer.close
152
+ seen = nil
153
+ result = session.expect_result(timeout: 1) { eof { |object| seen = object.before } }
154
+ assert_nil result.number
155
+ assert result.eof?
156
+ assert_equal "last bytes", seen
157
+ assert_empty session.buffer
158
+ end
159
+
160
+ def test_read_without_patterns_and_match_before_eof
161
+ session, writer = pipe_session
162
+ writer.write("END")
163
+ writer.close
164
+ assert_equal 1, session.expect("END", timeout: nil)
165
+ assert session.expect_result(timeout: nil).eof?
166
+ end
167
+
168
+ def test_argument_validation_does_not_read_io
169
+ session, = pipe_session
170
+ assert_raises(ArgumentError) { session.expect("x", timeout: -1) }
171
+ assert_raises(ArgumentError) { session.expect("x", timeout: Float::INFINITY) }
172
+ assert_raises(ArgumentError) { session.expect(["a", 42], timeout: 0) }
173
+ assert_raises(ArgumentError) { session.expect("-i", [], timeout: 0) }
174
+ assert_raises(ArgumentError) { session.expect(Object.new, timeout: 0) }
175
+ assert_raises(ArgumentError) { Expect.expect("x", timeout: 0) }
176
+ assert_raises(ArgumentError) { session.buffer_limit = -1 }
177
+ end
178
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class MultiSessionTest < ExpectTest
6
+ def test_multi_session_pattern_groups_and_global_numbering
7
+ first, first_writer = pipe_session
8
+ second, second_writer = pipe_session
9
+ first_writer.write("irrelevant")
10
+ second_writer.write("second:42")
11
+ result = Expect.expect_result(timeout: 1) do
12
+ on("first", from: first)
13
+ on(/second:(\d+)/, from: second)
14
+ end
15
+ assert_equal 2, result.number
16
+ assert_same second, result.session
17
+ assert_equal ["42"], result.captures
18
+ assert_equal "irrelevant", first.buffer
19
+ end
20
+
21
+ def test_shared_patterns_across_sessions
22
+ first, = pipe_session
23
+ second, writer = pipe_session
24
+ writer.write("ready")
25
+ assert_equal 1, Expect.expect("ready", from: [first, second], timeout: 1)
26
+ assert_equal "ready", second.match
27
+ end
28
+
29
+ def test_eof_continue_removes_closed_session_and_waits_for_remaining
30
+ first, first_writer = pipe_session
31
+ second, second_writer = pipe_session
32
+ first_writer.close
33
+ ended = []
34
+ result = Expect.expect_result(timeout: 1) do
35
+ eof(from: first) do |connection|
36
+ ended << connection
37
+ second_writer.write("done")
38
+ Expect.continue(reset_timeout: false)
39
+ end
40
+ on("done", from: second)
41
+ end
42
+ assert_equal [first], ended
43
+ assert_equal 2, result.number
44
+ assert_same second, result.session
45
+ end
46
+
47
+ def test_all_eof_continuation_returns_without_waiting_forever
48
+ first, writer = pipe_session
49
+ writer.close
50
+ result = bounded { first.expect_result(timeout: nil) { eof { Expect.continue } } }
51
+ assert result.eof?
52
+ end
53
+
54
+ def test_same_session_in_multiple_groups_is_read_once
55
+ session, writer = pipe_session
56
+ writer.write("right")
57
+ other, = pipe_session
58
+ result = Expect.expect_result(timeout: 1) do
59
+ on("wrong", from: session)
60
+ on("missing", from: other)
61
+ on("right", from: session)
62
+ end
63
+ assert_equal 3, result.number
64
+ assert_equal "right", result.match
65
+ end
66
+ end