Public/New-HDTPowerService.ps1

function New-HDTPowerService {
    <#
        .SYNOPSIS
            Creates the real IPowerService adapter, which restarts or shuts down
            the machine.
 
        .DESCRIPTION
            The one place in HDT that reboots anything, behind an interface so
            the whole reboot ceremony is provable under Pester
            against New-HDTFakePowerService with nothing restarted.
 
              Restart($DelaySecond)
              Stop($DelaySecond)
 
            One question was answered by mounting the boot image this repository
            builds:
 
                Windows\System32\shutdown.exe ABSENT
                Windows\System32\wpeutil.exe PRESENT
 
            So the adapter's old default was not merely unverified, it was
            WRONG: a Restart step in WinPE called a command that is not there.
            Nothing caught it because DEMO-M3 and DEMO-M4 deliberately have no
            Restart step, and the IPowerService contract's real row is skipped
            permanently - a contract test may not reboot the machine running it,
            and there is no dry-run form of shutdown.exe.
 
            THE FIX IS A MANDATORY PARAMETER, NOT A BETTER DEFAULT. A default is
            what let every caller inherit the wrong answer in silence. The two
            payloads that build a power service already know which world they
            are in: Start-HDTDeployment.ps1 is the WinPE entry point and
            Start-HDTResume.ps1 runs from RunOnce in the deployed OS.
 
            IT REMAINS BRANCH-FREE, which is what earns a thin adapter its
            exemption from TDD (tests/helpers/README.md section 10). Every
            decision lives in Get-HDTPowerCommand, which is
            pure and unit tested; this file asks, sleeps and invokes. The sleep
            is unconditional because Start-Sleep -Seconds 0 is a no-op and a
            guard would be a branch - the WinPE plan carries the delay there
            since `wpeutil reboot` has nowhere to put one.
 
            tests/unit/New-HDTPowerService.Tests.ps1 asserts all of that from the
            token stream, and tests/e2e/WinPeSmoke.E2E.Tests.ps1 is where this
            adapter is EXECUTED: the smoke VM is powered off by this object, in
            WinPE, and the absence of the probe's fallback marker is what proves
            it rather than the machine simply having ended.
 
            It is a [pscustomobject] carrying ScriptMethod members rather than a
            PowerShell class: classes dot-sourced into the module are the known
            flaky path across -Force re-imports (see 01-03).
 
        .PARAMETER Environment
            WinPE or FullOS - which set of commands this machine has. Mandatory
            and undefaulted, deliberately.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with Restart and Stop
            ScriptMethods. Note that Get-Member -MemberType Method does NOT list
            a ScriptMethod - use -MemberType Method, ScriptMethod.
 
        .EXAMPLE
            $power = New-HDTPowerService -Environment FullOS
            $power.Restart(30)
 
        .EXAMPLE
            $power = New-HDTPowerService -Environment WinPE
            $power.Stop(0)
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds a stateless service adapter object; it changes no state. The restart itself is a method on the object, invoked by the engine loop.')]
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateSet('WinPE', 'FullOS')]
        [string] $Environment
    )

    $service = [pscustomobject] @{
        ServiceName = 'PowerService'
        Environment = $Environment
        Operations  = [System.Collections.ArrayList]::new()
        Journal     = $null
    }

    $service | Add-Member -MemberType ScriptMethod -Name Record -Value {
        param([string] $Operation, [object[]] $Argument)

        [void] $this.Operations.Add([pscustomobject] @{
                Sequence  = $this.Operations.Count + 1
                Operation = $Operation
                Arguments = $Argument
            })

        if ($null -ne $this.Journal) {
            [void] $this.Journal.Add([pscustomobject] @{
                    Sequence  = $this.Journal.Count + 1
                    Service   = $this.ServiceName
                    Operation = $Operation
                    Arguments = $Argument
                })
        }
    }

    $service | Add-Member -MemberType ScriptMethod -Name GetOperationName -Value {
        return , ([string[]] @($this.Operations | ForEach-Object { $_.Operation }))
    }

    # The plan is fetched, not decided. Note that the recorded operation and its
    # arguments are unchanged from phase 03, so every journal assertion in
    # phases 03 to 05 still means exactly what it meant.
    $service | Add-Member -MemberType ScriptMethod -Name Restart -Value {
        param([int] $DelaySecond)

        $this.Record('Restart', @($DelaySecond))

        $plan = Get-HDTPowerCommand -Environment $this.Environment -Operation 'Restart' -DelaySecond $DelaySecond

        Start-Sleep -Seconds $plan.SleepSecond

        & $plan.Command @($plan.Argument)
    }

    $service | Add-Member -MemberType ScriptMethod -Name Stop -Value {
        param([int] $DelaySecond)

        $this.Record('Stop', @($DelaySecond))

        $plan = Get-HDTPowerCommand -Environment $this.Environment -Operation 'Stop' -DelaySecond $DelaySecond

        Start-Sleep -Seconds $plan.SleepSecond

        & $plan.Command @($plan.Argument)
    }

    return $service
}