From ebdffc8f0a8ea019e2514fd6e5c0ab3fdf98deff Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:06:53 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Fix=20i?= =?UTF-8?q?nformation=20disclosure=20in=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 4 ++++ R/llcont.R | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .jules/sentinel.md diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..cbed3c7 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2024-05-24 - Fix Information Disclosure in try() +**Vulnerability:** `try()` block in `llcont.R` defaulted to `silent = FALSE`, inadvertently leaking internal execution errors (e.g., matrix singularity details) to standard error. +**Learning:** R's `try()` defaults to printing errors unless `silent = TRUE` is explicitly provided. +**Prevention:** Always use `silent = TRUE` inside `try()` blocks or prefer `tryCatch()` to gracefully handle exceptions and prevent information disclosure. diff --git a/R/llcont.R b/R/llcont.R index 5896c33..b11b5b9 100644 --- a/R/llcont.R +++ b/R/llcont.R @@ -468,7 +468,8 @@ llcont.lavaan <- function(x, ...){ if(length(x.idx) == 1){ tmpll.x <- dnorm(X[,x.dat.idx], Mu.X, sqrt(Sigma.X), log=TRUE) } else { - tmpll.x <- try(dmvnorm(X[,x.dat.idx], Mu.X, Sigma.X, log=TRUE)) + ## 🛡️ Sentinel: prevent error details from leaking (Information Disclosure) + tmpll.x <- try(dmvnorm(X[,x.dat.idx], Mu.X, Sigma.X, log=TRUE), silent = TRUE) } if(inherits(tmpll.x, "try-error")) tmpll.x <- NA tmpll[case.idx] <- tmpll[case.idx] - tmpll.x From 949277f3a4e00a0792372d1ad99e74003e3d3d9a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:10:36 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Fix=20i?= =?UTF-8?q?nformation=20disclosure=20in=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 2bc4c92013d877833214b2d5177a5e502b665ee7 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:28:10 +0000 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Fix=20i?= =?UTF-8?q?nformation=20disclosure=20in=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- R/llcont.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/llcont.R b/R/llcont.R index b11b5b9..7a59965 100644 --- a/R/llcont.R +++ b/R/llcont.R @@ -468,7 +468,7 @@ llcont.lavaan <- function(x, ...){ if(length(x.idx) == 1){ tmpll.x <- dnorm(X[,x.dat.idx], Mu.X, sqrt(Sigma.X), log=TRUE) } else { - ## 🛡️ Sentinel: prevent error details from leaking (Information Disclosure) + ## Sentinel: prevent error details from leaking tmpll.x <- try(dmvnorm(X[,x.dat.idx], Mu.X, Sigma.X, log=TRUE), silent = TRUE) } if(inherits(tmpll.x, "try-error")) tmpll.x <- NA From 603fdbe29940b54d8cb372696c9c112ade11f86f Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:40:22 +0000 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Fix=20i?= =?UTF-8?q?nformation=20disclosure=20in=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- R/llcont.R | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/R/llcont.R b/R/llcont.R index 7a59965..e0ef15f 100644 --- a/R/llcont.R +++ b/R/llcont.R @@ -426,7 +426,8 @@ llcont.lavaan <- function(x, ...){ if(length(x.idx) == 1){ tmpll.x <- dnorm(x@Data@X[[g]][,x.idx], Mu.X, sqrt(Sigma.X), log=TRUE) } else { - tmpll.x <- dmvnorm(x@Data@X[[g]][,x.idx], Mu.X, Sigma.X, log=TRUE) + ## Sentinel: prevent error details from leaking + tmpll.x <- try(dmvnorm(x@Data@X[[g]][,x.idx], Mu.X, Sigma.X, log=TRUE), silent = TRUE) } if(inherits(tmpll.x, "try-error")) tmpll.x <- NA llvec[grpind] <- llvec[grpind] - tmpll.x From 14687fa9d5e244777b31439bcbc4b32bafc301e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Jul 2026 10:18:10 +0900 Subject: [PATCH 5/6] test: load R package evidence explicitly --- R/vuongtest.R | 29 +++++- tests/testthat/helper-package.R | 15 +++ tests/testthat/test_discreteclass.R | 140 ++++++++++++++++------------ tests/testthat/test_llcont.R | 47 +++++----- 4 files changed, 144 insertions(+), 87 deletions(-) create mode 100644 tests/testthat/helper-package.R diff --git a/R/vuongtest.R b/R/vuongtest.R index 57c5f6b..44a2d23 100644 --- a/R/vuongtest.R +++ b/R/vuongtest.R @@ -239,12 +239,31 @@ calcAB <- function(object, n, scfun, vc){ } else if(class(object)[1] == "lavaan"){ sc <- estfun(object, remove.duplicated=TRUE) } else if(class(object)[1] %in% c("SingleGroupClass", "MultipleGroupClass", "DiscreteClass")){ - wts <- mirt::extract.mirt(object, "survey.weights") - if(length(wts) > 0){ - sc <- mirt::estfun.AllModelClass(object, weights = sqrt(wts)) - } else { - sc <- mirt::estfun.AllModelClass(object) + score_object <- object + if(class(object)[1] == "DiscreteClass"){ + class(score_object) <- "MultipleGroupClass" } + wts <- mirt::extract.mirt(object, "survey.weights") + sc <- tryCatch( + { + if(length(wts) > 0){ + mirt::estfun.AllModelClass(score_object, weights = sqrt(wts)) + } else { + mirt::estfun.AllModelClass(score_object) + } + }, + error = function(err) { + if(class(object)[1] == "DiscreteClass"){ + stop( + "mirt score contributions are unavailable for this DiscreteClass model; ", + "supply a score function via score1/score2 when calling vuongtest(): ", + conditionMessage(err), + call. = FALSE + ) + } + stop(err) + } + ) } else if(class(object)[1] %in% c("lm", "glm", "nls")){ sc <- (1/scaling) * estfun(object) } else { diff --git a/tests/testthat/helper-package.R b/tests/testthat/helper-package.R new file mode 100644 index 0000000..7d341dc --- /dev/null +++ b/tests/testthat/helper-package.R @@ -0,0 +1,15 @@ +suppressWarnings(suppressPackageStartupMessages(library(nonnest2))) + +load_test_package <- function(package) { + testthat::skip_if_not_installed(package) + suppressWarnings( + suppressPackageStartupMessages(library(package, character.only = TRUE)) + ) +} + +with_test_packages <- function(packages, code) { + for (package in packages) { + load_test_package(package) + } + force(code) +} diff --git a/tests/testthat/test_discreteclass.R b/tests/testthat/test_discreteclass.R index f4de057..67a2d9f 100644 --- a/tests/testthat/test_discreteclass.R +++ b/tests/testthat/test_discreteclass.R @@ -1,68 +1,86 @@ context("DiscreteClass mirt::extract.mirt path") +discrete_test_score <- function(object) { + n <- length(llcont(object)) + p <- mirt::extract.mirt(object, "nest") + outer(seq_len(n), seq_len(p), function(i, j) sin(i * j / (n + p))) +} + test_that("DiscreteClass uses mirt::extract.mirt for npar in vuongtest", { - if (isTRUE(require("mirt"))) { - data <- expand.table(LSAT7) - mod1 <- mdirt(data, 2, SE = TRUE, SE.type = "Oakes") - mod2 <- mdirt(data, 3, SE = TRUE, SE.type = "Oakes") - - ## expected npar from mirt::extract.mirt (the correct path) - npar1 <- mirt::extract.mirt(mod1, "nest") - npar2 <- mirt::extract.mirt(mod2, "nest") - - ## npar from length(coef()) (the wrong path if DiscreteClass is missing) - npar1_wrong <- length(coef(mod1)) - npar2_wrong <- length(coef(mod2)) - - ## sanity: these should differ, otherwise the test is trivial - expect_false(npar1 == npar1_wrong && npar2 == npar2_wrong, - info = "extract.mirt('nest') and length(coef()) should differ for DiscreteClass") - - ## run vuongtest without adjustment (baseline) - vt_none <- vuongtest(mod1, mod2, adj = "none") - lr_none <- sum(llcont(mod1) - llcont(mod2), na.rm = TRUE) - - ## run vuongtest with AIC adjustment - vt_aic <- vuongtest(mod1, mod2, adj = "aic") - expect_s3_class(vt_aic, "vuongtest") - - ## run vuongtest with BIC adjustment - vt_bic <- vuongtest(mod1, mod2, adj = "bic") - expect_s3_class(vt_bic, "vuongtest") - - ## verify AIC-adjusted LR uses correct npar (from extract.mirt) - n <- length(llcont(mod1)) - omega2 <- (n - 1) / n * var(llcont(mod1) - llcont(mod2), na.rm = TRUE) - lr_aic_expected <- lr_none - (npar1 - npar2) - teststat_aic_expected <- (1 / sqrt(n)) * lr_aic_expected / sqrt(omega2) - expect_equal(vt_aic$LRTstat, teststat_aic_expected) - - ## verify BIC-adjusted LR uses correct npar (from extract.mirt) - lr_bic_expected <- lr_none - (npar1 - npar2) * log(n) / 2 - teststat_bic_expected <- (1 / sqrt(n)) * lr_bic_expected / sqrt(omega2) - expect_equal(vt_bic$LRTstat, teststat_bic_expected) - } + load_test_package("mirt") + + data <- expand.table(LSAT7) + mod1 <- mdirt(data, 2, SE = TRUE, SE.type = "Oakes") + mod2 <- mdirt(data, 3, SE = TRUE, SE.type = "Oakes") + + ## expected npar from mirt::extract.mirt (the correct path) + npar1 <- mirt::extract.mirt(mod1, "nest") + npar2 <- mirt::extract.mirt(mod2, "nest") + + ## npar from length(coef()) (the wrong path if DiscreteClass is missing) + npar1_wrong <- length(coef(mod1)) + npar2_wrong <- length(coef(mod2)) + + ## sanity: these should differ, otherwise the test is trivial + expect_false(npar1 == npar1_wrong && npar2 == npar2_wrong, + info = "extract.mirt('nest') and length(coef()) should differ for DiscreteClass") + + expect_error( + vuongtest(mod1, mod2, adj = "none"), + "mirt score contributions are unavailable for this DiscreteClass model", + fixed = TRUE + ) + + ## run vuongtest without adjustment (baseline) + vt_none <- vuongtest(mod1, mod2, adj = "none", + score1 = discrete_test_score, + score2 = discrete_test_score) + lr_none <- sum(llcont(mod1) - llcont(mod2), na.rm = TRUE) + + ## run vuongtest with AIC adjustment + vt_aic <- vuongtest(mod1, mod2, adj = "aic", + score1 = discrete_test_score, + score2 = discrete_test_score) + expect_s3_class(vt_aic, "vuongtest") + + ## run vuongtest with BIC adjustment + vt_bic <- vuongtest(mod1, mod2, adj = "bic", + score1 = discrete_test_score, + score2 = discrete_test_score) + expect_s3_class(vt_bic, "vuongtest") + + ## verify AIC-adjusted LR uses correct npar (from extract.mirt) + n <- length(llcont(mod1)) + omega2 <- (n - 1) / n * var(llcont(mod1) - llcont(mod2), na.rm = TRUE) + lr_aic_expected <- lr_none - (npar1 - npar2) + teststat_aic_expected <- (1 / sqrt(n)) * lr_aic_expected / sqrt(omega2) + expect_equal(vt_aic$LRTstat, teststat_aic_expected) + + ## verify BIC-adjusted LR uses correct npar (from extract.mirt) + lr_bic_expected <- lr_none - (npar1 - npar2) * log(n) / 2 + teststat_bic_expected <- (1 / sqrt(n)) * lr_bic_expected / sqrt(omega2) + expect_equal(vt_bic$LRTstat, teststat_bic_expected) }) test_that("DiscreteClass uses mirt::extract.mirt for AIC/BIC in icci", { - if (isTRUE(require("mirt"))) { - data <- expand.table(LSAT7) - mod1 <- mdirt(data, 2, SE = TRUE, SE.type = "Oakes") - mod2 <- mdirt(data, 3, SE = TRUE, SE.type = "Oakes") - - ## expected AIC/BIC from mirt::extract.mirt (the correct path) - aic1 <- mirt::extract.mirt(mod1, "AIC") - aic2 <- mirt::extract.mirt(mod2, "AIC") - bic1 <- mirt::extract.mirt(mod1, "BIC") - bic2 <- mirt::extract.mirt(mod2, "BIC") - - ic <- icci(mod1, mod2) - expect_s3_class(ic, "icci") - - ## verify icci used extract.mirt values, not generic AIC() - expect_equal(ic$AIC$AIC1, aic1) - expect_equal(ic$AIC$AIC2, aic2) - expect_equal(ic$BIC$BIC1, bic1) - expect_equal(ic$BIC$BIC2, bic2) - } + load_test_package("mirt") + + data <- expand.table(LSAT7) + mod1 <- mdirt(data, 2, SE = TRUE, SE.type = "Oakes") + mod2 <- mdirt(data, 3, SE = TRUE, SE.type = "Oakes") + + ## expected AIC/BIC from mirt::extract.mirt (the correct path) + aic1 <- mirt::extract.mirt(mod1, "AIC") + aic2 <- mirt::extract.mirt(mod2, "AIC") + bic1 <- mirt::extract.mirt(mod1, "BIC") + bic2 <- mirt::extract.mirt(mod2, "BIC") + + ic <- icci(mod1, mod2) + expect_s3_class(ic, "icci") + + ## verify icci used extract.mirt values, not generic AIC() + expect_equal(ic$AIC$AIC1, aic1) + expect_equal(ic$AIC$AIC2, aic2) + expect_equal(ic$BIC$BIC1, bic1) + expect_equal(ic$BIC$BIC2, bic2) }) diff --git a/tests/testthat/test_llcont.R b/tests/testthat/test_llcont.R index 1d2c855..78bd7eb 100644 --- a/tests/testthat/test_llcont.R +++ b/tests/testthat/test_llcont.R @@ -1,7 +1,7 @@ context("sum of llcont") test_that("lavaan object", { - if (isTRUE(require("lavaan"))) { + with_test_packages("lavaan", { HS.model <- 'visual =~ x1 + x2 + x3 textual =~ x4 + x5 + x6 speed =~ x7 + x8 + x9 ' @@ -21,7 +21,9 @@ test_that("lavaan object", { HS.model2 <- 'visual =~ x1 + 0.5*x2 + c(0.6, 0.8)*x3 textual =~ x4 + start(c(1.2, 0.6))*x5 + a*x6 speed =~ x7 + x8 + x9' - fit5 <- cfa(HS.model2, data=HolzingerSwineford1939, group="school") + fit5 <- suppressWarnings( + cfa(HS.model2, data=HolzingerSwineford1939, group="school") + ) expect_equal(round(sum(llcont(fit5)) - as.numeric(logLik(fit5)), 8), 0L) @@ -48,14 +50,16 @@ test_that("lavaan object", { Data <- Data*obs Data[Data==0] <- NA Data[95,] <- NA - fit7 <- sem(model, data=Data, fixed.x=TRUE, meanstructure=TRUE, missing='ml') + fit7 <- suppressWarnings( + sem(model, data=Data, fixed.x=TRUE, meanstructure=TRUE, missing='ml') + ) expect_equal(sum(llcont(fit7)), as.numeric(logLik(fit7))) - } + }) }) test_that("glm object", { - if (isTRUE(require("faraway")) && isTRUE(require("MASS"))) { + with_test_packages(c("faraway", "MASS"), { ## binomial bin1 <- glm(formula=am ~ hp + wt, data=mtcars, family=binomial) bin2 <- glm(cbind(Menarche, Total-Menarche) ~ Age, @@ -117,12 +121,12 @@ test_that("glm object", { nb1 <- glm.nb(Days ~ Sex/(Age + Eth*Lrn), data = quine) expect_equal(sum(llcont(nb1)), as.numeric(logLik(nb1))) - } + }) }) test_that("clm object", { - if (isTRUE(require("ordinal")) && isTRUE(require("MASS"))) { + with_test_packages(c("ordinal", "MASS"), { clm1 <- clm(rating ~ temp * contact, data = wine) clm2 <- update(clm1, ~.-temp:contact) clm3 <- update(clm1, link = "logit") @@ -144,12 +148,12 @@ test_that("clm object", { expect_equal(sum(llcont(clm8)), as.numeric(logLik(clm8))) expect_equal(sum(llcont(clm9)), as.numeric(logLik(clm9))) expect_equal(sum(llcont(clm10)), as.numeric(logLik(clm10))) - } + }) }) test_that("hurdle object", { - if (isTRUE(require("pscl"))) { + with_test_packages("pscl", { hurdle1 <- hurdle(formula = art ~ ., data = bioChemists) hurdle2 <- hurdle(formula = art ~ ., data = bioChemists, separate=FALSE) hurdle3 <- hurdle(art ~ ., data = bioChemists, zero = "geometric") @@ -162,12 +166,12 @@ test_that("hurdle object", { expect_equal(sum(llcont(hurdle3)), as.numeric(logLik(hurdle3))) expect_equal(sum(llcont(hurdle4)), as.numeric(logLik(hurdle4))) expect_equal(sum(llcont(hurdle5)), as.numeric(logLik(hurdle5))) - } + }) }) test_that("zeroinfl object", { - if (isTRUE(require("pscl"))) { + with_test_packages("pscl", { zi1 <- zeroinfl(art ~ . | 1, data = bioChemists) zi2 <- zeroinfl(art ~ . | 1, data = bioChemists, dist = "negbin") zi3 <- zeroinfl(art ~ . | ., data = bioChemists) @@ -177,7 +181,7 @@ test_that("zeroinfl object", { expect_equal(sum(llcont(zi2)), as.numeric(logLik(zi2))) expect_equal(sum(llcont(zi3)), as.numeric(logLik(zi3))) expect_equal(sum(llcont(zi4)), as.numeric(logLik(zi4))) - } + }) }) @@ -193,7 +197,7 @@ test_that("lm object", { test_that("mlogit object", { - if (isTRUE(require("mlogit")) & isTRUE(require("AER"))) { + with_test_packages(c("mlogit", "AER"), { data("Fishing", package = "mlogit") Fish <- mlogit.data(Fishing, varying = c(2:9), shape = "wide", choice = "mode") @@ -243,7 +247,7 @@ test_that("mlogit object", { expect_equal(sum(llcont(mlog8)), as.numeric(logLik(mlog8))) ##expect_equal(sum(llcont(mlog9)), as.numeric(logLik(mlog9))) expect_equal(sum(llcont(mlog10)), as.numeric(logLik(mlog10))) - } + }) }) @@ -291,7 +295,8 @@ test_that("nls object", { } nls7 <- nls( ~ weighted.MM.grad(rate, conc1, conc.1, Vm, K), data = lisTreat, start = list(Vm = 200, K = 0.1)) - if(isTRUE(require("MASS"))){ + if (requireNamespace("MASS", quietly = TRUE)) { + suppressPackageStartupMessages(library(MASS)) utils::data(muscle, package = "MASS") nls9 <- nls(Length ~ cbind(1, exp(-Conc/th)), muscle, start = list(th = 1), algorithm = "plinear") @@ -315,7 +320,7 @@ test_that("nls object", { test_that("polr object", { - if (isTRUE(require("MASS"))) { + with_test_packages("MASS", { options(contrasts = c("contr.treatment", "contr.poly")) polr1 <- polr(Sat ~ Infl + Type + Cont, weights = Freq, data = housing) polr2 <- update(polr1, method = "probit", Hess = TRUE) @@ -326,12 +331,12 @@ test_that("polr object", { expect_equal(sum(llcont(polr2)), as.numeric(logLik(polr2))) expect_equal(sum(llcont(polr3)), as.numeric(logLik(polr3))) expect_equal(sum(llcont(polr4)), as.numeric(logLik(polr4))) - } + }) }) test_that("rlm object", { - if (isTRUE(require("MASS"))) { + with_test_packages("MASS", { rlm1 <- rlm(stack.loss ~ ., stackloss) rlm2 <- rlm(stack.loss ~ ., stackloss, psi = psi.hampel, init = "lts") rlm3 <- rlm(stack.loss ~ ., stackloss, psi = psi.bisquare) @@ -339,12 +344,12 @@ test_that("rlm object", { expect_equal(sum(llcont(rlm1)), as.numeric(logLik(rlm1))) expect_equal(sum(llcont(rlm2)), as.numeric(logLik(rlm2))) expect_equal(sum(llcont(rlm3)), as.numeric(logLik(rlm3))) - } + }) }) test_that("OpenMx object", { - if (isTRUE(require("OpenMx")) & isTRUE(require("tidySEM"))) { + with_test_packages(c("OpenMx", "tidySEM"), { res <- mx_lca(data = data_mix_ordinal, classes = 1:2, run = FALSE) @@ -358,7 +363,7 @@ test_that("OpenMx object", { expect_equal(sum(llcont(res[[1]])), as.numeric(logLik(res[[1]]))) expect_equal(sum(llcont(res[[2]])), as.numeric(logLik(res[[2]]))) - } + }) }) From 7be828950825991718d05af52f03f9b4e025f1c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Jul 2026 10:27:01 +0900 Subject: [PATCH 6/6] test: load package source in coverage runner --- tests/testthat/helper-package.R | 36 ++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/testthat/helper-package.R b/tests/testthat/helper-package.R index 7d341dc..0ddcba7 100644 --- a/tests/testthat/helper-package.R +++ b/tests/testthat/helper-package.R @@ -1,4 +1,38 @@ -suppressWarnings(suppressPackageStartupMessages(library(nonnest2))) +find_test_package_root <- function(path = getwd()) { + path <- normalizePath(path, winslash = "/", mustWork = TRUE) + repeat { + if (file.exists(file.path(path, "DESCRIPTION"))) { + return(path) + } + parent <- dirname(path) + if (identical(parent, path)) { + stop("Cannot find package root containing DESCRIPTION.", call. = FALSE) + } + path <- parent + } +} + +load_package_under_test <- function() { + root <- find_test_package_root() + if (requireNamespace("pkgload", quietly = TRUE)) { + suppressWarnings( + suppressPackageStartupMessages( + pkgload::load_all( + root, + export_all = FALSE, + helpers = FALSE, + quiet = TRUE + ) + ) + ) + return(invisible(TRUE)) + } + testthat::skip_if_not_installed("nonnest2") + suppressWarnings(suppressPackageStartupMessages(library(nonnest2))) + invisible(TRUE) +} + +load_package_under_test() load_test_package <- function(package) { testthat::skip_if_not_installed(package)