diff --git a/.github/workflows/terraform-tests.yml b/.github/workflows/terraform-tests.yml index 4f7dd18c..b81a9576 100644 --- a/.github/workflows/terraform-tests.yml +++ b/.github/workflows/terraform-tests.yml @@ -60,71 +60,7 @@ jobs: - name: Convert test results to JUnit XML if: always() && steps.terraform-tests.outcome != 'skipped' - run: | - $jsonPath = 'logs/terraform-test-results.json' - $xmlPath = 'logs/terraform-test-results.xml' - - if (-not (Test-Path $jsonPath)) { - Write-Warning "Test results not found at $jsonPath" - return - } - - $results = Get-Content $jsonPath -Raw | ConvertFrom-Json - $totalTests = $results.summary.total_passed + $results.summary.total_failed + $results.summary.total_errors - - $xml = [System.Xml.XmlDocument]::new() - $declaration = $xml.CreateXmlDeclaration('1.0', 'UTF-8', $null) - $xml.AppendChild($declaration) | Out-Null - - $testsuites = $xml.CreateElement('testsuites') - $testsuites.SetAttribute('name', 'Terraform Tests') - $testsuites.SetAttribute('tests', $totalTests) - $testsuites.SetAttribute('failures', $results.summary.total_failed) - $testsuites.SetAttribute('errors', $results.summary.total_errors) - $xml.AppendChild($testsuites) | Out-Null - - foreach ($module in $results.modules) { - $moduleTests = $module.passed + $module.failed + $module.errors - $testsuite = $xml.CreateElement('testsuite') - $testsuite.SetAttribute('name', $module.path) - $testsuite.SetAttribute('tests', $moduleTests) - $testsuite.SetAttribute('failures', $module.failed) - $testsuite.SetAttribute('errors', $module.errors) - $testsuites.AppendChild($testsuite) | Out-Null - - # Emit one testcase per passed test - for ($i = 1; $i -le $module.passed; $i++) { - $testcase = $xml.CreateElement('testcase') - $testcase.SetAttribute('classname', $module.path) - $testcase.SetAttribute('name', "test_$i") - $testsuite.AppendChild($testcase) | Out-Null - } - - # Emit one testcase per failed test - for ($i = 1; $i -le $module.failed; $i++) { - $testcase = $xml.CreateElement('testcase') - $testcase.SetAttribute('classname', $module.path) - $testcase.SetAttribute('name', "failed_$i") - $failure = $xml.CreateElement('failure') - $failure.SetAttribute('message', "Test failed in $($module.path)") - $testcase.AppendChild($failure) | Out-Null - $testsuite.AppendChild($testcase) | Out-Null - } - - # Emit one testcase per errored test - for ($i = 1; $i -le $module.errors; $i++) { - $testcase = $xml.CreateElement('testcase') - $testcase.SetAttribute('classname', $module.path) - $testcase.SetAttribute('name', "error_$i") - $errorEl = $xml.CreateElement('error') - $errorEl.SetAttribute('message', "Test errored in $($module.path)") - $testcase.AppendChild($errorEl) | Out-Null - $testsuite.AppendChild($testcase) | Out-Null - } - } - - $xml.Save($xmlPath) - Write-Host "JUnit XML written to $xmlPath" + run: shared/ci/linting/ConvertTo-JUnitXml.ps1 - name: Upload Terraform test results if: always() && steps.terraform-tests.outcome != 'skipped' @@ -141,7 +77,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: use_oidc: true - report-type: test_results + report_type: test_results files: logs/terraform-test-results.xml flags: terraform name: terraform-test-results diff --git a/codecov.yml b/codecov.yml index 830305c7..c1cbf19c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -3,6 +3,7 @@ # Flags: # pester — PowerShell tests covering scripts/ # pytest — Python tests covering src/ +# terraform — Terraform tests covering infrastructure/terraform/ codecov: notify: @@ -50,6 +51,10 @@ flags: paths: - data-management/viewer/backend/src/ carryforward: true + terraform: + paths: + - infrastructure/terraform/ + carryforward: true parsers: jacoco: diff --git a/infrastructure/terraform/tests/outputs.tftest.hcl b/infrastructure/terraform/tests/outputs.tftest.hcl index 680aa2a4..a342db4c 100644 --- a/infrastructure/terraform/tests/outputs.tftest.hcl +++ b/infrastructure/terraform/tests/outputs.tftest.hcl @@ -126,3 +126,4 @@ run "optional_outputs_null_when_disabled" { error_message = "aml_compute_cluster output should be null when AML compute is disabled" } } + diff --git a/shared/ci/linting/ConvertTo-JUnitXml.ps1 b/shared/ci/linting/ConvertTo-JUnitXml.ps1 new file mode 100644 index 00000000..d6daf0cb --- /dev/null +++ b/shared/ci/linting/ConvertTo-JUnitXml.ps1 @@ -0,0 +1,156 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +#Requires -Version 7.0 + +<# +.SYNOPSIS + Converts Terraform test JSON results to JUnit XML format. +.DESCRIPTION + Reads JSON output from Invoke-TerraformTest.ps1 and produces a JUnit XML + file compatible with Codecov Test Analytics. Includes required attributes + (time, skipped, timestamp) on all elements and uses real test names from + the test_runs array when available. +.PARAMETER InputPath + Path to the JSON results file. Defaults to logs/terraform-test-results.json. +.PARAMETER OutputPath + Path for the JUnit XML output. Defaults to logs/terraform-test-results.xml. +#> + +[CmdletBinding()] +param( + [string]$InputPath, + [string]$OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot "../../../scripts/lib/Modules/CIHelpers.psm1") -Force + +function ConvertTo-JUnitXmlCore { + [CmdletBinding()] + param( + [string]$InputPath, + [string]$OutputPath + ) + + $repoRoot = & git rev-parse --show-toplevel 2>$null + if (-not $repoRoot) { + $repoRoot = (Get-Item $PSScriptRoot).Parent.Parent.Parent.FullName + } + + if (-not $InputPath) { $InputPath = Join-Path $repoRoot 'logs/terraform-test-results.json' } + if (-not $OutputPath) { $OutputPath = Join-Path $repoRoot 'logs/terraform-test-results.xml' } + + if (-not (Test-Path $InputPath)) { + Write-Warning "Test results not found at $InputPath" + Write-CIAnnotation -Level Warning -Message "Test results not found at $InputPath" + return 1 + } + + $results = Get-Content $InputPath -Raw | ConvertFrom-Json + $totalTests = $results.summary.total_passed + $results.summary.total_failed + $results.summary.total_errors + $timestamp = ([datetime]::Parse($results.timestamp)).ToString('yyyy-MM-ddTHH:mm:ss') + + $xml = [System.Xml.XmlDocument]::new() + $declaration = $xml.CreateXmlDeclaration('1.0', 'UTF-8', $null) + $xml.AppendChild($declaration) | Out-Null + + $testsuites = $xml.CreateElement('testsuites') + $testsuites.SetAttribute('name', 'Terraform Tests') + $testsuites.SetAttribute('tests', $totalTests) + $testsuites.SetAttribute('failures', $results.summary.total_failed) + $testsuites.SetAttribute('errors', $results.summary.total_errors) + $testsuites.SetAttribute('time', '0') + $xml.AppendChild($testsuites) | Out-Null + + foreach ($module in $results.modules) { + $moduleTests = $module.passed + $module.failed + $module.errors + $testsuite = $xml.CreateElement('testsuite') + $testsuite.SetAttribute('name', $module.path) + $testsuite.SetAttribute('tests', $moduleTests) + $testsuite.SetAttribute('failures', $module.failed) + $testsuite.SetAttribute('errors', $module.errors) + $testsuite.SetAttribute('skipped', '0') + $testsuite.SetAttribute('timestamp', $timestamp) + $testsuite.SetAttribute('time', '0') + $testsuites.AppendChild($testsuite) | Out-Null + + $hasTestRuns = ($null -ne $module.PSObject.Properties['test_runs']) -and ($module.test_runs.Count -gt 0) + if ($hasTestRuns) { + foreach ($run in $module.test_runs) { + $testcase = $xml.CreateElement('testcase') + $testcase.SetAttribute('classname', $module.path) + $testcase.SetAttribute('name', $run.name) + $testcase.SetAttribute('time', '0') + + if ($run.status -eq 'fail') { + $failure = $xml.CreateElement('failure') + $failure.SetAttribute('message', "Test failed: $($run.name)") + $testcase.AppendChild($failure) | Out-Null + } + elseif ($run.status -eq 'error') { + $errorEl = $xml.CreateElement('error') + $errorEl.SetAttribute('message', "Test error: $($run.name)") + $testcase.AppendChild($errorEl) | Out-Null + } + + $testsuite.AppendChild($testcase) | Out-Null + } + } + else { + for ($i = 1; $i -le $module.passed; $i++) { + $testcase = $xml.CreateElement('testcase') + $testcase.SetAttribute('classname', $module.path) + $testcase.SetAttribute('name', "test_$i") + $testcase.SetAttribute('time', '0') + $testsuite.AppendChild($testcase) | Out-Null + } + for ($i = 1; $i -le $module.failed; $i++) { + $testcase = $xml.CreateElement('testcase') + $testcase.SetAttribute('classname', $module.path) + $testcase.SetAttribute('name', "failed_$i") + $testcase.SetAttribute('time', '0') + $failure = $xml.CreateElement('failure') + $failure.SetAttribute('message', "Test failed in $($module.path)") + $testcase.AppendChild($failure) | Out-Null + $testsuite.AppendChild($testcase) | Out-Null + } + for ($i = 1; $i -le $module.errors; $i++) { + $testcase = $xml.CreateElement('testcase') + $testcase.SetAttribute('classname', $module.path) + $testcase.SetAttribute('name', "error_$i") + $testcase.SetAttribute('time', '0') + $errorEl = $xml.CreateElement('error') + $errorEl.SetAttribute('message', "Test errored in $($module.path)") + $testcase.AppendChild($errorEl) | Out-Null + $testsuite.AppendChild($testcase) | Out-Null + } + } + } + + $outputDir = Split-Path $OutputPath -Parent + if ($outputDir -and -not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + } + + $xml.Save($OutputPath) + Write-Host "JUnit XML written to $OutputPath" + return 0 +} + +#region Main Execution +if ($MyInvocation.InvocationName -ne '.') { + try { + $exitCode = ConvertTo-JUnitXmlCore @PSBoundParameters + exit $exitCode + } + catch { + Write-Error -ErrorAction Continue "ConvertTo-JUnitXml failed: $($_.Exception.Message)" + Write-CIAnnotation -Level Error -Message $_.Exception.Message + exit 1 + } +} +#endregion Main Execution diff --git a/shared/ci/linting/Invoke-TerraformTest.ps1 b/shared/ci/linting/Invoke-TerraformTest.ps1 index 6d5ecc76..43483bb3 100644 --- a/shared/ci/linting/Invoke-TerraformTest.ps1 +++ b/shared/ci/linting/Invoke-TerraformTest.ps1 @@ -156,11 +156,12 @@ function Invoke-TerraformTestCore { if ($LASTEXITCODE -ne 0) { $totalErrors++ $moduleResults += @{ - path = $displayPath - passed = 0 - failed = 0 - errors = 1 - skipped = $false + path = $displayPath + passed = 0 + failed = 0 + errors = 1 + skipped = $false + test_runs = @() } Write-CIAnnotation -Level Error -Message "terraform init failed in $displayPath" continue @@ -171,6 +172,7 @@ function Invoke-TerraformTestCore { $modulePassed = 0 $moduleFailed = 0 $moduleErrors = 0 + $moduleTestRuns = @() foreach ($line in $testOutput) { $lineStr = $line.ToString().Trim() @@ -185,17 +187,18 @@ function Invoke-TerraformTestCore { if ($jsonObj.type -eq 'test_run' -and $jsonObj.test_run.progress -eq 'complete') { $status = $jsonObj.test_run.status + $testName = "$($jsonObj.test_run.run)" + $moduleTestRuns += @{ name = $testName; status = $status } + if ($status -eq 'pass') { $modulePassed++ } elseif ($status -eq 'fail') { $moduleFailed++ - $testName = "$($jsonObj.test_run.run)" Write-CIAnnotation -Level Error -Message "Test failed in ${displayPath}: $testName" } elseif ($status -eq 'error') { $moduleErrors++ - $testName = "$($jsonObj.test_run.run)" Write-CIAnnotation -Level Error -Message "Test error in ${displayPath}: $testName" } } @@ -206,11 +209,12 @@ function Invoke-TerraformTestCore { $totalErrors += $moduleErrors $moduleResults += @{ - path = $displayPath - passed = $modulePassed - failed = $moduleFailed - errors = $moduleErrors - skipped = $false + path = $displayPath + passed = $modulePassed + failed = $moduleFailed + errors = $moduleErrors + skipped = $false + test_runs = @($moduleTestRuns) } } finally { @@ -227,11 +231,12 @@ function Invoke-TerraformTestCore { terraform_version = $versionString modules = @($moduleResults | ForEach-Object { @{ - path = $_.path - passed = $_.passed - failed = $_.failed - errors = $_.errors - skipped = if ($_.skipped) { $true } else { $false } + path = $_.path + passed = $_.passed + failed = $_.failed + errors = $_.errors + skipped = if ($_.skipped) { $true } else { $false } + test_runs = @($_.test_runs) } }) summary = @{ diff --git a/shared/ci/tests/linting/ConvertTo-JUnitXml.Tests.ps1 b/shared/ci/tests/linting/ConvertTo-JUnitXml.Tests.ps1 new file mode 100644 index 00000000..cc43cdd7 --- /dev/null +++ b/shared/ci/tests/linting/ConvertTo-JUnitXml.Tests.ps1 @@ -0,0 +1,411 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +BeforeAll { + . $PSScriptRoot/../../linting/ConvertTo-JUnitXml.ps1 + $ErrorActionPreference = 'Continue' + Import-Module (Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1') -Force + + function New-TestResults { + param( + [array]$Modules = @(), + [int]$TotalPassed = 0, + [int]$TotalFailed = 0, + [int]$TotalErrors = 0 + ) + return @{ + timestamp = '2026-03-19T12:00:00Z' + terraform_version = '1.14.7' + modules = $Modules + summary = @{ + modules_tested = $Modules.Count + modules_passed = @($Modules | Where-Object { $_.failed -eq 0 -and $_.errors -eq 0 }).Count + modules_skipped = 0 + total_passed = $TotalPassed + total_failed = $TotalFailed + total_errors = $TotalErrors + overall_passed = ($TotalFailed -eq 0 -and $TotalErrors -eq 0) + } + } | ConvertTo-Json -Depth 5 + } +} + +Describe 'ConvertTo-JUnitXmlCore' -Tag 'Unit' { + BeforeAll { Save-CIEnvironment } + AfterAll { Restore-CIEnvironment } + + BeforeEach { + $script:MockFiles = Initialize-MockCIEnvironment -Workspace $TestDrive + $script:TestInputPath = Join-Path $TestDrive 'logs/terraform-test-results.json' + $script:TestOutputPath = Join-Path $TestDrive 'logs/terraform-test-results.xml' + + New-Item -ItemType Directory -Force -Path (Join-Path $TestDrive 'logs') | Out-Null + + Mock git { return $TestDrive } -ParameterFilter { $args[0] -eq 'rev-parse' } + Mock Write-CIAnnotation {} + } + + AfterEach { + Restore-CIEnvironment + Remove-MockCIFiles -MockFiles $script:MockFiles + } + + Context 'input validation' { + It 'Returns 1 when input file does not exist' { + $result = ConvertTo-JUnitXmlCore -InputPath (Join-Path $TestDrive 'nonexistent.json') ` + -OutputPath $script:TestOutputPath + $result | Should -Be 1 + } + + It 'Writes warning annotation when input file missing' { + ConvertTo-JUnitXmlCore -InputPath (Join-Path $TestDrive 'nonexistent.json') ` + -OutputPath $script:TestOutputPath + Should -Invoke Write-CIAnnotation -Times 1 -ParameterFilter { + $Level -eq 'Warning' + } + } + + It 'Does not create output file when input missing' { + ConvertTo-JUnitXmlCore -InputPath (Join-Path $TestDrive 'nonexistent.json') ` + -OutputPath $script:TestOutputPath + $script:TestOutputPath | Should -Not -Exist + } + } + + Context 'empty results' { + BeforeEach { + New-TestResults | Set-Content $script:TestInputPath + } + + It 'Returns 0 with zero modules' { + $result = ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + $result | Should -Be 0 + } + + It 'Creates valid XML with tests="0"' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + $script:TestOutputPath | Should -Exist + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.tests | Should -Be '0' + $xml.testsuites.failures | Should -Be '0' + $xml.testsuites.errors | Should -Be '0' + } + } + + Context 'passing tests with test_runs' { + BeforeEach { + $module = @{ + path = 'modules/network' + passed = 3 + failed = 0 + errors = 0 + skipped = $false + test_runs = @( + @{ name = 'naming_convention'; status = 'pass' } + @{ name = 'subnet_allocation'; status = 'pass' } + @{ name = 'nsg_rules'; status = 'pass' } + ) + } + New-TestResults -Modules @($module) -TotalPassed 3 | Set-Content $script:TestInputPath + } + + It 'Returns 0' { + $result = ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + $result | Should -Be 0 + } + + It 'Creates testcase elements with real names' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $testcases = $xml.testsuites.testsuite.testcase + $testcases.Count | Should -Be 3 + $testcases[0].name | Should -Be 'naming_convention' + $testcases[1].name | Should -Be 'subnet_allocation' + $testcases[2].name | Should -Be 'nsg_rules' + } + + It 'Sets classname to module path' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.testsuite.testcase[0].classname | Should -Be 'modules/network' + } + + It 'Sets time attribute on testcase elements' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.testsuite.testcase[0].time | Should -Be '0' + } + } + + Context 'failing tests with test_runs' { + BeforeEach { + $module = @{ + path = 'modules/storage' + passed = 1 + failed = 1 + errors = 0 + skipped = $false + test_runs = @( + @{ name = 'valid_config'; status = 'pass' } + @{ name = 'encryption_check'; status = 'fail' } + ) + } + New-TestResults -Modules @($module) -TotalPassed 1 -TotalFailed 1 | + Set-Content $script:TestInputPath + } + + It 'Adds failure element to failed testcase' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $failedCase = $xml.testsuites.testsuite.testcase | Where-Object { $_.name -eq 'encryption_check' } + $failedCase.failure | Should -Not -BeNullOrEmpty + $failedCase.failure.message | Should -Be 'Test failed: encryption_check' + } + + It 'Does not add failure to passing testcase' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $passedCase = $xml.testsuites.testsuite.testcase | Where-Object { $_.name -eq 'valid_config' } + $passedCase.ChildNodes | Where-Object { $_.Name -eq 'failure' } | Should -BeNullOrEmpty + } + } + + Context 'error tests with test_runs' { + BeforeEach { + $module = @{ + path = 'modules/identity' + passed = 0 + failed = 0 + errors = 1 + skipped = $false + test_runs = @( + @{ name = 'role_assignment'; status = 'error' } + ) + } + New-TestResults -Modules @($module) -TotalErrors 1 | Set-Content $script:TestInputPath + } + + It 'Adds error element to errored testcase' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $errorCase = $xml.testsuites.testsuite.testcase | Where-Object { $_.name -eq 'role_assignment' } + $errorCase.error | Should -Not -BeNullOrEmpty + $errorCase.error.message | Should -Be 'Test error: role_assignment' + } + } + + Context 'fallback naming without test_runs' { + BeforeEach { + $module = @{ + path = 'modules/compute' + passed = 2 + failed = 1 + errors = 1 + skipped = $false + } + New-TestResults -Modules @($module) -TotalPassed 2 -TotalFailed 1 -TotalErrors 1 | + Set-Content $script:TestInputPath + } + + It 'Uses test_N naming for passed tests' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $testcases = $xml.testsuites.testsuite.testcase + ($testcases | Where-Object { $_.name -eq 'test_1' }) | Should -Not -BeNullOrEmpty + ($testcases | Where-Object { $_.name -eq 'test_2' }) | Should -Not -BeNullOrEmpty + } + + It 'Uses failed_N naming with failure element' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $failedCase = $xml.testsuites.testsuite.testcase | Where-Object { $_.name -eq 'failed_1' } + $failedCase | Should -Not -BeNullOrEmpty + $failedCase.failure | Should -Not -BeNullOrEmpty + } + + It 'Uses error_N naming with error element' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $errorCase = $xml.testsuites.testsuite.testcase | Where-Object { $_.name -eq 'error_1' } + $errorCase | Should -Not -BeNullOrEmpty + $errorCase.error | Should -Not -BeNullOrEmpty + } + + It 'Creates correct total testcase count' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.testsuite.testcase.Count | Should -Be 4 + } + } + + Context 'XML structure and attributes' { + BeforeEach { + $module = @{ + path = 'modules/aks' + passed = 2 + failed = 0 + errors = 0 + skipped = $false + test_runs = @( + @{ name = 'cluster_config'; status = 'pass' } + @{ name = 'node_pool'; status = 'pass' } + ) + } + New-TestResults -Modules @($module) -TotalPassed 2 | Set-Content $script:TestInputPath + } + + It 'Sets time attribute on testsuites element' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.time | Should -Be '0' + } + + It 'Sets testsuites name to Terraform Tests' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.name | Should -Be 'Terraform Tests' + } + + It 'Sets skipped attribute on testsuite element' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.testsuite.skipped | Should -Be '0' + } + + It 'Sets timestamp attribute on testsuite element' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.testsuite.timestamp | Should -Be '2026-03-19T12:00:00' + } + + It 'Sets time attribute on testsuite element' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.testsuite.time | Should -Be '0' + } + + It 'Sets testsuite name to module path' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.testsuite.name | Should -Be 'modules/aks' + } + } + + Context 'multiple modules' { + BeforeEach { + $modules = @( + @{ + path = 'modules/network' + passed = 2 + failed = 0 + errors = 0 + skipped = $false + test_runs = @( + @{ name = 'vnet_config'; status = 'pass' } + @{ name = 'subnet_config'; status = 'pass' } + ) + } + @{ + path = 'modules/storage' + passed = 1 + failed = 1 + errors = 0 + skipped = $false + test_runs = @( + @{ name = 'account_setup'; status = 'pass' } + @{ name = 'access_policy'; status = 'fail' } + ) + } + ) + New-TestResults -Modules $modules -TotalPassed 3 -TotalFailed 1 | + Set-Content $script:TestInputPath + } + + It 'Creates one testsuite per module' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.testsuite.Count | Should -Be 2 + } + + It 'Sets correct totals on testsuites element' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $xml.testsuites.tests | Should -Be '4' + $xml.testsuites.failures | Should -Be '1' + } + + It 'Sets correct counts per testsuite' { + ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath ` + -OutputPath $script:TestOutputPath + [xml]$xml = Get-Content $script:TestOutputPath -Raw + $networkSuite = $xml.testsuites.testsuite | Where-Object { $_.name -eq 'modules/network' } + $networkSuite.tests | Should -Be '2' + $networkSuite.failures | Should -Be '0' + } + } + + Context 'output directory creation' { + It 'Creates output directory when it does not exist' { + $nestedOutput = Join-Path $TestDrive 'nested/dir/results.xml' + $module = @{ + path = 'modules/test' + passed = 1 + failed = 0 + errors = 0 + skipped = $false + test_runs = @(@{ name = 'basic'; status = 'pass' }) + } + New-TestResults -Modules @($module) -TotalPassed 1 | Set-Content $script:TestInputPath + + $result = ConvertTo-JUnitXmlCore -InputPath $script:TestInputPath -OutputPath $nestedOutput + $result | Should -Be 0 + $nestedOutput | Should -Exist + } + } + + Context 'default paths' { + It 'Uses default paths when parameters not specified' { + $defaultInput = Join-Path $TestDrive 'logs/terraform-test-results.json' + $defaultOutput = Join-Path $TestDrive 'logs/terraform-test-results.xml' + New-Item -ItemType Directory -Force -Path (Join-Path $TestDrive 'logs') | Out-Null + + $module = @{ + path = 'modules/test' + passed = 1 + failed = 0 + errors = 0 + skipped = $false + test_runs = @(@{ name = 'default_test'; status = 'pass' }) + } + New-TestResults -Modules @($module) -TotalPassed 1 | Set-Content $defaultInput + + $result = ConvertTo-JUnitXmlCore + $result | Should -Be 0 + $defaultOutput | Should -Exist + } + } +}