tests/WorkflowCompiler-Backend.Tests.ps1

#Requires -Version 7
#Requires -Modules Pester

BeforeAll {
    Import-Module (Join-Path $PSScriptRoot '../CpmfUipsPack.psd1') -Force

    # A stand-in compiler binary. Resolve-CpmfUipsWorkflowCompiler only checks
    # that the path exists; the process itself is always mocked away.
    $script:fakeCompiler = Join-Path ([System.IO.Path]::GetTempPath()) "CpmfUipsFakeCompiler-$(New-Guid).exe"
    Set-Content -LiteralPath $script:fakeCompiler -Value 'not a real binary'

    # Materialise the fixture props with the placeholder pointing at it.
    $fixture = Get-Content (Join-Path $PSScriptRoot 'fixtures/publisher/test-publisher.props') -Raw
    $script:propsPath = Join-Path ([System.IO.Path]::GetTempPath()) "CpmfUipsProps-$(New-Guid).props"
    # String.Replace, not -replace: a Windows path is full of regex metacharacters.
    Set-Content -LiteralPath $script:propsPath -Value $fixture.Replace('__COMPILER_PATH__', $script:fakeCompiler)
}

AfterAll {
    Remove-Item $script:fakeCompiler -Force -ErrorAction SilentlyContinue
    Remove-Item $script:propsPath -Force -ErrorAction SilentlyContinue
}

Describe 'Get-CpmfUipsPublisherProps' {

    It 'flattens every PropertyGroup into one lookup' {
        InModuleScope CpmfUipsPack -Parameters @{ p = $script:propsPath } {
            param($p)
            $props = Get-CpmfUipsPublisherProps -Path $p
            $props['PublisherAuthors']            | Should -Be 'Test Author'
            $props['PublisherPackPolicyFileType'] | Should -Be 'Default'
        }
    }

    It 'drops empty elements so callers can tell unset from set' {
        InModuleScope CpmfUipsPack -Parameters @{ p = $script:propsPath } {
            param($p)
            (Get-CpmfUipsPublisherProps -Path $p).ContainsKey('PublisherIconUrl') | Should -BeFalse
        }
    }

    It 'returns an empty hashtable for an empty path' {
        InModuleScope CpmfUipsPack {
            (Get-CpmfUipsPublisherProps -Path '').Count | Should -Be 0
        }
    }

    It 'throws when a path is given but missing' {
        InModuleScope CpmfUipsPack {
            { Get-CpmfUipsPublisherProps -Path 'X:\nope\missing.props' } | Should -Throw '*not found*'
        }
    }
}

Describe 'Resolve-CpmfUipsWorkflowCompiler' {

    It 'runs a .dll through the dotnet host' {
        InModuleScope CpmfUipsPack {
            $dll = Join-Path ([System.IO.Path]::GetTempPath()) "CpmfUipsFake-$(New-Guid).dll"
            Set-Content -LiteralPath $dll -Value 'x'
            try {
                $r = Resolve-CpmfUipsWorkflowCompiler -WorkflowCompilerPath $dll -DotnetPath 'dotnet'
                $r.FilePath        | Should -Be 'dotnet'
                $r.ArgumentPrefix  | Should -HaveCount 1
                $r.ArgumentPrefix[0] | Should -BeLike '*.dll'
            } finally { Remove-Item $dll -Force -ErrorAction SilentlyContinue }
        }
    }

    It 'runs an .exe directly with no dotnet host' {
        InModuleScope CpmfUipsPack -Parameters @{ exe = $script:fakeCompiler } {
            param($exe)
            $r = Resolve-CpmfUipsWorkflowCompiler -WorkflowCompilerPath $exe
            $r.FilePath       | Should -Be $exe
            $r.ArgumentPrefix | Should -HaveCount 0
        }
    }

    It 'falls back to the props file when no explicit path is given' {
        InModuleScope CpmfUipsPack -Parameters @{ exe = $script:fakeCompiler } {
            param($exe)
            $r = Resolve-CpmfUipsWorkflowCompiler -PublisherProps @{ PublisherPackWorkflowCompilerPath = $exe }
            $r.FilePath | Should -Be $exe
        }
    }

    It 'throws a message naming all three sources when nothing is configured' {
        InModuleScope CpmfUipsPack {
            $saved = $env:UIPATH_WORKFLOWCOMPILER_LOCATION
            Remove-Item Env:\UIPATH_WORKFLOWCOMPILER_LOCATION -ErrorAction SilentlyContinue
            try {
                { Resolve-CpmfUipsWorkflowCompiler } | Should -Throw '*UIPATH_WORKFLOWCOMPILER_LOCATION*'
            } finally {
                if ($saved) { $env:UIPATH_WORKFLOWCOMPILER_LOCATION = $saved }
            }
        }
    }
}

