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,515 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class RubyAPITest < ExpectTest
6
+ def test_result_uses_native_struct_conversion_and_pattern_matching
7
+ session, = pipe_session
8
+ session.buffer = "before value=42 after"
9
+ session.expect(/value=(\d+)/, timeout: 0)
10
+ result = session.last_result
11
+ values = [1, nil, "value=42", "before ", " after", session, ["42"]]
12
+
13
+ assert_equal values, result.to_a
14
+ assert_equal values, result.deconstruct
15
+ assert_equal values, Array(result)
16
+ assert_equal ["42"], result.to_h.fetch(:captures)
17
+ refute_respond_to result, :to_ary
18
+ number, error, match, before, after, connection, captures = result.to_a
19
+ assert_equal values, [number, error, match, before, after, connection, captures]
20
+ assert_pattern { result => { number: 1, captures: ["42"] } }
21
+ end
22
+
23
+ def test_send_retains_ruby_reflection_and_write_sends_bytes
24
+ client, server = Socket.pair(:UNIX, :STREAM, 0)
25
+ @ios.push(client, server)
26
+ Expect.open(client) do |session|
27
+ session.buffer = "ready"
28
+ assert_equal "ready", session.send(:buffer)
29
+ assert_nil session.send(:timeout=, nil)
30
+ assert_equal 3, session.send(:write, "abc")
31
+ assert_equal("abc", bounded { server.read(3) })
32
+ end
33
+ end
34
+
35
+ def test_concise_expect_dsl_drives_a_dialogue_without_a_result_object
36
+ session = child('print "name: "; puts "hello " + gets.strip', raw_pty: true)
37
+ name = "Ruby"
38
+ matched = session.expect(timeout: 2) do
39
+ on("name: ") do |connection|
40
+ connection.puts(name)
41
+ connection.continue
42
+ end
43
+ on(/hello (\w+)/)
44
+ end
45
+ assert_equal 2, matched
46
+ assert_equal "hello Ruby", session.match
47
+ assert_equal ["Ruby"], session.captures
48
+ assert_equal 0, session.wait(timeout: 1).exitstatus
49
+ end
50
+
51
+ def test_concise_dsl_supports_events_multi_session_and_result_objects
52
+ first, writer = pipe_session
53
+ second, = pipe_session
54
+ writer.close
55
+ ended = []
56
+ timed_out = []
57
+ result = Expect.expect_result(timeout: 0.02) do
58
+ eof(from: first) do |connection|
59
+ ended << connection
60
+ connection.continue(reset_timeout: false)
61
+ end
62
+ on("missing", from: second)
63
+ timeout { |sessions| timed_out.concat(sessions) }
64
+ end
65
+ assert result.timeout?
66
+ assert_equal [first], ended
67
+ assert_equal [second], timed_out
68
+ assert_same second, result.session
69
+ end
70
+
71
+ def test_explicit_block_parameter_preserves_the_callers_self
72
+ session, = pipe_session
73
+ session.buffer = "ready"
74
+ owner = self
75
+ matched = session.expect(timeout: 0) do |patterns|
76
+ assert_same owner, self
77
+ patterns.on("ready") { assert_same owner, self }
78
+ end
79
+ assert_equal 1, matched
80
+ end
81
+
82
+ def test_optional_block_parameter_still_receives_the_pattern_builder
83
+ session, = pipe_session
84
+ session.buffer = "ready"
85
+ matched = session.expect(timeout: 0) { |patterns = nil| patterns.on("ready") }
86
+ assert_equal 1, matched
87
+ end
88
+
89
+ def test_break_from_a_concise_definition_does_not_consume_input
90
+ session, writer = pipe_session
91
+ writer.write("ready")
92
+ result = session.expect(timeout: 0) do
93
+ on("ready")
94
+ break :cancelled
95
+ end
96
+ assert_equal :cancelled, result
97
+ assert_nil session.last_result
98
+ assert_equal "ready", session.to_io.read_nonblock(5)
99
+ end
100
+
101
+ def test_exception_in_a_concise_definition_does_not_consume_input
102
+ session, writer = pipe_session
103
+ writer.write("ready")
104
+ assert_raises(RuntimeError) do
105
+ session.expect(timeout: 0) do
106
+ on("ready")
107
+ raise "definition failed"
108
+ end
109
+ end
110
+ assert_equal "ready", session.to_io.read_nonblock(5)
111
+ end
112
+
113
+ def test_block_patterns_drive_a_real_dialogue_with_captured_variables
114
+ session = child(<<~'RUBY', raw_pty: true)
115
+ print "name: "
116
+ name = gets.strip
117
+ print "code: "
118
+ puts "hello #{name}:#{gets.strip}"
119
+ RUBY
120
+ name = "Ruby"
121
+ result = session.expect_result(timeout: 2) do |patterns|
122
+ patterns.on("name: ") do |connection|
123
+ connection.puts(name)
124
+ connection.continue
125
+ end
126
+ patterns.on("code: ") do |connection|
127
+ connection.send_slow("123\n", delay: 0)
128
+ connection.continue(reset_timeout: false)
129
+ end
130
+ patterns.on(/hello (\w+):(\d+)/)
131
+ end
132
+
133
+ assert_equal 3, result.number
134
+ assert_equal %w[Ruby 123], result.captures
135
+ assert_same session, result.session
136
+ assert_equal 0, session.wait(timeout: 1).exitstatus
137
+ end
138
+
139
+ def test_keyword_timeout_and_default_timeout
140
+ session, writer = pipe_session
141
+ session.timeout = 0
142
+ assert session.expect_result("missing").timeout?
143
+ assert session.expect_result("missing", timeout: 0.01).timeout?
144
+ background do
145
+ sleep 0.02
146
+ writer.write("ready")
147
+ end
148
+ assert_equal(1, bounded { session.expect("ready", timeout: nil) })
149
+ assert_equal 0, session.timeout
150
+ end
151
+
152
+ def test_block_uses_keyword_timeout_and_returns_pattern_number
153
+ session, = pipe_session
154
+ session.buffer = "ready"
155
+ number = session.expect(timeout: 0) { |patterns| patterns.on("ready") }
156
+ assert_equal 1, number
157
+ end
158
+
159
+ def test_literal_patterns_are_unambiguous_even_with_callbacks
160
+ %w[-i -ex -unknown timeout eof a.c].each do |literal|
161
+ session, = pipe_session
162
+ session.buffer = "prefix #{literal} suffix"
163
+ observed = nil
164
+ result = session.expect_result(timeout: 0) do |patterns|
165
+ patterns.on(literal) { |connection| observed = connection.match }
166
+ end
167
+ assert result.matched?, literal
168
+ assert_equal literal, observed
169
+ assert_equal "prefix ", result.before
170
+ assert_equal " suffix", session.buffer
171
+ end
172
+ end
173
+
174
+ def test_positional_strings_are_literal_and_do_not_parse_command_line_flags
175
+ %w[-i -ex -re timeout eof].each do |literal|
176
+ session, = pipe_session
177
+ session.buffer = "before #{literal} after"
178
+ result = session.expect_result(literal, timeout: 0)
179
+ assert result.matched?
180
+ assert_equal literal, result.match
181
+ assert_equal " after", session.buffer
182
+ end
183
+ end
184
+
185
+ def test_duplicate_timeout_callbacks_are_rejected_before_reading
186
+ session, writer = pipe_session
187
+ writer.write("ready")
188
+ assert_raises(ArgumentError) do
189
+ session.expect(timeout: 0) do
190
+ timeout { nil }
191
+ timeout { nil }
192
+ end
193
+ end
194
+ assert_equal "ready", session.to_io.read_nonblock(5)
195
+ end
196
+
197
+ def test_timeout_callback_receives_all_active_sessions
198
+ first, = pipe_session
199
+ second, = pipe_session
200
+ observed = nil
201
+ Expect.expect(timeout: 0) do
202
+ on("first", from: first)
203
+ on("second", from: second)
204
+ timeout { |sessions| observed = sessions }
205
+ end
206
+ assert_equal [first, second], observed
207
+ end
208
+
209
+ def test_patterns_keep_declaration_priority
210
+ session, = pipe_session
211
+ session.buffer = "second first"
212
+ result = session.expect_result(timeout: 0) do |patterns|
213
+ patterns.on("first")
214
+ patterns.on("second")
215
+ end
216
+ assert_equal 1, result.number
217
+ assert_equal "second ", result.before
218
+ end
219
+
220
+ def test_invalid_patterns_and_positional_timeouts_do_not_consume_input
221
+ session, writer = pipe_session
222
+ writer.write("ready")
223
+ [nil, 0].each do |timeout|
224
+ assert_raises(ArgumentError) { session.expect(timeout, "ready", timeout: 1) }
225
+ assert_raises(ArgumentError) { Expect.expect(timeout, "-i", session, "ready", timeout: 1) }
226
+ end
227
+ assert_raises(ArgumentError) { session.expect("ready", timeout: 0) { |patterns| patterns.on("ready") } }
228
+ assert_raises(ArgumentError) { session.expect(timeout: 0) { |patterns| patterns.on(:eof) } }
229
+ assert_raises(ArgumentError) { session.expect(timeout: 0) { |patterns| patterns.on("ready", from: []) } }
230
+ assert_raises(ArgumentError) { Expect.expect(timeout: 0) { |patterns| patterns.on("ready") } }
231
+ assert_raises(ArgumentError) { Expect.expect(timeout: 0) { |patterns| patterns.timeout { nil } } }
232
+ assert_raises(ArgumentError) { session.expect(timeout: -1) { flunk "invalid timeout must fail before the block" } }
233
+ assert_empty session.buffer
234
+ assert_equal "ready", session.to_io.read_nonblock(5)
235
+ end
236
+
237
+ def test_definition_exception_does_not_run_callbacks_or_consume_input
238
+ session, writer = pipe_session
239
+ writer.write("ready")
240
+ assert_raises(RuntimeError) do
241
+ session.expect(timeout: 0) do |patterns|
242
+ patterns.on("ready") { flunk "callbacks must wait until configuration finishes" }
243
+ raise "configuration failed"
244
+ end
245
+ end
246
+ assert_nil session.last_result
247
+ assert_equal "ready", session.to_io.read_nonblock(5)
248
+ end
249
+
250
+ def test_callback_exception_preserves_the_match_and_unread_buffer
251
+ session, = pipe_session
252
+ session.buffer = "ready tail"
253
+ error = assert_raises(RuntimeError) do
254
+ session.expect(timeout: 0) do |patterns|
255
+ patterns.on("ready") { raise "callback failed" }
256
+ end
257
+ end
258
+ assert_equal "callback failed", error.message
259
+ assert_equal "ready", session.match
260
+ assert_equal " tail", session.buffer
261
+ end
262
+
263
+ def test_timeout_callback_can_continue_with_active_sessions
264
+ session, = pipe_session
265
+ observed = nil
266
+ result = session.expect_result(timeout: 0) do |patterns|
267
+ patterns.on("ready")
268
+ patterns.timeout do |sessions|
269
+ observed = sessions
270
+ session.buffer = "ready"
271
+ Expect.continue
272
+ end
273
+ end
274
+ assert_equal [session], observed
275
+ assert_equal 1, result.number
276
+ end
277
+
278
+ def test_continuation_without_reset_observes_the_deadline
279
+ session, = pipe_session
280
+ session.buffer = "ready"
281
+ result = bounded(1) do
282
+ session.expect_result(timeout: 0.01) do |patterns|
283
+ patterns.on(/(?=ready)/) { Expect.continue(reset_timeout: false) }
284
+ end
285
+ end
286
+ assert result.timeout?
287
+ assert_equal "ready", session.buffer
288
+ end
289
+
290
+ def test_eof_block_receives_the_session_and_remaining_bytes
291
+ session, writer = pipe_session
292
+ writer.write("tail")
293
+ writer.close
294
+ observed = nil
295
+ result = session.expect_result(timeout: 1) do |patterns|
296
+ patterns.eof { |connection| observed = [connection, connection.before] }
297
+ end
298
+ assert result.eof?
299
+ assert_equal [session, "tail"], observed
300
+ assert_empty session.buffer
301
+ end
302
+
303
+ def test_class_block_routes_patterns_to_multiple_sessions
304
+ first, = pipe_session
305
+ second, writer = pipe_session
306
+ writer.write("ready:42")
307
+ result = Expect.expect_result(timeout: 1) do |patterns|
308
+ patterns.on("missing", from: first)
309
+ patterns.on(/ready:(\d+)/, from: [first, second])
310
+ end
311
+ assert_equal 2, result.number
312
+ assert_same second, result.session
313
+ assert_equal ["42"], result.captures
314
+ end
315
+
316
+ def test_class_keyword_from_selects_a_session
317
+ session, writer = pipe_session
318
+ writer.write("ready")
319
+ assert_equal 1, Expect.expect("ready", from: session, timeout: 1)
320
+ end
321
+
322
+ def test_eof_continuation_keeps_other_sessions_active
323
+ first, first_writer = pipe_session
324
+ second, second_writer = pipe_session
325
+ first_writer.close
326
+ observed = []
327
+ result = Expect.expect_result(timeout: 1) do |patterns|
328
+ patterns.eof(from: first) do |connection|
329
+ observed << connection
330
+ second_writer.write("ready")
331
+ connection.continue(reset_timeout: false)
332
+ end
333
+ patterns.on("ready", from: second)
334
+ end
335
+ assert_equal [first], observed
336
+ assert_same second, result.session
337
+ assert_equal 2, result.number
338
+ end
339
+
340
+ def test_timeout_before_class_patterns_receives_active_sessions
341
+ session, = pipe_session
342
+ observed = nil
343
+ result = Expect.expect_result(timeout: 0) do |patterns|
344
+ patterns.timeout { |sessions| observed = sessions }
345
+ patterns.on("missing", from: session)
346
+ end
347
+ assert result.timeout?
348
+ assert_equal [session], observed
349
+ end
350
+
351
+ def test_open_returns_block_value_and_preserves_borrowed_io
352
+ reader, writer = IO.pipe
353
+ @ios.push(reader, writer)
354
+ connection = nil
355
+ result = Expect.open(reader) do |session|
356
+ connection = session
357
+ :finished
358
+ end
359
+ assert_equal :finished, result
360
+ assert connection.closed?
361
+ refute reader.closed?
362
+ refute writer.closed?
363
+ end
364
+
365
+ def test_open_closes_both_owned_handles_on_exception
366
+ reader, writer = IO.pipe
367
+ @ios.push(reader, writer)
368
+ assert_raises(RuntimeError) do
369
+ Expect.open(reader, writer: writer, own: true) { raise "stop" }
370
+ end
371
+ assert reader.closed?
372
+ assert writer.closed?
373
+ end
374
+
375
+ def test_invalid_open_options_close_owned_io_and_preserve_borrowed_io
376
+ [false, true].each do |own|
377
+ reader, writer = IO.pipe
378
+ @ios.push(reader, writer)
379
+ assert_raises(ArgumentError) do
380
+ Expect.open(reader, writer: writer, own: own, unknown: true) { flunk "invalid options" }
381
+ end
382
+ assert_equal own, reader.closed?
383
+ assert_equal own, writer.closed?
384
+ end
385
+ end
386
+
387
+ def test_boolean_predicates_use_ruby_truthiness_and_have_no_write_arguments
388
+ session, = pipe_session
389
+ refute session.log_stdout?
390
+ session.log_stdout = true
391
+ assert session.log_stdout?
392
+ session.log_stdout = 0
393
+ assert session.log_stdout?
394
+ session.log_stdout = nil
395
+ refute session.log_stdout?
396
+ assert_raises(ArgumentError) { session.log_stdout?(true) }
397
+ end
398
+
399
+ def test_readiness_accepts_an_empty_group_with_a_keyword_timeout
400
+ assert_empty Expect.readable_sessions(timeout: 0)
401
+ end
402
+
403
+ def test_spawn_closes_on_nonlocal_block_exit
404
+ connection = nil
405
+ result = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", "sleep 60", log_stdout: false) do |session|
406
+ connection = session
407
+ break :finished
408
+ end
409
+ assert_equal :finished, result
410
+ assert connection.closed?
411
+ refute connection.alive?
412
+ end
413
+
414
+ def test_buffer_setter_copies_bytes_and_applies_the_limit
415
+ session, = pipe_session(buffer_limit: 4)
416
+ value = +"before tail"
417
+ session.buffer = value
418
+ value.replace("changed")
419
+ session.buffer.clear
420
+ assert_equal "tail", session.buffer
421
+ assert_equal Encoding::BINARY, session.buffer.encoding
422
+ assert_equal "tail", session.clear_buffer
423
+ assert_empty session.buffer
424
+ assert_raises(ArgumentError) { session.buffer = nil }
425
+ end
426
+
427
+ def test_listeners_setter_takes_a_snapshot_and_validates_before_replacement
428
+ session, writer = pipe_session
429
+ output = StringIO.new
430
+ listeners = [output]
431
+ session.listeners = listeners
432
+ listeners.clear
433
+ session.listeners.clear
434
+ assert_raises(ArgumentError) { session.listeners = [Object.new] }
435
+ writer.write("ready")
436
+ session.expect("ready", timeout: 1)
437
+ assert_equal "ready", output.string
438
+ session.listeners = []
439
+ assert_empty session.listeners
440
+ end
441
+
442
+ def test_log_block_and_setter_receive_only_read_bytes
443
+ session, writer = pipe_session
444
+ chunks = []
445
+ session.log_to { |bytes| chunks << bytes }
446
+ assert_raises(ArgumentError) { session.log_to(StringIO.new) { nil } }
447
+ writer.write("first")
448
+ session.expect("first", timeout: 1)
449
+ output = StringIO.new
450
+ session.log_output = output
451
+ writer.write("second")
452
+ session.expect("second", timeout: 1)
453
+ session.log_output = nil
454
+ assert_equal "first", chunks.join
455
+ assert_equal "second", output.string
456
+ refute output.closed?
457
+ end
458
+
459
+ def test_puts_matches_standard_ruby_io_for_nested_and_recursive_arrays
460
+ client, server = Socket.pair(:UNIX, :STREAM, 0)
461
+ @ios.push(client, server)
462
+ recursive = ["once"]
463
+ recursive << recursive
464
+ values = [nil, 42, "中文", "already\n", ["nested", ["last"]], recursive, "\xff".b]
465
+ expected = StringIO.new("".b)
466
+ expected.puts
467
+ expected.puts(*values)
468
+ Expect.open(client) do |session|
469
+ assert_nil session.puts
470
+ assert_nil session.puts(*values)
471
+ end
472
+ assert_equal(expected.string, bounded { server.read(expected.string.bytesize) })
473
+ end
474
+
475
+ def test_assigning_the_current_log_preserves_file_ownership
476
+ session, = pipe_session
477
+ Dir.mktmpdir do |directory|
478
+ log = session.log_to(File.join(directory, "session.log"))
479
+ session.log_output = log
480
+ session.close
481
+ assert log.closed?
482
+ end
483
+ end
484
+
485
+ def test_write_converts_objects_and_append_returns_the_session
486
+ client, server = Socket.pair(:UNIX, :STREAM, 0)
487
+ @ios.push(client, server)
488
+ values = [nil, 42, %w[a b]]
489
+ expected = StringIO.new
490
+ count = expected.write(*values)
491
+ Expect.open(client) do |session|
492
+ assert_equal count, session.write(*values)
493
+ assert_same session, session << "tail" << "\n"
494
+ end
495
+ expected << "tail\n"
496
+ assert_equal(expected.string, bounded { server.read(expected.string.bytesize) })
497
+ end
498
+
499
+ def test_sequence_block_filters_escape_and_keeps_trailing_bytes
500
+ session, writer = pipe_session
501
+ output = StringIO.new
502
+ session.listeners = [output]
503
+ observed = []
504
+ value = :done
505
+ session.on_sequence("STOP") do
506
+ observed << value
507
+ false
508
+ end
509
+ writer.write("beforeSTOPafter")
510
+ assert_same session, Expect.interconnect(session, timeout: 1)
511
+ assert_equal [:done], observed
512
+ assert_equal "before", output.string
513
+ assert_equal "after", session.buffer
514
+ end
515
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/script_probe"
5
+ require "etc"
6
+
7
+ class ScriptLoggingTest < ExpectTest
8
+ def shell_probe
9
+ session = Expect.spawn("/bin/sh", "-i", env: { "PS1" => ScriptProbe::PROMPT, "ENV" => nil, "LC_ALL" => "C" },
10
+ raw_pty: true, log_stdout: false, write_timeout: 3)
11
+ @sessions << session
12
+ ScriptProbe::Runner.new(session).ready!
13
+ end
14
+
15
+ def test_multiple_script_files_with_persisted_log_and_shutdown_drain
16
+ runner = shell_probe
17
+ Dir.mktmpdir do |dir|
18
+ report = ScriptProbe.verify_file_session(runner, File.join(dir, "session.log"), user: Etc.getpwuid.name)
19
+ assert_equal([0, 0, 0, 7, 0, 0, 0], report[:cases].map { |result| result[:status] })
20
+ assert(report[:cases].all? { |result| result[:passed] })
21
+ assert_operator report[:log_bytes], :>, 500
22
+ assert_equal 10, report[:checks].length
23
+ end
24
+ end
25
+
26
+ def test_callback_log_records_multiple_scripts_exactly_once
27
+ runner = shell_probe
28
+ chunks = []
29
+ runner.session.log_to(->(bytes) { chunks << bytes.dup })
30
+ %w[first second].each do |value|
31
+ runner.run(value, "printf '#{value}\\n'\n", expected_status: 0, expected_output: "#{value}\n")
32
+ end
33
+ runner.finish!
34
+ log = ScriptProbe.normalize(chunks.join)
35
+ assert_equal 1, log.scan(/^first$/).length
36
+ assert_equal 1, log.scan(/^second$/).length
37
+ assert_operator log.index("[SEND] first"), :<, log.index("[SEND] second")
38
+ assert_includes log, "SESSION_FINAL_TAIL\n"
39
+ end
40
+
41
+ def test_borrowed_log_is_flushed_and_stays_open_after_multiple_scripts
42
+ runner = shell_probe
43
+ Tempfile.create("expect-borrowed-log") do |file|
44
+ runner.session.log_to(file)
45
+ runner.run("one", "printf 'BORROWED_ONE\\n'", expected_status: 0, expected_output: "BORROWED_ONE\n")
46
+ assert_includes File.binread(file.path), "BORROWED_ONE\n"
47
+ runner.run("two", "printf 'BORROWED_TWO\\n'", expected_status: 0, expected_output: "BORROWED_TWO\n")
48
+ runner.finish!
49
+ refute file.closed?
50
+ text = File.binread(file.path)
51
+ assert_includes text, "BORROWED_TWO\n"
52
+ assert_includes text, "SESSION_FINAL_TAIL\n"
53
+ end
54
+ end
55
+
56
+ def test_nonzero_exit_is_reported_even_when_expected_text_appears
57
+ runner = shell_probe
58
+ failure = assert_raises(ScriptProbe::Failure) do
59
+ runner.run("bad_status", "printf 'looks good\\n'; exit 9", expected_status: 0, expected_output: "looks good\n")
60
+ end
61
+ assert_match(/exit 9, expected 0/, failure.message)
62
+ refute runner.results.last[:passed]
63
+ result = runner.run("after_failure", "printf 'still alive\\n'", expected_status: 0,
64
+ expected_output: "still alive\n")
65
+ assert result[:passed]
66
+ end
67
+
68
+ def test_matching_output_is_required_even_with_zero_exit_status
69
+ runner = shell_probe
70
+ assert_raises(ScriptProbe::Failure) do
71
+ runner.run("wrong_output", "printf 'actual\\n'", expected_status: 0, expected_output: "expected\n")
72
+ end
73
+ refute runner.results.last[:passed]
74
+ end
75
+
76
+ def test_script_arguments_preserve_quotes_and_shell_metacharacters
77
+ runner = shell_probe
78
+ result = runner.run("quoting", "printf '%s\\n' \"a'b\" '$(not-a-command)' 'semi;colon'\n",
79
+ expected_status: 0, expected_output: "a'b\n$(not-a-command)\nsemi;colon\n")
80
+ assert result[:passed]
81
+ end
82
+
83
+ def test_crlf_terminal_output_preserves_exact_script_boundaries
84
+ runner = shell_probe
85
+ runner.session.write("stty opost onlcr\n")
86
+ assert_equal 1, runner.session.expect(ScriptProbe::PROMPT, timeout: 2)
87
+ ["", "line\n", "no final newline", "two\n\n"].each do |output|
88
+ result = runner.run("crlf", "printf %s #{Shellwords.escape(output)}",
89
+ expected_status: 0, expected_output: output)
90
+ assert_equal output, result[:output]
91
+ end
92
+ end
93
+
94
+ def test_logger_error_is_visible_to_caller
95
+ runner = shell_probe
96
+ runner.session.log_to(->(_) { raise IOError, "log destination failed" })
97
+ error = assert_raises(IOError) do
98
+ runner.run("logger_failure", "printf 'output\\n'", expected_status: 0, expected_output: "output\n")
99
+ end
100
+ assert_equal "log destination failed", error.message
101
+ assert_empty runner.results
102
+ end
103
+
104
+ def test_timeout_preserves_log_and_subsequent_output_is_captured
105
+ runner = shell_probe
106
+ session = runner.session
107
+ Dir.mktmpdir do |dir|
108
+ path = File.join(dir, "timeout.log")
109
+ session.log_to(path, mode: "w")
110
+ session.write("printf 'BEFORE_TIMEOUT\\n'; sleep 0.15; printf 'AFTER_TIMEOUT\\n'\n")
111
+ assert_equal 1, session.expect("BEFORE_TIMEOUT\n", timeout: 2)
112
+ assert_nil session.expect("AFTER_TIMEOUT", timeout: 0.02)
113
+ assert_equal :timeout, session.error
114
+ assert_includes File.binread(path), "BEFORE_TIMEOUT\n"
115
+ assert_equal 1, session.expect("AFTER_TIMEOUT\n", timeout: 2)
116
+ assert_equal 1, session.expect(ScriptProbe::PROMPT, timeout: 2)
117
+ runner.finish!
118
+ text = File.binread(path)
119
+ assert_equal 1, text.scan("BEFORE_TIMEOUT\n").length
120
+ assert_equal 1, text.scan("AFTER_TIMEOUT\n").length
121
+ assert_includes text, "SESSION_FINAL_TAIL\n"
122
+ end
123
+ end
124
+ end