diff --git a/src/powershell/Tests/Unit/PowerBITemplates.Tests.ps1 b/src/powershell/Tests/Unit/PowerBITemplates.Tests.ps1 new file mode 100644 index 000000000..4997d3b20 --- /dev/null +++ b/src/powershell/Tests/Unit/PowerBITemplates.Tests.ps1 @@ -0,0 +1,353 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + Validates the built Power BI templates (PBIT) without opening Power BI Desktop. + + A PBIT is a ZIP file. Its DataModelSchema part holds the parameters and Power Query (M) + queries a person sees when they open the template, so most of what is checked by hand today + (the parameter prompt, no demo data source, gzip support) can be read from it. This runs on + any platform. + + The templates are build output (release/pbit, or release/PowerBI-kql.zip and + release/PowerBI-storage.zip). When none of them have been built, every test is skipped with + the command that builds them instead of failing, so unit CI does not depend on a build. When + some are built and some are missing, the missing ones fail. + + ./src/scripts/Build-PowerBI.ps1 -NoPbip + + Expected values come from the sources the build reads (src/power-bi/reports.json and the + shared datasets), so a deliberate change there does not need a change here. + + Opening the templates in Power BI Desktop is covered by src/scripts/Test-PowerBITemplates.ps1. +#> + +BeforeDiscovery { + $srcDir = (Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.FullName + $reports =(Get-Content (Join-Path $srcDir 'power-bi/reports.json') -Raw | ConvertFrom-Json -Depth 10).reports.PSObject.Properties.Name + + # One entry per template that should exist, whether it was built or not + $templates = foreach ($type in 'kql', 'storage') + { + foreach ($name in $reports) + { + @{ Name = $name; Type = $type; Label = "$name.$type" } + } + } +} + +BeforeAll { + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $script:srcDir = (Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.FullName + $script:releaseDir = Join-Path (Split-Path $script:srcDir -Parent) 'release' + $script:powerBiDir = Join-Path $script:srcDir 'power-bi' + $script:notBuilt = 'Power BI templates are not built. Run ./src/scripts/Build-PowerBI.ps1 -NoPbip to build them.' + + # Reuse the build's own allowlist and TMDL name logic instead of copying it + $helpers = 'Resolve-Allowlist', 'ConvertFrom-TmdlDeclaration' + $ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:srcDir 'scripts/Build-PowerBI.ps1'), [ref]$null, [ref]$null) + $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $helpers -contains $node.Name }, $false) ` + | ForEach-Object { . ([scriptblock]::Create($_.Extent.Text)) } + + # Find the built templates: release/pbit first, then the release ZIP files + $script:extractedDir = $null + $script:templatePath = @{} + $pbitDir = Join-Path $script:releaseDir 'pbit' + foreach ($file in @(Get-ChildItem (Join-Path $pbitDir '*.pbit') -ErrorAction SilentlyContinue)) + { + $script:templatePath[$file.BaseName] = $file.FullName + } + if ($script:templatePath.Count -eq 0) + { + foreach ($type in 'kql', 'storage') + { + $zip = Join-Path $script:releaseDir "PowerBI-$type.zip" + if (-not (Test-Path $zip)) { continue } + + if (-not $script:extractedDir) + { + $script:extractedDir = Join-Path ([System.IO.Path]::GetTempPath()) "ftk-pbit-$([guid]::NewGuid().ToString('N'))" + New-Item $script:extractedDir -ItemType Directory | Out-Null + } + [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $script:extractedDir, $true) + } + if ($script:extractedDir) + { + foreach ($file in @(Get-ChildItem (Join-Path $script:extractedDir '*.pbit'))) + { + $script:templatePath[$file.BaseName] = $file.FullName + } + } + } + $script:anyBuilt = $script:templatePath.Count -gt 0 + + $script:config = (Get-Content (Join-Path $script:powerBiDir 'reports.json') -Raw | ConvertFrom-Json -Depth 10).reports + + <# + Reads a part of a PBIT as text. Power BI writes most parts as UTF-16LE, with or without a + byte order mark, and other parts (like theme files) as UTF-8, so the encoding is detected. + #> + function Get-PbitText([System.IO.Compression.ZipArchiveEntry] $Entry) + { + $stream = $Entry.Open() + try + { + $buffer = New-Object System.IO.MemoryStream + $stream.CopyTo($buffer) + $bytes = $buffer.ToArray() + } + finally { $stream.Dispose() } + + if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2) } + if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) { return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2) } + # UTF-16LE text that is mostly ASCII has a zero byte in every second position + if ($bytes.Length -ge 4 -and $bytes[1] -eq 0 -and $bytes[3] -eq 0) { return [System.Text.Encoding]::Unicode.GetString($bytes) } + return [System.Text.Encoding]::UTF8.GetString($bytes).TrimStart([char]0xFEFF) + } + + # Why a template's tests can't run, or nothing when they can. Nothing built skips everything. + # A template that is missing or unreadable fails once, in "Is built", and skips the rest. + function Get-SkipReason + { + if (-not $script:anyBuilt) { return $script:notBuilt } + if (-not $script:model) { return "$Label.pbit is missing or unreadable (see the 'Is built' test)." } + return $null + } + + # M and DAX are stored as a string or as an array of lines, depending on the writer + function Get-ExpressionText($Expression) + { + return (@($Expression) -join "`n") + } + + function Test-PbitParameter($Expression) + { + return (Get-ExpressionText $Expression) -match 'IsParameterQuery\s*=\s*true' + } + + function Get-SourceParameter([string] $Type) + { + $path = Join-Path $script:powerBiDir "$Type/Shared.Dataset/definition/expressions.tmdl" + $parameters = @{} + foreach ($line in [System.IO.File]::ReadAllLines($path)) + { + if ($line -notmatch "^expression (?:'(?(?:[^']|'')+)'|(?\S+)) = (?.+) meta \[(?[^\]]*IsParameterQuery=true[^\]]*)\]\s*$") { continue } + + $name = if ($Matches.quoted) { $Matches.quoted.Replace("''", "'") } else { $Matches.plain } + $parameters[$name] = @{ Value = $Matches.value; Meta = $Matches.meta } + } + return $parameters + } + + # reports.json lists a table for both report types when only one of them has it (for example, + # Prices only exists in the storage model), so what a template gets is the overlap + function Get-SourceTable([string] $Type) + { + $names = foreach ($file in @(Get-ChildItem (Join-Path $script:powerBiDir "$Type/Shared.Dataset/definition/tables") -Filter '*.tmdl')) + { + foreach ($line in [System.IO.File]::ReadAllLines($file.FullName)) + { + if ($line -match '^table\s+(?.+)$') { ConvertFrom-TmdlDeclaration $Matches.rest; break } + } + } + return @($names) + } + + # Normalize spacing, which Power BI Desktop can change when it re-saves a model + function Format-Expression([string] $Text) + { + return ($Text -replace '\s+', ' ').Trim() + } + + $script:sourceParameters = @{ kql = Get-SourceParameter 'kql'; storage = Get-SourceParameter 'storage' } + $script:sourceTables = @{ kql = Get-SourceTable 'kql'; storage = Get-SourceTable 'storage' } +} + +AfterAll { + if ($script:extractedDir) { Remove-Item $script:extractedDir -Recurse -Force -ErrorAction SilentlyContinue } +} + +Describe 'Power BI templates' { + It 'Has the expected source parameters to compare against' { + # Guards the tests below, which would otherwise pass with nothing to compare + $script:sourceParameters.kql.Keys | Should -Contain 'Cluster URL' + $script:sourceParameters.storage.Keys | Should -Contain 'Storage URL' + } + + It 'Has a <_> template per report' -ForEach 'kql', 'storage' { + if (-not $script:anyBuilt) { Set-ItResult -Skipped -Because $script:notBuilt; return } + + # One template per report in reports.json, named . + $type = $_ + $expected = @($script:config.PSObject.Properties.Name | ForEach-Object { "$_.$type" } | Sort-Object) + $built = @($script:templatePath.Keys | Where-Object { $_.EndsWith(".$type") } | Sort-Object) + $built | Should -Be $expected + } + + It 'Has a PowerBI-<_>.zip with the same templates' -ForEach 'kql', 'storage' { + $zip = Join-Path $script:releaseDir "PowerBI-$_.zip" + if (-not (Test-Path $zip)) { Set-ItResult -Skipped -Because "release/PowerBI-$_.zip is not built. $script:notBuilt"; return } + + $type = $_ + $archive = [System.IO.Compression.ZipFile]::OpenRead($zip) + try { $entries = @($archive.Entries | ForEach-Object { $_.FullName } | Where-Object { $_ -like '*.pbit' } | Sort-Object) } + finally { $archive.Dispose() } + + $expected = @($script:config.PSObject.Properties.Name | ForEach-Object { "$_.$type.pbit" } | Sort-Object) + $entries | Should -Be $expected + } +} + +Describe 'Power BI template