tests/testthat/test-stacks-deep.R

formatError <- function(err, full = FALSE, offset = TRUE, cleanPaths = TRUE) {
  # This complicated capturing code is necessary because printStackTrace uses a
  # combination of `message()` and `cat(file=stderr())` to print the error,
  # stack traces, and stack trace boundaries ("From earlier call:"). We want to
  # treat all of it as part of the same string.

  str <- noquote(capture.output(
    suppressWarnings(
      suppressMessages(
        withCallingHandlers(
          printError(err, full = full, offset = offset),
          warning = function(cnd) {
            cat(conditionMessage(cnd), "\n", sep = "", file = stderr())
          },
          message = function(cnd) {
            cat(conditionMessage(cnd), file = stderr())
          }
        )
      )
    ),
    type = "message"
  ))

  # Remove directories and line numbers from file/line references, e.g.
  #   53: callback [/Users/jcheng/Development/rstudio/shiny/R/conditions.R#155]
  # becomes
  #   53: callback [conditions.R#XXX]
  #
  # This is to make the snapshot tests more stable across different machines and
  # ignores benign code movement within a file.
  str <- sub("#\\d+\\]$", "#XXX]", str, perl = TRUE)
  # Remove any file/line number reference that's not test-stacks-deep.R. These
  # are just too inconsistent across different ways of invoking testthat--not
  # relative vs. absolute paths, but whether the file/line number is included at
  # all!
  str <- sub(" \\[(?!test-stacks-deep.R)[^[]+#XXX\\]", "", str, perl = TRUE)
  # The frame numbers vary too much between different ways of invoking testthat
  # ("Run Tests" editor toolbar button and "Test" Build tab button in RStudio,
  # devtools::test(), etc.) so we blank them out.
  str <- sub("^[ \\d]+:", "    :", str, perl = TRUE)
  str
}


describe("deep stack trace filtering", {
  it("passes smoke test", {
    st <- list(
      c(
        common <- c("1", "2", "..stacktraceoff..", "3", "..stacktracefloor.."),
        "4", "..stacktraceon..", "5"
      ),
      c(common, "6", "..stacktraceoff..", "7"),
      c(common, "8", "..stacktraceon.."),
      c(common, "9")
    )

    expect_equal(
      stripStackTraces(values = TRUE, st),
      jsonlite::fromJSON('[["1", "2", "5"],["6"],[],["9"]]')
    )
  })

  it("handles null cases", {
    expect_equal(
      stripStackTraces(values = TRUE, list(c())),
      list(character(0))
    )
  })

  it("handles various edge cases", {
    expect_equal(
      stripStackTraces(values = TRUE, list(
        c("..stacktraceoff..", "..stacktraceoff..")
      )),
      list(character(0))
    )

    expect_equal(
      stripStackTraces(values = TRUE, list(
        c("..stacktraceoff..", "..stacktraceoff.."),
        c(),
        c("..stacktraceon.."),
        c("..stacktraceon.."),
        c("1")
      )),
      list(character(0), character(0), character(0), character(0), "1")
    )
  })
})

test_that("deep stack capturing", {
  `%...>%` <- promises::`%...>%`
  `%...!%` <- promises::`%...!%`
  finally <- promises::finally

  err <- NULL
  captureStackTraces({
    promise_resolve("one") %...>% {
      promise_reject("error") %...!% {
        finally(promise_resolve("two"), ~{
          stop("boom")
        })
      }
    }
  }) %...!% (function(err) {
    err <<- err
  })

  wait_for_it()

  expect_s3_class(err, "error", exact = FALSE)
  expect_snapshot(cat(sep="\n", formatError(err)))
  expect_snapshot(cat(sep="\n", formatError(err, full = TRUE)))
})

test_that("deep stack capturing within reactives", {
  rerr <- NULL
  observe({
    promise_resolve("one") %...>% {
      promise_resolve("two") %...>% {
        stop("boom")
      }
    } %...!% (function(err) {
      rerr <<- err
    })
  })

  flushReact()
  wait_for_it()

  expect_s3_class(rerr, "error", exact = FALSE)
  expect_length(attr(rerr, "deep.stack.trace"), 2)
})

