Private/New-UpdateAssertionScript.ps1

function New-UpdateAssertionScript {
    <#
    .SYNOPSIS
        Generates Sandbox\UpdateAssertions.ps1 — the in-sandbox pass/fail checks for the Update test.
    .DESCRIPTION
        The Update test scenario runs this script twice inside Windows Sandbox (Windows PowerShell 5.1):
 
          -Phase PreUpdate after the old-baseline vendor install: runs
                             SupportFiles\UpdateRequirement.ps1 (the update app's Intune requirement
                             rule) and asserts it exits 0 — i.e. the requirement correctly detects a
                             REAL old install with no tattoo. This is the only environment where that
                             signal can be validated before rollout. A failed/hung baseline install
                             also surfaces here (the requirement won't find the app).
          -Phase PostUpdate after the PSADT update: re-runs the requirement script (still 0) and
                             asserts the install tattoo holds the NEW version — exactly what the
                             update app's Intune detection rule evaluates.
 
        Results are appended as 'ASSERT <name> = PASS|FAIL|SKIP' lines to
        C:\PSADT\Sandbox\Logs\UpdateAssertions.log (the mapped project folder, so they land on the
        host live); 'RESULT COMPLETE' marks the end. Wait-Win32ToolkitUpdateAssertion parses them
        host-side.
 
        Tattoo expectations are read from AppConfig.json with the same DisplayName||Name fallback the
        deploy script and Get-Win32DetectionRules use, and are spliced into the generated script as
        escaped single-quoted literals (ConvertTo-PSSingleQuoted) — data, never code.
    .PARAMETER ProjectPath
        Full path to the PSADT project folder.
          -Phase PreBaseline is run FIRST (before the old install) to snapshot existing ARP keys, so the
          old app's entry can be identified as "new since baseline" (avoids matching the Sandbox base
          image's preinstalled apps). PostUpdate then asserts OldArpGone (no duplicate install).
    .PARAMETER SkipRequirement
        Do not run SupportFiles\UpdateRequirement.ps1 in either phase — the requirement assertions are
        emitted as explicit SKIPs (even if a stale UpdateRequirement.ps1 exists from a previous
        packaging). The tattoo/detection assertion still runs. Used by
        Test-Win32ToolkitProject -SkipRequirementCheck.
    .PARAMETER OldVersion
        The baseline (old) version string. Used to identify the old app's ARP entry by DisplayVersion
        (loose match) and to assert it is gone/updated after the update.
    .PARAMETER ExpectBaselineTattoo
        The baseline was installed by a PREVIOUS toolkit package (Test-Win32ToolkitProject
        -BaselineProjectPath), so it wrote the install tattoo. Adds a PreUpdate assertion that the
        tattoo holds the OLD version, and PostUpdate then proves the old→new overwrite.
    .OUTPUTS
        [string] the path of the generated script.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$ProjectPath,

        [switch]$SkipRequirement,

        [string]$OldVersion,

        [switch]$ExpectBaselineTattoo
    )

    $sandboxPath = Join-Path $ProjectPath 'Sandbox'
    if (-not (Test-Path -LiteralPath $sandboxPath)) {
        New-Item -ItemType Directory -Path $sandboxPath -Force | Out-Null
    }

    # Tattoo expectation — same source and fallback as the deploy script / detection rule.
    $cfg = Get-Win32ToolkitAppConfig -ProjectPath $ProjectPath
    $app = if ($cfg.PSObject.Properties.Name -contains 'App') { $cfg.App } else { $null }
    $uninstall = if ($cfg.PSObject.Properties.Name -contains 'Uninstall') { $cfg.Uninstall } else { $null }
    $tattooName = if ($app -and ($app.PSObject.Properties.Name -contains 'DisplayName') -and $app.DisplayName) { $app.DisplayName }
                  elseif ($app -and $app.Name) { $app.Name }
                  else { $null }
    $hasTattoo = [bool]($app -and $app.ScriptAuthor -and $app.Vendor -and $tattooName -and $app.Version)

    # ARP DisplayName candidates for identifying the old app (exact match): the clean name and the
    # captured uninstall DisplayName — but NOT the 'Unknown App' sentinel (Update-PSADTUninstallLogic).
    $candNames = @()
    if ($tattooName) { $candNames += $tattooName }
    if ($uninstall -and $uninstall.AppName -and $uninstall.AppName -ne 'Unknown App') { $candNames += $uninstall.AppName }
    $candNames = @($candNames | Select-Object -Unique)
    $candLiteral = if ($candNames.Count) { '@(' + (($candNames | ForEach-Object { "'" + (ConvertTo-PSSingleQuoted $_) + "'" }) -join ', ') + ')' } else { '@()' }

    # ---- Fixed body (single-quoted here-strings: device-side $ stays literal) ----
    $header = @'
param([Parameter(Mandatory = $true)][ValidateSet('PreBaseline', 'PreUpdate', 'PostUpdate')][string]$Phase)
# win32-toolkit update-test assertions (Windows PowerShell 5.1-safe; runs inside Windows Sandbox).
$logDir = 'C:\PSADT\Sandbox\Logs'
if (-not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$assertLog = Join-Path $logDir 'UpdateAssertions.log'
function Write-AssertLine([string]$Message) {
    ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message) | Add-Content -Path $assertLog -Encoding UTF8
}
 
# Loose version equality: tolerate 'v' prefix, component-count drift (2.5 == 2.5.0), and a decorated
# suffix ('2.53.0' ~ '2.53.0.windows.1') — old-version ARP DisplayVersions vary a lot.
function Test-LooseVersionEqual([string]$a, [string]$b) {
    if ([string]::IsNullOrWhiteSpace($a) -or [string]::IsNullOrWhiteSpace($b)) { return $false }
    $ta = $a.Trim() -replace '^[vV]', ''
    $tb = $b.Trim() -replace '^[vV]', ''
    if ($ta -ieq $tb) { return $true }
    $pa = $null; $pb = $null
    if ([version]::TryParse($ta, [ref]$pa) -and [version]::TryParse($tb, [ref]$pb) -and $pa -eq $pb) { return $true }
    if ($ta.StartsWith("$tb.") -or $tb.StartsWith("$ta.")) { return $true }
    return $false
}
# STRICT equality (only trailing-zero drift, 2.5 == 2.5.0) — for deciding whether a surviving ARP
# entry's version changed. Test-LooseVersionEqual's prefix tolerance is WRONG here: it would treat a
# 2.5 -> 2.5.1 in-place upgrade as "unchanged" and false-FAIL a successful update.
function Test-VersionUnchanged([string]$actual, [string]$old) {
    if ([string]::IsNullOrWhiteSpace($actual) -or [string]::IsNullOrWhiteSpace($old)) { return $false }
    $na = $actual.Trim() -replace '^[vV]', ''
    $no = $old.Trim() -replace '^[vV]', ''
    if ($na -ieq $no) { return $true }
    $pa = $null; $po = $null
    if ([version]::TryParse($na, [ref]$pa) -and [version]::TryParse($no, [ref]$po)) {
        $sa = '{0}.{1}.{2}.{3}' -f [Math]::Max($pa.Major,0), [Math]::Max($pa.Minor,0), [Math]::Max($pa.Build,0), [Math]::Max($pa.Revision,0)
        $so = '{0}.{1}.{2}.{3}' -f [Math]::Max($po.Major,0), [Math]::Max($po.Minor,0), [Math]::Max($po.Build,0), [Math]::Max($po.Revision,0)
        return ($sa -eq $so)
    }
    return $false
}
 
$arpPaths = @(
    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall',
    'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
)
function Get-ArpEntries {
    $out = @()
    foreach ($p in $arpPaths) {
        if (-not (Test-Path -LiteralPath $p)) { continue }
        foreach ($k in @(Get-ChildItem -LiteralPath $p -ErrorAction SilentlyContinue)) {
            $props = Get-ItemProperty -LiteralPath $k.PSPath -ErrorAction SilentlyContinue
            $out += [pscustomobject]@{ KeyPath = "$($k.PSPath)"; DisplayName = "$($props.DisplayName)"; DisplayVersion = "$($props.DisplayVersion)" }
        }
    }
    $out
}
$preBaselineFile = Join-Path $logDir 'PreBaselineArp.json'
$identifiedFile = Join-Path $logDir 'PreUpdateArpBaseline.json'
 
Write-AssertLine "=== Phase: $Phase ==="
 
# PreBaseline: snapshot existing ARP keys BEFORE the old install, then stop (no assertions).
if ($Phase -eq 'PreBaseline') {
    $keys = @(Get-ArpEntries | ForEach-Object { $_.KeyPath })
    ConvertTo-Json -InputObject @($keys) | Set-Content -LiteralPath $preBaselineFile -Encoding UTF8
    Write-AssertLine "PreBaseline ARP snapshot: $($keys.Count) keys"
    Write-AssertLine "=== End PreBaseline ==="
    return
}
'@


    $requirementBlock = if ($SkipRequirement) {
        @'
 
Write-AssertLine "ASSERT Requirement-$Phase = SKIP (requirement check disabled with -SkipRequirementCheck)"
'@

    }
    else {
        @'
 
# Update-app requirement rule: exit 0 + STDOUT means Intune would consider the requirement MET.
$reqScript = 'C:\PSADT\SupportFiles\UpdateRequirement.ps1'
if (Test-Path -LiteralPath $reqScript) {
    $reqOutput = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $reqScript 2>&1
    $reqCode = $LASTEXITCODE
    Write-AssertLine ('Requirement script exit code: {0}; output: [{1}]' -f $reqCode, (@($reqOutput | ForEach-Object { "$_" }) -join ' '))
    if ($reqCode -eq 0) { Write-AssertLine "ASSERT Requirement-$Phase = PASS" }
    elseif ($Phase -eq 'PreUpdate') { Write-AssertLine "ASSERT Requirement-$Phase = FAIL (rule did not detect the vendor-installed old version - the update app would be NOT APPLICABLE on machines that got the app outside this toolkit; the update itself may still work)" }
    else { Write-AssertLine "ASSERT Requirement-$Phase = FAIL (rule did not detect the app AFTER the update - presence signals are broken)" }
}
else {
    Write-AssertLine "ASSERT Requirement-$Phase = SKIP (SupportFiles\UpdateRequirement.ps1 not present)"
}
'@

    }
    # ---- OldArpGone: old app's ARP entry must be gone/updated after the update (data spliced) ----
    $arpData = @"
 
# Old-app identification data (spliced from AppConfig; DATA, never code).
`$candidateNames = $candLiteral
`$expectedOldVersion = '$(ConvertTo-PSSingleQuoted $OldVersion)'
"@


    $arpBlock = @'
 
if ($Phase -eq 'PreUpdate') {
    # Identify the old app's ARP entries = keys NEW since PreBaseline that match a candidate name or
    # the old version (intersection kills the Sandbox base image's preinstalled Edge/WebView2/etc.).
    $preSet = @{}
    if (Test-Path -LiteralPath $preBaselineFile) {
        # Where-Object filters the empty-array artifact: PS 5.1 ConvertTo-Json @() -> "[\n\n]", which
        # ConvertFrom-Json reads back as one empty Object[] element (not zero).
        foreach ($k in @(Get-Content -LiteralPath $preBaselineFile -Raw | ConvertFrom-Json | Where-Object { $_ })) { $preSet["$k"] = $true }
    }
    $identified = @()
    foreach ($e in @(Get-ArpEntries)) {
        if ($preSet.ContainsKey($e.KeyPath)) { continue }
        $nameMatch = $e.DisplayName -and ($candidateNames -contains $e.DisplayName)
        $verMatch = $expectedOldVersion -and $e.DisplayName -and (Test-LooseVersionEqual $e.DisplayVersion $expectedOldVersion)
        if ($nameMatch -or $verMatch) {
            $identified += [pscustomobject]@{ KeyPath = $e.KeyPath; DisplayName = $e.DisplayName; DisplayVersion = $e.DisplayVersion }
        }
    }
    ConvertTo-Json -InputObject @($identified) | Set-Content -LiteralPath $identifiedFile -Encoding UTF8
    Write-AssertLine "PreUpdate: identified $($identified.Count) ARP entry(ies) as the old app"
}
if ($Phase -eq 'PostUpdate') {
    # Where-Object drops the PS 5.1 empty-array artifact so a zero-identified run SKIPs (not false-PASS).
    $identified = @()
    if (Test-Path -LiteralPath $identifiedFile) { $identified = @(Get-Content -LiteralPath $identifiedFile -Raw | ConvertFrom-Json | Where-Object { $_ -and $_.KeyPath }) }
    if ($identified.Count -eq 0) {
        Write-AssertLine "ASSERT OldArpGone-PostUpdate = SKIP (no old-app ARP entry identified after the baseline install - MSIX baseline or unrecognizable DisplayName/DisplayVersion)"
    } else {
        $stillOld = @()
        foreach ($e in $identified) {
            $p = $null
            try { $p = Get-ItemProperty -LiteralPath $e.KeyPath -ErrorAction Stop } catch { }
            if (-not $p) { continue } # key gone -> updated in place or removed
            if ($expectedOldVersion -and -not (Test-VersionUnchanged "$($p.DisplayVersion)" $expectedOldVersion)) { continue } # version bumped -> upgraded (strict, not prefix-loose)
            $stillOld += "$($e.DisplayName) [$($e.KeyPath)]"
        }
        if ($stillOld.Count -gt 0) {
            Write-AssertLine "ASSERT OldArpGone-PostUpdate = FAIL (old version still registered: $($stillOld -join '; '))"
        } else {
            Write-AssertLine "ASSERT OldArpGone-PostUpdate = PASS"
        }
    }
}
'@


    # ---- Baseline tattoo (PreUpdate only; baseline was a previous TOOLKIT package) ----
    $baselineTattooBlock = ''
    if ($ExpectBaselineTattoo -and $hasTattoo) {
        $baselineTattooBlock = @"
 
if (`$Phase -eq 'PreUpdate') {
    # The baseline was installed by a previous toolkit package - it must have written its own tattoo
    # at the OLD version (PostUpdate then proves the old->new overwrite).
    `$btKey = 'HKLM:\SOFTWARE\$(ConvertTo-PSSingleQuoted $app.ScriptAuthor)\$(ConvertTo-PSSingleQuoted $app.Vendor)\$(ConvertTo-PSSingleQuoted $tattooName)'
    `$btActual = `$null
    try { `$btActual = (Get-ItemProperty -LiteralPath `$btKey -ErrorAction Stop).Version } catch { }
    Write-AssertLine ('TattooBaseline [{0}] actual=[{1}] expected(old)=[{2}]' -f `$btKey, `$btActual, `$expectedOldVersion)
    if (Test-LooseVersionEqual "`$btActual" `$expectedOldVersion) { Write-AssertLine 'ASSERT TattooBaseline-PreUpdate = PASS' }
    else { Write-AssertLine 'ASSERT TattooBaseline-PreUpdate = FAIL (baseline toolkit package did not write its tattoo at the old version - baseline install failed?)' }
}
"@

    }

    $body = $header + $requirementBlock + $arpData + $arpBlock + $baselineTattooBlock

    # ---- Tattoo check (PostUpdate only; expectations spliced as escaped data) ----
    if ($hasTattoo) {
        $tattooBlock = @"
 
if (`$Phase -eq 'PostUpdate') {
    # The update app's Intune detection rule: tattoo key holds the NEW version after the update.
    `$tattooKey = 'HKLM:\SOFTWARE\$(ConvertTo-PSSingleQuoted $app.ScriptAuthor)\$(ConvertTo-PSSingleQuoted $app.Vendor)\$(ConvertTo-PSSingleQuoted $tattooName)'
    `$expectedVersion = '$(ConvertTo-PSSingleQuoted $app.Version)'
    `$actualVersion = `$null
    try { `$actualVersion = (Get-ItemProperty -LiteralPath `$tattooKey -ErrorAction Stop).Version } catch { }
    Write-AssertLine ('Tattoo [{0}] Version: actual=[{1}] expected=[{2}]' -f `$tattooKey, `$actualVersion, `$expectedVersion)
    if (`$actualVersion -eq `$expectedVersion) { Write-AssertLine 'ASSERT Tattoo-PostUpdate = PASS' }
    else { Write-AssertLine 'ASSERT Tattoo-PostUpdate = FAIL' }
}
"@

    }
    else {
        $tattooBlock = @'
 
if ($Phase -eq 'PostUpdate') {
    Write-AssertLine 'ASSERT Tattoo-PostUpdate = SKIP (AppConfig.json has no complete tattoo values - regenerate the project)'
}
'@

    }

    $footer = @'
 
Write-AssertLine "=== End $Phase ==="
if ($Phase -eq 'PostUpdate') { Write-AssertLine 'RESULT COMPLETE' }
'@


    $scriptPath = Join-Path $sandboxPath 'UpdateAssertions.ps1'
    # UTF-8 WITH BOM: the script runs under Windows PowerShell 5.1 in the sandbox, which decodes
    # BOM-less files as ANSI — non-ASCII app metadata (vendor/product names) would mojibake and
    # produce false assertion FAILs. (PS7's Set-Content -Encoding UTF8 writes no BOM.)
    [System.IO.File]::WriteAllText($scriptPath, ($body + $tattooBlock + $footer), (New-Object System.Text.UTF8Encoding($true)))

    # Sanity: the generated script must parse (it runs unattended on 5.1 in the sandbox).
    $errs = $null
    [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$null, [ref]$errs) | Out-Null
    if ($errs -and $errs.Count) {
        throw "Generated UpdateAssertions.ps1 has parse errors: $($errs[0].Message)"
    }
    return $scriptPath
}