diff --git a/.gitattributes b/.gitattributes index 7527711..7fd3b64 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,4 +13,4 @@ *.png binary *.dll binary *.so binary -*.zip binary +*.zip binary -eol diff --git a/README.md b/README.md index ab32b0d..76f1097 100644 --- a/README.md +++ b/README.md @@ -163,24 +163,32 @@ does not fit the data. ## Conformance corpus -The offline test gate runs the complete released `yaml-test-suite` data corpus -at commit `6ad3d2c62885d82fc349026c136ef560838fdf3d` (generated from source -commit `45db50ae`). The pinned archive contains 402 inputs: - -- all 94 fixtures marked invalid are rejected; -- 306 of 308 fixtures marked valid are accepted; -- the other two valid-syntax fixtures, `2JQS` and `X38W`, are deliberately - rejected as a load/composition policy after syntactic recognition because this - module rejects duplicate mapping keys in the representation graph; -- 282 fixtures include `in.json`; three belong to invalid inputs, 277 of the - 279 applicable constructions match exactly, and two use a different - documented projection policy. - -The two construction-policy differences are `565N`, where this module -constructs `!!binary` as `byte[]` instead of a base64 string, and `J7PZ`, where -the explicitly supported `!!omap` tag becomes an ordered dictionary instead of -remaining a sequence of one-entry mappings. The deterministic runner reports -398 passing cases, four policy exclusions, and no unexplained failures. +The offline test gate runs the latest official `yaml-test-suite` source release, +`v2022-01-17`, at commit `45db50aecf9b1520f8258938c88f396e96f30831`. +Its `data-2022-01-17` export is pinned at commit +`6e6c296ae9c9d2d5c4134b4b64d01b29ac19ff6f` with archive SHA-256 +`47C173AFFEB480517B30FB77DC8C76FD48609B9B65DD1C1D3D0D0BAEE48D6AA9`. +The archive contains 402 inputs: + +| Surface | Pass | PolicyDifference | Fail | NotApplicable | +| --- | ---: | ---: | ---: | ---: | +| Syntax and composition | 400 | 2 | 0 | 0 | +| Representation events | 308 | 0 | 0 | 94 | +| JSON projection | 277 | 2 | 0 | 123 | +| `out.yaml` projection | 241 | 0 | 0 | 161 | +| Emit and round trip | 306 | 0 | 0 | 96 | + +All 94 fixtures marked invalid are rejected. The valid `2JQS` and `X38W` +inputs are syntactically recognized and produce matching representation +events, then are rejected during load validation because representation +mapping keys must be unique. They are not unsupported grammar. + +The two JSON projection differences are `565N`, where `!!binary` intentionally +becomes `byte[]` instead of a Base64 string, and `J7PZ`, where legacy `!!omap` +intentionally becomes `System.Collections.Specialized.OrderedDictionary` +instead of remaining a sequence of one-entry mappings. No event mismatch is +classified as policy. The deterministic runner reports no unexplained +failures. These results are a pinned compatibility measurement, not a claim that a finite corpus proves complete YAML 1.2.2 compliance. diff --git a/tests/Conformance.Tests.ps1 b/tests/Conformance.Tests.ps1 index 6442030..79a42f8 100644 --- a/tests/Conformance.Tests.ps1 +++ b/tests/Conformance.Tests.ps1 @@ -13,11 +13,22 @@ param() BeforeAll { $archivePath = Join-Path $PSScriptRoot 'fixtures\yaml-test-suite\yaml-test-suite-data-2022-01-17.zip' + $sourcesPath = Join-Path $PSScriptRoot 'fixtures\yaml-test-suite\SOURCES.txt' + $repositoryRoot = Split-Path -Parent $PSScriptRoot $suitePath = Join-Path $TestDrive 'yaml-test-suite' + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') + $conformanceYamlModule = $yamlModule Expand-Archive -LiteralPath $archivePath -DestinationPath $suitePath + $suiteRoots = @( + Get-ChildItem -LiteralPath $suitePath -Directory + ) + if ($suiteRoots.Count -ne 1) { + throw "Expected one release archive root, but found $($suiteRoots.Count)." + } + $suiteDataPath = $suiteRoots[0].FullName $suiteResults = @( & (Join-Path $PSScriptRoot 'tools\Invoke-YamlTestSuite.ps1') ` - -Path $suitePath ` + -Path $suiteDataPath ` -CompareJson ` -CompareEvents ` -CompareOutYaml ` @@ -26,26 +37,48 @@ BeforeAll { } Describe 'Released yaml-test-suite corpus accounting' { + It 'uses the built module for conformance in GitHub Actions' { + if ($env:GITHUB_ACTIONS -eq 'true') { + $conformanceYamlModule | Should -Not -BeNullOrEmpty + $command = Get-Command -Name ConvertFrom-Yaml + $command.Module | Should -Be $conformanceYamlModule + $conformanceYamlModule.ModuleBase | + Should -Not -Be (Join-Path $repositoryRoot 'src') + $conformanceYamlModule.PowerShellVersion | Should -Be '7.6' + @($conformanceYamlModule.CompatiblePSEditions) | Should -Be @('Core') + } + } + It 'uses the pinned unmodified release archive' { (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash | - Should -Be 'DCC1F037B13F6C3032D5190C447B6A6EF5560738A4C104F29B4243B0AB8F8029' + Should -Be '47C173AFFEB480517B30FB77DC8C76FD48609B9B65DD1C1D3D0D0BAEE48D6AA9' $suiteResults.Count | Should -Be 402 } - It 'accounts for syntax acceptance, rejection, and policy differences' { + It 'pins the latest official source and data release attribution' { + $sources = Get-Content -LiteralPath $sourcesPath -Raw + + $sources | Should -Match 'Latest source release:\s+v2022-01-17' + $sources | Should -Match '45db50aecf9b1520f8258938c88f396e96f30831' + $sources | Should -Match '6e6c296ae9c9d2d5c4134b4b64d01b29ac19ff6f' + } + + It 'accounts for syntax recognition and representation-key load policy' { @($suiteResults | Where-Object SyntaxResult -EQ 'Pass').Count | Should -Be 400 @($suiteResults | Where-Object SyntaxResult -EQ 'PolicyDifference').Count | Should -Be 2 @($suiteResults | Where-Object SyntaxResult -EQ 'Fail').Count | Should -Be 0 @($suiteResults | Where-Object SyntaxResult -EQ 'NotApplicable').Count | Should -Be 0 - $syntaxPolicyCases = @( + $policyResults = @( $suiteResults | Where-Object SyntaxResult -EQ 'PolicyDifference' | - Select-Object -ExpandProperty Case | - Sort-Object + Sort-Object Case ) - $syntaxPolicyCases | Should -Be @('2JQS', 'X38W') + @($policyResults.Case) | Should -Be @('2JQS', 'X38W') + @($policyResults.SyntaxReason | Select-Object -Unique) | + Should -Be @('RepresentationMappingKeyUniqueness') + @($policyResults | Where-Object EventResult -NE 'Pass').Count | Should -Be 0 } It 'accounts for parser representation/event comparisons' { @@ -64,13 +97,52 @@ Describe 'Released yaml-test-suite corpus accounting' { @($suiteResults | Where-Object JsonResult -EQ 'Fail').Count | Should -Be 0 @($suiteResults | Where-Object JsonResult -EQ 'NotApplicable').Count | Should -Be 123 - $jsonPolicyCases = @( + $policyResults = @( $suiteResults | Where-Object JsonResult -EQ 'PolicyDifference' | - Select-Object -ExpandProperty Case | - Sort-Object + Sort-Object Case + ) + @($policyResults.Case) | Should -Be @('565N', 'J7PZ') + @($policyResults.JsonReason) | Should -Be @( + 'BinaryByteArrayProjection', + 'LegacyOrderedMapProjection' ) - $jsonPolicyCases | Should -Be @('565N', 'J7PZ') + } + + It 'does not mask altered JSON values as projection policy' { + $mutatedSuitePath = Join-Path $TestDrive 'mutated-policy-cases' + $null = New-Item -Path $mutatedSuitePath -ItemType Directory -Force + foreach ($case in @('565N', 'J7PZ')) { + Copy-Item -LiteralPath (Join-Path $suiteDataPath $case) ` + -Destination $mutatedSuitePath -Recurse + } + + $binaryJsonPath = Join-Path $mutatedSuitePath '565N\in.json' + $binaryJson = Get-Content -LiteralPath $binaryJsonPath -Raw | + ConvertFrom-Json -AsHashtable + $binaryJson['description'] = 'Altered expected value' + $binaryJson | ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $binaryJsonPath -Encoding utf8NoBOM + + $orderedMapJsonPath = Join-Path $mutatedSuitePath 'J7PZ\in.json' + $orderedMapJson = Get-Content -LiteralPath $orderedMapJsonPath -Raw | + ConvertFrom-Json -AsHashtable + $orderedMapJson[0]['Mark McGwire'] = 66 + $orderedMapJson | ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $orderedMapJsonPath -Encoding utf8NoBOM + + $mutatedResults = @( + & (Join-Path $PSScriptRoot 'tools\Invoke-YamlTestSuite.ps1') ` + -Path $mutatedSuitePath ` + -CompareJson + ) + @($mutatedResults | Sort-Object Case | Select-Object -ExpandProperty Case) | + Should -Be @('565N', 'J7PZ') + @($mutatedResults | Where-Object JsonResult -EQ 'PolicyDifference').Count | + Should -Be 0 + @($mutatedResults | Where-Object JsonResult -EQ 'Fail').Count | Should -Be 2 + @($mutatedResults.JsonReason | Select-Object -Unique) | + Should -Be @('ConstructedValueMismatch') } It 'accounts for out.yaml representation comparisons' { diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index 82d6e00..2b0842a 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -187,6 +187,21 @@ mapping: ! $result.mapping.name | Should -Be 'safe' } + It 'retains unknown local and global tags before neutral value projection' { + $local = Get-TestYamlRepresentationRoot -Yaml '!local value' + $global = Get-TestYamlRepresentationRoot -Yaml ( + '! value' + ) + + $local.Tag | Should -Be '!local' + $local.HasUnknownTag | Should -BeTrue + $global.Tag | Should -Be 'tag:example.test,2026:object' + $global.HasUnknownTag | Should -BeTrue + ('!local value' | ConvertFrom-Yaml) | Should -Be 'value' + ('! value' | ConvertFrom-Yaml) | + Should -Be 'value' + } + It 'preserves repeated collection references' { $result = @' source: &source @@ -364,21 +379,18 @@ date: !!timestamp 2001-12-14 } It 'decodes percent escapes in expanded tags using UTF-8' { - $yaml = @' -%TAG !e! tag:example.com,2000:app/ ---- -first: !e!tag%21 value -second: !e!currency%E2%82%AC amount -'@ - $representation = Read-YamlStreamCore -Yaml $yaml -Depth 32 -MaxNodes 64 -MaxAliases 16 ` - -MaxScalarLength 4096 -MaxTagLength 1024 -MaxTotalTagLength 4096 ` - -MaxNumericLength 64 - - $representation.Value.Count | Should -Be 1 - $entries = $representation.Value[0].Entries - $entries.Count | Should -Be 2 - $entries[0].Value.Tag | Should -Be 'tag:example.com,2000:app/tag!' - $entries[1].Value.Tag | Should -Be ('tag:example.com,2000:app/currency' + [char] 0x20AC) + $escaped = Get-TestYamlRepresentationRoot -Yaml ( + "%TAG !e! tag:example.com,2000:app/`n--- !e!tag%21 value" + ) + $multibyte = Get-TestYamlRepresentationRoot -Yaml ( + "%TAG !e! tag:example.com,2000:app/`n--- !e!currency%E2%82%AC amount" + ) + + $escaped.Tag | Should -Be 'tag:example.com,2000:app/tag!' + $escaped.HasUnknownTag | Should -BeTrue + $multibyte.Tag | + Should -Be ('tag:example.com,2000:app/currency' + [char] 0x20AC) + $multibyte.HasUnknownTag | Should -BeTrue } It 'rejects malformed or non-UTF8 tag percent escapes' { diff --git a/tests/Packaging.Tests.ps1 b/tests/Packaging.Tests.ps1 index e6daef6..dbd1e53 100644 --- a/tests/Packaging.Tests.ps1 +++ b/tests/Packaging.Tests.ps1 @@ -8,72 +8,57 @@ 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Required for Pester tests' )] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSProvideCommentHelp', '', - Justification = 'Internal test helper function for packaging assertions.' -)] [CmdletBinding()] param() -$script:repositoryRoot = $null -$script:resolvedArtifactManifestPath = $null -$script:artifactManifestPath = $null - -function Test-YamlArtifactManifestAvailable { - param() - - if (-not [string]::IsNullOrWhiteSpace($env:PSMODULE_YAML_TEST_ARTIFACT)) { - return $true - } - $repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path - Test-Path -LiteralPath (Join-Path $repositoryRoot 'outputs\module\Yaml\Yaml.psd1') -PathType Leaf -} - -$artifactManifestAvailable = Test-YamlArtifactManifestAvailable +$importedYamlModule = Get-Module -Name Yaml | Select-Object -First 1 +$skipArtifactTests = [string]::IsNullOrWhiteSpace($env:PSMODULE_YAML_TEST_ARTIFACT) -and ( + $null -eq $importedYamlModule +) BeforeAll { . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') - $script:repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path - $defaultArtifactManifestPath = Join-Path $script:repositoryRoot 'outputs\module\Yaml\Yaml.psd1' - $script:resolvedArtifactManifestPath = if ( - Test-Path -LiteralPath $defaultArtifactManifestPath -PathType Leaf - ) { - (Resolve-Path -LiteralPath $defaultArtifactManifestPath).Path - } elseif (-not [string]::IsNullOrWhiteSpace($env:PSMODULE_YAML_TEST_ARTIFACT)) { - if (Test-Path -LiteralPath $env:PSMODULE_YAML_TEST_ARTIFACT -PathType Leaf) { - (Resolve-Path -LiteralPath $env:PSMODULE_YAML_TEST_ARTIFACT).Path - } else { - $null - } + $repositoryRoot = Split-Path -Parent $PSScriptRoot + $loadedYamlModule = $yamlModule + $artifactManifestPath = if ($null -ne $loadedYamlModule) { + Join-Path $loadedYamlModule.ModuleBase 'Yaml.psd1' } else { $null } - $script:artifactManifestPath = $script:resolvedArtifactManifestPath } Describe 'Dependency-free package source' { It 'contains no external parser assembly, license, or notice payload' { @( - Get-ChildItem -Path (Join-Path $script:repositoryRoot 'src\assemblies') ` + Get-ChildItem -Path (Join-Path $repositoryRoot 'src\assemblies') ` -File -ErrorAction SilentlyContinue ).Count | Should -Be 0 - Test-Path (Join-Path $script:repositoryRoot 'src\licenses\YamlDotNet.LICENSE.txt') | Should -BeFalse - Test-Path (Join-Path $script:repositoryRoot 'src\THIRD-PARTY-NOTICES.txt') | Should -BeFalse + Test-Path (Join-Path $repositoryRoot 'src\licenses\YamlDotNet.LICENSE.txt') | Should -BeFalse + Test-Path (Join-Path $repositoryRoot 'src\THIRD-PARTY-NOTICES.txt') | Should -BeFalse } It 'keeps RequiredAssemblies out of the source manifest' { $manifest = Import-PowerShellDataFile -Path ( - Join-Path $script:repositoryRoot 'src\manifest.psd1' + Join-Path $repositoryRoot 'src\manifest.psd1' ) $manifest.PowerShellVersion | Should -Be '7.6' @($manifest.CompatiblePSEditions) | Should -Be @('Core') + @($manifest.CompatiblePSEditions) | Should -Not -Contain 'Desktop' $manifest.ContainsKey('DotNetFrameworkVersion') | Should -BeFalse $manifest.ContainsKey('RequiredAssemblies') | Should -BeFalse } + It 'declares the generated artifact runtime once in the module header' { + $requirementPath = Join-Path $repositoryRoot 'src\header.ps1' + $source = Get-Content -LiteralPath $requirementPath -Raw + + $source | Should -Match '(?m)^#Requires -Version 7\.6\r?$' + $source | Should -Match '(?m)^#Requires -PSEdition Core\r?$' + } + It 'contains no external parser references or custom assembly loader' { - $sourceFiles = Get-ChildItem -Path (Join-Path $script:repositoryRoot 'src') -Recurse -File + $sourceFiles = Get-ChildItem -Path (Join-Path $repositoryRoot 'src') -Recurse -File $source = $sourceFiles | Where-Object Extension -In @('.ps1', '.psd1', '.psm1') | Get-Content -Raw @@ -84,7 +69,7 @@ Describe 'Dependency-free package source' { } It 'keeps the owned processor layers explicit and source-level' { - $privatePath = Join-Path $script:repositoryRoot 'src\functions\private' + $privatePath = Join-Path $repositoryRoot 'src\functions\private' @( 'New-YamlReaderContext.ps1', 'Read-YamlDirectiveBlock.ps1', @@ -103,7 +88,7 @@ Describe 'Dependency-free package source' { It 'uses Process-PSModule 6.1.13 and treats tests as important changes' { $workflow = Get-Content -Path ( - Join-Path $script:repositoryRoot '.github\workflows\Process-PSModule.yml' + Join-Path $repositoryRoot '.github\workflows\Process-PSModule.yml' ) -Raw $workflow | Should -Match 'workflow\.yml@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6\.1\.13' @@ -113,7 +98,7 @@ Describe 'Dependency-free package source' { It 'does not skip generated documentation' { $configuration = Get-Content -Path ( - Join-Path $script:repositoryRoot '.github\PSModule.yml' + Join-Path $repositoryRoot '.github\PSModule.yml' ) -Raw $configuration | Should -Not -Match '(?ms)Build:\s+Docs:\s+.*Skip:\s*true' @@ -121,43 +106,55 @@ Describe 'Dependency-free package source' { It 'uses zensical configuration and does not skip site build' { $configuration = Get-Content -Path ( - Join-Path $script:repositoryRoot '.github\PSModule.yml' + Join-Path $repositoryRoot '.github\PSModule.yml' ) -Raw $configuration | Should -Not -Match '(?ms)Build:\s+Site:\s+.*Skip:\s*true' - Test-Path -Path (Join-Path $script:repositoryRoot '.github\zensical.toml') | Should -BeTrue - Test-Path -Path (Join-Path $script:repositoryRoot '.github\mkdocs.yml') | Should -BeFalse + Test-Path -Path (Join-Path $repositoryRoot '.github\zensical.toml') | Should -BeTrue + Test-Path -Path (Join-Path $repositoryRoot '.github\mkdocs.yml') | Should -BeFalse } } Describe 'Generated artifact package' { It 'has no RequiredAssemblies or packaged DLL and has a complete FileList' ` - -Skip:(-not $artifactManifestAvailable) { - $manifest = Import-PowerShellDataFile -Path $script:artifactManifestPath - $moduleBase = Split-Path -Parent $script:artifactManifestPath + -Skip:$skipArtifactTests { + $manifest = Import-PowerShellDataFile -Path $artifactManifestPath + $moduleBase = Split-Path -Parent $artifactManifestPath $manifest.PowerShellVersion | Should -Be '7.6' @($manifest.CompatiblePSEditions) | Should -Be @('Core') + @($manifest.CompatiblePSEditions) | Should -Not -Contain 'Desktop' $manifest.ContainsKey('RequiredAssemblies') | Should -BeFalse $manifest.ContainsKey('DotNetFrameworkVersion') | Should -BeFalse @($manifest.FunctionsToExport | Sort-Object) | Should -Be @('ConvertFrom-Yaml', 'ConvertTo-Yaml', 'Test-Yaml') @($manifest.FileList) | Should -Contain 'Yaml.psm1' + $packagedFiles = @( + Get-ChildItem -Path $moduleBase -Recurse -File | + Where-Object FullName -NE $artifactManifestPath | + ForEach-Object { + [System.IO.Path]::GetRelativePath($moduleBase, $_.FullName) + } | + Sort-Object + ) + @($manifest.FileList | Sort-Object) | Should -Be $packagedFiles @($manifest.FileList | Where-Object { $_ -match '\.(?:dll|exe)$' }).Count | Should -Be 0 @($manifest.FileList | Where-Object { $_ -match 'THIRD-PARTY|YamlDotNet' }).Count | Should -Be 0 + @($manifest.PrivateData.PSData.Tags) | Should -Not -Contain 'PSEdition_Desktop' @(Get-ChildItem -Path $moduleBase -Recurse -File -Filter '*.dll').Count | Should -Be 0 { Test-ModuleManifest -Path $artifactManifestPath } | Should -Not -Throw } - It 'imports in a fresh PowerShell 7 process and preserves arrays, aliases, and depth' ` - -Skip:(( - -not $artifactManifestAvailable - ) -or ($null -eq (Get-Command pwsh -ErrorAction SilentlyContinue))) { + It 'imports in a fresh PowerShell 7.6 Core process and preserves arrays, aliases, and depth' ` + -Skip:($skipArtifactTests -or $null -eq (Get-Command pwsh -ErrorAction SilentlyContinue)) { $script = @' $ErrorActionPreference = 'Stop' $ps = $PSVersionTable.PSVersion -if ($ps.Major -lt 7 -or ($ps.Major -eq 7 -and $ps.Minor -lt 6)) { - throw "Expected PowerShell 7.6+ but got $ps." +if ($ps -lt [version] '7.6') { + throw "Expected PowerShell 7.6 or newer but got $ps." +} +if ($PSVersionTable.PSEdition -cne 'Core') { + throw "Expected PowerShell Core but got $($PSVersionTable.PSEdition)." } Import-Module -Name '__MANIFEST__' -Force $value = 'v: []' | ConvertFrom-Yaml -AsHashtable @@ -194,11 +191,21 @@ $deepYaml = ConvertTo-Yaml -InputObject $atLimit -Depth 128 -MaxNodes 300 if (-not (Test-Yaml -Yaml $deepYaml -Depth 128 -MaxNodes 300)) { throw 'The public maximum serialization depth failed.' } -'powershell-7-ok' -'@.Replace('__MANIFEST__', $script:artifactManifestPath.Replace("'", "''")) +"powershell-runtime=$ps;edition=$($PSVersionTable.PSEdition)" +'@.Replace('__MANIFEST__', $artifactManifestPath.Replace("'", "''")) + + $output = @(& pwsh -NoLogo -NoProfile -Command $script) + $runtime = $output | Where-Object { $_ -like 'powershell-runtime=*' } | + Select-Object -Last 1 - (& pwsh -NoLogo -NoProfile -Command $script) | - Should -Contain 'powershell-7-ok' + $runtimeMatch = [regex]::Match( + [string] $runtime, + '^powershell-runtime=(?\d+(?:\.\d+){1,3});edition=Core$' + ) + $runtimeMatch.Success | Should -BeTrue + ([version] $runtimeMatch.Groups['Version'].Value) -lt [version] '7.6' | + Should -BeFalse + Write-Information -MessageData $runtime -InformationAction Continue $LASTEXITCODE | Should -Be 0 } } diff --git a/tests/Specification.Tests.ps1 b/tests/Specification.Tests.ps1 index f316a65..c5257f9 100644 --- a/tests/Specification.Tests.ps1 +++ b/tests/Specification.Tests.ps1 @@ -202,5 +202,23 @@ Describe 'Pinned yaml-test-suite reference cases' { $result.canonical, $result.generic ) | Should -BeTrue + , $result.canonical | Should -BeOfType [byte[]] + , $result.generic | Should -BeOfType [byte[]] + [System.Convert]::ToHexString( + [System.Security.Cryptography.SHA256]::HashData($result.canonical) + ) | Should -Be '0DD8F84D24840A21A56495526E5B227911D13389109C62194A64B6CCBF3B1400' + } + + It 'projects the legacy ordered map in case J7PZ as an ordered dictionary' { + $result = @' +--- !!omap +- Mark McGwire: 65 +- Sammy Sosa: 63 +- Ken Griffy: 58 +'@ | ConvertFrom-Yaml + + $result | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + @($result.Keys) | Should -Be @('Mark McGwire', 'Sammy Sosa', 'Ken Griffy') + @($result.Values) | Should -Be @(65, 63, 58) } } diff --git a/tests/Test-Yaml.Tests.ps1 b/tests/Test-Yaml.Tests.ps1 index b3fcdd4..af2b143 100644 --- a/tests/Test-Yaml.Tests.ps1 +++ b/tests/Test-Yaml.Tests.ps1 @@ -99,13 +99,13 @@ Describe 'Test-Yaml' { } It 'does not swallow an unexpected runtime failure' { - $yamlCommand = Get-Command -Name Test-Yaml -ErrorAction Stop - if ([string]::IsNullOrEmpty($yamlCommand.ModuleName)) { + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { Mock Read-YamlStream { throw [System.InvalidOperationException]::new('unexpected runtime failure') } } else { - Mock Read-YamlStream -ModuleName $yamlCommand.ModuleName { + Mock Read-YamlStream -ModuleName $loadedModule.Name { throw [System.InvalidOperationException]::new('unexpected runtime failure') } } diff --git a/tests/TestBootstrap.ps1 b/tests/TestBootstrap.ps1 index 660a962..a6e671d 100644 --- a/tests/TestBootstrap.ps1 +++ b/tests/TestBootstrap.ps1 @@ -1,9 +1,23 @@ $artifactManifestOverride = $env:PSMODULE_YAML_TEST_ARTIFACT $yamlModule = $null if (-not [string]::IsNullOrWhiteSpace($artifactManifestOverride)) { - $yamlModule = Import-Module -Name $artifactManifestOverride -Force -Global -PassThru | - Where-Object Name -EQ 'Yaml' | + $resolvedArtifactManifestPath = ( + Resolve-Path -LiteralPath $artifactManifestOverride -ErrorAction Stop + ).Path + $artifactModuleBase = Split-Path -Parent $resolvedArtifactManifestPath + $yamlModule = Get-Module -Name Yaml | + Where-Object ModuleBase -EQ $artifactModuleBase | Select-Object -First 1 + if ($null -eq $yamlModule) { + $yamlModule = Import-Module -Name $resolvedArtifactManifestPath -Force -Global -PassThru ` + -ErrorAction Stop | + Where-Object Name -EQ 'Yaml' | + Select-Object -First 1 + } +} + +if ($null -eq $yamlModule) { + $yamlModule = Get-Module -Name Yaml | Select-Object -First 1 } if ($null -eq $yamlModule) { @@ -49,3 +63,34 @@ function Get-TestYamlFingerprintLength { } return @(& $loadedModule $implementation $Yaml) } + +function Get-TestYamlRepresentationRoot { + <# + .SYNOPSIS + Returns observable metadata from the root representation node. + #> + param ( + [Parameter(Mandatory)] + [string] $Yaml + ) + + $implementation = { + param ([string] $YamlText) + + $document = (Read-YamlStreamCore -Yaml $YamlText -Depth 100 -MaxNodes 100 -MaxAliases 100 ` + -MaxScalarLength 1048576 -MaxTagLength 1024 -MaxTotalTagLength 65536 ` + -MaxNumericLength 4096).Value[0] + [pscustomobject]@{ + Kind = $document.Kind + Tag = $document.Tag + HasUnknownTag = $document.HasUnknownTag + Value = $document.Value + } + } + + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + return & $implementation $Yaml + } + return & $loadedModule $implementation $Yaml +} diff --git a/tests/fixtures/yaml-test-suite/SOURCES.txt b/tests/fixtures/yaml-test-suite/SOURCES.txt index 48eaa8f..62c2e50 100644 --- a/tests/fixtures/yaml-test-suite/SOURCES.txt +++ b/tests/fixtures/yaml-test-suite/SOURCES.txt @@ -23,12 +23,18 @@ See LICENSE.txt for the upstream MIT license. The complete released data corpus is vendored as: yaml-test-suite-data-2022-01-17.zip -Data commit: - 6ad3d2c62885d82fc349026c136ef560838fdf3d -Generated from source commit: - 45db50ae +Latest source release: + v2022-01-17 (verified 2026-07-23) +Source release commit: + 45db50aecf9b1520f8258938c88f396e96f30831 +Data release: + data-2022-01-17 +Data release commit: + 6e6c296ae9c9d2d5c4134b4b64d01b29ac19ff6f +Archive source: + https://github.com/yaml/yaml-test-suite/archive/refs/tags/data-2022-01-17.zip Archive SHA-256: - DCC1F037B13F6C3032D5190C447B6A6EF5560738A4C104F29B4243B0AB8F8029 + 47C173AFFEB480517B30FB77DC8C76FD48609B9B65DD1C1D3D0D0BAEE48D6AA9 The archive contains 402 released inputs in 352 case directories. It is consumed locally by tests/tools/Invoke-YamlTestSuite.ps1 without network diff --git a/tests/fixtures/yaml-test-suite/yaml-test-suite-data-2022-01-17.zip b/tests/fixtures/yaml-test-suite/yaml-test-suite-data-2022-01-17.zip index e940eeb..d384622 100644 Binary files a/tests/fixtures/yaml-test-suite/yaml-test-suite-data-2022-01-17.zip and b/tests/fixtures/yaml-test-suite/yaml-test-suite-data-2022-01-17.zip differ diff --git a/tests/tools/Invoke-YamlTestSuite.ps1 b/tests/tools/Invoke-YamlTestSuite.ps1 index b9d28f4..0fa559a 100644 --- a/tests/tools/Invoke-YamlTestSuite.ps1 +++ b/tests/tools/Invoke-YamlTestSuite.ps1 @@ -242,23 +242,121 @@ function ConvertTo-YamlSuiteReferenceSignature { return ($output -join ';') } -function Get-YamlSuitePolicyReason { - [OutputType([string])] +function Test-YamlSuiteBinaryByteArrayProjection { + [OutputType([bool])] param ( - [string] $YamlText, - [string] $Expected, - [string] $Actual, - [string] $DefaultReason + [Parameter(Mandatory)] + [object[]] $ExpectedValues, + + [Parameter(Mandatory)] + [object[]] $ActualValues + ) + + if ($ExpectedValues.Count -ne 1 -or $ActualValues.Count -ne 1) { + return $false + } + $expectedDocument = $ExpectedValues[0] + $actualDocument = $ActualValues[0] + if ($expectedDocument -isnot [System.Collections.IDictionary] -or + $actualDocument -isnot [System.Collections.IDictionary] -or + $expectedDocument.Count -ne 3 -or $actualDocument.Count -ne 3) { + return $false + } + + foreach ($key in @('canonical', 'generic', 'description')) { + if (-not $expectedDocument.Contains($key) -or -not $actualDocument.Contains($key)) { + return $false + } + } + if ($expectedDocument['description'] -isnot [string] -or + $actualDocument['description'] -isnot [string] -or + $actualDocument['description'] -cne $expectedDocument['description']) { + return $false + } + + foreach ($key in @('canonical', 'generic')) { + if ($expectedDocument[$key] -isnot [string] -or + $actualDocument[$key] -isnot [byte[]]) { + return $false + } + $expectedBase64 = $expectedDocument[$key] -replace '\s', '' + $expectedBytes = [System.Convert]::FromBase64String($expectedBase64) + if (-not [System.Linq.Enumerable]::SequenceEqual[byte]( + $expectedBytes, + [byte[]] $actualDocument[$key] + )) { + return $false + } + } + return $true +} + +function Test-YamlSuiteLegacyOrderedMapProjection { + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [object[]] $ExpectedValues, + + [Parameter(Mandatory)] + [object[]] $ActualValues ) - if ($DefaultReason) { - return $DefaultReason + if ($ExpectedValues.Count -ne 1 -or $ActualValues.Count -ne 1 -or + $ExpectedValues[0] -is [System.Collections.IDictionary] -or + $ExpectedValues[0] -isnot [System.Collections.IEnumerable] -or + $ActualValues[0] -isnot [System.Collections.Specialized.OrderedDictionary]) { + return $false } - if ($YamlText -cmatch '!!(?:binary|omap|pairs|set|timestamp)(?:[ \t\r\n,\[\]\{\}]|$)') { - return 'StandardTagProjectionPolicy' + + $expectedEntries = @($ExpectedValues[0]) + $actualDocument = $ActualValues[0] + $actualKeys = @($actualDocument.Keys) + if ($expectedEntries.Count -ne $actualDocument.Count) { + return $false } - if (($Expected -match 'unsupported:' -or $Actual -match 'unsupported:')) { - return 'PowerShellTypePolicy' + for ($index = 0; $index -lt $expectedEntries.Count; $index++) { + $expectedEntry = $expectedEntries[$index] + if ($expectedEntry -isnot [System.Collections.IDictionary] -or + $expectedEntry.Count -ne 1) { + return $false + } + $expectedKey = @($expectedEntry.Keys)[0] + if ($expectedKey -isnot [string] -or $actualKeys[$index] -cne $expectedKey -or + (ConvertTo-YamlSuiteCanonicalValue -Value $actualDocument[$expectedKey]) -cne + (ConvertTo-YamlSuiteCanonicalValue -Value $expectedEntry[$expectedKey])) { + return $false + } + } + return $true +} + +function Get-YamlSuiteJsonPolicyReason { + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] $Case, + + [Parameter(Mandatory)] + [object[]] $ExpectedValues, + + [Parameter(Mandatory)] + [object[]] $ActualValues + ) + + switch -CaseSensitive ($Case) { + '565N' { + if (Test-YamlSuiteBinaryByteArrayProjection -ExpectedValues $ExpectedValues ` + -ActualValues $ActualValues) { + return 'BinaryByteArrayProjection' + } + } + 'J7PZ' { + if (Test-YamlSuiteLegacyOrderedMapProjection -ExpectedValues $ExpectedValues ` + -ActualValues $ActualValues) { + return 'LegacyOrderedMapProjection' + } + } + default { return '' } } return '' } @@ -688,9 +786,10 @@ foreach ($inputFile in $inputFiles) { } if ($expectsError) { $syntaxResult = 'Pass' - } elseif ($_.Exception.Data['YamlErrorId'] -eq 'YamlDuplicateKey') { + } elseif ($casePath -cin @('2JQS', 'X38W') -and + $_.Exception.Data['YamlErrorId'] -eq 'YamlDuplicateKey') { $syntaxResult = 'PolicyDifference' - $syntaxReason = 'DuplicateKeyRejected' + $syntaxReason = 'RepresentationMappingKeyUniqueness' } else { $syntaxResult = 'Fail' $syntaxReason = [string] $_.Exception.Data['YamlErrorId'] @@ -726,15 +825,8 @@ foreach ($inputFile in $inputFiles) { if (Compare-YamlSuiteCanonicalList -Left $actualEvents -Right $expectedEvents) { $eventResult = 'Pass' } else { - $reason = Get-YamlSuitePolicyReason -YamlText $yaml -Expected ($expectedEvents -join "`n") ` - -Actual ($actualEvents -join "`n") -DefaultReason '' - if ($reason) { - $eventResult = 'PolicyDifference' - $eventReason = $reason - } else { - $eventResult = 'Fail' - $eventReason = 'EventMismatch' - } + $eventResult = 'Fail' + $eventReason = 'EventMismatch' } } } @@ -759,8 +851,9 @@ foreach ($inputFile in $inputFiles) { if ($projectedCanonical -ceq $expectedCanonical) { $jsonResult = 'Pass' } else { - $reason = Get-YamlSuitePolicyReason -YamlText $yaml -Expected $expectedCanonical ` - -Actual $projectedCanonical -DefaultReason '' + $reason = Get-YamlSuiteJsonPolicyReason -Case $casePath ` + -ExpectedValues ([object[]] $expectedValues.ToArray()) ` + -ActualValues ([object[]] $projectedValues) if ($reason) { $jsonResult = 'PolicyDifference' $jsonReason = $reason @@ -788,15 +881,8 @@ foreach ($inputFile in $inputFiles) { if ($outCanonical -ceq $projectedCanonical) { $outYamlResult = 'Pass' } else { - $reason = Get-YamlSuitePolicyReason -YamlText $yaml -Expected $projectedCanonical ` - -Actual $outCanonical -DefaultReason '' - if ($reason) { - $outYamlResult = 'PolicyDifference' - $outYamlReason = $reason - } else { - $outYamlResult = 'Fail' - $outYamlReason = 'OutYamlConstructionMismatch' - } + $outYamlResult = 'Fail' + $outYamlReason = 'OutYamlConstructionMismatch' } } catch { if ($_.Exception.Data.Contains('IsYamlException')) { @@ -814,7 +900,7 @@ foreach ($inputFile in $inputFiles) { $emitResult = 'NotApplicable' if ($expectsError) { $emitReason = 'InvalidSyntax' } } elseif ($projectionError) { - $emitResult = 'PolicyDifference' + $emitResult = 'Fail' $emitReason = $projectionError } elseif ($syntaxResult -eq 'PolicyDifference') { $emitResult = 'PolicyDifference' @@ -849,19 +935,12 @@ foreach ($inputFile in $inputFiles) { if ($roundCanonical -ceq $projectedCanonical -and $roundReference -ceq $projectedReference) { $emitResult = 'Pass' } else { - $reason = Get-YamlSuitePolicyReason -YamlText $yaml -Expected $projectedCanonical ` - -Actual $roundCanonical -DefaultReason '' - if ($reason) { - $emitResult = 'PolicyDifference' - $emitReason = $reason - } else { - $emitResult = 'Fail' - $emitReason = 'EmitRoundTripMismatch' - } + $emitResult = 'Fail' + $emitReason = 'EmitRoundTripMismatch' } } } catch [System.NotSupportedException] { - $emitResult = 'PolicyDifference' + $emitResult = 'Fail' $emitReason = 'UnsupportedEmissionType' } catch { if ($_.Exception.Data.Contains('IsYamlException')) {