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 |
} |