tools/Build-PrivateUnitTests.ps1
|
<# .SYNOPSIS Generate Pester 3 contract tests for every private GitEasy helper. .DESCRIPTION Writes one Tests\Unit\<HelperName>.Tests.ps1 file per private helper. Because private helpers are not exported from the module, the generated tests use AST parsing of the helper's source file rather than Get-Command -Module. Each test asserts the helper's contract: the source file declares a function with the matching name, the function has [CmdletBinding()], the parameters are declared, switch parameters are typed correctly, validate-set values are present, and the function ships comment-based help (.SYNOPSIS at minimum). The generated files are deterministic - re-running this script regenerates them in place. Hand edits will be overwritten. .PARAMETER ProjectRoot Absolute path to the GitEasy source repository. Defaults to C:\Sysadmin\Scripts\GitEasy. .EXAMPLE .\tools\Build-PrivateUnitTests.ps1 #> [CmdletBinding()] param( [string]$ProjectRoot = 'C:\Sysadmin\Scripts\GitEasy' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $privateRoot = Join-Path $ProjectRoot 'Private' $unitRoot = Join-Path $ProjectRoot 'Tests\Unit' if (-not (Test-Path -LiteralPath $privateRoot -PathType Container)) { throw "Missing Private folder: $privateRoot" } if (-not (Test-Path -LiteralPath $unitRoot -PathType Container)) { New-Item -Path $unitRoot -ItemType Directory -Force | Out-Null } $privateFiles = @(Get-ChildItem -LiteralPath $privateRoot -Filter '*.ps1' -File | Sort-Object Name) $generated = 0 foreach ($file in $privateFiles) { $helperName = $file.BaseName $tokens = $null; $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$errors) $fnAst = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) | Select-Object -First 1 if (-not $fnAst) { Write-Warning "Skipping $helperName - no function definition found." continue } $declaredParams = @() if ($fnAst.Body.ParamBlock -and $fnAst.Body.ParamBlock.Parameters) { foreach ($p in $fnAst.Body.ParamBlock.Parameters) { $name = $p.Name.VariablePath.UserPath $type = if ($p.StaticType) { $p.StaticType.Name } else { '' } $isMandatory = $false $validateSet = @() foreach ($attr in $p.Attributes) { if ($attr -is [System.Management.Automation.Language.AttributeAst]) { if ($attr.TypeName.Name -eq 'Parameter') { foreach ($na in $attr.NamedArguments) { if ($na.ArgumentName -eq 'Mandatory') { $isMandatory = $true } } } if ($attr.TypeName.Name -eq 'ValidateSet') { foreach ($pa in $attr.PositionalArguments) { if ($pa -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $validateSet += $pa.Value } } } } } $declaredParams += [PSCustomObject]@{ Name = $name Type = $type Mandatory = $isMandatory IsSwitch = ($type -eq 'SwitchParameter') ValidateSet = $validateSet } } } $hasCmdletBinding = $false if ($fnAst.Body.ParamBlock -and $fnAst.Body.ParamBlock.Attributes) { foreach ($attr in $fnAst.Body.ParamBlock.Attributes) { if ($attr -is [System.Management.Automation.Language.AttributeAst] -and $attr.TypeName.Name -eq 'CmdletBinding') { $hasCmdletBinding = $true } } } $relativePath = ('Private\' + $file.Name) $lines = New-Object System.Collections.Generic.List[string] $lines.Add('# Generated by tools\Build-PrivateUnitTests.ps1. Re-run to regenerate.') $lines.Add('') $lines.Add('$ProjectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)') $lines.Add('$RelativePath = ''' + $relativePath + '''') $lines.Add('$SourcePath = Join-Path $ProjectRoot $RelativePath') $lines.Add('') $lines.Add("Describe '$helperName (private contract)' {") $lines.Add('') $lines.Add(' BeforeAll {') $lines.Add(' $script:Ast = $null') $lines.Add(' $script:Fn = $null') $lines.Add(' if (Test-Path -LiteralPath $SourcePath -PathType Leaf) {') $lines.Add(' $tokens = $null; $errors = $null') $lines.Add(' $script:Ast = [System.Management.Automation.Language.Parser]::ParseFile($SourcePath, [ref]$tokens, [ref]$errors)') $lines.Add(' $script:Fn = @($script:Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) | Select-Object -First 1') $lines.Add(' }') $lines.Add(' }') $lines.Add('') $lines.Add(" It 'source file exists at the expected location' {") $lines.Add(' Test-Path -LiteralPath $SourcePath -PathType Leaf | Should Be $true') $lines.Add(' }') $lines.Add('') $lines.Add(" It 'declares a function whose name matches the file name' {") $lines.Add(' $script:Fn.Name | Should Be ''' + $helperName + '''') $lines.Add(' }') $lines.Add('') $lines.Add(" It 'name uses the GE prefix convention' {") $lines.Add(' $script:Fn.Name | Should Match ''^[A-Za-z]+-GE[A-Za-z0-9]+$''') $lines.Add(' }') $lines.Add('') if ($hasCmdletBinding) { $lines.Add(" It 'declares [CmdletBinding()]' {") $lines.Add(' $hasBinding = $false') $lines.Add(' if ($script:Fn.Body.ParamBlock -and $script:Fn.Body.ParamBlock.Attributes) {') $lines.Add(' foreach ($attr in $script:Fn.Body.ParamBlock.Attributes) {') $lines.Add(' if ($attr.TypeName.Name -eq ''CmdletBinding'') { $hasBinding = $true }') $lines.Add(' }') $lines.Add(' }') $lines.Add(' $hasBinding | Should Be $true') $lines.Add(' }') $lines.Add('') } foreach ($p in $declaredParams) { $lines.Add(" It 'declares the -$($p.Name) parameter' {") $lines.Add(' $names = @()') $lines.Add(' if ($script:Fn.Body.ParamBlock -and $script:Fn.Body.ParamBlock.Parameters) {') $lines.Add(' $names = @($script:Fn.Body.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath })') $lines.Add(' }') $lines.Add(' $names -contains ''' + $p.Name + ''' | Should Be $true') $lines.Add(' }') $lines.Add('') if ($p.IsSwitch) { $lines.Add(" It '-$($p.Name) is a switch parameter' {") $lines.Add(' $param = $script:Fn.Body.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq ''' + $p.Name + ''' } | Select-Object -First 1') $lines.Add(' $param.StaticType.Name | Should Be ''SwitchParameter''') $lines.Add(' }') $lines.Add('') } if ($p.Mandatory) { $lines.Add(" It '-$($p.Name) is mandatory' {") $lines.Add(' $param = $script:Fn.Body.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq ''' + $p.Name + ''' } | Select-Object -First 1') $lines.Add(' $isMandatory = $false') $lines.Add(' foreach ($attr in $param.Attributes) {') $lines.Add(' if ($attr.TypeName.Name -eq ''Parameter'') {') $lines.Add(' foreach ($na in $attr.NamedArguments) {') $lines.Add(' if ($na.ArgumentName -eq ''Mandatory'') { $isMandatory = $true }') $lines.Add(' }') $lines.Add(' }') $lines.Add(' }') $lines.Add(' $isMandatory | Should Be $true') $lines.Add(' }') $lines.Add('') } if ($p.ValidateSet.Count -gt 0) { $lines.Add(" It '-$($p.Name) accepts only the documented values' {") $lines.Add(' $param = $script:Fn.Body.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq ''' + $p.Name + ''' } | Select-Object -First 1') $lines.Add(' $values = @()') $lines.Add(' foreach ($attr in $param.Attributes) {') $lines.Add(' if ($attr.TypeName.Name -eq ''ValidateSet'') {') $lines.Add(' foreach ($pa in $attr.PositionalArguments) {') $lines.Add(' if ($pa.Value) { $values += $pa.Value }') $lines.Add(' }') $lines.Add(' }') $lines.Add(' }') foreach ($v in $p.ValidateSet) { $lines.Add(' $values -contains ''' + $v + ''' | Should Be $true') } $lines.Add(' }') $lines.Add('') } } $lines.Add(" It 'has comment-based help with .SYNOPSIS' {") $lines.Add(' $body = Get-Content -LiteralPath $SourcePath -Raw') $lines.Add(' $hasCbh = ($body -match ''(?ms)<#.*?\.SYNOPSIS\s+\S.*?#>'')') $lines.Add(' $hasCbh | Should Be $true') $lines.Add(' }') $lines.Add('') $lines.Add('}') $body = ($lines.ToArray() -join "`r`n") + "`r`n" $outPath = Join-Path $unitRoot ($helperName + '.Tests.ps1') [System.IO.File]::WriteAllText($outPath, $body, [System.Text.UTF8Encoding]::new($false)) $generated++ } Write-Host '' Write-Host "Generated $generated private unit-test files in $unitRoot" -ForegroundColor Green |