From 021f51ec3b140f49357622ba3ec56fb7d654ea5a Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Sep 2026 09:17:59 -0400 Subject: [PATCH 01/10] feat: add compact package test reporter --- DESCRIPTION | 1 + R/pkg-test-reporter.R | 180 ++++++++++++++++++++++++ R/tool-pkg-devtools.R | 62 +++++--- exec/btw.R | 17 ++- inst/cli-skill/r-btw-cli/SKILL.md | 2 +- man/btw_tool_pkg_test.Rd | 14 +- tests/testthat/test-cli.R | 79 +++++++++-- tests/testthat/test-pkg-test-reporter.R | 86 +++++++++++ tests/testthat/test-tool-pkg-devtools.R | 60 ++++++-- 9 files changed, 447 insertions(+), 54 deletions(-) create mode 100644 R/pkg-test-reporter.R create mode 100644 tests/testthat/test-pkg-test-reporter.R diff --git a/DESCRIPTION b/DESCRIPTION index 9e056a6f..450721bc 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -113,6 +113,7 @@ Collate: 'import-standalone-purrr.R' 'import-standalone-types-check.R' 'mcp.R' + 'pkg-test-reporter.R' 'task_create_btw_md.R' 'task_create_readme.R' 'task_create_skill.R' diff --git a/R/pkg-test-reporter.R b/R/pkg-test-reporter.R new file mode 100644 index 00000000..5bfa62c7 --- /dev/null +++ b/R/pkg-test-reporter.R @@ -0,0 +1,180 @@ +# Compact testthat reporter used by btw pkg test and btw_tool_pkg_test(). + +btw_test_name <- function(file) { + sub("\\.[rR]$", "", sub("^test-", "", basename(file))) +} + +btw_test_duration <- function(seconds) { + decimals <- if (seconds < 10) 2L else if (seconds < 100) 1L else 0L + if (round(seconds, decimals) >= 10 && decimals == 2L) { + decimals <- 1L + } + if (round(seconds, decimals) >= 100 && decimals == 1L) { + decimals <- 0L + } + sprintf(paste0("%.", decimals, "fs"), seconds) +} + +btw_test_location <- function(result) { + ref <- result$srcref + if (!inherits(ref, "srcref")) { + return("") + } + filename <- attr(ref, "srcfile")$filename + if (is.null(filename)) { + return("") + } + sprintf("%s:%s:%s", filename, ref[[1]], ref[[2]]) +} + +btw_test_type <- function(result) { + sub("^expectation_", "", class(result)[[1]]) +} + +btw_test_color <- function(text, style, enabled) { + if (!enabled) { + return(text) + } + switch( + style, + muted = cli::col_grey(text), + pass = cli::col_green(text), + fail = cli::col_red(text), + warn = cli::col_yellow(text) + ) +} + +btw_compact_reporter <- function(pkg = ".", filter = NULL) { + rlang::check_installed("R6") + test_dir <- file.path(pkg, "tests", "testthat") + files <- if (dir.exists(test_dir)) { + testthat::find_test_scripts(test_dir, filter = filter, full.names = FALSE) + } else { + character() + } + width <- if (length(files)) { + max(nchar(btw_test_name(files), type = "width")) + } else { + 0L + } + + R6::R6Class("BtwCompactReporter", inherit = testthat::Reporter, public = list( + name = NULL, + file_id = NULL, + files = NULL, + name_width = NULL, + color = NULL, + counts = NULL, + failures = NULL, + warnings = NULL, + + initialize = function(width) { + super$initialize() + self$capabilities$parallel_support <- TRUE + self$capabilities$parallel_updates <- TRUE + self$name_width <- width + self$files <- list() + self$color <- identical(self$out, stdout()) && + sink.number() == 0L && cli::num_ansi_colors() > 1L + self$counts <- c(P = 0L, F = 0L, S = 0L, W = 0L) + self$failures <- list() + self$warnings <- list() + }, + start_file = function(name) { + self$file_id <- name + self$name <- btw_test_name(name) + # In testthat's parallel mode, start_file() is repeated for every + # event. Keep each running file's timer and counts across events. + if (!is.null(self$files[[self$file_id]])) { + return(invisible(NULL)) + } + self$files[[self$file_id]] <- list( + started = proc.time()[[3L]], + counts = c(P = 0L, F = 0L, S = 0L, W = 0L) + ) + }, + add_result = function(context, test, result) { + type <- btw_test_type(result) + key <- if (type %in% c("failure", "error")) { + self$failures <- c(self$failures, list(result)) + "F" + } else if (type == "skip") { + "S" + } else if (type == "warning") { + self$warnings <- c(self$warnings, list(result)) + "W" + } else { + "P" + } + file <- self$files[[self$file_id]] + file$counts[[key]] <- file$counts[[key]] + 1L + self$files[[self$file_id]] <- file + self$counts[[key]] <- self$counts[[key]] + 1L + }, + end_file = function() { + file <- self$files[[self$file_id]] + if (is.null(file)) { + return(invisible(NULL)) + } + elapsed <- proc.time()[[3L]] - file$started + counts <- file$counts + status <- if (counts[["F"]] > 0L) { + "✗" + } else if (counts[["W"]] > 0L) { + "!" + } else { + "✓" + } + counts <- counts[counts > 0L] + if (!length(counts)) { + counts <- c(P = 0L) + } + summary <- paste(vapply(names(counts), function(key) { + style <- switch(key, P = "pass", F = "fail", S = "muted", W = "warn") + btw_test_color(paste0(key, ":", counts[[key]]), style, self$color) + }, ""), collapse = " ") + styled_status <- btw_test_color( + status, switch(status, "✓" = "pass", "✗" = "fail", "warn"), self$color + ) + styled_time <- btw_test_color(btw_test_duration(elapsed), "muted", self$color) + self$cat_line(sprintf( + "%s %-*s %s %s", + styled_status, max(self$name_width, nchar(self$name, type = "width")), + self$name, styled_time, summary + )) + if (identical(self$out, stdout())) flush(stdout()) + self$files[[self$file_id]] <- NULL + }, + end_reporter = function() { + if (length(self$warnings)) { + self$cat_line() + self$cat_line(btw_test_color("WARNINGS ========", "warn", self$color)) + for (warning in self$warnings) { + self$cat_line( + btw_test_color("WARN", "warn", self$color), ": ", + btw_test_location(warning) + ) + self$cat_line(format(warning)) + self$cat_line() + } + } + if (length(self$failures)) { + self$cat_line() + self$cat_line(btw_test_color("FAILURES ========", "fail", self$color)) + for (failure in self$failures) { + self$cat_line( + btw_test_color(toupper(btw_test_type(failure)), "fail", self$color), + ": ", btw_test_location(failure) + ) + self$cat_line(format(failure)) + self$cat_line() + } + } + self$cat_line(sprintf( + "[ FAIL %d | WARN %d | SKIP %d | PASS %d ]", + self$counts[["F"]], self$counts[["W"]], self$counts[["S"]], self$counts[["P"]] + )) + if (identical(self$out, stdout())) flush(stdout()) + } + ))$new(width = width) +} diff --git a/R/tool-pkg-devtools.R b/R/tool-pkg-devtools.R index 71cb244a..3aed3e39 100644 --- a/R/tool-pkg-devtools.R +++ b/R/tool-pkg-devtools.R @@ -153,12 +153,16 @@ For iterative development, use the `btw_tool_pkg_test` if available or `devtools #' Tool: Run package tests #' #' Run package tests using [devtools::test()]. Optionally filter tests by name -#' pattern. +#' pattern. The default `"compact"` reporter prints a short summary as each +#' file finishes, then failures before a final test summary. Use `"minimal"` +#' for the previous summary-and-failures output, or a testthat reporter name. #' #' @param pkg Path to package directory. Defaults to '.'. Must be within #' current working directory. #' @param filter Optional regex to filter test files. Example: 'helper' matches #' 'test-helper.R'. +#' @param reporter Either `"compact"` (the default), `"minimal"` (the previous +#' behavior), or a testthat reporter name passed to [devtools::test()]. #' @inheritParams btw_tool_docs_package_news #' #' @returns The output from [devtools::test()]. @@ -166,29 +170,49 @@ For iterative development, use the `btw_tool_pkg_test` if available or `devtools #' @seealso [btw_tools()] #' @family pkg tools #' @export -btw_tool_pkg_test <- function(pkg = ".", filter = NULL, `_intent`) {} +btw_tool_pkg_test <- function(pkg = ".", filter = NULL, reporter = "compact", `_intent`) {} -btw_tool_pkg_test_impl <- function(pkg = ".", filter = NULL) { +btw_pkg_test_validate <- function(pkg, filter, reporter) { check_string(pkg) check_path_within_current_wd(pkg) + check_string(filter, allow_null = TRUE) + check_string(reporter) +} - filter_arg <- if (!is.null(filter)) { - check_string(filter) - sprintf(', filter = "%s"', filter) - } else { - "" - } +btw_pkg_test_run <- function(pkg = ".", filter = NULL, reporter = "compact") { + btw_pkg_test_validate(pkg, filter, reporter) + withr::local_envvar(TESTTHAT_PROBLEMS = "false") + + resolved_reporter <- switch( + reporter, + compact = if (utils::packageVersion("testthat") >= "3.1.7") { + btw_compact_reporter(pkg, filter) + } else { + # Older testthat versions don't call the per-file reporter hooks. + "check" + }, + minimal = if (utils::packageVersion("testthat") >= "3.3.2") "llm" else "check", + reporter + ) + invisible(devtools::test( + pkg = pkg, + filter = filter, + stop_on_failure = FALSE, + export_all = TRUE, + reporter = resolved_reporter + )) +} - rptr <- if (utils::packageVersion("testthat") >= "3.3.2") "llm" else "check" +btw_tool_pkg_test_impl <- function(pkg = ".", filter = NULL, reporter = "compact") { + btw_pkg_test_validate(pkg, filter, reporter) + # Use one runner for both the captured tool output and the streaming CLI. code <- sprintf( - 'devtools::test(pkg = "%s"%s, stop_on_failure = FALSE, export_all = TRUE, reporter = "%s")', - pkg, - filter_arg, - rptr + "btw:::btw_pkg_test_run(pkg = %s, filter = %s, reporter = %s)", + encodeString(pkg, quote = '"'), + if (is.null(filter)) "NULL" else encodeString(filter, quote = '"'), + encodeString(reporter, quote = '"') ) - - withr::local_envvar(TESTTHAT_PROBLEMS = "false") btw_tool_run_r_impl(code) } @@ -212,7 +236,7 @@ The filter parameter accepts a regular expression matched against test file name - No filter runs all tests - It is common to pair `test-{name}.R` with a source `{name}.R` file. To test this file, you can generally use filter = '{name}'. -Use `filter` when working on specific functionality to get faster feedback. The tool always runs all matching tests to completion regardless of failures.", +Use `filter` when working on specific functionality to get faster feedback. The tool always runs all matching tests to completion regardless of failures. `reporter = 'compact'` summarizes each file when it finishes and prints failures at the end; `'minimal'` keeps the previous output. Other testthat reporter names are passed through.", annotations = ellmer::tool_annotations( title = "Testing package", read_only_hint = FALSE, @@ -227,6 +251,10 @@ Use `filter` when working on specific functionality to get faster feedback. The filter = ellmer::type_string( "Optional regex to filter test files. Example: 'helper' matches 'test-helper.R'.", required = FALSE + ), + reporter = ellmer::type_string( + "Reporter: 'compact' (default), 'minimal', or any testthat reporter name.", + required = FALSE ) ) ) diff --git a/exec/btw.R b/exec/btw.R index d29f9c9c..aded1461 100755 --- a/exec/btw.R +++ b/exec/btw.R @@ -294,12 +294,13 @@ btw_pkg_check <- function(path) { btw_output(btw:::btw_tool_pkg_check_impl(path)) } -btw_pkg_test <- function(path, filter) { - btw_output( - btw:::btw_tool_pkg_test_impl( - path, - if (has_value(filter)) filter else NULL - ) +btw_pkg_test <- function(path, filter, reporter) { + # Run directly rather than via the capturing tool, so file starts are visible + # to tail -f while tests are still running. + btw:::btw_pkg_test_run( + path, + if (has_value(filter)) filter else NULL, + reporter ) } @@ -956,7 +957,9 @@ switch( #| description: Regex to filter test files. #| short: 'f' filter <- "" - tryCatch(btw_pkg_test(path, filter), error = btw_error) + #| description: compact, minimal, or a testthat reporter name. + reporter <- "compact" + tryCatch(btw_pkg_test(path, filter, reporter), error = btw_error) }, #| title: Load package with pkgload diff --git a/inst/cli-skill/r-btw-cli/SKILL.md b/inst/cli-skill/r-btw-cli/SKILL.md index 8c939e27..b91e8382 100644 --- a/inst/cli-skill/r-btw-cli/SKILL.md +++ b/inst/cli-skill/r-btw-cli/SKILL.md @@ -30,7 +30,7 @@ Use `btw pkg` to run development tasks on an R package under active development. ``` btw pkg document [--path ] Generate roxygen2 docs btw pkg check [--path ] Run R CMD check -btw pkg test [-f ] [--path ] Run testthat tests +btw pkg test [-f ] [--reporter compact|minimal|] [--path ] Run testthat tests with live per-file progress by default btw pkg load [--path ] Load package with pkgload btw pkg coverage [--file ] [--json] Compute test coverage ``` diff --git a/man/btw_tool_pkg_test.Rd b/man/btw_tool_pkg_test.Rd index e65e0092..697061e0 100644 --- a/man/btw_tool_pkg_test.Rd +++ b/man/btw_tool_pkg_test.Rd @@ -4,7 +4,12 @@ \alias{btw_tool_pkg_test} \title{Tool: Run package tests} \usage{ -btw_tool_pkg_test(pkg = ".", filter = NULL, `_intent` = "") +btw_tool_pkg_test( + pkg = ".", + filter = NULL, + reporter = "compact", + `_intent` = "" +) } \arguments{ \item{pkg}{Path to package directory. Defaults to '.'. Must be within @@ -13,6 +18,9 @@ current working directory.} \item{filter}{Optional regex to filter test files. Example: 'helper' matches 'test-helper.R'.} +\item{reporter}{Either \code{"compact"} (the default), \code{"minimal"} (the previous +behavior), or a testthat reporter name passed to \code{\link[devtools:test]{devtools::test()}}.} + \item{_intent}{An optional string describing the intent of the tool use. When the tool is used by an LLM, the model will use this argument to explain why it called the tool.} @@ -22,7 +30,9 @@ The output from \code{\link[devtools:test]{devtools::test()}}. } \description{ Run package tests using \code{\link[devtools:test]{devtools::test()}}. Optionally filter tests by name -pattern. +pattern. The default \code{"compact"} reporter prints a short summary as each +file finishes, then failures before a final test summary. Use \code{"minimal"} +for the previous summary-and-failures output, or a testthat reporter name. } \seealso{ \code{\link[=btw_tools]{btw_tools()}} diff --git a/tests/testthat/test-cli.R b/tests/testthat/test-cli.R index 3d949852..288e8252 100644 --- a/tests/testthat/test-cli.R +++ b/tests/testthat/test-cli.R @@ -242,29 +242,82 @@ test_that("btw pkg check calls check impl", { expect_equal(env$path, ".") }) -test_that("btw pkg test calls test impl with filter", { - mock_filter <- NULL +test_that("btw pkg test emits completed-file results by default", { + args <- NULL local_mocked_bindings( - btw_tool_pkg_test_impl = function(pkg, filter = NULL) { - mock_filter <<- filter - "Tests passed." + btw_pkg_test_run = function(pkg, filter = NULL, reporter = "compact") { + args <<- list(pkg = pkg, filter = filter, reporter = reporter) + cat("✓ utils 0.10s P:1\n") } ) env <- run_btw_quietly("pkg", "test", "-f", "utils") expect_equal(env$filter, "utils") - expect_equal(mock_filter, "utils") + expect_equal(args, list(pkg = ".", filter = "utils", reporter = "compact")) + expect_equal(env$.output, "✓ utils 0.10s P:1") }) -test_that("btw pkg test without filter passes NULL", { - mock_filter <- "SENTINEL" +test_that("btw pkg test forwards the reporter and missing filter", { + args <- NULL local_mocked_bindings( - btw_tool_pkg_test_impl = function(pkg, filter = NULL) { - mock_filter <<- filter - "Tests passed." + btw_pkg_test_run = function(pkg, filter = NULL, reporter = "compact") { + args <<- list(pkg = pkg, filter = filter, reporter = reporter) } ) - run_btw_quietly("pkg", "test") - expect_null(mock_filter) + run_btw_quietly("pkg", "test", "--reporter", "minimal") + expect_equal(args, list(pkg = ".", filter = NULL, reporter = "minimal")) +}) + +test_that("btw pkg test streams results before all files finish", { + skip_if_not_installed("processx") + pkg <- withr::local_tempdir(tmpdir = getwd()) + scripts <- file.path(pkg, "tests", "testthat") + dir.create(scripts, recursive = TRUE) + writeLines(c( + "Package: btwtestfixture", "Version: 0.0.1", "Title: Test fixture", + "Description: An isolated test package.", "License: MIT", + "Suggests: testthat", "Config/testthat/edition: 3", + "Config/testthat/parallel: true" + ), file.path(pkg, "DESCRIPTION")) + writeLines('library(testthat)\ntest_check("btwtestfixture")', file.path(pkg, "tests", "testthat.R")) + gate <- file.path(pkg, "continue") + writeLines(c( + 'test_that("slow test", {', + sprintf(' while (!file.exists(%s)) Sys.sleep(0.05)', + encodeString(gate, quote = '"')), + ' expect_true(TRUE)', + '})' + ), file.path(scripts, "test-slow.R")) + writeLines('test_that("fast test", expect_true(TRUE))', + file.path(scripts, "test-fast.R")) + + output_file <- withr::local_tempfile() + error_file <- withr::local_tempfile() + script <- sprintf( + 'pkgload::load_all(%s, quiet = TRUE); Rapp::run(%s, c("pkg", "test", "--path", %s))', + encodeString(btw_pkg_dir_resolved, quote = '"'), + encodeString(btw_cli_path(), quote = '"'), + encodeString(pkg, quote = '"') + ) + proc <- processx::process$new( + "Rscript", c("-e", script), stdout = output_file, stderr = error_file + ) + withr::defer(if (proc$is_alive()) proc$kill()) + deadline <- Sys.time() + 20 + repeat { + lines <- if (file.exists(output_file)) readLines(output_file, warn = FALSE) else character() + if (any(grepl("^✓ fast", lines)) || !proc$is_alive() || Sys.time() > deadline) break + proc$poll_io(100) + } + expect_true(any(grepl("^✓ fast", lines)), info = paste(readLines(error_file, warn = FALSE), collapse = "\n")) + expect_true(proc$is_alive(), info = "A file result should arrive while another file is running") + expect_false(any(grepl("^@ ", lines))) + expect_false(any(grepl("^✓ slow", lines))) + file.create(gate) + proc$wait(timeout = 10000) + expect_false(proc$is_alive()) + expect_equal(proc$get_exit_status(), 0L, info = paste(readLines(error_file, warn = FALSE), collapse = "\n")) + expect_equal(tail(readLines(output_file, warn = FALSE), 1), + "[ FAIL 0 | WARN 0 | SKIP 0 | PASS 2 ]") }) test_that("btw pkg load calls load impl", { diff --git a/tests/testthat/test-pkg-test-reporter.R b/tests/testthat/test-pkg-test-reporter.R new file mode 100644 index 00000000..375bd7b0 --- /dev/null +++ b/tests/testthat/test-pkg-test-reporter.R @@ -0,0 +1,86 @@ +test_that("compact reporter tracks files and prints a final summary", { + test_dir <- withr::local_tempdir() + dir.create(file.path(test_dir, "tests", "testthat"), recursive = TRUE) + scripts <- file.path(test_dir, "tests", "testthat") + writeLines(c( + 'test_that("passing", {', + ' expect_true(TRUE)', + ' skip("not applicable")', + '})' + ), file.path(scripts, "test-tool-run.R")) + writeLines(c( + 'test_that("failure and warning", {', + ' expect_true(FALSE)', + ' warning("a warning")', + '})' + ), file.path(scripts, "test-config.R")) + + output_file <- withr::local_tempfile() + withr::local_options(testthat.output_file = output_file) + reporter <- btw_compact_reporter(test_dir) + suppressMessages(testthat::test_dir(scripts, reporter = reporter, stop_on_failure = FALSE)) + output <- readLines(output_file, warn = FALSE) + done <- grep("^[✓✗!] ", output, value = TRUE) + expect_false(any(grepl("^@ ", output))) + expect_length(done, 2) + expect_match(done[[1]], "^✗ config\\s+[0-9.]+s F:1 W:1$") + expect_match(done[[2]], "^✓ tool-run\\s+[0-9.]+s P:1 S:1$") + expect_true("FAILURES ========" %in% output) + expect_true("WARNINGS ========" %in% output) + expect_equal(tail(output, 1), "[ FAIL 1 | WARN 1 | SKIP 1 | PASS 1 ]") + expect_false(any(grepl("\033", output, fixed = TRUE))) + expect_gt(which(output == "FAILURES ========"), + max(which(grepl("^[✓✗!] ", output)))) +}) + +test_that("files finishing out of order print only completion lines", { + output_file <- withr::local_tempfile() + withr::local_options(testthat.output_file = output_file) + reporter <- btw_compact_reporter() + reporter$start_file("test-a.R") + reporter$start_file("test-b.R") + reporter$start_file("test-a.R") # testthat repeats this callback in parallel mode + reporter$end_file() + reporter$start_file("test-b.R") + reporter$end_file() + reporter$end_reporter() + + output <- readLines(output_file, warn = FALSE) + expect_false(any(grepl("^@ ", output))) + expect_match(output[[1]], "^✓ a\\s+[0-9.]+s P:0$") + expect_match(output[[2]], "^✓ b\\s+[0-9.]+s P:0$") + expect_equal(tail(output, 1), "[ FAIL 0 | WARN 0 | SKIP 0 | PASS 0 ]") +}) + +test_that("color styling is opt-in", { + expect_equal(btw_test_color("0.12s", "muted", FALSE), "0.12s") + withr::local_options(cli.num_colors = 8L) + expect_true(grepl("\033[", btw_test_color("0.12s", "muted", TRUE), fixed = TRUE)) +}) + +test_that("compact duration uses three display digits", { + expect_equal(btw_test_duration(0.123), "0.12s") + expect_equal(btw_test_duration(1.4), "1.40s") + expect_equal(btw_test_duration(12.34), "12.3s") + expect_equal(btw_test_duration(123.4), "123s") + expect_equal(btw_test_duration(9.999), "10.0s") + expect_equal(btw_test_duration(99.999), "100s") +}) + +test_that("compact reporter displays empty files and counts errors as failures", { + test_dir <- withr::local_tempdir() + scripts <- file.path(test_dir, "tests", "testthat") + dir.create(scripts, recursive = TRUE) + writeLines("# no tests", file.path(scripts, "test-empty.R")) + writeLines('test_that("error", stop("oops"))', file.path(scripts, "test-error.R")) + + output_file <- withr::local_tempfile() + withr::local_options(testthat.output_file = output_file) + suppressMessages(testthat::test_dir( + scripts, reporter = btw_compact_reporter(test_dir), stop_on_failure = FALSE + )) + output <- readLines(output_file, warn = FALSE) + expect_true(any(grepl("^✓ empty\\s+[0-9.]+s P:0$", output))) + expect_true(any(grepl("^✗ error\\s+[0-9.]+s F:1$", output))) + expect_equal(tail(output, 1), "[ FAIL 1 | WARN 0 | SKIP 0 | PASS 0 ]") +}) diff --git a/tests/testthat/test-tool-pkg-devtools.R b/tests/testthat/test-tool-pkg-devtools.R index b0acf796..a14d117b 100644 --- a/tests/testthat/test-tool-pkg-devtools.R +++ b/tests/testthat/test-tool-pkg-devtools.R @@ -152,12 +152,10 @@ test_that("btw_tool_pkg_test constructs correct code without filter", { result <- btw_tool_pkg_test_impl(".") expect_s7_class(result, BtwRunToolResult) - expect_match(result@extra$code, "devtools::test") + expect_match(result@extra$code, "btw_pkg_test_run") expect_match(result@extra$code, 'pkg = "."') - expect_match(result@extra$code, "stop_on_failure = FALSE") - expect_match(result@extra$code, "export_all = TRUE") - # Should NOT have filter argument - expect_false(grepl("filter", result@extra$code)) + expect_match(result@extra$code, "filter = NULL") + expect_match(result@extra$code, 'reporter = "compact"') }) test_that("btw_tool_pkg_test constructs correct code with filter", { @@ -177,11 +175,48 @@ test_that("btw_tool_pkg_test constructs correct code with filter", { result <- btw_tool_pkg_test_impl(".", filter = "helper") expect_s7_class(result, BtwRunToolResult) - expect_match(result@extra$code, "devtools::test") + expect_match(result@extra$code, "btw_pkg_test_run") expect_match(result@extra$code, 'pkg = "."') expect_match(result@extra$code, 'filter = "helper"') - expect_match(result@extra$code, "stop_on_failure = FALSE") - expect_match(result@extra$code, "export_all = TRUE") + expect_match(result@extra$code, 'reporter = "compact"') +}) + +test_that("btw_tool_pkg_test forwards reporter names", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "Test output")), + extra = list(code = code, status = "success", data = NULL, contents = list()) + ) + } + ) + + expect_match(btw_tool_pkg_test_impl(reporter = "minimal")@extra$code, 'reporter = "minimal"') + expect_match(btw_tool_pkg_test_impl(reporter = "progress")@extra$code, 'reporter = "progress"') + expect_error(btw_tool_pkg_test_impl(reporter = 1)) +}) + +test_that("package test runner resolves built-in and external reporters", { + args <- NULL + local_mocked_bindings( + test = function(...) args <<- list(...), + .package = "devtools" + ) + local_mocked_bindings( + btw_compact_reporter = function(pkg, filter) "CUSTOM" + ) + + btw_pkg_test_run(filter = "utils") + expect_equal(args$reporter, "CUSTOM") + expect_equal(args$filter, "utils") + expect_false(args$stop_on_failure) + + btw_pkg_test_run(reporter = "minimal") + expected <- if (utils::packageVersion("testthat") >= "3.3.2") "llm" else "check" + expect_equal(args$reporter, expected) + + btw_pkg_test_run(reporter = "progress") + expect_equal(args$reporter, "progress") }) test_that("btw_tool_pkg_test handles different filter patterns", { @@ -333,12 +368,9 @@ test_that("btw_tool_pkg_test handles filter with special regex chars", { result <- btw_tool_pkg_test_impl(".", filter = "test-.*\\.R$") expect_s7_class(result, BtwRunToolResult) - # deparse() should properly quote the regex - expect_true(grepl( - 'filter = \"test-.*\\.R$\"', - result@extra$code, - fixed = TRUE - )) + # Generated R code must preserve regex backslashes when evaluated. + expect_match(result@extra$code, "filter = ", fixed = TRUE) + expect_match(result@extra$code, "test-.*\\\\.R$", fixed = TRUE) }) # Test return values ----------------------------------------------------------- From d1c4fbc4d07c558813745073bc4eb86f28930aef Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Sep 2026 09:20:20 -0400 Subject: [PATCH 02/10] style: balance package test report headings --- R/pkg-test-reporter.R | 4 ++-- tests/testthat/test-pkg-test-reporter.R | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/R/pkg-test-reporter.R b/R/pkg-test-reporter.R index 5bfa62c7..f9d155aa 100644 --- a/R/pkg-test-reporter.R +++ b/R/pkg-test-reporter.R @@ -148,7 +148,7 @@ btw_compact_reporter <- function(pkg = ".", filter = NULL) { end_reporter = function() { if (length(self$warnings)) { self$cat_line() - self$cat_line(btw_test_color("WARNINGS ========", "warn", self$color)) + self$cat_line(btw_test_color("======== WARNINGS ========", "warn", self$color)) for (warning in self$warnings) { self$cat_line( btw_test_color("WARN", "warn", self$color), ": ", @@ -160,7 +160,7 @@ btw_compact_reporter <- function(pkg = ".", filter = NULL) { } if (length(self$failures)) { self$cat_line() - self$cat_line(btw_test_color("FAILURES ========", "fail", self$color)) + self$cat_line(btw_test_color("======== FAILURES ========", "fail", self$color)) for (failure in self$failures) { self$cat_line( btw_test_color(toupper(btw_test_type(failure)), "fail", self$color), diff --git a/tests/testthat/test-pkg-test-reporter.R b/tests/testthat/test-pkg-test-reporter.R index 375bd7b0..d2ea45cb 100644 --- a/tests/testthat/test-pkg-test-reporter.R +++ b/tests/testthat/test-pkg-test-reporter.R @@ -25,11 +25,11 @@ test_that("compact reporter tracks files and prints a final summary", { expect_length(done, 2) expect_match(done[[1]], "^✗ config\\s+[0-9.]+s F:1 W:1$") expect_match(done[[2]], "^✓ tool-run\\s+[0-9.]+s P:1 S:1$") - expect_true("FAILURES ========" %in% output) - expect_true("WARNINGS ========" %in% output) + expect_true("======== FAILURES ========" %in% output) + expect_true("======== WARNINGS ========" %in% output) expect_equal(tail(output, 1), "[ FAIL 1 | WARN 1 | SKIP 1 | PASS 1 ]") expect_false(any(grepl("\033", output, fixed = TRUE))) - expect_gt(which(output == "FAILURES ========"), + expect_gt(which(output == "======== FAILURES ========"), max(which(grepl("^[✓✗!] ", output)))) }) From ad5f1e44df6a9750103b3521e96d1df25955154d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Sep 2026 09:23:30 -0400 Subject: [PATCH 03/10] refactor: move test reporter color styling into method --- R/pkg-test-reporter.R | 41 ++++++++++++------------- tests/testthat/test-pkg-test-reporter.R | 9 ++++-- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/R/pkg-test-reporter.R b/R/pkg-test-reporter.R index f9d155aa..d41d2f0e 100644 --- a/R/pkg-test-reporter.R +++ b/R/pkg-test-reporter.R @@ -31,19 +31,6 @@ btw_test_type <- function(result) { sub("^expectation_", "", class(result)[[1]]) } -btw_test_color <- function(text, style, enabled) { - if (!enabled) { - return(text) - } - switch( - style, - muted = cli::col_grey(text), - pass = cli::col_green(text), - fail = cli::col_red(text), - warn = cli::col_yellow(text) - ) -} - btw_compact_reporter <- function(pkg = ".", filter = NULL) { rlang::check_installed("R6") test_dir <- file.path(pkg, "tests", "testthat") @@ -80,6 +67,18 @@ btw_compact_reporter <- function(pkg = ".", filter = NULL) { self$failures <- list() self$warnings <- list() }, + colorize = function(text, style) { + if (!self$color) { + return(text) + } + switch( + style, + muted = cli::col_grey(text), + pass = cli::col_green(text), + fail = cli::col_red(text), + warn = cli::col_yellow(text) + ) + }, start_file = function(name) { self$file_id <- name self$name <- btw_test_name(name) @@ -131,12 +130,12 @@ btw_compact_reporter <- function(pkg = ".", filter = NULL) { } summary <- paste(vapply(names(counts), function(key) { style <- switch(key, P = "pass", F = "fail", S = "muted", W = "warn") - btw_test_color(paste0(key, ":", counts[[key]]), style, self$color) + self$colorize(paste0(key, ":", counts[[key]]), style) }, ""), collapse = " ") - styled_status <- btw_test_color( - status, switch(status, "✓" = "pass", "✗" = "fail", "warn"), self$color + styled_status <- self$colorize( + status, switch(status, "✓" = "pass", "✗" = "fail", "warn") ) - styled_time <- btw_test_color(btw_test_duration(elapsed), "muted", self$color) + styled_time <- self$colorize(btw_test_duration(elapsed), "muted") self$cat_line(sprintf( "%s %-*s %s %s", styled_status, max(self$name_width, nchar(self$name, type = "width")), @@ -148,10 +147,10 @@ btw_compact_reporter <- function(pkg = ".", filter = NULL) { end_reporter = function() { if (length(self$warnings)) { self$cat_line() - self$cat_line(btw_test_color("======== WARNINGS ========", "warn", self$color)) + self$cat_line(self$colorize("======== WARNINGS ========", "warn")) for (warning in self$warnings) { self$cat_line( - btw_test_color("WARN", "warn", self$color), ": ", + self$colorize("WARN", "warn"), ": ", btw_test_location(warning) ) self$cat_line(format(warning)) @@ -160,10 +159,10 @@ btw_compact_reporter <- function(pkg = ".", filter = NULL) { } if (length(self$failures)) { self$cat_line() - self$cat_line(btw_test_color("======== FAILURES ========", "fail", self$color)) + self$cat_line(self$colorize("======== FAILURES ========", "fail")) for (failure in self$failures) { self$cat_line( - btw_test_color(toupper(btw_test_type(failure)), "fail", self$color), + self$colorize(toupper(btw_test_type(failure)), "fail"), ": ", btw_test_location(failure) ) self$cat_line(format(failure)) diff --git a/tests/testthat/test-pkg-test-reporter.R b/tests/testthat/test-pkg-test-reporter.R index d2ea45cb..7e71daa3 100644 --- a/tests/testthat/test-pkg-test-reporter.R +++ b/tests/testthat/test-pkg-test-reporter.R @@ -53,9 +53,14 @@ test_that("files finishing out of order print only completion lines", { }) test_that("color styling is opt-in", { - expect_equal(btw_test_color("0.12s", "muted", FALSE), "0.12s") + output_file <- withr::local_tempfile() + withr::local_options(testthat.output_file = output_file) + reporter <- btw_compact_reporter() + expect_equal(reporter$colorize("0.12s", "muted"), "0.12s") + withr::local_options(cli.num_colors = 8L) - expect_true(grepl("\033[", btw_test_color("0.12s", "muted", TRUE), fixed = TRUE)) + reporter$color <- TRUE + expect_true(grepl("\033[", reporter$colorize("0.12s", "muted"), fixed = TRUE)) }) test_that("compact duration uses three display digits", { From ab7c0eb3aaaaeb941b184e19907ff11a76f03c15 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Sep 2026 09:32:11 -0400 Subject: [PATCH 04/10] refactor: simplify compact test reporter status --- R/pkg-test-reporter.R | 26 +++++++------------------ R/tool-pkg-devtools.R | 11 ++++++----- man/btw_tool_pkg_test.Rd | 7 ++++--- tests/testthat/test-cli.R | 8 ++++---- tests/testthat/test-pkg-test-reporter.R | 23 +++++++++++----------- tests/testthat/test-tool-pkg-devtools.R | 2 +- 6 files changed, 34 insertions(+), 43 deletions(-) diff --git a/R/pkg-test-reporter.R b/R/pkg-test-reporter.R index d41d2f0e..27306916 100644 --- a/R/pkg-test-reporter.R +++ b/R/pkg-test-reporter.R @@ -31,35 +31,22 @@ btw_test_type <- function(result) { sub("^expectation_", "", class(result)[[1]]) } -btw_compact_reporter <- function(pkg = ".", filter = NULL) { +btw_compact_reporter <- function() { rlang::check_installed("R6") - test_dir <- file.path(pkg, "tests", "testthat") - files <- if (dir.exists(test_dir)) { - testthat::find_test_scripts(test_dir, filter = filter, full.names = FALSE) - } else { - character() - } - width <- if (length(files)) { - max(nchar(btw_test_name(files), type = "width")) - } else { - 0L - } R6::R6Class("BtwCompactReporter", inherit = testthat::Reporter, public = list( name = NULL, file_id = NULL, files = NULL, - name_width = NULL, color = NULL, counts = NULL, failures = NULL, warnings = NULL, - initialize = function(width) { + initialize = function() { super$initialize() self$capabilities$parallel_support <- TRUE self$capabilities$parallel_updates <- TRUE - self$name_width <- width self$files <- list() self$color <- identical(self$out, stdout()) && sink.number() == 0L && cli::num_ansi_colors() > 1L @@ -91,6 +78,8 @@ btw_compact_reporter <- function(pkg = ".", filter = NULL) { started = proc.time()[[3L]], counts = c(P = 0L, F = 0L, S = 0L, W = 0L) ) + self$cat_line(self$colorize(paste0("@ ", self$name), "muted")) + if (identical(self$out, stdout())) flush(stdout()) }, add_result = function(context, test, result) { type <- btw_test_type(result) @@ -137,9 +126,8 @@ btw_compact_reporter <- function(pkg = ".", filter = NULL) { ) styled_time <- self$colorize(btw_test_duration(elapsed), "muted") self$cat_line(sprintf( - "%s %-*s %s %s", - styled_status, max(self$name_width, nchar(self$name, type = "width")), - self$name, styled_time, summary + "%s %s %s %s", + styled_status, self$name, styled_time, summary )) if (identical(self$out, stdout())) flush(stdout()) self$files[[self$file_id]] <- NULL @@ -175,5 +163,5 @@ btw_compact_reporter <- function(pkg = ".", filter = NULL) { )) if (identical(self$out, stdout())) flush(stdout()) } - ))$new(width = width) + ))$new() } diff --git a/R/tool-pkg-devtools.R b/R/tool-pkg-devtools.R index 3aed3e39..888ca38d 100644 --- a/R/tool-pkg-devtools.R +++ b/R/tool-pkg-devtools.R @@ -153,9 +153,10 @@ For iterative development, use the `btw_tool_pkg_test` if available or `devtools #' Tool: Run package tests #' #' Run package tests using [devtools::test()]. Optionally filter tests by name -#' pattern. The default `"compact"` reporter prints a short summary as each -#' file finishes, then failures before a final test summary. Use `"minimal"` -#' for the previous summary-and-failures output, or a testthat reporter name. +#' pattern. The default `"compact"` reporter announces each file, prints a +#' short summary as it finishes, then failures before a final test summary. +#' Use `"minimal"` for the previous summary-and-failures output, or a testthat +#' reporter name. #' #' @param pkg Path to package directory. Defaults to '.'. Must be within #' current working directory. @@ -186,7 +187,7 @@ btw_pkg_test_run <- function(pkg = ".", filter = NULL, reporter = "compact") { resolved_reporter <- switch( reporter, compact = if (utils::packageVersion("testthat") >= "3.1.7") { - btw_compact_reporter(pkg, filter) + btw_compact_reporter() } else { # Older testthat versions don't call the per-file reporter hooks. "check" @@ -236,7 +237,7 @@ The filter parameter accepts a regular expression matched against test file name - No filter runs all tests - It is common to pair `test-{name}.R` with a source `{name}.R` file. To test this file, you can generally use filter = '{name}'. -Use `filter` when working on specific functionality to get faster feedback. The tool always runs all matching tests to completion regardless of failures. `reporter = 'compact'` summarizes each file when it finishes and prints failures at the end; `'minimal'` keeps the previous output. Other testthat reporter names are passed through.", +Use `filter` when working on specific functionality to get faster feedback. The tool always runs all matching tests to completion regardless of failures. `reporter = 'compact'` announces each file as it starts, summarizes it when it finishes, and prints failures at the end; `'minimal'` keeps the previous output. Other testthat reporter names are passed through.", annotations = ellmer::tool_annotations( title = "Testing package", read_only_hint = FALSE, diff --git a/man/btw_tool_pkg_test.Rd b/man/btw_tool_pkg_test.Rd index 697061e0..db4280a8 100644 --- a/man/btw_tool_pkg_test.Rd +++ b/man/btw_tool_pkg_test.Rd @@ -30,9 +30,10 @@ The output from \code{\link[devtools:test]{devtools::test()}}. } \description{ Run package tests using \code{\link[devtools:test]{devtools::test()}}. Optionally filter tests by name -pattern. The default \code{"compact"} reporter prints a short summary as each -file finishes, then failures before a final test summary. Use \code{"minimal"} -for the previous summary-and-failures output, or a testthat reporter name. +pattern. The default \code{"compact"} reporter announces each file, prints a +short summary as it finishes, then failures before a final test summary. +Use \code{"minimal"} for the previous summary-and-failures output, or a testthat +reporter name. } \seealso{ \code{\link[=btw_tools]{btw_tools()}} diff --git a/tests/testthat/test-cli.R b/tests/testthat/test-cli.R index 288e8252..7d96292f 100644 --- a/tests/testthat/test-cli.R +++ b/tests/testthat/test-cli.R @@ -242,18 +242,18 @@ test_that("btw pkg check calls check impl", { expect_equal(env$path, ".") }) -test_that("btw pkg test emits completed-file results by default", { +test_that("btw pkg test emits file starts and completions by default", { args <- NULL local_mocked_bindings( btw_pkg_test_run = function(pkg, filter = NULL, reporter = "compact") { args <<- list(pkg = pkg, filter = filter, reporter = reporter) - cat("✓ utils 0.10s P:1\n") + cat("@ utils\n✓ utils 0.10s P:1\n") } ) env <- run_btw_quietly("pkg", "test", "-f", "utils") expect_equal(env$filter, "utils") expect_equal(args, list(pkg = ".", filter = "utils", reporter = "compact")) - expect_equal(env$.output, "✓ utils 0.10s P:1") + expect_equal(env$.output, c("@ utils", "✓ utils 0.10s P:1")) }) test_that("btw pkg test forwards the reporter and missing filter", { @@ -310,7 +310,7 @@ test_that("btw pkg test streams results before all files finish", { } expect_true(any(grepl("^✓ fast", lines)), info = paste(readLines(error_file, warn = FALSE), collapse = "\n")) expect_true(proc$is_alive(), info = "A file result should arrive while another file is running") - expect_false(any(grepl("^@ ", lines))) + expect_true(any(grepl("^@ slow", lines))) expect_false(any(grepl("^✓ slow", lines))) file.create(gate) proc$wait(timeout = 10000) diff --git a/tests/testthat/test-pkg-test-reporter.R b/tests/testthat/test-pkg-test-reporter.R index 7e71daa3..3b23f58f 100644 --- a/tests/testthat/test-pkg-test-reporter.R +++ b/tests/testthat/test-pkg-test-reporter.R @@ -17,14 +17,15 @@ test_that("compact reporter tracks files and prints a final summary", { output_file <- withr::local_tempfile() withr::local_options(testthat.output_file = output_file) - reporter <- btw_compact_reporter(test_dir) + reporter <- btw_compact_reporter() suppressMessages(testthat::test_dir(scripts, reporter = reporter, stop_on_failure = FALSE)) output <- readLines(output_file, warn = FALSE) + start <- grep("^@ ", output, value = TRUE) done <- grep("^[✓✗!] ", output, value = TRUE) - expect_false(any(grepl("^@ ", output))) + expect_equal(start, c("@ config", "@ tool-run")) expect_length(done, 2) - expect_match(done[[1]], "^✗ config\\s+[0-9.]+s F:1 W:1$") - expect_match(done[[2]], "^✓ tool-run\\s+[0-9.]+s P:1 S:1$") + expect_match(done[[1]], "^✗ config [0-9.]+s F:1 W:1$") + expect_match(done[[2]], "^✓ tool-run [0-9.]+s P:1 S:1$") expect_true("======== FAILURES ========" %in% output) expect_true("======== WARNINGS ========" %in% output) expect_equal(tail(output, 1), "[ FAIL 1 | WARN 1 | SKIP 1 | PASS 1 ]") @@ -33,22 +34,22 @@ test_that("compact reporter tracks files and prints a final summary", { max(which(grepl("^[✓✗!] ", output)))) }) -test_that("files finishing out of order print only completion lines", { +test_that("files finishing out of order each get one start and completion", { output_file <- withr::local_tempfile() withr::local_options(testthat.output_file = output_file) reporter <- btw_compact_reporter() reporter$start_file("test-a.R") - reporter$start_file("test-b.R") + reporter$start_file("test-longer.R") reporter$start_file("test-a.R") # testthat repeats this callback in parallel mode reporter$end_file() - reporter$start_file("test-b.R") + reporter$start_file("test-longer.R") reporter$end_file() reporter$end_reporter() output <- readLines(output_file, warn = FALSE) - expect_false(any(grepl("^@ ", output))) - expect_match(output[[1]], "^✓ a\\s+[0-9.]+s P:0$") - expect_match(output[[2]], "^✓ b\\s+[0-9.]+s P:0$") + expect_equal(grep("^@ ", output, value = TRUE), c("@ a", "@ longer")) + expect_match(output[[3]], "^✓ a [0-9.]+s P:0$") + expect_match(output[[4]], "^✓ longer [0-9.]+s P:0$") expect_equal(tail(output, 1), "[ FAIL 0 | WARN 0 | SKIP 0 | PASS 0 ]") }) @@ -82,7 +83,7 @@ test_that("compact reporter displays empty files and counts errors as failures", output_file <- withr::local_tempfile() withr::local_options(testthat.output_file = output_file) suppressMessages(testthat::test_dir( - scripts, reporter = btw_compact_reporter(test_dir), stop_on_failure = FALSE + scripts, reporter = btw_compact_reporter(), stop_on_failure = FALSE )) output <- readLines(output_file, warn = FALSE) expect_true(any(grepl("^✓ empty\\s+[0-9.]+s P:0$", output))) diff --git a/tests/testthat/test-tool-pkg-devtools.R b/tests/testthat/test-tool-pkg-devtools.R index a14d117b..8049ba3e 100644 --- a/tests/testthat/test-tool-pkg-devtools.R +++ b/tests/testthat/test-tool-pkg-devtools.R @@ -203,7 +203,7 @@ test_that("package test runner resolves built-in and external reporters", { .package = "devtools" ) local_mocked_bindings( - btw_compact_reporter = function(pkg, filter) "CUSTOM" + btw_compact_reporter = function() "CUSTOM" ) btw_pkg_test_run(filter = "utils") From 5cca5b6dcd0f37c06b852f86f7fcb487fe9b6bad Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Sep 2026 09:42:50 -0400 Subject: [PATCH 05/10] test: silence incidental test output --- tests/testthat/helpers.R | 4 +++- tests/testthat/test-btw_chat_history_store.R | 2 ++ tests/testthat/test-btw_client_app.R | 5 +++++ tests/testthat/test-cli.R | 8 ++++---- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/testthat/helpers.R b/tests/testthat/helpers.R index d68cdbcc..5978f1f8 100644 --- a/tests/testthat/helpers.R +++ b/tests/testthat/helpers.R @@ -62,7 +62,9 @@ scrub_system_info <- function(x) { x <- sub( sprintf( "Anthropic/%s", - ellmer::chat_anthropic(credentials = \() "not-a-real-key")$get_model() + suppressMessages( + ellmer::chat_anthropic(credentials = \() "not-a-real-key") + )$get_model() ), "Anthropic/DEFAULT_MODEL", x, diff --git a/tests/testthat/test-btw_chat_history_store.R b/tests/testthat/test-btw_chat_history_store.R index c851631a..a1d026c0 100644 --- a/tests/testthat/test-btw_chat_history_store.R +++ b/tests/testthat/test-btw_chat_history_store.R @@ -1,5 +1,7 @@ skip_if_not_installed("RSQLite") skip_if_no_shinychat_v05() +# Avoid Shiny's attachment banner when the history tests first use Shinychat. +suppressPackageStartupMessages(withr::local_package("shiny")) history_record <- function( id, diff --git a/tests/testthat/test-btw_client_app.R b/tests/testthat/test-btw_client_app.R index f15ad415..85328fc7 100644 --- a/tests/testthat/test-btw_client_app.R +++ b/tests/testthat/test-btw_client_app.R @@ -1,3 +1,8 @@ +# Avoid Shiny's attachment banner when these tests first render the app. +if (requireNamespace("shiny", quietly = TRUE)) { + suppressPackageStartupMessages(withr::local_package("shiny")) +} + test_that("app_set_disabled() namespaces controls and preserves an array payload", { message <- NULL session <- list( diff --git a/tests/testthat/test-cli.R b/tests/testthat/test-cli.R index 7d96292f..cc2482bd 100644 --- a/tests/testthat/test-cli.R +++ b/tests/testthat/test-cli.R @@ -562,12 +562,12 @@ test_that("btw pkg src list auto-loads a dev package found in cwd", { app <- btw_cli_path() local_dev_package("devpkgone") - expect_message( + suppressMessages(expect_message( output <- capture.output( env <- Rapp::run(app, c("pkg", "src", "list", "devpkgone")) ), "Loaded in-development package" - ) + )) expect_match(paste(output, collapse = "\n"), "devpkgone_hello") }) @@ -584,7 +584,7 @@ test_that("btw pkg src get finds a dev package in an R/ subfolder", { app <- btw_cli_path() local_dev_package("devpkgthree", subdir = "R") - expect_message( + suppressMessages(expect_message( output <- capture.output( env <- Rapp::run( app, @@ -592,7 +592,7 @@ test_that("btw pkg src get finds a dev package in an R/ subfolder", { ) ), "Loaded in-development package" - ) + )) expect_match(paste(output, collapse = "\n"), "devpkgthree_hello") }) From 76976a58a45965102df5f51dfb67d62d602b1b22 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Sep 2026 09:44:53 -0400 Subject: [PATCH 06/10] test: wait for both parallel test files before asserting progress --- tests/testthat/test-cli.R | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/testthat/test-cli.R b/tests/testthat/test-cli.R index cc2482bd..08ebece8 100644 --- a/tests/testthat/test-cli.R +++ b/tests/testthat/test-cli.R @@ -302,15 +302,17 @@ test_that("btw pkg test streams results before all files finish", { "Rscript", c("-e", script), stdout = output_file, stderr = error_file ) withr::defer(if (proc$is_alive()) proc$kill()) - deadline <- Sys.time() + 20 + deadline <- Sys.time() + 45 repeat { lines <- if (file.exists(output_file)) readLines(output_file, warn = FALSE) else character() - if (any(grepl("^✓ fast", lines)) || !proc$is_alive() || Sys.time() > deadline) break + fast_done <- any(grepl("^✓ fast", lines)) + slow_started <- any(grepl("^@ slow", lines)) + if ((fast_done && slow_started) || !proc$is_alive() || Sys.time() > deadline) break proc$poll_io(100) } - expect_true(any(grepl("^✓ fast", lines)), info = paste(readLines(error_file, warn = FALSE), collapse = "\n")) + expect_true(fast_done, info = paste(readLines(error_file, warn = FALSE), collapse = "\n")) + expect_true(slow_started, info = "The slow file must start before its gate is released") expect_true(proc$is_alive(), info = "A file result should arrive while another file is running") - expect_true(any(grepl("^@ slow", lines))) expect_false(any(grepl("^✓ slow", lines))) file.create(gate) proc$wait(timeout = 10000) From cba92c9f10fc78411dcec5261120a4aa7a0f01c4 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Sep 2026 09:56:27 -0400 Subject: [PATCH 07/10] feat: default package test tool to minimal reporter --- R/tool-pkg-devtools.R | 24 +++++++++++++----------- exec/btw.R | 7 ++++++- inst/cli-skill/r-btw-cli/SKILL.md | 4 ++++ man/btw_tool_pkg_test.Rd | 15 ++++++++------- tests/testthat/test-cli.R | 8 ++++++++ tests/testthat/test-tool-pkg-devtools.R | 5 +++-- 6 files changed, 42 insertions(+), 21 deletions(-) diff --git a/R/tool-pkg-devtools.R b/R/tool-pkg-devtools.R index 888ca38d..ad20087a 100644 --- a/R/tool-pkg-devtools.R +++ b/R/tool-pkg-devtools.R @@ -153,17 +153,18 @@ For iterative development, use the `btw_tool_pkg_test` if available or `devtools #' Tool: Run package tests #' #' Run package tests using [devtools::test()]. Optionally filter tests by name -#' pattern. The default `"compact"` reporter announces each file, prints a -#' short summary as it finishes, then failures before a final test summary. -#' Use `"minimal"` for the previous summary-and-failures output, or a testthat -#' reporter name. +#' pattern. The default `"minimal"` reporter returns failures and a final +#' summary without per-file progress, which suits non-streaming tool clients. +#' Use `"compact"` to include file starts, per-file results, and timings, or +#' pass a testthat reporter name. #' #' @param pkg Path to package directory. Defaults to '.'. Must be within #' current working directory. #' @param filter Optional regex to filter test files. Example: 'helper' matches #' 'test-helper.R'. -#' @param reporter Either `"compact"` (the default), `"minimal"` (the previous -#' behavior), or a testthat reporter name passed to [devtools::test()]. +#' @param reporter Either `"minimal"` (the default), `"compact"` (per-file +#' progress and timing), or a testthat reporter name passed to +#' [devtools::test()]. #' @inheritParams btw_tool_docs_package_news #' #' @returns The output from [devtools::test()]. @@ -171,7 +172,7 @@ For iterative development, use the `btw_tool_pkg_test` if available or `devtools #' @seealso [btw_tools()] #' @family pkg tools #' @export -btw_tool_pkg_test <- function(pkg = ".", filter = NULL, reporter = "compact", `_intent`) {} +btw_tool_pkg_test <- function(pkg = ".", filter = NULL, reporter = "minimal", `_intent`) {} btw_pkg_test_validate <- function(pkg, filter, reporter) { check_string(pkg) @@ -204,7 +205,7 @@ btw_pkg_test_run <- function(pkg = ".", filter = NULL, reporter = "compact") { )) } -btw_tool_pkg_test_impl <- function(pkg = ".", filter = NULL, reporter = "compact") { +btw_tool_pkg_test_impl <- function(pkg = ".", filter = NULL, reporter = "minimal") { btw_pkg_test_validate(pkg, filter, reporter) # Use one runner for both the captured tool output and the streaming CLI. @@ -229,7 +230,8 @@ btw_tool_pkg_test_impl <- function(pkg = ".", filter = NULL, reporter = "compact Runs `devtools::test()` which executes the test suite in tests/testthat/ and reports: - Number of tests passed, failed, warned, and skipped - Detailed failure messages with file locations -- Test execution time + +The `compact` reporter also shows per-file progress and execution time. The filter parameter accepts a regular expression matched against test file names after stripping the 'test-' prefix and '.R' extension. For example: - filter = 'helper' runs test-helper.R @@ -237,7 +239,7 @@ The filter parameter accepts a regular expression matched against test file name - No filter runs all tests - It is common to pair `test-{name}.R` with a source `{name}.R` file. To test this file, you can generally use filter = '{name}'. -Use `filter` when working on specific functionality to get faster feedback. The tool always runs all matching tests to completion regardless of failures. `reporter = 'compact'` announces each file as it starts, summarizes it when it finishes, and prints failures at the end; `'minimal'` keeps the previous output. Other testthat reporter names are passed through.", +Use `filter` when working on specific functionality to get faster feedback. The tool always runs all matching tests to completion regardless of failures. `reporter = 'minimal'` (default) keeps output short for non-streaming clients, with failures and a final summary. Use `'compact'` for per-file progress and timing; the CLI defaults to compact. Other testthat reporter names are passed through.", annotations = ellmer::tool_annotations( title = "Testing package", read_only_hint = FALSE, @@ -254,7 +256,7 @@ Use `filter` when working on specific functionality to get faster feedback. The required = FALSE ), reporter = ellmer::type_string( - "Reporter: 'compact' (default), 'minimal', or any testthat reporter name.", + "Reporter: 'minimal' (default, brief output), 'compact' (per-file progress and timing), or any testthat reporter name.", required = FALSE ) ) diff --git a/exec/btw.R b/exec/btw.R index aded1461..1b7c3366 100755 --- a/exec/btw.R +++ b/exec/btw.R @@ -953,11 +953,16 @@ switch( }, #| title: Run package tests + #| description: > + #| Run testthat tests. compact (default) streams file starts and results + #| with timing; minimal is a good choice for short, non-streaming output. test = { #| description: Regex to filter test files. #| short: 'f' filter <- "" - #| description: compact, minimal, or a testthat reporter name. + #| description: > + #| compact (default) shows per-file progress and timing. minimal shows + #| failures and a final summary. Other testthat reporter names work too. reporter <- "compact" tryCatch(btw_pkg_test(path, filter, reporter), error = btw_error) }, diff --git a/inst/cli-skill/r-btw-cli/SKILL.md b/inst/cli-skill/r-btw-cli/SKILL.md index b91e8382..ac698e5c 100644 --- a/inst/cli-skill/r-btw-cli/SKILL.md +++ b/inst/cli-skill/r-btw-cli/SKILL.md @@ -35,6 +35,10 @@ btw pkg load [--path ] Load package with pkgload btw pkg coverage [--file ] [--json] Compute test coverage ``` +`btw pkg test` defaults to `--reporter compact` for live file progress and +per-file timings. Use `--reporter minimal` for a short failures-and-summary +report when you do not need to watch progress. + Use `btw pkg src` to inspect the **R namespace implementations** of installed packages (or the dev package via `.`), e.g. to understand behavior the docs don't cover. It returns exact source when available and deparsed functions diff --git a/man/btw_tool_pkg_test.Rd b/man/btw_tool_pkg_test.Rd index db4280a8..7bfc3151 100644 --- a/man/btw_tool_pkg_test.Rd +++ b/man/btw_tool_pkg_test.Rd @@ -7,7 +7,7 @@ btw_tool_pkg_test( pkg = ".", filter = NULL, - reporter = "compact", + reporter = "minimal", `_intent` = "" ) } @@ -18,8 +18,9 @@ current working directory.} \item{filter}{Optional regex to filter test files. Example: 'helper' matches 'test-helper.R'.} -\item{reporter}{Either \code{"compact"} (the default), \code{"minimal"} (the previous -behavior), or a testthat reporter name passed to \code{\link[devtools:test]{devtools::test()}}.} +\item{reporter}{Either \code{"minimal"} (the default), \code{"compact"} (per-file +progress and timing), or a testthat reporter name passed to +\code{\link[devtools:test]{devtools::test()}}.} \item{_intent}{An optional string describing the intent of the tool use. When the tool is used by an LLM, the model will use this argument to @@ -30,10 +31,10 @@ The output from \code{\link[devtools:test]{devtools::test()}}. } \description{ Run package tests using \code{\link[devtools:test]{devtools::test()}}. Optionally filter tests by name -pattern. The default \code{"compact"} reporter announces each file, prints a -short summary as it finishes, then failures before a final test summary. -Use \code{"minimal"} for the previous summary-and-failures output, or a testthat -reporter name. +pattern. The default \code{"minimal"} reporter returns failures and a final +summary without per-file progress, which suits non-streaming tool clients. +Use \code{"compact"} to include file starts, per-file results, and timings, or +pass a testthat reporter name. } \seealso{ \code{\link[=btw_tools]{btw_tools()}} diff --git a/tests/testthat/test-cli.R b/tests/testthat/test-cli.R index 08ebece8..5cb7b5f5 100644 --- a/tests/testthat/test-cli.R +++ b/tests/testthat/test-cli.R @@ -242,6 +242,14 @@ test_that("btw pkg check calls check impl", { expect_equal(env$path, ".") }) +test_that("btw pkg test help explains reporter trade-offs", { + result <- run_btw_subprocess("pkg", "test", "--help") + expect_equal(result$status, 0) + expect_match(result$stdout, "minimal is a good choice", fixed = TRUE) + expect_match(result$stdout, "per-file progress and timing", fixed = TRUE) + expect_match(result$stdout, '[default: "compact"]', fixed = TRUE) +}) + test_that("btw pkg test emits file starts and completions by default", { args <- NULL local_mocked_bindings( diff --git a/tests/testthat/test-tool-pkg-devtools.R b/tests/testthat/test-tool-pkg-devtools.R index 8049ba3e..f1627559 100644 --- a/tests/testthat/test-tool-pkg-devtools.R +++ b/tests/testthat/test-tool-pkg-devtools.R @@ -155,7 +155,7 @@ test_that("btw_tool_pkg_test constructs correct code without filter", { expect_match(result@extra$code, "btw_pkg_test_run") expect_match(result@extra$code, 'pkg = "."') expect_match(result@extra$code, "filter = NULL") - expect_match(result@extra$code, 'reporter = "compact"') + expect_match(result@extra$code, 'reporter = "minimal"') }) test_that("btw_tool_pkg_test constructs correct code with filter", { @@ -178,7 +178,7 @@ test_that("btw_tool_pkg_test constructs correct code with filter", { expect_match(result@extra$code, "btw_pkg_test_run") expect_match(result@extra$code, 'pkg = "."') expect_match(result@extra$code, 'filter = "helper"') - expect_match(result@extra$code, 'reporter = "compact"') + expect_match(result@extra$code, 'reporter = "minimal"') }) test_that("btw_tool_pkg_test forwards reporter names", { @@ -192,6 +192,7 @@ test_that("btw_tool_pkg_test forwards reporter names", { ) expect_match(btw_tool_pkg_test_impl(reporter = "minimal")@extra$code, 'reporter = "minimal"') + expect_match(btw_tool_pkg_test_impl(reporter = "compact")@extra$code, 'reporter = "compact"') expect_match(btw_tool_pkg_test_impl(reporter = "progress")@extra$code, 'reporter = "progress"') expect_error(btw_tool_pkg_test_impl(reporter = 1)) }) From 76fd58a730da9f17c6a75a7dc3152520a38642c7 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Sep 2026 10:10:18 -0400 Subject: [PATCH 08/10] fix: require testthat 3.1.7 for package tests --- DESCRIPTION | 2 +- R/cli.R | 2 +- R/tool-pkg-devtools.R | 1 + tests/testthat/test-tool-pkg-devtools.R | 15 +++++++++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 450721bc..7e2701d9 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -79,7 +79,7 @@ Suggests: RSQLite (>= 2.2.2), shiny, shinychat (>= 0.4.0), - testthat (>= 3.0.0), + testthat (>= 3.1.7), tibble, usethis Config/Needs/website: brand.yml, tidyverse/tidytemplate diff --git a/R/cli.R b/R/cli.R index 34a28c07..7ced9530 100644 --- a/R/cli.R +++ b/R/cli.R @@ -25,7 +25,7 @@ install_btw_cli <- function(destdir = NULL, ...) { "pkgload", "callr", "covr", - "testthat", + "testthat (>= 3.1.7)", "rmarkdown", "pkgsearch" )) diff --git a/R/tool-pkg-devtools.R b/R/tool-pkg-devtools.R index ad20087a..d9fb6fef 100644 --- a/R/tool-pkg-devtools.R +++ b/R/tool-pkg-devtools.R @@ -206,6 +206,7 @@ btw_pkg_test_run <- function(pkg = ".", filter = NULL, reporter = "compact") { } btw_tool_pkg_test_impl <- function(pkg = ".", filter = NULL, reporter = "minimal") { + rlang::check_installed("testthat", version = "3.1.7") btw_pkg_test_validate(pkg, filter, reporter) # Use one runner for both the captured tool output and the streaming CLI. diff --git a/tests/testthat/test-tool-pkg-devtools.R b/tests/testthat/test-tool-pkg-devtools.R index f1627559..8639926c 100644 --- a/tests/testthat/test-tool-pkg-devtools.R +++ b/tests/testthat/test-tool-pkg-devtools.R @@ -158,6 +158,21 @@ test_that("btw_tool_pkg_test constructs correct code without filter", { expect_match(result@extra$code, 'reporter = "minimal"') }) +test_that("btw_tool_pkg_test requires testthat 3.1.7", { + requirement <- NULL + local_mocked_bindings( + check_installed = function(pkg, ..., version = NULL) { + requirement <<- c(pkg = pkg, version = version) + }, + .package = "rlang" + ) + local_mocked_bindings(btw_tool_run_r_impl = function(code) code) + + btw_tool_pkg_test_impl() + + expect_equal(requirement, c(pkg = "testthat", version = "3.1.7")) +}) + test_that("btw_tool_pkg_test constructs correct code with filter", { local_mocked_bindings( btw_tool_run_r_impl = function(code) { From f8c2e8d124cc8d0610920536afe97dfcab7a5dc8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Sep 2026 10:17:07 -0400 Subject: [PATCH 09/10] docs: add compact test reporter news entry --- NEWS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NEWS.md b/NEWS.md index ad25c84b..68823705 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # btw (development version) +* Package tests now support a compact reporter that shows per-file progress, timing, warning and failure details, and final counts. `btw pkg test` uses it by default, while `btw_tool_pkg_test()` defaults to concise output for non-streaming clients; both accept other testthat reporter names (#220). + # btw 1.5.0 ## New features From ae431ccb571df26149740998d902a27c8392dd05 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Sep 2026 10:23:46 -0400 Subject: [PATCH 10/10] fix: escape reporter glyphs for package checks --- R/pkg-test-reporter.R | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/R/pkg-test-reporter.R b/R/pkg-test-reporter.R index 27306916..425728a7 100644 --- a/R/pkg-test-reporter.R +++ b/R/pkg-test-reporter.R @@ -107,11 +107,11 @@ btw_compact_reporter <- function() { elapsed <- proc.time()[[3L]] - file$started counts <- file$counts status <- if (counts[["F"]] > 0L) { - "✗" + "\u2717" } else if (counts[["W"]] > 0L) { "!" } else { - "✓" + "\u2713" } counts <- counts[counts > 0L] if (!length(counts)) { @@ -122,7 +122,7 @@ btw_compact_reporter <- function() { self$colorize(paste0(key, ":", counts[[key]]), style) }, ""), collapse = " ") styled_status <- self$colorize( - status, switch(status, "✓" = "pass", "✗" = "fail", "warn") + status, switch(status, "\u2713" = "pass", "\u2717" = "fail", "warn") ) styled_time <- self$colorize(btw_test_duration(elapsed), "muted") self$cat_line(sprintf(