Skip to content

Commit 7f24d17

Browse files
authored
Merge pull request #44 from Klafyvel/functional-tests
test(eval): Added rough tests for evaluation.
2 parents c0908d4 + a9054cc commit 7f24d17

File tree

8 files changed

+276
-6
lines changed

8 files changed

+276
-6
lines changed

.github/workflows/CI.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ jobs:
3939
- uses: codecov/codecov-action@v4
4040
with:
4141
files: lcov.info
42+
token: ${{ secrets.CODECOV_TOKEN }}
4243
docs:
4344
name: Documentation
4445
runs-on: ubuntu-latest

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ JETExt = "JET"
2121
AbstractTrees = "0.4"
2222
Aqua = "0.8"
2323
BaseDirs = "1"
24+
JET = "0.9"
2425
JuliaSyntax = "0.4"
2526
MsgPack = "1"
2627
REPL = "1"
2728
Sockets = "1"
2829
Test = "1"
2930
julia = "1.8"
30-
JET = "0.9"
3131

3232
[extras]
3333
Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"

src/Server.jl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,14 @@ struct Session{T}
3838
"The specific [`Protocols.Protocol`](@ref) used in this session."
3939
protocol::Protocols.Protocol
4040
end
41+
const DEFAULT_SESSION_DICT = Dict(
42+
"evalbyblocks" => false, "showdir" => tempdir(), "enableimages" => true,
43+
"iocontext" => Dict{Symbol, Any}(),
44+
)
4145
Session(specific, serializer) = Session(
4246
Channel(1),
4347
Channel(1),
44-
Dict("evalbyblocks" => false, "showdir" => tempdir(), "enableimages" => true, "iocontext" => Dict{Symbol, Any}()),
48+
deepcopy(DEFAULT_SESSION_DICT),
4549
Main,
4650
specific,
4751
Protocols.Protocol(serializer, io(specific)),

src/eval.jl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,15 @@ function stripblock(s)
6262
end
6363

6464
"""
65-
evaluate_entry(session, msgid, file, line, value)
65+
evaluate_entry(session, msgid, file, line, value, repl=Base.active_repl))
6666
6767
Evaluate the code in `value` in the context of the given `session`, replacing the
6868
context of the code with `file` and `line`. If an error occurs, it will put a
6969
using Base: JuliaSyntax
7070
[`Protocols.Error`](@ref) to the outgoing channel of the session.
7171
"""
72-
function evaluate_entry(session, msgid, file, line, value)
72+
function evaluate_entry(session, msgid, file, line, value, repl = Base.active_repl)
7373
@debug "Evaluating entry" session file line value
74-
repl = Base.active_repl
7574
current_line = line
7675
julia_prompt = repl.interface.modes[1] # Fragile, but assumed throughout REPL.jl
7776
current_mode = repl.mistate.current_mode
@@ -149,6 +148,7 @@ function renumber_evaluated_expression!(expression, firstline, file)
149148
end
150149
end
151150
end
151+
152152
function evaluate_expression(expression, evalmodule)
153153
response = nothing
154154
error = nothing

test/FakeSessions.jl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module FakeSessions
2+
struct FakeSession
3+
evaluatein::Module
4+
sessionparams::Dict
5+
responsechannel::Channel
6+
end
7+
end

test/FakeTerminals.jl

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Taken from Base.REPL tests
2+
# This file is a part of Julia. License is MIT: https://julialang.org/license
3+
4+
module FakeTerminals
5+
6+
import REPL
7+
8+
mutable struct FakeTerminal <: REPL.Terminals.UnixTerminal
9+
in_stream::Base.IO
10+
out_stream::Base.IO
11+
err_stream::Base.IO
12+
hascolor::Bool
13+
raw::Bool
14+
FakeTerminal(stdin, stdout, stderr, hascolor = true) =
15+
new(stdin, stdout, stderr, hascolor, false)
16+
end
17+
18+
REPL.Terminals.hascolor(t::FakeTerminal) = t.hascolor
19+
REPL.Terminals.raw!(t::FakeTerminal, raw::Bool) = t.raw = raw
20+
REPL.Terminals.size(t::FakeTerminal) = (24, 80)
21+
22+
end

test/evaltests.jl

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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

test/runtests.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ using Aqua
88
end
99
include("protocol.jl")
1010
include("msgpackserializer.jl")
11-
# Write your tests here.
11+
include("evaltests.jl")
1212
end

0 commit comments

Comments
 (0)