public/WinOS/New-WinpeStartupProfile.Tests.ps1

BeforeAll {
    . "$PSScriptRoot\New-WinpeStartupProfile.ps1"
    . "$PSScriptRoot\..\WinPE\Invoke-WinpeStartup.ps1"
    $script:bootAssetsPath = 'C:\ProgramData\OSDeployCore\boot-assets\winpestartup-profiles'
    if (-not ('WinpeStartupProfileTests.DecliningHost' -as [type])) {
        Add-Type -TypeDefinition @'
using System;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Management.Automation;
using System.Management.Automation.Host;
using System.Security;
 
namespace WinpeStartupProfileTests {
    public sealed class DecliningHost : PSHost {
        private readonly PSHost inner;
        private readonly DecliningUI ui;
        public DecliningHost(PSHost inner) { this.inner = inner; ui = new DecliningUI(inner.UI); }
        public int PromptCount { get { return ui.PromptCount; } }
        public override Guid InstanceId { get { return inner.InstanceId; } }
        public override string Name { get { return "DecliningProfileTestHost"; } }
        public override Version Version { get { return inner.Version; } }
        public override CultureInfo CurrentCulture { get { return inner.CurrentCulture; } }
        public override CultureInfo CurrentUICulture { get { return inner.CurrentUICulture; } }
        public override PSHostUserInterface UI { get { return ui; } }
        public override void SetShouldExit(int code) { throw new NotSupportedException(); }
        public override void EnterNestedPrompt() { throw new NotSupportedException(); }
        public override void ExitNestedPrompt() { throw new NotSupportedException(); }
        public override void NotifyBeginApplication() { inner.NotifyBeginApplication(); }
        public override void NotifyEndApplication() { inner.NotifyEndApplication(); }
    }
    public sealed class DecliningUI : PSHostUserInterface {
        private readonly PSHostUserInterface inner;
        public DecliningUI(PSHostUserInterface inner) { this.inner = inner; }
        public int PromptCount { get; private set; }
        public override PSHostRawUserInterface RawUI { get { return inner.RawUI; } }
        public override int PromptForChoice(string caption, string message, Collection<ChoiceDescription> choices, int defaultChoice) {
            PromptCount++;
            for (int i = 0; i < choices.Count; i++) {
                if (choices[i].Label.Replace("&", "") == "No") { return i; }
            }
            throw new InvalidOperationException("Expected a No confirmation choice.");
        }
        public override string ReadLine() { throw new NotSupportedException(); }
        public override SecureString ReadLineAsSecureString() { throw new NotSupportedException(); }
        public override System.Collections.Generic.Dictionary<string, PSObject> Prompt(string caption, string message, Collection<FieldDescription> fields) { throw new NotSupportedException(); }
        public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) { throw new NotSupportedException(); }
        public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes types, PSCredentialUIOptions options) { throw new NotSupportedException(); }
        public override void Write(string value) { inner.Write(value); }
        public override void Write(ConsoleColor foreground, ConsoleColor background, string value) { inner.Write(foreground, background, value); }
        public override void WriteLine(string value) { inner.WriteLine(value); }
        public override void WriteErrorLine(string value) { inner.WriteErrorLine(value); }
        public override void WriteDebugLine(string value) { inner.WriteDebugLine(value); }
        public override void WriteVerboseLine(string value) { inner.WriteVerboseLine(value); }
        public override void WriteWarningLine(string value) { inner.WriteWarningLine(value); }
        public override void WriteProgress(long sourceId, ProgressRecord record) { inner.WriteProgress(sourceId, record); }
    }
}
'@

    }
}

