Public/Tests/New-ModuleCustomTemplate.Tests.ps1

BeforeDiscovery {
    $HasScriptAnalyzer = [bool](Get-Module -ListAvailable -Name PSScriptAnalyzer)
}

BeforeAll {
    $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config'
    $env:TCS_SKIP_UPDATE_CHECK = '1'
    $env:TCS_TELEMETRY_OPTOUT = '1'
    $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
    Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.utils.psd1') -Force
}

AfterAll {
    Remove-Module -Name tcs.utils -Force -ErrorAction SilentlyContinue
}

Describe 'New-ModuleCustomTemplate' {
    BeforeAll {
        function Get-ParseErrorCount {
            param([string]$Path)
            $tokens = $null
            $errors = $null
            $null = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
            @($errors).Count
        }
        $SettingsPath = Join-Path -Path (Split-Path -Path (Split-Path -Path $ModuleRoot -Parent) -Parent) -ChildPath 'PSScriptAnalyzerSettings.psd1'
    }

    BeforeEach {
        $Target = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('N'))
        $null = New-Item -Path $Target -ItemType Directory
    }

    Context 'Generated files' {
        BeforeEach {
            $Output = New-ModuleCustomTemplate -Names 'my.module' -Path $Target -Author "O'Brien" -Description 'Test module' -RequiredModules 'Other.Module'
            $Generated = Join-Path -Path $Target -ChildPath 'my.module'
            $Psm1 = Join-Path -Path $Generated -ChildPath 'my.module.psm1'
            $Psd1 = Join-Path -Path $Generated -ChildPath 'my.module.psd1'
            $Config = Join-Path -Path $Generated -ChildPath 'Config.ps1'
            $Psm1Content = Get-Content -Path $Psm1 -Raw
        }

        It 'Writes nothing to the pipeline' {
            $Output | Should -BeNullOrEmpty
        }

        It 'Creates the folder structure and files' {
            foreach ($relative in @('Classes', 'Public', 'Private', 'Public/Tests', 'Private/Tests', 'README.md', '.gitignore', 'Config.ps1', 'my.module.psm1', 'my.module.psd1')) {
                Test-Path -Path (Join-Path -Path $Generated -ChildPath $relative) | Should -BeTrue -Because "$relative should exist"
            }
            Get-ChildItem -Path $Generated -Recurse | Where-Object { $_.Name -match '\\' } | Should -BeNullOrEmpty -Because 'names must not contain literal backslashes'
        }

        It 'Creates PowerShell files that parse without errors' {
            Get-ParseErrorCount -Path $Psm1 | Should -Be 0
            Get-ParseErrorCount -Path $Config | Should -Be 0
            Get-ParseErrorCount -Path $Psd1 | Should -Be 0
        }

        It 'Uses the current config, telemetry and update-check pattern in the psm1' {
            $Psm1Content | Should -Match ([regex]::Escape('$CurrentConfig = Get-ModuleConfig -CommandPath $PSCommandPath -ErrorAction Stop'))
            $Psm1Content | Should -Match ([regex]::Escape("-CommandName 'Import-Module' -ExecutionID ([guid]::NewGuid().ToString()) -Stage 'Module-Load'"))
            $Psm1Content | Should -Match ([regex]::Escape('$null = Get-ModuleStatus -ShowMessage'))
            $Psm1Content | Should -Match ([regex]::Escape('my.module configuration could not be loaded'))
            $Psm1Content | Should -Match 'Join-Path'
        }

        It 'Does not contain obsolete code' {
            foreach ($file in @($Psm1, $Config)) {
                $content = Get-Content -Path $file -Raw
                $content | Should -Not -Match 'NOTYETDEFINED'
                $content | Should -Not -Match 'TelmetryArgs'
                $content | Should -Not -Match 'Minimal'
                $content | Should -Not -Match 'BasicTelemetry'
                $content | Should -Not -Match '###TEMPLATE'
                $content | Should -Not -Match '\$PSScriptRoot\\'
            }
        }

        It 'Leaves Config.ps1 disabled unless -UncommentConfig is used' {
            $Psm1Content | Should -Match '(?m)^#\. \(Join-Path'
        }

        It 'Creates a Config.ps1 that never writes into the module folder' {
            $content = Get-Content -Path $Config -Raw
            $content | Should -Not -Match 'Out-File|Set-Content|New-Item|Add-Content'
            $content | Should -Match 'ModuleConfigPath'
        }

        It 'Creates a valid manifest that requires tcs.core 0.3.0 and lists no wildcard exports' {
            $manifest = Import-PowerShellDataFile -Path $Psd1
            $manifest.RootModule | Should -Be 'my.module.psm1'
            $manifest.ModuleVersion | Should -Be '0.1.0'
            $manifest.Author | Should -Be "O'Brien"
            $manifest.Description | Should -Be 'Test module'
            $manifest.PowerShellVersion | Should -Be '5.1'
            $manifest.CompatiblePSEditions | Should -Be @('Desktop', 'Core')
            $manifest.RequiredModules[0].ModuleName | Should -Be 'tcs.core'
            $manifest.RequiredModules[0].ModuleVersion | Should -Be '0.3.0'
            $manifest.RequiredModules[1] | Should -Be 'Other.Module'
            @($manifest.FunctionsToExport).Count | Should -Be 0
            $manifest.Keys | Should -Not -Contain 'NestedModules'
            { [guid]$manifest.GUID } | Should -Not -Throw
        }

        It 'Has no PSScriptAnalyzer findings with the repository settings' -Skip:(-not $HasScriptAnalyzer) {
            foreach ($file in @($Psm1, $Config, $Psd1)) {
                Invoke-ScriptAnalyzer -Path $file -Settings $SettingsPath | Should -BeNullOrEmpty -Because "$file should be clean"
            }
        }
    }

    Context 'Importing a generated module' {
        It 'Imports without errors and without writing into the module folder' {
            New-ModuleCustomTemplate -Names 'gen.module' -Path $Target -UncommentConfig
            $generated = Join-Path -Path $Target -ChildPath 'gen.module'
            $before = @(Get-ChildItem -Path $generated -Recurse -Force | ForEach-Object FullName | Sort-Object)
            $settingsFolder = Join-Path -Path $env:TCS_CONFIG_ROOT -ChildPath 'gen.module'
            $null = New-Item -Path $settingsFolder -ItemType Directory -Force
            Set-Content -Path (Join-Path -Path $settingsFolder -ChildPath 'Config.psd1') -Value "@{ Greeting = 'hello' }"
            try {
                $output = Import-Module -Name (Join-Path -Path $generated -ChildPath 'gen.module.psd1') -Force -PassThru -ErrorVariable importErrors -WarningVariable importWarnings
                $importErrors | Should -BeNullOrEmpty
                $importWarnings | Should -BeNullOrEmpty
                @($output).Count | Should -Be 1
                & $output { $Greeting } | Should -Be 'hello'
                $after = @(Get-ChildItem -Path $generated -Recurse -Force | ForEach-Object FullName | Sort-Object)
                $after | Should -Be $before
            }
            finally {
                Remove-Module -Name 'gen.module' -Force -ErrorAction SilentlyContinue
            }
        }
    }

    Context 'Loading functions from subfolders' {
        It 'Loads Public and Private scripts in subfolders but not tests' {
            New-ModuleCustomTemplate -Names 'deep.module' -Path $Target
            $generated = Join-Path -Path $Target -ChildPath 'deep.module'
            $publicDeep = Join-Path -Path $generated -ChildPath 'Public/Generated/Functions'
            $privateDeep = Join-Path -Path $generated -ChildPath 'Private/Generated'
            $null = New-Item -Path $publicDeep, $privateDeep -ItemType Directory -Force
            Set-Content -Path (Join-Path -Path $publicDeep -ChildPath 'Get-DeepThing.ps1') -Value 'function Get-DeepThing { Get-DeepHelper }'
            Set-Content -Path (Join-Path -Path $privateDeep -ChildPath 'Get-DeepHelper.ps1') -Value "function Get-DeepHelper { 'deep' }"
            Set-Content -Path (Join-Path -Path $generated -ChildPath 'Public/Tests/Get-DeepThing.Tests.ps1') -Value 'throw "tests must not be loaded"'
            Set-Content -Path (Join-Path -Path $publicDeep -ChildPath 'Get-DeepThing.Tests.ps1') -Value 'throw "tests must not be loaded"'
            $null = Update-ModuleManifestExports -ModulePath $generated
            try {
                $module = Import-Module -Name (Join-Path -Path $generated -ChildPath 'deep.module.psd1') -Force -PassThru -ErrorVariable importErrors
                $importErrors | Should -BeNullOrEmpty
                $module.ExportedFunctions.Keys | Should -Be @('Get-DeepThing')
                Get-DeepThing | Should -Be 'deep'
            }
            finally {
                Remove-Module -Name 'deep.module' -Force -ErrorAction SilentlyContinue
            }
        }
    }

    Context 'Repository files' {
        It 'Creates the tests, analyzer settings and CI workflow with -IncludeRepoFiles' {
            $modules = Join-Path -Path $Target -ChildPath 'modules'
            $null = New-Item -Path $modules -ItemType Directory
            New-ModuleCustomTemplate -Names 'repo.module' -Path $modules -RepoPath $Target -IncludeRepoFiles
            $tests = Join-Path -Path $Target -ChildPath 'tests/Module.Tests.ps1'
            $workflow = Join-Path -Path $Target -ChildPath '.github/workflows/ci-validate.yml'
            Test-Path -Path $tests | Should -BeTrue
            Test-Path -Path (Join-Path -Path $Target -ChildPath 'PSScriptAnalyzerSettings.psd1') | Should -BeTrue
            Get-ParseErrorCount -Path $tests | Should -Be 0
            Get-Content -Path $tests -Raw | Should -Match ([regex]::Escape("'modules/repo.module'"))
            $workflowText = Get-Content -Path $workflow -Raw
            $workflowText | Should -Match 'ntatschner/tcs-shared-workflows/.github/workflows/ci-validate.yml@main'
            $workflowText | Should -Match "module-path: 'modules/repo.module'"
            $workflowText | Should -Not -Match '###TEMPLATE'
            { Import-PowerShellDataFile -Path (Join-Path -Path $Target -ChildPath 'PSScriptAnalyzerSettings.psd1') } | Should -Not -Throw
        }

        It 'Does not create repository files without -IncludeRepoFiles' {
            New-ModuleCustomTemplate -Names 'plain.module' -Path $Target
            Test-Path -Path (Join-Path -Path $Target -ChildPath 'tests') | Should -BeFalse
            Test-Path -Path (Join-Path -Path $Target -ChildPath '.github') | Should -BeFalse
        }
    }

    Context 'Behaviour' {
        It 'Creates several modules from the pipeline' {
            'one.module', 'two.module' | New-ModuleCustomTemplate -Path $Target
            Test-Path -Path (Join-Path -Path $Target -ChildPath 'one.module/one.module.psd1') | Should -BeTrue
            Test-Path -Path (Join-Path -Path $Target -ChildPath 'two.module/two.module.psd1') | Should -BeTrue
        }

        It 'Keeps existing files unless -Force is used' {
            New-ModuleCustomTemplate -Names 'keep.module' -Path $Target
            $readme = Join-Path -Path $Target -ChildPath 'keep.module/README.md'
            Set-Content -Path $readme -Value 'my notes'
            New-ModuleCustomTemplate -Names 'keep.module' -Path $Target -WarningAction SilentlyContinue
            (Get-Content -Path $readme -Raw).Trim() | Should -Be 'my notes'
            New-ModuleCustomTemplate -Names 'keep.module' -Path $Target -Force
            (Get-Content -Path $readme -Raw) | Should -Match '# keep.module PowerShell Module'
        }

        It 'Creates nothing with -WhatIf' {
            New-ModuleCustomTemplate -Names 'whatif.module' -Path $Target -WhatIf
            Test-Path -Path (Join-Path -Path $Target -ChildPath 'whatif.module') | Should -BeFalse
        }

        It 'Sends telemetry Start and End once per call' {
            Mock -ModuleName tcs.utils Invoke-TelemetryCollection { }
            'a.module', 'b.module' | New-ModuleCustomTemplate -Path $Target
            Should -Invoke -ModuleName tcs.utils Invoke-TelemetryCollection -Times 1 -Exactly -ParameterFilter { $Stage -eq 'Start' }
            Should -Invoke -ModuleName tcs.utils Invoke-TelemetryCollection -Times 1 -Exactly -ParameterFilter { $Stage -eq 'End' -and -not $Failed }
        }
    }
}