tests/testthat/test-runtime-timeout.R

# M69: the `tidymedia.timeout` seam.
#
# The resolver is the whole contract's front door: every spawn site passes what
# it returns straight to base R's `timeout=`. Two of its rules exist because
# base R's own handling is unsafe as a package default (measured on R 4.6.1):
#   - a value below 1 TRUNCATES to 0, which is base R's sentinel for "no
#     limit", so `0.5` would leave the call completely unbounded -- the exact
#     silent failure this milestone removes. Non-whole values are refused.
#   - `system2()` accepts `"2"` and `c(1, 2)` without complaint, so nothing
#     downstream will catch a malformed option for us.

test_that("resolve_timeout() returns 0 when the option is unset", {
  withr::local_options(tidymedia.timeout = NULL)
  expect_identical(resolve_timeout(), 0)
})

test_that("resolve_timeout() returns a whole positive limit unchanged", {
  withr::local_options(tidymedia.timeout = 30)
  expect_identical(resolve_timeout(), 30)
  withr::local_options(tidymedia.timeout = 1L)
  expect_identical(resolve_timeout(), 1)
})

test_that("resolve_timeout() accepts an explicit 0 as 'no limit'", {
  withr::local_options(tidymedia.timeout = 0)
  expect_identical(resolve_timeout(), 0)
})

test_that("resolve_timeout() refuses a fractional limit, naming whole seconds", {
  # 0.5 is the dangerous one: base R truncates it to 0 and the call runs
  # unbounded. 1.9 is refused for the same reason (it silently becomes 1).
  withr::local_options(tidymedia.timeout = 0.5)
  expect_error(resolve_timeout(), "whole number")
  withr::local_options(tidymedia.timeout = 1.9)
  expect_error(resolve_timeout(), "whole number")
})

test_that("resolve_timeout() names the option in its refusal", {
  withr::local_options(tidymedia.timeout = 0.5)
  expect_error(resolve_timeout(), "tidymedia.timeout")
})

test_that("resolve_timeout() refuses a negative, NA, string or vector limit", {
  withr::local_options(tidymedia.timeout = -1)
  expect_error(resolve_timeout())
  withr::local_options(tidymedia.timeout = NA_real_)
  expect_error(resolve_timeout())
  # base system2() ACCEPTS both of these, so only this guard catches them.
  withr::local_options(tidymedia.timeout = "2")
  expect_error(resolve_timeout())
  withr::local_options(tidymedia.timeout = c(1, 2))
  expect_error(resolve_timeout())
})

# timeout_status() ------------------------------------------------------------

# AC4: a timeout is recognized by the `status` attribute, never by matching R's
# warning text, whose wording is translated under a non-English locale (M46).
# AC6: status 124 is only a timeout when a limit was actually in force -- a
# program may exit 124 for its own reasons.

test_that("timeout_status() reports a timeout only when a limit was in force", {
  out <- structure(character(0), status = 124L)
  expect_true(is_timeout(out, limit = 2))
  expect_false(is_timeout(out, limit = 0))
})

test_that("timeout_status() does not read any other status as a timeout", {
  expect_false(is_timeout(structure(character(0), status = 1L), limit = 2))
  expect_false(is_timeout(structure(character(0), status = 234L), limit = 2))
  expect_false(is_timeout(character(0), limit = 2))
})

test_that("is_timeout() accepts the status however system2() types it", {
  # system() and system2() have both been seen returning the status as double
  # and as integer; the comparison must not depend on which.
  expect_true(is_timeout(structure(character(0), status = 124), limit = 2))
  expect_true(is_timeout(structure(character(0), status = 124L), limit = 2))
})

test_that("abort_timeout() names the program and the limit in seconds", {
  err <- expect_error(abort_timeout("FFmpeg", 30))
  msg <- cli::ansi_strip(conditionMessage(err))
  expect_match(msg, "FFmpeg")
  expect_match(msg, "30")
  expect_match(msg, "second")
})

test_that("abort_timeout() signals a distinct condition class", {
  # The class is what lets a caller distinguish a timeout from any other
  # failure -- probe_all() and count_audio_streams() deliberately absorb both
  # today, and the class is what would let a later change separate them.
  err <- expect_error(abort_timeout("FFmpeg", 30))
  expect_s3_class(err, "tidymedia_timeout")
})

test_that("abort_timeout() names the option so the caller can raise it", {
  err <- expect_error(abort_timeout("FFmpeg", 30))
  expect_match(cli::ansi_strip(conditionMessage(err)), "tidymedia.timeout")
})