Describe 'New-WinpeStartupProfile' {
    BeforeEach {
        $script:bootExists = $false
        $script:elevated = $true
        $script:testRoot = [System.IO.Path]::Combine($TestDrive, [guid]::NewGuid().ToString('N'))
        $script:bootRoot = [System.IO.Path]::Combine($script:testRoot, 'BootAssets')
        $script:volumes = @([pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = 'F' })
        $script:disk = [pscustomobject]@{ BusType = 'USB'; IsReadOnly = $false; IsOffline = $false }
        $script:partition = [pscustomobject]@{ DiskNumber = 7; IsReadOnly = $false; IsOffline = $false }
        Mock Test-Path { $script:bootExists } -ParameterFilter { $LiteralPath -eq $script:bootAssetsPath }
        Mock Join-Path {
            [System.IO.Path]::Combine($script:bootRoot, $ChildPath)
        } -ParameterFilter { $Path -eq $script:bootAssetsPath }
        Mock Join-Path {
            [System.IO.Path]::Combine($script:testRoot, "USB-$($Path.Substring(0, 1))", $ChildPath)
        } -ParameterFilter { $Path -match '^[A-Z]:\\$' }
        Mock Get-Volume { $script:volumes }
        Mock Get-Partition { $script:partition }
        Mock Get-Disk { $script:disk }
        Mock Start-Process {}
        Mock New-Object {
            $principal = [pscustomobject]@{}
            $principal | Add-Member -MemberType ScriptMethod -Name IsInRole -Value { $script:elevated }
            $principal
        } -ParameterFilter { $TypeName -eq 'System.Security.Principal.WindowsPrincipal' }
    }

    It 'exposes every runtime setting with the same type and error-action validation' {
        $runtime = Get-Command Invoke-WinpeStartup
        $author = Get-Command New-WinpeStartupProfile
        $common = [System.Management.Automation.PSCmdlet]::CommonParameters
        $settings = @($runtime.Parameters.Keys | Where-Object { $_ -notin $common })
        $settings.Count | Should -Be 16
        foreach ($name in $settings) {
            $author.Parameters[$name].ParameterType | Should -Be $runtime.Parameters[$name].ParameterType
            if ($name -like '*EA') {
                ($author.Parameters[$name].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }).ValidValues |
                    Should -Be @('Continue', 'Stop')
            }
        }
    }

    It 'writes all 16 settings using exact names and native JSON types without authoring or common parameters' {
        $settings = @{
            ProfileName = 'AllSettings'
            SkipOnScreenKeyboard = $true
            ShowPnpDevices = $false
            ShowPnpErrors = $true
            SkipWiFi = $false
            SkipIPConfig = $true
            SkipUpdateOSDCloud = $false
            InstallModule = @('ModuleOne', 'ModuleTwo')
            InvokeStartupCommand = @('Write-Output preparation', 'https://example.invalid/start.ps1')
            InvokeStartupCommandNoExit = $true
            InvokeStartupCommandEA = 'Stop'
            InvokeMainCommand = @('Write-Output main')
            InvokeMainCommandNoExit = $false
            InvokeMainCommandEA = 'Continue'
            InvokeShutdownCommand = @()
            InvokeShutdownCommandNoExit = $true
            InvokeShutdownCommandEA = 'Stop'
            PassThru = $true
            Force = $true
            Confirm = $false
            Verbose = $false
            ErrorAction = 'Stop'
        }
        $file = New-WinpeStartupProfile @settings
        $file | Should -BeOfType ([System.IO.FileInfo])
        $file.FullName | Should -Be ([System.IO.Path]::Combine($script:testRoot, 'USB-F', 'WinpeStartup', 'Profiles', 'AllSettings', 'winpestartup.json'))
        $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json
        @($savedSettings.PSObject.Properties).Count | Should -Be 17
        foreach ($name in @('SkipOnScreenKeyboard', 'ShowPnpDevices', 'ShowPnpErrors', 'SkipWiFi', 'SkipIPConfig', 'SkipUpdateOSDCloud',
            'InvokeStartupCommandNoExit', 'InvokeMainCommandNoExit', 'InvokeShutdownCommandNoExit')) {
            $savedSettings."Invoke-WinpeStartup:$name" | Should -BeOfType ([bool])
            $savedSettings."Invoke-WinpeStartup:$name" | Should -Be $settings[$name]
        }
        foreach ($name in @('InstallModule', 'InvokeStartupCommand', 'InvokeMainCommand', 'InvokeShutdownCommand')) {
            $savedSettings."Invoke-WinpeStartup:$name".GetType().IsArray | Should -BeTrue
            $savedSettings."Invoke-WinpeStartup:$name" | Should -Be $settings[$name]
        }
        foreach ($name in @('InvokeStartupCommandEA', 'InvokeMainCommandEA', 'InvokeShutdownCommandEA')) {
            $savedSettings."Invoke-WinpeStartup:$name" | Should -Be $settings[$name]
        }
    }

    It 'includes unspecified defaults and preserves explicit false' {
        $file = New-WinpeStartupProfile -ProfileName 'ExplicitFalse' -InvokeMainCommandNoExit:$false -PassThru
        $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json
        @($savedSettings.PSObject.Properties).Count | Should -Be 17
        $savedSettings.'Invoke-WinpeStartup:InvokeMainCommandNoExit' | Should -BeFalse
    }

    It 'preserves non-ASCII commands through the runtime Get-Content path' {
        $command = "Write-Output 'caf$([char]0x00e9)'"
        $file = New-WinpeStartupProfile -ProfileName 'Unicode' -InvokeMainCommand $command -PassThru
        $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json
        $savedSettings.'Invoke-WinpeStartup:InvokeMainCommand'[0] | Should -BeExactly $command
        $bytes = [System.IO.File]::ReadAllBytes($file.FullName)
        @($bytes[0..2]) | Should -Be @(239, 187, 191)
    }

    It 'creates a complete profile matching bundled OSDCloud defaults with no success-stream output' {
        @(New-WinpeStartupProfile -ProfileName 'Empty').Count | Should -Be 0
        $path = [System.IO.Path]::Combine($script:testRoot, 'USB-F', 'WinpeStartup', 'Profiles', 'Empty', 'winpestartup.json')
        $savedSettings = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
        $defaults = Get-Content -LiteralPath "$PSScriptRoot\..\..\core\PSDefaultParameterValues.json" -Raw | ConvertFrom-Json
        $bundled = Get-Content -LiteralPath "$PSScriptRoot\..\..\core\winpestartup-profiles\OSDCloud\winpestartup.json" -Raw | ConvertFrom-Json
        @($savedSettings.PSObject.Properties).Count | Should -Be 17
        foreach ($property in $defaults.PSObject.Properties) {
            $expected = $property.Value
            if ($bundled.PSObject.Properties[$property.Name]) {
                $expected = $bundled.($property.Name)
            }
            $savedSettings.($property.Name) | Should -Be $expected
        }
        $savedSettings.Environment.WINPESTARTUP_AUTHOR | Should -BeExactly ([System.Environment]::UserName)
        $savedSettings.Environment.WINPESTARTUP_PROFILE | Should -BeExactly 'Empty'
        $savedSettings.PSObject.Properties.Name | Should -Be @(
            'Environment'
            'Invoke-WinpeStartup:ShowPnpDevices'
            'Invoke-WinpeStartup:ShowPnpErrors'
            'Invoke-WinpeStartup:SkipOnScreenKeyboard'
            'Invoke-WinpeStartup:SkipWiFi'
            'Invoke-WinpeStartup:SkipIPConfig'
            'Invoke-WinpeStartup:SkipUpdateOSDCloud'
            'Invoke-WinpeStartup:InstallModule'
            'Invoke-WinpeStartup:InvokeStartupCommand'
            'Invoke-WinpeStartup:InvokeStartupCommandEA'
            'Invoke-WinpeStartup:InvokeStartupCommandNoExit'
            'Invoke-WinpeStartup:InvokeMainCommand'
            'Invoke-WinpeStartup:InvokeMainCommandEA'
            'Invoke-WinpeStartup:InvokeMainCommandNoExit'
            'Invoke-WinpeStartup:InvokeShutdownCommand'
            'Invoke-WinpeStartup:InvokeShutdownCommandEA'
            'Invoke-WinpeStartup:InvokeShutdownCommandNoExit'
        )
        $savedSettings.Environment.PSObject.Properties.Name | Should -Be @('WINPESTARTUP_AUTHOR', 'WINPESTARTUP_PROFILE')
        $script:expectedEditorPath = $path
        Should -Invoke Start-Process -Times 1 -Exactly -ParameterFilter {
            $FilePath -eq 'notepad.exe' -and $ArgumentList -eq "`"$script:expectedEditorPath`""
        }
    }

    It 'normalizes error-action values to canonical runtime values' {
        $file = New-WinpeStartupProfile -ProfileName 'Canonical' -InvokeMainCommandEA stop -InvokeShutdownCommandEA continue -PassThru
        $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json
        $savedSettings.'Invoke-WinpeStartup:InvokeMainCommandEA' | Should -BeExactly 'Stop'
        $savedSettings.'Invoke-WinpeStartup:InvokeShutdownCommandEA' | Should -BeExactly 'Continue'
    }

    It 'stores scalar environment values without changing the author environment' {
        $original = [System.Environment]::GetEnvironmentVariable('OSDCLOUD_PROFILE_AUTHOR_TEST', 'Process')
        $file = New-WinpeStartupProfile -ProfileName 'Environment' -Environment @{
            OSDCLOUD_PROFILE_AUTHOR_TEST = 'site'
            Ring = 2
            Interactive = $true
            Fraction = [decimal]1.25
            Empty = ''
        } -PassThru
        $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json
        @($savedSettings.PSObject.Properties).Count | Should -Be 17
        $savedSettings.Environment.OSDCLOUD_PROFILE_AUTHOR_TEST | Should -Be 'site'
        $savedSettings.Environment.Ring | Should -Be 2
        $savedSettings.Environment.Interactive | Should -BeOfType ([bool])
        $savedSettings.Environment.Fraction | Should -Be 1.25
        $savedSettings.Environment.Empty | Should -BeExactly ''
        [System.Environment]::GetEnvironmentVariable('OSDCLOUD_PROFILE_AUTHOR_TEST', 'Process') | Should -Be $original
    }

    It 'retains generated metadata when Environment is explicitly empty' {
        $file = New-WinpeStartupProfile -ProfileName 'EmptyEnvironment' -Environment @{} -PassThru
        $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json
        $savedSettings.PSObject.Properties.Name | Should -Contain 'Environment'
        $savedSettings.Environment | Should -BeOfType ([pscustomobject])
        @($savedSettings.Environment.PSObject.Properties).Count | Should -Be 2
        $savedSettings.Environment.WINPESTARTUP_AUTHOR | Should -BeExactly ([System.Environment]::UserName)
        $savedSettings.Environment.WINPESTARTUP_PROFILE | Should -BeExactly 'EmptyEnvironment'
    }

    It 'overrides supplied metadata without modifying the caller hashtable' {
        $environmentValues = @{
            WINPESTARTUP_AUTHOR = 'OtherUser'
            WINPESTARTUP_PROFILE = 'OtherProfile'
            Site = 'BranchOffice'
        }
        $file = New-WinpeStartupProfile -ProfileName 'ActualProfile' -Environment $environmentValues -PassThru
        $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json
        $savedSettings.Environment.WINPESTARTUP_AUTHOR | Should -BeExactly ([System.Environment]::UserName)
        $savedSettings.Environment.WINPESTARTUP_PROFILE | Should -BeExactly 'ActualProfile'
        $savedSettings.Environment.Site | Should -BeExactly 'BranchOffice'
        $environmentValues.WINPESTARTUP_AUTHOR | Should -BeExactly 'OtherUser'
        $environmentValues.WINPESTARTUP_PROFILE | Should -BeExactly 'OtherProfile'
    }

    It 'rejects invalid folder name <Name> before discovery or writes' -ForEach @(
        @{ Name = '.' }, @{ Name = '..' }, @{ Name = 'C:\Profile' },
        @{ Name = 'Parent\Child' }, @{ Name = 'Parent/Child' }, @{ Name = ' ' },
        @{ Name = 'Trailing.' }, @{ Name = 'Trailing ' }, @{ Name = 'CON' },
        @{ Name = 'nul.json' }, @{ Name = 'COM1' }, @{ Name = 'LPT9.txt' },
        @{ Name = 'CONIN$' }, @{ Name = 'CONOUT$' }, @{ Name = ('a' * 256) },
        @{ Name = 'Wild*Card' }, @{ Name = 'Wild?Card' }, @{ Name = 'Control' + [char]1 }
    ) {
        { New-WinpeStartupProfile -ProfileName $Name } | Should -Throw '*single valid Windows folder name*'
        Should -Invoke Get-Volume -Times 0 -Exactly
    }

    It 'rejects invalid array setting <Setting>' -ForEach @(
        @{ Setting = 'InstallModule'; Value = $null },
        @{ Setting = 'InvokeStartupCommand'; Value = @('') },
        @{ Setting = 'InvokeMainCommand'; Value = @('valid', ' ') },
        @{ Setting = 'InvokeShutdownCommand'; Value = @($null) }
    ) {
        $arguments = @{ ProfileName = 'Invalid'; $Setting = $Value }
        { New-WinpeStartupProfile @arguments } | Should -Throw '*array of non-empty strings*'
        Should -Invoke Get-Volume -Times 0 -Exactly
    }

    It 'rejects unsupported error-action settings before discovery' {
        { New-WinpeStartupProfile -ProfileName 'InvalidEA' -InvokeMainCommandEA Ignore } | Should -Throw
        Should -Invoke Get-Volume -Times 0 -Exactly
    }

    It 'rejects invalid environment entry <Case> before discovery' -ForEach @(
        @{ Case = 'null'; Value = @{ Site = $null } },
        @{ Case = 'array'; Value = @{ Site = @('one') } },
        @{ Case = 'object'; Value = @{ Site = @{ Nested = 'one' } } },
        @{ Case = 'date'; Value = @{ Site = [datetime]'2026-01-01' } },
        @{ Case = 'NaN'; Value = @{ Site = [double]::NaN } },
        @{ Case = 'infinity'; Value = @{ Site = [single]::PositiveInfinity } },
        @{ Case = 'enum'; Value = @{ Site = [System.DayOfWeek]::Monday } },
        @{ Case = 'equals in name'; Value = @{ 'Bad=Name' = 'site' } },
        @{ Case = 'empty name'; Value = @{ '' = 'site' } },
        @{ Case = 'non-string name'; Value = @{ 42 = 'site' } },
        @{ Case = 'null character in name'; Value = @{ ("Bad" + [char]0) = 'site' } }
    ) {
        { New-WinpeStartupProfile -ProfileName 'InvalidEnvironment' -Environment $Value } | Should -Throw '*Environment variable*'
        Should -Invoke Get-Volume -Times 0 -Exactly
    }

    It 'saves identical content to Boot-Assets and multiple actual USB drive letters' {
        $script:bootExists = $true
        $script:volumes = @(
            [pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = 'H' }
            [pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = 'Q' }
        )
        $files = @(New-WinpeStartupProfile -ProfileName 'Multiple' -InvokeMainCommand 'Write-Output ready' -PassThru)
        $files.Count | Should -Be 3
        $files[0].FullName | Should -Be ([System.IO.Path]::Combine($script:bootRoot, 'Multiple', 'winpestartup.json'))
        $files[1].FullName | Should -Match '\\USB-H\\'
        $files[2].FullName | Should -Match '\\USB-Q\\'
        (Get-Content -LiteralPath $files[0].FullName -Raw) | Should -BeExactly (Get-Content -LiteralPath $files[1].FullName -Raw)
        (Get-Content -LiteralPath $files[0].FullName -Raw) | Should -BeExactly (Get-Content -LiteralPath $files[2].FullName -Raw)
        Should -Invoke Start-Process -Times 3 -Exactly -ParameterFilter { $FilePath -eq 'notepad.exe' }
    }

    It 'does not create a missing Boot-Assets root or require elevation for USB' {
        $script:elevated = $false
        New-WinpeStartupProfile -ProfileName 'USBOnly'
        Test-Path -LiteralPath $script:bootRoot | Should -BeFalse
        Should -Invoke New-Object -Times 0 -Exactly -ParameterFilter { $TypeName -eq 'System.Security.Principal.WindowsPrincipal' }
    }

    It 'reports insufficient elevation for Boot-Assets but still saves to USB' {
        $script:bootExists = $true
        $script:elevated = $false
        $files = @(New-WinpeStartupProfile -ProfileName 'NonAdmin' -PassThru -ErrorAction SilentlyContinue -ErrorVariable saveErrors)
        $files.Count | Should -Be 1
        $files[0].FullName | Should -Match '\\USB-F\\'
        Should -Invoke Start-Process -Times 1 -Exactly -ParameterFilter { $ArgumentList -like '*USB-F*' }
        @($saveErrors | Where-Object { $_.FullyQualifiedErrorId -like 'WinpeStartupProfileSaveFailed*' }).Count | Should -Be 1
        $saveErrors.Exception.Message | Should -Contain "Administrator rights are required to save '$([System.IO.Path]::Combine($script:bootRoot, 'NonAdmin', 'winpestartup.json'))'. Run PowerShell as administrator."
        Test-Path -LiteralPath $script:bootRoot | Should -BeFalse
    }

    It 'stops at the first save failure when ErrorAction Stop is requested' {
        $script:bootExists = $true
        $script:elevated = $false
        { New-WinpeStartupProfile -ProfileName 'StopOnFailure' -ErrorAction Stop } | Should -Throw '*Administrator rights*'
        Test-Path -LiteralPath ([System.IO.Path]::Combine($script:testRoot, 'USB-F')) | Should -BeFalse
    }

    It 'ignores partial labels and volumes without a drive letter' {
        $script:volumes = @(
            [pscustomobject]@{ FileSystemLabel = 'OSDCloud-Data'; DriveLetter = 'F' }
            [pscustomobject]@{ FileSystemLabel = 'NotOSDCloud'; DriveLetter = 'H' }
            [pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = $null }
        )
        { New-WinpeStartupProfile -ProfileName 'NoMatch' } | Should -Throw '*No usable destinations*'
        Should -Invoke Get-Partition -Times 0 -Exactly
    }

    It 'excludes non-USB volumes' {
        $script:disk.BusType = 'NVMe'
        { New-WinpeStartupProfile -ProfileName 'LocalDisk' } | Should -Throw '*No usable destinations*'
        Test-Path -LiteralPath ([System.IO.Path]::Combine($script:testRoot, 'USB-F')) | Should -BeFalse
    }

    It 'excludes unavailable media: <Case>' -ForEach @(
        @{ Case = 'read-only disk'; ObjectName = 'disk'; Property = 'IsReadOnly' },
        @{ Case = 'offline disk'; ObjectName = 'disk'; Property = 'IsOffline' },
        @{ Case = 'read-only partition'; ObjectName = 'partition'; Property = 'IsReadOnly' },
        @{ Case = 'offline partition'; ObjectName = 'partition'; Property = 'IsOffline' }
    ) {
        (Get-Variable -Name $ObjectName -Scope Script -ValueOnly).$Property = $true
        Mock Write-Warning {}
        { New-WinpeStartupProfile -ProfileName 'Unavailable' } | Should -Throw '*No usable destinations*'
        Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'read-only OSDCloud USB volume' }
        Test-Path -LiteralPath ([System.IO.Path]::Combine($script:testRoot, 'USB-F')) | Should -BeFalse
    }

    It 'reports volume discovery failure but saves to Boot-Assets when available' {
        $script:bootExists = $true
        Mock Get-Volume { throw 'Storage discovery failed' }
        $file = New-WinpeStartupProfile -ProfileName 'DiscoveryFailure' -PassThru -ErrorAction SilentlyContinue -ErrorVariable discoveryErrors
        $file.FullName | Should -Be ([System.IO.Path]::Combine($script:bootRoot, 'DiscoveryFailure', 'winpestartup.json'))
        $discoveryErrors.Exception.Message | Should -Contain 'Storage discovery failed'
    }

    It 'reports Boot-Assets discovery failure but still saves to USB' {
        Mock Test-Path { throw 'Boot-Assets discovery denied' } -ParameterFilter { $LiteralPath -eq $script:bootAssetsPath }
        $file = New-WinpeStartupProfile -ProfileName 'BootDiscoveryFailure' -PassThru -ErrorAction SilentlyContinue -ErrorVariable discoveryErrors
        $file.FullName | Should -Match '\\USB-F\\'
        $discoveryErrors.Exception.Message | Should -Contain 'Boot-Assets discovery denied'
    }

    It 'reports per-volume discovery failure and still processes the next volume' {
        $script:volumes = @(
            [pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = 'F' }
            [pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = 'H' }
        )
        Mock Get-Partition { throw 'Partition discovery failed' } -ParameterFilter { $DriveLetter -eq 'F' }
        $file = New-WinpeStartupProfile -ProfileName 'PartitionFailure' -PassThru -ErrorAction SilentlyContinue -ErrorVariable discoveryErrors
        $file.FullName | Should -Match '\\USB-H\\'
        $discoveryErrors.Exception.Message | Should -Contain 'Partition discovery failed'
    }

    It 'reports actual filesystem failure and still saves to another destination' {
        $script:bootExists = $true
        $null = New-Item -Path $script:bootRoot -ItemType Directory -Force
        $null = New-Item -Path ([System.IO.Path]::Combine($script:bootRoot, 'Blocked')) -ItemType File
        $file = New-WinpeStartupProfile -ProfileName 'Blocked' -PassThru -ErrorAction SilentlyContinue -ErrorVariable saveErrors
        $file.FullName | Should -Match '\\USB-F\\'
        @($saveErrors | Where-Object { $_.FullyQualifiedErrorId -like 'WinpeStartupProfileSaveFailed*' }).Count | Should -Be 1
    }

    It 'refuses overwrite and leaves existing content unchanged' {
        $file = New-WinpeStartupProfile -ProfileName 'Existing' -SkipWiFi -PassThru
        $original = Get-Content -LiteralPath $file.FullName -Raw
        { New-WinpeStartupProfile -ProfileName 'Existing' -SkipIPConfig -ErrorAction Stop } | Should -Throw '*Use -Force*'
        (Get-Content -LiteralPath $file.FullName -Raw) | Should -BeExactly $original
        @(Get-ChildItem -LiteralPath $file.DirectoryName -Filter '*.tmp').Count | Should -Be 0
        Should -Invoke Start-Process -Times 1 -Exactly
    }

    It 'replaces existing content with Force and removes temporary files' {
        $file = New-WinpeStartupProfile -ProfileName 'Replace' -SkipWiFi -PassThru
        New-WinpeStartupProfile -ProfileName 'Replace' -SkipIPConfig -Force
        $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json
        @($savedSettings.PSObject.Properties).Count | Should -Be 17
        $savedSettings.'Invoke-WinpeStartup:SkipIPConfig' | Should -BeTrue
        $savedSettings.'Invoke-WinpeStartup:SkipWiFi' | Should -BeFalse
        @(Get-ChildItem -LiteralPath $file.DirectoryName -Filter '*.tmp').Count | Should -Be 0
    }

    It 'preserves the old file and cleans temporary files after a failed replacement' {
        $file = New-WinpeStartupProfile -ProfileName 'Locked' -SkipWiFi -PassThru
        $original = Get-Content -LiteralPath $file.FullName -Raw
        $lock = [System.IO.File]::Open($file.FullName, 'Open', 'Read', 'None')
        try {
            { New-WinpeStartupProfile -ProfileName 'Locked' -SkipIPConfig -Force -ErrorAction Stop } | Should -Throw
        }
        finally {
            $lock.Dispose()
        }
        (Get-Content -LiteralPath $file.FullName -Raw) | Should -BeExactly $original
        @(Get-ChildItem -LiteralPath $file.DirectoryName -Filter '*.tmp').Count | Should -Be 0
    }

    It 'errors when no save succeeds and cleans temporary files after publication fails' {
        $directory = [System.IO.Path]::Combine($script:testRoot, 'USB-F', 'WinpeStartup', 'Profiles', 'BlockedTarget')
        $null = New-Item -Path ([System.IO.Path]::Combine($directory, 'winpestartup.json')) -ItemType Directory -Force
        { New-WinpeStartupProfile -ProfileName 'BlockedTarget' -Force -ErrorAction SilentlyContinue } | Should -Throw '*No profile was saved*'
        Test-Path -LiteralPath ([System.IO.Path]::Combine($directory, 'winpestartup.json')) -PathType Container | Should -BeTrue
        @(Get-ChildItem -LiteralPath $directory -Filter '*.tmp').Count | Should -Be 0
        Should -Invoke Start-Process -Times 0 -Exactly
    }

    It 'reports Notepad launch failure separately without losing the saved profile' {
        Mock Start-Process { throw 'Notepad unavailable' }
        $file = New-WinpeStartupProfile -ProfileName 'EditorFailure' -PassThru -ErrorAction SilentlyContinue -ErrorVariable editorErrors
        $file.Exists | Should -BeTrue
        @($editorErrors | Where-Object { $_.FullyQualifiedErrorId -like 'WinpeStartupProfileEditorFailed*' }).Count | Should -Be 1
        $editorErrors.Exception.Message | Should -Contain 'Notepad unavailable'
        (Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json).'Invoke-WinpeStartup:InvokeMainCommand' |
            Should -Be @('Show-OSDCloudDeviceInfo', 'Deploy-OSDCloud')
    }

    It 'does not write or open an editor if bundled defaults cannot be read' {
        Mock Get-Content { throw 'Bundled profile unavailable' } -ParameterFilter { $LiteralPath -like '*winpestartup-profiles\OSDCloud\winpestartup.json' }
        { New-WinpeStartupProfile -ProfileName 'MissingDefaults' } | Should -Throw '*Bundled profile unavailable*'
        Should -Invoke Get-Volume -Times 0 -Exactly
        Should -Invoke Start-Process -Times 0 -Exactly
    }

    It 'does not mutate any destination with WhatIf, even with Force and missing elevation' {
        $script:bootExists = $true
        $script:elevated = $false
        @(New-WinpeStartupProfile -ProfileName 'Preview' -SkipWiFi -Force -PassThru -WhatIf).Count | Should -Be 0
        Test-Path -LiteralPath $script:bootRoot | Should -BeFalse
        Test-Path -LiteralPath ([System.IO.Path]::Combine($script:testRoot, 'USB-F')) | Should -BeFalse
        Should -Invoke New-Object -Times 0 -Exactly -ParameterFilter { $TypeName -eq 'System.Security.Principal.WindowsPrincipal' }
        Should -Invoke Start-Process -Times 0 -Exactly
    }

    It 'does not replace an existing profile under WhatIf' {
        $file = New-WinpeStartupProfile -ProfileName 'PreviewExisting' -SkipWiFi -PassThru
        $original = Get-Content -LiteralPath $file.FullName -Raw
        New-WinpeStartupProfile -ProfileName 'PreviewExisting' -SkipIPConfig -Force -WhatIf
        (Get-Content -LiteralPath $file.FullName -Raw) | Should -BeExactly $original
        @(Get-ChildItem -LiteralPath $file.DirectoryName -Filter '*.tmp').Count | Should -Be 0
        Should -Invoke Start-Process -Times 1 -Exactly
    }

    It 'does not create files when confirmation is declined, even with Force' {
        $decliningHost = [WinpeStartupProfileTests.DecliningHost]::new($Host)
        $runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($decliningHost)
        $runspace.Open()
        $session = [powershell]::Create()
        $session.Runspace = $runspace
        try {
            $null = $session.AddScript({
                param($FunctionPath, $DestinationRoot)
                . $FunctionPath
                $script:destinationRoot = $DestinationRoot
                function Test-Path {
                    [CmdletBinding()]
                    param($LiteralPath, $PathType)
                    $true
                }
                function Get-Volume {
                    [CmdletBinding()]
                    param()
                    @()
                }
                function Join-Path {
                    param($Path, $ChildPath)
                    if ($Path -eq 'C:\ProgramData\OSDeployCore\boot-assets\winpestartup-profiles') {
                        $Path = $script:destinationRoot
                    }
                    [System.IO.Path]::Combine($Path, $ChildPath)
                }
                New-WinpeStartupProfile -ProfileName 'Declined' -Force -PassThru -Confirm
            }).AddArgument("$PSScriptRoot\New-WinpeStartupProfile.ps1").AddArgument($script:bootRoot)
            $result = @($session.Invoke())
            $session.HadErrors | Should -BeFalse -Because (($session.Streams.Error | Out-String).Trim())
            $decliningHost.PromptCount | Should -Be 1
            $result.Count | Should -Be 0
            [System.IO.Directory]::Exists($script:bootRoot) | Should -BeFalse
        }
        finally {
            $session.Dispose()
            $runspace.Dispose()
        }
    }

    It 'documents every declared authoring parameter' {
        $help = Get-Help New-WinpeStartupProfile -Full
        $common = @([System.Management.Automation.PSCmdlet]::CommonParameters + [System.Management.Automation.PSCmdlet]::OptionalCommonParameters)
        foreach ($name in (Get-Command New-WinpeStartupProfile).Parameters.Keys | Where-Object { $_ -notin $common }) {
            $help.Parameters.Parameter.Name | Should -Contain $name
        }
        @($help.Examples.Example).Count | Should -BeGreaterOrEqual 2
    }

    It 'registers the export in the manifest and loads the file in both environments' {
        $moduleRoot = [System.IO.Path]::GetFullPath("$PSScriptRoot\..\..")
        $manifest = Import-PowerShellDataFile -LiteralPath "$moduleRoot\OSDCloud.psd1"
        $manifest.FunctionsToExport | Should -Contain 'New-WinpeStartupProfile'
        foreach ($systemDrive in @('C:', 'X:')) {
            $session = [powershell]::Create()
            try {
                $null = $session.AddScript({
                    param($ModuleRoot, $SystemDrive)
                    $originalSystemDrive = $env:SystemDrive
                    try {
                        $env:SystemDrive = $SystemDrive
                        Import-Module "$ModuleRoot\OSDCloud.psd1" -Force -ErrorAction Stop
                        Get-Command New-WinpeStartupProfile -Module OSDCloud -ErrorAction Stop | Select-Object -ExpandProperty Name
                    }
                    finally {
                        $env:SystemDrive = $originalSystemDrive
                    }
                }).AddArgument($moduleRoot).AddArgument($systemDrive)
                $result = $session.Invoke()
                $session.HadErrors | Should -BeFalse -Because (($session.Streams.Error | Out-String).Trim())
                $result | Should -Contain 'New-WinpeStartupProfile'
            }
            finally {
                $session.Dispose()
            }
        }
    }
}