Modules/businessdev.ALbuild.Containers/Public/Install-BcCaptureBrowser.ps1

function Install-BcCaptureBrowser {
    <#
    .SYNOPSIS
        Installs the headless browser 'albuild screenshot' captures with: Playwright at the pinned
        version plus its Chromium.
 
    .DESCRIPTION
        This is the one expensive prerequisite of the capture command - a few hundred megabytes per
        agent - and the reason it is a separate, explicit step rather than something that happens on
        first use. A command that downloads half a gigabyte because someone asked for a screenshot has
        made a decision that was not theirs to make.
 
        Installed PER PLAYWRIGHT VERSION under the ALbuild base folder, with the browsers inside that
        same folder (PLAYWRIGHT_BROWSERS_PATH), NOT in the user profile. Two reasons, both paid for
        elsewhere in this toolchain already:
 
          * The MCP server commonly runs as a Windows service under LocalSystem. A browser under an
            interactive account's %LOCALAPPDATA% is invisible to it - the install reports success and
            every capture afterwards fails as though nothing had been installed.
          * On a build server the base folder is deliberately off the system drive; the default
            Playwright cache is not.
 
        Per-version isolation means a pin bump installs beside the old browser instead of over it, so a
        build that is still running against the previous version keeps working.
 
        Idempotent: an installation that already satisfies the pin is left alone unless -Force.
 
        Node.js is the one part this does NOT install. It is a machine-level runtime, and silently
        putting one on a build agent is a bigger decision than caching a browser - so a missing Node is
        an error that names the install command.
 
    .PARAMETER InstallPath
        Where to install. Default: <BaseFolder>\capture\<playwright-version>.
 
    .PARAMETER Force
        Reinstall even when the pinned version is already present.
 
    .PARAMETER PruneOtherVersions
        After the pinned version is in place, remove the OTHER version folders under the capture root.
        Each one is a few hundred megabytes, and without this an agent accumulates one per pin bump
        until somebody notices the disk. A folder whose files are locked - a capture running against
        the older version right now - is reported and left alone rather than half-deleted.
 
    .PARAMETER NodeExecutable
        The Node.js executable (default 'node').
 
    .PARAMETER NpmExecutable
        The npm executable (default 'npm').
 
    .OUTPUTS
        PSCustomObject with Installed (whether anything was downloaded this call) plus the fields of
        Get-BcCaptureBrowser.
 
    .EXAMPLE
        Install-BcCaptureBrowser
 
    .EXAMPLE
        Install-BcCaptureBrowser -Force
        # Reinstalls the pinned Playwright and re-downloads Chromium into the same per-version folder.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [string] $InstallPath,
        [switch] $Force,
        [switch] $PruneOtherVersions,
        [string] $NodeExecutable = 'node',
        [string] $NpmExecutable = 'npm'
    )

    $resource = Get-BcCaptureResource
    $pinned = $resource.PlaywrightVersion

    if (-not $InstallPath) {
        $baseFolder = Get-ALbuildConfig -Name BaseFolder
        $InstallPath = Join-Path -Path (Join-Path -Path $baseFolder -ChildPath 'capture') -ChildPath $pinned
    }
    $browsersPath = Join-Path -Path $InstallPath -ChildPath 'browsers'

    $status = Get-BcCaptureBrowser -InstallPath $InstallPath -NodeExecutable $NodeExecutable

    # Node first, and as a hard error: every step below shells into it, and 'npm ERR! ... not found' is
    # a worse way to learn this than being told which command installs it.
    if ($status.Missing -contains 'node') {
        throw ("BROWSER_NOT_INSTALLED: Node.js 18 or newer is required to capture web client " +
            "screenshots and was not found$(if ($status.NodeVersion) { " (found $($status.NodeVersion))" }). " +
            "Install it and run this again: $($status.Remedy)")
    }

    if ($status.Ready -and -not $Force) {
        Write-ALbuildLog "Capture browser already present: Playwright $pinned in '$InstallPath' (cached)."
        $pruned = @(if ($PruneOtherVersions) { Remove-BcCaptureBrowserVersion -KeepPath $InstallPath -Confirm:$false })
        return ($status |
                Add-Member -NotePropertyName Installed -NotePropertyValue $false -PassThru -Force |
                Add-Member -NotePropertyName Pruned -NotePropertyValue $pruned -PassThru -Force)
    }

    if (-not $PSCmdlet.ShouldProcess("Playwright $pinned + Chromium", "Install the capture browser into '$InstallPath'")) {
        return ($status | Add-Member -NotePropertyName Installed -NotePropertyValue $false -PassThru -Force)
    }

    if (-not (Test-Path -LiteralPath $InstallPath)) {
        New-Item -ItemType Directory -Force -Path $InstallPath | Out-Null
    }
    # npm installs from the manifest in --prefix, so the module's pinned manifest is copied in. Copying
    # rather than pointing at the module folder keeps the install self-contained: the module can be
    # updated or removed underneath a warm agent without stranding node_modules.
    Copy-Item -LiteralPath $resource.ManifestPath -Destination (Join-Path -Path $InstallPath -ChildPath 'package.json') -Force

    # PLAYWRIGHT_BROWSERS_PATH steers BOTH steps: the package's own postinstall and the explicit
    # download below. Invoke-ALbuildProcess launches children with the current environment, so a
    # process-scoped variable is the transport - restored afterwards so a long-lived session (the MCP
    # server) does not keep a global pointing at one version's folder.
    $previousBrowsersPath = $env:PLAYWRIGHT_BROWSERS_PATH
    try {
        $env:PLAYWRIGHT_BROWSERS_PATH = $browsersPath

        Write-ALbuildLog "Installing Playwright $pinned into '$InstallPath'..."
        $npmArguments = @('install', '--prefix', $InstallPath, '--no-audit', '--no-fund')
        $npmResult = Invoke-ALbuildProcess -FilePath $NpmExecutable -Arguments $npmArguments -PassThru -SuccessExitCodes @(0)
        $npmOutput = "$($npmResult.StdOut)`n$($npmResult.StdErr)".Trim()
        if ($npmOutput) { Write-ALbuildLog "npm $($npmArguments -join ' '):`n$npmOutput" }
        if (-not $npmResult.Success) {
            throw "BROWSER_NOT_INSTALLED: npm could not install Playwright $pinned into '$InstallPath' [exit $($npmResult.ExitCode)]: $npmOutput"
        }

        # The package's postinstall usually fetches Chromium already; this makes it explicit and is a
        # no-op when it is there. Two candidate CLI entry points because the file moved between major
        # Playwright lines - refusing by name beats calling into something that does not exist.
        $playwrightRoot = Join-Path -Path $InstallPath -ChildPath 'node_modules'
        $cliCandidates = @(
            (Join-Path -Path (Join-Path -Path $playwrightRoot -ChildPath 'playwright') -ChildPath 'cli.js')
            (Join-Path -Path (Join-Path -Path $playwrightRoot -ChildPath 'playwright-core') -ChildPath 'cli.js')
        )
        $cli = $cliCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
        if (-not $cli) {
            throw ("BROWSER_NOT_INSTALLED: Playwright $pinned installed but its CLI was not found " +
                "(looked for: $($cliCandidates -join ', ')). The install is incomplete; re-run with -Force.")
        }

        Write-ALbuildLog "Downloading Chromium into '$browsersPath' (a few hundred MB on a cold agent)..."
        $downloadResult = Invoke-ALbuildProcess -FilePath $NodeExecutable -Arguments @($cli, 'install', 'chromium') -PassThru -SuccessExitCodes @(0)
        $downloadOutput = "$($downloadResult.StdOut)`n$($downloadResult.StdErr)".Trim()
        if ($downloadOutput) { Write-ALbuildLog "playwright install chromium:`n$downloadOutput" }
        if (-not $downloadResult.Success) {
            throw "BROWSER_NOT_INSTALLED: Chromium could not be downloaded into '$browsersPath' [exit $($downloadResult.ExitCode)]: $downloadOutput"
        }
    }
    finally {
        $env:PLAYWRIGHT_BROWSERS_PATH = $previousBrowsersPath
    }

    # Verified against the same check the caller will use, not against the exit codes above: an npm that
    # exits 0 having installed the wrong version still leaves this host unable to capture.
    $status = Get-BcCaptureBrowser -InstallPath $InstallPath -NodeExecutable $NodeExecutable
    if (-not $status.Ready) {
        throw ("BROWSER_NOT_INSTALLED: the install finished but '$InstallPath' is still not usable " +
            "(missing: $($status.Missing -join ', ')). Re-run with -Force, or check the output above.")
    }

    Write-ALbuildLog "Capture browser ready: Playwright $pinned, Chromium at '$($status.ChromiumPath)'."
    # Pruned only AFTER the new version is verified usable: removing the old one first would leave an
    # agent with nothing at all if the download failed.
    $pruned = @(if ($PruneOtherVersions) { Remove-BcCaptureBrowserVersion -KeepPath $InstallPath -Confirm:$false })
    return ($status |
            Add-Member -NotePropertyName Installed -NotePropertyValue $true -PassThru -Force |
            Add-Member -NotePropertyName Pruned -NotePropertyValue $pruned -PassThru -Force)
}