# The four spawn sites (AC1, AC2) ---------------------------------------------

# The domain of AC1 is the four sites named in the milestone's Scope, not
# "every spawn site" -- a source regex cannot enumerate that, and the plan's
# criteria audit cut the universal for exactly that reason. These read the
# functions' own bodies, so a site that stops passing the limit reddens here.

timeout_site_bodies <- function() {
  list(
    ffmpeg    = tidymedia::ffmpeg,
    ffprobe   = tidymedia::ffprobe,
    mediainfo = tidymedia::mediainfo,
    run_program = get("run_program", envir = asNamespace("tidymedia"))
  )
}

test_that("each of the four spawn sites passes a resolved limit to timeout=", {
  for (nm in names(timeout_site_bodies())) {
    src <- paste(deparse(body(timeout_site_bodies()[[nm]])), collapse = "\n")
    expect_match(src, "resolve_timeout(", fixed = TRUE,
                 info = paste(nm, "must resolve the limit"))
    expect_match(src, "timeout = limit", fixed = TRUE,
                 info = paste(nm, "must pass it to timeout="))
    expect_match(src, "guard_timeout(", fixed = TRUE,
                 info = paste(nm, "must route through the shared guard"))
  }
})

test_that("with the option unset, each site resolves a limit of 0", {
  withr::local_options(tidymedia.timeout = NULL)
  expect_identical(resolve_timeout(), 0)
})

test_that("guard_timeout() re-raises a non-timeout warning but drops it on a timeout", {
  # Layer 0 has always let a non-zero exit warn; that must survive.
  expect_warning(
    guard_timeout("FFmpeg", 0, {warning("ordinary"); structure("x", status = 1L)}),
    "ordinary"
  )
  # On a timeout the held warning is dropped -- it carries the command line and
  # the input= temp path -- and replaced by the package's own message.
  expect_error(
    guard_timeout("FFmpeg", 2, {warning("secret /tmp/path"); structure("x", status = 124L)}),
    "timed out"
  )
})

test_that("guard_timeout(suppress = TRUE) discards warnings, as run_program() always has", {
  expect_no_warning(
    guard_timeout("x", 0, {warning("ordinary"); "ok"}, suppress = TRUE)
  )
})

test_that("no warning at all escapes a timed-out guard (AC7, locale-free)", {
  # Asserted as "no warning", never as a match on `timed out after`: R's warning
  # text is translated under a non-English locale, so a text match would pass
  # while the command line still leaked (M46).
  expect_no_warning(
    tryCatch(
      guard_timeout("FFmpeg", 2, {warning("timed out after 2s"); structure("x", status = 124L)}),
      error = function(e) NULL
    )
  )
})

# Execution: a real program actually gets killed (AC3, AC5, AC7) ---------------

# These are the criterion's real evidence. The body-reading tests above prove
# the limit is PASSED; only these prove it BITES.
#
# The hang is produced by a FIFO nobody writes to: FFmpeg blocks reading its
# header forever, so the test does not race the machine's encoding speed the way
# "encode 600 seconds of video" would. A slow-command fixture that a fast host
# finishes before the limit would pass this file while measuring nothing.
#
# Windows has no mkfifo, so the gate skips there. Per M68, the fixture is built
# INSIDE the gate -- a platform that cannot create it must not reach the
# creation call -- and the gate skips rather than fail()s, because
# testthat::fail() RECORDS a failure and RETURNS, falling on into the operation
# it guards.
#
# These tests are SLOW on Linux and the slowness is the feature under test, not
# waste: base R escalates SIGINT -> SIGTERM -> SIGKILL across limit + 40 s, and
# an FFmpeg blocked on the FIFO rides that ladder to the end, so each of these
# costs ~42 s there against ~2 s on macOS. Five of them is four minutes of a
# CRAN check spent waiting, which is not a reasonable thing to ask of CRAN's
# machines -- so they skip there. `devtools::check()` and the CI workflow both
# set NOT_CRAN, so the release gate and every push still run them; only CRAN's
# own submission check opts out.
# local_blocking_input() itself lives in helper-timeout-sweep.R, so M70's grid
# can anchor against the same real hang rather than building a second fixture
# (M40). Everything above about why it is a FIFO, and why it skips, still
# applies to it.