Describe 'Invoke-WorkflowCompilerPack argument construction' {

    BeforeEach {
        $script:tmpRoot     = Join-Path ([System.IO.Path]::GetTempPath()) "CpmfUipsWcTest-$(New-Guid)"
        $script:tmpFeed     = Join-Path ([System.IO.Path]::GetTempPath()) "CpmfUipsWcFeed-$(New-Guid)"
        $script:projectJson = Join-Path $script:tmpRoot 'project.json'
        $null = New-Item -ItemType Directory -Path $script:tmpRoot -Force
        Set-Content $script:projectJson '{"projectVersion": "1.0.0", "name": "TestProject"}'
    }

    AfterEach {
        Remove-Item $script:tmpRoot -Recurse -Force -ErrorAction SilentlyContinue
        Remove-Item $script:tmpFeed -Recurse -Force -ErrorAction SilentlyContinue
    }

    It 'excludes sources by default — the compiler default is the opposite' {
        InModuleScope CpmfUipsPack -Parameters @{ pj = $script:projectJson; feed = $script:tmpFeed; exe = $script:fakeCompiler } {
            param($pj, $feed, $exe)
            $script:args = $null
            Mock Invoke-NativeCommandCapture {
                param($FilePath, $ArgumentList, $WorkingDirectory)
                $script:args = $ArgumentList
                $outDir = $ArgumentList[$ArgumentList.IndexOf('-o') + 1]
                New-Item -ItemType File -Path (Join-Path $outDir 'TestProject.1.1.0.nupkg') -Force | Out-Null
                [pscustomobject]@{ ExitCode = 0; StdOutLines = @('{"Type":"Result","Success":true}'); StdErrLines = @() }
            }

            Invoke-PackAndStage -ProjectJson $pj -FeedPath $feed -UipcliArgs @() -UipcliExe 'unused.exe' `
                -Backend workflowcompiler `
                -WorkflowCompiler @{ FilePath = $exe; ArgumentPrefix = @() } `
                -PackOptions @{ id = 'TestProject' } -PackSettings @{ IncludeSources = $false } | Out-Null

            $i = $script:args.IndexOf('--include-sources')
            $i | Should -BeGreaterThan -1
            $script:args[$i + 1] | Should -Be 'false'
        }
    }

    It 'passes --include-sources true when explicitly requested' {
        InModuleScope CpmfUipsPack -Parameters @{ pj = $script:projectJson; feed = $script:tmpFeed; exe = $script:fakeCompiler } {
            param($pj, $feed, $exe)
            $script:args = $null
            Mock Invoke-NativeCommandCapture {
                param($FilePath, $ArgumentList, $WorkingDirectory)
                $script:args = $ArgumentList
                $outDir = $ArgumentList[$ArgumentList.IndexOf('-o') + 1]
                New-Item -ItemType File -Path (Join-Path $outDir 'TestProject.1.1.0.nupkg') -Force | Out-Null
                [pscustomobject]@{ ExitCode = 0; StdOutLines = @(); StdErrLines = @() }
            }

            Invoke-PackAndStage -ProjectJson $pj -FeedPath $feed -UipcliArgs @() -UipcliExe 'unused.exe' `
                -Backend workflowcompiler `
                -WorkflowCompiler @{ FilePath = $exe; ArgumentPrefix = @() } `
                -PackOptions @{ id = 'TestProject' } -PackSettings @{ IncludeSources = $true } | Out-Null

            $script:args[$script:args.IndexOf('--include-sources') + 1] | Should -Be 'true'
        }
    }

    It 'sends the project directory, not project.json, and writes identity to a pack-options file' {
        InModuleScope CpmfUipsPack -Parameters @{ pj = $script:projectJson; feed = $script:tmpFeed; exe = $script:fakeCompiler } {
            param($pj, $feed, $exe)
            $script:args = $null
            $script:packJson = $null
            Mock Invoke-NativeCommandCapture {
                param($FilePath, $ArgumentList, $WorkingDirectory)
                $script:args = $ArgumentList
                # Read the options file while it still exists — it is deleted in finally.
                $optFile = $ArgumentList[$ArgumentList.IndexOf('--pack-options-file') + 1]
                $script:packJson = Get-Content -LiteralPath $optFile -Raw | ConvertFrom-Json
                $outDir = $ArgumentList[$ArgumentList.IndexOf('-o') + 1]
                New-Item -ItemType File -Path (Join-Path $outDir 'TestProject.1.1.0.nupkg') -Force | Out-Null
                [pscustomobject]@{ ExitCode = 0; StdOutLines = @(); StdErrLines = @() }
            }

            Invoke-PackAndStage -ProjectJson $pj -FeedPath $feed -UipcliArgs @() -UipcliExe 'unused.exe' `
                -Backend workflowcompiler `
                -WorkflowCompiler @{ FilePath = $exe; ArgumentPrefix = @() } `
                -PackOptions @{
                    id = 'RPAForge.Thing'; author = 'Test Author'; tags = 'a b'
                    projectUrl = 'https://example.invalid'; repositoryType = 'git'
                } `
                -PackSettings @{ IncludeSources = $false } | Out-Null

            $script:args[$script:args.IndexOf('-p') + 1] | Should -Be (Split-Path -Parent $pj)
            $script:args | Should -Contain '--package-name'
            $script:args[$script:args.IndexOf('--package-name') + 1] | Should -Be 'RPAForge.Thing'

            $script:packJson.id         | Should -Be 'RPAForge.Thing'
            $script:packJson.author     | Should -Be 'Test Author'
            $script:packJson.projectUrl | Should -Be 'https://example.invalid'
            # Version is only known after the bump, and is injected there.
            $script:packJson.version    | Should -Be '1.1.0'
        }
    }

    It 'omits empty identity fields rather than stamping empty strings' {
        InModuleScope CpmfUipsPack -Parameters @{ pj = $script:projectJson; feed = $script:tmpFeed; exe = $script:fakeCompiler } {
            param($pj, $feed, $exe)
            $script:packJson = $null
            Mock Invoke-NativeCommandCapture {
                param($FilePath, $ArgumentList, $WorkingDirectory)
                $optFile = $ArgumentList[$ArgumentList.IndexOf('--pack-options-file') + 1]
                $script:packJson = Get-Content -LiteralPath $optFile -Raw | ConvertFrom-Json
                $outDir = $ArgumentList[$ArgumentList.IndexOf('-o') + 1]
                New-Item -ItemType File -Path (Join-Path $outDir 'TestProject.1.1.0.nupkg') -Force | Out-Null
                [pscustomobject]@{ ExitCode = 0; StdOutLines = @(); StdErrLines = @() }
            }

            Invoke-PackAndStage -ProjectJson $pj -FeedPath $feed -UipcliArgs @() -UipcliExe 'unused.exe' `
                -Backend workflowcompiler `
                -WorkflowCompiler @{ FilePath = $exe; ArgumentPrefix = @() } `
                -PackOptions @{ id = 'TestProject'; author = ''; iconUrl = '' } `
                -PackSettings @{ IncludeSources = $false } | Out-Null

            $script:packJson.PSObject.Properties.Name | Should -Not -Contain 'author'
            $script:packJson.PSObject.Properties.Name | Should -Not -Contain 'iconUrl'
        }
    }
}

