diff --git a/test/runtests.jl b/test/runtests.jl index 32e19de..079b08a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,98 +1,24 @@ -import JSON3 -import JSONSchema -import NodeJS_18_jll -import quarto_jll - -using QuartoNotebookRunner -using Test - -# Julia 1.6 doesn't support testing error messages, yet -macro test_throws_message(message::String, exp) - quote - threw_exception = false - try - $(esc(exp)) - catch e - threw_exception = true - @test occursin($message, e.msg) # Currently only works for ErrorException - end - @test threw_exception - end -end +include("utilities/prelude.jl") -function with_extension(path, ext) - root, _ = splitext(path) - return "$root.$ext" -end - -# Update and precompile all project TOMLs when in CI. -if get(ENV, "CI", "false") == "true" - for dir in ["integrations", "mimetypes"] - for (root, dirs, files) in walkdir(joinpath(@__DIR__, "examples", dir)) - for each in files - if each == "Project.toml" - manifest = joinpath(root, "Manifest.toml") - if isfile(manifest) - rm(manifest; force = true) - end - run( - `$(Base.julia_cmd()) --project=$root -e 'push!(LOAD_PATH, "@stdlib"); import Pkg; Pkg.update()'`, - ) - end - end - end - end -end +include("utilities/project_precompile.jl") +include("utilities/cleanup.jl") @testset "QuartoNotebookRunner" begin + include("testsets/cell_options.jl") + include("testsets/const_redefinition.jl") include("testsets/error_configuration/01.jl") include("testsets/error_configuration/02.jl") + include("testsets/non_standard_mimetypes.jl") + include("testsets/package_integration_hooks.jl") + include("testsets/project_frontmatter.jl") + include("testsets/relative_paths.jl") + include("testsets/render_function.jl") + include("testsets/socket_server/socket_server.jl") + include("testsets/stdout_exeflags.jl") - @testset "socket server" begin - cd(@__DIR__) do - node = NodeJS_18_jll.node() - client = joinpath(@__DIR__, "client.js") - port = 4001 - server = QuartoNotebookRunner.serve(; port, showprogress = false) - sleep(1) - json(cmd) = JSON3.read(read(cmd, String), Any) - - d1 = json(`$node $client $port run $(joinpath("examples", "cell_types.qmd"))`) - @test length(d1["notebook"]["cells"]) == 6 - - d2 = json(`$node $client $port run $(joinpath("examples", "cell_types.qmd"))`) - @test d1 == d2 - - d3 = json(`$node $client $port close $(joinpath("examples", "cell_types.qmd"))`) - @test d3["status"] == true - - d4 = json(`$node $client $port run $(joinpath("examples", "cell_types.qmd"))`) - @test d1 == d4 - - d5 = json(`$node $client $port stop`) - @test d5["message"] == "Server stopped." - - wait(server) - end - end - - schema = JSONSchema.Schema( - open(JSON3.read, joinpath(@__DIR__, "schema/nbformat.v4.schema.json")), - ) examples = joinpath(@__DIR__, "examples") - function common_tests(json) - @test json["nbformat"] == 4 - @test json["nbformat_minor"] == 5 - @test json["metadata"]["language_info"]["name"] == "julia" - @test json["metadata"]["language_info"]["version"] == "$VERSION" - @test json["metadata"]["language_info"]["codemirror_mode"] == "julia" - @test json["metadata"]["kernel_info"]["name"] == "julia" - @test startswith(json["metadata"]["kernelspec"]["name"], "julia") - @test startswith(json["metadata"]["kernelspec"]["display_name"], "Julia") - @test json["metadata"]["kernelspec"]["language"] == "julia" - end tests = let tests = Dict() function file(fn, path) @@ -565,495 +491,13 @@ end tests end - server = QuartoNotebookRunner.Server() for (root, dirs, files) in walkdir(examples) for each in files _, ext = splitext(each) if ext in (".qmd", ".jl") && !contains(root, "TestPackage") each = joinpath(examples, root, each) - - buffer = IOBuffer() - QuartoNotebookRunner.run!( - server, - each; - output = buffer, - showprogress = false, - ) - seekstart(buffer) - json = JSON3.read(buffer, Any) - - @test JSONSchema.validate(schema, json) === nothing - - # File-specific tests. - @testset "$(relpath(each, pwd()))" begin - common_tests(json) - if haskey(tests, each) - tests[each](json) - end - end - - ipynb = joinpath(examples, with_extension(each, "ipynb")) - QuartoNotebookRunner.run!( - server, - each; - output = ipynb, - showprogress = false, - ) - - if !Sys.iswindows() - # No macOS ARM build, so just look for a local version that the dev - # should have installed. This avoids having to use rosetta2 to run - # the x86_64 version of Julia to get access to the x86_64 version of - # Quarto artifact. - quarto_bin = - quarto_jll.is_available() ? quarto_jll.quarto() : setenv(`quarto`) - # Just a smoke test to make sure it runs. Use docx since it doesn't - # output a bunch of folders (html), or require a tinytex install - # (pdf). All we are doing here at the moment is ensuring quarto doesn't - # break on our notebook outputs. - if success(`$quarto_bin --version`) - @test success(`$quarto_bin render $ipynb --to docx`) - else - @error "quarto not found, skipping smoke test." - end - end - - QuartoNotebookRunner.close!(server, each) - end - end - end - close!(server) - - # Switching exeflags within a running notebook causes it to restart so that - # the new exeflags can be applied. - mktempdir() do dir - @testset "non-standard mime types" begin - server = QuartoNotebookRunner.Server() - expected = Dict("typst" => "```{=typst}", "docx" => "```{=openxml}") - - env = joinpath(dir, "integrations", "CairoMakie") - mkpath(env) - cp(joinpath(@__DIR__, "examples/integrations/CairoMakie"), env; force = true) - - for (format, ext) in ("typst" => "pdf", "docx" => "docx") - cd(dir) do - source = joinpath(@__DIR__, "examples/$(format)_mimetypes.qmd") - content = read(source, String) - write("$(format)_mimetypes.qmd", content) - ipynb = "$(format)_mimetypes.ipynb" - QuartoNotebookRunner.run!( - server, - "$(format)_mimetypes.qmd"; - output = ipynb, - showprogress = false, - options = Dict{String,Any}( - "format" => Dict("pandoc" => Dict("to" => format)), - ), - ) - - json = JSON3.read(ipynb) - markdown = json.cells[end].outputs[1].data["text/markdown"] - @test contains(markdown, expected[format]) - - if !Sys.iswindows() - # No macOS ARM build, so just look for a local version that the dev - # should have installed. This avoids having to use rosetta2 to run - # the x86_64 version of Julia to get access to the x86_64 version of - # Quarto artifact. - quarto_bin = - quarto_jll.is_available() ? quarto_jll.quarto() : - setenv(`quarto`) - # Just a smoke test to make sure it runs. Use docx since it doesn't - # output a bunch of folders (html), or require a tinytex install - # (pdf). All we are doing here at the moment is ensuring quarto doesn't - # break on our notebook outputs. - if success(`$quarto_bin --version`) - @test success(`$quarto_bin render $ipynb --to $format`) - else - @error "quarto not found, skipping smoke test." - end - @test isfile("$(format)_mimetypes.$ext") - end - end - end - close!(server) - end - @testset "exeflags notebook restart" begin - content = read(joinpath(@__DIR__, "examples/stdout_exeflags.qmd"), String) - cd(dir) do - server = QuartoNotebookRunner.Server() - write("notebook.qmd", content) - json = - QuartoNotebookRunner.run!(server, "notebook.qmd"; showprogress = false) - - cells = json.cells - cell = cells[8] - @test contains(cell.outputs[1].text, "┌ Info: info text") - - content = replace(content, "--color=no" => "--color=yes") - write("notebook.qmd", content) - json = - QuartoNotebookRunner.run!(server, "notebook.qmd"; showprogress = false) - - cells = json.cells - cell = cells[8] - @test contains(cell.outputs[1].text, "\e[1mInfo: \e[22m\e[39minfo text") - - close!(server) - end - end - - @testset "Const redefinition" begin - # Ensure that when we update a running notebook and try to re-evaluate - # cells that contain const definitions that have changed, e.g. structs - # or consts that we still get the correct output and not redefinition - # errors. - notebook = joinpath(dir, "notebook.qmd") - write( - notebook, - """ - --- - title: "Const redefinition" - --- - - ```{julia} - struct T - x::Int - end - ``` - - ```{julia} - const t = T(1) - ``` - """, - ) - - server = QuartoNotebookRunner.Server() - - buffer = IOBuffer() - QuartoNotebookRunner.run!( - server, - notebook; - output = buffer, - showprogress = false, - ) - - seekstart(buffer) - json = JSON3.read(buffer, Any) - - @test JSONSchema.validate(schema, json) === nothing - - cells = json["cells"] - - cell = cells[2] - @test only(cell["outputs"]) == Dict( - "output_type" => "execute_result", - "execution_count" => 1, - "data" => Dict(), - "metadata" => Dict(), - ) - - cell = cells[4] - @test only(cell["outputs"]) == Dict( - "output_type" => "execute_result", - "execution_count" => 1, - "data" => Dict("text/plain" => "T(1)"), - "metadata" => Dict(), - ) - - write( - notebook, - """ - --- - title: "Const redefinition" - --- - - ```{julia} - struct T - x::String - end - ``` - - ```{julia} - const t = T("") - ``` - """, - ) - - buffer = IOBuffer() - QuartoNotebookRunner.run!( - server, - notebook; - output = buffer, - showprogress = false, - ) - - seekstart(buffer) - json = JSON3.read(buffer, Any) - - @test JSONSchema.validate(schema, json) === nothing - - cells = json["cells"] - - cell = cells[2] - @test only(cell["outputs"]) == Dict( - "output_type" => "execute_result", - "execution_count" => 1, - "data" => Dict(), - "metadata" => Dict(), - ) - - cell = cells[4] - @test only(cell["outputs"]) == Dict( - "output_type" => "execute_result", - "execution_count" => 1, - "data" => Dict("text/plain" => "T(\"\")"), - "metadata" => Dict(), - ) - - close!(server) - end - - @testset "render" begin - buffer = IOBuffer() - QuartoNotebookRunner.render( - joinpath(@__DIR__, "examples/cell_types.qmd"); - output = buffer, - showprogress = false, - ) - seekstart(buffer) - json = JSON3.read(buffer, Any) - - @test JSONSchema.validate(schema, json) === nothing - end - - @testset "Invalid cell option" begin - text = """ - ```{julia} - #| valid: true - ``` - """ - @test QuartoNotebookRunner.extract_cell_options( - text; - file = "file.qmd", - line = 1, - ) == Dict("valid" => true) - - text = """ - ```{julia} - #| valid: true - #| invalid - ``` - """ - @test_throws_message "file.qmd:1" QuartoNotebookRunner.extract_cell_options( - text; - file = "file.qmd", - line = 1, - ) - - text = """ - ```{julia} - a = 1 - ``` - """ - @test QuartoNotebookRunner.extract_cell_options( - text; - file = "file.qmd", - line = 1, - ) == Dict() - - notebook = joinpath(dir, "notebook.qmd") - write( - notebook, - """ - --- - title: "Invalid cell option" - --- - - ```{julia} - #| this is not yaml - ``` - """, - ) - - server = QuartoNotebookRunner.Server() - - buffer = IOBuffer() - @test_throws_message "Error parsing cell attributes" QuartoNotebookRunner.run!( - server, - notebook; - output = buffer, - showprogress = false, - ) - - close!(server) - end - - @testset "Invalid eval option" begin - notebook = joinpath(dir, "notebook.qmd") - write( - notebook, - """ - --- - title: "Invalid eval option" - --- - - ```{julia} - #| eval: 1 - ``` - """, - ) - - server = QuartoNotebookRunner.Server() - - buffer = IOBuffer() - @test_throws_message "Cannot handle an `eval` code cell option with value 1, only true or false." QuartoNotebookRunner.run!( - server, - notebook; - output = buffer, - showprogress = false, - ) - - close!(server) - end - - @testset "relative paths in `output`" begin - content = read(joinpath(@__DIR__, "examples/stdout.qmd"), String) - cd(dir) do - server = QuartoNotebookRunner.Server() - write("notebook.qmd", content) - ipynb = "notebook.ipynb" - QuartoNotebookRunner.run!( - server, - "notebook.qmd"; - output = ipynb, - showprogress = false, - ) - - json = JSON3.read(ipynb) - - cells = json.cells - cell = cells[8] - @test contains(cell.outputs[1].text, "info text") - - close!(server) - end - end - - @testset "project frontmatter" begin - content = read(joinpath(@__DIR__, "examples/mimetypes.qmd"), String) - cd(dir) do - cp(joinpath(@__DIR__, "examples/mimetypes"), joinpath(dir, "mimetypes")) - server = QuartoNotebookRunner.Server() - write("mimetypes.qmd", content) - options = Dict{String,Any}( - "format" => Dict{String,Any}( - "execute" => Dict{String,Any}("fig-dpi" => 100), - ), - ) - json = QuartoNotebookRunner.run!( - server, - "mimetypes.qmd"; - options, - showprogress = false, - ) - cell = json.cells[6] - metadata = cell.outputs[1].metadata["image/png"] - @test metadata.width == 625 - @test metadata.height == 469 - - options_file = "temp_options.json" - open(options_file, "w") do io - JSON3.pretty(io, options) - end - - json = QuartoNotebookRunner.run!( - server, - "mimetypes.qmd"; - options = options_file, - showprogress = false, - ) - cell = json.cells[6] - metadata = cell.outputs[1].metadata["image/png"] - @test metadata.width == 625 - @test metadata.height == 469 - - close!(server) - end - end - - @testset "package integration hooks" begin - env_dir = joinpath(@__DIR__, "examples/integrations/CairoMakie") - content = - read(joinpath(@__DIR__, "examples/integrations/CairoMakie.qmd"), String) - cd(dir) do - server = QuartoNotebookRunner.Server() - - cp(env_dir, joinpath(dir, "CairoMakie")) - - function png_metadata(preamble = nothing) - # handle Windows - content_unified = replace(content, "\r\n" => "\n") - _content = - preamble === nothing ? content_unified : - replace(content_unified, """ - fig-width: 4 - fig-height: 3 - fig-dpi: 150""" => preamble) - - write("CairoMakie.qmd", _content) - json = QuartoNotebookRunner.run!( - server, - "CairoMakie.qmd"; - showprogress = false, - ) - return json.cells[end].outputs[1].metadata["image/png"] - end - - metadata = png_metadata() - @test metadata.width == 4 * 150 - @test metadata.height == 3 * 150 - - metadata = png_metadata(""" - fig-width: 8 - fig-height: 6 - fig-dpi: 300""") - @test metadata.width == 8 * 300 - @test metadata.height == 6 * 300 - - metadata = png_metadata(""" - fig-width: 5 - fig-dpi: 100""") - @test metadata.width == 5 * 100 - @test metadata.height == round(5 / 4 * 3 * 100) - - metadata = png_metadata(""" - fig-height: 5 - fig-dpi: 100""") - @test metadata.height == 5 * 100 - @test metadata.width == round(5 / 3 * 4 * 100) - - # we don't want to rely on hardcoding Makie's own default size for our tests - # but for the dpi-only test we can check that doubling the - # dpi doubles image dimensions, whatever they are - metadata_100dpi = png_metadata(""" - fig-dpi: 96""") - metadata_200dpi = png_metadata(""" - fig-dpi: 192""") - @test 2 * metadata_100dpi.height == metadata_200dpi.height - @test 2 * metadata_100dpi.width == metadata_200dpi.width - - # same logic for width and height only - metadata_single = png_metadata(""" - fig-width: 3 - fig-height: 2""") - metadata_double = png_metadata(""" - fig-width: 6 - fig-height: 4""") - @test 2 * metadata_single.height == metadata_double.height - @test 2 * metadata_single.width == metadata_double.width - - close!(server) + fn = get(tests, each, json -> nothing) + test_example(fn, each) end end end diff --git a/test/testsets/cell_options.jl b/test/testsets/cell_options.jl new file mode 100644 index 0000000..ea010bd --- /dev/null +++ b/test/testsets/cell_options.jl @@ -0,0 +1,108 @@ +include("../utilities/prelude.jl") + +# Julia 1.6 doesn't support testing error messages, yet +macro test_throws_message(message::String, exp) + quote + threw_exception = false + try + $(esc(exp)) + catch e + threw_exception = true + @test occursin($message, e.msg) # Currently only works for ErrorException + end + @test threw_exception + end +end + +@testset "cell options" begin + mktempdir() do dir + @testset "Invalid cell option" begin + text = """ + ```{julia} + #| valid: true + ``` + """ + @test QuartoNotebookRunner.extract_cell_options( + text; + file = "file.qmd", + line = 1, + ) == Dict("valid" => true) + + text = """ + ```{julia} + #| valid: true + #| invalid + ``` + """ + @test_throws_message "file.qmd:1" QuartoNotebookRunner.extract_cell_options( + text; + file = "file.qmd", + line = 1, + ) + + text = """ + ```{julia} + a = 1 + ``` + """ + @test QuartoNotebookRunner.extract_cell_options( + text; + file = "file.qmd", + line = 1, + ) == Dict() + + notebook = joinpath(dir, "notebook.qmd") + write( + notebook, + """ + --- + title: "Invalid cell option" + --- + + ```{julia} + #| this is not yaml + ``` + """, + ) + + server = QuartoNotebookRunner.Server() + + buffer = IOBuffer() + @test_throws_message "Error parsing cell attributes" QuartoNotebookRunner.run!( + server, + notebook; + output = buffer, + showprogress = false, + ) + + close!(server) + end + @testset "Invalid eval option" begin + notebook = joinpath(dir, "notebook.qmd") + write( + notebook, + """ + --- + title: "Invalid eval option" + --- + + ```{julia} + #| eval: 1 + ``` + """, + ) + + server = QuartoNotebookRunner.Server() + + buffer = IOBuffer() + @test_throws_message "Cannot handle an `eval` code cell option with value 1, only true or false." QuartoNotebookRunner.run!( + server, + notebook; + output = buffer, + showprogress = false, + ) + + close!(server) + end + end +end diff --git a/test/testsets/const_redefinition.jl b/test/testsets/const_redefinition.jl new file mode 100644 index 0000000..896f778 --- /dev/null +++ b/test/testsets/const_redefinition.jl @@ -0,0 +1,104 @@ +include("../utilities/prelude.jl") + +@testset "Const redefinition" begin + mktempdir() do dir + # Ensure that when we update a running notebook and try to re-evaluate + # cells that contain const definitions that have changed, e.g. structs + # or consts that we still get the correct output and not redefinition + # errors. + notebook = joinpath(dir, "notebook.qmd") + write( + notebook, + """ + --- + title: "Const redefinition" + --- + + ```{julia} + struct T + x::Int + end + ``` + + ```{julia} + const t = T(1) + ``` + """, + ) + + server = QuartoNotebookRunner.Server() + + buffer = IOBuffer() + QuartoNotebookRunner.run!(server, notebook; output = buffer, showprogress = false) + + seekstart(buffer) + json = JSON3.read(buffer, Any) + + @test JSONSchema.validate(SCHEMA, json) === nothing + + cells = json["cells"] + + cell = cells[2] + @test only(cell["outputs"]) == Dict( + "output_type" => "execute_result", + "execution_count" => 1, + "data" => Dict(), + "metadata" => Dict(), + ) + + cell = cells[4] + @test only(cell["outputs"]) == Dict( + "output_type" => "execute_result", + "execution_count" => 1, + "data" => Dict("text/plain" => "T(1)"), + "metadata" => Dict(), + ) + + write( + notebook, + """ + --- + title: "Const redefinition" + --- + + ```{julia} + struct T + x::String + end + ``` + + ```{julia} + const t = T("") + ``` + """, + ) + + buffer = IOBuffer() + QuartoNotebookRunner.run!(server, notebook; output = buffer, showprogress = false) + + seekstart(buffer) + json = JSON3.read(buffer, Any) + + @test JSONSchema.validate(SCHEMA, json) === nothing + + cells = json["cells"] + + cell = cells[2] + @test only(cell["outputs"]) == Dict( + "output_type" => "execute_result", + "execution_count" => 1, + "data" => Dict(), + "metadata" => Dict(), + ) + + cell = cells[4] + @test only(cell["outputs"]) == Dict( + "output_type" => "execute_result", + "execution_count" => 1, + "data" => Dict("text/plain" => "T(\"\")"), + "metadata" => Dict(), + ) + + close!(server) + end +end diff --git a/test/testsets/error_configuration/01.jl b/test/testsets/error_configuration/01.jl index f90701e..617cd66 100644 --- a/test/testsets/error_configuration/01.jl +++ b/test/testsets/error_configuration/01.jl @@ -1,4 +1,4 @@ -using Logging, Test, QuartoNotebookRunner +include("../../utilities/prelude.jl") @testset "error_configuration/01" begin server = Server() diff --git a/test/testsets/error_configuration/02.jl b/test/testsets/error_configuration/02.jl index cec426b..a0be211 100644 --- a/test/testsets/error_configuration/02.jl +++ b/test/testsets/error_configuration/02.jl @@ -1,4 +1,4 @@ -using Logging, Test, QuartoNotebookRunner +include("../../utilities/prelude.jl") @testset "error_configuration/02" begin server = Server() diff --git a/test/testsets/non_standard_mimetypes.jl b/test/testsets/non_standard_mimetypes.jl new file mode 100644 index 0000000..5406d55 --- /dev/null +++ b/test/testsets/non_standard_mimetypes.jl @@ -0,0 +1,54 @@ +include("../utilities/prelude.jl") + +@testset "non-standard mime types" begin + mktempdir() do dir + server = QuartoNotebookRunner.Server() + expected = Dict("typst" => "```{=typst}", "docx" => "```{=openxml}") + + env = joinpath(dir, "integrations", "CairoMakie") + mkpath(env) + cp(joinpath(@__DIR__, "../examples/integrations/CairoMakie"), env; force = true) + + for (format, ext) in ("typst" => "pdf", "docx" => "docx") + cd(dir) do + source = joinpath(@__DIR__, "../examples/$(format)_mimetypes.qmd") + content = read(source, String) + write("$(format)_mimetypes.qmd", content) + ipynb = "$(format)_mimetypes.ipynb" + QuartoNotebookRunner.run!( + server, + "$(format)_mimetypes.qmd"; + output = ipynb, + showprogress = false, + options = Dict{String,Any}( + "format" => Dict("pandoc" => Dict("to" => format)), + ), + ) + + json = JSON3.read(ipynb) + markdown = json.cells[end].outputs[1].data["text/markdown"] + @test contains(markdown, expected[format]) + + if !Sys.iswindows() + # No macOS ARM build, so just look for a local version that the dev + # should have installed. This avoids having to use rosetta2 to run + # the x86_64 version of Julia to get access to the x86_64 version of + # Quarto artifact. + quarto_bin = + quarto_jll.is_available() ? quarto_jll.quarto() : setenv(`quarto`) + # Just a smoke test to make sure it runs. Use docx since it doesn't + # output a bunch of folders (html), or require a tinytex install + # (pdf). All we are doing here at the moment is ensuring quarto doesn't + # break on our notebook outputs. + if success(`$quarto_bin --version`) + @test success(`$quarto_bin render $ipynb --to $format`) + else + @error "quarto not found, skipping smoke test." + end + @test isfile("$(format)_mimetypes.$ext") + end + end + end + close!(server) + end +end diff --git a/test/testsets/package_integration_hooks.jl b/test/testsets/package_integration_hooks.jl new file mode 100644 index 0000000..0a51d51 --- /dev/null +++ b/test/testsets/package_integration_hooks.jl @@ -0,0 +1,78 @@ +include("../utilities/prelude.jl") + +@testset "package integration hooks" begin + mktempdir() do dir + env_dir = joinpath(@__DIR__, "../examples/integrations/CairoMakie") + content = + read(joinpath(@__DIR__, "../examples/integrations/CairoMakie.qmd"), String) + cd(dir) do + server = QuartoNotebookRunner.Server() + + cp(env_dir, joinpath(dir, "CairoMakie")) + + function png_metadata(preamble = nothing) + # handle Windows + content_unified = replace(content, "\r\n" => "\n") + _content = + preamble === nothing ? content_unified : + replace(content_unified, """ + fig-width: 4 + fig-height: 3 + fig-dpi: 150""" => preamble) + + write("CairoMakie.qmd", _content) + json = QuartoNotebookRunner.run!( + server, + "CairoMakie.qmd"; + showprogress = false, + ) + return json.cells[end].outputs[1].metadata["image/png"] + end + + metadata = png_metadata() + @test metadata.width == 4 * 150 + @test metadata.height == 3 * 150 + + metadata = png_metadata(""" + fig-width: 8 + fig-height: 6 + fig-dpi: 300""") + @test metadata.width == 8 * 300 + @test metadata.height == 6 * 300 + + metadata = png_metadata(""" + fig-width: 5 + fig-dpi: 100""") + @test metadata.width == 5 * 100 + @test metadata.height == round(5 / 4 * 3 * 100) + + metadata = png_metadata(""" + fig-height: 5 + fig-dpi: 100""") + @test metadata.height == 5 * 100 + @test metadata.width == round(5 / 3 * 4 * 100) + + # we don't want to rely on hardcoding Makie's own default size for our tests + # but for the dpi-only test we can check that doubling the + # dpi doubles image dimensions, whatever they are + metadata_100dpi = png_metadata(""" + fig-dpi: 96""") + metadata_200dpi = png_metadata(""" + fig-dpi: 192""") + @test 2 * metadata_100dpi.height == metadata_200dpi.height + @test 2 * metadata_100dpi.width == metadata_200dpi.width + + # same logic for width and height only + metadata_single = png_metadata(""" + fig-width: 3 + fig-height: 2""") + metadata_double = png_metadata(""" + fig-width: 6 + fig-height: 4""") + @test 2 * metadata_single.height == metadata_double.height + @test 2 * metadata_single.width == metadata_double.width + + close!(server) + end + end +end diff --git a/test/testsets/project_frontmatter.jl b/test/testsets/project_frontmatter.jl new file mode 100644 index 0000000..d8aebf8 --- /dev/null +++ b/test/testsets/project_frontmatter.jl @@ -0,0 +1,44 @@ +include("../utilities/prelude.jl") + +@testset "project frontmatter" begin + mktempdir() do dir + content = read(joinpath(@__DIR__, "../examples/mimetypes.qmd"), String) + cd(dir) do + cp(joinpath(@__DIR__, "../examples/mimetypes"), joinpath(dir, "mimetypes")) + server = QuartoNotebookRunner.Server() + write("mimetypes.qmd", content) + options = Dict{String,Any}( + "format" => + Dict{String,Any}("execute" => Dict{String,Any}("fig-dpi" => 100)), + ) + json = QuartoNotebookRunner.run!( + server, + "mimetypes.qmd"; + options, + showprogress = false, + ) + cell = json.cells[6] + metadata = cell.outputs[1].metadata["image/png"] + @test metadata.width == 625 + @test metadata.height == 469 + + options_file = "temp_options.json" + open(options_file, "w") do io + JSON3.pretty(io, options) + end + + json = QuartoNotebookRunner.run!( + server, + "mimetypes.qmd"; + options = options_file, + showprogress = false, + ) + cell = json.cells[6] + metadata = cell.outputs[1].metadata["image/png"] + @test metadata.width == 625 + @test metadata.height == 469 + + close!(server) + end + end +end diff --git a/test/testsets/relative_paths.jl b/test/testsets/relative_paths.jl new file mode 100644 index 0000000..b7d90b5 --- /dev/null +++ b/test/testsets/relative_paths.jl @@ -0,0 +1,26 @@ +include("../utilities/prelude.jl") + +@testset "relative paths in `output`" begin + mktempdir() do dir + content = read(joinpath(@__DIR__, "../examples/stdout.qmd"), String) + cd(dir) do + server = QuartoNotebookRunner.Server() + write("notebook.qmd", content) + ipynb = "notebook.ipynb" + QuartoNotebookRunner.run!( + server, + "notebook.qmd"; + output = ipynb, + showprogress = false, + ) + + json = JSON3.read(ipynb) + + cells = json.cells + cell = cells[8] + @test contains(cell.outputs[1].text, "info text") + + close!(server) + end + end +end diff --git a/test/testsets/render_function.jl b/test/testsets/render_function.jl new file mode 100644 index 0000000..b7f1662 --- /dev/null +++ b/test/testsets/render_function.jl @@ -0,0 +1,14 @@ +include("../utilities/prelude.jl") + +@testset "render" begin + buffer = IOBuffer() + QuartoNotebookRunner.render( + joinpath(@__DIR__, "../examples/cell_types.qmd"); + output = buffer, + showprogress = false, + ) + seekstart(buffer) + json = JSON3.read(buffer, Any) + + @test JSONSchema.validate(SCHEMA, json) === nothing +end diff --git a/test/client.js b/test/testsets/socket_server/client.js similarity index 100% rename from test/client.js rename to test/testsets/socket_server/client.js diff --git a/test/testsets/socket_server/socket_server.jl b/test/testsets/socket_server/socket_server.jl new file mode 100644 index 0000000..d5ce5bb --- /dev/null +++ b/test/testsets/socket_server/socket_server.jl @@ -0,0 +1,31 @@ +include("../../utilities/prelude.jl") + +@testset "socket server" begin + cd(@__DIR__) do + node = NodeJS_18_jll.node() + client = joinpath(@__DIR__, "client.js") + port = 4001 + server = QuartoNotebookRunner.serve(; port, showprogress = false) + sleep(1) + json(cmd) = JSON3.read(read(cmd, String), Any) + + cell_types = "../../examples/cell_types.qmd" + + d1 = json(`$node $client $port run $(cell_types)`) + @test length(d1["notebook"]["cells"]) == 6 + + d2 = json(`$node $client $port run $(cell_types)`) + @test d1 == d2 + + d3 = json(`$node $client $port close $(cell_types)`) + @test d3["status"] == true + + d4 = json(`$node $client $port run $(cell_types)`) + @test d1 == d4 + + d5 = json(`$node $client $port stop`) + @test d5["message"] == "Server stopped." + + wait(server) + end +end diff --git a/test/testsets/stdout_exeflags.jl b/test/testsets/stdout_exeflags.jl new file mode 100644 index 0000000..31b77e5 --- /dev/null +++ b/test/testsets/stdout_exeflags.jl @@ -0,0 +1,26 @@ +include("../utilities/prelude.jl") + +@testset "exeflags notebook restart" begin + mktempdir() do dir + content = read(joinpath(@__DIR__, "../examples/stdout_exeflags.qmd"), String) + cd(dir) do + server = QuartoNotebookRunner.Server() + write("notebook.qmd", content) + json = QuartoNotebookRunner.run!(server, "notebook.qmd"; showprogress = false) + + cells = json.cells + cell = cells[8] + @test contains(cell.outputs[1].text, "┌ Info: info text") + + content = replace(content, "--color=no" => "--color=yes") + write("notebook.qmd", content) + json = QuartoNotebookRunner.run!(server, "notebook.qmd"; showprogress = false) + + cells = json.cells + cell = cells[8] + @test contains(cell.outputs[1].text, "\e[1mInfo: \e[22m\e[39minfo text") + + close!(server) + end + end +end diff --git a/test/utilities/cleanup.jl b/test/utilities/cleanup.jl new file mode 100644 index 0000000..6d6c2e7 --- /dev/null +++ b/test/utilities/cleanup.jl @@ -0,0 +1,20 @@ +let removed = [] + for (root, dirs, files) in walkdir(joinpath(@__DIR__, ".."); topdown = false) + for file in files + _, ext = splitext(file) + if ext in (".html", ".pdf", ".tex", ".docx", ".typ", ".ipynb", ".png", ".css") + path = joinpath(root, file) + push!(removed, path) + rm(path; force = true) + end + end + for dir in dirs + path = joinpath(root, dir) + if isempty(readdir(path)) + push!(removed, path) + rm(path; force = true, recursive = true) + end + end + end + @info "removed files and directories" removed +end diff --git a/test/utilities/prelude.jl b/test/utilities/prelude.jl new file mode 100644 index 0000000..1cbf1dc --- /dev/null +++ b/test/utilities/prelude.jl @@ -0,0 +1,69 @@ +using Test +using Logging +using QuartoNotebookRunner + +import JSON3 +import JSONSchema +import NodeJS_18_jll +import quarto_jll + +if !@isdefined(SCHEMA) + SCHEMA = JSONSchema.Schema( + open(JSON3.read, joinpath(@__DIR__, "../schema/nbformat.v4.schema.json")), + ) + + function test_example(f, each) + examples = joinpath(@__DIR__, "../examples") + name = relpath(each, pwd()) + @info "Testing $name" + @testset "$(name)" begin + server = QuartoNotebookRunner.Server() + buffer = IOBuffer() + QuartoNotebookRunner.run!(server, each; output = buffer, showprogress = false) + seekstart(buffer) + json = JSON3.read(buffer, Any) + + @test JSONSchema.validate(SCHEMA, json) === nothing + + ## Common tests. + @test json["nbformat"] == 4 + @test json["nbformat_minor"] == 5 + @test json["metadata"]["language_info"]["name"] == "julia" + @test json["metadata"]["language_info"]["version"] == "$VERSION" + @test json["metadata"]["language_info"]["codemirror_mode"] == "julia" + @test json["metadata"]["kernel_info"]["name"] == "julia" + @test startswith(json["metadata"]["kernelspec"]["name"], "julia") + @test startswith(json["metadata"]["kernelspec"]["display_name"], "Julia") + @test json["metadata"]["kernelspec"]["language"] == "julia" + + ## File-specific tests. + f(json) + + function with_extension(path, ext) + root, _ = splitext(path) + return "$root.$ext" + end + ipynb = joinpath(examples, with_extension(each, "ipynb")) + QuartoNotebookRunner.run!(server, each; output = ipynb, showprogress = false) + + if !Sys.iswindows() + # No macOS ARM build, so just look for a local version that the dev + # should have installed. This avoids having to use rosetta2 to run + # the x86_64 version of Julia to get access to the x86_64 version of + # Quarto artifact. + quarto_bin = + quarto_jll.is_available() ? quarto_jll.quarto() : setenv(`quarto`) + # Just a smoke test to make sure it runs. Use docx since it doesn't + # output a bunch of folders (html), or require a tinytex install + # (pdf). All we are doing here at the moment is ensuring quarto doesn't + # break on our notebook outputs. + if success(`$quarto_bin --version`) + @test success(`$quarto_bin render $ipynb --to docx`) + else + @error "quarto not found, skipping smoke test." + end + end + QuartoNotebookRunner.close!(server, each) + end + end +end diff --git a/test/utilities/project_precompile.jl b/test/utilities/project_precompile.jl new file mode 100644 index 0000000..e4e48ce --- /dev/null +++ b/test/utilities/project_precompile.jl @@ -0,0 +1,18 @@ +# Update and precompile all project TOMLs when in CI. +if get(ENV, "CI", "false") == "true" + for dir in ["integrations", "mimetypes"] + for (root, dirs, files) in walkdir(joinpath(@__DIR__, "..", "examples", dir)) + for each in files + if each == "Project.toml" + manifest = joinpath(root, "Manifest.toml") + if isfile(manifest) + rm(manifest; force = true) + end + run( + `$(Base.julia_cmd()) --project=$root -e 'push!(LOAD_PATH, "@stdlib"); import Pkg; Pkg.update()'`, + ) + end + end + end + end +end