expect-pty 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +28 -19
  4. data/docs/API.md +88 -0
  5. data/docs/MIGRATION.md +15 -0
  6. data/lib/expect/cleanup.rb +33 -0
  7. data/lib/expect/configuration.rb +87 -66
  8. data/lib/expect/interaction.rb +226 -209
  9. data/lib/expect/logging.rb +157 -156
  10. data/lib/expect/matcher.rb +30 -29
  11. data/lib/expect/pattern.rb +8 -2
  12. data/lib/expect/pattern_list.rb +35 -5
  13. data/lib/expect/redactor.rb +11 -4
  14. data/lib/expect/relay.rb +97 -73
  15. data/lib/expect/relay_writer.rb +2 -1
  16. data/lib/expect/result.rb +32 -8
  17. data/lib/expect/session.rb +508 -0
  18. data/lib/expect/session_resources.rb +10 -2
  19. data/lib/expect/terminal.rb +78 -78
  20. data/lib/expect/version.rb +1 -1
  21. data/lib/expect.rb +333 -520
  22. data/sig/expect.rbs +355 -0
  23. metadata +13 -106
  24. data/.rubocop.yml +0 -66
  25. data/CONTRIBUTING.md +0 -29
  26. data/Gemfile +0 -28
  27. data/Rakefile +0 -38
  28. data/benchmark/matching.rb +0 -98
  29. data/benchmark/redactor.rb +0 -64
  30. data/benchmark/relay.rb +0 -48
  31. data/benchmark/scaling.rb +0 -70
  32. data/benchmark/send_slow.rb +0 -41
  33. data/benchmark/support.rb +0 -114
  34. data/docs/COMPATIBILITY.md +0 -98
  35. data/docs/INTERNAL_CONTRACTS.md +0 -154
  36. data/docs/PERFORMANCE.md +0 -165
  37. data/docs/RELEASING.md +0 -84
  38. data/docs/VERIFICATION.md +0 -456
  39. data/examples/dialogue.rb +0 -30
  40. data/examples/kibitz/README.md +0 -81
  41. data/examples/kibitz/kibitz.rb +0 -142
  42. data/examples/kibitz/test_kibitz.rb +0 -37
  43. data/examples/ssh_auto.rb +0 -94
  44. data/examples/ssh_interact.rb +0 -159
  45. data/examples/ssh_login.rb +0 -64
  46. data/expect-pty.gemspec +0 -33
  47. data/script/ci +0 -121
  48. data/script/release.rb +0 -319
  49. data/test/buffer_accounting_test.rb +0 -83
  50. data/test/cleanup_test.rb +0 -251
  51. data/test/compare_upstream.rb +0 -157
  52. data/test/configuration_test.rb +0 -133
  53. data/test/deadline_test.rb +0 -237
  54. data/test/diagnostics_test.rb +0 -353
  55. data/test/edge_case_test.rb +0 -262
  56. data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
  57. data/test/fixtures/ssh_scripts/02_output.sh +0 -5
  58. data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
  59. data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
  60. data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
  61. data/test/initialization_failure_test.rb +0 -92
  62. data/test/integration/README.md +0 -109
  63. data/test/integration/ssh_scripts.rb +0 -94
  64. data/test/interact_test.rb +0 -253
  65. data/test/interconnect_test.rb +0 -425
  66. data/test/io_test.rb +0 -321
  67. data/test/kibitz_test.rb +0 -45
  68. data/test/lifecycle_contract_test.rb +0 -71
  69. data/test/literal_scan_test.rb +0 -97
  70. data/test/matching_test.rb +0 -211
  71. data/test/multi_session_test.rb +0 -66
  72. data/test/ownership_sequence_test.rb +0 -208
  73. data/test/pattern_offset_test.rb +0 -43
  74. data/test/process_interruption_test.rb +0 -296
  75. data/test/process_test.rb +0 -291
  76. data/test/redactor_test.rb +0 -183
  77. data/test/relay_recovery_test.rb +0 -451
  78. data/test/relay_reentrancy_test.rb +0 -159
  79. data/test/release_test.rb +0 -375
  80. data/test/ruby_api_test.rb +0 -515
  81. data/test/scan_reuse_test.rb +0 -109
  82. data/test/script_logging_test.rb +0 -124
  83. data/test/support/interact_probe.rb +0 -125
  84. data/test/support/kibitz_probe.rb +0 -177
  85. data/test/support/script_probe.rb +0 -158
  86. data/test/terminal_cleanup_test.rb +0 -349
  87. data/test/test_helper.rb +0 -58
  88. data/test/timeout_test.rb +0 -289
  89. data/test/write_contract_test.rb +0 -105
