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

function Get-BcCaptureBrowser {
    <#
    .SYNOPSIS
        Reports whether this host can capture Business Central web client screenshots. Changes nothing.
 
    .DESCRIPTION
        'albuild screenshot' drives a headless browser through Playwright, which needs Node.js, the
        pinned Playwright package and a downloaded Chromium. Together that is a few hundred megabytes -
        too much to put on every build agent by reflex - so it is NOT installed with the module and NOT
        installed on first use. This function says whether it is there; Install-BcCaptureBrowser puts it
        there.
 
        The split is deliberate. 'albuild doctor' calls this, and a readiness check that installs half a
        gigabyte as a side effect would violate the first rule the CLI holds itself to: describing never
        acts.
 
        Nothing here is fatal. A missing component is REPORTED (Ready = $false, named in Missing, with a
        runnable Remedy) rather than thrown, because the caller asking is usually asking precisely
        because it does not know yet.
 
        WHERE THE BROWSER LIVES. Under the ALbuild base folder, per Playwright version - not in the
        user profile. The MCP server commonly runs as a Windows service under LocalSystem, and a browser
        under some interactive account's %LOCALAPPDATA% is invisible to it: the install looks like it
        worked and every capture then fails as if nothing were installed.
 
    .PARAMETER InstallPath
        Where the capture browser is expected. Default: <BaseFolder>\capture\<playwright-version>.
 
    .PARAMETER NodeExecutable
        The Node.js executable to probe (default 'node').
 
    .OUTPUTS
        PSCustomObject with Ready, Missing, Remedy, NodePath, NodeVersion, PlaywrightVersion,
        PlaywrightPath, InstallPath, BrowsersPath and ChromiumPath.
 
    .EXAMPLE
        (Get-BcCaptureBrowser).Ready
 
    .EXAMPLE
        Get-BcCaptureBrowser | Format-List
        # Reports what is missing and the command that installs it.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string] $InstallPath,
        [string] $NodeExecutable = 'node'
    )

    # Playwright 1.63 needs Node 18+; below that npm resolves the package and the engine then fails at
    # runtime with a syntax error, which reads like a bug in the engine rather than an old runtime.
    $minimumNodeMajor = 18

    $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'

    $missing = @()

    # --- Node.js -------------------------------------------------------------------------------------
    $nodePath = $null
    $nodeVersion = $null
    $nodeCommand = Get-Command -Name $NodeExecutable -CommandType Application -ErrorAction SilentlyContinue |
        Select-Object -First 1
    if ($nodeCommand) {
        $nodePath = $nodeCommand.Source
        $probe = Invoke-ALbuildProcess -FilePath $nodePath -Arguments @('--version') -PassThru -SuccessExitCodes @(0)
        if ($probe.Success) { $nodeVersion = "$($probe.StdOut)".Trim() }
    }
    $nodeMajor = 0
    if ($nodeVersion -match '^v?(\d+)') { $nodeMajor = [int] $Matches[1] }
    if (-not $nodeVersion -or $nodeMajor -lt $minimumNodeMajor) { $missing += 'node' }

    # --- Playwright, at the pinned version -----------------------------------------------------------
    # The installed version is read back rather than assumed: the folder is named for the pin, but a
    # half-finished npm install leaves the folder there with the wrong thing (or nothing) inside it.
    $playwrightPath = Join-Path -Path (Join-Path -Path $InstallPath -ChildPath 'node_modules') -ChildPath 'playwright'
    $playwrightInstalled = $null
    $playwrightManifest = Join-Path -Path $playwrightPath -ChildPath 'package.json'
    if (Test-Path -LiteralPath $playwrightManifest) {
        try {
            $parsed = Get-Content -LiteralPath $playwrightManifest -Raw | ConvertFrom-Json
            if ($parsed.PSObject.Properties['version']) { $playwrightInstalled = "$($parsed.version)".Trim() }
        }
        catch {
            Write-ALbuildLog -Level Warning "Could not read '$playwrightManifest': $($_.Exception.Message)"
        }
    }
    if ($playwrightInstalled -ne $pinned) { $missing += 'playwright' }

    # --- Chromium ------------------------------------------------------------------------------------
    # The INSTALLED Playwright is asked where its browser is, rather than this function looking for a
    # file. The layout under the browser store is not a contract: it moved from 'chrome-win\chrome.exe'
    # to the Chrome for Testing shape 'chrome-win64\chrome.exe', and there is a second build
    # ('chromium_headless_shell-<rev>') beside it. A path guess one release out of date reports "no
    # browser" about a browser that is sitting right there - and sends the caller off to download a few
    # hundred megabytes that were never missing. This cost one round to find out.
    $chromiumPath = $null
    if ($playwrightInstalled -eq $pinned -and $nodePath) {
        $resolver = Join-Path -Path $resource.Folder -ChildPath 'resolve-browser.js'
        $previousBrowsersPath = $env:PLAYWRIGHT_BROWSERS_PATH
        try {
            $env:PLAYWRIGHT_BROWSERS_PATH = $browsersPath
            $resolved = Invoke-ALbuildProcess -FilePath $nodePath -Arguments @($resolver, $InstallPath) -PassThru -SuccessExitCodes @(0)
            if ($resolved.Success) {
                $candidate = "$($resolved.StdOut)".Trim()
                # Playwright answers with the path it WOULD launch, whether or not the download ran, so
                # the file still has to be there.
                if ($candidate -and (Test-Path -LiteralPath $candidate)) { $chromiumPath = $candidate }
            }
            else {
                Write-ALbuildLog -Level Warning ("Could not ask Playwright $pinned where its Chromium is: " +
                    "$("$($resolved.StdErr)".Trim())")
            }
        }
        finally {
            $env:PLAYWRIGHT_BROWSERS_PATH = $previousBrowsersPath
        }
    }
    if (-not $chromiumPath) { $missing += 'chromium' }

    # --- Remedy --------------------------------------------------------------------------------------
    # One runnable command, never a template. Node is the only part ALbuild does not install itself, so
    # it is named separately and first - 'albuild setup --screenshots' cannot succeed without it.
    $remedy = $null
    if ($missing -contains 'node') {
        $isWindowsHost = (Test-BcPlatform).IsWindows
        $remedy = if ($isWindowsHost) {
            'winget install OpenJS.NodeJS.LTS # then: albuild setup --screenshots'
        }
        else {
            'Install Node.js 18 or newer (https://nodejs.org), then: albuild setup --screenshots'
        }
    }
    elseif ($missing.Count -gt 0) {
        $remedy = 'albuild setup --screenshots'
    }

    [PSCustomObject]@{
        Ready             = ($missing.Count -eq 0)
        Missing           = @($missing)
        Remedy            = $remedy
        NodePath          = $nodePath
        NodeVersion       = $nodeVersion
        PlaywrightVersion = $pinned
        PlaywrightPath    = $(if ($playwrightInstalled -eq $pinned) { $playwrightPath } else { $null })
        InstallPath       = $InstallPath
        BrowsersPath      = $browsersPath
        ChromiumPath      = $chromiumPath
    }
}