| 1 |
#' Checks to run before initializing the shinyValidator template |
|
| 2 |
#' |
|
| 3 |
#' Useful for \link{use_validator}
|
|
| 4 |
#' |
|
| 5 |
#' @inheritParams use_validator |
|
| 6 |
#' |
|
| 7 |
#' @return Error if any of the requirement is not met. |
|
| 8 |
#' @keywords internal |
|
| 9 |
check_setup_requirements <- function(cicd_platform) {
|
|
| 10 | 5x |
message("Checking requirements ...")
|
| 11 | ||
| 12 | 5x |
check_if_validator_installed(cicd_platform) |
| 13 | ||
| 14 | 5x |
if (R.version$major < "4") {
|
| 15 | ! |
message("Note: {shinyValidator} works better with R >=4.")
|
| 16 |
} |
|
| 17 | 5x |
if (!file.exists("DESCRIPTION")) {
|
| 18 | 1x |
stop("{shinyValidator} only works inside R packages.")
|
| 19 |
} |
|
| 20 | 4x |
if (!file.exists("renv.lock")) {
|
| 21 | 1x |
stop("Please setup {renv} to manage dependencies in your project.")
|
| 22 |
} |
|
| 23 | 3x |
if (!file.exists("./R/app_server.R")) {
|
| 24 | 1x |
stop("Project must contain: app_ui.R and app_server.R like in {golem} templates.")
|
| 25 |
} |
|
| 26 | 2x |
if (!dir.exists("tests")) {
|
| 27 | 2x |
message("No unit tests found. call usethis::use_testthat(); ...")
|
| 28 |
} |
|
| 29 |
# Requires gh-pages branch if GitHub |
|
| 30 | 2x |
if (!is_git_repository()) {
|
| 31 | 1x |
stop("Project is not under version control: run 'git init' ...")
|
| 32 |
} else {
|
|
| 33 | 1x |
initialize_gh_pages(cicd_platform) |
| 34 |
} |
|
| 35 | 1x |
message("Requirements: DONE ...")
|
| 36 |
} |
|
| 37 | ||
| 38 | ||
| 39 |
#' Check if git is initialized locally |
|
| 40 |
#' |
|
| 41 |
#' @return Boolean. |
|
| 42 |
#' @keywords internal |
|
| 43 |
is_git_repository <- function() {
|
|
| 44 | 2x |
dir.exists(".git")
|
| 45 |
} |
|
| 46 | ||
| 47 |
#' Check if current repo has remote part |
|
| 48 |
#' |
|
| 49 |
#' @return Boolean. |
|
| 50 |
#' @keywords internal |
|
| 51 |
has_git_remote <- function() {
|
|
| 52 | 2x |
length(system("git remote -v", intern = TRUE) > 1)
|
| 53 |
} |
|
| 54 | ||
| 55 |
#' Find a git branch |
|
| 56 |
#' |
|
| 57 |
#' @param name Branch name. |
|
| 58 |
#' |
|
| 59 |
#' @return Branch name if found |
|
| 60 |
#' @keywords internal |
|
| 61 |
find_branch <- function(name) {
|
|
| 62 | 2x |
if (length(grep(name, system("git branch -a", intern = TRUE))) > 0) {
|
| 63 | ! |
name |
| 64 |
} |
|
| 65 |
} |
|
| 66 | ||
| 67 | 1x |
`%OR%` <- function(a, b) if (!is.null(a)) a else b |
| 68 | ||
| 69 |
#' Find the main branch |
|
| 70 |
#' |
|
| 71 |
#' @return master or main depending on the initial configuration. |
|
| 72 |
#' @keywords internal |
|
| 73 |
find_main_branch <- function() {
|
|
| 74 | 1x |
find_branch("main") %OR% find_branch("master")
|
| 75 |
} |
|
| 76 | ||
| 77 |
#' Checks if gh_pages branch exists |
|
| 78 |
#' |
|
| 79 |
#' Creates gh_pages branch if not |
|
| 80 |
#' |
|
| 81 |
#' @inheritParams use_validator |
|
| 82 |
#' @keywords internal |
|
| 83 |
initialize_gh_pages <- function(cicd_platform) {
|
|
| 84 | 2x |
if (cicd_platform == "github") {
|
| 85 | 1x |
if (!has_git_remote()) {
|
| 86 | 1x |
stop("Current repo does not have remote. Please add GitHub remote ...")
|
| 87 |
} |
|
| 88 | ||
| 89 | ! |
has_gh_pages <- length( |
| 90 | ! |
suppressWarnings( |
| 91 | ! |
system("git rev-parse --verify gh-pages", intern = TRUE)
|
| 92 |
) |
|
| 93 |
) |
|
| 94 | ! |
if (has_gh_pages == 0) {
|
| 95 | ! |
message("Missing 'gh-pages' branch. Creating new branch ...")
|
| 96 | ! |
system( |
| 97 | ! |
paste( |
| 98 | ! |
"git checkout --orphan gh-pages; |
| 99 | ! |
git reset --hard; |
| 100 | ! |
git commit --allow-empty -m 'fresh and empty gh-pages branch'; |
| 101 | ! |
git push origin gh-pages;", |
| 102 | ! |
sprintf("git checkout %s;", find_main_branch())
|
| 103 |
), |
|
| 104 | ! |
intern = TRUE, |
| 105 | ! |
ignore.stdout = TRUE |
| 106 |
) |
|
| 107 | ! |
message("gh-pages: DONE ...")
|
| 108 |
} else {
|
|
| 109 | ! |
message("gh-pages already exists. Nothing to do ...")
|
| 110 |
} |
|
| 111 |
} |
|
| 112 |
} |
|
| 113 | ||
| 114 |
#' Process scope for project |
|
| 115 |
#' |
|
| 116 |
#' @param scope Current project scope |
|
| 117 |
#' |
|
| 118 |
#' @return Reassign parms based on the scope |
|
| 119 |
#' @keywords internal |
|
| 120 |
process_scope <- function(scope) {
|
|
| 121 | 3x |
switch(scope, |
| 122 | 2x |
"manual" = NULL, |
| 123 | ! |
"DMC" = apply_dmc_scope(), |
| 124 | 1x |
"POC" = apply_poc_scope() |
| 125 |
) |
|
| 126 |
} |
|
| 127 | ||
| 128 |
#' Apply DMC scope |
|
| 129 |
#' |
|
| 130 |
#' DMC are the most critical applications |
|
| 131 |
#' |
|
| 132 |
#' @keywords internal |
|
| 133 |
apply_dmc_scope <- function() { # nocov start
|
|
| 134 | ||
| 135 |
} # nocov end |
|
| 136 | ||
| 137 |
#' Apply POC scope |
|
| 138 |
#' |
|
| 139 |
#' POC apps just need basic check: lint, style + crash test |
|
| 140 |
#' |
|
| 141 |
#' @keywords internal |
|
| 142 |
apply_poc_scope <- function() {
|
|
| 143 |
# need to modify env in grand parent |
|
| 144 |
# (apply_poc_scope -- process_scope -- use_validator) |
|
| 145 | 1x |
p <- parent.frame(n = 2) |
| 146 | 1x |
p[["output_validation"]] <- FALSE |
| 147 | 1x |
p[["coverage"]] <- FALSE |
| 148 | 1x |
p[["load_testing"]] <- FALSE |
| 149 | 1x |
p[["profile_code"]] <- FALSE |
| 150 | 1x |
p[["check_reactivity"]] <- FALSE |
| 151 | 1x |
p[["flow"]] <- FALSE |
| 152 |
} |
|
| 153 | ||
| 154 |
#' Checks if the validator is already installed |
|
| 155 |
#' |
|
| 156 |
#' @inheritParams use_validator |
|
| 157 |
#' |
|
| 158 |
#' @return Boolean. |
|
| 159 |
#' @keywords internal |
|
| 160 |
check_if_validator_installed <- function(cicd_platform) {
|
|
| 161 | ||
| 162 | 6x |
file_name <- switch(cicd_platform, |
| 163 | 6x |
"gitlab" = "./.gitlab-ci.yml", |
| 164 | 6x |
"gitlab-docker" = ".gitlab-ci.yml", |
| 165 | 6x |
"github" = "./.github/workflows/shiny-validator.yaml" |
| 166 |
) |
|
| 167 | ||
| 168 | 6x |
if (file.exists(file_name)) {
|
| 169 | 1x |
tmp <- readLines(file_name) |
| 170 | 1x |
sum(grep("### <shinyValidator template DON'T REMOVE> ###", tmp) == 1)
|
| 171 | 1x |
stop("Validator already installed! Aborting ...")
|
| 172 |
} else {
|
|
| 173 | 5x |
FALSE |
| 174 |
} |
|
| 175 |
} |
|
| 176 | ||
| 177 |
#' Copy and rename app helpers |
|
| 178 |
#' |
|
| 179 |
#' @keywords internal |
|
| 180 |
copy_app_file <- function() {
|
|
| 181 | 1x |
message("Copying run_app.R and archive old run_app.R function")
|
| 182 | 1x |
file.rename("./R/run_app.R", "./R/run_app-old.R")
|
| 183 | 1x |
file.copy( |
| 184 | 1x |
system.file("run-app/run_app.R", package = "shinyValidator"),
|
| 185 | 1x |
"./R/run_app.R" |
| 186 |
) |
|
| 187 |
# Comment out old files |
|
| 188 | 1x |
old_file <- readLines("./R/run_app-old.R")
|
| 189 | 1x |
write( |
| 190 | 1x |
unlist(lapply(old_file, sub, pattern = "^", replacement = "# ")), |
| 191 | 1x |
"./R/run_app-old.R" |
| 192 |
) |
|
| 193 |
} |
|
| 194 | ||
| 195 |
#' Edit .Rbuildignore file |
|
| 196 |
#' @inheritParams use_validator |
|
| 197 |
#' @return Edit existing .Rbuidignore file with additional entries |
|
| 198 |
#' @keywords internal |
|
| 199 |
edit_buildignore <- function(cicd_platform) {
|
|
| 200 | ||
| 201 | 1x |
cicd_ignore <- switch(cicd_platform, |
| 202 | 1x |
"gitlab" = ".gitlab-ci.yml", |
| 203 | 1x |
"gitlab-docker" = ".gitlab-ci.yml", |
| 204 | 1x |
"github" = ".github" |
| 205 |
) |
|
| 206 | ||
| 207 | 1x |
usethis::use_build_ignore( |
| 208 | 1x |
c( |
| 209 | 1x |
cicd_ignore, |
| 210 | 1x |
".Rprofile", |
| 211 | 1x |
".lintr", |
| 212 |
# if audit_app is run locally |
|
| 213 | 1x |
"public", |
| 214 | 1x |
"recording.log" |
| 215 |
) |
|
| 216 |
) |
|
| 217 |
} |
|
| 218 | ||
| 219 |
suggested_pkgs_names <- c( |
|
| 220 |
"DT", |
|
| 221 |
"testthat", |
|
| 222 |
"shinyValidator", |
|
| 223 |
"pkgload", |
|
| 224 |
"lubridate", |
|
| 225 |
"rmarkdown", |
|
| 226 |
"vdiffr", |
|
| 227 |
"withr" |
|
| 228 |
) |
|
| 229 | ||
| 230 |
shinyValidator_suggested_pkgs <- data.frame( |
|
| 231 |
name = suggested_pkgs_names, |
|
| 232 |
type = rep("Suggests", length(suggested_pkgs_names))
|
|
| 233 |
) |
|
| 234 | ||
| 235 |
globalVariables("shinyValidator_suggested_pkgs")
|
|
| 236 | ||
| 237 |
#' Add suggested pkgs to DESCRIPTION |
|
| 238 |
#' |
|
| 239 |
#' These pkgs are required to perform the CICD job |
|
| 240 |
#' |
|
| 241 |
#' @return Edit the current Suggests DESCRIPTION fields |
|
| 242 |
#' @keywords internal |
|
| 243 |
add_suggested_packages <- function() {
|
|
| 244 | 1x |
apply( |
| 245 | 1x |
shinyValidator_suggested_pkgs, |
| 246 | 1x |
1, |
| 247 | 1x |
function(pkg) {
|
| 248 | 8x |
desc::desc_set_dep( |
| 249 | 8x |
pkg[["name"]], |
| 250 | 8x |
type = pkg[["type"]] |
| 251 |
) |
|
| 252 |
} |
|
| 253 |
) |
|
| 254 |
} |
|
| 255 | ||
| 256 |
#' Add local copy of gremlins.js |
|
| 257 |
#' |
|
| 258 |
#' Useful if running behind a corporate proxy. |
|
| 259 |
#' |
|
| 260 |
#' @keywords internal |
|
| 261 |
add_gremlins_assets <- function() {
|
|
| 262 | 2x |
message("Adding gremlins.js assets ...")
|
| 263 | 2x |
if (!dir.exists("inst")) {
|
| 264 | 2x |
dir.create("inst")
|
| 265 |
} |
|
| 266 | 2x |
dir.create("inst/shinyValidator-js")
|
| 267 | 2x |
file.copy( |
| 268 | 2x |
system.file("shinyValidator-js/gremlins.min.js", package = "shinyValidator"),
|
| 269 | 2x |
"inst/shinyValidator-js/gremlins.min.js" |
| 270 |
) |
|
| 271 |
} |
|
| 272 | ||
| 273 |
#' Initialize CI/CD template |
|
| 274 |
#' |
|
| 275 |
#' @inheritParams use_validator |
|
| 276 |
#' @return A yml file with CI/CD steps. |
|
| 277 |
#' @keywords internal |
|
| 278 |
initialize_cicd <- function(cicd_platform) {
|
|
| 279 | 1x |
message(sprintf("Initialized %s CI/CD template", cicd_platform))
|
| 280 | 1x |
file_name <- switch(cicd_platform, |
| 281 | 1x |
"gitlab" = ".gitlab-ci.yml", |
| 282 | 1x |
"gitlab-docker" = "docker/.gitlab-ci.yml", |
| 283 | 1x |
"github" = "shiny-validator.yaml" |
| 284 |
) |
|
| 285 | ||
| 286 | 1x |
if (cicd_platform == "github") {
|
| 287 |
# directory may already exist if user has GA setup |
|
| 288 | ! |
if (!dir.exists(".github/workflows")) {
|
| 289 | ! |
dir.create(".github/workflows", recursive = TRUE)
|
| 290 |
} |
|
| 291 |
} |
|
| 292 | ||
| 293 | 1x |
file.copy( |
| 294 | 1x |
from = system.file( |
| 295 | 1x |
sprintf("workflows/%s", file_name),
|
| 296 | 1x |
package = "shinyValidator" |
| 297 |
), |
|
| 298 | 1x |
to = if (cicd_platform %in% c("gitlab", "gitlab-docker")) {
|
| 299 | 1x |
"./.gitlab-ci.yml" |
| 300 | 1x |
} else if (cicd_platform == "github") {
|
| 301 | ! |
".github/workflows/shiny-validator.yaml" |
| 302 |
} |
|
| 303 |
) |
|
| 304 |
} |
| 1 |
#' Run Shiny app validation tools in your project |
|
| 2 |
#' |
|
| 3 |
#' Run all specified tools and requirements to validate Shiny apps project. |
|
| 4 |
#' |
|
| 5 |
#' @param cran Whether to apply as CRAN check. Defaults to FALSE. |
|
| 6 |
#' @param vignettes Whether to build vignettes. Defaults to FALSE. |
|
| 7 |
#' @param error_on When to raise an error. Possible choices: |
|
| 8 |
#' \code{c("never", "error", "warning", "note")}. Defaults to never.
|
|
| 9 |
#' @param timeout Time to wait after starting the subprocess (s). Useful is you know |
|
| 10 |
#' how much time the app takes to load. |
|
| 11 |
#' @param headless_actions Custom code passed as a string to manipulate the app with headless |
|
| 12 |
#' web browser, for instance |
|
| 13 |
#' \code{"headless_app$set_inputs(obs = 200); headless_app$run_js('1+1');"}.
|
|
| 14 |
#' If NULL, the app will undergo a classic random Monkey test session. |
|
| 15 |
#' @param workers Number of workers for shinycannon. Default to 5. |
|
| 16 |
#' @param scope Project scope. Accepted values \code{c("manual", "DMC", "POC")}.
|
|
| 17 |
#' @param output_validation Whether to compare output snapshots for |
|
| 18 |
#' plots and htmlwidgets. Default to TRUE. |
|
| 19 |
#' @param coverage Whether to perform coverage report. Default to TRUE. |
|
| 20 |
#' @param load_testing Whether to perform load test. Default to TRUE. |
|
| 21 |
#' @param profile_code Whether to profile R code. Default to TRUE. |
|
| 22 |
#' @param check_reactivity Whether to check reactivity log. Default to TRUE. |
|
| 23 |
#' @param flow Whether to display project overview. Default to TRUE. |
|
| 24 |
#' |
|
| 25 |
#' @export |
|
| 26 |
audit_app <- function( |
|
| 27 |
cran = FALSE, |
|
| 28 |
vignettes = FALSE, |
|
| 29 |
error_on = "never", |
|
| 30 |
timeout = 5, |
|
| 31 |
headless_actions = NULL, |
|
| 32 |
workers = 5, |
|
| 33 |
scope = c("manual", "DMC", "POC"),
|
|
| 34 |
output_validation = FALSE, |
|
| 35 |
coverage = TRUE, |
|
| 36 |
load_testing = TRUE, |
|
| 37 |
profile_code = TRUE, |
|
| 38 |
check_reactivity = TRUE, |
|
| 39 |
flow = FALSE |
|
| 40 |
) {
|
|
| 41 | ||
| 42 |
# Technical requirements |
|
| 43 | 1x |
check_audit_requirements() |
| 44 | ||
| 45 |
# Scope |
|
| 46 | 1x |
scope <- match.arg(scope) |
| 47 | 1x |
process_scope(scope) |
| 48 | ||
| 49 |
# Run check |
|
| 50 | 1x |
tab_check <- check_package(cran, vignettes, error_on) |
| 51 |
# Run crash test |
|
| 52 | 1x |
tab_crash_test <- run_crash_test(timeout, headless_actions) |
| 53 |
# Output validation |
|
| 54 | 1x |
tab_output_validation <- if (output_validation) {
|
| 55 | ! |
validate_outputs() |
| 56 |
} else {
|
|
| 57 | 1x |
NULL |
| 58 |
} |
|
| 59 |
# Load test, profiling, reactlog |
|
| 60 | 1x |
if (load_testing) record_app(timeout, headless_actions, workers) |
| 61 | 1x |
if (profile_code) profile_app(timeout, headless_actions) |
| 62 | 1x |
if (check_reactivity) upload_reactlog(timeout, headless_actions) |
| 63 | 1x |
if (coverage) covr::gitlab(quiet = FALSE, file = "public/coverage.html") |
| 64 | 1x |
if (flow) {
|
| 65 | ! |
pkgload::load_all() |
| 66 | ! |
flow::flow_view_shiny(run_app, out = "public/flow.html") |
| 67 |
} |
|
| 68 | ||
| 69 | 1x |
message("\n---- BEGIN REPORT GENERATION ---- \n")
|
| 70 | ||
| 71 |
# Generate report with tabs |
|
| 72 | 1x |
create_audit_report( |
| 73 | 1x |
output_validation, |
| 74 | 1x |
coverage, |
| 75 | 1x |
load_testing, |
| 76 | 1x |
profile_code, |
| 77 | 1x |
check_reactivity, |
| 78 | 1x |
flow, |
| 79 | 1x |
tab_output_validation = tab_output_validation, |
| 80 | 1x |
package_name = tab_check$package_name, |
| 81 | 1x |
package_version = tab_check$package_version, |
| 82 | 1x |
tab_package_check = tab_check$tab_package_check, |
| 83 | 1x |
tab_crash_test = tab_crash_test |
| 84 |
) |
|
| 85 | ||
| 86 | 1x |
message("\n---- ALL GOOD ---- \n")
|
| 87 |
} |
|
| 88 | ||
| 89 |
#' Checks to run before running the audit tools |
|
| 90 |
#' |
|
| 91 |
#' Useful for \link{audit_app}. Briefly, we expect the user
|
|
| 92 |
#' to have shinycannon and Chrome installed since they are required |
|
| 93 |
#' for loadtest, headless testing ... |
|
| 94 |
#' |
|
| 95 |
#' @return Error if any of the requirement is not met. |
|
| 96 |
#' @keywords internal |
|
| 97 |
check_audit_requirements <- function() {
|
|
| 98 | 1x |
message("Checking technical requirements ...")
|
| 99 | ||
| 100 | 1x |
if (length(system("which shinycannon", intern = TRUE)) == 0) {
|
| 101 | ! |
stop("Missing shinycannon: https://github.com/rstudio/shinycannon")
|
| 102 |
} |
|
| 103 | ||
| 104 | 1x |
has_web_browser <- suppressWarnings( |
| 105 | 1x |
length(system("which google-chrome", intern = TRUE)) +
|
| 106 | 1x |
length(system("which chromium", intern = TRUE))
|
| 107 |
) |
|
| 108 | 1x |
if (has_web_browser == 0) {
|
| 109 | ! |
stop("Missing Chrome browser ...")
|
| 110 |
} |
|
| 111 | ||
| 112 | 1x |
message("Requirements: DONE ...")
|
| 113 |
} |
| 1 |
#' Start run_app as background process |
|
| 2 |
#' |
|
| 3 |
#' Required by \link{start_r_bg}.
|
|
| 4 |
#' |
|
| 5 |
#' @keywords internal |
|
| 6 |
shiny_bg <- function() {
|
|
| 7 | ! |
options(shiny.port = 3515) |
| 8 | ! |
pkgload::load_all() |
| 9 | ! |
run_app() |
| 10 |
} |
|
| 11 | ||
| 12 |
#' Start shinyloadtest recorder in the background |
|
| 13 |
#' |
|
| 14 |
#' Required by \link{start_r_bg}.
|
|
| 15 |
#' |
|
| 16 |
#' @param shiny_port Port where runs the shiny apps. This is automatically |
|
| 17 |
#' detected in \link{start_r_bg}.
|
|
| 18 |
#' |
|
| 19 |
#' @keywords internal |
|
| 20 |
recorder_bg <- function(shiny_port) {
|
|
| 21 | 1x |
shinyloadtest::record_session( |
| 22 | 1x |
target_app_url = sprintf("http://127.0.0.1:%s", shiny_port),
|
| 23 | 1x |
host = "127.0.0.1", |
| 24 | 1x |
port = 8600, |
| 25 | 1x |
output_file = "recording.log", |
| 26 | 1x |
open_browser = FALSE |
| 27 |
) |
|
| 28 |
} |
|
| 29 | ||
| 30 |
#' Start Shiny + profvis recorder in the background |
|
| 31 |
#' |
|
| 32 |
#' Required by \link{start_r_bg}.
|
|
| 33 |
#' |
|
| 34 |
#' @keywords internal |
|
| 35 |
profile_bg <- function() {
|
|
| 36 | ! |
options(keep.source = TRUE, shiny.port = 3515) |
| 37 | ! |
pkgload::load_all() |
| 38 | ! |
.profile_code <- TRUE |
| 39 | ! |
profvis::profvis( |
| 40 |
{
|
|
| 41 | ! |
profvis::pause(0.2) |
| 42 | ! |
run_app() |
| 43 |
}, |
|
| 44 | ! |
simplify = FALSE, |
| 45 | ! |
split = "v" |
| 46 |
) |
|
| 47 |
} |
|
| 48 | ||
| 49 |
#' Start run_app as background process |
|
| 50 |
#' |
|
| 51 |
#' Also enables reactlog. |
|
| 52 |
#' |
|
| 53 |
#' Required by \link{start_r_bg}.
|
|
| 54 |
#' |
|
| 55 |
#' @keywords internal |
|
| 56 |
reactlog_bg <- function() {
|
|
| 57 | ! |
options("shiny.port" = 3515)
|
| 58 | ! |
pkgload::load_all() |
| 59 | ! |
.enable_reactlog <- TRUE |
| 60 | ! |
reactlog::reactlog_enable() |
| 61 | ! |
run_app() |
| 62 |
} |
|
| 63 | ||
| 64 |
#' Start background R process |
|
| 65 |
#' |
|
| 66 |
#' Start process in the background. Required by |
|
| 67 |
#' \link{record_app}, ...
|
|
| 68 |
#' |
|
| 69 |
#' @param fun Passed to \link[callr]{r_bg}.
|
|
| 70 |
#' |
|
| 71 |
#' @return Process or error |
|
| 72 |
#' @keywords internal |
|
| 73 |
start_r_bg <- function(fun) {
|
|
| 74 | ||
| 75 | 12x |
func_name <- deparse(substitute(fun)) |
| 76 | 12x |
parms <- if (func_name == "recorder_bg") {
|
| 77 | 3x |
list(shiny_port = 3515) |
| 78 |
} else {
|
|
| 79 | 9x |
list() |
| 80 |
} |
|
| 81 | ||
| 82 | 12x |
port <- if (func_name == "recorder_bg") 8600 else 3515 |
| 83 | ||
| 84 | 12x |
process <- callr::r_bg( |
| 85 | 12x |
func = fun, |
| 86 | 12x |
stderr= "", |
| 87 | 12x |
stdout = "", |
| 88 | 12x |
args = parms |
| 89 |
) |
|
| 90 | ||
| 91 | 12x |
while (any(is.na(pingr::ping_port("127.0.0.1", port)))) {
|
| 92 | 198x |
message("Waiting for Shiny app to start...")
|
| 93 | 198x |
Sys.sleep(0.1) |
| 94 |
} |
|
| 95 | ||
| 96 | ! |
if (!process$is_alive()) stop("Unable to launch the subprocess")
|
| 97 | ||
| 98 | 12x |
process |
| 99 |
} |
|
| 100 |
| 1 |
#' Lint R code within project |
|
| 2 |
#' |
|
| 3 |
#' Uses the `{lintr}` package to check all R sources in provided paths.
|
|
| 4 |
#' To fine tune the lintr behavior, edit the `.lintr` file. |
|
| 5 |
#' |
|
| 6 |
#' @param paths Paths to check. |
|
| 7 |
#' @param tolerance Errors to allow. Default to 0. |
|
| 8 |
#' @export |
|
| 9 |
lint_code <- function(paths = "R", tolerance = 0) {
|
|
| 10 | ||
| 11 | ! |
lints <- unlist(lapply(paths, lintr::lint_dir)) |
| 12 | ! |
errors <- length(lints) |
| 13 | ||
| 14 | ! |
if (errors > tolerance) {
|
| 15 | ! |
print(lints) |
| 16 | ! |
stop(sprintf("Number of style errors: %s.", errors))
|
| 17 |
} |
|
| 18 |
} |
|
| 19 | ||
| 20 |
#' Build and check package |
|
| 21 |
#' |
|
| 22 |
#' @inheritParams audit_app |
|
| 23 |
#' |
|
| 24 |
#' @return Build and check package. Results are inserted into the |
|
| 25 |
#' main HTML report. |
|
| 26 |
#' @importFrom shiny tags HTML tagList |
|
| 27 |
#' @export |
|
| 28 |
check_package <- function(cran = FALSE, vignettes = FALSE, error_on = "never") {
|
|
| 29 | 2x |
tmp_chk <- rcmdcheck::rcmdcheck( |
| 30 | 2x |
args = c( |
| 31 | 2x |
if (!vignettes) "--ignore-vignettes", |
| 32 | 2x |
"--no-manual", |
| 33 | 2x |
if (cran) "--as-cran" |
| 34 |
), |
|
| 35 | 2x |
build_args = c(if (!vignettes) "--no-build-vignettes"), |
| 36 | 2x |
error_on = error_on, |
| 37 | 2x |
check_dir = "public" |
| 38 |
) |
|
| 39 | ||
| 40 | 2x |
check_res <- rcmdcheck::check_details(tmp_chk) |
| 41 | ||
| 42 |
# Avoids any error if test folder does not exist. |
|
| 43 |
# It is possible that people don't have tests at the |
|
| 44 |
# begining but still want to run loadtest and profiling ... |
|
| 45 | 2x |
tests_out <- if (dir.exists("tests")) {
|
| 46 | ! |
out_tmp <- readLines( |
| 47 | ! |
file.path( |
| 48 | ! |
sprintf( |
| 49 | ! |
"public/%s.Rcheck/tests/testthat.Rout", |
| 50 | ! |
check_res$package |
| 51 |
) |
|
| 52 |
) |
|
| 53 |
) |
|
| 54 | ! |
HTML(paste(out_tmp, collapse = "\n")) |
| 55 |
} else {
|
|
| 56 | 2x |
"No tests available." |
| 57 |
} |
|
| 58 | ||
| 59 | 2x |
steps <- list( |
| 60 | 2x |
"Building" = HTML(check_res$install_out), |
| 61 | 2x |
"Checking" = tmp_chk, |
| 62 | 2x |
"Testing" = tests_out |
| 63 |
) |
|
| 64 | ||
| 65 | 2x |
n_errors <- length(check_res$errors) |
| 66 | 2x |
n_warnings <- length(check_res$warnings) |
| 67 | 2x |
n_notes <- length(check_res$notes) |
| 68 | ||
| 69 |
# count failed tests if tests exist ... |
|
| 70 | 2x |
n_failed_test <- if (dir.exists("tests")) {
|
| 71 | ! |
as.numeric( |
| 72 | ! |
strsplit( |
| 73 | ! |
trimws( |
| 74 | ! |
strsplit( |
| 75 | ! |
tests_out[which(grepl("FAIL", tests_out, perl = TRUE) == TRUE)],
|
| 76 | ! |
"FAIL" |
| 77 | ! |
)[[1]][2]), |
| 78 |
"|" |
|
| 79 | ! |
)[[1]][1] |
| 80 |
) |
|
| 81 |
} else {
|
|
| 82 | 2x |
0 |
| 83 |
} |
|
| 84 | ||
| 85 |
# Prepare check tab UI |
|
| 86 | 2x |
package_check_tab_ui <- create_tab_content( |
| 87 | 2x |
tags$div( |
| 88 | 2x |
class = "ui mini steps", |
| 89 | 2x |
lapply(seq_along(steps), function(i) {
|
| 90 | 6x |
tags$div( |
| 91 | 6x |
class = "link step", |
| 92 | 6x |
tags$i( |
| 93 | 6x |
class = paste( |
| 94 | 6x |
if (n_errors == 0 && |
| 95 | 6x |
n_warnings == 0 && |
| 96 | 6x |
n_failed_test == 0) {
|
| 97 | 6x |
"green check" |
| 98 |
} else {
|
|
| 99 | ! |
"red times" |
| 100 |
}, |
|
| 101 | 6x |
"icon" |
| 102 |
) |
|
| 103 |
), |
|
| 104 | 6x |
tags$div( |
| 105 | 6x |
class = "content", |
| 106 | 6x |
tags$div(class = "title", names(steps)[[i]]), |
| 107 | 6x |
tags$div( |
| 108 | 6x |
class = "description", |
| 109 | 6x |
style="white-space: pre-line;", |
| 110 | 6x |
if (names(steps)[[i]] == "Checking") {
|
| 111 | 2x |
tagList( |
| 112 | 2x |
create_message_div(check_res$errors), |
| 113 | 2x |
create_message_div(check_res$warnings), |
| 114 | 2x |
create_message_div(check_res$notes) |
| 115 |
) |
|
| 116 |
} else {
|
|
| 117 | 4x |
steps[[i]] |
| 118 |
} |
|
| 119 |
) |
|
| 120 |
) |
|
| 121 |
) |
|
| 122 |
}) |
|
| 123 |
), |
|
| 124 | 2x |
tab_name = "check", |
| 125 | 2x |
title = "Project check" |
| 126 |
) |
|
| 127 | ||
| 128 | 2x |
list( |
| 129 | 2x |
package_name = check_res$package, |
| 130 | 2x |
package_version = check_res$version, |
| 131 | 2x |
tab_package_check = package_check_tab_ui |
| 132 |
) |
|
| 133 |
} |
|
| 134 | ||
| 135 |
#' Create message container |
|
| 136 |
#' |
|
| 137 |
#' Useful to display R CMD check elements like errors |
|
| 138 |
#' |
|
| 139 |
#' @param el Element such as list of errors, warnings... |
|
| 140 |
#' |
|
| 141 |
#' @keywords internal |
|
| 142 |
create_message_div <- function(el) {
|
|
| 143 | 6x |
tmp_type <- as.character(substitute(el))[3] |
| 144 | 6x |
color <- switch(tmp_type, |
| 145 | 6x |
"errors" = "red", |
| 146 | 6x |
"warnings" = "orange", |
| 147 | 6x |
"notes" = "blue" |
| 148 |
) |
|
| 149 | 6x |
if (length(el) > 0) {
|
| 150 | ! |
lapply(seq_along(el), function(i) {
|
| 151 | ! |
tags$div( |
| 152 | ! |
class = sprintf("ui %s message", color),
|
| 153 | ! |
el[[i]] |
| 154 |
) |
|
| 155 |
}) |
|
| 156 |
} |
|
| 157 |
} |
| 1 |
find_pkg_suggests <- function(path = "./DESCRIPTION") {
|
|
| 2 | 3x |
desc <- readLines(path) |
| 3 | 3x |
suggests_start <- grep("^Suggests: (.*)", desc)
|
| 4 | 3x |
if (length(suggests_start) == 0) {
|
| 5 | 1x |
stop("This package does not have any 'Suggests' field.")
|
| 6 |
} |
|
| 7 | 2x |
suggests_end <- NULL |
| 8 | 2x |
for (i in seq_along(desc)) {
|
| 9 | 12x |
if (suggests_start + i <= length(desc)) {
|
| 10 | 10x |
tmp <- grepl(" ", desc[[suggests_start + i]])
|
| 11 | 10x |
if (!tmp) {
|
| 12 | ! |
suggests_end <- i + suggests_start - 1 |
| 13 | ! |
break |
| 14 |
} |
|
| 15 |
} else {
|
|
| 16 | 2x |
suggests_end <- i + suggests_start - 1 |
| 17 | 2x |
break |
| 18 |
} |
|
| 19 |
} |
|
| 20 | 2x |
suggests_start <- suggests_start + 1 |
| 21 | 2x |
trimws(gsub(",", "", desc[suggests_start:suggests_end]))
|
| 22 |
} |
|
| 23 | ||
| 24 |
copy_shiny_app_files <- function() { # nocov start
|
|
| 25 |
if (!dir.exists("R")) dir.create("R")
|
|
| 26 |
file.copy( |
|
| 27 |
from = system.file("tests/app_server.R", package = "shinyValidator"),
|
|
| 28 |
to = "./R/app_server.R" |
|
| 29 |
) |
|
| 30 |
file.copy( |
|
| 31 |
from = system.file("tests/app_ui.R", package = "shinyValidator"),
|
|
| 32 |
to = "./R/app_ui.R" |
|
| 33 |
) |
|
| 34 |
file.copy( |
|
| 35 |
from = system.file("run-app/run_app.R", package = "shinyValidator"),
|
|
| 36 |
to = "./R/run_app.R" |
|
| 37 |
) |
|
| 38 |
file.copy( |
|
| 39 |
from = system.file("tests/utils.R", package = "shinyValidator"),
|
|
| 40 |
to = "./R/utils.R" |
|
| 41 |
) |
|
| 42 |
} # nocov end |
| 1 |
#' Crash test for Shiny app |
|
| 2 |
#' |
|
| 3 |
#' Function required to perform |
|
| 4 |
#' crash test for a Shiny app. Test whether the app starts and |
|
| 5 |
#' whether it stays alive after a series of random clicks (monkey test) |
|
| 6 |
#' or targeted actions. |
|
| 7 |
#' |
|
| 8 |
#' @note If the Shiny app takes time to load, you may pass load_timeout |
|
| 9 |
#' parameter with duration in ms. For instance load_timeout = 5000, |
|
| 10 |
#' would wait 5 seconds. |
|
| 11 |
#' |
|
| 12 |
#' @inheritParams audit_app |
|
| 13 |
#' |
|
| 14 |
#' @return Errors if any of the step fails. |
|
| 15 |
#' |
|
| 16 |
#' @export |
|
| 17 |
run_crash_test <- function(timeout = 5, headless_actions = NULL) {
|
|
| 18 | 2x |
message("\n---- BEGIN CRASH-TEST ---- \n")
|
| 19 | 2x |
bg_app <- start_r_bg(shiny_bg) |
| 20 | 2x |
chrome <- shinytest2::AppDriver$new( |
| 21 | 2x |
"http://127.0.0.1:3515", |
| 22 | 2x |
load_timeout = timeout * 1000 |
| 23 |
) |
|
| 24 |
# By default AppDriver$new waits for shiny to be IDLE 200ms |
|
| 25 |
# after the initial timeout. No need for extra waiting here. |
|
| 26 | 2x |
if (!dir.exists("public/crash-test")) dir.create("public/crash-test", recursive = TRUE)
|
| 27 | 2x |
chrome$get_screenshot("public/crash-test/1-init-crash.png")
|
| 28 | 2x |
run_monkey_test(chrome, headless_actions) |
| 29 | ||
| 30 |
# cleanup ports |
|
| 31 | 2x |
chrome$stop() |
| 32 | 2x |
if (bg_app$is_alive()) bg_app$kill() |
| 33 | ||
| 34 | 2x |
screenshots <- list.files("public/crash-test")
|
| 35 | ||
| 36 | 2x |
create_tab_content( |
| 37 | 2x |
tags$div( |
| 38 | 2x |
class = "ui equal width grid", |
| 39 | 2x |
lapply(screenshots, function(screenshot) {
|
| 40 | 4x |
tags$div( |
| 41 | 4x |
class = "eight wide column", |
| 42 | 4x |
tags$h2(class = "ui header", strsplit(screenshot, "\\.")[[1]][1]), |
| 43 | 4x |
tags$img(src = file.path("./crash-test", screenshot), width="100%")
|
| 44 |
) |
|
| 45 |
}) |
|
| 46 |
), |
|
| 47 | 2x |
tab_name = "crash-test", |
| 48 | 2x |
title = "Crash test" |
| 49 |
) |
|
| 50 |
} |
|
| 51 | ||
| 52 | ||
| 53 |
#' Inject and call gremlins |
|
| 54 |
#' |
|
| 55 |
#' Internally required by \link{run_monkey_test}. Tries
|
|
| 56 |
#' to workaround any proxy settings |
|
| 57 |
#' |
|
| 58 |
#' @inheritParams run_monkey_test |
|
| 59 |
#' @keywords internal |
|
| 60 |
call_gremlins <- function(headless_app, screenshot = TRUE) {
|
|
| 61 | 4x |
message("Injecting gremlins.js script")
|
| 62 | ||
| 63 |
# allows flexibility if running behind proxy |
|
| 64 | 4x |
gremlins_path <- if (dir.exists("inst/shinyValidator-js")) {
|
| 65 | 4x |
"./gremlins/gremlins.min.js" |
| 66 |
} else {
|
|
| 67 | ! |
"https://unpkg.com/gremlins.js" |
| 68 |
} |
|
| 69 | ||
| 70 | 4x |
headless_app$run_js( |
| 71 | 4x |
sprintf("
|
| 72 | 4x |
var s = document.createElement('script');
|
| 73 | 4x |
s.src = '%s'; |
| 74 | 4x |
document.body.appendChild(s); |
| 75 | 4x |
", gremlins_path) |
| 76 |
) |
|
| 77 | ||
| 78 | 4x |
message("Checking gremlins ...")
|
| 79 | 4x |
Sys.sleep(1) |
| 80 | 4x |
check_gremlins <- headless_app$get_js("typeof window.gremlins")
|
| 81 | 4x |
if (check_gremlins == "undefined") {
|
| 82 | ! |
stop("gremlins are not properly injected. Are you behind a proxy?")
|
| 83 |
} |
|
| 84 | ||
| 85 | 4x |
message("Unleashing gremlins ... This runs about 10 seconds.")
|
| 86 | ||
| 87 | 4x |
headless_app$run_js( |
| 88 | 4x |
"gremlins.createHorde({
|
| 89 | 4x |
randomizer: new gremlins.Chance(1234), // repeatable |
| 90 | 4x |
species: [ |
| 91 | 4x |
gremlins.species.clicker(), |
| 92 | 4x |
gremlins.species.toucher(), |
| 93 | 4x |
gremlins.species.formFiller(), |
| 94 | 4x |
gremlins.species.typer() |
| 95 |
], |
|
| 96 | 4x |
mogwais: [gremlins.mogwais.alert(), gremlins.mogwais.gizmo()], |
| 97 | 4x |
strategies: [gremlins.strategies.distribution()] |
| 98 | 4x |
}).unleash().then(() => {
|
| 99 | 4x |
console.log('Gremlins test success')
|
| 100 |
});" |
|
| 101 |
) |
|
| 102 | ||
| 103 | 4x |
if (screenshot) {
|
| 104 | 2x |
Sys.sleep(3) |
| 105 | 2x |
headless_app$get_screenshot("public/crash-test/2-gremlins.png")
|
| 106 |
} |
|
| 107 |
# Wait remaining 7 seconds so that gremlins are over |
|
| 108 | 4x |
Sys.sleep(7) |
| 109 |
} |
|
| 110 | ||
| 111 | ||
| 112 | ||
| 113 |
#' Perform basic monkey testing |
|
| 114 |
#' |
|
| 115 |
#' Internally required by \link{record_app}, \link{run_crash_test}, ... after
|
|
| 116 |
#' the headless connection is opened. |
|
| 117 |
#' |
|
| 118 |
#' @param headless_app Headless app R6 instance. |
|
| 119 |
#' @param screenshot Whether to take screenshot. Defaults to TRUE. |
|
| 120 |
#' @inheritParams run_crash_test |
|
| 121 |
#' @keywords internal |
|
| 122 |
run_monkey_test <- function(headless_app, headless_actions, screenshot = TRUE) {
|
|
| 123 | 4x |
if (is.null(headless_actions)) {
|
| 124 | 4x |
call_gremlins(headless_app, screenshot) |
| 125 |
} else {
|
|
| 126 |
# Allow \n in case headless_actions had multiple lines |
|
| 127 | ! |
headless_actions <- gsub("\n", " ", headless_actions)
|
| 128 | ! |
eval(parse(text = headless_actions)) |
| 129 | ! |
if (screenshot) {
|
| 130 | ! |
headless_app$get_screenshot("public/crash-test/2-gremlins.png")
|
| 131 |
} |
|
| 132 |
} |
|
| 133 |
} |
| 1 |
#' Generate Shiny app reactlog |
|
| 2 |
#' |
|
| 3 |
#' Runs app in subprocess, controls it with headless browser |
|
| 4 |
#' and generate reactlog file for GitLab CI/CD. |
|
| 5 |
#' |
|
| 6 |
#' @inheritParams audit_app |
|
| 7 |
#' |
|
| 8 |
#' @export |
|
| 9 |
upload_reactlog <- function(timeout = 5, headless_actions = NULL) {
|
|
| 10 | 2x |
message("\n---- BEGIN REACTLOG ---- \n")
|
| 11 | 2x |
reactlog_app <- start_r_bg(reactlog_bg) |
| 12 | ||
| 13 | 2x |
chrome <- shinytest2::AppDriver$new( |
| 14 | 2x |
"http://127.0.0.1:3515", |
| 15 | 2x |
load_timeout = timeout * 1000 |
| 16 |
) |
|
| 17 | 2x |
if (!is.null(headless_actions)) {
|
| 18 | ! |
run_monkey_test(chrome, headless_actions, screenshot = FALSE) |
| 19 |
} |
|
| 20 |
# shutdown |
|
| 21 | 2x |
chrome$stop() |
| 22 | 2x |
Sys.sleep(1) # required so that we can get_result() |
| 23 |
# move reactlog artifacts |
|
| 24 | 2x |
process_reactlog(reactlog_app) |
| 25 |
} |
|
| 26 | ||
| 27 | ||
| 28 | ||
| 29 |
#' Extract and process reactlog |
|
| 30 |
#' |
|
| 31 |
#' Used by \link{upload_reactlog}.
|
|
| 32 |
#' |
|
| 33 |
#' @param app Background app. |
|
| 34 |
#' |
|
| 35 |
#' @keywords internal |
|
| 36 |
process_reactlog <- function(app) {
|
|
| 37 | 2x |
appLog <- app$get_result() |
| 38 | 2x |
appLog <- reactlog::reactlog_render(appLog) |
| 39 | 2x |
report_root <- paste(utils::head(strsplit(appLog, "/")[[1]], -1), collapse = "/") |
| 40 | 2x |
tmp <- c(appLog, file.path(report_root, "reactlogAsset")) |
| 41 | ||
| 42 |
# move reactlog artifacts |
|
| 43 | 2x |
system(sprintf("mv %s public/reactlog.html", tmp[1]))
|
| 44 | 2x |
system(sprintf("mv %s public/", tmp[2]))
|
| 45 |
} |
| 1 |
#' Create output HTML tab |
|
| 2 |
#' |
|
| 3 |
#' Useful for \link{validate_outputs}.
|
|
| 4 |
#' |
|
| 5 |
#' @param done Boolean. Internal to \link{validate_outputs}.
|
|
| 6 |
#' |
|
| 7 |
#' @return A shiny HTML tag |
|
| 8 |
#' @keywords internal |
|
| 9 |
create_output_tab <- function(done) {
|
|
| 10 | 3x |
tmp_html <- if (done) {
|
| 11 | ||
| 12 | 1x |
output_snaps <- list.files("public/outputs", pattern = ".html$", full.names = TRUE)
|
| 13 | ||
| 14 | 1x |
tags$div( |
| 15 | 1x |
class = "ui equal width grid", |
| 16 | 1x |
lapply(seq_along(output_snaps), function(i) {
|
| 17 | ! |
tags$div( |
| 18 | ! |
class = "eight wide column", |
| 19 | ! |
tags$iframe( |
| 20 | ! |
src = sprintf("./%s", strsplit(output_snaps[[i]], "public/")[[1]][2]),
|
| 21 | ! |
frameborder = "0", |
| 22 | ! |
scrolling = "yes", |
| 23 | ! |
width = "100%", |
| 24 | ! |
height = "770px" |
| 25 |
) |
|
| 26 |
) |
|
| 27 |
}) |
|
| 28 |
) |
|
| 29 | ||
| 30 |
} else {
|
|
| 31 | 2x |
tags$h1(class = "ui header", "No visual change to review") |
| 32 |
} |
|
| 33 | ||
| 34 | 3x |
create_tab_content( |
| 35 | 3x |
tmp_html, |
| 36 | 3x |
tab_name = "output", |
| 37 | 3x |
title = "Output validation" |
| 38 |
) |
|
| 39 |
} |
|
| 40 | ||
| 41 |
#' Validate plot outputs |
|
| 42 |
#' |
|
| 43 |
#' @return For each snapshot folder found in tests/testthat/_snaps |
|
| 44 |
#' save a standalone htmlwidget html page to be included in the final report. |
|
| 45 |
#' @export |
|
| 46 |
validate_outputs <- function() {
|
|
| 47 | 2x |
message("\n---- BEGIN COMPARE OUTPUTS ---- \n")
|
| 48 | 2x |
if (!dir.exists("tests/testthat/_snaps")) {
|
| 49 | 1x |
stop( |
| 50 | 1x |
"No snapshot folder found. Make sure to use expect_snapshot_file within |
| 51 | 1x |
your testthat unit tests. |
| 52 |
") |
|
| 53 |
} |
|
| 54 | ||
| 55 | 1x |
outputs <- list.dirs("tests/testthat/_snaps", recursive = FALSE)
|
| 56 | 1x |
done <- FALSE |
| 57 | ||
| 58 |
# loop over all existing snapshots |
|
| 59 | 1x |
lapply(outputs, function(output) {
|
| 60 | ||
| 61 | 2x |
tmp_file <- utils::tail(strsplit(output, "/")[[1]], n = 1) |
| 62 | 2x |
old <- grep("^.(?!.*new)", list.files(output), perl = TRUE, value = TRUE)
|
| 63 |
# handle htmlwidget vs plots |
|
| 64 | 2x |
ext <- if (grepl(".svg$", old)) "svg" else "html"
|
| 65 | 2x |
new <- list.files(output, pattern = sprintf("new.%s$", ext))
|
| 66 | ||
| 67 |
# Don't do anything if there is nothing to review |
|
| 68 | 2x |
if (length(new) == 1) {
|
| 69 | ! |
if (!dir.exists("public/outputs")) dir.create("public/outputs")
|
| 70 | ||
| 71 | ! |
target_file <- sprintf("public/outputs/%s-validation.html", tmp_file)
|
| 72 | ||
| 73 | ! |
htmlwidgets::saveWidget( |
| 74 | ! |
diffviewer::visual_diff( |
| 75 | ! |
sprintf("%s/%s", output, old),
|
| 76 | ! |
sprintf("%s/%s", output, new)
|
| 77 |
), |
|
| 78 | ! |
target_file |
| 79 |
) |
|
| 80 | ||
| 81 | ! |
done <<- TRUE |
| 82 | ||
| 83 |
} else {
|
|
| 84 | 2x |
message( |
| 85 | 2x |
sprintf("No visual change to review in the %s folder", output)
|
| 86 |
) |
|
| 87 |
} |
|
| 88 |
}) |
|
| 89 | ||
| 90 |
# Tag to display in the report |
|
| 91 | 1x |
create_output_tab(done) |
| 92 |
} |
| 1 |
#' Edit HTML validation report |
|
| 2 |
#' |
|
| 3 |
#' Useful when items are programmatically generated |
|
| 4 |
#' and the HTML output cannot be determined in advance. |
|
| 5 |
#' |
|
| 6 |
#' @param ... Allow for parameter expansion. |
|
| 7 |
#' @param source Document to edit. Defaults to path to overwrite the |
|
| 8 |
#' existing report. Set to NULL to fallback to the shinyValidator original |
|
| 9 |
#' template. |
|
| 10 |
#' @param path Where to write. Default to the global report in /public. |
|
| 11 |
#' @return Modified HTML report in public dir. |
|
| 12 |
#' @importFrom htmltools renderDocument htmlTemplate |
|
| 13 |
#' @keywords internal |
|
| 14 |
edit_html_report <- function(..., source = path, path = "public/index.html") {
|
|
| 15 | 1x |
edited_report <- renderDocument( |
| 16 | 1x |
htmlTemplate( |
| 17 | 1x |
if (is.null(source)) {
|
| 18 | 1x |
system.file("report/index.html", package = "shinyValidator")
|
| 19 |
} else {
|
|
| 20 | ! |
source |
| 21 |
}, |
|
| 22 |
..., |
|
| 23 | 1x |
document_ = TRUE |
| 24 |
) |
|
| 25 |
) |
|
| 26 | 1x |
writeLines(edited_report, path) |
| 27 |
} |
|
| 28 | ||
| 29 | ||
| 30 |
#' Create a tab content for HTML report |
|
| 31 |
#' |
|
| 32 |
#' Used by \link{check_package}, ...
|
|
| 33 |
#' |
|
| 34 |
#' @param ... Tab content. |
|
| 35 |
#' @param tab_name Unique tab id. Must match that of \link{create_audit_report}.
|
|
| 36 |
#' @param title Tab title. |
|
| 37 |
#' |
|
| 38 |
#' @return An HTML tag representing a tab element content. |
|
| 39 |
#' @keywords internal |
|
| 40 |
#' @importFrom htmltools tags |
|
| 41 |
create_tab_content <- function(..., tab_name, title) {
|
|
| 42 | 14x |
tags$div( |
| 43 | 14x |
class = "ui tab", |
| 44 | 14x |
`data-tab` = tab_name, |
| 45 | 14x |
tags$div( |
| 46 | 14x |
class = "ui container", |
| 47 | 14x |
style = "margin: auto; width: 85%;", |
| 48 | 14x |
tags$div( |
| 49 | 14x |
class = "ui raised tall stacked segment", |
| 50 | 14x |
tags$a( |
| 51 | 14x |
class = "ui primary right ribbon label", |
| 52 | 14x |
title, |
| 53 | 14x |
tags$i(class = "question circle icon") |
| 54 |
), |
|
| 55 |
... |
|
| 56 |
) |
|
| 57 |
) |
|
| 58 |
) |
|
| 59 |
} |
|
| 60 | ||
| 61 |
#' Create a tab menu |
|
| 62 |
#' |
|
| 63 |
#' Useful inside \link{create_audit_report}.
|
|
| 64 |
#' |
|
| 65 |
#' @param steps Audit steps: coverage, output validation, load testing, ... |
|
| 66 |
#' @param pkg_name Audited package name. |
|
| 67 |
#' @param pkg_version Audited package version. |
|
| 68 |
#' |
|
| 69 |
#' @return A shiny HTML tag |
|
| 70 |
#' @keywords internal |
|
| 71 |
create_tabs_menu <- function(steps, pkg_name, pkg_version) {
|
|
| 72 | 2x |
tags$div( |
| 73 | 2x |
class = "ui pointing menu", |
| 74 | 2x |
lapply(seq_along(steps), function(i) {
|
| 75 | 10x |
tags$a(class = "item", `data-tab` = steps[[i]], names(steps)[[i]]) |
| 76 |
}), |
|
| 77 | 2x |
tags$div( |
| 78 | 2x |
class = "right menu", |
| 79 | 2x |
tags$a(class = "ui item", pkg_name), |
| 80 | 2x |
tags$a(class = "ui tag label", pkg_version) |
| 81 |
) |
|
| 82 |
) |
|
| 83 |
} |
|
| 84 | ||
| 85 |
#' Helper to process audit steps |
|
| 86 |
#' |
|
| 87 |
#' Useful for \link{create_audit_report}.
|
|
| 88 |
#' |
|
| 89 |
#' @inheritParams use_validator |
|
| 90 |
#' |
|
| 91 |
#' @return A list composed of dynamic and static steps. |
|
| 92 |
#' @keywords internal |
|
| 93 |
create_report_steps <- function( |
|
| 94 |
output_validation = TRUE, |
|
| 95 |
coverage = TRUE, |
|
| 96 |
load_testing = TRUE, |
|
| 97 |
profile_code = TRUE, |
|
| 98 |
check_reactivity = TRUE, |
|
| 99 |
flow = TRUE |
|
| 100 |
) {
|
|
| 101 |
# Steps that are programmatically generated |
|
| 102 | 9x |
dynamic_steps <- c( |
| 103 | 9x |
"Package check" = "check", |
| 104 | 9x |
"Crash test" = "crash-test", |
| 105 | 9x |
"Output validation" = if (output_validation) "output" else NULL |
| 106 |
) |
|
| 107 | ||
| 108 |
# Steps for which report is just iframe |
|
| 109 | 9x |
static_steps <- c( |
| 110 | 9x |
"Coverage" = if (coverage) "coverage" else NULL, |
| 111 | 9x |
"Load test" = if (load_testing) "load-test" else NULL, |
| 112 | 9x |
"Reactivity" = if (check_reactivity) "reactlog" else NULL, |
| 113 | 9x |
"Code profile" = if (profile_code) "code-profile" else NULL, |
| 114 | 9x |
"Project structure" = if (flow) "flow" else NULL |
| 115 |
) |
|
| 116 | ||
| 117 | 9x |
list(dynamic_steps, static_steps) |
| 118 |
} |
|
| 119 | ||
| 120 | ||
| 121 |
#' Create static tabs |
|
| 122 |
#' |
|
| 123 |
#' Useful in \link{create_audit_report}.
|
|
| 124 |
#' |
|
| 125 |
#' @param static_steps Provided by \link{create_report_steps}.
|
|
| 126 |
#' |
|
| 127 |
#' @return A shiny html tag |
|
| 128 |
#' @keywords internal |
|
| 129 |
create_static_tabs <- function(static_steps) {
|
|
| 130 | 2x |
lapply(seq_along(static_steps), function(i) {
|
| 131 | 6x |
create_tab_content( |
| 132 | 6x |
if (static_steps[[i]] == "code-profile") tags$br(), |
| 133 | 6x |
tags$iframe( |
| 134 | 6x |
src = sprintf("./%s.html", static_steps[[i]]),
|
| 135 | 6x |
frameborder = "0", |
| 136 | 6x |
scrolling = "yes", |
| 137 | 6x |
width = "100%", |
| 138 | 6x |
height = "770px" |
| 139 |
), |
|
| 140 | 6x |
tab_name = static_steps[[i]], |
| 141 | 6x |
title = names(static_steps)[[i]] |
| 142 |
) |
|
| 143 |
}) |
|
| 144 |
} |
|
| 145 | ||
| 146 |
#' Inject JS code |
|
| 147 |
#' |
|
| 148 |
#' Necessary to control the report interactivity |
|
| 149 |
#' |
|
| 150 |
#' @param steps CI/CD steps |
|
| 151 |
#' |
|
| 152 |
#' @return An HTML tag containing the script to be inserted in the report. |
|
| 153 |
#' @keywords internal |
|
| 154 |
inject_js_helpers <- function(steps) {
|
|
| 155 | ||
| 156 | 2x |
help_scripts <- paste( |
| 157 | 2x |
vapply(seq_along(steps), function(i) {
|
| 158 | 10x |
sprintf( |
| 159 | 10x |
"$('[data-tab=\"%s\"] .ribbon')
|
| 160 | 10x |
.popup({
|
| 161 | 10x |
title : '%s', |
| 162 | 10x |
content : '%s' |
| 163 |
});", |
|
| 164 | 10x |
steps[[i]], |
| 165 | 10x |
names(steps)[[i]], |
| 166 | 10x |
gsub("\n ", "", steps_doc[[names(steps)[[i]]]])
|
| 167 |
) |
|
| 168 | 2x |
}, FUN.VALUE = character(1)), |
| 169 | 2x |
collapse = "\n" |
| 170 |
) |
|
| 171 | ||
| 172 | 2x |
tags$script( |
| 173 | 2x |
HTML( |
| 174 | 2x |
sprintf( |
| 175 | 2x |
"$('.pointing.menu .item').tab(); // activate navigation
|
| 176 | 2x |
$('.ui.basic.modal')
|
| 177 | 2x |
.modal({
|
| 178 | 2x |
blurring: true |
| 179 |
}) |
|
| 180 | 2x |
.modal('show');
|
| 181 | 2x |
%s |
| 182 |
", |
|
| 183 | 2x |
help_scripts |
| 184 |
) |
|
| 185 |
) |
|
| 186 |
) |
|
| 187 |
} |
|
| 188 | ||
| 189 |
# Maybe needs to be automated later ... |
|
| 190 |
steps_doc <- c( |
|
| 191 |
"Package check" = "Package is first built. Any error is shown in case the |
|
| 192 |
installation fails. Package is checked for consistency |
|
| 193 |
(style, files, ...). Unit tests are run. Overall you want |
|
| 194 |
to see all green.", |
|
| 195 |
"Crash test" = "Crash test ensures the app starts and takes a snapshot |
|
| 196 |
when the app is loaded. Then, the app is manipulated with headless |
|
| 197 |
browser and check for alive. Another snapshot is taken.", |
|
| 198 |
"Output validation" = "The report shows noticed output differences found during unit testing. |
|
| 199 |
It is the business user responsibility to decide whether to accept the |
|
| 200 |
difference(s).", |
|
| 201 |
"Coverage" = "Code coverage shows the amounts of code covered by unit tests. |
|
| 202 |
The higher the coverage, the higher the package reliability |
|
| 203 |
(assuming relevant unit tests).", |
|
| 204 |
"Load test" = "Load testing consists of checking whether the app can support multiple |
|
| 205 |
simultaneous sessions with reasonable performances. |
|
| 206 |
If you see a lot of blue area in the session tab, the app is |
|
| 207 |
likely not very well optimized.", |
|
| 208 |
"Reactivity" = "{reactlog} represents the reactive graph of the Shiny app.
|
|
| 209 |
It is useful to identify and fix reactivity-related issues.", |
|
| 210 |
"Code profile" = "{profvis} runs the app and returns the time taken by the R code
|
|
| 211 |
to finish. The lower the result, the higher the performances. |
|
| 212 |
It is useful to identify bottlenecks.", |
|
| 213 |
"Project structure" = "{flow} displays the overall project structure."
|
|
| 214 |
) |
|
| 215 | ||
| 216 |
globalVariables("steps_doc")
|
|
| 217 | ||
| 218 |
#' Create a tab menu for HTML report |
|
| 219 |
#' |
|
| 220 |
#' @inheritParams use_validator |
|
| 221 |
#' @param ... To pass extra parameters to \link{edit_html_report}.
|
|
| 222 |
#' |
|
| 223 |
#' @return A tab menu used to navigate through \link{create_tab_content}
|
|
| 224 |
#' elements. |
|
| 225 |
#' @keywords internal |
|
| 226 |
create_audit_report <- function( |
|
| 227 |
output_validation = TRUE, |
|
| 228 |
coverage = TRUE, |
|
| 229 |
load_testing = TRUE, |
|
| 230 |
profile_code = TRUE, |
|
| 231 |
check_reactivity = TRUE, |
|
| 232 |
flow = TRUE, |
|
| 233 |
... |
|
| 234 |
) {
|
|
| 235 | ||
| 236 | 1x |
items <- list(...) |
| 237 | ||
| 238 | 1x |
steps <- create_report_steps( |
| 239 | 1x |
output_validation, |
| 240 | 1x |
coverage, |
| 241 | 1x |
load_testing, |
| 242 | 1x |
profile_code, |
| 243 | 1x |
check_reactivity, |
| 244 | 1x |
flow |
| 245 |
) |
|
| 246 | ||
| 247 |
# Return all steps (dynamic steps + static steps) |
|
| 248 | 1x |
all_steps <- unlist(steps) |
| 249 | 1x |
static_steps <- steps[[2]] |
| 250 | ||
| 251 | 1x |
tabs_menu_tag <- create_tabs_menu(all_steps, items$package_name, items$package_version) |
| 252 | ||
| 253 |
# Setup HTML report (needs source = NULL to start from clean report) |
|
| 254 | 1x |
edit_html_report( |
| 255 | 1x |
source = NULL, |
| 256 | 1x |
tabs_menu = tabs_menu_tag, |
| 257 |
# Inject JS helpers |
|
| 258 | 1x |
js_code = inject_js_helpers(all_steps), |
| 259 |
# passed from top level function (see audit_app) |
|
| 260 |
..., |
|
| 261 |
# Handle tabs that are just iframe (load-test, profile, reactlog, coverage, flow) |
|
| 262 | 1x |
tabs_static = create_static_tabs(static_steps) |
| 263 |
) |
|
| 264 |
} |
| 1 |
#' Record a Shiny session |
|
| 2 |
#' |
|
| 3 |
#' Start Shiny app as local R subprocess. Connect the |
|
| 4 |
#' shinyloadtest recorder and connect Chrome to the recorder. |
|
| 5 |
#' Manipulate the headless Chrome and close connection. shinycannon |
|
| 6 |
#' replays |
|
| 7 |
#' |
|
| 8 |
#' @inheritParams audit_app |
|
| 9 |
#' |
|
| 10 |
#' @export |
|
| 11 |
record_app <- function(timeout = 5, headless_actions = NULL, workers = 5) {
|
|
| 12 | 2x |
message("\n---- BEGIN LOAD-TEST ---- \n")
|
| 13 |
# start app + recorder |
|
| 14 | 2x |
target <- start_r_bg(shiny_bg) |
| 15 | 2x |
recorder <- start_r_bg(recorder_bg) |
| 16 | ||
| 17 |
# start headless chrome (points to recorder!). |
|
| 18 |
# AppDriver also support remote urls. |
|
| 19 | 2x |
chrome <- shinytest2::AppDriver$new( |
| 20 | 2x |
"http://127.0.0.1:8600", |
| 21 | 2x |
load_timeout = timeout * 1000 |
| 22 |
) |
|
| 23 | ||
| 24 | 2x |
run_monkey_test(chrome, headless_actions, screenshot = FALSE) |
| 25 | ||
| 26 |
# clean |
|
| 27 | 2x |
chrome$stop() |
| 28 |
# needed to avoid |
|
| 29 |
# java.lang.IllegalStateException: last event in log not a |
|
| 30 |
# WS_CLOSE (did you close the tab after recording?) |
|
| 31 | 2x |
Sys.sleep(2) |
| 32 | ||
| 33 |
# shinycannon (maybe expose other params later ...) |
|
| 34 | 2x |
target_url <- "http://127.0.0.1:3515" |
| 35 | 2x |
system( |
| 36 | 2x |
sprintf( |
| 37 | 2x |
"shinycannon recording.log %s --workers %s --loaded-duration-minutes 2 --output-dir run1", |
| 38 | 2x |
target_url, workers |
| 39 |
) |
|
| 40 |
) |
|
| 41 | ||
| 42 | 2x |
target$kill() |
| 43 | ||
| 44 |
# Treat data and generate report |
|
| 45 | 2x |
df <- shinyloadtest::load_runs("run1")
|
| 46 | 2x |
shinyloadtest::shinyloadtest_report( |
| 47 | 2x |
df, |
| 48 | 2x |
"public/load-test.html", |
| 49 | 2x |
self_contained = FALSE, |
| 50 | 2x |
open_browser = FALSE |
| 51 |
) |
|
| 52 |
} |
| 1 |
#' Include Shiny app validation tools in your project |
|
| 2 |
#' |
|
| 3 |
#' Setup all necessary tools and requirements to validate Shiny apps project. |
|
| 4 |
#' This function is the first to be called. |
|
| 5 |
#' |
|
| 6 |
#' @param cicd_platform CI/CD engine. GitLab or GitHub Actions. |
|
| 7 |
#' When gitlab-docker is selected, we leverage docker executor. |
|
| 8 |
#' The provided CI/CD template will pull docker image from |
|
| 9 |
#' \url{https://hub.docker.com/repository/docker/divadnojnarg/shinyvalidator-docker},
|
|
| 10 |
#' which provides installation of R, shinycannon, Chrome, necessary to |
|
| 11 |
#' run the pipeline without issue. This is typically the easiest setup since |
|
| 12 |
#' the classic GitLab CI/CD templates assumes that your GitLab runner has |
|
| 13 |
#' everything installed, which is not necessarily the case. |
|
| 14 |
#' |
|
| 15 |
#' @details By default, the package is checked, built and test are run. |
|
| 16 |
#' Also, we quickly check if the Shiny application is able to start and run |
|
| 17 |
#' without crashing. |
|
| 18 |
#' @export |
|
| 19 |
use_validator <- function(cicd_platform = c("github", "gitlab-docker", "gitlab")) {
|
|
| 20 | 5x |
cicd_platform <- match.arg(cicd_platform) |
| 21 |
# setup prerequisites |
|
| 22 | 5x |
check_setup_requirements(cicd_platform) |
| 23 | ||
| 24 |
# CI/CD |
|
| 25 | 1x |
initialize_cicd(cicd_platform) |
| 26 | ||
| 27 |
# Add lintr |
|
| 28 | 1x |
file.copy(system.file("lintr/.lintr", package = "shinyValidator"), ".")
|
| 29 | ||
| 30 |
# Add gremlins.js assets |
|
| 31 | 1x |
add_gremlins_assets() |
| 32 | ||
| 33 |
# Copy R/run_app.R |
|
| 34 | 1x |
copy_app_file() |
| 35 | ||
| 36 |
# treat .Rbuildignore |
|
| 37 | 1x |
edit_buildignore(cicd_platform) |
| 38 | ||
| 39 |
# Add suggested pkgs to DESCRIPTION + install them in renv library |
|
| 40 | 1x |
add_suggested_packages() |
| 41 | ||
| 42 | 1x |
message("Don't forget to call renv::snapshot() and restart R")
|
| 43 |
} |
| 1 |
#' Profile a shiny app |
|
| 2 |
#' |
|
| 3 |
#' Start Shiny + profvis in the background. Chrome connects |
|
| 4 |
#' to the app and then closes to interrupt profvis. |
|
| 5 |
#' The profile report is then saved and exported in the public |
|
| 6 |
#' folder needed for CI/CD. |
|
| 7 |
#' |
|
| 8 |
#' @inheritParams audit_app |
|
| 9 |
#' |
|
| 10 |
#' @return Write a .Rprof file to be reused by CI/CD to publish the report on GitLab pages |
|
| 11 |
#' @export |
|
| 12 |
profile_app <- function(timeout = 5, headless_actions = NULL) {
|
|
| 13 | 2x |
message("\n---- BEGIN CODE PROFILE ---- \n")
|
| 14 | 2x |
prof_app <- start_r_bg(profile_bg) |
| 15 |
# chrome is just needed to trigger onSessionEnded callback from app_server |
|
| 16 | 2x |
chrome <- shinytest2::AppDriver$new( |
| 17 | 2x |
"http://127.0.0.1:3515", |
| 18 | 2x |
load_timeout = timeout * 1000 |
| 19 |
) |
|
| 20 | 2x |
if (!is.null(headless_actions)) {
|
| 21 | ! |
run_monkey_test(chrome, headless_actions, screenshot = FALSE) |
| 22 |
} |
|
| 23 | 2x |
chrome$stop() |
| 24 | 2x |
Sys.sleep(1) # required so that we can get_result() |
| 25 | ||
| 26 | 2x |
htmlwidgets::saveWidget(prof_app$get_result(), "public/code-profile.html") |
| 27 |
} |