@@ -1,353 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "test_helper"
4
- require "logger"
5
-
6
- class DiagnosticsTest < ExpectTest
7
- def test_logger_receives_lifecycle_and_payload_at_separate_levels
8
- session, writer = pipe_session(debug_level: 2)
9
- output = StringIO.new
10
- logger = Logger.new(output)
11
- logger.level = Logger::INFO
12
- session.diagnostic_output = logger
13
- writer.write("ready")
14
- session.expect("ready", timeout: 1)
15
- assert_includes output.string, "matched pattern 1"
16
- refute_includes output.string, "received"
17
- logger.level = Logger::DEBUG
18
- writer.write("next")
19
- session.expect("next", timeout: 1)
20
- assert_includes output.string, 'received "next"'
21
- session.close
22
- refute output.closed?
23
- end
24
-
25
- def test_constructor_accepts_borrowed_diagnostic_io_and_keeps_transcript_separate
26
- diagnostics = StringIO.new
27
- session, writer = pipe_session(debug_level: 2, diagnostic_output: diagnostics)
28
- transcript = StringIO.new
29
- session.log_to(transcript)
30
- writer.write("ready")
31
- session.expect("ready", timeout: 1)
32
- assert_equal "ready", transcript.string
33
- assert_includes diagnostics.string, 'received "ready"'
34
- session.close
35
- refute diagnostics.closed?
36
- refute transcript.closed?
37
- end
38
-
39
- def test_callable_diagnostics_receive_immutable_metadata_without_the_session
40
- events = []
41
- session, writer = pipe_session(debug_level: 2, diagnostic_output: ->(event) { events << event })
42
- writer.write("ready")
43
- session.expect("ready", timeout: 1)
44
- event = events.find { |item| item[:event] == :received }
45
- assert event.frozen?
46
- assert event[:message].frozen?
47
- assert_equal :debug, event[:level]
48
- assert_equal session.fileno, event[:fd]
49
- assert_equal 'received "ready"', event[:message]
50
- refute_includes event.values, session
51
- assert_equal :info, events.last[:level]
52
- end
53
-
54
- def test_invalid_diagnostic_target_preserves_the_previous_target
55
- session, = pipe_session
56
- output = StringIO.new
57
- session.diagnostic_output = output
58
- assert_raises(ArgumentError) { session.diagnostic_output = Object.new }
59
- assert_same output, session.diagnostic_output
60
- end
61
-
62
- def test_diagnostic_failure_keeps_original_input_available
63
- session, writer = pipe_session(debug_level: 2)
64
- failure = IOError.new("diagnostic failed")
65
- session.diagnostic_output = ->(_) { raise failure }
66
- writer.write("ready")
67
- result = session.expect_result("ready", timeout: 1)
68
- assert_same failure, result.error
69
- assert_equal "ready", session.buffer
70
- session.debug_level = 0
71
- assert_equal 1, session.expect("ready", timeout: 0)
72
- end
73
-
74
- def test_nested_diagnostic_wait_preserves_the_outer_match_result
75
- session, = pipe_session(debug_level: 1)
76
- session.buffer = "ready"
77
- nested = nil
78
- notified = false
79
- callback_result = nil
80
- session.diagnostic_output = lambda do |event|
81
- next unless event[:event] == :matched && !notified
82
-
83
- notified = true
84
- nested = session.expect_result("missing", timeout: 0)
85
- end
86
- result = session.expect_result(timeout: 0) do |patterns|
87
- patterns.on("ready") { |source| callback_result = source.last_result }
88
- end
89
- assert nested.timeout?
90
- assert result.matched?
91
- assert_equal "ready", result.match
92
- assert_same result, callback_result
93
- assert_same result, session.last_result
94
- end
95
-
96
- def test_redaction_handles_every_two_chunk_split_and_overlapping_secrets
97
- (0..11).each do |split|
98
- session, = pipe_session
99
- output = StringIO.new
100
- session.log_to(output)
101
- session.redact("abc", "bcd", "secret")
102
- text = "xabcdsecret!"
103
- session.write_log(text.byteslice(0, split))
104
- session.write_log(text.byteslice(split..))
105
- session.log_output = nil
106
- assert_equal "x[FILTERED]!", output.string, "split #{split}"
107
- end
108
- end
109
-
110
- def test_byte_at_a_time_redaction_preserves_matching_and_listeners
111
- session, writer = pipe_session(debug_level: 3)
112
- events = []
113
- log = StringIO.new
114
- forwarded = StringIO.new
115
- session.diagnostic_output = ->(event) { events << event }
116
- session.log_to(log)
117
- session.listeners = [forwarded]
118
- session.redact("password")
119
- "password!".each_char do |byte|
120
- writer.write(byte)
121
- session.__send__(:read_available)
122
- refute_includes log.string, "password"
123
- end
124
- assert_equal 1, session.expect("password!", timeout: 0)
125
- writer.close
126
- assert session.expect_result(:eof, timeout: 1).eof?
127
- assert_equal "[FILTERED]!", log.string
128
- assert_equal "password!", forwarded.string
129
- received = events.select { |event| event[:event] == :received }.map { |event| event[:message] }.join
130
- assert_includes received, "[FILTERED]"
131
- refute_includes events.inspect, "password"
132
- buffers = events.select { |event| event[:event] == :buffer }
133
- assert(buffers.all? { |event| event[:message] == "buffer [FILTERED]" })
134
- end
135
-
136
- def test_sending_diagnostics_are_filtered_across_separate_writes
137
- client, peer = Socket.pair(:UNIX, :STREAM, 0)
138
- @ios.push(client, peer)
139
- output = StringIO.new
140
- session = Expect.open(client, debug_level: 2, diagnostic_output: output)
141
- @sessions << session
142
- session.redact("password")
143
- %w[pa ss wo rd].each { |part| session.write(part) }
144
- assert_equal "password", peer.read(8)
145
- session.close
146
- refute_includes output.string, "password"
147
- refute_includes output.string, '"pa"'
148
- assert_includes output.string, "[FILTERED]"
149
- end
150
-
151
- def test_registration_copies_secrets_is_additive_and_isolated_between_sessions
152
- first, = pipe_session
153
- second, = pipe_session
154
- first_log = StringIO.new
155
- second_log = StringIO.new
156
- first.log_to(first_log)
157
- second.log_to(second_log)
158
- secret = +"first"
159
- assert_same first, first.redact(secret)
160
- secret.replace("changed")
161
- first.write_log("fi")
162
- first.redact("second")
163
- first.write_log("rst second")
164
- second.write_log("first second")
165
- first.close
166
- assert_equal "[FILTERED] [FILTERED]", first_log.string
167
- assert_equal "first second", second_log.string
168
- end
169
-
170
- def test_binary_secrets_are_matched_before_diagnostic_escaping
171
- session, = pipe_session(debug_level: 2)
172
- log = StringIO.new
173
- diagnostics = StringIO.new
174
- session.log_to(log)
175
- session.diagnostic_output = diagnostics
176
- secret = "\xff\0\n".b
177
- session.redact(secret)
178
- session.write_log("before#{secret}after".b)
179
- session.__send__(:trace_data, :received, secret, level: 2)
180
- session.close
181
- assert_equal "before[FILTERED]after", log.string
182
- refute_includes diagnostics.string, '\\xFF'
183
- assert_includes diagnostics.string, "[FILTERED]"
184
- end
185
-
186
- def test_eof_and_close_flush_benign_tails_but_hide_incomplete_secret_prefixes
187
- session, writer = pipe_session
188
- log = StringIO.new
189
- session.log_to(log)
190
- session.redact("password")
191
- writer.write("hello pass")
192
- writer.close
193
- assert session.expect_result(:eof, timeout: 1).eof?
194
- assert_equal "hello [FILTERED]", log.string
195
- session.close
196
- assert_equal "hello [FILTERED]", log.string
197
- end
198
-
199
- def test_replacing_log_finishes_the_original_target_and_keeps_it_borrowed
200
- session, = pipe_session
201
- first = StringIO.new
202
- second = StringIO.new
203
- session.redact("secret")
204
- session.log_to(first)
205
- session.write_log("hello")
206
- session.log_to(second)
207
- session.write_log("secret!")
208
- session.close
209
- assert_equal "hello", first.string
210
- assert_equal "[FILTERED]!", second.string
211
- refute first.closed?
212
- refute second.closed?
213
- end
214
-
215
- def test_invalid_registration_does_not_partially_add_secrets
216
- session, = pipe_session
217
- output = StringIO.new
218
- session.log_to(output)
219
- [[], [""], [nil], ["valid", 42]].each do |values|
220
- assert_raises(ArgumentError) { session.redact(*values) }
221
- end
222
- session.write_log("valid")
223
- assert_equal "valid", output.string
224
- end
225
-
226
- def test_reopening_the_same_log_path_finishes_the_old_stream_before_truncating
227
- session, = pipe_session
228
- session.redact("password")
229
- Dir.mktmpdir do |directory|
230
- path = File.join(directory, "session.log")
231
- previous = session.log_to(path, mode: "w")
232
- session.write_log("hello")
233
- assert_equal "", File.binread(path)
234
- session.log_to(path, mode: "w")
235
- assert previous.closed?
236
- session.write_log("x")
237
- session.close
238
- assert_equal "x", File.binread(path)
239
- end
240
- end
241
-
242
- def test_failed_old_log_flush_does_not_truncate_the_new_path
243
- session, = pipe_session
244
- session.redact("password")
245
- failure = IOError.new("old log failed")
246
- session.log_to { raise failure }
247
- session.write_log("tail")
248
- Dir.mktmpdir do |directory|
249
- path = File.join(directory, "existing.log")
250
- File.write(path, "keep")
251
- assert_same failure, assert_raises(IOError) { session.log_to(path, mode: "w") }
252
- assert_equal "keep", File.binread(path)
253
- end
254
- session.log_output = nil
255
- assert_nil session.log_output
256
- end
257
-
258
- def test_flush_failure_does_not_prevent_handle_and_child_cleanup
259
- session = child('puts "ready"; sleep 60', raw_pty: true)
260
- session.expect("ready", timeout: 2)
261
- session.redact("password")
262
- failure = IOError.new("flush failed")
263
- session.log_to { raise failure }
264
- session.write_log("hi") # 小于保留窗口,失败应发生在关闭时的尾部交付。
265
- assert_same failure, assert_raises(IOError) { session.close }
266
- assert session.closed?
267
- refute session.alive?
268
- session.log_output = nil
269
- end
270
-
271
- def test_unexpected_diagnostic_flush_failure_still_closes_owned_log
272
- session, = pipe_session(debug_level: 2)
273
- session.redact("password")
274
- Dir.mktmpdir do |directory|
275
- log = session.log_to(File.join(directory, "session.log"))
276
- failure = RuntimeError.new("diagnostic callback failed")
277
- session.diagnostic_output = ->(_) { raise failure }
278
- session.__send__(:trace_data, :sending, "hi", level: 2)
279
- assert_same failure, assert_raises(RuntimeError) { session.close }
280
- assert log.closed?
281
- assert session.closed?
282
- end
283
- end
284
-
285
- def test_replacing_diagnostics_finishes_the_previous_stream
286
- session, = pipe_session(debug_level: 2)
287
- session.redact("password")
288
- first = StringIO.new
289
- second = StringIO.new
290
- session.diagnostic_output = first
291
- session.__send__(:trace_data, :received, "pass", level: 2)
292
- session.diagnostic_output = second
293
- session.__send__(:trace_data, :received, "password!", level: 2)
294
- session.close
295
- assert_includes first.string, "[FILTERED]"
296
- refute_includes first.string, "pass"
297
- assert_includes second.string, "[FILTERED]"
298
- refute_includes second.string, "password"
299
- refute first.closed?
300
- refute second.closed?
301
- end
302
-
303
- def test_log_callback_can_rotate_the_target_during_close
304
- session, = pipe_session
305
- session.redact("long-secret")
306
- delivered = []
307
- rotated = nil
308
- Dir.mktmpdir do |directory|
309
- path = File.join(directory, "rotated.log")
310
- session.log_to do |data|
311
- delivered << data
312
- rotated = session.log_to(path, mode: "w")
313
- session.write_log("body")
314
- end
315
- session.write_log("xyz")
316
- session.close
317
- assert_equal ["xyz"], delivered
318
- assert rotated.closed?
319
- assert_equal "body", File.binread(path)
320
- assert_nil session.log_output
321
- end
322
- end
323
-
324
- def test_diagnostic_flush_callback_can_create_or_revisit_the_other_direction
325
- [nil, "seed"].each do |initial_send|
326
- client, peer = Socket.pair(:UNIX, :STREAM, 0)
327
- @ios.push(client, peer)
328
- session = Expect.open(client, debug_level: 2)
329
- @sessions << session
330
- session.redact("long-secret")
331
- events = []
332
- session.diagnostic_output = lambda do |event|
333
- events << event
334
- session.write("ack") if event[:event] == :received
335
- end
336
- if initial_send
337
- session.write(initial_send)
338
- assert_equal initial_send, peer.read(initial_send.bytesize)
339
- end
340
- peer.write("xyz")
341
- assert_equal 1, session.expect("xyz", timeout: 0)
342
- replacement = StringIO.new
343
- session.diagnostic_output = replacement
344
- assert_equal "ack", peer.read(3)
345
- expected = initial_send ? %i[matched sending received sending] : %i[matched received sending]
346
- assert_equal(expected, events.map { |event| event[:event] })
347
- assert_includes events.last[:message], "ack"
348
- assert_equal "", replacement.string
349
- session.close
350
- assert_equal "", replacement.string
351
- end
352
- end
353
- end
@@ -1,262 +0,0 @@
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_zero_width_continuation_waits_for_new_input_before_matching_again
17
- session, = pipe_session
18
- session.buffer = "ready"
19
- calls = 0
20
-
21
- result = bounded(1) do
22
- session.expect_result(timeout: 0.01) do
23
- on(/(?=ready)/) do
24
- calls += 1
25
- Expect.continue
26
- end
27
- end
28
- end
29
-
30
- assert result.timeout?
31
- assert_equal 1, calls
32
- end
33
-
34
- def test_stalled_pattern_can_match_again_after_new_input
35
- session, writer = pipe_session
36
- session.buffer = "ready"
37
- first_match = Queue.new
38
- background do
39
- first_match.pop
40
- writer.write("!")
41
- end
42
- calls = 0
43
-
44
- result = bounded(1) do
45
- session.expect_result(timeout: 0.5) do
46
- on(/(?=ready)/) do
47
- calls += 1
48
- first_match << true if calls == 1
49
- calls == 1 ? Expect.continue : nil
50
- end
51
- end
52
- end
53
-
54
- assert result.matched?
55
- assert_equal 2, calls
56
- assert_equal "ready!", session.buffer
57
- end
58
-
59
- def test_stalled_pattern_can_match_again_after_timeout_callback_changes_buffer
60
- session, = pipe_session
61
- session.buffer = "ready"
62
- calls = 0
63
- timeouts = 0
64
-
65
- result = bounded(1) do
66
- session.expect_result(timeout: 0.01) do
67
- on(/(?=ready)/) do
68
- calls += 1
69
- calls == 1 ? Expect.continue : nil
70
- end
71
- timeout do
72
- timeouts += 1
73
- session.buffer = "ready!"
74
- timeouts == 1 ? Expect.continue : nil
75
- end
76
- end
77
- end
78
-
79
- assert result.matched?
80
- assert_equal 2, calls
81
- assert_equal 1, timeouts
82
- end
83
-
84
- def test_stdout_works_with_utf8_banner_and_ascii_regexp
85
- session, writer = pipe_session
86
- writer.write("欢迎登录\nprompt>")
87
- assert_equal 1, session.expect(/prompt>/, timeout: 1)
88
- assert_equal "欢迎登录\n".b, session.before
89
- end
90
-
91
- def test_invalid_fixed_encoding_regexp_data_raises
92
- session, = pipe_session
93
- session.buffer = "\xffinvalid".b
94
- assert_raises(EncodingError) { session.expect(/中文/, timeout: 0) }
95
- end
96
-
97
- def test_replacing_log_with_invalid_target_preserves_current_log
98
- session, = pipe_session
99
- log = StringIO.new
100
- session.log_to(log)
101
- assert_raises(ArgumentError) { session.log_to(42) }
102
- assert_same log, session.log_output
103
- session.write_log("still open")
104
- assert_equal "still open", log.string
105
- end
106
-
107
- def test_new_session_predicates_use_ruby_truthiness
108
- Expect.configure(raw_pty: 0, log_stdout: nil)
109
- session = Expect.new
110
- @sessions << session
111
- assert session.raw_pty?
112
- refute session.log_stdout?
113
- end
114
-
115
- def test_read_only_regular_file
116
- Tempfile.create("expect-input") do |file|
117
- file.write("first\nsecond\n")
118
- file.rewind
119
- Expect.open(file) do |session|
120
- assert_equal 1, session.expect(/^second$/, timeout: 0)
121
- assert_equal "first\n", session.before
122
- assert session.expect_result(:eof, timeout: 1).eof?
123
- end
124
- refute file.closed?
125
- end
126
- end
127
-
128
- def test_existing_pty_eio_is_eof
129
- master, slave = PTY.open
130
- @ios.push(master, slave)
131
- session = Expect.open(master)
132
- @sessions << session
133
- slave.write("end")
134
- slave.flush
135
- assert_equal 1, session.expect("end", timeout: 1)
136
- slave.close
137
- assert bounded { session.expect_result(:eof, timeout: 1) }.eof?
138
- end
139
-
140
- def test_distinct_reader_writer_io
141
- incoming, producer = IO.pipe
142
- consumer, outgoing = IO.pipe
143
- @ios.push(incoming, producer, consumer, outgoing)
144
- session = Expect.open(incoming, writer: outgoing)
145
- @sessions << session
146
- producer.write("ready")
147
- assert_equal 1, session.expect("ready", timeout: 1)
148
- assert_equal 3, session.write("abc")
149
- assert_equal "abc", consumer.read(3)
150
- session.close
151
- refute incoming.closed?
152
- refute outgoing.closed?
153
- end
154
-
155
- def test_control_character_delivers_signal_to_foreground_child
156
- session = child('Signal.trap("INT") { puts "interrupted"; exit 0 }; puts "ready"; sleep 30')
157
- session.expect("ready", timeout: 2)
158
- session.write("\x03")
159
- assert_equal 1, session.expect("interrupted", timeout: 2)
160
- assert_equal 0, session.wait(timeout: 1).exitstatus
161
- end
162
-
163
- def test_multiple_real_pty_processes
164
- first = child('puts "one"')
165
- second = child('STDIN.gets; puts "two"', raw_pty: true)
166
- seen = []
167
- record_session = lambda do |session|
168
- seen << session
169
- Expect.continue(reset_timeout: false)
170
- end
171
- result = Expect.expect_result(timeout: 2) do
172
- on(/one/, from: first, &record_session)
173
- eof(from: first) do
174
- # 明确建立两个进程的顺序,不能通过 sleep 推断启动和输出的先后。
175
- second.puts("continue")
176
- Expect.continue(reset_timeout: false)
177
- end
178
- on("two", from: second)
179
- end
180
- assert_equal [first], seen
181
- assert_same second, result.session
182
- assert_equal 3, result.number
183
- end
184
-
185
- def test_signal_interruption_preserves_deadline
186
- session, writer = pipe_session
187
- old_handler = Signal.trap("USR1") { nil }
188
- background do
189
- 4.times do
190
- sleep 0.01
191
- Process.kill("USR1", Process.pid)
192
- end
193
- writer.write("ready")
194
- end
195
- assert_equal(1, bounded { session.expect("ready", timeout: 1) })
196
- ensure
197
- Signal.trap("USR1", old_handler) if old_handler
198
- end
199
-
200
- def test_gc_reclaims_abandoned_child
201
- assert_gc_reclaims_abandoned_child
202
- end
203
-
204
- def test_gc_reclaims_abandoned_child_with_log_callback_capturing_session
205
- assert_gc_reclaims_abandoned_child(log_callback: true)
206
- end
207
-
208
- def test_invalid_options_raise_before_spawning
209
- assert_raises(ArgumentError) { Expect.new(typo: true) }
210
- session, = pipe_session
211
- assert_raises(ArgumentError) { session.expect(0, "x") }
212
- assert_raises(ArgumentError) { session.expect(["-unknown", "x"], timeout: 0) }
213
- assert_raises(ArgumentError) { session.on_sequence("") }
214
- end
215
-
216
- def test_entrypoint_can_coexist_with_standard_library_expect
217
- entrypoint = File.expand_path("../lib/expect/pty.rb", __dir__)
218
- script = <<~RUBY
219
- require "rbconfig"
220
- require File.join(RbConfig::CONFIG.fetch("rubylibdir"), "expect.rb")
221
- require ARGV.fetch(0)
222
- puts [IO.method_defined?(:expect), defined?(Expect), Expect::VERSION].join(":")
223
- RUBY
224
- environment = { "RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil }
225
- output, status = Open3.capture2e(environment, RbConfig.ruby, "-e", script, entrypoint)
226
- assert status.success?, output
227
- assert_equal "true:constant:#{Expect::VERSION}\n", output
228
- end
229
-
230
- private
231
-
232
- def assert_gc_reclaims_abandoned_child(log_callback: false)
233
- script = <<~RUBY
234
- require "expect"
235
- require "rbconfig"
236
- def abandoned
237
- session = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", "sleep 60", log_stdout: false)
238
- session.log_to { |bytes| [session.pid, bytes] } if #{log_callback}
239
- session.pid
240
- end
241
- # Ruby 的保守 GC 可能扫描到创建线程栈上残留的引用;先结束该线程,确保会话确实不可达。
242
- pid = Thread.new { abandoned }.value
243
- 20.times do
244
- GC.start
245
- sleep 0.02
246
- begin
247
- Process.kill(0, pid)
248
- rescue Errno::ESRCH
249
- puts "reaped"
250
- exit 0
251
- end
252
- end
253
- Process.kill("KILL", pid) rescue nil
254
- Process.waitpid(pid) rescue nil
255
- abort "abandoned child survived GC"
256
- RUBY
257
- output, status = Open3.capture2e(RbConfig.ruby, "--disable-gems", "-I", File.expand_path("../lib", __dir__), "-e",
258
- script)
259
- assert status.success?, output
260
- assert_equal "reaped\n", output
261
- end
262
- end
@@ -1,4 +0,0 @@
1
- set -eu
2
- printf 'USER=%s\n' "$(id -un)"
3
- printf 'TTY=%s\n' "$(tty)"
4
- printf 'IDENTITY_OK\n'
@@ -1,5 +0,0 @@
1
- set -eu
2
- printf 'STDOUT_FIRST\n'
3
- printf 'STDERR_SECOND\n' >&2
4
- printf '中文输出:日志验证\n'
5
- printf 'STDOUT_LAST\n'
@@ -1,6 +0,0 @@
1
- set -eu
2
- printf 'DELAY_BEGIN\n'
3
- sleep 0.1
4
- printf 'DELAY_MIDDLE\n'
5
- sleep 0.1
6
- printf 'DELAY_END\n'
@@ -1,2 +0,0 @@
1
- printf 'EXPECTED_FAILURE\n' >&2
2
- exit 7
@@ -1,3 +0,0 @@
1
- set -eu
2
- printf 'RECOVERY_OK\n'
3
- printf 'VALUE=%s\n' "$((20 + 22))"