Modules/businessdev.ALbuild.Containers/Public/Export-BcContainerScreenshot.ps1
|
function Export-BcContainerScreenshot { <# .SYNOPSIS Captures Business Central web client pages and reports from a container as PNG files. .DESCRIPTION Every UI change in a product app has to be evidenced by web client screenshots. This takes them deterministically: sign in, navigate, wait, dismiss what covers the view, save a PNG, and write a manifest saying what the pictures show. It runs where the container is REACHABLE. That is the whole reason it exists as a command rather than as a browser on the developer's machine: a container's web client URL is often a NAT address like http://172.25.0.77/BC/ that resolves only on the host, and the failure otherwise shows up after the container has already been built. WHAT IS CHECKED BEFORE THE BROWSER STARTS. The container runs, its web client URL resolves, the capture browser is installed, and - the expensive one - the app under test is actually installed. A run that succeeds and produces pictures of standard Business Central without the feature in them is worse than a failure, because it looks like evidence. WHAT IT DOES NOT DO. No click-through demos, no data entry, no video. Those need an automation DSL and stay in the bc-ui-capture skill, where being per-feature and exploratory is the point. The dividing line: if it can be expressed as a list of objects it belongs here; if it needs a script it does not. 'Create' mode is not offered at all - it writes data. .PARAMETER Name The container to capture from. .PARAMETER Credential Credentials for the web client. The caller holds these (the pool or the local registry knows them); they never appear in a command line or a log. .PARAMETER Page Page object ids to capture. .PARAMETER Report Report object ids to capture. Without -PreviewReport this is the request page. .PARAMETER OutputFolder Where the PNGs and the manifest are written. Created if missing. .PARAMETER Company Company to open. Default: the container's default company. .PARAMETER Language UI culture, e.g. 'de-DE'. Governs the browser's Accept-Language and which caption table the engine matches on. It does NOT change the container's configured default language - that is New-BcContainer -Language. .PARAMETER Viewport 'WxH', default '1600x1000'. Smaller is REFUSED: below it the role centre navigation collapses and entries such as 'Debitoren' become unreachable, which is a correctness problem and not an aesthetic one. .PARAMETER FullPage Capture the full scrollable content instead of the viewport. .PARAMETER PreviewReport Also render each report and capture its output. Both artefacts are kept: a request page and a rendered report are different things and both are worth having. .PARAMETER RequireData Fail instead of writing a screenshot of an empty list. The rule behind this feature is not "produce a screenshot" but "produce one that shows plausible data". .PARAMETER Filter Record filter applied to every page, e.g. "'No.' IS '10000'". A card is addressed this way - clicking a row in a list only selects it. .PARAMETER Mode 'View' (default) or 'Edit'. .PARAMETER ProjectFolder AL project whose app.json identifies the app under test. .PARAMETER AppName App name to require in the container, when there is no project folder. .PARAMETER AppId App id to require in the container. .PARAMETER SkipAppCheck Do not require any app - the documented way to capture standard Business Central on purpose. .PARAMETER UiStringPath A JSON file merged over the shipped caption table, for a BC version that renamed something. .PARAMETER TimeoutSeconds Per-target budget for the client to become ready. Default 60. .OUTPUTS PSCustomObject with OutputFolder, ManifestPath, Captures, Warnings and Manifest. .EXAMPLE Export-BcContainerScreenshot -Name bld -Credential $cred -Page 22 -OutputFolder ./screenshots .EXAMPLE Export-BcContainerScreenshot -Name bld -Credential $cred -Report 101 -PreviewReport -RequireData # Request page and rendered output, and a failure rather than a picture of an empty report. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [Alias('ContainerName')] [string] $Name, [Parameter(Mandatory)] [PSCredential] $Credential, [int[]] $Page = @(), [int[]] $Report = @(), [string] $OutputFolder = './screenshots', [string] $Company, [string] $Language, [string] $Viewport = '1600x1000', [switch] $FullPage, [switch] $PreviewReport, [switch] $RequireData, [string] $Filter, [ValidateSet('View', 'Edit')] [string] $Mode = 'View', [string] $ProjectFolder, [string] $AppName, [string] $AppId, [switch] $SkipAppCheck, [string] $UiStringPath, [ValidateRange(10, 3600)] [int] $TimeoutSeconds = 60 ) if ($Page.Count -eq 0 -and $Report.Count -eq 0) { throw 'Nothing to capture: pass -Page and/or -Report.' } # --- Viewport ------------------------------------------------------------------------------------ # Refused, not silently raised: a caller who asked for 1280x800 wanted 1280x800, and quietly # giving them something else hides that the request could not be honoured. if ("$Viewport" -notmatch '^(\d+)\s*[xX]\s*(\d+)$') { throw "Viewport '$Viewport' is not in WxH form, e.g. '1600x1000'." } $width = [int] $Matches[1] $height = [int] $Matches[2] if ($width -lt 1600 -or $height -lt 1000) { throw ("Viewport '$Viewport' is below the 1600x1000 minimum. Under it the web client collapses " + 'its role centre navigation and parts of the UI become unreachable, so the screenshot would ' + 'not show what the page actually looks like.') } # --- The browser --------------------------------------------------------------------------------- $browser = Get-BcCaptureBrowser if (-not $browser.Ready) { throw ("BROWSER_NOT_INSTALLED: this host cannot capture screenshots (missing: " + "$($browser.Missing -join ', ')). Install it with: $($browser.Remedy)") } # --- The container ------------------------------------------------------------------------------- $container = Get-BcContainer -Name $Name if (-not $container) { throw "Container '$Name' was not found." } if (-not $container.Running) { throw "Container '$Name' is not running; start it before capturing." } # Read before the browser starts: it is evidence for the manifest, and asking a container that # has just been driven through a capture is no more reliable than asking it now. $artifact = Get-BcContainerArtifactInfo -Name $Name $webClient = Get-BcContainerWebClientUrl -Name $Name if (-not $webClient) { throw ("WEB_CLIENT_UNREACHABLE: no web client URL could be resolved for '$Name'. " + "Check the container with: Get-BcContainer -Name $Name") } # --- The app under test -------------------------------------------------------------------------- $app = [PSCustomObject]@{ Known = $false; Installed = $null; Name = $null; Id = $null; Version = $null; Published = $false } if (-not $SkipAppCheck) { $app = Test-BcContainerAppPresence -ContainerName $Name -AppId $AppId -AppName $AppName -ProjectFolder $ProjectFolder if ($app.Known -and -not $app.Installed) { $published = if ($app.Published) { ' It is published but not installed.' } else { '' } throw ("APP_NOT_INSTALLED: '$($app.Name)' is not installed in '$Name'.$published Screenshots " + 'taken now would show standard Business Central without the feature in them. Deploy it ' + "first: Publish-BcContainerApp -Name $Name -AppFile <app> -Sync -Install") } if (-not $app.Known) { Write-ALbuildLog -Level Warning ('No app under test could be identified (pass -ProjectFolder, ' + '-AppName or -AppId, or -SkipAppCheck to say it is deliberate). The manifest records this.') } } # --- Targets ------------------------------------------------------------------------------------- $uiStrings = Resolve-BcWebClientUiString -Language $Language -OverridePath $UiStringPath $targets = [System.Collections.Generic.List[object]]::new() foreach ($id in $Page) { $targets.Add(@{ name = "page-$id"; kind = 'page'; id = $id url = Resolve-BcWebClientUrl -BaseUrl $webClient.Url -Kind page -Id $id -Company $Company -Filter $Filter -Mode $Mode file = "page-$id.png"; mode = $Mode; filter = $Filter }) } foreach ($id in $Report) { $target = @{ name = "report-$id"; kind = 'report'; id = $id url = Resolve-BcWebClientUrl -BaseUrl $webClient.Url -Kind report -Id $id -Company $Company file = "report-$id.png" } if ($PreviewReport) { $target['preview'] = $true $target['previewFile'] = "report-$id-preview.png" } $targets.Add($target) } $resolvedOut = if ([System.IO.Path]::IsPathRooted($OutputFolder)) { $OutputFolder } else { Join-Path -Path (Get-Location).Path -ChildPath $OutputFolder } if (-not $PSCmdlet.ShouldProcess("$($targets.Count) object(s) in '$Name'", "Capture web client screenshots into '$resolvedOut'")) { return [PSCustomObject]@{ OutputFolder = $resolvedOut; ManifestPath = $null; Captures = @(); Warnings = @(); Manifest = $null } } if (-not (Test-Path -LiteralPath $resolvedOut)) { New-Item -ItemType Directory -Force -Path $resolvedOut | Out-Null } $job = @{ schemaVersion = '1' baseUrl = $webClient.Url installPath = $browser.InstallPath username = $Credential.UserName language = $Language viewport = @{ width = $width; height = $height } fullPage = [bool] $FullPage requireData = [bool] $RequireData outDir = $resolvedOut timeouts = @{ navigationMs = [int] ($TimeoutSeconds * 1000 * 3) readyMs = [int] ($TimeoutSeconds * 1000) reportPreviewMs = [int] ($TimeoutSeconds * 1000 * 2) } uiStrings = $uiStrings targets = @($targets) } Write-ALbuildLog "Capturing $($targets.Count) object(s) from '$Name' ($($webClient.Url)) into '$resolvedOut'..." $run = Invoke-BcCaptureEngine -Job $job -Credential $Credential -Browser $browser -OnEvent { param($captureEvent) if ($captureEvent.type -ne 'progress') { return } # Every property is probed before it is read. The module runs under Set-StrictMode -Latest, # where reading an absent property is a TERMINATING error - and the sign-in event legitimately # carries no 'target'. The first live run died on that: the whole capture was reported as # failed by the code that was only supposed to write a log line, after the PNG was on disk. $target = if ($captureEvent.PSObject.Properties['target']) { "$($captureEvent.target)" } else { 'sign-in' } $step = if ($captureEvent.PSObject.Properties['step']) { "$($captureEvent.step)" } else { '' } $message = if ($captureEvent.PSObject.Properties['message']) { "$($captureEvent.message)" } else { '' } Write-ALbuildLog " ${target}: $step $message".TrimEnd() } # --- Manifest ------------------------------------------------------------------------------------ # What makes a capture run reviewable months later. Everything in it is OBSERVED: the language the # client rendered in, the browser that took the picture, the size of every file. $done = $run.Done $captures = @( foreach ($capture in $run.Captures) { $path = Join-Path -Path $resolvedOut -ChildPath $capture.file [PSCustomObject]@{ kind = $capture.kind id = $capture.id mode = $capture.mode filter = $capture.filter preview = [bool] $capture.preview file = $capture.file bytes = $capture.bytes sha256 = $(if (Test-Path -LiteralPath $path) { (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash } else { $null }) hasData = $capture.hasData teachingTipsDismissed = $capture.teachingTipsDismissed durationMs = $capture.durationMs } } ) $manifest = [PSCustomObject]@{ schemaVersion = '1' runId = [guid]::NewGuid().ToString() capturedAt = (Get-Date).ToUniversalTime().ToString('o') container = $Name bcVersion = $artifact.Version country = $artifact.Country artifactType = $artifact.ArtifactType webClientUrl = $webClient.Url company = $Company uiLanguage = [PSCustomObject]@{ requested = $Language; observed = $(if ($done) { $done.observedLanguage } else { $null }) } app = [PSCustomObject]@{ name = $app.Name; id = $app.Id; version = $app.Version installed = $app.Installed; checked = [bool] $app.Known } viewport = "$($width)x$($height)" fullPage = [bool] $FullPage engine = [PSCustomObject]@{ albuild = "$((Get-Module businessdev.ALbuild | Select-Object -First 1).Version)" playwright = $(if ($done) { $done.playwrightVersion } else { $null }) chromium = $(if ($done) { $done.chromiumVersion } else { $null }) } captures = $captures warnings = @(if ($done -and $done.warnings) { $done.warnings }) } $manifestPath = Join-Path -Path $resolvedOut -ChildPath 'capture-manifest.json' $manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 # A failed target keeps its stable code all the way out: the first one becomes the exception's # leading token, so a caller branches on the cause instead of matching a message. if ($run.Errors.Count -gt 0) { $first = $run.Errors[0] $more = if ($run.Errors.Count -gt 1) { " (and $($run.Errors.Count - 1) more)" } else { '' } throw "$($first.code): $($first.message)$more" } Write-ALbuildLog "Captured $($captures.Count) file(s); manifest at '$manifestPath'." [PSCustomObject]@{ OutputFolder = $resolvedOut ManifestPath = $manifestPath Captures = $captures Warnings = @($manifest.warnings) Manifest = $manifest } } |