diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 000000000..991e823e8 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,26 @@ +name: 'Documentation Tests' + +on: + pull_request: + paths: + - 'docs/**' + - 'docs-mslearn/**' + - 'docs-wiki/**' + - '*.md' + - 'src/**/*.md' + +jobs: + broken_links: + name: Broken Links + runs-on: Windows-latest + steps: + - name: Install and cache PowerShell modules + uses: potatoqualitee/psmodulecache@v6.2.1 + with: + modules-to-cache: Pester + shell: pwsh + - uses: actions/checkout@v3 + - name: Run Broken Links Test + shell: pwsh + run: | + src/scripts/Test-PowerShell.ps1 -Markdown \ No newline at end of file diff --git a/.github/workflows/dev.yml b/.github/workflows/powershell.yml similarity index 100% rename from .github/workflows/dev.yml rename to .github/workflows/powershell.yml diff --git a/src/powershell/Tests/Lint/BrokenLinks.Tests.ps1 b/src/powershell/Tests/Lint/BrokenLinks.Tests.ps1 new file mode 100644 index 000000000..6f83a1491 --- /dev/null +++ b/src/powershell/Tests/Lint/BrokenLinks.Tests.ps1 @@ -0,0 +1,490 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeDiscovery { + $rootPath = ((Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.Parent).FullName + + # Define test groups + $docsFiles = Get-ChildItem -Path "$rootPath/docs" -Recurse -Include '*.md' -ErrorAction SilentlyContinue + $docsMslearnFiles = Get-ChildItem -Path "$rootPath/docs-mslearn" -Recurse -Include '*.md' -ErrorAction SilentlyContinue + $docsWikiFiles = Get-ChildItem -Path "$rootPath/docs-wiki" -Recurse -Include '*.md' -ErrorAction SilentlyContinue + + # Get other repo markdown files (excluding the above folders) + $otherMarkdownFiles = Get-ChildItem -Path $rootPath -Recurse -Include '*.md' -ErrorAction SilentlyContinue | + Where-Object { + $_.FullName -notmatch [regex]::Escape("$rootPath/docs/") -and + $_.FullName -notmatch [regex]::Escape("$rootPath/docs-mslearn/") -and + $_.FullName -notmatch [regex]::Escape("$rootPath/docs-wiki/") -and + $_.FullName -notmatch [regex]::Escape("$rootPath/.git/") -and + $_.FullName -notmatch [regex]::Escape("$rootPath/node_modules/") + } +} + +Describe 'Broken Links - docs folder [<_>]' -Tag 'BrokenLinks', 'Docs' -ForEach $docsFiles.FullName { + BeforeAll { + $rootPath = ((Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.Parent).FullName + $file = $_ + + function Get-MarkdownLinks { + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return @() } + + # Find markdown links [text](url) and reference links [text]: url + $linkPattern = '\[([^\]]*)\]\(([^)]+)\)' + $refLinkPattern = '^\s*\[([^\]]+)\]:\s*(.+)$' + + $links = @() + + # Extract inline links + [regex]::Matches($content, $linkPattern) | ForEach-Object { + $links += @{ + Text = $_.Groups[1].Value + Url = $_.Groups[2].Value.Trim() + } + } + + # Extract reference links + $content -split "`n" | ForEach-Object { + if ($_ -match $refLinkPattern) { + $links += @{ + Text = $matches[1] + Url = $matches[2].Trim() + } + } + } + + return $links + } + + function Test-LinkValidity { + param( + [string]$FilePath, + [string]$LinkUrl, + [string]$FolderType + ) + + $relativeFilePath = $FilePath -replace [regex]::Escape($rootPath), '' + + # Skip anchors, javascript, and data URLs + if ($LinkUrl -match '^(#|javascript:|data:|mailto:)') { + return $true + } + + # Skip Jekyll template variables + if ($LinkUrl -match '^\{\{.*\}\}$') { + return $true + } + + # Check for language/locale in URLs (should not exist except for Azure blog) + if ($LinkUrl -match 'https?://[^/]+/[a-z]{2}-[a-z]{2}/' -and $LinkUrl -notmatch 'azure\.microsoft\.com/blog') { + Write-Warning "Link contains language/locale: $LinkUrl in $relativeFilePath" + return $false + } + + switch ($FolderType) { + 'docs' { + # docs should use relative links for files in docs, fully qualified for others + # Also allow root-relative links for Microsoft Learn content and Jekyll template variables + if ($LinkUrl -match '^\.\.?/') { + # Relative link - check if it points to a file in docs + $targetPath = Resolve-Path -Path (Join-Path (Split-Path $FilePath) $LinkUrl) -ErrorAction SilentlyContinue + if ($targetPath -and $targetPath.Path -like "$rootPath/docs/*") { + # Valid relative link within docs + return Test-Path $targetPath.Path + } + else { + Write-Warning "Relative link pointing outside docs folder: $LinkUrl in $relativeFilePath" + return $false + } + } + elseif ($LinkUrl -match '^/') { + # Root-relative link - allow for Microsoft Learn content + return $true + } + elseif ($LinkUrl -match '^https?://') { + # Fully qualified link - this is expected for external content + return $true + } + else { + Write-Warning "Invalid link format: $LinkUrl in $relativeFilePath" + return $false + } + } + 'docs-mslearn' { + # docs-mslearn should use folder-relative for same folder, root-relative for learn.microsoft.com, fully qualified for others + if ($LinkUrl -match '^\.\.?/') { + # Relative link - check if it points to a file in docs-mslearn + $targetPath = Resolve-Path -Path (Join-Path (Split-Path $FilePath) $LinkUrl) -ErrorAction SilentlyContinue + if ($targetPath -and $targetPath.Path -like "$rootPath/docs-mslearn/*") { + return Test-Path $targetPath.Path + } + else { + Write-Warning "Relative link pointing outside docs-mslearn folder: $LinkUrl in $relativeFilePath" + return $false + } + } + elseif ($LinkUrl -match '^/') { + # Root-relative link - should be for learn.microsoft.com content + return $true + } + elseif ($LinkUrl -match '^https://learn\.microsoft\.com/') { + Write-Warning "Should use root-relative link instead of https://learn.microsoft.com/: $LinkUrl in $relativeFilePath" + return $false + } + elseif ($LinkUrl -match '^https?://') { + # Fully qualified link for external content + return $true + } + else { + Write-Warning "Invalid link format: $LinkUrl in $relativeFilePath" + return $false + } + } + 'docs-wiki' { + # docs-wiki should use relative links for files in docs-wiki, fully qualified for others + if ($LinkUrl -match '^\.\.?/') { + # Relative link - check if it points to a file in docs-wiki + $targetPath = Resolve-Path -Path (Join-Path (Split-Path $FilePath) $LinkUrl) -ErrorAction SilentlyContinue + if ($targetPath -and $targetPath.Path -like "$rootPath/docs-wiki/*") { + return Test-Path $targetPath.Path + } + else { + Write-Warning "Relative link pointing outside docs-wiki folder: $LinkUrl in $relativeFilePath" + return $false + } + } + elseif ($LinkUrl -match '^https?://') { + # Fully qualified link for external content + return $true + } + else { + Write-Warning "Invalid link format: $LinkUrl in $relativeFilePath" + return $false + } + } + 'other' { + # Other markdown files should use relative for repo files, fully qualified for external + if ($LinkUrl -match '^\.\.?/') { + # Relative link - check if it points to a file in the repo + $targetPath = Resolve-Path -Path (Join-Path (Split-Path $FilePath) $LinkUrl) -ErrorAction SilentlyContinue + if ($targetPath -and $targetPath.Path -like "$rootPath/*") { + return Test-Path $targetPath.Path + } + else { + Write-Warning "Relative link pointing outside repository: $LinkUrl in $relativeFilePath" + return $false + } + } + elseif ($LinkUrl -match '^https?://') { + # Fully qualified link for external content + return $true + } + else { + Write-Warning "Invalid link format: $LinkUrl in $relativeFilePath" + return $false + } + } + } + + return $false + } + } + + It 'Should have valid links' { + $links = Get-MarkdownLinks -FilePath $file + + $invalidLinks = @() + foreach ($link in $links) { + if (-not (Test-LinkValidity -FilePath $file -LinkUrl $link.Url -FolderType 'docs')) { + $invalidLinks += "[$($link.Text)]($($link.Url))" + } + } + + $invalidLinks | Should -BeNullOrEmpty -Because "All links should be valid in $($file -replace [regex]::Escape($rootPath), '')" + } +} + +Describe 'Broken Links - docs-mslearn folder [<_>]' -Tag 'BrokenLinks', 'DocsMslearn' -ForEach $docsMslearnFiles.FullName { + BeforeAll { + $rootPath = ((Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.Parent).FullName + $file = $_ + + function Get-MarkdownLinks { + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return @() } + + $linkPattern = '\[([^\]]*)\]\(([^)]+)\)' + $refLinkPattern = '^\s*\[([^\]]+)\]:\s*(.+)$' + + $links = @() + + [regex]::Matches($content, $linkPattern) | ForEach-Object { + $links += @{ + Text = $_.Groups[1].Value + Url = $_.Groups[2].Value.Trim() + } + } + + $content -split "`n" | ForEach-Object { + if ($_ -match $refLinkPattern) { + $links += @{ + Text = $matches[1] + Url = $matches[2].Trim() + } + } + } + + return $links + } + + function Test-LinkValidity { + param([string]$FilePath, [string]$LinkUrl, [string]$FolderType) + + $relativeFilePath = $FilePath -replace [regex]::Escape($rootPath), '' + + if ($LinkUrl -match '^(#|javascript:|data:|mailto:)') { + return $true + } + + # Skip Jekyll template variables + if ($LinkUrl -match '^\{\{.*\}\}$') { + return $true + } + + if ($LinkUrl -match 'https?://[^/]+/[a-z]{2}-[a-z]{2}/' -and $LinkUrl -notmatch 'azure\.microsoft\.com/blog') { + Write-Warning "Link contains language/locale: $LinkUrl in $relativeFilePath" + return $false + } + + if ($FolderType -eq 'docs-mslearn') { + if ($LinkUrl -match '^\.\.?/') { + $targetPath = Resolve-Path -Path (Join-Path (Split-Path $FilePath) $LinkUrl) -ErrorAction SilentlyContinue + if ($targetPath -and $targetPath.Path -like "$rootPath/docs-mslearn/*") { + return Test-Path $targetPath.Path + } + else { + Write-Warning "Relative link pointing outside docs-mslearn folder: $LinkUrl in $relativeFilePath" + return $false + } + } + elseif ($LinkUrl -match '^/') { + return $true + } + elseif ($LinkUrl -match '^https://learn\.microsoft\.com/') { + Write-Warning "Should use root-relative link instead of https://learn.microsoft.com/: $LinkUrl in $relativeFilePath" + return $false + } + elseif ($LinkUrl -match '^https?://') { + return $true + } + else { + Write-Warning "Invalid link format: $LinkUrl in $relativeFilePath" + return $false + } + } + + return $false + } + } + + It 'Should have valid links' { + $links = Get-MarkdownLinks -FilePath $file + + $invalidLinks = @() + foreach ($link in $links) { + if (-not (Test-LinkValidity -FilePath $file -LinkUrl $link.Url -FolderType 'docs-mslearn')) { + $invalidLinks += "[$($link.Text)]($($link.Url))" + } + } + + $invalidLinks | Should -BeNullOrEmpty -Because "All links should be valid in $($file -replace [regex]::Escape($rootPath), '')" + } +} + +Describe 'Broken Links - docs-wiki folder [<_>]' -Tag 'BrokenLinks', 'DocsWiki' -ForEach $docsWikiFiles.FullName { + BeforeAll { + $rootPath = ((Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.Parent).FullName + $file = $_ + + function Get-MarkdownLinks { + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return @() } + + $linkPattern = '\[([^\]]*)\]\(([^)]+)\)' + $refLinkPattern = '^\s*\[([^\]]+)\]:\s*(.+)$' + + $links = @() + + [regex]::Matches($content, $linkPattern) | ForEach-Object { + $links += @{ + Text = $_.Groups[1].Value + Url = $_.Groups[2].Value.Trim() + } + } + + $content -split "`n" | ForEach-Object { + if ($_ -match $refLinkPattern) { + $links += @{ + Text = $matches[1] + Url = $matches[2].Trim() + } + } + } + + return $links + } + + function Test-LinkValidity { + param([string]$FilePath, [string]$LinkUrl, [string]$FolderType) + + $relativeFilePath = $FilePath -replace [regex]::Escape($rootPath), '' + + if ($LinkUrl -match '^(#|javascript:|data:|mailto:)') { + return $true + } + + # Skip Jekyll template variables + if ($LinkUrl -match '^\{\{.*\}\}$') { + return $true + } + + if ($LinkUrl -match 'https?://[^/]+/[a-z]{2}-[a-z]{2}/' -and $LinkUrl -notmatch 'azure\.microsoft\.com/blog') { + Write-Warning "Link contains language/locale: $LinkUrl in $relativeFilePath" + return $false + } + + if ($FolderType -eq 'docs-wiki') { + if ($LinkUrl -match '^\.\.?/') { + $targetPath = Resolve-Path -Path (Join-Path (Split-Path $FilePath) $LinkUrl) -ErrorAction SilentlyContinue + if ($targetPath -and $targetPath.Path -like "$rootPath/docs-wiki/*") { + return Test-Path $targetPath.Path + } + else { + Write-Warning "Relative link pointing outside docs-wiki folder: $LinkUrl in $relativeFilePath" + return $false + } + } + elseif ($LinkUrl -match '^https?://') { + return $true + } + else { + Write-Warning "Invalid link format: $LinkUrl in $relativeFilePath" + return $false + } + } + + return $false + } + } + + It 'Should have valid links' { + $links = Get-MarkdownLinks -FilePath $file + + $invalidLinks = @() + foreach ($link in $links) { + if (-not (Test-LinkValidity -FilePath $file -LinkUrl $link.Url -FolderType 'docs-wiki')) { + $invalidLinks += "[$($link.Text)]($($link.Url))" + } + } + + $invalidLinks | Should -BeNullOrEmpty -Because "All links should be valid in $($file -replace [regex]::Escape($rootPath), '')" + } +} + +Describe 'Broken Links - other repository markdown files [<_>]' -Tag 'BrokenLinks', 'Other' -ForEach $otherMarkdownFiles.FullName { + BeforeAll { + $rootPath = ((Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.Parent).FullName + $file = $_ + + function Get-MarkdownLinks { + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return @() } + + $linkPattern = '\[([^\]]*)\]\(([^)]+)\)' + $refLinkPattern = '^\s*\[([^\]]+)\]:\s*(.+)$' + + $links = @() + + [regex]::Matches($content, $linkPattern) | ForEach-Object { + $links += @{ + Text = $_.Groups[1].Value + Url = $_.Groups[2].Value.Trim() + } + } + + $content -split "`n" | ForEach-Object { + if ($_ -match $refLinkPattern) { + $links += @{ + Text = $matches[1] + Url = $matches[2].Trim() + } + } + } + + return $links + } + + function Test-LinkValidity { + param([string]$FilePath, [string]$LinkUrl, [string]$FolderType) + + $relativeFilePath = $FilePath -replace [regex]::Escape($rootPath), '' + + if ($LinkUrl -match '^(#|javascript:|data:|mailto:)') { + return $true + } + + # Skip Jekyll template variables + if ($LinkUrl -match '^\{\{.*\}\}$') { + return $true + } + + if ($LinkUrl -match 'https?://[^/]+/[a-z]{2}-[a-z]{2}/' -and $LinkUrl -notmatch 'azure\.microsoft\.com/blog') { + Write-Warning "Link contains language/locale: $LinkUrl in $relativeFilePath" + return $false + } + + if ($FolderType -eq 'other') { + if ($LinkUrl -match '^\.\.?/') { + $targetPath = Resolve-Path -Path (Join-Path (Split-Path $FilePath) $LinkUrl) -ErrorAction SilentlyContinue + if ($targetPath -and $targetPath.Path -like "$rootPath/*") { + return Test-Path $targetPath.Path + } + else { + Write-Warning "Relative link pointing outside repository: $LinkUrl in $relativeFilePath" + return $false + } + } + elseif ($LinkUrl -match '^https?://') { + return $true + } + else { + Write-Warning "Invalid link format: $LinkUrl in $relativeFilePath" + return $false + } + } + + return $false + } + } + + It 'Should have valid links' { + $links = Get-MarkdownLinks -FilePath $file + + $invalidLinks = @() + foreach ($link in $links) { + if (-not (Test-LinkValidity -FilePath $file -LinkUrl $link.Url -FolderType 'other')) { + $invalidLinks += "[$($link.Text)]($($link.Url))" + } + } + + $invalidLinks | Should -BeNullOrEmpty -Because "All links should be valid in $($file -replace [regex]::Escape($rootPath), '')" + } +} \ No newline at end of file diff --git a/src/scripts/Test-PowerShell.ps1 b/src/scripts/Test-PowerShell.ps1 index 2620c66eb..44aba3641 100644 --- a/src/scripts/Test-PowerShell.ps1 +++ b/src/scripts/Test-PowerShell.ps1 @@ -47,6 +47,9 @@ .PARAMETER AllTests Optional. Indicates whether to run all lint, unit, and integration tests. If set, this overrides Lint, Unit, and Integration options. Default = false. + .PARAMETER Markdown + Optional. Indicates whether to run markdown broken link tests. Default = false. + .PARAMETER RunFailed Optional. Indicates whether to re-run previously failed tests. This can only be run after a run fails. Only the failed tests will be re-run. If there a no previous run details, nothing will run. Default = false. #> @@ -85,6 +88,9 @@ param ( [switch] $AllTests, + [switch] + $Markdown, + [switch] $RunFailed ) @@ -105,47 +111,70 @@ if ($RunFailed) } else { - $typesToRun = @( - if ($AllTests -or $Lint) { 'Lint' } - if ($AllTests -or $Integration) { 'Integration' } - if ($AllTests -or $Unit -or (-not $Lint -and -not $Integration)) { 'Unit' } - ) - if ($typesToRun.Count -eq 3) { $typesToRun = '*' } - - $testsToRun = @() - if ($Cost) { $testsToRun += '*-FinOpsCost*', 'Cost*' } - if ($Data) { $testsToRun += '*-OpenData*', '*-FinOpsPricingUnit*', '*-FinOpsRegion*', '*-FinOpsResourceType*', '*-FinOpsService*' } - if ($Exports) { $testsToRun += '*-FinOpsCostExport*', 'CostExports.Tests.ps1' } - if ($FOCUS) { $testsToRun += '*-FinOpsSchema*', 'FOCUS.Tests.ps1' } - if ($Hubs) { $testsToRun += '*-FinOpsHub*', '*-Hub*', 'Hubs.Tests.ps1' } - if ($Toolkit) { $testsToRun += 'Toolkit.Tests.ps1', '*-FinOpsToolkit*' } - if ($Private) { $testsToRun += (Get-ChildItem -Path "$PSScriptRoot/../powershell/Tests/$testType/Unit" -Exclude *-FinOps*, *-Hub*, *-OpenData* -Name *.Tests.ps1) } - if (-not $testsToRun) { $testsToRun = "*" } - - Write-Host '' - Write-Host ("Finding <$($typesToRun -join '|')>/<$($testsToRun -join '|')> tests..." -replace '<\*>/', '' -replace '<([^\|>]+)>', '$1' -replace '\*\-?', '' -replace '/ tests', ' tests') -NoNewline - - $testsToRun = $typesToRun ` - | ForEach-Object { - $testType = $_ - $testsToRun | ForEach-Object { - $path = "$PSScriptRoot/../powershell/Tests/$testType/$_" - if ((Get-ChildItem $path -ErrorAction SilentlyContinue).Count -gt 0) - { - return $path - } + # Handle special case for Markdown tests - only run broken links test + if ($Markdown -and -not ($Cost -or $Data -or $Exports -or $FOCUS -or $Hubs -or $Toolkit -or $Private -or $AllTests -or $Lint -or $Unit -or $Integration)) + { + Write-Host '' + Write-Host "Finding broken links test..." -NoNewline + + $testsToRun = @("$PSScriptRoot/../powershell/Tests/Lint/BrokenLinks.Tests.ps1") + Write-Host "1 found" + Write-Host '' + + if (-not (Test-Path $testsToRun[0])) + { + Write-Host "Broken links test not found at $($testsToRun[0])" -ForegroundColor Red + return } + + $config = New-PesterConfiguration + $config.Run.Path = $testsToRun } - - Write-Host "$($testsToRun.Count) found" - Write-Host '' - if (-not $testsToRun) + else { - return - } + $typesToRun = @( + if ($AllTests -or $Lint) { 'Lint' } + if ($AllTests -or $Integration) { 'Integration' } + if ($AllTests -or $Unit -or (-not $Lint -and -not $Integration -and -not $Markdown)) { 'Unit' } + ) + if ($typesToRun.Count -eq 3) { $typesToRun = '*' } + + $testsToRun = @() + if ($Cost) { $testsToRun += '*-FinOpsCost*', 'Cost*' } + if ($Data) { $testsToRun += '*-OpenData*', '*-FinOpsPricingUnit*', '*-FinOpsRegion*', '*-FinOpsResourceType*', '*-FinOpsService*' } + if ($Exports) { $testsToRun += '*-FinOpsCostExport*', 'CostExports.Tests.ps1' } + if ($FOCUS) { $testsToRun += '*-FinOpsSchema*', 'FOCUS.Tests.ps1' } + if ($Hubs) { $testsToRun += '*-FinOpsHub*', '*-Hub*', 'Hubs.Tests.ps1' } + if ($Toolkit) { $testsToRun += 'Toolkit.Tests.ps1', '*-FinOpsToolkit*' } + if ($Markdown) { $testsToRun += 'BrokenLinks.Tests.ps1' } + if ($Private) { $testsToRun += (Get-ChildItem -Path "$PSScriptRoot/../powershell/Tests/$testType/Unit" -Exclude *-FinOps*, *-Hub*, *-OpenData* -Name *.Tests.ps1) } + if (-not $testsToRun) { $testsToRun = "*" } + + Write-Host '' + Write-Host ("Finding <$($typesToRun -join '|')>/<$($testsToRun -join '|')> tests..." -replace '<\*>/', '' -replace '<([^\|>]+)>', '$1' -replace '\*\-?', '' -replace '/ tests', ' tests') -NoNewline + + $testsToRun = $typesToRun ` + | ForEach-Object { + $testType = $_ + $testsToRun | ForEach-Object { + $path = "$PSScriptRoot/../powershell/Tests/$testType/$_" + if ((Get-ChildItem $path -ErrorAction SilentlyContinue).Count -gt 0) + { + return $path + } + } + } + + Write-Host "$($testsToRun.Count) found" + Write-Host '' + if (-not $testsToRun) + { + return + } - $config = New-PesterConfiguration - $config.Run.Path = $testsToRun | Select-Object -Unique + $config = New-PesterConfiguration + $config.Run.Path = $testsToRun | Select-Object -Unique + } } Write-Host '--------------------------------------------------'