#' @importFrom rlang missing_arg
#' @importFrom lifecycle deprecated
#' @importFrom callr process
NULL
check_cran_deprecated <- function(
cran = deprecated(),
env = rlang::caller_env(),
user_env = rlang::caller_env(2)
) {
if (lifecycle::is_present(cran)) {
lifecycle::deprecate_warn(
"0.3.3",
I("cran ="),
details = "`cran =` is no longer supported as `shinytest2::AppDriver` objects can not be created when CRAN is testing. Please remove this parameter.",
env = env,
user_env = user_env
)
}
}
#' Drive a Shiny application
#'
#' @description
#' This class starts a Shiny app in a new R session, along with \pkg{chromote}'s
#' headless browser that can be used to simulate user actions. This provides
#' a full simulation of a Shiny app so that you can test user interactions
#' with a live app.
#'
#' Methods described below are ordered by perceived popularity.
#' _Expect_ methods are grouped next to their corresponding _get_ methods.
#'
#' @section Vignettes:
#'
#' Please see [Testing in depth](https://rstudio.github.io/shinytest2/articles/in-depth.html)
#' for more details about the different expectation methods.
#'
#' Please see [Robust testing](https://rstudio.github.io/shinytest2/articles/robust.html)
#' for more details about the cost / benefits for each expectation method.
#'
#' @section Test mode:
#'
#' To have your `AppDriver` retrieve values from your Shiny app, be sure to
#' set `shiny::runApp(test.mode = TRUE)` when running your Shiny app.
#'
#' If you are deploying your Shiny app where you do not have control over
#' the call to `shiny::runApp()`, you can set `options(shiny.testmode = TRUE)` in
#' a `.Rprofile` file within your Shiny app directory.
#'
#'
#' @section Start-up failure:
#'
#' If the app throws an error during initialization, the `AppDriver` will
#' be stored in `rlang::last_error()$app`. This allows for the "failure
#' to initialize" to be signaled while also allowing for the `app` to be
#' retrieved after any initialization error has been thrown.
#'
#' @section Exporting reactive values:
#'
#' Reactive values from within your Shiny application can be exported using the
#' method:
#' [`shiny::exportTestValues()`](https://shiny.rstudio.com/reference/shiny/latest/exportTestValues.html).
#' This underutilized method exposes internal values of your app
#' without needing to create a corresponding input value or output value.
#'
#' For example:
#'
#' ```r
#' library(shiny)
#' shiny_app <- shinyApp(
#' fluidPage(
#' h1("Pythagorean theorem"),
#' numericInput("A", "A", 3),
#' numericInput("B", "B", 4),
#' verbatimTextOutput("C"),
#' ),
#' function(input, output) {
#' a_squared <- reactive({ req(input$A); input$A * input$A })
#' b_squared <- reactive({ req(input$B); input$B * input$B })
#' c_squared <- reactive({ a_squared() + b_squared() })
#' c_value <- reactive({ sqrt(c_squared()) })
#' output$C <- renderText({ c_value() })
#'
#' exportTestValues(
#' a_squared = { a_squared() },
#' b_squared = { b_squared() },
#' c_squared = { c_squared() }
#' )
#' }
#' )
#'
#' app <- AppDriver$new(shiny_app)
#'
#' init_vals <- app$get_values()
#' str(init_vals)
#' #> List of 3
#' #> $ input :List of 2
#' #> ..$ A: int 3
#' #> ..$ B: int 4
#' #> $ output:List of 1
#' #> ..$ C: chr "5"
#' #> $ export:List of 3
#' #> ..$ a_squared: int 9
#' #> ..$ b_squared: int 16
#' #> ..$ c_squared: int 25
#' ```
#'
#' These exported test values are only exposed when `shiny::runApp(test.mode = TRUE)`
#' is set. \pkg{shinytest2} sets this variable when running Shiny based app or
#' document.
#'
#' @section \pkg{testthat} wrappers:
#'
#' The two main expectation methods: `$expect_values()` and `$expect_screenshot()`
#' eventually wrap [`testthat::expect_snapshot_file()`].
#'
#' Their underlying logic is similar to:
#' ```r
#' skip_on_cran() # AppDriver
#'
#' ## Expect values
#' tmpfile <- tempfile(fileext = ".json")
#' jsonlite::write_json(app$get_values(), tmpfile)
#' expect_snapshot_file(
#' tmpfile,
#' variant = app$get_variant(),
#' compare = testthat::compare_file_text
#' )
#'
#'
#' ## Expect screenshot
#' tmpfile <- tempfile(fileext = ".png")
#' app$get_screenshot(tmpfile)
#' expect_snapshot_file(
#' tmpfile,
#' variant = app$get_variant(),
#' compare = testthat::compare_file_binary
#' )
#' ```
#'
#' To update the snapshot values, you will need to run a variation of
#' [`testthat::snapshot_review()`].
#'
#'
#' @param ... Must be empty. Allows for parameter expansion.
#' @param timeout Amount of time to wait before giving up (milliseconds).
#' Defaults to the resolved `timeout` value during the `AppDriver` initialization.
#' @param timeout_ Amount of time to wait before giving up (milliseconds).
#' Defaults to the resolved `timeout` value during the `AppDriver` initialization.
#' @param cran Deprecated. With `AppDriver` never testing on CRAN,
#' this parameter no longer has any effect.
#' @param wait_ Wait until all reactive updates have completed?
#' @param hash_images If `TRUE`, images will be hashed before being returned.
#' Otherwise, all images will return their full data64 encoded value.
#' @param screenshot_args This named list of arguments is passed along to
#' [`chromote::ChromoteSession`]'s `$get_screenshot()` method. If missing, the
#' value will default to `$new(screenshot_args=)`.
#'
#' If `screenshot_args` is:
#' * `TRUE`: A screenshot of the browser's scrollable area will be taken with no delay
#' * A named list of arguments: Arguments passed directly to
#' [`chromote::ChromoteSession`]'s `$get_screenshot()` method. The `delay`
#' argument will default to `0` seconds. The `selector` argument can take two
#' special values in addition to being a CSS DOM selector.
#'
#' * `"scrollable_area"` (default): The entire scrollable area will be
#' captured. Typically this is your browser's viewport size, but it can be
#' larger if the page is scrollable. This value works well with Apps that
#' contain elements whose calculated dimensions may be different than
#' their presented size.
#'
#' * `"viewport"`: This value will capture the browser's viewport in its
#' current viewing location, height, and width. It will only capture
#' what is currently being seen with `$view()`.
#'
#' In `v0.3.0`, the default `selector` value was changed from the HTML DOM
#' selector (`"html"`) to entire scrollable area (`"scrollable_area"`).
#'
#' If `screenshot_args=FALSE` is provided, the parameter will be ignored and a
#' screenshot will be taken with default behavior.
#' @param delay The number of **seconds** to wait before taking the screenshot.
#' This value can be supplied as `delay` or `screenshot_args$delay`, with the
#' `delay` parameter having preference.
#' @param selector The selector is a CSS selector that will be used to select a
#' portion of the page to be captured. This value can be supplied as
#' `selector` or `screenshot_args$selector`, with the `selector` parameter
#' having preference.
#'
#' In `v0.3.0`, two special `selector` values were added:
#' * `"scrollable_area"` (default): The entire scrollable area will be
#' captured. Typically this is your browser's viewport size, but it can be
#' larger if the page is scrollable. This value works well with Apps that
#' contain elements whose calculated dimensions may be different than their
#' presented size.
#'
#' * `"viewport"`: This value will capture the browser's viewport in its
#' current viewing location, height, and width. It will only capture
#' what is currently being seen with `$view()`.
#'
#' In `v0.3.0`, the default `selector` value was changed from the HTML DOM
#' selector (`"html"`) to entire scrollable area (`"scrollable_area"`).
#' @importFrom R6 R6Class
#' @seealso [`platform_variant()`], [`use_shinytest2_test()`]
#' @export
# nolint start
AppDriver <- R6Class(
# nolint end
"AppDriver",
cloneable = FALSE,
private = list(
chromote_session = "<chromote::ChromoteSession>",
shiny_process = NULL, # `callr::r_bg()` object
shiny_proc_value = NULL, # Output of `private$shiny_process$value()`
counter = "<Count>",
shiny_url = "<Url>",
load_timeout = 15 * 1000, # Time for shiny app to load; ms
timeout = 4 * 1000, # Default timeout value; ms
logs = list(), # List of all log messages added via `$log_message()`
clean_logs = TRUE, # Whether to clean logs when GC'd
name = NULL, # character / NULL
variant = "missing_value()", # character / NULL
state = "stopped", # "stopped" or "running"
shiny_worker_id = NA_character_,
dir = NULL, # Full dir path to app. No file path included
save_dir = NULL, # Temp folder to store snapshot outputs
default_screenshot_args = "missing_value()", # Default screenshot args to use
default_expect_values_screenshot_args = TRUE, # Should `$expect_values()` expect a non-failing screenshot?
shiny_test_url = NULL, # URL for shiny's test API
finalize = function() {
app_finalize(self, private)
}
),
public = list(
#' @description
#' Initialize an `AppDriver` object
#'
#' @param app_dir This value can be many different things:
#' * A directory containing your Shiny application or a run-time Shiny R
#' Markdown document.
#' * A URL pointing to your shiny application. (Don't forget to set
#' `testmode = TRUE` when running your application!)
#' * A Shiny application object which inherits from `"shiny.appobj"`.
#'
#' By default, `app_dir` is set to `test_path("../../")` to work in both
#' interactive and testing usage.
#'
#' If a file path is not provided to `app_dir`, snapshots will be saved as
#' if the root of the Shiny application was the current directory.
#'
#' @param name Prefix value to use when saving testthat snapshot files. Ex:
#' `NAME-001.json`. Name **must** be unique when saving multiple snapshots
#' from within the same testing file. Otherwise, two different `AppDriver`
#' objects will be referencing the same files.
#' @param variant
#' If not-`NULL`, results will be saved in
#' `_snaps/{variant}/{test.md}`, so `variant` must be a single
#' string of alphanumeric characters suitable for use as a
#' directory name.
#'
#' You can variants to deal with cases where the snapshot output
#' varies and you want to capture and test the variations.
#' Common use cases include variations for operating system, R
#' version, or version of key dependency. For example usage,
#' see [`platform_variant()`].
#' @param seed An optional random seed to use before starting the application.
#' For apps that use R's random number generator, this can make their
#' behavior repeatable.
#' @param load_timeout How long to wait for the app to load, in ms.
#' This includes the time to start R. Defaults to 15s.
#'
#' If `load_timeout` is missing, the first numeric value found will be used:
#' * R option `options(shinytest2.load_timeout=)`
#' * System environment variable `SHINYTEST2_LOAD_TIMEOUT`
#' * `15 * 1000` (15 seconds)
#' @param timeout Default number of milliseconds for any `timeout` or
#' `timeout_` parameter in the `AppDriver` class. Defaults to 4s.
#'
#' If `timeout` is missing, the first numeric value found will be used:
#' * R option `options(shinytest2.timeout=)`
#' * System environment variable `SHINYTEST2_TIMEOUT`
#' * `4 * 1000` (4 seconds)
#' @param wait If `TRUE`, `$wait_for_idle(duration = 200, timeout = load_timeout)`
#' will be called once the app has connected a new session, blocking until the
#' Shiny app is idle for 200ms.
#' @param screenshot_args Default set of arguments to pass in to
#' [`chromote::ChromoteSession`]'s `$get_screenshot()` method when taking
#' screenshots within `$expect_screenshot()`. To disable screenshots by
#' default, set to `FALSE`.
#' @param expect_values_screenshot_args The value for `screenshot_args` when
#' producing a debug screenshot for `$expect_values()`. To disable debug
#' screenshots by default, set to `FALSE`.
#' @param check_names Check if widget names are unique once the application
#' initially loads? If duplicate names are found on initialization, a
#' warning will be displayed.
#' @param view Opens the [`chromote::ChromoteSession`] in an interactive browser tab
#' before attempting to navigate to the Shiny app.
#' @param height,width Window size to use when opening the
#' [`chromote::ChromoteSession`]. Both `height` and `width` values must be non-null
#' values to be used.
#' @param clean_logs Whether to remove the `stdout` and `stderr` Shiny app
#' logs when the `AppDriver` object is garbage collected.
#' @param shiny_args A list of options to pass to [shiny::runApp()]. Ex:
#' `list(port = 8080)`.
#' @param render_args Passed to `rmarkdown::run(render_args=)` for
#' interactive `.Rmd`s. Ex: `list(quiet = TRUE)`
#' @param options A list of [base::options()] to set in the Shiny
#' application's child R process. See [`shiny::shinyOptions()`] for
#' inspiration. If `shiny.trace = TRUE`, then all WebSocket traffic will
#' be captured by `chromote` and time-stamped for logging purposes.
#' @return An object with class `AppDriver` and the many methods described in this documentation.
#' @examples
#' \dontrun{
#' # Create an AppDriver from the Shiny app in the current directory
#' app <- AppDriver()
#'
#' # Create an AppDriver object from a different Shiny app directory
#' example_app <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver(example_app)
#'
#' # Expect consistent inital values
#' app$expect_values()
#' }
initialize = function(
app_dir = testthat::test_path("../../"),
...,
name = NULL,
variant = missing_arg(),
seed = NULL,
load_timeout = missing_arg(),
timeout = missing_arg(),
wait = TRUE,
screenshot_args = missing_arg(),
expect_values_screenshot_args = TRUE,
check_names = TRUE,
view = missing_arg(),
height = NULL,
width = NULL,
clean_logs = TRUE,
shiny_args = list(),
render_args = NULL,
options = list()
) {
app_initialize(
self,
private,
app_dir = app_dir,
...,
load_timeout = load_timeout,
timeout = timeout,
wait = wait,
expect_values_screenshot_args = expect_values_screenshot_args,
screenshot_args = screenshot_args,
check_names = check_names,
name = name,
variant = variant,
view = view,
height = height,
width = width,
seed = seed,
clean_logs = clean_logs,
shiny_args = shiny_args,
render_args = render_args,
options = options
)
},
#' @description
#' View the Shiny application
#'
#' Calls `$view()` on the [`chromote::ChromoteSession`] object to _view_ your Shiny
#' application in a Chrome browser.
#'
#' This method is very helpful for debugging while writing your tests.
#' @examples
#' \dontrun{
#' # Open app in Chrome
#' app$view()
#' }
view = function() {
app_view(self, private)
},
#' @description Click an element
#'
#' Find a Shiny input/output value or DOM CSS selector and click it using
#' the [DOM method
#' `TAG.click()`](https://www.w3schools.com/jsref/met_html_click.asp).
#'
#' This method can be used to click input buttons and other elements that
#' need to simulate a click action.
#'
#' @param input,output,selector A name of an Shiny `input`/`output` value or
#' a DOM CSS selector. Only one of these may be used.
#' @param ... If `input` is used, all extra arguments are passed to
#' `$set_inputs(!!input := "click", ...)`. This means that the
#' `AppDriver` will wait until an output has been updated within the
#' specified `timeout_`. When clicking any other content, `...` must be empty.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/07_widgets", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' tmpfile <- write.csv(cars, "cars.csv")
#' app$upload_file(file1 = tmpfile)
#' cat(app$get_text("#view"))
#' app$set_inputs(dataset = "cars", obs = 6)
#' app$click("update")
#' cat(app$get_text("#view"))
#' }
click = function(
input = missing_arg(),
output = missing_arg(),
selector = missing_arg(),
...
) {
app_click(
self,
private,
input = input,
output = output,
selector = selector,
...
)
},
#' @description Set input values
#'
#' Set Shiny inputs by sending the value to the Chrome browser and
#' programmatically updating the values. Given `wait_ = TRUE`, the method will
#' not return until an output value has been updated.
#'
#' @param ... Name-value pairs, `component_name_1 = value_1, component_name_2 = value_2` etc.
#' Input with name `component_name_1` will be assigned value `value_1`.
#' @param allow_no_input_binding_ When setting the value of an input, allow
#' it to set the value of an input even if that input does not have an
#' input binding. This is useful to replicate behavior like hovering over
#' a \pkg{plotly} plot.
#' @param priority_ Sets the event priority. For expert use only: see
#' <https://shiny.rstudio.com/articles/communicating-with-js.html#values-vs-events> for details.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/07_widgets", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' cat(app$get_text("#view"))
#' app$set_inputs(dataset = "cars", obs = 6)
#' app$click("update")
#' cat(app$get_text("#view"))
#' }
set_inputs = function(
...,
wait_ = TRUE,
timeout_ = missing_arg(),
allow_no_input_binding_ = FALSE,
priority_ = c("input", "event")
) {
app_set_inputs(
self,
private,
...,
wait_ = wait_,
timeout_ = timeout_,
allow_no_input_binding_ = allow_no_input_binding_,
priority_ = priority_
)
},
#' @description Upload a file
#'
#' Uploads a file to the specified file input.
#' @param ... Name-path pair, e.g. `component_name = file_path`. The file located at
#' `file_path` will be uploaded to file input with name `component_name`.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/09_upload", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' # Save example file
#' tmpfile <- tempfile(fileext = ".csv")
#' write.csv(cars, tmpfile, row.names = FALSE)
#'
#' # Upload file to input named: file1
#' app$upload_file(file1 = tmpfile)
#' }
upload_file = function(..., wait_ = TRUE, timeout_ = missing_arg()) {
app_upload_file(self, private, ..., wait_ = wait_, timeout_ = timeout_)
},
#' @description
#' Expect `input`, `output`, and `export` values
#'
#' A JSON snapshot is saved of given the results from the underlying call to `$get_values()`.
#'
#' Note, values that contain environments or other values that will have
#' trouble serializing may not work well. Instead, these objects should be
#' manually inspected and have their components tested individually.
#'
#' Please see [Robust testing](https://rstudio.github.io/shinytest2/articles/robust.html) for more details.
#'
#' @param name The file name to be used for the snapshot. The file extension
#' will be overwritten to `.json`. By default, the `name` supplied to
#' `app` on initialization with a counter will be used (e.g. `"NAME-001.json"`).
#' @param input,output,export
#' Depending on which parameters are supplied, different return values can occur:
#' * If `input`, `output`, and `export` are all missing, then all values are included in the snapshot.
#' * If at least one `input`, `output`, or `export` is specified, then only the requested values are included in the snapshot.
#'
#' The values supplied to each variable can be:
#' * A character vector of specific names to only include in the snapshot.
#' * `TRUE` to request that all values of that type are included in the snapshot.
#' * Anything else (e.g. `NULL` or `FALSE`) will result in the parameter being ignored.
#' @param screenshot_args This value is passed along to
#' `$expect_screenshot()` where the resulting snapshot expectation is ignored. If
#' missing, the default value will be
#' `$new(expect_values_screenshot_args=)`.
#'
#' The final value can either be:
#' * `TRUE`: A screenshot of the browser's scrollable area will be taken with no delay
#' * `FALSE`: No screenshot will be taken
#' * A named list of arguments. These arguments are passed directly to
#' [`chromote::ChromoteSession`]'s `$get_screenshot()` method. The `selector`
#' and `delay` will default to `"html"` and `0` respectively.
#' @return The result of the snapshot expectation
#' @examples
#' \dontrun{
#' library(shiny)
#' shiny_app <- shinyApp(
#' fluidPage(
#' h1("Pythagorean theorem"),
#' numericInput("A", "A", 3),
#' numericInput("B", "B", 4),
#' verbatimTextOutput("C"),
#' ),
#' function(input, output) {
#' a_squared <- reactive({ req(input$A); input$A * input$A })
#' b_squared <- reactive({ req(input$B); input$B * input$B })
#' c_squared <- reactive({ a_squared() + b_squared() })
#' c_value <- reactive({ sqrt(c_squared()) })
#' output$C <- renderText({ c_value() })
#'
#' exportTestValues(
#' a_squared = { a_squared() },
#' b_squared = { b_squared() },
#' c_squared = { c_squared() }
#' )
#' }
#' )
#'
#' app <- AppDriver$new(shiny_app)
#'
#' # Snapshot all known values
#' app$expect_values()
#'
#' # Snapshot only `export` values
#' app$expect_values(export = TRUE)
#'
#' # Snapshot values `"A"` from `input` and `"C"` from `output`
#' app$expect_values(input = "A", output = "C")
#' }
expect_values = function(
...,
input = missing_arg(),
output = missing_arg(),
export = missing_arg(),
screenshot_args = missing_arg(),
name = NULL,
cran = deprecated()
) {
check_cran_deprecated(cran)
app_expect_values(
self,
private,
...,
input = input,
output = output,
export = export,
screenshot_args = screenshot_args,
name = name,
)
},
#' @description
#' Get a single `input`, `output`, or `export` value
#'
#' This is a helper function around `$get_values()` to retrieve a single
#' `input`, `output`, or `export` value. Only a single `input`, `output`, or
#' `export` value can be used.
#'
#' Note, values that contain environments or other values that will have
#' trouble serializing to RDS may not work well.
#'
#' @param input,output,export One of these variable should contain a single
#' string value. If more than one value is specified or no values are
#' specified, an error will be thrown.
#' @return The requested `input`, `output`, or `export` value.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/04_mpg", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' # Retrieve a single value
#' app$get_value(output = "caption")
#' #> [1] "mpg ~ cyl"
#' # Equivalent code using `$get_values()`
#' app$get_values(output = "caption")$output$caption
#' #> [1] "mpg ~ cyl"
#' }
get_value = function(
...,
input = missing_arg(),
output = missing_arg(),
export = missing_arg(),
hash_images = FALSE
) {
app_get_value(
self,
private,
...,
input = input,
output = output,
export = export,
hash_images = hash_images
)
},
#' @description
#' Get `input`, `output`, and `export` values
#'
#' Retrieves a list of all known `input`, `output`, or `export` values. This
#' method is a core method when inspecting your Shiny app.
#'
#' Note, values that contain environments or other values that will have
#' trouble serializing may not work well.
#'
#' @param input,output,export
#' Depending on which parameters are supplied, different return values can occur:
#' * If `input`, `output`, and `export` are all missing, then all values are included in the snapshot.
#' * If at least one `input`, `output`, or `export` is specified, then only the requested values are included in the snapshot.
#'
#' The values supplied to each variable can be:
#' * A character vector of specific names to only include in the snapshot.
#' * `TRUE` to request that all values of that type are included in the snapshot.
#' * Anything else (e.g. `NULL` or `FALSE`) will result in the parameter being ignored.
#' @return A named list of all inputs, outputs, and export values.
#' @examples
#' \dontrun{
#' library(shiny)
#' shiny_app <- shinyApp(
#' fluidPage(
#' h1("Pythagorean theorem"),
#' numericInput("A", "A", 3),
#' numericInput("B", "B", 4),
#' verbatimTextOutput("C"),
#' ),
#' function(input, output) {
#' a_squared <- reactive({ req(input$A); input$A * input$A })
#' b_squared <- reactive({ req(input$B); input$B * input$B })
#' c_squared <- reactive({ a_squared() + b_squared() })
#' c_value <- reactive({ sqrt(c_squared()) })
#' output$C <- renderText({ c_value() })
#'
#' exportTestValues(
#' a_squared = { a_squared() },
#' b_squared = { b_squared() },
#' c_squared = { c_squared() }
#' )
#' }
#' )
#'
#' app <- AppDriver$new(shiny_app)
#'
#' # Show all known values
#' str(app$get_values())
#' #> List of 3
#' #> $ input :List of 2
#' #> ..$ A: int 3
#' #> ..$ B: int 4
#' #> $ output:List of 1
#' #> ..$ C: chr "5"
#' #> $ export:List of 3
#' #> ..$ a_squared: int 9
#' #> ..$ b_squared: int 16
#' #> ..$ c_squared: int 25
#'
#' # Get only `export` values
#' str(app$get_values(export = TRUE))
#' #> List of 1
#' #> $ export:List of 3
#' #> ..$ a_squared: int 9
#' #> ..$ b_squared: int 16
#' #> ..$ c_squared: int 25
#'
#' # Get values `"A"` from `input` and `"C"` from `output`
#' str(app$get_values(input = "A", output = "C"))
#' #> List of 2
#' #> $ input :List of 1
#' #> ..$ A: int 3
#' #> $ output:List of 1
#' #> ..$ C: chr "5"
#' }
get_values = function(
...,
input = missing_arg(),
output = missing_arg(),
export = missing_arg(),
hash_images = FALSE
) {
app_get_values(
self,
private,
input = input,
output = output,
export = export,
...,
hash_images = hash_images
)
},
#' @description
#' Expect a downloadable file
#'
#' Given a [shiny::downloadButton()]/[shiny::downloadLink()] `output` ID, the corresponding
#' file will be downloaded and saved as a snapshot file.
#'
#' @param output `output` ID of [shiny::downloadButton()]/[shiny::downloadLink()]
#' @param name File name to save file to (including file name extension). The default, `NULL`,
#' generates an ascending sequence of names: `001.download`,
#' `002.download`, etc.
#' @param compare This value is passed through to [`testthat::expect_snapshot_file()`].
#' By default it is set to `NULL` which will default to `testthat::compare_file_text` if `name`
#' has extension `.r`, `.R`, `.Rmd`, `.md`, or `.txt`, and otherwise uses
#' `testthat::compare_file_binary`.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/10_download", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' # Save snapshot of rock.csv as 001.download
#' # Save snapshot value of `rock.csv` to capture default file name
#' app$expect_download("downloadData", compare = testthat::compare_file_text)
#' }
expect_download = function(
output,
...,
compare = NULL,
name = NULL,
cran = deprecated()
) {
check_cran_deprecated(cran)
app_expect_download(
self,
private,
output = output,
...,
compare = compare,
name = name
)
},
#' @description
#' Get downloadable file
#'
#' Given a [shiny::downloadButton()]/[shiny::downloadLink()] `output` ID, the corresponding
#' file will be downloaded and saved as a file.
#'
#' @param output `output` ID of [shiny::downloadButton()]/[shiny::downloadLink()]
#' @param filename File path to save the downloaded file to.
#' @return
#' `$get_download()` will return the final save location of the file. This
#' location can change depending on the value of `filename` and response
#' headers.
#'
#' Location logic:
#' * If `filename` is not NULL, `filename` will be returned.
#' * If a [\code{content-disposition} \code{filename}](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) is provided,
#' then a temp file containing this `filename` will be
#' returned.
#' * Otherwise, a temp file ending in `.download` will be returned.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/10_download", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' # Get rock.csv as a tempfile
#' app$get_download("downloadData")
#' #> [1] "/TEMP/PATH/rock.csv"
#'
#' # Get rock.csv as a "./myfile.csv"
#' app$get_download("downloadData", filename = "./myfile.csv")
#' #> [1] "./myfile.csv"
#' }
get_download = function(output, filename = NULL) {
app_get_download(self, private, output = output, filename = filename)
},
#' @description
#' Expect snapshot of UI text
#'
#' `$expect_text()` will extract the text value of all matching elements via
#' `TAG.textContent` and store them in a snapshot file. This method is more
#' robust to internal package change as only the text values will be
#' maintained. Note, this method will not retrieve any `<input />` value's
#' text content, e.g. text inputs or text areas, as the input values are not
#' stored in the live HTML.
#'
#' When possible, use `$expect_text()` over `$expect_html()` to allow
#' package authors room to alter their HTML structures. The resulting array
#' of `TAG.textContent` values found will be stored in a snapshot file.
#'
#' Please see [Robust testing](https://rstudio.github.io/shinytest2/articles/robust.html) for more details.
#'
#' @param selector A DOM CSS selector to be passed into `document.querySelectorAll()`
#' @examples
#' \dontrun{
#' hello_app <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver$new(hello_app)
#'
#' # Make a snapshot of `"Hello Shiny!"`
#' app$expect_text("h2")
#' }
expect_text = function(selector, ..., cran = deprecated()) {
check_cran_deprecated(cran)
app_expect_text(self, private, selector, ...)
},
#' @description
#' Get UI text
#'
#' `$get_text()` will extract the text value of all matching elements via
#' `TAG.textContent`. Note, this method will not retrieve any `<input />`
#' value's text content, e.g. text inputs or text areas, as the input values
#' are not stored in the live HTML.
#'
#' @param selector A DOM CSS selector to be passed into `document.querySelectorAll()`
#' @return A vector of character values
#' @examples
#' \dontrun{
#' hello_app <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver$new(hello_app)
#'
#' app$get_text("h2")
#' #> [1] "Hello Shiny!"
#' }
get_text = function(selector) {
app_get_text(self, private, selector = selector)
},
#' @description Expect snapshot of UI HTML
#'
#' `$expect_html()` will extract the full DOM structures of each matching
#' element and store them in a snapshot file. This method captures internal
#' DOM structure which may be brittle to changes by external authors or
#' dependencies.
#'
#' Note, this method will not retrieve any `<input />` value's
#' text content, e.g. text inputs or text areas, as the input values are not
#' stored in the live HTML.
#'
#' When possible, use `$expect_text()` over `$expect_html()` to allow
#' package authors room to alter their HTML structures. The resulting array
#' of `TAG.textContent` values found will be stored in a snapshot file.
#'
#' Please see [Robust testing](https://rstudio.github.io/shinytest2/articles/robust.html) for more details.
#'
#' @param selector A DOM selector to be passed into `document.querySelectorAll()`
#' @param outer_html If `TRUE` (default), the full DOM structure will be returned (`TAG.outerHTML`).
#' If `FALSE`, the full DOM structure of the child elements will be returned (`TAG.innerHTML`).
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/04_mpg", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' # Save a snapshot of the `caption` output
#' app$expect_html("#caption")
#' }
expect_html = function(
selector,
...,
outer_html = TRUE,
cran = deprecated()
) {
check_cran_deprecated(cran)
app_expect_html(
self,
private,
selector,
...,
outer_html = outer_html
)
},
#' @description Get UI HTML
#'
#' `$get()` will extract the full DOM structures of each matching
#' element. This method captures internal
#' DOM structure which may be brittle to changes by external authors or
#' dependencies.
#'
#' Note, this method will not retrieve any `<input />` value's
#' text content, e.g. text inputs or text areas, as the input values are not
#' stored in the live HTML.
#'
#' Please see [Robust testing](https://rstudio.github.io/shinytest2/articles/robust.html) for more details.
#'
#' @param selector A DOM selector to be passed into `document.querySelectorAll()`
#' @param outer_html If `TRUE`, the full DOM structure will be returned (`TAG.outerHTML`).
#' If `FALSE`, the full DOM structure of the child elements will be returned (`TAG.innerHTML`).
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/03_reactivity", package = "shiny")
#' app <- AppDriver$new(app_path, check_names = FALSE)
#'
#' app$set_inputs(caption = "Custom value!")
#' cat(app$get_html(".shiny-input-container")[1])
#' #> <div class="form-group shiny-input-container">
#' #> <label class="control-label" id="caption-label" for="caption">Caption:</label>
#' #> <input id="caption" type="text" class="form-control shiny-bound-input" value="Data Summary">
#' #> </div>
#' ## ^^ No update to the DOM of `caption`
#' }
get_html = function(selector, ..., outer_html = TRUE) {
app_get_html(self, private, selector, ..., outer_html = outer_html)
},
#' @description
#' Expect snapshot of JavaScript script output
#'
#' This is a building block function that may be called by other functions.
#' For example, `$expect_text()` and `$expect_html()` are thin wrappers around this function.
#'
#' Once the `script` has executed, the JSON result will be saved to a snapshot file.
#'
#' @param script A string containing the JavaScript script to be executed.
#' @param file A file containing JavaScript code to be read and used as the `script`. Only one of `script` or `file` can be specified.
#' @param pre_snapshot A function to be called on the result of the script before taking the snapshot.
#' `$expect_html()` and `$expect_text()` both use [`unlist()`].
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/07_widgets", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' # Track how many clicks are given to `#update` button
#' app$run_js("
#' window.test_counter = 0;
#' $('#update').click(() => window.test_counter++);
#' ")
#' app$set_inputs(obs = 20)
#' # Click the update button, incrementing the counter
#' app$click("update")
#' # Save a snapshot of number of clicks (1)
#' app$expect_js("window.test_counter;")
#' }
expect_js = function(
script = missing_arg(),
...,
file = missing_arg(),
timeout = missing_arg(),
pre_snapshot = NULL,
cran = deprecated()
) {
check_cran_deprecated(cran)
app_expect_js(
self,
private,
script = script,
...,
file = file,
timeout = timeout,
pre_snapshot = pre_snapshot
)
},
#' @description
#' Execute JavaScript code in the browser and return the result
#'
#' This function will block the local R session until the code has finished
#' executing its _tick_ in the browser. If a `Promise` is returned from the
#' script, `$get_js()` will wait for the promise to resolve. To have
#' JavaScript code execute asynchronously, wrap the code in a Promise object
#' and have the script return an atomic value.
#'
#' Arguments will have to be inserted into the script as there is not access
#' to `arguments`. This can be done with commands like `paste()`. If using
#' `glue::glue()`, be sure to use uncommon `.open` and `.close` values to
#' avoid having to double all `{` and `}`.
#' @param script JavaScript to execute. If a JavaScript Promise is returned,
#' the R session will block until the promise has been resolved and return
#' the value.
#' @param file A (local) file containing JavaScript code to be read and used
#' as the `script`. Only one of `script` or `file` can be specified.
#' @return Result of the `script` (or `file` contents)
#' @examples
#' \dontrun{
#' library(shiny)
#' shiny_app <- shinyApp(h1("Empty App"), function(input, output) { })
#' app <- AppDriver$new(shiny_app)
#'
#' # Execute JavaScript code in the app's browser
#' app$get_js("1 + 1;")
#' #> [1] 2
#'
#' # Execute a JavaScript Promise. Return the resolved value.
#' app$get_js("
#' new Promise((resolve) => {
#' setTimeout(() => resolve(1 + 1), 1000)
#' }).
#' then((value) => value + 1);
#' ")
#' #> [1] 3
#'
#' # With escaped arguments
#' loc_field <- "hostname"
#' js_txt <- paste0("window.location[", jsonlite::toJSON(loc_field, auto_unbox = TRUE), "]")
#' app$get_js(js_txt)
#' #> [1] "127.0.0.1"
#'
#' # With `glue::glue()`
#' js_txt <- glue::glue_data(
#' lapply(
#' list(x = 40, y = 2),
#' jsonlite::toJSON,
#' auto_unbox = TRUE
#' ),
#' .open = "<", .close = ">",
#' "let answer = function(a, b) {\n",
#' " return a + b;\n",
#' "};\n",
#' "answer(<x>, <y>);\n"
#' )
#' app$get_js(js_txt)
#' #> [1] 42
#' }
get_js = function(
script = missing_arg(),
...,
file = missing_arg(),
timeout = missing_arg()
) {
app_get_js(
self,
private,
script = script,
...,
file = file,
timeout = timeout
)
},
#' @description
#' Execute JavaScript code in the browser
#'
#' This function will block the local R session until the code has finished
#' executing its _tick_ in the browser.
#'
#' The final result of the code will be ignored and not returned to the R session.
#' @param script JavaScript to execute.
#' @param file A (local) file containing JavaScript code to be read and used
#' as the `script`. Only one of `script` or `file` can be specified.
#' @examples
#' \dontrun{
#' library(shiny)
#' shiny_app <- shinyApp(h1("Empty App"), function(input, output) { })
#' app <- AppDriver$new(shiny_app)
#'
#' # Get JavaScript answer from the app's browser
#' app$get_js("1 + 1")
#' #> [1] 2
#' # Execute JavaScript code in the app's browser
#' app$run_js("1 + 1")
#' # (Returns `app` invisibly)
#'
#' # With escaped arguments
#' loc_field <- "hostname"
#' js_txt <- paste0("window.location[", jsonlite::toJSON(loc_field, auto_unbox = TRUE), "]")
#' app$run_js(js_txt)
#' app$get_js(js_txt)
#' #> [1] "127.0.0.1"
#' }
run_js = function(
script = missing_arg(),
...,
file = missing_arg(),
timeout = missing_arg()
) {
app_run_js(
self,
private,
script = script,
...,
file = file,
timeout = timeout
)
},
#' @description
#' Expect a screenshot of the Shiny application
#'
#' This method takes a screenshot of the application (of only the `selector`
#' area) and compares the image to the expected image.
#'
#' Please be aware that this method is very brittle to changes outside of your Shiny application.
#' These changes can include:
#' * running on a different R version
#' * running on a different in operating system
#' * using a different default system font
#' * using different package versions
#' These differences are explicitly clear when working with plots.
#'
#' Unless absolutely necessary for application consistency, it is strongly
#' recommended to use other expectation methods.
#'
#' @param screenshot_args This named list of arguments is passed along to
#' [`chromote::ChromoteSession`]'s `$get_screenshot()` method. If missing, the
#' value will default to `$new(screenshot_args=)`.
#'
#' If `screenshot_args` is:
#'
#' * `TRUE`: A screenshot of the browser's scrollable area will be taken
#' with no delay
#' * A named list of arguments: Arguments passed directly to
#' [`chromote::ChromoteSession`]'s `$get_screenshot()` method. The
#' `delay` argument will default to `0` seconds. The `selector` argument
#' can take two special values in addition to being a CSS DOM selector.
#'
#' * `"scrollable_area"` (default): The entire scrollable area will be
#' captured. Typically this is your browser's viewport size, but it
#' can be larger if the page is scrollable. This value works well with
#' Apps that contain elements whose calculated dimensions may be
#' different than their presented size.
#'
#' * `"viewport"`: This value will capture the browser's viewport in its
#' current viewing location, height, and width. It will only capture
#' what is currently being seen with `$view()`.
#'
#' In `v0.3.0`, the default `selector` value was changed from the HTML
#' DOM selector (`"html"`) to entire scrollable area
#' (`"scrollable_area"`).
#' @param name The file name to be used for the snapshot. The file extension
#' will overwritten to `.png`. By default, the `name` supplied to
#' `app` on initialization with a counter will be used (e.g. `"NAME-001.png"`).
#' @param compare A function used to compare the screenshot snapshot files.
#' The function should take two inputs, the paths to the `old` and `new`
#' snapshot, and return either `TRUE` or `FALSE`.
#'
#' `compare` defaults to a function that wraps around
#' `compare_screenshot_threshold(old, new, threshold = threshold,
#' kernel_size = kernel_size, quiet = quiet)`. Note: if `threshold` is
#' `NULL` (default), `compare` will behave as if
#' [`testthat::compare_file_binary()`] was provided, comparing the two
#' images byte-by-byte.
#' @param threshold Parameter supplied to [`compare_screenshot_threshold()`]
#' when using the default `compare` method. The default value can be set
#' globally with the `shinytest2.compare_screenshot.threshold` option.
#'
#' If the value of `threshold` is `NULL`,
#' [`compare_screenshot_threshold()`] will act like
#' [`testthat::compare_file_binary`]. However, if `threshold` is a
#' positive number, it will be compared against the largest convolution
#' value found if the two images fail a [`testthat::compare_file_binary`]
#' comparison.
#'
#' Which value should I use? Threshold values values below 5 help deter
#' false-positive screenshot comparisons (such as inconsistent rounded
#' corners). Larger values in the 10s and 100s will help find _real_
#' changes. However, not all values are one size fits all and you will
#' need to play with a threshold that fits your needs.
#' @param kernel_size Parameter supplied to
#' [`compare_screenshot_threshold()`] when using the default `compare`
#' method. The `kernel_size` represents the height and width of the
#' convolution kernel applied to the pixel differences. This integer-like
#' value should be relatively small. The default value can be set globally
#' with the `shinytest2.compare_screenshot.kernel_size` option.
#' @param quiet Parameter supplied to [`compare_screenshot_threshold()`]
#' when using the default `compare` method. If `FALSE`, diagnostic
#' information will be presented when the computed value is larger than a
#' non-`NULL` `threshold` value.
#' @examples
#' \dontrun{
#' # These example lines should be performed in a `./tests/testthat`
#' # test file so that snapshot files can be properly saved
#'
#' app_path <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver$new(app_path, variant = platform_variant())
#'
#' # Expect a full size screenshot to be pixel perfect
#' app$expect_screenshot()
#'
#' # Images are brittle when containing plots
#' app$expect_screenshot(selector = "#distPlot")
#'
#' # Test with more threshold in pixel value differences
#' # Helps with rounded corners
#' app$expect_screenshot(threshold = 10)
#'
#' # Equivalent expectations
#' app$expect_screenshot() # default
#' app$expect_screenshot(threshold = NULL)
#' app$expect_screenshot(compare = testthat::compare_file_binary)
#' expect_snapshot_file(
#' app$get_screenshot(),
#' variant = app$get_variant(),
#' compare = testthat::compare_file_binary
#' )
#'
#' # Equivalent expectations
#' app$expect_screenshot(threshold = 3, kernel_size = 5)
#' app$expect_screenshot(compare = function(old, new) {
#' compare_screenshot_threshold(
#' old, new,
#' threshold = 3,
#' kernel_size = 5
#' )
#' })
#' expect_screenshot_file(
#' app$get_screenshot(),
#' variant = app$get_variant(),
#' compare = function(old, new) {
#' compare_screenshot_threshold(
#' old, new,
#' threshold = 3,
#' kernel_size = 5
#' )
#' }
#' )
#'
#' # Take a screenshot of the entire scrollable area
#' app$expect_screenshot()
#' app$expect_screenshot(selector = "scrollable_area")
#'
#' ## Take a screenshot of the current viewport
#' # Shrink the window to be smaller than the app
#' app$set_window_size(400, 500)
#' # Scroll the viewport just a bit
#' app$run_js("window.scroll(30, 70)")
#' # Take screenshot of browser viewport
#' app$expect_screenshot(selector = "viewport")
#' }
expect_screenshot = function(
...,
threshold = getOption("shinytest2.compare_screenshot.threshold", NULL),
kernel_size = getOption("shinytest2.compare_screenshot.kernel_size", 5),
screenshot_args = missing_arg(),
delay = missing_arg(),
selector = missing_arg(),
compare = missing_arg(),
quiet = FALSE,
name = NULL,
cran = deprecated()
) {
check_cran_deprecated(cran)
app_expect_screenshot_and_variant(
self,
private,
...,
threshold = threshold,
kernel_size = kernel_size,
quiet = quiet,
compare = compare,
screenshot_args = screenshot_args,
delay = delay,
selector = selector,
name = name
)
},
#' @description
#' Take a screenshot
#'
#' Take a screenshot of the Shiny application.
#'
#' @param file If `NULL`, then the image will be displayed to the current
#' Graphics Device. If a file path, then the screenshot will be saved to
#' that file.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' # Display in graphics device
#' app$get_screenshot()
#'
#' # Update bins then display `"disPlot"` in graphics device
#' app$set_inputs(bins = 10)
#' app$get_screenshot(selector = "#distPlot")
#'
#' # Save screenshot to file and view it
#' tmpfile <- tempfile(fileext = ".png")
#' app$get_screenshot(tmpfile)
#' showimage::show_image(tmpfile)
#' }
get_screenshot = function(
file = NULL,
...,
screenshot_args = missing_arg(),
delay = missing_arg(),
selector = missing_arg()
) {
app_get_screenshot(
self,
private,
file = file,
...,
screenshot_args = screenshot_args,
delay = delay,
selector = selector
)
},
#' @description Wait for Shiny to not be busy (idle) for a set amount of time
#'
#' Waits until Shiny has not been busy for a set duration of time, e.g. no
#' reactivity is updating or has occurred.
#'
#' This is useful, for example, when waiting for your application to
#' initialize or if you've resized the window with `$set_window_size()` and
#' want to make sure all plot redrawing is complete before take a
#' screenshot.
#'
#' By default,
#' * `$new(wait = TRUE)` waits for Shiny to not be busy after initializing
#' the application
#' * `$set_window_size(wait = TRUE)` waits for Shiny to not be busy after
#' resizing the window.)
#' @param duration How long Shiny must be idle (in ms) before unblocking the
#' R session.
#' @return `invisible(self)` if Shiny stabilizes within the `timeout`.
#' Otherwise an error will be thrown
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' pre_value <- app$get_value(output = "distPlot")
#' # Update bins value
#' app$set_inputs(bins = 10, wait_ = FALSE)
#' middle_value <- app$get_value(output = "distPlot")
#' app$wait_for_idle()
#' post_value <- app$get_value(output = "distPlot")
#'
#' # No guarantee that these values are different
#' identical(pre_value, middle_value)
#' # Will not be equal
#' identical(pre_value, post_value)
#'
#' # ---------------------
#' ## Change the screen size to trigger a plot update
#' pre_value <- app$get_value(output = "distPlot")
#' app$set_window_size(height = 1080, width = 1920, wait = FALSE)
#' middle_value <- app$get_value(output = "distPlot")
#' app$wait_for_idle()
#' post_value <- app$get_value(output = "distPlot")
#'
#' # No guarantee that these values are different
#' identical(pre_value, middle_value)
#' # Will not be equal
#' identical(pre_value, post_value)
#' }
wait_for_idle = function(duration = 500, timeout = missing_arg()) {
app_wait_for_idle(self, private, duration = duration, timeout = timeout)
},
#' @description Wait for a new Shiny value
#'
#' Waits until an `input`, `output`, or `export` Shiny value is not one of
#' `ignore`d values, or the `timeout` is reached.
#'
#' Only a single `input`, `output`, or `export` value may be used.
#'
#' This function can be useful in helping determine if an application
#' has finished processing a complex reactive situation.
#' @param input,output,export A name of an input, output, or export value.
#' Only one of these parameters may be used.
#' @param ignore List of possible values to ignore when checking for
#' updates.
#' @param timeout_ Amount of time to wait for a new `output` value before giving up (milliseconds).
#' Defaults to the resolved `timeout` value during the `AppDriver` initialization.
#' @param interval How often to check for the condition, in ms.
#' @return Newly found value
#' @examples
#' \dontrun{
#' library(shiny)
#' shiny_app <- shinyApp(
#' fluidPage(
#' h1("Dynamic output"),
#' actionButton("display", "Display UI"),
#' uiOutput("dym1"),
#' ),
#' function(input, output) {
#' output$dym1 <- renderUI({
#' req(input$display)
#' Sys.sleep(runif(1, max = 2)) # Artificial calculations
#' tagList(
#' sliderInput("slider1", "Slider #1", 0, 100, 25),
#' uiOutput("dym2")
#' )
#' })
#' output$dym2 <- renderUI({
#' Sys.sleep(runif(1, max = 2)) # Artificial calculations
#' tagList(
#' sliderInput("slider2", "Slider #2", 0, 100, 50),
#' "Total:", verbatimTextOutput("total")
#' )
#' })
#' output$total <- renderText({
#' req(input$slider1, input$slider2)
#' input$slider1 + input$slider2
#' })
#' }
#' )
#'
#' app <- AppDriver$new(shiny_app)
#'
#' # Create UI / output values
#' app$click("display")
#' # Wait for total to be calculated (or have a non-NULL value)
#' new_total_value <- app$wait_for_value(output = "total")
#' #> [1] "75"
#' app$get_value(output = "total")
#' #> [1] "75"
#' }
wait_for_value = function(
...,
input = missing_arg(),
output = missing_arg(),
export = missing_arg(),
ignore = list(NULL, ""),
timeout = missing_arg(),
interval = 400
) {
app_wait_for_value(
self,
private,
input = input,
output = output,
export = export,
...,
ignore = ignore,
timeout = timeout,
interval = interval
)
},
#' @description Wait for a JavaScript expression to be true
#'
#' Waits until a JavaScript `expr`ession evaluates to `true` or the
#' `timeout` is exceeded.
#'
#' @param script A string containing JavaScript code. This code must
#' eventually return a [`true`thy
#' value](https://developer.mozilla.org/en-US/docs/Glossary/Truthy) or a
#' timeout error will be thrown.
#' @param timeout How long the script has to return a `true`thy value (milliseconds).
#' Defaults to the resolved `timeout` value during the `AppDriver` initialization.
#' @param interval How often to check for the condition (milliseconds).
#' @return `invisible(self)` if expression evaluates to `true` without error
#' within the timeout. Otherwise an error will be thrown
#' @examples
#' \dontrun{
#' shiny_app <- shinyApp(h1("Empty App"), function(input, output) { })
#' app <- AppDriver$new(shiny_app)
#'
#' # Contrived example:
#' # Wait until `Date.now()` returns a number that ends in a 5. (0 - 10 seconds)
#' system.time(
#' app$wait_for_js("Math.floor((Date.now() / 1000) % 10) == 5;")
#' )
#'
#' ## A second example where we run the contents of a JavaScript file
#' ## and use the result to wait for a condition
#' app$run_js(file = "complicated_file.js")
#' app$wait_for_js("complicated_condition();")
#' }
wait_for_js = function(
script,
timeout = missing_arg(),
interval = 100
) {
app_wait_for_js(
self,
private,
script = script,
timeout = timeout,
interval = interval
)
},
#' @description
#' Expect unique input and output names.
#'
#' If the HTML has duplicate input or output elements with matching `id`
#' values, this function will throw an error. It is similar to
#' `AppDriver$new(check_names = TRUE)`, but asserts that no warnings are
#' displayed.
#'
#' This method will not throw if a single input and a single output have the
#' same name.
#' @examples
#' \dontrun{
#' shiny_app <- shinyApp(
#' ui = fluidPage(
#' # Duplicate input IDs: `"text"`
#' textInput("text", "Text 1"),
#' textInput("text", "Text 2")
#' ),
#' server = function(input, output) {
#' # empty
#' }
#' )
#' # Initial checking for unique names (default behavior)
#' app <- AppDriver$new(shiny_app, check_names = TRUE)
#' #> Warning:
#' #> ! Shiny inputs should have unique HTML id values.
#' #> i The following HTML id values are not unique:
#' #> • text
#' app$stop()
#'
#' # Manually assert that all names are unique
#' app <- AppDriver$new(shiny_app, check_names = FALSE)
#' app$expect_unique_names()
#' #> Error: `app_check_unique_names(self, private)` threw an unexpected warning.
#' #> Message: ! Shiny inputs should have unique HTML id values.
#' #> i The following HTML id values are not unique:
#' #> • text
#' #> Class: rlang_warning/warning/condition
#' app$stop()
#' }
expect_unique_names = function() {
app_expect_unique_names(self, private)
},
#' @description
#' Retrieve the Shiny app path
#'
#' @return The directory containing the Shiny application or Shiny runtime
#' document. If a URL was provided to `app_dir` during initialization, the
#' current directory will be returned.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' identical(app$get_dir(), app_path)
#' #> [1] TRUE
#' }
get_dir = function() {
app_get_dir(self, private)
},
#' @description
#' Retrieve the Shiny app URL
#'
#' @return URL where the Shiny app is being hosted
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' browseURL(app$get_url())
#' }
get_url = function() {
private$shiny_url$get()
},
#' @description
#' Get window size
#'
#' Get current size of the browser window, as list of numeric scalars
#' named `width` and `height`.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' app$get_window_size()
#' #> $width
#' #> [1] 992
#' #>
#' #> $height
#' #> [1] 1323
#' }
get_window_size = function() {
app_get_window_size(self, private)
},
#' @description
#' Sets size of the browser window.
#' @param width,height Height and width of browser, in pixels.
#' @param wait If `TRUE`, `$wait_for_idle()` will be called after setting
#' the window size. This will block until any width specific items (such
#' as plots) that need to be re-rendered.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/01_hello", package = "shiny")
#' # Set init window size
#' app <- AppDriver$new(app_path, height = 1400, width = 1000)
#'
#' app$get_window_size()
#' #> $width
#' #> [1] 1000
#' #>
#' #> $height
#' #> [1] 1400
#'
#' # Manually set the window size
#' app$set_window_size(height = 1080, width = 1920)
#' app$get_window_size()
#' #> $width
#' #> [1] 1920
#' #>
#' #> $height
#' #> [1] 1080
#' }
set_window_size = function(width, height, wait = TRUE) {
app_set_window_size(
self,
private,
width = width,
height = height,
wait = wait
)
},
#' @description
#' Get Chromote Session
#'
#' Get the [`chromote::ChromoteSession`] object from the \pkg{chromote} package.
#' @return [`chromote::ChromoteSession`] R6 object
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' b <- app$get_chromote_session()
#' b$Runtime$evaluate("1 + 1")
#' #> $result
#' #> $result$type
#' #> [1] "number"
#' #>
#' #> $result$value
#' #> [1] 2
#' #>
#' #> $result$description
#' #> [1] "2"
#' }
get_chromote_session = function() {
app_get_chromote_session(self, private)
},
#' @description
#' Get the variant
#'
#' Get the `variant` supplied during initialization
#' @return The `variant` value supplied during initialization or `NULL` if
#' no value was supplied.
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/01_hello", package = "shiny")
#'
#' app <- AppDriver$new(app_path)
#'
#' app$get_variant()
#' #> NULL
#'
#' app <- AppDriver$new(app_path, variant = platform_variant())
#' app$get_variant()
#' #> [1] "mac-4.1"
#' }
get_variant = function() {
app_get_variant(self, private)
},
#' @description
#' Get all logs
#'
#' Retrieve all of the debug logs that have been recorded.
#' @return A data.frame with the following columns:
#' * `workerid`: The shiny worker ID found within the browser
#' * `timestamp`: POSIXct timestamp of the message
#' * `location`: The location of the message was found. One of three values:
#' * `"shinytest2"`: Occurs when `$log_message()` is called
#' * `"shiny"`: `stdin` and `stdout` messages from the Shiny server. Note `message()` output is sent to `stdout`.
#' * `"chromote"`: Captured by the \pkg{chromote} event handlers. See
#' [console API](https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/#event-consoleAPICalled),
#' [exception thrown](https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/#event-exceptionThrown),
#' [websocket sent](https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webSocketFrameSent), and
#' [websocket received](https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webSocketFrameReceived)
#' for more details
#' * `level`: For a given location, there are different types of log levels.
#' * `"shinytest2"`: `"log"`; Only log messages are captured.
#' * `"shiny"`: `"stdout"` or `"stderr"`; Note, `message()` output is sent
#' to `stderr`.
#' * `"chromote"`: Correspond to any level of a JavaScript
#' `console.LEVEL()` function call. Typically, these are "log"` and
#' `"error"` but can include `"info"`, `"debug"`, and `"warn"`. If
#' `options(shiny.trace = TRUE)`, then the level will recorded as
#' `"websocket"`.
#' @examples
#' \dontrun{
#' app1 <- AppDriver$new(system.file("examples/01_hello", package = "shiny"))
#'
#' app1$get_logs()
#' #> \{shinytest2\} R info 10:00:28.86 Start AppDriver initialization
#' #> \{shinytest2\} R info 10:00:28.86 Starting Shiny app
#' #> \{shinytest2\} R info 10:00:29.76 Creating new ChromoteSession
#' #> \{shinytest2\} R info 10:00:30.56 Navigating to Shiny app
#' #> \{shinytest2\} R info 10:00:30.70 Injecting shiny-tracer.js
#' #> \{chromote\} JS info 10:00:30.75 shinytest2; jQuery found
#' #> \{chromote\} JS info 10:00:30.77 shinytest2; Waiting for shiny session to connect
#' #> \{chromote\} JS info 10:00:30.77 shinytest2; Loaded
#' #> \{shinytest2\} R info 10:00:30.77 Waiting for Shiny to become ready
#' #> \{chromote\} JS info 10:00:30.90 shinytest2; Connected
#' #> \{chromote\} JS info 10:00:30.95 shinytest2; shiny:busy
#' #> \{shinytest2\} R info 10:00:30.98 Waiting for Shiny to become idle for 200ms within 15000ms
#' #> \{chromote\} JS info 10:00:30.98 shinytest2; Waiting for Shiny to be stable
#' #> \{chromote\} JS info 10:00:31.37 shinytest2; shiny:idle
#' #> \{chromote\} JS info 10:00:31.38 shinytest2; shiny:value distPlot
#' #> \{chromote\} JS info 10:00:31.57 shinytest2; Shiny has been idle for 200ms
#' #> \{shinytest2\} R info 10:00:31.57 Shiny app started
#' #> \{shiny\} R stderr ----------- Loading required package: shiny
#' #> \{shiny\} R stderr ----------- Running application in test mode.
#' #> \{shiny\} R stderr -----------
#' #> \{shiny\} R stderr ----------- Listening on http://127.0.0.1:4679
#'
#'
#' # To capture all websocket traffic, set `options = list(shiny.trace = TRUE)`
#' app2 <- AppDriver$new(
#' system.file("examples/01_hello", package = "shiny"),
#' options = list(shiny.trace = TRUE)
#' )
#'
#' app2$get_logs()
#' ## (All WebSocket messages have been replaced with `WEBSOCKET_MSG` in example below)
#' #> \{shinytest2\} R info 10:01:57.49 Start AppDriver initialization
#' #> \{shinytest2\} R info 10:01:57.50 Starting Shiny app
#' #> \{shinytest2\} R info 10:01:58.20 Creating new ChromoteSession
#' #> \{shinytest2\} R info 10:01:58.35 Navigating to Shiny app
#' #> \{shinytest2\} R info 10:01:58.47 Injecting shiny-tracer.js
#' #> \{chromote\} JS info 10:01:58.49 shinytest2; jQuery not found
#' #> \{chromote\} JS info 10:01:58.49 shinytest2; Loaded
#' #> \{shinytest2\} R info 10:01:58.50 Waiting for Shiny to become ready
#' #> \{chromote\} JS info 10:01:58.55 shinytest2; jQuery found
#' #> \{chromote\} JS info 10:01:58.55 shinytest2; Waiting for shiny session to connect
#' #> \{chromote\} JS websocket 10:01:58.64 send WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.67 recv WEBSOCKET_MSG
#' #> \{chromote\} JS info 10:01:58.67 shinytest2; Connected
#' #> \{chromote\} JS websocket 10:01:58.71 recv WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.72 recv WEBSOCKET_MSG
#' #> \{chromote\} JS info 10:01:58.72 shinytest2; shiny:busy
#' #> \{chromote\} JS websocket 10:01:58.73 recv WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.73 recv WEBSOCKET_MSG
#' #> \{shinytest2\} R info 10:01:58.75 Waiting for Shiny to become idle for 200ms within 15000ms
#' #> \{chromote\} JS info 10:01:58.75 shinytest2; Waiting for Shiny to be stable
#' #> \{chromote\} JS websocket 10:01:58.81 recv WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.81 recv WEBSOCKET_MSG
#' #> \{chromote\} JS info 10:01:58.81 shinytest2; shiny:idle
#' #> \{chromote\} JS websocket 10:01:58.82 recv WEBSOCKET_MSG
#' #> \{chromote\} JS info 10:01:58.82 shinytest2; shiny:value distPlot
#' #> \{chromote\} JS info 10:01:59.01 shinytest2; Shiny has been idle for 200ms
#' #> \{shinytest2\} R info 10:01:59.01 Shiny app started
#' #> \{shiny\} R stderr ----------- Loading required package: shiny
#' #> \{shiny\} R stderr ----------- Running application in test mode.
#' #> \{shiny\} R stderr -----------
#' #> \{shiny\} R stderr ----------- Listening on http://127.0.0.1:4560
#' #> \{shiny\} R stderr ----------- SEND \{"config":\{"workerId":"","sessionId"|truncated
#' #> \{shiny\} R stderr ----------- RECV \{"method":"init","data":\{"bins":30,|truncated
#' #> \{shiny\} R stderr ----------- SEND \{"custom":\{"showcase-src":\{"srcref":|truncated
#' #> \{shiny\} R stderr ----------- SEND \{"busy":"busy"\}
#' #> \{shiny\} R stderr ----------- SEND \{"custom":\{"showcase-src":\{"srcref":|truncated
#' #> \{shiny\} R stderr ----------- SEND \{"recalculating":\{"name":"distPlot",|truncated
#' #> \{shiny\} R stderr ----------- SEND \{"recalculating":\{"name":"distPlot",|truncated
#' #> \{shiny\} R stderr ----------- SEND \{"busy":"idle"\}
#' #> \{shiny\} R stderr ----------- SEND \{"errors":\{\},"values":\{"distPlot":|truncated
#'
#' # The log that is returned is a `data.frame()`.
#' log <- app2$get_logs()
#' tibble::glimpse(log)
#' #> Rows: 43
#' #> Columns: 5
#' #> $ workerid <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, …
#' #> $ timestamp <dttm> 2022-09-19 10:01:57, 2022-09-19 10:01:57, 2022-09-19 10:01:58, 2022…
#' #> $ location <chr> "shinytest2", "shinytest2", "shinytest2", "shinytest2", "shinytest2"…
#' #> $ level <chr> "info", "info", "info", "info", "info", "info", "info", "info", "inf…
#' #> $ message <chr> "Start AppDriver initialization", "Starting Shiny app", "Creating ne…
#' #> $ workerid <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, …
#' #> $ timestamp <dttm> 2022-03-16 11:09:57, 2022-03-16 11:09:57, 2022-03-16 11:09:…
#' #> $ location <chr> "shinytest2", "shinytest2", "shinytest2", "shinytest2", "shi…
#' #> $ level <chr> "info", "info", "info", "info", "info", "info", "info", "inf…
#' #> $ message <chr> "Start AppDriver initialization", "Starting Shiny app", "Cre…
#'
#' # It may be filtered to find desired logs
#' subset(log, level == "websocket")
#' ## (All WebSocket messages have been replaced with `WEBSOCKET_MSG` in example below)
#' #> \{chromote\} JS websocket 10:01:58.64 send WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.67 recv WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.71 recv WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.72 recv WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.73 recv WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.73 recv WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.81 recv WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.81 recv WEBSOCKET_MSG
#' #> \{chromote\} JS websocket 10:01:58.82 recv WEBSOCKET_MSG
#' }
get_logs = function() {
app_get_logs(self, private)
},
#' @description
#' Add a message to the \pkg{shinytest2} log.
#' @param message Single message to store in log
#' @examples
#' \dontrun{
#' app_path <- system.file("examples/01_hello", package = "shiny")
#' app <- AppDriver$new(app_path)
#'
#' app$log_message("Setting bins to smaller value")
#' app$set_inputs(bins = 10)
#' app$get_logs()
#' }
log_message = function(message) {
app_log_message(self, private, message = message)
},
#' @description Stop the Shiny application driver
#'
#' This method stops all known processes:
#' * The Shiny application in the background R process,
#' * the background R process hosting the Shiny application, and
#' * the Chromote Session instance.
#'
#' To stop your shiny application and return a value from `$stop()`, see
#' [`shiny::stopApp()`]. This is useful in testing to return context
#' information.
#'
#' Typically, this can be paired with a button that when clicked will call
#' `shiny::stopApp(info)` to return `info` from the test app back to the
#' main R session.
#' @param signal_timeout Milliseconds to wait between sending a `SIGINT`,
#' `SIGTERM`, and `SIGKILL` to the Shiny process. Defaults to 500ms and does
#' not utilize the resolved value from `AppDriver$new(timeout=)`. However,
#' if \pkg{covr} is currently executing, then the `timeout` is set to
#' 20,000ms to allow for the coverage report to be generated.
#' @return The result of the background process if the Shiny application has
#' already been terminated.
#' @examples
#' \dontrun{
#' rlang::check_installed("reactlog")
#'
#' library(shiny)
#' shiny_app <- shinyApp(
#' ui = fluidPage(
#' actionButton("button", "Stop app and return Reactlog"),
#' "Click count:", textOutput("count")
#' ),
#' server = function(input, output) {
#' output$count <- renderText({ input$button })
#' observe({
#' req(input$button)
#' stopApp(shiny::reactlog())
#' })
#' }
#' )
#'
#' app <- AppDriver$new(
#' shiny_app,
#' # Enable reactlog in background R session
#' options = list(shiny.reactlog = TRUE)
#' )
#'
#' app$click("button")
#' rlog <- app$stop()
#' str(head(rlog, 2))
#' #> List of 2
#' #> $ :List of 7
#' #> ..$ action : chr "define"
#' #> ..$ reactId: chr "r3"
#' #> ..$ label : chr "Theme Counter"
#' #> ..$ type : chr "reactiveVal"
#' #> ..$ value : chr " num 0"
#' #> ..$ session: chr "bdc7417f2fc8c84fc05c9518e36fdc44"
#' #> ..$ time : num 1.65e+09
#' #> $ :List of 7
#' #> ..$ action : chr "define"
#' #> ..$ reactId: chr "r4"
#' #> ..$ label : chr "output$count"
#' #> .. ..- attr(*, "srcref")= int [1:6] 7 32 7 45 32 45
#' #> .. ..- attr(*, "srcfile")= chr ""
#' #> ..$ type : chr "observer"
#' #> ..$ value : chr " NULL"
#' #> ..$ session: chr "bdc7417f2fc8c84fc05c9518e36fdc44"
#' #> ..$ time : num 1.65e+09
#' }
stop = function(signal_timeout = missing_arg()) {
app_stop(self, private, signal_timeout = signal_timeout)
}
)
)
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.