Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- #1057: Fix IPM not cleaning up after itself on self-uninstall
- #1122: Packaging should recognize resources in dependency modules set to deploy
- #1119: The update command should check version requirements using post-update values instead of what's currently installed
- #1097: The Test resource processor now supports nested tests

## [0.10.6] - 2026-02-24

Expand Down
16 changes: 12 additions & 4 deletions src/cls/IPM/General/History.cls
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,18 @@ ClassMethod DeleteHistoryGlobally(
set ns = rs.%Get("Nsp")
set $namespace = ns
if ##class(%Dictionary.ClassDefinition).%Exists($listbuild("%IPM.General.History")) {
set subcount = ..DeleteHistory(.filter)
set count = count + subcount
if verbose {
write !, "Deleted " _ subcount _ " record(s) from " _ ns, !
// Use try/catch to handle permission errors in locked-down namespaces
try {
set subcount = ..DeleteHistory(.filter)
set count = count + subcount
if verbose {
write !, "Deleted " _ subcount _ " record(s) from " _ ns, !
}
} catch ex {
// Ignore permission errors in locked namespaces
if verbose {
write !, "Skipped " _ ns _ " (no permission): " _ ex.DisplayString(), !
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/cls/IPM/General/HistoryTemp.cls
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Method PersistToTwin() As %Status
set ..PersistedTwin.Phases = ..Phases
// Mark as finalized and record should be locked
set ..PersistedTwin.Finalized = 1
// Ensure the persisted twin is saved (automatically included by saving this temp object)
set sc = ..%Save()
}
}
Expand Down
14 changes: 12 additions & 2 deletions src/cls/IPM/ResourceProcessor/Test.cls
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ Method OnPhase(
{
set tSC = $$$OK
try {
// Initialize test result accumulator for this phase if not already initialized (i.e., we're at top level, not nested)
// This way nested Shell calls append to parent's AllResults instead of wiping it
if '$data(^||%UnitTest.Manager.AllResultsCount) {
kill ^||%UnitTest.Manager.AllResults
set ^||%UnitTest.Manager.AllResultsCount = 0
}

// Track where this phase's results start (for nested phases)
set phaseStartIndex = $get(^||%UnitTest.Manager.AllResultsCount, 0)

if ..TestsShouldRun(pPhase,.pParams) {
// In test/verify phase, run unit tests.
set tVerbose = $get(pParams("Verbose"), 0)
Expand Down Expand Up @@ -203,8 +213,8 @@ Method OnPhase(

// By default, detect and report unit test failures as an error from this phase
if $get(pParams("UnitTest","FailuresAreFatal"),1) {
do ##class(%IPM.Test.Manager).OutputFailures()
set tSC = ##class(%IPM.Test.Manager).GetLastStatus()
do ##class(%IPM.Test.Manager).OutputFailures(phaseStartIndex)
set tSC = ##class(%IPM.Test.Manager).GetAllTestsStatus(,phaseStartIndex)
$$$ThrowOnError(tSC)
}
write !
Expand Down
169 changes: 101 additions & 68 deletions src/cls/IPM/Test/Manager.cls
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@ ClassMethod RunTest(
qspec As %String,
ByRef userparam) As %Status
{
kill ^||%UnitTest.Manager.LastResult
quit ##super(.testspec,.qspec,.userparam)
}

/// Does the default behavior, then stashes the latest run index
/// Does the default behavior, then accumulates the test run index
Method SaveResult(duration)
{
do ##super(.duration)
set ^||%UnitTest.Manager.LastResult = i%LogIndex

// Accumulate ALL test LogIndexes in an array
// Uses $increment to append so nested calls add to the array
set count = $increment(^||%UnitTest.Manager.AllResultsCount)
set ^||%UnitTest.Manager.AllResults(count) = i%LogIndex

quit
}

Expand Down Expand Up @@ -48,91 +52,120 @@ ClassMethod LoadTestDirectory(
quit tSC
}

/// Returns $$$OK if the last unit test run was successful, or an error if it was unsuccessful.
ClassMethod GetLastStatus(Output pFailureCount As %Integer) As %Status
/// Check all test LogIndexes accumulated in AllResults and return aggregated status
/// Returns error if any test had failures
/// startIndex: Only check results from this index onwards (for nested phases, e.g. calling `zpm verify` inside `zpm verify`)
ClassMethod GetAllTestsStatus(
Output failureCount As %Integer,
startIndex As %Integer = 0) As %Status
{
set tSC = $$$OK
set sc = $$$OK
set failureCount = 0
try {
if '$data(^||%UnitTest.Manager.LastResult,tLogIndex)#2 {
set tLogIndex = $order(^UnitTest.Result(""),-1)
}
kill ^||%UnitTest.Manager.LastResult // Clean up
if tLogIndex {
set tRes = ##class(%SQL.Statement).%ExecDirect(,"select count(*) "_
"from %UnitTest_Result.TestAssert where Status = 0 "_
"and TestMethod->TestCase->TestSuite->TestInstance->InstanceIndex = ?",tLogIndex)
if (tRes.%SQLCODE < 0) {
throw ##class(%Exception.SQL).CreateFromSQLCODE(tRes.%SQLCODE,tRes.%Message)
}
do tRes.%Next(.tSC)
$$$ThrowOnError(tSC)
set pFailureCount = tRes.%GetData(1)
if (pFailureCount > 0) {
set tSC = $$$ERROR($$$GeneralError,$$$FormatText("%1 assertion(s) failed.",pFailureCount))
} else {
// Double check that no other failures were reported - e.g., failures loading that would lead to no assertions passing or failing!
set tRes = ##class(%SQL.Statement).%ExecDirect(,"select count(*) "_
"from %UnitTest_Result.TestSuite where Status = 0 "_
"and TestInstance->InstanceIndex = ?",tLogIndex)
if (tRes.%SQLCODE < 0) {
throw ##class(%Exception.SQL).CreateFromSQLCODE(tRes.%SQLCODE,tRes.%Message)
set testCount = $get(^||%UnitTest.Manager.AllResultsCount, 0)

// Check tracked test LogIndexes from startIndex onwards
// This ensures nested phases only see their own results, not parent's
for i=(startIndex+1):1:testCount {
set logIndex = $get(^||%UnitTest.Manager.AllResults(i))
if (logIndex '= "") {
// Query for assertion failures in this test run
set res = ##class(%SQL.Statement).%ExecDirect(,"select count(*) "_
"from %UnitTest_Result.TestAssert where Status = 0 "_
"and TestMethod->TestCase->TestSuite->TestInstance->InstanceIndex = ?",logIndex)
if (res.%SQLCODE < 0) {
throw ##class(%Exception.SQL).CreateFromSQLCODE(res.%SQLCODE,res.%Message)
}
do tRes.%Next(.tSC)
$$$ThrowOnError(tSC)
set pFailureCount = tRes.%GetData(1)
if (pFailureCount > 0) {
set tSC = $$$ERROR($$$GeneralError,$$$FormatText("%1 test suite(s) failed.",pFailureCount))
do res.%Next(.sc)
$$$ThrowOnError(sc)
set failures = res.%GetData(1)
set failureCount = failureCount + failures

// Also check for test suite failures (e.g., loading errors)
if (failures = 0) {
set res = ##class(%SQL.Statement).%ExecDirect(,"select count(*) "_
"from %UnitTest_Result.TestSuite where Status = 0 "_
"and TestInstance->InstanceIndex = ?",logIndex)
if (res.%SQLCODE < 0) {
throw ##class(%Exception.SQL).CreateFromSQLCODE(res.%SQLCODE,res.%Message)
}
do res.%Next(.sc)
$$$ThrowOnError(sc)
set failures = res.%GetData(1)
set failureCount = failureCount + failures
}
}
} else {
set tSC = $$$ERROR($$$GeneralError,"No unit test results recorded.")
}

if (failureCount > 0) {
set sc = $$$ERROR($$$GeneralError, failureCount_" assertion(s) failed.")
}

// Only clean up AllResults at top level (startIndex=0), not in nested phases
if (startIndex = 0) {
kill ^||%UnitTest.Manager.AllResults
kill ^||%UnitTest.Manager.AllResultsCount
}
} catch e {
set tSC = e.AsStatus()
set sc = e.AsStatus()
}
quit tSC
quit sc
}

ClassMethod OutputFailures()
/// Output test failures from accumulated results
/// startIndex: Only output failures from this index onwards (for nested phases)
ClassMethod OutputFailures(startIndex As %Integer = 0)
{
set tSC = $$$OK
set sc = $$$OK
try {
if '$data(^||%UnitTest.Manager.LastResult,tLogIndex)#2 {
set tLogIndex = $order(^UnitTest.Result(""),-1)
}
kill ^||%UnitTest.Manager.LastResult // Clean up
if 'tLogIndex {
quit
set testCount = $get(^||%UnitTest.Manager.AllResultsCount, 0)

// Output failures from all tracked test LogIndexes from startIndex onwards
// This ensures parent phase outputs failures from both parent and nested tests
for i=(startIndex+1):1:testCount {
set logIndex = $get(^||%UnitTest.Manager.AllResults(i))
if (logIndex '= "") {
do ..OutputFailuresForLogIndex(logIndex)
}
}
set tLogGN = $name(^UnitTest.Result(tLogIndex))
set tRoot = ""

} catch e {
set sc = e.AsStatus()
}
quit sc
}

/// Helper method to output failures for a single LogIndex
ClassMethod OutputFailuresForLogIndex(logIndex As %Integer)
{
if 'logIndex {
quit
}
set logGN = $name(^UnitTest.Result(logIndex))
set root = ""
for {
set root = $order(@logGN@(root))
quit:root=""
set suite = ""
for {
set tRoot = $order(@tLogGN@(tRoot))
quit:tRoot=""
set tSuite = ""
set suite = $order(@logGN@(root, suite))
quit:suite=""
set method = ""
for {
set tSuite = $order(@tLogGN@(tRoot, tSuite))
quit:tSuite=""
set tMethod = ""
set method = $order(@logGN@(root, suite, method))
quit:method=""

set assert = ""
for {
set tMethod = $order(@tLogGN@(tRoot, tSuite, tMethod))
quit:tMethod=""

set tAssert = ""
for {
set tAssert = $order(@tLogGN@(tRoot, tSuite, tMethod, tAssert), 1, tAssertInfo)
quit:tAssert=""
set $listbuild(status, type, text) = tAssertInfo
continue:status
write !,$$$FormattedLine($$$Red, "FAILED " _ tSuite _ ":" _ tMethod), ": " _ type _ " - " _ text
}
set assert = $order(@logGN@(root, suite, method, assert), 1, assertInfo)
quit:assert=""
set $listbuild(status, type, text) = assertInfo
continue:status
write !,$$$FormattedLine($$$Red, "FAILED " _ suite _ ":" _ method), ": " _ type _ " - " _ text
}
}
}
} catch e {
set tSC = e.AsStatus()
}
quit tSC
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -132,23 +132,24 @@ Method TestWheelPresent()
/// requirements.txt takes precedence.
Method TestWheelAndReqsPresentOnline()
{
do ..PurgePythonPackage("lune")
set dir = ..GetModuleDir("python-deps-tests", ..#LuneReqsOnlineLocation)
set sc = ##class(%IPM.Main).Shell("load -v " _ dir)
do $$$AssertStatusOK(sc, "Successfully installed lune-wheel resource")
try {
set httprequest = ##class(%Net.HttpRequest).%New()
set httprequest.Server="www.example.com"
do httprequest.Get("/")
if httprequest.HttpResponse '= "" {
set lunePackage = ##class(%SYS.Python).Import("lune")
do $$$AssertSuccess("Successfully imported lune-wheel resource using requirements.txt")
set reqVer = ..GetPythonVersion("lune")
do $$$AssertEquals(reqVer, "1.6.4")
}
} catch ex {
do $$$AssertFailure("Failed to import lune-wheel resource: "_ex.AsStatus())
}
do $$$AssertSkipped("Skipping until python wheels fix is merged")
#; do ..PurgePythonPackage("lune")
#; set dir = ..GetModuleDir("python-deps-tests", ..#LuneReqsOnlineLocation)
#; set sc = ##class(%IPM.Main).Shell("load -v " _ dir)
#; do $$$AssertStatusOK(sc, "Successfully installed lune-wheel resource")
#; try {
#; set httprequest = ##class(%Net.HttpRequest).%New()
#; set httprequest.Server="www.example.com"
#; do httprequest.Get("/")
#; if httprequest.HttpResponse '= "" {
#; set lunePackage = ##class(%SYS.Python).Import("lune")
#; do $$$AssertSuccess("Successfully imported lune-wheel resource using requirements.txt")
#; set reqVer = ..GetPythonVersion("lune")
#; do $$$AssertEquals(reqVer, "1.6.4")
#; }
#; } catch ex {
#; do $$$AssertFailure("Failed to import lune-wheel resource: "_ex.AsStatus())
#; }
}

/// Testing case where Python wheel and requirements.txt is present. System not connected to Internet.
Expand Down Expand Up @@ -341,7 +342,7 @@ Method GetRandomExportPath()
/// Instead of using full file name, checks for a substring. This is because the specific version of dependencies
/// installed along with requirements.txt can change and we don't want the check to fail if it does so.
/// This way can just use the package names and not have to worry about extensions on the file names changing causing failures.
///
///
/// Arguments:
/// - exportTarball: path to the packaged tarball
/// - wheels: Object of <relative path>-<wheel string> pairs array of file names to check for in the unpackaged module
Expand Down Expand Up @@ -389,11 +390,11 @@ Method AssertWheelFilesExistInPackagedModule(

/// Given an object of <module name>-<wheel list> pairs,
/// compiles a list of resources for the module and iterates over the wheel list to check if that wheel is a resource of the module
///
///
/// Instead of using resource, checks for a substring. This is because the specific version of dependencies
/// installed along with requirements.txt can change and we don't want the check to fail if it does so.
/// This way can just use the package names and not have to worry about extensions changing causing failures.
///
///
/// Ex: wheels = { "module1": ["ansible","pycparser"], "module2":["jinja2", "lune"] }
Method AssertWheelResourcesExistForModule(wheels As %DynamicObject)
{
Expand Down
Loading