Nothing
# run_program() is the internal safe shell-out used by the metadata readers.
# It must pass arguments to the CLI verbatim (no shell interpolation) and abort
# cleanly when the program cannot be located.
test_that("run_program() passes arguments verbatim without shell interpolation", {
# Uses printf as a stand-in CLI. POSIX-only: Windows shQuote/echo semantics
# differ and the metadata binaries are exercised in their own gated tests.
skip_on_os("windows")
printf <- Sys.which("printf")
skip_if_not(nzchar(printf), "printf not available")
weird <- "a b'c$x`y;z"
out <- run_program(printf, c("%s", weird))
expect_equal(out, weird)
})
test_that("run_program() aborts when the program is missing", {
expect_error(run_program(NULL, "x"), "locate")
expect_error(run_program("", "x"), "locate")
})
# The remembered location lives under tools::R_user_dir() (M097) -------------
test_that("the published program vocabulary is the one arg_match() enforces", {
# M112 split the body into `tm_set_program()`, and `arg_match()` reads ITS
# formal default, not the exported one -- so the Rd usage line a reader
# copies from and the guard that refuses them are now two separate literals.
# Pinned together here, because nothing else would notice them diverging: a
# fifth program added to only one of the two leaves the help page advertising
# a value the guard refuses (or the reverse), with the whole suite green.
expect_identical(
eval(formals(set_program)$program),
eval(formals(tm_set_program)$program)
)
expect_identical(eval(formals(tm_set_program)$program), tm_program_vocabulary)
})
test_that("set_program() writes exactly at tools::R_user_dir(\"tidymedia\", \"config\")", {
# AC1. Equality over the WHOLE set of files written, never containment:
# rappdirs honors R_USER_CONFIG_DIR too, so "somewhere under the redirected
# dir" is true of the pre-M097 function as well (measured at the plan gate:
# `<dir>/tidymedia` vs `<dir>/R/tidymedia`).
expect_identical(eval(formals(set_program)$program), tm_program_vocabulary)
config <- withr::local_tempdir()
withr::local_envvar(R_USER_CONFIG_DIR = config)
stub <- tm_stub_executable()
for (program in tm_program_vocabulary) set_program(program, stub, confirm = FALSE)
expected <- file.path(
tools::R_user_dir("tidymedia", "config"),
paste0(tm_program_vocabulary, "_location.txt")
)
written <- list.files(config, recursive = TRUE, full.names = TRUE, all.files = TRUE)
expect_setequal(normalizePath(written), normalizePath(expected))
for (file in expected) expect_identical(readLines(file), stub)
})
test_that("both config directories are redirected, and differ", {
# AC2's guard on its own instrument: neither library may reach the user's
# real config dir, and the two must not collapse onto one path (which would
# make the both-present state assert nothing).
dirs <- tm_redirect_config()
root <- normalizePath(dirs$root)
# The legacy dir is pinned to what the helper ASKED rappdirs for, not to the
# helper's own answer: a helper body that drifts (say, dropping the "R"
# appauthor, the pre-M097 Windows layout) would otherwise write and read its
# own wrong path and stay green. The mock's return value encodes the call.
expect_identical(
dirs$legacy, file.path(dirs$root, "legacy", "R", "tidymedia")
)
expect_identical(dirs$new, tools::R_user_dir("tidymedia", "config"))
expect_false(identical(normalizePath(dirs$new), normalizePath(dirs$legacy)))
expect_true(startsWith(normalizePath(dirs$new), root))
expect_true(startsWith(normalizePath(dirs$legacy), root))
expect_identical(Sys.which("ffmpeg"), c(ffmpeg = ""))
})
test_that("a location written before M097 is still returned after it", {
# AC2, three file states per program. Distinct stubs at the two paths, so
# the returned value says WHICH file was read, not merely that one was.
dirs <- tm_redirect_config()
old_stub <- tm_stub_executable("old")
new_stub <- tm_stub_executable("new")
expect_false(identical(old_stub, new_stub))
for (program in tm_program_vocabulary) {
# legacy alone
tm_write_location(dirs$legacy, program, old_stub)
expect_no_warning(expect_identical(find_program(program), old_stub))
# both: the R_user_dir() one wins
tm_write_location(dirs$new, program, new_stub)
expect_no_warning(expect_identical(find_program(program), new_stub))
# R_user_dir() alone
unlink(tm_config_file(program, dirs$legacy))
expect_no_warning(expect_identical(find_program(program), new_stub))
}
})
test_that("the stale-location branch fires from the legacy path too", {
# AC3. A legacy file naming a binary that no longer exists produces the
# existing warning and a NULL location, as it did before the move.
dirs <- tm_redirect_config()
gone <- file.path(withr::local_tempdir(), "gone")
expect_false(file.exists(gone))
for (program in tm_program_vocabulary) {
tm_write_location(dirs$legacy, program, gone)
expect_warning(
expect_null(find_program(program)),
"no longer seems to exist"
)
}
})
test_that("with no file at either path, find_program() still warns and returns NULL", {
# The pre-M097 nothing-configured branch survives the fallback unchanged.
tm_redirect_config()
expect_warning(expect_null(find_program("ffmpeg")), "Failed to find ffmpeg")
})
# The not-found warning's recovery advice (M115 AC5) -------------------------
# The warning, captured whole, for one program on one held platform. `tm_os()`
# is the only seam held: the branch under test reads it and nothing else about
# the host, and holding anything wider would stop the test saying which fact
# decided the message.
tm_not_found_warning <- function(program, os, env = parent.frame()) {
tm_redirect_config(env = env)
testthat::local_mocked_bindings(tm_os = function(...) os, .env = env)
rlang::catch_cnd(find_program(program), classes = "warning")
}
test_that("the not-found warning names set_<program>() on every platform", {
# AC5. Both branches, so the call the caller always has is never the one the
# widening dropped.
for (os in c("windows", "darwin", "linux")) {
for (program in tm_program_vocabulary) {
w <- tm_not_found_warning(program, os)
expect_match(conditionMessage(w), paste0("Failed to find ", program),
fixed = TRUE, info = paste(os, program))
expect_match(conditionMessage(w), paste0("set_", program, "()"),
fixed = TRUE, info = paste(os, program))
}
}
})
test_that("the not-found warning offers install_on_win() only where it runs", {
# AC5. The offer is made for the three programs the installer registers, on
# Windows, and for nothing else -- naming it to a macOS caller, or for
# mediainfo, points at a call that refuses them. Asserted over the whole
# vocabulary crossed with three platforms, so a widening in either dimension
# shows up as a failure rather than as an untested cell.
for (os in c("windows", "darwin", "linux")) {
for (program in tm_program_vocabulary) {
offered <- identical(os, "windows") && program %in% tm_install_registers
msg <- conditionMessage(tm_not_found_warning(program, os))
expect_equal(grepl("install_on_win()", msg, fixed = TRUE), offered,
info = paste(os, program))
}
}
})
test_that("the not-found warning's class vector is unchanged by the widening", {
# AC5. The M110 condition contract: this site carries no class of its own and
# no data fields, and the widened message must not have quietly added either.
# Both branches, because the offer is built by appending a bullet and an
# appended bullet is exactly where a stray class or field could arrive. The
# two figures below are what this site produced BEFORE the widening, read off
# the pre-M115 code at 78263f7 rather than off the new code, so the
# expectation is not derived from the artifact it is checking.
for (os in c("windows", "darwin")) {
w <- tm_not_found_warning("ffmpeg", os)
expect_identical(class(w), c("rlang_warning", "warning", "condition"),
info = os)
expect_identical(sort(names(w)),
c("call", "footer", "message", "parent"), info = os)
}
})
# install_on_win()'s default install dir (M098) ------------------------------
# `R_USER_DATA_DIR` is what tools::R_user_dir() reads, so pointing it at a
# temp dir keeps the test out of the user's real data directory. Returns the
# redirected root so a test can assert the full set of directories created
# under it.
tm_redirect_data <- function(env = parent.frame()) {
root <- withr::local_tempdir(.local_envir = env)
withr::local_envvar(c(R_USER_DATA_DIR = root), .local_envir = env)
root
}
test_that("install_on_win()'s own default install dir is R_user_dir()'s ffmpeg subdir", {
tm_local_windows()
# AC2. The function is called with no `install_dir`, so it is the function's
# own default resolution that runs, not a helper called in its place. The
# download is aimed at a `file://` URL that does not exist: no network is
# touched, and `download.file()` fails only AFTER the default has resolved
# and the directory has been created, which is the state being asserted.
# The config directory is redirected too, so the registration this call never
# reaches could not touch the developer's real one even if it did.
config <- tm_redirect_config()
root <- tm_redirect_data()
expected <- file.path(tools::R_user_dir("tidymedia", "data"), "ffmpeg")
# tools::R_user_dir() interposes an `R` component under the envvar's root.
expect_identical(expected, file.path(root, "R", "tidymedia", "ffmpeg"))
# `file:///` + a path with its leading separator removed is the absolute-path
# form on both families: `file:///var/...` on Unix, `file:///C:/...` on
# Windows. Built this way so the call fails because the archive is missing,
# not because the URL is malformed.
missing_archive <- paste0(
"file:///",
sub("^/", "", chartr("\\", "/", file.path(withr::local_tempdir(), "no-such-build.7z")))
)
# `confirm = FALSE`: this test is about where the default install directory
# resolves to, and M101 put a consent gate above the resolution's first
# write. The gate has its own tests below. The URL is not the package's own
# default, so M102 fetches no digest and says so before it refuses the
# download; the refusal is asserted by class, not by base R's wording.
# M103 made a refused install take its own directories back again, so the
# chain this test reads is gone by the time the call returns. The removal is
# held here rather than defeated: the seam records what it was handed and
# leaves it in place, so the directories below are still the ones the call
# actually made, and the recorded set says the removal aimed at exactly them
# and nothing above.
removed <- NULL
testthat::local_mocked_bindings(
tm_remove_created_dirs = function(dirs) {
removed <<- dirs
invisible(NULL)
}
)
suppressWarnings(
expect_error(
suppressMessages(install_on_win(download_url = missing_archive, confirm = FALSE)),
class = "tidymedia_download_unavailable"
)
)
# The `ffmpeg` subdirectory is preserved, not dropped: the data directory
# itself is not the extraction root. Pinned as the full set of directories
# created beneath the redirected root, so a write anywhere else fails here.
# Both sides normalized: `list.dirs()` and `file.path()` disagree on the
# separator under a Windows `tempdir()`.
chain <- c(file.path(root, "R"), file.path(root, "R", "tidymedia"), expected)
expect_identical(
normalizePath(setdiff(list.dirs(root), root)),
normalizePath(chain)
)
# And the removal was handed that same chain, outermost first (M103 AC3).
expect_identical(
normalizePath(removed, winslash = "/", mustWork = FALSE),
normalizePath(chain, winslash = "/", mustWork = FALSE)
)
# Nothing was registered: the three set_*() calls run only after extraction,
# so a failed download must leave the config directory empty. This is what
# fails if that ordering is ever inverted.
expect_identical(
list.files(config$root, recursive = TRUE, all.files = TRUE),
character(0)
)
})
# tm_confirm() (M101) --------------------------------------------------------
test_that("tm_confirm() returns the reader's answer when someone can be asked", {
# T1's ask branch. `rlang::local_interactive()` moves what tm_confirm()
# gates on but not `base::interactive()`, which is what menu() itself
# refuses on, so the mock is the only route into this branch.
rlang::local_interactive()
answers <- c(1L, 2L, 0L)
seen <- list()
testthat::local_mocked_bindings(
menu = function(choices, graphics = FALSE, title = NULL) {
seen[[length(seen) + 1L]] <<- list(choices = choices, title = title)
answers[[length(seen)]]
},
.package = "utils"
)
expect_true(tm_confirm("proceed?"))
expect_false(tm_confirm("proceed?"))
# 0 is what menu() returns when the reader answers nothing: a decline, not
# an approval.
expect_false(tm_confirm("proceed?"))
expect_identical(length(seen), 3L)
for (call in seen) {
expect_identical(call$choices, c("Yes", "No"))
expect_identical(call$title, "proceed?")
}
})
test_that("tm_confirm() refuses, with the caller's bullets, when no one can be asked", {
# T1's refusal branch, and the seam property D080 states: the message names
# the escape hatch its CALLER supplied, so the helper carries no argument
# name of its own. A menu() mock that aborts proves the ask is never
# reached rather than merely that the abort happened first.
testthat::local_mocked_bindings(
menu = function(...) stop("menu() must not be reached"),
.package = "utils"
)
withr::local_options(rlang_interactive = FALSE)
expect_error(
tm_confirm("proceed?", "i" = "Pass {.code somehow = FALSE} to skip."),
class = "tidymedia_confirmation_unavailable"
)
expect_error(tm_confirm("proceed?", "i" = "Pass somehow = FALSE."), "somehow = FALSE")
# Nothing of the helper's own is asserted about a hatch it was not given.
expect_error(tm_confirm("proceed?"), "non-interactive session")
})
test_that("tm_confirm() refuses the same way when menu() itself cannot ask", {
# The two predicates can disagree: `rlang_interactive = TRUE` in a session
# with no console leaves rlang::is_interactive() TRUE while menu() still
# refuses. Before this was routed through the refusal, that reached the
# caller as menu()'s own unclassed error with no escape hatch named.
rlang::local_interactive()
testthat::local_mocked_bindings(
menu = function(...) stop("menu() cannot be used non-interactively"),
.package = "utils"
)
expect_error(
tm_confirm("proceed?", "i" = "Pass {.code somehow = FALSE} to skip."),
class = "tidymedia_confirmation_unavailable"
)
expect_error(tm_confirm("proceed?", "i" = "Pass somehow = FALSE."), "somehow = FALSE")
})
# install_on_win() asks before it writes (M101) ------------------------------
# The two REDIRECTED ROOTS an install can touch, as M101 AC1 states them: every
# file and every directory beneath each. A declined or refused call must
# leave both exactly as it found them.
tm_roots_snapshot <- function(...) {
lapply(c(...), function(r) {
list(
files = list.files(r, recursive = TRUE, all.files = TRUE),
dirs = list.dirs(r)
)
})
}
# Records every write install_on_win() would make instead of making it, so a
# test asserts WHICH calls happened rather than only that the function
# returned. `confirm` is a function of the prompt returning the answer to
# give; `NULL` leaves the real tm_confirm() in place.
tm_mock_install <- function(confirm = NULL,
unpack = tm_install_registers,
sidecar = NULL,
archive = NULL,
download = NULL,
real_set = FALSE,
spoil = NULL,
env = parent.frame()) {
# Every install this mocks runs below M108's platform gate.
tm_local_windows(env = env)
rec <- new.env(parent = emptyenv())
rec$download <- list()
rec$sidecar <- list()
rec$extract <- list()
rec$set <- list()
rec$prompts <- character(0)
add <- function(slot, value) rec[[slot]] <- c(rec[[slot]], list(value))
# What the download mock delivers. The body is fixed, so its digest can be
# computed here and published by the sidecar mock below: a verifying run
# passes because the two agree, not because verification was skipped. A test
# wanting a mismatch publishes its own `sidecar` body, and a test wanting a
# real extraction failure passes its own `archive` file.
if (is.null(archive)) {
archive <- withr::local_tempfile(.local_envir = env)
writeLines("stub archive", archive)
}
rec$digest <- tm_archive_digest(archive)
# `download` overrides the whole download.file() mock, which is how the two
# shapes of a failed fetch are reached: signalling, and a non-zero status.
testthat::local_mocked_bindings(
download.file = function(url, destfile, ...) {
# The digest and the archive are two calls to one function, told apart
# here the way install_on_win() builds them: the digest's URL is the
# archive's with `.sha256` appended.
is_sidecar <- grepl("[.]sha256$", url)
add(if (is_sidecar) "sidecar" else "download", list(url = url))
if (!is.null(download)) return(download(url, destfile, is_sidecar))
if (is_sidecar) {
writeLines(if (is.null(sidecar)) rec$digest else sidecar, destfile)
return(0L)
}
# The destfile is a fresh tempfile() on every call, so it is held aside
# rather than recorded: two runs of the same install must produce equal
# records. The extract mock below checks the linkage instead.
rec$destfile <- destfile
file.copy(archive, destfile, overwrite = TRUE)
0L
},
.package = "utils", .env = env
)
# `unpack = NULL` leaves the REAL archive::archive_extract() in place, which
# is how the corrupt-archive fixtures reach libarchive rather than a stub.
if (!is.null(unpack)) {
testthat::local_mocked_bindings(
archive_extract = function(archive, dir, ...) {
# tm_unpack() hands archive_extract() an open connection rather than
# a path -- it owns the connection so the failing path can close it
# (M102 AC3) -- so the linkage back to the download is read off the
# connection's description.
from <- if (inherits(archive, "connection")) {
summary(archive)$description
} else {
archive
}
add("extract", list(dir = dir, from_download = identical(from, rec$destfile)))
# What the extraction PRODUCES is what install_on_win() registers, so
# the mock has to leave files behind: `unpack` is how a test says
# which programs this build contained.
bin <- file.path(dir, "bin")
# `spoil` names, per program, which of the four unusable forms to
# plant instead of a working stub (M104 AC4). Every form is still
# LISTED in the return value below, because the list is what the
# extraction reported and the check exists to disagree with it.
forms <- vapply(
unpack,
function(program) {
if (program %in% names(spoil)) spoil[[program]] else "good"
},
character(1)
)
# `bin/` is created only where something is going to be written into
# it. An extraction whose every path is `absent` created nothing, so
# it leaves the install directory as empty as it found it -- which is
# the state M105's all-absent refusal describes, and an unconditional
# `bin/` here would make that directory non-empty and put the
# refusal's directory-removal arm out of reach.
if (any(forms != "absent")) {
dir.create(bin, recursive = TRUE, showWarnings = FALSE)
}
for (program in unpack) {
exe <- file.path(bin, paste0(program, ".exe"))
form <- forms[[program]]
switch(
form,
# Listed and never created: nothing is written at all.
absent = NULL,
# Created with no bytes -- the truncation a bit test cannot see.
empty = file.create(exe),
dir = dir.create(exe),
# Bytes but no executable bit. POSIX only: Windows has no such
# bit, which is why the check does not rest on it alone.
noexec = {
writeLines("stub program", exe)
Sys.chmod(exe, "0644")
},
# A working stub is NON-EMPTY, because the registration check asks
# for bytes: the zero-length stub this helper used to write would
# now refuse every install it mocks.
good = writeLines("stub program", exe),
stop("unknown spoil form: ", form)
)
# The stub is made executable whatever `real_set` says. Two checks
# ask Sys.which() about it: the REAL set_program(), reached only
# under `real_set = TRUE`, and install_on_win()'s own registration
# check, which runs on every path (M104). Before that second check
# existed this bit was needed only for the first. The two forms
# whose whole point is that they do not resolve are the exception.
if (!form %in% c("absent", "dir", "noexec")) Sys.chmod(exe, "0755")
}
# What the install directory held the moment the extraction finished,
# so a refusal below it can be held to leaving that state alone
# (M104 AC2).
rec$after_extract <- tm_dir_snapshot(dir)
# The real archive_extract() returns the paths it wrote, relative to
# `dir` and after strip_components -- measured 2026-09-02 against a
# three-program control archive, which returned exactly these three
# strings. install_on_win() registers off that list, so a mock that
# returned nothing would make every AC4 assertion vacuous.
invisible(file.path("bin", paste0(unpack, ".exe")))
},
.package = "archive", .env = env
)
}
# `real_set = TRUE` leaves the real set_program() in place, so what the
# install writes can be read back off disk rather than off the record.
if (!real_set) {
testthat::local_mocked_bindings(
set_program = function(program, location, confirm = TRUE) {
add("set", list(program = program, location = location,
confirm = confirm))
invisible(NULL)
},
.env = env
)
}
if (!is.null(confirm)) {
testthat::local_mocked_bindings(
tm_confirm = function(prompt, ...) {
rec$prompts <- c(rec$prompts, prompt)
confirm(prompt)
},
.env = env
)
}
rec
}
test_that("install_on_win() refuses rather than assume consent when no one can be asked", {
tm_local_windows()
# AC1. Nothing is mocked below tm_confirm(): the refusal has to happen
# before the first write, so the real download and extraction are simply
# never reached, and the two snapshots are what says so.
config <- tm_redirect_config()
data_root <- tm_redirect_data()
withr::local_options(rlang_interactive = FALSE)
expect_false(rlang::is_interactive())
before <- tm_roots_snapshot(data_root, config$root)
expect_error(install_on_win(), class = "tidymedia_confirmation_unavailable")
# The escape hatch is named by install_on_win(), not by the seam, so the
# message says the argument this caller actually has.
expect_error(install_on_win(), "confirm = FALSE")
# The refusal names what the call would have done, so a caller told to pass
# `confirm = FALSE` has seen what that authorizes.
withr::local_options(cli.width = 1000)
u <- "https://example.invalid/build.7z"
d <- file.path(withr::local_tempdir(), "an install {dir}")
# Suppressed rather than asserted here: this source is caller-named, so the
# unverified notice now fires above the confirmation, and the ordering it
# belongs to is asserted with the prompt tests below.
msg <- suppressMessages(tryCatch(
install_on_win(download_url = u, install_dir = d),
tidymedia_confirmation_unavailable = function(cnd) cli::ansi_strip(conditionMessage(cnd))
))
for (needle in c(u, d, vapply(tm_install_registers, tm_config_file, character(1), USE.NAMES = FALSE))) {
expect_true(grepl(needle, msg, fixed = TRUE), label = paste("refusal names", needle))
}
expect_identical(tm_roots_snapshot(data_root, config$root), before)
})
test_that("a declined install creates, downloads, extracts and registers nothing", {
# AC2. The three mocks would record a call if one were made, so zero
# records is evidence about the calls themselves and not only about the
# directories they would have written to.
config <- tm_redirect_config()
data_root <- tm_redirect_data()
rec <- tm_mock_install(confirm = function(prompt) FALSE)
before <- tm_roots_snapshot(data_root, config$root)
expect_false(install_on_win())
expect_identical(rec$download, list())
expect_identical(rec$extract, list())
expect_identical(rec$set, list())
expect_identical(tm_roots_snapshot(data_root, config$root), before)
})
test_that("an accepted install downloads, extracts and registers; confirm = FALSE does the same without asking", {
# AC3 and AC5, in one block so AC5's "identical to AC3's record" is asserted
# against the very record AC3 pinned, over the same URL and directory.
tm_redirect_config()
tm_redirect_data()
u <- "https://example.invalid/ffmpeg-release-essentials.7z"
d <- file.path(withr::local_tempdir(), "ffmpeg")
accepted <- tm_mock_install(confirm = function(prompt) TRUE)
# A caller-named source is unverified, so M102's message fires on every call
# in this test. It is asserted where it belongs -- the prompt/verification
# tests below -- and only muted here, where it is not what is being tested.
expect_true(suppressMessages(
install_on_win(download_url = u, install_dir = d, confirm = TRUE)
))
expect_identical(length(accepted$download), 1L)
expect_identical(accepted$download[[1]]$url, u)
expect_identical(length(accepted$extract), 1L)
expect_identical(accepted$extract[[1]]$dir, d)
# What is unpacked is what was just downloaded, not some other file.
expect_true(accepted$extract[[1]]$from_download)
expect_identical(
vapply(accepted$set, function(x) x$program, character(1)),
c("ffmpeg", "ffprobe", "ffplay")
)
expect_identical(
vapply(accepted$set, function(x) x$location, character(1)),
file.path(d, "bin", c("ffmpeg.exe", "ffprobe.exe", "ffplay.exe"))
)
# AC5: someone IS there to ask, and the call still must not ask. The mock
# aborts rather than returning a value, so reaching it fails the test
# instead of quietly changing what is asserted below.
rlang::local_interactive()
skipped <- tm_mock_install(
confirm = function(prompt) stop("tm_confirm() must not be reached")
)
expect_true(suppressMessages(
install_on_win(download_url = u, install_dir = d, confirm = FALSE)
))
expect_identical(skipped$prompts, character(0))
expect_identical(skipped$download, accepted$download)
expect_identical(skipped$extract, accepted$extract)
expect_identical(skipped$set, accepted$set)
})
test_that("the prompt names the archive, the directory, and every location it overwrites", {
# AC4. The prompt is read as the caller handed it over, before menu() ever
# formats it. `cli.width` is pinned because AC4 names it, not because
# anything here wraps: cli::format_inline() emits one line whatever the
# width, so the option fences a formatter change rather than today's.
config <- tm_redirect_config()
data_root <- tm_redirect_data()
withr::local_options(cli.width = 1000)
rec <- tm_mock_install(confirm = function(prompt) TRUE)
default_url <- "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.7z"
default_dir <- file.path(tools::R_user_dir("tidymedia", "data"), "ffmpeg")
named_url <- "https://example.invalid/build.7z"
named_dir <- file.path(withr::local_tempdir(), "plain")
# A directory whose name contains a space and a brace: cli interpolates
# every bullet in the calling frame, so a raw `{...}` in a value would abort
# the prompt or print a name that does not exist (M44).
odd_dir <- file.path(withr::local_tempdir(), "an install {dir}")
expect_true(install_on_win())
# As above: the two caller-named sources are unverified, and that message is
# asserted in the verification tests rather than here.
expect_true(suppressMessages(
install_on_win(download_url = named_url, install_dir = named_dir)
))
expect_true(suppressMessages(
install_on_win(download_url = named_url, install_dir = odd_dir)
))
expect_identical(length(rec$prompts), 3L)
# The set of files the prompt must name is read off the record of what the
# call actually registered, never hand-listed here.
registered <- unique(vapply(rec$set, function(x) x$program, character(1)))
expect_identical(registered, c("ffmpeg", "ffprobe", "ffplay"))
config_files <- vapply(registered, tm_config_file, character(1), USE.NAMES = FALSE)
expect_true(all(startsWith(config_files, config$new)))
# tm_config_file() is what builds the prompt's paths too, so pin the names
# AC4 states rather than leave both sides free to move together (M097 F1).
expect_identical(basename(config_files), paste0(registered, "_location.txt"))
expected <- list(
c(default_url, default_dir, config_files),
c(named_url, named_dir, config_files),
c(named_url, odd_dir, config_files)
)
for (i in seq_along(expected)) {
for (needle in expected[[i]]) {
expect_true(
grepl(needle, rec$prompts[[i]], fixed = TRUE),
label = paste0("prompt ", i, " contains ", needle)
)
}
}
})
# install_on_win() verifies before it registers (M102) ------------------------
# The digest of the stub archive tm_mock_install() delivers, written the three
# ways a source can publish one. Built from the record's own digest rather than
# hard-coded, so the fixture cannot drift from the file it describes.
tm_sidecar_bodies <- function(digest) {
c(
bare = digest,
sha256sum = paste0(digest, " ffmpeg-release-essentials.7z"),
openssl = paste0("SHA256(ffmpeg-release-essentials.7z)= ", toupper(digest))
)
}
test_that("the default source's published digest is fetched, parsed three ways, and checked", {
# AC1. The URL is left at its default, so it is install_on_win()'s own
# resolution that decides a sidecar is available -- not the test naming one.
tm_redirect_config()
tm_redirect_data()
for (shape in names(tm_sidecar_bodies("x"))) {
# A fresh install dir per iteration: the shapes are meant to be
# independent runs, and a shared directory would let one iteration's
# extraction stand in for the next one's.
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(confirm = function(prompt) TRUE)
body <- tm_sidecar_bodies(rec$digest)[[shape]]
# Re-mock with the body this shape wants, now that the digest is known.
rec <- tm_mock_install(confirm = function(prompt) TRUE, sidecar = body)
expect_true(install_on_win(install_dir = d), label = shape)
# The digest was fetched, from the archive's URL plus `.sha256`. That it
# was fetched BEFORE the archive is not visible here -- the two records
# are separate lists -- and is asserted where it IS visible: the
# unreadable-digest test below finds `download` empty after the sidecar
# fetch failed.
expect_identical(length(rec$sidecar), 1L, label = shape)
expect_identical(
rec$sidecar[[1]]$url,
paste0(rec$download[[1]]$url, ".sha256"),
label = shape
)
# The matching-digest control reaches the extraction step, which is what
# says the three shapes were understood rather than skipped.
expect_identical(length(rec$extract), 1L, label = shape)
expect_identical(rec$extract[[1]]$dir, d, label = shape)
}
})
test_that("the consent prompt names the digest fetch, and only where one happens", {
# AC1 and AC2 together: M101's property is that the prompt names every fetch
# the call makes, so the sidecar line has to appear on the path that fetches
# one and be absent on the two paths that do not.
tm_redirect_config()
tm_redirect_data()
withr::local_options(cli.width = 1000)
d <- file.path(withr::local_tempdir(), "ffmpeg")
sidecar_url <- paste0(tm_default_download_url, ".sha256")
rec <- tm_mock_install(confirm = function(prompt) TRUE)
expect_true(install_on_win(install_dir = d))
expect_true(grepl(sidecar_url, rec$prompts[[1]], fixed = TRUE))
# Same source, but the caller brought a digest: no fetch, so no line.
supplied <- tm_mock_install(confirm = function(prompt) TRUE)
digest <- supplied$digest
supplied <- tm_mock_install(confirm = function(prompt) TRUE)
expect_true(install_on_win(install_dir = d, archive_checksum = digest))
expect_identical(supplied$sidecar, list())
expect_false(grepl(sidecar_url, supplied$prompts[[1]], fixed = TRUE))
# A caller-named source has no sidecar to fetch either. It says so, and it
# says so BEFORE the prompt: what a caller approves includes knowing that
# nothing will check this download, which an after-the-fact notice does not
# give them. The two events are recorded into one sequence, so the ordering
# is asserted rather than inferred from where the message appears.
order <- character(0)
named <- tm_mock_install(confirm = function(prompt) {
order <<- c(order, "prompt")
TRUE
})
withCallingHandlers(
expect_true(install_on_win(
download_url = "https://example.invalid/build.7z", install_dir = d
)),
message = function(cnd) {
if (grepl("will not be verified", conditionMessage(cnd), fixed = TRUE)) {
order <<- c(order, "notice")
}
invokeRestart("muffleMessage")
}
)
expect_identical(order, c("notice", "prompt"))
expect_identical(named$sidecar, list())
expect_false(grepl(".sha256", named$prompts[[1]], fixed = TRUE))
})
test_that("an unreadable published digest aborts before anything is unpacked", {
# AC1. Three routes to the same class: a body that is not a digest, a fetch
# that signals, and a fetch that returns a non-zero status.
config <- tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
garbage <- tm_mock_install(confirm = function(prompt) TRUE,
sidecar = "<html>404 Not Found</html>")
expect_error(
install_on_win(install_dir = d),
class = "tidymedia_checksum_unavailable"
)
expect_identical(garbage$extract, list())
expect_identical(garbage$set, list())
signalled <- tm_mock_install(
confirm = function(prompt) TRUE,
download = function(url, destfile, is_sidecar) {
if (is_sidecar) stop("cannot open URL") else 0L
}
)
expect_error(
install_on_win(install_dir = d),
class = "tidymedia_checksum_unavailable"
)
# The digest is fetched first, so the archive was never downloaded either.
expect_identical(signalled$download, list())
status <- tm_mock_install(
confirm = function(prompt) TRUE,
download = function(url, destfile, is_sidecar) if (is_sidecar) 1L else 0L
)
expect_error(
install_on_win(install_dir = d),
class = "tidymedia_checksum_unavailable"
)
expect_identical(status$download, list())
expect_identical(
list.files(config$root, recursive = TRUE, all.files = TRUE),
character(0)
)
})
test_that("a sidecar the fetch reported but did not deliver aborts classed", {
# `download.file()` reporting status 0 is not a promise that a readable file
# arrived. Without a guard on the read, this path leaves install_on_win()
# through a bare "cannot open connection" -- unclassed, and invisible to the
# AC6 census, which sees only `return()` and `cli_abort()` nodes.
tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE,
download = function(url, destfile, is_sidecar) 0L
)
expect_error(
install_on_win(install_dir = d),
class = "tidymedia_checksum_unavailable"
)
expect_identical(rec$extract, list())
expect_identical(rec$set, list())
})
test_that("a digest that does not match aborts, names both digests, and registers nothing", {
# AC1. The config directory is compared before and after, so the claim is
# about the remembered locations rather than about the return value.
config <- tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
wrong <- paste(rep("a", 64), collapse = "")
rec <- tm_mock_install(confirm = function(prompt) TRUE, sidecar = wrong)
before <- tm_roots_snapshot(config$root)
msg <- tryCatch(
install_on_win(install_dir = d),
tidymedia_checksum_mismatch = function(cnd) cli::ansi_strip(conditionMessage(cnd))
)
# Both digests are named: the one that was published, and the one the
# downloaded file actually has.
expect_true(grepl(wrong, msg, fixed = TRUE))
expect_true(grepl(rec$digest, msg, fixed = TRUE))
expect_identical(rec$extract, list())
expect_identical(rec$set, list())
expect_identical(tm_roots_snapshot(config$root), before)
})
test_that("a caller-supplied digest is checked case-insensitively and fetches nothing", {
# AC2. Both cases of the same digest are accepted, and neither one sends the
# call to fetch a second copy of a digest it was already handed.
tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
probe <- tm_mock_install(confirm = function(prompt) TRUE)
digest <- probe$digest
for (value in c(digest, toupper(digest))) {
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(confirm = function(prompt) TRUE)
expect_true(install_on_win(install_dir = d, archive_checksum = value))
expect_identical(rec$sidecar, list())
expect_identical(length(rec$extract), 1L)
}
# And a supplied digest that is wrong still refuses, on this path as on the
# published one -- the class is the same failure.
wrong <- tm_mock_install(confirm = function(prompt) TRUE)
expect_error(
install_on_win(install_dir = d, archive_checksum = paste(rep("a", 64), collapse = "")),
class = "tidymedia_checksum_mismatch"
)
expect_identical(wrong$set, list())
})
test_that("archive_checksum is refused at the front door, unclassed, before any fetch", {
# AC2. The four rejected shapes, and the ordering: the download mock aborts
# rather than returning, so a value that got past the check would fail here
# instead of quietly passing the test.
tm_redirect_config()
tm_redirect_data()
testthat::local_mocked_bindings(
download.file = function(...) stop("download.file() must not be reached"),
.package = "utils"
)
hex <- paste(rep("a", 64), collapse = "")
bad <- list(
short = substr(hex, 1, 63),
non_hex = paste0(substr(hex, 1, 63), "z"),
missing = NA_character_,
two = c(hex, hex)
)
for (name in names(bad)) {
cnd <- tryCatch(
install_on_win(archive_checksum = bad[[name]], confirm = FALSE),
error = function(cnd) cnd
)
expect_s3_class(cnd, "rlang_error")
# The argument is named, so the caller is told which one to fix.
expect_true(
grepl("archive_checksum", cli::ansi_strip(conditionMessage(cnd)), fixed = TRUE),
label = paste(name, "names the argument")
)
# And, like the package's other front-door checks, it is unclassed: the
# tidymedia_* classes describe an install that went wrong, and here none
# was attempted.
expect_false(
any(grepl("^tidymedia_", class(cnd))),
label = paste(name, "carries no tidymedia_ class")
)
}
})
test_that("an archive libarchive cannot read aborts without libarchive's text", {
# AC3. `unpack = NULL` leaves the real archive::archive_extract() in place,
# so these two fixtures reach libarchive itself. They cover its two failure
# routes: refusing to open at all, and failing to decompress an archive it
# did open (data-raw/corrupt-archive-fixtures.R).
tm_redirect_config()
tm_redirect_data()
for (fixture in c("not-an-archive.7z", "corrupt-payload.7z")) {
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE,
unpack = NULL,
archive = testthat::test_path("fixtures", fixture)
)
open_before <- nrow(showConnections(all = FALSE))
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_archive_unreadable")
# The mechanism the surviving download was made of, asserted where every
# platform can see it: archive_extract() opens its own connection to the
# archive and, on a failure inside archive_read_data_block(), leaves it
# open -- and Windows will not delete a file something still holds open.
# The file.exists() line below is the criterion, but it can only go red on
# Windows, so this one is what a developer running the suite anywhere else
# would see if tm_unpack() stopped owning the connection.
expect_identical(
nrow(showConnections(all = FALSE)), open_before,
label = paste(fixture, "leaves no connection open")
)
# The abort names the archive it could not read. Whether it also names the
# install directory now depends on what the cleanup managed: M103 gave
# this call its directories back, so where `d` is gone the message must
# not point at it, and where the extraction left something undeletable
# the message names both the directory and what stayed. AC7's own tests
# below assert each branch; here the two are held together, so neither
# can be satisfied by the message saying nothing at all.
msg <- cli::ansi_strip(conditionMessage(cnd))
expect_true(grepl(rec$destfile, msg, fixed = TRUE), label = fixture)
expect_identical(dir.exists(d), grepl(d, msg, fixed = TRUE), label = fixture)
# And libarchive's own text reaches the caller nowhere -- not in this
# message, and not in any condition carried underneath it.
walk <- cnd
while (!is.null(walk)) {
expect_false(
grepl("archive_extract.cpp", cli::ansi_strip(conditionMessage(walk)), fixed = TRUE),
label = paste(fixture, "chain is free of libarchive's source location")
)
walk <- walk$parent
}
# Nothing was registered, and the download did not survive the failure.
expect_identical(rec$set, list())
expect_false(file.exists(rec$destfile), label = fixture)
}
# The control: the same path, succeeding, also leaves no download behind and
# no connection open.
d <- file.path(withr::local_tempdir(), "ffmpeg")
ok <- tm_mock_install(confirm = function(prompt) TRUE)
open_before <- nrow(showConnections(all = FALSE))
expect_true(install_on_win(install_dir = d, archive_checksum = ok$digest))
expect_identical(nrow(showConnections(all = FALSE)), open_before)
expect_false(file.exists(ok$destfile))
})
test_that("the real archive_extract() returns the paths the mock stands in for", {
# The whole AC4 registration gate reads `tm_unpack()`'s file list, and every
# test above reaches that list through a mock which hard-codes its shape.
# This is the one test that asks libarchive itself, so a change in what
# `archive_extract()` returns -- absolute paths, a `./` prefix, paths before
# `strip_components` rather than after -- fails here instead of shipping and
# aborting on every real Windows install.
skip_if_not_installed("archive")
# The upstream layout: one wrapper directory named for the build, holding
# `bin/`. `strip_components = 1` is what removes the wrapper.
root <- withr::local_tempdir()
wrapper <- "ffmpeg-8.0-essentials_build"
dir.create(file.path(root, wrapper, "bin"), recursive = TRUE)
members <- file.path(wrapper, "bin", paste0(tm_install_registers, ".exe"))
for (member in members) writeLines("stub", file.path(root, member))
arch <- file.path(withr::local_tempdir(), "control.7z")
withr::with_dir(root, archive::archive_write_files(arch, members))
d <- withr::local_tempdir()
produced <- tm_unpack(arch, d)
# Relative to the directory, after strip_components -- which is exactly the
# string tm_extracted_programs() matches against.
expect_setequal(produced$files, file.path("bin", paste0(tm_install_registers, ".exe")))
expect_setequal(
tm_extracted_programs(produced$files, tm_install_registers),
tm_install_registers
)
# A succeeding unpack removed nothing, so it has nothing to report (M103).
expect_identical(produced$leftovers, character(0))
})
test_that("tm_extracted_programs() reads the path shapes libarchive can report", {
# The normalization exists for Windows, where a backslash separator and a
# capitalized name are both ordinary -- and where no test on this machine
# would otherwise execute it.
expect_setequal(
tm_extracted_programs(
c("bin\\ffmpeg.exe", "./bin/FFprobe.EXE", "bin/ffplay.exe"),
tm_install_registers
),
tm_install_registers
)
# And it stays a match on the install's own layout, not a name search: the
# same three programs one directory deeper are not what the install reads.
expect_identical(
tm_extracted_programs(
file.path("tools", "bin", paste0(tm_install_registers, ".exe")),
tm_install_registers
),
character(0)
)
})
test_that("tm_files_on_disk() reads the same path shapes, and answers for the disk", {
# The companion to the test above, and for the same reason: the separator
# normalization exists for Windows and no other test in this suite executes
# it -- with it removed, every assertion below but the last still passed
# (M105 review F5).
dir <- withr::local_tempdir()
dir.create(file.path(dir, "bin"))
file.create(file.path(dir, "bin", "ffmpeg.exe"))
file.create(file.path(dir, "bin", "ffprobe.exe"))
# A backslash entry and a `./`-prefixed entry both resolve; the answer is
# the entry as reported, normalized, and not the path it was tested at.
expect_identical(
tm_files_on_disk(c("bin\\ffmpeg.exe", "./bin/ffprobe.exe"), dir),
c("bin/ffmpeg.exe", "bin/ffprobe.exe")
)
# It is the disk that decides, not the list: a path the list names and the
# directory does not hold drops out, which is the whole point of the helper.
expect_identical(
tm_files_on_disk(c("bin/ffmpeg.exe", "bin/ffplay.exe"), dir),
"bin/ffmpeg.exe"
)
expect_identical(tm_files_on_disk("bin/ffplay.exe", dir), character(0))
expect_identical(tm_files_on_disk(character(0), dir), character(0))
})
test_that("only the programs the extraction produced are registered", {
# AC4. Four builds, told apart by what the extraction leaves behind.
tm_redirect_config()
tm_redirect_data()
registered <- function(rec) vapply(rec$set, function(x) x$program, character(1))
all_three <- tm_mock_install(confirm = function(prompt) TRUE)
d <- file.path(withr::local_tempdir(), "ffmpeg")
expect_true(install_on_win(install_dir = d, archive_checksum = all_three$digest))
expect_identical(registered(all_three), c("ffmpeg", "ffprobe", "ffplay"))
# ffplay is optional: its absence is said out loud and the install still
# succeeds, but no location is remembered for it.
d <- file.path(withr::local_tempdir(), "ffmpeg")
no_ffplay <- tm_mock_install(
confirm = function(prompt) TRUE, unpack = c("ffmpeg", "ffprobe")
)
expect_message(
expect_true(install_on_win(install_dir = d, archive_checksum = no_ffplay$digest)),
"ffplay"
)
expect_identical(registered(no_ffplay), c("ffmpeg", "ffprobe"))
# Either required program missing refuses, names it, and writes nothing at
# all -- not even the one that WAS unpacked.
for (absent in c("ffmpeg", "ffprobe")) {
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE,
unpack = setdiff(tm_install_registers, absent)
)
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_program_not_extracted")
expect_true(
grepl(absent, cli::ansi_strip(conditionMessage(cnd)), fixed = TRUE),
label = paste("names", absent)
)
expect_identical(rec$set, list())
}
})
test_that("a re-install into a used directory counts only what THIS extraction produced", {
# AC4, on the path the default `install_dir` puts every caller on: the same
# directory, install after install, never cleared by the extraction. A
# directory listing cannot tell this build's binaries from the last one's,
# so a build missing a required program has to refuse even where the file
# it did not produce is sitting there from before.
tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
bin <- file.path(d, "bin")
dir.create(bin, recursive = TRUE)
for (program in c("ffmpeg", "ffplay")) {
file.create(file.path(bin, paste0(program, ".exe")))
}
rec <- tm_mock_install(confirm = function(prompt) TRUE, unpack = "ffprobe")
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_program_not_extracted")
expect_true(
grepl("ffmpeg", cli::ansi_strip(conditionMessage(cnd)), fixed = TRUE)
)
expect_identical(rec$set, list())
# And the optional half of the same reading: a build producing the two
# required programs and no ffplay says so and remembers no ffplay location,
# even with a previous run's ffplay.exe still in the directory.
leftover <- tm_mock_install(
confirm = function(prompt) TRUE, unpack = c("ffmpeg", "ffprobe")
)
expect_message(
expect_true(install_on_win(install_dir = d, archive_checksum = leftover$digest)),
"ffplay"
)
expect_identical(
vapply(leftover$set, function(x) x$program, character(1)),
c("ffmpeg", "ffprobe")
)
})
test_that("an archive the fetch reported but did not deliver aborts classed", {
# The archive-side counterpart of the sidecar guard above: status 0 with no
# file left behind. Without it, tm_archive_digest() aborts with a bare
# simpleError -- unclassed, and invisible to AC6's census, which sees only
# `return()` and `cli_abort()` nodes (AC5).
tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE,
download = function(url, destfile, is_sidecar) {
# The sidecar arrives, so the run reaches the archive fetch and fails
# there rather than one step earlier.
if (is_sidecar) {
writeLines(paste(rep("a", 64), collapse = ""), destfile)
}
0L
}
)
cnd <- tryCatch(install_on_win(install_dir = d), error = function(cnd) cnd)
expect_s3_class(cnd, "tidymedia_download_unavailable")
expect_identical(rec$extract, list())
expect_identical(rec$set, list())
})
test_that("what an install remembers is read back off disk, not off the record", {
# AC4 again, with the REAL set_program() in place: the criterion's claim is
# about config FILES, and every assertion above it reads the recorded calls
# instead. This one asks the config directory what is actually there, so a
# write reaching it by some route other than set_program() is visible too.
config <- tm_redirect_config()
tm_redirect_data()
file_for <- function(p) tm_config_file(p)
d <- file.path(withr::local_tempdir(), "ffmpeg")
all_three <- tm_mock_install(confirm = function(prompt) TRUE, real_set = TRUE)
expect_true(install_on_win(install_dir = d, archive_checksum = all_three$digest))
for (program in tm_install_registers) {
expect_true(file.exists(file_for(program)), label = paste(program, "written"))
expect_identical(
readLines(file_for(program)),
tm_install_binary(d, program),
label = paste(program, "contents")
)
}
# ffplay optional: two files, and no third.
config <- tm_redirect_config()
file_for <- function(p) tm_config_file(p)
d <- file.path(withr::local_tempdir(), "ffmpeg")
no_ffplay <- tm_mock_install(
confirm = function(prompt) TRUE, unpack = c("ffmpeg", "ffprobe"),
real_set = TRUE
)
expect_message(
expect_true(install_on_win(install_dir = d, archive_checksum = no_ffplay$digest)),
"ffplay"
)
expect_true(file.exists(file_for("ffmpeg")))
expect_true(file.exists(file_for("ffprobe")))
expect_false(file.exists(file_for("ffplay")))
# A required program missing writes nothing at all: the config directory is
# byte-for-byte what it was, not merely missing the absent program's file.
for (absent in tm_install_required) {
config <- tm_redirect_config()
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE,
unpack = setdiff(tm_install_registers, absent),
real_set = TRUE
)
before <- tm_roots_snapshot(config$root)
expect_error(
install_on_win(install_dir = d, archive_checksum = rec$digest),
class = "tidymedia_program_not_extracted"
)
expect_identical(tm_roots_snapshot(config$root), before, label = absent)
}
})
test_that("tm_archive_digest() computes SHA-256, pinned to a known answer", {
# Every verifying test above compares tm_archive_digest() against itself:
# the published digest and the downloaded file's digest both come from it,
# so `algo = "sha256"` quietly becoming another 64-hex algorithm would leave
# all of them green. This pins one file to the digest `shasum -a 256` gives.
f <- withr::local_tempfile()
writeBin(charToRaw("abc"), f)
expect_identical(
tm_archive_digest(f),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
)
# And a file that is not there is NULL, not a bare simpleError: the caller
# turns that NULL into a classed refusal, which it cannot do for a condition
# raised out from under it.
expect_null(tm_archive_digest(file.path(tempdir(), "no-such-archive")))
})
test_that("an archive that vanishes between the fetch and the digest refuses classed", {
# The second line of defense behind tm_fetch()'s existence test: whatever
# the reason the digest cannot be computed, the refusal is AC5's classed
# one naming the URL rather than whatever `digest` happened to raise.
tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(confirm = function(prompt) TRUE)
testthat::local_mocked_bindings(tm_archive_digest = function(path) NULL)
cnd <- tryCatch(install_on_win(install_dir = d), error = function(cnd) cnd)
expect_s3_class(cnd, "tidymedia_download_unavailable")
expect_identical(rec$extract, list())
expect_identical(rec$set, list())
})
test_that("tm_unpack() reports failure where the archive cannot even be opened", {
# The connection is opened by tm_unpack() itself (T9), so a path that is not
# there fails before libarchive is reached -- and has to read as the same
# refusal, since the caller only asks whether the unpack produced anything.
# `file()` warns on its way to the error it raises; the warning is base R's
# and says the same thing, so it is suppressed rather than asserted.
out <- suppressWarnings(
tm_unpack(file.path(tempdir(), "no-such-archive"), tempdir())
)
expect_null(out$files)
# And nothing was extracted, so nothing is named as left behind -- the
# cleanup is never reached on this path (M103).
expect_identical(out$leftovers, character(0))
})
test_that("a download that does not deliver aborts, keeping the base condition underneath", {
# AC5. Both shapes download.file()'s contract allows, asserted by class.
tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
u <- "https://example.invalid/build.7z"
signalled <- tm_mock_install(
confirm = function(prompt) TRUE,
download = function(url, destfile, is_sidecar) stop("cannot open URL '", url, "'")
)
cnd <- tryCatch(
suppressMessages(install_on_win(download_url = u, install_dir = d)),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_download_unavailable")
expect_true(grepl(u, cli::ansi_strip(conditionMessage(cnd)), fixed = TRUE))
# The base condition is kept, so a caller who wants base R's wording can
# still reach it -- it just is not what the top-level message says.
expect_s3_class(cnd$parent, "condition")
expect_true(grepl("cannot open URL", conditionMessage(cnd$parent), fixed = TRUE))
expect_identical(signalled$set, list())
status <- tm_mock_install(
confirm = function(prompt) TRUE,
download = function(url, destfile, is_sidecar) 1L
)
expect_error(
suppressMessages(install_on_win(download_url = u, install_dir = d)),
class = "tidymedia_download_unavailable"
)
expect_identical(status$set, list())
})
# install_on_win()'s exits are all accounted for (M102 AC6) -------------------
# Collect every `return()` and every `cli::cli_abort()` call in a function's
# own body. The DOMAIN is derived rather than listed: a new exit added to
# install_on_win() is collected the day it is written, so the census cannot go
# stale the way a hand-kept list of sites would.
#
# Two node types only, and deliberately: a call to a helper is neither, so the
# front-door checks and the aborts that originate inside tm_confirm() and
# set_program() are outside what this can see. AC6 names those rather than
# claiming them, and T2 sites every classed abort in the body itself so that
# what the walk cannot see is only what is named.
tm_collect_exits <- function(fn) {
found <- list()
walk <- function(node) {
if (is.call(node)) {
if (identical(tm_call_name(node), "return") ||
identical(tm_call_name(node), "cli_abort")) {
found[[length(found) + 1L]] <<- node
}
}
if (is.call(node) || is.pairlist(node)) {
parts <- as.list(node)
# Two node kinds hold the empty symbol: a call with a blank argument
# (`x[, 1]`), and the formals of an inline `function(x) ...`, whose
# arguments without defaults are empty. Binding one to a variable makes
# that variable missing, so the parts are reached by index and the empty
# ones skipped before they are ever forced.
for (i in seq_along(parts)) {
if (identical(parts[[i]], quote(expr = ))) next
walk(parts[[i]])
}
}
}
walk(body(fn))
found
}
# The name a call calls, seeing through `pkg::fn`.
tm_call_name <- function(node) {
head <- node[[1]]
if (is.name(head)) return(as.character(head))
if (is.call(head) && identical(as.character(head[[1]]), "::")) {
return(as.character(head[[3]]))
}
NA_character_
}
# The classes a collected `cli_abort()` passes, as a character vector, or
# none. `class = c("tidymedia_x", "y")` reaches here as an unevaluated call to
# `c()` rather than as a vector, which a plain is.character() test would read
# as no class at all; a vector holding the class IS catchable by it, so the
# constant form is unwrapped instead.
tm_exit_classes <- function(node) {
class <- node[["class"]]
if (is.null(class)) return(character(0))
if (is.call(class) && identical(tm_call_name(class), "c")) {
parts <- as.list(class)[-1]
if (!all(vapply(parts, is.character, logical(1)))) return(character(0))
class <- unlist(parts)
}
if (is.character(class)) class else character(0)
}
# The property AC6 asserts of each collected exit: a `return()` hands back a
# literal TRUE or FALSE, and a `cli_abort()` passes a class beginning
# `tidymedia_`. A bare `return()` -- which hands back NULL -- is read as
# non-compliant rather than left to throw a subscript error out of the walk.
tm_exit_ok <- function(node) {
if (identical(tm_call_name(node), "return")) {
if (length(node) < 2L) return(FALSE)
value <- node[[2]]
return(is.logical(value) && length(value) == 1L && !is.na(value))
}
any(startsWith(tm_exit_classes(node), "tidymedia_"))
}
test_that("every exit install_on_win() takes in its own body is TRUE/FALSE or classed", {
# AC6. The claim is exactly the collected set.
exits <- tm_collect_exits(install_on_win)
for (node in exits) {
expect_true(tm_exit_ok(node), label = deparse1(node))
}
# The floor against a collector that silently under-reads the body: the set
# holds the five abort sites AC1, AC3, AC4 and AC5 name. Without this, a
# walk that found nothing would satisfy the loop above vacuously.
classes <- unlist(lapply(exits, tm_exit_classes))
expect_true(all(
c(
"tidymedia_checksum_unavailable",
"tidymedia_checksum_mismatch",
"tidymedia_download_unavailable",
"tidymedia_archive_unreadable",
"tidymedia_program_not_extracted"
) %in% classes
))
})
test_that("the exit census can fail", {
# The collector and the property are only worth their green if they go red
# on the two defects they exist to catch, so both are planted here rather
# than left to a one-time check that no later reader can re-run.
unclassed <- function() cli::cli_abort("something went wrong")
non_literal <- function(x) return(x)
compliant <- function(x) {
if (x) return(FALSE)
cli::cli_abort("no", class = "tidymedia_planted")
}
# Two shapes the property has to READ rather than break on: a bare
# `return()`, whose value node does not exist, and a class passed as a
# vector, which is catchable and so compliant. Neither shape is in
# install_on_win() today, which is why they are asserted here.
bare <- function() return()
vector_class <- function() {
cli::cli_abort("no", class = c("tidymedia_planted", "extra"))
}
ok <- function(fn) all(vapply(tm_collect_exits(fn), tm_exit_ok, logical(1)))
expect_false(ok(unclassed))
expect_false(ok(non_literal))
expect_true(ok(compliant))
expect_false(ok(bare))
expect_true(ok(vector_class))
expect_identical(
tm_exit_classes(tm_collect_exits(vector_class)[[1]]),
c("tidymedia_planted", "extra")
)
# And the collector reads a non-empty domain in each case, so "no exits
# found" can never be what makes a function look compliant.
expect_identical(length(tm_collect_exits(unclassed)), 1L)
expect_identical(length(tm_collect_exits(non_literal)), 1L)
expect_identical(length(tm_collect_exits(compliant)), 2L)
})
# Every refusal above the unpack leaves no directory behind (M103 AC3) -------
# The M102 census, narrowed to the exits that come BEFORE a named call.
#
# `tm_collect_exits()` walks the whole body and cannot say where in it a node
# sat, which is the one fact AC3's domain turns on: the rule is about the
# refusals that happen while the install directory holds nothing the call put
# there. The narrowing is positional rather than by name or class because the
# body's own order is what decides it -- the same abort class appears on both
# sides of some calls, and a class-keyed filter would silently take the wrong
# one.
#
# The filter runs over the body's top-level statements: `tm_unpack()` is
# called from one of them, and everything a `{` block holds before that
# statement is what precedes the call.
tm_collect_exits_before <- function(fn, callee) {
statements <- as.list(body(fn))[-1]
holds_callee <- vapply(
statements,
function(st) {
hit <- FALSE
walk <- function(node) {
if (is.call(node)) {
if (identical(tm_call_name(node), callee)) hit <<- TRUE
for (part in as.list(node)) {
if (identical(part, quote(expr = ))) next
walk(part)
}
}
}
walk(st)
hit
},
logical(1)
)
stopifnot(any(holds_callee))
before <- statements[seq_len(which(holds_callee)[[1]] - 1L)]
unlist(lapply(before, function(st) {
tm_collect_exits(eval(call("function", NULL, st)))
}), recursive = FALSE)
}
# A stable name for a collected exit: the condition class it raises, or the
# value it returns, plus its ordinal among the exits naming the same one.
#
# Position alone would do, but a key made of position alone renumbers every
# case below it the moment an exit is inserted above -- so a real omission and
# a harmless insertion would fail the same way. Keyed like this, a new exit
# raising a new class takes a key no case claims and the bijection names IT,
# while a new exit raising an EXISTING class pushes the count past the
# registry's and the bijection fails on the count.
tm_exit_keys <- function(nodes) {
names <- vapply(nodes, function(node) {
if (identical(tm_call_name(node), "return")) {
return(paste0("return(", deparse1(node[[2]]), ")"))
}
classes <- tm_exit_classes(node)
classes <- classes[startsWith(classes, "tidymedia_")]
if (length(classes)) classes[[1]] else deparse1(node)
}, character(1))
vapply(
seq_along(names),
function(i) paste0(names[[i]], " #", sum(names[seq_len(i)] == names[[i]])),
character(1)
)
}
# One triggering case per exit AC3's domain holds, keyed by tm_exit_keys().
#
# Each case is a function of the install directory that drives install_on_win()
# to exactly that exit and returns nothing; the runner below supplies the
# directory and does the asserting, so a case says only how to reach its exit.
# `env` is the caller's frame, which is what the withr/testthat mocks below
# have to be scoped to.
#
# The two `return(FALSE)` exits are told apart by what makes them fire, which
# is also the only difference AC3 cares about: the first refuses above the
# first dir.create(), the second is dir.create() itself failing after it has
# already made the parents.
tm_ac3_cases <- function(env = parent.frame()) {
list(
"return(FALSE) #1" = function(dir) {
tm_mock_install(confirm = function(prompt) FALSE, env = env)
expect_false(install_on_win(install_dir = dir))
},
"return(FALSE) #2" = function(dir) {
rec <- tm_mock_install(confirm = function(prompt) TRUE, env = env)
# Captured BEFORE the mock replaces the binding, so the mock can still
# make the parents the real recursive call would have made. Without
# that, this case would assert the removal of directories nothing
# created.
real_dir_create <- base::dir.create
testthat::local_mocked_bindings(
dir.create = function(path, showWarnings = TRUE, recursive = FALSE,
mode = "0777") {
real_dir_create(dirname(path), showWarnings = FALSE, recursive = TRUE)
FALSE
},
.package = "base", .env = env
)
expect_false(install_on_win(install_dir = dir, archive_checksum = rec$digest))
},
"tidymedia_checksum_unavailable #1" = function(dir) {
tm_mock_install(
confirm = function(prompt) TRUE,
download = function(url, destfile, is_sidecar) if (is_sidecar) 1L else 0L,
env = env
)
expect_error(
install_on_win(install_dir = dir),
class = "tidymedia_checksum_unavailable"
)
},
"tidymedia_checksum_unavailable #2" = function(dir) {
tm_mock_install(
confirm = function(prompt) TRUE, sidecar = "no digest here", env = env
)
expect_error(
install_on_win(install_dir = dir),
class = "tidymedia_checksum_unavailable"
)
},
"tidymedia_download_unavailable #1" = function(dir) {
rec <- tm_mock_install(
confirm = function(prompt) TRUE,
download = function(url, destfile, is_sidecar) 1L,
env = env
)
expect_error(
install_on_win(install_dir = dir, archive_checksum = rec$digest),
class = "tidymedia_download_unavailable"
)
},
"tidymedia_download_unavailable #2" = function(dir) {
rec <- tm_mock_install(confirm = function(prompt) TRUE, env = env)
testthat::local_mocked_bindings(
tm_archive_digest = function(path) NULL, .env = env
)
expect_error(
install_on_win(install_dir = dir, archive_checksum = rec$digest),
class = "tidymedia_download_unavailable"
)
},
"tidymedia_wrong_platform #1" = function(dir) {
# The only case here that refuses without any install machinery at all:
# the gate is above every mock the others need, so a seam reporting a
# platform this function does not install for is the whole setup.
testthat::local_mocked_bindings(tm_os = function(...) "darwin", .env = env)
expect_error(
install_on_win(install_dir = dir),
class = "tidymedia_wrong_platform"
)
},
"tidymedia_checksum_mismatch #1" = function(dir) {
tm_mock_install(confirm = function(prompt) TRUE, env = env)
expect_error(
install_on_win(install_dir = dir, archive_checksum = strrep("a", 64)),
class = "tidymedia_checksum_mismatch"
)
}
)
}
# The five refusals the walk cannot see, because none of them is a `return()`
# or a `cli_abort()` in install_on_win()'s own body: four front-door checks
# that abort inside rlang or check_sha256(), and tm_confirm()'s refusal to
# assume consent where there is no one to ask.
#
# Each refuses ABOVE the call's first dir.create(), so each must create no
# directory at all -- which is why they take the install directory as a path
# to check rather than a path to pass. The three that cannot accept a
# directory argument at all take the default one instead.
tm_ac3_uncovered_cases <- function(env = parent.frame()) {
list(
# The four front-door checks abort inside rlang, which gives all four the
# same `rlang_error` class -- so the message is the only instrument that
# tells them apart, and each match names its own argument and its own
# constraint. A classless `expect_error()` here would stay green with the
# check it names deleted, because the call would then fail somewhere else
# or not at all.
"check_bool(confirm)" = function(dir) {
expect_error(
install_on_win(install_dir = dir, confirm = "yes"),
"`confirm` must be `TRUE` or `FALSE`"
)
},
"check_string(download_url)" = function(dir) {
expect_error(
install_on_win(install_dir = dir, download_url = 1),
"`download_url` must be a single string"
)
},
"check_string(install_dir)" = function(dir) {
expect_error(
install_on_win(install_dir = 1),
"`install_dir` must be a single string"
)
},
"check_sha256(archive_checksum)" = function(dir) {
expect_error(
install_on_win(install_dir = dir, archive_checksum = "nope"),
"`archive_checksum` must be a SHA-256 digest"
)
},
"tm_confirm() has no one to ask" = function(dir) {
# The real tm_confirm() is left in place and the session is not
# interactive, which is the state D080 refuses rather than assumes
# consent for.
tm_mock_install(confirm = NULL, env = env)
expect_error(
install_on_win(install_dir = dir),
class = "tidymedia_confirmation_unavailable"
)
}
)
}
# The exits that cannot be reached with the install directory already there:
# `dir.create()` only runs where it is not.
tm_ac3_needs_new_dir <- "return(FALSE) #2"
# Run one case in a frame of its own, so its mocks and temp files unwind
# before the next case starts. Building the registry HERE is what scopes them:
# the cases take `env` from this frame.
tm_ac3_run <- function(key, dir) {
tm_redirect_config()
data_root <- tm_redirect_data()
cases <- c(
tm_ac3_cases(env = environment()),
tm_ac3_uncovered_cases(env = environment())
)
cases[[key]](dir)
# What the case created under the DEFAULT install location, read here
# rather than by the caller: the redirected root is this frame's temp
# directory and is deleted the moment this function returns, so a caller
# computing the default path for itself would be asking about a root no run
# ever used. That is what made the assertion below unfalsifiable before
# (M103 review pass 1).
setdiff(list.dirs(data_root), data_root)
}
test_that("every exit above the unpack has a case, and every case has an exit", {
# AC3's bijection. The domain is derived from the body, so an exit added
# above the unpack with no case here fails this the day it is written; a
# case naming an exit that no longer exists fails it the same way.
exits <- tm_collect_exits_before(install_on_win, "tm_unpack")
expect_setequal(tm_exit_keys(exits), names(tm_ac3_cases()))
expect_identical(length(exits), length(tm_ac3_cases()))
# The floor against a filter that silently under-reads: the narrowed set
# still holds both `return(FALSE)` exits and the three abort classes that
# can only fire above the unpack.
expect_true(all(
c("return(FALSE) #1", "return(FALSE) #2",
"tidymedia_checksum_unavailable #1", "tidymedia_download_unavailable #1",
"tidymedia_checksum_mismatch #1") %in% tm_exit_keys(exits)
))
# And the floor against a filter that reads too far: the three exits BELOW
# the unpack are the ones the narrowing exists to drop.
all_classes <- unlist(lapply(tm_collect_exits(install_on_win), tm_exit_classes))
expect_true("tidymedia_archive_unreadable" %in% all_classes)
expect_false(
"tidymedia_archive_unreadable" %in%
unlist(lapply(exits, tm_exit_classes))
)
expect_false(
"tidymedia_program_not_extracted" %in%
unlist(lapply(exits, tm_exit_classes))
)
# The third, added by M104 and below the unpack for the same reason.
expect_true("tidymedia_program_unusable" %in% all_classes)
expect_false(
"tidymedia_program_unusable" %in%
unlist(lapply(exits, tm_exit_classes))
)
})
test_that("a refusal above the unpack leaves no directory the call created", {
# AC3, first half. The install directory is named two levels below a
# directory that already exists, so `dir.create(recursive = TRUE)` has three
# levels to make and the assertion is about the outermost of them: a removal
# that stopped at `install_dir` would leave the other two behind and fail
# here.
for (key in names(tm_ac3_cases())) {
root <- withr::local_tempdir()
outermost <- file.path(root, "made")
dir <- file.path(outermost, "by-the-call", "ffmpeg")
tm_ac3_run(key, dir)
expect_false(dir.exists(dir), label = key)
expect_false(dir.exists(outermost), label = paste(key, "outermost"))
}
})
test_that("a refusal above the unpack leaves an existing install directory as it was", {
# AC3, second half. A directory the call did NOT create is never removed,
# and neither is anything in it -- the same created-or-changed line the
# unpack cleanup draws, drawn here.
for (key in setdiff(names(tm_ac3_cases()), tm_ac3_needs_new_dir)) {
root <- withr::local_tempdir()
dir <- file.path(root, "ffmpeg")
dir.create(file.path(dir, "keep"), recursive = TRUE)
writeLines("the caller's own file", file.path(dir, "keep", "mine.txt"))
before <- tm_dir_snapshot(dir)
tm_ac3_run(key, dir)
expect_true(dir.exists(dir), label = key)
expect_identical(tm_dir_snapshot(dir), before, label = key)
}
})
test_that("the refusals the census cannot see create no directory at all", {
# AC3's named exceptions. None of the five is a `return()` or a
# `cli_abort()` in install_on_win()'s own body, so no walk over that body
# can find them; each is listed by hand and each refuses above the first
# dir.create(). Both the directory the caller named and the default one are
# checked, because two of the five refuse before either has been resolved.
for (key in names(tm_ac3_uncovered_cases())) {
root <- withr::local_tempdir()
dir <- file.path(root, "made", "by-the-call", "ffmpeg")
created_by_default <- tm_ac3_run(key, dir)
expect_false(dir.exists(file.path(root, "made")), label = key)
# And nothing under the default location either, which is where the two
# cases that refuse before `install_dir` is resolved would create one.
# It is NOT what carries `check_string(install_dir)`: that case passes
# `install_dir = 1`, which is non-NULL, so `tm_install_dir()` is never
# reached and this assertion could not go red for it whatever the call
# did. That case is carried by its own message assertion inside
# `tm_ac3_run()`, and the cell as a whole is falsifiable -- commenting out
# `rlang::check_string(install_dir, allow_null = TRUE)` turns it red
# (M103 review pass 2). That THIS assertion can go red is shown by the
# M098 default-install-dir test above, where a call allowed past the
# checks creates `R/tidymedia/ffmpeg` beneath exactly this root.
expect_identical(created_by_default, character(0), label = paste(key, "default dir"))
}
})
# The unpack refusal says what it left behind (M103 AC7) ---------------------
test_that("a failed unpack that leaves nothing says so, and takes its own directory back", {
# AC7, the two states with no leftovers, over both libarchive failure
# routes. `unpack = NULL` leaves the real archive::archive_extract() in
# place, so what the message reports is what the cleanup actually did.
tm_redirect_config()
tm_redirect_data()
for (fixture in c("not-an-archive.7z", "corrupt-payload.7z")) {
# State one: the call created the install directory. It is removed again,
# and the message names no directory -- naming one it had just deleted is
# the failure this asserts against.
root <- withr::local_tempdir()
made <- file.path(root, "made", "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, unpack = NULL,
archive = testthat::test_path("fixtures", fixture)
)
cnd <- tryCatch(
install_on_win(install_dir = made, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_archive_unreadable")
msg <- cli::ansi_strip(conditionMessage(cnd))
if (dir.exists(made)) {
# The extraction left something the platform will not delete. The
# message then names the directory and every surviving entry under it,
# so the caller can go and look -- the branch Windows takes.
survivors <- list.files(
made, recursive = TRUE, all.files = TRUE, include.dirs = TRUE,
no.. = TRUE
)
expect_gt(length(survivors), 0)
expect_true(grepl(made, msg, fixed = TRUE), label = fixture)
expect_true(grepl(file.path(made, survivors[[1]]), msg, fixed = TRUE),
label = fixture)
expect_match(msg, "could not", fixed = TRUE)
} else {
# Nothing survived, so the directory this call made is gone too, and
# the message names no directory the caller could not go and look at.
expect_false(dir.exists(file.path(root, "made")), label = fixture)
expect_false(grepl(made, msg, fixed = TRUE), label = fixture)
expect_match(msg, "removed it again", fixed = TRUE)
}
# State two: the directory was already there, holding a file of the
# caller's. It stays, with its file, and the message says so.
kept <- file.path(withr::local_tempdir(), "ffmpeg")
dir.create(kept, recursive = TRUE)
writeLines("the caller's own file", file.path(kept, "mine.txt"))
before <- tm_dir_snapshot(kept)
rec <- tm_mock_install(
confirm = function(prompt) TRUE, unpack = NULL,
archive = testthat::test_path("fixtures", fixture)
)
cnd <- tryCatch(
install_on_win(install_dir = kept, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_archive_unreadable")
msg <- cli::ansi_strip(conditionMessage(cnd))
# The directory the call did not create is never removed, and the file in
# it is untouched -- on every platform, whatever the cleanup managed.
expect_true(dir.exists(kept), label = fixture)
expect_true(file.exists(file.path(kept, "mine.txt")), label = fixture)
expect_true(grepl(kept, msg, fixed = TRUE), label = fixture)
if (identical(tm_dir_snapshot(kept), before)) {
expect_match(msg, "Nothing was left behind", fixed = TRUE)
} else {
# Something the extraction wrote is still there. It is named, and the
# caller's own file is still one of the entries above.
expect_match(msg, "could not", fixed = TRUE)
}
}
})
test_that("a failed unpack names every entry it could not remove", {
# AC7's mocked-failure cell. The seam is made to fail on every call, so what
# survives is the whole of what the cleanup targeted: the created directory
# -- which still keeps its child -- and the created file inside it. Both are
# named, which is what "more than one entry" is here for: a message that
# reported only the first would pass a one-entry cell.
tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, unpack = NULL,
archive = testthat::test_path("fixtures", "corrupt-payload.7z")
)
testthat::local_mocked_bindings(tm_unlink = function(path, recursive = FALSE) 1L)
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_archive_unreadable")
msg <- cli::ansi_strip(conditionMessage(cnd))
entry <- gsub("/+", "/", sub(
"^/+", "",
archive::archive(testthat::test_path("fixtures", "corrupt-payload.7z"))$path[[1]]
))
topmost <- strsplit(entry, "/", fixed = TRUE)[[1]][[1]]
expect_gt(length(strsplit(entry, "/", fixed = TRUE)[[1]]), 1)
# Both leftovers, by full path -- and the directory is the one that still
# holds the file, so the two are different kinds of target, not one target
# named twice.
# Each is matched with its CLOSING quote, which is what makes the two
# assertions independent: `{.file x}` renders as `'x'`, and the file's path
# has the directory's as a literal prefix, so a bare substring match on the
# directory would be satisfied by the file's line alone and could never fail
# on the directory being dropped from the message (M103 review pass 1).
flat <- gsub("\\s+", " ", msg)
expect_true(grepl(paste0("'", file.path(d, entry), "'"), flat, fixed = TRUE))
expect_true(grepl(paste0("'", file.path(d, topmost), "'"), flat, fixed = TRUE))
expect_match(msg, "could not", fixed = TRUE)
# And they really are still there. The seam is mocked to fail on every
# call, so this cell reads the same on every platform.
expect_true(file.exists(file.path(d, entry)))
expect_true(dir.exists(file.path(d, topmost)))
})
test_that("a refusal with more leftovers than cli will print still names every one", {
# AC7's "names every entry", at a count that catches the formatter rather
# than the cleanup. cli truncates a vector in a message: measured on 25
# paths, entries 19 through 23 come back as an ellipsis. A refusal that
# named 20 of 25 undeletable entries would leave the caller hunting for the
# rest, so the count is the instrument here and the removal seam is not --
# `tm_remove_added()` is mocked to leave a fixed 25, which is the only way
# to reach a count no fixture produces.
tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, unpack = NULL,
archive = testthat::test_path("fixtures", "not-an-archive.7z")
)
left <- sprintf("leftover-%02d.txt", 1:25)
testthat::local_mocked_bindings(
tm_remove_added = function(dir, before, after) {
# The entries are put on disk as well as reported: a message naming
# something that is not there is the other half of AC7, and the cell
# should not be the one place that state is incoherent.
for (rel in left) file.create(file.path(dir, rel))
left
}
)
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_archive_unreadable")
flat <- gsub("\\s+", " ", cli::ansi_strip(conditionMessage(cnd)))
for (rel in left) {
expect_true(
grepl(paste0("'", file.path(d, rel), "'"), flat, fixed = TRUE),
label = paste("message names", rel)
)
}
})
test_that("a refusal that removed a file of the caller's says so", {
# M103 review pass 3. D082 removes a pre-existing file the failed
# extraction wrote over -- what it holds afterwards is nothing the caller
# put there -- and that removal leaves no leftover, so the refusal used to
# take the branch saying "the directory holds what it held when this call
# started". Measured on this machine: a 22-byte file of the caller's at the
# path the fixture writes was gone and the message said nothing had
# changed. The message now names it.
#
# The file is placed at a path the fixture's own `archive::archive()`
# listing says it writes, which is what makes the extraction write over it
# rather than beside it; the cell above places one where it does not.
skip_if_not(tm_unpack_deletes_open_files())
tm_redirect_config()
tm_redirect_data()
fixture <- "corrupt-payload.7z"
entry <- tm_fixture_entry(fixture)
expect_false(is.null(entry))
d <- file.path(withr::local_tempdir(), "ffmpeg")
dir.create(file.path(d, dirname(entry)), recursive = TRUE)
mine <- file.path(d, entry)
writeLines("the caller's own bytes", mine)
rec <- tm_mock_install(
confirm = function(prompt) TRUE, unpack = NULL,
archive = testthat::test_path("fixtures", fixture)
)
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_archive_unreadable")
flat <- gsub("\\s+", " ", cli::ansi_strip(conditionMessage(cnd)))
# The file really is gone -- the state the message has to describe.
expect_false(file.exists(mine))
expect_true(grepl(paste0("'", mine, "'"), flat, fixed = TRUE))
expect_match(flat, "written over", fixed = TRUE)
# And it does NOT claim the directory is as the call found it.
expect_false(grepl("Nothing was left behind", flat, fixed = TRUE))
})
test_that("a refusal that could not remove the directory it created says so", {
# M103 review pass 3. Both refusals used `!dir.exists(install_dir)` as the
# proxy for "this call created it and took it back", so a created directory
# the removal could NOT delete fell through to the sentence claiming the
# directory holds what it held -- for a directory that did not exist before
# the call. `tm_remove_created_dirs()` now reports what it left standing
# and the message names it.
tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "made", "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, unpack = NULL,
archive = testthat::test_path("fixtures", "not-an-archive.7z")
)
# The seam that removes a created directory is made to fail, which is the
# one thing this cell mocks: the fixture writes nothing, so there is no
# leftover and the branch turns on the created directory alone.
testthat::local_mocked_bindings(
tm_unlink = function(path, recursive = FALSE) 1L
)
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_archive_unreadable")
flat <- gsub("\\s+", " ", cli::ansi_strip(conditionMessage(cnd)))
expect_true(dir.exists(d))
expect_true(grepl(paste0("'", d, "'"), flat, fixed = TRUE))
expect_match(flat, "could not remove", fixed = TRUE)
expect_false(grepl("Nothing was left behind", flat, fixed = TRUE))
expect_false(grepl("removed it again", flat, fixed = TRUE))
})
# An archive whose every entry is a single segment, so `strip_components = 1`
# strips all of them: the extraction succeeds and writes nothing.
tm_flat_archive <- function(envir = parent.frame()) {
src <- withr::local_tempdir(.local_envir = envir)
writeLines("payload", file.path(src, "payload.txt"))
flat <- file.path(withr::local_tempdir(.local_envir = envir), "flat.7z")
withr::with_dir(src, archive::archive_write_files(flat, "payload.txt"))
flat
}
test_that("a successful unpack that produced no files takes back the directory it created", {
# The boundary Scope, AC4 and D082 all draw: `tidymedia_program_not_extracted`
# is the one refusal outside the leaves-it-as-found rule, because its
# extraction SUCCEEDED and its message tells the caller the unpacked files
# are still there. The carve-out is written over those files, so where the
# extraction produced none it does not reach: there is nothing in the
# directory to point the caller at, and a call that created that directory
# has to give it back like any other refusal (M103 review pass 2).
tm_redirect_config()
tm_redirect_data()
flat <- tm_flat_archive()
made <- file.path(withr::local_tempdir(), "made", "ffmpeg")
rec <- tm_mock_install(confirm = function(prompt) TRUE, unpack = NULL, archive = flat)
cnd <- tryCatch(
install_on_win(install_dir = made, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_program_not_extracted")
expect_false(dir.exists(made))
expect_false(dir.exists(dirname(made)))
msg <- cli::ansi_strip(conditionMessage(cnd))
# The extraction reported NOTHING, so the exact sentence is the one about an
# empty archive; "none of the files it reported are there" would imply a
# report that never happened (M105 review F3).
expect_match(msg, "The archive produced no files at all", fixed = TRUE)
expect_no_match(msg, "the extraction reported are there", fixed = TRUE)
expect_match(msg, "removed the install directory it created", fixed = TRUE)
# And it does not tell the caller to go and look in a directory that is gone.
expect_no_match(msg, "the files the extraction did produce are in", fixed = TRUE)
# Nothing was on the list, so nothing "vanished": the quarantine line and
# the headline that goes with it are for a path the extraction CLAIMED, and
# neither may be said of a program the archive never mentioned. Without the
# `vanished` intersect both would be said here (M105 review F1).
expect_no_match(msg, "reported writing", fixed = TRUE)
expect_no_match(msg, "Antivirus quarantine", fixed = TRUE)
expect_no_match(msg, "did not leave behind", fixed = TRUE)
expect_match(msg, "The archive did not produce", fixed = TRUE)
})
test_that("a successful unpack that produced no files leaves a directory it found alone", {
# The other half: the same refusal, but the install directory was already
# there. Nothing to give back, and nothing of the caller's to touch.
tm_redirect_config()
tm_redirect_data()
flat <- tm_flat_archive()
found <- file.path(withr::local_tempdir(), "found")
dir.create(found)
writeLines("the caller's own file", file.path(found, "keep.txt"))
rec <- tm_mock_install(confirm = function(prompt) TRUE, unpack = NULL, archive = flat)
cnd <- tryCatch(
install_on_win(install_dir = found, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_program_not_extracted")
expect_true(dir.exists(found))
expect_identical(readLines(file.path(found, "keep.txt")), "the caller's own file")
msg <- cli::ansi_strip(conditionMessage(cnd))
expect_match(msg, "The archive produced no files at all", fixed = TRUE)
expect_no_match(msg, "the extraction reported are there", fixed = TRUE)
expect_match(msg, "holds what it held when this call started", fixed = TRUE)
expect_no_match(msg, "reported writing", fixed = TRUE)
})
test_that("a successful unpack that produced files but no required program keeps its directory", {
# The carve-out itself, unchanged: an extraction that DID write files leaves
# them where they are, and the message points the caller at them.
tm_redirect_config()
tm_redirect_data()
src <- withr::local_tempdir()
dir.create(file.path(src, "top", "bin"), recursive = TRUE)
writeLines("not a program", file.path(src, "top", "bin", "readme.txt"))
nested <- file.path(withr::local_tempdir(), "nested.7z")
withr::with_dir(src, archive::archive_write_dir(nested, "top"))
made <- file.path(withr::local_tempdir(), "made", "ffmpeg")
rec <- tm_mock_install(confirm = function(prompt) TRUE, unpack = NULL, archive = nested)
cnd <- tryCatch(
install_on_win(install_dir = made, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_program_not_extracted")
# The premise: this extraction really did write something.
# `strip_components = 1` drops the leading `bin/`, so the file lands at the
# root of the install directory -- what matters here is only that the
# extraction wrote one.
expect_true("readme.txt" %in% list.files(made, recursive = TRUE))
expect_true(dir.exists(made))
expect_match(
cli::ansi_strip(conditionMessage(cnd)),
"the files the extraction did produce are in", fixed = TRUE
)
})
# Every program registers, or none does (M104) --------------------------------
# The four forms a produced path takes when the archive listed it and what is
# on disk is not a program this install can register, as
# `tm_mock_install(spoil = )` plants them. Three of them are files that cannot
# be used; `absent` is no file at all, and M105 disposes it as such. Named
# once so the AC4 tests and the AC2/AC3 ones cannot drift apart about which
# form is which.
tm_unusable_forms <- c("absent", "empty", "dir", "noexec")
# Every registered program planted `absent`: the extraction lists three paths
# and creates none of them, which is the state AC3 is about (M105).
tm_all_absent <- stats::setNames(
rep("absent", length(tm_install_registers)), tm_install_registers
)
test_that("a produced program that cannot be used stops every registration", {
# AC1. The config root is NOT empty when the call starts: `ffmpeg` already
# has a remembered location pointing somewhere else, so an identical
# snapshot afterwards says the install wrote nothing rather than saying the
# directory was empty both times. The file's CONTENTS are read back too,
# because the snapshot records names and not bytes -- re-registering
# `ffmpeg` at the install directory's path would leave the name list alone.
config <- tm_redirect_config()
tm_redirect_data()
kept <- tm_stub_executable("already remembered")
tm_write_location(config$new, "ffmpeg", kept)
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, real_set = TRUE,
spoil = c(ffprobe = "empty")
)
before <- tm_roots_snapshot(config$root)
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_program_unusable")
expect_identical(tm_roots_snapshot(config$root), before)
expect_identical(readLines(tm_config_file("ffmpeg", config$new)), kept)
# The premise the criterion rests on: `ffmpeg` passed the check, so the
# unchanged config root is the check refusing to write rather than nothing
# having been registrable in the first place.
expect_true(file.size(tm_install_binary(d, "ffmpeg")) > 0)
})
test_that("the refusal names every failed program and leaves the install directory alone", {
# AC2. Both required programs fail in one call, so the plural is exercised
# by two entries rather than asserted of one.
config <- tm_redirect_config()
tm_redirect_data()
withr::local_options(cli.width = 1000)
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, real_set = TRUE,
spoil = c(ffmpeg = "empty", ffprobe = "empty")
)
before <- tm_roots_snapshot(config$root)
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_program_unusable")
expect_identical(blamed_verb(cnd), "install_on_win")
msg <- cli::ansi_strip(conditionMessage(cnd))
for (program in c("ffmpeg", "ffprobe")) {
expect_match(msg, program, fixed = TRUE)
expect_match(msg, tm_install_binary(d, program), fixed = TRUE)
}
# Not set_program()'s own unclassed abort, which is the failure this check
# exists to reach first (M104 AC4).
expect_no_match(msg, "Can't find an executable", fixed = TRUE)
expect_identical(tm_roots_snapshot(config$root), before)
# D082's boundary: this sits BELOW a successful extraction, so the unpacked
# files stay exactly as the extraction left them.
expect_identical(tm_dir_snapshot(d), rec$after_extract)
})
test_that("a produced ffplay that cannot be used leaves the install successful", {
# AC3. The wording has to be the produced-but-unusable one, not the
# archive-did-not-produce one, which would be false here: the archive DID
# produce a path for `ffplay`.
config <- tm_redirect_config()
tm_redirect_data()
withr::local_options(cli.width = 1000)
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, real_set = TRUE,
spoil = c(ffplay = "empty")
)
msg <- NULL
expect_true(withCallingHandlers(
install_on_win(install_dir = d, archive_checksum = rec$digest),
message = function(m) {
msg <<- c(msg, cli::ansi_strip(conditionMessage(m)))
invokeRestart("muffleMessage")
}
))
msg <- paste(msg, collapse = "")
expect_match(msg, "ffplay", fixed = TRUE)
expect_match(msg, tm_install_binary(d, "ffplay"), fixed = TRUE)
expect_match(msg, "could not be used", fixed = TRUE)
expect_no_match(msg, "did not produce", fixed = TRUE)
expect_true(file.exists(tm_config_file("ffmpeg", config$new)))
expect_true(file.exists(tm_config_file("ffprobe", config$new)))
expect_false(file.exists(tm_config_file("ffplay", config$new)))
})
# Which refusal a planted form is due. `absent` is no file at all, so nothing
# there can be called unusable; the other three are files the check refuses on
# their own account (M105).
tm_refusal_class <- function(form) {
if (identical(form, "absent")) {
"tidymedia_program_not_extracted"
} else {
"tidymedia_program_unusable"
}
}
# AC4 (M104) and AC2 (M105): each of the four planted forms is disposed the
# way the criteria state, at each of the two required programs and at both in
# one call. One test per form and plant location, so a form or a location that
# stops being refused names itself rather than hiding inside a loop's first
# failure.
tm_expect_required_refusal <- function(form, where = "ffprobe",
class = tm_refusal_class(form)) {
config <- tm_redirect_config()
tm_redirect_data()
withr::local_options(cli.width = 1000)
# The config root is NOT empty when the call starts: `ffplay` already has a
# remembered location pointing somewhere else, so an unchanged file list
# afterwards says the install wrote nothing rather than saying there was
# nothing to overwrite. The file's CONTENTS are read back too, because the
# snapshot records names and not bytes.
kept <- tm_stub_executable("already remembered")
tm_write_location(config$new, "ffplay", kept)
d <- file.path(withr::local_tempdir(), "ffmpeg")
spoil <- stats::setNames(rep(form, length(where)), where)
rec <- tm_mock_install(
confirm = function(prompt) TRUE, real_set = TRUE, spoil = spoil
)
before <- tm_roots_snapshot(config$root)
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, class)
expect_identical(blamed_verb(cnd), "install_on_win")
msg <- cli::ansi_strip(conditionMessage(cnd))
for (program in where) {
expect_match(msg, program, fixed = TRUE)
expect_match(msg, tm_install_binary(d, program), fixed = TRUE)
}
expect_no_match(msg, "Can't find an executable", fixed = TRUE)
expect_identical(tm_roots_snapshot(config$root), before)
expect_identical(readLines(tm_config_file("ffplay", config$new)), kept)
expect_identical(tm_dir_snapshot(d), rec$after_extract)
invisible(cnd)
}
test_that("a path the extraction listed and did not create is refused", {
# AC4, form 1. The premise: nothing is at that path at all -- which is why
# M105 routes it to the archive-did-not-produce refusal rather than to the
# cannot-be-used one: there is no produced file here to call unusable.
cnd <- tm_expect_required_refusal("absent")
expect_no_match(
cli::ansi_strip(conditionMessage(cnd)), "cannot be used", fixed = TRUE
)
})
test_that("a produced path created as an empty file is refused", {
# AC4, form 2 -- the one the executable bit cannot see, which is why the
# check asks for bytes as well as for a resolvable path.
tm_expect_required_refusal("empty")
})
test_that("a produced path created as a directory is refused", {
# AC4, form 3.
tm_expect_required_refusal("dir")
})
test_that("a produced path with no executable bit is refused", {
# AC4, form 4. POSIX only: Windows has no executable bit, so there is no
# such state to plant there.
skip_on_os("windows")
tm_expect_required_refusal("noexec")
})
# Refusals key on what is on disk (M105) --------------------------------------
# AC2's other two plant locations: the same four forms at `ffmpeg`, and at
# both required programs in one call. The ffprobe column is the four M104
# tests above.
for (tm_form in tm_unusable_forms) {
test_that(paste0("a required program planted ", tm_form, " at ffmpeg is refused"), {
if (identical(tm_form, "noexec")) skip_on_os("windows")
tm_expect_required_refusal(tm_form, "ffmpeg")
})
test_that(paste0("both required programs planted ", tm_form, " are refused together"), {
if (identical(tm_form, "noexec")) skip_on_os("windows")
# Both entries named in one message, so the plural is exercised by two
# programs rather than asserted of one.
tm_expect_required_refusal(tm_form, c("ffmpeg", "ffprobe"))
})
}
test_that("a listed path that was never created is not reported as unusable", {
# AC1, read off the whole message rather than off one line of it. The two
# prohibited phrases are the two things this refusal must not say: a path
# holding nothing is not a file that "cannot be used", and the file the
# caller was sent looking for is not "still in that directory".
config <- tm_redirect_config()
tm_redirect_data()
withr::local_options(cli.width = 1000)
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, real_set = TRUE,
spoil = c(ffprobe = "absent")
)
cnd <- tryCatch(
install_on_win(install_dir = d, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_program_not_extracted")
expect_identical(blamed_verb(cnd), "install_on_win")
msg <- cli::ansi_strip(conditionMessage(cnd))
expect_match(msg, tm_install_binary(d, "ffprobe"), fixed = TRUE)
expect_no_match(msg, "cannot be used", fixed = TRUE)
expect_no_match(msg, "still in that directory", fixed = TRUE)
# The premise this test rests on: the extraction REPORTED that path, so the
# refusal is the disk's answer and not the archive list's.
expect_match(msg, "reported writing", fixed = TRUE)
# And the arm is the one for a directory that does hold the rest of the
# build, which is what makes the two prohibitions above non-vacuous.
expect_true(file.exists(tm_install_binary(d, "ffmpeg")))
expect_match(msg, "the files the extraction did produce are in", fixed = TRUE)
expect_false(file.exists(tm_config_file("ffmpeg", config$new)))
})
test_that("an extraction that listed everything and created nothing gives back the directory it made", {
# AC3, first directory case. Every registered program is planted `absent`,
# so the extraction's list names three paths and the install directory holds
# none of them -- the state in which D082's give-back rule applies to this
# refusal like any other, because there is nothing unpacked to point the
# caller at.
tm_redirect_config()
tm_redirect_data()
withr::local_options(cli.width = 1000)
made <- file.path(withr::local_tempdir(), "made", "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, real_set = TRUE, spoil = tm_all_absent
)
cnd <- tryCatch(
install_on_win(install_dir = made, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_program_not_extracted")
expect_false(dir.exists(made))
expect_false(dir.exists(dirname(made)))
msg <- cli::ansi_strip(conditionMessage(cnd))
expect_match(msg, "None of the files the extraction reported are there", fixed = TRUE)
expect_match(msg, "removed the install directory it created", fixed = TRUE)
# It does not send the caller to a directory that is gone.
expect_no_match(msg, "the files the extraction did produce are in", fixed = TRUE)
expect_no_match(msg, "still in that directory", fixed = TRUE)
# The other half of F1/F2: here the extraction DID claim every missing
# program, so the quarantine line is said and the headline is the one that
# does not contradict it. "Did not produce" would (M105 review F2).
expect_match(msg, "reported writing", fixed = TRUE)
expect_match(msg, "Antivirus quarantine", fixed = TRUE)
expect_match(msg, "The archive did not leave behind", fixed = TRUE)
expect_no_match(msg, "The archive did not produce", fixed = TRUE)
})
test_that("an extraction that listed everything and created nothing leaves a directory it found alone", {
# AC3, second directory case: the same all-absent state, but the install
# directory was already there and holds a file of the caller's. Nothing to
# give back, and the message says what the directory holds.
tm_redirect_config()
tm_redirect_data()
withr::local_options(cli.width = 1000)
found <- file.path(withr::local_tempdir(), "found")
dir.create(found)
writeLines("the caller's own file", file.path(found, "keep.txt"))
rec <- tm_mock_install(
confirm = function(prompt) TRUE, real_set = TRUE, spoil = tm_all_absent
)
cnd <- tryCatch(
install_on_win(install_dir = found, archive_checksum = rec$digest),
error = function(cnd) cnd
)
expect_s3_class(cnd, "tidymedia_program_not_extracted")
expect_true(dir.exists(found))
expect_identical(readLines(file.path(found, "keep.txt")), "the caller's own file")
msg <- cli::ansi_strip(conditionMessage(cnd))
expect_match(msg, "None of the files the extraction reported are there", fixed = TRUE)
expect_match(msg, "holds what it held when this call started", fixed = TRUE)
expect_no_match(msg, "the files the extraction did produce are in", fixed = TRUE)
expect_match(msg, "The archive did not leave behind", fixed = TRUE)
expect_no_match(msg, "The archive did not produce", fixed = TRUE)
})
test_that("a directory planted at ffplay's path is informed about, not refused", {
# AC4's second half: one of the four forms is also disposed at an optional
# program the way AC3 states -- the install completes and says what it
# skipped.
config <- tm_redirect_config()
tm_redirect_data()
withr::local_options(cli.width = 1000)
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, real_set = TRUE, spoil = c(ffplay = "dir")
)
msg <- NULL
expect_true(withCallingHandlers(
install_on_win(install_dir = d, archive_checksum = rec$digest),
message = function(m) {
msg <<- c(msg, cli::ansi_strip(conditionMessage(m)))
invokeRestart("muffleMessage")
}
))
msg <- paste(msg, collapse = "")
expect_match(msg, "could not be used", fixed = TRUE)
expect_no_match(msg, "did not produce", fixed = TRUE)
expect_no_match(msg, "Can't find an executable", fixed = TRUE)
expect_false(file.exists(tm_config_file("ffplay", config$new)))
expect_true(file.exists(tm_config_file("ffmpeg", config$new)))
})
test_that("an absent optional program is still reported in one message", {
# The archive omits `ffplay` entirely and nothing else fails. This is the
# pre-M104 optional-program state, asserted here so the new branch beside it
# cannot turn one message into two.
#
# It does NOT exercise both optional states at once, and nothing can:
# `tm_install_registers` minus `tm_install_required` is exactly `ffplay`
# (`R/program_management.R:306`, `:312`), so `absent_optional` and
# `unusable_optional` can never both be non-empty in one call. The combining
# branch in `install_on_win()` is written for a fourth registered program
# that does not exist yet, and is unreachable until one does (M104 review
# F4).
tm_redirect_config()
tm_redirect_data()
withr::local_options(cli.width = 1000)
d <- file.path(withr::local_tempdir(), "ffmpeg")
rec <- tm_mock_install(
confirm = function(prompt) TRUE, real_set = TRUE,
unpack = c("ffmpeg", "ffprobe")
)
count <- 0L
expect_true(withCallingHandlers(
install_on_win(install_dir = d, archive_checksum = rec$digest),
message = function(m) {
count <<- count + 1L
invokeRestart("muffleMessage")
}
))
expect_identical(count, 1L)
})
test_that("an install directory written with a tilde is not refused as unusable", {
# M104 review F1. `file.info()` expands `~` and `Sys.which()` does not, so a
# path built with a tilde was a non-empty file to one clause of the
# registration check and absent to another: the check refused a build it had
# just unpacked correctly, and blamed the archive for it. The fix expands
# where the path is BUILT, so the check and the `set_program()` call after it
# ask about one file -- expanding inside the check alone would move the same
# failure into the loop, which is the partial registration M104 exists to
# stop, so this test asserts the whole install succeeded rather than only
# that the check passed.
skip_on_os("windows")
home <- withr::local_tempdir()
withr::local_envvar(HOME = home)
# The instrument, asserted rather than assumed: with no tilde redirection
# this test would run against the real home directory and prove nothing.
expect_identical(normalizePath(path.expand("~")), normalizePath(home))
config <- tm_redirect_config()
tm_redirect_data()
rec <- tm_mock_install(confirm = function(prompt) TRUE, real_set = TRUE)
expect_true(suppressMessages(
install_on_win(install_dir = "~/ffmpeg", archive_checksum = rec$digest)
))
for (program in c("ffmpeg", "ffprobe", "ffplay")) {
file <- tm_config_file(program, config$new)
expect_true(file.exists(file))
# And what was remembered is the expanded path, so no later caller has to
# expand it again to find the program.
expect_false(grepl("~", readLines(file), fixed = TRUE))
}
})
# tm_usable_binary(), directly (M105) -----------------------------------------
# The six inputs AC5 names, built once. `good` and `empty` are both
# executable, so the size clause is the only thing between them: an empty file
# with the bit set reads as runnable to the predicate exactly as a real program
# does, which is the Windows truncation the check exists to catch.
tm_usable_fixtures <- function(env = parent.frame()) {
dir <- withr::local_tempdir(.local_envir = env)
make <- function(name, bytes, mode) {
path <- file.path(dir, name)
if (bytes) writeLines("stub program", path) else file.create(path)
Sys.chmod(path, mode)
path
}
subdir <- file.path(dir, "subdir.exe")
dir.create(subdir)
list(
good = make("good.exe", TRUE, "0755"),
absent = file.path(dir, "absent.exe"),
empty = make("empty.exe", FALSE, "0755"),
dir = subdir,
noexec = make("noexec.exe", TRUE, "0644")
)
}
test_that("tm_usable_binary() answers for a file, an absent path, an empty file and a directory", {
# AC5, the four forms every platform has. The TRUE case is the control: it
# is asserted here so a check that answered FALSE for everything could not
# pass the three refusals below it.
f <- tm_usable_fixtures()
expect_true(tm_usable_binary(f$good))
expect_false(tm_usable_binary(f$absent))
expect_false(tm_usable_binary(f$empty))
expect_false(tm_usable_binary(f$dir))
# The premise the empty case rests on: it is refused for its size and not
# for failing to resolve, which is what makes it a different case from
# `absent`. Asked of the predicate the check now rests on rather than of
# `Sys.which()`, whose answer for a path is the host's `which` (M138): on the
# two CRAN Fedora images these three lines failed for the instrument, not for
# the code under test.
expect_true(file.exists(f$empty))
expect_true(tm_runnable_program(f$empty))
expect_equal(unname(file.size(f$empty)), 0)
})
test_that("tm_usable_binary() refuses a file with no executable bit", {
# AC5, form 5. POSIX only: Windows has no such bit, so there is no such
# state to plant there -- which is the reason the check does not rest on
# `Sys.which()` alone.
skip_on_os("windows")
f <- tm_usable_fixtures()
expect_false(tm_usable_binary(f$noexec))
# And the premise: it is a real, non-empty file, so the bit is what decides.
expect_gt(file.size(f$noexec), 0)
})
test_that("tm_usable_binary() accepts a tilde-relative path naming a good program", {
# AC5's form 6, and the M104 review F1 disagreement CLOSED rather than
# disclosed (M138). That disagreement was between two readers of one path:
# `file.info()` expands `~` and `Sys.which()` does not, so a tilde path was a
# non-empty file to one clause and absent to the other, and the helper
# answered FALSE for a program that was there. Both readers the predicate
# asks now, `file.info()` and `file.access()`, expand `~` themselves, so the
# clauses ask about one file and the answer is TRUE.
#
# `tm_install_binary()` and `set_program()` still expand where the path is
# BUILT, for D083's and D098's reason: the spawners do not expand a quoted
# `~`, so no tilde string may be recorded. The install test above pins that.
skip_on_os("windows")
home <- withr::local_tempdir()
withr::local_envvar(HOME = home)
# The instrument, asserted rather than assumed.
expect_identical(normalizePath(path.expand("~")), normalizePath(home))
exe <- file.path(home, "good.exe")
writeLines("stub program", exe)
Sys.chmod(exe, "0755")
expect_true(tm_usable_binary("~/good.exe"))
# The expanded form answers the same, which is what "one file" means here.
expect_true(tm_usable_binary(path.expand("~/good.exe")))
# And the tilde is not a free pass: a tilde path naming nothing is refused by
# the same clause as any other absent path.
expect_false(tm_usable_binary("~/no-such-program.exe"))
})
test_that("tm_usable_binary() refuses a directory on its own account", {
# AC4's directory clause. Before M138 this test mocked `Sys.which()` to
# resolve the directory, because `Sys.which()` refuses one on macOS and the
# `!info$isdir` clause could therefore have been doing nothing here. The
# predicate no longer asks `Sys.which()` about a path, and the thing that
# would otherwise accept a directory is `file.access(dir, 1L)`, which answers
# 0 for any directory you can search -- so the clause is load-bearing with no
# mock at all, and this asserts it directly.
f <- tm_usable_fixtures()
# The premise, on the platform where the predicate asks the execute question
# at all: the only clause left to refuse a directory is the directory test,
# because `file.access()` answers 0 for one you can search. On Windows the
# predicate does not ask it (D101: its Windows rule counts a directory as
# executable), so existence is the premise and the directory test is again
# the only clause left.
if (.Platform$OS.type == "windows") {
expect_true(file.exists(f$dir))
} else {
expect_identical(unname(file.access(f$dir, 1L)), 0L)
expect_true(file.exists(f$dir))
}
expect_false(tm_usable_binary(f$dir))
expect_identical(tm_program_state(f$dir), "absent")
# And the check has not become one that refuses everything.
expect_true(tm_usable_binary(f$good))
})
test_that("tm_usable_binary() answers elementwise", {
# AC5's vector half. One call over the six paths returns exactly what the
# six one-path calls return, unnamed -- `Sys.which()` names its result and
# `file.info()` names its rows, so an unnamed answer is a promise about this
# function rather than about either of them.
skip_on_os("windows")
# The sixth path is a tilde-relative path naming a NON-EMPTY EXECUTABLE. It
# was the one form whose two readers disagreed before M138, and now it is the
# one form that proves they agree: it answers TRUE in the vector exactly as
# it does on its own.
home <- withr::local_tempdir()
withr::local_envvar(HOME = home)
expect_identical(normalizePath(path.expand("~")), normalizePath(home))
tilde_exe <- file.path(home, "good.exe")
writeLines("stub program", tilde_exe)
Sys.chmod(tilde_exe, "0755")
expect_true(tm_usable_binary(path.expand("~/good.exe")))
f <- tm_usable_fixtures()
paths <- c(f$good, f$absent, f$empty, f$dir, f$noexec, "~/good.exe")
one_at_a_time <- vapply(paths, tm_usable_binary, logical(1), USE.NAMES = FALSE)
expect_identical(tm_usable_binary(paths), one_at_a_time)
expect_identical(
tm_usable_binary(paths),
c(TRUE, FALSE, FALSE, FALSE, FALSE, TRUE)
)
expect_null(names(tm_usable_binary(paths)))
})
test_that("tm_usable_binary() answers once per element, whatever the length", {
# The three shapes AC5 names beside the six-path vector: a repeat answers
# twice, a length-1 vector answers length 1, and a zero-length vector
# answers `logical(0)` rather than erroring or answering for nothing.
f <- tm_usable_fixtures()
expect_identical(tm_usable_binary(c(f$good, f$good)), c(TRUE, TRUE))
expect_identical(tm_usable_binary(c(f$empty, f$empty)), c(FALSE, FALSE))
expect_identical(tm_usable_binary(f$good), TRUE)
expect_identical(tm_usable_binary(character(0)), logical(0))
})
# tm_os() ------------------------------------------------------------------
test_that("tm_os() reports the running host, lowercased", {
# The seam is bound to the machine, not to a constant: the value it returns
# is `Sys.info()`'s own `sysname` with nothing but the case changed. This is
# the assertion a seam wired to nothing would fail, and it is the one every
# mocked test below borrows its meaning from.
expect_identical(tm_os(), tolower(Sys.info()[["sysname"]]))
})
test_that("tm_os() speaks one lowercase word", {
os <- tm_os()
expect_type(os, "character")
expect_length(os, 1L)
expect_false(is.na(os))
expect_identical(os, tolower(os))
expect_gt(nchar(os), 0L)
# And it is unnamed: `Sys.info()[["sysname"]]` drops the name, where
# `["sysname"]` would keep it and put it into every message built from it.
expect_null(names(os))
})
test_that("tm_os() agrees with .Platform about which family this host is in", {
# Two independent readings of the same fact. `.Platform$OS.type` is compiled
# into R and says only `windows` or `unix`; the gate refuses on the `sysname`
# word, so a disagreement here would mean the gate is deciding on a name the
# rest of R does not recognize for this machine.
if (.Platform$OS.type == "windows") {
expect_identical(tm_os(), "windows")
} else {
expect_false(tm_os() == "windows")
}
})
test_that("tm_os() lowercases whatever sysname the host reports", {
# The vocabulary, pinned over the five names uname produces on the platforms
# this package reaches or could reach. `Sys.info()` is an argument for this
# reason: on every machine the suite runs on it reports one of these, so the
# other four are only reachable by handing them in.
expect_identical(tm_os(info = c(sysname = "Windows")), "windows")
expect_identical(tm_os(info = c(sysname = "Darwin")), "darwin")
expect_identical(tm_os(info = c(sysname = "Linux")), "linux")
expect_identical(tm_os(info = c(sysname = "FreeBSD")), "freebsd")
expect_identical(tm_os(info = c(sysname = "SunOS")), "sunos")
})
test_that("tm_os() falls back to .Platform where Sys.info() is unimplemented", {
# R documents `Sys.info()` as returning NULL where the platform does not
# implement it. The fallback is coarser -- `unix` is not a `sysname` and
# names no package manager -- but it still answers the gate's one question,
# and a Windows host that cannot run `Sys.info()` is still not refused.
expect_identical(tm_os(info = NULL, os_type = "windows"), "windows")
expect_identical(tm_os(info = NULL, os_type = "unix"), "unix")
})
# install_on_win()'s platform gate -----------------------------------------
# The four calls in `install_on_win()` that spend something, each replaced by a
# stub that aborts. Between them they cover every cost the call can incur: the
# unverified-source notice is the first thing said to the caller, `tm_confirm()`
# the first thing asked of them, `dir.create()` the first write, and `tm_fetch()`
# the first byte off the network. A gate that fires above all four has spent
# nothing, and the abort a stub raises is a plain error carrying its own text,
# so a stub that IS reached fails the test by name rather than by class.
tm_forbid_spending <- function(writes = TRUE, env = parent.frame()) {
boom <- function(what) function(...) stop(paste0("reached ", what))
testthat::local_mocked_bindings(
tm_confirm = boom("tm_confirm()"),
tm_fetch = boom("tm_fetch()"),
.env = env
)
testthat::local_mocked_bindings(
cli_inform = boom("cli::cli_inform()"),
.package = "cli",
.env = env
)
# `dir.create()` is base's, so stubbing it replaces the binding the whole
# session sees, testthat's own included: waldo builds its diff through a
# temporary directory, so any `expect_identical()` under this stub dies in
# the reporter rather than in the code under test. `writes = FALSE` leaves it
# alone for a block that compares values; the four-stub claim is asserted by
# the test below that makes no such comparison.
if (writes) {
testthat::local_mocked_bindings(
dir.create = boom("dir.create()"),
.package = "base",
.env = env
)
}
}
test_that("a platform that is not Windows is refused before anything is spent", {
# Two named platforms and one the routing table does not know, so the gate is
# shown to refuse on "not windows" rather than on a list of what to deny.
#
# Two sources, because the four stubs are not all on one call's path: the
# default source has a published digest, so its call never reaches the
# unverified-source notice at all and that stub would sit there proving
# nothing. A caller-named source with no digest is the shape that does reach
# it, and running both is what makes the four-stub claim true of the pair.
sources <- list(
default = list(),
unverified = list(download_url = "https://example.invalid/ffmpeg.7z")
)
for (os in c("darwin", "linux", "freebsd")) {
for (source in names(sources)) {
testthat::local_mocked_bindings(tm_os = function(...) os)
tm_forbid_spending()
expect_error(
do.call(install_on_win, sources[[source]]),
class = "tidymedia_wrong_platform"
)
}
}
})
test_that("an argument mistake still reports on a platform that cannot install", {
# D086 sites the gate BELOW the four argument checks so a caller hears about
# a malformed argument on any machine, and nothing else in the suite pins
# that: the existing cases that would break if the gate were hoisted mock no
# seam, so on the windows-latest leg they pass whatever the ordering is, and
# a hoisted gate would ship green.
testthat::local_mocked_bindings(tm_os = function(...) "darwin")
tm_forbid_spending(writes = FALSE)
bad <- list(
confirm = list(confirm = "yes"),
install_dir = list(install_dir = 42L),
download_url = list(download_url = 42L),
archive_checksum = list(archive_checksum = "nope")
)
for (arg in names(bad)) {
cnd <- expect_error(do.call(install_on_win, bad[[arg]]))
# The argument error, not the platform refusal: the class discriminates,
# and the message names the argument the caller got wrong.
expect_false(inherits(cnd, "tidymedia_wrong_platform"))
expect_match(cli::ansi_strip(conditionMessage(cnd)), arg, fixed = TRUE)
}
})
test_that("the wrong-platform refusal names the platform and its route", {
routes <- list(
darwin = "brew install ffmpeg",
linux = "sudo apt-get install ffmpeg"
)
other <- unname(unlist(routes))
# The name a caller would recognize, where the uname word is not one. Held
# here rather than read from `tm_os_names` so the expectation is stated
# independently of the table under test.
known <- list(darwin = "macOS", sunos = "Solaris")
for (os in c("darwin", "linux", "freebsd", "sunos")) {
testthat::local_mocked_bindings(tm_os = function(...) os)
tm_forbid_spending(writes = FALSE)
cnd <- expect_error(
install_on_win(),
class = "tidymedia_wrong_platform"
)
# The message asserted is the condition's own, not anything emitted beside
# it: a route printed by a separate `cli_inform()` would leave this blind.
msg <- cli::ansi_strip(conditionMessage(cnd))
expect_match(msg, os, fixed = TRUE)
expect_match(msg, "set_program()", fixed = TRUE)
# And where the uname word is not the name a caller knows the platform by,
# that name is beside it -- and where it IS, nothing is added. cli wraps
# the bullet, so the whitespace is flattened before the sentence is read.
flat <- gsub("[[:space:]]+", " ", msg)
expect_match(
flat,
if (is.null(known[[os]])) {
paste0("running on ", os, ".")
} else {
paste0("running on ", os, " (", known[[os]], ").")
},
fixed = TRUE
)
if (!is.null(routes[[os]])) {
expect_match(msg, routes[[os]], fixed = TRUE)
# And only its own route: the two package managers are named one at a
# time, so a message listing both would pass a one-sided check.
for (wrong in setdiff(other, routes[[os]])) {
expect_no_match(msg, wrong, fixed = TRUE)
}
} else {
# A platform the table does not know gets no package-manager advice the
# package cannot stand behind (M108's gate).
for (wrong in other) {
expect_no_match(msg, wrong, fixed = TRUE)
}
}
# The seam's value travels on the condition, in the `tm_`-prefixed field
# data fields take (D062), so a caller can branch on it without parsing.
expect_identical(cnd$tm_platform, os)
}
})
test_that("the gate refuses nothing on Windows", {
# The control: the same stubs, the same call, a seam reporting `windows`, and
# execution reaches the confirmation instead of stopping above it. Three
# argument shapes, because the gate sits above the two argument defaults and
# below the argument checks, and a shape that skipped it would be a gate
# reading something other than the platform.
shapes <- list(
default = list(),
named_dir = list(install_dir = file.path(tempdir(), "m108-install")),
other_source = list(
download_url = "https://example.invalid/ffmpeg.7z",
archive_checksum = strrep("a", 64L)
)
)
for (shape in names(shapes)) {
testthat::local_mocked_bindings(tm_os = function(...) "windows")
reached <- FALSE
testthat::local_mocked_bindings(
tm_confirm = function(...) {
reached <<- TRUE
FALSE
}
)
expect_false(do.call(install_on_win, shapes[[shape]]))
expect_true(reached)
}
})
test_that("install_on_win() aborts tidymedia_confirmation_unavailable from its own frame", {
# AC5 documents this outcome in `@return`, so something has to hold it true.
# `tm_confirm()` has its own test of the refusal, but nothing asserted that
# the class is reachable THROUGH this function -- the documented promise is
# about what a caller of `install_on_win()` can catch, and a refactor that
# answered the prompt some other way would make the help page lie with the
# suite green.
tm_local_windows()
# Not mocked: the real `tm_confirm()` runs, and `rlang::is_interactive()` is
# FALSE under testthat, so it takes the refusal branch on its own.
cnd <- expect_error(
install_on_win(install_dir = withr::local_tempdir()),
class = "tidymedia_confirmation_unavailable"
)
# And it is raised against this function's frame, not the helper's, so the
# error a caller reads names the call they made (D074).
expect_match(
rlang::expr_deparse(conditionCall(cnd))[[1]],
"install_on_win", fixed = TRUE
)
})
# set_program() asks before it remembers a location (M110) -------------------
# Every `set_*` function NAMESPACE exports, read from the file rather than a
# hand list, so an export added later is covered by these guards or reddens
# them. Two shapes for the same reason `rd_sources()` has two: under
# devtools::test() the source tree's NAMESPACE is right there, and under
# R CMD check the tests run against an INSTALLED package, whose root carries
# its own copy. `getNamespaceExports()` is NOT that read: under
# `load_all(export_all = TRUE)` it answers with the internals too.
tm_namespace_set_exports <- function() {
path <- if (file.exists("../../NAMESPACE")) {
"../../NAMESPACE"
} else {
system.file("NAMESPACE", package = "tidymedia")
}
txt <- readLines(path, warn = FALSE)
hits <- regmatches(txt, regexpr("^export\\(set_[^)]+\\)$", txt))
sub("\\)$", "", sub("^export\\(", "", hits))
}
# How each export is called, and which program it ends up remembering. Stated
# here rather than derived from the export's own name, so a wrapper wired to
# the wrong program is a failure rather than an invisible agreement.
tm_set_export_args <- function(fn, location) {
if (fn == "set_program") list("ffmpeg", location) else list(location)
}
tm_set_export_program <- function(fn) {
if (fn == "set_program") "ffmpeg" else sub("^set_", "", fn)
}
# A directory's whole content, byte for byte, plus whether it is there at all.
# The existence flag is load-bearing: a listing of a directory that does not
# exist is `character(0)`, which compares equal to the listing of one this call
# created and left empty.
tm_dir_state <- function(dir) {
files <- sort(list.files(dir, recursive = TRUE, all.files = TRUE, no.. = TRUE))
list(
exists = dir.exists(dir),
files = files,
dirs = sort(list.dirs(dir, full.names = FALSE)),
bytes = lapply(file.path(dir, files), function(p) {
readBin(p, "raw", n = file.size(p))
})
)
}
# One AC1 cell. A function rather than a loop body so every withr redirect and
# every mock unwinds at the end of the cell instead of stacking across 24 of
# them.
tm_set_refusal_cell <- function(state, program, mode, stub) {
root <- withr::local_tempdir()
withr::local_envvar(R_USER_CONFIG_DIR = root)
dir <- tm_config_dir()
if (state != "absent") {
dir.create(dir, recursive = TRUE)
if (state == "occupied") {
tm_write_location(dir, program, "/a/prior/location")
# A second, DIFFERENT program, so the cell compares a two-file directory
# for every member of the vocabulary. Naming one literally would collapse
# this cell to one file on the iteration that names it.
other <- setdiff(tm_program_vocabulary, program)[[1]]
tm_write_location(dir, other, "/another/prior/location")
}
}
before <- tm_dir_state(dir)
# A capability memo present before the call must still be there after it:
# the forget sits on the write path only.
memo <- "tm_m110_memo"
assign(memo, TRUE, envir = .tm_capabilities)
if (mode == "decline") {
rlang::local_interactive()
testthat::local_mocked_bindings(
menu = function(...) 2L,
.package = "utils"
)
expect_false(set_program(program, stub))
} else {
withr::local_options(rlang_interactive = FALSE)
expect_error(
set_program(program, stub),
class = "tidymedia_confirmation_unavailable"
)
}
label <- paste(state, program, mode)
expect_true(exists(memo, envir = .tm_capabilities), label = label)
rm(list = memo, envir = .tm_capabilities)
if (state == "absent") expect_false(dir.exists(dir), label = label)
expect_identical(tm_dir_state(dir), before, label = label)
}
test_that("a declined or refused set_program() leaves the config directory as it found it", {
# AC1. Four programs by three prior directory states by the two ways consent
# is not given. Nothing below tm_confirm() is mocked -- least of all
# dir.create(), whose base-namespace stub takes waldo down with it and so
# every expect_identical() in the same test (M108) -- so the before/after
# comparison observes the disk rather than a record of intentions.
stub <- tm_stub_executable()
for (state in c("absent", "empty", "occupied")) {
for (program in tm_program_vocabulary) {
for (mode in c("decline", "refuse")) {
tm_set_refusal_cell(state, program, mode, stub)
}
}
}
})
# One AC2 cell, a function for the same unwinding reason as above.
tm_set_export_cell <- function(fn, stub) {
root <- withr::local_tempdir()
withr::local_envvar(R_USER_CONFIG_DIR = root)
# Pinned so the escape hatch the message names cannot be split across a
# wrap: the criterion asks for `confirm = FALSE` in the message text.
withr::local_options(cli.width = 1000)
args <- tm_set_export_args(fn, stub)
program <- tm_set_export_program(fn)
withr::local_options(rlang_interactive = FALSE)
cnd <- tryCatch(do.call(fn, args), error = function(cnd) cnd)
expect_s3_class(cnd, "tidymedia_confirmation_unavailable")
expect_match(cli::ansi_strip(conditionMessage(cnd)), "confirm = FALSE",
fixed = TRUE, label = fn)
expect_false(dir.exists(tm_config_dir()), label = fn)
expect_true(do.call(fn, c(args, list(confirm = FALSE))), label = fn)
expect_identical(readLines(tm_config_file(program)), stub, label = fn)
expect_identical(list.files(tm_config_dir()),
paste0(program, "_location.txt"), label = fn)
}
test_that("every exported set_* function takes confirm, refuses, and writes under FALSE", {
# AC2. The domain is read from NAMESPACE; the count is asserted so a read
# that silently returned nothing fails here instead of passing vacuously.
fns <- tm_namespace_set_exports()
expect_setequal(
fns,
c("set_ffmpeg", "set_ffplay", "set_ffprobe", "set_mediainfo", "set_program")
)
defaults <- vapply(fns, function(fn) {
identical(formals(get(fn, envir = asNamespace("tidymedia")))$confirm, TRUE)
}, logical(1))
expect_identical(defaults, stats::setNames(rep(TRUE, length(fns)), fns))
stub <- tm_stub_executable()
for (fn in fns) tm_set_export_cell(fn, stub)
})
test_that("the consent prompt names the file it would write and the location it would write", {
# AC4. The prompt is read at menu(), where the caller would see it. Two of
# the three locations carry braces, which cli would otherwise interpolate in
# the calling frame: `{program}` names a local of the prompt-building frame,
# and an unmatched `{` aborts the call outright (M44).
root <- withr::local_tempdir()
withr::local_envvar(R_USER_CONFIG_DIR = root)
withr::local_options(cli.width = 1000)
rlang::local_interactive()
titles <- character(0)
testthat::local_mocked_bindings(
menu = function(choices, graphics = FALSE, title = NULL) {
titles <<- c(titles, title)
2L
},
.package = "utils"
)
for (name in c("plain", "a{program}b", "c{d")) {
stub <- tm_stub_executable(name = name)
expect_false(set_program("ffmpeg", stub))
prompt <- cli::ansi_strip(titles[[length(titles)]])
expect_match(prompt, stub, fixed = TRUE, label = name)
expect_match(prompt, tm_config_file("ffmpeg"), fixed = TRUE, label = name)
# The braces are shown, never resolved: `ffmpeg` is what `{program}` would
# have become had the value been interpolated.
expect_false(grepl("affmpegb", prompt, fixed = TRUE), label = name)
}
# And what is named is the string as typed, never what Sys.which() resolves
# it to -- the typed string is what gets written, so a prompt naming the
# resolution asks consent for a write that does not happen.
stub <- tm_stub_executable(name = "onpath")
withr::local_path(dirname(stub))
expect_false(set_program("ffmpeg", basename(stub)))
prompt <- cli::ansi_strip(titles[[length(titles)]])
expect_match(prompt, paste0("'", basename(stub), "'"), fixed = TRUE)
expect_false(grepl(dirname(stub), prompt, fixed = TRUE))
# A leading `~` is the one change: the call writes the expanded path (D098),
# so that is what the prompt names.
skip_on_os("windows")
home <- withr::local_tempdir()
withr::local_envvar(HOME = home)
expect_identical(normalizePath(path.expand("~")), normalizePath(home))
writeLines("#!/bin/sh", file.path(home, "tilde.sh"))
Sys.chmod(file.path(home, "tilde.sh"), "0755")
expect_false(set_program("ffmpeg", "~/tilde.sh"))
prompt <- cli::ansi_strip(titles[[length(titles)]])
expect_match(prompt, path.expand("~/tilde.sh"), fixed = TRUE)
expect_false(grepl("~/tilde.sh", prompt, fixed = TRUE))
})
test_that("a set_* call returns TRUE or FALSE invisibly, and the Rd page says both", {
# AC5. Both halves of the value the corrected @return promises, and the page
# that promises it.
root <- withr::local_tempdir()
withr::local_envvar(R_USER_CONFIG_DIR = root)
stub <- tm_stub_executable()
written <- withVisible(set_ffmpeg(stub, confirm = FALSE))
expect_true(written$value)
expect_false(written$visible)
rlang::local_interactive()
testthat::local_mocked_bindings(menu = function(...) 2L, .package = "utils")
declined <- withVisible(set_ffprobe(stub))
expect_false(declined$value)
expect_false(declined$visible)
rd <- rd_sources()
skip_if(is.null(rd), "no Rd source in this run")
hit <- rd[grepl("^set_program\\.Rd$", names(rd))]
expect_length(hit, 1L)
value <- sub("(?s).*\\\\value\\{", "", hit[[1]], perl = TRUE)
value <- sub("(?s)\n\\}.*", "", value, perl = TRUE)
expect_match(value, "TRUE", fixed = TRUE)
expect_match(value, "FALSE", fixed = TRUE)
expect_match(value, "nvisibl")
})
test_that("a location with no executable aborts by name, blaming the export that was typed", {
# AC6. The `call` field, per export -- set_program() included, which is the
# one call reaching the argument's own default (M100: under caller_env() a
# direct call is blamed on its caller, NULL at the console).
fns <- tm_namespace_set_exports()
missing <- file.path(withr::local_tempdir(), "no-such-executable")
expect_false(file.exists(missing))
for (fn in fns) {
cnd <- tryCatch(do.call(fn, tm_set_export_args(fn, missing)),
error = function(cnd) cnd)
expect_s3_class(cnd, "tidymedia_program_not_found")
expect_identical(rlang::call_name(conditionCall(cnd)), fn)
}
})
test_that("a set_* argument refusal blames the export the caller typed", {
# Review finding: the argument checkers ran with their own `caller_env()`
# default, so `set_ffmpeg(x, confirm = "yes")` was blamed on
# `set_program("ffmpeg", location, confirm = confirm, call = ...)` -- a
# function the caller never typed, with the threading shown. The same
# concern AC6 pins for the not-found abort, on the argument-check path.
fns <- tm_namespace_set_exports()
for (fn in fns) {
bad_bool <- tryCatch(
do.call(fn, c(tm_set_export_args(fn, "/bin/ls"), list(confirm = "yes"))),
error = function(cnd) cnd
)
expect_identical(rlang::call_name(conditionCall(bad_bool)), fn, label = fn)
bad_loc <- tryCatch(do.call(fn, tm_set_export_args(fn, 1L)),
error = function(cnd) cnd)
expect_identical(rlang::call_name(conditionCall(bad_loc)), fn, label = fn)
}
# And the program vocabulary, which only set_program() exposes.
bad_program <- tryCatch(set_program("bogus", "/bin/ls"),
error = function(cnd) cnd)
expect_identical(rlang::call_name(conditionCall(bad_program)), "set_program")
})
test_that("the not-found abort carries the program and the location it refused", {
# Review finding: the class was added without the data fields D062's family
# names, so a handler had to regex the cli-formatted message to learn which
# program and which location failed.
missing <- file.path(withr::local_tempdir(), "no-such-executable")
cnd <- tryCatch(set_ffprobe(missing), error = function(cnd) cnd)
expect_s3_class(cnd, "tidymedia_program_not_found")
expect_identical(cnd$tm_program, "ffprobe")
expect_identical(cnd$tm_location, missing)
})
test_that("an approved install asks exactly once and still registers what it produced", {
# AC3. Counted at menu(), below tm_confirm(), so the real confirmation seam
# and the real set_program() both run: a registration that asked again would
# be a second call here. `real_set = TRUE` also means the registrations are
# read back off disk rather than off the mock's record.
asks <- 0L
rlang::local_interactive()
testthat::local_mocked_bindings(
menu = function(choices, graphics = FALSE, title = NULL) {
asks <<- asks + 1L
1L
},
.package = "utils"
)
# All three programs.
tm_redirect_config()
tm_redirect_data()
d <- file.path(withr::local_tempdir(), "ffmpeg")
all_three <- tm_mock_install(real_set = TRUE)
expect_true(install_on_win(install_dir = d, archive_checksum = all_three$digest))
expect_identical(asks, 1L)
for (program in tm_install_registers) {
expect_true(file.exists(tm_config_file(program)), label = program)
expect_identical(readLines(tm_config_file(program)),
tm_install_binary(d, program), label = program)
}
# And an archive producing fewer than three: the count does not follow the
# number of registrations, so a per-program ask would show up here as two.
asks <- 0L
tm_redirect_config()
d2 <- file.path(withr::local_tempdir(), "ffmpeg")
partial <- tm_mock_install(unpack = c("ffmpeg", "ffprobe"), real_set = TRUE)
expect_message(
expect_true(install_on_win(install_dir = d2, archive_checksum = partial$digest)),
"ffplay"
)
expect_identical(asks, 1L)
expect_identical(readLines(tm_config_file("ffmpeg")),
tm_install_binary(d2, "ffmpeg"))
expect_identical(readLines(tm_config_file("ffprobe")),
tm_install_binary(d2, "ffprobe"))
expect_false(file.exists(tm_config_file("ffplay")))
})
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.