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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "script_probe"
4
+
5
+ module InteractProbe
6
+ ESCAPE = "\x1d".b.freeze
7
+ TAIL = "LOCAL_ONLY_TAIL".b.freeze
8
+
9
+ def self.configuration(session)
10
+ state = session.stty
11
+ return state unless RUBY_PLATFORM.include?("darwin")
12
+
13
+ # PENDIN is a transient Darwin kernel state, not a termios configuration.
14
+ state.sub(/lflag=([0-9a-f]+)/) { "lflag=#{(Regexp.last_match(1).to_i(16) & ~0x20000000).to_s(16)}" }
15
+ end
16
+
17
+ def self.settings(session)
18
+ [session.listeners, session.log_stdout, session.log_listeners, session.raw_terminal?,
19
+ session.instance_variable_get(:@sequences).dup]
20
+ end
21
+
22
+ def self.prepare(session, echo: false)
23
+ # The remote tty needs ISIG for Ctrl-C to reach its foreground process.
24
+ # Manual interaction needs remote echo because the local input is raw.
25
+ session.write("stty sane #{echo ? "echo" : "-echo"}; set +o emacs; set +o vi\n")
26
+ ScriptProbe.check(session.expect(ScriptProbe::PROMPT, timeout: 5), "terminal setup did not return to shell")
27
+ session.clear_buffer
28
+ end
29
+
30
+ # A real local terminal slave is passed to interact; its master acts as a
31
+ # keyboard/screen. Only interact reads the remote session during handoff.
32
+ def self.verify(session, user:)
33
+ master, slave = PTY.open
34
+ source = Expect.open(slave)
35
+ screen = Expect.open(master, write_timeout: 3)
36
+ source.on_sequence("ORIGINAL_ESCAPE") { false }
37
+ source.log_listeners = false
38
+ source.log_stdout = false
39
+ terminal_state = configuration(source)
40
+ remote_state = configuration(session)
41
+ source_settings = settings(source)
42
+ remote_settings = settings(session)
43
+ results = []
44
+ checks = []
45
+
46
+ run_cycle = lambda do |number, &actions|
47
+ nonce = SecureRandom.hex(8)
48
+ marker = "HANDOFF_#{nonce}"
49
+ session.write("printf '\\n%s%s\\n' 'HANDOFF_' '#{nonce}'\n")
50
+ worker = Thread.new do
51
+ ScriptProbe.check(screen.expect(/#{Regexp.escape(marker)}\r?\n/, timeout: 5),
52
+ "handoff output did not reach local terminal")
53
+ ScriptProbe.check(screen.expect(ScriptProbe::PROMPT, timeout: 5), "handoff prompt missing")
54
+ ScriptProbe.check(!slave.echo?, "local input terminal did not disable echo")
55
+ runner = ScriptProbe::Runner.new(screen)
56
+ actions.call(runner)
57
+ screen.write(ESCAPE + TAIL)
58
+ runner.results
59
+ rescue Exception # rubocop:disable Lint/RescueException -- Clean up terminal drivers even on Interrupt or SystemExit.
60
+ begin
61
+ screen.write(ESCAPE)
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ raise
66
+ end
67
+ worker.report_on_exception = false
68
+ begin
69
+ returned = session.interact(input: source, escape: ESCAPE, output: slave, timeout: 20)
70
+ ScriptProbe.check(worker.join(2), "keyboard driver did not finish")
71
+ results.concat(worker.value)
72
+ ScriptProbe.check(returned.equal?(source), "interact did not return on the local escape")
73
+ ScriptProbe.check(source.clear_buffer == TAIL, "escape tail was lost or forwarded to remote")
74
+ ScriptProbe.check(session.alive?, "local escape closed the remote process")
75
+ ScriptProbe.check(configuration(source) == terminal_state, "local terminal settings were not restored")
76
+ ScriptProbe.check(configuration(session) == remote_state,
77
+ "remote transport terminal settings were not restored")
78
+ ScriptProbe.check(settings(source) == source_settings, "local groups/flags/escape handlers were not restored")
79
+ ScriptProbe.check(settings(session) == remote_settings, "remote groups/flags/escape handlers were not restored")
80
+ checks << "cycle_#{number}_escape_tail_and_restore"
81
+ ensure
82
+ worker.kill.join if worker.alive?
83
+ end
84
+ end
85
+
86
+ run_cycle.call(1) do |runner|
87
+ runner.run("interactive_identity", "printf 'USER='; id -un; test -t 0 && printf 'TTY_OK\\n'",
88
+ expected_status: 0, expected_output: "USER=#{user}\nTTY_OK\n")
89
+ runner.run_file(
90
+ File.join(ScriptProbe::FIXTURES, "02_output.sh"),
91
+ expected_status: 0,
92
+ expected_output: "STDOUT_FIRST\nSTDERR_SECOND\n中文输出:日志验证\nSTDOUT_LAST\n"
93
+ )
94
+ nonce = SecureRandom.hex(8)
95
+ # An actual foreground shell handles the forwarded Ctrl-C. Readiness is
96
+ # acknowledged before sending the control byte, without a timing guess.
97
+ script = "trap 'printf \"INT_HANDLED_#{nonce}\\n\"; exit 0' INT; " \
98
+ "printf 'INT_READY_#{nonce}\\n'; while :; do sleep 1; done"
99
+ screen.write("/bin/sh -c #{Shellwords.escape(script)}\n")
100
+ ScriptProbe.check(screen.expect(/INT_READY_#{nonce}\r?\n/, timeout: 5), "foreground command did not become ready")
101
+ screen.write("\x03")
102
+ ScriptProbe.check(screen.expect(/INT_HANDLED_#{nonce}\r?\n/, timeout: 5),
103
+ "Ctrl-C did not reach remote foreground process")
104
+ ScriptProbe.check(screen.expect(ScriptProbe::PROMPT, timeout: 5), "shell did not recover after Ctrl-C")
105
+ checks << "ctrl_c_forwarded_to_remote"
106
+ end
107
+
108
+ resumed = ScriptProbe::Runner.new(session)
109
+ resumed.run("expect_after_interact", "printf 'AUTOMATION_RESUMED\\n'", expected_status: 0,
110
+ expected_output: "AUTOMATION_RESUMED\n")
111
+ results.concat(resumed.results)
112
+ run_cycle.call(2) do |runner|
113
+ runner.run("interact_again", "printf 'SECOND_HANDOFF_OK\\n'", expected_status: 0,
114
+ expected_output: "SECOND_HANDOFF_OK\n")
115
+ end
116
+ checks.push("keyboard_to_remote", "remote_to_screen", "utf8_stdout_stderr",
117
+ "raw_mode_during_interact", "expect_resume", "reenter_interact")
118
+ { cases: results, checks: checks,
119
+ local_tty: slave.path, local_terminal_restored: true, transport_terminal_restored: true }
120
+ ensure
121
+ source&.close
122
+ screen&.close
123
+ [master, slave].compact.each { |io| io.close unless io.closed? }
124
+ end
125
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "interact_probe"
4
+
5
+ # Drive the actual host and join CLIs through two independent local terminals.
6
+ # No Minitest dependency, SSH service, password, or external user is needed.
7
+ module KibitzProbe
8
+ EXAMPLE = File.expand_path("../../examples/kibitz/kibitz.rb", __dir__)
9
+
10
+ class Pair
11
+ attr_reader :host, :guest, :socket_path, :log_path
12
+
13
+ def initialize(directory, noproc: false, host_flags: [], guest_flags: [])
14
+ @sessions = []
15
+ @terminal_modes = {}
16
+ @log_path = File.join(directory, "session.log")
17
+ command = noproc ? ["--noproc"] : ["--", "env", "ENV=", "PS1=#{ScriptProbe::PROMPT}", "LC_ALL=C", "/bin/sh", "-i"]
18
+ @host = spawn_cli("--timeout", "15", "--log", log_path, *host_flags, *command)
19
+ invitation = host.expect_result(%r{--join (/tmp/expect-kibitz-[^\r\n ]+/peer\.sock)}, timeout: 5)
20
+ ScriptProbe.check(invitation.matched?, "host did not print the join command")
21
+ @socket_path = invitation.captures.first
22
+ ScriptProbe.check(File.stat(File.dirname(socket_path)).mode & 0o777 == 0o700, "socket directory is not private")
23
+ ScriptProbe.check(File.stat(socket_path).mode & 0o777 == 0o600, "socket is not private")
24
+ @guest = spawn_cli("--join", socket_path, "--timeout", "15", *guest_flags)
25
+ [host, guest].each { |terminal| expect(terminal, /Kibitz connected\.[^\r\n]*\r?\n/) }
26
+ ScriptProbe.check(!host.to_io.echo? && !guest.to_io.echo?, "connected banner preceded terminal readiness")
27
+ rescue Exception # rubocop:disable Lint/RescueException -- Clean up terminal drivers even on Interrupt or SystemExit.
28
+ close
29
+ raise
30
+ end
31
+
32
+ def spawn_cli(*)
33
+ terminal = Expect.new(log_stdout: false, write_timeout: 3)
34
+ @sessions << terminal
35
+ @terminal_modes[terminal] = InteractProbe.configuration(terminal)
36
+ terminal.spawn(RbConfig.ruby, EXAMPLE, *)
37
+ terminal
38
+ end
39
+
40
+ def expect(terminal, pattern)
41
+ result = terminal.expect_result(pattern, timeout: 5)
42
+ ScriptProbe.check(result.matched?,
43
+ "#{terminal.equal?(host) ? "host" : "guest"} missing #{pattern.inspect} (#{result.error})")
44
+ result
45
+ end
46
+
47
+ def both(pattern)
48
+ [host, guest].map { |terminal| expect(terminal, pattern) }
49
+ end
50
+
51
+ def prepare_shell
52
+ ScriptProbe::Runner.new(host).ready!
53
+ host.write("printf '\\n%s%s\\n' 'KIBITZ_' 'READY'\n")
54
+ both(/\r?\nKIBITZ_READY\r?\n/)
55
+ both(ScriptProbe::PROMPT)
56
+ end
57
+
58
+ def command(terminal, command, output)
59
+ terminal.write(command, "\n")
60
+ both(output)
61
+ both(ScriptProbe::PROMPT)
62
+ end
63
+
64
+ def finish!(expected_host_status: 0)
65
+ [host, guest].each do |terminal|
66
+ result = terminal.expect_result(:eof, timeout: 5)
67
+ ScriptProbe.check(result.eof?, "kibitz did not reach EOF")
68
+ status = terminal.wait(timeout: 2)
69
+ expected = terminal.equal?(host) ? expected_host_status : 0
70
+ ScriptProbe.check(status && status.exitstatus == expected,
71
+ "kibitz exit status was #{status&.exitstatus.inspect}, expected #{expected}")
72
+ ScriptProbe.check(InteractProbe.configuration(terminal) == @terminal_modes.fetch(terminal),
73
+ "terminal mode was not restored")
74
+ end
75
+ ScriptProbe.check(!File.exist?(File.dirname(socket_path)), "socket directory was not cleaned up")
76
+ ScriptProbe.check(File.stat(log_path).mode & 0o777 == 0o600, "log permissions changed")
77
+ self
78
+ end
79
+
80
+ def close
81
+ @sessions&.reverse_each(&:close)
82
+ end
83
+ end
84
+
85
+ def self.shared_shell(directory)
86
+ pair = Pair.new(directory)
87
+ # With local terminals raw, the process's terminal supplies typed echo.
88
+ pair.both(ScriptProbe::PROMPT)
89
+ pair.host.write("printf 'VISIBLE_TYPING\\n'")
90
+ pair.both("printf 'VISIBLE_TYPING\\n'")
91
+ pair.host.write("\n")
92
+ pair.both(/\r?\nVISIBLE_TYPING\r?\n/)
93
+ pair.both(ScriptProbe::PROMPT)
94
+ # ready! expects an outstanding prompt; use a fresh one after the echo check.
95
+ pair.host.write("\n")
96
+ pair.prepare_shell
97
+ pair.command(pair.host, "shared_value=42; printf 'HOST_SET\\n'", /HOST_SET\r?\n/)
98
+ pair.command(pair.guest, "printf 'SHARED=%s\\n' \"$shared_value\"", /SHARED=42\r?\n/)
99
+ pair.command(pair.guest, "printf '中文输出\\n'; printf 'GUEST_STDERR\\n' >&2", /中文输出\r?\nGUEST_STDERR\r?\n/)
100
+ pair.command(pair.host, "/bin/sh -c 'exit 7'; printf 'RECOVERED=%s\\n' \"$?\"", /RECOVERED=7\r?\n/)
101
+ script = "trap 'printf \"INT_HANDLED\\n\"; exit 0' INT; printf 'INT_READY\\n'; while :; do sleep 1; done"
102
+ pair.host.write("/bin/sh -c #{Shellwords.escape(script)}\n")
103
+ pair.both(/INT_READY\r?\n/)
104
+ pair.guest.write("\x03")
105
+ pair.both(/INT_HANDLED\r?\n/)
106
+ pair.both(ScriptProbe::PROMPT)
107
+ pair.guest.write("printf 'FINAL_TAIL\\n'; exit 0\n")
108
+ pair.both(/FINAL_TAIL\r?\n/)
109
+ pair.finish!
110
+ log = ScriptProbe.normalize(File.binread(pair.log_path))
111
+ %w[HOST_SET SHARED=42 GUEST_STDERR RECOVERED=7 INT_READY INT_HANDLED FINAL_TAIL].each do |line|
112
+ # With echo disabled the output can follow a prompt on the same line.
113
+ ScriptProbe.check(log.scan("#{line}\n").length == 1, "#{line} missing or duplicated in log")
114
+ end
115
+ { name: "shared_shell", passed: true,
116
+ checks: %w[visible_typing both_keyboards shared_shell_state broadcast_stdout_stderr utf8 nonzero_recovery
117
+ guest_ctrl_c process_eof final_tail unique_log terminal_restore socket_cleanup] }
118
+ ensure
119
+ pair&.close
120
+ end
121
+
122
+ def self.direct(directory, ending: :host)
123
+ custom = ending == :host ? ["--escape", "STOP"] : []
124
+ pair = Pair.new(directory, noproc: true, host_flags: custom)
125
+ pair.host.write("HOST_MESSAGE")
126
+ pair.expect(pair.guest, "HOST_MESSAGE")
127
+ ScriptProbe.check(pair.host.expect("HOST_MESSAGE", timeout: 0.02).nil?, "noproc echoed the sender's input")
128
+ pair.guest.write("中文回信")
129
+ pair.expect(pair.host, "中文回信")
130
+ ScriptProbe.check(pair.guest.expect("中文回信", timeout: 0.02).nil?, "noproc echoed the guest's input")
131
+ if ending == :host
132
+ pair.host.write("prefixST")
133
+ pair.expect(pair.guest, "prefix")
134
+ ScriptProbe.check(pair.guest.expect("ST", timeout: 0.02).nil?, "split escape prefix leaked to peer")
135
+ pair.host.write("OPLOCAL_TAIL")
136
+ else
137
+ pair.guest.write(InteractProbe::ESCAPE)
138
+ end
139
+ pair.finish!
140
+ ScriptProbe.check(!pair.guest.before.include?("LOCAL_TAIL") && !pair.guest.before.include?("STOP"),
141
+ "local escape leaked to peer")
142
+ { name: "noproc_#{ending}_escape", passed: true,
143
+ checks: %w[two_way_bytes utf8 no_local_echo escape peer_eof terminal_restore socket_cleanup] }
144
+ ensure
145
+ pair&.close
146
+ end
147
+
148
+ def self.noescape(directory)
149
+ pair = Pair.new(directory, noproc: true, host_flags: ["--noescape"])
150
+ pair.host.write("before\x1dafter")
151
+ pair.expect(pair.guest, "before\x1dafter")
152
+ pair.guest.write(InteractProbe::ESCAPE)
153
+ pair.finish!
154
+ { name: "noescape", passed: true, checks: %w[control_byte_forwarded peer_eof terminal_restore socket_cleanup] }
155
+ ensure
156
+ pair&.close
157
+ end
158
+
159
+ def self.process_failure(directory)
160
+ pair = Pair.new(directory)
161
+ pair.prepare_shell
162
+ pair.host.write("exit 7\n")
163
+ pair.finish!(expected_host_status: 7)
164
+ { name: "process_failure", passed: true, checks: %w[nonzero_exit_status peer_eof terminal_restore socket_cleanup] }
165
+ ensure
166
+ pair&.close
167
+ end
168
+
169
+ def self.relay_timeout(directory)
170
+ pair = Pair.new(directory, noproc: true, host_flags: ["--timeout", "1"])
171
+ pair.finish!(expected_host_status: 1)
172
+ ScriptProbe.check(pair.host.before.include?("Kibitz ended (timeout)."), "relay did not report its timeout")
173
+ { name: "relay_timeout", passed: true, checks: %w[timeout_status peer_eof terminal_restore socket_cleanup] }
174
+ ensure
175
+ pair&.close
176
+ end
177
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+ require "shellwords"
6
+ require_relative "../../lib/expect/pty"
7
+
8
+ # Shared by the real SSH check and the automatic local-PTY tests. This is a
9
+ # test harness for a POSIX shell, not a network-device command abstraction.
10
+ module ScriptProbe
11
+ PROMPT = "EXPECT_SCRIPT_PROMPT> "
12
+ FIXTURES = File.expand_path("../fixtures/ssh_scripts", __dir__)
13
+ class Failure < StandardError; end
14
+
15
+ def self.check(condition, message)
16
+ raise Failure, message unless condition
17
+ end
18
+
19
+ def self.normalize(bytes)
20
+ bytes.gsub("\r\n", "\n").b
21
+ end
22
+
23
+ class Runner
24
+ attr_reader :session, :results
25
+
26
+ def initialize(session, timeout: 5)
27
+ @session = session
28
+ @timeout = timeout
29
+ @results = []
30
+ end
31
+
32
+ def ready!
33
+ result = session.expect_result(PROMPT, timeout: @timeout)
34
+ ScriptProbe.check(result.matched?, "shell prompt missing (#{result.error})")
35
+ # Avoid terminal echo being mistaken for script output or leaking the
36
+ # test's wrapper command into the output we verify.
37
+ # Interactive shells may interpret high-bit bytes as readline commands
38
+ # in a C locale. Disable editing for literal script transmission.
39
+ session.write("stty -echo; set +o emacs; set +o vi\n")
40
+ result = session.expect_result(PROMPT, timeout: @timeout)
41
+ ScriptProbe.check(result.matched?, "shell setup failed (#{result.error})")
42
+ session.clear_buffer
43
+ self
44
+ end
45
+
46
+ def run_file(path, expected_status:, expected_output:)
47
+ run(File.basename(path), File.binread(path), expected_status: expected_status, expected_output: expected_output)
48
+ end
49
+
50
+ def run(name, script, expected_status:, expected_output:)
51
+ nonce = SecureRandom.hex(12)
52
+ start_marker = "PROBE_BEGIN_#{nonce}"
53
+ end_marker = "PROBE_END_#{nonce}"
54
+ digest = Digest::SHA256.hexdigest(script)
55
+ # Quote once for the remote shell without inserting backslashes between
56
+ # UTF-8 bytes (File.binread returns an ASCII-8BIT string).
57
+ quoted_script = "'#{script.gsub("'", %q('"'"'))}'"
58
+ # Inputs are logged deliberately as metadata: Expect's automatic log
59
+ # records received bytes, not sends. Never include authentication here.
60
+ session.write_log("\n[SEND] #{name} sha256=#{digest}\n")
61
+ command = "printf '\\n%s%s\\n' 'PROBE_BEGIN_' '#{nonce}'; " \
62
+ "/bin/sh -c #{quoted_script}; probe_status=$?; " \
63
+ "printf '\\n%s%s:%s\\n' 'PROBE_END_' '#{nonce}' \"$probe_status\"\n"
64
+ session.write(command)
65
+ started = session.expect_result(/(?:\A|\r?\n)#{Regexp.escape(start_marker)}\r?\n/, timeout: @timeout)
66
+ ScriptProbe.check(started.matched?, "#{name}: begin marker missing (#{started.error})")
67
+ ended = session.expect_result(/(?:\A|\r?\n)#{Regexp.escape(end_marker)}:(\d+)\r?\n/, timeout: @timeout)
68
+ ScriptProbe.check(ended.matched?, "#{name}: script did not finish (#{ended.error})")
69
+ output = ScriptProbe.normalize(ended.before)
70
+ status = Integer(ended.captures.fetch(0), 10)
71
+ # Wait for the shell before starting another script, including when the
72
+ # prior script exited nonzero. Each script runs in its own subshell.
73
+ prompt = session.expect_result(PROMPT, timeout: @timeout)
74
+ ScriptProbe.check(prompt.matched?, "#{name}: shell did not recover (#{prompt.error})")
75
+ session.write_log("\n[EXIT] #{name} status=#{status}\n")
76
+ passed = status == expected_status && output == expected_output.b
77
+ result = { name: name, sha256: digest, status: status, expected_status: expected_status,
78
+ output: output.dup.force_encoding(Encoding::UTF_8), passed: passed }
79
+ results << result
80
+ ScriptProbe.check(status == expected_status, "#{name}: exit #{status}, expected #{expected_status}")
81
+ ScriptProbe.check(output == expected_output.b, "#{name}: output differs from fixture expectation")
82
+ result
83
+ end
84
+
85
+ def finish!
86
+ # The last bytes arrive while soft_close drains the session. No expect
87
+ # call is made after this send, so the log proves shutdown draining.
88
+ session.write("sleep 0.1; printf '%s%s\\n' 'SESSION_' 'FINAL_TAIL'; exit 0\n")
89
+ session.soft_close(timeout: 3)
90
+ ScriptProbe.check(session.closed? && session.exit_code&.zero?, "shell did not exit cleanly")
91
+ end
92
+ end
93
+
94
+ # Run all script fixtures in one session, then verify the persisted log.
95
+ # An explicit nonzero fixture is an expected success of the test harness.
96
+ def self.verify_file_session(runner, path, user:)
97
+ session = runner.session
98
+ # With SSH, the remote TTY differs from the local PTY. Obtain it using an
99
+ # independent command, then demand an exact identity fixture response.
100
+ session.write("printf '\\n%s' 'TTY_PROBE='; tty\n")
101
+ tty = session.expect_result(%r{(?:\A|\n)TTY_PROBE=(/dev/[^\r\n]+)\r?\n}, timeout: 5)
102
+ check(tty.matched?, "remote TTY probe failed")
103
+ terminal = tty.captures.fetch(0)
104
+ check(session.expect(PROMPT, timeout: 5), "TTY probe did not return to shell")
105
+
106
+ # The caller allocates a fresh private directory; exercise truncation only
107
+ # on this new test file, never on an existing user's log.
108
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write("OLD_TEST_CONTENT\n") }
109
+ log = session.log_to(path, mode: "w")
110
+ cases = [
111
+ ["01_identity.sh", 0, "USER=#{user}\nTTY=#{terminal}\nIDENTITY_OK\n"],
112
+ ["02_output.sh", 0, "STDOUT_FIRST\nSTDERR_SECOND\n中文输出:日志验证\nSTDOUT_LAST\n"],
113
+ ["03_delayed.sh", 0, "DELAY_BEGIN\nDELAY_MIDDLE\nDELAY_END\n"],
114
+ ["04_failure.sh", 7, "EXPECTED_FAILURE\n"],
115
+ ["05_recovery.sh", 0, "RECOVERY_OK\nVALUE=42\n"]
116
+ ]
117
+ cases.each do |name, status, output|
118
+ runner.run_file(File.join(FIXTURES, name), expected_status: status, expected_output: output)
119
+ # Read while the logger is open to prove writes are immediately visible.
120
+ live = normalize(File.binread(path))
121
+ check(live.include?(output.b), "#{name}: live log is missing output")
122
+ check(live.include?("[EXIT] #{name} status=#{status}\n"), "#{name}: live exit annotation missing")
123
+ end
124
+ session.log_output = nil
125
+ check(log.closed?, "owned log was not closed")
126
+ before_disabled = File.binread(path)
127
+ runner.run("logging_disabled", "printf 'UNLOGGED_OUTPUT\\n'\n", expected_status: 0,
128
+ expected_output: "UNLOGGED_OUTPUT\n")
129
+ check(File.binread(path) == before_disabled, "disabled logger still wrote data")
130
+
131
+ # The default mode must append, retaining every preceding command.
132
+ appended = session.log_to(path)
133
+ runner.run("logging_resumed", "printf 'APPEND_OK\\n'\n", expected_status: 0, expected_output: "APPEND_OK\n")
134
+ runner.finish!
135
+ check(appended.closed?, "close did not close the owned log")
136
+ bytes = File.binread(path)
137
+ check(bytes.start_with?(before_disabled), "append mode overwrote previous log bytes")
138
+ text = normalize(bytes)
139
+ check(!text.include?("OLD_TEST_CONTENT") && !text.include?("UNLOGGED_OUTPUT"),
140
+ "truncated or disabled data leaked into log")
141
+ position = -1
142
+ cases.each do |name, status, output|
143
+ sent = text.index("[SEND] #{name} ")
144
+ exited = text.index("[EXIT] #{name} status=#{status}\n")
145
+ check(sent && exited && sent > position && exited > sent, "#{name}: incorrect log execution order")
146
+ check(text.scan(Regexp.new(Regexp.escape(output.b), Regexp::NOENCODING)).length == 1,
147
+ "#{name}: output missing or duplicated")
148
+ position = exited
149
+ end
150
+ check(text.include?("APPEND_OK\n"), "resumed logging lost output")
151
+ check(text.scan("SESSION_FINAL_TAIL\n").length == 1, "shutdown lost or duplicated final output")
152
+ { cases: runner.results, remote_tty: terminal, log_bytes: bytes.bytesize,
153
+ log_sha256: Digest::SHA256.hexdigest(bytes),
154
+ checks: %w[exact_output execution_order live_flush stdout_stderr utf8 nonzero_recovery
155
+ truncate disable append eof_drain] }
156
+ end
157
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "timeout"
5
+ require "stringio"
6
+ require "tempfile"
7
+ require "tmpdir"
8
+ require "socket"
9
+ require_relative "../lib/expect"
10
+
11
+ class ExpectTest < Minitest::Test
12
+ def setup
13
+ @sessions = []
14
+ @ios = []
15
+ @threads = []
16
+ @configuration = Expect.configuration
17
+ end
18
+
19
+ def teardown
20
+ @threads.each { |thread| thread.kill.join if thread.alive? }
21
+ @sessions.reverse_each { |session| session.hard_close(timeout: 0.03) }
22
+ @ios.each { |io| io.close unless io.closed? }
23
+ Expect.configure(**@configuration.to_h)
24
+ end
25
+
26
+ def child(script, **)
27
+ session = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", "STDOUT.sync = true; STDERR.sync = true; #{script}",
28
+ log_stdout: false, **)
29
+ @sessions << session
30
+ session
31
+ end
32
+
33
+ def pipe_session(**)
34
+ reader, writer = IO.pipe
35
+ @ios.push(reader, writer)
36
+ session = Expect.open(reader, **)
37
+ @sessions << session
38
+ [session, writer]
39
+ end
40
+
41
+ def background(&)
42
+ @threads << Thread.new(&)
43
+ @threads.last
44
+ end
45
+
46
+ def bounded(seconds = 5, &)
47
+ Timeout.timeout(seconds, &)
48
+ end
49
+
50
+ def terminal_configuration(session)
51
+ state = session.stty
52
+ return state unless RUBY_PLATFORM.include?("darwin")
53
+
54
+ # Darwin marks pending-input retyping after tcsetattr. PENDIN is a kernel
55
+ # state bit (sys/termios.h), not a changed terminal configuration.
56
+ state.sub(/lflag=([0-9a-f]+)/) { "lflag=#{(Regexp.last_match(1).to_i(16) & ~0x20000000).to_s(16)}" }
57
+ end
58
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class TimeoutTest < ExpectTest
6
+ def test_default_timeout_and_explicit_infinite_timeout
7
+ session, writer = pipe_session
8
+ session.timeout = 0.01
9
+ assert_nil session.expect("ready")
10
+ background do
11
+ sleep 0.04
12
+ writer.write("ready")
13
+ end
14
+ assert_equal(1, bounded { session.expect("ready", timeout: nil) })
15
+ end
16
+
17
+ def test_zero_timeout_polls_existing_data_without_waiting
18
+ session, writer = pipe_session
19
+ writer.write("ready")
20
+ assert_equal 1, session.expect("ready", timeout: 0)
21
+ start = Expect.monotonic
22
+ assert_nil session.expect("missing", timeout: 0)
23
+ assert_operator Expect.monotonic - start, :<, 0.1
24
+ end
25
+
26
+ def test_continue_resets_timeout
27
+ session, writer = pipe_session
28
+ number = with_timed_input(writer, [[7, "A"], [16, "B"]]) do
29
+ session.expect(timeout: 13) do
30
+ on("A") { Expect.continue }
31
+ on("B")
32
+ end
33
+ end
34
+ assert_equal 2, number
35
+ end
36
+
37
+ def test_continue_timeout_preserves_deadline
38
+ session, writer = pipe_session
39
+ number = with_timed_input(writer, [[7, "A"], [19, "B"]]) do
40
+ result = session.expect(timeout: 14) do
41
+ on("A") { Expect.continue(reset_timeout: false) }
42
+ on("B")
43
+ end
44
+ assert_equal 14, Expect.monotonic
45
+ result
46
+ end
47
+ assert_nil number
48
+ assert_equal :timeout, session.error
49
+ end
50
+
51
+ def test_restart_timeout_on_receive
52
+ session, writer = pipe_session
53
+ session.reset_timeout_on_read = true
54
+ number = with_timed_input(writer, [[6, "."], [12, "."], [18, "."], [24, ".done"]]) do
55
+ session.expect("done", timeout: 10)
56
+ end
57
+ assert_equal 1, number
58
+ end
59
+
60
+ def test_receive_keeps_deadline_without_reset
61
+ session, writer = pipe_session
62
+ result = with_timed_input(writer, [[6, "."], [12, "done"]]) do
63
+ session.expect_result("done", timeout: 10)
64
+ end
65
+ assert result.timeout?
66
+ assert_equal ".", session.buffer
67
+ end
68
+
69
+ def test_timeout_callback_receives_group_and_can_retry
70
+ session, = pipe_session
71
+ count = 0
72
+ groups = []
73
+ argument = :value
74
+ number = session.expect(timeout: 0.01) do
75
+ timeout do |objects|
76
+ groups << [objects, argument]
77
+ count += 1
78
+ count < 3 ? Expect.continue : nil
79
+ end
80
+ end
81
+ assert_nil number
82
+ assert_equal 3, count
83
+ assert_equal [[[session], :value]] * 3, groups
84
+ end
85
+
86
+ def test_callback_exception_propagates
87
+ session, = pipe_session
88
+ session.buffer = "ready"
89
+ assert_raises(RuntimeError) do
90
+ session.expect(timeout: 0) { on("ready") { raise "handler failed" } }
91
+ end
92
+ assert_equal "ready", session.match
93
+ end
94
+
95
+ def test_continuation_through_buffered_states
96
+ session, = pipe_session
97
+ session.buffer = "A B C D End"
98
+ states = []
99
+ number = session.expect(timeout: 1) do
100
+ on(/[ABCD]/) do |connection|
101
+ states << connection.match
102
+ connection.continue
103
+ end
104
+ on("End")
105
+ end
106
+ assert_equal 2, number
107
+ assert_equal %w[A B C D], states
108
+ end
109
+
110
+ def test_absolute_timeout_even_with_continuous_unmatched_output
111
+ session = child('loop { print "x" * 16384 }', raw_pty: true, buffer_limit: 1024)
112
+ start = Expect.monotonic
113
+ result = bounded(2) { session.expect_result("missing", timeout: 0.05) }
114
+ assert result.timeout?
115
+ assert_operator Expect.monotonic - start, :<, 0.4
116
+ end
117
+
118
+ def test_repeated_select_interruptions_do_not_extend_the_deadline
119
+ session, = pipe_session
120
+ interrupted = lambda do |*|
121
+ sleep 0.01
122
+ raise Errno::EINTR
123
+ end
124
+ result = bounded(1) do
125
+ IO.stub(:select, interrupted) { session.expect_result("missing", timeout: 0.02) }
126
+ end
127
+ assert result.timeout?
128
+ end
129
+
130
+ def test_interrupted_read_retries_without_losing_input
131
+ session, writer = pipe_session
132
+ writer.write("ready")
133
+ original = session.to_io.method(:read_nonblock)
134
+ interrupted = true
135
+ read = lambda do |*args, **options|
136
+ if interrupted
137
+ interrupted = false
138
+ raise Errno::EINTR
139
+ end
140
+ original.call(*args, **options)
141
+ end
142
+ session.to_io.stub(:read_nonblock, read) do
143
+ assert_equal 1, session.expect("ready", timeout: 1)
144
+ end
145
+ assert_equal "ready", session.match
146
+ end
147
+
148
+ private
149
+
150
+ # 按虚拟时间向真实管道写入数据;select 仍按调用方给定的期限决定就绪或超时。
151
+ # 这样可精确检查期限是否重置,不依赖 CI 线程能否在几十毫秒内获得调度。
152
+ def with_timed_input(writer, events, &block)
153
+ now = 0.0
154
+ pending = events.dup
155
+ select = lambda do |readers, _writers, _errors, timeout|
156
+ deadline = now + timeout
157
+ if pending.any? && pending.first[0] <= deadline
158
+ now, data = pending.shift
159
+ writer.write(data)
160
+ [readers, [], []]
161
+ else
162
+ now = deadline
163
+ nil
164
+ end
165
+ end
166
+ Expect.stub(:monotonic, -> { now }) do
167
+ IO.stub(:select, select, &block)
168
+ end
169
+ end
170
+ end