diff --git a/resources/Help/Microsoft.VSCode.Dsc/VSCodeExtension.md b/resources/Help/Microsoft.VSCode.Dsc/VSCodeExtension.md index 4f5e62b1..c00c47fc 100644 --- a/resources/Help/Microsoft.VSCode.Dsc/VSCodeExtension.md +++ b/resources/Help/Microsoft.VSCode.Dsc/VSCodeExtension.md @@ -35,7 +35,7 @@ The `VSCodeExtension` DSC Resource allows you to install, update, and remove Vis $params = @{ Name = 'ms-python.python' } -Invoke-DscResource -Name VSCodeExtension -Method Set -Property $params -ModuleName Microsoft.VSCode.Dsc +Invoke-DscResource -ModuleName Microsoft.VSCode.Dsc -Name VSCodeExtension -Method Set -Property $params ``` ### EXAMPLE 2 @@ -46,7 +46,7 @@ $params = @{ Name = 'ms-python.python' Version = '2021.5.842923320' } -Invoke-DscResource -Name VSCodeExtension -Method Set -Property $params -ModuleName Microsoft.VSCode.Dsc +Invoke-DscResource -ModuleName Microsoft.VSCode.Dsc -Name VSCodeExtension -Method Set -Property $params ``` ### EXAMPLE 3 @@ -57,7 +57,7 @@ $params = @{ Name = 'ms-python.python' Exist = $false } -Invoke-DscResource -Name VSCodeExtension -Method Set -Property $params -ModuleName Microsoft.VSCode.Dsc +Invoke-DscResource -ModuleName Microsoft.VSCode.Dsc -Name VSCodeExtension -Method Set -Property $params ``` ### EXAMPLE 4 @@ -68,5 +68,5 @@ $params = @{ Name = 'ms-python.python' Insiders = $true } -Invoke-DscResource -Name VSCodeExtension -Method Set -Property $params -ModuleName Microsoft.VSCode.Dsc +Invoke-DscResource -ModuleName Microsoft.VSCode.Dsc -Name VSCodeExtension -Method Set -Property $params ``` diff --git a/resources/YarnDsc/YarnDsc.psm1 b/resources/YarnDsc/YarnDsc.psm1 index b5c35797..4970b81f 100644 --- a/resources/YarnDsc/YarnDsc.psm1 +++ b/resources/YarnDsc/YarnDsc.psm1 @@ -3,6 +3,53 @@ using namespace System.Collections.Generic +#region Functions +function Assert-Yarn { + # Refresh session $path value before invoking 'npm' + $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') + try { + Invoke-Yarn -Command 'help' + return + } catch { + throw 'Yarn is not installed' + } +} + +function Invoke-YarnInfo { + param( + [Parameter()] + [string]$Arguments + ) + + $command = [List[string]]::new() + $command.Add('info') + $command.Add($Arguments) + return Invoke-Yarn -Command $command +} + +function Invoke-YarnInstall { + param ( + [Parameter()] + [string]$Arguments + ) + + $command = [List[string]]::new() + $command.Add('install') + $command.Add($Arguments) + return Invoke-Yarn -Command $command +} + +function Invoke-Yarn { + param ( + [Parameter(Mandatory = $true)] + [string]$Command + ) + + return Invoke-Expression -Command "yarn $Command" +} + +#endregion Functions + # Assert once that Yarn is already installed on the system. Assert-Yarn @@ -50,50 +97,3 @@ class YarnInstall { } #endregion DSCResources - -#region Functions -function Assert-Yarn { - # Refresh session $path value before invoking 'npm' - $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') - try { - Invoke-Yarn -Command 'help' - return - } catch { - throw 'Yarn is not installed' - } -} - -function Invoke-YarnInfo { - param( - [Parameter()] - [string]$Arguments - ) - - $command = [List[string]]::new() - $command.Add('info') - $command.Add($Arguments) - return Invoke-Yarn -Command $command -} - -function Invoke-YarnInstall { - param ( - [Parameter()] - [string]$Arguments - ) - - $command = [List[string]]::new() - $command.Add('install') - $command.Add($Arguments) - return Invoke-Yarn -Command $command -} - -function Invoke-Yarn { - param ( - [Parameter(Mandatory = $true)] - [string]$Command - ) - - return Invoke-Expression -Command "yarn $Command" -} - -#endregion Functions diff --git a/tests/QA/module.tests.ps1 b/tests/QA/module.tests.ps1 new file mode 100644 index 00000000..41e612f8 --- /dev/null +++ b/tests/QA/module.tests.ps1 @@ -0,0 +1,387 @@ +#Requires -Version 7 + +param ( + [Parameter()] + [string] $repoRootPath = (Get-Item $PSScriptRoot).Parent.Parent.FullName, + + [Parameter()] + [array] $modules = (Get-ChildItem -Path (Join-Path $repoRootPath -ChildPath 'resources') -File -Recurse -Filter '*.psm1') +) + +Write-Verbose ("repoRootPath: $repoRootPath") -Verbose +Write-Verbose ("modules: $($modules.Count)") -Verbose + +#region Functions +function Get-MarkdownHeadings { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$FilePath + ) + + $fileContent = Get-Content -Path $FilePath + + $headings = @() + + # Use pattern to capture all headings + $headingPattern = '^(#+)\s+(.*)' + + foreach ($line in $fileContent) { + if ($line -match $headingPattern) { + $level = $matches[1].Length + $text = $matches[2] + + $heading = [PSCustomObject]@{ + Level = $level + Text = $text + } + + $headings += $heading + } + } + + return $headings +} + +function Get-MdCodeBlock { + [CmdletBinding()] + [OutputType([CodeBlock])] + param ( + [Parameter(Mandatory, ValueFromPipeline, Position = 0)] + [string[]] + [SupportsWildcards()] + $Path, + + [Parameter()] + [string] + $BasePath = '.', + + [Parameter()] + [string] + $Language + ) + + process { + foreach ($unresolved in $Path) { + foreach ($file in (Resolve-Path -Path $unresolved).Path) { + $file = (Resolve-Path -Path $file).Path + $BasePath = (Resolve-Path -Path $BasePath).Path + $escapedRoot = [regex]::Escape($BasePath) + $relativePath = $file -replace "$escapedRoot\\", '' + + + # This section imports files referenced by PyMdown snippet syntax + # Example: --8<-- "abbreviations.md" + # Note: This function only supports very basic snippet syntax. + # See https://facelessuser.github.io/pymdown-extensions/extensions/snippets/ for documentation on the Snippets PyMdown extension + $lines = [System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8) | ForEach-Object { + if ($_ -match '--8<-- "(?[^"]+)"') { + $snippetPath = Join-Path -Path $BasePath -ChildPath $Matches.file + if (Test-Path -Path $snippetPath) { + Get-Content -Path $snippetPath + } else { + Write-Warning "Snippet not found: $snippetPath" + } + } else { + $_ + } + } + + + $lineNumber = 0 + $code = $null + $state = [MdState]::Undefined + $content = [System.Text.StringBuilder]::new() + + foreach ($line in $lines) { + $lineNumber++ + switch ($state) { + 'Undefined' { + if ($line -match '^\s*```(?\w+)?' -and ([string]::IsNullOrWhiteSpace($Language) -or $Matches.lang -eq $Language)) { + $state = [MdState]::InCodeBlock + $code = [CodeBlock]@{ + Source = $relativePath + Language = $Matches.lang + LineNumber = $lineNumber + } + } elseif (($inlineMatches = [regex]::Matches($line, '(?\w+) )?(?[^`]+)`(?!`)'))) { + if (-not [string]::IsNullOrWhiteSpace($Language) -and $inlineMatch.Groups.lang -ne $Language) { + continue + } + foreach ($inlineMatch in $inlineMatches) { + [CodeBlock]@{ + Source = $relativePath + Language = $inlineMatch.Groups.lang + Content = $inlineMatch.Groups.code + LineNumber = $lineNumber + Position = $inlineMatch.Index + Inline = $true + } + } + } + } + + 'InCodeBlock' { + if ($line -match '^\s*```') { + $state = [MdState]::Undefined + $code.Content = $content.ToString() + $code + $code = $null + $null = $content.Clear() + } else { + $null = $content.AppendLine($line) + } + } + } + } + } + } + } +} +#endRegion Functions + +#region Enum +enum MdState { + Undefined + InCodeBlock +} +#endRegion Enum +class CodeBlock { + [string] $Source + [string] $Language + [string] $Content + [int] $LineNumber + [int] $Position + [bool] $Inline + + [string] ToString() { + return '{0}:{1}:{2}' -f $this.Source, $this.LineNumber, $this.Language + } +} +#region Classes + +#endRegion Classes + +BeforeDiscovery { + $moduleResources = [System.Collections.ArrayList]@() + + foreach ($module in $modules) { + $moduleResources += @{ + moduleName = $module.BaseName + modulePath = $module.FullName + } + } +} + +Describe 'Module tests' { + Context 'General resource folder test' -Tags 'FunctionalQuality' { + It '[]' -TestCases $testCases -Skip:(-not $scriptAnalyzerRules) { + $functionFile = Get-ChildItem -Path $sourcePath -Recurse -Include "$Name.ps1" + + $pssaResult = (Invoke-ScriptAnalyzer -Path $functionFile.FullName) + $report = $pssaResult | Format-Table -AutoSize | Out-String -Width 110 + $pssaResult | Should -BeNullOrEmpty -Because ` + "some rule triggered.`r`n`r`n $report" + } + + It '[] Should import without error' -TestCases $moduleResources { + + param ( + [string] $modulePath, + [string] $moduleName + ) + + { Import-Module -Name $modulePath -Force -ErrorAction Stop } | Should -Not -Throw + + Get-Module -Name $moduleName | Should -Not -BeNullOrEmpty + } + + It '[] Should remove without error' -TestCases $moduleResources { + { Remove-Module -Name $moduleName -Force -ErrorAction Stop } | Should -Not -Throw + + Get-Module $moduleName | Should -BeNullOrEmpty + } + + It '[] Should have unit test' -TestCases $moduleResources { + Get-ChildItem -Path 'tests\' -Recurse -Include "$ModuleName.Tests.ps1" | Should -Not -BeNullOrEmpty + } + } + + Context 'Quality checks' -Tags 'TestQuality' { + BeforeDiscovery { + if (Get-Command -Name Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue) { + $scriptAnalyzerRules = Get-ScriptAnalyzerRule + } else { + if ($ErrorActionPreference -ne 'Stop') { + Write-Warning -Message 'ScriptAnalyzer not found!' + } else { + throw 'ScriptAnalyzer not found!' + } + } + } + + It '[] Should pass PSScriptAnalyzer' -TestCases $moduleResources -Skip:(-not $scriptAnalyzerRules) { + param ( + [string] $modulePath, + [string] $moduleName + ) + + $pssaResult = Invoke-ScriptAnalyzer -Path $modulePath + $report = $pssaResult | Format-Table -AutoSize | Out-String -Width 110 + $pssaResult | Should -BeNullOrEmpty -Because ` + "some rule triggered.`r`n`r`n $report" + } + } + + Context 'Documentation checks' -Tags 'DocQuality' -ForEach $moduleResources { + $moduleResource = $_ + $moduleImport = Import-PowerShellDataFile -Path $moduleResource.ModulePath.Replace('.psm1', '.psd1') + + # For the resources + $resources = [System.Collections.ArrayList]@() + + # For the code blocks to capture in the examples + $codeBlocks = [System.Collections.ArrayList]@() + + foreach ($resource in $moduleImport.DscResourcesToExport) { + $helpFile = Join-Path $repoRootPath 'resources' 'Help' $moduleResource.ModuleName "$resource.md" + + $resources += @{ + moduleName = $moduleResource.ModuleName + resource = $resource + helpFile = $helpFile + CodeBlock = Get-MdCodeBlock -Path $helpFile -Language 'powershell' -ErrorAction SilentlyContinue + } + + $blocks = Get-MdCodeBlock -Path $helpFile -Language 'powershell' -ErrorAction SilentlyContinue + if (-not $blocks) { + $codeBlocks += @{ + moduleName = $moduleResource.ModuleName + resource = $resource + content = 'No code block found' + language = 'powershell' + } + } + + foreach ($block in $blocks) { + $codeBlocks += @{ + moduleName = $moduleResource.ModuleName + resource = $resource + content = $block.Content + language = $block.Language + } + } + } + + It '[] Should have a help file for [] resource' -TestCases $resources { + param ( + [string] $moduleName, + [string] $resource, + [string] $helpFile + ) + + $expectedFile = Test-Path $helpFile -ErrorAction SilentlyContinue + $expectedFile | Should -Be $true + } + + It '[] Should have a help file for [] resource that is not empty' -TestCases $resources { + param ( + [string] $moduleName, + [string] $resource, + [string] $helpFile + ) + + $file = Get-Item -Path $helpFile -ErrorAction SilentlyContinue + $file.Length | Should -BeGreaterThan 0 + } + + It '[] Should have a help file for [] resource with heading 1' -TestCases $resources { + param ( + [string] $moduleName, + [string] $resource, + [string] $helpFile + ) + + $headings = Get-MarkdownHeadings -FilePath $helpFile -ErrorAction SilentlyContinue + + $h1 = $headings | Where-Object { $_.Level -eq 1 -and $_.Text -eq $moduleName } + $h1 | Should -Not -BeNullOrEmpty + } + + It '[] Should have a help file for [] resource with heading 2 matching SYNOPSIS' -TestCases $resources { + param ( + [string] $moduleName, + [string] $resource, + [string] $helpFile + ) + + $headings = Get-MarkdownHeadings -FilePath $helpFile -ErrorAction SilentlyContinue + + $h2 = $headings | Where-Object { $_.Level -eq 2 -and $_.Text -eq 'SYNOPSIS' } + $h2 | Should -Not -BeNullOrEmpty + } + + It '[] Should have a help file for [] resource with heading 2 matching DESCRIPTION' -TestCases $resources { + param ( + [string] $moduleName, + [string] $resource, + [string] $helpFile + ) + + $headings = Get-MarkdownHeadings -FilePath $helpFile -ErrorAction SilentlyContinue + + $h2 = $headings | Where-Object { $_.Level -eq 2 -and $_.Text -eq 'DESCRIPTION' } + $h2 | Should -Not -BeNullOrEmpty + } + + It '[] Should have a help file for [] resource with heading 2 matching PARAMETERS' -TestCases $resources { + param ( + [string] $moduleName, + [string] $resource, + [string] $helpFile + ) + + $headings = Get-MarkdownHeadings -FilePath $helpFile -ErrorAction SilentlyContinue + + $h2 = $headings | Where-Object { $_.Level -eq 2 -and $_.Text -eq 'PARAMETERS' } + $h2 | Should -Not -BeNullOrEmpty + } + + It '[] Should have a help file for [] resource with heading 2 matching EXAMPLES' -TestCases $resources { + param ( + [string] $moduleName, + [string] $resource, + [string] $helpFile + ) + + $headings = Get-MarkdownHeadings -FilePath $helpFile -ErrorAction SilentlyContinue + + $h2 = $headings | Where-Object { $_.Level -eq 2 -and $_.Text -eq 'EXAMPLES' } + $h2 | Should -Not -BeNullOrEmpty + } + + It '[] Should have a help file for [] with 1 example' -TestCases $resources { + param ( + [string] $moduleName, + [string] $resource, + [string] $helpFile + ) + + $headings = Get-MarkdownHeadings -FilePath $helpFile -ErrorAction SilentlyContinue + + $h3 = $headings | Where-Object { $_.Level -eq 3 -and $_.Text -eq 'EXAMPLE 1' } + $h3 | Should -Not -BeNullOrEmpty + } + + It '[] Should have at least a PowerShell coding example with Invoke-DscResource' -TestCases $codeBlocks { + param ( + [string] $ModuleName, + [string] $Content, + [string] $Language + ) + + $Content | Should -Match "Invoke-DscResource -ModuleName $ModuleName -Name $ResourceName" + } + } +} +