diff --git a/DESCRIPTION b/DESCRIPTION index de5e545f5..aff867d2f 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -109,7 +109,8 @@ Suggests: svglite, ggplot2, chromote, - magick + magick, + callr License: GPL-3 Encoding: UTF-8 LazyData: true diff --git a/R/z_knitr.R b/R/z_knitr.R index 0e094a7ad..9ab17865d 100644 --- a/R/z_knitr.R +++ b/R/z_knitr.R @@ -99,7 +99,6 @@ renderAnimint <- function(expr, env = parent.frame(), quoted = FALSE) { renderFunc <- function(shinysession, name, ...) { val <- func() tmp <- tempfile() - dir.create(tmp) stuff <- animint2dir(val, out.dir = tmp, open.browser = FALSE) shiny::addResourcePath("animintAssets", tmp) list(jsonFile = "plot.json") diff --git a/inst/examples/rmarkdown/index.Rmd b/inst/examples/rmarkdown/index.Rmd index ceccd66c0..3d75dddfc 100644 --- a/inst/examples/rmarkdown/index.Rmd +++ b/inst/examples/rmarkdown/index.Rmd @@ -8,9 +8,12 @@ runtime: shiny [Animint](https://github.com/tdhock/animint) is an R package for creating web-based interactive graphics and animations using ggplot2’s grammar of graphics approach. Now you can embed animint plots inside [shiny apps](http://shiny.rstudio.com/). In the shiny app below, you can change the x/y/color variables and both plots will update. ```{r embedded} -library(shiny) +setwd(normalizePath(file.path("..", "..", ".."))) +if (!dir.exists("inst/examples/shiny")) { + stop("Working directory is not the repository root: ", getwd()) +} shinyAppDir( - system.file("examples/shiny", package = "animint2"), + "inst/examples/shiny", options = list(width = "100%", height = 500) ) ``` @@ -90,4 +93,3 @@ renderAnimint({ ```{r sessionInfo} sessionInfo() ``` - diff --git a/inst/examples/rmarkdown/runDoc.R b/inst/examples/rmarkdown/runDoc.R index ed916f518..d99d9f46e 100644 --- a/inst/examples/rmarkdown/runDoc.R +++ b/inst/examples/rmarkdown/runDoc.R @@ -1 +1 @@ -rmarkdown::run(system.file("examples/rmarkdown/index.Rmd", package = "animint2")) \ No newline at end of file +rmarkdown::run("inst/examples/rmarkdown/index.Rmd") \ No newline at end of file diff --git a/inst/examples/shiny-WorldBank/server.R b/inst/examples/shiny-WorldBank/server.R index da0ca5f80..0b9cb277c 100644 --- a/inst/examples/shiny-WorldBank/server.R +++ b/inst/examples/shiny-WorldBank/server.R @@ -1,13 +1,51 @@ library(shiny) library(animint2) +library(maps) data(WorldBank) WorldBank$literacy <- WorldBank[["15.to.25.yr.female.literacy"]] WorldBank$latitude <- as.numeric(paste(WorldBank$latitude)) WorldBank$longitude <- as.numeric(paste(WorldBank$longitude)) +# Map data processing (from first code) +map_df <- animint2::map_data("world") +country2region <- with(unique(WorldBank[, c("region","country")]), structure(region, names=country)) +map2wb <- c( + Antigua="Antigua and Barbuda", + Brunei="Brunei Darussalam", + Bahamas="Bahamas, The", + "Democratic Republic of the Congo"="Congo, Dem. Rep.", + "Republic of Congo"="Congo, Rep.", + "Ivory Coast"="Cote d'Ivoire", + Egypt="Egypt, Arab Rep.", + Micronesia="Micronesia, Fed. Sts.", + UK="United Kingdom", + Gambia="Gambia, The", + Iran="Iran, Islamic Rep.", + Kyrgyzstan="Kyrgyz Republic", + "Saint Kitts"="St. Kitts and Nevis", + "North Korea"="Korea, Dem. Rep.", + "South Korea"="Korea, Rep.", + Laos="Lao PDR", + "Saint Lucia"="St. Lucia", + "North Macedonia"="Macedonia, FYR", + Palestine="West Bank and Gaza", + Russia="Russian Federation", + Slovakia="Slovak Republic", + "Saint Martin"="Sint Maarten (Dutch part)", + Syria="Syrian Arab Republic", + Trinidad="Trinidad and Tobago", + Tobago="Trinidad and Tobago", + USA="United States", + "Saint Vincent"="St. Vincent and the Grenadines", + Venezuela="Venezuela, RB", + "Virgin Islands"="Virgin Islands (U.S.)", + Yemen="Yemen, Rep.") +map_disp <- with(map_df, data.frame( + group, country=ifelse(region %in% names(map2wb), map2wb[region], region))) +map_disp$region <- country2region[map_disp$country] is.discrete <- function(x){ is.factor(x) || is.character(x) || is.logical(x) } - +# server.R shinyServer(function(input, output) { getViz <- reactive({ @@ -19,6 +57,7 @@ shinyServer(function(input, output) { TS <- function(df)BOTH(df, "Years", input$y) SCATTER <- function(df)BOTH(df, input$x, input$y) TS2 <- function(df)BOTH(df, input$x, "Years") + MAP <- function(df)BOTH(df, "Years", "Years") y.na <- WorldBank[[input$y]] x.na <- WorldBank[[input$x]] not.na <- WorldBank[!(is.na(y.na) | is.na(x.na)),] @@ -37,6 +76,17 @@ shinyServer(function(input, output) { data_i <- SCATTER(not.na) data_i$color <- input$color + # Process map coordinates (from first code) + first.year <- min(WorldBank$year, na.rm=TRUE) + last.year <- max(WorldBank$year, na.rm=TRUE) + map_names <- c(x="long", y="lat") + for(new.var in names(map_names)){ + old.var <- map_names[[new.var]] + old.val <- map_df[[old.var]] + m <- min(old.val) + old.01 <- (old.val-m)/(max(old.val)-m) + map_disp[[new.var]] <- old.01*(last.year-first.year)+first.year + } gg <- ggplot()+ theme_bw()+ @@ -111,6 +161,16 @@ shinyServer(function(input, output) { showSelected=c("country","year", "color"), clickSelects="country", data=data_i)+ + # Add world map polygon (from first code) + geom_polygon(aes( + x, y, group=group, fill=region), + title="World map", + clickSelects="country", + color="black", + color_off="transparent", + alpha=1, + alpha_off=0.3, + data=MAP(map_disp))+ facet_grid(side ~ top, scales="free")+ geom_text(aes(x, y, label=paste0("year = ", year)), @@ -134,4 +194,4 @@ shinyServer(function(input, output) { getViz() }) -}) +}) \ No newline at end of file diff --git a/tests/testthat/helper-HTML.R b/tests/testthat/helper-HTML.R index 615017c54..cd47f7136 100644 --- a/tests/testthat/helper-HTML.R +++ b/tests/testthat/helper-HTML.R @@ -127,13 +127,22 @@ start_js_coverage <- function() { stop_js_coverage <- function() { tryCatch({ cov <- remDr$Profiler$takePreciseCoverage() + results <- cov$result + # Filter to only the main animint.js script + results <- Filter(function(x) grepl("animint.js", x$url), results) + if (length(results) == 0) { + warning("No animint.js coverage collected.") + return(FALSE) + } + # Get the single, true path to animint.js + local_path <- normalizePath(system.file("htmljs", "animint.js", package = "animint2")) + # Update all relevant entries to point to this one local file + for (i in seq_along(results)) { + results[[i]]$url <- local_path + } + # Write to a single output file outfile <- "js-coverage.json" - # Ensure the format matches what v8-to-istanbul expects - coverage_data <- list( - result = cov$result, - url = "http://localhost:4848/animint-htmltest/animint.js" - ) - jsonlite::write_json(coverage_data, outfile, auto_unbox = TRUE) + jsonlite::write_json(list(result = results), outfile, auto_unbox = TRUE, pretty = TRUE) message("JS coverage saved to ", normalizePath(outfile)) TRUE }, error = function(e) { diff --git a/tests/testthat/helper-functions.R b/tests/testthat/helper-functions.R index a8c6b8b9a..7ae74a852 100644 --- a/tests/testthat/helper-functions.R +++ b/tests/testthat/helper-functions.R @@ -368,6 +368,43 @@ run_servr <- function(directory, port) { animint2:::start_servr(directory, port, tmpPath = find_test_path()) } +# Helper function to start Shiny app +start_app <- function(app_type = c("shiny", "rmd"), path, port) { + app_type <- match.arg(app_type) + if (app_type == "shiny" && !dir.exists(path)) { + stop("App directory does not exist: ", path) + } + if (app_type == "rmd" && !file.exists(path)) { + stop("RMarkdown file does not exist: ", path) + } + app_url <- sprintf("http://127.0.0.1:%d", port) + proc <- callr::r_bg( + function(app_type, path, port) { + if (app_type == "shiny") { + shiny::runApp(path, port = port, launch.browser = FALSE) + } else { + rmarkdown::run(file = path, shiny_args = list(port = port, launch.browser = FALSE)) + } + }, + args = list(app_type = app_type, path = path, port = port) + ) + Sys.sleep(6) + # Wait for startup + start_time <- Sys.time() + app_started <- FALSE + while (Sys.time() - start_time < 30) { + if (!proc$is_alive()) stop(app_type, " app failed") + con <- try(socketConnection("localhost", port, open = "r+", timeout = 5), silent = FALSE) + if (!inherits(con, "try-error")) { + close(con) + app_started <- TRUE + break + } + Sys.sleep(2) + } + if (!app_started) stop("Failed to start ", app_type, " app after 30 seconds") + return(list(proc = proc, url = app_url)) +} # -------------------------- # Functions that are used in multiple places # -------------------------- diff --git a/tests/testthat/test-renderer4-shiny.R b/tests/testthat/test-renderer4-shiny.R new file mode 100644 index 000000000..a7f23f261 --- /dev/null +++ b/tests/testthat/test-renderer4-shiny.R @@ -0,0 +1,98 @@ +print("Running shiny tests...") +port <- 3147 +setwd(normalizePath(file.path("..", ".."))) +test_that("animint plot renders in a shiny app", { + app_dir <- file.path("inst", "examples", "shiny") + if (!dir.exists(app_dir)) skip("Shiny app directory not found") + app_info <- start_app("shiny", app_dir, port) + on.exit(app_info$proc$kill(), add = TRUE) + remDr$navigate(app_info$url) + Sys.sleep(20) + # Wait for animint div to be present + animint_ready <- FALSE + for (i in 1:10) { + res <- remDr$Runtime$evaluate("document.querySelector('div#animint') !== null") + if (isTRUE(res$result$value)) { + animint_ready <- TRUE + break + } + Sys.sleep(0.1) + } + expect_true(animint_ready, info = "animint div should be present") + # Check for rendered circles + circles <- remDr$Runtime$evaluate( + "document.querySelector('div#animint svg').querySelectorAll('circle').length" + )$result$value + expect_true(circles >= 1, info = "At least one circle should be rendered in div#animint svg") +}) +test_that("WorldBank shiny app functionality", { + worldbank_dir <- file.path("inst", "examples", "shiny-WorldBank") + if (!dir.exists(worldbank_dir)) skip("WorldBank app directory not found") + if (file.path(worldbank_dir, "app.R") == file.path(tempdir(), "app.R")) { + skip("Tests skipped for mock app") + } + app_info <- start_app("shiny", worldbank_dir, port) + on.exit(app_info$proc$kill(), add = TRUE) + remDr$navigate(app_info$url) + Sys.sleep(20) + # Wait for animint div to be present + animint_ready <- FALSE + for (i in 1:10) { + res <- remDr$Runtime$evaluate("document.querySelector('div#animint') !== null") + if (isTRUE(res$result$value)) { + animint_ready <- TRUE + break + } + Sys.sleep(0.1) + } + expect_true(animint_ready, info = "animint div should be present") + # Check for rendered circles + circles <- remDr$Runtime$evaluate( + "document.querySelector('div#animint svg')?.querySelectorAll('circle').length || 0" + )$result$value + expect_true(circles >= 1, info = "At least one circle should be rendered") + get_year <- function() { + year <- remDr$Runtime$evaluate( + "var nodes = document.querySelectorAll('svg text'); var t = Array.from(nodes).find(n => n.textContent.includes('year = ')); t ? t.textContent.replace('year = ', '') : ''" + )$result$value + expect_true(nchar(year) > 0, info = "Year text should be present") + return(year) + } + old_year <- get_year() + Sys.sleep(10) + new_year <- get_year() + expect_true(old_year != new_year, info = "Year should change after animation") + div_left <- remDr$Runtime$evaluate( + "document.querySelector('#animint').getBoundingClientRect().left" + )$result$value + expect_true(is.numeric(div_left), info = "Div left position should be numeric") +}) +test_that("animint plot renders in an interactive document", { + if (!requireNamespace("rmarkdown")) skip("Package 'rmarkdown' not installed") + rmd_file <- file.path("inst", "examples", "rmarkdown", "index.Rmd") + if (!file.exists(rmd_file)) skip("RMarkdown file not found") + app_info <- start_app("rmd", rmd_file, port) + on.exit(app_info$proc$kill(), add = TRUE) + remDr$navigate(app_info$url) + Sys.sleep(20) + iframe_ready <- FALSE + for (i in 1:10) { + res <- remDr$Runtime$evaluate("document.querySelector('.shiny-frame') !== null") + if (isTRUE(res$result$value)) { + iframe_ready <- TRUE + break + } + Sys.sleep(0.1) + } + expect_true(iframe_ready, info = "Shiny iframe should be present") + circles <- remDr$Runtime$evaluate( + "document.querySelector('.shiny-frame').contentDocument.querySelectorAll('svg circle').length" + )$result$value + if (circles == 0) { + animint_circles <- remDr$Runtime$evaluate( + "document.querySelector('.shiny-frame').contentDocument.querySelectorAll('div#animint svg circle').length" + )$result$value + circles <- animint_circles + } + expect_true(circles >= 1, info = "At least one circle should be rendered in iframe") +}) \ No newline at end of file diff --git a/tests/testthat/test-shiny.R b/tests/testthat/test-shiny.R deleted file mode 100644 index e019ed792..000000000 --- a/tests/testthat/test-shiny.R +++ /dev/null @@ -1,136 +0,0 @@ -acontext("shiny") - -## We do not need if(on wercker or travis){skip shiny test} as of 10 -## Oct 2015, since we only run tests that match the TEST_SUITE env -## var, and test-shiny.R never matches. TODO convert -## sendKeysToActiveElement to new sendKeys, get shiny tests working on -## new chromote framework. - -## shiny tests require navigating to different ports, so remember -## where we are and return when tests are done -old_address <- remDr$getCurrentUrl()[[1]] -remDr$setImplicitWaitTimeout(milliseconds = 30000) - -shiny_dir <- system.file("examples/shiny", package = "animint") -shiny_cmd <- "shiny::runApp(appDir=\"%s\", port=%d, launch.browser=FALSE)" -animint:::run_servr(port = 3147, directory = shiny_dir, code = shiny_cmd) -address <- sprintf("http://localhost:3147") - -test_that("animint plot renders in a shiny app", { - Sys.sleep(10) # give shiny a second to do it's thing - remDr$navigate(address) - Sys.sleep(10) - ## just check that svg is displayed - html <- getHTML() - circles <- getNodeSet(html, "//div[@id='animint']//circle") - expect_true(length(circles) >= 1) -}) - -shiny_dir <- system.file("examples/shiny-WorldBank", package = "animint") -shiny_cmd <- "shiny::runApp(appDir=\"%s\", port=%d, launch.browser=FALSE)" -animint:::run_servr(port = 3148, directory = shiny_dir, code = shiny_cmd) -address <- sprintf("http://localhost:3148") - -test_that("WorldBank renders in a shiny app", { - Sys.sleep(1) # give shiny a second to do it's thing - remDr$navigate(address) - Sys.sleep(20) - ## just check that svg is displayed - html <- getHTML() - circles <- getNodeSet(html, "//div[@id='animint']//circle") - expect_true(length(circles) >= 1) -}) - -getYear <- function(){ - node.set <- getNodeSet(getHTML(), '//g[@class="geom10_text_ts"]//text') - expect_equal(length(node.set), 1) - value <- xmlValue(node.set[[1]]) - sub("year = ", "", value) -} - -test_that("animation updates", { - old.year <- getYear() - Sys.sleep(5) #wait for two animation frames. - new.year <- getYear() - expect_true(old.year != new.year) -}) - -getTickLeft <- function(){ - remDr$executeScript(' -var node_list = document.querySelectorAll(".yaxis text"); -var left_array = []; -for(var i=0; i < node_list.length; i++){ - var rect = node_list[i].getBoundingClientRect(); - left_array[i] = rect["left"]; -} -return left_array; -')[[1]] -} - -getDivLeft <- function(){ - remDr$executeScript(' -return document.querySelector("#animint").getBoundingClientRect()["left"]; -')[[1]] -} - -test_that("animint fits in div", { - tick.left.vec <- getTickLeft() - div.left <- getDivLeft() - expect_true(all(div.left < tick.left.vec)) -}) - -getCountries <- function(){ - country.labels <- getNodeSet(getHTML(), '//g[@class="geom9_text_ts"]//text') - sort(sapply(country.labels, xmlValue)) -} - -test_that("clicking selects country", { - old.countries <- getCountries() - expect_identical(old.countries, c("United States", "Vietnam")) - clickID("Bahrain") - new.countries <- getCountries() - expect_identical(new.countries, c("Bahrain", "United States", "Vietnam")) -}) - -getFacets <- function(){ - facets <- getNodeSet(getHTML(), '//g[@class="topStrip"]//text') - sapply(facets, xmlValue) -} - -test_that("shiny changes axes", { - old.facets <- getFacets() - expect_identical(old.facets, c("fertility.rate", "Years")) - e <- remDr$findElement("class name", "selectize-input") - ## This click and sendKeys is just to make sure we have focus on the - ## first selectize element. - e$clickElement() - e$sendKeysToElement(list(key="backspace")) - e$clickElement() # hide menu - e$clickElement() # show menu - remDr$sendKeysToActiveElement(list(key="backspace")) - remDr$sendKeysToActiveElement(list("lite")) - remDr$sendKeysToActiveElement(list(key="enter")) - Sys.sleep(10) - new.facets <- getFacets() - expect_identical(new.facets, c("literacy", "Years")) -}) - -rmd_dir <- system.file("examples/rmarkdown", package = "animint") -rmd_cmd <- "rmarkdown::run(dir = \"%s\", shiny_args = list(port=%d, launch.browser=FALSE))" -animint:::run_servr(port = 3120, directory = rmd_dir, code = rmd_cmd) -address <- sprintf("http://localhost:3120") - -test_that("animint plot renders in an interactive document", { - Sys.sleep(10) # give shiny a second to do it's thing - remDr$navigate(address) - Sys.sleep(10) - e <- remDr$findElement("class name", "shiny-frame") - remDr$switchToFrame(e) - html <- getHTML() - circles <- getNodeSet(html, "//svg//circle") - expect_true(length(circles) >= 1) -}) - -## go back to non-shiny tests -remDr$navigate(old_address) - diff --git a/v8-to-istanbul.js b/v8-to-istanbul.js index 434b95d6d..fd7547d28 100644 --- a/v8-to-istanbul.js +++ b/v8-to-istanbul.js @@ -4,58 +4,42 @@ const v8toIstanbul = require('v8-to-istanbul'); async function convertToIstanbul() { try { - // Path configuration const coverageJsonPath = path.join('tests', 'testthat', 'js-coverage.json'); const outputIstanbulPath = 'coverage-istanbul.json'; - const baseDir = path.join(__dirname, 'inst', 'htmljs'); - console.log(`Reading coverage data from: ${coverageJsonPath}`); - console.log(`Looking for source files in: ${baseDir}`); - - // Check if input file exists if (!fs.existsSync(coverageJsonPath)) { console.error(`Error: Coverage file not found at ${coverageJsonPath}`); process.exit(1); } const rawCoverage = JSON.parse(fs.readFileSync(coverageJsonPath, 'utf8')); const istanbulCoverage = {}; - // Process each file's coverage for (const scriptCoverage of rawCoverage.result) { - const url = scriptCoverage.url; - // Skip empty URLs - if (!url) continue; - // Extract the relative file path from the URL - const filePath = url.replace(/^http:\/\/localhost:\d+\/animint-htmltest\//, ''); - if (filePath.startsWith('vendor/')) { - //Skip files under vendor/ + const fullPath = scriptCoverage.url; + if (!fullPath || !fs.existsSync(fullPath)) { + console.warn(`Skipping non-existent temp file: ${fullPath}`); continue; } - const fullPath = path.join(baseDir, filePath); - // Skip if path is empty or file doesn't exist - if (!filePath || !fs.existsSync(fullPath)) { - continue; + // Optionally skip vendor files if their path contains 'vendor' + if (fullPath.includes('vendor')) { + continue; } try { const scriptSource = fs.readFileSync(fullPath, 'utf8'); - // Create converter for this file const converter = v8toIstanbul(fullPath, 0, { source: scriptSource }); await converter.load(); converter.applyCoverage(scriptCoverage.functions); - // Get Istanbul coverage data for this file and merge it const fileCoverage = converter.toIstanbul(); Object.assign(istanbulCoverage, fileCoverage); - console.log(`Processed coverage for: ${filePath}`); + console.log(`Processed coverage for temp file: ${path.basename(fullPath)}`); } catch (err) { - console.error(`Error processing ${filePath}:`, err.message); + console.error(`Error processing ${fullPath}:`, err.message); } } - // Save Istanbul coverage data if (Object.keys(istanbulCoverage).length > 0) { fs.writeFileSync(outputIstanbulPath, JSON.stringify(istanbulCoverage, null, 2)); console.log(`Successfully converted coverage to ${outputIstanbulPath}`); - } else { console.error('No valid coverage data was processed'); process.exit(1); @@ -66,4 +50,4 @@ async function convertToIstanbul() { } } -convertToIstanbul(); \ No newline at end of file +convertToIstanbul();