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.
- checksums.yaml +7 -0
- data/.rubocop.yml +29 -0
- data/CHANGELOG.md +25 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +264 -0
- data/Rakefile +38 -0
- data/docs/COMPATIBILITY.md +65 -0
- data/docs/RELEASING.md +55 -0
- data/docs/VERIFICATION.md +146 -0
- data/examples/dialogue.rb +30 -0
- data/examples/kibitz/README.md +73 -0
- data/examples/kibitz/kibitz.rb +139 -0
- data/examples/kibitz/test_kibitz.rb +37 -0
- data/examples/ssh_auto.rb +94 -0
- data/examples/ssh_interact.rb +159 -0
- data/examples/ssh_login.rb +64 -0
- data/expect-pty.gemspec +25 -0
- data/lib/expect/configuration.rb +113 -0
- data/lib/expect/engine.rb +141 -0
- data/lib/expect/interconnect.rb +170 -0
- data/lib/expect/pattern.rb +62 -0
- data/lib/expect/pattern_list.rb +90 -0
- data/lib/expect/pty.rb +4 -0
- data/lib/expect/resources.rb +63 -0
- data/lib/expect/result.rb +14 -0
- data/lib/expect/version.rb +6 -0
- data/lib/expect.rb +591 -0
- data/script/ci +44 -0
- data/script/release.rb +267 -0
- data/test/compare_upstream.rb +157 -0
- data/test/configuration_test.rb +90 -0
- data/test/edge_case_test.rb +183 -0
- data/test/fixtures/ssh_scripts/01_identity.sh +4 -0
- data/test/fixtures/ssh_scripts/02_output.sh +5 -0
- data/test/fixtures/ssh_scripts/03_delayed.sh +6 -0
- data/test/fixtures/ssh_scripts/04_failure.sh +2 -0
- data/test/fixtures/ssh_scripts/05_recovery.sh +3 -0
- data/test/integration/README.md +90 -0
- data/test/integration/ssh_scripts.rb +94 -0
- data/test/interact_test.rb +151 -0
- data/test/interconnect_test.rb +226 -0
- data/test/io_test.rb +184 -0
- data/test/kibitz_test.rb +45 -0
- data/test/matching_test.rb +178 -0
- data/test/multi_session_test.rb +66 -0
- data/test/process_test.rb +244 -0
- data/test/release_test.rb +153 -0
- data/test/ruby_api_test.rb +515 -0
- data/test/script_logging_test.rb +124 -0
- data/test/support/interact_probe.rb +125 -0
- data/test/support/kibitz_probe.rb +177 -0
- data/test/support/script_probe.rb +157 -0
- data/test/test_helper.rb +58 -0
- data/test/timeout_test.rb +170 -0
- 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
|
data/test/test_helper.rb
ADDED
|
@@ -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
|