|
| 1 | +using REPL |
| 2 | + |
| 3 | +include("./FakeSessions.jl") |
| 4 | +using .FakeSessions |
| 5 | + |
| 6 | +# Because evaluation is tied to the REPL, we need a REPL to do the evaluation. |
| 7 | +# I'm relying heavily on Base.REPL tests. (https://github.com/JuliaLang/julia/blob/master/stdlib/REPL/test/repl.jl) |
| 8 | + |
| 9 | +# Fragile, at the time of writing, the julia prompt is printed as : |
| 10 | +# 1. clear until begining of line, print `julia> `, carriage return, then move |
| 11 | +# 7 characters to the right. |
| 12 | +JULIA_PROMPT_OVERRIDE = "\r\e[0Kjulia> \r\e[7C" |
| 13 | + |
| 14 | +const BASE_TEST_PATH = joinpath(Sys.BINDIR, "..", "share", "julia", "test") |
| 15 | +isdefined(Main, :FakePTYs) || @eval Main include(joinpath($(BASE_TEST_PATH), "testhelpers", "FakePTYs.jl")) |
| 16 | +import .Main.FakePTYs: with_fake_pty |
| 17 | + |
| 18 | +include("FakeTerminals.jl") |
| 19 | +import .FakeTerminals.FakeTerminal |
| 20 | + |
| 21 | +function kill_timer(delay) |
| 22 | + # Give ourselves a generous timer here, just to prevent |
| 23 | + # this causing e.g. a CI hang when there's something unexpected in the output. |
| 24 | + # This is really messy and leaves the process in an undefined state. |
| 25 | + # the proper and correct way to do this in real code would be to destroy the |
| 26 | + # IO handles: `close(stdout_read); close(stdin_write)` |
| 27 | + test_task = current_task() |
| 28 | + function kill_test(t) |
| 29 | + # **DON'T COPY ME.** |
| 30 | + # The correct way to handle timeouts is to close the handle: |
| 31 | + # e.g. `close(stdout_read); close(stdin_write)` |
| 32 | + test_task.queue === nothing || Base.list_deletefirst!(test_task.queue, test_task) |
| 33 | + schedule(test_task, "hard kill repl test"; error = true) |
| 34 | + print(stderr, "WARNING: attempting hard kill of repl test after exceeding timeout\n") |
| 35 | + end |
| 36 | + return Timer(kill_test, delay) |
| 37 | +end |
| 38 | + |
| 39 | +function fake_repl(@nospecialize(f); options::REPL.Options = REPL.Options(confirm_exit = false)) |
| 40 | + # Use pipes so we can easily do blocking reads |
| 41 | + # In the future if we want we can add a test that the right object |
| 42 | + # gets displayed by intercepting the display |
| 43 | + input = Pipe() |
| 44 | + output = Pipe() |
| 45 | + err = Pipe() |
| 46 | + Base.link_pipe!(input, reader_supports_async = true, writer_supports_async = true) |
| 47 | + Base.link_pipe!(output, reader_supports_async = true, writer_supports_async = true) |
| 48 | + Base.link_pipe!(err, reader_supports_async = true, writer_supports_async = true) |
| 49 | + |
| 50 | + repl = REPL.LineEditREPL(FakeTerminal(input.out, output.in, err.in, options.hascolor), options.hascolor) |
| 51 | + repl.options = options |
| 52 | + |
| 53 | + hard_kill = kill_timer(900) # Your debugging session starts now. You have 15 minutes. Go. |
| 54 | + f(input.in, output.out, err.out, repl) |
| 55 | + t = @async begin |
| 56 | + close(input.in) |
| 57 | + close(output.in) |
| 58 | + close(err.in) |
| 59 | + end |
| 60 | + @test read(err.out, String) == "" |
| 61 | + #display(read(output.out, String)) |
| 62 | + Base.wait(t) |
| 63 | + close(hard_kill) |
| 64 | + nothing |
| 65 | +end |
| 66 | + |
| 67 | +# Writing ^C to the repl will cause sigint, so let's not die on that |
| 68 | +Base.exit_on_sigint(false) |
| 69 | + |
| 70 | +function consume_responses(responsechannel, result, mime = MIME("text/plain"); file, line, msgid) |
| 71 | + response = take!(responsechannel) |
| 72 | + @test response isa REPLSmuggler.Protocols.ResultResponse |
| 73 | + @test response.msgid == msgid |
| 74 | + @test response.line == line |
| 75 | + @test response.mime == mime |
| 76 | + @test response.result == string(result) |
| 77 | +end |
| 78 | +function consume_responses(responsechannel, results::Vector; file, line, msgid) |
| 79 | + for (producerline, result, mime) in results |
| 80 | + consume_responses(responsechannel, result, mime; file, msgid, line = producerline) |
| 81 | + end |
| 82 | +end |
| 83 | +function consume_responses(responsechannel, results::Nothing; file, line, msgid) |
| 84 | +end |
| 85 | +function test_ans_value(result) |
| 86 | + @test getglobal(Base.MainInclude, :ans) == result |
| 87 | +end |
| 88 | +function test_ans_value(results::Vector) |
| 89 | + result = last(results)[2] |
| 90 | + test_ans_value(result) |
| 91 | +end |
| 92 | +reg_cmd = r"(\r\e\[0Kjulia>(.|\n)+)?\r\e\[0Kjulia> (\r\e\[7C)+(?<cmd>(.|\n)+)\r\e\[[0-9]+C\n" |
| 93 | +function test_printed_result(stdout_read, result; test_print_results, collect_print_results) |
| 94 | + # We want to check that the command has been printed, and then the result. |
| 95 | + # Because of the inner workings of the REPL and of REPLSmuggler, this is |
| 96 | + # actually printed twice: first when we "insert" the command in the prompt, |
| 97 | + # then when we print it (after the `julia>` prompt has been printed. The |
| 98 | + # first print is invisible, and so it does not really make sense to test on |
| 99 | + # it. We are interested in the second one. |
| 100 | + r = readuntil(stdout_read, "C\n", keep = true) |
| 101 | + match_cmd_printed = match(reg_cmd, r) |
| 102 | + @test !isnothing(match_cmd_printed) |
| 103 | + if !isnothing(match_cmd_printed) |
| 104 | + printed_command = match_cmd_printed[:cmd] |
| 105 | + else |
| 106 | + printed_command = "" |
| 107 | + @info "Failed to match command." r |
| 108 | + end |
| 109 | + if collect_print_results |
| 110 | + # Then the result, which is followed by two new lines, so that's easy. |
| 111 | + printed_result = rstrip(String(readuntil(stdout_read, "\n\n"))) |
| 112 | + if test_print_results |
| 113 | + @test printed_result == string(result) |
| 114 | + end |
| 115 | + else |
| 116 | + printed_result = "" |
| 117 | + end |
| 118 | + # Consume the julia prompt print |
| 119 | + r = readuntil(stdout_read, JULIA_PROMPT_OVERRIDE) |
| 120 | + printed_command, printed_result |
| 121 | +end |
| 122 | +function test_printed_result(stdout_read, results::Vector; test_print_results, collect_print_results) |
| 123 | + printed = [] |
| 124 | + for (_, result, _) in results |
| 125 | + printed_command, printed_result = test_printed_result(stdout_read, result; test_print_results, collect_print_results) |
| 126 | + push!(printed, (printed_command, printed_result)) |
| 127 | + end |
| 128 | + printed |
| 129 | +end |
| 130 | + |
| 131 | +function eval_test_cmd(stdout_read, repl, session, cmd, result; file = "test.jl", line = 1, msgid = 0x01, test_print_results = true, test_ans = true, collect_print_results = true) |
| 132 | + # First consume everything that might be left from previous code. |
| 133 | + # readavailable(stdout_read) |
| 134 | + # Evaluate the command |
| 135 | + REPLSmuggler.Server.evaluate_entry(session, msgid, file, line, cmd, repl) |
| 136 | + # Now we check what happened. First, take the response. |
| 137 | + consume_responses(session.responsechannel, result; file, line, msgid) |
| 138 | + # Test that `ans` has been set correctly |
| 139 | + if test_ans |
| 140 | + test_ans_value(result) |
| 141 | + end |
| 142 | + # Test what's been printed |
| 143 | + res = test_printed_result(stdout_read, result; test_print_results, collect_print_results) |
| 144 | + flushed = String(readavailable(stdout_read)) |
| 145 | + res |
| 146 | +end |
| 147 | + |
| 148 | +@testset "Eval tests" begin |
| 149 | + fake_repl(options = REPL.Options(confirm_exit = false, hascolor = false)) do stdin_write, stdout_read, stderr_read, repl |
| 150 | + repl.specialdisplay = REPL.REPLDisplay(repl) |
| 151 | + repl.history_file = false |
| 152 | + |
| 153 | + # Don't expect more than 256 responses! |
| 154 | + responsechannel = Channel(256) |
| 155 | + session = FakeSessions.FakeSession(Main, deepcopy(REPLSmuggler.Server.DEFAULT_SESSION_DICT), responsechannel) |
| 156 | + |
| 157 | + repltask = @async begin |
| 158 | + REPL.run_repl(repl) |
| 159 | + end |
| 160 | + # Give some time to the REPL to be initialized |
| 161 | + readuntil(stdout_read, JULIA_PROMPT_OVERRIDE) |
| 162 | + @info "REPL seems ready." |
| 163 | + |
| 164 | + # Simplest eval test, if this breaks the package has no purpose anymore :D |
| 165 | + # This can also be used as a template to check the output of evaluations. |
| 166 | + printed = eval_test_cmd(stdout_read, repl, session, "1+1", 2) |
| 167 | + printed_cmd = printed[1] |
| 168 | + @test printed_cmd == "1+1" |
| 169 | + # Test multiple statements (eval by statements) |
| 170 | + printed = eval_test_cmd( |
| 171 | + stdout_read, repl, session, "1+1\n2+2\n3+3", [ |
| 172 | + (1, 2, MIME("text/plain")), |
| 173 | + (2, 4, MIME("text/plain")), |
| 174 | + (3, 6, MIME("text/plain")), |
| 175 | + ], |
| 176 | + ) |
| 177 | + for i in 1:3 |
| 178 | + printed_cmd = printed[i][1] |
| 179 | + @test printed_cmd == "$i+$i" |
| 180 | + end |
| 181 | + # Test multiple statements (eval by blocks) |
| 182 | + session.sessionparams["evalbyblocks"] = true |
| 183 | + printed = eval_test_cmd( |
| 184 | + stdout_read, repl, session, "1+1\n2+2\n3+3", [ |
| 185 | + (3, 6, MIME("text/plain")), |
| 186 | + ], test_print_results = false, |
| 187 | + ) |
| 188 | + printed_cmd = printed[1][1] |
| 189 | + @test printed_cmd == "1+1\n\r\e[7C2+2\n\r\e[7C3+3" |
| 190 | + printed_result = printed[1][2] |
| 191 | + @test printed_result == "6" |
| 192 | + session.sessionparams["evalbyblocks"] = false |
| 193 | + |
| 194 | + # Variables with UTF-8 names |
| 195 | + cmd = "ħ = 1/2π" |
| 196 | + printed = eval_test_cmd(stdout_read, repl, session, cmd, 0.15915494309189535) |
| 197 | + printed_cmd = printed[1] |
| 198 | + @test printed_cmd == cmd |
| 199 | + |
| 200 | + # Command that ends with a `;` |
| 201 | + cmd = "1+1+1;" |
| 202 | + printed = eval_test_cmd(stdout_read, repl, session, cmd, nothing, test_print_results = false, test_ans = false, collect_print_results = false) |
| 203 | + printed_cmd = printed[1] |
| 204 | + printed_result = printed[2] |
| 205 | + @test printed_cmd == cmd |
| 206 | + @test printed_result == "" |
| 207 | + |
| 208 | + # Weird indentation, and multiline. |
| 209 | + cmd = """ |
| 210 | + function bad_bad_bad() |
| 211 | + error("hey!") |
| 212 | + end |
| 213 | + """ |
| 214 | + printed = eval_test_cmd(stdout_read, repl, session, cmd, nothing, test_print_results = false, test_ans = false) |
| 215 | + printed_cmd = printed[1] |
| 216 | + printed_result = printed[2] |
| 217 | + for line in split(printed_cmd, "\n")[2:end] |
| 218 | + @test startswith(line, "\r\e[7C") |
| 219 | + end |
| 220 | + @test printed_result == "bad_bad_bad (generic function with 1 method)" |
| 221 | + |
| 222 | + # An error |
| 223 | + cmd = "error(\"bad bad\")" |
| 224 | + printed = eval_test_cmd(stdout_read, repl, session, cmd, nothing, test_print_results = false, test_ans = false) |
| 225 | + printed_cmd = printed[1] |
| 226 | + printed_result = printed[2] |
| 227 | + @test printed_cmd == cmd |
| 228 | + @test startswith(printed_result, "ERROR: bad bad\nStacktrace:\n [1] error(s::String)") |
| 229 | + |
| 230 | + # Delete line (^U) and close REPL (^D) |
| 231 | + write(stdin_write, "\x15\x04") |
| 232 | + Base.wait(repltask) |
| 233 | + |
| 234 | + nothing |
| 235 | + end |
| 236 | +end |
0 commit comments