test_that("ffmpeg() aborts at the limit instead of blocking forever", {
  blocked <- local_blocking_input()
  out <- withr::local_tempfile(fileext = ".mp4")
  withr::local_options(tidymedia.timeout = 2)
  start <- Sys.time()
  err <- expect_error(
    ffmpeg(paste("-y -i", shQuote(blocked), shQuote(out))),
    class = "tidymedia_timeout"
  )
  expect_lt(as.numeric(difftime(Sys.time(), start, units = "secs")), 60)
  msg <- cli::ansi_strip(conditionMessage(err))
  expect_match(msg, "FFmpeg")
  expect_match(msg, "2 seconds")
})

test_that("ffprobe() aborts at the limit", {
  skip_if_no_ffprobe()
  blocked <- local_blocking_input()
  withr::local_options(tidymedia.timeout = 2)
  start <- Sys.time()
  err <- expect_error(ffprobe(paste("-i", shQuote(blocked))),
                      class = "tidymedia_timeout")
  expect_lt(as.numeric(difftime(Sys.time(), start, units = "secs")), 60)
  msg <- cli::ansi_strip(conditionMessage(err))
  expect_match(msg, "FFprobe")
  expect_match(msg, "2 seconds")
})

test_that("the abort names the limit but never R's command line or temp path", {
  # AC7's other half: R's timeout warning embeds the full command line and the
  # input= temp file. The package's message replaces it.
  blocked <- local_blocking_input()
  out <- withr::local_tempfile(fileext = ".mp4")
  withr::local_options(tidymedia.timeout = 2)
  err <- expect_error(ffmpeg(paste("-y -i", shQuote(blocked), shQuote(out))))
  msg <- cli::ansi_strip(conditionMessage(err))
  expect_no_match(msg, tempdir(), fixed = TRUE)
})

test_that("no R timeout warning escapes the call (AC7, locale-free)", {
  blocked <- local_blocking_input()
  out <- withr::local_tempfile(fileext = ".mp4")
  withr::local_options(tidymedia.timeout = 2)
  # Asserted as "no warning at all", never as a match on R's English text:
  # under a translated locale a text match passes while the warning still leaks.
  expect_no_warning(
    tryCatch(ffmpeg(paste("-y -i", shQuote(blocked), shQuote(out))),
             error = function(e) NULL)
  )
})

test_that("ffm_run() aborts at the limit and states D046's disposition (AC5)", {
  blocked <- local_blocking_input()
  out <- withr::local_tempfile(fileext = ".mp4")
  withr::local_options(tidymedia.timeout = 2)
  start <- Sys.time()
  err <- expect_error(ffm_run(ffm_files(blocked, out)), class = "tidymedia_timeout")
  expect_lt(as.numeric(difftime(Sys.time(), start, units = "secs")), 60)
  msg <- cli::ansi_strip(conditionMessage(err))
  expect_match(msg, "FFmpeg")
  expect_match(msg, "2 seconds")
  # FFmpeg blocks on the FIFO's header BEFORE it opens the output, so it writes
  # nothing and D046 correctly has nothing to report -- the disposition bullet
  # is absent, not missing. What must hold is that no output was left behind.
  expect_false(file.exists(out))
})

test_that("a timed-out ffm_run() removes what the killed run DID write (AC5)", {
  # The written case cannot be produced by the FIFO: FFmpeg never reaches the
  # output. Racing a real encode against the limit would make the test depend on
  # the host's speed, which is the failure mode M31/M46 both paid for. So the
  # kill is injected at the seam instead, leaving the cleanup path -- the thing
  # AC5 is about -- entirely real.
  skip_if_no_ffmpeg()
  video <- make_test_video()
  out <- withr::local_tempfile(fileext = ".mp4")
  withr::local_options(tidymedia.timeout = 2)
  calls <- 0L
  local_mocked_bindings(
    run_program = function(...) {
      calls <<- calls + 1L
      # What a killed FFmpeg leaves behind: a partial output on disk.
      writeLines("half an mp4", out)
      abort_timeout("FFmpeg", 2)
    }
  )
  err <- expect_error(ffm_run(ffm_files(video, out)), class = "tidymedia_timeout")
  # Count the invocation rather than trusting the mock ran: a mock that is
  # never reached makes every assertion below vacuous (M44).
  expect_identical(calls, 1L)
  msg <- cli::ansi_strip(conditionMessage(err))
  expect_match(msg, "timed out")
  expect_match(msg, "was removed")
  expect_false(file.exists(out))
})