Describe 'Invoke-CpmfUipsPack — workflowcompiler backend' {

    BeforeEach {
        $script:tmpRoot     = Join-Path ([System.IO.Path]::GetTempPath()) "CpmfUipsWcE2E-$(New-Guid)"
        $script:tmpFeed     = Join-Path ([System.IO.Path]::GetTempPath()) "CpmfUipsWcE2EFeed-$(New-Guid)"
        $script:projectJson = Join-Path $script:tmpRoot 'project.json'
        $null = New-Item -ItemType Directory -Path $script:tmpRoot -Force
        Set-Content $script:projectJson '{"projectVersion": "1.0.0", "name": "TestProject"}'
    }

    AfterEach {
        Remove-Item $script:tmpRoot -Recurse -Force -ErrorAction SilentlyContinue
        Remove-Item $script:tmpFeed -Recurse -Force -ErrorAction SilentlyContinue
    }

    It 'reads identity and policy from the publisher props file' {
        InModuleScope CpmfUipsPack -Parameters @{ pj = $script:projectJson; feed = $script:tmpFeed; props = $script:propsPath } {
            param($pj, $feed, $props)
            $script:packJson = $null
            $script:args = $null
            Mock Test-CpmfUipsPackPrerequisites { }
            Mock Invoke-WithFileLock { param($LockFile, $ScriptBlock) & $ScriptBlock }
            Mock Get-CpmfUipsToolPaths { @{ UipcliExe = 'fake.exe'; DotnetDir = 'C:\fake' } }
            Mock Get-CpmfUipsGitMetadata { @{ RepositoryUrl = 'https://git.invalid/r.git'; RepositoryBranch = 'main'; RepositoryCommit = 'abc1234' } }
            Mock Install-CpmfUipsPackCommandLineTool { throw 'must not install for this backend' }
            Mock Invoke-NativeCommandCapture {
                param($FilePath, $ArgumentList, $WorkingDirectory)
                $script:args = $ArgumentList
                $optFile = $ArgumentList[$ArgumentList.IndexOf('--pack-options-file') + 1]
                $script:packJson = Get-Content -LiteralPath $optFile -Raw | ConvertFrom-Json
                $outDir = $ArgumentList[$ArgumentList.IndexOf('-o') + 1]
                New-Item -ItemType File -Path (Join-Path $outDir 'TestProject.1.1.0.nupkg') -Force | Out-Null
                [pscustomobject]@{ ExitCode = 0; StdOutLines = @(); StdErrLines = @() }
            }

            Invoke-CpmfUipsPack -ProjectJson $pj -FeedPath $feed -Targets net8 `
                -Backend workflowcompiler -PublisherProps $props -PackageId 'CPMForge.Thing' | Out-Null

            $script:packJson.author         | Should -Be 'Test Author'
            $script:packJson.tags           | Should -Be 'testforge uipath rpa'
            $script:packJson.projectUrl     | Should -Be 'https://github.com/testforge'
            $script:packJson.repositoryType | Should -Be 'git'
            # Repository specifics come from git, not from shared identity.
            $script:packJson.repositoryUrl    | Should -Be 'https://git.invalid/r.git'
            $script:packJson.repositoryCommit | Should -Be 'abc1234'
            # PublisherPackSkipAnalyze is true in the fixture.
            $script:args[$script:args.IndexOf('--skip-analyze') + 1] | Should -Be 'true'
            $script:args[$script:args.IndexOf('--include-sources') + 1] | Should -Be 'false'
        }
    }

    It 'never emits copyright or licence — the compiler has no field for them' {
        InModuleScope CpmfUipsPack -Parameters @{ pj = $script:projectJson; feed = $script:tmpFeed; props = $script:propsPath } {
            param($pj, $feed, $props)
            $script:packJson = $null
            Mock Test-CpmfUipsPackPrerequisites { }
            Mock Invoke-WithFileLock { param($LockFile, $ScriptBlock) & $ScriptBlock }
            Mock Get-CpmfUipsToolPaths { @{ UipcliExe = 'fake.exe'; DotnetDir = 'C:\fake' } }
            Mock Get-CpmfUipsGitMetadata { @{} }
            Mock Invoke-NativeCommandCapture {
                param($FilePath, $ArgumentList, $WorkingDirectory)
                $optFile = $ArgumentList[$ArgumentList.IndexOf('--pack-options-file') + 1]
                $script:packJson = Get-Content -LiteralPath $optFile -Raw
                $outDir = $ArgumentList[$ArgumentList.IndexOf('-o') + 1]
                New-Item -ItemType File -Path (Join-Path $outDir 'TestProject.1.1.0.nupkg') -Force | Out-Null
                [pscustomobject]@{ ExitCode = 0; StdOutLines = @(); StdErrLines = @() }
            }

            Invoke-CpmfUipsPack -ProjectJson $pj -FeedPath $feed -Targets net8 `
                -Backend workflowcompiler -PublisherProps $props | Out-Null

            $script:packJson | Should -Not -Match 'Copyright'
            $script:packJson | Should -Not -Match 'Apache'
        }
    }

    It 'lets an explicit parameter win over the props file' {
        InModuleScope CpmfUipsPack -Parameters @{ pj = $script:projectJson; feed = $script:tmpFeed; props = $script:propsPath } {
            param($pj, $feed, $props)
            $script:packJson = $null
            Mock Test-CpmfUipsPackPrerequisites { }
            Mock Invoke-WithFileLock { param($LockFile, $ScriptBlock) & $ScriptBlock }
            Mock Get-CpmfUipsToolPaths { @{ UipcliExe = 'fake.exe'; DotnetDir = 'C:\fake' } }
            Mock Get-CpmfUipsGitMetadata { @{} }
            Mock Invoke-NativeCommandCapture {
                param($FilePath, $ArgumentList, $WorkingDirectory)
                $optFile = $ArgumentList[$ArgumentList.IndexOf('--pack-options-file') + 1]
                $script:packJson = Get-Content -LiteralPath $optFile -Raw | ConvertFrom-Json
                $outDir = $ArgumentList[$ArgumentList.IndexOf('-o') + 1]
                New-Item -ItemType File -Path (Join-Path $outDir 'TestProject.1.1.0.nupkg') -Force | Out-Null
                [pscustomobject]@{ ExitCode = 0; StdOutLines = @(); StdErrLines = @() }
            }

            Invoke-CpmfUipsPack -ProjectJson $pj -FeedPath $feed -Targets net8 `
                -Backend workflowcompiler -PublisherProps $props -PackageAuthor 'Explicit Wins' | Out-Null

            $script:packJson.author | Should -Be 'Explicit Wins'
        }
    }

    It 'rejects -MultiTfm and net6, which the single net8 compiler cannot serve' {
        InModuleScope CpmfUipsPack -Parameters @{ pj = $script:projectJson; feed = $script:tmpFeed; props = $script:propsPath } {
            param($pj, $feed, $props)
            Mock Test-CpmfUipsPackPrerequisites { }
            Mock Invoke-WithFileLock { param($LockFile, $ScriptBlock) & $ScriptBlock }

            { Invoke-CpmfUipsPack -ProjectJson $pj -FeedPath $feed -Backend workflowcompiler `
                -PublisherProps $props -Targets net6, net8 -MultiTfm } | Should -Throw '*MultiTfm*'

            { Invoke-CpmfUipsPack -ProjectJson $pj -FeedPath $feed -Backend workflowcompiler `
                -PublisherProps $props -Targets net6 } | Should -Throw '*net6*'
        }
    }

    It 'is the default backend, at the default net8 target' {
        InModuleScope CpmfUipsPack -Parameters @{ pj = $script:projectJson; feed = $script:tmpFeed; props = $script:propsPath } {
            param($pj, $feed, $props)
            $script:args = $null
            Mock Test-CpmfUipsPackPrerequisites { }
            Mock Invoke-WithFileLock { param($LockFile, $ScriptBlock) & $ScriptBlock }
            Mock Get-CpmfUipsToolPaths { @{ UipcliExe = 'fake.exe'; DotnetDir = 'C:\fake' } }
            Mock Get-CpmfUipsGitMetadata { @{} }
            Mock Invoke-UipcliPack { throw 'uipcli must not be the default backend' }
            Mock Invoke-NativeCommandCapture {
                param($FilePath, $ArgumentList, $WorkingDirectory)
                $script:args = $ArgumentList
                $outDir = $ArgumentList[$ArgumentList.IndexOf('-o') + 1]
                New-Item -ItemType File -Path (Join-Path $outDir 'TestProject.1.1.0.nupkg') -Force | Out-Null
                [pscustomobject]@{ ExitCode = 0; StdOutLines = @(); StdErrLines = @() }
            }

            # No -Backend, no -Targets: both must default.
            Invoke-CpmfUipsPack -ProjectJson $pj -FeedPath $feed -PublisherProps $props | Out-Null

            $script:args | Should -Contain 'build'
            $script:args[$script:args.IndexOf('--include-sources') + 1] | Should -Be 'false'
            Should -Invoke Get-CpmfUipsToolPaths -Times 1 -ParameterFilter { $CliVersion -notlike '23.*' }
        }
    }

    It 'survives a session where no native command has run yet ($LASTEXITCODE unset)' {
        # Set-StrictMode -Version Latest makes reading an unset $LASTEXITCODE a
        # terminating error, and it does not exist until a native command runs.
        $probe = Join-Path ([System.IO.Path]::GetTempPath()) "CpmfUipsLec-$(New-Guid).ps1"
        $manifest = Join-Path $PSScriptRoot '../CpmfUipsPack.psd1'
        @"
Set-StrictMode -Version Latest
Import-Module '$manifest' -Force
if (Get-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue) { throw 'precondition: LASTEXITCODE already set' }
InModuleScope CpmfUipsPack { (Get-CpmfUipsGitMetadata -Path '$PSScriptRoot').Count } | Out-Null
'OK'
"@
 | Set-Content -LiteralPath $probe
        try {
            $out = & (Get-Command pwsh).Source -NoProfile -File $probe 2>&1
            ($out -join "`n") | Should -Match 'OK'
        } finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue }
    }

    It 'warns that uipcli cannot honour source-inclusion or identity settings' {
        InModuleScope CpmfUipsPack -Parameters @{ pj = $script:projectJson; feed = $script:tmpFeed } {
            param($pj, $feed)
            Mock Test-CpmfUipsPackPrerequisites { }
            Mock Invoke-WithFileLock { param($LockFile, $ScriptBlock) & $ScriptBlock }
            Mock Get-CpmfUipsToolPaths { @{ UipcliExe = 'fake.exe'; DotnetDir = 'C:\fake' } }
            Mock Install-CpmfUipsPackCommandLineTool { }
            Mock Invoke-UipcliPack {
                param($UipcliExe, $PackArgs)
                New-Item -ItemType File -Path (Join-Path $PackArgs[$PackArgs.IndexOf('-o') + 1] 'TestProject.1.1.0.nupkg') -Force | Out-Null
                return 0
            }

            $warnings = @()
            Invoke-CpmfUipsPack -ProjectJson $pj -FeedPath $feed -Targets net8 `
                -Backend uipcli -IncludeSources:$false -PackageId 'Ignored.Id' `
                -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null

            ($warnings -join "`n") | Should -Match 'cannot control source inclusion'
            ($warnings -join "`n") | Should -Match '-PackageId'
        }
    }

    It 'throws a clear message for analyze, which this backend does not implement' {
        InModuleScope CpmfUipsPack {
            { Invoke-CliBackend -Op analyze -Backend workflowcompiler -ProjectJson 'C:\x\project.json' } |
                Should -Throw '*does not support analyze*'
        }
    }
}