Files
MBN_STOCK_WEBVIEW/tests/Scripts/Test-LegacyPgmParityRun.Static.ps1

303 lines
14 KiB
PowerShell

[CmdletBinding()]
param([string] $NodeExecutable = '')
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$wrapperPath = Join-Path $repositoryRoot 'scripts\Invoke-LegacyPgmParityRun.ps1'
$stagePath = Join-Path $repositoryRoot 'scripts\LegacyPgmParityStage.mjs'
$protocolPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyBridge\LegacyBridgeProtocol.cs'
$playoutUiPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\Web\playout-ui.js'
$appUiPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\Web\app.js'
foreach ($path in @($wrapperPath, $stagePath, $protocolPath, $playoutUiPath, $appUiPath)) {
if (-not [IO.File]::Exists($path)) { throw "Required parity contract file is missing: $path" }
}
if ([string]::IsNullOrWhiteSpace($NodeExecutable)) {
$command = Get-Command node.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $command) {
$NodeExecutable = $command.Source
} else {
$NodeExecutable = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'
}
}
$NodeExecutable = [IO.Path]::GetFullPath($NodeExecutable)
if (-not [IO.File]::Exists($NodeExecutable)) { throw "Node executable is missing: $NodeExecutable" }
$tokens = $null
$parseErrors = $null
[Management.Automation.Language.Parser]::ParseFile($wrapperPath, [ref]$tokens, [ref]$parseErrors) | Out-Null
if ($parseErrors.Count -ne 0) {
throw "PowerShell syntax failed at line $($parseErrors[0].Extent.StartLineNumber): $($parseErrors[0].Message)"
}
& $NodeExecutable --check $stagePath
if ($LASTEXITCODE -ne 0) { throw 'Node syntax check failed.' }
$wrapper = [IO.File]::ReadAllText($wrapperPath, [Text.Encoding]::UTF8)
$stage = [IO.File]::ReadAllText($stagePath, [Text.Encoding]::UTF8)
$protocol = [IO.File]::ReadAllText($protocolPath, [Text.Encoding]::UTF8)
$playoutUi = [IO.File]::ReadAllText($playoutUiPath, [Text.Encoding]::UTF8)
$appUi = [IO.File]::ReadAllText($appUiPath, [Text.Encoding]::UTF8)
$forbidden = @(
'Wickedness\.MBNStockWebView\.LegacyParity_[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+_',
'(?i)\$(?:Pgm|App)ProcessId\s*=\s*[0-9]{3,}',
'MBNWEB-20260718-DEV-LIVE-02',
'legacy-named-[0-9]',
'MBN_STOCK_WEBVIEW\.LegacyParityApp\.Playout\.dll',
'C:\\Users\\[^\\]+',
'C:\\Tornado2'
)
foreach ($pattern in $forbidden) {
if ([regex]::IsMatch($wrapper + "`n" + $stage, $pattern)) {
throw "A stale runtime/package/named-playlist pin was found: $pattern"
}
}
$requiredWrapperTokens = @(
'Get-AppxPackage -Name $ExpectedPackageName',
'$package.IsDevelopmentMode -ne $true',
"'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Debug'",
'$debugSegments[1] -cne ''win-x64''',
'$debugSegments[2] -cne ''AppX''',
'Find-UniqueProcessByPath',
'Get-EstablishedPgmConnection',
'[Net.IPAddress]::IsLoopback($localAddress)',
'[Net.IPAddress]::IsLoopback($remoteAddress)',
'-PgmProcessId $pgmProcess.Id',
'-RemotePort $connection.LocalPort',
"'MBN_STOCK_WEBVIEW.LegacyParityApp.exe'",
"'MBN_STOCK_WEBVIEW.LegacyParityApp.dll'",
"'MBN_STOCK_WEBVIEW.Playout.dll'",
"[string] `$ExpectedPgmExecutable = ''",
'$env:MBN_STOCK_PGM_EXECUTABLE',
"'..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts'",
'Use-ManifestDiscoverySettings -Manifest $manifest',
'Assert-PinsExact -Pinned $manifest.pins',
'SetTopMostForCapture',
'CopyFromScreen',
'[AllowEmptyString()] [string] $Text',
'[Diagnostics.ProcessStartInfo]::new()',
'$start.RedirectStandardOutput = $true',
'$start.RedirectStandardError = $true',
'$stdoutTask = $process.StandardOutput.ReadToEndAsync()',
'$exitCode = [int]$process.ExitCode',
'STOPPED.json',
'FileMode]::CreateNew',
"'^(?!0000-)\d{4}-'",
"'RunNext'",
'VgAtAFMAdABvAGMAawAgAJ3JjK0VyPS8ocGczdzCpMJc0SAAZgBvAHIAIADkuXzHvawcyFQAVgAgACgAMgA2AC4AMAAzAC4AMgA2ACkA'
)
foreach ($token in $requiredWrapperTokens) {
if (-not $wrapper.Contains($token)) { throw "Wrapper safety contract is missing: $token" }
}
if ($wrapper.Contains('SetForegroundWindow')) {
throw 'Evidence capture must not steal keyboard focus from either application.'
}
if ($wrapper.Contains('Start-Process -FilePath $nodePath')) {
throw 'Windows PowerShell Start-Process cannot provide a trustworthy Node exit code on this workstation.'
}
if (@([regex]::Matches($wrapper, '\[AllowEmptyString\(\)\]\s*\[string\]\s*\$Text')).Count -ne 2) {
throw 'Both empty Network evidence writes and empty Network delta counts must remain supported.'
}
$requiredStageTokens = @(
"'search-stocks'", "'select-stock'", "'cut-pointer-down'", "'drop-selected-cuts'",
"'activate-playlist-row'", "'take-in'", "'next-playout'", "'take-out'",
"'open-manual-financial'", "'search-manual-financial'",
"'activate-manual-financial-result'", "'select-comparison-pair'", "'delete-selected-comparison-pair'",
"'playout-take-in'", "'playout-next'", "'playout-take-out'",
'Input.dispatchMouseEvent', 'OUTCOME_UNKNOWN', 'current.state.currentCode === code',
'payload?.allowAppend === true', 'physicalDragStockCut(client, physicalIndex, label)',
"types[0] !== 'cut-pointer-down'", "types[1] !== 'drop-selected-cuts'",
'comparisonPairRows(comparison)', 'deleteTemporaryComparisonPair(client, description, baseline, addedPairId)',
'restoreComparisonSelection(client, selectedPairId)', 'savedPairBaseline',
'resolved: row.isSceneResolved === true', 'row.resolved !== true',
'const FIXED_VISIBLE_ROW_IDENTITIES = new Map([', 'expected.market != null && row.market !== expected.market',
"['5077', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '6\uc885\ubaa9 \ud604\uc7ac\uac00', subtype: '\ucf54\uc2a4\ud53c \uc2dc\uac00\ucd1d\uc561' }]",
"['5088', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '12\uc885\ubaa9 \ud604\uc7ac\uac00', subtype: '\ucf54\uc2a4\ud53c \uc2dc\uac00\ucd1d\uc561' }]",
"second.pageText !== '1/20'", "result.state.rows.some(row => row.pageText !== '1/20')",
"current.state.pageCount === 20", "current.state.itemCount === 120",
"current.state.itemCount === 240", "const STATE_TIMEOUT_MS = 180_000",
"case 'take-out-recovery-8001'", "case 'take-out-recovery-5077-page2'", "case 'resume-30-manifest'"
)
foreach ($token in $requiredStageTokens) {
if (-not $stage.Contains($token)) { throw "One-shot stage contract is missing: $token" }
}
if ($stage.Contains("postAndWait(client, 'cut-pointer-down'") -or
$stage.Contains("postAndWait(client, 'drop-selected-cuts'")) {
throw 'Stock cut setup must use the actual pointer drag path instead of direct native messages.'
}
if (@([regex]::Matches($stage, 'allowMissingState:\s*true')).Count -ne 1 -or
$stage -notmatch "Boolean\(value\.state\),\s*'initial native state',\s*STATE_TIMEOUT_MS,\s*\{\s*allowMissingState:\s*true\s*\}" -or
$stage -notmatch "PROBE_VIOLATION[\s\S]*?const state = result\.state;\s*if \(allowMissingState && !state\) return;[\s\S]*?LIVE_STATE_MISMATCH") {
throw 'Only bootstrap may tolerate the probe-to-native-state initialization gap.'
}
if (@([regex]::Matches($stage, '\bgraphicCode\b')).Count -ne 2) {
throw 'The runner must not infer hidden cut aliases from WebView playlist state.'
}
if (-not $protocol.Contains('row.IsSceneResolved')) {
throw 'Latest serialized playlist state no longer exposes IsSceneResolved.'
}
foreach ($messageType in @(
'search-stocks', 'select-stock', 'cut-pointer-down', 'drop-selected-cuts',
'activate-playlist-row', 'take-in', 'next-playout', 'take-out',
'open-manual-financial', 'search-manual-financial',
'activate-manual-financial-result', 'select-comparison-pair', 'delete-selected-comparison-pair')) {
if (-not $protocol.Contains('"' + $messageType + '"') -and
-not $playoutUi.Contains('"' + $messageType + '"') -and
-not $appUi.Contains('"' + $messageType + '"')) {
throw "Latest source no longer exposes native message '$messageType'."
}
}
foreach ($buttonId in @('playout-take-in', 'playout-next', 'playout-take-out')) {
if (-not $playoutUi.Contains('"' + $buttonId + '"')) {
throw "Latest playout UI no longer exposes '$buttonId'."
}
}
if (-not $appUi.Contains('element.dataset.rowId = row.rowId') -or
-not $appUi.Contains('rowHeader.className = "playlist-row-header"')) {
throw 'Latest playlist row/header DOM contract changed.'
}
$packageBuildRoot = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Debug'
$fixtureRoots = @()
if ([IO.Directory]::Exists($packageBuildRoot)) {
foreach ($manifestFile in @(Get-ChildItem -LiteralPath $packageBuildRoot -Recurse -File `
-Filter 'AppxManifest.xml' -ErrorAction Stop)) {
$candidateRoot = $manifestFile.Directory.FullName
$relative = $candidateRoot.Substring([IO.Path]::GetFullPath($packageBuildRoot).TrimEnd('\').Length).TrimStart('\')
$segments = @($relative.Split('\') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($segments.Count -eq 3 -and
$segments[0] -match '^net[0-9]+\.[0-9]+-windows[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' -and
$segments[1] -ceq 'win-x64' -and $segments[2] -ceq 'AppX' -and
[IO.File]::Exists((Join-Path $candidateRoot 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe')) -and
[IO.File]::Exists((Join-Path $candidateRoot 'MBN_STOCK_WEBVIEW.LegacyParityApp.dll')) -and
[IO.File]::Exists((Join-Path $candidateRoot 'MBN_STOCK_WEBVIEW.Playout.dll'))) {
$fixtureRoots += $candidateRoot
}
}
}
if ($fixtureRoots.Count -lt 1) {
throw 'No exact x64 Debug target-framework/win-x64/AppX runtime-file fixture was found.'
}
$runtimeFixtureRoot = @($fixtureRoots | Sort-Object)[-1]
$runtimeFixtureFiles = @(
'MBN_STOCK_WEBVIEW.LegacyParityApp.exe',
'MBN_STOCK_WEBVIEW.LegacyParityApp.dll',
'MBN_STOCK_WEBVIEW.Playout.dll'
)
$runtimeFixtureHashes = @($runtimeFixtureFiles | ForEach-Object {
$fixturePath = Join-Path $runtimeFixtureRoot $_
[ordered]@{
name = $_
length = [IO.FileInfo]::new($fixturePath).Length
sha256 = (Get-FileHash -LiteralPath $fixturePath -Algorithm SHA256).Hash
}
})
$expectedRoutes = @(
'5016', '50160', '5078', '8067', '5086', '50860', '5023', '5024',
'5077', '5082', '5083', '5084', '5085', '5088', '6067', '8035',
'5011', '8003', '5037', '8001', '5026', '5087', '5029', '8032',
'5032', '5076', '5079', '5080', '5081', '5025'
)
$routeBlock = [regex]::Match(
$stage,
'const THIRTY_CODES = \[(?<body>.*?)\];',
[Text.RegularExpressions.RegexOptions]::Singleline)
if (-not $routeBlock.Success) { throw 'THIRTY_CODES block is missing.' }
$actualRoutes = @([regex]::Matches($routeBlock.Groups['body'].Value, "'(?<code>[0-9]+)'") |
ForEach-Object { $_.Groups['code'].Value })
if (($actualRoutes -join ',') -cne ($expectedRoutes -join ',')) {
throw 'The 30-route validation order changed.'
}
$probeFixedIdBlock = [regex]::Match(
$stage,
'const fixedIds = new Set\(\[(?<body>.*?)\]\);',
[Text.RegularExpressions.RegexOptions]::Singleline)
if (-not $probeFixedIdBlock.Success) { throw 'The probe fixed-action allowlist is missing.' }
foreach ($actionId in @('fixed-072', 'fixed-073', 'fixed-074', 'fixed-075')) {
if (-not $probeFixedIdBlock.Groups['body'].Value.Contains("'$actionId'")) {
throw "The S6001 physical action is not probe-allowlisted: $actionId"
}
}
foreach ($token in @(
'S6001_ASSET_INDEPENDENT_ACTIONS',
"actionId: 'fixed-072'",
"actionId: 'fixed-073'",
"actionId: 'fixed-074'",
"actionId: 'fixed-075'",
"subtype: 'DubaiCrude'",
"subtype: 'WtiCrude'",
"subtype: 'BrentCrude'",
"subtype: 'Gold'",
"physicalSelectCatalogTab(client, 'overseas')",
'#market-tabs button[data-tab-id=',
'current.probe.nativeStateCount > nativeStateBefore',
"current.dom.activeWorkspace === 'catalog'",
'current.dom.catalogVisible === true',
"types[0] !== 'select-tab'",
'physicalDoubleClickAndWait(client, selector',
"current.state.currentCode === '6001'")) {
if (-not $stage.Contains($token)) {
throw "The asset-independent s6001 physical-input contract is missing: $token"
}
}
$staticJson = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $wrapperPath -Action StaticAudit
if ($LASTEXITCODE -ne 0) { throw 'Wrapper StaticAudit failed.' }
$static = $staticJson | ConvertFrom-Json
if ($static.ok -ne $true -or $static.authoritativeCutCount -ne 33 -or
$static.authoritativeAssetCount -ne 2 -or
$static.workflowStageCounts.Core5001 -ne 6 -or
$static.workflowStageCounts.ThirtyRoutes -ne 125 -or
$static.workflowStageCounts.PagedBoundary -ne 24 -or
$static.workflowStageCounts.S6001AssetIndependent -ne 18 -or
$static.workflowStageCounts.Recovery8001 -ne 1 -or
$static.workflowStageCounts.Recovery5077Page2 -ne 1 -or
$static.workflowStageCounts.ThirtyRoutes20To29 -ne 41 -or
$static.runtimeProcessesQueried -ne $false -or $static.cdpAttached -ne $false -or
$static.nativeMessagesSent -ne 0) {
throw 'Wrapper StaticAudit result is not exact.'
}
if (@($static.authoritativeCutHashes).Count -ne 33 -or
@($static.authoritativeCutHashes | Where-Object {
[string]::IsNullOrWhiteSpace([string]$_.path) -or
[long]$_.length -le 0 -or
[string]$_.sha256 -notmatch '^[0-9A-F]{64}$'
}).Count -ne 0) {
throw 'Wrapper StaticAudit cut identities contain a null or invalid value.'
}
if (@($static.authoritativeAssetHashes).Count -ne 2 -or
@($static.authoritativeAssetHashes | Where-Object {
[string]::IsNullOrWhiteSpace([string]$_.path) -or
[long]$_.length -le 0 -or
[string]$_.sha256 -notmatch '^[0-9A-F]{64}$'
}).Count -ne 0) {
throw 'Wrapper StaticAudit asset identities contain a null or invalid value.'
}
[ordered]@{
ok = $true
checkedAtUtc = [DateTime]::UtcNow.ToString('o')
powershellSyntax = 'passed'
nodeSyntax = 'passed'
latestBridgeAndDomContract = 'passed'
hardcodedRuntimeProcessIds = 0
hardcodedPackageFullNames = 0
namedPlaylistDependencies = 0
developmentDebugRuntimeFixture = $runtimeFixtureRoot
developmentDebugRuntimeFiles = $runtimeFixtureHashes
routeCount = $actualRoutes.Count
workflowStageCounts = $static.workflowStageCounts
runtimeProcessesQueried = $false
cdpAttached = $false
nativeMessagesSent = 0
} | ConvertTo-Json -Depth 8 -Compress