test_that("ffm_run() keeps an output the killed run never wrote, per D046", {
  blocked <- local_blocking_input()
  out <- withr::local_tempfile(fileext = ".mp4")
  writeLines("pre-existing", out)
  keep <- readLines(out)
  withr::local_options(tidymedia.timeout = 2)
  err <- expect_error(ffm_run(ffm_files(blocked, out)), class = "tidymedia_timeout")
  expect_match(cli::ansi_strip(conditionMessage(err)), "left as it was")
  # D046's rule is applied unchanged: FFmpeg blocked on the input and never
  # opened the output, so what was already there survives.
  expect_true(file.exists(out))
  expect_identical(readLines(out), keep)
})

test_that("with the option unset the same blocking input is NOT bounded", {
  # The control. Without it every assertion above would pass even if the abort
  # came from something other than the timeout -- a FIFO input is unusual
  # enough that FFmpeg could plausibly refuse it outright.
  blocked <- local_blocking_input()
  out <- withr::local_tempfile(fileext = ".mp4")
  withr::local_options(tidymedia.timeout = NULL)
  # Bounded by the harness, not by the package: if the package were still
  # imposing a limit this would abort instead of being killed at 8 s.
  res <- withCallingHandlers(
    system2(find_ffmpeg(), shQuote(c("-y", "-i", blocked, out)),
            stdout = TRUE, input = "", timeout = 8),
    warning = function(w) invokeRestart("muffleWarning")
  )
  expect_identical(as.integer(attr(res, "status")), 124L)
})

# The readers absorb a timeout instead of losing the batch (D047) --------------

# D047 puts a timeout on the same footing as any other unreadable file for the
# metadata readers. The defect these fence is not "the hung file fails" -- it is
# that the hung file used to take every OTHER file's result down with it, which
# is what a 500-file corpus with one bad file actually costs.

test_that("probe_all() yields an NA row for a hung file and keeps the rest", {
  skip_if_no_ffprobe()
  blocked <- local_blocking_input()
  good <- make_test_video()
  withr::local_options(tidymedia.timeout = 2)
  info <- NULL
  expect_warning(info <- probe_all(c(good, blocked, good)), "Could not probe")
  # Three rows, in input order, and the two readable files still carry data:
  # the assertion that fails without the absorber is that this returned at all.
  expect_identical(nrow(info$container), 3L)
  expect_identical(info$container$file, c(good, blocked, good))
  expect_true(is.na(info$container$format_name[[2]]))
  expect_false(is.na(info$container$format_name[[1]]))
})

test_that("mediainfo_parameter() returns NA for a hung file and keeps the rest", {
  # The kill is injected at the run_program() seam rather than produced by a
  # FIFO: MediaInfo's behavior on a named pipe is not something this suite can
  # pin down across platforms, and the loop's recovery -- the thing F3 is about
  # -- stays entirely real either way.
  one <- withr::local_tempfile(fileext = ".mp4")
  two <- withr::local_tempfile(fileext = ".mp4")
  writeLines("not really a video", one)
  writeLines("not really a video", two)
  calls <- 0L
  local_mocked_bindings(
    find_mediainfo = function(...) "mediainfo",
    run_program = function(...) {
      calls <<- calls + 1L
      if (calls == 2L) abort_timeout("MediaInfo", 2)
      "1920"
    }
  )
  vals <- NULL
  expect_warning(
    vals <- mediainfo_parameter(c(one, two), section = "Video",
                                parameter = "Width"),
    "Could not read"
  )
  # Both files were attempted, and the first file's value survived the second
  # file's timeout.
  expect_identical(calls, 2L)
  expect_identical(vals, c(1920L, NA_integer_))
})

test_that("mediainfo_query() returns an NA row for a hung file and keeps the rest", {
  # mediainfo_read() is the third absorber site and the one the first return
  # shipped untested; mediainfo_query() and mediainfo_template() both reach it.
  one <- withr::local_tempfile(fileext = ".mp4")
  two <- withr::local_tempfile(fileext = ".mp4")
  writeLines("not really a video", one)
  writeLines("not really a video", two)
  calls <- 0L
  local_mocked_bindings(
    find_mediainfo = function(...) "mediainfo",
    run_program = function(...) {
      calls <<- calls + 1L
      if (calls == 1L) abort_timeout("MediaInfo", 2)
      c("Width, Height", "1920, 1080")
    }
  )
  res <- NULL
  expect_warning(
    res <- mediainfo_query(c(one, two), section = "Video",
                           parameters = c("Width", "Height")),
    "Could not read"
  )
  expect_identical(calls, 2L)
  expect_identical(nrow(res), 2L)
  # The hung file is row 1 and NA; the file after it still carries its values,
  # which is the property the absorber exists for.
  expect_true(is.na(res$Width[[1]]))
  expect_identical(res$Width[[2]], 1920L)
})

