diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f72ed274..7de760cfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - #992: Implement automatic history purge logic - #973: Enables CORS and JWT configuration for WebApplications in module.xml +- #971: Adds support for JSON, YAML, and Toon formats via the -f flag and new -DUnitTest.*Output directives. ### Fixed - #1001: The `unmap` and `enable` commands will now only activate CPF merge once after all namespaces have been configured instead after every namespace diff --git a/preload/cls/IPM/Installer.cls b/preload/cls/IPM/Installer.cls index 26970544b..02916635b 100644 --- a/preload/cls/IPM/Installer.cls +++ b/preload/cls/IPM/Installer.cls @@ -113,6 +113,7 @@ ClassMethod ZPMInit( $$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue("UseStandalonePip", "", 0)) $$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue("SemVerPostRelease", 0, 0)) $$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue("DefaultLogEntryLimit",20, 0)) + $$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue("TestReportFormat","toon", 0)) quit $$$OK } diff --git a/src/cls/IPM/Main.cls b/src/cls/IPM/Main.cls index 64f7e2819..d6b65e204 100644 --- a/src/cls/IPM/Main.cls +++ b/src/cls/IPM/Main.cls @@ -75,6 +75,7 @@ Can also specify desired version to update to. + diff --git a/src/cls/IPM/Repo/UniversalSettings.cls b/src/cls/IPM/Repo/UniversalSettings.cls index e1ff1f3ed..f644c3dc7 100644 --- a/src/cls/IPM/Repo/UniversalSettings.cls +++ b/src/cls/IPM/Repo/UniversalSettings.cls @@ -45,7 +45,10 @@ Parameter SemVerPostRelease = "SemVerPostRelease"; /// to retain IPM history records before they are eligible for cleanup. Parameter HistoryRetain = "history_retain"; -Parameter CONFIGURABLE = "trackingId,analytics,ColorScheme,TerminalPrompt,PublishTimeout,PipCaller,UseStandalonePip,SemVerPostRelease,DefaultLogEntryLimit,HistoryRetain"; +/// Specifies the serialization format (JSON, TOON, YAML) for unit and integration test results in the shell. +Parameter TestReportFormat = "TestReportFormat"; + +Parameter CONFIGURABLE = "trackingId,analytics,ColorScheme,TerminalPrompt,PublishTimeout,PipCaller,UseStandalonePip,SemVerPostRelease,DefaultLogEntryLimit,HistoryRetain,TestReportFormat"; /// Returns configArray, that includes all configurable settings ClassMethod GetAll(Output configArray) As %Status @@ -190,4 +193,16 @@ ClassMethod GetHistoryRetain() As %Integer return ..GetValue(..#HistoryRetain) } +ClassMethod SetTestReportFormat( + val As %String, + overwrite As %Boolean = 1) As %Boolean +{ + return ..SetValue(..#TestReportFormat, val, overwrite) +} + +ClassMethod GetTestReportFormat() As %String +{ + return ..GetValue(..#TestReportFormat) +} + } diff --git a/src/cls/IPM/ResourceProcessor/Test.cls b/src/cls/IPM/ResourceProcessor/Test.cls index efb500c85..070aba8f4 100644 --- a/src/cls/IPM/ResourceProcessor/Test.cls +++ b/src/cls/IPM/ResourceProcessor/Test.cls @@ -189,23 +189,54 @@ Method OnPhase( zkill ^UnitTestRoot $$$ThrowOnError(tSC) - if $data(pParams("UnitTest","JUnitOutput"),tJUnitFile) { - set tPostfix = "-"_$zconvert(pPhase,"L")_"-" - if (..Package '= "") { - set tPostfix = tPostfix_$replace(..Package,".","-")_"-PKG" - } elseif (..Class '= "") { - set tPostfix = tPostfix_$replace(..Class,".","-")_"-CLS" + if $data(pParams("UnitTest"))>1 { + set outputType="" + for { + set outputType = $order(pParams("UnitTest",outputType),1,fileName) + quit:outputType="" + set tPostfix = "-"_$$$lcase(pPhase)_"-" + if (..Package '= "") { + set tPostfix = tPostfix_$replace(..Package,".","-")_"-PKG" + } elseif (..Class '= "") { + set tPostfix = tPostfix_$replace(..Class,".","-")_"-CLS" + } + set outputClass = "%IPM.Test."_outputType + if '$$$defClassDefined(outputClass) { + $$$ThrowOnError($$$ERROR($$$GeneralError,"The requested "_outputType_" output format does not exist.")) + } + set extension = $select(outputType="JsonOutput":".json",outputType="ToonOutput":".toon",outputType="YamlOutput":".yaml",1:".xml") + set fileName = $piece(fileName,".",1,*-1)_tPostfix_extension + set tSC = $classmethod(outputClass,"ToFile",fileName) + $$$ThrowOnError(tSC) } - set tJUnitFile = $piece(tJUnitFile,".",1,*-1)_tPostfix_".xml" - set tSC = ##class(%IPM.Test.JUnitOutput).ToFile(tJUnitFile) - $$$ThrowOnError(tSC) } + write ! + if $data(pParams("outputformat"),outputFormat)||('tVerbose) { + write !,"Test result summary",! + set defaultOutputFormat = ##class(%IPM.Repo.UniversalSettings).GetTestReportFormat() + if defaultOutputFormat'="" { + set outputFormat = defaultOutputFormat + } + else { + if $get(outputFormat)="" { + set outputFormat="Toon" + } + } + set outputClass = "%IPM.Test."_$zconvert(outputFormat,"w")_"Output" + if '$$$defClassDefined(outputClass) { + $$$ThrowOnError($$$ERROR($$$GeneralError,"The requested "_outputType_" output format does not exist.")) + } + set defaultTestStatus = "failed" + set sc = $classmethod(outputClass,"OutputToDevice",,defaultTestStatus) + $$$ThrowOnError(sc) + write ! + } // 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() - $$$ThrowOnError(tSC) + set sc = ##class(%IPM.Test.Manager).GetLastStatus() + $$$ThrowOnError(sc) } write ! } diff --git a/src/cls/IPM/Test/Abstract.cls b/src/cls/IPM/Test/Abstract.cls new file mode 100644 index 000000000..c64cc3817 --- /dev/null +++ b/src/cls/IPM/Test/Abstract.cls @@ -0,0 +1,68 @@ +/// The class serves as the base class for all the unit test result formatting. +Class %IPM.Test.Abstract Extends %RegisteredObject +{ + +ClassMethod ToFile( + FileName As %String, + CaseStatus As %String = "", + TestIndex As %Integer = {$order(^UnitTest.Result(""),-1)}) As %Status [ Abstract ] +{ +} + +ClassMethod OutputToDevice( + TestIndex As %Integer = {$order(^UnitTest.Result(""),-1)}, + TestStatus As %String = "") [ Abstract ] +{ +} + +Query FilteredTestResults( + Instance As %Integer, + TestStatus) As %SQLQuery(ROWSPEC = "TotalCounts:%Integer,namespace:%String,duration:%String,testDateTime:%String,suiteName:%String,testcaseName:%String,methodName:%String,testMethod:%String,assertAction:%String,assertCounter:%Integer,assertDescription:%String,assertLocation:%String", SELECTMODE = "DISPLAY") +{ +SELECT + count(*) as TotalCounts, + tinstance.Namespace AS namespace, + tinstance.Duration AS duration, + tinstance.DateTime AS testDateTime, + tsuite.Name AS suiteName, + tcase.Name AS testcaseName, + tmethod.Name AS methodName, + tassert.TestMethod AS testMethod, + tassert.Action AS assertAction, + tassert.Counter AS assertCounter, + tassert.Description AS assertDescription, + tassert.Location AS assertLocation +FROM +%UnitTest_Result.TestInstance tinstance +JOIN %UnitTest_Result.TestSuite tsuite ON tsuite.TestInstance=tinstance.ID +JOIN %UnitTest_Result.TestCase tcase ON tcase.TestSuite=tsuite.ID +JOIN %UnitTest_Result.TestMethod tmethod ON tmethod.TestCase=tcase.ID +JOIN %UnitTest_Result.TestAssert tassert ON tassert.TestMethod=tmethod.ID +WHERE tinstance.ID=:Instance AND tassert.Status=:TestStatus +} + +Query GetAllTestResults(Instance As %Integer) As %SQLQuery(ROWSPEC = "TotalCounts:%String,namespace:%String,duration:%String,testDateTime:%String,suiteName:%String,testcaseName:%String,methodName:%String,testMethod:%String,assertAction:%String,assertCounter:%Integer,assertDescription:%String,assertLocation:%String", SELECTMODE = "DISPLAY") +{ +SELECT + count(*) as TotalCounts, + tinstance.Namespace AS namespace, + tinstance.Duration AS duration, + tinstance.DateTime AS testDateTime, + tsuite.Name AS suiteName, + tcase.Name AS testcaseName, + tmethod.Name AS methodName, + tassert.TestMethod AS testMethod, + tassert.Action AS assertAction, + tassert.Counter AS assertCounter, + tassert.Description AS assertDescription, + tassert.Location AS assertLocation +FROM +%UnitTest_Result.TestInstance tinstance +JOIN %UnitTest_Result.TestSuite tsuite ON tsuite.TestInstance=tinstance.ID +JOIN %UnitTest_Result.TestCase tcase ON tcase.TestSuite=tsuite.ID +JOIN %UnitTest_Result.TestMethod tmethod ON tmethod.TestCase=tcase.ID +JOIN %UnitTest_Result.TestAssert tassert ON tassert.TestMethod=tmethod.ID +WHERE tinstance.ID=:Instance +} + +} diff --git a/src/cls/IPM/Test/JsonOutput.cls b/src/cls/IPM/Test/JsonOutput.cls new file mode 100644 index 000000000..dac91487d --- /dev/null +++ b/src/cls/IPM/Test/JsonOutput.cls @@ -0,0 +1,93 @@ +Class %IPM.Test.JsonOutput Extends %IPM.Test.Abstract +{ + +ClassMethod ToFile( + FileName As %String, + TestStatus As %String = "", + TestIndex As %Integer = {$order(^UnitTest.Result(""),-1)}) As %Status +{ + set sc = $$$OK + try { + set fileStream = ##class(%Stream.FileCharacter).%New() + set fileStream.TranslateTable = "UTF8" + $$$ThrowOnError(fileStream.LinkToFile(FileName)) + set responseJson = ..JSON(TestIndex, TestStatus) + if $isobject(responseJson) { + do fileStream.Write(responseJson.%ToJSON()) + } + $$$ThrowOnError(fileStream.%Save()) + } catch ex { + set sc = ex.AsStatus() + } + return sc +} + +ClassMethod OutputToDevice( + TestIndex As %Integer = {$order(^UnitTest.Result(""),-1)}, + pCaseStatus As %String = "") As %Status +{ + set sc = $$$OK + try { + set responseJson = ..JSON(TestIndex, pCaseStatus) + write ! + if $isobject(responseJson) { + do responseJson.%ToJSON() + } + } catch ex { + set sc = ex.AsStatus() + } + return sc +} + +ClassMethod JSON( + TestIndex As %Integer, + TestStatus As %String) As %DynamicObject +{ + if TestStatus'="" { + set tResult = ..FilteredTestResultsFunc(TestIndex, TestStatus) + } else { + set tResult = ..GetAllTestResultsFunc(TestIndex) + } + set unitTest = {} + set unitTest.results = [] + set (previousID,currentSuite,currentTestcase,suiteObj,testcaseObj) = "" + + while tResult.%Next() { + if previousID = "" { + set unitTest.id = TestIndex + set unitTest.namespace = tResult.namespace + set unitTest.duration = tResult.duration + set unitTest.testDateTime = tResult.testDateTime + } + set previousID = TestIndex + if tResult.suiteName '= currentSuite { + set currentSuite = tResult.suiteName + set suiteObj = { + "suiteName": (currentSuite), + "testcases": [] + } + do unitTest.results.%Push(suiteObj) + set currentTestcase = "" + } + if tResult.testcaseName '= currentTestcase { + set currentTestcase = tResult.testcaseName + set testcaseObj = { + "testcaseName": (currentTestcase), + "methods": [] + } + do suiteObj.testcases.%Push(testcaseObj) + } + set methodObj = { + "methodName": (tResult.methodName), + "testMethod": (tResult.testMethod), + "assertAction": (tResult.assertAction), + "assertCounter": (tResult.assertCounter), + "assertDescription": (tResult.assertDescription), + "assertLocation": (tResult.assertLocation) + } + do testcaseObj.methods.%Push(methodObj) + } + return unitTest +} + +} diff --git a/src/cls/IPM/Test/ToonOutput.cls b/src/cls/IPM/Test/ToonOutput.cls new file mode 100644 index 000000000..f0826a21f --- /dev/null +++ b/src/cls/IPM/Test/ToonOutput.cls @@ -0,0 +1,77 @@ +Class %IPM.Test.ToonOutput Extends %IPM.Test.Abstract +{ + +ClassMethod ToFile( + FileName As %String, + TestStatus As %String = "", + TestIndex As %Integer = {$order(^UnitTest.Result(""),-1)}) As %Status +{ + set sc = $$$OK + try { + set fileStream = ##class(%Stream.FileCharacter).%New() + set fileStream.TranslateTable = "UTF8" + $$$ThrowOnError(fileStream.LinkToFile(FileName)) + + if TestStatus'="" { + set tResult = ..FilteredTestResultsFunc(TestIndex, TestStatus) + } else { + set tResult = ..GetAllTestResultsFunc(TestIndex) + } + + set currentID="" + while tResult.%Next() { + if currentID = "" { + set currentID = TestIndex + do fileStream.WriteLine("unitTest:") + do fileStream.WriteLine(" id: "_TestIndex) + do fileStream.WriteLine(" namespace: "_tResult.namespace) + do fileStream.WriteLine(" duration: "_tResult.duration) + do fileStream.WriteLine(" testDateTime: "_tResult.testDateTime) + do fileStream.WriteLine() + do fileStream.WriteLine("results["_tResult.TotalCounts_"]{suiteName,testcaseName,methodName,status,assertAction,assertCounter,assertDescription,assertLocation}:") + } + set data = " "_tResult.suiteName_","_tResult.testcaseName_","_tResult.methodName_","_TestIndex_","_ + tResult.assertAction_","_tResult.assertCounter_","""_$translate(tResult.assertDescription,"""")_""","""_tResult.assertLocation_"""" + do fileStream.WriteLine(data) + } + $$$ThrowOnError(fileStream.%Save()) + } catch ex { + set sc = ex.AsStatus() + } + return sc +} + +ClassMethod OutputToDevice( + TestIndex As %Integer = {$order(^UnitTest.Result(""),-1)}, + TestStatus As %String = "") As %Status +{ + set sc = $$$OK + try { + if TestStatus'="" { + set tResult = ..FilteredTestResultsFunc(TestIndex, TestStatus) + } else { + set tResult = ..GetAllTestResultsFunc(TestIndex) + } + set currentID="" + while tResult.%Next() { + if currentID = "" { + set currentID = TestIndex + write !,"unitTest:" + write !," id: "_TestIndex + write !," namespace: "_tResult.namespace + write !," duration: "_tResult.duration + write !," testDateTime: "_tResult.testDateTime + write ! + write !,"results["_tResult.TotalCounts_"]{suiteName,testcaseName,methodName,status,assertAction,assertCounter,assertDescription,assertLocation}:" + } + set data = " "_tResult.suiteName_","_tResult.testcaseName_","_tResult.methodName_","_TestIndex_","_ + tResult.assertAction_","_tResult.assertCounter_","""_$translate(tResult.assertDescription,"""")_""","""_tResult.assertLocation_"""" + write !,data + } + } catch ex { + set sc = ex.AsStatus() + } + return sc +} + +} diff --git a/src/cls/IPM/Test/YamlOutput.cls b/src/cls/IPM/Test/YamlOutput.cls new file mode 100644 index 000000000..bc6c1d229 --- /dev/null +++ b/src/cls/IPM/Test/YamlOutput.cls @@ -0,0 +1,83 @@ +Class %IPM.Test.YamlOutput Extends %IPM.Test.Abstract +{ + +ClassMethod ToFile( + FileName As %String, + TestStatus As %String = "", + TestIndex As %Integer = {$order(^UnitTest.Result(""),-1)}) As %Status +{ + set sc = $$$OK + try { + set fileStream = ##class(%Stream.FileCharacter).%New() + set fileStream.TranslateTable = "UTF8" + $$$ThrowOnError(fileStream.LinkToFile(FileName)) + do fileStream.CopyFrom(..YAML(TestIndex, TestStatus)) + $$$ThrowOnError(fileStream.%Save()) + } catch ex { + set sc = ex.AsStatus() + } + return sc +} + +ClassMethod OutputToDevice( + TestIndex As %Integer = {$order(^UnitTest.Result(""),-1)}, + TestStatus As %String = "") As %Status +{ + set sc = $$$OK + try { + set yamlStream = ..YAML(TestIndex, TestStatus) + write ! + while 'yamlStream.AtEnd { + write yamlStream.Read() + } + } catch ex { + set sc = ex.AsStatus() + } + return sc +} + +ClassMethod YAML( + TestIndex = {$order(^UnitTest.Result(""),-1)}, + TestStatus As %String = "") As %Stream.TmpCharacter +{ + if TestStatus'="" { + set tResult = ..FilteredTestResultsFunc(TestIndex, TestStatus) + } else { + set tResult = ..GetAllTestResultsFunc(TestIndex) + } + set yamlStream = ##class(%Stream.TmpCharacter).%New() + set (yaml,currentID,currentSuite,currentTestcase) = "" + while tResult.%Next() { + if currentID = "" { + set currentID = TestIndex + do yamlStream.WriteLine("unitTest:") + do yamlStream.WriteLine(" id: "_TestIndex) + do yamlStream.WriteLine(" namespace: """_tResult.namespace_"""") + do yamlStream.WriteLine(" duration: "_tResult.duration) + do yamlStream.WriteLine(" testDateTime: """_tResult.testDateTime_"""") + do yamlStream.WriteLine() + do yamlStream.WriteLine(" results:") + } + if tResult.suiteName '= currentSuite { + set currentSuite = tResult.suiteName + set currentTestcase = "" + do yamlStream.WriteLine(" - suiteName: """_tResult.suiteName_"""") + do yamlStream.WriteLine(" testcases:") + } + if tResult.testcaseName '= currentTestcase { + set currentTestcase = tResult.testcaseName + do yamlStream.WriteLine(" - testcaseName: """_tResult.testcaseName_"""") + do yamlStream.WriteLine(" methods:") + } + do yamlStream.WriteLine(" - methodName: """_tResult.methodName_"""") + do yamlStream.WriteLine(" testMethod: """_tResult.testMethod_"""") + do yamlStream.WriteLine(" assertAction: """_tResult.assertAction_"""") + do yamlStream.WriteLine(" assertCounter: "_tResult.assertCounter) + do yamlStream.WriteLine(" assertDescription: |") + do yamlStream.WriteLine(" "_$replace(tResult.assertDescription, $char(10), $char(10)_" ")) + do yamlStream.WriteLine(" assertLocation: """_tResult.assertLocation_"""") + } + return yamlStream +} + +} diff --git a/tests/unit_tests/Test/PM/Unit/CLI.cls b/tests/unit_tests/Test/PM/Unit/CLI.cls index 8e9f2329a..797ce4541 100644 --- a/tests/unit_tests/Test/PM/Unit/CLI.cls +++ b/tests/unit_tests/Test/PM/Unit/CLI.cls @@ -352,4 +352,16 @@ Method TestUninstallWithoutModuleName() do $$$AssertNotTrue(exists, "Module removed successfully.") } +/// Specifies the serialization format (json, toon, yaml) for unit and integration test results in the shell. +Method TestReportFormatConfiguration() +{ + do ..RunCommand("config set TestReportFormat json") + set format = ##class(%IPM.Repo.UniversalSettings).GetTestReportFormat() + do $$$AssertEquals(format, "json", "Verify TestReportFormat is set to JSON") + + do ..RunCommand("config set TestReportFormat yaml") + set format = ##class(%IPM.Repo.UniversalSettings).GetTestReportFormat() + do $$$AssertEquals(format, "yaml", "Verify TestReportFormat is set to YAML") +} + } diff --git a/tests/unit_tests/Test/PM/Unit/TestResultsOPFormatAndFileGenTest.cls b/tests/unit_tests/Test/PM/Unit/TestResultsOPFormatAndFileGenTest.cls new file mode 100644 index 000000000..354eaa0d3 --- /dev/null +++ b/tests/unit_tests/Test/PM/Unit/TestResultsOPFormatAndFileGenTest.cls @@ -0,0 +1,59 @@ +/// Unit Test Class to validate the ZPM 'test' command output configuration. +/// This class ensures two primary functions work correctly: +/// 1. Console Formatting: Verifies the `-f` / `-output` flags correctly +/// format test results for terminal display (e.g., YAML, JSON). +/// 2. File Generation: Verifies the `-DUnitTest.Output` definitions +/// successfully create and populate the structured results files (e.g., .json, .yaml, .toon). +Class Test.PM.Unit.TestResultsOPFormatAndFileGenTest Extends %UnitTest.TestCase +{ + +/// generate .yaml,.json,.toon files +Method TestResultFileGeneration() +{ + #define NormalizeFilename(%file) ##class(%File).NormalizeFilename(%file) + + do $$$LogMessage("This file generation picks the last or current unit test id and generate the reports") + + set fileDir = ##class(%File).NormalizeDirectory($get(^UnitTestRoot)_"/test-reports/") + if '##class(%File).DirectoryExists(fileDir){ + set status = ##class(%File).CreateDirectoryChain(fileDir) + do $$$AssertStatusOK(status,"Directory created: "_fileDir) + } + + do $$$LogMessage("Start generating the reports") + set fileName = $$$NormalizeFilename(fileDir_"/test.yaml") + set status = ##class(%IPM.Test.YamlOutput).ToFile(fileName) + do $$$AssertStatusOK(status,"yaml file generated successfully in "_fileDir) + + set fileName = $$$NormalizeFilename(fileDir_"/test.Json") + set status = ##class(%IPM.Test.JsonOutput).ToFile(fileName) + do $$$AssertStatusOK(status,"Json file generated successfully in "_fileDir) + + set fileName = $$$NormalizeFilename(fileDir_"/test.toon") + set status = ##class(%IPM.Test.ToonOutput).ToFile(fileName) + do $$$AssertStatusOK(status,"Toon file generated successfully in "_fileDir) + + do ..ShowGeneratedFilesAndCleaup(fileDir) +} + +Method ShowGeneratedFilesAndCleaup(FileDir As %String) +{ + do $$$LogMessage("Display the generated unit test report files") + set fileSet = ##class(%File).FileSetFunc(FileDir) + while fileSet.%Next() { + set file = fileSet.Name + set fileNames(file) = fileSet.ItemName + do $$$LogMessage("Generated file "_file) + } + do $$$LogMessage("Started Cleanup the generated unit test report files") + set file = "" + for { + set file = $order(fileNames(file),1,fileName) + quit:file="" + set status = ##class(%File).Delete(file) + do $$$AssertStatusOK(status," File '"_fileName_"' has been deleted successfully") + } + do $$$LogMessage("File cleanup completed") +} + +}