test_that("deep stacks long chain", {
  op <- options(shiny.deepstacktrace = 3L)
  on.exit(options(op), add = TRUE, after = FALSE)

  # Without deep stack traces, the stack trace would give no clue that the error
  # originally started from a call to `A__()`. With deep stack traces, we can
  # see that the error originated from `A__` and passed through `I__` and `J__`.
  # But due to culling, we don't see `B__` through `H__`--these are omitted for
  # brevity and to prevent unbounded growth of the accounting we do.

  A__ <- function() promise_resolve(TRUE) %...>% B__()
  B__ <- function(x) promise_resolve(TRUE) %...>% C__()
  C__ <- function(x) promise_resolve(TRUE) %...>% D__()
  D__ <- function(x) promise_resolve(TRUE) %...>% E__()
  E__ <- function(x) promise_resolve(TRUE) %...>% F__()
  F__ <- function(x) promise_resolve(TRUE) %...>% G__()
  G__ <- function(x) promise_resolve(TRUE) %...>% H__()
  H__ <- function(x) promise_resolve(TRUE) %...>% I__()
  I__ <- function(x) promise_resolve(TRUE) %...>% J__()
  J__ <- function(x) promise_resolve(TRUE) %...>% { stop("boom") }

  dserr <- NULL
  captureStackTraces(
    A__()
  ) %...!% (function(err) {
    dserr <<- err
  })

  wait_for_it()

  expect_s3_class(dserr, "error", exact = FALSE)
  expect_snapshot(cat(sep="\n", stacktrace <- formatError(dserr)))
  # Ensure we dropTrivialTestFrames only when snapshotting
  expect_false(length(stacktrace) == length(formatError(dserr)))
  # Ensure that A__ through J__ are present in the traces
  for (letter in LETTERS[1:10]) {
    expect_length(which(grepl(paste0(letter, "__"), stacktrace)), 1L)
  }
})

test_that("Deep stack deduplication", {
  recursive_promise <- function(n) {
    if (n <= 0) {
      stop("boom")
    }

    p <- promises::promise_resolve(TRUE)
    promises::then(p, ~{
      recursive_promise(n - 1)
    })
  }

  op <- options(shiny.deepstacktrace = TRUE)
  on.exit(options(op), add = TRUE, after = FALSE)

  uerr <- NULL
  captureStackTraces(recursive_promise(100)) %...!% (function(err) {
    uerr <<- err
  })

  wait_for_it()

  expect_s3_class(uerr, "error", exact = FALSE)
  # Even though we traveled through 100 promises recursively, we only retained
  # the unique ones
  expect_identical(length(attr(uerr, "deep.stack.trace", exact = TRUE)), 2L)
})

test_that("stack trace stripping works", {
  A__ <- function() promise_resolve(TRUE) %...>% B__()
  B__ <- function(x) promise_resolve(TRUE) %...>% { ..stacktraceoff..(C__()) }
  C__ <- function(x) promise_resolve(TRUE) %...>% D__()
  D__ <- function(x) promise_resolve(TRUE) %...>% { ..stacktraceon..(E__()) }
  E__ <- function(x) promise_resolve(TRUE) %...>% { stop("boom") }

  strperr <- NULL
  captureStackTraces(A__()) %...!% (function(err) {
    strperr <<- err
  })

  ..stacktracefloor..(
    wait_for_it()
  )

  expect_s3_class(strperr, "error", exact = FALSE)

  str <- formatError(strperr)
  expect_length(which(grepl("A__", str)), 1L)
  expect_length(which(grepl("B__", str)), 1L)
  expect_length(which(grepl("C__", str)), 0L)
  expect_length(which(grepl("D__", str)), 0L)
  expect_length(which(grepl("E__", str)), 1L)

  str_full <- formatError(strperr, full = TRUE)
  expect_length(which(grepl("A__", str_full)), 1L)
  expect_length(which(grepl("B__", str_full)), 1L)
  expect_length(which(grepl("C__", str_full)), 1L)
  expect_length(which(grepl("D__", str_full)), 1L)
  expect_length(which(grepl("E__", str_full)), 1L)
})

test_that("coro async generator deep stack count is low", {
  gen <- coro::async_generator(function() {
    for (i in 1:50) {
      await(coro::async_sleep(0.001))
      yield(i)
    }
    stop("boom")
  })

  cgerr <- NULL
  captureStackTraces(
    coro::async_collect(gen()) %...!% (function(err) {
      cgerr <<- err
    })
  )

  wait_for_it()

  expect_s3_class(cgerr, "error", exact = FALSE)
  expect_length(attr(cgerr, "deep.stack.trace"), 2L)
})

Try the shiny package in your browser

Any scripts or data that you put into this service are public.

shiny documentation built on July 3, 2025, 9:08 a.m.