# A hung file is named as hung, not merged into "unreadable" -------------------

# The readers still return the same NA row D047 specifies. What changed is the
# diagnosis: a caller who set a limit precisely to catch hangs could not tell a
# hang from a corrupt file, and one caller -- verify_media() -- turned that
# ambiguity into an actively wrong message.

test_that("probe_all()'s warning says which files timed out", {
  skip_if_no_ffprobe()
  blocked <- local_blocking_input()
  good <- make_test_video()
  withr::local_options(tidymedia.timeout = 2)
  expect_warning(probe_all(c(good, blocked)), "timed out rather than")
})

test_that("probe_all() does not mention a timeout when nothing timed out", {
  # The control: without it the assertion above would pass on a reader that
  # always says "timed out", which would be the same defect facing the other way.
  skip_if_no_ffprobe()
  missing <- file.path(tempdir(), "no-such-file-XYZ.mp4")
  good <- make_test_video()
  withr::local_options(tidymedia.timeout = NULL)
  w <- tryCatch(probe_all(c(good, missing)), warning = function(w) w)
  expect_s3_class(w, "warning")
  expect_no_match(cli::ansi_strip(conditionMessage(w)), "timed out")
})

test_that("verify_media() refuses on a timed-out probe instead of failing every check", {
  # The defect: probe_all() absorbs the timeout, every expectation reads
  # `actual = NA`, and ffm_run(verify=) then aborts blaming a successful encode
  # for producing the wrong width. A probe that never answered is not an answer.
  video <- make_test_video()
  withr::local_options(tidymedia.timeout = 2)
  calls <- 0L
  local_mocked_bindings(
    run_program = function(...) {
      calls <<- calls + 1L
      abort_timeout("FFprobe", 2)
    }
  )
  err <- NULL
  # No warning either: the probe's "could not read" warning describes the same
  # event the abort describes, and telling the caller twice is noise.
  expect_no_warning(
    err <- expect_error(
      verify_media(video, width = 64, video_codec = "h264"),
      class = "tidymedia_timeout"
    )
  )
  expect_identical(calls, 1L)
  msg <- cli::ansi_strip(conditionMessage(err))
  expect_match(msg, "FFprobe")
  expect_match(msg, "2 seconds")
  # The wrong message must be gone, not merely joined by a right one.
  expect_no_match(msg, "expected", fixed = TRUE)
})

test_that("verify_media() still surfaces the probe's warning when nothing timed out", {
  # The other half of the hold-and-replay: holding the warning is only correct
  # because it is replayed when the abort does NOT happen. Without this, an
  # unreadable file would go from "warned about" to silent, which is a
  # regression the timeout test cannot see.
  skip_if_no_ffprobe()
  bad <- withr::local_tempfile(fileext = ".mp4")
  writeLines("not a video at all", bad)
  withr::local_options(tidymedia.timeout = NULL)
  res <- NULL
  w <- expect_warning(
    res <- verify_media(bad, width = 64),
    "Could not probe"
  )
  # Replayed as the condition it was, not re-signalled from its text.
  expect_s3_class(w, "rlang_warning")
  expect_false(res$pass[[1]])
})

# The Layer 0 hatch leaves its partial output behind (F7's narrowed claim) -----

test_that("a timed-out ffmpeg() leaves what the killed run wrote", {
  # ?tidymedia and NEWS.md both say the raw hatch does NOT clean up after a
  # timeout, because it never parses the argument string and so cannot know
  # which argument is the output. That claim needs a run that actually writes
  # something before it hangs, which a FIFO input cannot produce -- FFmpeg
  # blocks on the header before opening its output. A stand-in binary that
  # writes and then blocks produces it deterministically, without racing a real
  # encode against the limit on an unknown host (the M31/M46 failure mode).
  skip_on_os("windows")
  out <- withr::local_tempfile(fileext = ".mp4")
  fake <- withr::local_tempfile(fileext = ".sh")
  writeLines(
    c("#!/bin/sh", 'printf partial > "$1"', "exec sleep 600"),
    fake
  )
  Sys.chmod(fake, "0755")
  local_mocked_bindings(find_ffmpeg = function(...) shQuote(fake))
  withr::local_options(tidymedia.timeout = 2)
  start <- Sys.time()
  expect_error(ffmpeg(shQuote(out)), class = "tidymedia_timeout")
  expect_lt(as.numeric(difftime(Sys.time(), start, units = "secs")), 60)
  # The kill really happened after the write, so the surviving file is the
  # documented behavior and not an artifact of nothing having run.
  expect_true(file.exists(out))
  expect_identical(readLines(out, warn = FALSE), "partial")
})

# Documentation (AC8) ---------------------------------------------------------

test_that("?tidymedia documents the option's name, unit, default and effect", {
  rd <- rd_sources()
  skip_if(is.null(rd), "no Rd source available")
  # Read through the shared two-shape reader: under R CMD check there is no
  # man/ dir, and a source-tree-only guard would silently SKIP in exactly the
  # run the release gate uses (M51).
  hit <- rd[grepl("tidymedia-package", names(rd))]
  expect_length(hit, 1)
  txt <- hit[[1]]
  expect_match(txt, "tidymedia.timeout", fixed = TRUE)
  expect_match(txt, "second")            # the unit
  expect_match(txt, "no limit")          # what the default means
  # What reaching it does moved to ?with_timeout at M127; ?tidymedia points
  # there, and that page names the error.
  expect_match(txt, "link[=with_timeout]", fixed = TRUE)
  src <- doc_timeout_sources()
  skip_if(is.null(src$rd), "?with_timeout not available")
  expect_match(src$rd, "error with the class \\code{tidymedia_timeout}",
               fixed = TRUE)
})

# The assertions above are substring greps, and a substring grep is how the
# over-broad claim shipped green once already: "abort" was present the whole
# time the doc said EVERY timed-out call aborts, which stopped being true when
# the readers began absorbing. These two guards fence the scoped claim instead,
# so restoring the unqualified sentence reddens them.

# doc_timeout_sources() lives in helper-rd.R: M70's uniform-rule guard reads the
# same two shapes, and a second reader is how two guards stop agreeing (M40).

# The scoped-claim guard and the no-warning disclosure guard that stood here are
# retired with the disclosure itself (M70/T6). They fenced a three-way
# description that M69 had to write because it could not enumerate its own
# domain. The replacement is in test-timeout-silence.R: the uniform rule, plus
# the sweep that derives the calls it quantifies over.

test_that("both docs disclose that the abort can lag the limit", {
  src <- doc_timeout_sources()
  skip_if(is.null(src$rd) || is.null(src$news), "docs not available")
  for (nm in c("rd", "news")) {
    txt <- src[[nm]]
    expect_match(txt, "40 seconds", fixed = TRUE, info = nm)
    expect_match(txt, "guarantee", info = nm)
  }
})

# M078 measured the lag the guard above only asserts the SHAPE of. "40 seconds"
# was present the whole time the two per-function topics said a program is
# "bounded by" the limit with no lag mentioned at all -- the doc carrying the
# disclosure was `?tidymedia`, and the grep does not know which topic it read.
# This one fences the measured number, so a future edit that drops the
# arithmetic back to a bare "may be exceeded" reddens.
#
# M127 moved the measured 42.0 s out of the help page and into the code comment
# at the top of R/timeout.R, since user docs carry no measured timings. The
# changelog keeps the number; the help page keeps the arithmetic behind the
# bound, so an edit back to a bare "may be exceeded" still reddens.
test_that("both docs give the lag's size, not just its existence", {
  src <- doc_timeout_sources()
  skip_if(is.null(src$rd) || is.null(src$news), "docs not available")
  expect_match(src$news, "42.0 seconds", fixed = TRUE)
  rd <- gsub("[[:space:]]+", " ", src$rd)
  expect_match(rd, "20 seconds later", fixed = TRUE)
  expect_match(rd, "up to 40 seconds past the limit", fixed = TRUE)
})

test_that("NEWS.md carries the entry", {
  # Two shapes, for the same reason the Rd guard has two: under R CMD check the
  # tests run against an INSTALLED package with no source tree. NEWS.md IS
  # installed into the package root, so the guard runs in both shapes rather
  # than skipping in exactly the run the release gate uses (M51).
  news <- if (file.exists("../../NEWS.md")) {
    "../../NEWS.md"
  } else {
    p <- system.file("NEWS.md", package = "tidymedia")
    if (nzchar(p)) p else NULL
  }
  skip_if(is.null(news), "NEWS.md not available in either shape")
  txt <- paste(readLines(news, warn = FALSE), collapse = "\n")
  expect_match(txt, "tidymedia.timeout", fixed = TRUE)
})

Try the tidymedia package in your browser

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

tidymedia documentation built on Oct. 11, 2026, 5:08 p.m.